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:
Gorgious56
2026-05-30 13:04:49 +02:00
parent 49348908e6
commit 2114c1d5d0
6 changed files with 1032 additions and 1 deletions
+6
View File
@@ -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
+797 -1
View File
@@ -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"