Commit Graph

20991 Commits

Author SHA1 Message Date
Gorgious56 49fe0756fa Fix spurious X/Y rotation on fillet corner wall
When the two source walls were placed at different elevations, the
fillet corner wall ended up with sub-degree X and Y Euler rotations
even though both source walls had only a Z rotation.

Cause: _apply_fillet_corner_geometry derived the corner's local X
axis from `chord = tangent_b - tangent_a` (a 3D vector). With walls
at different Z, `chord.z` was non-zero, so `x_dir = chord.normalized()`
inherited that Z component. The Z axis was already hardcoded to world
Z, so x_dir and z_dir were no longer orthogonal — the resulting
matrix_world was non-orthonormal, and Blender's Euler decomposition
surfaced the skew as the visible X/Y rotation drift.

Project the chord to the XY plane before normalising so x_dir is
strictly XY-aligned and orthogonal to z_dir. The corner wall is now
placed at wall A's elevation with a pure Z rotation, which matches
the user's expectation when both inputs are Z-aligned regardless of
their relative elevation.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:40 +02:00
Gorgious56 1534003e51 Fix fillet partner missing from wall unjoin gizmo
GizmoWallUnjoinSingle.poll accepts fillet-corner walls via the looser
tool.Parametric.is_path_connectable_wall predicate (fillet corners
have no LAYER2 usage by IFC spec, but they still participate in
IfcRelConnectsPathElements). The partner filter inside
_iter_path_connections used the stricter tool.Blender.Modifier.is_wall
(LAYER2-only), so adjacent LAYER2 walls silently dropped their
fillet-corner partners from the connection list — the unjoin icon
appeared when the fillet wall itself was selected but not on either
of its LAYER2 neighbours.

Switch the partner filter to is_path_connectable_wall so host and
partner predicates match. Add a regression test for the fillet case
and an AST forward-compat guard pinning the predicate symbol so a
future "tidy the imports" can't silently re-introduce the asymmetry.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:40 +02:00
Gorgious56 ea487fb17c Add array parametric edit lifecycle + GizmoArrayEdition / Child
Ports the array parametric-edit lifecycle, gizmo group, child guard,
per-layer ARRAY entry icons, and the array bbox decorators
(preview + selection highlight + layer-children) from gizmos-8088.
Restores the array_gizmo icon's positioning + visibility in the
framework's parametric edit row.

Registry (tool/parametric.py):
* EDIT_TYPES adds ParametricObject("array", supports_build_edit_lifecycle=True).
  _ArrayEditMixin in array.py feeds build_edit_lifecycle which auto-
  generates EnableEditingArray / FinishEditingArray / CancelEditingArray
  with the conventional bl_idnames the gizmo references.

tool/blender.py:
* Adds is_array predicate wrapper around tool.Parametric.is_array.
  The registry contract test test_every_entry_has_modifier_predicate
  enforces every EDIT_TYPES entry has a matching is_<name> wrapper on
  tool.Blender.Modifier.

array.py (+1130 LOC port from gizmos-8088):
* _ArrayEditMixin(ParametricEditMixinBase) drives the auto-generated
  enable / finish / cancel lifecycle.
* GizmoArrayEdition: validate + cancel + count display + +/- adjusters
  + method toggle + delete button + per-layer ARRAY entry icons
  (preallocated pool of MAX_LAYER_GIZMOS=8).
* GizmoArrayChild: child-array gizmo for the array-replica case.
* EditArrayFromChild: resolves the spawning layer via
  tool.Array.get_child_layer_index so clicking a child's array gizmo
  opens the layer that produced that child rather than always layer 0
  (the gizmos-8088 source itself hardcoded item=0; HEAD has the helper
  to do it right).
* New operators: EnableEditingArrayItem, ArrayParentGizmoClick,
  ArrayGizmoClick, ToggleArrayMethod, RemoveArrayLayerFromEdit,
  InputArrayCount, AdjustArrayCount.

prop.py: BIMArrayProperties gets per_child_opening BoolProperty
(when the array parent fills a host, give each child its own
opening + filling pair).

Bug fix: guard update_relating_array_from_object against the
cleanup-time None set. _finish_one writes relating_array_object = None
to clear the source-array reference; that fired the update callback,
which dispatched bpy.ops.bim.enable_editing_array(item=self.is_editing).
With is_editing just flipped to False, the bool coerced to 0 and
re-opened layer-0 edit immediately after every validate. The guard
short-circuits on None; item is also fixed to 0 (the bool-as-layer-
index was always meaningless for the legitimate user-pick path).

decorator.py (+312 LOC, all ports from gizmos-8088):
* bbox_world_edges / draw_polyline_segments / _BBOX_EDGES - shared
  geometry helpers usable across array decorators.
* draw_array_layer_children_bbox - green wireframe bbox per child of
  one array layer, drawn inline from a gizmo's draw() so the highlight
  tracks the hover cursor without POST_VIEW lag.
* ArrayPreviewDecorator - faint cyan ghost bboxes at each future
  array instance during the edit lifecycle (offset math mirrors
  Model.regenerate_array, gated on props.is_editing).
