Bonsai: draw a bend fitting's flow arrow along its curve, not the port chord

The MEP flow-direction decoration always drew a straight line between a
2-port element's two ports. For a bend fitting that means the line cuts
diagonally across the corner instead of following the bend, which looks
broken (#6278).

A bend fitting's own two ports share the same local rotation in Bonsai's
authored geometry, so the port's own placement can't say which way it
turns. The neighbouring straight segment on each side can: its own axis
at the shared connection point is the fitting's true tangent there, by
physical continuity. tool.System.get_port_neighbour_axis looks that up,
and bend_curve_points builds a cubic bezier tangent to both axes (control
points at the tangent-lines' intersection, scaled by the standard
0.5523 arc-approximation constant), sampled into the polyline actually
drawn. Arrowheads are repositioned along the curve's arc length with
their local tangent, instead of the straight chord.

Segments, terminals, and any fitting whose tangent can't be resolved (no
neighbour, ambiguous neighbour, non-convex or near-180-degree geometry)
keep the original straight-line code path untouched, so their decoration
is unaffected.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Petru Conduraru
2026-07-21 13:29:47 +03:00
parent e52e5e2e58
commit 07b19277bf
4 changed files with 1895 additions and 27 deletions
@@ -221,6 +221,8 @@ class SystemDecorationData:
Port data includes:
- local port position in SI units
- port flow direction
- the port entity itself (e.g. so the decorator can look up its
neighbour to infer a tangent direction for curved fittings)
"""
if element not in cls.elements_ports_positions:
@@ -232,6 +234,7 @@ class SystemDecorationData:
port_data = {
"position": position,
"flow_direction": port.FlowDirection,
"port": port,
}
ports_data.append(port_data)
cls.elements_ports_positions[element] = ports_data
+257 -27
View File
@@ -51,6 +51,130 @@ _DIRECTION_FROM_FLOW_PAIR: dict[tuple[str, str], str] = {
("SOURCEANDSINK", "SOURCEANDSINK"): "SOURCEANDSINK",
}
# Cubic-bezier control-point offset, as a fraction of the distance from each
# port to the tangent lines' intersection ("corner") point. 0.5523 is the
# standard constant for approximating a 90 degree circular arc with a single
# cubic bezier; it's a reasonable single approximation for other bend angles
# too since decoration is illustrative, not a precise arc reconstruction.
BEND_CURVE_KAPPA = 0.5523
# Points sampled along the bezier to build the polyline actually drawn.
BEND_CURVE_SAMPLES = 12
# If a "curved" fit deviates from the straight chord by less than this
# (in SI units), treat the ports as collinear and fall back to a straight line.
BEND_CURVE_MIN_SAGITTA = 1e-4
def _rays_closest_point_distances(
pos_a: Vector, axis_a: Vector, pos_b: Vector, axis_b: Vector
) -> Union[tuple[float, float], None]:
"""Distances ``(s, t)`` along ``axis_a`` from ``pos_a`` and along ``axis_b``
from ``pos_b`` to the closest approach between the two rays. Used to find
where a port's tangent line would meet the other port's tangent line (the
corner a bend's two straight legs would meet at if extended).
Returns ``None`` when the axes are (near) parallel, i.e. no well-defined
corner exists - the caller should fall back to a straight line."""
w0 = pos_a - pos_b
b = axis_a.dot(axis_b)
denom = 1 - b * b
if abs(denom) < 1e-6:
return None
d = axis_a.dot(w0)
e = axis_b.dot(w0)
s = (b * e - d) / denom
t = (e - b * d) / denom
return s, t
def _sample_cubic_bezier(p0: Vector, p1: Vector, p2: Vector, p3: Vector, n: int) -> list[Vector]:
points = []
for i in range(n + 1):
t = i / n
mt = 1 - t
point = p0 * (mt**3) + p1 * (3 * mt**2 * t) + p2 * (3 * mt * t**2) + p3 * (t**3)
points.append(point)
return points
def _curve_length_table(points: list[Vector]) -> tuple[list[float], float]:
"""Cumulative arc-length at each sample point, and the total length."""
cumulative = [0.0]
for i in range(len(points) - 1):
cumulative.append(cumulative[-1] + (points[i + 1] - points[i]).length)
return cumulative, cumulative[-1]
def _point_and_tangent_at_length(
points: list[Vector], cumulative: list[float], target_length: float, fallback_tangent: Vector
) -> tuple[Vector, Vector]:
"""Position and unit tangent at arc-length ``target_length`` along the
polyline ``points`` (with precomputed cumulative lengths)."""
total = cumulative[-1]
s = max(0.0, min(total, target_length))
for i in range(len(points) - 1):
seg_start, seg_end = cumulative[i], cumulative[i + 1]
if s <= seg_end or i == len(points) - 2:
seg_length = seg_end - seg_start
local_t = 0.0 if seg_length < 1e-9 else (s - seg_start) / seg_length
position = points[i].lerp(points[i + 1], local_t)
segment = points[i + 1] - points[i]
tangent = segment.normalized() if segment.length > 1e-9 else fallback_tangent
return position, tangent
return points[-1], fallback_tangent
def bend_curve_points(
pos_a: Vector, axis_a: Union[Vector, None], pos_b: Vector, axis_b: Union[Vector, None]
) -> Union[list[Vector], None]:
"""Sampled points of a cubic bezier that leaves ``pos_a`` tangent to
``axis_a`` and arrives at ``pos_b`` tangent to ``axis_b``, approximating
a bend fitting's curved centerline instead of the straight port-to-port
chord.
``axis_a``/``axis_b`` are unit vectors pointing "into" the fitting from
each port (i.e. the direction the connected straight run was already
heading as it reaches that port - see ``System.get_port_neighbour_axis``).
Returns ``None`` - meaning "just draw the straight chord" - whenever the
axes are missing, degenerate, or the resulting curve would be
indistinguishable from a straight line (collinear ports)."""
if axis_a is None or axis_b is None:
return None
if axis_a.length < 1e-6 or axis_b.length < 1e-6:
return None
axis_a = axis_a.normalized()
axis_b = axis_b.normalized()
chord = pos_b - pos_a
chord_length = chord.length
if chord_length < 1e-9:
return None
chord_dir = chord / chord_length
corner = _rays_closest_point_distances(pos_a, axis_a, pos_b, axis_b)
if corner is None:
return None
s, t = corner
# Tangent lines meeting "behind" a port (non-convex), or so far ahead that
# the axes are nearly parallel (an extreme, near-180-degree turn), aren't
# a shape this single-bezier approximation handles well - fall back.
if s <= 1e-6 or t <= 1e-6 or s > 3 * chord_length or t > 3 * chord_length:
return None
control_a = pos_a + axis_a * (s * BEND_CURVE_KAPPA)
control_b = pos_b + axis_b * (t * BEND_CURVE_KAPPA)
points = _sample_cubic_bezier(pos_a, control_a, control_b, pos_b, BEND_CURVE_SAMPLES)
max_sagitta = 0.0
for point in points:
offset = point - pos_a
lateral = offset - chord_dir * offset.dot(chord_dir)
max_sagitta = max(max_sagitta, lateral.length)
if max_sagitta < BEND_CURVE_MIN_SAGITTA:
return None
return points
def direction_from_port_pair(port_a: ifcopenshell.entity_instance, port_b: ifcopenshell.entity_instance) -> str:
"""Derive the ``direction`` arg for ``ifcopenshell.api.system.connect_port``
@@ -188,6 +312,44 @@ class System(bonsai.core.tool.System):
rel = port.Nests[0] if port.Nests else None
return rel.RelatingObject if rel else None
@classmethod
def get_port_neighbour_axis(cls, port: ifcopenshell.entity_instance) -> Union[Vector, None]:
"""World-space unit vector giving the pipe axis direction at ``port``,
pointing from the connected neighbour's far end towards ``port`` (i.e.
the direction the neighbour's straight run was already heading as it
reaches this connection).
A bend fitting's own two ports share the same local rotation in
Bonsai's authored geometry (only their positions differ), so the
port's own placement can't tell you which way it turns. The
neighbouring segment's own two ports can: as long as that neighbour
is a simple two-port run, its own axis at the shared connection point
equals this port's true tangent, by physical continuity.
Returns ``None`` when a tangent can't be determined unambiguously:
no connection, the neighbour has other than exactly one other port,
or no Blender object backs the neighbour."""
connected_port = cls.get_connected_port(port)
if connected_port is None:
return None
neighbour = cls.get_port_relating_element(connected_port)
if neighbour is None:
return None
far_ports = [p for p in cls.get_ports(neighbour) if p.id() != connected_port.id()]
if len(far_ports) != 1:
return None
neighbour_obj = tool.Ifc.get_object(neighbour)
if neighbour_obj is None:
return None
near_pos = tool.Model.get_element_matrix(connected_port, keep_local=True).translation
far_pos = tool.Model.get_element_matrix(far_ports[0], keep_local=True).translation
near_world = neighbour_obj.matrix_world @ near_pos
far_world = neighbour_obj.matrix_world @ far_pos
direction = near_world - far_world
if direction.length < 1e-6:
return None
return direction.normalized()
@classmethod
def get_port_predefined_type(cls, mep_element: ifcopenshell.entity_instance) -> str:
split_camel_case = lambda x: re.findall("[A-Z][^A-Z]*", x)
@@ -373,6 +535,30 @@ class System(bonsai.core.tool.System):
verts = range(start_vert_i, start_vert_i + len(port_data))
edges = [(i, i + 1) for i in range(start_vert_i, start_vert_i + len(port_data) - 1)]
# A bend (or other 2-port) fitting's ports sit at the true curved
# centerline's endpoints, but a straight port-to-port edge cuts the
# corner instead of following the bend. Where each port's true
# tangent can be recovered from its neighbouring straight run,
# replace the chord with a bezier that leaves/arrives tangent to
# those axes. Segments, terminals, and fittings whose tangent
# can't be determined (or whose ports are collinear anyway) keep
# the original straight edge untouched.
curve_points = None
if len(port_data) == 2 and element.is_a("IfcFlowFitting"):
axis_a = cls.get_port_neighbour_axis(port_data[0]["port"])
axis_b = cls.get_port_neighbour_axis(port_data[1]["port"])
curve_points = bend_curve_points(verts_pos[0], axis_a, verts_pos[1], axis_b)
if curve_points is not None:
curve_interior = curve_points[1:-1]
interior_start = start_vert_i + len(verts_pos)
chain = (
[start_vert_i]
+ list(range(interior_start, interior_start + len(curve_interior)))
+ [start_vert_i + 1]
)
edges = [(chain[i], chain[i + 1]) for i in range(len(chain) - 1)]
verts_pos.extend(curve_interior)
def get_flow_direction(port_data):
# diagram - https://i.imgur.com/ioYL7bZ.png
flow_dirs = [p["flow_direction"] for p in port_data]
@@ -396,11 +582,14 @@ class System(bonsai.core.tool.System):
and selected_element
and (flow_direction := get_flow_direction(port_data)) != FlowDirection.AMBIGUOUS
):
edge_verts = verts_pos.copy()
edge_verts = verts_pos[:2]
arrow_curve_points = curve_points
both_directions = flow_direction == FlowDirection.BOTH
if not both_directions:
edge_verts = edge_verts[:: flow_direction.value]
if arrow_curve_points is not None and flow_direction.value == -1:
arrow_curve_points = list(reversed(arrow_curve_points))
# create direction lines
direction_lines_offset = 0.4
@@ -417,37 +606,78 @@ class System(bonsai.core.tool.System):
# for now it's hardcoded to local Y axis to avoid using viewport data
# for performance reasons
for j in range(2):
edge_ortho = obj.matrix_world.col[j].to_3d().normalized()
second_ortho = edge_dir.cross(edge_ortho)
edge_ortho = second_ortho.cross(edge_dir)
# direction lines should be around the edge center
n_direction_lines, start_offset = divmod(edge_length, direction_lines_offset)
n_direction_lines = int(n_direction_lines) + 1
start_offset /= 2
start_offset = edge_dir * start_offset + base_vert
if arrow_curve_points is not None:
curve_cumulative, curve_length = _curve_length_table(arrow_curve_points)
verts_before_arrows = len(verts_pos)
if both_directions:
cur_vert_index = start_vert_i + len(port_data) + j * 2 * n_direction_lines
else:
cur_vert_index = start_vert_i + len(port_data) + j * 3 * n_direction_lines
for j in range(2):
ortho_axis = obj.matrix_world.col[j].to_3d().normalized()
n_direction_lines, start_offset = divmod(curve_length, direction_lines_offset)
n_direction_lines = int(n_direction_lines) + 1
start_offset /= 2
for i in range(n_direction_lines):
cur_offset = start_offset + edge_dir * i * direction_lines_offset
if both_directions:
verts_pos.append(cur_offset + edge_ortho * direction_lines_width)
verts_pos.append(cur_offset - edge_ortho * direction_lines_width)
edges.append((cur_vert_index, cur_vert_index + 1))
cur_vert_index += 2
cur_vert_index = start_vert_i + verts_before_arrows + j * 2 * n_direction_lines
else:
arrow_base = cur_offset - edge_dir * direction_lines_width
verts_pos.append(arrow_base + edge_ortho * direction_lines_width)
verts_pos.append(cur_offset)
verts_pos.append(arrow_base - edge_ortho * direction_lines_width)
edges.append((cur_vert_index, cur_vert_index + 1))
edges.append((cur_vert_index + 1, cur_vert_index + 2))
cur_vert_index += 3
cur_vert_index = start_vert_i + verts_before_arrows + j * 3 * n_direction_lines
for i in range(n_direction_lines):
arc_length = start_offset + i * direction_lines_offset
cur_offset, local_dir = _point_and_tangent_at_length(
arrow_curve_points, curve_cumulative, arc_length, edge_dir
)
second_ortho = local_dir.cross(ortho_axis)
edge_ortho = second_ortho.cross(local_dir)
if edge_ortho.length < 1e-9:
edge_ortho = ortho_axis
if both_directions:
verts_pos.append(cur_offset + edge_ortho * direction_lines_width)
verts_pos.append(cur_offset - edge_ortho * direction_lines_width)
edges.append((cur_vert_index, cur_vert_index + 1))
cur_vert_index += 2
else:
arrow_base = cur_offset - local_dir * direction_lines_width
verts_pos.append(arrow_base + edge_ortho * direction_lines_width)
verts_pos.append(cur_offset)
verts_pos.append(arrow_base - edge_ortho * direction_lines_width)
edges.append((cur_vert_index, cur_vert_index + 1))
edges.append((cur_vert_index + 1, cur_vert_index + 2))
cur_vert_index += 3
else:
for j in range(2):
edge_ortho = obj.matrix_world.col[j].to_3d().normalized()
second_ortho = edge_dir.cross(edge_ortho)
edge_ortho = second_ortho.cross(edge_dir)
# direction lines should be around the edge center
n_direction_lines, start_offset = divmod(edge_length, direction_lines_offset)
n_direction_lines = int(n_direction_lines) + 1
start_offset /= 2
start_offset = edge_dir * start_offset + base_vert
if both_directions:
cur_vert_index = start_vert_i + len(port_data) + j * 2 * n_direction_lines
else:
cur_vert_index = start_vert_i + len(port_data) + j * 3 * n_direction_lines
for i in range(n_direction_lines):
cur_offset = start_offset + edge_dir * i * direction_lines_offset
if both_directions:
verts_pos.append(cur_offset + edge_ortho * direction_lines_width)
verts_pos.append(cur_offset - edge_ortho * direction_lines_width)
edges.append((cur_vert_index, cur_vert_index + 1))
cur_vert_index += 2
else:
arrow_base = cur_offset - edge_dir * direction_lines_width
verts_pos.append(arrow_base + edge_ortho * direction_lines_width)
verts_pos.append(cur_offset)
verts_pos.append(arrow_base - edge_ortho * direction_lines_width)
edges.append((cur_vert_index, cur_vert_index + 1))
edges.append((cur_vert_index + 1, cur_vert_index + 2))
cur_vert_index += 3
all_vertices.extend(verts_pos)
File diff suppressed because one or more lines are too long
+186
View File
@@ -16,11 +16,13 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import math
from math import pi
import bpy
import ifcopenshell
import ifcopenshell.api
import ifcopenshell.api.attribute
import ifcopenshell.api.root
import ifcopenshell.api.system
import ifcopenshell.util.representation
@@ -32,6 +34,7 @@ from mathutils import Euler, Matrix, Vector
import bonsai.core.tool
import bonsai.tool as tool
from bonsai.tool.system import System as subject
from bonsai.tool.system import bend_curve_points
from test.bim.bootstrap import NewFile
@@ -457,3 +460,186 @@ class TestFlowElementAndControls(NewFile):
controls = subject.get_flow_element_controls(flow_element)
assert set(controls) == set((flow_control, flow_control1))
assert subject.get_flow_control_flow_element(flow_control) == flow_element
class TestBendCurvePoints:
"""https://github.com/IfcOpenShell/IfcOpenShell/issues/6278 - the
flow-direction decoration used to draw a straight port-to-port line
through a bend fitting, cutting across the corner instead of following
it. ``bend_curve_points`` is the pure geometry behind the fix: a cubic
bezier tangent to each port's axis, sampled into a polyline."""
# A real 90 degree duct bend's two ports (radius 0.375), reproduced from
# a live bim.mep_add_bend splice - see TestBuildDecorationDataBendCurve.
PORT_A = Vector((2.7, 2.0, 0.0))
AXIS_A = Vector((1.0, 0.0, 0.0))
PORT_B = Vector((3.075, 1.625, 0.0))
AXIS_B = Vector((0.0, 1.0, 0.0))
def test_90_degree_bend_hugs_the_true_arc(self):
points = bend_curve_points(self.PORT_A, self.AXIS_A, self.PORT_B, self.AXIS_B)
assert points is not None
assert points[0] == self.PORT_A
assert points[-1] == self.PORT_B
# The true arc (perpendicular tangents at A and B) has its center
# where the two tangent lines cross, here (2.7, 1.625, 0), radius
# 0.375. The curve's own midpoint should land on it almost exactly,
# unlike the chord's midpoint, which cuts across the corner.
center = Vector((2.7, 1.625, 0.0))
radius = 0.375
true_arc_midpoint = center + ((self.PORT_A - center) + (self.PORT_B - center)).normalized() * radius
curve_midpoint = points[len(points) // 2]
chord_midpoint = (self.PORT_A + self.PORT_B) / 2
curve_error = (curve_midpoint - true_arc_midpoint).length
chord_error = (chord_midpoint - true_arc_midpoint).length
assert curve_error < 1e-4
assert chord_error > 0.1
assert curve_error < chord_error / 100
def test_degenerate_axes_fall_back_to_straight_line(self):
# Collinear ports (a straight fitting): both axes already match the chord.
assert bend_curve_points(Vector((0, 0, 0)), Vector((1, 0, 0)), Vector((2, 0, 0)), Vector((-1, 0, 0))) is None
# Missing axis (e.g. neighbour couldn't be resolved).
assert bend_curve_points(Vector((0, 0, 0)), None, Vector((2, 0, 0)), Vector((1, 0, 0))) is None
# Zero-length axis.
assert bend_curve_points(Vector((0, 0, 0)), Vector((0, 0, 0)), Vector((2, 0, 0)), Vector((1, 0, 0))) is None
# Non-convex/divergent tangents (no sensible corner ahead of either port).
assert bend_curve_points(Vector((0, 0, 0)), Vector((-1, 0, 0)), Vector((2, 0, 0)), Vector((1, 0, 0))) is None
# Coincident ports.
assert bend_curve_points(Vector((1, 1, 1)), Vector((1, 0, 0)), Vector((1, 1, 1)), Vector((0, 1, 0))) is None
def test_shallow_and_sharp_angles_stay_close_to_the_true_arc(self):
center = Vector((0.0, 1.0, 0.0))
radius = 1.0
port_a = center + Vector((0, -1, 0)) * radius
axis_a = Vector((1, 0, 0))
for degrees in (5, 10, 30, 45, 90):
theta = math.radians(degrees)
port_b = center + Vector((math.sin(theta), -math.cos(theta), 0)) * radius
axis_b = -Vector((math.cos(theta), math.sin(theta), 0))
points = bend_curve_points(port_a, axis_a, port_b, axis_b)
assert points is not None, f"expected a curve at {degrees} degrees"
true_mid = center + ((port_a - center) + (port_b - center)).normalized() * radius
curve_mid = points[len(points) // 2]
assert (curve_mid - true_mid).length < 0.011, f"curve strayed too far from the arc at {degrees} degrees"
def test_extreme_near_reversal_falls_back_to_straight_line(self):
# ~170 degrees of turn: tangent lines meet so far away that a single
# cubic bezier can't approximate it sensibly - must not crash or
# produce a wild result, just fall back.
center = Vector((0.0, 1.0, 0.0))
radius = 1.0
port_a = center + Vector((0, -1, 0)) * radius
axis_a = Vector((1, 0, 0))
theta = math.radians(170)
port_b = center + Vector((math.sin(theta), -math.cos(theta), 0)) * radius
axis_b = -Vector((math.cos(theta), math.sin(theta), 0))
assert bend_curve_points(port_a, axis_a, port_b, axis_b) is None
class TestBuildDecorationDataBendCurve(NewFile):
"""End-to-end: splice a real bend into two straight ducts via the actual
bim.mep_add_bend operator, then check tool.System's decoration builder
draws a curved polyline through the bend (not the straight chord) while
an untouched straight segment's own decoration is completely unaffected."""
FIXTURE = "test/files/mep-duct-bend-flow-direction.ifc"
SEGMENT_UPSTREAM_ID = 4276
SEGMENT_DOWNSTREAM_ID = 4298
UPSTREAM_PORT_ID = 4350
STRAIGHT_SEGMENT_ID = 4252
def _build_bend(self):
result = bpy.ops.bim.load_project(filepath=self.FIXTURE)
assert result == {"FINISHED"}
ifc = tool.Ifc.get()
upstream_obj = tool.Ifc.get_object(ifc.by_id(self.SEGMENT_UPSTREAM_ID))
downstream_obj = tool.Ifc.get_object(ifc.by_id(self.SEGMENT_DOWNSTREAM_ID))
bpy.context.view_layer.objects.active = upstream_obj
upstream_obj.select_set(True)
downstream_obj.select_set(True)
result = bpy.ops.bim.mep_add_bend(
start_segment_id=ifc.by_id(self.SEGMENT_UPSTREAM_ID).id(),
end_segment_id=ifc.by_id(self.SEGMENT_DOWNSTREAM_ID).id(),
)
assert result == {"FINISHED"}
fitting_port = subject.get_connected_port(ifc.by_id(self.UPSTREAM_PORT_ID))
fitting = subject.get_port_relating_element(fitting_port)
assert fitting.is_a("IfcDuctFitting")
# Give the fitting's own two ports a resolvable SOURCE/SINK pair so
# the decorator actually draws an arrow through it (this repo's
# mep_add_bend doesn't establish that on its own - see #6278/#8733,
# a separate, already-fixed issue about the flow direction itself).
for port in subject.get_ports(fitting):
ifcopenshell.api.attribute.edit_attributes(
ifc, product=port, attributes={"FlowDirection": "SINK" if port == fitting_port else "SOURCE"}
)
return ifc, fitting
def _decoration_for(self, ifc, element):
from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData
obj = tool.Ifc.get_object(element)
bpy.ops.object.select_all(action="DESELECT")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.context.view_layer.update()
ObjectSystemData.is_loaded = False
ObjectSystemData.load()
SystemDecorationData.is_loaded = False
SystemDecorationData.load()
SystemDecorationData.data["decorated_elements"] = {element}
subject._decoration_data_cache_key = None
subject._decoration_data_cache = None
return subject._build_decoration_data()
def test_bend_fitting_draws_a_curve_not_a_chord(self):
ifc, fitting = self._build_bend()
data = self._decoration_for(ifc, fitting)
# Straight chord = 2 vertices/1 edge for the base line; a curve
# injects sampled interior points, so there must be more than that.
assert len(data["all_vertices"]) > 14
port_a, port_b = data["all_vertices"][0], data["all_vertices"][1]
# The curve's own sampled points must bulge away from the chord.
max_deviation = 0.0
chord_dir = (port_b - port_a).normalized()
for vertex in data["all_vertices"][2:]:
offset = vertex - port_a
lateral = offset - chord_dir * offset.dot(chord_dir)
max_deviation = max(max_deviation, lateral.length)
assert max_deviation > 0.05, "expected the curve to visibly bulge away from the straight chord"
def test_straight_segment_decoration_is_unaffected(self):
ifc, _fitting = self._build_bend()
segment = ifc.by_id(self.STRAIGHT_SEGMENT_ID)
ports = subject.get_ports(segment)
for i, port in enumerate(ports):
ifcopenshell.api.attribute.edit_attributes(
ifc, product=port, attributes={"FlowDirection": "SOURCE" if i == 0 else "SINK"}
)
data = self._decoration_for(ifc, segment)
# A plain 2-port straight run: exactly the port-to-port chord edge,
# no injected curve vertices - the fitting-only code path must never
# touch a segment's own decoration.
assert data["selected_edges"][0] == (0, 1)
port_a, port_b = data["all_vertices"][0], data["all_vertices"][1]
chord_dir = (port_b - port_a).normalized()
direction_lines_width = 0.05
for vertex in data["all_vertices"][2:]:
# Every arrow vertex is either exactly on the port-to-port chord
# (the arrow tip) or offset from it by exactly the arrowhead's
# perpendicular wingspan (the two wing tips) - i.e. still the
# original straight-line arrow shape, never a curve sample.
offset = vertex - port_a
lateral = offset - chord_dir * offset.dot(chord_dir)
assert lateral.length < 1e-4 or abs(lateral.length - direction_lines_width) < 1e-4