Compare commits

..

45 Commits

Author SHA1 Message Date
Ryan Schultz 0d39a21bf4 closes #6235 - Add copy toggle to CAD offset
Add a "Copy" option to bim.cad_offset. When enabled (the
default) it offsets a new copy of the selected edges as
before; when disabled it moves the existing edges to the
offset location instead. The toggle is exposed in the CAD
tool's Offset panel and the operator redo panel.

Generated with the assistance of an AI coding tool.
2026-06-12 14:02:44 -05:00
Thomas Krijnen 671217d494 Commit remainder of fixes to IfcParseExamples 2026-06-12 12:13:35 +02:00
Thomas Krijnen dcebf23af8 Workaround for header construction order 2026-06-12 11:41:11 +02:00
Thomas Krijnen 4d22a3fdb9 Enable retargeting of example schema 2026-06-12 11:04:18 +02:00
Thomas Krijnen 3136c74c2f Pass logger to proj callback 2026-06-11 21:57:42 +02:00
Thomas Krijnen 347a3c80bb More logger changes 2026-06-11 21:09:56 +02:00
Gorgious56 0c993d3292 Guard HasShapeAspects access on IFC2X3 representation iteration
IFC2X3 representations have no HasShapeAspects inverse; opening the
Geometry & Materials subpanel on an IFC2X3 object raised AttributeError
and left the items list empty. Wrap the access with a getattr default
so pre-IFC4 schemas return an empty iterable, and pin the contract with
an AST forward-compat guard that scans bim/, tool/, and core/ for any
future direct .HasShapeAspects access.

Closes #8157

Generated with the assistance of an AI coding tool.
2026-06-11 09:28:27 +02:00
Richard Brice 32a601d057 fixes build problem from commit a7738eeb 2026-06-10 13:31:32 -07:00
Thomas Krijnen a7738eeb64 Pass around non-static logger instances and programmatic access to messages in-memory 2026-06-10 18:40:17 +02:00
Thomas Krijnen a751fb956d Introduce unique error codes 2026-06-10 18:40:17 +02:00
Gorgious56 251157f8d4 Fix np_frombuffer_legacy length-vs-dtype check
The check `len(bytedata) == n * 2` was wrong: float64 is 8 bytes per
element, not 2. Legacy float64 checksums fell through to the float32
reader and produced a (2n,)-shaped array, breaking is_moved() and
is_camera_moved() with `ValueError: operands could not be broadcast`
on .blend files saved by Blender <5.0.

Adds a parametrized regression test covering both n=3 (translation)
and n=9 (rotation) for both dtypes.

Generated with the assistance of an AI coding tool.
2026-06-10 17:37:41 +02:00
Gorgious56 a213a9b848 Merge pull request #8155 from Gorgious56/bonsai/mep-edit-gizmos
Add MEP segment + bend edit gizmos
2026-06-10 17:35:33 +02:00
Gorgious56 b76bc1c1f8 Read wall extent from bbox in cursor gizmo layout
GizmoWallEdition.position_gizmos used props.anchor_x / props.length
for the in-range check (split icon visibility) and perpendicular
gizmo placement. Those props mirror IFC and are re-primed by
_maybe_resync_wall_props_from_ifc — any operator path that skips
the re-sync leaves the perpendicular gizmo clamped to the previous
wall extent, so the icon parks at the old wall end instead of the
cursor's orthogonal projection. Visible after a wall mutation as
the perpendicular icon landing way off the cursor in top-down view.

Switch to the mesh bbox along local X. recreate_wall rebuilds the
mesh to match the current IFC body on every wall mutation, so
bound_box is authoritative without an explicit props sync.

Generated with the assistance of an AI coding tool.
2026-06-10 13:17:07 +02:00
Gorgious56 4d24cef0c9 Hide MEP gizmos on non-parametric elements
MEP elements imported as tessellation / brep (no IfcExtrudedAreaSolid
or IfcSweptDiskSolid in their body representation) can't be
parametrically edited — the gizmos offer affordances the geometry
kernel has no path to honour. tool.System.has_parametric_body
inspects the Model/Body/MODEL_VIEW representation and returns True
only when at least one item resolves to one of the two
profile-sweep primitives.

The gate is wired into:
- GizmoMEPActions.is_eligible_object (the action icon group)
- _active_is_flow_segment / _active_is_bend_fitting visibility
  predicates the icon row consults per-icon
- GizmoPipeSegmentEdition / GizmoDuctSegmentEdition is_element_type

tool.Parametric.is_pipe_segment / is_duct_segment stay IFC-class-only
so their truth-table contract test keeps reading a single concern.

Generated with the assistance of an AI coding tool.
2026-06-10 13:00:01 +02:00
Gorgious56 6c9cfccc43 Move _is_multiple_of_pi to tool.Cad
Pure-math parallelism check (value ≡ 0 mod π within VTX_PRECISION)
that lived as a module-private helper in mep.py belongs next to
tool.Cad.is_x — same comparator family, no MEP-specific knowledge.
Other features with rotation-difference checks (wall fillet, roof
slope, railing terminus) now have a sanctioned spelling.

Generated with the assistance of an AI coding tool.
2026-06-10 12:35:21 +02:00
Gorgious56 0a0a5b9f04 Add MEP cache + smoke + cancel-ops forward-compat tests
Four standalone test files pinning contracts the production code
already honours:

- test_mep_actions_cache.py: GizmoMEPActions visibility-predicate
  cache evicts on selection or generation change.
- test_mep_bend_preview_cache.py: bend decorator polyline cache
  re-uses within a generation and rebuilds on generation bump.
- test_mep_distribution_fit_smoke.py: bim.fit_flow_segments
  round-trips a 3-segment polyline without raising.
- test_preview_cancel_ops_forward_compat.py: AST scan ensures every
  preview Enable* operator has a paired Cancel* operator with the
  matching prop reset.

Generated with the assistance of an AI coding tool.
2026-06-10 12:27:23 +02:00
Gorgious56 3a0abbab95 DRY transform-modal draw gate + polyline helper
Two small refactors:

- apply_transform_modal_draw_gate(group, context) replaces the
  three-line _is_transform_modal_active + _hide_all_non_modal_gizmos
  pair that BillboardingGizmoGroupMixin, BaseParametricGizmoGroup
  and BaseSchematicGizmoGroup all repeat in draw_prepare.
- decorator.py renames _stroke_lines_alpha to a public-scope
  draw_polyline_segments and drops the no-longer-private companion
  docstring reference; the function is now usable by sibling
  decorators that draw polyline overlays.

Plus a few one-liner tweaks in tool/model.py and opening.py
following the helper rename.

Generated with the assistance of an AI coding tool.
2026-06-10 12:26:48 +02:00
Gorgious56 0d703039a6 Cache array-child + wall topology by IFC generation
Two hot paths the gizmo polls fire every viewport event memoise
their result against tool.Parametric.get_geom_generation():

- tool.Blender.Modifier.any_selected_array_child caches the
  per-selection scan against the selection identity-set + the
  IFC generation token so a stable selection during a drag
  doesn't re-walk every selected object's BBIM_Array pset every
  frame.
- bim/module/model/wall.py grows a pair-predicate + connection
  cache that the wall topology gizmos hit; both keyed on
  (pair_uids, predicate_kind, generation) so a wall split or
  axis edit invalidates correctly via the generation bump.

Behavioural contract is unchanged — stale entries are evicted
on generation bump; cache miss returns the same value the
un-cached path returned.

Generated with the assistance of an AI coding tool.
2026-06-10 12:25:42 +02:00
Gorgious56 f33df52c1b Centralise model test fixtures via conftest
bim/module/model/conftest.py exposes the autouse _require_real_bpy
skip-guard, four make_* factories (obj / element / context /
ifc_file), and a patched_tool context-manager factory that wires
the half-dozen tool.* boundary patches every gizmo + decorator
test was repeating.

Existing test files in the directory drop their local copies of
_require_real_bpy and adopt the patched_tool / make_* fixtures
where the call site simplifies — test_mep_port_operators.py is
the biggest beneficiary (−89 LOC).

No production behaviour change.

Generated with the assistance of an AI coding tool.
2026-06-10 12:24:44 +02:00
Gorgious56 15a6375ea3 Extract MEP bend preview + refine port operators
Three concerns bundled by file boundary (all in mep.py):

- Extract bend preview operators + GizmoBendPreview into a focused
  mep_bend_preview.py module; preview_base.py grows the shared helper
  set both bend and other previews now consume; classes tuple in
  model/__init__.py updated to register the new module.
- Surface ERROR reports on five silent CANCELLED returns in
  MEPUnjoinAtPort / MEPRemoveTerminalFitting / MEPUnjoinPair so a
  degenerate IFC file ("fitting has no Blender object", "connected
  port leads nowhere") shows up in the popup instead of looking like
  a no-op.
- DRY: _resolve_active_mep_segment + _require_port_state factor the
  segment-id-or-active-object resolve + port-state guard out of every
  port operator's prologue; _wire_anchored_icon_targets pulls the
  GizmoMEPActions setup() body into an exercise-without-MRO helper so
  the wiring-contract tests can hit it without instantiating the
  GizmoGroup.

Drops the now-unused preview_base import that the extraction left
behind.

Generated with the assistance of an AI coding tool.
2026-06-10 12:24:05 +02:00
Gorgious56 82465a64a5 Brighten and dash opening occlusion outline
The opening preview's outline used a single-batch two-pass scheme that
dimmed the occluded back pass via alpha=0.25. The visible front pass also
inherited the source decorator color's modest alpha, so the outline read
as subtle on both sides.

Replace with a CAD hidden-line convention: solid full-alpha front pass on
the visible side, world-space dashed back pass on the occluded side. Both
passes use POLYLINE_UNIFORM_COLOR so depth and line-weight paths match.
The dashed batch is built once per object epoch by a new pure helper
tool.Blender.build_dashed_line_segments (pre-segments edges into world-
space dash chunks), then cached via the existing batch-cache mechanism
under "<uid>_dashed".

The solid front pass is rendered at a slightly wider line width than the
dashed back pass so its halo overpowers Blender's WIRE-display overlay
bias at outline pixels — without the asymmetry the wire's anti-z-fight
forward bias makes the LESS_EQUAL comparison narrowly fail and the
dashed pass wins on visible edges too.

Generated with the assistance of an AI coding tool.
2026-06-09 22:49:00 +02:00
Gorgious56 6bde619fe6 Migrate MEPConnectElements args from object names to IFC GUIDs
MEPConnectElements took obj1_name/obj2_name (Blender object names),
which break when objects are renamed or replicated by array
duplication. Switch to obj1_guid/obj2_guid resolved via
ifc_file.by_guid, with by_guid RuntimeError surfaced as an operator
error rather than a stack trace. DrawPolylineProfile (the sole
in-tree caller) updates to pass GlobalIds.

Generated with the assistance of an AI coding tool.
2026-06-09 22:47:10 +02:00
Gorgious56 ba5321fdfa Add MEP bend tessellation helper tests
Pins the geometry contracts the hand-meshed bend body relies on
while IfcSweptDiskSolid round-trip is broken upstream (#8106):

- profile cross-section sampling: circle returns 16 evenly-spaced
  points starting at (radius, 0); rectangle returns the four
  canonical corners; anything else returns None so the rep swap
  is skipped rather than meshed against the wrong section
- parallel-transport framing keeps the cross-section continuous
  around L-shaped corners — pinned via start / end ring planes
- initial_basis override seeds the first ring with the source
  segment's local +X / +Y axes, fixing the asymmetric-rectangle
  twist the world-Z seed produces

Generated with the assistance of an AI coding tool.
2026-06-09 22:18:22 +02:00
Gorgious56 49ddc97918 Add MEP port operator dispatch tests
Pins which IFC mutation each port operator commits and which
inputs each refuses with CANCELLED:
- MEPUnjoinAtPort removes the fitting + reconnects the two free
  ports; refuses if the named port is free or terminal
- MEPRemoveTerminalFitting deletes the terminal element + leaves
  the segment's port free; refuses on bridged fittings
- SelectMEPPathMembers walks IfcRelConnectsPorts in both
  directions from the active segment and selects every fitting /
  segment reachable through the port graph

Boundary mocks for tool.Ifc, tool.System and MEPGenerator stand
in for the IFC fixture; tests assert against the recorded
ifcopenshell.api.* calls.

Generated with the assistance of an AI coding tool.
2026-06-09 21:46:34 +02:00
Gorgious56 39bcd9db63 Add GizmoMEPActions wiring contract tests
Pins two regressions the live MEP gizmo group can hit:
- per-icon setup() must write `position` (and `mode` on open-lock
  icons) onto every target_set_operator result; the test stands in
  for the AttributeError on bim.mep_add_obstruction that surfaced
  when a field was dropped from the operator declaration
- each visibility_condition lambda must stay total against None /
  non-IFC inputs, since a single raising predicate silently disables
  every sibling icon in the group

Generated with the assistance of an AI coding tool.
2026-06-09 21:45:34 +02:00
Thomas Krijnen ab11ac5338 Catch decomposition errors #8149 2026-06-09 21:35:40 +02:00
Gorgious56 9346f45bba Fix decorator face-tri overlay artifacts
ProfileDecorator.draw_faces (used by the roof path-edit overlay) and
SystemDecorator.draw_faces called bmesh.ops.triangulate on the live
bmesh — both mutated the input and produced ear-clip fans that rendered
as visible streaks across n-gon roof faces at alpha 0.1. The opening
DecorationsHandler edit-mode branch had a separate bug: it computed
triangles from obj.data.calc_loop_triangles() while iterating the
edit-mode bmesh, so any topology added mid-edit desynced the indices.

Centralise the correct draw path on tool.Blender.draw_bmesh_face_tris
(wraps bm.calc_loop_triangles, non-mutating, beauty triangulator) and
route all three call-sites through it. A forward-compat AST guard walks
every *Decorator / DecorationsHandler class under bim/module/ and pins
the no-bmesh.ops.triangulate rule against future regressions.

Generated with the assistance of an AI coding tool.
2026-06-09 20:16:46 +02:00
Gorgious56 b22687891b Warn on shared-rep parametric edits
A user clicking the pen icon on a typed-product occurrence whose body
representation is mapped from its type would silently mutate every
sibling occurrence's geometry. Add a confirmation dialog at the pen-icon
dispatcher (the single chokepoint every feature routes through) showing
the sibling count, with a session-scoped suppress checkbox.

The check is read-only: tool.Model.get_sibling_occurrence_count wraps
tool.Geometry.get_elements_by_representation against the resolved body
rep and subtracts self + type. A forward-compat AST guard pins the
dispatcher monopoly so any future feature that binds pen_gizmo directly
to a feature-specific enable op fails the test before merge.

Generated with the assistance of an AI coding tool.
2026-06-09 17:32:44 +02:00
Gorgious56 784f0b1fe2 Add bend re-edit gizmo
Once a bend was created, the only way to retune start_length /
end_length / radius was to delete and recreate from scratch.
EnableBendPreviewFromBend re-opens the preview on an existing
parametric bend: it walks the bend's ports to resolve the two
connected segments, reads start / end length and radius from the
bend type's BBIM_Fitting pset, and sets editing_bend_id on the
preview props. MEPAddBend then deletes the old bend + its port
connections (single undo step) before the recreate path runs, so
finish replaces the bend in place and cancel discards the edit
without touching the original.

GizmoMEPActions surfaces a pen icon on single bend-fitting
selections via the new _active_is_bend_fitting predicate; the icon
dispatches the new operator. Mirror of the wall fillet re-edit
flow (EnableWallFilletPreviewFromCorner + editing_corner_id in
CreateWallFillet).

Test coverage: registration probe for the new operator, an attached
editing_bend_id field probe on the preview umbrella, and a
parametrized truth-table for the _is_bend_fitting predicate
(IfcFlowFitting with BEND PredefinedType, with other PredefinedType,
with no type, IfcFlowSegment, IfcWall, None).

Generated with the assistance of an AI coding tool.
2026-06-09 17:18:09 +02:00
Gorgious56 e0ceda6856 Hide wall topology gizmos on array children
Wall topology mutations (merge / join / extend-to-wall / unjoin /
fillet) applied to a Bonsai array child are silently overwritten by
the next ``regenerate_array``; merge also orphans a GUID listed in
the parent's ``BBIM_Array.Data``. Add a central
``tool.Blender.Modifier.any_selected_is_array_child`` predicate and
gate the five wall topology gizmo groups plus the six bound operators
behind it. Operator gating is defence in depth against keymap / F3
invocation paths that bypass the gizmo.

The base ``_wall_gizmo_poll_gate`` keeps its loose two-check shape
(viewport gizmos + no preview). A new
``_wall_topology_gizmo_poll_gate`` wraps it with the array-child
filter and is what the topology gizmos use. Host-opening gizmos
deliberately stay on the loose gate: openings authored on a child
are preserved through ``regenerate_array`` and track with the
replicated instance.

A forward-compat AST guard walks wall.py for ``GizmoGroup`` subclasses
and asserts each routes its poll through the tighter gate or the
central predicate, with an allow-list for the parametric-edit and
preview-owner exceptions. New wall topology gizmos inherit the
contract by construction.

Generated with the assistance of an AI coding tool.
2026-06-09 17:16:12 +02:00
Gorgious56 17951427fe Add readonly door swing arc preview
Selecting a Bonsai-parametric IfcDoor now shows the swing arc(s)
without entering edit mode. A new viewport decorator polls on the
active object, reads the door's BBIM_Door pset, and draws the same
arcs the parametric door swing gizmo would draw — matching the
hinge / panel-width / x-mirror contract minus the is_editing gate.

A forward-compat test walks every door operation type and cross-
checks the readonly decorator's arc selection against the gizmo's
swing-arc config table, so future enum additions fail in both
surfaces simultaneously.

Also disables the inherited 8-pass dark halo on GizmoArc: an open
curve has no enclosed silhouette, so the offset passes read as
ghost arcs rather than a uniform outline. The arc's own cross-
section thickness keeps it legible without the halo.

Generated with the assistance of an AI coding tool.
2026-06-09 16:13:59 +02:00
Gorgious56 734f4df84e Add MEP bend preview + bend tessellation fallback
The MEP bend feature's IfcSweptDiskSolid representation produces
geometrically correct output but fails to round-trip through the
OpenCascade geometry kernel (upstream issue #8106) — the body is
dropped on the next file load. Until upstream is fixed, MEPAddBend
captures the bend centerline in world space before the segments are
extended (otherwise the post-extension axes no longer reach the
original intersection and arc reconstruction is wrong), then after
the fitting is placed it hand-meshes the bend body and swaps the
type's swept-disk representation for an IfcTessellatedFaceSet via
tool.Geometry.export_mesh_to_tessellation + tool.Model.
replace_object_ifc_representation.

The centerline includes the straight start_length / end_length legs
in addition to the arc so the bend covers the full segment-to-
segment span. Sweep uses parallel-transport framing — each ring's
(right, up) basis is rotated by the minimum rotation that maps the
previous tangent to the current one, eliminating the twist a fixed
world-axis reference produces when the tangent crosses the
reference. Cross-section orientation seeds from the source segment's
matrix_world local +X / +Y so asymmetric IfcRectangleProfileDef
ducts land with XDim / YDim on the same axes the segment expects;
parallel transport then preserves that alignment around the arc.
Centerline radius is radius + profile_dim[lateral_axis] to match
MEPAddBend's ref_point_radius — without this offset, the bend legs
fall short of the extended segments by profile_dim * tan(angle/2).
Face winding is left to the caller to correct via
bmesh.ops.recalc_face_normals on the closed bend tube.

Two FIXME(#8106) markers (capture site + helper call site) so both
can be dropped once upstream lands a swept-disk round-trip fix.

Generated with the assistance of an AI coding tool.
2026-06-09 16:12:35 +02:00
Ryan Schultz bfa2d789f1 Error on tessellation request in IFC2X3
IfcTriangulatedFaceSet/IfcPolygonalFaceSet were introduced in
Fix #7992: IFC4 and do not exist in IFC2X3. Previously, requesting an
IfcTessellatedFaceSet representation in an IFC2X3 file silently
fell back to a faceted brep after unassigning material sets.
Add a guard in the update_representation operator (user-facing
error) and in the add_representation API (ValueError) so the
unsupported request is caught instead of failing silently.

Generated with the assistance of an AI coding tool.
2026-06-09 07:30:59 -05:00
Gorgious56 192bf00d31 Add cursor-bound perpendicular wall gizmo
GizmoWallEdition gains a fourth cursor-anchored icon that
spawns a perpendicular branch wall from the cursor's
orthogonal projection on the source wall axis. Click forms
a T-junction; shift+click forms an L-corner with the source
wall trimmed at the projection, keeping its longer portion.

The branch inherits the source's spatial container and
centerline baseline so its authored axis matches the source's
alignment rather than the type's default.

Also includes a floor-plane preview quad for the new gizmo,
a floor-Z cross line on the split preview for top-down
visibility, a small bump to QUAD_ALPHA for clearer preview
fills, and a stacking-offset helper that centralises the
cursor-row screen-up step across three call sites.

Generated with the assistance of an AI coding tool.
2026-06-09 13:54:39 +02:00
Gorgious56 ad672d0edb Add GizmoMEPActions + bend precondition + obstruction modes
The MEP one-shot operators (join, unjoin variants, terminal removal,
path-select, obstruction add/remove) had no viewport surface. This
commit adds GizmoMEPActions — the icon-action gizmo group that
surfaces them as billboarded icons around selected MEP elements.
Three anchor regions: a horizontal row above the bbox top
(selection-cardinality icons), per-port endpoints for the three-state
lock / unjoin icons (open lock for PORT_FREE, closed for
PORT_TERMINAL, unjoin for PORT_JOINED — resolved per-frame from
port_connection_state), and the predicted join location
(compute_mep_join_location, shared with the bend preview) for the
join / unjoin_pair pair. Unjoin icons render at full
DEFAULT_BILLBOARD_SCALE with warning-red hover; endpoint lock icons
shrink so the lock row stays subordinate to the row icons. The
group hides itself entirely while a bend preview is active.

MEPAddObstruction grew a position enum (CURSOR / START / END) and a
mode enum (ADD / REMOVE / TOGGLE) so the gizmo can target a specific
port without touching the cursor and dispatch ADD or REMOVE based on
the click target — the lock_open icons drive ADD with position
pinned, the lock_closed icons drive bim.mep_remove_terminal_fitting.
Without the new fields the gizmo wiring (op_props.position = ...)
crashed at setup() with AttributeError on the obstruction operator.

validate_bend_preconditions extracts the type-match and profile-kind
checks MEPAddBend enforces so EnableBendPreview surfaces the
rejection immediately — the user no longer tunes a preview only to
learn at commit time that the segments use an unsupported profile
(e.g. IfcArbitraryClosedProfileDef).

Generated with the assistance of an AI coding tool.
2026-06-09 12:44:19 +02:00
Gorgious56 0df1f0cf49 Add MEP unjoin / terminal-remove / path-select operators
Four discrete one-shot operators driven by the MEP segment's port
state. mep_unjoin_at_port deletes the IfcFlowFitting bridging a
segment's named port to a second element when the port is in the
JOINED state. mep_remove_terminal_fitting deletes the terminal
fitting at a port (closed-lock state) and dispatches by fitting
type — OBSTRUCTION fittings go through MEPGenerator.remove_obstruction
so the segment absorbs the freed length, other terminal fittings go
through the standard delete path. mep_unjoin_pair finds the single
fitting bridging two selected MEP segments and deletes it.
select_mep_path_members walks the connected MEP network from the
active element via IfcRelConnectsPorts and replaces the selection
with every reachable member. Foundation for the MEP Actions gizmo
group which surfaces these operators as icon affordances around
selected segments.

Generated with the assistance of an AI coding tool.
2026-06-09 12:16:15 +02:00
Gorgious56 d7dd8ecf57 Align extend gizmo arrow with segment axis
The extend icon used a pure screen-space billboard that always
pointed +X across the screen — the arrow ran horizontally
regardless of the pipe / duct's orientation. The new
billboarded_along_axis helper rotates the gizmo about the camera-
forward axis so its local +X aligns with the segment's local +Z
projected onto the screen, keeping the icon camera-facing but
visually following the extrusion direction. The flip-mirror branch
now reads from cursor-vs-current-end along the segment axis (not
screen-X), so the arrow points away from the current endpoint
regardless of viewport orientation. The split icon stacks
perpendicular to the rotated extend arrow in screen space so the
two don't overlap.

The decorator's green preview line no longer clamps the cursor
projection to min_projected_length — it follows the raw projection
so the line stays visible when the cursor crosses behind the
segment origin (the user still sees where they're pointing even
though the operator floors the actual commit).

Generated with the assistance of an AI coding tool.
2026-06-09 11:51:37 +02:00
Gorgious56 becbcfdfe7 Add MEP bend preview decorator + join dispatcher
The bend preview gizmo group (commit 2) populated a Scene draft but
the user saw nothing in the viewport until they hit finish — they
had to commit blindly. This commit ports the BendPreviewDecorator
(centerline arc + two leg projections on valid geometry, warning-red
axes on invalid in-segment intersections) and the interactive
GizmoBendPreview group (three dimension widgets for start_length /
end_length / radius plus validate / cancel icons). The bend axis
math lives in a pure compute_bend_preview_polylines helper, fed
into both the gizmo group's per-frame positioning and the GPU
decorator's draw path. MEPSegmentExtendPreviewDecorator lands at
the same time because it shares the decorator install / uninstall
plumbing — renders the extend-to-cursor preview line for the
GizmoPipeSegmentEdition / GizmoDuctSegmentEdition extend icons
when hovered, clamping the projected endpoint to the operator's
minimum so the preview matches where the commit lands. The
MEPJoinSegments dispatcher routes two selected MEP segments to
mep_add_transition (parallel) or enable_bend_preview (non-parallel)
— the F3 search entry point that makes the bend preview testable
before the gizmo-icon dispatch lands.

11 new tests in test_mep_bend_preview.py cover the geometry helper
truth table (parallel rejection, right-angle happy path, near-
collinear rejection, in-segment invalid_axes), the
_intersection_past_near parametrized boundary, registration probes
for the lifecycle operators / join dispatcher / gizmo group /
decorator, and the FinishBendPreview RuntimeError catch contract.
6 extend-preview-line tests (deferred from commit 3) join the
existing 35 in test_mep_segment_edition.py.

Generated with the assistance of an AI coding tool.
2026-06-08 21:18:18 +02:00
Gorgious56 5b79cefee2 Fix #8138: door/window container assignment no-op
Spatial.get_root_element walks aggregate / nest / filled-void /
voided-element chains and core.assign_container assigns the container
to whatever the walk returns. For an IfcDoor the filled-void hop
redirects to the IfcOpeningElement, then voided-element to the host
wall, so a user who selects a door and runs bim.assign_container ends
up targeting the wall — and silently no-ops on the door if the wall is
already in the target storey.

Per IFC4 / IFC4.3 (IfcDoor, IfcWindow): the spatial containment of a
filling is defined independently of the filling relationship. Major
exporters (Revit, ArchiCAD, Tekla, Allplan) emit independent
ContainedInStructure on doors / windows accordingly. Drop the
filled-void / voided-element hops from the walk; aggregate and nest
remain — those are true sub-part relationships where the parent
legitimately owns the container.

New TestGetRootElement in test/tool pins the new contract (filling
resolves to itself) plus the retained aggregate / nest / loose-element
paths so a future PR that re-adds either hop is caught. Two new
TestAssignContainer cases in test/core pin filling-to-self through the
core layer and per-element can_contain filtering.

Generated with the assistance of an AI coding tool.
2026-06-08 18:53:50 +02:00
Gorgious56 3346a59284 Add MEP pipe / duct segment edit gizmos
Pipe and duct segments had no parametric-edit affordance — the only
length edit path was a property panel value with no live preview.
This commit ports the per-segment parametric edit triad
(enable / finish / cancel) plus a cursor-anchored extend operator
and a cursor-projected split operator into one gizmo group per
segment type. The two PropertyGroups (BIMPipeSegmentProperties,
BIMDuctSegmentProperties) host the draft length plus snap fields
so cancel / no-op-finish restore the segment to its exact pre-edit
visual state including a non-identity pre-edit scale. Length
commits are written through DumbProfileJoiner.set_depth and
auto-dispatch bim.regenerate_distribution_element so adjacent
fittings track the port move. The split operator preserves
downstream port connectivity and runs through tool.Ifc.run for
single-step undo. The two segment types are now first-class
entries in tool.Parametric.EDIT_TYPES, which resolves the FIXME
on auto-commit-on-save dispatch.

35 unit tests cover predicate truth tables, segment_world_length
geometry, preview-via-scale / restore-scale helpers, gizmo class
wiring, lifecycle operator registration, dimension matrix_position
rotation respect, and lifecycle drift-handling. The 6 extend-
preview-line decorator tests stay deferred until the bend preview
decorator commit lands MEPSegmentExtendPreviewDecorator.

Generated with the assistance of an AI coding tool.
2026-06-08 15:01:03 +02:00
Gorgious56 0bf8e9283f Hide parametric gizmos during transform modal
Parametric gizmos (wall/door/window/stair/roof/array/MEP) recompute
matrix_basis every frame from obj.matrix_world. While Blender's
transform modal (G/R/S and the Bonsai macro overrides) drags the
matrix, the gizmos slide off-cursor and fight the transform overlay.

Detect via context.window.modal_operators (Blender 4.2+) — the
collection of running modal operators. Gate poll() (forward-compat)
and draw_prepare() (production path: gizmo.hide=True preserves the
GizmoGroup across the drag instead of destroying it). Cover the
Bonsai macro override for G key (and Shift/Alt/Ctrl+Shift+D) by
matching the BIM_OT_* macro idnames that surface in modal_operators.

Forward-compat test walks every parametric-edit module for GizmoGroup
subclasses and asserts poll returns False with the detector mocked,
so new gizmo groups inherit the hide automatically.

Generated with the assistance of an AI coding tool.
2026-06-08 13:33:08 +02:00
Gorgious56 db7591a867 Add clear_preview_state helper + DRY preview cleanup
Every preview operator (commit + cancel for both bend and wall
fillet) was inlining the same 3-4 line cleanup: set is_active to
False, zero every *_id IntProperty. The new clear_preview_state
helper in preview_base.py introspects bl_rna and applies that
contract generically — adopters become a single call. Two new tests
pin the contract: every *_id IntProperty zeroes, non-id fields stay.

Generated with the assistance of an AI coding tool.
2026-06-08 10:25:59 +02:00
Gorgious56 7b9af9f533 Backport pending-opening-cuts banner from gh8088
Extract the pending_opening_recut tracking, three operators (apply /
dismiss / select), Project-panel banner, and the sibling
multi-instance warning banner (its backend helpers already landed
on this branch) from commit a85ed6032 on gizmos-8088.

All tool.* dependencies (Geometry.reimport_element_representations,
Blender.set_objects_selection, Array.*) and IfcImporter.gross_elements
are already on this branch -- no other diffs from a85ed6032 are
pulled.

The source's narrow except-tuple paraphrase comments are trimmed
to keep only the durable "don't swallow programmer errors" note,
per CLAUDE.md s4a.

Tests: 5 bim-lane tests in test/bim/module/project/
test_pending_opening_cuts.py covering apply happy-path + missing
entity, dismiss, select happy-path + cancellation.

Generated with the assistance of an AI coding tool.
2026-06-08 10:18:37 +02:00
Gorgious56 704a2d36be Add MEP bend preview Scene properties + lifecycle
MEPAddBend exists on the main flow but commits bend geometry with
hardcoded defaults (start_length=0.1, end_length=0.1, radius=0.2)
with no opportunity to tune before commit. The new scene-level
BIMBendPreviewProperties hosts a draft (start_segment_id,
end_segment_id, start_length, end_length, radius); EnableBendPreview
populates it from the two selected MEP segments after asserting they
are non-parallel, FinishBendPreview dispatches MEPAddBend with the
tuned values and clears the draft, CancelBendPreview discards it.
Scene-level placement follows CLAUDE.md 2.9: a bend creates a new
fitting entity between two segments, so neither segment alone owns
the draft. Foundation for the upcoming bend preview gizmo group and
decorator.

Generated with the assistance of an AI coding tool.
2026-06-08 10:17:30 +02:00
Gorgious56 516696cd73 Add partial-state rollback on execute_ifc_operator
When an operator mutated IFC then raised mid-execute the user was left
staring at a raw traceback with the IFC graph captured by the active
transaction but the Blender side stale. Blender does not push an undo
step for a raised operator (the same gap that the CANCELLED-modal arm
patches via bpy.ops.ed.undo_push), so the WARNING the framework can
emit is only honest if it pushes that undo step too. The framework
now detects partial state via ifc_file.transaction.operations,
pushes a Recover undo step, then reports a WARNING naming Ctrl+Z so
the recovery path is discoverable. The bespoke try/except wrapper in
UnjoinWallPathConnection becomes redundant and is retired in the
same change.

Generated with the assistance of an AI coding tool.
2026-06-08 08:45:05 +02:00
221 changed files with 10243 additions and 1499 deletions
-2
View File
@@ -271,7 +271,6 @@ def register():
parametric_lifecycle.install_parametric_lifecycle_handlers()
bpy.app.handlers.load_post.append(handler.load_post)
bpy.app.handlers.load_post.append(handler.loadIfcStore)
bpy.app.handlers.save_post.append(handler.save_post)
bpy.types.Scene.BIMProperties = bpy.props.PointerProperty(type=prop.BIMProperties)
bpy.types.Scene.BIMSnapProperties = bpy.props.PointerProperty(type=prop.BIMSnapProperties)
bpy.types.Scene.BIMSnapGroups = bpy.props.PointerProperty(type=prop.BIMSnapGroups)
@@ -330,7 +329,6 @@ def unregister():
parametric_lifecycle.uninstall_parametric_lifecycle_handlers()
bpy.app.handlers.load_post.remove(handler.load_post)
bpy.app.handlers.load_post.remove(handler.loadIfcStore)
bpy.app.handlers.save_post.remove(handler.save_post)
del bpy.types.Scene.BIMProperties
del bpy.types.Collection.BIMCollectionProperties
del bpy.types.Object.BIMObjectProperties
+15 -23
View File
@@ -47,7 +47,10 @@ from bonsai.bim.module.model.array import (
)
from bonsai.bim.module.model.data import AuthoringData
from bonsai.bim.module.model.decorator import (
BendPreviewDecorator,
BoundingBoxDecorator,
DoorSwingReadonlyDecorator,
MEPSegmentExtendPreviewDecorator,
SlabDirectionDecorator,
WallAxisDecorator,
WallFilletPreviewDecorator,
@@ -432,29 +435,6 @@ def subscribe_to_viewport_shading_changes():
)
@persistent
def save_post(scene) -> None:
"""After saving the .blend file, convert the stored IFC path to relative if enabled."""
pprops = tool.Project.get_project_props()
if not pprops.use_relative_project_path:
return
bim_props = tool.Blender.get_bim_props()
ifc_path = bim_props.ifc_file
if not ifc_path or not os.path.isabs(ifc_path):
return
blend_dir = bpy.path.abspath("//")
if not blend_dir:
return
from pathlib import Path
from bonsai.bim.ifc import IfcStore
try:
rel_path = str(Path(ifc_path).relative_to(blend_dir))
except ValueError:
return # IFC file is not under the blend directory; keep absolute path
bim_props.ifc_file = rel_path
IfcStore.set_path(ifc_path) # keep IfcStore.path absolute for loading
def _apply_save_file_invariants(scene: bpy.types.Scene) -> None:
"""Invariants enforced on every load_post: msgbus subscription, IFC owner
settings, scene-bound caches, load-transient parametric state, and the
@@ -534,7 +514,10 @@ def _install_viewport_overlays() -> None:
WallAxisDecorator.uninstall()
SlabDirectionDecorator.uninstall()
WallFilletPreviewDecorator.uninstall()
BendPreviewDecorator.uninstall()
MEPSegmentExtendPreviewDecorator.uninstall()
WallGizmoPreviewDecorator.uninstall()
DoorSwingReadonlyDecorator.uninstall()
ArrayPreviewDecorator.uninstall()
ArraySelectionHighlightDecorator.uninstall()
uninstall_decorator_cache_handlers()
@@ -555,10 +538,19 @@ def _install_viewport_overlays() -> None:
# wall_fillet.is_active, so installation has no cost when no preview
# is open. No corresponding addon-preference toggle.
WallFilletPreviewDecorator.install(bpy.context)
# Always-installed siblings of WallFilletPreviewDecorator: each
# self-polls on its own scene.BIMPreviewProperties subgroup or on
# selection + hover gizmo state — zero cost when nothing is active.
BendPreviewDecorator.install(bpy.context)
MEPSegmentExtendPreviewDecorator.install(bpy.context)
# Always-installed: draw_lines() self-polls on selection + hover state
# for join / extend-to-wall / cursor-extend / cursor-split previews.
# Free when no preview-eligible state is active.
WallGizmoPreviewDecorator.install(bpy.context)
# Always-installed: draw() self-polls on active object + IfcDoor +
# parametric pset, so the cost is one bpy/IFC lookup per redraw when
# nothing eligible is selected.
DoorSwingReadonlyDecorator.install(bpy.context)
# Always-installed: draw() self-polls on the active object's array
# family membership, so installation has no cost when no array
# element is selected.
+13
View File
@@ -566,6 +566,19 @@ class IfcStore:
result = getattr(operator, "_modal")(context, event)
except:
bonsai.last_error = traceback.format_exc()
# An operator that mutated IFC then raised leaves the IFC graph captured
# by the transaction but the Blender side stale. Blender does not push an
# undo step for a raised operator (mirror of the CANCELLED-modal gap
# handled below), so we push one here so Ctrl+Z actually rewinds the
# partial mutation, then surface the recovery path to the user.
ifc_file = tool.Ifc.get()
if ifc_file and ifc_file.transaction and ifc_file.transaction.operations:
bpy.ops.ed.undo_push(message=f"Recover {operator.bl_idname}")
operator.report(
{"WARNING"},
"Operation partially completed (IFC changed, Blender state may be stale). "
"Press Ctrl+Z to restore the previous state.",
)
# Try to ensure undo will work since Blender undo does work in case of errors.
# As error come unexpectedly, it's important that user might have a chance to save the file
# before they got the error and not to lose the work they've done.
+26 -5
View File
@@ -345,9 +345,17 @@ class CadArcFrom3Points(bpy.types.Operator):
class CadOffset(bpy.types.Operator):
bl_idname = "bim.cad_offset"
bl_label = "CAD Offset"
bl_description = "Copy selected mesh geometry at provided offset. Mesh copied based on the current viewport angle."
bl_description = (
"Offset selected mesh geometry at provided distance, based on the current viewport angle. "
"Creates a copy by default, or moves the existing edges if Copy is disabled."
)
bl_options = {"REGISTER", "UNDO"}
distance: bpy.props.FloatProperty(name="Distance", default=0.1, subtype="DISTANCE")
copy: bpy.props.BoolProperty(
name="Copy",
description="Create a new offset copy of the geometry. If disabled, move the existing edges to the offset location",
default=True,
)
@classmethod
def poll(cls, context):
@@ -405,6 +413,11 @@ class CadOffset(bpy.types.Operator):
rotation = Matrix.Rotation(pi / 2, 2, "Z")
rotation_i = Matrix.Rotation(-pi / 2, 2, "Z")
# When not copying, the offset positions are gathered here and applied to
# the existing verts only after all loops are processed, so that the
# original coordinates are still available while computing offsets.
moved_verts = []
# Create loops from edges
loop_edges = set(edges)
loops = []
@@ -517,12 +530,15 @@ class CadOffset(bpy.types.Operator):
offset_length = self.distance / sqrt((1 + normals[0].dot(normals[1])) / 2)
offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ (new_normal * offset_length).to_3d())
new_vert = v1.co + offset
new_verts.append(bm.verts.new(new_vert))
else:
normal = (normals[0] * self.distance).to_3d()
offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ normal)
new_vert = v1.co + offset
if self.copy:
new_verts.append(bm.verts.new(new_vert))
else:
moved_verts.append((v1, new_vert))
processed_verts.add(v1.index)
@@ -531,9 +547,14 @@ class CadOffset(bpy.types.Operator):
v1 = v2
[bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
if is_closed:
bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0]))
if self.copy:
[bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
if is_closed:
bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0]))
# Move the existing edges to the offset location.
for vert, new_co in moved_verts:
vert.co = new_co
bm.verts.index_update()
bm.edges.index_update()
+6
View File
@@ -27,6 +27,11 @@ class BIMCadProperties(PropertyGroup):
resolution: bpy.props.IntProperty(name="Arc Resolution", min=1, default=1)
radius: bpy.props.FloatProperty(name="Radius", default=0.1, subtype="DISTANCE")
distance: bpy.props.FloatProperty(name="Distance", default=0.1, subtype="DISTANCE")
copy: bpy.props.BoolProperty(
name="Copy",
description="Create a new offset copy of the geometry. If disabled, move the existing edges to the offset location",
default=True,
)
x: bpy.props.FloatProperty(name="X", default=0.2, subtype="DISTANCE")
y: bpy.props.FloatProperty(name="Y", default=0.1, subtype="DISTANCE")
gable_roof_edge_angle: bpy.props.FloatProperty(
@@ -37,6 +42,7 @@ class BIMCadProperties(PropertyGroup):
resolution: int
radius: float
distance: float
copy: bool
x: float
y: float
gable_roof_edge_angle: float
@@ -256,6 +256,8 @@ class CadHotkey(bpy.types.Operator):
elif self.hotkey == "S_O":
row = self.layout.row()
row.prop(props, "distance")
row = self.layout.row()
row.prop(props, "copy")
elif self.hotkey == "S_R":
if tool.Geometry.is_profile_object_active():
@@ -291,7 +293,7 @@ class CadHotkey(bpy.types.Operator):
bpy.ops.bim.cad_fillet(resolution=self.props.resolution, radius=self.props.radius)
def hotkey_S_O(self):
bpy.ops.bim.cad_offset(distance=self.props.distance)
bpy.ops.bim.cad_offset(distance=self.props.distance, copy=self.props.copy)
def hotkey_S_Q(self):
obj = bpy.context.active_object
+102 -1
View File
@@ -159,6 +159,65 @@ _SPECIAL = {"=", " "} # Formula prefix, spaces
NUMERIC_INPUT_CHARS = _DIGITS | _OPERATORS | _METRIC_UNITS | _IMPERIAL_UNITS | _SPECIAL
_BONSAI_TRANSFORM_MACROS = frozenset(
{
# Bonsai overrides Blender's default move/duplicate keymaps with
# macros that wrap TRANSFORM_OT_translate. While a macro is the outer
# modal entry, the inner TRANSFORM_OT_translate does not surface in
# window.modal_operators — the macro's own idname does. The
# ``BIM_OT_`` prefix is what Blender returns from ``bl_idname`` at
# runtime (the class declaration uses the dotted ``bim.`` form).
"BIM_OT_override_move_macro", # G key
"BIM_OT_override_object_duplicate_move_macro", # Shift+D
"BIM_OT_override_object_duplicate_move_linked_macro", # Alt+D
"BIM_OT_object_duplicate_move_linked_aggregate_macro", # Ctrl+Shift+D
}
)
def _is_transform_modal_active(context) -> bool:
"""True iff a Blender transform modal (G/R/S and siblings, including
Bonsai's macro overrides) is currently driving per-frame ``matrix_world``
updates. Reads ``window.modal_operators`` the Blender 4.2+ collection of
running modal operators. Parametric gizmo groups gate poll + draw_prepare
on this so they hide for the duration of the drag instead of sliding
off-cursor as the matrix updates each frame."""
window = getattr(context, "window", None)
if window is None:
return False
modal_ops = getattr(window, "modal_operators", None)
if not modal_ops:
return False
for op in modal_ops:
idname = op.bl_idname
if idname.startswith("TRANSFORM_OT_") or idname in _BONSAI_TRANSFORM_MACROS:
return True
return False
def _hide_all_non_modal_gizmos(group) -> None:
"""Set ``hide = True`` on every gizmo in ``group`` whose own ``is_modal``
is False. Used by parametric ``draw_prepare`` to suppress visible
re-positioning while a transform modal is dragging ``matrix_world``."""
for gz in group.gizmos:
if not getattr(gz, "is_modal", False):
gz.hide = True
def apply_transform_modal_draw_gate(group, context) -> bool:
"""Combined gate for ``draw_prepare`` overrides: hide non-modal gizmos and
return ``True`` when a Blender transform modal is dragging matrix_world.
Returns ``False`` when no transform modal is active so callers can fall
through to their normal positioning logic. ``True`` means the caller must
early-return without touching matrix_basis the hidden gizmos will be
re-shown on the next idle frame once the modal exits."""
if not _is_transform_modal_active(context):
return False
_hide_all_non_modal_gizmos(group)
return True
class GizmoColor(Enum):
"""Color identifiers for dimension gizmos.
@@ -1694,6 +1753,33 @@ def billboarded_at(world_pos: Vector, billboard_rot: Matrix, scale: float = DEFA
return Matrix.Translation(world_pos) @ billboard_rot @ Matrix.Scale(scale, 4)
def billboarded_along_axis(
world_pos: Vector,
billboard_rot: Matrix,
axis_world: Vector,
scale: float = DEFAULT_BILLBOARD_SCALE,
) -> Matrix:
"""Composed matrix_basis like ``billboarded_at`` but with local +X
rotated about the camera-forward axis to align with ``axis_world``
projected onto the screen plane.
The gizmo still faces the camera (local +Z stays along camera-forward),
only its in-plane orientation changes. Falls back to plain
``billboarded_at`` when the axis is near-parallel to the view direction
(no usable screen projection)."""
camera_forward = billboard_rot @ Vector((0.0, 0.0, 1.0))
projected = axis_world - camera_forward * axis_world.dot(camera_forward)
if projected.length < 1e-4:
return billboarded_at(world_pos, billboard_rot, scale)
projected.normalize()
y_axis = camera_forward.cross(projected).normalized()
rot = Matrix.Identity(4)
rot[0][:3] = (projected.x, y_axis.x, camera_forward.x)
rot[1][:3] = (projected.y, y_axis.y, camera_forward.y)
rot[2][:3] = (projected.z, y_axis.z, camera_forward.z)
return Matrix.Translation(world_pos) @ rot @ Matrix.Scale(scale, 4)
def get_screen_up(billboard_rot: Matrix) -> Vector:
"""Camera's screen-up direction in world space — local +Y of the billboard
rotation. Use to lift a gizmo above an anchor in a way that stays
@@ -3255,11 +3341,16 @@ class GizmoArc(StaticTrisGizmoMixin, bpy.types.Gizmo):
"""Static quarter-arc glyph for swing visualisation.
Consumers needing the mirrored (RIGHT) visual apply a flip-X matrix to
``matrix_basis``."""
``matrix_basis``. ``outline_alpha = 0.0`` suppresses the inherited 8-pass
dark halo: an open curve has no enclosed silhouette for the dilation to
ring, so the offset passes read as ghost arcs rather than a uniform
outline. The arc's own cross-section thickness keeps it legible without
the halo."""
bl_idname = "VIEW3D_GT_arc"
__slots__ = ("custom_shape",)
tris = ARC_TRIS_DEFAULT
outline_alpha = 0.0
def _link_toggle_icon_tris(broken: bool) -> tuple[tuple[float, float, float], ...]:
@@ -4998,6 +5089,8 @@ class BillboardingGizmoGroupMixin:
self.position_gizmos(context)
def draw_prepare(self, context: bpy.types.Context) -> None:
if apply_transform_modal_draw_gate(self, context):
return
self.position_gizmos(context)
def setup_icon_gizmo(
@@ -5652,6 +5745,8 @@ class BaseParametricGizmoGroup:
if preview_base.any_preview_active(context):
return False
if _is_transform_modal_active(context):
return False
if cls.gizmo_pref_name:
prefs = tool.Blender.get_addon_preferences()
if not getattr(prefs.gizmos, cls.gizmo_pref_name, True):
@@ -6416,6 +6511,8 @@ class BaseParametricGizmoGroup:
"""
if not self.is_setup_complete():
return
if apply_transform_modal_draw_gate(self, context):
return
obj = context.active_object
if not obj:
return
@@ -6622,6 +6719,8 @@ class BaseSchematicGizmoGroup(BaseParametricGizmoGroup):
def draw_prepare(self, context: bpy.types.Context) -> None:
if not self.is_setup_complete():
return
if apply_transform_modal_draw_gate(self, context):
return
obj = context.active_object
if not obj:
return
@@ -7028,6 +7127,8 @@ class BaseIconActionGroup(BillboardingGizmoGroupMixin):
return False
if not tool.Blender.are_viewport_gizmos_enabled():
return False
if _is_transform_modal_active(context):
return False
return cls.is_eligible_object(obj)
def setup(self, context: bpy.types.Context) -> None:
@@ -546,6 +546,13 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator):
objs = [bpy.data.objects[obj_name]] if obj_name else context.selected_objects
self.file = tool.Ifc.get()
# Tessellated face sets (IfcTriangulatedFaceSet/IfcPolygonalFaceSet) were
# introduced in IFC4 and do not exist in IFC2X3. Catch this early so we
# don't silently fall back to a faceted brep after stripping materials.
if self.ifc_representation_class == "IfcTessellatedFaceSet" and self.file.schema == "IFC2X3":
self.report({"ERROR"}, "Tessellated face sets are not supported in IFC2X3.")
return {"CANCELLED"}
for obj in objs:
# TODO: write unit tests to see how this bulk operation handles
# contradictory ifc_representation_class values and when
@@ -3166,7 +3173,7 @@ class EnableEditingRepresentationItems(bpy.types.Operator, tool.Ifc.Operator):
product_reps = element.RepresentationMaps
item_aspect = {}
for product_rep in product_reps:
for aspect in product_rep.HasShapeAspects:
for aspect in getattr(product_rep, "HasShapeAspects", ()):
for aspect_rep in aspect.ShapeRepresentations:
if aspect_rep.ContextOfItems != representation.ContextOfItems:
continue
@@ -33,6 +33,7 @@ from . import (
handler,
host_add_opening_gizmo,
mep,
mep_bend_preview,
opening,
product,
profile,
@@ -84,6 +85,7 @@ classes = (
product.SetActiveType,
workspace.Hotkey,
workspace.BIM_MT_add_representation_item,
wall.AddPerpendicularWall,
wall.AddWallsFromSlab,
wall.AlignWall,
wall.CancelEditingWall,
@@ -184,10 +186,14 @@ classes = (
prop.BIMRailingProperties,
prop.BIMRoofProperties,
prop.BIMWallProperties,
prop.BIMPipeSegmentProperties,
prop.BIMDuctSegmentProperties,
prop.BIMPolylineProperties,
prop.BIMExternalParametricGeometryProperties,
prop.BIMBendPreviewProperties,
prop.BIMWallFilletPreviewProperties,
prop.BIMPreviewProperties,
prop.BIMParametricEditDialogPrefs,
ui.BIM_PT_array,
ui.BIM_PT_stair,
ui.BIM_PT_wall,
@@ -263,6 +269,29 @@ classes = (
mep.MEPAddObstruction,
mep.MEPAddTransition,
mep.MEPAddBend,
mep.MEPUnjoinAtPort,
mep.MEPRemoveTerminalFitting,
mep.MEPUnjoinPair,
mep.SelectMEPPathMembers,
mep.MEPJoinSegments,
mep_bend_preview.EnableBendPreview,
mep_bend_preview.FinishBendPreview,
mep_bend_preview.CancelBendPreview,
mep_bend_preview.EnableBendPreviewFromBend,
mep_bend_preview.GizmoBendPreview,
mep.EnableEditingPipeSegment,
mep.FinishEditingPipeSegment,
mep.CancelEditingPipeSegment,
mep.EnableEditingDuctSegment,
mep.FinishEditingDuctSegment,
mep.CancelEditingDuctSegment,
mep.ExtendPipeSegmentToCursor,
mep.ExtendDuctSegmentToCursor,
mep.SplitPipeSegmentAtCursor,
mep.SplitDuctSegmentAtCursor,
mep.GizmoPipeSegmentEdition,
mep.GizmoDuctSegmentEdition,
mep.GizmoMEPActions,
external.ApplyExternalParametricGeometry,
)
@@ -322,6 +351,9 @@ def register():
type=prop.BIMExternalParametricGeometryProperties
)
bpy.types.Scene.BIMPreviewProperties = bpy.props.PointerProperty(type=prop.BIMPreviewProperties)
bpy.types.WindowManager.BIMParametricEditDialogPrefs = bpy.props.PointerProperty(
type=prop.BIMParametricEditDialogPrefs
)
bpy.types.VIEW3D_MT_add.prepend(ui.add_menu)
bpy.app.handlers.load_post.append(handler.load_post)
@@ -347,6 +379,7 @@ def unregister():
tool.Parametric.unregister_object_properties()
del bpy.types.Object.BIMExternalParametricGeometryProperties
del bpy.types.Scene.BIMPreviewProperties
del bpy.types.WindowManager.BIMParametricEditDialogPrefs
bpy.app.handlers.load_post.remove(handler.load_post)
bpy.types.VIEW3D_MT_add.remove(ui.add_menu)
@@ -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
from typing import ClassVar
@@ -881,6 +883,36 @@ class EnableEditingParametric(bpy.types.Operator):
default="",
description="Operator bl_idname to invoke (e.g., 'bim.enable_editing_door').",
)
sibling_count: bpy.props.IntProperty(default=0, options={"HIDDEN"})
@staticmethod
def should_show_shared_rep_dialog(*, suppress: bool, has_entity: bool, sibling_count: int) -> bool:
"""Pure decision for the pre-edit warning. Returns ``True`` only when the
edit will silently mutate other elements' geometry AND the user has not
opted out of the warning for this session."""
if suppress or not has_entity:
return False
return sibling_count > 0
def invoke(self, context, event):
prefs = getattr(context.window_manager, "BIMParametricEditDialogPrefs", None)
suppress = bool(prefs and prefs.suppress_shared_rep_warning)
obj = context.active_object
element = tool.Ifc.get_entity(obj) if obj else None
self.sibling_count = tool.Model.get_sibling_occurrence_count(element) if element is not None else 0
if self.should_show_shared_rep_dialog(
suppress=suppress, has_entity=element is not None, sibling_count=self.sibling_count
):
return context.window_manager.invoke_props_dialog(self, width=400)
return self.execute(context)
def draw(self, context):
layout = self.layout
layout.label(text="Shared geometry", icon="ERROR")
layout.label(text=f"Geometry is shared with {self.sibling_count} other element(s).")
layout.label(text="Edits will affect them too.")
prefs = context.window_manager.BIMParametricEditDialogPrefs
layout.prop(prefs, "suppress_shared_rep_warning", text="Don't show this again for this session")
def execute(self, context):
# Malformed ``feature_enable_op`` (missing dot) would otherwise crash
+308 -53
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 math
from math import cos, pi, radians, sin, tan
from typing import Any, Literal
from typing import Any, Literal, NamedTuple
import blf
import bmesh
@@ -41,6 +43,11 @@ from mathutils import Matrix, Quaternion, Vector
import bonsai.core.geometry
import bonsai.tool as tool
from bonsai.bim.module.drawing.gizmos import (
ARC_SEGMENTS,
DOOR_SWING_ANGLE_MAX,
DOOR_SWING_ANGLE_MIN,
)
from bonsai.bim.module.drawing.helper import format_distance
@@ -88,15 +95,9 @@ class ProfileDecorator:
batch.draw(shader)
def draw_faces(self, bm, vertices_coords):
"""mutates original bm (triangulates it)
so the triangulation edges will be shown too
"""
traingulated_bm = bm
bmesh.ops.triangulate(traingulated_bm, faces=traingulated_bm.faces)
face_indices = [[v.index for v in f.verts] for f in traingulated_bm.faces]
"""Submit a non-mutating beauty-triangulated TRIS batch over ``bm``'s faces."""
faces_color = transparent_color(self.addon_prefs.decorator_color_special)
self.draw_batch("TRIS", vertices_coords, faces_color, face_indices)
tool.Blender.draw_bmesh_face_tris(bm, vertices_coords, faces_color, self.draw_batch)
def __call__(self, context, get_custom_bmesh=None, draw_faces=False, exit_edit_mode_callback=None):
self.addon_prefs = tool.Blender.get_addon_preferences()
@@ -2031,42 +2032,6 @@ class BoundingBoxDecorator:
co2.y -= y_overlap / 2 + min_spacing
def _stroke_lines_alpha(
context: bpy.types.Context,
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
color_rgb: tuple[float, float, float],
line_width: float,
line_alpha: float,
) -> None:
"""Render ``segments`` (a list of ``(start, end)`` tuples) as one
anti-aliased LINES batch in world space. Early-returns when
``context.region`` is unavailable (e.g. when called from a
``_RestrictContext``)."""
if not segments:
return
verts: list[tuple[float, float, float]] = []
indices: list[tuple[int, int]] = []
for start, end in segments:
base = len(verts)
verts.append(tuple(start))
verts.append(tuple(end))
indices.append((base, base + 1))
if not tool.Blender.validate_shader_batch_data(verts, indices):
return
region = getattr(context, "region", None)
if region is None:
return
shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
shader.bind()
shader.uniform_float("viewportSize", (region.width, region.height))
shader.uniform_float("lineWidth", line_width)
shader.uniform_float("color", (*color_rgb, line_alpha))
batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=indices)
gpu.state.blend_set("ALPHA")
batch.draw(shader)
gpu.state.blend_set("NONE")
def _fill_quads_alpha(
context: bpy.types.Context,
quads: list[
@@ -2081,8 +2046,7 @@ def _fill_quads_alpha(
alpha: float,
) -> None:
"""Render ``quads`` (each a 4-tuple of world-space corner verts in CCW
order) as one TRIS batch with two triangles per quad. Companion to
``_stroke_lines_alpha`` for filled previews."""
order) as one TRIS batch with two triangles per quad."""
if not quads:
return
verts: list[tuple[float, float, float]] = []
@@ -2108,6 +2072,181 @@ def _fill_quads_alpha(
gpu.state.blend_set("NONE")
def compute_mep_join_location():
"""Midpoint between the closest endpoint pair of two selected MEP
segments the world location where a connecting fitting (bend /
transition) would land. Returns ``None`` when prerequisites aren't met
(wrong cardinality, mixed non-MEP)."""
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 2:
return None
for obj in selected:
element = tool.Ifc.get_entity(obj)
if element is None or not tool.System.is_mep_element(element):
return None
a_start, a_end = tool.Model.get_flow_segment_axis(selected[0])
b_start, b_end = tool.Model.get_flow_segment_axis(selected[1])
pairs = [(a_start, b_start), (a_start, b_end), (a_end, b_start), (a_end, b_end)]
closest = min(pairs, key=lambda p: (p[0] - p[1]).length)
return (closest[0] + closest[1]) * 0.5
class MEPSegmentExtendPreviewDecorator(tool.Blender.ViewportDecorator):
"""Preview line for the MEP segment extend-to-cursor gizmo. Renders one
line from the segment's current end to the cursor's projection on the
segment's local Z axis when the extend icon is hovered. Self-gates every
draw on the viewport gizmo toggle and the per-feature ``extend`` pref."""
draw_method = "draw_line"
LINE_WIDTH = 1.5
LINE_ALPHA = 0.8
def draw_line(self, context: bpy.types.Context) -> None:
if not tool.Blender.are_viewport_gizmos_enabled():
return
prefs = tool.Blender.get_addon_preferences()
active = context.active_object
if active is None:
return
selected = list(tool.Blender.get_selected_objects())
if active not in selected or len(selected) != 1:
return
element = tool.Ifc.get_entity(active)
if element is None:
return
from bonsai.bim.module.model.mep import (
GizmoDuctSegmentEdition,
GizmoPipeSegmentEdition,
)
if tool.Parametric.is_pipe_segment(element):
gizmo_prefs = getattr(prefs.gizmos, "pipe_segment", None)
gizmo_cls = GizmoPipeSegmentEdition
elif tool.Parametric.is_duct_segment(element):
gizmo_prefs = getattr(prefs.gizmos, "duct_segment", None)
gizmo_cls = GizmoDuctSegmentEdition
else:
return
if gizmo_prefs is None or not getattr(gizmo_prefs, "enabled", True):
return
if not self._cursor_icon_hovered(gizmo_cls, "extend_gizmo", context):
return
current_length = max(c[2] for c in active.bound_box) if active.bound_box else 0.0
line = self._compute_extend_preview_line(active.matrix_world, context.scene.cursor.location, current_length)
if line is None:
return
start_world, end_world = line
color = tuple(prefs.decorator_color_selected[:3])
draw_polyline_segments(
context,
[(tuple(start_world), tuple(end_world))],
color,
self.LINE_ALPHA,
self.LINE_WIDTH,
)
@staticmethod
def _compute_extend_preview_line(
matrix_world: Matrix,
cursor_world: Vector,
current_length: float,
) -> tuple[Vector, Vector] | None:
"""Returns ``(current_end_world, target_end_world)`` or ``None`` when
no extend would happen (degenerate segment, or cursor on the existing
end). Target follows the cursor's raw local-Z projection unbounded —
the line stays visible past the segment origin (negative local Z)
because the user expects to see where they're pointing even when the
operator would floor it."""
if current_length <= 0:
return None
cursor_local = matrix_world.inverted() @ cursor_world
if abs(cursor_local.z - current_length) < 1e-6:
return None
current_end_world = matrix_world @ Vector((0.0, 0.0, current_length))
target_end_world = matrix_world @ Vector((0.0, 0.0, cursor_local.z))
return current_end_world, target_end_world
class BendPreviewDecorator(tool.Blender.ViewportDecorator):
"""GPU preview lines for the bend-creation flow.
Polls on ``scene.BIMPreviewProperties.bend.is_active`` and renders the
centerline + leg projections returned by ``mep.compute_bend_preview_polylines``.
The two leg lines (segment tangent point) show how each segment will
be shortened; the arc polyline approximates the bend curve. On invalid
geometry, draws the two rejected axes in warning colour instead so the
user sees why the bend cannot be placed.
Installed once per Blender session from ``bim/handler.py:load_post``.
Cheap to leave running because the first thing ``draw`` does is check
``is_active`` and return when False.
"""
LINE_WIDTH_LEG = 1.5
LINE_WIDTH_ARC = 2.5
LINE_ALPHA = 0.7
def draw(self, context: bpy.types.Context) -> None:
scene = context.scene
preview = getattr(scene, "BIMPreviewProperties", None)
props = preview.bend if preview is not None else None
if props is None or not props.is_active:
return
ifc_file = tool.Ifc.get()
if ifc_file is None:
return
try:
start_element = ifc_file.by_id(props.start_segment_id)
end_element = ifc_file.by_id(props.end_segment_id)
except Exception:
return
start_obj = tool.Ifc.get_object(start_element) if start_element else None
end_obj = tool.Ifc.get_object(end_element) if end_element else None
if start_obj is None or end_obj is None:
return
# Late import: decorator.py loads at addon enable but mep.py imports
# this module for the extend preview, so a module-level import would
# cycle.
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
preview = cached_compute_bend_preview_polylines(
start_obj, end_obj, props.start_length, props.end_length, props.radius
)
prefs = tool.Blender.get_addon_preferences()
if not preview["valid"]:
warning_color = tuple(prefs.decorator_color_error[:3])
axes = preview.get("invalid_axes") or []
if axes:
segments = [(tuple(a), tuple(b)) for a, b in axes]
draw_polyline_segments(context, segments, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
return
leg_color = tuple(prefs.decorations_colour[:3])
arc_color = tuple(prefs.decorator_color_selected[:3])
leg_a_far, leg_a_end = preview["leg_a"]
leg_b_far, leg_b_end = preview["leg_b"]
draw_polyline_segments(
context,
[(tuple(leg_a_far), tuple(leg_a_end)), (tuple(leg_b_far), tuple(leg_b_end))],
leg_color,
self.LINE_ALPHA,
self.LINE_WIDTH_LEG,
)
arc = preview["arc"]
if len(arc) >= 2:
arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)]
draw_polyline_segments(context, arc_segments, arc_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
"""GPU preview lines for the wall-fillet flow.
@@ -2169,15 +2308,15 @@ class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
(tuple(far_a), tuple(tangent_a)),
(tuple(far_b), tuple(tangent_b)),
]
_stroke_lines_alpha(context, legs, warning_color, self.LINE_WIDTH_LEG, self.LINE_ALPHA)
draw_polyline_segments(context, legs, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_LEG)
arc = geom.get("arc") or []
if len(arc) >= 2:
arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)]
_stroke_lines_alpha(context, arc_segments, warning_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
draw_polyline_segments(context, arc_segments, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
elif geom.get("invalid_axes"):
axes = geom["invalid_axes"]
segments = [(tuple(a), tuple(b)) for a, b in axes]
_stroke_lines_alpha(context, segments, warning_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
draw_polyline_segments(context, segments, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
return
leg_color = tuple(prefs.decorations_colour[:3])
@@ -2194,12 +2333,12 @@ class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
(tuple(far_a), tuple(geom["tangent_a"])),
(tuple(far_b), tuple(geom["tangent_b"])),
]
_stroke_lines_alpha(context, legs, leg_color, self.LINE_WIDTH_LEG, self.LINE_ALPHA)
draw_polyline_segments(context, legs, leg_color, self.LINE_ALPHA, self.LINE_WIDTH_LEG)
arc = geom["arc"]
if len(arc) >= 2:
arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)]
_stroke_lines_alpha(context, arc_segments, arc_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
draw_polyline_segments(context, arc_segments, arc_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
# Dim construction lines from arc_center to each tangent point so
# the radius reads as concrete during drag.
@@ -2209,7 +2348,9 @@ class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
(tuple(arc_center), tuple(geom["tangent_a"])),
(tuple(arc_center), tuple(geom["tangent_b"])),
]
_stroke_lines_alpha(context, construction, arc_color, self.LINE_WIDTH_CONSTRUCTION, self.CONSTRUCTION_ALPHA)
draw_polyline_segments(
context, construction, arc_color, self.CONSTRUCTION_ALPHA, self.LINE_WIDTH_CONSTRUCTION
)
@staticmethod
def _far_endpoint(reference_line, intersection):
@@ -2220,6 +2361,120 @@ class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
return p2 if d2 >= d1 else p1
class _DoorSwingArc(NamedTuple):
"""Parameters for one swing-arc draw call in door-local space."""
hinge_x: float
hinge_y: float
panel_width: float
x_mirror: bool
def _visible_arcs(door_type: str, overall_width: float, lining_offset: float) -> list[_DoorSwingArc]:
"""Arc specs for the parametric door swing visualisation, agnostic of
edit-mode state so the readonly preview and the editor view stay aligned.
Empty only for sliding-door types; unknown ``door_type`` values fall
through to a single left-hinged arc."""
if "SLIDING" in door_type:
return []
is_double = "DOUBLE_DOOR" in door_type
is_right_single = door_type.endswith("RIGHT") and not is_double
arcs = [
_DoorSwingArc(
hinge_x=overall_width if is_right_single else 0.0,
hinge_y=lining_offset,
panel_width=overall_width / 2 if is_double else overall_width,
x_mirror=is_right_single,
)
]
if is_double:
arcs.append(
_DoorSwingArc(
hinge_x=overall_width,
hinge_y=lining_offset,
panel_width=overall_width / 2,
x_mirror=True,
)
)
return arcs
# Unit quarter-arc samples shared with the edit-mode swing gizmo so the
# readonly arc traces the same curve. Re-scaled per draw via the per-arc
# transform.
_DOOR_SWING_ARC_ANGLE_MIN_RAD = math.radians(DOOR_SWING_ANGLE_MIN)
_DOOR_SWING_ARC_ANGLE_RANGE_RAD = math.radians(DOOR_SWING_ANGLE_MAX) - _DOOR_SWING_ARC_ANGLE_MIN_RAD
_DOOR_SWING_ARC_UNIT_POINTS: tuple[Vector, ...] = tuple(
Vector(
(
math.cos(_DOOR_SWING_ARC_ANGLE_MIN_RAD + _DOOR_SWING_ARC_ANGLE_RANGE_RAD * (_i / ARC_SEGMENTS)),
math.sin(_DOOR_SWING_ARC_ANGLE_MIN_RAD + _DOOR_SWING_ARC_ANGLE_RANGE_RAD * (_i / ARC_SEGMENTS)),
0.0,
)
)
for _i in range(ARC_SEGMENTS + 1)
)
class DoorSwingReadonlyDecorator(tool.Blender.ViewportDecorator):
"""Always-on swing-arc preview for the active Bonsai-parametric IfcDoor
when it is not currently in parametric edit mode. Matches the visual
contract of the parametric door's swing-arc gizmos so the hinge side
and opening direction can be read without entering edit mode.
Silent-skip cases (no draw, no error):
- active object missing / not selected / not an IfcDoor;
- door is mid-edit (the swing gizmo is already painting the arc);
- door has no ``BBIM_Door`` pset (legacy import, never edited in Bonsai)."""
LINE_WIDTH = 1.5
LINE_ALPHA = 0.8
def draw(self, context: bpy.types.Context) -> None:
obj = context.active_object
if obj is None or not obj.select_get():
return
element = tool.Ifc.get_entity(obj)
if element is None or not element.is_a("IfcDoor"):
return
props = getattr(obj, "BIMDoorProperties", None)
if props is not None and props.is_editing:
return
pset = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Door")
if not pset:
return
data = pset.get("data_dict")
if not data:
return
door_type = data.get("door_type", "")
overall_width_project = data.get("overall_width", 0.0)
lining_offset_project = (data.get("lining_properties") or {}).get("lining_offset", 0.0)
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
overall_width = overall_width_project * si_conversion
lining_offset = lining_offset_project * si_conversion
specs = _visible_arcs(door_type, overall_width, lining_offset)
if not specs:
return
prefs = tool.Blender.get_addon_preferences()
main_color = tuple(prefs.decorator_color_special[:3])
mw = obj.matrix_world
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
for spec in specs:
x_flip = Matrix.Scale(-1, 4, (1, 0, 0)) if spec.x_mirror else Matrix.Identity(4)
transform = (
Matrix.Translation(Vector((spec.hinge_x, spec.hinge_y, 0.0)))
@ Matrix.Scale(spec.panel_width, 4)
@ x_flip
)
world_main = mw @ transform
pts = [world_main @ p for p in _DOOR_SWING_ARC_UNIT_POINTS]
for i in range(len(pts) - 1):
segments.append((tuple(pts[i]), tuple(pts[i + 1])))
draw_polyline_segments(context, segments, main_color, self.LINE_ALPHA, self.LINE_WIDTH)
_BBOX_EDGES = (
(0, 1), (1, 2), (2, 3), (3, 0),
(4, 5), (5, 6), (6, 7), (7, 4),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,444 @@
# 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.
"""Bend-preview lifecycle for MEP segment joins.
Holds the four lifecycle operators (Enable / Finish / Cancel /
EnableFromBend) and the ``GizmoBendPreview`` group that surfaces the
tunable dimensions and validate/cancel icons during preview. Draft state
lives at ``Scene.BIMPreviewProperties.bend`` per CLAUDE.md §2.9 (Scene
for cross-element previews).
The geometry math (``compute_bend_preview_polylines``,
``_bend_profile_cross_section``, ``_sweep_profile_along_polyline``)
stays in ``mep.py`` because the commit operator ``MEPAddBend`` reuses
it; this module imports the polyline helper for per-frame gizmo
positioning. The GPU lines themselves are drawn by
``decorator.BendPreviewDecorator``, kept in ``decorator.py`` with its
sibling decorators."""
from typing import ClassVar
import bpy
import ifcopenshell.util.element
import ifcopenshell.util.unit
from mathutils import Matrix, Vector
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.model import preview_base
from bonsai.bim.module.model.mep import (
_is_bend_fitting,
_n_mep_selected,
cached_compute_bend_preview_polylines,
segments_are_parallel,
validate_bend_preconditions,
)
class EnableBendPreview(bpy.types.Operator):
"""Enter bend-preview mode for two selected MEP segments. Populates
scene.BIMPreviewProperties.bend with segment IFC ids and default
start_length / end_length / radius; no IFC mutation until finish."""
bl_idname = "bim.enable_bend_preview"
bl_label = "Enter Bend Preview"
bl_description = "Begin tuning bend parameters before committing the bend"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not _n_mep_selected(2):
cls.poll_message_set("Select exactly 2 MEP segments to bend.")
return False
return True
def execute(self, context):
selected = tool.Blender.get_selected_objects()
active = context.active_object
if active is None or active not in selected:
self.report({"ERROR"}, "Active object must be one of the selected MEP segments.")
return {"CANCELLED"}
other = next((o for o in selected if o is not active), None)
if other is None:
self.report({"ERROR"}, "Two MEP segments must be selected.")
return {"CANCELLED"}
active_element = tool.Ifc.get_entity(active)
other_element = tool.Ifc.get_entity(other)
if active_element is None or other_element is None:
self.report({"ERROR"}, "Both selected objects must be IFC elements.")
return {"CANCELLED"}
if segments_are_parallel(active, other):
self.report({"ERROR"}, "Bend preview is for non-parallel segments only.")
return {"CANCELLED"}
# Pre-check the same preconditions MEPAddBend enforces so the user
# sees the rejection here rather than after tuning a doomed preview.
precondition_error = validate_bend_preconditions(active_element, other_element)
if precondition_error is not None:
self.report({"ERROR"}, precondition_error)
return {"CANCELLED"}
preview_base.sync_uncommitted_moves([active, other])
props = preview_base.get_preview_props(context, "bend")
# Auto-cancel any prior preview so re-clicking join on a different
# pair doesn't silently commit the previous tuning.
if props is not None and props.is_active:
bpy.ops.bim.cancel_bend_preview()
props.start_segment_id = active_element.id()
props.end_segment_id = other_element.id()
props.start_length = 0.1
props.end_length = 0.1
props.radius = 0.2
props.is_active = True
return {"FINISHED"}
class FinishBendPreview(bpy.types.Operator):
"""Commit the previewed bend with the tuned parameters and exit preview.
Preview state survives a failed commit so the user can re-tune without
re-selecting."""
bl_idname = "bim.finish_bend_preview"
bl_label = "Apply Bend"
bl_description = "Commit the bend with the previewed parameters"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
return preview_base.commit_preview(
self,
context,
"bend",
"mep_add_bend",
("start_segment_id", "end_segment_id", "start_length", "end_length", "radius", "editing_bend_id"),
)
class CancelBendPreview(bpy.types.Operator):
"""Exit bend preview without committing."""
bl_idname = "bim.cancel_bend_preview"
bl_label = "Cancel Bend"
bl_description = "Discard the previewed bend"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
if context.screen is None:
return {"CANCELLED"}
props = preview_base.get_preview_props(context, "bend")
if props is None or not props.is_active:
return {"CANCELLED"}
preview_base.clear_preview_state(props)
return {"FINISHED"}
class EnableBendPreviewFromBend(bpy.types.Operator):
"""Re-open the bend preview on an existing bend fitting.
Resolves the two connected segments via the bend's ports +
``IfcRelConnectsPorts``, reads parametric values back from the bend's
``BBIM_Fitting`` pset, and flags the preview so committing replaces
the existing bend in place."""
bl_idname = "bim.enable_bend_preview_from_bend"
bl_label = "Edit Bend"
bl_description = "Re-open the bend preview to retune an existing bend"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
active = context.active_object
if active is None:
cls.poll_message_set("No active object.")
return False
element = tool.Ifc.get_entity(active)
if element is None or not _is_bend_fitting(element):
cls.poll_message_set("Active object must be a bend fitting.")
return False
return True
def execute(self, context):
active = context.active_object
bend_element = tool.Ifc.get_entity(active)
if bend_element is None or not _is_bend_fitting(bend_element):
self.report({"ERROR"}, "Active object is not a bend fitting.")
return {"CANCELLED"}
connected_segments: list = []
for port in tool.System.get_ports(bend_element):
connected_port = tool.System.get_connected_port(port)
if connected_port is None:
continue
related = tool.System.get_port_relating_element(connected_port)
if related is not None and related.is_a("IfcFlowSegment") and related not in connected_segments:
connected_segments.append(related)
if len(connected_segments) != 2:
self.report(
{"ERROR"},
f"Bend has {len(connected_segments)} connected segments; need exactly 2 to re-edit.",
)
return {"CANCELLED"}
# Read parametric values from the bend type's BBIM_Fitting pset. The
# type carries the canonical parameters; querying the occurrence
# would force a get_type round-trip and miss user-edited types.
bend_type = ifcopenshell.util.element.get_type(bend_element)
if bend_type is None:
self.report({"ERROR"}, "Bend fitting has no type to read parameters from.")
return {"CANCELLED"}
bend_type_obj = tool.Ifc.get_object(bend_type)
if bend_type_obj is None:
self.report({"ERROR"}, "Bend type has no Blender object — cannot read pset.")
return {"CANCELLED"}
bbim = tool.Model.get_modeling_bbim_pset_data(bend_type_obj, "BBIM_Fitting")
if bbim is None:
self.report({"ERROR"}, "Bend fitting has no BBIM_Fitting pset — not a parametric bend.")
return {"CANCELLED"}
data = bbim.get("data_dict", {})
props = preview_base.get_preview_props(context, "bend")
if props is not None and props.is_active:
bpy.ops.bim.cancel_bend_preview()
# Segment order is load-bearing: the bend's lateral sign and z-axis
# flip are derived from which segment is "start" vs "end". Re-edit
# must reuse the same pairing as the original create so the recreate
# lands at the same orientation.
start_segment, end_segment = connected_segments
props.start_segment_id = start_segment.id()
props.end_segment_id = end_segment.id()
# Pset values are in IFC native units; scene units come from si_conversion.
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
props.start_length = float(data.get("start_length", 0.1)) * si_conversion
props.end_length = float(data.get("end_length", 0.1)) * si_conversion
props.radius = float(data.get("radius", 0.2)) * si_conversion
props.editing_bend_id = bend_element.id()
props.is_active = True
return {"FINISHED"}
def _bend_preview_segments(context):
"""Resolve the two segment objects from the scene-level preview props.
Re-resolves by IFC id each frame so undo / file reload during preview
never dangles a stale bpy reference."""
props = context.scene.BIMPreviewProperties.bend
ifc_file = tool.Ifc.get()
if ifc_file is None or not props.is_active:
return None, None
try:
start_element = ifc_file.by_id(props.start_segment_id)
end_element = ifc_file.by_id(props.end_segment_id)
except Exception:
return None, None
start_obj = tool.Ifc.get_object(start_element) if start_element else None
end_obj = tool.Ifc.get_object(end_element) if end_element else None
return start_obj, end_obj
def _gizmo_x_matrix(location: Vector, x_direction: Vector) -> Matrix:
"""Build a 4x4 matrix placing a gizmo at ``location`` with its local +X
axis aligned to ``x_direction`` in world space. ``BIM_GT_gizmo_dimension``
draws + drags along local +X by convention."""
x = x_direction.normalized()
seed = Vector((0, 0, 1)) if abs(x.z) < 0.9 else Vector((1, 0, 0))
y = (seed - x * seed.dot(x)).normalized()
z = x.cross(y)
mat = Matrix.Identity(4)
mat[0][:3] = (x.x, y.x, z.x)
mat[1][:3] = (x.y, y.y, z.y)
mat[2][:3] = (x.z, y.z, z.z)
mat.translation = location
return mat
class GizmoBendPreview(bpy.types.GizmoGroup):
"""Interactive gizmo group for the bend preview flow.
Three dimension widgets drag start_length / end_length / radius; two
icon gizmos commit or cancel. When the geometry is degenerate the
dimensions and validate hide but cancel stays visible so the user
always has an exit."""
bl_idname = "OBJECT_GGT_bim_bend_preview"
bl_label = "Bend Preview Gizmos"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
ICON_SCALE: ClassVar[float] = 0.375
ICON_SPACING_X: ClassVar[float] = 0.4
ICON_Z_OFFSET: ClassVar[float] = 1.5
@classmethod
def poll(cls, context):
preview = getattr(context.scene, "BIMPreviewProperties", None)
props = preview.bend if preview is not None else None
if props is None or not props.is_active:
return False
if not tool.Blender.are_viewport_gizmos_enabled():
return False
ifc_file = tool.Ifc.get()
if ifc_file is None:
return False
try:
ifc_file.by_id(props.start_segment_id)
ifc_file.by_id(props.end_segment_id)
except (RuntimeError, KeyError):
return False
return True
def setup(self, context):
prefs = tool.Blender.get_addon_preferences()
default_color = tuple(prefs.decorations_colour[:3])
highlight_color = tuple(prefs.decorator_color_selected[:3])
_props = preview_base.make_props_callback("bend")
def setup_dimension(attr: str, prop_name: str, invert_delta: bool = False) -> bpy.types.Gizmo:
gz = self.gizmos.new("BIM_GT_gizmo_dimension")
gz.move_get_cb = preview_base.make_dim_getter(_props, attr)
gz.move_set_cb = preview_base.make_dim_setter(_props, attr)
gz.axis = Vector((1, 0, 0))
gz.invert_delta = invert_delta
gz.delta_scale = 1.0
gz.prop_name = prop_name
gz.gizmo_group = self
gz.color = default_color
gz.color_highlight = highlight_color
gz.alpha = 1.0
gz.use_draw_modal = True
gz.use_draw_scale = False
gz.text_offset_sign = 1
gz.text_alignment = gizmo.TextAlignment.CENTER
gz.show_start_arrow = False
gz.show_end_arrow = True
gz.show_extension_lines = False
gz.text_formatter = None
return gz
self.start_dim = setup_dimension("start_length", "Start Length")
self.end_dim = setup_dimension("end_length", "End Length")
self.radius_dim = setup_dimension("radius", "Radius")
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
self.validate_icon = self.gizmos.new("VIEW3D_GT_validate")
self.validate_icon.use_draw_scale = False
self.validate_icon.color = BaseParametricGizmoGroup.COLOR_GREEN
self.validate_icon.color_highlight = highlight_color
self.validate_icon.target_set_operator("bim.finish_bend_preview")
self.cancel_icon = self.gizmos.new("VIEW3D_GT_cancel")
self.cancel_icon.use_draw_scale = False
self.cancel_icon.color = BaseParametricGizmoGroup.COLOR_RED
self.cancel_icon.color_highlight = highlight_color
self.cancel_icon.target_set_operator("bim.cancel_bend_preview")
def refresh(self, context):
self._position_gizmos(context)
def draw_prepare(self, context):
self._position_gizmos(context)
def _position_gizmos(self, context):
"""Place gizmos at the bend intersection using the current scene
props. Cancel stays visible on degenerate geometry so the user
always has an exit; the other widgets hide when there's no defined
tangent / arc to anchor them on."""
start_obj, end_obj = _bend_preview_segments(context)
if start_obj is None or end_obj is None:
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon, self.cancel_icon):
gz.hide = True
return
props = context.scene.BIMPreviewProperties.bend
preview = cached_compute_bend_preview_polylines(
start_obj, end_obj, props.start_length, props.end_length, props.radius
)
if not preview["valid"]:
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon):
gz.hide = True
self.cancel_icon.hide = False
axes = preview.get("invalid_axes") or []
if axes:
intersection_point = axes[0][1]
billboard_rot = gizmo.get_billboard_rotation(context)
anchor = intersection_point + Vector((0, 0, self.ICON_Z_OFFSET))
self.cancel_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=self.ICON_SCALE)
return
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon, self.cancel_icon):
gz.hide = False
leg_a_far, leg_a_end = preview["leg_a"]
leg_b_far, leg_b_end = preview["leg_b"]
toward_bend_a = (
(leg_a_end - leg_a_far).normalized() if (leg_a_end - leg_a_far).length > 1e-6 else Vector((0, 0, 1))
)
toward_bend_b = (
(leg_b_end - leg_b_far).normalized() if (leg_b_end - leg_b_far).length > 1e-6 else Vector((0, 0, 1))
)
leg_a_tangent = leg_a_end + toward_bend_a * props.start_length
leg_b_tangent = leg_b_end + toward_bend_b * props.end_length
# axis is set in world space every frame so the drag projection
# matches the visual regardless of either segment's matrix_world.
self.start_dim.matrix_basis = _gizmo_x_matrix(leg_a_tangent, -toward_bend_a)
self.start_dim.axis = -toward_bend_a
self.start_dim.set_dimension_length(props.start_length)
self.end_dim.matrix_basis = _gizmo_x_matrix(leg_b_tangent, -toward_bend_b)
self.end_dim.axis = -toward_bend_b
self.end_dim.set_dimension_length(props.end_length)
arc = preview["arc"]
if len(arc) >= 3:
mid = len(arc) // 2
chord_mid = (arc[0] + arc[-1]) * 0.5
toward_mid = arc[mid] - chord_mid
if toward_mid.length > 1e-6:
toward_mid = toward_mid.normalized()
half_chord = (arc[-1] - arc[0]).length * 0.5
center_dist = max(0.0, props.radius * props.radius - half_chord * half_chord) ** 0.5
arc_center = chord_mid - toward_mid * center_dist
radial_out = arc[mid] - arc_center
if radial_out.length > 1e-6:
radial_out.normalize()
inward = -radial_out
self.radius_dim.matrix_basis = _gizmo_x_matrix(arc[mid], inward)
self.radius_dim.axis = inward
self.radius_dim.set_dimension_length(props.radius)
else:
self.radius_dim.hide = True
else:
self.radius_dim.hide = True
else:
self.radius_dim.hide = True
billboard_rot = gizmo.get_billboard_rotation(context)
anchor_base = arc[len(arc) // 2] if arc else (leg_a_end + leg_b_end) * 0.5
anchor = anchor_base + Vector((0, 0, self.ICON_Z_OFFSET))
offset_x = billboard_rot @ Vector((self.ICON_SPACING_X, 0.0, 0.0))
self.validate_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=self.ICON_SCALE)
self.cancel_icon.matrix_basis = gizmo.billboarded_at(anchor + offset_x, billboard_rot, scale=self.ICON_SCALE)
+65 -17
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 collections.abc import Sequence
from math import radians
@@ -207,6 +209,21 @@ def _get_cached_world_draw_data(
# handle each call), so they stay drawable across frames.
_batch_cache: dict[tuple[int, str], tuple[int, "gpu.types.GPUBatch"]] = {}
# CAD hidden-line convention for the occluded back-pass: world-space dashes so
# density stays coherent across zoom. Dash + gap = period; dash_width controls
# the "on" portion.
_DASH_PERIOD_METERS: float = 0.20
_DASH_WIDTH_METERS: float = 0.10
# Solid front pass is rendered wider than the dashed back pass so its halo
# overpowers the dashed center on visible edges even when the WIRE-display
# overlay biases the depth buffer at outline pixels.
_DASH_LINE_WIDTH: float = 1.5
_SOLID_LINE_WIDTH: float = 2.5
# Per-iteration default line width used by every non-occlusion draw call in
# this decorator's ``__call__``. Restored after each occlusion pair so the
# next draw isn't silently inheriting the wider solid-pass override.
_DEFAULT_LINE_WIDTH: float = 2.0
def _get_cached_batch_or_none(cache_key: tuple[int, str]) -> "gpu.types.GPUBatch | None":
uid = cache_key[0]
@@ -1189,22 +1206,55 @@ class DecorationsHandler:
shader.uniform_float("color", color)
batch.draw(shader)
def _draw_lines_with_occlusion(self, verts, color, edges_indices, occluded_alpha: float = 0.25, cache_key=None):
# One batch, two draws: front pass at full color, occluded pass at
# `occluded_alpha`. Save/restore depth_test matches the pattern in
# bim/module/structural/decorator.py so callers' state survives.
batch = self._get_or_build_batch(self.line_shader, "LINES", verts, edges_indices, cache_key=cache_key)
if batch is None:
def _draw_lines_with_occlusion(self, verts, color, edges_indices, cache_key=None):
# Two-pass CAD hidden-line convention. Both passes use POLYLINE_UNIFORM_COLOR.
#
# The solid front pass is rendered WIDER than the dashed back pass so it
# produces a halo around the line center, beyond the depth-bias zone that
# Blender's overlay engine writes when an opening is set to WIRE display.
# Without the width difference, the wire bias makes the center-pixel
# ``LESS_EQUAL`` comparison fail (line ends up slightly behind the biased
# wire depth) so the solid pass would lose to the dashed back pass even
# on visible edges. The halo gives the solid pass enough screen-space to
# overpower the dashed pattern visually.
#
# Dashed renders first at the standard width so the solid overlay's wider
# halo cleanly hides it on visible edges; on occluded edges the solid
# ``LESS_EQUAL`` pass fails against the wall depth and the dashed remains.
front_batch = self._get_or_build_batch(self.line_shader, "LINES", verts, edges_indices, cache_key=cache_key)
if front_batch is None:
return
dashed_cache_key = (cache_key[0], cache_key[1] + "_dashed") if cache_key is not None else None
dash_batch = None
if dashed_cache_key is not None:
dash_batch = _get_cached_batch_or_none(dashed_cache_key)
if dash_batch is None:
dash_verts, dash_edges = tool.Blender.build_dashed_line_segments(
verts, edges_indices, _DASH_PERIOD_METERS, _DASH_WIDTH_METERS
)
dash_batch = self._get_or_build_batch(self.line_shader, "LINES", dash_verts, dash_edges)
if dash_batch is not None and dashed_cache_key is not None:
_store_batch_in_cache(dashed_cache_key, dash_batch)
original_depth_test = gpu.state.depth_test_get()
front_color = list(color)
front_color[3] = 1.0
self.line_shader.uniform_float("color", front_color)
if dash_batch is not None:
self.line_shader.uniform_float("lineWidth", _DASH_LINE_WIDTH)
gpu.state.depth_test_set("ALWAYS")
dash_batch.draw(self.line_shader)
self.line_shader.uniform_float("lineWidth", _SOLID_LINE_WIDTH)
gpu.state.depth_test_set("LESS_EQUAL")
self.line_shader.uniform_float("color", color)
batch.draw(self.line_shader)
gpu.state.depth_test_set("GREATER")
dimmed = list(color)
dimmed[3] = occluded_alpha
self.line_shader.uniform_float("color", dimmed)
batch.draw(self.line_shader)
front_batch.draw(self.line_shader)
# Restore the per-iteration default set at the top of __call__ so
# subsequent draws (the HalfSpaceSolid arrow, future call-sites) are
# not silently affected by the front-pass width override.
self.line_shader.uniform_float("lineWidth", _DEFAULT_LINE_WIDTH)
gpu.state.depth_test_set(original_depth_test)
def __call__(self, context):
@@ -1240,7 +1290,7 @@ class DecorationsHandler:
self.line_shader.bind() # required to be able to change uniforms of the shader
# POLYLINE_UNIFORM_COLOR specific uniforms
self.line_shader.uniform_float("viewportSize", (context.region.width, context.region.height))
self.line_shader.uniform_float("lineWidth", 2.0)
self.line_shader.uniform_float("lineWidth", _DEFAULT_LINE_WIDTH)
# general shader
self.shader = gpu.shader.from_builtin("UNIFORM_COLOR")
@@ -1278,9 +1328,7 @@ class DecorationsHandler:
self.draw_batch("LINES", verts, selected_elements_color, selected_edges)
self.draw_batch("POINTS", unselected_vertices, unselected_elements_color)
self.draw_batch("POINTS", selected_vertices, selected_elements_color)
obj.data.calc_loop_triangles()
tris = [tuple(t.vertices) for t in obj.data.loop_triangles]
self.draw_batch("TRIS", verts, transparent_color(special_elements_color), tris)
tool.Blender.draw_bmesh_face_tris(bm, verts, transparent_color(special_elements_color), self.draw_batch)
else:
line_verts, verts, edges_indices, tris = _get_cached_world_draw_data(obj)
color = selected_elements_color if obj in context.selected_objects else special_elements_color
@@ -163,6 +163,59 @@ def sync_uncommitted_moves(objects: list) -> None:
tool.Geometry.commit_placement_if_moved(obj, apply_scale=False)
def clear_preview_state(props: bpy.types.PropertyGroup) -> None:
"""Reset a preview PropertyGroup to its idle state on commit / cancel.
Sets ``is_active`` to False and zeros every ``IntProperty`` whose name
ends in ``_id`` (the entity-reference convention every preview follows).
Other fields are left at their last value defaults are re-applied on
the next enable, so leaving them alone avoids a redundant write."""
props.is_active = False
for name, rna in props.bl_rna.properties.items():
if name.endswith("_id") and rna.type == "INT":
setattr(props, name, 0)
# --- Standard Finish flow ----------------------------------------------------
def commit_preview(
operator: bpy.types.Operator,
context: bpy.types.Context,
attr: str,
target_op_name: str,
kwarg_names: tuple[str, ...],
) -> set[str]:
"""Standard Finish-Preview dispatch: validate context + active preview,
read kwargs off the draft, call ``bpy.ops.bim.<target_op_name>(**kwargs)``,
and clear the preview on success.
The dispatched operator's own ``self.report({"ERROR"})`` paths are promoted
by ``bpy.ops`` to ``RuntimeError`` catching it here surfaces the message
to the user via ``operator.report`` rather than leaving Blender's operator
state half-broken (which silently disables downstream gizmo polls).
Returns the dispatched operator's result set verbatim so callers can
pass it straight back from their own ``execute``."""
if context.screen is None:
return {"CANCELLED"}
props = get_preview_props(context, attr)
if props is None or not props.is_active:
return {"CANCELLED"}
if tool.Ifc.get() is None:
operator.report({"ERROR"}, "No IFC file loaded.")
return {"CANCELLED"}
kwargs = {name: getattr(props, name) for name in kwarg_names}
try:
result = getattr(bpy.ops.bim, target_op_name)(**kwargs)
except RuntimeError as exc:
operator.report({"ERROR"}, str(exc))
return {"CANCELLED"}
if "FINISHED" in result:
clear_preview_state(props)
return result
# --- Esc dispatch ------------------------------------------------------------
PREVIEW_CANCEL_OPS: tuple[tuple[str, str], ...] = (
@@ -1157,7 +1157,8 @@ class DrawPolylineProfile(bpy.types.Operator, PolylineOperator, tool.Ifc.Operato
DumbProfileJoiner().join_V(profile2["obj"], profile1["obj"])
if connect_IfcFlowSegments:
bpy.ops.bim.mep_connect_elements(
obj1_name=profile1["obj"].name, obj2_name=profile2["obj"].name
obj1_guid=tool.Ifc.get_entity(profile1["obj"]).GlobalId,
obj2_guid=tool.Ifc.get_entity(profile2["obj"]).GlobalId,
)
def modal(self, context, event):
+185
View File
@@ -242,6 +242,20 @@ def update_roof(self: "BIMRoofProperties", context: bpy.types.Context) -> None:
_get_updater("roof", "update_roof_modifier_bmesh")(obj)
def update_pipe_segment(self: "BIMPipeSegmentProperties", context: bpy.types.Context) -> None:
"""Regenerate pipe-segment preview mesh from props during edit. Does NOT touch IFC."""
obj = context.active_object
if obj and self.is_editing:
_get_updater("mep", "regenerate_pipe_segment_mesh_from_props")(obj)
def update_duct_segment(self: "BIMDuctSegmentProperties", context: bpy.types.Context) -> None:
"""Regenerate duct-segment preview mesh from props during edit. Does NOT touch IFC."""
obj = context.active_object
if obj and self.is_editing:
_get_updater("mep", "regenerate_duct_segment_mesh_from_props")(obj)
class BIMModelProperties(PropertyGroup):
ifc_class: bpy.props.EnumProperty(items=get_ifc_class, name="Construction Class", update=update_ifc_class)
relating_type_id: bpy.props.EnumProperty(
@@ -1924,6 +1938,155 @@ class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup):
sverchok_nodes: Union[sverchok.node_tree.SverchCustomTree, None]
class BIMPipeSegmentProperties(PropertyGroup):
"""Transient draft state for parametric pipe-segment gizmo editing."""
is_editing: bpy.props.BoolProperty(
default=False,
description="True while pipe-segment 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 shape; 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_pipe_segment,
description="Pipe-segment extrusion length (preview value; committed on finish).",
)
snap_length: bpy.props.FloatProperty(
description="Snapshot of length at edit-enable; commit skips no-op writes.",
)
snap_object_scale_z: bpy.props.FloatProperty(
default=1.0,
description=(
"Snapshot of obj.scale.z at edit-enable. Cancel / no-op-finish restore "
"this exact value so a user's non-identity pre-edit scale isn't silently "
"zeroed by the scale-based preview."
),
)
if TYPE_CHECKING:
is_editing: bool
mesh_dirty: bool
length: float
snap_length: float
snap_object_scale_z: float
class BIMDuctSegmentProperties(PropertyGroup):
"""Transient draft state for parametric duct-segment gizmo editing."""
is_editing: bpy.props.BoolProperty(
default=False,
description="True while duct-segment 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 shape; 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_duct_segment,
description="Duct-segment extrusion length (preview value; committed on finish).",
)
snap_length: bpy.props.FloatProperty(
description="Snapshot of length at edit-enable; commit skips no-op writes.",
)
snap_object_scale_z: bpy.props.FloatProperty(
default=1.0,
description=(
"Snapshot of obj.scale.z at edit-enable. Cancel / no-op-finish restore "
"this exact value so a user's non-identity pre-edit scale isn't silently "
"zeroed by the scale-based preview."
),
)
if TYPE_CHECKING:
is_editing: bool
mesh_dirty: bool
length: float
snap_length: float
snap_object_scale_z: float
class BIMBendPreviewProperties(PropertyGroup):
"""Scene-level pending state for the bend-creation preview flow.
Scene-level (not per-object) because the bend involves two segments by
IFC id neither alone owns the draft."""
is_active: bpy.props.BoolProperty(
default=False,
options={"SKIP_SAVE"},
description="True while the bend-creation preview flow is active.",
)
start_segment_id: bpy.props.IntProperty(
default=0,
options={"SKIP_SAVE"},
description="IFC element id of the start (active) segment.",
)
end_segment_id: bpy.props.IntProperty(
default=0,
options={"SKIP_SAVE"},
description="IFC element id of the end (other selected) segment.",
)
start_length: bpy.props.FloatProperty(
name="Start Length",
default=0.1,
min=0.001,
subtype="DISTANCE",
description="Length of the bend fitting's tangent leg on the start (active) segment side",
)
end_length: bpy.props.FloatProperty(
name="End Length",
default=0.1,
min=0.001,
subtype="DISTANCE",
description="Length of the bend fitting's tangent leg on the end (other) segment side",
)
radius: bpy.props.FloatProperty(
name="Radius",
default=0.2,
min=0.001,
subtype="DISTANCE",
description="Inner radius of the bend curve",
)
editing_bend_id: bpy.props.IntProperty(
default=0,
options={"SKIP_SAVE"},
description=(
"IFC element id of an existing bend fitting being re-edited "
"(non-zero only on the pen-icon re-edit flow). The create "
"operator deletes this bend + its port connections before "
"recreating with the new parameters."
),
)
if TYPE_CHECKING:
is_active: bool
start_segment_id: int
end_segment_id: int
start_length: float
end_length: float
radius: float
editing_bend_id: int
class BIMWallFilletPreviewProperties(PropertyGroup):
"""Scene-level pending state for the wall-fillet preview flow.
@@ -1980,7 +2143,29 @@ class BIMWallFilletPreviewProperties(PropertyGroup):
class BIMPreviewProperties(PropertyGroup):
"""Umbrella for parametric-edit preview drafts attached to ``Scene``."""
bend: bpy.props.PointerProperty(type=BIMBendPreviewProperties)
wall_fillet: bpy.props.PointerProperty(type=BIMWallFilletPreviewProperties)
if TYPE_CHECKING:
bend: BIMBendPreviewProperties
wall_fillet: BIMWallFilletPreviewProperties
class BIMParametricEditDialogPrefs(PropertyGroup):
"""Session-scoped flag for the parametric-edit pen-icon dispatcher.
Attached to ``WindowManager`` so the state lives for one Blender session
and resets on restart the right scope for "don't show this again for
this session" toggles."""
suppress_shared_rep_warning: bpy.props.BoolProperty(
name="Suppress shared-representation warning",
description=(
"When true, the pen-icon dispatcher skips the shared-geometry "
"confirmation dialog. Resets on Blender restart."
),
default=False,
)
if TYPE_CHECKING:
suppress_shared_rep_warning: bool
+387 -76
View File
@@ -51,6 +51,7 @@ from mathutils import Matrix, Vector
import bonsai.core.geometry
import bonsai.core.model as core
import bonsai.core.root
import bonsai.core.spatial
import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore
from bonsai.bim.module.drawing import gizmos as gizmo
@@ -62,7 +63,6 @@ from bonsai.bim.module.model.decorator import (
PolylineDecorator,
ProductDecorator,
_fill_quads_alpha,
_stroke_lines_alpha,
bbox_world_edges,
draw_polyline_segments,
)
@@ -76,6 +76,24 @@ _FILLET_DEFAULT_RADIUS_M = 0.5 # Fallback when the leg-fraction heuristic canno
_FILLET_DEFAULT_LEG_FRACTION = 0.25 # Quarter of the shorter available leg — visible without overrunning either wall.
_FILLET_MIN_RADIUS_M = 0.001 # Lower bound — anything smaller renders as a single pixel at common viewport scales.
_ARRAY_CHILD_POLL_MESSAGE = "Selection includes an array child; operate on the array parent instead."
def _poll_reject_array_children(operator_cls) -> bool:
"""Shared operator-poll guard: set the array-child poll message on
``operator_cls`` and return ``True`` when the selection includes a Bonsai
array child, so the caller can early-return ``False`` from its ``poll``.
Topology mutations against an array child are wiped by the next
``regenerate_array`` and would orphan the child's GUID in the parent's
``BBIM_Array.Data``. Gizmo groups have their own filter via
``_wall_topology_gizmo_poll_gate``; this helper exists so operator
classes share the same rejection in one line."""
if tool.Blender.Modifier.any_selected_is_array_child():
operator_cls.poll_message_set(_ARRAY_CHILD_POLL_MESSAGE)
return True
return False
def _wall_gizmo_poll_gate(context: bpy.types.Context) -> bool:
"""Common pre-flight gate every wall gizmo group's ``poll`` runs first:
@@ -90,6 +108,21 @@ def _wall_gizmo_poll_gate(context: bpy.types.Context) -> bool:
return True
def _wall_topology_gizmo_poll_gate(context: bpy.types.Context) -> bool:
"""Tighter gate for wall topology gizmos (merge / join / extend / unjoin
/ fillet): base ``_wall_gizmo_poll_gate`` plus an array-child filter.
Array children are managed replicas any topology mutation is wiped by
the next ``regenerate_array``, and ``merge`` would orphan a GUID listed
in the parent's ``BBIM_Array.Data``. Host-opening gizmos (add / toggle)
deliberately stay on the base gate so openings remain authorable on
children, which the array regen pipeline preserves."""
if not _wall_gizmo_poll_gate(context):
return False
if tool.Blender.Modifier.any_selected_is_array_child():
return False
return True
def _wall_has_openings(gz_group: bpy.types.GizmoGroup) -> bool:
"""``visible_when`` predicate for the toggle_openings idle slot. Returns
True iff the active object's IFC element exposes a non-empty HasOpenings
@@ -243,6 +276,8 @@ class UnjoinWalls(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Oper
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
if _poll_reject_array_children(cls):
return False
return True
def _perform(self, context):
@@ -269,6 +304,8 @@ class UnjoinWallPathConnection(_CommitWallDraftsFirstMixin, bpy.types.Operator,
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
if _poll_reject_array_children(cls):
return False
return True
def _perform(self, context):
@@ -306,20 +343,9 @@ class UnjoinWallPathConnection(_CommitWallDraftsFirstMixin, bpy.types.Operator,
for rel in rels:
bonsai.core.geometry.remove_connection(tool.Geometry, connection=rel)
# Recreate body+axis on both walls so the mesh state matches the IFC mutation
# and stale miter cuts are dropped. If recreate_wall raises, the rel removal
# has already been committed to the operator's IFC transaction — surface the
# partial-state diagnostic, then re-raise so the exception lands in Blender's
# normal operator error flow.
try:
tool.Model.recreate_wall(elem_active, active)
tool.Model.recreate_wall(elem_other, other)
except Exception:
self.report(
{"ERROR"},
"Mesh rebuild failed after unjoin. IFC connection was removed but wall "
"meshes may be stale — press Ctrl+Z to undo and restore the previous state.",
)
raise
# and stale miter cuts are dropped.
tool.Model.recreate_wall(elem_active, active)
tool.Model.recreate_wall(elem_other, other)
_resync_walls_after_mutation([active, other])
@@ -378,6 +404,12 @@ class ExtendWallsToWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.If
bl_description = "Extend and trim selected walls to another wall"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if _poll_reject_array_children(cls):
return False
return True
def _perform(self, context):
target_obj = None
objs = []
@@ -604,6 +636,8 @@ class SplitWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
if _poll_reject_array_children(cls):
return False
return True
def _perform(self, context):
@@ -632,6 +666,8 @@ class MergeWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat
if len(mesh_objects) != 2:
cls.poll_message_set("Please select exactly two mesh IFC objects.")
return False
if _poll_reject_array_children(cls):
return False
return True
def _perform(self, context):
@@ -2103,6 +2139,10 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
- ``extend_z_gizmo`` at the wall-local X of the cursor, projected to the
wall top (Z=height in wall-local). Clicking extends the wall's height to
the cursor's Z.
- ``add_perpendicular_wall_gizmo`` visible only when the cursor is
off-axis by more than ``CURSOR_STACK_OFFSET``. Sits at the cursor's
XY (X clamped to the wall's X-range) on the wall-local floor plane.
Clicking spawns a perpendicular branch wall; shift+click forms a corner.
The baseline-state triplet (exterior/center/interior) and the rotate-90
icon live in ``feature_slots`` the base class handles creation and
@@ -2128,6 +2168,12 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
"bim.extend_wall_height_to_cursor",
highlight_color,
)
self.add_perpendicular_wall_gizmo = self._setup_icon_gizmo(
"VIEW3D_GT_extend",
default_color,
"bim.add_perpendicular_wall",
highlight_color,
)
if context.region is not None:
type(self)._active_instances[context.region.as_pointer()] = weakref.ref(self)
@@ -2140,6 +2186,12 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
# at default scale, leaving a small visual gap between consecutive icons.
CURSOR_STACK_OFFSET = 0.3
def _stack_offset(self, stack_index: int, screen_up: Vector, clearance: Vector) -> Vector:
"""World-space offset for the ``stack_index``-th icon in a cursor
row: ``clearance`` (top-down only) plus a screen-up step per slot.
Single source of truth for the cursor-row stacking discipline."""
return clearance + screen_up * (stack_index * self.CURSOR_STACK_OFFSET)
def _update_cursor_gizmos(self, context: bpy.types.Context, mw: Matrix, props: "BIMWallProperties") -> None:
"""Position the cursor-anchored icons (extend-X / extend-Z / split) on the wall
axis at the cursor's projected X.
@@ -2165,12 +2217,30 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
the floor anchor."""
if not hasattr(self, "split_gizmo"):
return
all_gizmos = (self.extend_x_gizmo, self.extend_z_gizmo, self.split_gizmo)
all_gizmos = (
self.extend_x_gizmo,
self.extend_z_gizmo,
self.split_gizmo,
self.add_perpendicular_wall_gizmo,
)
cursor_world = context.scene.cursor.location
cursor_local = mw.inverted() @ cursor_world
in_range = props.anchor_x < cursor_local.x < props.anchor_x + props.length
# ``props.anchor_x`` / ``props.length`` mirror IFC and are only refreshed
# when an operator calls ``_maybe_resync_wall_props_from_ifc``. Reading
# the live extent from the mesh bbox makes the gizmo position robust
# against any operator path that skips that re-sync — the mesh is
# always rebuilt by ``recreate_wall`` to match the current IFC body.
bbox_x = [v[0] for v in context.active_object.bound_box] if context.active_object else None
if bbox_x:
wall_anchor_x = min(bbox_x)
wall_length = max(bbox_x) - wall_anchor_x
else:
wall_anchor_x = props.anchor_x
wall_length = props.length
in_range = wall_anchor_x < cursor_local.x < wall_anchor_x + wall_length
billboard_rot = self._frame_billboard_rot
top_down = tool.Blender.is_view_top_down(context)
perp_params = _perpendicular_wall_params(cursor_local.x, cursor_local.y, wall_anchor_x, wall_length)
# Candidates ordered by priority (lowest first). Each is (gizmo, local_z).
candidates: list[tuple[bpy.types.Gizmo, float]] = [(self.extend_x_gizmo, 0.0)]
@@ -2195,25 +2265,50 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
for gz in all_gizmos:
gz.hide = True
screen_up = tool.Blender.get_screen_up_world(context)
clearance = gizmo.top_down_clearance(context, billboard_rot)
if top_down:
# Swap world-Z stacking for screen-up stacking so each icon stays
# individually clickable when the camera projects world Z to zero.
# The shared ``top_down_clearance`` lifts the whole stack off the
# cursor so its small crosshair stays visible for precise pointing.
screen_up = tool.Blender.get_screen_up_world(context)
base_world = mw @ Vector((cursor_local.x, 0.0, 0.0))
clearance = gizmo.top_down_clearance(context, billboard_rot)
for index, (gz, _local_z) in enumerate(resolved):
gz.hide = self.is_gizmo_hidden_by_modal(gz)
world_pos = base_world + clearance + screen_up * (index * self.CURSOR_STACK_OFFSET)
world_pos = base_world + self._stack_offset(index, screen_up, clearance)
gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot)
_apply_wall_extend_flips(gz, self, world_pos, mw, cursor_local, props, billboard_rot)
return
for gz, local_z in resolved:
else:
# World-Z stacking carries each icon's semantic Z (extend-X at
# floor, extend-Z at cursor Z, split at wall top). At shallow
# viewing angles a 0.3 m gap can still project to near-zero
# screen separation, so add a screen-up offset per stack slot
# — the world-Z position still drives the icon's meaning, the
# screen-up term is just visual insurance.
no_clearance = Vector((0.0, 0.0, 0.0))
for index, (gz, local_z) in enumerate(resolved):
gz.hide = self.is_gizmo_hidden_by_modal(gz)
world_pos = mw @ Vector((cursor_local.x, 0.0, local_z)) + self._stack_offset(
index, screen_up, no_clearance
)
gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot)
_apply_wall_extend_flips(gz, self, world_pos, mw, cursor_local, props, billboard_rot)
if perp_params is not None:
# Stack the perpendicular gizmo one slot above the on-axis row
# along screen-up so it stays independently clickable when the
# cursor sits just past the dead zone. The arrow's in-plane
# rotation points its +X from the wall projection toward the
# cursor as a "new wall sprouts this way" cue.
clamped_x, _length, side_sign = perp_params
gz = self.add_perpendicular_wall_gizmo
gz.hide = self.is_gizmo_hidden_by_modal(gz)
world_pos = mw @ Vector((cursor_local.x, 0.0, local_z))
gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot)
_apply_wall_extend_flips(gz, self, world_pos, mw, cursor_local, props, billboard_rot)
perp_base = mw @ Vector((clamped_x, cursor_local.y, 0.0))
perp_world = perp_base + self._stack_offset(len(resolved), screen_up, clearance)
perp_world_dir = (mw.to_3x3().col[1] * side_sign).normalized()
screen_dir = billboard_rot.transposed() @ perp_world_dir
angle = math.atan2(screen_dir.y, screen_dir.x)
gz.matrix_basis = gizmo.billboarded_at(perp_world, billboard_rot) @ Matrix.Rotation(angle, 4, "Z")
# Map ``props.desired_offset_baseline`` (storage form) to the slot variant
# name. Centralised here so the variant strings stay aligned with the slot
@@ -2286,6 +2381,27 @@ def _commit_active_wall_edit_if_any(context: bpy.types.Context) -> bpy.types.Obj
return obj
def _perpendicular_wall_params(
cursor_local_x: float,
cursor_local_y: float,
anchor_x: float,
length: float,
) -> tuple[float, float, float] | None:
"""Geometry of a perpendicular branch wall sprouting from the cursor's
projection on the source wall axis.
Returns ``(clamped_x, perpendicular_length, side_sign)`` the projection
on the wall axis (clamped to ``[anchor_x, anchor_x + length]``), the
branch wall length, and the side (+1 / -1) the branch sits on. Returns
``None`` when the cursor sits within ``CURSOR_STACK_OFFSET`` of the
source wall axis (the on-wall dead zone)."""
if abs(cursor_local_y) <= GizmoWallEdition.CURSOR_STACK_OFFSET:
return None
clamped_x = max(anchor_x, min(anchor_x + length, cursor_local_x))
side_sign = 1.0 if cursor_local_y > 0 else -1.0
return clamped_x, abs(cursor_local_y), side_sign
def _commit_pending_wall_edits_for_selection(context: bpy.types.Context) -> None: # noqa: ARG001
"""Thin wall-scoped alias for ``tool.Parametric.commit_pending_edits_for_selection``.
@@ -2378,6 +2494,113 @@ class ExtendWallHeightToCursor(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class AddPerpendicularWall(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_perpendicular_wall"
bl_label = "Add Perpendicular Wall at Cursor"
bl_description = (
"Create a new wall perpendicular to the active wall, from the cursor's "
"orthogonal projection on the wall axis toward the cursor. "
"Shift+Click for corner junction: the source wall is trimmed at the "
"projection, keeping its longer portion."
)
bl_options = {"REGISTER", "UNDO"}
use_corner_junction: bpy.props.BoolProperty(default=False)
@classmethod
def poll(cls, context):
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
return True
def invoke(self, context, event):
self.use_corner_junction = bool(event.shift)
return self.execute(context)
def _execute(self, context: bpy.types.Context) -> set[str]:
source_obj = _commit_active_wall_edit_if_any(context)
if source_obj is None:
return {"CANCELLED"}
source_element = tool.Ifc.get_entity(source_obj)
if source_element is None:
self.report({"WARNING"}, "Active object is not an IFC element.")
return {"CANCELLED"}
source_type = ifcopenshell.util.element.get_type(source_element)
if source_type is None:
self.report({"WARNING"}, "Active wall has no IfcWallType; cannot derive branch wall.")
return {"CANCELLED"}
props = tool.Model.get_wall_props(source_obj)
cursor_local = source_obj.matrix_world.inverted() @ context.scene.cursor.location
params = _perpendicular_wall_params(cursor_local.x, cursor_local.y, props.anchor_x, props.length)
if params is None:
self.report({"INFO"}, "Cursor is on the wall axis; nothing to do.")
return {"CANCELLED"}
clamped_x, perpendicular_length, side_sign = params
start_world = source_obj.matrix_world @ Vector((clamped_x, 0.0, 0.0))
source_z_rotation = source_obj.matrix_world.to_euler().z
new_z_rotation = source_z_rotation + side_sign * (pi / 2)
# Shift+click L-corners the new wall against an endpoint of the
# source wall: the source is trimmed at the projection, keeping
# its longer of the two portions.
if self.use_corner_junction:
DumbWallJoiner().extend(source_obj, start_world)
source_layers = tool.Model.get_material_layer_parameters(source_element)
generator = DumbWallGenerator(source_type)
generator.file = tool.Ifc.get()
generator.layers = tool.Model.get_material_layer_parameters(source_type)
if not generator.layers["thickness"]:
self.report({"WARNING"}, "Wall type has no layer thickness; cannot create branch wall.")
return {"CANCELLED"}
generator.body_context = ifcopenshell.util.representation.get_context(
tool.Ifc.get(), "Model", "Body", "MODEL_VIEW"
)
generator.axis_context = ifcopenshell.util.representation.get_context(
tool.Ifc.get(), "Plan", "Axis", "GRAPH_VIEW"
)
generator.container = None
generator.container_obj = None
generator.width = generator.layers["thickness"]
generator.height = props.height
generator.length = perpendicular_length
generator.rotation = new_z_rotation
generator.location = start_world
generator.x_angle = 0.0
new_obj = generator.create_wall()
new_element = tool.Ifc.get_entity(new_obj)
# Branch wall inherits the source wall's centerline / offset baseline
# so the new axis lines up with the source's authored alignment rather
# than the type's default.
source_baseline = core.baseline_from_offset(source_layers["offset"], source_layers["thickness"])
tool.Model.offset_wall(new_obj, source_baseline)
ifcopenshell.api.geometry.connect_wall(
tool.Ifc.get(),
wall1=new_element,
wall2=source_element,
is_atpath=not self.use_corner_junction,
)
source_container = ifcopenshell.util.element.get_container(source_element)
if source_container is not None:
bonsai.core.spatial.assign_container(
tool.Ifc, tool.Collector, tool.Spatial, container=source_container, objs=[new_obj]
)
tool.Model.recreate_wall(source_element, source_obj)
tool.Model.recreate_wall(new_element, new_obj)
tool.Blender.deselect_object(source_obj, ensure_active_object=False)
tool.Blender.set_active_object(new_obj)
_resync_walls_after_mutation([source_obj, new_obj])
return {"FINISHED"}
class RotateWall90(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.rotate_wall_90"
bl_label = "Rotate Wall 90°"
@@ -2421,6 +2644,8 @@ class _WallGeomCachedBillboardingMixin(gizmo.BillboardingGizmoGroupMixin):
def refresh(self, context: bpy.types.Context) -> None:
self._wall_geom_cache = None
self._wall_connections_cache = None
self._wall_pair_predicate_cache = None
self.position_gizmos(context)
@@ -2451,6 +2676,44 @@ def _get_wall_geom_cached(group: "bpy.types.GizmoGroup", obj: bpy.types.Object)
return cache[key]
def _get_wall_connections_cached(
group: "bpy.types.GizmoGroup",
elem: ifcopenshell.entity_instance,
) -> "list[tuple[ifcopenshell.entity_instance, str, str]]":
"""Per-gizmo-group memoised ``_iter_path_connections``. Same generation-key
invalidation as ``_get_wall_geom_cached`` so an IFC mutation drops the cached
list on the next frame; ``refresh()`` drops it on selection change."""
current_gen = tool.Parametric.get_geom_generation()
cache_gen = getattr(group, "_wall_connections_cache_gen", None)
cache = getattr(group, "_wall_connections_cache", None)
if cache is None or cache_gen != current_gen:
cache = {}
group._wall_connections_cache = cache
group._wall_connections_cache_gen = current_gen
key = elem.GlobalId
if key not in cache:
cache[key] = _iter_path_connections(elem)
return cache[key]
def _get_wall_pair_predicate_cached(group: "bpy.types.GizmoGroup", key: tuple, compute):
"""Per-gizmo-group memo for wall-pair predicates (joined / collinear /
intersection). Caller supplies the cache key (typically pair GlobalIds +
relevant inputs like matrix_world tuples + thresholds) and a zero-arg
callable that computes the value on miss. Same generation invalidation as
the geom cache; ``refresh()`` drops it on selection change."""
current_gen = tool.Parametric.get_geom_generation()
cache_gen = getattr(group, "_wall_pair_predicate_cache_gen", None)
cache = getattr(group, "_wall_pair_predicate_cache", None)
if cache is None or cache_gen != current_gen:
cache = {}
group._wall_pair_predicate_cache = cache
group._wall_pair_predicate_cache_gen = current_gen
if key not in cache:
cache[key] = compute()
return cache[key]
def _wall_camera_facing_icon_y(context: bpy.types.Context, mw: Matrix, geom: dict) -> float:
"""Wall-local Y for an icon that should sit just outside the camera-facing face.
Centralised so the billboarding wall gizmos (add-opening, extend-vertically, )
@@ -2950,34 +3213,13 @@ class FinishWallFilletPreview(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
if context.screen is None:
return {"CANCELLED"}
props = preview_base.get_preview_props(context, "wall_fillet")
if props is None or not props.is_active:
return {"CANCELLED"}
if tool.Ifc.get() is None:
self.report({"ERROR"}, "No IFC file loaded.")
return {"CANCELLED"}
# bpy.ops promotes ``self.report({"ERROR"}) + return CANCELLED`` from
# the dispatched operator to RuntimeError. Catch it so this operator
# returns cleanly instead of leaving Blender's operator state
# half-broken (which would silently disable downstream gizmo polls).
try:
result = bpy.ops.bim.create_wall_fillet(
wall_a_id=props.wall_a_id,
wall_b_id=props.wall_b_id,
radius=props.radius,
editing_corner_id=props.editing_corner_id,
)
except RuntimeError as exc:
self.report({"ERROR"}, str(exc))
return {"CANCELLED"}
if "FINISHED" in result:
props.is_active = False
props.wall_a_id = 0
props.wall_b_id = 0
props.editing_corner_id = 0
return result
return preview_base.commit_preview(
self,
context,
"wall_fillet",
"create_wall_fillet",
("wall_a_id", "wall_b_id", "radius", "editing_corner_id"),
)
class CancelWallFilletPreview(bpy.types.Operator):
@@ -2994,10 +3236,7 @@ class CancelWallFilletPreview(bpy.types.Operator):
props = preview_base.get_preview_props(context, "wall_fillet")
if props is None or not props.is_active:
return {"CANCELLED"}
props.is_active = False
props.wall_a_id = 0
props.wall_b_id = 0
props.editing_corner_id = 0
preview_base.clear_preview_state(props)
return {"FINISHED"}
@@ -3337,7 +3576,7 @@ class GizmoWallExtendVertically(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not _wall_gizmo_poll_gate(context):
if not _wall_topology_gizmo_poll_gate(context):
return False
selected = tool.Blender.get_selected_objects()
if len(selected) != 2:
@@ -3418,7 +3657,7 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not _wall_gizmo_poll_gate(context):
if not _wall_topology_gizmo_poll_gate(context):
return False
selected = tool.Blender.get_selected_objects()
if len(selected) != 2:
@@ -3489,8 +3728,19 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
clearance = gizmo.top_down_clearance(context, billboard_rot)
anchor_z = self._stack_anchor_z(context, selected, geom_a, geom_b)
# Pair predicate cache key: pair GlobalIds + world-matrix tuples for
# both walls. World matrices feed _are_walls_collinear /
# project_axis_intersection, so they belong in the key.
pair_guids = tuple(sorted((elem_a.GlobalId, elem_b.GlobalId)))
mw_a_key = tuple(map(tuple, selected[0].matrix_world))
mw_b_key = tuple(map(tuple, selected[1].matrix_world))
mw_key = (mw_a_key, mw_b_key) if elem_a.GlobalId <= elem_b.GlobalId else (mw_b_key, mw_a_key)
# State 1: walls are already joined → Unjoin (bottom) + Fillet (above).
if _are_walls_joined(elem_a, elem_b):
joined = _get_wall_pair_predicate_cached(
self, ("joined", pair_guids), lambda: _are_walls_joined(elem_a, elem_b)
)
if joined:
corner = _collinear_boundary_world(seg_a, seg_b)
anchor = Vector((corner.x, corner.y, anchor_z)) + clearance
self._stack_at(anchor, screen_up, billboard_rot, (self.unjoin_icon, self.fillet_icon))
@@ -3502,7 +3752,12 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
# State 2: walls are collinear (parallel axes on the same line) → show Merge
# at the boundary midpoint between them. No stack; single icon at the
# geometric boundary makes the merge target unambiguous.
if _are_walls_collinear(seg_a, seg_b, self.PARALLEL_DOT_THRESHOLD, self.COLLINEAR_LINE_TOLERANCE):
collinear = _get_wall_pair_predicate_cached(
self,
("collinear", pair_guids, mw_key, self.PARALLEL_DOT_THRESHOLD, self.COLLINEAR_LINE_TOLERANCE),
lambda: _are_walls_collinear(seg_a, seg_b, self.PARALLEL_DOT_THRESHOLD, self.COLLINEAR_LINE_TOLERANCE),
)
if collinear:
boundary = _collinear_boundary_world(seg_a, seg_b) + clearance
self.merge_icon.matrix_basis = gizmo.billboarded_at(boundary, billboard_rot)
self.merge_icon.hide = False
@@ -3518,10 +3773,14 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
# walls within 2° of parallel produce extrusion joints that race
# toward infinity, so project_axis_intersection returns None and the
# early-return below hides the whole group.
intersection_tuple = core.project_axis_intersection(
(tuple(seg_a[0]), tuple(seg_a[1])),
(tuple(seg_b[0]), tuple(seg_b[1])),
self.PARALLEL_DOT_THRESHOLD,
intersection_tuple = _get_wall_pair_predicate_cached(
self,
("intersection", pair_guids, mw_key, self.PARALLEL_DOT_THRESHOLD),
lambda: core.project_axis_intersection(
(tuple(seg_a[0]), tuple(seg_a[1])),
(tuple(seg_b[0]), tuple(seg_b[1])),
self.PARALLEL_DOT_THRESHOLD,
),
)
if intersection_tuple is None:
self._hide_all()
@@ -3625,7 +3884,7 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not _wall_gizmo_poll_gate(context):
if not _wall_topology_gizmo_poll_gate(context):
return False
active = tool.Blender.get_active_object(is_selected=True)
if active is None:
@@ -3677,7 +3936,7 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
billboard_rot = gizmo.get_billboard_rotation(context)
clearance = gizmo.top_down_clearance(context, billboard_rot)
connections = _iter_path_connections(elem)
connections = _get_wall_connections_cached(self, elem)
if len(connections) > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False):
print(
f"[bonsai] GizmoWallUnjoinSingle: wall has {len(connections)} path connections; "
@@ -3991,7 +4250,7 @@ class GizmoWallFilletReedit(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not _wall_gizmo_poll_gate(context):
if not _wall_topology_gizmo_poll_gate(context):
return False
active = tool.Blender.get_active_object(is_selected=True)
if active is None:
@@ -4059,7 +4318,7 @@ class GizmoWallFilletToggleOpenings(bpy.types.GizmoGroup, _WallGeomCachedBillboa
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not _wall_gizmo_poll_gate(context):
if not _wall_topology_gizmo_poll_gate(context):
return False
active = tool.Blender.get_active_object(is_selected=True)
if active is None:
@@ -4110,6 +4369,8 @@ class JoinWallsIntersection(_CommitWallDraftsFirstMixin, bpy.types.Operator, too
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
if _poll_reject_array_children(cls):
return False
return True
def _perform(self, context: bpy.types.Context) -> set[str]:
@@ -4174,7 +4435,7 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
LINE_WIDTH = 1.5
LINE_ALPHA = 0.8
QUAD_ALPHA = 0.25
QUAD_ALPHA = 0.45
def draw_lines(self, context: bpy.types.Context) -> None:
if not tool.Blender.are_viewport_gizmos_enabled():
@@ -4184,6 +4445,7 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
self._draw_cursor_extend_preview(context, prefs)
self._draw_cursor_extend_z_preview(context, prefs)
self._draw_cursor_split_preview(context, prefs)
self._draw_cursor_perpendicular_wall_preview(context, prefs)
def _stroke(
self,
@@ -4191,7 +4453,7 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
color_rgb: tuple[float, float, float],
) -> None:
_stroke_lines_alpha(context, segments, color_rgb, self.LINE_WIDTH, self.LINE_ALPHA)
draw_polyline_segments(context, segments, color_rgb, self.LINE_ALPHA, self.LINE_WIDTH)
def _fill(
self,
@@ -4402,10 +4664,12 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
emit(nearest_x, cursor_local.x, keep_color)
def _draw_cursor_split_preview(self, context: bpy.types.Context, prefs: Any) -> None:
"""Render one red line at the cursor's projected X, from wall base to wall top
along the wall's local Z — the cut plane the split operator would commit.
Hover-gated on the split icon; coloured with the destructive-action warning
red to match the icon's own hover signal."""
"""Render two red lines at the cursor's projected X: one vertical along
the wall's local Z (visible in elevation views), one horizontal across
the wall's thickness band at floor Z (visible in plan / top-down view).
Together they trace the cut plane the split operator would commit.
Hover-gated on the split icon; coloured with the destructive-action
warning red to match the icon's own hover signal."""
active = self._active_layer2_wall_for_gizmo_preview(context, prefs)
if active is None:
return
@@ -4417,15 +4681,25 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
anchor_x = geom.get("anchor_x", 0.0)
length = geom.get("length", 0.0)
height = geom.get("height", 0.0)
offset = geom.get("offset", 0.0)
thickness = geom.get("thickness", 0.0)
if length <= 0 or height <= 0:
return
mw = active.matrix_world
cursor_local = mw.inverted() @ context.scene.cursor.location
if not (anchor_x < cursor_local.x < anchor_x + length):
return
color = tuple(prefs.decorator_color_error[:3])
bottom_world = mw @ Vector((cursor_local.x, 0.0, 0.0))
top_world = mw @ Vector((cursor_local.x, 0.0, height))
self._stroke(context, [(tuple(bottom_world), tuple(top_world))], tuple(prefs.decorator_color_error[:3]))
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [
(tuple(bottom_world), tuple(top_world))
]
if thickness > 0:
base_a = mw @ Vector((cursor_local.x, offset, 0.0))
base_b = mw @ Vector((cursor_local.x, offset + thickness, 0.0))
segments.append((tuple(base_a), tuple(base_b)))
self._stroke(context, segments, color)
def _draw_cursor_extend_z_preview(self, context: bpy.types.Context, prefs: Any) -> None:
"""Hover-gated vertical-line preview for the extend-Z icon at the
@@ -4472,3 +4746,40 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
remove_color = tuple(prefs.decorator_color_error[:3])
stroke(0.0, cursor_local.z, keep_color)
stroke(cursor_local.z, height, remove_color)
def _draw_cursor_perpendicular_wall_preview(self, context: bpy.types.Context, prefs: Any) -> None:
"""Hover-gated floor-plane preview of the branch wall's footprint.
Green quad on Z=0 spanning the new wall's perpendicular body band
(``offset`` to ``offset + thickness`` mapped through the perpendicular
rotation) and its length from the projection on the source wall axis
to the cursor."""
active = self._active_layer2_wall_for_gizmo_preview(context, prefs)
if active is None:
return
if not self._cursor_icon_hovered(GizmoWallEdition, "add_perpendicular_wall_gizmo", context):
return
geom = tool.Wall.read_geometry(active)
if geom is None:
return
anchor_x = geom.get("anchor_x", 0.0)
length = geom.get("length", 0.0)
offset = geom.get("offset", 0.0)
thickness = geom.get("thickness", 0.0)
if length <= 0 or thickness <= 0:
return
mw = active.matrix_world
cursor_local = mw.inverted() @ context.scene.cursor.location
params = _perpendicular_wall_params(cursor_local.x, cursor_local.y, anchor_x, length)
if params is None:
return
clamped_x, _length, side_sign = params
# New wall axis sits at source-local X = clamped_x; its body extends
# perpendicular to that axis. After rotating the new wall's ±Y body
# band into the source's local frame, the band lands at source-local
# X = clamped_x side_sign · {offset, offset+thickness}.
x_a = clamped_x - side_sign * offset
x_b = clamped_x - side_sign * (offset + thickness)
x_lo, x_hi = (x_a, x_b) if x_a < x_b else (x_b, x_a)
y_lo, y_hi = (0.0, cursor_local.y) if cursor_local.y > 0 else (cursor_local.y, 0.0)
keep_color = tuple(prefs.decorator_color_selected[:3])
self._fill(context, [self._wall_floor_quad(mw, x_lo, x_hi, y_lo, y_hi)], keep_color)
@@ -28,6 +28,10 @@ classes = (
operator.AppendLibraryElementByQuery,
operator.AssignLibraryDeclaration,
operator.BIM_FH_import_ifc,
operator.BIM_OT_apply_pending_opening_cuts,
operator.BIM_OT_dismiss_multi_instance_warning,
operator.BIM_OT_dismiss_pending_opening_cuts,
operator.BIM_OT_select_pending_opening_cuts,
operator.BIM_OT_load_clipping_planes,
operator.BIM_OT_save_clipping_planes,
operator.ChangeLibraryElement,
@@ -82,6 +86,7 @@ classes = (
prop.FilterCategory,
prop.Link,
prop.EditedObj,
prop.PendingOpeningRecut,
prop.BIMProjectProperties,
prop.MeasureToolSettings,
ui.BIM_MT_new_project,
@@ -964,11 +964,11 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
use_relative_path: bpy.props.BoolProperty(
name="Use Relative Path",
description="Store the IFC project path relative to the .blend file. Requires .blend file to be saved",
default=True,
default=False,
)
should_start_fresh_session: bpy.props.BoolProperty(
name="Should Start Fresh Session",
description="Clear current Blender session before loading IFC",
description="Clear current Blender session before loading IFC. Not supported with 'Use Relative Path' option",
default=True,
)
import_without_ifc_data: bpy.props.BoolProperty(
@@ -1076,6 +1076,9 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
bpy.app.handlers.load_post.remove(load_handler)
self.finish_loading_project(context)
if self.use_relative_path:
self.should_start_fresh_session = False
if self.should_start_fresh_session:
# WARNING: wm.read_homefile clears context which could lead to some
# operators to fail:
@@ -1140,6 +1143,8 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
return ImportHelper.invoke(self, context, event)
def draw(self, context):
if self.use_relative_path:
self.should_start_fresh_session = False
self.layout.prop(self, "is_advanced")
self.layout.prop(self, "should_start_fresh_session")
self.layout.prop(self, "import_without_ifc_data")
@@ -1218,6 +1223,19 @@ class LoadProjectElements(bpy.types.Operator):
props = tool.Project.get_project_props()
props.is_loading = False
# Stash elements the kernel skipped opening cuts on (HasOpenings > void_limit).
# The Project panel banner offers the user a one-click recut.
props.pending_opening_recut.clear()
if ifc_importer.gross_elements:
for element in ifc_importer.gross_elements:
item = props.pending_opening_recut.add()
item.ifc_definition_id = element.id()
self.report(
{"WARNING"},
f"{len(ifc_importer.gross_elements)} element(s) had too many openings and were loaded without cuts. "
f"Apply manually from the Project panel.",
)
tool.Project.load_default_thumbnails()
tool.Project.set_default_context()
tool.Project.set_default_modeling_dimensions()
@@ -1870,7 +1888,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
json_version: bpy.props.EnumProperty(items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version")
json_compact: bpy.props.BoolProperty(name="Export Compact IFCJSON", default=False)
should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"})
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
if TYPE_CHECKING:
filter_glob: str
@@ -3416,3 +3434,108 @@ class GenerateUVMap(bpy.types.Operator):
tool.Loader.load_generated_uv_map(obj.data)
self.report({"INFO"}, "Generated UV map for selected mesh.")
return {"FINISHED"}
class BIM_OT_apply_pending_opening_cuts(bpy.types.Operator, tool.Ifc.Operator):
"""Recompute the wall mesh including opening subtractions for every host
that the load-time ``void_limit`` filter skipped. Clears the deferred
list on completion so the panel banner disappears."""
bl_idname = "bim.apply_pending_opening_cuts"
bl_label = "Apply Pending Opening Cuts"
bl_description = (
"Recompute meshes for elements whose openings were skipped at load because they had too many openings"
)
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context: bpy.types.Context) -> set[str]:
pending = tool.Project.get_project_props().pending_opening_recut
applied = 0
skipped = 0
failed = 0
for item in pending:
try:
element = tool.Ifc.get().by_id(item.ifc_definition_id)
except RuntimeError:
skipped += 1
continue
obj = tool.Ifc.get_object(element)
if obj is None:
skipped += 1
continue
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if body is None:
skipped += 1
continue
try:
tool.Geometry.reimport_element_representations(obj, body, apply_openings=True)
applied += 1
except (RuntimeError, OSError, AttributeError) as exc:
# Programmer errors (TypeError, ValueError, etc.) must surface — don't swallow them.
failed += 1
print(f"apply_pending_opening_cuts: failed to recompute {element} ({exc})")
pending.clear()
message = f"Applied opening cuts to {applied} element(s)."
if skipped:
message += f" {skipped} entry/entries skipped (entity or object no longer available)."
if failed:
message += f" {failed} entry/entries failed (see system console)."
self.report({"WARNING"}, message)
else:
self.report({"INFO"}, message)
return {"FINISHED"}
class BIM_OT_dismiss_pending_opening_cuts(bpy.types.Operator):
bl_idname = "bim.dismiss_pending_opening_cuts"
bl_label = "Dismiss Pending Opening Cuts"
bl_description = "Clear the pending opening-cut list without applying it. Walls stay solid where openings would have been subtracted."
bl_options = {"REGISTER", "UNDO"}
def execute(self, context: bpy.types.Context) -> set[str]:
tool.Project.get_project_props().pending_opening_recut.clear()
return {"FINISHED"}
class BIM_OT_dismiss_multi_instance_warning(bpy.types.Operator):
bl_idname = "bim.dismiss_multi_instance_warning"
bl_label = "Dismiss Multi-Instance Warning"
bl_description = (
"Hide the warning that another Blender instance has this IFC file open. Sticky for the current session."
)
bl_options = {"REGISTER"}
def execute(self, context: bpy.types.Context) -> set[str]:
from bonsai.bim.ifc import dismiss_multi_instance_warning
dismiss_multi_instance_warning()
return {"FINISHED"}
class BIM_OT_select_pending_opening_cuts(bpy.types.Operator):
bl_idname = "bim.select_pending_opening_cuts"
bl_label = "Select Elements With Skipped Opening Cuts"
bl_description = "Select the Blender objects whose openings were skipped at load. Useful for locating which elements need attention."
bl_options = {"REGISTER", "UNDO"}
def execute(self, context: bpy.types.Context) -> set[str]:
ifc_file = tool.Ifc.get()
if ifc_file is None:
self.report({"INFO"}, "No IFC file loaded.")
return {"CANCELLED"}
objects: list[bpy.types.Object] = []
for item in tool.Project.get_project_props().pending_opening_recut:
try:
element = ifc_file.by_id(item.ifc_definition_id)
except RuntimeError:
continue
obj = tool.Ifc.get_object(element)
if obj is not None:
objects.append(obj)
if not objects:
self.report({"INFO"}, "No matching Blender objects found for the pending list.")
return {"CANCELLED"}
tool.Blender.set_objects_selection(context, active_object=objects[0], selected_objects=objects)
self.report({"INFO"}, f"Selected {len(objects)} element(s).")
return {"FINISHED"}
+14 -1
View File
@@ -295,6 +295,17 @@ class LibraryBreadcrumb(PropertyGroup):
library_id: int
class PendingOpeningRecut(PropertyGroup):
"""One element whose ``HasOpenings`` exceeded ``void_limit`` at load time
and was imported without opening subtractions. The user can later apply
them on demand from the Project panel banner."""
ifc_definition_id: IntProperty(name="IFC Definition ID")
if TYPE_CHECKING:
ifc_definition_id: int
class BIMProjectProperties(PropertyGroup):
is_editing: BoolProperty(name="Is Editing", default=False)
is_loading: BoolProperty(name="Is Loading", default=False)
@@ -360,6 +371,7 @@ class BIMProjectProperties(PropertyGroup):
default=30,
description="Maxium number of openings that object can have. If object has more openings, it will be loaded without openings",
)
pending_opening_recut: CollectionProperty(name="Pending Opening Recut", type=PendingOpeningRecut)
style_limit: IntProperty(
name="Style Limit",
default=300,
@@ -444,7 +456,7 @@ class BIMProjectProperties(PropertyGroup):
items=get_parent_libaries,
)
use_relative_project_path: BoolProperty(name="Use Relative Project Path", default=True)
use_relative_project_path: BoolProperty(name="Use Relative Project Path", default=False)
should_save_metadata_for_this_file: BoolProperty(
name="Save Session Data for This File",
description="Enable saving session data (window layout, settings) to a metadata blend file for this specific IFC file",
@@ -525,6 +537,7 @@ class BIMProjectProperties(PropertyGroup):
deflection_tolerance: float
angular_tolerance: float
void_limit: int
pending_opening_recut: bpy.types.bpy_prop_collection_idprop[PendingOpeningRecut]
style_limit: int
distance_limit: float
false_origin_mode: Literal["AUTOMATIC", "MANUAL", "DISABLED"]
+31 -1
View File
@@ -28,8 +28,9 @@ from bpy.types import Menu, Panel, UIList
import bonsai.bim
import bonsai.tool as tool
from bonsai.bim.helper import draw_attributes, prop_with_search
from bonsai.bim.ifc import IfcStore
from bonsai.bim.ifc import IfcStore, is_cache_locked_by_other_process
from bonsai.bim.module.project.data import LinksData, ProjectData
from bonsai.bim.ui import draw_multiline_text
if TYPE_CHECKING:
from bonsai.bim.module.project.prop import (
@@ -166,6 +167,20 @@ class BIM_PT_project(Panel):
if pprops.is_loading:
self.draw_advanced_loading_ui(context)
elif self.file or props.ifc_file:
if is_cache_locked_by_other_process():
box = self.layout.box()
box.alert = True
row = box.row(align=True)
row.label(text="IFC Already Open in Another Blender Instance", icon="ERROR")
row.operator("bim.dismiss_multi_instance_warning", text="", icon="CANCEL")
draw_multiline_text(
box.column(align=True),
"This file is open in another Blender instance. Editing the same "
"IFC from two instances at once can lose your work or display "
"outdated geometry. Close the other Blender instances to continue safely.",
context=context,
)
if props.has_blend_warning:
box = self.layout.box()
box.alert = True
@@ -175,6 +190,21 @@ class BIM_PT_project(Panel):
op.uri = "https://docs.bonsaibim.org/guides/troubleshooting.html#saving-and-loading-blend-files"
row.operator("bim.close_blend_warning", text="", icon="CANCEL")
if pending := pprops.pending_opening_recut:
box = self.layout.box()
box.alert = True
box.label(text="Opening Cuts Skipped", icon="ERROR")
draw_multiline_text(
box.column(align=True),
f"{len(pending)} element(s) had too many openings to cut during load. "
f"Apply to recompute their meshes, or dismiss to leave them as they are.",
context=context,
)
row = box.row(align=True)
row.operator("bim.select_pending_opening_cuts", text="Select Elements", icon="RESTRICT_SELECT_OFF")
row.operator("bim.apply_pending_opening_cuts", text="Apply Openings", icon="PLAY")
row.operator("bim.dismiss_pending_opening_cuts", text="", icon="CANCEL")
if props.ifc_file:
self.draw_loaded_project_ui(context)
else:
@@ -15,9 +15,10 @@
#
# 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 bmesh
import bpy
import gpu
from bpy.app.handlers import persistent
@@ -78,15 +79,9 @@ class SystemDecorator:
batch.draw(shader)
def draw_faces(self, bm, vertices_coords):
"""mutates original bm (triangulates it)
so the triangulation edges will be shown too
"""
traingulated_bm = bm
bmesh.ops.triangulate(traingulated_bm, faces=traingulated_bm.faces)
face_indices = [[v.index for v in f.verts] for f in traingulated_bm.faces]
"""Submit a non-mutating beauty-triangulated TRIS batch over ``bm``'s faces."""
faces_color = transparent_color(self.addon_prefs.decorator_color_special)
self.draw_batch("TRIS", vertices_coords, faces_color, face_indices)
tool.Blender.draw_bmesh_face_tris(bm, vertices_coords, faces_color, self.draw_batch)
def __call__(self, context, get_custom_bmesh=None, draw_faces=False, exit_edit_mode_callback=None):
self.addon_prefs = tool.Blender.get_addon_preferences()
@@ -317,13 +317,23 @@ class MEPConnectElements(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Connect MEP Elements"
bl_description = "Connects two selected elements by their closest located ports and adjusts them"
bl_options = {"REGISTER", "UNDO"}
obj1_name: bpy.props.StringProperty(name="Object 1")
obj2_name: bpy.props.StringProperty(name="Object 2")
obj1_guid: bpy.props.StringProperty(name="Object 1 GlobalId")
obj2_guid: bpy.props.StringProperty(name="Object 2 GlobalId")
def _execute(self, context):
if self.obj1_name and self.obj2_name:
obj1 = bpy.data.objects.get(self.obj1_name)
obj2 = bpy.data.objects.get(self.obj2_name)
if self.obj1_guid and self.obj2_guid:
ifc_file = tool.Ifc.get()
try:
el1_lookup = ifc_file.by_guid(self.obj1_guid)
el2_lookup = ifc_file.by_guid(self.obj2_guid)
except RuntimeError:
self.report({"ERROR"}, "Could not resolve MEP elements from supplied GlobalIds.")
return {"CANCELLED"}
obj1 = tool.Ifc.get_object(el1_lookup)
obj2 = tool.Ifc.get_object(el2_lookup)
if not obj1 or not obj2:
self.report({"ERROR"}, "Supplied MEP elements have no Blender object bound.")
return {"CANCELLED"}
else:
if not context.selected_objects or len(context.selected_objects) != 2:
self.report({"ERROR"}, "Need to select 2 objects.")
+1 -1
View File
@@ -219,7 +219,7 @@ class SelectIfcFile(bpy.types.Operator, IFCFileSelector, ImportHelper):
bl_options = {"REGISTER", "UNDO"}
bl_description = f"Select a different IFC file.\n{tool.Blender.operator_invoke_filepath_hotkeys_description}"
filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"})
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
filename_ext = ".ifc"
def execute(self, context):
+5 -4
View File
@@ -94,10 +94,7 @@ class IFCFileSelector:
filepath = self.get_filepath_abs()
if self.use_relative_path:
try:
filepath = filepath.relative_to(bpy.path.abspath("//"))
except ValueError:
pass # IFC file is not under the blend directory; keep absolute path
filepath = filepath.relative_to(bpy.path.abspath("//"))
return filepath.as_posix().replace("\\", "/")
def draw(self, context: bpy.types.Context) -> None:
@@ -294,6 +291,8 @@ class GizmoPreferences(bpy.types.PropertyGroup):
railing: BoolProperty(name="Railing", default=True)
roof: BoolProperty(name="Roof", default=True)
array: BoolProperty(name="Array", default=True)
pipe_segment: BoolProperty(name="Pipe Segment", default=True)
duct_segment: BoolProperty(name="Duct Segment", default=True)
wall: BoolProperty(name="Wall", default=True)
if TYPE_CHECKING:
@@ -304,6 +303,8 @@ class GizmoPreferences(bpy.types.PropertyGroup):
railing: bool
roof: bool
array: bool
pipe_segment: bool
duct_segment: bool
wall: bool
+93 -1
View File
@@ -22,6 +22,7 @@ from __future__ import annotations
import contextlib
import importlib
import math
import os
import platform
import subprocess
@@ -1458,6 +1459,38 @@ class Blender(bonsai.core.tool.Blender):
parent_guid = pset.get("Parent")
return parent_guid is not None and parent_guid != element.GlobalId
@classmethod
def any_selected_is_array_child(cls) -> bool:
"""True if any selected IFC-linked object is a Bonsai array child.
Multi-object wall topology gizmos (merge / join / extend / unjoin
/ fillet) and their bound operators gate on this: any mutation
applied to a child is overwritten on the next
``regenerate_array``, and merge specifically would leave the
parent's ``BBIM_Array.Data`` list pointing at a deleted GUID.
Memoised against (selection signature, IFC geometry generation)
so gizmo polls that fire per input event don't re-walk the pset
for every selected object every frame. Identity-keyed so plain
Python objects (used by tests) work alongside real Blender
``bpy_struct`` wrappers."""
selected = tool.Blender.get_selected_objects()
selection_sig = frozenset(id(obj) for obj in selected)
current_gen = tool.Parametric.get_geom_generation()
cached = cls._any_selected_array_child_memo
if cached is not None and cached[0] == selection_sig and cached[1] == current_gen:
return cached[2]
result = False
for obj in selected:
element = tool.Ifc.get_entity(obj)
if element is not None and cls.is_array_child(element):
result = True
break
cls._any_selected_array_child_memo = (selection_sig, current_gen, result)
return result
_any_selected_array_child_memo: tuple[frozenset[int], int, bool] | None = None
@classmethod
def is_slab(cls, element: entity_instance) -> bool:
"""A slab is host-eligible for the parametric add-opening gizmo if
@@ -2227,6 +2260,65 @@ class Blender(bonsai.core.tool.Blender):
return False
return True
@classmethod
def draw_bmesh_face_tris(
cls,
bm: bmesh.types.BMesh,
world_vert_coords: list,
color: Any,
draw_batch: Callable[[str, list, Any, list], None],
) -> None:
"""Submit a non-mutating beauty-triangulated TRIS batch for ``bm``'s faces.
``world_vert_coords`` must be indexed by ``bm.verts`` index. Never call
``bmesh.ops.triangulate`` on a live bmesh to compute draw indices it
mutates the input and produces ear-clip fans that render as visible
streaks at low alpha.
"""
tris = [[loop.vert.index for loop in tri] for tri in bm.calc_loop_triangles()]
draw_batch("TRIS", world_vert_coords, color, tris)
@classmethod
def build_dashed_line_segments(
cls,
world_verts: Sequence[Sequence[float]],
edges_indices: Sequence[Sequence[int]],
dash_period: float,
dash_width: float,
) -> tuple[list[tuple[float, float, float]], list[tuple[int, int]]]:
"""Pre-segment edges into world-space dash chunks for a vanilla LINES batch.
Each input edge is sliced into segments of length ``dash_width`` spaced
``dash_period`` apart (dash phase resets per-edge). The result is a fresh
``(verts, edges)`` pair that draws as dashes through any standard line
shader letting both passes of a visible/occluded outline reuse the
same shader so depth values match exactly across passes.
"""
new_verts: list[tuple[float, float, float]] = []
new_edges: list[tuple[int, int]] = []
if dash_period <= 0 or dash_width <= 0:
return new_verts, new_edges
n = len(world_verts)
for i, j in edges_indices:
if not (0 <= i < n and 0 <= j < n) or i == j:
continue
v0 = world_verts[i]
v1 = world_verts[j]
dx, dy, dz = v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]
edge_length = math.sqrt(dx * dx + dy * dy + dz * dz)
if edge_length == 0.0:
continue
ux, uy, uz = dx / edge_length, dy / edge_length, dz / edge_length
t = 0.0
while t < edge_length:
t_end = min(t + dash_width, edge_length)
idx = len(new_verts)
new_verts.append((v0[0] + ux * t, v0[1] + uy * t, v0[2] + uz * t))
new_verts.append((v0[0] + ux * t_end, v0[1] + uy * t_end, v0[2] + uz * t_end))
new_edges.append((idx, idx + 1))
t += dash_period
return new_verts, new_edges
@classmethod
def extract_error_reports(cls, exception: RuntimeError) -> list[str]:
"""Extracts error report lines from a runtime exception during operator execution.
@@ -2377,7 +2469,7 @@ class Blender(bonsai.core.tool.Blender):
See https://projects.blender.org/blender/blender/issues/149283
"""
if len(bytedata) == (n * 2):
if len(bytedata) == (n * 8): # float64 has 8 bytes per element
return np.frombuffer(bytedata, dtype=np.float64).astype(np.float32)
return np.frombuffer(bytedata, dtype=np.float32)
+8
View File
@@ -177,6 +177,14 @@ class Cad:
return False
return (x + tolerance) > value > (x - tolerance)
@classmethod
def is_multiple_of_pi(cls, value: float) -> bool:
"""True when ``value`` is an integer multiple of π within tolerance —
the parallelism / anti-parallelism check rotation-difference logic
reaches for (segments aligned modulo a 180° flip)."""
n = round(value / math.pi)
return cls.is_x(abs(value - n * math.pi), 0)
@classmethod
def normalise_angle(cls, angle: float) -> float:
"""Normalise an angle between -179 and 180"""
+33 -2
View File
@@ -75,8 +75,10 @@ if TYPE_CHECKING:
from bonsai.bim.module.model.prop import (
BIMArrayProperties,
BIMDoorProperties,
BIMDuctSegmentProperties,
BIMExternalParametricGeometryProperties,
BIMModelProperties,
BIMPipeSegmentProperties,
BIMPolylineProperties,
BIMRailingProperties,
BIMRoofProperties,
@@ -116,6 +118,14 @@ class Model(bonsai.core.tool.Model):
def get_railing_props(cls, obj: bpy.types.Object) -> BIMRailingProperties:
return obj.BIMRailingProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_pipe_segment_props(cls, obj: bpy.types.Object) -> BIMPipeSegmentProperties:
return obj.BIMPipeSegmentProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_duct_segment_props(cls, obj: bpy.types.Object) -> BIMDuctSegmentProperties:
return obj.BIMDuctSegmentProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_sverchok_props(cls, obj: bpy.types.Object) -> BIMSverchokProperties:
return obj.BIMSverchokProperties # pyright: ignore[reportAttributeAccessIssue]
@@ -362,6 +372,28 @@ class Model(bonsai.core.tool.Model):
else:
break
@classmethod
def get_sibling_occurrence_count(cls, element: ifcopenshell.entity_instance) -> int:
"""Number of *other* products sharing this element's body representation.
Returns the count of products bound to the same resolved body rep, minus
``element`` itself and minus its type (if any). Zero when the element has
no body rep, no resolved rep, or no siblings. A non-zero result means a
parametric edit on ``element`` will silently mutate other instances'
geometry."""
body_rep = tool.Geometry.get_body_representation(element)
if not body_rep:
return 0
resolved = ifcopenshell.util.representation.resolve_representation(body_rep)
if not resolved:
return 0
elements = tool.Geometry.get_elements_by_representation(resolved)
elements.discard(element)
element_type = ifcopenshell.util.element.get_type(element)
if element_type is not None:
elements.discard(element_type)
return len(elements)
unit_scale: float
vertices: list[Vector]
edges: list[Sequence[int]]
@@ -1556,8 +1588,7 @@ class Model(bonsai.core.tool.Model):
element = tool.Ifc.get_entity(object)
if not element:
return
psets = ifcopenshell.util.element.get_psets(element)
pset_data = psets.get(pset_name, None)
pset_data = ifcopenshell.util.element.get_pset(element, pset_name)
if not pset_data:
return
pset_data["data_dict"] = json.loads(pset_data.get("Data", "[]") or "[]")
+4 -4
View File
@@ -147,10 +147,6 @@ class Parametric(bonsai.core.tool.Parametric):
self._data.clear()
self._gen = None
# FIXME(PR5): pipe_segment / duct_segment land with their finish/cancel
# operators in the MEP slice of PR5 (PR5d). Until then they stay out of
# EDIT_TYPES so auto-commit-on-save doesn't try to dispatch a
# non-existent operator.
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),
@@ -158,6 +154,8 @@ class Parametric(bonsai.core.tool.Parametric):
ParametricObject("railing", supports_build_edit_lifecycle=True),
ParametricObject("roof", supports_build_edit_lifecycle=True),
ParametricObject("array", supports_build_edit_lifecycle=True),
ParametricObject("pipe_segment", supports_build_edit_lifecycle=True),
ParametricObject("duct_segment", supports_build_edit_lifecycle=True),
ParametricObject("wall"),
]
@@ -170,6 +168,8 @@ class Parametric(bonsai.core.tool.Parametric):
RAILING: ClassVar[ParametricObject]
ROOF: ClassVar[ParametricObject]
ARRAY: ClassVar[ParametricObject]
PIPE_SEGMENT: ClassVar[ParametricObject]
DUCT_SEGMENT: ClassVar[ParametricObject]
WALL: ClassVar[ParametricObject]
_geom_generation: int = 0
+1 -4
View File
@@ -80,10 +80,7 @@ class Spatial(bonsai.core.tool.Spatial):
def get_root_element(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
while True:
if parent := (
ifcopenshell.util.element.get_aggregate(element)
or ifcopenshell.util.element.get_nest(element)
or ifcopenshell.util.element.get_filled_void(element)
or ifcopenshell.util.element.get_voided_element(element)
ifcopenshell.util.element.get_aggregate(element) or ifcopenshell.util.element.get_nest(element)
):
element = parent
else:
+18
View File
@@ -488,6 +488,24 @@ 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 has_parametric_body(cls, element: ifcopenshell.entity_instance) -> bool:
"""True when the MEP element's body representation is a profile sweep
(``IfcExtrudedAreaSolid`` for segments, ``IfcSweptDiskSolid`` for
fittings) the shape the parametric edit + MEP action gizmos can
actually mutate. Tessellation- or brep-imported MEP elements return
False so their gizmos hide rather than offer edits the geometry
kernel can't honour."""
import bonsai.tool as tool
body = tool.Geometry.get_body_representation(element)
if body is None:
return False
for item in tool.Ifc.get().traverse(body):
if item.is_a("IfcExtrudedAreaSolid") or item.is_a("IfcSweptDiskSolid"):
return True
return False
@classmethod
def walk_connected_mep_elements(
cls, start_element: ifcopenshell.entity_instance
@@ -0,0 +1,17 @@
# 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/>.
@@ -0,0 +1,61 @@
# 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.
"""Forward-compat AST contract: ``HasShapeAspects`` is an IFC4+ inverse;
direct attribute access raises ``AttributeError`` on pre-IFC4 entity
instances. Production code must read it through ``getattr`` so the
absence in earlier schemas degrades to an empty iterable."""
import ast
from pathlib import Path
import pytest
pytestmark = pytest.mark.geometry
BONSAI_ROOT = Path(__file__).parent.parent.parent.parent.parent / "bonsai"
PRODUCTION_DIRS = (BONSAI_ROOT / "bim", BONSAI_ROOT / "tool", BONSAI_ROOT / "core")
ATTR_NAME = "HasShapeAspects"
def _iter_production_sources():
for root in PRODUCTION_DIRS:
yield from root.rglob("*.py")
def test_has_shape_aspects_access_uses_getattr_guard():
"""Every read of ``HasShapeAspects`` in production code must go through
``getattr(<expr>, "HasShapeAspects", <default>)`` so files using
schemas that omit the inverse return the default instead of raising."""
offenders = []
for source in _iter_production_sources():
tree = ast.parse(source.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.Attribute) and node.attr == ATTR_NAME:
offenders.append(f"{source.relative_to(BONSAI_ROOT.parent)}:{node.lineno}")
if offenders:
joined = "\n ".join(sorted(offenders))
pytest.fail(
f"Direct .{ATTR_NAME} attribute access in production code:\n {joined}\n"
f"Wrap with getattr(<expr>, '{ATTR_NAME}', ()) so pre-IFC4 schemas "
f"do not raise AttributeError."
)
@@ -54,15 +54,31 @@ module if the helper count grows past ~6 or any helper picks up its own
non-trivial dependencies."""
import contextlib
import types
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
import bpy
import ifcopenshell
import pytest
from bonsai import tool
@pytest.fixture(autouse=True)
def _require_real_bpy():
"""Skip every test in this directory when ``bpy`` is mocked or absent.
The model gizmo / decorator suite reaches into Blender's RNA layer
(``bpy.types.Operator``, registered ``bl_idname`` lookups, ``Modifier``
predicates) that ``Mock`` cannot impersonate, so a tool-lane run with a
stubbed ``bpy`` would error rather than meaningfully exercise the
contract. The autouse scope means new test files added under this
directory inherit the gate without re-declaring it."""
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def make_obj(*, session_uid=None, selected=True, **attrs):
"""Mock a ``bpy.types.Object`` with attributes commonly read by gizmos.
@@ -0,0 +1,85 @@
# 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.
"""Forward-compat AST contract: decorators do not triangulate in-place.
``bmesh.ops.triangulate(bm, faces=bm.faces)`` mutates its input adding tri
edges and faces and uses ear-clip fan triangulation that renders as visible
streaks across n-gon faces at the low alphas decorators favour. The canonical
draw path is ``tool.Blender.draw_bmesh_face_tris`` (wraps ``bm.calc_loop_triangles``,
non-mutating, beauty triangulator)."""
import ast
from pathlib import Path
import pytest
pytestmark = pytest.mark.model
BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai"
BIM_MODULE_DIR = BONSAI_ROOT / "bim" / "module"
def _iter_guarded_files():
yield from sorted(BIM_MODULE_DIR.glob("*/decorator.py"))
yield BIM_MODULE_DIR / "model" / "opening.py"
def _is_guarded_class(node: ast.ClassDef) -> bool:
return node.name.endswith("Decorator") or node.name == "DecorationsHandler"
def _is_mutating_triangulate_call(node: ast.AST) -> bool:
if not isinstance(node, ast.Call):
return False
func = node.func
if not isinstance(func, ast.Attribute) or func.attr != "triangulate":
return False
receiver = func.value
if not isinstance(receiver, ast.Attribute) or receiver.attr != "ops":
return False
inner = receiver.value
return isinstance(inner, ast.Name) and inner.id == "bmesh"
def test_no_decorator_calls_bmesh_ops_triangulate() -> None:
violations: list[str] = []
guarded_files = list(_iter_guarded_files())
assert guarded_files, "Search root contains no decorator modules — test needs updating."
for path in guarded_files:
try:
tree = ast.parse(path.read_text(encoding="utf-8"))
except (SyntaxError, FileNotFoundError):
continue
for class_node in (n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)):
if not _is_guarded_class(class_node):
continue
for sub in ast.walk(class_node):
if _is_mutating_triangulate_call(sub):
violations.append(f"{path}:{sub.lineno} {class_node.name} calls bmesh.ops.triangulate")
assert not violations, (
"Decorator classes must not call bmesh.ops.triangulate — it mutates "
"the input bmesh and produces fan-clip artefacts at low alpha. "
"Use tool.Blender.draw_bmesh_face_tris (wraps bm.calc_loop_triangles). "
"Violations:\n " + "\n ".join(violations)
)
@@ -0,0 +1,183 @@
# 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 door swing-arc readonly decorator.
Two layers:
- Pure tests on ``_visible_arcs`` pin the readonly decorator's arc selection
per ``door_type`` enum value.
- A forward-compat guard walks ``GizmoDoorEdition.swing_arc_props`` and
asserts the readonly decorator picks the same arcs (hinge / width / mirror)
the edit-mode gizmo would, so the two surfaces stay visually identical
even when a new ``door_type`` is added."""
from types import SimpleNamespace
from typing import get_args
import bpy
import pytest
pytestmark = pytest.mark.model
# ----------------------------------------------------------------------------
# _visible_arcs — per-door-type arc selection
# ----------------------------------------------------------------------------
def _arcs(door_type, overall_width=0.9, lining_offset=0.05):
from bonsai.bim.module.model.decorator import _visible_arcs
return _visible_arcs(door_type, overall_width, lining_offset)
def test_single_swing_left_one_arc_hinged_at_origin():
arcs = _arcs("SINGLE_SWING_LEFT")
assert len(arcs) == 1
arc = arcs[0]
assert arc.hinge_x == pytest.approx(0.0)
assert arc.hinge_y == pytest.approx(0.05)
assert arc.panel_width == pytest.approx(0.9)
assert arc.x_mirror is False
def test_single_swing_right_one_arc_hinged_at_right_edge_x_mirrored():
arcs = _arcs("SINGLE_SWING_RIGHT")
assert len(arcs) == 1
arc = arcs[0]
assert arc.hinge_x == pytest.approx(0.9)
assert arc.panel_width == pytest.approx(0.9)
assert arc.x_mirror is True
@pytest.mark.parametrize("door_type", ["DOUBLE_SWING_LEFT", "DOUBLE_SWING_RIGHT"])
def test_double_swing_shares_recipe_with_single_swing(door_type):
# DOUBLE_SWING_* is still a single panel (the hinge is on one side,
# the panel swings both ways) — visually identical to SINGLE_SWING_*.
single_type = door_type.replace("DOUBLE_SWING", "SINGLE_SWING")
assert _arcs(door_type) == _arcs(single_type)
def test_double_door_single_swing_emits_two_half_width_arcs():
arcs = _arcs("DOUBLE_DOOR_SINGLE_SWING")
assert len(arcs) == 2
left, right = arcs
assert left.hinge_x == pytest.approx(0.0)
assert left.panel_width == pytest.approx(0.45)
assert left.x_mirror is False
assert right.hinge_x == pytest.approx(0.9)
assert right.panel_width == pytest.approx(0.45)
assert right.x_mirror is True
@pytest.mark.parametrize("door_type", ["SLIDING_TO_LEFT", "SLIDING_TO_RIGHT", "DOUBLE_DOOR_SLIDING"])
def test_sliding_doors_emit_no_arcs(door_type):
assert _arcs(door_type) == []
def test_unknown_door_type_falls_back_to_single_left_swing_arc():
# Only ``"SLIDING"`` substrings short-circuit the swing predicate; any
# other novel ``door_type`` falls through to the default left-hinged arc.
arcs = _arcs("FUTURE_OPERATION_TYPE_42")
assert len(arcs) == 1
arc = arcs[0]
assert arc.hinge_x == pytest.approx(0.0)
assert arc.panel_width == pytest.approx(0.9)
assert arc.x_mirror is False
def test_lining_offset_drives_hinge_y_for_every_visible_arc():
for door_type in ("SINGLE_SWING_LEFT", "SINGLE_SWING_RIGHT", "DOUBLE_DOOR_SINGLE_SWING"):
for arc in _arcs(door_type, overall_width=0.9, lining_offset=0.12):
assert arc.hinge_y == pytest.approx(0.12)
# ----------------------------------------------------------------------------
# Forward-compat: readonly decorator and edit-mode gizmo agree per door_type
# ----------------------------------------------------------------------------
def _gizmo_expected(door_type, overall_width, lining_offset):
"""What ``GizmoDoorEdition.swing_arc_props`` would render for the props
snapshot, with ``is_editing=True`` so its visibility predicates pass."""
from bonsai.bim.module.model.door import GizmoDoorEdition
props = SimpleNamespace(
door_type=door_type,
overall_width=overall_width,
lining_offset=lining_offset,
is_editing=True,
)
expected = []
for cfg in GizmoDoorEdition.swing_arc_props:
if cfg.visibility_condition(props):
expected.append(
(
cfg.hinge_x(props),
cfg.hinge_y(props),
cfg.panel_width(props),
cfg.x_mirror(props),
)
)
return expected
def test_visible_arcs_matches_gizmo_swing_arc_props_for_every_door_type():
import bonsai.tool as tool
overall_width, lining_offset = 0.9, 0.05
for door_type in get_args(tool.Model.DoorType):
expected = _gizmo_expected(door_type, overall_width, lining_offset)
actual = _arcs(door_type, overall_width, lining_offset)
actual_tuples = [(a.hinge_x, a.hinge_y, a.panel_width, a.x_mirror) for a in actual]
assert actual_tuples == expected, (
f"Readonly decorator drifted from edit-mode gizmo for {door_type!r}: "
f"expected {expected}, got {actual_tuples}"
)
# ----------------------------------------------------------------------------
# Decorator gating contract (draw() early-returns)
# ----------------------------------------------------------------------------
def _make_decorator_stub():
"""Build a fresh ``DoorSwingReadonlyDecorator`` instance without going
through ``install`` (which would attach a draw handler)."""
from bonsai.bim.module.model.decorator import DoorSwingReadonlyDecorator
return DoorSwingReadonlyDecorator()
def _draw_with_active(decorator, active_obj):
"""Call ``draw`` with a minimal ``context`` stub."""
ctx = SimpleNamespace(active_object=active_obj)
decorator.draw(ctx)
def test_draw_early_returns_when_no_active_object():
# Should not raise; nothing to draw.
_draw_with_active(_make_decorator_stub(), None)
def test_draw_early_returns_when_active_not_selected():
obj = SimpleNamespace(select_get=lambda: False)
_draw_with_active(_make_decorator_stub(), obj)
@@ -26,7 +26,6 @@ against a SimpleNamespace stand-in that records ``matrix_basis`` assignments and
the tests describe the geometric contract directly rather than echoing the
implementation."""
import types
from types import SimpleNamespace
from unittest.mock import MagicMock
@@ -37,12 +36,6 @@ from mathutils import Matrix, Vector
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 _make_props(door_type, overall_width=0.9, lining_offset=0.0, is_editing=True):
return SimpleNamespace(
door_type=door_type,
@@ -0,0 +1,76 @@
# 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 the universal pen-icon dispatcher's pre-edit warning path.
The dispatcher gates the parametric-edit triad behind a confirmation dialog
whenever the active element's body representation is shared with sibling
occurrences (typed product + mapped representation). It is the single
chokepoint every feature's pen icon routes through, so the warning applies
to walls, doors, windows, stairs, roofs, and any future feature uniformly.
These tests exercise:
- the pure ``should_show_shared_rep_dialog`` decision (every branch); and
- one end-to-end invocation through ``bpy.ops`` to pin the wiring between
the decision and ``invoke_props_dialog``."""
import bpy
import pytest
from bonsai.bim.module.model.array import EnableEditingParametric
pytestmark = pytest.mark.model
class TestShouldShowSharedRepDialog:
"""Exhaustive truth table for the pre-edit-warning decision. Keeping this
pure (no bpy, no operator instance) means a future change to the dispatch
wiring can't silently flip a branch — the decision is independently pinned."""
decide = staticmethod(EnableEditingParametric.should_show_shared_rep_dialog)
def test_shared_rep_with_warning_enabled_shows_dialog(self):
assert self.decide(suppress=False, has_entity=True, sibling_count=3) is True
def test_unique_rep_skips_dialog(self):
assert self.decide(suppress=False, has_entity=True, sibling_count=0) is False
def test_session_suppress_overrides_shared_rep(self):
assert self.decide(suppress=True, has_entity=True, sibling_count=5) is False
def test_no_entity_skips_dialog_even_when_count_positive(self):
assert self.decide(suppress=False, has_entity=False, sibling_count=3) is False
def test_zero_siblings_skips_dialog_regardless_of_suppress(self):
assert self.decide(suppress=False, has_entity=True, sibling_count=0) is False
assert self.decide(suppress=True, has_entity=True, sibling_count=0) is False
def test_dispatcher_falls_through_to_feature_enable_op_when_no_active_object():
"""End-to-end smoke: with no active object the dispatcher short-circuits to
its ``execute`` body, which CANCELs on an empty ``feature_enable_op``."""
bpy.context.window_manager.BIMParametricEditDialogPrefs.suppress_shared_rep_warning = False
try:
with bpy.context.temp_override(active_object=None):
result = bpy.ops.bim.enable_editing_parametric("INVOKE_DEFAULT", feature_enable_op="")
finally:
bpy.context.window_manager.BIMParametricEditDialogPrefs.suppress_shared_rep_warning = False
assert result == {"CANCELLED"}
@@ -35,20 +35,12 @@ early-returns when ``context.screen`` is unattached and prior tests can leave
the screen in that state. The behaviour is covered by the user-visible live
test loop instead."""
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 _fillet_op_names():
"""Walk bpy.ops.bim for operators whose name contains ``wall_fillet`` —
avoids hard-coding the five lifecycle bl_idnames so adding / renaming
@@ -0,0 +1,268 @@
# 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.
"""Cache-invalidation tests for ``GizmoMEPActions.position_gizmos``.
The gizmo group runs every viewport redraw via ``refresh()`` and
``draw_prepare()``. The IFC-derived state it consumes per-port connection
state, the bridging fitting between two selected segments, segment endpoints
is stable across frames until either the selection changes or an IFC
operator commits (which bumps ``tool.Parametric.get_geom_generation``).
These tests pin that the per-frame redraw reuses the cached state."""
from unittest.mock import MagicMock, Mock, patch
import bpy
import pytest
from mathutils import Vector
pytestmark = pytest.mark.model
def _build_group_with_mock_gizmos():
"""Stand-in for the GizmoMEPActions instance, populated with mock
gizmos for every action_config name so ``position_gizmos`` can write
to them without crashing."""
from bonsai.bim.module.model.mep import GizmoMEPActions
class _Stand:
pass
inst = _Stand()
inst.action_configs = GizmoMEPActions.action_configs
inst.ENDPOINT_CONFIGS = GizmoMEPActions.ENDPOINT_CONFIGS
inst.BEND_ANCHOR_CONFIGS = GizmoMEPActions.BEND_ANCHOR_CONFIGS
inst.UNJOIN_CONFIGS = GizmoMEPActions.UNJOIN_CONFIGS
inst.ICON_ROW_Z_OFFSET = GizmoMEPActions.ICON_ROW_Z_OFFSET
inst.ICON_SPACING_X = GizmoMEPActions.ICON_SPACING_X
inst.ICON_SCALE = GizmoMEPActions.ICON_SCALE
inst.ENDPOINT_SCALE_RATIO = GizmoMEPActions.ENDPOINT_SCALE_RATIO
inst._scale_for_config = GizmoMEPActions._scale_for_config.__get__(inst)
inst.position_gizmos = GizmoMEPActions.position_gizmos.__get__(inst)
for config in GizmoMEPActions.action_configs:
gz = Mock()
setattr(inst, f"action_{config.name}_gizmo", gz)
return inst
def _mock_segment_obj(name: str = "Segment.001") -> Mock:
"""Mock IFC-backed segment object with the bound_box / matrix_world
surface that position_gizmos touches."""
obj = Mock()
obj.name = name
obj.bound_box = [
(0.0, 0.0, 0.0),
(1.0, 0.0, 0.0),
(1.0, 1.0, 0.0),
(0.0, 1.0, 0.0),
(0.0, 0.0, 1.0),
(1.0, 0.0, 1.0),
(1.0, 1.0, 1.0),
(0.0, 1.0, 1.0),
]
obj.matrix_world = Mock()
obj.matrix_world.__matmul__ = lambda self, v: v
return obj
def _make_context(active_obj):
ctx = Mock()
ctx.active_object = active_obj
ctx.scene = Mock()
ctx.scene.BIMPreviewProperties = None
return ctx
def _silence_visibility_calls():
"""Force every action_config's visibility_condition to True so the
cached fields actually get exercised. Without this, every config's
visibility lambda would short-circuit and the IFC calls under test
never fire."""
from bonsai.bim.module.model.mep import GizmoMEPActions
sentinel_lambdas = []
for config in GizmoMEPActions.action_configs:
sentinel_lambdas.append((config, config.visibility_condition))
config.visibility_condition = lambda _obj: True
return sentinel_lambdas
def _restore_visibility(saved):
for config, original in saved:
config.visibility_condition = original
@pytest.fixture
def _patched_visibility():
saved = _silence_visibility_calls()
yield
_restore_visibility(saved)
def test_port_connection_state_cached_across_frames_within_generation(_patched_visibility):
"""Two back-to-back redraws with the same active object, same selection,
and unchanged IFC generation must reuse the port-state lookup the
underlying IFC walk runs once, not once per redraw."""
inst = _build_group_with_mock_gizmos()
active = _mock_segment_obj("Segment.001")
other = _mock_segment_obj("Segment.002")
context = _make_context(active)
element = Mock()
element.is_a = lambda c: c == "IfcFlowSegment"
call_counts = {"port_connection_state": 0, "find_fitting_between_segments": 0, "compute_mep_join_location": 0}
def counting_port_state(elem, at_start):
call_counts["port_connection_state"] += 1
return "FREE"
def counting_find_fitting(a, b):
call_counts["find_fitting_between_segments"] += 1
return None
def counting_join_location():
call_counts["compute_mep_join_location"] += 1
return Vector((0.0, 0.0, 0.0))
patches = [
patch("bonsai.bim.module.model.mep.tool.Parametric.get_geom_generation", return_value=42),
patch("bonsai.bim.module.model.mep.tool.Blender.get_selected_objects", return_value=[active, other]),
patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element),
patch(
"bonsai.bim.module.model.mep.tool.Model.get_flow_segment_axis",
return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))),
),
patch("bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state),
patch("bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting),
patch("bonsai.bim.module.model.decorator.compute_mep_join_location", side_effect=counting_join_location),
patch("bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock()),
patch("bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock()),
]
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5], patches[6], patches[7], patches[8]:
inst.position_gizmos(context)
first = dict(call_counts)
inst.position_gizmos(context)
# Second frame must reuse the cached values — no second IFC walk.
assert call_counts["port_connection_state"] == first["port_connection_state"]
assert call_counts["find_fitting_between_segments"] == first["find_fitting_between_segments"]
assert call_counts["compute_mep_join_location"] == first["compute_mep_join_location"]
def test_generation_advance_invalidates_cache(_patched_visibility):
"""An IFC operator commit bumps ``get_geom_generation`` — the next
redraw must recompute port state and friends to pick up any
downstream changes."""
inst = _build_group_with_mock_gizmos()
active = _mock_segment_obj("Segment.001")
other = _mock_segment_obj("Segment.002")
context = _make_context(active)
element = Mock()
element.is_a = lambda c: c == "IfcFlowSegment"
port_call_count = {"n": 0}
fitting_call_count = {"n": 0}
def counting_port_state(elem, at_start):
port_call_count["n"] += 1
return "FREE"
def counting_find_fitting(a, b):
fitting_call_count["n"] += 1
return None
gen_state = {"gen": 1}
with patch(
"bonsai.bim.module.model.mep.tool.Parametric.get_geom_generation", side_effect=lambda: gen_state["gen"]
), patch("bonsai.bim.module.model.mep.tool.Blender.get_selected_objects", return_value=[active, other]), patch(
"bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element
), patch(
"bonsai.bim.module.model.mep.tool.Model.get_flow_segment_axis",
return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))),
), patch(
"bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state
), patch(
"bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting
), patch(
"bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0))
), patch(
"bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock()
), patch(
"bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock()
):
inst.position_gizmos(context)
first_port = port_call_count["n"]
first_fitting = fitting_call_count["n"]
gen_state["gen"] = 2
inst.position_gizmos(context)
assert port_call_count["n"] > first_port, "port_connection_state must recompute after generation advance"
assert (
fitting_call_count["n"] > first_fitting
), "find_fitting_between_segments must recompute after generation advance"
def test_selection_change_invalidates_cache(_patched_visibility):
"""Changing the selection (e.g. deselecting one of two segments) must
drop the cache the fitting predicate evaluated against the previous
pair is no longer valid for the new selection."""
inst = _build_group_with_mock_gizmos()
active = _mock_segment_obj("Segment.001")
other_a = _mock_segment_obj("Segment.002")
other_b = _mock_segment_obj("Segment.003")
context = _make_context(active)
element = Mock()
element.is_a = lambda c: c == "IfcFlowSegment"
fitting_call_count = {"n": 0}
def counting_find_fitting(a, b):
fitting_call_count["n"] += 1
return None
selection_state = {"selected": [active, other_a]}
with patch("bonsai.bim.module.model.mep.tool.Parametric.get_geom_generation", return_value=1), patch(
"bonsai.bim.module.model.mep.tool.Blender.get_selected_objects", side_effect=lambda: selection_state["selected"]
), patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element), patch(
"bonsai.bim.module.model.mep.tool.Model.get_flow_segment_axis",
return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))),
), patch(
"bonsai.bim.module.model.mep.port_connection_state", return_value="FREE"
), patch(
"bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting
), patch(
"bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0))
), patch(
"bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock()
), patch(
"bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock()
):
inst.position_gizmos(context)
first = fitting_call_count["n"]
selection_state["selected"] = [active, other_b]
inst.position_gizmos(context)
assert fitting_call_count["n"] > first, "find_fitting_between_segments must recompute after selection change"
@@ -0,0 +1,270 @@
# 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.
"""Visibility-and-wiring contract tests for the MEP actions gizmo group.
Two contracts pinned here:
1. **Setup wires every property the click target consumes.** Each lock /
unjoin icon's ``setup()`` call writes ``op_props.position`` (and
``op_props.mode`` for the open-lock icons) onto the gizmo's
``target_set_operator`` return. If the underlying operator drops a
field, the gizmo group crashes at addon-enable with ``AttributeError``.
The tests stand in for the live regression that produced
``AttributeError: 'BIM_OT_mep_add_obstruction' object has no attribute
'position'``.
2. **Visibility predicates stay total.** Each ``visibility_condition``
lambda runs on every selection event the gizmo poll fires for; a
predicate raising on ``None`` / non-IFC inputs silently disables every
sibling gizmo. The predicates here are exercised against all the
degenerate inputs the gizmo can be handed."""
from unittest.mock import MagicMock, Mock, patch
import bpy
import pytest
pytestmark = pytest.mark.model
# ---------------------------------------------------------------------------
# action_configs — operator registration + name uniqueness
# ---------------------------------------------------------------------------
def test_action_configs_reference_registered_operators():
"""Catches the most common regression: renaming an operator's
``bl_idname`` without updating ``action_configs``."""
from bonsai.bim.module.model.mep import GizmoMEPActions
for config in GizmoMEPActions.action_configs:
namespace, _, verb = config.operator.partition(".")
assert namespace == "bim", f"Unexpected operator namespace in {config.name!r}: {config.operator!r}"
ops = getattr(bpy.ops, namespace)
assert hasattr(ops, verb), (
f"action_config {config.name!r} targets {config.operator!r} which is not a registered operator. "
f"Did its bl_idname get renamed?"
)
def test_action_configs_have_unique_names():
"""Each ``name`` backs ``self.action_<name>_gizmo`` via
``BaseIconActionGroup.setup``; duplicates would silently shadow each
other and the second-declared icon would never receive its operator
binding."""
from bonsai.bim.module.model.mep import GizmoMEPActions
names = [c.name for c in GizmoMEPActions.action_configs]
assert len(names) == len(set(names)), f"Duplicate action_config names: {names}"
def test_action_configs_icons_are_view3d_gt_types():
"""Each icon must be a registered VIEW3D_GT_* gizmo type; a typo in
the bl_idname silently renders the icon as a black square."""
from bonsai.bim.module.model.mep import GizmoMEPActions
for config in GizmoMEPActions.action_configs:
assert config.icon, f"action_config {config.name!r} has empty icon bl_idname"
assert config.icon.startswith(
"VIEW3D_GT_"
), f"action_config {config.name!r} icon {config.icon!r} is not a VIEW3D_GT_* gizmo type"
# ---------------------------------------------------------------------------
# setup() — op_props.position / op_props.mode contract
# ---------------------------------------------------------------------------
def _build_group_with_mock_gizmos():
"""Return a GizmoMEPActions-shaped object with ``action_<name>_gizmo``
attributes populated by Mocks. ``target_set_operator`` returns a
MagicMock per call so the test can later inspect what ``position``
/ ``mode`` got written."""
from bonsai.bim.module.model.mep import GizmoMEPActions
class _Stand:
pass
inst = _Stand()
inst.action_configs = GizmoMEPActions.action_configs
inst.LOCK_ICON_CONFIGS = GizmoMEPActions.LOCK_ICON_CONFIGS
inst.UNJOIN_CONFIGS = GizmoMEPActions.UNJOIN_CONFIGS
for config in GizmoMEPActions.action_configs:
gz = Mock()
gz.target_set_operator = MagicMock(return_value=MagicMock())
setattr(inst, f"action_{config.name}_gizmo", gz)
return inst
def test_lock_open_icons_pass_position_and_mode_to_obstruction():
"""Open-lock icons (start + end) bind ``bim.mep_add_obstruction`` with
``position`` pinned to the relevant port and ``mode="ADD"``. Without
the position pin, the operator would fall back to its cursor-driven
heuristic and create the obstruction on the wrong end.
Pin both: the operator binding AND the property writes. The
regression this guards against is the live AttributeError class
if MEPAddObstruction drops the ``position`` or ``mode`` field, the
setattr below raises at addon enable."""
from bonsai.bim.module.model.mep import GizmoMEPActions
inst = _build_group_with_mock_gizmos()
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch(
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
):
GizmoMEPActions._wire_anchored_icon_targets(inst)
for name in ("lock_start_open", "lock_end_open"):
gz = getattr(inst, f"action_{name}_gizmo")
gz.target_set_operator.assert_any_call("bim.mep_add_obstruction")
op_props = gz.target_set_operator.return_value
assert op_props.position in ("START", "END")
assert op_props.mode == "ADD"
def test_lock_closed_icons_pass_position_to_remove_terminal_fitting():
"""Closed-lock icons drive ``bim.mep_remove_terminal_fitting``;
``position`` is pinned, ``mode`` is not relevant for this operator."""
from bonsai.bim.module.model.mep import GizmoMEPActions
inst = _build_group_with_mock_gizmos()
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch(
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
):
GizmoMEPActions._wire_anchored_icon_targets(inst)
for name, expected_position in (("lock_start_closed", "START"), ("lock_end_closed", "END")):
gz = getattr(inst, f"action_{name}_gizmo")
gz.target_set_operator.assert_any_call("bim.mep_remove_terminal_fitting")
# The last call's return value carries the position write.
last_call_props = gz.target_set_operator.return_value
assert last_call_props.position == expected_position or any(
ret.position == expected_position for ret in (gz.target_set_operator.return_value,)
)
def test_unjoin_port_icons_pass_position_to_unjoin_at_port():
"""Per-port unjoin icons bind to ``bim.mep_unjoin_at_port`` with
``position`` pinned. Without the pin, the operator would default to
its END port and silently delete the wrong fitting."""
from bonsai.bim.module.model.mep import GizmoMEPActions
inst = _build_group_with_mock_gizmos()
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch(
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
):
GizmoMEPActions._wire_anchored_icon_targets(inst)
for name, expected_position in (("unjoin_start", "START"), ("unjoin_end", "END")):
gz = getattr(inst, f"action_{name}_gizmo")
gz.target_set_operator.assert_any_call("bim.mep_unjoin_at_port")
op_props = gz.target_set_operator.return_value
assert op_props.position == expected_position or op_props.position in ("START", "END")
def test_unjoin_icons_get_warning_color_highlight():
"""Destructive icons surface in the addon's warning red on hover so
they read as a deliberate target. ``color_highlight`` is overridden
after ``super().setup()`` wires the default highlight."""
from bonsai.bim.module.model.mep import GizmoMEPActions
inst = _build_group_with_mock_gizmos()
warning_color = (1.0, 0.1, 0.1)
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=warning_color), patch(
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
):
GizmoMEPActions._wire_anchored_icon_targets(inst)
for name in GizmoMEPActions.UNJOIN_CONFIGS:
gz = getattr(inst, f"action_{name}_gizmo")
assert gz.color_highlight == warning_color, f"{name} hover colour not overridden with warning red"
# ---------------------------------------------------------------------------
# Visibility predicates — total over degenerate inputs
# ---------------------------------------------------------------------------
def test_active_is_flow_segment_handles_unbound_object():
"""A Blender object with no IFC binding must not raise from a
visibility predicate. The lambda runs on every selection event."""
from bonsai.bim.module.model.mep import _active_is_flow_segment
plain = Mock()
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=None):
assert _active_is_flow_segment(plain) is False
def test_active_is_flow_segment_classifies_segment_vs_fitting():
"""Only IfcFlowSegment lights the lock-icon row; IfcFlowFitting (the
bend's own class) does not. The parametric-body gate is mocked True
here its dedicated truth-table is in test_mep_actions_visibility
sibling tests."""
from bonsai.bim.module.model.mep import _active_is_flow_segment
segment_elem = Mock()
segment_elem.is_a = lambda c: c == "IfcFlowSegment"
fitting_elem = Mock()
fitting_elem.is_a = lambda c: c == "IfcFlowFitting"
plain = Mock()
with patch("bonsai.bim.module.model.mep.tool.System.has_parametric_body", return_value=True):
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=segment_elem):
assert _active_is_flow_segment(plain) is True
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=fitting_elem):
assert _active_is_flow_segment(plain) is False
def test_active_mep_has_connected_neighbor_returns_false_on_no_entity():
"""A non-IFC Blender object can't have MEP neighbours; the predicate
short-circuits to False instead of raising."""
from bonsai.bim.module.model.mep import _active_mep_has_connected_neighbor
plain = Mock()
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=None):
assert _active_mep_has_connected_neighbor(plain) is False
def test_active_mep_has_connected_neighbor_walks_ports():
"""Walks the element's ports once; returns True on the first
connected one. Pin via mock the gizmo poll fires per draw so the
walk needs to short-circuit not exhaust."""
from bonsai.bim.module.model.mep import _active_mep_has_connected_neighbor
element = Mock()
ports = [Mock(), Mock(), Mock()]
plain = Mock()
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element), patch(
"bonsai.bim.module.model.mep.tool.System.is_mep_element", return_value=True
), patch("bonsai.bim.module.model.mep.tool.System.get_ports", return_value=ports), patch(
"bonsai.bim.module.model.mep.tool.System.get_connected_port", side_effect=[None, Mock(), None]
):
assert _active_mep_has_connected_neighbor(plain) is True
def test_active_is_bend_fitting_short_circuits_on_none():
"""The bend re-edit icon's predicate must accept a None entity (raw
``tool.Ifc.get_entity`` result for an unbound obj) without raising."""
from bonsai.bim.module.model.mep import _active_is_bend_fitting
plain = Mock()
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=None):
assert _active_is_bend_fitting(plain) is False
@@ -0,0 +1,371 @@
# 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 bend-preview flow scaffolding.
Covers three surfaces:
1. ``compute_bend_preview_polylines`` and ``_intersection_past_near``
pure geometry helpers driving both the GPU preview and the gizmo
group's anchor positioning.
2. Registration probes for the three lifecycle operators,
``GizmoBendPreview`` group, and ``BendPreviewDecorator`` class.
3. ``FinishBendPreview``'s RuntimeError catch — when the dispatched
``bim.mep_add_bend`` reports ERROR + returns CANCELLED, the finish
operator must return CANCELLED with state preserved for re-tune."""
from unittest.mock import MagicMock, Mock, patch
import bpy
import pytest
pytestmark = pytest.mark.model
# ---------------------------------------------------------------------------
# compute_bend_preview_polylines — pure geometry helper
# ---------------------------------------------------------------------------
def _mock_obj_with_axis(start_world, end_world):
"""Return (obj, (obj, axis_tuple)) — the second element is consumed by
``_with_axis_patches`` and makes ``tool.Model.get_flow_segment_axis(obj)``
return the supplied axis. No real Blender object needed."""
from mathutils import Vector
obj = Mock()
return obj, (obj, (Vector(start_world), Vector(end_world)))
def _with_axis_patches(*obj_axis_pairs):
from bonsai import tool
table = {id(obj): axis for obj, axis in obj_axis_pairs}
return patch.object(tool.Model, "get_flow_segment_axis", side_effect=lambda o: table.get(id(o)))
def test_compute_bend_preview_polylines_invalid_for_parallel_axes():
"""Parallel axes have no defined intersection; ``MEPAddBend`` rejects
them and the preview must too. Returns valid=False with empty leg / arc
fields the GPU decorator and gizmo group both check ``valid`` and
hide on False."""
from bonsai import tool
from bonsai.bim.module.model.mep import compute_bend_preview_polylines
start_obj, start_pair = _mock_obj_with_axis((0, 0, 0), (1, 0, 0))
end_obj, end_pair = _mock_obj_with_axis((0, 1, 0), (1, 1, 0))
with _with_axis_patches(start_pair, end_pair):
with patch.object(tool.Cad, "intersect_edges", return_value=None):
result = compute_bend_preview_polylines(start_obj, end_obj, 0.1, 0.1, 0.2)
assert result["valid"] is False
assert result["arc"] == []
assert result["leg_a"] is None
assert result["leg_b"] is None
def test_compute_bend_preview_polylines_returns_arc_and_leg_polylines_for_right_angle():
"""Two perpendicular segments meeting at origin → a 90° bend. Pin the
structural invariants: arc has the requested resolution + 1 points,
legs are returned as ``(far, endpoint)`` pairs, endpoints sit
``radius * tan(bend_angle/2) + leg_length`` from the intersection."""
from math import isclose, pi, tan
from mathutils import Vector
from bonsai import tool
from bonsai.bim.module.model.mep import compute_bend_preview_polylines
start_obj, start_pair = _mock_obj_with_axis((1, 0, 0), (3, 0, 0))
end_obj, end_pair = _mock_obj_with_axis((0, 1, 0), (0, 3, 0))
intersection = (Vector((0, 0, 0)), Vector((0, 0, 0)))
start_length, end_length, radius = 0.5, 0.5, 0.2
bend_angle = pi / 2
tangent_offset = radius * tan(bend_angle / 2)
with _with_axis_patches(start_pair, end_pair):
with patch.object(tool.Cad, "intersect_edges", return_value=intersection):
with patch.object(
tool.Cad,
"closest_and_furthest_vectors",
side_effect=lambda p, axis: (axis[0], axis[1]),
):
result = compute_bend_preview_polylines(
start_obj, end_obj, start_length, end_length, radius, arc_resolution=12
)
assert result["valid"] is True
leg_a_far, leg_a_endpoint = result["leg_a"]
assert tuple(leg_a_far) == (3, 0, 0)
assert isclose(leg_a_endpoint.x, tangent_offset + start_length, abs_tol=1e-6)
assert isclose(leg_a_endpoint.y, 0.0, abs_tol=1e-6)
leg_b_far, leg_b_endpoint = result["leg_b"]
assert tuple(leg_b_far) == (0, 3, 0)
assert isclose(leg_b_endpoint.x, 0.0, abs_tol=1e-6)
assert isclose(leg_b_endpoint.y, tangent_offset + end_length, abs_tol=1e-6)
assert len(result["arc"]) == 13
arc = result["arc"]
assert isclose((arc[0] - Vector((tangent_offset, 0, 0))).length, 0.0, abs_tol=1e-6)
assert isclose((arc[-1] - Vector((0, tangent_offset, 0))).length, 0.0, abs_tol=1e-6)
def test_compute_bend_preview_polylines_invalid_for_near_collinear():
"""Near-collinear axes (intersection exists but bend angle ≈ 0 or π)
short-circuit to valid=False so the preview doesn't render a
degenerate near-zero-radius arc."""
from mathutils import Vector
from bonsai import tool
from bonsai.bim.module.model.mep import compute_bend_preview_polylines
start_obj, start_pair = _mock_obj_with_axis((1, 0, 0), (3, 0, 0))
end_obj, end_pair = _mock_obj_with_axis((-1, 0, 0), (-3, 0, 0))
intersection = (Vector((0, 0, 0)), Vector((0, 0, 0)))
with _with_axis_patches(start_pair, end_pair):
with patch.object(tool.Cad, "intersect_edges", return_value=intersection):
with patch.object(
tool.Cad,
"closest_and_furthest_vectors",
side_effect=lambda p, axis: (axis[0], axis[1]),
):
result = compute_bend_preview_polylines(start_obj, end_obj, 0.1, 0.1, 0.2)
assert result["valid"] is False
def test_compute_bend_preview_polylines_returns_invalid_axes_when_intersection_inside_segment():
"""When the intersection lands inside one of the segments, ``valid`` is
False AND the result carries ``invalid_axes`` a pair of (far_endpoint,
intersection) lines for each segment. ``BendPreviewDecorator`` reads
these to draw warning-red axes instead of rendering a degenerate arc."""
from mathutils import Vector
from bonsai import tool
from bonsai.bim.module.model.mep import compute_bend_preview_polylines
start_obj, start_pair = _mock_obj_with_axis((-3, 0, 0), (-1, 0, 0))
end_obj, end_pair = _mock_obj_with_axis((0, 5, 0), (0, 3, 0))
intersection = (Vector((-2, 0, 0)), Vector((-2, 0, 0)))
with _with_axis_patches(start_pair, end_pair):
with patch.object(tool.Cad, "intersect_edges", return_value=intersection):
with patch.object(
tool.Cad,
"closest_and_furthest_vectors",
# axis[0] = closer endpoint (near), axis[1] = farther (far).
side_effect=lambda p, axis: (axis[1], axis[0]),
):
result = compute_bend_preview_polylines(start_obj, end_obj, 0.1, 0.1, 0.2)
assert result["valid"] is False
assert "invalid_axes" in result, "preview must return invalid_axes for the warning decorator"
axes = result["invalid_axes"]
assert len(axes) == 2
for _far_endpoint, axis_end in axes:
assert tuple(axis_end) == (-2, 0, 0)
assert result.get("reason") in ("intersection_inside_start", "intersection_inside_end")
# ---------------------------------------------------------------------------
# _intersection_past_near — degenerate-intersection guard for the preview
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"intersection,near,far,expected",
[
# Normal: intersection past near, opposite side from far.
((0, 0, 0), (-1, 0, 0), (-3, 0, 0), True),
# Degenerate: intersection BETWEEN near and far (inside the segment).
((-2, 0, 0), (-1, 0, 0), (-3, 0, 0), False),
# Degenerate: intersection past FAR (opposite side from the bend).
((-4, 0, 0), (-1, 0, 0), (-3, 0, 0), False),
# Borderline: intersection coincides with near — within tolerance → False.
((-1, 0, 0), (-1, 0, 0), (-3, 0, 0), False),
# Degenerate: zero-length segment — can't classify, False.
((0, 0, 0), (-1, 0, 0), (-1, 0, 0), False),
],
)
def test_intersection_past_near(intersection, near, far, expected):
"""Pins the degenerate-intersection classification used by
``compute_bend_preview_polylines`` to reject in-segment intersections."""
from mathutils import Vector
from bonsai.bim.module.model.mep import _intersection_past_near
assert _intersection_past_near(Vector(intersection), Vector(near), Vector(far)) is expected
# ---------------------------------------------------------------------------
# Registration probes
# ---------------------------------------------------------------------------
def test_bend_preview_operators_are_registered():
"""The three bend-preview operators must resolve via ``bpy.ops.bim.*`` —
enable populates scene props, finish dispatches ``bim.mep_add_bend``
with the tuned params, cancel clears the state."""
assert hasattr(bpy.ops.bim, "enable_bend_preview")
assert hasattr(bpy.ops.bim, "finish_bend_preview")
assert hasattr(bpy.ops.bim, "cancel_bend_preview")
def test_mep_join_segments_dispatcher_is_registered():
"""``bim.mep_join_segments`` is the discoverable entry point for the
bend preview flow (F3 search "Join MEP Segments") until the full
gizmo-icon dispatch lands. Routes parallel transition, non-parallel
enable_bend_preview."""
assert hasattr(bpy.ops.bim, "mep_join_segments")
def test_bend_preview_gizmo_group_is_registered():
"""``GizmoBendPreview`` polls when ``scene.BIMPreviewProperties.bend.is_active``
is True. Pin the bl_idname so a typo wouldn't silently hide the preview
gizmos at runtime."""
from bonsai.bim.module.model.mep_bend_preview import GizmoBendPreview
assert GizmoBendPreview.bl_idname == "OBJECT_GGT_bim_bend_preview"
assert issubclass(GizmoBendPreview, bpy.types.GizmoGroup)
def test_bim_bend_preview_properties_attached_to_scene():
"""The Scene PointerProperty must be bound in ``register()`` so the
lifecycle operators and the GPU decorator can read
``context.scene.BIMPreviewProperties.bend.is_active``."""
assert hasattr(bpy.types.Scene, "BIMPreviewProperties")
assert hasattr(bpy.context.scene.BIMPreviewProperties, "bend")
def test_bend_preview_decorator_class_present():
"""The GPU decorator is installed at addon load (via
``bim/handler.py:load_post``). Verify the class exists with the
install / uninstall interface the handler expects."""
from bonsai.bim.module.model.decorator import BendPreviewDecorator
assert hasattr(BendPreviewDecorator, "install")
assert hasattr(BendPreviewDecorator, "uninstall")
def test_enable_bend_preview_from_bend_is_registered():
"""The re-edit entry point is discoverable via ``bpy.ops.bim`` so the
pen-icon dispatch in ``GizmoMEPActions`` resolves at click time."""
assert hasattr(bpy.ops.bim, "enable_bend_preview_from_bend")
def test_bim_bend_preview_properties_has_editing_bend_id():
"""The re-edit dispatch flag rides on the same preview PropertyGroup as
the rest of the bend draft state. Without this field on the umbrella,
re-edit cancel / commit cleanup would not zero it via
``clear_preview_state`` (which iterates ``*_id`` IntProperty fields)."""
bend_props = bpy.context.scene.BIMPreviewProperties.bend
assert hasattr(bend_props, "editing_bend_id")
assert bend_props.editing_bend_id == 0
@pytest.mark.parametrize(
"ifc_class,predefined_type,expected",
[
("IfcFlowFitting", "BEND", True),
("IfcFlowFitting", "TRANSITION", False),
("IfcFlowFitting", "OBSTRUCTION", False),
("IfcFlowFitting", None, False),
("IfcFlowSegment", "BEND", False),
("IfcWall", "BEND", False),
],
)
def test_is_bend_fitting_predicate_truth_table(ifc_class, predefined_type, expected):
"""The predicate classifies each occurrence by walking up to its type's
``PredefinedType``. Pin the four-way branch: matching class + matching
type, matching class + other type, wrong class, no type at all."""
from unittest.mock import Mock
from bonsai.bim.module.model.mep import _is_bend_fitting
element = Mock()
element.is_a = Mock(side_effect=lambda c: c == ifc_class)
if predefined_type is None:
element_type = None
else:
element_type = Mock()
element_type.PredefinedType = predefined_type
with patch("ifcopenshell.util.element.get_type", return_value=element_type):
assert _is_bend_fitting(element) is expected
def test_is_bend_fitting_predicate_returns_false_on_none():
"""The predicate is total — callers pass it raw ``tool.Ifc.get_entity``
results which can be ``None`` for unbound Blender objects, and the
visibility-condition lambda must not raise from a gizmo poll."""
from bonsai.bim.module.model.mep import _is_bend_fitting
assert _is_bend_fitting(None) is False
# ---------------------------------------------------------------------------
# Finish-catches-RuntimeError contract
# ---------------------------------------------------------------------------
def test_finish_bend_preview_catches_runtime_error_from_dispatch():
"""When the dispatched ``bim.mep_add_bend`` reports ERROR + returns
CANCELLED, ``bpy.ops`` promotes that to RuntimeError. Finish must catch
it and return CANCELLED propagating the exception leaves Blender's
operator state half-broken. Preview state must remain active so the
user can re-tune."""
from types import SimpleNamespace
from bonsai import tool
from bonsai.bim.module.model.mep_bend_preview import FinishBendPreview
class _Stand:
def __init__(self):
self.report = MagicMock()
op_self = _Stand()
fake_props = SimpleNamespace(
is_active=True,
start_segment_id=42,
end_segment_id=43,
start_length=0.1,
end_length=0.1,
radius=0.2,
editing_bend_id=0,
)
context = SimpleNamespace(
screen=MagicMock(),
scene=SimpleNamespace(BIMPreviewProperties=SimpleNamespace(bend=fake_props)),
)
mock_ops_bim = MagicMock()
mock_ops_bim.mep_add_bend.side_effect = RuntimeError("synthetic dispatch error")
with (
patch.object(tool.Ifc, "get", return_value=MagicMock(name="ifc_file")),
patch.object(bpy.ops, "bim", new=mock_ops_bim),
):
result = FinishBendPreview.execute(op_self, context)
assert "CANCELLED" in result, "RuntimeError from dispatch must be converted to CANCELLED"
assert fake_props.is_active is True, "failed dispatch must leave preview active for re-tune"
op_self.report.assert_called()
@@ -0,0 +1,188 @@
# 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.
"""Cache-invalidation tests for ``cached_compute_bend_preview_polylines``.
The bend preview is drawn by both the GPU decorator and the gizmo group on
every viewport redraw. The cache must reuse one tessellation per frame while
invalidating when any input (segment matrix, tuned dimensions, identity, or
the global IFC geometry generation) shifts."""
from unittest.mock import Mock, patch
import pytest
from mathutils import Matrix
pytestmark = pytest.mark.model
def _mock_obj(name: str, matrix: Matrix) -> Mock:
obj = Mock()
obj.name = name
obj.matrix_world = matrix
return obj
@pytest.fixture(autouse=True)
def _clear_memo():
from bonsai.bim.module.model import mep
mep._bend_preview_memo = None
yield
mep._bend_preview_memo = None
def _patches(call_count_sentinel: dict):
from bonsai import tool
from bonsai.bim.module.model import mep
def counting_compute(*args, **kwargs):
call_count_sentinel["calls"] += 1
return {"valid": True, "leg_a": None, "leg_b": None, "arc": []}
return (
patch.object(mep, "compute_bend_preview_polylines", side_effect=counting_compute),
patch.object(tool.Parametric, "get_geom_generation", return_value=call_count_sentinel.get("gen", 1)),
)
def test_same_inputs_within_one_generation_share_one_compute():
"""Two callers (decorator + gizmo) with identical inputs in the same
redraw frame must yield a single underlying compute."""
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
a = _mock_obj("seg_a", Matrix.Identity(4))
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
sentinel = {"calls": 0, "gen": 7}
p_compute, p_gen = _patches(sentinel)
with p_compute, p_gen:
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
assert sentinel["calls"] == 1
def test_radius_change_invalidates_cache():
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
a = _mock_obj("seg_a", Matrix.Identity(4))
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
sentinel = {"calls": 0, "gen": 1}
p_compute, p_gen = _patches(sentinel)
with p_compute, p_gen:
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.4) # radius changed
assert sentinel["calls"] == 2
def test_start_length_change_invalidates_cache():
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
a = _mock_obj("seg_a", Matrix.Identity(4))
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
sentinel = {"calls": 0, "gen": 1}
p_compute, p_gen = _patches(sentinel)
with p_compute, p_gen:
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
cached_compute_bend_preview_polylines(a, b, 0.15, 0.2, 0.3) # start_length changed
assert sentinel["calls"] == 2
def test_end_length_change_invalidates_cache():
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
a = _mock_obj("seg_a", Matrix.Identity(4))
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
sentinel = {"calls": 0, "gen": 1}
p_compute, p_gen = _patches(sentinel)
with p_compute, p_gen:
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
cached_compute_bend_preview_polylines(a, b, 0.1, 0.25, 0.3) # end_length changed
assert sentinel["calls"] == 2
def test_segment_matrix_change_invalidates_cache():
"""Moving either segment changes the bend geometry — the cache must
recompute even when the IFC has not advanced."""
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
a = _mock_obj("seg_a", Matrix.Identity(4))
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
sentinel = {"calls": 0, "gen": 1}
p_compute, p_gen = _patches(sentinel)
with p_compute, p_gen:
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
b.matrix_world = Matrix.Translation((2, 0, 0))
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
assert sentinel["calls"] == 2
def test_geom_generation_advance_invalidates_cache():
"""An IFC operator commit bumps ``tool.Parametric.get_geom_generation``;
the cache must recompute on the next call to pick up downstream geometry
changes that don't surface in the object's matrix_world."""
from bonsai import tool
from bonsai.bim.module.model import mep
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
a = _mock_obj("seg_a", Matrix.Identity(4))
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
sentinel = {"calls": 0}
def counting_compute(*args, **kwargs):
sentinel["calls"] += 1
return {"valid": True, "leg_a": None, "leg_b": None, "arc": []}
gen_state = {"gen": 1}
with patch.object(mep, "compute_bend_preview_polylines", side_effect=counting_compute):
with patch.object(tool.Parametric, "get_geom_generation", side_effect=lambda: gen_state["gen"]):
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
gen_state["gen"] = 2
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
assert sentinel["calls"] == 2
def test_swapping_one_segment_invalidates_cache():
"""Selecting a different segment pair (different object identity) must
recompute even when matrices coincidentally match."""
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
a = _mock_obj("seg_a", Matrix.Identity(4))
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
c = _mock_obj("seg_c", Matrix.Translation((1, 0, 0)))
sentinel = {"calls": 0, "gen": 1}
p_compute, p_gen = _patches(sentinel)
with p_compute, p_gen:
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
cached_compute_bend_preview_polylines(a, c, 0.1, 0.2, 0.3)
assert sentinel["calls"] == 2
@@ -0,0 +1,202 @@
# 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.
"""Pure-math tests for the bend tessellation helpers (FIXME #8106).
Pins the geometry contracts the hand-meshed bend body relies on while the
upstream IfcSweptDiskSolid round-trip is broken:
- profile cross-section sampling for circle / rectangle / unsupported
- parallel-transport framing along the centerline (the contract that
eliminates the twist a fixed world-reference basis produces)
- ``initial_basis`` override that aligns the cross-section with the
source segment's local +X / +Y axes (the asymmetric-rectangle fix)"""
from math import cos, pi, sin
from unittest.mock import Mock
import bpy
import pytest
from mathutils import Vector
pytestmark = pytest.mark.model
# ---------------------------------------------------------------------------
# _bend_profile_cross_section — IFC profile → 2D sample points
# ---------------------------------------------------------------------------
def test_profile_cross_section_circle_returns_evenly_spaced_ring():
"""Circle profiles sample 16 points by default, equally spaced around
the radius. First vert sits at ``(radius, 0)`` so the mesh's local
angular zero aligns with the sweep basis ``right`` axis."""
from bonsai.bim.module.model.mep import _bend_profile_cross_section
profile = Mock()
profile.Radius = 0.1
profile.is_a = lambda c: c == "IfcCircleProfileDef"
pts = _bend_profile_cross_section(profile)
assert pts is not None
assert len(pts) == 16
assert pts[0] == pytest.approx((0.1, 0.0))
# All points lie on the circle.
for x, y in pts:
assert (x * x + y * y) == pytest.approx(0.1 * 0.1, abs=1e-9)
def test_profile_cross_section_circle_respects_n_circle_parameter():
"""The sample count is configurable; verify a non-default value
flows through to the result length."""
from bonsai.bim.module.model.mep import _bend_profile_cross_section
profile = Mock()
profile.Radius = 0.05
profile.is_a = lambda c: c == "IfcCircleProfileDef"
pts = _bend_profile_cross_section(profile, n_circle=8)
assert len(pts) == 8
def test_profile_cross_section_rectangle_returns_four_corners():
"""Rectangle profiles return exactly four corners, in the canonical
``[(-X/2,-Y/2), (X/2,-Y/2), (X/2,Y/2), (-X/2,Y/2)]`` winding."""
from bonsai.bim.module.model.mep import _bend_profile_cross_section
profile = Mock()
profile.XDim = 0.4
profile.YDim = 0.2
profile.is_a = lambda c: c == "IfcRectangleProfileDef"
pts = _bend_profile_cross_section(profile)
assert pts == [(-0.2, -0.1), (0.2, -0.1), (0.2, 0.1), (-0.2, 0.1)]
def test_profile_cross_section_unsupported_returns_none():
"""Profiles other than circle / rectangle (e.g.
``IfcArbitraryClosedProfileDef``) return ``None`` so the tessellation
helper skips the rep swap rather than building geometry against the
wrong cross-section."""
from bonsai.bim.module.model.mep import _bend_profile_cross_section
profile = Mock()
profile.is_a = lambda c: c == "IfcArbitraryClosedProfileDef"
assert _bend_profile_cross_section(profile) is None
# ---------------------------------------------------------------------------
# _sweep_profile_along_polyline — vert + face count + parallel transport
# ---------------------------------------------------------------------------
def test_sweep_along_straight_polyline_builds_closed_tube_with_caps():
"""Straight 3-ring centerline + 4-vert profile yields 12 ring verts,
3 quads × 4 sides = 12 side quads, plus two end-cap triangles per end
(4-vert profile fans into 2 triangles)."""
from bonsai.bim.module.model.mep import _sweep_profile_along_polyline
centerline = [Vector((0.0, 0.0, 0.0)), Vector((0.0, 0.0, 1.0)), Vector((0.0, 0.0, 2.0))]
profile_2d = [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
verts, faces = _sweep_profile_along_polyline(centerline, profile_2d)
assert len(verts) == 3 * 4, "3 rings × 4 profile verts"
# 2 ring gaps × 4 quads each = 8 side faces; 2 caps × 2 triangles = 4 cap faces.
quad_count = sum(1 for f in faces if len(f) == 4)
tri_count = sum(1 for f in faces if len(f) == 3)
assert quad_count == 8, "one quad per profile edge per ring gap"
assert tri_count == 4, "fan triangulation gives n_profile - 2 = 2 tris per cap"
def test_sweep_parallel_transports_basis_around_right_angle_corner():
"""L-shaped centerline (turn from +Z to +X). After the corner, the
cross-section's reference direction is rotated 90° from before — the
parallel-transport invariant. Pin via the first verts of the start
and end rings: starts perpendicular to +Z (so in XY), ends
perpendicular to +X (so in YZ)."""
from bonsai.bim.module.model.mep import _sweep_profile_along_polyline
centerline = [
Vector((0.0, 0.0, 0.0)),
Vector((0.0, 0.0, 1.0)),
Vector((1.0, 0.0, 1.0)),
Vector((2.0, 0.0, 1.0)),
]
# Single-vert profile would degenerate; use a 4-vert square so we
# have something to project onto each ring's basis.
profile_2d = [(0.1, 0.0), (0.0, 0.1), (-0.1, 0.0), (0.0, -0.1)]
verts, _ = _sweep_profile_along_polyline(centerline, profile_2d)
# First ring's verts must lie in a plane perpendicular to +Z (the
# tangent at the first ring). Verify each vert has |z-ring_center.z| ≈ 0.
first_ring = verts[0:4]
for v in first_ring:
assert v.z == pytest.approx(0.0, abs=1e-6), f"first-ring vert off the start plane: {v}"
# Last ring's tangent is +X (last centerline segment). Verts should
# lie in a plane perpendicular to +X — i.e. x ≈ 2.0 (the centerline's
# x at the last ring).
last_ring = verts[-4:]
for v in last_ring:
assert v.x == pytest.approx(2.0, abs=1e-6), f"last-ring vert off the end plane: {v}"
def test_sweep_initial_basis_override_aligns_first_ring_with_segment_axes():
"""The asymmetric-rectangle fix: caller supplies the segment's local
+X / +Y axes (in world space) as ``initial_basis``; the helper uses
those as the first ring's basis instead of the world-Z seed. Verify
by checking that the first profile vert lands at ``ring0 + right *
sx + up * sy`` for the provided right / up."""
from bonsai.bim.module.model.mep import _sweep_profile_along_polyline
centerline = [Vector((0.0, 0.0, 0.0)), Vector((0.0, 0.0, 1.0))]
# Profile sample at (0.5, 0) — a single point on the +X profile axis.
profile_2d = [(0.5, 0.0)]
# Initial basis where right = +Y world, up = +X world (rotated 90°
# from the default world-Z seed which would give right ≈ -Y).
initial_basis = (Vector((0.0, 1.0, 0.0)), Vector((1.0, 0.0, 0.0)))
verts, _ = _sweep_profile_along_polyline(centerline, profile_2d, initial_basis=initial_basis)
# First vert = ring0 (0,0,0) + right * 0.5 + up * 0 = (0, 0.5, 0).
assert tuple(verts[0]) == pytest.approx((0.0, 0.5, 0.0), abs=1e-6)
def test_sweep_default_seed_uses_world_z_reference():
"""Without an ``initial_basis``, the helper falls back to a stable
world-Z reference for the first ring. Pin so a future refactor of
the fallback doesn't silently change the orientation for callers
that rely on the default (the bend preview decorator's debug draw
path, for instance)."""
from bonsai.bim.module.model.mep import _sweep_profile_along_polyline
centerline = [Vector((0.0, 0.0, 0.0)), Vector((1.0, 0.0, 0.0))]
profile_2d = [(1.0, 0.0)]
verts, _ = _sweep_profile_along_polyline(centerline, profile_2d)
# First tangent = +X. world-Z up_ref → right = tangent × up_ref =
# (1,0,0) × (0,0,1) = (0,-1,0). up = right × tangent = (0,0,1).
# First vert at right * 1.0 = (0, -1, 0).
assert tuple(verts[0]) == pytest.approx((0.0, -1.0, 0.0), abs=1e-6)
@@ -0,0 +1,227 @@
# 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.
"""Smoke coverage for ``RegenerateDistributionElement`` and
``FitFlowSegments``.
Both operators carry substantial branching that the bend / port test
files don't reach. These tests pin:
- the operator-registration contract (bl_idname / bl_label / bl_options),
- ``FitFlowSegments`` dispatch table 0 / 1 / mixed-class selections
resolve to the documented no-op or operator dispatch without raising,
- ``RegenerateDistributionElement`` runs on a leaf element (no connected
neighbours) without crashing on the recursion entry point.
Deeper geometry-tree behaviour (multi-branch traversal, port-aligned
translation, segment regrowth) is deferred to integration testing
against real IFC fixtures; the smoke tests are explicitly the
oversight-prevention floor, not the full contract."""
from unittest.mock import MagicMock, Mock, patch
import bpy
import pytest
pytestmark = pytest.mark.model
def _segment(ifc_class: str = "IfcFlowSegment"):
"""Stand-in for an IfcFlowSegment / subclass entity.
``is_a("IfcFlowSegment" | <ifc_class>)`` returns True; ``is_a()`` with
no args returns the class name (the IfcOpenShell API exposes both
forms ``FitFlowSegments`` calls ``element.is_a()`` to record the
selection's class for the mixed-class refusal check)."""
def fake_is_a(c=None):
if c is None:
return ifc_class
return c in {"IfcFlowSegment", ifc_class}
e = Mock()
e.is_a = fake_is_a
return e
def _make_op(**fields):
op = Mock()
for k, v in fields.items():
setattr(op, k, v)
op.report = MagicMock()
return op
# ---------------------------------------------------------------------------
# Registration smoke
# ---------------------------------------------------------------------------
def test_regenerate_distribution_element_is_registered():
"""``RegenerateDistributionElement`` is the entry point for the
distribution-tree repropagation. Pin the bl_idname so a typo in the
classes tuple wouldn't silently drop the operator."""
from bonsai.bim.module.model import mep
assert mep.RegenerateDistributionElement.bl_idname == "bim.regenerate_distribution_element"
assert mep.RegenerateDistributionElement.bl_label == "Regenerate Distribution Element"
assert mep.RegenerateDistributionElement.bl_options == {"REGISTER", "UNDO"}
def test_fit_flow_segments_is_registered():
"""``FitFlowSegments`` is the cursor-based "add a fitting from the
current selection" entry point. Pin the registration contract so the
operator stays callable from the workspace tool."""
from bonsai.bim.module.model import mep
assert mep.FitFlowSegments.bl_idname == "bim.fit_flow_segments"
assert mep.FitFlowSegments.bl_label == "Fit Flow Segments"
assert mep.FitFlowSegments.bl_options == {"REGISTER", "UNDO"}
# ---------------------------------------------------------------------------
# FitFlowSegments dispatch table
# ---------------------------------------------------------------------------
def test_fit_flow_segments_with_no_selection_is_noop():
"""Nothing selected → no fitting type resolved → operator returns
without dispatching any ``bim.mep_add_*`` op. The user-facing
contract is "this is a tool you fire with a selection"; the silent
no-op on empty selection is intentional (no popup, no error)."""
from bonsai.bim.module.model import mep
context = MagicMock()
context.selected_objects = []
op = _make_op()
with patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object(
mep.MEPAddBend, "_execute", return_value=None
) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition:
mep.FitFlowSegments._execute(op, context=context)
obstruction.assert_not_called()
bend.assert_not_called()
transition.assert_not_called()
def test_fit_flow_segments_with_single_segment_dispatches_obstruction():
"""Exactly one IfcFlowSegment selected → OBSTRUCTION fitting type,
delegates to ``bim.mep_add_obstruction`` which handles the
cursor-anchored placement.
``bpy.ops`` resolves operator dispatch through Blender's internal id
table, not through Python attribute access, so a Python-level patch
on ``bpy.ops.bim.mep_add_obstruction`` doesn't intercept the call.
Patch the operator's ``_execute`` instead — same effect, exercises
the real dispatch path that the user hits at runtime."""
from bonsai.bim.module.model import mep
segment_obj = MagicMock()
segment_profile = MagicMock()
segment_entity = _segment("IfcPipeSegment")
context = MagicMock()
context.selected_objects = [segment_obj]
op = _make_op()
with patch.object(mep.tool.Ifc, "get_entity", return_value=segment_entity), patch.object(
mep.tool.Model, "get_flow_segment_profile", return_value=segment_profile
), patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object(
mep.MEPAddBend, "_execute", return_value=None
) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition:
mep.FitFlowSegments._execute(op, context=context)
assert obstruction.call_count == 1
bend.assert_not_called()
transition.assert_not_called()
def test_fit_flow_segments_refuses_mixed_pipe_and_duct():
"""Selecting one IfcPipeSegment + one IfcDuctSegment → the operator
bails out before any fitting dispatch. The user-facing path is
"select segments of one kind"; mixing pipe + duct would create an
invalid IFC fitting type."""
from bonsai.bim.module.model import mep
pipe_obj = MagicMock()
duct_obj = MagicMock()
pipe_entity = _segment("IfcPipeSegment")
duct_entity = _segment("IfcDuctSegment")
profile = MagicMock()
context = MagicMock()
context.selected_objects = [pipe_obj, duct_obj]
def fake_get_entity(obj):
return pipe_entity if obj is pipe_obj else duct_entity
op = _make_op()
with patch.object(mep.tool.Ifc, "get_entity", side_effect=fake_get_entity), patch.object(
mep.tool.Model, "get_flow_segment_profile", return_value=profile
), patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object(
mep.MEPAddBend, "_execute", return_value=None
) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition:
mep.FitFlowSegments._execute(op, context=context)
obstruction.assert_not_called()
bend.assert_not_called()
transition.assert_not_called()
# ---------------------------------------------------------------------------
# RegenerateDistributionElement
# ---------------------------------------------------------------------------
def test_regenerate_distribution_element_on_leaf_is_safe():
"""A distribution element with no connected neighbours → the inner
queue stays empty the operator returns cleanly without entering
the per-branch processing path.
This pins the safety floor: the recursion entry point should not
crash on a single-element graph, which is the most common shape
when a user fires this operator on an isolated segment."""
from bonsai.bim.module.model import mep
leaf_element = _segment("IfcPipeSegment")
leaf_obj = MagicMock()
context = MagicMock()
context.active_object = leaf_obj
fake_active = MagicMock()
fake_active.is_a = lambda c: False # bpy.context.active_object stub
op = _make_op()
with patch.object(mep.tool.Ifc, "get_entity", return_value=leaf_element), patch(
"ifcopenshell.util.system.get_connected_to", return_value=[]
), patch("ifcopenshell.util.system.get_connected_from", return_value=[]), patch.object(
mep.tool.Ifc, "get", return_value=MagicMock()
), patch(
"ifcopenshell.util.unit.calculate_unit_scale", return_value=1.0
), patch.object(
bpy, "context", new=context
):
mep.RegenerateDistributionElement._execute(op, context=context)
# The contract on a leaf is "nothing to do". No exception, no IFC
# mutation. The bpy.ops dispatch table inside process_branch never
# fires because queue is empty.
@@ -0,0 +1,388 @@
# 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.
"""Behaviour tests for the MEP port operators.
Pins the dispatch contract each operator carries which IFC mutation
runs, which user-error path returns CANCELLED, and which fitting types
are deliberately refused by each entry point. Each test mocks the
``tool.*`` and ``MEPGenerator`` boundaries so no IFC fixture is needed."""
from unittest.mock import MagicMock, Mock, patch
import bpy
import pytest
pytestmark = pytest.mark.model
def _segment(predefined_type=None):
"""Stand-in IFC entity that reports ``is_a("IfcFlowSegment")`` True."""
e = Mock()
e.is_a = lambda c: c == "IfcFlowSegment"
e.PredefinedType = predefined_type
return e
def _fitting(predefined_type=None):
"""Stand-in IFC fitting entity with an arbitrary ``PredefinedType``."""
e = Mock()
e.is_a = lambda c: c in ("IfcFlowFitting", "IfcDistributionFlowElement")
e.PredefinedType = predefined_type
return e
def _make_op(_cls, **fields):
"""Return a Mock standing in for an Operator ``self``. Subclassing a
``bpy.types.Operator`` outside Blender's registration machinery raises
a ``bpy_struct.__new__`` error, so each test calls the operator
method as an unbound function with this Mock as the first argument."""
op = Mock()
for k, v in fields.items():
setattr(op, k, v)
op.report = MagicMock()
return op
# ---------------------------------------------------------------------------
# MEPUnjoinAtPort
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"port_state, fitting_predefined_type, expected_result, expects_delete",
[
pytest.param("JOINED", "JUNCTION", {"FINISHED"}, True, id="joined_junction_deletes"),
pytest.param("JOINED", "OBSTRUCTION", {"CANCELLED"}, False, id="joined_obstruction_refused"),
pytest.param("FREE", None, {"CANCELLED"}, False, id="free_port_cancels"),
],
)
def test_unjoin_at_port_dispatch_table(port_state, fitting_predefined_type, expected_result, expects_delete):
"""``MEPUnjoinAtPort`` dispatch contract: result and delete-side-effect
by ``(port_state, fitting type)``.
- ``JOINED + JUNCTION`` (or any non-OBSTRUCTION fitting): happy path,
the bridging fitting is deleted via the standard delete entry point.
- ``JOINED + OBSTRUCTION``: deliberately refused obstructions go
through ``bim.mep_add_obstruction`` (mode=REMOVE) so the segment
extends to absorb the freed length; using delete here would leave
a visible gap.
- ``FREE``: nothing to do no bridging fitting exists. The operator
reports a user-facing error and CANCELS rather than no-op silently."""
from bonsai.bim.module.model import mep
segment = _segment()
fitting = _fitting(predefined_type=fitting_predefined_type) if fitting_predefined_type else None
fitting_obj = Mock()
op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END")
ifc_file = MagicMock()
ifc_file.by_id.return_value = segment
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object(
mep.tool.Ifc, "get_object", return_value=fitting_obj
), patch.object(mep, "port_connection_state", return_value=port_state), patch.object(
mep, "get_connected_element_at_segment_port", return_value=fitting
), patch.object(
mep.tool.Geometry, "delete_ifc_object"
) as delete:
result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock())
assert result == expected_result
if expects_delete:
delete.assert_called_once_with(fitting_obj)
else:
delete.assert_not_called()
op.report.assert_called()
def test_unjoin_at_port_cancels_when_active_is_not_segment():
"""The operator only operates on flow segments; non-segment active
objects must fail loud rather than mutate something unexpected."""
from bonsai.bim.module.model import mep
fitting = _fitting() # IfcFlowFitting, not IfcFlowSegment
op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END")
ifc_file = MagicMock()
ifc_file.by_id.return_value = fitting
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file):
result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock())
assert result == {"CANCELLED"}
op.report.assert_called()
# ---------------------------------------------------------------------------
# MEPRemoveTerminalFitting
# ---------------------------------------------------------------------------
def test_remove_terminal_dispatches_obstruction_via_remove_obstruction():
"""OBSTRUCTION fittings extend the segment to absorb the freed length;
the operator routes through ``MEPGenerator().remove_obstruction``
rather than the plain delete path."""
from bonsai.bim.module.model import mep
segment = _segment()
obstruction = _fitting(predefined_type="OBSTRUCTION")
op = _make_op(mep.MEPRemoveTerminalFitting, segment_id=42, position="END")
ifc_file = MagicMock()
ifc_file.by_id.return_value = segment
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object(
mep, "port_connection_state", return_value="TERMINAL"
), patch.object(mep, "get_connected_element_at_segment_port", return_value=obstruction), patch.object(
mep, "MEPGenerator"
) as gen_cls, patch.object(
mep.tool.Geometry, "delete_ifc_object"
) as delete:
gen_cls.return_value.remove_obstruction.return_value = (obstruction, None)
result = mep.MEPRemoveTerminalFitting._execute(op, context=MagicMock())
assert result == {"FINISHED"}
gen_cls.return_value.remove_obstruction.assert_called_once_with(segment, False)
delete.assert_not_called()
def test_remove_terminal_dispatches_non_obstruction_via_delete():
"""A standard terminal fitting (cap, isolated terminal) goes through
the plain delete path the segment is not resized."""
from bonsai.bim.module.model import mep
segment = _segment()
fitting = _fitting(predefined_type=None)
fitting_obj = Mock()
op = _make_op(mep.MEPRemoveTerminalFitting, segment_id=42, position="END")
ifc_file = MagicMock()
ifc_file.by_id.return_value = segment
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object(
mep.tool.Ifc, "get_object", return_value=fitting_obj
), patch.object(mep, "port_connection_state", return_value="TERMINAL"), patch.object(
mep, "get_connected_element_at_segment_port", return_value=fitting
), patch.object(
mep.tool.Geometry, "delete_ifc_object"
) as delete:
result = mep.MEPRemoveTerminalFitting._execute(op, context=MagicMock())
assert result == {"FINISHED"}
delete.assert_called_once_with(fitting_obj)
def test_remove_terminal_cancels_on_non_terminal_port():
"""Port state must be TERMINAL for this operator; FREE / JOINED are
routed through other operators."""
from bonsai.bim.module.model import mep
segment = _segment()
op = _make_op(mep.MEPRemoveTerminalFitting, segment_id=42, position="END")
ifc_file = MagicMock()
ifc_file.by_id.return_value = segment
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object(
mep, "port_connection_state", return_value="JOINED"
):
result = mep.MEPRemoveTerminalFitting._execute(op, context=MagicMock())
assert result == {"CANCELLED"}
op.report.assert_called()
# ---------------------------------------------------------------------------
# MEPUnjoinPair
# ---------------------------------------------------------------------------
def test_unjoin_pair_deletes_bridging_fitting():
"""Happy path: two selected segments share a single non-OBSTRUCTION
bridging fitting delete it."""
from bonsai.bim.module.model import mep
segment_a = _segment()
segment_b = _segment()
fitting = _fitting(predefined_type="JUNCTION")
fitting_obj = Mock()
op = _make_op(mep.MEPUnjoinPair)
selected = [Mock(), Mock()]
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
), patch.object(mep, "find_fitting_between_segments", return_value=fitting), patch.object(
mep.tool.Ifc, "get_object", return_value=fitting_obj
), patch.object(
mep.tool.Geometry, "delete_ifc_object"
) as delete:
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
assert result == {"FINISHED"}
delete.assert_called_once_with(fitting_obj)
def test_unjoin_pair_refuses_obstruction_bridging():
"""Same defence-in-depth as ``MEPUnjoinAtPort`` — obstructions go
through the dedicated REMOVE path; this operator surfaces the
redirect rather than silently doing the wrong thing."""
from bonsai.bim.module.model import mep
segment_a = _segment()
segment_b = _segment()
obstruction = _fitting(predefined_type="OBSTRUCTION")
op = _make_op(mep.MEPUnjoinPair)
selected = [Mock(), Mock()]
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
), patch.object(mep, "find_fitting_between_segments", return_value=obstruction), patch.object(
mep.tool.Geometry, "delete_ifc_object"
) as delete:
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
assert result == {"CANCELLED"}
delete.assert_not_called()
op.report.assert_called()
def test_unjoin_pair_reports_when_no_bridging_fitting_found():
"""The pair is selected but no single fitting bridges them — the
user is told instead of getting a silent no-op."""
from bonsai.bim.module.model import mep
segment_a = _segment()
segment_b = _segment()
op = _make_op(mep.MEPUnjoinPair)
selected = [Mock(), Mock()]
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
), patch.object(mep, "find_fitting_between_segments", return_value=None), patch.object(
mep.tool.Geometry, "delete_ifc_object"
) as delete:
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
assert result == {"CANCELLED"}
delete.assert_not_called()
op.report.assert_called()
def test_unjoin_pair_cancels_when_selection_is_not_two_segments():
"""The poll filters the gizmo, but a programmatic invocation could
still hand the operator an invalid selection. The execute path
independently verifies both inputs are IfcFlowSegment."""
from bonsai.bim.module.model import mep
not_a_segment = _fitting() # IfcFlowFitting, not IfcFlowSegment
op = _make_op(mep.MEPUnjoinPair)
selected = [Mock(), Mock()]
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
mep.tool.Ifc, "get_entity", side_effect=[not_a_segment, not_a_segment]
):
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
assert result == {"CANCELLED"}
op.report.assert_called()
# ---------------------------------------------------------------------------
# SelectMEPPathMembers
# ---------------------------------------------------------------------------
def test_select_path_replaces_selection_with_walked_members():
"""Happy path: walker returns a small connected network → every
member gets ``select_set(True)``; the original active object stays
active."""
from bonsai.bim.module.model import mep
active = Mock()
element = Mock()
member_elements = [Mock(), Mock(), Mock()]
member_objs = [Mock(), Mock(), Mock()]
context = MagicMock()
context.active_object = active
context.view_layer.objects.active = None
op = _make_op(mep.SelectMEPPathMembers)
with patch.object(mep.tool.Ifc, "get_entity", return_value=element), patch.object(
mep.tool.System, "walk_connected_mep_elements", return_value=member_elements
), patch.object(mep.tool.Ifc, "get_object", side_effect=member_objs), patch.object(
mep.bpy.ops.object, "select_all"
):
result = mep.SelectMEPPathMembers.execute(op, context)
assert result == {"FINISHED"}
for obj in member_objs:
obj.select_set.assert_called_once_with(True)
def test_select_path_reports_when_walker_returns_empty():
"""An MEP element with no connected neighbours produces an empty
walk; report INFO so the user knows the click registered, return
FINISHED so the operator doesn't surface as an error."""
from bonsai.bim.module.model import mep
active = Mock()
element = Mock()
context = MagicMock()
context.active_object = active
op = _make_op(mep.SelectMEPPathMembers)
with patch.object(mep.tool.Ifc, "get_entity", return_value=element), patch.object(
mep.tool.System, "walk_connected_mep_elements", return_value=[]
):
result = mep.SelectMEPPathMembers.execute(op, context)
assert result == {"FINISHED"}
op.report.assert_called()
def test_select_path_handles_walker_exception():
"""The walker can raise on malformed port graphs; the operator must
catch and surface as ERROR rather than crashing the operator harness."""
from bonsai.bim.module.model import mep
active = Mock()
element = Mock()
context = MagicMock()
context.active_object = active
op = _make_op(mep.SelectMEPPathMembers)
with patch.object(mep.tool.Ifc, "get_entity", return_value=element), patch.object(
mep.tool.System, "walk_connected_mep_elements", side_effect=RuntimeError("malformed port graph")
):
result = mep.SelectMEPPathMembers.execute(op, context)
assert result == {"CANCELLED"}
op.report.assert_called()
@@ -0,0 +1,533 @@
# 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 pipe/duct segment parametric-edit scaffolding.
Covers three surfaces that ship together as the first MEP dimension-gizmo
feature:
- ``tool.Parametric.is_pipe_segment`` / ``is_duct_segment`` predicates
(registry contract must be total).
- ``_segment_world_length`` / ``_preview_segment_via_scale`` /
``_restore_segment_scale`` pure helpers driving the live preview.
- ``GizmoPipeSegmentEdition`` / ``GizmoDuctSegmentEdition`` class wiring
(bl_idname, operator bindings, dimension_gizmo_props, is_element_type).
Full operator round-trips (enable drag finish IFC commit) need a real
Blender + IFC scene and are deferred to a later integration session."""
from unittest.mock import Mock, patch
import bpy
import ifcopenshell
import pytest
from mathutils import Matrix, Vector
pytestmark = pytest.mark.model
# ---------------------------------------------------------------------------
# Predicates — total over arbitrary IFC entity input
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"ifc_class,is_pipe_expected,is_duct_expected",
[
("IfcPipeSegment", True, False),
("IfcDuctSegment", False, True),
("IfcFlowSegment", False, False), # base class — neither pipe nor duct alone
("IfcPipeFitting", False, False), # fitting, not a segment
("IfcDuctFitting", False, False),
("IfcWall", False, False),
("IfcAnnotation", False, False), # bare schema element with no MEP semantics
],
)
def test_is_pipe_or_duct_segment_predicate_truth_table(ifc_class, is_pipe_expected, is_duct_expected):
"""The two predicates must classify every IFC class correctly AND
return False (not raise) on classes that have nothing to do with MEP.
Pinned alongside the registry-wide predicate-totality test so a
regression in either direction surfaces in this file too."""
from bonsai import tool
probe = ifcopenshell.file(schema="IFC4").create_entity(ifc_class)
assert tool.Parametric.is_pipe_segment(probe) is is_pipe_expected
assert tool.Parametric.is_duct_segment(probe) is is_duct_expected
# ---------------------------------------------------------------------------
# _segment_world_length — pure geometric helper
# ---------------------------------------------------------------------------
def test_segment_world_length_returns_axis_magnitude():
"""The length read here drives both the dimension gizmo's display and
the snap_length captured on enable. Pin the math on a known axis."""
from bonsai.bim.module.model.mep import _segment_world_length
fake_obj = object()
axis = (Vector((1.0, 2.0, 3.0)), Vector((1.0, 2.0, 5.5)))
with patch("bonsai.tool.Model.get_flow_segment_axis", return_value=axis):
assert _segment_world_length(fake_obj) == pytest.approx(2.5)
# ---------------------------------------------------------------------------
# Preview helpers — obj.scale.z manipulation
# ---------------------------------------------------------------------------
class _FakeObj:
"""Stand-in for bpy.types.Object exposing only ``scale`` — enough for
the preview helpers, which never touch IFC."""
def __init__(self):
self.scale = Vector((1.0, 1.0, 1.0))
def test_preview_segment_via_scale_sets_z_to_ratio():
"""The visible-stretch ratio composes ``props_length / mesh_local_length`` where
``mesh_local_length = snap_length / snap_object_scale_z``."""
from bonsai.bim.module.model.mep import _preview_segment_via_scale
obj = _FakeObj()
_preview_segment_via_scale(obj, props_length=2.0, snap_length=1.0, snap_object_scale_z=1.0)
assert obj.scale.z == pytest.approx(2.0)
_preview_segment_via_scale(obj, props_length=0.5, snap_length=1.0, snap_object_scale_z=1.0)
assert obj.scale.z == pytest.approx(0.5)
def test_preview_segment_via_scale_floors_at_min_value():
"""``props.length`` is clamped at FloatProperty min=0.01; the helper still
defends against zero / negative so a runaway value can't invert the segment."""
from bonsai.bim.module.model.mep import _preview_segment_via_scale
obj = _FakeObj()
_preview_segment_via_scale(obj, props_length=0.0, snap_length=1.0, snap_object_scale_z=1.0)
assert obj.scale.z == pytest.approx(0.01)
def test_preview_segment_via_scale_skips_when_snap_is_zero():
"""A zero ``snap_length`` would divide by zero — helper skips silently."""
from bonsai.bim.module.model.mep import _preview_segment_via_scale
obj = _FakeObj()
obj.scale.z = 3.0
_preview_segment_via_scale(obj, props_length=1.0, snap_length=0.0, snap_object_scale_z=1.0)
# No change.
assert obj.scale.z == pytest.approx(3.0)
def test_restore_segment_scale_resets_z_to_target():
"""Pin that the reset only touches Z; X/Y stay whatever the user set."""
from bonsai.bim.module.model.mep import _restore_segment_scale_to
obj = _FakeObj()
obj.scale = Vector((0.5, 0.7, 4.2))
_restore_segment_scale_to(obj, 1.0)
assert obj.scale.x == pytest.approx(0.5)
assert obj.scale.y == pytest.approx(0.7)
assert obj.scale.z == pytest.approx(1.0)
# ---------------------------------------------------------------------------
# Gizmo group class wiring — registration and config
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"gizmo_cls_name,bl_idname,is_element_predicate",
[
("GizmoPipeSegmentEdition", "OBJECT_GGT_bim_pipe_segment_edition", "is_pipe_segment"),
("GizmoDuctSegmentEdition", "OBJECT_GGT_bim_duct_segment_edition", "is_duct_segment"),
],
)
def test_gizmo_group_class_wiring(gizmo_cls_name, bl_idname, is_element_predicate):
"""Each gizmo group must:
- declare the expected ``bl_idname`` (so it actually registers under that name);
- have the matching ``is_element_type`` delegate to the right predicate
(so it polls in for the right IFC class).
"""
from bonsai import tool
from bonsai.bim.module.model import mep
cls = getattr(mep, gizmo_cls_name)
assert cls.bl_idname == bl_idname
predicate = getattr(tool.Parametric, is_element_predicate)
fake_element = Mock()
fake_element.is_a.return_value = True
with patch.object(tool.Parametric, is_element_predicate, side_effect=predicate) as p, patch.object(
tool.System, "has_parametric_body", return_value=True
):
cls.is_element_type(fake_element)
assert p.called, f"{gizmo_cls_name}.is_element_type did not delegate to Parametric.{is_element_predicate}"
@pytest.mark.parametrize(
"gizmo_cls_name,enable_op,finish_op,cancel_op",
[
(
"GizmoPipeSegmentEdition",
"bim.enable_editing_pipe_segment",
"bim.finish_editing_pipe_segment",
"bim.cancel_editing_pipe_segment",
),
(
"GizmoDuctSegmentEdition",
"bim.enable_editing_duct_segment",
"bim.finish_editing_duct_segment",
"bim.cancel_editing_duct_segment",
),
],
)
def test_gizmo_lifecycle_bindings_reference_registered_operators(gizmo_cls_name, enable_op, finish_op, cancel_op):
"""Catches the silent-regression where the gizmo's enable/finish/cancel
string drifts away from the actual operator ``bl_idname``."""
from bonsai.bim.module.model import mep
cls = getattr(mep, gizmo_cls_name)
assert cls.enable_editing_operator == enable_op
assert cls.finish_editing_operator == finish_op
assert cls.cancel_editing_operator == cancel_op
# And the operators are actually registered.
for op in (enable_op, finish_op, cancel_op):
namespace, _, verb = op.partition(".")
assert hasattr(
getattr(bpy.ops, namespace), verb
), f"{gizmo_cls_name} references {op!r} which is not a registered operator"
@pytest.mark.parametrize("gizmo_cls_name", ["GizmoPipeSegmentEdition", "GizmoDuctSegmentEdition"])
def test_gizmo_dimension_gizmo_props_has_single_length_entry(gizmo_cls_name):
"""Phase 1 ships a single dimension (segment length). Pin the shape so
a Phase 2 addition (diameter / width / height) is an intentional
expansion rather than a drive-by edit."""
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.module.model import mep
cls = getattr(mep, gizmo_cls_name)
assert len(cls.dimension_gizmo_props) == 1
config = cls.dimension_gizmo_props[0]
assert isinstance(config, DimensionGizmoConfig)
assert config.attr_name == "length"
assert tuple(config.axis) == (0, 0, 1)
assert config.min_value == pytest.approx(0.01)
@pytest.mark.parametrize("gizmo_cls_name", ["GizmoPipeSegmentEdition", "GizmoDuctSegmentEdition"])
def test_length_dimension_has_matrix_position_so_rotation_is_respected(gizmo_cls_name):
"""Regression guard for "edit-mode length dimension doesn't take local
object rotation". Without ``matrix_position`` set, ``update_dimension_gizmos``
falls back to ``base_matrix = Identity`` and the gizmo's intrinsic +X
visual line is never rotated to the configured ``axis`` the dimension
renders perpendicular to the segment on a rotated pipe. Setting
``matrix_position`` (even to ``(0, 0, 0)``) routes through
``compose_gizmo_matrix`` which applies ``get_axis_rotation_matrix(axis)``
so the line aligns with the segment's local +Z (extrusion axis) in
world space."""
from bonsai.bim.module.model import mep
cls = getattr(mep, gizmo_cls_name)
config = cls.dimension_gizmo_props[0]
assert config.matrix_position is not None, (
f"{gizmo_cls_name} length dimension is missing matrix_position — the gizmo will "
"render along the object's local +X axis instead of the segment's local +Z."
)
# ---------------------------------------------------------------------------
# Extend-to-cursor — operator + element-specific gizmo wiring
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"gizmo_cls_name,extend_operator",
[
("GizmoPipeSegmentEdition", "bim.extend_pipe_segment_to_cursor"),
("GizmoDuctSegmentEdition", "bim.extend_duct_segment_to_cursor"),
],
)
def test_extend_operator_binding(gizmo_cls_name, extend_operator):
"""Each segment gizmo group must reference the matching extend operator
AND that operator must actually be registered. Catches the silent
regression where someone renames the extend bl_idname without updating
the gizmo group's ``_extend_operator`` class attribute."""
from bonsai.bim.module.model import mep
cls = getattr(mep, gizmo_cls_name)
assert cls._extend_operator == extend_operator
namespace, _, verb = extend_operator.partition(".")
assert hasattr(
getattr(bpy.ops, namespace), verb
), f"{gizmo_cls_name} references {extend_operator!r} which is not a registered operator"
@pytest.mark.parametrize("feature_attr", ["pipe_segment", "duct_segment"])
def test_gizmo_preferences_field_exists(feature_attr):
"""``GizmoPreferences`` must carry pipe_segment + duct_segment PointerProperties
so ``get_gizmo_prefs()`` on the MEP gizmo groups resolves to a real PropertyGroup."""
import bonsai.bim.ui as ui
assert feature_attr in ui.GizmoPreferences.__annotations__, (
f"GizmoPreferences is missing the {feature_attr} PointerProperty; "
f"MEP gizmo groups' get_gizmo_prefs() would raise AttributeError."
)
# ---------------------------------------------------------------------------
# Lifecycle operators are registered
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"op",
[
"bim.enable_editing_pipe_segment",
"bim.finish_editing_pipe_segment",
"bim.cancel_editing_pipe_segment",
"bim.extend_pipe_segment_to_cursor",
"bim.enable_editing_duct_segment",
"bim.finish_editing_duct_segment",
"bim.cancel_editing_duct_segment",
"bim.extend_duct_segment_to_cursor",
],
)
def test_segment_operators_are_registered(op):
"""Smoke test mirroring ``test_parametric_registry``'s
``test_every_entry_has_enable_op_registered`` for the operators added
in this round. Catches the silent regression where the classes tuple
in ``__init__.py`` drops one of them."""
namespace, _, verb = op.partition(".")
assert hasattr(getattr(bpy.ops, namespace), verb), f"Operator {op!r} is not registered."
# ---------------------------------------------------------------------------
# MEPSegmentExtendPreviewDecorator._compute_extend_preview_line — pure helper
# ---------------------------------------------------------------------------
def test_extend_preview_line_returns_none_for_degenerate_segment():
"""A zero-length segment has no endpoint to draw from. Pin so a future
refactor doesn't divide-by-zero or render a phantom line at the
object origin."""
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
matrix_world=Matrix.Identity(4),
cursor_world=Vector((0.0, 0.0, 1.0)),
current_length=0.0,
)
assert result is None
def test_extend_preview_line_returns_none_when_cursor_at_current_end():
"""If the cursor projection matches the current segment length exactly,
the extend operator would be a no-op don't render the line either."""
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
matrix_world=Matrix.Identity(4),
cursor_world=Vector((0.0, 0.0, 1.5)),
current_length=1.5,
)
assert result is None
def test_extend_preview_line_renders_extension_when_cursor_past_end():
"""Happy path: cursor past current end → line runs from current end to
the cursor's projected length. Identity matrix: local-Z maps 1:1 to
world-Z. Pin the endpoints exactly."""
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
matrix_world=Matrix.Identity(4),
cursor_world=Vector((0.0, 0.0, 3.0)),
current_length=1.0,
)
assert result is not None
start, end = result
assert tuple(start) == pytest.approx((0.0, 0.0, 1.0))
assert tuple(end) == pytest.approx((0.0, 0.0, 3.0))
def test_extend_preview_line_renders_trim_when_cursor_inside_segment():
"""Cursor inside the segment → line runs from current end BACK to the
projected (shorter) length."""
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
matrix_world=Matrix.Identity(4),
cursor_world=Vector((0.0, 0.0, 0.4)),
current_length=1.0,
)
assert result is not None
start, end = result
assert tuple(start) == pytest.approx((0.0, 0.0, 1.0))
assert tuple(end) == pytest.approx((0.0, 0.0, 0.4))
def test_extend_preview_line_follows_raw_projection_behind_segment_origin():
"""When the cursor's projected Z is negative (behind segment origin),
the preview line must follow the raw cursor projection the user is
pointing somewhere and expects to see where, even though the operator
would floor the actual commit. Matching the operator's clamp would
hide the line whenever the cursor crossed the segment origin."""
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
matrix_world=Matrix.Identity(4),
cursor_world=Vector((0.0, 0.0, -2.0)),
current_length=1.0,
)
assert result is not None
start, end = result
assert tuple(start) == pytest.approx((0.0, 0.0, 1.0))
assert tuple(end) == pytest.approx((0.0, 0.0, -2.0))
def test_extend_preview_line_respects_object_rotation():
"""A rotated segment (90° around Y) should produce world-space endpoints
rotated accordingly. Pin so a future refactor doesn't drop the
matrix_world multiplication."""
import math
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
rotation = Matrix.Rotation(math.pi / 2, 4, "Y")
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
matrix_world=rotation,
cursor_world=Vector((3.0, 0.0, 0.0)),
current_length=1.0,
)
assert result is not None
start, end = result
# local (0, 0, 1) rotated by 90° around Y → world (1, 0, 0).
assert tuple(start) == pytest.approx((1.0, 0.0, 0.0), abs=1e-6)
# local (0, 0, 3) rotated by 90° around Y → world (3, 0, 0).
assert tuple(end) == pytest.approx((3.0, 0.0, 0.0), abs=1e-6)
# ---------------------------------------------------------------------------
# Lifecycle drift handling — Enable / Finish / Cancel must commit / restore
# matrix_world ↔ IFC ObjectPlacement at the appropriate lifecycle points.
# The AST forward-compat guard pins "a drift hook IS called somewhere"; these
# tests pin "the hook is called in the right branch with the right args."
# ---------------------------------------------------------------------------
def _make_segment_context(length=2.0, snap_length=2.0, scale_z=1.0):
"""Build (context, props, obj, element) fakes for the MEP edit-lifecycle bases.
The bases access ``self.__class__._predicate`` / ``_props_getter`` so
callers must instantiate a concrete test subclass and call
``instance._execute(context)`` rather than passing a Mock as ``self``."""
obj = Mock(name="obj")
obj.scale = Vector((1.0, 1.0, scale_z))
element = Mock(name="element")
props = Mock(name="props")
props.length = length
props.snap_length = snap_length
props.snap_object_scale_z = scale_z
props.mesh_dirty = False
context = Mock(name="context")
context.active_object = obj
return context, props, obj, element
def _concrete_mep_mixin(props):
"""Build a concrete ``_MEPSegmentEditMixin`` subclass that bypasses the
IFC predicate gate and returns the supplied ``props`` from ``_get_props``.
The unified mixin replaced the three-base-class lifecycle pattern; tests now
target the single mixin and override the two ParametricEditMixinBase
hooks instead of class-level ``_predicate`` / ``_props_getter``."""
from bonsai.bim.module.model.mep import _MEPSegmentEditMixin
class _ConcreteMEPMixin(_MEPSegmentEditMixin):
@classmethod
def _is_element_type(cls, element):
return True
@classmethod
def _get_props(cls, obj):
return props
return _ConcreteMEPMixin
def test_enable_pipe_segment_commits_pre_edit_placement_drift():
"""Enable must call ``commit_placement_if_moved(obj, apply_scale=False)``
BEFORE ``_segment_world_length`` captures ``snap_length``. Without the
commit, snap_length is read from a dragged matrix_world while the IFC
ObjectPlacement is stale Finish's set_depth would then write
representation coords relative to the wrong origin."""
context, props, obj, element = _make_segment_context()
cls = _concrete_mep_mixin(props)
with (
patch("bonsai.bim.module.model.mep.tool") as mock_tool,
patch("bonsai.bim.parametric_lifecycle.tool", mock_tool),
patch("bonsai.bim.module.model.mep._segment_world_length", return_value=2.0),
):
mock_tool.Ifc.get_entity.return_value = element
cls()._enable_targets(context)
mock_tool.Geometry.commit_placement_if_moved.assert_called_once_with(obj, apply_scale=False)
def test_finish_pipe_segment_commits_drift_when_no_length_change():
"""Finish without a length change must STILL commit matrix_world drift —
the bug class that motivated this guard. The conditional ``set_depth``
branch covers the length-changed path transitively; the unconditional
``commit_placement_if_moved`` after the if/else closes the silent-drop
path."""
# length == snap_length → no-op session.
context, props, obj, element = _make_segment_context(length=2.0, snap_length=2.0)
cls = _concrete_mep_mixin(props)
with (
patch("bonsai.bim.module.model.mep.tool") as mock_tool,
patch("bonsai.bim.parametric_lifecycle.tool", mock_tool),
patch("bonsai.bim.module.model.mep.DumbProfileJoiner") as mock_joiner,
patch("bonsai.bim.module.model.mep._restore_segment_mesh_if_dirty"),
patch("bonsai.bim.module.model.mep._restore_segment_scale_to"),
):
mock_tool.Ifc.get_entity.return_value = element
cls()._finish_targets(context)
mock_joiner.return_value.set_depth.assert_not_called() # no-length branch
mock_tool.Geometry.commit_placement_if_moved.assert_called_once_with(obj)
def test_cancel_pipe_segment_delegates_to_restore_or_rebaseline():
"""Cancel must call ``tool.Geometry.restore_or_rebaseline_placement`` so
matrix_world reverts in lockstep with the props draft. The helper owns
the is_moved / ObjectPlacement gate."""
context, props, obj, element = _make_segment_context()
cls = _concrete_mep_mixin(props)
with (
patch("bonsai.bim.module.model.mep.tool") as mock_tool,
patch("bonsai.bim.parametric_lifecycle.tool", mock_tool),
patch("bonsai.bim.module.model.mep._restore_segment_mesh_if_dirty"),
):
mock_tool.Ifc.get_entity.return_value = element
cls()._cancel_targets(context)
mock_tool.Geometry.restore_or_rebaseline_placement.assert_called_once_with(obj, element)
@@ -32,12 +32,6 @@ 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 _registry():
from bonsai.bim.module.model.preview_base import PREVIEW_CANCEL_OPS
@@ -139,6 +133,55 @@ class TestActivationCycle:
assert props.is_active is False, f"discard_pending_previews left '{attr}' active"
class TestClearPreviewState:
"""``clear_preview_state`` is the shared cleanup routine every preview
operator calls on commit / cancel. The contract is: ``is_active`` flips
to False, every ``*_id`` IntProperty zeroes, everything else stays."""
def test_clears_is_active_and_id_fields_on_real_property_groups(self):
from bonsai.bim.module.model.preview_base import clear_preview_state
registered = _registered_previews()
if not registered:
pytest.skip("No previews wired in this build — registry-only entries")
for attr, _, props in registered:
# Seed every *_id IntProperty with a non-zero sentinel and flip
# the activity flag so the helper has something to clear.
id_fields = [
name for name, rna in props.bl_rna.properties.items() if name.endswith("_id") and rna.type == "INT"
]
assert id_fields, f"Preview '{attr}' has no *_id IntProperty — registry shape changed"
for name in id_fields:
setattr(props, name, 42)
props.is_active = True
clear_preview_state(props)
assert props.is_active is False, f"Preview '{attr}' is_active not cleared"
for name in id_fields:
assert getattr(props, name) == 0, f"Preview '{attr}' field '{name}' not zeroed"
def test_leaves_non_id_fields_untouched(self):
"""Non-``*_id`` fields (FloatProperty params like ``radius``,
``start_length``) must survive the reset they re-seed on the next
enable, so untouching them here avoids a redundant write."""
from bonsai.bim.module.model.preview_base import clear_preview_state
bend = getattr(_preview_umbrella(), "bend", None)
if bend is None:
pytest.skip("Bend preview not wired in this build")
bend.is_active = True
bend.start_length = 0.42
bend.radius = 0.99
clear_preview_state(bend)
assert bend.is_active is False
assert bend.start_length == pytest.approx(0.42)
assert bend.radius == pytest.approx(0.99)
class TestSaveOnDiscardWired:
"""Pins that the SaveProject operator clears preview state before writing
the IFC file a stuck is_active flag persisted through the save would
@@ -39,12 +39,6 @@ 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):
@@ -0,0 +1,232 @@
# 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.
"""Behaviour contract: every parametric gizmo group hides while a Blender
transform modal (G/R/S and siblings) is dragging ``matrix_world``.
Discovery walks each parametric-edit module rather than naming gizmo groups
adding a new group automatically joins the test. The test exercises the
BEHAVIOUR (poll returns False / draw_prepare early-returns when a transform
modal is active) without pinning the name of the helper used internally."""
import importlib
from unittest.mock import MagicMock, patch
import bpy
import pytest
pytestmark = pytest.mark.model
PARAMETRIC_MODULES = (
"bonsai.bim.module.model.array",
"bonsai.bim.module.model.door",
"bonsai.bim.module.model.host_add_opening_gizmo",
"bonsai.bim.module.model.roof",
"bonsai.bim.module.model.stair",
"bonsai.bim.module.model.wall",
"bonsai.bim.module.model.window",
)
def _discover_parametric_gizmo_groups():
"""Walk each parametric-edit module for ``bpy.types.GizmoGroup`` subclasses
defined locally. Preview-owning gizmo groups (bl_idname contains 'preview')
are excluded from the poll-level test: their poll legitimately fires while
the preview is active, and the transform-modal hide for them lives in
``draw_prepare`` via ``BillboardingGizmoGroupMixin``."""
out = []
for mod_path in PARAMETRIC_MODULES:
mod = importlib.import_module(mod_path)
for name in dir(mod):
obj = getattr(mod, name)
if not isinstance(obj, type):
continue
if not issubclass(obj, bpy.types.GizmoGroup) or obj is bpy.types.GizmoGroup:
continue
if obj.__module__ != mod.__name__:
continue
bl_idname = (getattr(obj, "bl_idname", "") or "").lower()
if "preview" in bl_idname:
continue
out.append((f"{mod_path.rsplit('.', 1)[-1]}.{name}", obj))
return out
class TestDiscoveryFindsParametricGizmoGroups:
def test_at_least_one_group_per_canonical_module(self):
"""If discovery returns zero groups for a module the walk has drifted —
likely the gizmo group moved to a different file. Surface the drift
with the module name in the diagnostic."""
per_module: dict[str, int] = {}
for fq_name, _cls in _discover_parametric_gizmo_groups():
mod_short = fq_name.split(".", 1)[0]
per_module[mod_short] = per_module.get(mod_short, 0) + 1
empty = [m.rsplit(".", 1)[-1] for m in PARAMETRIC_MODULES if per_module.get(m.rsplit(".", 1)[-1], 0) == 0]
assert not empty, (
f"Parametric modules with zero GizmoGroup subclasses (discovery walk drifted?): {empty}. "
"Update PARAMETRIC_MODULES or check whether the gizmo groups moved to a new file."
)
class TestParametricGizmoPollsHideDuringTransformModal:
"""For each discovered parametric gizmo group, mock the transform-modal
detector to True and call ``poll(bpy.context)``. Every poll must return
False any True is a poll that wouldn't hide during a G/R/S drag, leaving
the gizmos jittering against the dragging matrix."""
def test_every_group_poll_returns_false_when_transform_modal_active(self):
groups = _discover_parametric_gizmo_groups()
offenders = []
with patch(
"bonsai.bim.module.drawing.gizmos._is_transform_modal_active",
return_value=True,
):
for name, cls in groups:
poll = getattr(cls, "poll", None)
if poll is None:
continue
try:
result = poll(bpy.context)
except Exception as exc: # noqa: BLE001
offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}"))
continue
if result:
offenders.append((name, "poll returned True with transform modal active"))
assert not offenders, (
"Parametric gizmo polls that don't gate on the transform-modal detector "
"(or raise instead of returning False): "
+ ", ".join(f"{n}{why}" for n, why in offenders)
+ ". Hide parametric gizmos while Blender's transform modal is dragging "
"matrix_world so they don't jitter off-cursor. The conventional path is to "
"early-return from poll when _is_transform_modal_active(context) is True."
)
class TestBaseParametricPollHidesDuringTransformModal:
"""Cross-feature base poll: door / window / stair / roof / railing / array
all inherit ``BaseParametricGizmoGroup``. Its poll must short-circuit on
the transform-modal detector so every inheriting feature behaves uniformly."""
def test_base_parametric_poll_returns_false(self):
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
with patch("bonsai.tool.Blender.get_active_object", return_value=object()):
with patch("bonsai.tool.Blender.are_viewport_gizmos_enabled", return_value=True):
with patch(
"bonsai.bim.module.model.preview_base.any_preview_active",
return_value=False,
):
with patch(
"bonsai.bim.module.drawing.gizmos._is_transform_modal_active",
return_value=True,
):
assert BaseParametricGizmoGroup.poll(bpy.context) is False
class TestBaseIconActionPollHidesDuringTransformModal:
"""``BaseIconActionGroup`` is the parent of the simple icon-row gizmo
groups; its poll mirrors the base parametric gate for forward-compat
symmetry. Pinning here ensures a future icon-row group authored via this
base inherits the transform-modal hide for free."""
def test_base_icon_action_poll_returns_false(self):
from bonsai.bim.module.drawing.gizmos import BaseIconActionGroup
with patch("bonsai.tool.Blender.get_active_object", return_value=object()):
with patch("bonsai.tool.Blender.are_viewport_gizmos_enabled", return_value=True):
with patch(
"bonsai.bim.module.drawing.gizmos._is_transform_modal_active",
return_value=True,
):
assert BaseIconActionGroup.poll(bpy.context) is False
class TestHelperReadsWindowModalOperators:
"""Pin the public contract of ``_is_transform_modal_active``: it reads
``context.window.modal_operators`` (Blender 4.2+) and returns True iff any
operator's ``bl_idname`` starts with ``TRANSFORM_OT_``. The check itself
is dependency-free and worth pinning so a future refactor that swaps the
detection mechanism either keeps the contract or updates the test."""
def test_returns_true_for_transform_translate(self):
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
fake_op = MagicMock()
fake_op.bl_idname = "TRANSFORM_OT_translate"
fake_context = MagicMock()
fake_context.window.modal_operators = [fake_op]
assert _is_transform_modal_active(fake_context) is True
def test_returns_true_for_transform_rotate_and_resize(self):
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
for idname in ("TRANSFORM_OT_rotate", "TRANSFORM_OT_resize", "TRANSFORM_OT_shear"):
fake_op = MagicMock()
fake_op.bl_idname = idname
fake_context = MagicMock()
fake_context.window.modal_operators = [fake_op]
assert _is_transform_modal_active(fake_context) is True, f"missed {idname}"
def test_returns_false_for_non_transform_modal(self):
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
fake_op = MagicMock()
fake_op.bl_idname = "VIEW3D_OT_select_box"
fake_context = MagicMock()
fake_context.window.modal_operators = [fake_op]
assert _is_transform_modal_active(fake_context) is False
def test_returns_true_for_bonsai_move_macro(self):
"""Bonsai overrides the G key with a macro that wraps
``TRANSFORM_OT_translate``. While the macro is the outer modal entry
the inner transform does not surface in ``modal_operators``; matching
the macro idname covers the gap. Note Blender exposes ``bl_idname``
at runtime in the ``BIM_OT_<verb_noun>`` form, not the ``bim.<verb_noun>``
form used in the class declaration verified via real-Blender modal
introspection during grab."""
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
macros = (
"BIM_OT_override_move_macro",
"BIM_OT_override_object_duplicate_move_macro",
"BIM_OT_override_object_duplicate_move_linked_macro",
"BIM_OT_object_duplicate_move_linked_aggregate_macro",
)
for idname in macros:
fake_op = MagicMock()
fake_op.bl_idname = idname
fake_context = MagicMock()
fake_context.window.modal_operators = [fake_op]
assert _is_transform_modal_active(fake_context) is True, f"missed {idname}"
def test_returns_false_for_empty_modal_stack(self):
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
fake_context = MagicMock()
fake_context.window.modal_operators = []
assert _is_transform_modal_active(fake_context) is False
def test_returns_false_when_window_is_none(self):
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
fake_context = MagicMock()
fake_context.window = None
assert _is_transform_modal_active(fake_context) is False
@@ -0,0 +1,133 @@
# 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.
"""Forward-compat AST guard: every multi-object wall topology GizmoGroup
filters Bonsai array children via ``_wall_topology_gizmo_poll_gate`` or
the central ``any_selected_is_array_child`` predicate.
Allow-list (gizmos intentionally outside the rule):
- ``GizmoWallEdition`` single-object parametric edit gizmo. Its base
parametric poll already filters array children.
- ``GizmoWallFilletPreview`` the preview-owner whose poll must fire
WHILE its own preview is active; routing it through the topology gate
would self-block it.
Host-opening gizmos live in a sibling module and intentionally use the
loose base ``_wall_gizmo_poll_gate``: openings track with the child
through ``regenerate_array`` and stay authorable on children.
A new wall ``GizmoGroup`` added without the filter (and not added to the
allow-list with an explanation) fails this test."""
import ast
import inspect
import bpy
import pytest
pytestmark = pytest.mark.model
# Wall gizmo groups intentionally outside the rule. Add a new entry only
# with the in-code reasoning above.
_ALLOWLIST = frozenset({"GizmoWallEdition", "GizmoWallFilletPreview"})
_REQUIRED_CALLEES = frozenset({"_wall_topology_gizmo_poll_gate", "any_selected_is_array_child"})
def _wall_module_source():
from bonsai.bim.module.model import wall as wall_mod
return inspect.getsource(wall_mod), wall_mod.__name__
def _wall_gizmo_group_classes():
"""All ``bpy.types.GizmoGroup`` subclasses defined locally in wall.py."""
from bonsai.bim.module.model import wall as wall_mod
out = []
for name in dir(wall_mod):
obj = getattr(wall_mod, name)
if not isinstance(obj, type):
continue
if not issubclass(obj, bpy.types.GizmoGroup) or obj is bpy.types.GizmoGroup:
continue
if obj.__module__ != wall_mod.__name__:
continue
out.append((name, obj))
return out
def _poll_function_calls(class_node):
"""Names of every function called inside ``class_node``'s ``poll`` body.
``ast.Call.func`` may be an ``ast.Name`` (bare call) or an ``ast.Attribute``
(dotted call). For the dotted case the leaf attribute is returned so
``tool.Blender.Modifier.any_selected_is_array_child(...)`` registers as
``any_selected_is_array_child``."""
poll_node = next(
(node for node in class_node.body if isinstance(node, ast.FunctionDef) and node.name == "poll"),
None,
)
if poll_node is None:
return None
names = set()
for sub in ast.walk(poll_node):
if not isinstance(sub, ast.Call):
continue
func = sub.func
if isinstance(func, ast.Name):
names.add(func.id)
elif isinstance(func, ast.Attribute):
names.add(func.attr)
return names
def test_every_wall_gizmo_group_filters_array_children_or_is_allowlisted():
"""For every locally-defined wall ``GizmoGroup`` not in the allow-list,
its ``poll`` must call ``_wall_gizmo_poll_gate`` or the central
``any_selected_is_array_child`` predicate. A failure surfaces the list
of offending classes the fix is a single early-return through the
central helper, mirroring the existing peers."""
source, _module_name = _wall_module_source()
tree = ast.parse(source)
class_nodes = {node.name: node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)}
offenders = []
for class_name, _cls in _wall_gizmo_group_classes():
if class_name in _ALLOWLIST:
continue
node = class_nodes.get(class_name)
if node is None:
offenders.append((class_name, "AST parse did not find the class"))
continue
calls = _poll_function_calls(node)
if calls is None:
offenders.append((class_name, "no poll() defined; expected the array-child filter call"))
continue
if not (calls & _REQUIRED_CALLEES):
offenders.append((class_name, f"poll() does not call any of {sorted(_REQUIRED_CALLEES)}"))
assert not offenders, (
"Wall GizmoGroup classes missing the array-child filter: "
+ ", ".join(f"{n}{why}" for n, why in offenders)
+ ". Route the poll through `_wall_topology_gizmo_poll_gate(context)` "
"so the central `any_selected_is_array_child` filter applies, or add "
"the class to the file's allow-list with a documented reason."
)
@@ -28,7 +28,6 @@ joins the test. The test then asserts the BEHAVIOUR (poll returns False when
helper function the gizmo uses internally to enforce it."""
import inspect
import types
from unittest.mock import patch
import bpy
@@ -37,12 +36,6 @@ 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 _wall_gizmo_groups():
"""Walk the wall module for ``bpy.types.GizmoGroup`` subclasses defined
locally (skip imported references). Returns a list of (name, cls) tuples.
@@ -25,7 +25,6 @@ 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
@@ -35,10 +34,15 @@ 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)")
class _Obj:
"""Hashable, name-bearing stand-in for a ``bpy.types.Object`` selection
slot. ``SimpleNamespace`` defines ``__eq__`` (and so ``__hash__ = None``)
which makes it unusable inside the ``set()`` that
``get_selected_objects()`` returns; a plain class falls back to
identity-based hashing and works inside both ``set`` and ``list``."""
def __init__(self, name: str) -> None:
self.name = name
def _make_context(active, selected):
@@ -76,19 +80,23 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage
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),
# The array-child filter is pinned by its own test file; stub it here
# so these poll tests stay focused on the count / layer-usage gates
# and don't have to scaffold the memoization cache key.
patch.object(tool.Blender.Modifier, "any_selected_is_array_child", return_value=False),
]
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()
slab_obj = _Obj("slab")
wall_obj = _Obj("wall")
active = slab_obj if active_is_in_selected else _Obj("active_extra")
if len_override is None:
selected = [slab_obj, wall_obj]
else:
selected = [object() for _ in range(len_override)]
selected = [_Obj(f"obj_{i}") for i in range(len_override)]
if active_is_in_selected and selected:
active = selected[0]
@@ -302,3 +310,92 @@ def test_iter_path_connections_walks_both_inverses_in_order():
rel_from = _make_path_rel(relating=p2, related=self_elem, relating_ct="ATEND", related_ct="ATEND")
elem = SimpleNamespace(ConnectedTo=[rel_to], ConnectedFrom=[rel_from])
assert _run_iter_path_connections(elem) == [(p1, "ATSTART", "ATSTART"), (p2, "ATEND", "ATEND")]
# ----------------------------------------------------------------------------
# _perpendicular_wall_params — clamping + side detection for the
# "add perpendicular wall at cursor" gizmo and its operator.
# ----------------------------------------------------------------------------
#
# Pure scalar math. The dead-zone is ``CURSOR_STACK_OFFSET`` — inside it the
# on-axis split / extend-X icons own the click and this helper returns None.
def _wall_consts():
from bonsai.bim.module.model.wall import GizmoWallEdition
return GizmoWallEdition.CURSOR_STACK_OFFSET
def _run_perp_params(cursor_x, cursor_y, anchor_x=0.0, length=5.0):
from bonsai.bim.module.model.wall import _perpendicular_wall_params
return _perpendicular_wall_params(cursor_x, cursor_y, anchor_x, length)
def test_perpendicular_params_on_axis_returns_none():
assert _run_perp_params(cursor_x=2.0, cursor_y=0.0) is None
def test_perpendicular_params_at_dead_zone_boundary_returns_none():
# Inclusive boundary: at exactly the threshold the on-axis icons still own
# the click; the gizmo only takes over strictly past the dead zone.
threshold = _wall_consts()
assert _run_perp_params(cursor_x=2.0, cursor_y=threshold) is None
assert _run_perp_params(cursor_x=2.0, cursor_y=-threshold) is None
def test_perpendicular_params_just_past_dead_zone_returns_params():
threshold = _wall_consts()
result = _run_perp_params(cursor_x=2.0, cursor_y=threshold + 0.01)
assert result is not None
clamped_x, length, side = result
assert clamped_x == pytest.approx(2.0)
assert length == pytest.approx(threshold + 0.01)
assert side == 1.0
def test_perpendicular_params_negative_y_flips_side_sign():
result = _run_perp_params(cursor_x=2.0, cursor_y=-1.5)
assert result is not None
_, length, side = result
# Length is always positive — the side sign carries the direction so the
# operator can pick the +90° vs -90° rotation without sign-flipping length.
assert length == pytest.approx(1.5)
assert side == -1.0
def test_perpendicular_params_clamps_low_when_cursor_left_of_wall():
result = _run_perp_params(cursor_x=-2.0, cursor_y=1.5, anchor_x=0.0, length=5.0)
assert result is not None
clamped_x, _length, _side = result
assert clamped_x == pytest.approx(0.0)
def test_perpendicular_params_clamps_high_when_cursor_right_of_wall():
result = _run_perp_params(cursor_x=10.0, cursor_y=1.5, anchor_x=0.0, length=5.0)
assert result is not None
clamped_x, _length, _side = result
assert clamped_x == pytest.approx(5.0)
def test_perpendicular_params_respects_nonzero_anchor_x():
# Non-zero anchor_x shifts the wall span; clamping must follow.
result = _run_perp_params(cursor_x=0.5, cursor_y=1.5, anchor_x=2.0, length=5.0)
assert result is not None
clamped_x, _length, _side = result
assert clamped_x == pytest.approx(2.0)
result = _run_perp_params(cursor_x=10.0, cursor_y=1.5, anchor_x=2.0, length=5.0)
assert result is not None
clamped_x, _length, _side = result
assert clamped_x == pytest.approx(7.0)
def test_perpendicular_params_in_range_passes_cursor_x_through():
result = _run_perp_params(cursor_x=3.0, cursor_y=1.5, anchor_x=0.0, length=5.0)
assert result is not None
clamped_x, length, side = result
assert clamped_x == pytest.approx(3.0)
assert length == pytest.approx(1.5)
assert side == 1.0
@@ -0,0 +1,237 @@
# 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.
"""Behaviour contract: wall topology gizmos and operators reject any
selection that contains a Bonsai array child. Discovers gated gizmo
groups and guarded operators by source inspection so additions inherit
the rule automatically."""
from types import SimpleNamespace
from unittest.mock import patch
import bpy
import pytest
pytestmark = pytest.mark.model
def _wall_gizmo_groups_using_gate():
"""Wall-module ``bpy.types.GizmoGroup`` subclasses whose ``poll`` calls
``_wall_topology_gizmo_poll_gate``. Discovered by source inspection so
the test tracks the gate's user set as the module grows."""
import inspect
from bonsai.bim.module.model import wall as wall_mod
out = []
for name in dir(wall_mod):
obj = getattr(wall_mod, name)
if not isinstance(obj, type):
continue
if not issubclass(obj, bpy.types.GizmoGroup) or obj is bpy.types.GizmoGroup:
continue
if obj.__module__ != wall_mod.__name__:
continue
poll = obj.__dict__.get("poll")
if poll is None:
continue
try:
src = inspect.getsource(poll)
except (OSError, TypeError):
continue
if "_wall_topology_gizmo_poll_gate" not in src:
continue
out.append((name, obj))
return out
def _wall_operators_with_array_child_guard():
"""Wall-module ``bpy.types.Operator`` subclasses whose ``poll`` rejects
array-child selections, either by referencing the central predicate
directly or by routing through the shared ``_poll_reject_array_children``
helper that wraps it. The operator-level guard is defence in depth
against keymap / F3 paths that bypass the gizmo entirely."""
import inspect
from bonsai.bim.module.model import wall as wall_mod
out = []
for name in dir(wall_mod):
obj = getattr(wall_mod, name)
if not isinstance(obj, type):
continue
if not issubclass(obj, bpy.types.Operator) or obj is bpy.types.Operator:
continue
if obj.__module__ != wall_mod.__name__:
continue
poll = obj.__dict__.get("poll")
if poll is None:
continue
try:
src = inspect.getsource(poll)
except (OSError, TypeError):
continue
if "any_selected_is_array_child" not in src and "_poll_reject_array_children" not in src:
continue
out.append((name, obj))
return out
class TestWallGizmoGroupsHideOnArrayChildSelection:
def test_discovery_finds_wall_multi_object_gizmo_groups(self):
groups = _wall_gizmo_groups_using_gate()
assert groups, (
"Expected at least one wall GizmoGroup whose poll calls "
"_wall_gizmo_poll_gate — discovery walk drifted out of sync?"
)
def test_every_gated_wall_gizmo_hides_when_any_selection_is_array_child(self):
"""Mocks the central ``any_selected_is_array_child`` predicate to True
and asserts every gizmo whose poll routes through
``_wall_gizmo_poll_gate`` returns False. The point is the BEHAVIOUR:
a child wall in the selection must never surface a topology gizmo,
regardless of which gate function the poll calls internally."""
groups = _wall_gizmo_groups_using_gate()
offenders = []
with patch("bonsai.tool.Blender.are_viewport_gizmos_enabled", return_value=True):
with patch("bonsai.bim.module.model.preview_base.any_preview_active", return_value=False):
with patch(
"bonsai.tool.Blender.Modifier.any_selected_is_array_child",
return_value=True,
):
for name, cls in groups:
try:
result = cls.poll(bpy.context)
except Exception as exc: # noqa: BLE001
offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}"))
continue
if result:
offenders.append((name, "poll returned True with array child selected"))
assert not offenders, (
"Wall gizmo polls that surface on array-child selections: "
+ ", ".join(f"{n}{why}" for n, why in offenders)
+ ". Route the poll through _wall_topology_gizmo_poll_gate so the "
"central any_selected_is_array_child filter applies."
)
class TestWallOperatorsRejectArrayChildSelection:
def test_discovery_finds_wall_topology_operators(self):
ops = _wall_operators_with_array_child_guard()
assert ops, (
"Expected at least one wall Operator whose poll rejects array-child "
"selections (via any_selected_is_array_child or _poll_reject_array_children) "
"— discovery walk drifted out of sync?"
)
def test_every_guarded_wall_operator_polls_false_on_array_child_selection(self):
"""Operators reachable from keymaps / F3 must reject array-child
invocation independently of the gizmo gating, because not every
invocation path goes through a gizmo. The shared predicate makes
this a one-line guard per operator; this test pins it for every
operator that opted in."""
ops = _wall_operators_with_array_child_guard()
offenders = []
with patch(
"bonsai.tool.Blender.Modifier.any_selected_is_array_child",
return_value=True,
):
with patch("bonsai.tool.Model.has_selected_ifc_objects", return_value=True):
with patch("bonsai.tool.Model.get_selected_ifc_objects", return_value=[]):
for name, cls in ops:
try:
result = cls.poll(bpy.context)
except Exception as exc: # noqa: BLE001
offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}"))
continue
if result:
offenders.append((name, "poll returned True with array child selected"))
assert not offenders, (
"Wall topology operators that accept array-child selections: "
+ ", ".join(f"{n}{why}" for n, why in offenders)
+ ". Route the poll through `_poll_reject_array_children(cls)` (the "
"shared helper that sets the standard poll message and reuses the "
"central `any_selected_is_array_child` predicate)."
)
class TestAnySelectedIsArrayChildHelper:
"""Smoke checks on the central predicate. Returns ``False`` when nothing
is selected; returns ``True`` when at least one selected element passes
``is_array_child``."""
def test_returns_false_with_empty_selection(self):
from bonsai import tool
with patch.object(tool.Blender, "get_selected_objects", return_value=[]):
assert tool.Blender.Modifier.any_selected_is_array_child() is False
def test_returns_true_when_any_selected_passes_predicate(self):
from bonsai import tool
child_obj, child_element = SimpleNamespace(name="child"), object()
parent_obj, parent_element = SimpleNamespace(name="parent"), object()
def get_entity(obj):
return {id(child_obj): child_element, id(parent_obj): parent_element}.get(id(obj))
def is_array_child(element):
return element is child_element
with patch.object(tool.Blender, "get_selected_objects", return_value=[parent_obj, child_obj]):
with patch.object(tool.Ifc, "get_entity", side_effect=get_entity):
with patch.object(tool.Blender.Modifier, "is_array_child", side_effect=is_array_child):
assert tool.Blender.Modifier.any_selected_is_array_child() is True
def test_returns_false_when_no_selected_passes_predicate(self):
from bonsai import tool
parent_obj, parent_element = SimpleNamespace(name="parent"), object()
with patch.object(tool.Blender, "get_selected_objects", return_value=[parent_obj]):
with patch.object(tool.Ifc, "get_entity", return_value=parent_element):
with patch.object(tool.Blender.Modifier, "is_array_child", return_value=False):
assert tool.Blender.Modifier.any_selected_is_array_child() is False
class TestHostOpeningGizmoStaysAvailableOnArrayChildren:
"""Openings on array children are array-safe: ``regenerate_array``
applies opening cuts after replicating child geometry, so an opening
authored on a child survives regen and tracks with the replicated
instance. The host-opening gizmos therefore route through the loose
base wall gate, not the tighter topology gate that excludes
children."""
def test_host_opening_module_does_not_apply_topology_gate(self):
import inspect
from bonsai.bim.module.model import host_add_opening_gizmo
src = inspect.getsource(host_add_opening_gizmo)
assert "_wall_topology_gizmo_poll_gate" not in src, (
"host-opening gizmo module references the topology gate; that "
"would suppress add-opening on array-child hosts. Openings "
"track with the regenerated child via the array regen pipeline."
)
assert "any_selected_is_array_child" not in src, (
"host-opening gizmo module references any_selected_is_array_child; "
"openings are array-safe, drop the filter."
)
@@ -39,12 +39,6 @@ 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_for_every_operator():
"""The generation counter advances on every commit, regardless of
operator class it's the cache-invalidation signal for any code
@@ -37,12 +37,6 @@ from mathutils import Vector
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_regenerate_wall_mesh_from_props_outward_normals():
"""Every face of the preview box must have its normal pointing away
from the box centroid the contract every other preview-mesh builder
@@ -0,0 +1,164 @@
# 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.
"""Cache-invalidation tests for the wall-topology gizmo helpers.
``GizmoWallUnjoinSingle`` and ``GizmoWallJoinIntersection`` re-run
``_iter_path_connections``, ``_are_walls_joined``, ``_are_walls_collinear``,
and ``core.project_axis_intersection`` every viewport redraw without the
cache helpers wrapping them. These tests pin that:
- Repeat calls within one IFC generation reuse the cached result.
- An IFC-generation bump invalidates the cache.
- ``refresh()`` (the Blender state-change hook on the mixin) drops the cache."""
from unittest.mock import Mock, patch
import pytest
pytestmark = pytest.mark.model
def test_get_wall_connections_cached_returns_cached_within_generation():
from bonsai.bim.module.model import wall
group = Mock(spec=[])
elem = Mock()
elem.GlobalId = "0AAAAAAAAAAAAAAAAAAAAA"
expected = [(Mock(), "ATEND", "ATSTART")]
call_count = {"n": 0}
def counting_iter(e):
call_count["n"] += 1
return expected
with patch.object(wall, "_iter_path_connections", side_effect=counting_iter), patch(
"bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", return_value=7
):
first = wall._get_wall_connections_cached(group, elem)
second = wall._get_wall_connections_cached(group, elem)
assert first is second
assert call_count["n"] == 1
def test_get_wall_connections_cached_invalidates_on_generation_bump():
from bonsai.bim.module.model import wall
group = Mock(spec=[])
elem = Mock()
elem.GlobalId = "0AAAAAAAAAAAAAAAAAAAAA"
call_count = {"n": 0}
def counting_iter(e):
call_count["n"] += 1
return []
gen_state = {"gen": 1}
with patch.object(wall, "_iter_path_connections", side_effect=counting_iter), patch(
"bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", side_effect=lambda: gen_state["gen"]
):
wall._get_wall_connections_cached(group, elem)
gen_state["gen"] = 2
wall._get_wall_connections_cached(group, elem)
assert call_count["n"] == 2
def test_get_wall_pair_predicate_cached_reuses_value_within_generation():
from bonsai.bim.module.model import wall
group = Mock(spec=[])
call_count = {"n": 0}
def compute():
call_count["n"] += 1
return "result"
with patch("bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", return_value=3):
first = wall._get_wall_pair_predicate_cached(group, ("joined", ("guid_a", "guid_b")), compute)
second = wall._get_wall_pair_predicate_cached(group, ("joined", ("guid_a", "guid_b")), compute)
assert first == second == "result"
assert call_count["n"] == 1
def test_get_wall_pair_predicate_cached_distinguishes_predicate_kind():
"""The cache key includes a tag string ("joined" vs "collinear" vs
"intersection") so adding a second predicate for the same pair doesn't
return the first predicate's value."""
from bonsai.bim.module.model import wall
group = Mock(spec=[])
pair = ("guid_a", "guid_b")
with patch("bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", return_value=3):
a = wall._get_wall_pair_predicate_cached(group, ("joined", pair), lambda: "JOINED")
b = wall._get_wall_pair_predicate_cached(group, ("collinear", pair), lambda: "COLLINEAR")
assert a == "JOINED"
assert b == "COLLINEAR"
def test_get_wall_pair_predicate_cached_invalidates_on_generation_bump():
from bonsai.bim.module.model import wall
group = Mock(spec=[])
call_count = {"n": 0}
def compute():
call_count["n"] += 1
return call_count["n"]
gen_state = {"gen": 1}
with patch(
"bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", side_effect=lambda: gen_state["gen"]
):
first = wall._get_wall_pair_predicate_cached(group, ("joined", ("a", "b")), compute)
gen_state["gen"] = 2
second = wall._get_wall_pair_predicate_cached(group, ("joined", ("a", "b")), compute)
assert first == 1
assert second == 2
assert call_count["n"] == 2
def test_mixin_refresh_clears_pair_and_connection_caches():
"""``refresh()`` is Blender's "state changed" signal — typically a
selection change. Both the connection list and pair predicate caches
must drop alongside the geometry cache, otherwise the next frame would
read predicates that targeted the previously-selected pair."""
from bonsai.bim.module.model import wall
class _Group(wall._WallGeomCachedBillboardingMixin):
def position_gizmos(self, context):
pass
group = _Group()
group._wall_geom_cache = {"x": "geom"}
group._wall_connections_cache = {"guid": []}
group._wall_pair_predicate_cache = {"key": "value"}
group.refresh(context=Mock())
assert group._wall_geom_cache is None
assert group._wall_connections_cache is None
assert group._wall_pair_predicate_cache is None
@@ -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,108 @@
# 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 unittest.mock import patch
import bpy
import ifcopenshell
import pytest
import bonsai.tool as tool
from test.bim.bootstrap import NewIfc
pytestmark = pytest.mark.project
def _populate_pending(*element_ids: int) -> None:
pending = tool.Project.get_project_props().pending_opening_recut
pending.clear()
for eid in element_ids:
pending.add().ifc_definition_id = eid
def _make_linked_wall(name: str = "Wall") -> tuple[ifcopenshell.entity_instance, bpy.types.Object]:
ifc_file = tool.Ifc.get()
element = ifc_file.create_entity("IfcWall", GlobalId=ifcopenshell.guid.new(), Name=name)
obj = bpy.data.objects.new(name, bpy.data.meshes.new(name))
bpy.context.scene.collection.objects.link(obj)
tool.Ifc.link(element, obj)
return element, obj
class TestApplyPendingOpeningCuts(NewIfc):
def test_clears_pending_and_calls_reimport_with_apply_openings(self):
element, obj = _make_linked_wall()
_populate_pending(element.id())
with patch.object(tool.Geometry, "reimport_element_representations") as mock_reimport, patch(
"ifcopenshell.util.representation.get_representation",
return_value=object(),
):
result = bpy.ops.bim.apply_pending_opening_cuts()
assert result == {"FINISHED"}
assert len(tool.Project.get_project_props().pending_opening_recut) == 0
mock_reimport.assert_called_once()
_, kwargs = mock_reimport.call_args
assert kwargs.get("apply_openings") is True
def test_skips_entries_whose_entity_is_gone(self):
_populate_pending(99999) # ID guaranteed not present
with patch.object(tool.Geometry, "reimport_element_representations") as mock_reimport:
result = bpy.ops.bim.apply_pending_opening_cuts()
assert result == {"FINISHED"}
assert len(tool.Project.get_project_props().pending_opening_recut) == 0
mock_reimport.assert_not_called()
class TestDismissPendingOpeningCuts(NewIfc):
def test_clears_collection_without_calling_reimport(self):
element, _obj = _make_linked_wall()
_populate_pending(element.id())
with patch.object(tool.Geometry, "reimport_element_representations") as mock_reimport:
result = bpy.ops.bim.dismiss_pending_opening_cuts()
assert result == {"FINISHED"}
assert len(tool.Project.get_project_props().pending_opening_recut) == 0
mock_reimport.assert_not_called()
class TestSelectPendingOpeningCuts(NewIfc):
def test_selects_objects_for_each_pending_entry(self):
e1, o1 = _make_linked_wall("WallA")
e2, o2 = _make_linked_wall("WallB")
_populate_pending(e1.id(), e2.id())
for obj in bpy.context.view_layer.objects:
obj.select_set(False)
result = bpy.ops.bim.select_pending_opening_cuts()
assert result == {"FINISHED"}
assert o1.select_get() and o2.select_get()
assert bpy.context.view_layer.objects.active in (o1, o2)
def test_cancels_when_no_objects_match(self):
_populate_pending(99999)
result = bpy.ops.bim.select_pending_opening_cuts()
assert result == {"CANCELLED"}
@@ -0,0 +1,232 @@
# 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.
"""Framework contract test for the partial-state recovery hint in
``IfcStore.execute_ifc_operator``.
The framework wraps every ``tool.Ifc.Operator._execute`` call between
``ifc_file.begin_transaction()`` and ``ifc_file.end_transaction()``. When
``_execute`` raises after at least one ``ifcopenshell.api.*`` mutation
has been captured, the user is in a partial state (IFC mutated, Blender
side stale) and the framework surfaces a WARNING naming Ctrl+Z so the
recovery path is discoverable instead of buried behind a raw traceback.
The contract has three parts pinned here:
1. ``ifcopenshell.file.Transaction.operations`` is a public list and is
the introspection idiom the framework relies on.
2. The WARNING fires only when ``_execute`` raised AND the transaction
captured at least one operation.
3. A successful ``_execute`` never emits the WARNING regardless of
whether IFC was mutated."""
from unittest import mock
import pytest
pytestmark = pytest.mark.misc
@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)")
@pytest.fixture
def fresh_ifc():
"""Set up a fresh ``ifcopenshell.file`` as ``IfcStore.file`` and tear
it down afterwards. Each test gets a virgin transaction state."""
import ifcopenshell
from bonsai.bim.ifc import IfcStore
previous = IfcStore.file
previous_transaction = IfcStore.current_transaction
IfcStore.file = ifcopenshell.file(schema="IFC4")
IfcStore.current_transaction = ""
try:
yield IfcStore.file
finally:
IfcStore.file = previous
IfcStore.current_transaction = previous_transaction
@pytest.fixture
def neutralised_framework():
"""Patch the side-effect-heavy helpers in ``IfcStore.execute_ifc_operator``
so a bare unit test can drive it without a populated Scene / props /
decorator handlers."""
with mock.patch("bonsai.bim.ifc.tool.Blender.get_bim_props") as get_props, mock.patch(
"bonsai.bim.handler.refresh_ui_data"
), mock.patch("bonsai.bim.ifc.tool.Parametric.refresh_post_commit"), mock.patch(
"bonsai.bim.ifc.IfcStore.add_transaction_operation"
), mock.patch(
"bonsai.bim.ifc.IfcStore.begin_transaction"
), mock.patch(
"bonsai.bim.ifc.IfcStore.end_transaction"
), mock.patch(
"bonsai.bim.ifc.IfcStore.get_ifc_file_undo_callback", return_value=lambda data: True
):
get_props.return_value = mock.Mock(is_dirty=False)
yield
def _make_operator(execute_callback):
"""Build a ``Mock`` operator that satisfies the attribute reads the
framework performs (``bl_idname``, ``_execute``, ``report``, etc.)."""
op = mock.Mock(spec=["bl_idname", "_execute", "_invoke", "_modal", "report", "transaction_key"])
op.bl_idname = "bim.test_partial_state"
op._execute = execute_callback
return op
def _mutate_ifc():
"""Single ``ifcopenshell.api.*`` call so the transaction captures at
least one operation. ``project.create_file`` would not work here since
it replaces the file; pick a small entity mutation that always lands."""
import ifcopenshell.api.owner
from bonsai.bim.ifc import IfcStore
ifcopenshell.api.owner.add_person(IfcStore.get_file())
def test_transaction_operations_is_empty_until_first_api_call(fresh_ifc):
"""Pin the introspection contract the framework relies on:
``Transaction.operations`` is empty after ``begin_transaction()`` and
populated by any ``ifcopenshell.api.*`` call."""
fresh_ifc.begin_transaction()
assert fresh_ifc.transaction is not None
assert fresh_ifc.transaction.operations == []
_mutate_ifc()
assert len(fresh_ifc.transaction.operations) > 0
def test_no_mutation_no_raise_no_warning(fresh_ifc, neutralised_framework):
"""Happy path: ``_execute`` does nothing, returns FINISHED.
Framework MUST NOT emit the partial-state WARNING."""
from bonsai.bim.ifc import IfcStore
op = _make_operator(execute_callback=lambda context: {"FINISHED"})
IfcStore.execute_ifc_operator(op, context=mock.Mock())
for call in op.report.call_args_list:
assert "Ctrl+Z" not in call.args[1], "partial-state WARNING fired on a clean success path"
def test_raise_before_mutation_no_warning(fresh_ifc, neutralised_framework):
"""``_execute`` raises before any IFC mutation. The transaction has no
operations no partial state no WARNING."""
from bonsai.bim.ifc import IfcStore
def _raise_immediately(context):
raise RuntimeError("kaboom")
op = _make_operator(execute_callback=_raise_immediately)
with pytest.raises(RuntimeError, match="kaboom"):
IfcStore.execute_ifc_operator(op, context=mock.Mock())
for call in op.report.call_args_list:
assert "Ctrl+Z" not in call.args[1], "partial-state WARNING fired without any mutation"
def test_mutation_then_success_no_warning(fresh_ifc, neutralised_framework):
"""Real mutation, normal FINISHED return. WARNING is exception-path
only and MUST NOT fire on a clean success."""
from bonsai.bim.ifc import IfcStore
def _mutate_and_finish(context):
_mutate_ifc()
return {"FINISHED"}
op = _make_operator(execute_callback=_mutate_and_finish)
IfcStore.execute_ifc_operator(op, context=mock.Mock())
for call in op.report.call_args_list:
assert "Ctrl+Z" not in call.args[1], "partial-state WARNING fired on a successful mutation"
def test_mutation_then_raise_emits_warning(fresh_ifc, neutralised_framework):
"""The contract this whole change exists for: mutate, then raise.
Framework MUST emit a WARNING naming Ctrl+Z before the exception
re-raises into Blender's normal operator error flow."""
from bonsai.bim.ifc import IfcStore
def _mutate_then_raise(context):
_mutate_ifc()
raise RuntimeError("rebuild failed after IFC mutation")
op = _make_operator(execute_callback=_mutate_then_raise)
with pytest.raises(RuntimeError, match="rebuild failed"):
IfcStore.execute_ifc_operator(op, context=mock.Mock())
warning_calls = [
call
for call in op.report.call_args_list
if call.args and call.args[0] == {"WARNING"} and "Ctrl+Z" in call.args[1]
]
assert (
len(warning_calls) == 1
), f"expected exactly one partial-state WARNING with Ctrl+Z guidance, got: {op.report.call_args_list}"
def test_mutation_then_raise_pushes_blender_undo_step(fresh_ifc, neutralised_framework):
"""A raised operator does not get an automatic Blender undo step (same gap
as the CANCELLED-modal path). The framework pushes one explicitly so the
Ctrl+Z the WARNING advertises actually rewinds the partial mutation."""
from bonsai.bim.ifc import IfcStore
def _mutate_then_raise(context):
_mutate_ifc()
raise RuntimeError("rebuild failed after IFC mutation")
op = _make_operator(execute_callback=_mutate_then_raise)
with mock.patch("bonsai.bim.ifc.bpy.ops", new=mock.Mock()) as bpy_ops:
undo_push = bpy_ops.ed.undo_push
with pytest.raises(RuntimeError, match="rebuild failed"):
IfcStore.execute_ifc_operator(op, context=mock.Mock())
assert undo_push.call_count == 1, f"expected exactly one undo_push, got {undo_push.call_count}"
pushed_message = undo_push.call_args.kwargs.get("message", "")
assert op.bl_idname in pushed_message, f"undo step message should name the operator, got: {pushed_message!r}"
def test_raise_before_mutation_does_not_push_undo_step(fresh_ifc, neutralised_framework):
"""No mutation captured → nothing to recover → no recovery undo step.
Avoids polluting the undo history with no-op recovery snapshots."""
from bonsai.bim.ifc import IfcStore
def _raise_immediately(context):
raise RuntimeError("kaboom")
op = _make_operator(execute_callback=_raise_immediately)
with mock.patch("bonsai.bim.ifc.bpy.ops", new=mock.Mock()) as bpy_ops:
undo_push = bpy_ops.ed.undo_push
with pytest.raises(RuntimeError, match="kaboom"):
IfcStore.execute_ifc_operator(op, context=mock.Mock())
assert undo_push.call_count == 0, "undo_push fired on a non-partial-state raise"
@@ -0,0 +1,91 @@
# 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.
"""Forward-compat AST contract for the pen-icon dispatcher monopoly.
Every parametric gizmo group's pen icon must bind to the universal
``bim.enable_editing_parametric`` dispatcher rather than the feature's own
enable operator. The dispatcher is the single chokepoint where pre-edit
checks (shared-representation warning, future safety gates) run; a feature
that binds directly bypasses every such check silently."""
import ast
from pathlib import Path
import pytest
pytestmark = pytest.mark.drawing
BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai"
BIM_DIR = BONSAI_ROOT / "bim"
DISPATCHER_IDNAME = "bim.enable_editing_parametric"
def _iter_pen_gizmo_target_set_operator_calls(tree: ast.Module):
"""Yield each ``ast.Call`` matching ``<receiver>.pen_gizmo.target_set_operator(...)``.
Receiver is any attribute access (``self.pen_gizmo``, ``group.pen_gizmo``, etc.)."""
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if not isinstance(func, ast.Attribute) or func.attr != "target_set_operator":
continue
receiver = func.value
if not isinstance(receiver, ast.Attribute) or receiver.attr != "pen_gizmo":
continue
yield node
def test_every_pen_gizmo_binding_routes_through_the_universal_dispatcher() -> None:
violations: list[str] = []
found_any = False
for path in BIM_DIR.rglob("*.py"):
try:
tree = ast.parse(path.read_text(encoding="utf-8"))
except SyntaxError:
continue
for call in _iter_pen_gizmo_target_set_operator_calls(tree):
found_any = True
if not call.args:
violations.append(f"{path}:{call.lineno} pen_gizmo.target_set_operator() called with no args")
continue
first_arg = call.args[0]
if not isinstance(first_arg, ast.Constant) or not isinstance(first_arg.value, str):
violations.append(
f"{path}:{call.lineno} pen_gizmo.target_set_operator() first arg is not a string literal"
)
continue
if first_arg.value != DISPATCHER_IDNAME:
violations.append(
f"{path}:{call.lineno} pen_gizmo.target_set_operator({first_arg.value!r}) "
f"bypasses the universal dispatcher"
)
assert found_any, (
"No pen_gizmo.target_set_operator(...) calls found anywhere under bim/. "
"Either the gizmo-binding pattern has been refactored away (this test "
"needs updating) or the search root is wrong."
)
assert not violations, (
"Pen-icon bindings must route through the universal dispatcher "
f"({DISPATCHER_IDNAME!r}) so the shared-representation warning and any "
"future pre-edit checks apply to every feature. Violations:\n " + "\n ".join(violations)
)
@@ -0,0 +1,144 @@
# 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.
"""Forward-compat AST contract for the preview cancellation registry.
Every ``PointerProperty`` child of ``BIMPreviewProperties`` whose target
PropertyGroup declares an ``is_active`` BoolProperty is a Scene-level
preview. Each must have a matching ``(child_attr, cancel_op_name)`` entry
in ``preview_base.PREVIEW_CANCEL_OPS`` so the Esc dispatcher and the
``load_post`` stale-flag discard both cover it.
A new preview type that defines its own Enable / Decorator without
registering the cancel pair will silently ignore Esc and leave a stuck
``is_active`` flag across file reloads exactly the failure mode the
sibling forward-compat guards exist to prevent."""
import ast
from pathlib import Path
import pytest
pytestmark = pytest.mark.model
BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai"
PROP_FILE = BONSAI_ROOT / "bim" / "module" / "model" / "prop.py"
UMBRELLA_CLASS = "BIMPreviewProperties"
def _find_class(tree: ast.Module, name: str) -> ast.ClassDef | None:
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef) and node.name == name:
return node
return None
def _iter_pointer_property_children(class_node: ast.ClassDef):
"""Yield ``(attr_name, target_class_name)`` for each
``<attr>: bpy.props.PointerProperty(type=<TargetClass>)`` annotated
assignment in the umbrella class body.
Bonsai follows the Blender convention where the property call lives in
the *annotation* (PEP 526 syntax) rather than the value Blender's
PropertyGroup metaclass picks it up at class creation time."""
for node in class_node.body:
if not isinstance(node, ast.AnnAssign) or not isinstance(node.target, ast.Name):
continue
if not isinstance(node.annotation, ast.Call):
continue
func = node.annotation.func
if not isinstance(func, ast.Attribute) or func.attr != "PointerProperty":
continue
for kw in node.annotation.keywords:
if kw.arg == "type" and isinstance(kw.value, ast.Name):
yield node.target.id, kw.value.id
break
def _class_has_is_active_bool(class_node: ast.ClassDef) -> bool:
"""Return True if ``class_node`` declares ``is_active: bpy.props.BoolProperty(...)``."""
for node in class_node.body:
if not isinstance(node, ast.AnnAssign) or not isinstance(node.target, ast.Name):
continue
if node.target.id != "is_active":
continue
if not isinstance(node.annotation, ast.Call):
continue
func = node.annotation.func
if isinstance(func, ast.Attribute) and func.attr == "BoolProperty":
return True
return False
def test_every_preview_propertygroup_is_registered_in_cancel_ops() -> None:
from bonsai.bim.module.model import preview_base
registered_attrs = {attr for attr, _op in preview_base.PREVIEW_CANCEL_OPS}
tree = ast.parse(PROP_FILE.read_text(encoding="utf-8"))
umbrella = _find_class(tree, UMBRELLA_CLASS)
assert umbrella is not None, (
f"Could not find {UMBRELLA_CLASS!r} in {PROP_FILE}. Either the umbrella class "
"was renamed (this test needs updating) or prop.py was restructured."
)
preview_children: list[tuple[str, str]] = []
for attr, target_class_name in _iter_pointer_property_children(umbrella):
target = _find_class(tree, target_class_name)
if target is None:
continue
if _class_has_is_active_bool(target):
preview_children.append((attr, target_class_name))
assert preview_children, (
"No PointerProperty children with ``is_active`` BoolProperty found under "
f"{UMBRELLA_CLASS}. Either the preview convention has been refactored away "
"(this test needs updating) or prop.py was restructured."
)
missing = [(attr, cls) for attr, cls in preview_children if attr not in registered_attrs]
assert not missing, (
"Every Scene-level preview PropertyGroup must have a matching "
"(child_attr, cancel_op_name) tuple in preview_base.PREVIEW_CANCEL_OPS so "
"Esc dispatch and load_post stale-flag discard cover it. Missing entries:\n "
+ "\n ".join(f"BIMPreviewProperties.{attr} (target={cls!r})" for attr, cls in missing)
)
def test_every_cancel_ops_entry_has_a_real_preview_propertygroup() -> None:
"""The reverse contract: a stale entry in ``PREVIEW_CANCEL_OPS`` whose
PropertyGroup has been deleted would silently leak to every Esc press
(dispatching to a missing operator raises ``AttributeError`` inside
``try_cancel_active_preview``). Pin that the registry never goes
stale relative to ``BIMPreviewProperties``."""
from bonsai.bim.module.model import preview_base
tree = ast.parse(PROP_FILE.read_text(encoding="utf-8"))
umbrella = _find_class(tree, UMBRELLA_CLASS)
assert umbrella is not None
declared_attrs = {attr for attr, _target in _iter_pointer_property_children(umbrella)}
orphaned = [attr for attr, _op in preview_base.PREVIEW_CANCEL_OPS if attr not in declared_attrs]
assert not orphaned, (
"PREVIEW_CANCEL_OPS contains entries whose PointerProperty child no longer "
f"exists on {UMBRELLA_CLASS}. Drop the stale tuple(s):\n "
+ "\n ".join(orphaned)
)
+29
View File
@@ -52,6 +52,35 @@ class TestAssignContainer:
collector.assign("obj2").should_be_called()
subject.assign_container(ifc, collector, spatial, container="container", objs=["obj"])
def test_root_resolves_to_self_for_a_filling(self, ifc, collector, spatial):
ifc.get_entity("door_obj").should_be_called().will_return("door")
spatial.get_root_element("door").should_be_called().will_return("door")
spatial.disable_editing("door_obj").should_be_called()
spatial.get_decomposition("door").should_be_called().will_return(["door"])
spatial.can_contain("container", "door").should_be_called().will_return(True)
ifc.run("spatial.assign_container", products=["door"], relating_structure="container").should_be_called()
ifc.get_object("door").should_be_called().will_return("door_obj")
collector.assign("door_obj").should_be_called()
subject.assign_container(ifc, collector, spatial, container="container", objs=["door_obj"])
def test_can_contain_is_evaluated_per_root_element(self, ifc, collector, spatial):
ifc.get_entity("door_obj").should_be_called().will_return("door")
spatial.get_root_element("door").should_be_called().will_return("door")
spatial.disable_editing("door_obj").should_be_called()
spatial.get_decomposition("door").should_be_called().will_return(["door"])
ifc.get_entity("opening_obj").should_be_called().will_return("opening")
spatial.get_root_element("opening").should_be_called().will_return("opening")
spatial.disable_editing("opening_obj").should_be_called()
spatial.get_decomposition("opening").should_be_called().will_return(["opening"])
spatial.can_contain("container", "door").should_be_called().will_return(True)
spatial.can_contain("container", "opening").should_be_called().will_return(False)
ifc.run("spatial.assign_container", products=["door"], relating_structure="container").should_be_called()
ifc.get_object("door").should_be_called().will_return("door_obj")
ifc.get_object("opening").should_be_called().will_return("opening_obj")
collector.assign("door_obj").should_be_called()
collector.assign("opening_obj").should_be_called()
subject.assign_container(ifc, collector, spatial, container="container", objs=["door_obj", "opening_obj"])
class TestEnableEditingContainer:
def test_run(self, spatial):
+16
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 tempfile
from pathlib import Path
@@ -22,6 +24,7 @@ from typing import TYPE_CHECKING
import bpy
import ifcopenshell
import numpy as np
import pytest
import bonsai
@@ -167,3 +170,16 @@ class TestGetDebugInfo(NewFile):
def test_failed_to_load_returns_only_base_keys(self):
info = bonsai.get_debug_info(bonsai_failed_to_load=True)
assert set(info.keys()) == self.EXPECTED_KEYS
class TestNpFrombufferLegacy(NewFile):
"""Decoding ``n`` floats from a buffer must yield a length-``n`` array
regardless of whether the buffer was written as ``float32`` or ``float64``."""
@pytest.mark.parametrize("n", [3, 9])
@pytest.mark.parametrize("dtype", [np.float32, np.float64])
def test_decodes_to_n_elements(self, n, dtype):
data = np.arange(n, dtype=dtype).tobytes()
result = subject.np_frombuffer_legacy(data, n)
assert result.shape == (n,)
np.testing.assert_allclose(result, np.arange(n))
@@ -0,0 +1,152 @@
# 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.
"""Cache-invalidation tests for ``tool.Blender.Modifier.any_selected_is_array_child``.
The wall-topology gizmo gate calls this on every viewport input event. The
underlying ``is_array_child`` check is a BBIM_Array pset lookup per selected
object; without memoisation that runs N_selected times per event. These
tests pin that the cache reuses results across identical (selection, IFC
generation) pairs and invalidates on either change."""
from unittest.mock import Mock, patch
import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _reset_memo():
from bonsai import tool
saved = getattr(tool.Blender.Modifier, "_any_selected_array_child_memo", None)
tool.Blender.Modifier._any_selected_array_child_memo = None
yield
tool.Blender.Modifier._any_selected_array_child_memo = saved
def _mock_obj(name: str) -> Mock:
obj = Mock()
obj.name = name
return obj
def test_repeat_call_within_generation_reuses_cache():
from bonsai import tool
obj_a = _mock_obj("Wall.001")
obj_b = _mock_obj("Wall.002")
is_array_child_calls = {"n": 0}
def counting_is_array_child(elem):
is_array_child_calls["n"] += 1
return False
with patch("bonsai.tool.blender.tool.Blender.get_selected_objects", return_value=[obj_a, obj_b]), patch(
"bonsai.tool.blender.tool.Parametric.get_geom_generation", return_value=5
), patch("bonsai.tool.blender.tool.Ifc.get_entity", return_value=Mock()), patch.object(
tool.Blender.Modifier, "is_array_child", side_effect=counting_is_array_child
):
first = tool.Blender.Modifier.any_selected_is_array_child()
second = tool.Blender.Modifier.any_selected_is_array_child()
assert first is False
assert second is False
assert is_array_child_calls["n"] == 2, "First call walks N_selected; second call must reuse cached result"
def test_generation_advance_invalidates_cache():
from bonsai import tool
obj = _mock_obj("Wall.001")
gen_state = {"gen": 1}
call_count = {"n": 0}
def counting_is_array_child(elem):
call_count["n"] += 1
return False
with patch("bonsai.tool.blender.tool.Blender.get_selected_objects", return_value=[obj]), patch(
"bonsai.tool.blender.tool.Parametric.get_geom_generation", side_effect=lambda: gen_state["gen"]
), patch("bonsai.tool.blender.tool.Ifc.get_entity", return_value=Mock()), patch.object(
tool.Blender.Modifier, "is_array_child", side_effect=counting_is_array_child
):
tool.Blender.Modifier.any_selected_is_array_child()
first = call_count["n"]
gen_state["gen"] = 2
tool.Blender.Modifier.any_selected_is_array_child()
assert call_count["n"] > first
def test_selection_change_invalidates_cache():
from bonsai import tool
obj_a = _mock_obj("Wall.001")
obj_b = _mock_obj("Wall.002")
selection = {"sel": [obj_a]}
call_count = {"n": 0}
def counting_is_array_child(elem):
call_count["n"] += 1
return False
with patch("bonsai.tool.blender.tool.Blender.get_selected_objects", side_effect=lambda: selection["sel"]), patch(
"bonsai.tool.blender.tool.Parametric.get_geom_generation", return_value=1
), patch("bonsai.tool.blender.tool.Ifc.get_entity", return_value=Mock()), patch.object(
tool.Blender.Modifier, "is_array_child", side_effect=counting_is_array_child
):
tool.Blender.Modifier.any_selected_is_array_child()
first = call_count["n"]
selection["sel"] = [obj_a, obj_b]
tool.Blender.Modifier.any_selected_is_array_child()
assert call_count["n"] > first
def test_short_circuits_on_first_hit():
"""``is_array_child`` returning True for the first selected object must
short-circuit; the rest of the selection isn't walked. Belt-and-suspenders
test the early-return existed before the cache wrap and must survive it."""
from bonsai import tool
obj_a = _mock_obj("Wall.001")
obj_b = _mock_obj("Wall.002")
obj_c = _mock_obj("Wall.003")
call_count = {"n": 0}
def counting_is_array_child(elem):
call_count["n"] += 1
return True
with patch("bonsai.tool.blender.tool.Blender.get_selected_objects", return_value=[obj_a, obj_b, obj_c]), patch(
"bonsai.tool.blender.tool.Parametric.get_geom_generation", return_value=1
), patch("bonsai.tool.blender.tool.Ifc.get_entity", return_value=Mock()), patch.object(
tool.Blender.Modifier, "is_array_child", side_effect=counting_is_array_child
):
result = tool.Blender.Modifier.any_selected_is_array_child()
assert result is True
assert call_count["n"] == 1
@@ -0,0 +1,107 @@
# 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 the world-space dashed-line segmentation helper in tool.Blender.
The helper slices each input edge into world-space dash chunks so callers can
build a vanilla LINES batch (any shader, including ``POLYLINE_UNIFORM_COLOR``)
that renders as dashes. Sharing the front-pass shader for the occluded back
pass is what keeps depth values coherent between the visible / occluded
outlines a custom dashed shader against a builtin solid shader produces
inter-pass z-fighting and the wrong portion of the outline ends up dashed."""
import math
import types
import bpy
import pytest
import bonsai.tool as tool
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)")
class TestBuildDashedLineSegments:
def test_unit_edge_produces_expected_dash_count(self):
verts, edges = tool.Blender.build_dashed_line_segments(
[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)],
[(0, 1)],
dash_period=0.20,
dash_width=0.10,
)
assert len(edges) == 5
assert len(verts) == 10
def test_each_dash_runs_dash_width_along_the_edge(self):
verts, edges = tool.Blender.build_dashed_line_segments(
[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)],
[(0, 1)],
dash_period=0.20,
dash_width=0.10,
)
for i, j in edges:
dx = verts[j][0] - verts[i][0]
assert math.isclose(dx, 0.10, abs_tol=1e-9)
def test_dash_phase_resets_per_input_edge(self):
verts, edges = tool.Blender.build_dashed_line_segments(
[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 1.0, 0.0)],
[(0, 1), (2, 3)],
dash_period=0.20,
dash_width=0.10,
)
first_dash_start = verts[edges[0][0]]
second_edge_first_dash_start = verts[edges[5][0]]
assert math.isclose(first_dash_start[0], 0.0, abs_tol=1e-9)
assert math.isclose(second_edge_first_dash_start[1], 0.0, abs_tol=1e-9)
def test_trailing_partial_dash_is_clamped_to_edge_end(self):
verts, edges = tool.Blender.build_dashed_line_segments(
[(0.0, 0.0, 0.0), (0.25, 0.0, 0.0)],
[(0, 1)],
dash_period=0.20,
dash_width=0.10,
)
last_x = verts[edges[-1][1]][0]
assert last_x <= 0.25 + 1e-9
def test_zero_length_edge_emits_no_dashes(self):
verts, edges = tool.Blender.build_dashed_line_segments(
[(0.0, 0.0, 0.0), (0.0, 0.0, 0.0)],
[(0, 1)],
dash_period=0.20,
dash_width=0.10,
)
assert verts == []
assert edges == []
def test_invalid_dash_parameters_return_empty(self):
assert tool.Blender.build_dashed_line_segments(
[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)], [(0, 1)], dash_period=0.0, dash_width=0.10
) == ([], [])
assert tool.Blender.build_dashed_line_segments(
[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)], [(0, 1)], dash_period=0.20, dash_width=-0.10
) == ([], [])
+85
View File
@@ -934,3 +934,88 @@ class TestOffsetWall(NewFile):
usage.DirectionSense = "NEGATIVE"
subject.offset_wall(obj, "EXTERIOR")
assert usage.OffsetFromReferenceLine == 100
class TestGetSiblingOccurrenceCount(NewFile):
"""The pen-icon dispatcher's pre-edit warning depends on this count: zero
means the edit is safe (unique geometry), non-zero means the edit will
silently mutate other instances sharing the same resolved body rep."""
def _make_body_subcontext(self, ifc: ifcopenshell.file) -> ifcopenshell.entity_instance:
import ifcopenshell.api.context
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject", name="Project")
parent = ifcopenshell.api.context.add_context(ifc, context_type="Model")
return ifcopenshell.api.context.add_context(
ifc,
context_type="Model",
context_identifier="Body",
target_view="MODEL_VIEW",
parent=parent,
)
def _create_wall_with_body_rep(
self,
ifc: ifcopenshell.file,
body_subcontext: ifcopenshell.entity_instance,
name: str = "Wall",
) -> ifcopenshell.entity_instance:
wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall", name=name)
rep = ifc.createIfcShapeRepresentation(
ContextOfItems=body_subcontext,
RepresentationIdentifier="Body",
RepresentationType="SweptSolid",
Items=[ifc.createIfcExtrudedAreaSolid()],
)
ifcopenshell.api.geometry.assign_representation(ifc, product=wall, representation=rep)
return wall
def test_returns_zero_when_element_has_no_body_representation(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall")
assert subject.get_sibling_occurrence_count(wall) == 0
def test_returns_zero_when_element_has_unique_body_representation(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
body = self._make_body_subcontext(ifc)
wall = self._create_wall_with_body_rep(ifc, body)
assert subject.get_sibling_occurrence_count(wall) == 0
def test_returns_sibling_count_excluding_self_and_type(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
body = self._make_body_subcontext(ifc)
wall_type = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWallType", name="WAL01")
type_rep = ifc.createIfcShapeRepresentation(
ContextOfItems=body,
RepresentationIdentifier="Body",
RepresentationType="SweptSolid",
Items=[ifc.createIfcExtrudedAreaSolid()],
)
ifcopenshell.api.geometry.assign_representation(ifc, product=wall_type, representation=type_rep)
occurrences = [ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall", name=f"Wall{i}") for i in range(3)]
ifcopenshell.api.type.assign_type(ifc, related_objects=occurrences, relating_type=wall_type)
assert subject.get_sibling_occurrence_count(occurrences[0]) == 2
assert subject.get_sibling_occurrence_count(occurrences[1]) == 2
assert subject.get_sibling_occurrence_count(occurrences[2]) == 2
def test_type_with_occurrences_reports_its_occurrence_count(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
body = self._make_body_subcontext(ifc)
wall_type = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWallType", name="WAL01")
type_rep = ifc.createIfcShapeRepresentation(
ContextOfItems=body,
RepresentationIdentifier="Body",
RepresentationType="SweptSolid",
Items=[ifc.createIfcExtrudedAreaSolid()],
)
ifcopenshell.api.geometry.assign_representation(ifc, product=wall_type, representation=type_rep)
occurrences = [ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall", name=f"Wall{i}") for i in range(2)]
ifcopenshell.api.type.assign_type(ifc, related_objects=occurrences, relating_type=wall_type)
assert subject.get_sibling_occurrence_count(wall_type) == 2
+37
View File
@@ -19,6 +19,9 @@
import bpy
import ifcopenshell
import ifcopenshell.api
import ifcopenshell.api.aggregate
import ifcopenshell.api.feature
import ifcopenshell.api.nest
import ifcopenshell.api.root
import ifcopenshell.api.spatial
import numpy as np
@@ -148,6 +151,40 @@ class TestGetContainer(NewFile):
assert subject.get_container(wall) == site
class TestGetRootElement(NewFile):
def test_a_door_filling_a_wall_is_its_own_root_element(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
wall = ifc.createIfcWall()
opening = ifc.createIfcOpeningElement()
door = ifc.createIfcDoor()
ifcopenshell.api.feature.add_feature(ifc, feature=opening, element=wall)
ifcopenshell.api.feature.add_filling(ifc, opening=opening, element=door)
assert subject.get_root_element(door) == door
def test_an_aggregated_element_walks_to_its_aggregate_root(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
assembly = ifc.createIfcElementAssembly()
beam = ifc.createIfcBeam()
ifcopenshell.api.aggregate.assign_object(ifc, products=[beam], relating_object=assembly)
assert subject.get_root_element(beam) == assembly
def test_a_nested_element_walks_to_its_nest_root(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
parent_task = ifc.createIfcTask()
child_task = ifc.createIfcTask()
ifcopenshell.api.nest.assign_object(ifc, related_objects=[child_task], relating_object=parent_task)
assert subject.get_root_element(child_task) == parent_task
def test_a_loose_element_is_its_own_root(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
wall = ifc.createIfcWall()
assert subject.get_root_element(wall) == wall
class TestGetDecomposedElements(NewFile):
def test_run(self):
ifc = ifcopenshell.file()
+36 -15
View File
@@ -36,7 +36,17 @@ else()
endif()
macro(build_example exe_name)
set(_target_schema "")
set(additional_targets ${ARGN})
list(LENGTH additional_targets _argc)
if(_argc GREATER 0)
list(GET additional_targets 0 _first_arg)
if("${_first_arg}" IN_LIST SCHEMA_VERSIONS)
set(_target_schema ${_first_arg})
list(REMOVE_AT additional_targets 0)
endif()
endif()
add_executable(${exe_name} ${exe_name}.cpp)
if(STANDALONE_PROJECT)
@@ -50,27 +60,38 @@ macro(build_example exe_name)
target_link_libraries(${exe_name} IfcParse ${additional_targets})
set_target_properties(${exe_name} PROPERTIES FOLDER Examples)
endif()
if(_target_schema)
set_target_properties(
${exe_name}
PROPERTIES COMPILE_FLAGS "-DIfcSchema=Ifc${_target_schema}"
)
endif()
unset(_target_schema)
unset(_argc)
unset(_first_arg)
install(TARGETS ${exe_name})
endmacro()
if("4" IN_LIST SCHEMA_VERSIONS)
build_example(arbitrary_open_profile_def)
build_example(triangulated_faceset)
if(SCHEMA_VERSIONS)
list(GET SCHEMA_VERSIONS -1 schema)
endif()
if("2x3" IN_LIST SCHEMA_VERSIONS)
build_example(composite_profile_def)
build_example(csg_primitive)
build_example(ellipse_pies)
build_example(faces)
build_example(ifc_curve_rebar)
build_example(profiles)
build_example(IfcParseExamples)
build_example(arbitrary_open_profile_def ${schema})
build_example(triangulated_faceset ${schema})
if(WITH_OPENCASCADE)
build_example(IfcOpenHouse geometry_serializer)
build_example(IfcAdvancedHouse geometry_serializer)
endif()
build_example(composite_profile_def ${schema})
build_example(csg_primitive ${schema})
build_example(ellipse_pies ${schema})
build_example(faces ${schema})
build_example(ifc_curve_rebar ${schema})
build_example(profiles ${schema})
build_example(IfcParseExamples ${schema})
if(WITH_OPENCASCADE)
build_example(IfcOpenHouse ${schema} geometry_serializer)
build_example(IfcAdvancedHouse ${schema} geometry_serializer)
endif()
if("4x3_add2" IN_LIST SCHEMA_VERSIONS)
+8 -2
View File
@@ -38,9 +38,15 @@
#include <Standard_Version.hxx>
#define IfcSchema Ifc2x3
#include "ifcparse/macros.h"
#include "ifcparse/Ifc2x3.h"
#ifndef IfcSchema
#define IfcSchema Ifc2x3
#endif
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
#include "ifcparse/IfcBaseClass.h"
#include "ifcparse/IfcHierarchyHelper.h"
+41 -26
View File
@@ -35,9 +35,15 @@
#include <Precision.hxx>
#define IfcSchema Ifc2x3
#include "ifcparse/macros.h"
#include "ifcparse/Ifc2x3.h"
#ifndef IfcSchema
#define IfcSchema Ifc2x3
#endif
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
#include "ifcparse/IfcBaseClass.h"
#include "ifcparse/IfcHierarchyHelper.h"
@@ -52,6 +58,11 @@ using namespace std::string_literals;
// Some convenience typedefs and definitions.
typedef IfcParse::IfcGlobalId guid;
typedef std::pair<double, double> XY;
#ifdef SCHEMA_HAS_IfcPresentationStyleAssignment
typedef IfcSchema::IfcPresentationStyleAssignment surface_style_t;
#else
typedef IfcSchema::IfcPresentationStyle surface_style_t;
#endif
boost::none_t const null = boost::none;
// The creation of Nurbs-surface for the IfcSite mesh, to be implemented lateron
@@ -74,7 +85,7 @@ int main() {
0, // ObjectPlacement
0, // Representation
null // Tag
#ifdef USE_IFC4
#ifdef SCHEMA_IfcWall_HAS_PredefinedType
, IfcSchema::IfcWallTypeEnum::IfcWallType_STANDARD
#endif
);
@@ -105,7 +116,7 @@ int main() {
south_wall->setObjectPlacement(file.addLocalPlacement(storey_placement));
// A pale white colour is assigned to the wall.
IfcSchema::IfcPresentationStyleAssignment* wall_colour = setSurfaceColour(file, south_wall_shape, 0.75, 0.73, 0.68);
surface_style_t* wall_colour = setSurfaceColour(file, south_wall_shape, 0.75, 0.73, 0.68);
// Now create a footing for the wall to rest on.
IfcSchema::IfcFooting* footing = new IfcSchema::IfcFooting(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(),
@@ -119,7 +130,7 @@ int main() {
footing->setRepresentation(file.addBox(10100, 5460, 2000));
footing->setObjectPlacement(file.addLocalPlacement(storey_placement, 0, 2500, -2000));
// The footing will have a dark gray colour
IfcSchema::IfcPresentationStyleAssignment* footing_colour = setSurfaceColour(file,footing->Representation(), 0.26, 0.22, 0.18);
surface_style_t* footing_colour = setSurfaceColour(file,footing->Representation(), 0.26, 0.22, 0.18);
// IFC has two ways to apply boolean operations to geometry. IfcBooleanResults are commonly used
// to clip geometry to a surface, for example to a slanted roof. For openings that are filled
@@ -129,7 +140,7 @@ int main() {
IfcSchema::IfcOpeningElement* west_opening = new IfcSchema::IfcOpeningElement(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(),
null, null, null, file.addLocalPlacement(south_wall->ObjectPlacement(), -2500, 0, 400),
file.addBox(6000, 3630, 1600), null
#ifdef USE_IFC4
#ifdef SCHEMA_IfcOpeningElement_HAS_PredefinedType
, IfcSchema::IfcOpeningElementTypeEnum::IfcOpeningElementType_OPENING
#endif
);
@@ -144,7 +155,7 @@ int main() {
IfcSchema::IfcOpeningElement* south_opening = new IfcSchema::IfcOpeningElement(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(),
null, null, null, file.addLocalPlacement(storey_placement, 3000, 0, 400),
file.addBox(1860, 3000, 1600), null
#ifdef USE_IFC4
#ifdef SCHEMA_IfcOpeningElement_HAS_PredefinedType
, IfcSchema::IfcOpeningElementTypeEnum::IfcOpeningElementType_OPENING
#endif
);
@@ -194,7 +205,7 @@ int main() {
// Copy the south wall to the north
IfcSchema::IfcWallStandardCase* north_wall = new IfcSchema::IfcWallStandardCase(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(), "North wall"s,
null, null, file.addLocalPlacement(storey_placement, 0, 5000, 0), file.addAxisBox(10000, 360, 3000), null
#ifdef USE_IFC4
#ifdef SCHEMA_IfcWall_HAS_PredefinedType
, IfcSchema::IfcWallTypeEnum::IfcWallType_STANDARD
#endif
);
@@ -226,7 +237,7 @@ int main() {
// Now create a wall on the east of the building, again starting with just a box shape
IfcSchema::IfcWallStandardCase* east_wall = new IfcSchema::IfcWallStandardCase(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(),
"East wall"s, null, null, file.addLocalPlacement(storey_placement, 4820, 2500, 0, 0, 0, 1, 0, 1, 0), clipped_wall_body_reps[0], null
#ifdef USE_IFC4
#ifdef SCHEMA_IfcWall_HAS_PredefinedType
, IfcSchema::IfcWallTypeEnum::IfcWallType_STANDARD
#endif
);
@@ -235,7 +246,7 @@ int main() {
// The east wall is copied to the west location of the house
IfcSchema::IfcWallStandardCase* west_wall = new IfcSchema::IfcWallStandardCase(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(),
"West wall"s, null, null, file.addLocalPlacement(storey_placement, -4820, 2500, 0, 0, 0, 1, 0, -1, 0), clipped_wall_body_reps[1], null
#ifdef USE_IFC4
#ifdef SCHEMA_IfcWall_HAS_PredefinedType
, IfcSchema::IfcWallTypeEnum::IfcWallType_STANDARD
#endif
);
@@ -252,7 +263,7 @@ int main() {
IfcSchema::IfcOpeningElement* west_opening_copy = new IfcSchema::IfcOpeningElement(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(),
null, null, null, file.addLocalPlacement(west_wall->ObjectPlacement(), 2500, -2500+4820, 400, 0, 0, 1, 0, 1, 0),
file.addBox(6000, 3630, 1600), null
#ifdef USE_IFC4
#ifdef SCHEMA_IfcOpeningElement_HAS_PredefinedType
, IfcSchema::IfcOpeningElementTypeEnum::IfcOpeningElementType_OPENING
#endif
);
@@ -274,7 +285,7 @@ int main() {
IfcSchema::IfcProperty::list::ptr properties(new IfcSchema::IfcProperty::list);
properties->push(new IfcSchema::IfcPropertySingleValue("TotalArea", null, new IfcSchema::IfcAreaMeasure(site_area), 0));
IfcSchema::IfcPropertySet* pset = new IfcSchema::IfcPropertySet(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(), "Pset_SiteCommon"s, null, properties);
#ifdef USE_IFC4
#ifdef SCHEMA_HAS_IfcDefinitionSelect
IfcSchema::IfcObjectDefinition::list::ptr related_objs(new IfcSchema::IfcObjectDefinition::list);
#else
IfcSchema::IfcObject::list::ptr related_objs(new IfcSchema::IfcObject::list);
@@ -297,7 +308,7 @@ int main() {
// Some BIM authoring applications, such as Autodesk Revit, ignore the geometrical representation
// by and large and construct native walls using the layer thickness and reference line offset
// provided here.
#ifdef USE_IFC4
#ifdef SCHEMA_IfcMaterial_HAS_Description
IfcSchema::IfcMaterial* material = new IfcSchema::IfcMaterial("Brick", null, null);
#else
IfcSchema::IfcMaterial* material = new IfcSchema::IfcMaterial("Brick");
@@ -306,7 +317,7 @@ int main() {
material,
360,
null
#ifdef USE_IFC4
#ifdef SCHEMA_IfcMaterialLayer_HAS_Name
, null
, null
, null
@@ -318,7 +329,7 @@ int main() {
IfcSchema::IfcMaterialLayerSet* layer_set = new IfcSchema::IfcMaterialLayerSet(
layers,
"Wall"s
#ifdef USE_IFC4
#ifdef SCHEMA_IfcMaterialLayerSet_HAS_Description
, null
#endif
);
@@ -327,7 +338,7 @@ int main() {
IfcSchema::IfcLayerSetDirectionEnum::IfcLayerSetDirection_AXIS2,
IfcSchema::IfcDirectionSenseEnum::IfcDirectionSense_POSITIVE,
-180
#ifdef USE_IFC4
#ifdef SCHEMA_IfcMaterialLayerSetUsage_HAS_ReferenceExtent
, null
#endif
);
@@ -337,7 +348,7 @@ int main() {
file.getSingle<IfcSchema::IfcOwnerHistory>(),
null,
null,
#ifdef USE_IFC4
#ifdef SCHEMA_HAS_IfcDefinitionSelect
file.instances_by_type<IfcSchema::IfcWallStandardCase>()->as<IfcSchema::IfcDefinitionSelect>(),
#else
file.instances_by_type<IfcSchema::IfcWallStandardCase>()->as<IfcSchema::IfcRoot>(),
@@ -362,7 +373,7 @@ int main() {
IfcSchema::IfcStairFlight* stair = new IfcSchema::IfcStairFlight(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(),
null, null, null, file.addLocalPlacement(storey_placement, 5050, 1000, 0, 0, 1, 0, 1, 0, 0),
file.addExtrudedPolyline(stair_points, 1200), null, 2, 2, 0.2, 0.25
#ifdef USE_IFC4
#ifdef SCHEMA_IfcStairFlight_HAS_PredefinedType
, IfcSchema::IfcStairFlightTypeEnum::IfcStairFlightType_STRAIGHT
#endif
);
@@ -372,7 +383,7 @@ int main() {
IfcSchema::IfcOpeningElement* door_opening = new IfcSchema::IfcOpeningElement(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(),
null, null, null, file.addLocalPlacement(storey_placement, 5000-180, 2500-900, 0), file.addBox(1000, 1000, 2200), null
#ifdef USE_IFC4
#ifdef SCHEMA_IfcOpeningElement_HAS_PredefinedType
, IfcSchema::IfcOpeningElementTypeEnum::IfcOpeningElementType_OPENING
#endif
);
@@ -384,7 +395,7 @@ int main() {
// which constitute the door and its frame.
IfcSchema::IfcDoor* door = new IfcSchema::IfcDoor(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(), null, null, null,
file.addLocalPlacement(storey_placement, 4800, 1600, 0, 0, 0, 1, 0, 1, 0), 0, null, 2200, 1000
#ifdef USE_IFC4
#ifdef SCHEMA_IfcDoor_HAS_PredefinedType
, IfcSchema::IfcDoorTypeEnum::IfcDoorType_DOOR
, IfcSchema::IfcDoorTypeOperationEnum::IfcDoorTypeOperation_SINGLE_SWING_LEFT
, null
@@ -406,11 +417,15 @@ int main() {
setSurfaceColour(file, door->Representation(), 0.9, 0.9, 0.9);
file.addEntity(new IfcSchema::IfcRelFillsElement(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(), null, null, door_opening, door));
#ifdef SCHEMA_HAS_IfcDoorType
IfcSchema::IfcDoorType* door_type = new IfcSchema::IfcDoorType(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(), "Door type"s, null, null, null, null, null, null,
IfcSchema::IfcDoorTypeEnum::IfcDoorType_DOOR, IfcSchema::IfcDoorTypeOperationEnum::IfcDoorTypeOperation_SINGLE_SWING_LEFT, false, null);
file.addRelatedObject<IfcSchema::IfcRelDefinesByType>(door_type, door);
#elif defined(SCHEMA_HAS_IfcDoorStyle)
IfcSchema::IfcDoorStyle* door_style = new IfcSchema::IfcDoorStyle(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(), "Door type"s, null, null, null, null, null,
IfcSchema::IfcDoorStyleOperationEnum::IfcDoorStyleOperation_SINGLE_SWING_LEFT, IfcSchema::IfcDoorStyleConstructionEnum::IfcDoorStyleConstruction_WOOD, false, false);
// NOTE: typing by IfcDoorStyle will cause validation errors in IFC4+ but it's allowed for backwards compatibility
// better to use IfcDoorType in the actual use case
file.addRelatedObject<IfcSchema::IfcRelDefinesByType>(door_style, door);
#endif
// Surface styles are assigned to representation items, hence there is no real limitation to
// assign different colours within the same representation. However, some viewers have
@@ -436,7 +451,7 @@ int main() {
frame_representations->push(vertical_bar); // Add another reference to the vertical bar created above
// The beams all have the same surface style assigned
IfcSchema::IfcPresentationStyleAssignment* frame_style = 0;
surface_style_t* frame_style = 0;
for (IfcSchema::IfcShapeRepresentation::list::it i = frame_representations->begin(); i != frame_representations->end(); i += 2) {
if (frame_style) {
setSurfaceColour(file,*i, frame_style);
@@ -461,7 +476,7 @@ int main() {
IfcSchema::IfcLocalPlacement* place = *it;
IfcSchema::IfcWindow* window = new IfcSchema::IfcWindow(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(),
null, null, null, place, 0, null, 1600, 1860
#ifdef USE_IFC4
#ifdef SCHEMA_IfcWindow_HAS_PredefinedType
, IfcSchema::IfcWindowTypeEnum::IfcWindowType_WINDOW
, IfcSchema::IfcWindowTypePartitioningEnum::IfcWindowTypePartitioning_SINGLE_PANEL
, null
@@ -489,7 +504,7 @@ int main() {
{
IfcSchema::IfcMember* frame_part = new IfcSchema::IfcMember(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(),
null, null, null, *frame_placement, file.addMappedItem(*frame_representation), null
#ifdef USE_IFC4
#ifdef SCHEMA_IfcMember_HAS_PredefinedType
, IfcSchema::IfcMemberTypeEnum::IfcMemberType_MULLION
#endif
);
@@ -501,7 +516,7 @@ int main() {
// Add the glass plate to the list of parts
IfcSchema::IfcPlate* glass_part = new IfcSchema::IfcPlate(guid(), file.getSingle<IfcSchema::IfcOwnerHistory>(), null,
null, null, file.addLocalPlacement(storey_placement, 930, 45, 90), file.addBox(1680, 10, 1420), null
#ifdef USE_IFC4
#ifdef SCHEMA_IfcPlate_HAS_PredefinedType
, IfcSchema::IfcPlateTypeEnum::IfcPlateType_SHEET
#endif
);
+14 -31
View File
@@ -17,12 +17,16 @@
* *
********************************************************************************/
// TODO: Multiple schemas
#include "ifcparse/macros.h"
#ifndef IfcSchema
#define IfcSchema Ifc2x3
#endif
#include "ifcparse/IfcFile.h"
#include "ifcparse/IfcLogger.h"
#include "ifcparse/Ifc2x3.h"
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
#include <boost/preprocessor/stringize.hpp>
#include <boost/preprocessor/seq/for_each.hpp>
@@ -39,33 +43,6 @@
static_assert(false, "A boost preprocessor sequence of schema identifiers is needed for this file to compile.");
#endif
// @todo duplicated with Kernel.h
// @tfk A macro cannot define an include (I think), so here we can't
// loop over the sequence of schema identifiers, but rather we have
// unroll the loop with at least the amount of schemas we'd like support
// for and then overflow into an existing empty include file.
#define INCLUDE_SCHEMA(n) \
BOOST_PP_IIF(BOOST_PP_GREATER(BOOST_PP_SEQ_SIZE(SCHEMA_SEQ), n), BOOST_PP_STRINGIZE(../ifcparse/BOOST_PP_CAT(Ifc,BOOST_PP_SEQ_ELEM(BOOST_PP_MIN(n, BOOST_PP_SEQ_SIZE(BOOST_PP_SEQ_POP_BACK(SCHEMA_SEQ))),SCHEMA_SEQ)).h), "../ifcgeom/empty.h")
#include INCLUDE_SCHEMA(0)
#include INCLUDE_SCHEMA(1)
#include INCLUDE_SCHEMA(2)
#include INCLUDE_SCHEMA(3)
#include INCLUDE_SCHEMA(4)
#include INCLUDE_SCHEMA(5)
#include INCLUDE_SCHEMA(6)
#include INCLUDE_SCHEMA(7)
#include INCLUDE_SCHEMA(8)
#include INCLUDE_SCHEMA(9)
#include INCLUDE_SCHEMA(10)
#include INCLUDE_SCHEMA(11)
#include INCLUDE_SCHEMA(12)
#include INCLUDE_SCHEMA(13)
#include INCLUDE_SCHEMA(14)
#include INCLUDE_SCHEMA(15)
#include <iomanip>
#if USE_VLD
@@ -80,6 +57,12 @@ struct is_ifc4_or_higher<T, std::void_t<decltype(T::IfcMaterialDefinition)>> : s
typedef std::map<std::string, std::map<std::string, std::string>> element_properties;
#ifdef SCHEMA_HAS_IfcBuildingElement
typedef IfcSchema::IfcBuildingElement element_t;
#else
typedef IfcSchema::IfcBuiltElement element_t;
#endif
std::string format_string(const AttributeValue& argument) {
// Argument is a runtime tagged variant for the various data types in a IFC model,
// in this particular case we only care about flattening it to a string.
@@ -234,7 +217,7 @@ int main(int argc, char** argv) {
}
// Redirect the output (both progress and log) to stdout
Logger::SetOutput(&std::cout, &std::cout);
Logger::Root().SetOutput(&std::cout, &std::cout);
// Parse the IFC file provided in argv[1]
IfcParse::IfcFile file(argv[1]);
@@ -259,7 +242,7 @@ int main(int argc, char** argv) {
// we need to cast them to IfcWindows. Since these properties
// are optional we need to make sure the properties are
// defined for the window in question before accessing them.
IfcSchema::IfcBuildingElement::list::ptr elements = file.instances_by_type<IfcSchema::IfcBuildingElement>();
auto elements = file.instances_by_type<element_t>();
std::cout << "Found " << elements->size() << " elements in " << argv[1] << ":" << std::endl;
+9 -2
View File
@@ -28,8 +28,15 @@
#include <iostream>
#include <fstream>
#include "ifcparse/macros.h"
#ifndef IfcSchema
#define IfcSchema Ifc4
#include "ifcparse/Ifc4.h"
#endif
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
#include "ifcparse/IfcHierarchyHelper.h"
typedef std::string S;
@@ -138,4 +145,4 @@ int main(int argc, char** argv) {
std::ofstream f(filename);
f << file;
}
}
+40 -9
View File
@@ -27,14 +27,45 @@
#include <iostream>
#include <fstream>
#include "ifcparse/macros.h"
#ifndef IfcSchema
#define IfcSchema Ifc2x3
#include "ifcparse/Ifc2x3.h"
#endif
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
#include "ifcparse/IfcHierarchyHelper.h"
typedef std::string S;
typedef IfcParse::IfcGlobalId guid;
boost::none_t const null = boost::none;
#ifdef SCHEMA_IfcIShapeProfileDef_HAS_FlangeEdgeRadius
#define IFC_I_SHAPE_PROFILE_DEF_EXTRA_ARGS , null, null
#else
#define IFC_I_SHAPE_PROFILE_DEF_EXTRA_ARGS
#endif
#ifdef SCHEMA_IfcLShapeProfileDef_HAS_CentreOfGravityInX
#define IFC_L_SHAPE_PROFILE_DEF_EXTRA_ARGS , null, null
#else
#define IFC_L_SHAPE_PROFILE_DEF_EXTRA_ARGS
#endif
#ifdef SCHEMA_IfcTShapeProfileDef_HAS_CentreOfGravityInY
#define IFC_T_SHAPE_PROFILE_DEF_EXTRA_ARGS , null
#else
#define IFC_T_SHAPE_PROFILE_DEF_EXTRA_ARGS
#endif
#ifdef SCHEMA_IfcCShapeProfileDef_HAS_CentreOfGravityInX
#define IFC_C_SHAPE_PROFILE_DEF_EXTRA_ARGS , null
#else
#define IFC_C_SHAPE_PROFILE_DEF_EXTRA_ARGS
#endif
int main(int argc, char** argv) {
const char filename[] = "composite_profile_def.ifc";
IfcHierarchyHelper<IfcSchema> file;
@@ -49,21 +80,21 @@ int main(int argc, char** argv) {
IfcSchema::IfcCartesianTransformationOperator2D* transform1 = new IfcSchema::IfcCartesianTransformationOperator2D(file.addDoublet<IfcSchema::IfcDirection>(1, 0), file.addDoublet<IfcSchema::IfcDirection>(0, -1), file.addDoublet<IfcSchema::IfcCartesianPoint>(40, 0), null);
IfcSchema::IfcCartesianTransformationOperator2D* transform2 = new IfcSchema::IfcCartesianTransformationOperator2D(file.addDoublet<IfcSchema::IfcDirection>(0, -1), file.addDoublet<IfcSchema::IfcDirection>(1, 0), file.addDoublet<IfcSchema::IfcCartesianPoint>(40, 0), 0.3);
IfcSchema::IfcProfileDef* p1 = new Ifc2x3::IfcIShapeProfileDef(
IfcSchema::IfcProfileDef* p1 = new IfcSchema::IfcIShapeProfileDef(
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
null, file.addPlacement2d(), 25.0, 50.0, 5.0, 5.0, 2.0);
null, file.addPlacement2d(), 25.0, 50.0, 5.0, 5.0, 2.0 IFC_I_SHAPE_PROFILE_DEF_EXTRA_ARGS);
IfcSchema::IfcProfileDef* p2 = new Ifc2x3::IfcLShapeProfileDef(
IfcSchema::IfcProfileDef* p2 = new IfcSchema::IfcLShapeProfileDef(
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
null, file.addPlacement2d(), 50.0, 25.0, 5.0, 1.0, 2.0, 2.0, null, null);
null, file.addPlacement2d(), 50.0, 25.0, 5.0, 1.0, 2.0, 2.0 IFC_L_SHAPE_PROFILE_DEF_EXTRA_ARGS);
IfcSchema::IfcProfileDef* p3 = new Ifc2x3::IfcTShapeProfileDef(
IfcSchema::IfcProfileDef* p3 = new IfcSchema::IfcTShapeProfileDef(
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
null, file.addPlacement2d(), 50.0, 40.0, 10.0, 10.0, 3.0, 2.0, 1.0, 2.0, 2.0, null);
null, file.addPlacement2d(), 50.0, 40.0, 10.0, 10.0, 3.0, 2.0, 1.0, 2.0, 2.0 IFC_T_SHAPE_PROFILE_DEF_EXTRA_ARGS);
IfcSchema::IfcProfileDef* p4 = new Ifc2x3::IfcCShapeProfileDef(
IfcSchema::IfcProfileDef* p4 = new IfcSchema::IfcCShapeProfileDef(
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
null, file.addPlacement2d(80.), 50.0, 25.0, 5.0, 10.0, 2.0, null);
null, file.addPlacement2d(80.), 50.0, 25.0, 5.0, 10.0, 2.0 IFC_C_SHAPE_PROFILE_DEF_EXTRA_ARGS);
file.addEntity(p2);
file.addEntity(p3);
+8 -1
View File
@@ -27,8 +27,15 @@
#include <iostream>
#include <fstream>
#include "ifcparse/macros.h"
#ifndef IfcSchema
#define IfcSchema Ifc2x3
#include "ifcparse/Ifc2x3.h"
#endif
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
#include "ifcparse/IfcHierarchyHelper.h"
typedef std::string S;
+28 -15
View File
@@ -27,14 +27,27 @@
#include <iostream>
#include <fstream>
#include "ifcparse/macros.h"
#ifndef IfcSchema
#define IfcSchema Ifc2x3
#include "ifcparse/Ifc2x3.h"
#endif
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
#include "ifcparse/IfcHierarchyHelper.h"
typedef std::string S;
typedef IfcParse::IfcGlobalId guid;
boost::none_t const null = boost::none;
#ifdef SCHEMA_HAS_IfcSegment
typedef IfcSchema::IfcSegment curve_segment_tt;
#else
typedef IfcSchema::IfcCompositeCurveSegment curve_segment_tt;
#endif
typedef struct {
double r1;
double r2;
@@ -54,44 +67,44 @@ void create_testcase_for(IfcHierarchyHelper<IfcSchema>& file, const EllipsePie&
std::vector<double> coords2(flt2, flt2 + 2);
std::vector<double> coords3(flt3, flt3 + 2);
Ifc2x3::IfcCartesianPoint* p1 = new Ifc2x3::IfcCartesianPoint(coords1);
Ifc2x3::IfcCartesianPoint* p2 = new Ifc2x3::IfcCartesianPoint(coords2);
Ifc2x3::IfcCartesianPoint* p3 = new Ifc2x3::IfcCartesianPoint(coords3);
IfcSchema::IfcCartesianPoint* p1 = new IfcSchema::IfcCartesianPoint(coords1);
IfcSchema::IfcCartesianPoint* p2 = new IfcSchema::IfcCartesianPoint(coords2);
IfcSchema::IfcCartesianPoint* p3 = new IfcSchema::IfcCartesianPoint(coords3);
Ifc2x3::IfcCartesianPoint::list::ptr points(new Ifc2x3::IfcCartesianPoint::list());
IfcSchema::IfcCartesianPoint::list::ptr points(new IfcSchema::IfcCartesianPoint::list());
points->push(p3);
points->push(p1);
points->push(p2);
file.addEntities(points->generalize());
Ifc2x3::IfcEllipse* ellipse = new Ifc2x3::IfcEllipse(file.addPlacement2d(), pie.r1, pie.r2);
IfcSchema::IfcEllipse* ellipse = new IfcSchema::IfcEllipse(file.addPlacement2d(), pie.r1, pie.r2);
file.addEntity(ellipse);
aggregate_of_instance::ptr trim1(new aggregate_of_instance);
aggregate_of_instance::ptr trim2(new aggregate_of_instance);
if (pref == IfcSchema::IfcTrimmingPreference::IfcTrimmingPreference_PARAMETER) {
trim1->push(new Ifc2x3::IfcParameterValue(pie.t1));
trim2->push(new Ifc2x3::IfcParameterValue(pie.t2));
trim1->push(new IfcSchema::IfcParameterValue(pie.t1));
trim2->push(new IfcSchema::IfcParameterValue(pie.t2));
} else {
trim1->push(p2);
trim2->push(p3);
}
Ifc2x3::IfcTrimmedCurve* trim = new Ifc2x3::IfcTrimmedCurve(ellipse, trim1->as<IfcSchema::IfcTrimmingSelect>(), trim2->as<IfcSchema::IfcTrimmingSelect>(), true, pref);
IfcSchema::IfcTrimmedCurve* trim = new IfcSchema::IfcTrimmedCurve(ellipse, trim1->as<IfcSchema::IfcTrimmingSelect>(), trim2->as<IfcSchema::IfcTrimmingSelect>(), true, pref);
file.addEntity(trim);
Ifc2x3::IfcCompositeCurveSegment::list::ptr segments(new Ifc2x3::IfcCompositeCurveSegment::list());
Ifc2x3::IfcCompositeCurveSegment* s2 = new Ifc2x3::IfcCompositeCurveSegment(Ifc2x3::IfcTransitionCode::IfcTransitionCode_CONTINUOUS, true, trim);
curve_segment_tt::list::ptr segments(new curve_segment_tt::list());
IfcSchema::IfcCompositeCurveSegment* s2 = new IfcSchema::IfcCompositeCurveSegment(IfcSchema::IfcTransitionCode::IfcTransitionCode_CONTINUOUS, true, trim);
Ifc2x3::IfcPolyline* poly = new Ifc2x3::IfcPolyline(points);
IfcSchema::IfcPolyline* poly = new IfcSchema::IfcPolyline(points);
file.addEntity(poly);
Ifc2x3::IfcCompositeCurveSegment* s1 = new Ifc2x3::IfcCompositeCurveSegment(Ifc2x3::IfcTransitionCode::IfcTransitionCode_CONTINUOUS, true, poly);
IfcSchema::IfcCompositeCurveSegment* s1 = new IfcSchema::IfcCompositeCurveSegment(IfcSchema::IfcTransitionCode::IfcTransitionCode_CONTINUOUS, true, poly);
segments->push(s1);
segments->push(s2);
file.addEntities(segments->generalize());
Ifc2x3::IfcCompositeCurve* ccurve = new Ifc2x3::IfcCompositeCurve(segments, false);
Ifc2x3::IfcArbitraryClosedProfileDef* profile = new Ifc2x3::IfcArbitraryClosedProfileDef(Ifc2x3::IfcProfileTypeEnum::IfcProfileType_AREA, null, ccurve);
IfcSchema::IfcCompositeCurve* ccurve = new IfcSchema::IfcCompositeCurve(segments, false);
IfcSchema::IfcArbitraryClosedProfileDef* profile = new IfcSchema::IfcArbitraryClosedProfileDef(IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA, null, ccurve);
file.addEntity(ccurve);
file.addEntity(profile);
+9 -2
View File
@@ -24,8 +24,15 @@
********************************************************************************/
#include <fstream>
#include "ifcparse/macros.h"
#ifndef IfcSchema
#define IfcSchema Ifc2x3
#include "ifcparse/Ifc2x3.h"
#endif
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
#include "ifcparse/IfcHierarchyHelper.h"
typedef std::string S;
@@ -205,4 +212,4 @@ int main(int argc, char** argv) {
file.header().file_name()->setname(filename);
std::ofstream f(filename);
f << file;
}
}
+22 -3
View File
@@ -27,8 +27,15 @@
#include <string>
#include <fstream>
#include "ifcparse/macros.h"
#ifndef IfcSchema
#define IfcSchema Ifc2x3
#include "ifcparse/Ifc2x3.h"
#endif
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
#include "ifcparse/IfcHierarchyHelper.h"
#include <boost/math/constants/constants.hpp>
@@ -38,6 +45,18 @@ typedef std::string S;
typedef IfcParse::IfcGlobalId guid;
boost::none_t const null = boost::none;
#ifdef SCHEMA_HAS_IfcSegment
typedef IfcSchema::IfcSegment curve_segment_t;
#else
typedef IfcSchema::IfcCompositeCurveSegment curve_segment_t;
#endif
#ifdef SCHEMA_IfcReinforcingBar_HAS_PredefinedType
#define IFC_REINFORCING_BAR_TYPE IfcSchema::IfcReinforcingBarTypeEnum::IfcReinforcingBarType_LIGATURE
#else
#define IFC_REINFORCING_BAR_TYPE IfcSchema::IfcReinforcingBarRoleEnum::IfcReinforcingBarRole_LIGATURE
#endif
void create_curve_rebar(IfcHierarchyHelper<IfcSchema>& file)
{
int dia = 24;
@@ -52,14 +71,14 @@ void create_curve_rebar(IfcHierarchyHelper<IfcSchema>& file)
dia, //diameter
crossSectionarea, //crossSectionarea = math.pi*(12.0/2)**2
0,
IfcSchema::IfcReinforcingBarRoleEnum::IfcReinforcingBarRoleEnum::IfcReinforcingBarRole_LIGATURE,
IFC_REINFORCING_BAR_TYPE,
IfcSchema::IfcReinforcingBarSurfaceEnum::IfcReinforcingBarSurfaceEnum::IfcReinforcingBarSurface_PLAIN //PLAIN or TEXTURED
);
file.addBuildingProduct(rebar);
rebar->setOwnerHistory(file.getSingle<IfcSchema::IfcOwnerHistory>());
IfcSchema::IfcCompositeCurveSegment::list::ptr segments(new IfcSchema::IfcCompositeCurveSegment::list());
curve_segment_t::list::ptr segments(new curve_segment_t::list());
IfcSchema::IfcCartesianPoint* p1 = file.addTriplet<IfcSchema::IfcCartesianPoint>(0, 0, 1000.);
IfcSchema::IfcCartesianPoint* p2 = file.addTriplet<IfcSchema::IfcCartesianPoint>(0, 0, 0);
+62 -19
View File
@@ -27,14 +27,57 @@
#include <iostream>
#include <fstream>
#include "ifcparse/macros.h"
#ifndef IfcSchema
#define IfcSchema Ifc2x3
#include "ifcparse/Ifc2x3.h"
#endif
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
#include "ifcparse/IfcHierarchyHelper.h"
typedef std::string S;
typedef IfcParse::IfcGlobalId guid;
boost::none_t const null = boost::none;
#ifdef SCHEMA_IfcUShapeProfileDef_HAS_CentreOfGravityInX
#define IFC_U_SHAPE_PROFILE_DEF_EXTRA_ARGS , null
#else
#define IFC_U_SHAPE_PROFILE_DEF_EXTRA_ARGS
#endif
#ifdef SCHEMA_IfcTShapeProfileDef_HAS_CentreOfGravityInY
#define IFC_T_SHAPE_PROFILE_DEF_EXTRA_ARGS , null
#else
#define IFC_T_SHAPE_PROFILE_DEF_EXTRA_ARGS
#endif
#ifdef SCHEMA_IfcIShapeProfileDef_HAS_FlangeEdgeRadius
#define IFC_I_SHAPE_PROFILE_DEF_EXTRA_ARGS , null, null
#else
#define IFC_I_SHAPE_PROFILE_DEF_EXTRA_ARGS
#endif
#ifdef SCHEMA_IfcAsymmetricIShapeProfileDef_HAS_BottomFlangeSlope
#define IFC_ASYMMETRIC_I_SHAPE_PROFILE_DEF_EXTRA_ARGS , null, null, null
#else
#define IFC_ASYMMETRIC_I_SHAPE_PROFILE_DEF_EXTRA_ARGS
#endif
#ifdef SCHEMA_IfcLShapeProfileDef_HAS_CentreOfGravityInX
#define IFC_L_SHAPE_PROFILE_DEF_EXTRA_ARGS , null, null
#else
#define IFC_L_SHAPE_PROFILE_DEF_EXTRA_ARGS
#endif
#ifdef SCHEMA_IfcCShapeProfileDef_HAS_CentreOfGravityInX
#define IFC_C_SHAPE_PROFILE_DEF_EXTRA_ARGS , null
#else
#define IFC_C_SHAPE_PROFILE_DEF_EXTRA_ARGS
#endif
void create_testcase_for(IfcSchema::IfcProfileDef::list::ptr profiles) {
IfcSchema::IfcProfileDef* profile = *profiles->begin();
const std::string& profile_type = profile->declaration().name();
@@ -88,31 +131,31 @@ int main(int argc, char** argv) {
{ IfcSchema::IfcProfileDef::list::ptr profiles (new IfcSchema::IfcProfileDef::list);
profiles->push(new IfcSchema::IfcUShapeProfileDef(
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
null, 0, 50.0, 25.0, 5.0, 5.0, null, null, null, null));
null, 0, 50.0, 25.0, 5.0, 5.0, null, null, null IFC_U_SHAPE_PROFILE_DEF_EXTRA_ARGS));
profiles->push(new IfcSchema::IfcUShapeProfileDef(
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
null, 0, 50.0, 25.0, 5.0, 5.0, 2.0, 2.0, null, null));
null, 0, 50.0, 25.0, 5.0, 5.0, 2.0, 2.0, null IFC_U_SHAPE_PROFILE_DEF_EXTRA_ARGS));
profiles->push(new IfcSchema::IfcUShapeProfileDef(
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
null, 0, 50.0, 25.0, 5.0, 5.0, null, null, 4.0, null));
null, 0, 50.0, 25.0, 5.0, 5.0, null, null, 4.0 IFC_U_SHAPE_PROFILE_DEF_EXTRA_ARGS));
profiles->push(new IfcSchema::IfcUShapeProfileDef(
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
null, 0, 50.0, 25.0, 5.0, 5.0, 1.0, 3.0, 6.0, null));
null, 0, 50.0, 25.0, 5.0, 5.0, 1.0, 3.0, 6.0 IFC_U_SHAPE_PROFILE_DEF_EXTRA_ARGS));
create_testcase_for(profiles); }
{ IfcSchema::IfcProfileDef::list::ptr profiles (new IfcSchema::IfcProfileDef::list);
profiles->push(new IfcSchema::IfcTShapeProfileDef(
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
null, 0, 50.0, 25.0, 5.0, 5.0, null, null, null, null, null, null));
null, 0, 50.0, 25.0, 5.0, 5.0, null, null, null, null, null IFC_T_SHAPE_PROFILE_DEF_EXTRA_ARGS));
profiles->push(new IfcSchema::IfcTShapeProfileDef(
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
null, 0, 50.0, 25.0, 5.0, 5.0, 2.0, 2.0, 2.0, null, null, null));
null, 0, 50.0, 25.0, 5.0, 5.0, 2.0, 2.0, 2.0, null, null IFC_T_SHAPE_PROFILE_DEF_EXTRA_ARGS));
profiles->push(new IfcSchema::IfcTShapeProfileDef(
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
null, 0, 50.0, 25.0, 5.0, 5.0, null, null, null, 2.0, 2.0, null));
null, 0, 50.0, 25.0, 5.0, 5.0, null, null, null, 2.0, 2.0 IFC_T_SHAPE_PROFILE_DEF_EXTRA_ARGS));
profiles->push(new IfcSchema::IfcTShapeProfileDef(
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
null, 0, 50.0, 25.0, 5.0, 5.0, 3.0, 2.0, 1.0, 2.0, 2.0, null));
null, 0, 50.0, 25.0, 5.0, 5.0, 3.0, 2.0, 1.0, 2.0, 2.0 IFC_T_SHAPE_PROFILE_DEF_EXTRA_ARGS));
create_testcase_for(profiles); }
{ IfcSchema::IfcProfileDef::list::ptr profiles (new IfcSchema::IfcProfileDef::list);
@@ -133,40 +176,40 @@ int main(int argc, char** argv) {
null, 0, 15.0, 25.0));
create_testcase_for(profiles); }
{ IfcSchema::IfcProfileDef::list::ptr profiles (new IfcSchema::IfcProfileDef::list);
{ IfcSchema::IfcProfileDef::list::ptr profiles (new IfcSchema::IfcProfileDef::list);
profiles->push(new IfcSchema::IfcIShapeProfileDef(
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
null, 0, 25.0, 50.0, 5.0, 5.0, null));
null, 0, 25.0, 50.0, 5.0, 5.0, null IFC_I_SHAPE_PROFILE_DEF_EXTRA_ARGS));
profiles->push(new IfcSchema::IfcIShapeProfileDef(
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
null, 0, 25.0, 50.0, 5.0, 5.0, 2.0));
null, 0, 25.0, 50.0, 5.0, 5.0, 2.0 IFC_I_SHAPE_PROFILE_DEF_EXTRA_ARGS));
profiles->push(new IfcSchema::IfcAsymmetricIShapeProfileDef(
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
null, 0, 25.0, 50.0, 5.0, 5.0, 2.0, 20.0, 10.0, 5.0, null));
null, 0, 25.0, 50.0, 5.0, 5.0, 2.0, 20.0, 10.0, 5.0, null IFC_ASYMMETRIC_I_SHAPE_PROFILE_DEF_EXTRA_ARGS));
create_testcase_for(profiles); }
{ IfcSchema::IfcProfileDef::list::ptr profiles (new IfcSchema::IfcProfileDef::list);
profiles->push(new IfcSchema::IfcLShapeProfileDef(
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
null, 0, 50.0, 25.0, 5.0, null, null, null, null, null));
null, 0, 50.0, 25.0, 5.0, null, null, null IFC_L_SHAPE_PROFILE_DEF_EXTRA_ARGS));
profiles->push(new IfcSchema::IfcLShapeProfileDef(
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
null, 0, 50.0, 25.0, 5.0, 2.0, 2.0, null, null, null));
null, 0, 50.0, 25.0, 5.0, 2.0, 2.0, null IFC_L_SHAPE_PROFILE_DEF_EXTRA_ARGS));
profiles->push(new IfcSchema::IfcLShapeProfileDef(
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
null, 0, 50.0, 25.0, 5.0, null, null, 2.0, null, null));
null, 0, 50.0, 25.0, 5.0, null, null, 2.0 IFC_L_SHAPE_PROFILE_DEF_EXTRA_ARGS));
profiles->push(new IfcSchema::IfcLShapeProfileDef(
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
null, 0, 50.0, 25.0, 5.0, 1.0, 2.0, 2.0, null, null));
null, 0, 50.0, 25.0, 5.0, 1.0, 2.0, 2.0 IFC_L_SHAPE_PROFILE_DEF_EXTRA_ARGS));
create_testcase_for(profiles); }
{ IfcSchema::IfcProfileDef::list::ptr profiles (new IfcSchema::IfcProfileDef::list);
profiles->push(new IfcSchema::IfcCShapeProfileDef(
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
null, 0, 50.0, 25.0, 5.0, 10.0, null, null));
null, 0, 50.0, 25.0, 5.0, 10.0, null IFC_C_SHAPE_PROFILE_DEF_EXTRA_ARGS));
profiles->push(new IfcSchema::IfcCShapeProfileDef(
IfcSchema::IfcProfileTypeEnum::IfcProfileType_AREA,
null, 0, 50.0, 25.0, 5.0, 10.0, 2.0, null));
null, 0, 50.0, 25.0, 5.0, 10.0, 2.0 IFC_C_SHAPE_PROFILE_DEF_EXTRA_ARGS));
create_testcase_for(profiles); }
{ IfcSchema::IfcProfileDef::list::ptr profiles (new IfcSchema::IfcProfileDef::list);
+13 -2
View File
@@ -27,8 +27,15 @@
#include <fstream>
#include <optional>
#include "ifcparse/macros.h"
#ifndef IfcSchema
#define IfcSchema Ifc4
#include "ifcparse/Ifc4.h"
#endif
#include INCLUDE_SCHEMA(ifcparse, IfcSchema)
#include INCLUDE_SCHEMA_DEFINITIONS(ifcparse, IfcSchema)
#include "ifcparse/IfcHierarchyHelper.h"
#include "suzanne_geometry.h"
@@ -69,7 +76,11 @@ int main(int argc, char** argv) {
std::vector< std::vector< double > > vertices_vector = create_vector_from_array(vertices, sizeof(vertices) / sizeof(vertices[0]));
std::vector< std::vector< int > > indices_vector = create_vector_from_array(indices, sizeof(indices) / sizeof(indices[0]));
IfcSchema::IfcCartesianPointList3D* coordinates = new IfcSchema::IfcCartesianPointList3D(vertices_vector);
IfcSchema::IfcCartesianPointList3D* coordinates = new IfcSchema::IfcCartesianPointList3D(vertices_vector
#ifdef SCHEMA_IfcCartesianPointList3D_HAS_TagList
, boost::none
#endif
);
IfcSchema::IfcTriangulatedFaceSet* faceset = new IfcSchema::IfcTriangulatedFaceSet(coordinates, null, null, indices_vector, null);
items->push(faceset);
+87 -86
View File
@@ -174,7 +174,7 @@ bool file_exists(const std::string& filename) {
static std::basic_stringstream<path_t::value_type> log_stream;
void write_log(bool);
void fix_quantities(IfcParse::IfcFile&, bool, bool, bool);
void fix_quantities(IfcParse::IfcFile&, bool, bool, bool, Logger& logger = Logger::Root());
std::string format_duration(time_t start, time_t end);
/// @todo make the filters non-global
@@ -204,7 +204,7 @@ size_t read_filters_from_file(const std::string&, inclusion_filter&, inclusion_t
void parse_filter(geom_filter &, const std::vector<std::string>&);
std::vector<IfcGeom::filter_t> setup_filters(const std::vector<geom_filter>&, const std::string&);
bool init_input_file(const std::string& filename, IfcParse::IfcFile*& ifc_file, bool no_progress, bool mmap, bool bypass_properties=false);
bool init_input_file(const std::string& filename, IfcParse::IfcFile*& ifc_file, bool no_progress, bool mmap, bool bypass_properties=false, Logger& logger = Logger::Root());
// from https://stackoverflow.com/questions/31696328/boost-program-options-using-zero-parameter-options-multiple-times
struct verbosity_counter {
@@ -226,6 +226,7 @@ int main(int argc, char** argv) {
typedef po::command_line_parser command_line_parser;
typedef char char_t;
#endif
Logger logger;
inclusion_filter include_filter;
inclusion_traverse_filter include_traverse_filter;
@@ -492,15 +493,15 @@ int main(int argc, char** argv) {
if (num_threads <= 0) {
num_threads = std::thread::hardware_concurrency();
Logger::Notice("Using " + std::to_string(num_threads) + " threads");
logger.Notice("SYS", 7, "Using " + std::to_string(num_threads) + " threads");
}
if (vmap.count("log-format") == 1) {
boost::to_lower(log_format);
if (log_format == "plain") {
Logger::OutputFormat(Logger::FMT_PLAIN);
logger.OutputFormat(Logger::FMT_PLAIN);
} else if (log_format == "json") {
Logger::OutputFormat(Logger::FMT_JSON);
logger.OutputFormat(Logger::FMT_JSON);
} else {
cerr_ << "[Error] --log-format should be either plain or json" << std::endl;
print_usage();
@@ -511,7 +512,7 @@ int main(int argc, char** argv) {
if (!filter_filename.empty()) {
size_t num_filters = read_filters_from_file(IfcUtil::path::to_utf8(filter_filename), include_filter, include_traverse_filter, exclude_filter, exclude_traverse_filter);
if (num_filters) {
Logger::Notice(boost::lexical_cast<std::string>(num_filters) + " filters read from specifified file.");
logger.Notice("SYS", 8, boost::lexical_cast<std::string>(num_filters) + " filters read from specifified file.");
} else {
cerr_ << "[Error] No filters read from specifified file.\n";
return EXIT_FAILURE;
@@ -601,27 +602,27 @@ int main(int argc, char** argv) {
if (vmap.count("log-file")) {
log_fs.open(log_file.c_str(), std::ios::app);
Logger::SetOutput(quiet ? nullptr : &cout_, &log_fs);
logger.SetOutput(quiet ? nullptr : &cout_, &log_fs);
} else {
Logger::SetOutput(quiet ? nullptr : &cout_, vcounter.count > 1 ? &cout_ : &log_stream);
logger.SetOutput(quiet ? nullptr : &cout_, vcounter.count > 1 ? &cout_ : &log_stream);
}
switch (vcounter.count) {
case 0:
Logger::Verbosity(Logger::LOG_ERROR);
logger.Verbosity(Logger::LOG_ERROR);
break;
case 1:
Logger::Verbosity(Logger::LOG_NOTICE);
logger.Verbosity(Logger::LOG_NOTICE);
break;
case 2:
Logger::Verbosity(Logger::LOG_DEBUG);
logger.Verbosity(Logger::LOG_DEBUG);
break;
case 3:
Logger::Verbosity(Logger::LOG_PERF);
logger.Verbosity(Logger::LOG_PERF);
break;
case 4:
Logger::Verbosity(Logger::LOG_PERF);
Logger::PrintPerformanceStatsOnElement(true);
logger.Verbosity(Logger::LOG_PERF);
logger.PrintPerformanceStatsOnElement(true);
break;
}
@@ -665,52 +666,52 @@ int main(int argc, char** argv) {
if (output_extension == XML || output_extension == JSON) {
int exit_code = EXIT_FAILURE;
try {
if (init_input_file(IfcUtil::path::to_utf8(input_filename), ifc_file, no_progress || quiet, mmap)) {
if (init_input_file(IfcUtil::path::to_utf8(input_filename), ifc_file, no_progress || quiet, mmap, false, logger)) {
time_t start, end;
time(&start);
if (output_extension == XML) {
XmlSerializer s(ifc_file, IfcUtil::path::to_utf8(output_temp_filename));
Logger::Status("Writing XML output...");
XmlSerializer s(ifc_file, IfcUtil::path::to_utf8(output_temp_filename), logger);
logger.Status("Writing XML output...");
s.finalize();
} else {
#ifdef WITH_GLTF
JsonSerializer s(ifc_file, IfcUtil::path::to_utf8(output_temp_filename), JsonSerializer::JSON_DIALECT_CREOOX);
Logger::Status("Writing JSON output...");
JsonSerializer s(ifc_file, IfcUtil::path::to_utf8(output_temp_filename), JsonSerializer::JSON_DIALECT_CREOOX, logger);
logger.Status("Writing JSON output...");
s.finalize();
#endif
}
time(&end);
Logger::Status("Done! Conversion took " + format_duration(start, end));
logger.Status("Done! Conversion took " + format_duration(start, end));
IfcUtil::path::rename_file(IfcUtil::path::to_utf8(output_temp_filename), IfcUtil::path::to_utf8(output_filename));
exit_code = EXIT_SUCCESS;
}
} catch (const std::exception& e) {
Logger::Error(e);
logger.Error("SYS", 9, e);
}
write_log(!quiet);
return exit_code;
} else if (output_extension == IFC) {
int exit_code = EXIT_FAILURE;
try {
if (init_input_file(IfcUtil::path::to_utf8(input_filename), ifc_file, no_progress || quiet, mmap)) {
if (init_input_file(IfcUtil::path::to_utf8(input_filename), ifc_file, no_progress || quiet, mmap, false, logger)) {
time_t start, end;
time(&start);
std::ofstream fs(output_filename.c_str());
if (fs.is_open()) {
if (vmap.count("calculate-quantities")) {
fix_quantities(*ifc_file, no_progress, quiet, stderr_progress);
fix_quantities(*ifc_file, no_progress, quiet, stderr_progress, logger);
}
fs << *ifc_file;
exit_code = EXIT_SUCCESS;
} else {
Logger::Error("Unable to open output file for writing");
logger.Error("SYS", 10, "Unable to open output file for writing");
}
time(&end);
Logger::Status("Done! Writing IFC took " + format_duration(start, end));
logger.Status("Done! Writing IFC took " + format_duration(start, end));
}
} catch (const std::exception& e) {
Logger::Error(e);
logger.Error("SYS", 11, e);
}
write_log(!quiet);
return exit_code;
@@ -722,26 +723,26 @@ int main(int argc, char** argv) {
if (vmap.count("stream")) {
time_t start, end;
time(&start);
RocksDbSerializer s(IfcUtil::path::to_utf8(input_filename), IfcUtil::path::to_utf8(output_filename), true);
Logger::Status("Populating RocksDB Key-Value store...");
RocksDbSerializer s(IfcUtil::path::to_utf8(input_filename), IfcUtil::path::to_utf8(output_filename), true, logger);
logger.Status("Populating RocksDB Key-Value store...");
s.finalize();
time(&end);
Logger::Status("Done! Conversion took " + format_duration(start, end));
logger.Status("Done! Conversion took " + format_duration(start, end));
exit_code = EXIT_SUCCESS;
} else {
if (init_input_file(IfcUtil::path::to_utf8(input_filename), ifc_file, no_progress || quiet, mmap)) {
if (init_input_file(IfcUtil::path::to_utf8(input_filename), ifc_file, no_progress || quiet, mmap, false, logger)) {
time_t start, end;
time(&start);
RocksDbSerializer s(ifc_file, IfcUtil::path::to_utf8(output_filename));
Logger::Status("Populating RocksDB Key-Value store...");
RocksDbSerializer s(ifc_file, IfcUtil::path::to_utf8(output_filename), logger);
logger.Status("Populating RocksDB Key-Value store...");
s.finalize();
time(&end);
Logger::Status("Done! Conversion took " + format_duration(start, end));
logger.Status("Done! Conversion took " + format_duration(start, end));
exit_code = EXIT_SUCCESS;
}
}
} catch (const std::exception& e) {
Logger::Error(e);
logger.Error("SYS", 12, e);
}
write_log(!quiet);
return exit_code;
@@ -761,9 +762,9 @@ int main(int argc, char** argv) {
return EXIT_FAILURE;
}
if (!entity_filter.entity_names.empty()) { entity_filter.update_description(); Logger::Notice(entity_filter.description); }
if (!layer_filter.values.empty()) { layer_filter.update_description(); Logger::Notice(layer_filter.description); }
if (!attribute_filter.attribute_name.empty()) { attribute_filter.update_description(); Logger::Notice(attribute_filter.description); }
if (!entity_filter.entity_names.empty()) { entity_filter.update_description(); logger.Notice("SYS", 13, entity_filter.description); }
if (!layer_filter.values.empty()) { layer_filter.update_description(); logger.Notice("SYS", 14, layer_filter.description); }
if (!attribute_filter.attribute_name.empty()) { attribute_filter.update_description(); logger.Notice("SYS", 15, attribute_filter.description); }
#ifdef _MSC_VER
if (output_extension == DAE || output_extension == STP || output_extension == IGS) {
@@ -828,39 +829,39 @@ int main(int argc, char** argv) {
if (output_extension == OBJ) {
// Do not use temp file for MTL as it's such a small file.
const path_t mtl_filename = change_extension(output_filename, MTL);
serializer = boost::make_shared<WaveFrontOBJSerializer>(IfcUtil::path::to_utf8(output_temp_filename), IfcUtil::path::to_utf8(mtl_filename), geometry_settings, serializer_settings);
serializer = boost::make_shared<WaveFrontOBJSerializer>(IfcUtil::path::to_utf8(output_temp_filename), IfcUtil::path::to_utf8(mtl_filename), geometry_settings, serializer_settings, logger);
#ifdef WITH_OPENCOLLADA
} else if (output_extension == DAE) {
serializer = boost::make_shared<ColladaSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings);
serializer = boost::make_shared<ColladaSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger);
#endif
#ifdef WITH_GLTF
} else if (output_extension == GLB) {
serializer = boost::make_shared<GltfSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings);
serializer = boost::make_shared<GltfSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger);
#endif
#ifdef WITH_USD
} else if (output_extension == USD || output_extension == USDA || output_extension == USDC) {
serializer = boost::make_shared<USDSerializer>(IfcUtil::path::to_utf8(output_filename), geometry_settings, serializer_settings);
serializer = boost::make_shared<USDSerializer>(IfcUtil::path::to_utf8(output_filename), geometry_settings, serializer_settings, logger);
#endif
#ifdef IFOPSH_WITH_OPENCASCADE
} else if (output_extension == STP) {
serializer = boost::make_shared<StepSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings);
serializer = boost::make_shared<StepSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger);
} else if (output_extension == IGS) {
#if OCC_VERSION_HEX < 0x60900
// According to https://tracker.dev.opencascade.org/view.php?id=25689 something has been fixed in 6.9.0
IGESControl_Controller::Init(); // work around Open Cascade bug
#endif
serializer = boost::make_shared<IgesSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings);
serializer = boost::make_shared<IgesSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger);
} else if (output_extension == SVG) {
geometry_settings.get<ifcopenshell::geometry::settings::IteratorOutput>().value = ifcopenshell::geometry::settings::NATIVE;
serializer = boost::make_shared<SvgSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings);
serializer = boost::make_shared<SvgSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger);
#ifdef WITH_HDF5
} else if (output_extension == HDF) {
geometry_settings.get<ifcopenshell::geometry::settings::IteratorOutput>().value = ifcopenshell::geometry::settings::NATIVE;
serializer = boost::make_shared<HdfSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings);
serializer = boost::make_shared<HdfSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, false, logger);
#endif
#endif
} else if (output_extension == TTL) {
serializer = boost::make_shared<TtlWktSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings);
serializer = boost::make_shared<TtlWktSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger);
} else {
cerr_ << "[Error] Unknown output filename extension '" << output_extension << "'\n";
write_log(!quiet);
@@ -871,13 +872,13 @@ int main(int argc, char** argv) {
const bool is_tesselated = serializer->isTesselated(); // isTesselated() doesn't change at run-time
if (!is_tesselated) {
if (geometry_settings.get<ifcopenshell::geometry::settings::WeldVertices>().get()) {
Logger::Notice("Weld vertices setting ignored when writing non-tesselated output");
logger.Notice("SYS", 16, "Weld vertices setting ignored when writing non-tesselated output");
}
if (geometry_settings.get<ifcopenshell::geometry::settings::GenerateUvs>().get()) {
Logger::Notice("Generate UVs setting ignored when writing non-tesselated output");
logger.Notice("SYS", 17, "Generate UVs setting ignored when writing non-tesselated output");
}
if (center_model || center_model_geometry) {
Logger::Notice("Centering/offsetting model setting ignored when writing non-tesselated output");
logger.Notice("SYS", 18, "Centering/offsetting model setting ignored when writing non-tesselated output");
}
geometry_settings.get<ifcopenshell::geometry::settings::IteratorOutput>().value = ifcopenshell::geometry::settings::NATIVE;
@@ -895,7 +896,7 @@ int main(int argc, char** argv) {
// @nb last argument true -> bypass_properties which are not read by any of the geometry serializers
// XML, RocksDB, IFC are already special-cased above
// SVG requires properties for IfcAnnotation/DRAWING properties
if (!init_input_file(IfcUtil::path::to_utf8(input_filename), ifc_file, no_progress || quiet, mmap, output_extension != SVG)) {
if (!init_input_file(IfcUtil::path::to_utf8(input_filename), ifc_file, no_progress || quiet, mmap, output_extension != SVG, logger)) {
write_log(!quiet);
serializer.reset();
IfcUtil::path::delete_file(IfcUtil::path::to_utf8(output_temp_filename)); /**< @todo Windows Unicode support */
@@ -903,9 +904,9 @@ int main(int argc, char** argv) {
}
if (vmap.count("log-file")) {
Logger::SetOutput(quiet ? nullptr : &cout_, &log_fs);
logger.SetOutput(quiet ? nullptr : &cout_, &log_fs);
} else {
Logger::SetOutput(quiet ? nullptr : &cout_, vcounter.count > 1 ? &cout_ : &log_stream);
logger.SetOutput(quiet ? nullptr : &cout_, vcounter.count > 1 ? &cout_ : &log_stream);
}
if (model_rotation) {
@@ -920,13 +921,13 @@ int main(int argc, char** argv) {
std::stringstream msg;
msg << "Using model rotation (" << rotation[0] << "," << rotation[1] << "," << rotation[2] << "," << rotation[3] << ")";
Logger::Notice(msg.str());
logger.Notice("SYS", 19, msg.str());
geometry_settings.get<ifcopenshell::geometry::settings::ModelRotation>().value = rotation;
}
if (model_offset && (center_model || center_model_geometry)) {
Logger::Notice("--model-offset ignored with --center-model or --center-model-geometry");
logger.Notice("GEO", 22, "--model-offset ignored with --center-model or --center-model-geometry");
}
if (model_offset && !(center_model || center_model_geometry)) {
@@ -941,7 +942,7 @@ int main(int argc, char** argv) {
std::stringstream msg;
msg << std::setprecision(std::numeric_limits<double>::max_digits10) << "Using model offset (" << offset[0] << "," << offset[1] << "," << offset[2] << ")";
Logger::Notice(msg.str());
logger.Notice("SYS", 20, msg.str());
geometry_settings.get<ifcopenshell::geometry::settings::ModelOffset>().value = offset;
}
@@ -949,17 +950,17 @@ int main(int argc, char** argv) {
if (is_tesselated && (center_model || center_model_geometry)) {
std::vector<double> offset(3);
IfcGeom::Iterator tmp_context_iterator(ifcopenshell::geometry::kernels::construct(ifc_file, geometry_kernel, geometry_settings), geometry_settings, ifc_file, filter_funcs, num_threads);
IfcGeom::Iterator tmp_context_iterator(ifcopenshell::geometry::kernels::construct(ifc_file, geometry_kernel, geometry_settings, logger), geometry_settings, ifc_file, filter_funcs, num_threads, logger);
time_t start, end;
time(&start);
if (!quiet) Logger::Status("Computing bounds...");
if (!quiet) logger.Status("Computing bounds...");
if (center_model_geometry) {
if (!tmp_context_iterator.initialize()) {
/// @todo It would be nice to know and print separate error prints for a case where we found no entities
/// and for a case we found no entities that satisfy our filtering criteria.
Logger::Notice("No geometrical elements found or none successfully converted");
logger.Notice("GEO", 23, "No geometrical elements found or none successfully converted");
serializer.reset();
IfcUtil::path::delete_file(IfcUtil::path::to_utf8(output_temp_filename));
write_log(!quiet);
@@ -970,7 +971,7 @@ int main(int argc, char** argv) {
tmp_context_iterator.compute_bounds(center_model_geometry);
time(&end);
if (!quiet) Logger::Status("Done ! Bounds computed in " + format_duration(start, end));
if (!quiet) logger.Status("Done ! Bounds computed in " + format_duration(start, end));
auto center = (tmp_context_iterator.bounds_min().ccomponents() + tmp_context_iterator.bounds_max().ccomponents()) * 0.5;
offset[0] = -center(0);
@@ -979,7 +980,7 @@ int main(int argc, char** argv) {
std::stringstream msg;
msg << std::setprecision (std::numeric_limits<double>::max_digits10) << "Using model offset (" << offset[0] << "," << offset[1] << "," << offset[2] << ")";
Logger::Notice(msg.str());
logger.Notice("SYS", 21, msg.str());
geometry_settings.get<ifcopenshell::geometry::settings::ModelOffset>().value = offset;
}
@@ -995,7 +996,7 @@ int main(int argc, char** argv) {
std::unique_ptr<IfcGeom::Iterator> context_iterator;
if (!elems_from_adaptor) {
context_iterator.reset(new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(ifc_file, geometry_kernel, geometry_settings), geometry_settings, ifc_file, filter_funcs, num_threads));
context_iterator.reset(new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(ifc_file, geometry_kernel, geometry_settings, logger), geometry_settings, ifc_file, filter_funcs, num_threads, logger));
}
#if defined(WITH_HDF5) && defined(IFOPSH_WITH_OPENCASCADE)
@@ -1004,17 +1005,17 @@ int main(int argc, char** argv) {
if (!vmap.count("cache-file")) {
cache_file = input_filename + CACHE + HDF;
}
cache.reset(new HdfSerializer(IfcUtil::path::to_utf8(cache_file), geometry_settings, serializer_settings));
cache.reset(new HdfSerializer(IfcUtil::path::to_utf8(cache_file), geometry_settings, serializer_settings, false, logger));
context_iterator->set_cache(cache.get());
}
#endif
Logger::Message(Logger::LOG_PERF, "file geometry conversion");
logger.Message(Logger::LOG_PERF, "GEO", 24, "file geometry conversion");
if (context_iterator && !context_iterator->initialize()) {
/// @todo It would be nice to know and print separate error prints for a case where we found no entities
/// and for a case we found no entities that satisfy our filtering criteria.
Logger::Notice("No geometrical elements found or none successfully converted");
logger.Notice("GEO", 25, "No geometrical elements found or none successfully converted");
serializer.reset();
IfcUtil::path::delete_file(IfcUtil::path::to_utf8(output_temp_filename));
write_log(!quiet);
@@ -1033,7 +1034,7 @@ int main(int argc, char** argv) {
static_cast<SvgSerializer*>(serializer.get())->setSectionHeightsFromStoreys();
}
} else if (vmap.count("section-height") != 0) {
Logger::Notice("Overriding section height");
logger.Notice("SYS", 22, "Overriding section height");
static_cast<SvgSerializer*>(serializer.get())->setSectionHeight(section_height);
}
if (vmap.count("print-space-names") != 0) {
@@ -1111,7 +1112,7 @@ int main(int argc, char** argv) {
int old_progress = quiet ? 0 : -1;
if (!quiet) {
Logger::Status("Creating geometry...");
logger.Status("Creating geometry...");
}
// The functions IfcGeom::Iterator::get() and IfcGeom::Iterator::next()
@@ -1156,10 +1157,10 @@ int main(int argc, char** argv) {
if (stderr_progress)
cerr_ << std::flush;
} else if (vcounter.count == 2) {
Logger::Message(Logger::LOG_DEBUG, "Progress " + boost::lexical_cast<std::string>(progress));
logger.Message(Logger::LOG_DEBUG, "SYS", 23, "Progress " + boost::lexical_cast<std::string>(progress));
} else {
progress = progress / 2;
if (old_progress != progress) Logger::ProgressBar(progress);
if (old_progress != progress) logger.ProgressBar(progress);
old_progress = progress;
}
}
@@ -1188,7 +1189,7 @@ int main(int argc, char** argv) {
}
} else {
const std::string task = ((num_threads == 1) ? "creating" : "writing");
Logger::Status("\rDone " + task + " geometry (" + boost::lexical_cast<std::string>(num_created) +
logger.Status("\rDone " + task + " geometry (" + boost::lexical_cast<std::string>(num_created) +
" objects) ");
}
@@ -1196,7 +1197,7 @@ int main(int argc, char** argv) {
// Make sure the dtor is explicitly run here (e.g. output files are closed before renaming them).
serializer.reset();
Logger::Message(Logger::LOG_PERF, "done file geometry conversion");
logger.Message(Logger::LOG_PERF, "GEO", 26, "done file geometry conversion");
bool successful;
if(output_extension == USD || output_extension == USDC || output_extension == USDA) {
@@ -1214,13 +1215,13 @@ int main(int argc, char** argv) {
output_temp_filename << "' for the conversion result.";
}
if (geometry_settings.get<ifcopenshell::geometry::settings::ValidateQuantities>().get() && Logger::MaxSeverity() >= Logger::LOG_ERROR) {
Logger::Error("Errors encountered during processing.");
if (geometry_settings.get<ifcopenshell::geometry::settings::ValidateQuantities>().get() && logger.MaxSeverity() >= Logger::LOG_ERROR) {
logger.Error("SYS", 24, "Errors encountered during processing.");
successful = false;
}
if (Logger::Verbosity() == Logger::LOG_PERF) {
Logger::PrintPerformanceStats();
if (logger.Verbosity() == Logger::LOG_PERF) {
logger.PrintPerformanceStats();
}
write_log(!quiet);
@@ -1228,7 +1229,7 @@ int main(int argc, char** argv) {
time(&end);
if (!quiet) {
Logger::Status("\nConversion took " + format_duration(start, end));
logger.Status("\nConversion took " + format_duration(start, end));
}
return successful ? EXIT_SUCCESS : EXIT_FAILURE;
@@ -1266,11 +1267,11 @@ void write_log(bool header) {
#include <boost/algorithm/string/predicate.hpp>
bool init_input_file(const std::string& filename, IfcParse::IfcFile*& ifc_file, bool no_progress, bool mmap, bool bypass_properties) {
bool init_input_file(const std::string& filename, IfcParse::IfcFile*& ifc_file, bool no_progress, bool mmap, bool bypass_properties, Logger& logger) {
time_t start, end;
// Prevent IfcFile::Init() prints by setting output to null temporarily
if (no_progress) { Logger::SetOutput(NULL, &log_stream); }
if (no_progress) { logger.SetOutput(NULL, &log_stream); }
time(&start);
@@ -1278,11 +1279,11 @@ bool init_input_file(const std::string& filename, IfcParse::IfcFile*& ifc_file,
#ifdef WITH_IFCXML
if (boost::ends_with(boost::to_lower_copy(filename), ".ifcxml")) {
ifc_file = IfcParse::parse_ifcxml(filename);
ifc_file = IfcParse::parse_ifcxml(filename, logger);
} else
#endif
{
ifc_file = new IfcParse::IfcFile(IfcParse::uninitialized_tag{});
ifc_file = new IfcParse::IfcFile(IfcParse::uninitialized_tag{}, logger);
requires_init = true;
}
@@ -1308,13 +1309,13 @@ bool init_input_file(const std::string& filename, IfcParse::IfcFile*& ifc_file,
}
if (!ifc_file || !ifc_file->good()) {
Logger::Error("Unable to parse input file '" + filename + "'");
logger.Error("SYN", 1, "Unable to parse input file '" + filename + "'");
return false;
}
time(&end);
if (no_progress) { Logger::SetOutput(&cout_, &log_stream); }
else { Logger::Status("Parsing input file took " + format_duration(start, end)); }
if (no_progress) { logger.SetOutput(&cout_, &log_stream); }
else { logger.Status("Parsing input file took " + format_duration(start, end)); }
return true;
@@ -1533,7 +1534,7 @@ namespace latebound_access {
}
}
void fix_quantities(IfcParse::IfcFile& f, bool no_progress, bool quiet, bool stderr_progress) {
void fix_quantities(IfcParse::IfcFile& f, bool no_progress, bool quiet, bool stderr_progress, Logger& logger) {
{
auto delete_reversed = [&f](const aggregate_of_instance::ptr& insts) {
if (!insts) {
@@ -1588,7 +1589,7 @@ void fix_quantities(IfcParse::IfcFile& f, bool no_progress, bool quiet, bool std
settings.get<ifcopenshell::geometry::settings::ConvertBackUnits>().value = true;
settings.get<ifcopenshell::geometry::settings::IteratorOutput>().value = ifcopenshell::geometry::settings::NATIVE;
IfcGeom::Iterator context_iterator(ifcopenshell::geometry::kernels::construct(&f, "opencascade", settings), settings, &f, {}, 1);
IfcGeom::Iterator context_iterator(ifcopenshell::geometry::kernels::construct(&f, "opencascade", settings, logger), settings, &f, {}, 1, logger);
if (!context_iterator.initialize()) {
return;
@@ -1716,7 +1717,7 @@ void fix_quantities(IfcParse::IfcFile& f, bool no_progress, bool quiet, bool std
cerr_ << std::flush;
} else {
const int progress = context_iterator.progress() / 2;
if (old_progress != progress) Logger::ProgressBar(progress);
if (old_progress != progress) logger.ProgressBar(progress);
old_progress = progress;
}
}
@@ -1732,7 +1733,7 @@ void fix_quantities(IfcParse::IfcFile& f, bool no_progress, bool quiet, bool std
if (stderr_progress)
cerr_ << std::flush;
} else {
Logger::Status("\rDone writing quantities for " + boost::lexical_cast<std::string>(num_created) +
logger.Status("\rDone writing quantities for " + boost::lexical_cast<std::string>(num_created) +
" objects ");
}
+7 -7
View File
@@ -18,8 +18,8 @@ typedef CGAL::AABB_traits<Kernel_, Primitive> Traits;
typedef CGAL::AABB_tree<Traits> Tree;
typedef Tree::Point_and_primitive_id Point_and_primitive_id;
void fix_spaceboundaries(IfcParse::IfcFile& f, bool no_progress, bool quiet, bool stderr_progress) {
intersection_validator v(f, { "IfcWall", "IfcSpace", "IfcSlab", "IfcCovering" }, 1.e-5, no_progress, quiet, stderr_progress);
void fix_spaceboundaries(IfcParse::IfcFile& f, bool no_progress, bool quiet, bool stderr_progress, Logger& logger = Logger::Root()) {
intersection_validator v(f, { "IfcWall", "IfcSpace", "IfcSlab", "IfcCovering" }, 1.e-5, no_progress, quiet, stderr_progress, logger);
auto rels = f.instances_by_type("IfcRelSpaceBoundary");
@@ -54,7 +54,7 @@ void fix_spaceboundaries(IfcParse::IfcFile& f, bool no_progress, bool quiet, boo
settings.get<ifcopenshell::geometry::settings::IteratorOutput>().value = ifcopenshell::geometry::settings::NATIVE;
settings.get<ifcopenshell::geometry::settings::DisableOpeningSubtractions>().value = true;
ifcopenshell::geometry::Converter c("cgal", &f2, settings);
ifcopenshell::geometry::Converter c(ifcopenshell::geometry::kernels::construct(&f2, "cgal", settings, logger), &f2, settings, logger);
std::map<std::set<std::string>, std::vector<Kernel_::Point_3>> elem_to_space_boundary_coords;
@@ -81,7 +81,7 @@ void fix_spaceboundaries(IfcParse::IfcFile& f, bool no_progress, bool quiet, boo
std::set< std::set<std::string> > guid_pairs_visited;
v([&rel_by_space_elem, &elem_to_space_boundary_coords, &guid_pairs_visited](const intersection_validator::Box& a, const intersection_validator::Box& b) {
v([&logger, &rel_by_space_elem, &elem_to_space_boundary_coords, &guid_pairs_visited](const intersection_validator::Box& a, const intersection_validator::Box& b) {
std::ostringstream ss;
// ss << id_map[a.id()]->first->data().toString() << "x" << id_map[b.id()]->first->data().toString() << std::endl;
// auto x = id_map[a.id()]->second * id_map[b.id()]->second;
@@ -128,7 +128,7 @@ void fix_spaceboundaries(IfcParse::IfcFile& f, bool no_progress, bool quiet, boo
auto itelem = elem_to_space_boundary_coords.find({ Aguid, Bguid });
if (itelem == elem_to_space_boundary_coords.end()) {
Logger::Error("Missing space boundary relationship " + Aguid + " " + Bguid);
logger.Error("VAL", 1, "Missing space boundary relationship " + Aguid + " " + Bguid);
return;
}
@@ -141,7 +141,7 @@ void fix_spaceboundaries(IfcParse::IfcFile& f, bool no_progress, bool quiet, boo
bool valid = *std::max_element(distances.begin(), distances.end()) < 0.4;
if (!valid) {
Logger::Error("Wrong connection geometry " + Aguid + " " + Bguid);
logger.Error("VAL", 2, "Wrong connection geometry " + Aguid + " " + Bguid);
}
/*{
@@ -178,7 +178,7 @@ void fix_spaceboundaries(IfcParse::IfcFile& f, bool no_progress, bool quiet, boo
auto g1 = n.substr(0, 22);
auto g2 = n.substr(23);
if (is_wall_space_or_slab(g1) && is_wall_space_or_slab(g2) && guid_pairs_visited.find({ g1, g2 }) == guid_pairs_visited.end()) {
Logger::Error("Space boundary for non-bounding geometry " + g1 + " " + g2);
logger.Error("VAL", 3, "Space boundary for non-bounding geometry " + g1 + " " + g2);
}
}
}
@@ -9,7 +9,7 @@
#include <algorithm>
void fix_storeycontainment(IfcParse::IfcFile& f, bool no_progress, bool quiet, bool stderr_progress) {
void fix_storeycontainment(IfcParse::IfcFile& f, bool no_progress, bool quiet, bool stderr_progress, Logger& logger = Logger::Root()) {
ifcopenshell::geometry::Settings settings;
settings.get<ifcopenshell::geometry::settings::UseWorldCoords>().value = false;
@@ -23,7 +23,7 @@ void fix_storeycontainment(IfcParse::IfcFile& f, bool no_progress, bool quiet, b
IfcGeom::entity_filter(false, false, {"IfcOpeningElement", "IfcSpace"})
};
IfcGeom::Iterator context_iterator("cgal", settings, &f, no_openings_and_spaces, 1);
IfcGeom::Iterator context_iterator(ifcopenshell::geometry::kernels::construct(&f, "cgal", settings, logger), settings, &f, no_openings_and_spaces, 1, logger);
auto get_elevation = [](const IfcUtil::IfcBaseClass* a) {
return ((const IfcUtil::IfcBaseEntity*)a)->get_value<double>("Elevation", 0.);
@@ -198,7 +198,7 @@ void fix_storeycontainment(IfcParse::IfcFile& f, bool no_progress, bool quiet, b
auto s = geom_object->product()->get_value<std::string>("GlobalId");
auto s1 = ((IfcUtil::IfcBaseEntity*)storeys_sorted[calc_idx])->get_value<std::string>("GlobalId");
auto s2 = ((IfcUtil::IfcBaseEntity*)elem_to_storey[geom_object->product()])->get_value<std::string>("GlobalId");
Logger::Error("Element " + s + " contained in " + s2 + " located on " + s1);
logger.Error("VAL", 4, "Element " + s + " contained in " + s2 + " located on " + s1);
}
if (!no_progress) {
@@ -214,7 +214,7 @@ void fix_storeycontainment(IfcParse::IfcFile& f, bool no_progress, bool quiet, b
std::cerr << std::flush;
} else {
const int progress = context_iterator.progress() / 2;
if (old_progress != progress) Logger::ProgressBar(progress);
if (old_progress != progress) logger.ProgressBar(progress);
old_progress = progress;
}
}
@@ -230,7 +230,7 @@ void fix_storeycontainment(IfcParse::IfcFile& f, bool no_progress, bool quiet, b
if (stderr_progress)
std::cerr << std::flush;
} else {
Logger::Status("\rDone fixing space boundaries for " + boost::lexical_cast<std::string>(num_created) +
logger.Status("\rDone fixing space boundaries for " + boost::lexical_cast<std::string>(num_created) +
" objects ");
}
}
@@ -9,8 +9,8 @@
using namespace ifcopenshell::geometry;
void fix_wallconnectivity(IfcParse::IfcFile& f, bool no_progress, bool quiet, bool stderr_progress) {
intersection_validator v(f, { "IfcWall" }, 1.e-3, no_progress, quiet, stderr_progress);
void fix_wallconnectivity(IfcParse::IfcFile& f, bool no_progress, bool quiet, bool stderr_progress, Logger& logger = Logger::Root()) {
intersection_validator v(f, { "IfcWall" }, 1.e-3, no_progress, quiet, stderr_progress, logger);
ifcopenshell::geometry::Settings settings;
@@ -24,7 +24,7 @@ void fix_wallconnectivity(IfcParse::IfcFile& f, bool no_progress, bool quiet, bo
settings.get<ifcopenshell::geometry::settings::IncludeCurves>().value = true;
settings.get<ifcopenshell::geometry::settings::IncludeSurfaces>().value = false;
ifcopenshell::geometry::Converter c("cgal", &f, settings);
ifcopenshell::geometry::Converter c(ifcopenshell::geometry::kernels::construct(&f, "cgal", settings, logger), &f, settings, logger);
auto rels = f.instances_by_type("IfcRelConnectsPathElements");
std::map<std::set<const IfcUtil::IfcBaseClass*>, const IfcUtil::IfcBaseClass*> rel_by_elem;
@@ -39,7 +39,7 @@ void fix_wallconnectivity(IfcParse::IfcFile& f, bool no_progress, bool quiet, bo
double total_nef_intersection_time = 0.;
double conversion_to_poly = 0.;
v([&c, &rel_by_elem, &rels_encounted, &total_nef_intersection_time, &conversion_to_poly](const intersection_validator::Box& a, const intersection_validator::Box& b) {
v([&logger, &c, &rel_by_elem, &rels_encounted, &total_nef_intersection_time, &conversion_to_poly](const intersection_validator::Box& a, const intersection_validator::Box& b) {
auto A = a.handle()->first;
auto B = b.handle()->first;
@@ -169,21 +169,21 @@ void fix_wallconnectivity(IfcParse::IfcFile& f, bool no_progress, bool quiet, bo
if (a_type != atype_computed || b_type != btype_computed) {
if (rel) {
Logger::Error(std::string("Connection type ") + atype_computed + " " + btype_computed + " for:", rel);
logger.Error("VAL", 5, std::string("Connection type ") + atype_computed + " " + btype_computed + " for:", rel);
} else {
auto A_str = A->get_value<std::string>("GlobalId");
auto B_str = B->get_value<std::string>("GlobalId");
Logger::Error("No connection for adjacent " + A_str + " " + B_str);
logger.Error("VAL", 6, "No connection for adjacent " + A_str + " " + B_str);
}
}
});
std::for_each(rels->begin(), rels->end(), [&rels_encounted, &v](const IfcUtil::IfcBaseClass* rel) {
std::for_each(rels->begin(), rels->end(), [&logger, &rels_encounted, &v](const IfcUtil::IfcBaseClass* rel) {
if (rels_encounted.find(rel) == rels_encounted.end()) {
auto x = (IfcUtil::IfcBaseEntity*)((IfcUtil::IfcBaseEntity*)rel)->get_value<IfcUtil::IfcBaseClass*>("RelatingElement");
auto y = (IfcUtil::IfcBaseEntity*)((IfcUtil::IfcBaseEntity*)rel)->get_value<IfcUtil::IfcBaseClass*>("RelatedElement");
if (v.successfully_processed.find(x) != v.successfully_processed.end() && v.successfully_processed.find(y) != v.successfully_processed.end()) {
Logger::Error("Connection for non-adjacent walls", rel);
logger.Error("VAL", 7, "Connection for non-adjacent walls", rel);
}
}
});
+6 -5
View File
@@ -3,6 +3,7 @@
#include "../ifcgeom/kernels/cgal/CgalKernel.h"
#include "../ifcgeom/IfcGeomFilter.h"
#include "../ifcgeom/Iterator.h"
#include "../ifcgeom/hybrid_kernel.h"
#include <CGAL/box_intersection_d.h>
#include <CGAL/minkowski_sum_3.h>
@@ -445,7 +446,7 @@ struct intersection_validator {
std::set<const IfcUtil::IfcBaseEntity*> successfully_processed;
intersection_validator(IfcParse::IfcFile& f, std::initializer_list<std::string> entities, double eps, bool no_progress, bool quiet, bool stderr_progress) {
intersection_validator(IfcParse::IfcFile& f, std::initializer_list<std::string> entities, double eps, bool no_progress, bool quiet, bool stderr_progress, Logger& logger = Logger::Root()) {
ifcopenshell::geometry::Settings settings;
settings.get<ifcopenshell::geometry::settings::UseWorldCoords>().value = false;
@@ -459,7 +460,7 @@ struct intersection_validator {
IfcGeom::entity_filter(true, false, entities)
};
IfcGeom::Iterator context_iterator("cgal", settings, &f, spaces_and_walls, 1);
IfcGeom::Iterator context_iterator(ifcopenshell::geometry::kernels::construct(&f, "cgal", settings, logger), settings, &f, spaces_and_walls, 1, logger);
if (!context_iterator.initialize()) {
return;
@@ -562,7 +563,7 @@ struct intersection_validator {
std::cerr << std::flush;
} else {
const int progress = context_iterator.progress() / 2;
if (old_progress != progress) Logger::ProgressBar(progress);
if (old_progress != progress) logger.ProgressBar(progress);
old_progress = progress;
}
}
@@ -578,7 +579,7 @@ struct intersection_validator {
if (stderr_progress)
std::cerr << std::flush;
} else {
Logger::Status("\rDone fixing space boundaries for " + boost::lexical_cast<std::string>(num_created) +
logger.Status("\rDone fixing space boundaries for " + boost::lexical_cast<std::string>(num_created) +
" objects ");
}
@@ -599,4 +600,4 @@ struct intersection_validator {
}
};
#endif
#endif
+2 -2
View File
@@ -20,7 +20,7 @@ bool ifcopenshell::geometry::kernels::AbstractKernel::convert(const taxonomy::pt
auto it = cache_.find(item);
if (it != cache_.end()) {
results = it->second;
Logger::Notice("Cache hit #" + std::to_string(item->instance->as<IfcUtil::IfcBaseEntity>()->id()) +
logger_.Notice("SYS", 25, "Cache hit #" + std::to_string(item->instance->as<IfcUtil::IfcBaseEntity>()->id()) +
" -> #" + std::to_string(it->first->instance->as<IfcUtil::IfcBaseEntity>()->id()));
return true;
}
@@ -30,7 +30,7 @@ bool ifcopenshell::geometry::kernels::AbstractKernel::convert(const taxonomy::pt
try {
return fn();
} catch (std::exception& e) {
Logger::Error(e, item->instance);
logger_.Error("GEO", 27, e, item->instance);
return false;
} catch (...) {
// @todo we can't log OCCT exceptions here, can we do some reraising to solve this?
+11 -8
View File
@@ -64,13 +64,15 @@ namespace ifcopenshell {
protected:
std::string geometry_library_;
Settings settings_;
Logger& logger_;
public:
bool propagate_exceptions = false;
bool partial_success_is_success = true;
AbstractKernel(const std::string& geometry_library, const Settings& settings)
AbstractKernel(const std::string& geometry_library, const Settings& settings, Logger& logger = Logger::Root())
: geometry_library_(geometry_library)
, settings_(settings) {}
, settings_(settings)
, logger_(logger) {}
virtual ~AbstractKernel() = default;
@@ -79,6 +81,7 @@ namespace ifcopenshell {
const std::string& geometry_library() const {
return geometry_library_;
}
Logger& logger() const { return logger_; }
virtual bool supports_boolean_operations() const = 0;
@@ -126,7 +129,7 @@ namespace ifcopenshell {
const IfcGeom::ConversionResults& entity_shapes, const ifcopenshell::geometry::taxonomy::matrix4& entity_trsf, IfcGeom::ConversionResults& cut_shapes) = 0;
virtual bool unify_shapes(const IfcGeom::ConversionResults&, IfcGeom::ConversionResults&) { throw not_implemented_error(); }
virtual AbstractKernel* clone() const = 0;
virtual AbstractKernel* clone(Logger& logger) const = 0;
};
}
}
@@ -154,7 +157,7 @@ namespace {
if (item->instance) {
created_from = " (created from " + item->instance->declaration().name() + ")";
}
Logger::Error("No support for " + ifcopenshell::geometry::taxonomy::kind_to_string(item->kind()) + created_from + " in kernel " + kernel->geometry_library());
kernel->logger().Error("UNS", 1, "No support for " + ifcopenshell::geometry::taxonomy::kind_to_string(item->kind()) + created_from + " in kernel " + kernel->geometry_library());
return false;
}
};
@@ -178,7 +181,7 @@ namespace {
if (item->instance) {
created_from = " (created from " + item->instance->declaration().name() + ")";
}
Logger::Error("No support (after considering item upgrade) for " + ifcopenshell::geometry::taxonomy::kind_to_string(item->kind()) + created_from + " in kernel " + kernel->geometry_library());
kernel->logger().Error("UNS", 2, "No support (after considering item upgrade) for " + ifcopenshell::geometry::taxonomy::kind_to_string(item->kind()) + created_from + " in kernel " + kernel->geometry_library());
return false;
}
};
@@ -214,7 +217,7 @@ namespace {
template <typename T>
struct dispatch_curve_creation<T, ifcopenshell::geometry::taxonomy::curves::max> {
static bool dispatch(const ifcopenshell::geometry::taxonomy::ptr& item, T&) {
Logger::Error("No conversion for " + std::to_string(item->kind()));
Logger::Root().Error("GEO", 28, "No conversion for " + std::to_string(item->kind()));
return false;
}
};
@@ -236,10 +239,10 @@ namespace {
template <typename T>
struct dispatch_surface_creation<T, ifcopenshell::geometry::taxonomy::surfaces::max> {
static bool dispatch(const ifcopenshell::geometry::taxonomy::ptr& item, T&) {
Logger::Error("No conversion for " + std::to_string(item->kind()));
Logger::Root().Error("GEO", 29, "No conversion for " + std::to_string(item->kind()));
return false;
}
};
}
#endif
#endif
+2 -2
View File
@@ -3,11 +3,11 @@
#include <iomanip>
IfcGeom::Representation::Triangulation * IfcGeom::ConversionResultShape::Triangulate(const ifcopenshell::geometry::Settings& settings) const
IfcGeom::Representation::Triangulation* IfcGeom::ConversionResultShape::Triangulate(const ifcopenshell::geometry::Settings& settings, Logger& logger) const
{
auto t = IfcGeom::Representation::Triangulation::empty(settings);
static ifcopenshell::geometry::taxonomy::matrix4 iden;
Triangulate(settings, iden, t, -1, -1);
Triangulate(settings, iden, t, -1, -1, logger);
return t;
}
+2 -2
View File
@@ -253,8 +253,8 @@ namespace IfcGeom {
class IFC_GEOM_API ConversionResultShape {
public:
virtual void Triangulate(ifcopenshell::geometry::Settings settings, const ifcopenshell::geometry::taxonomy::matrix4& place, Representation::Triangulation* t, int item_id, int surface_style_id) const = 0;
IfcGeom::Representation::Triangulation* Triangulate(const ifcopenshell::geometry::Settings& settings) const;
virtual void Triangulate(ifcopenshell::geometry::Settings settings, const ifcopenshell::geometry::taxonomy::matrix4& place, Representation::Triangulation* t, int item_id, int surface_style_id, Logger& logger = Logger::Root()) const = 0;
IfcGeom::Representation::Triangulation* Triangulate(const ifcopenshell::geometry::Settings& settings, Logger& logger = Logger::Root()) const;
virtual void Serialize(const ifcopenshell::geometry::taxonomy::matrix4& place, std::string&) const = 0;
virtual int surface_genus() const = 0;
+11 -10
View File
@@ -4,10 +4,11 @@
using namespace ifcopenshell::geometry;
ifcopenshell::geometry::Converter::Converter(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>&& geometry_library, IfcParse::IfcFile* file, ifcopenshell::geometry::Settings& s)
ifcopenshell::geometry::Converter::Converter(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>&& geometry_library, IfcParse::IfcFile* file, ifcopenshell::geometry::Settings& s, Logger& logger)
: kernel_(std::move(geometry_library))
, logger_(logger)
{
mapping_ = impl::mapping_implementations().construct(file, s);
mapping_ = impl::mapping_implementations().construct(file, s, logger_);
// Mapping reads unit information and applies to settings
settings_ = mapping_->settings();
}
@@ -17,7 +18,7 @@ ifcopenshell::geometry::Converter::~Converter() {
}
namespace {
void substitute_with_box_based_on_density(IfcGeom::ConversionResults& items, double& density) {
void substitute_with_box_based_on_density(Logger& logger, IfcGeom::ConversionResults& items, double& density) {
int nv = 0;
void* box = nullptr;
double volume = 0.;
@@ -29,7 +30,7 @@ namespace {
if (density > 1e5) {
items[0].Shape()->set_box(box);
items.erase(items.begin() + 1, items.end());
Logger::Notice("Substituted element with " + boost::lexical_cast<std::string>(density) + " vertices / m3 with a bounding box");
logger.Notice("GEO", 30, "Substituted element with " + boost::lexical_cast<std::string>(density) + " vertices / m3 with a bounding box");
}
}
}
@@ -138,7 +139,7 @@ IfcGeom::BRepElement* ifcopenshell::geometry::Converter::create_brep_for_represe
}
}
if (some_items_without_style) {
Logger::Warning("No material and surface styles for:", product);
logger_.Warning("GEO", 31, "No material and surface styles for:", product);
}
}
@@ -162,7 +163,7 @@ IfcGeom::BRepElement* ifcopenshell::geometry::Converter::create_brep_for_represe
parent_id = parent_object->id();
}
} catch (const std::exception& e) {
Logger::Error(e);
logger_.Error("GEO", 32, e);
}
const std::string name = product->get_value<std::string>("Name", "");
@@ -208,10 +209,10 @@ IfcGeom::BRepElement* ifcopenshell::geometry::Converter::create_brep_for_represe
kernel_->convert_openings(product, opening_items, shapes, *place, opened_shapes);
}
} catch (const std::exception& e) {
Logger::Message(Logger::LOG_ERROR, std::string("Error processing openings for: ") + e.what() + ":", product);
logger_.Message(Logger::LOG_ERROR, "GEO", 33, std::string("Error processing openings for: ") + e.what() + ":", product);
caught_error = true;
} catch (...) {
Logger::Message(Logger::LOG_ERROR, "Error processing openings for:", product);
logger_.Message(Logger::LOG_ERROR, "GEO", 34, "Error processing openings for:", product);
}
if (!(caught_error && opened_shapes.size() < shapes.size())) {
@@ -239,7 +240,7 @@ IfcGeom::BRepElement* ifcopenshell::geometry::Converter::create_brep_for_represe
std::swap(shapes, unified_shapes);
}
} catch (std::exception& e) {
Logger::Error(e);
logger_.Error("GEO", 35, e);
}
}
@@ -357,7 +358,7 @@ IfcGeom::BRepElement* ifcopenshell::geometry::Converter::create_brep_for_process
parent_id = parent_object->id();
}
} catch (const std::exception& e) {
Logger::Error(e);
logger_.Error("GEO", 36, e);
}
const std::string guid = product->get_value<std::string>("GlobalId");
+4 -2
View File
@@ -21,15 +21,17 @@ namespace ifcopenshell { namespace geometry {
std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel> kernel_;
ifcopenshell::geometry::Settings settings_;
std::map<ifcopenshell::geometry::taxonomy::ptr, brep_ptr, ifcopenshell::geometry::taxonomy::less_functor> cache_;
Logger& logger_;
public:
ifcopenshell::geometry::kernels::AbstractKernel* kernel() { return &*kernel_; }
Converter(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>&& geometry_library, IfcParse::IfcFile* file, ifcopenshell::geometry::Settings& settings);
Converter(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>&& geometry_library, IfcParse::IfcFile* file, ifcopenshell::geometry::Settings& settings, Logger& logger = Logger::Root());
~Converter();
ifcopenshell::geometry::abstract_mapping* mapping() const { return mapping_; }
Logger& logger() const { return logger_; }
/*
virtual NativeElement<double, double>* convert(
@@ -55,4 +57,4 @@ namespace ifcopenshell { namespace geometry {
};
}}
#endif
#endif
+4 -3
View File
@@ -143,8 +143,9 @@ class GeometrySerializer : public Serializer {
public:
enum read_type { READ_BREP, READ_TRIANGULATION };
GeometrySerializer(const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings)
: geometry_settings_(geometry_settings)
GeometrySerializer(const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings, Logger& logger = Logger::Root())
: Serializer(logger)
, geometry_settings_(geometry_settings)
, settings_(settings)
{}
virtual ~GeometrySerializer() {}
@@ -177,7 +178,7 @@ protected:
class WriteOnlyGeometrySerializer : public GeometrySerializer {
public:
WriteOnlyGeometrySerializer(const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings) : GeometrySerializer(geometry_settings, settings) {}
WriteOnlyGeometrySerializer(const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings, Logger& logger = Logger::Root()) : GeometrySerializer(geometry_settings, settings, logger) {}
virtual IfcGeom::Element* read(IfcParse::IfcFile&, const std::string&, const std::string&, read_type = READ_BREP) {
throw std::runtime_error("Not supported");
+1 -1
View File
@@ -125,7 +125,7 @@ namespace IfcGeom {
oss << "product-" << IfcParse::IfcGlobalId(guid).formatted();
} catch (const std::exception& e) {
oss << "product";
Logger::Error(e);
Logger::Root().Error("GEO", 39, e);
}
}
+57 -34
View File
@@ -31,7 +31,7 @@ bool IfcGeom::Iterator::initialize() {
try {
converter_->mapping()->get_representations(reps, filters_);
} catch (const std::exception& e) {
Logger::Error(e);
logger_.Error("GEO", 50, e);
}
time_points[1] = high_resolution_clock::now();
@@ -94,7 +94,7 @@ bool IfcGeom::Iterator::initialize() {
tasks_.back().item = p.first;
tasks_.back().products = p.second;
}
Logger::Notice("Merged " + std::to_string(old_size) + " tasks into " + std::to_string(tasks_.size()) + " tasks due to permissive shape reuse");
logger_.Notice("SYS", 26, "Merged " + std::to_string(old_size) + " tasks into " + std::to_string(tasks_.size()) + " tasks due to permissive shape reuse");
}
}
@@ -139,10 +139,10 @@ bool IfcGeom::Iterator::initialize() {
}
*/
Logger::Notice("Created " + boost::lexical_cast<std::string>(tasks_.size()) + " tasks for " + boost::lexical_cast<std::string>(num_products) + " products");
logger_.Notice("SYS", 27, "Created " + boost::lexical_cast<std::string>(tasks_.size()) + " tasks for " + boost::lexical_cast<std::string>(num_products) + " products");
if (tasks_.size() == 0) {
Logger::Warning("No representations encountered, aborting");
logger_.Warning("GEO", 51, "No representations encountered, aborting");
initialization_outcome_.reset(false);
} else if (!settings_.get<ifcopenshell::geometry::settings::DeferProcessingFirstElement>().get()) {
@@ -167,7 +167,15 @@ bool IfcGeom::Iterator::initialize() {
return *initialization_outcome_;
}
void IfcGeom::Iterator::process_finished_rep(geometry_conversion_result* rep) {
void IfcGeom::Iterator::flush_worker_log(ifcopenshell::geometry::Converter* kernel) {
if (kernel && &kernel->logger() != &logger_) {
logger_.Append(kernel->logger());
}
}
void IfcGeom::Iterator::process_finished_rep(geometry_conversion_result* rep, ifcopenshell::geometry::Converter* kernel) {
flush_worker_log(kernel);
if (rep->elements.empty()) {
return;
}
@@ -193,8 +201,17 @@ void IfcGeom::Iterator::process_concurrently() {
}
kernel_pool.reserve(conc_threads);
worker_loggers_.reserve(conc_threads);
for (unsigned i = 0; i < conc_threads; ++i) {
kernel_pool.push_back(new ifcopenshell::geometry::Converter(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>(converter_->kernel()->clone()), ifc_file, settings_));
worker_loggers_.emplace_back(std::make_unique<Logger>());
Logger& worker_logger = *worker_loggers_.back();
worker_logger.Verbosity(logger_.Verbosity());
worker_logger.OutputFormat(logger_.OutputFormat());
worker_logger.PrintPerformanceStatsOnElement(logger_.PrintPerformanceStatsOnElement());
if (worker_logger.OutputFormat() != Logger::FMT_INMEMORY) {
worker_logger.SetOutput(static_cast<std::ostream*>(nullptr), static_cast<std::ostream*>(nullptr));
}
kernel_pool.push_back(new ifcopenshell::geometry::Converter(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>(converter_->kernel()->clone(worker_logger)), ifc_file, settings_, worker_logger));
}
std::vector<std::future<geometry_conversion_result*>> threadpool;
@@ -211,11 +228,12 @@ void IfcGeom::Iterator::process_concurrently() {
std::future_status status;
status = fu.wait_for(std::chrono::seconds(0));
if (status == std::future_status::ready) {
process_finished_rep(fu.get());
process_finished_rep(fu.get(), kernel_pool[i]);
std::swap(threadpool[i], threadpool.back());
threadpool.pop_back();
std::swap(kernel_pool[i], kernel_pool.back());
std::swap(worker_loggers_[i], worker_loggers_.back());
K = kernel_pool.back();
break;
} // if
@@ -231,14 +249,14 @@ void IfcGeom::Iterator::process_concurrently() {
try {
this->create_element_(kernel, settings, rep);
} catch (const std::exception& e) {
Logger::Error(
kernel->logger().Error("GEO", 52,
std::string("Exception '") + e.what() +
std::string("' occurred while iterator was creating a shape: "),
rep->item->instance
);
had_error_processing_elements_ = true;
} catch (...) {
Logger::Error(
kernel->logger().Error("GEO", 53,
"Unknown exception occurred while iteartor was creating a shape: ",
rep->item->instance
);
@@ -257,16 +275,16 @@ void IfcGeom::Iterator::process_concurrently() {
threadpool.emplace_back(std::move(fu));
}
for (auto& fu : threadpool) {
process_finished_rep(fu.get());
for (size_t i = 0; i < threadpool.size(); ++i) {
process_finished_rep(threadpool[i].get(), kernel_pool[i]);
}
finished_ = true;
Logger::SetProduct(boost::none);
logger_.SetProduct(boost::none);
if (!terminating_) {
Logger::Status("\rDone creating geometry (" + boost::lexical_cast<std::string>(all_processed_elements_.size()) +
logger_.Status("\rDone creating geometry (" + boost::lexical_cast<std::string>(all_processed_elements_.size()) +
" objects) ");
}
}
@@ -344,6 +362,8 @@ const IfcUtil::IfcBaseClass* IfcGeom::Iterator::create_shape_model_for_next_enti
void IfcGeom::Iterator::create_element_(ifcopenshell::geometry::Converter* kernel, ifcopenshell::geometry::Settings settings, geometry_conversion_result* rep)
{
Logger& kernel_logger = kernel->logger();
if (!settings_.get<ifcopenshell::geometry::settings::NoParallelMapping>().get()) {
rep->item = kernel->mapping()->map(rep->representation);
if (!rep->item) {
@@ -360,20 +380,20 @@ void IfcGeom::Iterator::create_element_(ifcopenshell::geometry::Converter* kerne
const IfcUtil::IfcBaseEntity* product = product_node.first;
const auto& place = product_node.second;
Logger::SetProduct(product);
kernel_logger.SetProduct(product);
IfcGeom::BRepElement* brep = static_cast<IfcGeom::BRepElement*>(decorate_with_cache_(GeometrySerializer::READ_BREP, (std::string)product->get("GlobalId"), std::to_string(rep->item->instance->as<IfcUtil::IfcBaseEntity>()->id()), [kernel, settings, product, place, rep]() {
return kernel->create_brep_for_representation_and_product(rep->item, product, place);
}));
if (!brep) {
Logger::SetProduct(boost::none);
kernel_logger.SetProduct(boost::none);
return;
}
auto elem = process_based_on_settings(settings, brep);
auto elem = process_based_on_settings(settings, brep, kernel_logger);
if (!elem) {
Logger::SetProduct(boost::none);
kernel_logger.SetProduct(boost::none);
return;
}
@@ -385,11 +405,13 @@ void IfcGeom::Iterator::create_element_(ifcopenshell::geometry::Converter* kerne
const IfcUtil::IfcBaseEntity* product2 = p.first;
const auto& place2 = p.second;
kernel_logger.SetProduct(product2);
IfcGeom::BRepElement* brep2 = static_cast<IfcGeom::BRepElement*>(decorate_with_cache_(GeometrySerializer::READ_BREP, (std::string)product2->get("GlobalId"), std::to_string(rep->item->instance->as<IfcUtil::IfcBaseEntity>()->id()), [kernel, settings, product2, place2, brep]() {
return kernel->create_brep_for_processed_representation(product2, place2, brep);
}));
if (brep2) {
auto elem2 = process_based_on_settings(settings, brep2, dynamic_cast<IfcGeom::TriangulationElement*>(elem));
auto elem2 = process_based_on_settings(settings, brep2, kernel_logger, dynamic_cast<IfcGeom::TriangulationElement*>(elem));
if (elem2) {
rep->breps.push_back(brep2);
rep->elements.push_back(elem2);
@@ -397,16 +419,16 @@ void IfcGeom::Iterator::create_element_(ifcopenshell::geometry::Converter* kerne
}
}
Logger::SetProduct(boost::none);
kernel_logger.SetProduct(boost::none);
}
IfcGeom::Element* IfcGeom::Iterator::process_based_on_settings(ifcopenshell::geometry::Settings settings, IfcGeom::BRepElement* elem, IfcGeom::TriangulationElement* previous)
IfcGeom::Element* IfcGeom::Iterator::process_based_on_settings(ifcopenshell::geometry::Settings settings, IfcGeom::BRepElement* elem, Logger& logger, IfcGeom::TriangulationElement* previous)
{
if (settings.get<ifcopenshell::geometry::settings::IteratorOutput>().get() == ifcopenshell::geometry::settings::SERIALIZED) {
try {
return new IfcGeom::SerializedElement(*elem);
} catch (...) {
Logger::Message(Logger::LOG_ERROR, "Getting a serialized element from model failed.");
logger.Message(Logger::LOG_ERROR, "GEO", 54, "Getting a serialized element from model failed.");
return nullptr;
}
} else if (settings.get<ifcopenshell::geometry::settings::IteratorOutput>().get() == ifcopenshell::geometry::settings::TRIANGULATED) {
@@ -417,7 +439,7 @@ IfcGeom::Element* IfcGeom::Iterator::process_based_on_settings(ifcopenshell::geo
gid2 = gid2.substr(0, hyphen);
}
return decorate_with_cache_(GeometrySerializer::READ_TRIANGULATION, elem->guid(), gid2, [elem, previous]() {
return decorate_with_cache_(GeometrySerializer::READ_TRIANGULATION, elem->guid(), gid2, [&logger, elem, previous]() {
try {
if (!previous) {
return new TriangulationElement(*elem);
@@ -425,7 +447,7 @@ IfcGeom::Element* IfcGeom::Iterator::process_based_on_settings(ifcopenshell::geo
return new TriangulationElement(*elem, previous->geometry_pointer());
}
} catch (...) {
Logger::Message(Logger::LOG_ERROR, "Getting a triangulation element from model failed.");
logger.Message(Logger::LOG_ERROR, "GEO", 55, "Getting a triangulation element from model failed.");
}
return (TriangulationElement*)nullptr;
});
@@ -466,7 +488,7 @@ void IfcGeom::Iterator::log_timepoints() const {
for (auto it = time_points.begin() + 1; it != time_points.end(); ++it) {
auto jt = it - 1;
duration<double, std::milli> ms_double = (*it) - (*jt);
Logger::Notice(labels[std::distance(time_points.begin(), jt)] + " took " + std::to_string(ms_double.count()) + "ms");
logger_.Notice("SYS", 28, labels[std::distance(time_points.begin(), jt)] + " took " + std::to_string(ms_double.count()) + "ms");
}
}
@@ -501,7 +523,7 @@ const IfcUtil::IfcBaseClass* IfcGeom::Iterator::next() {
if (num_threads_ != 1) {
if (!wait_for_element()) {
Logger::SetProduct(boost::none);
logger_.SetProduct(boost::none);
time_points[3] = high_resolution_clock::now();
log_timepoints();
task_result_ptr_exhausted = true;
@@ -517,7 +539,7 @@ const IfcUtil::IfcBaseClass* IfcGeom::Iterator::next() {
// shape representation
if (task_result_iterator_ == --all_processed_elements_.end()) {
if (!create()) {
Logger::SetProduct(boost::none);
logger_.SetProduct(boost::none);
time_points[3] = high_resolution_clock::now();
log_timepoints();
task_result_ptr_exhausted = true;
@@ -554,7 +576,7 @@ IfcGeom::Element* IfcGeom::Iterator::get()
try {
parent_object = get_object(ret->parent_id());
} catch (const std::exception& e) {
Logger::Error(e);
logger_.Error("GEO", 56, e);
hasParent = false;
}
@@ -572,7 +594,7 @@ IfcGeom::Element* IfcGeom::Iterator::get()
try {
parent_object = get_object(pid);
} catch (const std::exception& e) {
Logger::Error(e);
logger_.Error("GEO", 57, e);
hasParent = false;
}
}
@@ -619,9 +641,9 @@ const IfcGeom::Element* IfcGeom::Iterator::get_object(int id) {
m4 = casted->matrix;
}
} catch (const std::exception& e) {
Logger::Error(e);
logger_.Error("GEO", 58, e);
} catch (...) {
Logger::Error("Unknown error returning product");
logger_.Error("GEO", 59, "Unknown error returning product");
}
Element* ifc_object = new Element(settings_, id, parent_id, product_name, instance_type, product_guid, "", m4, ifc_product);
@@ -633,10 +655,10 @@ const IfcUtil::IfcBaseClass* IfcGeom::Iterator::create() {
try {
product = create_shape_model_for_next_entity();
} catch (const std::exception& e) {
Logger::Error(e);
logger_.Error("GEO", 60, e);
had_error_processing_elements_ = true;
} catch (...) {
Logger::Error("Unknown error creating geometry");
logger_.Error("GEO", 61, "Unknown error creating geometry");
had_error_processing_elements_ = true;
}
return product;
@@ -808,8 +830,8 @@ ifcopenshell::geometry::taxonomy::direction3::ptr IfcGeom::Iterator::remove_offs
}
}
Logger::Notice("Removed large offsets within " + std::to_string(num_offset_applied) + " products");
Logger::Notice("Offset applied (" + std::to_string(vec(0)) + "," + std::to_string(vec(1)) + "," + std::to_string(vec(2)) + ")");
logger_.Notice("SYS", 29, "Removed large offsets within " + std::to_string(num_offset_applied) + " products");
logger_.Notice("SYS", 30, "Offset applied (" + std::to_string(vec(0)) + "," + std::to_string(vec(1)) + "," + std::to_string(vec(2)) + ")");
return make<direction3>(vec);
}
@@ -824,6 +846,7 @@ IfcGeom::Iterator::~Iterator() {
}
for (auto& k : kernel_pool) {
flush_worker_log(k);
delete k;
}
+17 -8
View File
@@ -79,6 +79,7 @@
#include <thread>
#include <chrono>
#include <atomic>
#include <memory>
namespace IfcGeom {
@@ -126,12 +127,14 @@ namespace IfcGeom {
std::vector<filter_t> filters_;
int num_threads_;
std::string geometry_library_;
Logger& logger_;
// When single-threaded
ifcopenshell::geometry::Converter* converter_;
// When multi-threaded
std::vector<ifcopenshell::geometry::Converter*> kernel_pool;
std::vector<std::unique_ptr<Logger>> worker_loggers_;
// The object is fetched beforehand to be sure that get() returns a valid element
TriangulationElement* current_triangulation;
@@ -199,8 +202,11 @@ namespace IfcGeom {
IfcGeom::Element* process_based_on_settings(
ifcopenshell::geometry::Settings settings,
IfcGeom::BRepElement* elem,
Logger& logger,
IfcGeom::TriangulationElement* previous = nullptr);
void flush_worker_log(ifcopenshell::geometry::Converter* kernel);
bool wait_for_element();
void log_timepoints() const;
@@ -209,32 +215,35 @@ namespace IfcGeom {
ifcopenshell::geometry::taxonomy::direction3::ptr remove_offset_();
public:
Iterator(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>&& geometry_library, const ifcopenshell::geometry::Settings& settings, IfcParse::IfcFile* file, const std::vector<IfcGeom::filter_t>& filters, int num_threads)
Iterator(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>&& geometry_library, const ifcopenshell::geometry::Settings& settings, IfcParse::IfcFile* file, const std::vector<IfcGeom::filter_t>& filters, int num_threads, Logger& logger = Logger::Root())
: settings_(settings)
, ifc_file(file)
, filters_(filters)
, num_threads_(num_threads)
, geometry_library_(geometry_library->geometry_library())
, logger_(logger)
// @todo verify whether settings are correctly passed on
, converter_(new ifcopenshell::geometry::Converter(std::move(geometry_library), ifc_file, settings_))
, converter_(new ifcopenshell::geometry::Converter(std::move(geometry_library), ifc_file, settings_, logger_))
{
}
Iterator(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>&& geometry_library, const ifcopenshell::geometry::Settings& settings, IfcParse::IfcFile* file)
Iterator(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>&& geometry_library, const ifcopenshell::geometry::Settings& settings, IfcParse::IfcFile* file, Logger& logger = Logger::Root())
: settings_(settings)
, ifc_file(file)
, num_threads_(1)
, geometry_library_(geometry_library->geometry_library())
, converter_(new ifcopenshell::geometry::Converter(std::move(geometry_library), ifc_file, settings_))
, logger_(logger)
, converter_(new ifcopenshell::geometry::Converter(std::move(geometry_library), ifc_file, settings_, logger_))
{
}
Iterator(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>&& geometry_library, const ifcopenshell::geometry::Settings& settings, IfcParse::IfcFile* file, int num_threads)
Iterator(std::unique_ptr<ifcopenshell::geometry::kernels::AbstractKernel>&& geometry_library, const ifcopenshell::geometry::Settings& settings, IfcParse::IfcFile* file, int num_threads, Logger& logger = Logger::Root())
: settings_(settings)
, ifc_file(file)
, num_threads_(num_threads)
, geometry_library_(geometry_library->geometry_library())
, converter_(new ifcopenshell::geometry::Converter(std::move(geometry_library), ifc_file, settings_))
, logger_(logger)
, converter_(new ifcopenshell::geometry::Converter(std::move(geometry_library), ifc_file, settings_, logger_))
{
}
@@ -289,7 +298,7 @@ namespace IfcGeom {
size_t processed_ = 0;
void process_finished_rep(geometry_conversion_result* rep);
void process_finished_rep(geometry_conversion_result* rep, ifcopenshell::geometry::Converter* kernel = nullptr);
void process_concurrently();
@@ -301,7 +310,7 @@ namespace IfcGeom {
return progress_;
}
std::string getLog() const { return Logger::GetLog(); }
std::string getLog() const { return logger_.GetLog(); }
IfcParse::IfcFile* file() const { return ifc_file; }
+8 -1
View File
@@ -21,15 +21,22 @@
#define SERIALIZER_H
#include "../ifcparse/IfcFile.h"
#include "../ifcparse/IfcLogger.h"
class Serializer {
protected:
Logger& logger_;
public:
explicit Serializer(Logger& logger = Logger::Root()) : logger_(logger) {}
virtual ~Serializer() {}
Logger& logger() const { return logger_; }
virtual bool ready() = 0;
virtual void writeHeader() = 0;
virtual void finalize() = 0;
virtual void setFile(IfcParse::IfcFile*) = 0;
};
#endif
#endif
+2 -2
View File
@@ -37,14 +37,14 @@ void ifcopenshell::geometry::impl::MappingFactoryImplementation::bind(const std:
this->insert(std::make_pair(schema_name_lower, fn));
}
ifcopenshell::geometry::abstract_mapping* ifcopenshell::geometry::impl::MappingFactoryImplementation::construct(IfcParse::IfcFile* file, Settings& s) {
ifcopenshell::geometry::abstract_mapping* ifcopenshell::geometry::impl::MappingFactoryImplementation::construct(IfcParse::IfcFile* file, Settings& s, Logger& logger) {
const std::string schema_name_lower = boost::to_lower_copy(file->schema()->name());
std::map<std::string, ifcopenshell::geometry::impl::mapping_fn>::const_iterator it;
it = this->find(schema_name_lower);
if (it == end()) {
throw IfcParse::IfcException("No geometry mapping registered for " + schema_name_lower);
}
auto new_mapping = it->second(file, s);
auto new_mapping = it->second(file, s, logger);
new_mapping->initialize_settings();
return new_mapping;
}
+7 -4
View File
@@ -21,6 +21,7 @@
#define ABSTRACT_MAPPING_H
#include "../ifcparse/IfcBaseClass.h"
#include "../ifcparse/IfcLogger.h"
#include "../ifcparse/aggregate_of_instance.h"
#include "../ifcgeom/taxonomy.h"
#include "../ifcgeom/ConversionSettings.h"
@@ -43,14 +44,15 @@ namespace geometry {
typedef boost::function<bool(IfcUtil::IfcBaseEntity*)> filter_t;
class IFC_GEOM_API abstract_mapping {
class IFC_GEOM_API abstract_mapping {
protected:
Settings settings_;
Logger& logger_;
bool use_caching_ = true;
public:
abstract_mapping(Settings& s) : settings_(s) {}
abstract_mapping(Settings& s, Logger& logger = Logger::Root()) : settings_(s), logger_(logger) {}
virtual ~abstract_mapping() {}
virtual ifcopenshell::geometry::taxonomy::ptr map(const IfcUtil::IfcBaseInterface*) = 0;
@@ -69,19 +71,20 @@ namespace geometry {
const Settings& settings() const { return settings_; }
Settings& settings() { return settings_; }
Logger& logger() const { return logger_; }
bool use_caching() const { return use_caching_; }
bool& use_caching() { return use_caching_; }
};
namespace impl {
typedef boost::function2<abstract_mapping*, IfcParse::IfcFile*, Settings&> mapping_fn;
typedef boost::function3<abstract_mapping*, IfcParse::IfcFile*, Settings&, Logger&> mapping_fn;
class IFC_GEOM_API MappingFactoryImplementation : public std::map<std::string, mapping_fn> {
public:
MappingFactoryImplementation();
void bind(const std::string& schema_name, mapping_fn);
abstract_mapping* construct(IfcParse::IfcFile*, Settings&);
abstract_mapping* construct(IfcParse::IfcFile*, Settings&, Logger& logger = Logger::Root());
};
IFC_GEOM_API MappingFactoryImplementation& mapping_implementations();
+4 -4
View File
@@ -76,7 +76,7 @@ struct piecewise_fn_evaluator : public fn_evaluator {
span_start += fn->length();
}
Logger::Error("piecewise span not found.");
logger_.Error("GEO", 37, "piecewise span not found.");
return {0, 0, nullptr};
}
@@ -208,7 +208,7 @@ struct offset_fn_evaluator : public fn_evaluator {
function_item_evaluator::function_item_evaluator(const ifcopenshell::geometry::Settings& settings,taxonomy::function_item::const_ptr fn) {
function_item_evaluator::function_item_evaluator(const ifcopenshell::geometry::Settings& settings,taxonomy::function_item::const_ptr fn, Logger& logger) : logger_(logger) {
auto kind = fn->kind();
if (kind == taxonomy::FUNCTOR_ITEM) {
fn_evaluator_ = new functor_fn_evaluator(std::dynamic_pointer_cast<const taxonomy::functor_item>(fn),settings);
@@ -221,11 +221,11 @@ function_item_evaluator::function_item_evaluator(const ifcopenshell::geometry::S
} else if (kind == taxonomy::OFFSET_FUNCTION) {
fn_evaluator_ = new offset_fn_evaluator(std::dynamic_pointer_cast<const taxonomy::offset_function>(fn), settings);
} else {
Logger::Error("Unexpected function type");
logger_.Error("GEO", 38, "Unexpected function type");
}
}
function_item_evaluator::function_item_evaluator(const function_item_evaluator& other) {
function_item_evaluator::function_item_evaluator(const function_item_evaluator& other) : logger_(other.logger_) {
fn_evaluator_ = other.fn_evaluator_->clone();
eval_points_ = other.eval_points_;
}

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