diff --git a/backends/com_backend.py b/backends/com_backend.py index 6af6ec5..aafe0aa 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -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 diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index b9ac24f..7d75bc8 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -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": diff --git a/backends/ocs.py b/backends/ocs.py index 253741b..6d08234 100644 --- a/backends/ocs.py +++ b/backends/ocs.py @@ -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 diff --git a/tests/test_ocs_extrusion.py b/tests/test_ocs_extrusion.py index 933ad59..f5e9c60 100644 --- a/tests/test_ocs_extrusion.py +++ b/tests/test_ocs_extrusion.py @@ -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) diff --git a/tests/test_pid_graph.py b/tests/test_pid_graph.py index 5a654c0..9db00a7 100644 --- a/tests/test_pid_graph.py +++ b/tests/test_pid_graph.py @@ -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