mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-10 17:58:20 +00:00
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.
This commit is contained in:
@@ -142,33 +142,11 @@ def _restore_wall_mesh_if_dirty(obj: bpy.types.Object) -> None:
|
|||||||
props.mesh_dirty = False
|
props.mesh_dirty = False
|
||||||
|
|
||||||
|
|
||||||
def _validate_wall_for_parametric_edit(obj: bpy.types.Object) -> str | None:
|
|
||||||
"""Return ``None`` if the wall is parametrically editable, else a user-facing reason
|
|
||||||
string explaining what's missing. Reports the *specific* gap rather than a generic
|
|
||||||
'not parametric' so the user knows whether to fix the material layer set, swap the
|
|
||||||
body representation, or pick a different object."""
|
|
||||||
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)."
|
|
||||||
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
|
||||||
if not representation:
|
|
||||||
return "Wall 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 " "(e.g. a brep mesh or boolean result without a base extrusion)."
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _read_wall_state_into_props(obj: bpy.types.Object, props: "BIMWallProperties") -> None:
|
def _read_wall_state_into_props(obj: bpy.types.Object, props: "BIMWallProperties") -> None:
|
||||||
"""Populate the draft props from current IFC state. Caller must have validated the
|
"""Populate the draft props from current IFC state. Caller must have validated the
|
||||||
wall via ``_validate_wall_for_parametric_edit`` first — this function assumes the
|
wall via ``tool.Wall.validate_for_parametric_edit`` first — this function assumes the
|
||||||
wall has a LAYER2 usage and an extruded MODEL_VIEW body."""
|
wall has a LAYER2 usage and an extruded MODEL_VIEW body."""
|
||||||
geom = _read_wall_geometry(obj)
|
geom = tool.Wall.read_geometry(obj)
|
||||||
assert geom
|
assert geom
|
||||||
|
|
||||||
props.anchor_x = geom["anchor_x"]
|
props.anchor_x = geom["anchor_x"]
|
||||||
@@ -195,7 +173,7 @@ def _maybe_resync_wall_props_from_ifc(obj: "bpy.types.Object | None") -> None:
|
|||||||
No-op during a draft session; the draft is then the source of truth."""
|
No-op during a draft session; the draft is then the source of truth."""
|
||||||
if obj is None:
|
if obj is None:
|
||||||
return
|
return
|
||||||
if _validate_wall_for_parametric_edit(obj) is not None:
|
if tool.Wall.validate_for_parametric_edit(obj) is not None:
|
||||||
return
|
return
|
||||||
props = tool.Model.get_wall_props(obj)
|
props = tool.Model.get_wall_props(obj)
|
||||||
if props.is_editing:
|
if props.is_editing:
|
||||||
@@ -1750,7 +1728,7 @@ class EnableEditingWall(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
obj = context.active_object
|
obj = context.active_object
|
||||||
if not obj:
|
if not obj:
|
||||||
return {"CANCELLED"}
|
return {"CANCELLED"}
|
||||||
reason = _validate_wall_for_parametric_edit(obj)
|
reason = tool.Wall.validate_for_parametric_edit(obj)
|
||||||
if reason:
|
if reason:
|
||||||
self.report({"WARNING"}, f"Cannot edit wall parametrically: {reason}")
|
self.report({"WARNING"}, f"Cannot edit wall parametrically: {reason}")
|
||||||
return {"CANCELLED"}
|
return {"CANCELLED"}
|
||||||
@@ -2390,35 +2368,10 @@ class ToggleWallOpenings(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
return {"FINISHED"}
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|
||||||
def _read_wall_geometry(obj: bpy.types.Object) -> dict | None:
|
|
||||||
"""Live-read wall geometry from IFC. Returns ``None`` if the wall is not a LAYER2 extruded wall."""
|
|
||||||
element = tool.Ifc.get_entity(obj)
|
|
||||||
if not element or not tool.Blender.Modifier.is_wall(element):
|
|
||||||
return None
|
|
||||||
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
|
||||||
if not representation:
|
|
||||||
return None
|
|
||||||
extrusion = tool.Model.get_extrusion(representation)
|
|
||||||
if not extrusion:
|
|
||||||
return None
|
|
||||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
|
||||||
p1, p2 = ifcopenshell.util.representation.get_reference_line(element)
|
|
||||||
layer_params = tool.Model.get_material_layer_parameters(element)
|
|
||||||
x_angle = tool.Model.get_existing_x_angle(extrusion)
|
|
||||||
return {
|
|
||||||
"anchor_x": p1[0] * unit_scale,
|
|
||||||
"length": (p2[0] - p1[0]) * unit_scale,
|
|
||||||
"height": core.vertical_height_from_extrusion_depth(extrusion.Depth * unit_scale, x_angle),
|
|
||||||
"x_angle": x_angle,
|
|
||||||
"thickness": layer_params["thickness"],
|
|
||||||
"offset": layer_params["offset"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _wall_axis_world_segment_from_geom(obj: bpy.types.Object, geom: dict) -> tuple[Vector, Vector]:
|
def _wall_axis_world_segment_from_geom(obj: bpy.types.Object, geom: dict) -> tuple[Vector, Vector]:
|
||||||
"""Compose the world-space axis segment from an already-read ``geom`` dict.
|
"""Compose the world-space axis segment from an already-read ``geom`` dict.
|
||||||
Used by the billboarding gizmo groups so a single cached IFC read drives both
|
Used by the billboarding gizmo groups so a single cached IFC read drives both
|
||||||
``_read_wall_geometry`` *and* the segment, avoiding two reads per wall per frame."""
|
``tool.Wall.read_geometry`` *and* the segment, avoiding two reads per wall per frame."""
|
||||||
p1_local = Vector((geom["anchor_x"], 0.0, 0.0))
|
p1_local = Vector((geom["anchor_x"], 0.0, 0.0))
|
||||||
p2_local = Vector((geom["anchor_x"] + geom["length"], 0.0, 0.0))
|
p2_local = Vector((geom["anchor_x"] + geom["length"], 0.0, 0.0))
|
||||||
return obj.matrix_world @ p1_local, obj.matrix_world @ p2_local
|
return obj.matrix_world @ p1_local, obj.matrix_world @ p2_local
|
||||||
@@ -2440,7 +2393,7 @@ class _WallGeomCachedBillboardingMixin(gizmo.BillboardingGizmoGroupMixin):
|
|||||||
|
|
||||||
|
|
||||||
def _get_wall_geom_cached(group: "bpy.types.GizmoGroup", obj: bpy.types.Object) -> dict | None:
|
def _get_wall_geom_cached(group: "bpy.types.GizmoGroup", obj: bpy.types.Object) -> dict | None:
|
||||||
"""Per-gizmo-group memoised ``_read_wall_geometry``. Without this, a
|
"""Per-gizmo-group memoised ``tool.Wall.read_geometry``. Without this, a
|
||||||
billboarding gizmo group re-runs the IFC read on every camera orbit frame —
|
billboarding gizmo group re-runs the IFC read on every camera orbit frame —
|
||||||
~120 IFC queries per second per wall, which is unwieldy on dense models.
|
~120 IFC queries per second per wall, which is unwieldy on dense models.
|
||||||
|
|
||||||
@@ -2462,7 +2415,7 @@ def _get_wall_geom_cached(group: "bpy.types.GizmoGroup", obj: bpy.types.Object)
|
|||||||
group._wall_geom_cache_gen = current_gen
|
group._wall_geom_cache_gen = current_gen
|
||||||
key = obj.name
|
key = obj.name
|
||||||
if key not in cache:
|
if key not in cache:
|
||||||
cache[key] = _read_wall_geometry(obj)
|
cache[key] = tool.Wall.read_geometry(obj)
|
||||||
return cache[key]
|
return cache[key]
|
||||||
|
|
||||||
|
|
||||||
@@ -3493,12 +3446,6 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
|
|||||||
# Hide the gizmo when walls are nearly parallel (intersection would be unreasonably far).
|
# Hide the gizmo when walls are nearly parallel (intersection would be unreasonably far).
|
||||||
# cos(2°) ≈ 0.9994 → walls within ~2° of parallel are treated as parallel for this purpose.
|
# cos(2°) ≈ 0.9994 → walls within ~2° of parallel are treated as parallel for this purpose.
|
||||||
PARALLEL_DOT_THRESHOLD = 0.9994
|
PARALLEL_DOT_THRESHOLD = 0.9994
|
||||||
# The intersection must be within this many *wall-lengths* of the NEAREST endpoint
|
|
||||||
# of each wall. This filters out the case where two walls are offset from world
|
|
||||||
# origin and their extrapolated axes happen to cross at a point that isn't near
|
|
||||||
# either wall's actual endpoints (which previously caused the icon to land at
|
|
||||||
# world origin for walls whose axes coincidentally converged there).
|
|
||||||
MAX_DISTANCE_TO_ENDPOINT_FACTOR = 0.75
|
|
||||||
# Perpendicular tolerance (m) for treating two parallel wall axes as collinear.
|
# Perpendicular tolerance (m) for treating two parallel wall axes as collinear.
|
||||||
COLLINEAR_LINE_TOLERANCE = 0.05
|
COLLINEAR_LINE_TOLERANCE = 0.05
|
||||||
|
|
||||||
@@ -3592,8 +3539,13 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
|
|||||||
self.fillet_icon.hide = True
|
self.fillet_icon.hide = True
|
||||||
return
|
return
|
||||||
|
|
||||||
# State 3: non-parallel walls whose axes meet near each wall's endpoint
|
# State 3: non-parallel walls → show Join at the floor + Extend-to-Wall
|
||||||
# → show Join at the floor + Extend-to-Wall at the active wall's top.
|
# at the active wall's top. PARALLEL_DOT_THRESHOLD (cos 2°) is the only
|
||||||
|
# bound that matters: walls within 2° of parallel produce extrusion
|
||||||
|
# joints that race toward infinity, so project_axis_intersection
|
||||||
|
# returns None and hits the early-return below. Beyond that, any
|
||||||
|
# crossing is geometrically valid — distance from the nearest endpoint
|
||||||
|
# is the user's concern, not ours.
|
||||||
intersection_tuple = core.project_axis_intersection(
|
intersection_tuple = core.project_axis_intersection(
|
||||||
(tuple(seg_a[0]), tuple(seg_a[1])),
|
(tuple(seg_a[0]), tuple(seg_a[1])),
|
||||||
(tuple(seg_b[0]), tuple(seg_b[1])),
|
(tuple(seg_b[0]), tuple(seg_b[1])),
|
||||||
@@ -3603,16 +3555,6 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
|
|||||||
self._hide_all()
|
self._hide_all()
|
||||||
return
|
return
|
||||||
intersection = Vector(intersection_tuple)
|
intersection = Vector(intersection_tuple)
|
||||||
len_a = (seg_a[1] - seg_a[0]).length
|
|
||||||
len_b = (seg_b[1] - seg_b[0]).length
|
|
||||||
near_a = min((intersection - seg_a[0]).length, (intersection - seg_a[1]).length)
|
|
||||||
near_b = min((intersection - seg_b[0]).length, (intersection - seg_b[1]).length)
|
|
||||||
if (
|
|
||||||
near_a > len_a * self.MAX_DISTANCE_TO_ENDPOINT_FACTOR
|
|
||||||
or near_b > len_b * self.MAX_DISTANCE_TO_ENDPOINT_FACTOR
|
|
||||||
):
|
|
||||||
self._hide_all()
|
|
||||||
return
|
|
||||||
|
|
||||||
# Join sits on the floor (lowest endpoint Z across both wall axes), exactly
|
# Join sits on the floor (lowest endpoint Z across both wall axes), exactly
|
||||||
# where the corner meets the ground — no visibility lift.
|
# where the corner meets the ground — no visibility lift.
|
||||||
@@ -3624,7 +3566,7 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
|
|||||||
# Extend-to-Wall sits at the active wall's top, same XY as the join icon —
|
# Extend-to-Wall sits at the active wall's top, same XY as the join icon —
|
||||||
# the Z gap is what differentiates "join at corner" from "extend into other".
|
# the Z gap is what differentiates "join at corner" from "extend into other".
|
||||||
active = context.active_object if context.active_object in selected else None
|
active = context.active_object if context.active_object in selected else None
|
||||||
geom = _read_wall_geometry(active) if active else None
|
geom = tool.Wall.read_geometry(active) if active else None
|
||||||
if geom is None:
|
if geom is None:
|
||||||
self.extend_to_wall_icon.hide = True
|
self.extend_to_wall_icon.hide = True
|
||||||
else:
|
else:
|
||||||
@@ -4053,13 +3995,12 @@ class GizmoWallFilletReedit(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
|
|||||||
element = tool.Ifc.get_entity(active)
|
element = tool.Ifc.get_entity(active)
|
||||||
if element is None or not element.is_a("IfcWall"):
|
if element is None or not element.is_a("IfcWall"):
|
||||||
return False
|
return False
|
||||||
if not tool.Parametric.is_fillet_corner_wall(element):
|
# IsFilletCorner pset is the authoritative signal — the re-edit
|
||||||
return False
|
# operator separately verifies both neighbour connections exist and
|
||||||
# Both neighbour connections must still exist for the re-edit to
|
# reports a user-facing error if either side has been disconnected
|
||||||
# recover the original corner.
|
# since creation. Validating that here would hide the pen icon
|
||||||
has_a = any(r.is_a("IfcRelConnectsPathElements") for r in getattr(element, "ConnectedFrom", []))
|
# silently, leaving the user with no obvious next step.
|
||||||
has_b = any(r.is_a("IfcRelConnectsPathElements") for r in getattr(element, "ConnectedTo", []))
|
return tool.Parametric.is_fillet_corner_wall(element)
|
||||||
return has_a and has_b
|
|
||||||
|
|
||||||
def setup(self, context: bpy.types.Context) -> None:
|
def setup(self, context: bpy.types.Context) -> None:
|
||||||
prefs = tool.Blender.get_addon_preferences()
|
prefs = tool.Blender.get_addon_preferences()
|
||||||
|
|||||||
Reference in New Issue
Block a user