diff --git a/README.md b/README.md
index 6d056a1..a632df5 100644
--- a/README.md
+++ b/README.md
@@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
-> **v1.5 release snapshot:** 204 tools · 8 resources · 5 prompt templates · 3284 collected tests.
-> 204 is the **registered** count; a default install advertises 199 over `tools/list`,
+> **v1.5 release snapshot:** 210 tools · 8 resources · 5 prompt templates · 3284 collected tests.
+> 210 is the **registered** count; a default install advertises 205 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
## Why this exists
@@ -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 |
-204 tools in 23 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates.
+210 tools in 24 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates.
**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.
diff --git a/discovery/aliases.py b/discovery/aliases.py
index f139c03..5f0df86 100644
--- a/discovery/aliases.py
+++ b/discovery/aliases.py
@@ -1859,6 +1859,96 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"save a detail view",
),
),
+ # ── Mechanical parts (SECTION 21, tracks B+G group M) ───────────────────
+ "mech_part_draw": ToolAliases(
+ acad=(),
+ synonyms=(
+ "draw a shaft",
+ "stepped shaft",
+ "draw a machine part",
+ "turned part",
+ "draw a plate",
+ "flange",
+ "part from a segment list",
+ "shaft with a keyway",
+ "mil",
+ "mil ciz",
+ "kademeli mil",
+ "kama",
+ "pah",
+ "plaka",
+ "flans",
+ ),
+ ),
+ "mech_view_add": ToolAliases(
+ acad=("SECTIONPLANE",),
+ synonyms=(
+ "add a section view",
+ "section a-a",
+ "cross section",
+ "half section",
+ "offset section",
+ "detail view",
+ "blown-up detail",
+ "end view",
+ "side view",
+ "another view of the part",
+ "kesit",
+ "kesit al",
+ "gorunus",
+ "yan gorunus",
+ "detay",
+ ),
+ ),
+ "mech_dimension_part": ToolAliases(
+ acad=(),
+ synonyms=(
+ "dimension the part",
+ "put the dimensions on",
+ "chain dimensions",
+ "baseline dimensions",
+ "ordinate dimensions",
+ "hole table",
+ "iso 286 fit on a dimension",
+ "h7 bore",
+ "olculendir",
+ "olcu ver",
+ "tolerans",
+ ),
+ ),
+ "mech_hole_pattern": ToolAliases(
+ acad=("ARRAY",),
+ synonyms=(
+ "bolt circle",
+ "hole pattern",
+ "hole array",
+ "pitch circle of holes",
+ "grid of holes",
+ "delik deseni",
+ "civata dairesi",
+ "delik aynasi",
+ ),
+ ),
+ "mech_part_from_spec": ToolAliases(
+ acad=(),
+ synonyms=(
+ "draw the whole sheet",
+ "several parts at once",
+ "mechanical sheet from a spec",
+ "batch draw parts",
+ "parca sayfasi",
+ ),
+ ),
+ "mech_part_inspect": ToolAliases(
+ acad=(),
+ synonyms=(
+ "what part is this",
+ "read the part model back",
+ "part features",
+ "which views were drawn",
+ "parca bilgisi",
+ ),
+ ),
}
diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json
index c5c16f1..55bb8ab 100644
--- a/docs/tool-inventory.json
+++ b/docs/tool-inventory.json
@@ -3,10 +3,10 @@
"schema_version": 1,
"package_version": "1.5.1",
"totals": {
- "tools": 204,
+ "tools": 210,
"resources": 8,
"prompts": 5,
- "groups": 23
+ "groups": 24
},
"groups": {
"analysis": 12,
@@ -22,6 +22,7 @@
"environment": 22,
"layers": 14,
"layouts": 12,
+ "mech": 6,
"page_setup": 6,
"pid": 9,
"premium": 12,
@@ -1968,6 +1969,90 @@
"readOnlyHint": false
}
},
+ {
+ "name": "mech_dimension_part",
+ "group": "mech",
+ "cost": "mutate",
+ "summary": "Dimension a drawn part: ISO 129 dimensions, ISO 286 fits, a hole table.",
+ "tags": [
+ "dimension",
+ "mech"
+ ],
+ "annotations": {
+ "title": "Mechanical: Dimension Part",
+ "destructiveHint": false
+ }
+ },
+ {
+ "name": "mech_hole_pattern",
+ "group": "mech",
+ "cost": "mutate",
+ "summary": "Draw a linear, grid or polar hole pattern with centre marks on any drawing.",
+ "tags": [
+ "create",
+ "mech"
+ ],
+ "annotations": {
+ "title": "Mechanical: Hole Pattern",
+ "destructiveHint": false
+ }
+ },
+ {
+ "name": "mech_part_draw",
+ "group": "mech",
+ "cost": "mutate",
+ "summary": "Draw a machine part from a segment list or an outline plus typed features.",
+ "tags": [
+ "create",
+ "mech"
+ ],
+ "annotations": {
+ "title": "Mechanical: Draw Part",
+ "destructiveHint": false
+ }
+ },
+ {
+ "name": "mech_part_from_spec",
+ "group": "mech",
+ "cost": "mutate",
+ "summary": "Draw a whole mechanical sheet - several parts and their views - in one transaction.",
+ "tags": [
+ "create",
+ "mech"
+ ],
+ "annotations": {
+ "title": "Mechanical: Sheet From Spec",
+ "destructiveHint": false
+ }
+ },
+ {
+ "name": "mech_part_inspect",
+ "group": "mech",
+ "cost": "read",
+ "summary": "Read the part model back off the drawing: segments, features, views, omissions.",
+ "tags": [
+ "mech",
+ "query"
+ ],
+ "annotations": {
+ "title": "Mechanical: Inspect Part",
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "mech_view_add",
+ "group": "mech",
+ "cost": "mutate",
+ "summary": "Add another view of a drawn part: side, top, section (full/half/offset) or detail.",
+ "tags": [
+ "create",
+ "mech"
+ ],
+ "annotations": {
+ "title": "Mechanical: Add View",
+ "destructiveHint": false
+ }
+ },
{
"name": "mleaderstyle_create",
"group": "styles",
diff --git a/engineering/mech/__init__.py b/engineering/mech/__init__.py
index 157ce19..2afd332 100644
--- a/engineering/mech/__init__.py
+++ b/engineering/mech/__init__.py
@@ -6,6 +6,73 @@ and alphabetical — keep every side's names.
from __future__ import annotations
+from .dimension import (
+ DIM_STYLES,
+ REFERENCE_FORMAT,
+ TEXT_WIDTH_FACTOR,
+ TOL_KEYS,
+ dimension_intents,
+ hole_table_rows,
+ intent_text,
+ is_reference,
+ layout_dimensions,
+ measured_value,
+ redundant_linear,
+ resolve_tolerance,
+ segment_key,
+ text_rects,
+)
+from .draw import (
+ ANCHOR_LAYER,
+ add_view,
+ dimension_part,
+ draw_from_spec,
+ draw_hole_pattern,
+ draw_part,
+ draw_prims,
+ read_part,
+)
+from .features import (
+ FEATURE_KINDS,
+ AxialBore,
+ BendLine,
+ CentreHole,
+ Chamfer,
+ CornerChamfer,
+ CornerFillet,
+ CrossHole,
+ Feature,
+ Fillet,
+ GearTeeth,
+ Hole,
+ HoleCircle,
+ HolePattern,
+ Keyway,
+ ORingGroove,
+ Pocket,
+ RetainingGroove,
+ Slot,
+ Thread,
+ Undercut,
+ feature_from_dict,
+ mirror_about_x,
+)
+from .part import (
+ PART_KINDS,
+ PrismaticPart,
+ RevolvedPart,
+ Segment,
+ build_part,
+ inner_radius_at,
+ outer_radius_at,
+ outline_bbox,
+ part_length,
+ part_max_diameter,
+ part_to_dict,
+ point_in_outline,
+ segment_bounds,
+ validate_part,
+)
from .primitives import (
ROLE_LAYER,
ROLES,
@@ -26,24 +93,121 @@ from .primitives import (
scale,
translate,
)
+from .views import (
+ CIRCLE_SEGMENTS,
+ PROJECTIONS,
+ SECTION_PLANE,
+ SECTION_STYLES,
+ VIEW_KINDS,
+ Band,
+ View,
+ build_view,
+ cut_loops,
+ detail_marker_prims,
+ detail_view,
+ layout_origin,
+ normalise_plane,
+ outline_prims,
+ radial_profile,
+ section_profile_prims,
+ section_view,
+ silhouette_prims,
+)
__all__ = [
- "ROLES",
- "ROLE_LAYER",
+ "ANCHOR_LAYER",
"Arc",
+ "AxialBore",
+ "Band",
+ "BendLine",
+ "CIRCLE_SEGMENTS",
+ "CentreHole",
+ "Chamfer",
"Circle",
+ "CornerChamfer",
+ "CornerFillet",
+ "CrossHole",
+ "DIM_STYLES",
"DimIntent",
+ "FEATURE_KINDS",
+ "Feature",
+ "Fillet",
+ "GearTeeth",
"HatchArea",
+ "Hole",
+ "HoleCircle",
+ "HolePattern",
+ "Keyway",
"Line",
+ "ORingGroove",
+ "PART_KINDS",
+ "PROJECTIONS",
+ "Pocket",
"Poly",
"Prim",
+ "PrismaticPart",
"Pt",
+ "REFERENCE_FORMAT",
+ "ROLES",
+ "ROLE_LAYER",
+ "RetainingGroove",
+ "RevolvedPart",
"Role",
+ "SECTION_PLANE",
+ "SECTION_STYLES",
+ "Segment",
+ "Slot",
+ "TEXT_WIDTH_FACTOR",
+ "TOL_KEYS",
"Text",
+ "Thread",
+ "Undercut",
+ "VIEW_KINDS",
+ "View",
+ "add_view",
"bbox",
+ "build_part",
+ "build_view",
+ "cut_loops",
+ "detail_marker_prims",
+ "detail_view",
+ "dimension_intents",
+ "dimension_part",
+ "draw_from_spec",
+ "draw_hole_pattern",
+ "draw_part",
+ "draw_prims",
+ "feature_from_dict",
+ "hole_table_rows",
+ "inner_radius_at",
+ "intent_text",
+ "is_reference",
"layer_for",
+ "layout_dimensions",
+ "layout_origin",
+ "measured_value",
+ "mirror_about_x",
+ "normalise_plane",
+ "outer_radius_at",
+ "outline_bbox",
+ "outline_prims",
+ "part_length",
+ "part_max_diameter",
+ "part_to_dict",
+ "point_in_outline",
"points_of",
+ "radial_profile",
+ "read_part",
+ "redundant_linear",
+ "resolve_tolerance",
"rotate",
"scale",
+ "section_profile_prims",
+ "section_view",
+ "segment_bounds",
+ "segment_key",
+ "silhouette_prims",
+ "text_rects",
"translate",
+ "validate_part",
]
diff --git a/engineering/mech/dimension.py b/engineering/mech/dimension.py
new file mode 100644
index 0000000..4e532b1
--- /dev/null
+++ b/engineering/mech/dimension.py
@@ -0,0 +1,650 @@
+"""Dimension intents for a view, laid out so no two texts overlap.
+
+Pure: no backend, no async, no DXF. The features declare what they want
+dimensioned; this module adds the part's own sizes, removes the duplicates
+(ISO 129-1: each dimension appears once), resolves ISO 286 fits and ISO 129
+tolerances through the modules that already own them, and then *places* the
+texts.
+
+The placement is not a matter of taste. Every intent's text is measured into a
+rectangle by :func:`text_rects`, and an intent is put on the lowest free level
+whose rectangle does not overlap one already placed on that level. Two
+rectangles are disjoint when they are separated along *either* axis, so the
+layout separates them along both in turn: within a level, along the axis the
+level runs in; between levels, along the axis the levels stack in. The second
+one is why the level pitch is clamped to the extent of the text *in the
+stacking direction* - which is the text height for the row of dimensions under
+the view, and the text **width** for the column of diameters beside it. A pitch
+clamped only to the height would let two 9.45 mm wide diameter labels sit
+8 mm apart on the same centreline, overlapping by 1.45 mm.
+
+The test asserts pairwise disjointness with the same helper, which makes "no
+overlapping dimension text" measured rather than claimed - and it is the same
+property the repository's `dim_overlap` critique focus checks on the finished
+drawing.
+
+Three rules the module enforces rather than assumes:
+
+*ISO 286 fits reach the pipeline.* :func:`dimension_intents` takes
+``fits={key: code}`` and stamps the code onto the intent it names, so a bearing
+seat gets its k6 without anyone hand-building a ``DimIntent``. The part's own
+diameters are addressable by segment (``"segment[1]"``, ``"segment[1].bore"``);
+a feature's are addressable by its id. An unknown key is refused by name - a fit
+that lands nowhere is a manufacturing requirement lost in silence.
+
+*An explicit tolerance is validated as a whole.* ``DimIntent.tol`` is
+``{"mode", "upper", "lower"}`` and nothing else. ``mode`` is required, because
+``build_dim_override`` reads a missing mode as ``"none"`` and returns an empty
+override: a +-0.05 with no ``mode`` used to resolve, report its own numbers back
+and then draw a dimension carrying no tolerance at all.
+
+*ISO 129-1: each measurement appears once.* A closed chain plus the overall
+states the overall twice. :func:`redundant_linear` finds the span that the
+others tile end to end, and :func:`dimension_intents` marks it as an auxiliary
+(reference) dimension - ``(<>)``, drawn in parentheses - rather than emitting a
+fourth plain DIMENSION that contradicts nothing and adds nothing.
+"""
+
+from __future__ import annotations
+
+import math
+
+from engineering.fits import fit_lookup, parse_fit_code
+from engineering.mech.features import Hole
+from engineering.mech.part import (
+ PrismaticPart,
+ RevolvedPart,
+ outline_bbox,
+ part_length,
+ segment_bounds,
+)
+from engineering.mech.primitives import DimIntent
+from engineering.tolerances import TOL_MODES, build_dim_override
+
+_EPS = 1e-9
+
+DIM_STYLES: tuple[str, ...] = ("chain", "baseline", "ordinate")
+
+#: Mean glyph width as a fraction of the text height, for measuring a label
+#: into a rectangle. Deliberately generous: over-estimating the width can only
+#: push two dimensions further apart.
+TEXT_WIDTH_FACTOR = 0.7
+
+#: Padding added around a measured label, in text heights.
+TEXT_PADDING = 0.6
+
+#: How an auxiliary (reference) dimension is written - ISO 129-1 puts a
+#: measurement that is already given elsewhere in parentheses. ``<>`` is the
+#: DIMENSION text placeholder for the measured value, on both engines.
+REFERENCE_FORMAT = "(<>)"
+
+#: The only keys ``DimIntent.tol`` accepts.
+TOL_KEYS: frozenset[str] = frozenset({"mode", "upper", "lower"})
+
+#: Tolerance modes that write no deviation, so carrying upper/lower with them
+#: would silently discard the numbers.
+_NO_DEVIATION_MODES: frozenset[str] = frozenset({"none", "basic"})
+
+
+def measured_value(intent: DimIntent) -> float:
+ """What the dimension measures, in drawing units."""
+ p1, p2 = intent.p1, intent.p2
+ if intent.kind == "linear":
+ dx, dy = abs(p2[0] - p1[0]), abs(p2[1] - p1[1])
+ return dx if dx >= dy else dy
+ return math.dist(p1, p2)
+
+
+def _format(value: float) -> str:
+ return f"{round(value, 3):g}"
+
+
+def _with_fit(text: str, fit: str | None) -> str:
+ """Append an ISO 286 code to a dimension's text, once.
+
+ A feature that already names itself (a hole's ``⌀6``) keeps its own text and
+ gains the code; without this the deviations would be written and the code
+ the inspector reads would not be on the sheet.
+ """
+ code = (fit or "").strip()
+ if not code or code in text:
+ return text
+ return f"{text} {code}"
+
+
+def _measured_text(intent: DimIntent) -> str:
+ if intent.kind == "diameter":
+ return f"⌀{_format(measured_value(intent))}"
+ if intent.kind == "radius":
+ return f"R{_format(measured_value(intent))}"
+ return _format(measured_value(intent))
+
+
+def intent_text(intent: DimIntent) -> str:
+ """The text a dimension will carry, as the engine will render it.
+
+ ``<>`` is the DIMENSION placeholder for the measured value, so an override
+ of ``"(<>)"`` reads ``"(75)"`` and a resolved fit's ``"<> H7"`` reads
+ ``"⌀40 H7"``. Expanding it here is what makes :func:`text_rects` measure the
+ string that is actually drawn - a label measured as ``"<> H7"`` and drawn as
+ ``"⌀40 H7"`` is a layout computed against the wrong width.
+ """
+ measured = _measured_text(intent)
+ base = intent.text_override.replace("<>", measured) if intent.text_override else measured
+ # The fit code is appended to the text by `resolve_tolerance`; the rectangle
+ # has to reserve room for it before the layout runs.
+ return _with_fit(base, intent.fit)
+
+
+def is_reference(intent: DimIntent) -> bool:
+ """True for an auxiliary (reference) dimension - ISO 129-1 parentheses."""
+ override = intent.text_override or ""
+ return override.startswith("(") and override.endswith(")")
+
+
+def _text_size(intent: DimIntent, height: float) -> tuple[float, float]:
+ """``(width, height)`` of the rectangle this intent's label occupies."""
+ label = intent_text(intent)
+ return (
+ (len(label) * TEXT_WIDTH_FACTOR + TEXT_PADDING) * height,
+ height * (1.0 + TEXT_PADDING),
+ )
+
+
+def text_rects(intents, *, height: float = 3.5):
+ """``(x0, y0, x1, y1)`` per placed intent, from its text and its ``text_at``."""
+ rects = []
+ for intent in intents:
+ if intent.text_at is None:
+ raise ValueError(
+ f"text_rects: intent {intent.feature or intent.kind!r} has no text_at; "
+ "run layout_dimensions first."
+ )
+ width, tall = _text_size(intent, height)
+ cx, cy = float(intent.text_at[0]), float(intent.text_at[1])
+ rects.append((cx - width / 2.0, cy - tall / 2.0, cx + width / 2.0, cy + tall / 2.0))
+ return tuple(rects)
+
+
+# -- tolerances --------------------------------------------------------------
+
+
+def _explicit_tol(intent: DimIntent) -> tuple[str, float | None, float | None]:
+ """Validate ``intent.tol`` as a whole and return ``(mode, upper, lower)``.
+
+ ``build_dim_override`` alone is not enough: it defaults a missing mode to
+ ``"none"`` and answers with an empty override, so ``{"upper": 0.05,
+ "lower": 0.05}`` used to resolve, echo its own numbers back and draw a
+ dimension with no DIMTOL/DIMTP/DIMTM at all. A tolerance that disappears
+ between the spec and the sheet is the silent wrong value this repository
+ refuses, so the schema is checked here, by name.
+ """
+ who = intent.feature or intent.kind
+ tol = intent.tol
+ if not isinstance(tol, dict):
+ raise ValueError(
+ f"{who}.tol: expected a dict with keys {sorted(TOL_KEYS)}, got {type(tol).__name__}."
+ )
+ unknown = set(tol) - TOL_KEYS
+ if unknown:
+ raise ValueError(
+ f"{who}.tol: unknown key(s) {sorted(unknown)}; a tolerance takes {sorted(TOL_KEYS)}."
+ )
+ if "mode" not in tol:
+ raise ValueError(
+ f"{who}.tol: needs an explicit 'mode' (one of {list(TOL_MODES)}). Without it the "
+ "deviations are carried in the payload and no tolerance reaches the drawing."
+ )
+ mode = str(tol["mode"]).strip().lower()
+ upper = tol.get("upper")
+ lower = tol.get("lower")
+ if mode in _NO_DEVIATION_MODES and (upper is not None or lower is not None):
+ raise ValueError(
+ f"{who}.tol: tol_mode={mode!r} writes no deviation, but upper={upper!r} / "
+ f"lower={lower!r} were given. Use 'symmetric', 'deviation' or 'limit', or drop "
+ "the values."
+ )
+ return mode, upper, lower
+
+
+def resolve_tolerance(intent: DimIntent, nominal: float | None = None) -> dict:
+ """Turn an intent's ``fit`` / ``tol`` into the backend dimension call's arguments.
+
+ Mirrors `server.py::_fit_to_tolerances`, including its sign convention:
+ ``build_dim_override`` reads ``tol_lower`` as the *magnitude* of the minus
+ deviation, so a resolved fit passes ``-deviation.lower_mm``.
+ """
+ has_tol = intent.tol is not None
+ if intent.fit and has_tol:
+ raise ValueError(
+ f"{intent.feature or intent.kind}: pass either fit= or an explicit "
+ "tol, not both."
+ )
+ if intent.fit:
+ size = float(nominal) if nominal is not None else measured_value(intent)
+ if size <= _EPS:
+ raise ValueError(
+ f"{intent.feature or intent.kind}: fit {intent.fit!r} needs a nominal size, and "
+ f"the intent measures {size}. Pass nominal= explicitly."
+ )
+ deviation = fit_lookup(intent.fit, size)
+ return {
+ "tol_upper": deviation.upper_mm,
+ "tol_lower": -deviation.lower_mm,
+ "tol_mode": "deviation",
+ "text_override": _with_fit(intent.text_override or "<>", deviation.code),
+ }
+ if has_tol:
+ mode, upper, lower = _explicit_tol(intent)
+ build_dim_override(upper, lower, mode, intent.text_override) # validates, or raises
+ return {
+ "tol_upper": upper,
+ "tol_lower": lower,
+ "tol_mode": mode,
+ "text_override": intent.text_override,
+ }
+ return {
+ "tol_upper": None,
+ "tol_lower": None,
+ "tol_mode": "none",
+ "text_override": intent.text_override,
+ }
+
+
+# -- collecting the intents --------------------------------------------------
+
+
+def _dedupe(intents) -> tuple[DimIntent, ...]:
+ seen: set[tuple] = set()
+ out: list[DimIntent] = []
+ for intent in intents:
+ key = (
+ intent.kind,
+ round(float(intent.p1[0]), 6),
+ round(float(intent.p1[1]), 6),
+ round(float(intent.p2[0]), 6),
+ round(float(intent.p2[1]), 6),
+ )
+ if key in seen:
+ continue
+ seen.add(key)
+ out.append(intent)
+ return tuple(out)
+
+
+def _linear_span(intent: DimIntent) -> tuple[str, float, float, float] | None:
+ """``(axis, level, lo, hi)`` of an axis-parallel linear dimension, else None.
+
+ ``level`` is the coordinate the dimension does *not* run along, so two
+ horizontal dimensions at different heights are never compared with each
+ other. A skewed linear intent has no span: it is not part of a chain.
+ """
+ if intent.kind != "linear":
+ return None
+ (ax, ay), (bx, by) = intent.p1, intent.p2
+ if abs(bx - ax) >= abs(by - ay):
+ if abs(by - ay) > _EPS:
+ return None
+ return ("x", round(float(ay), 6), round(min(ax, bx), 6), round(max(ax, bx), 6))
+ if abs(bx - ax) > _EPS:
+ return None
+ return ("y", round(float(ax), 6), round(min(ay, by), 6), round(max(ay, by), 6))
+
+
+def redundant_linear(intents) -> tuple[int, ...]:
+ """Indices of the linear intents that shorter ones already tile end to end.
+
+ ISO 129-1: a measurement appears once. A closed chain (20 + 40 + 15) that
+ also carries the overall (75) states the overall twice, and the second
+ statement has to be an auxiliary (reference) dimension or be left off.
+
+ The error is one-sided on purpose. A tiling that is found is a real one -
+ the pieces butt, in order, from ``lo`` to ``hi`` on the same axis and level
+ - so a dimension is never called redundant when it is not. A tiling that
+ the greedy walk misses simply leaves the dimension plain, which is the way
+ round the repository's critique rule wants: a check that misses a bad
+ drawing costs a review, a check that calls a good drawing bad gets
+ switched off.
+ """
+ groups: dict[tuple[str, float], list[tuple[float, float, int]]] = {}
+ for index, intent in enumerate(intents):
+ span = _linear_span(intent)
+ if span is None:
+ continue
+ axis, level, lo, hi = span
+ groups.setdefault((axis, level), []).append((lo, hi, index))
+
+ out: list[int] = []
+ for group in groups.values():
+ for lo, hi, index in group:
+ pieces = sorted(
+ (a, b)
+ for a, b, other in group
+ if other != index
+ and a >= lo - 1e-6
+ and b <= hi + 1e-6
+ and (b - a) < (hi - lo) - 1e-6
+ )
+ cursor = lo
+ for a, b in pieces:
+ if abs(a - cursor) <= 1e-6:
+ cursor = b
+ if cursor > lo + 1e-6 and abs(cursor - hi) <= 1e-6:
+ out.append(index)
+ return tuple(sorted(out))
+
+
+def _mark_references(intents: list[DimIntent]) -> list[DimIntent]:
+ """Put the redundant measurements in parentheses, ISO 129-1."""
+ for index in redundant_linear(intents):
+ intent = intents[index]
+ if intent.text_override is None:
+ intents[index] = intent._replace(text_override=REFERENCE_FORMAT)
+ return intents
+
+
+def _apply_fits(intents: list[DimIntent], fits, *, table_ids: frozenset[str]) -> list[DimIntent]:
+ """Stamp each ``{key: ISO 286 code}`` onto the intent its key names.
+
+ Without this the ``fit`` branch of :func:`resolve_tolerance` is reachable
+ only from a hand-built ``DimIntent``: no feature and no segment carries a
+ fit, so the ISO 286 path would be real in the table and absent from every
+ drawing the module actually produces.
+ """
+ if not fits:
+ return intents
+ if not isinstance(fits, dict):
+ raise ValueError(
+ f"fits: expected a dict of {{key: ISO 286 code}}, got {type(fits).__name__}."
+ )
+
+ available = {intent.feature for intent in intents if intent.feature}
+ by_key: dict[str, list[int]] = {}
+ for index, intent in enumerate(intents):
+ if intent.feature:
+ by_key.setdefault(intent.feature, []).append(index)
+
+ for key, code in fits.items():
+ name = str(key)
+ if name not in available:
+ if name in table_ids:
+ raise ValueError(
+ f"fits[{name!r}]: that hole's diameter moved into the hole table, which has "
+ "no tolerance column. Raise hole_table_threshold, or dimension the hole "
+ "directly."
+ )
+ raise ValueError(
+ f"fits[{name!r}]: nothing in this view is dimensioned under that name. "
+ f"Available: {sorted(available)}."
+ )
+ parse_fit_code(str(code)) # refuses a typo here, not at draw time
+ for index in by_key[name]:
+ intent = intents[index]
+ if is_reference(intent):
+ raise ValueError(
+ f"fits[{name!r}]: {intent_text(intent)} is an auxiliary (reference) "
+ "dimension - ISO 129-1 gives it no tolerance. Put the fit on the "
+ "dimension that states the size."
+ )
+ if intent.tol is not None:
+ raise ValueError(
+ f"fits[{name!r}]: that dimension already carries an explicit tol. Pass "
+ "either fit= or an explicit tol, not both."
+ )
+ intents[index] = intent._replace(fit=str(code))
+ return intents
+
+
+def _revolved_axial(part: RevolvedPart, style: str) -> list[DimIntent]:
+ stations = [0.0] + [x1 for _, x1 in segment_bounds(part)]
+ intents: list[DimIntent] = []
+ if style == "chain":
+ for a, b in zip(stations, stations[1:], strict=False):
+ intents.append(DimIntent(kind="linear", p1=(a, 0.0), p2=(b, 0.0), feature="part"))
+ intents.append(
+ DimIntent(kind="linear", p1=(0.0, 0.0), p2=(part_length(part), 0.0), feature="part")
+ )
+ else: # baseline and ordinate both measure from the left face
+ for station in stations[1:]:
+ intents.append(
+ DimIntent(kind="linear", p1=(0.0, 0.0), p2=(station, 0.0), feature="part")
+ )
+ return intents
+
+
+def segment_key(index: int, *, bore: bool = False) -> str:
+ """The ``fits`` key that names one segment's outer diameter, or its bore.
+
+ The part's own diameters have to be addressable one at a time: "ISO 286
+ fits on the bearing seats" means a code on *segment 1*, not on every
+ dimension the part emits.
+ """
+ return f"segment[{int(index)}].bore" if bore else f"segment[{int(index)}]"
+
+
+def _revolved_diameters(part: RevolvedPart) -> list[DimIntent]:
+ intents: list[DimIntent] = []
+ bounds = zip(segment_bounds(part), part.segments, strict=True)
+ for index, ((x0, x1), segment) in enumerate(bounds):
+ mid = (x0 + x1) / 2.0
+ radius = segment.d_outer / 2.0
+ intents.append(
+ DimIntent(
+ kind="diameter",
+ p1=(mid, radius),
+ p2=(mid, -radius),
+ feature=segment_key(index),
+ )
+ )
+ if segment.d_inner > _EPS:
+ bore = segment.d_inner / 2.0
+ intents.append(
+ DimIntent(
+ kind="diameter",
+ p1=(mid, bore),
+ p2=(mid, -bore),
+ feature=segment_key(index, bore=True),
+ )
+ )
+ return intents
+
+
+def _hole_groups(part) -> dict[tuple, list[Hole]]:
+ groups: dict[tuple, list[Hole]] = {}
+ for feature in getattr(part, "features", ()):
+ if not isinstance(feature, Hole):
+ continue
+ key = (
+ round(float(feature.diameter), 6),
+ feature.thread or "",
+ None if feature.cbore_d is None else round(float(feature.cbore_d), 6),
+ None if feature.depth is None else round(float(feature.depth), 6),
+ )
+ groups.setdefault(key, []).append(feature)
+ return groups
+
+
+def dimension_intents(
+ part,
+ view,
+ *,
+ style: str = "chain",
+ hole_table_threshold: int = 8,
+ fits: dict | None = None,
+) -> tuple[DimIntent, ...]:
+ """Every dimension this view wants: the features' own, plus the part's sizes.
+
+ ``fits`` maps a dimension's name to an ISO 286 code - a feature id, or one
+ of the part's own diameters by :func:`segment_key` (``"segment[1]"``,
+ ``"segment[1].bore"``). The code is stamped onto the matching intent, so
+ :func:`resolve_tolerance` resolves it and :func:`layout_dimensions` reserves
+ room for the widened text. A key that names nothing is refused with the
+ names that exist.
+
+ ISO 129-1 is applied last: a measurement the other dimensions already tile
+ end to end is marked as an auxiliary (reference) dimension, in parentheses,
+ instead of being drawn a second time as a plain DIMENSION.
+ """
+ name = str(style or "").strip().lower()
+ if name not in DIM_STYLES:
+ raise ValueError(f"style: expected one of {DIM_STYLES}, got {style!r}.")
+
+ intents: list[DimIntent] = list(view.dims)
+ kind = "front" if view.kind == "section" else view.kind
+
+ if isinstance(part, RevolvedPart):
+ if kind == "front":
+ intents.extend(_revolved_axial(part, name))
+ intents.extend(_revolved_diameters(part))
+ elif kind == "side":
+ last = len(part.segments) - 1
+ near = part.segments[last]
+ radius = near.d_outer / 2.0
+ intents.append(
+ DimIntent(
+ kind="diameter",
+ p1=(-radius, 0.0),
+ p2=(radius, 0.0),
+ feature=segment_key(last),
+ )
+ )
+ if near.d_inner > _EPS:
+ bore = near.d_inner / 2.0
+ intents.append(
+ DimIntent(
+ kind="diameter",
+ p1=(-bore, 0.0),
+ p2=(bore, 0.0),
+ feature=segment_key(last, bore=True),
+ )
+ )
+ elif isinstance(part, PrismaticPart):
+ x0, y0, x1, y1 = outline_bbox(part)
+ if kind == "front":
+ intents.append(DimIntent(kind="linear", p1=(x0, y0), p2=(x1, y0), feature="part"))
+ intents.append(DimIntent(kind="linear", p1=(x0, y0), p2=(x0, y1), feature="part"))
+ else:
+ # An edge view sees the thickness, measured at the near edge of
+ # whichever outline axis this view runs along.
+ u0 = y0 if kind == "side" else x0
+ intents.append(
+ DimIntent(
+ kind="linear",
+ p1=(u0, 0.0),
+ p2=(u0, float(part.thickness)),
+ feature="part",
+ )
+ )
+
+ groups = _hole_groups(part)
+ total_holes = sum(len(holes) for holes in groups.values())
+ table_ids: frozenset[str] = frozenset()
+ if total_holes > int(hole_table_threshold):
+ table_ids = frozenset(hole.id for holes in groups.values() for hole in holes)
+ intents = [i for i in intents if i.feature not in table_ids]
+
+ collected = _mark_references(list(_dedupe(intents)))
+ return tuple(_apply_fits(collected, fits, table_ids=table_ids))
+
+
+def hole_table_rows(part, view, *, hole_table_threshold: int = 8) -> tuple[dict, ...]:
+ """One row per hole, tagged and counted by group - empty under the threshold.
+
+ ``view`` is part of the pinned signature and is accepted unused, so a later
+ view-specific filter (a hole that does not appear in this view) has one
+ place to go.
+ """
+ del view
+ groups = _hole_groups(part)
+ total = sum(len(holes) for holes in groups.values())
+ if total <= int(hole_table_threshold):
+ return ()
+ rows: list[dict] = []
+ for index, key in enumerate(sorted(groups, key=lambda k: (k[0], k[1]))):
+ tag = chr(ord("A") + index)
+ holes = groups[key]
+ diameter, thread, cbore, depth = key
+ note_parts = []
+ if thread:
+ note_parts.append(str(thread))
+ if cbore is not None:
+ note_parts.append(f"c'bore ⌀{cbore:g}")
+ if depth is not None:
+ note_parts.append(f"deep {depth:g}")
+ note = " ".join(note_parts)
+ for hole in holes:
+ rows.append(
+ {
+ "tag": tag,
+ "x": float(hole.x),
+ "y": float(hole.y),
+ "d": float(diameter),
+ "qty": len(holes),
+ "note": note,
+ }
+ )
+ return tuple(rows)
+
+
+# -- the layout --------------------------------------------------------------
+
+
+def _axis_of(intent: DimIntent) -> str:
+ """ "bottom" (u = x, level moves -y), "left" (u = y, level moves -x) or "right"."""
+ if intent.kind in ("diameter", "radius", "angular"):
+ return "right"
+ dx = abs(intent.p2[0] - intent.p1[0])
+ dy = abs(intent.p2[1] - intent.p1[1])
+ return "bottom" if dx >= dy else "left"
+
+
+def layout_dimensions(
+ view, intents, *, offset: float = 10.0, step: float = 8.0, height: float = 3.5
+) -> tuple[DimIntent, ...]:
+ """Give every intent a ``text_at`` such that no two text rectangles overlap."""
+ x0, y0, x1, y1 = view.bbox
+ del y1
+
+ # Measure first: a level's pitch has to clear the widest text that stacks
+ # across it, and that is the text WIDTH on the left/right axes, where the
+ # levels march in x, not the text height.
+ measured: list[tuple[DimIntent, str, float, float]] = []
+ across: dict[str, float] = {}
+ for intent in intents:
+ axis = _axis_of(intent)
+ width, tall = _text_size(intent, height)
+ # ``along`` is the extent parallel to the level, ``across`` the extent
+ # perpendicular to it - the one successive levels have to clear.
+ along, over = (width, tall) if axis == "bottom" else (tall, width)
+ measured.append((intent, axis, along, over))
+ across[axis] = max(across.get(axis, 0.0), over)
+ pitch = {axis: max(float(step), value + 1.0) for axis, value in across.items()}
+
+ occupied: dict[tuple[str, int], list[tuple[float, float]]] = {}
+ placed: list[DimIntent] = []
+
+ for intent, axis, along, _over in measured:
+ if axis == "bottom":
+ center = (float(intent.p1[0]) + float(intent.p2[0])) / 2.0
+ else:
+ center = (float(intent.p1[1]) + float(intent.p2[1])) / 2.0
+ span = (center - along / 2.0, center + along / 2.0)
+
+ level = 0
+ while True:
+ taken = occupied.setdefault((axis, level), [])
+ if all(span[1] <= a + 1e-9 or span[0] >= b - 1e-9 for a, b in taken):
+ taken.append(span)
+ break
+ level += 1
+
+ distance = float(offset) + level * pitch[axis]
+ if axis == "bottom":
+ text_at = (center, y0 - distance)
+ elif axis == "left":
+ text_at = (x0 - distance, center)
+ else:
+ text_at = (x1 + distance, center)
+ placed.append(intent._replace(text_at=text_at))
+
+ return tuple(placed)
diff --git a/engineering/mech/draw.py b/engineering/mech/draw.py
new file mode 100644
index 0000000..0561412
--- /dev/null
+++ b/engineering/mech/draw.py
@@ -0,0 +1,1107 @@
+"""The only module in `engineering/mech/` that touches a backend.
+
+Everything above it is pure: the part model, the features, the view engine and
+the dimension layout take numbers and return primitive *descriptions*. This
+module turns those descriptions into `entity_create_*` / `dimension_*` calls,
+places them in WCS, and writes the part model back onto the drawing as
+`ACADMCP_MECH` XDATA so `mech_view_add` can add a view months later without the
+caller re-describing the part.
+
+It is not a contract member and neither are the tools above it - exactly like
+`gear_draw_*`, `keyway_draw_*` and `titleblock_apply_iso_a3`, it composes the
+generic members, so it is written once and works on both engines by
+construction.
+"""
+
+from __future__ import annotations
+
+import logging
+import math
+from typing import Any
+
+import anyio
+
+from engineering.mech.dimension import (
+ dimension_intents,
+ hole_table_rows,
+ intent_text,
+ layout_dimensions,
+ resolve_tolerance,
+)
+from engineering.mech.features import HolePattern
+from engineering.mech.part import build_part, part_to_dict, validate_part
+from engineering.mech.primitives import (
+ ROLE_LAYER,
+ Arc,
+ Circle,
+ DimIntent,
+ HatchArea,
+ Line,
+ Poly,
+ Text,
+ layer_for,
+ rotate,
+ translate,
+)
+from engineering.mech.standards.materials import hatch_for
+from engineering.mech.views import (
+ View,
+ build_view,
+ detail_marker_prims,
+ layout_origin,
+ normalise_plane,
+)
+from engineering.mech.xdata import APP_ID, PAYLOAD_VERSION, decode, to_values
+
+log = logging.getLogger(__name__)
+
+#: The layer the invisible per-part anchor POINT lives on. It carries the
+#: ACADMCP_MECH payload and nothing else, so `read_part` can find every part in
+#: a drawing with one filtered `entity_list`.
+ANCHOR_LAYER = "MECH"
+
+DEFAULT_GAP = 20.0
+
+#: How far a standalone hole pattern's ISO 128-23 centre mark reaches past the
+#: hole it marks, in drawing units. DECLARED, not standard: ISO 128-23 fixes
+#: that the mark overruns the circle, not by how much.
+CENTRE_MARK_OVERRUN = 3.0
+
+
+# -- the XDATA adapter -------------------------------------------------------
+#
+# `engineering/mech/xdata.encode` returns DXF (group code, value) rows, which is
+# the on-disk shape. `entity_set_xdata` takes plain JSON values and assigns the
+# codes itself through backends/xdata_specs.py, so the 1001 APPID row is the
+# backend's business and only the 1000 chunks travel. `to_values(payload)` is
+# that projection and it lives in `engineering/mech/xdata.py` (Task 1) - every
+# writer in this repository calls it rather than re-deriving the split, so a
+# change to the chunking is made in one place.
+
+
+def _xdata_payload(values) -> dict:
+ return decode(values)
+
+
+def _number(value, where: str, *, positive: bool = True) -> float:
+ try:
+ number = float(value)
+ except (TypeError, ValueError):
+ raise ValueError(f"{where}: expected a number, got {value!r}.") from None
+ if not math.isfinite(number):
+ raise ValueError(f"{where}: must be finite, got {value!r}.")
+ if positive and number <= 0.0:
+ raise ValueError(f"{where}: must be greater than zero, got {number:g}.")
+ return number
+
+
+# -- primitives to entities --------------------------------------------------
+
+
+async def draw_prims(
+ backend,
+ prims,
+ *,
+ at: tuple[float, float] = (0.0, 0.0),
+ rotation: float = 0.0,
+ layer_prefix: str = "",
+) -> dict:
+ """Draw primitive descriptions, turned by ``rotation`` about their origin, then placed at ``at``."""
+ placed = tuple(prims)
+ _refuse_islands_without_edge_paths(backend, placed)
+ if abs(float(rotation)) > 1e-12:
+ placed = rotate(placed, float(rotation), (0.0, 0.0))
+ if abs(float(at[0])) > 1e-12 or abs(float(at[1])) > 1e-12:
+ placed = translate(placed, float(at[0]), float(at[1]))
+
+ handles: list[str] = []
+ counts: dict[str, int] = {}
+
+ for prim in placed:
+ layer = f"{layer_prefix}{layer_for(prim)}"
+ if isinstance(prim, Line):
+ info = await backend.entity_create_line(
+ prim.p1[0], prim.p1[1], prim.p2[0], prim.p2[1], layer=layer
+ )
+ elif isinstance(prim, Arc):
+ info = await backend.entity_create_arc(
+ prim.center[0],
+ prim.center[1],
+ prim.radius,
+ prim.start_deg,
+ prim.end_deg,
+ layer=layer,
+ )
+ elif isinstance(prim, Circle):
+ info = await backend.entity_create_circle(
+ prim.center[0], prim.center[1], prim.radius, layer=layer
+ )
+ elif isinstance(prim, Poly):
+ info = await backend.entity_create_polyline(
+ [[x, y] for x, y in prim.points], prim.closed, layer=layer
+ )
+ elif isinstance(prim, Text):
+ info = await backend.entity_create_text(
+ prim.text,
+ prim.at[0],
+ prim.at[1],
+ prim.height,
+ prim.rotation,
+ layer=layer,
+ )
+ elif isinstance(prim, HatchArea):
+ style = hatch_for(prim.material)
+ info = await backend.entity_create_hatch(
+ style["pattern"],
+ [[x, y] for x, y in prim.loops[0]],
+ style["scale"],
+ style["angle"],
+ layer=layer,
+ )
+ for number, island in enumerate(prim.loops[1:], start=1):
+ points = list(island)
+ edges = [
+ {
+ "type": "line",
+ "start": [points[i][0], points[i][1]],
+ "end": [points[(i + 1) % len(points)][0], points[(i + 1) % len(points)][1]],
+ }
+ for i in range(len(points))
+ ]
+ # `hatch_add_boundary` answers a bad edge list or a bad handle
+ # with {"ok": False} rather than raising. An island that did not
+ # attach leaves the hatch solid straight through the bore it was
+ # meant to exclude, so the hatch is taken back out and the
+ # failure is raised - a missing hatch is honest, a wrong one is not.
+ try:
+ added = await backend.hatch_add_boundary(info.handle, edges)
+ except Exception:
+ await _discard(backend, info.handle)
+ raise
+ if not isinstance(added, dict) or not added.get("ok"):
+ await _discard(backend, info.handle)
+ error = added.get("error") if isinstance(added, dict) else added
+ raise ValueError(
+ f"hatch island {number} of {len(prim.loops) - 1} ({prim.material}) "
+ f"did not attach: {error}; the hatch was removed rather than left "
+ "filling the material it was meant to exclude."
+ )
+ else: # pragma: no cover - the Prim union is closed
+ raise ValueError(f"draw_prims: unknown primitive {type(prim).__name__}.")
+ handles.append(info.handle)
+ key = type(prim).__name__.lower()
+ counts[key] = counts.get(key, 0) + 1
+
+ return {"handles": handles, "counts": counts}
+
+
+def _refuse_islands_without_edge_paths(backend, prims) -> None:
+ """Refuse a hatch with islands on an engine that cannot attach them - before drawing.
+
+ Islands go through `hatch_add_boundary`, which the live engine refuses
+ (capability ``hatch_edge_paths``: ActiveX appends loops as objects, not
+ typed edges). Finding that out at the hatch would leave the rest of the
+ view already drawn; asking the capability map first leaves nothing behind.
+ """
+ if not any(isinstance(p, HatchArea) and len(p.loops) > 1 for p in prims):
+ return
+ try:
+ feature = backend.capabilities().features.get("hatch_edge_paths")
+ except Exception: # a backend without a capability map: let the member answer
+ return
+ if feature is not None and not feature.supported:
+ from backends.capability import UnsupportedCapabilityError
+
+ raise UnsupportedCapabilityError(
+ "hatch_edge_paths",
+ "this view hatches a cut face with an island (a bore or a hole through the "
+ f"section), and the {getattr(backend, 'name', 'current')} backend cannot attach "
+ f"hatch islands ({feature.reason}); nothing was drawn. Draw this view on the "
+ "headless backend (AUTOCAD_MCP_BACKEND=ezdxf).",
+ )
+
+
+async def _discard(backend, handle: str) -> None:
+ """Delete an entity this module just made; a failure here must not mask the cause."""
+ try:
+ await backend.entity_delete(handle)
+ except Exception as exc: # pragma: no cover - the original error is the one to raise
+ log.warning("draw_prims: could not remove hatch %s after a failed island: %s", handle, exc)
+
+
+# -- the placement: the sheet frame and WCS ----------------------------------
+#
+# Every view of a part is laid out in one *sheet frame*: the unrotated frame in
+# which the first view's lower-left corner sits at `placement.at`. Rotation is
+# then applied to the whole sheet as a rigid body about `at`, so the views stay
+# on their projection axes and the anchor POINT stays on the part's corner.
+# A view record keeps both boxes: `frame_bbox` is what `add_view` lays the next
+# view out against, `bbox` is the WCS extent of what was drawn.
+
+
+def _turn(point, at, rotation: float) -> tuple[float, float]:
+ x, y = float(point[0]), float(point[1])
+ if abs(rotation) <= 1e-12:
+ return (x, y)
+ cos = math.cos(math.radians(rotation))
+ sin = math.sin(math.radians(rotation))
+ dx, dy = x - float(at[0]), y - float(at[1])
+ return (float(at[0]) + dx * cos - dy * sin, float(at[1]) + dx * sin + dy * cos)
+
+
+def _to_wcs(prims, offset, at, rotation: float) -> tuple:
+ """Primitives in a view's own coordinates -> WCS: offset into the frame, turn about ``at``."""
+ placed = translate(prims, float(offset[0]), float(offset[1]))
+ if abs(rotation) > 1e-12:
+ placed = rotate(placed, float(rotation), (float(at[0]), float(at[1])))
+ return placed
+
+
+def _wcs_bbox(frame_bbox, at, rotation: float) -> list[float]:
+ x0, y0, x1, y1 = (float(v) for v in frame_bbox)
+ corners = [_turn(p, at, rotation) for p in ((x0, y0), (x1, y0), (x1, y1), (x0, y1))]
+ xs = [p[0] for p in corners]
+ ys = [p[1] for p in corners]
+ return [min(xs), min(ys), max(xs), max(ys)]
+
+
+def _frame_bbox(record: dict) -> list[float]:
+ """The sheet-frame box of a record (a payload written before it existed: its WCS box)."""
+ return [float(v) for v in (record.get("frame_bbox") or record["bbox"])]
+
+
+def _full_size(scale, where: str) -> float:
+ """Refuse a view scale the geometry would not have.
+
+ The view engine builds every view but a detail at 1:1, so a scale here
+ would only be *recorded* - and `mech_part_inspect` would then report a
+ scale the drawing does not have. Refused by name until it is honoured.
+ """
+ value = _number(scale, where)
+ if abs(value - 1.0) > 1e-12:
+ raise ValueError(
+ f"{where}: {value:g} is refused - views are drawn full size (1:1) in this build, "
+ "so the scale would be recorded but not drawn. Scale the sheet through a paper-space "
+ "viewport (viewport_set_scale); a detail view takes its own scale in detail['scale']."
+ )
+ return value
+
+
+# -- dimensions --------------------------------------------------------------
+
+
+async def _draw_dimension(
+ backend, intent, *, linear_angle: float | None = None
+) -> tuple[str | None, dict]:
+ """One laid-out intent as a real DIMENSION. Returns (handle, the resolved row).
+
+ ``linear_angle`` is the measuring direction of a linear intent when the
+ caller knows it better than the points do: a part turned by its placement
+ rotation measures along its own axes, which the WCS points alone no longer
+ reveal. ``None`` reads it from the points (horizontal or vertical).
+ """
+ resolved = resolve_tolerance(intent)
+ row = {
+ "feature": intent.feature,
+ "kind": intent.kind,
+ "text": intent_text(intent),
+ "fit": intent.fit,
+ **resolved,
+ }
+ text_at = intent.text_at or (
+ (intent.p1[0] + intent.p2[0]) / 2.0,
+ (intent.p1[1] + intent.p2[1]) / 2.0,
+ )
+ tol = {
+ "tol_upper": resolved["tol_upper"],
+ "tol_lower": resolved["tol_lower"],
+ "tol_mode": resolved["tol_mode"],
+ "text_override": resolved["text_override"],
+ }
+
+ if intent.kind == "linear":
+ if linear_angle is None:
+ horizontal = abs(intent.p2[1] - intent.p1[1]) <= abs(intent.p2[0] - intent.p1[0])
+ linear_angle = 0.0 if horizontal else 90.0
+ info = await backend.dimension_linear(
+ intent.p1[0],
+ intent.p1[1],
+ intent.p2[0],
+ intent.p2[1],
+ text_at[0],
+ text_at[1],
+ float(linear_angle),
+ ROLE_LAYER["dim"],
+ **tol,
+ )
+ elif intent.kind == "aligned":
+ if tol["tol_mode"] != "none" or tol["text_override"]:
+ angle = math.degrees(
+ math.atan2(intent.p2[1] - intent.p1[1], intent.p2[0] - intent.p1[0])
+ )
+ # dimension_aligned carries no tolerance arguments, so a toleranced
+ # aligned dimension is drawn as a rotated linear one - the same
+ # measurement, with the tolerance the caller asked for.
+ info = await backend.dimension_linear(
+ intent.p1[0],
+ intent.p1[1],
+ intent.p2[0],
+ intent.p2[1],
+ text_at[0],
+ text_at[1],
+ angle,
+ ROLE_LAYER["dim"],
+ **tol,
+ )
+ else:
+ info = await backend.dimension_aligned(
+ intent.p1[0],
+ intent.p1[1],
+ intent.p2[0],
+ intent.p2[1],
+ text_at[0],
+ text_at[1],
+ ROLE_LAYER["dim"],
+ )
+ elif intent.kind == "diameter":
+ info = await backend.dimension_diameter(
+ intent.p1[0],
+ intent.p1[1],
+ intent.p2[0],
+ intent.p2[1],
+ 10.0,
+ ROLE_LAYER["dim"],
+ **tol,
+ )
+ elif intent.kind == "radius":
+ info = await backend.dimension_radius(
+ intent.p1[0],
+ intent.p1[1],
+ intent.p2[0],
+ intent.p2[1],
+ 10.0,
+ ROLE_LAYER["dim"],
+ **tol,
+ )
+ else:
+ return None, {
+ **row,
+ "skipped": "an angular intent needs a vertex, which a DimIntent does not carry; "
+ "no feature in this release emits one",
+ }
+ return info.handle, row
+
+
+# -- the part payload --------------------------------------------------------
+
+
+def _view_record(
+ view: View,
+ offset: tuple[float, float],
+ kw: dict,
+ at: tuple[float, float] = (0.0, 0.0),
+ rotation: float = 0.0,
+) -> dict:
+ x0, y0, x1, y1 = view.bbox
+ frame = [x0 + offset[0], y0 + offset[1], x1 + offset[0], y1 + offset[1]]
+ return {
+ "kind": view.kind,
+ "label": view.label,
+ "scale": view.scale,
+ "offset": [float(offset[0]), float(offset[1])],
+ # `bbox` is the WCS extent of what was drawn - the part turned by the
+ # placement rotation - and `frame_bbox` the same box in the sheet frame
+ # the next view is laid out in. They are equal while rotation is 0.
+ "bbox": _wcs_bbox(frame, at, rotation),
+ "frame_bbox": frame,
+ "dimensioned": False,
+ "kw": kw,
+ }
+
+
+def _offset_for(view: View, lower_left: tuple[float, float]) -> tuple[float, float]:
+ return (lower_left[0] - view.bbox[0], lower_left[1] - view.bbox[1])
+
+
+def _placed(view: View, offset) -> View:
+ x0, y0, x1, y1 = view.bbox
+ return View(
+ kind=view.kind,
+ prims=view.prims,
+ dims=view.dims,
+ omitted=view.omitted,
+ bbox=(x0 + offset[0], y0 + offset[1], x1 + offset[0], y1 + offset[1]),
+ label=view.label,
+ scale=view.scale,
+ )
+
+
+def _view_from_record(part, payload: dict, record: dict) -> View:
+ """Rebuild the view a record describes, with the keywords it was drawn with."""
+ kw = record.get("kw") or {}
+ return build_view(
+ part,
+ record["kind"],
+ side=str(kw.get("side", "right")),
+ plane=kw.get("plane"),
+ style=str(kw.get("style", "full")),
+ scale=float(kw.get("scale", 1.0)),
+ projection=str((payload.get("placement") or {}).get("projection", "first")),
+ detail=kw.get("detail"),
+ )
+
+
+def _placement(payload: dict) -> tuple[tuple[float, float], float]:
+ placement = payload.get("placement") or {}
+ at = placement.get("at") or [0.0, 0.0]
+ return (float(at[0]), float(at[1])), float(placement.get("rotation", 0.0))
+
+
+async def draw_part(
+ backend,
+ part,
+ *,
+ at: tuple[float, float] = (0.0, 0.0),
+ rotation: float = 0.0,
+ views=("front",),
+ projection: str = "first",
+ dimension: bool = True,
+ style: str = "chain",
+ scale: float = 1.0,
+) -> dict:
+ """Draw a part's views, dimension them, and write the model onto the drawing.
+
+ ``at`` is where the first view's lower-left corner goes, and ``rotation``
+ turns the whole sheet of views about that point as one rigid body, so the
+ views keep their projection axes and the dimensions turn with them.
+ ``scale`` must be 1.0: every view is drawn full size in this build and a
+ recorded-but-undrawn scale is refused rather than written into the model.
+ """
+ validate_part(part)
+ try:
+ at_x, at_y = at
+ except (TypeError, ValueError):
+ raise ValueError(f"at: expected [x, y], got {at!r}.") from None
+ origin = (_number(at_x, "at[0]", positive=False), _number(at_y, "at[1]", positive=False))
+ turn = _number(rotation, "rotation", positive=False)
+ scale = _full_size(scale, "scale")
+ kinds = [str(kind).strip().lower() for kind in (views or ())]
+ if not kinds:
+ raise ValueError("views: name at least one view to draw.")
+
+ handles: list[str] = []
+ records: list[dict] = []
+ omitted: list[dict] = []
+ parent: View | None = None
+ parent_offset = (0.0, 0.0)
+
+ for index, kind in enumerate(kinds):
+ view = build_view(part, kind, projection=projection, scale=scale)
+ if index == 0:
+ offset = _offset_for(view, origin)
+ else:
+ lower_left = layout_origin(
+ _placed(parent, parent_offset),
+ kind,
+ projection=projection,
+ gap=DEFAULT_GAP,
+ child=view,
+ )
+ offset = _offset_for(view, lower_left)
+ drawn = await draw_prims(backend, _to_wcs(view.prims, offset, origin, turn))
+ handles.extend(drawn["handles"])
+ omitted.extend(view.omitted)
+ records.append(_view_record(view, offset, {"scale": scale}, origin, turn))
+ if index == 0:
+ parent, parent_offset = view, offset
+
+ anchor = await backend.entity_create_point(origin[0], origin[1], layer=ANCHOR_LAYER)
+ payload = {
+ "v": PAYLOAD_VERSION,
+ "kind": "part",
+ "id": anchor.handle,
+ "view": records[0]["kind"],
+ "part": part_to_dict(part),
+ "placement": {
+ "at": [origin[0], origin[1]],
+ "rotation": turn,
+ "projection": projection,
+ },
+ "views": records,
+ }
+ await backend.entity_set_xdata(anchor.handle, APP_ID, to_values(payload))
+
+ result = {
+ "part_id": anchor.handle,
+ "views": records,
+ "handles": handles,
+ "omitted": omitted,
+ "backend": backend.name,
+ }
+ if dimension:
+ dimensioned = await dimension_part(backend, anchor.handle, style=style)
+ for index in dimensioned["views_dimensioned"]:
+ records[index]["dimensioned"] = True
+ result["dimensions"] = dimensioned["count"]
+ result["handles"].extend(dimensioned["handles"])
+ return result
+
+
+async def read_part(backend, part_id: str | None = None) -> dict:
+ """The part model written onto the drawing, read back out of its anchor."""
+ if part_id:
+ raw = await backend.entity_get_xdata(part_id, APP_ID)
+ values = (raw.get("xdata") or {}).get(APP_ID) or []
+ if not values:
+ raise ValueError(f"{part_id}: carries no {APP_ID} part payload.")
+ payload = _xdata_payload(values)
+ return {"part_id": part_id, **payload}
+
+ anchors = await backend.entity_list(type_filter="POINT", layer_filter=ANCHOR_LAYER, limit=1000)
+ found: list[tuple[str, dict]] = []
+ for anchor in anchors:
+ raw = await backend.entity_get_xdata(anchor.handle, APP_ID)
+ values = (raw.get("xdata") or {}).get(APP_ID) or []
+ if values:
+ found.append((anchor.handle, _xdata_payload(values)))
+ if not found:
+ raise ValueError(
+ f"this drawing carries no {APP_ID} part payload; draw one with mech_part_draw."
+ )
+ if len(found) > 1:
+ raise ValueError(
+ "this drawing carries "
+ f"{len(found)} parts ({', '.join(handle for handle, _ in found)}); "
+ "name one with part_id."
+ )
+ handle, payload = found[0]
+ return {"part_id": handle, **payload}
+
+
+def _overlaps(box, other, gap: float) -> bool:
+ """Do two frame rectangles come closer than ``gap`` on *both* axes?"""
+ eps = 1e-6
+ return (
+ box[0] < other[2] + gap - eps
+ and box[2] > other[0] - gap + eps
+ and box[1] < other[3] + gap - eps
+ and box[3] > other[1] - gap + eps
+ )
+
+
+def _clear_of(records, lower_left, width: float, height: float, parent_box, gap: float):
+ """Step a new view past every view already placed, away from its parent.
+
+ `layout_origin` put the view on its projection axis (left, right, above or
+ below the parent); a collision is resolved by stepping further along that
+ same axis, so a third-angle end view is never shoved back into the front
+ view it was placed away from, and a view that only shares an X range with
+ another (a top view under the front) is not a collision at all.
+ """
+ cx = lower_left[0] + width / 2.0
+ cy = lower_left[1] + height / 2.0
+ dx = cx - (parent_box[0] + parent_box[2]) / 2.0
+ dy = cy - (parent_box[1] + parent_box[3]) / 2.0
+ boxes = [_frame_bbox(record) for record in records]
+ for _ in range(len(boxes) + 1):
+ box = (lower_left[0], lower_left[1], lower_left[0] + width, lower_left[1] + height)
+ hit = next((other for other in boxes if _overlaps(box, other, gap)), None)
+ if hit is None:
+ return lower_left
+ if abs(dx) >= abs(dy):
+ x = hit[2] + gap if dx > 0 else hit[0] - gap - width
+ lower_left = (x, lower_left[1])
+ else:
+ y = hit[3] + gap if dy > 0 else hit[1] - gap - height
+ lower_left = (lower_left[0], y)
+ return lower_left # pragma: no cover - each step clears one box for good
+
+
+async def add_view(backend, part_id: str, kind: str, **kw) -> dict:
+ """Add another view of a part already on the drawing, laid out against its first."""
+ payload = await read_part(backend, part_id)
+ part = build_part(payload["part"])
+ records = list(payload.get("views") or [])
+ if not records:
+ raise ValueError(f"{part_id}: the payload carries no view to lay this one out against.")
+ projection = str((payload.get("placement") or {}).get("projection", "first"))
+ origin, rotation = _placement(payload)
+
+ plane = kw.pop("plane", None)
+ if plane is not None:
+ plane = normalise_plane(plane)
+ detail = kw.pop("detail", None)
+ gap = float(kw.pop("gap", DEFAULT_GAP))
+ side = str(kw.pop("side", "right"))
+ style = str(kw.pop("style", "full"))
+ scale = _full_size(kw.pop("scale", 1.0), "scale")
+ if kw:
+ raise ValueError(f"add_view: unknown argument(s) {sorted(kw)}.")
+
+ view = build_view(
+ part,
+ kind,
+ side=side,
+ plane=plane,
+ style=style,
+ scale=scale,
+ projection=projection,
+ detail=detail,
+ )
+
+ parent_record = records[0]
+ parent_view = _view_from_record(part, payload, parent_record)
+ parent_frame = _placed(parent_view, parent_record["offset"])
+ lower_left = layout_origin(
+ parent_frame,
+ kind,
+ side=side,
+ projection=projection,
+ gap=gap,
+ child=view,
+ )
+ lower_left = _clear_of(
+ records,
+ lower_left,
+ view.bbox[2] - view.bbox[0],
+ view.bbox[3] - view.bbox[1],
+ parent_frame.bbox,
+ gap,
+ )
+ offset = _offset_for(view, lower_left)
+
+ drawn = await draw_prims(backend, _to_wcs(view.prims, offset, origin, rotation))
+ marker_handles: list[str] = []
+ if kind == "detail":
+ marker = detail_marker_prims(detail or {})
+ marker_drawn = await draw_prims(
+ backend, _to_wcs(marker, parent_record["offset"], origin, rotation)
+ )
+ marker_handles = marker_drawn["handles"]
+
+ records.append(
+ _view_record(
+ view,
+ offset,
+ {
+ "side": side,
+ "style": style,
+ "scale": scale,
+ "plane": _plane_to_json(plane),
+ "detail": detail,
+ },
+ origin,
+ rotation,
+ )
+ )
+ payload["views"] = records
+ await backend.entity_set_xdata(
+ part_id, APP_ID, to_values({k: v for k, v in payload.items() if k != "part_id"})
+ )
+ return {
+ "part_id": part_id,
+ "view": records[-1],
+ "handles": drawn["handles"] + marker_handles,
+ "omitted": list(view.omitted),
+ "backend": backend.name,
+ }
+
+
+def _plane_to_json(plane: dict | None) -> dict | None:
+ """`normalise_plane` answers with tuples; JSON round-trips them as lists."""
+ if plane is None:
+ return None
+ return {
+ "p1": [float(plane["p1"][0]), float(plane["p1"][1])],
+ "p2": [float(plane["p2"][0]), float(plane["p2"][1])],
+ "label": str(plane["label"]),
+ "direction": [float(plane["direction"][0]), float(plane["direction"][1])],
+ "style": str(plane["style"]),
+ "via": [[float(p[0]), float(p[1])] for p in plane.get("via", ())],
+ }
+
+
+def _fits_for_view(part, view, fits: dict, *, style: str, threshold: int) -> dict:
+ """The subset of ``fits`` this view actually dimensions.
+
+ ``dimension_intents`` refuses a key that names nothing *in the view it is
+ given*, which is right for one view and wrong for a part drawn in three:
+ a fit on ``segment[0]`` names nothing in the end view, and refusing there
+ would make a correct sheet undrawable. So the split happens here and the
+ keys nothing anywhere claims are re-offered to :func:`dimension_intents`
+ afterwards, which refuses them in its own words.
+ """
+ if not fits:
+ return {}
+ plain = dimension_intents(part, view, style=style, hole_table_threshold=threshold)
+ available = {intent.feature for intent in plain if intent.feature}
+ return {key: code for key, code in fits.items() if str(key) in available}
+
+
+def _view_name(index: int, record: dict) -> str:
+ return f"{index} ({record.get('label') or record.get('kind')})"
+
+
+def _dimension_targets(part_id: str, records: list[dict], views) -> list[int]:
+ """Which views this call dimensions - never one that already carries its dimensions.
+
+ ISO 129-1: each dimension appears once. A view record carries a
+ ``dimensioned`` flag, so a second call (or `mech_dimension_part` after a
+ `mech_part_draw` that already dimensioned) adds only the views that have
+ none, and naming a dimensioned view is refused rather than stacked.
+ """
+ if views is None:
+ targets = [i for i, record in enumerate(records) if not record.get("dimensioned")]
+ if not targets:
+ names = ", ".join(_view_name(i, r) for i, r in enumerate(records))
+ raise ValueError(
+ f"{part_id}: every view is already dimensioned ({names}); each measurement "
+ "appears once (ISO 129-1). Add a view with mech_view_add, then dimension it."
+ )
+ return targets
+ if isinstance(views, (str, bytes)) or not isinstance(views, (list, tuple)) or not views:
+ raise ValueError(f"views: expected a list of view indices such as [1], got {views!r}.")
+ targets: list[int] = []
+ for n, value in enumerate(views):
+ if isinstance(value, bool) or not isinstance(value, int):
+ raise ValueError(f"views[{n}]: expected a view index (int), got {value!r}.")
+ if not 0 <= value < len(records):
+ names = ", ".join(_view_name(i, r) for i, r in enumerate(records))
+ raise ValueError(f"views[{n}]: {value} is not a view of {part_id}; it has {names}.")
+ if value not in targets:
+ targets.append(value)
+ already = [_view_name(i, records[i]) for i in targets if records[i].get("dimensioned")]
+ if already:
+ raise ValueError(
+ f"views: {', '.join(already)} already dimensioned; each measurement appears once "
+ "(ISO 129-1), so dimensioning it again would stack a duplicate on every measurement."
+ )
+ return targets
+
+
+async def dimension_part(
+ backend,
+ part_id: str,
+ *,
+ style: str = "chain",
+ hole_table_threshold: int = 8,
+ fits: dict | None = None,
+ views: list[int] | None = None,
+) -> dict:
+ """Dimension the views of a part that carry no dimensions yet, laid out without overlaps.
+
+ ``views`` names view indices (the order `read_part` lists them in); by
+ default every view not yet dimensioned is. A view already dimensioned is
+ never dimensioned again (ISO 129-1), and the flag that says so is written
+ back into the part payload.
+ """
+ payload = await read_part(backend, part_id)
+ part = build_part(payload["part"])
+ records = list(payload.get("views") or [])
+ if not records:
+ raise ValueError(f"{part_id}: the payload carries no view to dimension.")
+ if fits is not None and not isinstance(fits, dict):
+ raise ValueError(
+ f"fits: expected a dict of {{key: ISO 286 code}}, got {type(fits).__name__}."
+ )
+ fits = dict(fits or {})
+ targets = _dimension_targets(part_id, records, views)
+ origin, rotation = _placement(payload)
+ threshold = int(hole_table_threshold)
+
+ # Everything that can be refused is refused before the first DIMENSION is
+ # drawn: a fit no target view claims must not leave half a view dimensioned
+ # and unflagged behind it.
+ planned: list[tuple[int, View, list]] = []
+ unclaimed = set(fits)
+ for index in targets:
+ view = _view_from_record(part, payload, records[index])
+ mine = _fits_for_view(part, view, fits, style=style, threshold=threshold)
+ unclaimed -= set(mine)
+ intents = dimension_intents(
+ part, view, style=style, hole_table_threshold=threshold, fits=mine or None
+ )
+ planned.append((index, view, layout_dimensions(view, intents)))
+ if unclaimed:
+ # No view claimed these keys. Hand them back to the module that owns the
+ # vocabulary so the refusal names the keys that do exist, and the hole
+ # table's own message survives.
+ dimension_intents(
+ part,
+ planned[0][1],
+ style=style,
+ hole_table_threshold=threshold,
+ fits={key: fits[key] for key in sorted(unclaimed)},
+ )
+
+ handles: list[str] = []
+ resolved_rows: list[dict] = []
+ skipped: list[dict] = []
+ table_handle: str | None = None
+
+ for index, view, placed in planned:
+ record = records[index]
+ offset = record["offset"]
+ for intent in placed:
+ frame = intent._replace(
+ p1=(intent.p1[0] + offset[0], intent.p1[1] + offset[1]),
+ p2=(intent.p2[0] + offset[0], intent.p2[1] + offset[1]),
+ text_at=(intent.text_at[0] + offset[0], intent.text_at[1] + offset[1]),
+ )
+ angle = None
+ if intent.kind == "linear":
+ # the measuring direction is decided in the part's own frame and
+ # turned with it; the rotated points alone no longer show it
+ horizontal = abs(frame.p2[1] - frame.p1[1]) <= abs(frame.p2[0] - frame.p1[0])
+ angle = (0.0 if horizontal else 90.0) + rotation
+ moved = frame._replace(
+ p1=_turn(frame.p1, origin, rotation),
+ p2=_turn(frame.p2, origin, rotation),
+ text_at=_turn(frame.text_at, origin, rotation),
+ )
+ handle, row = await _draw_dimension(backend, moved, linear_angle=angle)
+ if handle is None:
+ skipped.append(row)
+ continue
+ handles.append(handle)
+ resolved_rows.append(row)
+
+ rows = hole_table_rows(part, view, hole_table_threshold=threshold)
+ if rows and table_handle is None and not payload.get("hole_table"):
+ info = await backend.entity_create_table(
+ record["bbox"][2] + DEFAULT_GAP,
+ record["bbox"][3],
+ [[r["tag"], f"{r['d']:g}", str(r["qty"]), r["note"]] for r in rows],
+ ["TAG", "DIA", "QTY", "NOTE"],
+ None,
+ 7.0,
+ 2.5,
+ "HOLE TABLE",
+ ROLE_LAYER["text"],
+ )
+ table_handle = info.handle
+ handles.append(info.handle)
+ if placed or rows:
+ record["dimensioned"] = True
+ else:
+ # Said, not silently counted as done: the view stays unflagged, so a
+ # later call can dimension it once the dimension layer covers it.
+ skipped.append(
+ {
+ "view": _view_name(index, record),
+ "kind": view.kind,
+ "skipped": f"the dimension layer emits no intents for a {view.kind} view "
+ "in this build; the view is left undimensioned",
+ }
+ )
+
+ payload["views"] = records
+ if table_handle:
+ payload["hole_table"] = table_handle
+ await backend.entity_set_xdata(
+ part_id, APP_ID, to_values({k: v for k, v in payload.items() if k != "part_id"})
+ )
+
+ return {
+ "part_id": part_id,
+ "count": len(handles) - (1 if table_handle else 0),
+ "handles": handles,
+ "resolved": resolved_rows,
+ "skipped": skipped,
+ "hole_table": table_handle,
+ "views_dimensioned": [i for i in targets if records[i].get("dimensioned")],
+ "backend": backend.name,
+ }
+
+
+# -- the standalone hole pattern ---------------------------------------------
+
+
+async def draw_hole_pattern(
+ backend,
+ *,
+ pattern: str,
+ x: float,
+ y: float,
+ diameter: float,
+ count: int = 2,
+ spacing: float = 0.0,
+ count_y: int = 1,
+ spacing_y: float = 0.0,
+ pcd: float = 0.0,
+ start_angle: float = 0.0,
+ angle: float = 0.0,
+ layer: str | None = None,
+ centre_marks: bool = True,
+ dimension: bool = False,
+) -> dict:
+ """Draw a hole pattern on any drawing - no part model needed."""
+ # A standalone pattern has no part, so `Hole.validate` (which needs one) is
+ # out of reach: the size guard is made here rather than left to the drawing.
+ diameter = _number(diameter, "diameter")
+ _number(x, "x", positive=False)
+ _number(y, "y", positive=False)
+ expansion = HolePattern(
+ id="pattern",
+ pattern=pattern,
+ base={"x": float(x), "y": float(y), "diameter": diameter},
+ count=int(count),
+ spacing=float(spacing),
+ count_y=int(count_y),
+ spacing_y=float(spacing_y),
+ pcd=float(pcd),
+ start_angle=float(start_angle),
+ angle=float(angle),
+ ).expand() # validates the pattern before anything is drawn
+
+ prims: list[Any] = []
+ for hole in expansion:
+ radius = float(hole.diameter) / 2.0
+ prims.append(Circle((float(hole.x), float(hole.y)), radius, "visible"))
+ if centre_marks:
+ reach = radius + CENTRE_MARK_OVERRUN
+ prims.append(Line((hole.x - reach, hole.y), (hole.x + reach, hole.y), "center"))
+ prims.append(Line((hole.x, hole.y - reach), (hole.x, hole.y + reach), "center"))
+ drawn = await draw_prims(backend, prims)
+ if layer:
+ # the role map owns the layers; an explicit override moves the circles
+ # only, so the ISO 128-23 centre marks stay on CENTER.
+ for handle, prim in zip(drawn["handles"], prims, strict=True):
+ if isinstance(prim, Circle):
+ await backend.entity_set_properties(handle, layer=layer)
+
+ result = {
+ "count": len(expansion),
+ "handles": drawn["handles"],
+ "centers": [[float(h.x), float(h.y)] for h in expansion],
+ "backend": backend.name,
+ }
+ if dimension:
+ first = expansion[0]
+ radius = float(first.diameter) / 2.0
+ handle, _row = await _draw_dimension(
+ backend,
+ DimIntent(
+ kind="diameter",
+ p1=(first.x - radius, first.y),
+ p2=(first.x + radius, first.y),
+ text_at=(first.x + radius + 12.0, first.y + 12.0),
+ text_override=f"{len(expansion)}x ⌀{float(first.diameter):g}",
+ feature="pattern",
+ ),
+ )
+ if handle:
+ result["handles"].append(handle)
+ return result
+
+
+# -- the whole sheet ---------------------------------------------------------
+
+
+async def draw_from_spec(backend, spec: dict) -> dict:
+ """A whole mechanical sheet from one declarative spec, inside one transaction.
+
+ Every part is validated before the transaction opens, so a refusal leaves
+ the drawing untouched. The rollback is shielded for the reason
+ `engineering/pid/spec.py` documents at length: the MCP SDK cancels a request
+ by cancelling an anyio scope, anyio cancellation is level-triggered, and a
+ bare ``await`` in the handler would itself be cancelled before the rollback
+ body ran - leaving half a sheet inside an open transaction.
+ """
+ if not isinstance(spec, dict):
+ raise ValueError(f"spec: expected a dict, got {type(spec).__name__}.")
+ raw_parts = spec.get("parts")
+ if not isinstance(raw_parts, (list, tuple)) or not raw_parts:
+ raise ValueError("parts: name at least one part to draw.")
+
+ jobs: list[dict] = []
+ for index, item in enumerate(raw_parts):
+ if not isinstance(item, dict):
+ raise ValueError(f"parts[{index}]: expected a dict with a 'part' key.")
+ try:
+ part = build_part(item.get("part") or {})
+ except ValueError as exc:
+ raise ValueError(f"parts[{index}]: {exc}") from exc
+ at = item.get("at")
+ if at is None:
+ at = [0.0, 0.0]
+ if isinstance(at, (str, bytes)) or not isinstance(at, (list, tuple)) or len(at) != 2:
+ raise ValueError(f"parts[{index}]: at: expected [x, y], got {at!r}.")
+ jobs.append(
+ {
+ "part": part,
+ "at": (
+ _number(at[0], f"parts[{index}]: at[0]", positive=False),
+ _number(at[1], f"parts[{index}]: at[1]", positive=False),
+ ),
+ "rotation": _number(
+ item.get("rotation", 0.0), f"parts[{index}]: rotation", positive=False
+ ),
+ "views": tuple(item.get("views") or ("front",)),
+ "projection": str(item.get("projection", "first")),
+ "dimension": bool(item.get("dimension", True)),
+ "style": str(item.get("style", "chain")),
+ }
+ )
+
+ sheet = spec.get("sheet") or {}
+ if backend.get_plan_spec() is None:
+ await backend.drawing_plan(
+ sheet.get("intent", "mechanical part sheet"),
+ sheet.get("sheet_size", "A3"),
+ float(sheet.get("scale", 1.0)),
+ sheet.get("layer_set", "mech"),
+ )
+
+ begun = await backend.transaction_begin()
+ if not isinstance(begun, dict) or not begun.get("ok"):
+ # a refusal, not a crash: ValueError is what the tool layer turns into
+ # a ToolError, and nothing has been drawn yet
+ detail = begun.get("error") if isinstance(begun, dict) else begun
+ raise ValueError(
+ f"mech_part_from_spec: could not open a transaction ({detail}); nothing was drawn. "
+ "If one is already open, close it with transaction_commit or "
+ "transaction_rollback and retry."
+ )
+
+ drawn: list[dict] = []
+ try:
+ for index, job in enumerate(jobs):
+ try:
+ drawn.append(
+ await draw_part(
+ backend,
+ job["part"],
+ at=job["at"],
+ rotation=job["rotation"],
+ views=job["views"],
+ projection=job["projection"],
+ dimension=job["dimension"],
+ style=job["style"],
+ )
+ )
+ except ValueError as exc:
+ raise ValueError(f"parts[{index}]: {exc}") from exc
+ except BaseException as exc:
+ cancelled = isinstance(exc, anyio.get_cancelled_exc_class())
+ with anyio.CancelScope(shield=True):
+ try:
+ await backend.transaction_rollback()
+ except Exception as rollback_exc:
+ if not cancelled:
+ raise
+ log.error(
+ "mech_part_from_spec: rollback after a cancelled request failed (%s: %s); "
+ "the sheet may be half-drawn - check system_status and "
+ "transaction_rollback before retrying",
+ type(rollback_exc).__name__,
+ rollback_exc,
+ )
+ raise
+ await backend.transaction_commit()
+
+ return {
+ "parts": drawn,
+ "part_ids": [item["part_id"] for item in drawn],
+ "backend": backend.name,
+ }
diff --git a/engineering/mech/features.py b/engineering/mech/features.py
new file mode 100644
index 0000000..a9b8873
--- /dev/null
+++ b/engineering/mech/features.py
@@ -0,0 +1,2254 @@
+"""Every feature of the part model: validation, projected geometry, dimensions.
+
+Pure: no backend, no async, no DXF. A feature answers four questions about the
+part it sits on - *is my placement legal*, *what do I contribute to this view*,
+*what do I contribute to a cut face*, *what dimension do I want* - and five
+structural ones the view engine asks:
+
+``removal_box(part)``
+ the axis-aligned box of material this feature removes, **in the front
+ view's plane** (revolved: x along the axis, y radial; prismatic: the
+ outline's own XY). ``None`` opts out - used by features whose removal is
+ not a box (an annulus, an axisymmetric bore).
+``radial_edit(part)``
+ ``{"kind": "inner"|"outer", "x0", "x1", "radius"}`` records that change the
+ revolved radial profile, which is what makes both the silhouette and the
+ section cut face come out of one calculation.
+``corner_edit(part)``
+ ``{"corner", "before", "after", "arc"}`` - replace one silhouette corner
+ with a chamfer line or a tangent fillet arc.
+``outline_edit(part)``
+ the prismatic equivalent, keyed by outline vertex index.
+``omission(part, view)``
+ why this feature has no geometry in that view, for the honesty rule: a
+ feature that contributes nothing and edits nothing is reported in the
+ view's ``omitted`` list rather than silently dropped.
+
+Two features reuse the repository rather than restating a table:
+``Keyway`` takes its width and depth from ``engineering.keyway`` (DIN 6885-1)
+and ``GearTeeth`` takes its outline from ``engineering.gear``'s involute
+generator. ``Thread`` takes its pitch from ``standards/threads.py`` (ISO 261)
+and its geometry from ISO 6410. ``Undercut``, ``RetainingGroove``,
+``CentreHole`` and ``ORingGroove`` take their dimensions from the registered
+standards tables, which are not transcribed in this build - so each of them
+either receives explicit dimensions or is refused by name. None of them
+invents a number.
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Iterable
+from dataclasses import dataclass, fields
+from typing import Any, ClassVar, Protocol, runtime_checkable
+
+from engineering.gear import generate_full_gear_outline
+from engineering.keyway import keyway_dimensions
+from engineering.mech.part import (
+ PrismaticPart,
+ RevolvedPart,
+ inner_radius_at,
+ outer_radius_at,
+ outline_bbox,
+ part_length,
+ point_in_outline,
+ segment_bounds,
+)
+from engineering.mech.primitives import Arc, Circle, DimIntent, HatchArea, Line, Poly, Prim, Text
+from engineering.mech.standards.centres import centre_hole_dims
+from engineering.mech.standards.orings import KINDS as ORING_KINDS
+from engineering.mech.standards.orings import oring_groove_dims
+from engineering.mech.standards.rings import ring_groove_dims
+from engineering.mech.standards.threads import (
+ MINOR_RATIO,
+ parse_designation,
+ thread_axial_prims,
+ thread_end_prims,
+)
+from engineering.mech.standards.undercuts import undercut_profile
+
+_EPS = 1e-9
+
+
+@runtime_checkable
+class Feature(Protocol):
+ """The surface the part model and the view engine rely on."""
+
+ id: str
+
+ def validate(self, part) -> None: ...
+ def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: ...
+ def cut_prims(self, part, plane, ctx: dict) -> tuple[Prim, ...]: ...
+ def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: ...
+
+
+# -- shared helpers ----------------------------------------------------------
+
+
+def mirror_about_x(prims: Iterable[Prim]) -> tuple[Prim, ...]:
+ """Mirror primitives about the y = 0 axis (the revolved part's axis)."""
+ out: list[Prim] = []
+ for prim in prims:
+ if isinstance(prim, Line):
+ out.append(Line((prim.p1[0], -prim.p1[1]), (prim.p2[0], -prim.p2[1]), prim.role))
+ elif isinstance(prim, Arc):
+ out.append(
+ Arc(
+ (prim.center[0], -prim.center[1]),
+ prim.radius,
+ (-prim.end_deg) % 360.0,
+ (-prim.start_deg) % 360.0,
+ prim.role,
+ )
+ )
+ elif isinstance(prim, Circle):
+ out.append(Circle((prim.center[0], -prim.center[1]), prim.radius, prim.role))
+ elif isinstance(prim, Poly):
+ out.append(Poly(tuple((x, -y) for x, y in prim.points), prim.closed, prim.role))
+ elif isinstance(prim, HatchArea):
+ out.append(
+ HatchArea(
+ tuple(tuple((x, -y) for x, y in loop) for loop in prim.loops),
+ prim.material,
+ )
+ )
+ elif isinstance(prim, Text):
+ out.append(
+ Text((prim.at[0], -prim.at[1]), prim.text, prim.height, prim.rotation, prim.role)
+ )
+ else: # pragma: no cover - the union is closed
+ raise ValueError(f"mirror_about_x: unknown primitive {type(prim).__name__}.")
+ return tuple(out)
+
+
+# -- cutting-line chords -----------------------------------------------------
+#
+# A section plane meets a feature along a *chord*, not along the feature's
+# bounding box. The difference is not cosmetic: a plane 4.9 mm off the centre
+# of a 10 mm hole cuts a 1.990 mm chord, and the box would punch the whole
+# 10 mm out of the cut face - a wrong hatched area and a wrong drawn outline at
+# the same time. Every function here returns parameters measured along ``unit``
+# from ``p1``, on the infinite line; the caller clips them to its own leg.
+
+
+def _box_around(center, radius: float) -> tuple[float, float, float, float]:
+ """The axis-aligned box of a circle - what ``removal_box`` reports, and
+ exactly why a cut face may not be built from it (see ``cut_spans``)."""
+ x, y = float(center[0]), float(center[1])
+ return (x - radius, y - radius, x + radius, y + radius)
+
+
+def _merge_spans(spans) -> tuple[tuple[float, float], ...]:
+ """Sorted, non-overlapping spans. A union, so a shape built from several
+ pieces (a slot is two circles and a rectangle) reports one interval."""
+ items = sorted((float(a), float(b)) for a, b in spans if float(b) - float(a) > _EPS)
+ out: list[list[float]] = []
+ for a, b in items:
+ if out and a <= out[-1][1] + _EPS:
+ out[-1][1] = max(out[-1][1], b)
+ else:
+ out.append([a, b])
+ return tuple((a, b) for a, b in out)
+
+
+def _box_chord(box, p1, unit) -> tuple[tuple[float, float], ...]:
+ """The exact chord of an axis-aligned rectangle (a slab clip, not a corner
+ projection: projecting the four corners onto the line reports the box's
+ whole shadow, which is only the chord when the line is parallel to a side)."""
+ if box is None:
+ return ()
+ low, high = -math.inf, math.inf
+ for axis in (0, 1):
+ direction = float(unit[axis])
+ lo, hi = float(box[axis]), float(box[axis + 2])
+ origin = float(p1[axis])
+ if abs(direction) < 1e-12:
+ if origin < lo - _EPS or origin > hi + _EPS:
+ return ()
+ continue
+ a, b = (lo - origin) / direction, (hi - origin) / direction
+ if a > b:
+ a, b = b, a
+ low, high = max(low, a), min(high, b)
+ return ((low, high),) if high - low > _EPS else ()
+
+
+def _circle_chord(center, radius, p1, unit) -> tuple[tuple[float, float], ...]:
+ """``2*sqrt(r**2 - d**2)`` about the foot of the perpendicular - exact, and
+ empty when the line only grazes the circle."""
+ dx, dy = float(center[0]) - float(p1[0]), float(center[1]) - float(p1[1])
+ along = dx * unit[0] + dy * unit[1]
+ off = -dx * unit[1] + dy * unit[0]
+ inside = float(radius) * float(radius) - off * off
+ if inside <= _EPS:
+ return ()
+ half = math.sqrt(inside)
+ return ((along - half, along + half),)
+
+
+def _rotated_box_chord(center, half_u, half_v, angle_deg, p1, unit):
+ """The exact chord of a rectangle rotated about its own centre: the line is
+ taken into the rectangle's frame and slab-clipped there."""
+ theta = math.radians(float(angle_deg))
+ cos, sin = math.cos(theta), math.sin(theta)
+ dx, dy = float(p1[0]) - float(center[0]), float(p1[1]) - float(center[1])
+ origin = (dx * cos + dy * sin, -dx * sin + dy * cos)
+ direction = (unit[0] * cos + unit[1] * sin, -unit[0] * sin + unit[1] * cos)
+ return _box_chord((-half_u, -half_v, half_u, half_v), origin, direction)
+
+
+def _polygon_chord(points, p1, unit, *, what: str = "the removed outline"):
+ """The intervals of the line that lie inside a simple polygon.
+
+ Crossings are paired in order, exact for a polygon the line crosses
+ transversally. A line through a vertex gives an odd count and is refused
+ rather than paired wrong - the same rule the outline itself follows.
+ """
+ ts: list[float] = []
+ count = len(points)
+ for index in range(count):
+ ax, ay = points[index]
+ bx, by = points[(index + 1) % count]
+ ex, ey = bx - ax, by - ay
+ denominator = ex * unit[1] - ey * unit[0]
+ if abs(denominator) < 1e-12:
+ continue
+ s = ((p1[0] - ax) * unit[1] - (p1[1] - ay) * unit[0]) / denominator
+ if -1e-9 <= s <= 1.0 + 1e-9:
+ px, py = ax + s * ex, ay + s * ey
+ ts.append((px - p1[0]) * unit[0] + (py - p1[1]) * unit[1])
+ ts = sorted({round(t, 9) for t in ts})
+ if len(ts) % 2 != 0:
+ raise ValueError(
+ f"section: the cutting plane grazes a vertex of {what}, so its crossings cannot "
+ "be paired. Move the plane off the vertex rather than accept a guessed cut face."
+ )
+ return tuple((ts[i], ts[i + 1]) for i in range(0, len(ts), 2))
+
+
+def _number(value: Any, path: str, *, positive: bool = True) -> float:
+ try:
+ number = float(value)
+ except (TypeError, ValueError):
+ raise ValueError(f"{path}: expected a number, got {value!r}.") from None
+ if not math.isfinite(number):
+ raise ValueError(f"{path}: {value!r} is not a finite number.")
+ if positive and number <= _EPS:
+ raise ValueError(f"{path}: must be greater than zero, got {number}.")
+ return number
+
+
+def _require_revolved(part, kind: str) -> RevolvedPart:
+ if not isinstance(part, RevolvedPart):
+ raise ValueError(f"{kind} applies to a revolved part, not a {type(part).__name__}.")
+ return part
+
+
+def _require_prismatic(part, kind: str) -> PrismaticPart:
+ if not isinstance(part, PrismaticPart):
+ raise ValueError(f"{kind} applies to a prismatic part, not a {type(part).__name__}.")
+ return part
+
+
+def _segment_index(part: RevolvedPart, index: Any, path: str = "segment") -> int:
+ try:
+ value = int(index)
+ except (TypeError, ValueError):
+ raise ValueError(f"{path}: expected a segment index, got {index!r}.") from None
+ count = len(part.segments)
+ if not 0 <= value < count:
+ raise ValueError(
+ f"{path} {value} is outside the {count}-segment part (segments 0-{count - 1})."
+ )
+ return value
+
+
+def _parse_at(at: str, part: RevolvedPart, *, allow_step: bool = True) -> tuple[str, int]:
+ text = str(at or "").strip().lower()
+ if text in ("start", "end"):
+ return text, 0
+ if text.startswith("step:"):
+ if not allow_step:
+ raise ValueError(f"at {at!r}: this feature is placed at 'start' or 'end' only.")
+ try:
+ index = int(text.split(":", 1)[1])
+ except ValueError:
+ raise ValueError(f"at {at!r}: expected 'step:'.") from None
+ last = len(part.segments) - 2
+ if last < 0 or not 0 <= index <= last:
+ raise ValueError(
+ f"at 'step:{index}': the part has {len(part.segments)} segment(s), so its steps "
+ f"are step:0-step:{max(last, 0)}."
+ )
+ return "step", index
+ raise ValueError(f"at {at!r}: expected 'start', 'end' or 'step:'.")
+
+
+def _radius_right_end(part: RevolvedPart, index: int) -> float:
+ segment = part.segments[index]
+ return (segment.taper_to if segment.taper_to is not None else segment.d_outer) / 2.0
+
+
+def _radius_left_end(part: RevolvedPart, index: int) -> float:
+ return part.segments[index].d_outer / 2.0
+
+
+def _anchor(feature, part: RevolvedPart) -> tuple[float, float, int]:
+ """(x, radius, direction) of the corner a chamfer/fillet/undercut sits on."""
+ kind, index = _parse_at(feature.at, part)
+ if kind == "start":
+ return 0.0, _radius_left_end(part, 0), +1
+ if kind == "end":
+ last = len(part.segments) - 1
+ return part_length(part), _radius_right_end(part, last), -1
+ x = segment_bounds(part)[index][1]
+ left = _radius_right_end(part, index)
+ right = _radius_left_end(part, index + 1)
+ return (x, left, -1) if left >= right else (x, right, +1)
+
+
+def _anchor_segment(feature, part: RevolvedPart) -> int:
+ """Index of the segment a chamfer at ``feature.at`` actually cuts into.
+
+ ``_anchor`` gives the corner and the direction into the material; the leg
+ runs that way, so at a step it belongs to whichever of the two adjacent
+ segments carries the larger radius.
+ """
+ kind, index = _parse_at(feature.at, part)
+ if kind == "start":
+ return 0
+ if kind == "end":
+ return len(part.segments) - 1
+ left = _radius_right_end(part, index)
+ right = _radius_left_end(part, index + 1)
+ return index if left >= right else index + 1
+
+
+def _shoulder(feature, part: RevolvedPart) -> tuple[float, float, int]:
+ """(x, radius of the SMALLER cylinder, direction into it) for a relief groove."""
+ kind, index = _parse_at(feature.at, part)
+ if kind == "start":
+ return 0.0, _radius_left_end(part, 0), +1
+ if kind == "end":
+ last = len(part.segments) - 1
+ return part_length(part), _radius_right_end(part, last), -1
+ x = segment_bounds(part)[index][1]
+ left = _radius_right_end(part, index)
+ right = _radius_left_end(part, index + 1)
+ return (x, right, +1) if left >= right else (x, left, -1)
+
+
+def _jsonable(value: Any) -> Any:
+ if isinstance(value, tuple):
+ return [_jsonable(item) for item in value]
+ if isinstance(value, list):
+ return [_jsonable(item) for item in value]
+ if isinstance(value, dict):
+ return {key: _jsonable(item) for key, item in value.items()}
+ return value
+
+
+def _tuplify(value: Any) -> Any:
+ if isinstance(value, list):
+ return tuple(_tuplify(item) for item in value)
+ return value
+
+
+# -- the base every feature shares -------------------------------------------
+
+
+_REGISTRY: dict[str, type] = {}
+
+
+def _register(cls):
+ _REGISTRY[cls.KIND] = cls
+ return cls
+
+
+@dataclass(frozen=True, kw_only=True)
+class _Base:
+ id: str
+
+ KIND: ClassVar[str] = ""
+ #: ISO 128-3: a rib or web cut longitudinally is not hatched. Features that
+ #: can be one override this as a real field.
+ no_section_hatch = False
+ #: True when the feature's *front* primitives describe only the +Y half of
+ #: an axisymmetric cut, so the view engine mirrors them about the axis. A
+ #: keyway is not axisymmetric (it sits on one face); a ring groove is.
+ axisymmetric: ClassVar[bool] = False
+
+ def validate(self, part) -> None:
+ if not isinstance(self.id, str) or not self.id.strip():
+ raise ValueError("id: a non-empty feature id is required.")
+
+ def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
+ return ()
+
+ def cut_prims(self, part, plane, ctx: dict) -> tuple[Prim, ...]:
+ return self.prims(part, "front", ctx)
+
+ def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
+ return ()
+
+ def removal_box(self, part) -> tuple[float, float, float, float] | None:
+ return None
+
+ def cut_spans(self, part, p1, unit) -> tuple[tuple[float, float], ...]:
+ """Where a cutting line lies inside the material this feature removes.
+
+ Parameters along ``unit`` measured from ``p1``, in the front view's own
+ plane. The default is the exact chord of ``removal_box``, which is the
+ truth for a feature whose removal really is a rectangle. A feature
+ whose removal is a circle or an obround **must** override this: the
+ chord of a hole shrinks to nothing as the plane leaves its centre,
+ while its bounding box stays the full diameter wide, so a box-based cut
+ face shows an 8 mm gap through solid material where the plane merely
+ grazes the hole.
+ """
+ return _box_chord(self.removal_box(part), p1, unit)
+
+ def cross_section(self, part, station: float) -> dict | None:
+ """This feature's shape in a **transverse** cut face at ``station``.
+
+ ``{"start_deg", "end_deg", "path", "radius"}`` - the outer boundary of
+ the cut face keeps its arc from ``start_deg`` counter-clockwise to
+ ``end_deg`` (the material that survives the feature) and is closed by
+ ``path``, a polyline running from the point at ``start_deg`` to the
+ point at ``end_deg``, which the engine walks backwards to close the
+ loop. ``radius`` is the outside radius the record was built on, so the
+ engine can refuse to graft it onto a face of another size rather than
+ draw a notch that does not reach the surface.
+
+ ``None`` means the feature has no transverse projection here. If the
+ plane really passes through it, the honesty rule reports it instead of
+ hatching over it.
+ """
+ return None
+
+ def radial_edit(self, part) -> tuple[dict, ...]:
+ return ()
+
+ def corner_edit(self, part) -> dict | None:
+ return None
+
+ def outline_edit(self, part) -> dict | None:
+ return None
+
+ def omission(self, part, view: str) -> str | None:
+ return f"{type(self).__name__} has no {view} projection"
+
+ def to_dict(self) -> dict:
+ payload = {"kind": self.KIND}
+ for field_ in fields(self):
+ payload[field_.name] = _jsonable(getattr(self, field_.name))
+ return payload
+
+
+def feature_from_dict(d: dict) -> Feature:
+ """Build a feature from its dict form; the ``kind`` key selects the class."""
+ if not isinstance(d, dict):
+ raise ValueError(f"expected a dict with a 'kind' key, got {type(d).__name__}.")
+ kind = str(d.get("kind", "")).strip().lower()
+ if kind not in _REGISTRY:
+ raise ValueError(f"unknown feature kind {d.get('kind')!r}. Known kinds: {FEATURE_KINDS}.")
+ cls = _REGISTRY[kind]
+ names = {field_.name for field_ in fields(cls)}
+ payload = {key: value for key, value in d.items() if key != "kind"}
+ unknown = sorted(set(payload) - names)
+ if unknown:
+ raise ValueError(f"{kind}: unknown key(s) {unknown}; it takes {sorted(names)}.")
+ try:
+ return cls(**{key: _tuplify(value) for key, value in payload.items()})
+ except TypeError as exc:
+ raise ValueError(f"{kind}: {exc}") from exc
+
+
+# -- revolved features -------------------------------------------------------
+
+
+@_register
+@dataclass(frozen=True, kw_only=True)
+class Chamfer(_Base):
+ """ISO 128-3 corner chamfer at an end or a step. ``size`` is the axial leg."""
+
+ KIND: ClassVar[str] = "chamfer"
+ at: str
+ size: float
+ angle: float = 45.0
+
+ def _legs(self) -> tuple[float, float]:
+ """(axial leg, radial leg).
+
+ 45 degrees is special-cased to an exact 1.0 slope:
+ ``math.tan(math.radians(45))`` is 0.9999999999999999, and the
+ overwhelmingly common chamfer would otherwise carry that error into
+ every coordinate it produces.
+ """
+ angle = float(self.angle)
+ slope = 1.0 if abs(angle - 45.0) < 1e-12 else math.tan(math.radians(angle))
+ return float(self.size), float(self.size) * slope
+
+ def validate(self, part) -> None:
+ super().validate(part)
+ part = _require_revolved(part, "Chamfer")
+ _number(self.size, "size")
+ angle = _number(self.angle, "angle")
+ if not 0.0 < angle < 90.0:
+ raise ValueError(f"angle: a chamfer angle is between 0 and 90 degrees, got {angle}.")
+ x, radius, direction = _anchor(self, part)
+ axial, radial = self._legs()
+ if radial >= radius:
+ raise ValueError(
+ f"size {self.size} at {self.at!r} cuts {radial:.3f} mm off a {radius:.3f} mm "
+ "radius: the chamfer is larger than the material it sits on."
+ )
+ index = _anchor_segment(self, part)
+ segment_length = part.segments[index].length
+ if axial > segment_length + _EPS:
+ far = x + direction * segment_length
+ raise ValueError(
+ f"size {self.size} at {self.at!r} runs {axial:g} mm along the axis, past "
+ f"segment {index}'s {segment_length:g} mm: the chamfer would cross the step "
+ f"at x={far:g} and be anchored at a radius that segment does not have."
+ )
+ del x
+
+ def corner_edit(self, part) -> dict:
+ x, radius, direction = _anchor(self, _require_revolved(part, "Chamfer"))
+ axial, radial = self._legs()
+ if direction > 0:
+ before, after = (x, radius - radial), (x + axial, radius)
+ else:
+ before, after = (x - axial, radius), (x, radius - radial)
+ return {"corner": (x, radius), "before": before, "after": after, "arc": None}
+
+ def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
+ if view != "side":
+ return ()
+ part = _require_revolved(part, "Chamfer")
+ _, radius, _ = _anchor(self, part)
+ _, radial = self._legs()
+ return (Circle((0.0, 0.0), radius - radial, "visible"),)
+
+ def removal_box(self, part):
+ edit = self.corner_edit(part)
+ xs = (edit["before"][0], edit["after"][0])
+ ys = (edit["before"][1], edit["after"][1])
+ return (min(xs), min(ys), max(xs), max(ys))
+
+ def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
+ if view != "front":
+ return ()
+ edit = self.corner_edit(part)
+ return (
+ DimIntent(
+ kind="aligned",
+ p1=edit["before"],
+ p2=edit["after"],
+ text_override=f"{float(self.size):g}x{float(self.angle):g}°",
+ feature=self.id,
+ ),
+ )
+
+ def omission(self, part, view: str) -> str | None:
+ return None if view in ("front", "side") else f"a chamfer has no {view} projection"
+
+
+@_register
+@dataclass(frozen=True, kw_only=True)
+class Fillet(_Base):
+ """A tangent fillet radius in the inside corner of a step."""
+
+ KIND: ClassVar[str] = "fillet"
+ at: str
+ radius: float
+
+ def validate(self, part) -> None:
+ super().validate(part)
+ part = _require_revolved(part, "Fillet")
+ radius = _number(self.radius, "radius")
+ kind, index = _parse_at(self.at, part)
+ if kind != "step":
+ raise ValueError(
+ f"at {self.at!r}: a fillet is defined at a step ('step:'); an end "
+ "corner takes a Chamfer."
+ )
+ left = _radius_right_end(part, index)
+ right = _radius_left_end(part, index + 1)
+ if abs(left - right) < _EPS:
+ raise ValueError(f"at {self.at!r}: the two segments have the same diameter, no step.")
+ if radius >= abs(left - right):
+ raise ValueError(
+ f"radius {radius} is not smaller than the step height {abs(left - right):.3f} mm."
+ )
+ shortest = min(part.segments[index].length, part.segments[index + 1].length)
+ if radius >= shortest:
+ raise ValueError(
+ f"radius {radius} is not smaller than the shortest segment {shortest}."
+ )
+
+ def corner_edit(self, part) -> dict:
+ part = _require_revolved(part, "Fillet")
+ _, index = _parse_at(self.at, part)
+ x = segment_bounds(part)[index][1]
+ left = _radius_right_end(part, index)
+ right = _radius_left_end(part, index + 1)
+ radius = float(self.radius)
+ if left < right: # step up going right: the small cylinder is on the left
+ corner = (x, left)
+ before = (x - radius, left)
+ after = (x, left + radius)
+ center = (x - radius, left + radius)
+ arc = Arc(center, radius, 270.0, 360.0, "visible")
+ else: # step down going right: the small cylinder is on the right
+ corner = (x, right)
+ before = (x, right + radius)
+ after = (x + radius, right)
+ center = (x + radius, right + radius)
+ arc = Arc(center, radius, 180.0, 270.0, "visible")
+ return {"corner": corner, "before": before, "after": after, "arc": arc}
+
+ def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
+ if view != "front":
+ return ()
+ edit = self.corner_edit(part)
+ return (
+ DimIntent(
+ kind="radius",
+ p1=edit["arc"].center,
+ p2=edit["before"],
+ text_override=f"R{float(self.radius):g}",
+ feature=self.id,
+ ),
+ )
+
+ def omission(self, part, view: str) -> str | None:
+ if view == "front":
+ return None
+ return "a step fillet projects onto the end view as a circle that coincides with the step"
+
+
+@_register
+@dataclass(frozen=True, kw_only=True)
+class Keyway(_Base):
+ """DIN 6885-1 keyway on the +Y face of a segment.
+
+ Width and depth come from ``engineering.keyway.keyway_dimensions`` unless
+ both are given. Because the slot is modelled on the +Y face, the front view
+ shows its floor and its two end walls as visible lines; the width edges
+ coincide with the silhouette and are not drawn twice.
+ """
+
+ KIND: ClassVar[str] = "keyway"
+ segment: int
+ length: float
+ offset: float = 0.0
+ from_end: str = "left"
+ width: float | None = None
+ depth: float | None = None
+
+ def _table(self, part) -> dict:
+ index = _segment_index(part, self.segment)
+ diameter = part.segments[index].d_outer
+ if self.width is not None and self.depth is not None:
+ return {"width": float(self.width), "depth_shaft": float(self.depth)}
+ try:
+ table = keyway_dimensions(diameter)
+ except ValueError as exc:
+ raise ValueError(f"DIN 6885: {exc}") from exc
+ return {
+ "width": float(self.width) if self.width is not None else table["width"],
+ "depth_shaft": float(self.depth) if self.depth is not None else table["depth_shaft"],
+ }
+
+ def _span(self, part) -> tuple[float, float, float, float, float]:
+ index = _segment_index(part, self.segment)
+ x0, x1 = segment_bounds(part)[index]
+ table = self._table(part)
+ offset = float(self.offset)
+ length = float(self.length)
+ if str(self.from_end).strip().lower() == "right":
+ xa = x1 - offset - length
+ else:
+ xa = x0 + offset
+ radius = part.segments[index].d_outer / 2.0
+ return xa, xa + length, radius, table["width"], table["depth_shaft"]
+
+ def validate(self, part) -> None:
+ super().validate(part)
+ part = _require_revolved(part, "Keyway")
+ index = _segment_index(part, self.segment)
+ _number(self.length, "length")
+ _number(self.offset, "offset", positive=False)
+ if str(self.from_end).strip().lower() not in ("left", "right"):
+ raise ValueError(f"from_end: expected 'left' or 'right', got {self.from_end!r}.")
+ segment_length = part.segments[index].length
+ if float(self.offset) + float(self.length) > segment_length + _EPS:
+ raise ValueError(
+ f"length {self.length} + offset {self.offset} exceeds segment {index}'s "
+ f"{segment_length} mm."
+ )
+ table = self._table(part)
+ radius = part.segments[index].d_outer / 2.0
+ if table["depth_shaft"] >= radius:
+ raise ValueError(
+ f"depth {table['depth_shaft']} mm is not smaller than the radius {radius} mm."
+ )
+
+ def _notch(self, part) -> dict:
+ """The notch the slot cuts out of the round section, once.
+
+ The end view draws it and a transverse cut face closes on it, so both
+ come out of this one calculation and cannot disagree. ``Arc`` is CCW
+ from start to end, so the shaft that SURVIVES the slot runs from the
+ left notch wall the long way round to the right one; emitting
+ (right -> left) would draw the ~31 degree cap over the opening instead
+ of the ~329 degrees of material.
+ """
+ _xa, _xb, radius, width, depth = self._span(part)
+ half = width / 2.0
+ top = math.sqrt(max(radius * radius - half * half, 0.0))
+ floor = radius - depth
+ return {
+ "radius": radius,
+ "start_deg": math.degrees(math.atan2(top, -half)) % 360.0,
+ "end_deg": math.degrees(math.atan2(top, half)) % 360.0,
+ "path": ((-half, top), (-half, floor), (half, floor), (half, top)),
+ }
+
+ def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
+ part = _require_revolved(part, "Keyway")
+ xa, xb, radius, width, depth = self._span(part)
+ if view == "front":
+ floor = radius - depth
+ return (
+ Line((xa, radius), (xa, floor), "visible"),
+ Line((xa, floor), (xb, floor), "visible"),
+ Line((xb, floor), (xb, radius), "visible"),
+ )
+ if view == "side":
+ notch = self._notch(part)
+ path = notch["path"]
+ return (
+ Arc((0.0, 0.0), radius, notch["start_deg"], notch["end_deg"], "visible"),
+ *(Line(path[index], path[index + 1], "visible") for index in range(len(path) - 1)),
+ )
+ return ()
+
+ def cross_section(self, part, station: float) -> dict | None:
+ """The notch, when the transverse plane falls inside the slot's length."""
+ part = _require_revolved(part, "Keyway")
+ xa, xb, _radius, _width, _depth = self._span(part)
+ if not xa - _EPS <= float(station) <= xb + _EPS:
+ return None
+ return self._notch(part)
+
+ def removal_box(self, part):
+ xa, xb, radius, _, depth = self._span(part)
+ return (xa, radius - depth, xb, radius)
+
+ def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
+ xa, xb, radius, width, depth = self._span(part)
+ if view == "front":
+ return (DimIntent(kind="linear", p1=(xa, radius), p2=(xb, radius), feature=self.id),)
+ if view == "side":
+ floor = radius - depth
+ return (
+ DimIntent(
+ kind="linear",
+ p1=(-width / 2.0, floor),
+ p2=(width / 2.0, floor),
+ feature=self.id,
+ ),
+ DimIntent(
+ kind="linear",
+ p1=(0.0, -radius),
+ p2=(0.0, floor),
+ text_override=f"{radius + floor:g}",
+ feature=self.id,
+ ),
+ )
+ return ()
+
+ def omission(self, part, view: str) -> str | None:
+ if view in ("front", "side"):
+ return None
+ return "a keyway on the +Y face coincides with the outline in the top view"
+
+
+@_register
+@dataclass(frozen=True, kw_only=True)
+class Thread(_Base):
+ """An ISO 261 thread drawn with the ISO 6410 representation."""
+
+ KIND: ClassVar[str] = "thread"
+ segment: int
+ designation: str
+ length: float
+ offset: float = 0.0
+ from_end: str = "left"
+ internal: bool = False
+
+ def _parsed(self) -> dict:
+ return parse_designation(self.designation)
+
+ def _span(self, part) -> tuple[float, float, float]:
+ index = _segment_index(part, self.segment)
+ x0, x1 = segment_bounds(part)[index]
+ length = float(self.length)
+ if str(self.from_end).strip().lower() == "right":
+ xa = x1 - float(self.offset) - length
+ else:
+ xa = x0 + float(self.offset)
+ return xa, xa + length, self._parsed()["d"]
+
+ def validate(self, part) -> None:
+ super().validate(part)
+ part = _require_revolved(part, "Thread")
+ index = _segment_index(part, self.segment)
+ parsed = self._parsed()
+ _number(self.length, "length")
+ _number(self.offset, "offset", positive=False)
+ segment = part.segments[index]
+ if float(self.offset) + float(self.length) > segment.length + _EPS:
+ raise ValueError(
+ f"length {self.length} + offset {self.offset} exceeds segment {index}'s "
+ f"{segment.length} mm."
+ )
+ if self.internal:
+ # ISO 6410: a tapped hole is modelled with the *drilled* bore, i.e.
+ # the minor diameter at 0.8 x major, and the major is what the
+ # section hatch then runs to (radial_edit below).
+ wanted = MINOR_RATIO * parsed["d"]
+ if abs(wanted - segment.d_inner) > 0.01:
+ raise ValueError(
+ f"{parsed['designation']} internal is drawn with its minor diameter at "
+ f"{wanted:g} mm (ISO 6410, 0.8 x major) but segment {index}'s bore is "
+ f"{segment.d_inner:g} mm. Drill the segment to the minor diameter, or "
+ "name the thread that fits it."
+ )
+ elif abs(parsed["d"] - segment.d_outer) > 0.01:
+ raise ValueError(
+ f"{parsed['designation']} has a major diameter of {parsed['d']:g} mm but "
+ f"segment {index}'s outside is {segment.d_outer:g} mm. Cut the segment to the "
+ "thread diameter, or name the thread that fits it."
+ )
+
+ def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
+ part = _require_revolved(part, "Thread")
+ xa, xb, d = self._span(part)
+ if view == "front":
+ return thread_axial_prims(x0=xa, length=xb - xa, d=d, internal=bool(self.internal))
+ if view == "side":
+ return thread_end_prims(center=(0.0, 0.0), d=d, internal=bool(self.internal))
+ return ()
+
+ def radial_edit(self, part) -> tuple[dict, ...]:
+ if not self.internal:
+ return ()
+ # ISO 6410 section convention: the hatch of an internal thread runs to
+ # the major diameter, so the cut face's bore is the major, not the minor.
+ xa, xb, d = self._span(part)
+ return ({"kind": "inner", "x0": xa, "x1": xb, "radius": d / 2.0},)
+
+ def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
+ if view != "front":
+ return ()
+ xa, _, d = self._span(part)
+ return (
+ DimIntent(
+ kind="diameter",
+ p1=(xa, d / 2.0),
+ p2=(xa, -d / 2.0),
+ text_override=self._parsed()["designation"],
+ feature=self.id,
+ ),
+ )
+
+ def omission(self, part, view: str) -> str | None:
+ return None if view in ("front", "side") else f"a thread has no {view} projection"
+
+ @property
+ def minor_ratio(self) -> float:
+ return MINOR_RATIO
+
+
+@_register
+@dataclass(frozen=True, kw_only=True)
+class Undercut(_Base):
+ """A DIN 509 relief groove drawn from the profile the standard publishes.
+
+ ``profile`` is a sequence of ``(along, depth)`` pairs in millimetres,
+ measured from the shoulder corner into the smaller cylinder. With no
+ explicit profile the registered DIN 509 table is asked - and refuses by
+ name, because no row is transcribed in this build. Nothing is reconstructed.
+ """
+
+ KIND: ClassVar[str] = "undercut"
+ axisymmetric: ClassVar[bool] = True
+ at: str
+ form: str = "E"
+ profile: tuple[tuple[float, float], ...] | None = None
+
+ def _profile(self, part) -> tuple[tuple[float, float], ...]:
+ if self.profile is not None:
+ return tuple((float(a), float(b)) for a, b in self.profile)
+ _, radius, _ = _shoulder(self, part)
+ return undercut_profile(self.form, radius * 2.0)
+
+ def validate(self, part) -> None:
+ super().validate(part)
+ part = _require_revolved(part, "Undercut")
+ _parse_at(self.at, part)
+ profile = self._profile(part)
+ if len(profile) < 2:
+ raise ValueError("profile: a relief profile needs at least two (along, depth) pairs.")
+ previous = -math.inf
+ for index, (along, depth) in enumerate(profile):
+ _number(along, f"profile[{index}].along", positive=False)
+ _number(depth, f"profile[{index}].depth", positive=False)
+ if along < previous - _EPS:
+ raise ValueError(f"profile[{index}].along: the profile must run monotonically.")
+ previous = along
+ _, radius, _ = _shoulder(self, part)
+ deepest = max(depth for _, depth in profile)
+ if deepest >= radius:
+ raise ValueError(
+ f"profile: the deepest relief {deepest} mm is not smaller than the "
+ f"{radius} mm radius it is cut into."
+ )
+
+ def _points(self, part) -> tuple[tuple[float, float], ...]:
+ x, radius, direction = _shoulder(self, part)
+ return tuple(
+ (x + direction * along, radius - depth) for along, depth in self._profile(part)
+ )
+
+ def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
+ if view != "front":
+ return ()
+ return (Poly(self._points(_require_revolved(part, "Undercut")), False, "visible"),)
+
+ def removal_box(self, part):
+ points = self._points(part)
+ xs = [p[0] for p in points]
+ ys = [p[1] for p in points]
+ return (min(xs), min(ys), max(xs), max(ys))
+
+ def omission(self, part, view: str) -> str | None:
+ return None if view == "front" else f"a relief groove has no {view} projection"
+
+
+@_register
+@dataclass(frozen=True, kw_only=True)
+class RetainingGroove(_Base):
+ """A DIN 471 (shaft) or DIN 472 (bore) retaining-ring groove."""
+
+ KIND: ClassVar[str] = "retaining_groove"
+ axisymmetric: ClassVar[bool] = True
+ x: float
+ d: float
+ where: str = "shaft"
+ m: float | None = None
+ d2: float | None = None
+ standard: str | None = None
+
+ def _standard(self) -> str:
+ if self.standard:
+ return " ".join(str(self.standard).split()).upper()
+ return "DIN 471" if str(self.where).strip().lower() == "shaft" else "DIN 472"
+
+ def _dims(self) -> dict:
+ if self.m is not None and self.d2 is not None:
+ return {"m": float(self.m), "d2": float(self.d2)}
+ return ring_groove_dims(self._standard(), float(self.d))
+
+ def validate(self, part) -> None:
+ super().validate(part)
+ part = _require_revolved(part, "RetainingGroove")
+ where = str(self.where).strip().lower()
+ if where not in ("shaft", "bore"):
+ raise ValueError(f"where: expected 'shaft' or 'bore', got {self.where!r}.")
+ x = _number(self.x, "x", positive=False)
+ _number(self.d, "d")
+ if not 0.0 <= x <= part_length(part) + _EPS:
+ raise ValueError(f"x {x} is outside the part (0 .. {part_length(part)} mm).")
+ dims = self._dims()
+ _number(dims["m"], "m")
+ _number(dims["d2"], "d2")
+ radius = outer_radius_at(part, x)
+ bore = inner_radius_at(part, x)
+ if where == "shaft" and dims["d2"] / 2.0 >= radius:
+ raise ValueError(
+ f"d2 {dims['d2']} mm does not cut into the {radius * 2.0:g} mm shaft at x={x}."
+ )
+ if where == "bore" and dims["d2"] / 2.0 <= bore:
+ raise ValueError(
+ f"d2 {dims['d2']} mm does not cut into the {bore * 2.0:g} mm bore at x={x}."
+ )
+
+ def _span(self, part) -> tuple[float, float, float, float]:
+ dims = self._dims()
+ x = float(self.x)
+ half = dims["m"] / 2.0
+ surface = (
+ outer_radius_at(part, x)
+ if str(self.where).strip().lower() == "shaft"
+ else inner_radius_at(part, x)
+ )
+ return x - half, x + half, dims["d2"] / 2.0, surface
+
+ def radial_edit(self, part) -> tuple[dict, ...]:
+ x0, x1, groove_r, _ = self._span(part)
+ kind = "outer" if str(self.where).strip().lower() == "shaft" else "inner"
+ return ({"kind": kind, "x0": x0, "x1": x1, "radius": groove_r},)
+
+ def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
+ if view != "front":
+ return ()
+ x0, x1, groove_r, surface = self._span(_require_revolved(part, "RetainingGroove"))
+ return (
+ Line((x0, surface), (x0, groove_r), "visible"),
+ Line((x0, groove_r), (x1, groove_r), "visible"),
+ Line((x1, groove_r), (x1, surface), "visible"),
+ )
+
+ def removal_box(self, part):
+ x0, x1, groove_r, surface = self._span(part)
+ return (x0, min(groove_r, surface), x1, max(groove_r, surface))
+
+ def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
+ if view != "front":
+ return ()
+ x0, x1, groove_r, _ = self._span(part)
+ dims = self._dims()
+ return (
+ DimIntent(kind="linear", p1=(x0, groove_r), p2=(x1, groove_r), feature=self.id),
+ DimIntent(
+ kind="diameter",
+ p1=(float(self.x), groove_r),
+ p2=(float(self.x), -groove_r),
+ text_override=f"⌀{dims['d2']:g} ({self._standard()})",
+ feature=self.id,
+ ),
+ )
+
+ def omission(self, part, view: str) -> str | None:
+ return None if view == "front" else f"a ring groove has no {view} projection"
+
+
+@_register
+@dataclass(frozen=True, kw_only=True)
+class CentreHole(_Base):
+ """A DIN 332-1 centre hole, forms A and B. Form R is refused, not approximated."""
+
+ KIND: ClassVar[str] = "centre_hole"
+ at: str = "end"
+ form: str = "A"
+ size: float = 0.0
+ d1: float | None = None
+ d2: float | None = None
+ d3: float | None = None
+ t: float | None = None
+
+ def _dims(self) -> dict:
+ form = str(self.form).strip().upper()
+ explicit = self.d1 is not None and self.d2 is not None and self.t is not None
+ if form == "B":
+ explicit = explicit and self.d3 is not None
+ if explicit:
+ return {
+ "d1": float(self.d1),
+ "d2": float(self.d2),
+ "d3": float(self.d3) if self.d3 is not None else None,
+ "t": float(self.t),
+ }
+ return centre_hole_dims(self.form, float(self.size))
+
+ def validate(self, part) -> None:
+ super().validate(part)
+ part = _require_revolved(part, "CentreHole")
+ _parse_at(self.at, part, allow_step=False)
+ if str(self.form).strip().upper() == "R":
+ # the table module owns the wording; ask it so there is one message
+ centre_hole_dims("R", float(self.size))
+ dims = self._dims()
+ _number(dims["d1"], "d1")
+ _number(dims["d2"], "d2")
+ _number(dims["t"], "t")
+ if dims["d2"] <= dims["d1"]:
+ raise ValueError(f"d2 {dims['d2']} must be larger than d1 {dims['d1']}.")
+ kind, _ = _parse_at(self.at, part, allow_step=False)
+ x = 0.0 if kind == "start" else part_length(part)
+ if dims["d2"] / 2.0 >= outer_radius_at(part, x):
+ raise ValueError(
+ f"d2 {dims['d2']} mm does not fit the {outer_radius_at(part, x) * 2.0:g} mm face."
+ )
+
+ def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
+ part = _require_revolved(part, "CentreHole")
+ dims = self._dims()
+ kind, _ = _parse_at(self.at, part, allow_step=False)
+ face = 0.0 if kind == "start" else part_length(part)
+ step = 1.0 if kind == "start" else -1.0
+ r1, r2 = dims["d1"] / 2.0, dims["d2"] / 2.0
+ cone = (r2 - r1) / math.tan(math.radians(30.0)) # 60 deg included angle
+ if view == "front":
+ prims: list[Prim] = [
+ Line((face, r2), (face + step * cone, r1), "hidden"),
+ Line((face, -r2), (face + step * cone, -r1), "hidden"),
+ Line((face + step * cone, r1), (face + step * dims["t"], r1), "hidden"),
+ Line((face + step * cone, -r1), (face + step * dims["t"], -r1), "hidden"),
+ Line(
+ (face + step * dims["t"], -r1),
+ (face + step * dims["t"], r1),
+ "hidden",
+ ),
+ ]
+ if str(self.form).strip().upper() == "B" and dims.get("d3"):
+ r3 = dims["d3"] / 2.0
+ guard = (r3 - r2) / math.tan(math.radians(60.0)) # 120 deg protective cone
+ prims.insert(0, Line((face, r3), (face + step * guard, r2), "hidden"))
+ prims.insert(1, Line((face, -r3), (face + step * guard, -r2), "hidden"))
+ return tuple(prims)
+ if view == "side":
+ circles: list[Prim] = [
+ Circle((0.0, 0.0), r1, "hidden"),
+ Circle((0.0, 0.0), r2, "hidden"),
+ ]
+ if str(self.form).strip().upper() == "B" and dims.get("d3"):
+ circles.append(Circle((0.0, 0.0), dims["d3"] / 2.0, "hidden"))
+ return tuple(circles)
+ return ()
+
+ def removal_box(self, part):
+ dims = self._dims()
+ kind, _ = _parse_at(self.at, part, allow_step=False)
+ face = 0.0 if kind == "start" else part_length(part)
+ step = 1.0 if kind == "start" else -1.0
+ far = face + step * dims["t"]
+ outer = max(dims["d2"], dims.get("d3") or 0.0) / 2.0
+ return (min(face, far), -outer, max(face, far), outer)
+
+ def omission(self, part, view: str) -> str | None:
+ return None if view in ("front", "side") else f"a centre hole has no {view} projection"
+
+
+@_register
+@dataclass(frozen=True, kw_only=True)
+class ORingGroove(_Base):
+ """An ISO 3601-2 radial O-ring housing. An axial (face) groove is refused."""
+
+ KIND: ClassVar[str] = "oring_groove"
+ axisymmetric: ClassVar[bool] = True
+ x: float
+ cord: float
+ where: str = "shaft"
+ housing: str = "radial"
+ b: float | None = None
+ h: float | None = None
+
+ def _dims(self) -> dict:
+ if self.b is not None and self.h is not None:
+ return {"b": float(self.b), "h": float(self.h)}
+ return oring_groove_dims(float(self.cord), str(self.housing))
+
+ def validate(self, part) -> None:
+ super().validate(part)
+ part = _require_revolved(part, "ORingGroove")
+ if str(self.housing).strip().lower() not in ORING_KINDS:
+ raise ValueError(f"housing: expected one of {ORING_KINDS}, got {self.housing!r}.")
+ if str(self.housing).strip().lower() == "axial":
+ raise ValueError(
+ "housing 'axial': a face groove is not on the revolved silhouette and is "
+ "refused rather than drawn as if it were radial. Model the face as a "
+ "prismatic pocket."
+ )
+ if str(self.where).strip().lower() not in ("shaft", "bore"):
+ raise ValueError(f"where: expected 'shaft' or 'bore', got {self.where!r}.")
+ x = _number(self.x, "x", positive=False)
+ _number(self.cord, "cord")
+ if not 0.0 <= x <= part_length(part) + _EPS:
+ raise ValueError(f"x {x} is outside the part (0 .. {part_length(part)} mm).")
+ dims = self._dims()
+ _number(dims["b"], "b")
+ _number(dims["h"], "h")
+ radius = outer_radius_at(part, x)
+ if str(self.where).strip().lower() == "shaft" and dims["h"] >= radius:
+ raise ValueError(f"h {dims['h']} mm is not smaller than the radius {radius} mm.")
+
+ def _span(self, part) -> tuple[float, float, float, float]:
+ dims = self._dims()
+ x = float(self.x)
+ half = dims["b"] / 2.0
+ if str(self.where).strip().lower() == "shaft":
+ surface = outer_radius_at(part, x)
+ return x - half, x + half, surface - dims["h"], surface
+ surface = inner_radius_at(part, x)
+ return x - half, x + half, surface + dims["h"], surface
+
+ def radial_edit(self, part) -> tuple[dict, ...]:
+ x0, x1, groove_r, _ = self._span(part)
+ kind = "outer" if str(self.where).strip().lower() == "shaft" else "inner"
+ return ({"kind": kind, "x0": x0, "x1": x1, "radius": groove_r},)
+
+ def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
+ if view != "front":
+ return ()
+ x0, x1, groove_r, surface = self._span(_require_revolved(part, "ORingGroove"))
+ return (
+ Line((x0, surface), (x0, groove_r), "visible"),
+ Line((x0, groove_r), (x1, groove_r), "visible"),
+ Line((x1, groove_r), (x1, surface), "visible"),
+ )
+
+ def removal_box(self, part):
+ x0, x1, groove_r, surface = self._span(part)
+ return (x0, min(groove_r, surface), x1, max(groove_r, surface))
+
+ def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
+ if view != "front":
+ return ()
+ x0, x1, groove_r, _ = self._span(part)
+ return (DimIntent(kind="linear", p1=(x0, groove_r), p2=(x1, groove_r), feature=self.id),)
+
+ def omission(self, part, view: str) -> str | None:
+ return None if view == "front" else f"an O-ring groove has no {view} projection"
+
+
+@_register
+@dataclass(frozen=True, kw_only=True)
+class CrossHole(_Base):
+ """A radial hole through (or into) a revolved part.
+
+ Only a radial hole (``angle`` 90 degrees) is projected. An inclined cross
+ hole is refused rather than drawn as if it were radial.
+ """
+
+ KIND: ClassVar[str] = "cross_hole"
+ x: float
+ diameter: float
+ angle: float = 90.0
+ depth: float | None = None
+
+ def validate(self, part) -> None:
+ super().validate(part)
+ part = _require_revolved(part, "CrossHole")
+ x = _number(self.x, "x", positive=False)
+ diameter = _number(self.diameter, "diameter")
+ angle = _number(self.angle, "angle", positive=False)
+ if abs(angle - 90.0) > _EPS:
+ raise ValueError(
+ f"angle {angle}: only a radial cross hole (angle=90) is projected by this "
+ "engine; an inclined hole is refused rather than drawn as if it were radial."
+ )
+ if not 0.0 <= x <= part_length(part) + _EPS:
+ raise ValueError(f"x {x} is outside the part (0 .. {part_length(part)} mm).")
+ radius = outer_radius_at(part, x)
+ if diameter >= 2.0 * radius:
+ raise ValueError(
+ f"diameter {diameter} mm is not smaller than the {2.0 * radius:g} mm outside "
+ f"diameter at x={x}."
+ )
+ if self.depth is not None:
+ depth = _number(self.depth, "depth")
+ if depth > 2.0 * radius + _EPS:
+ raise ValueError(f"depth {depth} mm is deeper than the part at x={x}.")
+
+ def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
+ part = _require_revolved(part, "CrossHole")
+ x = float(self.x)
+ half = float(self.diameter) / 2.0
+ radius = outer_radius_at(part, x)
+ if view == "front":
+ bottom = -radius if self.depth is None else radius - float(self.depth)
+ prims: list[Prim] = [
+ Line((x - half, bottom), (x - half, radius), "hidden"),
+ Line((x + half, bottom), (x + half, radius), "hidden"),
+ ]
+ if self.depth is not None:
+ prims.append(Line((x - half, bottom), (x + half, bottom), "hidden"))
+ return tuple(prims)
+ if view == "side":
+ return (
+ Line((-radius, half), (radius, half), "hidden"),
+ Line((-radius, -half), (radius, -half), "hidden"),
+ )
+ return ()
+
+ def removal_box(self, part):
+ x = float(self.x)
+ half = float(self.diameter) / 2.0
+ radius = outer_radius_at(part, x)
+ bottom = -radius if self.depth is None else radius - float(self.depth)
+ return (x - half, bottom, x + half, radius)
+
+ def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
+ if view != "front":
+ return ()
+ x = float(self.x)
+ half = float(self.diameter) / 2.0
+ return (
+ DimIntent(
+ kind="diameter",
+ p1=(x - half, 0.0),
+ p2=(x + half, 0.0),
+ text_override=f"⌀{float(self.diameter):g}",
+ feature=self.id,
+ ),
+ )
+
+ def omission(self, part, view: str) -> str | None:
+ return None if view in ("front", "side") else f"a cross hole has no {view} projection"
+
+
+@_register
+@dataclass(frozen=True, kw_only=True)
+class HoleCircle(_Base):
+ """A bolt circle on a face: hidden pairs in the axial view, real holes in the end view."""
+
+ KIND: ClassVar[str] = "hole_circle"
+ x: float
+ pcd: float
+ count: int
+ diameter: float
+ start_angle: float = 0.0
+ thread: str | None = None
+
+ def validate(self, part) -> None:
+ super().validate(part)
+ part = _require_revolved(part, "HoleCircle")
+ x = _number(self.x, "x", positive=False)
+ pcd = _number(self.pcd, "pcd")
+ diameter = _number(self.diameter, "diameter")
+ _number(self.start_angle, "start_angle", positive=False)
+ if int(self.count) < 2:
+ raise ValueError(f"count: a bolt circle needs at least 2 holes, got {self.count}.")
+ if not 0.0 <= x <= part_length(part) + _EPS:
+ raise ValueError(f"x {x} is outside the part (0 .. {part_length(part)} mm).")
+ radius = outer_radius_at(part, x)
+ bore = inner_radius_at(part, x)
+ if pcd / 2.0 + diameter / 2.0 > radius + _EPS:
+ raise ValueError(
+ f"pcd {pcd} with ⌀{diameter} reaches {pcd / 2.0 + diameter / 2.0:.3f} mm, "
+ f"past the {radius:.3f} mm outside radius at x={x}."
+ )
+ if pcd / 2.0 - diameter / 2.0 < bore - _EPS:
+ raise ValueError(
+ f"pcd {pcd} with ⌀{diameter} reaches {pcd / 2.0 - diameter / 2.0:.3f} mm, "
+ f"inside the {bore:.3f} mm bore at x={x}."
+ )
+ if self.thread:
+ parse_designation(self.thread)
+
+ def _centers(self) -> tuple[tuple[float, float], ...]:
+ count = int(self.count)
+ radius = float(self.pcd) / 2.0
+ start = math.radians(float(self.start_angle))
+ return tuple(
+ (
+ radius * math.cos(start + 2.0 * math.pi * k / count),
+ radius * math.sin(start + 2.0 * math.pi * k / count),
+ )
+ for k in range(count)
+ )
+
+ def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
+ part = _require_revolved(part, "HoleCircle")
+ pcd_r = float(self.pcd) / 2.0
+ half = float(self.diameter) / 2.0
+ if view == "front":
+ index, _, _, _ = _segment_at_index(part, float(self.x))
+ x0, x1 = segment_bounds(part)[index]
+ prims: list[Prim] = []
+ for sign in (1.0, -1.0):
+ for offset in (half, -half):
+ y = sign * (pcd_r + offset)
+ prims.append(Line((x0, y), (x1, y), "hidden"))
+ prims.append(Line((x0, sign * pcd_r), (x1, sign * pcd_r), "center"))
+ return tuple(prims)
+ if view == "side":
+ prims = [Circle((0.0, 0.0), pcd_r, "center")]
+ for cx, cy in self._centers():
+ prims.append(Circle((cx, cy), half, "visible"))
+ prims.append(Line((cx - half - 2.0, cy), (cx + half + 2.0, cy), "center"))
+ prims.append(Line((cx, cy - half - 2.0), (cx, cy + half + 2.0), "center"))
+ return tuple(prims)
+ return ()
+
+ def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
+ if view != "side":
+ return ()
+ pcd_r = float(self.pcd) / 2.0
+ half = float(self.diameter) / 2.0
+ first = self._centers()[0]
+ label = self.thread or f"⌀{float(self.diameter):g}"
+ return (
+ DimIntent(
+ kind="diameter",
+ p1=(first[0] - half, first[1]),
+ p2=(first[0] + half, first[1]),
+ text_override=f"{int(self.count)}x {label}",
+ feature=self.id,
+ ),
+ DimIntent(
+ kind="diameter",
+ p1=(-pcd_r, 0.0),
+ p2=(pcd_r, 0.0),
+ text_override=f"⌀{float(self.pcd):g}",
+ feature=self.id,
+ ),
+ )
+
+ def omission(self, part, view: str) -> str | None:
+ return None if view in ("front", "side") else f"a bolt circle has no {view} projection"
+
+
+def _segment_at_index(part: RevolvedPart, x: float) -> tuple[int, float, float, Any]:
+ bounds = zip(segment_bounds(part), part.segments, strict=True)
+ for index, ((x0, x1), segment) in enumerate(bounds):
+ if x0 - _EPS <= x <= x1 + _EPS:
+ return index, x0, x1, segment
+ raise ValueError(f"x={x} is outside the part (0 .. {part_length(part)} mm).")
+
+
+@_register
+@dataclass(frozen=True, kw_only=True)
+class AxialBore(_Base):
+ """A bore drilled in from one end. Expressed through the radial profile."""
+
+ KIND: ClassVar[str] = "axial_bore"
+ at: str = "start"
+ diameter: float
+ depth: float | None = None
+
+ def _span(self, part) -> tuple[float, float]:
+ kind, _ = _parse_at(self.at, part, allow_step=False)
+ length = part_length(part)
+ depth = length if self.depth is None else float(self.depth)
+ return (0.0, depth) if kind == "start" else (length - depth, length)
+
+ def validate(self, part) -> None:
+ super().validate(part)
+ part = _require_revolved(part, "AxialBore")
+ _parse_at(self.at, part, allow_step=False)
+ diameter = _number(self.diameter, "diameter")
+ if self.depth is not None:
+ depth = _number(self.depth, "depth")
+ if depth > part_length(part) + _EPS:
+ raise ValueError(
+ f"depth {depth} mm is longer than the part {part_length(part)} mm."
+ )
+ x0, x1 = self._span(part)
+ for x in (x0 + _EPS, (x0 + x1) / 2.0, x1 - _EPS):
+ if diameter >= 2.0 * outer_radius_at(part, x):
+ raise ValueError(
+ f"diameter {diameter} mm is not smaller than the "
+ f"{2.0 * outer_radius_at(part, x):g} mm outside diameter at x={x:.3f}."
+ )
+ for other in getattr(part, "features", ()):
+ if other is self or not isinstance(other, AxialBore):
+ continue
+ ox0, ox1 = other._span(part)
+ if min(x1, ox1) - max(x0, ox0) > _EPS:
+ raise ValueError(
+ f"bores {self.id!r} and {other.id!r} both remove material over "
+ f"x {max(x0, ox0):.3f}..{min(x1, ox1):.3f}. A bore that changes diameter "
+ "is two segments, not two features."
+ )
+
+ def radial_edit(self, part) -> tuple[dict, ...]:
+ x0, x1 = self._span(part)
+ return ({"kind": "inner", "x0": x0, "x1": x1, "radius": float(self.diameter) / 2.0},)
+
+ def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
+ part = _require_revolved(part, "AxialBore")
+ half = float(self.diameter) / 2.0
+ kind, _ = _parse_at(self.at, part, allow_step=False)
+ x0, x1 = self._span(part)
+ if view == "front" and self.depth is not None:
+ bottom = x1 if kind == "start" else x0
+ return (Line((bottom, -half), (bottom, half), "hidden"),)
+ if view == "side":
+ near = str(ctx.get("side", "right")).strip().lower()
+ open_here = (near == "left" and kind == "start") or (near == "right" and kind == "end")
+ return (Circle((0.0, 0.0), half, "visible" if open_here else "hidden"),)
+ return ()
+
+ def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
+ if view != "side":
+ return ()
+ half = float(self.diameter) / 2.0
+ return (
+ DimIntent(
+ kind="diameter",
+ p1=(-half, 0.0),
+ p2=(half, 0.0),
+ text_override=f"⌀{float(self.diameter):g}",
+ feature=self.id,
+ ),
+ )
+
+ def omission(self, part, view: str) -> str | None:
+ return None # the bore always reaches the drawing through the radial profile
+
+
+@_register
+@dataclass(frozen=True, kw_only=True)
+class GearTeeth(_Base):
+ """Involute spur/helical teeth on a segment, from the repository's own generator."""
+
+ KIND: ClassVar[str] = "gear_teeth"
+ segment: int
+ module: float
+ teeth: int
+ pressure_angle: float = 20.0
+ helix_angle: float = 0.0
+ hand: str = "RH"
+
+ def _radii(self) -> tuple[float, float, float]:
+ module = float(self.module)
+ teeth = int(self.teeth)
+ pitch = module * teeth / 2.0
+ return pitch + module, pitch, pitch - 1.25 * module
+
+ def validate(self, part) -> None:
+ super().validate(part)
+ part = _require_revolved(part, "GearTeeth")
+ index = _segment_index(part, self.segment)
+ _number(self.module, "module")
+ angle = _number(self.pressure_angle, "pressure_angle")
+ if int(self.teeth) < 6:
+ raise ValueError(
+ f"teeth: the involute generator needs at least 6 teeth, got {self.teeth}."
+ )
+ if not 10.0 <= angle <= 35.0:
+ raise ValueError(f"pressure_angle {angle}: expected between 10 and 35 degrees.")
+ if str(self.hand).strip().upper() not in ("RH", "LH"):
+ raise ValueError(f"hand: expected 'RH' or 'LH', got {self.hand!r}.")
+ tip, _, _ = self._radii()
+ outside = part.segments[index].d_outer
+ if abs(tip * 2.0 - outside) > 0.01:
+ raise ValueError(
+ f"m={float(self.module):g} z={int(self.teeth)} gives a tip diameter of "
+ f"{tip * 2.0:g} mm, but segment {index} is {outside:g} mm. Cut the segment to "
+ "the tip diameter, or name the module and tooth count that fit it."
+ )
+
+ def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
+ part = _require_revolved(part, "GearTeeth")
+ index = _segment_index(part, self.segment)
+ _, pitch, root = self._radii()
+ if view == "front":
+ x0, x1 = segment_bounds(part)[index]
+ prims: list[Prim] = [
+ Line((x0, root), (x1, root), "visible"),
+ Line((x0, -root), (x1, -root), "visible"),
+ Line((x0, pitch), (x1, pitch), "center"),
+ Line((x0, -pitch), (x1, -pitch), "center"),
+ ]
+ if abs(float(self.helix_angle)) > _EPS:
+ # ISO 2203: three thin lines at the helix angle mark the hand.
+ mid = (x0 + x1) / 2.0
+ run = (x1 - x0) / 3.0
+ rise = run * math.tan(math.radians(float(self.helix_angle)))
+ sign = 1.0 if str(self.hand).strip().upper() == "RH" else -1.0
+ for offset in (-run, 0.0, run):
+ prims.append(
+ Line(
+ (mid + offset - run / 2.0, -pitch / 2.0),
+ (mid + offset + run / 2.0, -pitch / 2.0 + sign * rise),
+ "phantom",
+ )
+ )
+ return tuple(prims)
+ if view == "side":
+ outline = generate_full_gear_outline(
+ float(self.module),
+ int(self.teeth),
+ float(self.pressure_angle),
+ float(self.helix_angle),
+ str(self.hand).strip().upper(),
+ (0.0, 0.0),
+ )
+ return (
+ Poly(tuple(outline), True, "visible"),
+ Circle((0.0, 0.0), pitch, "center"),
+ )
+ return ()
+
+ def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
+ tip, _, _ = self._radii()
+ if view != "side":
+ return ()
+ return (
+ DimIntent(
+ kind="diameter",
+ p1=(-tip, 0.0),
+ p2=(tip, 0.0),
+ text_override=(f"⌀{tip * 2.0:g} (m={float(self.module):g}, z={int(self.teeth)})"),
+ feature=self.id,
+ ),
+ )
+
+ def omission(self, part, view: str) -> str | None:
+ return None if view in ("front", "side") else f"gear teeth have no {view} projection"
+
+
+# -- prismatic features ------------------------------------------------------
+#
+# View frames for a prismatic part:
+# "front" : the outline's own XY
+# "side" : u = part Y, v = z in [0, thickness] (looking along -X)
+# "top" : u = part X, v = z in [0, thickness] (looking along -Y)
+#
+# THE MACHINED FACE IS v = thickness. The front view looks at the outline from
+# +Z, so the face it shows -- the face a feature placed by its (x, y) is cut
+# from -- is the top of the side and top views, and every blind feature hangs
+# down from there toward v = 0. Hole, Pocket and anything added later must
+# agree on this: a drawing whose pocket is milled from one face and whose blind
+# hole is drilled from the other is silently wrong, and no validator can tell.
+
+
+@_register
+@dataclass(frozen=True, kw_only=True)
+class Hole(_Base):
+ """A hole in a prismatic part, optionally counterbored, countersunk or tapped.
+
+ Drilled from the machined face (``v = thickness``, the one the front view
+ looks at), so a blind hole hangs down from there and a counterbore steps
+ down from the same face -- the convention ``Pocket`` follows too.
+ """
+
+ KIND: ClassVar[str] = "hole"
+ x: float
+ y: float
+ diameter: float
+ depth: float | None = None
+ cbore_d: float | None = None
+ cbore_depth: float | None = None
+ csink_d: float | None = None
+ thread: str | None = None
+
+ def validate(self, part) -> None:
+ super().validate(part)
+ part = _require_prismatic(part, "Hole")
+ _number(self.x, "x", positive=False)
+ _number(self.y, "y", positive=False)
+ diameter = _number(self.diameter, "diameter")
+ if not point_in_outline(part, float(self.x), float(self.y)):
+ raise ValueError(
+ f"({float(self.x):g}, {float(self.y):g}) is outside the part outline "
+ f"{outline_bbox(part)}."
+ )
+ if self.depth is not None:
+ depth = _number(self.depth, "depth")
+ if depth > part.thickness + _EPS:
+ raise ValueError(f"depth {depth} is deeper than the {part.thickness} mm thickness.")
+ for name, value in (("cbore_d", self.cbore_d), ("csink_d", self.csink_d)):
+ if value is not None and _number(value, name) <= diameter:
+ raise ValueError(f"{name} {value} must be larger than the hole {diameter}.")
+ if self.cbore_d is not None and self.cbore_depth is None:
+ raise ValueError("cbore_depth: a counterbore needs its depth.")
+ if self.thread:
+ parsed = parse_designation(self.thread)
+ if abs(parsed["d"] - diameter) > 0.01:
+ raise ValueError(
+ f"{parsed['designation']} has a major diameter of {parsed['d']:g} mm but "
+ f"diameter is {diameter:g} mm."
+ )
+
+ def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
+ part = _require_prismatic(part, "Hole")
+ x, y = float(self.x), float(self.y)
+ half = float(self.diameter) / 2.0
+ if view == "front":
+ prims: list[Prim] = []
+ if self.thread:
+ minor = MINOR_RATIO * float(self.diameter) / 2.0
+ prims.append(Circle((x, y), minor, "visible"))
+ prims.append(Arc((x, y), half, 90.0, 0.0, "visible"))
+ else:
+ prims.append(Circle((x, y), half, "visible"))
+ for extra in (self.cbore_d, self.csink_d):
+ if extra is not None:
+ prims.append(Circle((x, y), float(extra) / 2.0, "visible"))
+ extras = [float(v) / 2.0 for v in (self.cbore_d, self.csink_d) if v is not None]
+ reach = max([half, *extras]) + 3.0
+ prims.append(Line((x - reach, y), (x + reach, y), "center"))
+ prims.append(Line((x, y - reach), (x, y + reach), "center"))
+ return tuple(prims)
+ if view in ("side", "top"):
+ u = y if view == "side" else x
+ face = part.thickness
+ depth = part.thickness if self.depth is None else float(self.depth)
+ floor = face - depth
+ prims = [
+ Line((u - half, face), (u - half, floor), "hidden"),
+ Line((u + half, face), (u + half, floor), "hidden"),
+ ]
+ if self.depth is not None:
+ prims.append(Line((u - half, floor), (u + half, floor), "hidden"))
+ if self.cbore_d is not None:
+ cb = float(self.cbore_d) / 2.0
+ cf = face - float(self.cbore_depth)
+ prims.append(Line((u - cb, face), (u - cb, cf), "hidden"))
+ prims.append(Line((u + cb, face), (u + cb, cf), "hidden"))
+ prims.append(Line((u - cb, cf), (u + cb, cf), "hidden"))
+ return tuple(prims)
+ return ()
+
+ def removal_box(self, part):
+ return _box_around(self._centre(), self._outer_radius())
+
+ def _centre(self) -> tuple[float, float]:
+ return (float(self.x), float(self.y))
+
+ def _outer_radius(self) -> float:
+ """The widest circle the plane can meet: the drill, or a counterbore or
+ countersink that is wider than it."""
+ extras = [float(v) / 2.0 for v in (self.cbore_d, self.csink_d) if v is not None]
+ return max([float(self.diameter) / 2.0, *extras])
+
+ def cut_spans(self, part, p1, unit):
+ """The true chord of the drilled circle, not the square that bounds it.
+
+ On an 80x50x8 plate with a 10 mm hole at (40, 25): a plane at y=29.9
+ grazes the hole and cuts a 1.990 mm chord, leaving 624.08 mm2 of cut
+ face. The bounding box reports 10 mm at every offset and hatches
+ 560 mm2 - a 64 mm2 error, with an 8 mm gap drawn through solid metal.
+ """
+ return _circle_chord(self._centre(), self._outer_radius(), p1, unit)
+
+ def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
+ if view != "front":
+ return ()
+ x, y = float(self.x), float(self.y)
+ half = float(self.diameter) / 2.0
+ label = self.thread or f"⌀{float(self.diameter):g}"
+ return (
+ DimIntent(
+ kind="diameter",
+ p1=(x - half, y),
+ p2=(x + half, y),
+ text_override=label,
+ feature=self.id,
+ ),
+ )
+
+ def omission(self, part, view: str) -> str | None:
+ return None if view in ("front", "side", "top") else f"a hole has no {view} projection"
+
+
+@_register
+@dataclass(frozen=True, kw_only=True)
+class Slot(_Base):
+ """An obround slot: two parallel flanks closed by two semicircular ends."""
+
+ KIND: ClassVar[str] = "slot"
+ x: float
+ y: float
+ length: float
+ width: float
+ angle: float = 0.0
+
+ def _ends(self) -> tuple[tuple[float, float], tuple[float, float], float]:
+ half_run = (float(self.length) - float(self.width)) / 2.0
+ theta = math.radians(float(self.angle))
+ ux, uy = math.cos(theta), math.sin(theta)
+ x, y = float(self.x), float(self.y)
+ return (
+ (x - half_run * ux, y - half_run * uy),
+ (x + half_run * ux, y + half_run * uy),
+ float(self.width) / 2.0,
+ )
+
+ def validate(self, part) -> None:
+ super().validate(part)
+ part = _require_prismatic(part, "Slot")
+ _number(self.x, "x", positive=False)
+ _number(self.y, "y", positive=False)
+ length = _number(self.length, "length")
+ width = _number(self.width, "width")
+ _number(self.angle, "angle", positive=False)
+ if length <= width:
+ raise ValueError(
+ f"length {length} must be greater than width {width}; a slot as wide as it is "
+ "long is a hole."
+ )
+ for point in self._ends()[:2]:
+ if not point_in_outline(part, point[0], point[1]):
+ raise ValueError(
+ f"slot end ({point[0]:g}, {point[1]:g}) is outside the part outline "
+ f"{outline_bbox(part)}."
+ )
+
+ def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
+ part = _require_prismatic(part, "Slot")
+ (c1, c2, half) = self._ends()
+ theta = math.radians(float(self.angle))
+ nx, ny = -math.sin(theta), math.cos(theta)
+ if view == "front":
+ return (
+ Line(
+ (c1[0] + half * nx, c1[1] + half * ny),
+ (c2[0] + half * nx, c2[1] + half * ny),
+ "visible",
+ ),
+ Line(
+ (c1[0] - half * nx, c1[1] - half * ny),
+ (c2[0] - half * nx, c2[1] - half * ny),
+ "visible",
+ ),
+ Arc(
+ c2,
+ half,
+ (float(self.angle) - 90.0) % 360.0,
+ (float(self.angle) + 90.0) % 360.0,
+ "visible",
+ ),
+ Arc(
+ c1,
+ half,
+ (float(self.angle) + 90.0) % 360.0,
+ (float(self.angle) + 270.0) % 360.0,
+ "visible",
+ ),
+ Line((c1[0], c1[1]), (c2[0], c2[1]), "center"),
+ )
+ if view in ("side", "top"):
+ box = self.removal_box(part)
+ u0, u1 = (box[1], box[3]) if view == "side" else (box[0], box[2])
+ return (
+ Line((u0, 0.0), (u0, part.thickness), "hidden"),
+ Line((u1, 0.0), (u1, part.thickness), "hidden"),
+ )
+ return ()
+
+ def removal_box(self, part):
+ (c1, c2, half) = self._ends()
+ xs = (c1[0] - half, c1[0] + half, c2[0] - half, c2[0] + half)
+ ys = (c1[1] - half, c1[1] + half, c2[1] - half, c2[1] + half)
+ return (min(xs), min(ys), max(xs), max(ys))
+
+ def cut_spans(self, part, p1, unit):
+ """The true chord of the obround: the union of its two end circles and
+ the rectangle between them. The bounding box of an angled slot is far
+ wider than the slot itself, so the box would erase material the slot
+ never touches."""
+ (c1, c2, half) = self._ends()
+ middle = ((c1[0] + c2[0]) / 2.0, (c1[1] + c2[1]) / 2.0)
+ run = math.dist(c1, c2)
+ return _merge_spans(
+ [
+ *_circle_chord(c1, half, p1, unit),
+ *_circle_chord(c2, half, p1, unit),
+ *_rotated_box_chord(middle, run / 2.0, half, float(self.angle), p1, unit),
+ ]
+ )
+
+ def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
+ if view != "front":
+ return ()
+ (c1, c2, half) = self._ends()
+ theta = math.radians(float(self.angle))
+ nx, ny = -math.sin(theta), math.cos(theta)
+ return (
+ DimIntent(
+ kind="aligned",
+ p1=(c1[0], c1[1]),
+ p2=(c2[0], c2[1]),
+ text_override=f"{float(self.length):g}",
+ feature=self.id,
+ ),
+ DimIntent(
+ kind="aligned",
+ p1=(c2[0] + half * nx, c2[1] + half * ny),
+ p2=(c2[0] - half * nx, c2[1] - half * ny),
+ text_override=f"{float(self.width):g}",
+ feature=self.id,
+ ),
+ )
+
+ def omission(self, part, view: str) -> str | None:
+ return None if view in ("front", "side", "top") else f"a slot has no {view} projection"
+
+
+@_register
+@dataclass(frozen=True, kw_only=True)
+class Pocket(_Base):
+ """A milled pocket. ``no_section_hatch`` marks a rib or web (ISO 128-3).
+
+ Milled from the machined face (``v = thickness``), the same face ``Hole``
+ drills from.
+ """
+
+ KIND: ClassVar[str] = "pocket"
+ outline: tuple[tuple[float, float], ...]
+ depth: float
+ no_section_hatch: bool = False
+
+ def validate(self, part) -> None:
+ super().validate(part)
+ part = _require_prismatic(part, "Pocket")
+ depth = _number(self.depth, "depth")
+ if depth >= part.thickness - _EPS:
+ raise ValueError(
+ f"depth {depth} is not smaller than the {part.thickness} mm thickness; "
+ "a pocket that goes through is a Hole or a Slot."
+ )
+ points = tuple((float(x), float(y)) for x, y in self.outline)
+ if len(points) < 3:
+ raise ValueError(f"outline: a pocket needs at least 3 vertices, got {len(points)}.")
+ from engineering.measure import is_self_intersecting
+
+ if is_self_intersecting(points) is True:
+ raise ValueError("outline: the pocket outline crosses itself.")
+ for index, point in enumerate(points):
+ if not point_in_outline(part, point[0], point[1]):
+ raise ValueError(
+ f"outline[{index}] ({point[0]:g}, {point[1]:g}) is outside the part outline "
+ f"{outline_bbox(part)}."
+ )
+
+ def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
+ part = _require_prismatic(part, "Pocket")
+ points = tuple((float(x), float(y)) for x, y in self.outline)
+ if view == "front":
+ return (Poly(points, True, "visible"),)
+ if view in ("side", "top"):
+ box = self.removal_box(part)
+ u0, u1 = (box[1], box[3]) if view == "side" else (box[0], box[2])
+ face = part.thickness
+ floor = face - float(self.depth)
+ return (
+ Line((u0, face), (u0, floor), "hidden"),
+ Line((u0, floor), (u1, floor), "hidden"),
+ Line((u1, floor), (u1, face), "hidden"),
+ )
+ return ()
+
+ def cut_spans(self, part, p1, unit):
+ """The chord of the pocket's own outline, not of the box around it: a
+ pocket is only rectangular when its outline is."""
+ return _polygon_chord(
+ tuple((float(x), float(y)) for x, y in self.outline),
+ p1,
+ unit,
+ what=f"pocket {self.id!r}",
+ )
+
+ def removal_box(self, part):
+ xs = [float(x) for x, _ in self.outline]
+ ys = [float(y) for _, y in self.outline]
+ return (min(xs), min(ys), max(xs), max(ys))
+
+ def omission(self, part, view: str) -> str | None:
+ return None if view in ("front", "side", "top") else f"a pocket has no {view} projection"
+
+
+@_register
+@dataclass(frozen=True, kw_only=True)
+class CornerFillet(_Base):
+ """A rounded outline corner: two tangent points and the arc between them."""
+
+ KIND: ClassVar[str] = "corner_fillet"
+ vertex: int
+ radius: float
+
+ def _frame(self, part):
+ points = _require_prismatic(part, "CornerFillet").outline
+ count = len(points)
+ index = int(self.vertex)
+ if not 0 <= index < count:
+ raise ValueError(f"vertex {index} is outside the outline (0-{count - 1}).")
+ v = points[index]
+ p = points[(index - 1) % count]
+ n = points[(index + 1) % count]
+ return v, p, n
+
+ def validate(self, part) -> None:
+ super().validate(part)
+ radius = _number(self.radius, "radius")
+ v, p, n = self._frame(part)
+ edge_p = math.dist(v, p)
+ edge_n = math.dist(v, n)
+ u1 = ((p[0] - v[0]) / edge_p, (p[1] - v[1]) / edge_p)
+ u2 = ((n[0] - v[0]) / edge_n, (n[1] - v[1]) / edge_n)
+ cosine = max(-1.0, min(1.0, u1[0] * u2[0] + u1[1] * u2[1]))
+ half = math.acos(cosine) / 2.0
+ if half < 1e-6 or abs(cosine + 1.0) < 1e-9:
+ raise ValueError(
+ f"vertex {self.vertex} is a straight run, there is no corner to round."
+ )
+ tangent = radius / math.tan(half)
+ if tangent > min(edge_p, edge_n) / 2.0 + _EPS:
+ raise ValueError(
+ f"radius {radius} needs {tangent:.3f} mm of edge, more than half the shorter "
+ f"adjacent edge ({min(edge_p, edge_n):.3f} mm)."
+ )
+
+ def outline_edit(self, part) -> dict:
+ v, p, n = self._frame(part)
+ radius = float(self.radius)
+ edge_p = math.dist(v, p)
+ edge_n = math.dist(v, n)
+ u1 = ((p[0] - v[0]) / edge_p, (p[1] - v[1]) / edge_p)
+ u2 = ((n[0] - v[0]) / edge_n, (n[1] - v[1]) / edge_n)
+ cosine = max(-1.0, min(1.0, u1[0] * u2[0] + u1[1] * u2[1]))
+ half = math.acos(cosine) / 2.0
+ tangent = radius / math.tan(half)
+ before = (v[0] + u1[0] * tangent, v[1] + u1[1] * tangent)
+ after = (v[0] + u2[0] * tangent, v[1] + u2[1] * tangent)
+ bisector = (u1[0] + u2[0], u1[1] + u2[1])
+ norm = math.hypot(*bisector)
+ center = (
+ v[0] + bisector[0] / norm * (radius / math.sin(half)),
+ v[1] + bisector[1] / norm * (radius / math.sin(half)),
+ )
+ start = math.degrees(math.atan2(before[1] - center[1], before[0] - center[0])) % 360.0
+ end = math.degrees(math.atan2(after[1] - center[1], after[0] - center[0])) % 360.0
+ cross = u1[0] * u2[1] - u1[1] * u2[0]
+ if cross > 0: # the arc runs the other way round
+ start, end = end, start
+ return {
+ "vertex": int(self.vertex),
+ "before": before,
+ "after": after,
+ "arc": Arc(center, radius, start, end, "visible"),
+ }
+
+ def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
+ if view != "front":
+ return ()
+ edit = self.outline_edit(part)
+ return (
+ DimIntent(
+ kind="radius",
+ p1=edit["arc"].center,
+ p2=edit["before"],
+ text_override=f"R{float(self.radius):g}",
+ feature=self.id,
+ ),
+ )
+
+ def omission(self, part, view: str) -> str | None:
+ return None if view == "front" else f"a corner radius has no {view} projection"
+
+
+@_register
+@dataclass(frozen=True, kw_only=True)
+class CornerChamfer(_Base):
+ """A bevelled outline corner: two points and the straight run between them."""
+
+ KIND: ClassVar[str] = "corner_chamfer"
+ vertex: int
+ size: float
+
+ def _frame(self, part):
+ points = _require_prismatic(part, "CornerChamfer").outline
+ count = len(points)
+ index = int(self.vertex)
+ if not 0 <= index < count:
+ raise ValueError(f"vertex {index} is outside the outline (0-{count - 1}).")
+ return points[index], points[(index - 1) % count], points[(index + 1) % count]
+
+ def validate(self, part) -> None:
+ super().validate(part)
+ size = _number(self.size, "size")
+ v, p, n = self._frame(part)
+ shortest = min(math.dist(v, p), math.dist(v, n))
+ if size > shortest / 2.0 + _EPS:
+ raise ValueError(
+ f"size {size} is more than half the shorter adjacent edge ({shortest:.3f} mm)."
+ )
+
+ def outline_edit(self, part) -> dict:
+ v, p, n = self._frame(part)
+ size = float(self.size)
+ edge_p = math.dist(v, p)
+ edge_n = math.dist(v, n)
+ before = (v[0] + (p[0] - v[0]) / edge_p * size, v[1] + (p[1] - v[1]) / edge_p * size)
+ after = (v[0] + (n[0] - v[0]) / edge_n * size, v[1] + (n[1] - v[1]) / edge_n * size)
+ return {"vertex": int(self.vertex), "before": before, "after": after, "arc": None}
+
+ def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
+ if view != "front":
+ return ()
+ edit = self.outline_edit(part)
+ return (
+ DimIntent(
+ kind="aligned",
+ p1=edit["before"],
+ p2=edit["after"],
+ text_override=f"{float(self.size):g}x45°",
+ feature=self.id,
+ ),
+ )
+
+ def omission(self, part, view: str) -> str | None:
+ return None if view == "front" else f"a corner chamfer has no {view} projection"
+
+
+@_register
+@dataclass(frozen=True, kw_only=True)
+class BendLine(_Base):
+ """A sheet-metal bend line, **representation only**.
+
+ Spec section 2 puts flat patterns and bend allowance out of scope: no
+ developed length is computed here, because a wrong developed length is a
+ scrapped part. This feature draws the phantom line and its R/angle note and
+ claims nothing else.
+ """
+
+ KIND: ClassVar[str] = "bend_line"
+ p1: tuple[float, float]
+ p2: tuple[float, float]
+ angle: float
+ inside_radius: float
+ direction: str = "up"
+
+ def validate(self, part) -> None:
+ super().validate(part)
+ _require_prismatic(part, "BendLine")
+ for name, point in (("p1", self.p1), ("p2", self.p2)):
+ if not isinstance(point, (tuple, list)) or len(point) != 2:
+ raise ValueError(f"{name}: expected [x, y], got {point!r}.")
+ _number(point[0], f"{name}.x", positive=False)
+ _number(point[1], f"{name}.y", positive=False)
+ if math.dist(tuple(self.p1), tuple(self.p2)) < _EPS:
+ raise ValueError("p1 and p2 are the same point; a bend line needs a direction.")
+ angle = _number(self.angle, "angle", positive=False)
+ if not 0.0 < abs(angle) <= 180.0:
+ raise ValueError(f"angle {angle}: a bend angle is between 0 and 180 degrees.")
+ _number(self.inside_radius, "inside_radius")
+ if str(self.direction).strip().lower() not in ("up", "down"):
+ raise ValueError(f"direction: expected 'up' or 'down', got {self.direction!r}.")
+
+ def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
+ if view != "front":
+ return ()
+ p1 = (float(self.p1[0]), float(self.p1[1]))
+ p2 = (float(self.p2[0]), float(self.p2[1]))
+ mid = ((p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0)
+ rotation = math.degrees(math.atan2(p2[1] - p1[1], p2[0] - p1[0]))
+ note = (
+ f"R{float(self.inside_radius):g} {float(self.angle):g}° "
+ f"{str(self.direction).strip().upper()}"
+ )
+ return (
+ Line(p1, p2, "phantom"),
+ Text((mid[0], mid[1] + 2.0), note, 3.5, rotation, "text"),
+ )
+
+ def omission(self, part, view: str) -> str | None:
+ return None if view == "front" else f"a bend line has no {view} projection"
+
+
+@_register
+@dataclass(frozen=True, kw_only=True)
+class HolePattern(_Base):
+ """A linear, grid or polar array of one base hole. Expanded before validation."""
+
+ KIND: ClassVar[str] = "hole_pattern"
+ pattern: str
+ base: dict
+ count: int = 2
+ spacing: float = 0.0
+ count_y: int = 1
+ spacing_y: float = 0.0
+ pcd: float = 0.0
+ start_angle: float = 0.0
+ angle: float = 0.0
+
+ PATTERNS: ClassVar[tuple[str, ...]] = ("linear", "grid", "polar")
+
+ def _base_fields(self) -> dict:
+ if not isinstance(self.base, dict):
+ raise ValueError(
+ f"base: expected a dict of Hole fields, got {type(self.base).__name__}."
+ )
+ payload = {key: value for key, value in self.base.items() if key not in ("kind", "id")}
+ names = {field_.name for field_ in fields(Hole)}
+ unknown = sorted(set(payload) - names)
+ if unknown:
+ raise ValueError(f"base: unknown key(s) {unknown}; a Hole takes {sorted(names)}.")
+ for required in ("x", "y", "diameter"):
+ if required not in payload:
+ raise ValueError(f"base.{required}: a base hole needs x, y and diameter.")
+ return payload
+
+ def expand(self) -> tuple[Hole, ...]:
+ kind = str(self.pattern).strip().lower()
+ if kind not in self.PATTERNS:
+ raise ValueError(f"pattern: expected one of {self.PATTERNS}, got {self.pattern!r}.")
+ base = self._base_fields()
+ x0, y0 = float(base["x"]), float(base["y"])
+ count = int(self.count)
+ if count < 1:
+ raise ValueError(f"count: expected at least 1, got {count}.")
+ holes: list[Hole] = []
+ if kind == "linear":
+ if float(self.spacing) <= _EPS:
+ raise ValueError("spacing: a linear pattern needs a spacing greater than zero.")
+ theta = math.radians(float(self.angle))
+ for k in range(count):
+ holes.append(
+ Hole(
+ **{
+ **base,
+ "id": f"{self.id}_{k}",
+ "x": x0 + k * float(self.spacing) * math.cos(theta),
+ "y": y0 + k * float(self.spacing) * math.sin(theta),
+ }
+ )
+ )
+ elif kind == "grid":
+ rows = int(self.count_y)
+ if rows < 1:
+ raise ValueError(f"count_y: expected at least 1, got {rows}.")
+ if count > 1 and float(self.spacing) <= _EPS:
+ raise ValueError("spacing: a grid with more than one column needs a spacing.")
+ if rows > 1 and float(self.spacing_y) <= _EPS:
+ raise ValueError("spacing_y: a grid with more than one row needs a spacing_y.")
+ for j in range(rows):
+ for i in range(count):
+ holes.append(
+ Hole(
+ **{
+ **base,
+ "id": f"{self.id}_{i}_{j}",
+ "x": x0 + i * float(self.spacing),
+ "y": y0 + j * float(self.spacing_y),
+ }
+ )
+ )
+ else:
+ if float(self.pcd) <= _EPS:
+ raise ValueError("pcd: a polar pattern needs a pitch-circle diameter.")
+ if count < 2:
+ raise ValueError(f"count: a polar pattern needs at least 2 holes, got {count}.")
+ radius = float(self.pcd) / 2.0
+ start = math.radians(float(self.start_angle))
+ for k in range(count):
+ theta = start + 2.0 * math.pi * k / count
+ holes.append(
+ Hole(
+ **{
+ **base,
+ "id": f"{self.id}_{k}",
+ "x": x0 + radius * math.cos(theta),
+ "y": y0 + radius * math.sin(theta),
+ }
+ )
+ )
+ return tuple(holes)
+
+ def validate(self, part) -> None: # pragma: no cover - expanded before it reaches a part
+ super().validate(part)
+ for hole in self.expand():
+ hole.validate(part)
+
+
+FEATURE_KINDS: tuple[str, ...] = tuple(sorted(_REGISTRY))
diff --git a/engineering/mech/part.py b/engineering/mech/part.py
new file mode 100644
index 0000000..dd74c1e
--- /dev/null
+++ b/engineering/mech/part.py
@@ -0,0 +1,359 @@
+"""The mechanical part model: a profile plus a list of typed features.
+
+Pure: no backend, no async, no DXF. A part is either **revolved** (a stack of
+segments along +X, the axis at y = 0, the left end at x = 0) or **prismatic**
+(a closed outline in part-local XY plus a thickness along +Z). Every coordinate
+here is part-local; `engineering/mech/draw.py` is the only module that maps
+part-local to WCS.
+
+Everything is validated before anything is drawn (the repository's
+validate-first rule), and every refusal names the offending path the way
+`pid_from_spec` does: ``segments[2].d_outer: ...``.
+
+The feature surface is duck-typed on purpose, so this module does not import
+`features.py` at module scope and the two are testable independently:
+
+ feature.id -> str
+ feature.validate(part) -> None, ValueError naming what is wrong
+ feature.to_dict() -> dict accepted by feature_from_dict
+ feature.removal_box(part) -> (x0, y0, x1, y1) | None (optional)
+ feature.expand() -> tuple[Feature, ...] (optional)
+"""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass, replace
+from typing import Any, NamedTuple
+
+from engineering.measure import is_self_intersecting
+from engineering.mech.primitives import Pt
+from engineering.mech.standards.materials import hatch_for
+
+_EPS = 1e-9
+
+
+class Segment(NamedTuple):
+ """One turned step of a revolved part, left to right along +X."""
+
+ length: float
+ d_outer: float
+ d_inner: float = 0.0
+ taper_to: float | None = None
+
+
+@dataclass(frozen=True)
+class RevolvedPart:
+ name: str
+ segments: tuple[Segment, ...]
+ features: tuple[Any, ...] = ()
+ material: str = "steel"
+
+
+@dataclass(frozen=True)
+class PrismaticPart:
+ name: str
+ outline: tuple[Pt, ...]
+ thickness: float
+ features: tuple[Any, ...] = ()
+ material: str = "steel"
+
+
+Part = RevolvedPart | PrismaticPart
+
+PART_KINDS: tuple[str, ...] = ("revolved", "prismatic")
+
+_SEGMENT_KEYS = frozenset({"length", "d_outer", "d_inner", "taper_to"})
+
+
+# -- number guards -----------------------------------------------------------
+
+
+def _finite(value: Any, path: str) -> float:
+ try:
+ number = float(value)
+ except (TypeError, ValueError):
+ raise ValueError(f"{path}: expected a number, got {value!r}.") from None
+ if not math.isfinite(number):
+ raise ValueError(f"{path}: {value!r} is not a finite number.")
+ return number
+
+
+def _positive(value: Any, path: str) -> float:
+ number = _finite(value, path)
+ if number <= _EPS:
+ raise ValueError(f"{path}: must be greater than zero, got {number}.")
+ return number
+
+
+def _non_negative(value: Any, path: str) -> float:
+ number = _finite(value, path)
+ if number < -_EPS:
+ raise ValueError(f"{path}: must not be negative, got {number}.")
+ return max(number, 0.0)
+
+
+# -- geometry helpers (Tasks 3-7 read the part through these) ----------------
+
+
+def segment_bounds(part: RevolvedPart) -> tuple[tuple[float, float], ...]:
+ """``((x0, x1), ...)`` per segment, left to right from x = 0."""
+ bounds: list[tuple[float, float]] = []
+ x = 0.0
+ for segment in part.segments:
+ bounds.append((x, x + segment.length))
+ x += segment.length
+ return tuple(bounds)
+
+
+def part_length(part: Part) -> float:
+ if isinstance(part, RevolvedPart):
+ return float(sum(segment.length for segment in part.segments))
+ x0, _, x1, _ = outline_bbox(part)
+ return x1 - x0
+
+
+def part_max_diameter(part: RevolvedPart) -> float:
+ diameters = [segment.d_outer for segment in part.segments]
+ diameters += [s.taper_to for s in part.segments if s.taper_to is not None]
+ return float(max(diameters))
+
+
+def _segment_at(part: RevolvedPart, x: float) -> tuple[int, float, float, Segment]:
+ bounds = zip(segment_bounds(part), part.segments, strict=True) # one bound per segment
+ for index, ((x0, x1), segment) in enumerate(bounds):
+ if x0 - _EPS <= x <= x1 + _EPS:
+ return index, x0, x1, segment
+ raise ValueError(f"x={x} is outside the part (0 .. {part_length(part)} mm).")
+
+
+def outer_radius_at(part: RevolvedPart, x: float) -> float:
+ """Outer radius at an axial station, honouring a segment's taper."""
+ _, x0, x1, segment = _segment_at(part, float(x))
+ if segment.taper_to is None:
+ return segment.d_outer / 2.0
+ span = x1 - x0
+ t = 0.0 if span <= _EPS else (float(x) - x0) / span
+ return (segment.d_outer + t * (segment.taper_to - segment.d_outer)) / 2.0
+
+
+def inner_radius_at(part: RevolvedPart, x: float) -> float:
+ """Bore radius at an axial station; 0.0 where the part is solid."""
+ _, _, _, segment = _segment_at(part, float(x))
+ return segment.d_inner / 2.0
+
+
+def outline_bbox(part: PrismaticPart) -> tuple[float, float, float, float]:
+ xs = [p[0] for p in part.outline]
+ ys = [p[1] for p in part.outline]
+ return (min(xs), min(ys), max(xs), max(ys))
+
+
+def point_in_outline(part: PrismaticPart, x: float, y: float) -> bool:
+ """Even-odd ray cast against the closed outline; a point on an edge counts as in."""
+ points = part.outline
+ count = len(points)
+ inside = False
+ for i in range(count):
+ ax, ay = points[i]
+ bx, by = points[(i + 1) % count]
+ cross = (bx - ax) * (y - ay) - (by - ay) * (x - ax)
+ on_span = min(ax, bx) - _EPS <= x <= max(ax, bx) + _EPS
+ on_span = on_span and min(ay, by) - _EPS <= y <= max(ay, by) + _EPS
+ if abs(cross) < 1e-9 and on_span:
+ return True
+ if (ay > y) != (by > y):
+ t = (y - ay) / (by - ay)
+ if x < ax + t * (bx - ax):
+ inside = not inside
+ return inside
+
+
+# -- construction ------------------------------------------------------------
+
+
+def _segment_from(index: int, raw: Any) -> Segment:
+ if not isinstance(raw, dict):
+ raise ValueError(f"segments[{index}]: expected a dict, got {type(raw).__name__}.")
+ unknown = set(raw) - _SEGMENT_KEYS
+ if unknown:
+ raise ValueError(
+ f"segments[{index}]: unknown key(s) {sorted(unknown)}; "
+ f"a segment takes {sorted(_SEGMENT_KEYS)}."
+ )
+ length = _positive(raw.get("length"), f"segments[{index}].length")
+ d_outer = _positive(raw.get("d_outer"), f"segments[{index}].d_outer")
+ d_inner = _non_negative(raw.get("d_inner") or 0.0, f"segments[{index}].d_inner")
+ if d_inner >= d_outer - _EPS:
+ raise ValueError(
+ f"segments[{index}].d_inner: {d_inner} mm is not smaller than d_outer "
+ f"{d_outer} mm. A bore that changes diameter is two segments."
+ )
+ taper_to = raw.get("taper_to")
+ if taper_to is not None:
+ taper_to = _positive(taper_to, f"segments[{index}].taper_to")
+ if taper_to <= d_inner + _EPS:
+ raise ValueError(
+ f"segments[{index}].taper_to: {taper_to} mm is not larger than the bore "
+ f"{d_inner} mm."
+ )
+ return Segment(length, d_outer, d_inner, taper_to)
+
+
+def _outline_from(raw: Any) -> tuple[Pt, ...]:
+ if not isinstance(raw, (list, tuple)) or not raw:
+ raise ValueError("outline: expected a non-empty list of [x, y] points.")
+ points: list[Pt] = []
+ for index, item in enumerate(raw):
+ if not isinstance(item, (list, tuple)) or len(item) != 2:
+ raise ValueError(f"outline[{index}]: expected [x, y], got {item!r}.")
+ points.append(
+ (
+ _finite(item[0], f"outline[{index}].x"),
+ _finite(item[1], f"outline[{index}].y"),
+ )
+ )
+ if len(points) > 1 and math.dist(points[0], points[-1]) < _EPS:
+ points = points[:-1]
+ distinct: list[Pt] = []
+ for point in points:
+ if not distinct or math.dist(distinct[-1], point) > _EPS:
+ distinct.append(point)
+ if len(distinct) < 3:
+ raise ValueError(
+ f"outline: a closed outline needs at least 3 distinct vertices, got {len(distinct)}."
+ )
+ if is_self_intersecting(distinct) is True:
+ raise ValueError(
+ "outline: the closed outline crosses itself. A crossed loop has no single "
+ "enclosed area, so neither the view engine nor the section hatch can be right."
+ )
+ return tuple(distinct)
+
+
+def _features_from(raw: Any) -> tuple[Any, ...]:
+ if raw is None:
+ return ()
+ if not isinstance(raw, (list, tuple)):
+ raise ValueError("features: expected a list of feature dicts.")
+ if not raw:
+ # No feature dicts to build, so `features.py` is not imported at all:
+ # a featureless part stays usable while Task 3 is still unwritten, and
+ # the import stays local either way (features.py imports this module).
+ return ()
+ from engineering.mech.features import feature_from_dict # local: avoids an import cycle
+
+ built: list[Any] = []
+ seen: set[str] = set()
+ for index, item in enumerate(raw):
+ if not isinstance(item, dict):
+ raise ValueError(f"features[{index}]: expected a dict with a 'kind' key.")
+ try:
+ feature = feature_from_dict(item)
+ expand = getattr(feature, "expand", None)
+ expanded = tuple(expand()) if callable(expand) else (feature,)
+ except ValueError as exc:
+ raise ValueError(f"features[{index}]: {exc}") from exc
+ for one in expanded:
+ if one.id in seen:
+ raise ValueError(f"features[{index}]: duplicate feature id {one.id!r}.")
+ seen.add(one.id)
+ built.append(one)
+ return tuple(built)
+
+
+def validate_part(part: Part) -> None:
+ """Every feature validates against the part, and no two remove the same material."""
+ for index, feature in enumerate(part.features):
+ try:
+ feature.validate(part)
+ except ValueError as exc:
+ raise ValueError(f"features[{index}] ({feature.id}): {exc}") from exc
+
+ boxes: list[tuple[str, tuple[float, float, float, float]]] = []
+ for feature in part.features:
+ getter = getattr(feature, "removal_box", None)
+ box = getter(part) if callable(getter) else None
+ if box is not None:
+ x0, y0, x1, y1 = (float(v) for v in box)
+ boxes.append((feature.id, (x0, y0, x1, y1)))
+
+ for a in range(len(boxes)):
+ id_a, (ax0, ay0, ax1, ay1) = boxes[a]
+ for b in range(a + 1, len(boxes)):
+ id_b, (bx0, by0, bx1, by1) = boxes[b]
+ ox0, ox1 = max(ax0, bx0), min(ax1, bx1)
+ oy0, oy1 = max(ay0, by0), min(ay1, by1)
+ if ox1 - ox0 > _EPS and oy1 - oy0 > _EPS:
+ raise ValueError(
+ f"features {id_a!r} and {id_b!r} remove the same material "
+ f"(overlap x {ox0:.3f}..{ox1:.3f}, y {oy0:.3f}..{oy1:.3f}). "
+ "Move one of them, or merge them into a single feature."
+ )
+
+
+def build_part(spec: dict) -> Part:
+ """Validate a part spec and return the typed model. ValueError names the path."""
+ if not isinstance(spec, dict):
+ raise ValueError(f"spec: expected a dict describing the part, got {type(spec).__name__}.")
+ kind = str(spec.get("kind", "")).strip().lower()
+ if kind not in PART_KINDS:
+ raise ValueError(f"kind: expected one of {PART_KINDS}, got {spec.get('kind')!r}.")
+ name = spec.get("name")
+ if not isinstance(name, str) or not name.strip():
+ raise ValueError("name: a non-empty part name is required.")
+ material = str(spec.get("material", "steel"))
+ try:
+ hatch_for(material)
+ except ValueError as exc:
+ raise ValueError(f"material: {exc}") from exc
+
+ if kind == "revolved":
+ raw_segments = spec.get("segments")
+ if not isinstance(raw_segments, (list, tuple)) or not raw_segments:
+ raise ValueError("segments: a revolved part needs at least one segment.")
+ part: Part = RevolvedPart(
+ name=name.strip(),
+ segments=tuple(_segment_from(i, s) for i, s in enumerate(raw_segments)),
+ material=material,
+ )
+ else:
+ part = PrismaticPart(
+ name=name.strip(),
+ outline=_outline_from(spec.get("outline")),
+ thickness=_positive(spec.get("thickness"), "thickness"),
+ material=material,
+ )
+
+ part = replace(part, features=_features_from(spec.get("features")))
+ validate_part(part)
+ return part
+
+
+def part_to_dict(part: Part) -> dict:
+ """The dict `build_part` accepts back, unchanged."""
+ common = {
+ "name": part.name,
+ "material": part.material,
+ "features": [feature.to_dict() for feature in part.features],
+ }
+ if isinstance(part, RevolvedPart):
+ return {
+ "kind": "revolved",
+ **common,
+ "segments": [
+ {
+ "length": s.length,
+ "d_outer": s.d_outer,
+ "d_inner": s.d_inner,
+ "taper_to": s.taper_to,
+ }
+ for s in part.segments
+ ],
+ }
+ return {
+ "kind": "prismatic",
+ **common,
+ "outline": [[x, y] for x, y in part.outline],
+ "thickness": part.thickness,
+ }
diff --git a/engineering/mech/standards/__init__.py b/engineering/mech/standards/__init__.py
index b550ada..5bf0b8c 100644
--- a/engineering/mech/standards/__init__.py
+++ b/engineering/mech/standards/__init__.py
@@ -13,6 +13,12 @@ wrong row ships a wrong workshop drawing; a narrow table is merely narrow.
variants at the same size (DIN 509 forms E and F, DIN 332 forms A/B/R). ``**kw``
selects among those variants and is refused when it selects none or more than
one — an ambiguous lookup never quietly picks the first row.
+
+``rows`` may be **empty**: that is a table whose structure, coverage and SOURCE
+ship before any row could be verified against the standard (DIN 509, DIN 471,
+DIN 472, DIN 332-1 and ISO 3601-2 in this build). Registering it keeps the
+later transcription a data edit, and every lookup against it is refused by
+name — which is exactly the table rule, not an exception to it.
"""
from __future__ import annotations
@@ -62,8 +68,11 @@ def register(standard: str, rows: dict, coverage: Coverage, source: str) -> None
raise ValueError("standards.register: standard must be a non-empty name")
if standard in _REGISTRY:
raise ValueError(f"standards.register: {standard!r} is already registered")
- if not rows:
- raise ValueError(f"standards.register: {standard!r} registered no rows")
+ if not isinstance(rows, dict):
+ raise ValueError(
+ f"standards.register: {standard!r} must register a rows dict; an empty one is "
+ "the table whose structure ships before its rows are transcribed"
+ )
if not isinstance(source, str) or "table" not in source.lower():
raise ValueError(
f"standards.register: {standard!r} needs a SOURCE naming the standard, its "
diff --git a/engineering/mech/standards/centres.py b/engineering/mech/standards/centres.py
new file mode 100644
index 0000000..75d305b
--- /dev/null
+++ b/engineering/mech/standards/centres.py
@@ -0,0 +1,67 @@
+"""DIN 332-1 centre holes, forms A and B.
+
+SOURCE
+------
+DIN 332-1:1986-04, *Centre holes 60 degrees - Forms R, A and B*, Table 1.
+
+COVERAGE
+--------
+**No rows are transcribed in this build** (same rule as ``undercuts.py``).
+Form R is not drawn by this build at all: its profile is an arc form whose
+radius is not transcribed here, and it is refused outright rather than
+approximated by the straight 60 degree cone of forms A and B.
+
+Extending is a data edit: fill ``ROWS_A`` / ``ROWS_B`` with
+``{pilot_diameter_mm: {"d1": ..., "d2": ..., "d3": ..., "t": ...}}`` (``d3``
+only for form B's protective 120 degree countersink) and widen the coverage.
+"""
+
+from __future__ import annotations
+
+from engineering.mech.standards import Coverage, register
+
+SOURCE = "DIN 332-1:1986-04, Table 1 (forms A and B) - NOT TRANSCRIBED in this build"
+
+#: The keys a DIN 332 row must carry; form A rows may omit "d3".
+CENTRE_ROW_KEYS: tuple[str, ...] = ("d1", "d2", "d3", "t")
+
+FORMS: tuple[str, ...] = ("A", "B")
+
+ROWS_A: dict[float, dict] = {}
+ROWS_B: dict[float, dict] = {}
+
+COVERAGE_A = Coverage(0.0, 0.0, "mm")
+COVERAGE_B = Coverage(0.0, 0.0, "mm")
+
+register("DIN 332-A", ROWS_A, COVERAGE_A, SOURCE)
+register("DIN 332-B", ROWS_B, COVERAGE_B, SOURCE)
+
+_ROWS = {"A": ROWS_A, "B": ROWS_B}
+
+
+def centre_hole_dims(form: str, size: float) -> dict:
+ """``{"d1","d2","d3","t"}`` for a pilot diameter, or a named refusal."""
+ key = str(form or "").strip().upper()
+ if key == "R":
+ raise ValueError(
+ "DIN 332 form R: this build draws forms A and B only. Form R's arc profile "
+ "is not transcribed here and is refused rather than approximated by the "
+ "straight 60 degree cone."
+ )
+ if key not in FORMS:
+ raise ValueError(f"DIN 332: expected form A or B, got {form!r}.")
+ rows = _ROWS[key]
+ if not rows:
+ raise ValueError(
+ f"DIN 332 form {key}: no rows are transcribed in this build, so no centre hole "
+ f"can be looked up for d1 = {float(size):g} mm. Pass d1, d2 (and d3 for form B) "
+ f"and t explicitly, or transcribe {SOURCE.split(' - ')[0]} into "
+ "engineering/mech/standards/centres.py. Nothing is interpolated."
+ )
+ pilot = float(size)
+ if pilot in rows:
+ row = rows[pilot]
+ return {name: (float(row[name]) if name in row else None) for name in CENTRE_ROW_KEYS}
+ raise ValueError(
+ f"DIN 332 form {key}: d1 = {pilot:g} mm has no transcribed row (sizes: {sorted(rows)})."
+ )
diff --git a/engineering/mech/standards/orings.py b/engineering/mech/standards/orings.py
new file mode 100644
index 0000000..db02161
--- /dev/null
+++ b/engineering/mech/standards/orings.py
@@ -0,0 +1,54 @@
+"""ISO 3601-2 O-ring housing (groove) dimensions.
+
+SOURCE
+------
+ISO 3601-2:2016, *Fluid power systems - O-rings - Part 2: Housing dimensions
+for general applications*, Tables 1 and 2 (radial static housings).
+
+COVERAGE
+--------
+**No rows are transcribed in this build** (same rule as ``undercuts.py``): the
+groove width and depth per cord diameter decide whether the seal works, and
+they are not written here from memory. Every lookup is refused by name.
+
+Extending is a data edit: fill ``ROWS`` with
+``{cord_diameter_mm: {"b": groove_width_mm, "h": groove_depth_mm}}`` - the two
+keys group S's ``std_feature_draw`` already reads - and widen ``COVERAGE``.
+"""
+
+from __future__ import annotations
+
+from engineering.mech.standards import Coverage, register
+
+SOURCE = "ISO 3601-2:2016, Tables 1-2 (radial static housings) - NOT TRANSCRIBED in this build"
+
+#: The keys every ISO 3601-2 row must carry.
+ORING_ROW_KEYS: tuple[str, ...] = ("b", "h")
+
+KINDS: tuple[str, ...] = ("radial", "axial")
+
+ROWS: dict[float, dict] = {}
+
+COVERAGE = Coverage(0.0, 0.0, "mm")
+
+register("ISO 3601-2", ROWS, COVERAGE, SOURCE)
+
+
+def oring_groove_dims(cord: float, kind: str = "radial") -> dict:
+ """``{"b": width, "h": depth}`` for a cord diameter, or a named refusal."""
+ key = str(kind or "").strip().lower()
+ if key not in KINDS:
+ raise ValueError(f"O-ring housing: expected kind 'radial' or 'axial', got {kind!r}.")
+ if not ROWS:
+ raise ValueError(
+ f"ISO 3601-2: no rows are transcribed in this build, so no {key} housing can be "
+ f"looked up for a {float(cord):g} mm cord. Pass b and h explicitly, or transcribe "
+ f"{SOURCE.split(' - ')[0]} into engineering/mech/standards/orings.py. "
+ "Nothing is interpolated."
+ )
+ size = float(cord)
+ if size in ROWS:
+ return {name: float(ROWS[size][name]) for name in ORING_ROW_KEYS}
+ raise ValueError(
+ f"ISO 3601-2: cord {size:g} mm has no transcribed row (sizes: {sorted(ROWS)})."
+ )
diff --git a/engineering/mech/standards/rings.py b/engineering/mech/standards/rings.py
new file mode 100644
index 0000000..01201b0
--- /dev/null
+++ b/engineering/mech/standards/rings.py
@@ -0,0 +1,60 @@
+"""DIN 471 / DIN 472 retaining-ring groove dimensions.
+
+SOURCE
+------
+DIN 471:2011-04, *Retaining rings for shafts*, Table 1 (groove diameter d2 and
+groove width m) and DIN 472:2011-04, *Retaining rings for bores*, Table 1.
+
+COVERAGE
+--------
+**No rows are transcribed in this build**, for the reason given in
+``undercuts.py``: the groove diameter is the number a wrong drawing gets wrong,
+and it is not written here from memory. Every lookup is refused by name.
+
+Extending is a data edit: fill ``ROWS_471`` / ``ROWS_472`` with
+``{nominal_shaft_or_bore_mm: {"m": groove_width_mm, "d2": groove_diameter_mm}}``
+and widen the coverage. Those two keys are the contract group S's
+``std_feature_draw`` already reads.
+"""
+
+from __future__ import annotations
+
+from engineering.mech.standards import Coverage, register
+
+SOURCE = (
+ "DIN 471:2011-04 Table 1 (shafts) and DIN 472:2011-04 Table 1 (bores) "
+ "- NOT TRANSCRIBED in this build"
+)
+
+#: The keys every DIN 471/472 row must carry.
+RING_ROW_KEYS: tuple[str, ...] = ("m", "d2")
+
+ROWS_471: dict[float, dict] = {}
+ROWS_472: dict[float, dict] = {}
+
+COVERAGE_471 = Coverage(0.0, 0.0, "mm")
+COVERAGE_472 = Coverage(0.0, 0.0, "mm")
+
+register("DIN 471", ROWS_471, COVERAGE_471, SOURCE)
+register("DIN 472", ROWS_472, COVERAGE_472, SOURCE)
+
+_ROWS = {"DIN 471": ROWS_471, "DIN 472": ROWS_472}
+
+
+def ring_groove_dims(standard: str, d: float) -> dict:
+ """``{"m": ..., "d2": ...}`` for a nominal size, or a named refusal."""
+ key = " ".join(str(standard or "").split()).upper()
+ if key not in _ROWS:
+ raise ValueError(f"retaining-ring groove: expected DIN 471 or DIN 472, got {standard!r}.")
+ rows = _ROWS[key]
+ if not rows:
+ raise ValueError(
+ f"{key}: no rows are transcribed in this build, so no groove can be looked up "
+ f"for d = {float(d):g} mm. Pass m and d2 explicitly, or transcribe "
+ f"{SOURCE.split(' - ')[0]} into engineering/mech/standards/rings.py. "
+ "Nothing is interpolated."
+ )
+ size = float(d)
+ if size in rows:
+ return {name: float(rows[size][name]) for name in RING_ROW_KEYS}
+ raise ValueError(f"{key}: d = {size:g} mm has no transcribed row (sizes: {sorted(rows)}).")
diff --git a/engineering/mech/standards/threads.py b/engineering/mech/standards/threads.py
new file mode 100644
index 0000000..ff4612c
--- /dev/null
+++ b/engineering/mech/standards/threads.py
@@ -0,0 +1,250 @@
+"""ISO 261 metric thread pitches and the ISO 6410 drawing representation.
+
+SOURCE
+------
+**Pitches.** ISO 261:2022, *ISO general purpose metric screw threads - General
+plan*, Table 1 (coarse pitch series) and Table 2 (fine pitch series).
+
+**Transcribed coverage.** The first-choice diameters M1.6 to M64, plus the
+second-choice diameters M14, M18, M22, M27, M33, M39, M45, M52 and M60. The
+small second-choice diameters (M1.8, M2.2, M3.5, M4.5, M5.5, M7, M9, M11),
+every third-choice diameter, everything above M64 and the fine pitch series
+below M8 are **not** transcribed and are refused by name rather than
+interpolated - the table rule of this track. Where a diameter's fine series
+here is shorter than the standard's, the pitch that is missing is refused by
+name as well; widening it is a data edit in ``FINE`` with no code change.
+
+**Basic diameters.** ISO 68-1:1998 fundamental triangle, H = (sqrt(3)/2) * P,
+with ISO 724's basic minor diameter d1 = d - 1.25*H = d - 1.082532*P and basic
+pitch diameter d2 = d - 0.75*H = d - 0.649519*P. That is the standard's own
+formula rather than transcribed table data, which is why it can be pinned
+against published ISO 724 values instead of trusted: M20x2.5 reproduces
+17.294 / 18.376 exactly, M10x1.5 reproduces 8.376 / 9.026, M8x1.25 reproduces
+6.647 / 7.188.
+
+**Representation.** ISO 6410-1:1993, *Technical drawings - Screw threads and
+threaded parts*: the minor diameter is drawn as a thin continuous line at
+0.8 x the major diameter (`MINOR_RATIO`); in a view along the axis that same
+circle is drawn as three quarters of a circle with the gap in one quadrant;
+the thread run-out / thread-length limit is a line across the full major
+diameter. An internal thread shown without a section is drawn hidden
+throughout.
+"""
+
+from __future__ import annotations
+
+import math
+import re
+
+from engineering.mech.primitives import Arc, Line, Prim
+from engineering.mech.standards import Coverage, register
+
+SOURCE_PITCH = (
+ "ISO 261:2022 Table 1 (coarse) and Table 2 (fine); first-choice M1.6-M64 "
+ "plus second-choice M14, M18, M22, M27, M33, M39, M45, M52, M60"
+)
+SOURCE_BASIC = "ISO 68-1:1998 fundamental triangle; values pinned against ISO 724 Table 1"
+SOURCE_REPRESENTATION = "ISO 6410-1:1993"
+
+#: ISO 6410-1: the minor diameter is represented at 0.8 x the major diameter.
+MINOR_RATIO = 0.8
+
+#: ISO 261 Table 1 - coarse pitch series, major diameter (mm) -> pitch (mm).
+COARSE: dict[float, float] = {
+ 1.6: 0.35,
+ 2.0: 0.40,
+ 2.5: 0.45,
+ 3.0: 0.50,
+ 4.0: 0.70,
+ 5.0: 0.80,
+ 6.0: 1.00,
+ 8.0: 1.25,
+ 10.0: 1.50,
+ 12.0: 1.75,
+ 14.0: 2.00,
+ 16.0: 2.00,
+ 18.0: 2.50,
+ 20.0: 2.50,
+ 22.0: 2.50,
+ 24.0: 3.00,
+ 27.0: 3.00,
+ 30.0: 3.50,
+ 33.0: 3.50,
+ 36.0: 4.00,
+ 39.0: 4.00,
+ 42.0: 4.50,
+ 45.0: 4.50,
+ 48.0: 5.00,
+ 52.0: 5.00,
+ 56.0: 5.50,
+ 60.0: 5.50,
+ 64.0: 6.00,
+}
+
+#: ISO 261 Table 2 - fine pitch series, major diameter (mm) -> pitches (mm),
+#: coarsest first. Transcribed from M8 upwards only.
+FINE: dict[float, tuple[float, ...]] = {
+ 8.0: (1.0, 0.75),
+ 10.0: (1.25, 1.0, 0.75),
+ 12.0: (1.5, 1.25, 1.0),
+ 14.0: (1.5, 1.0),
+ 16.0: (1.5, 1.0),
+ 18.0: (2.0, 1.5, 1.0),
+ 20.0: (2.0, 1.5, 1.0),
+ 22.0: (2.0, 1.5, 1.0),
+ 24.0: (2.0, 1.5, 1.0),
+ 27.0: (2.0, 1.5, 1.0),
+ 30.0: (2.0, 1.5, 1.0),
+ 33.0: (2.0, 1.5),
+ 36.0: (3.0, 2.0, 1.5),
+ 39.0: (3.0, 2.0, 1.5),
+ 42.0: (3.0, 2.0, 1.5),
+ 45.0: (3.0, 2.0, 1.5),
+ 48.0: (3.0, 2.0, 1.5),
+ 52.0: (3.0, 2.0, 1.5),
+ 56.0: (4.0, 3.0, 2.0, 1.5),
+ 60.0: (4.0, 3.0, 2.0, 1.5),
+ 64.0: (4.0, 3.0, 2.0, 1.5),
+}
+
+DIAMETERS: tuple[float, ...] = tuple(sorted(COARSE))
+
+COVERAGE = Coverage(DIAMETERS[0], DIAMETERS[-1], "mm")
+
+_DESIGNATION_RE = re.compile(
+ r"^\s*M\s*(\d+(?:[.,]\d+)?)\s*(?:[x×X]\s*(\d+(?:[.,]\d+)?))?\s*(?:-\s*(LH|RH))?\s*$",
+ re.IGNORECASE,
+)
+
+register(
+ "ISO 261",
+ {d: {"pitch": COARSE[d], "fine": FINE.get(d, ())} for d in DIAMETERS},
+ COVERAGE,
+ SOURCE_PITCH,
+)
+
+
+def _number(text: str) -> float:
+ return float(text.replace(",", "."))
+
+
+def _pitch_text(pitch: float) -> str:
+ """A pitch in millimetres, always carrying a decimal: 2.5, 2.0, 0.75."""
+ text = f"{float(pitch):g}"
+ return text if "." in text else f"{text}.0"
+
+
+def pitch_for(d: float, pitch: float | None = None) -> float:
+ """The coarse pitch for ``d``, or validate an explicit fine pitch against the series."""
+ diameter = float(d)
+ if diameter not in COARSE:
+ raise ValueError(
+ f"ISO 261: M{diameter:g} is not in the transcribed table. "
+ f"Coverage is M{DIAMETERS[0]:g}-M{DIAMETERS[-1]:g}, the first-choice diameters plus "
+ "the second-choice M14, M18, M22, M27, M33, M39, M45, M52 and M60; the remaining "
+ "second-choice diameters and every third-choice diameter are not transcribed and "
+ "are not interpolated."
+ )
+ if pitch is None:
+ return COARSE[diameter]
+ value = float(pitch)
+ if abs(value - COARSE[diameter]) < 1e-9:
+ return COARSE[diameter]
+ series = FINE.get(diameter, ())
+ for candidate in series:
+ if abs(value - candidate) < 1e-9:
+ return candidate
+ known = ", ".join(_pitch_text(p) for p in (COARSE[diameter], *series))
+ raise ValueError(
+ f"ISO 261: M{diameter:g} has no pitch {value:g} mm in the transcribed series. "
+ f"Transcribed pitches for M{diameter:g}: {known}."
+ )
+
+
+def parse_designation(designation: str) -> dict:
+ """``M20`` / ``M20x1.5`` / ``M20x1.5-LH`` -> d, pitch, series, hand."""
+ match = _DESIGNATION_RE.match(str(designation or ""))
+ if not match:
+ raise ValueError(
+ f"designation {designation!r}: expected an ISO metric thread such as "
+ "'M20', 'M20x1.5' or 'M20x1.5-LH'."
+ )
+ diameter = _number(match.group(1))
+ raw_pitch = _number(match.group(2)) if match.group(2) else None
+ pitch = pitch_for(diameter, raw_pitch)
+ hand = "left" if (match.group(3) or "RH").upper() == "LH" else "right"
+ series = "coarse" if abs(pitch - COARSE[diameter]) < 1e-9 else "fine"
+ text = f"M{diameter:g}" if raw_pitch is None else f"M{diameter:g}x{pitch:g}"
+ if hand == "left":
+ text += "-LH"
+ return {
+ "designation": text,
+ "d": diameter,
+ "pitch": pitch,
+ "series": series,
+ "hand": hand,
+ }
+
+
+def _h(pitch: float) -> float:
+ """ISO 68-1 fundamental triangle height."""
+ return math.sqrt(3.0) / 2.0 * float(pitch)
+
+
+def basic_minor_diameter(d: float, pitch: float) -> float:
+ """ISO 724 basic minor diameter d1 = d - 1.25 * H."""
+ return float(d) - 1.25 * _h(pitch)
+
+
+def basic_pitch_diameter(d: float, pitch: float) -> float:
+ """ISO 724 basic pitch diameter d2 = d - 0.75 * H."""
+ return float(d) - 0.75 * _h(pitch)
+
+
+def thread_axial_prims(
+ *,
+ x0: float,
+ length: float,
+ d: float,
+ internal: bool = False,
+ minor_ratio: float = MINOR_RATIO,
+ limit_line: bool = True,
+) -> tuple[Prim, ...]:
+ """ISO 6410 representation in a view along the axis (axis on y = 0, +X along it).
+
+ External: the two minor-diameter lines at 0.8 x d plus the thread-length
+ limit line across the full major diameter. Internal (not sectioned): the
+ same pair at the *major* diameter, hidden, because the drawn bore already
+ carries the minor.
+ """
+ x1 = float(x0) + float(length)
+ role = "hidden" if internal else "visible"
+ radius = float(d) / 2.0 if internal else float(minor_ratio) * float(d) / 2.0
+ prims: list[Prim] = [
+ Line((float(x0), radius), (x1, radius), role),
+ Line((float(x0), -radius), (x1, -radius), role),
+ ]
+ if limit_line:
+ major = float(d) / 2.0
+ prims.append(Line((x1, -major), (x1, major), role))
+ return tuple(prims)
+
+
+def thread_end_prims(
+ *,
+ center: tuple[float, float],
+ d: float,
+ internal: bool = False,
+ gap_center_deg: float = 45.0,
+ minor_ratio: float = MINOR_RATIO,
+) -> tuple[Prim, ...]:
+ """ISO 6410 three-quarter circle for a view along the thread axis.
+
+ External: three quarters of the *minor* circle. Internal: three quarters of
+ the *major* circle. The quarter-circle gap is centred on ``gap_center_deg``
+ (45 deg = the upper-right quadrant, the usual placement).
+ """
+ radius = float(d) / 2.0 if internal else float(minor_ratio) * float(d) / 2.0
+ start = (float(gap_center_deg) + 45.0) % 360.0
+ end = (start + 270.0) % 360.0
+ return (Arc((float(center[0]), float(center[1])), radius, start, end, "visible"),)
diff --git a/engineering/mech/standards/undercuts.py b/engineering/mech/standards/undercuts.py
new file mode 100644
index 0000000..19b0508
--- /dev/null
+++ b/engineering/mech/standards/undercuts.py
@@ -0,0 +1,67 @@
+"""DIN 509 relief grooves (Freistiche), forms E and F.
+
+SOURCE
+------
+DIN 509:1998-06, *Technische Zeichnungen - Freistiche*, Table 1 (forms E and F).
+
+COVERAGE
+--------
+**No rows are transcribed in this build.** The track's table rule is absolute:
+a row that cannot be verified against the standard's own table is not shipped,
+because a wrong relief depth is a wrong workshop drawing while a narrow table
+is merely narrow. Every lookup here is therefore refused by name and names the
+route to fix it.
+
+Extending this module is a **data edit with no code change**: fill ``ROWS_E`` /
+``ROWS_F`` with ``{shaft_diameter_mm: {"profile": [[along, depth], ...]}}`` and
+widen ``COVERAGE_E`` / ``COVERAGE_F`` to the transcribed range. ``profile`` is
+the standard's own published relief profile expressed as (distance along the
+axis from the shoulder corner, depth below the smaller cylinder) pairs in
+millimetres - the same ``profile`` key group S's ``std_feature_draw`` reads -
+so nothing downstream ever reconstructs a profile it was not handed.
+"""
+
+from __future__ import annotations
+
+from engineering.mech.standards import Coverage, register
+
+SOURCE = "DIN 509:1998-06, Table 1 (forms E and F) - NOT TRANSCRIBED in this build"
+
+#: The keys every DIN 509 row must carry for the drawing code to use it.
+UNDERCUT_ROW_KEYS: tuple[str, ...] = ("profile",)
+
+FORMS: tuple[str, ...] = ("E", "F")
+
+ROWS_E: dict[float, dict] = {}
+ROWS_F: dict[float, dict] = {}
+
+COVERAGE_E = Coverage(0.0, 0.0, "mm")
+COVERAGE_F = Coverage(0.0, 0.0, "mm")
+
+register("DIN 509-E", ROWS_E, COVERAGE_E, SOURCE)
+register("DIN 509-F", ROWS_F, COVERAGE_F, SOURCE)
+
+_ROWS = {"E": ROWS_E, "F": ROWS_F}
+
+
+def undercut_profile(form: str, d: float) -> tuple[tuple[float, float], ...]:
+ """The published relief profile for a shaft diameter, or a named refusal."""
+ key = str(form or "").strip().upper()
+ if key not in FORMS:
+ raise ValueError(f"DIN 509 defines forms E and F; got {form!r}.")
+ rows = _ROWS[key]
+ if not rows:
+ raise ValueError(
+ f"DIN 509 form {key}: no rows are transcribed in this build, so no relief "
+ f"profile can be looked up for d = {float(d):g} mm. Pass the profile "
+ "explicitly (Undercut(profile=[[along, depth], ...])), or transcribe "
+ f"{SOURCE.split(' - ')[0]} into engineering/mech/standards/undercuts.py. "
+ "Nothing is interpolated and no profile is invented."
+ )
+ size = float(d)
+ if size in rows:
+ return tuple(tuple(float(v) for v in pair) for pair in rows[size]["profile"])
+ raise ValueError(
+ f"DIN 509 form {key}: d = {size:g} mm has no transcribed row "
+ f"(transcribed sizes: {sorted(rows)})."
+ )
diff --git a/engineering/mech/views.py b/engineering/mech/views.py
new file mode 100644
index 0000000..2666078
--- /dev/null
+++ b/engineering/mech/views.py
@@ -0,0 +1,1284 @@
+"""The view engine: a part model projected onto a plane, as primitive descriptions.
+
+Pure: no backend, no async, no DXF. A ``View`` carries the primitives, the
+dimension intents its features asked for, the features it could **not** project
+(the honesty rule of spec section 6 - nothing is silently dropped), and its own
+bounding box, so the next view can be laid out against it.
+
+The one calculation everything comes out of for a revolved part is the **radial
+profile**: the part cut into bands of constant (or linearly tapering) outer
+radius and constant bore radius, with every feature's ``radial_edit`` applied.
+The silhouette is that profile walked as a path; the section cut face (Task 5)
+is the same profile walked as a closed loop. One calculation, so the outline
+and the hatched area can never disagree.
+
+Line roles follow ISO 128 and are mapped to layers by
+``engineering/mech/primitives.py``: the silhouette is ``visible``, a bore or a
+feature behind material is ``hidden``, the axis and centre marks are
+``center``, a bend line is ``phantom``.
+"""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass
+from typing import Any, NamedTuple
+
+from engineering.mech.features import Pocket as _PocketType
+from engineering.mech.features import mirror_about_x
+from engineering.mech.part import (
+ PrismaticPart,
+ RevolvedPart,
+ outline_bbox,
+ part_length,
+ part_max_diameter,
+ segment_bounds,
+)
+from engineering.mech.primitives import (
+ Arc,
+ Circle,
+ DimIntent,
+ HatchArea,
+ Line,
+ Poly,
+ Prim,
+ Pt,
+ Text,
+ bbox,
+ translate,
+)
+from engineering.mech.primitives import scale as scale_prims
+
+_EPS = 1e-9
+_SNAP = 6 # decimals used to match a corner vertex to a corner edit
+#: Radii closer together than this are the same circle, in millimetres. Looser
+#: than ``_EPS`` on purpose: it compares *measured* radii, not exact vertices.
+_TOL = 1e-6
+
+VIEW_KINDS: tuple[str, ...] = ("front", "side", "top", "section", "detail")
+PROJECTIONS: tuple[str, ...] = ("first", "third")
+SIDES: tuple[str, ...] = ("right", "left")
+
+#: How far the axis and the centre lines run past the material, as a fraction
+#: of the part's largest dimension (ISO 128-23 asks for a short, even overrun).
+AXIS_OVERRUN = 0.10
+
+#: The cutting-plane record `section_line` (Task 11, group A) produces and this
+#: engine consumes. `via` is optional and only meaningful for an offset plane.
+SECTION_PLANE = {
+ "p1": (0.0, 0.0),
+ "p2": (0.0, 0.0),
+ "label": "A",
+ "direction": (0.0, -1.0),
+ "style": "full",
+}
+
+SECTION_STYLES: tuple[str, ...] = ("full", "half", "offset", "revolved")
+
+#: A circular cut-face boundary has to be handed to the hatch as points. 180
+#: segments is a 2 degree chord: the boundary is never further than
+#: r * (1 - cos(1 deg)) = 0.015% of the radius from the true circle, and the
+#: area of the inscribed 180-gon is 0.0203% under the circle's. Both figures are
+#: stated rather than hidden, and the same loop is what the hatch and any area
+#: check read, so they cannot disagree with each other.
+CIRCLE_SEGMENTS = 180
+
+
+@dataclass(frozen=True)
+class View:
+ kind: str
+ prims: tuple[Prim, ...]
+ dims: tuple[DimIntent, ...]
+ omitted: tuple[dict, ...]
+ bbox: tuple[float, float, float, float]
+ label: str | None = None
+ scale: float = 1.0
+
+
+class Band(NamedTuple):
+ """One axial slice of a revolved part with a constant bore and a linear outside."""
+
+ x0: float
+ x1: float
+ r_in: float
+ r_out0: float
+ r_out1: float
+
+
+# -- the radial profile ------------------------------------------------------
+
+
+def _band_radii(part: RevolvedPart, a: float, b: float) -> tuple[float, float, float]:
+ """``(r_out0, r_out1, r_in)`` of one band, read off the segment it lies in.
+
+ The segment is resolved from the band's **midpoint** - a band never spans a
+ segment boundary, because every boundary is one of the cuts - and a taper is
+ then evaluated at the band's own two ends. Sampling a hair inside the band
+ instead (the obvious way to dodge the boundary ambiguity) writes the taper
+ slope times that inset into every coordinate the silhouette is built from:
+ a 30 -> 10 mm cone over 20 mm reports 14.99999 for a radius that is exactly
+ 15, and nothing downstream can tell that it is wrong.
+ """
+ mid = (a + b) / 2.0
+ found: tuple[float, float, Any] | None = None
+ for (x0, x1), segment in zip(segment_bounds(part), part.segments, strict=True):
+ if x0 - _EPS <= mid <= x1 + _EPS:
+ found = (x0, x1, segment)
+ break
+ if found is None: # pragma: no cover - the cuts always include every segment bound
+ raise ValueError(f"radial_profile: x={mid} is outside the part.")
+ x0, x1, segment = found
+ r_in = segment.d_inner / 2.0
+ if segment.taper_to is None:
+ radius = segment.d_outer / 2.0
+ return radius, radius, r_in
+ span = x1 - x0
+
+ def taper_at(x: float) -> float:
+ t = 0.0 if span <= _EPS else (x - x0) / span
+ return (segment.d_outer + t * (segment.taper_to - segment.d_outer)) / 2.0
+
+ return taper_at(a), taper_at(b), r_in
+
+
+def radial_profile(part: RevolvedPart) -> tuple[Band, ...]:
+ """The part as bands, with every feature's ``radial_edit`` applied."""
+ length = part_length(part)
+ cuts: set[float] = {0.0, length}
+ for x0, x1 in segment_bounds(part):
+ cuts.add(x0)
+ cuts.add(x1)
+
+ edits: list[dict] = []
+ for feature in part.features:
+ for edit in feature.radial_edit(part):
+ x0 = max(0.0, min(length, float(edit["x0"])))
+ x1 = max(0.0, min(length, float(edit["x1"])))
+ if x1 - x0 <= _EPS:
+ continue
+ edits.append(
+ {"kind": edit["kind"], "x0": x0, "x1": x1, "radius": float(edit["radius"])}
+ )
+ cuts.add(x0)
+ cuts.add(x1)
+
+ xs = sorted(cuts)
+ bands: list[Band] = []
+ for a, b in zip(xs, xs[1:], strict=False):
+ if b - a <= _EPS:
+ continue
+ r_out0, r_out1, r_in = _band_radii(part, a, b)
+ for edit in edits:
+ if edit["x0"] - _EPS <= a and b <= edit["x1"] + _EPS:
+ if edit["kind"] == "outer":
+ r_out0 = min(r_out0, edit["radius"])
+ r_out1 = min(r_out1, edit["radius"])
+ else:
+ r_in = max(r_in, edit["radius"])
+ bands.append(Band(a, b, r_in, r_out0, r_out1))
+ return tuple(bands)
+
+
+def _outer_path(bands: tuple[Band, ...]) -> list[Pt]:
+ """The +Y half-outline, from the left face at the bore, over the outside, down
+ to the right face at the bore. The end faces are *part of the path*, so a
+ chamfer or fillet at an end corner substitutes into them instead of being
+ drawn across them - which is what stops the overshoot the `untrimmed_corner`
+ critique focus looks for."""
+ points: list[Pt] = [(bands[0].x0, bands[0].r_in), (bands[0].x0, bands[0].r_out0)]
+ for band in bands:
+ if math.dist(points[-1], (band.x0, band.r_out0)) > _EPS:
+ points.append((band.x0, band.r_out0))
+ points.append((band.x1, band.r_out1))
+ points.append((bands[-1].x1, bands[-1].r_in))
+ return points
+
+
+def _bore_path(bands: tuple[Band, ...]) -> list[list[Pt]]:
+ """One path per contiguous run of bored bands; a blind end is capped."""
+ runs: list[list[Pt]] = []
+ current: list[Pt] = []
+ for band in bands:
+ if band.r_in <= _EPS:
+ if current:
+ runs.append(current)
+ current = []
+ continue
+ if not current:
+ current = [(band.x0, band.r_in)]
+ elif math.dist(current[-1], (band.x0, band.r_in)) > _EPS:
+ current.append((band.x0, band.r_in))
+ current.append((band.x1, band.r_in))
+ if current:
+ runs.append(current)
+ return runs
+
+
+def _key(point: Pt) -> tuple[float, float]:
+ return (round(float(point[0]), _SNAP), round(float(point[1]), _SNAP))
+
+
+def _emit_path(points: list[Pt], edits: list[dict], role: str, *, closed: bool = False):
+ """Walk a vertex path, substituting corner edits, and emit Lines and Arcs.
+
+ Returns ``(prims, used)`` - ``used`` is the set of edit ids that actually
+ found their corner, so the caller can report the ones that did not.
+ """
+ by_corner: dict[tuple[float, float], dict] = {}
+ for edit in edits:
+ by_corner[_key(edit["corner"])] = edit
+
+ sequence: list[tuple[str, Any]] = []
+ used: set[str] = set()
+ for point in points:
+ edit = by_corner.get(_key(point))
+ if edit is None:
+ sequence.append(("pt", (float(point[0]), float(point[1]))))
+ continue
+ used.add(edit["owner"])
+ sequence.append(("pt", edit["before"]))
+ if edit["arc"] is not None:
+ sequence.append(("arc", edit["arc"]))
+ sequence.append(("pt", edit["after"]))
+
+ if closed and sequence:
+ sequence.append(sequence[0])
+
+ prims: list[Prim] = []
+ previous: Pt | None = None
+ for tag, item in sequence:
+ if tag == "pt":
+ if previous is not None and math.dist(previous, item) > _EPS:
+ prims.append(Line(previous, item, role))
+ previous = item
+ else:
+ prims.append(item)
+ previous = None
+ return tuple(prims), used
+
+
+def _corner_edits(part: RevolvedPart) -> list[dict]:
+ """Every feature's silhouette corner substitution, tagged with its owner."""
+ edits: list[dict] = []
+ for feature in part.features:
+ edit = feature.corner_edit(part)
+ if edit is not None:
+ edits.append({**edit, "owner": feature.id})
+ return edits
+
+
+def _unplaced(edits: list[dict], used: set[str]) -> tuple[dict, ...]:
+ return tuple(
+ {
+ "feature": edit["owner"],
+ "reason": (
+ "its silhouette corner was changed by another feature, so the "
+ "chamfer/fillet could not be placed"
+ ),
+ }
+ for edit in edits
+ if edit["owner"] not in used
+ )
+
+
+def silhouette_prims(part: RevolvedPart) -> tuple[tuple[Prim, ...], tuple[dict, ...]]:
+ """The mirrored half-outline (end faces included), the bore, and any unplaced edit."""
+ bands = radial_profile(part)
+ edits = _corner_edits(part)
+
+ upper, used = _emit_path(_outer_path(bands), edits, "visible")
+ prims: list[Prim] = list(upper) + list(mirror_about_x(upper))
+
+ for run in _bore_path(bands):
+ bore, _ = _emit_path(run, [], "hidden")
+ prims.extend(bore)
+ prims.extend(mirror_about_x(bore))
+
+ return tuple(prims), _unplaced(edits, used)
+
+
+def section_profile_prims(
+ part: RevolvedPart, *, half: bool = False
+) -> tuple[tuple[Prim, ...], tuple[dict, ...]]:
+ """The longitudinal **cut** outline of a revolved part.
+
+ Every edge of this outline lies in the cutting plane, so every edge is a
+ continuous line: ISO 128-50 bounds a surface lying in the cutting plane
+ with a continuous wide line, and ISO 128-3 leaves hidden detail out of a
+ sectional view altogether. The bore wall is the case that matters - the
+ un-cut silhouette draws it dashed because material hides it, but in a
+ section it *is* the cut face's own boundary, and drawing the hatch loop and
+ a dashed line on the same coordinates is the one thing a section must not
+ do. ``half=True`` keeps the dashed bore on the -Y half, which is the half a
+ half section does not cut.
+ """
+ bands = radial_profile(part)
+ edits = _corner_edits(part)
+
+ upper, used = _emit_path(_outer_path(bands), edits, "visible")
+ prims: list[Prim] = list(upper) + list(mirror_about_x(upper))
+
+ for run in _bore_path(bands):
+ bore, _ = _emit_path(run, [], "visible")
+ prims.extend(bore)
+ mirrored = mirror_about_x(bore)
+ if half:
+ mirrored = tuple(prim._replace(role="hidden") for prim in mirrored)
+ prims.extend(mirrored)
+
+ return tuple(prims), _unplaced(edits, used)
+
+
+def outline_prims(part: PrismaticPart) -> tuple[tuple[Prim, ...], tuple[dict, ...]]:
+ """The closed outline with every corner fillet/chamfer substituted in."""
+ edits: list[dict] = []
+ for feature in part.features:
+ edit = feature.outline_edit(part)
+ if edit is None:
+ continue
+ corner = part.outline[int(edit["vertex"]) % len(part.outline)]
+ edits.append({**edit, "corner": corner, "owner": feature.id})
+
+ prims, used = _emit_path(list(part.outline), edits, "visible", closed=True)
+ unplaced = tuple(
+ {"feature": edit["owner"], "reason": "its outline vertex was already replaced"}
+ for edit in edits
+ if edit["owner"] not in used
+ )
+ return prims, unplaced
+
+
+# -- view construction -------------------------------------------------------
+
+
+def _axis_prims(part: RevolvedPart) -> tuple[Prim, ...]:
+ length = part_length(part)
+ over = max(length, part_max_diameter(part)) * AXIS_OVERRUN
+ return (Line((-over, 0.0), (length + over, 0.0), "center"),)
+
+
+def _band_order(bands: tuple[Band, ...], side: str) -> list[Band]:
+ """The bands from the end the observer stands at, nearest first."""
+ return list(reversed(bands)) if side == "right" else list(bands)
+
+
+def _band_radii_near_first(band: Band, side: str) -> tuple[float, float]:
+ """A band's two outer radii, the one nearer the observer first.
+
+ They differ only on a taper: ``r_out0`` sits at ``x0`` and ``r_out1`` at
+ ``x1``, so which of them faces the observer depends on the end they are
+ looking from.
+ """
+ return (band.r_out1, band.r_out0) if side == "right" else (band.r_out0, band.r_out1)
+
+
+def _front_max(bands: tuple[Band, ...], side: str, x: float) -> float:
+ """The largest outer radius of the material lying **between** ``x`` and the observer.
+
+ This is the whole hidden-line rule for a view along the axis of a solid of
+ revolution: a circular edge of radius ``r`` at station ``x`` is drawn
+ continuous when ``r`` is larger than everything in front of it, because then
+ nothing can cover it, and dashed when it is not, because the material in
+ front does cover it. A step face whose circle is *larger* than the material
+ ahead of it is the one edge that cannot be occluded.
+ """
+ best = 0.0
+ for band in bands:
+ nearer = band.x0 >= x - _TOL if side == "right" else band.x1 <= x + _TOL
+ if nearer:
+ best = max(best, band.r_out0, band.r_out1)
+ return best
+
+
+def _end_circles(part: RevolvedPart, side: str) -> tuple[Prim, ...]:
+ """The concentric circles the profile crosses at one end, roled by occlusion.
+
+ Outer edges: walking from the observer, a radius larger than everything
+ already passed is ``visible``; a smaller one is behind that material and is
+ ``hidden`` - it is still drawn, dashed, because it is a real edge of the
+ part. Bore edges follow the mirror-image rule: you can see down a bore only
+ while it stays at least as wide, so a bore radius no larger than the
+ narrowest bore in front of it is ``visible`` and anything wider (or anything
+ behind solid material) is ``hidden``.
+
+ Each distinct radius is emitted once: two coincident circles would be a
+ duplicate entity, which is what ``drawing_critique`` reports.
+ """
+ bands = radial_profile(part)
+ order = _band_order(bands, side)
+
+ prims: list[Prim] = []
+ emitted: set[float] = set()
+
+ def emit(radius: float, role: str) -> None:
+ key = round(float(radius), _SNAP)
+ if key <= _TOL or key in emitted:
+ return
+ emitted.add(key)
+ prims.append(Circle((0.0, 0.0), float(radius), role))
+
+ front_max = 0.0
+ for band in order:
+ for radius in _band_radii_near_first(band, side):
+ emit(radius, "visible" if radius > front_max + _TOL else "hidden")
+ front_max = max(front_max, radius)
+
+ open_r = math.inf
+ for band in order:
+ if band.r_in <= _TOL:
+ open_r = 0.0 # solid across the whole section: nothing behind is seen
+ continue
+ emit(band.r_in, "visible" if band.r_in <= open_r + _TOL else "hidden")
+ open_r = min(open_r, band.r_in)
+
+ over = front_max * (1.0 + AXIS_OVERRUN)
+ prims.append(Line((-over, 0.0), (over, 0.0), "center"))
+ prims.append(Line((0.0, -over), (0.0, over), "center"))
+ return tuple(prims)
+
+
+def _rectangle(
+ u0: float, u1: float, v0: float, v1: float, role: str = "visible"
+) -> tuple[Prim, ...]:
+ return (
+ Line((u0, v0), (u1, v0), role),
+ Line((u1, v0), (u1, v1), role),
+ Line((u1, v1), (u0, v1), role),
+ Line((u0, v1), (u0, v0), role),
+ )
+
+
+def _mirror_u(prims, axis: float) -> tuple[Prim, ...]:
+ """Reflect primitives about the vertical line ``u = axis`` (screen left <-> right).
+
+ ``Text`` moves with the drawing but keeps its rotation: a mirrored label
+ would read backwards, and no drawing has ever wanted that.
+ """
+
+ def flip(point: Pt) -> Pt:
+ return (2.0 * axis - float(point[0]), float(point[1]))
+
+ out: list[Prim] = []
+ for prim in prims:
+ if isinstance(prim, Line):
+ out.append(Line(flip(prim.p1), flip(prim.p2), prim.role))
+ elif isinstance(prim, Arc):
+ # reflection reverses the sweep, so the CCW arc runs from the
+ # reflected end angle to the reflected start angle
+ out.append(
+ Arc(
+ flip(prim.center),
+ prim.radius,
+ (180.0 - prim.end_deg) % 360.0,
+ (180.0 - prim.start_deg) % 360.0,
+ prim.role,
+ )
+ )
+ elif isinstance(prim, Circle):
+ out.append(Circle(flip(prim.center), prim.radius, prim.role))
+ elif isinstance(prim, Poly):
+ out.append(Poly(tuple(flip(pt) for pt in prim.points), prim.closed, prim.role))
+ elif isinstance(prim, HatchArea):
+ out.append(
+ HatchArea(
+ tuple(tuple(flip(pt) for pt in loop) for loop in prim.loops), prim.material
+ )
+ )
+ elif isinstance(prim, Text):
+ out.append(prim._replace(at=flip(prim.at)))
+ else: # pragma: no cover - Prim is a closed union
+ raise TypeError(f"_mirror_u: not a primitive: {prim!r}")
+ return tuple(out)
+
+
+def _same_geometry(a: Prim, b: Prim) -> bool:
+ """True when two primitives would be drawn on top of each other, role aside.
+
+ Exactly what ``engineering/critique.py::_check_duplicate_entities`` matches,
+ so anything this returns True for is a warning on the finished drawing.
+ """
+ if isinstance(a, Line) and isinstance(b, Line):
+ forward = math.dist(a.p1, b.p1) <= _TOL and math.dist(a.p2, b.p2) <= _TOL
+ reverse = math.dist(a.p1, b.p2) <= _TOL and math.dist(a.p2, b.p1) <= _TOL
+ return forward or reverse
+ if isinstance(a, Circle) and isinstance(b, Circle):
+ return math.dist(a.center, b.center) <= _TOL and abs(a.radius - b.radius) <= _TOL
+ if isinstance(a, Arc) and isinstance(b, Arc):
+ return (
+ math.dist(a.center, b.center) <= _TOL
+ and abs(a.radius - b.radius) <= _TOL
+ and abs((a.start_deg - b.start_deg) % 360.0) <= _TOL
+ and abs((a.end_deg - b.end_deg) % 360.0) <= _TOL
+ )
+ return False
+
+
+def _profile_owned(feature, part, prim: Prim, kind: str, body: list[Prim]) -> bool:
+ """True when the radial profile has already drawn this primitive.
+
+ Two separate reasons, and both are the same principle - the profile is the
+ one calculation the silhouette comes out of, so a feature never draws it a
+ second time:
+
+ * the primitive is coincident with something the body already emitted; or
+ * the feature edits the radial profile and this is a **silhouette** line of
+ a revolved front view. ``radial_profile`` already cut the notch, and the
+ feature's own idea of where the surface is can even contradict it - a
+ groove straddling a step reads one surface radius for a span that has two.
+ """
+ if any(_same_geometry(prim, other) for other in body):
+ return True
+ if kind == "front" and getattr(prim, "role", None) == "visible":
+ return bool(feature.radial_edit(part))
+ return False
+
+
+def _end_view_role(feature, part, prim: Prim, bands: tuple[Band, ...], side: str) -> Prim:
+ """Re-role a feature's axis-centred end-view circle by the same occlusion rule.
+
+ The feature knows its diameter, not what is standing in front of it. Its
+ axial station comes from ``removal_box``; a feature that does not report one
+ keeps its own verdict rather than having one guessed for it.
+ """
+ if not isinstance(prim, Circle) or math.dist(prim.center, (0.0, 0.0)) > _TOL:
+ return prim
+ box = feature.removal_box(part)
+ if box is None:
+ return prim
+ x_near = float(box[2]) if side == "right" else float(box[0])
+ covered = _front_max(bands, side, x_near)
+ return prim._replace(role="visible" if prim.radius > covered + _TOL else "hidden")
+
+
+def _feature_layer(
+ part,
+ kind: str,
+ ctx: dict,
+ *,
+ body: list[Prim] | None = None,
+ bands: tuple[Band, ...] | None = None,
+) -> tuple[list[Prim], list[DimIntent], list[dict]]:
+ drawn: list[Prim] = list(body or ())
+ prims: list[Prim] = []
+ dims: list[DimIntent] = []
+ omitted: list[dict] = []
+ end_view = bands is not None and kind == "side"
+ side = str(ctx.get("side", "right")).strip().lower()
+ for feature in part.features:
+ own = tuple(feature.prims(part, kind, ctx))
+ if end_view:
+ own = tuple(_end_view_role(feature, part, prim, bands, side) for prim in own)
+ own = tuple(prim for prim in own if not _profile_owned(feature, part, prim, kind, drawn))
+ if own and kind == "front" and getattr(feature, "axisymmetric", False):
+ own = tuple(own) + mirror_about_x(own)
+ prims.extend(own)
+ drawn.extend(own)
+ dims.extend(feature.dims(part, kind, ctx))
+ if own:
+ continue
+ if feature.radial_edit(part) or feature.corner_edit(part) or feature.outline_edit(part):
+ continue
+ reason = feature.omission(part, kind)
+ if reason:
+ omitted.append({"feature": feature.id, "reason": reason})
+ return prims, dims, omitted
+
+
+def build_view(
+ part,
+ kind: str,
+ *,
+ side: str = "right",
+ plane: dict | None = None,
+ style: str = "full",
+ scale: float = 1.0,
+ projection: str = "first",
+ detail: dict | None = None,
+) -> View:
+ """Project a part onto one view plane and return its primitive description.
+
+ ``side`` is the end the observer stands at for an end view, and it decides
+ both what is in front of what and which way round the view reads. The
+ ``top`` view is always seen from above, so ``side`` does not apply to it.
+ """
+ name = str(kind or "").strip().lower()
+ if name not in VIEW_KINDS:
+ raise ValueError(f"build_view: kind {kind!r} is not one of {VIEW_KINDS}.")
+ where = str(side or "right").strip().lower()
+ if where not in SIDES:
+ raise ValueError(f"build_view: side {side!r} is not one of {SIDES}.")
+ if name == "section":
+ return section_view(part, plane, style=style, scale=scale, side=where)
+ if name == "detail":
+ return detail_view(part, detail or {}, projection=projection, side=where)
+
+ ctx = {"side": where, "scale": float(scale), "projection": str(projection)}
+ prims: list[Prim] = []
+ omitted: list[dict] = []
+ bands: tuple[Band, ...] | None = None
+ mirror_u: float | None = None
+
+ if isinstance(part, RevolvedPart):
+ if name == "top":
+ raise ValueError(
+ "build_view: a revolved part's top view is identical to its front view; "
+ "use kind='front', or kind='section' for a cut."
+ )
+ if name == "front":
+ body, unplaced = silhouette_prims(part)
+ prims.extend(body)
+ prims.extend(_axis_prims(part))
+ omitted.extend(unplaced)
+ else:
+ bands = radial_profile(part)
+ prims.extend(_end_circles(part, where))
+ elif isinstance(part, PrismaticPart):
+ x0, y0, x1, y1 = outline_bbox(part)
+ if name == "front":
+ body, unplaced = outline_prims(part)
+ prims.extend(body)
+ omitted.extend(unplaced)
+ elif name == "side":
+ prims.extend(_rectangle(y0, y1, 0.0, part.thickness))
+ # An observer standing at -X sees screen-right = -Y, so the left
+ # view is the right view mirrored. The thickness rectangle spans
+ # y0..y1 either way and is its own mirror; the features are not.
+ if where == "left":
+ mirror_u = (y0 + y1) / 2.0
+ else:
+ prims.extend(_rectangle(x0, x1, 0.0, part.thickness))
+ else: # pragma: no cover - build_part only makes these two
+ raise ValueError(f"build_view: {type(part).__name__} is not a part model.")
+
+ feature_prims, dims, feature_omitted = _feature_layer(part, name, ctx, body=prims, bands=bands)
+ if mirror_u is not None:
+ feature_prims = list(_mirror_u(feature_prims, mirror_u))
+ prims.extend(feature_prims)
+ omitted.extend(feature_omitted)
+
+ return View(
+ kind=name,
+ prims=tuple(prims),
+ dims=tuple(dims),
+ omitted=tuple(omitted),
+ bbox=bbox(prims) if prims else (0.0, 0.0, 0.0, 0.0),
+ label=None,
+ scale=float(scale),
+ )
+
+
+def layout_origin(
+ parent: View,
+ kind: str,
+ *,
+ side: str = "right",
+ projection: str = "first",
+ gap: float = 20.0,
+ child: View | None = None,
+) -> Pt:
+ """Where a child view's bounding box lower-left corner goes, relative to its parent.
+
+ First angle (ISO 128-30, the default here): the view obtained by looking
+ from the right is placed on the **left**, and the view from above is placed
+ **below**. Third angle flips both. With no ``child`` the child is assumed
+ the same size as the parent, which keeps the views aligned even before the
+ child has been built.
+ """
+ projection_name = str(projection or "").strip().lower()
+ if projection_name not in PROJECTIONS:
+ raise ValueError(f"layout_origin: projection {projection!r} is not one of {PROJECTIONS}.")
+ px0, py0, px1, py1 = parent.bbox
+ if child is None:
+ width, height = px1 - px0, py1 - py0
+ else:
+ cx0, cy0, cx1, cy1 = child.bbox
+ width, height = cx1 - cx0, cy1 - cy0
+ mid_x = (px0 + px1) / 2.0
+ mid_y = (py0 + py1) / 2.0
+ first = projection_name == "first"
+ name = str(kind or "").strip().lower()
+ gap = float(gap)
+
+ if name in ("side", "section"):
+ looking_from_right = str(side or "right").strip().lower() == "right"
+ to_the_left = looking_from_right == first
+ if to_the_left:
+ return (px0 - gap - width, mid_y - height / 2.0)
+ return (px1 + gap, mid_y - height / 2.0)
+ if name == "top":
+ if first:
+ return (mid_x - width / 2.0, py0 - gap - height)
+ return (mid_x - width / 2.0, py1 + gap)
+ if name == "detail":
+ return (px1 + gap, mid_y - height / 2.0)
+ raise ValueError(
+ f"layout_origin: kind {kind!r} has no projection position; expected "
+ "'side', 'top', 'section' or 'detail'."
+ )
+
+
+# -- sections ----------------------------------------------------------------
+
+
+def normalise_plane(plane: dict | None) -> dict:
+ """Fill the `SECTION_PLANE` shape and refuse a plane that has no direction."""
+ if plane is None:
+ raise ValueError(
+ "section: a cutting plane is required - pass plane={'p1': ..., 'p2': ...}, "
+ "or take one from section_line()."
+ )
+ if not isinstance(plane, dict):
+ raise ValueError(f"plane: expected a dict, got {type(plane).__name__}.")
+ unknown = sorted(set(plane) - set(SECTION_PLANE) - {"via"})
+ if unknown:
+ raise ValueError(f"plane: unknown key(s) {unknown}; it takes {sorted(SECTION_PLANE)}.")
+ p1 = tuple(float(v) for v in plane.get("p1", SECTION_PLANE["p1"]))
+ p2 = tuple(float(v) for v in plane.get("p2", SECTION_PLANE["p2"]))
+ if math.dist(p1, p2) < 1e-6:
+ raise ValueError(f"plane: p1 and p2 are the same point, the plane has zero length ({p1}).")
+ style = str(plane.get("style", SECTION_PLANE["style"])).strip().lower()
+ if style not in SECTION_STYLES:
+ raise ValueError(f"plane.style: expected one of {SECTION_STYLES}, got {style!r}.")
+ via = tuple(tuple(float(v) for v in point) for point in plane.get("via", ()))
+ return {
+ "p1": p1,
+ "p2": p2,
+ "label": str(plane.get("label", SECTION_PLANE["label"])),
+ "direction": tuple(float(v) for v in plane.get("direction", SECTION_PLANE["direction"])),
+ "style": style,
+ "via": via,
+ }
+
+
+def _inner_path(bands: tuple[Band, ...]) -> list[Pt]:
+ points: list[Pt] = [(bands[0].x0, bands[0].r_in)]
+ for band in bands:
+ if math.dist(points[-1], (band.x0, band.r_in)) > _EPS:
+ points.append((band.x0, band.r_in))
+ points.append((band.x1, band.r_in))
+ return points
+
+
+def _arc_loop_points(
+ radius: float, start_deg: float, end_deg: float, *, center: Pt = (0.0, 0.0)
+) -> tuple[Pt, ...]:
+ """An arc as points, at the same 2 degree chord ``CIRCLE_SEGMENTS`` states.
+
+ Endpoints are exact, so a loop built from an arc plus a polyline closes on
+ the polyline's own corners.
+ """
+ sweep = (float(end_deg) - float(start_deg)) % 360.0
+ if sweep <= _EPS:
+ sweep = 360.0
+ count = max(1, math.ceil(sweep / (360.0 / CIRCLE_SEGMENTS) - 1e-9))
+ out: list[Pt] = []
+ for step in range(count + 1):
+ angle = math.radians(float(start_deg) + sweep * step / count)
+ out.append((center[0] + radius * math.cos(angle), center[1] + radius * math.sin(angle)))
+ return tuple(out)
+
+
+def _flatten_path(prims) -> tuple[Pt, ...]:
+ """A contiguous run of ``Line``/``Arc`` primitives as a point path.
+
+ This is what makes the drawn outline and the hatched loop **one**
+ calculation rather than two that agree by inspection: the loop is the
+ emitted outline, flattened, so a chamfer or a fillet that substitutes a
+ silhouette corner substitutes it in the cut face too.
+ """
+ points: list[Pt] = []
+
+ def push(point: Pt) -> None:
+ pt = (float(point[0]), float(point[1]))
+ if not points or math.dist(points[-1], pt) > _EPS:
+ points.append(pt)
+
+ for prim in prims:
+ if isinstance(prim, Line):
+ push(prim.p1)
+ push(prim.p2)
+ elif isinstance(prim, Arc):
+ for point in _arc_loop_points(
+ prim.radius, prim.start_deg, prim.end_deg, center=prim.center
+ ):
+ push(point)
+ else: # pragma: no cover - only an outline path is ever flattened
+ raise TypeError(f"_flatten_path: {type(prim).__name__} is not a path primitive.")
+ return tuple(points)
+
+
+def _circle_loop(radius: float, *, reverse: bool = False) -> tuple[Pt, ...]:
+ steps = range(CIRCLE_SEGMENTS - 1, -1, -1) if reverse else range(CIRCLE_SEGMENTS)
+ return tuple(
+ (
+ radius * math.cos(2.0 * math.pi * k / CIRCLE_SEGMENTS),
+ radius * math.sin(2.0 * math.pi * k / CIRCLE_SEGMENTS),
+ )
+ for k in steps
+ )
+
+
+def _is_longitudinal(plane: dict) -> bool:
+ """True when the cutting plane runs along the revolved axis (y = 0 at both ends)."""
+ return abs(plane["p1"][1]) < 1e-6 and abs(plane["p2"][1]) < 1e-6
+
+
+def _subtract(intervals: list[tuple[float, float]], cut: tuple[float, float]):
+ out: list[tuple[float, float]] = []
+ low, high = cut
+ for a, b in intervals:
+ if high <= a + _EPS or low >= b - _EPS:
+ out.append((a, b))
+ continue
+ if low > a + _EPS:
+ out.append((a, low))
+ if high < b - _EPS:
+ out.append((high, b))
+ return [(a, b) for a, b in out if b - a > 1e-9]
+
+
+def _chords(outline, p1: Pt, unit: Pt) -> list[tuple[float, float]]:
+ """Parameters along `unit` from `p1` where the line is inside the outline.
+
+ Crossings are paired in order, which is exact for a simple polygon whose
+ edges the line crosses transversally. A line that grazes a vertex produces
+ an odd crossing count and is refused by the caller rather than paired wrong.
+ """
+ ts: list[float] = []
+ count = len(outline)
+ for index in range(count):
+ ax, ay = outline[index]
+ bx, by = outline[(index + 1) % count]
+ ex, ey = bx - ax, by - ay
+ denominator = ex * unit[1] - ey * unit[0]
+ if abs(denominator) < 1e-12:
+ continue
+ s = ((p1[0] - ax) * unit[1] - (p1[1] - ay) * unit[0]) / denominator
+ if -1e-9 <= s <= 1.0 + 1e-9:
+ px, py = ax + s * ex, ay + s * ey
+ ts.append((px - p1[0]) * unit[0] + (py - p1[1]) * unit[1])
+ ts = sorted({round(t, 9) for t in ts})
+ if len(ts) % 2 != 0:
+ raise ValueError(
+ "section: the cutting plane grazes an outline vertex, so its crossings cannot be "
+ "paired. Move the plane off the vertex rather than accept a guessed cut face."
+ )
+ return [(ts[i], ts[i + 1]) for i in range(0, len(ts), 2)]
+
+
+def _plane_legs(plane: dict) -> list[tuple[Pt, Pt]]:
+ """The cutting legs of an offset plane.
+
+ ISO 128-40: the offset itself is not drawn, so with ``via`` present the
+ odd-numbered legs are the transfer jogs and contribute nothing to the cut
+ face.
+ """
+ points = [plane["p1"], *plane["via"], plane["p2"]]
+ legs = [(points[i], points[i + 1]) for i in range(len(points) - 1)]
+ if plane["via"]:
+ legs = [leg for index, leg in enumerate(legs) if index % 2 == 0]
+ return legs
+
+
+def _prismatic_cut(part: PrismaticPart, plane: dict, style: str):
+ """``[(u0, u1, v0, v1, hatched), ...]`` - the cut rectangles along the plane."""
+ legs = _plane_legs(plane) if style == "offset" else [(plane["p1"], plane["p2"])]
+ thickness = float(part.thickness)
+ rectangles: list[tuple[float, float, float, float, bool]] = []
+ origin = 0.0
+ for start, end in legs:
+ length = math.dist(start, end)
+ if length < 1e-9:
+ continue
+ unit = ((end[0] - start[0]) / length, (end[1] - start[1]) / length)
+ intervals = _chords(part.outline, start, unit)
+ intervals = [(a, b) for a, b in intervals if b > -1e-9 and a < length + 1e-9]
+ intervals = [(max(a, 0.0), min(b, length)) for a, b in intervals]
+ intervals = [(a, b) for a, b in intervals if b - a > 1e-9]
+
+ pockets: list[tuple[tuple[float, float], float, bool]] = []
+ for feature in part.features:
+ # the feature's own chord, never the box around it: a box punches
+ # the full width of a hole or an angled slot into the cut face
+ # wherever the plane passes, which is a wrong hatched area and a
+ # wrong drawn outline at the same time.
+ for span in feature.cut_spans(part, start, unit):
+ span = (max(span[0], 0.0), min(span[1], length))
+ if span[1] - span[0] <= 1e-9:
+ continue
+ if isinstance(feature, _PocketType):
+ pockets.append(
+ (span, thickness - float(feature.depth), feature.no_section_hatch)
+ )
+ else:
+ intervals = _subtract(intervals, span)
+
+ for pocket_span, floor, unhatched in pockets:
+ intervals = _subtract(intervals, pocket_span)
+ rectangles.append(
+ (origin + pocket_span[0], origin + pocket_span[1], 0.0, floor, not unhatched)
+ )
+ for a, b in intervals:
+ rectangles.append((origin + a, origin + b, 0.0, thickness, True))
+ origin += length
+ if rectangles:
+ # the section view has its own frame: u = 0 at the first cut.
+ shift = min(u0 for u0, _u1, _v0, _v1, _hatched in rectangles)
+ rectangles = [
+ (u0 - shift, u1 - shift, v0, v1, hatched) for u0, u1, v0, v1, hatched in rectangles
+ ]
+ return rectangles
+
+
+def _band_at(bands: tuple[Band, ...], station: float) -> Band | None:
+ for band in bands:
+ if band.x0 - _TOL <= station <= band.x1 + _TOL:
+ return band
+ return None
+
+
+def _represented_by_the_profile(feature, part, station: float) -> bool:
+ """True when the radial profile already carries this feature at ``station``.
+
+ A groove, an undercut, an axial bore or an ISO 6410 internal thread edits
+ the profile, and the transverse cut face is read off that profile - so the
+ feature is in the face already and must not be reported missing from it.
+ """
+ for edit in feature.radial_edit(part):
+ if float(edit["x0"]) - _TOL <= station <= float(edit["x1"]) + _TOL:
+ return True
+ return False
+
+
+def _transverse_face(part: RevolvedPart, station: float) -> dict:
+ """One transverse cut face: its boundary primitives, its loops, its omissions.
+
+ The radii come from ``radial_profile``, the same one calculation the
+ silhouette and the longitudinal cut face come out of, so a groove or an
+ internal thread at that station is in the face without being asked for.
+ A feature that is *not* axisymmetric is asked for its own ``cross_section``
+ notch; one that the plane really passes through and that has no such record
+ is reported rather than hatched over - a keyed shaft whose cross section
+ came out as a plain hatched disc is exactly the drawing the honesty rule
+ exists to prevent.
+ """
+ bands = radial_profile(part)
+ band = _band_at(bands, station)
+ if band is None:
+ raise ValueError(
+ f"section: the plane at x={station:g} is outside {part.name!r} "
+ f"(0 .. {part_length(part):g} mm)."
+ )
+ span = band.x1 - band.x0
+ fraction = 0.0 if span <= _EPS else (station - band.x0) / span
+ r_out = band.r_out0 + fraction * (band.r_out1 - band.r_out0)
+ r_in = band.r_in
+
+ notch: dict | None = None
+ omitted: list[dict] = []
+ for feature in part.features:
+ record = feature.cross_section(part, station)
+ if record is not None:
+ if notch is not None:
+ omitted.append(
+ {
+ "feature": feature.id,
+ "reason": (
+ "the cut face already carries another feature's notch; this "
+ "engine cuts one notch per transverse face"
+ ),
+ }
+ )
+ continue
+ measured = float(record["radius"])
+ if abs(measured - r_out) > _TOL:
+ omitted.append(
+ {
+ "feature": feature.id,
+ "reason": (
+ f"its notch was measured on a {2.0 * measured:g} mm surface but "
+ f"the cut face at x={station:g} is {2.0 * r_out:g} mm, so the "
+ "notch would not reach it"
+ ),
+ }
+ )
+ continue
+ notch = record
+ continue
+ if _represented_by_the_profile(feature, part, station):
+ continue
+ box = feature.removal_box(part)
+ if box is not None and float(box[0]) - _TOL <= station <= float(box[2]) + _TOL:
+ omitted.append(
+ {
+ "feature": feature.id,
+ "reason": (
+ f"the cutting plane at x={station:g} passes through it, but a "
+ f"{type(feature).__name__} has no transverse cut-face projection"
+ ),
+ }
+ )
+ else:
+ omitted.append(
+ {
+ "feature": feature.id,
+ "reason": f"it is not on the cutting plane at x={station:g}",
+ }
+ )
+
+ if notch is None:
+ loops: list[tuple[tuple[Pt, ...], bool]] = [(_circle_loop(r_out), True)]
+ prims: list[Prim] = [Circle((0.0, 0.0), r_out, "visible")]
+ else:
+ path = tuple((float(x), float(y)) for x, y in notch["path"])
+ start, end = float(notch["start_deg"]), float(notch["end_deg"])
+ # the material arc plus the notch walls: one boundary, and the loop is
+ # that same boundary sampled, so the hatch cannot cover the notch. The
+ # arc ends where the path ends, so the path is walked backwards to
+ # close the loop, and both of its endpoints are already on the arc.
+ closing = tuple(reversed(path))[1:-1]
+ loops = [(_arc_loop_points(r_out, start, end) + closing, True)]
+ prims = [Arc((0.0, 0.0), r_out, start, end, "visible")]
+ prims.extend(
+ Line(path[index], path[index + 1], "visible") for index in range(len(path) - 1)
+ )
+ if r_in > _EPS:
+ loops.append((_circle_loop(r_in, reverse=True), True))
+ prims.append(Circle((0.0, 0.0), r_in, "visible"))
+
+ return {
+ "r_out": r_out,
+ "r_in": r_in,
+ "loops": tuple(loops),
+ "prims": tuple(prims),
+ "omitted": tuple(omitted),
+ }
+
+
+def cut_loops(part, plane: dict, *, style: str = "full"):
+ """The closed cut-face loops, each flagged with whether it is hatched."""
+ plane = normalise_plane(plane)
+ name = str(style or "full").strip().lower()
+ if name not in SECTION_STYLES:
+ raise ValueError(f"style: expected one of {SECTION_STYLES}, got {style!r}.")
+
+ if isinstance(part, RevolvedPart):
+ bands = radial_profile(part)
+ if _is_longitudinal(plane):
+ if name == "offset":
+ raise ValueError(
+ "section: an offset plane has no meaning along a revolved part's axis; "
+ "use style='full' (or a transverse plane for a cross section)."
+ )
+ # the loop is the *emitted* outline flattened, so a chamfer or a
+ # fillet is cut out of the hatched area exactly where it is drawn
+ outline, _used = _emit_path(_outer_path(bands), _corner_edits(part), "visible")
+ upper = _flatten_path(outline) + tuple(reversed(_inner_path(bands)))
+ loops = [(upper, True)]
+ if name != "half":
+ loops.append((tuple((x, -y) for x, y in upper), True))
+ return tuple(loops)
+ if name == "half":
+ raise ValueError(
+ "section: a half section is defined on a longitudinal plane through the axis; "
+ "a transverse plane takes style='full' or 'revolved'."
+ )
+ return _transverse_face(part, plane["p1"][0])["loops"]
+
+ if name in ("half", "revolved"):
+ raise ValueError(
+ f"section: a {name} section is defined on a revolved part; a prismatic part takes "
+ "style='full' or 'offset'."
+ )
+ rectangles = _prismatic_cut(part, plane, name)
+ if not rectangles:
+ raise ValueError(
+ f"section: the plane {plane['p1']} -> {plane['p2']} does not cut {part.name!r}."
+ )
+ return tuple(
+ (((u0, v0), (u1, v0), (u1, v1), (u0, v1)), hatched)
+ for u0, u1, v0, v1, hatched in rectangles
+ )
+
+
+def section_view(
+ part, plane: dict, *, style: str = "full", scale: float = 1.0, side: str = "right"
+) -> View:
+ """A cut view: the cut faces as hatch areas, plus the geometry behind them."""
+ from engineering.mech.standards.materials import hatch_for
+
+ plane = normalise_plane(plane)
+ name = str(style or plane["style"]).strip().lower()
+ hatch_for(part.material) # refuse an unknown material before anything is built
+ ctx = {"side": side, "scale": float(scale), "projection": "first", "plane": plane}
+
+ loops = cut_loops(part, plane, style=name)
+ prims: list[Prim] = []
+ omitted: list[dict] = []
+ dims: list[DimIntent] = []
+
+ if isinstance(part, RevolvedPart) and _is_longitudinal(plane):
+ body, unplaced = section_profile_prims(part, half=name == "half")
+ prims.extend(body)
+ prims.extend(_axis_prims(part))
+ omitted.extend(unplaced)
+ for feature in part.features:
+ own = feature.cut_prims(part, plane, ctx)
+ if own and getattr(feature, "axisymmetric", False):
+ own = tuple(own) + mirror_about_x(own)
+ prims.extend(own)
+ dims.extend(feature.dims(part, "front", ctx))
+ elif isinstance(part, RevolvedPart):
+ role = "phantom" if name == "revolved" else "visible"
+ face = _transverse_face(part, plane["p1"][0])
+ prims.extend(prim._replace(role=role) for prim in face["prims"])
+ omitted.extend(face["omitted"])
+ over = face["r_out"] * (1.0 + AXIS_OVERRUN)
+ prims.append(Line((-over, 0.0), (over, 0.0), "center"))
+ prims.append(Line((0.0, -over), (0.0, over), "center"))
+ else:
+ for points, _hatched in loops:
+ xs = [p[0] for p in points]
+ ys = [p[1] for p in points]
+ prims.extend(_rectangle(min(xs), max(xs), min(ys), max(ys)))
+
+ hatched = [points for points, flag in loops if flag]
+ if isinstance(part, RevolvedPart) and not _is_longitudinal(plane):
+ # a transverse cut face is one region with the bore as its island
+ if hatched:
+ prims.append(HatchArea(tuple(hatched), part.material))
+ else:
+ for points in hatched:
+ prims.append(HatchArea((points,), part.material))
+
+ label = f"{plane['label']}-{plane['label']}"
+ return View(
+ kind="section",
+ prims=tuple(prims),
+ dims=tuple(dims),
+ omitted=tuple(omitted),
+ bbox=bbox(prims) if prims else (0.0, 0.0, 0.0, 0.0),
+ label=label,
+ scale=float(scale),
+ )
+
+
+# -- detail views ------------------------------------------------------------
+
+
+def _prim_bbox(prim) -> tuple[float, float, float, float]:
+ return bbox([prim])
+
+
+def _touches(prim, center: Pt, radius: float) -> bool:
+ x0, y0, x1, y1 = _prim_bbox(prim)
+ nearest_x = min(max(center[0], x0), x1)
+ nearest_y = min(max(center[1], y0), y1)
+ return math.dist((nearest_x, nearest_y), center) <= radius + 1e-9
+
+
+def _clip_segment(p1: Pt, p2: Pt, center: Pt, radius: float):
+ """The part of the segment inside the circle, or None."""
+ dx, dy = p2[0] - p1[0], p2[1] - p1[1]
+ fx, fy = p1[0] - center[0], p1[1] - center[1]
+ a = dx * dx + dy * dy
+ if a < 1e-18:
+ return (p1, p2) if math.dist(p1, center) <= radius + 1e-9 else None
+ b = 2.0 * (fx * dx + fy * dy)
+ c = fx * fx + fy * fy - radius * radius
+ discriminant = b * b - 4.0 * a * c
+ if discriminant <= 0.0:
+ return None
+ root = math.sqrt(discriminant)
+ t0 = max(0.0, (-b - root) / (2.0 * a))
+ t1 = min(1.0, (-b + root) / (2.0 * a))
+ if t1 - t0 <= 1e-9:
+ return None
+ return ((p1[0] + t0 * dx, p1[1] + t0 * dy), (p1[0] + t1 * dx, p1[1] + t1 * dy))
+
+
+def _clip_to_circle(prim, center: Pt, radius: float) -> tuple[Prim, ...]:
+ """Straight geometry is trimmed to the detail circle; curved geometry is kept
+ whole when it touches it.
+
+ Trimming an arc would mean solving its angular range against the circle, and
+ a detail that silently re-cuts an arc is worse than one that carries a
+ slightly long arc into the enlargement - so the rule is stated rather than
+ approximated, and a detail is meant to be drawn inside its circle.
+ """
+ if isinstance(prim, Line):
+ clipped = _clip_segment(prim.p1, prim.p2, center, radius)
+ return (Line(clipped[0], clipped[1], prim.role),) if clipped else ()
+ if isinstance(prim, Poly):
+ points = list(prim.points)
+ if prim.closed and points:
+ points.append(points[0])
+ out: list[Prim] = []
+ for index in range(len(points) - 1):
+ clipped = _clip_segment(points[index], points[index + 1], center, radius)
+ if clipped:
+ out.append(Line(clipped[0], clipped[1], prim.role))
+ return tuple(out)
+ return (prim,) if _touches(prim, center, radius) else ()
+
+
+def detail_marker_prims(detail: dict) -> tuple[Prim, ...]:
+ """ISO 128-34 detail circle and its letter, for the **parent** view."""
+ center = tuple(float(v) for v in detail["center"])
+ radius = float(detail["radius"])
+ label = str(detail.get("label", "A"))
+ return (
+ Circle(center, radius, "visible"),
+ Text((center[0] + radius + 2.0, center[1] + radius + 2.0), label, 5.0, 0.0, "text"),
+ )
+
+
+def detail_view(part, detail: dict, *, projection: str = "first", side: str = "right") -> View:
+ """A circular region of a parent view, enlarged, with the ISO 128-34 labels.
+
+ Straight geometry is trimmed to the circle and curved geometry that touches
+ it is kept whole (see :func:`_clip_to_circle`); every feature whose own
+ primitives all fall outside is reported in ``omitted``.
+ """
+ if not isinstance(detail, dict):
+ raise ValueError(f"detail: expected a dict, got {type(detail).__name__}.")
+ if "radius" not in detail:
+ raise ValueError("detail.radius: a detail needs the radius of its circle.")
+ center = tuple(float(v) for v in detail.get("center", (0.0, 0.0)))
+ radius = float(detail["radius"])
+ if radius <= _EPS:
+ raise ValueError(f"detail.radius: must be greater than zero, got {radius}.")
+ factor = float(detail.get("scale", 5.0))
+ if factor <= _EPS:
+ raise ValueError(f"detail.scale: must be greater than zero, got {factor}.")
+ label = str(detail.get("label", "A"))
+ of = str(detail.get("of", "front")).strip().lower()
+
+ parent = build_view(part, of, side=side, projection=projection)
+ kept: list[Prim] = []
+ for prim in parent.prims:
+ kept.extend(_clip_to_circle(prim, center, radius))
+ kept = tuple(kept)
+ if not kept:
+ raise ValueError(
+ f"detail: the circle at {center} r={radius} contains no geometry of the {of} view."
+ )
+
+ moved = translate(kept, -center[0], -center[1])
+ enlarged = scale_prims(moved, factor, (0.0, 0.0))
+
+ omitted = list(parent.omitted)
+ for feature in part.features:
+ own = feature.prims(part, of, {"side": side, "scale": 1.0, "projection": projection})
+ if own and not any(_touches(prim, center, radius) for prim in own):
+ omitted.append({"feature": feature.id, "reason": f"outside the detail circle {label}"})
+
+ return View(
+ kind="detail",
+ prims=tuple(enlarged),
+ dims=(),
+ omitted=tuple(omitted),
+ bbox=bbox(enlarged),
+ label=f"{label} ({factor:g}:1)",
+ scale=factor,
+ )
diff --git a/server.py b/server.py
index 1635d7e..0871291 100644
--- a/server.py
+++ b/server.py
@@ -690,6 +690,7 @@ async def _registered_tool_count() -> int | None:
# entity/drawing tags. Tools whose tags match none fall into "other".
_GROUP_TAG_PRIORITY = (
"pid",
+ "mech",
"style",
"pagesetup",
"environment",
@@ -718,6 +719,7 @@ _GROUP_TAG_PRIORITY = (
# Map the winning tag to a human-readable group label for the breakdown.
_GROUP_TAG_LABELS = {
"pid": "pid",
+ "mech": "mech",
"style": "styles",
"pagesetup": "page_setup",
"environment": "environment",
@@ -960,7 +962,7 @@ SOLID_TOOL_NAMES = frozenset(
# mechanically should not pay for the P&ID surface, and a client that never
# talks to a live seat should not pay for the environment surface. Styles and
# page setup (SECTION 18/19) are drafting essentials and stay in core.
-TOOL_PACK_NAMES = ("core", "pid", "settings")
+TOOL_PACK_NAMES = ("core", "pid", "settings", "mech")
PACK_TOOL_NAMES: dict[str, frozenset[str]] = {
"pid": frozenset(
{
@@ -1002,6 +1004,17 @@ PACK_TOOL_NAMES: dict[str, frozenset[str]] = {
"system_prompt_message",
}
),
+ # SECTION 21 - the mechanical part model and its view engine (tracks B+G).
+ "mech": frozenset(
+ {
+ "mech_part_draw",
+ "mech_view_add",
+ "mech_dimension_part",
+ "mech_hole_pattern",
+ "mech_part_from_spec",
+ "mech_part_inspect",
+ }
+ ),
}
_active_tool_profile: dict | None = None
@@ -8123,6 +8136,437 @@ async def drawing_properties_set(
return await _backend(ctx).drawing_properties_set(summary, custom)
+# ---------------------------------------------------------------------------
+# ── SECTION 21: Mechanical Parts (6 tools) ──────────────────────────────────
+# ---------------------------------------------------------------------------
+
+
+@cad_tool(
+ summary="Draw a machine part from a segment list or an outline plus typed features.",
+ cost="mutate",
+)
+@mcp.tool(
+ annotations={"title": "Mechanical: Draw Part", "destructiveHint": False},
+ tags={"mech", "create"},
+)
+async def mech_part_draw(
+ spec: Annotated[
+ dict,
+ Field(
+ description=(
+ "The part: {kind: 'revolved'|'prismatic', name, material, "
+ "segments:[{length, d_outer, d_inner, taper_to}] | outline:[[x,y]] + thickness, "
+ "features:[{kind, id, ...}]}"
+ )
+ ),
+ ],
+ at_x: Annotated[
+ float,
+ Field(
+ default=0.0,
+ description="WCS X of the first view's lower-left corner (the rotation pivot)",
+ ),
+ ] = 0.0,
+ at_y: Annotated[
+ float,
+ Field(
+ default=0.0,
+ description="WCS Y of the first view's lower-left corner (the rotation pivot)",
+ ),
+ ] = 0.0,
+ rotation: Annotated[
+ float,
+ Field(
+ default=0.0,
+ description="Degrees CCW; turns every view and its dimensions about (at_x, at_y)",
+ ),
+ ] = 0.0,
+ views: Annotated[
+ list[str] | None,
+ Field(
+ default=None,
+ description="Views to draw in order: front | side | top (default ['front'])",
+ ),
+ ] = None,
+ projection: Annotated[
+ str, Field(default="first", description="first (ISO 128-30, default) | third")
+ ] = "first",
+ dimension: Annotated[
+ bool, Field(default=True, description="Also dimension the part (ISO 129 + ISO 286 fits)")
+ ] = True,
+ style: Annotated[
+ str,
+ Field(default="chain", description="Axial dimension style: chain | baseline | ordinate"),
+ ] = "chain",
+ ctx: Context = None,
+) -> dict:
+ """One part model - a turned profile or a plate outline plus typed features - drawn as views.
+
+ The model is written onto the drawing as `ACADMCP_MECH` XDATA on an anchor
+ POINT, so `mech_view_add` can add a section months later without the caller
+ re-describing the part, and `mech_part_inspect` reads it back.
+
+ Refused **before anything is drawn**, by path: a non-finite or non-positive
+ number (`segments[2].d_outer`), a bore that is not smaller than its outside,
+ an outline with fewer than three distinct vertices or one that crosses
+ itself, an unknown material, a feature whose placement is off the part, two
+ features that would remove the same material (both names given), a thread
+ outside the transcribed ISO 261 table, a keyway outside DIN 6885's bore
+ range, gear teeth whose tip diameter disagrees with their segment. A DIN 509
+ undercut, a DIN 471/472 groove, a DIN 332 centre hole and an ISO 3601-2
+ O-ring groove need explicit dimensions in this build: those tables are not
+ transcribed and the lookup refuses by name rather than interpolate. A
+ feature that cannot be projected into a requested view is listed in
+ `omitted`, never dropped.
+ """
+ from engineering.mech.draw import draw_part
+ from engineering.mech.part import build_part
+
+ try:
+ part = build_part(spec)
+ except ValueError as exc:
+ raise ToolError(str(exc)) from exc
+ await ctx.info(f"mech_part_draw: {part.name} at ({at_x}, {at_y})")
+ try:
+ return await draw_part(
+ _backend(ctx),
+ part,
+ at=(at_x, at_y),
+ rotation=rotation,
+ views=tuple(views or ("front",)),
+ projection=projection,
+ dimension=dimension,
+ style=style,
+ )
+ except ValueError as exc:
+ raise ToolError(str(exc)) from exc
+
+
+@cad_tool(
+ summary="Add another view of a drawn part: side, top, section (full/half/offset) or detail.",
+ cost="mutate",
+)
+@mcp.tool(
+ annotations={"title": "Mechanical: Add View", "destructiveHint": False},
+ tags={"mech", "create"},
+)
+async def mech_view_add(
+ part_id: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description="Anchor handle from mech_part_draw; omit if the drawing holds one part",
+ ),
+ ] = None,
+ kind: Annotated[
+ str, Field(default="section", description="front | side | top | section | detail")
+ ] = "section",
+ side: Annotated[
+ str, Field(default="right", description="Which end an end view looks from: right | left")
+ ] = "right",
+ style: Annotated[
+ str,
+ Field(default="full", description="Section style: full | half | offset | revolved"),
+ ] = "full",
+ plane: Annotated[
+ dict | None,
+ Field(
+ default=None,
+ description="Cutting plane {p1:[x,y], p2:[x,y], label, direction:[dx,dy], via:[[x,y]]} - required for a section",
+ ),
+ ] = None,
+ detail: Annotated[
+ dict | None,
+ Field(
+ default=None,
+ description="Detail region {center:[x,y], radius, scale, label, of:'front'} - required for a detail",
+ ),
+ ] = None,
+ gap: Annotated[
+ float, Field(default=20.0, description="Gap between this view and its parent (mm)")
+ ] = 20.0,
+ scale: Annotated[
+ float,
+ Field(
+ default=1.0,
+ description=(
+ "Must be 1.0: views are drawn full size (a detail takes its scale in "
+ "detail.scale; a sheet scale is the viewport's)"
+ ),
+ ),
+ ] = 1.0,
+ ctx: Context = None,
+) -> dict:
+ """Another view of a part already on the drawing, laid out on its projection axis.
+
+ The part model comes back out of the drawing's `ACADMCP_MECH` XDATA, so the
+ caller never re-describes it, and the placement follows the projection angle
+ recorded when the part was drawn (first angle by default: the view from the
+ right goes to the left).
+
+ Refused by name: a section without a plane, or with a plane of zero length,
+ a plane that misses the part, or one that grazes an outline vertex so its
+ crossings cannot be paired; a half or offset section on the wrong kind of
+ plane (the message names the style that does apply); a detail without a
+ radius or with a scale of zero; an unknown part_id, and `part_id=None` on a
+ drawing that holds more than one part.
+ """
+ from engineering.mech.draw import add_view, read_part
+
+ backend = _backend(ctx)
+ try:
+ resolved = part_id or (await read_part(backend))["part_id"]
+ await ctx.info(f"mech_view_add: {kind} on {resolved}")
+ return await add_view(
+ backend,
+ resolved,
+ kind,
+ side=side,
+ style=style,
+ plane=plane,
+ detail=detail,
+ gap=gap,
+ scale=scale,
+ )
+ except ValueError as exc:
+ raise ToolError(str(exc)) from exc
+
+
+@cad_tool(
+ summary="Dimension a drawn part: ISO 129 dimensions, ISO 286 fits, a hole table.",
+ cost="mutate",
+)
+@mcp.tool(
+ annotations={"title": "Mechanical: Dimension Part", "destructiveHint": False},
+ tags={"mech", "dimension"},
+)
+async def mech_dimension_part(
+ part_id: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description="Anchor handle from mech_part_draw; omit if the drawing holds one part",
+ ),
+ ] = None,
+ style: Annotated[
+ str,
+ Field(default="chain", description="Axial dimension style: chain | baseline | ordinate"),
+ ] = "chain",
+ hole_table_threshold: Annotated[
+ int,
+ Field(default=8, description="Above this many holes the diameters move into a hole table"),
+ ] = 8,
+ fits: Annotated[
+ dict | None,
+ Field(
+ default=None,
+ description="ISO 286 fit per feature id, e.g. {'bearing_seat': 'k6', 'bore': 'H7'}",
+ ),
+ ] = None,
+ views: Annotated[
+ list[int] | None,
+ Field(
+ default=None,
+ description=(
+ "View indices to dimension, in mech_part_inspect order "
+ "(default: every view not yet dimensioned)"
+ ),
+ ),
+ ] = None,
+ ctx: Context = None,
+) -> dict:
+ """Every dimension the part and its features ask for, laid out without overlaps.
+
+ Axial lengths are chained, stacked from a baseline or measured from an
+ origin; diameters stack outside the silhouette; fillets get a radius. Each
+ measurement appears once (ISO 129-1): every view record carries a
+ `dimensioned` flag, so after a `mech_part_draw` that dimensioned the front
+ view and a `mech_view_add`, this call dimensions only the new view, and
+ `views` naming a view already dimensioned is refused by name. `fits` resolves an ISO 286 code
+ against the measured nominal through the repository's authored tables and
+ appends the code to the dimension text; an explicit `tol` on a feature's own
+ intent does the same through the ISO 129 path.
+
+ Refused by name: a fit together with an explicit tolerance, a fit outside
+ the authored ISO 286 range (over 1 mm to 500 mm, IT4-IT11), a fit on an
+ intent with no measurable nominal, an unknown style, an unknown part_id, a
+ call when every view is already dimensioned (the message lists them), and
+ `part_id=None` on a drawing that holds more than one part. An angular intent
+ is reported in `skipped` rather than drawn, because a DimIntent carries no
+ vertex.
+ """
+ from engineering.mech.draw import dimension_part, read_part
+
+ backend = _backend(ctx)
+ try:
+ resolved = part_id or (await read_part(backend))["part_id"]
+ await ctx.info(f"mech_dimension_part: {resolved} ({style})")
+ return await dimension_part(
+ backend,
+ resolved,
+ style=style,
+ hole_table_threshold=hole_table_threshold,
+ fits=fits,
+ views=views,
+ )
+ except ValueError as exc:
+ raise ToolError(str(exc)) from exc
+
+
+@cad_tool(
+ summary="Draw a linear, grid or polar hole pattern with centre marks on any drawing.",
+ cost="mutate",
+)
+@mcp.tool(
+ annotations={"title": "Mechanical: Hole Pattern", "destructiveHint": False},
+ tags={"mech", "create"},
+)
+async def mech_hole_pattern(
+ pattern: Annotated[str, Field(description="linear | grid | polar")],
+ x: Annotated[float, Field(description="WCS X of the first hole (polar: the circle centre)")],
+ y: Annotated[float, Field(description="WCS Y of the first hole (polar: the circle centre)")],
+ diameter: Annotated[float, Field(description="Hole diameter in drawing units")],
+ count: Annotated[
+ int, Field(default=2, description="Holes along the pattern (polar: around the circle)")
+ ] = 2,
+ spacing: Annotated[
+ float, Field(default=0.0, description="Pitch between holes (linear, grid columns)")
+ ] = 0.0,
+ count_y: Annotated[int, Field(default=1, description="Rows, for a grid")] = 1,
+ spacing_y: Annotated[float, Field(default=0.0, description="Row pitch, for a grid")] = 0.0,
+ pcd: Annotated[
+ float, Field(default=0.0, description="Pitch-circle diameter, for a polar pattern")
+ ] = 0.0,
+ start_angle: Annotated[
+ float, Field(default=0.0, description="Angle of the first hole (polar), degrees CCW")
+ ] = 0.0,
+ angle: Annotated[
+ float, Field(default=0.0, description="Direction of a linear pattern, degrees CCW")
+ ] = 0.0,
+ layer: Annotated[
+ str | None, Field(default=None, description="Override the GEOMETRY layer for the circles")
+ ] = None,
+ centre_marks: Annotated[
+ bool, Field(default=True, description="Draw ISO 128-23 centre marks")
+ ] = True,
+ dimension: Annotated[
+ bool, Field(default=False, description="Add a '6x ⌀8' diameter callout")
+ ] = False,
+ ctx: Context = None,
+) -> dict:
+ """A hole pattern on any drawing - the part model is not required.
+
+ The same expansion `HolePattern` performs inside a part, exposed for a
+ drawing that was not built from the model.
+
+ Refused before anything is drawn: an unknown pattern, a count below 1 (below
+ 2 for polar), a linear pattern with no spacing, a grid with more than one
+ column or row and no pitch for it, a polar pattern with no pitch-circle
+ diameter, and a non-finite or non-positive diameter.
+ """
+ from engineering.mech.draw import draw_hole_pattern
+
+ await ctx.info(f"mech_hole_pattern: {pattern} x{count}")
+ try:
+ return await draw_hole_pattern(
+ _backend(ctx),
+ pattern=pattern,
+ x=x,
+ y=y,
+ diameter=diameter,
+ count=count,
+ spacing=spacing,
+ count_y=count_y,
+ spacing_y=spacing_y,
+ pcd=pcd,
+ start_angle=start_angle,
+ angle=angle,
+ layer=layer,
+ centre_marks=centre_marks,
+ dimension=dimension,
+ )
+ except ValueError as exc:
+ raise ToolError(str(exc)) from exc
+
+
+@cad_tool(
+ summary="Draw a whole mechanical sheet - several parts and their views - in one transaction.",
+ cost="mutate",
+)
+@mcp.tool(
+ annotations={"title": "Mechanical: Sheet From Spec", "destructiveHint": False},
+ tags={"mech", "create"},
+)
+async def mech_part_from_spec(
+ spec: Annotated[
+ dict,
+ Field(
+ description=(
+ "{sheet: {intent, sheet_size, scale, layer_set}, "
+ "parts: [{part: {...}, at: [x, y], rotation, views: ['front','side'], "
+ "projection, dimension, style}]}"
+ )
+ ),
+ ],
+ ctx: Context = None,
+) -> dict:
+ """Several parts, their views and their dimensions, drawn inside one transaction.
+
+ Every part is validated before the transaction opens, so a refusal leaves
+ the drawing untouched and names the offending path (`parts[1]:
+ segments[0].d_outer: ...`). If a draw fails or the request is cancelled
+ part-way, the transaction is rolled back under an anyio shield, so a
+ cancelled call cannot leave half a sheet inside an open transaction.
+
+ Refused by name: an empty `parts` list, any part refusal from
+ `mech_part_draw`, and a transaction that cannot be opened.
+ """
+ from engineering.mech.draw import draw_from_spec
+
+ await ctx.info(f"mech_part_from_spec: {len((spec or {}).get('parts') or [])} part(s)")
+ try:
+ return await draw_from_spec(_backend(ctx), spec)
+ except ValueError as exc:
+ raise ToolError(str(exc)) from exc
+
+
+@cad_tool(
+ summary="Read the part model back off the drawing: segments, features, views, omissions.",
+ cost="read",
+)
+@mcp.tool(
+ annotations={"title": "Mechanical: Inspect Part", "readOnlyHint": True},
+ tags={"mech", "query"},
+)
+async def mech_part_inspect(
+ part_id: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description="Anchor handle from mech_part_draw; omit if the drawing holds one part",
+ ),
+ ] = None,
+ ctx: Context = None,
+) -> dict:
+ """The `ACADMCP_MECH` payload a drawn part carries, decoded.
+
+ Never modifies the drawing. Returns the part model exactly as
+ `mech_part_draw` accepts it (`part`), the views drawn so far with their
+ placements, and the payload version.
+
+ Refused by name: an unknown part_id, a handle that carries no
+ `ACADMCP_MECH` payload, a corrupt or foreign payload, and `part_id=None` on
+ a drawing that holds no part or more than one (the message lists the
+ handles).
+ """
+ from engineering.mech.draw import read_part
+
+ try:
+ return await read_part(_backend(ctx), part_id)
+ except ValueError as exc:
+ raise ToolError(str(exc)) from exc
+
+
# ---------------------------------------------------------------------------
# ── RESOURCES ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
diff --git a/tests/test_mech_dimension.py b/tests/test_mech_dimension.py
new file mode 100644
index 0000000..23ee80d
--- /dev/null
+++ b/tests/test_mech_dimension.py
@@ -0,0 +1,514 @@
+"""Dimension intents, ISO 286 fits, the hole table and the overlap-free layout.
+
+The layout test is the one that matters: it asserts the placed text rectangles
+are pairwise disjoint, computed by the same helper the layout uses, so "no
+overlapping dimension text" is a measured property rather than a claim.
+"""
+
+from __future__ import annotations
+
+import dataclasses
+import itertools
+
+import pytest
+
+from engineering.mech.dimension import (
+ DIM_STYLES,
+ dimension_intents,
+ hole_table_rows,
+ intent_text,
+ is_reference,
+ layout_dimensions,
+ measured_value,
+ redundant_linear,
+ resolve_tolerance,
+ segment_key,
+ text_rects,
+)
+from engineering.mech.features import Hole, Keyway
+from engineering.mech.part import PrismaticPart, RevolvedPart, Segment
+from engineering.mech.primitives import DimIntent
+from engineering.mech.views import build_view
+from engineering.tolerances import build_dim_override
+
+
+def shaft(*features) -> RevolvedPart:
+ return RevolvedPart(
+ name="shaft",
+ segments=(Segment(20.0, 25.0), Segment(40.0, 30.0), Segment(15.0, 20.0)),
+ features=tuple(features),
+ )
+
+
+def plate(*holes) -> PrismaticPart:
+ return PrismaticPart(
+ name="plate",
+ outline=((0.0, 0.0), (80.0, 0.0), (80.0, 50.0), (0.0, 50.0)),
+ thickness=8.0,
+ features=tuple(holes),
+ )
+
+
+def grid(count: int, diameter: float = 6.0):
+ return tuple(Hole(id=f"h{i}", x=5.0 + 7.0 * i, y=25.0, diameter=diameter) for i in range(count))
+
+
+def test_the_three_styles_are_the_ones_the_spec_names():
+ assert DIM_STYLES == ("chain", "baseline", "ordinate")
+
+
+def test_measured_value_and_text_follow_the_intent():
+ linear = DimIntent(kind="linear", p1=(0.0, 0.0), p2=(20.0, 0.0))
+ assert measured_value(linear) == 20.0
+ assert intent_text(linear) == "20"
+ override = DimIntent(kind="diameter", p1=(-20.0, 0.0), p2=(20.0, 0.0), text_override="M20")
+ assert measured_value(override) == 40.0
+ assert intent_text(override) == "M20"
+
+
+# -- ISO 286 fits and ISO 129 tolerances -------------------------------------
+
+
+def test_a_h7_fit_on_a_40_mm_bore_resolves_to_plus_25_microns_over_zero():
+ intent = DimIntent(kind="diameter", p1=(-20.0, 0.0), p2=(20.0, 0.0), fit="H7")
+ resolved = resolve_tolerance(intent)
+ assert resolved["tol_upper"] == pytest.approx(0.025)
+ assert resolved["tol_lower"] == pytest.approx(0.0)
+ assert resolved["tol_mode"] == "deviation"
+ assert resolved["text_override"] == "<> H7"
+
+
+def test_a_shaft_fit_keeps_the_build_dim_override_sign_convention():
+ intent = DimIntent(kind="diameter", p1=(-10.0, 0.0), p2=(10.0, 0.0), fit="g6")
+ resolved = resolve_tolerance(intent)
+ # ISO 286 g6 at 20 mm: es = -0.007, ei = -0.020; build_dim_override wants the
+ # lower deviation as a positive magnitude of the minus side.
+ assert resolved["tol_upper"] == pytest.approx(-0.007)
+ assert resolved["tol_lower"] == pytest.approx(0.020)
+
+
+def test_an_explicit_tolerance_passes_through_and_is_validated():
+ intent = DimIntent(
+ kind="linear",
+ p1=(0.0, 0.0),
+ p2=(30.0, 0.0),
+ tol={"mode": "symmetric", "upper": 0.1},
+ )
+ resolved = resolve_tolerance(intent)
+ assert resolved["tol_mode"] == "symmetric"
+ assert resolved["tol_upper"] == pytest.approx(0.1)
+ bad = DimIntent(kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol={"mode": "loose"})
+ with pytest.raises(ValueError, match="tol_mode"):
+ resolve_tolerance(bad)
+
+
+def test_a_fit_and_an_explicit_tolerance_together_are_refused():
+ intent = DimIntent(
+ kind="diameter",
+ p1=(-20.0, 0.0),
+ p2=(20.0, 0.0),
+ fit="H7",
+ tol={"mode": "symmetric", "upper": 0.1},
+ )
+ with pytest.raises(ValueError, match="not both"):
+ resolve_tolerance(intent)
+
+
+def test_a_fit_outside_the_iso_286_tables_is_refused_by_name():
+ intent = DimIntent(kind="diameter", p1=(-400.0, 0.0), p2=(400.0, 0.0), fit="H7")
+ with pytest.raises(ValueError, match="ISO 286"):
+ resolve_tolerance(intent)
+
+
+def test_a_fit_with_no_measurable_nominal_is_refused_rather_than_guessed():
+ intent = DimIntent(kind="diameter", p1=(5.0, 0.0), p2=(5.0, 0.0), fit="H7")
+ with pytest.raises(ValueError, match="nominal"):
+ resolve_tolerance(intent)
+
+
+@pytest.mark.parametrize(
+ "tol",
+ [
+ {"mode": "symmetric", "upper": 0.05},
+ {"mode": "deviation", "upper": 0.05, "lower": 0.02},
+ {"mode": "limit", "upper": 30.05, "lower": 29.98},
+ {"upper": 0.05, "lower": 0.05}, # the shape whose tolerance used to vanish
+ {"mode": "none", "upper": 0.05},
+ {},
+ ],
+)
+def test_a_tolerance_is_either_refused_or_reaches_the_dimension_variables(tol):
+ """Accepted and then written as nothing is the one answer that is not allowed.
+
+ `build_dim_override` defaults a missing mode to "none" and answers with an
+ empty override, so a tol with no mode used to resolve, echo its own numbers
+ back and draw a dimension carrying no DIMTOL/DIMTP/DIMTM at all.
+ """
+ intent = DimIntent(kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol=tol)
+ try:
+ resolved = resolve_tolerance(intent)
+ except ValueError:
+ return # refused up front, which is the other legal answer
+ override, _ = build_dim_override(
+ resolved["tol_upper"],
+ resolved["tol_lower"],
+ resolved["tol_mode"],
+ resolved["text_override"],
+ )
+ assert override, f"{tol} resolved to {resolved} and wrote no dimension variable"
+
+
+def test_a_tolerance_with_no_mode_is_refused_rather_than_silently_discarded():
+ intent = DimIntent(
+ kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol={"upper": 0.05, "lower": 0.05}
+ )
+ with pytest.raises(ValueError, match="needs an explicit 'mode'"):
+ resolve_tolerance(intent)
+
+
+def test_an_empty_tol_is_refused_rather_than_read_as_no_tolerance():
+ intent = DimIntent(kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol={})
+ with pytest.raises(ValueError, match="mode"):
+ resolve_tolerance(intent)
+
+
+def test_a_tol_key_outside_the_schema_is_refused_by_name():
+ intent = DimIntent(
+ kind="linear",
+ p1=(0.0, 0.0),
+ p2=(30.0, 0.0),
+ tol={"mode": "symmetric", "upper": 0.05, "plus": 0.05},
+ )
+ with pytest.raises(ValueError, match="plus"):
+ resolve_tolerance(intent)
+
+
+def test_deviations_under_a_mode_that_writes_none_are_refused():
+ intent = DimIntent(
+ kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol={"mode": "none", "upper": 0.05}
+ )
+ with pytest.raises(ValueError, match="writes no deviation"):
+ resolve_tolerance(intent)
+
+
+def test_a_tol_that_is_not_a_dict_is_refused():
+ intent = DimIntent(kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol=0.05)
+ with pytest.raises(ValueError, match="expected a dict"):
+ resolve_tolerance(intent)
+
+
+# -- the ISO 286 route from the part to the dimension ------------------------
+
+
+def bush() -> RevolvedPart:
+ """A 60 mm bore housing: one segment, ⌀60 outside, ⌀40 bore."""
+ return RevolvedPart(name="bush", segments=(Segment(60.0, 60.0, 40.0),))
+
+
+def test_a_fit_reaches_a_dimension_through_the_modules_own_pipeline():
+ """`mech_iso286_on_dimension`, end to end: a ⌀40 H7 bore carries +0.025 / 0.
+
+ `resolve_tolerance` always resolved a fit; nothing produced an intent that
+ carried one, so the headline claim was reachable only from a hand-built
+ DimIntent - which is what the tests used.
+ """
+ part = bush()
+ view = build_view(part, "front")
+ intents = dimension_intents(part, view, fits={segment_key(0, bore=True): "H7"})
+ fitted = [i for i in intents if i.fit]
+ assert len(fitted) == 1
+ assert measured_value(fitted[0]) == pytest.approx(40.0)
+ resolved = resolve_tolerance(fitted[0])
+ assert resolved["tol_upper"] == pytest.approx(0.025)
+ assert resolved["tol_lower"] == pytest.approx(0.0)
+ assert resolved["tol_mode"] == "deviation"
+ assert resolved["text_override"] == "<> H7"
+ # the fit has to survive the layout, or the number is right in the table
+ # and absent from the sheet
+ placed = layout_dimensions(view, intents)
+ assert [i.fit for i in placed if i.fit] == ["H7"]
+
+
+def test_a_bearing_seat_takes_its_fit_by_segment():
+ part = shaft()
+ view = build_view(part, "front")
+ intents = dimension_intents(part, view, fits={segment_key(1): "k6"})
+ seat = [i for i in intents if i.fit]
+ assert [i.feature for i in seat] == ["segment[1]"]
+ assert measured_value(seat[0]) == pytest.approx(30.0)
+ assert intent_text(seat[0]) == "⌀30 k6"
+
+
+def test_a_fit_on_a_feature_id_lands_on_that_features_dimension():
+ part = plate(*grid(3))
+ view = build_view(part, "front")
+ intents = dimension_intents(part, view, fits={"h1": "H7"})
+ assert [i.feature for i in intents if i.fit] == ["h1"]
+ # a hole already names itself; the code is appended, never swallowed
+ hole = next(i for i in intents if i.fit)
+ assert intent_text(hole) == "⌀6 H7"
+ assert resolve_tolerance(hole)["text_override"] == "⌀6 H7"
+
+
+def test_a_fit_widens_the_measured_label_so_the_layout_reserves_room():
+ part = shaft()
+ view = build_view(part, "front")
+ plain = next(i for i in dimension_intents(part, view) if i.feature == segment_key(1))
+ fitted = next(
+ i
+ for i in dimension_intents(part, view, fits={segment_key(1): "k6"})
+ if i.feature == segment_key(1)
+ )
+
+ def width(intent):
+ rect = text_rects(layout_dimensions(view, [intent]))[0]
+ return rect[2] - rect[0]
+
+ assert width(fitted) > width(plain)
+
+
+def test_a_fit_key_that_names_nothing_is_refused_with_the_names_that_exist():
+ part = shaft()
+ view = build_view(part, "front")
+ with pytest.raises(ValueError, match=r"segment\[0\]"):
+ dimension_intents(part, view, fits={"bearing_seat": "k6"})
+
+
+def test_a_malformed_fit_code_is_refused_where_it_is_attached():
+ part = shaft()
+ view = build_view(part, "front")
+ with pytest.raises(ValueError, match="fit code"):
+ dimension_intents(part, view, fits={segment_key(1): "H"})
+
+
+def test_a_fit_on_a_reference_dimension_is_refused():
+ part = shaft()
+ view = build_view(part, "front")
+ with pytest.raises(ValueError, match="reference"):
+ dimension_intents(part, view, fits={"part": "H7"})
+
+
+def test_a_fit_on_a_hole_that_moved_into_the_table_is_refused_by_name():
+ part = plate(*grid(10))
+ view = build_view(part, "front")
+ with pytest.raises(ValueError, match="hole table"):
+ dimension_intents(part, view, fits={"h1": "H7"}, hole_table_threshold=8)
+
+
+# -- collecting the intents --------------------------------------------------
+
+
+def test_the_front_view_of_a_shaft_gets_its_overall_length_and_each_diameter_once():
+ view = build_view(shaft(), "front")
+ intents = dimension_intents(shaft(), view)
+ lengths = [i for i in intents if i.kind == "linear"]
+ diameters = [i for i in intents if i.kind == "diameter"]
+ assert any(measured_value(i) == 75.0 for i in lengths)
+ assert sorted({round(measured_value(i), 6) for i in diameters}) == [20.0, 25.0, 30.0]
+
+
+def test_chain_and_baseline_place_the_same_stations_differently():
+ part = shaft()
+ view = build_view(part, "front")
+ chain = [i for i in dimension_intents(part, view, style="chain") if i.kind == "linear"]
+ baseline = [i for i in dimension_intents(part, view, style="baseline") if i.kind == "linear"]
+ assert {round(measured_value(i), 6) for i in chain} >= {20.0, 40.0, 15.0}
+ assert {round(measured_value(i), 6) for i in baseline} >= {20.0, 60.0, 75.0}
+
+
+def test_ordinate_measures_every_station_from_the_origin():
+ part = shaft()
+ view = build_view(part, "front")
+ intents = [i for i in dimension_intents(part, view, style="ordinate") if i.kind == "linear"]
+ assert all(i.p1[0] == 0.0 for i in intents)
+
+
+def test_a_feature_intent_survives_collection_and_keeps_its_feature_id():
+ part = shaft(Keyway(id="kw", segment=1, length=20.0))
+ view = build_view(part, "front")
+ intents = dimension_intents(part, view)
+ assert any(i.feature == "kw" for i in intents)
+
+
+def test_a_duplicate_intent_is_collapsed_rather_than_drawn_twice():
+ """`_dedupe` against an intent the collector did not generate itself.
+
+ Asserting uniqueness over what `dimension_intents` returns proves nothing -
+ it is the key `_dedupe` uniquifies. Feeding the duplicate in through the
+ view's own dims is what exercises the gate.
+ """
+ part = shaft()
+ view = build_view(part, "front")
+ once = dimension_intents(part, view)
+ repeated = next(i for i in once if i.kind == "diameter")
+ doubled = dataclasses.replace(view, dims=view.dims + (repeated._replace(text_at=None),))
+ assert len(dimension_intents(part, doubled)) == len(once)
+
+
+def test_iso129_a_closed_chain_marks_the_overall_as_a_reference_dimension():
+ """20 + 40 + 15 and 75 state the same distance twice; ISO 129-1 parenthesises one.
+
+ The plan's own test asserts the 75 is present, which a bare fourth plain
+ DIMENSION satisfies. What matters is that it is drawn as an auxiliary
+ (reference) dimension.
+ """
+ part = shaft()
+ view = build_view(part, "front")
+ linear = [i for i in dimension_intents(part, view, style="chain") if i.kind == "linear"]
+ assert sorted(round(measured_value(i), 6) for i in linear) == [15.0, 20.0, 40.0, 75.0]
+ overall = [i for i in linear if measured_value(i) == 75.0]
+ assert len(overall) == 1
+ assert is_reference(overall[0])
+ assert intent_text(overall[0]) == "(75)"
+ assert [i for i in linear if is_reference(i)] == overall
+
+
+def test_iso129_no_plain_dimension_restates_a_measurement_the_others_give():
+ """The real property, on every style and both part kinds."""
+ parts = (shaft(Keyway(id="kw", segment=1, length=20.0)), plate(*grid(4)))
+ for style in DIM_STYLES:
+ for part in parts:
+ view = build_view(part, "front")
+ intents = dimension_intents(part, view, style=style)
+ plain = [i for i in intents if not is_reference(i)]
+ assert redundant_linear(plain) == (), f"{part.name} / {style}"
+
+
+def test_the_redundancy_check_fires_instead_of_restating_its_own_post_condition():
+ """A gate that cannot fail is not a gate: hand it the un-marked chain."""
+ chain = [
+ DimIntent(kind="linear", p1=(0.0, 0.0), p2=(20.0, 0.0)),
+ DimIntent(kind="linear", p1=(20.0, 0.0), p2=(60.0, 0.0)),
+ DimIntent(kind="linear", p1=(60.0, 0.0), p2=(75.0, 0.0)),
+ DimIntent(kind="linear", p1=(0.0, 0.0), p2=(75.0, 0.0)),
+ ]
+ assert redundant_linear(chain) == (3,)
+ baseline = [
+ DimIntent(kind="linear", p1=(0.0, 0.0), p2=(20.0, 0.0)),
+ DimIntent(kind="linear", p1=(0.0, 0.0), p2=(60.0, 0.0)),
+ DimIntent(kind="linear", p1=(0.0, 0.0), p2=(75.0, 0.0)),
+ ]
+ assert redundant_linear(baseline) == ()
+ # two horizontal chains at different heights measure different things
+ stacked = [
+ DimIntent(kind="linear", p1=(0.0, 0.0), p2=(20.0, 0.0)),
+ DimIntent(kind="linear", p1=(20.0, 5.0), p2=(75.0, 5.0)),
+ DimIntent(kind="linear", p1=(0.0, 0.0), p2=(75.0, 0.0)),
+ ]
+ assert redundant_linear(stacked) == ()
+
+
+def test_baseline_states_every_station_once_so_nothing_is_parenthesised():
+ part = shaft()
+ view = build_view(part, "front")
+ intents = dimension_intents(part, view, style="baseline")
+ assert not any(is_reference(i) for i in intents)
+
+
+def test_a_prismatic_front_view_gets_its_two_overall_sizes():
+ view = build_view(plate(), "front")
+ intents = dimension_intents(plate(), view)
+ assert sorted({round(measured_value(i), 6) for i in intents if i.kind == "linear"}) == [
+ 50.0,
+ 80.0,
+ ]
+
+
+# -- the hole table ----------------------------------------------------------
+
+
+def test_under_the_threshold_the_holes_keep_their_own_diameters_and_there_is_no_table():
+ part = plate(*grid(4))
+ view = build_view(part, "front")
+ intents = dimension_intents(part, view, hole_table_threshold=8)
+ assert sum(1 for i in intents if i.kind == "diameter") == 4
+ assert hole_table_rows(part, view) == ()
+
+
+def test_over_the_threshold_the_hole_diameters_move_into_a_table():
+ part = plate(*grid(10))
+ view = build_view(part, "front")
+ intents = dimension_intents(part, view, hole_table_threshold=8)
+ assert not any(i.kind == "diameter" for i in intents)
+ rows = hole_table_rows(part, view)
+ assert len(rows) == 10
+ assert {row["tag"] for row in rows} == {"A"}
+ assert rows[0]["qty"] == 10
+ assert rows[0]["d"] == 6.0
+ assert set(rows[0]) == {"tag", "x", "y", "d", "qty", "note"}
+
+
+def test_two_hole_sizes_get_two_tags():
+ part = plate(
+ *grid(6), *(Hole(id=f"b{i}", x=5.0 + 9.0 * i, y=40.0, diameter=10.0) for i in range(4))
+ )
+ view = build_view(part, "front")
+ rows = hole_table_rows(part, view)
+ assert {row["tag"] for row in rows} == {"A", "B"}
+ by_tag = {row["tag"]: row["qty"] for row in rows}
+ assert by_tag == {"A": 6, "B": 4}
+
+
+def test_a_tapped_hole_carries_its_designation_in_the_note():
+ part = plate(
+ *(Hole(id=f"t{i}", x=5.0 + 9.0 * i, y=25.0, diameter=10.0, thread="M10") for i in range(9))
+ )
+ view = build_view(part, "front")
+ rows = hole_table_rows(part, view)
+ assert rows[0]["note"] == "M10"
+
+
+# -- the layout, and its overlap proof ---------------------------------------
+
+
+def test_the_layout_places_every_intent_and_the_text_rectangles_are_disjoint():
+ part = shaft(Keyway(id="kw", segment=1, length=20.0))
+ view = build_view(part, "front")
+ intents = dimension_intents(part, view)
+ placed = layout_dimensions(view, intents)
+ assert len(placed) == len(intents)
+ assert all(intent.text_at is not None for intent in placed)
+ rects = text_rects(placed)
+ for a, b in itertools.combinations(rects, 2):
+ overlap_x = min(a[2], b[2]) - max(a[0], b[0])
+ overlap_y = min(a[3], b[3]) - max(a[1], b[1])
+ assert overlap_x <= 1e-9 or overlap_y <= 1e-9, f"{a} overlaps {b}"
+
+
+def test_a_crowded_prismatic_view_still_lays_out_disjointly():
+ part = plate(*grid(6))
+ view = build_view(part, "front")
+ placed = layout_dimensions(view, dimension_intents(part, view, hole_table_threshold=100))
+ rects = text_rects(placed)
+ for a, b in itertools.combinations(rects, 2):
+ overlap_x = min(a[2], b[2]) - max(a[0], b[0])
+ overlap_y = min(a[3], b[3]) - max(a[1], b[1])
+ assert overlap_x <= 1e-9 or overlap_y <= 1e-9, f"{a} overlaps {b}"
+
+
+def test_horizontal_dimensions_stack_below_the_view_and_vertical_ones_to_its_left():
+ part = shaft()
+ view = build_view(part, "front")
+ placed = layout_dimensions(view, dimension_intents(part, view), offset=10.0, step=8.0)
+ horizontal = [i for i in placed if i.kind == "linear" and abs(i.p1[1] - i.p2[1]) < 1e-9]
+ assert horizontal and all(i.text_at[1] <= view.bbox[1] - 10.0 + 1e-9 for i in horizontal)
+ diameters = [i for i in placed if i.kind == "diameter"]
+ assert diameters and all(i.text_at[0] >= view.bbox[2] + 10.0 - 1e-9 for i in diameters)
+
+
+def test_the_step_is_never_smaller_than_the_text_it_has_to_clear():
+ part = shaft()
+ view = build_view(part, "front")
+ placed = layout_dimensions(view, dimension_intents(part, view), offset=10.0, step=1.0)
+ rects = text_rects(placed)
+ for a, b in itertools.combinations(rects, 2):
+ overlap_x = min(a[2], b[2]) - max(a[0], b[0])
+ overlap_y = min(a[3], b[3]) - max(a[1], b[1])
+ assert overlap_x <= 1e-9 or overlap_y <= 1e-9
+
+
+def test_an_unknown_style_is_refused():
+ part = shaft()
+ view = build_view(part, "front")
+ with pytest.raises(ValueError, match="chain"):
+ dimension_intents(part, view, style="freehand")
diff --git a/tests/test_mech_draw.py b/tests/test_mech_draw.py
new file mode 100644
index 0000000..800290b
--- /dev/null
+++ b/tests/test_mech_draw.py
@@ -0,0 +1,494 @@
+"""The async layer: primitives to entities, the part payload, and the sheet.
+
+Headless ezdxf against a real document - the `backend` fixture in
+tests/conftest.py. The COM paths are the same code (draw.py composes the
+generic contract members), which is the whole point of the pattern
+`gear_draw_*` and `titleblock_apply_iso_a3` already use.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from engineering.mech.draw import (
+ ANCHOR_LAYER,
+ add_view,
+ dimension_part,
+ draw_from_spec,
+ draw_hole_pattern,
+ draw_part,
+ draw_prims,
+ read_part,
+)
+from engineering.mech.part import build_part
+from engineering.mech.primitives import Arc, Circle, HatchArea, Line, Poly, Text
+
+pytestmark = pytest.mark.asyncio
+
+SHAFT = {
+ "kind": "revolved",
+ "name": "shaft",
+ "material": "steel",
+ "segments": [
+ {"length": 20.0, "d_outer": 25.0},
+ {"length": 40.0, "d_outer": 30.0},
+ {"length": 15.0, "d_outer": 20.0},
+ ],
+ "features": [{"kind": "chamfer", "id": "c1", "at": "start", "size": 2.0}],
+}
+
+PLATE = {
+ "kind": "prismatic",
+ "name": "plate",
+ "material": "aluminium",
+ "thickness": 8.0,
+ "outline": [[0.0, 0.0], [80.0, 0.0], [80.0, 50.0], [0.0, 50.0]],
+ "features": [{"kind": "hole", "id": "h1", "x": 40.0, "y": 25.0, "diameter": 10.0}],
+}
+
+
+async def entities(backend, type_filter=None, layer=None):
+ return await backend.entity_list(type_filter=type_filter, layer_filter=layer, limit=2000)
+
+
+async def test_draw_prims_puts_every_role_on_its_own_layer(backend):
+ result = await draw_prims(
+ backend,
+ [
+ Line((0.0, 0.0), (10.0, 0.0), "visible"),
+ Line((0.0, 5.0), (10.0, 5.0), "hidden"),
+ Line((-2.0, 2.5), (12.0, 2.5), "center"),
+ Circle((5.0, 2.5), 1.0, "visible"),
+ Arc((5.0, 2.5), 2.0, 0.0, 90.0, "visible"),
+ Poly(((0.0, 0.0), (10.0, 0.0), (10.0, 5.0)), True, "visible"),
+ Text((5.0, 8.0), "A", 3.5, 0.0, "text"),
+ HatchArea((((0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (0.0, 5.0)),), "steel"),
+ ],
+ )
+ assert len(result["handles"]) == 8
+ layers = {e.layer for e in await entities(backend)}
+ assert {"GEOMETRY", "HIDDEN", "CENTER", "TEXT", "HATCH"} <= layers
+
+
+async def test_draw_prims_places_and_rotates_into_wcs(backend):
+ await draw_prims(backend, [Line((0.0, 0.0), (10.0, 0.0), "visible")], at=(100.0, 50.0))
+ (line,) = await entities(backend, type_filter="LINE")
+ start = line.properties["start"]
+ assert (round(start[0], 6), round(start[1], 6)) == (100.0, 50.0)
+
+
+async def test_draw_part_writes_the_model_back_onto_the_drawing(backend):
+ part = build_part(SHAFT)
+ result = await draw_part(backend, part, at=(50.0, 100.0))
+ assert result["part_id"]
+ assert result["views"][0]["kind"] == "front"
+ assert result["handles"]
+
+ anchors = await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER)
+ assert len(anchors) == 1
+ assert anchors[0].handle == result["part_id"]
+
+ read = await read_part(backend, result["part_id"])
+ assert read["part"]["name"] == "shaft"
+ assert [f["id"] for f in read["part"]["features"]] == ["c1"]
+ assert build_part(read["part"]) == part
+
+
+async def test_read_part_without_an_id_finds_the_only_part(backend):
+ part = build_part(SHAFT)
+ drawn = await draw_part(backend, part)
+ read = await read_part(backend)
+ assert read["part_id"] == drawn["part_id"]
+
+
+async def test_read_part_refuses_when_there_is_more_than_one_and_no_id(backend):
+ await draw_part(backend, build_part(SHAFT), at=(0.0, 0.0))
+ await draw_part(backend, build_part(PLATE), at=(200.0, 0.0))
+ with pytest.raises(ValueError) as excinfo:
+ await read_part(backend)
+ assert "part_id" in str(excinfo.value)
+
+
+async def test_two_views_are_placed_in_first_angle_positions(backend):
+ part = build_part(SHAFT)
+ result = await draw_part(backend, part, at=(0.0, 0.0), views=("front", "side"))
+ front, end = result["views"]
+ assert end["kind"] == "side"
+ # first angle: the view from the right sits to the LEFT of the front view
+ assert end["bbox"][2] <= front["bbox"][0] + 1e-6
+
+
+async def test_third_angle_puts_the_end_view_on_the_other_side(backend):
+ part = build_part(SHAFT)
+ result = await draw_part(
+ backend, part, at=(0.0, 0.0), views=("front", "side"), projection="third"
+ )
+ front, end = result["views"]
+ assert end["bbox"][0] >= front["bbox"][2] - 1e-6
+
+
+async def test_add_view_reads_the_model_back_and_appends_a_section(backend):
+ part = build_part(SHAFT)
+ drawn = await draw_part(backend, part, at=(0.0, 0.0))
+ added = await add_view(
+ backend,
+ drawn["part_id"],
+ "section",
+ plane={"p1": [0.0, 0.0], "p2": [100.0, 0.0], "label": "A"},
+ )
+ assert added["view"]["kind"] == "section"
+ assert added["view"]["label"] == "A-A"
+ assert len(await entities(backend, type_filter="HATCH")) == 2
+ read = await read_part(backend, drawn["part_id"])
+ assert [v["kind"] for v in read["views"]] == ["front", "section"]
+
+
+async def test_a_detail_view_also_marks_its_circle_on_the_parent(backend):
+ part = build_part(SHAFT)
+ drawn = await draw_part(backend, part, at=(0.0, 0.0))
+ added = await add_view(
+ backend,
+ drawn["part_id"],
+ "detail",
+ detail={"center": [20.0, 12.5], "radius": 8.0, "scale": 5.0, "label": "D"},
+ )
+ assert added["view"]["label"] == "D (5:1)"
+ texts = [e for e in await entities(backend, type_filter="TEXT")]
+ assert any(e.properties.get("text") == "D" for e in texts)
+
+
+async def test_dimension_part_creates_real_dimension_entities(backend):
+ part = build_part(SHAFT)
+ drawn = await draw_part(backend, part, at=(0.0, 0.0), dimension=False)
+ assert not await entities(backend, type_filter="DIMENSION")
+ result = await dimension_part(backend, drawn["part_id"])
+ assert result["count"] > 0
+ dims = await entities(backend, type_filter="DIMENSION")
+ assert len(dims) == result["count"]
+ assert {e.layer for e in dims} == {"DIM"}
+
+
+async def test_an_iso_286_fit_reaches_the_dimension_as_a_real_tolerance(backend):
+ """`fits` names a dimension, and 0.025 is H7 on the 40 mm bore.
+
+ The key is the one `engineering/mech/dimension.segment_key` mints, not the
+ part's name: `dimension_intents` addresses the part's own diameters one at
+ a time (`segment[0]`, `segment[0].bore`) precisely so a fit lands on the
+ bore rather than on every dimension the part emits. Measured: H7 is
+ +0.025/0 at 40 mm, +0.030/0 at 60 mm and +0.021/0 at the 30 mm length.
+ """
+ spec = {
+ "kind": "revolved",
+ "name": "bore",
+ "segments": [{"length": 30.0, "d_outer": 60.0, "d_inner": 40.0}],
+ "features": [],
+ }
+ part = build_part(spec)
+ drawn = await draw_part(backend, part, at=(0.0, 0.0), dimension=False)
+ result = await dimension_part(backend, drawn["part_id"], fits={"segment[0].bore": "H7"})
+ assert result["count"] > 0
+ assert any(
+ row.get("fit") == "H7"
+ and row["tol_upper"] == pytest.approx(0.025)
+ and row["tol_lower"] == pytest.approx(0.0)
+ and row["tol_mode"] == "deviation"
+ and "H7" in row["text_override"]
+ for row in result["resolved"]
+ )
+ # and the outside diameter of the same segment is untouched
+ assert all(
+ row["tol_mode"] == "none"
+ for row in result["resolved"]
+ if row["feature"] != "segment[0].bore"
+ )
+
+
+async def test_a_fit_that_names_nothing_is_refused_with_the_names_that_exist(backend):
+ part = build_part(SHAFT)
+ drawn = await draw_part(backend, part, at=(0.0, 0.0), views=("front", "side"), dimension=False)
+ with pytest.raises(ValueError) as excinfo:
+ await dimension_part(backend, drawn["part_id"], fits={"bearing_seat": "k6"})
+ assert "bearing_seat" in str(excinfo.value)
+ assert "segment[0]" in str(excinfo.value)
+
+
+async def test_a_fit_on_one_view_is_not_refused_because_another_view_lacks_it(backend):
+ """A part drawn front + side: `segment[0]` is dimensioned in the front view
+ only, and the end view (which dimensions the *last* segment) must not turn
+ a correct sheet into a refusal."""
+ part = build_part(SHAFT)
+ drawn = await draw_part(backend, part, at=(0.0, 0.0), views=("front", "side"), dimension=False)
+ result = await dimension_part(backend, drawn["part_id"], fits={"segment[0]": "k6"})
+ assert any(row.get("fit") == "k6" for row in result["resolved"])
+
+
+async def test_a_hole_pattern_draws_its_holes_and_centre_marks(backend):
+ result = await draw_hole_pattern(
+ backend, pattern="polar", x=0.0, y=0.0, diameter=8.0, count=6, pcd=80.0
+ )
+ assert result["count"] == 6
+ circles = await entities(backend, type_filter="CIRCLE")
+ assert len(circles) == 6
+ assert len(await entities(backend, layer="CENTER")) >= 12
+
+
+async def test_a_hole_pattern_with_no_spacing_is_refused_before_anything_is_drawn(backend):
+ with pytest.raises(ValueError, match="spacing"):
+ await draw_hole_pattern(backend, pattern="linear", x=0.0, y=0.0, diameter=8.0, count=4)
+ assert not await entities(backend, type_filter="CIRCLE")
+
+
+async def test_draw_from_spec_draws_a_whole_sheet_in_one_transaction(backend):
+ result = await draw_from_spec(
+ backend,
+ {
+ "sheet": {"intent": "mechanical sheet", "sheet_size": "A3", "scale": 1.0},
+ "parts": [
+ {"part": SHAFT, "at": [40.0, 200.0], "views": ["front", "side"]},
+ {"part": PLATE, "at": [40.0, 60.0], "views": ["front"]},
+ ],
+ },
+ )
+ assert len(result["parts"]) == 2
+ assert len(await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER)) == 2
+
+
+async def test_draw_from_spec_validates_every_part_before_it_draws_anything(backend):
+ bad = dict(SHAFT, segments=[{"length": 20.0, "d_outer": float("inf")}])
+ with pytest.raises(ValueError) as excinfo:
+ await draw_from_spec(
+ backend,
+ {"parts": [{"part": PLATE, "at": [0.0, 0.0]}, {"part": bad, "at": [200.0, 0.0]}]},
+ )
+ assert "parts[1]" in str(excinfo.value)
+ assert "segments[0].d_outer" in str(excinfo.value)
+ assert not await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER)
+
+
+async def test_draw_from_spec_rolls_back_when_a_draw_fails_midway(backend, monkeypatch):
+ calls = {"n": 0}
+ real = backend.entity_create_line
+
+ async def explode(*args, **kwargs):
+ calls["n"] += 1
+ if calls["n"] > 3:
+ raise RuntimeError("the seat went away")
+ return await real(*args, **kwargs)
+
+ monkeypatch.setattr(backend, "entity_create_line", explode)
+ with pytest.raises(RuntimeError, match="the seat went away"):
+ await draw_from_spec(backend, {"parts": [{"part": SHAFT, "at": [0.0, 0.0]}]})
+ monkeypatch.undo()
+ assert not await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER)
+ assert not await entities(backend, type_filter="LINE")
+
+
+# -- review round: rotation, single dimensioning, honest refusals -------------
+
+TWO_STEP = {
+ "kind": "revolved",
+ "name": "shaft",
+ "material": "steel",
+ "segments": [{"length": 20.0, "d_outer": 25.0}, {"length": 40.0, "d_outer": 30.0}],
+ "features": [],
+}
+
+TUBE = {
+ "kind": "revolved",
+ "name": "tube",
+ "material": "steel",
+ "segments": [{"length": 30.0, "d_outer": 60.0, "d_inner": 40.0}],
+ "features": [],
+}
+
+
+def _geometry_bbox(backend):
+ xs: list[float] = []
+ ys: list[float] = []
+ for e in backend._doc.modelspace():
+ if e.dxftype() == "LINE":
+ xs += [e.dxf.start.x, e.dxf.end.x]
+ ys += [e.dxf.start.y, e.dxf.end.y]
+ return [min(xs), min(ys), max(xs), max(ys)]
+
+
+def _defpoints(backend):
+ return sorted(
+ (round(e.dxf.get(name).x, 6), round(e.dxf.get(name).y, 6))
+ for e in backend._doc.modelspace().query("DIMENSION")
+ for name in ("defpoint2", "defpoint3")
+ if e.dxf.hasattr(name)
+ )
+
+
+def _turn90(point, about):
+ return (round(about[0] - (point[1] - about[1]), 6), round(about[1] + (point[0] - about[0]), 6))
+
+
+async def test_rotation_turns_geometry_record_and_dimensions_about_at(backend):
+ """rotation=90 about at=(100, 100): the part, its recorded box and every
+ DIMENSION defpoint are the rotation=0 drawing turned about `at`."""
+ from backends.ezdxf_backend import EzdxfBackend
+
+ flat = EzdxfBackend()
+ await flat.connect()
+ await flat.drawing_new()
+ try:
+ await draw_part(flat, build_part(TWO_STEP), at=(100.0, 100.0), rotation=0.0)
+ expected = sorted(_turn90(p, (100.0, 100.0)) for p in _defpoints(flat))
+ finally:
+ await flat.disconnect()
+
+ result = await draw_part(backend, build_part(TWO_STEP), at=(100.0, 100.0), rotation=90.0)
+ geometry = _geometry_bbox(backend)
+ assert geometry == pytest.approx([70.0, 100.0, 100.0, 172.0])
+ assert result["views"][0]["bbox"] == pytest.approx(geometry)
+ read = await read_part(backend, result["part_id"])
+ assert read["views"][0]["bbox"] == pytest.approx(geometry)
+ assert read["views"][0]["frame_bbox"] == pytest.approx([100.0, 100.0, 172.0, 130.0])
+ assert expected
+ assert _defpoints(backend) == expected
+
+
+async def test_a_view_added_to_a_rotated_part_turns_with_it(backend):
+ drawn = await draw_part(
+ backend, build_part(TWO_STEP), at=(0.0, 0.0), rotation=90.0, dimension=False
+ )
+ added = await add_view(backend, drawn["part_id"], "side")
+ # first angle: the side view sits left of the front in the part's frame,
+ # which is BELOW it once the sheet is turned 90 degrees CCW
+ front, side = drawn["views"][0]["bbox"], added["view"]["bbox"]
+ assert side[3] <= front[1] + 1e-6
+
+
+async def test_draw_then_add_view_then_dimension_never_duplicates(backend):
+ drawn = await draw_part(backend, build_part(SHAFT), at=(0.0, 0.0), dimension=True)
+ before = len(await entities(backend, type_filter="DIMENSION"))
+ assert before == drawn["dimensions"] > 0
+ await add_view(backend, drawn["part_id"], "side")
+ result = await dimension_part(backend, drawn["part_id"])
+ assert result["views_dimensioned"] == [1]
+ after = len(await entities(backend, type_filter="DIMENSION"))
+ assert after == before + result["count"]
+ assert result["count"] > 0
+
+ with pytest.raises(ValueError, match="already dimensioned"):
+ await dimension_part(backend, drawn["part_id"])
+ with pytest.raises(ValueError, match=r"0 \(front\) already dimensioned"):
+ await dimension_part(backend, drawn["part_id"], views=[0])
+ assert len(await entities(backend, type_filter="DIMENSION")) == after
+
+
+async def test_dimension_part_twice_is_refused_not_stacked(backend):
+ drawn = await draw_part(backend, build_part(PLATE), at=(0.0, 0.0), dimension=False)
+ first = await dimension_part(backend, drawn["part_id"])
+ with pytest.raises(ValueError, match="every view is already dimensioned"):
+ await dimension_part(backend, drawn["part_id"])
+ assert len(await entities(backend, type_filter="DIMENSION")) == first["count"]
+
+
+async def test_a_view_the_dimension_layer_cannot_dimension_is_reported_not_flagged(backend):
+ drawn = await draw_part(backend, build_part(SHAFT), at=(0.0, 0.0))
+ await add_view(
+ backend,
+ drawn["part_id"],
+ "detail",
+ detail={"center": [20.0, 12.5], "radius": 8.0, "scale": 5.0, "label": "D"},
+ )
+ result = await dimension_part(backend, drawn["part_id"])
+ assert result["views_dimensioned"] == []
+ assert any(row.get("kind") == "detail" for row in result["skipped"])
+ assert result["count"] == 0
+
+
+async def test_an_island_that_does_not_attach_removes_the_hatch_and_raises(backend, monkeypatch):
+ drawn = await draw_part(backend, build_part(TUBE), at=(0.0, 0.0), dimension=False)
+ real = backend.hatch_add_boundary
+
+ async def refuse(handle, edges):
+ return await real(handle, [])
+
+ monkeypatch.setattr(backend, "hatch_add_boundary", refuse)
+ with pytest.raises(ValueError, match="did not attach"):
+ await add_view(
+ backend,
+ drawn["part_id"],
+ "section",
+ plane={"p1": [15.0, -40.0], "p2": [15.0, 40.0], "label": "B"},
+ )
+ assert not await entities(backend, type_filter="HATCH")
+
+
+async def test_an_engine_without_hatch_edge_paths_refuses_before_drawing(backend, monkeypatch):
+ from backends.base import CapabilityMap, FeatureCapability
+ from backends.capability import UnsupportedCapabilityError
+
+ drawn = await draw_part(backend, build_part(TUBE), at=(0.0, 0.0), dimension=False)
+ count = len(await entities(backend))
+ monkeypatch.setattr(
+ backend,
+ "capabilities",
+ lambda: CapabilityMap(
+ "com", {"hatch_edge_paths": FeatureCapability(False, reason="measured")}
+ ),
+ )
+ with pytest.raises(UnsupportedCapabilityError) as excinfo:
+ await add_view(
+ backend,
+ drawn["part_id"],
+ "section",
+ plane={"p1": [15.0, -40.0], "p2": [15.0, 40.0], "label": "B"},
+ )
+ assert excinfo.value.capability == "hatch_edge_paths"
+ assert len(await entities(backend)) == count
+
+
+async def test_a_scale_that_would_only_be_recorded_is_refused(backend):
+ with pytest.raises(ValueError, match="full size"):
+ await draw_part(backend, build_part(PLATE), scale=0.5)
+ drawn = await draw_part(backend, build_part(PLATE), dimension=False)
+ with pytest.raises(ValueError, match="full size"):
+ await add_view(backend, drawn["part_id"], "side", scale=2.0)
+
+
+async def test_a_second_added_view_steps_along_its_own_projection_axis(backend):
+ drawn = await draw_part(
+ backend, build_part(PLATE), at=(0.0, 0.0), dimension=False, projection="third"
+ )
+ side = await add_view(backend, drawn["part_id"], "side")
+ section = await add_view(
+ backend,
+ drawn["part_id"],
+ "section",
+ plane={"p1": [0.0, 25.0], "p2": [80.0, 25.0], "label": "A"},
+ )
+ front = drawn["views"][0]["bbox"]
+ # third angle puts both to the RIGHT; the second steps further right,
+ # never back onto the front view
+ assert section["view"]["bbox"][0] >= side["view"]["bbox"][2] + 20.0 - 1e-6
+ assert section["view"]["bbox"][0] > front[2]
+
+
+async def test_a_top_view_sharing_the_x_range_is_not_shoved(backend):
+ drawn = await draw_part(backend, build_part(PLATE), at=(0.0, 0.0), dimension=False)
+ await add_view(backend, drawn["part_id"], "side")
+ top = await add_view(backend, drawn["part_id"], "top", gap=25.0)
+ front = drawn["views"][0]["bbox"]
+ assert top["view"]["bbox"][0] == pytest.approx(front[0])
+ assert top["view"]["bbox"][2] == pytest.approx(front[2])
+
+
+@pytest.mark.parametrize("at", [[], [5.0], {"x": 1}, "0,0", [1.0, "a"]])
+async def test_a_malformed_at_is_refused_with_its_part_path(backend, at):
+ with pytest.raises(ValueError, match=r"parts\[0\]: at"):
+ await draw_from_spec(backend, {"parts": [{"part": PLATE, "at": at}]})
+ assert not await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER)
+
+
+async def test_a_transaction_that_cannot_open_is_a_value_error(backend, monkeypatch):
+ async def busy():
+ return {"ok": False, "error": "a transaction is already open"}
+
+ monkeypatch.setattr(backend, "transaction_begin", busy)
+ with pytest.raises(ValueError, match="could not open a transaction"):
+ await draw_from_spec(backend, {"parts": [{"part": PLATE, "at": [0.0, 0.0]}]})
diff --git a/tests/test_mech_features.py b/tests/test_mech_features.py
new file mode 100644
index 0000000..b529d3f
--- /dev/null
+++ b/tests/test_mech_features.py
@@ -0,0 +1,753 @@
+"""Every feature of spec section 5: validation, emitted primitives, dimensions.
+
+Geometry is asserted as emitted primitive tuples - counts, roles, coordinates
+to 1e-9 - never as a screenshot. The two features with a real table
+(`Keyway` -> DIN 6885 via engineering.keyway, `GearTeeth` -> the involute
+generator in engineering.gear) are pinned against those modules rather than
+against numbers restated here; the four not-transcribed tables are exercised
+through their refusals.
+"""
+
+from __future__ import annotations
+
+import math
+
+import pytest
+
+from engineering.gear import generate_full_gear_outline
+from engineering.keyway import keyway_dimensions
+from engineering.mech.features import (
+ FEATURE_KINDS,
+ AxialBore,
+ BendLine,
+ CentreHole,
+ Chamfer,
+ CornerChamfer,
+ CornerFillet,
+ CrossHole,
+ Fillet,
+ GearTeeth,
+ Hole,
+ HoleCircle,
+ HolePattern,
+ Keyway,
+ ORingGroove,
+ Pocket,
+ RetainingGroove,
+ Slot,
+ Thread,
+ Undercut,
+ feature_from_dict,
+ mirror_about_x,
+)
+from engineering.mech.part import PrismaticPart, RevolvedPart, Segment, build_part
+from engineering.mech.primitives import Arc, Circle, Line, Poly, Text, bbox
+
+CTX: dict = {"side": "right", "scale": 1.0, "projection": "first"}
+
+
+def shaft(*features) -> RevolvedPart:
+ return RevolvedPart(
+ name="shaft",
+ segments=(Segment(20.0, 25.0), Segment(40.0, 30.0, 10.0), Segment(15.0, 20.0)),
+ features=tuple(features),
+ )
+
+
+def plate(*features) -> PrismaticPart:
+ return PrismaticPart(
+ name="plate",
+ outline=((0.0, 0.0), (80.0, 0.0), (80.0, 50.0), (0.0, 50.0)),
+ thickness=8.0,
+ features=tuple(features),
+ )
+
+
+def test_the_catalogue_is_the_nineteen_kinds_spec_section_5_names():
+ assert FEATURE_KINDS == (
+ "axial_bore",
+ "bend_line",
+ "centre_hole",
+ "chamfer",
+ "corner_chamfer",
+ "corner_fillet",
+ "cross_hole",
+ "fillet",
+ "gear_teeth",
+ "hole",
+ "hole_circle",
+ "hole_pattern",
+ "keyway",
+ "oring_groove",
+ "pocket",
+ "retaining_groove",
+ "slot",
+ "thread",
+ "undercut",
+ )
+
+
+def test_feature_from_dict_round_trips_every_kind_it_builds():
+ payload = {"kind": "chamfer", "id": "c1", "at": "start", "size": 2.0, "angle": 45.0}
+ feature = feature_from_dict(payload)
+ assert isinstance(feature, Chamfer)
+ assert feature.to_dict() == payload
+
+
+def test_feature_from_dict_refuses_an_unknown_kind_and_an_unknown_key():
+ with pytest.raises(ValueError, match="unknown feature"):
+ feature_from_dict({"kind": "spline", "id": "s"})
+ with pytest.raises(ValueError, match="unknown key"):
+ feature_from_dict({"kind": "chamfer", "id": "c", "at": "start", "size": 1.0, "deg": 30})
+
+
+def test_mirror_about_x_flips_every_primitive_type():
+ line, arc, circle, poly, text = mirror_about_x(
+ [
+ Line((0.0, 5.0), (10.0, 5.0)),
+ Arc((0.0, 4.0), 2.0, 30.0, 90.0),
+ Circle((1.0, 3.0), 2.0),
+ Poly(((0.0, 1.0), (2.0, 3.0)), True),
+ Text((1.0, 2.0), "A"),
+ ]
+ )
+ assert line.p1 == (0.0, -5.0) and line.p2 == (10.0, -5.0)
+ assert arc.center == (0.0, -4.0)
+ assert (arc.start_deg, arc.end_deg) == (270.0, 330.0)
+ assert circle.center == (1.0, -3.0)
+ assert poly.points == ((0.0, -1.0), (2.0, -3.0))
+ assert text.at == (1.0, -2.0)
+
+
+# -- chamfer and fillet ------------------------------------------------------
+
+
+def test_a_45_degree_chamfer_at_the_start_replaces_the_corner_with_equal_legs():
+ feature = Chamfer(id="c1", at="start", size=2.0)
+ part = shaft(feature)
+ edit = feature.corner_edit(part)
+ assert edit["corner"] == (0.0, 12.5)
+ assert edit["before"] == (0.0, 10.5)
+ assert edit["after"] == (2.0, 12.5)
+ assert edit["arc"] is None
+ assert feature.removal_box(part) == (0.0, 10.5, 2.0, 12.5)
+
+
+def test_a_30_degree_chamfer_at_the_end_cuts_toward_minus_x():
+ """The two replacement points sit on the two surfaces the corner joined.
+
+ The silhouette runs left to right, so ``before`` is the point left on the
+ cylinder (x = 75 - size, r unchanged) and ``after`` is the point left on
+ the end face (x = 75, r reduced by the radial leg). Neither of them is the
+ corner itself.
+ """
+ feature = Chamfer(id="c2", at="end", size=3.0, angle=30.0)
+ part = shaft(feature)
+ edit = feature.corner_edit(part)
+ radial = 3.0 * math.tan(math.radians(30.0))
+ assert edit["corner"] == (75.0, 10.0)
+ assert edit["before"][0] == pytest.approx(72.0)
+ assert edit["before"][1] == pytest.approx(10.0)
+ assert edit["after"][0] == pytest.approx(75.0)
+ assert edit["after"][1] == pytest.approx(10.0 - radial)
+ assert edit["arc"] is None
+
+
+def test_a_chamfer_shows_as_an_extra_circle_in_the_end_view():
+ feature = Chamfer(id="c1", at="end", size=2.0)
+ part = shaft(feature)
+ (circle,) = feature.prims(part, "side", CTX)
+ assert isinstance(circle, Circle)
+ assert circle.radius == pytest.approx(8.0)
+
+
+def test_a_chamfer_bigger_than_the_step_is_refused_by_name():
+ feature = Chamfer(id="c1", at="start", size=40.0)
+ with pytest.raises(ValueError, match="c1|size"):
+ feature.validate(shaft(feature))
+
+
+def test_a_chamfer_whose_axial_leg_outruns_its_segment_is_refused_by_name():
+ """The radial guard only bites near 45 deg; a shallow lead-in slips past it.
+
+ The end segment of `shaft()` is 15 mm long at r = 10. A 30 mm chamfer at
+ 10 deg takes only 5.29 mm off the radius, so `radial >= radius` is happy,
+ and the old part-length bound (75 mm) was happy too - yet `before` landed
+ at x = 45, two segments away, anchored at a radius that segment does not
+ have.
+ """
+ feature = Chamfer(id="c1", at="end", size=30.0, angle=10.0)
+ with pytest.raises(ValueError) as excinfo:
+ feature.validate(shaft(feature))
+ message = str(excinfo.value)
+ assert "30" in message and "15" in message and "segment 2" in message
+
+
+def test_a_shallow_chamfer_that_fits_inside_its_segment_is_still_accepted():
+ """The new bound refuses only what leaves the segment; 14 < 15 mm stays."""
+ feature = Chamfer(id="c1", at="end", size=14.0, angle=10.0)
+ part = shaft(feature)
+ feature.validate(part)
+ edit = feature.corner_edit(part)
+ assert edit["before"][0] == pytest.approx(61.0)
+ assert edit["before"][0] >= 60.0 # inside segment 2, which spans x 60..75
+
+
+def test_a_chamfer_at_a_step_is_bounded_by_the_segment_it_cuts_into():
+ """`shaft()` steps UP at x = 20, so the chamfer cuts into segment 1 (40 mm).
+
+ A 45 mm leg outruns that segment even though the part is 75 mm long.
+ """
+ feature = Chamfer(id="c1", at="step:0", size=45.0, angle=5.0)
+ with pytest.raises(ValueError) as excinfo:
+ feature.validate(shaft(feature))
+ assert "segment 1" in str(excinfo.value) and "40" in str(excinfo.value)
+
+
+def test_a_fillet_at_a_step_up_emits_a_tangent_arc_in_the_lower_right_quadrant():
+ feature = Fillet(id="f1", at="step:0", radius=2.0)
+ part = shaft(feature)
+ edit = feature.corner_edit(part)
+ assert edit["corner"] == (20.0, 12.5)
+ assert edit["before"] == (18.0, 12.5)
+ assert edit["after"] == (20.0, 14.5)
+ arc = edit["arc"]
+ assert isinstance(arc, Arc)
+ assert arc.center == (18.0, 14.5)
+ assert (arc.radius, arc.start_deg, arc.end_deg) == (2.0, 270.0, 360.0)
+
+
+def test_a_fillet_at_a_step_down_puts_the_arc_in_the_lower_left_quadrant():
+ feature = Fillet(id="f2", at="step:1", radius=1.5)
+ part = shaft(feature)
+ edit = feature.corner_edit(part)
+ assert edit["corner"] == (60.0, 10.0)
+ assert edit["before"] == (60.0, 11.5)
+ assert edit["after"] == (61.5, 10.0)
+ assert edit["arc"].center == (61.5, 11.5)
+ assert (edit["arc"].start_deg, edit["arc"].end_deg) == (180.0, 270.0)
+
+
+def test_a_fillet_is_only_defined_at_a_step():
+ feature = Fillet(id="f3", at="start", radius=1.0)
+ with pytest.raises(ValueError, match="step"):
+ feature.validate(shaft(feature))
+
+
+# -- keyway (DIN 6885 through engineering.keyway) ----------------------------
+
+
+def test_a_keyway_takes_its_width_and_depth_from_din_6885():
+ feature = Keyway(id="kw", segment=1, length=25.0, offset=5.0)
+ part = shaft(feature)
+ feature.validate(part)
+ table = keyway_dimensions(30.0)
+ prims = feature.prims(part, "front", CTX)
+ assert len(prims) == 3
+ assert all(isinstance(p, Line) and p.role == "visible" for p in prims)
+ bottom = 15.0 - table["depth_shaft"]
+ assert prims[0].p1 == (25.0, 15.0) and prims[0].p2 == (25.0, bottom)
+ assert prims[1].p1 == (25.0, bottom) and prims[1].p2 == (50.0, bottom)
+ assert prims[2].p1 == (50.0, bottom) and prims[2].p2 == (50.0, 15.0)
+ assert feature.removal_box(part) == (25.0, bottom, 50.0, 15.0)
+
+
+def test_a_keyway_end_view_breaks_the_circle_and_draws_the_notch():
+ """The arc is the shaft that SURVIVES the slot, not the cap over its mouth.
+
+ `Arc` is CCW start -> end, so the sweep has to be the long way round: it
+ leaves the left notch wall, goes over the bottom of the shaft and arrives
+ at the right one. Emitting the two angles the other way round draws a short
+ chord-like cap straight across the keyway opening - geometrically a legal
+ arc, which is why only the sweep and the endpoints catch it. Measured on
+ AutoCAD 2026: the 30.93 deg arc is 8.098 mm long, the 329.07 deg one is
+ 86.150 mm, and the full r=15 circle is 94.248 mm.
+ """
+ feature = Keyway(id="kw", segment=1, length=25.0)
+ part = shaft(feature)
+ prims = feature.prims(part, "side", CTX)
+ arcs = [p for p in prims if isinstance(p, Arc)]
+ lines = [p for p in prims if isinstance(p, Line)]
+ assert len(arcs) == 1 and len(lines) == 3
+ width = keyway_dimensions(30.0)["width"]
+ assert lines[1].p1[0] == pytest.approx(-width / 2.0)
+ assert lines[1].p2[0] == pytest.approx(width / 2.0)
+
+ arc = arcs[0]
+ radius = 15.0
+ half = width / 2.0
+ top = math.sqrt(radius * radius - half * half)
+ # start at the LEFT wall, end at the RIGHT one: everything but the opening.
+ assert arc.radius == pytest.approx(radius)
+ assert arc.start_deg == pytest.approx(math.degrees(math.atan2(top, -half)))
+ assert arc.end_deg == pytest.approx(math.degrees(math.atan2(top, half)))
+ sweep = (arc.end_deg - arc.start_deg) % 360.0
+ assert sweep == pytest.approx(329.068, abs=1e-3)
+ assert math.radians(sweep) * radius == pytest.approx(86.150, abs=1e-3)
+ # and the end view therefore spans the whole shaft, not just the notch:
+ # full width and full depth, topped by the notch walls because the slot has
+ # taken the crown of the circle away.
+ assert bbox(prims) == pytest.approx((-radius, -radius, radius, top))
+
+
+def test_a_keyway_longer_than_its_segment_is_refused_by_name():
+ feature = Keyway(id="kw", segment=1, length=50.0)
+ with pytest.raises(ValueError) as excinfo:
+ feature.validate(shaft(feature))
+ assert "40" in str(excinfo.value)
+
+
+def test_a_keyway_on_a_segment_that_does_not_exist_is_refused_by_range():
+ feature = Keyway(id="kw", segment=5, length=10.0)
+ with pytest.raises(ValueError) as excinfo:
+ feature.validate(shaft(feature))
+ assert "segments 0-2" in str(excinfo.value)
+
+
+def test_a_keyway_outside_the_din_6885_bore_range_is_refused():
+ part = RevolvedPart(name="pin", segments=(Segment(20.0, 4.0),))
+ feature = Keyway(id="kw", segment=0, length=10.0)
+ with pytest.raises(ValueError, match="DIN 6885"):
+ feature.validate(part)
+
+
+# -- thread ------------------------------------------------------------------
+
+
+def test_an_external_thread_draws_the_iso6410_minor_lines_and_the_limit_line():
+ feature = Thread(id="th", segment=2, designation="M20", length=12.0)
+ part = shaft(feature)
+ feature.validate(part)
+ prims = feature.prims(part, "front", CTX)
+ assert len(prims) == 3
+ assert prims[0].p1 == (60.0, 8.0) and prims[0].p2 == (72.0, 8.0)
+ assert prims[2].p1 == (72.0, -10.0) and prims[2].p2 == (72.0, 10.0)
+
+
+def test_a_thread_whose_diameter_does_not_match_its_segment_is_refused_with_both_numbers():
+ feature = Thread(id="th", segment=1, designation="M20", length=10.0)
+ with pytest.raises(ValueError) as excinfo:
+ feature.validate(shaft(feature))
+ message = str(excinfo.value)
+ assert "M20" in message and "30" in message
+
+
+def test_an_internal_thread_enlarges_the_bore_to_the_major_for_the_section_hatch():
+ feature = Thread(id="th", segment=0, designation="M10", length=20.0, internal=True)
+ part = RevolvedPart(
+ name="boss",
+ segments=(Segment(30.0, 40.0, 8.0),), # drilled to the ISO 6410 minor, 0.8 x 10
+ features=(feature,),
+ )
+ feature.validate(part)
+ (edit,) = feature.radial_edit(part)
+ assert edit == {"kind": "inner", "x0": 0.0, "x1": 20.0, "radius": 5.0}
+
+
+def test_a_thread_designation_outside_iso_261_is_refused_by_the_table():
+ feature = Thread(id="th", segment=2, designation="M7", length=5.0)
+ with pytest.raises(ValueError, match="ISO 261"):
+ feature.validate(shaft(feature))
+
+
+# -- the four not-transcribed tables, through their features -----------------
+
+
+def test_an_undercut_without_an_explicit_profile_is_refused_by_din_509():
+ feature = Undercut(id="uc", at="step:0", form="E")
+ with pytest.raises(ValueError, match="DIN 509"):
+ feature.validate(shaft(feature))
+
+
+def test_an_undercut_draws_exactly_the_profile_it_is_handed():
+ profile = ((0.0, 0.0), (0.5, 0.4), (2.0, 0.4), (2.5, 0.0))
+ feature = Undercut(id="uc", at="step:0", form="E", profile=profile)
+ part = shaft(feature)
+ feature.validate(part)
+ (poly,) = feature.prims(part, "front", CTX)
+ assert isinstance(poly, Poly) and poly.closed is False
+ assert poly.points[0] == (20.0, 12.5)
+ assert poly.points[1] == (19.5, 12.1)
+ assert poly.points[-1] == (17.5, 12.5)
+ assert feature.removal_box(part) == (17.5, 12.1, 20.0, 12.5)
+
+
+def test_a_retaining_groove_without_explicit_dimensions_is_refused_by_din_471():
+ feature = RetainingGroove(id="rg", x=30.0, d=30.0)
+ with pytest.raises(ValueError, match="DIN 471"):
+ feature.validate(shaft(feature))
+
+
+def test_a_retaining_groove_with_explicit_dimensions_cuts_the_profile():
+ feature = RetainingGroove(id="rg", x=30.0, d=30.0, m=1.6, d2=28.6)
+ part = shaft(feature)
+ feature.validate(part)
+ (edit,) = feature.radial_edit(part)
+ assert edit["kind"] == "outer"
+ assert edit["radius"] == pytest.approx(14.3)
+ assert (edit["x0"], edit["x1"]) == (pytest.approx(29.2), pytest.approx(30.8))
+ lines = feature.prims(part, "front", CTX)
+ assert len(lines) == 3
+ assert lines[1].p1[1] == pytest.approx(14.3)
+
+
+def test_a_centre_hole_without_explicit_dimensions_is_refused_by_din_332():
+ feature = CentreHole(id="ch", at="end", form="A", size=2.5)
+ with pytest.raises(ValueError, match="DIN 332"):
+ feature.validate(shaft(feature))
+
+
+def test_a_centre_hole_form_r_is_refused_outright():
+ feature = CentreHole(id="ch", at="end", form="R", size=2.5, d1=2.5, d2=5.3, t=5.0)
+ with pytest.raises(ValueError, match="form R"):
+ feature.validate(shaft(feature))
+
+
+def test_a_centre_hole_with_explicit_dimensions_draws_a_hidden_60_degree_cone():
+ feature = CentreHole(id="ch", at="end", form="A", size=2.5, d1=2.5, d2=5.3, t=5.0)
+ part = shaft(feature)
+ feature.validate(part)
+ prims = feature.prims(part, "front", CTX)
+ assert {p.role for p in prims} == {"hidden"}
+ cone_depth = (5.3 - 2.5) / 2.0 / math.tan(math.radians(30.0))
+ assert prims[0].p1 == (75.0, 2.65)
+ assert prims[0].p2[0] == pytest.approx(75.0 - cone_depth)
+ assert prims[0].p2[1] == pytest.approx(1.25)
+
+
+def test_an_oring_groove_without_explicit_dimensions_is_refused_by_iso_3601():
+ feature = ORingGroove(id="og", x=30.0, cord=3.53)
+ with pytest.raises(ValueError, match="ISO 3601-2"):
+ feature.validate(shaft(feature))
+
+
+def test_an_axial_oring_groove_is_refused_rather_than_drawn_on_the_silhouette():
+ feature = ORingGroove(id="og", x=30.0, cord=3.53, housing="axial", b=4.7, h=2.7)
+ with pytest.raises(ValueError, match="face groove"):
+ feature.validate(shaft(feature))
+
+
+def test_an_oring_groove_round_trips_because_its_field_is_not_called_kind():
+ """``to_dict`` writes the feature kind under ``kind``.
+
+ A field of the same name would overwrite the discriminator - the payload
+ would come back as ``{"kind": "radial", ...}`` and ``feature_from_dict``
+ would refuse it - so the housing kind is carried by ``housing``.
+ """
+ feature = ORingGroove(id="og", x=30.0, cord=3.53, b=4.7, h=2.7)
+ payload = feature.to_dict()
+ assert payload["kind"] == "oring_groove"
+ assert payload["housing"] == "radial"
+ assert feature_from_dict(payload) == feature
+
+
+# -- holes, bores, bolt circles, teeth ---------------------------------------
+
+
+def test_a_cross_hole_draws_two_hidden_lines_through_the_silhouette():
+ feature = CrossHole(id="xh", x=30.0, diameter=6.0)
+ part = shaft(feature)
+ feature.validate(part)
+ prims = feature.prims(part, "front", CTX)
+ assert len(prims) == 2
+ assert {p.role for p in prims} == {"hidden"}
+ assert prims[0].p1 == (27.0, -15.0) and prims[0].p2 == (27.0, 15.0)
+ assert prims[1].p1 == (33.0, -15.0) and prims[1].p2 == (33.0, 15.0)
+
+
+def test_an_inclined_cross_hole_is_refused_rather_than_drawn_as_if_radial():
+ feature = CrossHole(id="xh", x=30.0, diameter=6.0, angle=45.0)
+ with pytest.raises(ValueError, match="angle"):
+ feature.validate(shaft(feature))
+
+
+def test_an_axial_bore_edits_the_radial_profile_and_is_not_flagged_as_omitted():
+ feature = AxialBore(id="ab", at="start", diameter=8.0, depth=12.0)
+ part = shaft(feature)
+ feature.validate(part)
+ (edit,) = feature.radial_edit(part)
+ assert edit == {"kind": "inner", "x0": 0.0, "x1": 12.0, "radius": 4.0}
+ assert feature.removal_box(part) is None
+
+
+def test_two_axial_bores_from_the_same_end_are_refused_with_both_names():
+ a = AxialBore(id="ab1", at="start", diameter=8.0, depth=12.0)
+ b = AxialBore(id="ab2", at="start", diameter=6.0, depth=20.0)
+ part = shaft(a, b)
+ with pytest.raises(ValueError) as excinfo:
+ a.validate(part)
+ assert "ab1" in str(excinfo.value) and "ab2" in str(excinfo.value)
+
+
+def test_a_bolt_circle_draws_its_holes_and_its_pitch_circle_in_the_end_view():
+ feature = HoleCircle(id="bc", x=10.0, pcd=36.0, count=6, diameter=5.0)
+ part = RevolvedPart(name="flange", segments=(Segment(20.0, 60.0),), features=(feature,))
+ feature.validate(part)
+ prims = feature.prims(part, "side", CTX)
+ holes = [p for p in prims if isinstance(p, Circle) and p.role == "visible"]
+ pitch = [p for p in prims if isinstance(p, Circle) and p.role == "center"]
+ assert len(holes) == 6 and len(pitch) == 1
+ assert pitch[0].radius == pytest.approx(18.0)
+ assert holes[0].center == (pytest.approx(18.0), pytest.approx(0.0))
+ assert holes[1].center[0] == pytest.approx(18.0 * math.cos(math.radians(60.0)))
+
+
+def test_a_bolt_circle_that_does_not_fit_the_flange_is_refused_by_name():
+ feature = HoleCircle(id="bc", x=10.0, pcd=58.0, count=6, diameter=5.0)
+ part = RevolvedPart(name="flange", segments=(Segment(20.0, 60.0),), features=(feature,))
+ with pytest.raises(ValueError, match="bc|pcd"):
+ feature.validate(part)
+
+
+def test_gear_teeth_reuse_the_repository_involute_generator():
+ feature = GearTeeth(id="g1", segment=0, module=2.0, teeth=20)
+ part = RevolvedPart(name="pinion", segments=(Segment(15.0, 44.0),), features=(feature,))
+ feature.validate(part)
+ (poly, pitch) = feature.prims(part, "side", CTX)[:2]
+ expected = generate_full_gear_outline(2.0, 20, 20.0, 0.0, "RH", (0.0, 0.0))
+ assert poly.points == tuple(expected)
+ assert poly.closed is True
+ assert pitch.radius == pytest.approx(20.0)
+
+
+def test_gear_teeth_whose_tip_diameter_disagrees_with_the_segment_are_refused():
+ feature = GearTeeth(id="g1", segment=0, module=2.0, teeth=20)
+ part = RevolvedPart(name="pinion", segments=(Segment(15.0, 40.0),), features=(feature,))
+ with pytest.raises(ValueError) as excinfo:
+ feature.validate(part)
+ assert "44" in str(excinfo.value) and "40" in str(excinfo.value)
+
+
+# -- prismatic features ------------------------------------------------------
+
+
+def test_a_hole_draws_a_circle_in_true_shape_and_hidden_lines_in_the_side_view():
+ feature = Hole(id="h1", x=20.0, y=25.0, diameter=10.0)
+ part = plate(feature)
+ feature.validate(part)
+ front = feature.prims(part, "front", CTX)
+ circle = next(p for p in front if isinstance(p, Circle))
+ assert circle.center == (20.0, 25.0) and circle.radius == 5.0
+ assert sum(1 for p in front if isinstance(p, Line) and p.role == "center") == 2
+ side = feature.prims(part, "side", CTX)
+ assert {p.role for p in side} == {"hidden"}
+ # a through hole runs the whole thickness, drawn from the machined face down
+ assert side[0].p1 == (20.0, 8.0) and side[0].p2 == (20.0, 0.0)
+ top = feature.prims(part, "top", CTX)
+ assert top[0].p1 == (15.0, 8.0)
+
+
+def test_a_blind_hole_and_a_pocket_are_cut_from_the_same_face():
+ """Both enter at v = thickness - the face the front view looks at.
+
+ Nothing downstream can catch a disagreement here: two blind features cut
+ from opposite faces of the same plate are each a legal drawing on their
+ own, `omission()` returns None for both, and the validator sees only
+ hidden lines inside the material. So it has to be pinned at the source.
+ """
+ hole = Hole(id="h1", x=20.0, y=25.0, diameter=6.0, depth=5.0)
+ pocket = Pocket(
+ id="p1",
+ outline=((40.0, 10.0), (70.0, 10.0), (70.0, 40.0), (40.0, 40.0)),
+ depth=5.0,
+ )
+ part = plate(hole, pocket)
+ hole.validate(part)
+ pocket.validate(part)
+ for view in ("side", "top"):
+ entry = {round(p.p1[1], 9) for p in hole.prims(part, view, CTX)} | {
+ round(p.p2[1], 9) for p in hole.prims(part, view, CTX)
+ }
+ pocket_entry = {round(p.p1[1], 9) for p in pocket.prims(part, view, CTX)} | {
+ round(p.p2[1], 9) for p in pocket.prims(part, view, CTX)
+ }
+ # the same two v values: the face at 8.0 and a floor 5 mm below it
+ assert entry == {8.0, 3.0}
+ assert pocket_entry == {8.0, 3.0}
+ (left, right, floor) = hole.prims(part, "side", CTX)
+ assert left.p1 == (22.0, 8.0) and left.p2 == (22.0, 3.0)
+ assert right.p1 == (28.0, 8.0) and right.p2 == (28.0, 3.0)
+ assert floor.p1 == (22.0, 3.0) and floor.p2 == (28.0, 3.0)
+
+
+def test_a_counterbore_is_stepped_down_from_the_machined_face_too():
+ feature = Hole(id="h1", x=20.0, y=25.0, diameter=6.0, cbore_d=12.0, cbore_depth=3.0)
+ part = plate(feature)
+ feature.validate(part)
+ # the side view runs u = part Y, so the hole sits at u = 25 and the
+ # counterbore walls at 25 +/- 6.
+ side = feature.prims(part, "side", CTX)
+ cbore = [p for p in side if {19.0, 31.0} & {p.p1[0], p.p2[0]}]
+ assert len(cbore) == 3
+ assert {round(p.p1[1], 9) for p in cbore} | {round(p.p2[1], 9) for p in cbore} == {8.0, 5.0}
+ # and the bore itself still runs the full thickness from that same face
+ bore = [p for p in side if {22.0, 28.0} & {p.p1[0], p.p2[0]}]
+ assert [(p.p1, p.p2) for p in bore] == [
+ ((22.0, 8.0), (22.0, 0.0)),
+ ((28.0, 8.0), (28.0, 0.0)),
+ ]
+
+
+def test_a_hole_outside_the_outline_is_refused_by_name():
+ feature = Hole(id="h1", x=200.0, y=25.0, diameter=10.0)
+ with pytest.raises(ValueError, match="outline"):
+ feature.validate(plate(feature))
+
+
+def test_a_tapped_hole_draws_the_minor_circle_and_a_three_quarter_major_arc():
+ feature = Hole(id="h1", x=20.0, y=25.0, diameter=10.0, thread="M10")
+ part = plate(feature)
+ feature.validate(part)
+ front = feature.prims(part, "front", CTX)
+ circles = [p for p in front if isinstance(p, Circle)]
+ arcs = [p for p in front if isinstance(p, Arc)]
+ assert circles[0].radius == pytest.approx(4.0)
+ assert len(arcs) == 1 and arcs[0].radius == pytest.approx(5.0)
+
+
+def test_a_slot_is_an_obround_of_two_lines_and_two_semicircles():
+ feature = Slot(id="s1", x=40.0, y=25.0, length=30.0, width=10.0)
+ part = plate(feature)
+ feature.validate(part)
+ prims = feature.prims(part, "front", CTX)
+ lines = [p for p in prims if isinstance(p, Line) and p.role == "visible"]
+ arcs = [p for p in prims if isinstance(p, Arc)]
+ assert len(lines) == 2 and len(arcs) == 2
+ assert lines[0].p1 == (pytest.approx(30.0), pytest.approx(30.0))
+ assert arcs[0].center == (pytest.approx(50.0), pytest.approx(25.0))
+ assert feature.removal_box(part) == (25.0, 20.0, 55.0, 30.0)
+
+
+def test_a_slot_narrower_than_it_is_long_is_required():
+ feature = Slot(id="s1", x=40.0, y=25.0, length=5.0, width=10.0)
+ with pytest.raises(ValueError, match="length"):
+ feature.validate(plate(feature))
+
+
+def test_a_pocket_can_declare_itself_unhatched_in_a_longitudinal_section():
+ feature = Pocket(
+ id="p1",
+ outline=((10.0, 10.0), (30.0, 10.0), (30.0, 30.0), (10.0, 30.0)),
+ depth=4.0,
+ no_section_hatch=True,
+ )
+ part = plate(feature)
+ feature.validate(part)
+ (poly,) = feature.prims(part, "front", CTX)
+ assert poly.closed is True and poly.role == "visible"
+ assert feature.no_section_hatch is True
+ assert feature.removal_box(part) == (10.0, 10.0, 30.0, 30.0)
+
+
+def test_a_pocket_deeper_than_the_plate_is_refused():
+ feature = Pocket(
+ id="p1",
+ outline=((10.0, 10.0), (30.0, 10.0), (30.0, 30.0)),
+ depth=20.0,
+ )
+ with pytest.raises(ValueError, match="thickness"):
+ feature.validate(plate(feature))
+
+
+def test_a_corner_fillet_replaces_a_vertex_with_two_tangent_points_and_an_arc():
+ feature = CornerFillet(id="cf", vertex=1, radius=10.0)
+ part = plate(feature)
+ feature.validate(part)
+ edit = feature.outline_edit(part)
+ assert edit["vertex"] == 1
+ assert edit["before"] == (pytest.approx(70.0), pytest.approx(0.0))
+ assert edit["after"] == (pytest.approx(80.0), pytest.approx(10.0))
+ assert edit["arc"].center == (pytest.approx(70.0), pytest.approx(10.0))
+ assert edit["arc"].radius == pytest.approx(10.0)
+
+
+def test_a_corner_chamfer_replaces_a_vertex_with_two_points_and_no_arc():
+ feature = CornerChamfer(id="cc", vertex=1, size=6.0)
+ part = plate(feature)
+ feature.validate(part)
+ edit = feature.outline_edit(part)
+ assert edit["before"] == (pytest.approx(74.0), pytest.approx(0.0))
+ assert edit["after"] == (pytest.approx(80.0), pytest.approx(6.0))
+ assert edit["arc"] is None
+
+
+def test_a_corner_radius_larger_than_its_edges_is_refused():
+ feature = CornerFillet(id="cf", vertex=1, radius=60.0)
+ with pytest.raises(ValueError, match="edge"):
+ feature.validate(plate(feature))
+
+
+def test_a_bend_line_is_representation_only_and_says_so():
+ feature = BendLine(id="b1", p1=(0.0, 25.0), p2=(80.0, 25.0), angle=90.0, inside_radius=2.0)
+ part = plate(feature)
+ feature.validate(part)
+ line, text = feature.prims(part, "front", CTX)
+ assert isinstance(line, Line) and line.role == "phantom"
+ assert isinstance(text, Text)
+ assert "R2" in text.text and "90" in text.text
+ assert feature.dims(part, "front", CTX) == ()
+
+
+def test_a_hole_pattern_expands_before_it_reaches_the_part():
+ spec = {
+ "kind": "prismatic",
+ "name": "plate",
+ "thickness": 8.0,
+ "outline": [[0.0, 0.0], [80.0, 0.0], [80.0, 50.0], [0.0, 50.0]],
+ "features": [
+ {
+ "kind": "hole_pattern",
+ "id": "hp",
+ "pattern": "grid",
+ "base": {"x": 10.0, "y": 10.0, "diameter": 6.0},
+ "count": 3,
+ "spacing": 20.0,
+ "count_y": 2,
+ "spacing_y": 25.0,
+ }
+ ],
+ }
+ part = build_part(spec)
+ assert len(part.features) == 6
+ assert all(isinstance(f, Hole) for f in part.features)
+ assert [f.id for f in part.features][:3] == ["hp_0_0", "hp_1_0", "hp_2_0"]
+ assert part.features[2].x == pytest.approx(50.0)
+ assert part.features[3].y == pytest.approx(35.0)
+
+
+def test_a_polar_hole_pattern_places_its_holes_on_the_pitch_circle():
+ pattern = HolePattern(
+ id="hp",
+ pattern="polar",
+ base={"x": 40.0, "y": 25.0, "diameter": 5.0},
+ count=4,
+ pcd=30.0,
+ )
+ holes = pattern.expand()
+ assert len(holes) == 4
+ assert holes[0].x == pytest.approx(55.0) and holes[0].y == pytest.approx(25.0)
+ assert holes[1].x == pytest.approx(40.0) and holes[1].y == pytest.approx(40.0)
+
+
+def test_a_linear_pattern_without_a_spacing_is_refused():
+ pattern = HolePattern(
+ id="hp",
+ pattern="linear",
+ base={"x": 10.0, "y": 10.0, "diameter": 6.0},
+ count=3,
+ )
+ with pytest.raises(ValueError, match="spacing"):
+ pattern.expand()
+
+
+def test_every_feature_declares_its_dimension_intents_against_its_own_id():
+ features = (
+ Chamfer(id="c1", at="start", size=2.0),
+ Keyway(id="kw", segment=1, length=25.0),
+ Thread(id="th", segment=2, designation="M20", length=12.0),
+ CrossHole(id="xh", x=30.0, diameter=6.0),
+ )
+ part = shaft(*features)
+ for feature in features:
+ for intent in feature.dims(part, "front", CTX):
+ assert intent.feature == feature.id
diff --git a/tests/test_mech_part.py b/tests/test_mech_part.py
new file mode 100644
index 0000000..59ee8a8
--- /dev/null
+++ b/tests/test_mech_part.py
@@ -0,0 +1,216 @@
+"""The part model: construction, round-trip and every refusal the spec names.
+
+Pure arithmetic - no backend, no DXF. The feature surface is exercised through
+a local stub so this module's gate (placement on the part, two features
+removing the same material) is pinned before `engineering/mech/features.py`
+exists.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import pytest
+
+from engineering.mech.part import (
+ PrismaticPart,
+ RevolvedPart,
+ Segment,
+ build_part,
+ inner_radius_at,
+ outer_radius_at,
+ outline_bbox,
+ part_length,
+ part_max_diameter,
+ part_to_dict,
+ point_in_outline,
+ segment_bounds,
+ validate_part,
+)
+
+EPS = 1e-9
+
+SHAFT = {
+ "kind": "revolved",
+ "name": "shaft",
+ "material": "steel",
+ "segments": [
+ {"length": 20.0, "d_outer": 25.0},
+ {"length": 40.0, "d_outer": 30.0, "d_inner": 10.0},
+ {"length": 15.0, "d_outer": 20.0, "taper_to": 16.0},
+ ],
+}
+
+PLATE = {
+ "kind": "prismatic",
+ "name": "plate",
+ "material": "aluminium",
+ "thickness": 8.0,
+ "outline": [[0.0, 0.0], [80.0, 0.0], [80.0, 50.0], [0.0, 50.0]],
+}
+
+
+@dataclass(frozen=True, kw_only=True)
+class StubFeature:
+ """The duck-typed surface `build_part` uses, and nothing else."""
+
+ id: str
+ segment: int = 0
+ box: tuple[float, float, float, float] | None = None
+
+ def validate(self, part) -> None:
+ if not 0 <= self.segment < len(part.segments):
+ raise ValueError(
+ f"segment {self.segment} is outside the {len(part.segments)}-segment part "
+ f"(segments 0-{len(part.segments) - 1})."
+ )
+
+ def removal_box(self, part):
+ return self.box
+
+ def to_dict(self) -> dict:
+ return {"kind": "stub", "id": self.id, "segment": self.segment}
+
+
+def test_build_part_makes_a_revolved_part_with_typed_segments():
+ part = build_part(SHAFT)
+ assert isinstance(part, RevolvedPart)
+ assert part.name == "shaft"
+ assert part.material == "steel"
+ assert part.segments[1] == Segment(40.0, 30.0, 10.0, None)
+ assert part.segments[2].taper_to == 16.0
+ assert part.features == ()
+
+
+def test_build_part_makes_a_prismatic_part_and_drops_a_repeated_closing_vertex():
+ closed = dict(PLATE, outline=PLATE["outline"] + [[0.0, 0.0]])
+ part = build_part(closed)
+ assert isinstance(part, PrismaticPart)
+ assert len(part.outline) == 4
+ assert part.thickness == 8.0
+ assert outline_bbox(part) == (0.0, 0.0, 80.0, 50.0)
+
+
+@pytest.mark.parametrize("spec", [SHAFT, PLATE])
+def test_part_to_dict_round_trips_through_build_part(spec):
+ part = build_part(spec)
+ assert build_part(part_to_dict(part)) == part
+
+
+def test_geometry_helpers_report_the_real_numbers():
+ part = build_part(SHAFT)
+ assert part_length(part) == 75.0
+ assert part_max_diameter(part) == 30.0
+ assert segment_bounds(part) == ((0.0, 20.0), (20.0, 60.0), (60.0, 75.0))
+ assert outer_radius_at(part, 10.0) == 12.5
+ assert inner_radius_at(part, 30.0) == 5.0
+ assert inner_radius_at(part, 10.0) == 0.0
+ # the taper runs 20 -> 16 mm over the last 15 mm, so its mid-point is 18 mm
+ assert outer_radius_at(part, 67.5) == pytest.approx(9.0, abs=EPS)
+ plate = build_part(PLATE)
+ assert part_length(plate) == 80.0
+ assert point_in_outline(plate, 40.0, 25.0) is True
+ assert point_in_outline(plate, 90.0, 25.0) is False
+
+
+@pytest.mark.parametrize("bad", [float("inf"), float("nan"), 0.0, -5.0])
+def test_a_non_finite_or_non_positive_number_is_refused_by_path(bad):
+ spec = dict(SHAFT, segments=[dict(s) for s in SHAFT["segments"]])
+ spec["segments"][2]["d_outer"] = bad
+ with pytest.raises(ValueError) as excinfo:
+ build_part(spec)
+ assert "segments[2].d_outer" in str(excinfo.value)
+
+
+def test_a_bore_that_is_not_smaller_than_the_outside_is_refused():
+ spec = dict(SHAFT, segments=[dict(s) for s in SHAFT["segments"]])
+ spec["segments"][1]["d_inner"] = 30.0
+ with pytest.raises(ValueError) as excinfo:
+ build_part(spec)
+ assert "segments[1].d_inner" in str(excinfo.value)
+ assert "two segments" in str(excinfo.value)
+
+
+def test_an_outline_with_fewer_than_three_distinct_vertices_is_refused():
+ spec = dict(PLATE, outline=[[0.0, 0.0], [10.0, 0.0], [10.0, 0.0]])
+ with pytest.raises(ValueError, match="at least 3 distinct vertices"):
+ build_part(spec)
+
+
+def test_a_self_intersecting_outline_is_refused():
+ spec = dict(PLATE, outline=[[0.0, 0.0], [10.0, 10.0], [10.0, 0.0], [0.0, 10.0]])
+ with pytest.raises(ValueError, match="crosses itself"):
+ build_part(spec)
+
+
+def test_an_unknown_material_is_refused():
+ with pytest.raises(ValueError, match="material"):
+ build_part(dict(SHAFT, material="unobtainium"))
+
+
+def test_a_feature_off_the_part_is_refused_by_its_own_name():
+ part = RevolvedPart(
+ name="shaft",
+ segments=(Segment(20.0, 25.0), Segment(40.0, 30.0), Segment(15.0, 20.0)),
+ features=(StubFeature(id="keyway_1", segment=5),),
+ )
+ with pytest.raises(ValueError) as excinfo:
+ validate_part(part)
+ message = str(excinfo.value)
+ assert "keyway_1" in message
+ assert "segment 5" in message
+ assert "segments 0-2" in message
+
+
+def test_two_features_removing_the_same_material_are_refused_with_both_names():
+ part = RevolvedPart(
+ name="shaft",
+ segments=(Segment(60.0, 30.0),),
+ features=(
+ StubFeature(id="groove_a", box=(10.0, 12.0, 14.0, 15.0)),
+ StubFeature(id="groove_b", box=(12.0, 13.0, 16.0, 15.0)),
+ ),
+ )
+ with pytest.raises(ValueError) as excinfo:
+ validate_part(part)
+ message = str(excinfo.value)
+ assert "groove_a" in message and "groove_b" in message
+ assert "same material" in message
+
+
+def test_features_that_only_touch_are_not_an_overlap():
+ part = RevolvedPart(
+ name="shaft",
+ segments=(Segment(60.0, 30.0),),
+ features=(
+ StubFeature(id="groove_a", box=(10.0, 12.0, 14.0, 15.0)),
+ StubFeature(id="groove_b", box=(14.0, 12.0, 18.0, 15.0)),
+ ),
+ )
+ validate_part(part)
+
+
+def test_an_unknown_kind_is_refused():
+ with pytest.raises(ValueError, match="kind"):
+ build_part(dict(SHAFT, kind="lofted"))
+
+
+def test_an_unknown_segment_key_is_refused_by_name():
+ spec = dict(SHAFT, segments=[{"length": 10.0, "d_outer": 20.0, "diameter": 20.0}])
+ with pytest.raises(ValueError) as excinfo:
+ build_part(spec)
+ assert "segments[0]" in str(excinfo.value)
+ assert "diameter" in str(excinfo.value)
+
+
+def test_a_duplicate_feature_id_is_refused():
+ """The only test here that reaches `feature_from_dict` (Task 3)."""
+ spec = dict(
+ SHAFT,
+ features=[
+ {"kind": "chamfer", "id": "c1", "at": "start", "size": 2.0},
+ {"kind": "chamfer", "id": "c1", "at": "end", "size": 2.0},
+ ],
+ )
+ with pytest.raises(ValueError, match="duplicate feature id"):
+ build_part(spec)
diff --git a/tests/test_mech_sections.py b/tests/test_mech_sections.py
new file mode 100644
index 0000000..7c40863
--- /dev/null
+++ b/tests/test_mech_sections.py
@@ -0,0 +1,473 @@
+"""Sections, cut faces, material hatch and detail views.
+
+The cut face and the silhouette come out of the same radial profile, so the
+tests assert that the hatched loop closes on the real geometry - including the
+ISO 6410 rule that an internal thread's hatch runs to the major diameter, and
+the ISO 128-3 rule that a rib cut longitudinally is not hatched.
+"""
+
+from __future__ import annotations
+
+import math
+
+import pytest
+
+from engineering.mech.features import (
+ AxialBore,
+ Chamfer,
+ CrossHole,
+ Hole,
+ Keyway,
+ Pocket,
+ RetainingGroove,
+ Slot,
+ Thread,
+)
+from engineering.mech.part import PrismaticPart, RevolvedPart, Segment
+from engineering.mech.primitives import Arc, Circle, HatchArea, Line, Text
+from engineering.mech.views import (
+ CIRCLE_SEGMENTS,
+ SECTION_PLANE,
+ SECTION_STYLES,
+ VIEW_KINDS,
+ build_view,
+ cut_loops,
+ detail_marker_prims,
+ normalise_plane,
+ radial_profile,
+)
+
+EPS = 1e-9
+
+
+def shaft(*features) -> RevolvedPart:
+ return RevolvedPart(
+ name="shaft",
+ segments=(Segment(20.0, 25.0), Segment(40.0, 30.0), Segment(15.0, 20.0)),
+ features=tuple(features),
+ )
+
+
+def plate(*features) -> PrismaticPart:
+ return PrismaticPart(
+ name="plate",
+ outline=((0.0, 0.0), (80.0, 0.0), (80.0, 50.0), (0.0, 50.0)),
+ thickness=8.0,
+ features=tuple(features),
+ )
+
+
+def axis_plane(**kw) -> dict:
+ return normalise_plane({"p1": (0.0, 0.0), "p2": (100.0, 0.0), **kw})
+
+
+def polygon_area(points) -> float:
+ total = 0.0
+ count = len(points)
+ for i in range(count):
+ x1, y1 = points[i]
+ x2, y2 = points[(i + 1) % count]
+ total += x1 * y2 - x2 * y1
+ return abs(total) / 2.0
+
+
+def test_the_engine_now_declares_five_kinds_and_four_section_styles():
+ assert VIEW_KINDS == ("front", "side", "top", "section", "detail")
+ assert SECTION_STYLES == ("full", "half", "offset", "revolved")
+ assert set(SECTION_PLANE) == {"p1", "p2", "label", "direction", "style"}
+
+
+def test_normalise_plane_fills_the_pinned_shape_and_refuses_a_zero_length_plane():
+ plane = normalise_plane({"p1": (0.0, 0.0), "p2": (10.0, 0.0)})
+ assert plane["label"] == "A"
+ assert plane["direction"] == (0.0, -1.0)
+ assert plane["style"] == "full"
+ with pytest.raises(ValueError, match="zero length"):
+ normalise_plane({"p1": (1.0, 1.0), "p2": (1.0, 1.0)})
+
+
+# -- revolved longitudinal sections ------------------------------------------
+
+
+def test_a_solid_shaft_full_section_hatches_both_halves_to_the_axis():
+ loops = cut_loops(shaft(), axis_plane(), style="full")
+ assert len(loops) == 2
+ assert all(hatched for _, hatched in loops)
+ upper, lower = (points for points, _ in loops)
+ assert max(y for _, y in upper) == 15.0
+ assert min(y for _, y in upper) == 0.0
+ assert min(y for _, y in lower) == -15.0
+ # the cut face is the real cross-section: 20x12.5 + 40x15 + 15x10 per half
+ assert polygon_area(upper) == pytest.approx(20 * 12.5 + 40 * 15.0 + 15 * 10.0)
+
+
+def test_a_bore_is_not_hatched_because_the_loop_closes_on_it():
+ part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),))
+ (upper, _), (lower, _) = cut_loops(part, axis_plane(), style="full")
+ assert polygon_area(upper) == pytest.approx(30.0 * (20.0 - 10.0))
+ assert min(y for _, y in upper) == 10.0
+ assert max(y for _, y in lower) == -10.0
+
+
+def test_a_half_section_hatches_one_half_only():
+ loops = cut_loops(shaft(), axis_plane(style="half"), style="half")
+ assert len(loops) == 1
+ assert min(y for _, y in loops[0][0]) == 0.0
+
+
+def test_iso6410_an_internal_thread_hatch_runs_to_the_major_diameter():
+ thread = Thread(id="th", segment=0, designation="M20", length=25.0, internal=True)
+ # drilled to the ISO 6410 minor (0.8 x 20 = 16 mm); the hatch must stop at
+ # the 20 mm major, not at the drilled 16.
+ part = RevolvedPart(name="boss", segments=(Segment(30.0, 50.0, 16.0),), features=(thread,))
+ bands = radial_profile(part)
+ threaded = [b for b in bands if b.x1 <= 25.0 + EPS]
+ assert {round(b.r_in, 6) for b in threaded} == {10.0}
+ unthreaded = [b for b in bands if b.x0 >= 25.0 - EPS]
+ assert {round(b.r_in, 6) for b in unthreaded} == {8.0}
+ (upper, _), _ = cut_loops(part, axis_plane(), style="full")
+ # The cut face closes on the major over the threaded span - and on the real
+ # drilled bore over the 5 mm that is drilled but not threaded. Asserting the
+ # minimum over the *whole* loop would be asserting that the drill does not
+ # exist beyond the thread, which is a different (and wrong) drawing.
+ assert min(y for x, y in upper if x < 25.0 - EPS) == pytest.approx(10.0)
+ assert min(y for x, y in upper if x > 25.0 + EPS) == pytest.approx(8.0)
+
+
+def test_a_section_draws_its_own_cut_boundary_continuous_and_never_dashed():
+ """ISO 128-50: a surface lying in the cutting plane is bounded by a
+ continuous wide line, and ISO 128-3 leaves hidden detail out of a sectional
+ view. The bore wall is the case that catches a section built out of the
+ un-cut silhouette: there the material hides the bore, so the silhouette
+ draws it dashed - but y = +-10 is the hatch loop's own boundary, and a
+ dashed line drawn on the hatch boundary means the outline and the hatched
+ area disagree at identical coordinates.
+ """
+ part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),))
+ view = build_view(part, "section", plane=axis_plane(), style="full")
+ assert "hidden" not in {getattr(prim, "role", None) for prim in view.prims}
+ bore = [
+ line
+ for line in view.prims
+ if isinstance(line, Line)
+ and abs(abs(line.p1[1]) - 10.0) < EPS
+ and abs(abs(line.p2[1]) - 10.0) < EPS
+ ]
+ assert len(bore) == 2
+ assert {line.role for line in bore} == {"visible"}
+ (upper, _), (lower, _) = cut_loops(part, axis_plane(), style="full")
+ assert min(y for _, y in upper) == pytest.approx(10.0)
+ assert max(y for _, y in lower) == pytest.approx(-10.0)
+
+
+def test_a_half_section_keeps_its_dashed_bore_only_on_the_half_it_does_not_cut():
+ part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),))
+ view = build_view(part, "section", plane=axis_plane(style="half"), style="half")
+ hidden = [prim for prim in view.prims if getattr(prim, "role", None) == "hidden"]
+ assert hidden, "the un-sectioned half still shows its bore, dashed"
+ assert all(line.p1[1] < 0.0 and line.p2[1] < 0.0 for line in hidden)
+ assert all(abs(abs(line.p1[1]) - 10.0) < EPS for line in hidden)
+ # the cut half carries the hatch, and nothing dashed on it
+ ((upper, _),) = cut_loops(part, axis_plane(style="half"), style="half")
+ assert min(y for _, y in upper) == pytest.approx(10.0)
+
+
+def test_a_chamfer_is_cut_out_of_the_hatched_area_exactly_where_it_is_drawn():
+ """The loop is the emitted outline flattened, so a corner substitution
+ reaches the cut face too: a 2 mm 45 deg chamfer takes a 2 mm2 triangle off
+ the 1000 mm2 half face, and a loop built from the raw profile would hatch
+ over the chamfer it just drew."""
+ (upper, _), _lower = cut_loops(
+ shaft(Chamfer(id="c1", at="start", size=2.0)), axis_plane(), style="full"
+ )
+ assert polygon_area(upper) == pytest.approx(20 * 12.5 + 40 * 15.0 + 15 * 10.0 - 2.0)
+
+
+def test_the_section_view_carries_a_hatch_area_per_loop_with_the_parts_material():
+ view = build_view(shaft(), "section", plane=axis_plane(), style="full")
+ hatches = [p for p in view.prims if isinstance(p, HatchArea)]
+ assert len(hatches) == 2
+ assert {h.material for h in hatches} == {"steel"}
+ assert view.label == "A-A"
+
+
+def test_an_unknown_material_is_refused_before_a_section_is_built():
+ part = RevolvedPart(name="x", segments=(Segment(10.0, 20.0),), material="unobtainium")
+ with pytest.raises(ValueError, match="unobtainium"):
+ build_view(part, "section", plane=axis_plane())
+
+
+def test_an_offset_plane_on_a_revolved_part_is_refused_with_the_alternative():
+ with pytest.raises(ValueError) as excinfo:
+ build_view(shaft(), "section", plane=axis_plane(style="offset"), style="offset")
+ assert "style='full'" in str(excinfo.value)
+
+
+# -- revolved transverse (cross) sections ------------------------------------
+
+
+def test_a_transverse_section_hatches_the_annulus_at_that_station():
+ part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),))
+ plane = normalise_plane({"p1": (15.0, -30.0), "p2": (15.0, 30.0), "label": "B"})
+ view = build_view(part, "section", plane=plane, style="full")
+ hatches = [p for p in view.prims if isinstance(p, HatchArea)]
+ assert len(hatches) == 1
+ outer, island = hatches[0].loops
+ assert len(outer) == CIRCLE_SEGMENTS and len(island) == CIRCLE_SEGMENTS
+ # An inscribed regular CIRCLE_SEGMENTS-gon is exactly
+ # 1 - (n / 2pi) sin(2pi / n) = 2.0307e-4 under the circle, so the tolerance
+ # is the flattening the constant declares and nothing looser.
+ flattening = 1.0 - (CIRCLE_SEGMENTS / (2.0 * math.pi)) * math.sin(
+ 2.0 * math.pi / CIRCLE_SEGMENTS
+ )
+ assert flattening == pytest.approx(2.0307e-4, rel=1e-3)
+ assert polygon_area(outer) == pytest.approx(math.pi * 20.0**2, rel=2.1e-4)
+ assert polygon_area(island) == pytest.approx(math.pi * 10.0**2, rel=2.1e-4)
+ assert view.label == "B-B"
+ circles = [p for p in view.prims if isinstance(p, Circle)]
+ assert {round(c.radius, 6) for c in circles} == {20.0, 10.0}
+
+
+def test_a_revolved_style_cross_section_draws_its_boundary_as_phantom():
+ part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),))
+ plane = normalise_plane({"p1": (15.0, -30.0), "p2": (15.0, 30.0)})
+ view = build_view(part, "section", plane=plane, style="revolved")
+ circles = [p for p in view.prims if isinstance(p, Circle)]
+ assert {c.role for c in circles} == {"phantom"}
+
+
+def keyed_shaft(*features) -> RevolvedPart:
+ """A plain 40 mm shaft; DIN 6885-1 gives its keyway 12 mm x 5 mm deep."""
+ return RevolvedPart(name="keyed", segments=(Segment(60.0, 40.0),), features=tuple(features))
+
+
+def test_a_cross_section_through_a_keyway_cuts_the_notch_out_of_the_disc():
+ """The commonest mechanical cross section there is. The plane at x=20 is
+ squarely inside the slot, so the face is a notched disc - not the plain
+ hatched circle a face built from the segment diameter alone produces."""
+ keyway = Keyway(id="kw", segment=0, length=30.0, offset=5.0)
+ plane = normalise_plane({"p1": (20.0, -40.0), "p2": (20.0, 40.0)})
+ view = build_view(keyed_shaft(keyway), "section", plane=plane, style="full")
+ assert view.omitted == ()
+ arcs = [prim for prim in view.prims if isinstance(prim, Arc)]
+ assert len(arcs) == 1 and arcs[0].radius == pytest.approx(20.0)
+ assert not [prim for prim in view.prims if isinstance(prim, Circle)]
+
+ radius, half, floor = 20.0, 6.0, 15.0
+ top = math.sqrt(radius * radius - half * half)
+ # the disc, less the notch rectangle, less the cap the slot mouth removes
+ cap = radius * radius * math.acos(top / radius) - top * half
+ expected = math.pi * radius * radius - 2.0 * half * (top - floor) - cap
+ (hatch,) = [prim for prim in view.prims if isinstance(prim, HatchArea)]
+ assert polygon_area(hatch.loops[0]) == pytest.approx(expected, rel=2.1e-4)
+ # and it really is a notch: the whole disc is 56 mm2 larger
+ assert polygon_area(hatch.loops[0]) < math.pi * radius * radius - 50.0
+ # the arc the face is bounded by is the arc the end view draws
+ assert arcs[0].start_deg == pytest.approx(math.degrees(math.atan2(top, -half)) % 360.0)
+ assert arcs[0].end_deg == pytest.approx(math.degrees(math.atan2(top, half)) % 360.0)
+
+
+def test_a_cross_section_reports_the_feature_it_cannot_project():
+ """The honesty rule, on the one view where silence is indistinguishable
+ from a correct drawing: the face is hatched either way."""
+ cross = CrossHole(id="xh", x=20.0, diameter=6.0)
+ plane = normalise_plane({"p1": (20.0, -40.0), "p2": (20.0, 40.0)})
+ view = build_view(keyed_shaft(cross), "section", plane=plane, style="full")
+ assert [entry["feature"] for entry in view.omitted] == ["xh"]
+ assert "transverse" in view.omitted[0]["reason"]
+
+
+def test_a_feature_the_transverse_plane_misses_is_reported_as_off_the_plane():
+ keyway = Keyway(id="kw", segment=0, length=30.0, offset=5.0)
+ plane = normalise_plane({"p1": (50.0, -40.0), "p2": (50.0, 40.0)})
+ view = build_view(keyed_shaft(keyway), "section", plane=plane, style="full")
+ assert [entry["feature"] for entry in view.omitted] == ["kw"]
+ assert "not on the cutting plane" in view.omitted[0]["reason"]
+
+
+def test_a_transverse_face_is_read_off_the_radial_profile_not_the_segment():
+ """A groove is in the profile, so it is in the cross section - at its own
+ reduced diameter, and without being reported missing."""
+ groove = RetainingGroove(id="rg", x=30.0, d=40.0, m=1.85, d2=37.5)
+ plane = normalise_plane({"p1": (30.0, -40.0), "p2": (30.0, 40.0)})
+ view = build_view(keyed_shaft(groove), "section", plane=plane, style="full")
+ assert view.omitted == ()
+ circles = [prim for prim in view.prims if isinstance(prim, Circle)]
+ assert {round(circle.radius, 6) for circle in circles} == {37.5 / 2.0}
+
+
+def test_a_half_transverse_section_is_refused_with_its_reason():
+ part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),))
+ plane = normalise_plane({"p1": (15.0, -30.0), "p2": (15.0, 30.0)})
+ with pytest.raises(ValueError, match="half section"):
+ build_view(part, "section", plane=plane, style="half")
+
+
+# -- prismatic sections ------------------------------------------------------
+
+
+def test_a_prismatic_full_section_is_the_chord_times_the_thickness():
+ plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0), "label": "C"})
+ view = build_view(plate(), "section", plane=plane, style="full")
+ (hatch,) = [p for p in view.prims if isinstance(p, HatchArea)]
+ assert polygon_area(hatch.loops[0]) == pytest.approx(80.0 * 8.0)
+ assert view.bbox == (0.0, 0.0, 80.0, 8.0)
+
+
+def test_a_through_hole_splits_the_cut_face_in_two():
+ hole = Hole(id="h1", x=40.0, y=25.0, diameter=10.0)
+ plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)})
+ view = build_view(plate(hole), "section", plane=plane, style="full")
+ hatches = [p for p in view.prims if isinstance(p, HatchArea)]
+ assert len(hatches) == 2
+ total = sum(polygon_area(h.loops[0]) for h in hatches)
+ assert total == pytest.approx((80.0 - 10.0) * 8.0)
+
+
+@pytest.mark.parametrize("offset", [0.0, 4.0, 4.9])
+def test_a_hole_is_cut_on_its_chord_and_not_on_the_box_around_it(offset):
+ """The gap a hole punches in a cut face is the chord the plane actually
+ cuts, and it shrinks to nothing as the plane leaves the centre. Removing
+ the hole by its bounding box reports the full 10 mm diameter at every
+ offset: 32 mm2 of missing cut face at 4 mm off centre and 64 mm2 at 4.9,
+ with an 8 mm gap drawn through solid metal where the plane merely grazes
+ the hole."""
+ hole = Hole(id="h1", x=40.0, y=25.0, diameter=10.0)
+ plane = normalise_plane({"p1": (-10.0, 25.0 + offset), "p2": (100.0, 25.0 + offset)})
+ view = build_view(plate(hole), "section", plane=plane, style="full")
+ chord = 2.0 * math.sqrt(5.0**2 - offset**2)
+ total = sum(polygon_area(prim.loops[0]) for prim in view.prims if isinstance(prim, HatchArea))
+ assert total == pytest.approx((80.0 - chord) * 8.0)
+ if offset > EPS:
+ assert total != pytest.approx((80.0 - 10.0) * 8.0)
+
+
+def test_a_plane_tangent_to_a_hole_leaves_the_cut_face_whole():
+ hole = Hole(id="h1", x=40.0, y=25.0, diameter=10.0)
+ plane = normalise_plane({"p1": (-10.0, 30.0), "p2": (100.0, 30.0)})
+ view = build_view(plate(hole), "section", plane=plane, style="full")
+ hatches = [prim for prim in view.prims if isinstance(prim, HatchArea)]
+ assert len(hatches) == 1
+ assert polygon_area(hatches[0].loops[0]) == pytest.approx(80.0 * 8.0)
+
+
+def test_an_angled_slot_is_cut_on_its_obround_and_not_on_the_box_around_it():
+ """A 45 degree slot's bounding box is 24.14 mm across the cutting line; the
+ slot itself is 14.14. The box would erase 10 mm of material the slot never
+ touches."""
+ slot = Slot(id="s1", x=40.0, y=25.0, length=30.0, width=10.0, angle=45.0)
+ plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)})
+ view = build_view(plate(slot), "section", plane=plane, style="full")
+ total = sum(polygon_area(prim.loops[0]) for prim in view.prims if isinstance(prim, HatchArea))
+ assert total == pytest.approx((80.0 - 10.0 * math.sqrt(2.0)) * 8.0)
+
+
+def test_a_pocket_reduces_the_cut_face_to_the_remaining_thickness():
+ pocket = Pocket(
+ id="p1",
+ outline=((20.0, 15.0), (60.0, 15.0), (60.0, 35.0), (20.0, 35.0)),
+ depth=3.0,
+ )
+ plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)})
+ view = build_view(plate(pocket), "section", plane=plane, style="full")
+ hatches = [p for p in view.prims if isinstance(p, HatchArea)]
+ total = sum(polygon_area(h.loops[0]) for h in hatches)
+ assert total == pytest.approx(40.0 * 8.0 + 40.0 * 5.0)
+
+
+def test_iso128_3_a_rib_cut_longitudinally_is_drawn_but_not_hatched():
+ rib = Pocket(
+ id="rib",
+ outline=((20.0, 15.0), (60.0, 15.0), (60.0, 35.0), (20.0, 35.0)),
+ depth=3.0,
+ no_section_hatch=True,
+ )
+ plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)})
+ loops = cut_loops(plate(rib), plane, style="full")
+ hatched = [points for points, flag in loops if flag]
+ unhatched = [points for points, flag in loops if not flag]
+ assert len(unhatched) == 1
+ assert polygon_area(unhatched[0]) == pytest.approx(40.0 * 5.0)
+ assert sum(polygon_area(p) for p in hatched) == pytest.approx(40.0 * 8.0)
+ view = build_view(plate(rib), "section", plane=plane, style="full")
+ assert len([p for p in view.prims if isinstance(p, HatchArea)]) == 2
+
+
+def test_an_offset_prismatic_section_accumulates_along_the_dog_leg():
+ plane = normalise_plane(
+ {
+ "p1": (-10.0, 15.0),
+ "p2": (100.0, 35.0),
+ "via": [[30.0, 15.0], [30.0, 35.0]],
+ "style": "offset",
+ }
+ )
+ view = build_view(plate(), "section", plane=plane, style="offset")
+ hatches = [p for p in view.prims if isinstance(p, HatchArea)]
+ total = sum(polygon_area(h.loops[0]) for h in hatches)
+ assert total == pytest.approx(80.0 * 8.0)
+
+
+def test_a_revolved_style_section_of_a_prismatic_part_is_refused():
+ plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)})
+ with pytest.raises(ValueError, match="revolved section"):
+ build_view(plate(), "section", plane=plane, style="revolved")
+
+
+def test_a_plane_that_misses_the_part_is_refused_rather_than_hatched_empty():
+ plane = normalise_plane({"p1": (-10.0, 200.0), "p2": (100.0, 200.0)})
+ with pytest.raises(ValueError, match="does not cut"):
+ build_view(plate(), "section", plane=plane, style="full")
+
+
+# -- detail views ------------------------------------------------------------
+
+
+def test_a_detail_scales_the_region_and_labels_both_places():
+ detail = {"center": (20.0, 12.5), "radius": 8.0, "scale": 5.0, "label": "D"}
+ view = build_view(shaft(), "detail", detail=detail)
+ assert view.kind == "detail"
+ assert view.scale == 5.0
+ assert view.label == "D (5:1)"
+ width = view.bbox[2] - view.bbox[0]
+ # straight geometry is trimmed to the circle, so the enlargement can never
+ # be wider than the circle itself at the detail scale
+ assert width <= 2.0 * 8.0 * 5.0 + 1e-6
+ assert width > 8.0 * 5.0
+ marker = detail_marker_prims(detail)
+ circle = next(p for p in marker if isinstance(p, Circle))
+ text = next(p for p in marker if isinstance(p, Text))
+ assert circle.center == (20.0, 12.5) and circle.radius == 8.0
+ assert text.text == "D"
+
+
+def test_a_detail_reports_the_features_that_fall_outside_its_circle():
+ from engineering.mech.features import RetainingGroove
+
+ groove = RetainingGroove(id="rg", x=70.0, d=20.0, m=1.6, d2=18.6)
+ detail = {"center": (5.0, 12.5), "radius": 6.0, "scale": 5.0, "label": "D"}
+ view = build_view(shaft(groove), "detail", detail=detail)
+ assert any(entry["feature"] == "rg" for entry in view.omitted)
+ assert "detail circle D" in next(
+ entry["reason"] for entry in view.omitted if entry["feature"] == "rg"
+ )
+
+
+def test_a_detail_without_a_radius_or_with_a_bad_scale_is_refused():
+ with pytest.raises(ValueError, match="radius"):
+ build_view(shaft(), "detail", detail={"center": (20.0, 12.5), "scale": 5.0})
+ with pytest.raises(ValueError, match="scale"):
+ build_view(shaft(), "detail", detail={"center": (20.0, 12.5), "radius": 8.0, "scale": 0.0})
+
+
+def test_a_section_without_a_plane_is_refused_by_name():
+ with pytest.raises(ValueError, match="plane"):
+ build_view(shaft(), "section")
+
+
+def test_an_axial_bore_shows_its_flat_bottom_in_the_section():
+ bore = AxialBore(id="ab", at="start", diameter=8.0, depth=30.0)
+ view = build_view(shaft(bore), "section", plane=axis_plane(), style="full")
+ (hatch_upper, _hatch_lower) = [p for p in view.prims if isinstance(p, HatchArea)]
+ assert min(y for _, y in hatch_upper.loops[0]) == 0.0
+ assert 4.0 in {round(y, 6) for _, y in hatch_upper.loops[0]}
diff --git a/tests/test_mech_standards.py b/tests/test_mech_standards.py
index 84ec877..d107e6a 100644
--- a/tests/test_mech_standards.py
+++ b/tests/test_mech_standards.py
@@ -89,6 +89,19 @@ def test_a_source_that_does_not_name_its_table_is_refused():
register("TEST 4", {1: {"a": 1}}, Coverage(1.0, 1.0), "made up")
+def test_a_table_may_register_its_structure_before_its_rows_are_transcribed():
+ """DIN 509, DIN 471/472, DIN 332-1 and ISO 3601-2 ship exactly this way."""
+ register("TEST 5", {}, Coverage(0.0, 0.0), SOURCE)
+ assert "TEST 5" in standards()
+ with pytest.raises(ValueError, match="TEST 5"):
+ lookup("TEST 5", 12)
+
+
+def test_a_rows_argument_that_is_not_a_dict_is_refused():
+ with pytest.raises(ValueError, match="rows dict"):
+ register("TEST 6", [{"d": 1.0}], Coverage(1.0, 1.0), SOURCE)
+
+
def test_registering_the_same_standard_twice_is_refused():
with pytest.raises(ValueError, match="already registered"):
register("TEST 1", {10: {"d": 10.0}}, Coverage(10.0, 10.0), SOURCE)
diff --git a/tests/test_mech_standards_coverage.py b/tests/test_mech_standards_coverage.py
new file mode 100644
index 0000000..f5e95f1
--- /dev/null
+++ b/tests/test_mech_standards_coverage.py
@@ -0,0 +1,160 @@
+"""The four tables this build does NOT transcribe, and how they refuse.
+
+The track's table rule is absolute: a row that cannot be sourced against the
+standard is not shipped, because a wrong relief depth or a wrong groove
+diameter is a wrong workshop drawing, while a narrow table is merely narrow.
+DIN 509, DIN 471/472, DIN 332-1 and ISO 3601-2 therefore ship with their
+structure, their SOURCE and an empty row set, and every lookup is refused by
+name and says what to transcribe.
+
+These tests are the gate that keeps that honest in both directions: the
+refusal must name the standard and the transcription route, and the shape gate
+must fail the moment somebody adds a row that is missing a column the drawing
+code reads.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from engineering.mech.standards import standards
+from engineering.mech.standards.centres import (
+ CENTRE_ROW_KEYS,
+ ROWS_A,
+ ROWS_B,
+ centre_hole_dims,
+)
+from engineering.mech.standards.centres import (
+ SOURCE as SOURCE_CENTRES,
+)
+from engineering.mech.standards.orings import (
+ ORING_ROW_KEYS,
+ oring_groove_dims,
+)
+from engineering.mech.standards.orings import (
+ ROWS as ROWS_ORING,
+)
+from engineering.mech.standards.orings import (
+ SOURCE as SOURCE_ORING,
+)
+from engineering.mech.standards.rings import (
+ RING_ROW_KEYS,
+ ROWS_471,
+ ROWS_472,
+ ring_groove_dims,
+)
+from engineering.mech.standards.rings import (
+ SOURCE as SOURCE_RINGS,
+)
+from engineering.mech.standards.undercuts import (
+ ROWS_E,
+ ROWS_F,
+ UNDERCUT_ROW_KEYS,
+ undercut_profile,
+)
+from engineering.mech.standards.undercuts import (
+ SOURCE as SOURCE_UNDERCUTS,
+)
+
+
+def test_every_table_is_registered_so_a_later_transcription_is_a_data_edit():
+ registered = standards()
+ for name in (
+ "DIN 509-E",
+ "DIN 509-F",
+ "DIN 471",
+ "DIN 472",
+ "DIN 332-A",
+ "DIN 332-B",
+ "ISO 3601-2",
+ ):
+ assert name in registered
+
+
+@pytest.mark.parametrize(
+ ("source", "standard"),
+ [
+ (SOURCE_UNDERCUTS, "DIN 509"),
+ (SOURCE_RINGS, "DIN 471"),
+ (SOURCE_CENTRES, "DIN 332"),
+ (SOURCE_ORING, "ISO 3601-2"),
+ ],
+)
+def test_every_source_names_its_standard_and_edition(source, standard):
+ assert standard in source
+ assert any(ch.isdigit() for ch in source.split(":")[-1])
+
+
+def test_no_row_is_shipped_in_this_build():
+ assert ROWS_E == {} and ROWS_F == {}
+ assert ROWS_471 == {} and ROWS_472 == {}
+ assert ROWS_A == {} and ROWS_B == {}
+ assert ROWS_ORING == {}
+
+
+def test_the_undercut_lookup_is_refused_by_name_and_says_what_to_transcribe():
+ with pytest.raises(ValueError) as excinfo:
+ undercut_profile("E", 40.0)
+ message = str(excinfo.value)
+ assert "DIN 509" in message
+ assert "form E" in message
+ assert "40" in message
+ assert "undercuts.py" in message
+
+
+def test_an_unknown_undercut_form_is_refused_before_the_coverage_refusal():
+ with pytest.raises(ValueError, match="forms E and F"):
+ undercut_profile("G", 40.0)
+
+
+def test_the_ring_groove_lookup_is_refused_by_name_for_shaft_and_bore():
+ for standard in ("DIN 471", "DIN 472"):
+ with pytest.raises(ValueError) as excinfo:
+ ring_groove_dims(standard, 30.0)
+ assert standard in str(excinfo.value)
+ assert "rings.py" in str(excinfo.value)
+ with pytest.raises(ValueError, match="DIN 471 or DIN 472"):
+ ring_groove_dims("DIN 6799", 30.0)
+
+
+def test_the_centre_hole_lookup_is_refused_by_name_and_form_r_is_refused_outright():
+ with pytest.raises(ValueError) as excinfo:
+ centre_hole_dims("A", 2.5)
+ assert "DIN 332" in str(excinfo.value)
+ assert "centres.py" in str(excinfo.value)
+ with pytest.raises(ValueError) as excinfo:
+ centre_hole_dims("R", 2.5)
+ assert "form R" in str(excinfo.value)
+
+
+def test_the_oring_lookup_is_refused_by_name():
+ with pytest.raises(ValueError) as excinfo:
+ oring_groove_dims(3.53)
+ assert "ISO 3601-2" in str(excinfo.value)
+ assert "orings.py" in str(excinfo.value)
+
+
+def test_the_row_key_contracts_are_the_ones_the_drawing_code_reads():
+ assert UNDERCUT_ROW_KEYS == ("profile",)
+ assert RING_ROW_KEYS == ("m", "d2")
+ assert CENTRE_ROW_KEYS == ("d1", "d2", "d3", "t")
+ assert ORING_ROW_KEYS == ("b", "h")
+
+
+@pytest.mark.parametrize(
+ ("rows", "keys"),
+ [
+ (ROWS_E, ("profile",)),
+ (ROWS_F, ("profile",)),
+ (ROWS_471, ("m", "d2")),
+ (ROWS_472, ("m", "d2")),
+ (ROWS_A, ("d1", "d2", "t")),
+ (ROWS_B, ("d1", "d2", "d3", "t")),
+ (ROWS_ORING, ("b", "h")),
+ ],
+)
+def test_any_row_that_is_ever_added_must_carry_every_key_the_drawing_code_reads(rows, keys):
+ """Empty today; this gate fires the moment a row lands without a column."""
+ for size, row in rows.items():
+ missing = [key for key in keys if key not in row]
+ assert not missing, f"row {size} is missing {missing}"
diff --git a/tests/test_mech_threads.py b/tests/test_mech_threads.py
new file mode 100644
index 0000000..e42c761
--- /dev/null
+++ b/tests/test_mech_threads.py
@@ -0,0 +1,187 @@
+"""ISO 261 pitches, ISO 724 basic diameters and the ISO 6410 representation.
+
+Provenance: every asserted pitch is a value the module transcribed from
+ISO 261:2022; every asserted basic diameter is a published ISO 724 value that
+the ISO 68-1 formula has to reproduce exactly. The coverage refusals are
+asserted at both ends, because a size outside the transcribed table must be
+refused by name and never interpolated.
+"""
+
+from __future__ import annotations
+
+import math
+
+import pytest
+
+from engineering.mech.primitives import Arc, Line
+from engineering.mech.standards import lookup
+from engineering.mech.standards.threads import (
+ COARSE,
+ DIAMETERS,
+ FINE,
+ MINOR_RATIO,
+ basic_minor_diameter,
+ basic_pitch_diameter,
+ parse_designation,
+ pitch_for,
+ thread_axial_prims,
+ thread_end_prims,
+)
+
+
+@pytest.mark.parametrize(
+ ("d", "pitch"),
+ [
+ (1.6, 0.35),
+ (2.0, 0.40),
+ (3.0, 0.50),
+ (5.0, 0.80),
+ (6.0, 1.00),
+ (8.0, 1.25),
+ (10.0, 1.50),
+ (12.0, 1.75),
+ (14.0, 2.00),
+ (16.0, 2.00),
+ (20.0, 2.50),
+ (24.0, 3.00),
+ (30.0, 3.50),
+ (33.0, 3.50),
+ (36.0, 4.00),
+ (42.0, 4.50),
+ (48.0, 5.00),
+ (56.0, 5.50),
+ (64.0, 6.00),
+ ],
+)
+def test_iso261_coarse_pitches_are_the_transcribed_values(d, pitch):
+ assert COARSE[d] == pitch
+
+
+def test_the_transcribed_diameters_are_first_choice_plus_six_second_choice():
+ assert DIAMETERS[0] == 1.6
+ assert DIAMETERS[-1] == 64.0
+ assert {14.0, 18.0, 22.0, 27.0, 33.0, 39.0} <= set(DIAMETERS)
+ # third-choice diameters are deliberately absent
+ assert 7.0 not in DIAMETERS
+ assert 9.0 not in DIAMETERS
+ assert 3.5 not in DIAMETERS
+
+
+@pytest.mark.parametrize(
+ ("d", "pitches"),
+ [
+ (8.0, (1.0, 0.75)),
+ (10.0, (1.25, 1.0, 0.75)),
+ (12.0, (1.5, 1.25, 1.0)),
+ (16.0, (1.5, 1.0)),
+ (20.0, (2.0, 1.5, 1.0)),
+ (24.0, (2.0, 1.5, 1.0)),
+ (36.0, (3.0, 2.0, 1.5)),
+ (64.0, (4.0, 3.0, 2.0, 1.5)),
+ ],
+)
+def test_iso261_fine_pitch_series(d, pitches):
+ assert FINE[d] == pitches
+
+
+def test_a_bare_designation_resolves_to_the_coarse_pitch():
+ assert parse_designation("M20") == {
+ "designation": "M20",
+ "d": 20.0,
+ "pitch": 2.5,
+ "series": "coarse",
+ "hand": "right",
+ }
+
+
+def test_a_fine_designation_resolves_and_keeps_its_hand():
+ parsed = parse_designation("M20x1.5-LH")
+ assert parsed["d"] == 20.0
+ assert parsed["pitch"] == 1.5
+ assert parsed["series"] == "fine"
+ assert parsed["hand"] == "left"
+
+
+def test_a_diameter_outside_the_transcribed_table_is_refused_by_name():
+ with pytest.raises(ValueError) as excinfo:
+ parse_designation("M7")
+ message = str(excinfo.value)
+ assert "ISO 261" in message
+ assert "M7" in message
+ with pytest.raises(ValueError, match="ISO 261"):
+ parse_designation("M70")
+
+
+def test_a_fine_pitch_that_is_not_in_the_series_is_refused_and_lists_the_series():
+ with pytest.raises(ValueError) as excinfo:
+ parse_designation("M20x1.25")
+ message = str(excinfo.value)
+ assert "M20" in message
+ assert "2.0" in message and "1.5" in message and "1.0" in message
+
+
+def test_a_malformed_designation_is_refused():
+ with pytest.raises(ValueError, match="designation"):
+ parse_designation("20mm")
+
+
+def test_pitch_for_matches_parse_designation():
+ assert pitch_for(20.0) == 2.5
+ assert pitch_for(20.0, 1.5) == 1.5
+ with pytest.raises(ValueError):
+ pitch_for(20.0, 1.25)
+
+
+@pytest.mark.parametrize(
+ ("d", "pitch", "d1", "d2"),
+ [
+ (20.0, 2.50, 17.294, 18.376),
+ (10.0, 1.50, 8.376, 9.026),
+ (8.0, 1.25, 6.647, 7.188),
+ (12.0, 1.75, 10.106, 10.863),
+ (6.0, 1.00, 4.917, 5.350),
+ ],
+)
+def test_iso68_1_reproduces_the_published_iso724_basic_diameters(d, pitch, d1, d2):
+ assert basic_minor_diameter(d, pitch) == pytest.approx(d1, abs=5e-4)
+ assert basic_pitch_diameter(d, pitch) == pytest.approx(d2, abs=5e-4)
+
+
+def test_the_registry_carries_the_pitch_table_and_refuses_outside_its_coverage():
+ assert lookup("ISO 261", 20.0)["pitch"] == 2.5
+ with pytest.raises(ValueError, match="ISO 261"):
+ lookup("ISO 261", 70.0)
+
+
+def test_iso6410_axial_representation_of_an_external_thread():
+ prims = thread_axial_prims(x0=10.0, length=30.0, d=20.0)
+ lines = [p for p in prims if isinstance(p, Line)]
+ assert len(prims) == 3 and len(lines) == 3
+ minor = MINOR_RATIO * 20.0 / 2.0
+ assert {round(line.p1[1], 9) for line in lines[:2]} == {round(minor, 9), round(-minor, 9)}
+ assert lines[0].p1 == (10.0, minor) and lines[0].p2 == (40.0, minor)
+ assert lines[1].p1 == (10.0, -minor) and lines[1].p2 == (40.0, -minor)
+ # the thread-length limit line runs the full major diameter
+ assert lines[2].p1 == (40.0, -10.0) and lines[2].p2 == (40.0, 10.0)
+ assert {line.role for line in lines} == {"visible"}
+
+
+def test_iso6410_axial_representation_of_an_internal_thread_is_hidden():
+ prims = thread_axial_prims(x0=0.0, length=20.0, d=12.0, internal=True)
+ assert {p.role for p in prims} == {"hidden"}
+ assert prims[0].p1 == (0.0, 6.0) and prims[0].p2 == (20.0, 6.0)
+
+
+def test_iso6410_end_view_is_three_quarters_of_a_circle():
+ (arc,) = thread_end_prims(center=(0.0, 0.0), d=20.0)
+ assert isinstance(arc, Arc)
+ assert arc.radius == pytest.approx(8.0)
+ assert (arc.end_deg - arc.start_deg) % 360.0 == pytest.approx(270.0)
+ assert arc.start_deg == pytest.approx(90.0)
+
+
+def test_the_end_view_gap_can_be_moved_and_stays_a_quarter():
+ (arc,) = thread_end_prims(center=(0.0, 0.0), d=20.0, gap_center_deg=135.0)
+ assert arc.start_deg == pytest.approx(180.0)
+ assert (arc.end_deg - arc.start_deg) % 360.0 == pytest.approx(270.0)
+ assert math.isclose(arc.radius, 8.0)
diff --git a/tests/test_mech_views.py b/tests/test_mech_views.py
new file mode 100644
index 0000000..168e19b
--- /dev/null
+++ b/tests/test_mech_views.py
@@ -0,0 +1,464 @@
+"""The orthographic view engine: silhouette, hidden lines, centre lines, layout.
+
+Everything is asserted as emitted primitive tuples - counts, roles, coordinates
+to 1e-9. The radial profile is the single calculation both the silhouette and
+(from Task 5) the section cut face come out of, so it is pinned first.
+"""
+
+from __future__ import annotations
+
+from collections import Counter
+
+import pytest
+
+from engineering.mech.features import (
+ AxialBore,
+ Chamfer,
+ CornerFillet,
+ CrossHole,
+ Fillet,
+ Hole,
+ Keyway,
+ ORingGroove,
+ RetainingGroove,
+)
+from engineering.mech.part import PrismaticPart, RevolvedPart, Segment
+from engineering.mech.primitives import Arc, Circle, Line
+from engineering.mech.views import (
+ PROJECTIONS,
+ VIEW_KINDS,
+ Band,
+ View,
+ build_view,
+ layout_origin,
+ radial_profile,
+)
+
+EPS = 1e-9
+
+
+def shaft(*features) -> RevolvedPart:
+ return RevolvedPart(
+ name="shaft",
+ segments=(Segment(20.0, 25.0), Segment(40.0, 30.0), Segment(15.0, 20.0)),
+ features=tuple(features),
+ )
+
+
+def plate(*features) -> PrismaticPart:
+ return PrismaticPart(
+ name="plate",
+ outline=((0.0, 0.0), (80.0, 0.0), (80.0, 50.0), (0.0, 50.0)),
+ thickness=8.0,
+ features=tuple(features),
+ )
+
+
+def roles(view: View, role: str):
+ return [p for p in view.prims if getattr(p, "role", None) == role]
+
+
+def coincident(view: View) -> dict:
+ """Geometries drawn more than once - exactly what ``_check_duplicate_entities`` flags."""
+ counts: Counter = Counter()
+ for prim in view.prims:
+ if isinstance(prim, Line):
+ counts[tuple(sorted((prim.p1, prim.p2)))] += 1
+ elif isinstance(prim, Circle):
+ counts[(prim.center, round(prim.radius, 9))] += 1
+ return {key: n for key, n in counts.items() if n > 1}
+
+
+def test_the_engine_declares_three_orthographic_kinds_and_two_projections():
+ # The three orthographic kinds come first; Task 5 appended the two cut kinds
+ # (tests/test_mech_sections.py pins the full tuple from the other side).
+ assert VIEW_KINDS[:3] == ("front", "side", "top")
+ assert VIEW_KINDS == ("front", "side", "top", "section", "detail")
+ assert PROJECTIONS == ("first", "third")
+
+
+# -- the radial profile ------------------------------------------------------
+
+
+def test_a_plain_shaft_has_one_band_per_segment():
+ bands = radial_profile(shaft())
+ assert bands == (
+ Band(0.0, 20.0, 0.0, 12.5, 12.5),
+ Band(20.0, 60.0, 0.0, 15.0, 15.0),
+ Band(60.0, 75.0, 0.0, 10.0, 10.0),
+ )
+
+
+def test_a_taper_is_carried_as_two_end_radii():
+ part = RevolvedPart(name="cone", segments=(Segment(20.0, 30.0, 0.0, 10.0),))
+ (band,) = radial_profile(part)
+ assert band.r_out0 == pytest.approx(15.0, abs=1e-6)
+ assert band.r_out1 == pytest.approx(5.0, abs=1e-6)
+
+
+def test_a_ring_groove_cuts_a_band_out_of_the_outer_profile():
+ groove = RetainingGroove(id="rg", x=30.0, d=30.0, m=1.6, d2=28.6)
+ bands = radial_profile(shaft(groove))
+ # the cut band is the one INSIDE the 30 mm segment whose outside is pulled
+ # back; segments 0 (d25) and 2 (d20) are simply thinner and are not cuts.
+ cut = [b for b in bands if 20.0 <= b.x0 and b.x1 <= 60.0 and b.r_out0 < 15.0 - EPS]
+ assert len(cut) == 1
+ assert cut[0].x0 == pytest.approx(29.2)
+ assert cut[0].x1 == pytest.approx(30.8)
+ assert cut[0].r_out0 == pytest.approx(14.3)
+
+
+def test_an_axial_bore_raises_the_inner_profile_over_its_depth():
+ bore = AxialBore(id="ab", at="start", diameter=8.0, depth=30.0)
+ bands = radial_profile(shaft(bore))
+ bored = [b for b in bands if b.r_in > 0.0]
+ assert [(b.x0, b.x1) for b in bored] == [(0.0, 20.0), (20.0, 30.0)]
+ assert {b.r_in for b in bored} == {4.0}
+
+
+# -- the revolved front view -------------------------------------------------
+
+
+def test_a_plain_shaft_front_view_is_a_mirrored_silhouette_on_an_axis():
+ view = build_view(shaft(), "front")
+ assert view.kind == "front"
+ axis = roles(view, "center")
+ assert len(axis) == 1
+ assert axis[0].p1[1] == 0.0 and axis[0].p2[1] == 0.0
+ assert axis[0].p1[0] < 0.0 and axis[0].p2[0] > 75.0
+ visible = roles(view, "visible")
+ # the +Y half-outline is 7 lines (left face, 3 cylinders, 2 steps, right face)
+ # and it is mirrored about the axis
+ assert len(visible) == 14
+ ys = sorted({round(line.p1[1], 6) for line in visible})
+ assert ys == [-15.0, -12.5, -10.0, 0.0, 10.0, 12.5, 15.0]
+ assert view.bbox == (pytest.approx(-7.5), -15.0, pytest.approx(82.5), 15.0)
+
+
+def test_a_bore_reaches_the_front_view_as_hidden_lines():
+ part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),))
+ view = build_view(part, "front")
+ hidden = roles(view, "hidden")
+ assert len(hidden) == 2
+ assert {round(line.p1[1], 6) for line in hidden} == {10.0, -10.0}
+ assert hidden[0].p1[0] == 0.0 and hidden[0].p2[0] == 30.0
+
+
+def test_a_chamfer_replaces_the_silhouette_corner_instead_of_being_drawn_over_it():
+ chamfer = Chamfer(id="c1", at="start", size=2.0)
+ view = build_view(shaft(chamfer), "front")
+ upper = [line for line in roles(view, "visible") if line.p1[1] > 0 or line.p2[1] > 0]
+ starts = {(round(line.p1[0], 6), round(line.p1[1], 6)) for line in upper}
+ assert (0.0, 10.5) in starts
+ assert (0.0, 12.5) not in starts
+ assert any(line.p1 == (0.0, 10.5) and line.p2 == (2.0, 12.5) for line in upper)
+
+
+def test_a_fillet_puts_a_tangent_arc_into_the_silhouette():
+ fillet = Fillet(id="f1", at="step:0", radius=2.0)
+ view = build_view(shaft(fillet), "front")
+ arcs = [p for p in view.prims if isinstance(p, Arc)]
+ assert len(arcs) == 2 # the fillet and its mirror
+ upper = next(a for a in arcs if a.center[1] > 0)
+ assert upper.center == (18.0, 14.5)
+ assert (upper.radius, upper.start_deg, upper.end_deg) == (2.0, 270.0, 360.0)
+
+
+def test_an_axisymmetric_feature_is_mirrored_and_a_keyway_is_not():
+ groove = RetainingGroove(id="rg", x=30.0, d=30.0, m=1.6, d2=28.6)
+ keyway = Keyway(id="kw", segment=1, length=20.0, offset=5.0)
+ view = build_view(shaft(groove, keyway), "front")
+ groove_lines = [
+ line for line in view.prims if isinstance(line, Line) and abs(abs(line.p1[1]) - 14.3) < 1e-6
+ ]
+ assert {line.p1[1] > 0 for line in groove_lines} == {True, False}
+ floor = 15.0 - 4.0 # DIN 6885 depth_shaft for a 30 mm shaft
+ keyway_lines = [
+ line for line in view.prims if isinstance(line, Line) and abs(line.p1[1] - floor) < 1e-6
+ ]
+ assert keyway_lines and all(line.p1[1] > 0 for line in keyway_lines)
+
+
+@pytest.mark.parametrize(
+ "groove",
+ [
+ RetainingGroove(id="rg", x=30.0, d=30.0, m=1.6, d2=28.6),
+ ORingGroove(id="or", x=40.0, cord=3.0, b=4.1, h=2.3),
+ ],
+ ids=["retaining", "oring"],
+)
+def test_a_groove_is_the_notch_in_the_silhouette_and_is_not_drawn_a_second_time(groove):
+ """The spec's rule is `grooves cut a notch in the silhouette`. The notch is
+ the radial profile's; the feature does not redraw it. Two coincident LINEs
+ are what ``drawing_critique(focus=None)`` reports as a duplicate, and
+ premium rule 7 needs that report empty before ``drawing_finalize``."""
+ view = build_view(shaft(groove), "front")
+ assert coincident(view) == {}
+ # the notch is still there, mirrored, once per side
+ floor = {round(abs(line.p1[1]), 6) for line in view.prims if isinstance(line, Line)}
+ assert (14.3 in floor) or (12.7 in floor)
+ assert view.omitted == ()
+
+
+def test_an_axial_bore_end_circle_is_drawn_once():
+ """``_end_circles`` reads the bore off the same radial profile the feature
+ edited, so the feature's own circle would be the second copy of it."""
+ view = build_view(shaft(AxialBore(id="ab", at="start", diameter=8.0, depth=30.0)), "side")
+ assert coincident(view) == {}
+ assert len([c for c in view.prims if isinstance(c, Circle) and abs(c.radius - 4.0) < EPS]) == 1
+
+
+def test_a_blind_bore_keeps_the_bottom_line_the_profile_cannot_draw():
+ """Suppression is `the profile already drew this`, not `this feature edits
+ the profile` - the blind bottom is not on the radial profile at all."""
+ view = build_view(shaft(AxialBore(id="ab", at="start", diameter=8.0, depth=30.0)), "front")
+ bottoms = [
+ line
+ for line in roles(view, "hidden")
+ if isinstance(line, Line) and abs(line.p1[0] - 30.0) < EPS and abs(line.p2[0] - 30.0) < EPS
+ ]
+ assert len(bottoms) == 1
+ assert {round(bottoms[0].p1[1], 6), round(bottoms[0].p2[1], 6)} == {-4.0, 4.0}
+
+
+def test_a_groove_straddling_a_step_leaves_one_closed_silhouette():
+ """The feature's own prims read a single surface radius for a span that has
+ two, so drawing them as well as the profile's notch put a groove floor
+ through solid material and left the +Y path with four odd-degree nodes.
+ An open path has exactly two: its two ends on the axis."""
+ part = shaft(RetainingGroove(id="rg", x=20.0, d=30.0, m=1.6, d2=28.6))
+ upper = [
+ line
+ for line in roles(build_view(part, "front"), "visible")
+ if isinstance(line, Line) and line.p1[1] >= 0.0 and line.p2[1] >= 0.0
+ ]
+ degree: Counter = Counter()
+ for line in upper:
+ degree[(round(line.p1[0], 6), round(line.p1[1], 6))] += 1
+ degree[(round(line.p2[0], 6), round(line.p2[1], 6))] += 1
+ assert sorted(node for node, n in degree.items() if n % 2) == [(0.0, 0.0), (75.0, 0.0)]
+ # nothing is drawn at the groove floor radius outside the band the profile cut
+ assert not [
+ line
+ for line in upper
+ if abs(line.p1[1] - 14.3) < EPS and abs(line.p2[1] - 14.3) < EPS and line.p1[0] < 20.0
+ ]
+
+
+# -- the revolved end view ---------------------------------------------------
+
+
+def test_the_end_view_hides_the_smaller_diameter_and_draws_the_step_face_continuous():
+ """Ground truth by ray cast along the axis of the same solid of revolution
+ (viewer at +X, probe just inside each edge):
+
+ edge r=10 at x=75 -> first surface at x=75 -> VISIBLE
+ edge r=15 at x=60 -> first surface at x=60 -> VISIBLE
+ edge r=12.5 at x=20 -> first surface at x=60 -> HIDDEN
+
+ The annular step face at x=60 faces the observer and nothing at projected
+ radius 15-eps stands in front of it, so the 30 mm circle is continuous. The
+ 25 mm circle is the one that is genuinely covered, and it is still *drawn* -
+ dashed, not dropped.
+ """
+ view = build_view(shaft(), "side", side="right")
+ circles = [p for p in view.prims if isinstance(p, Circle)]
+ radii = {(round(c.radius, 6), c.role) for c in circles}
+ assert radii == {(10.0, "visible"), (15.0, "visible"), (12.5, "hidden")}
+ assert len([p for p in view.prims if getattr(p, "role", None) == "center"]) == 2
+
+
+def test_looking_from_the_left_reverses_which_circle_is_visible():
+ """From -X the near end is the 25 mm face; the 20 mm end is behind the 30 mm body."""
+ view = build_view(shaft(), "side", side="left")
+ circles = [p for p in view.prims if isinstance(p, Circle)]
+ radii = {(round(c.radius, 6), c.role) for c in circles}
+ assert radii == {(12.5, "visible"), (15.0, "visible"), (10.0, "hidden")}
+
+
+def test_a_feature_circle_at_the_far_end_is_hidden_behind_the_larger_body():
+ """A chamfer at x=0 seen from +X sits behind the 30 mm band, so its circle
+ is dashed; seen from -X nothing is in front of it and it is continuous."""
+ part = shaft(Chamfer(id="c1", at="start", size=2.0))
+ right = {
+ (round(c.radius, 6), c.role)
+ for c in build_view(part, "side", side="right").prims
+ if isinstance(c, Circle)
+ }
+ left = {
+ (round(c.radius, 6), c.role)
+ for c in build_view(part, "side", side="left").prims
+ if isinstance(c, Circle)
+ }
+ assert (10.5, "hidden") in right
+ assert (10.5, "visible") in left
+
+
+def test_a_bore_that_widens_behind_a_narrower_one_is_hidden():
+ """Looking into the narrow end you see the small bore; the wider bore behind
+ it is covered by the wall of the narrow one. From the other end, both are
+ seen down the widening hole."""
+ part = RevolvedPart(
+ name="stepped-bore",
+ segments=(Segment(20.0, 40.0, 10.0), Segment(20.0, 40.0, 20.0)),
+ )
+ right = {
+ (round(c.radius, 6), c.role)
+ for c in build_view(part, "side", side="right").prims
+ if isinstance(c, Circle)
+ }
+ left = {
+ (round(c.radius, 6), c.role)
+ for c in build_view(part, "side", side="left").prims
+ if isinstance(c, Circle)
+ }
+ assert (10.0, "visible") in right and (5.0, "visible") in right
+ assert (5.0, "visible") in left and (10.0, "hidden") in left
+
+
+def test_a_revolved_top_view_is_refused_because_it_repeats_the_front():
+ with pytest.raises(ValueError, match="identical to its front view"):
+ build_view(shaft(), "top")
+
+
+# -- the prismatic views -----------------------------------------------------
+
+
+def test_a_prismatic_front_view_is_the_closed_outline():
+ view = build_view(plate(), "front")
+ lines = roles(view, "visible")
+ assert len(lines) == 4
+ assert view.bbox == (0.0, 0.0, 80.0, 50.0)
+
+
+def test_a_corner_fillet_replaces_an_outline_vertex_with_an_arc():
+ view = build_view(plate(CornerFillet(id="cf", vertex=1, radius=10.0)), "front")
+ arcs = [p for p in view.prims if isinstance(p, Arc)]
+ # the substituted arc carries role "visible" too, so the straight edges are
+ # counted by type: 4 outline edges remain, two of them shortened.
+ lines = [p for p in roles(view, "visible") if isinstance(p, Line)]
+ assert len(arcs) == 1
+ assert arcs[0].center == (pytest.approx(70.0), pytest.approx(10.0))
+ assert len(lines) == 4
+ assert not any(abs(line.p1[0] - 80.0) < EPS and abs(line.p1[1]) < EPS for line in lines)
+
+
+def test_a_prismatic_side_view_is_the_thickness_rectangle_with_a_hidden_line_per_hole():
+ # y=10, deliberately OFF the y=25 mirror axis of the 0..50 plate: a hole on
+ # the axis cannot tell a right-hand view from a left-hand one.
+ hole = Hole(id="h1", x=20.0, y=10.0, diameter=10.0)
+ view = build_view(plate(hole), "side")
+ visible = roles(view, "visible")
+ hidden = roles(view, "hidden")
+ assert len(visible) == 4
+ assert view.bbox == (0.0, 0.0, 50.0, 8.0)
+ assert len(hidden) == 2
+ assert {round(line.p1[0], 6) for line in hidden} == {5.0, 15.0}
+
+
+def test_a_prismatic_left_side_view_is_the_right_view_mirrored():
+ """An observer at -X sees screen-right = -Y, so the hole at y=10 in a plate
+ spanning y 0..50 lands at u = 50-15 .. 50-5. Accepting ``side='left'`` and
+ returning the right-hand view puts every feature on the wrong side of the
+ sheet with nothing in the drawing to reveal it."""
+ hole = Hole(id="h1", x=20.0, y=10.0, diameter=10.0)
+ part = plate(hole)
+ right = {
+ round(line.p1[0], 6) for line in roles(build_view(part, "side", side="right"), "hidden")
+ }
+ left = {round(line.p1[0], 6) for line in roles(build_view(part, "side", side="left"), "hidden")}
+ assert right == {5.0, 15.0}
+ assert left == {35.0, 45.0}
+ # the thickness rectangle is its own mirror, so only the features move
+ assert build_view(part, "side", side="left").bbox == (0.0, 0.0, 50.0, 8.0)
+
+
+def test_a_prismatic_top_view_is_always_seen_from_above():
+ """``side`` names the end an *end* view is taken from; the top view is not
+ one, so it must not move with it."""
+ part = plate(Hole(id="h1", x=20.0, y=10.0, diameter=10.0))
+ from_right = {
+ round(line.p1[0], 6) for line in roles(build_view(part, "top", side="right"), "hidden")
+ }
+ from_left = {
+ round(line.p1[0], 6) for line in roles(build_view(part, "top", side="left"), "hidden")
+ }
+ assert from_right == from_left == {15.0, 25.0}
+
+
+def test_a_prismatic_top_view_projects_the_other_axis():
+ hole = Hole(id="h1", x=20.0, y=25.0, diameter=10.0)
+ view = build_view(plate(hole), "top")
+ assert view.bbox == (0.0, 0.0, 80.0, 8.0)
+ hidden = roles(view, "hidden")
+ assert {round(line.p1[0], 6) for line in hidden} == {15.0, 25.0}
+
+
+# -- the honesty rule --------------------------------------------------------
+
+
+def test_a_feature_that_projects_into_the_view_is_not_reported_as_omitted():
+ side = build_view(shaft(Chamfer(id="c1", at="start", size=2.0)), "side")
+ assert side.omitted == ()
+ front = build_view(shaft(CrossHole(id="xh", x=30.0, diameter=6.0)), "front")
+ assert front.omitted == ()
+ bore = build_view(shaft(AxialBore(id="ab", at="start", diameter=8.0, depth=12.0)), "front")
+ assert bore.omitted == ()
+
+
+def test_a_bend_line_is_omitted_from_a_side_view_with_a_reason():
+ from engineering.mech.features import BendLine
+
+ bend = BendLine(id="b1", p1=(0.0, 25.0), p2=(80.0, 25.0), angle=90.0, inside_radius=2.0)
+ view = build_view(plate(bend), "side")
+ assert len(view.omitted) == 1
+ assert view.omitted[0]["feature"] == "b1"
+ assert "side" in view.omitted[0]["reason"]
+
+
+def test_the_view_collects_the_dimension_intents_of_its_own_features():
+ keyway = Keyway(id="kw", segment=1, length=20.0)
+ view = build_view(shaft(keyway), "front")
+ assert [intent.feature for intent in view.dims] == ["kw"]
+
+
+def test_an_unknown_view_kind_is_refused_by_name():
+ with pytest.raises(ValueError, match="front"):
+ build_view(shaft(), "isometric")
+
+
+# -- projection layout -------------------------------------------------------
+
+
+def test_first_angle_puts_the_right_hand_view_on_the_left_and_the_top_below():
+ front = build_view(shaft(), "front")
+ end = build_view(shaft(), "side")
+ left = layout_origin(front, "side", side="right", projection="first", child=end)
+ width = end.bbox[2] - end.bbox[0]
+ height = end.bbox[3] - end.bbox[1]
+ assert left[0] == pytest.approx(front.bbox[0] - 20.0 - width)
+ assert left[1] == pytest.approx((front.bbox[1] + front.bbox[3]) / 2.0 - height / 2.0)
+ below = layout_origin(front, "top", projection="first", child=end)
+ assert below[1] == pytest.approx(front.bbox[1] - 20.0 - height)
+
+
+def test_third_angle_flips_both_placements():
+ front = build_view(shaft(), "front")
+ end = build_view(shaft(), "side")
+ right = layout_origin(front, "side", side="right", projection="third", child=end)
+ assert right[0] == pytest.approx(front.bbox[2] + 20.0)
+ above = layout_origin(front, "top", projection="third", child=end)
+ assert above[1] == pytest.approx(front.bbox[3] + 20.0)
+
+
+def test_without_a_child_the_layout_assumes_the_parent_size():
+ front = build_view(shaft(), "front")
+ width = front.bbox[2] - front.bbox[0]
+ left = layout_origin(front, "side", side="right", projection="first")
+ assert left[0] == pytest.approx(front.bbox[0] - 20.0 - width)
+
+
+def test_an_unknown_projection_is_refused():
+ front = build_view(shaft(), "front")
+ with pytest.raises(ValueError, match="first"):
+ layout_origin(front, "side", projection="second")
diff --git a/tests/test_tool_packs.py b/tests/test_tool_packs.py
index fc370c2..e423f98 100644
--- a/tests/test_tool_packs.py
+++ b/tests/test_tool_packs.py
@@ -86,7 +86,16 @@ LEAN_SETTINGS_ESSENTIALS = {
"textstyle_set_current",
}
-ALL_PACKS = ["core", "pid", "settings"]
+ALL_PACKS = ["core", "pid", "settings", "mech"]
+ALL_PACKS_SORTED = sorted(ALL_PACKS)
+MECH_TOOLS = {
+ "mech_part_draw",
+ "mech_view_add",
+ "mech_dimension_part",
+ "mech_hole_pattern",
+ "mech_part_from_spec",
+ "mech_part_inspect",
+}
@pytest_asyncio.fixture(autouse=True)
@@ -97,8 +106,9 @@ async def _restore(monkeypatch):
def test_pack_registry_names_real_tools_and_only_them():
- assert server.TOOL_PACK_NAMES == ("core", "pid", "settings")
+ assert server.TOOL_PACK_NAMES == ("core", "pid", "settings", "mech")
assert server.PACK_TOOL_NAMES["pid"] == frozenset(PID_TOOLS)
+ assert server.PACK_TOOL_NAMES["mech"] == frozenset(MECH_TOOLS)
assert server.PACK_TOOL_NAMES["settings"] == frozenset(SETTINGS_TOOLS)
assert len(SETTINGS_TOOLS) == 22
assert not (server.PACK_TOOL_NAMES["settings"] & server.PACK_TOOL_NAMES["pid"])
@@ -137,7 +147,7 @@ async def test_all_is_the_default_and_enables_every_pack(monkeypatch):
info = await server._apply_tool_profile("full")
disabled = set(info["disabled_tools"])
assert not ((PID_TOOLS | SETTINGS_TOOLS) & disabled)
- assert info["tool_packs"]["enabled"] == ALL_PACKS
+ assert info["tool_packs"]["enabled"] == ALL_PACKS_SORTED
async def test_unknown_pack_is_ignored_with_a_warning_and_core_cannot_be_dropped(
@@ -184,7 +194,8 @@ async def test_the_three_track_e_sections_file_under_their_own_groups():
assert set(groups["styles"]) == STYLE_TOOLS
assert set(groups["page_setup"]) == PAGE_SETUP_TOOLS
assert set(groups["environment"]) == SETTINGS_TOOLS
- assert len(groups) == 23
+ assert set(groups["mech"]) == MECH_TOOLS
+ assert len(groups) == 24
async def test_system_about_reports_packs():
diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py
index 4b55428..36edea0 100644
--- a/tests/test_tool_registry.py
+++ b/tests/test_tool_registry.py
@@ -527,8 +527,15 @@ async def test_tool_group_sizes_are_unchanged():
merge task tagged them, the four groups had filed those 28 tools under
`layouts` / `templates` / `drawing` / `layers` / `view` / `system` by
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.
+ when the section tags landed.) `mech` appeared (0 -> 6) when v1.6's
+ SECTION 21 opened with the part model and its view engine
+ (`mech_part_draw` / `mech_view_add` / `mech_dimension_part` /
+ `mech_hole_pattern` / `mech_part_from_spec` / `mech_part_inspect`); the
+ `mech` tag is ranked directly after `pid` in `_GROUP_TAG_PRIORITY`, so
+ those tools file under `mech` rather than under their secondary `create` /
+ `dimension` / `query` tags. This snapshot is branch-local: the merge task
+ recomputes it once all four track B+G branches have landed. Every other
+ number here has been unchanged since the snapshot was taken.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
assert sizes == {
@@ -545,6 +552,7 @@ async def test_tool_group_sizes_are_unchanged():
"environment": 22,
"layers": 14,
"layouts": 12,
+ "mech": 6,
"page_setup": 6,
"pid": 9,
"premium": 12,
@@ -556,4 +564,4 @@ async def test_tool_group_sizes_are_unchanged():
"validation": 1,
"view": 4,
}
- assert sum(sizes.values()) == 204
+ assert sum(sizes.values()) == 210