diff --git a/engineering/mech/annotate.py b/engineering/mech/annotate.py index 94328c5..9b1fb63 100644 --- a/engineering/mech/annotate.py +++ b/engineering/mech/annotate.py @@ -45,8 +45,10 @@ weld annotation. from __future__ import annotations import math +from collections.abc import Iterable from typing import TYPE_CHECKING +from engineering.layers import ENGINEERING_LAYERS from engineering.mech.primitives import ( ROLE_LAYER, Arc, @@ -73,6 +75,7 @@ __all__ = [ "draw_annotation_prims", "draw_surface_texture", "draw_weld_symbol", + "ensure_annotation_layers", "leader_prims", "surface_texture_prims", "symbol_proportions", @@ -312,8 +315,18 @@ def weld_symbol_prims( point a leader attaches to. The continuous reference line runs in +X; the dashed identification line runs parallel below it and is omitted for a symmetrical weld (`side="both"`), which ISO 2553:2019 permits. The - arrow-side symbol stands on the reference line, the other-side symbol - hangs below the identification line. + arrow-side symbol stands on the reference line and the other-side symbol + hangs below the identification line - except for a symmetrical weld, where + ISO 2553 places the two elementary symbols back-to-back on the continuous + reference line itself (an X butt weld is one symbol, not two detached + halves), so the lower one hangs from y = 0 and the pair is symmetric about + the reference line. + + The size and the length/pitch are written beside the symbol they belong + to - size to its left, length to its right - which for an other-side weld + means beside the identification line, not above the reference line: a + throat dimension written on the arrow side of a joint with no arrow-side + weld tells a welder the wrong thing. """ if kind not in WELD_KINDS: raise ValueError( @@ -360,15 +373,24 @@ def weld_symbol_prims( prims.append(Line((x, ident_y), (x_end, ident_y), "dim")) x = x_end + gap + # A symmetrical weld has no identification line, so its other-side symbol + # stands on the reference line; otherwise it stands on the identification + # line. `sense=-1` then grows it downward from that baseline. + other_baseline = 0.0 if side == "both" else ident_y if side in ("arrow", "both"): prims.extend(_elementary_prims(kind, x_symbol, 0.0, 1.0, h)) if side in ("other", "both"): - prims.extend(_elementary_prims(kind, x_symbol, ident_y, -1.0, h)) + prims.extend(_elementary_prims(kind, x_symbol, other_baseline, -1.0, h)) + # The dimensions sit beside their own symbol: 0.25h clear of the line the + # symbol stands on, on the symbol's side of it. For "both" that is the + # arrow-side symbol, which is where ISO 2553 writes a symmetrical weld's + # single set of dimensions. + text_y = ident_y - 1.25 * h if side == "other" else 0.25 * h if size_text: - prims.append(Text((x_size, 0.25 * h), size_text, h)) + prims.append(Text((x_size, text_y), size_text, h)) if right_text: - prims.append(Text((x_right, 0.25 * h), right_text, h)) + prims.append(Text((x_right, text_y), right_text, h)) if field_weld: top = FLAG_POLE_FACTOR * h @@ -442,6 +464,48 @@ def leader_prims( ) +#: The layer definitions `ROLE_LAYER` names, so a layer this module creates +#: gets the colour/linetype/lineweight `drawing_new` would have given it +#: instead of a bare white Continuous one. +_LAYER_DEFS: dict[str, tuple[int, str, float]] = { + name: (color, linetype, lineweight) + for name, color, linetype, lineweight, _desc in ENGINEERING_LAYERS +} + + +async def ensure_annotation_layers( + backend: AutoCADBackend, names: Iterable[str] +) -> tuple[str, ...]: + """Create whichever of `names` the drawing does not have yet; return those. + + MEASURED on AutoCAD 2026: assigning `entity.Layer = "DIM"` on a drawing + whose layer table is `['0']` raises `-0x7ffdfff7 ('Key not found')` - + ActiveX will not create the layer for you. The headless engine will + (`EzdxfBackend._apply_attrs` calls `doc.layers.add`), so without this step + the same annotation silently succeeds headless and dies live on the first + entity, having already written the ones before it. `drawing_open` runs no + bootstrap, so annotating a foreign drawing - the case the spec names - is + exactly when the layer is missing. Ensuring every target layer up front, + before any entity is created, is what makes the two engines agree and what + keeps a refusal from leaving half a symbol behind. + """ + wanted: list[str] = [] + for name in names: + if name not in wanted: + wanted.append(name) + if not wanted: + return () + existing = {lyr.name.lower() for lyr in await backend.layer_list()} + created: list[str] = [] + for name in wanted: + if name.lower() in existing: + continue + color, linetype, lineweight = _LAYER_DEFS.get(name, (7, "Continuous", 0.25)) + await backend.layer_create(name=name, color=color, linetype=linetype, lineweight=lineweight) + created.append(name) + return tuple(created) + + async def draw_annotation_prims( backend: AutoCADBackend, prims, @@ -454,7 +518,10 @@ async def draw_annotation_prims( Rotates about the symbol origin first, then translates to `at`, so every coordinate that reaches the backend is already WCS. Each primitive lands on - `ROLE_LAYER[role]` unless `layer` overrides it. + `ROLE_LAYER[role]` unless `layer` overrides it, and every target layer is + created first (`ensure_annotation_layers`) - live AutoCAD refuses an + assignment to a layer that is not in the table, and a foreign drawing that + was merely opened has no engineering layers at all. """ placed = tuple(prims) for prim in placed: @@ -467,9 +534,11 @@ async def draw_annotation_prims( placed = rotate(placed, float(rotation)) placed = translate(placed, float(at[0]), float(at[1])) + targets = [layer or ROLE_LAYER[prim.role] for prim in placed] + created = await ensure_annotation_layers(backend, targets) + handles: list[str] = [] - for prim in placed: - target = layer or ROLE_LAYER[prim.role] + for prim, target in zip(placed, targets, strict=True): if isinstance(prim, Line): info = await backend.entity_create_line( prim.p1[0], prim.p1[1], prim.p2[0], prim.p2[1], 0.0, 0.0, target @@ -496,7 +565,12 @@ async def draw_annotation_prims( prim.text, prim.at[0], prim.at[1], prim.height, prim.rotation, target ) handles.append(info.handle) - return {"ok": True, "handles": handles, "count": len(handles)} + return { + "ok": True, + "handles": handles, + "count": len(handles), + "layers_created": list(created), + } async def draw_surface_texture( @@ -538,6 +612,9 @@ async def draw_surface_texture( drawn = await draw_annotation_prims(backend, leader, layer=layer) result["handles"].extend(drawn["handles"]) result["count"] = len(result["handles"]) + result["layers_created"].extend( + name for name in drawn["layers_created"] if name not in result["layers_created"] + ) result["standard"] = standard return result @@ -581,6 +658,9 @@ async def draw_weld_symbol( drawn = await draw_annotation_prims(backend, leader, layer=layer) result["handles"].extend(drawn["handles"]) result["count"] = len(result["handles"]) + result["layers_created"].extend( + name for name in drawn["layers_created"] if name not in result["layers_created"] + ) result["kind"] = kind result["side"] = side return result diff --git a/tests/test_mech_annotate.py b/tests/test_mech_annotate.py index a05140d..b1ea98a 100644 --- a/tests/test_mech_annotate.py +++ b/tests/test_mech_annotate.py @@ -8,6 +8,7 @@ document through the `backend` fixture. No screenshots anywhere. from __future__ import annotations import math +import types import pytest @@ -199,16 +200,38 @@ async def test_the_reference_line_comes_first_and_the_identification_line_is_das assert dashes[-1].p2[0] <= length + EPS -async def test_a_symmetrical_weld_omits_the_identification_line(): +async def test_a_symmetrical_weld_meets_back_to_back_on_the_reference_line(): + """ISO 2553: no identification line, and the two elementary symbols share it. + + The earlier version of this test asserted the lower V apex at -0.4h, i.e. + on the (omitted) identification line, which drew a double-V butt weld as + two detached halves that were not even symmetric about the reference line. + """ prims = weld_symbol_prims(kind="v", side="both", height=3.5) assert not [ p for p in prims if isinstance(p, Line) and p.p1[1] == pytest.approx(-0.4 * 3.5, abs=EPS) ] - # One V above the reference line, one V below the identification offset. + # One V above the reference line, one below it, both standing on y = 0. vees = [p for p in prims if isinstance(p, Poly) and len(p.points) == 3 and not p.closed] assert len(vees) == 2 - assert vees[0].points[1][1] == pytest.approx(0.0, abs=EPS) - assert vees[1].points[1][1] == pytest.approx(-0.4 * 3.5, abs=EPS) + upper, lower = vees + assert upper.points[1][1] == pytest.approx(0.0, abs=EPS) + assert lower.points[1][1] == pytest.approx(0.0, abs=EPS) + # Mirror images of each other about y = 0, same x. + assert _flat(lower.points) == pytest.approx( + tuple(v if i % 2 == 0 else -v for i, v in enumerate(_flat(upper.points))), abs=EPS + ) + ys = [y for _x, y in upper.points + lower.points] + assert (min(ys), max(ys)) == pytest.approx((-3.5, 3.5), abs=EPS) + + +async def test_a_symmetrical_fillet_weld_is_the_double_fillet_not_two_halves(): + prims = weld_symbol_prims(kind="fillet", side="both", height=3.5) + triangles = [p for p in prims if isinstance(p, Poly) and p.closed] + assert len(triangles) == 2 + x0 = triangles[0].points[0][0] + assert _flat(triangles[0].points) == pytest.approx((x0, 0.0, x0, 3.5, x0 + 3.5, 0.0), abs=EPS) + assert _flat(triangles[1].points) == pytest.approx((x0, 0.0, x0, -3.5, x0 + 3.5, 0.0), abs=EPS) async def test_the_fillet_triangle_stands_on_the_line_with_its_leg_on_the_left(): @@ -228,6 +251,45 @@ async def test_the_other_side_symbol_hangs_below_the_identification_line(): ) +async def test_other_side_dimensions_are_written_beside_the_other_side_symbol(): + """ISO 2553 writes the size left of the symbol and the length right of it. + + For an other-side weld the symbol hangs from the identification line, so + both texts belong there too. Written above the reference line instead, + `a6` sits exactly where an arrow-side throat dimension sits - on a joint + that has no arrow-side weld. + """ + prims = weld_symbol_prims(kind="fillet", side="other", size=6, length=50, height=3.5) + triangle = [p for p in prims if isinstance(p, Poly) and p.closed][0] + x_symbol = triangle.points[0][0] + texts = [p for p in prims if isinstance(p, Text)] + assert [t.text for t in texts] == ["a6", "50"] + # Beside the symbol: size to its left, length to its right. + assert texts[0].at[0] < x_symbol + assert texts[1].at[0] > x_symbol + 3.5 + # Below the reference line, 0.25h clear of the identification line, so the + # whole text body lies between the identification line and the symbol's + # far edge - never above y = 0. + ident_y = -0.4 * 3.5 + for t in texts: + assert t.at[1] == pytest.approx(ident_y - 1.25 * 3.5, abs=EPS) + assert t.at[1] + t.height < 0.0 + # The arrow side is unchanged: its dimensions stay above the reference line. + arrow = [ + p + for p in weld_symbol_prims(kind="fillet", side="arrow", size=6, length=50, height=3.5) + if isinstance(p, Text) + ] + assert all(t.at[1] == pytest.approx(0.25 * 3.5, abs=EPS) for t in arrow) + # A symmetrical weld carries one set, beside its arrow-side symbol. + both = [ + p + for p in weld_symbol_prims(kind="fillet", side="both", size=6, length=50, height=3.5) + if isinstance(p, Text) + ] + assert all(t.at[1] == pytest.approx(0.25 * 3.5, abs=EPS) for t in both) + + async def test_the_u_symbol_is_a_half_circle_between_two_uprights(): prims = weld_symbol_prims(kind="u", height=3.5) arc = [p for p in prims if isinstance(p, Arc)][0] @@ -304,6 +366,87 @@ from engineering.mech.annotate import ( # noqa: E402 - grouped with its own tes from engineering.mech.primitives import ROLE_LAYER # noqa: E402 +class _StrictLayerBackend: + """A backend that refuses an entity on a layer the drawing does not have. + + MEASURED on AutoCAD 2026: `Documents.Add()` gives a layer table of `['0']`, + and `entity.Layer = "DIM"` on it raises + `AutoCAD COM error (-0x7ffdfff7) ('Key not found')` - live ActiveX never + auto-creates the layer, while `EzdxfBackend._apply_attrs` does. This fake + models that measured refusal (and only it: it has no member the real + `AutoCADBackend` lacks), so the headless suite fails the way the live + engine fails. Before the fix it recorded one LINE and then raised; the + drawing kept that orphan, which is the partial write the fix removes. + """ + + def __init__(self, layers=("0",)): + self.layers = {name.lower(): name for name in layers} + self.calls: list[tuple] = [] + self._next = 0 + + async def layer_list(self): + self.calls.append(("layer_list",)) + return [types.SimpleNamespace(name=name) for name in self.layers.values()] + + async def layer_create(self, name, color=7, linetype="Continuous", lineweight=-3): + self.calls.append(("layer_create", name, color, linetype, lineweight)) + self.layers[name.lower()] = name + return types.SimpleNamespace(name=name) + + def _entity(self, kind, layer): + if layer is not None and layer.lower() not in self.layers: + raise RuntimeError(f"AutoCAD COM error (-0x7ffdfff7) ('Key not found'): {layer}") + self.calls.append((kind, layer)) + self._next += 1 + return types.SimpleNamespace(handle=f"{self._next:X}") + + async def entity_create_line(self, x1, y1, x2, y2, z1=0.0, z2=0.0, layer=None, **kw): + return self._entity("LINE", layer) + + async def entity_create_circle(self, cx, cy, radius, layer=None, **kw): + return self._entity("CIRCLE", layer) + + async def entity_create_arc(self, cx, cy, radius, start_angle, end_angle, layer=None, **kw): + return self._entity("ARC", layer) + + async def entity_create_polyline(self, points, closed=False, layer=None, **kw): + return self._entity("LWPOLYLINE", layer) + + async def entity_create_text(self, text, x, y, height=2.5, rotation=0.0, layer=None, **kw): + return self._entity("TEXT", layer) + + +async def test_every_target_layer_is_created_before_the_first_entity(): + backend = _StrictLayerBackend() + result = await draw_surface_texture( + backend, at=(0.0, 0.0), ra=3.2, machining="required", lay="perpendicular", allowance=0.5 + ) + assert result["ok"] is True and result["count"] == 6 + assert result["layers_created"] == [ROLE_LAYER["dim"], ROLE_LAYER["text"]] + # Nothing is drawn until every layer the symbol needs exists. + first_entity = next( + i + for i, call in enumerate(backend.calls) + if call[0] != "layer_list" and call[0] != "layer_create" + ) + assert {call[1] for call in backend.calls[:first_entity] if call[0] == "layer_create"} == { + ROLE_LAYER["dim"], + ROLE_LAYER["text"], + } + # Created with their ENGINEERING_LAYERS definition, not a bare default. + dim_create = next(c for c in backend.calls if c[0] == "layer_create" and c[1] == "DIM") + assert dim_create[2:] == (2, "Continuous", 0.18) + + +async def test_a_drawing_that_already_has_the_layers_is_not_rewritten(): + backend = _StrictLayerBackend(layers=("0", "dim", "TEXT")) + result = await draw_weld_symbol( + backend, at=(0.0, 0.0), kind="fillet", size=5, length=50, leader_to=(-10.0, -10.0) + ) + assert result["layers_created"] == [] + assert not [c for c in backend.calls if c[0] == "layer_create"] + + async def _types_and_layers(backend, handles): rows = [] for handle in handles: