Compare commits

...

3 Commits

Author SHA1 Message Date
Ryan Schultz e7fe85c623 Make wall fillet corner IFC-class agnostic (LAYER2, not just IfcWall)
bim.enable_wall_fillet_preview and its re-edit / toggle-openings gizmos gated
on is_a("IfcWall"), so two straight LAYER2 coverings (siding/cladding) couldn't
be filleted despite meeting every real requirement.

Drop the is_a("IfcWall") checks in _resolve_two_walls and the two fillet gizmo
polls; the actual gates (has_layer2_usage + is_straight_axis, and
is_fillet_corner_wall) are already class-agnostic. The generated corner
inherits the source element's type via add_occurrence, and fillet state lives
on EPset_Parametric, so a covering corner needs no wall-specific handling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 08:01:08 -05:00
Ryan Schultz 8d939eb425 Allow parametric pen edit on any LAYER2 element, not just IfcWall
The pen icon's gizmo group already polls the class-agnostic is_wall, so it
appears for LAYER2 coverings/siding and dispatches to bim.enable_editing_wall
— but validate_for_parametric_edit hard-rejected anything that isn't IfcWall,
cancelling the edit with a "not an IfcWall" warning.

Drop the is_a("IfcWall") gate; the actual requirement (AXIS2 layer usage + an
extrusion body) is already checked and is class-agnostic. Reword the remaining
"Wall …" blocker messages to "Element …" since they now surface for coverings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 07:08:59 -05:00
Ryan Schultz 5e9ddcf2bf 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>
2026-07-01 09:48:11 -05:00
3 changed files with 83 additions and 40 deletions
+24 -14
View File
@@ -442,6 +442,8 @@ class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, to
element = tool.Ifc.get_entity(obj)
if not element:
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):
walls.append(obj)
else:
@@ -3156,7 +3158,12 @@ def _apply_fillet_corner_geometry(
def _resolve_two_walls(context: bpy.types.Context) -> tuple[bpy.types.Object, bpy.types.Object] | None:
"""``(active, other)`` from a 2-wall selection, both LAYER2 with straight axes."""
"""``(active, other)`` from a 2-element selection, both LAYER2 with straight axes.
IFC-class agnostic: the fillet only needs two straight LAYER2 bodies (the
corner it creates inherits the active element's type), so coverings / siding
fillet the same way walls do. The ``has_layer2_usage`` check below is the
real gate, not entity type."""
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 2:
return None
@@ -3168,7 +3175,7 @@ def _resolve_two_walls(context: bpy.types.Context) -> tuple[bpy.types.Object, bp
return None
for obj in (active, other):
element = tool.Ifc.get_entity(obj)
if element is None or not element.is_a("IfcWall"):
if element is None:
return None
if not tool.Wall.has_layer2_usage(element):
return None
@@ -3208,12 +3215,12 @@ def _pick_dominant_wall_material(
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``
and its neighbours' current layer parameters."""
"""Rebuild a fillet corner wall's banana body from
``EPset_Parametric.FilletRadius`` and its neighbours' current layer parameters."""
ifc_file = tool.Ifc.get()
if ifc_file is None:
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:
return
@@ -3417,7 +3424,7 @@ class EnableWallFilletPreviewFromCorner(bpy.types.Operator):
self.report({"ERROR"}, "Selection is not a fillet corner wall.")
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:
self.report({"ERROR"}, "Corner wall has no FilletRadius pset to re-edit.")
return {"CANCELLED"}
@@ -3633,12 +3640,13 @@ class CreateWallFillet(bpy.types.Operator, tool.Ifc.Operator):
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
# geometry. The pset also gates the enable poll. FilletRadius is
# stored alongside IsFilletCorner so the corner can be rebuilt later
# (neighbour move, layer-thickness edit, pen-icon re-edit).
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=corner_elem, name="BBIM_Wall")
# (neighbour move, layer-thickness edit, pen-icon re-edit). Stored on
# 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(
ifc_file,
pset=pset,
@@ -4340,10 +4348,12 @@ class GizmoPairDisconnect(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixi
if pair is None:
return
active, partner_obj, active_elem, partner_elem = pair
# Helper expects wall + slab regardless of which the user marked active.
if active_elem.is_a("IfcWall"):
# Helper expects the LAYER2 element + its underside target regardless of
# 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
elif partner_elem.is_a("IfcWall"):
elif tool.Parametric.is_path_connectable_wall(partner_elem):
wall_obj, slab_obj = partner_obj, active
else:
return
@@ -4649,7 +4659,7 @@ class GizmoWallFilletReedit(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
if len(selected) != 1:
return False
element = tool.Ifc.get_entity(active)
if element is None or not element.is_a("IfcWall"):
if element is None:
return False
# IsFilletCorner pset is the authoritative signal — the re-edit
# operator separately verifies both neighbour connections exist and
@@ -4716,7 +4726,7 @@ class GizmoWallFilletToggleOpenings(bpy.types.GizmoGroup, _WallGeomCachedBillboa
if len(list(tool.Blender.get_selected_objects())) != 1:
return False
element = tool.Ifc.get_entity(active)
if element is None or not element.is_a("IfcWall"):
if element is None:
return False
return tool.Parametric.is_fillet_corner_wall(element)
+49 -19
View File
@@ -486,40 +486,70 @@ class Parametric(bonsai.core.tool.Parametric):
@classmethod
def is_wall(cls, element: entity_instance) -> bool:
"""A wall is editable by the parametric gizmo if it is an IfcWall with LAYER2 usage.
"""A LAYER2 axis-driven element editable by the parametric wall gizmo.
Unlike doors/windows/stairs, walls do not carry a proprietary BBIM_Wall pset —
their parametric state lives in standard IFC (axis polyline, IfcMaterialLayerSetUsage,
IfcExtrudedAreaSolid). Any LAYER2 wall qualifies."""
if element is None or not element.is_a("IfcWall"):
IFC-class agnostic by design. The gizmos, edit mode and property panel
all operate on the standard-IFC parametric state — axis polyline,
``IfcMaterialLayerSetUsage`` (LAYER2), ``IfcExtrudedAreaSolid`` — none of
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 tool.Model.get_usage_type(element) == "LAYER2"
@classmethod
def is_path_connectable_wall(cls, element: entity_instance) -> bool:
"""An IfcWall that may participate in IfcRelConnectsPathElements joins —
either a LAYER2 parametric wall, or a fillet-corner wall whose body is
hand-built but whose axis still drives path connections.
"""A LAYER2 axis-driven element (or fillet corner) whose axis can drive
``IfcRelConnectsPathElements`` joins and underside clips.
Distinct from ``is_wall``: that predicate gates parametric edits that
would regenerate the body and flatten a curved fillet. Unjoin / join
gizmo polls and path-connection partner enumeration use this looser
predicate so fillet corners (which have no LAYER2 usage by spec) still
surface their join icons."""
if element is None or not element.is_a("IfcWall"):
IFC-class agnostic, like :meth:`is_wall` — path connection, unjoin,
extend-to-wall and extend-to-underside all read placement + axis +
layer set, never the entity type — so LAYER2 claddings / siding
(``IfcCovering``) join and clip the same way an ``IfcWall`` does.
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
if tool.Model.get_usage_type(element) == "LAYER2":
return True
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
def is_fillet_corner_wall(cls, element: entity_instance) -> bool:
"""``True`` if the wall carries the ``BBIM_Wall.IsFilletCorner`` flag,
marking it as a curved corner whose banana body is hand-built rather
than regenerated from the wall's axis + layer set."""
def get_parametric_prop(cls, element: entity_instance, name: str) -> Any:
"""Read a Bonsai parametric flag from ``EPset_Parametric``, falling back
to the legacy ``BBIM_Wall`` pset for files authored before the fillet-
corner state was generalized off the wall-specific pset."""
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
def is_pipe_segment(cls, element: entity_instance) -> bool:
+10 -7
View File
@@ -161,23 +161,26 @@ class Wall(bonsai.core.tool.Wall):
@classmethod
def validate_for_parametric_edit(cls, obj: bpy.types.Object) -> str | None:
"""``None`` if the wall is parametrically editable, else a user-facing string naming
the specific gap so the user can fix the precise blocker."""
"""``None`` if the element is parametrically editable, else a user-facing string
naming the specific gap so the user can fix the precise blocker.
IFC-class agnostic: the real requirement is AXIS2 layer usage + an extrusion
body, not ``IfcWall`` per se, so LAYER2 coverings/siding and other vertical
layered elements are editable the same way a wall is."""
element = tool.Ifc.get_entity(obj)
if not element:
return "Object is not an IFC element."
if not element.is_a("IfcWall"):
return f"Object is an {element.is_a()}, not an IfcWall."
if tool.Model.get_usage_type(element) != "LAYER2":
return (
"Wall has no IfcMaterialLayerSetUsage with LayerSetDirection AXIS2 (required for parametric editing)."
"Element has no IfcMaterialLayerSetUsage with LayerSetDirection AXIS2 "
"(required for parametric editing)."
)
representation = tool.Geometry.get_body_representation(element)
if not representation:
return "Wall has no Model/Body/MODEL_VIEW representation to drive parametric dimensions."
return "Element has no Model/Body/MODEL_VIEW representation to drive parametric dimensions."
if not tool.Model.get_extrusion(representation):
return (
"Wall body is not an IfcExtrudedAreaSolid "
"Element body is not an IfcExtrudedAreaSolid "
"(e.g. a brep mesh or boolean result without a base extrusion)."
)
return None