fix(task-14): foreign-line membership before junctions, and edges measure the arc the drawing holds

Two reader defects, both about trusting a derived number over the drawing.

Stale junctions: endpoints were resolved before a foreign line's membership
was decided, so a P&ID line ending on a foreign line that was then discarded
(it touched no node) kept `{"junction": "J1"}` while `junctions` was empty
and `stats.dangling` was one short. Node attachment now runs first for every
edge, the foreign lines that attach are admitted, and only then are junctions
and dangling ends resolved over the edges actually in the graph.

Chord length: `length` was a chord walk over the vertices, 12.5% short on a
semicircular crossing jump with no field saying so, and junction detection
tested the chord too, so a line ending on the arc dangled. Both engines now
report per-vertex `bulges` next to `points` (COM asks GetBulge only when
`Length` exceeds the chord walk, since an arc is always longer than its
chord); the reader takes the engine's bulge-aware `length` and tests
endpoints against the arc. The chord sum survives only for an engine that
reports no length, and is then labelled `length_approximate`.

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 00:30:21 +03:00
co-authored by Claude Opus 5
parent e246b5e86a
commit 8fcd587b5b
4 changed files with 258 additions and 44 deletions
+28
View File
@@ -326,6 +326,28 @@ def _com_bulge(entity, index: int) -> float:
return 0.0
def _com_bulges(entity, coords, closed: bool, length) -> list[float]:
"""Every vertex's bulge, read only when ``Length`` says an arc exists.
``coords`` is the flat ``Coordinates`` variant (x0, y0, x1, y1, ...). A
circular arc is strictly longer than its chord, so when AutoCAD's own
``Length`` equals the chord walk the polyline is straight everywhere and
the per-vertex COM round trips are skipped.
"""
count = len(coords) // 2
pts = [(float(coords[i]), float(coords[i + 1])) for i in range(0, 2 * count, 2)]
chord = sum(math.dist(a, b) for a, b in zip(pts, pts[1:], strict=False))
if closed and count > 1:
chord += math.dist(pts[-1], pts[0])
try:
straight = abs(float(length) - chord) <= 1e-9 * max(1.0, chord)
except (TypeError, ValueError):
straight = False
if straight:
return [0.0] * count
return [_com_bulge(entity, i) for i in range(count)]
# What a lightweight polyline calls itself over ActiveX. The live name is
# ``AcDbPolyline`` (measured on AutoCAD 2026, 2026-08-06 — the member profile in
# tests/test_com_backend.py); ``AcDbLWPolyline`` is the DXF-flavoured spelling
@@ -392,6 +414,12 @@ def _entity_info(entity) -> EntityInfo:
props["points"] = pts
props["closed"] = bool(entity.Closed)
props["length"] = entity.Length
# Per-vertex bulges, parallel to `points` (ezdxf parity). GetBulge
# is one cross-process call per vertex, so it is only paid when
# the polyline can carry an arc: an arc is always longer than its
# chord, so a `Length` equal to the chord walk means every bulge
# is zero and no call is needed.
props["bulges"] = _com_bulges(entity, coords, bool(entity.Closed), props["length"])
elif obj_name == "AcDbText":
props["text"] = entity.TextString
ins = entity.InsertionPoint
+4
View File
@@ -813,6 +813,10 @@ def _entity_info_dxf(ent) -> EntityInfo:
_verts = [(float(x), float(y), float(b)) for x, y, b in ent.get_points(format="xyb")]
_area, _length = measure.polygon_area_perimeter(_verts, bool(ent.closed))
props["length"] = round(_length, 6)
# The bulge of the segment leaving each vertex (DXF convention), so
# a reader that walks `points` can test against the arc the drawing
# holds instead of the chord it does not. Parallel to `points`.
props["bulges"] = [b for _x, _y, b in _verts]
if ent.closed:
props["area"] = round(_area, 6)
elif ent_type == "TEXT":