* ArraySelectionHighlightDecorator - bounding-box overlay surfacing
  the array family of the selected object. Child selected -> parent
  in special color + siblings in unselected color; parent selected
  (idle) -> all children in unselected color. TokenCache-backed.

handler.py: imports + uninstall/install the 2 always-on decorators in
_install_viewport_overlays. Both self-poll, so installation has no
cost when no array is selected / in edit mode.

Registration (bim/module/model/__init__.py):
* Adds the 3 lifecycle classes generated by build_edit_lifecycle
  (CancelEditingArray, EnableEditingArray, FinishEditingArray) -
  they exist as module-level names but are only visible to Blender's
  operator registry when included in the classes tuple.
* Adds the 8 new operators + 2 new gizmo groups in alphabetical order.

gizmos.py: restores the array_gizmo icon position + visibility block
in BaseParametricGizmoGroup.update_editing_gizmos. Was force-hidden
in c250b2c1a because no array gizmo existed; the icon's plumbing
comes back online now that GizmoArrayEdition is registered.

Verified by test/bim/test_parametric_registry.py: all 8 tests pass -
enable/finish/cancel ops resolve, PropertyGroup attached, is_array
predicate present, predicate is total on non-matching elements.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:40 +02:00
Gorgious56 e0ad34ffd4 Drop duplicate _path_connection_location_world in wall.py
PR3 shipped tool.Wall.path_connection_location_world; the local
_path_connection_location_world added in PR4 commit 70845e4dd
duplicated the same logic. The only caller in wall.py already uses
the tool method (line 3687 area), so the local helper has been
dead code since the migration in 7e5e7b8d6 routed _get_wall_geom_cached
to tool.Wall.read_geometry. Drop it.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:40 +02:00
Gorgious56 12f9e377c8 Route _has_material_styles through tool.Root.has_material_styles
Pre-existing architectural smell on v0.8.0: core/root.py.copy_class
called a module-level _has_material_styles helper that did
ifcopenshell.util.element.get_materials() directly, bypassing the
Prophecy mock seam that every other branch in copy_class flowed
through. Symptom: test/core/test_root.py::TestCopyClass::
test_AAAAAAAAAAAA passed mock strings into copy_class, the helper
called .is_a() on the string, AttributeError.

