merge: tracks B+G group M

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-23 12:21:09 +03:00
co-authored by Claude Opus 5.5
27 changed files with 10254 additions and 17 deletions
+3 -3
View File
@@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
</div>
> **v1.5 release snapshot:** 204 tools · 8 resources · 5 prompt templates · 3284 collected tests.
> 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 |
<sub>204 tools in 23 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools and the 22 environment tools (<code>TOOL_PACKS=core,settings</code> keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
<sub>210 tools in 24 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools and the 22 environment tools (<code>TOOL_PACKS=core,settings</code> keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
+90
View File
@@ -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",
),
),
}
+87 -2
View File
@@ -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",
+166 -2
View File
@@ -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",
]
+650
View File
@@ -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=<ISO 286 code> 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=<ISO 286 code> 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)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+359
View File
@@ -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,
}
+11 -2
View File
@@ -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 "
+67
View File
@@ -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)})."
)
+54
View File
@@ -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)})."
)
+60
View File
@@ -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)}).")
+250
View File
@@ -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"),)
+67
View File
@@ -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)})."
)
File diff suppressed because it is too large Load Diff
+445 -1
View File
@@ -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 ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
+514
View File
@@ -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")
+494
View File
@@ -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]}]})
+753
View File
@@ -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
+216
View File
@@ -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)
+473
View File
@@ -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]}
+13
View File
@@ -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)
+160
View File
@@ -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}"
+187
View File
@@ -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)
+464
View File
@@ -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")
+15 -4
View File
@@ -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():
+11 -3
View File
@@ -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