Compare commits

...

104 Commits

Author SHA1 Message Date
Ryan Schultz f229ad5fd0 closes #3804: Add S_K split support for profile-based elements
Extends the existing Split hotkey (⇧ K) to work on elements with
IfcMaterialProfileSet usage (beams, columns, members) in addition to
layered walls. Adds DumbProfileJoiner.split(), a new SplitProfile
operator (bim.split_profile), and a Split button in the workspace
panel for PROFILE elements.

Generated with the assistance of an AI coding tool.
2026-03-29 09:41:09 -05:00
dependabot[bot] 0ed96d32dd Bump picomatch from 4.0.2 to 4.0.4 in /src/ifctester/webapp
Bumps [picomatch](https://github.com/micromatch/picomatch) from 4.0.2 to 4.0.4.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.2...4.0.4)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 16:53:31 +11:00
dependabot[bot] f96526195d Bump actions/deploy-pages from 4 to 5
Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5.
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/deploy-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 16:53:12 +11:00
dependabot[bot] fd29481d65 Bump hendrikmuhs/ccache-action from 1.2.21 to 1.2.22
Bumps [hendrikmuhs/ccache-action](https://github.com/hendrikmuhs/ccache-action) from 1.2.21 to 1.2.22.
- [Release notes](https://github.com/hendrikmuhs/ccache-action/releases)
- [Commits](https://github.com/hendrikmuhs/ccache-action/compare/v1.2.21...v1.2.22)

---
updated-dependencies:
- dependency-name: hendrikmuhs/ccache-action
  dependency-version: 1.2.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 16:53:03 +11:00
dependabot[bot] 24b48497f0 Bump actions/configure-pages from 5 to 6
Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 5 to 6.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/configure-pages
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 16:52:57 +11:00
dependabot[bot] 2b9822f141 Bump mamba-org/setup-micromamba from 2 to 3
Bumps [mamba-org/setup-micromamba](https://github.com/mamba-org/setup-micromamba) from 2 to 3.
- [Release notes](https://github.com/mamba-org/setup-micromamba/releases)
- [Commits](https://github.com/mamba-org/setup-micromamba/compare/v2...v3)

---
updated-dependencies:
- dependency-name: mamba-org/setup-micromamba
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 16:52:52 +11:00
dependabot[bot] 3d4db13fc1 Bump ruff from 0.15.7 to 0.15.8
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.7 to 0.15.8.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.7...0.15.8)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 16:52:46 +11:00
Bruno Perdigão 31b571322b Snap: improve handling with objects that are partially behind the camera. 2026-03-27 15:05:02 -03:00
Bruno Perdigão cef5d41b54 Snap - Improves logic from previous commit.
Previous commit: Snap - Refactor x-ray mode handling
to prevent double raycasting
2026-03-27 15:05:02 -03:00
Bruno Perdigão d7b2358d58 Snap - Refactor x-ray mode handling to prevent double raycasting 2026-03-27 15:05:02 -03:00
Bruno Perdigão cafe5aa7f7 Rename variable - small refactor 2026-03-27 15:05:01 -03:00
Bruno Perdigão de34e73451 Remove unnecessary comments. 2026-03-27 15:05:01 -03:00
Bruno Perdigão 5721a8b602 Snap: improve performance of wireframe objects intersection.
Enhances the performance of mouse intersection checks for wireframe objects.
Details:
- Calculated the intersection with the mouse in 2D pixels first.
- Converted objects to a BVH Tree to reduce the number of edges checked against the mouse position.
2026-03-27 15:04:48 -03:00
Bruno Postle f820214500 ifcmcp: fail early with clear message when mcp package is not installed
mcp is an optional dependency so that the embedded API (embedded.py) can
be used from Pyodide without pulling in pydantic-core and the rest of the
MCP protocol stack, which may not be available in all WASM environments.
2026-03-27 08:44:52 +00:00
Bruno Postle dae913e06a ifcmcp: sse,streamable-http transports and --help 2026-03-26 07:02:50 +00:00
Dion Moult 1a849395c2 Typo crashing edit tools panel when non-wall with wall selected
Fix #7034

bpy.ops.bim.extend_to_underside doesn't exist - the correct operator
name is bim.extend_walls_to_underside. The AttributeError killed the
entire panel draw, hiding mirror, align, aggregation, and QTO buttons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 13:38:05 +11:00
Dion Moult 611273a20a Fix add_georeferencing silently failing with orphan CRS or conversion
If a file had an IfcProjectedCRS without an IfcCoordinateOperation (or
vice versa), add_georeferencing would return early without creating the
missing entity. This caused edit_georeferencing to crash with IndexError.
Now detects the inconsistent state, cleans up, and recreates both.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 15:00:32 +11:00
Bruno Postle db68195310 Add ifcmcp: MCP server for IFC model querying and editing (#7847)
ifcmcp is a new Model Context Protocol server that wraps ifcquery and ifcedit, holding an IFC model in memory across tool calls. It is the preferred way to interact with IFC models from AI assistants and MCP-compatible clients.

Setup:

claude mcp add --transport stdio ifc -- python3 -m ifcmcp

Session tools: ifc_load, ifc_save

Query tools: ifc_summary, ifc_tree, ifc_info, ifc_select, ifc_relations, ifc_clash, ifc_validate, ifc_schedule, ifc_cost, ifc_schema, ifc_contexts, ifc_materials, ifc_plot, ifc_render, ifc_shape, ifc_shape_list, ifc_shape_docs

Edit discovery: ifc_list, ifc_docs

Edit execution: ifc_edit, ifc_quantify

The model stays in memory between calls - ifc_edit does not auto-save; call ifc_save explicitly when done.

Depends on both ifcquery and ifcedit

Generated with the assistance of an AI coding tool.
2026-03-23 23:54:17 +00:00
Bruno Postle 29079e8cba ifcquery README: add contexts, materials, plot, render subcommands (#7848) 2026-03-23 23:51:33 +00:00
Bruno Postle 6bf4259298 Add ifcedit: CLI wrapper for ifcopenshell.api mutation functions (#7846)
ifcedit is a new command-line tool for executing ifcopenshell.api mutations from the shell. It wraps the entire API surface — any function callable via ifcopenshell.api can be invoked without writing Python.

Subcommands:

    list [module] — list all API modules, or functions within a module
    docs <module.function> — full documentation (params, types, descriptions)
    run <file> <module.function> [--param value ...] — execute a mutation; overwrites input file by default, or use -o <output> to write elsewhere; --dry-run validates without executing
    quantify list — list available QTO rules
    quantify run <file> <rule> — run quantity take-off, writing IfcElementQuantity psets back to the file

Parameter coercion: entity references can be passed as step IDs (strings); lists, dicts, booleans, and None are handled automatically.

Usage:

python3 -m ifcedit run model.ifc root.remove_product --product 42
python3 -m ifcedit docs geometry.edit_object_placement

Generated with the assistance of an AI coding tool.
2026-03-23 23:45:42 +00:00
Bruno Postle 7cd40bf8cb Add ifcquery CLI tool for IFC model interrogation (#7845)
ifcquery is a new command-line tool for querying and inspecting IFC models. All output is JSON.

Subcommands:

    summary — schema version, entity counts, project metadata
    tree — full spatial hierarchy (Project → Site → Building → Storeys → Spaces → Elements)
    info <id> — deep inspection of any entity by step ID (attributes, psets, placement matrix, type, material)
    select <query> — filter elements using ifcopenshell selector syntax
    relations <id> — relationships for an element; --traverse up walks to IfcProject
    clash <id> — geometric intersection and clearance detection
    validate — schema/constraint validation; --rules adds EXPRESS checks
    schedule — work schedules with nested task trees
    cost — cost schedules with nested cost item trees
    schema <class> — IFC class documentation from the model's schema version
    plot — SVG plan drawing
    render — 3D geometry rendering
    contexts — geometric representation contexts
    materials — material assignments

Usage:

python3 -m ifcquery <file.ifc> <subcommand> [args]

Generated with the assistance of an AI coding tool.
2026-03-23 23:29:32 +00:00
Bruno Postle 8b8f78095d geometry_creation.rst: add sections for assemblies, clipping normals, openings (#7844)
Generated with the assistance of an AI coding tool.
2026-03-23 23:02:15 +00:00
Bruno Postle 23ba9e4db0 Add geometry.clip_solid, clip_solid_bounded, and copy_representation APIs (#7843)
* Add geometry.clip_solid API
* Add geometry.clip_solid_bounded API
* Add geometry.copy_representation API
Deep-copies the named representation from a source element to a target
element.

Generated with the assistance of an AI coding tool.
2026-03-23 23:00:12 +00:00
Bruno Postle 1aec991f08 api: docstring improvements across geometry, sequence, and feature modules (#7842)
* Doc clarification for api.sequence.assign_process
* Doc clarification for api.geometry.edit_object_placement
* Doc clarification for api.feature.remove_feature
* Doc clarification for api.geometry.add_wall_representation clippings normal
* regenerate_wall_representation: document BBIM_Boolean preservation requirement

Generated with the assistance of an AI coding tool.
2026-03-23 22:57:28 +00:00
Bruno Postle bddf9b85f8 shape_builder: complete docstrings and return type annotations (#7841)
* shape_builder: complete docstrings and return type annotations
* shape_builder: warn about mixed item types in get_representation
* shape_builder: fix half_space_solid agreement_flag docstring

Generated with the assistance of an AI coding tool.
2026-03-23 22:54:55 +00:00
Sayan J. Das f679c63a18 Merge pull request #7808 from theseyan/ifctester-improvements-rebased
IfcTester webapp improvements
2026-03-23 15:48:34 +05:30
Thomas Krijnen e6cc0e7813 Initialize ncount_total #7834 2026-03-23 10:54:38 +01:00
Dion Moult cf2acfc649 Add covering feature tests for ceiling and cursor variants
Add tests for all four covering generation operators: flooring/ceiling
from walls and flooring/ceiling from cursor. Previously only flooring
from walls was tested.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 23:25:04 +11:00
Dion Moult 60ebb99fda Fix covering geometry not persisting to IFC, same root cause as #7055
The covering tool used bmesh as an intermediate and relied on
type.assign_type post-listeners (removed in 44a52863a) to generate
the IfcExtrudedAreaSolid body. With those listeners gone, coverings
had no body representation and assign_swept_area_outer_curve crashed.

Build covering representations from scratch using ShapeBuilder, reading
the extrusion depth from the type's IfcMaterialLayerSet. Also replace
bpy.ops.bim.assign_class with bonsai.core.root.assign_class using
should_add_representation=False, consistent with the space fix.

Refactored shared coordinate-conversion and extrusion-building logic
into get_2d_vertices_from_polygon and set_extrusion_representation_from_polygon,
used by both space and covering code paths. Removed all bmesh-dependent
dead code from the spatial tool.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 23:18:59 +11:00
Dion Moult ab96add772 Gitignore all test cache files 2026-03-22 22:26:23 +11:00
Dion Moult d8de623086 Fix space regen not saving geometry to IFC (#7055)
Space regeneration was only updating the Blender mesh and marking the
object as edited, but the IFC representation was never synced on save.
Replace the bmesh-based approach with ShapeBuilder to write geometry
directly to IFC as an IfcExtrudedAreaSolid, then reload via
switch_representation. This applies to both new space creation and
existing space regeneration.

Also changes assign_ifcspace_class_to_obj to call
bonsai.core.root.assign_class directly with
should_add_representation=False instead of bpy.ops.bim.assign_class.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 22:14:02 +11:00
dependabot[bot] a3f2e061fb Bump hendrikmuhs/ccache-action from 1.2.20 to 1.2.21
Bumps [hendrikmuhs/ccache-action](https://github.com/hendrikmuhs/ccache-action) from 1.2.20 to 1.2.21.
- [Release notes](https://github.com/hendrikmuhs/ccache-action/releases)
- [Commits](https://github.com/hendrikmuhs/ccache-action/compare/v1.2.20...v1.2.21)

---
updated-dependencies:
- dependency-name: hendrikmuhs/ccache-action
  dependency-version: 1.2.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-22 21:31:54 +11:00
dependabot[bot] f51a4673db Bump ruff from 0.15.6 to 0.15.7
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.6 to 0.15.7.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.6...0.15.7)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-22 21:31:42 +11:00
Dion Moult 75b8d4f218 Remove spatial containment and aggregation when nesting
The nest assign_object API now removes existing spatial containment and
aggregate relationships before creating the nest, matching the behavior
documented in its docstring and consistent with aggregate.assign_object.

Fix #7248

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 15:28:47 +11:00
Dion Moult b8136d4762 Prevent cyclic references when assigning nesting or aggregation
Walk up the full hierarchy via get_parent() in can_nest() and
can_aggregate() to reject assignments that would create a cycle.
Also reject self-assignment.

Fix #7248

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 15:28:47 +11:00
Dion Moult ecde429d36 Fix crash after undo of assign_class on macOS (#7419)
After assigning an IFC class and undoing, msgbus subscriptions registered
with the old Python object wrapper survived (PERSISTENT flag) but could
not be cleared because: (1) rollback_link_element looked up objects by
their post-link name which no longer exists after undo, and (2) the
per-object clear_by_owner calls in rebuild_element_maps used new Python
wrappers that didn't match the old subscription owners.

Fix by using a dedicated stable object (object_subscription_owner) as
the msgbus owner for all per-object subscriptions, allowing
rebuild_element_maps to clear all stale subscriptions in one call
regardless of Python wrapper identity changes during undo/redo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 14:42:55 +11:00
Dion Moult 1771b34449 Fix error when entering edit mode on camera objects
Fixes #7313.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 14:08:12 +11:00
Dion Moult 41469acbc8 Fix walrus operator precedence in MaterialCreator
The `is not ...` was being captured by the walrus assignment due to
missing parentheses, causing the condition to always evaluate incorrectly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 13:58:17 +11:00
Ryan Schultz 547b22199f Without 'Material.Name' layers merge. (#7700) 2026-03-21 18:02:02 -05:00
Dion Moult 94c15213f6 Guard against emptying IfcShapeRepresentation Items
remove_representation_item now returns early if removing the item would
leave Items empty. edit_text_literals returns early on empty attributes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 20:10:11 +11:00
Dion Moult fca258fb07 Fix add_boolean removing second operands from unrelated representations
add_boolean was removing second operands from ALL IfcShapeRepresentations
that referenced them, which could corrupt unrelated shapes and leave
representations with empty Items (bug #7803).

The API no longer modifies Items — callers manage this explicitly.
validate_type and Bonsai's AddBoolean operator now handle their own
item removal scoped to the correct representation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 20:10:11 +11:00
Dion Moult bcfad8d96d Migrate remove_deep to remove_deep2 across API modules
remove_deep is deprecated and can silently delete elements still in use.
remove_deep2 requires zero inverses before removal, making it safer.
Also fixes a double-removal bug in remove_grid_axis and prevents
removing the last prop template from a pset template.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 20:10:11 +11:00
Parag Debnath c026dd3b6e IsVentilated now defaults to False (#7819)
* IsVantillated now defaults to false

* IsVentilated now defaults to False

---------

Co-authored-by: Parag Debnath <paragforwork@gmail.com>
2026-03-20 23:33:40 +11:00
Dion Moult d0f20371bd Add feature to get parent of a particular IFC class 2026-03-20 23:10:00 +11:00
Dion Moult 7b6e82a9cc Fix stair calculated params test to set custom_tread_lock=False
Tests using custom first/last tread runs were not setting
custom_tread_lock=False, so the custom values were silently ignored
since 8f7cf76d9 introduced the lock gate in the calculation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 23:09:15 +11:00
Andrej730 286c69429d gitignore bonsai external_dependencies 2026-03-20 15:49:08 +05:00
dependabot[bot] 9c22dc6013 Bump socket.io-parser from 4.2.4 to 4.2.6 in /src/ifctester/webapp
Bumps [socket.io-parser](https://github.com/socketio/socket.io) from 4.2.4 to 4.2.6.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/socket.io-parser@4.2.4...socket.io-parser@4.2.6)

---
updated-dependencies:
- dependency-name: socket.io-parser
  dependency-version: 4.2.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-20 11:47:01 +01:00
Andrej730 0acbd5ffad black . 2026-03-20 15:45:24 +05:00
Andrej730 77f0c43314 ids_doc_generator - fix invalid escape sequence SyntaxWarning
SyntaxWarning: invalid escape sequence '\/' at line 312.
`\/` in a plain string is treated as `/` by accident; replaced with raw string r"..." to be explicit.
2026-03-20 15:43:13 +05:00
Andrej730 c1a9708504 ci.yml - build ifctester docs
to ensure script doesn't break
2026-03-20 15:43:13 +05:00
Andrej730 bcf6f6197d ifctester - move build-ids-docs target to ifctester Makefile
Also added a note why it lives in test folder and added it's output to gitignore.
2026-03-20 15:43:13 +05:00
Andrej730 1778656bd5 ids_doc_generator - fix Property args missed (ed8eb75)
TypeError: Property.__init__() got an unexpected keyword argument 'name'
2026-03-20 15:43:12 +05:00
Andrej730 54f450129c ids_doc_generator - fix failed_entities removed (bd92c043)
AttributeError: 'Attribute' object has no attribute 'failed_entities'
2026-03-20 15:43:12 +05:00
Andrej730 9fad1569c7 ids_doc_generator - fix error due to stale cache (f40281e97)
AssertionError: bool(facet(inst)) is expected
2026-03-20 15:43:12 +05:00
Andrej730 722c374fa6 ids_doc_generator - handle invalid entities coming from a test (1ed770d)
Exception: About to emit invalid example data: IfcMaterial.Name not optional
2026-03-20 15:43:12 +05:00
Andrej730 91b6c3e256 bcf v3 tests - fix wrong args, add dead code TODOs 2026-03-20 15:43:12 +05:00
Andrej730 ea3f71b4e0 rename test files to test_* prefix for pytest discovery and fix missing add_pset name arg 2026-03-20 15:43:12 +05:00
Andrej730 1a8b17e235 ifcfm cobie24 - remove unused ifc_file param from get_unit_name 2026-03-20 15:43:12 +05:00
Andrej730 2bad861122 ifcopenshell_wrapper.pyi - support varargs and kwargs in constructors 2026-03-20 15:43:11 +05:00
Andrej730 6c1fb3b01a Remove stale mass_time_units_in_wizard references (5c31ae4c3) 2026-03-20 15:36:18 +05:00
Andrej730 f5be64af6c Remove redundant __init__ from BaseLinesShader 2026-03-20 15:36:18 +05:00
Andrej730 b043dd4d04 Fix unknown-argument error in BaseLinesShader.__init__ 2026-03-20 15:36:18 +05:00
Andrej730 ffd2466321 Fix missing prop name in bim.mep_add_bend 2026-03-20 15:36:17 +05:00
Andrej730 e9241fd812 Fix error in bim.fit_flow_segments 2026-03-20 15:36:17 +05:00
Andrej730 48d45451ac Remove dead code join_walls_TZ, join_T, join_Z superseded in acdc40fb4 2026-03-20 15:36:17 +05:00
Andrej730 3b7cf6e865 Fix error displaying bsdd description after API update (ed81a0a4b) 2026-03-20 15:36:17 +05:00
Andrej730 6038373ee5 ifcopenshell_wrapper.pyi - sync default values, validate_stub - suggest default values 2026-03-20 15:36:16 +05:00
Andrej730 3d7de87b46 ifcopenshell_wrapper.pyi - add temp MakeVolume stub 2026-03-20 15:36:16 +05:00
Andrej730 cb113ae8da ifcopenshell_wrapper.pyi - support stubs for constructors 2026-03-20 15:36:15 +05:00
Andrej730 26280d24fe Add ty to check for missing symbols and other simple errors 2026-03-20 15:36:14 +05:00
Andrej730 30551cb288 typing 2026-03-20 15:36:14 +05:00
Andrej730 3bf0edeca2 Fix subtle walrus operator bug in align_walls using e before assignment 2026-03-20 15:34:57 +05:00
Andrej730 3590b08e68 search/operator - remove unnecessary Ifc Operators 2026-03-20 15:34:57 +05:00
Ryan Schultz fd902d88fb Update selector_syntax.rst with query examples
Clarified usage of queries in IfcAnnotation tags with examples.
2026-03-18 18:25:03 -05:00
Sayan Jyoti Das aa5f5120e0 delete ifcopenshell wheel 2026-03-18 14:37:24 +05:30
Sayan Jyoti Das 81986bcbfb ifcopenshell wasm wheel should be dynamically fetched, not included in git 2026-03-18 14:35:55 +05:30
Andrej730 ec6c268cdb Fix type assign_type core test (44a52863a) 2026-03-18 13:15:39 +05:00
Andrej730 64003fd5ef Fix drawing update_drawing_name core test (19534e225) 2026-03-18 13:15:38 +05:00
Andrej730 58d07bace4 Fix drawing edit_text core test and tool interface (5e9f97a0c) 2026-03-18 13:15:38 +05:00
Andrej730 3b718bc58d Fix georeference core tests (b246998f6) 2026-03-18 13:15:38 +05:00
tsomanna_QCOM 18c035ea77 Fix Windows ARM64 Python Bindings Issue 2026-03-18 08:44:34 +01:00
Andrej730 f2e2e324b1 Fixing stubs
- `function_item`, `tags` added in df7318973
- MakeVolume added in c385b93, ignore as all other conversion settings
- moved `SeparateZUpNode` ignore to the other geom serializer settings
2026-03-18 12:25:14 +05:00
Andrej730 069dbbd8c2 bonsai docs - add maintenance page 2026-03-18 12:25:14 +05:00
Andrej730 3385872e8b ci-black-formatting - use variables for min Python versions 2026-03-18 12:25:14 +05:00
Andrej730 f0b27a0910 ifcopenshell-python Makefile - simplify pyversion check, similar to 6409f41 2026-03-18 12:25:13 +05:00
Andrej730 888158570a Remove Python 3.9 references 2026-03-18 11:20:22 +05:00
Andrej730 c03156b5cd control.assign_control - remove deprecated related_object argument support 2026-03-18 11:20:22 +05:00
Andrej730 05bf2b82d2 system.disconnect_port - fix missing flow direction reset (bbda8d2) 2026-03-18 11:03:33 +05:00
Andrej730 7bdc1b6a75 cache_dependencies - skip ifcopenshell dir when packing 2026-03-18 11:01:38 +05:00
Sayan Jyoti Das 03de69814a fixes and cleanups from old branch 2026-03-18 11:22:41 +05:30
Sayan Jyoti Das 4dc6a0f2bc update ifcopenshell wheel to ifcopenshell-0.8.5+a51b2c5 2026-03-18 11:18:50 +05:30
Andrej730 c33509364c Fix error generating ifcpatch recipes docs for Bonsai tooltips
Mentioned in https://github.com/IfcOpenShell/IfcOpenShell/issues/7667#issuecomment-4076645173

Traceback:
```
Traceback (most recent call last):
  File "\bonsai\bim\module\patch\prop.py", line 55, in get_ifcpatch_recipes
    docs = ifcpatch.extract_docs(f, "Patcher", "__init__", ("src", "file", "logger", "args"))
  File "\ifcpatch\__init__.py", line 168, in extract_docs
    spec.loader.exec_module(submodule)
    ~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^
  File "<frozen importlib._bootstrap_external>", line 1027, in exec_module
  File "<frozen importlib._bootstrap>", line 488, in _call_with_frames_removed
  File "\ifcpatch/recipes/FixRevit2025TINs.py", line 31, in <module>
    class Patcher:
    ...<509 lines>...
            return co / self.unit_scale
  File "\ifcpatch/recipes/FixRevit2025TINs.py", line 168, in Patcher
    def create_edges(self, obj: bpy.types.Object) -> None:
                                ^^^
NameError: name 'bpy' is not defined
File "\bonsai\bim\module\patch\prop.py", line 43, in get_ifcpatch_recipes
```
2026-03-18 10:04:36 +05:00
Sayan Jyoti Das 47f058341b local ifctester wheel build 2026-03-18 10:29:09 +05:30
Thomas Krijnen a51b2c587c Revert "Simplifies IfxAxis2PlacementLinear, assumes default Axis = (0,0,1)"
This reverts commit cf1552e79e.
2026-03-17 20:49:48 +01:00
Sayan Jyoti Das 845a13ba83 some fixes and lint cleanups 2026-03-17 21:16:53 +05:30
Sayan Jyoti Das 530841967e Merge branch 'v0.8.0' into ifctester-improvements 2026-03-17 19:49:09 +05:30
Andrej730 c36e7badae Remove use of deprecated os.popen 2026-03-17 18:14:22 +05:00
Andrej730 515fe8d2ef Remove use of deprecated tempfile.mktemp 2026-03-17 18:14:22 +05:00
Andrej730 b8d3d1d105 pyproject.toml - add ty command to check for deprecated methods 2026-03-17 18:14:22 +05:00
Sayan Jyoti Das e95da857d6 convert codebase to typescript + introduce biome lint 2026-03-16 21:55:10 +05:30
Sayan Jyoti Das d587d1ac11 build step for pyodide 2026-03-16 21:46:46 +05:30
Sayan Jyoti Das 6117417b89 update webapp + bonsai integration 2026-03-16 15:54:52 +05:30
Thomas Krijnen 0398584c69 empty 2026-03-16 15:45:41 +05:30
Thomas Krijnen 0c69f85d5e Empty 2026-03-16 15:45:40 +05:30
311 changed files with 13999 additions and 1671 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ jobs:
python ../nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.20
uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: mac-${{ matrix.arch }}
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
python ../IfcOpenShell/nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.20
uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: ubuntu-22.04-${{ runner.arch }}
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
python3 ../nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.20
uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
python3 ../nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.20
uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
}
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.20
uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: win-${{ matrix.arch }}
# Windows ccache needs ~1GB
+7 -4
View File
@@ -7,6 +7,9 @@ on:
jobs:
lint-formatting:
runs-on: ubuntu-latest
env:
MIN_IOS_PY_VERSION: "3.10"
MIN_BLENDER_PY_VERSION: "3.11"
steps:
- name: Action - checkout repository
uses: actions/checkout@v6
@@ -14,12 +17,12 @@ jobs:
- name: Action - install python
uses: actions/setup-python@v6
with:
python-version: "3.10"
python-version: ${{ env.MIN_IOS_PY_VERSION }}
- name: Action - install python
uses: actions/setup-python@v6
with:
python-version: "3.11"
python-version: ${{ env.MIN_BLENDER_PY_VERSION }}
- name: Install dependencies
run: |
@@ -35,8 +38,8 @@ jobs:
ERROR=0
# Using 2 Python versions - one minimum required for IfcOpenShell
# and other that's used by Blender currently.
python3.10 -W error -m compileall -q src/ifcopenshell-python || ERROR=1
python3.11 -W error -m compileall -q src/bonsai || ERROR=1
python${{ env.MIN_IOS_PY_VERSION }} -W error -m compileall -q src/ifcopenshell-python || ERROR=1
python${{ env.MIN_BLENDER_PY_VERSION }} -W error -m compileall -q src/bonsai || ERROR=1
exit $ERROR
continue-on-error: true
@@ -24,7 +24,7 @@ jobs:
if: |
github.repository == 'IfcOpenShell/IfcOpenShell'
steps:
- uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba
- uses: mamba-org/setup-micromamba@v3 # https://github.com/mamba-org/setup-micromamba
with:
environment-name: test-env
create-args: >-
@@ -84,7 +84,7 @@ jobs:
run: |
curl -L https://github.com/phracker/MacOSX-SDKs/releases/download/11.3/MacOSX10.13.sdk.tar.xz | tar -xvJf - -C /Users/runner/work/
- uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba
- uses: mamba-org/setup-micromamba@v3 # https://github.com/mamba-org/setup-micromamba
with:
environment-name: test-env
create-args: >-
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
-
name: ccache
uses: hendrikmuhs/ccache-action@v1.2.20
uses: hendrikmuhs/ccache-action@v1.2.22
-
name: Build ifcopenshell
@@ -24,7 +24,7 @@ jobs:
strategy:
fail-fast: false
matrix:
pyver: [py39, py310, py311, py312, py313, py314]
pyver: [py310, py311, py312, py313, py314]
config:
- {
name: "Windows 64bit",
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
strategy:
fail-fast: false
matrix:
pyver: [py39, py310, py311, py312, py313, py314]
pyver: [py310, py311, py312, py313, py314]
config:
- {
name: "Windows 64bit",
+2 -1
View File
@@ -79,7 +79,7 @@ jobs:
libhdf5-dev libcgal-dev libeigen3-dev
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.20
uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: ubuntu-22.04-${{ runner.arch }}
@@ -254,6 +254,7 @@ jobs:
cd ../ifcpatch && make test || ERROR=1
pip install -e ../ifctester --no-deps
cd ../ifctester && make test || ERROR=1
make build-ids-docs || ERROR=1
# Run mathutils related tests at the end to ensure no other code is relying on mathutils.
cd ../ifcopenshell-python
pip install mathutils
@@ -31,7 +31,7 @@ jobs:
submodules: recursive
fetch-depth: 0
- name: Setup Pages
uses: actions/configure-pages@v5
uses: actions/configure-pages@v6
- name: Upload static files as artifact
id: deployment
uses: actions/upload-pages-artifact@v4
@@ -47,4 +47,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
+10 -3
View File
@@ -5,6 +5,8 @@
/_installed-vs*-x*/
/build/
/src/examples/build/
# ifctester docs output
/src/ifctester/test/build/
# output directories
/cmake/out/
@@ -12,6 +14,7 @@
/src/ifcmax/out/
/src/ifcwrap/out/
/src/qtviewer/out/
/src/ifctester/webapp/public/pyodide/
/win/BuildDepsCache*.txt
@@ -80,10 +83,14 @@ src/ifcopenshell-python/test/build
# bonsai i18n
src/bonsai/bonsai/translations.py
# bonsai test temp files
# bonsai external dependencies (cloned for just ty checks)
src/bonsai/external_dependencies/
# bonsai test temp/cache files
src/bonsai/test/files/temp
src/bonsai/test/files/basic.ifc.cache.blend
src/bonsai/test/files/basic.ifc.cache.sqlite
src/bonsai/test/files/*.cache.blend
src/bonsai/test/files/*.cache.json
src/bonsai/test/files/*.cache.sqlite
# bonsai data
src/bonsai/bonsai/bim/data/build/
+19 -10
View File
@@ -13,6 +13,7 @@ import hashlib
import os
import pathlib
import re
import subprocess
from typing import NoReturn
from urllib import request
@@ -20,7 +21,7 @@ from github import Github
def get_repo_tag_names() -> list[str]:
git_return = os.popen("git tag -l").read()
git_return = subprocess.check_output("git tag -l", text=True)
tag_names = [tag_name for tag_name in git_return.split("\n") if tag_name]
print(f"{len(tag_names)} tag_names found in repo")
return tag_names
@@ -78,6 +79,10 @@ def get_release_zip(tag: str) -> tuple[str, str]:
raise Exception(f"Couldn't find the release matching '{python_version}' and '{TARGET_OS}' in tag '{tag}'.")
def run(command: str) -> None:
subprocess.check_output(command)
start = datetime.datetime.now()
URL_CHOCO_PACKAGE = "https://community.chocolatey.org/packages/blender"
@@ -97,7 +102,7 @@ should_release = False
target_release_tag = ""
TARGET_OS = "windows-x64"
git_status = os.popen("git status").read()
git_status = subprocess.check_output("git status", text=True)
print(git_status)
for tag_name in get_repo_tag_names():
@@ -147,7 +152,7 @@ blenderbim_build_version = target_release_tag.replace("blenderbim-", "")
# url_blenderbim_py3x_win_zip
release_zip_file_name, url_blenderbim_py3x_win_zip = get_release_zip(target_release_tag)
os.popen(f"wget {url_blenderbim_py3x_win_zip} --no-verbose").read()
subprocess.check_call(f"wget {url_blenderbim_py3x_win_zip} --no-verbose")
# sha256sum_blenderbim_py310_win_zip
sha256sum_blenderbim_py3x_win_zip = get_file_sha256_hash(release_zip_file_name)
@@ -201,13 +206,13 @@ print("[INFO] inserting dynamic chocolatey package parameters successful")
print("\n_____ build choco.exe with mono")
choco_version = "1.1.0"
os.popen(f"wget https://github.com/chocolatey/choco/archive/refs/tags/{choco_version}.tar.gz --quiet").read()
os.popen(f"tar -xzf {choco_version}.tar.gz").read()
run(f"wget https://github.com/chocolatey/choco/archive/refs/tags/{choco_version}.tar.gz --quiet")
run(f"tar -xzf {choco_version}.tar.gz")
print("choco tar unpack successful")
os.chdir("choco-1.1.0")
os.popen("./build.sh").read()
run("./build.sh")
os.popen("cp -r build_output/chocolatey /opt/chocolatey").read()
run("cp -r build_output/chocolatey /opt/chocolatey")
os.chdir(BLENDERBIM_DIR)
if pathlib.Path("/opt/chocolatey/choco.exe").exists():
@@ -215,11 +220,15 @@ if pathlib.Path("/opt/chocolatey/choco.exe").exists():
print("\n_____ build choco pack")
os.popen("mono /opt/chocolatey/choco.exe pack --allow-unofficial").read()
os.popen('mono /opt/chocolatey/choco.exe setapikey --key="{choco_token}" --source="https://push.chocolatey.org/" --allow-unofficial').read()
run("mono /opt/chocolatey/choco.exe pack --allow-unofficial")
run(
'mono /opt/chocolatey/choco.exe setapikey --key="{choco_token}" --source="https://push.chocolatey.org/" --allow-unofficial'
)
print("\n_____ build choco push")
os.popen('mono /opt/chocolatey/choco.exe push --source="https://push.chocolatey.org/" --key="$CHOCO_TOKEN" --allow-unofficial --verbose').read()
run(
'mono /opt/chocolatey/choco.exe push --source="https://push.chocolatey.org/" --key="$CHOCO_TOKEN" --allow-unofficial --verbose'
)
print(f"choco push of version: {target_release_tag} successful!")
print(f"it took: {datetime.datetime.now() - start}")
+3
View File
@@ -41,6 +41,9 @@ def pack_dependencies(install_dir: Path) -> None:
if not dependency_path.is_dir():
continue
dependency_name = dependency_path.name
# Skip ifcopenshell - it's a build output, not a dependency to reuse across builds.
if dependency_name == "ifcopenshell":
continue
tar_path = install_dir / f"{CACHE_PREFIX}{dependency_name}.tar.gz"
if tar_path.exists():
print(f"Skipping existing cache: '{tar_path}'")
+175 -1
View File
@@ -3,7 +3,7 @@ name = "IfcOpenShell"
version = "0.0.0"
dependencies = [
"black==26.3.1",
"ruff==0.15.6",
"ruff==0.15.8",
"poethepoet",
"gersemi==0.26.1",
]
@@ -78,6 +78,139 @@ ignore = [
"UP032", # Replace .format with f-string
]
[tool.ty.rules]
all = "ignore"
# Structural rules (no deep type inference needed, easier to adapt).
abstract-method-in-final-class = "error"
ambiguous-protocol-member = "error"
byte-string-type-annotation = "error"
conflicting-declarations = "error"
conflicting-metaclass = "error"
cyclic-class-definition = "error"
cyclic-type-alias-definition = "error"
dataclass-field-order = "error"
duplicate-base = "error"
duplicate-kw-only = "error"
empty-body = "error"
escape-character-in-forward-annotation = "error"
final-on-non-method = "error"
final-without-value = "error"
fstring-type-annotation = "error"
ignore-comment-unknown-rule = "error"
implicit-concatenated-string-type-annotation = "error"
inconsistent-mro = "error"
ineffective-final = "error"
instance-layout-conflict = "error"
invalid-dataclass = "error"
invalid-dataclass-override = "error"
invalid-enum-member-annotation = "error"
invalid-explicit-override = "error"
invalid-frozen-dataclass-subclass = "error"
invalid-generic-class = "error"
invalid-generic-enum = "error"
invalid-ignore-comment = "error"
invalid-legacy-positional-parameter = "error"
invalid-legacy-type-variable = "error"
invalid-named-tuple = "error"
invalid-newtype = "error"
invalid-overload = "error"
invalid-paramspec = "error"
invalid-protocol = "error"
invalid-syntax-in-forward-annotation = "error"
invalid-total-ordering = "error"
invalid-type-alias-type = "error"
invalid-type-checking-constant = "error"
invalid-type-guard-definition = "error"
invalid-type-variable-bound = "error"
invalid-type-variable-constraints = "error"
invalid-typed-dict-header = "error"
invalid-typed-dict-statement = "error"
override-of-final-method = "error"
override-of-final-variable = "error"
possibly-missing-import = "error"
possibly-missing-submodule = "error"
# Has false positives due to ty walrus operator bug.
# possibly-unresolved-reference = "error"
raw-string-type-annotation = "error"
redundant-final-classvar = "error"
shadowed-type-variable = "error"
subclass-of-final-class = "error"
super-call-in-named-tuple-method = "error"
unavailable-implicit-super-arguments = "error"
unbound-type-variable = "error"
undefined-reveal = "error"
unresolved-global = "error"
unresolved-import = "error"
unresolved-reference = "error"
unused-ignore-comment = "error"
unused-type-ignore-comment = "error"
useless-overload-body = "error"
# Non-structural rules:
deprecated = "error"
zero-stepsize-in-slice = "error"
possibly-missing-implicit-call = "error"
unused-awaitable = "error"
# Function argument rules:
# Conflicts with `ifcopenshell.api.geometry.add_representation` type of callables we have, confusing them with a module.
# call-non-callable = "error"
conflicting-argument-forms = "error"
# Too many false positives.
# invalid-argument-type = "error"
missing-argument = "error"
parameter-already-assigned = "error"
positional-only-parameter-as-kwarg = "error"
too-many-positional-arguments = "error"
unknown-argument = "error"
# Has a lot of warnings due to current ty walrus operator issues.
# index-out-of-bounds = "error"
# unresolved-attribute = "error"
[tool.ty.environment]
extra-paths = [
"src/bonsai/external_dependencies",
"src/bcf",
"src/bsdd",
"src/bonsai",
"src/ifc4d",
"src/ifc5d",
"src/ifccityjson",
"src/ifcclash",
"src/ifccsv",
"src/ifcdiff",
"src/ifcfm",
"src/ifcopenshell-python",
"src/ifcpatch",
"src/ifctester",
]
[tool.ty.src]
exclude = [
# External dependencies cloned for type checking only.
"src/bonsai/external_dependencies",
# Submodules.
"src/ifcopenshell-python/ifcopenshell/express",
"src/ifcopenshell-python/ifcopenshell/mvd",
"src/ifcopenshell-python/ifcopenshell/simple_spf",
"src/svgfill/3rdparty",
# Has special dependencies.
"src/ifcopenshell-python/ifcopenshell/geom/app.py",
"src/ifcopenshell-python/ifcopenshell/geom/code_editor_pane.py",
"src/ifcopenshell-python/ifcopenshell/util/doc.py",
"src/ifcopenshell-python/ifcopenshell/util/generate_pset_templates.py",
"src/ifcopenshell-python/ifcopenshell/util/ifc4x3dev_scrape_data_for_docs.py",
# Too esoteric.
"src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.py",
"src/ifc2ca/templates",
# Too dev.
"src/bcf/setup.py",
"src/bsdd/yml_to_classes.py",
# Deprecated.
"src/ifc2ca/_deprecated",
]
[tool.poe.tasks]
ruff-main = "ruff check --extend-exclude nix/build-all.py"
@@ -87,6 +220,47 @@ ruff.sequence = ["ruff-main", "ruff-old"]
black = "black ."
ty.sequence = ["ty-bonsai", "ty-ios"]
ty.help = "Run ty type checker. Requires ty-venv to be set up first."
ty-bonsai = "ty check src/bonsai --python=src/bonsai/.venv"
ty-venv.sequence = ["ty-venv-bonsai", "ty-venv-ios"]
ty-venv-bonsai.sequence = [
{cmd = "uv venv src/bonsai/.venv --python=3.11 --allow-existing"},
{cmd = "uv pip install -r src/bonsai/type-check-requirements.txt --python=src/bonsai/.venv"},
]
ty-venv-ios.sequence = [
{cmd = "uv venv src/ifcopenshell-python/.venv --python=3.10 --allow-existing"},
{cmd = "uv pip install -r src/ifcopenshell-python/type-check-requirements.txt --python=src/ifcopenshell-python/.venv"},
]
format.sequence = ["black", "ruff-main", "ruff-old"]
cmake-format = "gersemi . --in-place"
[tool.poe.tasks.ty-ios]
# --ignore unresolved-reference: walrus operator false positives in ty.
cmd = """
ty check
src/bcf
src/bsdd
src/ifc2ca
src/ifc4d
src/ifc5d
src/ifccityjson
src/ifcclash
src/ifccsv
src/ifcdiff
src/ifcfm
src/ifcopenshell-python
src/ifcpatch
src/ifctester
--python=src/ifcopenshell-python/.venv
--ignore unresolved-reference
"""
[tool.poe.tasks.bonsai-deps]
help = "Clone or update Bonsai external dependencies."
cmd = "python src/bonsai/scripts/bonsai_deps.py"
+3 -3
View File
@@ -34,8 +34,8 @@ client_id, client_secret = "", ""
class OAuthReceiver(http.server.BaseHTTPRequestHandler):
def do_GET(self) -> None:
query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
self.server.auth_code = query.get("code", [""])[0] # type: ignore
self.server.auth_state = query.get("state", [""])[0] # type: ignore
self.server.auth_code = query.get("code", [""])[0]
self.server.auth_state = query.get("state", [""])[0]
self.send_response(200)
self.send_header("Content-type", "text/plain")
self.end_headers()
@@ -255,7 +255,7 @@ class BcfClient:
project_id: str = "",
topics: str = "",
query_string: Optional[str] = None,
) -> list[Any]:
) -> None:
# return self.get(
# f"/projects/{project_id}/topics",
# {
+14 -10
View File
@@ -173,16 +173,17 @@ def assert_viewpoints(viewpoints):
assert viewpoint.snapshot is not None
# TODO: dead code - ported from v2 but buildingSMART/BCF-XML has no v3 MaximumInformation.bcf equivalent
def assert_second_viewpoint(viewpoint, expected_selection, expected_exception, expected_coloring) -> None:
expected_vp = mdl.VisualizationInfo(
components=mdl.Components(
view_setup_hints=mdl.ViewSetupHints(
spaces_visible=False,
space_boundaries_visible=False,
openings_visible=False,
),
selection=expected_selection,
visibility=mdl.ComponentVisibility(
view_setup_hints=mdl.ViewSetupHints(
spaces_visible=False,
space_boundaries_visible=False,
openings_visible=False,
),
exceptions=expected_exception,
default_visibility=False,
),
@@ -193,6 +194,7 @@ def assert_second_viewpoint(viewpoint, expected_selection, expected_exception, e
camera_direction=mdl.Direction(x=0.6745243072509766, y=-0.6599355936050415, z=-0.33091068267822266),
camera_up_vector=mdl.Direction(x=0.2271970510482788, y=-0.24091780185699463, z=0.9435783624649048),
field_of_view=60,
aspect_ratio=1.0,
),
guid="21dd4807-e9af-439e-a980-04d913a6b1ce",
)
@@ -200,16 +202,17 @@ def assert_second_viewpoint(viewpoint, expected_selection, expected_exception, e
assert viewpoint.snapshot is not None
# TODO: dead code - ported from v2 but buildingSMART/BCF-XML has no v3 MaximumInformation.bcf equivalent
def assert_third_viewpoint(viewpoint, expected_selection, expected_exception, expected_coloring) -> None:
expected_vp = mdl.VisualizationInfo(
components=mdl.Components(
view_setup_hints=mdl.ViewSetupHints(
spaces_visible=False,
space_boundaries_visible=False,
openings_visible=True,
),
selection=expected_selection,
visibility=mdl.ComponentVisibility(
view_setup_hints=mdl.ViewSetupHints(
spaces_visible=False,
space_boundaries_visible=False,
openings_visible=True,
),
exceptions=expected_exception,
default_visibility=True,
),
@@ -220,6 +223,7 @@ def assert_third_viewpoint(viewpoint, expected_selection, expected_exception, ex
camera_direction=mdl.Direction(x=0.7232745289802551, y=0.5967116951942444, z=-0.3475759029388428),
camera_up_vector=mdl.Direction(x=0.27662187814712524, y=0.21082592010498047, z=0.937567412853241),
field_of_view=60,
aspect_ratio=1.0,
),
guid="81daa431-bf01-4a49-80a2-1ab07c177717",
)
+8 -5
View File
@@ -45,16 +45,19 @@ from bonsai.bim.module.nest.decorator import NestDecorator
cwd = os.path.dirname(os.path.realpath(__file__))
global_subscription_owner = object()
# Separate owner for per-object msgbus subscriptions (name, active_material_index).
# Using a dedicated owner allows clearing all per-object subscriptions at once
# during undo/redo without affecting other global subscriptions.
object_subscription_owner = object()
def name_callback(obj: Union[bpy.types.Object, bpy.types.Material], data: str) -> None:
try:
obj.name
except:
# The object is invalid but somehow still has a callback. Clear all
# msgbus subscriptions to prevent useless further triggers.
bpy.msgbus.clear_by_owner(obj)
return # In case the object RNA is gone during an undo / redo operation
# The object is invalid but somehow still has a callback.
# This can occur during undo/redo when the Python wrapper is stale.
return
# Blender names are up to 63 UTF-8 bytes
if len(bytes(obj.name, "utf-8")) >= 63:
return
@@ -189,7 +192,7 @@ def subscribe_to(obj: bpy.types.ID, data_path: str, callback: Callable[[bpy.type
return
bpy.msgbus.subscribe_rna(
key=subscribe_to,
owner=obj,
owner=object_subscription_owner,
args=(
obj,
data_path,
+4 -9
View File
@@ -316,11 +316,8 @@ class IfcStore:
del IfcStore.id_map[data["id"]]
if "guid" in data:
del IfcStore.guid_map[data["guid"]]
obj = IfcStore.get_object_by_name(data["obj"])
if obj is None:
# obj was just created during this step and didn't existed before.
return
bpy.msgbus.clear_by_owner(obj)
# Note: msgbus subscriptions are cleared globally during
# rebuild_element_maps which runs after every undo/redo.
@staticmethod
def commit_link_element(data: OperationData) -> None:
@@ -367,10 +364,8 @@ class IfcStore:
del IfcStore.id_map[data["id"]]
if "guid" in data:
del IfcStore.guid_map[data["guid"]]
obj = IfcStore.get_object_by_name(data["obj"])
# obj might be removed after unlink.
if not obj:
bpy.msgbus.clear_by_owner(obj)
# Note: msgbus subscriptions are cleared globally during
# rebuild_element_maps which runs after every undo/redo.
@staticmethod
def unlink_element(
+2 -2
View File
@@ -64,8 +64,8 @@ class MaterialCreator:
mesh: Union[OBJECT_DATA_TYPE, None],
shape_has_openings: bool,
) -> None:
if ((rep := getattr(element, "Representation", ...) is not ...) and not rep) or (
(rep := getattr(element, "RepresentationMaps", ...) is not ...) and not rep
if (((rep := getattr(element, "Representation", ...)) is not ... and not rep) or
((rep := getattr(element, "RepresentationMaps", ...)) is not ... and not rep)
):
return
@@ -101,7 +101,7 @@ class AggregateDecorator:
cls.is_installed = False
def dotted_line_shader(self):
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
vert_out.smooth("FLOAT", "v_ArcLength")
shader_info = gpu.types.GPUShaderCreateInfo()
+1 -1
View File
@@ -230,7 +230,7 @@ class BcfTopic(PropertyGroup):
def get_related_topics(self: "BCFProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]:
global RELATED_TOPICS_ENUM_ITEMS
global RELATED_TOPICS_ENUM_ITEMS # ty: ignore[unresolved-global]
props = self
active_topic = props.active_topic
active_related_topics = active_topic.related_topics.keys()
+4 -4
View File
@@ -46,26 +46,26 @@ def get_libraries(self, context):
def get_namespaces(self, context):
global NAMESPACES_ENUM_ITEMS
global NAMESPACES_ENUM_ITEMS # ty: ignore[unresolved-global]
NAMESPACES_ENUM_ITEMS = [(uri, f"{alias}: {uri}", "") for alias, uri in BrickStore.namespaces]
return NAMESPACES_ENUM_ITEMS
def get_brick_entity_classes(self, context):
global ENTITY_CLASSES_ENUM_ITEMS
global ENTITY_CLASSES_ENUM_ITEMS # ty: ignore[unresolved-global]
entity = self.brick_entity_create_type
ENTITY_CLASSES_ENUM_ITEMS = [(uri, uri.split("#")[-1], "") for uri in BrickStore.entity_classes[entity]]
return ENTITY_CLASSES_ENUM_ITEMS
def get_brick_roots(self, context):
global BRICK_ROOTS_ENUM_ITEMS
global BRICK_ROOTS_ENUM_ITEMS # ty: ignore[unresolved-global]
BRICK_ROOTS_ENUM_ITEMS = [(root, root, "") for root in BrickStore.root_classes]
return BRICK_ROOTS_ENUM_ITEMS
def get_brick_relations(self, context):
global BRICK_RELATIONS_ENUM_ITEMS
global BRICK_RELATIONS_ENUM_ITEMS # ty: ignore[unresolved-global]
BRICK_RELATIONS_ENUM_ITEMS = [(uri, uri.split("#")[-1], "") for uri in BrickStore.relationships]
for relation in BrickschemaData.data["active_relations"]:
if relation["predicate_name"] == "label":
@@ -1285,7 +1285,7 @@ class SnapManager:
continue
coords = np.empty(vertex_count * 3, dtype=np.float32)
mesh.vertices.foreach_get("co", coords) # type: ignore[arg-type]
mesh.vertices.foreach_get("co", coords)
coords = coords.reshape(-1, 3)
matrix = np.array(obj_eval.matrix_world, dtype=np.float32)
@@ -456,7 +456,8 @@ def format_distance(
tx_dist = fmt % d_cm
else:
tx_dist = fmt % value
assert f"Unexpected unit_system - '{unit_system}'."
# tx_dist = fmt % value
return tx_dist
@@ -1426,6 +1426,7 @@ class CreateDrawing(bpy.types.Operator):
"/Pset_.*Common/.Status",
"EPset_Status.Status",
"EPset_Status.UserDefinedStatus",
"Material.Name",
]
group = root.find("{http://www.w3.org/2000/svg}g")
@@ -366,9 +366,6 @@ class BaseLinesShader(BaseShader):
}
"""
def __init__(self, gap_size=16):
super().__init__(gap_size=gap_size)
def glenable(self):
super().glenable()
@@ -1066,7 +1066,7 @@ class OverrideOutlinerDelete(bpy.types.Operator, tool.Ifc.Operator):
cls.poll_message_set("Only available from Outliner.")
return False
def execute(self, context):
def execute(self, context): # ty:ignore[override-of-final-method]
if len(getattr(context, "selected_ids", [])) == 0:
return {"FINISHED"}
@@ -2289,7 +2289,7 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator):
elif obj in pprops.clipping_planes_objs:
self.report({"ERROR"}, "Clipping planes cannot be edited")
elif element:
if not obj.data:
if not obj.data or obj.type not in ("MESH", "CURVE"):
self.report({"INFO"}, "No geometry to edit")
elif tool.Geometry.is_locked(element):
self.report({"ERROR"}, lock_error_message(obj.name))
@@ -139,7 +139,9 @@ def update_local_coordinates(self: "BIMGeoreferenceProperties", context: bpy.typ
tool.Georeference.set_coordinates(
"blender",
ifcopenshell.util.geolocation.enh2xyz(
*local_coordinates,
local_coordinates[0],
local_coordinates[1],
local_coordinates[2],
float(props.blender_offset_x),
float(props.blender_offset_y),
float(props.blender_offset_z),
@@ -162,7 +164,9 @@ def update_map_coordinates(self: "BIMGeoreferenceProperties", context: bpy.types
tool.Georeference.set_coordinates(
"blender",
ifcopenshell.util.geolocation.enh2xyz(
*local_coordinates,
local_coordinates[0],
local_coordinates[1],
local_coordinates[2],
float(props.blender_offset_x),
float(props.blender_offset_y),
float(props.blender_offset_z),
+1 -1
View File
@@ -27,7 +27,7 @@ from bonsai.bim.prop import StrProperty
class BIMCityJsonProperties(PropertyGroup):
def get_lods(self, context):
global LODS_ENUM_ITEMS
global LODS_ENUM_ITEMS # ty: ignore[unresolved-global]
LODS_ENUM_ITEMS = [(item.name, "LOD" + item.name, "Level of Detail " + item.name) for item in self.lods]
return LODS_ENUM_ITEMS
+1 -1
View File
@@ -320,7 +320,7 @@ class RadianceExporterProperties(PropertyGroup):
)
def get_subcategories(self, context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS:
global SUBCATEGORIES_ENUM_ITEMS
global SUBCATEGORIES_ENUM_ITEMS # ty: ignore[unresolved-global]
if self.category in spectraldb:
SUBCATEGORIES_ENUM_ITEMS = [(k, k, "") for k in spectraldb[self.category].keys()]
else:
@@ -717,7 +717,11 @@ class EnableEditingMaterialSetItem(bpy.types.Operator):
self.props.material_set_item_material = str(material_set_item.Material.id())
self.props.material_set_item_attributes.clear()
bonsai.bim.helper.import_attributes(material_set_item, self.props.material_set_item_attributes)
bonsai.bim.helper.import_attributes(
material_set_item,
self.props.material_set_item_attributes,
callback=self.import_attributes_callback,
)
if material_set_item.is_a("IfcMaterialProfile"):
if material_set_item.Profile and material_set_item.Profile.ProfileName:
@@ -725,6 +729,29 @@ class EnableEditingMaterialSetItem(bpy.types.Operator):
return {"FINISHED"}
def import_attributes_callback(
self, name: str, prop: Union["Attribute", None], data: dict[str, Any]
) -> None | Literal[True]:
if data["type"] != "IfcMaterialLayer" or name != "IsVentilated" or not prop:
return None
# Keep null semantics unchanged on export, but avoid an empty UI selection.
prop.data_type = "enum"
prop.special_type = "LOGICAL"
prop.enum_items = json.dumps(("TRUE", "FALSE", "UNKNOWN"))
value = data[name]
if value == "UNKNOWN":
prop.enum_value = "UNKNOWN"
elif value is None:
# Keep visible default as FALSE, but preserve null semantics on save.
prop.enum_value = "FALSE"
prop.is_null = True
else:
prop.enum_value = "TRUE" if value else "FALSE"
return True
class DisableEditingMaterialSetItem(bpy.types.Operator):
bl_idname = "bim.disable_editing_material_set_item"
@@ -104,6 +104,7 @@ classes = (
profile.ExtendProfile,
profile.RecalculateProfile,
profile.Rotate90,
profile.SplitProfile,
profile.PatchNonParametricMepSegment,
roof.GenerateHippedRoof,
slab.DisableEditingExtrusionProfile,
+2 -2
View File
@@ -227,7 +227,7 @@ class FitFlowSegments(bpy.types.Operator, tool.Ifc.Operator):
is_parallel21 = tool.Cad.is_x(angle21, (0, 180), tolerance=0.001)
is_parallel23 = tool.Cad.is_x(angle23, (0, 180), tolerance=0.001)
if not all(is_parallel12, is_parallel13, is_parallel21, is_parallel23):
if not all([is_parallel12, is_parallel13, is_parallel21, is_parallel23]):
fitting_type = "WYE"
if not fitting_type:
@@ -903,7 +903,7 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator):
start_segment_id: bpy.props.IntProperty(name="Start Segment Element ID", default=0)
end_segment_id: bpy.props.IntProperty(name="End Segment Element ID", default=0)
radius: bpy.props.FloatProperty(
"Bend Inner Radius", description="Bend inner radius in SI units", default=0.2, subtype="DISTANCE", min=0
name="Bend Inner Radius", description="Bend inner radius in SI units", default=0.2, subtype="DISTANCE", min=0
)
def _execute(self, context):
@@ -540,6 +540,16 @@ class AddBoolean(Operator, tool.Ifc.Operator):
booleans = ifcopenshell.api.geometry.add_boolean(tool.Ifc.get(), first_item, second_items, props.operator)
rep_obj = tool.Geometry.get_geometry_props().representation_obj
if booleans:
# Users typically select two top-level items and expect the
# operand to be absorbed into the boolean, not remain as a
# standalone item alongside it.
representation = tool.Geometry.get_active_representation(rep_obj)
representation = ifcopenshell.util.representation.resolve_representation(representation)
second_items_set = set(second_items)
new_items = [i for i in representation.Items if i not in second_items_set]
if new_items:
representation.Items = new_items
rep_element = tool.Ifc.get_entity(rep_obj)
tool.Model.mark_manual_booleans(rep_element, booleans)
tool.Geometry.reload_representation(rep_obj)
@@ -421,7 +421,7 @@ class PolylineOperator:
tool.Polyline.calculate_x_y_and_z(context, self.input_ui, self.tool_state)
tool.Blender.update_viewport()
return {"RUNNING_MODAL"}
return {"RUNNING_MODAL"}
def set_offset(self, context: bpy.types.Context, relating_type: ifcopenshell.entity_instance) -> None:
props = tool.Model.get_model_props()
@@ -461,6 +461,7 @@ class PolylineOperator:
self.tool_state.axis_method = None
self.tool_state.plane_method = None
self.tool_state.mode = "Mouse"
tool.Raycast.clear_snap_objs()
self.visible_objs = tool.Raycast.get_visible_objects(context)
for obj in self.visible_objs:
if bbox_2d := tool.Raycast.get_on_screen_2d_bounding_boxes(context, obj):
@@ -694,10 +694,14 @@ def generate_box(usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[
new_settings = settings.copy()
new_settings["context"] = box_context
new_box = ifcopenshell.api.geometry.add_representation(ifc_file, should_run_listeners=False, **new_settings)
new_box = ifcopenshell.api.geometry.add_representation(
ifc_file,
should_run_listeners=False, # ty:ignore[unknown-argument]
**new_settings,
)
ifcopenshell.api.geometry.assign_representation(
ifc_file,
should_run_listeners=False,
should_run_listeners=False, # ty:ignore[unknown-argument]
product=product,
representation=new_box,
)
+114 -16
View File
@@ -18,7 +18,7 @@
import copy
from math import atan2, degrees, pi, radians
from typing import Any, Literal, Optional, Union
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
import bpy
import ifcopenshell
@@ -49,7 +49,7 @@ ProfileFrom2PointsReturn = Union[dict[str, Any], None]
class DumbProfileGenerator:
def __init__(self, relating_type):
def __init__(self, relating_type: ifcopenshell.entity_instance):
self.relating_type = relating_type
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
@@ -201,7 +201,7 @@ class DumbProfileGenerator:
class DumbProfileRegenerator:
def regenerate_from_profile_def(self, profile):
def regenerate_from_profile_def(self, profile: ifcopenshell.entity_instance) -> None:
self.file = tool.Ifc.get()
objs = []
if not profile:
@@ -221,7 +221,7 @@ class DumbProfileRegenerator:
for element in self.get_element_types_using_profile(profile):
tool.Model.mark_thumbnail_for_update(element)
def regenerate_from_profile(self, usecase_path, ifc_file, settings):
def regenerate_from_profile(self, usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[str, Any]) -> None:
self.file = ifc_file
objs = []
profile = settings["profile"].Profile
@@ -233,7 +233,7 @@ class DumbProfileRegenerator:
objs.append(obj)
DumbProfileRecalculator().recalculate(objs)
def get_elements_using_profile(self, profile):
def get_elements_using_profile(self, profile: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
results = []
profile_sets = [
mp.ToMaterialProfileSet[0] for mp in self.file.get_inverse(profile) if mp.is_a("IfcMaterialProfile")
@@ -252,7 +252,9 @@ class DumbProfileRegenerator:
results.extend(rel.RelatedObjects)
return results
def get_element_types_using_profile(self, profile):
def get_element_types_using_profile(
self, profile: ifcopenshell.entity_instance
) -> list[ifcopenshell.entity_instance]:
results = []
profile_sets = [
mp.ToMaterialProfileSet[0] for mp in self.file.get_inverse(profile) if mp.is_a("IfcMaterialProfile")
@@ -269,12 +271,18 @@ class ExtendProfile(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.extend_profile"
bl_label = "Extend Profile"
bl_options = {"REGISTER", "UNDO"}
join_type: bpy.props.StringProperty()
join_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
items=[("-", "Unjoin", ""), ("L", "L", ""), ("V", "V", ""), ("T", "T", "")],
default="-",
)
if TYPE_CHECKING:
join_type: Literal["-", "L", "V", "T"]
def _execute(self, context):
selected_objs = context.selected_objects
joiner = DumbProfileJoiner()
if not self.join_type:
if self.join_type == "-":
for obj in selected_objs:
joiner.unjoin(obj)
return {"FINISHED"}
@@ -626,11 +634,15 @@ class DumbProfileJoiner:
if connection1 == "ATEND":
if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal:
plane = self.get_profile_plane(profile2, furthest_plane)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
self.body[1] = intersect
else:
plane = self.get_profile_plane(profile2, furthest_plane, z_inwards=False)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
max_dim = self.get_max_bound_box_dimension(profile1)
self.body[1] = intersect + profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim))
@@ -673,11 +685,15 @@ class DumbProfileJoiner:
elif connection1 == "ATSTART":
if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal:
plane = self.get_profile_plane(profile2, furthest_plane)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
self.body[0] = intersect
else:
plane = self.get_profile_plane(profile2, furthest_plane, z_inwards=False)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
max_dim = self.get_max_bound_box_dimension(profile1)
self.body[0] = intersect - profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim))
@@ -721,7 +737,9 @@ class DumbProfileJoiner:
if connection1 == "ATEND":
if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal:
plane = self.get_profile_plane(profile2, furthest_plane if is_relating else closest_plane)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
self.body[1] = intersect
else:
plane = self.get_profile_plane(
@@ -729,7 +747,9 @@ class DumbProfileJoiner:
furthest_plane if is_relating else closest_plane,
z_inwards=False if is_relating else True,
)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
max_dim = self.get_max_bound_box_dimension(profile1)
self.body[1] = intersect + profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim))
self.clippings.append(
@@ -742,7 +762,9 @@ class DumbProfileJoiner:
elif connection1 == "ATSTART":
if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal:
plane = self.get_profile_plane(profile2, furthest_plane if is_relating else closest_plane)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
self.body[0] = intersect
else:
plane = self.get_profile_plane(
@@ -750,7 +772,9 @@ class DumbProfileJoiner:
furthest_plane if is_relating else closest_plane,
z_inwards=False if is_relating else True,
)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
max_dim = self.get_max_bound_box_dimension(profile1)
self.body[0] = intersect - profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim))
self.clippings.append(
@@ -825,6 +849,57 @@ class DumbProfileJoiner:
def create_matrix(self, p: Vector, x: Vector, y: Vector, z: Vector) -> Matrix:
return Matrix([x, y, z, p]).to_4x4().transposed()
def split(self, profile1: bpy.types.Object, target: Vector) -> None:
element1 = tool.Ifc.get_entity(profile1)
if not element1:
return
if tool.Ifc.is_moved(profile1):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=profile1)
axis1 = self.get_profile_axis(profile1)
intersect, cut_percentage = mathutils.geometry.intersect_point_line(target, *axis1)
if cut_percentage < 0 or cut_percentage > 1 or tool.Cad.is_x(cut_percentage, (0, 1)):
return
# Duplicate the profile element
profile2 = profile1.copy()
profile2.data = profile2.data.copy()
for collection in profile1.users_collection:
collection.objects.link(profile2)
bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=profile2)
element2 = tool.Ifc.get_entity(profile2)
# Transfer ATEND connection from element1 to element2
relating_element = None
relating_connection = None
description = None
for conn in list(element1.ConnectedTo):
if conn.is_a("IfcRelConnectsPathElements") and conn.RelatingConnectionType == "ATEND":
relating_element = conn.RelatedElement
relating_connection = conn.RelatedConnectionType
description = conn.Description
bonsai.core.geometry.remove_connection(tool.Geometry, connection=conn)
for conn in list(element1.ConnectedFrom):
if conn.is_a("IfcRelConnectsPathElements") and conn.RelatedConnectionType == "ATEND":
relating_element = conn.RelatingElement
relating_connection = conn.RelatingConnectionType
description = conn.Description
bonsai.core.geometry.remove_connection(tool.Geometry, connection=conn)
if relating_element:
ifcopenshell.api.geometry.connect_path(
tool.Ifc.get(),
relating_element=relating_element,
related_element=element2,
relating_connection=relating_connection,
related_connection="ATEND",
description=description,
)
# Recreate both profiles with split axes
self.recreate_profile(element1, profile1, [axis1[0], intersect], [axis1[0], intersect])
self.recreate_profile(element2, profile2, [intersect, axis1[1]], [intersect, axis1[1]])
def get_profile_axis(self, obj: bpy.types.Object) -> list[Vector]:
z_values = [v[2] for v in obj.bound_box]
return [
@@ -833,6 +908,29 @@ class DumbProfileJoiner:
]
class SplitProfile(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.split_profile"
bl_label = "Split Profile"
bl_options = {"REGISTER", "UNDO"}
bl_description = (
"Split selected profile element into two elements at the Blender cursor location. "
"The cursor must be positioned on the element's axis."
)
@classmethod
def poll(cls, context):
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
return True
def _execute(self, context):
selected_objs = tool.Model.get_selected_mesh_objects()
for obj in selected_objs:
DumbProfileJoiner().split(obj, context.scene.cursor.location)
return {"FINISHED"}
class RecalculateProfile(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.recalculate_profile"
bl_label = "Recalculate Profile"
+2 -2
View File
@@ -31,11 +31,11 @@ def calculate_quantities(usecase_path, ifc_file: ifcopenshell.file, settings):
return
task = next(e for e in ifc_file.get_inverse(element) if e.is_a("IfcTask"))
qto = ifcopenshell.api.pset.add_qto(
ifc_file, should_run_listeners=False, product=task, name="Qto_TaskBaseQuantities"
ifc_file, should_run_listeners=False, product=task, name="Qto_TaskBaseQuantities" # ty:ignore[unknown-argument]
)
ifcopenshell.api.pset.edit_qto(
ifc_file,
should_run_listeners=False,
should_run_listeners=False, # ty:ignore[unknown-argument]
qto=qto,
properties={
"StandardWork": ifcopenshell.util.date.ifc2datetime(element.ScheduleDuration).days,
@@ -1268,27 +1268,6 @@ class DumbWallJoiner:
bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=wall2)
return wall2
def join_Z(self, wall1, slab2):
element1 = tool.Ifc.get_entity(wall1)
element2 = tool.Ifc.get_entity(slab2)
for rel in element1.ConnectedFrom:
if rel.is_a() == "IfcRelConnectsElements" and rel.Description == "TOP":
ifcopenshell.api.geometry.disconnect_element(
tool.Ifc.get(),
relating_element=rel.RelatingElement,
related_element=element1,
)
ifcopenshell.api.geometry.connect_element(
tool.Ifc.get(),
relating_element=element2,
related_element=element1,
description="TOP",
)
tool.Model.recreate_wall(element1, wall1)
def set_axis(self, wall, p1, p2):
axis = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Plan", "Axis", "GRAPH_VIEW")
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
@@ -1333,29 +1312,6 @@ class DumbWallJoiner:
self.set_axis(element1, p1, p2)
tool.Model.recreate_wall(element1, wall1)
def join_T(self, wall1: bpy.types.Object, wall2: bpy.types.Object) -> None:
element1 = tool.Ifc.get_entity(wall1)
element2 = tool.Ifc.get_entity(wall2)
axis1 = tool.Model.get_wall_axis(wall1)
axis2 = tool.Model.get_wall_axis(wall2)
intersect = tool.Cad.intersect_edges(axis1["reference"], axis2["reference"])
if intersect:
intersect, _ = intersect
else:
return
connection = "ATEND" if tool.Cad.edge_percent(intersect, axis1["reference"]) > 0.5 else "ATSTART"
ifcopenshell.api.geometry.connect_path(
tool.Ifc.get(),
related_element=element1,
relating_element=element2,
relating_connection="ATPATH",
related_connection=connection,
description="BUTT",
)
tool.Model.recreate_wall(element1, wall1, axis1["reference"], axis1["reference"])
def connect(self, obj1: bpy.types.Object, obj2: bpy.types.Object) -> None:
wall1 = tool.Ifc.get_entity(obj1)
wall2 = tool.Ifc.get_entity(obj2)
@@ -937,13 +937,21 @@ class EditObjectUI:
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(row, "Mitre", "S_Y", "", ui_context)
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(
row,
"Split",
"S_K",
"Split selected Element into two Elements at the cursor location\n\nHotkey: ⇧ K",
ui_context,
)
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(row, "Rotate 90", "S_R", bpy.ops.bim.rotate_90.__doc__, ui_context)
else:
if "LAYER2" in AuthoringData.data["selected_material_usages"]:
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(
cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_to_underside.__doc__, ui_context
cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_walls_to_underside.__doc__, ui_context
)
if AuthoringData.data["is_flippable_element"]:
@@ -1361,6 +1369,8 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
return
if self.active_material_usage == "LAYER2":
bpy.ops.bim.split_wall()
elif self.active_material_usage == "PROFILE":
bpy.ops.bim.split_profile()
def hotkey_S_T(self):
if not bpy.context.selected_objects:
@@ -101,7 +101,7 @@ class NestDecorator:
cls.is_installed = False
def dotted_line_shader(self):
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
vert_out.smooth("FLOAT", "v_ArcLength")
shader_info = gpu.types.GPUShaderCreateInfo()
@@ -321,7 +321,7 @@ def get_gross_perimeter(o: bpy.types.Object) -> float:
return gross_perimeter
def get_space_net_perimeter(obj: bpy.types.Object) -> float:
def get_space_net_perimeter(obj: bpy.types.Object) -> None:
pass
@@ -619,7 +619,7 @@ class SelectFilterElements(bpy.types.Operator):
return {"FINISHED"}
class ApplyFilterFromText(Operator, tool.Ifc.Operator):
class ApplyFilterFromText(Operator):
bl_idname = "bim.apply_filter_from_text"
bl_label = "Apply Filter Configuration"
bl_description = "Apply the JSON filter configuration from the current text block"
@@ -1440,7 +1440,7 @@ class ShowAllElements(Operator):
return {"FINISHED"}
class SelectSimilar(Operator, tool.Ifc.Operator):
class SelectSimilar(Operator):
bl_idname = "bim.select_similar"
bl_label = "Select Similar"
bl_options = {"REGISTER", "UNDO"}
@@ -28,6 +28,7 @@ import ifcopenshell.util.representation
import ifcopenshell.util.unit
import ifcopenshell.util.unit as ifcunit
import numpy as np
import numpy.typing as npt
from mathutils import Vector
import bonsai.tool as tool
@@ -478,7 +479,7 @@ class ShaderInfo:
"""get the args to the point shader"""
location = np.array(location)
indices = []
direction_dict = {
direction_dict: dict[str, tuple[npt.NDArray, ...]] = {
"fx": (np.array((1, 0, 0)), np.array((0, 1, 0)), np.array((0, 0, 1))),
"fy": (np.array((0, 1, 0)), np.array((1, 0, 0)), np.array((0, 0, 1))),
"fz": (np.array((0, 0, 1)), np.array((0, 1, 0)), np.array((1, 0, 0))),
@@ -83,7 +83,7 @@ class DecorationShader:
PARALLEL DISTRIBUTED FORCE,
DISTRIBUTED MOMENT,
"""
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
vert_out.smooth("VEC3", "forces")
vert_out.smooth("VEC3", "co")
@@ -203,7 +203,7 @@ class DecorationShader:
"""param: pattern: type of pattern
SINGLE FORCE,
SINGLE MOMENT"""
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty: ignore[too-many-positional-arguments]
vert_out.smooth("VEC3", "co")
shader_info = gpu.types.GPUShaderCreateInfo()
@@ -253,7 +253,7 @@ class DecorationShader:
def get_planar_shader(self) -> gpu.types.GPUShader:
"""shader for planar loads"""
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty: ignore[too-many-positional-arguments]
vert_out.smooth("VEC3", "co")
shader_info = gpu.types.GPUShaderCreateInfo()
-2
View File
@@ -787,7 +787,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
doc: DocPreferences
default_parameters: DefaultParameters
container_hide_show_isolate: bool
mass_time_units_in_wizard: bool
chain_filter_with_set_operations: bool
save_metadata_blend_file: bool
metadata_blend_file_suffix: str
@@ -986,7 +985,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
def draw_extras_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
layout.prop(self, "container_hide_show_isolate")
layout.prop(self, "mass_time_units_in_wizard")
row = layout.row(align=True)
row.prop(self, "chain_filter_with_set_operations")
row.operator("bim.open_uri", text="", icon="HELP").uri = "https://community.osarch.org/discussion/3270"
+11 -61
View File
@@ -51,21 +51,11 @@ def add_instance_flooring_covering_from_cursor(
if isinstance(space_polygon, str):
return
bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True)
name = "Covering"
mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm)
obj = spatial.get_named_obj_from_mesh(name, mesh)
obj = spatial.create_object("Covering")
spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj)
spatial.translate_obj_to_z_location(obj, z)
points = spatial.get_2d_vertices_from_obj(obj)
points = spatial.get_scaled_2d_vertices(points)
spatial.assign_type_to_obj(obj)
spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
body = spatial.get_body_representation(obj)
spatial.regen_obj_representation(obj, body)
spatial.set_covering_representation_from_polygon(obj, space_polygon, polygon_is_si=True)
def add_instance_ceiling_covering_from_cursor(
@@ -95,21 +85,11 @@ def add_instance_ceiling_covering_from_cursor(
if isinstance(space_polygon, str):
return
bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True)
name = "Covering"
mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm)
obj = spatial.get_named_obj_from_mesh(name, mesh)
obj = spatial.create_object("Covering")
spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj)
spatial.translate_obj_to_z_location(obj, z + ceiling_height)
points = spatial.get_2d_vertices_from_obj(obj)
points = spatial.get_scaled_2d_vertices(points)
spatial.assign_type_to_obj(obj)
spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
body = spatial.get_body_representation(obj)
spatial.regen_obj_representation(obj, body)
spatial.set_covering_representation_from_polygon(obj, space_polygon, polygon_is_si=True)
def regen_selected_covering_object(root: type[tool.Root], spatial: type[tool.Spatial]) -> None:
@@ -127,19 +107,7 @@ def regen_selected_covering_object(root: type[tool.Root], spatial: type[tool.Spa
if isinstance(space_polygon, str):
return
bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True)
name = "Aux"
mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm)
mesh = spatial.get_transformed_mesh_from_local_to_global(mesh)
obj = spatial.get_named_obj_from_mesh(name, mesh)
points = spatial.get_2d_vertices_from_obj(obj)
points = spatial.get_scaled_2d_vertices(points)
spatial.assign_swept_area_outer_curve_from_2d_vertices(active_obj, vertices=points)
body = spatial.get_body_representation(active_obj)
spatial.regen_obj_representation(active_obj, body)
spatial.set_covering_representation_from_polygon(active_obj, space_polygon, polygon_is_si=True)
# TODO CHECK IF IT IS POSSIBLE TO CREATE ONLY ONE CORE FUNCTION FOR _FROM_WALLS
@@ -151,22 +119,13 @@ def add_instance_flooring_coverings_from_walls(root: type[tool.Root], spatial: t
union = spatial.get_union_shape_from_selected_objects()
for i, linear_ring in enumerate(union.interiors):
poly = spatial.get_buffered_poly_from_linear_ring(linear_ring)
bm = spatial.get_bmesh_from_polygon(poly, h=0, polygon_is_si=False)
name = "Covering" + str(i)
obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm)
spatial.set_obj_origin_to_bboxcenter(obj)
obj = spatial.create_object(name)
spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False)
spatial.translate_obj_to_z_location(obj, z)
points = spatial.get_2d_vertices_from_obj(obj)
points = spatial.get_scaled_2d_vertices(points)
spatial.assign_type_to_obj(obj)
spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
body = spatial.get_body_representation(obj)
spatial.regen_obj_representation(obj, body)
spatial.set_covering_representation_from_polygon(obj, poly, polygon_is_si=False)
def add_instance_ceiling_coverings_from_walls(
@@ -179,22 +138,13 @@ def add_instance_ceiling_coverings_from_walls(
union = spatial.get_union_shape_from_selected_objects()
for i, linear_ring in enumerate(union.interiors):
poly = spatial.get_buffered_poly_from_linear_ring(linear_ring)
bm = spatial.get_bmesh_from_polygon(poly, h=0, polygon_is_si=False)
name = "Covering" + str(i)
obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm)
spatial.set_obj_origin_to_bboxcenter(obj)
obj = spatial.create_object(name)
spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False)
spatial.translate_obj_to_z_location(obj, z)
points = spatial.get_2d_vertices_from_obj(obj)
points = spatial.get_scaled_2d_vertices(points)
spatial.assign_type_to_obj(obj)
spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
body = spatial.get_body_representation(obj)
spatial.regen_obj_representation(obj, body)
spatial.set_covering_representation_from_polygon(obj, poly, polygon_is_si=False)
class NoDefaultContainer(Exception):
+1 -4
View File
@@ -432,10 +432,7 @@ def update_drawing_name(
if drawing_tool.get_name(drawing) != name:
ifc.run("attribute.edit_attributes", product=drawing, attributes={"Name": name})
# Update the camera object name
camera = ifc.get_object(drawing)
if camera and camera.name != name:
camera.name = name
drawing_tool.set_camera_name(drawing, name)
group = drawing_tool.get_drawing_group(drawing)
if drawing_tool.get_name(group) != name:
+1 -43
View File
@@ -109,7 +109,7 @@ def align_walls(
align_type: AlignType,
):
reference_obj = blender.get_active_object(is_selected=True)
if not (e := ifc.get_entity(reference_obj) or not model.get_usage_type(e) == "LAYER2"):
if not reference_obj or not (e := ifc.get_entity(reference_obj)) or not model.get_usage_type(e) == "LAYER2":
reference_obj = None
objs = [
o
@@ -159,48 +159,6 @@ def extend_wall_to_slab(
model.reload_body_representation(wall_objs)
def join_walls_TZ(
ifc: type[tool.Ifc],
blender: type[tool.Blender],
geometry: type[tool.Geometry],
joiner: DumbWallJoiner,
model: type[tool.Model],
) -> None:
selected_objs = [
o
for o in blender.get_selected_objects()
if (e := ifc.get_entity(o)) and model.get_usage_type(e) in ("LAYER2", "LAYER3")
]
if len(selected_objs) < 2:
raise RequireAtLeastTwoLayeredElements(
"Two or more vertically or horizontally layered elements must be selected to connect their paths together"
)
for obj in selected_objs:
geometry.clear_scale(obj)
elements = [ifc.get_entity(o) for o in blender.get_selected_objects()]
layer2_elements = []
layer3_elements = []
for element in elements:
usage = model.get_usage_type(element)
if usage == "LAYER2":
layer2_elements.append(element)
elif usage == "LAYER3":
layer3_elements.append(element)
if layer3_elements:
target = ifc.get_object(layer3_elements[0])
for element in layer2_elements:
joiner.join_Z(ifc.get_object(element), target)
else:
if not (active_obj := blender.get_active_object()):
active_obj = selected_objs[0]
for obj in selected_objs:
if obj == active_obj:
continue
joiner.join_T(obj, active_obj)
class RequireTwoWallsError(Exception):
pass
+8 -12
View File
@@ -219,13 +219,8 @@ def generate_space(
else:
assert space_polygon
bm = spatial.get_bmesh_from_polygon(space_polygon, h=h, polygon_is_si=True)
mesh = spatial.get_named_mesh_from_bmesh(name="Space", bmesh=bm)
if element and element.is_a("IfcSpace"):
mesh = spatial.get_transformed_mesh_from_local_to_global(mesh)
spatial.edit_active_space_obj_from_mesh(mesh)
spatial.set_space_representation_from_polygon(active_obj, element, space_polygon, h, polygon_is_si=True)
spatial.translate_obj_to_z_location(active_obj, z)
else:
if relating_type:
@@ -233,12 +228,13 @@ def generate_space(
else:
name = "Space"
obj = spatial.get_named_obj_from_mesh(name, mesh)
obj = spatial.create_object(name)
spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj)
spatial.translate_obj_to_z_location(obj, z)
spatial.assign_ifcspace_class_to_obj(obj)
element = ifc.get_entity(obj)
spatial.set_space_representation_from_polygon(obj, element, space_polygon, h, polygon_is_si=True)
if relating_type:
spatial.assign_relating_type_to_element(ifc, type, element, relating_type)
@@ -257,16 +253,16 @@ def generate_spaces_from_walls(
for i, linear_ring in enumerate(union.interiors):
poly = spatial.get_buffered_poly_from_linear_ring(linear_ring)
bm = spatial.get_bmesh_from_polygon(poly, h, polygon_is_si=False)
name = "Space" + str(i)
obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm)
spatial.set_obj_origin_to_bboxcenter_and_zero_elevation(obj)
obj = spatial.create_object(name)
spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False)
spatial.translate_obj_to_z_location(obj, z)
spatial.assign_ifcspace_class_to_obj(obj)
element = ifc.get_entity(obj)
spatial.set_space_representation_from_polygon(obj, element, poly, h, polygon_is_si=False)
def toggle_space_visibility(ifc: type[tool.Ifc], spatial: type[tool.Spatial]) -> None:
model = ifc.get()
+15 -13
View File
@@ -349,7 +349,10 @@ class Drawing:
def enable_editing_text(cls, obj): pass
def ensure_unique_drawing_name(cls, name): pass
def ensure_unique_identification(cls, identification): pass
def export_font_size(cls, obj): pass
def export_symbol(cls, obj): pass
def export_text_literal_attributes(cls, obj): pass
def export_wrap_length(cls, obj): pass
def generate_drawing_matrix(cls, target_view, location_hint): pass
def generate_drawing_name(cls, target_view, location_hint): pass
def generate_reference_attributes(cls, reference, **attributes): pass
@@ -402,6 +405,7 @@ class Drawing:
def run_root_assign_class(cls, obj=None, ifc_class=None, predefined_type=None, should_add_representation=True, context=None, ifc_representation_class=None): pass
def run_type_assign_type(cls, element=None, relating_type=None): pass
def select_assigned_product(cls, drawing): pass
def set_camera_name(cls, drawing, name): pass
def set_drawing_collection_name(cls, drawing, collection): pass
def set_name(cls, element, name): pass
def setup_annotation_object(cls, obj, object_type): pass
@@ -620,7 +624,10 @@ class Model:
def import_rectangle(cls, obj, position, profile): pass
def load_openings(cls, openings): pass
def purge_scene_openings(cls): pass
def recalculate_walls(cls, objs): pass
def regenerate_array(cls, parent, data): pass
def regenerate_profile(cls, obj): pass
def regenerate_slab(cls, obj): pass
def reload_body_representation(cls, obj_or_objects): pass
def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass
@@ -992,14 +999,12 @@ class Spatial:
def get_purged_inner_holes_poly(cls, union_geom, min_area): pass
def get_poly_valid_interior_list(cls, poly, min_area, interiors_list): pass
def get_buffered_poly_from_linear_ring(cls, linear_ring): pass
def get_bmesh_from_polygon(cls, poly, h, polygon_is_si=False): pass
def get_named_obj_from_bmesh(cls, name, bmesh): pass
def get_named_obj_from_mesh(cls, name, mesh): pass
def get_named_mesh_from_bmesh(cls, name, bmesh): pass
def get_transformed_mesh_from_local_to_global(cls, mesh): pass
def edit_active_space_obj_from_mesh(cls, mesh): pass
def set_obj_origin_to_bboxcenter(cls, obj): pass
def set_obj_origin_to_bboxcenter_and_zero_elevation(cls, obj): pass
def get_2d_vertices_from_polygon(cls, poly, obj, polygon_is_si=True): pass
def set_extrusion_representation_from_polygon(cls, obj, element, poly, depth_ifc, polygon_is_si=True): pass
def set_space_representation_from_polygon(cls, obj, element, poly, h, polygon_is_si=True): pass
def set_covering_representation_from_polygon(cls, obj, poly, polygon_is_si=True): pass
def create_object(cls, name): pass
def set_obj_origin_to_polygon_center(cls, obj, poly, polygon_is_si=True): pass
def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj): pass
def get_selected_objects(cls): pass
def get_active_obj(cls): pass
@@ -1007,14 +1012,9 @@ class Spatial:
def get_active_obj_height(cls): pass
def get_relating_type_id(cls): pass
def translate_obj_to_z_location(cls, obj, z): pass
def get_2d_vertices_from_obj(cls, obj): pass
def get_scaled_2d_vertices(cls, points): pass
def assign_swept_area_outer_curve_from_2d_vertices(cls, obj, vertices): pass
def get_body_representation(cls, obj): pass
def assign_ifcspace_class_to_obj(cls, obj): pass
def assign_type_to_obj(cls, obj): pass
def assign_relating_type_to_element(cls, ifc, type, element, relating_type): pass
def regen_obj_representation(cls, obj, body): pass
def toggle_spaces_visibility_wired_and_textured(cls, spaces): pass
def toggle_hide_spaces(cls, spaces): pass
def set_default_container(cls, container): pass
@@ -1119,6 +1119,8 @@ class Type:
def get_representation_context(cls, representation): pass
def get_type_occurrences(cls, element_type): pass
def has_material_usage(cls, element): pass
def record_material_usage_attributes(cls, element): pass
def restore_material_usage_attributes(cls, element, usage_attributes): pass
def run_geometry_add_representation(cls, obj=None, context=None, ifc_representation_class=None, profile_set_usage=None): pass
def run_geometry_switch_representation(cls, obj=None, representation=None): pass
+28 -9
View File
@@ -49,21 +49,40 @@ class Aggregate(bonsai.core.tool.Aggregate):
related_object = tool.Ifc.get_entity(related_obj)
if not relating_object or not related_object:
return False
if relating_object == related_object:
return False
is_compatible_class = False
if (relating_object.is_a("IfcElement") or relating_object.is_a("IfcElementType")) and related_object.is_a(
"IfcElement"
):
return True
if tool.Ifc.get_schema() == "IFC2X3":
is_compatible_class = True
elif tool.Ifc.get_schema() == "IFC2X3":
if relating_object.is_a("IfcSpatialStructureElement") and related_object.is_a("IfcSpatialStructureElement"):
return True
if relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialStructureElement"):
return True
is_compatible_class = True
elif relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialStructureElement"):
is_compatible_class = True
else:
if relating_object.is_a("IfcSpatialElement") and related_object.is_a("IfcSpatialElement"):
return True
if relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialElement"):
return True
return False
is_compatible_class = True
elif relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialElement"):
is_compatible_class = True
if not is_compatible_class:
return False
# Prevent cyclic references: walk up the full hierarchy from the
# proposed parent and reject if we encounter the proposed child.
ancestor = ifcopenshell.util.element.get_parent(relating_object)
seen = {relating_object}
while ancestor:
if ancestor == related_object:
return False
if ancestor in seen:
break
seen.add(ancestor)
ancestor = ifcopenshell.util.element.get_parent(ancestor)
return True
@classmethod
def has_physical_body_representation(cls, element: ifcopenshell.entity_instance) -> bool:
+6 -3
View File
@@ -32,6 +32,8 @@ import bonsai.core.tool
import bonsai.tool as tool
if TYPE_CHECKING:
from bsdd.bsdd import ClassContractV1, ClassPropertyContractV1, PropertyContractV5
from bonsai.bim.module.bsdd.prop import BIMBSDDProperties, BSDDDictionary
@@ -39,8 +41,8 @@ class Bsdd(bonsai.core.tool.Bsdd):
default_identifier_url = "https://identifier.buildingsmart.org"
default_api_url = "https://api.bsdd.buildingsmart.org/api/"
client = bsdd.Client()
bsdd_classes: dict[str, dict] = {}
bsdd_properties: dict[str, dict] = {}
bsdd_classes: dict[str, ClassContractV1] = {}
bsdd_properties: dict[str, ClassPropertyContractV1 | PropertyContractV5] = {}
@classmethod
def identifier_url(cls) -> str:
@@ -267,7 +269,8 @@ class Bsdd(bonsai.core.tool.Bsdd):
@classmethod
def get_bsdd_property(cls, uri: str) -> dict:
if not (bsdd_property := cls.bsdd_properties.get(uri, {})):
bsdd_property = cls.client.get_property(uri, include_classes=True)
# Cache miss occurs for keyword search mode, for classes cache is prepopulated.
bsdd_property = cls.client.get_property(uri)
cls.bsdd_properties[uri] = bsdd_property
return bsdd_property
+1 -1
View File
@@ -154,7 +154,7 @@ class Cost(bonsai.core.tool.Cost):
device = aud.Device()
# chaching.mp3 is by Lucish_ CC-BY-3.0 https://freesound.org/people/Lucish_/sounds/554841/
filepath = tool.Blender.get_data_dir_path("chaching.mp3").__str__()
sound = aud.Sound(filepath)
sound = aud.Sound(filepath) # ty:ignore[too-many-positional-arguments]
device.play(sound)
@classmethod
+8
View File
@@ -857,6 +857,8 @@ class Drawing(bonsai.core.tool.Drawing):
@classmethod
def edit_text_literals(cls, obj: bpy.types.Object, literal_attributes: dict) -> None:
if not literal_attributes:
return
assert (element := tool.Ifc.get_entity(obj))
assert (rep := cls.get_annotation_representation(element))
to_remove = [i for i in rep.Items if i.is_a("IfcTextLiteral")]
@@ -1288,6 +1290,12 @@ class Drawing(bonsai.core.tool.Drawing):
def get_representation(cls, element, context):
return ifcopenshell.util.representation.get_representation(element, context)
@classmethod
def set_camera_name(cls, drawing: ifcopenshell.entity_instance, name: str) -> None:
camera = tool.Ifc.get_object(drawing)
if camera and camera.name != name:
camera.name = name
@classmethod
def set_drawing_collection_name(
cls, drawing: ifcopenshell.entity_instance, collection: bpy.types.Collection
+4 -3
View File
@@ -1407,8 +1407,6 @@ class Geometry(bonsai.core.tool.Geometry):
:param representation_item: item to remove.
:param element: item's element. Is used to unmark manual booleans.
"""
# NOTE: we assume it's not the last representation item
# otherwise we probably would need to remove representation too
# NOTE: a lot of shared code with `geometry.remove_representation`
ifc_file = tool.Ifc.get()
shape_aspects: list[ifcopenshell.entity_instance] = []
@@ -1467,7 +1465,10 @@ class Geometry(bonsai.core.tool.Geometry):
cls.remove_representation_items_from_shape_aspect([representation_item], shape_aspect)
if representation:
representation.Items = tuple(set(representation.Items) - {representation_item})
new_items = tuple(set(representation.Items) - {representation_item})
if not new_items:
return
representation.Items = new_items
also_consider = list(consider_inverses)
ifcopenshell.util.element.remove_deep2(ifc_file, representation_item, also_consider=also_consider)
+6 -4
View File
@@ -197,12 +197,16 @@ class Ifc(bonsai.core.tool.Ifc):
if not cls.get():
return
# Clear all per-object msgbus subscriptions at once using the dedicated
# owner. After undo/redo, per-object Python wrappers have new
# identities so clearing by individual obj would miss stale
# subscriptions registered with the old wrappers.
bpy.msgbus.clear_by_owner(bonsai.bim.handler.object_subscription_owner)
for obj in bpy.data.objects:
if obj.library:
continue
bpy.msgbus.clear_by_owner(obj)
element = cls.get_entity(obj)
if not element:
continue
@@ -217,8 +221,6 @@ class Ifc(bonsai.core.tool.Ifc):
if obj.library:
continue
bpy.msgbus.clear_by_owner(obj)
style = cls.get_entity(obj)
if not style:
continue
+1 -1
View File
@@ -284,7 +284,7 @@ class IfcGit:
if re.match("^Ifc", obj.name):
bpy.data.objects.remove(obj, do_unlink=True)
bpy.data.orphans_purge(do_recursive=True)
bpy.data.orphans_purge(do_recursive=True) # ty:ignore[unknown-argument]
settings = import_ifc.IfcImportSettings.factory(bpy.context, path_ifc, logging.getLogger("ImportIFC"))
settings.should_setup_viewport_camera = False
+17 -3
View File
@@ -45,9 +45,23 @@ class Nest(bonsai.core.tool.Nest):
related_object = tool.Ifc.get_entity(related_obj)
if not relating_object or not related_object:
return False
if relating_object.is_a("IfcElement") and related_object.is_a("IfcElement"):
return True
return False
if relating_object == related_object:
return False
is_compatible_class = relating_object.is_a("IfcElement") and related_object.is_a("IfcElement")
if not is_compatible_class:
return False
# Prevent cyclic references: walk up the full hierarchy from the
# proposed parent and reject if we encounter the proposed child.
ancestor = ifcopenshell.util.element.get_parent(relating_object)
seen = {relating_object}
while ancestor:
if ancestor == related_object:
return False
if ancestor in seen:
break
seen.add(ancestor)
ancestor = ifcopenshell.util.element.get_parent(ancestor)
return True
@classmethod
def disable_editing(cls, obj: bpy.types.Object) -> None:
+614 -13
View File
@@ -16,6 +16,9 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import math
from typing import Union
import bmesh
@@ -41,6 +44,7 @@ class Raycast(bonsai.core.tool.Raycast):
(0, -offset),
(offset, -offset),
)
snap_objs = []
@classmethod
def get_visible_objects(cls, context: bpy.types.Context):
@@ -69,8 +73,14 @@ class Raycast(bonsai.core.tool.Raycast):
rv3d = context.region_data
assert rv3d
view_location = rv3d.view_matrix.inverted().translation
view_normal = rv3d.view_rotation @ mathutils.Vector((0.0, 0.0, -1.0))
obj_matrix = obj.matrix_world.copy()
bbox = [obj_matrix @ Vector(v) for v in obj.bound_box]
bbox_edges = [
(0,1),(1,2),(2,3),(3,0),
(4,5),(5,6),(6,7),(7,4),
(0,4),(1,5),(2,6),(3,7)
]
transposed_bbox: list[Vector] = []
bbox_2d: list[float] = []
@@ -94,8 +104,23 @@ class Raycast(bonsai.core.tool.Raycast):
for v in bbox:
coord_2d = tool.Cad.location_3d_to_region_2d_np(context.region, context.space_data.region_3d, v)
if coord_2d is not None:
transposed_bbox.append(coord_2d)
transposed_bbox.append(coord_2d)
if not any(transposed_bbox):
transposed_bbox = []
# If there are None values in transposed_bbox it means that there are vertices behind the camera
# so we get the intersection of the edge with the region border
# new_bbox = []
if any(transposed_bbox) and not all(transposed_bbox):
new_bbox = transposed_bbox.copy()
new_bbox = [x for x in new_bbox if x is not None]
for edge in bbox_edges:
if (transposed_bbox[edge[0]] is None) ^ (transposed_bbox[edge[1]] is None):
point, _ = cls.intersect_edge_region_border(context.region, context.space_data, rv3d, bbox[edge[0]], bbox[edge[1]])
if point:
new_bbox.append(point)
if new_bbox:
transposed_bbox = new_bbox
region = context.region
borders = (0, region.width, 0, region.height)
@@ -117,6 +142,99 @@ class Raycast(bonsai.core.tool.Raycast):
return (obj, bbox_2d)
return None
def intersect_edge_region_border(region, space, rv3d, v1, v2):
def segment_intersect_near_plane(view_matrix, clip_start, p_world_a, p_world_b):
a_view = view_matrix @ p_world_a
b_view = view_matrix @ p_world_b
z_near = -clip_start
za = a_view.z
zb = b_view.z
denom = (zb - za)
if denom == 0.0:
return None, None
t = (z_near - za) / denom
if t < 0.0 or t > 1.0:
return None, None
p_view = a_view.lerp(b_view, t)
cam_world = view_matrix.inverted()
p_world = cam_world @ p_view
return p_world, t
def is_inside_region(pt2d, region):
return 0.0 <= pt2d.x <= region.width and 0.0 <= pt2d.y <= region.height
def clamp_to_region_border(point2d, region):
x, y = point2d
x_clamped = max(0.0, min(region.width, x))
y_clamped = max(0.0, min(region.height, y))
return Vector((x_clamped, y_clamped))
def find_nearby_onscreen_point(region, rv3d, p1, p2, initial_t_on_segment, max_iters=40, step=0.05):
"""
Use iterative approach: move t toward 0. Returns the first point that is inside region border
"""
t = initial_t_on_segment
for i in range(max_iters):
test_3d = p1.lerp(p2, t)
test_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, test_3d)
if test_2d is not None and is_inside_region(test_2d, region):
return test_3d, test_2d, t
# move t toward 0 by reducing it by a fraction of its current value
t -= step
# if t is already very small, break
if t <= 1e-6:
break
return None, None, None
# Ensures that all the calculation uses the same direction based on which point is on the screen
if view3d_utils.location_3d_to_region_2d(region, rv3d, v1):
onscreen_vert = v1
offscreen_vert = v2
else:
onscreen_vert = v2
offscreen_vert = v1
# v2, v1 = v1, v2
clip_start = space.clip_start
view_mat = rv3d.view_matrix
inter_world, t_on_ab = segment_intersect_near_plane(view_mat, clip_start, onscreen_vert, offscreen_vert)
if inter_world is None:
print("No intersection with viewport near plane found for the segment.")
return
init_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, inter_world)
if init_2d is not None and is_inside_region(init_2d, region):
final_world = inter_world
final_2d = init_2d
final_t = initial_t
else:
found_world, found_2d, found_t = find_nearby_onscreen_point(
region, rv3d,
onscreen_vert, offscreen_vert,
t_on_ab,
max_iters=600, step=0.01
)
if found_world is None:
if init_2d is None:
print("Initial projection invalid and iterative search failed.")
return
# fallback: clamp projected point to border via manual mapping
final_2d = clamp_to_region_border(init_2d, region)
final_world = None
final_t = None
# print("Iterative search failed; using clamped 2D:", final_2d)
else:
final_world = found_world
final_2d = found_2d
final_t = found_t
# print(f"Found onscreen point at t={final_t:.4f}")
# print("Final 2D:", final_2d)
return final_2d, v2
@classmethod
def intersect_mouse_2d_bounding_box(cls, mouse_pos: tuple[int, int], bbox: list[float, float, float, float]):
x, y = mouse_pos
@@ -232,6 +350,161 @@ class Raycast(bonsai.core.tool.Raycast):
else:
return None, None, None
@classmethod
def ray_cast_by_proximity_2d(
cls,
context: bpy.types.Context,
event: bpy.types.Event,
snap_obj: SnapObj,
):
def divide_vector(start, end, n):
points = []
delta = (end - start) / n
for i in range(1, n):
point = start + i * delta
points.append(point)
return points
region = context.region
rv3d = context.region_data
mouse_pos = event.mouse_region_x, event.mouse_region_y
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
points = []
try:
loc = tool.Cad.region_2d_to_location_3d_np(region, rv3d, mouse_pos, ray_direction)
except:
loc = Vector((0, 0, 0))
verts_2d = [
view3d_utils.location_3d_to_region_2d(region, rv3d, v) for v in snap_obj.verts_3d
] # Numpy version is worst in performance
intersected = snap_obj.raycast_boxes(
context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction)
)
edges = []
for it in intersected:
edges.extend(it.edges)
edges = set(edges)
edge_verts = {}
for e in edges:
verts_idx = tuple(snap_obj.obj.data.edges[e].vertices)
verts = snap_obj.obj.data.vertices
v1 = snap_obj.obj.matrix_world @ verts[verts_idx[0]].co
v1_2d = verts_2d[verts_idx[0]]
v2 = snap_obj.obj.matrix_world @ verts[verts_idx[1]].co
v2_2d = verts_2d[verts_idx[1]]
if (v1_2d is None) ^ (v2_2d is None):
point, _ = cls.intersect_edge_region_border(region, context.space_data, rv3d, v1, v2)
if v1_2d is None:
edge_verts[e] = (point, v2_2d)
else:
edge_verts[e] = (v1_2d, point)
else:
edge_verts[e] = (v1_2d, v2_2d)
snap_threshold = 10.0
for i, point in enumerate(verts_2d):
if not point:
continue
distance = (Vector(mouse_pos) - point).length
if distance <= snap_threshold:
snap_point = {
"object": snap_obj.obj,
"type": "Vertex",
"point": snap_obj.verts_3d[i],
"distance": distance / 10,
}
points.append(snap_point)
count = 0
selected_edges = {}
for e in edges:
p0, p1 = edge_verts[e]
p0x, p0y = p0
p1x, p1y = p1
px, py = mouse_pos
# segment vector = p1 - p0
sx = p1x - p0x
sy = p1y - p0y
# seg length squared
seg_len_sq = sx * sx + sy * sy
if seg_len_sq == 0.0:
# degenerate segment: return distance to p0
dx = px - p0x
dy = py - p0y
dist = math.hypot(dx, dy)
return dist, (p0x, p0y), 0.0
# project (p - p0) onto seg: t = dot(p-p0, seg) / |seg|^2
apx = px - p0x
apy = py - p0y
t = (apx * sx + apy * sy) / seg_len_sq
# clamp to segment
if t <= 0.0:
t_clamped = 0.0
cx, cy = p0x, p0y
elif t >= 1.0:
t_clamped = 1.0
cx, cy = p1x, p1y
else:
t_clamped = t
cx = p0x + sx * t_clamped
cy = p0y + sy * t_clamped
dx = px - cx
dy = py - cy
dist = math.hypot(dx, dy)
if dist <= snap_threshold:
selected_edges[dist] = e
if selected_edges:
min_dist = float("inf")
for key in selected_edges:
if key < min_dist:
min_dist = key
idx = snap_obj.obj.data.edges[selected_edges[min_dist]].vertices
edge_verts = (snap_obj.verts_3d[idx[0]], snap_obj.verts_3d[idx[1]])
division_points = divide_vector(
edge_verts[0], edge_verts[1], 2
) # TODO Make it work for different divisions
for division_point in division_points:
intersection = tool.Cad.point_on_edge(division_point, (ray_target, loc))
distance = (division_point - intersection).length
if distance < snap_threshold:
snap_point = {
"object": snap_obj.obj,
"type": "Edge Center",
"point": division_point.copy(),
"distance": distance,
}
points.append(snap_point)
intersection = tool.Cad.intersect_edges_v2((ray_target, loc), edge_verts)
if intersection[0]:
if tool.Cad.is_point_on_edge(intersection[1], edge_verts):
distance = (intersection[1] - intersection[0]).length
if distance < snap_threshold:
snap_point = {
"object": snap_obj.obj,
"type": "Edge",
"point": intersection[1].copy(),
"edge_verts": edge_verts,
"distance": distance,
}
points.append(snap_point)
return points
@classmethod
def ray_cast_by_proximity(
cls,
@@ -457,7 +730,8 @@ class Raycast(bonsai.core.tool.Raycast):
if bbox_2d:
if tool.Raycast.intersect_mouse_2d_bounding_box(mouse_pos, bbox_2d):
if tool.Raycast.object_is_visible_in_clipping_plane(obj):
objs_to_raycast.append(obj)
snap_obj = cls.create_snap_obj(obj)
objs_to_raycast.append(snap_obj)
return objs_to_raycast
@@ -474,12 +748,6 @@ class Raycast(bonsai.core.tool.Raycast):
face_index = None
# Wireframes
if obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0):
snap_points = tool.Raycast.ray_cast_by_proximity(context, event, obj)
if snap_points:
hit = sorted(snap_points, key=lambda x: x["distance"])[0]["point"]
if hit:
hit_world = obj.original.matrix_world @ hit
return obj, hit_world, face_index
return None, None, None
# Meshes
else:
@@ -514,19 +782,20 @@ class Raycast(bonsai.core.tool.Raycast):
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
for obj in objs_to_raycast:
for snap_obj in objs_to_raycast:
if not include_wireframes and (
obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0)
snap_obj.obj.type in {"EMPTY", "CURVE"}
or (hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0)
):
continue
snap_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, obj)
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
if hit is not None:
length_squared = (hit - ray_origin).length_squared
if best_obj is None or length_squared < best_length_squared:
best_length_squared = length_squared
best_obj = snap_obj
best_obj = hit_obj
best_hit = hit
best_face_index = face_index
@@ -536,6 +805,81 @@ class Raycast(bonsai.core.tool.Raycast):
else:
return None, None, None
@classmethod
def ray_cast_and_get_closest_to_camera_snaps(
cls,
context: bpy.types.Context,
event: bpy.types.Event,
objs_to_raycast: list[bpy.types.Object],
) -> Union[tuple[bpy.types.Object, Vector, int], tuple[None, None, None]]:
closest_length_squared = 1.0
closest_obj = None
closest_hit = None
closest_face_index = None
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
closest_snaps = []
hit = None
for snap_obj in objs_to_raycast:
if (snap_obj.obj.type in {"EMPTY", "CURVE"}
or (hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0)
):
# For wireframe objects we have to test all the snaps to see which is closer
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
closest_wf_hit = None
closest_wf_length_squared = 1.0
closest_wf_point = None
if snap_points:
for point in snap_points:
point["group"] = "Wireframe"
closest_snaps.append(point)
length = (point["point"] - ray_origin).length_squared
if closest_wf_hit is None or length < closest_wf_length_squared:
closest_wf_length_squared = length
closest_wf_hit = point["point"]
closest_wf_point = point
if closest_wf_point:
hit_obj = closest_wf_point["object"]
hit = closest_wf_point["point"]
face_index = None
else:
# Solid objects
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
if hit:
snap_point = {
"point": hit,
"type": "Face",
"group": "Object",
"object": hit_obj,
"face_index": face_index,
"distance": 9, # High value so it has low priority
}
closest_snaps.append(snap_point)
# Here we test which is closer, including wireframe and solid objects
if hit is not None:
length_squared = (hit - ray_origin).length_squared
if closest_obj is None or length_squared < closest_length_squared:
closest_length_squared = length_squared
closest_obj = hit_obj
closest_hit = hit
closest_face_index = face_index
# Label snaps from the closest object
if closest_obj is not None:
for snap in closest_snaps:
if snap["object"] == closest_obj:
snap["is_closest_to_camera"] = True
return closest_snaps
@classmethod
def calculate_snap_threshold(cls, view_distance):
snap_threshold = view_distance / 100
@@ -547,3 +891,260 @@ class Raycast(bonsai.core.tool.Raycast):
if lens < 50:
snap_threshold *= value
return snap_threshold
@classmethod
def create_snap_obj(cls, obj):
for snap_obj in cls.snap_objs:
if obj.name == snap_obj.obj.name:
return snap_obj
snap_obj = SnapObj(obj)
cls.snap_objs.append(snap_obj)
return snap_obj
@classmethod
def clear_snap_objs(cls):
TreeNode.__clear_all__()
SnapObj.__clear_all__()
cls.snap_objs.clear()
class TreeNode:
all = []
def __init__(self, box: tuple):
self.__class__.all.append(self)
self.box = box
self.child_a = None
self.child_b = None
self.edges = []
def __clear_all__():
for instance in TreeNode.all:
del instance
TreeNode.all.clear()
class SnapObj:
max_depth = 9
all = []
def __init__(self, obj: bpy.types.Object):
self.__class__.all.append(self)
self.obj = obj
self.root = self._create_root_node()
self.root.edges = [e.index for e in obj.data.edges]
self.split_box(self.root, 0)
self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices]
self.snap_points = []
def __clear_all__():
for instance in SnapObj.all:
del instance
SnapObj.all.clear()
def _create_root_node(self) -> TreeNode:
bbox = tool.Blender.get_object_bounding_box(self.obj)
min_point = self.obj.matrix_world @ bbox["min_point"]
max_point = self.obj.matrix_world @ bbox["max_point"]
new_bbox = self.expand_bounding_box((min_point, max_point))
return TreeNode(new_bbox)
def divide_bounding_box_along_longest_axis(
self, min_pt: Vector, max_pt: Vector
) -> Union[tuple[Vector, Vector], tuple[Vector, Vector]]:
"""
Divide a bounding box into two equal parts along the axis with the longest dimension.
Args:
min_pt: The minimum point of the bounding box.
max_pt: The maximum point of the bounding box.
Returns:
list: A list of two tuples, each containing the minimum and maximum points of the divided boxes.
"""
# Calculate the dimensions of the box
dx = max_pt.x - min_pt.x
dy = max_pt.y - min_pt.y
dz = max_pt.z - min_pt.z
# Determine the axis with the longest dimension
if dx >= dy and dx >= dz:
# Divide along the x-axis
mid_x = min_pt.x + dx / 2
box1 = (min_pt, Vector((mid_x, max_pt.y, max_pt.z)))
box2 = (Vector((mid_x, min_pt.y, min_pt.z)), max_pt)
elif dy >= dx and dy >= dz:
# Divide along the y-axis
mid_y = min_pt.y + dy / 2
box1 = (min_pt, Vector((max_pt.x, mid_y, max_pt.z)))
box2 = (Vector((min_pt.x, mid_y, min_pt.z)), max_pt)
else:
# Divide along the z-axis
mid_z = min_pt.z + dz / 2
box1 = (min_pt, Vector((max_pt.x, max_pt.y, mid_z)))
box2 = (Vector((min_pt.x, min_pt.y, mid_z)), max_pt)
return [box1, box2]
def expand_bounding_box(self, box: tuple[Vector, Vector], offset: float = 0.1) -> tuple[Vector, Vector]:
"""
Expand a 3D bounding box by a given offset.
Args:
min_pt: The minimum point of the bounding box.
max_pt: The maximum point of the bounding box.
offset: The offset to expand the bounding box by.
Returns:
tuple: A tuple containing the new minimum and maximum points of the expanded bounding box.
"""
min_pt, max_pt = box
# Calculate the new minimum and maximum points
new_min_pt = Vector((min_pt.x - offset, min_pt.y - offset, min_pt.z - offset))
new_max_pt = Vector((max_pt.x + offset, max_pt.y + offset, max_pt.z + offset))
return new_min_pt, new_max_pt
def split_box(self, parent: TreeNode, depth: int):
"""
Splits the bounding box creating two child nodes to compose a BVH Tree recursively.
Args:
parent: the TreeNode instance that represents the parent node of a BVH Tree.
depth: the depth of the BVH Tree no be used in recursion.
"""
if depth > self.max_depth:
return
box_a, box_b = self.divide_bounding_box_along_longest_axis(parent.box[0], parent.box[1])
parent.child_a = TreeNode(box_a)
parent.child_b = TreeNode(box_b)
edges_a = []
edges_b = []
for e in parent.edges:
verts_idx = [v for v in self.obj.data.edges[e].vertices]
verts_coords = []
for idx in verts_idx:
if idx < len(self.obj.data.vertices):
verts_coords.append(self.obj.matrix_world @ self.obj.data.vertices[idx].co)
if self.line_intersects_box(verts_coords[0], verts_coords[1], parent.child_a.box):
edges_a.append(e)
if self.line_intersects_box(verts_coords[0], verts_coords[1], parent.child_b.box):
edges_b.append(e)
parent.child_a.edges = edges_a
parent.child_b.edges = edges_b
self.split_box(parent.child_a, depth + 1)
self.split_box(parent.child_b, depth + 1)
def raycast_box(
self, context: bpy.types.Context, event: bpy.types.Event, node: TreeNode, rays: tuple[Vector, Vector]
) -> bool:
"""
Raycast bounding box.
Args:
context: Blender context.
event: Blender event.
node: a TreeNode instance.
rays: tuple containing ray origin and ray direction
Returns:
True if hits the box or False otherwise.
"""
box = node.box
min_v = box[0]
max_v = box[1]
t_min = 0.0
t_max = float("inf")
ray_origin, ray_dir = rays
inv_dir = Vector((1.0 / r if r != 0.0 else 1e32) for r in (ray_dir.x, ray_dir.y, ray_dir.z))
# X
tx1 = (min_v.x - ray_origin.x) * inv_dir[0]
tx2 = (max_v.x - ray_origin.x) * inv_dir[0]
tmin = min(tx1, tx2)
tmax = max(tx1, tx2)
# Y
ty1 = (min_v.y - ray_origin.y) * inv_dir[1]
ty2 = (max_v.y - ray_origin.y) * inv_dir[1]
tmin = max(tmin, min(ty1, ty2))
tmax = min(tmax, max(ty1, ty2))
# Z
tz1 = (min_v.z - ray_origin.z) * inv_dir[2]
tz2 = (max_v.z - ray_origin.z) * inv_dir[2]
tmin = max(tmin, min(tz1, tz2))
tmax = min(tmax, max(tz1, tz2))
return (tmax >= max(tmin, t_min)) and (tmin <= t_max)
def line_intersects_box(self, v1: mathutils.Vector, v2: mathutils.Vector, box: tuple) -> bool:
"""
Check if a line segment intersects an axis-aligned bounding box (AABB).
Args:
v1: The first endpoint of the line segment as a mathutils.Vector.
v2: The second endpoint of the line segment as a mathutils.Vector.
box: A tuple containing the minimum and maximum points of the AABB, where each point is a mathutils.Vector.
Returns:
bool: True if the segment [v1, v2] intersects the AABB; otherwise, False.
"""
bmin, bmax = box
dir = v2 - v1
tmin = 0.0
tmax = 1.0
for i in range(3):
if abs(dir[i]) < 1e-12:
# Line is parallel to slab. If origin not within slab -> no hit.
if v1[i] < bmin[i] or v1[i] > bmax[i]:
return False
else:
ood = 1.0 / dir[i]
t1 = (bmin[i] - v1[i]) * ood
t2 = (bmax[i] - v1[i]) * ood
if t1 > t2:
t1, t2 = t2, t1
if t1 > tmin:
tmin = t1
if t2 < tmax:
tmax = t2
if tmin > tmax:
return False
# If any overlap in [0,1] exists, there's intersection
return (tmax >= 0.0) and (tmin <= 1.0)
def raycast_boxes(
self,
context: bpy.types.Context,
event: bpy.Types.Event,
node: TreeNode,
intersected: Union[TreeNode] = [],
rays: tuple[Vector, Vector] = (),
) -> Union[TreeNode]:
"""
Raycast bounding box subdivisions recursively.
Args:
context: Blender context.
event: Blender event.
node: a TreeNode instance.
intersected: list of intersected boxes to use in recursion.
rays: tuple containing ray origin and ray direction
Returns:
tuple: a list of TreeNode instances that represent the subdivided boxes hit by the ray cast.
"""
if not node.child_a:
intersected.append(node)
return intersected
intersects_a = self.raycast_box(context, event, node.child_a, rays)
intersects_b = self.raycast_box(context, event, node.child_b, rays)
if intersects_a:
intersected = self.raycast_boxes(context, event, node.child_a, intersected, rays)
if intersects_b:
intersected = self.raycast_boxes(context, event, node.child_b, intersected, rays)
return intersected
+25 -51
View File
@@ -360,6 +360,7 @@ class Snap(bonsai.core.tool.Snap):
plane_normal = tool.Polyline.use_transform_orientations(plane_normal)
return plane_origin, plane_normal
# Polyline
polyline_props = tool.Model.get_polyline_props()
try:
@@ -388,58 +389,31 @@ class Snap(bonsai.core.tool.Snap):
# Objects
objs_to_raycast = tool.Raycast.filter_objects_to_raycast(context, event, objs_2d_bbox)
# Wireframes
# For wireframe we have to get all the objects so we can further calculate edge intersection
closest_snaps = tool.Raycast.ray_cast_and_get_closest_to_camera_snaps(context, event, objs_to_raycast)
detected_snaps.extend(closest_snaps)
xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or (space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe)
for snap_obj in objs_to_raycast:
if snap_obj.type in {"EMPTY", "CURVE"} or (snap_obj.type == "MESH" and len(snap_obj.data.polygons) == 0):
snap_points = tool.Raycast.ray_cast_by_proximity(context, event, snap_obj)
if snap_points:
for point in snap_points:
point["group"] = "Wireframe"
detected_snaps.append(point)
if (space.shading.type == "SOLID" and space.shading.show_xray) or (
space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe
):
results = []
for obj in objs_to_raycast:
results.append(tool.Raycast.cast_rays_to_single_object(context, event, obj))
else:
results = []
results.append(tool.Raycast.cast_rays_and_get_best_object(context, event, objs_to_raycast))
for result in results:
snap_obj = result[0]
hit = result[1]
face_index = result[2]
if hit is not None:
# Wireframes
if snap_obj.type in {"EMPTY", "CURVE"} or (
snap_obj.type == "MESH" and len(snap_obj.data.polygons) == 0
):
continue
# Meshes
else:
# Add face snap
snap_point = {
"point": hit,
"type": "Face",
"group": "Object",
"object": snap_obj,
"face_index": face_index,
"distance": 9, # High value so it has low priority
}
detected_snaps.append(snap_point)
# Add vertex and edge snap
snap_points = tool.Raycast.ray_cast_by_proximity(
context, event, snap_obj, snap_obj.data.polygons[face_index]
)
if snap_points:
for point in snap_points:
point["group"] = "Object"
detected_snaps.append(point)
for snap in closest_snaps:
if snap_obj.obj == snap["object"]:
if xray_mode:
if "face_index" in snap and snap["face_index"] is not None:
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
for point in snap_points:
point["group"] = "Object"
detected_snaps.append(point)
else:
# If it is a solid object that is closest to camera it ignores all the rest
if "is_closest_to_camera" in snap and snap["is_closest_to_camera"] and snap["group"] == "Object":
closest_snap = [snap] # discards objects that aren't the closest
if "face_index" in snap and snap["face_index"] is not None:
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
for point in snap_points:
point["group"] = "Object"
closest_snap.append(point)
detected_snaps = closest_snap
# snap to cut geometry (e.g. in plan view)
if CutDecorator.installed:
cut_snaps = []
+124 -151
View File
@@ -21,13 +21,13 @@ from __future__ import annotations
import json
from collections import defaultdict
from collections.abc import Generator, Iterable
from math import pi
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
import bmesh
import bpy
import ifcopenshell
import ifcopenshell.api.attribute
import ifcopenshell.api.geometry
import ifcopenshell.api.type
import ifcopenshell.geom
import ifcopenshell.util.classification
@@ -991,114 +991,107 @@ class Spatial(bonsai.core.tool.Spatial):
return poly
@classmethod
def get_bmesh_from_polygon(cls, poly: Polygon, h: float, polygon_is_si: bool = False) -> bmesh.types.BMesh:
"""
:param h: Height, in meters.
:param polygon_is_si: Should be True if `poly` is defined in meters.
"""
mat = Matrix()
bm = bmesh.new()
bm.verts.index_update()
bm.edges.index_update()
mat_invert = mat.inverted()
si_conversion = 1.0 if polygon_is_si else ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
new_verts = [
# bm.verts.new(mat_invert @ (Vector([v[0], v[1], 0]) * si_conversion)) for v in poly.exterior.coords[0:-1]
bm.verts.new(mat_invert @ (Vector([v[0], v[1], 0]) * si_conversion))
for v in shapely.get_exterior_ring(poly).coords[0:-1]
]
[bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0]))
bm.verts.index_update()
bm.edges.index_update()
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5)
bmesh.ops.triangle_fill(bm, edges=bm.edges)
bmesh.ops.dissolve_limit(bm, angle_limit=pi / 180 * 5, verts=bm.verts, edges=bm.edges)
if h != 0:
extrusion = bmesh.ops.extrude_face_region(bm, geom=bm.faces)
extruded_verts = [g for g in extrusion["geom"] if isinstance(g, bmesh.types.BMVert)]
bmesh.ops.translate(bm, vec=[0.0, 0.0, h], verts=extruded_verts)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
return bm
@classmethod
def get_named_obj_from_bmesh(cls, name: str, bmesh: bmesh.types.BMesh) -> bpy.types.Object:
mesh = cls.get_named_mesh_from_bmesh(name, bmesh)
obj = cls.get_named_obj_from_mesh(name, mesh)
return obj
@classmethod
def get_named_obj_from_mesh(cls, name: str, mesh: bpy.types.Mesh) -> bpy.types.Object:
def create_object(cls, name: str) -> bpy.types.Object:
mesh = bpy.data.meshes.new(name=name)
obj = bpy.data.objects.new(name, mesh)
return obj
@classmethod
def get_named_mesh_from_bmesh(cls, name: str, bmesh: bmesh.types.BMesh) -> bpy.types.Mesh:
mesh = bpy.data.meshes.new(name=name)
bmesh.to_mesh(mesh)
bmesh.free()
return mesh
def set_obj_origin_to_polygon_center(
cls, obj: bpy.types.Object, poly: Polygon, polygon_is_si: bool = True
) -> None:
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
centroid = poly.centroid
if polygon_is_si:
obj.location = Vector((centroid.x, centroid.y, 0))
else:
obj.location = Vector((centroid.x * unit_scale, centroid.y * unit_scale, 0))
@classmethod
def get_transformed_mesh_from_local_to_global(cls, mesh: bpy.types.Mesh) -> bpy.types.Mesh:
active_obj = cls.get_active_obj()
mat = active_obj.matrix_world
mesh.transform(mat.inverted())
mesh.update()
return mesh
def get_2d_vertices_from_polygon(
cls,
poly: Polygon,
obj: bpy.types.Object,
polygon_is_si: bool = True,
) -> list[list[float]]:
"""Convert a world-space shapely polygon to 2D vertices in obj's local space, in IFC file units.
:param poly: The polygon in world space.
:param obj: The Blender object whose local space is used.
:param polygon_is_si: True if polygon coords are in SI, False if in IFC file units.
:return: List of [x, y] coordinates (not closed).
"""
ifc_file = tool.Ifc.get()
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
bpy.context.view_layer.update()
mat_inv = obj.matrix_world.inverted()
coords_2d = []
for v in shapely.get_exterior_ring(poly).coords[:-1]:
world_si = Vector((v[0], v[1], 0))
if not polygon_is_si:
world_si = world_si * unit_scale
local_si = mat_inv @ world_si
coords_2d.append([local_si.x / unit_scale, local_si.y / unit_scale])
return coords_2d
@classmethod
def edit_active_space_obj_from_mesh(cls, mesh: bpy.types.Mesh) -> None:
active_obj = bpy.context.active_object
old_mesh = active_obj.data
old_mesh_name = old_mesh.name
assert active_obj and isinstance(old_mesh, bpy.types.Mesh)
tool.Geometry.get_mesh_props(mesh).ifc_definition_id = tool.Geometry.get_mesh_props(old_mesh).ifc_definition_id
tool.Geometry.change_object_data(active_obj, mesh, is_global=True)
tool.Ifc.edit(active_obj)
tool.Blender.remove_data_block(old_mesh)
# Rename after old mesh is removed to avoid .001 suffix.
mesh.name = old_mesh_name
def set_extrusion_representation_from_polygon(
cls,
obj: bpy.types.Object,
element: ifcopenshell.entity_instance,
poly: Polygon,
depth_ifc: float,
polygon_is_si: bool = True,
) -> None:
"""Create or replace the IFC body representation from a polygon extrusion.
:param obj: The Blender object.
:param element: The IFC product entity.
:param poly: The polygon in world space.
:param depth_ifc: The extrusion depth in IFC file units.
:param polygon_is_si: True if polygon coords are in SI, False if in IFC file units.
"""
ifc_file = tool.Ifc.get()
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
coords_2d = cls.get_2d_vertices_from_polygon(poly, obj, polygon_is_si)
curve = builder.polyline(coords_2d, closed=True)
item = builder.extrude(curve, magnitude=depth_ifc)
old_body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if old_body:
context = old_body.ContextOfItems
ifcopenshell.api.geometry.unassign_representation(ifc_file, product=element, representation=old_body)
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_body)
else:
context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
new_body = builder.get_representation(context, item)
ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=new_body)
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=new_body,
)
@classmethod
def set_obj_origin_to_bboxcenter(cls, obj: bpy.types.Object) -> None:
mat = obj.matrix_world
inverted = mat.inverted()
local_bbox_center = 0.125 * sum((Vector(b) for b in obj.bound_box), Vector())
global_bbox_center = mat @ local_bbox_center
def set_space_representation_from_polygon(
cls,
obj: bpy.types.Object,
element: ifcopenshell.entity_instance,
poly: Polygon,
h: float,
polygon_is_si: bool = True,
) -> None:
"""Create or replace the IFC body representation of a space from a polygon.
oldLoc = obj.location
newLoc = global_bbox_center
diff = newLoc - oldLoc
for vert in obj.data.vertices:
aux_vector = mat @ vert.co
aux_vector = aux_vector - diff
vert.co = inverted @ aux_vector
obj.location = newLoc
@classmethod
def set_obj_origin_to_bboxcenter_and_zero_elevation(cls, obj: bpy.types.Object) -> None:
mat = obj.matrix_world
inverted = mat.inverted()
local_bbox_center = 0.125 * sum((Vector(b) for b in obj.bound_box), Vector())
global_bbox_center = mat @ local_bbox_center
global_obj_origin = global_bbox_center
global_obj_origin.z = 0
oldLoc = obj.location
newLoc = global_obj_origin
diff = newLoc - oldLoc
for vert in obj.data.vertices:
aux_vector = mat @ vert.co
aux_vector = aux_vector - diff
vert.co = inverted @ aux_vector
obj.location = newLoc
:param h: The height in SI (meters).
"""
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si)
@classmethod
def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj: bpy.types.Object) -> None:
@@ -1145,65 +1138,53 @@ class Spatial(bonsai.core.tool.Spatial):
if z != 0:
obj.location = obj.location + Vector((0, 0, z))
@classmethod
def get_2d_vertices_from_obj(cls, obj: bpy.types.Object) -> list[tuple]:
points = []
vectors = [v.co for v in obj.data.vertices.values()]
for vector in vectors:
points.append(vector.xy)
points.append(vectors[0].xy)
return points
@classmethod
def get_scaled_2d_vertices(cls, points: list[Vector]) -> list[tuple[float, float]]:
model = tool.Ifc.get()
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(model)
_points = []
for p in points:
_p = list(p)
_p[0] /= unit_scale
_p[1] /= unit_scale
_points.append(_p)
return _points
@classmethod
def assign_swept_area_outer_curve_from_2d_vertices(cls, obj: bpy.types.Object, vertices: list[Vector]) -> None:
body = cls.get_body_representation(obj)
model = tool.Ifc.get()
extrusion = tool.Model.get_extrusion(body)
area = extrusion.SweptArea
old_area = area.OuterCurve
builder = ifcopenshell.util.shape_builder.ShapeBuilder(model)
outer_curve = builder.polyline(vertices, closed=True)
area.OuterCurve = outer_curve
ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), old_area)
@classmethod
def get_body_representation(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]:
element = tool.Ifc.get_entity(obj)
return ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
@classmethod
def assign_ifcspace_class_to_obj(cls, obj: bpy.types.Object) -> None:
bpy.ops.bim.assign_class(obj=obj.name, ifc_class="IfcSpace")
bonsai.core.root.assign_class(
tool.Ifc,
tool.Collector,
tool.Root,
obj=obj,
ifc_class="IfcSpace",
should_add_representation=False,
)
@classmethod
def assign_type_to_obj(cls, obj: bpy.types.Object) -> None:
# TODO this code looks in the wrong spot and suspicious
props = tool.Model.get_model_props()
ifc_file = tool.Ifc.get()
relating_type_id = props.relating_type_id
relating_type = tool.Ifc.get().by_id(int(relating_type_id))
relating_type = ifc_file.by_id(int(relating_type_id))
ifc_class = relating_type.is_a()
instance_class = ifcopenshell.util.type.get_applicable_entities(ifc_class, ifc_file.schema)[0]
bpy.ops.bim.assign_class(obj=obj.name, ifc_class=instance_class)
bonsai.core.root.assign_class(
tool.Ifc,
tool.Collector,
tool.Root,
obj=obj,
ifc_class=instance_class,
should_add_representation=False,
)
element = tool.Ifc.get_entity(obj)
assert element
ifcopenshell.api.type.assign_type(ifc_file, related_objects=[element], relating_type=relating_type)
@classmethod
def set_covering_representation_from_polygon(
cls,
obj: bpy.types.Object,
poly: Polygon,
polygon_is_si: bool = True,
) -> None:
"""Create the covering body representation from a polygon, extruded by the type's material layer thickness."""
element = tool.Ifc.get_entity(obj)
relating_type = ifcopenshell.util.element.get_type(element)
material = ifcopenshell.util.element.get_material(relating_type, should_skip_usage=True)
depth = 0.0
if material and material.is_a("IfcMaterialLayerSet"):
depth = sum(layer.LayerThickness for layer in material.MaterialLayers)
cls.set_extrusion_representation_from_polygon(obj, element, poly, depth, polygon_is_si)
@classmethod
def assign_relating_type_to_element(
cls,
@@ -1214,14 +1195,6 @@ class Spatial(bonsai.core.tool.Spatial):
) -> None:
bonsai.core.type.assign_type(ifc, tool.Model, type, element=element, type=relating_type)
@classmethod
def regen_obj_representation(cls, obj: bpy.types.Object, body: ifcopenshell.entity_instance) -> None:
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=body,
)
@classmethod
def set_space_visibility(cls, is_visible: bool) -> None:
@@ -19,4 +19,5 @@ This chapter covers how you can help contribute to Bonsai.
undo_system
writing_docs
debugging
maintenance
ide/index
@@ -0,0 +1,65 @@
Maintenance
===========
This page documents what needs to be updated in various maintenance scenarios.
Python Version Added or Removed
--------------------------------
When adding or removing a supported Python version, update the following:
.. list-table::
:header-rows: 1
* - File
- What to update
* - ``.github/workflows/ci-black-formatting.yaml``
- ``MIN_IOS_PY_VERSION``
* - ``.github/workflows/ci-ifcopenshell-python-pypi.yml``
- ``pyver`` matrix
* - ``.github/workflows/ci-ifcopenshell-python.yml``
- ``pyver`` matrix
* - ``nix/build-all.py``
- ``PYTHON_VERSIONS`` list
* - ``src/bsdd/pyproject.toml``
- ``requires-python``
* - ``src/ifcopenshell-python/docs/ifcopenshell-python/installation.rst``
- add or remove the row in the ZIP packages table
* - ``src/ifcopenshell-python/Makefile``
- ``SUPPORTED_PYVERSIONS``
* - ``src/ifcopenshell-python/pyproject.toml``
- ``requires-python``
* - ``src/ifcopenshell-python/test/test_package.py``
- ``SUPPORTED_PY_VERSIONS`` tuple
* - ``win/build-all-win.py``
- ``PYTHON_VERSIONS`` list
Blender Version Updated
-----------------------
When a new Blender version is released and supported:
.. list-table::
:header-rows: 1
* - File
- What to update
* - ``.github/workflows/ci-bonsai-daily.yml``
- Blender download URL
Blender's Bundled Python Version Updated
-----------------------------------------
When Blender ships with a new Python version:
.. list-table::
:header-rows: 1
* - File
- What to update
* - ``.github/workflows/ci-black-formatting.yaml``
- ``MIN_BLENDER_PY_VERSION``
* - ``src/bonsai/Makefile``
- ``SUPPORTED_PYVERSIONS``
* - ``src/bonsai/scripts/dev_environment.py``
- ``PYTHON_VERSION`` mapping (Blender version, bundled Python version)
+23
View File
@@ -0,0 +1,23 @@
"""Clone or update Bonsai external dependencies.
Must be run from the repository root.
"""
import subprocess
from pathlib import Path
DEPS = [
("https://projects.blender.org/pioverfour/sun_position.git", "sun_position"),
("https://github.com/kevancress/MeasureIt_ARCH", "MeasureIt_ARCH"),
("https://github.com/nortikin/sverchok.git", "sverchok"),
]
base = Path("src/bonsai/external_dependencies")
base.mkdir(parents=True, exist_ok=True)
for url, name in DEPS:
path = base / name
if not path.exists():
subprocess.check_call(["git", "clone", url, str(path)])
else:
subprocess.check_call(["git", "-C", str(path), "pull", "--rebase"])
+2 -2
View File
@@ -273,10 +273,10 @@ if BPY_IS_LOADED:
f"Couldn't find locale path in the source directory, creating dummy directory: {source_locale_path}.",
)
from ui_translate.settings import ( # pyright: ignore[reportMissingImports]
from ui_translate.settings import ( # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]
settings as ui_translate_settings,
)
from ui_translate.update_ui import ( # pyright: ignore[reportMissingImports]
from ui_translate.update_ui import ( # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]
UI_OT_i18n_updatetranslation_init_settings,
)
+3 -1
View File
@@ -23,7 +23,9 @@ import bpy
# sys.path.append('C:\Program Files\Python37\Lib\site-packages')
import lxml.etree
from bspy import Gbxml # pyright: ignore[reportMissingImports]
from bspy import ( # ty: ignore[unresolved-import]
Gbxml, # pyright: ignore[reportMissingImports]
)
class GbxmlExporter:
@@ -22,7 +22,7 @@
from math import pi
from pathlib import Path
import boltspy as bolts # pyright: ignore[reportMissingImports]
import boltspy as bolts # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]
import ifcopenshell.api
import ifcopenshell.api.material
import ifcopenshell.api.project
+1 -1
View File
@@ -31,7 +31,7 @@ import ifcopenshell.api.spatial
import ifcopenshell.api.unit
import ifcopenshell.guid
import numpy as np
import pymeshlab # pyright: ignore[reportMissingImports]
import pymeshlab # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]
class Obj2Ifc:
+1 -1
View File
@@ -31,7 +31,7 @@ import ifcopenshell.api.spatial
import ifcopenshell.api.unit
import ifcopenshell.guid
import numpy as np
import pywavefront # pyright: ignore[reportMissingImports]
import pywavefront # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]
class Obj2Ifc:
+108 -3
View File
@@ -2,7 +2,7 @@
Feature: Covering
Covers covering tool.
Scenario: Execute generate flooring coverings from walls
Scenario: Add flooring from walls
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
@@ -24,9 +24,9 @@ Scenario: Execute generate flooring coverings from walls
And the cursor is at "0,2.0,0"
And I set "scene.BIMModelProperties.length" to "1.9"
And I press "bim.add_occurrence"
# add_instance_flooring_coverings_from_walls is expecting FLOORING predefined type.
# Set COV30 predefined type to FLOORING.
And the object "IfcCoveringType/COV30" is selected
And I look at the "Attributes" panel
And I look at the "Object Attributes" panel
And I click "Edit"
And I set the "PredefinedType" property to "FLOORING"
And I click "Save Attributes"
@@ -42,3 +42,108 @@ Scenario: Execute generate flooring coverings from walls
Then the object "IfcCovering/Covering0" exists
And the object "IfcCovering/Covering0" is at "1.8,1.05,0.0"
And the object "IfcCovering/Covering0" dimensions are "3.4,1.9,0.03"
Scenario: Add ceiling from walls
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
And I press "bim.change_layer_length(length=3.6)"
And the cursor is at "3.6,0.1,3"
And I set "scene.BIMModelProperties.length" to "2.0"
And I press "bim.add_occurrence"
And the cursor is at "3.5,2.1,3"
And I set "scene.BIMModelProperties.length" to "3.5"
And I press "bim.add_occurrence"
And the cursor is at "0,2.0,0"
And I set "scene.BIMModelProperties.length" to "1.9"
And I press "bim.add_occurrence"
# Set COV30 predefined type to CEILING.
And the object "IfcCoveringType/COV30" is selected
And I look at the "Object Attributes" panel
And I click "Edit"
And I set the "PredefinedType" property to "CEILING"
And I click "Save Attributes"
# Run the operator with ceiling height = 2.7 (default).
When the object "IfcWall/Wall" is selected
And additionally the object "IfcWall/Wall.001" is selected
And additionally the object "IfcWall/Wall.002" is selected
And additionally the object "IfcWall/Wall.003" is selected
And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_instance_ceiling_coverings_from_walls"
Then the object "IfcCovering/Covering0" exists
And the object "IfcCovering/Covering0" is at "1.8,1.05,2.7"
And the object "IfcCovering/Covering0" dimensions are "3.4,1.9,0.03"
Scenario: Add flooring from cursor
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
And the cursor is at "1.1,0,0"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall.001" is selected
And I press "bim.hotkey(hotkey='S_R')"
And the cursor is at "0,.9,0"
And I press "bim.add_occurrence"
And the cursor is at "-1,0,0"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall.003" is selected
And I press "bim.hotkey(hotkey='S_R')"
And the object "IfcWall/Wall.003" is moved to "0,0,0"
# Set COV30 predefined type to FLOORING.
And the object "IfcCoveringType/COV30" is selected
And I look at the "Object Attributes" panel
And I click "Edit"
And I set the "PredefinedType" property to "FLOORING"
And I click "Save Attributes"
# Generate covering from cursor inside the room.
When the cursor is at "0.5,0.5,0"
And I deselect all objects
And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_instance_flooring_covering_from_cursor"
Then the object "IfcCovering/Covering" exists
And the object "IfcCovering/Covering" dimensions are "1,0.8,0.03"
Scenario: Add ceiling from cursor
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
And the cursor is at "1.1,0,0"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall.001" is selected
And I press "bim.hotkey(hotkey='S_R')"
And the cursor is at "0,.9,0"
And I press "bim.add_occurrence"
And the cursor is at "-1,0,0"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall.003" is selected
And I press "bim.hotkey(hotkey='S_R')"
And the object "IfcWall/Wall.003" is moved to "0,0,0"
# Set COV30 predefined type to CEILING.
And the object "IfcCoveringType/COV30" is selected
And I look at the "Object Attributes" panel
And I click "Edit"
And I set the "PredefinedType" property to "CEILING"
And I click "Save Attributes"
# Generate covering from cursor inside the room.
When the cursor is at "0.5,0.5,0"
And I deselect all objects
And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_instance_ceiling_covering_from_cursor"
Then the object "IfcCovering/Covering" exists
And the object "IfcCovering/Covering" dimensions are "1,0.8,0.03"
@@ -422,6 +422,24 @@ Scenario: Enable editing material set item
When I press "bim.enable_editing_material_set_item(material_set_item={material_profile})"
Then nothing happens
Scenario: Edit layer item defaults null IsVentilated to FALSE in UI
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I look at the "Class" panel
And I set the "Products" property to "IfcElement"
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
And I press "bim.add_material()"
And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialLayerSet"
And I press "bim.assign_material"
And I press "bim.enable_editing_assigned_material"
And the variable "layer" is "{ifc}.by_type('IfcMaterialLayer')[0].id()"
And I press "bim.enable_editing_material_set_item(material_set_item={layer})"
When I evaluate expression "attrs = bpy.context.active_object.BIMObjectMaterialProperties.material_set_item_attributes; is_vent = next(a for a in attrs if a.name == 'IsVentilated'); assert is_vent.enum_value == 'FALSE'; assert is_vent.is_null is True"
And I press "bim.edit_material_set_item(material_set_item={layer})"
Then I evaluate expression "assert {ifc}.by_id({layer}).IsVentilated is None"
Scenario: Add material set layer
Given an empty IFC project
And I add a cube
+38
View File
@@ -396,6 +396,44 @@ Scenario: Add a slab
And the object "IfcSlab/Slab" bottom left corner is at "0,0,0"
And the object "IfcSlab/Slab" top right corner is at "1,1,0.2"
Scenario: Extend walls to underside
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
And I set "scene.BIMModelProperties.ifc_class" to "IfcSlabType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcSlabType') if e.Name == 'FLR200'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
And the object "IfcSlab/Slab" is moved to "0,0,2.5"
When the object "IfcWall/Wall" is selected
And additionally the object "IfcSlab/Slab" is selected
And I look at the tool header
And I click "Extend To Underside"
Then the object "IfcWall/Wall" dimensions are "1,0.1,2.5"
Scenario: Extend walls to underside - extending to a tessellated gable roof
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
# Create gable roof: a cube turned into a prism with a ridge.
And I add a cube of size "1" at "0.5,0.05,3"
And the object "Cube" is selected
And I evaluate expression "obj = bpy.context.active_object; [setattr(v.co, 'y', 0) for v in obj.data.vertices if v.co.z > 0]"
And I set "scene.BIMRootProperties.ifc_product" to "IfcElement"
And I set "scene.BIMRootProperties.ifc_class" to "IfcRoof"
And I press "bim.assign_class"
When the object "IfcWall/Wall" is selected
And additionally the object "IfcRoof/Cube" is selected
And I look at the tool header
And I click "Extend To Underside"
Then the object "IfcWall/Wall" dimensions are "1,0.1,2.5"
Scenario: Enable editing a slab profile
Given an empty IFC project
And I load the demo construction library
+32 -2
View File
@@ -133,7 +133,11 @@ class PanelSpy:
self.spied_labels.append(kwargs["text"])
return self
elif self.spied_attr == "prop":
props, name = args
if args:
props, name = args
else:
props = kwargs.get("data")
name = kwargs.get("property")
props: bpy.types.bpy_struct
text = kwargs.get("text", props.bl_rna.properties[name].name)
icon = kwargs.get("icon", None)
@@ -242,7 +246,7 @@ class TemplateListItemSpy(PanelSpy):
self.spied_props: list[dict[str, Any]] = []
self.spied_operators: list[dict[str, Any]] = []
if len(signature(blender_panel.draw_item).parameters) == 8:
blender_panel.draw_item(
blender_panel.draw_item( # ty:ignore[missing-argument]
self,
bpy.context,
self,
@@ -390,6 +394,32 @@ def i_look_at_the_panel_panel(panel: str) -> None:
panel_spy.refresh_spy()
@given(parsers.parse("I look at the tool header"))
@when(parsers.parse("I look at the tool header"))
@then(parsers.parse("I look at the tool header"))
def i_look_at_the_tool_header() -> None:
from bonsai.bim.module.model.workspace import EditObjectUI
class MockRegion:
type = "UI"
class MockContext:
def __getattr__(self, name):
if name == "region":
return MockRegion()
return getattr(bpy.context, name)
global panel_spy
panel_spy = PanelSpy(EditObjectUI)
panel_spy.is_spy_dirty = False
panel_spy.spied_attr = None
panel_spy.spied_labels = []
panel_spy.spied_props = []
panel_spy.spied_operators = []
panel_spy.spied_lists = []
EditObjectUI.draw(MockContext(), panel_spy)
@given(parsers.parse('I open the "{name}" menu'))
@when(parsers.parse('I open the "{name}" menu'))
@then(parsers.parse('I open the "{name}" menu'))
+10 -3
View File
@@ -35,9 +35,14 @@ class TestDisableEditingText:
class TestEditText:
def test_run(self, drawing):
drawing.synchronise_ifc_and_text_attributes("obj").should_be_called()
drawing.update_text_size_pset("obj").should_be_called()
drawing.update_text_annotation_properties("obj").should_be_called()
drawing.export_text_literal_attributes("obj").should_be_called().will_return("literal_attributes")
drawing.export_font_size("obj").should_be_called().will_return("font_size")
drawing.edit_text_font_size("obj", "font_size").should_be_called()
drawing.export_wrap_length("obj").should_be_called().will_return("wrap_length")
drawing.edit_text_wrap_length("obj", "wrap_length").should_be_called()
drawing.export_symbol("obj").should_be_called().will_return("symbol")
drawing.edit_text_symbol("obj", "symbol").should_be_called()
drawing.edit_text_literals("obj", "literal_attributes").should_be_called()
drawing.disable_editing_text("obj").should_be_called()
subject.edit_text(drawing, obj="obj")
@@ -466,6 +471,7 @@ class TestRemoveDrawing:
class TestUpdateDrawingName:
def test_do_not_update_if_name_unchanged(self, ifc, drawing):
drawing.get_name("drawing").should_be_called().will_return("name")
drawing.set_camera_name("drawing", "name").should_be_called()
drawing.get_drawing_group("drawing").should_be_called().will_return("group")
drawing.get_name("group").should_be_called().will_return("name")
drawing.get_drawing_collection("drawing").should_be_called().will_return("collection")
@@ -482,6 +488,7 @@ class TestUpdateDrawingName:
def test_run(self, ifc, drawing):
drawing.get_name("drawing").should_be_called().will_return("oldname")
ifc.run("attribute.edit_attributes", product="drawing", attributes={"Name": "name"}).should_be_called()
drawing.set_camera_name("drawing", "name").should_be_called()
drawing.get_drawing_group("drawing").should_be_called().will_return("group")
drawing.get_name("group").should_be_called().will_return("oldname")
ifc.run("attribute.edit_attributes", product="group", attributes={"Name": "name"}).should_be_called()
+4 -2
View File
@@ -23,6 +23,7 @@ from test.core.bootstrap import georeference, ifc
class TestAddGeoreferencing:
def test_run(self, georeference):
georeference.add_georeferencing().should_be_called()
georeference.set_model_origin().should_be_called()
subject.add_georeferencing(georeference)
@@ -35,9 +36,10 @@ class TestEnableEditingGeoreferencing:
class TestRemoveGeoreferencing:
def test_run(self, ifc):
def test_run(self, ifc, georeference):
ifc.run("georeference.remove_georeferencing").should_be_called()
subject.remove_georeferencing(ifc)
georeference.set_model_origin().should_be_called()
subject.remove_georeferencing(ifc, georeference)
class TestDisableEditingGeoreferencing:
+5 -3
View File
@@ -22,8 +22,9 @@ from test.core.bootstrap import geometry, ifc, model, type
class TestAssignType:
def test_assigning_and_switching_to_an_existing_type_data(self, ifc, model, type):
type.record_material_usage_attributes("element").should_be_called().will_return(None)
ifc.run("type.assign_type", related_objects=["element"], relating_type="type").should_be_called()
type.has_material_usage("element").should_be_called().will_return(False)
model.get_usage_type("type").should_be_called(2).will_return(None)
ifc.get_object("type").should_be_called().will_return("type_obj")
type.get_object_data("type_obj").should_be_called().will_return("type_obj_data")
type.change_object_data("obj", "type_obj_data", is_global=False).should_be_called()
@@ -31,9 +32,10 @@ class TestAssignType:
type.disable_editing("obj").should_be_called()
subject.assign_type(ifc, model, type, element="element", type="type")
def test_assigning_and_not_changing_data_if_the_type_has_no_data(self, ifc, type):
def test_assigning_and_not_changing_data_if_the_type_has_no_data(self, ifc, model, type):
type.record_material_usage_attributes("element").should_be_called().will_return(None)
ifc.run("type.assign_type", related_objects=["element"], relating_type="type").should_be_called()
type.has_material_usage("element").should_be_called().will_return(False)
model.get_usage_type("type").should_be_called(2).will_return(None)
ifc.get_object("type").should_be_called().will_return("type_obj")
type.get_object_data("type_obj").should_be_called().will_return(None)
ifc.get_object("element").should_be_called().will_return("obj")
+37
View File
@@ -18,6 +18,7 @@
import bpy
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.context
import ifcopenshell.api.geometry
import ifcopenshell.api.root
@@ -99,6 +100,42 @@ class TestCanAggregate(NewFile):
subelement_obj = bpy.data.objects.new("Object", None)
assert subject.can_aggregate(element_obj, subelement_obj) is False
def test_element_cannot_aggregate_to_itself(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
element = ifc.createIfcElementAssembly()
element_obj = bpy.data.objects.new("Object", None)
tool.Ifc.link(element, element_obj)
assert subject.can_aggregate(element_obj, element_obj) is False
def test_cyclic_aggregation_is_prevented(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
assembly_a = ifc.createIfcElementAssembly()
assembly_a_obj = bpy.data.objects.new("AssemblyA", None)
tool.Ifc.link(assembly_a, assembly_a_obj)
beam = ifc.createIfcBeam()
beam_obj = bpy.data.objects.new("Beam", None)
tool.Ifc.link(beam, beam_obj)
ifcopenshell.api.aggregate.assign_object(ifc, products=[beam], relating_object=assembly_a)
assert subject.can_aggregate(beam_obj, assembly_a_obj) is False
def test_deep_cyclic_aggregation_is_prevented(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
assembly_a = ifc.createIfcElementAssembly()
assembly_a_obj = bpy.data.objects.new("AssemblyA", None)
tool.Ifc.link(assembly_a, assembly_a_obj)
assembly_b = ifc.createIfcElementAssembly()
assembly_b_obj = bpy.data.objects.new("AssemblyB", None)
tool.Ifc.link(assembly_b, assembly_b_obj)
beam = ifc.createIfcBeam()
beam_obj = bpy.data.objects.new("Beam", None)
tool.Ifc.link(beam, beam_obj)
ifcopenshell.api.aggregate.assign_object(ifc, products=[assembly_b], relating_object=assembly_a)
ifcopenshell.api.aggregate.assign_object(ifc, products=[beam], relating_object=assembly_b)
assert subject.can_aggregate(beam_obj, assembly_a_obj) is False
class TestHasPhysicalBodyRepresentation(NewFile):
def test_run(self):
+4
View File
@@ -176,6 +176,7 @@ class TestStairCalculatedParams(NewFile):
pset_data = pset_data_base.copy()
calculated_data = calculated_data_base.copy()
pset_data["custom_first_last_tread_run"] = (0.1, 0.4)
pset_data["custom_tread_lock"] = False
calculated_data["Length"] += -0.2 + 0.1
self.compare_data(pset_data, calculated_data)
@@ -183,6 +184,7 @@ class TestStairCalculatedParams(NewFile):
pset_data = pset_data_base.copy()
calculated_data = calculated_data_base.copy()
pset_data["custom_first_last_tread_run"] = (0.0, None)
pset_data["custom_tread_lock"] = False
calculated_data["Length"] = 0.9 # Only 3 treads at 0.3 each
self.compare_data(pset_data, calculated_data)
@@ -190,6 +192,7 @@ class TestStairCalculatedParams(NewFile):
pset_data = pset_data_base.copy()
calculated_data = calculated_data_base.copy()
pset_data["custom_first_last_tread_run"] = (None, 0.0)
pset_data["custom_tread_lock"] = False
calculated_data["Length"] = 0.9 # Only 3 treads at 0.3 each
self.compare_data(pset_data, calculated_data)
@@ -197,6 +200,7 @@ class TestStairCalculatedParams(NewFile):
pset_data = pset_data_base.copy()
calculated_data = calculated_data_base.copy()
pset_data["custom_first_last_tread_run"] = (0.0, 0.0)
pset_data["custom_tread_lock"] = False
calculated_data["Length"] = 0.6 # Only 2 middle treads at 0.3 each
self.compare_data(pset_data, calculated_data)
+37
View File
@@ -19,6 +19,7 @@
import bpy
import ifcopenshell
import ifcopenshell.api
import ifcopenshell.api.nest
import ifcopenshell.api.spatial
import bonsai.core.tool
@@ -51,6 +52,42 @@ class TestCanNest(NewFile):
subelement_obj = bpy.data.objects.new("Object", None)
assert subject.can_nest(element_obj, subelement_obj) is False
def test_element_cannot_nest_to_itself(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
element = ifc.createIfcWall()
element_obj = bpy.data.objects.new("Object", None)
tool.Ifc.link(element, element_obj)
assert subject.can_nest(element_obj, element_obj) is False
def test_cyclic_nesting_is_prevented(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
wall_a = ifc.createIfcWall()
wall_a_obj = bpy.data.objects.new("WallA", None)
tool.Ifc.link(wall_a, wall_a_obj)
wall_b = ifc.createIfcWall()
wall_b_obj = bpy.data.objects.new("WallB", None)
tool.Ifc.link(wall_b, wall_b_obj)
ifcopenshell.api.nest.assign_object(ifc, related_objects=[wall_b], relating_object=wall_a)
assert subject.can_nest(wall_b_obj, wall_a_obj) is False
def test_deep_cyclic_nesting_is_prevented(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
wall_a = ifc.createIfcWall()
wall_a_obj = bpy.data.objects.new("WallA", None)
tool.Ifc.link(wall_a, wall_a_obj)
wall_b = ifc.createIfcWall()
wall_b_obj = bpy.data.objects.new("WallB", None)
tool.Ifc.link(wall_b, wall_b_obj)
wall_c = ifc.createIfcWall()
wall_c_obj = bpy.data.objects.new("WallC", None)
tool.Ifc.link(wall_c, wall_c_obj)
ifcopenshell.api.nest.assign_object(ifc, related_objects=[wall_b], relating_object=wall_a)
ifcopenshell.api.nest.assign_object(ifc, related_objects=[wall_c], relating_object=wall_b)
assert subject.can_nest(wall_c_obj, wall_a_obj) is False
class TestDisableEditing(NewFile):
def test_run(self):
+1 -1
View File
@@ -365,7 +365,7 @@ class TestLoadingIfcSqlite(NewFile):
sql_type="SQLite",
)
patcher.patch()
tmp_file = Path(tempfile.mktemp(suffix=".ifcsqlite"))
tmp_file = Path(tempfile.mkstemp(suffix=".ifcsqlite")[1])
ifcpatch.write(patcher.get_output(), tmp_file)
elements_with_meshes = [
+41
View File
@@ -0,0 +1,41 @@
aiohttp
beautifulsoup4
boto3
botocore
brickschema
cjio >=0.8, <0.10
debugpy
ezdxf
fake-bpy-module-latest
git+https://github.com/prochitecture/bpypolyskel
git+https://github.com/Andrej730/IFC2JSON_python.git@pyproject_toml
gitpython
isodate
lark
lxml
lxml-stubs
markdown-it-py
natsort
numpy
odfpy
openpyxl
pandas
pillow
platformdirs
pygments
pyradiance
pystache
pytest
pytest_bdd
pytest_blender
python-dateutil
python-socketio
pytz
rdflib
requests
shapely
svgwrite
typing-extensions
typst
tzfpy
xsdata
+1 -1
View File
@@ -7,7 +7,7 @@ from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator
from .type_hints import *
from type_hints import *
def _lower_first(s: str) -> str:
+1
View File
@@ -28,6 +28,7 @@ dist:
mkdir -p dist
cp -r $(PACKAGE_NAME) build/
cp pyproject.toml build/
if [ -f README.md ]; then cp README.md build/; fi
ifeq ($(IS_STABLE), TRUE)
$(SED) 's/version = "0.0.0"/version = "$(VERSION)"/' build/pyproject.toml
ifdef IS_MODULE
+248
View File
@@ -0,0 +1,248 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
# ifcedit
A CLI wrapper that exposes all 350+ `ifcopenshell.api` mutation functions as
shell commands. Functions are auto-discovered at runtime via introspection --
no hardcoded list to maintain.
## Installation
```bash
pip install ifcedit
```
Requires `ifcopenshell`.
## Usage
```
ifcedit <command> [options] [--format json|text]
```
Three subcommands: `list` to discover functions, `docs` to read their
documentation, and `run` to execute them.
## Subcommands
### list
Discover available API modules and their functions.
**List all modules:**
```bash
ifcedit list
```
```json
[
{
"module": "root",
"description": "Functions for creating project-level entities",
"functions": ["create_entity", "remove_product", "copy_class"],
"count": 3
},
{
"module": "spatial",
"description": "Functions for managing spatial relationships",
"functions": ["assign_container", "unassign_container"],
"count": 2
}
]
```
**List functions in a module:**
```bash
ifcedit list root
```
```json
[
{
"name": "create_entity",
"description": "Create an IFC entity with optional initial attributes",
"params": [
{"name": "ifc_class", "type": "str", "required": true},
{"name": "name", "type": "Optional[str]"}
]
}
]
```
### docs
Show full documentation for a specific function, including parameter
descriptions from docstrings and return type.
```bash
ifcedit docs root.create_entity
```
```json
{
"module": "root",
"function": "create_entity",
"description": "Create an IFC entity with optional initial attributes",
"long_description": "This function creates a new entity instance...",
"params": [
{
"name": "ifc_class",
"type": "str",
"required": true,
"description": "The IFC class name (e.g. 'IfcWall', 'IfcProject')"
},
{
"name": "name",
"type": "Optional[str]",
"description": "Optional name attribute"
}
],
"return_type": "ifcopenshell.entity_instance",
"return_description": "The newly created entity instance"
}
```
### run
Execute an API function against an IFC file. Parameters are passed as
`--key value` pairs after the function name.
```bash
ifcedit run model.ifc root.create_entity --ifc_class IfcWall --name "My Wall"
```
```json
{
"ok": true,
"result": {"id": 42, "type": "IfcWall", "name": "My Wall"}
}
```
**Options:**
- `-o, --output <path>` -- write to a different file instead of overwriting the input
- `--dry-run` -- validate parameters without executing or saving
```bash
# Save to a new file
ifcedit run model.ifc root.create_entity -o out.ifc --ifc_class IfcWall
# Validate without executing
ifcedit run model.ifc root.create_entity --dry-run --ifc_class IfcWall
```
Dry-run output shows the resolved parameters:
```json
{
"ok": true,
"dry_run": true,
"module": "root",
"function": "create_entity",
"args": {"ifc_class": "IfcWall", "name": "My Wall"}
}
```
## Parameter type coercion
CLI strings are automatically converted to the types expected by each API
function, using the function's type annotations:
| Type | CLI input | Python value |
|------|-----------|--------------|
| `str` | `"hello"` | `"hello"` |
| `int` | `"42"` or `"#42"` | `42` |
| `float` | `"3.14"` | `3.14` |
| `bool` | `"true"`, `"1"`, `"yes"` | `True` |
| `Optional[X]` | `"none"` | `None` |
| `entity_instance` | `"42"` or `"#42"` | resolved from model by step ID |
| `list[entity_instance]` | `"5,6,7"` or `"[5, 6, 7]"` | list of resolved entities |
| `dict` | `'{"key": "val"}'` | parsed JSON object |
| `Literal["A", "B"]` | `"A"` | validated against allowed values |
## Examples
```bash
# Create a project
ifcedit run model.ifc root.create_entity --ifc_class IfcProject --name "My Project"
# Assign an element to a storey
ifcedit run model.ifc spatial.assign_container --products 10 --relating_structure 4
# Assign multiple elements at once
ifcedit run model.ifc aggregate.assign_object --products "5,6,7" --relating_object 1
# Add a property set
ifcedit run model.ifc pset.add_pset --product 10 --name "Pset_WallCommon"
# Edit properties
ifcedit run model.ifc pset.edit_pset --pset 15 \
--properties '{"IsExternal": true, "FireRating": "2HR"}'
```
### quantify
Run quantity take-off (QTO) on an IFC file, computing physical measurements
(volume, area, length, count, weight) and writing them back as
`IfcElementQuantity` property sets. Uses `ifc5d` rules.
**List available rules:**
```bash
ifcedit quantify list
```
```json
[
{"name": "IFC4QtoBaseQuantities"},
{"name": "IFC4X3QtoBaseQuantities"}
]
```
**Run QTO on a file:**
```bash
ifcedit quantify run model.ifc IFC4QtoBaseQuantities
ifcedit quantify run model.ifc IFC4QtoBaseQuantities --selector IfcWall
ifcedit quantify run model.ifc IFC4QtoBaseQuantities -o model_qto.ifc
```
```json
{"ok": true, "rule": "IFC4QtoBaseQuantities", "elements_quantified": 42}
```
Options:
- `--selector <query>` -- ifcopenshell selector to restrict elements (default: all `IfcElement`)
- `-o, --output <path>` -- write to a different file instead of overwriting the input
Note: `quantify run` writes geometry-based measurements and requires the
IfcOpenShell C++ geometry bindings for elements with computed quantities.
## Error handling
Errors are reported in the JSON response:
```json
{
"ok": false,
"error": "Entity #999 not found in model"
}
```
Exit code is 0 on success, 1 on error.
## Relationship to ifcquery
`ifcedit` and `ifcquery` are complementary tools:
- **ifcquery** reads and inspects IFC models (summary, tree, info, select, relations, clash, validate, schedule, cost, schema, contexts, materials, plot, render)
- **ifcedit** modifies IFC models by wrapping `ifcopenshell.api` functions, and runs QTO via `quantify`
A typical workflow: inspect with `ifcquery`, look up the right API function
with `ifcedit docs`, then apply changes with `ifcedit run`.
## License
LGPLv3+ -- see the IfcOpenShell project license.
+20
View File
@@ -0,0 +1,20 @@
# This file was generated with the assistance of an AI coding tool.
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcEdit.
#
# IfcEdit is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcEdit is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcEdit. If not, see <http://www.gnu.org/licenses/>.
__version__ = version = "0.0.0"
+217
View File
@@ -0,0 +1,217 @@
# This file was generated with the assistance of an AI coding tool.
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcEdit.
#
# IfcEdit is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcEdit is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcEdit. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import argparse
import json
import sys
import ifcopenshell
from ifcedit.discover import function_docs, list_functions, list_modules
from ifcedit.quantify import list_rules, run_quantify
from ifcedit.run import run_api
def format_output(data, fmt: str) -> str:
if fmt == "json":
return json.dumps(data, indent=2, ensure_ascii=False)
elif fmt == "text":
return _format_text(data)
return json.dumps(data, indent=2, ensure_ascii=False)
def _format_text(data, indent: int = 0) -> str:
prefix = " " * indent
lines = []
if isinstance(data, dict):
for key, value in data.items():
if isinstance(value, (dict, list)):
lines.append(f"{prefix}{key}:")
lines.append(_format_text(value, indent + 1))
else:
lines.append(f"{prefix}{key}: {value}")
elif isinstance(data, list):
for item in data:
if isinstance(item, dict):
lines.append(_format_text(item, indent))
lines.append("")
else:
lines.append(f"{prefix}- {item}")
else:
lines.append(f"{prefix}{data}")
return "\n".join(lines)
def cmd_list(args):
if args.module:
try:
functions = list_functions(args.module)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
print(format_output(functions, args.output_format))
else:
modules = list_modules()
print(format_output(modules, args.output_format))
def cmd_docs(args):
parts = args.function_path.split(".")
if len(parts) != 2:
print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr)
sys.exit(1)
module, function = parts
try:
docs = function_docs(module, function)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
print(format_output(docs, args.output_format))
def cmd_run(args, extra_args):
try:
model = ifcopenshell.open(args.ifc_file)
except Exception as e:
print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
sys.exit(1)
parts = args.function_path.split(".")
if len(parts) != 2:
print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr)
sys.exit(1)
module, function = parts
# Parse extra --key value arguments into a dict
raw_kwargs = _parse_extra_args(extra_args)
if args.dry_run:
result = {"ok": True, "dry_run": True, "module": module, "function": function, "args": raw_kwargs}
else:
result = run_api(model, module, function, raw_kwargs)
if result["ok"]:
output_path = args.output or args.ifc_file
model.write(output_path)
print(format_output(result, args.output_format))
if not result["ok"]:
sys.exit(1)
def _parse_extra_args(extra: list[str]) -> dict[str, str]:
"""Parse a list of ['--key', 'value', ...] into a dict."""
kwargs = {}
i = 0
while i < len(extra):
arg = extra[i]
if arg.startswith("--"):
key = arg[2:]
if i + 1 < len(extra) and not extra[i + 1].startswith("--"):
kwargs[key] = extra[i + 1]
i += 2
else:
# Flag without value — treat as "true"
kwargs[key] = "true"
i += 1
else:
print(f"Error: Unexpected argument: {arg}", file=sys.stderr)
sys.exit(1)
return kwargs
def cmd_quantify(args, extra_args):
if args.quantify_command == "list":
result = list_rules()
print(format_output(result, args.output_format))
elif args.quantify_command == "run":
try:
model = ifcopenshell.open(args.ifc_file)
except Exception as e:
print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
sys.exit(1)
selector = args.selector or None
result = run_quantify(model, args.rule_name, selector=selector)
if result["ok"]:
output_path = args.output or args.ifc_file
model.write(output_path)
print(format_output(result, args.output_format))
if not result["ok"]:
sys.exit(1)
else:
print("Error: quantify requires a subcommand: list or run", file=sys.stderr)
sys.exit(1)
def main():
parser = argparse.ArgumentParser(
prog="ifcedit",
description="CLI wrapper for ifcopenshell.api IFC model mutation functions",
)
parser.add_argument(
"--format",
choices=["json", "text"],
default="json",
dest="output_format",
help="Output format (default: json)",
)
subparsers = parser.add_subparsers(dest="command", required=True)
# list
list_parser = subparsers.add_parser("list", help="List API modules or functions in a module")
list_parser.add_argument("module", nargs="?", help="Module name (omit to list all modules)")
# docs
docs_parser = subparsers.add_parser("docs", help="Show full documentation for an API function")
docs_parser.add_argument("function_path", help="module.function (e.g. root.create_entity)")
# run
run_parser = subparsers.add_parser("run", help="Execute an API function on an IFC file")
run_parser.add_argument("ifc_file", help="Path to the IFC file")
run_parser.add_argument("function_path", help="module.function (e.g. root.create_entity)")
run_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
run_parser.add_argument("--dry-run", action="store_true", help="Validate without executing or saving")
# quantify
quantify_parser = subparsers.add_parser("quantify", help="Quantity take-off (QTO) using ifc5d rules")
quantify_sub = quantify_parser.add_subparsers(dest="quantify_command")
quantify_sub.add_parser("list", help="List available QTO rule names")
qrun_parser = quantify_sub.add_parser("run", help="Run QTO on an IFC file")
qrun_parser.add_argument("ifc_file", help="Path to the IFC file")
qrun_parser.add_argument("rule_name", help="QTO rule name (e.g. IFC4QtoBaseQuantities)")
qrun_parser.add_argument("--selector", help="ifcopenshell selector to restrict elements (default: all IfcElement)")
qrun_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
args, extra = parser.parse_known_args()
if args.command == "list":
cmd_list(args)
elif args.command == "docs":
cmd_docs(args)
elif args.command == "run":
cmd_run(args, extra)
elif args.command == "quantify":
cmd_quantify(args, extra)
if __name__ == "__main__":
main()
+180
View File
@@ -0,0 +1,180 @@
# This file was generated with the assistance of an AI coding tool.
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcEdit.
#
# IfcEdit is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcEdit is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcEdit. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import json
import typing
import ifcopenshell
def coerce_value(
value_str: str,
type_hint,
model: ifcopenshell.file | None = None,
lookup_file: ifcopenshell.file | None = None,
):
"""Convert a CLI string argument to the proper Python type based on a type hint.
Args:
value_str: The raw string from the CLI.
type_hint: The type annotation from the function signature.
model: The main open IFC model, needed to resolve entity instance references by ID.
lookup_file: Override file for entity resolution (e.g. a library file for
project.append_asset). When provided, entity IDs are looked up here instead
of in model.
Returns:
The converted Python value.
Raises:
ValueError: If the value cannot be converted.
TypeError: If the type hint is not supported.
"""
# When a library file has been opened, entity IDs are resolved from it, not the main model.
effective_lookup = lookup_file if lookup_file is not None else model
if type_hint is None:
return value_str
origin = typing.get_origin(type_hint)
args = typing.get_args(type_hint)
# Union / Optional
if origin is typing.Union:
non_none_types = [a for a in args if a is not type(None)]
if value_str.lower() == "none":
if type(None) in args:
return None
# Try each non-None type in order
for t in non_none_types:
try:
return coerce_value(value_str, t, model, lookup_file)
except (ValueError, TypeError):
continue
raise ValueError(f"Cannot convert '{value_str}' to any of {non_none_types}")
# Literal
if origin is typing.Literal:
allowed = args
if value_str in [str(a) for a in allowed]:
# return the actual literal value with proper type
for a in allowed:
if str(a) == value_str:
return a
raise ValueError(f"'{value_str}' is not one of: {', '.join(repr(a) for a in allowed)}")
# list types
if origin is list:
if args and _is_entity_type(args[0]):
return _coerce_entity_list(value_str, effective_lookup)
if args:
items = _split_list(value_str)
return [coerce_value(item.strip(), args[0], model, lookup_file) for item in items]
return _split_list(value_str)
# dict types
if origin is dict:
return _floatify_numeric_lists(json.loads(value_str))
# Simple types
if type_hint is str:
return value_str
if type_hint is int:
return int(value_str.lstrip("#"))
if type_hint is float:
return float(value_str)
if type_hint is bool:
return value_str.lower() in ("true", "1", "yes")
# ifcopenshell.file — open from path string
if type_hint is ifcopenshell.file:
return ifcopenshell.open(value_str)
# entity_instance
if _is_entity_type(type_hint):
return _coerce_entity(value_str, effective_lookup)
# Fallback: try json.loads for complex types, then plain string
try:
return json.loads(value_str)
except (json.JSONDecodeError, TypeError):
return value_str
def _is_entity_type(hint) -> bool:
"""Check if a type hint refers to ifcopenshell.entity_instance."""
if hint is ifcopenshell.entity_instance:
return True
if isinstance(hint, type) and issubclass(hint, ifcopenshell.entity_instance):
return True
return False
def _coerce_entity(value_str: str | int, lookup_file: ifcopenshell.file | None) -> ifcopenshell.entity_instance:
"""Resolve a step ID string like '123' or '#123' to an entity instance."""
if lookup_file is None:
raise ValueError("Cannot resolve entity reference without an IFC model")
if isinstance(value_str, int):
entity_id = value_str
else:
entity_id = int(value_str.strip().lstrip("#"))
try:
return lookup_file.by_id(entity_id)
except RuntimeError:
raise ValueError(f"Entity #{entity_id} not found in model")
def _coerce_entity_list(value_str: str, lookup_file: ifcopenshell.file | None) -> list[ifcopenshell.entity_instance]:
"""Resolve a comma-separated list of step IDs to entity instances."""
items = _split_list(value_str)
return [_coerce_entity(item.strip(), lookup_file) for item in items]
def _floatify_numeric_lists(obj):
"""Recursively convert lists of numbers to lists of floats.
IFC C++ bindings require Python floats (not ints) for AGGREGATE OF DOUBLE
attributes (e.g. DirectionRatios, Coordinates). JSON parsing produces ints
for whole numbers like 0, which causes a TypeError at the binding level.
"""
if isinstance(obj, dict):
return {k: _floatify_numeric_lists(v) for k, v in obj.items()}
if (
isinstance(obj, list)
and obj
and all(isinstance(v, (int, float)) for v in obj)
and any(isinstance(v, float) for v in obj)
):
return [float(v) for v in obj]
return obj
def _split_list(value_str: str) -> list[str]:
"""Split a comma-separated string, handling JSON arrays too."""
value_str = value_str.strip()
if value_str.startswith("["):
try:
parsed = json.loads(value_str)
if isinstance(parsed, list):
return [json.dumps(item) if isinstance(item, (dict, list)) else str(item) for item in parsed]
except json.JSONDecodeError:
pass
return [item.strip() for item in value_str.split(",") if item.strip()]
+282
View File
@@ -0,0 +1,282 @@
# This file was generated with the assistance of an AI coding tool.
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcEdit.
#
# IfcEdit is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcEdit is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcEdit. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import importlib
import inspect
import re
import typing
from pathlib import Path
def _api_package_path() -> Path:
"""Return the filesystem path to the ifcopenshell.api package."""
import ifcopenshell.api
return Path(ifcopenshell.api.__file__).parent
def list_modules() -> list[dict]:
"""List all API modules with their function counts and descriptions.
Returns a list of dicts: [{"module": "root", "description": "...", "functions": [...], "count": 4}, ...]
"""
api_path = _api_package_path()
modules = []
for child in sorted(api_path.iterdir()):
if not child.is_dir() or child.name.startswith("_"):
continue
init_file = child / "__init__.py"
if not init_file.exists():
continue
try:
mod = importlib.import_module(f"ifcopenshell.api.{child.name}")
except Exception:
continue
all_names = getattr(mod, "__all__", [])
if not all_names:
continue
description = ""
if mod.__doc__:
description = mod.__doc__.strip().split("\n")[0]
modules.append(
{
"module": child.name,
"description": description,
"functions": list(all_names),
"count": len(all_names),
}
)
return modules
def list_functions(module: str) -> list[dict]:
"""List functions in an API module with one-line descriptions and parameter info.
Returns a list of dicts: [{"name": "create_entity", "description": "...", "params": [...]}]
"""
mod = importlib.import_module(f"ifcopenshell.api.{module}")
all_names = getattr(mod, "__all__", [])
functions = []
for name in all_names:
fn = _get_underlying_function(module, name)
if fn is None:
continue
description = ""
if fn.__doc__:
description = fn.__doc__.strip().split("\n")[0]
params = _extract_params(fn)
functions.append(
{
"name": name,
"description": description,
"params": params,
}
)
return functions
def function_docs(module: str, function: str) -> dict:
"""Full documentation for a single API function.
Returns a dict with: module, function, description, params (with types/defaults/descriptions), return_type
"""
fn = _get_underlying_function(module, function)
if fn is None:
raise ValueError(f"Function '{module}.{function}' not found")
description = ""
long_description = ""
if fn.__doc__:
description, long_description = _parse_docstring_body(fn.__doc__)
params = _extract_params(fn)
param_descriptions = _parse_param_docs(fn.__doc__ or "")
for param in params:
if param["name"] in param_descriptions:
param["description"] = param_descriptions[param["name"]]
return_type = _format_type_hint(typing.get_type_hints(fn).get("return"))
return_description = _parse_return_doc(fn.__doc__ or "")
result = {
"module": module,
"function": function,
"description": description,
"long_description": long_description,
"params": params,
}
if return_type:
result["return_type"] = return_type
if return_description:
result["return_description"] = return_description
return result
def _get_underlying_function(module: str, function: str):
"""Get the actual function object (unwrapping the listener wrapper if needed)."""
try:
fn_module = importlib.import_module(f"ifcopenshell.api.{module}.{function}")
fn = getattr(fn_module, function, None)
return fn
except (ImportError, AttributeError):
return None
def _extract_params(fn) -> list[dict]:
"""Extract parameter info from a function's signature and type hints."""
sig = inspect.signature(fn)
try:
hints = typing.get_type_hints(fn)
except Exception:
hints = {}
params = []
for name, param in sig.parameters.items():
if name == "file" or name == "self":
continue
info = {"name": name}
if name in hints:
info["type"] = _format_type_hint(hints[name])
if param.default is not inspect.Parameter.empty:
info["default"] = _serialize_default(param.default)
else:
info["required"] = True
params.append(info)
return params
def _format_type_hint(hint) -> str | None:
"""Format a type hint to a readable string."""
import ifcopenshell
if hint is None:
return None
if hint is type(None):
return "None"
# ifcopenshell.file params are passed as a file path string
if hint is ifcopenshell.file:
return "file_path"
origin = typing.get_origin(hint)
args = typing.get_args(hint)
# Union (including Optional)
if origin is typing.Union:
formatted = [_format_type_hint(a) for a in args]
# Optional[X] is Union[X, None] — render as "Optional[X]"
if len(formatted) == 2 and "None" in formatted:
inner = next(f for f in formatted if f != "None")
return f"Optional[{inner}]"
return " | ".join(formatted)
# Literal
if origin is typing.Literal:
values = ", ".join(repr(a) for a in args)
return f"Literal[{values}]"
# Generic types (list, dict, etc.)
if origin is not None:
origin_name = getattr(origin, "__name__", str(origin))
if args:
inner = ", ".join(_format_type_hint(a) for a in args)
return f"{origin_name}[{inner}]"
return origin_name
# Simple types
return getattr(hint, "__name__", str(hint))
def _serialize_default(value):
"""Serialize a default value to something JSON-friendly."""
if value is None:
return None
if isinstance(value, (str, int, float, bool)):
return value
return repr(value)
def _parse_docstring_body(docstring: str) -> tuple[str, str]:
"""Parse the summary and long description from a docstring."""
lines = docstring.strip().split("\n")
summary = lines[0].strip() if lines else ""
body_lines = []
in_body = False
for line in lines[1:]:
stripped = line.strip()
if stripped.startswith(":param") or stripped.startswith(":return"):
break
if stripped.startswith("Example"):
break
if not in_body and not stripped:
in_body = True
continue
if in_body:
body_lines.append(stripped)
long_description = " ".join(body_lines).strip()
# collapse multiple spaces
long_description = re.sub(r"\s+", " ", long_description)
return summary, long_description
_FIELD_MARKER = re.compile(r":(?:param|returns?|rtype|type|raises?)\b")
def _parse_param_docs(docstring: str) -> dict[str, str]:
"""Extract :param name: description lines from a docstring."""
params = {}
current_param = None
current_lines = []
for line in docstring.split("\n"):
stripped = line.strip()
match = re.match(r":param\s+(\w+):\s*(.*)", stripped)
if match:
if current_param:
params[current_param] = " ".join(current_lines).strip()
current_param = match.group(1)
current_lines = [match.group(2)]
elif current_param and stripped and not _FIELD_MARKER.match(stripped):
current_lines.append(stripped)
elif _FIELD_MARKER.match(stripped) or (stripped == "" and current_param):
if current_param:
params[current_param] = " ".join(current_lines).strip()
current_param = None
current_lines = []
if current_param:
params[current_param] = " ".join(current_lines).strip()
# collapse whitespace
return {k: re.sub(r"\s+", " ", v) for k, v in params.items()}
def _parse_return_doc(docstring: str) -> str:
"""Extract :return: description from a docstring."""
lines = []
in_return = False
for line in docstring.split("\n"):
stripped = line.strip()
match = re.match(r":return:\s*(.*)", stripped)
if match:
in_return = True
lines = [match.group(1)]
elif in_return:
if _FIELD_MARKER.match(stripped) or stripped == "":
break
lines.append(stripped)
return re.sub(r"\s+", " ", " ".join(lines).strip())
+37
View File
@@ -0,0 +1,37 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
from typing import Any
import ifcopenshell
AVAILABLE_RULES = ["IFC4QtoBaseQuantities", "IFC4X3QtoBaseQuantities"]
def list_rules() -> list[dict[str, str]]:
"""Return a list of available quantification rule names."""
return [{"name": name} for name in AVAILABLE_RULES]
def run_quantify(model: ifcopenshell.file, rule: str, selector: str | None = None) -> dict[str, Any]:
"""Run quantity take-off on the model using the named rule.
Modifies the model in-place by adding/updating IfcElementQuantity psets.
Returns a summary dict with ok, rule, and elements_quantified.
"""
from ifc5d.qto import edit_qtos, quantify
from ifc5d.qto import rules as rule_sets
if rule not in rule_sets:
return {"ok": False, "error": f"Unknown rule: {rule}. Available: {list(rule_sets.keys())}"}
import ifcopenshell.util.selector
if selector:
elements = set(ifcopenshell.util.selector.filter_elements(model, selector))
else:
elements = set(model.by_type("IfcElement"))
results = quantify(model, elements, rule_sets[rule])
edit_qtos(model, results)
return {"ok": True, "rule": rule, "elements_quantified": len(results)}
+148
View File
@@ -0,0 +1,148 @@
# This file was generated with the assistance of an AI coding tool.
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcEdit.
#
# IfcEdit is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcEdit is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcEdit. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import importlib
import inspect
import typing
import ifcopenshell
from ifcedit.coerce import coerce_value
def _is_file_type(hint) -> bool:
"""Check if a type hint refers to ifcopenshell.file (or Optional[ifcopenshell.file])."""
if hint is ifcopenshell.file:
return True
origin = typing.get_origin(hint)
args = typing.get_args(hint)
if origin is typing.Union and ifcopenshell.file in args:
return True
return False
def run_api(
model: ifcopenshell.file,
module: str,
function: str,
raw_kwargs: dict[str, str],
) -> dict:
"""Execute an ifcopenshell.api function with CLI-provided string arguments.
Args:
model: The open IFC model.
module: API module name (e.g. "root").
function: Function name (e.g. "create_entity").
raw_kwargs: String keyword arguments from the CLI.
Returns:
A dict with {"ok": True, "result": ...} on success,
or {"ok": False, "error": "..."} on failure.
"""
try:
fn = _import_function(module, function)
except (ImportError, AttributeError) as e:
return {"ok": False, "error": f"Cannot find function '{module}.{function}': {e}"}
try:
hints = typing.get_type_hints(fn)
except Exception:
hints = {}
sig = inspect.signature(fn)
coerced_kwargs = {}
# Pass 1: coerce ifcopenshell.file-typed params first (e.g. library= in append_asset).
# The opened file is then used as the lookup file for entity resolution in pass 2.
opened_files: list[ifcopenshell.file] = []
for name, value_str in raw_kwargs.items():
if name not in sig.parameters:
return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"}
hint = hints.get(name)
if not _is_file_type(hint):
continue
try:
coerced = coerce_value(value_str, hint, model)
coerced_kwargs[name] = coerced
if isinstance(coerced, ifcopenshell.file):
opened_files.append(coerced)
except (ValueError, TypeError) as e:
return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"}
# Pass 2: coerce remaining params. Entity instance IDs are resolved from the opened
# library file (if any), since you are always appending from another file, never
# from the current model.
lookup_file = opened_files[0] if opened_files else None
for name, value_str in raw_kwargs.items():
if name in coerced_kwargs:
continue
if name not in sig.parameters:
return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"}
hint = hints.get(name)
try:
coerced_kwargs[name] = coerce_value(value_str, hint, model, lookup_file=lookup_file)
except (ValueError, TypeError) as e:
return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"}
# Determine if the function takes 'file' as its first parameter
first_param = next(iter(sig.parameters), None)
try:
if first_param == "file":
result = fn(model, **coerced_kwargs)
else:
result = fn(**coerced_kwargs)
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return {"ok": True, "result": serialize_result(result)}
def _import_function(module: str, function: str):
"""Import and return the underlying function from ifcopenshell.api."""
fn_module = importlib.import_module(f"ifcopenshell.api.{module}.{function}")
fn = getattr(fn_module, function)
return fn
def serialize_result(value) -> object:
"""Serialize an API result to a JSON-friendly structure."""
if value is None:
return None
if isinstance(value, ifcopenshell.entity_instance):
return _serialize_entity(value)
if isinstance(value, (list, tuple, set, frozenset)):
return [serialize_result(item) for item in value]
if isinstance(value, dict):
return {str(k): serialize_result(v) for k, v in value.items()}
if isinstance(value, (str, int, float, bool)):
return value
return str(value)
def _serialize_entity(entity: ifcopenshell.entity_instance) -> dict:
"""Serialize an entity instance to a summary dict."""
result = {
"id": entity.id(),
"type": entity.is_a(),
}
if hasattr(entity, "Name") and entity.Name:
result["name"] = entity.Name
return result
+33
View File
@@ -0,0 +1,33 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "ifcedit"
version = "0.0.0"
authors = [
{ name="Bruno Postle", email="bruno@postle.net" },
]
description = "CLI wrapper for ifcopenshell.api IFC model mutation functions"
readme = "README.md"
keywords = ["IFC", "BIM", "API"]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
]
dependencies = ["ifcopenshell", "ifc5d"]
[project.scripts]
ifcedit = "ifcedit.__main__:main"
[project.urls]
Homepage = "http://ifcopenshell.org"
Documentation = "https://docs.ifcopenshell.org"
Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues"
[tool.setuptools.packages.find]
include = ["ifcedit*"]
exclude = ["test*"]
[tool.ruff]
extend = "../../pyproject.toml"
+1
View File
@@ -0,0 +1 @@
# This file was generated with the assistance of an AI coding tool.
+63
View File
@@ -0,0 +1,63 @@
# This file was generated with the assistance of an AI coding tool.
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.material
import ifcopenshell.api.owner.settings
import ifcopenshell.api.project
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.api.spatial
import ifcopenshell.api.unit
import pytest
@pytest.fixture
def model():
"""Create an IFC4 model with a spatial hierarchy and a wall."""
f = ifcopenshell.api.project.create_file()
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
ifcopenshell.api.unit.assign_unit(f)
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
return f
@pytest.fixture
def model_file(model, tmp_path):
"""Write the model fixture to a temp file and return the path."""
path = tmp_path / "test.ifc"
model.write(str(path))
return str(path)
@pytest.fixture
def library():
"""Create an IFC4 library with a single IfcWallType asset."""
lib = ifcopenshell.api.project.create_file()
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
ifcopenshell.api.root.create_entity(lib, ifc_class="IfcProject", name="TestLibrary")
ifcopenshell.api.unit.assign_unit(lib)
ifcopenshell.api.root.create_entity(lib, ifc_class="IfcWallType", name="WAL01")
return lib
@pytest.fixture
def library_file(library, tmp_path):
"""Write the library fixture to a temp file and return the path."""
path = tmp_path / "library.ifc"
library.write(str(path))
return str(path)
+158
View File
@@ -0,0 +1,158 @@
# This file was generated with the assistance of an AI coding tool.
import json
from typing import Literal, Optional, Union
import ifcopenshell
import ifcopenshell.api.project
import pytest
from ifcedit.coerce import coerce_value
class TestStringCoercion:
def test_plain_string(self):
assert coerce_value("hello", str) == "hello"
def test_empty_string(self):
assert coerce_value("", str) == ""
class TestIntCoercion:
def test_plain_int(self):
assert coerce_value("42", int) == 42
def test_hash_prefix(self):
assert coerce_value("#42", int) == 42
def test_negative(self):
assert coerce_value("-5", int) == -5
class TestFloatCoercion:
def test_plain_float(self):
assert coerce_value("3.14", float) == pytest.approx(3.14)
def test_integer_as_float(self):
assert coerce_value("5", float) == 5.0
class TestBoolCoercion:
def test_true_values(self):
for val in ("true", "True", "TRUE", "1", "yes"):
assert coerce_value(val, bool) is True
def test_false_values(self):
for val in ("false", "False", "0", "no"):
assert coerce_value(val, bool) is False
class TestOptionalCoercion:
def test_optional_string(self):
assert coerce_value("hello", Optional[str]) == "hello"
def test_optional_none(self):
assert coerce_value("none", Optional[str]) is None
assert coerce_value("None", Optional[str]) is None
def test_optional_int(self):
assert coerce_value("42", Optional[int]) == 42
class TestUnionCoercion:
def test_union_str_int(self):
# Tries str first (or int first depending on order), both work
result = coerce_value("hello", Union[str, int])
assert result == "hello"
def test_union_int_none(self):
result = coerce_value("42", Union[int, None])
assert result == 42
class TestLiteralCoercion:
def test_valid_literal(self):
assert coerce_value("IFC4", Literal["IFC2X3", "IFC4", "IFC4X3"]) == "IFC4"
def test_invalid_literal(self):
with pytest.raises(ValueError, match="not one of"):
coerce_value("IFC5", Literal["IFC2X3", "IFC4", "IFC4X3"])
class TestDictCoercion:
def test_json_dict(self):
result = coerce_value('{"IsExternal": true, "FireRating": "2HR"}', dict[str, object])
assert result == {"IsExternal": True, "FireRating": "2HR"}
def test_mixed_float_int_list_coerced_to_float(self):
# [0.419, 0, 0.908] — JSON integer 0 mixed with floats must become float
# so ifcopenshell AGGREGATE OF DOUBLE attributes (e.g. DirectionRatios) don't reject the list
result = coerce_value('{"DirectionRatios": [0.419, 0, 0.908]}', dict[str, object])
assert result["DirectionRatios"] == pytest.approx([0.419, 0.0, 0.908])
assert all(isinstance(v, float) for v in result["DirectionRatios"])
def test_pure_int_list_not_coerced(self):
# All-integer lists (e.g. face indices) must stay as ints
result = coerce_value('{"CoordIndex": [0, 1, 2]}', dict[str, object])
assert result["CoordIndex"] == [0, 1, 2]
assert all(isinstance(v, int) for v in result["CoordIndex"])
class TestListCoercion:
def test_comma_separated(self):
result = coerce_value("a,b,c", list[str])
assert result == ["a", "b", "c"]
def test_json_array(self):
result = coerce_value("[1, 2, 3]", list[int])
assert result == [1, 2, 3]
class TestEntityCoercion:
def test_entity_by_id(self, model):
wall = model.by_type("IfcWall")[0]
result = coerce_value(str(wall.id()), ifcopenshell.entity_instance, model)
assert result == wall
def test_entity_with_hash(self, model):
wall = model.by_type("IfcWall")[0]
result = coerce_value(f"#{wall.id()}", ifcopenshell.entity_instance, model)
assert result == wall
def test_entity_not_found(self, model):
with pytest.raises(ValueError, match="not found"):
coerce_value("999999", ifcopenshell.entity_instance, model)
def test_entity_list(self, model):
wall = model.by_type("IfcWall")[0]
result = coerce_value(str(wall.id()), list[ifcopenshell.entity_instance], model)
assert len(result) == 1
assert result[0] == wall
def test_entity_list_multiple(self, model):
wall = model.by_type("IfcWall")[0]
storey = model.by_type("IfcBuildingStorey")[0]
result = coerce_value(f"{wall.id()},{storey.id()}", list[ifcopenshell.entity_instance], model)
assert len(result) == 2
def test_entity_no_model(self):
with pytest.raises(ValueError, match="without an IFC model"):
coerce_value("42", ifcopenshell.entity_instance, None)
class TestFileCoercion:
def test_opens_file_from_path(self, model_file):
result = coerce_value(model_file, ifcopenshell.file)
assert isinstance(result, ifcopenshell.file)
def test_entity_from_lookup_file(self, model_file):
lib = ifcopenshell.open(model_file)
wall = lib.by_type("IfcWall")[0]
empty_model = ifcopenshell.api.project.create_file()
result = coerce_value(str(wall.id()), ifcopenshell.entity_instance, empty_model, lookup_file=lib)
assert result.id() == wall.id()
assert result.is_a("IfcWall")
class TestFallback:
def test_no_type_hint(self):
assert coerce_value("hello", None) == "hello"
+104
View File
@@ -0,0 +1,104 @@
# This file was generated with the assistance of an AI coding tool.
from ifcedit.discover import function_docs, list_functions, list_modules
class TestListModules:
def test_returns_list(self):
result = list_modules()
assert isinstance(result, list)
assert len(result) > 0
def test_module_structure(self):
result = list_modules()
for entry in result:
assert "module" in entry
assert "description" in entry
assert "functions" in entry
assert "count" in entry
assert isinstance(entry["functions"], list)
assert entry["count"] == len(entry["functions"])
def test_known_modules_present(self):
result = list_modules()
module_names = [m["module"] for m in result]
for expected in ("root", "spatial", "pset", "aggregate", "unit"):
assert expected in module_names
def test_root_module_has_functions(self):
result = list_modules()
root = next(m for m in result if m["module"] == "root")
assert "create_entity" in root["functions"]
assert root["count"] >= 3
class TestListFunctions:
def test_root_functions(self):
result = list_functions("root")
assert isinstance(result, list)
names = [f["name"] for f in result]
assert "create_entity" in names
def test_function_structure(self):
result = list_functions("root")
for fn in result:
assert "name" in fn
assert "description" in fn
assert "params" in fn
def test_create_entity_params(self):
result = list_functions("root")
create = next(f for f in result if f["name"] == "create_entity")
param_names = [p["name"] for p in create["params"]]
assert "ifc_class" in param_names
assert "name" in param_names
def test_pset_functions(self):
result = list_functions("pset")
names = [f["name"] for f in result]
assert "add_pset" in names
assert "edit_pset" in names
class TestFunctionDocs:
def test_create_entity_docs(self):
result = function_docs("root", "create_entity")
assert result["module"] == "root"
assert result["function"] == "create_entity"
assert result["description"]
assert isinstance(result["params"], list)
assert len(result["params"]) > 0
def test_params_have_types(self):
result = function_docs("root", "create_entity")
for param in result["params"]:
assert "name" in param
assert "type" in param
def test_params_have_descriptions(self):
result = function_docs("root", "create_entity")
ifc_class = next(p for p in result["params"] if p["name"] == "ifc_class")
assert "description" in ifc_class
assert len(ifc_class["description"]) > 0
def test_return_type(self):
result = function_docs("root", "create_entity")
assert "return_type" in result
def test_assign_container_docs(self):
result = function_docs("spatial", "assign_container")
assert result["module"] == "spatial"
param_names = [p["name"] for p in result["params"]]
assert "products" in param_names
assert "relating_structure" in param_names
def test_unknown_function_raises(self):
import pytest
with pytest.raises(ValueError, match="not found"):
function_docs("root", "nonexistent_function")
def test_edit_pset_docs(self):
result = function_docs("pset", "edit_pset")
param_names = [p["name"] for p in result["params"]]
assert "pset" in param_names
assert "properties" in param_names
+100
View File
@@ -0,0 +1,100 @@
# This file was generated with the assistance of an AI coding tool.
import json
import subprocess
import sys
import pytest
def run_ifcedit(*args):
"""Run ifcedit as a subprocess and return (stdout, stderr, returncode)."""
result = subprocess.run(
[sys.executable, "-m", "ifcedit", *args],
capture_output=True,
text=True,
)
return result.stdout, result.stderr, result.returncode
class TestListCommand:
def test_list_all_modules(self):
stdout, stderr, rc = run_ifcedit("list")
assert rc == 0
data = json.loads(stdout)
assert isinstance(data, list)
module_names = [m["module"] for m in data]
assert "root" in module_names
assert "spatial" in module_names
def test_list_module_functions(self):
stdout, stderr, rc = run_ifcedit("list", "root")
assert rc == 0
data = json.loads(stdout)
assert isinstance(data, list)
names = [f["name"] for f in data]
assert "create_entity" in names
def test_list_text_format(self):
stdout, stderr, rc = run_ifcedit("--format", "text", "list")
assert rc == 0
assert "root" in stdout
class TestDocsCommand:
def test_docs_create_entity(self):
stdout, stderr, rc = run_ifcedit("docs", "root.create_entity")
assert rc == 0
data = json.loads(stdout)
assert data["module"] == "root"
assert data["function"] == "create_entity"
assert "params" in data
def test_docs_invalid_path(self):
stdout, stderr, rc = run_ifcedit("docs", "invalid_path")
assert rc != 0
assert "module.function" in stderr
def test_docs_unknown_function(self):
stdout, stderr, rc = run_ifcedit("docs", "root.nonexistent")
assert rc != 0
class TestRunCommand:
def test_create_entity(self, model_file):
stdout, stderr, rc = run_ifcedit(
"run", model_file, "root.create_entity", "--ifc_class", "IfcWall", "--name", "CLIWall"
)
assert rc == 0, f"stderr: {stderr}"
data = json.loads(stdout)
assert data["ok"] is True
assert data["result"]["type"] == "IfcWall"
assert data["result"]["name"] == "CLIWall"
def test_dry_run(self, model_file):
stdout, stderr, rc = run_ifcedit("run", model_file, "root.create_entity", "--dry-run", "--ifc_class", "IfcWall")
assert rc == 0
data = json.loads(stdout)
assert data["ok"] is True
assert data["dry_run"] is True
def test_output_to_different_file(self, model_file, tmp_path):
output = str(tmp_path / "output.ifc")
stdout, stderr, rc = run_ifcedit(
"run", model_file, "root.create_entity", "-o", output, "--ifc_class", "IfcSlab"
)
assert rc == 0, f"stderr: {stderr}"
data = json.loads(stdout)
assert data["ok"] is True
import os
assert os.path.exists(output)
def test_run_error_bad_function(self, model_file):
stdout, stderr, rc = run_ifcedit("run", model_file, "root.nonexistent")
assert rc != 0
def test_run_invalid_function_path(self, model_file):
stdout, stderr, rc = run_ifcedit("run", model_file, "invalid_path")
assert rc != 0
assert "module.function" in stderr

Some files were not shown because too many files have changed in this diff Show More