Sweep docstrings for rot-prone references

Docstrings naming sibling methods, private helpers, test files, or
historical symbols silently go wrong on rename. Strip Sphinx :meth:
/ :class: / :func: / :attr: markup that mostly added noise (no
Sphinx in this project), and rewrite five docstrings that cited
specific test paths or private hooks to describe the behaviour
instead.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-05-21 11:09:29 +02:00
parent 1e36cc318e
commit 6caf94f1d3
6 changed files with 96 additions and 88 deletions
+5 -12
View File
@@ -31,18 +31,11 @@ from . import handler, operator, prop, ui
def _parametric_gizmo_preference_classes() -> list[type]: def _parametric_gizmo_preference_classes() -> list[type]:
"""Lazy resolution. ``bonsai.tool/__init__.py`` transitively loads """Resolves the registry-driven ``GizmoPreferences<X>`` classes for the
``tool/ifc.py`` (and several siblings) which import ``from bonsai.bim.ifc ``classes`` list below. ``import bonsai.tool`` is kept local to surface
import IfcStore`` at module top — that ``tool → bim`` cycle means the load-order constraint: it relies on ``from . import handler, …``
``bonsai.tool`` cannot be imported here before ``from . import handler, …`` above having primed the
above has primed the bim partial-import dance through ``handler``'s own ``tool/ifc.py → bim/ifc.py → bim/handler.py → bonsai.tool`` cycle."""
``import bonsai.tool``. By the time this function runs (during the
classes-tuple build below), ``handler`` has fully loaded and ``bonsai.tool``
is safely importable.
The architectural root cause is ``IfcStore`` living in ``bim/ifc.py``;
moving it to ``tool/ifc.py`` would let ``tool/`` stop reaching into ``bim/``
and eliminate the need for this indirection. Tracked separately."""
import bonsai.tool as tool import bonsai.tool as tool
return tool.Parametric.iter_gizmo_preference_classes(ui) return tool.Parametric.iter_gizmo_preference_classes(ui)
+33 -28
View File
@@ -3168,7 +3168,7 @@ class GizmoMerge(TrisGizmoMixin, bpy.types.Gizmo):
class GizmoSplit(TrisGizmoMixin, bpy.types.Gizmo): class GizmoSplit(TrisGizmoMixin, bpy.types.Gizmo):
"""Two arrows pointing outward away from each other — conveys splitting/cutting """Two arrows pointing outward away from each other — conveys splitting/cutting
one element into two. Visual inverse of :class:`GizmoMerge`.""" one element into two. Visual inverse of `GizmoMerge`."""
bl_idname = "VIEW3D_GT_split" bl_idname = "VIEW3D_GT_split"
@@ -3216,7 +3216,7 @@ class GizmoExtend(TrisGizmoMixin, bpy.types.Gizmo):
class GizmoExtendVertical(TrisGizmoMixin, bpy.types.Gizmo): class GizmoExtendVertical(TrisGizmoMixin, bpy.types.Gizmo):
"""Vertical sibling of :class:`GizmoExtend` — arrow pointing UP into a horizontal """Vertical sibling of `GizmoExtend` — arrow pointing UP into a horizontal
bar. Conveys extending an element's height to a target Z.""" bar. Conveys extending an element's height to a target Z."""
bl_idname = "VIEW3D_GT_extend_vertical" bl_idname = "VIEW3D_GT_extend_vertical"
@@ -4211,7 +4211,7 @@ class BillboardingGizmoGroupMixin:
operator: str, operator: str,
alpha: float = 0.8, alpha: float = 0.8,
) -> bpy.types.Gizmo: ) -> bpy.types.Gizmo:
"""Convenience wrapper over :func:`setup_icon_gizmo` for subclasses.""" """Convenience wrapper over `setup_icon_gizmo` for subclasses."""
return setup_icon_gizmo(self, gizmo_type, color, highlight_color, operator, alpha) return setup_icon_gizmo(self, gizmo_type, color, highlight_color, operator, alpha)
def position_gizmos(self, context: bpy.types.Context) -> None: def position_gizmos(self, context: bpy.types.Context) -> None:
@@ -4445,7 +4445,7 @@ class BaseParametricGizmoGroup:
) -> float: ) -> float:
"""Y coordinate just outside the camera-facing face of an element. """Y coordinate just outside the camera-facing face of an element.
Generalises :meth:`get_y_position_for_view` for elements whose near face Generalises `get_y_position_for_view` for elements whose near face
isn't at the local origin. ``near_y`` is the local-Y of the -Y face; isn't at the local origin. ``near_y`` is the local-Y of the -Y face;
``far_y`` is the local-Y of the +Y face. Returns the Y just *outside* the ``far_y`` is the local-Y of the +Y face. Returns the Y just *outside* the
face the camera is currently looking at, pushed by ``gizmo_offset`` (use face the camera is currently looking at, pushed by ``gizmo_offset`` (use
@@ -4670,11 +4670,10 @@ class BaseParametricGizmoGroup:
""" """
pass pass
# Frame-scoped caches populated by :meth:`_prime_frame_caches` at the top of # Frame-scoped caches primed at the top of ``refresh()`` and ``draw_prepare()``.
# ``refresh()`` and ``draw_prepare()``. Every per-frame helper — preferences # Every per-frame helper — preferences access, view-direction lookup, billboard
# access, view-direction lookup, billboard rotation — reads these instead of # rotation — reads these instead of re-deriving the same values, since each
# re-deriving the same values, since each gizmo group ends up needing them # gizmo group ends up needing them 25× per frame across its position helpers.
# 25× per frame across its position helpers.
_frame_prefs: Any = None _frame_prefs: Any = None
_frame_view_dir: tuple[bool, bool] | None = None _frame_view_dir: tuple[bool, bool] | None = None
_frame_billboard_rot: "Matrix | None" = None _frame_billboard_rot: "Matrix | None" = None
@@ -4939,7 +4938,7 @@ class BaseParametricGizmoGroup:
) -> bpy.types.Gizmo: ) -> bpy.types.Gizmo:
"""Create and configure an icon gizmo with standard settings. """Create and configure an icon gizmo with standard settings.
Thin wrapper over :func:`setup_icon_gizmo` that defaults ``highlight_color`` Thin wrapper over `setup_icon_gizmo` that defaults ``highlight_color``
to the addon-prefs selection color via ``get_decoration_colors``. to the addon-prefs selection color via ``get_decoration_colors``.
""" """
if highlight_color is None: if highlight_color is None:
@@ -5112,33 +5111,39 @@ class BaseParametricGizmoGroup:
icon_z = self.get_element_height(props) + self.ICON_Z_OFFSET icon_z = self.get_element_height(props) + self.ICON_Z_OFFSET
icon_y = self.get_icon_y_offset(context, mw) icon_y = self.get_icon_y_offset(context, mw)
billboard_rot = self._frame_billboard_rot billboard_rot = self._frame_billboard_rot
# set_icon_gizmo_position no-ops on hidden gizmos (via get_gizmo_if_visible),
# This ensures icons face camera regardless of object rotation # so the hide flag must be set first; that gates whether the matrix is written.
local_pos_validate = Vector((self.ICON_VALIDATE_X, icon_y, icon_z))
world_pos_validate = mw @ local_pos_validate
icon_matrix_base = Matrix.Translation(world_pos_validate) @ billboard_rot @ Matrix.Scale(0.5, 4)
if props.is_editing: if props.is_editing:
self.pen_gizmo.hide = True self.pen_gizmo.hide = True
self.validate_gizmo.hide = self.is_gizmo_hidden_by_modal(self.validate_gizmo) self.validate_gizmo.hide = self.is_gizmo_hidden_by_modal(self.validate_gizmo)
self.validate_gizmo.matrix_basis = icon_matrix_base self.set_icon_gizmo_position(
"validate_gizmo", mw=mw, x=self.ICON_VALIDATE_X, y=icon_y, z=icon_z, billboard_rot=billboard_rot
)
self.cancel_gizmo.hide = self.is_gizmo_hidden_by_modal(self.cancel_gizmo) self.cancel_gizmo.hide = self.is_gizmo_hidden_by_modal(self.cancel_gizmo)
local_pos_cancel = Vector((self.ICON_VALIDATE_X + self.ICON_CANCEL_X, icon_y, icon_z)) self.set_icon_gizmo_position(
world_pos_cancel = mw @ local_pos_cancel "cancel_gizmo",
self.cancel_gizmo.matrix_basis = Matrix.Translation(world_pos_cancel) @ billboard_rot @ Matrix.Scale(0.5, 4) mw=mw,
x=self.ICON_VALIDATE_X + self.ICON_CANCEL_X,
y=icon_y,
z=icon_z,
billboard_rot=billboard_rot,
)
if self.cycle_type_operator: if self.cycle_type_operator:
self.cycle_gizmo.hide = self.is_gizmo_hidden_by_modal(self.cycle_gizmo) self.cycle_gizmo.hide = self.is_gizmo_hidden_by_modal(self.cycle_gizmo)
local_pos_cycle = Vector((self.ICON_VALIDATE_X + self.ICON_CYCLE_X, icon_y, icon_z)) self.set_icon_gizmo_position(
world_pos_cycle = mw @ local_pos_cycle "cycle_gizmo",
self.cycle_gizmo.matrix_basis = ( mw=mw,
Matrix.Translation(world_pos_cycle) @ billboard_rot @ Matrix.Scale(0.30, 4) x=self.ICON_VALIDATE_X + self.ICON_CYCLE_X,
y=icon_y,
z=icon_z,
billboard_rot=billboard_rot,
scale=0.30,
) )
else: else:
self.pen_gizmo.hide = self.is_gizmo_hidden_by_modal(self.pen_gizmo) self.pen_gizmo.hide = self.is_gizmo_hidden_by_modal(self.pen_gizmo)
self.pen_gizmo.matrix_basis = icon_matrix_base self.set_icon_gizmo_position(
"pen_gizmo", mw=mw, x=self.ICON_VALIDATE_X, y=icon_y, z=icon_z, billboard_rot=billboard_rot
)
self.validate_gizmo.hide = True self.validate_gizmo.hide = True
self.cancel_gizmo.hide = True self.cancel_gizmo.hide = True
if self.cycle_type_operator: if self.cycle_type_operator:
+3 -4
View File
@@ -614,15 +614,14 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002 self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
) -> None: ) -> None:
"""Update stair-specific lock and tread count gizmos. Lock positioning is """Update stair-specific lock and tread count gizmos. Lock positioning is
handled per-frame in :py:meth:`_update_lock_gizmo_position`.""" handled per-frame in the dimension-positioning hook."""
self.update_lock_gizmo(props) self.update_lock_gizmo(props)
self.update_tread_lock_gizmo(props) self.update_tread_lock_gizmo(props)
self.update_tread_count_gizmos(props) self.update_tread_count_gizmos(props)
def update_lock_gizmo(self, props: "BIMStairProperties") -> None: def update_lock_gizmo(self, props: "BIMStairProperties") -> None:
"""Update lock gizmo color and visibility. Positioning is handled in """Update lock gizmo color and visibility. Positioning is handled
:py:meth:`_update_lock_gizmo_position` (called per frame via per-frame by the dimension-positioning hook."""
:py:meth:`_update_dimension_gizmo_positions`)."""
gizmo_prefs = self.get_gizmo_prefs() gizmo_prefs = self.get_gizmo_prefs()
if not self.update_gizmo_visibility(self.lock_gizmo, props.is_editing, gizmo_prefs.lock): if not self.update_gizmo_visibility(self.lock_gizmo, props.is_editing, gizmo_prefs.lock):
return # Hidden, skip color update return # Hidden, skip color update
+13 -11
View File
@@ -1617,6 +1617,7 @@ class FinishEditingWall(bpy.types.Operator, tool.Ifc.Operator):
height_changed = not tool.Cad.is_x(props.height, props.snap_height, tolerance=1e-5) height_changed = not tool.Cad.is_x(props.height, props.snap_height, tolerance=1e-5)
x_angle_changed = not tool.Cad.is_x(props.x_angle, props.snap_x_angle, tolerance=1e-5) x_angle_changed = not tool.Cad.is_x(props.x_angle, props.snap_x_angle, tolerance=1e-5)
baseline_changed = props.desired_offset_baseline != props.snap_offset_baseline baseline_changed = props.desired_offset_baseline != props.snap_offset_baseline
any_change = length_changed or height_changed or x_angle_changed or baseline_changed
# Order matters: baseline shifts the layer-set reference line, then length # Order matters: baseline shifts the layer-set reference line, then length
# adjusts endpoints relative to that, then x_angle changes the slope (and # adjusts endpoints relative to that, then x_angle changes the slope (and
@@ -1638,7 +1639,7 @@ class FinishEditingWall(bpy.types.Operator, tool.Ifc.Operator):
if height_changed: if height_changed:
bpy.ops.bim.change_extrusion_depth(depth=props.height) bpy.ops.bim.change_extrusion_depth(depth=props.height)
if length_changed or height_changed or x_angle_changed or baseline_changed: if any_change:
props.mesh_dirty = False props.mesh_dirty = False
else: else:
_restore_wall_mesh_if_dirty(obj) _restore_wall_mesh_if_dirty(obj)
@@ -1816,7 +1817,6 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
# The baseline icons (EXT / CEN / INT) all share ICON_CYCLE_X — only one is # The baseline icons (EXT / CEN / INT) all share ICON_CYCLE_X — only one is
# ever visible at a time so they don't overlap. # ever visible at a time so they don't overlap.
ICON_ROTATE_X = 1.24 ICON_ROTATE_X = 1.24
ICON_TOGGLE_OPENINGS_X = 1.61
# Mapping from BIMWallProperties.desired_offset_baseline value to the # Mapping from BIMWallProperties.desired_offset_baseline value to the
# attribute on `self` that holds the corresponding state icon. # attribute on `self` that holds the corresponding state icon.
@@ -1969,8 +1969,10 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
- Toggle-openings icon next to the pen. Lives outside edit mode because - Toggle-openings icon next to the pen. Lives outside edit mode because
opening visibility is a viewport-display concern, not a wall-edit action. opening visibility is a viewport-display concern, not a wall-edit action.
Uses ``billboarded_at`` directly for parity with the base class's Calls ``billboarded_at`` directly rather than routing through
``update_editing_gizmos`` validate/cancel/cycle pattern.""" ``set_icon_gizmo_position`` because the icon row has wall-specific
visibility/state branching (baseline-indicator selection, edit-mode
toggle for opening-visibility) that the helper does not model."""
if not hasattr(self, "rotate_gizmo"): if not hasattr(self, "rotate_gizmo"):
return return
gizmo_prefs = self.get_gizmo_prefs() gizmo_prefs = self.get_gizmo_prefs()
@@ -2030,7 +2032,7 @@ def _commit_active_wall_edit_if_any(context: bpy.types.Context) -> bpy.types.Obj
def _commit_pending_wall_edits_for_selection(context: bpy.types.Context) -> None: # noqa: ARG001 def _commit_pending_wall_edits_for_selection(context: bpy.types.Context) -> None: # noqa: ARG001
"""Thin wall-scoped alias for :meth:`tool.Parametric.commit_pending_edits_for_selection`. """Thin wall-scoped alias for `tool.Parametric.commit_pending_edits_for_selection`.
Kept as a named helper because every multi-wall operator (split / join / merge / Kept as a named helper because every multi-wall operator (split / join / merge /
unjoin / extend-to-wall ) calls it at the top of ``_execute``; centralising the unjoin / extend-to-wall ) calls it at the top of ``_execute``; centralising the
@@ -2198,12 +2200,12 @@ def _wall_axis_world_segment_from_geom(obj: bpy.types.Object, geom: dict) -> tup
class _WallGeomCachedBillboardingMixin(gizmo.BillboardingGizmoGroupMixin): class _WallGeomCachedBillboardingMixin(gizmo.BillboardingGizmoGroupMixin):
"""Adds IFC-read caching to :class:`BillboardingGizmoGroupMixin` for wall-driven """Adds IFC-read caching to `BillboardingGizmoGroupMixin` for wall-driven
gizmo groups. ``refresh()`` is Blender's "something state-relevant changed" gizmo groups. ``refresh()`` is Blender's "something state-relevant changed"
signal that's when we drop the cache. ``draw_prepare()`` (every redraw) reuses signal that's when we drop the cache. ``draw_prepare()`` (every redraw) reuses
whatever ``_get_wall_geom_cached`` populated, so plain camera orbits don't re-hit whatever ``_get_wall_geom_cached`` populated, so plain camera orbits don't re-hit
IFC. ``_get_wall_geom_cached`` also drops entries on its own when IFC. ``_get_wall_geom_cached`` also drops entries on its own when
:meth:`tool.Parametric.get_geom_generation` advances (any ``tool.Ifc.Operator`` `tool.Parametric.get_geom_generation` advances (any ``tool.Ifc.Operator``
commit) so external ``bpy.ops`` mutations on the same selection don't leave commit) so external ``bpy.ops`` mutations on the same selection don't leave
stale geometry behind.""" stale geometry behind."""
@@ -2274,7 +2276,7 @@ def _are_walls_collinear(
parallel_threshold: float = 0.9994, parallel_threshold: float = 0.9994,
line_tolerance: float = 0.05, line_tolerance: float = 0.05,
) -> bool: ) -> bool:
"""Vector wrapper around :func:`core.are_axes_collinear` — converts Vector """Vector wrapper around `core.are_axes_collinear` — converts Vector
endpoints to plain tuples at the boundary so the math stays unit-testable in endpoints to plain tuples at the boundary so the math stays unit-testable in
``test/core/`` without a mathutils dependency.""" ``test/core/`` without a mathutils dependency."""
return core.are_axes_collinear( return core.are_axes_collinear(
@@ -2286,7 +2288,7 @@ def _are_walls_collinear(
def _collinear_boundary_world(seg_a: tuple[Vector, Vector], seg_b: tuple[Vector, Vector]) -> Vector: def _collinear_boundary_world(seg_a: tuple[Vector, Vector], seg_b: tuple[Vector, Vector]) -> Vector:
"""Vector wrapper around :func:`core.closest_endpoint_midpoint`.""" """Vector wrapper around `core.closest_endpoint_midpoint`."""
return Vector( return Vector(
core.closest_endpoint_midpoint( core.closest_endpoint_midpoint(
(tuple(seg_a[0]), tuple(seg_a[1])), (tuple(seg_a[0]), tuple(seg_a[1])),
@@ -2302,7 +2304,7 @@ class GizmoWallAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin
object's projected origin. Clicking dispatches `bim.add_opening`, which lets the object's projected origin. Clicking dispatches `bim.add_opening`, which lets the
existing FilledOpeningGenerator decide how the opening is applied. existing FilledOpeningGenerator decide how the opening is applied.
Per-frame positioning via :class:`BillboardingGizmoGroupMixin` ensures the icon Per-frame positioning via `BillboardingGizmoGroupMixin` ensures the icon
keeps facing the camera as the viewport is orbited.""" keeps facing the camera as the viewport is orbited."""
bl_idname = "OBJECT_GGT_bim_wall_add_opening" bl_idname = "OBJECT_GGT_bim_wall_add_opening"
@@ -2447,7 +2449,7 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
"join the corner" vs "extend this wall into the other." "join the corner" vs "extend this wall into the other."
- **None of the above**: all icons hidden. - **None of the above**: all icons hidden.
Per-frame positioning via :class:`BillboardingGizmoGroupMixin` ensures the icons Per-frame positioning via `BillboardingGizmoGroupMixin` ensures the icons
keep facing the camera as the viewport is orbited.""" keep facing the camera as the viewport is orbited."""
bl_idname = "OBJECT_GGT_bim_wall_join_intersection" bl_idname = "OBJECT_GGT_bim_wall_join_intersection"
@@ -22,12 +22,12 @@
Two mixins fit the parametric-edit triads in ``bim/module/model/``: Two mixins fit the parametric-edit triads in ``bim/module/model/``:
:class:`FeatureModifierEditMixin` `FeatureModifierEditMixin`
Door, Window BBIM_<Type> pset with nested ``lining_properties`` / Door, Window BBIM_<Type> pset with nested ``lining_properties`` /
``panel_properties``; Finish calls ``update_<type>_modifier_representation`` ``panel_properties``; Finish calls ``update_<type>_modifier_representation``
via ``ifcopenshell.api.feature``; Cancel restores via ``switch_representation``. via ``ifcopenshell.api.feature``; Cancel restores via ``switch_representation``.
:class:`PathPreservingEditMixin` `PathPreservingEditMixin`
Railing, Roof BBIM_<Type> pset whose ``path_data`` is preserved through Railing, Roof BBIM_<Type> pset whose ``path_data`` is preserved through
edit (only general kwargs are user-editable); Finish calls edit (only general kwargs are user-editable); Finish calls
``update_<type>_modifier_bmesh`` / ``update_<type>_modifier_ifc_data``; ``update_<type>_modifier_bmesh`` / ``update_<type>_modifier_ifc_data``;
@@ -38,7 +38,7 @@ fit either mixin without optional escape hatches (Stair has a unique
``update_ifc_stair_props`` post-Finish step + a separate ``get_props_kwargs_for_ifc_export``; ``update_ifc_stair_props`` post-Finish step + a separate ``get_props_kwargs_for_ifc_export``;
Wall is validation-first, snapshot-driven, no preview regen in operators). Wall is validation-first, snapshot-driven, no preview regen in operators).
This module sits separately from :class:`bonsai.tool.Parametric` (the registry + This module sits separately from `bonsai.tool.Parametric` (the registry +
auto-commit) because it imports ``bonsai.tool`` freely, while the registry auto-commit) because it imports ``bonsai.tool`` freely, while the registry
itself must stay light ``tool/blender.py`` consumes the registry at module load.""" itself must stay light ``tool/blender.py`` consumes the registry at module load."""
@@ -201,9 +201,9 @@ class PathPreservingEditMixin(_ParametricEditMixinBase):
Enable: Enable:
Fetch pset data via ``tool.Model.get_modeling_bbim_pset_data`` set Fetch pset data via ``tool.Model.get_modeling_bbim_pset_data`` set
draft props ``is_editing = True``. Subclass override draft props ``is_editing = True``. The subclass post-load hook
:meth:`_post_load_data` lets railing JSON-serialise ``path_data`` for lets railing JSON-serialise ``path_data`` for the PropertyGroup
the PropertyGroup string field. string field.
Finish: Finish:
Read fresh pset keep ``path_data`` gather ``general`` kwargs Read fresh pset keep ``path_data`` gather ``general`` kwargs
+36 -27
View File
@@ -21,7 +21,7 @@
"""Registry + save-time auto-commit for parametric draft edits. """Registry + save-time auto-commit for parametric draft edits.
Single source of truth: adding a new parametric element type is one entry in Single source of truth: adding a new parametric element type is one entry in
:attr:`Parametric.EDIT_TYPES`. Every consumer save-time auto-commit, the `Parametric.EDIT_TYPES`. Every consumer save-time auto-commit, the
finish/cancel chains in ``tool.Blender.Modifier``, the ``PointerProperty`` finish/cancel chains in ``tool.Blender.Modifier``, the ``PointerProperty``
attachment in ``bim/module/model/__init__.py``, and the per-type attachment in ``bim/module/model/__init__.py``, and the per-type
``GizmoPreferences<X>`` registration in ``bim/__init__.py`` derives the ``GizmoPreferences<X>`` registration in ``bim/__init__.py`` derives the
@@ -45,7 +45,7 @@ implementation files it references — if a step's example code stops matching
the real registration site, the step is out of date. the real registration site, the step is out of date.
STEP 1 Add the registry entry (this file) STEP 1 Add the registry entry (this file)
Append to :attr:`Parametric.EDIT_TYPES`:: Append to `Parametric.EDIT_TYPES`::
ParametricObject("slab", has_non_editable_path=False), ParametricObject("slab", has_non_editable_path=False),
@@ -57,7 +57,7 @@ STEP 1 — Add the registry entry (this file)
STEP 2 Define the ``PropertyGroup`` (``bim/module/model/prop.py``) STEP 2 Define the ``PropertyGroup`` (``bim/module/model/prop.py``)
Class name **must** be ``BIM<Name>Properties`` capitalisation matches Class name **must** be ``BIM<Name>Properties`` capitalisation matches
:attr:`ParametricObject.props_attr`:: `ParametricObject.props_attr`::
class BIMSlabProperties(bpy.types.PropertyGroup): class BIMSlabProperties(bpy.types.PropertyGroup):
is_editing: BoolProperty(...) is_editing: BoolProperty(...)
@@ -70,7 +70,7 @@ STEP 3 — Register the PropertyGroup class
Add it to the ``classes`` tuple in ``bim/module/model/__init__.py`` (near Add it to the ``classes`` tuple in ``bim/module/model/__init__.py`` (near
the existing ``prop.BIM<X>Properties`` entries). The the existing ``prop.BIM<X>Properties`` entries). The
``bpy.types.Object.BIMSlabProperties`` attachment is automatic ``bpy.types.Object.BIMSlabProperties`` attachment is automatic
:meth:`Parametric.register_object_properties` loops the registry. `Parametric.register_object_properties` loops the registry.
STEP 4 Implement the Enable / Finish / Cancel triad STEP 4 Implement the Enable / Finish / Cancel triad
In ``bim/module/model/slab.py``, define three ``bpy.types.Operator`` In ``bim/module/model/slab.py``, define three ``bpy.types.Operator``
@@ -81,7 +81,7 @@ STEP 4 — Implement the Enable / Finish / Cancel triad
- ``CancelEditingSlab`` ``bl_idname = "bim.cancel_editing_slab"`` - ``CancelEditingSlab`` ``bl_idname = "bim.cancel_editing_slab"``
**First, check if your new type fits one of the existing lifecycle **First, check if your new type fits one of the existing lifecycle
shapes** in :mod:`bonsai.bim.parametric_lifecycle`. If it does, inherit shapes** in `bonsai.bim.parametric_lifecycle`. If it does, inherit
the matching mixin and the triad collapses to ~25 lines total: the matching mixin and the triad collapses to ~25 lines total:
- ``FeatureModifierEditMixin`` BBIM_<Type> pset with nested - ``FeatureModifierEditMixin`` BBIM_<Type> pset with nested
@@ -128,7 +128,7 @@ STEP 6 — Add the element-type predicate (``tool/blender.py``)
return tool.Pset.get_element_pset(element, "BBIM_Slab") return tool.Pset.get_element_pset(element, "BBIM_Slab")
The method name **must** be ``is_<name>`` to match The method name **must** be ``is_<name>`` to match
:attr:`ParametricObject.name` :meth:`Parametric.find_for_element` `ParametricObject.name` `Parametric.find_for_element`
looks it up by string. looks it up by string.
STEP 7 OPTIONAL: typed property accessor (``tool/model.py``) STEP 7 OPTIONAL: typed property accessor (``tool/model.py``)
@@ -153,9 +153,9 @@ STEP 8 — OPTIONAL: gizmo visibility preferences (``bim/ui.py``)
slab: bpy.props.PointerProperty(type=GizmoPreferencesSlab) slab: bpy.props.PointerProperty(type=GizmoPreferencesSlab)
Do **not** add ``GizmoPreferencesSlab`` to the ``classes`` list in Do **not** add ``GizmoPreferencesSlab`` to the ``classes`` list in
``bim/__init__.py`` :meth:`Parametric.iter_gizmo_preference_classes` ``bim/__init__.py`` the registry-driven discovery in this module finds
discovers it from the registry automatically by its name it by name (``GizmoPreferences`` + capitalised registry token) and
(``GizmoPreferences`` + capitalised registry token). registers it automatically.
STEP 9 OPTIONAL: pure geometry helpers (``core/model.py``) STEP 9 OPTIONAL: pure geometry helpers (``core/model.py``)
Per-type math (collinearity checks, slope/displacement conversions, Per-type math (collinearity checks, slope/displacement conversions,
@@ -172,13 +172,12 @@ STEP 10 — Verify
pytest test/core/ -x -q pytest test/core/ -x -q
blender -b -P runpytest.py -- test/bim/ -x -q -m model blender -b -P runpytest.py -- test/bim/ -x -q -m model
The Blender-backed lane runs the registration smoke test in The Blender-backed lane runs a registry smoke test that iterates the
``test/bim/test_parametric_registry.py`` it iterates EDIT_TYPES list and asserts each entry's enable/finish/cancel operator
:attr:`Parametric.EDIT_TYPES` and asserts each ``enable_op`` / resolves to a registered ``bpy.ops.bim.*``, that ``bpy.types.Object``
``finish_op`` / ``cancel_op`` resolves to a registered operator, that carries the matching ``BIM<Name>Properties`` attribute, and that the
``bpy.types.Object`` carries the matching ``BIM<Name>Properties`` ``is_<name>`` predicate exists on ``tool.Blender.Modifier``. Forget any
attribute, and that ``tool.Blender.Modifier.is_<name>`` exists. Forget of the steps above and that test fails with a precise pointer at
any of the steps above and that test fails with a precise pointer at
what's missing. what's missing.
Then manually in Blender: Then manually in Blender:
@@ -223,14 +222,19 @@ class ParametricObject:
"wall", ) drives every derived identifier: the ``BIM<Name>Properties`` "wall", ) drives every derived identifier: the ``BIM<Name>Properties``
attribute on ``bpy.types.Object`` and the ``bim.enable_editing_<name>`` / attribute on ``bpy.types.Object`` and the ``bim.enable_editing_<name>`` /
``bim.finish_editing_<name>`` / ``bim.cancel_editing_<name>`` operator ``bim.finish_editing_<name>`` / ``bim.cancel_editing_<name>`` operator
``bl_idname``s. The ``name`` is validated at construction time ``bl_idname``s. The ``name`` is validated at construction time a
multi-word IFC types (e.g. ``IfcCurtainWall``) would silently mis-derive multi-word IFC type would silently mis-derive through
through ``str.capitalize()`` and need a different approach than ``str.capitalize()`` and breaks the single-token assumption.
appending to :data:`Parametric.EDIT_TYPES` directly.
``has_non_editable_path`` flags element types whose modifier exposes no ``has_non_editable_path`` flags element types whose modifier exposes no
user-editable path (door, window, stair) historically queried via user-editable path (door, window, stair).
``tool.Blender.Modifier.is_modifier_with_non_editable_path``."""
The paired runtime predicate ``tool.Blender.Modifier.is_<name>(element)``
is part of the registry contract: it MUST be **total** accept any
IFC entity and return a boolean, never raise. The registry iterates
every predicate against the active element on save; a raising predicate
propagates upward and breaks the save path for *all* parametric types,
not just its own."""
name: str name: str
has_non_editable_path: bool = False has_non_editable_path: bool = False
@@ -342,12 +346,17 @@ class Parametric(bonsai.core.tool.Parametric):
def run_bim_op(cls, bl_idname: str) -> None: def run_bim_op(cls, bl_idname: str) -> None:
"""Invoke a ``bim.*`` operator by its ``bl_idname``. """Invoke a ``bim.*`` operator by its ``bl_idname``.
Constraint: only use with operators that are themselves Constraint enforced via ``assert``: the operator MUST be a
``tool.Ifc.Operator`` subclasses their transaction wrap is what ``tool.Ifc.Operator`` subclass its transaction wrap is what
makes the IFC mutation undo-aware. Direct ``bpy.ops.bim.*`` invocation makes the IFC mutation undo-aware. Direct ``bpy.ops.bim.*`` invocation
of a non-``Ifc.Operator`` would mutate IFC outside Bonsai's of a non-``Ifc.Operator`` would mutate IFC outside Bonsai's
transaction system.""" transaction system."""
getattr(bpy.ops.bim, bl_idname.removeprefix("bim."))() verb = bl_idname.removeprefix("bim.")
op_cls = getattr(bpy.types, f"BIM_OT_{verb}", None)
assert op_cls is not None and issubclass(
op_cls, tool.Ifc.Operator
), f"{bl_idname!r} must be a registered tool.Ifc.Operator subclass for undo-safe IFC mutation"
getattr(bpy.ops.bim, verb)()
@classmethod @classmethod
def commit_object_draft(cls, obj: bpy.types.Object, finish_op: str) -> bool: def commit_object_draft(cls, obj: bpy.types.Object, finish_op: str) -> bool:
@@ -397,7 +406,7 @@ class Parametric(bonsai.core.tool.Parametric):
def commit_pending_edits_for_selection( def commit_pending_edits_for_selection(
cls, names: Optional[tuple[str, ...]] = None cls, names: Optional[tuple[str, ...]] = None
) -> tuple[int, list[bpy.types.Object]]: ) -> tuple[int, list[bpy.types.Object]]:
"""Selection-scoped variant of :meth:`commit_pending_edits`. ``names`` """Selection-scoped variant of `commit_pending_edits`. ``names``
filters which registry entries to consider e.g. ``("wall",)`` to commit filters which registry entries to consider e.g. ``("wall",)`` to commit
only wall drafts among selected objects; ``None`` considers every type. only wall drafts among selected objects; ``None`` considers every type.
@@ -439,7 +448,7 @@ class Parametric(bonsai.core.tool.Parametric):
@classmethod @classmethod
def iter_gizmo_preference_classes(cls, ui_module) -> list[type]: def iter_gizmo_preference_classes(cls, ui_module) -> list[type]:
"""``GizmoPreferences<Name>`` classes that exist on ``ui_module`` for """``GizmoPreferences<Name>`` classes that exist on ``ui_module`` for
every registry entry. Order matches :attr:`EDIT_TYPES`. Used by every registry entry. Order matches `EDIT_TYPES`. Used by
``bim/__init__.py`` to inject the per-type ``GizmoPreferences<X>`` ``bim/__init__.py`` to inject the per-type ``GizmoPreferences<X>``
classes at the correct point before ``ui.GizmoPreferences``, which classes at the correct point before ``ui.GizmoPreferences``, which
references them via ``PointerProperty``.""" references them via ``PointerProperty``."""