Make LAYER2 parametric wall tools IFC-class agnostic

The wall gizmos, joins, extend/regenerate-to-underside and fillet-corner
machinery are driven entirely by placement + axis + IfcMaterialLayerSetUsage,
never the entity type. The IfcWall restriction lived only in gate predicates
and a wall-specific pset, so any AXIS2 LAYER2 element (e.g. vertical
IfcCovering siding/cladding) was excluded despite working end-to-end.

- Drop the is_a("IfcWall") gate from is_wall and is_path_connectable_wall;
  gate on LAYER2 usage (plus fillet corner) instead.
- Resolve the wall/underside gizmo partner pair via the predicate, not is_a.
- Move fillet-corner state (IsFilletCorner / FilletRadius) off the wall-only
  BBIM_Wall pset onto the class-agnostic EPset_Parametric, via a shared
  get_parametric_prop helper that falls back to BBIM_Wall for existing files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ryan Schultz
2026-07-01 09:48:11 -05:00
parent 340d4fb82a
commit 5e9ddcf2bf
2 changed files with 64 additions and 29 deletions
+15 -10
View File
@@ -442,6 +442,8 @@ class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, to
element = tool.Ifc.get_entity(obj) element = tool.Ifc.get_entity(obj)
if not element: if not element:
continue continue
# IFC-class agnostic: any LAYER2 body (wall, covering, …) is
# extendable; everything else is treated as an underside target.
if tool.Parametric.is_path_connectable_wall(element): if tool.Parametric.is_path_connectable_wall(element):
walls.append(obj) walls.append(obj)
else: else:
@@ -3208,12 +3210,12 @@ def _pick_dominant_wall_material(
def regenerate_fillet_corner_wall(element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None: def regenerate_fillet_corner_wall(element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None:
"""Rebuild a fillet corner wall's banana body from ``BBIM_Wall.FilletRadius`` """Rebuild a fillet corner wall's banana body from
and its neighbours' current layer parameters.""" ``EPset_Parametric.FilletRadius`` and its neighbours' current layer parameters."""
ifc_file = tool.Ifc.get() ifc_file = tool.Ifc.get()
if ifc_file is None: if ifc_file is None:
return return
radius_si = ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "FilletRadius") radius_si = tool.Parametric.get_parametric_prop(element, "FilletRadius")
if not radius_si: if not radius_si:
return return
@@ -3417,7 +3419,7 @@ class EnableWallFilletPreviewFromCorner(bpy.types.Operator):
self.report({"ERROR"}, "Selection is not a fillet corner wall.") self.report({"ERROR"}, "Selection is not a fillet corner wall.")
return {"CANCELLED"} return {"CANCELLED"}
radius = ifcopenshell.util.element.get_pset(corner_elem, "BBIM_Wall", "FilletRadius") radius = tool.Parametric.get_parametric_prop(corner_elem, "FilletRadius")
if not radius: if not radius:
self.report({"ERROR"}, "Corner wall has no FilletRadius pset to re-edit.") self.report({"ERROR"}, "Corner wall has no FilletRadius pset to re-edit.")
return {"CANCELLED"} return {"CANCELLED"}
@@ -3633,12 +3635,13 @@ class CreateWallFillet(bpy.types.Operator, tool.Ifc.Operator):
Vector((chord_length_si / unit_scale, 0.0)), Vector((chord_length_si / unit_scale, 0.0)),
) )
# Mark the corner wall BEFORE the downstream recalculate so # Mark the corner BEFORE the downstream recalculate so
# tool.Model.recreate_wall short-circuits and preserves the curved # tool.Model.recreate_wall short-circuits and preserves the curved
# geometry. The pset also gates the enable poll. FilletRadius is # geometry. The pset also gates the enable poll. FilletRadius is
# stored alongside IsFilletCorner so the corner can be rebuilt later # stored alongside IsFilletCorner so the corner can be rebuilt later
# (neighbour move, layer-thickness edit, pen-icon re-edit). # (neighbour move, layer-thickness edit, pen-icon re-edit). Stored on
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=corner_elem, name="BBIM_Wall") # the class-agnostic EPset_Parametric so LAYER2 siding corners work too.
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=corner_elem, name=tool.Parametric.PARAMETRIC_PSET)
ifcopenshell.api.pset.edit_pset( ifcopenshell.api.pset.edit_pset(
ifc_file, ifc_file,
pset=pset, pset=pset,
@@ -4340,10 +4343,12 @@ class GizmoPairDisconnect(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixi
if pair is None: if pair is None:
return return
active, partner_obj, active_elem, partner_elem = pair active, partner_obj, active_elem, partner_elem = pair
# Helper expects wall + slab regardless of which the user marked active. # Helper expects the LAYER2 element + its underside target regardless of
if active_elem.is_a("IfcWall"): # which the user marked active. Class-agnostic: the LAYER2 side may be a
# wall or a covering (siding), the target a slab/roof/etc.
if tool.Parametric.is_path_connectable_wall(active_elem):
wall_obj, slab_obj = active, partner_obj wall_obj, slab_obj = active, partner_obj
elif partner_elem.is_a("IfcWall"): elif tool.Parametric.is_path_connectable_wall(partner_elem):
wall_obj, slab_obj = partner_obj, active wall_obj, slab_obj = partner_obj, active
else: else:
return return
+49 -19
View File
@@ -486,40 +486,70 @@ class Parametric(bonsai.core.tool.Parametric):
@classmethod @classmethod
def is_wall(cls, element: entity_instance) -> bool: def is_wall(cls, element: entity_instance) -> bool:
"""A wall is editable by the parametric gizmo if it is an IfcWall with LAYER2 usage. """A LAYER2 axis-driven element editable by the parametric wall gizmo.
Unlike doors/windows/stairs, walls do not carry a proprietary BBIM_Wall pset IFC-class agnostic by design. The gizmos, edit mode and property panel
their parametric state lives in standard IFC (axis polyline, IfcMaterialLayerSetUsage, all operate on the standard-IFC parametric state axis polyline,
IfcExtrudedAreaSolid). Any LAYER2 wall qualifies.""" ``IfcMaterialLayerSetUsage`` (LAYER2), ``IfcExtrudedAreaSolid`` none of
if element is None or not element.is_a("IfcWall"): which is exclusive to ``IfcWall``. So any element modelled this way
qualifies: typically an ``IfcWall``, but also vertical claddings / siding
(``IfcCovering``) that are drawn, edited and joined the same way. Unlike
doors / windows / stairs these carry no proprietary pset their
parametric state lives entirely in standard IFC.
Gated on LAYER2 usage rather than entity type; LAYER3 elements (slabs)
and anything without vertical layered usage are excluded."""
if element is None:
return False return False
return tool.Model.get_usage_type(element) == "LAYER2" return tool.Model.get_usage_type(element) == "LAYER2"
@classmethod @classmethod
def is_path_connectable_wall(cls, element: entity_instance) -> bool: def is_path_connectable_wall(cls, element: entity_instance) -> bool:
"""An IfcWall that may participate in IfcRelConnectsPathElements joins — """A LAYER2 axis-driven element (or fillet corner) whose axis can drive
either a LAYER2 parametric wall, or a fillet-corner wall whose body is ``IfcRelConnectsPathElements`` joins and underside clips.
hand-built but whose axis still drives path connections.
Distinct from ``is_wall``: that predicate gates parametric edits that IFC-class agnostic, like :meth:`is_wall` path connection, unjoin,
would regenerate the body and flatten a curved fillet. Unjoin / join extend-to-wall and extend-to-underside all read placement + axis +
gizmo polls and path-connection partner enumeration use this looser layer set, never the entity type so LAYER2 claddings / siding
predicate so fillet corners (which have no LAYER2 usage by spec) still (``IfcCovering``) join and clip the same way an ``IfcWall`` does.
surface their join icons."""
if element is None or not element.is_a("IfcWall"): Looser than :meth:`is_wall` in one respect: it also admits fillet-corner
walls, whose hand-built body carries no LAYER2 usage by spec but whose
axis still drives path connections. :meth:`is_wall` excludes them because
regenerating their body from the axis would flatten the curve."""
if element is None:
return False return False
if tool.Model.get_usage_type(element) == "LAYER2": if tool.Model.get_usage_type(element) == "LAYER2":
return True return True
return cls.is_fillet_corner_wall(element) return cls.is_fillet_corner_wall(element)
# Bonsai's class-agnostic parametric metadata (currently the fillet-corner
# flags IsFilletCorner / FilletRadius). Deliberately not on the entity-
# specific BBIM_Wall pset, so the same state can live on any LAYER2 element
# — a wall or a vertical cladding / siding (IfcCovering).
PARAMETRIC_PSET = "EPset_Parametric"
# Files authored before the move stored these flags on the wall-only pset.
LEGACY_PARAMETRIC_PSET = "BBIM_Wall"
@classmethod @classmethod
def is_fillet_corner_wall(cls, element: entity_instance) -> bool: def get_parametric_prop(cls, element: entity_instance, name: str) -> Any:
"""``True`` if the wall carries the ``BBIM_Wall.IsFilletCorner`` flag, """Read a Bonsai parametric flag from ``EPset_Parametric``, falling back
marking it as a curved corner whose banana body is hand-built rather to the legacy ``BBIM_Wall`` pset for files authored before the fillet-
than regenerated from the wall's axis + layer set.""" corner state was generalized off the wall-specific pset."""
import ifcopenshell.util.element import ifcopenshell.util.element
return bool(ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner")) value = ifcopenshell.util.element.get_pset(element, cls.PARAMETRIC_PSET, name)
if value is None:
value = ifcopenshell.util.element.get_pset(element, cls.LEGACY_PARAMETRIC_PSET, name)
return value
@classmethod
def is_fillet_corner_wall(cls, element: entity_instance) -> bool:
"""``True`` if the element carries the ``EPset_Parametric.IsFilletCorner``
flag (legacy: ``BBIM_Wall.IsFilletCorner``), marking it as a curved
corner whose banana body is hand-built rather than regenerated from the
axis + layer set. Class-agnostic walls and LAYER2 siding alike."""
return bool(cls.get_parametric_prop(element, "IsFilletCorner"))
@classmethod @classmethod
def is_pipe_segment(cls, element: entity_instance) -> bool: def is_pipe_segment(cls, element: entity_instance) -> bool: