mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
Add wall-fillet feature: operators, gizmos, decorator
End-to-end fillet flow on top of the helpers + recreate_wall hook (landed in the previous commit). Users select two LAYER2 walls, click the fillet entry icon, drag the live radius widget, and validate to replace the corner with a curved LAYER2 corner wall (banana body). Operators (5): * EnableWallFilletPreview: 2-wall selection → validates LAYER2 + straight axis + zero-slope + intersect-or-joined state → seeds the preview props with a default radius computed from the shorter available leg. * FinishWallFilletPreview: dispatches CreateWallFillet with the tuned radius; clears preview state on FINISHED, preserves it on failure so the user can re-tune without re-selecting. * CancelWallFilletPreview: clears preview state, no IFC mutation. * EnableWallFilletPreviewFromCorner: pen-icon re-edit on an existing fillet corner — pre-fills the preview from the corner's BBIM_Wall pset + walks the inverse graph to recover wall A and wall B. * CreateWallFillet: deletes any prior corner + A↔B path connection, shortens A and B to the tangent points, instantiates a corner wall from A's type, unassigns the swept-layer material/type (the explicit banana body MUST own its geometry), assigns the dominant material, rebuilds the body, sets a straight 2-point chord axis, stores BBIM_Wall.IsFilletCorner+FilletRadius, reconnects A and B to the corner with NOTDEFINED on the corner's side. Gizmo groups (2 new + entry icon on existing): * GizmoWallFilletPreview: visible while a preview is active. Bundles a radius_dim widget at the arc apex, a trim_dim widget along wall A expressing the same DOF via the leg setback distance (trim = |radius| * tan(sweep/2)), and validate / cancel icons anchored above the apex in screen-up. * GizmoWallFilletReedit: pen-icon entry on an existing fillet corner wall (single-selection, BBIM_Wall.IsFilletCorner set, both neighbour connections present). Mutually exclusive with an active preview. * GizmoWallJoinIntersection now stacks a fillet entry icon (VIEW3D_GT_fillet → bim.enable_wall_fillet_preview) above the existing join/unjoin icon in the joined and intersect state branches. Property + decorator infrastructure: * prop.py: BIMWallFilletPreviewProperties (Scene-level draft) + BIMPreviewProperties umbrella with only the wall_fillet pointer. The umbrella is the seam preview_base.py (landed in PR3) already reads via getattr(scene, "BIMPreviewProperties", None). * decorator.py: _stroke_lines_alpha helper + WallFilletPreviewDecorator. Polls is_active; renders leg projections + arc + arc-center construction lines from tool.Wall.compute_wall_fillet_geometry. * __init__.py: registers operators + gizmo groups + property groups + wires Scene.BIMPreviewProperties. * handler.py: WallFilletPreviewDecorator.install/uninstall in _install_decorators — always installed, self-polls on is_active. Drive-by: extract gizmo.get_screen_up(billboard_rot) helper — the local +Y of a billboard rotation is the camera's screen-up world direction. Replaces 4 inline `billboard_rot @ Vector((0.0, 1.0, 0.0))` sites added across the fillet feature's gizmo groups. Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -46,6 +46,7 @@ from bonsai.bim.module.model.decorator import (
|
||||
BoundingBoxDecorator,
|
||||
SlabDirectionDecorator,
|
||||
WallAxisDecorator,
|
||||
WallFilletPreviewDecorator,
|
||||
)
|
||||
from bonsai.bim.module.model.preview_base import discard_pending_previews
|
||||
from bonsai.bim.module.nest.decorator import NestDecorator
|
||||
@@ -462,6 +463,7 @@ def _install_viewport_overlays() -> None:
|
||||
NestDecorator.uninstall()
|
||||
WallAxisDecorator.uninstall()
|
||||
SlabDirectionDecorator.uninstall()
|
||||
WallFilletPreviewDecorator.uninstall()
|
||||
uninstall_decorator_cache_handlers()
|
||||
try:
|
||||
if georeference_props.should_visualise:
|
||||
@@ -476,6 +478,10 @@ def _install_viewport_overlays() -> None:
|
||||
SlabDirectionDecorator.install(bpy.context)
|
||||
if model_props.show_bounding_box:
|
||||
BoundingBoxDecorator.install(bpy.context)
|
||||
# Always-installed: draw() self-polls on Scene.BIMPreviewProperties.
|
||||
# wall_fillet.is_active, so installation has no cost when no preview
|
||||
# is open. No corresponding addon-preference toggle.
|
||||
WallFilletPreviewDecorator.install(bpy.context)
|
||||
finally:
|
||||
install_decorator_cache_handlers()
|
||||
|
||||
|
||||
@@ -1674,6 +1674,14 @@ def billboarded_at(world_pos: Vector, billboard_rot: Matrix, scale: float = DEFA
|
||||
return Matrix.Translation(world_pos) @ billboard_rot @ Matrix.Scale(scale, 4)
|
||||
|
||||
|
||||
def get_screen_up(billboard_rot: Matrix) -> Vector:
|
||||
"""Camera's screen-up direction in world space — local +Y of the billboard
|
||||
rotation. Use to lift a gizmo above an anchor in a way that stays
|
||||
perpendicular to the view plane (world +Z collapses to zero on-screen in
|
||||
top-down view and lands lifted gizmos on top of their anchors)."""
|
||||
return billboard_rot @ Vector((0.0, 1.0, 0.0))
|
||||
|
||||
|
||||
# Dead-band on the screen-X delta — prevents flicker when the gizmo sits on the
|
||||
# element origin.
|
||||
EXTEND_FLIP_EPSILON = 1e-4
|
||||
|
||||
@@ -93,6 +93,8 @@ classes = (
|
||||
wall.GizmoWallAddOpening,
|
||||
wall.GizmoWallEdition,
|
||||
wall.GizmoWallExtendVertically,
|
||||
wall.GizmoWallFilletPreview,
|
||||
wall.GizmoWallFilletReedit,
|
||||
wall.GizmoWallJoinIntersection,
|
||||
wall.GizmoWallUnjoinSingle,
|
||||
wall.JoinWallsIntersection,
|
||||
@@ -105,6 +107,11 @@ classes = (
|
||||
wall.ToggleWallOpenings,
|
||||
wall.UnjoinWallPathConnection,
|
||||
wall.UnjoinWalls,
|
||||
wall.EnableWallFilletPreview,
|
||||
wall.FinishWallFilletPreview,
|
||||
wall.CancelWallFilletPreview,
|
||||
wall.EnableWallFilletPreviewFromCorner,
|
||||
wall.CreateWallFillet,
|
||||
opening.AddBoolean,
|
||||
opening.CloneOpening,
|
||||
opening.EditOpenings,
|
||||
@@ -165,6 +172,8 @@ classes = (
|
||||
prop.BIMWallProperties,
|
||||
prop.BIMPolylineProperties,
|
||||
prop.BIMExternalParametricGeometryProperties,
|
||||
prop.BIMWallFilletPreviewProperties,
|
||||
prop.BIMPreviewProperties,
|
||||
ui.BIM_PT_array,
|
||||
ui.BIM_PT_stair,
|
||||
ui.BIM_PT_wall,
|
||||
@@ -295,6 +304,7 @@ def register():
|
||||
bpy.types.Object.BIMExternalParametricGeometryProperties = bpy.props.PointerProperty(
|
||||
type=prop.BIMExternalParametricGeometryProperties
|
||||
)
|
||||
bpy.types.Scene.BIMPreviewProperties = bpy.props.PointerProperty(type=prop.BIMPreviewProperties)
|
||||
|
||||
bpy.types.VIEW3D_MT_add.prepend(ui.add_menu)
|
||||
bpy.app.handlers.load_post.append(handler.load_post)
|
||||
@@ -313,6 +323,7 @@ def unregister():
|
||||
del bpy.types.Object.BIMSverchokProperties
|
||||
tool.Parametric.unregister_object_properties()
|
||||
del bpy.types.Object.BIMExternalParametricGeometryProperties
|
||||
del bpy.types.Scene.BIMPreviewProperties
|
||||
|
||||
bpy.app.handlers.load_post.remove(handler.load_post)
|
||||
bpy.types.VIEW3D_MT_add.remove(ui.add_menu)
|
||||
|
||||
@@ -2029,3 +2029,151 @@ class BoundingBoxDecorator:
|
||||
else:
|
||||
co1.y += y_overlap / 2 + min_spacing
|
||||
co2.y -= y_overlap / 2 + min_spacing
|
||||
|
||||
|
||||
def _stroke_lines_alpha(
|
||||
context: bpy.types.Context,
|
||||
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
|
||||
color_rgb: tuple[float, float, float],
|
||||
line_width: float,
|
||||
line_alpha: float,
|
||||
) -> None:
|
||||
"""Render ``segments`` (a list of ``(start, end)`` tuples) as one
|
||||
anti-aliased LINES batch in world space. Early-returns when
|
||||
``context.region`` is unavailable (e.g. when called from a
|
||||
``_RestrictContext``)."""
|
||||
if not segments:
|
||||
return
|
||||
verts: list[tuple[float, float, float]] = []
|
||||
indices: list[tuple[int, int]] = []
|
||||
for start, end in segments:
|
||||
base = len(verts)
|
||||
verts.append(tuple(start))
|
||||
verts.append(tuple(end))
|
||||
indices.append((base, base + 1))
|
||||
if not tool.Blender.validate_shader_batch_data(verts, indices):
|
||||
return
|
||||
region = getattr(context, "region", None)
|
||||
if region is None:
|
||||
return
|
||||
shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
|
||||
shader.bind()
|
||||
shader.uniform_float("viewportSize", (region.width, region.height))
|
||||
shader.uniform_float("lineWidth", line_width)
|
||||
shader.uniform_float("color", (*color_rgb, line_alpha))
|
||||
batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=indices)
|
||||
gpu.state.blend_set("ALPHA")
|
||||
batch.draw(shader)
|
||||
gpu.state.blend_set("NONE")
|
||||
|
||||
|
||||
class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
"""GPU preview lines for the wall-fillet flow.
|
||||
|
||||
Polls on ``scene.BIMPreviewProperties.wall_fillet.is_active`` and renders
|
||||
the leg projections + arc + radial construction lines returned by
|
||||
``tool.Wall.compute_wall_fillet_geometry``. The two leg lines show how
|
||||
each wall will be shortened to its tangent point; the arc approximates
|
||||
the rounded corner; the two construction lines (arc center to each
|
||||
tangent point) visually pin the radius.
|
||||
|
||||
Installed once per Blender session from ``bim/handler.py:load_post``
|
||||
and uninstalled in ``bim/module/model/__init__.py:unregister``."""
|
||||
|
||||
LINE_WIDTH_LEG = 1.5
|
||||
LINE_WIDTH_ARC = 2.5
|
||||
LINE_WIDTH_CONSTRUCTION = 1.0
|
||||
LINE_ALPHA = 0.7
|
||||
CONSTRUCTION_ALPHA = 0.4
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
scene = context.scene
|
||||
preview_props = getattr(scene, "BIMPreviewProperties", None)
|
||||
props = preview_props.wall_fillet if preview_props is not None else None
|
||||
if props is None or not props.is_active:
|
||||
return
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
return
|
||||
try:
|
||||
wall_a = ifc_file.by_id(props.wall_a_id)
|
||||
wall_b = ifc_file.by_id(props.wall_b_id)
|
||||
except Exception:
|
||||
return
|
||||
wall_a_obj = tool.Ifc.get_object(wall_a) if wall_a else None
|
||||
wall_b_obj = tool.Ifc.get_object(wall_b) if wall_b else None
|
||||
if wall_a_obj is None or wall_b_obj is None:
|
||||
return
|
||||
|
||||
geom = tool.Wall.compute_wall_fillet_geometry(wall_a_obj, wall_b_obj, props.radius)
|
||||
if geom is None:
|
||||
return
|
||||
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
warning_color = tuple(prefs.decorator_color_error[:3])
|
||||
|
||||
if not geom["valid"]:
|
||||
# Degenerate geometry paints red: invalid_radius shows legs+arc
|
||||
# past the wall ends; invalid_axes shows the parallel/collinear
|
||||
# axes.
|
||||
if geom.get("invalid_radius"):
|
||||
tangent_a = geom.get("tangent_a")
|
||||
tangent_b = geom.get("tangent_b")
|
||||
ref_a = tool.Wall.get_world_reference_line(wall_a_obj)
|
||||
ref_b = tool.Wall.get_world_reference_line(wall_b_obj)
|
||||
if tangent_a is not None and tangent_b is not None and ref_a is not None and ref_b is not None:
|
||||
far_a = self._far_endpoint(ref_a, geom["intersection"])
|
||||
far_b = self._far_endpoint(ref_b, geom["intersection"])
|
||||
legs = [
|
||||
(tuple(far_a), tuple(tangent_a)),
|
||||
(tuple(far_b), tuple(tangent_b)),
|
||||
]
|
||||
_stroke_lines_alpha(context, legs, warning_color, self.LINE_WIDTH_LEG, self.LINE_ALPHA)
|
||||
arc = geom.get("arc") or []
|
||||
if len(arc) >= 2:
|
||||
arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)]
|
||||
_stroke_lines_alpha(context, arc_segments, warning_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
|
||||
elif geom.get("invalid_axes"):
|
||||
axes = geom["invalid_axes"]
|
||||
segments = [(tuple(a), tuple(b)) for a, b in axes]
|
||||
_stroke_lines_alpha(context, segments, warning_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
|
||||
return
|
||||
|
||||
leg_color = tuple(prefs.decorations_colour[:3])
|
||||
arc_color = tuple(prefs.decorator_color_selected[:3])
|
||||
|
||||
# Resolved against the IFC reference line, not mesh bounds, so trimmed
|
||||
# walls and openings don't shift the leg endpoints.
|
||||
ref_a = tool.Wall.get_world_reference_line(wall_a_obj)
|
||||
ref_b = tool.Wall.get_world_reference_line(wall_b_obj)
|
||||
if ref_a is not None and ref_b is not None and geom["intersection"] is not None:
|
||||
far_a = self._far_endpoint(ref_a, geom["intersection"])
|
||||
far_b = self._far_endpoint(ref_b, geom["intersection"])
|
||||
legs = [
|
||||
(tuple(far_a), tuple(geom["tangent_a"])),
|
||||
(tuple(far_b), tuple(geom["tangent_b"])),
|
||||
]
|
||||
_stroke_lines_alpha(context, legs, leg_color, self.LINE_WIDTH_LEG, self.LINE_ALPHA)
|
||||
|
||||
arc = geom["arc"]
|
||||
if len(arc) >= 2:
|
||||
arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)]
|
||||
_stroke_lines_alpha(context, arc_segments, arc_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
|
||||
|
||||
# Dim construction lines from arc_center to each tangent point so
|
||||
# the radius reads as concrete during drag.
|
||||
arc_center = geom.get("arc_center")
|
||||
if arc_center is not None:
|
||||
construction = [
|
||||
(tuple(arc_center), tuple(geom["tangent_a"])),
|
||||
(tuple(arc_center), tuple(geom["tangent_b"])),
|
||||
]
|
||||
_stroke_lines_alpha(context, construction, arc_color, self.LINE_WIDTH_CONSTRUCTION, self.CONSTRUCTION_ALPHA)
|
||||
|
||||
@staticmethod
|
||||
def _far_endpoint(reference_line, intersection):
|
||||
"""Endpoint of ``reference_line`` furthest from ``intersection``."""
|
||||
p1, p2 = reference_line
|
||||
d1 = (p1.x - intersection[0]) ** 2 + (p1.y - intersection[1]) ** 2 + (p1.z - intersection[2]) ** 2
|
||||
d2 = (p2.x - intersection[0]) ** 2 + (p2.y - intersection[1]) ** 2 + (p2.z - intersection[2]) ** 2
|
||||
return p2 if d2 >= d1 else p1
|
||||
|
||||
@@ -1902,3 +1902,65 @@ class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup):
|
||||
geometry_source: Literal["GEONODES", "IFCSVERCHOK"]
|
||||
geo_nodes: Union[bpy.types.GeometryNodeTree, None]
|
||||
sverchok_nodes: Union[sverchok.node_tree.SverchCustomTree, None]
|
||||
|
||||
|
||||
class BIMWallFilletPreviewProperties(PropertyGroup):
|
||||
"""Scene-level pending state for the wall-fillet preview flow.
|
||||
|
||||
Scene-level because the fillet spans two walls and commits a third
|
||||
(corner) wall between them. ``SKIP_SAVE`` fields throughout."""
|
||||
|
||||
is_active: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
description="True while the wall-fillet preview flow is active.",
|
||||
)
|
||||
wall_a_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description=(
|
||||
"IFC element id of the active wall — the corner wall inherits its "
|
||||
"material layer set, height, x_angle, and type."
|
||||
),
|
||||
)
|
||||
wall_b_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description="IFC element id of the other selected wall.",
|
||||
)
|
||||
radius: bpy.props.FloatProperty(
|
||||
name="Radius",
|
||||
default=0.5,
|
||||
soft_min=-10.0,
|
||||
soft_max=10.0,
|
||||
subtype="DISTANCE",
|
||||
unit="LENGTH",
|
||||
options={"SKIP_SAVE"},
|
||||
description="Radius of the circular arc connecting the two walls.",
|
||||
)
|
||||
editing_corner_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description=(
|
||||
"IFC element id of an existing fillet corner being re-edited "
|
||||
"(non-zero only on the pen-icon re-edit flow). The create "
|
||||
"operator deletes this corner + its connections before recreating "
|
||||
"with the new radius."
|
||||
),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_active: bool
|
||||
wall_a_id: int
|
||||
wall_b_id: int
|
||||
radius: float
|
||||
editing_corner_id: int
|
||||
|
||||
|
||||
class BIMPreviewProperties(PropertyGroup):
|
||||
"""Umbrella for parametric-edit preview drafts attached to ``Scene``."""
|
||||
|
||||
wall_fillet: bpy.props.PointerProperty(type=BIMWallFilletPreviewProperties)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
wall_fillet: BIMWallFilletPreviewProperties
|
||||
|
||||
@@ -62,6 +62,11 @@ if TYPE_CHECKING:
|
||||
from bonsai.bim.module.model.prop import BIMWallProperties
|
||||
|
||||
|
||||
_FILLET_DEFAULT_RADIUS_M = 0.5 # Fallback when the leg-fraction heuristic cannot resolve a value.
|
||||
_FILLET_DEFAULT_LEG_FRACTION = 0.25 # Quarter of the shorter available leg — visible without overrunning either wall.
|
||||
_FILLET_MIN_RADIUS_M = 0.001 # Lower bound — anything smaller renders as a single pixel at common viewport scales.
|
||||
|
||||
|
||||
def regenerate_wall_mesh_from_props(obj: bpy.types.Object) -> None:
|
||||
"""Rebuild ``obj.data`` as a preview box from ``BIMWallProperties`` without touching IFC.
|
||||
|
||||
@@ -2851,6 +2856,434 @@ def regenerate_fillet_corner_wall(element: ifcopenshell.entity_instance, obj: bp
|
||||
_apply_fillet_corner_geometry(ifc_file, obj, geom, wall_a_obj)
|
||||
|
||||
|
||||
class EnableWallFilletPreview(bpy.types.Operator):
|
||||
"""Enter wall-fillet preview mode for two selected walls. No IFC
|
||||
mutation until finish."""
|
||||
|
||||
bl_idname = "bim.enable_wall_fillet_preview"
|
||||
bl_label = "Enter Wall Fillet Preview"
|
||||
bl_description = "Begin tuning the fillet radius before committing the rounded corner"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if _resolve_two_walls(context) is None:
|
||||
cls.poll_message_set("Select exactly 2 LAYER2 walls with straight axes.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
walls = _resolve_two_walls(context)
|
||||
if walls is None:
|
||||
self.report({"ERROR"}, "Selection no longer eligible for fillet preview.")
|
||||
return {"CANCELLED"}
|
||||
wall_a, wall_b = walls
|
||||
|
||||
elem_a = tool.Ifc.get_entity(wall_a)
|
||||
elem_b = tool.Ifc.get_entity(wall_b)
|
||||
|
||||
if not _walls_have_zero_slope_for_fillet(self, wall_a, wall_b):
|
||||
return {"CANCELLED"}
|
||||
|
||||
# Joined / intersecting only; parallel pairs have no corner to round.
|
||||
seg_a = tool.Wall.get_world_reference_line(wall_a)
|
||||
seg_b = tool.Wall.get_world_reference_line(wall_b)
|
||||
if seg_a is None or seg_b is None:
|
||||
self.report({"ERROR"}, "Could not read reference line on one of the walls.")
|
||||
return {"CANCELLED"}
|
||||
are_joined = _are_walls_joined(elem_a, elem_b)
|
||||
state, _ = core.classify_wall_join_state(
|
||||
(tuple(seg_a[0]), tuple(seg_a[1])),
|
||||
(tuple(seg_b[0]), tuple(seg_b[1])),
|
||||
are_joined,
|
||||
core.PARALLEL_DOT_THRESHOLD,
|
||||
core.COLLINEAR_LINE_TOLERANCE,
|
||||
)
|
||||
if state not in {"intersect", "joined"}:
|
||||
self.report({"ERROR"}, f"Fillet requires intersecting or joined walls (state was {state}).")
|
||||
return {"CANCELLED"}
|
||||
|
||||
preview_base.sync_uncommitted_moves([wall_a, wall_b])
|
||||
|
||||
props = _wall_fillet_props(context)
|
||||
if props is None:
|
||||
self.report({"ERROR"}, "Wall fillet preview state is unavailable.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
# Auto-cancel any prior preview before opening a fresh one — fillet
|
||||
# creates a new IFC entity at finish.
|
||||
if props.is_active:
|
||||
bpy.ops.bim.cancel_wall_fillet_preview()
|
||||
|
||||
# Default radius: a fraction of the shorter available leg, clamped
|
||||
# against the tangent-overshoot upper bound.
|
||||
geom = tool.Wall.compute_wall_fillet_geometry(wall_a, wall_b, radius=_FILLET_DEFAULT_RADIUS_M)
|
||||
default_radius = _FILLET_DEFAULT_RADIUS_M
|
||||
if geom is not None and geom.get("sweep_angle") and geom["sweep_angle"] > 1e-3:
|
||||
leg_a_available = geom.get("leg_a_available") or 0.0
|
||||
leg_b_available = geom.get("leg_b_available") or 0.0
|
||||
shortest_leg = min(leg_a_available, leg_b_available)
|
||||
if shortest_leg > 1e-6:
|
||||
upper = shortest_leg / max(math.tan(geom["sweep_angle"] / 2), 1e-6)
|
||||
default_radius = max(
|
||||
_FILLET_MIN_RADIUS_M,
|
||||
min(_FILLET_DEFAULT_LEG_FRACTION * shortest_leg, upper, _FILLET_DEFAULT_RADIUS_M),
|
||||
)
|
||||
|
||||
props.wall_a_id = elem_a.id()
|
||||
props.wall_b_id = elem_b.id()
|
||||
props.radius = default_radius
|
||||
props.editing_corner_id = 0
|
||||
props.is_active = True
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class FinishWallFilletPreview(bpy.types.Operator):
|
||||
"""Commit the previewed fillet with the tuned radius and exit preview.
|
||||
|
||||
Preview state survives a failed commit so the user can re-tune without
|
||||
re-selecting."""
|
||||
|
||||
bl_idname = "bim.finish_wall_fillet_preview"
|
||||
bl_label = "Apply Wall Fillet"
|
||||
bl_description = "Commit the rounded corner with the previewed radius"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
if context.screen is None:
|
||||
return {"CANCELLED"}
|
||||
props = preview_base.get_preview_props(context, "wall_fillet")
|
||||
if props is None or not props.is_active:
|
||||
return {"CANCELLED"}
|
||||
if tool.Ifc.get() is None:
|
||||
self.report({"ERROR"}, "No IFC file loaded.")
|
||||
return {"CANCELLED"}
|
||||
# bpy.ops promotes ``self.report({"ERROR"}) + return CANCELLED`` from
|
||||
# the dispatched operator to RuntimeError. Catch it so this operator
|
||||
# returns cleanly instead of leaving Blender's operator state
|
||||
# half-broken (which would silently disable downstream gizmo polls).
|
||||
try:
|
||||
result = bpy.ops.bim.create_wall_fillet(
|
||||
wall_a_id=props.wall_a_id,
|
||||
wall_b_id=props.wall_b_id,
|
||||
radius=props.radius,
|
||||
editing_corner_id=props.editing_corner_id,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
self.report({"ERROR"}, str(exc))
|
||||
return {"CANCELLED"}
|
||||
if "FINISHED" in result:
|
||||
props.is_active = False
|
||||
props.wall_a_id = 0
|
||||
props.wall_b_id = 0
|
||||
props.editing_corner_id = 0
|
||||
return result
|
||||
|
||||
|
||||
class CancelWallFilletPreview(bpy.types.Operator):
|
||||
"""Exit wall-fillet preview without committing."""
|
||||
|
||||
bl_idname = "bim.cancel_wall_fillet_preview"
|
||||
bl_label = "Cancel Wall Fillet"
|
||||
bl_description = "Discard the previewed fillet"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
if context.screen is None:
|
||||
return {"CANCELLED"}
|
||||
props = preview_base.get_preview_props(context, "wall_fillet")
|
||||
if props is None or not props.is_active:
|
||||
return {"CANCELLED"}
|
||||
props.is_active = False
|
||||
props.wall_a_id = 0
|
||||
props.wall_b_id = 0
|
||||
props.editing_corner_id = 0
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class EnableWallFilletPreviewFromCorner(bpy.types.Operator):
|
||||
"""Re-open the fillet preview on an existing corner wall (pen-icon entry).
|
||||
|
||||
Validate deletes and recreates the corner inside a single undo step."""
|
||||
|
||||
bl_idname = "bim.enable_wall_fillet_preview_from_corner"
|
||||
bl_label = "Edit Wall Fillet"
|
||||
bl_description = "Open the fillet preview for an existing rounded corner — drag radius to retune"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
selected = list(tool.Blender.get_selected_objects())
|
||||
if len(selected) != 1:
|
||||
return False
|
||||
element = tool.Ifc.get_entity(selected[0])
|
||||
return element is not None and tool.Parametric.is_fillet_corner_wall(element)
|
||||
|
||||
def execute(self, context: bpy.types.Context):
|
||||
selected = list(tool.Blender.get_selected_objects())
|
||||
if len(selected) != 1:
|
||||
self.report({"ERROR"}, "Select exactly one fillet corner wall.")
|
||||
return {"CANCELLED"}
|
||||
corner_obj = selected[0]
|
||||
corner_elem = tool.Ifc.get_entity(corner_obj)
|
||||
if corner_elem is None or not tool.Parametric.is_fillet_corner_wall(corner_elem):
|
||||
self.report({"ERROR"}, "Selection is not a fillet corner wall.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
radius = ifcopenshell.util.element.get_pset(corner_elem, "BBIM_Wall", "FilletRadius")
|
||||
if not radius:
|
||||
self.report({"ERROR"}, "Corner wall has no FilletRadius pset to re-edit.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
# The corner's own side of the rel is NOTDEFINED (see regenerate_
|
||||
# fillet_corner_wall) so neighbours don't miter against the chord axis
|
||||
# — read the single rel on each side of the inverse graph rather than
|
||||
# filtering on connection type.
|
||||
wall_a = None
|
||||
for rel in getattr(corner_elem, "ConnectedFrom", []):
|
||||
if rel.is_a("IfcRelConnectsPathElements"):
|
||||
wall_a = rel.RelatingElement
|
||||
break
|
||||
wall_b = None
|
||||
for rel in getattr(corner_elem, "ConnectedTo", []):
|
||||
if rel.is_a("IfcRelConnectsPathElements"):
|
||||
wall_b = rel.RelatedElement
|
||||
break
|
||||
if wall_a is None or wall_b is None:
|
||||
self.report({"ERROR"}, "Corner wall is not connected to both source walls anymore.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
wall_a_obj = tool.Ifc.get_object(wall_a)
|
||||
wall_b_obj = tool.Ifc.get_object(wall_b)
|
||||
if not _walls_have_zero_slope_for_fillet(self, wall_a_obj, wall_b_obj):
|
||||
return {"CANCELLED"}
|
||||
|
||||
props = _wall_fillet_props(context)
|
||||
if props is None:
|
||||
self.report({"ERROR"}, "Wall fillet preview state is unavailable.")
|
||||
return {"CANCELLED"}
|
||||
if props.is_active:
|
||||
bpy.ops.bim.cancel_wall_fillet_preview()
|
||||
|
||||
props.wall_a_id = wall_a.id()
|
||||
props.wall_b_id = wall_b.id()
|
||||
props.radius = float(radius)
|
||||
props.editing_corner_id = corner_elem.id()
|
||||
props.is_active = True
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class CreateWallFillet(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Replace the corner between two straight walls with a curved LAYER2
|
||||
corner wall (banana body, inherits layer set / height / x_angle / type
|
||||
from wall A)."""
|
||||
|
||||
bl_idname = "bim.create_wall_fillet"
|
||||
bl_label = "Create Wall Fillet"
|
||||
bl_description = "Replace the corner between two walls with a rounded corner of the given radius"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
wall_a_id: bpy.props.IntProperty(name="Wall A (active) IFC id")
|
||||
wall_b_id: bpy.props.IntProperty(name="Wall B (other) IFC id")
|
||||
radius: bpy.props.FloatProperty(
|
||||
name="Radius",
|
||||
default=0.5,
|
||||
subtype="DISTANCE",
|
||||
unit="LENGTH",
|
||||
description=(
|
||||
"Signed radius — positive produces a convex outward fillet, "
|
||||
"negative flips the arc center to the opposite side for an "
|
||||
"inverted (concave inward) corner."
|
||||
),
|
||||
)
|
||||
editing_corner_id: bpy.props.IntProperty(
|
||||
name="Existing fillet corner IFC id",
|
||||
default=0,
|
||||
description=(
|
||||
"Non-zero on the pen-icon re-edit flow. The operator deletes this "
|
||||
"corner + its path connections before recreating with the new radius."
|
||||
),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
wall_a_id: int
|
||||
wall_b_id: int
|
||||
radius: float
|
||||
editing_corner_id: int
|
||||
|
||||
def _execute(self, context):
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
self.report({"ERROR"}, "No IFC file loaded.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
try:
|
||||
elem_a = ifc_file.by_id(self.wall_a_id)
|
||||
elem_b = ifc_file.by_id(self.wall_b_id)
|
||||
except Exception:
|
||||
self.report({"ERROR"}, "One of the source walls is no longer in the IFC file.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
wall_a_obj = tool.Ifc.get_object(elem_a)
|
||||
wall_b_obj = tool.Ifc.get_object(elem_b)
|
||||
if wall_a_obj is None or wall_b_obj is None:
|
||||
self.report({"ERROR"}, "One of the source walls has no Blender object.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
if not _walls_have_zero_slope_for_fillet(self, wall_a_obj, wall_b_obj):
|
||||
return {"CANCELLED"}
|
||||
|
||||
geom = tool.Wall.compute_wall_fillet_geometry(wall_a_obj, wall_b_obj, self.radius)
|
||||
if geom is None or not geom["valid"]:
|
||||
reason = geom.get("reason") if geom else "unknown"
|
||||
self.report({"ERROR"}, f"Fillet geometry rejected (reason: {reason}).")
|
||||
return {"CANCELLED"}
|
||||
if geom["wall_type_id"] is None:
|
||||
self.report({"ERROR"}, "Active wall has no IfcWallType to inherit.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
tangent_a = Vector(geom["tangent_a"])
|
||||
tangent_b = Vector(geom["tangent_b"])
|
||||
side_a = geom["wall_a_join_side"]
|
||||
side_b = geom["wall_b_join_side"]
|
||||
chord = tangent_b - tangent_a
|
||||
chord_length = chord.length
|
||||
if chord_length < 1e-6:
|
||||
self.report({"ERROR"}, "Tangent points coincide — invalid fillet geometry.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
# Pen-icon re-edit path: remove the existing fillet corner + its two
|
||||
# path connections to A and B before recreating. The deletion +
|
||||
# recreation runs in the same tool.Ifc.Operator transaction, so a
|
||||
# single undo restores the pre-re-edit state.
|
||||
if self.editing_corner_id:
|
||||
try:
|
||||
old_corner = ifc_file.by_id(self.editing_corner_id)
|
||||
except Exception:
|
||||
old_corner = None
|
||||
if old_corner is not None:
|
||||
for rel in list(getattr(old_corner, "ConnectedFrom", [])) + list(
|
||||
getattr(old_corner, "ConnectedTo", [])
|
||||
):
|
||||
if rel.is_a("IfcRelConnectsPathElements"):
|
||||
bonsai.core.geometry.remove_connection(tool.Geometry, connection=rel)
|
||||
old_corner_obj = tool.Ifc.get_object(old_corner)
|
||||
ifcopenshell.api.root.remove_product(ifc_file, product=old_corner)
|
||||
if old_corner_obj is not None:
|
||||
bpy.data.objects.remove(old_corner_obj)
|
||||
|
||||
# Drop any existing direct connection between A and B before
|
||||
# retopologising — the corner wall will own the new connections at
|
||||
# both ends.
|
||||
for conn in list(elem_a.ConnectedTo) + list(elem_a.ConnectedFrom):
|
||||
if not conn.is_a("IfcRelConnectsPathElements"):
|
||||
continue
|
||||
other = conn.RelatedElement if conn.RelatingElement == elem_a else conn.RelatingElement
|
||||
if other == elem_b:
|
||||
bonsai.core.geometry.remove_connection(tool.Geometry, connection=conn)
|
||||
|
||||
# Shorten A and B so their corner-side endpoints sit on the tangent
|
||||
# points. DumbWallJoiner.extend projects the world-space target onto
|
||||
# the wall's local axis and rewrites the relevant endpoint, then
|
||||
# regenerates the body so it matches the new axis.
|
||||
joiner = DumbWallJoiner()
|
||||
joiner.extend(wall_a_obj, tangent_a, connection=side_a)
|
||||
joiner.extend(wall_b_obj, tangent_b, connection=side_b)
|
||||
|
||||
# Instantiate the corner wall from A's wall type so it inherits the
|
||||
# material layer set, height, x_angle, and IfcWallType.
|
||||
bpy.ops.bim.add_occurrence(relating_type_id=geom["wall_type_id"])
|
||||
corner_obj = bpy.context.active_object
|
||||
if corner_obj is None:
|
||||
self.report({"ERROR"}, "Failed to instantiate the corner wall.")
|
||||
return {"CANCELLED"}
|
||||
corner_elem = tool.Ifc.get_entity(corner_obj)
|
||||
if corner_elem is None:
|
||||
self.report({"ERROR"}, "Corner wall has no IFC entity after creation.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
# IfcMaterialLayerSetUsage on a wall contracts that the body is
|
||||
# derived from the Axis swept along the layer-set thicknesses;
|
||||
# spec-honouring importers discard an explicit body when they see a
|
||||
# usage. The corner's defining geometry IS the explicit banana body,
|
||||
# so neither the usage form nor the owning IfcWallType may stay
|
||||
# associated. A plain IfcMaterial carries no swept-layer contract —
|
||||
# the corner inherits a single material from the dominant (thickest)
|
||||
# layer of wall A's effective material set for QTO / colour /
|
||||
# reporting purposes without putting the explicit body at risk.
|
||||
ifcopenshell.api.material.unassign_material(ifc_file, products=[corner_elem])
|
||||
ifcopenshell.api.type.unassign_type(ifc_file, related_objects=[corner_elem])
|
||||
|
||||
dominant_material = _pick_dominant_wall_material(elem_a)
|
||||
if dominant_material is not None:
|
||||
ifcopenshell.api.material.assign_material(
|
||||
ifc_file,
|
||||
products=[corner_elem],
|
||||
type="IfcMaterial",
|
||||
material=dominant_material,
|
||||
)
|
||||
|
||||
placement = _apply_fillet_corner_geometry(ifc_file, corner_obj, geom, wall_a_obj)
|
||||
if placement is None:
|
||||
self.report({"ERROR"}, "Could not apply fillet corner geometry (degenerate chord or missing body context).")
|
||||
return {"CANCELLED"}
|
||||
_, _, _, chord_length_si = placement
|
||||
|
||||
# Axis: 2-point straight chord polyline from (0,0) to (chord_length,0)
|
||||
# in wall-local IFC units. The body curves while the axis stays
|
||||
# straight — IFC viewers and downstream Bonsai code that read the
|
||||
# reference line via get_reference_line get a usable 2-point result
|
||||
# instead of partial samples off a 3-point arc.
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
|
||||
joiner.set_axis(
|
||||
corner_elem,
|
||||
Vector((0.0, 0.0)),
|
||||
Vector((chord_length_si / unit_scale, 0.0)),
|
||||
)
|
||||
|
||||
# Mark the corner wall 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")
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
ifc_file,
|
||||
pset=pset,
|
||||
properties={"IsFilletCorner": True, "FilletRadius": float(self.radius)},
|
||||
)
|
||||
|
||||
# Connect A and B to the corner with the corner's OWN side typed as
|
||||
# NOTDEFINED rather than ATSTART/ATEND. regenerate_wall_representation
|
||||
# .join() early-returns when either side is NOTDEFINED, so neighbour
|
||||
# A's miter cut never reads the corner's chord-axis reference line.
|
||||
# A and B end FLAT at tangent_a / tangent_b — which is perpendicular
|
||||
# to their own axis AND to the curve's tangent direction at that
|
||||
# point, so the neighbour cross-sections align exactly with the
|
||||
# banana profile's cap.
|
||||
ifcopenshell.api.geometry.connect_path(
|
||||
ifc_file,
|
||||
relating_element=elem_a,
|
||||
related_element=corner_elem,
|
||||
relating_connection=side_a,
|
||||
related_connection="NOTDEFINED",
|
||||
)
|
||||
ifcopenshell.api.geometry.connect_path(
|
||||
ifc_file,
|
||||
relating_element=corner_elem,
|
||||
related_element=elem_b,
|
||||
relating_connection="NOTDEFINED",
|
||||
related_connection=side_b,
|
||||
)
|
||||
|
||||
# Recalculate A and B so their miter cuts pick up the new connections
|
||||
# to the corner. The corner itself is skipped by tool.Model.
|
||||
# recreate_wall's IsFilletCorner gate, preserving the curved body.
|
||||
tool.Model.recalculate_walls([wall_a_obj, corner_obj, wall_b_obj])
|
||||
_resync_walls_after_mutation([wall_a_obj, corner_obj, wall_b_obj])
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def _wall_fillet_gizmo_x_matrix(location: Vector, x_direction: Vector) -> Matrix:
|
||||
"""4×4 matrix placing a gizmo at ``location`` with local +X aligned to
|
||||
``x_direction`` in world space."""
|
||||
@@ -3072,6 +3505,11 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
|
||||
return False
|
||||
return True
|
||||
|
||||
# Screen-space vertical offset between stacked icons in a state branch —
|
||||
# camera's screen-up so the fillet icon sits visibly clear of the
|
||||
# join/unjoin icon at any view angle.
|
||||
ICON_STACK_OFFSET_Y: ClassVar[float] = 0.4
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
default_color = prefs.decorations_colour[:3]
|
||||
@@ -3084,9 +3522,15 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
|
||||
self.extend_to_wall_icon = self.setup_icon_gizmo(
|
||||
"VIEW3D_GT_extend", 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
|
||||
# flow; GizmoWallFilletPreview takes over from there.
|
||||
self.fillet_icon = self.setup_icon_gizmo(
|
||||
"VIEW3D_GT_fillet", default_color, highlight_color, "bim.enable_wall_fillet_preview"
|
||||
)
|
||||
|
||||
def _all_icons(self) -> tuple[bpy.types.Gizmo, ...]:
|
||||
return (self.unjoin_icon, self.merge_icon, self.join_icon, self.extend_to_wall_icon)
|
||||
return (self.unjoin_icon, self.merge_icon, self.join_icon, self.extend_to_wall_icon, self.fillet_icon)
|
||||
|
||||
def _hide_all(self) -> None:
|
||||
for icon in self._all_icons():
|
||||
@@ -3118,6 +3562,12 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
|
||||
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
|
||||
@@ -3129,6 +3579,7 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
|
||||
self.unjoin_icon.hide = True
|
||||
self.join_icon.hide = True
|
||||
self.extend_to_wall_icon.hide = True
|
||||
self.fillet_icon.hide = True
|
||||
return
|
||||
|
||||
# State 3: non-parallel walls whose axes meet near each wall's endpoint
|
||||
@@ -3172,6 +3623,13 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
|
||||
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
|
||||
|
||||
self.unjoin_icon.hide = True
|
||||
self.merge_icon.hide = True
|
||||
|
||||
@@ -3286,6 +3744,344 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
|
||||
self.unjoin_op_props[slot_idx].other_wall_guid = other_elem.GlobalId
|
||||
|
||||
|
||||
class GizmoWallFilletPreview(bpy.types.GizmoGroup):
|
||||
"""Gizmo group for the wall-fillet preview: radius dimension widget +
|
||||
trim-length dimension widget + validate / cancel icons.
|
||||
|
||||
On degenerate geometry the dimensions and validate hide but cancel stays
|
||||
visible so the user always has an exit. Radius and trim widgets express
|
||||
the same single DOF — both read/write the canonical `props.radius`."""
|
||||
|
||||
bl_idname = "OBJECT_GGT_bim_wall_fillet_preview"
|
||||
bl_label = "Wall Fillet Preview Gizmos"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
ICON_SCALE: ClassVar[float] = 0.375
|
||||
ICON_SPACING_X: ClassVar[float] = 0.4
|
||||
ICON_Z_OFFSET: ClassVar[float] = 1.5
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
props = _wall_fillet_props(context)
|
||||
if props is None or not props.is_active:
|
||||
return False
|
||||
if not tool.Blender.are_viewport_gizmos_enabled():
|
||||
return False
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
return False
|
||||
try:
|
||||
ifc_file.by_id(props.wall_a_id)
|
||||
ifc_file.by_id(props.wall_b_id)
|
||||
except (RuntimeError, KeyError):
|
||||
return False
|
||||
return True
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
default_color = tuple(prefs.decorations_colour[:3])
|
||||
highlight_color = tuple(prefs.decorator_color_selected[:3])
|
||||
|
||||
# Lazy-fetched closures re-resolve the Scene per call so the freed-RNA
|
||||
# crash on file open / undo doesn't hit the gizmo callbacks.
|
||||
_props_callback = preview_base.make_props_callback("wall_fillet")
|
||||
|
||||
gz = self.gizmos.new("BIM_GT_gizmo_dimension")
|
||||
gz.move_get_cb = preview_base.make_dim_getter(_props_callback, "radius")
|
||||
gz.move_set_cb = preview_base.make_dim_setter(_props_callback, "radius")
|
||||
# Set `axis` only (NOT `local_axis`) so `get_axis_direction` falls
|
||||
# through to the world-space direction we set in `_position_gizmos`.
|
||||
# The preview spans world space independent of either wall's local
|
||||
# frame, so the active-object transform that `local_axis` would go
|
||||
# through is the wrong frame.
|
||||
gz.axis = Vector((1, 0, 0))
|
||||
gz.invert_delta = False
|
||||
gz.delta_scale = 1.0
|
||||
gz.prop_name = "Radius"
|
||||
gz.gizmo_group = self
|
||||
gz.color = default_color
|
||||
gz.color_highlight = highlight_color
|
||||
gz.alpha = 1.0
|
||||
gz.use_draw_modal = True
|
||||
gz.use_draw_scale = False
|
||||
gz.text_offset_sign = 1
|
||||
gz.text_alignment = gizmo.TextAlignment.CENTER
|
||||
# Arrowheads at BOTH ends + extension lines make this read as a proper
|
||||
# dimension annotation rather than a single-direction drag arrow.
|
||||
gz.show_start_arrow = True
|
||||
gz.show_end_arrow = True
|
||||
gz.show_extension_lines = True
|
||||
gz.text_formatter = None
|
||||
self.radius_dim = gz
|
||||
|
||||
# Sweep angle is geometrically invariant during drag (depends only on
|
||||
# the angle between the two walls). Cached here per-frame from
|
||||
# `_position_gizmos` so the trim getter / setter can convert
|
||||
# trim_length ↔ radius via `tan(sweep/2)` without re-running the full
|
||||
# geometry pipeline on every drag tick.
|
||||
self._sweep_angle = math.pi / 2
|
||||
|
||||
# Trim-length widget expresses the SAME single DOF as the radius
|
||||
# widget via the leg setback distance (intersection → tangent point).
|
||||
# Architects often think "how much of each wall do I cut back" rather
|
||||
# than "what radius do I want"; this widget surfaces that mental model
|
||||
# without introducing a second degree of freedom. Both widgets stay
|
||||
# in sync because they read/write the same canonical `radius` field.
|
||||
trim_gz = self.gizmos.new("BIM_GT_gizmo_dimension")
|
||||
trim_gz.move_get_cb = self._make_trim_getter()
|
||||
trim_gz.move_set_cb = self._make_trim_setter()
|
||||
trim_gz.axis = Vector((1, 0, 0))
|
||||
trim_gz.invert_delta = False
|
||||
trim_gz.delta_scale = 1.0
|
||||
trim_gz.prop_name = "Trim Length"
|
||||
trim_gz.gizmo_group = self
|
||||
trim_gz.color = default_color
|
||||
trim_gz.color_highlight = highlight_color
|
||||
trim_gz.alpha = 1.0
|
||||
trim_gz.use_draw_modal = True
|
||||
trim_gz.use_draw_scale = False
|
||||
trim_gz.text_offset_sign = 1
|
||||
trim_gz.text_alignment = gizmo.TextAlignment.CENTER
|
||||
trim_gz.show_start_arrow = True
|
||||
trim_gz.show_end_arrow = True
|
||||
trim_gz.show_extension_lines = True
|
||||
trim_gz.text_formatter = None
|
||||
self.trim_dim = trim_gz
|
||||
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
|
||||
self.validate_icon = self.gizmos.new("VIEW3D_GT_validate")
|
||||
self.validate_icon.use_draw_scale = False
|
||||
self.validate_icon.color = BaseParametricGizmoGroup.COLOR_GREEN
|
||||
self.validate_icon.color_highlight = highlight_color
|
||||
self.validate_icon.target_set_operator("bim.finish_wall_fillet_preview")
|
||||
|
||||
self.cancel_icon = self.gizmos.new("VIEW3D_GT_cancel")
|
||||
self.cancel_icon.use_draw_scale = False
|
||||
self.cancel_icon.color = BaseParametricGizmoGroup.COLOR_RED
|
||||
self.cancel_icon.color_highlight = highlight_color
|
||||
self.cancel_icon.target_set_operator("bim.cancel_wall_fillet_preview")
|
||||
|
||||
def _make_trim_getter(self):
|
||||
"""Closure returning |radius| * tan(sweep/2) — the live leg setback
|
||||
distance — from the cached sweep angle and the canonical radius."""
|
||||
|
||||
def _get() -> float:
|
||||
props = _wall_fillet_props(bpy.context)
|
||||
if props is None:
|
||||
return 0.0
|
||||
sweep = max(self._sweep_angle, 1e-3)
|
||||
return abs(float(props.radius)) * math.tan(sweep / 2.0)
|
||||
|
||||
return _get
|
||||
|
||||
def _make_trim_setter(self):
|
||||
"""Closure writing radius from a dragged trim_length, preserving the
|
||||
radius sign so a concave preview stays concave when the user drags the
|
||||
trim widget. Clamps to the FloatProperty's lower bound so the gizmo
|
||||
can't push radius below the geometry helper's tolerance."""
|
||||
|
||||
def _set(value: float) -> None:
|
||||
props = _wall_fillet_props(bpy.context)
|
||||
if props is None:
|
||||
return
|
||||
sweep = max(self._sweep_angle, 1e-3)
|
||||
tan_half = math.tan(sweep / 2.0)
|
||||
if tan_half < 1e-9:
|
||||
return
|
||||
sign = -1.0 if float(props.radius) < 0 else 1.0
|
||||
new_radius = sign * max(0.001, float(value)) / tan_half
|
||||
props.radius = new_radius
|
||||
for area in bpy.context.screen.areas if bpy.context.screen else ():
|
||||
if area.type == "VIEW_3D":
|
||||
area.tag_redraw()
|
||||
|
||||
return _set
|
||||
|
||||
def refresh(self, context: bpy.types.Context) -> None:
|
||||
self._position_gizmos(context)
|
||||
|
||||
def draw_prepare(self, context: bpy.types.Context) -> None:
|
||||
self._position_gizmos(context)
|
||||
|
||||
def _position_gizmos(self, context: bpy.types.Context) -> None:
|
||||
wall_a_obj, wall_b_obj = _wall_fillet_preview_walls(context)
|
||||
if wall_a_obj is None or wall_b_obj is None:
|
||||
for gz in (self.radius_dim, self.trim_dim, self.validate_icon, self.cancel_icon):
|
||||
gz.hide = True
|
||||
return
|
||||
|
||||
props = _wall_fillet_props(context)
|
||||
if props is None:
|
||||
for gz in (self.radius_dim, self.trim_dim, self.validate_icon, self.cancel_icon):
|
||||
gz.hide = True
|
||||
return
|
||||
|
||||
geom = tool.Wall.compute_wall_fillet_geometry(wall_a_obj, wall_b_obj, props.radius)
|
||||
billboard_rot = gizmo.get_billboard_rotation(context)
|
||||
|
||||
# Geometry helper failed outright (e.g. wall A's reference line went
|
||||
# missing). No anchor to draw on — hide everything.
|
||||
if geom is None:
|
||||
for gz in (self.radius_dim, self.trim_dim, self.validate_icon, self.cancel_icon):
|
||||
gz.hide = True
|
||||
return
|
||||
|
||||
# Parallel / near-collinear axes — no defined arc at all. Drop radius
|
||||
# + trim + validate; keep cancel visible at the would-be intersection
|
||||
# so the user has an exit. The dim widgets have nowhere to anchor.
|
||||
if not geom["valid"] and not geom.get("invalid_radius"):
|
||||
self.radius_dim.hide = True
|
||||
self.trim_dim.hide = True
|
||||
self.validate_icon.hide = True
|
||||
anchor = None
|
||||
if geom.get("arc_center") is not None:
|
||||
anchor = Vector(geom["arc_center"])
|
||||
elif geom.get("intersection") is not None:
|
||||
anchor = Vector(geom["intersection"])
|
||||
if anchor is not None:
|
||||
# Same screen-up lift as the valid branch so the cancel icon
|
||||
# doesn't sit on top of any underlying preview lines in
|
||||
# top-down view.
|
||||
screen_up = gizmo.get_screen_up(billboard_rot)
|
||||
self.cancel_icon.matrix_basis = gizmo.billboarded_at(
|
||||
anchor + screen_up * self.ICON_Z_OFFSET, billboard_rot, scale=self.ICON_SCALE
|
||||
)
|
||||
self.cancel_icon.hide = False
|
||||
else:
|
||||
self.cancel_icon.hide = True
|
||||
return
|
||||
|
||||
# Both `valid=True` and `invalid_radius=True` populate arc_center,
|
||||
# apex, and tangent points. Keep the radius dim visible on overshoot
|
||||
# so the user can drag back to a valid radius; hide validate so a
|
||||
# commit can't surface an operator-level error.
|
||||
invalid_radius = bool(geom.get("invalid_radius"))
|
||||
self.radius_dim.hide = False
|
||||
self.trim_dim.hide = False
|
||||
self.cancel_icon.hide = False
|
||||
self.validate_icon.hide = invalid_radius
|
||||
|
||||
# Cache the sweep angle so the trim widget's getter / setter can
|
||||
# convert without re-running the geometry pipeline. Falls back to a
|
||||
# right angle if the helper somehow omits it.
|
||||
self._sweep_angle = float(geom.get("sweep_angle") or math.pi / 2)
|
||||
|
||||
arc = geom["arc"]
|
||||
arc_center = Vector(geom["arc_center"])
|
||||
tangent_a = Vector(geom["tangent_a"])
|
||||
tangent_b = Vector(geom["tangent_b"])
|
||||
intersection = Vector(geom["intersection"])
|
||||
|
||||
# Radius dimension at the arc apex with local +X pointing INWARD
|
||||
# toward the arc center. Visual line traces apex → center, matching
|
||||
# the radius itself; drag in the +X direction (toward arrow tip =
|
||||
# toward arc center) increases the radius. Anchored at the FLOOR of
|
||||
# the wall (z=0 of the arc samples) so the gizmo reads against the
|
||||
# wall geometry rather than hovering in mid-air.
|
||||
apex_index = len(arc) // 2
|
||||
apex = Vector(arc[apex_index])
|
||||
inward = arc_center - apex
|
||||
if inward.length > 1e-6:
|
||||
inward.normalize()
|
||||
self.radius_dim.matrix_basis = _wall_fillet_gizmo_x_matrix(apex, inward)
|
||||
self.radius_dim.axis = inward
|
||||
self.radius_dim.set_dimension_length(abs(props.radius))
|
||||
else:
|
||||
self.radius_dim.hide = True
|
||||
|
||||
# Trim dimension along wall A from intersection toward tangent_a;
|
||||
# same DOF as the radius widget, both update `radius`.
|
||||
along_a = tangent_a - intersection
|
||||
tangent_offset = abs(float(props.radius)) * math.tan(self._sweep_angle / 2.0)
|
||||
if along_a.length > 1e-6 and tangent_offset > 1e-6:
|
||||
along_a_dir = along_a.normalized()
|
||||
self.trim_dim.matrix_basis = _wall_fillet_gizmo_x_matrix(intersection, along_a_dir)
|
||||
self.trim_dim.axis = along_a_dir
|
||||
self.trim_dim.set_dimension_length(tangent_offset)
|
||||
else:
|
||||
self.trim_dim.hide = True
|
||||
|
||||
# Validate / cancel anchored ABOVE the arc apex along the camera's
|
||||
# screen-up direction so they're always visibly clear of the radius
|
||||
# dim widget (which runs apex → arc_center). Screen-up keeps the
|
||||
# offset perpendicular to the view plane at any angle — world +Z
|
||||
# would collapse to zero on-screen in top-down view and plant the
|
||||
# icons on top of the radius arrowhead.
|
||||
screen_up = gizmo.get_screen_up(billboard_rot)
|
||||
anchor = apex + screen_up * self.ICON_Z_OFFSET
|
||||
offset_x = billboard_rot @ Vector((self.ICON_SPACING_X, 0.0, 0.0))
|
||||
self.validate_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=self.ICON_SCALE)
|
||||
self.cancel_icon.matrix_basis = gizmo.billboarded_at(anchor + offset_x, billboard_rot, scale=self.ICON_SCALE)
|
||||
|
||||
|
||||
class GizmoWallFilletReedit(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
|
||||
"""Pen-icon re-edit gizmo for an existing fillet corner wall.
|
||||
|
||||
Mutually exclusive with an active preview and with GizmoWallEdition."""
|
||||
|
||||
bl_idname = "OBJECT_GGT_bim_wall_fillet_reedit"
|
||||
bl_label = "Wall Fillet Re-edit Gizmo"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
ICON_TOP_LIFT: ClassVar[float] = 0.15
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
if not tool.Blender.are_viewport_gizmos_enabled():
|
||||
return False
|
||||
if _wall_fillet_preview_active(context):
|
||||
return False
|
||||
active = tool.Blender.get_active_object(is_selected=True)
|
||||
if active is None:
|
||||
return False
|
||||
selected = list(tool.Blender.get_selected_objects())
|
||||
if len(selected) != 1:
|
||||
return False
|
||||
element = tool.Ifc.get_entity(active)
|
||||
if element is None or not element.is_a("IfcWall"):
|
||||
return False
|
||||
if not tool.Parametric.is_fillet_corner_wall(element):
|
||||
return False
|
||||
# Both neighbour connections must still exist for the re-edit to
|
||||
# recover the original corner.
|
||||
has_a = any(r.is_a("IfcRelConnectsPathElements") for r in getattr(element, "ConnectedFrom", []))
|
||||
has_b = any(r.is_a("IfcRelConnectsPathElements") for r in getattr(element, "ConnectedTo", []))
|
||||
return has_a and has_b
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
default_color = prefs.decorations_colour[:3]
|
||||
highlight_color = prefs.decorator_color_selected[:3]
|
||||
self.edit_icon = self.setup_icon_gizmo(
|
||||
"VIEW3D_GT_pen",
|
||||
default_color,
|
||||
highlight_color,
|
||||
"bim.enable_wall_fillet_preview_from_corner",
|
||||
)
|
||||
|
||||
def position_gizmos(self, context: bpy.types.Context) -> None:
|
||||
selected = list(tool.Blender.get_selected_objects())
|
||||
if len(selected) != 1:
|
||||
self.edit_icon.hide = True
|
||||
return
|
||||
corner_obj = selected[0]
|
||||
geom = _get_wall_geom_cached(self, corner_obj)
|
||||
if geom is None:
|
||||
self.edit_icon.hide = True
|
||||
return
|
||||
billboard_rot = gizmo.get_billboard_rotation(context)
|
||||
origin = corner_obj.matrix_world.translation
|
||||
top_z = origin.z + (geom.get("height") or 3.0) + self.ICON_TOP_LIFT
|
||||
anchor = Vector((origin.x, origin.y, top_z))
|
||||
self.edit_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot)
|
||||
self.edit_icon.hide = False
|
||||
|
||||
|
||||
class JoinWallsIntersection(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.join_walls_intersection"
|
||||
bl_label = "Join Walls at Corner"
|
||||
|
||||
Reference in New Issue
Block a user