mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
Stack wall-join trio along screen-up + L/T glyphs
GizmoWallJoinIntersection used to place its icons at state-specific world points: join at floor Z, extend-to-wall at the active wall's top Z, fillet stacked screen-up above join. Same XY at different Z collapses to a single screen pixel in plan / top view, so two icons became one hit target — invisible from above. * position_gizmos now always-stacks along screen-up at a wall-top anchor in both the joined (unjoin + fillet) and the intersecting (extend + join + fillet) states. Order bottom-up is extend / L / fillet. Collinear-merge keeps its single boundary icon (no stack needed). * New _stack_anchor_z picks the active wall's top Z (or the taller of the two on mid-selection-transition frames). New _stack_at lays a tuple of icons along screen-up at the resolved anchor. * Glyph swap: join_icon -> VIEW3D_GT_wall_corner (L), extend_to_wall_icon -> VIEW3D_GT_wall_tee (T). Both classes already existed in bim/module/drawing/gizmos.py from an earlier commit; only the setup() bl_idname strings changed. The previous arrow-merge / arrow-extend pair read as the same direction once stacked. Forward-compat AST contracts in test_wall_gizmos_forward_compat.py pin the new invariants: the L and T bl_idnames must appear in setup(), and position_gizmos must route through _stack_at so a regression that reintroduces a direct billboarded_at write for any state-specific icon fails CI before it flattens the stack again. Also folds in a one-line typo fix in core/spatial.py: assign_container's per-element can_contain check iterated `e` but predicate-tested `root_element` (the outer for-loop variable), so every element in the comprehension was tested against the same container/element pair. Switch the argument to `e`. Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -3450,11 +3450,14 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
|
||||
default_color, highlight_color = self.get_decoration_colors()
|
||||
self.unjoin_icon = self.setup_icon_gizmo("VIEW3D_GT_split", default_color, highlight_color, "bim.unjoin_walls")
|
||||
self.merge_icon = self.setup_icon_gizmo("VIEW3D_GT_merge", default_color, highlight_color, "bim.merge_wall")
|
||||
# L-corner glyph reads as "join at the corner"; differentiated from
|
||||
# the T glyph (extend) by where the bars meet (corner vs midline).
|
||||
self.join_icon = self.setup_icon_gizmo(
|
||||
"VIEW3D_GT_merge", default_color, highlight_color, "bim.join_walls_intersection"
|
||||
"VIEW3D_GT_wall_corner", default_color, highlight_color, "bim.join_walls_intersection"
|
||||
)
|
||||
# T-junction glyph reads as "extend this wall into the other's side".
|
||||
self.extend_to_wall_icon = self.setup_icon_gizmo(
|
||||
"VIEW3D_GT_extend", default_color, highlight_color, "bim.extend_walls_to_wall"
|
||||
"VIEW3D_GT_wall_tee", default_color, highlight_color, "bim.extend_walls_to_wall"
|
||||
)
|
||||
# Fillet entry — shows in the same two states (joined / intersect)
|
||||
# where rounding the corner is well-defined. Click enters the preview
|
||||
@@ -3485,27 +3488,22 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
|
||||
seg_a = _wall_axis_world_segment_from_geom(selected[0], geom_a)
|
||||
seg_b = _wall_axis_world_segment_from_geom(selected[1], geom_b)
|
||||
billboard_rot = gizmo.get_billboard_rotation(context)
|
||||
screen_up = gizmo.get_screen_up(billboard_rot)
|
||||
anchor_z = self._stack_anchor_z(context, selected, geom_a, geom_b)
|
||||
|
||||
# State 1: walls are already joined → show Unjoin only, at the shared
|
||||
# corner's floor Z (no visibility lift — user expects the icon to sit
|
||||
# exactly at the corner, not floating above it).
|
||||
# State 1: walls are already joined → Unjoin (bottom) + Fillet (above).
|
||||
if _are_walls_joined(elem_a, elem_b):
|
||||
corner = _collinear_boundary_world(seg_a, seg_b)
|
||||
self.unjoin_icon.matrix_basis = gizmo.billboarded_at(corner, billboard_rot)
|
||||
self.unjoin_icon.hide = False
|
||||
anchor = Vector((corner.x, corner.y, anchor_z))
|
||||
self._stack_at(anchor, screen_up, billboard_rot, (self.unjoin_icon, self.fillet_icon))
|
||||
self.merge_icon.hide = True
|
||||
self.join_icon.hide = True
|
||||
self.extend_to_wall_icon.hide = True
|
||||
# Fillet entry stacked above the unjoin icon in screen-up.
|
||||
screen_up = gizmo.get_screen_up(billboard_rot)
|
||||
self.fillet_icon.matrix_basis = gizmo.billboarded_at(
|
||||
corner + screen_up * self.ICON_STACK_OFFSET_Y, billboard_rot
|
||||
)
|
||||
self.fillet_icon.hide = False
|
||||
return
|
||||
|
||||
# State 2: walls are collinear (parallel axes on the same line) → show Merge
|
||||
# at the boundary midpoint between them, at floor Z (no visibility lift).
|
||||
# at the boundary midpoint between them. No stack; single icon at the
|
||||
# geometric boundary makes the merge target unambiguous.
|
||||
if _are_walls_collinear(seg_a, seg_b, self.PARALLEL_DOT_THRESHOLD, self.COLLINEAR_LINE_TOLERANCE):
|
||||
boundary = _collinear_boundary_world(seg_a, seg_b)
|
||||
self.merge_icon.matrix_basis = gizmo.billboarded_at(boundary, billboard_rot)
|
||||
@@ -3516,13 +3514,12 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
|
||||
self.fillet_icon.hide = True
|
||||
return
|
||||
|
||||
# State 3: non-parallel walls → show Join at the floor + Extend-to-Wall
|
||||
# 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.
|
||||
# State 3: non-parallel walls → Join (L, bottom) + Extend-to-Wall (T)
|
||||
# + Fillet (top) stacked along screen-up at the wall-top anchor.
|
||||
# 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 the
|
||||
# early-return below hides the whole group.
|
||||
intersection_tuple = core.project_axis_intersection(
|
||||
(tuple(seg_a[0]), tuple(seg_a[1])),
|
||||
(tuple(seg_b[0]), tuple(seg_b[1])),
|
||||
@@ -3532,36 +3529,44 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
|
||||
self._hide_all()
|
||||
return
|
||||
intersection = Vector(intersection_tuple)
|
||||
|
||||
# Join sits on the floor (lowest endpoint Z across both wall axes), exactly
|
||||
# where the corner meets the ground — no visibility lift.
|
||||
floor_z = min(seg_a[0].z, seg_a[1].z, seg_b[0].z, seg_b[1].z)
|
||||
join_world = Vector((intersection.x, intersection.y, floor_z))
|
||||
self.join_icon.matrix_basis = gizmo.billboarded_at(join_world, billboard_rot)
|
||||
self.join_icon.hide = False
|
||||
|
||||
# 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".
|
||||
active = context.active_object if context.active_object in selected else None
|
||||
geom = tool.Wall.read_geometry(active) if active else None
|
||||
if geom is None:
|
||||
self.extend_to_wall_icon.hide = True
|
||||
else:
|
||||
active_top_z = active.matrix_world.translation.z + geom["height"]
|
||||
extend_world = Vector((intersection.x, intersection.y, active_top_z))
|
||||
self.extend_to_wall_icon.matrix_basis = gizmo.billboarded_at(extend_world, billboard_rot)
|
||||
self.extend_to_wall_icon.hide = False
|
||||
|
||||
# Fillet entry stacked above the join icon in screen-up.
|
||||
screen_up = gizmo.get_screen_up(billboard_rot)
|
||||
self.fillet_icon.matrix_basis = gizmo.billboarded_at(
|
||||
join_world + screen_up * self.ICON_STACK_OFFSET_Y, billboard_rot
|
||||
)
|
||||
self.fillet_icon.hide = False
|
||||
|
||||
anchor = Vector((intersection.x, intersection.y, anchor_z))
|
||||
self._stack_at(anchor, screen_up, billboard_rot, (self.extend_to_wall_icon, self.join_icon, self.fillet_icon))
|
||||
self.unjoin_icon.hide = True
|
||||
self.merge_icon.hide = True
|
||||
|
||||
def _stack_anchor_z(
|
||||
self,
|
||||
context: bpy.types.Context,
|
||||
selected: list[bpy.types.Object],
|
||||
geom_a: dict,
|
||||
geom_b: dict,
|
||||
) -> float:
|
||||
# Wall-top Z is the bottom of the screen-up stack — high enough that
|
||||
# the icons sit on top of the wall instead of clipping into it.
|
||||
# Prefer the active wall's top (the height the user is operating on);
|
||||
# fall back to the taller of the two if the active object isn't one
|
||||
# of the selected walls (mid-selection-transition frame).
|
||||
active = context.active_object if context.active_object in selected else None
|
||||
if active is selected[0]:
|
||||
return active.matrix_world.translation.z + geom_a["height"]
|
||||
if active is selected[1]:
|
||||
return active.matrix_world.translation.z + geom_b["height"]
|
||||
return max(
|
||||
selected[0].matrix_world.translation.z + geom_a["height"],
|
||||
selected[1].matrix_world.translation.z + geom_b["height"],
|
||||
)
|
||||
|
||||
def _stack_at(
|
||||
self,
|
||||
anchor: Vector,
|
||||
screen_up: Vector,
|
||||
billboard_rot: Matrix,
|
||||
icons: tuple[bpy.types.Gizmo, ...],
|
||||
) -> None:
|
||||
for k, icon in enumerate(icons):
|
||||
icon.matrix_basis = gizmo.billboarded_at(anchor + screen_up * (self.ICON_STACK_OFFSET_Y * k), billboard_rot)
|
||||
icon.hide = False
|
||||
|
||||
|
||||
class GizmoWallLinkToggle(gizmo.GizmoLinkToggle, bpy.types.Gizmo):
|
||||
"""Link-toggle glyph with a partner-wall highlight on hover. The owning
|
||||
|
||||
@@ -64,7 +64,7 @@ def assign_container(
|
||||
spatial.disable_editing(obj)
|
||||
all_elements.add(root_element)
|
||||
all_elements.update(spatial.get_decomposition(root_element))
|
||||
if products := [e for e in root_elements if spatial.can_contain(container, root_element)]:
|
||||
if products := [e for e in root_elements if spatial.can_contain(container, e)]:
|
||||
ifc.run("spatial.assign_container", products=products, relating_structure=container)
|
||||
for element in all_elements:
|
||||
if obj := ifc.get_object(element):
|
||||
|
||||
@@ -159,3 +159,54 @@ def test_gizmo_wall_add_opening_accepts_fillet_corner_active():
|
||||
"drops fillet-corner walls. Use is_path_connectable_wall instead, matching "
|
||||
"the host gate every other wall-state gizmo group uses."
|
||||
)
|
||||
|
||||
|
||||
def test_join_intersection_uses_l_and_t_glyphs():
|
||||
"""``GizmoWallJoinIntersection.setup`` must bind the join icon to the L
|
||||
glyph (``VIEW3D_GT_wall_corner``) and the extend-to icon to the T glyph
|
||||
(``VIEW3D_GT_wall_tee``). The L / T pair makes the corner-join vs
|
||||
extend-into-side distinction read at a glance — a regression to the
|
||||
arrow-merge glyph for both icons makes them visually indistinguishable
|
||||
once they're stacked at the same XY."""
|
||||
from bonsai.bim.module.model.wall import GizmoWallJoinIntersection
|
||||
|
||||
source = textwrap.dedent(inspect.getsource(GizmoWallJoinIntersection.setup))
|
||||
assert '"VIEW3D_GT_wall_corner"' in source, (
|
||||
"GizmoWallJoinIntersection.setup must bind join_icon to VIEW3D_GT_wall_corner "
|
||||
"(the L glyph). The arrow-merge glyph (VIEW3D_GT_merge) is the collinear-merge "
|
||||
"case and was visually ambiguous with the extend-to icon when both were stacked."
|
||||
)
|
||||
assert '"VIEW3D_GT_wall_tee"' in source, (
|
||||
"GizmoWallJoinIntersection.setup must bind extend_to_wall_icon to "
|
||||
"VIEW3D_GT_wall_tee (the T glyph). The arrow-extend glyph was visually "
|
||||
"ambiguous with the join icon when both were stacked."
|
||||
)
|
||||
|
||||
|
||||
def test_join_intersection_stacks_along_screen_up_in_both_states():
|
||||
"""``GizmoWallJoinIntersection.position_gizmos`` must route both the
|
||||
joined (unjoin + fillet) and the intersecting (join + extend + fillet)
|
||||
states through ``_stack_at`` so the icons stay individually clickable
|
||||
in any view, including top / plan view where world-Z separation
|
||||
collapses to zero on screen. A regression that re-introduces a
|
||||
per-state ``billboarded_at(corner, ...)`` write outside ``_stack_at``
|
||||
silently flattens the stack back onto one screen pixel."""
|
||||
from bonsai.bim.module.model.wall import GizmoWallJoinIntersection
|
||||
|
||||
source = textwrap.dedent(inspect.getsource(GizmoWallJoinIntersection.position_gizmos))
|
||||
tree = ast.parse(source)
|
||||
call_names: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if isinstance(node.func, ast.Attribute):
|
||||
call_names.add(node.func.attr)
|
||||
elif isinstance(node.func, ast.Name):
|
||||
call_names.add(node.func.id)
|
||||
|
||||
assert "_stack_at" in call_names, (
|
||||
"GizmoWallJoinIntersection.position_gizmos must call self._stack_at to "
|
||||
"lay icons along screen-up at the wall-top anchor. Direct "
|
||||
"billboarded_at writes for the join/unjoin/extend/fillet icons bypass "
|
||||
"the stacking contract and re-introduce the top-view collapse bug."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user