Compare commits

...

62 Commits

Author SHA1 Message Date
Ryan Schultz 2cb9ff6946 fix 8119: Fix ReferenceError in snap object cache lookup
Cached SnapObj entries holding references to freed Blender
objects raised ReferenceError on .obj.name access. Evict
stale entries at the start of create_snap_obj.

Generated with the assistance of an AI coding tool.
2026-05-29 07:49:44 -05:00
Ryan Schultz 6ba5f5af3d Fix CardinalPoint not applied to all selected objects
EditAssignedMaterial propagated layer set usage attributes
to all selected objects but skipped this loop for profile
set usage. Add the same loop so CardinalPoint and
ReferenceExtent are copied to each selected object's
IfcMaterialProfileSetUsage on save.

Generated with the assistance of an AI coding tool.
2026-05-28 21:21:16 -05:00
Ryan Schultz 335ee1a1bb Fix negative zero in imperial feet-inches parser
When the user enters `-0' - 10"`, Python parses feet as -0.0.
The check `feet < 0` is False for negative zero, so the sign was
silently dropped. Use math.copysign to detect it correctly.

Generated with the assistance of an AI coding tool.
2026-05-28 12:14:23 -05:00
Gorgious56 1325705d8e Merge pull request #8112 from Gorgious56/bonsai/parametric-framework-infra
Decorator cache + parametric lifecycle drift triad + wall split fixes
2026-05-27 21:43:51 +02:00
Gorgious56 cb2f20b2b6 Add tests for decorator_cache + undo-resync dispatch
Two paired test files for the framework infrastructure landed
earlier in this PR.

test_decorator_cache.py (11 tests):
* The 4-hook invalidation list (depsgraph_update_post + undo_post +
  redo_post + load_post) is symmetrically managed by
  install_decorator_cache_handlers / uninstall_decorator_cache_handlers.
  A future edit that drops a hook from one side without the other
  would land as a Blender segfault when a cached bpy.types.Object
  ref outlives its underlying ID block — the regression must surface
  as a test failure first.
* install is idempotent (calling twice doesn't double-register).
* uninstall when not installed doesn't raise.
* The bump handler accepts Blender's variadic args.
* The depsgraph predicate gates correctly: bumps on Object geometry
  or transform updates, silently skips on Material / NodeTree / Image
  updates (which would otherwise rebuild every cache on every node
  edit).
* TokenCache.get_or_compute short-circuits on key+token match and
  recomputes when the token bumps.

test_undo_resync_parametric_drafts.py (3 tests):
* UNDO_REGENERATORS keys must all be in tool.Parametric.EDIT_TYPES.
  A typo would silently no-op on Ctrl+Z, restoring the desync the
  helper is meant to prevent.
* The dispatcher skips objects with no active parametric edit
  (undo_post fires for every undo, most of which touch zero drafts).
* The dispatcher silently skips parametric types that have no
  UNDO_REGENERATORS entry (door / window / array are IFC-derived
  with no draft preview mesh — they don't need a regenerator).

Mocks use spec=bpy.types.Depsgraph / spec=bpy.types.DepsgraphUpdate
/ spec=tool.parametric.ParametricObject so typos in mocked-attribute
access fail loudly (CLAUDE.md test discipline).

Generated with the assistance of an AI coding tool.
2026-05-27 21:28:41 +02:00
Gorgious56 f41f5dfdd8 Fix wall split: preserve door/window fill rel
Splitting a wall through a door orphaned the door (door.FillsVoids
became empty). The fill rel was being reassigned by setting its
RelatedBuildingElement slot — schema-wise that's the filling slot, not
the wall slot — so when remove_feature deleted the old opening it
also cascade-removed the rel. Transferring via RelatingOpeningElement
keeps the rel pointing at the new opening so the door stays
associated. Pre-existing bug from 5a6476a57, surfaced by ef144dce2.

Generated with the assistance of an AI coding tool.
2026-05-27 21:28:41 +02:00
Gorgious56 1855e4c019 Fix wall split: keep straddling openings on both walls
DumbWallJoiner.split assigned openings by projecting the opening's
centre-point onto the wall axis, so any opening whose footprint
straddled the cut was silently dropped from whichever wall its centre
missed. Now the full axis-projected extent (via ifcopenshell.geom.
create_shape) drives the assignment; for filled openings whose void
straddles the cut, a pure-void copy is added back to the neighbour
wall so its body is also cut.

Generated with the assistance of an AI coding tool.
2026-05-27 21:28:41 +02:00
Gorgious56 2feade01cb DRY tag-redraw-3D-viewports loops via tool.Blender.update_all_viewports
Five inline copies of the same defensive pattern lived across
``tool/parametric.py``, ``bim/parametric_lifecycle.py``,
``bim/module/model/preview_base.py`` (twice), and as a near-twin
in ``tool/blender.py:update_all_viewports`` itself.

``tool.Blender.update_all_viewports`` already covered the
``tag_redraw`` job but used an ``assert context.screen`` that would
raise during background-mode operators or early-load_post calls
where ``screen`` legitimately is None. Relax to a defensive
``getattr(context, "screen", None)`` + silent return so the helper
fits every caller's needs, then collapse the 4 inline copies to
single calls.

Net -9 LOC. The helper now describes its contract ("silent no-op
when no screen attached") rather than naming specific callers, so
moving a caller doesn't rot the docstring.

Generated with the assistance of an AI coding tool.
2026-05-27 21:28:41 +02:00
Gorgious56 ff4c642db1 Add parametric-draft undo-resync registry
Ctrl+Z / Ctrl+Shift+Z on an in-progress parametric draft (wall /
stair / roof) used to leave the preview mesh frozen in its
pre-undo shape — the IFC mutation rolls back but the bmesh built
from draft props doesn't repaint.

Add a registry of per-type regenerator functions
(``UNDO_REGENERATORS``) that re-build each type's preview mesh
from its current props. The dispatcher
``resync_parametric_drafts_after_undo`` walks all objects, skips
any without an active parametric edit, looks up the regenerator
by feature name, and calls it. Tagged 3D viewports for redraw.

Types without an entry (door / window / railing / etc.) are
intentionally absent — they're IFC-derived, so the undo's
representation rollback + next-frame refresh already repaints
correctly without a draft-side regenerator.

Undo/redo wiring is self-installed by
``bonsai.bim.parametric_lifecycle``: a ``@persistent``
``_resync_on_undo`` callback dispatches into the registry, and
``install_parametric_lifecycle_handlers()`` /
``uninstall_parametric_lifecycle_handlers()`` append/remove it
from ``bpy.app.handlers.undo_post`` and ``redo_post``.
``bim/__init__.py``'s ``register()`` calls the install function
*after* the central ``handler.undo_post`` / ``redo_post`` appends
so the regenerators see restored IFC state — ``bpy.app.handlers``
fire in append order. ``handler.py`` itself stays ignorant of the
parametric subsystem. The lazy function-local imports in each
regenerator break the addon-load cycle —
``bonsai.bim.parametric_lifecycle`` loads before
``bim/module/model/*``.

Generated with the assistance of an AI coding tool.
2026-05-27 21:28:20 +02:00
Gorgious56 e7e489e390 Refactor bim/parametric_lifecycle — drift triad + Cancel polish
Three changes to the shared Enable/Finish/Cancel mixins:

1. Always-on drift triad on ParametricEditMixinBase. The base now
   provides ``_handle_drift_on_enable`` / ``_handle_drift_on_finish``
   / ``_handle_drift_on_cancel`` classmethods, called from the
   per-mixin ``_enable_one`` / ``_finish_one`` / ``_cancel_one``.
   Pre-edit Blender-side translations commit to IFC on Enable
   (apply_scale=False — only translation/rotation, not the user's
   accidental scale), in-edit drag commits on Finish (apply_scale=True),
   and Cancel restores the committed IFC placement via
   ``restore_or_rebaseline_placement``. Prevents the
   "uncommitted drag disappears on Finish" and "preview snaps back
   on Cancel" UX bugs.

2. ``_ParametricEditMixinBase`` renamed to ``ParametricEditMixinBase``
   (public). Per-feature mixins that need to subclass directly
   (e.g., when neither FeatureModifier nor PathPreserving fits)
   can do so without reaching into a private name.

3. ``_update_modifier_bmesh`` (PathPreserving) renamed to
   ``_restore_viewport_after_cancel``. The old name was inaccurate
   for subclasses that load a different IFC representation on
   Cancel rather than rebuilding a bmesh preview from props.

Plus two polish changes:

* ``_mark_type_thumbnail_dirty`` helper on the base centralises the
  ``ifcopenshell.util.element.get_type`` + thumbnail-mark pattern
  that both mixins repeated inline.
* ``FeatureModifierEditMixin._cancel_one`` and
  ``PathPreservingEditMixin._cancel_one`` wrap the restore in
  ``try/finally`` so ``props.is_editing = False`` flips even on
  partial restore failure. Without this, a Cancel that raised
  mid-restore would leave the user locked out of the edit lifecycle.
* ``PathPreservingEditMixin._finish_one`` / ``_cancel_one`` skip the
  pset commit + viewport rebuild when the draft equals the stored
  pset (no-op Enable→Finish round-trip should not pollute the
  representation list or burn an undo entry).

``FeatureModifierEditMixin._finish_one`` now routes the pset commit
through ``tool.Pset.write_bbim_data`` instead of inlining the
``createIfcText(json.dumps(...))`` + ``ifcopenshell.api.pset.edit_pset``
dance. Two test assertions updated to match.

Generated with the assistance of an AI coding tool.
2026-05-27 15:51:37 +02:00
Gorgious56 5e23030a0f Decompose bim/handler.py load_post + install cache + discard hooks
Three concerns folded into ``load_post`` argue for separation:

1. Save-file invariants every load must re-establish (msgbus
   subscription, owner-settings, thumbnail cache, draft-flag healing,
   blend-warning flag, H5 lock probe).
2. User-preference-driven UI setup (toolbar, workspace, viewport
   shading, panel hijack, snap defaults).
3. Viewport overlay sync (every decorator's install/uninstall).

Pull each into its own function (``_apply_save_file_invariants`` /
``_apply_user_preferences`` / ``_install_viewport_overlays``). The
``load_post`` callback becomes a 3-line orchestrator. Each phase
is independently call-able from tests and from PR4 features that
need to re-trigger one phase without the others.

Two new hooks land with the decompose:

* ``tool.Parametric.heal_stale_edit_flags()`` + ``discard_pending_previews(scene)``
  fire in ``_apply_save_file_invariants``. The first clears
  object-level ``BIM<Name>Properties.is_editing`` flags that lost
  their backing IFC element across a load; the second clears
  scene-level ``BIMPreviewProperties.<x>.is_active`` so saved
  preview state never resurfaces with no UI to interact with it.

* ``install_decorator_cache_handlers`` / ``uninstall_decorator_cache_handlers``
  wrap the decorator install/install pass in
  ``_install_viewport_overlays``. The bump handlers append to
  ``depsgraph_update_post`` + ``undo_post`` + ``redo_post`` +
  ``load_post`` so the previous commit's ``TokenCache`` in
  ``tool.System.get_decoration_data`` finally invalidates on
  structural scene changes.

Generated with the assistance of an AI coding tool.
2026-05-27 15:28:18 +02:00
Gorgious56 c9f12dd441 Add bim/module/model/preview_base module
Shared helpers for Bonsai's Scene-level parametric preview flows.
Two PR4 features will consume this — MEP bend preview and wall
fillet preview — both following the same shape:

    Enable<X>Preview   — populates draft on Scene.BIMPreviewProperties.<x>
    Gizmo<X>Preview    — polls on is_active, surfaces tunable widgets
    <X>PreviewDecorator — GPU lines while is_active is True
    Finish<X>Preview   — bpy.ops.bim.<verb>(...) with draft kwargs
    Cancel<X>Preview   — pure state reset

The module hosts the cross-cutting accessors (``get_preview_props``,
``is_preview_active``), lazy-closure factories for gizmo dimension
callbacks (``make_props_callback`` / ``make_dim_getter`` /
``make_dim_setter`` — defensive against missing scene / freed RNA
struct on file open / undo), the Enable-time IFC-placement sync
(``sync_uncommitted_moves``), and the Esc + load_post discard
machinery (``PREVIEW_CANCEL_OPS`` registry, ``try_cancel_active_preview``,
``discard_pending_previews``).

Ships standalone — the consumer features land in PR4 (preview
PropertyGroups, Enable/Finish/Cancel operators, gizmo groups,
decorators, Esc keymap binding). All accessors are defensive
against missing PropertyGroups / operators on v0.8.0 — calling
``discard_pending_previews(scene)`` from the next commit's
load_post hook is a no-op until PR4 attaches BIMPreviewProperties.

Generated with the assistance of an AI coding tool.
2026-05-27 15:25:07 +02:00
Gorgious56 4b9ad66c95 Wrap tool.System.get_decoration_data with TokenCache lookup
System decoration draws on every viewport refresh — the
``_build_decoration_data`` body walks every distribution element,
resolves connected ports, builds the vert/edge arrays for the GPU
batch. A bare call per frame burns time on an unchanged scene.

Add a single-entry cache keyed on ``(decorator_cache_token,
id(decorated_elements_set))``. Reads short-circuit when neither
component moved:

* ``decorator_cache_token`` from ``bim.decorator_cache`` invalidates
  on depsgraph / undo / redo / load via the bump handler.
* ``id(decorated_elements_set)`` invalidates when
  ``SystemDecorationData.load()`` reassigns the set (e.g. when the
  user changes the set of decorated systems via the panel).

The handler that bumps the token is installed in the next commit
(bim/handler.py decompose). Until then the token stays at 0, so
the cache only hits when ``id()`` also matches — degraded behaviour
during the bisect window but not incorrect.

Generated with the assistance of an AI coding tool.
2026-05-27 14:55:44 +02:00
Gorgious56 d43a1353e0 Add bim/decorator_cache module — TokenCache + handler primitives
New helper module for POST_VIEW decorators. Exports:

* ``get_decorator_cache_token()`` — global int counter consumers
  include in their cache key so the value invalidates on structural
  scene changes.
* ``_bump_decorator_cache_token()`` — ``@bpy.app.handlers.persistent``
  callback that increments the token. Gates on the depsgraph payload
  so animation playback / driver evaluation doesn't churn the token.
* ``install_decorator_cache_handlers`` / ``uninstall_…`` — idempotent
  append / remove against depsgraph_update_post + undo_post + redo_post
  + load_post. Called once from ``bim.register`` / ``unregister``.
* ``TokenCache[T]`` — single-entry memoiser keyed on ``(caller_key,
  token)``. Cached ``bpy.types.Object`` references can't outlive the
  underlying ID blocks because any depsgraph / undo / load bumps the
  token and forces a recompute.

This commit ships the module standalone. The next commits in this
PR wire it: tool/system.py adds the cache wrap on get_decoration_data
and bim/handler.py installs the bump callbacks. Until both land,
the module is intentionally dead code — keeps the diff narrow and
the commit history bisectable.

Generated with the assistance of an AI coding tool.
2026-05-27 14:53:06 +02:00
Gorgious56 b1fa2407a9 Merge pull request #8109 from Gorgious56/bonsai/parametric-framework-slim
Extract parametric framework foundation into tool/ and core/
2026-05-27 14:46:59 +02:00
Gorgious56 786d3c8a89 Fix latent runtime bugs + ty annotations surfaced by CI
Five code paths in slim PR2 referenced symbols that don't exist in
v0.8.0's bim layer, raising at first call. Plus three type
annotations that ty flagged as unresolved.

1. tool/system.py:get_decoration_data — drop the cache layer that
   keyed on a token from a bim/decorator_cache.py module. The cache
   is dead-or-broken in slim: the depsgraph bump handler that would
   invalidate the token lives in PR3's bim/handler.py decompose, so
   the token stays at 0 forever. Either the cache never hits
   (decorated_elements rebuilt → new id() per call) or returns
   stale data (list reused). Revert to direct
   `_build_decoration_data()` calls. PR3 reintroduces the cache
   atomically: decorator_cache module + handler install + cache
   wrap + tests. Keeps `_build_decoration_data` extraction
   (cleaner than v0.8.0's monolithic version regardless of cache).

2. tool/spatial.py — add `get_host_element` + `get_host_wall`.
   The interface stubs in `core/tool.py:1037-1038` were declared
   but never implemented. `tool/duplicate.py:99` (object duplication
   with fills) and `tool/model.py:1260` (array per-child opening
   mirror) call these and would raise AttributeError.

3. tool/model.py:recreate_wall — drop the fillet-corner branch
   that function-locally imports `regenerate_fillet_corner_wall`
   from `bim/module/model/wall`. The function lands with PR4; fall
   through to the straight-extrusion path preserves v0.8.0
   behaviour for fillet walls until then. Tag FIXME(PR4).

4. tool/model.py — drop `get_pipe_segment_props` /
   `get_duct_segment_props` accessors. Their return types reference
   `BIMPipeSegmentProperties` / `BIMDuctSegmentProperties` which
   land with PR4's prop.py; calling either accessor on v0.8.0 would
   AttributeError on `obj.BIM<X>SegmentProperties`. Zero callers in
   slim — PR4 reintroduces both accessors together with the
   PropertyGroups they wrap. Also drops the matching TYPE_CHECKING
   imports.

5. tool/blender.py:557 — `Mapping[type[ViewportDecorator], bool]`
   needs the qualified `Blender.ViewportDecorator` because the
   annotation is on a method INSIDE the same nested class; the
   bare name doesn't resolve at type-check time.

6. core/tool.py Surveyor — drop the `obj: "bpy.types.Object"` /
   `z: float` / `-> float` / `-> None` annotations on
   `get_z_rotation` / `set_z_rotation`. The `@interface` decorator
   wraps each method as `classmethod(abstractmethod(...))` at
   import time, but ty doesn't track the wrap and flags every
   call site as `missing-argument` plus the `pass` body as
   `empty-body` against the declared return type, plus the
   `bpy.types.Object` forward-ref as `unresolved-reference`.
   Reverting to v0.8.0's untyped style (matching the sibling
   `get_absolute_matrix(cls, obj)` stub) clears six ty errors at
   the cost of zero runtime semantics — the abstract stubs only
   serve as registry markers, concrete `tool.Surveyor.*` carries
   the real signatures.

Generated with the assistance of an AI coding tool.
2026-05-27 14:38:37 +02:00
Gorgious56 89b7eff03e Add addon-load smoke test pinning register/unregister cycle
Surfaces any regression in:

* the modules dict in bim/__init__.py (added a folder, forgot the entry)
* PointerProperty wiring on bpy.types.{Scene,Object,...}
* registry-driven GizmoPreferences<Name> auto-registration in
  tool.Parametric.iter_gizmo_preference_classes
* bpy.app.handlers append/remove balance
* every register()/unregister() across the 45+ feature modules

as a single PASSED/FAILED test instead of the silent "addon failed to
enable" users encounter in a fresh Blender. Paired with the existing
test_parametric_registry.py contract tests, this catches both the
registry-shape regressions (operators/PropertyGroups/predicates) and
the registration-mechanics regressions (PointerProperty types not
registered before their owners).

Generated with the assistance of an AI coding tool.
2026-05-27 13:26:38 +02:00
Gorgious56 1c8fad3c13 Fix tool.Parametric to ship safely on v0.8.0 bim layer
Three corrective fixes folded into one commit. All surface as
addon-load / save-time exceptions on v0.8.0's bim layer because
PR2's tool.Parametric refactor over-committed to the PR4 contract.

1. iter_gizmo_preference_classes — the previous implementation
   returned only the shared GizmoPreferencesFeature class. v0.8.0's
   bim/ui.py declares PointerProperty fields ('door', 'window', ...)
   on GizmoPreferences that point at per-feature
   GizmoPreferences<Name> classes; those must be registered BEFORE
   GizmoPreferences itself. The shared-class-only return broke
   addon registration with:
      'door' PointerProperty could not register (see previous error)
   Restore the v0.8.0 per-feature lookup (iterate EDIT_TYPES, look
   up each GizmoPreferences<Capitalize(name)> on ui_module) and
   keep the shared-class lookup as forward-compat. Tag FIXME(PR5).

2. EDIT_TYPES — drop the array / pipe_segment / duct_segment
   entries from the registry. Their bim.finish_editing_<name>
   operators land with PR4. Registering them in PR2's EDIT_TYPES
   without the operators makes auto-commit-on-save dispatch a
   non-existent finish_op for any object whose
   BIM<Name>Properties.is_editing flag is True, raising:
      RuntimeError: 'bim.finish_editing_array' must be a registered
      tool.Ifc.Operator subclass for undo-safe IFC mutation
   PR4 re-adds the three entries together with their operators.
   Tag FIXME(PR4).

3. tool.Blender.Modifier shim block — upgrade the prose comment to
   a formal FIXME(PR5) marker so the PR5 cleanup sweep finds it via
   grep alongside every other tagged shim site.

Generated with the assistance of an AI coding tool.
2026-05-27 13:26:21 +02:00
Gorgious56 6ec8372378 Extract bim/ifc + tool/cad helpers referenced by PR2
Fixes addon-load ImportError that surfaces when tool/geometry.py
and tool/model.py (extracted in C8 / C9) reference symbols that
don't exist on v0.8.0:

* bim/ifc.py: get_cache_or_detect_lock — IfcStore.get_cache
  variant that tracks the multi-instance-cache-locked-by-other-
  process flag, sets it on PermissionError, clears it (along with
  the dismiss flag) on subsequent success. Used by
  tool.Geometry.* to gate IFC cache reads without crashing when
  another Blender instance holds the cache lock.
* tool/cad.py: WELD_TOLERANCE constant + paired CAD helpers
  (auto-detect-curves vertex precision, polyline normal helpers,
  etc.) used by tool.Model.* + by the parametric model operators
  that land in PR4.

Both modules had zero upstream commits since the gizmos-8088 fork
point — safe bulk extraction. PR4 has no caller-line work for
either file (the additions are pure additions, no existing API
removed); the v0.8.0 callers of get_cache_or_detect_lock and
WELD_TOLERANCE are the PR2-scope files that needed them.

Generated with the assistance of an AI coding tool.
2026-05-27 11:44:01 +02:00
Gorgious56 5dc7513de0 Add tool.Blender.Modifier backward-compat shims
The previous commit moved is_<type> predicates off tool.Blender.Modifier
onto tool.Parametric, and earlier C4 moved the Array helper bag off
tool.Blender.Modifier.Array onto tool.Array. PR4 will migrate every
caller; this commit keeps the OLD entry points alive as thin delegates
so PR2 ships without breaking ~30 caller sites that still spell the
old API in v0.8.0:

* tool.Blender.Modifier.is_door / is_railing / is_roof / is_stair /
  is_wall / is_window — delegate to tool.Parametric.is_<type>.
* tool.Blender.Modifier.Array.bake_children_transform / constrain_
  children_to_parent / get_all_children_objects / get_all_objects /
  get_children_objects / get_modifiers_data / remove_constraints /
  set_children_lock_state — delegate to tool.Array.<same name>.

These shims are removed in PR5's cleanup commit once PR4 has rewritten
the call sites in bim/import_ifc.py, bim/module/geometry/operator.py,
bim/module/geometry/data.py, bim/module/model/array.py + the per-feature
operators (door, wall, window, railing, roof, stair, ui).

Generated with the assistance of an AI coding tool.
2026-05-27 09:23:29 +02:00
Gorgious56 f37c77e80c Refactor tool.Parametric — feature registry + lifecycle hooks
tool.Parametric becomes the central registry for Bonsai's parametric
features (wall, slab, door, window, railing, roof, stair, plus
mep-segment variants). Each feature registers a ParametricObject spec
declaring its enable/finish/cancel op names, props accessor, regen
callback, and is_element_type predicate.

Public surface:

* tool.Parametric.WALL / SLAB / DOOR / WINDOW / RAILING / ROOF /
  STAIR / PIPE_SEGMENT / DUCT_SEGMENT — typed accessors per feature.
* tool.Parametric.is_wall / is_door / is_window / is_railing /
  is_roof / is_stair — element-type predicates that move off
  tool.Blender.Modifier into the parametric registry. The next
  commit adds backward-compat shims on tool.Blender.Modifier so
  v0.8.0 callers keep working.
* tool.Parametric.is_object_editing(obj) — returns the registered
  feature an object is currently editing, or None.
* tool.Parametric.run_bim_op(op_name) — invoke a parametric op by
  bl_idname.
* tool.Parametric.heal_stale_edit_flags — clear is_editing flags
  on file load so a saved-mid-edit project doesn't leave gizmos
  poll-locked.
* supports_build_edit_lifecycle field on ParametricObject — declares
  whether the feature implements the build/edit/cancel triad.

The previous bare `print(f"Bonsai: commit of {obj.name!r} via
{finish_op} failed: {e}")` exception-handler is replaced with
logger.warning(..., exc_info=True). Same channel (Bonsai configures
logging to the Blender console at WARNING level), strictly more
information (full traceback), correct idiom for an error-path
message. A second logger.warning is added for parametric predicate
failures, also exception-handler scope.

Generated with the assistance of an AI coding tool.
2026-05-27 09:21:39 +02:00
Gorgious56 db9d903650 Polish tool.Model + tool.Pset + add tool.Slab service
tool.Model gains:

* get_pipe_segment_props / get_duct_segment_props — typed prop accessors
  for the MEP-segment edit lifecycle.
* resolve_active_props_for_edit — picks the right BIM*Properties to
  drive a parametric edit triad based on the active object's IFC class.
* mirror_parent_void_fillings_to_children — when an array parent has
  hosted fillings (door/window in a wall), replicate the same fill
  rels onto each array child. Uses tool.Array.get_parametric_propagation_
  targets so the propagation stays within the array family (the old
  get_all_element_occurrences over-propagated to standalone occurrences
  of the same type, which silently mutated unrelated arrays).
* unshare_opening_representation — fork a shared IfcShapeRepresentation
  so editing one opening doesn't mutate its array sibling.
* duplicate_ifc_objects gains a post-condition select-restore on the
  array parent so callers don't get a deselected parent for N>=2 arrays.

sync_object_ifc_position is kept as a thin delegate to
tool.Geometry.commit_placement_if_moved (the new home, added in C8) so
the 6 v0.8.0 callers in mep / product / system don't AttributeError;
PR4 migrates each caller and removes the delegate.

tool.Pset gains:

* upsert_pset — get-or-add-or-edit in one call.
* write_bbim_data — JSON-encode + write BBIM_* metadata in one call.

tool.Slab is new — slab-specific reads (active extrusion, axis
direction) used by the slab gizmos, pure-IFC, no PropertyGroup mutation.

Generated with the assistance of an AI coding tool.
2026-05-27 00:14:51 +02:00
Gorgious56 3483683cb4 Add tool.Geometry helpers for body representation + placement
Adds:

* get_body_representation(element) — DRY of the repeated
  ifcopenshell.util.representation.get_representation(element, "Model",
  "Body", "MODEL_VIEW") call across slab / wall / opening / stair /
  roof / door / window / mep. One central place to read the body rep;
  every caller stops re-spelling the four magic strings.
* has_axis_representation(element) — predicate for elements with a
  GRAPH_VIEW Axis representation. Used by the wall/MEP path decorators
  to skip elements without an unambiguous 1D path.
* has_material_styles(element) — predicate for whether the element
  carries IfcStyledItem material assignments.
* restore_placement_from_ifc(obj, element) — snap obj.matrix_world back
  to element's committed IFC placement + rebaseline the drift checksum.
* restore_or_rebaseline_placement(obj, element) — Cancel-flow helper:
  restores if ObjectPlacement exists, just rebaselines the checksum if
  not.
* detach_representation(product) — remove the active representation
  from a product without deleting the entity (used by parametric
  rebuilds that wipe + re-add).

commit_placement_if_moved docstring expanded with a "drop-in scope"
note so callers don't redundantly wrap it in an is_moved check that
the helper already does.

Switches the duplicate-aware helper calls (formerly tool.Root.*) to
tool.Duplicate.* now that the service exists (C6).

Generated with the assistance of an AI coding tool.
2026-05-27 00:04:03 +02:00
Gorgious56 a0c6f6f9a6 Extend tool.Blender for parametric framework + decorators
Adds:

* ViewportDecorator base class — install/uninstall/draw lifecycle for
  3D viewport gpu overlays, with handler-rollback-on-failure so a
  partial install can't leave dangling draw handlers.
* sync_all classmethod — drive each listed ViewportDecorator subclass
  to its desired install state in one call.
* is_view_top_down + top_down_factor — viewport-camera orientation
  predicates used by gizmo billboarding and decorator layout.
* get_screen_up_world — screen-up vector in world space for gizmo
  text orientation.
* are_viewport_gizmos_enabled — central gate for the global
  draw_gizmos_in_3d_viewport pref, replacing duplicated prefs reads.
* DecoratorColors NamedTuple + get_decorator_colors — single source
  for the colour palette every viewport decorator binds.

Preserves Ryan Schultz's add_layout_hotkey_operator polish (719309571,
2026-05-25): the row-position move + separator(factor=1) between the
modifier and key icons stay intact in this extraction.

Generated with the assistance of an AI coding tool.
2026-05-27 00:00:53 +02:00
Gorgious56 49ddda6281 Add tool.Duplicate service
Extract the duplicate-aware relationship-walk + restoration logic
(get_decomposition_relationships, get_connection_relationships,
get_port_connection_relationships, recreate_decompositions,
recreate_connections, recreate_port_connections, consume_warnings)
out of tool.Root into its own service.

tool.Root's responsibility is identity and addressing of IFC roots;
the duplicate-aware bookkeeping of "before duplication, what relations
did this graph have, and how do I restore them on the new copies?"
deserves its own home. The split was already declared on core/tool.py
(C2); this commit lands the concrete tool.Duplicate implementation.

tool.Root keeps its own copies of the methods on v0.8.0's tool/root.py
during this PR so callers in bim/module/spatial/operator.py keep
working at runtime; the Root cleanup lands in PR4 alongside the
caller updates.

Generated with the assistance of an AI coding tool.
2026-05-26 23:48:11 +02:00
Gorgious56 96b6985960 Extend tool.System with port + path helpers
Adds:

* direction_from_port_pair(port_a, port_b) — derive the connect_port
  direction kwarg from each port's FlowDirection (NOTDEFINED for
  non-canonical pairs). Centralises a pattern that callers were
  inlining inconsistently.
* tool.System.walk_connected_mep_elements — BFS over connected MEP
  flow elements via IfcRelConnectsPorts.
* tool.System.get_port_world_position — port placement → world-space
  Vector, used by the MEP path decorator.
* tool.System._build_decoration_data — cached decoration metadata
  for the MEP system-path overlay.

Plus a get_port_relating_element return-type tightening (Union with
None) and a partial-init cycle workaround on bim.module.system.data
imports (now function-local — top-level import triggered the cycle
through tool.Ifc.Operator).

Generated with the assistance of an AI coding tool.
2026-05-26 23:45:14 +02:00
Gorgious56 b19b2ac7cd Add tool.Array service
Top-level array-domain service extracted out of tool.Blender.Modifier.Array.
Owns the BBIM_Array pset graph navigation (constrain_children_to_parent,
remove_constraints, get_modifiers_data, get_children_objects,
get_all_children_objects, get_child_layer_index, bake_children_transform),
plus the Blender-side CHILD_OF constraint lifecycle that ties each child
replica to its parent's transform.

Array's own module gives the parent/child semantics a clean home — array
behaviour was previously scattered between tool.Blender.Modifier and ad-hoc
helpers in bim/module/model/array.py. The relocation eliminates the inline
duplication and gives Bonsai callers a single import surface.

Generated with the assistance of an AI coding tool.
2026-05-26 23:41:56 +02:00
Gorgious56 fdf4b82371 Add tool.Wall service
Bpy-permitted wall reads — get_axis_local_extent, get_length_and_height,
get_x_angle, get_path_connection_location, walk_connected_walls — used
by gizmo lambdas that need wall dimensions and join topology without
the side effect of loading the wall's draft BIMWallProperties (the
loader mutates PropertyGroup state and would clobber the wall's own
gizmo state when both the wall and a hosted filling are selected).

All reads go through ifcopenshell.util.representation / .util.element
so the IFC graph stays the source of truth. tool.Wall consumes
core.model's PARALLEL_DOT_THRESHOLD + collinearity helpers (no inline
magic numbers).

Generated with the assistance of an AI coding tool.
2026-05-26 23:40:19 +02:00
Gorgious56 2f40441f1c Add tool.* interface stubs to core.tool
Declares the bpy-free contract for tool services landing in subsequent
commits — tool.Wall, tool.Array, tool.System, tool.Duplicate (extracted
from tool.Root), tool.Parametric, plus minor additions on existing
interfaces (tool.Spatial.get_host_element / get_host_wall,
tool.Geometry.has_axis_representation / has_material_styles,
tool.Surveyor.get_z_rotation / set_z_rotation).

The @interface declarations are empty-bodied; concrete implementations
land in the per-service tool/* commits below. Keeping the contract in
core lets core/* helpers and tests reference the surface without
importing the concrete tool modules.

Moves get_decomposition_relationships + recreate_decompositions off
tool.Root onto the new tool.Duplicate (extraction of duplicate-aware
behaviour into its own service).

Generated with the assistance of an AI coding tool.
2026-05-26 23:31:28 +02:00
Gorgious56 230cbe1fd8 Add core/model.py constants + core/product.py helpers
core/model.py gains:

* Three calibrated dot-product / distance thresholds — PARALLEL_DOT_THRESHOLD
  (~2° from parallel, cos(2°) ≈ 0.9994), COLLINEAR_LINE_TOLERANCE (50mm
  perpendicular distance for two parallel wall axes to share a line),
  BASELINE_OFFSET_TOLERANCE — replacing inline magic numbers that the
  wall-join classifier, fillet-state machine, and gizmo preview decorator
  all read from.
* Pure wall-join geometry helpers (project_axis_intersection,
  are_axes_collinear, classify_wall_join_state, wall_join_preview_lines,
  resolve_extend_walls_target, extrusion_depth_from_vertical_height,
  length_and_height_from_extrusion). They take primitive tuples + floats,
  no bpy, no ifcopenshell — testable in the core lane.

core/product.py is new — pure-Python aggregate-walk helpers (resolve_host_
of_product, collect_decomposed_products) that downstream tool/spatial and
tool/aggregate consumers can call without importing ifcopenshell at module
load.

Generated with the assistance of an AI coding tool.
2026-05-26 23:28:19 +02:00
Gorgious56 4d4c5b4d51 Split railing representation into pure-compute + IFC wrapper
add_railing_representation now factors into two parts:

* compute_wall_mounted_handrail_geometry returns a pure-geometry
  WallMountedHandrailGeometry dataclass (handrail polyline + support
  list + terminal caps), no IFC mutation.
* add_railing_representation wraps that dataclass into an
  IfcShapeRepresentation as before.

Downstream consumers that want the same math without round-tripping
through an IFC file (Blender gizmo previews, viewport drafts) now
drive compute_X directly. Future add_X_representation work in the
geometry API is encouraged to follow the same shape — a sibling
compute_X function + thin IFC wrapper.

The railing_type parameter is dropped from the signature — only
WALL_MOUNTED_HANDRAIL was ever supported, so the kwarg was dead.
The Bonsai railing-modifier caller is updated in the same commit
to stop passing it; without that update Bonsai's
finish_editing_railing_path raises TypeError on the first edit.

RailingSupport and WallMountedHandrailGeometry use @dataclass(slots=True)
— they're constructed N-per-cap during arc sampling, so the per-instance
overhead matters.

Public symbols (RailingSupport, TERMINAL_TYPE,
WallMountedHandrailGeometry, compute_wall_mounted_handrail_geometry,
add_railing_representation) re-exported from ifcopenshell.api.geometry.
New test/api/geometry/test_add_railing_representation.py covers the
compute/wrap contract.

Generated with the assistance of an AI coding tool.
2026-05-26 23:22:19 +02:00
Gorgious56 3d81660dad Use util.unit.mm_to_m in add_window_representation
Drops the module-local ``mm()`` helper in favour of the centralised
``ifcopenshell.util.unit.mm_to_m`` (added earlier in this PR). The
``as mm`` import alias preserves the existing call sites' readability.

Generated with the assistance of an AI coding tool.
2026-05-26 23:22:19 +02:00
Gorgious56 b4abd999b6 Use util.unit.mm_to_m in add_door_representation
Drops the module-local ``mm()`` helper in favour of the centralised
``ifcopenshell.util.unit.mm_to_m`` (added earlier in this PR). The
``as mm`` import alias preserves the existing call sites' readability.

Generated with the assistance of an AI coding tool.
2026-05-26 23:22:19 +02:00
Gorgious56 1e6db764d4 Add numpy axis-index constants + silence MEP-transition prints
ShapeBuilder gains module-level NP_X / NP_Y / NP_Z / NP_XY / NP_XZ /
NP_YZ / NP_YX axis-index constants. Downstream geometry builders had
been redefining local copies for indexing np.ndarray vectors of shape
(3,) or (N, 3); centralising removes the duplication.

mep_transition_length and mep_transition_calculate verbose default
flipped from True to False. The prints are diagnostic-only output;
True-by-default spammed the console on every transition computation,
which fires per-fitting on IFC load.

Generated with the assistance of an AI coding tool.
2026-05-26 23:22:19 +02:00
Gorgious56 936526b41b Add ifcopenshell.util.unit.mm_to_m helper
Centralises the millimetre-to-metre conversion shortcut that
add_door_representation and add_window_representation each defined
locally. Subsequent commits in this PR switch both call sites to
import this from util.unit, removing the duplicate definitions.

Generated with the assistance of an AI coding tool.
2026-05-26 23:22:19 +02:00
Richard Brice 45ea5eb07a Updates alignment api. Fixes bugs authoring semantic-only alignment 2026-05-25 10:34:29 -07:00
Richard Brice 42ed398169 Simplifies line and circle parent curves and parent curve normalization 2026-05-25 10:34:29 -07:00
Richard Brice f70044d373 Fixes bug computing cross slope 2026-05-25 10:34:29 -07:00
Ryan Schultz 719309571e Improve active tool panel hotkey button display
Use add_layout_hotkey_operator for draw_regen_operations so the Regen
button shows text and shortcut icons in the sidebar like all other
panel buttons. Add a separator between modifier and key icons for
readability.
2026-05-25 09:35:12 -05:00
Bruno Postle d3f0ad03fb Quote {id} placeholders in examples (issue #8101)
Shell {} expressions require quoting
2026-05-24 20:25:36 +01:00
Gorgious56 7e96692764 Merge pull request #8089 from Gorgious56/gizmos
Parametric gizmos : Support wall and wall operations
2026-05-21 11:58:54 +02:00
Gorgious56 3e0978062f Add lifecycle-mixin tests + predicate-total registry guard
test_parametric_lifecycle.py covers the door/window/railing/roof
state-transition contracts (enable/finish/cancel; no-op on
non-matching elements; draft preserved on finish-time failure)
that the registry smoke test never exercised.

test_parametric_registry.py gains a check that every is_<name>
predicate stays total (never raises on a non-matching IFC entity)
— a raising predicate would break the save path for unrelated
types. Also rewrites the gizmo-prefs check to read __annotations__
instead of hasattr, which depended on Blender registration timing.

Generated with the assistance of an AI coding tool.
2026-05-21 11:40:38 +02:00
Gorgious56 b3f482e0fa Defer mathutils imports in stair gizmo tests
Aligns with the test/bim/ convention: heavy imports go inside test
functions so the autouse _require_real_bpy fixture skips cleanly
when bpy is mocked, rather than module-level imports failing at
collection time and erroring out the whole file.

Generated with the assistance of an AI coding tool.
2026-05-21 11:18:00 +02:00
Gorgious56 4943c77c5e Add BONSAI_TEST_ARGS env-var fallback to runpytest.py
PowerShell and some wrapper scripts on Windows occasionally strip
or reorder the `--` separator before Blender sees it, dropping the
pytest args into Blender's positional file-load slot ("File format
is not supported"). The env var carries the same args via a
shell-evaluation-free channel. Default `--` path is byte-identical
to the pre-change behaviour.

Generated with the assistance of an AI coding tool.
2026-05-21 11:17:31 +02:00
Gorgious56 6caf94f1d3 Sweep docstrings for rot-prone references
Docstrings naming sibling methods, private helpers, test files, or
historical symbols silently go wrong on rename. Strip Sphinx :meth:
/ :class: / :func: / :attr: markup that mostly added noise (no
Sphinx in this project), and rewrite five docstrings that cited
specific test paths or private hooks to describe the behaviour
instead.

Generated with the assistance of an AI coding tool.
2026-05-21 11:09:29 +02:00
Gorgious56 1e36cc318e Drop save-time parametric-edit confirm dialog
The dialog's only outcomes were "Apply & Save" (same as silent save)
or "Cancel" (same as not saving) — net friction with no actual choice.
Auto-commit stays as the safety net; the count now suffixes the
existing save-success report so it isn't immediately overwritten.

Generated with the assistance of an AI coding tool.
2026-05-21 11:00:19 +02:00
Gorgious56 46381ec08b Prioritize smaller distance gizmos in selection
When two GizmoDimension hit regions overlap (a short dimension
nested inside a longer one along the same axis), the larger one
used to win because hit boxes are scaled by world-space length —
the long box fully contains the short one, leaving the short
gizmo unreachable. The larger gizmo stays clickable at its
exposed ends, so smaller-wins is the right UX default.

Sets self.select_bias = -self._dimension_length inside
GizmoDimension.set_dimension_length. The smaller gizmo writes a
less-negative depth value in the GPU select buffer and wins the
tie-break. select_bias is unused elsewhere in the codebase, so
icon and arrow gizmos keep bias=0 and are unaffected (icons
correctly still win against dimensions, since 0 > -length).

Adds test/bim/module/drawing/test_dimension_gizmo_priority.py
with 5 cases: direct ordering, monotonicity across length ranges,
abs() handling for signed dimensions, and NaN/Inf safety.

Generated with the assistance of an AI coding tool.
2026-05-21 10:30:32 +02:00
Gorgious56 47af955dd1 Simplify pending edit popup text 2026-05-21 09:48:00 +02:00
Gorgious56 f582d0230c Fix set_icon_gizmo_position so billboard ignores object rotation
set_icon_gizmo_position computed
``mw @ (Translation @ billboard_rot @ Scale)`` — the object's world
matrix was applied AFTER the billboard rotation, so any non-trivial
object rotation (e.g. a wall rotated in plan, a stair rotated to
match a corridor) carried over into the icon's transform and tilted
it edge-on to the camera instead of facing it.

Switch to ``billboarded_at(world_pos, billboard_rot, scale)`` where
``world_pos = mw @ local_pos``: translate to world space first, then
apply the billboard rotation independently of the object's rotation.
This matches the manual pattern the base class's
``update_editing_gizmos`` already uses for validate/cancel/cycle for
exactly this reason.

Drops the now-stale workaround docstring on
``GizmoWallEdition._update_icon_row_extras`` that documented why it
bypassed ``set_icon_gizmo_position`` — the helper does the right
thing now.

Adds ``test/bim/module/model/test_stair_gizmos.py`` as the regression
guard: parametrised over six rotation angles, asserts that the rotation
part of the resulting matrix equals ``billboard_rot`` (no contribution
from ``mw``'s rotation) and that the translation lands at
``world_pos``. Also exercises ``set_icon_gizmo_position`` end-to-end via
a stub gizmo to catch the exact shape of the previously-broken call
site.

Generated with the assistance of an AI coding tool.
2026-05-20 17:28:18 +02:00
Gorgious56 26eef20eb5 Add wall parametric editing and gizmos
Walls gain in-viewport parametric editing matching the door/window/stair
UX: drag handles for length, height, slope (x-angle), layer baseline
cycle, plus cursor-anchored quality-of-life operators (split at cursor,
extend to cursor, extend height, rotate 90, toggle openings) and
two-object state-machine gizmos (unjoin / merge / join-corner /
extend-to-wall / extend-vertically / add-opening).

Wall enters tool.Parametric.EDIT_TYPES, so save-time auto-commit,
GizmoPreferencesWall registration, and the in-progress-edit predicates
all light up automatically through the registry plumbing landed two
commits back.

The three-layer commit model (drag -> BIMWallProperties -> bmesh
preview -> Finish -> single ifc.run) means dragging a handle through
hundreds of intermediate values produces zero extra IFC entities. A
no-op enable->finish round-trip is byte-identical. The snapshot diff
in FinishEditingWall skips unchanged params.
_commit_active_wall_edit_if_any ensures cursor-anchored operators see
committed geometry, not the draft preview box.

Also lands the `prompt_auto_commit_parametric_edits` BoolProperty on
BIM_ADDON_preferences (consumed by the auto-commit dialog landed in
the framework commit) and refactors
`draw_{door,window,stair}_gizmo_parameters` into a shared
`_draw_parametric_gizmo_parameters` helper that the new
`draw_wall_gizmo_parameters` reuses. This commit and the framework
commit are stacked - the framework commit references the BoolProperty
defined here, so they must land together.

Tests cover pure math (core/test_model.py), DimensionGizmoConfig text
formatter, GizmoWallExtendVertically.poll() preconditions, and the
refresh_post_commit cache-invalidation regression. BDD scenarios in
model.feature cover the edit triad, auto-commit on save, and the
two-object gizmos. Documentation added to creating_walls.rst.

Generated with the assistance of an AI coding tool.
2026-05-20 16:58:39 +02:00
Gorgious56 2143262883 Fix dead duplicates and misleading import comments
Three small post-landing cleanups against the parametric framework commit:

* core/model.py had `are_axes_collinear` and `closest_endpoint_midpoint`
  each defined twice — Python silently kept the second copy, the first
  was dead code. Removed the dead copies; runtime behavior unchanged
  (the live versions were already the kept ones).
* bim/__init__.py's `_parametric_gizmo_preference_classes` docstring
  named the wrong link in the import chain (`tool.blender → bim.ifc`).
  The real chain is `tool/ifc.py` (and ~6 other tool/* modules) which
  import `from bonsai.bim.ifc import IfcStore` at module load. Updated
  docstring to cite that root cause and the architectural fix (move
  `IfcStore` out of `bim/`).
* tool/blender.py's `from bonsai.bim.ifc import IFC_CONNECTED_TYPE`
  carried a 5-line comment claiming it was "lazy" to avoid a circular
  load. The import sits inside an `if TYPE_CHECKING:` block with
  `from __future__ import annotations` — it never runs at runtime
  regardless. Comment removed; the TYPE_CHECKING guard is
  self-explanatory.

Generated with the assistance of an AI coding tool.
2026-05-20 16:25:49 +02:00
Gorgious56 233cc344fa Add tool.Parametric registry and lifecycle mixins
Establish a single source of truth for parametric element types (door,
window, stair, railing, roof). tool.Parametric.EDIT_TYPES drives:
- BIM<Name>Properties PointerProperty attachment via the registry
- GizmoPreferences<Name> class registration in bim/__init__.py
- save-time auto-commit of pending draft edits
- the refresh_post_commit epilogue called from IfcStore after every IFC
  mutation, which fixes the stale-header bug where in-place hotkey
  mutations (S_E / C_E) left BIMModelProperties and the gizmo cache
  pointing at obsolete values.

Refactors door/window/railing/roof onto shared mixins from
bim/parametric_lifecycle.py (FeatureModifierEditMixin and
PathPreservingEditMixin); stair gets the lock-gizmo refactor and
frame-cache integration. Behavior preserved.

Adds BaseParametricGizmoGroup._prime_frame_caches so the parametric
gizmos stop re-deriving preferences, view direction, and billboard
rotation per frame; reorders poll() to short-circuit on the cheapest
predicate first. Adds the icon library + BillboardingGizmoGroupMixin
that the wall feature in the next commit will consume.

Generated with the assistance of an AI coding tool.
2026-05-20 15:18:44 +02:00
Gorgious56 a64e737d9c Merge pull request #8078 from Gorgious56/v0.8.0
Fix 8077 : Fix SHIFT + D with non-ifc object selection
2026-05-19 13:03:21 +02:00
Gorgious56 1b2507e143 Fix 8077 : Fix SHIFT + D with non-ifc object selection
When a project has a ifc file associated, selecting non-ifc objects and duplicating them with SHIFT + D now correctly both duplicate them, keep the new objects selected and starts the transform modal. IFC objects behaviour is unaffected.
2026-05-19 12:29:18 +02:00
Geert Hesselink 508b99cb73 Fix lint failures and add missing pyparsing dependency (#8048)
* unblock voxel schema loading, add test for express

* Apply black formatting

* Fix lint failures and add missing pyparsing dependency

* align ty -> 0.0.34
2026-05-18 22:17:45 +02:00
Thomas Krijnen 4e406ab1ce Change default value of assume_asset_uniqueness_by_name #8045 2026-05-18 13:29:39 +02:00
Thomas Krijnen 227d85d81f arrange polygons: limit width ratio when merging boxes 2026-05-15 21:12:43 +02:00
Thomas Krijnen a24cdf4958 Merge branch 'v0.8.0' of https://github.com/IfcOpenShell/IfcOpenShell into v0.8.0 2026-05-15 21:12:01 +02:00
Thomas Krijnen 9345b9ce3f arrange polies: don't allow snapped point paths to cross non-containing other rect axes 2026-05-14 21:45:59 +02:00
Thomas Krijnen 0b5dded3b3 Fix temporary solution storage in arrange polygons 2026-05-14 14:37:44 +02:00
Thomas Krijnen 97218b1fdb Calculate box-width as orthogonal distance; aabb code for segment intersection (disabled) 2026-05-14 14:17:10 +02:00
Thomas Krijnen 1b637c6499 Arrange polies: reorder segment to exterior insertion based on length 2026-05-12 20:52:30 +02:00
107 changed files with 10982 additions and 1541 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
uv tool install ruff
uv tool install black
uv tool install poethepoet
uv tool install ty
uv tool install ty==0.0.34
# black doesn't catch all syntax errors, so we check them explicitly.
- name: Check syntax errors
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install xmlschema xsdata numpy lxml pytest isodate lark networkx tabulate python-dateutil shapely
pip install xmlschema xsdata numpy lxml pytest isodate lark networkx tabulate python-dateutil shapely pyparsing
pip install src/bcf --no-deps
pip install pytest-xdist==3.8.0
+1 -2
View File
@@ -126,9 +126,8 @@ ssl._create_default_https_context = ssl._create_unverified_context
import time
from collections.abc import Generator, Sequence
from pathlib import Path
from urllib.request import urlretrieve
from typing import Literal, Union
from urllib.request import urlretrieve
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
+22 -4
View File
@@ -15,6 +15,8 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
import importlib
import os
@@ -25,7 +27,19 @@ import bpy
import bpy.utils.previews
from bpy_extras.io_utils import ExportHelper, ImportHelper
from . import handler, operator, prop, ui
from . import handler, operator, parametric_lifecycle, prop, ui
def _parametric_gizmo_preference_classes() -> list[type]:
"""Resolves the registry-driven ``GizmoPreferences<X>`` classes for the
``classes`` list below. ``import bonsai.tool`` is kept local to surface
the load-order constraint: it relies on ``from . import handler, …``
above having primed the
``tool/ifc.py → bim/ifc.py → bim/handler.py → bonsai.tool`` cycle."""
import bonsai.tool as tool
return tool.Parametric.iter_gizmo_preference_classes(ui)
try:
from bonsai.translations import translations_dict
@@ -157,9 +171,10 @@ classes = [
ui.BIM_UL_tab_visibilities,
ui.BIM_UL_panel_visibilities,
ui.DocPreferences,
ui.GizmoPreferencesDoor, # Register before GizmoPreferences
ui.GizmoPreferencesWindow, # Register before GizmoPreferences
ui.GizmoPreferencesStair, # Register before GizmoPreferences
# Per-parametric-type ``GizmoPreferences<Name>`` classes — must register
# before ``ui.GizmoPreferences`` which holds the matching PointerProperty
# fields. Driven by ``tool.Parametric.EDIT_TYPES``.
*_parametric_gizmo_preference_classes(),
ui.GizmoPreferences,
# ui.DefaultParameters and ui.BIM_ADDON_preferences are registered separately after modules (see late_classes below)
# Tabs panel
@@ -268,6 +283,8 @@ def register():
bpy.app.handlers.depsgraph_update_post.append(on_register)
bpy.app.handlers.undo_post.append(handler.undo_post)
bpy.app.handlers.redo_post.append(handler.redo_post)
# Must follow the two appends above so regenerators see restored IFC state.
parametric_lifecycle.install_parametric_lifecycle_handlers()
bpy.app.handlers.load_post.append(handler.load_post)
bpy.app.handlers.load_post.append(handler.loadIfcStore)
bpy.types.Scene.BIMProperties = bpy.props.PointerProperty(type=prop.BIMProperties)
@@ -325,6 +342,7 @@ def unregister():
unregister_classes(classes)
parametric_lifecycle.uninstall_parametric_lifecycle_handlers()
bpy.app.handlers.load_post.remove(handler.load_post)
bpy.app.handlers.load_post.remove(handler.loadIfcStore)
del bpy.types.Scene.BIMProperties
+119
View File
@@ -0,0 +1,119 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Shared structural-change cache token for POST_VIEW decorators.
Decorators include the token in their cache key and rebuild on bump."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any, Generic, TypeVar
import bpy
T = TypeVar("T")
_DECORATOR_CACHE_TOKEN = 0
def get_decorator_cache_token() -> int:
return _DECORATOR_CACHE_TOKEN
def reset_for_test() -> None:
"""Test-only: reset the cache token to 0 so bump-count assertions are stable."""
global _DECORATOR_CACHE_TOKEN
_DECORATOR_CACHE_TOKEN = 0
@bpy.app.handlers.persistent
def _bump_decorator_cache_token(*args: Any) -> None:
"""depsgraph_update_post fires every animation frame and every driver
evaluation, even when no IFC-relevant ID block changed. Unconditional
bumping defeats the cache: an animated scene rebuilds every decorator
every viewport tick. Gate the depsgraph path on Object geometry or
transform updates; undo / redo / load have no depsgraph and always
invalidate.
Coverage assumption: ``TokenCache`` consumers key on Object identity
(depsgraph updates whose ``id`` is a ``bpy.types.Object``). Mesh /
Material / NodeTree updates that don't surface as an Object change
do NOT invalidate the token — a decorator that caches material- or
mesh-data-derived state must gate on a separate signal."""
global _DECORATOR_CACHE_TOKEN
if len(args) >= 2:
depsgraph = args[1]
if depsgraph is not None and hasattr(depsgraph, "updates"):
if not any(
(getattr(u, "is_updated_geometry", False) or getattr(u, "is_updated_transform", False))
and hasattr(u, "id")
and isinstance(u.id, bpy.types.Object)
for u in depsgraph.updates
):
return
_DECORATOR_CACHE_TOKEN += 1
def _hooks() -> tuple[Any, ...]:
return (
bpy.app.handlers.depsgraph_update_post,
bpy.app.handlers.undo_post,
bpy.app.handlers.redo_post,
bpy.app.handlers.load_post,
)
def install_decorator_cache_handlers() -> None:
"""Append the bump handler to each hook; idempotent."""
for hook in _hooks():
if _bump_decorator_cache_token not in hook:
hook.append(_bump_decorator_cache_token)
def uninstall_decorator_cache_handlers() -> None:
for hook in _hooks():
try:
hook.remove(_bump_decorator_cache_token)
except ValueError:
pass
class TokenCache(Generic[T]):
"""Memoise a single value keyed on ``(caller_key, get_decorator_cache_token())``.
The token component invalidates the cache on depsgraph / undo / redo / load,
so cached ``bpy.types.Object`` references can't outlive the underlying ID
blocks. Holds exactly one entry — last key wins."""
__slots__ = ("_key", "_value")
def __init__(self) -> None:
self._key: tuple[Any, int] | None = None
self._value: T | None = None
def get_or_compute(self, key: Any, compute: Callable[[], T]) -> T:
token_key = (key, _DECORATOR_CACHE_TOKEN)
if token_key == self._key:
return self._value # type: ignore[return-value]
value = compute()
self._key = token_key
self._value = value
return value
+90 -30
View File
@@ -15,11 +15,12 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
import os
import weakref
from collections.abc import Callable
from math import cos
from typing import Union
import bpy
@@ -31,8 +32,13 @@ from bpy.app.handlers import persistent
from mathutils import Vector
import bonsai.bim
import bonsai.core.model as core_model
import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore
from bonsai.bim.decorator_cache import (
install_decorator_cache_handlers,
uninstall_decorator_cache_handlers,
)
from bonsai.bim.ifc import IfcStore, get_cache_or_detect_lock
from bonsai.bim.module.aggregate.decorator import AggregateDecorator
from bonsai.bim.module.georeference.decorator import GeoreferenceDecorator
from bonsai.bim.module.model.data import AuthoringData
@@ -41,6 +47,7 @@ from bonsai.bim.module.model.decorator import (
SlabDirectionDecorator,
WallAxisDecorator,
)
from bonsai.bim.module.model.preview_base import discard_pending_previews
from bonsai.bim.module.nest.decorator import NestDecorator
cwd = os.path.dirname(os.path.realpath(__file__))
@@ -133,14 +140,32 @@ def update_bim_tool_props():
if is_annotation_tool and (object_type := tool.Drawing.get_annotation_type_object_type(element_type)):
aprops.object_type = object_type
aprops.relating_type_id = str(element_type.id())
try:
aprops.relating_type_id = str(element_type.id())
except TypeError:
# EnumProperty items are rebuilt asynchronously when ifc_class changes;
# this assignment can race a stale item list. Skipping is harmless —
# the UI will resync on the next active_object_callback.
pass
return
if is_bim_tool:
props.ifc_class = element_type.is_a()
if is_bim_tool or TOOLS_TO_CLASSES_MAP.get(current_tool.idname) == element_type.is_a():
props.relating_type_id = str(element_type.id())
# Only assign when the target enum is the one that lists this type — otherwise
# we hit `enum "<id>" not found in (...)` if the user selects an element of a
# different class than the workspace tool was built for (e.g. selecting a wall
# while the door tool is active).
tool_class_match = TOOLS_TO_CLASSES_MAP.get(current_tool.idname) == element_type.is_a()
bim_tool_class_match = is_bim_tool and props.ifc_class == element_type.is_a()
if bim_tool_class_match or tool_class_match:
try:
props.relating_type_id = str(element_type.id())
except TypeError:
# Defensive: the enum item list can lag behind ifc_class assignment
# above. Skipping leaves the panel briefly out of sync rather than
# crashing the handler (which Blender re-fires on every selection).
pass
if is_annotation_tool:
return
@@ -165,7 +190,9 @@ def update_bim_tool_props():
if AuthoringData.data["active_material_usage"] == "LAYER2":
x_angle = get_x_angle(extrusion)
axis = tool.Model.get_wall_axis(obj)["reference"]
props.extrusion_depth = abs(extrusion.Depth * si_conversion * cos(x_angle))
props.extrusion_depth = core_model.vertical_height_from_extrusion_depth(
extrusion.Depth * si_conversion, x_angle
)
props.length = (axis[1] - axis[0]).length
props.x_angle = x_angle
@@ -356,8 +383,10 @@ def subscribe_to_viewport_shading_changes():
)
@persistent
def load_post(scene):
def _apply_save_file_invariants(scene: bpy.types.Scene) -> None:
"""Invariants enforced on every load_post: msgbus subscription, IFC owner
settings, scene-bound caches, draft-flag healing, multi-instance lock probe,
and previews discarded so saved preview state never resurfaces on reopen."""
global global_subscription_owner
active_object_key = bpy.types.LayerObjects, "active"
bpy.msgbus.subscribe_rna(
@@ -368,6 +397,24 @@ def load_post(scene):
ifcopenshell.api.owner.settings.get_application = get_application
AuthoringData.type_thumbnails = {}
tool.Parametric.heal_stale_edit_flags()
discard_pending_previews(scene)
if tool.Ifc.get() and bpy.data.is_saved:
props = tool.Blender.get_bim_props()
props.has_blend_warning = True
# Probe the H5 cooked-geometry cache so the multi-instance warning surfaces
# right after .blend load. Without this, the lock is only detected when a
# mutation triggers ``clear_cache`` — by which time the user has already
# made changes that may now conflict with the other Blender instance.
if tool.Ifc.get():
get_cache_or_detect_lock()
def _apply_user_preferences() -> None:
"""User-preference-driven UI setup: toolbar, BIM workspace, viewport shading
subscription, scene-panel hijack, tab layout, snap defaults."""
preferences = tool.Blender.get_addon_preferences()
if not preferences.should_setup_toolbar:
tool.Blender.unregister_toolbar()
@@ -391,11 +438,21 @@ def load_post(scene):
tool.Blender.override_scene_panel(panel)
tool.Blender.setup_tabs()
if tool.Ifc.get() and bpy.data.is_saved:
props = tool.Blender.get_bim_props()
props.has_blend_warning = True
if preferences.should_use_snap and (scene := bpy.context.scene):
# Snapping is off by default in Blender, but in BIM, it's more useful to be on
scene.tool_settings.use_snap = True
# Match default Bonsai snaps
scene.tool_settings.snap_elements_base = {"EDGE", "EDGE_PERPENDICULAR", "VERTEX", "EDGE_MIDPOINT", "FACE"}
# Bonsai overlays
tool.Blender.sync_old_preferences()
def _install_viewport_overlays() -> None:
"""Sync every Bonsai viewport decorator to its enabled state.
Wrapped in uninstall/install of the decorator-cache bump handlers so a
decorator's own install path doesn't double-bind to depsgraph_update_post
via ``TokenCache`` instances created during their own ``install()``."""
georeference_props = tool.Georeference.get_georeference_props()
aggregate_props = tool.Aggregate.get_aggregate_props()
nest_props = tool.Nest.get_nest_props()
@@ -405,23 +462,26 @@ def load_post(scene):
NestDecorator.uninstall()
WallAxisDecorator.uninstall()
SlabDirectionDecorator.uninstall()
if georeference_props.should_visualise:
GeoreferenceDecorator.install(bpy.context)
if aggregate_props.aggregate_decorator:
AggregateDecorator.install(bpy.context)
if nest_props.nest_decorator:
NestDecorator.install(bpy.context)
if model_props.show_wall_axis:
WallAxisDecorator.install(bpy.context)
if model_props.show_slab_direction:
SlabDirectionDecorator.install(bpy.context)
if model_props.show_bounding_box:
BoundingBoxDecorator.install(bpy.context)
uninstall_decorator_cache_handlers()
try:
if georeference_props.should_visualise:
GeoreferenceDecorator.install(bpy.context)
if aggregate_props.aggregate_decorator:
AggregateDecorator.install(bpy.context)
if nest_props.nest_decorator:
NestDecorator.install(bpy.context)
if model_props.show_wall_axis:
WallAxisDecorator.install(bpy.context)
if model_props.show_slab_direction:
SlabDirectionDecorator.install(bpy.context)
if model_props.show_bounding_box:
BoundingBoxDecorator.install(bpy.context)
finally:
install_decorator_cache_handlers()
if preferences.should_use_snap and (scene := bpy.context.scene):
# Snapping is off by default in Blender, but in BIM, it's more useful to be on
scene.tool_settings.use_snap = True
# Match default Bonsai snaps
scene.tool_settings.snap_elements_base = {"EDGE", "EDGE_PERPENDICULAR", "VERTEX", "EDGE_MIDPOINT", "FACE"}
tool.Blender.sync_old_preferences()
@persistent
def load_post(scene):
_apply_save_file_invariants(scene)
_apply_user_preferences()
_install_viewport_overlays()
+40 -1
View File
@@ -64,6 +64,44 @@ class TransactionStep(TypedDict):
operations: list[Operation]
# Set when ``IfcStore.get_cache`` observes an external lock on the HDF5 cache —
# signal that another Blender process has the same IFC file open. Project panel
# polls ``is_cache_locked_by_other_process`` to warn the user. The dismissed
# flag is sticky per-session so the warning doesn't re-nag once the user has
# acknowledged it.
_cache_locked_by_other_process: bool = False
_multi_instance_warning_dismissed: bool = False
def is_cache_locked_by_other_process() -> bool:
return _cache_locked_by_other_process and not _multi_instance_warning_dismissed
def dismiss_multi_instance_warning() -> None:
global _multi_instance_warning_dismissed
_multi_instance_warning_dismissed = True
def get_cache_or_detect_lock() -> ifcopenshell.geom.serializers.hdf5 | None:
"""Like ``IfcStore.get_cache`` but tracks the multi-instance lock flag — sets
it on ``PermissionError``, clears it (along with the dismiss flag) when a
subsequent call succeeds. Returns ``None`` on lock; other exceptions
propagate. Callers that don't need the warning side effect can use
``IfcStore.get_cache`` directly."""
global _cache_locked_by_other_process, _multi_instance_warning_dismissed
try:
cache = IfcStore.get_cache()
except PermissionError:
_cache_locked_by_other_process = True
return None
if _cache_locked_by_other_process:
# Lock released — clear both flags so a future re-locking re-surfaces
# the warning rather than staying suppressed by the previous dismiss.
_cache_locked_by_other_process = False
_multi_instance_warning_dismissed = False
return cache
class IfcStore:
path: str = ""
"""Should be set only using ``tool.Ifc.set_path``."""
@@ -196,7 +234,7 @@ class IfcStore:
shutil.copy2(IfcStore.cache_path, new_cache_path)
except PermissionError:
pass # Well we tried. No cache for you!
IfcStore.get_cache()
get_cache_or_detect_lock()
@staticmethod
def load_file(path: str) -> None:
@@ -514,6 +552,7 @@ class IfcStore:
BrickStore.end_transaction()
IfcStore.end_transaction(operator)
bonsai.bim.handler.refresh_ui_data()
tool.Parametric.refresh_post_commit()
if method == "MODAL":
cls.modal_in_progress = False
@@ -15,6 +15,8 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
import bpy
@@ -143,6 +145,14 @@ classes = (
gizmos.GizmoCancel,
gizmos.GizmoPlus,
gizmos.GizmoMinus,
gizmos.GizmoMerge,
gizmos.GizmoSplit,
gizmos.GizmoExtend,
gizmos.GizmoExtendVertical,
gizmos.GizmoOffsetExterior,
gizmos.GizmoOffsetCenter,
gizmos.GizmoOffsetInterior,
gizmos.GizmoAddOpening,
gizmos.GizmoCycle,
# Drawing-specific gizmos
gizmos.UglyDotGizmo,
+418 -73
View File
@@ -16,6 +16,8 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
"""
Gizmo infrastructure for parametric BIM element editing.
@@ -511,6 +513,7 @@ class DimensionTextRenderer:
color: tuple[float, float, float],
offset_sign: int = 1,
alignment: TextAlignment | str = TextAlignment.CENTER,
display_text: str | None = None,
) -> None:
"""Draw formatted dimension value text at the given screen position.
@@ -522,15 +525,20 @@ class DimensionTextRenderer:
color: Text color (r, g, b)
offset_sign: 1 for above/right, -1 for below/left
alignment: TextAlignment enum value
display_text: Pre-formatted label. If provided, used verbatim instead of
formatting `value`.
"""
# Normalize string to enum for comparison
if isinstance(alignment, str):
alignment = TextAlignment(alignment)
is_negative = value < 0
text = tool.Unit.format_distance(abs(value))
if is_negative:
text = "-" + text
if display_text is not None:
text = display_text
else:
is_negative = value < 0
text = tool.Unit.format_distance(abs(value))
if is_negative:
text = "-" + text
font_id = 0
font_size = tool.Blender.scale_font_size(self.VALUE_FONT_SIZE)
@@ -795,6 +803,7 @@ class DimensionRenderer:
text_alignment: TextAlignment = TextAlignment.CENTER,
prop_name: str | None = None,
display_value: float | None = None,
display_text: str | None = None,
) -> None:
"""Draw complete dimension graphics in screen space.
@@ -816,6 +825,8 @@ class DimensionRenderer:
text_alignment: TextAlignment enum for text positioning
prop_name: Property name for tooltip (shown when highlighted)
display_value: Value to display as text (can be negative); uses dimension_length if None
display_text: Pre-formatted label string. If provided, used verbatim instead of
formatting `display_value` via tool.Unit.format_distance.
"""
if dimension_length < 0:
return
@@ -935,7 +946,14 @@ class DimensionRenderer:
)
text_color = highlight_color if is_highlight else color
DimensionTextRenderer.get_instance().draw_value_text(
context, center_screen, perpendicular, text_value, text_color, text_offset_sign, text_alignment
context,
center_screen,
perpendicular,
text_value,
text_color,
text_offset_sign,
text_alignment,
display_text,
)
if is_highlight and prop_name:
@@ -1121,6 +1139,13 @@ class DimensionGizmoConfig:
If provided, eliminates need for get_dimension_matrix_{attr_name} method.
The returned Vector is the local-space position where the gizmo origin
will be placed. Combined with axis to create the full transformation matrix.
text_formatter: Optional function(props, value) -> str for the dimension label.
Receives the props bag and the post-`compute_value` display value
(i.e. the same number `apply_value` consumes during drag — for the
wall slope gizmo this is the displacement, NOT the underlying
`x_angle`). The raw underlying attribute is accessible as
`getattr(props, attr_name)`. If None, falls back to the default
`tool.Unit.format_distance(abs(value))` with negative-sign handling.
"""
attr_name: str
@@ -1138,6 +1163,7 @@ class DimensionGizmoConfig:
apply_value: Callable[[Any, float], None] | None = None
visibility_condition: Callable[[Any], bool] | None = None
matrix_position: Callable[[Any], "Vector"] | None = None # Optional: function(props) -> Vector position
text_formatter: Callable[[Any, float], str] | None = None # Optional: function(props, value) -> label text
def __post_init__(self):
# Validate attr_name
@@ -1576,6 +1602,78 @@ def get_billboard_rotation(context: bpy.types.Context) -> Matrix:
return rv3d.view_matrix.to_3x3().transposed().to_4x4()
def billboarded_at(world_pos: Vector, billboard_rot: Matrix, scale: float = 0.5) -> Matrix:
"""Compose the standard icon ``matrix_basis``: translate to ``world_pos``, billboard
to the camera, then uniformly scale. Replaces the repeated
``Matrix.Translation(...) @ billboard_rot @ Matrix.Scale(scale, 4)`` pattern."""
return Matrix.Translation(world_pos) @ billboard_rot @ Matrix.Scale(scale, 4)
def setup_icon_gizmo(
gizmo_group: bpy.types.GizmoGroup,
gizmo_type: str,
color: tuple[float, float, float],
highlight_color: tuple[float, float, float],
operator: str,
alpha: float = 0.8,
) -> bpy.types.Gizmo:
"""Create and configure a stand-alone icon gizmo with the Bonsai defaults
(no draw-scale, fixed alpha, click-to-operator). Use this from any
``GizmoGroup.setup`` to avoid hand-rolling the same five property assignments."""
gizmo = gizmo_group.gizmos.new(gizmo_type)
gizmo.use_draw_scale = False
gizmo.color = color
gizmo.color_highlight = highlight_color
gizmo.alpha = alpha
gizmo.target_set_operator(operator)
return gizmo
# --- Tris geometry helpers ----------------------------------------------------
# Shared by the icon ``bpy.types.Gizmo`` subclasses defined later in this module.
# Each gizmo declares a flat ``tris`` tuple of (x, y, z) vertices grouped into
# triangles of 3; these helpers compose tris from primitives so the per-gizmo
# definitions stay small and visually readable.
def rect_tris(x0: float, y0: float, x1: float, y1: float) -> tuple[tuple[float, float, float], ...]:
"""Two triangles forming an axis-aligned rectangle from ``(x0, y0)`` to ``(x1, y1)``,
in the Z=0 plane (the convention for icon gizmos)."""
return (
(x0, y0, 0.0),
(x0, y1, 0.0),
(x1, y1, 0.0),
(x0, y0, 0.0),
(x1, y1, 0.0),
(x1, y0, 0.0),
)
def swap_xy_tris(
tris: tuple[tuple[float, float, float], ...],
) -> tuple[tuple[float, float, float], ...]:
"""Reflect a ``tris`` tuple across the Y=X diagonal — useful when a "vertical"
sibling of a "horizontal" icon should otherwise be a literal copy."""
return tuple((y, x, z) for x, y, z in tris)
class TrisGizmoMixin:
"""Mixin for stand-alone ``bpy.types.Gizmo`` classes whose only behaviour is
drawing a static ``tris`` triangle tuple. Subclasses set the class-level
``tris`` and ``bl_idname`` attributes; the mixin supplies ``setup`` / ``draw`` /
``draw_select``. Use only with gizmos that have no per-instance state beyond
``custom_shape``."""
def setup(self) -> None:
self.custom_shape = self.new_custom_shape("TRIS", self.tris)
def draw(self, context: bpy.types.Context) -> None:
self.draw_custom_shape(self.custom_shape)
def draw_select(self, context: bpy.types.Context, select_id: int) -> None:
self.draw_custom_shape(self.custom_shape, select_id=select_id)
def get_camera_direction(context: bpy.types.Context, position: Vector) -> Vector | None:
"""Get normalized direction from position towards camera."""
rv3d = context.region_data
@@ -3042,6 +3140,145 @@ class GizmoMinus(bpy.types.Gizmo):
self.draw_custom_shape(self.custom_shape, select_id=select_id)
class GizmoMerge(TrisGizmoMixin, bpy.types.Gizmo):
"""Two arrows pointing inward toward each other — conveys joining/merging elements."""
bl_idname = "VIEW3D_GT_merge"
__slots__ = ("custom_shape",)
# Two solid triangles pointing toward the center on the horizontal axis,
# plus two thin tails behind each tip to make them read as arrows rather than
# standalone triangles.
tris = (
# Left arrowhead pointing right (tip at x≈-0.05).
(-0.35, -0.20, 0.0),
(-0.35, 0.20, 0.0),
(-0.05, 0.0, 0.0),
# Left tail behind the arrowhead.
*rect_tris(-0.45, -0.06, -0.30, 0.06),
# Right arrowhead pointing left (tip at x≈0.05).
(0.35, -0.20, 0.0),
(0.35, 0.20, 0.0),
(0.05, 0.0, 0.0),
# Right tail behind the arrowhead.
*rect_tris(0.30, -0.06, 0.45, 0.06),
)
class GizmoSplit(TrisGizmoMixin, bpy.types.Gizmo):
"""Two arrows pointing outward away from each other — conveys splitting/cutting
one element into two. Visual inverse of `GizmoMerge`."""
bl_idname = "VIEW3D_GT_split"
__slots__ = ("custom_shape",)
# Two solid triangles pointing OUTWARD on the horizontal axis (tips at x=±0.35),
# with tails extending toward the centerline. The tails meet at center to form a
# short horizontal bar, suggesting the split point itself.
tris = (
# Left arrowhead pointing left (tip at x=-0.35).
(-0.05, -0.20, 0.0),
(-0.05, 0.20, 0.0),
(-0.35, 0.0, 0.0),
# Left tail extending toward the right (away from the tip, toward center).
*rect_tris(-0.05, -0.06, 0.10, 0.06),
# Right arrowhead pointing right (tip at x=0.35).
(0.05, -0.20, 0.0),
(0.05, 0.20, 0.0),
(0.35, 0.0, 0.0),
# Right tail extending toward the left.
*rect_tris(-0.10, -0.06, 0.05, 0.06),
)
class GizmoExtend(TrisGizmoMixin, bpy.types.Gizmo):
"""An arrow pointing into a vertical bar — conveys extending an element to a target
line (e.g. extending a wall to the 3D cursor)."""
bl_idname = "VIEW3D_GT_extend"
__slots__ = ("custom_shape",)
# Layout: thick vertical bar at the right edge (the "target") with a horizontal
# arrow pointing into it from the left.
tris = (
# Vertical target bar (x = 0.25 to 0.35, full height).
*rect_tris(0.25, -0.30, 0.35, 0.30),
# Arrowhead pointing right toward the bar (tip at x=0.20).
(-0.05, -0.18, 0.0),
(-0.05, 0.18, 0.0),
(0.20, 0.0, 0.0),
# Tail extending leftward from the arrowhead base.
*rect_tris(-0.35, -0.06, -0.05, 0.06),
)
class GizmoExtendVertical(TrisGizmoMixin, bpy.types.Gizmo):
"""Vertical sibling of `GizmoExtend` — arrow pointing UP into a horizontal
bar. Conveys extending an element's height to a target Z."""
bl_idname = "VIEW3D_GT_extend_vertical"
__slots__ = ("custom_shape",)
# Mechanically derived from GizmoExtend by reflecting across Y=X.
tris = swap_xy_tris(GizmoExtend.tris)
def _offset_baseline_tris(mark_x: float) -> tuple[tuple[float, float, float], ...]:
"""Shared geometry for the three offset-baseline icons: a horizontal "wall
section" bar with a vertical mark at ``mark_x`` indicating where the reference
axis sits within the wall thickness. Matches the visual convention used in the
Bonsai N-panel's wall Align row."""
return rect_tris(-0.25, -0.07, 0.25, 0.07) + rect_tris(mark_x - 0.04, -0.22, mark_x + 0.04, 0.22)
class GizmoOffsetExterior(TrisGizmoMixin, bpy.types.Gizmo):
"""Wall offset baseline indicator — reference axis at the exterior face (left mark)."""
bl_idname = "VIEW3D_GT_offset_exterior"
__slots__ = ("custom_shape",)
tris = _offset_baseline_tris(-0.24)
class GizmoOffsetCenter(TrisGizmoMixin, bpy.types.Gizmo):
"""Wall offset baseline indicator — reference axis at the centreline (middle mark)."""
bl_idname = "VIEW3D_GT_offset_center"
__slots__ = ("custom_shape",)
tris = _offset_baseline_tris(0.0)
class GizmoOffsetInterior(TrisGizmoMixin, bpy.types.Gizmo):
"""Wall offset baseline indicator — reference axis at the interior face (right mark)."""
bl_idname = "VIEW3D_GT_offset_interior"
__slots__ = ("custom_shape",)
tris = _offset_baseline_tris(0.24)
class GizmoAddOpening(TrisGizmoMixin, bpy.types.Gizmo):
"""A rectangular frame (square outline with a hole in the middle) — conveys adding an
opening (window/door/void) to a wall."""
bl_idname = "VIEW3D_GT_add_opening"
__slots__ = ("custom_shape",)
# Outer 0.40 × 0.40 square with a 0.25 × 0.25 inner hole, drawn as four bars
# forming a frame, plus a small "+" in the inner hole to convey "add".
tris = (
*rect_tris(-0.20, 0.125, 0.20, 0.20), # Top bar
*rect_tris(-0.20, -0.20, 0.20, -0.125), # Bottom bar
*rect_tris(-0.20, -0.125, -0.125, 0.125), # Left bar
*rect_tris(0.125, -0.125, 0.20, 0.125), # Right bar
*rect_tris(-0.07, -0.015, 0.07, 0.015), # "+" horizontal stroke
*rect_tris(-0.015, -0.07, 0.015, 0.07), # "+" vertical stroke
)
def _generate_circular_arrow_tris() -> tuple[tuple[float, float, float], ...]:
"""Generate circular arrow geometry covering ~300 degrees."""
triangles = []
@@ -3421,6 +3658,7 @@ class GizmoDimension(GizmoMovable):
"_original_value", # Original property value before interaction
"_click_offset", # Offset from dimension tip to click position (for snap correction)
"show_extension_lines", # Whether to show extension lines at dimension endpoints
"text_formatter", # Optional (props, value) -> str to override the default dimension label
)
ARROW_SIZE = 10
@@ -3479,6 +3717,16 @@ class GizmoDimension(GizmoMovable):
start_world = self.matrix_basis.translation.copy()
end_world = start_world + axis_world * self._dimension_length
display_value = getattr(self, "_display_value", self._dimension_length)
text_formatter = getattr(self, "text_formatter", None)
gizmo_group = getattr(self, "gizmo_group", None)
display_text: str | None = None
if text_formatter is not None and gizmo_group is not None:
obj = bpy.context.active_object
props = gizmo_group.get_props(obj) if obj is not None else None
if props is not None:
display_text = text_formatter(props, display_value)
DimensionRenderer.get_instance().draw(
context=context,
start_world=start_world,
@@ -3496,7 +3744,8 @@ class GizmoDimension(GizmoMovable):
text_offset_sign=getattr(self, "text_offset_sign", 1),
text_alignment=getattr(self, "text_alignment", TextAlignment.CENTER),
prop_name=getattr(self, "prop_name", None),
display_value=getattr(self, "_display_value", self._dimension_length),
display_value=display_value,
display_text=display_text,
)
def _calculate_screen_endpoints(self, context: bpy.types.Context) -> tuple[Vector, Vector, Vector, float] | None:
@@ -3615,6 +3864,11 @@ class GizmoDimension(GizmoMovable):
self._display_value = max(-10000.0, min(length, 10000.0))
# Clamp to valid range (0 to 10000 meters is reasonable for BIM) for drawing
self._dimension_length = max(0.0, min(abs(length), 10000.0))
# Smaller dimensions win selection when hit regions overlap: a long gizmo's
# hit box fully contains a nested short one's, so without a bias the long
# one wins and the short one is unreachable. The long one stays clickable
# at its exposed ends regardless of bias.
self.select_bias = -self._dimension_length
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set:
"""Initialize dimension gizmo interaction with click-position tracking.
@@ -3913,6 +4167,59 @@ class CycleTypeMixin:
return {"FINISHED"}
class BillboardingGizmoGroupMixin:
"""Mixin for standalone ``bpy.types.GizmoGroup`` classes whose icons must billboard
(face the camera) and re-position every frame.
Blender calls ``GizmoGroup.refresh()`` only on state-change events (selection,
property change, dependency update) — not on camera rotation. A gizmo group that
only sets ``matrix_basis`` in ``refresh()`` will appear to "freeze" its rotation
at the camera angle in effect when it was last refreshed; orbiting the camera
leaves the icon facing the wrong way.
``draw_prepare()`` *is* called every redraw, so the fix is to run the same
positioning code from both events. Rather than overriding ``refresh()`` and
``draw_prepare()`` in every gizmo group that has this need, subclass this mixin
and implement a single ``position_gizmos(context)`` method.
Usage::
class MyGizmoGroup(bpy.types.GizmoGroup, BillboardingGizmoGroupMixin):
bl_idname = "..."
...
def setup(self, context):
...
def position_gizmos(self, context):
# set matrix_basis on every gizmo here, using get_billboard_rotation
# for any icon that should face the camera.
...
``position_gizmos`` should be idempotent — it's called twice when a state change
coincides with a redraw (once via ``refresh``, once via ``draw_prepare``)."""
def refresh(self, context: bpy.types.Context) -> None:
self.position_gizmos(context)
def draw_prepare(self, context: bpy.types.Context) -> None:
self.position_gizmos(context)
def setup_icon_gizmo(
self,
gizmo_type: str,
color: tuple[float, float, float],
highlight_color: tuple[float, float, float],
operator: str,
alpha: float = 0.8,
) -> bpy.types.Gizmo:
"""Convenience wrapper over `setup_icon_gizmo` for subclasses."""
return setup_icon_gizmo(self, gizmo_type, color, highlight_color, operator, alpha)
def position_gizmos(self, context: bpy.types.Context) -> None:
raise NotImplementedError(
f"{type(self).__name__} must implement position_gizmos(context) when using BillboardingGizmoGroupMixin."
)
class BaseParametricGizmoGroup:
"""Base mixin for parametric element gizmo groups (doors, windows, stairs, etc.).
@@ -4129,6 +4436,32 @@ class BaseParametricGizmoGroup:
return width + (self.GIZMO_OFFSET if use_offset else 0)
return -self.GIZMO_OFFSET if use_offset else 0
@staticmethod
def get_camera_facing_outer_y(
viewing_from_negative_y: bool,
near_y: float,
far_y: float,
gizmo_offset: float = 0.0,
) -> float:
"""Y coordinate just outside the camera-facing face of an element.
Generalises `get_y_position_for_view` for elements whose near face
isn't at the local origin. ``near_y`` is the local-Y of the -Y face;
``far_y`` is the local-Y of the +Y face. Returns the Y just *outside* the
face the camera is currently looking at, pushed by ``gizmo_offset`` (use
``cls.GIZMO_OFFSET`` for the standard handle gap).
Suits walls (``near_y = props.offset``, ``far_y = props.offset + props.thickness``)
and any other element whose section sits inside a non-zero Y band. Stair /
door / window can also call this once their callers pass explicit near/far
instead of the implicit ``width_attr`` pattern, eliminating
``get_y_position_for_view``, ``get_lining_y_position_for_view`` etc. as
wrappers around the same shape — but they're left intact for now to avoid
churning code paths that already work."""
if viewing_from_negative_y:
return near_y - gizmo_offset
return far_y + gizmo_offset
def get_icon_y_for_view(self, props, viewing_from_negative_y: bool) -> float:
"""Get Y position for editing icons based on view direction.
@@ -4224,13 +4557,13 @@ class BaseParametricGizmoGroup:
"""
return 0.0
def _update_view_dependent_dimensions(self, context: bpy.types.Context, mw: Matrix, props) -> None:
def _update_view_dependent_dimensions(self, context: bpy.types.Context, mw: Matrix, props) -> None: # noqa: ARG002
"""Update overall_width, overall_height, and lining_offset based on view direction.
This base implementation handles the common pattern for door/window gizmos.
Subclasses can override get_casing_offset() to customize behavior.
"""
viewing_from_negative_y, viewing_from_negative_x = self.get_local_view_direction(context, mw)
viewing_from_negative_y, viewing_from_negative_x = self._frame_view_dir
y_pos = self.get_lining_y_position_for_view(props, viewing_from_negative_y)
self.set_dimension_gizmo_position("overall_width", mw, Vector((0, y_pos, -self.GIZMO_OFFSET)), (1, 0, 0))
@@ -4309,21 +4642,15 @@ class BaseParametricGizmoGroup:
@classmethod
def poll(cls, context) -> bool:
prefs = tool.Blender.get_addon_preferences()
if not prefs.gizmos.draw_gizmos_in_3d_viewport:
return False
obj = tool.Blender.get_active_object(is_selected=True)
if not obj:
if obj is None:
return False
if not tool.Blender.get_addon_preferences().gizmos.draw_gizmos_in_3d_viewport:
return False
if len(tool.Blender.get_selected_objects()) != 1:
return False
element = tool.Ifc.get_entity(obj)
if not element or not cls.is_element_type(element):
return False
return True
return bool(element) and cls.is_element_type(element)
def setup(self, context: bpy.types.Context) -> None:
"""Template method for gizmo setup.
@@ -4343,6 +4670,19 @@ class BaseParametricGizmoGroup:
"""
pass
# Frame-scoped caches primed at the top of ``refresh()`` and ``draw_prepare()``.
# Every per-frame helper — preferences access, view-direction lookup, billboard
# rotation — reads these instead of re-deriving the same values, since each
# gizmo group ends up needing them 25× per frame across its position helpers.
_frame_prefs: Any = None
_frame_view_dir: tuple[bool, bool] | None = None
_frame_billboard_rot: "Matrix | None" = None
def _prime_frame_caches(self, context: bpy.types.Context, mw: "Matrix") -> None:
self._frame_prefs = tool.Blender.get_addon_preferences()
self._frame_view_dir = self.get_local_view_direction(context, mw)
self._frame_billboard_rot = get_billboard_rotation(context)
def refresh(self, context: bpy.types.Context) -> None:
"""Template method for gizmo refresh.
@@ -4357,6 +4697,7 @@ class BaseParametricGizmoGroup:
props = self.get_props(obj)
mw = obj.matrix_world
self._prime_frame_caches(context, mw)
self.update_editing_gizmos(context, mw, props)
self.update_dimension_gizmos(mw, props)
self._refresh_element_specific(context, mw, props)
@@ -4364,8 +4705,10 @@ class BaseParametricGizmoGroup:
def _refresh_element_specific(self, context: bpy.types.Context, mw: "Matrix", props) -> None: # noqa: ARG002
"""Override for element-specific refresh logic.
Called after update_editing_gizmos and update_dimension_gizmos.
Examples: door swing gizmos, stair lock/tread/plus/minus gizmos.
Called from both refresh() (on state change) and draw_prepare() (per frame),
so any override must be idempotent and cheap. Use this to re-position or
re-billboard element-specific gizmos (door swing arcs, stair lock/+/- icons,
wall cursor icons, etc.).
"""
pass
@@ -4385,10 +4728,11 @@ class BaseParametricGizmoGroup:
return getattr(tool.Model, self.props_getter)(obj)
raise NotImplementedError("Subclass must define props_getter or override get_props()")
@staticmethod
def get_addon_prefs():
"""Get addon preferences (cached accessor)."""
return tool.Blender.get_addon_preferences()
def get_addon_prefs(self):
"""Return the addon preferences struct. Inside ``refresh`` / ``draw_prepare``
the frame cache is hit; outside (e.g. ``setup``) we fall through to a fresh
lookup so callers don't have to know which call path they're on."""
return self._frame_prefs if self._frame_prefs is not None else tool.Blender.get_addon_preferences()
def get_decoration_colors(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
"""Get default and highlight colors from preferences.
@@ -4507,8 +4851,8 @@ class BaseParametricGizmoGroup:
scale: Gizmo scale factor (default 0.5)
"""
if gz := self.get_gizmo_if_visible(gizmo_name):
local_transform = Matrix.Translation(Vector((x, y, z))) @ billboard_rot @ Matrix.Scale(scale, 4)
gz.matrix_basis = mw @ local_transform
world_pos = mw @ Vector((x, y, z))
gz.matrix_basis = billboarded_at(world_pos, billboard_rot, scale)
def set_dimension_gizmo_position(
self,
@@ -4594,28 +4938,12 @@ class BaseParametricGizmoGroup:
) -> bpy.types.Gizmo:
"""Create and configure an icon gizmo with standard settings.
Reduces boilerplate in setup_editing_gizmos.
Args:
gizmo_type: Blender gizmo type identifier (e.g., "VIEW3D_GT_pen")
color: RGB color tuple
operator: Operator to invoke on click
highlight_color: Optional highlight color (defaults to prefs selection color)
alpha: Gizmo alpha (default 0.8)
Returns:
Configured gizmo instance.
Thin wrapper over `setup_icon_gizmo` that defaults ``highlight_color``
to the addon-prefs selection color via ``get_decoration_colors``.
"""
if highlight_color is None:
_, highlight_color = self.get_decoration_colors()
gizmo = self.gizmos.new(gizmo_type)
gizmo.use_draw_scale = False
gizmo.color = color
gizmo.color_highlight = highlight_color
gizmo.alpha = alpha
gizmo.target_set_operator(operator)
return gizmo
return setup_icon_gizmo(self, gizmo_type, color, highlight_color, operator, alpha)
def setup_editing_gizmos(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
@@ -4696,6 +5024,7 @@ class BaseParametricGizmoGroup:
gizmo.delta_scale = config.delta_scale
gizmo.prop_name = config.prop_name # Auto-derived in __post_init__
gizmo.gizmo_group = self
gizmo.text_formatter = config.text_formatter
gizmo.color = self.get_color_from_name(config.color)
gizmo.color_highlight = highlight_color
gizmo.alpha = 1.0
@@ -4723,10 +5052,9 @@ class BaseParametricGizmoGroup:
gizmo.hide = False
# Priority: config.matrix_position > get_dimension_matrix_* method > Identity
# Priority: config.matrix_position > get_dimension_matrix_* method > Identity.
if config.matrix_position:
position = config.matrix_position(props)
base_matrix = self.compose_gizmo_matrix(position, config.axis)
base_matrix = self.compose_gizmo_matrix(config.matrix_position(props), config.axis)
else:
matrix_method = getattr(self, f"get_dimension_matrix_{config.attr_name}", None)
base_matrix = matrix_method(props) if matrix_method else Matrix.Identity(4)
@@ -4758,7 +5086,7 @@ class BaseParametricGizmoGroup:
"""
return (0.0, 0.0)
def get_icon_y_offset(self, context: bpy.types.Context, mw: Matrix) -> float:
def get_icon_y_offset(self, context: bpy.types.Context, mw: Matrix) -> float: # noqa: ARG002
"""Get Y offset for icons based on view direction.
Uses get_icon_y_extent() to determine how far to offset icons based on
@@ -4774,8 +5102,7 @@ class BaseParametricGizmoGroup:
props = self.get_props(obj)
positive_extent, negative_extent = self.get_icon_y_extent(props)
viewing_from_negative_y, _ = self.get_local_view_direction(context, mw)
if viewing_from_negative_y:
if self._frame_view_dir[0]:
return -negative_extent
return positive_extent
@@ -4783,34 +5110,40 @@ class BaseParametricGizmoGroup:
"""Update editing icon gizmo positions to billboard toward camera."""
icon_z = self.get_element_height(props) + self.ICON_Z_OFFSET
icon_y = self.get_icon_y_offset(context, mw)
billboard_rot = get_billboard_rotation(context)
# This ensures icons face camera regardless of object rotation
local_pos_validate = Vector((self.ICON_VALIDATE_X, icon_y, icon_z))
world_pos_validate = mw @ local_pos_validate
icon_matrix_base = Matrix.Translation(world_pos_validate) @ billboard_rot @ Matrix.Scale(0.5, 4)
billboard_rot = self._frame_billboard_rot
# set_icon_gizmo_position no-ops on hidden gizmos (via get_gizmo_if_visible),
# so the hide flag must be set first; that gates whether the matrix is written.
if props.is_editing:
self.pen_gizmo.hide = True
self.validate_gizmo.hide = self.is_gizmo_hidden_by_modal(self.validate_gizmo)
self.validate_gizmo.matrix_basis = icon_matrix_base
self.set_icon_gizmo_position(
"validate_gizmo", mw=mw, x=self.ICON_VALIDATE_X, y=icon_y, z=icon_z, billboard_rot=billboard_rot
)
self.cancel_gizmo.hide = self.is_gizmo_hidden_by_modal(self.cancel_gizmo)
local_pos_cancel = Vector((self.ICON_VALIDATE_X + self.ICON_CANCEL_X, icon_y, icon_z))
world_pos_cancel = mw @ local_pos_cancel
self.cancel_gizmo.matrix_basis = Matrix.Translation(world_pos_cancel) @ billboard_rot @ Matrix.Scale(0.5, 4)
self.set_icon_gizmo_position(
"cancel_gizmo",
mw=mw,
x=self.ICON_VALIDATE_X + self.ICON_CANCEL_X,
y=icon_y,
z=icon_z,
billboard_rot=billboard_rot,
)
if self.cycle_type_operator:
self.cycle_gizmo.hide = self.is_gizmo_hidden_by_modal(self.cycle_gizmo)
local_pos_cycle = Vector((self.ICON_VALIDATE_X + self.ICON_CYCLE_X, icon_y, icon_z))
world_pos_cycle = mw @ local_pos_cycle
self.cycle_gizmo.matrix_basis = (
Matrix.Translation(world_pos_cycle) @ billboard_rot @ Matrix.Scale(0.30, 4)
self.set_icon_gizmo_position(
"cycle_gizmo",
mw=mw,
x=self.ICON_VALIDATE_X + self.ICON_CYCLE_X,
y=icon_y,
z=icon_z,
billboard_rot=billboard_rot,
scale=0.30,
)
else:
self.pen_gizmo.hide = self.is_gizmo_hidden_by_modal(self.pen_gizmo)
self.pen_gizmo.matrix_basis = icon_matrix_base
self.set_icon_gizmo_position(
"pen_gizmo", mw=mw, x=self.ICON_VALIDATE_X, y=icon_y, z=icon_z, billboard_rot=billboard_rot
)
self.validate_gizmo.hide = True
self.cancel_gizmo.hide = True
if self.cycle_type_operator:
@@ -4819,16 +5152,26 @@ class BaseParametricGizmoGroup:
def draw_prepare(self, context: bpy.types.Context) -> None:
"""Called before drawing - updates gizmos to face camera.
This method updates editing gizmos and dimension gizmos.
Subclasses can override _update_dimension_gizmo_positions() to customize
dimension gizmo positioning based on view direction.
This method updates editing gizmos, dimension gizmos, and element-specific
gizmos. Subclasses can override _update_dimension_gizmo_positions() to
customize dimension gizmo positioning, and _refresh_element_specific() to
re-billboard element-specific gizmos per frame.
"""
obj = context.active_object
if not obj:
return
props = self.get_props(obj)
mw = obj.matrix_world
self._prime_frame_caches(context, mw)
self.update_editing_gizmos(context, mw, props)
# `update_dimension_gizmos` flips the dimension gizmos' `hide` flag
# based on `props.is_editing` + per-config visibility conditions.
# `refresh()` already calls it, but `refresh()` only fires on depsgraph
# events — a `finish_editing_*` operator that toggles `is_editing` to
# False without mutating IFC (e.g. wall no-op commit, cancel) does not
# trigger a depsgraph update, so without this call the dimension gizmos
# would stay visible until the next user input.
self.update_dimension_gizmos(mw, props)
self._update_dimension_gizmo_positions(context, mw, props)
@@ -4836,6 +5179,8 @@ class BaseParametricGizmoGroup:
for _, gizmo in self.iter_visible_dimension_gizmos():
gizmo.draw_prepare(context)
self._refresh_element_specific(context, mw, props)
def _update_dimension_gizmo_positions(
self, context: bpy.types.Context, mw: "Matrix", props # noqa: ARG002
) -> None:
@@ -1183,7 +1183,7 @@ class OverrideDuplicateMove(bpy.types.Operator):
operator: bpy.types.Operator, context: bpy.types.Context, linked: bool = False
) -> set["rna_enums.OperatorReturnItems"]:
# Deep magick from the dawn of time
if tool.Ifc.get():
if tool.Ifc.get() and tool.Model.has_selected_ifc_objects(include_active=False):
IfcStore.execute_ifc_operator(operator, context)
return {"FINISHED"}
@@ -1287,6 +1287,11 @@ class OverrideDuplicateMove(bpy.types.Operator):
if part_obj:
all_objects_to_select.add(part_obj)
# Non-IFC duplicates aren't tracked in old_to_new but are left selected by duplicate_ifc_objects
all_objects_to_select.update(
obj for obj in context.selected_objects if not tool.Ifc.get_entity(obj)
)
# Deselect everything first
bpy.ops.object.select_all(action="DESELECT")
@@ -637,6 +637,16 @@ class EditAssignedMaterial(bpy.types.Operator, tool.Ifc.Operator):
usage=material_set_usage,
attributes=attributes,
)
for obj in objects:
obj_element = tool.Ifc.get_entity(obj)
if not obj_element:
continue
obj_material_usage = ifcopenshell.util.element.get_material(obj_element)
if obj_material_usage and obj_material_usage.is_a("IfcMaterialProfileSetUsage"):
obj_material_usage.CardinalPoint = material_set_usage.CardinalPoint
obj_material_usage.ReferenceExtent = material_set_usage.ReferenceExtent
model_profile.DumbProfileRecalculator().recalculate(objects)
bpy.ops.bim.disable_editing_assigned_material(obj=active_obj.name)
+24 -10
View File
@@ -15,11 +15,15 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
from typing import NamedTuple
import bpy
import bonsai.tool as tool
from . import (
array,
covering,
@@ -70,18 +74,32 @@ classes = (
workspace.BIM_MT_add_representation_item,
wall.AddWallsFromSlab,
wall.AlignWall,
wall.CancelEditingWall,
wall.ChangeExtrusionDepth,
wall.ChangeExtrusionXAngle,
wall.ChangeLayerLength,
wall.CycleWallOffset,
wall.DrawPolylineWall,
wall.EnableEditingWall,
wall.ExtendWallHeightToCursor,
wall.ExtendWallsToUnderside,
wall.ExtendWallsToWall,
wall.ExtendWallsToPolylinePoint,
wall.ExtendWallToCursor,
wall.FinishEditingWall,
wall.FlipWall,
wall.GizmoWallAddOpening,
wall.GizmoWallEdition,
wall.GizmoWallExtendVertically,
wall.GizmoWallJoinIntersection,
wall.JoinWallsIntersection,
wall.MergeWall,
wall.OffsetWalls,
wall.RecalculateWall,
wall.RotateWall90,
wall.SplitWall,
wall.SplitWallAtCursor,
wall.ToggleWallOpenings,
wall.UnjoinWalls,
opening.AddBoolean,
opening.CloneOpening,
@@ -140,10 +158,12 @@ classes = (
prop.BIMDoorProperties,
prop.BIMRailingProperties,
prop.BIMRoofProperties,
prop.BIMWallProperties,
prop.BIMPolylineProperties,
prop.BIMExternalParametricGeometryProperties,
ui.BIM_PT_array,
ui.BIM_PT_stair,
ui.BIM_PT_wall,
ui.BIM_PT_sverchok,
ui.BIM_PT_window,
ui.BIM_PT_door,
@@ -264,12 +284,10 @@ def register():
bpy.types.Scene.BIMModelProperties = bpy.props.PointerProperty(type=prop.BIMModelProperties)
bpy.types.Scene.BIMPolylineProperties = bpy.props.PointerProperty(type=prop.BIMPolylineProperties)
bpy.types.Object.BIMArrayProperties = bpy.props.PointerProperty(type=prop.BIMArrayProperties)
bpy.types.Object.BIMStairProperties = bpy.props.PointerProperty(type=prop.BIMStairProperties)
bpy.types.Object.BIMSverchokProperties = bpy.props.PointerProperty(type=prop.BIMSverchokProperties)
bpy.types.Object.BIMWindowProperties = bpy.props.PointerProperty(type=prop.BIMWindowProperties)
bpy.types.Object.BIMDoorProperties = bpy.props.PointerProperty(type=prop.BIMDoorProperties)
bpy.types.Object.BIMRailingProperties = bpy.props.PointerProperty(type=prop.BIMRailingProperties)
bpy.types.Object.BIMRoofProperties = bpy.props.PointerProperty(type=prop.BIMRoofProperties)
# Per-parametric-type ``BIM<Name>Properties`` PointerProperties — driven by
# ``tool.Parametric.EDIT_TYPES``; adding a registry entry is the single touchpoint.
tool.Parametric.register_object_properties(prop)
bpy.types.Object.BIMExternalParametricGeometryProperties = bpy.props.PointerProperty(
type=prop.BIMExternalParametricGeometryProperties
)
@@ -288,12 +306,8 @@ def unregister():
del bpy.types.Scene.BIMModelProperties
del bpy.types.Scene.BIMPolylineProperties
del bpy.types.Object.BIMArrayProperties
del bpy.types.Object.BIMStairProperties
del bpy.types.Object.BIMSverchokProperties
del bpy.types.Object.BIMWindowProperties
del bpy.types.Object.BIMDoorProperties
del bpy.types.Object.BIMRailingProperties
del bpy.types.Object.BIMRoofProperties
tool.Parametric.unregister_object_properties()
del bpy.types.Object.BIMExternalParametricGeometryProperties
bpy.app.handlers.load_post.remove(handler.load_post)
+35 -79
View File
@@ -38,6 +38,7 @@ import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.module.model.window import create_bm_box, create_bm_window
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
if TYPE_CHECKING:
from bonsai.bim.module.model.prop import BIMDoorProperties
@@ -566,103 +567,58 @@ class AddDoor(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class CancelEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
class _DoorEditMixin(FeatureModifierEditMixin):
"""Type-specific hooks for door parametric-edit operators. Multi-object —
iterates ``tool.Blender.get_selected_objects()`` so a finish/cancel applies
to every selected door at once."""
pset_name = "BBIM_Door"
@classmethod
def _iter_targets(cls, context: bpy.types.Context) -> list[bpy.types.Object]:
return tool.Blender.get_selected_objects()
@classmethod
def _is_element_type(cls, element):
return tool.Blender.Modifier.is_door(element)
@classmethod
def _get_props(cls, obj: bpy.types.Object):
return tool.Model.get_door_props(obj)
@classmethod
def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_door_modifier_representation(obj)
class CancelEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_door"
bl_label = "Cancel Editing Door on Selected Objects"
bl_description = "Cancel editing and revert door parameters to their previous values"
bl_options = {"REGISTER", "UNDO"}
def cancel_editing_door_on_object(self, obj: bpy.types.Object) -> None:
element = tool.Ifc.get_entity(obj)
assert element
if not tool.Blender.Modifier.is_door(element):
return
props = tool.Model.get_door_props(obj)
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
# restore previous settings since editing was canceled
props.set_props_kwargs_from_ifc_data(data)
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
core.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=body,
)
props.is_editing = False
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
for obj in tool.Blender.get_selected_objects():
self.cancel_editing_door_on_object(obj)
return {"FINISHED"}
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._cancel_targets(context)
class FinishEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
class FinishEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_door"
bl_label = "Finish Editing Door on Selected Objects"
bl_description = "Apply changes and finish editing door parameters"
bl_options = {"REGISTER", "UNDO"}
def finish_editing_door_on_object(self, obj: bpy.types.Object) -> None:
element = tool.Ifc.get_entity(obj)
assert element
if not tool.Blender.Modifier.is_door(element):
return
props = tool.Model.get_door_props(obj)
door_data = props.get_general_kwargs(convert_to_project_units=True)
lining_props = props.get_lining_kwargs(convert_to_project_units=True)
panel_props = props.get_panel_kwargs(convert_to_project_units=True)
door_data["lining_properties"] = lining_props
door_data["panel_properties"] = panel_props
props.is_editing = False
update_door_modifier_representation(obj)
element_type = ifcopenshell.util.element.get_type(element)
if element_type:
tool.Model.mark_thumbnail_for_update(element_type)
pset = tool.Pset.get_element_pset(element, "BBIM_Door")
door_data = tool.Ifc.get().createIfcText(json.dumps(door_data, default=list))
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": door_data})
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
for obj in tool.Blender.get_selected_objects():
self.finish_editing_door_on_object(obj)
return {"FINISHED"}
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._finish_targets(context)
class EnableEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
class EnableEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_door"
bl_label = "Enable Editing Door on Selected Objects"
bl_description = "Enter edit mode to modify door parameters interactively"
bl_options = {"REGISTER", "UNDO"}
def edit_door_on_obj(self, obj: bpy.types.Object) -> None:
element = tool.Ifc.get_entity(obj)
assert element
if not tool.Blender.Modifier.is_door(element):
return
props = tool.Model.get_door_props(obj)
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
data.update(tool.Model.get_constituents_props_data(element))
# required since we could load pset from .ifc and BIMDoorProperties won't be set
props.set_props_kwargs_from_ifc_data(data)
props.is_editing = True
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
for obj in tool.Blender.get_selected_objects():
self.edit_door_on_obj(obj)
return {"FINISHED"}
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._enable_targets(context)
class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
@@ -939,7 +895,7 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
def update_swing_gizmos(self, mw: Matrix, props: "BIMDoorProperties") -> None:
"""Update swing gizmo position and color based on editing state."""
prefs = tool.Blender.get_addon_preferences()
prefs = self.get_addon_prefs()
door_gizmo_prefs = prefs.gizmos.door
door_type_visible = self.update_gizmo_visibility(
@@ -0,0 +1,206 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Shared helpers for Bonsai's parametric preview flows.
Multiple Bonsai features follow the same Scene-level preview pattern:
Enable<X>Preview validates a selection, populates draft state on
``Scene.BIMPreviewProperties.<x>``, flips ``is_active``.
Gizmo<X>Preview polls on ``is_active``, surfaces tunable widgets +
validate/cancel icons.
<X>PreviewDecorator GPU lines drawn while ``is_active`` is True.
Finish<X>Preview direct ``bpy.ops.bim.<verb>(...)`` call with kwargs
read off the draft state, then clears it.
Cancel<X>Preview pure state reset.
The MEP bend and wall fillet flows are the two current callers. They write
their Finish / Cancel operators directly, matching the convention used
throughout the rest of ``bim/module/model/`` for operator-to-operator
dispatch (explicit ``bpy.ops.bim.X(kwarg=value)`` at the call site, no
string indirection). This module hosts the cross-cutting accessors only;
no base class layer.
The GPU draw-handler lifecycle for ``<X>PreviewDecorator`` lives on the
feature-neutral ``tool.Blender.ViewportDecorator`` base, which every
viewport decorator (preview or otherwise) inherits from."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
import bpy
import bonsai.tool as tool
# --- Props accessors ---------------------------------------------------------
def get_preview_props(context: bpy.types.Context, attr: str):
"""Resolve a child preview PropertyGroup under ``Scene.BIMPreviewProperties``.
Returns ``None`` if the umbrella isn't attached yet — true briefly
during addon register and during plug-out, so polls / draw callbacks
must defend against ``None`` rather than assuming the prop is always
available."""
preview = getattr(context.scene, "BIMPreviewProperties", None)
return getattr(preview, attr, None) if preview is not None else None
def is_preview_active(context: bpy.types.Context, attr: str) -> bool:
"""``True`` while a specific preview is open. Used by sibling gizmo
polls to hide themselves so the preview is the only interactive
surface in the viewport (the bend / fillet preview groups take over
the same selection's icon stack)."""
props = get_preview_props(context, attr)
return bool(props is not None and props.is_active)
# --- Lazy closure factories --------------------------------------------------
#
# Used by preview gizmo groups when wiring ``BIM_GT_gizmo_dimension``'s
# ``move_get_cb`` / ``move_set_cb`` callbacks. The closures re-resolve
# ``bpy.context.scene`` per CALL rather than capturing it at setup() time
# — the captured Scene's RNA struct can be freed on file open / undo, and
# referencing a freed struct crashes Blender. Lazy lookup survives the
# whole undo / reload lifecycle.
def make_props_callback(attr: str) -> Callable[[], Any]:
"""Return a zero-arg callable that lazily fetches the preview props.
Equivalent to ``getattr(bpy.context.scene.BIMPreviewProperties, attr)``
with full defensiveness against missing scene / missing umbrella."""
def _props():
scene = bpy.context.scene
preview = getattr(scene, "BIMPreviewProperties", None) if scene else None
return getattr(preview, attr, None) if preview is not None else None
return _props
def make_dim_getter(props_callback: Callable[[], Any], field: str) -> Callable[[], float]:
"""Factory for ``BIM_GT_gizmo_dimension.move_get_cb`` reading a single
FloatProperty off the live preview state. Returns ``0.0`` defensively
when the props are temporarily unavailable so the widget doesn't crash
Blender during plug-out / reload."""
def _get() -> float:
props = props_callback()
return getattr(props, field) if props is not None else 0.0
return _get
def make_dim_setter(
props_callback: Callable[[], Any],
field: str,
min_value: float = 0.001,
) -> Callable[[float], None]:
"""Factory for ``BIM_GT_gizmo_dimension.move_set_cb`` writing a single
FloatProperty + tagging viewport areas for redraw so the GPU preview
decorator tracks the value live during drag. Clamps at ``min_value``
to match the FloatProperty's declared lower bound."""
def _set(value: float) -> None:
props = props_callback()
if props is None:
return
setattr(props, field, max(min_value, float(value)))
tool.Blender.update_all_viewports()
return _set
# --- Shared Enable lifecycle helpers -----------------------------------------
def sync_uncommitted_moves(objects: list) -> None:
"""Push any Blender-side translation / rotation of ``objects`` back to
their IFC ``ObjectPlacement`` before a preview decorator starts reading
``obj.matrix_world`` per frame.
Without this sync, a user who grabbed-moved an object but didn't commit
the move sees the live preview at the dragged position while the final
commit lands at the stale IFC position a confusing "where did my
preview go?" experience. Both bend and fillet enable paths call this
on the relevant pair just before activating the preview."""
for obj in objects:
tool.Geometry.commit_placement_if_moved(obj, apply_scale=False)
# --- Esc dispatch ------------------------------------------------------------
PREVIEW_CANCEL_OPS: tuple[tuple[str, str], ...] = (
("bend", "cancel_bend_preview"),
("wall_fillet", "cancel_wall_fillet_preview"),
)
"""Registry of ``(child PointerProperty on Scene.BIMPreviewProperties, bim
operator name)`` consulted by the Esc handler. Adding a new preview means
appending one tuple; the forward-compat test pins that every preview
PropertyGroup with ``is_active`` has an entry here."""
def try_cancel_active_preview(context: bpy.types.Context) -> bool:
"""Cancel every registered preview that is currently active.
Returns ``True`` iff at least one preview was cancelled. Multiple
previews can be simultaneously active (e.g. a stale bend preview opened
just before the user starts a wall fillet) one Esc must clear them
all rather than forcing the user to tap Esc once per preview.
Tags 3D viewports for redraw on success the Esc keymap entry runs
outside a viewport mouse event so the gizmo poll wouldn't re-evaluate
until the next interaction without an explicit redraw."""
cancelled = False
for attr, op_name in PREVIEW_CANCEL_OPS:
if is_preview_active(context, attr):
getattr(bpy.ops.bim, op_name)()
cancelled = True
if cancelled:
tool.Blender.update_all_viewports(context)
return cancelled
def discard_pending_previews(scene: bpy.types.Scene) -> None:
"""Clear every active preview under ``Scene.BIMPreviewProperties`` so
saved preview state never resurfaces on file load.
Mirrors ``tool.Parametric.heal_stale_edit_flags`` for the object-level
parametric-edit lifecycle except previews are *discarded* rather than
validated. A preview's only UI cue is its in-viewport widget; reloading
a ``.blend`` saved mid-preview restores the flag but not the surrounding
user attention, and a stuck ``is_active`` silently hides every sibling
gizmo poll gated on it.
Iterates ``PREVIEW_CANCEL_OPS`` so any preview registered for Esc
cancellation is automatically covered here too. Sets ``is_active``
directly rather than dispatching the cancel operator: load_post may
fire before ``bpy.context.screen`` is reattached, and the cancel
operators bail on ``context.screen is None``."""
preview = getattr(scene, "BIMPreviewProperties", None)
if preview is None:
return
for attr, _op_name in PREVIEW_CANCEL_OPS:
child = getattr(preview, attr, None)
if child is not None and getattr(child, "is_active", False):
child.is_active = False
+140
View File
@@ -15,6 +15,8 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
import math
from collections.abc import Callable
@@ -193,6 +195,32 @@ def update_stair(self: "BIMStairProperties", context: bpy.types.Context) -> None
_get_updater("stair", "regenerate_stair_mesh")(obj)
def update_wall(self: "BIMWallProperties", context: bpy.types.Context) -> None:
"""Regenerate wall mesh preview when property changes. Does NOT touch IFC."""
obj = context.active_object
if obj and self.is_editing:
_get_updater("wall", "regenerate_wall_mesh_from_props")(obj)
def update_wall_offset_baseline(self: "BIMWallProperties", context: bpy.types.Context) -> None:
"""Recompute the preview-only ``offset`` when the draft baseline cycles. Does not touch IFC.
``offset`` itself has no ``update`` callback on purpose adding one would make
every baseline cycle rebuild the bmesh twice (once via offset's callback, once
explicitly below)."""
obj = context.active_object
if not (obj and self.is_editing):
return
t = self.thickness
if self.desired_offset_baseline == "CENTER":
self.offset = -t / 2
elif self.desired_offset_baseline == "INTERIOR":
self.offset = -t
else: # EXTERIOR
self.offset = 0.0
_get_updater("wall", "regenerate_wall_mesh_from_props")(obj)
def update_railing(self: "BIMRailingProperties", context: bpy.types.Context) -> None:
"""Regenerate railing mesh when property changes."""
if self.is_editing:
@@ -1631,6 +1659,118 @@ class BIMRoofProperties(PropertyGroup):
setattr(target_props, prop_name, prop_value)
class BIMWallProperties(PropertyGroup):
"""Transient draft state for parametric wall gizmo editing.
Populated from IFC on `bim.enable_editing_wall`, mutated by gizmo drags during edit
(preview only no IFC writes), and either committed by `bim.finish_editing_wall`
or discarded by `bim.cancel_editing_wall`.
The `snap_*` fields are the values captured on enable; `finish_editing_wall` compares
current vs snap to skip unchanged params and guarantee a no-op session leaves the
IFC file byte-identical.
"""
is_editing: bpy.props.BoolProperty(
default=False,
description="True while wall parametric edit mode is active.",
)
mesh_dirty: bpy.props.BoolProperty(
default=False,
options={"HIDDEN", "SKIP_SAVE"},
description=(
"True while the visible mesh is the preview box; cleared once the real "
"IFC-derived geometry is restored (on commit or cancel)."
),
)
length: bpy.props.FloatProperty(
name="Length",
default=1.0,
min=0.01,
subtype="DISTANCE",
update=update_wall,
description="Wall length along its reference axis (preview value; committed on finish).",
)
height: bpy.props.FloatProperty(
name="Height",
default=3.0,
min=0.01,
subtype="DISTANCE",
update=update_wall,
description="Wall vertical height (preview value; committed on finish).",
)
x_angle: bpy.props.FloatProperty(
name="Slope (X Angle)",
default=0.0,
soft_min=-math.pi / 3,
soft_max=math.pi / 3,
subtype="ANGLE",
update=update_wall,
description="Slope angle: tilt of the wall's top face along +Y (preview value; committed on finish).",
)
thickness: bpy.props.FloatProperty(
name="Thickness",
default=0.2,
min=0.001,
subtype="DISTANCE",
description="Wall thickness captured from IFC at edit-enable; not gizmo-bound.",
)
offset: bpy.props.FloatProperty(
name="Offset",
default=0.0,
subtype="DISTANCE",
description="Layer-set offset captured from IFC at edit-enable; driven by desired_offset_baseline.",
)
desired_offset_baseline: bpy.props.EnumProperty(
items=[
("EXTERIOR", "Exterior", "Reference axis at the exterior face"),
("CENTER", "Center", "Reference axis at the wall centreline"),
("INTERIOR", "Interior", "Reference axis at the interior face"),
],
name="Desired Offset Baseline",
default="CENTER",
update=update_wall_offset_baseline,
description="Which face of the wall the reference axis aligns to (preview value; committed on finish).",
)
anchor_x: bpy.props.FloatProperty(
default=0.0,
subtype="DISTANCE",
description="Local-X of the wall's axis polyline start, so the preview box lands where the IFC mesh does.",
)
snap_length: bpy.props.FloatProperty(description="Snapshot of length at edit-enable; commit skips no-op writes.")
snap_height: bpy.props.FloatProperty(description="Snapshot of height at edit-enable; commit skips no-op writes.")
snap_thickness: bpy.props.FloatProperty(
description="Snapshot of thickness at edit-enable; commit skips no-op writes."
)
snap_offset: bpy.props.FloatProperty(description="Snapshot of offset at edit-enable; commit skips no-op writes.")
snap_x_angle: bpy.props.FloatProperty(
subtype="ANGLE",
description="Snapshot of x_angle at edit-enable; commit skips no-op writes.",
)
snap_offset_baseline: bpy.props.StringProperty(
default="",
description="Snapshot of desired_offset_baseline at edit-enable; commit skips no-op writes.",
)
if TYPE_CHECKING:
is_editing: bool
mesh_dirty: bool
length: float
height: float
x_angle: float
thickness: float
offset: float
desired_offset_baseline: Literal["EXTERIOR", "CENTER", "INTERIOR"]
anchor_x: float
snap_length: float
snap_height: float
snap_thickness: float
snap_offset: float
snap_x_angle: float
snap_offset_baseline: str
class SnapMousePoint(PropertyGroup):
x: bpy.props.FloatProperty(name="X")
y: bpy.props.FloatProperty(name="Y")
+47 -48
View File
@@ -34,6 +34,7 @@ import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.model.data import RailingData, refresh
from bonsai.bim.module.model.decorator import ProfileDecorator
from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
# reference:
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRailing.htm
@@ -92,7 +93,6 @@ def update_railing_modifier_ifc_data(context: bpy.types.Context) -> None:
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
representation_data = {
"railing_type": props.railing_type,
"context": body,
"railing_path": railing_path,
"use_manual_supports": props.use_manual_supports,
@@ -406,66 +406,65 @@ class CopyRailingParameters(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class EnableEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_railing"
bl_label = "Enable Editing Railing"
bl_options = {"REGISTER"}
class _RailingEditMixin(PathPreservingEditMixin):
"""Type-specific hooks for railing parametric-edit operators. Single-object
(active_object). ``path_data`` is preserved through the edit; the separate
``Enable/Finish/CancelEditingRailingPath`` operators handle path editing."""
def _execute(self, context):
obj = context.active_object
assert obj
props = tool.Model.get_railing_props(obj)
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"]
pset_name = "BBIM_Railing"
@classmethod
def _is_element_type(cls, element):
return tool.Blender.Modifier.is_railing(element)
@classmethod
def _get_props(cls, obj: bpy.types.Object):
return tool.Model.get_railing_props(obj)
@classmethod
def _post_load_data(cls, data: dict) -> dict:
# BIMRailingProperties.path_data is a StringProperty holding JSON.
data["path_data"] = json.dumps(data["path_data"])
return data
# required since we could load pset from .ifc and BIMRailingProperties won't be set
props.set_props_kwargs_from_ifc_data(data)
@classmethod
def _update_pset(cls, element, data: dict) -> None:
update_bbim_railing_pset(element, data)
props.is_editing = True
return {"FINISHED"}
@classmethod
def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_railing_modifier_ifc_data(context)
class CancelEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_railing"
bl_label = "Cancel Editing Railing"
bl_options = {"REGISTER"}
def _execute(self, context):
obj = context.active_object
assert obj
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"]
props = tool.Model.get_railing_props(obj)
# restore previous settings since editing was canceled
props.set_props_kwargs_from_ifc_data(data)
@classmethod
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_railing_modifier_bmesh(context)
props.is_editing = False
return {"FINISHED"}
class FinishEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_railing"
bl_label = "Finish Editing Railing"
bl_options = {"REGISTER"}
class EnableEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_railing"
bl_label = "Enable Editing Railing"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
obj = context.active_object
assert obj
element = tool.Ifc.get_entity(obj)
assert element
props = tool.Model.get_railing_props(obj)
return self._enable_targets(context)
pset_data = tool.Model.get_modeling_bbim_pset_data(bpy.context.active_object, "BBIM_Railing")
path_data = pset_data["data_dict"]["path_data"]
railing_data = props.get_general_kwargs(convert_to_project_units=True)
railing_data["path_data"] = path_data
props.is_editing = False
class CancelEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_railing"
bl_label = "Cancel Editing Railing"
bl_options = {"REGISTER", "UNDO"}
update_bbim_railing_pset(element, railing_data)
update_railing_modifier_ifc_data(context)
return {"FINISHED"}
def _execute(self, context):
return self._cancel_targets(context)
class FinishEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_railing"
bl_label = "Finish Editing Railing"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
return self._finish_targets(context)
class FlipRailingPathOrder(bpy.types.Operator, tool.Ifc.Operator):
+41 -42
View File
@@ -34,6 +34,7 @@ import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.model.data import RoofData, refresh
from bonsai.bim.module.model.decorator import ProfileDecorator
from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
# reference:
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRoof.htm
@@ -608,61 +609,59 @@ class AddRoof(bpy.types.Operator, tool.Ifc.Operator):
tool.Model.add_body_representation(obj)
class EnableEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_roof"
bl_label = "Enable Editing Roof"
bl_options = {"REGISTER"}
class _RoofEditMixin(PathPreservingEditMixin):
"""Type-specific hooks for roof parametric-edit operators. Single-object
(active_object). ``path_data`` is preserved through the edit; the separate
``Enable/Finish/CancelEditingRoofPath`` operators handle path editing."""
def _execute(self, context):
obj = context.active_object
assert obj
props = tool.Model.get_roof_props(obj)
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")["data_dict"]
# required since we could load pset from .ifc and BIMRoofProperties won't be set
props.set_props_kwargs_from_ifc_data(data)
props.is_editing = True
return {"FINISHED"}
pset_name = "BBIM_Roof"
@classmethod
def _is_element_type(cls, element):
return tool.Blender.Modifier.is_roof(element)
class CancelEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_roof"
bl_label = "Cancel Editing Roof"
bl_options = {"REGISTER"}
@classmethod
def _get_props(cls, obj: bpy.types.Object):
return tool.Model.get_roof_props(obj)
def _execute(self, context):
obj = context.active_object
assert obj
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")["data_dict"]
props = tool.Model.get_roof_props(obj)
@classmethod
def _update_pset(cls, element, data: dict) -> None:
update_bbim_roof_pset(element, data)
# restore previous settings since editing was canceled
props.set_props_kwargs_from_ifc_data(data)
@classmethod
def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_roof_modifier_ifc_data(context)
@classmethod
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_roof_modifier_bmesh(obj)
props.is_editing = False
return {"FINISHED"}
class FinishEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_roof"
bl_label = "Finish Editing Roof"
bl_options = {"REGISTER"}
class EnableEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_roof"
bl_label = "Enable Editing Roof"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
obj = context.active_object
element = tool.Ifc.get_entity(obj)
props = tool.Model.get_roof_props(obj)
return self._enable_targets(context)
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")
path_data = pset_data["data_dict"]["path_data"]
roof_data = props.get_general_kwargs(convert_to_project_units=True)
roof_data["path_data"] = path_data
props.is_editing = False
class CancelEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_roof"
bl_label = "Cancel Editing Roof"
bl_options = {"REGISTER", "UNDO"}
update_bbim_roof_pset(element, roof_data)
update_roof_modifier_ifc_data(context)
return {"FINISHED"}
def _execute(self, context):
return self._cancel_targets(context)
class FinishEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_roof"
bl_label = "Finish Editing Roof"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
return self._finish_targets(context)
class EnableEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator):
+16 -20
View File
@@ -15,6 +15,8 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
import json
@@ -262,7 +264,6 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator):
# Use the special method that includes custom_tread_lock for IFC storage
data = props.get_props_kwargs_for_ifc_export(convert_to_project_units=True)
props.is_editing = False
regenerate_stair_mesh(obj)
tool.Model.add_body_representation(obj)
@@ -272,6 +273,7 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator):
# update IfcStairFlight properties
update_ifc_stair_props(obj)
props.is_editing = False
return {"FINISHED"}
@@ -608,29 +610,23 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
"VIEW3D_GT_minus", self.COLOR_RED, "bim.adjust_stair_treads", increment=-1
)
def _refresh_element_specific(self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties") -> None:
"""Update stair-specific lock and tread count gizmos."""
billboard_rot = gizmo.get_billboard_rotation(context)
self.update_lock_gizmo(mw, props, billboard_rot)
def _refresh_element_specific(
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
) -> None:
"""Update stair-specific lock and tread count gizmos. Lock positioning is
handled per-frame in the dimension-positioning hook."""
self.update_lock_gizmo(props)
self.update_tread_lock_gizmo(props)
self.update_tread_count_gizmos(props)
def update_lock_gizmo(self, mw: Matrix, props: "BIMStairProperties", billboard_rot: Matrix) -> None:
"""Update lock gizmo visibility, color, and position."""
def update_lock_gizmo(self, props: "BIMStairProperties") -> None:
"""Update lock gizmo color and visibility. Positioning is handled
per-frame by the dimension-positioning hook."""
gizmo_prefs = self.get_gizmo_prefs()
if not self.update_gizmo_visibility(self.lock_gizmo, props.is_editing, gizmo_prefs.lock):
return # Hidden, skip positioning
return # Hidden, skip color update
self.lock_gizmo.color = self.COLOR_RED if props.total_length_lock else self.COLOR_GREEN
total_run = props.get_total_run()
local_transform = (
Matrix.Translation(Vector((total_run + self.ICON_Z_OFFSET, -self.GIZMO_OFFSET, -self.GIZMO_OFFSET)))
@ billboard_rot
@ Matrix.Scale(self.EDITING_ICON_SCALE, 4)
)
self.lock_gizmo.matrix_basis = mw @ local_transform
def update_tread_lock_gizmo(self, props: "BIMStairProperties") -> None:
"""Update visibility of tread lock gizmo. Positioning is handled in _update_editing_icon_positions."""
if not hasattr(self, "tread_lock_gizmo"):
@@ -650,11 +646,11 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
)
def _update_dimension_gizmo_positions(
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties"
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
) -> None:
"""Update dimension gizmo positions based on camera view direction."""
viewing_from_negative_y, viewing_from_negative_x = self.get_local_view_direction(context, mw)
billboard_rot = gizmo.get_billboard_rotation(context)
viewing_from_negative_y, viewing_from_negative_x = self._frame_view_dir
billboard_rot = self._frame_billboard_rot
total_run = props.get_total_run()
riser_height = props.get_riser_height()
+30
View File
@@ -338,6 +338,36 @@ class BIM_PT_stair(bpy.types.Panel):
row.operator("bim.add_stair", icon="ADD", text="")
class BIM_PT_wall(bpy.types.Panel):
bl_label = "Wall"
bl_idname = "BIM_PT_wall"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_options = {"DEFAULT_CLOSED"}
bl_parent_id = "BIM_PT_tab_parametric_geometry"
@classmethod
def poll(cls, context):
obj = context.active_object
if not obj:
return False
element = tool.Ifc.get_entity(obj)
return bool(element) and tool.Blender.Modifier.is_wall(element)
def draw(self, context):
obj = context.active_object
if obj is None:
return
props = tool.Model.get_wall_props(obj)
row = self.layout.row(align=True)
if props.is_editing:
row.operator("bim.finish_editing_wall", icon="CHECKMARK", text="Finish Editing")
row.operator("bim.cancel_editing_wall", icon="CANCEL", text="")
else:
row.operator("bim.enable_editing_wall", icon="GREASEPENCIL", text="Edit Wall")
class BIM_PT_sverchok(bpy.types.Panel):
bl_label = "Sverchok"
bl_idname = "BIM_PT_sverchok"
File diff suppressed because it is too large Load Diff
+29 -65
View File
@@ -39,6 +39,7 @@ import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
if TYPE_CHECKING:
from bonsai.bim.module.model.prop import BIMWindowProperties
@@ -482,90 +483,53 @@ class AddWindow(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class CancelEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
class _WindowEditMixin(FeatureModifierEditMixin):
"""Type-specific hooks for window parametric-edit operators. Single-object
by design (window edits target the active object only)."""
pset_name = "BBIM_Window"
@classmethod
def _is_element_type(cls, element):
return tool.Blender.Modifier.is_window(element)
@classmethod
def _get_props(cls, obj: bpy.types.Object):
return tool.Model.get_window_props(obj)
@classmethod
def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_window_modifier_representation(context)
class CancelEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_window"
bl_label = "Cancel Editing Window"
bl_description = "Cancel editing and revert window parameters to their previous values"
bl_options = {"REGISTER"}
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
assert obj
element = tool.Ifc.get_entity(obj)
assert element
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Window", "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
props = tool.Model.get_window_props(obj)
props.set_props_kwargs_from_ifc_data(data)
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=body,
)
props.is_editing = False
return {"FINISHED"}
return self._cancel_targets(context)
class FinishEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
class FinishEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_window"
bl_label = "Finish Editing Window"
bl_description = "Apply changes and finish editing window parameters"
bl_options = {"REGISTER"}
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
assert obj
element = tool.Ifc.get_entity(obj)
assert element
props = tool.Model.get_window_props(obj)
window_data = props.get_general_kwargs(convert_to_project_units=True)
lining_props = props.get_lining_kwargs(convert_to_project_units=True)
panel_props = props.get_panel_kwargs(convert_to_project_units=True)
window_data["lining_properties"] = lining_props
window_data["panel_properties"] = panel_props
props.is_editing = False
update_window_modifier_representation(context)
element_type = ifcopenshell.util.element.get_type(element)
if element_type:
tool.Model.mark_thumbnail_for_update(element_type)
pset = tool.Pset.get_element_pset(element, "BBIM_Window")
window_data = tool.Ifc.get().createIfcText(json.dumps(window_data, default=list))
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": window_data})
return {"FINISHED"}
return self._finish_targets(context)
class EnableEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
class EnableEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_window"
bl_label = "Enable Editing Window"
bl_description = "Enter edit mode to modify window parameters interactively"
bl_options = {"REGISTER"}
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
assert obj
props = tool.Model.get_window_props(obj)
element = tool.Ifc.get_entity(obj)
assert element
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Window", "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
data.update(tool.Model.get_constituents_props_data(element))
# required since we could load pset from .ifc and BIMWindowProperties won't be set
props.set_props_kwargs_from_ifc_data(data)
props.is_editing = True
return {"FINISHED"}
return self._enable_targets(context)
class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator):
@@ -841,7 +841,7 @@ class EditObjectUI:
row = cls.layout.row(align=True)
row.separator()
row.label(text="Operations") if ui_context != "TOOL_HEADER" else row
cls.draw_regen_operations(row)
cls.draw_regen_operations(row, ui_context)
if AuthoringData.data["active_material_usage"] == "LAYER2":
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
@@ -962,20 +962,14 @@ class EditObjectUI:
return row
@classmethod
def draw_regen_operations(cls, row):
custom_icon = custom_icon_previews.get("REGEN", custom_icon_previews["IFC"]).icon_id
def draw_regen_operations(cls, row, ui_context):
if AuthoringData.data["is_regenable_element"]:
op = row.operator("bim.hotkey", text="", icon_value=custom_icon)
description = "Recalculate Element Geometry\nHotkey: S G"
op.hotkey = "S_G"
op.description = description.strip()
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(row, "Regen", "S_G", "Recalculate Element Geometry", ui_context)
if PortData.data["total_ports"] > 0:
op = row.operator("bim.hotkey", text="", icon_value=custom_icon)
description = f"{bpy.ops.bim.regenerate_distribution_element.__doc__}\n\nHotkey: S G"
op.hotkey = "S_G"
op.description = description.strip()
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(row, "Regen", "S_G", bpy.ops.bim.regenerate_distribution_element.__doc__, ui_context)
@classmethod
def draw_void(cls, context, row):
@@ -15,6 +15,8 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
import datetime
import json
@@ -1903,11 +1905,11 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
self.use_relative_path = tool.Project.get_project_props().use_relative_project_path
props = tool.Blender.get_bim_props()
if (filepath := props.ifc_file) and not self.should_save_as:
self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(filepath)))
return self.execute(context)
return ExportHelper.invoke(self, context, event)
filepath = props.ifc_file
if not filepath or self.should_save_as:
return ExportHelper.invoke(self, context, event)
self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(filepath)))
return self.execute(context)
def check(self, context):
# ExportHelper is automatically adjusting suffix to `filename_ext`.
@@ -1933,6 +1935,16 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
return {"FINISHED"}
def _execute(self, context):
committed, failed_commits = tool.Parametric.commit_pending_edits()
# Suffix is appended to the IFC save-success report below so the auto-commit
# info isn't immediately overwritten by the success message in Blender's
# status bar (only the latest self.report({"INFO"}, ...) sticks).
commit_suffix = f" (auto-committed {committed} pending parametric edit(s))" if committed else ""
if failed_commits:
names = ", ".join(o.name for o in failed_commits)
msg = f"Auto-commit failed for {len(failed_commits)} object(s): {names}"
print(f"Bonsai: {msg} (their drafts are NOT saved to the IFC file).")
self.report({"ERROR"}, msg)
start = time.time()
logger = logging.getLogger("ExportIFC")
path_log = tool.Blender.get_data_dir_path("process.log")
@@ -2001,7 +2013,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
blendmetadata_path = output_file + suffix
self.report(
{"INFO"},
f'IFC Project "{os.path.basename(output_file)}" And Metadata File Saved to: {os.path.basename(blendmetadata_path)}',
f'IFC Project "{os.path.basename(output_file)}" And Metadata File Saved to: {os.path.basename(blendmetadata_path)}{commit_suffix}',
)
except Exception as e:
self.report({"ERROR"}, f"Failed to save blend metadata file: {e}")
@@ -2011,7 +2023,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
bpy.ops.wm.save_mainfile(filepath=bpy.data.filepath)
self.report(
{"INFO"},
f'IFC Project "{os.path.basename(output_file)}" {"" if not save_blend_file else "And Current Blend File Are"} Saved',
f'IFC Project "{os.path.basename(output_file)}" {"" if not save_blend_file else "And Current Blend File Are"} Saved{commit_suffix}',
)
bonsai.bim.handler.refresh_ui_data()
@@ -0,0 +1,462 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Shared operator mixins for parametric-edit operators.
Edit-lifecycle mixins (Enable / Finish / Cancel):
`FeatureModifierEditMixin` door, window (BBIM_<Type> pset; nested
lining/panel properties; Finish + Cancel route through
``ifcopenshell.api.feature``).
`PathPreservingEditMixin` railing, roof (path_data preserved across
edit; only general kwargs are user-editable).
Pattern selection (which approach a new feature should adopt):
Every parametric edit lifecycle commits to one of three patterns. Pick by
answering "does the feature share the Enable→Finish→Cancel shape that
one of the existing mixins already encodes?":
A. Inherit one of the shared mixins below and route through
`tool.Parametric.build_edit_lifecycle`:
- `FeatureModifierEditMixin` when the feature stores its pset as
`{general fields} + {lining_properties: {...}} + {panel_properties: {...}}`
and Finish must call a per-type `update_<type>_modifier_representation`.
- `PathPreservingEditMixin` when the feature's pset carries a
`path_data` field that survives general-kwarg edits untouched, with
a separate Enable/Finish/Cancel lifecycle for path editing itself.
B. Write a per-feature mixin that subclasses `ParametricEditMixinBase`
and provides `_enable_targets` / `_finish_targets` / `_cancel_targets`,
then route through `build_edit_lifecycle`. Pick this when the
feature's pset roundtrip or representation handling diverges from the
shared mixins but the EnableFinishCancel shape still fits.
C. Declare standalone Enable/Finish/Cancel Operator subclasses (no
factory) when the feature's parameter-change logic is sufficiently
unique that even a per-feature mixin would force optional hooks or
dead branches. Such operators MUST call the matrix_world drift
helpers (`tool.Geometry.commit_placement_if_moved` on Enable/Finish,
`tool.Geometry.restore_or_rebaseline_placement` on Cancel) the
drift contract is enforced uniformly regardless of which pattern the
operators adopt.
The authoritative list of registered parametric types and which use
`build_edit_lifecycle` vs. standalone operators lives in
`tool/parametric.py`'s `EDIT_TYPES` and is enforced by the registry
contract tests in `test/bim/test_parametric_registry.py`.
This module hosts operator-side mixins that import ``bonsai.tool`` freely.
The lightweight parametric registry consumed at addon-enable time must stay
free of such imports and lives separately in ``tool/parametric.py``."""
from __future__ import annotations
import json
from collections.abc import Callable
from typing import TYPE_CHECKING, ClassVar
import bpy
import ifcopenshell.util.element
from bpy.app.handlers import persistent
import bonsai.core.geometry
import bonsai.tool as tool
if TYPE_CHECKING:
from ifcopenshell import entity_instance
class ParametricEditMixinBase:
"""Common scaffolding for parametric edit-lifecycle mixins.
Each per-type subclass provides four hooks:
``pset_name``: BBIM_<Type> pset identifier
``_is_element_type(element)``: IFC element predicate
``_get_props(obj)``: PropertyGroup accessor
``_iter_targets(context)``: list of objects to act on (default: ``[active_object]``)
Drift handling is built in: pre-edit matrix_world drift commits to IFC on
Enable, in-edit drag commits on Finish, and Cancel restores the committed
IFC placement. This prevents an uncommitted drag from disappearing on
Finish or snapping back on Cancel.
Operator subclasses call one of ``_enable_targets`` / ``_finish_targets`` /
``_cancel_targets`` from their ``_execute`` method."""
pset_name: ClassVar[str]
@classmethod
def _iter_targets(cls, context: bpy.types.Context) -> list[bpy.types.Object]:
obj = context.active_object
return [obj] if obj else []
@classmethod
def _is_element_type(cls, element: entity_instance) -> bool:
raise NotImplementedError
@classmethod
def _get_props(cls, obj: bpy.types.Object):
raise NotImplementedError
@classmethod
def _resolve(cls, obj: bpy.types.Object):
"""Look up ``(element, props)`` for ``obj`` if it matches this type, else None.
Common predicate guard for every lifecycle method collapses the
``element = tool.Ifc.get_entity(obj); assert element; if not is_<type>(element): return``
triplet into one call."""
element = tool.Ifc.get_entity(obj)
if not element or not cls._is_element_type(element):
return None
return element, cls._get_props(obj)
@classmethod
def _handle_drift_on_enable(cls, obj: bpy.types.Object) -> None:
tool.Geometry.commit_placement_if_moved(obj, apply_scale=False)
@classmethod
def _handle_drift_on_finish(cls, obj: bpy.types.Object) -> None:
tool.Geometry.commit_placement_if_moved(obj)
@classmethod
def _handle_drift_on_cancel(cls, obj: bpy.types.Object, element: entity_instance) -> None:
tool.Geometry.restore_or_rebaseline_placement(obj, element)
@classmethod
def _mark_type_thumbnail_dirty(cls, element: entity_instance) -> None:
"""Mark the element's type's preview thumbnail for refresh so the
property-panel preview reflects post-edit geometry. No-op for
occurrences without a backing type."""
element_type = ifcopenshell.util.element.get_type(element)
if element_type:
tool.Model.mark_thumbnail_for_update(element_type)
class FeatureModifierEditMixin(ParametricEditMixinBase):
"""Lifecycle for door- and window-style parametric modifier operators.
Enable:
Read BBIM_<Type> pset JSON unwrap ``lining_properties`` and
``panel_properties`` merge constituents data set draft props
``is_editing = True``.
Finish:
Gather ``general / lining / panel`` kwargs (project units) nest
``is_editing = False`` call ``_update_modifier_representation``
mark thumbnail write back to BBIM_<Type> pset via
``ifcopenshell.api.pset.edit_pset``.
Cancel:
Read BBIM_<Type> pset JSON unwrap restore draft props
``switch_representation`` to the Body representation
``is_editing = False``."""
@classmethod
def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
"""Hook: call the per-type ``update_<type>_modifier_representation``."""
raise NotImplementedError
@classmethod
def _enable_one(cls, obj: bpy.types.Object) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
element, props = resolved
cls._handle_drift_on_enable(obj)
data = json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
data.update(tool.Model.get_constituents_props_data(element))
# required since the pset can be loaded from .ifc and the PropertyGroup
# would otherwise still hold its default values
props.set_props_kwargs_from_ifc_data(data)
props.is_editing = True
@classmethod
def _finish_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
element, props = resolved
data = props.get_general_kwargs(convert_to_project_units=True)
data["lining_properties"] = props.get_lining_kwargs(convert_to_project_units=True)
data["panel_properties"] = props.get_panel_kwargs(convert_to_project_units=True)
cls._update_modifier_representation(obj, context)
cls._mark_type_thumbnail_dirty(element)
tool.Pset.write_bbim_data(element, cls.pset_name, data)
cls._handle_drift_on_finish(obj)
# Set only on success: if any IFC op above raised, the user's draft survives for retry.
props.is_editing = False
@classmethod
def _cancel_one(cls, obj: bpy.types.Object) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
element, props = resolved
# Cancel must always clear is_editing — leaving it True after a
# restore-failure would block the user from re-entering edit mode and
# the next save's stale-flag heal would silently roll back the
# cancellation. Wrap the restore in try/finally so the flag flips
# even on partial failure.
try:
data = json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
props.set_props_kwargs_from_ifc_data(data)
body = tool.Geometry.get_body_representation(element)
bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=body)
cls._handle_drift_on_cancel(obj, element)
finally:
props.is_editing = False
def _enable_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._enable_one(obj)
return {"FINISHED"}
def _finish_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._finish_one(obj, context)
return {"FINISHED"}
def _cancel_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._cancel_one(obj)
return {"FINISHED"}
class PathPreservingEditMixin(ParametricEditMixinBase):
"""Lifecycle for railing- and roof-style parametric modifier operators.
Distinctive: ``path_data`` is part of the BBIM_<Type> pset but is **not**
user-editable through this lifecycle it survives the edit untouched, only
general kwargs are diffed. (Path editing has its own separate operator
pair, ``Enable/Finish/CancelEditing<Type>Path``, out of scope here.)
Enable:
Fetch pset data via ``tool.Model.get_modeling_bbim_pset_data`` set
draft props ``is_editing = True``. The subclass post-load hook
can reshape the dict to fit the PropertyGroup's storage layout
(e.g., pre-serialise a structured pset value to JSON for a
``StringProperty`` field).
Finish:
Read fresh pset keep ``path_data`` gather ``general`` kwargs
(project units) reassemble ``is_editing = False`` call
``_update_pset`` (per-type pset writer) call ``_update_modifier_ifc_data``
(per-type geometry commit).
Cancel:
Read fresh pset restore draft props call
``_restore_viewport_after_cancel`` (per-type viewport restore typically
rebuilds the bmesh preview, but subclasses may load a different
representation entirely) ``is_editing = False``."""
@classmethod
def _post_load_data(cls, data: dict) -> dict:
"""Hook: optionally transform the pset data dict after loading and before
passing to ``set_props_kwargs_from_ifc_data``. Default: pass-through.
Override when the PropertyGroup stores a structured pset field as a
serialised primitive e.g., a list/dict value mapped onto a
``StringProperty`` requires JSON-encoding here."""
return data
@classmethod
def _update_pset(cls, element: entity_instance, data: dict) -> None:
"""Hook: per-type pset writer (``update_bbim_<type>_pset``)."""
raise NotImplementedError
@classmethod
def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
"""Hook: per-type ``update_<type>_modifier_ifc_data`` — commits the
modified geometry to IFC. Signature accepts ``(obj, context)`` so
subclasses can forward either argument to their existing helper."""
raise NotImplementedError
@classmethod
def _restore_viewport_after_cancel(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
"""Hook: restore the viewport mesh to match the just-restored draft props.
Most subclasses rebuild a bmesh preview from props. Subclasses whose
committed IFC representation diverges from the preview may switch
the mesh back to the committed representation instead."""
raise NotImplementedError
@classmethod
def _enable_one(cls, obj: bpy.types.Object) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
_element, props = resolved
cls._handle_drift_on_enable(obj)
data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)["data_dict"]
data = cls._post_load_data(data)
props.set_props_kwargs_from_ifc_data(data)
props.is_editing = True
@classmethod
def _finish_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
element, props = resolved
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)
stored = pset_data["data_dict"]
data = props.get_general_kwargs(convert_to_project_units=True)
data["path_data"] = stored["path_data"]
# Skip the pset commit when the draft is identical to the stored pset:
# an Enable → Finish-without-changes cycle should not pollute the
# representation list or burn an undo entry. Drift commit still runs
# unconditionally — matrix_world drift is independent of pset content.
if data != stored:
cls._update_pset(element, data)
cls._update_modifier_ifc_data(obj, context)
cls._mark_type_thumbnail_dirty(element)
cls._handle_drift_on_finish(obj)
# Set only on success: if any IFC op above raised, the user's draft survives for retry.
props.is_editing = False
@classmethod
def _cancel_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
element, props = resolved
try:
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)
stored = pset_data["data_dict"]
draft = props.get_general_kwargs(convert_to_project_units=True)
draft["path_data"] = stored["path_data"]
nothing_changed = draft == stored
data = cls._post_load_data(stored)
props.set_props_kwargs_from_ifc_data(data)
# Skip the viewport rebuild on a no-op cancel: the mesh on screen is
# still the committed representation, and the per-type viewport-restore
# hook may be expensive (some subclasses reload a high-poly IFC
# representation rather than rebuild a preview mesh).
if not nothing_changed:
cls._restore_viewport_after_cancel(obj, context)
cls._handle_drift_on_cancel(obj, element)
finally:
# Always clear the flag — see ``FeatureModifierEditMixin._cancel_one``
# for the rationale.
props.is_editing = False
def _enable_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._enable_one(obj)
return {"FINISHED"}
def _finish_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._finish_one(obj, context)
return {"FINISHED"}
def _cancel_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._cancel_one(obj, context)
return {"FINISHED"}
# --- Undo-resync registry ----------------------------------------------------
#
# Per-type regenerators called from ``resync_parametric_drafts_after_undo``
# (wired into ``bim/handler.py:undo_post`` and ``redo_post``) so the preview
# mesh of an in-progress parametric draft repaints after Ctrl+Z / Ctrl+Shift+Z.
#
# Each regenerator is a one-line lazy-import + call. Lazy imports because
# ``bonsai.bim.parametric_lifecycle`` loads before ``bim/module/model/*``
# at addon enable; a module-level import would cycle. Each function-local
# import lands at first call, after the feature module has registered.
#
# Types with no entry — door, window, railing, etc. — are IFC-derived: undo
# of an IFC mutation already restores the entity, and ``switch_representation``
# repaints the mesh as a side effect of the next refresh. They don't need a
# bespoke preview regenerator.
def _wall_undo_regenerator(obj: bpy.types.Object) -> None:
from bonsai.bim.module.model.wall import regenerate_wall_mesh_from_props
regenerate_wall_mesh_from_props(obj)
def _stair_undo_regenerator(obj: bpy.types.Object) -> None:
from bonsai.bim.module.model.stair import regenerate_stair_mesh
regenerate_stair_mesh(obj)
def _roof_undo_regenerator(obj: bpy.types.Object) -> None:
from bonsai.bim.module.model.roof import update_roof_modifier_bmesh
update_roof_modifier_bmesh(obj)
UNDO_REGENERATORS: dict[str, Callable[[bpy.types.Object], None]] = {
"wall": _wall_undo_regenerator,
"stair": _stair_undo_regenerator,
"roof": _roof_undo_regenerator,
}
def resync_parametric_drafts_after_undo() -> None:
"""Re-render preview meshes for every parametric draft currently active.
Walks all objects, skips any not in a registered parametric edit,
dispatches to the per-type regenerator in ``UNDO_REGENERATORS``. A type
without an entry is left alone its preview is either already correct
(IFC-derived) or has no draft preview mesh."""
for obj in bpy.data.objects:
feature = tool.Parametric.is_object_editing(obj)
if feature is None:
continue
regenerator = UNDO_REGENERATORS.get(feature.name)
if regenerator is None:
continue
regenerator(obj)
tool.Blender.update_all_viewports()
@persistent
def _resync_on_undo(scene: bpy.types.Scene) -> None:
resync_parametric_drafts_after_undo()
def install_parametric_lifecycle_handlers() -> None:
"""Append the undo-resync callback to undo_post and redo_post; idempotent.
Caller must invoke this AFTER appending the central undo/redo handlers so
regenerators see restored IFC state bpy.app.handlers fire in append order."""
for hook in (bpy.app.handlers.undo_post, bpy.app.handlers.redo_post):
if _resync_on_undo not in hook:
hook.append(_resync_on_undo)
def uninstall_parametric_lifecycle_handlers() -> None:
for hook in (bpy.app.handlers.undo_post, bpy.app.handlers.redo_post):
try:
hook.remove(_resync_on_undo)
except ValueError:
pass
+112 -31
View File
@@ -15,6 +15,8 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
import os
import platform
@@ -380,6 +382,76 @@ class GizmoPreferencesStair(bpy.types.PropertyGroup):
cycle: bool
class GizmoPreferencesWall(bpy.types.PropertyGroup):
"""Property group for wall gizmo visibility settings."""
length: BoolProperty(
name="Length",
default=True,
description="Show the length dimension gizmo along the wall axis.",
)
height: BoolProperty(
name="Height",
default=True,
description="Show the height dimension gizmo at the wall's start endpoint.",
)
height_end: BoolProperty(
name="Height (far end, walls > 5m)",
default=True,
description=(
"Show a second height gizmo at the wall's far end so long walls don't "
"require panning to reach the handle."
),
)
x_angle: BoolProperty(
name="Slope",
default=True,
description="Show the slope gizmo at the wall top measuring horizontal displacement of the top face.",
)
cycle: BoolProperty(
name="Cycle Offset Baseline",
default=True,
description="Show the baseline-state icon (Exterior / Centreline / Interior) in the editing icon row.",
)
scissors: BoolProperty(
name="Split at cursor",
default=True,
description="Show the split icon at the 3D cursor when it lies within the wall's length range.",
)
extend: BoolProperty(
name="Extend length to cursor X",
default=True,
description="Show the extend-length icon at the 3D cursor's projected wall-axis X.",
)
extend_height: BoolProperty(
name="Extend height to cursor Z",
default=True,
description="Show the extend-height icon at the 3D cursor's Z, on the wall axis.",
)
rotate: BoolProperty(
name="Rotate 90°",
default=True,
description="Show the rotate-90 icon in the editing icon row (rotates the wall around its Z axis).",
)
toggle_openings: BoolProperty(
name="Toggle Openings",
default=True,
description="Show the toggle-openings icon next to the pen (toggles opening fill visibility in the viewport).",
)
if TYPE_CHECKING:
length: bool
height: bool
height_end: bool
x_angle: bool
cycle: bool
scissors: bool
extend: bool
extend_height: bool
rotate: bool
toggle_openings: bool
class GizmoPreferences(bpy.types.PropertyGroup):
"""Property group for all gizmo visibility settings."""
@@ -391,12 +463,14 @@ class GizmoPreferences(bpy.types.PropertyGroup):
door: bpy.props.PointerProperty(type=GizmoPreferencesDoor)
window: bpy.props.PointerProperty(type=GizmoPreferencesWindow)
stair: bpy.props.PointerProperty(type=GizmoPreferencesStair)
wall: bpy.props.PointerProperty(type=GizmoPreferencesWall)
if TYPE_CHECKING:
draw_gizmos_in_3d_viewport: bool
door: GizmoPreferencesDoor
window: GizmoPreferencesWindow
stair: GizmoPreferencesStair
wall: GizmoPreferencesWall
class DocPreferences(bpy.types.PropertyGroup):
@@ -849,49 +923,56 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Door", self.draw_door_gizmo_parameters)
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Window", self.draw_window_gizmo_parameters)
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Stair", self.draw_stair_gizmo_parameters)
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Wall", self.draw_wall_gizmo_parameters)
def _draw_parametric_gizmo_parameters(
self,
layout: bpy.types.UILayout,
gizmo_pg: bpy.types.PropertyGroup,
dimension_gizmo_class: type,
special_gizmo_names: frozenset[str] = frozenset(),
) -> None:
"""Draw the per-element gizmo visibility toggles. Surfaces every annotation
on ``gizmo_pg`` that either maps to one of ``dimension_gizmo_class``'s
dimension gizmos or is named in ``special_gizmo_names`` (non-dimension icons
like baseline cycle, scissors, rotate, )."""
visible_names = {p.attr_name for p in dimension_gizmo_class.dimension_gizmo_props} | special_gizmo_names
try:
annotations = gizmo_pg.__annotations__
except AttributeError:
annotations = type(gizmo_pg).__annotations__
for prop in annotations:
if prop in visible_names:
layout.prop(gizmo_pg, prop)
def draw_door_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.door import GizmoDoorEdition
door_gizmos = self.gizmos.door
gizmo_prop_names = {p.attr_name for p in GizmoDoorEdition.dimension_gizmo_props}
# Add special gizmos not in dimension_gizmo_props
gizmo_prop_names.update(("swing_arc", "flip_arc"))
try:
annotations = door_gizmos.__annotations__
except AttributeError:
annotations = type(door_gizmos).__annotations__
for prop in annotations:
if prop in gizmo_prop_names:
layout.prop(door_gizmos, prop)
self._draw_parametric_gizmo_parameters(
layout, self.gizmos.door, GizmoDoorEdition, frozenset({"swing_arc", "flip_arc"})
)
def draw_window_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.window import GizmoWindowEdition
window_gizmos = self.gizmos.window
gizmo_prop_names = {p.attr_name for p in GizmoWindowEdition.dimension_gizmo_props}
try:
annotations = window_gizmos.__annotations__
except AttributeError:
annotations = type(window_gizmos).__annotations__
for prop in annotations:
if prop in gizmo_prop_names:
layout.prop(window_gizmos, prop)
self._draw_parametric_gizmo_parameters(layout, self.gizmos.window, GizmoWindowEdition)
def draw_stair_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.stair import GizmoStairEdition
stair_gizmos = self.gizmos.stair
gizmo_prop_names = {p.attr_name for p in GizmoStairEdition.dimension_gizmo_props}
# Add special gizmos not in dimension_gizmo_props
special_gizmo_names = {"lock", "plus", "minus", "cycle"}
try:
annotations = stair_gizmos.__annotations__
except AttributeError:
annotations = type(stair_gizmos).__annotations__
for prop in annotations:
if prop in gizmo_prop_names or prop in special_gizmo_names:
layout.prop(stair_gizmos, prop)
self._draw_parametric_gizmo_parameters(
layout, self.gizmos.stair, GizmoStairEdition, frozenset({"lock", "plus", "minus", "cycle"})
)
def draw_wall_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.wall import GizmoWallEdition
self._draw_parametric_gizmo_parameters(
layout,
self.gizmos.wall,
GizmoWallEdition,
frozenset({"cycle", "scissors", "extend", "extend_height", "rotate", "toggle_openings"}),
)
def draw_model_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
layout.prop(self, "occurrence_name_style")
+457 -1
View File
@@ -15,10 +15,13 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, Optional
import math
from typing import TYPE_CHECKING, Any, Literal, Optional
if TYPE_CHECKING:
import bpy
@@ -31,6 +34,24 @@ if TYPE_CHECKING:
OffsetType = Literal["CENTER", "EXTERIOR", "INTERIOR"]
# Arc sample count for fillet preview polylines. 24 samples produces a visually
# smooth arc at common viewport scales without bloating the GPU batch.
FILLET_DEFAULT_ARC_RESOLUTION = 24
# Dot-product floor for treating two wall-axis segments as parallel — below
# this the projected intersection is too sensitive to floating-point noise
# to be useful as a junction apex. Calibrated to ~2° from parallel.
PARALLEL_DOT_THRESHOLD = 0.9994
# Perpendicular distance (SI metres) under which two parallel wall axes are
# considered to share the same infinite line. Calibrated to absorb sub-50mm
# placement drift between authored-joined walls without merging genuinely
# offset parallel walls.
COLLINEAR_LINE_TOLERANCE = 0.05
# Default proximity (SI metres) for classifying a layer offset against the
# canonical EXTERIOR / CENTER / INTERIOR baselines. Tight enough that ordinary
# millimetre-scale modelling intent always falls into the nearest baseline.
BASELINE_OFFSET_TOLERANCE = 0.001
def unjoin_walls(
ifc: type[tool.Ifc],
blender: type[tool.Blender],
@@ -173,3 +194,438 @@ class RequireAtLeastTwoElements(Exception):
class RequireLayeredElement(Exception):
pass
# --- Wall geometry math (pure) ------------------------------------------------
# Tuple in / tuple out so these helpers run without ``bpy`` or ``mathutils``.
# Callers convert ``mathutils.Vector`` at the boundary.
def baseline_from_offset(offset: float, thickness: float, tolerance: float = BASELINE_OFFSET_TOLERANCE) -> str:
"""Classify a numeric layer offset as EXTERIOR / CENTER / INTERIOR.
Handles both POSITIVE and NEGATIVE direction_sense walls. Returns the
closest canonical baseline; falls back to ``"CENTER"`` when nothing is
within ``tolerance``."""
candidates = (
("EXTERIOR", 0.0),
("CENTER", -thickness / 2),
("INTERIOR", -thickness),
("EXTERIOR", thickness),
("CENTER", thickness / 2),
("INTERIOR", 0.0),
)
best = min(candidates, key=lambda c: abs(offset - c[1]))
return best[0] if abs(offset - best[1]) < tolerance else "CENTER"
def project_axis_intersection(
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
parallel_threshold: float,
) -> Optional[tuple[float, float, float]]:
"""Compute the 2D (X,Y plane) intersection of two world-space axis segments.
Each segment is a pair of 3-tuples. Returns the intersection as a 3-tuple
(Z is the average of the four input Zs, for visual placement) or ``None`` if
the segments are parallel within ``parallel_threshold`` (a dot-product magnitude
threshold see ``PARALLEL_DOT_THRESHOLD`` for the calibrated value)."""
p1, p2 = seg_a
p3, p4 = seg_b
d1x, d1y = p2[0] - p1[0], p2[1] - p1[1]
d2x, d2y = p4[0] - p3[0], p4[1] - p3[1]
d1_len = (d1x * d1x + d1y * d1y) ** 0.5
d2_len = (d2x * d2x + d2y * d2y) ** 0.5
if d1_len < 1e-9 or d2_len < 1e-9:
return None
dot = (d1x * d2x + d1y * d2y) / (d1_len * d2_len)
if abs(dot) >= parallel_threshold:
return None
denom = d1x * d2y - d1y * d2x
if abs(denom) < 1e-9:
return None
t = ((p3[0] - p1[0]) * d2y - (p3[1] - p1[1]) * d2x) / denom
ix = p1[0] + t * d1x
iy = p1[1] + t * d1y
iz = (p1[2] + p2[2] + p3[2] + p4[2]) / 4
return (ix, iy, iz)
def opening_is_past_cut(min_t: float, cut_percentage: float) -> bool:
"""True when the opening's near edge sits past the cut on the t axis.
Strict inequality is load-bearing: a boundary touch or NaN keeps the
opening on both walls the safe default when extent resolution fails."""
return min_t > cut_percentage
def opening_is_before_cut(max_t: float, cut_percentage: float) -> bool:
"""True when the opening's far edge sits before the cut on the t axis."""
return max_t < cut_percentage
def opening_straddles_cut(min_t: float, max_t: float, cut_percentage: float) -> bool:
"""True when the opening's extent crosses the cut on the t axis."""
return min_t < cut_percentage < max_t
WallJoinState = Literal["joined", "collinear", "intersect", "none"]
def classify_wall_join_state(
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
are_joined: bool,
parallel_threshold: float,
collinear_tolerance: float,
) -> tuple[WallJoinState, Optional[tuple[float, float, float]]]:
"""Classify a wall pair's geometric state — ``(state, intersection)``.
Priority: ``"joined"`` (caller-supplied flag) ``"collinear"``
``"intersect"`` (projected point returned) ``"none"`` (parallel,
non-collinear)."""
if are_joined:
return "joined", None
if are_axes_collinear(seg_a, seg_b, parallel_threshold, collinear_tolerance):
return "collinear", None
intersection = project_axis_intersection(seg_a, seg_b, parallel_threshold)
if intersection is None:
return "none", None
return "intersect", intersection
def wall_join_preview_lines(
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
intersection: tuple[float, float, float],
) -> list[tuple[tuple[float, float, float], tuple[float, float, float]]]:
"""Two segments showing each wall axis extending to ``intersection``.
Each segment runs from the input axis's nearest endpoint to the
intersection, held at that wall's own Z. Returned in input order
``[floor_a, floor_b]``."""
ix, iy, _ = intersection
def _nearest(seg: tuple[tuple[float, float, float], tuple[float, float, float]]) -> tuple[float, float, float]:
return min(seg, key=lambda p: (p[0] - ix) ** 2 + (p[1] - iy) ** 2)
near_a = _nearest(seg_a)
near_b = _nearest(seg_b)
return [
(near_a, (ix, iy, near_a[2])),
(near_b, (ix, iy, near_b[2])),
]
def resolve_extend_walls_target(
target_obj: Any,
objs: list[Any],
reverse: bool,
) -> tuple[Any, list[Any]]:
"""Pick which object is the extend-target and which are extended.
Default direction: ``objs`` are extended to meet ``target_obj``.
Reversed direction (``reverse=True``) swaps the pair equivalent to
having passed them in the opposite order. The swap is well-defined only
for the 1+1 case (one target + one other); for ``n>1`` it would be
ambiguous, so the default direction is preserved instead."""
if reverse and target_obj is not None and len(objs) == 1:
return objs[0], [target_obj]
return target_obj, objs
def displacement_from_x_angle(height: float, x_angle: float) -> float:
"""Top-edge horizontal displacement for a wall of given vertical ``height``
and slope ``x_angle`` (radians). Inverse of ``x_angle_from_displacement``."""
return height * math.tan(x_angle)
def x_angle_from_displacement(height: float, displacement: float) -> float:
"""Recover slope ``x_angle`` (radians) from a top-edge horizontal displacement.
``height`` is clamped to ``max(height, 1e-6)`` so zero-height walls map
cleanly to ``±π/2`` instead of dividing by zero."""
return math.atan2(displacement, max(height, 1e-6))
def vertical_height_from_extrusion_depth(extrusion_depth: float, x_angle: float) -> float:
"""Vertical height of a wall given its slanted extrusion depth and slope.
``IfcExtrudedAreaSolid.Depth`` measures along the (possibly slanted) extrusion
direction. The vertical height the user thinks of is ``depth * cos(x_angle)``.
Unit-agnostic: the result is in the same units as ``extrusion_depth``."""
return extrusion_depth * abs(math.cos(x_angle))
def extrusion_depth_from_vertical_height(vertical_height: float, x_angle: float) -> float:
"""``vertical_height / cos(x_angle)`` with ``cos`` clamped at ``1e-6`` to
stay finite near ``±π/2``."""
return vertical_height / max(abs(math.cos(x_angle)), 1e-6)
def length_and_height_from_extrusion(
extrusion_depth: float,
x_angle: float,
reference_line_x_extent: float,
unit_scale: float,
) -> tuple[float, float]:
"""SI ``(length, vertical_height)`` of a LAYER2 wall.
Height is the *vertical* projection of the slanted depth, not the
slanted depth itself."""
length = reference_line_x_extent * unit_scale
height = vertical_height_from_extrusion_depth(extrusion_depth * unit_scale, x_angle)
return length, height
def are_axes_collinear(
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
parallel_threshold: float = PARALLEL_DOT_THRESHOLD,
line_tolerance: float = COLLINEAR_LINE_TOLERANCE,
) -> bool:
"""True if both axis segments lie on the same infinite line in plan.
Two conditions: directions must be (anti-)parallel within ``parallel_threshold``,
AND any endpoint of B must lie on A's infinite line within ``line_tolerance``.
Plan-only (Z ignored)."""
d1x, d1y = seg_a[1][0] - seg_a[0][0], seg_a[1][1] - seg_a[0][1]
d2x, d2y = seg_b[1][0] - seg_b[0][0], seg_b[1][1] - seg_b[0][1]
d1_len = (d1x * d1x + d1y * d1y) ** 0.5
d2_len = (d2x * d2x + d2y * d2y) ** 0.5
if d1_len < 1e-9 or d2_len < 1e-9:
return False
if abs((d1x * d2x + d1y * d2y) / (d1_len * d2_len)) < parallel_threshold:
return False
# Project seg_b[0] onto the infinite line through seg_a; the perpendicular
# distance to the original point tells us how far off the line B sits.
nx, ny = d1x / d1_len, d1y / d1_len
dx, dy = seg_b[0][0] - seg_a[0][0], seg_b[0][1] - seg_a[0][1]
t = dx * nx + dy * ny
proj_x = seg_a[0][0] + nx * t
proj_y = seg_a[0][1] + ny * t
perp_x = seg_b[0][0] - proj_x
perp_y = seg_b[0][1] - proj_y
return (perp_x * perp_x + perp_y * perp_y) ** 0.5 < line_tolerance
def closest_endpoint_midpoint(
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
) -> tuple[float, float, float]:
"""Midpoint of the closest endpoint pair between two segments."""
endpoints_a = (seg_a[0], seg_a[1])
endpoints_b = (seg_b[0], seg_b[1])
def _distance_sq(p: tuple[float, float, float], q: tuple[float, float, float]) -> float:
return (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + (p[2] - q[2]) ** 2
closest_pair = min(((a, b) for a in endpoints_a for b in endpoints_b), key=lambda pair: _distance_sq(*pair))
a, b = closest_pair
return ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2)
def compute_path_connection_location(
seg_self: tuple[tuple[float, float, float], tuple[float, float, float]],
self_conn_type: str,
seg_other: tuple[tuple[float, float, float], tuple[float, float, float]],
other_conn_type: str,
parallel_threshold: float = PARALLEL_DOT_THRESHOLD,
) -> tuple[float, float, float]:
"""World-space location of a single ``IfcRelConnectsPathElements`` between
two wall axes.
Priority: ``self``'s ATSTART/ATEND endpoint → ``other``'s ATSTART/ATEND
endpoint axis intersection closest-endpoint midpoint fallback."""
if self_conn_type == "ATSTART":
return seg_self[0]
if self_conn_type == "ATEND":
return seg_self[1]
if other_conn_type == "ATSTART":
return seg_other[0]
if other_conn_type == "ATEND":
return seg_other[1]
intersection = project_axis_intersection(seg_self, seg_other, parallel_threshold)
if intersection is not None:
return intersection
return closest_endpoint_midpoint(seg_self, seg_other)
def _vec_sub(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
def _vec_dot(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
def _vec_cross(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
return (a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0])
def _vec_length(v: tuple[float, float, float]) -> float:
return (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) ** 0.5
def _rotate_around_axis(
v: tuple[float, float, float],
axis: tuple[float, float, float],
angle: float,
) -> tuple[float, float, float]:
"""Rotate ``v`` around unit-length ``axis`` by ``angle`` radians."""
cos_a = math.cos(angle)
sin_a = math.sin(angle)
dot = _vec_dot(axis, v)
cross = _vec_cross(axis, v)
k = 1.0 - cos_a
return (
v[0] * cos_a + cross[0] * sin_a + axis[0] * dot * k,
v[1] * cos_a + cross[1] * sin_a + axis[1] * dot * k,
v[2] * cos_a + cross[2] * sin_a + axis[2] * dot * k,
)
def compute_fillet_polylines(
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
radius: float,
arc_resolution: int = FILLET_DEFAULT_ARC_RESOLUTION,
parallel_threshold: float = PARALLEL_DOT_THRESHOLD,
) -> dict:
"""Preview polylines for a circular fillet at the junction of two axes.
Returns a dict with ``valid``, ``reason``, ``intersection``, ``tangent_a``
/ ``tangent_b``, ``arc`` (``arc_resolution + 1`` samples), ``arc_center``,
``arc_radius``, ``sweep_angle``, ``sweep_axis``, ``tangent_offset``,
``wall_a_join_side`` / ``wall_b_join_side`` (ATSTART/ATEND/None),
``invalid_radius`` (tangent overshoots arc + tangents still populated
for warning rendering), and ``invalid_axes`` (set on parallel)."""
blank: dict = {
"valid": False,
"reason": None,
"intersection": None,
"tangent_a": None,
"tangent_b": None,
"arc": [],
"arc_center": None,
"arc_radius": radius,
"sweep_angle": 0.0,
"sweep_axis": None,
"tangent_offset": 0.0,
"wall_a_join_side": None,
"wall_b_join_side": None,
"invalid_radius": False,
"invalid_axes": None,
}
intersection = project_axis_intersection(seg_a, seg_b, parallel_threshold)
if intersection is None:
return {**blank, "reason": "parallel", "invalid_axes": [seg_a, seg_b]}
def _classify(seg, ipt):
d0 = (seg[0][0] - ipt[0]) ** 2 + (seg[0][1] - ipt[1]) ** 2 + (seg[0][2] - ipt[2]) ** 2
d1 = (seg[1][0] - ipt[0]) ** 2 + (seg[1][1] - ipt[1]) ** 2 + (seg[1][2] - ipt[2]) ** 2
if d0 <= d1:
return seg[0], seg[1], "ATSTART"
return seg[1], seg[0], "ATEND"
near_a, far_a, side_a = _classify(seg_a, intersection)
near_b, far_b, side_b = _classify(seg_b, intersection)
# Direction along each segment AWAY from the corner. ``far - intersection``
# handles both the shared-corner and extended-axes cases uniformly.
dir_a_raw = _vec_sub(far_a, intersection)
dir_b_raw = _vec_sub(far_b, intersection)
far_len_a = _vec_length(dir_a_raw)
far_len_b = _vec_length(dir_b_raw)
if far_len_a < 1e-9 or far_len_b < 1e-9:
return {**blank, "reason": "near_collinear", "intersection": intersection}
dir_a = (dir_a_raw[0] / far_len_a, dir_a_raw[1] / far_len_a, dir_a_raw[2] / far_len_a)
dir_b = (dir_b_raw[0] / far_len_b, dir_b_raw[1] / far_len_b, dir_b_raw[2] / far_len_b)
cos_angle = max(-1.0, min(1.0, _vec_dot(dir_a, dir_b)))
angle = math.acos(cos_angle)
sweep_angle = math.pi - angle
if sweep_angle < 1e-3 or sweep_angle > math.pi - 1e-3:
return {
**blank,
"reason": "near_collinear",
"intersection": intersection,
"sweep_angle": sweep_angle,
"wall_a_join_side": side_a,
"wall_b_join_side": side_b,
}
tangent_offset = radius * math.tan(sweep_angle / 2)
tangent_a = (
intersection[0] + dir_a[0] * tangent_offset,
intersection[1] + dir_a[1] * tangent_offset,
intersection[2] + dir_a[2] * tangent_offset,
)
tangent_b = (
intersection[0] + dir_b[0] * tangent_offset,
intersection[1] + dir_b[1] * tangent_offset,
intersection[2] + dir_b[2] * tangent_offset,
)
plane_normal_raw = _vec_cross(dir_a, dir_b)
pn_len = _vec_length(plane_normal_raw)
if pn_len < 1e-9:
return {**blank, "reason": "near_collinear", "intersection": intersection}
plane_normal = (
plane_normal_raw[0] / pn_len,
plane_normal_raw[1] / pn_len,
plane_normal_raw[2] / pn_len,
)
perp_a = _vec_cross(plane_normal, dir_a)
if _vec_dot(perp_a, dir_b) < 0:
perp_a = (-perp_a[0], -perp_a[1], -perp_a[2])
arc_center = (
tangent_a[0] + perp_a[0] * radius,
tangent_a[1] + perp_a[1] * radius,
tangent_a[2] + perp_a[2] * radius,
)
v_a = _vec_sub(tangent_a, arc_center)
v_b = _vec_sub(tangent_b, arc_center)
sweep_axis = plane_normal
if _vec_dot(_vec_cross(v_a, v_b), plane_normal) < 0:
sweep_axis = (-plane_normal[0], -plane_normal[1], -plane_normal[2])
arc_points: list[tuple[float, float, float]] = []
for i in range(arc_resolution + 1):
t = i / arc_resolution
rotated = _rotate_around_axis(v_a, sweep_axis, sweep_angle * t)
arc_points.append(
(
arc_center[0] + rotated[0],
arc_center[1] + rotated[1],
arc_center[2] + rotated[2],
)
)
# Overshoot check only for convex fillets (positive ``tangent_offset``);
# the inverted-fillet case puts tangents past the intersection.
invalid_radius = tangent_offset > 0 and (tangent_offset > far_len_a or tangent_offset > far_len_b)
return {
"valid": not invalid_radius,
"reason": "invalid_radius" if invalid_radius else None,
"intersection": intersection,
"tangent_a": tangent_a,
"tangent_b": tangent_b,
"arc": arc_points,
"arc_center": arc_center,
"arc_radius": radius,
"sweep_angle": sweep_angle,
"sweep_axis": sweep_axis,
"tangent_offset": tangent_offset,
"wall_a_join_side": side_a,
"wall_b_join_side": side_b,
"leg_a_available": far_len_a,
"leg_b_available": far_len_b,
"invalid_radius": invalid_radius,
"invalid_axes": None,
}
+64
View File
@@ -0,0 +1,64 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
import math
from collections.abc import Iterable
from typing import TYPE_CHECKING
import bonsai.core.geometry
if TYPE_CHECKING:
import bpy
import bonsai.tool as tool
Z_ROTATION_ALIGNMENT_TOLERANCE = 1e-9
def _z_rotation_diff(target_z: float, source_z: float) -> float:
"""Signed Z-Euler difference wrapped to [-π, π]."""
return (target_z - source_z + math.pi) % (2 * math.pi) - math.pi
def copy_z_rotation_to_selected(
ifc: type[tool.Ifc],
geometry: type[tool.Geometry],
surveyor: type[tool.Surveyor],
*,
active: bpy.types.Object,
targets: Iterable[bpy.types.Object],
flip: bool = False,
) -> int:
"""Apply ``active``'s Z-Euler rotation to each target."""
source_z = surveyor.get_z_rotation(active)
if flip:
source_z += math.pi
rotated = 0
for obj in targets:
if abs(_z_rotation_diff(surveyor.get_z_rotation(obj), source_z)) < Z_ROTATION_ALIGNMENT_TOLERANCE:
continue
surveyor.set_z_rotation(obj, source_z)
rotated += 1
if ifc.get_entity(obj) is not None:
bonsai.core.geometry.edit_object_placement(ifc, geometry, surveyor, obj=obj)
return rotated
+59 -2
View File
@@ -415,6 +415,17 @@ class Drawing:
def update_embedded_svg_location(cls, uri, old_location, new_location): pass
@interface
class Duplicate:
def get_decomposition_relationships(cls, objs): pass
def get_connection_relationships(cls, objs): pass
def get_port_connection_relationships(cls, objs): pass
def recreate_decompositions(cls, relationships, old_to_new): pass
def recreate_connections(cls, relationship, old_to_new): pass
def recreate_port_connections(cls, snapshot, old_to_new): pass
def consume_warnings(cls): pass
@interface
class Feature:
def add_feature(cls, featured_obj, featured_objs): pass
@@ -445,8 +456,10 @@ class Geometry:
def get_representation_name(cls, representation): pass
def get_styles(cls, obj): pass
def get_total_representation_items(cls, obj): pass
def has_axis_representation(cls, element): pass
def has_data_users(cls, data): pass
def has_material_style_override(cls, obj): pass
def has_material_styles(cls, element): pass
def import_representation_parameters(cls, data): pass
def is_body_representation(cls, representation): pass
def is_box_representation(cls, representation): pass
@@ -776,6 +789,12 @@ class Profile:
def get_profile(cls, element): pass
@interface
class Parametric:
def get_geom_generation(cls) -> int: pass
def refresh_post_commit(cls) -> None: pass
@interface
class Pset:
def add_proposed_property(cls, name, value, props): pass
@@ -859,7 +878,6 @@ class Root:
def assign_body_styles(cls, element, obj): pass
def copy_representation(cls, source, dest): pass
def does_type_have_representations(cls, element): pass
def get_decomposition_relationships(cls, objs): pass
def get_default_container(cls): pass
def get_element_representation(cls, element, context): pass
def get_element_type(cls, element): pass
@@ -873,7 +891,6 @@ class Root:
def is_in_nest_mode(cls, element): pass
def is_spatial_element(cls, element): pass
def link_object_data(cls, source_obj, destination_obj): pass
def recreate_decompositions(cls, relationships, old_to_new): pass
def run_geometry_add_representation(cls, obj=None, context=None, ifc_representation_class=None, profile_set_usage=None): pass
def set_object_name(cls, obj, element): pass
@@ -1017,6 +1034,8 @@ class Spatial:
def get_container(cls, element): pass
def get_decomposed_elements(cls, container, recursive): pass
def get_decomposition(cls, element): pass
def get_host_element(cls, filling): pass
def get_host_wall(cls, filling): pass
def get_object_matrix(cls, obj): pass
def get_relative_object_matrix(cls, target_obj, relative_to_obj): pass
def get_root_element(cls, element): pass
@@ -1137,6 +1156,8 @@ class Style:
@interface
class Surveyor:
def get_absolute_matrix(cls, obj): pass
def get_z_rotation(cls, obj): pass
def set_z_rotation(cls, obj, z): pass
@interface
@@ -1203,6 +1224,42 @@ class Voider:
def void(cls, opening_obj, building_obj): pass
@interface
class Array:
def bake_children_transform(cls, parent_element, item): pass
def constrain_children_to_parent(cls, parent_element): pass
def get_all_children_objects(cls, parent_element): pass
def get_all_objects(cls, parent_element): pass
def get_child_layer_index(cls, child_element): pass
def get_children_objects(cls, modifier_data): pass
def get_modifiers_data(cls, parent_element): pass
def get_parent_element(cls, element): pass
def get_parent_object(cls, element): pass
def remove_constraints(cls, parent_element): pass
def set_children_lock_state(cls, parent_element, item, lock_state): pass
@interface
class Slab:
def read_geometry(cls, obj): pass
@interface
class Wall:
def collinear_boundary_world(cls, seg_a, seg_b): pass
def compute_wall_fillet_geometry(cls, wall_a_obj, wall_b_obj, radius, arc_resolution): pass
def get_axis_local_extent(cls, wall): pass
def get_length_and_height(cls, wall): pass
def get_world_reference_line(cls, obj): pass
def get_x_angle(cls, wall): pass
def has_layer2_usage(cls, wall): pass
def is_straight_axis(cls, wall): pass
def path_connection_location_world(cls, seg_self, self_conn_type, seg_other, other_conn_type, parallel_threshold): pass
def read_geometry(cls, obj): pass
def validate_for_parametric_edit(cls, obj): pass
def walk_connected_walls(cls, start_element, node_cap): pass
@interface
class Web:
pass
+5
View File
@@ -20,6 +20,7 @@
# ruff: noqa: F401
from bonsai.tool.aggregate import Aggregate
from bonsai.tool.array import Array
from bonsai.tool.attribute import Attribute
from bonsai.tool.bcf import Bcf
from bonsai.tool.blender import Blender
@@ -37,6 +38,7 @@ from bonsai.tool.debug import Debug
from bonsai.tool.demo import Demo
from bonsai.tool.document import Document
from bonsai.tool.drawing import Drawing
from bonsai.tool.duplicate import Duplicate
from bonsai.tool.feature import Feature
from bonsai.tool.geometry import Geometry
from bonsai.tool.georeference import Georeference
@@ -51,6 +53,7 @@ from bonsai.tool.misc import Misc
from bonsai.tool.model import Model
from bonsai.tool.nest import Nest
from bonsai.tool.owner import Owner
from bonsai.tool.parametric import Parametric
from bonsai.tool.patch import Patch
from bonsai.tool.polyline import Polyline
from bonsai.tool.profile import Profile
@@ -63,6 +66,7 @@ from bonsai.tool.resource import Resource
from bonsai.tool.root import Root
from bonsai.tool.search import Search
from bonsai.tool.sequence import Sequence
from bonsai.tool.slab import Slab
from bonsai.tool.snap import Snap
from bonsai.tool.spatial import Spatial
from bonsai.tool.structural import Structural
@@ -72,4 +76,5 @@ from bonsai.tool.system import System
from bonsai.tool.tester import Tester
from bonsai.tool.type import Type
from bonsai.tool.unit import Unit
from bonsai.tool.wall import Wall
from bonsai.tool.web import Web
+207
View File
@@ -0,0 +1,207 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Bonsai parametric array service.
Top-level array-domain helpers. The ``BBIM_Array`` pset on a parent ``IfcElement``
holds the list of layers; each layer holds the GUIDs of its child replicas. These
helpers navigate that graph and manage the Blender-side CHILD_OF constraint that
pins children to the parent's matrix_world."""
from __future__ import annotations
import json
from collections.abc import Generator
from typing import TYPE_CHECKING, Any
import bpy
import ifcopenshell
import ifcopenshell.util.element
import bonsai.core.tool
import bonsai.tool as tool
if TYPE_CHECKING:
from ifcopenshell import entity_instance
class Array(bonsai.core.tool.Array):
@classmethod
def bake_children_transform(cls, parent_element: entity_instance, item: int) -> None:
modifier_data = list(cls.get_modifiers_data(parent_element))[item]
children = cls.get_children_objects(modifier_data)
for child in children:
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
if constraint:
with bpy.context.temp_override(object=child):
bpy.ops.constraint.apply(constraint=constraint.name, owner="OBJECT")
@classmethod
def constrain_children_to_parent(cls, parent_element: ifcopenshell.entity_instance) -> None:
if not (parent_obj := tool.Ifc.get_object(parent_element)):
return # Filtered out, arrayed void, etc
assert isinstance(parent_obj, bpy.types.Object)
children = cls.get_all_children_objects(parent_element)
for child in children:
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
if constraint:
child.constraints.remove(constraint)
constraint = child.constraints.new("CHILD_OF")
constraint.name = "BBIM_Array_CHILD_OF"
assert isinstance(constraint, bpy.types.ChildOfConstraint)
constraint.target = parent_obj
@classmethod
def set_children_lock_state(
cls, parent_element: ifcopenshell.entity_instance, item: int, lock_state: bool = True
) -> None:
modifier_data = list(cls.get_modifiers_data(parent_element))[item]
children = cls.get_children_objects(modifier_data)
for child_obj in children:
tool.Blender.lock_transform(child_obj, lock_state)
@classmethod
def remove_constraints(cls, parent_element: ifcopenshell.entity_instance) -> None:
children = cls.get_all_children_objects(parent_element)
for child in children:
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
if constraint:
child.constraints.remove(constraint)
@classmethod
def get_all_objects(cls, parent_element: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
parent_obj = tool.Ifc.get_object(parent_element)
assert isinstance(parent_obj, bpy.types.Object)
children_objects = list(cls.get_all_children_objects(parent_element))
array_objects = [parent_obj] + children_objects # We ensure the parent is at index 0
return array_objects
@classmethod
def get_all_children_objects(
cls, parent_element: ifcopenshell.entity_instance
) -> Generator[bpy.types.Object, None, None]:
for array_modifier in cls.get_modifiers_data(parent_element):
yield from cls.get_children_objects(array_modifier)
@classmethod
def get_parent_element(cls, element: entity_instance) -> entity_instance | None:
"""Inverse of ``get_all_children_objects``: resolve an array element
back to its parent entity. Returns ``None`` when the element isn't
part of a Bonsai parametric array, or the stored Parent GUID does
not resolve in the current file (this is a data-integrity warning
and is logged to the console)."""
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not pset:
return None
parent_guid = pset["Parent"]
try:
return tool.Ifc.get().by_guid(parent_guid)
except RuntimeError:
print(
f"BBIM_Array.Parent GUID {parent_guid!r} on {element} does not resolve "
f"in the current file — array integrity may be broken."
)
return None
@classmethod
def get_parent_object(cls, element: entity_instance) -> bpy.types.Object | None:
parent_element = cls.get_parent_element(element)
if parent_element is None:
return None
return tool.Ifc.get_object(parent_element)
@classmethod
def get_modifiers_data(cls, parent_element: ifcopenshell.entity_instance) -> Generator[dict[str, Any], None, None]:
array_pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
yield from json.loads(array_pset["Data"])
@classmethod
def get_children_objects(cls, modifier_data: dict[str, Any]) -> Generator[bpy.types.Object, None, None]:
child_guid: str
for child_guid in modifier_data["children"]:
child_obj = tool.Blender.get_object_from_guid(child_guid)
if child_obj:
yield child_obj
@classmethod
def get_array_root_guid(cls, element: entity_instance) -> str:
"""Walk ``BBIM_Array.Parent`` upwards and return the topmost ancestor's
GlobalId. For an element with no ``BBIM_Array`` pset (independent
window, never arrayed, or former-child after the apply path), returns
the element's own GlobalId — its "family" is just itself."""
current = element
seen: set[str] = set()
while True:
pset = ifcopenshell.util.element.get_pset(current, "BBIM_Array")
parent_guid = pset.get("Parent") if pset else None
if not parent_guid or parent_guid == current.GlobalId or parent_guid in seen:
return current.GlobalId
seen.add(parent_guid)
try:
current = tool.Ifc.get().by_guid(parent_guid)
except RuntimeError:
return current.GlobalId
@classmethod
def get_parametric_propagation_targets(cls, element: entity_instance) -> list[entity_instance]:
"""Type-occurrences that should receive parametric updates when
``element`` is edited.
Returns occurrences in ``element``'s Bonsai array family. When
``element`` is not part of any array, returns the type-occurrence
peers that are likewise free of ``BBIM_Array`` (preserving the
bulk-edit-by-type UX for standalone parametric elements). An
occurrence whose ``BBIM_Array`` root differs from ``element``'s root
is excluded that is the "independent former child" case the array
apply path produces."""
occurrences = tool.Ifc.get_all_element_occurrences(element)
element_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not element_pset:
return [o for o in occurrences if not ifcopenshell.util.element.get_pset(o, "BBIM_Array")]
element_root = cls.get_array_root_guid(element)
return [o for o in occurrences if cls.get_array_root_guid(o) == element_root]
@classmethod
def get_child_layer_index(cls, child_element: entity_instance) -> int | None:
"""Index of the layer that produced ``child_element``, or ``None``
if the child is unparented, missing from the parent's data, or the
parent's pset is unreadable. Total: never raises."""
pset = ifcopenshell.util.element.get_pset(child_element, "BBIM_Array")
if not pset:
return None
parent_guid = pset.get("Parent")
if not parent_guid or parent_guid == child_element.GlobalId:
return None
try:
parent_element = tool.Ifc.get().by_guid(parent_guid)
except RuntimeError:
return None
data_text = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array", "Data")
if not data_text:
return None
try:
layers = json.loads(data_text)
except (ValueError, TypeError):
return None
child_guid = child_element.GlobalId
for i, layer in enumerate(layers):
if child_guid in layer.get("children", []):
return i
return None
+331 -148
View File
@@ -15,12 +15,13 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
from __future__ import annotations
import contextlib
import importlib
import json
import os
import platform
import subprocess
@@ -28,7 +29,7 @@ import sys
import tempfile
import traceback
import types
from collections.abc import Callable, Generator, Iterable, Sequence, Sized
from collections.abc import Callable, Generator, Iterable, Mapping, Sequence, Sized
from datetime import datetime
from functools import cache, lru_cache
from pathlib import Path
@@ -45,7 +46,6 @@ from typing import (
import bmesh
import bpy
import ifcopenshell.api
import ifcopenshell.util.element
import numpy as np
import numpy.typing as npt
@@ -55,12 +55,12 @@ from mathutils import Matrix, Vector
import bonsai.bim
import bonsai.core.tool
import bonsai.tool as tool
from bonsai.bim.ifc import IFC_CONNECTED_TYPE
if TYPE_CHECKING:
import bpy.stub_internal.rna_enums as rna_enums
from sun_position.properties import SunPosProperties
from bonsai.bim.ifc import IFC_CONNECTED_TYPE
from bonsai.bim.module.attribute.prop import BIMAttributeProperties
from bonsai.bim.module.constraint.prop import (
BIMConstraintProperties,
@@ -97,6 +97,19 @@ VIEWPORT_ATTRIBUTES = [
OBJECT_DATA_TYPE = Union[bpy.types.Mesh, bpy.types.Curve, bpy.types.Camera]
_RAILING_MODIFIER_IFC_CLASSES = ("IfcRailing", "IfcRailingType")
_STAIR_MODIFIER_IFC_CLASSES = (
"IfcStairFlight",
"IfcStairFlightType",
"IfcMember",
"IfcMemberType",
"IfcStair",
"IfcStairType",
)
_WINDOW_MODIFIER_IFC_CLASSES = ("IfcWindow", "IfcWindowType", "IfcWindowStyle")
_DOOR_MODIFIER_IFC_CLASSES = ("IfcDoor", "IfcDoorType", "IfcDoorStyle")
_ROOF_MODIFIER_IFC_CLASSES = ("IfcRoof", "IfcRoofType")
class Blender(bonsai.core.tool.Blender):
OBJECT_TYPES_THAT_SUPPORT_EDIT_MODE = ("MESH", "CURVE", "SURFACE", "META", "FONT", "LATTICE", "ARMATURE")
@@ -415,6 +428,189 @@ class Blender(bonsai.core.tool.Blender):
with bpy.context.temp_override(**cls.get_viewport_context()):
bpy.ops.wm.tool_set_by_id(name=tool_name)
@classmethod
def are_viewport_gizmos_enabled(cls) -> bool:
"""Central gate every Bonsai gizmo poll / decorator draw checks before
rendering. Centralises the read of
``gizmos.draw_gizmos_in_3d_viewport`` from addon preferences."""
return cls.get_addon_preferences().gizmos.draw_gizmos_in_3d_viewport
class DecoratorColors(NamedTuple):
selected: tuple
unselected: tuple
special: tuple
error: tuple
background: tuple
@classmethod
def get_decorator_colors(cls) -> Blender.DecoratorColors:
"""The five ``decorator_color_*`` fields read together so each viewport
decorator's draw callback resolves them in one call instead of five."""
prefs = cls.get_addon_preferences()
return cls.DecoratorColors(
selected=prefs.decorator_color_selected,
unselected=prefs.decorator_color_unselected,
special=prefs.decorator_color_special,
error=prefs.decorator_color_error,
background=prefs.decorator_color_background,
)
class ViewportDecorator:
"""Shared ``SpaceView3D.draw_handler_add`` lifecycle for feature decorators.
Single-handler subclasses set ``draw_method`` (default ``"draw"``); the
handler binds at ``POST_VIEW``. Multi-handler subclasses set
``draw_methods`` to a tuple of ``(method_name, phase)`` pairs; when it
is non-``None`` it supersedes ``draw_method``.
Decorators whose ``install`` must accept extra arguments (e.g. a callback
or a precomputed bmesh) override ``install`` themselves."""
draw_method: str = "draw"
draw_methods: tuple[tuple[str, str], ...] | None = None
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
cls.handlers = []
cls.is_installed = False
# Fail loudly at class-definition time if draw_method / draw_methods
# names an attribute the class doesn't expose. Without this, a typo
# only surfaces on the first redraw — as a silent missing-attribute
# handler — which may be far from the offending declaration.
method_names = (
tuple(name for name, _phase in cls.draw_methods) if cls.draw_methods is not None else (cls.draw_method,)
)
for name in method_names:
if getattr(cls, name, None) is None:
raise TypeError(f"{cls.__name__}: draw method {name!r} is declared but not defined on the class")
@classmethod
def install(cls, context: bpy.types.Context) -> None:
if cls.is_installed:
cls.uninstall()
handler = cls()
bindings = cls.draw_methods if cls.draw_methods is not None else ((cls.draw_method, "POST_VIEW"),)
# Rollback partial registrations on any draw_handler_add failure, so
# cls.handlers never ends up holding a half-installed set.
added: list = []
try:
for method_name, phase in bindings:
added.append(
bpy.types.SpaceView3D.draw_handler_add(
getattr(handler, method_name), (context,), "WINDOW", phase
)
)
except Exception:
for h in added:
try:
bpy.types.SpaceView3D.draw_handler_remove(h, "WINDOW")
except ValueError:
pass
raise
cls.handlers = added
cls.is_installed = True
@classmethod
def uninstall(cls) -> None:
for h in cls.handlers:
try:
bpy.types.SpaceView3D.draw_handler_remove(h, "WINDOW")
except ValueError:
pass
cls.handlers.clear()
cls.is_installed = False
@staticmethod
def _lookup_active_instance(gizmo_cls: type, context: bpy.types.Context) -> Optional[Any]:
"""Return the live ``GizmoGroup`` instance registered under
``context.region``, or ``None`` if there isn't one. The per-region
weakref dict on the gizmo class is populated by ``setup()``; multi-
viewport setups put one entry per region in it so each region's
decorator sees only its own region's hover state."""
instances = getattr(gizmo_cls, "_active_instances", None)
if not instances:
return None
region = getattr(context, "region", None)
if region is None:
return None
ref = instances.get(region.as_pointer())
if ref is None:
return None
return ref()
def _cursor_icon_hovered(self, gizmo_cls: type, attr_name: str, context: bpy.types.Context) -> bool:
"""True iff the gizmo group instance in the current region exposes a gizmo
under ``attr_name`` that reports as highlighted. Any access exception is
swallowed so a transient bpy-state hiccup never breaks the draw loop."""
inst = self._lookup_active_instance(gizmo_cls, context)
if inst is None:
return False
try:
return bool(getattr(inst, attr_name).is_highlight)
except (AttributeError, ReferenceError):
return False
@classmethod
def sync_all(
cls,
context: bpy.types.Context,
enabled: Mapping[type[Blender.ViewportDecorator], bool],
) -> None:
"""Drive each listed decorator to its desired install state in one call.
Each entry whose value is ``True`` ends up installed; each entry whose
value is ``False`` ends up uninstalled. Pass ``True`` for always-on
overlays so they survive subsequent file loads."""
for decorator_cls, should_install in enabled.items():
if should_install:
decorator_cls.install(context)
else:
decorator_cls.uninstall()
@classmethod
def is_view_top_down(cls, context: bpy.types.Context, threshold: float = 0.9659) -> bool:
"""True when the viewport camera is looking ~straight down (or up) the world Z axis.
Default threshold of 0.9659 = cos(15°) a 15° tilt cone around ±world Z.
Above the threshold the world-Z axis projects to a small fraction of its
true length on screen, so callers that lay icons or markers out along
world Z should switch to a screen-space offset and any gizmo whose intent
is specifically "vertical" loses its visual cue. The cone is kept narrow
so vertical-intent gizmos stay visible across the typical orbit range of
3D viewport work and drop out only near genuine plan view."""
rv3d = context.region_data
if rv3d is None:
return False
view_forward = Vector(rv3d.view_matrix.inverted().col[2][:3]).normalized()
return abs(view_forward.z) > threshold
@classmethod
def top_down_factor(cls, context: bpy.types.Context, threshold: float = 0.9659) -> float:
"""Continuous 01 ramp matching ``is_view_top_down``'s cone: 0 outside the
cone, ramping linearly to 1 at strict alignment with world Z. Callers that
want a proportional effect (an icon-stack lift growing as the view
approaches plan) use this in place of the boolean to avoid a one-frame
visual jump as the camera crosses the threshold."""
rv3d = context.region_data
if rv3d is None:
return 0.0
view_forward = Vector(rv3d.view_matrix.inverted().col[2][:3]).normalized()
alignment = abs(view_forward.z)
if alignment <= threshold:
return 0.0
return (alignment - threshold) / (1.0 - threshold)
@classmethod
def get_screen_up_world(cls, context: bpy.types.Context) -> Vector:
"""World-space direction corresponding to the camera's up axis (screen-vertical).
Returns ``+Y`` when region data is unavailable so callers can compute an
offset without a guard branch."""
rv3d = context.region_data
if rv3d is None:
return Vector((0.0, 1.0, 0.0))
return Vector(rv3d.view_matrix.inverted().col[1][:3]).normalized()
@classmethod
def get_shader_editor_context(cls) -> Union[dict[str, Any], None]:
for screen in bpy.data.screens:
@@ -484,9 +680,13 @@ class Blender(bonsai.core.tool.Blender):
@classmethod
def update_all_viewports(cls, context: bpy.types.Context | None = None) -> None:
"""Tag every visible 3D viewport for redraw. Silent no-op when no
screen attached (background mode, plug-out, mid-load_post)."""
context = context or bpy.context
assert context.screen
for area in context.screen.areas:
screen = getattr(context, "screen", None)
if screen is None:
return
for area in screen.areas:
if area.type == "VIEW_3D":
area.tag_redraw()
@@ -635,10 +835,11 @@ class Blender(bonsai.core.tool.Blender):
op_text = "" if ui_context == "TOOL_HEADER" else text
modifier_icon, modifier_str = cls.KEY_MODIFIERS.get(modifier, ("NONE", ""))
row = layout if ui_context == "TOOL_HEADER" else layout.row(align=True)
module = sys.modules[module_name]
icon_previews: Union[bpy.utils.previews.ImagePreviewCollection, None]
icon_previews = getattr(module, "custom_icon_previews", None)
row = layout if ui_context == "TOOL_HEADER" else layout.row(align=True)
if icon_previews:
custom_icon = icon_previews.get(text.upper().replace(" ", "_"), icon_previews["IFC"]).icon_id
op = row.operator(operator_to_use, text=op_text, icon_value=custom_icon)
@@ -646,6 +847,7 @@ class Blender(bonsai.core.tool.Blender):
op = row.operator(operator_to_use, text=op_text)
if ui_context != "TOOL_HEADER":
row.label(text="", icon=modifier_icon)
row.separator(factor=1)
row.label(text="", icon=f"EVENT_{key}")
if operator_to_use == hotkey_operator:
@@ -1130,6 +1332,74 @@ class Blender(bonsai.core.tool.Blender):
return True
class Modifier:
# ----------------------------------------------------------------------
# FIXME(PR5): backward-compat shims for callers still using the
# pre-refactor API. The is_<type> predicates now live on tool.Parametric;
# the Array helper bag now lives on tool.Array. PR4 migrates each caller;
# this whole shim block is removed in PR5's cleanup.
# ----------------------------------------------------------------------
@classmethod
def is_door(cls, element: entity_instance) -> bool:
return tool.Parametric.is_door(element)
@classmethod
def is_railing(cls, element: entity_instance) -> bool:
return tool.Parametric.is_railing(element)
@classmethod
def is_roof(cls, element: entity_instance) -> bool:
return tool.Parametric.is_roof(element)
@classmethod
def is_stair(cls, element: entity_instance) -> bool:
return tool.Parametric.is_stair(element)
@classmethod
def is_wall(cls, element: entity_instance) -> bool:
return tool.Parametric.is_wall(element)
@classmethod
def is_window(cls, element: entity_instance) -> bool:
return tool.Parametric.is_window(element)
class Array:
@classmethod
def bake_children_transform(cls, parent_element: ifcopenshell.entity_instance, item: int) -> None:
tool.Array.bake_children_transform(parent_element, item)
@classmethod
def constrain_children_to_parent(cls, parent_element: ifcopenshell.entity_instance) -> None:
tool.Array.constrain_children_to_parent(parent_element)
@classmethod
def get_all_children_objects(cls, parent_element: ifcopenshell.entity_instance) -> list:
return tool.Array.get_all_children_objects(parent_element)
@classmethod
def get_all_objects(cls, parent_element: ifcopenshell.entity_instance) -> list:
return tool.Array.get_all_objects(parent_element)
@classmethod
def get_children_objects(cls, modifier_data: dict) -> list:
return tool.Array.get_children_objects(modifier_data)
@classmethod
def get_modifiers_data(cls, parent_element: ifcopenshell.entity_instance):
return tool.Array.get_modifiers_data(parent_element)
@classmethod
def remove_constraints(cls, parent_element: ifcopenshell.entity_instance) -> None:
tool.Array.remove_constraints(parent_element)
@classmethod
def set_children_lock_state(
cls, parent_element: ifcopenshell.entity_instance, item: int, lock: bool
) -> None:
tool.Array.set_children_lock_state(parent_element, item, lock)
# ----------------------------------------------------------------------
@classmethod
def try_applying_edit_mode(cls, obj: bpy.types.Object, element: entity_instance) -> bool:
"""Tries to validate the current BIM modifier parameters for the active object
@@ -1137,20 +1407,18 @@ class Blender(bonsai.core.tool.Blender):
:return: True if an action was taken, False otherwise
"""
if cls.is_roof(element):
if cls.is_editing_roof_parameters(obj):
bpy.ops.bim.finish_editing_roof()
# roof and railing both finalize then drop into path-edit mode — handle
# them before the generic finish dispatch so the path transition runs.
if tool.Parametric.is_roof(element):
if tool.Parametric.ROOF.is_editing(obj):
tool.Parametric.run_bim_op(tool.Parametric.ROOF.finish_op)
bpy.ops.bim.enable_editing_roof_path()
elif cls.is_railing(element):
if cls.is_editing_railing_parameters(obj):
bpy.ops.bim.finish_editing_railing()
elif tool.Parametric.is_railing(element):
if tool.Parametric.RAILING.is_editing(obj):
tool.Parametric.run_bim_op(tool.Parametric.RAILING.finish_op)
bpy.ops.bim.enable_editing_railing_path()
elif cls.is_editing_stair_parameters(obj):
bpy.ops.bim.finish_editing_stair()
elif cls.is_editing_door_parameters(obj):
bpy.ops.bim.finish_editing_door()
elif cls.is_editing_window_parameters(obj):
bpy.ops.bim.finish_editing_window()
elif feature := tool.Parametric.is_object_editing(obj):
tool.Parametric.run_bim_op(feature.finish_op)
else:
return False
return True
@@ -1161,68 +1429,80 @@ class Blender(bonsai.core.tool.Blender):
:return: True if an action was taken, False otherwise
"""
# Path-edit modes are distinct from parametric draft modes; handle them first.
if cls.is_editing_railing_path(obj):
bpy.ops.bim.cancel_editing_railing_path()
elif cls.is_editing_roof_path(obj):
bpy.ops.bim.cancel_editing_roof_path()
elif cls.is_editing_railing_parameters(obj):
bpy.ops.bim.cancel_editing_railing()
elif cls.is_editing_door_parameters(obj):
bpy.ops.bim.cancel_editing_door()
elif cls.is_editing_window_parameters(obj):
bpy.ops.bim.cancel_editing_window()
elif cls.is_editing_roof_parameters(obj):
bpy.ops.bim.cancel_editing_roof()
elif cls.is_editing_stair_parameters(obj):
bpy.ops.bim.cancel_editing_stair()
elif feature := tool.Parametric.is_object_editing(obj):
tool.Parametric.run_bim_op(feature.cancel_op)
else:
return False
return True
@classmethod
def is_eligible_for_railing_modifier(cls, obj: bpy.types.Object) -> bool:
return tool.Blender.is_object_an_ifc_class(obj, ("IfcRailing", "IfcRailingType"))
return tool.Blender.is_object_an_ifc_class(obj, _RAILING_MODIFIER_IFC_CLASSES)
@classmethod
def is_eligible_for_stair_modifier(cls, obj: bpy.types.Object) -> bool:
return tool.Blender.is_object_an_ifc_class(
obj, ("IfcStairFlight", "IfcStairFlightType", "IfcMember", "IfcMemberType", "IfcStair", "IfcStairType")
)
return tool.Blender.is_object_an_ifc_class(obj, _STAIR_MODIFIER_IFC_CLASSES)
@classmethod
def is_eligible_for_window_modifier(cls, obj: bpy.types.Object) -> bool:
return tool.Blender.is_object_an_ifc_class(obj, ("IfcWindow", "IfcWindowType", "IfcWindowStyle"))
return tool.Blender.is_object_an_ifc_class(obj, _WINDOW_MODIFIER_IFC_CLASSES)
@classmethod
def is_eligible_for_door_modifier(cls, obj: bpy.types.Object) -> bool:
return tool.Blender.is_object_an_ifc_class(obj, ("IfcDoor", "IfcDoorType", "IfcDoorStyle"))
return tool.Blender.is_object_an_ifc_class(obj, _DOOR_MODIFIER_IFC_CLASSES)
@classmethod
def is_eligible_for_roof_modifier(cls, obj: bpy.types.Object) -> bool:
return tool.Blender.is_object_an_ifc_class(obj, ("IfcRoof", "IfcRoofType"))
return tool.Blender.is_object_an_ifc_class(obj, _ROOF_MODIFIER_IFC_CLASSES)
@classmethod
def is_railing(cls, element: entity_instance) -> bool:
return tool.Pset.get_element_pset(element, "BBIM_Railing")
def is_array_child(cls, element: entity_instance) -> bool:
"""True if element is a CHILD of a Bonsai parametric array.
Children are managed replicas regenerated from the parent's pset —
their parametric attributes (door dimensions, wall lengths, ) are
overwritten on the next ``regenerate_array``. Parametric gizmo
groups skip children via this predicate in ``poll``.
This sits on a different axis from ``tool.Parametric.is_array``:
cardinality (parent vs child) is orthogonal to feature kind, and
an arrayed wall fires both ``is_wall`` and ``is_array`` on the
same element."""
if element is None:
return False
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not pset:
return False
parent_guid = pset.get("Parent")
return parent_guid is not None and parent_guid != element.GlobalId
@classmethod
def is_roof(cls, element: entity_instance) -> bool:
return tool.Pset.get_element_pset(element, "BBIM_Roof")
def is_slab(cls, element: entity_instance) -> bool:
"""A slab is host-eligible for the parametric add-opening gizmo if
it is an IfcSlab with LAYER3 usage.
Slabs carry no proprietary BBIM_Slab pset their parametric state
lives in standard IFC (extrusion depth, IfcMaterialLayerSetUsage
with LayerSetDirection AXIS3). Any LAYER3 slab qualifies."""
if element is None or not element.is_a("IfcSlab"):
return False
return tool.Model.get_usage_type(element) == "LAYER3"
@classmethod
def is_window(cls, element: entity_instance) -> bool:
return tool.Pset.get_element_pset(element, "BBIM_Window")
def is_pipe_segment(cls, element: entity_instance) -> bool:
return element is not None and element.is_a("IfcPipeSegment")
@classmethod
def is_door(cls, element: entity_instance) -> bool:
return tool.Pset.get_element_pset(element, "BBIM_Door")
def is_duct_segment(cls, element: entity_instance) -> bool:
return element is not None and element.is_a("IfcDuctSegment")
@classmethod
def is_stair(cls, element: entity_instance) -> bool:
return tool.Pset.get_element_pset(element, "BBIM_Stair")
@classmethod
def is_editing_railing_path(cls, obj: bpy.types.Object):
def is_editing_railing_path(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_railing_props(obj)
return props.is_editing_path
@@ -1231,107 +1511,10 @@ class Blender(bonsai.core.tool.Blender):
props = tool.Model.get_roof_props(obj)
return props.is_editing_path
@classmethod
def is_editing_railing_parameters(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_railing_props(obj)
return props.is_editing
@classmethod
def is_editing_roof_parameters(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_roof_props(obj)
return props.is_editing
@classmethod
def is_editing_window_parameters(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_window_props(obj)
return props.is_editing
@classmethod
def is_editing_door_parameters(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_door_props(obj)
return props.is_editing
@classmethod
def is_editing_stair_parameters(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_stair_props(obj)
return props.is_editing
@classmethod
def is_modifier_with_non_editable_path(cls, element: entity_instance) -> bool:
return cls.is_stair(element) or cls.is_door(element) or cls.is_window(element)
class Array:
@classmethod
def bake_children_transform(cls, parent_element: entity_instance, item: int) -> None:
modifier_data = list(cls.get_modifiers_data(parent_element))[item]
children = cls.get_children_objects(modifier_data)
for child in children:
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
if constraint:
with bpy.context.temp_override(object=child):
bpy.ops.constraint.apply(constraint=constraint.name, owner="OBJECT")
@classmethod
def constrain_children_to_parent(cls, parent_element: ifcopenshell.entity_instance) -> None:
if not (parent_obj := tool.Ifc.get_object(parent_element)):
return # Filtered out, arrayed void, etc
assert isinstance(parent_obj, bpy.types.Object)
children = cls.get_all_children_objects(parent_element)
for child in children:
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
if constraint:
child.constraints.remove(constraint)
constraint = child.constraints.new("CHILD_OF")
constraint.name = "BBIM_Array_CHILD_OF"
assert isinstance(constraint, bpy.types.ChildOfConstraint)
constraint.target = parent_obj
@classmethod
def set_children_lock_state(
cls, parent_element: ifcopenshell.entity_instance, item: int, lock_state: bool = True
) -> None:
modifier_data = list(cls.get_modifiers_data(parent_element))[item]
children = cls.get_children_objects(modifier_data)
for child_obj in children:
Blender.lock_transform(child_obj, lock_state)
@classmethod
def remove_constraints(cls, parent_element: ifcopenshell.entity_instance) -> None:
children = cls.get_all_children_objects(parent_element)
for child in children:
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
if constraint:
child.constraints.remove(constraint)
@classmethod
def get_all_objects(cls, parent_element: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
parent_obj = tool.Ifc.get_object(parent_element)
assert isinstance(parent_obj, bpy.types.Object)
children_objects = list(cls.get_all_children_objects(parent_element))
array_objects = [parent_obj] + children_objects # We ensure the parent is at index 0
return array_objects
@classmethod
def get_all_children_objects(
cls, parent_element: ifcopenshell.entity_instance
) -> Generator[bpy.types.Object, None, None]:
for array_modifier in cls.get_modifiers_data(parent_element):
yield from cls.get_children_objects(array_modifier)
@classmethod
def get_modifiers_data(
cls, parent_element: ifcopenshell.entity_instance
) -> Generator[dict[str, Any], None, None]:
array_pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
yield from json.loads(array_pset["Data"])
@classmethod
def get_children_objects(cls, modifier_data: dict[str, Any]) -> Generator[bpy.types.Object, None, None]:
child_guid: str
for child_guid in modifier_data["children"]:
child_obj = tool.Blender.get_object_from_guid(child_guid)
if child_obj:
yield child_obj
feature = tool.Parametric.find_for_element(element)
return bool(feature and feature.has_non_editable_path)
class Attribute:
@classmethod
+111
View File
@@ -32,6 +32,7 @@ from __future__ import annotations
import math
import sys
from collections.abc import Sequence
from typing import TYPE_CHECKING, Union
import bmesh
@@ -45,6 +46,13 @@ if TYPE_CHECKING:
VTX_PRECISION = 1.0e-5
# Tolerances below are in Blender units (SI metres).
# Looser than VTX_PRECISION because regen-time numeric drift exceeds CAD snap precision.
WELD_TOLERANCE = 1.0e-4
# How close a vertex must be to the cut plane to count as on it.
BISECT_TOLERANCE = 1.0e-4
# Strict weld for cleaning up exactly-coincident vertices.
WELD_EPSILON = 1.0e-6
class Cad:
@@ -996,3 +1004,106 @@ class Cad:
y = height_half + height_half * (prj[1] / w)
return Vector((float(x), float(y)))
return default
@classmethod
def sweep_disk_along_polyline(
cls,
bm: bmesh.types.BMesh,
points: Sequence[Vector],
radius: float,
arc_indices: Sequence[int] = (),
profile_segments: int = 8,
) -> None:
"""Append a tube of ``radius`` along the polyline ``points`` to ``bm``.
Viewport-quality approximation of an IFC ``IfcSweptDiskSolid``: each
consecutive pair of points becomes a capped cylinder. The cylinders
overlap at joints rather than being mitered the visual artifact is
negligible at typical handrail radii (~25mm) and acceptable for
live parametric-edit preview.
``arc_indices`` is accepted for API symmetry with the IFC builder
(which receives the same data structure), but is currently unused
arcs are visualised as polyline kinks. Tessellating each arc with a
Lagrange or circular interpolation would smooth the joints; deferred
until profile fidelity becomes a concern.
:param bm: target bmesh, mutated in place.
:param points: polyline vertices.
:param radius: tube radius (project units).
:param arc_indices: indices of arc midpoints (currently ignored).
:param profile_segments: sides on each cylinder cross-section.
"""
del arc_indices # accepted for forward compatibility; see docstring
if len(points) < 2:
return
for p0, p1 in zip(points, points[1:]):
cls._add_capped_cylinder(bm, Vector(p0), Vector(p1), radius, profile_segments)
@classmethod
def add_disk_extrusion(
cls,
bm: bmesh.types.BMesh,
position: Vector,
radius: float,
depth: float,
axis_rotation_z: float,
profile_segments: int = 12,
) -> None:
"""Append a flat cylinder (disk extrusion) to ``bm``.
A disk of ``radius`` extruded by ``depth`` along the +Y axis rotated
by ``axis_rotation_z`` radians around Z. ``position`` is the disk's
base, not its centre.
:param bm: target bmesh, mutated in place.
:param position: base of the extrusion in object-local coordinates.
:param radius: disk radius.
:param depth: extrusion depth along the (rotated) Y axis.
:param axis_rotation_z: rotation around Z applied to the +Y axis to
obtain the extrusion direction.
:param profile_segments: sides on the disk's edge.
"""
# The +Y axis rotated by axis_rotation_z around Z gives the extrusion
# direction: (-sin(θ), cos(θ), 0). The disk axis points along it.
axis = Vector((-math.sin(axis_rotation_z), math.cos(axis_rotation_z), 0.0))
end = position + axis * depth
cls._add_capped_cylinder(bm, position, end, radius, profile_segments)
@classmethod
def _add_capped_cylinder(
cls,
bm: bmesh.types.BMesh,
p0: Vector,
p1: Vector,
radius: float,
segments: int,
) -> None:
"""Append one capped cylinder of ``radius`` from ``p0`` to ``p1`` to ``bm``."""
direction = p1 - p0
length = direction.length
if length < 1e-9:
return
direction = direction / length
z_axis = Vector((0.0, 0.0, 1.0))
dot = direction.dot(z_axis)
if dot > 1.0 - 1e-6:
rotation = Matrix.Identity(4)
elif dot < -1.0 + 1e-6:
# Anti-parallel: rotate 180° around X so the cone flips bottom-to-top.
rotation = Matrix.Rotation(math.pi, 4, "X")
else:
rotation = z_axis.rotation_difference(direction).to_matrix().to_4x4()
matrix = Matrix.Translation((p0 + p1) * 0.5) @ rotation
bmesh.ops.create_cone(
bm,
cap_ends=True,
cap_tris=False,
segments=segments,
radius1=radius,
radius2=radius,
depth=length,
matrix=matrix,
)
+328
View File
@@ -0,0 +1,328 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
import bpy
import ifcopenshell
import ifcopenshell.util.element
import ifcopenshell.util.placement
import ifcopenshell.util.representation
import bonsai.core.geometry
import bonsai.core.tool
import bonsai.tool as tool
@dataclass
class DecompositionRecord:
type: Literal["fill"]
element: ifcopenshell.entity_instance
@dataclass
class ConnectionRecord:
type: Literal["path"]
relating_element: ifcopenshell.entity_instance
related_element: ifcopenshell.entity_instance
relating_connection_type: str
related_connection_type: str
relating_priorities: list[int]
related_priorities: list[int]
@dataclass
class PortConnectionRecord:
relating_port_index: int
related_element: ifcopenshell.entity_instance
related_port_index: int
direction: str
@dataclass
class PortConnectionSnapshot:
"""Port-to-port connections and per-element port counts captured before duplication."""
by_element: dict[ifcopenshell.entity_instance, list[PortConnectionRecord]] = field(default_factory=dict)
port_counts: dict[ifcopenshell.entity_instance, int] = field(default_factory=dict)
class Duplicate(bonsai.core.tool.Duplicate):
_pending_warnings: list[str] = []
@classmethod
def _emit_warning(cls, message: str) -> None:
"""Buffer a warning for later retrieval by an operator. Falling through
to a print keeps the message in the Blender console for the headless /
no-operator code path."""
cls._pending_warnings.append(message)
print(f"Bonsai: WARNING — {message}")
@classmethod
def consume_warnings(cls) -> list[str]:
"""Return and clear the buffered warnings — operators call this after
``tool.Geometry.duplicate_ifc_objects`` to forward each to ``self.report``."""
warnings = cls._pending_warnings
cls._pending_warnings = []
return warnings
@classmethod
def get_decomposition_relationships(
cls, objs: list[bpy.types.Object]
) -> dict[ifcopenshell.entity_instance, DecompositionRecord]:
relationships: dict[ifcopenshell.entity_instance, DecompositionRecord] = {}
for obj in objs:
element = tool.Ifc.get_entity(obj)
if not element:
continue
if building := tool.Spatial.get_host_element(element):
relationships[element] = DecompositionRecord(type="fill", element=building)
return relationships
@classmethod
def get_connection_relationships(
cls, objs: list[bpy.types.Object]
) -> dict[ifcopenshell.entity_instance, ConnectionRecord]:
relationships: dict[ifcopenshell.entity_instance, ConnectionRecord] = {}
for obj in objs:
element = tool.Ifc.get_entity(obj)
if not element:
continue
if hasattr(element, "ConnectedTo") and element.ConnectedTo:
paths = [
connection for connection in element.ConnectedTo if connection.is_a("IfcRelConnectsPathElements")
]
for path in paths:
relationships[element] = ConnectionRecord(
type="path",
relating_element=path.RelatingElement,
related_element=path.RelatedElement,
relating_connection_type=path.RelatingConnectionType,
related_connection_type=path.RelatedConnectionType,
relating_priorities=list(path.RelatingPriorities or []),
related_priorities=list(path.RelatedPriorities or []),
)
return relationships
@classmethod
def get_port_connection_relationships(cls, objs: list[bpy.types.Object]) -> PortConnectionSnapshot:
"""Snapshot ``IfcRelConnectsPorts`` among MEP elements in ``objs``, indexed for positional-port replay onto duplicates."""
# Function-local: top-level import would trigger a partial-init cycle.
from bonsai.tool.system import direction_from_port_pair
snapshot = PortConnectionSnapshot()
elements_in_set: set[ifcopenshell.entity_instance] = set()
for obj in objs:
element = tool.Ifc.get_entity(obj)
if element is not None and tool.System.is_mep_element(element):
elements_in_set.add(element)
if not elements_in_set:
return snapshot
ordered_elements = sorted(elements_in_set, key=lambda e: e.id())
for element in ordered_elements:
snapshot.port_counts[element] = len(tool.System.get_ports(element))
seen: set[tuple[tuple[int, int], tuple[int, int]]] = set()
for element in ordered_elements:
ports = tool.System.get_ports(element)
for port_index, port in enumerate(ports):
connected_port = tool.System.get_connected_port(port)
if connected_port is None:
continue
other_element = tool.System.get_port_relating_element(connected_port)
if other_element is None or other_element not in elements_in_set:
continue
other_ports = tool.System.get_ports(other_element)
try:
other_port_index = other_ports.index(connected_port)
except ValueError:
continue
pair_key = tuple(
sorted(
[
(element.id(), port_index),
(other_element.id(), other_port_index),
]
)
)
if pair_key in seen:
continue
seen.add(pair_key)
snapshot.by_element.setdefault(element, []).append(
PortConnectionRecord(
relating_port_index=port_index,
related_element=other_element,
related_port_index=other_port_index,
direction=direction_from_port_pair(port, connected_port),
)
)
return snapshot
@classmethod
def recreate_decompositions(
cls,
relationships: dict[ifcopenshell.entity_instance, DecompositionRecord],
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]],
) -> None:
for subelement, data in relationships.items():
new_subelements = old_to_new.get(subelement)
new_elements = old_to_new.get(data.element)
if not new_subelements or not new_elements:
continue
for i, new_subelement in enumerate(new_subelements):
new_element = new_elements[i]
if data.type == "fill":
element = new_element
filling = new_subelement
voided_obj = tool.Ifc.get_object(new_element)
filling_obj = tool.Ifc.get_object(new_subelement)
existing_opening_occurrence = subelement.FillsVoids[0].RelatingOpeningElement
opening = tool.Ifc.run("root.copy_class", product=existing_opening_occurrence)
tool.Ifc.run(
"geometry.edit_object_placement",
product=opening,
matrix=ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement),
is_si=False,
)
representation = ifcopenshell.util.representation.get_representation(
existing_opening_occurrence, "Model", "Body", "MODEL_VIEW"
)
representation = ifcopenshell.util.representation.resolve_representation(representation)
mapped_representation = tool.Ifc.run("geometry.map_representation", representation=representation)
tool.Ifc.run(
"geometry.assign_representation",
product=opening,
representation=mapped_representation,
)
tool.Ifc.run("feature.add_feature", feature=opening, element=element)
tool.Ifc.run("feature.add_filling", opening=opening, element=filling)
voided_objs = [voided_obj]
# Openings affect all subelements of an aggregate
for child_subelement in ifcopenshell.util.element.get_decomposition(element):
subobj = tool.Ifc.get_object(child_subelement)
if subobj:
voided_objs.append(subobj)
for voided_obj in voided_objs:
if mesh_data := voided_obj.data:
representation = tool.Ifc.get().by_id(
tool.Geometry.get_mesh_props(mesh_data).ifc_definition_id
)
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=voided_obj,
representation=representation,
)
@classmethod
def recreate_connections(
cls,
relationship: dict[ifcopenshell.entity_instance, ConnectionRecord],
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]],
) -> None:
for element, data in relationship.items():
try:
new_relating_element = old_to_new.get(data.relating_element)[0]
new_related_element = old_to_new.get(data.related_element)[0]
except (KeyError, IndexError, TypeError):
continue
new_rel = tool.Ifc.run(
"geometry.connect_path",
relating_element=new_relating_element,
related_element=new_related_element,
relating_connection=data.relating_connection_type,
related_connection=data.related_connection_type,
)
# connect_path hardcodes priorities to []; restore them post-hoc.
priority_attrs: dict[str, Any] = {}
if data.relating_priorities:
priority_attrs["RelatingPriorities"] = data.relating_priorities
if data.related_priorities:
priority_attrs["RelatedPriorities"] = data.related_priorities
if new_rel is not None and priority_attrs:
try:
tool.Ifc.run("attribute.edit_attributes", product=new_rel, attributes=priority_attrs)
except (RuntimeError, ifcopenshell.Error) as e:
cls._emit_warning(
f"connection priority restore failed for {new_rel}; "
f"duplicate has empty RelatingPriorities/RelatedPriorities: {e}"
)
@classmethod
def recreate_port_connections(
cls,
snapshot: PortConnectionSnapshot,
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]],
) -> None:
"""Recreate ``IfcRelConnectsPorts`` between duplicates; skip records whose duplicate's port count diverges from the snapshot."""
for relating_element, records in snapshot.by_element.items():
for record in records:
related_element = record.related_element
try:
new_relating = old_to_new[relating_element][0]
new_related = old_to_new[related_element][0]
except (KeyError, IndexError):
continue
new_relating_ports = tool.System.get_ports(new_relating)
new_related_ports = tool.System.get_ports(new_related)
expected_relating = snapshot.port_counts.get(relating_element)
if expected_relating is not None and len(new_relating_ports) != expected_relating:
cls._emit_warning(
f"port reconnect skipped — duplicate has {len(new_relating_ports)} ports, "
f"snapshot had {expected_relating}"
)
continue
expected_related = snapshot.port_counts.get(related_element)
if expected_related is not None and len(new_related_ports) != expected_related:
cls._emit_warning(
f"port reconnect skipped — duplicate has {len(new_related_ports)} ports, "
f"snapshot had {expected_related}"
)
continue
try:
new_port_a = new_relating_ports[record.relating_port_index]
new_port_b = new_related_ports[record.related_port_index]
except IndexError:
cls._emit_warning(
f"port reconnect skipped — record references port index past the duplicate's port list"
)
continue
try:
tool.Ifc.run(
"system.connect_port",
port1=new_port_a,
port2=new_port_b,
direction=record.direction or "NOTDEFINED",
)
except (RuntimeError, ifcopenshell.Error) as e:
cls._emit_warning(f"port reconnect failed between duplicates: {e}")
+116 -13
View File
@@ -73,7 +73,7 @@ import bonsai.core.style
import bonsai.core.system
import bonsai.core.tool
import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore
from bonsai.bim.ifc import IfcStore, get_cache_or_detect_lock
if TYPE_CHECKING:
from bonsai.bim.module.geometry.prop import (
@@ -115,10 +115,42 @@ class Geometry(bonsai.core.tool.Geometry):
@classmethod
def clear_cache(cls, element: ifcopenshell.entity_instance) -> None:
cache = IfcStore.get_cache()
# Cache acquisition can fail if the HDF5 file is locked by another
# process — degrade gracefully rather than aborting the caller's
# reimport flow. A stale cache entry is harmless; a raised exception
# prevents the actual mesh swap. The wrapper sets the project-panel
# warning flag on lock so the user sees one prominent notice instead
# of per-element log spam.
try:
cache = get_cache_or_detect_lock()
except Exception as exc:
print(f"clear_cache: skipping cache invalidation for {element} ({exc})")
return
if cache and hasattr(element, "GlobalId"):
cache.remove(element.GlobalId)
@classmethod
def has_axis_representation(cls, element: ifcopenshell.entity_instance) -> bool:
"""True if the element carries a shape representation whose
RepresentationIdentifier is 'Axis'. Elements without one cannot be
projected to an unambiguous 1D path; callers that draw schematic axis
overlays must skip them rather than fall back to mesh-derived geometry."""
product_rep = getattr(element, "Representation", None)
if product_rep is None:
return False
for rep in product_rep.Representations:
if getattr(rep, "RepresentationIdentifier", None) == "Axis":
return True
return False
@classmethod
def get_body_representation(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
"""The element's ``Model/Body/MODEL_VIEW`` representation, or ``None``.
Single source for the ``(context, identifier, target_view)`` triple used
by every body-geometry reader across walls, slabs, doors, openings, and
feature decorators."""
return ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
@classmethod
def clear_modifiers(cls, obj: bpy.types.Object) -> None:
for modifier in obj.modifiers:
@@ -788,6 +820,15 @@ class Geometry(bonsai.core.tool.Geometry):
return True
return False
@classmethod
def has_material_styles(cls, element: ifcopenshell.entity_instance) -> bool:
"""True when any of ``element``'s materials exposes an
``IfcSurfaceStyle``. Gate body-style assignment to avoid double-styling."""
return any(
tool.Material.get_style(material) is not None
for material in ifcopenshell.util.element.get_materials(element)
)
@classmethod
def reimport_element_representations(
cls, obj: bpy.types.Object, representation: ifcopenshell.entity_instance, apply_openings: bool = True
@@ -1154,6 +1195,53 @@ class Geometry(bonsai.core.tool.Geometry):
props.location_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.translation).tobytes())
props.rotation_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.to_3x3()).tobytes())
@classmethod
def commit_placement_if_moved(cls, obj: bpy.types.Object, *, apply_scale: bool = True) -> None:
"""Write ``obj.matrix_world`` back to its IFC ``ObjectPlacement`` when the
object has drifted since its last placement commit.
Scope: drop-in only when the gate is exactly ``is_moved(obj)``. Call sites
whose gate is wider (e.g. ``is_moved OR is_scaled``) or already enforced
upstream (inside an ``if is_moved:`` block) should call
``edit_object_placement`` directly to avoid the redundant inner check."""
if not tool.Ifc.is_moved(obj):
return
bonsai.core.geometry.edit_object_placement(
tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj, apply_scale=apply_scale
)
@classmethod
def restore_placement_from_ifc(cls, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None:
"""Snap ``obj.matrix_world`` back to ``element``'s committed IFC placement,
then re-baseline the drift checksum so ``tool.Ifc.is_moved(obj)`` returns
False afterwards.
Precondition: ``element.ObjectPlacement`` must not be None. Callers in a
cancel-style flow that want a "restore-or-clear-drift" semantic must gate
on ObjectPlacement themselves and call ``record_object_position`` directly
in the no-placement branch."""
assert element.ObjectPlacement is not None, (
"restore_placement_from_ifc requires ObjectPlacement — gate the caller "
"or use restore_or_rebaseline_placement for the restore-or-clear-drift semantic"
)
matrix_np = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement).copy()
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
matrix_np[:3, 3] *= unit_scale
obj.matrix_world = tool.Loader.apply_blender_offset_to_matrix_world(obj, matrix_np)
cls.record_object_position(obj)
@classmethod
def restore_or_rebaseline_placement(cls, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None:
"""Cancel-flow placement restore: revert ``obj.matrix_world`` to the committed
IFC placement; when the element has no ObjectPlacement, re-baseline the drift
checksum instead so a subsequent edit does not silently commit the discarded drag."""
if not tool.Ifc.is_moved(obj):
return
if element.ObjectPlacement is None:
cls.record_object_position(obj)
return
cls.restore_placement_from_ifc(obj, element)
@classmethod
def remove_connection(cls, connection: ifcopenshell.entity_instance) -> None:
tool.Ifc.get().remove(connection)
@@ -1205,6 +1293,20 @@ class Geometry(bonsai.core.tool.Geometry):
bpy.data.objects.remove(obj)
return new_obj
@classmethod
def detach_representation(cls, product: ifcopenshell.entity_instance) -> None:
"""Replace ``product.Representation`` with a deep copy so the product
no longer shares its representation tree (mapped or direct) with any
other entity. The ``IfcGeometricRepresentationContext`` is excluded
from the copy so contexts stay file-singletons. No-op when the
product has no ``Representation`` attribute or it is unset."""
rep = getattr(product, "Representation", None)
if rep is None:
return
product.Representation = ifcopenshell.util.element.copy_deep(
tool.Ifc.get(), rep, exclude=["IfcGeometricRepresentationContext"]
)
@classmethod
def resolve_mapped_representation(
cls, representation: ifcopenshell.entity_instance
@@ -2132,8 +2234,11 @@ class Geometry(bonsai.core.tool.Geometry):
new_active_obj = None
# Track decompositions so they can be recreated after the operation
decomposition_relationships = tool.Root.get_decomposition_relationships(objects_to_duplicate)
connection_relationships = tool.Root.get_connection_relationships(objects_to_duplicate)
decomposition_relationships = tool.Duplicate.get_decomposition_relationships(objects_to_duplicate)
connection_relationships = tool.Duplicate.get_connection_relationships(objects_to_duplicate)
# Snapshot port-to-port connections — copy_class disconnects new ports
# by default, leaving Shift+D duplicates unconnected.
port_connection_snapshot = tool.Duplicate.get_port_connection_relationships(objects_to_duplicate)
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]] = {}
old_obj_name_to_new_obj_name: dict[str, str] = {}
@@ -2155,10 +2260,7 @@ class Geometry(bonsai.core.tool.Geometry):
keep_data_linked = linked and not element and not is_tracked_opening
# Prior to duplicating, sync the object placement to make decomposition recreation more stable.
if tool.Ifc.is_moved(obj):
bonsai.core.geometry.edit_object_placement(
tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj, apply_scale=False
)
cls.commit_placement_if_moved(obj, apply_scale=False)
new_obj = obj.copy()
temp_data = None
@@ -2212,7 +2314,7 @@ class Geometry(bonsai.core.tool.Geometry):
array_data = arrays_to_duplicate.get(obj, None)
tool.Model.handle_array_on_copied_element(new, array_data)
if array_data:
for child in tool.Blender.Modifier.Array.get_all_children_objects(new):
for child in tool.Array.get_all_children_objects(new):
child.select_set(True)
# TODO: add new array children to recreate their decomposition too
@@ -2240,10 +2342,11 @@ class Geometry(bonsai.core.tool.Geometry):
# Remove connections with old objects and recreates paths
cls.remove_old_connections(old_to_new)
tool.Root.recreate_connections(connection_relationships, old_to_new)
tool.Duplicate.recreate_connections(connection_relationships, old_to_new)
tool.Duplicate.recreate_port_connections(port_connection_snapshot, old_to_new)
# Recreate decompositions
tool.Root.recreate_decompositions(decomposition_relationships, old_to_new)
tool.Duplicate.recreate_decompositions(decomposition_relationships, old_to_new)
cls.remove_linked_aggregate_data(old_to_new)
bonsai.bim.handler.refresh_ui_data()
tool.Root.reload_grid_decorator()
@@ -2308,8 +2411,8 @@ class Geometry(bonsai.core.tool.Geometry):
continue
array_data = []
for modifier_data in tool.Blender.Modifier.Array.get_modifiers_data(array_parent):
children = set(tool.Blender.Modifier.Array.get_children_objects(modifier_data))
for modifier_data in tool.Array.get_modifiers_data(array_parent):
children = set(tool.Array.get_children_objects(modifier_data))
if children.issubset(selected_objects):
modifier_data["children"] = []
array_data.append(modifier_data)
+209 -26
View File
@@ -15,12 +15,14 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
from __future__ import annotations
import collections.abc
import json
from collections.abc import Iterable, Sequence
from collections.abc import Callable, Iterable, Sequence
from copy import deepcopy
from math import atan, cos, degrees, pi, radians
from typing import (
@@ -37,9 +39,11 @@ from typing import (
import bmesh
import bpy
import ifcopenshell
import ifcopenshell.api.feature
import ifcopenshell.api.geometry
import ifcopenshell.api.grid
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.geom
import ifcopenshell.ifcopenshell_wrapper as W
import ifcopenshell.util.element
@@ -58,6 +62,7 @@ import bonsai.core.geometry
import bonsai.core.tool
import bonsai.tool as tool
from bonsai.bim import import_ifc
from bonsai.tool.cad import VTX_PRECISION, WELD_TOLERANCE
T = TypeVar("T")
V_ = tool.Blender.V_
@@ -77,6 +82,7 @@ if TYPE_CHECKING:
BIMRoofProperties,
BIMStairProperties,
BIMSverchokProperties,
BIMWallProperties,
BIMWindowProperties,
)
@@ -98,6 +104,10 @@ class Model(bonsai.core.tool.Model):
def get_stair_props(cls, obj: bpy.types.Object) -> BIMStairProperties:
return obj.BIMStairProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_wall_props(cls, obj: bpy.types.Object) -> BIMWallProperties:
return obj.BIMWallProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_roof_props(cls, obj: bpy.types.Object) -> BIMRoofProperties:
return obj.BIMRoofProperties # pyright: ignore[reportAttributeAccessIssue]
@@ -123,6 +133,35 @@ class Model(bonsai.core.tool.Model):
assert (scene := bpy.context.scene)
return scene.BIMPolylineProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def resolve_active_props_for_edit(
cls,
context: bpy.types.Context,
props_getter: Callable[[bpy.types.Object], Any],
*,
subtype: Optional[tuple[str, Any]] = None,
) -> Optional[tuple[bpy.types.Object, Any]]:
"""Resolve ``(obj, props)`` for an operator that acts on the active
object only while a parametric edit is active.
Returns ``None`` (the operator should ``return {"CANCELLED"}``) when
any of these fail:
- no active object,
- ``props.is_editing`` is False,
- ``subtype`` is given as ``(attr, value)`` and ``props.<attr> != value``.
"""
obj = context.active_object
if not obj:
return None
props = props_getter(obj)
if not getattr(props, "is_editing", False):
return None
if subtype is not None:
attr, value = subtype
if getattr(props, attr, None) != value:
return None
return obj, props
@classmethod
def convert_si_to_unit(cls, value: T) -> T:
if isinstance(value, (tuple, list)):
@@ -792,7 +831,7 @@ class Model(bonsai.core.tool.Model):
assert element or representation, "Either element or representation must be provided."
if representation is None:
assert element
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
representation = tool.Geometry.get_body_representation(element)
if not representation:
return []
booleans = []
@@ -813,7 +852,7 @@ class Model(bonsai.core.tool.Model):
return []
boolean_ids = json.loads(pset["Data"])
if representation is None:
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
representation = tool.Geometry.get_body_representation(element)
if not representation:
return []
booleans = [b for b in cls.get_booleans(element, representation) if b.id() in boolean_ids]
@@ -902,7 +941,7 @@ class Model(bonsai.core.tool.Model):
# Revolved area check should happen inside bim.enable_editing_extrusion_axis
# but keep it here to trigger import_representation_items,
# so users will be able to at least move IfcRevolvedAreaSolid, until there will be a full support.
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
body = tool.Geometry.get_body_representation(element)
if body and any(
i.is_a("IfcRevolvedAreaSolid") for i in ifcopenshell.util.representation.resolve_base_items(body)
):
@@ -1015,7 +1054,14 @@ class Model(bonsai.core.tool.Model):
def handle_array_on_copied_element(
cls, element: ifcopenshell.entity_instance, array_data: Optional[dict[str, Any]] = None
) -> None:
"""if no `array_data` is provided then an array will be removed from the element"""
"""Post-copy hook: decide what to do with the BBIM_Array pset a copy
inherits from its source.
- ``array_data=None`` detach the copy from any array. Removes the
inherited BBIM_Array pset and any CHILD_OF constraint.
- ``array_data`` provided promote the copy to a fresh array parent
with an empty children list, using the provided layer config.
"""
if array_data is None:
array_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
@@ -1059,8 +1105,8 @@ class Model(bonsai.core.tool.Model):
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=array_pset, properties={"Data": json_data})
for i in range(len(array_data)):
tool.Blender.Modifier.Array.set_children_lock_state(element, i, True)
tool.Blender.Modifier.Array.constrain_children_to_parent(element)
tool.Array.set_children_lock_state(element, i, True)
tool.Array.constrain_children_to_parent(element)
@classmethod
def regenerate_array(
@@ -1097,12 +1143,17 @@ class Model(bonsai.core.tool.Model):
offset = base_offset * i
for obj in obj_stack:
# IndexError when child_i is past the recorded children list
# (count grew); RuntimeError when by_guid finds no entity (the
# child was deleted outside the array op); AssertionError when
# the IFC entity exists but its Blender object was unlinked.
# All three fall through to duplication.
try:
global_id = array["children"][child_i]
child_element = tool.Ifc.get().by_guid(global_id)
child_obj = tool.Ifc.get_object(child_element)
assert child_obj
except:
except (IndexError, RuntimeError, AssertionError):
old_to_new, _ = tool.Geometry.duplicate_ifc_objects([parent_obj])
child_element = next(iter(old_to_new.values()))[0]
child_obj = tool.Ifc.get_object(child_element)
@@ -1139,14 +1190,24 @@ class Model(bonsai.core.tool.Model):
removed_children = set(existing_children) - set(array["children"])
for removed_child in removed_children:
element = tool.Ifc.get().by_guid(removed_child)
# Strip any wall/slab opening cut by this child before deletion,
# so the host's HasOpenings shrinks symmetrically with count.
if getattr(element, "FillsVoids", None):
ifcopenshell.api.feature.remove_feature(
tool.Ifc.get(), feature=element.FillsVoids[0].RelatingOpeningElement
)
obj = tool.Ifc.get_object(element)
if obj:
tool.Geometry.delete_ifc_object(obj)
if array.get("per_child_opening", array.get("mirror_to_host", True)) and children_elements:
cls.mirror_parent_void_fillings_to_children(parent_element, children_elements)
if array_i in array_layers_to_apply:
for child_element in children_elements:
pset = tool.Pset.get_element_pset(child_element, "BBIM_Array")
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=child_element, pset=pset)
cls.unshare_opening_representation(child_element)
array["children"] = []
array["count"] = 1
@@ -1159,6 +1220,112 @@ class Model(bonsai.core.tool.Model):
tool.Ifc.get(), pset=pset, properties={"Data": json_data, "Parent": parent_element.GlobalId}
)
# Post-condition: parent is selected on return. duplicate_ifc_objects
# deselects the source on every call inside the regen loop; without
# this restore, callers get a deselected parent for arrays with N >= 2.
# TODO: batch the per-child duplicate_ifc_objects([parent]) calls into
# a single N-way duplicate — N depsgraph churns + N select/deselect
# flips is wasteful, and a batched duplicate would also remove the
# need for this restore.
parent_obj.select_set(True)
@classmethod
def mirror_parent_void_fillings_to_children(
cls,
parent_element: ifcopenshell.entity_instance,
children_elements: Sequence[ifcopenshell.entity_instance],
) -> None:
"""Replicate the parent's FillsVoids → host chain onto each array child.
For each child, tears down any stale opening, creates a new
IfcOpeningElement at the child's current placement, reuses the parent's
opening representation as a MappedRepresentation, and adds the
void + filling pair so the host element is cut once per child.
No-op when the parent is not a filling, when the host element cannot
be resolved, or when the children list is empty. Opt out via the
per-layer ``per_child_opening`` flag on ``BBIM_Array.Data`` (legacy
key ``mirror_to_host`` still honoured for round-trip with older files).
"""
host = tool.Spatial.get_host_element(parent_element)
if host is None or not children_elements:
return
ifc_file = tool.Ifc.get()
parent_opening = parent_element.FillsVoids[0].RelatingOpeningElement
parent_opening_rep = ifcopenshell.util.representation.get_representation(
parent_opening, "Model", "Body", "MODEL_VIEW"
)
if parent_opening_rep is None:
return
parent_opening_rep = ifcopenshell.util.representation.resolve_representation(parent_opening_rep)
for child in children_elements:
if getattr(child, "FillsVoids", None):
ifcopenshell.api.feature.remove_feature(ifc_file, feature=child.FillsVoids[0].RelatingOpeningElement)
child_obj = tool.Ifc.get_object(child)
if child_obj is None:
continue
new_opening = ifcopenshell.api.root.create_entity(
ifc_file,
ifc_class="IfcOpeningElement",
predefined_type="OPENING",
name="Opening",
)
ifcopenshell.api.geometry.edit_object_placement(
ifc_file,
product=new_opening,
matrix=np.array(child_obj.matrix_world),
is_si=True,
)
mapped_representation = ifcopenshell.api.geometry.map_representation(
ifc_file, representation=parent_opening_rep
)
ifcopenshell.api.geometry.assign_representation(
ifc_file, product=new_opening, representation=mapped_representation
)
ifcopenshell.api.feature.add_feature(ifc_file, feature=new_opening, element=host)
ifcopenshell.api.feature.add_filling(ifc_file, opening=new_opening, element=child)
# Openings affect every sub-element of an aggregate, not just the named host.
voided_objs: list[bpy.types.Object] = []
host_obj = tool.Ifc.get_object(host)
if host_obj is not None:
voided_objs.append(host_obj)
for subelement in tool.Aggregate.get_parts_recursively(host):
subobj = tool.Ifc.get_object(subelement)
if subobj is not None:
voided_objs.append(subobj)
for voided_obj in voided_objs:
if not voided_obj.data:
continue
voided_element = tool.Ifc.get_entity(voided_obj)
if voided_element is None:
continue
context = tool.Geometry.get_active_representation_context(voided_obj)
representation = tool.Geometry.get_representation_by_context(voided_element, context)
if representation is None:
continue
bonsai.core.geometry.switch_representation(
tool.Ifc, tool.Geometry, obj=voided_obj, representation=representation
)
@classmethod
def unshare_opening_representation(cls, filling: ifcopenshell.entity_instance) -> None:
"""Detach a filling's opening representation from any shared mapped body.
Required when a Bonsai array child is promoted to an independent
object: the array's per-child opening mirror builds each child's
opening representation as an ``IfcMappedRepresentation`` over the
parent opening's body. Without this detach, a later edit replacing
the parent body rewrites the shared ``IfcRepresentationMap`` and
reshapes the former-child's opening too."""
if not getattr(filling, "FillsVoids", None):
return
tool.Geometry.detach_representation(filling.FillsVoids[0].RelatingOpeningElement)
@classmethod
def replace_object_ifc_representation(
cls,
@@ -1305,8 +1472,8 @@ class Model(bonsai.core.tool.Model):
return [obj for obj in tool.Blender.get_selected_objects() if tool.Ifc.get_entity(obj)]
@classmethod
def has_selected_ifc_objects(cls) -> bool:
return any(tool.Ifc.get_entity(obj) for obj in tool.Blender.get_selected_objects())
def has_selected_ifc_objects(cls, include_active: bool = True) -> bool:
return any(tool.Ifc.get_entity(obj) for obj in tool.Blender.get_selected_objects(include_active=include_active))
@classmethod
def get_selected_mesh_objects(cls) -> list[bpy.types.Object]:
@@ -1355,8 +1522,7 @@ class Model(bonsai.core.tool.Model):
@classmethod
def sync_object_ifc_position(cls, obj: bpy.types.Object) -> None:
"""make sure IFC position will be in sync with the Blender object position, if object was moved in Blender"""
if tool.Ifc.is_moved(obj):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
tool.Geometry.commit_placement_if_moved(obj)
@classmethod
def get_element_matrix(cls, element: ifcopenshell.entity_instance, keep_local: bool = False) -> Matrix:
@@ -1388,7 +1554,7 @@ class Model(bonsai.core.tool.Model):
if not obj.data:
continue
element = tool.Ifc.get_entity(obj)
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
body = tool.Geometry.get_body_representation(element)
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
@@ -1505,6 +1671,10 @@ class Model(bonsai.core.tool.Model):
"TRIPLE_PANEL_VERTICAL",
]
RoofGenerationMethod = Literal["HEIGHT", "ANGLE"]
RailingType = Literal["FRAMELESS_PANEL", "WALL_MOUNTED_HANDRAIL"]
@classmethod
def generate_stair_2d_profile(
cls,
@@ -1756,7 +1926,7 @@ class Model(bonsai.core.tool.Model):
from bonsai.bim.module.model.opening import FilledOpeningGenerator
ifc_file = tool.Ifc.get()
fillings = {e: tool.Ifc.get_object(e) for e in tool.Ifc.get_all_element_occurrences(element)}
fillings = {e: tool.Ifc.get_object(e) for e in tool.Array.get_parametric_propagation_targets(element)}
voided_objs = set()
has_replaced_opening_representation = False
@@ -1898,7 +2068,9 @@ class Model(bonsai.core.tool.Model):
bm = bmesh.new()
bm.from_mesh(mesh)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-4)
# Looser than auto_detect_curves' VTX_PRECISION: profiles must close into
# a single loop, so nearly-coincident endpoints should snap together.
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=WELD_TOLERANCE)
bmesh.ops.delete(bm, geom=bm.faces, context="FACES_ONLY")
# https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess
@@ -2126,7 +2298,7 @@ class Model(bonsai.core.tool.Model):
bm = bmesh.new()
bm.from_mesh(mesh)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=VTX_PRECISION)
bmesh.ops.delete(bm, geom=bm.faces, context="FACES_ONLY")
# https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess
@@ -2345,6 +2517,12 @@ class Model(bonsai.core.tool.Model):
@classmethod
def get_existing_x_angle(cls, extrusion: ifcopenshell.entity_instance) -> float:
"""Signed slope of the extrusion's direction in the y-z plane (radians).
Assumes extrusion directions lie in the y-z plane (LAYER2 wall and
LAYER3 slab convention). For inverted extrusions (z 0), adds π to
preserve angular continuity for callers consuming the angle via
cos/sin."""
x, y, z = extrusion.ExtrudedDirection.DirectionRatios
vector = Vector((0, 1))
x_angle = vector.angle_signed(Vector((y, z)))
@@ -2693,6 +2871,10 @@ class Model(bonsai.core.tool.Model):
@classmethod
def recreate_wall(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None:
# FIXME(PR4): the fillet-corner branch lands with PR4's
# `regenerate_fillet_corner_wall` (bim/module/model/wall.py). On v0.8.0
# the function doesn't exist; falling through to the straight-extrusion
# path preserves v0.8.0 behaviour for fillet walls until PR4 ships.
rep = ifcopenshell.api.geometry.regenerate_wall_representation(tool.Ifc.get(), element)
bonsai.core.geometry.switch_representation(
tool.Ifc,
@@ -2713,28 +2895,29 @@ class Model(bonsai.core.tool.Model):
queue: set[tuple[ifcopenshell.entity_instance, bpy.types.Object]] = set()
for wall in walls:
element = tool.Ifc.get_entity(wall)
if tool.Ifc.is_moved(wall):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall)
tool.Geometry.commit_placement_if_moved(wall)
queue.add((element, wall))
for rel in getattr(element, "ConnectedTo", []):
obj = tool.Ifc.get_object(rel.RelatedElement)
if tool.Ifc.is_moved(obj):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
tool.Geometry.commit_placement_if_moved(obj)
queue.add((rel.RelatedElement, obj))
for rel in getattr(element, "ConnectedFrom", []):
obj = tool.Ifc.get_object(rel.RelatingElement)
if tool.Ifc.is_moved(obj):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
tool.Geometry.commit_placement_if_moved(obj)
queue.add((rel.RelatingElement, obj))
for element, wall in queue:
if tool.Model.get_usage_type(element) == "LAYER2" and wall:
# Use layer custom offset
if not wall:
continue
is_layer2_usage = tool.Model.get_usage_type(element) == "LAYER2"
is_fillet_corner = bool(ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner"))
if not (is_layer2_usage or is_fillet_corner):
continue
if is_layer2_usage:
custom_offset = tool.Model.get_material_layer_custom_offset(element, wall)
material = ifcopenshell.util.element.get_material(element)
if material.is_a("IfcMaterialLayerSetUsage") and custom_offset is not None:
material.OffsetFromReferenceLine = custom_offset
cls.recreate_wall(element, wall)
cls.recreate_wall(element, wall)
@classmethod
def regenerate_slab(cls, obj: bpy.types.Object) -> None:
+616
View File
@@ -0,0 +1,616 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Registry and save-time auto-commit for parametric draft edits.
The registry is consumed along two orthogonal axes:
- **Predicate axis**: every entry carries an ``is_<name>`` total predicate. Used
by ``find_for_element``, save-flow auto-commit, and per-feature gizmo polls.
- **Lifecycle axis**: a subset of entries flagged ``supports_build_edit_lifecycle=True``
share the ``Enable/Finish/CancelEditing<Type>`` operator shape and are wired
through ``build_edit_lifecycle``. The remainder declare their edit operators
directly because their lifecycle (per-attribute diff dispatch, layer-stack
editing, mid-spline gizmo drag, ) does not fit the shared mixin contract.
Adding a new parametric element type is a single entry in ``EDIT_TYPES``;
flag ``supports_build_edit_lifecycle`` only if the type's edit lifecycle matches
one of the shared mixins in ``bim/parametric_lifecycle.py``."""
from __future__ import annotations
import logging
import re
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, ClassVar, Optional
import bpy
import bonsai.core.tool
import bonsai.tool as tool
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from ifcopenshell import entity_instance
# Lowercase ASCII snake_case token; each segment a non-empty letter/digit
# sequence starting with a letter. ``"pipe_segment"`` → ``"BIMPipeSegmentProperties"``.
_VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$")
def _camel_case(name: str) -> str:
return "".join(part.capitalize() for part in name.split("_"))
@dataclass(frozen=True)
class ParametricObject:
"""One parametric element type's draft + enable + finish + cancel edit lifecycle.
The ``name`` token drives every derived identifier: the
``BIM<Name>Properties`` attribute on ``bpy.types.Object``, the
``bim.enable_editing_<name>`` / ``bim.finish_editing_<name>`` /
``bim.cancel_editing_<name>`` operator ``bl_idname``s, and the
``tool.Parametric.is_<name>`` runtime predicate.
The predicate is part of the contract and MUST be total accept any IFC
entity, return a bool, never raise. A raising predicate breaks the save
path for every parametric type, not just its own.
``supports_build_edit_lifecycle`` marks entries whose edit lifecycle fits the
shared mixin contract (``_enable_targets`` / ``_finish_targets`` /
``_cancel_targets``) and that therefore wire their operators through
``build_edit_lifecycle``. Entries with bespoke edit lifecycles (per-attribute
diff dispatch, layer-stack editing, mid-spline gizmo drag) leave this
False and declare their operator classes directly."""
name: str
has_non_editable_path: bool = False
supports_build_edit_lifecycle: bool = False
def __post_init__(self) -> None:
if not _VALID_NAME_RE.match(self.name):
raise ValueError(
f"ParametricObject name {self.name!r} must match "
f"{_VALID_NAME_RE.pattern!r} — lowercase letters / digits, "
f"optionally split by single underscores (e.g. ``door`` or "
f"``pipe_segment``). Leading / trailing underscores and "
f"consecutive underscores are rejected because they produce "
f"empty CamelCase segments in derived class names."
)
@property
def props_attr(self) -> str:
return f"BIM{_camel_case(self.name)}Properties"
@property
def enable_op(self) -> str:
return f"bim.enable_editing_{self.name}"
@property
def finish_op(self) -> str:
return f"bim.finish_editing_{self.name}"
@property
def cancel_op(self) -> str:
return f"bim.cancel_editing_{self.name}"
def is_editing(self, obj: bpy.types.Object) -> bool:
props = getattr(obj, self.props_attr, None)
return bool(props and getattr(props, "is_editing", False))
class Parametric(bonsai.core.tool.Parametric):
class GenerationKeyedCache:
"""A dict-keyed cache stamped with the parametric generation counter
at fill time. Reads at a later generation drop the whole dict and
re-run the loader. Any IFC commit bumps the generation, invalidating
all entries en bloc.
``None`` values are stored verbatim; only "key not in dict" counts as
a miss."""
def __init__(self) -> None:
self._gen: int | None = None
self._data: dict = {}
def get_or_compute(self, key, loader):
current = Parametric.get_geom_generation()
if self._gen != current:
self._data.clear()
self._gen = current
if key not in self._data:
self._data[key] = loader()
return self._data[key]
def clear(self) -> None:
"""Explicit drop. Use from ``load_post`` so a fresh file starts clean."""
self._data.clear()
self._gen = None
# FIXME(PR4): array / pipe_segment / duct_segment land with their
# finish/cancel operators in PR4. Adding them to EDIT_TYPES without those
# operators makes auto-commit-on-save dispatch bim.finish_editing_<name>
# for objects flagged as in-edit, which then raises because the operator
# doesn't exist. PR4 re-adds the three entries together with the operators.
EDIT_TYPES: list[ParametricObject] = [
ParametricObject("door", has_non_editable_path=True, supports_build_edit_lifecycle=True),
ParametricObject("window", has_non_editable_path=True, supports_build_edit_lifecycle=True),
ParametricObject("stair", has_non_editable_path=True, supports_build_edit_lifecycle=True),
ParametricObject("railing", supports_build_edit_lifecycle=True),
ParametricObject("roof", supports_build_edit_lifecycle=True),
ParametricObject("wall"),
]
# Annotations for the uppercase constants populated from ``EDIT_TYPES`` by
# the binding loop at module bottom. Declared here so IDEs and type
# checkers see the attributes without running the loop.
DOOR: ClassVar[ParametricObject]
WINDOW: ClassVar[ParametricObject]
STAIR: ClassVar[ParametricObject]
RAILING: ClassVar[ParametricObject]
ROOF: ClassVar[ParametricObject]
WALL: ClassVar[ParametricObject]
_geom_generation: int = 0
@classmethod
def get_geom_generation(cls) -> int:
return cls._geom_generation
@classmethod
def refresh_post_commit(cls) -> None:
"""Post-commit hook for ``tool.Ifc.Operator``: re-syncs scene-level
workspace-tool header fields from current IFC state and bumps the
geometry generation counter so caches keyed off it drop stale
entries on the next draw."""
import bonsai.bim.handler # late import: bim.handler imports tool.*
cls._geom_generation += 1
bonsai.bim.handler.update_bim_tool_props()
tool.Blender.update_all_viewports()
@classmethod
def find_by_name(cls, name: str) -> Optional[ParametricObject]:
return next((f for f in cls.EDIT_TYPES if f.name == name), None)
@classmethod
def _safe_predicate(cls, feature: ParametricObject, element: entity_instance) -> bool:
"""Resolve and invoke ``is_<feature.name>`` defensively. The contract is
that predicates are total (see ``ParametricObject`` docstring); a
regression that turns one predicate raising would otherwise break the
save path for every parametric type, not just its own."""
predicate = getattr(cls, f"is_{feature.name}", None)
if predicate is None:
return False
try:
return bool(predicate(element))
except Exception:
logger.warning(
"parametric predicate is_%s raised on %r",
feature.name,
element,
exc_info=True,
)
return False
@classmethod
def find_for_element(cls, element: entity_instance) -> Optional[ParametricObject]:
"""Return the registry entry whose IFC type predicate matches ``element``."""
for feature in cls.EDIT_TYPES:
if cls._safe_predicate(feature, element):
return feature
return None
@classmethod
def is_object_editing(cls, obj: bpy.types.Object, skip_name: Optional[str] = None) -> Optional[ParametricObject]:
"""Return the registry entry whose edit lifecycle is active on ``obj``, or None.
``skip_name`` excludes one entry from the scan, for callers that want
to know if a *different* type is editing."""
for feature in cls.EDIT_TYPES:
if feature.name == skip_name:
continue
if feature.is_editing(obj):
return feature
return None
@classmethod
def _validated_editing_feature(cls, obj: bpy.types.Object) -> Optional[ParametricObject]:
"""Return the active registry entry on ``obj``, validated against the
per-type predicate. Returns None when no ``is_editing`` flag is set
or when the flag is stale.
Self-heals: a predicate mismatch clears the flag in place so the
finish dispatch never re-picks up a phantom edit."""
feature = cls.is_object_editing(obj)
if feature is None:
return None
element = tool.Ifc.get_entity(obj)
if element is None or not cls._safe_predicate(feature, element):
getattr(obj, feature.props_attr).is_editing = False
return None
return feature
@classmethod
def heal_stale_edit_flags(cls) -> None:
"""Validate every scene object's ``is_editing`` flag against the
per-type predicate, clearing stale flags in place.
Run from ``load_post`` so a ``.blend`` saved with phantom flags
(e.g. a save that bypassed the auto-commit flush) is consistent the
moment it opens."""
for obj in bpy.data.objects:
cls._validated_editing_feature(obj)
@classmethod
def get_pending_edits(cls) -> list[tuple[bpy.types.Object, str]]:
"""``(object, finish_operator_bl_idname)`` pairs for every object
with an in-progress parametric draft. Stale flags are cleared in
place and excluded."""
pending: list[tuple[bpy.types.Object, str]] = []
for obj in bpy.data.objects:
feature = cls._validated_editing_feature(obj)
if feature is not None:
pending.append((obj, feature.finish_op))
return pending
@classmethod
def run_bim_op(cls, bl_idname: str) -> None:
"""Invoke a ``bim.*`` operator by ``bl_idname``.
Asserts the operator is a ``tool.Ifc.Operator`` subclass bypassing
that wrap would mutate IFC outside Bonsai's transaction system."""
verb = bl_idname.removeprefix("bim.")
op_cls = getattr(bpy.types, f"BIM_OT_{verb}", None)
if op_cls is None or not issubclass(op_cls, tool.Ifc.Operator):
raise RuntimeError(
f"{bl_idname!r} must be a registered tool.Ifc.Operator subclass for undo-safe IFC mutation"
)
getattr(bpy.ops.bim, verb)()
@classmethod
def commit_object_draft(cls, obj: bpy.types.Object, finish_op: str) -> bool:
"""Run ``finish_op`` scoped to ``obj`` alone. Returns False (with
traceback printed) if the operator raised.
Both ``temp_override`` and ``view_layer.objects.active`` are set:
``temp_override`` does not rebind ``objects.active``, and some finish
operators read it directly."""
view_layer = bpy.context.view_layer
original_active = view_layer.objects.active
try:
with bpy.context.temp_override(active_object=obj, selected_objects=[obj]):
view_layer.objects.active = obj
try:
cls.run_bim_op(finish_op)
return True
except Exception:
logger.warning(
"commit of %r via %s failed",
obj.name,
finish_op,
exc_info=True,
)
return False
finally:
view_layer.objects.active = original_active
@classmethod
def commit_pending_edits(cls) -> tuple[int, list[bpy.types.Object]]:
"""Run each pending draft's finish operator scoped to its object.
A per-object failure does not abort the loop remaining drafts
still flush, otherwise the auto-commit would ship the exact silent
desync it exists to prevent."""
committed = 0
failed: list[bpy.types.Object] = []
for obj, finish_op in cls.get_pending_edits():
if cls.commit_object_draft(obj, finish_op):
committed += 1
else:
failed.append(obj)
return committed, failed
@classmethod
def commit_pending_edits_for_selection(
cls, names: Optional[tuple[str, ...]] = None
) -> tuple[int, list[bpy.types.Object]]:
"""Selection-scoped variant. ``names`` filters which registry entries
to consider; ``None`` considers every type."""
committed = 0
failed: list[bpy.types.Object] = []
for obj in tool.Blender.get_selected_objects():
feature = cls._validated_editing_feature(obj)
if feature is None:
continue
if names is not None and feature.name not in names:
continue
if cls.commit_object_draft(obj, feature.finish_op):
committed += 1
else:
failed.append(obj)
return committed, failed
@classmethod
def _assert_predicates_registered(cls) -> None:
"""Loud at addon-enable if any ``EDIT_TYPES`` entry has no matching
``is_<name>`` classmethod. Without this, a typo in the registry entry
produces a silent-False predicate that never matches every
parametric draft of that type bypasses save-flow auto-commit."""
missing = [feature.name for feature in cls.EDIT_TYPES if not callable(getattr(cls, f"is_{feature.name}", None))]
if missing:
raise RuntimeError(
f"tool.Parametric.EDIT_TYPES has entries with no is_<name> predicate: {missing}. "
f"Add `is_<name>(cls, element) -> bool` classmethods on tool.Parametric, "
f"or remove the entries from EDIT_TYPES."
)
@classmethod
def register_object_properties(cls, prop_module) -> None:
"""Attach ``bpy.types.Object.BIM<Name>Properties`` for every registered
parametric type. Skips entries whose ``PropertyGroup`` is absent."""
cls._assert_predicates_registered()
for feature in cls.EDIT_TYPES:
prop_cls = getattr(prop_module, feature.props_attr, None)
if prop_cls is None:
continue
setattr(bpy.types.Object, feature.props_attr, bpy.props.PointerProperty(type=prop_cls))
@classmethod
def unregister_object_properties(cls) -> None:
for feature in cls.EDIT_TYPES:
if hasattr(bpy.types.Object, feature.props_attr):
delattr(bpy.types.Object, feature.props_attr)
@classmethod
def iter_gizmo_preference_classes(cls, ui_module) -> list[type]:
"""``GizmoPreferences<Name>`` classes that exist on ``ui_module`` for
every registry entry, plus the shared ``GizmoPreferencesFeature`` if
present. Order matches ``EDIT_TYPES``. Used by ``bim/__init__.py`` to
inject the per-type ``GizmoPreferences<X>`` classes at the correct
point before ``ui.GizmoPreferences``, which references them via
``PointerProperty``."""
# FIXME(PR5): drop the per-feature loop once PR4 consolidates
# bim/ui.py to use a single shared GizmoPreferencesFeature class
# and rewrites GizmoPreferences accordingly. The shared-class
# branch is the forward-compat path; the per-feature loop keeps
# v0.8.0's bim/ui.py working until then.
out: list[type] = []
for feature in cls.EDIT_TYPES:
gpref = getattr(ui_module, f"GizmoPreferences{feature.name.capitalize()}", None)
if gpref is not None:
out.append(gpref)
shared = getattr(ui_module, "GizmoPreferencesFeature", None)
if shared is not None:
out.append(shared)
return out
# --- Feature-kind predicates ------------------------------------------------
# One predicate per registered parametric type. Each is total: accepts any
# IFC entity (or None), returns a bool, never raises. Predicates live with
# the registry rather than ``tool.Blender.Modifier`` because they ARE the
# registry contract — ``find_for_element`` and ``_validated_editing_feature``
# resolve them by name. Coupling them on the same class makes a typo at
# registration time an immediate AttributeError instead of a silent None
# predicate that never matches.
@classmethod
def is_array(cls, element: entity_instance) -> bool:
"""True if element is the PARENT of a Bonsai parametric array.
Array children also carry a ``BBIM_Array`` pset (their ``Parent``
field points back to the original), so checking pset presence alone
would falsely match them. The parent is distinguished by
``pset.Parent == element.GlobalId``."""
import ifcopenshell.util.element
if element is None:
return False
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not pset:
return False
return pset.get("Parent") == element.GlobalId
@classmethod
def is_railing(cls, element: entity_instance) -> bool:
if element is None:
return False
return tool.Pset.get_element_pset(element, "BBIM_Railing") is not None
@classmethod
def is_roof(cls, element: entity_instance) -> bool:
if element is None:
return False
return tool.Pset.get_element_pset(element, "BBIM_Roof") is not None
@classmethod
def is_window(cls, element: entity_instance) -> bool:
if element is None:
return False
return tool.Pset.get_element_pset(element, "BBIM_Window") is not None
@classmethod
def is_door(cls, element: entity_instance) -> bool:
if element is None:
return False
return tool.Pset.get_element_pset(element, "BBIM_Door") is not None
@classmethod
def is_stair(cls, element: entity_instance) -> bool:
if element is None:
return False
return tool.Pset.get_element_pset(element, "BBIM_Stair") is not None
@classmethod
def is_wall(cls, element: entity_instance) -> bool:
"""A wall is editable by the parametric gizmo if it is an IfcWall with LAYER2 usage.
Unlike doors/windows/stairs, walls do not carry a proprietary BBIM_Wall pset
their parametric state lives in standard IFC (axis polyline, IfcMaterialLayerSetUsage,
IfcExtrudedAreaSolid). Any LAYER2 wall qualifies."""
if element is None or not element.is_a("IfcWall"):
return False
return tool.Model.get_usage_type(element) == "LAYER2"
@classmethod
def is_path_connectable_wall(cls, element: entity_instance) -> bool:
"""An IfcWall that may participate in IfcRelConnectsPathElements joins —
either a LAYER2 parametric wall, or a fillet-corner wall whose body is
hand-built but whose axis still drives path connections.
Distinct from ``is_wall``: that predicate gates parametric edits that
would regenerate the body and flatten a curved fillet. Unjoin / join
gizmo polls and path-connection partner enumeration use this looser
predicate so fillet corners (which have no LAYER2 usage by spec) still
surface their join icons."""
if element is None or not element.is_a("IfcWall"):
return False
if tool.Model.get_usage_type(element) == "LAYER2":
return True
import ifcopenshell.util.element
return bool(ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner"))
@classmethod
def is_pipe_segment(cls, element: entity_instance) -> bool:
return element is not None and element.is_a("IfcPipeSegment")
@classmethod
def is_duct_segment(cls, element: entity_instance) -> bool:
return element is not None and element.is_a("IfcDuctSegment")
@classmethod
def build_edit_lifecycle(
cls,
feature_name: str,
mixin: type,
labels: tuple[tuple[str, str], tuple[str, str], tuple[str, str]],
bl_options: Optional[set[str]] = None,
enable_extra_props: Optional[dict[str, Any]] = None,
enable_extra_kwargs: Optional[Callable[[Any], dict[str, Any]]] = None,
module_name: Optional[str] = None,
) -> tuple[type, type, type]:
"""Generate (Enable, Finish, Cancel) operator classes for a parametric type.
``mixin`` provides ``_enable_targets`` / ``_finish_targets`` /
``_cancel_targets`` (i.e. inherits from ``ParametricEditMixinBase`` or
a sibling). ``labels`` is ``((enable_label, enable_desc), )`` in
Enable / Finish / Cancel order.
``bl_idname`` and the Python class name come from the registry entry
``feature_name`` MUST already be in ``EDIT_TYPES``, otherwise a typo
produces an unregistered operator. Anchoring bl_idnames to the registry
eliminates the silent-mismatch failure mode where a hand-typed
``bl_idname = "bim.enable_editing_dor"`` produces a class that
``find_for_element`` never resolves to.
``enable_extra_props`` declares extra ``bpy.props.*`` descriptors to
attach to the Enable class only (e.g. array's ``item: IntProperty``
carrying the target layer index across redo). When set,
``enable_extra_kwargs`` must also be supplied: it receives the Enable
operator instance and returns a kwargs dict forwarded to
``_enable_targets`` so the mixin's enable phase sees the extras.
``module_name`` sets ``__module__`` on the generated classes pass
``__name__`` from the calling feature module so Blender's right-click
Edit Source resolves to the feature module rather than the factory
site. Defaults to the factory's module, which is sub-optimal for
debugging but harmless."""
import bonsai.tool as _tool # late import: tool/__init__.py wires this module last
feature = cls.find_by_name(feature_name)
if feature is None:
raise RuntimeError(
f"build_edit_lifecycle: {feature_name!r} not in EDIT_TYPES — add a "
f"ParametricObject entry before declaring its operators"
)
if not feature.supports_build_edit_lifecycle:
raise RuntimeError(
f"build_edit_lifecycle: {feature_name!r} has supports_build_edit_lifecycle=False — "
f"its edit lifecycle is bespoke. Either declare "
f"Enable/Finish/CancelEditing{_camel_case(feature_name)} as direct Operator "
f"subclasses, or flip the flag on the EDIT_TYPES entry if the type does fit "
f"the shared mixin contract."
)
if (enable_extra_props is None) != (enable_extra_kwargs is None):
raise RuntimeError(
f"build_edit_lifecycle({feature_name!r}): enable_extra_props and "
f"enable_extra_kwargs must be supplied together — extras with no "
f"kwargs builder are unreachable, kwargs with no extras have nothing to forward"
)
options = bl_options if bl_options is not None else {"REGISTER", "UNDO"}
base_classes = (mixin, bpy.types.Operator, _tool.Ifc.Operator)
capitalised = _camel_case(feature_name)
def _build(
action: str, bl_idname: str, label: str, desc: str, target_method: str, extras: Optional[dict]
) -> type:
if extras and target_method == "_enable_targets":
assert enable_extra_kwargs is not None
kwargs_builder = enable_extra_kwargs
def _execute(self, context: bpy.types.Context) -> set[str]:
return getattr(self, target_method)(context, **kwargs_builder(self))
else:
def _execute(self, context: bpy.types.Context) -> set[str]:
return getattr(self, target_method)(context)
attrs: dict[str, Any] = {
"bl_idname": bl_idname,
"bl_label": label,
"bl_description": desc,
"bl_options": options,
"_execute": _execute,
}
if module_name is not None:
attrs["__module__"] = module_name
if extras:
# Blender's PropertyGroup machinery reads __annotations__ for bpy.props descriptors.
attrs["__annotations__"] = dict(extras)
return type(f"{action}Editing{capitalised}", base_classes, attrs)
return (
_build("Enable", feature.enable_op, labels[0][0], labels[0][1], "_enable_targets", enable_extra_props),
_build("Finish", feature.finish_op, labels[1][0], labels[1][1], "_finish_targets", None),
_build("Cancel", feature.cancel_op, labels[2][0], labels[2][1], "_cancel_targets", None),
)
_edit_type_names = [entry.name for entry in Parametric.EDIT_TYPES]
if len(set(_edit_type_names)) != len(_edit_type_names):
raise RuntimeError(
f"EDIT_TYPES name collision: {_edit_type_names}. Each name is the primary key "
f"for derived bl_idnames, BIM<Name>Properties attributes, is_<name> predicates, "
f"and the uppercase constant — a duplicate silently shadows the first entry."
)
del _edit_type_names
# Bind every registered ParametricObject as an uppercase class attribute so
# call sites can reference ``tool.Parametric.ROOF`` directly. Renaming a
# registry entry renames the constant; a typo at the call site surfaces as
# AttributeError at module load.
for _entry in Parametric.EDIT_TYPES:
setattr(Parametric, _entry.name.upper(), _entry)
del _entry
+30
View File
@@ -18,10 +18,12 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any, Literal, Union, assert_never
import bpy
import ifcopenshell
import ifcopenshell.api.pset
import ifcopenshell.util.attribute
import ifcopenshell.util.element
@@ -74,6 +76,34 @@ class Pset(bonsai.core.tool.Pset):
if pset:
return tool.Ifc.get().by_id(pset["id"])
@classmethod
def upsert_pset(
cls,
element: ifcopenshell.entity_instance,
pset_name: str,
properties: dict[str, Any],
) -> ifcopenshell.entity_instance:
"""Get or create ``pset_name`` on ``element``, write ``properties``, return the pset.
Centralises the get-element-pset add-pset-if-missing edit-pset idiom."""
ifc_file = tool.Ifc.get()
pset = cls.get_element_pset(element, pset_name)
if not pset:
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name=pset_name)
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties=properties)
return pset
@classmethod
def write_bbim_data(
cls,
element: ifcopenshell.entity_instance,
pset_name: str,
data: dict[str, Any],
) -> ifcopenshell.entity_instance:
"""Get or create the BBIM_<Type> pset and write ``data`` as the IfcText-serialised
JSON ``Data`` property. Canonical writer for parametric-modifier pset state."""
data_text = tool.Ifc.get().createIfcText(json.dumps(data, default=list))
return cls.upsert_pset(element, pset_name, {"Data": data_text})
@classmethod
def get_pset_props(cls, obj: str, obj_type: tool.Ifc.OBJECT_TYPE) -> PsetProperties:
if obj_type == "Object":
+9
View File
@@ -888,6 +888,15 @@ class Raycast(bonsai.core.tool.Raycast):
def create_snap_obj(cls, obj):
if obj.data is None or not isinstance(obj.data, bpy.types.Mesh):
return None
# Evict cached entries whose Blender object has since been freed.
valid = []
for s in cls.snap_objs:
try:
_ = s.obj.name
valid.append(s)
except ReferenceError:
pass
cls.snap_objs[:] = valid
for i, snap_obj in enumerate(cls.snap_objs):
if obj.name == snap_obj.obj.name:
# Handle objects modified while a modal operator is active.
+74
View File
@@ -0,0 +1,74 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Side-effect-free slab helpers — IFC reads for LAYER3 extrusions.
Exposes ``read_geometry``: a single live read of the parametric attributes
(extrusion depth and slope) that drive icon placement and dimension display
on a LAYER3 slab. Lives in ``tool/`` so bim-layer callers can stay
declarative they get a dict, not an IFC walk."""
from __future__ import annotations
from typing import TYPE_CHECKING, TypedDict
import ifcopenshell.util.unit
import bonsai.core.tool
import bonsai.tool as tool
if TYPE_CHECKING:
import bpy
class SlabGeometry(TypedDict):
depth: float
x_angle: float
class Slab(bonsai.core.tool.Slab):
@classmethod
def read_geometry(cls, obj: bpy.types.Object) -> SlabGeometry | None:
"""Live-read slab parametric geometry as a dict, or ``None`` if the
object is not a LAYER3 extruded slab.
Returned keys (all SI units): ``depth`` (extrusion thickness along the
slab's local Z), ``x_angle`` (slope in radians; zero for level slabs).
The slope is encoded in ``obj.matrix_world`` as a post-rotation, so
callers projecting world points into slab-local space via
``mw.inverted()`` will see a level frame whose Z runs along the slab
thickness ``x_angle`` is reported for callers that need the slope
as a scalar but is already applied by the placement."""
element = tool.Ifc.get_entity(obj)
if not element or not tool.Blender.Modifier.is_slab(element):
return None
representation = tool.Geometry.get_body_representation(element)
if not representation:
return None
extrusion = tool.Model.get_extrusion(representation)
if not extrusion:
return None
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
x_angle = tool.Model.get_existing_x_angle(extrusion)
return {
"depth": extrusion.Depth * unit_scale,
"x_angle": x_angle,
}
+26
View File
@@ -90,6 +90,32 @@ class Spatial(bonsai.core.tool.Spatial):
break
return element
@classmethod
def get_host_element(cls, filling: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
"""The building element that hosts a filling (door/window) via the
standard ``FillsVoids RelatingOpeningElement VoidsElements
RelatingBuildingElement`` chain, with safety guards at each hop.
Returns ``None`` if any link is missing, or if the given entity is
not a fillable type (no ``FillsVoids`` inverse).
For the wall-only case (gizmos that only make sense on walls), use
`get_host_wall` which adds an ``IfcWall`` type filter on top of this."""
if not getattr(filling, "FillsVoids", None):
return None
opening = filling.FillsVoids[0].RelatingOpeningElement
if not opening.VoidsElements:
return None
return opening.VoidsElements[0].RelatingBuildingElement
@classmethod
def get_host_wall(cls, filling: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
"""The ``IfcWall`` that hosts a filling (door/window), or ``None``.
Walls only fillings hosted in slabs / roofs / arbitrary elements
produce ``None`` so wall-offset callers stay opted out cleanly."""
host = cls.get_host_element(filling)
return host if host and host.is_a("IfcWall") else None
@classmethod
def can_contain(cls, container: ifcopenshell.entity_instance, element: ifcopenshell.entity_instance) -> bool:
if tool.Ifc.get_schema() == "IFC2X3":
+118 -22
View File
@@ -19,6 +19,7 @@
from __future__ import annotations
import re
from collections import deque
from enum import Enum
from typing import TYPE_CHECKING, Any, Optional, Union
@@ -26,6 +27,7 @@ import bpy
import ifcopenshell.api.geometry
import ifcopenshell.api.system
import ifcopenshell.util.element
import ifcopenshell.util.placement
import ifcopenshell.util.system
from mathutils import Matrix, Vector
@@ -35,12 +37,29 @@ import bonsai.core.root
import bonsai.core.tool
import bonsai.tool as tool
from bonsai.bim import import_ifc
from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData
# Data-class imports from ``bonsai.bim.module.system.data`` are function-local:
# a top-level import would trigger a partial-init cycle through tool.Ifc.Operator.
if TYPE_CHECKING:
from bonsai.bim.module.system.prop import BIMSystemProperties, BIMZoneProperties
_DIRECTION_FROM_FLOW_PAIR: dict[tuple[str, str], str] = {
("SOURCE", "SINK"): "SOURCE",
("SINK", "SOURCE"): "SINK",
("SOURCEANDSINK", "SOURCEANDSINK"): "SOURCEANDSINK",
}
def direction_from_port_pair(port_a: ifcopenshell.entity_instance, port_b: ifcopenshell.entity_instance) -> str:
"""Derive the ``direction`` arg for ``ifcopenshell.api.system.connect_port``
from each port's ``FlowDirection``. Returns ``NOTDEFINED`` for non-canonical pairs."""
a = getattr(port_a, "FlowDirection", None) or "NOTDEFINED"
b = getattr(port_b, "FlowDirection", None) or "NOTDEFINED"
return _DIRECTION_FROM_FLOW_PAIR.get((a, b), "NOTDEFINED")
class System(bonsai.core.tool.System):
@classmethod
def get_system_props(cls) -> BIMSystemProperties:
@@ -81,7 +100,7 @@ class System(bonsai.core.tool.System):
# make sure obj.dimensions and .matrix_world has valid data
bpy.context.view_layer.update()
# need to make sure .ObjectPlacement is also updated when we're going to add ports
tool.Model.sync_object_ifc_position(obj)
tool.Geometry.commit_placement_if_moved(obj)
mep_element = tool.Ifc.get_entity(obj)
bbox = tool.Blender.get_object_bounding_box(obj)
@@ -162,12 +181,12 @@ class System(bonsai.core.tool.System):
return ifcopenshell.util.system.get_ports(element)
@classmethod
def get_port_relating_element(cls, port: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
def get_port_relating_element(cls, port: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
if tool.Ifc.get_schema() == "IFC2X3":
element = port.ContainedIn[0].RelatedElement
else:
element = port.Nests[0].RelatingObject
return element
rel = port.ContainedIn[0] if port.ContainedIn else None
return rel.RelatedElement if rel else None
rel = port.Nests[0] if port.Nests else None
return rel.RelatingObject if rel else None
@classmethod
def get_port_predefined_type(cls, mep_element: ifcopenshell.entity_instance) -> str:
@@ -280,31 +299,42 @@ class System(bonsai.core.tool.System):
system_props = cls.get_system_props()
return tool.Ifc.get_entity_by_id(system_props.active_system_id)
# Decoration-data cache, keyed on (decorator_cache_token, id(decorated_elements_set)).
_decoration_data_cache_key: tuple | None = None
_decoration_data_cache: dict[str, Any] | None = None
@classmethod
def get_decoration_data(cls) -> dict[str, Any]:
from bonsai.bim.decorator_cache import get_decorator_cache_token
from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData
if not ObjectSystemData.is_loaded:
ObjectSystemData.load()
if not SystemDecorationData.is_loaded:
SystemDecorationData.load()
token = get_decorator_cache_token()
key = (token, id(SystemDecorationData.data["decorated_elements"]))
if key == cls._decoration_data_cache_key and cls._decoration_data_cache is not None:
return cls._decoration_data_cache
result = cls._build_decoration_data()
cls._decoration_data_cache_key = key
cls._decoration_data_cache = result
return result
@classmethod
def _build_decoration_data(cls) -> dict[str, Any]:
from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData
all_vertices = []
preview_edges = []
special_vertices = []
selected_edges = []
selected_vertices = []
view3d_space = tool.Blender.get_viewport_context()["space_data"].region_3d
viewport_matrix = view3d_space.view_matrix.inverted()
viewport_y_axis = viewport_matrix.col[1].to_3d().normalized()
camera_pos = viewport_matrix.translation
dir_to_camera = lambda x: (camera_pos - x).normalized()
def most_aligned_vector(a, vectors):
return max(vectors, key=lambda v: abs(a.dot(v)))
start_vert_i = 0
if not ObjectSystemData.is_loaded:
ObjectSystemData.load()
if not SystemDecorationData.is_loaded:
SystemDecorationData.load()
class FlowDirection(Enum):
BACKWARD = -1
FORWARD = 1
@@ -458,6 +488,72 @@ class System(bonsai.core.tool.System):
def is_mep_element(cls, element: ifcopenshell.entity_instance) -> bool:
return element.is_a("IfcFlowSegment") or element.is_a("IfcFlowFitting")
@classmethod
def walk_connected_mep_elements(
cls, start_element: ifcopenshell.entity_instance
) -> list[ifcopenshell.entity_instance]:
"""Return all MEP elements reachable from ``start_element`` via
``IfcRelConnectsPorts`` in either direction, in BFS order with
``start_element`` first.
Only ``IfcFlowSegment`` and ``IfcFlowFitting`` instances are
returned; non-MEP neighbours reached via a fitting's port are
traversed but not collected.
"""
if not cls.is_mep_element(start_element):
return []
result: list[ifcopenshell.entity_instance] = []
visited: set[int] = set()
queue: deque[ifcopenshell.entity_instance] = deque([start_element])
while queue:
element = queue.popleft()
if element.id() in visited:
continue
visited.add(element.id())
if not cls.is_mep_element(element):
continue
result.append(element)
for port in cls.get_ports(element):
connected_port = cls.get_connected_port(port)
if connected_port is None:
continue
neighbor = cls.get_port_relating_element(connected_port)
if neighbor is None or neighbor.id() in visited:
continue
queue.append(neighbor)
return result
@classmethod
def get_port_world_position(cls, port: ifcopenshell.entity_instance) -> Vector:
"""World-space position of an ``IfcDistributionPort``.
Follows the parent element's live ``matrix_world`` when available so
an uncommitted rotation doesn't drift from its ports; falls back to
the raw IFC placement otherwise."""
placement = getattr(port, "ObjectPlacement", None)
if placement is None:
return Vector((0.0, 0.0, 0.0))
port_ifc_matrix = Matrix(ifcopenshell.util.placement.get_local_placement(placement).tolist())
parent_element = cls.get_port_relating_element(port)
if parent_element is None:
return Vector(port_ifc_matrix.translation)
parent_obj = tool.Ifc.get_object(parent_element)
if parent_obj is None:
return Vector(port_ifc_matrix.translation)
parent_placement = getattr(parent_element, "ObjectPlacement", None)
if parent_placement is None:
return Vector(port_ifc_matrix.translation)
parent_ifc_matrix = Matrix(ifcopenshell.util.placement.get_local_placement(parent_placement).tolist())
try:
port_local_to_parent = parent_ifc_matrix.inverted() @ port_ifc_matrix
except ValueError:
return Vector(port_ifc_matrix.translation)
return (parent_obj.matrix_world @ port_local_to_parent).translation
@classmethod
def get_flow_element_controls(cls, element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
if not element.HasControlElements:
+2 -2
View File
@@ -199,8 +199,8 @@ class Unit(bonsai.core.tool.Unit):
if inches is None:
inches = 0
# If feet is negative, inches should also be negative (subtractive)
if feet < 0:
# If feet is negative (including -0), inches should also be negative (subtractive)
if math.copysign(1, feet) < 0:
inches = -inches
# Convert to meters
+327
View File
@@ -0,0 +1,327 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Side-effect-free wall helpers — IFC reads and wall-axis geometry, callable from
gizmo lambdas without loading the wall's draft props. The world-space geometry helpers
are pure-math wrappers over ``bonsai.core.model``."""
from __future__ import annotations
from collections import deque
from typing import TYPE_CHECKING, TypedDict
import ifcopenshell
import ifcopenshell.util.element
import ifcopenshell.util.representation
import ifcopenshell.util.unit
from mathutils import Vector
import bonsai.core.model
import bonsai.core.tool
import bonsai.tool as tool
if TYPE_CHECKING:
import bpy
class WallGeometry(TypedDict):
anchor_x: float
length: float
height: float
x_angle: float
thickness: float
offset: float
class Wall(bonsai.core.tool.Wall):
@classmethod
def get_length_and_height(cls, wall: ifcopenshell.entity_instance) -> tuple[float, float] | None:
"""SI length and vertical height of a LAYER2 extruded wall, or ``None`` for
non-parametric bodies (sweeps, brep, non-extrusion booleans)."""
representation = tool.Geometry.get_body_representation(wall)
if not representation:
return None
extrusion = tool.Model.get_extrusion(representation)
if not extrusion:
return None
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
p1, p2 = ifcopenshell.util.representation.get_reference_line(wall)
x_angle = tool.Model.get_existing_x_angle(extrusion)
return bonsai.core.model.length_and_height_from_extrusion(
extrusion_depth=extrusion.Depth,
x_angle=x_angle,
reference_line_x_extent=p2[0] - p1[0],
unit_scale=unit_scale,
)
@classmethod
def get_axis_local_extent(cls, wall: ifcopenshell.entity_instance) -> tuple[float, float] | None:
"""``(min_x, max_x)`` of the wall's IFC reference line in wall-local SI metres,
or ``None``. Anchors wall-edge gizmos at IFC-authoritative ends ``obj.bound_box``
would drift on trimmed walls or walls with end openings."""
representation = tool.Geometry.get_body_representation(wall)
if not representation:
return None
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
p1, p2 = ifcopenshell.util.representation.get_reference_line(wall)
x1, x2 = p1[0] * unit_scale, p2[0] * unit_scale
return (min(x1, x2), max(x1, x2))
@classmethod
def get_x_angle(cls, wall: ifcopenshell.entity_instance) -> float | None:
"""Slanted-extrusion angle (radians) of a LAYER2 wall, zero for vertical walls,
``None`` for non-parametric bodies. Callers that assume wall-local Z == world Z
must gate on this being zero."""
representation = tool.Geometry.get_body_representation(wall)
if not representation:
return None
extrusion = tool.Model.get_extrusion(representation)
if not extrusion:
return None
return tool.Model.get_existing_x_angle(extrusion)
@classmethod
def read_geometry(cls, obj: bpy.types.Object) -> WallGeometry | None:
"""Live wall geometry from IFC in SI metres/radians, or ``None`` for
non-path-connectable walls. Shared by gizmo positioning and draft
initialisation. Fillet-corner walls carry their chord axis as the
reference line and report zero thickness / offset (material was
unassigned at construction); callers that need a layer-driven thickness
must gate on ``tool.Parametric.is_wall`` upstream."""
element = tool.Ifc.get_entity(obj)
if not element or not tool.Parametric.is_path_connectable_wall(element):
return None
representation = tool.Geometry.get_body_representation(element)
if not representation:
return None
extrusion = tool.Model.get_extrusion(representation)
if not extrusion:
return None
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
p1, p2 = ifcopenshell.util.representation.get_reference_line(element)
layer_params = tool.Model.get_material_layer_parameters(element)
x_angle = tool.Model.get_existing_x_angle(extrusion)
return {
"anchor_x": p1[0] * unit_scale,
"length": (p2[0] - p1[0]) * unit_scale,
"height": bonsai.core.model.vertical_height_from_extrusion_depth(extrusion.Depth * unit_scale, x_angle),
"x_angle": x_angle,
"thickness": layer_params["thickness"],
"offset": layer_params["offset"],
}
@classmethod
def collinear_boundary_world(cls, seg_a: tuple[Vector, Vector], seg_b: tuple[Vector, Vector]) -> Vector:
"""World-space midpoint of the closest endpoint pair across two wall axis segments —
the anchor for Merge/Unjoin gizmos on collinear or already-joined walls."""
return Vector(
bonsai.core.model.closest_endpoint_midpoint(
(tuple(seg_a[0]), tuple(seg_a[1])),
(tuple(seg_b[0]), tuple(seg_b[1])),
)
)
@classmethod
def path_connection_location_world(
cls,
seg_self: tuple[Vector, Vector],
self_conn_type: str,
seg_other: tuple[Vector, Vector],
other_conn_type: str,
parallel_threshold: float = bonsai.core.model.PARALLEL_DOT_THRESHOLD,
) -> Vector:
"""World-space physical join point of an ``IfcRelConnectsPathElements`` — an
endpoint for end-connected walls, the axis intersection for ATPATH junctions."""
return Vector(
bonsai.core.model.compute_path_connection_location(
(tuple(seg_self[0]), tuple(seg_self[1])),
self_conn_type,
(tuple(seg_other[0]), tuple(seg_other[1])),
other_conn_type,
parallel_threshold,
)
)
@classmethod
def validate_for_parametric_edit(cls, obj: bpy.types.Object) -> str | None:
"""``None`` if the wall is parametrically editable, else a user-facing string naming
the specific gap so the user can fix the precise blocker."""
element = tool.Ifc.get_entity(obj)
if not element:
return "Object is not an IFC element."
if not element.is_a("IfcWall"):
return f"Object is an {element.is_a()}, not an IfcWall."
if tool.Model.get_usage_type(element) != "LAYER2":
return (
"Wall has no IfcMaterialLayerSetUsage with LayerSetDirection AXIS2 (required for parametric editing)."
)
representation = tool.Geometry.get_body_representation(element)
if not representation:
return "Wall has no Model/Body/MODEL_VIEW representation to drive parametric dimensions."
if not tool.Model.get_extrusion(representation):
return (
"Wall body is not an IfcExtrudedAreaSolid "
"(e.g. a brep mesh or boolean result without a base extrusion)."
)
return None
@classmethod
def has_layer2_usage(cls, wall: ifcopenshell.entity_instance) -> bool:
"""True iff ``wall`` is a LAYER2 parametric wall (has ``IfcMaterialLayerSetUsage``
with ``LayerSetDirection == AXIS2``). Required by every parametric wall edit
non-LAYER2 walls (brep / freeform bodies) cannot be driven by axis + thickness."""
return tool.Model.get_usage_type(wall) == "LAYER2"
@classmethod
def is_straight_axis(cls, wall: ifcopenshell.entity_instance) -> bool:
"""True iff the wall's Axis representation is a single straight line segment.
Curved-axis walls (e.g. a fillet corner inserted between two straight walls)
report ``False`` so callers gate them out of operations that assume a straight
reference line. The check inspects the ``Plan/Axis/GRAPH_VIEW`` representation
when present; falls back to True when no Axis representation exists (the
``Body`` extrusion alone is implicitly straight)."""
axis_rep = ifcopenshell.util.representation.get_representation(wall, "Plan", "Axis", "GRAPH_VIEW")
if axis_rep is None or not axis_rep.Items:
return True
for item in axis_rep.Items:
if item.is_a("IfcPolyline"):
if len(item.Points) != 2:
return False
elif item.is_a("IfcIndexedPolyCurve"):
# An ``IfcIndexedPolyCurve`` is straight only when (a) its
# ``Points`` list holds exactly two points and (b) it has no
# ``Segments`` or only ``IfcLineIndex`` segments. Any ``IfcArcIndex``
# makes it curved.
segments = getattr(item, "Segments", None)
if segments:
for seg in segments:
if seg.is_a("IfcArcIndex"):
return False
point_list = item.Points
point_coords = getattr(point_list, "CoordList", None) if point_list else None
if point_coords and len(point_coords) > 2:
return False
else:
# Trimmed curve, composite curve, B-spline — definitely curved.
return False
return True
@classmethod
def get_world_reference_line(cls, obj: bpy.types.Object) -> tuple[Vector, Vector] | None:
"""World-space endpoints of the wall's IFC reference line, in Blender units.
Returns ``(p1, p2)`` as 3D vectors with the wall's local Z preserved.
Returns ``None`` when the wall has no IFC element or no IFC Axis
representation. Anchors to the IFC reference line, not the mesh bound
box, so it stays correct when the mesh is stale or trimmed past the
IFC axis endpoints."""
element = tool.Ifc.get_entity(obj)
if element is None or not tool.Geometry.has_axis_representation(element):
return None
p1, p2 = ifcopenshell.util.representation.get_reference_line(element)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
local_p1 = Vector((p1[0] * unit_scale, p1[1] * unit_scale, 0.0))
local_p2 = Vector((p2[0] * unit_scale, p2[1] * unit_scale, 0.0))
return obj.matrix_world @ local_p1, obj.matrix_world @ local_p2
@classmethod
def walk_connected_walls(
cls,
start_element: ifcopenshell.entity_instance,
node_cap: int = 5000,
) -> list[ifcopenshell.entity_instance]:
"""BFS over ``IfcRelConnectsPathElements`` from ``start_element``.
Returns every ``IfcWall`` reachable in either direction (relating /
related side of the relation) in BFS order with ``start_element``
first. Stops when ``node_cap`` walls have been visited so a corrupt
or massive network can't lock up a draw callback. Non-wall path
elements (e.g. ``IfcRoof``, ``IfcSlab``) are traversed but not
collected they may bridge two disjoint wall runs.
Mirror of ``tool.System.walk_connected_mep_elements``."""
if not start_element.is_a("IfcWall"):
return []
result: list[ifcopenshell.entity_instance] = []
visited: set[int] = set()
queue: deque[ifcopenshell.entity_instance] = deque([start_element])
while queue and len(visited) < node_cap:
element = queue.popleft()
if element.id() in visited:
continue
visited.add(element.id())
if element.is_a("IfcWall"):
result.append(element)
# ``ConnectedTo`` / ``ConnectedFrom`` are the IFC inverse
# attributes that expose the relations where this element
# is the relating / related side respectively.
for rel in getattr(element, "ConnectedTo", []) or ():
if rel.is_a("IfcRelConnectsPathElements"):
neighbor = rel.RelatedElement
if neighbor is not None and neighbor.id() not in visited:
queue.append(neighbor)
for rel in getattr(element, "ConnectedFrom", []) or ():
if rel.is_a("IfcRelConnectsPathElements"):
neighbor = rel.RelatingElement
if neighbor is not None and neighbor.id() not in visited:
queue.append(neighbor)
return result
@classmethod
def compute_wall_fillet_geometry(
cls,
wall_a_obj: bpy.types.Object,
wall_b_obj: bpy.types.Object,
radius: float,
arc_resolution: int = bonsai.core.model.FILLET_DEFAULT_ARC_RESOLUTION,
) -> dict | None:
"""Compute fillet geometry between two walls in world space.
Returns a dict augmented with ``profile_thickness`` and ``height`` from
the active (A) wall's LAYER2 parameters, plus ``wall_type_id`` and
``x_angle``. Returns ``None`` when either wall lacks a reference line
or LAYER2 usage."""
axis_a = cls.get_world_reference_line(wall_a_obj)
axis_b = cls.get_world_reference_line(wall_b_obj)
if axis_a is None or axis_b is None:
return None
wall_a = tool.Ifc.get_entity(wall_a_obj)
if wall_a is None or not cls.has_layer2_usage(wall_a):
return None
seg_a = ((axis_a[0].x, axis_a[0].y, axis_a[0].z), (axis_a[1].x, axis_a[1].y, axis_a[1].z))
seg_b = ((axis_b[0].x, axis_b[0].y, axis_b[0].z), (axis_b[1].x, axis_b[1].y, axis_b[1].z))
result = bonsai.core.model.compute_fillet_polylines(seg_a, seg_b, radius, arc_resolution)
layers = tool.Model.get_material_layer_parameters(wall_a)
length_height = cls.get_length_and_height(wall_a)
wall_type = ifcopenshell.util.element.get_type(wall_a)
result.update(
{
"profile_thickness": layers["thickness"],
"profile_offset": layers["offset"],
"height": length_height[1] if length_height else None,
"x_angle": cls.get_x_angle(wall_a) or 0.0,
"wall_type_id": wall_type.id() if wall_type else None,
}
)
return result
@@ -51,6 +51,62 @@ To use these tools:
2. Use the appropriate shortcut or select the tool from the top bar.
3. Follow the on-screen prompts or adjust parameters as needed.
Interactive Parametric Editing
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Selected walls expose an in-viewport parametric edit mode that mirrors the door /
window / stair pen-icon UI:
1. Select a single wall. A pen (Edit Wall) icon appears next to the wall in the
3D viewport, and a matching ``Edit Wall`` button is available in the
``Parametric Geometry`` tab of the N panel.
2. Click the pen icon (or the panel button) to enter edit mode. Dimension
gizmos for length, height, slope (x-angle) and the layer offset baseline
appear around the wall.
3. Drag any handle to update the value. Dragging only modifies the in-progress
draft — the IFC file is not touched until you commit, so dragging a length
handle through many intermediate values produces zero extra IFC entities.
4. Click the green ✓ icon to commit; click the red ✗ to discard. Pressing the
✓ icon on a wall that hasn't been dragged is a true byte-identical no-op —
the IFC file is unchanged.
While editing, additional gizmos surface based on context:
- **Cycle Baseline**: cycles the layer offset baseline (Exterior → Centreline →
Interior). Shift+click cycles in reverse.
- **3D-cursor scissors**: appears when the 3D cursor sits on the wall axis;
clicking splits the wall at the cursor's projected X.
- **3D-cursor extend (horizontal)**: appears when the 3D cursor sits beyond the
wall axis; clicking extends the wall to the cursor's projected X.
- **3D-cursor extend (vertical)**: appears when the 3D cursor sits above /
below the wall; clicking extends the wall's height to the cursor's Z.
- **Rotate 90°**: rotates the wall around its Z axis.
- **Show / hide openings**: toggles opening fill visibility (doors and windows).
When two walls are selected, the gizmo switches to a state-aware icon at their
common point:
- Already joined → an Unjoin icon at the shared corner.
- Collinear (same axis line) → a Merge icon at the boundary midpoint.
- Joinable corner → a Join icon at the floor + an Extend-To-Wall icon at the
active wall's top.
When a wall and a slab (LAYER3 element) are selected, an Extend-Vertically icon
appears at the wall's origin / slab elevation; clicking dispatches
``bim.extend_walls_to_underside``.
When a wall and a non-wall, non-slab object are selected, an Add-Opening icon
appears above the wall at the other object's projected X.
Auto-commit on save
~~~~~~~~~~~~~~~~~~~
Pressing Ctrl+S (or running ``bim.save_project``) while any wall is mid-edit
flushes every pending parametric draft first — the same Apply-Wall-Edits the ✓
icon performs, scoped per wall. The IFC saved on disk reflects the values the
user dragged, not the snapshot taken when edit mode was entered. Each commit
produces its own undo entry, so Ctrl+Z walks back through commits individually.
Aligning Walls
^^^^^^^^^^^^^^
+31 -3
View File
@@ -17,18 +17,46 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
"""
Requires pytest installed under blender
Requires pytest installed under blender.
Usage: `blender -b -P runpytest.py -- ARGS`
Usage:
blender -b -P runpytest.py -- ARGS
Alternative (when the calling shell strips or reorders the ``--`` separator
before it reaches Blender observed with some PowerShell / wrapper-script
invocations on Windows): pass the same pytest args via the
``BONSAI_TEST_ARGS`` environment variable as a single shell-quoted string
and invoke without ``--``::
$env:BONSAI_TEST_ARGS = "test/bim/ -x -q"
blender -b -P runpytest.py
"""
import os
import shlex
import sys
import pytest
argv = [__file__]
if "--" in sys.argv:
env_args = os.environ.get("BONSAI_TEST_ARGS", "")
if env_args:
# POSIX-style quoting works on all three OSes — env var values are
# literal strings (no shell evaluation when Python reads them), and
# POSIX quoting (``'foo "bar baz" qux'`` → three tokens, quotes stripped)
# matches what most docs and examples use.
argv += shlex.split(env_args)
# On the env-var path the args never appear in Blender's argv at all,
# so any pytest plugin that reads ``sys.argv`` directly (instead of
# going through pytest's API) would otherwise see only Blender's own
# ``-b -P runpytest.py`` and miss the test args entirely. Shadow argv
# so those plugins see the pytest-shaped view they expect.
sys.argv = list(argv)
elif "--" in sys.argv:
# The traditional path: Blender forwards everything after ``--`` to the
# script via ``sys.argv``. ``sys.argv`` is deliberately left as Blender
# set it — pre-existing behavior, preserved.
i = sys.argv.index("--")
argv += sys.argv[i + 1 :]
@@ -285,6 +285,32 @@ Scenario: Override duplicate move - without active IFC data
Then the object "Cube" exists
And the object "Cube.001" exists
Scenario: Override duplicate move - non-IFC objects inside an IFC project
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
When I duplicate the selected objects
Then the object "Cube" exists
And the object "Cube.001" exists
And the object "Cube.001" is selected
Scenario: Override duplicate move - mixed IFC and non-IFC selection
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 add a cube
And the object "IfcWall/Cube" is selected
And additionally the object "Cube" is selected
When I duplicate the selected objects
Then the object "IfcWall/Cube.001" exists
And the object "IfcWall/Cube.001" is selected
And the object "Cube.001" exists
And the object "Cube.001" is selected
Scenario: Override duplicate move - with active IFC data
Given an empty IFC project
And I add a cube
+124
View File
@@ -285,6 +285,7 @@ Scenario: Split a wall which has a flipped door
And the object "IfcWall/Wall" is selected
And I press "bim.hotkey(hotkey='S_K')"
Then the object "IfcDoor/Door" is at "8.01,0.1,0"
And the object "IfcWall/Wall.001" is filled by "IfcDoor/Door"
Scenario: Offset walls
Given an empty IFC project
@@ -673,6 +674,129 @@ Scenario: Create door type based on door modifier, add an occurrence of it and e
And I press "bim.finish_editing_door()"
Then nothing happens
Scenario: Saving with a door mid-edit auto-commits the draft value to the IFC pset
Given an empty IFC project
And I trigger "Add Element"
And I set the "Class" property to "IfcDoorType"
And I set the "Predefined Type" property to "DOOR"
And I set the "Representation" property to "Door"
When I click "OK"
And I press "bim.add_occurrence"
And I press "bim.enable_editing_door()"
And I set "active_object.BIMDoorProperties.overall_height" to "2.5"
Then "active_object.BIMDoorProperties.is_editing" is "True"
When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)"
Then "active_object.BIMDoorProperties.is_editing" is "False"
And the variable "saved_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']"
And the variable "saved_height" equals "2.5"
Scenario: Saving with no parametric edits in progress leaves the door pset unchanged
Given an empty IFC project
And I trigger "Add Element"
And I set the "Class" property to "IfcDoorType"
And I set the "Predefined Type" property to "DOOR"
And I set the "Representation" property to "Door"
When I click "OK"
And I press "bim.add_occurrence"
And the variable "pre_save_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']"
When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)"
Then the variable "post_save_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']"
And the variable "post_save_height" equals "{pre_save_height}"
Scenario: Saving with a wall mid-edit auto-commits the draft to IFC
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
And I press "bim.enable_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "True"
When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)"
Then "active_object.BIMWallProperties.is_editing" is "False"
Scenario: Enabling and finishing a wall edit with no drag is a no-op
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
And the variable "entity_count_before" is "len(list({ifc}))"
When I press "bim.enable_editing_wall()"
And I press "bim.finish_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "False"
And "len(list({ifc}))" is "{entity_count_before}"
Scenario: Cancelling a wall edit clears is_editing
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
And I press "bim.enable_editing_wall()"
When I press "bim.cancel_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "False"
Scenario: Wall parametric edit works on IFC2X3 projects
Given an empty IFC2X3 project
And I add a cube
And the object "Cube" is selected
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
When I press "bim.enable_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "True"
When I press "bim.finish_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "False"
Scenario: Rotate a wall 90° via bim.rotate_wall_90
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
When I press "bim.rotate_wall_90()"
Then the object "IfcWall/Wall" dimensions are "1,0.1,3"
And the object "IfcWall/Wall" bottom left corner is at "0,0,0"
And the object "IfcWall/Wall" top right corner is at "-0.1,1,3"
Scenario: Splitting a wall with another wall mid-edit commits the pending edit first
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.enable_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "True"
When I press "bim.split_wall()"
Then "active_object.BIMWallProperties.is_editing" is "False"
Scenario: Create a door, undo and create a new door
Given an empty IFC project
And I prepare to undo
@@ -0,0 +1,100 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Regression guard: overlapping distance gizmos must let the smaller one win.
When two ``GizmoDimension`` instances overlap on screen (e.g. a short dimension
nested inside a longer one along the same axis), the longer one's hit box fully
contains the shorter one's. Without a depth bias the longer one wins the GPU
select tie-break and the shorter one becomes unreachable.
``GizmoDimension.set_dimension_length`` writes ``select_bias = -dimension_length``
so the smaller one writes a higher (less-negative) bias and wins. The longer one
stays clickable at its exposed ends regardless of bias.
We call ``set_dimension_length`` as an unbound method on a ``SimpleNamespace``
fake ``self``. Its body only *writes* attributes (``_display_value``,
``_dimension_length``, ``select_bias``), so it doesn't need a real
``bpy.types.Gizmo`` instance those only exist inside a registered
``GizmoGroup`` and aren't constructible in a headless test."""
import types
from types import SimpleNamespace
import bpy
import pytest
from bonsai.bim.module.drawing.gizmos import GizmoDimension
pytestmark = pytest.mark.drawing
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def test_smaller_dimension_wins_select_bias():
small = SimpleNamespace()
large = SimpleNamespace()
GizmoDimension.set_dimension_length(small, 0.077)
GizmoDimension.set_dimension_length(large, 0.109)
assert small.select_bias > large.select_bias
@pytest.mark.parametrize(
"lengths",
[
[0.0, 0.05, 0.077, 0.109, 1.0, 5.0, 10.0],
[0.001, 0.5, 2.5, 100.0, 9999.0],
],
)
def test_select_bias_is_non_increasing_in_length(lengths):
"""A monotonic mapping is all Blender's GPU select needs to break the tie."""
biases = []
for length in lengths:
gizmo = SimpleNamespace()
GizmoDimension.set_dimension_length(gizmo, length)
biases.append(gizmo.select_bias)
for prev, curr in zip(biases, biases[1:]):
assert prev >= curr, f"select_bias must be non-increasing in length, got {biases}"
def test_negative_length_uses_absolute_value_for_bias():
"""Negative dimension values (e.g. inverted angles) clamp to abs() for hit-box scaling;
select_bias follows the same clamped magnitude so signed-direction gizmos still
obey the smaller-wins rule against their positive-sided peers."""
positive = SimpleNamespace()
negative = SimpleNamespace()
GizmoDimension.set_dimension_length(positive, 0.5)
GizmoDimension.set_dimension_length(negative, -0.5)
assert positive.select_bias == negative.select_bias
def test_nan_and_inf_length_falls_back_to_zero_bias():
"""Invalid inputs are coerced to 0.0 before the bias is written, so a malformed
update can't push a gizmo arbitrarily far forward or backward in the select buffer."""
import math
for bad in (math.nan, math.inf, -math.inf, "not a number"):
gizmo = SimpleNamespace()
GizmoDimension.set_dimension_length(gizmo, bad)
assert gizmo.select_bias == 0.0
@@ -0,0 +1,54 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
import types
from types import SimpleNamespace
import bpy
import pytest
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
pytestmark = pytest.mark.drawing
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def test_text_formatter_defaults_to_none():
config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0))
assert config.text_formatter is None
def test_text_formatter_field_stores_callable():
formatter = lambda props, value: f"{value:.2f}m" # noqa: E731
config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0), text_formatter=formatter)
assert config.text_formatter is not None
assert callable(config.text_formatter)
def test_text_formatter_receives_props_and_value():
formatter = lambda props, value: f"{props.label}={value}" # noqa: E731
config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0), text_formatter=formatter)
props = SimpleNamespace(label="L")
assert config.text_formatter(props, 3.14) == "L=3.14"
@@ -0,0 +1,19 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
@@ -0,0 +1,176 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Contract tests for the shared decorator cache module.
The cache token + persistent handler are the only thing protecting cached
``bpy.types.Object`` refs in dependent decorators from being dereferenced
after the underlying object is freed. These tests pin that contract:
- The 4-hook invalidation list (depsgraph/undo/redo/load) is symmetrically
managed by install/uninstall. A future edit that drops a hook from one
side without the other lands as a Blender segfault the regression must
surface as a test failure first.
- The handler increments the token and accepts Blender's variadic args."""
import bpy
import pytest
from bonsai.bim import decorator_cache
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _reset_cache_token():
"""Fresh token between tests so the bump-count assertions are stable."""
decorator_cache.reset_for_test()
yield
def test_install_and_uninstall_manage_all_invalidation_hooks():
"""install_decorator_cache_handlers() must register the bump handler in
every hook the dependent decorators rely on; uninstall must remove it
from every hook install touched. Catches the regression class where
a hook is dropped from one side and not the other."""
expected_hooks = (
bpy.app.handlers.depsgraph_update_post,
bpy.app.handlers.undo_post,
bpy.app.handlers.redo_post,
bpy.app.handlers.load_post,
)
# Defensive cleanup in case a previous addon-init run left the handler
# registered — the test must observe a clean slate before install().
for hook in expected_hooks:
while decorator_cache._bump_decorator_cache_token in hook:
hook.remove(decorator_cache._bump_decorator_cache_token)
try:
decorator_cache.install_decorator_cache_handlers()
for hook in expected_hooks:
assert decorator_cache._bump_decorator_cache_token in hook, (
"install_decorator_cache_handlers() must register the bump "
"handler in every hook a dependent cache relies on"
)
decorator_cache.uninstall_decorator_cache_handlers()
for hook in expected_hooks:
assert decorator_cache._bump_decorator_cache_token not in hook, (
"uninstall_decorator_cache_handlers() must remove the bump " "handler from every hook install touched"
)
finally:
# Make sure the test never leaves the handler dangling.
for hook in expected_hooks:
while decorator_cache._bump_decorator_cache_token in hook:
hook.remove(decorator_cache._bump_decorator_cache_token)
def test_install_is_idempotent():
"""Calling install twice must not double-register the bump handler —
the addon-init path may run on script reload and we don't want to
invalidate the cache twice per event."""
hook = bpy.app.handlers.depsgraph_update_post
while decorator_cache._bump_decorator_cache_token in hook:
hook.remove(decorator_cache._bump_decorator_cache_token)
try:
decorator_cache.install_decorator_cache_handlers()
decorator_cache.install_decorator_cache_handlers()
appearances = sum(1 for h in hook if h is decorator_cache._bump_decorator_cache_token)
assert appearances == 1, "install must not double-register"
finally:
decorator_cache.uninstall_decorator_cache_handlers()
def test_bump_handler_increments_token():
"""undo / redo / load_post invoke the handler with at most one positional
argument (the scene or filepath). Every such call must bump the token
those events legitimately invalidate every cached Object reference."""
decorator_cache._bump_decorator_cache_token()
assert decorator_cache.get_decorator_cache_token() == 1
decorator_cache._bump_decorator_cache_token("scene")
assert decorator_cache.get_decorator_cache_token() == 2
def test_get_decorator_cache_token_reads_current_value():
"""``get_decorator_cache_token()`` is the public read interface — it must
reflect the current token, not a captured-at-import-time value."""
initial = decorator_cache.get_decorator_cache_token()
decorator_cache._bump_decorator_cache_token()
assert decorator_cache.get_decorator_cache_token() == initial + 1
def test_depsgraph_update_with_no_object_changes_does_not_bump():
"""depsgraph_update_post fires every animation frame, every driver
evaluation, and every UI-only state shift. None of those invalidate a
decorator's cached IFC-derived geometry — gating the bump is what makes
the ``TokenCache`` worth more than a per-frame recompute."""
from unittest.mock import MagicMock
initial = decorator_cache.get_decorator_cache_token()
depsgraph = MagicMock(spec=bpy.types.Depsgraph, name="depsgraph")
depsgraph.updates = [] # empty updates list — animation tick with no real changes
decorator_cache._bump_decorator_cache_token("scene", depsgraph)
assert (
decorator_cache.get_decorator_cache_token() == initial
), "depsgraph_update_post with no Object changes must not bump the token"
def test_depsgraph_update_with_object_geometry_change_bumps():
"""When the depsgraph reports an Object geometry or transform change,
cached references may now point at a renamed / freed ID block. The token
must advance so dependent caches re-fetch on the next read."""
from unittest.mock import MagicMock
initial = decorator_cache.get_decorator_cache_token()
update = MagicMock(spec=bpy.types.DepsgraphUpdate, name="update")
update.is_updated_geometry = True
update.is_updated_transform = False
update.id = bpy.data.objects.new("dep_cache_probe", None)
try:
depsgraph = MagicMock(spec=bpy.types.Depsgraph, name="depsgraph")
depsgraph.updates = [update]
decorator_cache._bump_decorator_cache_token("scene", depsgraph)
assert decorator_cache.get_decorator_cache_token() == initial + 1
finally:
bpy.data.objects.remove(update.id, do_unlink=True)
def test_depsgraph_update_with_non_object_change_does_not_bump():
"""Material / NodeTree / Image updates fire depsgraph_update_post too
but never invalidate the decorator's Object-keyed caches. Filter them
out so a node-graph edit doesn't trigger a global cache rebuild."""
from unittest.mock import MagicMock
initial = decorator_cache.get_decorator_cache_token()
update = MagicMock(spec=bpy.types.DepsgraphUpdate, name="update")
update.is_updated_geometry = True
update.is_updated_transform = True
update.id = bpy.data.materials.new("dep_cache_probe_mat")
try:
depsgraph = MagicMock(spec=bpy.types.Depsgraph, name="depsgraph")
depsgraph.updates = [update]
decorator_cache._bump_decorator_cache_token("scene", depsgraph)
assert (
decorator_cache.get_decorator_cache_token() == initial
), "Non-Object ID updates must not bump the decorator cache token"
finally:
bpy.data.materials.remove(update.id, do_unlink=True)
@@ -0,0 +1,135 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Regression guard for the stair icon billboard fix.
Before the fix, ``set_icon_gizmo_position`` in ``bim.module.drawing.gizmos``
composed ``mw @ (Translation @ billboard_rot @ Scale)``, which applied the
stair's world rotation on top of the billboard rotation. The result was
icons (validate / cancel / lock / +/- / cycle / tread_lock) drawn edge-on
to the camera for any stair rotated in plan effectively unclickable.
The fix routes through ``billboarded_at(world_pos, billboard_rot, scale)``,
which computes ``Translation(world_pos) @ billboard_rot @ Scale`` the
object's rotation is folded into the translation only, never the rotation."""
import math
import types
import bpy
import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def _rotation_close(a, b, tol: float = 1e-6) -> bool:
for row_a, row_b in zip(a, b):
for va, vb in zip(row_a, row_b):
if abs(va - vb) > tol:
return False
return True
@pytest.mark.parametrize("angle_deg", [0, 30, 45, 90, 135, 217])
def test_billboarded_at_rotation_is_pure_billboard(angle_deg):
"""Object rotation must not leak into the gizmo's rotation part."""
from mathutils import Matrix, Vector
from bonsai.bim.module.drawing.gizmos import billboarded_at
mw = Matrix.Rotation(math.radians(angle_deg), 4, "Z") @ Matrix.Translation((3, 4, 5))
billboard_rot = Matrix.Rotation(math.radians(30), 4, "X")
world_pos = mw @ Vector((1, 0, 2))
result = billboarded_at(world_pos, billboard_rot, scale=0.5)
# The rotation part of result, after stripping the 0.5 uniform scale,
# must equal billboard_rot — no contribution from mw's rotation.
rotation_part = result.to_3x3() * 2.0
assert _rotation_close(rotation_part.to_4x4(), billboard_rot)
def test_billboarded_at_translation_is_world_pos():
"""Translation lands exactly at the world-space target."""
from mathutils import Matrix, Vector
from bonsai.bim.module.drawing.gizmos import billboarded_at
world_pos = Vector((1.23, 4.56, 7.89))
result = billboarded_at(world_pos, Matrix.Identity(4), scale=0.5)
assert (result.translation - world_pos).length < 1e-6
def test_set_icon_gizmo_position_does_not_apply_object_rotation():
"""End-to-end: the helper used by every stair icon (and shared with all
parametric gizmo groups) must produce a matrix whose rotation part is
billboard_rot, not mw_rotation @ billboard_rot. This is the exact bug
that left stair icons edge-on to the camera."""
from mathutils import Matrix, Vector
from bonsai.bim.module.drawing.gizmos import (
BaseParametricGizmoGroup,
billboarded_at,
)
# Same inputs as the real call site (stair.py:747-765), but we drive the
# helper directly so we don't need a registered GizmoGroup. We bind a
# stand-in `get_gizmo_if_visible` that returns a tiny mock; the helper's
# observable output is the matrix_basis it assigns.
captured = {}
class _GizmoStub:
matrix_basis = Matrix.Identity(4)
stub = _GizmoStub()
def _fake_get(name):
captured["name"] = name
return stub
# Bind the helper to a throwaway instance so `self.get_gizmo_if_visible`
# resolves to our stub without registering a real GizmoGroup with Blender.
fake_self = types.SimpleNamespace(get_gizmo_if_visible=_fake_get)
mw = Matrix.Rotation(math.radians(45), 4, "Z") @ Matrix.Translation((3, 4, 5))
billboard_rot = Matrix.Rotation(math.radians(30), 4, "X")
local_pos = Vector((1, 0, 2))
BaseParametricGizmoGroup.set_icon_gizmo_position(
fake_self,
"validate_gizmo",
mw=mw,
x=local_pos.x,
y=local_pos.y,
z=local_pos.z,
billboard_rot=billboard_rot,
scale=0.5,
)
expected = billboarded_at(mw @ local_pos, billboard_rot, 0.5)
assert captured["name"] == "validate_gizmo"
for row_a, row_b in zip(stub.matrix_basis, expected):
for va, vb in zip(row_a, row_b):
assert abs(va - vb) < 1e-6
@@ -0,0 +1,106 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Tests for ``parametric_lifecycle.resync_parametric_drafts_after_undo``.
Blender's undo restores PropertyGroup field values but does not refire
their ``update`` callbacks, so the preview mesh of an in-progress
parametric draft desyncs from the gizmo dimension widget after Ctrl+Z.
The resync helper walks active drafts and re-runs the per-type
regenerator to bring preview back in line with the (restored) draft
state. This file pins the dispatch contract."""
from unittest.mock import MagicMock, patch
import bpy
import pytest
import bonsai.tool as tool
from bonsai.bim import parametric_lifecycle
pytestmark = pytest.mark.model
def test_undo_regenerators_target_registered_parametric_types():
"""Every entry in ``UNDO_REGENERATORS`` must name a real parametric
type. A typo would silently no-op on Ctrl+Z, restoring the desync
this helper is meant to prevent."""
registered_names = {f.name for f in tool.Parametric.EDIT_TYPES}
unknown = set(parametric_lifecycle.UNDO_REGENERATORS) - registered_names
assert not unknown, f"UNDO_REGENERATORS keys {unknown} are not in tool.Parametric.EDIT_TYPES"
def test_resync_skips_objects_not_in_parametric_edit():
"""Objects with no active parametric edit must not trigger any
regenerator the helper is called from undo_post which fires on
every undo, including undos that touch zero parametric drafts."""
captured = []
def fake_dispatch(obj):
captured.append(obj)
with patch.dict(parametric_lifecycle.UNDO_REGENERATORS, {"wall": fake_dispatch}, clear=False), patch.object(
tool.Parametric, "is_object_editing", return_value=None
):
parametric_lifecycle.resync_parametric_drafts_after_undo()
assert captured == []
def test_resync_dispatches_to_registered_regenerator_for_editing_object():
"""When an object is in parametric edit and its type has a registered
regenerator, the regenerator must run with that object as the sole
arg. This is the load-bearing branch: preview mesh re-renders from
current props, so the gizmo and preview re-sync."""
captured = []
def fake_wall_regenerator(obj):
captured.append(obj)
fake_feature = MagicMock(spec=tool.parametric.ParametricObject)
fake_feature.name = "wall"
obj = bpy.data.objects.new("test_wall_obj", bpy.data.meshes.new("test_wall_mesh"))
try:
with patch.dict(
parametric_lifecycle.UNDO_REGENERATORS, {"wall": fake_wall_regenerator}, clear=False
), patch.object(tool.Parametric, "is_object_editing", side_effect=lambda o: fake_feature if o is obj else None):
parametric_lifecycle.resync_parametric_drafts_after_undo()
finally:
bpy.data.objects.remove(obj, do_unlink=True)
assert captured == [obj]
def test_resync_skips_editing_object_whose_type_has_no_regenerator():
"""A parametric type without an ``UNDO_REGENERATORS`` entry (door /
window / array IFC-derived preview, no desync) must not raise; the
helper silently skips it."""
fake_feature = MagicMock(spec=tool.parametric.ParametricObject)
fake_feature.name = "door" # door has no entry in UNDO_REGENERATORS
obj = bpy.data.objects.new("test_door_obj", bpy.data.meshes.new("test_door_mesh"))
try:
with patch.object(
tool.Parametric, "is_object_editing", side_effect=lambda o: fake_feature if o is obj else None
):
parametric_lifecycle.resync_parametric_drafts_after_undo()
finally:
bpy.data.objects.remove(obj, do_unlink=True)
@@ -0,0 +1,181 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Unit tests for the poll() preconditions of wall billboarding gizmo groups.
These tests patch ``tool.Blender`` / ``tool.Ifc`` / ``tool.Model`` so the poll
logic can be exercised without a real IFC fixture. Each test pins one of the
gates ``poll()`` walks, so any silent regression in the gate order or in the
LAYER3-active / LAYER2-other contract is caught by a dedicated assertion."""
import types
from types import SimpleNamespace
from unittest.mock import patch
import bpy
import pytest
pytestmark = pytest.mark.wall
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def _make_context(active, selected):
"""Build a minimal ``context`` stub with the two attributes ``poll()`` reads."""
return SimpleNamespace(active_object=active, selected_objects=list(selected))
def _patch_tools(prefs_on, selected, active_element, other_element, active_usage, other_usage):
"""Return a stack of patches that simulate one selection / IFC state for poll().
``prefs.gizmos.draw_gizmos_in_3d_viewport`` is the top-level toggle. The
selection set, the IFC entity lookup, and the usage-type lookup are stubbed
so the test only depends on the predicate ordering in poll()."""
prefs = SimpleNamespace(gizmos=SimpleNamespace(draw_gizmos_in_3d_viewport=prefs_on))
entity_map = {}
usage_map = {}
# active_element/other_element are matched by object identity from the selected set
if len(selected) == 2:
entity_map[id(selected[0])] = active_element
entity_map[id(selected[1])] = other_element
usage_map[id(active_element)] = active_usage
usage_map[id(other_element)] = other_usage
def get_entity(obj):
return entity_map.get(id(obj))
def get_usage_type(element):
return usage_map.get(id(element))
from bonsai import tool
return [
patch.object(tool.Blender, "get_addon_preferences", return_value=prefs),
patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)),
patch.object(tool.Ifc, "get_entity", side_effect=get_entity),
patch.object(tool.Model, "get_usage_type", side_effect=get_usage_type),
]
def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other_usage, active_has_entity=True):
from bonsai.bim.module.model.wall import GizmoWallExtendVertically
slab_obj = object()
wall_obj = object()
active = slab_obj if active_is_in_selected else object()
if len_override is None:
selected = [slab_obj, wall_obj]
else:
selected = [object() for _ in range(len_override)]
if active_is_in_selected and selected:
active = selected[0]
slab_element = object() if active_has_entity else None
wall_element = object()
patches = _patch_tools(prefs_on, selected, slab_element, wall_element, active_usage, other_usage)
for p in patches:
p.start()
try:
return GizmoWallExtendVertically.poll(_make_context(active, selected))
finally:
for p in patches:
p.stop()
def test_poll_accepts_layer3_active_with_layer2_other():
assert (
_run_poll(
prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER2"
)
is True
)
def test_poll_rejects_when_gizmo_toggle_off():
assert (
_run_poll(
prefs_on=False, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER2"
)
is False
)
def test_poll_rejects_when_selection_count_is_not_two():
assert (
_run_poll(
prefs_on=True, active_is_in_selected=True, len_override=3, active_usage="LAYER3", other_usage="LAYER2"
)
is False
)
assert (
_run_poll(
prefs_on=True, active_is_in_selected=True, len_override=1, active_usage="LAYER3", other_usage="LAYER2"
)
is False
)
def test_poll_rejects_when_active_has_no_ifc_entity():
assert (
_run_poll(
prefs_on=True,
active_is_in_selected=True,
len_override=None,
active_usage="LAYER3",
other_usage="LAYER2",
active_has_entity=False,
)
is False
)
def test_poll_rejects_when_active_is_not_layer3():
# A LAYER2 active (wall) must NOT trigger this gizmo — the wall-join gizmo
# owns that case, and extend_walls_to_underside expects the slab to be active.
assert (
_run_poll(
prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER2", other_usage="LAYER2"
)
is False
)
# Active with no usage at all (generic mesh, e.g. an opening blocker) is also rejected.
assert (
_run_poll(prefs_on=True, active_is_in_selected=True, len_override=None, active_usage=None, other_usage="LAYER2")
is False
)
def test_poll_rejects_when_other_is_not_layer2_wall():
assert (
_run_poll(
prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER3"
)
is False
)
assert (
_run_poll(prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage=None)
is False
)
@@ -0,0 +1,87 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Regression tests for the post-IFC-commit refresh path that re-syncs the
workspace tool header (``BIMModelProperties``) and invalidates the per-wall
gizmo geometry cache.
Bug repro before the fix: hotkey operators that edited the active wall in
place (``bpy.ops.bim.hotkey(hotkey="S_E")`` / ``"C_E"``) mutated IFC but never
fired ``active_object_callback`` (no selection change), so the header H/L/A
fields and the gizmo cache both kept showing stale values. ``refresh_ui_data``
ran, but it never resynced ``BIMModelProperties`` and never invalidated the
per-gizmo-group geometry cache. The fix wires both refreshes through
``tool.Parametric.refresh_post_commit`` and calls it from every
``tool.Ifc.Operator`` epilogue."""
import types
from unittest.mock import patch
import bpy
import pytest
pytestmark = pytest.mark.wall
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def test_refresh_post_commit_bumps_generation_and_resyncs_header():
"""``refresh_post_commit`` must bump the generation counter and call
``update_bim_tool_props`` so the workspace tool header re-syncs from IFC."""
import bonsai.bim.handler as handler
from bonsai import tool
before = tool.Parametric.get_geom_generation()
with patch.object(handler, "update_bim_tool_props") as mock_resync:
tool.Parametric.refresh_post_commit()
assert tool.Parametric.get_geom_generation() == before + 1
mock_resync.assert_called_once()
def test_geom_generation_invalidates_wall_geom_cache():
"""Bumping the generation must cause ``_get_wall_geom_cached`` to drop its
stored entries on the next read, even when the same gizmo group instance
and the same wall object are reused (the case Blender's
``GizmoGroup.refresh()`` does not cover)."""
from bonsai import tool
from bonsai.bim.module.model import wall as wall_mod
class _FakeGroup:
pass
group = _FakeGroup()
fake_obj = types.SimpleNamespace(name="Wall/W001")
sentinel_a = {"length": 1.0, "height": 2.0, "x_angle": 0.0}
sentinel_b = {"length": 1.5, "height": 2.5, "x_angle": 0.0}
with patch.object(wall_mod, "_read_wall_geometry", side_effect=[sentinel_a, sentinel_b]):
first = wall_mod._get_wall_geom_cached(group, fake_obj)
assert first is sentinel_a
# Same call without a generation bump must hit the cache (no extra read).
assert wall_mod._get_wall_geom_cached(group, fake_obj) is sentinel_a
# Simulate an IFC commit: generation advances, cache must drop.
tool.Parametric._geom_generation += 1
second = wall_mod._get_wall_geom_cached(group, fake_obj)
assert second is sentinel_b
assert second is not first
@@ -0,0 +1,377 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Regression tests for wall-split opening assignment when the cut passes
through an opening.
Bug repro before the fix: when ``bpy.ops.bim.split_wall`` (Shift+K) cut a wall
through an opening, ``DumbWallJoiner.split`` decided opening assignment using
the opening's centre-point projected onto the wall axis. Any opening whose
extent straddled the cut was therefore assigned to whichever side its centre
sat on, leaving the neighbour wall with no void where the opening overlapped.
The fix replaces the single-point test with an axis-projected extent
(``_opening_axis_extent``): an opening is removed from a wall only when its
extent lies *entirely* outside that wall's portion of the axis. Straddling
openings stay on both walls."""
from unittest.mock import MagicMock, patch
import bpy
import pytest
pytestmark = pytest.mark.wall
def _fake_shape(verts_local, matrix_world_4x4):
"""Build a stand-in for the ``shape`` object returned by
``ifcopenshell.geom.create_shape``. ``get_vertices`` and
``get_shape_matrix`` are mocked separately to read off this stand-in."""
import numpy as np
shape = MagicMock(name="shape")
shape.geometry = MagicMock(name="geometry")
shape._verts = np.asarray(verts_local, dtype=np.float64)
shape._matrix = np.asarray(matrix_world_4x4, dtype=np.float64)
return shape
def test_opening_axis_extent_uses_geometry_kernel_vertices():
"""``_opening_axis_extent`` drives ``ifcopenshell.geom.create_shape`` to
get the opening's real geometry vertices and ``get_shape_matrix`` to get
its world placement, then projects the world-space corners onto the wall
axis. This is the production path works for every representation type
Bonsai may produce (mapped representation, swept area, brep, boolean).
A unit cube centred at world X=5 on a 10m wall axis projects to
t [0.45, 0.55] (the cube spans 0.5m on each axis around the centre)."""
from bonsai.bim.module.model.wall import _opening_axis_extent
# Unit cube in local coords, centred at (0,0,0), extent ±0.5.
verts_local = [
(-0.5, -0.5, -0.5),
(0.5, -0.5, -0.5),
(0.5, 0.5, -0.5),
(-0.5, 0.5, -0.5),
(-0.5, -0.5, 0.5),
(0.5, -0.5, 0.5),
(0.5, 0.5, 0.5),
(-0.5, 0.5, 0.5),
]
matrix_world = [
[1.0, 0.0, 0.0, 5.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
]
fake_shape = _fake_shape(verts_local, matrix_world)
opening = MagicMock(name="opening")
axis_reference = (
__import__("mathutils").Vector((0.0, 0.0)),
__import__("mathutils").Vector((10.0, 0.0)),
)
with (
patch("ifcopenshell.geom.create_shape", return_value=fake_shape),
patch("ifcopenshell.util.shape.get_vertices", return_value=fake_shape._verts),
patch("ifcopenshell.util.shape.get_shape_matrix", return_value=fake_shape._matrix),
):
min_t, max_t = _opening_axis_extent(opening, axis_reference, unit_scale=1.0)
# Cube spans world X ∈ [4.5, 5.5] → t ∈ [0.45, 0.55].
assert min_t == pytest.approx(0.45)
assert max_t == pytest.approx(0.55)
def test_opening_axis_extent_falls_back_to_placement_when_geometry_kernel_fails():
"""When ``ifcopenshell.geom.create_shape`` raises (representation it
can't process), the helper falls back to a degenerate single-point range
at the opening's composed placement origin. This is the safety net — it
matches the pre-fix center-only semantics rather than dropping the
opening entirely."""
from bonsai.bim.module.model.wall import _opening_axis_extent
opening = MagicMock(name="opening")
placement_matrix = [
[1.0, 0.0, 0.0, 5.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
]
axis_reference = (
__import__("mathutils").Vector((0.0, 0.0)),
__import__("mathutils").Vector((10.0, 0.0)),
)
with (
patch("ifcopenshell.geom.create_shape", side_effect=RuntimeError("kernel failure")),
patch("ifcopenshell.util.placement.get_local_placement") as mock_get_placement,
):
mock_get_placement.return_value = type("FakeArr", (), {"tolist": lambda self: placement_matrix})()
min_t, max_t = _opening_axis_extent(opening, axis_reference, unit_scale=1.0)
assert min_t == max_t == pytest.approx(0.5)
def test_opening_axis_extent_offset_cursor_inside_extent_returns_straddling_range():
"""The regression guard for the user-reported bug across two fix attempts:
when the cursor is placed *inside* the opening but not at its exact
centre, the helper must still return a range that straddles the cursor
position so the side test keeps the opening on both walls.
Pre-fix v2/v3 collapsed to a degenerate range whenever the production
representation type wasn't recognised (Blender bound_box absent in v2;
mapped representation not walked in v3). The current implementation uses
``ifcopenshell.geom.create_shape``, which handles every representation
Bonsai may produce."""
from bonsai.bim.module.model.wall import _opening_axis_extent
# 2m-wide opening centred at world X=5 → world X ∈ [4.0, 6.0] → t ∈ [0.4, 0.6].
verts_local = [(-1.0, -0.5, -0.5), (1.0, 0.5, 0.5)]
matrix_world = [
[1.0, 0.0, 0.0, 5.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
]
fake_shape = _fake_shape(verts_local, matrix_world)
opening = MagicMock(name="opening")
axis_reference = (
__import__("mathutils").Vector((0.0, 0.0)),
__import__("mathutils").Vector((10.0, 0.0)),
)
with (
patch("ifcopenshell.geom.create_shape", return_value=fake_shape),
patch("ifcopenshell.util.shape.get_vertices", return_value=fake_shape._verts),
patch("ifcopenshell.util.shape.get_shape_matrix", return_value=fake_shape._matrix),
):
min_t, max_t = _opening_axis_extent(opening, axis_reference, unit_scale=1.0)
# Cursor at world X=4.7 → t=0.47 (inside the opening, not centred on it).
cut_percentage = 0.47
assert (
min_t < cut_percentage < max_t
), f"opening [t={min_t}, t={max_t}] must straddle off-centre cursor at t={cut_percentage}"
def test_straddling_opening_is_kept_on_both_sides():
"""The pruning logic must keep an opening whose extent straddles the cut
on *both* element1 and element2.
Before the fix, an opening with centre at t=0.5 and cut_percentage=0.6
would be removed from element2 (centre < cut) but kept on element1; the
opening's right half — which physically overlaps element2 — would be
silently dropped. After the fix, the opening overlaps both portions of
the axis (min_t=0.3 < 0.6 < max_t=0.7) so both walls keep it.
Replays the boolean comparisons that ``DumbWallJoiner.split`` performs on
the helper's return value; does not call the helper itself."""
min_t, max_t = 0.3, 0.7 # straddles any cut_percentage in (0.3, 0.7)
cut_percentage = 0.6
removed_from_element1 = min_t > cut_percentage
removed_from_element2 = max_t < cut_percentage
assert removed_from_element1 is False, "straddling opening must remain on element1"
assert removed_from_element2 is False, "straddling opening must remain on element2"
def test_opening_entirely_past_cut_is_removed_from_element1_only():
"""Opening lies wholly on element2's side (min_t > cut_percentage).
Pre-fix and post-fix both remove it from element1; post-fix additionally
guarantees it stays on element2 because max_t > cut_percentage."""
min_t, max_t = 0.7, 0.9
cut_percentage = 0.5
assert (min_t > cut_percentage) is True # removed from element1
assert (max_t < cut_percentage) is False # kept on element2
def test_opening_entirely_before_cut_is_removed_from_element2_only():
"""Mirror of the above: opening wholly on element1's side."""
min_t, max_t = 0.1, 0.3
cut_percentage = 0.5
assert (min_t > cut_percentage) is False # kept on element1
assert (max_t < cut_percentage) is True # removed from element2
def test_opening_touching_cut_at_boundary_stays_on_both_walls():
"""Boundary touch: an opening's ``max_t`` lands exactly on the cut. Strict
inequalities keep the opening on both walls the safer default. (Non-
strict ``<=`` would have removed from element2 instead.)"""
min_t, max_t = 0.2, 0.5
cut_percentage = 0.5
assert (min_t > cut_percentage) is False # kept on element1
assert (max_t < cut_percentage) is False # kept on element2 (boundary == cut)
def test_degenerate_range_at_cut_keeps_opening_on_both_walls():
"""Regression guard for the **post-fix-v1 regression**: when the helper
falls back to a degenerate range ``(t, t)`` (geometry kernel failed, or
the pre-create_shape fix attempts that produced only the placement
centre), placing the 3D cursor *on* the opening's centre makes
``cut_percentage == t``.
With non-strict ``>=`` / ``<=`` tests, the degenerate range matched both
removal conditions and both walls dropped the opening leaving the user
with two walls and no hole anywhere. Strict ``>`` / ``<`` tests keep the
opening on both walls in this case, which matches the visible geometry."""
min_t, max_t = 0.5, 0.5 # degenerate range — both bounds at the centre
cut_percentage = 0.5 # cursor placed exactly on the opening centre
removed_from_element1 = min_t > cut_percentage
removed_from_element2 = max_t < cut_percentage
assert removed_from_element1 is False, "must not remove from element1 when cursor sits on opening centre"
assert removed_from_element2 is False, "must not remove from element2 when cursor sits on opening centre"
# ---------------------------------------------------------------------------
# Filled-opening void-straddle behaviour
#
# When a wall split passes through a door/window, the filling (the door
# element itself) belongs to whichever wall contains its centre — but the
# void cut by the IfcOpeningElement may still straddle the cut, in which
# case the neighbour wall's body must also be cut. The helper that adds the
# pure-void copy is ``_add_void_copy``; the decision is taken in
# ``DumbWallJoiner.split``'s filled-opening loop.
# ---------------------------------------------------------------------------
def _make_void_copy_mock(has_filling_rel=True):
"""Build the ``void_copy`` MagicMock returned by ``copy_class`` so its
``HasFillings`` / ``VoidsElements`` / ``ObjectPlacement`` shape matches
what ``_add_void_copy`` mutates."""
copy_placement = MagicMock(name="copy_placement")
copy_placement.is_a = lambda klass: klass == "IfcLocalPlacement"
void_relation = MagicMock(name="VoidsRelation")
void_copy = MagicMock(name="void_copy")
void_copy.HasFillings = (MagicMock(name="copy_filling_rel"),) if has_filling_rel else ()
void_copy.VoidsElements = (void_relation,)
void_copy.ObjectPlacement = copy_placement
return void_copy, void_relation, copy_placement
def test_add_void_copy_strips_fillings_and_reparents_to_target_wall():
"""``_add_void_copy`` must create a pure-void IfcOpeningElement attached
to the target wall: the filling relationship copied along with the source
must be removed, ``VoidsElements[0].RelatingBuildingElement`` must point
at the target wall, and the representation must be a deep copy (not a
shared reference with the source)."""
from bonsai.bim.module.model.wall import _add_void_copy
source_representation = MagicMock(name="source_representation")
source_opening = MagicMock(name="source_opening")
source_opening.Representation = source_representation
void_copy, void_relation, copy_placement = _make_void_copy_mock()
carried_filling_rel = void_copy.HasFillings[0]
target_placement = MagicMock(name="target_placement")
target_wall = MagicMock(name="target_wall")
target_wall.ObjectPlacement = target_placement
ifc_file = MagicMock(name="ifc_file")
deep_copy_result = MagicMock(name="copied_representation")
with (
patch("bonsai.tool.Ifc.get", return_value=ifc_file),
patch("ifcopenshell.api.root.copy_class", return_value=void_copy) as mock_copy_class,
patch("ifcopenshell.util.element.copy_deep", return_value=deep_copy_result),
):
_add_void_copy(target_wall, source_opening)
# The carried-over filling relationship must be removed — the copy is a pure void.
ifc_file.remove.assert_called_once_with(carried_filling_rel)
# The void now points at the target wall, not the source's wall.
assert void_relation.RelatingBuildingElement is target_wall
# The placement is reparented under the target wall's local placement.
assert copy_placement.PlacementRelTo is target_placement
# The representation is deep-copied so future edits don't ripple back to source.
assert void_copy.Representation is deep_copy_result
mock_copy_class.assert_called_once_with(ifc_file, product=source_opening)
def test_add_void_copy_handles_source_with_no_fillings():
"""If the source opening has no ``HasFillings`` (the copy_class result
inherits that), the loop over ``void_copy.HasFillings or ()`` must run
zero times no spurious ``ifc_file.remove`` call."""
from bonsai.bim.module.model.wall import _add_void_copy
source_opening = MagicMock(name="source_opening")
source_opening.Representation = MagicMock(name="rep")
void_copy, _void_relation, _copy_placement = _make_void_copy_mock(has_filling_rel=False)
target_wall = MagicMock(name="target_wall")
ifc_file = MagicMock(name="ifc_file")
with (
patch("bonsai.tool.Ifc.get", return_value=ifc_file),
patch("ifcopenshell.api.root.copy_class", return_value=void_copy),
patch("ifcopenshell.util.element.copy_deep", return_value=MagicMock()),
):
_add_void_copy(target_wall, source_opening)
ifc_file.remove.assert_not_called()
def test_filled_opening_void_straddle_decision_keeps_void_on_neighbour():
"""Replays the decision logic in ``DumbWallJoiner.split``'s filled-opening
loop for the case ``filling_position <= cut_percentage and void_straddles``:
filling stays on element1 (its centre is before the cut), but the void
extent crosses the cut, so the neighbour wall (element2) must receive a
pure-void copy via ``_add_void_copy``.
Mirrors the unfilled-opening decision tests exercises the boolean
branching rather than full ``split()`` integration."""
cut_percentage = 0.5
filling_position = 0.4 # filling centre on element1's side
min_t, max_t = 0.3, 0.7 # void extent straddles cut at 0.5
void_straddles = min_t < cut_percentage < max_t
filling_on_element2 = filling_position > cut_percentage
# Expected branch: filling stays, but void straddles → add copy to element2.
assert void_straddles is True
assert filling_on_element2 is False
# Equivalent to the ``elif void_straddles:`` path adding a void copy to element2.
def test_filled_opening_void_straddle_with_filling_on_far_side_keeps_void_on_origin():
"""The symmetric case: ``filling_position > cut_percentage and void_straddles``.
Filling moves to element2 with the original void; element1 needs a
pure-void copy back (the void's element1 portion would otherwise be
orphaned). Documents the boolean state of the inner branch."""
cut_percentage = 0.5
filling_position = 0.6 # filling centre on element2's side
min_t, max_t = 0.3, 0.7
void_straddles = min_t < cut_percentage < max_t
filling_on_element2 = filling_position > cut_percentage
assert void_straddles is True
assert filling_on_element2 is True
# Equivalent to the outer ``if filling_position > cut_percentage`` path
# taking its inner ``if void_straddles`` branch and adding a void copy
# back to element1.
+13
View File
@@ -15,6 +15,8 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
from __future__ import annotations
@@ -1131,6 +1133,17 @@ def the_variable_key_is_value(key, value):
variables[key] = eval(replace_variables(value))
@then(parsers.parse('the variable "{key}" equals "{value}"'))
def the_variable_key_equals_value(key, value):
assert key in variables, f'Variable "{key}" was never set'
expected = eval(replace_variables(value))
actual = variables[key]
if isinstance(actual, float) and isinstance(expected, float):
assert abs(actual - expected) < 1e-5, f'Variable "{key}" is {actual!r}, expected {expected!r}'
else:
assert actual == expected, f'Variable "{key}" is {actual!r}, expected {expected!r}'
@then("nothing happens")
def nothing_happens():
pass
@@ -0,0 +1,411 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Unit coverage for the shared parametric-edit lifecycle mixins.
``bonsai.bim.parametric_lifecycle`` is the load-bearing path for 4 of 6
parametric features (door, window, railing, roof). The registry smoke test
elsewhere verifies operators are wired up; the mixins' own state-transition
contracts are tested here.
The mixins are exercised through minimal in-test subclasses that supply the
abstract hooks (``_is_element_type``, ``_get_props``, etc.). All ``tool.*`` and
``ifcopenshell.*`` references at the module top of ``parametric_lifecycle`` are
patched at the module attribute (not the source module) so each test sees
isolated mock state."""
import json
from typing import ClassVar
from unittest import mock
import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
import types as _types
import bpy
if not isinstance(bpy, _types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
class _FakeProps:
"""Stand-in for ``BIM<Name>Properties`` — records what was set so tests can
assert state transitions without instantiating real PropertyGroups."""
def __init__(self):
self.is_editing = False
self.last_kwargs = None
self.general = {"width": 1000}
self.lining = {"thickness": 50}
self.panel = {"material": "wood"}
def set_props_kwargs_from_ifc_data(self, data):
self.last_kwargs = dict(data)
def get_general_kwargs(self, convert_to_project_units=True):
return dict(self.general)
def get_lining_kwargs(self, convert_to_project_units=True):
return dict(self.lining)
def get_panel_kwargs(self, convert_to_project_units=True):
return dict(self.panel)
def _make_obj(props):
obj = mock.Mock()
obj.props = props
obj.name = "TestObj"
return obj
def _make_pset_text(general, lining, panel):
payload = {"lining_properties": lining, "panel_properties": panel, **general}
return json.dumps(payload)
# ----------------------------------------------------------------------
# FeatureModifierEditMixin (door/window pattern)
# ----------------------------------------------------------------------
def _door_mixin_cls(match=True, raise_on_update=False):
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
raised = raise_on_update
class _TestDoorMixin(FeatureModifierEditMixin):
pset_name: ClassVar[str] = "BBIM_Door"
representations_called: ClassVar[list] = []
@classmethod
def _is_element_type(cls, element):
return match
@classmethod
def _get_props(cls, obj):
return obj.props
@classmethod
def _update_modifier_representation(cls, obj, context):
cls.representations_called.append(obj)
if raised:
raise RuntimeError("simulated representation failure")
return _TestDoorMixin
@pytest.fixture
def patched_tool_and_ifc():
"""Patch ``tool`` and ``ifcopenshell.*`` references on the lifecycle module.
Yields ``(mock_tool, mock_ifc_util_element, mock_ifc_api_pset,
mock_ifc_util_rep, mock_core_geometry)`` so tests can configure return
values and assert call args."""
target = "bonsai.bim.parametric_lifecycle"
with mock.patch(f"{target}.tool") as mock_tool, mock.patch(f"{target}.ifcopenshell") as mock_ifc, mock.patch(
f"{target}.bonsai"
) as mock_bonsai:
# Element returned by tool.Ifc.get_entity is reused across mocks.
element = mock.Mock(name="entity")
mock_tool.Ifc.get_entity.return_value = element
mock_tool.Ifc.get.return_value = mock.Mock(name="ifc_file")
mock_tool.Model.get_constituents_props_data.return_value = {"materials": []}
mock_tool.Pset.get_element_pset.return_value = mock.Mock(name="pset")
mock_ifc.util.element.get_type.return_value = None # skip thumbnail mark
yield {
"tool": mock_tool,
"ifc": mock_ifc,
"bonsai": mock_bonsai,
"element": element,
}
def test_feature_modifier_enable_one_sets_is_editing_and_loads_kwargs(patched_tool_and_ifc):
props = _FakeProps()
obj = _make_obj(props)
patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text(
{"width": 1234}, {"thickness": 50}, {"material": "wood"}
)
cls = _door_mixin_cls(match=True)
cls._enable_one(obj)
assert props.is_editing is True
assert props.last_kwargs is not None
assert props.last_kwargs["width"] == 1234
assert props.last_kwargs["thickness"] == 50
assert props.last_kwargs["material"] == "wood"
assert "materials" in props.last_kwargs # from get_constituents_props_data
def test_feature_modifier_enable_one_noop_when_element_not_match(patched_tool_and_ifc):
props = _FakeProps()
obj = _make_obj(props)
cls = _door_mixin_cls(match=False)
cls._enable_one(obj)
assert props.is_editing is False
assert props.last_kwargs is None
# get_pset must not be called when _is_element_type returns False — the
# _resolve guard short-circuits before reading pset data.
patched_tool_and_ifc["ifc"].util.element.get_pset.assert_not_called()
def test_feature_modifier_enable_one_noop_when_no_entity(patched_tool_and_ifc):
"""tool.Ifc.get_entity returning None must short-circuit before predicate runs."""
props = _FakeProps()
obj = _make_obj(props)
patched_tool_and_ifc["tool"].Ifc.get_entity.return_value = None
cls = _door_mixin_cls(match=True)
cls._enable_one(obj)
assert props.is_editing is False
def test_feature_modifier_finish_one_clears_is_editing_and_writes_pset(patched_tool_and_ifc):
props = _FakeProps()
props.is_editing = True
obj = _make_obj(props)
ctx = mock.Mock(name="context")
cls = _door_mixin_cls(match=True)
cls._finish_one(obj, ctx)
assert props.is_editing is False
assert obj in cls.representations_called
# tool.Pset.write_bbim_data is called exactly once with the merged dict.
patched_tool_and_ifc["tool"].Pset.write_bbim_data.assert_called_once()
call_args = patched_tool_and_ifc["tool"].Pset.write_bbim_data.call_args
assert call_args.args[1] == "BBIM_Door" # pset_name positional arg
written_data = call_args.args[2]
assert "lining_properties" in written_data and "panel_properties" in written_data
def test_feature_modifier_finish_one_exception_leaves_draft_in_progress(patched_tool_and_ifc):
"""If _update_modifier_representation raises, is_editing must stay True
so the user's draft survives for retry. This is the contract called out
in parametric_lifecycle.py:161 set is_editing=False only on success."""
props = _FakeProps()
props.is_editing = True
obj = _make_obj(props)
ctx = mock.Mock(name="context")
cls = _door_mixin_cls(match=True, raise_on_update=True)
with pytest.raises(RuntimeError, match="simulated representation failure"):
cls._finish_one(obj, ctx)
assert props.is_editing is True # draft survives
def test_feature_modifier_cancel_one_restores_and_clears_is_editing(patched_tool_and_ifc):
props = _FakeProps()
props.is_editing = True
obj = _make_obj(props)
patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text(
{"width": 900}, {"thickness": 60}, {"material": "steel"}
)
cls = _door_mixin_cls(match=True)
cls._cancel_one(obj)
assert props.is_editing is False
assert props.last_kwargs is not None and props.last_kwargs["width"] == 900
# switch_representation must be called via bonsai.core.geometry.
patched_tool_and_ifc["bonsai"].core.geometry.switch_representation.assert_called_once()
def test_feature_modifier_targets_loop_uses_iter_targets(patched_tool_and_ifc):
"""_enable_targets / _finish_targets / _cancel_targets iterate
_iter_targets default is [active_object]; subclasses can override."""
props_a, props_b = _FakeProps(), _FakeProps()
obj_a, obj_b = _make_obj(props_a), _make_obj(props_b)
patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text(
{"width": 1000}, {"thickness": 50}, {"material": "wood"}
)
cls = _door_mixin_cls(match=True)
cls._iter_targets = classmethod(lambda c, ctx: [obj_a, obj_b])
result = cls()._enable_targets(mock.Mock())
assert result == {"FINISHED"}
assert props_a.is_editing is True
assert props_b.is_editing is True
# ----------------------------------------------------------------------
# PathPreservingEditMixin (railing/roof pattern)
# ----------------------------------------------------------------------
class _FakePathProps:
"""Stand-in for railing/roof properties — get_general_kwargs only (no lining/panel)."""
def __init__(self):
self.is_editing = False
self.last_kwargs = None
self.general = {"width": 200, "thickness": 10}
def set_props_kwargs_from_ifc_data(self, data):
self.last_kwargs = dict(data)
def get_general_kwargs(self, convert_to_project_units=True):
return dict(self.general)
def _path_mixin_cls(match=True):
from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
class _TestPathMixin(PathPreservingEditMixin):
pset_name: ClassVar[str] = "BBIM_Railing"
pset_updates: ClassVar[list] = []
ifc_data_updates: ClassVar[list] = []
bmesh_updates: ClassVar[list] = []
@classmethod
def _is_element_type(cls, element):
return match
@classmethod
def _get_props(cls, obj):
return obj.props
@classmethod
def _update_pset(cls, element, data):
cls.pset_updates.append((element, data))
@classmethod
def _update_modifier_ifc_data(cls, obj, context):
cls.ifc_data_updates.append(obj)
@classmethod
def _restore_viewport_after_cancel(cls, obj, context):
cls.bmesh_updates.append(obj)
return _TestPathMixin
def test_path_preserving_enable_one_sets_is_editing(patched_tool_and_ifc):
props = _FakePathProps()
obj = _make_obj(props)
patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
"data_dict": {"width": 250, "path_data": {"points": [[0, 0], [1, 0]]}}
}
cls = _path_mixin_cls(match=True)
cls._enable_one(obj)
assert props.is_editing is True
assert props.last_kwargs is not None
assert props.last_kwargs["width"] == 250
# path_data passes through (default _post_load_data is pass-through)
assert props.last_kwargs["path_data"] == {"points": [[0, 0], [1, 0]]}
def test_path_preserving_finish_one_preserves_path_data_and_clears_is_editing(patched_tool_and_ifc):
props = _FakePathProps()
props.is_editing = True
obj = _make_obj(props)
ctx = mock.Mock(name="context")
sentinel_path = {"points": [[5, 5], [9, 9]], "edges": [[0, 1]]}
patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
"data_dict": {"path_data": sentinel_path}
}
cls = _path_mixin_cls(match=True)
cls._finish_one(obj, ctx)
assert props.is_editing is False
assert cls.pset_updates, "_update_pset must be called on Finish"
assert cls.pset_updates[-1][1]["path_data"] is sentinel_path # preserved by reference
assert obj in cls.ifc_data_updates
def test_path_preserving_cancel_one_calls_restore_viewport_after_cancel(patched_tool_and_ifc):
props = _FakePathProps()
props.is_editing = True
obj = _make_obj(props)
ctx = mock.Mock(name="context")
patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
"data_dict": {"width": 250, "path_data": {"points": []}}
}
cls = _path_mixin_cls(match=True)
cls._cancel_one(obj, ctx)
assert props.is_editing is False
assert obj in cls.bmesh_updates
def test_path_preserving_enable_one_post_load_data_hook_runs(patched_tool_and_ifc):
"""Railing overrides _post_load_data to JSON-serialise path_data —
confirm the hook is honoured (here we drop a sentinel key)."""
props = _FakePathProps()
obj = _make_obj(props)
patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
"data_dict": {"width": 250, "extra": "drop_me"}
}
cls = _path_mixin_cls(match=True)
cls._post_load_data = classmethod(lambda c, data: {k: v for k, v in data.items() if k != "extra"})
cls._enable_one(obj)
assert "extra" not in props.last_kwargs
# ----------------------------------------------------------------------
# _ParametricEditMixinBase._resolve guard
# ----------------------------------------------------------------------
def test_resolve_returns_none_when_obj_has_no_entity(patched_tool_and_ifc):
cls = _door_mixin_cls(match=True)
patched_tool_and_ifc["tool"].Ifc.get_entity.return_value = None
obj = _make_obj(_FakeProps())
assert cls._resolve(obj) is None
def test_resolve_returns_none_when_element_type_mismatch(patched_tool_and_ifc):
cls = _door_mixin_cls(match=False)
obj = _make_obj(_FakeProps())
assert cls._resolve(obj) is None
def test_resolve_returns_tuple_when_match(patched_tool_and_ifc):
cls = _door_mixin_cls(match=True)
props = _FakeProps()
obj = _make_obj(props)
resolved = cls._resolve(obj)
assert resolved is not None
element, returned_props = resolved
assert element is patched_tool_and_ifc["element"]
assert returned_props is props
@@ -0,0 +1,157 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Registration smoke test for `tool.Parametric.EDIT_TYPES`.
The registry is the single source of truth for which parametric element types
exist. Every consumer (auto-commit on save, finish/cancel chains, the
``PointerProperty`` attachment, the ``GizmoPreferences<X>`` registration) derives
identifiers from each entry's short ``name`` token. Forget any downstream
registration and the silent-desync the framework exists to prevent will ship.
These tests pin the registry-to-runtime contract: for every entry the operator
``bl_idname``s resolve to registered ``bpy.ops.bim.*`` callables, the
``PropertyGroup`` class is attached to ``bpy.types.Object``, and the per-type
predicate exists on `tool.Blender.Modifier`."""
import types
import bpy
import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
@pytest.fixture
def registry():
from bonsai import tool
return tool.Parametric.EDIT_TYPES
def test_registry_is_non_empty(registry):
assert len(registry) >= 1
def test_every_entry_has_enable_op_registered(registry):
missing = [e.enable_op for e in registry if not hasattr(bpy.ops.bim, e.enable_op.removeprefix("bim."))]
assert not missing, f"Missing enable operators: {missing}"
def test_every_entry_has_finish_op_registered(registry):
missing = [e.finish_op for e in registry if not hasattr(bpy.ops.bim, e.finish_op.removeprefix("bim."))]
assert not missing, f"Missing finish operators: {missing}"
def test_every_entry_has_cancel_op_registered(registry):
missing = [e.cancel_op for e in registry if not hasattr(bpy.ops.bim, e.cancel_op.removeprefix("bim."))]
assert not missing, f"Missing cancel operators: {missing}"
def test_every_entry_has_property_group_attached(registry):
# ``register_object_properties`` runs at addon enable; if any entry's
# PropertyGroup class is missing on prop module the attribute is skipped.
missing = [e.props_attr for e in registry if not hasattr(bpy.types.Object, e.props_attr)]
assert not missing, (
f"bpy.types.Object missing attributes: {missing}"
f"verify the matching PropertyGroup classes exist in bim.module.model.prop"
)
def test_every_entry_has_modifier_predicate(registry):
from bonsai import tool
missing = [e.name for e in registry if getattr(tool.Blender.Modifier, f"is_{e.name}", None) is None]
assert not missing, f"tool.Blender.Modifier missing is_<name> predicates: {missing}"
def test_every_predicate_does_not_raise_on_non_matching_element(registry):
"""Each ``is_<name>`` predicate must be **total**: accept any IFC entity
and return a truthy/falsy value, never raise.
The registry iterates every predicate against the active IFC element on
save; a raising predicate (e.g. ``AttributeError`` from a missing pset
accessor when handed a non-matching element type) propagates upward and
breaks the save path for *all* parametric types, not just its own.
This test probes each predicate with an ``IfcAnnotation`` (an element
that carries none of the BBIM_<Type> psets the predicates look up) and
asserts the call does not raise. Falsy returns are acceptable the
registry treats them as 'no match'. What's forbidden is raising."""
import ifcopenshell
from bonsai import tool
probe = ifcopenshell.file(schema="IFC4").create_entity("IfcAnnotation")
raised = []
for feature in registry:
predicate = getattr(tool.Blender.Modifier, f"is_{feature.name}", None)
if predicate is None:
continue
try:
predicate(probe)
except Exception as e:
raised.append((feature.name, type(e).__name__, str(e)))
assert not raised, (
f"is_<name> predicates raised on a non-matching IfcAnnotation: {raised}. "
f"Predicates must be total — return bool, never raise. Add an "
f"`if not element.is_a('IfcXxx'): return False` short-circuit or guard the pset lookup."
)
def test_gizmo_preferences_attached_when_class_exists(registry):
"""For every registry entry whose ``GizmoPreferences<Name>`` class exists in
``bonsai.bim.ui``, the matching sub-PointerProperty must be declared on
``ui.GizmoPreferences`` under the registry entry's ``name`` token.
Catches the silent-skip behaviour of the registry-driven gizmo-prefs
discovery: a typo in the class name or a dropped registration would
otherwise produce a missing sub-panel at runtime with no error.
Entries without a ``GizmoPreferences<Name>`` class are allowed not
every parametric type ships gizmo prefs.
Checks ``__annotations__`` rather than ``hasattr`` because Blender's
PropertyGroup syntax (``field: bpy.props.PointerProperty(...)``) is an
annotation-only assignment the attribute only materialises on the
class after Blender's metaclass installs the bpy_struct descriptor,
which depends on registration timing. Reading ``__annotations__``
pins the source-level contract independently of when register() ran."""
from bonsai.bim import ui
annotations = getattr(ui.GizmoPreferences, "__annotations__", {})
missing = []
for feature in registry:
prefs_class_name = f"GizmoPreferences{feature.name.capitalize()}"
if not hasattr(ui, prefs_class_name):
continue
if feature.name not in annotations:
missing.append((feature.name, prefs_class_name))
assert not missing, (
f"ui.GizmoPreferences missing sub-PointerProperty field(s) for: {missing}"
f"each registered ``GizmoPreferences<Name>`` class must have a matching "
f"``<name>: PointerProperty(type=GizmoPreferences<Name>)`` field on "
f"``ui.GizmoPreferences``"
)
+243
View File
@@ -0,0 +1,243 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Tests for pure-Python math helpers in bonsai.core.model used by the wall gizmo system.
These run in the core lane (``pytest test/core/``) no Blender, no IFC file. The
helpers under test live in ``bonsai/core/model.py`` and are deliberately pure (tuple
in, tuple out) so they're exercisable without ``mathutils`` or ``bpy``."""
import math
import pytest
import bonsai.core.model as subject
class TestBaselineFromOffset:
THICKNESS = 0.2
def test_positive_direction_exterior(self):
assert subject.baseline_from_offset(0.0, self.THICKNESS) == "EXTERIOR"
def test_positive_direction_center(self):
assert subject.baseline_from_offset(-self.THICKNESS / 2, self.THICKNESS) == "CENTER"
def test_positive_direction_interior(self):
assert subject.baseline_from_offset(-self.THICKNESS, self.THICKNESS) == "INTERIOR"
def test_negative_direction_exterior(self):
assert subject.baseline_from_offset(self.THICKNESS, self.THICKNESS) == "EXTERIOR"
def test_negative_direction_center(self):
assert subject.baseline_from_offset(self.THICKNESS / 2, self.THICKNESS) == "CENTER"
def test_negative_direction_interior(self):
assert subject.baseline_from_offset(0.0, self.THICKNESS) == "EXTERIOR"
def test_within_tolerance_still_matches(self):
# A 0.5mm jitter on a 200mm wall should still classify cleanly.
assert subject.baseline_from_offset(-self.THICKNESS / 2 + 0.0005, self.THICKNESS) == "CENTER"
def test_outside_tolerance_falls_back_to_center(self):
# 50mm offset on a 200mm wall — not a canonical position.
assert subject.baseline_from_offset(0.05, self.THICKNESS) == "CENTER"
class TestProjectAxisIntersection:
PARALLEL_THRESHOLD = 0.9994 # cos(2°)
def test_perpendicular_walls_meet_at_corner(self):
# Wall A along +X from origin; wall B along +Y from (5, 0, 0).
# Axes meet exactly at (5, 0).
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((5.0, 0.0, 0.0), (5.0, 3.0, 0.0))
result = subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD)
assert result is not None
assert result[0] == pytest.approx(5.0)
assert result[1] == pytest.approx(0.0)
def test_offset_walls_intersect_at_extrapolated_point(self):
# Wall A: y=0 from x=1 to x=6.
# Wall B: x=0 from y=1 to y=4.
# Infinite-line intersection at (0, 0).
seg_a = ((1.0, 0.0, 0.0), (6.0, 0.0, 0.0))
seg_b = ((0.0, 1.0, 0.0), (0.0, 4.0, 0.0))
result = subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD)
assert result is not None
assert result[0] == pytest.approx(0.0)
assert result[1] == pytest.approx(0.0)
def test_parallel_walls_return_none(self):
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((0.0, 1.0, 0.0), (5.0, 1.0, 0.0))
assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None
def test_anti_parallel_walls_return_none(self):
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((5.0, 1.0, 0.0), (0.0, 1.0, 0.0)) # opposite direction
assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None
def test_nearly_parallel_walls_return_none(self):
# 1° off parallel — within the ~2° dead-band.
angle = math.radians(1)
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((0.0, 1.0, 0.0), (5.0 * math.cos(angle), 1.0 + 5.0 * math.sin(angle), 0.0))
assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None
def test_zero_length_segment_returns_none(self):
seg_a = ((0.0, 0.0, 0.0), (0.0, 0.0, 0.0))
seg_b = ((0.0, 0.0, 0.0), (1.0, 1.0, 0.0))
assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None
def test_intersection_z_is_average_of_endpoint_zs(self):
# Walls at different elevations; the icon-placement Z should be the average.
seg_a = ((0.0, 0.0, 1.0), (5.0, 0.0, 1.0)) # at z=1
seg_b = ((5.0, 0.0, 3.0), (5.0, 3.0, 3.0)) # at z=3
result = subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD)
assert result is not None
assert result[2] == pytest.approx(2.0)
class TestSlopeRoundTrip:
def test_zero_angle_zero_displacement(self):
assert subject.displacement_from_x_angle(3.0, 0.0) == pytest.approx(0.0)
assert subject.x_angle_from_displacement(3.0, 0.0) == pytest.approx(0.0)
def test_positive_angle_positive_displacement(self):
# 30° slope on a 3m wall → top moves ~1.732m in +Y.
displacement = subject.displacement_from_x_angle(3.0, math.radians(30))
assert displacement == pytest.approx(3.0 * math.tan(math.radians(30)))
def test_negative_angle_negative_displacement(self):
displacement = subject.displacement_from_x_angle(3.0, math.radians(-15))
assert displacement < 0
def test_round_trip_preserves_angle(self):
# Drag-to-angle-to-drag preserves the original.
original_angle = math.radians(20)
displacement = subject.displacement_from_x_angle(3.0, original_angle)
recovered = subject.x_angle_from_displacement(3.0, displacement)
assert recovered == pytest.approx(original_angle, abs=1e-9)
def test_round_trip_handles_zero_height(self):
# Walls of effectively zero height should not divide-by-zero.
recovered = subject.x_angle_from_displacement(0.0, 1.0)
assert recovered == pytest.approx(math.pi / 2, abs=1e-3)
class TestAreAxesCollinear:
PARALLEL_THRESHOLD = 0.9994
LINE_TOLERANCE = 0.05
def test_end_to_end_walls_along_x_are_collinear(self):
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((5.0, 0.0, 0.0), (10.0, 0.0, 0.0))
assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
def test_separated_collinear_walls_with_gap(self):
# Walls with a 1m gap between them — still on the same line.
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((6.0, 0.0, 0.0), (10.0, 0.0, 0.0))
assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
def test_perpendicular_walls_are_not_collinear(self):
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((0.0, 0.0, 0.0), (0.0, 5.0, 0.0))
assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
def test_parallel_walls_offset_perpendicular_are_not_collinear(self):
# Two parallel walls 1m apart — same direction but not the same line.
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((0.0, 1.0, 0.0), (5.0, 1.0, 0.0))
assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
def test_anti_parallel_collinear_walls(self):
# Reversed direction on the same line still counts as collinear.
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((10.0, 0.0, 0.0), (6.0, 0.0, 0.0))
assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
def test_z_is_ignored_for_plan_collinearity(self):
# Walls on different floors are still considered collinear in plan.
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((5.0, 0.0, 3.0), (10.0, 0.0, 3.0))
assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
def test_zero_length_segment_is_not_collinear(self):
seg_a = ((0.0, 0.0, 0.0), (0.0, 0.0, 0.0))
seg_b = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
def test_slightly_off_line_within_tolerance(self):
# 2cm perpendicular offset — still within the 5cm tolerance.
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((5.0, 0.02, 0.0), (10.0, 0.02, 0.0))
assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
def test_too_far_off_line_fails_tolerance(self):
# 10cm perpendicular offset — outside the 5cm tolerance.
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((5.0, 0.10, 0.0), (10.0, 0.10, 0.0))
assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
class TestClosestEndpointMidpoint:
def test_end_to_end_walls_midpoint_is_the_shared_corner(self):
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((5.0, 0.0, 0.0), (10.0, 0.0, 0.0))
result = subject.closest_endpoint_midpoint(seg_a, seg_b)
assert result == (pytest.approx(5.0), pytest.approx(0.0), pytest.approx(0.0))
def test_walls_with_gap_midpoint_is_in_the_gap(self):
# Wall A ends at x=5; wall B starts at x=7. Boundary midpoint is at x=6.
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((7.0, 0.0, 0.0), (12.0, 0.0, 0.0))
result = subject.closest_endpoint_midpoint(seg_a, seg_b)
assert result == (pytest.approx(6.0), pytest.approx(0.0), pytest.approx(0.0))
def test_perpendicular_walls_midpoint_is_between_nearest_endpoints(self):
# Wall A's +X endpoint (5,0,0) and wall B's origin (5,0,0) → midpoint at (5,0,0).
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((5.0, 0.0, 0.0), (5.0, 3.0, 0.0))
result = subject.closest_endpoint_midpoint(seg_a, seg_b)
assert result == (pytest.approx(5.0), pytest.approx(0.0), pytest.approx(0.0))
def test_z_averaged_when_walls_at_different_elevations(self):
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((5.0, 0.0, 3.0), (10.0, 0.0, 3.0))
result = subject.closest_endpoint_midpoint(seg_a, seg_b)
# Closest pair: (5,0,0) and (5,0,3); midpoint Z = 1.5.
assert result[2] == pytest.approx(1.5)
class TestVerticalHeightFromExtrusionDepth:
def test_vertical_wall_returns_depth_unchanged(self):
assert subject.vertical_height_from_extrusion_depth(3.0, 0.0) == pytest.approx(3.0)
def test_30_degree_slope(self):
# cos(30°) ≈ 0.866 → vertical height of a 3m slanted extrusion ≈ 2.598m.
result = subject.vertical_height_from_extrusion_depth(3.0, math.radians(30))
assert result == pytest.approx(3.0 * math.cos(math.radians(30)))
def test_negative_angle_yields_same_magnitude(self):
positive = subject.vertical_height_from_extrusion_depth(3.0, math.radians(30))
negative = subject.vertical_height_from_extrusion_depth(3.0, math.radians(-30))
assert positive == pytest.approx(negative)
+3 -7
View File
@@ -17,20 +17,20 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import test.bim.bootstrap
import ifcopenshell.api.cost
import bonsai.core.tool
import bonsai.tool as tool
import test.bim.bootstrap
from bonsai.tool.cost import Cost as subject
from test.bim.bootstrap import NewFile
from bonsai.tool.cost import Cost as subject
class TestImplementsTool(NewFile):
def test_run(self):
assert isinstance(subject(), bonsai.core.tool.Cost)
class TestDisableEditingCostItemParent(NewFile):
def test_avoid_recursion_error(newfile, monkeypatch):
class DummyProps:
@@ -39,11 +39,7 @@ class TestDisableEditingCostItemParent(NewFile):
self.active_cost_item_id = 5
props = DummyProps()
monkeypatch.setattr(
"bonsai.tool.Cost.get_cost_props",
lambda: props
)
monkeypatch.setattr("bonsai.tool.Cost.get_cost_props", lambda: props)
subject.disable_editing_cost_item_parent()
assert props.active_cost_item_id == 0
assert props.change_cost_item_parent is not False
+3 -3
View File
@@ -189,7 +189,7 @@ each JSON object. The model is opened once and saved once regardless of how
many elements are processed.
```bash
ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product '{id}'
```
```json
@@ -201,7 +201,7 @@ Placeholder tokens match the fields emitted by `ifcquery` — typically `{id}`,
```bash
ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \
--product {id} --attributes '{"Name": "Door"}'
--product '{id}' --attributes '{"Name": "Door"}'
```
**Options:**
@@ -297,7 +297,7 @@ ifcedit run model.ifc spatial.unassign_container \
--products "$(ifcquery model.ifc --format ids select 'IfcWall')"
# Fan-out — one operation per element, model opened and saved once
ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product '{id}'
```
## License
+100 -64
View File
@@ -133,23 +133,21 @@ struct spiral_parent_curve : public parent_curve_function {
// this is the piecewise curve segment function for horizontal and vertical
struct curve_segment_function {
curve_segment_function(const Eigen::Matrix4d& curve_segment_placement, const Eigen::Matrix4d& remove_parent_curve_rotation, const Eigen::Matrix4d& remove_parent_curve_translation, std::shared_ptr<parent_curve_function> parent_curve_fn) :
curve_segment_function(const Eigen::Matrix4d& curve_segment_placement, const Eigen::Matrix4d& parent_curve_normalization, std::shared_ptr<parent_curve_function> parent_curve_fn) :
curve_segment_placement_(curve_segment_placement),
remove_parent_curve_rotation_(remove_parent_curve_rotation),
remove_parent_curve_translation_(remove_parent_curve_translation),
parent_curve_normalization_(parent_curve_normalization),
parent_curve_fn_(parent_curve_fn) {
}
Eigen::Matrix4d operator()(double u) const {
Eigen::Matrix4d parent_curve_point = (*parent_curve_fn_)(u);
Eigen::Matrix4d curve_segment_point = curve_segment_placement_ * remove_parent_curve_rotation_ * remove_parent_curve_translation_ * parent_curve_point;
Eigen::Matrix4d curve_segment_point = curve_segment_placement_ * parent_curve_normalization_ * parent_curve_point;
return curve_segment_point + parent_curve_fn_->curvature(u);
}
private:
Eigen::Matrix4d curve_segment_placement_;
Eigen::Matrix4d remove_parent_curve_rotation_;
Eigen::Matrix4d remove_parent_curve_translation_;
Eigen::Matrix4d parent_curve_normalization_;
std::shared_ptr<parent_curve_function> parent_curve_fn_;
};
@@ -166,9 +164,46 @@ struct cant_curve_segment_function {
// Subtract the parent_curve_start_point to get the incremental cant rotation and superelevation
// Add the incremental cant rotation and superelevation to curve_segment_placement to get the curve_segment_point
Eigen::Matrix4d parent_curve_point = (*parent_curve_fn_)(u);
Eigen::Matrix4d cant_increment = parent_curve_point - parent_curve_start_point_;
Eigen::Matrix4d curve_segment_point = curve_segment_placement_ + cant_increment;
Eigen::Matrix3d parent_curve_start_rotation_ = parent_curve_start_point_.block<3, 3>(0, 0);
Eigen::Matrix3d parent_curve_point_rotation = parent_curve_point.block<3, 3>(0, 0);
Eigen::Matrix3d incremental_rotation = parent_curve_point_rotation * parent_curve_start_rotation_.transpose();
Eigen::Matrix3d placement_rotation_ = curve_segment_placement_.block<3, 3>(0, 0);
Eigen::Matrix3d curve_segment_rotation = incremental_rotation * placement_rotation_;
Eigen::Vector3d parent_curve_start_translation_ = parent_curve_start_point_.block<3, 1>(0, 3);
Eigen::Vector3d parent_curve_point_translation = parent_curve_point.block<3, 1>(0, 3);
Eigen::Vector3d incremental_translation = parent_curve_point_translation - parent_curve_start_translation_;
Eigen::Vector3d placement_translation_ = curve_segment_placement_.block<3, 1>(0, 3);
Eigen::Vector3d curve_segment_translation = incremental_translation + placement_translation_;
Eigen::Matrix4d curve_segment_point = Eigen::Matrix4d::Identity();
curve_segment_point.block<3, 3>(0, 0) = curve_segment_rotation;
curve_segment_point.block<3, 1>(0, 3) = curve_segment_translation;
//if (0.0 < u) {
// Eigen::IOFormat latexFormat(
// Eigen::FullPrecision, // full precision
// 0, // no alignment flags
// " & ", // coeff separator
// " \\\\ \n", // row separator
// "", // row prefix
// "", // row suffix
// "", // matrix prefix
// "" // matrix suffix
// );
// std::cout << "Placement (M_CSP)" << std::endl;
// std::cout << curve_segment_placement_.format(latexFormat) << std::endl;
// std::cout << "Parent curve start point (M_PCS)" << std::endl;
// std::cout << parent_curve_start_point_.format(latexFormat) << std::endl;
// std::cout << "Parent curve point (M_PCl)" << std::endl;
// std::cout << parent_curve_point.format(latexFormat) << std::endl;
// std::cout << "Curve segment point (M_c)" << std::endl;
// std::cout << curve_segment_point.format(latexFormat) << std::endl;
//}
return curve_segment_point + parent_curve_fn_->curvature(u);
}
private:
@@ -318,33 +353,26 @@ class curve_segment_evaluator {
return taxonomy::make<taxonomy::functor_item>(length, fn);
} else {
// The parent curve function returns the 4x4 matrix for the parent curve.
// Subtract the parent curve start point (remove the translation and rotation)
// to get the incremental translation and rotation. Apply the incremental
// Normalize the parent curve so that the trim start point and tangent direction at the start point
// are aligned with the origin. This is accomplished with a normalization matrix that subtracts the
// incremental parent curve start point and applies a rotation. Apply the incremental
// translation and rotation to the curve_segment_placement to get the curve_segment_point
// Do a negative translation of the parent curve point relative to the start of the parent curve.
// This moves parent_curve_fn(u=0.0) to coordinate (0,0).
// This is done so the curve_segment_placement is applied relative to (0,0)
Eigen::Matrix4d remove_parent_curve_translation = Eigen::Matrix4d::Identity();
remove_parent_curve_translation.col(3) = -1.0 * (*parent_curve_start_point_).col(3);
remove_parent_curve_translation(3, 3) = 1.0;
auto rotation = (*parent_curve_start_point_).block<3, 3>(0, 0);
auto dxo = rotation(0, 0);
auto dyo = rotation(1, 0);
rotation(0, 1) *= -1.0;
rotation(1, 0) *= -1.0;
auto xo = (*parent_curve_start_point_)(0, 3);
auto yo = (*parent_curve_start_point_)(1, 3);
auto xn = -xo*dxo - yo*dyo;
auto yn = xo*dyo - yo*dxo;
Eigen::Matrix4d parent_curve_normalization = Eigen::Matrix4d::Identity();
parent_curve_normalization.block<3, 3>(0, 0) = rotation;
parent_curve_normalization(0, 3) = xn;
parent_curve_normalization(1, 3) = yn;
// Do a rotation so that the tangent of the parent curve is in the direction (1,0)
// Example: if the parent curve IfcLine is at a 30 degree clockwise angle, this does
// a 30 degree counter-clockwise rotation
// Clockwise rotation matrix = [cos(angle) -sin(angle)]
// [sin(angle) cos(angle)]
//
// Counter-clockwise rotation = [ cos(angle) sin(angle)]
// [-sin(angle) cos(angle)]
//
// That's just a sign flip in positions (0,1) and (1,0)
Eigen::Matrix4d remove_parent_curve_rotation = (*parent_curve_start_point_);
remove_parent_curve_rotation(0, 1) *= -1.0;
remove_parent_curve_rotation(1, 0) *= -1.0;
remove_parent_curve_rotation.col(3) = Eigen::Vector4d(0, 0, 0, 1); // remove the parent curve placement point
auto fn = curve_segment_function(*curve_segment_placement_, remove_parent_curve_rotation, remove_parent_curve_translation, parent_curve_fn_);
auto fn = curve_segment_function(*curve_segment_placement_, parent_curve_normalization, parent_curve_fn_);
return taxonomy::make<taxonomy::functor_item>(length, fn);
}
}
@@ -495,11 +523,10 @@ class curve_segment_evaluator {
// tilt angle in the plane of the cross section
auto cant = Cant(u);
auto tilt_angle = start_angle + delta_angle * (cant - start_cant) / delta_cant;
Eigen::Vector4d z(0.0, cos(tilt_angle), sin(tilt_angle), 0.0);
Eigen::Vector4d axis(0.0, cos(tilt_angle), sin(tilt_angle), 0.0);
// compute axis direction
Eigen::Vector4d y = z.cross3(ref_dir);
Eigen::Vector4d axis = ref_dir.cross3(y);
// compute cross slope direction
Eigen::Vector4d y = axis.cross3(ref_dir);
Eigen::Matrix4d m = Eigen::Matrix4d::Identity();
m.col(0) = ref_dir;
@@ -831,6 +858,8 @@ class curve_segment_evaluator {
auto R = c->Radius() * length_unit_;
auto parent_curve_position = taxonomy::cast<taxonomy::matrix4>(mapping_->map(c->Position()))->ccomponents();
auto sign_l = sign(length_);
// center point of the parent curve
auto pcCenterX = parent_curve_position(0, 3);
auto pcCenterY = parent_curve_position(1, 3);
@@ -844,7 +873,8 @@ class curve_segment_evaluator {
// angle from X = 0 to the first point on the trimmed curve
auto start_angle = pc_axis_angle + sweep_start_angle;
auto sign_l = sign(length_);
auto pcStartX = pcCenterX + R * cos(start_angle);
auto pcStartY = pcCenterY + R * sin(start_angle);
projected_length_ = length_;
@@ -856,34 +886,27 @@ class curve_segment_evaluator {
#ifdef SCHEMA_IfcCurveSegment_HAS_Placement
curve_segment_placement = taxonomy::cast<taxonomy::matrix4>(mapping_->map(inst_->Placement()))->ccomponents();
#endif
auto csStartX = curve_segment_placement(0, 3);
auto csStartY = curve_segment_placement(1, 3);
auto csStartDx = curve_segment_placement(0, 0);
auto csStartDy = curve_segment_placement(1, 0);
auto csCenterX = csStartX - sign_l * csStartDy * R;
auto csCenterY = csStartY + sign_l * csStartDx * R;
// determine projected length along the x-axis
auto subtended_angle = R ? length_ / R : 0.0;
auto end_angle = start_angle + subtended_angle;
auto csEndX = csCenterX + R * cos(end_angle);
projected_length_ = csEndX - csStartX;
auto pcEndX = pcCenterX + R * cos(end_angle);
projected_length_ = pcEndX - pcStartX;
convert_u = [csStartX, csStartY, csCenterX, csCenterY, R, sign_l](double u) {
convert_u = [pcStartX, pcStartY, pcCenterX, pcCenterY, R, sign_l](double u) {
// for vertical, u is measured along the horizonal but we need it to be an arc length
// x and y are coordinates on the curve segment for horizontal distance u from the start point
// u is a horizontal distance so x = csStartX + u
// Recognizing the triangle
// R^2 = (u + csStartX - csCenterX)^2 + (y - csCenterY)^2
// R^2 = (u + pcStartX - pcCenterX)^2 + (y - pcCenterY)^2
// solve for y
// (y - csCenterY) = sqrt( R^2 - (u + csStartX - csCenterX)^2 )
// y = csCenterY + sqrt( R^2 - (u + csStartX - csCenterX)^2 )
auto x = csStartX + u;
auto y = csCenterY - sign_l * sqrt(pow(R, 2) - pow(u + csStartX - csCenterX, 2));
// (y - pcCenterY) = sqrt( R^2 - (u + pcStartX - pcCenterX)^2 )
// y = pcCenterY + sqrt( R^2 - (u + pcStartX - pcCenterX)^2 )
auto x = pcStartX + u;
auto y = pcCenterY - sign_l * sqrt(pow(R, 2) - pow(u + pcStartX - pcCenterX, 2));
// compute the chord distance between the start point and (x,y)
auto c = sqrt(pow(x - csStartX, 2.0) + pow(y - csStartY, 2.0));
auto c = sqrt(pow(x - pcStartX, 2.0) + pow(y - pcStartY, 2.0));
// compute the subtended angle
// c = 2R*sin(delta/2)
@@ -986,18 +1009,18 @@ class curve_segment_evaluator {
double m_squared = std::inner_product(dr.begin(), dr.end(), dr.begin(), 0.0);
double m = sqrt(m_squared);
std::transform(dr.begin(), dr.end(), dr.begin(), [m](auto& d) { return d / m; });
auto pcDx = dr[0];
auto pcDy = dr[1];
auto pcDXx = dr[0];
auto pcDXy = dr[1];
if (segment_type_ == ST_VERTICAL && curve_segment_placement_) {
// the general algorithm for mapping parent curve onto curve segment doesn't
// exactly work for IfcLine. This is easily overcome by using the curve segment
// placement for the IfcLine direction
pcDx = (*curve_segment_placement_)(0, 0);
pcDy = (*curve_segment_placement_)(1, 0);
pcDXx = (*curve_segment_placement_)(0, 0);
pcDXy = (*curve_segment_placement_)(1, 0);
// projected length along the x-axis is the 'i' component of the total length
projected_length_ = length_ * pcDx;
projected_length_ = length_ * pcDXx;
}
if (segment_type_ == ST_HORIZONTAL || segment_type_ == ST_VERTICAL || segment_type_ == ST_CANT) {
@@ -1006,19 +1029,32 @@ class curve_segment_evaluator {
convert_u = [](double u) { return u; }; // u is along curve
} else {
// u is along horizontal, convert to along curve
convert_u = [pcDx](double u) { return u/pcDx; };
convert_u = [pcDXx](double u) { return u/pcDXx; };
}
auto pcDZy = curve_segment_placement_ ? (*curve_segment_placement_)(1, 2) : 0.;
auto pcDZz = curve_segment_placement_ ? (*curve_segment_placement_)(2, 2) : 1.;
parent_curve_fn_ = std::make_shared<line_parent_curve>(
[pcX, pcY, pcDx, pcDy, convert_u](double u)->Eigen::Matrix4d {
[segment_type = segment_type_,pcX, pcY, pcDXx, pcDXy, pcDZy, pcDZz, convert_u](double u)->Eigen::Matrix4d {
u = convert_u(u);
auto x = pcX + pcDx * u;
auto y = pcY + pcDy * u;
auto x = pcX + pcDXx * u;
auto y = pcY + pcDXy * u;
Eigen::Matrix4d m = Eigen::Matrix4d::Identity();
m.col(0) = Eigen::Vector4d(pcDx, pcDy, 0, 0);
m.col(1) = Eigen::Vector4d(-pcDy, pcDx, 0, 0);
Eigen::Vector3d X(pcDXx, pcDXy, 0);
Eigen::Vector3d Z(0, 0, 1);
if (segment_type == ST_CANT) {
Z = Eigen::Vector3d(0, pcDZy, pcDZz);
}
Eigen::Vector3d Y = Z.cross(X).normalized();
m.col(0) = Eigen::Vector4d(X[0], X[1], X[2], 0);
m.col(1) = Eigen::Vector4d(Y[0], Y[1], Y[2], 0);
m.col(2) = Eigen::Vector4d(Z[0], Z[1], Z[2], 0);
m.col(3) = Eigen::Vector4d(x, y, 0.0, 1.0);
return m;
},
+3 -3
View File
@@ -57,13 +57,13 @@ Dry-run to validate without modifying the file::
Apply an API function to each element in a JSON array from stdin (``{field}``
placeholders are substituted from each item; model is opened and saved once)::
$ ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
$ ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product '{id}'
$ ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \
--product {id} --attributes '{"Name": "Door"}'
--product '{id}' --attributes '{"Name": "Door"}'
Write to a separate output file instead of overwriting::
$ ifcquery model.ifc select 'IfcWall' | ifcedit foreach model.ifc root.remove_product -o output.ifc --product {id}
$ ifcquery model.ifc select 'IfcWall' | ifcedit foreach model.ifc root.remove_product -o output.ifc --product '{id}'
Quantity take-off (writes ``IfcElementQuantity`` psets back to the file; requires C++ geometry bindings)::
+1 -1
View File
@@ -86,7 +86,7 @@ pass query results directly into ``ifcedit run`` parameters, or pipe JSON into
--products "$(ifcquery model.ifc --format ids select 'IfcWall')"
# Fan-out — one operation per element, model opened and saved once
$ ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
$ ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product '{id}'
# Render an element highlighted against everything related to it
$ ifcquery model.ifc render -o relations.png \
@@ -70,8 +70,10 @@ from .get_basis_curve import get_basis_curve
from .get_cant_layout import get_cant_layout
from .get_child_alignments import get_child_alignments
from .get_curve import get_curve
from .get_curve_segment import get_curve_segment
from .get_curve_segment_transition_code import get_curve_segment_transition_code
from .get_horizontal_layout import get_horizontal_layout
from .get_layout import get_layout
from .get_layout_curve import get_layout_curve
from .get_layout_segments import get_layout_segments
from .get_mapped_segments import get_mapped_segments
@@ -86,6 +88,7 @@ from .layout_vertical_alignment_by_pi_method import (
layout_vertical_alignment_by_pi_method,
)
from .name_segments import name_segments
from .update_end_point import update_end_point
from .update_fallback_position import update_fallback_position
from .util import *
@@ -112,8 +115,10 @@ __all__ = [
"get_cant_layout",
"get_child_alignments",
"get_curve",
"get_curve_segment",
"get_curve_segment_transition_code",
"get_horizontal_layout",
"get_layout",
"get_layout_curve",
"get_layout_segments",
"get_parent_alignment",
@@ -124,6 +129,7 @@ __all__ = [
"layout_vertical_alignment_by_pi_method",
"name_segments",
"register_referent_name_callback",
"update_end_point",
"update_fallback_position",
"get_mapped_segments",
]
@@ -16,6 +16,7 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from typing import Union
import numpy as np
import ifcopenshell
@@ -24,6 +25,9 @@ import ifcopenshell.geom
import ifcopenshell.ifcopenshell_wrapper as ifcopenshell_wrapper
import ifcopenshell.util.unit
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._get_segment_endpoint import _get_segment_endpoint
from ifcopenshell.api.alignment._update_zero_length_segment_placement import _update_zero_length_segment_placement
from ifcopenshell.api.alignment._map_alignment_cant_segment import (
_map_alignment_cant_segment,
)
@@ -39,11 +43,26 @@ from ifcopenshell.api.alignment._update_curve_segment_transition_code import (
def _add_curve_segment_to_composite_curve(
file: ifcopenshell.file, curve_segment: entity_instance, composite_curve: entity_instance
):
file: ifcopenshell.file,
layout_segment: entity_instance,
curve_segment: entity_instance,
composite_curve: entity_instance,
) -> Union[np.array, None]:
"""
Adds a curve segment to a composite curve and returns the end point of the added segment.
:param file: The IFC file
:param layout_segment: The layout segment
:param curve_segment: The curve segment to be added
:param composite_curve: The composite curve to which the segment will be added
:return: The end point of the added segment or None if an error occurs
"""
if 0 < len(curve_segment.UsingCurves):
raise TypeError("IfcCurveSegment cannot belong to other curves")
prev_segment = None
zero_length_segment = None
settings = ifcopenshell.geom.settings()
if composite_curve.Segments == None or 0 == len(composite_curve.Segments):
# this is the first segment so just add it
@@ -56,22 +75,29 @@ def _add_curve_segment_to_composite_curve(
composite_curve.Segments += (curve_segment,)
assert len(curve_segment.UsingCurves) == 1
else:
# not the first segment, so get the zero_length segment (if it exists)
zero_length_segment = (
composite_curve.Segments[-1]
if ifcopenshell.api.alignment.has_zero_length_segment(composite_curve)
else None
)
prev_segment = None
# get the previous segment, which is either the on preceeding the zero length segment (if it exists) or
# the last curve segment if there is no zero length segment.
# This segment's transition code will need to be updated to match the new curve segment.
if zero_length_segment and 1 < len(composite_curve.Segments):
prev_segment = composite_curve.Segments[-2]
elif zero_length_segment == None:
prev_segment = composite_curve.Segments[-1]
curve_segment.Transition = "CONTINUOUS"
# IfcCompositeCurve is supposed to be comprised of continuous segments
curve_segment.Transition = "DISCONTINUOUS"
# get a list of all but the last segment (skips the zero length segment, if it exists)
segments = composite_curve.Segments[0:-1]
if zero_length_segment:
# if there is a zero length segment, need to append new curve_segment and the zero length segment to the array
# them update the composite curve segments with the new array
segments += (
curve_segment,
zero_length_segment,
@@ -79,31 +105,23 @@ def _add_curve_segment_to_composite_curve(
composite_curve.Segments = []
composite_curve.Segments += segments
else:
# if there is no zero length segment, we can just append the new curve segment to the existing array of segments
composite_curve.Segments += (curve_segment,)
if prev_segment:
_update_curve_segment_transition_code(prev_segment, curve_segment)
if prev_segment:
_update_curve_segment_transition_code(prev_segment, curve_segment)
if zero_length_segment:
settings = ifcopenshell.geom.settings()
segment_fn = ifcopenshell_wrapper.map_shape(settings, curve_segment.wrapped_data)
segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn)
e = segment_evaluator.evaluate(segment_fn.end())
end = np.array(e)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
end_point = _get_segment_endpoint(file, layout_segment)
if zero_length_segment:
_update_zero_length_segment_placement(file, zero_length_segment, end_point)
_update_curve_segment_transition_code(curve_segment, zero_length_segment)
# assume IfcAxis2Placement2D
zero_length_segment.Placement.Location.Coordinates = (x, y)
zero_length_segment.Placement.RefDirection.DirectionRatios = (dx, dy)
_update_curve_segment_transition_code(curve_segment, zero_length_segment)
return end_point
def _add_segment_to_curve(file: ifcopenshell.file, segment: entity_instance, curve: entity_instance) -> None:
def _add_segment_to_curve(
file: ifcopenshell.file, layout_segment: entity_instance, curve: entity_instance
) -> Union[np.array, None]:
"""
Creates an IfcCurveSegment from the IfcAlignmentSegment and adds it to the representation curve. The IfcCurveSegment is added
at the end of the curve, but before the manditory zero length segment. The IfcCurveSegment.Transition for the segment
@@ -114,16 +132,18 @@ def _add_segment_to_curve(file: ifcopenshell.file, segment: entity_instance, cur
:return: None
"""
expected_types = ["IfcAlignmentSegment"]
if not segment.is_a() in expected_types:
if not layout_segment.is_a() in expected_types:
raise TypeError(
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received '{segment.is_a()}"
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received '{layout_segment.is_a()}"
)
if segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment") and not curve.is_a("IfcCompositeCurve"):
if layout_segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment") and not curve.is_a("IfcCompositeCurve"):
raise TypeError(f"Expected to see IfcCompositeCurve, instead received '{curve.is_a()}'.")
elif segment.DesignParameters.is_a("IfcAlignmentVerticalSegment") and not curve.is_a("IfcGradientCurve"):
elif layout_segment.DesignParameters.is_a("IfcAlignmentVerticalSegment") and not curve.is_a("IfcGradientCurve"):
raise TypeError(f"Expected to see IfcGradientCurve, instead received '{curve.is_a()}'.")
elif segment.DesignParameters.is_a("IfcAlignmentCantSegment") and not curve.is_a("IfcSegmentedReferenceCurve"):
elif layout_segment.DesignParameters.is_a("IfcAlignmentCantSegment") and not curve.is_a(
"IfcSegmentedReferenceCurve"
):
raise TypeError(f"Expected to see IfcSegmentedReferenceCurve, instead received '{curve.is_a()}'.")
expected_type = "IfcCompositeCurve"
@@ -131,16 +151,18 @@ def _add_segment_to_curve(file: ifcopenshell.file, segment: entity_instance, cur
raise TypeError(f"Expected to see {expected_type}, instead received {curve.is_a()}.")
# map the IfcAlignmentSegment to an IfcCurveSegment (or two in the case of helmert curves)
if segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"):
mapped_segments = _map_alignment_horizontal_segment(file, segment)
elif segment.DesignParameters.is_a("IfcAlignmentVerticalSegment"):
mapped_segments = _map_alignment_vertical_segment(file, segment)
elif segment.DesignParameters.is_a("IfcAlignmentCantSegment"):
cant_layout = segment.Nests[0].RelatingObject
mapped_segments = _map_alignment_cant_segment(file, segment, cant_layout.RailHeadDistance)
if layout_segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"):
mapped_segments = _map_alignment_horizontal_segment(file, layout_segment)
elif layout_segment.DesignParameters.is_a("IfcAlignmentVerticalSegment"):
mapped_segments = _map_alignment_vertical_segment(file, layout_segment)
elif layout_segment.DesignParameters.is_a("IfcAlignmentCantSegment"):
cant_layout = layout_segment.Nests[0].RelatingObject
mapped_segments = _map_alignment_cant_segment(file, layout_segment, cant_layout.RailHeadDistance)
else:
assert False
for mapped_segment in mapped_segments:
if mapped_segment:
_add_curve_segment_to_composite_curve(file, mapped_segment, curve)
end_point = _add_curve_segment_to_composite_curve(file, layout_segment, mapped_segment, curve)
return end_point
@@ -16,12 +16,14 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import math
from typing import Union
import numpy as np
import ifcopenshell
import ifcopenshell.api.alignment
from ifcopenshell.api.alignment import _map_alignment_cant_segment
from ifcopenshell.api.alignment._update_zero_length_segment_placement import _update_zero_length_segment_placement
import ifcopenshell.api.nest
import ifcopenshell.api.pset
import ifcopenshell.geom
@@ -29,15 +31,29 @@ import ifcopenshell.util.alignment
import ifcopenshell.util.unit
from ifcopenshell import entity_instance, ifcopenshell_wrapper
from ifcopenshell.api.alignment._add_segment_to_curve import _add_segment_to_curve
from ifcopenshell.api.alignment._get_segment_endpoint import _get_segment_endpoint
from ifcopenshell.api.alignment._get_segment_start_point_label import (
_get_segment_start_point_label,
)
from ifcopenshell.api.alignment._map_alignment_cant_segment import (
_map_alignment_cant_segment,
)
from ifcopenshell.api.alignment._map_alignment_horizontal_segment import (
_map_alignment_horizontal_segment,
)
from ifcopenshell.api.alignment._map_alignment_vertical_segment import (
_map_alignment_vertical_segment,
)
def _add_segment_to_layout(file: ifcopenshell.file, layout: entity_instance, segment: entity_instance) -> None:
def _add_segment_to_layout(
file: ifcopenshell.file, layout: entity_instance, layout_segment: entity_instance
) -> Union[np.array, None]:
"""
Adds an IfcAlignmentSegment to a layout alignment (IfcAlignmentHorizontal/Vertical/Cant). This segment is added at the end
of the layout, before the manditory zero length segment. An IfcCurveSegment is created for the corresponding geometric representation.
of the layout, before the manditory zero length segment (if it exists).
If the layout has a corresponding geometric representation, an IfcCurveSegment is created for it and appended at the end
of the representation curve, before the zero length segment (if it exists).
:param layout: The layout alignment
:param segment: The segment to be appended
@@ -50,160 +66,31 @@ def _add_segment_to_layout(file: ifcopenshell.file, layout: entity_instance, seg
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received {layout.is_a()}"
)
if not (segment.is_a("IfcAlignmentSegment")):
raise TypeError(f"Expected to see IfcAlignmentSegment, instead received {segment.is_a()}.")
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
if not (layout_segment.is_a("IfcAlignmentSegment")):
raise TypeError(f"Expected to see IfcAlignmentSegment, instead received {layout_segment.is_a()}.")
# add the new segment to the layout
ifcopenshell.api.nest.assign_object(file, related_objects=[segment], relating_object=layout)
ifcopenshell.api.nest.assign_object(file, related_objects=[layout_segment], relating_object=layout)
# segment is attached at the end, but this is after the zero length segment
# swap the last two segments
ifcopenshell.api.nest.reorder_nesting(file, segment, -1, -1)
ifcopenshell.api.nest.reorder_nesting(file, layout_segment, -1, -1)
# For cant segments, the end point depends on the next segment. The next segment is the
# zero-length segment and it hasn't been updated to match the end point.
# For this reason, we can't compute the end point from the IfcCurveSegment, but instead we
# compute it from the layout segment design parameters.
end_point = _get_segment_endpoint(file, layout_segment)
# update the position of the zero length layout segment to be at the end point of the newly added segment
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout)
zero_length_layout_segment = segment_nest.RelatedObjects[-1]
_update_zero_length_segment_placement(file, zero_length_layout_segment, end_point)
# if there is a curve defined, add a new IfcCurveSegment to it.
# _add_segment_to_curve maps the layout segment to the appropriate IfcCurveSegment type and adds it to the curve.
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
if curve:
# add the new segment to the geometric representation curve
_add_segment_to_curve(file, segment, curve)
_add_segment_to_curve(file, layout_segment, curve)
# gather information to:
# (1) add a referent at the start of this segment
# (2) update the name of the zero length segment's referent
# get the distance along the alignment to the start of the new segment
dist_along = 0.0
if layout.is_a("IfcAlignmentHorizontal"):
for nest in layout.IsNestedBy:
for seg in nest.RelatedObjects:
if seg.is_a("IfcAlignmentSegment"):
dist_along += seg.DesignParameters.SegmentLength
# the length of the current segment is in dist_along, so subtract it out
dist_along -= segment.DesignParameters.SegmentLength
else:
dist_along = segment.DesignParameters.StartDistAlong
# get the station of the start of the segment
alignment = ifcopenshell.api.alignment.get_alignment(layout)
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
station = start_station + dist_along
# update the zero length layout segment
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout)
zero_length_segment = segment_nest.RelatedObjects[-1]
mapped_segments = ifcopenshell.api.alignment.get_mapped_segments(segment)
mapped_segment = mapped_segments[0] if mapped_segments[1] == None else mapped_segments[1]
# compute the end point matrix
settings = ifcopenshell.geom.settings()
segment_fn = ifcopenshell_wrapper.map_shape(settings, mapped_segment.wrapped_data)
segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn)
e = segment_evaluator.evaluate(segment_fn.end())
end = np.array(e)
# update the zero length segment semantic representation parameters
if zero_length_segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"):
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
zero_length_segment.DesignParameters.StartPoint.Coordinates = (x, y)
zero_length_segment.DesignParameters.StartDirection = dy / dx
elif zero_length_segment.DesignParameters.is_a("IfcAlignmentVerticalSegment"):
y = float(end[1, 3]) / unit_scale
zero_length_segment.DesignParameters.StartHeight = y
dx = float(end[0, 0])
dy = float(end[1, 0])
zero_length_segment.DesignParameters.StartGradient = dy / dx
zero_length_segment.DesignParameters.EndGradient = zero_length_segment.DesignParameters.StartGradient
else:
z = float(end[2, 3]) / unit_scale
dx = float(end[0, 1])
dy = float(end[1, 1])
dz = float(end[2, 1])
ds = math.sqrt(dx * dx + dy * dy)
slope = dz / ds
railhead = layout.RailHeadDistance
zero_length_segment.DesignParameters.StartCantLeft = z + slope * railhead / 2.0
zero_length_segment.DesignParameters.StartCantRight = z - slope * railhead / 2.0
# updated the referent's name because the referent is now at a new station
start_dist_along = 0.0
if segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"):
start_dist_along = dist_along + segment.DesignParameters.SegmentLength
else:
start_dist_along = segment.DesignParameters.StartDistAlong + segment.DesignParameters.HorizontalLength
zero_length_segment.DesignParameters.StartDistAlong = start_dist_along
end_referent = zero_length_segment.PositionedRelativeTo[0].RelatingPositioningElement
end_referent.Name = f"{_get_segment_start_point_label(zero_length_segment,None)} ({ifcopenshell.util.alignment.station_as_string(file,start_station+start_dist_along)})"
# update the referent's geometric representation's location
end_referent.ObjectPlacement.RelativePlacement.Location.DistanceAlong.wrappedValue = start_dist_along
settings = ifcopenshell.geom.settings()
basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
curve_fn = ifcopenshell_wrapper.map_shape(settings, basis_curve.wrapped_data)
curve_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, curve_fn)
p = curve_evaluator.evaluate(start_dist_along * unit_scale)
p = np.array(p)
x = float(p[0, 3]) / unit_scale
y = float(p[1, 3]) / unit_scale
z = float(p[2, 3]) / unit_scale
rx = float(p[0, 0])
ry = float(p[1, 0])
rz = float(p[2, 0])
ax = float(p[0, 2])
ay = float(p[1, 2])
az = float(p[2, 2])
end_referent.ObjectPlacement.CartesianPosition.Location.Coordinates = (x, y, z)
end_referent.ObjectPlacement.CartesianPosition.Axis.DirectionRatios = (ax, ay, az)
end_referent.ObjectPlacement.CartesianPosition.RefDirection.DirectionRatios = (rx, ry, rz)
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
end_referent_station = start_station + start_dist_along
pset_stationing = ifcopenshell.api.pset.add_pset(file, product=end_referent, name="Pset_Stationing")
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties={"Station": end_referent_station})
# create the start of segment referent
# get the previous segment. Working from the end of the basis curve, -1 is zero length segment
# -2 is the newly added segment, so -3 is the segment occuring just before the newly added segment
prev_segment = segment_nest.RelatedObjects[-3] if 2 < len(segment_nest.RelatedObjects) else None
name = f"{_get_segment_start_point_label(prev_segment,segment)} ({ifcopenshell.util.alignment.station_as_string(file,station)})"
referent = ifcopenshell.api.alignment.add_stationing_referent(
file, alignment, distance_along=dist_along, station=station, name=name, positioned_product=segment
)
if len(curve.Segments) == 2 and layout.is_a("IfcAlignmentHorizontal"):
# this is the first real segment in the horizontal alignment
# update the location of the alignment's stationing referent
alignment = ifcopenshell.api.alignment.get_alignment(layout)
ref_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
stationing_referent = ref_nest.RelatedObjects[0]
p = curve_evaluator.evaluate(
stationing_referent.ObjectPlacement.RelativePlacement.Location.DistanceAlong.wrappedValue
)
p = np.array(p)
x = float(p[0, 3]) / unit_scale
y = float(p[1, 3]) / unit_scale
z = float(p[2, 3]) / unit_scale
rx = float(p[0, 0])
ry = float(p[1, 0])
rz = float(p[2, 0])
ax = float(p[0, 2])
ay = float(p[1, 2])
az = float(p[2, 2])
stationing_referent.ObjectPlacement.CartesianPosition.Location.Coordinates = (x, y, z)
stationing_referent.ObjectPlacement.CartesianPosition.Axis.DirectionRatios = (ax, ay, az)
stationing_referent.ObjectPlacement.CartesianPosition.RefDirection.DirectionRatios = (rx, ry, rz)
return end_point
@@ -42,17 +42,8 @@ def _add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance) -
f"Expected layout type to be one of {[_ for _ in expected_types]}, instead received {layout.is_a()}"
)
if not ifcopenshell.api.alignment.add_zero_length_segment(file, layout, include_referent=False):
return # zero length segment not added, probably because it already exists
ifcopenshell.api.alignment.add_zero_length_segment(file, layout)
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
if curve:
ifcopenshell.api.alignment.add_zero_length_segment(file, curve)
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout)
segment = segment_nest.RelatedObjects[-1]
alignment = ifcopenshell.api.alignment.get_alignment(layout)
station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
name = f"{_get_segment_start_point_label(segment,None)} ({ifcopenshell.util.alignment.station_as_string(file,station)})"
referent = ifcopenshell.api.alignment.add_stationing_referent(file, alignment, 0.0, station, name, segment)
@@ -35,6 +35,8 @@ def _create_geometric_representation(file: ifcopenshell.file, alignment: entity_
4) Vertical only (this occurs when horizontal is reused from a parent alignment) -> IfcGradientCurve
5) Vertical + Cant (this occurs when horizontal is reused from a parent alignment) -> IfcSegmentedReferenceCurve
This method creates the geometric representation entity and assigns it to the alignment, but does not populate the geometry of the representation.
:param alignment: The alignment for which the representation is being created
:return: None
"""
@@ -43,13 +45,6 @@ def _create_geometric_representation(file: ifcopenshell.file, alignment: entity_
if not alignment.is_a(expected_type):
raise TypeError(f"Expected {expected_type} but got {alignment.is_a()}")
placement = file.createIfcLocalPlacement(
PlacementRelTo=None,
RelativePlacement=file.createIfcAxis2Placement2D(Location=file.createIfcCartesianPoint(Coordinates=(0.0, 0.0))),
)
alignment.ObjectPlacement = placement
axis_geom_subcontext = ifcopenshell.api.alignment.get_axis_subcontext(file)
layouts = ifcopenshell.api.alignment.get_alignment_layouts(alignment)
@@ -126,7 +121,7 @@ def _create_geometric_representation(file: ifcopenshell.file, alignment: entity_
ifcopenshell.api.geometry.assign_representation(file, alignment, representation)
for child_alignment in children:
child_alignment.ObjectPlacement = placement
child_alignment.ObjectPlacement = alignment.ObjectPlacement
child_layouts = ifcopenshell.api.alignment.get_alignment_layouts(child_alignment)
if len(child_layouts) == 1:
assert child_layouts[0].is_a("IfcAlignmentVertical")
@@ -0,0 +1,88 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell 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.
#
# IfcOpenShell 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 IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell.api.alignment
from ifcopenshell import entity_instance, ifcopenshell_wrapper
from ifcopenshell.api.alignment._map_alignment_segment import _map_alignment_segment
from typing import Union
import math
import numpy as np
def _get_segment_endpoint(file: ifcopenshell.file, segment: entity_instance) -> Union[np.array, None]:
"""
Computes the 4x4 matrix for a segment end point. The segment can be an IfcAlignmentSegment
or IfcCurveSegment
"""
expected_types = ["IfcAlignmentSegment", "IfcCurveSegment"]
if not segment.is_a() in expected_types:
raise TypeError(
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received {segment.is_a()}"
)
file.begin_transaction() # use a transaction so we can discard any temporary IFC entities created
curve_segment = segment
if segment.is_a("IfcAlignmentSegment"):
layout = ifcopenshell.api.alignment.get_layout(segment)
mapped_segments = _map_alignment_segment(file, layout, segment)
curve_segment = mapped_segments[0] if mapped_segments[1] == None else mapped_segments[1]
# Inside of the IfcOpenShell C++ implementation where the IfcCurveSegment calculations occur,
# the composite curve owning the segment is evaluated to determine if a horizontal, vertical, or cant segment is being evaluated.
# This is necessary to determine how the end point of the curve segment is calculated.
# A temporary curve segment has been created and it needs to be associated with the correct composite curve for the end point to be calculated correctly.
# Inside the C++ implementation, if a composite curve isn't associated with the segment the segment is assumed to be horizontal. For this reason
# a temporary IfcCompositeCurve for horizontal segments doesn't need to be created.
if layout.is_a("IfcAlignmentVertical"):
gc = file.createIfcGradientCurve(Segments=[curve_segment])
elif layout.is_a("IfcAlignmentCant"):
# The evaluation of cant segments depend on the start conditions of the next segment. In the absense of a next segment the
# optional EndPoint is used. Since a tempoaryar IfcSegmentReferenceCurve is being used, there is not a next segment.
# For this reason the EndPoint must be created from the design parameters of the sementic segment definiton.
Dsl = segment.DesignParameters.StartCantLeft
Dsr = segment.DesignParameters.StartCantRight
Del = segment.DesignParameters.EndCantLeft if segment.DesignParameters.EndCantLeft != None else Dsl
Der = segment.DesignParameters.EndCantRight if segment.DesignParameters.EndCantRight != None else Dsr
cant = Der - Del
rh = layout.RailHeadDistance
Ay = cant / rh
Az = math.sqrt(rh**2 - cant**2) / rh
src = file.createIfcSegmentedReferenceCurve(
Segments=[curve_segment],
EndPoint=file.createIfcAxis2Placement3D(
Location=file.createIfcCartesianPoint((segment.DesignParameters.StartDistAlong, 0.5 * cant, 0.0)),
RefDirection=file.createIfcDirection((1.0, 0.0, 0.0)),
Axis=file.createIfcDirection((0.0, Ay, Az)),
),
)
settings = ifcopenshell.geom.settings()
segment_fn = ifcopenshell_wrapper.map_shape(settings, curve_segment.wrapped_data)
segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn)
x = segment_fn.end()
e = segment_evaluator.evaluate(x)
end = np.array(e)
file.discard_transaction()
return end
@@ -24,10 +24,12 @@ from ifcopenshell import entity_instance
def _get_axis(file: ifcopenshell.file, Ds: float, rail_head_distance: float) -> entity_instance:
Dy = rail_head_distance
Dz = 2 * Ds
D = math.sqrt(Dy * Dy + Dz * Dz)
return file.createIfcDirection((0.0, Dz / D, Dy / D))
# solves the ratio right triangle legs to hypotenous
# Dh^2 = Dy^2 + Dz^2
Dh = rail_head_distance # hypotenous
Dy = 2 * Ds # horizontal leg
Dz = math.sqrt(Dh * Dh - Dy * Dy) # vertical leg
return file.createIfcDirection((0.0, Dy / Dh, Dz / Dh))
def _map_constant_cant(
@@ -54,7 +56,7 @@ def _map_constant_cant(
Transition=transition,
Placement=file.createIfcAxis2Placement3D(
Location=start_point,
Axis=_get_axis(file, Ds, rail_head_distance),
Axis=_get_axis(file, 0.5 * (Dsr - Dsl), rail_head_distance),
RefDirection=file.createIfcDirection((math.cos(start_direction), math.sin(start_direction), 0.0)),
),
SegmentStart=file.createIfcLengthMeasure(0.0),
@@ -0,0 +1,49 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell 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.
#
# IfcOpenShell 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 IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from collections.abc import Sequence
import ifcopenshell
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._map_alignment_cant_segment import (
_map_alignment_cant_segment,
)
from ifcopenshell.api.alignment._map_alignment_horizontal_segment import (
_map_alignment_horizontal_segment,
)
from ifcopenshell.api.alignment._map_alignment_vertical_segment import (
_map_alignment_vertical_segment,
)
def _map_alignment_segment(
file: ifcopenshell.file, layout: entity_instance, segment: entity_instance
) -> Sequence[entity_instance]:
"""
Maps an IfcAlignmentSegment to its corresponding IfcCurveSegment(s) in the geometric representation.
The mapping is done based on the layout type and segment type.
"""
if layout.is_a("IfcAlignmentHorizontal"):
mapped_segments = _map_alignment_horizontal_segment(file, segment)
elif layout.is_a("IfcAlignmentVertical"):
mapped_segments = _map_alignment_vertical_segment(file, segment)
else:
mapped_segments = _map_alignment_cant_segment(file, segment, layout.RailHeadDistance)
return mapped_segments
@@ -0,0 +1,71 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell 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.
#
# IfcOpenShell 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 IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import numpy as np
import ifcopenshell
import math
import ifcopenshell.api.alignment
import ifcopenshell.util.unit
from ifcopenshell import entity_instance
def _update_zero_length_segment_placement(
file: ifcopenshell.file, zero_length_segment: entity_instance, placement: np.array
) -> None:
"""
Updates the placement of a zero length segment (i.e. a segment with identical start and end point) based on a 4x4 placement matrix.
The zero_length_segment can be an IfcAlignmentSegment or IfcCurveSegment.
"""
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
x = float(placement[0, 3]) / unit_scale
y = float(placement[1, 3]) / unit_scale
z = float(placement[2, 3]) / unit_scale
Rdx = float(placement[0, 0])
Rdy = float(placement[1, 0])
Rdz = float(placement[2, 0])
Adx = float(placement[0, 2])
Ady = float(placement[1, 2])
Adz = float(placement[2, 2])
if zero_length_segment.is_a("IfcCurveSegment"):
if zero_length_segment.Placement.is_a("IfcAxis2Placement2D"):
zero_length_segment.Placement.Location.Coordinates = (x, y)
zero_length_segment.Placement.RefDirection.DirectionRatios = (Rdx, Rdy)
else:
zero_length_segment.Placement.Location.Coordinates = (x, y, z)
zero_length_segment.Placement.RefDirection.DirectionRatios = (Rdx, Rdy, Rdz)
zero_length_segment.Placement.Axis.DirectionRatios = (Adx, Ady, Adz)
elif zero_length_segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"):
zero_length_segment.DesignParameters.StartPoint.Coordinates = (x, y)
zero_length_segment.DesignParameters.StartDirection = math.atan(Rdy / Rdx)
elif zero_length_segment.DesignParameters.is_a("IfcAlignmentVerticalSegment"):
zero_length_segment.DesignParameters.StartDistAlong = x
zero_length_segment.DesignParameters.StartHeight = y
zero_length_segment.DesignParameters.StartGradient = Rdy / Rdx
zero_length_segment.DesignParameters.EndGradient = zero_length_segment.DesignParameters.StartGradient
else:
slope = Ady / math.sqrt(Ady**2 + Adz**2)
layout = ifcopenshell.api.alignment.get_layout(zero_length_segment)
railhead = layout.RailHeadDistance
zero_length_segment.DesignParameters.StartDistAlong = x
zero_length_segment.DesignParameters.StartCantLeft = y - slope * railhead / 2.0
zero_length_segment.DesignParameters.StartCantRight = y + slope * railhead / 2.0
zero_length_segment.DesignParameters.EndCantLeft = zero_length_segment.DesignParameters.StartCantLeft
zero_length_segment.DesignParameters.EndCantRight = zero_length_segment.DesignParameters.StartCantRight
@@ -20,6 +20,7 @@ import numpy as np
import ifcopenshell
import ifcopenshell.api.alignment
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
import ifcopenshell.api.pset
import ifcopenshell.geom
import ifcopenshell.guid
@@ -58,7 +59,7 @@ def add_stationing_referent(
object_placement = None
representation = None
if basis_curve:
if basis_curve and basis_curve.is_a("IfcCompositeCurve") and 0 < len(basis_curve.Segments):
object_placement = file.createIfcLinearPlacement(
RelativePlacement=file.createIfcAxis2PlacementLinear(
Location=file.createIfcPointByDistanceExpression(
@@ -71,54 +72,13 @@ def add_stationing_referent(
),
)
is_valid_curve = True
if basis_curve.is_a("IfcCompositeCurve") and len(basis_curve.Segments) == 0:
is_valid_curve = False
if basis_curve.is_a("IfcPolyline") and len(basis_curve.Points) < 2:
is_valid_curve = False
elif basis_curve.is_a("IfcIndexedPolyCurve") and len(basis_curve.Points.CoordList) < 2:
is_valid_curve = False
if is_valid_curve:
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
settings = ifcopenshell.geom.settings()
fn = ifcopenshell_wrapper.map_shape(settings, basis_curve.wrapped_data)
if basis_curve.is_a("IfcPolyline") or basis_curve.is_a("IfcIndexedPolyCurve"):
fn = ifcopenshell_wrapper.convert_loop_to_function_item(fn)
evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, fn)
p = evaluator.evaluate(distance_along * unit_scale)
p = np.array(p)
x = float(p[0, 3]) / unit_scale
y = float(p[1, 3]) / unit_scale
z = float(p[2, 3]) / unit_scale
rx = float(p[0, 0])
ry = float(p[1, 0])
rz = float(p[2, 0])
ax = float(p[0, 2])
ay = float(p[1, 2])
az = float(p[2, 2])
else:
x = 0.0
y = 0.0
z = 0.0
rx = 1.0
ry = 0.0
rz = 0.0
ax = 0.0
ay = 0.0
az = 1.0
object_placement.CartesianPosition = file.createIfcAxis2Placement3D(
Location=file.createIfcCartesianPoint((x, y, z)),
Axis=file.createIfcDirection((ax, ay, az)),
RefDirection=file.createIfcDirection((rx, ry, rz)),
update_fallback_position(file, object_placement)
else:
object_placement = file.createIfcLocalPlacement(
PlacementRelTo=None,
RelativePlacement=file.createIfcAxis2Placement2D(
Location=file.createIfcCartesianPoint(alignment.ObjectPlacement.RelativePlacement.Location.Coordinates)
),
)
# this commented out code is what you would do to add a geometric representation of the referent
@@ -144,7 +104,12 @@ def add_stationing_referent(
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties={"Station": station})
nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
nest.RelatedObjects += (referent,)
if nest is None:
nest = file.createIfcRelNests(
GlobalId=ifcopenshell.guid.new(), RelatingObject=alignment, RelatedObjects=(referent,)
)
else:
nest.RelatedObjects += (referent,)
nest.RelatedObjects = sorted(
nest.RelatedObjects, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station")
@@ -18,14 +18,12 @@
import math
import numpy as np
import ifcopenshell
import ifcopenshell.api.alignment
from ifcopenshell.api.alignment._get_segment_endpoint import _get_segment_endpoint
from ifcopenshell.api.alignment._update_zero_length_segment_placement import _update_zero_length_segment_placement
import ifcopenshell.api.nest
import ifcopenshell.geom
import ifcopenshell.ifcopenshell_wrapper as wrapper
import ifcopenshell.util.alignment
import ifcopenshell.util.unit
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._get_segment_start_point_label import (
@@ -42,14 +40,13 @@ from ifcopenshell.api.alignment._update_curve_segment_transition_code import (
)
def add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance, include_referent: bool = True) -> bool:
def add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance) -> bool:
"""
Adds a zero length segment to the end of a layout.
If the layout already has a zero length segment, nothing is changed.
:param layout: An IfcAlignmentHorizontal, IfcAlignmentVertical, IfcAlignmentCant, IfcCompositeCurve, IfcGradientCurve, IfcSegmentedReferenceCurve
:param include_referent: If True, an IfcReferent representing the ending point of the layout is included for IfcLinearElement layouts (i.e. business logic)
:return: True if segment is added
"""
@@ -74,28 +71,6 @@ def add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance, in
return False
if layout.is_a("IfcCompositeCurve") or layout.is_a("IfcGradientCurve") or layout.is_a("IfcSegmentedReferenceCurve"):
x = 0.0
y = 0.0
dx = 1.0
dy = 0.0
segment_start = 0.0
last_segment = None
if layout.Segments and 0 < len(layout.Segments):
# If there are segments, get the last segment and compute the end point and tangent direction
# because this becomes of placement of the zero length segment
last_segment = layout.Segments[-1]
settings = ifcopenshell.geom.settings()
fn = wrapper.map_shape(settings, last_segment.wrapped_data)
eval = wrapper.function_item_evaluator(settings, fn)
e = np.array(eval.evaluate(fn.end()))
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
e[:3, 3] /= unit_scale
x = float(e[0, 3])
y = float(e[1, 3])
dx = float(e[0, 0])
dy = float(e[1, 0])
parent_curve = file.createIfcLine(
Pnt=file.createIfcCartesianPoint(Coordinates=((0.0, 0.0))),
Dir=file.createIfcVector(
@@ -103,22 +78,36 @@ def add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance, in
Magnitude=1.0,
),
)
if layout.is_a("IfcSegmentedReferenceCurve"):
placement = file.createIfcAxis2Placement3D(
Location=file.createIfcCartesianPoint((0.0, 0.0, 0.0)),
RefDirection=file.createIfcDirection((1.0, 0.0, 0.0)),
Axis=file.createIfcDirection((0.0, 0.0, 1.0)),
)
else:
placement = file.createIfcAxis2Placement2D(
Location=file.createIfcCartesianPoint((0.0, 0.0)),
RefDirection=file.createIfcDirection((1.0, 0.0)),
)
zero_length_curve_segment = file.createIfcCurveSegment(
Transition="DISCONTINUOUS",
Placement=file.createIfcAxis2Placement2D(
Location=file.createIfcCartesianPoint((x, y)),
RefDirection=file.createIfcDirection((dx, dy)),
),
Placement=placement,
SegmentStart=file.createIfcLengthMeasure(0.0),
SegmentLength=file.createIfcLengthMeasure(0.0),
ParentCurve=parent_curve,
)
layout.Segments += (zero_length_curve_segment,)
if last_segment:
if layout.Segments and 0 < len(layout.Segments):
# If there are segments, get the last segment and compute the end point and tangent direction
# because this becomes of placement of the zero length segment
last_segment = layout.Segments[-1]
end_point = _get_segment_endpoint(file, last_segment)
_update_zero_length_segment_placement(file, zero_length_curve_segment, end_point)
_update_curve_segment_transition_code(last_segment, zero_length_curve_segment)
layout.Segments += (zero_length_curve_segment,)
# add zero length segments to base curves
if layout.is_a("IfcSegmentedReferenceCurve"):
ifcopenshell.api.alignment.add_zero_length_segment(file, layout.BaseCurve)
@@ -139,22 +128,14 @@ def add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance, in
break
if last_segment:
file.begin_transaction() # use a transaction so we can discard any temporary IFC entities created
e = _get_segment_endpoint(file, last_segment)
settings = ifcopenshell.geom.settings()
mapped_segments = _map_alignment_horizontal_segment(file, last_segment)
geometry_segment = mapped_segments[0] if mapped_segments[1] == None else mapped_segments[1]
fn = wrapper.map_shape(settings, geometry_segment.wrapped_data)
eval = wrapper.function_item_evaluator(settings, fn)
e = np.array(eval.evaluate(fn.end()))
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
x = float(e[0, 3]) / unit_scale
y = float(e[1, 3]) / unit_scale
dx = float(e[0, 0])
dy = float(e[1, 0])
file.discard_transaction()
angle_unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file, "PLANEANGLEUNIT")
design_parameters = file.createIfcAlignmentHorizontalSegment(
StartPoint=file.createIfcCartesianPoint((x, y)),
@@ -178,22 +159,14 @@ def add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance, in
break
if last_segment:
file.begin_transaction()
last_segment_dist_along = (
last_segment.DesignParameters.StartDistAlong + last_segment.DesignParameters.HorizontalLength
)
last_segment_end_gradient = last_segment.DesignParameters.EndGradient
settings = ifcopenshell.geom.settings()
mapped_segments = _map_alignment_vertical_segment(file, last_segment)
geometry_segment = mapped_segments[0] if mapped_segments[1] == None else mapped_segments[1]
fn = wrapper.map_shape(settings, geometry_segment.wrapped_data)
eval = wrapper.function_item_evaluator(settings, fn)
e = np.array(eval.evaluate(fn.end()))
e = _get_segment_endpoint(file, last_segment)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
last_segment_height = float(e[1, 3]) / unit_scale
file.discard_transaction()
design_parameters = file.createIfcAlignmentVerticalSegment(
StartDistAlong=last_segment_dist_along,
HorizontalLength=0.0,
@@ -240,13 +213,4 @@ def add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance, in
ifcopenshell.api.nest.assign_object(file, related_objects=[zero_length_curve_segment], relating_object=layout)
if include_referent:
alignment = ifcopenshell.api.alignment.get_alignment(layout)
station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
name = f"{_get_segment_start_point_label(zero_length_curve_segment,None)} ({ifcopenshell.util.alignment.station_as_string(file,station)})"
referent = ifcopenshell.api.alignment.add_stationing_referent(
file, alignment, 0.0, station, name, zero_length_curve_segment
)
referent.Description = f"Positions zero length segment {zero_length_curve_segment.id()}"
return True
@@ -63,6 +63,12 @@ def create(
alignment = file.createIfcAlignment(
GlobalId=ifcopenshell.guid.new(),
Name=name,
ObjectPlacement=file.createIfcLocalPlacement(
PlacementRelTo=None,
RelativePlacement=file.createIfcAxis2Placement2D(
Location=file.createIfcCartesianPoint(Coordinates=(0.0, 0.0))
),
),
)
alignment_layouts = []
@@ -80,10 +86,10 @@ def create(
if include_geometry:
_create_geometric_representation(file, alignment)
name = ifcopenshell.util.alignment.station_as_string(file, start_station)
referent = ifcopenshell.api.alignment.add_stationing_referent(
file, alignment, 0.0, start_station, name, alignment
)
referent_name = ifcopenshell.util.alignment.station_as_string(file, start_station)
referent = ifcopenshell.api.alignment.add_stationing_referent(
file, alignment, 0.0, start_station, referent_name, alignment
)
for layout in alignment_layouts:
_add_zero_length_segment(file, layout)
@@ -53,35 +53,8 @@ def create_layout_segment(
# create the segment and add it to the layout.
segment = file.createIfcAlignmentSegment(GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters)
_add_segment_to_layout(file, layout, segment) # adds to layout and geometric representation
end = _add_segment_to_layout(
file, layout, segment
) # adds to layout and geometric representation (if present, also updates zero length segment position)
# compute the 4x4 matrix at the end of the segment so this information can be
# returned and used when defining the next segment
alignment = ifcopenshell.api.alignment.get_alignment(layout)
curve = ifcopenshell.api.alignment.get_curve(alignment)
if curve:
if layout.is_a("IfcAlignmentHorizontal"):
if curve.is_a("IfcGradientCurve"):
curve = curve.BaseCurve
elif curve.is_a("IfcSegmentedReferenceCurve"):
curve = (
curve.BaseCurve.BaseCurve
) # layout is horizontal and curve is segmented ref ... we want the curve's base curve
elif layout.is_a("IfcAlignmentVertical"):
if curve.is_a("IfcSegmentedReferenceCurve"):
curve = curve.BaseCurve
# the new segment is two from the end... the end segment is zero length
curve_segment = curve.Segments[-2]
settings = ifcopenshell.geom.settings()
segment_fn = ifcopenshell_wrapper.map_shape(settings, curve_segment.wrapped_data)
segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn)
e = segment_evaluator.evaluate(segment_fn.end())
end = np.array(e)
return end
else:
return None
return end
@@ -23,6 +23,7 @@ from ifcopenshell.api.alignment._add_segment_to_curve import _add_segment_to_cur
from ifcopenshell.api.alignment._create_geometric_representation import (
_create_geometric_representation,
)
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
def create_representation(
@@ -34,8 +35,13 @@ def create_representation(
This function is intended to be used when a model has only the semantic definition of an alignment
and you want to add the geometric representation.
If the alignments are complete, it is recommended that add_zero_length_segment is called after this method to ensure
the proper structure of the semantic and geometric definitions of the alignment
If the alignments are complete, it is recommended that add_zero_length_segment is called before this method to ensure
the proper structure of the semantic and geometric definitions of the alignment.
It is presumed that the alignment does not have any geometric representation. However, if the alignment has stationing defined,
the referent defining the stationing is not related to the alignment geometry (it can't be because the geometry doesn't exist yet).
When the geometric representation is created, the referent is updated to have an IfcLinearPlacement that references the basis curve geometry.
This function assumes the referent defines the stationing at the start of the alignment, and therefore sets the IfcLinearPlacement.RelativePlacement.Location.DistanceAlong to 0.0.
:param alignment: The alignment to create the representation.
"""
@@ -51,6 +57,40 @@ def create_representation(
layouts = ifcopenshell.api.alignment.get_alignment_layouts(alignment)
for layout in layouts:
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
layout_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout)
for segment in layout_nest.RelatedObjects:
_add_segment_to_curve(file, segment, curve)
# if the alignment is created without geometry it's stationing referent isn't related to the alignment geometry.
# the stationing referent needs to be updated to have an IfcLinearPlacement that references the basis curve geometry
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
if (
referent_nest
and 0 < len(referent_nest.RelatedObjects)
and referent_nest.RelatedObjects[0].ObjectPlacement
and not referent_nest.RelatedObjects[0].ObjectPlacement.is_a("IfcLinearPlacement")
):
basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
if referent_nest.RelatedObjects[0].ObjectPlacement:
if referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location:
file.remove(referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location)
if referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection:
file.remove(referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection)
file.remove(referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement)
file.remove(referent_nest.RelatedObjects[0].ObjectPlacement)
lp = file.createIfcLinearPlacement(
RelativePlacement=file.createIfcAxis2PlacementLinear(
Location=file.createIfcPointByDistanceExpression(
DistanceAlong=file.createIfcLengthMeasure(0.0),
OffsetLateral=None,
OffsetVertical=None,
OffsetLongitudinal=None,
BasisCurve=basis_curve,
)
)
)
update_fallback_position(file, lp)
referent_nest.RelatedObjects[0].ObjectPlacement = lp
@@ -0,0 +1,51 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell 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.
#
# IfcOpenShell 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 IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from collections.abc import Sequence
from ifcopenshell import entity_instance
import ifcopenshell.api.alignment
from ifcopenshell.api.alignment.get_mapped_segments import _get_curve_segment_count
def get_curve_segment(layout: entity_instance, segment: entity_instance) -> entity_instance:
"""
Returns the IfcCurveSegment associated with the given alignment segment. If the curve segment does not exist, None is returned.
Example:
.. code:: python
horizontal = model.by_type("IfcAlignmentHorizontal")[0]
curve_segment = ifcopenshell.api.alignment.get_curve_segment(horizontal, alignment_segment)
"""
index = 0
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout)
for related_object in segment_nest.RelatedObjects:
if related_object == segment:
break
n = _get_curve_segment_count(related_object)
index += n
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
if curve and index < len(curve.Segments):
return curve.Segments[index]
else:
return None
@@ -0,0 +1,34 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell 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.
#
# IfcOpenShell 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 IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from ifcopenshell import entity_instance
def get_layout(segment: entity_instance) -> entity_instance:
"""
Retrieves the layout to which an alignment segment belongs.
"""
if not segment.is_a("IfcAlignmentSegment"):
raise TypeError(f"Expected entity type to be IfcAlignmentSegment, instead received {segment.is_a()}")
layout = None
nests = segment.Nests
if nests:
layout = nests[0].RelatingObject
return layout
@@ -22,11 +22,11 @@ from ifcopenshell import entity_instance
def get_referent_nest(file: ifcopenshell.file, alignment: entity_instance) -> entity_instance:
"""
Searches for the IfcRelNest that contains IfcReferent. If one is not found, a empty IfcRelNests is created.
Searches for the IfcRelNest that contains IfcReferent.
:param file:
:param alignment: The IfcAlignment which hosts IfcReferent
:return: Returns the IfcRelNests.
:return: Returns the IfcRelNests or None
"""
if not alignment.is_a("IfcAlignment"):
raise TypeError(f"Expected IfcAlignment, instead received {alignment.is_a()}")
@@ -36,5 +36,4 @@ def get_referent_nest(file: ifcopenshell.file, alignment: entity_instance) -> en
if related_object.is_a("IfcReferent"):
return nest
nest = file.createIfcRelNests(GlobalId=ifcopenshell.guid.new(), RelatingObject=alignment, RelatedObjects=[])
return nest
return None
@@ -0,0 +1,90 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell 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.
#
# IfcOpenShell 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 IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import numpy as np
import ifcopenshell
import ifcopenshell.util.placement
from ifcopenshell import entity_instance
def update_end_point(file: ifcopenshell.file, curve: entity_instance):
"""
Updates the IfcGradientCurve.EndPoint and IfcSegmentedReferenceCurve.EndPoint.
If the curve does not have a zero length segment, one is added. The EndPoint is then updated to match the placement of the zero length segment.
:param curve: The gradient curve or segmented reference curve
:return: None
"""
expected_types = ["IfcGradientCurve", "IfcSegmentedReferenceCurve"]
if not curve.is_a() in expected_types:
raise TypeError(
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received '{curve.is_a()}"
)
if not ifcopenshell.api.alignment.has_zero_length_segment(curve):
ifcopenshell.api.alignment.add_zero_length_segment(file, curve)
zero_length_segment = curve.Segments[-1]
if not curve.EndPoint:
if curve.is_a("IfcGradientCurve"):
curve.EndPoint = file.createIfcAxis2Placement2D(
Location=file.createIfcCartesianPoint((0.0, 0.0)),
RefDirection=file.createIfcDirection((1.0, 0.0)),
)
else:
curve.EndPoint = file.createIfcAxis2Placement3D(
Location=file.createIfcCartesianPoint((0.0, 0.0, 0.0)),
RefDirection=file.createIfcDirection((1.0, 0.0, 0.0)),
Axis=file.createIfcDirection((0.0, 0.0, 1.0)),
)
p = np.array(ifcopenshell.util.placement.get_axis2placement(zero_length_segment.Placement))
x = float(p[0, 3])
y = float(p[1, 3])
z = float(p[2, 3])
rx = float(p[0, 0])
ry = float(p[1, 0])
rz = float(p[2, 0])
ax = float(p[0, 2])
ay = float(p[1, 2])
az = float(p[2, 2])
if curve.is_a("IfcGradientCurve"):
curve.EndPoint.Location.Coordinates = (x, y)
if not curve.EndPoint.RefDirection:
curve.EndPoint.RefDirection = file.createIfcDirection((1.0, 0.0))
curve.EndPoint.RefDirection.DirectionRatios = (rx, ry)
else:
curve.EndPoint.Location.Coordinates = (x, y, z)
if not curve.EndPoint.RefDirection:
curve.EndPoint.RefDirection = file.createIfcDirection((1.0, 0.0, 0.0))
if not curve.EndPoint.Axis:
curve.EndPoint.Axis = file.createIfcDirection((0.0, 0.0, 1.0))
curve.EndPoint.RefDirection.DirectionRatios = (rx, ry, rz)
curve.EndPoint.Axis.DirectionRatios = (ax, ay, az)
@@ -34,7 +34,7 @@ def update_fallback_position(file: ifcopenshell.file, lp: entity_instance):
"""
if not lp.CartesianPosition:
lp.CartesianPosition = file.createIfcAxis2Placement3D(Location=file.createIfcCartesianPoint((0.0, 0.0)))
lp.CartesianPosition = file.createIfcAxis2Placement3D(Location=file.createIfcCartesianPoint((0.0, 0.0, 0.0)))
p = np.array(ifcopenshell.util.placement.get_axis2placement(lp.RelativePlacement))
@@ -60,7 +60,7 @@ def evaluate_segment(segment: entity_instance, dist_along: float) -> np.ndarray:
segment_type = segment.is_a().upper()
if not segment_type in supported_segment_types:
raise NotImplementedError(f"Expected entity type 'IFCCURVESEGMENT', got '{segment_type}")
if dist_along > segment.SegmentLength:
if dist_along > abs(segment.SegmentLength.wrappedValue):
raise ValueError(f"Provided value {dist_along=} is beyond the end of the segment ({segment.SegmentLength}).")
s = ifcopenshell.geom.settings()
@@ -33,7 +33,20 @@ from .add_door_representation import add_door_representation
from .add_footprint_representation import add_footprint_representation
from .add_mesh_representation import add_mesh_representation
from .add_profile_representation import add_profile_representation
from .add_railing_representation import add_railing_representation
# add_railing_representation is the pilot for a "pure-compute + IFC-wrap" split:
# compute_wall_mounted_handrail_geometry returns a dataclass with the raw geometry,
# add_railing_representation wraps it into an IfcShapeRepresentation. The split lets
# downstream consumers (Blender gizmo previews, etc.) drive the same math without
# round-tripping through an IFC file. Future add_X_representation work is encouraged
# to follow the same shape — sibling compute_X_geometry function + thin IFC wrapper.
from .add_railing_representation import (
RailingSupport,
TERMINAL_TYPE,
WallMountedHandrailGeometry,
add_railing_representation,
compute_wall_mounted_handrail_geometry,
)
try:
from .add_representation import add_representation
@@ -72,8 +85,12 @@ __all__ = [
"add_door_representation",
"add_footprint_representation",
"add_mesh_representation",
"RailingSupport",
"TERMINAL_TYPE",
"WallMountedHandrailGeometry",
"add_profile_representation",
"add_railing_representation",
"compute_wall_mounted_handrail_geometry",
"add_representation",
"add_shape_aspect",
"add_slab_representation",
@@ -28,6 +28,7 @@ import ifcopenshell.api.geometry
import ifcopenshell.util.unit
from ifcopenshell.api.geometry.add_window_representation import create_ifc_window
from ifcopenshell.util.shape_builder import ShapeBuilder, V
from ifcopenshell.util.unit import mm_to_m as mm
DOOR_TYPE = Literal[
"SINGLE_SWING_LEFT",
@@ -43,11 +44,6 @@ DOOR_TYPE = Literal[
SUPPORTED_DOOR_TYPES = get_args(DOOR_TYPE)
def mm(x: float) -> float:
"""mm to meters shortcut for readability"""
return x / 1000
def create_ifc_door_lining(
builder: ShapeBuilder, size: np.ndarray, thickness: Union[list[float], float], position: Optional[np.ndarray] = None
) -> ifcopenshell.entity_instance:
@@ -16,18 +16,21 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from dataclasses import dataclass, field
from math import cos, pi, radians, sin, tan
from typing import Any, Literal, Optional
from typing import Callable, Literal, Optional
import numpy as np
from typing_extensions import assert_never
import ifcopenshell.util.unit
from ifcopenshell.util.shape_builder import (
NP_XY,
NP_YX,
NP_Z,
PRECISION,
SequenceOfVectors,
ShapeBuilder,
V,
is_x,
np_angle,
np_angle_signed,
np_intersect_line_line,
@@ -36,12 +39,7 @@ from ifcopenshell.util.shape_builder import (
np_normalized,
np_to_3d,
)
def mm(x: float) -> float:
"""mm to meters shortcut for readability"""
return x / 1000
from ifcopenshell.util.unit import mm_to_m as mm
TERMINAL_TYPE = Literal[
"180",
@@ -49,15 +47,524 @@ TERMINAL_TYPE = Literal[
"TO_WALL",
"TO_FLOOR",
"TO_END_POST_AND_FLOOR",
"NONE",
]
# Geometric design constants for the WALL_MOUNTED_HANDRAIL railing type (millimetres).
TERMINAL_RADIUS_MM = 150
HANDRAIL_FILLET_RADIUS_MM = 100
SUPPORT_ARC_RADIUS_MM = 10
SUPPORT_DISK_DEPTH_MM = 20
# Default parameter values for ``add_railing_representation`` (millimetres).
DEFAULT_SUPPORT_SPACING_MM = 1000
DEFAULT_RAILING_DIAMETER_MM = 50
DEFAULT_CLEAR_WIDTH_MM = 40
DEFAULT_HEIGHT_MM = 1000
@dataclass(slots=True)
class RailingSupport:
"""Pure-geometry description of a single wall-mount support.
A support consists of:
- A 3-point polyline (base at the handrail, mid-arc, floor end)
swept into a cylinder of radius ``arc_radius``.
- A short disk extrusion (wall-attachment plate) at the floor end.
All values are in IFC project units.
"""
arc_polyline: np.ndarray # shape (3, 3)
arc_radius: float
disk_position: np.ndarray # shape (3,) — equal to arc_polyline[-1]
disk_radius: float
disk_depth: float
disk_z_rotation: float # rotation around Z applied to the disk's "Y" extrude axis
@dataclass(slots=True)
class WallMountedHandrailGeometry:
"""Pure-geometry description of a wall-mounted handrail.
Decoupled from any IFC entity creation. The shared data structure is
consumed by the IFC-representation wrapper and by viewport-only previews
in authoring add-ons that need to update mesh state without mutating the
IFC file.
All values are in IFC project units.
"""
handrail_polyline: np.ndarray # shape (N, 3)
handrail_arc_point_indices: list[int]
handrail_radius: float
supports: list[RailingSupport] = field(default_factory=list)
_Z_DOWN = V(0, 0, -1)
_ARC_MIDDLE_POINT_COS = sin(radians(45))
@dataclass(frozen=True)
class _RailingDims:
"""Derived dimensions for a wall-mounted-handrail compute pass.
All values are in IFC project units.
"""
railing_radius: float
height_below_handrail: float
terminal_radius: float
fillet_radius: float
support_spacing: float
support_length: float
support_arc_radius: float
support_disk_radius: float
support_disk_depth: float
clear_width: float
cap_type: TERMINAL_TYPE
def _collinear(d0: np.ndarray, d1: np.ndarray) -> bool:
# Cross-product magnitude is linear near zero, so the test stays
# numerically stable for near-parallel unit vectors. The natural
# arccos(dot) formulation is not stable here: sub-ulp overshoot of
# dot past 1.0 returns NaN, which would silently break the fillet
# on straight subdivided edges. Anti-parallel vectors also collapse
# |d0 × d1| to 0 — and that "no usable turn" outcome is what the
# fillet caller wants, so we treat it as collinear too.
return bool(np.linalg.norm(np.cross(d0, d1)) < PRECISION)
def _get_fillet_points(v0: np.ndarray, v1: np.ndarray, v2: np.ndarray, radius: float) -> list[np.ndarray]:
"""Fillet arc points between edges v0v1 and v1v2.
Raises ``ZeroDivisionError`` / ``FloatingPointError`` (and may return
NaN/inf points) on numerically degenerate input callers that may
receive degenerate input must guard.
"""
dir1 = np_normalized(v0 - v1)
dir2 = np_normalized(v2 - v1)
edge_angle = np_angle(dir1, dir2)
slide_distance = radius / tan(edge_angle / 2)
fillet_v1co = v1 + (dir1 * slide_distance)
fillet_v2co = v1 + (dir2 * slide_distance)
normal = np_normal([v0, v1, v2])
center = np_intersect_line_line(
fillet_v1co,
fillet_v1co + np.cross(normal, dir1),
fillet_v2co,
fillet_v2co + np.cross(normal, dir2),
)[0]
dir_ = np_normalized(np_lerp(fillet_v1co, fillet_v2co, 0.5) - center)
midpointco = center + dir_ * radius
return [fillet_v1co, midpointco, fillet_v2co]
def _make_support(point: np.ndarray, railing_direction: np.ndarray, dims: _RailingDims) -> RailingSupport:
"""Build a pure-geometry support description from a point + railing direction."""
ortho_dir = railing_direction[NP_YX] * (1, -1)
ortho_dir = np_normalized(np_to_3d(ortho_dir))
arc_center = point + ortho_dir * dims.support_length
support_points = V(
[
point,
arc_center - ortho_dir * dims.support_length * cos(pi / 4) + _Z_DOWN * dims.support_length * sin(pi / 4),
arc_center + _Z_DOWN * dims.support_length,
]
)
angle = np_angle_signed((0, 1), ortho_dir[NP_XY])
return RailingSupport(
arc_polyline=support_points,
arc_radius=dims.support_arc_radius,
disk_position=support_points[-1],
disk_radius=dims.support_disk_radius,
disk_depth=dims.support_disk_depth,
disk_z_rotation=angle,
)
def _add_arcs_on_turning_points(
base_points: np.ndarray, dims: _RailingDims, looped_path: bool
) -> tuple[np.ndarray, list[np.ndarray]]:
"""Add 3-point fillet arcs on turning points of the railing path.
Returns ``(polyline_with_arcs, arc_midpoints)``.
"""
arc_points: list[np.ndarray] = []
if len(base_points) < 3:
return base_points, arc_points
# looking for turning points by checking non-collinear edges
output_points: list[np.ndarray] = list(base_points[:1])
prev_dir = np_normalized(base_points[1] - base_points[0])
i = 1
while i < len(base_points) - 1:
cur_dir = np_normalized(base_points[i + 1] - base_points[i])
# Treat NaN cur_dir (zero-length edge → np_normalized of zero) as
# collinear: a coincident path vertex carries no turn information,
# so the safest fallback is "stay on the previous direction".
cur_dir_is_nan = bool(np.any(np.isnan(cur_dir)))
if cur_dir_is_nan or _collinear(cur_dir, prev_dir):
output_points.append(base_points[i])
else:
# User-supplied railing paths can produce numerically degenerate
# turns (anti-parallel directions, nearly-collinear triangle,
# zero-length edges from coincident vertices). Falling back to a
# sharp turn at the original vertex keeps the rest of the
# polyline real-valued instead of poisoning it with NaN.
fillet_points: Optional[list[np.ndarray]]
try:
fillet_points = _get_fillet_points(
base_points[i - 1], base_points[i], base_points[i + 1], dims.fillet_radius
)
except (ZeroDivisionError, FloatingPointError):
fillet_points = None
else:
if any(np.any(np.isnan(fp)) or np.any(np.isinf(fp)) for fp in fillet_points):
fillet_points = None
if fillet_points is None:
output_points.append(base_points[i])
else:
output_points.extend(fillet_points)
arc_points.append(fillet_points[1])
# Only advance prev_dir when cur_dir is well-defined — keeping a
# NaN prev_dir would cascade through every subsequent collinearity
# check.
if not cur_dir_is_nan:
prev_dir = cur_dir
i = i + 1
if looped_path:
output_points[0] = output_points[-1]
else:
output_points.append(base_points[-1])
return V(output_points), arc_points
def _collect_supports(coords: np.ndarray, manual_supports: bool, dims: _RailingDims) -> list[RailingSupport]:
"""Build the list of supports for the railing path."""
supports: list[RailingSupport] = []
# simplified_coords is a list of points that form non-collinear edges
simplified_coords: list[np.ndarray] = [coords[0]]
prev_dir = np_normalized(coords[1] - coords[0])
# iterating over each edge of the railing path
for i in range(1, len(coords) - 1):
cur_dir = np_normalized(coords[i + 1] - coords[i])
if not _collinear(cur_dir, prev_dir):
simplified_coords.append(coords[i])
prev_dir = cur_dir
# for manual supports each vertex on the railing path edge
# will be a point for a support
elif manual_supports:
supports.append(_make_support(coords[i], cur_dir, dims))
simplified_coords.append(coords[-1])
if manual_supports:
return supports
# create automatic supports based on the support spacing
for i in range(len(simplified_coords) - 1):
v0, v1 = simplified_coords[i : i + 2]
edge = v1 - v0
length: float = np.linalg.norm(edge)
edge_dir = np_normalized(edge)
n_supports, support_offset = divmod(length, dims.support_spacing)
n_supports = int(n_supports) + 1
support_offset /= 2
start_position = v0 + support_offset * edge_dir
for support_i in range(n_supports):
support_position = start_position + support_i * dims.support_spacing * edge_dir
supports.append(_make_support(support_position, edge, dims))
return supports
# Per-cap-type builders. Each takes the cap-frame inputs (precomputed by the
# dispatcher) and returns ``(cap_coords, new_arc_points)``. The shared
# orientation flip and final ``np.vstack`` live in the dispatcher so the
# builders stay focused on the geometric shape of their cap.
_CapBuilder = Callable[
[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, "_RailingDims"],
tuple[list[np.ndarray], list[np.ndarray]],
]
def _cap_180(
railing_coords_for_cap: np.ndarray,
start_point: np.ndarray,
cap_dir: np.ndarray,
ortho_dir: np.ndarray,
local_z_down: np.ndarray,
dims: "_RailingDims",
) -> tuple[list[np.ndarray], list[np.ndarray]]:
arc_point = start_point + cap_dir * dims.terminal_radius + dims.terminal_radius * local_z_down
cap_coords = [arc_point, start_point + dims.terminal_radius * 2 * local_z_down]
return cap_coords, [arc_point]
def _cap_to_end_post(
railing_coords_for_cap: np.ndarray,
start_point: np.ndarray,
cap_dir: np.ndarray,
ortho_dir: np.ndarray,
local_z_down: np.ndarray,
dims: "_RailingDims",
) -> tuple[list[np.ndarray], list[np.ndarray]]:
arc_point = start_point + cap_dir * dims.terminal_radius + dims.terminal_radius * local_z_down
end_point = railing_coords_for_cap[-2].copy()
end_point[NP_Z] -= dims.terminal_radius * 2
cap_coords = [arc_point, start_point + dims.terminal_radius * 2 * local_z_down, end_point]
return cap_coords, [arc_point]
def _cap_to_wall(
railing_coords_for_cap: np.ndarray,
start_point: np.ndarray,
cap_dir: np.ndarray,
ortho_dir: np.ndarray,
local_z_down: np.ndarray,
dims: "_RailingDims",
) -> tuple[list[np.ndarray], list[np.ndarray]]:
arc_point = (
start_point
+ cap_dir * dims.clear_width * _ARC_MIDDLE_POINT_COS
+ ortho_dir * dims.clear_width * (1 - _ARC_MIDDLE_POINT_COS)
)
cap_coords = [arc_point, start_point + ortho_dir * dims.clear_width + cap_dir * dims.clear_width]
return cap_coords, [arc_point]
def _cap_to_floor(
railing_coords_for_cap: np.ndarray,
start_point: np.ndarray,
cap_dir: np.ndarray,
ortho_dir: np.ndarray,
local_z_down: np.ndarray,
dims: "_RailingDims",
) -> tuple[list[np.ndarray], list[np.ndarray]]:
arc_point = (
start_point
+ cap_dir * dims.terminal_radius * _ARC_MIDDLE_POINT_COS
+ _Z_DOWN * dims.terminal_radius * (1 - _ARC_MIDDLE_POINT_COS)
)
arc_end = start_point + cap_dir * dims.terminal_radius + dims.terminal_radius * _Z_DOWN
cap_coords = [
arc_point,
arc_end,
arc_end + _Z_DOWN * (dims.height_below_handrail - dims.terminal_radius),
]
return cap_coords, [arc_point]
def _cap_to_end_post_and_floor(
railing_coords_for_cap: np.ndarray,
start_point: np.ndarray,
cap_dir: np.ndarray,
ortho_dir: np.ndarray,
local_z_down: np.ndarray,
dims: "_RailingDims",
) -> tuple[list[np.ndarray], list[np.ndarray]]:
first_arc_end = start_point + cap_dir * dims.terminal_radius + dims.terminal_radius * local_z_down
first_arc_coords = _get_fillet_points(
start_point, start_point + cap_dir * dims.terminal_radius, first_arc_end, dims.terminal_radius
)
end_point = railing_coords_for_cap[-2].copy()
end_point[NP_Z] -= dims.height_below_handrail
second_arc_coords = _get_fillet_points(
first_arc_end, first_arc_end + local_z_down * dims.terminal_radius, end_point, dims.terminal_radius
)
cap_coords = [start_point] + first_arc_coords + second_arc_coords + [end_point]
return cap_coords, [first_arc_coords[1], second_arc_coords[1]]
# Dispatch table for handrail terminal caps. "NONE" stays out of this table:
# every other cap type appends real geometry to the polyline, so a "NONE" slot
# would need an awkward empty-vstack contract — the dispatcher early-returns
# unchanged instead.
_CAP_BUILDERS: dict[TERMINAL_TYPE, _CapBuilder] = {
"180": _cap_180,
"TO_END_POST": _cap_to_end_post,
"TO_WALL": _cap_to_wall,
"TO_FLOOR": _cap_to_floor,
"TO_END_POST_AND_FLOOR": _cap_to_end_post_and_floor,
}
def _add_cap(
railing_coords: np.ndarray,
arc_points_list: list[np.ndarray],
start: bool,
dims: _RailingDims,
) -> tuple[np.ndarray, list[np.ndarray]]:
"""Add a handrail terminal cap at one end of the railing.
Returns the inputs unchanged when ``dims.cap_type == "NONE"``.
"""
if dims.cap_type == "NONE":
return railing_coords, arc_points_list
railing_coords_for_cap = railing_coords[::-1] if start else railing_coords
arc_points_list = arc_points_list[::-1] if start else arc_points_list
start_point: np.ndarray = railing_coords_for_cap[-1]
cap_dir = np_normalized(railing_coords_for_cap[-1] - railing_coords_for_cap[-2])
ortho_dir = np_normalized(np_to_3d(cap_dir[NP_YX] * (1, -1)))
local_z_down = np.cross(cap_dir, ortho_dir)
if start:
ortho_dir = -ortho_dir
cap_coords, new_arc_points = _CAP_BUILDERS[dims.cap_type](
railing_coords_for_cap, start_point, cap_dir, ortho_dir, local_z_down, dims
)
arc_points_list.extend(new_arc_points)
railing_coords = np.vstack((railing_coords_for_cap, cap_coords))
if start:
railing_coords = railing_coords[::-1]
arc_points_list = arc_points_list[::-1]
return railing_coords, arc_points_list
def _get_arc_indices(points: np.ndarray, arc_pts: list[np.ndarray]) -> list[int]:
points_ = points.copy()
arc_indices = []
i_base = 0
for arc_point in arc_pts:
for i, point in enumerate(points_):
if np.allclose(arc_point, point):
current_index = i + i_base
arc_indices.append(current_index)
i_base = current_index + 1
break
else:
raise Exception(
f"Arc point '{arc_point}' is not present in points:\n{points_}\nFull points data:\n{points}"
)
points_ = points_[i + 1 :]
return arc_indices
def compute_wall_mounted_handrail_geometry(
*,
railing_path: SequenceOfVectors,
support_spacing: float,
railing_diameter: float,
clear_width: float,
height: float,
use_manual_supports: bool = False,
terminal_type: TERMINAL_TYPE = "180",
looped_path: bool = False,
unit_scale: float = 1.0,
) -> WallMountedHandrailGeometry:
"""Compute pure geometric data for a wall-mounted handrail.
The result can be wrapped into an ``IfcShapeRepresentation`` by the
railing-representation API, or converted directly to a Blender bmesh
(or any other viewport mesh) for a live preview that does not mutate
the IFC file.
Geometric inputs (``railing_path``, ``support_spacing``,
``railing_diameter``, ``clear_width``, ``height``) are expected in IFC
project units. ``unit_scale`` is used only to convert hard-coded
millimetre constants (fillet radius, support rod radius, etc.) into
project units.
Constraints:
- ``railing_path`` must contain at least 2 points.
- ``railing_diameter`` must be > 0.
- ``height`` must be ``railing_diameter / 2`` (otherwise the
``TO_FLOOR`` / ``TO_END_POST_AND_FLOOR`` caps extrude upward
instead of down).
- ``clear_width`` must be > 0 (otherwise the support wraps backward
into the wall).
:param railing_path: Sequence of 3D points along the top of the
handrail (not the centre).
:param support_spacing: Distance between automatic supports.
:param railing_diameter: Handrail tube diameter.
:param clear_width: Clear gap between the wall and the handrail tube.
:param height: Total railing height (top of handrail to floor).
:param use_manual_supports: If true, one support is placed on every
non-collinear vertex of ``railing_path``; if false, supports are
distributed automatically by ``support_spacing``.
:param terminal_type: Style of the terminal end cap, or ``"NONE"`` for
no cap. Ignored when ``looped_path=True`` (no open ends to cap).
:param looped_path: If true, the railing closes on its first point.
:param unit_scale: Output of
:func:`ifcopenshell.util.unit.calculate_unit_scale`. Defaults to
1.0 (i.e. inputs are already in metres).
"""
railing_radius = railing_diameter / 2
# for calculations purposes we use height without railing radius
height_below_handrail = height - railing_radius
railing_coords: np.ndarray = np.subtract(railing_path, _Z_DOWN * railing_radius)
dims = _RailingDims(
railing_radius=railing_radius,
height_below_handrail=height_below_handrail,
terminal_radius=mm(TERMINAL_RADIUS_MM) / unit_scale,
fillet_radius=mm(HANDRAIL_FILLET_RADIUS_MM) / unit_scale,
support_spacing=support_spacing,
support_length=clear_width + railing_radius,
support_arc_radius=mm(SUPPORT_ARC_RADIUS_MM) / unit_scale,
support_disk_radius=railing_radius,
support_disk_depth=mm(SUPPORT_DISK_DEPTH_MM) / unit_scale,
clear_width=clear_width,
cap_type=terminal_type,
)
# need to add first two points to the path
# to create the turning arcs and supports on the last segment of the loop
if looped_path:
railing_coords = np.vstack((railing_coords, railing_coords[:2]))
supports = _collect_supports(railing_coords, use_manual_supports, dims)
railing_coords, arc_points = _add_arcs_on_turning_points(railing_coords, dims, looped_path)
if not looped_path:
railing_coords, arc_points = _add_cap(railing_coords, arc_points, start=True, dims=dims)
railing_coords, arc_points = _add_cap(railing_coords, arc_points, start=False, dims=dims)
return WallMountedHandrailGeometry(
handrail_polyline=railing_coords,
handrail_arc_point_indices=_get_arc_indices(railing_coords, arc_points),
handrail_radius=railing_radius,
supports=supports,
)
def _resolve_default_mm(value: Optional[float], default_mm: float, unit_scale: float) -> float:
"""Resolve an optional millimetre-defaulted parameter into project units.
Callers pass ``value`` as the user-supplied override (or ``None``) and
``default_mm`` as the integer millimetre default; the result is in project
units (``mm/1000 / unit_scale``).
"""
if value is not None:
return value
return mm(default_mm) / unit_scale
def add_railing_representation(
file: ifcopenshell.file,
*, # keywords only as this API implementation is probably not final
# IfcGeometricRepresentationContext
context: ifcopenshell.entity_instance,
railing_type: Literal["WALL_MOUNTED_HANDRAIL"] = "WALL_MOUNTED_HANDRAIL",
railing_path: SequenceOfVectors,
use_manual_supports: bool = False,
support_spacing: Optional[float] = None,
@@ -72,7 +579,6 @@ def add_railing_representation(
Units are expected to be in IFC project units.
:param context: IfcGeometricRepresentationContext for the representation.
:param railing_type: Type of the railing. Defaults to "WALL_MOUNTED_HANDRAIL".
:param railing_path: A list of points coordinates for the railing path,
coordinates are expected to be at the top of the railing, not at the center.
If not provided, default path [(0, 0, 1), (1, 0, 1), (2, 0, 1)] (in meters) will be used
@@ -81,7 +587,7 @@ def add_railing_representation(
:param support_spacing: Distance between supports if automatic supports are used. Defaults to 1m.
:param railing_diameter: Railing diameter. Defaults to 50mm.
:param clear_width: Clear width between the railing and the wall. Defaults to 40mm.
:param terminal_type: type of the cap. Defaults to "180".
:param terminal_type: type of the cap, or "NONE" for no cap. Defaults to "180".
:param height: defaults to 1m
:param looped_path: Whether to end the railing on the first point of `railing_path`. Defaults to False.
:param unit_scale: The unit scale as calculated by
@@ -89,317 +595,51 @@ def add_railing_representation(
will be automatically calculated for you.
:return: IfcShapeRepresentation for a railing.
"""
usecase = Usecase()
usecase.file = file
# define unit_scale first as it's going to be used setting default arguments
settings: dict[str, Any] = {
"unit_scale": ifcopenshell.util.unit.calculate_unit_scale(file) if unit_scale is None else unit_scale,
}
settings.update(
{
"context": context,
"railing_type": railing_path,
"railing_path": (
railing_path
if railing_path is not None
else usecase.path_si_to_units(V([(0, 0, 1), (1, 0, 1), (2, 0, 1)]))
),
"use_manual_supports": use_manual_supports,
"support_spacing": support_spacing if support_spacing is not None else usecase.convert_si_to_unit(mm(1000)),
"railing_diameter": (
railing_diameter if railing_diameter is not None else usecase.convert_si_to_unit(mm(50))
),
"clear_width": clear_width if clear_width is not None else usecase.convert_si_to_unit(mm(40)),
"terminal_type": terminal_type,
"height": height if height is not None else usecase.convert_si_to_unit(mm(1000)),
"looped_path": looped_path,
}
if unit_scale is None:
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
if railing_path is None:
railing_path = V([(0, 0, 1), (1, 0, 1), (2, 0, 1)]) / unit_scale
support_spacing = _resolve_default_mm(support_spacing, DEFAULT_SUPPORT_SPACING_MM, unit_scale)
railing_diameter = _resolve_default_mm(railing_diameter, DEFAULT_RAILING_DIAMETER_MM, unit_scale)
clear_width = _resolve_default_mm(clear_width, DEFAULT_CLEAR_WIDTH_MM, unit_scale)
height = _resolve_default_mm(height, DEFAULT_HEIGHT_MM, unit_scale)
geometry = compute_wall_mounted_handrail_geometry(
railing_path=railing_path,
use_manual_supports=use_manual_supports,
support_spacing=support_spacing,
railing_diameter=railing_diameter,
clear_width=clear_width,
terminal_type=terminal_type,
height=height,
looped_path=looped_path,
unit_scale=unit_scale,
)
usecase.settings = settings
if railing_type != "WALL_MOUNTED_HANDRAIL":
raise Exception('Only "WALL_MOUNTED_HANDRAIL" railing_type is supported at the moment.')
return usecase.execute()
builder = ShapeBuilder(file)
items_3d: list[ifcopenshell.entity_instance] = []
for support in geometry.supports:
support_polyline = builder.polyline(support.arc_polyline, closed=False, arc_points=(1,))
items_3d.append(builder.create_swept_disk_solid(support_polyline, support.arc_radius))
class Usecase:
file: ifcopenshell.file
settings: dict[str, Any]
def execute(self):
arc_points: list[np.ndarray] = []
items_3d: list[ifcopenshell.entity_instance] = []
builder = ShapeBuilder(self.file)
z_down = V(0, 0, -1)
# measurements
# from settings
use_manual_supports: bool = self.settings["use_manual_supports"]
railing_radius: float = self.settings["railing_diameter"] / 2
support_spacing: float = self.settings["support_spacing"]
clear_width: float = self.settings["clear_width"]
# for calculations purposes we use height without railing radius
height: float = self.settings["height"] - railing_radius
cap_type: TERMINAL_TYPE = self.settings["terminal_type"]
ifc_context: ifcopenshell.entity_instance = self.settings["context"]
railing_coords: SequenceOfVectors = self.settings["railing_path"]
looped_path: bool = self.settings["looped_path"]
railing_coords: np.ndarray
railing_coords = np.subtract(railing_coords, z_down * railing_radius)
# constant
terminal_radius = self.convert_si_to_unit(mm(150))
railing_fillet_radius = self.convert_si_to_unit(mm(100))
support_length = clear_width + railing_radius
support_radius = self.convert_si_to_unit(mm(10))
support_disk_radius = railing_radius
support_disk_depth = self.convert_si_to_unit(mm(20))
# util functions
def collinear(d0: np.ndarray, d1: np.ndarray) -> bool:
return is_x(np_angle(d0, d1), 0)
np_Z = 2
np_XY = slice(2)
np_YX = [1, 0]
def add_support_on_point(
point: np.ndarray, railing_direction: np.ndarray
) -> tuple[ifcopenshell.entity_instance, ...]:
"""create a support arc and a disk based on the position and direction of the railing"""
ortho_dir = railing_direction[np_YX] * (1, -1)
ortho_dir = np_normalized(np_to_3d(ortho_dir))
arc_center = point + ortho_dir * support_length
support_points: list[np.ndarray] = [
point,
arc_center - ortho_dir * support_length * cos(pi / 4) + z_down * support_length * sin(pi / 4),
arc_center + z_down * support_length,
]
polyline = builder.polyline(support_points, closed=False, arc_points=(1,))
solid = builder.create_swept_disk_solid(polyline, support_radius)
support_disk_circle = builder.circle(radius=support_disk_radius)
angle = np_angle_signed((0, 1), ortho_dir[np_XY])
y_extrusion_kwargs = builder.rotate_extrusion_kwargs_by_z(builder.extrude_kwargs("Y"), angle)
support_disk = builder.extrude(
support_disk_circle, support_disk_depth, position=support_points[-1], **y_extrusion_kwargs
disk_circle = builder.circle(radius=support.disk_radius)
y_extrusion_kwargs = builder.rotate_extrusion_kwargs_by_z(builder.extrude_kwargs("Y"), support.disk_z_rotation)
items_3d.append(
builder.extrude(
disk_circle,
support.disk_depth,
position=support.disk_position,
**y_extrusion_kwargs,
)
return (solid, support_disk)
def get_fillet_points(v0: np.ndarray, v1: np.ndarray, v2: np.ndarray, radius: float) -> list[np.ndarray]:
"""get fillet points between edges v0v1 and v1v2"""
dir1 = np_normalized(v0 - v1)
dir2 = np_normalized(v2 - v1)
edge_angle = np_angle(dir1, dir2)
slide_distance = radius / tan(edge_angle / 2)
fillet_v1co = v1 + (dir1 * slide_distance)
fillet_v2co = v1 + (dir2 * slide_distance)
normal = np_normal([v0, v1, v2])
center = np_intersect_line_line(
fillet_v1co,
fillet_v1co + np.cross(normal, dir1),
fillet_v2co,
fillet_v2co + np.cross(normal, dir2),
)[0]
dir_ = np_normalized(np_lerp(fillet_v1co, fillet_v2co, 0.5) - center)
midpointco = center + dir_ * radius
return [fillet_v1co, midpointco, fillet_v2co]
def add_arcs_on_turnings_points(base_points: np.ndarray) -> np.ndarray:
"""add 3 point fillet arcs on turning points of the railing path"""
if len(base_points) < 3:
return base_points
# looking for turning points by checking non-collinear edges
output_points: list[np.ndarray] = list(base_points[:1])
prev_dir = np_normalized(base_points[1] - base_points[0])
i = 1
while i < len(base_points) - 1:
cur_dir = np_normalized(base_points[i + 1] - base_points[i])
if collinear(cur_dir, prev_dir):
output_points.append(base_points[i])
else:
fillet_points = get_fillet_points(
base_points[i - 1], base_points[i], base_points[i + 1], railing_fillet_radius
)
output_points.extend(fillet_points)
arc_points.append(fillet_points[1])
prev_dir = cur_dir
i = i + 1
if looped_path:
output_points[0] = output_points[-1]
else:
output_points.append(base_points[-1])
return V(output_points)
def create_supports_items(
railing_coords: np.ndarray, manual_supports: bool = False
) -> list[ifcopenshell.entity_instance]:
"""create supports items based on the railing coordinates"""
supports_items: list[ifcopenshell.entity_instance] = []
# simplified_coords is a list of points that form non-collinear edges
simplified_coords: list[np.ndarray] = [railing_coords[0]]
prev_dir = np_normalized(railing_coords[1] - railing_coords[0])
# iterating over each edge of the railing path
for i in range(1, len(railing_coords) - 1):
cur_dir = np_normalized(railing_coords[i + 1] - railing_coords[i])
if not collinear(cur_dir, prev_dir):
simplified_coords.append(railing_coords[i])
prev_dir = cur_dir
# for manual supports each vertex on the railing path edge
# will be a point for a support
elif manual_supports:
supports_items.extend(add_support_on_point(point=railing_coords[i], railing_direction=cur_dir))
simplified_coords.append(railing_coords[-1])
if manual_supports:
return supports_items
# create automatic supports based on the support spacing
for i in range(0, len(simplified_coords) - 1):
v0, v1 = simplified_coords[i : i + 2]
edge = v1 - v0
length: float = np.linalg.norm(edge)
edge_dir = np_normalized(edge)
n_supports, support_offset = divmod(length, support_spacing)
n_supports = int(n_supports) + 1
support_offset /= 2
start_position = v0 + support_offset * edge_dir
for support_i in range(n_supports):
support_position = start_position + support_i * support_spacing * edge_dir
supports_items.extend(add_support_on_point(point=support_position, railing_direction=edge))
return supports_items
def add_cap(railing_coords: np.ndarray, arc_points: list[np.ndarray], start: bool = False):
"""add handrail terminal cap"""
railing_coords_for_cap = railing_coords[::-1] if start else railing_coords
arc_points = arc_points[::-1] if start else arc_points
start_point: np.ndarray = railing_coords_for_cap[-1]
cap_dir = railing_coords_for_cap[-1] - railing_coords_for_cap[-2]
cap_dir = np_normalized(cap_dir)
ortho_dir = np_to_3d(cap_dir[np_YX] * (1, -1))
ortho_dir = np_normalized(ortho_dir)
local_z_down = np.cross(cap_dir, ortho_dir)
if start:
ortho_dir = -ortho_dir
arc_middle_point_cos = sin(radians(45))
if cap_type in ("180", "TO_END_POST"):
arc_point = start_point + cap_dir * terminal_radius + terminal_radius * local_z_down
arc_points.append(arc_point)
cap_coords = [arc_point, start_point + terminal_radius * 2 * local_z_down]
if cap_type == "TO_END_POST":
end_point = railing_coords_for_cap[-2].copy()
end_point[np_Z] -= terminal_radius * 2
cap_coords.append(end_point)
elif cap_type == "TO_WALL":
arc_point = (
start_point
+ cap_dir * clear_width * arc_middle_point_cos
+ ortho_dir * clear_width * (1 - arc_middle_point_cos)
)
arc_points.append(arc_point)
cap_coords = [arc_point, start_point + ortho_dir * clear_width + cap_dir * clear_width]
elif cap_type == "TO_FLOOR":
arc_point = (
start_point
+ cap_dir * terminal_radius * arc_middle_point_cos
+ z_down * terminal_radius * (1 - arc_middle_point_cos)
)
arc_points.append(arc_point)
arc_end = start_point + cap_dir * terminal_radius + terminal_radius * z_down
cap_coords = [
arc_point,
arc_end,
arc_end + z_down * (height - terminal_radius),
]
elif cap_type == "TO_END_POST_AND_FLOOR":
first_arc_end = start_point + cap_dir * terminal_radius + terminal_radius * local_z_down
first_arc_coords = get_fillet_points(
start_point, start_point + cap_dir * terminal_radius, first_arc_end, terminal_radius
)
arc_points.append(first_arc_coords[1])
end_point = railing_coords_for_cap[-2].copy()
end_point[np_Z] -= height
second_arc_coords = get_fillet_points(
first_arc_end, first_arc_end + local_z_down * terminal_radius, end_point, terminal_radius
)
arc_points.append(second_arc_coords[1])
cap_coords = [start_point] + first_arc_coords + second_arc_coords + [end_point]
else:
assert_never(cap_type)
railing_coords = np.vstack((railing_coords_for_cap, cap_coords))
if start:
railing_coords = railing_coords[::-1]
arc_points = arc_points[::-1]
return railing_coords, arc_points
# need to add first two points to the path
# to create the turning arcs and supports on the last segment of the loop
if looped_path:
railing_coords = np.vstack((railing_coords, railing_coords[:2]))
items_3d.extend(create_supports_items(railing_coords, manual_supports=use_manual_supports))
railing_coords = add_arcs_on_turnings_points(railing_coords)
if not looped_path and cap_type != "NONE":
railing_coords, arc_points = add_cap(railing_coords, arc_points, start=True)
railing_coords, arc_points = add_cap(railing_coords, arc_points, start=False)
def get_arc_indices(points: np.ndarray, arc_points: list[np.ndarray]) -> list[int]:
points_ = points.copy()
arc_indices = []
i_base = 0
for arc_point in arc_points:
for i, point in enumerate(points_):
if np.allclose(arc_point, point):
current_index = i + i_base
arc_indices.append(current_index)
i_base = current_index + 1
break
else:
raise Exception(
f"Arc point '{arc_point}' is not present in points:\n{points_}\nFull points data:\n{points}"
)
points_ = points_[i + 1 :]
return arc_indices
railing_path = builder.polyline(
railing_coords,
closed=False,
arc_points=get_arc_indices(railing_coords, arc_points),
)
railing_solid = builder.create_swept_disk_solid(railing_path, railing_radius)
items_3d.append(railing_solid)
representation = builder.get_representation(ifc_context, items=items_3d)
return representation
def convert_si_to_unit(self, value: float) -> float:
return value / self.settings["unit_scale"]
railing_path_entity = builder.polyline(
geometry.handrail_polyline,
closed=False,
arc_points=geometry.handrail_arc_point_indices,
)
items_3d.append(builder.create_swept_disk_solid(railing_path_entity, geometry.handrail_radius))
def path_si_to_units(self, path: np.ndarray) -> np.ndarray:
"""converts list of vectors from SI to ifc project units"""
return path / self.settings["unit_scale"]
return builder.get_representation(context, items=items_3d)
@@ -27,6 +27,7 @@ import numpy as np
import ifcopenshell.api.geometry
import ifcopenshell.util.unit
from ifcopenshell.util.shape_builder import ShapeBuilder, V
from ifcopenshell.util.unit import mm_to_m as mm
# SCHEMAS describe panels setup
# where:
@@ -59,11 +60,6 @@ DEFAULT_PANEL_SCHEMAS = {
}
def mm(x: float) -> float:
"""mm to meters shortcut for readability"""
return x / 1000
def create_ifc_window_frame_simple(
builder: ShapeBuilder, size: np.ndarray, thickness: Union[list[float], float], position: Optional[np.ndarray] = None
) -> list[ifcopenshell.entity_instance]:
+4 -1
View File
@@ -538,7 +538,10 @@ def main(
*(tup for i, tup in enumerate(zip(path_objects, section_polies, polies)) if has_relevant_zone(i))
)
arranged = W.arrange_polygons(*filter(None, (ARRANGE_POLYGON_SETTINGS,)), polies)
arranged = W.arrange_polygons(
*filter(None, (ARRANGE_POLYGON_SETTINGS,)),
polies, # ty: ignore[too-many-positional-arguments]
)
svg_data_3 = W.polygons_to_svg(arranged, False)
dom3 = parseString(svg_data_3)
svg3 = dom3.childNodes[0]
@@ -1695,7 +1695,7 @@ class type_declaration(declaration):
class uninitialized_tag: ...
def arrange_polygons(polygons): ...
def arrange_polygons(settings, polygons): ...
def clear_schemas(): ...
def construct_iterator(geometry_library, settings, file, num_threads): ...
def construct_iterator_with_include_exclude(geometry_library, settings, file, elems, include, num_threads): ...
@@ -35,6 +35,15 @@ import ifcopenshell.util.unit
PRECISION = 1.0e-5
# Numpy axis-index helpers for 3D coordinates. Use these instead of redefining
# local copies in every geometry-builder module — they index ``np.ndarray``
# vectors of shape ``(3,)`` or ``(N, 3)``.
NP_X, NP_Y, NP_Z = 0, 1, 2
NP_XY = slice(2)
NP_XZ = [0, 2]
NP_YZ = [1, 2]
NP_YX = [1, 0]
if TYPE_CHECKING:
# NOTE: mathutils is never used at runtime in ifcopenshell,
@@ -1826,7 +1835,7 @@ class ShapeBuilder:
end_half_dim: np.ndarray,
angle: float,
profile_offset: VectorType = (0.0, 0.0),
verbose: bool = True,
verbose: bool = False,
) -> Optional[float]:
"""Get the transition length for two profile half-dimensions, an angle, and an XY offset.
@@ -1838,7 +1847,9 @@ class ShapeBuilder:
:param end_half_dim: Half-dimensions of the end profile in the same format.
:param angle: Maximum allowed transition angle, in degrees.
:param profile_offset: 2D XY offset between the centrelines of the start and end profiles.
:param verbose: If True, print diagnostic values during calculation.
:param verbose: If True, print diagnostic values during calculation. Default is False
the prints are debug-only output; enabling them spams the console on every transition
geometry computation (which fires per-fitting on IFC load).
:return: Transition length in project length units, or ``None`` if no valid length exists
for the given angle and offset.
"""
@@ -1899,7 +1910,7 @@ class ShapeBuilder:
end_profile: bool = False,
length: Optional[float] = None,
angle: Optional[float] = None,
verbose: bool = True,
verbose: bool = False,
) -> Union[float, None]:
"""Calculate MEP transition length from angle, or transition angle from length.
@@ -644,6 +644,11 @@ def convert_unit(value: float, from_unit: ifcopenshell.entity_instance, to_unit:
)
def mm_to_m(value: float) -> float:
"""Convert a millimetre value to metres."""
return value / 1000
def convert(value: float, from_prefix: Optional[str], from_unit: str, to_prefix: Optional[str], to_unit: str) -> float:
"""Converts between length, area, and volume units
+1
View File
@@ -21,6 +21,7 @@ dependencies = [
"isodate",
"python-dateutil",
"lark",
"pyparsing",
"typing-extensions",
]
@@ -38,6 +38,12 @@ def test_add_segment_to_layout():
)
alignment = ifcopenshell.api.alignment.create(file, "")
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
assert (
len(referent_nest.RelatedObjects) == 1
) # the alignment creates the stationing nest and it has one referent to defined the stationing for the alignment
horizontal_alignment = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
design_parameters = file.create_entity(
@@ -70,7 +76,7 @@ def test_add_segment_to_layout():
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal_alignment)
assert len(segment_nest.RelatedObjects) == 2
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
assert len(referent_nest.RelatedObjects) == 3
assert len(referent_nest.RelatedObjects) == 1 # test this a second time to make sure that it is still true
test_add_segment_to_layout()
@@ -37,7 +37,9 @@ def test_add_vertical_alignment():
assert len(layout_nest.RelatedObjects) == 1
assert layout_nest.RelatedObjects[0].is_a("IfcAlignmentHorizontal")
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
assert len(referent_nest.RelatedObjects) == 2
assert (
len(referent_nest.RelatedObjects) == 1
) # the alignment creates the stationing nest and it has one referent to defined the stationing for the alignment
assert referent_nest.RelatedObjects[0].is_a("IfcReferent")
curve = ifcopenshell.api.alignment.get_curve(alignment)
@@ -62,7 +64,7 @@ def test_add_vertical_alignment():
for child_alignment in alignment.IsDecomposedBy[0].RelatedObjects:
assert child_alignment.is_a("IfcAlignment")
assert len(child_alignment.IsNestedBy) == 2
assert len(child_alignment.IsNestedBy) == 1
child_layout_nest = ifcopenshell.api.alignment.get_alignment_layout_nest(child_alignment)
assert len(child_layout_nest.RelatedObjects) == 1 # The IfcAlignmentVertical
assert child_layout_nest.RelatedObjects[0].is_a("IfcAlignmentVertical")
@@ -52,7 +52,7 @@ def test_create_by_pi_method():
assert len(layout_nest.RelatedObjects) == 2
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
assert len(referent_nest.RelatedObjects) == 19
assert len(referent_nest.RelatedObjects) == 1
horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
horizontal_segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal_layout)
@@ -73,9 +73,16 @@ def _test_horizontal() -> ifcopenshell.file:
assert y == 0.0
assert z == 0.0
# check the start point of the zero length segment
assert horizontal_alignment.IsNestedBy[0].RelatedObjects[1].DesignParameters.SegmentLength == 0.0
assert horizontal_alignment.IsNestedBy[0].RelatedObjects[1].DesignParameters.StartPoint.Coordinates[0] == x
assert horizontal_alignment.IsNestedBy[0].RelatedObjects[1].DesignParameters.StartPoint.Coordinates[1] == y
curve = ifcopenshell.api.alignment.get_curve(ali)
assert curve.is_a("IfcCompositeCurve")
assert len(curve.Segments) == 2
assert curve.Segments[0].Transition == "CONTSAMEGRADIENTSAMECURVATURE"
assert curve.Segments[1].Transition == "DISCONTINUOUS"
design_parameters = file.create_entity(
type="IfcAlignmentHorizontalSegment",
@@ -101,9 +108,16 @@ def _test_horizontal() -> ifcopenshell.file:
assert y == 50.0 * math.sin(math.pi / 6)
assert z == 0.0
# check the start point of the zero length segment
assert horizontal_alignment.IsNestedBy[0].RelatedObjects[2].DesignParameters.SegmentLength == 0.0
assert horizontal_alignment.IsNestedBy[0].RelatedObjects[2].DesignParameters.StartPoint.Coordinates[0] == x
assert horizontal_alignment.IsNestedBy[0].RelatedObjects[2].DesignParameters.StartPoint.Coordinates[1] == y
curve = ifcopenshell.api.alignment.get_curve(ali)
assert curve.is_a("IfcCompositeCurve")
assert len(curve.Segments) == 3
assert curve.Segments[1].Transition == "CONTSAMEGRADIENTSAMECURVATURE"
assert curve.Segments[2].Transition == "DISCONTINUOUS"
return file
@@ -50,7 +50,14 @@ def test_create_no_geometry():
PredefinedType="LINE",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, horizontal_alignment, design_parameters)
assert end == None
x = end[0, 3]
y = end[1, 3]
z = end[2, 3]
assert x == 100.0
assert y == 0.0
assert z == 0.0
design_parameters = file.createIfcAlignmentVerticalSegment(
StartDistAlong=0.0,
@@ -61,7 +68,14 @@ def test_create_no_geometry():
PredefinedType="CONSTANTGRADIENT",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, vertical_alignment, design_parameters)
assert end == None
x = end[0, 3]
y = end[1, 3]
z = end[2, 3]
assert x == 50.0
assert y == 20.0 + 50.0 * 1.0 / 100.0
assert z == 0.0
test_create_no_geometry()
@@ -0,0 +1,443 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell 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.
#
# IfcOpenShell 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 IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import math
import pytest
import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.api.unit
import numpy as np
def test_create_representation():
# expected values for horizontal segment ends points (X,Y,dx,dy)
h_expected = [
(500.0, 2500.0, math.cos(math.radians(327.0613)), math.sin(math.radians(327.0613))),
(2142.2378194934668, 1436.0145490066361, 0.8392527899703555, -0.5437414408769801),
(3660.446048592728, 2050.735651565721, 0.22453168741127044, 0.9744667882222808),
(4084.1161141648777, 3889.4623490042068, 0.22453168741127047, 0.9744667882222809),
(5469.395455576321, 4847.565492667097, 0.9910142023415828, -0.13375668490687387),
(7019.971720182908, 4638.284999653966, 0.9910142023415827, -0.13375668490687387),
(7790.932377201981, 4006.729563689594, 0.32621900658961334, -0.9452942186111613),
(8479.999918938518, 2009.9986857258034, 0.32621900658961345, -0.9452942186111613),
]
# expected values for vertical segment ends points (X,Y,dx,dy)
v_expected = [
(0.0, 100.0, 0.999846910161925, 0.01749732092783369),
(1200.0, 121.0, 0.999846910161925, 0.01749732092783369),
(2799.99999384661, 127.00000006153391, 0.9999500037507449, -0.009999499931751348),
(4399.99999384661, 111.00000023075212, 0.999950003750745, -0.009999499931751352),
(5599.9999883553455, 117.00000018438367, 0.999800059982751, 0.019996001062400855),
(6399.999988355345, 133.0000000745584, 0.999800059982751, 0.019996001062400855),
(8399.99998428796, 133.00000001862446, 0.999800059981633, -0.019996001118301257),
(9399.99998428796, 113.00000009997211, 0.999800059981633, -0.019996001118301257),
(10199.99998062693, 103.00000015081635, 0.9999875002340269, -0.004999937569813611),
(12799.99998062693, 89.99999997234107, 0.9999875002340269, -0.004999937569813611),
]
file = ifcopenshell.file(schema="IFC4X3_ADD2")
file.header.file_description.description = ["ViewDefinition [Alignment-basedView]"]
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="FHWA Alignment")
# ifcopenshell.api.unit.assign_unit(file)
# length = ifcopenshell.api.unit.add_si_unit(file,unit_type="LENGTHUNIT")
length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot")
ifcopenshell.api.unit.assign_unit(file, units=[length])
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=geometric_representation_context,
)
site = file.createIfcSite(GlobalId=ifcopenshell.guid.new(), Name="Site")
ifcopenshell.api.aggregate.assign_object(file, relating_object=project, products=[site])
alignment = ifcopenshell.api.alignment.create(
file, "E-Line", include_vertical=True, start_station=10000.0, include_geometry=False
)
# alignment is referenced into spatial structure of site per CT 4.1.5.1
ifcopenshell.api.spatial.reference_structure(file, products=[alignment], relating_structure=site)
layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
segment1 = file.createIfcAlignmentHorizontalSegment(
StartPoint=file.createIfcCartesianPoint(Coordinates=((500.0, 2500.0))),
StartDirection=math.radians(327.0613),
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=1956.785654,
PredefinedType="LINE",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, layout, segment1)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
dir = math.atan2(dy, dx)
assert (
pytest.approx(h_expected[1][0]) == x
and pytest.approx(h_expected[1][1]) == y
and pytest.approx(h_expected[1][2]) == dx
and pytest.approx(h_expected[1][3]) == dy
)
segment2 = file.createIfcAlignmentHorizontalSegment(
StartPoint=file.createIfcCartesianPoint((x, y)),
StartDirection=dir,
StartRadiusOfCurvature=1000.0,
EndRadiusOfCurvature=1000.0,
SegmentLength=1919.222667,
PredefinedType="CIRCULARARC",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, layout, segment2)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
dir = math.atan2(dy, dx)
assert (
pytest.approx(h_expected[2][0]) == x
and pytest.approx(h_expected[2][1]) == y
and pytest.approx(h_expected[2][2]) == dx
and pytest.approx(h_expected[2][3]) == dy
)
segment3 = file.createIfcAlignmentHorizontalSegment(
StartPoint=file.createIfcCartesianPoint((x, y)),
StartDirection=dir,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=1886.905454,
PredefinedType="LINE",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, layout, segment3)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
dir = math.atan2(dy, dx)
assert (
pytest.approx(h_expected[3][0]) == x
and pytest.approx(h_expected[3][1]) == y
and pytest.approx(h_expected[3][2]) == dx
and pytest.approx(h_expected[3][3]) == dy
)
segment4 = file.createIfcAlignmentHorizontalSegment(
StartPoint=file.createIfcCartesianPoint((x, y)),
StartDirection=dir,
StartRadiusOfCurvature=-1250.0,
EndRadiusOfCurvature=-1250.0,
SegmentLength=1848.115835,
PredefinedType="CIRCULARARC",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, layout, segment4)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
dir = math.atan2(dy, dx)
assert (
pytest.approx(h_expected[4][0]) == x
and pytest.approx(h_expected[4][1]) == y
and pytest.approx(h_expected[4][2]) == dx
and pytest.approx(h_expected[4][3]) == dy
)
segment5 = file.createIfcAlignmentHorizontalSegment(
StartPoint=file.createIfcCartesianPoint((x, y)),
StartDirection=dir,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=1564.635765,
PredefinedType="LINE",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, layout, segment5)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
dir = math.atan2(dy, dx)
assert (
pytest.approx(h_expected[5][0]) == x
and pytest.approx(h_expected[5][1]) == y
and pytest.approx(h_expected[5][2]) == dx
and pytest.approx(h_expected[5][3]) == dy
)
segment6 = file.createIfcAlignmentHorizontalSegment(
StartPoint=file.createIfcCartesianPoint((x, y)),
StartDirection=dir,
StartRadiusOfCurvature=-950.0,
EndRadiusOfCurvature=-950.0,
SegmentLength=1049.119737,
PredefinedType="CIRCULARARC",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, layout, segment6)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
dir = math.atan2(dy, dx)
assert (
pytest.approx(h_expected[6][0]) == x
and pytest.approx(h_expected[6][1]) == y
and pytest.approx(h_expected[6][2]) == dx
and pytest.approx(h_expected[6][3]) == dy
)
segment7 = file.createIfcAlignmentHorizontalSegment(
StartPoint=file.createIfcCartesianPoint((x, y)),
StartDirection=dir,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=2112.285084,
PredefinedType="LINE",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, layout, segment7)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
assert (
pytest.approx(h_expected[7][0]) == x
and pytest.approx(h_expected[7][1]) == y
and pytest.approx(h_expected[7][2]) == dx
and pytest.approx(h_expected[7][3]) == dy
)
vlayout = ifcopenshell.api.alignment.get_vertical_layout(alignment)
segment1 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=0.0,
HorizontalLength=1200.0,
StartHeight=100.0,
StartGradient=1.75 / 100.0,
EndGradient=1.75 / 100.0,
PredefinedType="CONSTANTGRADIENT",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment1)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
assert (
pytest.approx(v_expected[1][0]) == x
and pytest.approx(v_expected[1][1]) == y
and pytest.approx(v_expected[1][2]) == dx
and pytest.approx(v_expected[1][3]) == dy
)
segment2 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=x,
HorizontalLength=1600.0,
StartHeight=y,
StartGradient=dy / dx,
EndGradient=-1.0 / 100.0,
PredefinedType="PARABOLICARC",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment2)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
assert (
pytest.approx(v_expected[2][0]) == x
and pytest.approx(v_expected[2][1]) == y
and pytest.approx(v_expected[2][2]) == dx
and pytest.approx(v_expected[2][3]) == dy
)
segment3 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=x,
HorizontalLength=1600.0,
StartHeight=y,
StartGradient=dy / dx,
EndGradient=-1.0 / 100.0,
PredefinedType="CONSTANTGRADIENT",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment3)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
assert (
pytest.approx(v_expected[3][0]) == x
and pytest.approx(v_expected[3][1]) == y
and pytest.approx(v_expected[3][2]) == dx
and pytest.approx(v_expected[3][3]) == dy
)
segment4 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=x,
HorizontalLength=1200.0,
StartHeight=y,
StartGradient=dy / dx,
EndGradient=2.0 / 100.0,
PredefinedType="PARABOLICARC",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment4)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
assert (
pytest.approx(v_expected[4][0]) == x
and pytest.approx(v_expected[4][1]) == y
and pytest.approx(v_expected[4][2]) == dx
and pytest.approx(v_expected[4][3]) == dy
)
segment5 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=x,
HorizontalLength=800.0,
StartHeight=y,
StartGradient=dy / dx,
EndGradient=2.0 / 100.0,
PredefinedType="CONSTANTGRADIENT",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment5)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
assert (
pytest.approx(v_expected[5][0]) == x
and pytest.approx(v_expected[5][1]) == y
and pytest.approx(v_expected[5][2]) == dx
and pytest.approx(v_expected[5][3]) == dy
)
segment6 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=x,
HorizontalLength=2000.0,
StartHeight=y,
StartGradient=dy / dx,
EndGradient=-2.0 / 100.0,
PredefinedType="PARABOLICARC",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment6)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
assert (
pytest.approx(v_expected[6][0]) == x
and pytest.approx(v_expected[6][1]) == y
and pytest.approx(v_expected[6][2]) == dx
and pytest.approx(v_expected[6][3]) == dy
)
segment7 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=x,
HorizontalLength=1000.0,
StartHeight=y,
StartGradient=dy / dx,
EndGradient=-2.0 / 100.0,
PredefinedType="CONSTANTGRADIENT",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment7)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
assert (
pytest.approx(v_expected[7][0]) == x
and pytest.approx(v_expected[7][1]) == y
and pytest.approx(v_expected[7][2]) == dx
and pytest.approx(v_expected[7][3]) == dy
)
segment8 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=x,
HorizontalLength=800.0,
StartHeight=y,
StartGradient=dy / dx,
EndGradient=-0.5 / 100.0,
PredefinedType="PARABOLICARC",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment8)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
assert (
pytest.approx(v_expected[8][0]) == x
and pytest.approx(v_expected[8][1]) == y
and pytest.approx(v_expected[8][2]) == dx
and pytest.approx(v_expected[8][3]) == dy
)
segment9 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=x,
HorizontalLength=2600.0,
StartHeight=y,
StartGradient=dy / dx,
EndGradient=-0.5 / 100.0,
PredefinedType="CONSTANTGRADIENT",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment9)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
assert (
pytest.approx(v_expected[9][0]) == x
and pytest.approx(v_expected[9][1]) == y
and pytest.approx(v_expected[9][2]) == dx
and pytest.approx(v_expected[9][3]) == dy
)
ifcopenshell.api.alignment.create_representation(file, alignment)
curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
assert curve.is_a("IfcCompositeCurve")
for s in curve.Segments:
assert len(s.UsingCurves) == 1
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
assert curve.is_a("IfcCompositeCurve")
for index, s in enumerate(curve.Segments):
assert len(s.UsingCurves) == 1
assert s.Placement.Location.Coordinates[0] == pytest.approx(h_expected[index][0])
assert s.Placement.Location.Coordinates[1] == pytest.approx(h_expected[index][1])
assert s.Placement.RefDirection.DirectionRatios[0] == pytest.approx(h_expected[index][2])
assert s.Placement.RefDirection.DirectionRatios[1] == pytest.approx(h_expected[index][3])
curve = ifcopenshell.api.alignment.get_layout_curve(vlayout)
assert curve.is_a("IfcGradientCurve")
for index, s in enumerate(curve.Segments):
assert len(s.UsingCurves) == 1
assert s.Placement.Location.Coordinates[0] == pytest.approx(v_expected[index][0])
assert s.Placement.Location.Coordinates[1] == pytest.approx(v_expected[index][1])
assert s.Placement.RefDirection.DirectionRatios[0] == pytest.approx(v_expected[index][2])
assert s.Placement.RefDirection.DirectionRatios[1] == pytest.approx(v_expected[index][3])
test_create_representation()

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