fix(task-14): arcs past a semicircle and foreign ports behind tag text

Two verified blocking findings on pid_graph.

`_point_segment_distance` put the centre of an arc larger than a
semicircle (|bulge| > 1) on the wrong side of the chord: `copysign`
discarded the sign of `radius - sagitta`, which is exactly what carries
the centre across the chord past 180 degrees. A bulge-2 arc was tested
as its own reflection, so a line ending on its true apex dangled and one
ending on empty paper made a junction. The offset now keeps that sign
and only takes the bulge's direction from the bulge.

Foreign-block port inference tested line ends against the INSERT's
`bounding_box`, which on both engines takes the ATTRIB text with it. A
TAG lettered above a valve pushed the box past the body, so a line
ending on the body's real edge on that side was *inside* the box and
reported dangling. Both engines now report `geometry_bbox` for an
INSERT - the drawn geometry only, ATTRIB/ATTDEF excluded, in WCS, omitted
when the block draws nothing (ezdxf via `virtual_entities`, COM by
carrying each definition member's box through the INSERT's origin,
scale and rotation). The graph prefers it over `bounding_box`, counts
an end as touching when it is within tolerance of the box or inside it,
and when several foreign boxes hold the end picks the nearest insertion.

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 09:53:24 +03:00
co-authored by Claude Opus 5
parent 8b6f355c3d
commit d17a26103f
5 changed files with 378 additions and 32 deletions
+123
View File
@@ -481,3 +481,126 @@ async def test_a_3dsolid_is_refused_by_name_because_activex_has_no_area_for_it()
with pytest.raises(RuntimeError, match="no surface-area member"):
await backend.entity_measure("S01")
# ── geometry_bbox on a block reference: the drawn body, not its tag ─────────
#
# ``GetBoundingBox`` on an INSERT takes the ATTRIBs with it, so a TAG lettered
# above a valve pushes the box past the body and a line ending on the body's
# real edge reads as *inside* the box. The definition's members are read in
# block space, attribute definitions skipped, and carried through the INSERT.
class _Block:
"""An AcadBlock: ``Count`` members reachable by ``Item(i)``."""
def __init__(self, members, origin=(0.0, 0.0, 0.0)):
self._members = members
self.Count = len(members)
self.Origin = origin
def Item(self, i):
return self._members[i]
def _blockref(name, blocks, insertion=(0.0, 0.0, 0.0), sx=1.0, sy=1.0, rotation=0.0, **extra):
doc = SimpleNamespace(Blocks=SimpleNamespace(Item=lambda n: blocks[n]))
return _Entity(
"AcDbBlockReference",
Name=name,
Document=doc,
InsertionPoint=insertion,
XScaleFactor=sx,
YScaleFactor=sy,
ZScaleFactor=1.0,
Rotation=rotation,
Handle="B1",
Layer="0",
Color=256,
Linetype="ByLayer",
Visible=True,
**extra,
)
def _gate_valve_block():
body_left = _Entity("AcDbPolyline", GetBoundingBox=lambda: ((-4.0, -2.0, 0.0), (0.0, 2.0, 0.0)))
body_right = _Entity("AcDbPolyline", GetBoundingBox=lambda: ((0.0, -2.0, 0.0), (4.0, 2.0, 0.0)))
tag = _Entity(
"AcDbAttributeDefinition",
GetBoundingBox=lambda: ((0.0, 4.0, 0.0), (10.75, 6.5, 0.0)),
)
return _Block([body_left, body_right, tag])
def test_com_geometry_bbox_is_the_drawn_body_without_the_attribute():
from backends.com_backend import _entity_info
ent = _blockref(
"GATE_VALVE",
{"GATE_VALVE": _gate_valve_block()},
insertion=(50.0, 50.0, 0.0),
# what ActiveX answers for the INSERT itself: the TAG is in it
GetBoundingBox=lambda: ((46.0, 48.0, 0.0), (60.75, 56.5, 0.0)),
)
props = _entity_info(ent).properties
assert props["bounding_box"] == {"min": [46.0, 48.0], "max": [60.75, 56.5]}
assert props["geometry_bbox"]["min"] == pytest.approx([46.0, 48.0])
assert props["geometry_bbox"]["max"] == pytest.approx([54.0, 52.0])
assert props["block_name"] == "GATE_VALVE" and props["insertion"] == [50.0, 50.0]
def test_com_geometry_bbox_follows_the_inserts_scale_rotation_and_block_origin():
import math
from backends.com_backend import _com_geometry_bbox
# scale 2 and a quarter turn: body x∈[-4,4] y∈[-2,2] → x∈[-4,4] y∈[-8,8]
ent = _blockref(
"GATE_VALVE",
{"GATE_VALVE": _gate_valve_block()},
insertion=(10.0, 20.0, 0.0),
sx=2.0,
sy=2.0,
rotation=math.pi / 2,
)
box = _com_geometry_bbox(ent)
assert box["min"] == pytest.approx([6.0, 12.0]) and box["max"] == pytest.approx([14.0, 28.0])
# a mirrored INSERT (YScaleFactor -1) keeps the same extents for a symmetric body
ent = _blockref("GATE_VALVE", {"GATE_VALVE": _gate_valve_block()}, sy=-1.0)
box = _com_geometry_bbox(ent)
assert box["min"] == pytest.approx([-4.0, -2.0]) and box["max"] == pytest.approx([4.0, 2.0])
# the block's base point is what lands on the insertion point
blk = _Block(
[_Entity("AcDbLine", GetBoundingBox=lambda: ((10.0, 10.0, 0.0), (20.0, 10.0, 0.0)))],
origin=(10.0, 10.0, 0.0),
)
ent = _blockref("OFFSET", {"OFFSET": blk}, insertion=(100.0, 100.0, 0.0))
box = _com_geometry_bbox(ent)
assert box["min"] == pytest.approx([100.0, 100.0]) and box["max"] == pytest.approx(
[110.0, 100.0]
)
def test_com_geometry_bbox_is_omitted_when_the_block_draws_nothing_or_activex_refuses():
from backends.com_backend import _com_geometry_bbox, _entity_info
only_tag = _Block(
[
_Entity(
"AcDbAttributeDefinition", GetBoundingBox=lambda: ((0.0, 4.0, 0.0), (5.0, 6.5, 0.0))
)
]
)
assert _com_geometry_bbox(_blockref("TAG_ONLY", {"TAG_ONLY": only_tag})) is None
props = _entity_info(_blockref("TAG_ONLY", {"TAG_ONLY": only_tag})).properties
assert "geometry_bbox" not in props and props["block_name"] == "TAG_ONLY"
def _refuse(_name):
raise RuntimeError("Key not found")
ent = _blockref("MISSING", {})
ent.Document = SimpleNamespace(Blocks=SimpleNamespace(Item=_refuse))
assert _com_geometry_bbox(ent) is None
props = _entity_info(ent).properties
assert "geometry_bbox" not in props and props["rotation_deg"] == 0.0
+132
View File
@@ -138,6 +138,69 @@ async def test_foreign_blocks_classify_with_reduced_confidence_and_inferred_port
assert graph["stats"]["entities_scanned"] >= 3
async def test_a_foreign_line_ending_on_the_body_behind_the_tag_text_attaches(backend):
"""The INSERT's ``bounding_box`` takes the ATTRIB with it, so the TAG
lettered above the valve pushed the box to x~60 and a line ending on the
body's real right edge (54,50) was *inside* the box — and dangled."""
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})
valve = (await backend.block_insert("GATE_VALVE", 50, 50, attributes={"TAG": "HV-9"})).handle
props = (await backend.entity_get(valve)).properties
assert props["geometry_bbox"]["min"] == pytest.approx([46.0, 48.0])
assert props["geometry_bbox"]["max"] == pytest.approx([54.0, 52.0])
assert props["bounding_box"]["max"][0] > 54.0 # the attribute-inclusive box
line = (await backend.entity_create_line(54, 50, 90, 50, layer="PROCESS-PIPING-MAIN")).handle
graph = await build_graph(backend)
edge = next(e for e in graph["edges"] if e["id"] == line)
assert edge["from"] == {"node": valve, "port": "p1"}
node = next(n for n in graph["nodes"] if n["id"] == valve)
port = node["ports"]["p1"]
assert port["inferred"] is True and (port["x"], port["y"]) == pytest.approx((54.0, 50.0))
assert (line, "from") not in {(d["edge"], d["end"]) for d in graph["dangling"]}
async def test_a_foreign_vessel_takes_lines_on_every_edge_regardless_of_where_its_tag_sits(
backend,
):
blk = backend._doc.blocks.new(name="VESSEL_V")
blk.add_lwpolyline([(-5, -10), (5, -10), (5, 10), (-5, 10)], close=True)
blk.add_attdef("TAG", insert=(0, 13), text="", dxfattribs={"height": 2.5})
vessel = (await backend.block_insert("VESSEL_V", 100, 100, attributes={"TAG": "V-1"})).handle
props = (await backend.entity_get(vessel)).properties
assert props["geometry_bbox"]["min"] == pytest.approx([95.0, 90.0])
assert props["geometry_bbox"]["max"] == pytest.approx([105.0, 110.0])
top = (await backend.entity_create_line(100, 140, 100, 110, layer="PROCESS-PIPING-MAIN")).handle
side = (
await backend.entity_create_line(140, 100, 105, 100, layer="PROCESS-PIPING-MAIN")
).handle
graph = await build_graph(backend)
edges = {e["id"]: e for e in graph["edges"]}
assert edges[top]["to"]["node"] == vessel and edges[side]["to"]["node"] == vessel
node = next(n for n in graph["nodes"] if n["id"] == vessel)
assert node["kind"] == "equipment" and node["tag"] == "V-1"
ends = {(round(p["x"], 6), round(p["y"], 6)) for p in node["ports"].values()}
assert ends == {(100.0, 110.0), (105.0, 100.0)}
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."""
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
graph = await build_graph(backend)
edge = next(e for e in graph["edges"] if e["id"] == line)
assert edge["to"]["node"] == valve
assert next(n for n in graph["nodes"] if n["id"] == vessel)["ports"] == {}
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))
@@ -258,6 +321,75 @@ def test_point_to_arc_distance_follows_the_bulge():
assert _point_segment_distance((5, 0), a, b, 0.0) == pytest.approx(0.0) # straight
def _arc_distance_sampled(p, a, b, bulge, samples=20000):
"""Nearest distance from ``p`` to the bulge arc, measured on ezdxf's own
arc (centre, angles, radius) by dense sampling — an independent answer."""
from ezdxf.math import Vec2, bulge_to_arc
centre, start, end, radius = bulge_to_arc(Vec2(a), Vec2(b), bulge)
if end < start:
end += 2.0 * math.pi
return min(
math.dist(
p,
(
centre.x + radius * math.cos(start + (end - start) * i / samples),
centre.y + radius * math.sin(start + (end - start) * i / samples),
),
)
for i in range(samples + 1)
)
def test_point_to_arc_distance_past_a_semicircle_keeps_the_centre_on_the_arcs_side():
"""For |bulge| > 1 the centre crosses the chord (``radius - sagitta`` goes
negative). ``copysign`` threw that sign away, so a bulge-2 arc was tested
as its own reflection: the apex read as far away and empty paper as on it."""
from ezdxf.math import bulge_center
from engineering.pid.graph import _point_segment_distance
a, b = (0.0, 0.0), (10.0, 0.0)
assert tuple(bulge_center(a, b, 2.0)) == pytest.approx((5.0, -3.75))
assert tuple(bulge_center(a, b, -2.0)) == pytest.approx((5.0, 3.75))
# centre (5,-3.75), r 6.25 -> apex (5,-10); the mirror image is (5,10)
assert _point_segment_distance((5, -10), a, b, 2.0) == pytest.approx(0.0)
assert _point_segment_distance((5, 10), a, b, 2.0) == pytest.approx(
_arc_distance_sampled((5, 10), a, b, 2.0), abs=1e-6
)
assert _point_segment_distance((5, 10), a, b, 2.0) == pytest.approx(math.hypot(5, 10))
assert _point_segment_distance((5, 10), a, b, -2.0) == pytest.approx(0.0)
assert _point_segment_distance((5, -10), a, b, -2.0) == pytest.approx(
_arc_distance_sampled((5, -10), a, b, -2.0), abs=1e-6
)
assert _point_segment_distance((5, -10), a, b, -2.0) == pytest.approx(math.hypot(5, 10))
async def test_a_line_ending_on_the_apex_of_an_arc_past_a_semicircle_is_a_junction(backend):
msp = backend._doc.modelspace()
pipe = msp.add_lwpolyline(
[(-20, 0, 0), (0, 0, 2), (10, 0, 0), (30, 0, 0)],
format="xyb",
dxfattribs={"layer": "PROCESS-PIPING-MAIN"},
).dxf.handle
on_arc = (
await backend.entity_create_line(5, -10, 5, -30, layer="PROCESS-PIPING-SECONDARY")
).handle
on_paper = (
await backend.entity_create_line(5, 10, 5, 30, layer="PROCESS-PIPING-SECONDARY")
).handle
graph = await build_graph(backend)
edges = {e["id"]: e for e in graph["edges"]}
assert edges[on_arc]["from"] and "junction" in edges[on_arc]["from"]
assert edges[on_paper]["from"] is None
assert [j["id"] for j in graph["junctions"]] == [edges[on_arc]["from"]["junction"]]
junction = graph["junctions"][0]
assert (junction["x"], junction["y"]) == pytest.approx((5.0, -10.0))
assert set(junction["edges"]) == {pipe, on_arc}
dangling = {(d["edge"], d["end"]) for d in graph["dangling"]}
assert (on_paper, "from") in dangling and (on_arc, "from") not in dangling
async def test_a_mirrored_arc_is_tested_on_the_side_the_drawing_holds(backend):
"""The bulge is signed in the polyline's own frame, and ``entity_mirror``
leaves that frame a reflection (extrusion -Z). Handing the stored sign