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:
co-authored by
Claude Opus 5
parent
8fcd587b5b
commit
8b6f355c3d
+11
-2
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -118,6 +118,23 @@ async def test_mirrored_polyline_points_are_wcs(backend):
|
||||
_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):
|
||||
text = await backend.entity_create_text("PART A", 40, 15, 3.0)
|
||||
mirrored = await backend.entity_mirror(text.handle, *Y_AXIS)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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():
|
||||
"""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
|
||||
|
||||
Reference in New Issue
Block a user