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
+43 -15
View File
@@ -263,23 +263,51 @@ def _bbox(info: EntityInfo):
edge on that side lies *inside* the box. Both engines report
``geometry_bbox`` (attributes excluded) for an INSERT that draws anything;
the attribute-inclusive box is the fallback for a reader that lacks it.
An engine may mark ``geometry_bbox`` ``approximate`` (the COM engine does
for an INSERT rotated off a right angle whose members it cannot measure
exactly): the box then encloses the drawn geometry but may overshoot it,
and a line ending on the body's true edge would sit *inside* that box
and dangle. The drawn geometry lies inside the attribute-inclusive box
too, so the tighter wall of the two is taken on every side.
"""
bb = info.properties.get("geometry_bbox") or info.properties.get("bounding_box")
if not bb:
geometry = info.properties.get("geometry_bbox")
outer = info.properties.get("bounding_box")
boxes = [
(float(bb["min"][0]), float(bb["min"][1]), float(bb["max"][0]), float(bb["max"][1]))
for bb in (geometry, outer)
if bb
]
if not boxes:
return None
return (float(bb["min"][0]), float(bb["min"][1]), float(bb["max"][0]), float(bb["max"][1]))
if geometry and outer and geometry.get("approximate"):
return (
max(boxes[0][0], boxes[1][0]),
max(boxes[0][1], boxes[1][1]),
min(boxes[0][2], boxes[1][2]),
min(boxes[0][3], boxes[1][3]),
)
return boxes[0]
def _touches_bbox(p, bbox, tolerance) -> bool:
"""Within ``tolerance`` of the box's boundary, or strictly inside it.
def _on_bbox_boundary(p, bbox, tolerance) -> bool:
"""Within ``tolerance`` of the box's boundary (spec §9.2 step 3).
A line drawn into the symbol is a connection just as one that stops on
its edge is: foreign P&IDs overshoot into the body as often as they meet
it, and a box tested on its boundary alone called those ends dangling.
The boundary, not the interior: a foreign block's box is only a proxy for
where its ports are, and its edges are the only place a port can be. An
end strictly inside was once accepted as "drawn into the body" — but a
sheet border or title block is an INSERT too, its box encloses the whole
drawing, and every end that missed a port then attached to the border
instead of becoming the junction or dangling end the tool exists to
report. Overshoot into a body is the tolerance's business.
"""
x, y = p
xmin, ymin, xmax, ymax = bbox
return xmin - tolerance <= x <= xmax + tolerance and ymin - tolerance <= y <= ymax + tolerance
inside_x = xmin - tolerance <= x <= xmax + tolerance
inside_y = ymin - tolerance <= y <= ymax + tolerance
near_vertical = min(abs(x - xmin), abs(x - xmax)) <= tolerance and inside_y
near_horizontal = min(abs(y - ymin), abs(y - ymax)) <= tolerance and inside_x
return near_vertical or near_horizontal
async def _collect(backend: AutoCADBackend, scope: str) -> list[tuple[EntityInfo, str]]:
@@ -461,9 +489,9 @@ async def build_graph(
return edge.vertices[0] if end == "from" else edge.vertices[-1]
def _attach(edge: _Edge, end: str) -> dict | None:
"""``{node, port}`` when this end sits on a port, or on or inside a
foreign block's drawn box; None otherwise. Only a successful match has
side effects."""
"""``{node, port}`` when this end sits on a port, or on the boundary of
a foreign block's drawn box; None otherwise. Only a successful match
has side effects."""
x, y = _end(edge, end)
for node_id, pname, cx, cy, r in radial_ports:
if abs(math.hypot(x - cx, y - cy) - r) <= tolerance:
@@ -477,15 +505,15 @@ async def build_graph(
_, (_kind, node_id, pname) = best
nodes[node_id].ports[pname]["edges"].append(edge.id)
return {"node": node_id, "port": pname}
# Several foreign boxes can hold the same end (a valve drawn inside a
# vessel's outline, a nested detail); the one inserted nearest wins.
# Two foreign boundaries can share the end (a valve drawn flush with
# a vessel's outline, a nested detail); the one inserted nearest wins.
node = min(
(
n
for n in list(nodes.values()) + list(candidates.values())
if n.source in ("heuristic", "inferred")
and n.bbox
and _touches_bbox((x, y), n.bbox, tolerance)
and _on_bbox_boundary((x, y), n.bbox, tolerance)
),
key=lambda n: math.hypot(x - n.x, y - n.y),
default=None,