diff --git a/engineering/mech/marks.py b/engineering/mech/marks.py index a4eff9a..8179995 100644 --- a/engineering/mech/marks.py +++ b/engineering/mech/marks.py @@ -18,9 +18,23 @@ principles of presentation - Basic conventions for cuts and sections": a long-dash-dotted line that is *wide* at its ends and at every change of direction, an arrow at each end pointing in the direction of viewing, and the same capital letter at both ends. Line width is a layer property here, so the -wide ends are emitted on the `visible` role (GEOMETRY, 0.50 mm) and the thin -middle on the `center` role (CENTER, 0.25 mm) - the ISO appearance, expressed -in the role vocabulary this repository already has. +wide ends are emitted on the `visible` role and the thin middle on the +`center` role - the wide/narrow contrast, expressed in the role vocabulary +this repository already has. + +The two widths are not numbers this module picks: they are whatever +`engineering.layers.ENGINEERING_LAYERS` gives GEOMETRY and CENTER. MEASURED +on a drawing `drawing_new` bootstrapped: + + GEOMETRY is 0.50 mm, CENTER is 0.18 mm, a ratio of 2.78:1. + +That measured pair is what is stated here - not the standard's own +wide:narrow ratio, which this module does not transcribe. +A ratio quoted from memory beside a measured one is exactly the invented +number the SOURCE blocks above refuse to ship, and an earlier revision of +this block did ship one (it said CENTER was 0.25 mm and the pair 2:1; both +were wrong). `tests/test_mech_marks.py` now pins these three figures to +`ENGINEERING_LAYERS`, so the sentence cannot drift from the layer set again. DECLARED, not transcribed: ISO 128-40 says the ends are thickened and that arrows are placed there; it does not dimension them. The end-segment length, @@ -44,6 +58,7 @@ if TYPE_CHECKING: # pragma: no cover - typing only __all__ = [ "CENTRE_STYLES", + "HATCH_FLATTEN_SAGITTA", "SECTION_STYLES", "centre_mark_prims", "draw_centre_marks", @@ -65,6 +80,17 @@ ARROW_SHAFT_FACTOR = 2.0 # arrow shaft length, perpendicular to the plane ARROW_HEAD_FACTOR = 0.8 # arrowhead length (half-width is 0.35 of it, as elsewhere) LABEL_OFFSET_FACTOR = 0.7 # gap between the arrow tail and the label +# DECLARED, not transcribed: the largest gap (drawing units) left between a +# curved boundary edge and the chords that stand in for it. +# `entity_create_hatch` takes a vertex list on both engines, so an arc edge +# has to reach it as vertices; what must not happen - and did - is dropping +# the curve and hatching the chord. MEASURED on the r=20 semicircular cut +# face in `tests/test_mech_marks.py`: 0.01 spaces that arc into 50 chords and +# hatches 1027.905195 of its real 1028.318531, an error of 0.413 (0.040%), +# against the 628.3 (61%) an earlier revision silently lost. The payload says +# `accuracy: "flatten_tolerance"` rather than claiming the area is exact. +HATCH_FLATTEN_SAGITTA = 0.01 + def centre_mark_prims( center: tuple[float, float], @@ -335,6 +361,89 @@ async def draw_section_line( return result +def _polygon_area(points: list[tuple[float, float]]) -> float: + """The shoelace area of a closed straight-edged polygon. + + Legitimate here and nowhere else in this module: it is applied to the + vertex list actually written into the HATCH, every edge of which really is + a straight line, so it measures what was drawn rather than approximating + what was meant. The curved input is handled by `_flatten_bulge` *before* + this sees it, and the residual is reported, never swallowed. + """ + total = 0.0 + count = len(points) + for index in range(count): + x1, y1 = points[index] + x2, y2 = points[(index + 1) % count] + total += x1 * y2 - x2 * y1 + return abs(total) / 2.0 + + +def _flatten_bulge( + p1: tuple[float, float], + p2: tuple[float, float], + bulge: float, + tolerance: float = HATCH_FLATTEN_SAGITTA, +) -> list[tuple[float, float]]: + """The interior vertices standing in for one bulged polyline segment. + + DXF stores a circular arc between two LWPOLYLINE vertices as a *bulge*: + tan(theta/4) of the arc's included angle, positive counter-clockwise. The + points returned lie strictly between `p1` and `p2`, spaced so no chord + departs from the arc by more than `tolerance`; an empty list means the + segment is already straight. + """ + b = float(bulge) + if not math.isfinite(b) or abs(b) <= 1e-12: + return [] + x1, y1 = float(p1[0]), float(p1[1]) + x2, y2 = float(p2[0]), float(p2[1]) + chord = math.hypot(x2 - x1, y2 - y1) + if chord <= 1e-12: + return [] + theta = 4.0 * math.atan(b) + half = theta / 2.0 + radius = (chord / 2.0) / abs(math.sin(half)) + # Centre: off the chord's midpoint along its left normal, signed by the + # sweep, so a negative bulge puts it on the other side without a branch. + ux, uy = (x2 - x1) / chord, (y2 - y1) / chord + offset = -(chord / 2.0) / math.tan(half) + cx = (x1 + x2) / 2.0 + (-uy) * offset + cy = (y1 + y2) / 2.0 + ux * offset + tol = min(float(tolerance), radius) + step = 2.0 * math.acos(max(-1.0, min(1.0, 1.0 - tol / radius))) + count = max(2, math.ceil(abs(theta) / step)) if step > 1e-12 else 2 + start = math.atan2(y1 - cy, x1 - cx) + return [ + ( + cx + radius * math.cos(start + theta * index / count), + cy + radius * math.sin(start + theta * index / count), + ) + for index in range(1, count) + ] + + +async def _discard_traced_loop(backend: AutoCADBackend, handle: str | None) -> bool: + """Delete the outline `boundary_from_entities` drew, reporting whether it went. + + `boundary_from_entities` does not merely compute a loop, it *writes* one: + an LWPOLYLINE on the current layer. `hatch_material` asked for a hatch, not + an outline, so leaving it behind drops a stray polyline on layer 0 that the + caller cannot find - and that `drawing_critique` / `drawing_finalize` then + score against the drawing under the layer-discipline rule. The boolean is + put in the result rather than assumed, so a delete that failed is visible. + """ + if not handle: + return False + try: + result = await backend.entity_delete(handle) + except Exception: + return False + if isinstance(result, dict): + return bool(result.get("ok", True)) + return True + + async def draw_material_hatch( backend: AutoCADBackend, *, @@ -350,6 +459,24 @@ async def draw_material_hatch( `hatch_for` owns the map and its refusal, so an unknown material name is rejected here before anything is drawn. `scale` multiplies the material's own pattern scale; `angle` replaces the material's own angle when given. + + With `handles` the loop is chained by `boundary_from_entities`, and both of + that call's side effects are dealt with here rather than left to the + caller. Its traced LWPOLYLINE carries `points` **and** a parallel `bulges` + list; reading only `points` hatches the chord of every arc edge, which on a + semicircular cut face leaves 61% of the face unhatched while reporting + success. Each bulged segment is therefore expanded into chords no further + than `HATCH_FLATTEN_SAGITTA` from the real arc, and the payload states its + own accuracy the way `analysis_measure_entity` does: `accuracy` is + `"exact"` or `"flatten_tolerance"`, `area` is the polygon actually written + and `boundary_area` the exact area of the chained loop, so the two can be + compared instead of trusted. The traced outline itself is deleted - it was + scaffolding, and an orphan polyline on layer 0 is scored against the + drawing - and `traced` reports its handle and whether it went. + + Refused: a loop that encloses no area, and a traced loop that flattens to + fewer than three vertices. Both used to write a HATCH of area 0.0 and + return `ok`. """ from engineering.mech.annotate import ensure_annotation_layers from engineering.mech.primitives import ROLE_LAYER as _ROLE_LAYER @@ -365,16 +492,58 @@ async def draw_material_hatch( if boundary is not None and handles: raise ValueError("hatch_material takes boundary or handles, not both.") + traced_report: dict | None = None + boundary_area: float | None = None + curved_edges = 0 if boundary is not None: - points = [[float(p[0]), float(p[1])] for p in boundary] + points = [(float(p[0]), float(p[1])) for p in boundary] if len(points) < 3: raise ValueError(f"a hatch boundary needs at least three points; got {len(points)}.") else: loop = await backend.boundary_from_entities(list(handles or [])) if not loop.get("ok"): raise ValueError(f"hatch_material could not close a loop: {loop.get('error')}") - traced = await backend.entity_get(loop["handle"]) - points = [[float(p[0]), float(p[1])] for p in traced.properties["points"]] + traced_handle = loop.get("handle") + if loop.get("area") is not None: + boundary_area = float(loop["area"]) + try: + traced = await backend.entity_get(traced_handle) + vertices = [(float(p[0]), float(p[1])) for p in traced.properties["points"]] + bulges = [float(b) for b in (traced.properties.get("bulges") or ())] + finally: + # In `finally:` so a drawing is never left with the scaffolding of + # a call that raised while reading it. + traced_report = { + "handle": traced_handle, + "removed": await _discard_traced_loop(backend, traced_handle), + } + points = [] + for index, vertex in enumerate(vertices): + points.append(vertex) + bulge = bulges[index] if index < len(bulges) else 0.0 + arc = _flatten_bulge(vertex, vertices[(index + 1) % len(vertices)], bulge) + if arc: + curved_edges += 1 + points.extend(arc) + if len(points) < 3: + raise ValueError( + f"the chained loop flattens to {len(points)} vertices, which encloses nothing; " + "a hatch boundary needs at least three." + ) + + area = _polygon_area(points) + if area <= 1e-9: + raise ValueError( + f"the hatch boundary encloses no area (measured {area:g}); a zero-area HATCH " + "fills nothing while reporting success." + ) + # The traced loop knows its own exact area, arcs and all. If the polygon + # written here does not match it, something was straightened - say so by + # name instead of reporting an area that is not the cut face's. + if boundary_area is None or abs(area - boundary_area) <= max(1e-9, 1e-9 * abs(boundary_area)): + accuracy, flatten_tolerance = "exact", None + else: + accuracy, flatten_tolerance = "flatten_tolerance", HATCH_FLATTEN_SAGITTA pattern_scale = float(spec["scale"]) * factor pattern_angle = float(spec["angle"]) if angle is None else float(angle) @@ -389,7 +558,7 @@ async def draw_material_hatch( created = await ensure_annotation_layers(backend, [target]) info = await backend.entity_create_hatch( spec["pattern"], - points, + [[x, y] for x, y in points], pattern_scale, pattern_angle, target, @@ -402,6 +571,12 @@ async def draw_material_hatch( "scale": pattern_scale, "angle": pattern_angle, "points": len(points), + "area": round(area, 6), + "boundary_area": boundary_area, + "accuracy": accuracy, + "flatten_tolerance": flatten_tolerance, + "curved_edges": curved_edges, "layer": target, "layers_created": list(created), + "traced": traced_report, } diff --git a/server.py b/server.py index a9900df..84bfa68 100644 --- a/server.py +++ b/server.py @@ -8445,10 +8445,20 @@ async def hatch_material( is drawn), neither `boundary` nor `handles` (or both), a boundary with fewer than three points, and a non-positive `scale`. With `handles` the loop is chained by `boundary_from_entities`, which the COM engine refuses - (capability `boundary_trace`) — pass `boundary` on a live seat, or run + (capability `boundary_trace`) - pass `boundary` on a live seat, or run headlessly. The HATCH layer is created first when the drawing lacks it: ActiveX refuses an entity on an absent layer and would otherwise leave the hatch orphaned on layer 0. + + The payload states its own accuracy, as `analysis_measure_entity` does: + `area` is the polygon actually hatched, `boundary_area` the exact area of + the chained loop, and `accuracy` is `"exact"` or `"flatten_tolerance"` - + an arc edge reaches `entity_create_hatch` as chords, so a curved cut face + is hatched to within `HATCH_FLATTEN_SAGITTA` of itself rather than being + replaced by its chord polygon. With `handles`, the outline + `boundary_from_entities` draws is scaffolding and is deleted; `traced` + reports its handle and whether the delete succeeded. A loop enclosing no + area is refused instead of writing a HATCH of area 0.0. """ from engineering.mech.marks import draw_material_hatch diff --git a/tests/test_mech_marks.py b/tests/test_mech_marks.py index 63625cc..3c5c01a 100644 --- a/tests/test_mech_marks.py +++ b/tests/test_mech_marks.py @@ -6,6 +6,8 @@ real ezdxf document through the `backend` fixture. from __future__ import annotations +import math + import pytest from engineering.mech.marks import ( @@ -334,3 +336,160 @@ async def test_a_drawing_that_already_has_the_hatch_layer_is_not_rewritten(): ) assert result["layers_created"] == [] assert not [c for c in backend.calls if c[0] == "layer_create"] + + +# ── the cut face is the face, not its chords (regression) ─────────────────── + +from engineering.layers import ENGINEERING_LAYERS # noqa: E402 +from engineering.mech import marks as _marks # noqa: E402 +from engineering.mech.marks import ( # noqa: E402 + HATCH_FLATTEN_SAGITTA, + _flatten_bulge, + _polygon_area, +) + + +async def test_a_bulged_segment_is_expanded_onto_its_real_arc(): + """A bulge of 1 is a 180 degree arc; its chords must sit on that circle.""" + interior = _flatten_bulge((40.0, 10.0), (0.0, 10.0), 1.0) + assert len(interior) >= 40 # a 0.01 sagitta on r=20 needs ~50 chords + for x, y in interior: + assert math.hypot(x - 20.0, y - 10.0) == pytest.approx(20.0, abs=1e-9) + assert y >= 10.0 # a positive bulge sweeps counter-clockwise, i.e. above + # Every chord's sagitta stays inside the declared tolerance. + walk = [(40.0, 10.0), *interior, (0.0, 10.0)] + for (x1, y1), (x2, y2) in zip(walk, walk[1:], strict=False): + chord = math.hypot(x2 - x1, y2 - y1) + sagitta = 20.0 - math.sqrt(max(0.0, 400.0 - (chord / 2.0) ** 2)) + assert sagitta <= HATCH_FLATTEN_SAGITTA + 1e-12 + assert _flatten_bulge((0.0, 0.0), (10.0, 0.0), 0.0) == [] + + +async def _arc_topped_face(backend): + """A 40x10 box closed by a 180 degree arc: 400 straight + 628.3 under the arc.""" + lower = await backend.entity_create_line(0.0, 0.0, 40.0, 0.0, 0.0, 0.0, "GEOMETRY") + right = await backend.entity_create_line(40.0, 0.0, 40.0, 10.0, 0.0, 0.0, "GEOMETRY") + arc = await backend.entity_create_arc(20.0, 10.0, 20.0, 0.0, 180.0, "GEOMETRY") + left = await backend.entity_create_line(0.0, 10.0, 0.0, 0.0, 0.0, 0.0, "GEOMETRY") + return [lower.handle, right.handle, arc.handle, left.handle] + + +async def _rectangle(backend): + handles = [] + for start, end in ( + ((0.0, 0.0), (40.0, 0.0)), + ((40.0, 0.0), (40.0, 10.0)), + ((40.0, 10.0), (0.0, 10.0)), + ((0.0, 10.0), (0.0, 0.0)), + ): + line = await backend.entity_create_line(start[0], start[1], end[0], end[1], 0.0, 0.0) + handles.append(line.handle) + return handles + + +async def test_an_arc_bounded_cut_face_is_hatched_on_the_arc_not_its_chord(backend): + """The failure README.md names: reading `points` drops the parallel `bulges`. + + Reading only `traced.properties["points"]` hatched the chord polygon - + 400.0 of a 1028.3 cut face, 61% of it silently unhatched. The hatch must + now measure the face, and the payload must state its own accuracy. + """ + handles = await _arc_topped_face(backend) + exact = (await backend.boundary_from_entities(handles))["area"] + assert exact == pytest.approx(400.0 + 200.0 * math.pi, abs=1e-6) + + result = await draw_material_hatch(backend, handles=handles, material="steel") + assert result["ok"] is True + assert result["curved_edges"] == 1 + assert result["points"] > 4 # not the four-vertex chord polygon + assert result["boundary_area"] == pytest.approx(exact, abs=1e-6) + # Inside the declared flattening tolerance of the real face, not 61% short. + assert result["area"] == pytest.approx(exact, rel=1e-3) + assert result["accuracy"] == "flatten_tolerance" + assert result["flatten_tolerance"] == HATCH_FLATTEN_SAGITTA + # What the drawing holds agrees with what the payload claims. + measured = await backend.entity_measure(result["handle"]) + assert measured["area"] == pytest.approx(result["area"], abs=1e-6) + + +async def test_a_straight_sided_face_is_still_reported_exact(backend): + handles = await _rectangle(backend) + result = await draw_material_hatch(backend, handles=handles, material="steel") + assert result["accuracy"] == "exact" + assert result["flatten_tolerance"] is None + assert result["curved_edges"] == 0 + assert result["area"] == pytest.approx(400.0, abs=EPS) + assert result["boundary_area"] == pytest.approx(400.0, abs=EPS) + assert result["points"] == 4 + + +async def test_the_traced_outline_is_removed_and_reported_not_left_on_layer_0(backend): + """`boundary_from_entities` *draws* its loop; hatch_material asked for a hatch.""" + handles = await _rectangle(backend) + before = await backend.analysis_stats() + result = await draw_material_hatch(backend, handles=handles, material="steel") + after = await backend.analysis_stats() + # Exactly one new entity: the hatch. No LWPOLYLINE scaffolding survives. + assert after["total_entities"] == before["total_entities"] + 1 + assert after["by_type"].get("LWPOLYLINE", 0) == 0 + assert result["traced"]["removed"] is True + assert result["traced"]["handle"] + live = {entity.dxf.handle for entity in backend._doc.modelspace()} + assert result["traced"]["handle"] not in live + + +async def test_a_loop_that_encloses_no_area_is_refused_and_leaves_nothing_behind(backend): + """Three collinear segments chain into a closed loop of area 0.0. + + The handles branch had no such guard - the 'at least three points' refusal + only ever covered `boundary` - so it wrote a HATCH of area 0.0 and returned + ok. The traced outline must still be cleaned up on the refusal path. + """ + handles = [] + for start, end in ( + ((0.0, 0.0), (10.0, 0.0)), + ((10.0, 0.0), (20.0, 0.0)), + ((20.0, 0.0), (0.0, 0.0)), + ): + line = await backend.entity_create_line(start[0], start[1], end[0], end[1], 0.0, 0.0) + handles.append(line.handle) + before = await backend.analysis_stats() + with pytest.raises(ValueError) as excinfo: + await draw_material_hatch(backend, handles=handles, material="steel") + assert "no area" in str(excinfo.value) + after = await backend.analysis_stats() + assert after["total_entities"] == before["total_entities"] + assert after["by_type"].get("LWPOLYLINE", 0) == 0 + assert after["by_type"].get("HATCH", 0) == 0 + + +async def test_the_polygon_area_helper_is_orientation_blind(): + square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] + assert _polygon_area(square) == pytest.approx(100.0, abs=EPS) + assert _polygon_area(list(reversed(square))) == pytest.approx(100.0, abs=EPS) + + +# ── the SOURCE block may not quote a lineweight the layer set does not have ── + + +async def test_the_section_line_source_block_quotes_the_layer_set_it_draws_on(): + """A SOURCE block asserting 0.25 mm while CENTER is 0.18 mm is a fabricated + number in the one place this repository refuses to fabricate one.""" + widths = {name: weight for name, _c, _lt, weight, _d in ENGINEERING_LAYERS} + wide, narrow = widths["GEOMETRY"], widths["CENTER"] + doc = _marks.__doc__ + assert f"GEOMETRY is {wide:.2f} mm" in doc + assert f"CENTER is {narrow:.2f} mm" in doc + assert f"{wide / narrow:.2f}:1" in doc + + +async def test_the_cutting_plane_line_really_lands_on_those_two_lineweights(backend): + result = await draw_section_line( + backend, (0.0, 0.0), (100.0, 0.0), label="A", direction=(0.0, -1.0), height=5.0 + ) + widths = {layer.name: layer.lineweight for layer in await backend.layer_list()} + drawn = {(await backend.entity_get(handle)).layer for handle in result["handles"]} + assert {ROLE_LAYER["visible"], ROLE_LAYER["center"]} <= drawn + # lineweight is reported in 1/100 mm; the SOURCE block quotes millimetres. + assert widths[ROLE_LAYER["visible"]] == pytest.approx(50.0, abs=EPS) + assert widths[ROLE_LAYER["center"]] == pytest.approx(18.0, abs=EPS)