merge: tracks B+G group A

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-23 12:25:44 +03:00
co-authored by Claude Opus 5.5
10 changed files with 2798 additions and 10 deletions
+666
View File
@@ -0,0 +1,666 @@
"""ISO annotation symbols: surface texture (ISO 21920-1 / ISO 1302) and welds (ISO 2553).
Pure geometry. Every function here returns primitive *descriptions* from
`engineering.mech.primitives` in a symbol-local frame whose origin is the point
the leader attaches to; `draw_*` is the only async part and the only part that
touches a backend. That split is what lets a test assert the emitted tuples
instead of a screenshot.
SOURCE - surface-texture symbol proportions: ISO 1302:2002, "Geometrical
Product Specifications (GPS) - Indication of surface texture in technical
product documentation", the table of graphical-symbol dimensions (text height
h -> line width d', short-leg height H1, minimum overall height H2). The seven
rows below are transcribed from it. ISO 21920-1:2021 superseded ISO 1302
without changing the graphical symbol, so the same proportions serve both
designations. The clause/table *number* is deliberately not quoted: the values
are what this implementer can state, the numbering is not, and a fabricated
citation is worse than none. A text height outside the seven rows is refused
by name - never interpolated.
SOURCE - direction-of-lay symbols: ISO 1302:2002, the seven symbols for the
direction of lay (=, perpendicular, X, M, C, R, P). A lay name outside those
seven is refused with the list.
SOURCE - weld symbols: ISO 2553:2019, "Welding and allied processes -
Symbolic representation on drawings - Welded joints", elementary weld symbols,
plus its rules for the reference line, the dashed identification line, the
size written before the symbol, the length and pitch written after it, the
field-weld flag and the all-around circle at the kink, and the tail carrying
the process reference. SIX elementary symbols are transcribed here: square (I),
single-V, single-bevel, single-U, single-J and fillet. The rest of the
elementary table - spot, seam, plug/slot, backing run, surfacing, edge,
steep-flanked V and bevel, fold joint, inclined joint - is NOT shipped,
because their drawn form could not be verified; a weld kind outside the six is
refused with the list of the six. A narrow catalogue is merely narrow; a
guessed weld symbol is a wrong instruction to a welder.
DECLARED, not transcribed. Neither standard dimensions these, so they are this
implementation's stated choices and are named as such wherever they are used:
the all-around circle radius, the field-weld flag, the identification line's
dash pattern and its offset from the reference line, the text line pitch and
the character-width estimate used to size the extension line and lay out the
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,
Circle,
Line,
Poly,
Prim,
Text,
rotate,
translate,
)
if TYPE_CHECKING: # pragma: no cover - typing only
from backends.base import AutoCADBackend
__all__ = [
"ISO1302_SYMBOL_PROPORTIONS",
"LAY_SYMBOLS",
"MACHINING_KINDS",
"SURFACE_HEIGHTS",
"SURFACE_STANDARDS",
"WELD_KINDS",
"WELD_SIDES",
"draw_annotation_prims",
"draw_surface_texture",
"draw_weld_symbol",
"ensure_annotation_layers",
"leader_prims",
"surface_texture_prims",
"symbol_proportions",
"weld_symbol_prims",
]
# ── ISO 1302:2002 graphical-symbol dimensions (transcribed) ─────────────────
# text height h -> (line width d', short-leg height H1, minimum height H2), mm.
ISO1302_SYMBOL_PROPORTIONS: dict[float, tuple[float, float, float]] = {
2.5: (0.25, 3.5, 7.5),
3.5: (0.35, 5.0, 10.5),
5.0: (0.5, 7.0, 15.0),
7.0: (0.7, 10.0, 21.0),
10.0: (1.0, 14.0, 30.0),
14.0: (1.4, 20.0, 42.0),
20.0: (2.0, 28.0, 60.0),
}
SURFACE_HEIGHTS: tuple[float, ...] = tuple(sorted(ISO1302_SYMBOL_PROPORTIONS))
# ISO 21920-1:2021 is the current designation; ISO 1302 is accepted as an alias
# for drawings still issued under the older one. The geometry is identical.
SURFACE_STANDARDS: tuple[str, ...] = ("ISO 21920-1", "ISO 1302")
# ISO 1302:2002 direction-of-lay symbols (transcribed).
LAY_SYMBOLS: dict[str, str] = {
"parallel": "=",
"perpendicular": "⊥",
"crossed": "X",
"multidirectional": "M",
"circular": "C",
"radial": "R",
"particulate": "P",
}
MACHINING_KINDS: tuple[str, ...] = ("any", "required", "prohibited")
# DECLARED layout constants (not standard values).
ALL_AROUND_RADIUS_FACTOR = 0.35 # all-around circle radius, in text heights
TEXT_GAP_FACTOR = 0.35 # gap between the extension line and the nearest text
TEXT_PITCH_FACTOR = 1.5 # line pitch for stacked annotation text
CHAR_WIDTH_FACTOR = 0.62 # mean character advance, in text heights
# tan(30 deg): the horizontal run of a 60 deg leg per unit of rise.
_LEG_RUN = 1.0 / math.sqrt(3.0)
def symbol_proportions(height: float) -> tuple[float, float, float]:
"""(d', H1, H2) for a text height, straight out of the ISO 1302 table.
A height the standard does not tabulate is refused: interpolating would
invent a proportion the standard never published.
"""
key = float(height)
if key not in ISO1302_SYMBOL_PROPORTIONS:
listed = ", ".join(f"{h:g}" for h in SURFACE_HEIGHTS)
raise ValueError(
f"ISO 1302 dimensions the surface-texture symbol only for text heights "
f"{listed} mm; {key:g} mm is outside the table and is not interpolated."
)
return ISO1302_SYMBOL_PROPORTIONS[key]
def surface_texture_prims(
*,
ra: float | None = None,
rz: float | None = None,
process: str | None = None,
lay: str | None = None,
machining: str = "any",
all_around: bool = False,
height: float = 3.5,
allowance: float | None = None,
standard: str = "ISO 21920-1",
) -> tuple[Prim, ...]:
"""The surface-texture symbol in a symbol-local frame, apex at the origin.
The apex is the point a leader attaches to. The short leg runs up-left and
the long leg up-right, both at 60 degrees to the horizontal, so the
included angle is 60 degrees. `machining="required"` closes the vee with
the bar at H1; `machining="prohibited"` inscribes the circle in the vee.
"""
if standard not in SURFACE_STANDARDS:
raise ValueError(
f"surface texture standard {standard!r} is unknown; use one of "
f"{', '.join(SURFACE_STANDARDS)}."
)
if machining not in MACHINING_KINDS:
raise ValueError(
f"machining {machining!r} is unknown; use one of {', '.join(MACHINING_KINDS)}."
)
if lay is not None and lay not in LAY_SYMBOLS:
raise ValueError(
f"lay {lay!r} is not an ISO 1302 direction of lay; use one of "
f"{', '.join(sorted(LAY_SYMBOLS))}."
)
_width, h1, h2 = symbol_proportions(height)
h = float(height)
short_top = (-h1 * _LEG_RUN, h1)
long_top = (h2 * _LEG_RUN, h2)
prims: list[Prim] = [
Line((0.0, 0.0), short_top, "dim"),
Line((0.0, 0.0), long_top, "dim"),
]
if machining == "required":
prims.append(Line(short_top, (h1 * _LEG_RUN, h1), "dim"))
elif machining == "prohibited":
# Inscribed circle of the 60 deg vee: tangent to both legs and to the
# bar height H1, so its centre sits at 2*H1/3 with radius H1/3.
prims.append(Circle((0.0, 2.0 * h1 / 3.0), h1 / 3.0, "dim"))
above: list[str] = []
if ra is not None:
above.append(f"Ra {float(ra):g}")
if rz is not None:
above.append(f"Rz {float(rz):g}")
if process:
above.append(str(process))
below: list[str] = []
if lay is not None:
below.append(LAY_SYMBOLS[lay])
if allowance is not None:
below.append(f"{float(allowance):g}")
below_text = " ".join(below)
widest = max([len(text) for text in above] + [len(below_text)])
extension = max(2.0 * h, CHAR_WIDTH_FACTOR * h * widest + h)
prims.append(Line(long_top, (long_top[0] + extension, h2), "dim"))
x_text = long_top[0] + 0.5 * h
for index, line in enumerate(above):
prims.append(
Text((x_text, h2 + TEXT_GAP_FACTOR * h + index * TEXT_PITCH_FACTOR * h), line, h)
)
if below_text:
prims.append(Text((x_text, h2 - TEXT_GAP_FACTOR * h - h), below_text, h))
if all_around:
# DECLARED: the standard shows the circle at the kink between the long
# leg and the extension line but does not dimension it.
prims.append(Circle(long_top, ALL_AROUND_RADIUS_FACTOR * h, "dim"))
return tuple(prims)
# ── ISO 2553:2019 weld symbols ──────────────────────────────────────────────
WELD_KINDS: tuple[str, ...] = ("square", "v", "bevel", "u", "j", "fillet")
WELD_SIDES: tuple[str, ...] = ("arrow", "other", "both")
# Symbol width per kind, in text heights: the half symbols (bevel, J) are half
# as wide as the full ones they are half of.
_WELD_WIDTH_FACTOR: dict[str, float] = {
"square": 1.0,
"v": 1.0,
"bevel": 0.5,
"u": 1.0,
"j": 0.5,
"fillet": 1.0,
}
# DECLARED layout constants for the weld annotation (ISO 2553 fixes none of
# these numerically; it fixes only what goes where).
IDENT_OFFSET_FACTOR = 0.4 # identification line offset below the reference line
IDENT_DASH_FACTOR = 0.8 # dash length of the identification line
IDENT_GAP_FACTOR = 0.4 # gap between its dashes
FLAG_POLE_FACTOR = 1.4 # field-weld flag pole height
KINK_CIRCLE_FACTOR = 0.4 # all-around circle radius at the kink
TAIL_FORK_FACTOR = 0.8 # tail fork length
def _weld_size_text(kind: str, size) -> str:
"""The text written before the symbol.
A string passes through verbatim, so `z7` (leg length) stays available; a
number on a fillet takes the `a` prefix, ISO 2553's design throat
thickness, because a bare number on a fillet weld is ambiguous.
"""
if size is None:
return ""
if isinstance(size, str):
return size.strip()
if kind == "fillet":
return f"a{float(size):g}"
return f"{float(size):g}"
def _elementary_prims(kind: str, x0: float, y0: float, sense: float, h: float) -> tuple[Prim, ...]:
"""One elementary symbol standing on (x0, y0), growing in `sense` (+1/-1)."""
s = float(sense)
if kind == "square":
return (
Line((x0 + 0.3 * h, y0), (x0 + 0.3 * h, y0 + s * h), "dim"),
Line((x0 + 0.7 * h, y0), (x0 + 0.7 * h, y0 + s * h), "dim"),
)
if kind == "v":
return (Poly(((x0, y0 + s * h), (x0 + 0.5 * h, y0), (x0 + h, y0 + s * h)), False, "dim"),)
if kind == "bevel":
return (
Line((x0, y0), (x0, y0 + s * h), "dim"),
Line((x0, y0), (x0 + 0.5 * h, y0 + s * h), "dim"),
)
if kind == "u":
cy = y0 + s * 0.5 * h
start, end = (180.0, 360.0) if s > 0 else (0.0, 180.0)
return (
Arc((x0 + 0.5 * h, cy), 0.5 * h, start, end, "dim"),
Line((x0, cy), (x0, y0 + s * h), "dim"),
Line((x0 + h, cy), (x0 + h, y0 + s * h), "dim"),
)
if kind == "j":
cy = y0 + s * 0.5 * h
start, end = (270.0, 360.0) if s > 0 else (0.0, 90.0)
return (
Arc((x0, cy), 0.5 * h, start, end, "dim"),
Line((x0 + 0.5 * h, cy), (x0 + 0.5 * h, y0 + s * h), "dim"),
Line((x0, y0), (x0, y0 + s * h), "dim"),
)
# fillet
return (Poly(((x0, y0), (x0, y0 + s * h), (x0 + h, y0)), True, "dim"),)
def weld_symbol_prims(
*,
kind: str,
size=None,
length: float | None = None,
pitch: float | None = None,
side: str = "arrow",
field_weld: bool = False,
all_around: bool = False,
process: str | None = None,
height: float = 3.5,
) -> tuple[Prim, ...]:
"""The ISO 2553 weld annotation in a local frame, kink at the origin.
The kink is where the arrow line meets the reference line, so it is the
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 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(
f"weld kind {kind!r} is not drawn by this server. The ISO 2553 elementary "
f"symbols transcribed here are: {', '.join(WELD_KINDS)}. The rest of the "
"elementary table is not shipped rather than guessed."
)
if side not in WELD_SIDES:
raise ValueError(f"side {side!r} is unknown; use one of {', '.join(WELD_SIDES)}.")
h = float(height)
if not math.isfinite(h) or h <= 0.0:
raise ValueError(f"height must be a positive finite number; got {height!r}.")
if pitch is not None and length is None:
raise ValueError(
"pitch needs length: ISO 2553 writes the pitch in brackets after the weld length."
)
size_text = _weld_size_text(kind, size)
right_text = ""
if length is not None:
right_text = (
f"{float(length):g}" if pitch is None else f"{float(length):g} ({float(pitch):g})"
)
ident_y = -IDENT_OFFSET_FACTOR * h
cursor = (1.0 if (field_weld or all_around) else 0.5) * h
x_size = cursor
if size_text:
cursor += CHAR_WIDTH_FACTOR * h * len(size_text) + 0.4 * h
x_symbol = cursor
cursor += _WELD_WIDTH_FACTOR[kind] * h + 0.4 * h
x_right = cursor
if right_text:
cursor += CHAR_WIDTH_FACTOR * h * len(right_text)
ref_length = max(cursor + 0.5 * h, 6.0 * h)
prims: list[Prim] = [Line((0.0, 0.0), (ref_length, 0.0), "dim")]
if side != "both":
x = 0.0
dash, gap = IDENT_DASH_FACTOR * h, IDENT_GAP_FACTOR * h
while x < ref_length - 1e-9:
x_end = min(x + dash, ref_length)
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, 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, text_y), size_text, h))
if right_text:
prims.append(Text((x_right, text_y), right_text, h))
if field_weld:
top = FLAG_POLE_FACTOR * h
prims.append(Line((0.0, 0.0), (0.0, top), "dim"))
prims.append(
Poly(
((0.0, top), (0.7 * h, top - 0.25 * h), (0.0, top - 0.5 * h)),
True,
"dim",
)
)
if all_around:
prims.append(Circle((0.0, 0.0), KINK_CIRCLE_FACTOR * h, "dim"))
if process:
fork = TAIL_FORK_FACTOR * h
prims.append(Line((ref_length, 0.0), (ref_length + fork, 0.5 * h), "dim"))
prims.append(Line((ref_length, 0.0), (ref_length + fork, -0.5 * h), "dim"))
prims.append(Text((ref_length + h, -0.35 * h), str(process), h))
return tuple(prims)
# ── the async layer ─────────────────────────────────────────────────────────
#
# Task 7's `engineering/mech/draw.py` is a sibling branch (group M) and is not
# importable from here, so the annotation tools carry their own small renderer.
# It is deliberately named `draw_annotation_prims`, not `draw_prims`, so the
# two coexist after the merge instead of colliding on a re-export.
_DRAWABLE = (Line, Circle, Arc, Poly, Text)
def leader_prims(
tip: tuple[float, float],
to: tuple[float, float],
*,
arrow_size: float = 2.5,
role: str = "dim",
) -> tuple[Prim, ...]:
"""A plain leader: a line from `tip` to `to` plus a solid arrowhead at `tip`.
`leader_create_mleader` is not used here: it refuses empty text
(`engineering/annotation.py::validate_mleader`), and a weld or surface
symbol needs the leader *without* an MTEXT label beside it. The arrowhead
reproduces the proportions that backend already draws (length `arrow_size`,
half-width 0.35 * `arrow_size`) so both routes look identical.
"""
x0, y0 = float(tip[0]), float(tip[1])
x1, y1 = float(to[0]), float(to[1])
dx, dy = x1 - x0, y1 - y0
length = math.hypot(dx, dy)
if length <= 1e-12:
raise ValueError(
f"leader_to ({x0:g}, {y0:g}) and the symbol position ({x1:g}, {y1:g}) "
"must be distinct points."
)
ux, uy = dx / length, dy / length
px, py = -uy, ux
a = float(arrow_size)
return (
Line((x0, y0), (x1, y1), role),
Poly(
(
(x0, y0),
(x0 + ux * a + px * a * 0.35, y0 + uy * a + py * a * 0.35),
(x0 + ux * a - px * a * 0.35, y0 + uy * a - py * a * 0.35),
),
True,
role,
),
)
#: 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,
*,
at: tuple[float, float] = (0.0, 0.0),
rotation: float = 0.0,
layer: str | None = None,
) -> dict:
"""Draw primitive descriptions through the generic entity contracts.
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, 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:
if not isinstance(prim, _DRAWABLE):
raise TypeError(
f"draw_annotation_prims cannot draw a {type(prim).__name__}; it draws "
"Line, Circle, Arc, Poly and Text."
)
if rotation:
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, 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
)
elif isinstance(prim, Circle):
info = await backend.entity_create_circle(
prim.center[0], prim.center[1], prim.radius, target
)
elif isinstance(prim, Arc):
info = await backend.entity_create_arc(
prim.center[0],
prim.center[1],
prim.radius,
prim.start_deg,
prim.end_deg,
target,
)
elif isinstance(prim, Poly):
info = await backend.entity_create_polyline(
[[float(p[0]), float(p[1])] for p in prim.points], prim.closed, target
)
else: # Text
info = await backend.entity_create_text(
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),
"layers_created": list(created),
}
async def draw_surface_texture(
backend: AutoCADBackend,
*,
at: tuple[float, float],
leader_to: tuple[float, float] | None = None,
ra: float | None = None,
rz: float | None = None,
process: str | None = None,
lay: str | None = None,
machining: str = "any",
all_around: bool = False,
allowance: float | None = None,
height: float = 3.5,
standard: str = "ISO 21920-1",
rotation: float = 0.0,
layer: str | None = None,
) -> dict:
"""Place a surface-texture symbol with its apex at `at`."""
prims = surface_texture_prims(
ra=ra,
rz=rz,
process=process,
lay=lay,
machining=machining,
all_around=all_around,
height=height,
allowance=allowance,
standard=standard,
)
result = await draw_annotation_prims(backend, prims, at=at, rotation=rotation, layer=layer)
if leader_to is not None:
leader = leader_prims(
(float(leader_to[0]), float(leader_to[1])),
(float(at[0]), float(at[1])),
arrow_size=0.7 * float(height),
)
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
async def draw_weld_symbol(
backend: AutoCADBackend,
*,
at: tuple[float, float],
kind: str,
leader_to: tuple[float, float] | None = None,
size=None,
length: float | None = None,
pitch: float | None = None,
side: str = "arrow",
field_weld: bool = False,
all_around: bool = False,
process: str | None = None,
height: float = 3.5,
rotation: float = 0.0,
layer: str | None = None,
) -> dict:
"""Place an ISO 2553 weld annotation with its kink at `at`."""
prims = weld_symbol_prims(
kind=kind,
size=size,
length=length,
pitch=pitch,
side=side,
field_weld=field_weld,
all_around=all_around,
process=process,
height=height,
)
result = await draw_annotation_prims(backend, prims, at=at, rotation=rotation, layer=layer)
if leader_to is not None:
leader = leader_prims(
(float(leader_to[0]), float(leader_to[1])),
(float(at[0]), float(at[1])),
arrow_size=0.7 * float(height),
)
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
+582
View File
@@ -0,0 +1,582 @@
"""Centre marks, the cutting-plane line and the material hatch.
Pure geometry: these functions return primitive descriptions from
`engineering.mech.primitives`; the `draw_*` functions are the only async part
and the only part that touches a backend.
SOURCE - centre marks and centre lines: ISO 128-23, "Technical drawings -
General principles of presentation - Lines on construction drawings", which
draws a centre line as a long-dash-dotted line that overruns the feature's
outline by a short amount. The *dash pattern itself is not written here*: the
prims are plain LINE entities on the CENTER layer, whose CENTER linetype
(bootstrapped by `drawing_new`) supplies the standard's dash proportions. That
is deliberate - transcribing a dash table this module cannot verify would put
an invented pattern next to a real one.
SOURCE - the cutting-plane line: ISO 128-40, "Technical drawings - General
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 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,
the arrow shaft length, the arrowhead size and the label offset below are this
implementation's stated choices, expressed in text heights.
The material hatch is not authored here at all: it comes from
`engineering.mech.standards.materials.hatch_for`, which carries the ISO 128-50
map and its own refusal.
"""
from __future__ import annotations
import math
from typing import TYPE_CHECKING
from engineering.mech.primitives import Line, Poly, Prim, Text
if TYPE_CHECKING: # pragma: no cover - typing only
from backends.base import AutoCADBackend
__all__ = [
"CENTRE_STYLES",
"HATCH_FLATTEN_SAGITTA",
"SECTION_STYLES",
"centre_mark_prims",
"draw_centre_marks",
"draw_material_hatch",
"draw_section_line",
"section_line_prims",
"section_plane",
]
CENTRE_STYLES: tuple[str, ...] = ("mark", "lines")
# The four styles `views.build_view(kind="section", style=...)` accepts; the
# dict this module returns is fed straight to it.
SECTION_STYLES: tuple[str, ...] = ("full", "half", "offset", "revolved")
# DECLARED layout constants for the cutting-plane line, in text heights.
END_SEGMENT_FACTOR = 2.0 # wide segment at each end
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],
radius: float,
*,
style: str = "mark",
extension: float = 3.0,
) -> tuple[Prim, ...]:
"""A centre mark or a pair of full centre lines for a circular feature.
`style="mark"` draws the short cross AutoCAD's DIMCEN draws: arms of
`extension` either side of the centre. `style="lines"` draws the two full
centre lines, overrunning the circle by `extension` at each end, which is
ISO 128-23's short overrun of the outline.
"""
if style not in CENTRE_STYLES:
raise ValueError(
f"centre style {style!r} is unknown; use one of {', '.join(CENTRE_STYLES)}."
)
ext = float(extension)
if not math.isfinite(ext) or ext <= 0.0:
raise ValueError(f"extension must be a positive finite number; got {extension!r}.")
cx, cy = float(center[0]), float(center[1])
if style == "mark":
arm = ext
else:
r = float(radius)
if not math.isfinite(r) or r <= 0.0:
raise ValueError(
f"style='lines' needs the feature's radius to know how far to overrun it; "
f"got radius={radius!r}."
)
arm = r + ext
return (
Line((cx - arm, cy), (cx + arm, cy), "center"),
Line((cx, cy - arm), (cx, cy + arm), "center"),
)
def _unit(vx: float, vy: float, what: str) -> tuple[float, float]:
length = math.hypot(vx, vy)
if length <= 1e-12:
raise ValueError(f"{what} must be a non-zero vector; got ({vx:g}, {vy:g}).")
return vx / length, vy / length
def section_line_prims(
p1: tuple[float, float],
p2: tuple[float, float],
*,
label: str = "A",
direction: tuple[float, float] = (0.0, -1.0),
height: float = 5.0,
) -> tuple[Prim, ...]:
"""The ISO 128-40 cutting-plane line: wide ends, arrows, the letter at both ends.
`direction` is the direction of viewing and must not be parallel to the
plane. The arrowhead's tip sits on the end of the cutting-plane line and
its shaft extends away from the part, so the head reads as pointing the way
the section is viewed; the letter goes beyond the tail.
"""
if not str(label).strip():
raise ValueError("a cutting-plane line needs a label; ISO 128-40 letters both ends.")
h = float(height)
if not math.isfinite(h) or h <= 0.0:
raise ValueError(f"height must be a positive finite number; got {height!r}.")
x1, y1 = float(p1[0]), float(p1[1])
x2, y2 = float(p2[0]), float(p2[1])
ux, uy = _unit(x2 - x1, y2 - y1, "the cutting plane (p1 and p2 must be distinct)")
dx, dy = _unit(float(direction[0]), float(direction[1]), "direction")
if abs(ux * dy - uy * dx) < 1e-6:
raise ValueError(
f"direction ({dx:g}, {dy:g}) is parallel to the cutting plane; the viewing "
"direction must be perpendicular to it."
)
span = math.hypot(x2 - x1, y2 - y1)
end = END_SEGMENT_FACTOR * h
prims: list[Prim] = []
if span <= 2.0 * end + 1e-9:
prims.append(Line((x1, y1), (x2, y2), "visible"))
else:
a = (x1 + ux * end, y1 + uy * end)
b = (x2 - ux * end, y2 - uy * end)
prims.append(Line((x1, y1), a, "visible"))
prims.append(Line(b, (x2, y2), "visible"))
prims.append(Line(a, b, "center"))
shaft = ARROW_SHAFT_FACTOR * h
head = ARROW_HEAD_FACTOR * h
px, py = -dy, dx
text = str(label).strip()
for tip in ((x1, y1), (x2, y2)):
tail = (tip[0] - dx * shaft, tip[1] - dy * shaft)
prims.append(Line(tail, tip, "visible"))
base = (tip[0] - dx * head, tip[1] - dy * head)
prims.append(
Poly(
(
tip,
(base[0] + px * head * 0.35, base[1] + py * head * 0.35),
(base[0] - px * head * 0.35, base[1] - py * head * 0.35),
),
True,
"visible",
)
)
for tip in ((x1, y1), (x2, y2)):
offset = shaft + LABEL_OFFSET_FACTOR * h
prims.append(Text((tip[0] - dx * offset, tip[1] - dy * offset), text, h))
return tuple(prims)
def section_plane(
p1: tuple[float, float],
p2: tuple[float, float],
*,
label: str = "A",
direction: tuple[float, float] = (0.0, -1.0),
style: str = "full",
) -> dict:
"""The plane definition `mech_view_add(kind="section")` consumes.
Exactly the five keys the plan pins as SECTION_PLANE, in that order, with
`direction` normalised to a unit vector. Returning it from the tool that
draws the cutting-plane line is what makes the label on the plan and the
label on the section view the same string by construction.
"""
if style not in SECTION_STYLES:
raise ValueError(
f"section style {style!r} is unknown; use one of {', '.join(SECTION_STYLES)}."
)
text = str(label).strip()
if not text:
raise ValueError("a section plane needs a label; ISO 128-40 letters both ends.")
x1, y1 = float(p1[0]), float(p1[1])
x2, y2 = float(p2[0]), float(p2[1])
_unit(x2 - x1, y2 - y1, "the cutting plane (p1 and p2 must be distinct)")
dx, dy = _unit(float(direction[0]), float(direction[1]), "direction")
return {
"p1": (x1, y1),
"p2": (x2, y2),
"label": text,
"direction": (dx, dy),
"style": style,
}
# ── the async layer ─────────────────────────────────────────────────────────
_CIRCULAR_TYPES = ("CIRCLE", "ARC")
async def draw_centre_marks(
backend: AutoCADBackend,
*,
handles: list[str] | None = None,
centers: list[list[float]] | None = None,
style: str = "mark",
extension: float = 3.0,
layer: str | None = None,
) -> dict:
"""Centre marks or centre lines for circular features.
With `handles`, the circles' real centre and radius are read back out of
the drawing through `entity_get` - the caller is never asked to restate a
radius it could get wrong. With `centers`, each row is `[x, y]` or
`[x, y, radius]`; `style="lines"` needs the radius, because the overrun is
measured from the outline.
"""
from engineering.mech.annotate import draw_annotation_prims
if not handles and not centers:
raise ValueError("centre_marks needs either handles or centers; both are empty.")
if handles and centers:
raise ValueError("centre_marks takes handles or centers, not both.")
sources: list[dict] = []
if handles:
for handle in handles:
info = await backend.entity_get(handle)
if info.type not in _CIRCULAR_TYPES:
raise ValueError(
f"handle {handle} is a {info.type}; a centre mark needs a circular "
f"feature ({', '.join(_CIRCULAR_TYPES)})."
)
radius = info.properties.get("radius")
if radius is None:
raise ValueError(
f"handle {handle} lies in a plane tilted out of WCS XY, so it has no "
"radius in this frame (capability 'ocs_tilted_plane'); its centre mark "
"cannot be drawn in xy."
)
center = info.properties["center"]
sources.append(
{
"handle": info.handle,
"center": [float(center[0]), float(center[1])],
"radius": float(radius),
}
)
else:
for index, row in enumerate(centers or []):
if len(row) < 2:
raise ValueError(f"centers[{index}] must be [x, y] or [x, y, radius].")
radius = float(row[2]) if len(row) > 2 else None
if style == "lines" and radius is None:
raise ValueError(
f"centers[{index}] has no radius, and style='lines' measures its overrun "
"from the outline; pass [x, y, radius] or use style='mark'."
)
sources.append(
{
"handle": None,
"center": [float(row[0]), float(row[1])],
"radius": radius,
}
)
# Validate every source before the first entity is written.
batches = [
centre_mark_prims(
(source["center"][0], source["center"][1]),
source["radius"] if source["radius"] is not None else 0.0,
style=style,
extension=extension,
)
for source in sources
]
handles_out: list[str] = []
for prims in batches:
drawn = await draw_annotation_prims(backend, prims, layer=layer)
handles_out.extend(drawn["handles"])
return {
"ok": True,
"handles": handles_out,
"count": len(handles_out),
"style": style,
"sources": sources,
}
async def draw_section_line(
backend: AutoCADBackend,
p1: tuple[float, float],
p2: tuple[float, float],
*,
label: str = "A",
direction: tuple[float, float] = (0.0, -1.0),
style: str = "full",
height: float = 5.0,
layer: str | None = None,
) -> dict:
"""Draw the cutting-plane line and hand back the plane the view engine consumes."""
from engineering.mech.annotate import draw_annotation_prims
plane = section_plane(p1, p2, label=label, direction=direction, style=style)
prims = section_line_prims(
plane["p1"],
plane["p2"],
label=plane["label"],
direction=plane["direction"],
height=height,
)
result = await draw_annotation_prims(backend, prims, layer=layer)
result["plane"] = plane
result["label"] = plane["label"]
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,
*,
boundary: list[list[float]] | None = None,
handles: list[str] | None = None,
material: str = "steel",
scale: float = 1.0,
angle: float | None = None,
layer: str | None = None,
) -> dict:
"""Hatch a closed region with the ISO 128-50 pattern for a material.
`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
from engineering.mech.standards.materials import hatch_for
spec = hatch_for(material) # refuses an unknown material, naming the list
factor = float(scale)
if not math.isfinite(factor) or factor <= 0.0:
raise ValueError(f"scale must be a positive finite number; got {scale!r}.")
if boundary is None and not handles:
raise ValueError("hatch_material needs either boundary points or handles; both are empty.")
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]
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_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)
target = layer or _ROLE_LAYER["hatch"]
# MEASURED on AutoCAD 2026: `msp.AddHatch(...)` + `Evaluate()` puts the
# AcDbHatch in model space, and the next statement - `entity.Layer = "HATCH"`
# inside `_apply_entity_attrs` - raises `('Key not found')` on a drawing whose
# layer table is `['0']`, leaving that hatch orphaned on layer 0. ActiveX never
# creates the layer; `EzdxfBackend._apply_attrs` does, so without this the same
# call succeeds headless and half-writes live. `drawing_open` runs no bootstrap,
# which is exactly when a cut face has no HATCH layer to land on.
created = await ensure_annotation_layers(backend, [target])
info = await backend.entity_create_hatch(
spec["pattern"],
[[x, y] for x, y in points],
pattern_scale,
pattern_angle,
target,
)
return {
"ok": True,
"handle": info.handle,
"material": material,
"pattern": spec["pattern"],
"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,
}