feat(mech): SECTION 23 opens with surface_texture and weld_symbol
ISO 1302:2002's seven transcribed symbol-proportion rows (text height -> line width d', short-leg height H1, minimum height H2) drive the 60 deg vee, the machining-required bar at H1, the machining-prohibited circle inscribed in the vee, and the all-around circle at the kink; the Ra/Rz values, the process, the lay symbol and the machining allowance land around the extension line, which grows with the widest of them. A text height outside the seven rows is refused by name rather than interpolated, and ISO 1302 is accepted as an alias designation for drawings still issued under it - the graphical symbol is the same one. ISO 2553:2019's reference line, dashed identification line (omitted for a symmetrical weld, as the standard permits), size written before the symbol, length and pitch written after it, field-weld flag, all-around circle and process tail. Six elementary symbols are transcribed - square, single-V, single-bevel, single-U, single-J, fillet. The rest of the elementary table is deliberately not shipped: a weld kind outside the six is refused with the list of the six, because a guessed weld symbol is a wrong instruction to a welder. Both tools place the symbol on a plain leader - a line plus an arrowhead with the proportions the backend's own mleader already draws - rather than through leader_create_mleader, which refuses empty text and would hang an MTEXT label off every symbol. Proportions neither standard dimensions (the all-around circles, the flag, the identification line's dashes, the text pitch and the character width estimate) are named constants documented as this implementation's declared choices, never as transcribed values. No new ActiveX member is called: both tools compose LINE / ARC / CIRCLE / LWPOLYLINE / TEXT through the entity-creation contracts both engines already implement, so no live COM probe was needed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5
parent
2bb8044144
commit
a4ce46ba9b
@@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
||||
|
||||
</div>
|
||||
|
||||
> **v1.5 release snapshot:** 204 tools · 8 resources · 5 prompt templates · 3284 collected tests.
|
||||
> **v1.5 release snapshot:** 206 tools · 8 resources · 5 prompt templates · 3284 collected tests.
|
||||
> 204 is the **registered** count; a default install advertises 199 over `tools/list`,
|
||||
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
||||
|
||||
@@ -117,7 +117,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
|
||||
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
|
||||
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
|
||||
|
||||
<sub>204 tools in 23 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools and the 22 environment tools (<code>TOOL_PACKS=core,settings</code> keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
|
||||
<sub>206 tools in 23 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools and the 22 environment tools (<code>TOOL_PACKS=core,settings</code> keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
|
||||
|
||||
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
|
||||
|
||||
|
||||
@@ -345,6 +345,33 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
||||
"true position",
|
||||
),
|
||||
),
|
||||
# ── Mechanical annotation (ISO 21920-1 / ISO 2553) ──────────────────────
|
||||
"surface_texture": ToolAliases(
|
||||
synonyms=(
|
||||
"surface finish",
|
||||
"surface roughness",
|
||||
"surface texture symbol",
|
||||
"roughness symbol",
|
||||
"ra value",
|
||||
"rz value",
|
||||
"machining symbol",
|
||||
"yuzey puruzlulugu",
|
||||
"yuzey isleme sembolu",
|
||||
),
|
||||
),
|
||||
"weld_symbol": ToolAliases(
|
||||
synonyms=(
|
||||
"weld symbol",
|
||||
"welding symbol",
|
||||
"fillet weld",
|
||||
"butt weld",
|
||||
"weld callout",
|
||||
"field weld",
|
||||
"weld all around",
|
||||
"kaynak sembolu",
|
||||
"kose kaynagi",
|
||||
),
|
||||
),
|
||||
# ── Dimensions ──────────────────────────────────────────────────────────
|
||||
"dimension_aligned": ToolAliases(
|
||||
acad=("DIMALIGNED", "DAL"),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"schema_version": 1,
|
||||
"package_version": "1.5.1",
|
||||
"totals": {
|
||||
"tools": 204,
|
||||
"tools": 206,
|
||||
"resources": 8,
|
||||
"prompts": 5,
|
||||
"groups": 23
|
||||
@@ -15,7 +15,7 @@
|
||||
"corner_ops": 4,
|
||||
"dimensions": 5,
|
||||
"drawing": 11,
|
||||
"engineering": 10,
|
||||
"engineering": 12,
|
||||
"entity_creation": 18,
|
||||
"entity_modification": 16,
|
||||
"entity_query": 9,
|
||||
@@ -2333,6 +2333,20 @@
|
||||
"destructiveHint": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "surface_texture",
|
||||
"group": "engineering",
|
||||
"cost": "mutate",
|
||||
"summary": "Draw an ISO 21920-1 surface-texture symbol: Ra/Rz, process, lay, allowance.",
|
||||
"tags": [
|
||||
"engineering",
|
||||
"mech"
|
||||
],
|
||||
"annotations": {
|
||||
"title": "Surface Texture Symbol (ISO 21920-1)",
|
||||
"destructiveHint": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "system_about",
|
||||
"group": "system",
|
||||
@@ -2903,6 +2917,20 @@
|
||||
"title": "Set Viewport Scale",
|
||||
"destructiveHint": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "weld_symbol",
|
||||
"group": "engineering",
|
||||
"cost": "mutate",
|
||||
"summary": "Draw an ISO 2553 weld symbol: reference line, identification line, size and pitch.",
|
||||
"tags": [
|
||||
"engineering",
|
||||
"mech"
|
||||
],
|
||||
"annotations": {
|
||||
"title": "Weld Symbol (ISO 2553)",
|
||||
"destructiveHint": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
"""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 typing import TYPE_CHECKING
|
||||
|
||||
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",
|
||||
"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, the other-side symbol
|
||||
hangs below the identification line.
|
||||
"""
|
||||
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
|
||||
|
||||
if side in ("arrow", "both"):
|
||||
prims.extend(_elementary_prims(kind, x_symbol, 0.0, 1.0, h))
|
||||
if side in ("other", "both"):
|
||||
prims.extend(_elementary_prims(kind, x_symbol, ident_y, -1.0, h))
|
||||
|
||||
if size_text:
|
||||
prims.append(Text((x_size, 0.25 * h), size_text, h))
|
||||
if right_text:
|
||||
prims.append(Text((x_right, 0.25 * h), 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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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]))
|
||||
|
||||
handles: list[str] = []
|
||||
for prim in placed:
|
||||
target = layer or ROLE_LAYER[prim.role]
|
||||
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)}
|
||||
|
||||
|
||||
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["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["kind"] = kind
|
||||
result["side"] = side
|
||||
return result
|
||||
@@ -8123,6 +8123,187 @@ async def drawing_properties_set(
|
||||
return await _backend(ctx).drawing_properties_set(summary, custom)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ── SECTION 23: Mechanical Annotation (2 tools) ─────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
# ISO annotation symbols composed from LINE / ARC / CIRCLE / LWPOLYLINE / TEXT,
|
||||
# so the same symbol lands on COM and ezdxf. The standards tables live in
|
||||
# engineering/mech/annotate.py with their sources; a value outside a
|
||||
# transcribed table is refused there, before the first entity is written.
|
||||
|
||||
|
||||
@cad_tool(
|
||||
summary="Draw an ISO 21920-1 surface-texture symbol: Ra/Rz, process, lay, allowance.",
|
||||
cost="mutate",
|
||||
)
|
||||
@mcp.tool(
|
||||
annotations={"title": "Surface Texture Symbol (ISO 21920-1)", "destructiveHint": False},
|
||||
tags={"engineering", "mech"},
|
||||
)
|
||||
async def surface_texture(
|
||||
x: Annotated[float, "Symbol apex X (WCS) — the point the leader attaches to."],
|
||||
y: Annotated[float, "Symbol apex Y (WCS)."],
|
||||
ra: Annotated[
|
||||
float | None, Field(default=None, description="Ra value in µm, written as 'Ra 3.2'.")
|
||||
] = None,
|
||||
rz: Annotated[
|
||||
float | None, Field(default=None, description="Rz value in µm, written as 'Rz 12.5'.")
|
||||
] = None,
|
||||
machining: Annotated[
|
||||
str,
|
||||
Field(
|
||||
default="any",
|
||||
description=(
|
||||
"any (basic vee) | required (bar across the vee, material removal "
|
||||
"required) | prohibited (circle in the vee, material removal not allowed)."
|
||||
),
|
||||
),
|
||||
] = "any",
|
||||
process: Annotated[
|
||||
str | None, "Manufacturing method, treatment or coating, e.g. 'milled'."
|
||||
] = None,
|
||||
lay: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Direction of lay: parallel, perpendicular, crossed, multidirectional, "
|
||||
"circular, radial, particulate."
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
allowance: Annotated[
|
||||
float | None, "Machining allowance in mm, written below the extension line."
|
||||
] = None,
|
||||
all_around: Annotated[bool, "Circle at the kink: the requirement applies all around."] = False,
|
||||
leader_to: Annotated[
|
||||
list[float] | None, "[x, y] on the surface; draws a leader with an arrowhead there."
|
||||
] = None,
|
||||
height: Annotated[
|
||||
float,
|
||||
Field(
|
||||
default=3.5,
|
||||
gt=0,
|
||||
description="Text height (mm). ISO 1302 tabulates 2.5, 3.5, 5, 7, 10, 14, 20.",
|
||||
),
|
||||
] = 3.5,
|
||||
rotation: Annotated[float, "Rotate the whole symbol about its apex (degrees CCW)."] = 0.0,
|
||||
standard: Annotated[
|
||||
str, "ISO 21920-1 (default) or ISO 1302 for drawings issued under the older designation."
|
||||
] = "ISO 21920-1",
|
||||
layer: Annotated[
|
||||
str | None, "Override the role layers (DIM for geometry, TEXT for text)."
|
||||
] = None,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Draw the 60° surface-texture vee with its annotation in the standard positions.
|
||||
|
||||
Refusals, all before the first entity reaches the drawing: a text height
|
||||
outside the seven ISO 1302 rows (2.5/3.5/5/7/10/14/20 mm — never
|
||||
interpolated), a `lay` that is not one of the seven ISO 1302 directions of
|
||||
lay, a `machining` outside any/required/prohibited, and a `standard` other
|
||||
than ISO 21920-1 or ISO 1302.
|
||||
"""
|
||||
from engineering.mech.annotate import draw_surface_texture
|
||||
|
||||
await ctx.debug(f"Surface texture symbol at ({x}, {y})")
|
||||
return await draw_surface_texture(
|
||||
_backend(ctx),
|
||||
at=(x, y),
|
||||
leader_to=tuple(leader_to) if leader_to else None,
|
||||
ra=ra,
|
||||
rz=rz,
|
||||
process=process,
|
||||
lay=lay,
|
||||
machining=machining,
|
||||
all_around=all_around,
|
||||
allowance=allowance,
|
||||
height=height,
|
||||
standard=standard,
|
||||
rotation=rotation,
|
||||
layer=layer,
|
||||
)
|
||||
|
||||
|
||||
@cad_tool(
|
||||
summary="Draw an ISO 2553 weld symbol: reference line, identification line, size and pitch.",
|
||||
cost="mutate",
|
||||
)
|
||||
@mcp.tool(
|
||||
annotations={"title": "Weld Symbol (ISO 2553)", "destructiveHint": False},
|
||||
tags={"engineering", "mech"},
|
||||
)
|
||||
async def weld_symbol(
|
||||
x: Annotated[float, "Kink X (WCS) — where the arrow line meets the reference line."],
|
||||
y: Annotated[float, "Kink Y (WCS)."],
|
||||
kind: Annotated[str, "Elementary symbol: square | v | bevel | u | j | fillet."] = "fillet",
|
||||
size: Annotated[
|
||||
float | str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Written before the symbol. A number on a fillet takes the ISO 2553 "
|
||||
"design-throat prefix (5 → 'a5'); pass a string for leg length ('z7')."
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
length: Annotated[float | None, "Weld length (mm), written after the symbol."] = None,
|
||||
pitch: Annotated[
|
||||
float | None, "Pitch (mm) of an intermittent weld, written in brackets after the length."
|
||||
] = None,
|
||||
side: Annotated[
|
||||
str,
|
||||
Field(
|
||||
default="arrow",
|
||||
description=(
|
||||
"arrow (symbol on the reference line) | other (on the dashed "
|
||||
"identification line) | both (symmetrical; the identification line is "
|
||||
"omitted, per ISO 2553:2019)."
|
||||
),
|
||||
),
|
||||
] = "arrow",
|
||||
field_weld: Annotated[bool, "Flag at the kink: weld made on site."] = False,
|
||||
all_around: Annotated[bool, "Circle at the kink: weld all around."] = False,
|
||||
process: Annotated[str | None, "Tail reference, e.g. an ISO 4063 process number."] = None,
|
||||
leader_to: Annotated[
|
||||
list[float] | None, "[x, y] on the joint; draws a leader with an arrowhead there."
|
||||
] = None,
|
||||
height: Annotated[float, Field(default=3.5, gt=0, description="Text height (mm).")] = 3.5,
|
||||
rotation: Annotated[float, "Rotate the whole annotation about the kink (degrees CCW)."] = 0.0,
|
||||
layer: Annotated[
|
||||
str | None, "Override the role layers (DIM for geometry, TEXT for text)."
|
||||
] = None,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Draw an ISO 2553 weld annotation: reference line, identification line, symbol, dimensions.
|
||||
|
||||
Refusals, all before the first entity reaches the drawing: a `kind` outside
|
||||
the six elementary symbols transcribed here (square, v, bevel, u, j,
|
||||
fillet — the rest of the ISO 2553 table is deliberately not shipped rather
|
||||
than guessed), a `side` outside arrow/other/both, a non-positive `height`,
|
||||
and a `pitch` given without a `length`.
|
||||
"""
|
||||
from engineering.mech.annotate import draw_weld_symbol
|
||||
|
||||
await ctx.debug(f"Weld symbol {kind} at ({x}, {y})")
|
||||
return await draw_weld_symbol(
|
||||
_backend(ctx),
|
||||
at=(x, y),
|
||||
kind=kind,
|
||||
leader_to=tuple(leader_to) if leader_to else None,
|
||||
size=size,
|
||||
length=length,
|
||||
pitch=pitch,
|
||||
side=side,
|
||||
field_weld=field_weld,
|
||||
all_around=all_around,
|
||||
process=process,
|
||||
height=height,
|
||||
rotation=rotation,
|
||||
layer=layer,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ── RESOURCES ───────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
"""ISO 21920-1 / ISO 1302 surface texture and ISO 2553 weld symbols.
|
||||
|
||||
Pure geometry is asserted as emitted primitive tuples (counts, roles,
|
||||
coordinates to 1e-9); the async layer is asserted against a real ezdxf
|
||||
document through the `backend` fixture. No screenshots anywhere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.mech.annotate import (
|
||||
ISO1302_SYMBOL_PROPORTIONS,
|
||||
LAY_SYMBOLS,
|
||||
MACHINING_KINDS,
|
||||
SURFACE_HEIGHTS,
|
||||
SURFACE_STANDARDS,
|
||||
surface_texture_prims,
|
||||
symbol_proportions,
|
||||
)
|
||||
from engineering.mech.primitives import Circle, Line, Text
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
EPS = 1e-9
|
||||
K = 1.0 / math.sqrt(3.0) # tan(30 deg): the 60 deg legs' horizontal run per unit rise
|
||||
|
||||
|
||||
def _flat(points):
|
||||
"""Flatten a point sequence: pytest.approx refuses nested structures."""
|
||||
return tuple(float(value) for point in points for value in point)
|
||||
|
||||
|
||||
async def test_the_seven_iso1302_proportion_rows_are_the_transcribed_ones():
|
||||
assert SURFACE_HEIGHTS == (2.5, 3.5, 5.0, 7.0, 10.0, 14.0, 20.0)
|
||||
assert ISO1302_SYMBOL_PROPORTIONS[2.5] == (0.25, 3.5, 7.5)
|
||||
assert ISO1302_SYMBOL_PROPORTIONS[3.5] == (0.35, 5.0, 10.5)
|
||||
assert ISO1302_SYMBOL_PROPORTIONS[5.0] == (0.5, 7.0, 15.0)
|
||||
assert ISO1302_SYMBOL_PROPORTIONS[7.0] == (0.7, 10.0, 21.0)
|
||||
assert ISO1302_SYMBOL_PROPORTIONS[10.0] == (1.0, 14.0, 30.0)
|
||||
assert ISO1302_SYMBOL_PROPORTIONS[14.0] == (1.4, 20.0, 42.0)
|
||||
assert ISO1302_SYMBOL_PROPORTIONS[20.0] == (2.0, 28.0, 60.0)
|
||||
# Every row: line width is h/10 and the minimum overall height is 3h.
|
||||
for height, (width, _h1, h2) in ISO1302_SYMBOL_PROPORTIONS.items():
|
||||
assert width == pytest.approx(height / 10.0, abs=EPS)
|
||||
assert h2 == pytest.approx(3.0 * height, abs=EPS)
|
||||
|
||||
|
||||
async def test_a_text_height_outside_the_table_is_refused_by_name():
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
symbol_proportions(4.0)
|
||||
message = str(excinfo.value)
|
||||
assert "ISO 1302" in message and "4 mm" in message
|
||||
assert "2.5, 3.5, 5, 7, 10, 14, 20" in message
|
||||
|
||||
|
||||
async def test_the_basic_symbol_is_a_60_degree_vee_on_the_dim_role():
|
||||
prims = surface_texture_prims(height=3.5)
|
||||
short, long_ = prims[0], prims[1]
|
||||
assert isinstance(short, Line) and isinstance(long_, Line)
|
||||
assert short.p1 == (0.0, 0.0) and long_.p1 == (0.0, 0.0)
|
||||
assert short.p2 == pytest.approx((-5.0 * K, 5.0), abs=EPS)
|
||||
assert long_.p2 == pytest.approx((10.5 * K, 10.5), abs=EPS)
|
||||
assert short.role == "dim" and long_.role == "dim"
|
||||
# The angle between the two legs is 60 degrees.
|
||||
a = math.atan2(short.p2[1], short.p2[0])
|
||||
b = math.atan2(long_.p2[1], long_.p2[0])
|
||||
assert math.degrees(a - b) == pytest.approx(60.0, abs=1e-9)
|
||||
# No bar, no circle: the basic symbol plus the extension line only.
|
||||
assert [type(p).__name__ for p in prims] == ["Line", "Line", "Line"]
|
||||
|
||||
|
||||
async def test_machining_required_closes_the_vee_with_the_bar():
|
||||
prims = surface_texture_prims(height=3.5, machining="required")
|
||||
bar = prims[2]
|
||||
assert isinstance(bar, Line)
|
||||
assert bar.p1 == pytest.approx((-5.0 * K, 5.0), abs=EPS)
|
||||
assert bar.p2 == pytest.approx((5.0 * K, 5.0), abs=EPS)
|
||||
assert bar.role == "dim"
|
||||
|
||||
|
||||
async def test_machining_prohibited_inscribes_the_circle_in_the_vee():
|
||||
prims = surface_texture_prims(height=3.5, machining="prohibited")
|
||||
circle = prims[2]
|
||||
assert isinstance(circle, Circle)
|
||||
assert circle.center == pytest.approx((0.0, 10.0 / 3.0), abs=EPS)
|
||||
assert circle.radius == pytest.approx(5.0 / 3.0, abs=EPS)
|
||||
# Tangent to the bar height H1 and to both legs.
|
||||
assert circle.center[1] + circle.radius == pytest.approx(5.0, abs=EPS)
|
||||
|
||||
|
||||
async def test_ra_rz_process_lay_and_allowance_land_in_their_declared_positions():
|
||||
prims = surface_texture_prims(
|
||||
height=3.5, ra=3.2, rz=12.5, process="milled", lay="perpendicular", allowance=0.5
|
||||
)
|
||||
texts = [p for p in prims if isinstance(p, Text)]
|
||||
assert [t.text for t in texts] == ["Ra 3.2", "Rz 12.5", "milled", "⊥ 0.5"]
|
||||
x_leg = 10.5 * K
|
||||
for t in texts:
|
||||
assert t.at[0] == pytest.approx(x_leg + 0.5 * 3.5, abs=EPS)
|
||||
assert t.height == 3.5 and t.role == "text"
|
||||
# Above the extension line the three lines stack upward at 1.5h pitch.
|
||||
assert texts[0].at[1] == pytest.approx(10.5 + 0.35 * 3.5, abs=EPS)
|
||||
assert texts[1].at[1] == pytest.approx(10.5 + 0.35 * 3.5 + 1.5 * 3.5, abs=EPS)
|
||||
assert texts[2].at[1] == pytest.approx(10.5 + 0.35 * 3.5 + 3.0 * 3.5, abs=EPS)
|
||||
# Lay and allowance sit below it, on one line.
|
||||
assert texts[3].at[1] == pytest.approx(10.5 - 0.35 * 3.5 - 3.5, abs=EPS)
|
||||
|
||||
|
||||
async def test_the_extension_line_grows_with_the_widest_text():
|
||||
prims = surface_texture_prims(height=3.5, ra=3.2)
|
||||
extension = [p for p in prims if isinstance(p, Line)][-1]
|
||||
x_leg = 10.5 * K
|
||||
expected = 0.62 * 3.5 * len("Ra 3.2") + 3.5
|
||||
assert extension.p1 == pytest.approx((x_leg, 10.5), abs=EPS)
|
||||
assert extension.p2 == pytest.approx((x_leg + expected, 10.5), abs=EPS)
|
||||
# With no text at all it falls back to the declared minimum of 2h.
|
||||
bare = [p for p in surface_texture_prims(height=3.5) if isinstance(p, Line)][-1]
|
||||
assert bare.p2[0] - bare.p1[0] == pytest.approx(2.0 * 3.5, abs=EPS)
|
||||
|
||||
|
||||
async def test_all_around_puts_the_circle_on_the_kink():
|
||||
prims = surface_texture_prims(height=3.5, all_around=True)
|
||||
circle = [p for p in prims if isinstance(p, Circle)][0]
|
||||
assert circle.center == pytest.approx((10.5 * K, 10.5), abs=EPS)
|
||||
assert circle.radius == pytest.approx(0.35 * 3.5, abs=EPS)
|
||||
|
||||
|
||||
async def test_iso1302_is_accepted_as_an_alias_designation_and_draws_the_same_symbol():
|
||||
assert SURFACE_STANDARDS == ("ISO 21920-1", "ISO 1302")
|
||||
new = surface_texture_prims(height=3.5, ra=3.2, standard="ISO 21920-1")
|
||||
old = surface_texture_prims(height=3.5, ra=3.2, standard="ISO 1302")
|
||||
assert new == old
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
surface_texture_prims(height=3.5, standard="ASME B46.1")
|
||||
assert "ISO 21920-1, ISO 1302" in str(excinfo.value)
|
||||
|
||||
|
||||
async def test_an_undefined_lay_and_an_undefined_machining_are_refused_with_the_list():
|
||||
assert set(LAY_SYMBOLS) == {
|
||||
"parallel",
|
||||
"perpendicular",
|
||||
"crossed",
|
||||
"multidirectional",
|
||||
"circular",
|
||||
"radial",
|
||||
"particulate",
|
||||
}
|
||||
assert MACHINING_KINDS == ("any", "required", "prohibited")
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
surface_texture_prims(lay="diagonal")
|
||||
assert "circular, crossed, multidirectional" in str(excinfo.value)
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
surface_texture_prims(machining="maybe")
|
||||
assert "any, required, prohibited" in str(excinfo.value)
|
||||
|
||||
|
||||
# ── ISO 2553 weld symbols ───────────────────────────────────────────────────
|
||||
|
||||
from engineering.mech.annotate import ( # noqa: E402 - grouped with its own tests
|
||||
WELD_KINDS,
|
||||
WELD_SIDES,
|
||||
weld_symbol_prims,
|
||||
)
|
||||
from engineering.mech.primitives import Arc, Poly # noqa: E402
|
||||
|
||||
|
||||
async def test_only_the_six_transcribed_elementary_symbols_are_offered():
|
||||
assert WELD_KINDS == ("square", "v", "bevel", "u", "j", "fillet")
|
||||
assert WELD_SIDES == ("arrow", "other", "both")
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
weld_symbol_prims(kind="spot")
|
||||
message = str(excinfo.value)
|
||||
assert "square, v, bevel, u, j, fillet" in message
|
||||
assert "ISO 2553" in message
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
weld_symbol_prims(kind="fillet", side="both_sides")
|
||||
assert "arrow, other, both" in str(excinfo.value)
|
||||
|
||||
|
||||
async def test_the_reference_line_comes_first_and_the_identification_line_is_dashed():
|
||||
prims = weld_symbol_prims(kind="fillet", height=3.5)
|
||||
reference = prims[0]
|
||||
assert isinstance(reference, Line)
|
||||
assert reference.p1 == (0.0, 0.0)
|
||||
assert reference.p2[1] == 0.0 and reference.p2[0] > 0.0
|
||||
assert reference.role == "dim"
|
||||
length = reference.p2[0]
|
||||
dashes = [
|
||||
p for p in prims if isinstance(p, Line) and p.p1[1] == pytest.approx(-0.4 * 3.5, abs=EPS)
|
||||
]
|
||||
assert len(dashes) >= 2
|
||||
assert all(d.p2[1] == pytest.approx(-0.4 * 3.5, abs=EPS) for d in dashes)
|
||||
assert dashes[0].p1[0] == pytest.approx(0.0, abs=EPS)
|
||||
assert dashes[0].p2[0] - dashes[0].p1[0] == pytest.approx(0.8 * 3.5, abs=EPS)
|
||||
assert dashes[1].p1[0] - dashes[0].p2[0] == pytest.approx(0.4 * 3.5, abs=EPS)
|
||||
assert dashes[-1].p2[0] <= length + EPS
|
||||
|
||||
|
||||
async def test_a_symmetrical_weld_omits_the_identification_line():
|
||||
prims = weld_symbol_prims(kind="v", side="both", height=3.5)
|
||||
assert not [
|
||||
p for p in prims if isinstance(p, Line) and p.p1[1] == pytest.approx(-0.4 * 3.5, abs=EPS)
|
||||
]
|
||||
# One V above the reference line, one V below the identification offset.
|
||||
vees = [p for p in prims if isinstance(p, Poly) and len(p.points) == 3 and not p.closed]
|
||||
assert len(vees) == 2
|
||||
assert vees[0].points[1][1] == pytest.approx(0.0, abs=EPS)
|
||||
assert vees[1].points[1][1] == pytest.approx(-0.4 * 3.5, abs=EPS)
|
||||
|
||||
|
||||
async def test_the_fillet_triangle_stands_on_the_line_with_its_leg_on_the_left():
|
||||
prims = weld_symbol_prims(kind="fillet", height=3.5)
|
||||
triangle = [p for p in prims if isinstance(p, Poly) and p.closed][0]
|
||||
x0 = triangle.points[0][0]
|
||||
assert _flat(triangle.points) == pytest.approx((x0, 0.0, x0, 3.5, x0 + 3.5, 0.0), abs=EPS)
|
||||
assert triangle.role == "dim"
|
||||
|
||||
|
||||
async def test_the_other_side_symbol_hangs_below_the_identification_line():
|
||||
prims = weld_symbol_prims(kind="fillet", side="other", height=3.5)
|
||||
triangle = [p for p in prims if isinstance(p, Poly) and p.closed][0]
|
||||
x0 = triangle.points[0][0]
|
||||
assert _flat(triangle.points) == pytest.approx(
|
||||
(x0, -1.4, x0, -1.4 - 3.5, x0 + 3.5, -1.4), abs=EPS
|
||||
)
|
||||
|
||||
|
||||
async def test_the_u_symbol_is_a_half_circle_between_two_uprights():
|
||||
prims = weld_symbol_prims(kind="u", height=3.5)
|
||||
arc = [p for p in prims if isinstance(p, Arc)][0]
|
||||
assert arc.radius == pytest.approx(1.75, abs=EPS)
|
||||
assert (arc.start_deg, arc.end_deg) == (180.0, 360.0)
|
||||
assert arc.center[1] == pytest.approx(1.75, abs=EPS)
|
||||
mirrored = [
|
||||
p for p in weld_symbol_prims(kind="u", side="other", height=3.5) if isinstance(p, Arc)
|
||||
][0]
|
||||
assert (mirrored.start_deg, mirrored.end_deg) == (0.0, 180.0)
|
||||
assert mirrored.center[1] == pytest.approx(-1.4 - 1.75, abs=EPS)
|
||||
|
||||
|
||||
async def test_size_is_written_before_the_symbol_and_length_and_pitch_after_it():
|
||||
prims = weld_symbol_prims(kind="fillet", size=5, length=50, pitch=100, height=3.5)
|
||||
texts = [p for p in prims if isinstance(p, Text)]
|
||||
assert [t.text for t in texts] == ["a5", "50 (100)"]
|
||||
triangle = [p for p in prims if isinstance(p, Poly) and p.closed][0]
|
||||
x_symbol = triangle.points[0][0]
|
||||
assert texts[0].at[0] < x_symbol
|
||||
assert texts[1].at[0] > x_symbol + 3.5
|
||||
# A string size is written verbatim, so z-notation stays available.
|
||||
verbatim = weld_symbol_prims(kind="fillet", size="z7", height=3.5)
|
||||
assert [p.text for p in verbatim if isinstance(p, Text)] == ["z7"]
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
weld_symbol_prims(kind="fillet", pitch=100, height=3.5)
|
||||
assert "pitch needs length" in str(excinfo.value)
|
||||
|
||||
|
||||
async def test_field_weld_flag_and_all_around_circle_sit_on_the_kink():
|
||||
prims = weld_symbol_prims(kind="fillet", field_weld=True, all_around=True, height=3.5)
|
||||
pole = [p for p in prims if isinstance(p, Line) and p.p1 == (0.0, 0.0) and p.p2[0] == 0.0][0]
|
||||
assert pole.p2 == pytest.approx((0.0, 1.4 * 3.5), abs=EPS)
|
||||
flag = [p for p in prims if isinstance(p, Poly) and p.closed and p.points[0][0] == 0.0][0]
|
||||
assert _flat(flag.points) == pytest.approx(
|
||||
(
|
||||
0.0,
|
||||
1.4 * 3.5,
|
||||
0.7 * 3.5,
|
||||
1.4 * 3.5 - 0.25 * 3.5,
|
||||
0.0,
|
||||
1.4 * 3.5 - 0.5 * 3.5,
|
||||
),
|
||||
abs=EPS,
|
||||
)
|
||||
circle = [p for p in prims if isinstance(p, Circle)][0]
|
||||
assert circle.center == (0.0, 0.0)
|
||||
assert circle.radius == pytest.approx(0.4 * 3.5, abs=EPS)
|
||||
|
||||
|
||||
async def test_a_process_reference_gets_the_tail_fork_at_the_far_end():
|
||||
prims = weld_symbol_prims(kind="fillet", process="ISO 4063 - 135", height=3.5)
|
||||
reference = prims[0]
|
||||
length = reference.p2[0]
|
||||
fork = [
|
||||
p for p in prims if isinstance(p, Line) and p.p1 == pytest.approx((length, 0.0), abs=EPS)
|
||||
]
|
||||
assert len(fork) == 2
|
||||
assert fork[0].p2 == pytest.approx((length + 0.8 * 3.5, 0.5 * 3.5), abs=EPS)
|
||||
assert fork[1].p2 == pytest.approx((length + 0.8 * 3.5, -0.5 * 3.5), abs=EPS)
|
||||
tail_text = [p for p in prims if isinstance(p, Text)][-1]
|
||||
assert tail_text.text == "ISO 4063 - 135"
|
||||
assert tail_text.at[0] == pytest.approx(length + 3.5, abs=EPS)
|
||||
|
||||
|
||||
# ── the async layer: primitives -> a real ezdxf document ────────────────────
|
||||
|
||||
from engineering.mech.annotate import ( # noqa: E402 - grouped with its own tests
|
||||
draw_annotation_prims,
|
||||
draw_surface_texture,
|
||||
draw_weld_symbol,
|
||||
leader_prims,
|
||||
)
|
||||
from engineering.mech.primitives import ROLE_LAYER # noqa: E402
|
||||
|
||||
|
||||
async def _types_and_layers(backend, handles):
|
||||
rows = []
|
||||
for handle in handles:
|
||||
info = await backend.entity_get(handle)
|
||||
rows.append((info.type, info.layer))
|
||||
return rows
|
||||
|
||||
|
||||
async def test_draw_annotation_prims_puts_every_role_on_its_own_layer(backend):
|
||||
prims = (
|
||||
Line((0.0, 0.0), (10.0, 0.0), "dim"),
|
||||
Circle((5.0, 5.0), 2.0, "dim"),
|
||||
Text((0.0, 12.0), "Ra 3.2", 3.5),
|
||||
)
|
||||
result = await draw_annotation_prims(backend, prims, at=(100.0, 50.0))
|
||||
assert result["ok"] is True and result["count"] == 3
|
||||
assert await _types_and_layers(backend, result["handles"]) == [
|
||||
("LINE", ROLE_LAYER["dim"]),
|
||||
("CIRCLE", ROLE_LAYER["dim"]),
|
||||
("TEXT", ROLE_LAYER["text"]),
|
||||
]
|
||||
line = await backend.entity_get(result["handles"][0])
|
||||
assert line.properties["start"] == pytest.approx([100.0, 50.0], abs=EPS)
|
||||
assert line.properties["end"] == pytest.approx([110.0, 50.0], abs=EPS)
|
||||
circle = await backend.entity_get(result["handles"][1])
|
||||
assert circle.properties["center"] == pytest.approx([105.0, 55.0], abs=EPS)
|
||||
|
||||
|
||||
async def test_an_explicit_layer_overrides_every_role(backend):
|
||||
result = await draw_annotation_prims(
|
||||
backend,
|
||||
(Line((0.0, 0.0), (1.0, 0.0), "dim"), Text((0.0, 2.0), "x", 3.5)),
|
||||
layer="ANNOT",
|
||||
)
|
||||
assert {layer for _type, layer in await _types_and_layers(backend, result["handles"])} == {
|
||||
"ANNOT"
|
||||
}
|
||||
|
||||
|
||||
async def test_leader_prims_reuse_the_repository_arrowhead_proportions():
|
||||
line, head = leader_prims((0.0, 0.0), (10.0, 0.0), arrow_size=2.5)
|
||||
assert isinstance(line, Line) and isinstance(head, Poly) and head.closed
|
||||
assert _flat(head.points) == pytest.approx(
|
||||
(0.0, 0.0, 2.5, 2.5 * 0.35, 2.5, -2.5 * 0.35), abs=EPS
|
||||
)
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
leader_prims((1.0, 1.0), (1.0, 1.0))
|
||||
assert "distinct" in str(excinfo.value)
|
||||
|
||||
|
||||
async def test_draw_surface_texture_draws_the_symbol_and_its_leader(backend):
|
||||
result = await draw_surface_texture(
|
||||
backend, at=(40.0, 60.0), leader_to=(20.0, 50.0), ra=3.2, machining="required"
|
||||
)
|
||||
assert result["ok"] is True and result["standard"] == "ISO 21920-1"
|
||||
rows = await _types_and_layers(backend, result["handles"])
|
||||
# 3 vee/bar lines + extension line + the Ra text + leader line + arrowhead.
|
||||
assert [t for t, _ in rows] == [
|
||||
"LINE",
|
||||
"LINE",
|
||||
"LINE",
|
||||
"LINE",
|
||||
"TEXT",
|
||||
"LINE",
|
||||
"LWPOLYLINE",
|
||||
]
|
||||
apex = await backend.entity_get(result["handles"][0])
|
||||
assert apex.properties["start"] == pytest.approx([40.0, 60.0], abs=EPS)
|
||||
leader = await backend.entity_get(result["handles"][5])
|
||||
assert leader.properties["start"] == pytest.approx([20.0, 50.0], abs=EPS)
|
||||
assert leader.properties["end"] == pytest.approx([40.0, 60.0], abs=EPS)
|
||||
|
||||
|
||||
async def test_draw_weld_symbol_refuses_an_unknown_kind_before_touching_the_drawing(backend):
|
||||
before = (await backend.analysis_stats())["total_entities"]
|
||||
with pytest.raises(ValueError):
|
||||
await draw_weld_symbol(backend, at=(0.0, 0.0), kind="seam")
|
||||
assert (await backend.analysis_stats())["total_entities"] == before
|
||||
|
||||
|
||||
async def test_draw_weld_symbol_places_the_kink_at_the_given_point(backend):
|
||||
result = await draw_weld_symbol(
|
||||
backend, at=(10.0, 20.0), kind="fillet", size=5, length=50, leader_to=(0.0, 10.0)
|
||||
)
|
||||
assert result["ok"] is True
|
||||
reference = await backend.entity_get(result["handles"][0])
|
||||
assert reference.properties["start"] == pytest.approx([10.0, 20.0], abs=EPS)
|
||||
assert reference.properties["end"][1] == pytest.approx(20.0, abs=EPS)
|
||||
texts = []
|
||||
for handle in result["handles"]:
|
||||
info = await backend.entity_get(handle)
|
||||
if info.type == "TEXT":
|
||||
texts.append(info.properties["text"])
|
||||
assert texts == ["a5", "50"]
|
||||
@@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
|
||||
|
||||
|
||||
async def test_tool_group_sizes_are_unchanged():
|
||||
"""Frozen snapshot of the surface (204 tools, 23 groups).
|
||||
"""Frozen snapshot of the surface (206 tools, 23 groups).
|
||||
|
||||
Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when
|
||||
v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and
|
||||
@@ -529,6 +529,11 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
their secondary tags; those counts returned to their pre-track-E values
|
||||
when the section tags landed.) Every other number here has been unchanged
|
||||
since the snapshot was taken.
|
||||
|
||||
`engineering` moved 10 -> 12 when v1.6's SECTION 23 opened with
|
||||
`surface_texture` / `weld_symbol`; both are tagged `engineering` and `mech`,
|
||||
and `mech` is not in `_GROUP_TAG_PRIORITY`, so they file under
|
||||
`engineering` rather than opening a group of their own.
|
||||
"""
|
||||
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||
assert sizes == {
|
||||
@@ -538,7 +543,7 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
"corner_ops": 4,
|
||||
"dimensions": 5,
|
||||
"drawing": 11,
|
||||
"engineering": 10,
|
||||
"engineering": 12,
|
||||
"entity_creation": 18,
|
||||
"entity_modification": 16,
|
||||
"entity_query": 9,
|
||||
@@ -556,4 +561,4 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
"validation": 1,
|
||||
"view": 4,
|
||||
}
|
||||
assert sum(sizes.values()) == 204
|
||||
assert sum(sizes.values()) == 206
|
||||
|
||||
Reference in New Issue
Block a user