Move the check to tool.Root.has_material_styles (paired with
assign_body_styles — they're called in sequence as "is there a
material style? if not, assign body style"). core/root.py now
calls root.has_material_styles(new) like every other dependency,
fixing the test failure and dropping the ifcopenshell.util.element
import that was the only consumer of the ifcopenshell import at
module load in core/root.py.

* core/tool.py: add abstract has_material_styles to Root interface.
* tool/root.py: add concrete classmethod near assign_body_styles.
* core/root.py: replace _has_material_styles helper call site with
  root.has_material_styles; drop the local helper and its import.
* test/core/test_root.py: add the new mock expectation
  root.has_material_styles("element").will_return(False) before the
  existing assign_body_styles expectation.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:40 +02:00
Gorgious56 669e5c2aed Add behaviour-contract tests for PR4 surfaces
Three test files covering PR4's new surfaces — preview registry,
wall-gizmo poll behaviour, fillet operator registration. Every test
walks the live registry or class hierarchy instead of hard-coding
preview keys, operator names, or helper function names, so adding a
new preview / wall gizmo group / fillet operator exercises the same
invariants without test edits.

test_preview_base.py (6 tests):
* RegistryContract: every PREVIEW_CANCEL_OPS entry resolves to a
  callable cancel operator on bpy.ops.bim.
* GetPreviewPropsTolerance: get_preview_props returns None for
  contexts without a scene (regression guard for the SimpleNamespace
  bug fixed in commit ee63137c6).
* ActivationCycle (registry-driven loop): any_preview_active toggles
  with each registered preview's is_active flag;
  discard_pending_previews clears every active flag across every
  registered preview.
* SaveOnDiscardWired: locates the bim.save_project operator
  dynamically and verifies its execute path references the discard
  helper by its actual __name__.

test_wall_gizmo_poll_gate.py (4 tests):
* WallGizmoGroupsHideDuringPreview: walks the wall module for
  bpy.types.GizmoGroup subclasses (skips preview-owner exceptions
  whose bl_idname contains 'preview'), mocks any_preview_active to
  True, and asserts every discovered gizmo's poll returns False.
* BaseParametricGizmoPollHidesDuringPreview: mirrors the test for
  the cross-feature parametric framework base class.

test_fillet_operators.py (3 tests):
* FilletOperatorsRegistered: at-least-four-ops + every-discovered-op-
  is-callable. Catches accidental deregistration.
* EnableRejectsIneligibleSelection: poll returns False without a
  selection so the operator is greyed-out in menus.

State-clearing tests via bpy.ops.bim.cancel_wall_fillet_preview() are
deliberately omitted — the operator early-returns when context.screen
is unattached and prior tests in the model lane can leave the screen
in that state, making the dispatch path inherently flaky. Live testing
covers the behaviour.

Net: 13 tests pass cleanly in both single-file and full model lane.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:40 +02:00
Gorgious56 3f5273744d Discard previews on IFC save + harden preview-active gate
Save-path:
* SaveProject._execute (project/operator.py) now calls
  preview_base.discard_pending_previews(context.scene) right after
  tool.Parametric.commit_pending_edits(). Previews are session-
  transient — discard rather than commit. Sibling gizmo polls gate
  on each preview's is_active flag; a stuck flag persisted through
  the save would silently hide them on reload. Mirrors the pattern
  already in gizmos-8088.

Preview-active gate hardening:
* preview_base.get_preview_props tolerates contexts without a
  ``scene`` attribute. Pre-existing tests use SimpleNamespace mocks
  for the context; the previous getattr(context.scene, ...) raised
  AttributeError before the inner default kicked in.

Test update:
* test_wall_header_refresh.test_geom_generation_invalidates_wall_geom_cache
  patches tool.Wall.read_geometry instead of the now-deleted local
  wall._read_wall_geometry (commit 7e5e7b8d6 migrated the call site).

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:40 +02:00
Gorgious56 d6f55b0bf0 Drop wall.py local read_geometry + validate dupes + relax gates
Two cohesive cleanups in one commit.

A. Migrate wall.py to PR3-absorbed tool methods (fixes bug 4: pen icon
missing on fillet corner walls):

PR3 shipped tool.Wall.read_geometry + tool.Wall.validate_for_parametric_edit
but wall.py kept local duplicates predating that work. The local
_read_wall_geometry guards on tool.Blender.Modifier.is_wall (LAYER2-only)
while the tool method guards on tool.Parametric.is_path_connectable_wall
(LAYER2 OR fillet corner). Consequence: _get_wall_geom_cached → local
_read_wall_geometry returned None for every fillet corner →
GizmoWallFilletReedit.position_gizmos hit `if geom is None: hide` →
pen icon was unreachable for every fillet corner the user created.

Three _read_wall_geometry callers migrated to tool.Wall.read_geometry
(_read_wall_state_into_props, _get_wall_geom_cached,
GizmoWallJoinIntersection.position_gizmos). Two
_validate_wall_for_parametric_edit callers migrated to
tool.Wall.validate_for_parametric_edit (_maybe_resync_wall_props_from_ifc,
EnableEditingWall._execute). Local helpers deleted; docstring references
updated.

B. Drop over-restrictive gizmo gates (fixes bug 1: join icons missing
when walls intersect away from endpoints):

GizmoWallJoinIntersection.position_gizmos no longer hides itself when
the projected intersection lands further than MAX_DISTANCE_TO_ENDPOINT_
FACTOR (0.75 wall lengths) from any endpoint. The remaining
PARALLEL_DOT_THRESHOLD (cos 2°) gate via project_axis_intersection
returns None for near-parallel walls and is the only correctness bound;
distance from endpoints is a UI concern, not a geometric one.

GizmoWallFilletReedit.poll drops the has_a / has_b ConnectedFrom +
ConnectedTo guard — the IsFilletCorner pset is the authoritative signal.
EnableWallFilletPreviewFromCorner.execute already separately validates
both neighbour connections and reports a user-facing error if either
side is disconnected.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:40 +02:00
Gorgious56 bf6fd52786 Hide sister gizmos during preview + ESC cancels + DRY wall polls
Three live-session regressions surfaced after the fillet feature
landed.

Sister gizmos competed with the active preview:
* preview_base.any_preview_active(context): new helper iterates the
  PREVIEW_CANCEL_OPS registry and returns True if any preview is open.
  Future previews registered there automatically gate sister gizmos.
* BaseParametricGizmoGroup.poll (gizmos.py): short-circuits on
  any_preview_active so every parametric gizmo (door/window/stair/
  roof/railing/wall edition) hides during ANY preview.
* The 4 wall gizmo groups with explicit polls (GizmoWallAddOpening,
  GizmoWallExtendVertically, GizmoWallJoinIntersection,
  GizmoWallUnjoinSingle) + GizmoWallFilletReedit gain the same gate.

DRY: extract _wall_gizmo_poll_gate(context):
* 5 wall gizmo polls each duplicated the 2 pre-flight checks
  (viewport-gizmos enabled + no preview active). The helper centralises
  them — each poll becomes a single short-circuit line followed by its
  per-feature selection inspection.

ESC cancels the active preview:
* try_cancel_active_preview already existed in preview_base since PR3
  but had no caller. Hooked into OverrideEscape.execute (geometry/
  operator.py) as a new elif branch — same keymap that already cancels
  pen gizmo edit mode + item mode + edit mode + aggregate mode. Order
  in the branch chain matters: try preview cancel before falling back
  to try_canceling_editing_modifier_parameters_or_path so the in-
  flight preview wins over a stale modifier-edit cancel attempt.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:39 +02:00
Gorgious56 9f748fa4a9 Add wall-fillet feature: operators, gizmos, decorator
End-to-end fillet flow on top of the helpers + recreate_wall hook
(landed in the previous commit). Users select two LAYER2 walls, click
the fillet entry icon, drag the live radius widget, and validate to
replace the corner with a curved LAYER2 corner wall (banana body).

Operators (5):
* EnableWallFilletPreview: 2-wall selection → validates LAYER2 +
  straight axis + zero-slope + intersect-or-joined state → seeds the
  preview props with a default radius computed from the shorter
  available leg.
* FinishWallFilletPreview: dispatches CreateWallFillet with the tuned
  radius; clears preview state on FINISHED, preserves it on failure so
  the user can re-tune without re-selecting.
* CancelWallFilletPreview: clears preview state, no IFC mutation.
* EnableWallFilletPreviewFromCorner: pen-icon re-edit on an existing
  fillet corner — pre-fills the preview from the corner's BBIM_Wall
  pset + walks the inverse graph to recover wall A and wall B.
* CreateWallFillet: deletes any prior corner + A↔B path connection,
  shortens A and B to the tangent points, instantiates a corner wall
  from A's type, unassigns the swept-layer material/type (the explicit
  banana body MUST own its geometry), assigns the dominant material,
  rebuilds the body, sets a straight 2-point chord axis, stores
  BBIM_Wall.IsFilletCorner+FilletRadius, reconnects A and B to the
  corner with NOTDEFINED on the corner's side.

