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:
co-authored by
Claude Opus 5
parent
8b6f355c3d
commit
d17a26103f
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user