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
+133 -14
View File
@@ -348,16 +348,113 @@ def _com_bulges(entity, coords, closed: bool, length) -> list[float]:
return [_com_bulge(entity, i) for i in range(count)]
def _is_right_angle(rotation: float) -> bool:
"""True when ``rotation`` (radians) is a multiple of a quarter turn."""
quarter = rotation / (math.pi / 2.0)
return abs(quarter - round(quarter)) <= 1e-9
def _arc_extent_points(cx, cy, radius, start, end):
"""The points that bound a counter-clockwise circular arc: its two ends
and every quadrant point (0, 90, 180, 270 degrees) the sweep passes."""
sweep = (end - start) % (2.0 * math.pi)
pts = [
(cx + radius * math.cos(start), cy + radius * math.sin(start)),
(cx + radius * math.cos(end), cy + radius * math.sin(end)),
]
for q in range(4):
angle = q * math.pi / 2.0
if (angle - start) % (2.0 * math.pi) <= sweep:
pts.append((cx + radius * math.cos(angle), cy + radius * math.sin(angle)))
return pts
def _com_member_extent_points(member, xform, sx, sy, rotation):
"""WCS points whose extents are exactly the transformed member's extents,
for the members a P&ID symbol is drawn from; None when the member's
geometry is not one this reads (text, hatch, spline, ellipse, a nested
reference) so the caller falls back to its box and says so.
A LINE, a POINT and a straight polyline are bounded by their vertices
under any affine map. A CIRCLE becomes an ellipse under an axis scale, and
its extents after a rotation are closed-form. An ARC and a bulged
polyline segment stay circular only under a uniform scale; a reflection
(``sx * sy < 0``) reverses their sense.
"""
name = member.ObjectName
uniform = abs(abs(sx) - abs(sy)) <= 1e-12
mirrored = sx * sy < 0
if name == "AcDbLine":
s, e = member.StartPoint, member.EndPoint
return [xform(s[0], s[1]), xform(e[0], e[1])]
if name == "AcDbPoint":
c = member.Coordinates
return [xform(c[0], c[1])]
if name == "AcDbCircle":
c = member.Center
r = float(member.Radius)
cx, cy = xform(c[0], c[1])
a, b = r * abs(sx), r * abs(sy)
cos_r, sin_r = math.cos(rotation), math.sin(rotation)
hx = math.hypot(a * cos_r, b * sin_r)
hy = math.hypot(a * sin_r, b * cos_r)
return [(cx - hx, cy - hy), (cx + hx, cy + hy)]
if name == "AcDbArc":
if not uniform:
return None
c = member.Center
r = float(member.Radius)
cx, cy = xform(c[0], c[1])
ends = []
for angle in (float(member.StartAngle), float(member.EndAngle)):
px, py = xform(c[0] + r * math.cos(angle), c[1] + r * math.sin(angle))
ends.append(math.atan2(py - cy, px - cx))
if mirrored:
ends.reverse()
return _arc_extent_points(cx, cy, r * abs(sx), ends[0], ends[1])
if name in _COM_LWPOLYLINE_NAMES:
coords = list(member.Coordinates)
closed = bool(member.Closed)
bulges = _com_bulges(member, coords, closed, member.Length)
pts = [xform(coords[i], coords[i + 1]) for i in range(0, len(coords) - 1, 2)]
if not any(bulges):
return pts
if not uniform:
return None
from ezdxf.math import Vec2, bulge_to_arc
segments = list(zip(pts, pts[1:], bulges, strict=False))
if closed and len(pts) > 1:
segments.append((pts[-1], pts[0], bulges[len(pts) - 1]))
out = list(pts)
for a, b, bulge in segments:
if not bulge or math.dist(a, b) < 1e-12:
continue
centre, start, end, radius = bulge_to_arc(
Vec2(a), Vec2(b), -bulge if mirrored else bulge
)
out.extend(_arc_extent_points(centre.x, centre.y, radius, start, end))
return out
return None
def _com_geometry_bbox(entity) -> dict | None:
"""WCS extents of a block reference's *drawn* geometry, attributes excluded.
``GetBoundingBox`` on an INSERT takes its ATTRIBs with it, so a TAG lettered
above a valve pushes the box past the body — a line ending on the body's
real edge then lies *inside* the box. The definition's members are read in
block space (skipping ``AcDbAttributeDefinition``), each member's box is
carried through the INSERT (origin, scale, rotation) corner by corner, and
the extents of those corners is the answer. None when there is nothing
drawn or ActiveX refuses any step — the caller then omits the key.
block space (skipping ``AcDbAttributeDefinition``) and carried through the
INSERT (origin, scale, rotation).
At a right-angle rotation (the P&ID case) each member's own box maps to
an axis-aligned box, so carrying its corners is exact. Off a right angle
the box of a rotated box is too large — a 45-degree-turned circle of
radius 5 would read as 7.07 — so each member's real geometry is measured
(`_com_member_extent_points`); a member that cannot be, or refuses, is
carried by its corners and the result is marked ``approximate: True`` so
no reader mistakes it for the extents the ezdxf engine reports. None when
there is nothing drawn or ActiveX refuses — the caller then omits the key.
"""
try:
try:
@@ -373,24 +470,46 @@ def _com_geometry_bbox(entity) -> dict | None:
sx, sy = float(entity.XScaleFactor), float(entity.YScaleFactor)
rot = float(entity.Rotation)
cos_r, sin_r = math.cos(rot), math.sin(rot)
right_angle = _is_right_angle(rot)
def xform(bx, by):
lx = (float(bx) - float(origin[0])) * sx
ly = (float(by) - float(origin[1])) * sy
return (
float(ins[0]) + lx * cos_r - ly * sin_r,
float(ins[1]) + lx * sin_r + ly * cos_r,
)
xs: list[float] = []
ys: list[float] = []
approximate = False
for i in range(blk.Count):
member = blk.Item(i)
if member.ObjectName == "AcDbAttributeDefinition":
continue
try:
lo, hi = member.GetBoundingBox()
except Exception:
continue
for bx, by in ((lo[0], lo[1]), (hi[0], lo[1]), (lo[0], hi[1]), (hi[0], hi[1])):
lx = (float(bx) - float(origin[0])) * sx
ly = (float(by) - float(origin[1])) * sy
xs.append(float(ins[0]) + lx * cos_r - ly * sin_r)
ys.append(float(ins[1]) + lx * sin_r + ly * cos_r)
pts = None
if not right_angle:
try:
pts = _com_member_extent_points(member, xform, sx, sy, rot)
except Exception as exc:
log.debug("exact extents of block member failed: %s", exc)
pts = None
if pts is None:
try:
lo, hi = member.GetBoundingBox()
except Exception:
continue
corners = ((lo[0], lo[1]), (hi[0], lo[1]), (lo[0], hi[1]), (hi[0], hi[1]))
pts = [xform(bx, by) for bx, by in corners]
approximate = approximate or not right_angle
xs.extend(x for x, _ in pts)
ys.extend(y for _, y in pts)
if not xs:
return None
return {"min": [min(xs), min(ys)], "max": [max(xs), max(ys)]}
box = {"min": [min(xs), min(ys)], "max": [max(xs), max(ys)]}
if approximate:
box["approximate"] = True
return box
except Exception as exc:
log.debug("geometry bbox failed for block reference: %s", exc)
return None