Gizmo groups (2 new + entry icon on existing):
* GizmoWallFilletPreview: visible while a preview is active. Bundles
  a radius_dim widget at the arc apex, a trim_dim widget along wall A
  expressing the same DOF via the leg setback distance
  (trim = |radius| * tan(sweep/2)), and validate / cancel icons
  anchored above the apex in screen-up.
* GizmoWallFilletReedit: pen-icon entry on an existing fillet corner
  wall (single-selection, BBIM_Wall.IsFilletCorner set, both neighbour
  connections present). Mutually exclusive with an active preview.
* GizmoWallJoinIntersection now stacks a fillet entry icon
  (VIEW3D_GT_fillet → bim.enable_wall_fillet_preview) above the
  existing join/unjoin icon in the joined and intersect state branches.

Property + decorator infrastructure:
* prop.py: BIMWallFilletPreviewProperties (Scene-level draft) +
  BIMPreviewProperties umbrella with only the wall_fillet pointer.
  The umbrella is the seam preview_base.py (landed in PR3) already
  reads via getattr(scene, "BIMPreviewProperties", None).
* decorator.py: _stroke_lines_alpha helper + WallFilletPreviewDecorator.
  Polls is_active; renders leg projections + arc + arc-center
  construction lines from tool.Wall.compute_wall_fillet_geometry.
* __init__.py: registers operators + gizmo groups + property groups +
  wires Scene.BIMPreviewProperties.
* handler.py: WallFilletPreviewDecorator.install/uninstall in
  _install_decorators — always installed, self-polls on is_active.

Drive-by: extract gizmo.get_screen_up(billboard_rot) helper —
the local +Y of a billboard rotation is the camera's screen-up world
direction. Replaces 4 inline `billboard_rot @ Vector((0.0, 1.0, 0.0))`
sites added across the fillet feature's gizmo groups.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:39 +02:00
Gorgious56 eeede522d9 Add wall-fillet helper functions + recreate_wall hook
Eleven module-level helpers in wall.py that the upcoming wall-fillet
operators + gizmo groups depend on. Each is self-contained or
references only helpers earlier in the file; the operators and
gizmos themselves land in follow-up commits.

* _wall_fillet_props / _wall_fillet_preview_active /
  _wall_fillet_preview_walls: thin read-side accessors over the
  BIMPreviewProperties.wall_fillet pointer (added with the
  operators commit). Safe today: get_preview_props returns None
  until the pointer is attached.
* _walls_have_zero_slope_for_fillet: validates that input walls
  are vertical (x_angle ~ 0); slanted-extrusion fillets require
  swept-along-curve geometry the banana profile builder doesn't
  support.
* _build_curved_corner_body_representation: builds the banana
  (annular sector) IfcExtrudedAreaSolid as a polyline-tessellated
  IfcIndexedPolyCurve.
* _apply_fillet_corner_geometry: positions the corner wall at
  tangent_a and rebuilds its body. Shared by the creation operator
  and the regenerate path.
* _resolve_two_walls: pulls (active, other) from a 2-wall
  selection, validates both as LAYER2 + straight-axis + not-already-
  a-fillet-corner.
* _pick_dominant_wall_material: returns the thickest layer's
  material from an element's IfcMaterialLayerSet / Usage.
* regenerate_fillet_corner_wall: re-runs the geometry build from
  BBIM_Wall.FilletRadius + current neighbour layer parameters.
  Called by tool.Model.recreate_wall when the IsFilletCorner pset
  is set; the FIXME(PR4) placeholder in recreate_wall is dropped.
* _wall_fillet_gizmo_x_matrix: 4x4 placement matrix with local +X
  aligned to a world-space direction; used by the fillet preview
  gizmo group.

