fix(task-14): polyline bulges leave the backends in WCS, so a mirrored arc reads on its true side

The per-vertex `bulges` list was handed out in the entity's own frame next
to `points` that had been translated to WCS. A mirrored LWPOLYLINE
(extrusion / Normal -Z, what `entity_mirror` produces) is a reflection,
which reverses every arc's sense, so the reader tested endpoints against
the arc's mirror image: a line ending on the true apex dangled, a line
ending on empty paper made a junction, and `length` (frame-invariant)
said nothing. Both engines had it — ezdxf emitted `get_points("xyb")`
raw, COM returned `GetBulge` raw after reflecting `Coordinates`.

`ocs.wcs_bulge(extrusion, bulge)` now negates the sign for a left-handed
frame (the same rule `wcs_arc_angles` applies to an ARC's endpoints) and
both `_entity_info` paths route bulges through it whenever they translate
the points. Fenced by an end-to-end mirrored-pipe graph test, the COM
`_entity_info` fake with Normal -Z, and a boundary-level OCS test.

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:44:34 +03:00
co-authored by Claude Opus 5
parent 8fcd587b5b
commit 8b6f355c3d
5 changed files with 132 additions and 4 deletions
+11 -2
View File
@@ -403,13 +403,17 @@ def _entity_info(entity) -> EntityInfo:
# changing its stride on a doc reading alone, with no way to verify # changing its stride on a doc reading alone, with no way to verify
# against live AutoCAD from here, risks breaking a path that works # against live AutoCAD from here, risks breaking a path that works
# today. # today.
normal = None
if obj_name in _COM_LWPOLYLINE_NAMES: if obj_name in _COM_LWPOLYLINE_NAMES:
try: try:
normal = tuple(entity.Normal) normal = tuple(entity.Normal)
if not ocs.is_wcs_frame(normal): if not ocs.is_wcs_frame(normal):
elevation = float(getattr(entity, "Elevation", 0.0)) elevation = float(getattr(entity, "Elevation", 0.0))
pts = [ocs.to_wcs_2d(normal, p[0], p[1], elevation) for p in pts] pts = [ocs.to_wcs_2d(normal, p[0], p[1], elevation) for p in pts]
else:
normal = None
except Exception as exc: except Exception as exc:
normal = None
log.debug("OCS normalisation of polyline coordinates failed: %s", exc) log.debug("OCS normalisation of polyline coordinates failed: %s", exc)
props["points"] = pts props["points"] = pts
props["closed"] = bool(entity.Closed) props["closed"] = bool(entity.Closed)
@@ -418,8 +422,13 @@ def _entity_info(entity) -> EntityInfo:
# is one cross-process call per vertex, so it is only paid when # 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 # 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 # chord, so a `Length` equal to the chord walk means every bulge
# is zero and no call is needed. # is zero and no call is needed. GetBulge answers in the same OCS
props["bulges"] = _com_bulges(entity, coords, bool(entity.Closed), props["length"]) # as Coordinates, so a translated `points` list needs the bulges
# translated with it — a mirrored frame reverses the arc's sense.
bulges = _com_bulges(entity, coords, bool(entity.Closed), props["length"])
if normal is not None:
bulges = [ocs.wcs_bulge(normal, b) for b in bulges]
props["bulges"] = bulges
elif obj_name == "AcDbText": elif obj_name == "AcDbText":
props["text"] = entity.TextString props["text"] = entity.TextString
ins = entity.InsertionPoint ins = entity.InsertionPoint
+7 -2
View File
@@ -815,8 +815,13 @@ def _entity_info_dxf(ent) -> EntityInfo:
props["length"] = round(_length, 6) props["length"] = round(_length, 6)
# The bulge of the segment leaving each vertex (DXF convention), so # The bulge of the segment leaving each vertex (DXF convention), so
# a reader that walks `points` can test against the arc the drawing # a reader that walks `points` can test against the arc the drawing
# holds instead of the chord it does not. Parallel to `points`. # holds instead of the chord it does not. Parallel to `points`, and
props["bulges"] = [b for _x, _y, b in _verts] # in the same frame as them: a mirrored polyline's frame is a
# reflection, which reverses every arc's sense, so the stored sign
# next to translated points would describe the arc's mirror image.
props["bulges"] = [
ocs.wcs_bulge(extrusion, b) if needs_ocs else b for _x, _y, b in _verts
]
if ent.closed: if ent.closed:
props["area"] = round(_area, 6) props["area"] = round(_area, 6)
elif ent_type == "TEXT": elif ent_type == "TEXT":
+26
View File
@@ -98,3 +98,29 @@ def plane_normal(extrusion) -> list[float]:
"""The entity's plane normal, for reporting a frame that xy cannot express.""" """The entity's plane normal, for reporting a frame that xy cannot express."""
uz = OCS(Vec3(extrusion)).uz uz = OCS(Vec3(extrusion)).uz
return [float(uz.x), float(uz.y), float(uz.z)] return [float(uz.x), float(uz.y), float(uz.z)]
def wcs_bulge(extrusion, bulge) -> float:
"""An LWPOLYLINE vertex bulge as the arc reads in WCS XY.
The bulge is ``tan(sweep/4)``, signed counter-clockwise *in the entity's
own frame*. A left-handed frame (``uz.z < 0`` — what a mirror produces) is
a reflection of WCS XY, and a reflection reverses every arc's sense: the
same stored ``+1`` that bowed to -y before ``entity_mirror`` bows to +y
after it, while ``points`` translated through :func:`to_wcs_2d` land where
the vertices really are. Handing the stored sign out next to translated
points describes the arc's mirror image, and nothing in the payload can
show it (``length`` is frame-invariant). Same rule as
:func:`wcs_arc_angles` reversing an ARC's endpoints.
The magnitude is left alone: it is exact for every flat frame, and in a
tilted plane the projected arc is no longer circular at all, which
``plane_normal`` already reports.
"""
value = float(bulge or 0.0)
if value == 0.0:
return 0.0
try:
return -value if OCS(Vec3(extrusion)).uz.z < 0 else value
except Exception:
return value # an unreadable extrusion is not evidence of a reflected frame
+17
View File
@@ -118,6 +118,23 @@ async def test_mirrored_polyline_points_are_wcs(backend):
_assert_points(info.properties["points"], [[-10, 0], [-20, 0], [-20, 10]]) _assert_points(info.properties["points"], [[-10, 0], [-20, 0], [-20, 10]])
async def test_mirrored_polyline_bulges_are_wcs(backend):
"""A reflection reverses an arc's sense, so the bulge sign flips with the
points — otherwise ``points`` and ``bulges`` describe two different arcs
and, ``length`` being frame-invariant, nothing in the payload says so."""
msp = backend._doc.modelspace()
poly = msp.add_lwpolyline([(10, 0, 1), (20, 0, 0), (20, 10, 0)], format="xyb")
mirrored = await backend.entity_mirror(poly.dxf.handle, *Y_AXIS)
info = await backend.entity_get(mirrored.handle)
_assert_points(info.properties["points"], [[-10, 0], [-20, 0], [-20, 10]])
# Bulge +1 bowed to -y from (10,0) to (20,0); mirrored across x=0 it runs
# (-10,0) to (-20,0) and still bows to -y, which in that direction is CW.
assert info.properties["bulges"] == pytest.approx([-1.0, 0.0, 0.0])
assert info.properties["bounding_box"]["min"][1] == pytest.approx(-5.0, abs=1e-9)
assert info.properties["length"] == pytest.approx(5 * 3.141592653589793 + 10, abs=1e-6)
async def test_mirrored_text_insertion_is_wcs(backend): async def test_mirrored_text_insertion_is_wcs(backend):
text = await backend.entity_create_text("PART A", 40, 15, 3.0) text = await backend.entity_create_text("PART A", 40, 15, 3.0)
mirrored = await backend.entity_mirror(text.handle, *Y_AXIS) mirrored = await backend.entity_mirror(text.handle, *Y_AXIS)
+71
View File
@@ -258,6 +258,77 @@ def test_point_to_arc_distance_follows_the_bulge():
assert _point_segment_distance((5, 0), a, b, 0.0) == pytest.approx(0.0) # straight assert _point_segment_distance((5, 0), a, b, 0.0) == pytest.approx(0.0) # straight
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
out next to WCS ``points`` described the arc's mirror image: a line ending
on the true apex dangled and one ending on empty paper made a junction,
with ``length`` (frame-invariant) saying nothing about it."""
msp = backend._doc.modelspace()
pipe = msp.add_lwpolyline(
[(-20, 0, 0), (0, 0, 1), (10, 0, 0), (30, 0, 0)],
format="xyb",
dxfattribs={"layer": "PROCESS-PIPING-MAIN"},
)
# Mirror across the X axis: the arc that bowed to -y now bows to +y, which
# the engine's own bounding box confirms.
pipe = (await backend.entity_mirror(pipe.dxf.handle, 0, 0, 1, 0, delete_original=True)).handle
info = (await backend.entity_get(pipe)).properties
assert info["bounding_box"]["max"][1] == pytest.approx(5.0)
assert info["bounding_box"]["min"][1] == pytest.approx(0.0)
assert info["bulges"] == [0.0, -1.0, 0.0, 0.0]
on_arc = (
await backend.entity_create_line(5, 5, 5, 30, layer="PROCESS-PIPING-SECONDARY")
).handle
on_paper = (
await backend.entity_create_line(5, -5, 5, -30, layer="PROCESS-PIPING-SECONDARY")
).handle
graph = await build_graph(backend)
edges = {e["id"]: e for e in graph["edges"]}
assert edges[pipe]["length"] == pytest.approx(20 + 5 * math.pi + 20)
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, 5.0))
assert set(junction["edges"]) == {pipe, on_arc}
assert {(d["edge"], d["end"]) for d in graph["dangling"]} >= {(on_paper, "from")}
assert (on_arc, "from") not in {(d["edge"], d["end"]) for d in graph["dangling"]}
def test_com_polyline_bulges_follow_points_into_wcs():
"""ActiveX ``GetBulge`` answers in the same OCS as ``Coordinates``. Once
the points are reflected into WCS the arc's sense reverses with them, so
the bulge sign flips — the same polyline as the test above, as AutoCAD
would hand it over after MIRROR."""
from types import SimpleNamespace
from backends.com_backend import _entity_info
from engineering.pid.graph import _point_segment_distance
ent = SimpleNamespace(
ObjectName="AcDbPolyline",
Coordinates=(20.0, 0.0, 0.0, 0.0, -10.0, 0.0, -30.0, 0.0),
Normal=(0.0, 0.0, -1.0),
Elevation=0.0,
Handle="2F",
Layer="PROCESS-PIPING-MAIN",
Color=256,
Linetype="ByLayer",
Visible=True,
Closed=False,
Length=40 + 5 * math.pi,
GetBoundingBox=lambda: ((-20.0, 0.0, 0.0), (30.0, 5.0, 0.0)),
GetBulge=lambda i: [0.0, 1.0, 0.0, 0.0][i],
)
props = _entity_info(ent).properties
assert props["points"] == [[-20.0, 0.0], [0.0, 0.0], [10.0, 0.0], [30.0, 0.0]]
assert props["bulges"] == [0.0, -1.0, 0.0, 0.0]
a, b, bulge = props["points"][1], props["points"][2], props["bulges"][1]
assert _point_segment_distance((5.0, 5.0), a, b, bulge) == pytest.approx(0.0)
assert _point_segment_distance((5.0, -5.0), a, b, bulge) == pytest.approx(math.hypot(5, 5))
def test_com_polyline_bulges_are_read_only_when_length_says_an_arc_exists(): def test_com_polyline_bulges_are_read_only_when_length_says_an_arc_exists():
"""The COM engine pays one round trip per vertex for GetBulge, so it asks """The COM engine pays one round trip per vertex for GetBulge, so it asks
only when ActiveX ``Length`` exceeds the chord walk — an arc is always only when ActiveX ``Length`` exceeds the chord walk — an arc is always