mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-16 18:44:47 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user