Centralises the IsFilletCorner pset read as
tool.Parametric.is_fillet_corner_wall — replaces 3 inline
get_pset(element, "BBIM_Wall", "IsFilletCorner") sites
(tool.Model.recreate_wall, tool.Model.recalculate_walls,
tool.Parametric.is_path_connectable_wall) plus the new
_resolve_two_walls call.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:39 +02:00
Gorgious56 d955763f63 Gate parametric-edit array gizmo until integration completes
The framework's parametric-edit icon row currently binds an array
icon to bim.add_array_from_feature_edit, but the supporting per-
feature add-array flow and gizmo positioning haven't fully landed.
Showing the icon today lets the user click it and trigger a half-
wired flow.

Force the icon hidden inside the props.is_editing branch of
BaseParametricGizmoGroup.update_editing_gizmos. The else-branch
(not editing) already hides it, so this just mirrors that behavior
during edit mode. Drop this gate when array integration completes
to re-enable the icon position + visibility plumbing.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:39 +02:00
Gorgious56 93c51c1a39 Add cursor-aware extend-arrow flip on wall edit gizmos
The extend-X / extend-Z icons in GizmoWallEdition's cursor row are
billboarded toward the camera; without orientation polish they
always point in the same screen-space direction regardless of which
wall endpoint the click will move (or whether the cursor sits above
or below the wall top). New helper mirrors the icon's local-X (extend-X)
or local-Y (extend-Z) axis so each arrow points toward the end it
will move:

* Extend-X: walk wall midpoint to figure out which endpoint stays
  fixed (cursor past midpoint → ATSTART stays; cursor before midpoint
  → ATEND stays). Project the fixed endpoint into screen-space and
  flip the arrow when the gizmo's anchor sits on the same side.
* Extend-Z: flip when the cursor is below the wall top (within
  EXTEND_FLIP_EPSILON tolerance).

Called once per resolved cursor gizmo from
``GizmoWallEdition._update_cursor_gizmos``, after the gizmo's
``matrix_basis`` is set by ``gizmo.billboarded_at``. Reuses
``gizmo.should_flip_extend_arrow`` + ``EXTEND_FLIP_MIRROR_X/Y`` +
``EXTEND_FLIP_EPSILON`` already on tool.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:39 +02:00
Gorgious56 deaf090a50 Add single-wall unjoin operator + gizmo group
GizmoWallJoinIntersection's unjoin only fires when exactly two walls
are selected and surfaces one icon at their shared corner — useless
when the wall has 3+ joins and the user wants to disconnect just one.

* UnjoinWallPathConnection: surgical counterpart to UnjoinWalls.
  Disconnects the active wall from a single partner wall identified
  by IFC GlobalId (invariant under Blender-object renames + file
  save/reload + undo). Walks both inverse arrays of the active wall
  for the specific IfcRelConnectsPathElements joining the pair —
  matches DumbWallJoiner.split's pattern and avoids disconnect_path's
  direction-sensitivity. Resyncs both walls' draft props after the
  recreate_wall pass.
* GizmoWallUnjoinSingle: activates on exactly-one selected
  LAYER2 wall. Preallocates a pool of 16 unjoin icons (Blender forbids
  gizmo allocation outside setup(); ATSTART + ATEND + ATPATH rels are
  rarely more than a handful). Per-frame, iterates _iter_path_connections,
  positions one billboarded icon at each join via
  tool.Wall.path_connection_location_world, and hides the rest. Each
  visible icon's bound operator carries the partner GlobalId, so a
  click removes only that one rel.
* model/__init__.py: register both classes alphabetically.

Mutually exclusive with GizmoWallJoinIntersection via poll() — that
group requires len(selected) == 2; this one requires 1.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:39 +02:00
Gorgious56 db94877d8b Add wall path-connection inverse-walk helpers
The single-wall unjoin gizmo needs to enumerate every
IfcRelConnectsPathElements a wall participates in, regardless of which
side of the rel the wall was authored on, and place an icon at each
join's physical location. Two helpers carry that work:

_path_connection_location_world wraps core.compute_path_connection_location
at the Vector boundary. _iter_path_connections walks ConnectedTo +
ConnectedFrom, normalises orientation to (other, self_ct, other_ct),
and filters non-wall partners + None refs so per-frame gizmo positioning
survives malformed IFC.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:39 +02:00
Gorgious56 8e93fde930 Add wall draft-resync helper + wire 6 mutation operators
After a one-shot wall IFC mutation (unjoin / split / merge / extend /
join-at-corner …) the always-visible gizmos on the OTHER side of the
join can be left reading stale ``BIMWallProperties`` — the IFC
geometry moved but the draft props that drive the gizmo handles still
point at the pre-mutation numbers, so a subsequent edit-mode enter
shows the wall at its old length / position.

* New ``_maybe_resync_wall_props_from_ifc(obj)``: re-primes a single
  wall's draft props from current IFC, with guards for non-walls,
  non-parametric walls, and walls in an active draft session (the
  draft is then the source of truth, not IFC). Must run from an
  operator ``_execute`` — ID writes from gizmo refresh raise.
* New ``_resync_walls_after_mutation(objs)``: iterates the above
  across a selection.
