mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
Add MEP bend preview + bend tessellation fallback
The MEP bend feature's IfcSweptDiskSolid representation produces geometrically correct output but fails to round-trip through the OpenCascade geometry kernel (upstream issue #8106) — the body is dropped on the next file load. Until upstream is fixed, MEPAddBend captures the bend centerline in world space before the segments are extended (otherwise the post-extension axes no longer reach the original intersection and arc reconstruction is wrong), then after the fitting is placed it hand-meshes the bend body and swaps the type's swept-disk representation for an IfcTessellatedFaceSet via tool.Geometry.export_mesh_to_tessellation + tool.Model. replace_object_ifc_representation. The centerline includes the straight start_length / end_length legs in addition to the arc so the bend covers the full segment-to- segment span. Sweep uses parallel-transport framing — each ring's (right, up) basis is rotated by the minimum rotation that maps the previous tangent to the current one, eliminating the twist a fixed world-axis reference produces when the tangent crosses the reference. Cross-section orientation seeds from the source segment's matrix_world local +X / +Y so asymmetric IfcRectangleProfileDef ducts land with XDim / YDim on the same axes the segment expects; parallel transport then preserves that alignment around the arc. Centerline radius is radius + profile_dim[lateral_axis] to match MEPAddBend's ref_point_radius — without this offset, the bend legs fall short of the extended segments by profile_dim * tan(angle/2). Face winding is left to the caller to correct via bmesh.ops.recalc_face_normals on the closed bend tube. Two FIXME(#8106) markers (capture site + helper call site) so both can be dropped once upstream lands a swept-disk round-trip fix. Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -1477,6 +1477,44 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator):
|
||||
)
|
||||
return {"ERROR"}
|
||||
|
||||
# FIXME(#8106): capture the bend centerline in world space BEFORE
|
||||
# the segments are extended — once DumbProfileJoiner.join_E reshapes
|
||||
# them, the axes no longer reach the original intersection and
|
||||
# compute_bend_preview_polylines would reconstruct the wrong arc.
|
||||
# The arc points are consumed at the end of _execute to tessellate
|
||||
# the fitting and bypass the IfcSweptDiskSolid round-trip bug. Drop
|
||||
# this capture once https://github.com/IfcOpenShell/IfcOpenShell/issues/8106
|
||||
# is fixed and mep_bend_shape's output is round-trip-safe.
|
||||
# ``self.start_length`` / ``self.end_length`` / ``self.radius`` are in
|
||||
# scene (SI) units and ``compute_bend_preview_polylines`` works in
|
||||
# world / scene coordinates, so no si_conversion division here.
|
||||
# The bend's swept-disk centerline isn't at the user's "inner radius"
|
||||
# — it's offset by half the profile width (matches MEPAddBend's
|
||||
# ``ref_point_radius = self.radius + profile_dim[lateral_axis]``).
|
||||
# Without that offset the bend's leg endpoints fall short of the
|
||||
# extended segment by ``profile_dim * tan(angle/2)`` and a visible
|
||||
# gap appears at each joint.
|
||||
_bend_centerline_world = compute_bend_preview_polylines(
|
||||
start_object,
|
||||
end_object,
|
||||
self.start_length,
|
||||
self.end_length,
|
||||
self.radius + profile_dim[lateral_axis],
|
||||
arc_resolution=24,
|
||||
)
|
||||
if _bend_centerline_world["valid"]:
|
||||
# The bend fitting covers the straight start_length leg, the arc,
|
||||
# and the straight end_length leg — its centerline runs from the
|
||||
# segment's new endpoint through the arc to the other segment's
|
||||
# new endpoint. ``leg_a[1]`` and ``leg_b[1]`` are those endpoints.
|
||||
_bend_centerline_arc = (
|
||||
[_bend_centerline_world["leg_a"][1]]
|
||||
+ list(_bend_centerline_world["arc"])
|
||||
+ [_bend_centerline_world["leg_b"][1]]
|
||||
)
|
||||
else:
|
||||
_bend_centerline_arc = None
|
||||
|
||||
DumbProfileJoiner().join_E(start_object, start_segment_extend_point, start_connection)
|
||||
DumbProfileJoiner().join_E(end_object, end_segment_extend_point, end_connection)
|
||||
|
||||
@@ -1599,9 +1637,113 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator):
|
||||
ifcopenshell.api.system.connect_port(ifc_file, port1=ports[0], port2=start_port, direction="NOTDEFINED")
|
||||
ifcopenshell.api.system.connect_port(ifc_file, port1=ports[1], port2=end_port, direction="NOTDEFINED")
|
||||
|
||||
# FIXME(#8106): IfcSweptDiskSolid representations from mep_bend_shape
|
||||
# are geometrically correct but fail to round-trip through the
|
||||
# OpenCascade geometry kernel — they don't load back after save.
|
||||
# Until the upstream parser / kernel fix lands at
|
||||
# https://github.com/IfcOpenShell/IfcOpenShell/issues/8106, replace
|
||||
# the swept-disk with a hand-tessellated IfcTriangulatedFaceSet
|
||||
# built by sweeping the segment's profile along the bend centerline
|
||||
# captured before segment extension. Drop this branch + the
|
||||
# _tessellate_bend_fitting helper once the upstream fix lands.
|
||||
if _bend_centerline_arc is not None:
|
||||
# Seed the sweep basis from start_object's local X / Y axes so
|
||||
# asymmetric IfcRectangleProfileDef ducts land with XDim / YDim
|
||||
# on the same axes the segment's profile actually uses
|
||||
# (parallel transport then preserves that alignment around the arc).
|
||||
start_rotation = start_object.matrix_world.to_3x3()
|
||||
initial_basis = (
|
||||
(start_rotation @ Vector((1.0, 0.0, 0.0))),
|
||||
(start_rotation @ Vector((0.0, 1.0, 0.0))),
|
||||
)
|
||||
self._tessellate_bend_fitting(
|
||||
fitting_obj, bend_type, _bend_centerline_arc, profile, si_conversion, initial_basis
|
||||
)
|
||||
|
||||
self.report({"INFO"}, f"Success!.. kind of. The angle was {round(bend_data['angle'])}")
|
||||
return {"FINISHED"}
|
||||
|
||||
@staticmethod
|
||||
def _tessellate_bend_fitting(
|
||||
fitting_obj: bpy.types.Object,
|
||||
bend_type: ifcopenshell.entity_instance,
|
||||
arc_points_world: "list[Vector]",
|
||||
profile: ifcopenshell.entity_instance,
|
||||
si_conversion: float,
|
||||
initial_basis: "tuple[Vector, Vector] | None" = None,
|
||||
) -> None:
|
||||
"""Hand-mesh the bend body and replace the bend type's representation
|
||||
with an ``IfcTessellatedFaceSet`` so the occurrence inherits the
|
||||
tessellation and the swept-disk path never reaches a saved file.
|
||||
|
||||
The mesh is computed in the occurrence's local frame
|
||||
(``inv(fitting_obj.matrix_world)``) — occurrence world geometry =
|
||||
``fitting_obj.matrix_world @ type_local_mesh``, so building in
|
||||
``inv(M) @ P`` and storing on the type lands the occurrence at the
|
||||
intended world arc points ``P``. We target the type rather than the
|
||||
occurrence because the swept-disk representation lives on the type;
|
||||
the occurrence inherits and has no own representation to update."""
|
||||
profile_2d_ifc = _bend_profile_cross_section(profile)
|
||||
if profile_2d_ifc is None:
|
||||
return
|
||||
# ``_bend_profile_cross_section`` reads ``profile.Radius`` / ``XDim`` /
|
||||
# ``YDim`` straight from the IFC entity, which are in IFC native units
|
||||
# (millimetres for an mm file). Blender mesh data lives in scene
|
||||
# (SI / metres) units, so apply the same ``* si_conversion``
|
||||
# conversion ``MEPAddBend`` uses for ``profile_dim``.
|
||||
profile_2d_scene = [(x * si_conversion, y * si_conversion) for x, y in profile_2d_ifc]
|
||||
|
||||
type_obj = tool.Ifc.get_object(bend_type)
|
||||
if type_obj is None:
|
||||
return
|
||||
|
||||
inv_matrix = fitting_obj.matrix_world.inverted()
|
||||
centerline_local = [inv_matrix @ p for p in arc_points_world]
|
||||
|
||||
# ``initial_basis`` comes from the source segment's matrix_world (world
|
||||
# directions). Express it in the fitting's local frame too so the
|
||||
# rectangle's XDim / YDim land on the segment's local +X / +Y after
|
||||
# the occurrence's matrix_world transform.
|
||||
local_basis: tuple[Vector, Vector] | None = None
|
||||
if initial_basis is not None:
|
||||
inv_3x3 = inv_matrix.to_3x3()
|
||||
local_basis = ((inv_3x3 @ initial_basis[0]), (inv_3x3 @ initial_basis[1]))
|
||||
|
||||
verts_local, faces = _sweep_profile_along_polyline(centerline_local, profile_2d_scene, local_basis)
|
||||
|
||||
# Build the mesh on a throwaway object that ``export_mesh_to_tessellation``
|
||||
# can read. The helper iterates Blender's ``split_by_loose_parts`` and
|
||||
# would delete the meshes it consumes, so we don't reuse type_obj.data
|
||||
# here (replace_object_ifc_representation below refreshes type_obj from
|
||||
# the new IFC representation).
|
||||
import bmesh
|
||||
|
||||
source_mesh = bpy.data.meshes.new("BendTessSource")
|
||||
source_mesh.from_pydata([tuple(v) for v in verts_local], [], faces)
|
||||
source_mesh.update()
|
||||
|
||||
# _sweep_profile_along_polyline leaves face winding to the caller —
|
||||
# recalc_face_normals orients them outward consistently for the closed
|
||||
# bend tube (sides + start cap + end cap).
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(source_mesh)
|
||||
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
|
||||
bm.to_mesh(source_mesh)
|
||||
bm.free()
|
||||
source_mesh.update()
|
||||
|
||||
source_obj = bpy.data.objects.new("BendTessSource", source_mesh)
|
||||
|
||||
ifc_file = tool.Ifc.get()
|
||||
body = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
|
||||
try:
|
||||
new_rep = tool.Geometry.export_mesh_to_tessellation(source_obj, body)
|
||||
tool.Model.replace_object_ifc_representation(body, type_obj, new_rep)
|
||||
finally:
|
||||
bpy.data.objects.remove(source_obj)
|
||||
if source_mesh.users == 0:
|
||||
bpy.data.meshes.remove(source_mesh)
|
||||
|
||||
|
||||
def _n_mep_selected(n: int) -> bool:
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
@@ -1916,6 +2058,95 @@ def compute_bend_preview_polylines(
|
||||
}
|
||||
|
||||
|
||||
def _bend_profile_cross_section(profile, n_circle: int = 16) -> "list[tuple[float, float]] | None":
|
||||
"""Return the segment's cross-section profile as a list of 2D points in
|
||||
the (right, up) sweep plane. Circle → ``n_circle`` evenly-spaced ring
|
||||
points; rectangle → 4 corners. Returns ``None`` for unsupported types."""
|
||||
if profile.is_a("IfcCircleProfileDef"):
|
||||
r = profile.Radius
|
||||
return [(r * cos(2 * pi * i / n_circle), r * sin(2 * pi * i / n_circle)) for i in range(n_circle)]
|
||||
if profile.is_a("IfcRectangleProfileDef"):
|
||||
hx, hy = profile.XDim / 2, profile.YDim / 2
|
||||
return [(-hx, -hy), (hx, -hy), (hx, hy), (-hx, hy)]
|
||||
return None
|
||||
|
||||
|
||||
def _sweep_profile_along_polyline(
|
||||
centerline: "list[Vector]",
|
||||
profile_2d: "list[tuple[float, float]]",
|
||||
initial_basis: "tuple[Vector, Vector] | None" = None,
|
||||
) -> "tuple[list[Vector], list[tuple[int, ...]]]":
|
||||
"""Sweep a 2D profile along a 3D centerline polyline. Returns
|
||||
``(verts, faces)``.
|
||||
|
||||
Uses parallel-transport framing: the (right, up) basis at each ring is
|
||||
obtained by rotating the previous ring's basis by the minimum rotation
|
||||
that maps the previous tangent to the current one. This avoids the
|
||||
abrupt twist a fixed world-reference basis introduces when the tangent
|
||||
crosses the reference axis. Face winding is left to the caller to
|
||||
correct via ``bmesh.ops.recalc_face_normals`` on the resulting mesh —
|
||||
cheaper than puzzling through the chirality here.
|
||||
|
||||
``initial_basis`` is the (right, up) world-direction pair at the first
|
||||
ring. Asymmetric rectangular profiles need it set from the source
|
||||
segment's matrix_world so XDim / YDim land on the right segment-local
|
||||
axes; circles + symmetric rectangles get the same shape either way."""
|
||||
verts: list[Vector] = []
|
||||
n_profile = len(profile_2d)
|
||||
n_rings = len(centerline)
|
||||
|
||||
def _tangent_at(i: int) -> Vector:
|
||||
if i == 0:
|
||||
return (centerline[1] - centerline[0]).normalized()
|
||||
if i == n_rings - 1:
|
||||
return (centerline[-1] - centerline[-2]).normalized()
|
||||
return (centerline[i + 1] - centerline[i - 1]).normalized()
|
||||
|
||||
first_tangent = _tangent_at(0)
|
||||
if initial_basis is not None:
|
||||
right, up = initial_basis
|
||||
right = right.normalized()
|
||||
up = up.normalized()
|
||||
else:
|
||||
# Fallback when the caller has no opinion: stable world-Z reference.
|
||||
up_ref = Vector((0.0, 0.0, 1.0)) if abs(first_tangent.z) < 0.95 else Vector((1.0, 0.0, 0.0))
|
||||
right = first_tangent.cross(up_ref).normalized()
|
||||
up = right.cross(first_tangent).normalized()
|
||||
prev_tangent = first_tangent
|
||||
|
||||
for i, p in enumerate(centerline):
|
||||
current_tangent = _tangent_at(i)
|
||||
if i > 0:
|
||||
axis = prev_tangent.cross(current_tangent)
|
||||
if axis.length > 1e-6:
|
||||
axis.normalize()
|
||||
angle = prev_tangent.angle(current_tangent)
|
||||
rot = Matrix.Rotation(angle, 3, axis)
|
||||
right = (rot @ right).normalized()
|
||||
up = (rot @ up).normalized()
|
||||
for s_x, s_y in profile_2d:
|
||||
verts.append(p + right * s_x + up * s_y)
|
||||
prev_tangent = current_tangent
|
||||
|
||||
faces: list[tuple[int, ...]] = []
|
||||
for ring_i in range(n_rings - 1):
|
||||
for j in range(n_profile):
|
||||
v0 = ring_i * n_profile + j
|
||||
v1 = ring_i * n_profile + ((j + 1) % n_profile)
|
||||
v2 = (ring_i + 1) * n_profile + ((j + 1) % n_profile)
|
||||
v3 = (ring_i + 1) * n_profile + j
|
||||
faces.append((v0, v1, v2, v3))
|
||||
|
||||
# End caps: fan triangulation from vertex 0 of each terminal ring.
|
||||
for j in range(1, n_profile - 1):
|
||||
faces.append((0, j + 1, j))
|
||||
last_start = (n_rings - 1) * n_profile
|
||||
for j in range(1, n_profile - 1):
|
||||
faces.append((last_start, last_start + j, last_start + j + 1))
|
||||
|
||||
return verts, faces
|
||||
|
||||
|
||||
def _bend_preview_segments(context):
|
||||
"""Resolve the two segment objects from the scene-level preview props.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user