fix(task-14): a border INSERT is not a port, and a turned symbol's box is its geometry

Two verified blocking findings on pid_graph.

A foreign block's inferred ports were any line end within tolerance of
its drawn box *or strictly inside it*. A sheet border or title block is
an INSERT too, and its box encloses the whole drawing, so every end that
missed a port attached to the border instead of becoming the junction
or dangling end the tool exists to report: on a border sheet the plan's
own small P&ID read back with no junction, no dangling end and its open
signal line "connected" to A3_BORDER. The test is the boundary again, as
spec 9.2 step 3 says (within tolerance of the block's bounding-box
boundary); overshoot into a body is the tolerance's business. The
nearest-insertion tie-break stays for two boundaries that share an end.

The COM engine's `geometry_bbox` carried each definition member's own
axis-aligned box through the INSERT corner by corner, which is exact at
a right-angle rotation and too large everywhere else: a 45-degree-turned
circle of radius 5 read as 7.07, a diagonal line stood upright read 7.07
wide, and the two engines disagreed on the same drawing with no marker.
Off a right angle the member's real geometry is now measured - a LINE,
POINT and straight polyline by their vertices, a CIRCLE by the
closed-form extents of the scaled-and-turned ellipse, an ARC and a
bulged polyline by their ends and the quadrant points the sweep passes
(uniform scale, reflection reversing the sense) - and cross-checked
against ezdxf's `virtual_entities` extents at 37 and 45 degrees, scaled
and mirrored. A member that cannot be measured (text, hatch, a nested
reference, an elliptical arc) is still carried by its corners and the
box then says `approximate: true`; the graph takes the tighter wall of
that box and the attribute-inclusive `bounding_box`, both of which hold
the drawn geometry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-16 10:09:04 +03:00
co-authored by Claude Opus 5
parent d17a26103f
commit ea47cbdd7e
4 changed files with 457 additions and 35 deletions
+168
View File
@@ -604,3 +604,171 @@ def test_com_geometry_bbox_is_omitted_when_the_block_draws_nothing_or_activex_re
assert _com_geometry_bbox(ent) is None
props = _entity_info(ent).properties
assert "geometry_bbox" not in props and props["rotation_deg"] == 0.0
# ── geometry_bbox off a right angle: the geometry, not the box of a box ──────
#
# Rotating a member's axis-aligned box and taking the extents of the corners
# is exact only at multiples of 90 degrees. A circle of radius 5 turned 45
# degrees is still x in [-5, 5]; its box's corners reach 7.07. The ezdxf
# engine measures the real geometry, so the live engine must too — or say
# that it could not.
def _circle(cx, cy, r):
return _Entity(
"AcDbCircle",
Center=(cx, cy, 0.0),
Radius=r,
GetBoundingBox=lambda: ((cx - r, cy - r, 0.0), (cx + r, cy + r, 0.0)),
)
def _line(x0, y0, x1, y1):
return _Entity(
"AcDbLine",
StartPoint=(x0, y0, 0.0),
EndPoint=(x1, y1, 0.0),
GetBoundingBox=lambda: ((min(x0, x1), min(y0, y1), 0.0), (max(x0, x1), max(y0, y1), 0.0)),
)
def _arc(cx, cy, r, start_deg, end_deg):
import math
return _Entity(
"AcDbArc",
Center=(cx, cy, 0.0),
Radius=r,
StartAngle=math.radians(start_deg),
EndAngle=math.radians(end_deg),
GetBoundingBox=lambda: ((cx - r, cy - r, 0.0), (cx + r, cy + r, 0.0)),
)
def _polyline(points, bulges, closed=False):
import math
coords = [c for p in points for c in p]
chord = sum(math.dist(a, b) for a, b in zip(points, points[1:], strict=False))
if closed:
chord += math.dist(points[-1], points[0])
length = chord + (1.0 if any(bulges) else 0.0) # "longer than its chord" is all that matters
xs = [p[0] for p in points]
ys = [p[1] for p in points]
return _Entity(
"AcDbPolyline",
Coordinates=coords,
Closed=closed,
Length=length,
GetBulge=lambda i: bulges[i],
GetBoundingBox=lambda: ((min(xs), min(ys), 0.0), (max(xs), max(ys), 0.0)),
)
def test_com_geometry_bbox_of_a_rotated_circle_and_diagonal_line_is_exact():
import math
from backends.com_backend import _com_geometry_bbox
ent = _blockref(
"PUMP_C",
{"PUMP_C": _Block([_circle(0.0, 0.0, 5.0)])},
insertion=(100.0, 100.0, 0.0),
rotation=math.radians(45.0),
)
box = _com_geometry_bbox(ent)
assert box["min"] == pytest.approx([95.0, 95.0]) and box["max"] == pytest.approx([105.0, 105.0])
assert "approximate" not in box
# a diagonal line turned 45 degrees stands upright: x is exactly 0
ent = _blockref(
"DIAG", {"DIAG": _Block([_line(0.0, 0.0, 10.0, 10.0)])}, rotation=math.radians(45.0)
)
box = _com_geometry_bbox(ent)
assert box["min"] == pytest.approx([0.0, 0.0], abs=1e-9)
assert box["max"] == pytest.approx([0.0, math.sqrt(200.0)], abs=1e-9)
assert "approximate" not in box
@pytest.mark.parametrize(
("rotation_deg", "sx", "sy"),
[(37.0, 1.0, 1.0), (45.0, 1.5, 1.5), (-120.0, 1.0, -1.0), (37.0, 2.0, -2.0)],
)
def test_com_geometry_bbox_off_a_right_angle_matches_the_ezdxf_engine(rotation_deg, sx, sy):
"""Same block, same INSERT, both engines: a circle, an arc, a bulged
polyline and a diagonal line, turned and scaled and mirrored."""
import math
import ezdxf
from ezdxf import bbox as _bbox
from backends.com_backend import _com_geometry_bbox
doc = ezdxf.new()
blk = doc.blocks.new("MIXED")
blk.add_circle((3.0, 0.0), 5.0)
blk.add_arc((0.0, 8.0), 4.0, 200.0, 330.0)
blk.add_lwpolyline([(-6, -6, 0.0), (0, -6, 0.8), (0, -12, 0.0)], format="xyb")
blk.add_line((-2.0, 1.0), (7.0, 9.0))
blk.add_attdef("TAG", insert=(0, 15), text="", dxfattribs={"height": 2.5})
ref = doc.modelspace().add_blockref(
"MIXED",
(50.0, 20.0),
dxfattribs={"rotation": rotation_deg, "xscale": sx, "yscale": sy},
)
expected = _bbox.extents(
e for e in ref.virtual_entities() if e.dxftype() not in ("ATTRIB", "ATTDEF")
)
members = [
_circle(3.0, 0.0, 5.0),
_arc(0.0, 8.0, 4.0, 200.0, 330.0),
_polyline([(-6.0, -6.0), (0.0, -6.0), (0.0, -12.0)], [0.0, 0.8, 0.0]),
_line(-2.0, 1.0, 7.0, 9.0),
_Entity(
"AcDbAttributeDefinition", GetBoundingBox=lambda: ((0.0, 15.0, 0.0), (8.0, 17.5, 0.0))
),
]
ent = _blockref(
"MIXED",
{"MIXED": _Block(members)},
insertion=(50.0, 20.0, 0.0),
sx=sx,
sy=sy,
rotation=math.radians(rotation_deg),
)
box = _com_geometry_bbox(ent)
assert "approximate" not in box
# ezdxf flattens the curves (default 0.01), so agreement is to that
assert box["min"] == pytest.approx([expected.extmin.x, expected.extmin.y], abs=0.02)
assert box["max"] == pytest.approx([expected.extmax.x, expected.extmax.y], abs=0.02)
def test_com_geometry_bbox_is_marked_approximate_when_a_rotated_member_cannot_be_measured():
import math
from backends.com_backend import _com_geometry_bbox, _entity_info
hatch = _Entity("AcDbHatch", GetBoundingBox=lambda: ((-5.0, -5.0, 0.0), (5.0, 5.0, 0.0)))
blocks = {"FILLED": _Block([_circle(0.0, 0.0, 5.0), hatch])}
# at a right angle the corners are exact — no marker, no geometry reads
ent = _blockref("FILLED", blocks, insertion=(100.0, 100.0, 0.0), rotation=math.pi)
box = _com_geometry_bbox(ent)
assert box == {"min": [95.0, 95.0], "max": [105.0, 105.0]}
# off one, the hatch is carried by its corners and the box says so
ent = _blockref("FILLED", blocks, insertion=(100.0, 100.0, 0.0), rotation=math.radians(45.0))
props = _entity_info(ent).properties
box = props["geometry_bbox"]
assert box["approximate"] is True
half = 5.0 * math.sqrt(2.0)
assert box["min"] == pytest.approx([100.0 - half, 100.0 - half])
assert box["max"] == pytest.approx([100.0 + half, 100.0 + half])
# a non-uniformly scaled arc is an elliptical arc: not measured, said so
ent = _blockref(
"ARC",
{"ARC": _Block([_arc(0.0, 0.0, 4.0, 0.0, 90.0)])},
sx=2.0,
sy=1.0,
rotation=math.radians(30.0),
)
assert _com_geometry_bbox(ent)["approximate"] is True
+113 -6
View File
@@ -185,22 +185,94 @@ async def test_a_foreign_vessel_takes_lines_on_every_edge_regardless_of_where_it
assert {(d["edge"], d["end"]) for d in graph["dangling"]} == {(top, "from"), (side, "from")}
async def test_a_line_drawn_into_a_foreign_body_attaches_to_the_nearest_block(backend):
"""An end strictly inside the drawn symbol is a connection, and when two
foreign boxes hold it the one inserted nearest wins."""
async def test_a_line_drawn_into_a_foreign_body_is_dangling_unless_tolerance_reaches_the_edge(
backend,
):
"""Spec §9.2 step 3: a foreign port is an end within ``tolerance`` of the
block's bounding-box *boundary*. The interior is not a port — a border
INSERT's interior is the whole sheet."""
small = backend._doc.blocks.new(name="GATE_VALVE")
small.add_lwpolyline([(-4, -2), (4, -2), (4, 2), (-4, 2)], close=True)
valve = (await backend.block_insert("GATE_VALVE", 20, 0, attributes={"TAG": "HV-2"})).handle
# body x in [16, 24]; the line overshoots 2 mm into it
line = (await backend.entity_create_line(60, 0, 22, 0, layer="PROCESS-PIPING-MAIN")).handle
graph = await build_graph(backend, tolerance=0.5)
edge = next(e for e in graph["edges"] if e["id"] == line)
assert edge["to"] is None
assert {(d["edge"], d["end"]) for d in graph["dangling"]} == {(line, "from"), (line, "to")}
assert next(n for n in graph["nodes"] if n["id"] == valve)["ports"] == {}
graph = await build_graph(backend, tolerance=2.0)
edge = next(e for e in graph["edges"] if e["id"] == line)
assert edge["to"] == {"node": valve, "port": "p1"}
assert {(d["edge"], d["end"]) for d in graph["dangling"]} == {(line, "from")}
async def test_two_foreign_boundaries_sharing_an_end_go_to_the_nearest_insertion(backend):
big = backend._doc.blocks.new(name="VESSEL_BIG")
big.add_lwpolyline([(-30, -30), (30, -30), (30, 30), (-30, 30)], close=True)
small = backend._doc.blocks.new(name="GATE_VALVE")
small.add_lwpolyline([(-4, -2), (4, -2), (4, 2), (-4, 2)], close=True)
vessel = (await backend.block_insert("VESSEL_BIG", 0, 0, attributes={"TAG": "V-2"})).handle
valve = (await backend.block_insert("GATE_VALVE", 20, 0, attributes={"TAG": "HV-2"})).handle
line = (await backend.entity_create_line(60, 0, 22, 0, layer="PROCESS-PIPING-MAIN")).handle
# the valve sits flush with the vessel's right wall: both boundaries hold (30, 0)
valve = (await backend.block_insert("GATE_VALVE", 26, 0, attributes={"TAG": "HV-2"})).handle
line = (await backend.entity_create_line(60, 0, 30, 0, layer="PROCESS-PIPING-MAIN")).handle
graph = await build_graph(backend)
edge = next(e for e in graph["edges"] if e["id"] == line)
assert edge["to"]["node"] == valve
assert edge["to"] == {"node": valve, "port": "p1"}
assert next(n for n in graph["nodes"] if n["id"] == vessel)["ports"] == {}
async def test_a_sheet_border_insert_never_swallows_junctions_or_dangling_ends(backend):
"""The border is an INSERT whose box encloses the whole sheet. Accepting
ends strictly inside a foreign box made every end that missed a port a
port of the border: no junction, no dangling end, the signal line
'connected' to A3_BORDER."""
border = backend._doc.blocks.new(name="A3_BORDER")
border.add_lwpolyline([(0, 0), (420, 0), (420, 297), (0, 297)], close=True)
sheet = (await backend.block_insert("A3_BORDER", 0, 0)).handle
ids = await _small_pid(backend)
branch = await draw_line(
backend, {"x": 130, "y": 111}, {"x": 130, "y": 60}, line_class="process_minor"
)
graph = await build_graph(backend)
assert graph["unclassified"] == [{"handle": sheet, "block_name": "A3_BORDER"}]
assert sheet not in {n["id"] for n in graph["nodes"]}
assert [(j["id"], j["x"], j["y"]) for j in graph["junctions"]] == [("J1", 130.0, 111.0)]
assert set(graph["junctions"][0]["edges"]) == {branch["handle"], ids["l1"]["handle"]}
dangling = {(d["edge"], d["end"]) for d in graph["dangling"]}
assert dangling == {(ids["sig"]["handle"], "to"), (branch["handle"], "to")}
assert graph["stats"]["dangling"] == 2 and graph["stats"]["unclassified"] == 1
edges = {e["id"]: e for e in graph["edges"]}
assert edges[ids["sig"]["handle"]]["to"] is None
assert edges[branch["handle"]]["from"] == {"junction": "J1"}
async def test_a_foreign_title_block_does_not_connect_the_valve_or_adopt_loose_lines(backend):
title = backend._doc.blocks.new(name="TITLEBLOCK_A3")
title.add_lwpolyline([(0, 0), (420, 0), (420, 297), (0, 297)], close=True)
title.add_lwpolyline([(240, 0), (240, 60), (420, 60)])
blk = backend._doc.blocks.new(name="GATE_VALVE")
blk.add_lwpolyline([(-4, -2), (-4, 2), (0, 0)], close=True)
blk.add_lwpolyline([(4, -2), (4, 2), (0, 0)], close=True)
blk.add_attdef("TAG", insert=(0, 4), text="", dxfattribs={"height": 2.5})
sheet = (await backend.block_insert("TITLEBLOCK_A3", 0, 0)).handle
valve = (await backend.block_insert("GATE_VALVE", 50, 50, attributes={"TAG": "HV-9"})).handle
left = (await backend.entity_create_line(10, 50, 46, 50, layer="PROCESS-PIPING-MAIN")).handle
right = (await backend.entity_create_line(54, 50, 120, 50, layer="PROCESS-PIPING-MAIN")).handle
loose = (await backend.entity_create_line(200, 200, 260, 200, layer="0")).handle
graph = await build_graph(backend)
edges = {e["id"]: e for e in graph["edges"]}
assert set(edges) == {left, right}
assert loose not in edges and "unknown" not in graph["stats"]["edges_by_class"]
assert edges[left]["to"] == {"node": valve, "port": "p1"}
assert edges[right]["from"] == {"node": valve, "port": "p2"}
assert edges[left]["from"] is None and edges[right]["to"] is None
assert {(d["edge"], d["end"]) for d in graph["dangling"]} == {(left, "from"), (right, "to")}
assert graph["stats"]["dangling"] == 2
assert graph["unclassified"] == [{"handle": sheet, "block_name": "TITLEBLOCK_A3"}]
assert {n["id"] for n in graph["nodes"]} == {valve}
async def test_include_foreign_false_ignores_untagged_blocks(backend):
blk = backend._doc.blocks.new(name="GATE_VALVE")
blk.add_line((0, 0), (1, 0))
@@ -499,3 +571,38 @@ def test_com_polyline_bulges_are_read_only_when_length_says_an_arc_exists():
curved, calls = fake((0.0, 0.0, 10.0, 0.0, 10.0, 10.0), 10 + 5 * math.pi, [0.0, 1.0, 0.0])
assert _entity_info(curved).properties["bulges"] == [0.0, 1.0, 0.0]
assert calls == [0, 1, 2]
def test_an_approximate_geometry_bbox_is_tightened_by_the_attribute_inclusive_box():
"""The COM engine marks a rotated INSERT's box ``approximate`` when a
member could only be carried by its corners: the box then overshoots the
body, and a line ending on the body's true edge would sit *inside* it and
dangle. The drawn geometry lies inside ``bounding_box`` too, so every
wall takes the tighter of the two; an exact box is used as it is."""
from backends.base import EntityInfo
from engineering.pid.graph import _bbox, _on_bbox_boundary
def info(**props):
return EntityInfo(
handle="1",
type="INSERT",
layer="0",
color=256,
linetype="ByLayer",
visible=True,
properties=props,
)
exact = {"min": [95.0, 95.0], "max": [105.0, 105.0]}
outer = {"min": [95.0, 95.0], "max": [105.0, 118.0]} # the TAG above the body
assert _bbox(info(geometry_bbox=exact, bounding_box=outer)) == (95.0, 95.0, 105.0, 105.0)
loose = {"min": [92.93, 92.93], "max": [107.07, 107.07], "approximate": True}
assert _bbox(info(geometry_bbox=loose, bounding_box=outer)) == (95.0, 95.0, 105.0, 107.07)
assert _bbox(info(geometry_bbox=loose)) == (92.93, 92.93, 107.07, 107.07)
assert _bbox(info(bounding_box=outer)) == (95.0, 95.0, 105.0, 118.0)
assert _bbox(info()) is None
# a line ending on the body's true left edge attaches through the tightened box
assert _on_bbox_boundary(
(95.0, 100.0), _bbox(info(geometry_bbox=loose, bounding_box=outer)), 0.5
)
assert not _on_bbox_boundary((95.0, 100.0), _bbox(info(geometry_bbox=loose)), 0.5)