* Six existing mutation operators gain a resync call after their
  ``core.*`` / ``DumbWallJoiner`` mutation completes:
  UnjoinWalls, ExtendWallsToUnderside, ExtendWallsToWall, SplitWall,
  MergeWall, JoinWallsIntersection. MergeWall resyncs only the
  surviving wall — the active wall is the deletion target.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:39 +02:00
Gorgious56 f28b901a49 Fix parametric framework live-session regressions
Bundle of bugs surfaced when exercising the new gizmo framework
end-to-end in a live Blender session after the
bim/module/drawing/gizmos.py refactor + TypeAccessor/CycleType/PickType
mixins landed.

Register / annotation resolution
* parametric_lifecycle.py: hoist `entity_instance` import out of
  TYPE_CHECKING so typing.get_type_hints resolves the
  Callable[[entity_instance], bool] annotation at operator registration
  (CycleDoorType, CycleWindowType, CycleStairType failed with NameError).
  Clarify the INTERFACE return contract on the picker entry-point so
  readers see why the gizmo step stays off the undo stack.

Framework callable contracts
* model/wall.py, door.py, window.py, stair.py: migrate `props_getter`
  and `element_checker` from bl_idname strings to bound classmethods
  on tool.Model / tool.Parametric. BaseParametricGizmoGroup.get_props
  expects a callable; the string form raised TypeError on first
  gizmo poll.
* model/door.py, model/stair.py: drop the dead `prop_path=` operator
  kwarg from create_arc_gizmo / create_icon_gizmo call sites. The
  framework helper blindly setattrs every kwarg onto the operator's
  OperatorProperties, but ToggleDoorSwing / ToggleStairProperty don't
  declare prop_path — the setattr raised mid-setup_element_specific_gizmos,
  so self.gizmo_door_type / self.lock_gizmo never got assigned and
  every subsequent draw_prepare tornadoed AttributeError. Nothing
  reads op.prop_path anywhere; the kwarg was dead data.

Dispatcher operators
* model/array.py: add EnableEditingParametric (the framework pen-icon
  dispatcher that routes to a per-feature edit operator by bl_idname
  string) and AddArrayFromFeatureEdit (binds the framework's array
  icon to bim.add_array on the current parametric draft).
* model/__init__.py: register both new operators.

Per-frame robustness
* drawing/gizmos.py: guard BaseParametricGizmoGroup.draw_prepare with
  is_setup_complete() — matches the existing guard in refresh() and
  in BaseSchematicGizmoGroup.draw_prepare(). Defense-in-depth: when
  any subclass's setup raises mid-way, draw_prepare now no-ops cleanly
  instead of per-frame AttributeError-tornadoing on whatever attribute
  the failed setup phase was meant to populate.
* model/decorator.py: guard ProfileDecorator.__call__ against
  context.active_object is None. The decorator is a per-frame
  viewport draw handler; deselecting or deleting the active object
  while it's installed crashed on obj.mode access. Treat None the
  same as "no longer in edit mode" — uninstall + fire the exit
  callback if present.
* geometry/data.py: ViewportData.load() populates `data` before
  flipping `is_loaded`, so a raise from cls.mode() no longer leaves
  the class flag-set but data-empty for subsequent reads.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:38 +02:00
