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
# against live AutoCAD from here, risks breaking a path that works
# today.
normal = None
if obj_name in _COM_LWPOLYLINE_NAMES:
try:
normal = tuple(entity.Normal)
if not ocs.is_wcs_frame(normal):
elevation = float(getattr(entity, "Elevation", 0.0))
pts = [ocs.to_wcs_2d(normal, p[0], p[1], elevation) for p in pts]
else:
normal = None
except Exception as exc:
normal = None
log.debug("OCS normalisation of polyline coordinates failed: %s", exc)
props["points"] = pts
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
# 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"])
# is zero and no call is needed. GetBulge answers in the same OCS
# 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":
props["text"] = entity.TextString
ins = entity.InsertionPoint
+7 -2
View File
@@ -815,8 +815,13 @@ def _entity_info_dxf(ent) -> EntityInfo:
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]
# holds instead of the chord it does not. Parallel to `points`, and
# 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:
props["area"] = round(_area, 6)
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."""
uz = OCS(Vec3(extrusion)).uz
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