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
+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,
+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)