Gorgious56 1b272c039d Refactor bim/module/drawing/gizmos — framework + icon infra
Three concerns bundled into one cohesive refactor of gizmos.py
(splitting them surgically requires intermediate commits with
duplicate same-named classes that Python can't parse):

1. Framework primitives — StaticTrisGizmoMixin + TexturedQuadGizmoMixin
   replace the older TrisGizmoMixin. New module-level helpers:
   _get_static_tris_shader / _get_static_tris_batch / clear_static_
   tris_cache for cached GPU batch reuse, _draw_outline_and_body for
   the shared outline-then-body render path, draw_tris_with_outline
   as the public wrapper. billboarded_at(world_pos, billboard_rot,
   scale) is the canonical billboard-matrix helper; should_flip_extend_
   arrow encapsulates the view-aware mirror decision for extend
   gizmos; get_warning_color_from_prefs reads the user's warning
   color.

2. Config classes — BaseValueGizmoConfig (shared visibility + dimension-
   text contract), CountGizmoConfig (array N indicator),
   DimensionGizmoConfig (length / height / depth labels), IconActionConfig
   (icon-only gizmos that invoke an operator on click). DimensionRenderer
   draws the actual numeric label using BLF.

3. Icon classes — each rewritten on StaticTrisGizmoMixin so they share
   the cached GPU batch + outline-then-body render path:
   GizmoLockOpen / GizmoLockClosed (replacing the single-state
   GizmoLock), GizmoArc, GizmoFillet, GizmoWallCornerIcon,
   GizmoWallTeeIcon, GizmoPen / GizmoValidate / GizmoCancel (the
   parametric-edit triad), GizmoPlus / GizmoMinus / GizmoTrash,
   GizmoArrayParent / GizmoArrayAll / GizmoArrayLayerIndicator (array
   context indicators with a small digit-rendering helper for the "xN"
   count label), GizmoMerge / GizmoSplit / GizmoUnjoin (wall-join
   icons), and GizmoMenu (textured-quad icon-action menu trigger).

The legacy TrisGizmoMixin, GizmoLock, and DimensionDrawConfig are
removed; downstream callers in subsequent PR4 commits swap to the
new mixin and config classes when their feature operators land.

CycleTypeMixin / PickTypeMixin / TypeAccessorBase live in
bim.parametric_lifecycle (previous commit). The three mixins are
re-exported from gizmos.py here so feature-module access via
``gizmo.<MixinName>`` keeps working until PR5 cleanup drops the
re-exports.

bim/module/drawing/__init__.py is updated in the same commit to
register the 11 new gizmo classes (GizmoLockOpen / GizmoLockClosed /
GizmoFillet / GizmoWallCornerIcon / GizmoWallTeeIcon / GizmoTrash /
GizmoArrayParent / GizmoArrayAll / GizmoArrayLayerIndicator /
GizmoUnjoin / GizmoMenu) — without that, the new classes exist in
gizmos.py but aren't usable as bpy gizmo types.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:38 +02:00
Gorgious56 b84be2e84c Add TypeAccessorBase + CycleTypeMixin + PickTypeMixin
Three operator mixins for type-selection ops on parametric features
(door type-cycle, window type-pick, stair type-cycle, railing
type-pick, roof type-cycle, etc.). Each shares the same contract:

* ``element_checker`` validates the active object is the expected
  IFC type
* ``props_getter`` resolves the BIM<Name>Properties group
* ``type_literal`` is the Literal type whose args drive the enum
* ``type_attr`` is the PropertyGroup field to read/write
* ``skip_element_check=True`` bypasses element validation (for
  operators that target a non-IFC context)

CycleTypeMixin shift-click reverses direction (forward by default).
PickTypeMixin opens a popup menu and routes the picked value
through execute() so F6 redo / EXEC_DEFAULT reach the apply path.
The PickType modal-handler dance waits for LEFTMOUSE release before
opening the menu when invoked mid-click (e.g. from a gizmo's
target_set_operator) so Blender's drag-through-pick gesture doesn't
commit an accidental item.

Ships standalone — the next commit's gizmos.py framework refactor
re-exports these names from bonsai.bim.parametric_lifecycle so
gizmo modules can spell ``gizmo.CycleTypeMixin`` / ``gizmo.PickTypeMixin``.
Concrete operator subclasses land in subsequent PR4 commits per
feature (door / window / stair / railing / roof).

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:38 +02:00
Bruno Perdigão d179c4415c Remove debug print 2026-06-11 18:34:38 +02:00
Bruno Perdigão cf34be07e1 Add more no headless test for snap 2026-06-11 18:34:38 +02:00
Bruno Perdigão 5f5e7de5f6 Merge tests into a single file 2026-06-11 18:34:38 +02:00
Bruno Perdigão bc07342354 Add test files and scripts 2026-06-11 18:34:38 +02:00
Bruno Perdigão 64dadac8d6 Initial implementation of tests for modal operators 2026-06-11 18:34:38 +02:00
Gorgious56 89b4072d6d Bonsai Makefile - pin deepdiff<9.1
deepdiff 9.1.0 added cachebox<6,>=5.2 as a direct runtime dep.
cachebox 5.2.3 only publishes macOS x86_64 wheels for macosx_10_12+,
incompatible with the macos py311 build's --platform macosx_10_10_x86_64.
The daily build's linux-wheel safeguard fires when the resulting
cachebox-*-manylinux_*.whl leaks into the macOS / windows wheels folder
(builds run on ubuntu-latest and cross-build via pip download --platform).

Pin deepdiff to <9.1 (resolves to 9.0.0, no cachebox transitive dep) as
the minimal hotfix. Long-term cleanup: bump the macos py311 platform tag
from 10_10 to 10_13 (matching py312/py313) and re-flag this line with the
standard \$(PYPI_PLATFORM) --only-binary=:all: pattern used by brickschema
and python-socketio.

Partly generated with the assistance of an AI coding tool.
2026-06-11 18:34:38 +02:00
Ryan Schultz ee3f49b111 Restore pre-aggregate selection on exit; deselect on unsupported profile
When override_mode_set_edit encounters an unsupported profile (Couldn't
import profile), deselect the object so Tab continues to cycle cleanly.

Also restores the selection that existed before entering aggregate mode
when finally tabbing out, via save/restore_previous_selection().
2026-06-11 18:34:38 +02:00
Ryan Schultz 73063cebf1 Deselect geometry after exiting item mode in aggregate context
Following the pattern from 586f9be077, deselect the active object after
exiting item mode so Tab continues to cycle cleanly. Also deselects
parametric LAYER1/LAYER2 items that cannot be edited directly, avoiding
the need to manually deselect before Tab-cycling out of aggregate mode.
2026-06-11 18:34:38 +02:00
Ryan Schultz 656e3bb4e8 Add select_similar to type attribute panels
In BIM_PT_type_attributes and BIM_PT_object_attributes (when
the active object is a type), attribute value buttons now use
"type.<Attr>" as the selector key so the operator finds
matching occurrences via their relating type rather than the
occurrence's own (often unset) attributes.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:38 +02:00
Ryan Schultz f1a4ea4207 Add clipboard copy to SelectSimilarContainer operator
After selecting objects in the same container, copy a `location="Name"`
filter query to the clipboard and report it — consistent with the same
behaviour in SelectSimilarType, SelectSimilarAggregate, SelectIfcClass,
and SelectSimilarMaterial.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:37 +02:00
Ryan Schultz 70ec460376 Fix #8128: Fix filter_elements skipping groups after a zero-result facet_list
When a `+`-separated filter group returns no results, `FacetTransformer.facet_list`
was skipping the reset of `has_additive_facet_in_current_list` because the reset
was inside the `if self.elements:` guard. The stale flag caused the next group's
`add_default_elements()` to bail out early, leaving its element set empty and
silently dropping every subsequent group from the result.

Move the flag reset outside the guard so it always fires regardless of whether
the group produced any results.
2026-06-11 18:34:37 +02:00
Ryan Schultz 51ba331226 Closes #8127: Add imperial location display to Placement panel
In the Placement panel, show Location and Rotation X/Y/Z
each on their own row beneath a header label. When the IFC
file uses imperial units, display a read-only feet-and-inches
label alongside each Location input field.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:37 +02:00
Ryan Schultz efcabed10d Format stair lengths using IFC length unit
Display general and calculated stair parameters (Width,
Height, Tread Run, Tread Rise, Length, etc.) formatted
to the IFC file's configured length unit rather than
raw numeric values.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:37 +02:00
carlopav 11154baa39 IfcCostSchedule PDF export with typst: fix bugs
Fixed a bug when a summary cost has no sum applied.
Added Currency in table header.
Cleanup.
Added guards for end summary.
2026-06-11 18:34:37 +02:00
Gorgious56 3e9b947b3f Cache opening previews + dissolve fill
DecorationsHandler now caches dissolved edges (mesh-keyed), world-space
draw payload, and GPUBatch objects with per-object epoch invalidation —
moving one wall doesn't wipe 50 opening caches. Object-mode dissolve
removes triangulation noise; 2-pass depth-test split dims occluded lines
instead of hiding them. Edit-mode behavior unchanged.

Also: disable viewport shadows for IfcFeatureElementSubtraction objects,
and wire DecorationsHandler.uninstall() into the model module's
unregister() so the new persistent handlers don't leak on addon disable.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:37 +02:00
Ryan Schultz 37456c9a1d Fix CardinalPoint not applied to all selected objects
EditAssignedMaterial propagated layer set usage attributes
to all selected objects but skipped this loop for profile
set usage. Add the same loop so CardinalPoint and
ReferenceExtent are copied to each selected object's
IfcMaterialProfileSetUsage on save.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:37 +02:00
Ryan Schultz 4e54f67cc0 Fix negative zero in imperial feet-inches parser
When the user enters `-0' - 10"`, Python parses feet as -0.0.
The check `feet < 0` is False for negative zero, so the sign was
silently dropped. Use math.copysign to detect it correctly.

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:37 +02:00
Gorgious56 f9f756ae79 Add tests for decorator_cache + undo-resync dispatch
Two paired test files for the framework infrastructure landed
earlier in this PR.

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

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

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

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

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

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

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

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

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

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

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

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

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:36 +02:00
Gorgious56 aba9986628 Refactor bim/parametric_lifecycle — drift triad + Cancel polish
Three changes to the shared Enable/Finish/Cancel mixins:

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

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

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

Plus two polish changes:

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

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

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:36 +02:00
Gorgious56 ed6530f68b Decompose bim/handler.py load_post + install cache + discard hooks
Three concerns folded into ``load_post`` argue for separation:

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

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

Two new hooks land with the decompose:

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

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

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

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

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

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

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

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

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

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

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:36 +02:00
Gorgious56 3f9ecbeda1 Add bim/decorator_cache module — TokenCache + handler primitives
New helper module for POST_VIEW decorators. Exports:

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

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

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:36 +02:00
Gorgious56 731b057892 Fix latent runtime bugs + ty annotations surfaced by CI
Five code paths in slim PR2 referenced symbols that don't exist in
v0.8.0's bim layer, raising at first call. Plus three type
annotations that ty flagged as unresolved.

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

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

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

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

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

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

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:36 +02:00
Gorgious56 7d77ea032c Add addon-load smoke test pinning register/unregister cycle
Surfaces any regression in:

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

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

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:36 +02:00
Gorgious56 8669f53fc1 Fix tool.Parametric to ship safely on v0.8.0 bim layer
Three corrective fixes folded into one commit. All surface as
addon-load / save-time exceptions on v0.8.0's bim layer because
PR2's tool.Parametric refactor over-committed to the PR4 contract.

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

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

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

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:36 +02:00
Gorgious56 3eafc9dc49 Extract bim/ifc + tool/cad helpers referenced by PR2
Fixes addon-load ImportError that surfaces when tool/geometry.py
and tool/model.py (extracted in C8 / C9) reference symbols that
don't exist on v0.8.0:

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

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

Generated with the assistance of an AI coding tool.
2026-06-11 18:34:35 +02:00