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:
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user