diff --git a/README.md b/README.md index 6d056a1..a632df5 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 204 tools · 8 resources · 5 prompt templates · 3284 collected tests. -> 204 is the **registered** count; a default install advertises 199 over `tools/list`, +> **v1.5 release snapshot:** 210 tools · 8 resources · 5 prompt templates · 3284 collected tests. +> 210 is the **registered** count; a default install advertises 205 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. ## Why this exists @@ -117,7 +117,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor | Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) | | Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks | -204 tools in 23 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. +210 tools in 24 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. **Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy. diff --git a/discovery/aliases.py b/discovery/aliases.py index f139c03..5f0df86 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -1859,6 +1859,96 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "save a detail view", ), ), + # ── Mechanical parts (SECTION 21, tracks B+G group M) ─────────────────── + "mech_part_draw": ToolAliases( + acad=(), + synonyms=( + "draw a shaft", + "stepped shaft", + "draw a machine part", + "turned part", + "draw a plate", + "flange", + "part from a segment list", + "shaft with a keyway", + "mil", + "mil ciz", + "kademeli mil", + "kama", + "pah", + "plaka", + "flans", + ), + ), + "mech_view_add": ToolAliases( + acad=("SECTIONPLANE",), + synonyms=( + "add a section view", + "section a-a", + "cross section", + "half section", + "offset section", + "detail view", + "blown-up detail", + "end view", + "side view", + "another view of the part", + "kesit", + "kesit al", + "gorunus", + "yan gorunus", + "detay", + ), + ), + "mech_dimension_part": ToolAliases( + acad=(), + synonyms=( + "dimension the part", + "put the dimensions on", + "chain dimensions", + "baseline dimensions", + "ordinate dimensions", + "hole table", + "iso 286 fit on a dimension", + "h7 bore", + "olculendir", + "olcu ver", + "tolerans", + ), + ), + "mech_hole_pattern": ToolAliases( + acad=("ARRAY",), + synonyms=( + "bolt circle", + "hole pattern", + "hole array", + "pitch circle of holes", + "grid of holes", + "delik deseni", + "civata dairesi", + "delik aynasi", + ), + ), + "mech_part_from_spec": ToolAliases( + acad=(), + synonyms=( + "draw the whole sheet", + "several parts at once", + "mechanical sheet from a spec", + "batch draw parts", + "parca sayfasi", + ), + ), + "mech_part_inspect": ToolAliases( + acad=(), + synonyms=( + "what part is this", + "read the part model back", + "part features", + "which views were drawn", + "parca bilgisi", + ), + ), } diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index c5c16f1..55bb8ab 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -3,10 +3,10 @@ "schema_version": 1, "package_version": "1.5.1", "totals": { - "tools": 204, + "tools": 210, "resources": 8, "prompts": 5, - "groups": 23 + "groups": 24 }, "groups": { "analysis": 12, @@ -22,6 +22,7 @@ "environment": 22, "layers": 14, "layouts": 12, + "mech": 6, "page_setup": 6, "pid": 9, "premium": 12, @@ -1968,6 +1969,90 @@ "readOnlyHint": false } }, + { + "name": "mech_dimension_part", + "group": "mech", + "cost": "mutate", + "summary": "Dimension a drawn part: ISO 129 dimensions, ISO 286 fits, a hole table.", + "tags": [ + "dimension", + "mech" + ], + "annotations": { + "title": "Mechanical: Dimension Part", + "destructiveHint": false + } + }, + { + "name": "mech_hole_pattern", + "group": "mech", + "cost": "mutate", + "summary": "Draw a linear, grid or polar hole pattern with centre marks on any drawing.", + "tags": [ + "create", + "mech" + ], + "annotations": { + "title": "Mechanical: Hole Pattern", + "destructiveHint": false + } + }, + { + "name": "mech_part_draw", + "group": "mech", + "cost": "mutate", + "summary": "Draw a machine part from a segment list or an outline plus typed features.", + "tags": [ + "create", + "mech" + ], + "annotations": { + "title": "Mechanical: Draw Part", + "destructiveHint": false + } + }, + { + "name": "mech_part_from_spec", + "group": "mech", + "cost": "mutate", + "summary": "Draw a whole mechanical sheet - several parts and their views - in one transaction.", + "tags": [ + "create", + "mech" + ], + "annotations": { + "title": "Mechanical: Sheet From Spec", + "destructiveHint": false + } + }, + { + "name": "mech_part_inspect", + "group": "mech", + "cost": "read", + "summary": "Read the part model back off the drawing: segments, features, views, omissions.", + "tags": [ + "mech", + "query" + ], + "annotations": { + "title": "Mechanical: Inspect Part", + "readOnlyHint": true + } + }, + { + "name": "mech_view_add", + "group": "mech", + "cost": "mutate", + "summary": "Add another view of a drawn part: side, top, section (full/half/offset) or detail.", + "tags": [ + "create", + "mech" + ], + "annotations": { + "title": "Mechanical: Add View", + "destructiveHint": false + } + }, { "name": "mleaderstyle_create", "group": "styles", diff --git a/engineering/mech/__init__.py b/engineering/mech/__init__.py index 157ce19..2afd332 100644 --- a/engineering/mech/__init__.py +++ b/engineering/mech/__init__.py @@ -6,6 +6,73 @@ and alphabetical — keep every side's names. from __future__ import annotations +from .dimension import ( + DIM_STYLES, + REFERENCE_FORMAT, + TEXT_WIDTH_FACTOR, + TOL_KEYS, + dimension_intents, + hole_table_rows, + intent_text, + is_reference, + layout_dimensions, + measured_value, + redundant_linear, + resolve_tolerance, + segment_key, + text_rects, +) +from .draw import ( + ANCHOR_LAYER, + add_view, + dimension_part, + draw_from_spec, + draw_hole_pattern, + draw_part, + draw_prims, + read_part, +) +from .features import ( + FEATURE_KINDS, + AxialBore, + BendLine, + CentreHole, + Chamfer, + CornerChamfer, + CornerFillet, + CrossHole, + Feature, + Fillet, + GearTeeth, + Hole, + HoleCircle, + HolePattern, + Keyway, + ORingGroove, + Pocket, + RetainingGroove, + Slot, + Thread, + Undercut, + feature_from_dict, + mirror_about_x, +) +from .part import ( + PART_KINDS, + PrismaticPart, + RevolvedPart, + Segment, + build_part, + inner_radius_at, + outer_radius_at, + outline_bbox, + part_length, + part_max_diameter, + part_to_dict, + point_in_outline, + segment_bounds, + validate_part, +) from .primitives import ( ROLE_LAYER, ROLES, @@ -26,24 +93,121 @@ from .primitives import ( scale, translate, ) +from .views import ( + CIRCLE_SEGMENTS, + PROJECTIONS, + SECTION_PLANE, + SECTION_STYLES, + VIEW_KINDS, + Band, + View, + build_view, + cut_loops, + detail_marker_prims, + detail_view, + layout_origin, + normalise_plane, + outline_prims, + radial_profile, + section_profile_prims, + section_view, + silhouette_prims, +) __all__ = [ - "ROLES", - "ROLE_LAYER", + "ANCHOR_LAYER", "Arc", + "AxialBore", + "Band", + "BendLine", + "CIRCLE_SEGMENTS", + "CentreHole", + "Chamfer", "Circle", + "CornerChamfer", + "CornerFillet", + "CrossHole", + "DIM_STYLES", "DimIntent", + "FEATURE_KINDS", + "Feature", + "Fillet", + "GearTeeth", "HatchArea", + "Hole", + "HoleCircle", + "HolePattern", + "Keyway", "Line", + "ORingGroove", + "PART_KINDS", + "PROJECTIONS", + "Pocket", "Poly", "Prim", + "PrismaticPart", "Pt", + "REFERENCE_FORMAT", + "ROLES", + "ROLE_LAYER", + "RetainingGroove", + "RevolvedPart", "Role", + "SECTION_PLANE", + "SECTION_STYLES", + "Segment", + "Slot", + "TEXT_WIDTH_FACTOR", + "TOL_KEYS", "Text", + "Thread", + "Undercut", + "VIEW_KINDS", + "View", + "add_view", "bbox", + "build_part", + "build_view", + "cut_loops", + "detail_marker_prims", + "detail_view", + "dimension_intents", + "dimension_part", + "draw_from_spec", + "draw_hole_pattern", + "draw_part", + "draw_prims", + "feature_from_dict", + "hole_table_rows", + "inner_radius_at", + "intent_text", + "is_reference", "layer_for", + "layout_dimensions", + "layout_origin", + "measured_value", + "mirror_about_x", + "normalise_plane", + "outer_radius_at", + "outline_bbox", + "outline_prims", + "part_length", + "part_max_diameter", + "part_to_dict", + "point_in_outline", "points_of", + "radial_profile", + "read_part", + "redundant_linear", + "resolve_tolerance", "rotate", "scale", + "section_profile_prims", + "section_view", + "segment_bounds", + "segment_key", + "silhouette_prims", + "text_rects", "translate", + "validate_part", ] diff --git a/engineering/mech/dimension.py b/engineering/mech/dimension.py new file mode 100644 index 0000000..4e532b1 --- /dev/null +++ b/engineering/mech/dimension.py @@ -0,0 +1,650 @@ +"""Dimension intents for a view, laid out so no two texts overlap. + +Pure: no backend, no async, no DXF. The features declare what they want +dimensioned; this module adds the part's own sizes, removes the duplicates +(ISO 129-1: each dimension appears once), resolves ISO 286 fits and ISO 129 +tolerances through the modules that already own them, and then *places* the +texts. + +The placement is not a matter of taste. Every intent's text is measured into a +rectangle by :func:`text_rects`, and an intent is put on the lowest free level +whose rectangle does not overlap one already placed on that level. Two +rectangles are disjoint when they are separated along *either* axis, so the +layout separates them along both in turn: within a level, along the axis the +level runs in; between levels, along the axis the levels stack in. The second +one is why the level pitch is clamped to the extent of the text *in the +stacking direction* - which is the text height for the row of dimensions under +the view, and the text **width** for the column of diameters beside it. A pitch +clamped only to the height would let two 9.45 mm wide diameter labels sit +8 mm apart on the same centreline, overlapping by 1.45 mm. + +The test asserts pairwise disjointness with the same helper, which makes "no +overlapping dimension text" measured rather than claimed - and it is the same +property the repository's `dim_overlap` critique focus checks on the finished +drawing. + +Three rules the module enforces rather than assumes: + +*ISO 286 fits reach the pipeline.* :func:`dimension_intents` takes +``fits={key: code}`` and stamps the code onto the intent it names, so a bearing +seat gets its k6 without anyone hand-building a ``DimIntent``. The part's own +diameters are addressable by segment (``"segment[1]"``, ``"segment[1].bore"``); +a feature's are addressable by its id. An unknown key is refused by name - a fit +that lands nowhere is a manufacturing requirement lost in silence. + +*An explicit tolerance is validated as a whole.* ``DimIntent.tol`` is +``{"mode", "upper", "lower"}`` and nothing else. ``mode`` is required, because +``build_dim_override`` reads a missing mode as ``"none"`` and returns an empty +override: a +-0.05 with no ``mode`` used to resolve, report its own numbers back +and then draw a dimension carrying no tolerance at all. + +*ISO 129-1: each measurement appears once.* A closed chain plus the overall +states the overall twice. :func:`redundant_linear` finds the span that the +others tile end to end, and :func:`dimension_intents` marks it as an auxiliary +(reference) dimension - ``(<>)``, drawn in parentheses - rather than emitting a +fourth plain DIMENSION that contradicts nothing and adds nothing. +""" + +from __future__ import annotations + +import math + +from engineering.fits import fit_lookup, parse_fit_code +from engineering.mech.features import Hole +from engineering.mech.part import ( + PrismaticPart, + RevolvedPart, + outline_bbox, + part_length, + segment_bounds, +) +from engineering.mech.primitives import DimIntent +from engineering.tolerances import TOL_MODES, build_dim_override + +_EPS = 1e-9 + +DIM_STYLES: tuple[str, ...] = ("chain", "baseline", "ordinate") + +#: Mean glyph width as a fraction of the text height, for measuring a label +#: into a rectangle. Deliberately generous: over-estimating the width can only +#: push two dimensions further apart. +TEXT_WIDTH_FACTOR = 0.7 + +#: Padding added around a measured label, in text heights. +TEXT_PADDING = 0.6 + +#: How an auxiliary (reference) dimension is written - ISO 129-1 puts a +#: measurement that is already given elsewhere in parentheses. ``<>`` is the +#: DIMENSION text placeholder for the measured value, on both engines. +REFERENCE_FORMAT = "(<>)" + +#: The only keys ``DimIntent.tol`` accepts. +TOL_KEYS: frozenset[str] = frozenset({"mode", "upper", "lower"}) + +#: Tolerance modes that write no deviation, so carrying upper/lower with them +#: would silently discard the numbers. +_NO_DEVIATION_MODES: frozenset[str] = frozenset({"none", "basic"}) + + +def measured_value(intent: DimIntent) -> float: + """What the dimension measures, in drawing units.""" + p1, p2 = intent.p1, intent.p2 + if intent.kind == "linear": + dx, dy = abs(p2[0] - p1[0]), abs(p2[1] - p1[1]) + return dx if dx >= dy else dy + return math.dist(p1, p2) + + +def _format(value: float) -> str: + return f"{round(value, 3):g}" + + +def _with_fit(text: str, fit: str | None) -> str: + """Append an ISO 286 code to a dimension's text, once. + + A feature that already names itself (a hole's ``⌀6``) keeps its own text and + gains the code; without this the deviations would be written and the code + the inspector reads would not be on the sheet. + """ + code = (fit or "").strip() + if not code or code in text: + return text + return f"{text} {code}" + + +def _measured_text(intent: DimIntent) -> str: + if intent.kind == "diameter": + return f"⌀{_format(measured_value(intent))}" + if intent.kind == "radius": + return f"R{_format(measured_value(intent))}" + return _format(measured_value(intent)) + + +def intent_text(intent: DimIntent) -> str: + """The text a dimension will carry, as the engine will render it. + + ``<>`` is the DIMENSION placeholder for the measured value, so an override + of ``"(<>)"`` reads ``"(75)"`` and a resolved fit's ``"<> H7"`` reads + ``"⌀40 H7"``. Expanding it here is what makes :func:`text_rects` measure the + string that is actually drawn - a label measured as ``"<> H7"`` and drawn as + ``"⌀40 H7"`` is a layout computed against the wrong width. + """ + measured = _measured_text(intent) + base = intent.text_override.replace("<>", measured) if intent.text_override else measured + # The fit code is appended to the text by `resolve_tolerance`; the rectangle + # has to reserve room for it before the layout runs. + return _with_fit(base, intent.fit) + + +def is_reference(intent: DimIntent) -> bool: + """True for an auxiliary (reference) dimension - ISO 129-1 parentheses.""" + override = intent.text_override or "" + return override.startswith("(") and override.endswith(")") + + +def _text_size(intent: DimIntent, height: float) -> tuple[float, float]: + """``(width, height)`` of the rectangle this intent's label occupies.""" + label = intent_text(intent) + return ( + (len(label) * TEXT_WIDTH_FACTOR + TEXT_PADDING) * height, + height * (1.0 + TEXT_PADDING), + ) + + +def text_rects(intents, *, height: float = 3.5): + """``(x0, y0, x1, y1)`` per placed intent, from its text and its ``text_at``.""" + rects = [] + for intent in intents: + if intent.text_at is None: + raise ValueError( + f"text_rects: intent {intent.feature or intent.kind!r} has no text_at; " + "run layout_dimensions first." + ) + width, tall = _text_size(intent, height) + cx, cy = float(intent.text_at[0]), float(intent.text_at[1]) + rects.append((cx - width / 2.0, cy - tall / 2.0, cx + width / 2.0, cy + tall / 2.0)) + return tuple(rects) + + +# -- tolerances -------------------------------------------------------------- + + +def _explicit_tol(intent: DimIntent) -> tuple[str, float | None, float | None]: + """Validate ``intent.tol`` as a whole and return ``(mode, upper, lower)``. + + ``build_dim_override`` alone is not enough: it defaults a missing mode to + ``"none"`` and answers with an empty override, so ``{"upper": 0.05, + "lower": 0.05}`` used to resolve, echo its own numbers back and draw a + dimension with no DIMTOL/DIMTP/DIMTM at all. A tolerance that disappears + between the spec and the sheet is the silent wrong value this repository + refuses, so the schema is checked here, by name. + """ + who = intent.feature or intent.kind + tol = intent.tol + if not isinstance(tol, dict): + raise ValueError( + f"{who}.tol: expected a dict with keys {sorted(TOL_KEYS)}, got {type(tol).__name__}." + ) + unknown = set(tol) - TOL_KEYS + if unknown: + raise ValueError( + f"{who}.tol: unknown key(s) {sorted(unknown)}; a tolerance takes {sorted(TOL_KEYS)}." + ) + if "mode" not in tol: + raise ValueError( + f"{who}.tol: needs an explicit 'mode' (one of {list(TOL_MODES)}). Without it the " + "deviations are carried in the payload and no tolerance reaches the drawing." + ) + mode = str(tol["mode"]).strip().lower() + upper = tol.get("upper") + lower = tol.get("lower") + if mode in _NO_DEVIATION_MODES and (upper is not None or lower is not None): + raise ValueError( + f"{who}.tol: tol_mode={mode!r} writes no deviation, but upper={upper!r} / " + f"lower={lower!r} were given. Use 'symmetric', 'deviation' or 'limit', or drop " + "the values." + ) + return mode, upper, lower + + +def resolve_tolerance(intent: DimIntent, nominal: float | None = None) -> dict: + """Turn an intent's ``fit`` / ``tol`` into the backend dimension call's arguments. + + Mirrors `server.py::_fit_to_tolerances`, including its sign convention: + ``build_dim_override`` reads ``tol_lower`` as the *magnitude* of the minus + deviation, so a resolved fit passes ``-deviation.lower_mm``. + """ + has_tol = intent.tol is not None + if intent.fit and has_tol: + raise ValueError( + f"{intent.feature or intent.kind}: pass either fit= or an explicit " + "tol, not both." + ) + if intent.fit: + size = float(nominal) if nominal is not None else measured_value(intent) + if size <= _EPS: + raise ValueError( + f"{intent.feature or intent.kind}: fit {intent.fit!r} needs a nominal size, and " + f"the intent measures {size}. Pass nominal= explicitly." + ) + deviation = fit_lookup(intent.fit, size) + return { + "tol_upper": deviation.upper_mm, + "tol_lower": -deviation.lower_mm, + "tol_mode": "deviation", + "text_override": _with_fit(intent.text_override or "<>", deviation.code), + } + if has_tol: + mode, upper, lower = _explicit_tol(intent) + build_dim_override(upper, lower, mode, intent.text_override) # validates, or raises + return { + "tol_upper": upper, + "tol_lower": lower, + "tol_mode": mode, + "text_override": intent.text_override, + } + return { + "tol_upper": None, + "tol_lower": None, + "tol_mode": "none", + "text_override": intent.text_override, + } + + +# -- collecting the intents -------------------------------------------------- + + +def _dedupe(intents) -> tuple[DimIntent, ...]: + seen: set[tuple] = set() + out: list[DimIntent] = [] + for intent in intents: + key = ( + intent.kind, + round(float(intent.p1[0]), 6), + round(float(intent.p1[1]), 6), + round(float(intent.p2[0]), 6), + round(float(intent.p2[1]), 6), + ) + if key in seen: + continue + seen.add(key) + out.append(intent) + return tuple(out) + + +def _linear_span(intent: DimIntent) -> tuple[str, float, float, float] | None: + """``(axis, level, lo, hi)`` of an axis-parallel linear dimension, else None. + + ``level`` is the coordinate the dimension does *not* run along, so two + horizontal dimensions at different heights are never compared with each + other. A skewed linear intent has no span: it is not part of a chain. + """ + if intent.kind != "linear": + return None + (ax, ay), (bx, by) = intent.p1, intent.p2 + if abs(bx - ax) >= abs(by - ay): + if abs(by - ay) > _EPS: + return None + return ("x", round(float(ay), 6), round(min(ax, bx), 6), round(max(ax, bx), 6)) + if abs(bx - ax) > _EPS: + return None + return ("y", round(float(ax), 6), round(min(ay, by), 6), round(max(ay, by), 6)) + + +def redundant_linear(intents) -> tuple[int, ...]: + """Indices of the linear intents that shorter ones already tile end to end. + + ISO 129-1: a measurement appears once. A closed chain (20 + 40 + 15) that + also carries the overall (75) states the overall twice, and the second + statement has to be an auxiliary (reference) dimension or be left off. + + The error is one-sided on purpose. A tiling that is found is a real one - + the pieces butt, in order, from ``lo`` to ``hi`` on the same axis and level + - so a dimension is never called redundant when it is not. A tiling that + the greedy walk misses simply leaves the dimension plain, which is the way + round the repository's critique rule wants: a check that misses a bad + drawing costs a review, a check that calls a good drawing bad gets + switched off. + """ + groups: dict[tuple[str, float], list[tuple[float, float, int]]] = {} + for index, intent in enumerate(intents): + span = _linear_span(intent) + if span is None: + continue + axis, level, lo, hi = span + groups.setdefault((axis, level), []).append((lo, hi, index)) + + out: list[int] = [] + for group in groups.values(): + for lo, hi, index in group: + pieces = sorted( + (a, b) + for a, b, other in group + if other != index + and a >= lo - 1e-6 + and b <= hi + 1e-6 + and (b - a) < (hi - lo) - 1e-6 + ) + cursor = lo + for a, b in pieces: + if abs(a - cursor) <= 1e-6: + cursor = b + if cursor > lo + 1e-6 and abs(cursor - hi) <= 1e-6: + out.append(index) + return tuple(sorted(out)) + + +def _mark_references(intents: list[DimIntent]) -> list[DimIntent]: + """Put the redundant measurements in parentheses, ISO 129-1.""" + for index in redundant_linear(intents): + intent = intents[index] + if intent.text_override is None: + intents[index] = intent._replace(text_override=REFERENCE_FORMAT) + return intents + + +def _apply_fits(intents: list[DimIntent], fits, *, table_ids: frozenset[str]) -> list[DimIntent]: + """Stamp each ``{key: ISO 286 code}`` onto the intent its key names. + + Without this the ``fit`` branch of :func:`resolve_tolerance` is reachable + only from a hand-built ``DimIntent``: no feature and no segment carries a + fit, so the ISO 286 path would be real in the table and absent from every + drawing the module actually produces. + """ + if not fits: + return intents + if not isinstance(fits, dict): + raise ValueError( + f"fits: expected a dict of {{key: ISO 286 code}}, got {type(fits).__name__}." + ) + + available = {intent.feature for intent in intents if intent.feature} + by_key: dict[str, list[int]] = {} + for index, intent in enumerate(intents): + if intent.feature: + by_key.setdefault(intent.feature, []).append(index) + + for key, code in fits.items(): + name = str(key) + if name not in available: + if name in table_ids: + raise ValueError( + f"fits[{name!r}]: that hole's diameter moved into the hole table, which has " + "no tolerance column. Raise hole_table_threshold, or dimension the hole " + "directly." + ) + raise ValueError( + f"fits[{name!r}]: nothing in this view is dimensioned under that name. " + f"Available: {sorted(available)}." + ) + parse_fit_code(str(code)) # refuses a typo here, not at draw time + for index in by_key[name]: + intent = intents[index] + if is_reference(intent): + raise ValueError( + f"fits[{name!r}]: {intent_text(intent)} is an auxiliary (reference) " + "dimension - ISO 129-1 gives it no tolerance. Put the fit on the " + "dimension that states the size." + ) + if intent.tol is not None: + raise ValueError( + f"fits[{name!r}]: that dimension already carries an explicit tol. Pass " + "either fit= or an explicit tol, not both." + ) + intents[index] = intent._replace(fit=str(code)) + return intents + + +def _revolved_axial(part: RevolvedPart, style: str) -> list[DimIntent]: + stations = [0.0] + [x1 for _, x1 in segment_bounds(part)] + intents: list[DimIntent] = [] + if style == "chain": + for a, b in zip(stations, stations[1:], strict=False): + intents.append(DimIntent(kind="linear", p1=(a, 0.0), p2=(b, 0.0), feature="part")) + intents.append( + DimIntent(kind="linear", p1=(0.0, 0.0), p2=(part_length(part), 0.0), feature="part") + ) + else: # baseline and ordinate both measure from the left face + for station in stations[1:]: + intents.append( + DimIntent(kind="linear", p1=(0.0, 0.0), p2=(station, 0.0), feature="part") + ) + return intents + + +def segment_key(index: int, *, bore: bool = False) -> str: + """The ``fits`` key that names one segment's outer diameter, or its bore. + + The part's own diameters have to be addressable one at a time: "ISO 286 + fits on the bearing seats" means a code on *segment 1*, not on every + dimension the part emits. + """ + return f"segment[{int(index)}].bore" if bore else f"segment[{int(index)}]" + + +def _revolved_diameters(part: RevolvedPart) -> list[DimIntent]: + intents: list[DimIntent] = [] + bounds = zip(segment_bounds(part), part.segments, strict=True) + for index, ((x0, x1), segment) in enumerate(bounds): + mid = (x0 + x1) / 2.0 + radius = segment.d_outer / 2.0 + intents.append( + DimIntent( + kind="diameter", + p1=(mid, radius), + p2=(mid, -radius), + feature=segment_key(index), + ) + ) + if segment.d_inner > _EPS: + bore = segment.d_inner / 2.0 + intents.append( + DimIntent( + kind="diameter", + p1=(mid, bore), + p2=(mid, -bore), + feature=segment_key(index, bore=True), + ) + ) + return intents + + +def _hole_groups(part) -> dict[tuple, list[Hole]]: + groups: dict[tuple, list[Hole]] = {} + for feature in getattr(part, "features", ()): + if not isinstance(feature, Hole): + continue + key = ( + round(float(feature.diameter), 6), + feature.thread or "", + None if feature.cbore_d is None else round(float(feature.cbore_d), 6), + None if feature.depth is None else round(float(feature.depth), 6), + ) + groups.setdefault(key, []).append(feature) + return groups + + +def dimension_intents( + part, + view, + *, + style: str = "chain", + hole_table_threshold: int = 8, + fits: dict | None = None, +) -> tuple[DimIntent, ...]: + """Every dimension this view wants: the features' own, plus the part's sizes. + + ``fits`` maps a dimension's name to an ISO 286 code - a feature id, or one + of the part's own diameters by :func:`segment_key` (``"segment[1]"``, + ``"segment[1].bore"``). The code is stamped onto the matching intent, so + :func:`resolve_tolerance` resolves it and :func:`layout_dimensions` reserves + room for the widened text. A key that names nothing is refused with the + names that exist. + + ISO 129-1 is applied last: a measurement the other dimensions already tile + end to end is marked as an auxiliary (reference) dimension, in parentheses, + instead of being drawn a second time as a plain DIMENSION. + """ + name = str(style or "").strip().lower() + if name not in DIM_STYLES: + raise ValueError(f"style: expected one of {DIM_STYLES}, got {style!r}.") + + intents: list[DimIntent] = list(view.dims) + kind = "front" if view.kind == "section" else view.kind + + if isinstance(part, RevolvedPart): + if kind == "front": + intents.extend(_revolved_axial(part, name)) + intents.extend(_revolved_diameters(part)) + elif kind == "side": + last = len(part.segments) - 1 + near = part.segments[last] + radius = near.d_outer / 2.0 + intents.append( + DimIntent( + kind="diameter", + p1=(-radius, 0.0), + p2=(radius, 0.0), + feature=segment_key(last), + ) + ) + if near.d_inner > _EPS: + bore = near.d_inner / 2.0 + intents.append( + DimIntent( + kind="diameter", + p1=(-bore, 0.0), + p2=(bore, 0.0), + feature=segment_key(last, bore=True), + ) + ) + elif isinstance(part, PrismaticPart): + x0, y0, x1, y1 = outline_bbox(part) + if kind == "front": + intents.append(DimIntent(kind="linear", p1=(x0, y0), p2=(x1, y0), feature="part")) + intents.append(DimIntent(kind="linear", p1=(x0, y0), p2=(x0, y1), feature="part")) + else: + # An edge view sees the thickness, measured at the near edge of + # whichever outline axis this view runs along. + u0 = y0 if kind == "side" else x0 + intents.append( + DimIntent( + kind="linear", + p1=(u0, 0.0), + p2=(u0, float(part.thickness)), + feature="part", + ) + ) + + groups = _hole_groups(part) + total_holes = sum(len(holes) for holes in groups.values()) + table_ids: frozenset[str] = frozenset() + if total_holes > int(hole_table_threshold): + table_ids = frozenset(hole.id for holes in groups.values() for hole in holes) + intents = [i for i in intents if i.feature not in table_ids] + + collected = _mark_references(list(_dedupe(intents))) + return tuple(_apply_fits(collected, fits, table_ids=table_ids)) + + +def hole_table_rows(part, view, *, hole_table_threshold: int = 8) -> tuple[dict, ...]: + """One row per hole, tagged and counted by group - empty under the threshold. + + ``view`` is part of the pinned signature and is accepted unused, so a later + view-specific filter (a hole that does not appear in this view) has one + place to go. + """ + del view + groups = _hole_groups(part) + total = sum(len(holes) for holes in groups.values()) + if total <= int(hole_table_threshold): + return () + rows: list[dict] = [] + for index, key in enumerate(sorted(groups, key=lambda k: (k[0], k[1]))): + tag = chr(ord("A") + index) + holes = groups[key] + diameter, thread, cbore, depth = key + note_parts = [] + if thread: + note_parts.append(str(thread)) + if cbore is not None: + note_parts.append(f"c'bore ⌀{cbore:g}") + if depth is not None: + note_parts.append(f"deep {depth:g}") + note = " ".join(note_parts) + for hole in holes: + rows.append( + { + "tag": tag, + "x": float(hole.x), + "y": float(hole.y), + "d": float(diameter), + "qty": len(holes), + "note": note, + } + ) + return tuple(rows) + + +# -- the layout -------------------------------------------------------------- + + +def _axis_of(intent: DimIntent) -> str: + """ "bottom" (u = x, level moves -y), "left" (u = y, level moves -x) or "right".""" + if intent.kind in ("diameter", "radius", "angular"): + return "right" + dx = abs(intent.p2[0] - intent.p1[0]) + dy = abs(intent.p2[1] - intent.p1[1]) + return "bottom" if dx >= dy else "left" + + +def layout_dimensions( + view, intents, *, offset: float = 10.0, step: float = 8.0, height: float = 3.5 +) -> tuple[DimIntent, ...]: + """Give every intent a ``text_at`` such that no two text rectangles overlap.""" + x0, y0, x1, y1 = view.bbox + del y1 + + # Measure first: a level's pitch has to clear the widest text that stacks + # across it, and that is the text WIDTH on the left/right axes, where the + # levels march in x, not the text height. + measured: list[tuple[DimIntent, str, float, float]] = [] + across: dict[str, float] = {} + for intent in intents: + axis = _axis_of(intent) + width, tall = _text_size(intent, height) + # ``along`` is the extent parallel to the level, ``across`` the extent + # perpendicular to it - the one successive levels have to clear. + along, over = (width, tall) if axis == "bottom" else (tall, width) + measured.append((intent, axis, along, over)) + across[axis] = max(across.get(axis, 0.0), over) + pitch = {axis: max(float(step), value + 1.0) for axis, value in across.items()} + + occupied: dict[tuple[str, int], list[tuple[float, float]]] = {} + placed: list[DimIntent] = [] + + for intent, axis, along, _over in measured: + if axis == "bottom": + center = (float(intent.p1[0]) + float(intent.p2[0])) / 2.0 + else: + center = (float(intent.p1[1]) + float(intent.p2[1])) / 2.0 + span = (center - along / 2.0, center + along / 2.0) + + level = 0 + while True: + taken = occupied.setdefault((axis, level), []) + if all(span[1] <= a + 1e-9 or span[0] >= b - 1e-9 for a, b in taken): + taken.append(span) + break + level += 1 + + distance = float(offset) + level * pitch[axis] + if axis == "bottom": + text_at = (center, y0 - distance) + elif axis == "left": + text_at = (x0 - distance, center) + else: + text_at = (x1 + distance, center) + placed.append(intent._replace(text_at=text_at)) + + return tuple(placed) diff --git a/engineering/mech/draw.py b/engineering/mech/draw.py new file mode 100644 index 0000000..0561412 --- /dev/null +++ b/engineering/mech/draw.py @@ -0,0 +1,1107 @@ +"""The only module in `engineering/mech/` that touches a backend. + +Everything above it is pure: the part model, the features, the view engine and +the dimension layout take numbers and return primitive *descriptions*. This +module turns those descriptions into `entity_create_*` / `dimension_*` calls, +places them in WCS, and writes the part model back onto the drawing as +`ACADMCP_MECH` XDATA so `mech_view_add` can add a view months later without the +caller re-describing the part. + +It is not a contract member and neither are the tools above it - exactly like +`gear_draw_*`, `keyway_draw_*` and `titleblock_apply_iso_a3`, it composes the +generic members, so it is written once and works on both engines by +construction. +""" + +from __future__ import annotations + +import logging +import math +from typing import Any + +import anyio + +from engineering.mech.dimension import ( + dimension_intents, + hole_table_rows, + intent_text, + layout_dimensions, + resolve_tolerance, +) +from engineering.mech.features import HolePattern +from engineering.mech.part import build_part, part_to_dict, validate_part +from engineering.mech.primitives import ( + ROLE_LAYER, + Arc, + Circle, + DimIntent, + HatchArea, + Line, + Poly, + Text, + layer_for, + rotate, + translate, +) +from engineering.mech.standards.materials import hatch_for +from engineering.mech.views import ( + View, + build_view, + detail_marker_prims, + layout_origin, + normalise_plane, +) +from engineering.mech.xdata import APP_ID, PAYLOAD_VERSION, decode, to_values + +log = logging.getLogger(__name__) + +#: The layer the invisible per-part anchor POINT lives on. It carries the +#: ACADMCP_MECH payload and nothing else, so `read_part` can find every part in +#: a drawing with one filtered `entity_list`. +ANCHOR_LAYER = "MECH" + +DEFAULT_GAP = 20.0 + +#: How far a standalone hole pattern's ISO 128-23 centre mark reaches past the +#: hole it marks, in drawing units. DECLARED, not standard: ISO 128-23 fixes +#: that the mark overruns the circle, not by how much. +CENTRE_MARK_OVERRUN = 3.0 + + +# -- the XDATA adapter ------------------------------------------------------- +# +# `engineering/mech/xdata.encode` returns DXF (group code, value) rows, which is +# the on-disk shape. `entity_set_xdata` takes plain JSON values and assigns the +# codes itself through backends/xdata_specs.py, so the 1001 APPID row is the +# backend's business and only the 1000 chunks travel. `to_values(payload)` is +# that projection and it lives in `engineering/mech/xdata.py` (Task 1) - every +# writer in this repository calls it rather than re-deriving the split, so a +# change to the chunking is made in one place. + + +def _xdata_payload(values) -> dict: + return decode(values) + + +def _number(value, where: str, *, positive: bool = True) -> float: + try: + number = float(value) + except (TypeError, ValueError): + raise ValueError(f"{where}: expected a number, got {value!r}.") from None + if not math.isfinite(number): + raise ValueError(f"{where}: must be finite, got {value!r}.") + if positive and number <= 0.0: + raise ValueError(f"{where}: must be greater than zero, got {number:g}.") + return number + + +# -- primitives to entities -------------------------------------------------- + + +async def draw_prims( + backend, + prims, + *, + at: tuple[float, float] = (0.0, 0.0), + rotation: float = 0.0, + layer_prefix: str = "", +) -> dict: + """Draw primitive descriptions, turned by ``rotation`` about their origin, then placed at ``at``.""" + placed = tuple(prims) + _refuse_islands_without_edge_paths(backend, placed) + if abs(float(rotation)) > 1e-12: + placed = rotate(placed, float(rotation), (0.0, 0.0)) + if abs(float(at[0])) > 1e-12 or abs(float(at[1])) > 1e-12: + placed = translate(placed, float(at[0]), float(at[1])) + + handles: list[str] = [] + counts: dict[str, int] = {} + + for prim in placed: + layer = f"{layer_prefix}{layer_for(prim)}" + if isinstance(prim, Line): + info = await backend.entity_create_line( + prim.p1[0], prim.p1[1], prim.p2[0], prim.p2[1], layer=layer + ) + elif isinstance(prim, Arc): + info = await backend.entity_create_arc( + prim.center[0], + prim.center[1], + prim.radius, + prim.start_deg, + prim.end_deg, + layer=layer, + ) + elif isinstance(prim, Circle): + info = await backend.entity_create_circle( + prim.center[0], prim.center[1], prim.radius, layer=layer + ) + elif isinstance(prim, Poly): + info = await backend.entity_create_polyline( + [[x, y] for x, y in prim.points], prim.closed, layer=layer + ) + elif isinstance(prim, Text): + info = await backend.entity_create_text( + prim.text, + prim.at[0], + prim.at[1], + prim.height, + prim.rotation, + layer=layer, + ) + elif isinstance(prim, HatchArea): + style = hatch_for(prim.material) + info = await backend.entity_create_hatch( + style["pattern"], + [[x, y] for x, y in prim.loops[0]], + style["scale"], + style["angle"], + layer=layer, + ) + for number, island in enumerate(prim.loops[1:], start=1): + points = list(island) + edges = [ + { + "type": "line", + "start": [points[i][0], points[i][1]], + "end": [points[(i + 1) % len(points)][0], points[(i + 1) % len(points)][1]], + } + for i in range(len(points)) + ] + # `hatch_add_boundary` answers a bad edge list or a bad handle + # with {"ok": False} rather than raising. An island that did not + # attach leaves the hatch solid straight through the bore it was + # meant to exclude, so the hatch is taken back out and the + # failure is raised - a missing hatch is honest, a wrong one is not. + try: + added = await backend.hatch_add_boundary(info.handle, edges) + except Exception: + await _discard(backend, info.handle) + raise + if not isinstance(added, dict) or not added.get("ok"): + await _discard(backend, info.handle) + error = added.get("error") if isinstance(added, dict) else added + raise ValueError( + f"hatch island {number} of {len(prim.loops) - 1} ({prim.material}) " + f"did not attach: {error}; the hatch was removed rather than left " + "filling the material it was meant to exclude." + ) + else: # pragma: no cover - the Prim union is closed + raise ValueError(f"draw_prims: unknown primitive {type(prim).__name__}.") + handles.append(info.handle) + key = type(prim).__name__.lower() + counts[key] = counts.get(key, 0) + 1 + + return {"handles": handles, "counts": counts} + + +def _refuse_islands_without_edge_paths(backend, prims) -> None: + """Refuse a hatch with islands on an engine that cannot attach them - before drawing. + + Islands go through `hatch_add_boundary`, which the live engine refuses + (capability ``hatch_edge_paths``: ActiveX appends loops as objects, not + typed edges). Finding that out at the hatch would leave the rest of the + view already drawn; asking the capability map first leaves nothing behind. + """ + if not any(isinstance(p, HatchArea) and len(p.loops) > 1 for p in prims): + return + try: + feature = backend.capabilities().features.get("hatch_edge_paths") + except Exception: # a backend without a capability map: let the member answer + return + if feature is not None and not feature.supported: + from backends.capability import UnsupportedCapabilityError + + raise UnsupportedCapabilityError( + "hatch_edge_paths", + "this view hatches a cut face with an island (a bore or a hole through the " + f"section), and the {getattr(backend, 'name', 'current')} backend cannot attach " + f"hatch islands ({feature.reason}); nothing was drawn. Draw this view on the " + "headless backend (AUTOCAD_MCP_BACKEND=ezdxf).", + ) + + +async def _discard(backend, handle: str) -> None: + """Delete an entity this module just made; a failure here must not mask the cause.""" + try: + await backend.entity_delete(handle) + except Exception as exc: # pragma: no cover - the original error is the one to raise + log.warning("draw_prims: could not remove hatch %s after a failed island: %s", handle, exc) + + +# -- the placement: the sheet frame and WCS ---------------------------------- +# +# Every view of a part is laid out in one *sheet frame*: the unrotated frame in +# which the first view's lower-left corner sits at `placement.at`. Rotation is +# then applied to the whole sheet as a rigid body about `at`, so the views stay +# on their projection axes and the anchor POINT stays on the part's corner. +# A view record keeps both boxes: `frame_bbox` is what `add_view` lays the next +# view out against, `bbox` is the WCS extent of what was drawn. + + +def _turn(point, at, rotation: float) -> tuple[float, float]: + x, y = float(point[0]), float(point[1]) + if abs(rotation) <= 1e-12: + return (x, y) + cos = math.cos(math.radians(rotation)) + sin = math.sin(math.radians(rotation)) + dx, dy = x - float(at[0]), y - float(at[1]) + return (float(at[0]) + dx * cos - dy * sin, float(at[1]) + dx * sin + dy * cos) + + +def _to_wcs(prims, offset, at, rotation: float) -> tuple: + """Primitives in a view's own coordinates -> WCS: offset into the frame, turn about ``at``.""" + placed = translate(prims, float(offset[0]), float(offset[1])) + if abs(rotation) > 1e-12: + placed = rotate(placed, float(rotation), (float(at[0]), float(at[1]))) + return placed + + +def _wcs_bbox(frame_bbox, at, rotation: float) -> list[float]: + x0, y0, x1, y1 = (float(v) for v in frame_bbox) + corners = [_turn(p, at, rotation) for p in ((x0, y0), (x1, y0), (x1, y1), (x0, y1))] + xs = [p[0] for p in corners] + ys = [p[1] for p in corners] + return [min(xs), min(ys), max(xs), max(ys)] + + +def _frame_bbox(record: dict) -> list[float]: + """The sheet-frame box of a record (a payload written before it existed: its WCS box).""" + return [float(v) for v in (record.get("frame_bbox") or record["bbox"])] + + +def _full_size(scale, where: str) -> float: + """Refuse a view scale the geometry would not have. + + The view engine builds every view but a detail at 1:1, so a scale here + would only be *recorded* - and `mech_part_inspect` would then report a + scale the drawing does not have. Refused by name until it is honoured. + """ + value = _number(scale, where) + if abs(value - 1.0) > 1e-12: + raise ValueError( + f"{where}: {value:g} is refused - views are drawn full size (1:1) in this build, " + "so the scale would be recorded but not drawn. Scale the sheet through a paper-space " + "viewport (viewport_set_scale); a detail view takes its own scale in detail['scale']." + ) + return value + + +# -- dimensions -------------------------------------------------------------- + + +async def _draw_dimension( + backend, intent, *, linear_angle: float | None = None +) -> tuple[str | None, dict]: + """One laid-out intent as a real DIMENSION. Returns (handle, the resolved row). + + ``linear_angle`` is the measuring direction of a linear intent when the + caller knows it better than the points do: a part turned by its placement + rotation measures along its own axes, which the WCS points alone no longer + reveal. ``None`` reads it from the points (horizontal or vertical). + """ + resolved = resolve_tolerance(intent) + row = { + "feature": intent.feature, + "kind": intent.kind, + "text": intent_text(intent), + "fit": intent.fit, + **resolved, + } + text_at = intent.text_at or ( + (intent.p1[0] + intent.p2[0]) / 2.0, + (intent.p1[1] + intent.p2[1]) / 2.0, + ) + tol = { + "tol_upper": resolved["tol_upper"], + "tol_lower": resolved["tol_lower"], + "tol_mode": resolved["tol_mode"], + "text_override": resolved["text_override"], + } + + if intent.kind == "linear": + if linear_angle is None: + horizontal = abs(intent.p2[1] - intent.p1[1]) <= abs(intent.p2[0] - intent.p1[0]) + linear_angle = 0.0 if horizontal else 90.0 + info = await backend.dimension_linear( + intent.p1[0], + intent.p1[1], + intent.p2[0], + intent.p2[1], + text_at[0], + text_at[1], + float(linear_angle), + ROLE_LAYER["dim"], + **tol, + ) + elif intent.kind == "aligned": + if tol["tol_mode"] != "none" or tol["text_override"]: + angle = math.degrees( + math.atan2(intent.p2[1] - intent.p1[1], intent.p2[0] - intent.p1[0]) + ) + # dimension_aligned carries no tolerance arguments, so a toleranced + # aligned dimension is drawn as a rotated linear one - the same + # measurement, with the tolerance the caller asked for. + info = await backend.dimension_linear( + intent.p1[0], + intent.p1[1], + intent.p2[0], + intent.p2[1], + text_at[0], + text_at[1], + angle, + ROLE_LAYER["dim"], + **tol, + ) + else: + info = await backend.dimension_aligned( + intent.p1[0], + intent.p1[1], + intent.p2[0], + intent.p2[1], + text_at[0], + text_at[1], + ROLE_LAYER["dim"], + ) + elif intent.kind == "diameter": + info = await backend.dimension_diameter( + intent.p1[0], + intent.p1[1], + intent.p2[0], + intent.p2[1], + 10.0, + ROLE_LAYER["dim"], + **tol, + ) + elif intent.kind == "radius": + info = await backend.dimension_radius( + intent.p1[0], + intent.p1[1], + intent.p2[0], + intent.p2[1], + 10.0, + ROLE_LAYER["dim"], + **tol, + ) + else: + return None, { + **row, + "skipped": "an angular intent needs a vertex, which a DimIntent does not carry; " + "no feature in this release emits one", + } + return info.handle, row + + +# -- the part payload -------------------------------------------------------- + + +def _view_record( + view: View, + offset: tuple[float, float], + kw: dict, + at: tuple[float, float] = (0.0, 0.0), + rotation: float = 0.0, +) -> dict: + x0, y0, x1, y1 = view.bbox + frame = [x0 + offset[0], y0 + offset[1], x1 + offset[0], y1 + offset[1]] + return { + "kind": view.kind, + "label": view.label, + "scale": view.scale, + "offset": [float(offset[0]), float(offset[1])], + # `bbox` is the WCS extent of what was drawn - the part turned by the + # placement rotation - and `frame_bbox` the same box in the sheet frame + # the next view is laid out in. They are equal while rotation is 0. + "bbox": _wcs_bbox(frame, at, rotation), + "frame_bbox": frame, + "dimensioned": False, + "kw": kw, + } + + +def _offset_for(view: View, lower_left: tuple[float, float]) -> tuple[float, float]: + return (lower_left[0] - view.bbox[0], lower_left[1] - view.bbox[1]) + + +def _placed(view: View, offset) -> View: + x0, y0, x1, y1 = view.bbox + return View( + kind=view.kind, + prims=view.prims, + dims=view.dims, + omitted=view.omitted, + bbox=(x0 + offset[0], y0 + offset[1], x1 + offset[0], y1 + offset[1]), + label=view.label, + scale=view.scale, + ) + + +def _view_from_record(part, payload: dict, record: dict) -> View: + """Rebuild the view a record describes, with the keywords it was drawn with.""" + kw = record.get("kw") or {} + return build_view( + part, + record["kind"], + side=str(kw.get("side", "right")), + plane=kw.get("plane"), + style=str(kw.get("style", "full")), + scale=float(kw.get("scale", 1.0)), + projection=str((payload.get("placement") or {}).get("projection", "first")), + detail=kw.get("detail"), + ) + + +def _placement(payload: dict) -> tuple[tuple[float, float], float]: + placement = payload.get("placement") or {} + at = placement.get("at") or [0.0, 0.0] + return (float(at[0]), float(at[1])), float(placement.get("rotation", 0.0)) + + +async def draw_part( + backend, + part, + *, + at: tuple[float, float] = (0.0, 0.0), + rotation: float = 0.0, + views=("front",), + projection: str = "first", + dimension: bool = True, + style: str = "chain", + scale: float = 1.0, +) -> dict: + """Draw a part's views, dimension them, and write the model onto the drawing. + + ``at`` is where the first view's lower-left corner goes, and ``rotation`` + turns the whole sheet of views about that point as one rigid body, so the + views keep their projection axes and the dimensions turn with them. + ``scale`` must be 1.0: every view is drawn full size in this build and a + recorded-but-undrawn scale is refused rather than written into the model. + """ + validate_part(part) + try: + at_x, at_y = at + except (TypeError, ValueError): + raise ValueError(f"at: expected [x, y], got {at!r}.") from None + origin = (_number(at_x, "at[0]", positive=False), _number(at_y, "at[1]", positive=False)) + turn = _number(rotation, "rotation", positive=False) + scale = _full_size(scale, "scale") + kinds = [str(kind).strip().lower() for kind in (views or ())] + if not kinds: + raise ValueError("views: name at least one view to draw.") + + handles: list[str] = [] + records: list[dict] = [] + omitted: list[dict] = [] + parent: View | None = None + parent_offset = (0.0, 0.0) + + for index, kind in enumerate(kinds): + view = build_view(part, kind, projection=projection, scale=scale) + if index == 0: + offset = _offset_for(view, origin) + else: + lower_left = layout_origin( + _placed(parent, parent_offset), + kind, + projection=projection, + gap=DEFAULT_GAP, + child=view, + ) + offset = _offset_for(view, lower_left) + drawn = await draw_prims(backend, _to_wcs(view.prims, offset, origin, turn)) + handles.extend(drawn["handles"]) + omitted.extend(view.omitted) + records.append(_view_record(view, offset, {"scale": scale}, origin, turn)) + if index == 0: + parent, parent_offset = view, offset + + anchor = await backend.entity_create_point(origin[0], origin[1], layer=ANCHOR_LAYER) + payload = { + "v": PAYLOAD_VERSION, + "kind": "part", + "id": anchor.handle, + "view": records[0]["kind"], + "part": part_to_dict(part), + "placement": { + "at": [origin[0], origin[1]], + "rotation": turn, + "projection": projection, + }, + "views": records, + } + await backend.entity_set_xdata(anchor.handle, APP_ID, to_values(payload)) + + result = { + "part_id": anchor.handle, + "views": records, + "handles": handles, + "omitted": omitted, + "backend": backend.name, + } + if dimension: + dimensioned = await dimension_part(backend, anchor.handle, style=style) + for index in dimensioned["views_dimensioned"]: + records[index]["dimensioned"] = True + result["dimensions"] = dimensioned["count"] + result["handles"].extend(dimensioned["handles"]) + return result + + +async def read_part(backend, part_id: str | None = None) -> dict: + """The part model written onto the drawing, read back out of its anchor.""" + if part_id: + raw = await backend.entity_get_xdata(part_id, APP_ID) + values = (raw.get("xdata") or {}).get(APP_ID) or [] + if not values: + raise ValueError(f"{part_id}: carries no {APP_ID} part payload.") + payload = _xdata_payload(values) + return {"part_id": part_id, **payload} + + anchors = await backend.entity_list(type_filter="POINT", layer_filter=ANCHOR_LAYER, limit=1000) + found: list[tuple[str, dict]] = [] + for anchor in anchors: + raw = await backend.entity_get_xdata(anchor.handle, APP_ID) + values = (raw.get("xdata") or {}).get(APP_ID) or [] + if values: + found.append((anchor.handle, _xdata_payload(values))) + if not found: + raise ValueError( + f"this drawing carries no {APP_ID} part payload; draw one with mech_part_draw." + ) + if len(found) > 1: + raise ValueError( + "this drawing carries " + f"{len(found)} parts ({', '.join(handle for handle, _ in found)}); " + "name one with part_id." + ) + handle, payload = found[0] + return {"part_id": handle, **payload} + + +def _overlaps(box, other, gap: float) -> bool: + """Do two frame rectangles come closer than ``gap`` on *both* axes?""" + eps = 1e-6 + return ( + box[0] < other[2] + gap - eps + and box[2] > other[0] - gap + eps + and box[1] < other[3] + gap - eps + and box[3] > other[1] - gap + eps + ) + + +def _clear_of(records, lower_left, width: float, height: float, parent_box, gap: float): + """Step a new view past every view already placed, away from its parent. + + `layout_origin` put the view on its projection axis (left, right, above or + below the parent); a collision is resolved by stepping further along that + same axis, so a third-angle end view is never shoved back into the front + view it was placed away from, and a view that only shares an X range with + another (a top view under the front) is not a collision at all. + """ + cx = lower_left[0] + width / 2.0 + cy = lower_left[1] + height / 2.0 + dx = cx - (parent_box[0] + parent_box[2]) / 2.0 + dy = cy - (parent_box[1] + parent_box[3]) / 2.0 + boxes = [_frame_bbox(record) for record in records] + for _ in range(len(boxes) + 1): + box = (lower_left[0], lower_left[1], lower_left[0] + width, lower_left[1] + height) + hit = next((other for other in boxes if _overlaps(box, other, gap)), None) + if hit is None: + return lower_left + if abs(dx) >= abs(dy): + x = hit[2] + gap if dx > 0 else hit[0] - gap - width + lower_left = (x, lower_left[1]) + else: + y = hit[3] + gap if dy > 0 else hit[1] - gap - height + lower_left = (lower_left[0], y) + return lower_left # pragma: no cover - each step clears one box for good + + +async def add_view(backend, part_id: str, kind: str, **kw) -> dict: + """Add another view of a part already on the drawing, laid out against its first.""" + payload = await read_part(backend, part_id) + part = build_part(payload["part"]) + records = list(payload.get("views") or []) + if not records: + raise ValueError(f"{part_id}: the payload carries no view to lay this one out against.") + projection = str((payload.get("placement") or {}).get("projection", "first")) + origin, rotation = _placement(payload) + + plane = kw.pop("plane", None) + if plane is not None: + plane = normalise_plane(plane) + detail = kw.pop("detail", None) + gap = float(kw.pop("gap", DEFAULT_GAP)) + side = str(kw.pop("side", "right")) + style = str(kw.pop("style", "full")) + scale = _full_size(kw.pop("scale", 1.0), "scale") + if kw: + raise ValueError(f"add_view: unknown argument(s) {sorted(kw)}.") + + view = build_view( + part, + kind, + side=side, + plane=plane, + style=style, + scale=scale, + projection=projection, + detail=detail, + ) + + parent_record = records[0] + parent_view = _view_from_record(part, payload, parent_record) + parent_frame = _placed(parent_view, parent_record["offset"]) + lower_left = layout_origin( + parent_frame, + kind, + side=side, + projection=projection, + gap=gap, + child=view, + ) + lower_left = _clear_of( + records, + lower_left, + view.bbox[2] - view.bbox[0], + view.bbox[3] - view.bbox[1], + parent_frame.bbox, + gap, + ) + offset = _offset_for(view, lower_left) + + drawn = await draw_prims(backend, _to_wcs(view.prims, offset, origin, rotation)) + marker_handles: list[str] = [] + if kind == "detail": + marker = detail_marker_prims(detail or {}) + marker_drawn = await draw_prims( + backend, _to_wcs(marker, parent_record["offset"], origin, rotation) + ) + marker_handles = marker_drawn["handles"] + + records.append( + _view_record( + view, + offset, + { + "side": side, + "style": style, + "scale": scale, + "plane": _plane_to_json(plane), + "detail": detail, + }, + origin, + rotation, + ) + ) + payload["views"] = records + await backend.entity_set_xdata( + part_id, APP_ID, to_values({k: v for k, v in payload.items() if k != "part_id"}) + ) + return { + "part_id": part_id, + "view": records[-1], + "handles": drawn["handles"] + marker_handles, + "omitted": list(view.omitted), + "backend": backend.name, + } + + +def _plane_to_json(plane: dict | None) -> dict | None: + """`normalise_plane` answers with tuples; JSON round-trips them as lists.""" + if plane is None: + return None + return { + "p1": [float(plane["p1"][0]), float(plane["p1"][1])], + "p2": [float(plane["p2"][0]), float(plane["p2"][1])], + "label": str(plane["label"]), + "direction": [float(plane["direction"][0]), float(plane["direction"][1])], + "style": str(plane["style"]), + "via": [[float(p[0]), float(p[1])] for p in plane.get("via", ())], + } + + +def _fits_for_view(part, view, fits: dict, *, style: str, threshold: int) -> dict: + """The subset of ``fits`` this view actually dimensions. + + ``dimension_intents`` refuses a key that names nothing *in the view it is + given*, which is right for one view and wrong for a part drawn in three: + a fit on ``segment[0]`` names nothing in the end view, and refusing there + would make a correct sheet undrawable. So the split happens here and the + keys nothing anywhere claims are re-offered to :func:`dimension_intents` + afterwards, which refuses them in its own words. + """ + if not fits: + return {} + plain = dimension_intents(part, view, style=style, hole_table_threshold=threshold) + available = {intent.feature for intent in plain if intent.feature} + return {key: code for key, code in fits.items() if str(key) in available} + + +def _view_name(index: int, record: dict) -> str: + return f"{index} ({record.get('label') or record.get('kind')})" + + +def _dimension_targets(part_id: str, records: list[dict], views) -> list[int]: + """Which views this call dimensions - never one that already carries its dimensions. + + ISO 129-1: each dimension appears once. A view record carries a + ``dimensioned`` flag, so a second call (or `mech_dimension_part` after a + `mech_part_draw` that already dimensioned) adds only the views that have + none, and naming a dimensioned view is refused rather than stacked. + """ + if views is None: + targets = [i for i, record in enumerate(records) if not record.get("dimensioned")] + if not targets: + names = ", ".join(_view_name(i, r) for i, r in enumerate(records)) + raise ValueError( + f"{part_id}: every view is already dimensioned ({names}); each measurement " + "appears once (ISO 129-1). Add a view with mech_view_add, then dimension it." + ) + return targets + if isinstance(views, (str, bytes)) or not isinstance(views, (list, tuple)) or not views: + raise ValueError(f"views: expected a list of view indices such as [1], got {views!r}.") + targets: list[int] = [] + for n, value in enumerate(views): + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"views[{n}]: expected a view index (int), got {value!r}.") + if not 0 <= value < len(records): + names = ", ".join(_view_name(i, r) for i, r in enumerate(records)) + raise ValueError(f"views[{n}]: {value} is not a view of {part_id}; it has {names}.") + if value not in targets: + targets.append(value) + already = [_view_name(i, records[i]) for i in targets if records[i].get("dimensioned")] + if already: + raise ValueError( + f"views: {', '.join(already)} already dimensioned; each measurement appears once " + "(ISO 129-1), so dimensioning it again would stack a duplicate on every measurement." + ) + return targets + + +async def dimension_part( + backend, + part_id: str, + *, + style: str = "chain", + hole_table_threshold: int = 8, + fits: dict | None = None, + views: list[int] | None = None, +) -> dict: + """Dimension the views of a part that carry no dimensions yet, laid out without overlaps. + + ``views`` names view indices (the order `read_part` lists them in); by + default every view not yet dimensioned is. A view already dimensioned is + never dimensioned again (ISO 129-1), and the flag that says so is written + back into the part payload. + """ + payload = await read_part(backend, part_id) + part = build_part(payload["part"]) + records = list(payload.get("views") or []) + if not records: + raise ValueError(f"{part_id}: the payload carries no view to dimension.") + if fits is not None and not isinstance(fits, dict): + raise ValueError( + f"fits: expected a dict of {{key: ISO 286 code}}, got {type(fits).__name__}." + ) + fits = dict(fits or {}) + targets = _dimension_targets(part_id, records, views) + origin, rotation = _placement(payload) + threshold = int(hole_table_threshold) + + # Everything that can be refused is refused before the first DIMENSION is + # drawn: a fit no target view claims must not leave half a view dimensioned + # and unflagged behind it. + planned: list[tuple[int, View, list]] = [] + unclaimed = set(fits) + for index in targets: + view = _view_from_record(part, payload, records[index]) + mine = _fits_for_view(part, view, fits, style=style, threshold=threshold) + unclaimed -= set(mine) + intents = dimension_intents( + part, view, style=style, hole_table_threshold=threshold, fits=mine or None + ) + planned.append((index, view, layout_dimensions(view, intents))) + if unclaimed: + # No view claimed these keys. Hand them back to the module that owns the + # vocabulary so the refusal names the keys that do exist, and the hole + # table's own message survives. + dimension_intents( + part, + planned[0][1], + style=style, + hole_table_threshold=threshold, + fits={key: fits[key] for key in sorted(unclaimed)}, + ) + + handles: list[str] = [] + resolved_rows: list[dict] = [] + skipped: list[dict] = [] + table_handle: str | None = None + + for index, view, placed in planned: + record = records[index] + offset = record["offset"] + for intent in placed: + frame = intent._replace( + p1=(intent.p1[0] + offset[0], intent.p1[1] + offset[1]), + p2=(intent.p2[0] + offset[0], intent.p2[1] + offset[1]), + text_at=(intent.text_at[0] + offset[0], intent.text_at[1] + offset[1]), + ) + angle = None + if intent.kind == "linear": + # the measuring direction is decided in the part's own frame and + # turned with it; the rotated points alone no longer show it + horizontal = abs(frame.p2[1] - frame.p1[1]) <= abs(frame.p2[0] - frame.p1[0]) + angle = (0.0 if horizontal else 90.0) + rotation + moved = frame._replace( + p1=_turn(frame.p1, origin, rotation), + p2=_turn(frame.p2, origin, rotation), + text_at=_turn(frame.text_at, origin, rotation), + ) + handle, row = await _draw_dimension(backend, moved, linear_angle=angle) + if handle is None: + skipped.append(row) + continue + handles.append(handle) + resolved_rows.append(row) + + rows = hole_table_rows(part, view, hole_table_threshold=threshold) + if rows and table_handle is None and not payload.get("hole_table"): + info = await backend.entity_create_table( + record["bbox"][2] + DEFAULT_GAP, + record["bbox"][3], + [[r["tag"], f"{r['d']:g}", str(r["qty"]), r["note"]] for r in rows], + ["TAG", "DIA", "QTY", "NOTE"], + None, + 7.0, + 2.5, + "HOLE TABLE", + ROLE_LAYER["text"], + ) + table_handle = info.handle + handles.append(info.handle) + if placed or rows: + record["dimensioned"] = True + else: + # Said, not silently counted as done: the view stays unflagged, so a + # later call can dimension it once the dimension layer covers it. + skipped.append( + { + "view": _view_name(index, record), + "kind": view.kind, + "skipped": f"the dimension layer emits no intents for a {view.kind} view " + "in this build; the view is left undimensioned", + } + ) + + payload["views"] = records + if table_handle: + payload["hole_table"] = table_handle + await backend.entity_set_xdata( + part_id, APP_ID, to_values({k: v for k, v in payload.items() if k != "part_id"}) + ) + + return { + "part_id": part_id, + "count": len(handles) - (1 if table_handle else 0), + "handles": handles, + "resolved": resolved_rows, + "skipped": skipped, + "hole_table": table_handle, + "views_dimensioned": [i for i in targets if records[i].get("dimensioned")], + "backend": backend.name, + } + + +# -- the standalone hole pattern --------------------------------------------- + + +async def draw_hole_pattern( + backend, + *, + pattern: str, + x: float, + y: float, + diameter: float, + count: int = 2, + spacing: float = 0.0, + count_y: int = 1, + spacing_y: float = 0.0, + pcd: float = 0.0, + start_angle: float = 0.0, + angle: float = 0.0, + layer: str | None = None, + centre_marks: bool = True, + dimension: bool = False, +) -> dict: + """Draw a hole pattern on any drawing - no part model needed.""" + # A standalone pattern has no part, so `Hole.validate` (which needs one) is + # out of reach: the size guard is made here rather than left to the drawing. + diameter = _number(diameter, "diameter") + _number(x, "x", positive=False) + _number(y, "y", positive=False) + expansion = HolePattern( + id="pattern", + pattern=pattern, + base={"x": float(x), "y": float(y), "diameter": diameter}, + count=int(count), + spacing=float(spacing), + count_y=int(count_y), + spacing_y=float(spacing_y), + pcd=float(pcd), + start_angle=float(start_angle), + angle=float(angle), + ).expand() # validates the pattern before anything is drawn + + prims: list[Any] = [] + for hole in expansion: + radius = float(hole.diameter) / 2.0 + prims.append(Circle((float(hole.x), float(hole.y)), radius, "visible")) + if centre_marks: + reach = radius + CENTRE_MARK_OVERRUN + prims.append(Line((hole.x - reach, hole.y), (hole.x + reach, hole.y), "center")) + prims.append(Line((hole.x, hole.y - reach), (hole.x, hole.y + reach), "center")) + drawn = await draw_prims(backend, prims) + if layer: + # the role map owns the layers; an explicit override moves the circles + # only, so the ISO 128-23 centre marks stay on CENTER. + for handle, prim in zip(drawn["handles"], prims, strict=True): + if isinstance(prim, Circle): + await backend.entity_set_properties(handle, layer=layer) + + result = { + "count": len(expansion), + "handles": drawn["handles"], + "centers": [[float(h.x), float(h.y)] for h in expansion], + "backend": backend.name, + } + if dimension: + first = expansion[0] + radius = float(first.diameter) / 2.0 + handle, _row = await _draw_dimension( + backend, + DimIntent( + kind="diameter", + p1=(first.x - radius, first.y), + p2=(first.x + radius, first.y), + text_at=(first.x + radius + 12.0, first.y + 12.0), + text_override=f"{len(expansion)}x ⌀{float(first.diameter):g}", + feature="pattern", + ), + ) + if handle: + result["handles"].append(handle) + return result + + +# -- the whole sheet --------------------------------------------------------- + + +async def draw_from_spec(backend, spec: dict) -> dict: + """A whole mechanical sheet from one declarative spec, inside one transaction. + + Every part is validated before the transaction opens, so a refusal leaves + the drawing untouched. The rollback is shielded for the reason + `engineering/pid/spec.py` documents at length: the MCP SDK cancels a request + by cancelling an anyio scope, anyio cancellation is level-triggered, and a + bare ``await`` in the handler would itself be cancelled before the rollback + body ran - leaving half a sheet inside an open transaction. + """ + if not isinstance(spec, dict): + raise ValueError(f"spec: expected a dict, got {type(spec).__name__}.") + raw_parts = spec.get("parts") + if not isinstance(raw_parts, (list, tuple)) or not raw_parts: + raise ValueError("parts: name at least one part to draw.") + + jobs: list[dict] = [] + for index, item in enumerate(raw_parts): + if not isinstance(item, dict): + raise ValueError(f"parts[{index}]: expected a dict with a 'part' key.") + try: + part = build_part(item.get("part") or {}) + except ValueError as exc: + raise ValueError(f"parts[{index}]: {exc}") from exc + at = item.get("at") + if at is None: + at = [0.0, 0.0] + if isinstance(at, (str, bytes)) or not isinstance(at, (list, tuple)) or len(at) != 2: + raise ValueError(f"parts[{index}]: at: expected [x, y], got {at!r}.") + jobs.append( + { + "part": part, + "at": ( + _number(at[0], f"parts[{index}]: at[0]", positive=False), + _number(at[1], f"parts[{index}]: at[1]", positive=False), + ), + "rotation": _number( + item.get("rotation", 0.0), f"parts[{index}]: rotation", positive=False + ), + "views": tuple(item.get("views") or ("front",)), + "projection": str(item.get("projection", "first")), + "dimension": bool(item.get("dimension", True)), + "style": str(item.get("style", "chain")), + } + ) + + sheet = spec.get("sheet") or {} + if backend.get_plan_spec() is None: + await backend.drawing_plan( + sheet.get("intent", "mechanical part sheet"), + sheet.get("sheet_size", "A3"), + float(sheet.get("scale", 1.0)), + sheet.get("layer_set", "mech"), + ) + + begun = await backend.transaction_begin() + if not isinstance(begun, dict) or not begun.get("ok"): + # a refusal, not a crash: ValueError is what the tool layer turns into + # a ToolError, and nothing has been drawn yet + detail = begun.get("error") if isinstance(begun, dict) else begun + raise ValueError( + f"mech_part_from_spec: could not open a transaction ({detail}); nothing was drawn. " + "If one is already open, close it with transaction_commit or " + "transaction_rollback and retry." + ) + + drawn: list[dict] = [] + try: + for index, job in enumerate(jobs): + try: + drawn.append( + await draw_part( + backend, + job["part"], + at=job["at"], + rotation=job["rotation"], + views=job["views"], + projection=job["projection"], + dimension=job["dimension"], + style=job["style"], + ) + ) + except ValueError as exc: + raise ValueError(f"parts[{index}]: {exc}") from exc + except BaseException as exc: + cancelled = isinstance(exc, anyio.get_cancelled_exc_class()) + with anyio.CancelScope(shield=True): + try: + await backend.transaction_rollback() + except Exception as rollback_exc: + if not cancelled: + raise + log.error( + "mech_part_from_spec: rollback after a cancelled request failed (%s: %s); " + "the sheet may be half-drawn - check system_status and " + "transaction_rollback before retrying", + type(rollback_exc).__name__, + rollback_exc, + ) + raise + await backend.transaction_commit() + + return { + "parts": drawn, + "part_ids": [item["part_id"] for item in drawn], + "backend": backend.name, + } diff --git a/engineering/mech/features.py b/engineering/mech/features.py new file mode 100644 index 0000000..a9b8873 --- /dev/null +++ b/engineering/mech/features.py @@ -0,0 +1,2254 @@ +"""Every feature of the part model: validation, projected geometry, dimensions. + +Pure: no backend, no async, no DXF. A feature answers four questions about the +part it sits on - *is my placement legal*, *what do I contribute to this view*, +*what do I contribute to a cut face*, *what dimension do I want* - and five +structural ones the view engine asks: + +``removal_box(part)`` + the axis-aligned box of material this feature removes, **in the front + view's plane** (revolved: x along the axis, y radial; prismatic: the + outline's own XY). ``None`` opts out - used by features whose removal is + not a box (an annulus, an axisymmetric bore). +``radial_edit(part)`` + ``{"kind": "inner"|"outer", "x0", "x1", "radius"}`` records that change the + revolved radial profile, which is what makes both the silhouette and the + section cut face come out of one calculation. +``corner_edit(part)`` + ``{"corner", "before", "after", "arc"}`` - replace one silhouette corner + with a chamfer line or a tangent fillet arc. +``outline_edit(part)`` + the prismatic equivalent, keyed by outline vertex index. +``omission(part, view)`` + why this feature has no geometry in that view, for the honesty rule: a + feature that contributes nothing and edits nothing is reported in the + view's ``omitted`` list rather than silently dropped. + +Two features reuse the repository rather than restating a table: +``Keyway`` takes its width and depth from ``engineering.keyway`` (DIN 6885-1) +and ``GearTeeth`` takes its outline from ``engineering.gear``'s involute +generator. ``Thread`` takes its pitch from ``standards/threads.py`` (ISO 261) +and its geometry from ISO 6410. ``Undercut``, ``RetainingGroove``, +``CentreHole`` and ``ORingGroove`` take their dimensions from the registered +standards tables, which are not transcribed in this build - so each of them +either receives explicit dimensions or is refused by name. None of them +invents a number. +""" + +from __future__ import annotations + +import math +from collections.abc import Iterable +from dataclasses import dataclass, fields +from typing import Any, ClassVar, Protocol, runtime_checkable + +from engineering.gear import generate_full_gear_outline +from engineering.keyway import keyway_dimensions +from engineering.mech.part import ( + PrismaticPart, + RevolvedPart, + inner_radius_at, + outer_radius_at, + outline_bbox, + part_length, + point_in_outline, + segment_bounds, +) +from engineering.mech.primitives import Arc, Circle, DimIntent, HatchArea, Line, Poly, Prim, Text +from engineering.mech.standards.centres import centre_hole_dims +from engineering.mech.standards.orings import KINDS as ORING_KINDS +from engineering.mech.standards.orings import oring_groove_dims +from engineering.mech.standards.rings import ring_groove_dims +from engineering.mech.standards.threads import ( + MINOR_RATIO, + parse_designation, + thread_axial_prims, + thread_end_prims, +) +from engineering.mech.standards.undercuts import undercut_profile + +_EPS = 1e-9 + + +@runtime_checkable +class Feature(Protocol): + """The surface the part model and the view engine rely on.""" + + id: str + + def validate(self, part) -> None: ... + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: ... + def cut_prims(self, part, plane, ctx: dict) -> tuple[Prim, ...]: ... + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: ... + + +# -- shared helpers ---------------------------------------------------------- + + +def mirror_about_x(prims: Iterable[Prim]) -> tuple[Prim, ...]: + """Mirror primitives about the y = 0 axis (the revolved part's axis).""" + out: list[Prim] = [] + for prim in prims: + if isinstance(prim, Line): + out.append(Line((prim.p1[0], -prim.p1[1]), (prim.p2[0], -prim.p2[1]), prim.role)) + elif isinstance(prim, Arc): + out.append( + Arc( + (prim.center[0], -prim.center[1]), + prim.radius, + (-prim.end_deg) % 360.0, + (-prim.start_deg) % 360.0, + prim.role, + ) + ) + elif isinstance(prim, Circle): + out.append(Circle((prim.center[0], -prim.center[1]), prim.radius, prim.role)) + elif isinstance(prim, Poly): + out.append(Poly(tuple((x, -y) for x, y in prim.points), prim.closed, prim.role)) + elif isinstance(prim, HatchArea): + out.append( + HatchArea( + tuple(tuple((x, -y) for x, y in loop) for loop in prim.loops), + prim.material, + ) + ) + elif isinstance(prim, Text): + out.append( + Text((prim.at[0], -prim.at[1]), prim.text, prim.height, prim.rotation, prim.role) + ) + else: # pragma: no cover - the union is closed + raise ValueError(f"mirror_about_x: unknown primitive {type(prim).__name__}.") + return tuple(out) + + +# -- cutting-line chords ----------------------------------------------------- +# +# A section plane meets a feature along a *chord*, not along the feature's +# bounding box. The difference is not cosmetic: a plane 4.9 mm off the centre +# of a 10 mm hole cuts a 1.990 mm chord, and the box would punch the whole +# 10 mm out of the cut face - a wrong hatched area and a wrong drawn outline at +# the same time. Every function here returns parameters measured along ``unit`` +# from ``p1``, on the infinite line; the caller clips them to its own leg. + + +def _box_around(center, radius: float) -> tuple[float, float, float, float]: + """The axis-aligned box of a circle - what ``removal_box`` reports, and + exactly why a cut face may not be built from it (see ``cut_spans``).""" + x, y = float(center[0]), float(center[1]) + return (x - radius, y - radius, x + radius, y + radius) + + +def _merge_spans(spans) -> tuple[tuple[float, float], ...]: + """Sorted, non-overlapping spans. A union, so a shape built from several + pieces (a slot is two circles and a rectangle) reports one interval.""" + items = sorted((float(a), float(b)) for a, b in spans if float(b) - float(a) > _EPS) + out: list[list[float]] = [] + for a, b in items: + if out and a <= out[-1][1] + _EPS: + out[-1][1] = max(out[-1][1], b) + else: + out.append([a, b]) + return tuple((a, b) for a, b in out) + + +def _box_chord(box, p1, unit) -> tuple[tuple[float, float], ...]: + """The exact chord of an axis-aligned rectangle (a slab clip, not a corner + projection: projecting the four corners onto the line reports the box's + whole shadow, which is only the chord when the line is parallel to a side).""" + if box is None: + return () + low, high = -math.inf, math.inf + for axis in (0, 1): + direction = float(unit[axis]) + lo, hi = float(box[axis]), float(box[axis + 2]) + origin = float(p1[axis]) + if abs(direction) < 1e-12: + if origin < lo - _EPS or origin > hi + _EPS: + return () + continue + a, b = (lo - origin) / direction, (hi - origin) / direction + if a > b: + a, b = b, a + low, high = max(low, a), min(high, b) + return ((low, high),) if high - low > _EPS else () + + +def _circle_chord(center, radius, p1, unit) -> tuple[tuple[float, float], ...]: + """``2*sqrt(r**2 - d**2)`` about the foot of the perpendicular - exact, and + empty when the line only grazes the circle.""" + dx, dy = float(center[0]) - float(p1[0]), float(center[1]) - float(p1[1]) + along = dx * unit[0] + dy * unit[1] + off = -dx * unit[1] + dy * unit[0] + inside = float(radius) * float(radius) - off * off + if inside <= _EPS: + return () + half = math.sqrt(inside) + return ((along - half, along + half),) + + +def _rotated_box_chord(center, half_u, half_v, angle_deg, p1, unit): + """The exact chord of a rectangle rotated about its own centre: the line is + taken into the rectangle's frame and slab-clipped there.""" + theta = math.radians(float(angle_deg)) + cos, sin = math.cos(theta), math.sin(theta) + dx, dy = float(p1[0]) - float(center[0]), float(p1[1]) - float(center[1]) + origin = (dx * cos + dy * sin, -dx * sin + dy * cos) + direction = (unit[0] * cos + unit[1] * sin, -unit[0] * sin + unit[1] * cos) + return _box_chord((-half_u, -half_v, half_u, half_v), origin, direction) + + +def _polygon_chord(points, p1, unit, *, what: str = "the removed outline"): + """The intervals of the line that lie inside a simple polygon. + + Crossings are paired in order, exact for a polygon the line crosses + transversally. A line through a vertex gives an odd count and is refused + rather than paired wrong - the same rule the outline itself follows. + """ + ts: list[float] = [] + count = len(points) + for index in range(count): + ax, ay = points[index] + bx, by = points[(index + 1) % count] + ex, ey = bx - ax, by - ay + denominator = ex * unit[1] - ey * unit[0] + if abs(denominator) < 1e-12: + continue + s = ((p1[0] - ax) * unit[1] - (p1[1] - ay) * unit[0]) / denominator + if -1e-9 <= s <= 1.0 + 1e-9: + px, py = ax + s * ex, ay + s * ey + ts.append((px - p1[0]) * unit[0] + (py - p1[1]) * unit[1]) + ts = sorted({round(t, 9) for t in ts}) + if len(ts) % 2 != 0: + raise ValueError( + f"section: the cutting plane grazes a vertex of {what}, so its crossings cannot " + "be paired. Move the plane off the vertex rather than accept a guessed cut face." + ) + return tuple((ts[i], ts[i + 1]) for i in range(0, len(ts), 2)) + + +def _number(value: Any, path: str, *, positive: bool = True) -> float: + try: + number = float(value) + except (TypeError, ValueError): + raise ValueError(f"{path}: expected a number, got {value!r}.") from None + if not math.isfinite(number): + raise ValueError(f"{path}: {value!r} is not a finite number.") + if positive and number <= _EPS: + raise ValueError(f"{path}: must be greater than zero, got {number}.") + return number + + +def _require_revolved(part, kind: str) -> RevolvedPart: + if not isinstance(part, RevolvedPart): + raise ValueError(f"{kind} applies to a revolved part, not a {type(part).__name__}.") + return part + + +def _require_prismatic(part, kind: str) -> PrismaticPart: + if not isinstance(part, PrismaticPart): + raise ValueError(f"{kind} applies to a prismatic part, not a {type(part).__name__}.") + return part + + +def _segment_index(part: RevolvedPart, index: Any, path: str = "segment") -> int: + try: + value = int(index) + except (TypeError, ValueError): + raise ValueError(f"{path}: expected a segment index, got {index!r}.") from None + count = len(part.segments) + if not 0 <= value < count: + raise ValueError( + f"{path} {value} is outside the {count}-segment part (segments 0-{count - 1})." + ) + return value + + +def _parse_at(at: str, part: RevolvedPart, *, allow_step: bool = True) -> tuple[str, int]: + text = str(at or "").strip().lower() + if text in ("start", "end"): + return text, 0 + if text.startswith("step:"): + if not allow_step: + raise ValueError(f"at {at!r}: this feature is placed at 'start' or 'end' only.") + try: + index = int(text.split(":", 1)[1]) + except ValueError: + raise ValueError(f"at {at!r}: expected 'step:'.") from None + last = len(part.segments) - 2 + if last < 0 or not 0 <= index <= last: + raise ValueError( + f"at 'step:{index}': the part has {len(part.segments)} segment(s), so its steps " + f"are step:0-step:{max(last, 0)}." + ) + return "step", index + raise ValueError(f"at {at!r}: expected 'start', 'end' or 'step:'.") + + +def _radius_right_end(part: RevolvedPart, index: int) -> float: + segment = part.segments[index] + return (segment.taper_to if segment.taper_to is not None else segment.d_outer) / 2.0 + + +def _radius_left_end(part: RevolvedPart, index: int) -> float: + return part.segments[index].d_outer / 2.0 + + +def _anchor(feature, part: RevolvedPart) -> tuple[float, float, int]: + """(x, radius, direction) of the corner a chamfer/fillet/undercut sits on.""" + kind, index = _parse_at(feature.at, part) + if kind == "start": + return 0.0, _radius_left_end(part, 0), +1 + if kind == "end": + last = len(part.segments) - 1 + return part_length(part), _radius_right_end(part, last), -1 + x = segment_bounds(part)[index][1] + left = _radius_right_end(part, index) + right = _radius_left_end(part, index + 1) + return (x, left, -1) if left >= right else (x, right, +1) + + +def _anchor_segment(feature, part: RevolvedPart) -> int: + """Index of the segment a chamfer at ``feature.at`` actually cuts into. + + ``_anchor`` gives the corner and the direction into the material; the leg + runs that way, so at a step it belongs to whichever of the two adjacent + segments carries the larger radius. + """ + kind, index = _parse_at(feature.at, part) + if kind == "start": + return 0 + if kind == "end": + return len(part.segments) - 1 + left = _radius_right_end(part, index) + right = _radius_left_end(part, index + 1) + return index if left >= right else index + 1 + + +def _shoulder(feature, part: RevolvedPart) -> tuple[float, float, int]: + """(x, radius of the SMALLER cylinder, direction into it) for a relief groove.""" + kind, index = _parse_at(feature.at, part) + if kind == "start": + return 0.0, _radius_left_end(part, 0), +1 + if kind == "end": + last = len(part.segments) - 1 + return part_length(part), _radius_right_end(part, last), -1 + x = segment_bounds(part)[index][1] + left = _radius_right_end(part, index) + right = _radius_left_end(part, index + 1) + return (x, right, +1) if left >= right else (x, left, -1) + + +def _jsonable(value: Any) -> Any: + if isinstance(value, tuple): + return [_jsonable(item) for item in value] + if isinstance(value, list): + return [_jsonable(item) for item in value] + if isinstance(value, dict): + return {key: _jsonable(item) for key, item in value.items()} + return value + + +def _tuplify(value: Any) -> Any: + if isinstance(value, list): + return tuple(_tuplify(item) for item in value) + return value + + +# -- the base every feature shares ------------------------------------------- + + +_REGISTRY: dict[str, type] = {} + + +def _register(cls): + _REGISTRY[cls.KIND] = cls + return cls + + +@dataclass(frozen=True, kw_only=True) +class _Base: + id: str + + KIND: ClassVar[str] = "" + #: ISO 128-3: a rib or web cut longitudinally is not hatched. Features that + #: can be one override this as a real field. + no_section_hatch = False + #: True when the feature's *front* primitives describe only the +Y half of + #: an axisymmetric cut, so the view engine mirrors them about the axis. A + #: keyway is not axisymmetric (it sits on one face); a ring groove is. + axisymmetric: ClassVar[bool] = False + + def validate(self, part) -> None: + if not isinstance(self.id, str) or not self.id.strip(): + raise ValueError("id: a non-empty feature id is required.") + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + return () + + def cut_prims(self, part, plane, ctx: dict) -> tuple[Prim, ...]: + return self.prims(part, "front", ctx) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + return () + + def removal_box(self, part) -> tuple[float, float, float, float] | None: + return None + + def cut_spans(self, part, p1, unit) -> tuple[tuple[float, float], ...]: + """Where a cutting line lies inside the material this feature removes. + + Parameters along ``unit`` measured from ``p1``, in the front view's own + plane. The default is the exact chord of ``removal_box``, which is the + truth for a feature whose removal really is a rectangle. A feature + whose removal is a circle or an obround **must** override this: the + chord of a hole shrinks to nothing as the plane leaves its centre, + while its bounding box stays the full diameter wide, so a box-based cut + face shows an 8 mm gap through solid material where the plane merely + grazes the hole. + """ + return _box_chord(self.removal_box(part), p1, unit) + + def cross_section(self, part, station: float) -> dict | None: + """This feature's shape in a **transverse** cut face at ``station``. + + ``{"start_deg", "end_deg", "path", "radius"}`` - the outer boundary of + the cut face keeps its arc from ``start_deg`` counter-clockwise to + ``end_deg`` (the material that survives the feature) and is closed by + ``path``, a polyline running from the point at ``start_deg`` to the + point at ``end_deg``, which the engine walks backwards to close the + loop. ``radius`` is the outside radius the record was built on, so the + engine can refuse to graft it onto a face of another size rather than + draw a notch that does not reach the surface. + + ``None`` means the feature has no transverse projection here. If the + plane really passes through it, the honesty rule reports it instead of + hatching over it. + """ + return None + + def radial_edit(self, part) -> tuple[dict, ...]: + return () + + def corner_edit(self, part) -> dict | None: + return None + + def outline_edit(self, part) -> dict | None: + return None + + def omission(self, part, view: str) -> str | None: + return f"{type(self).__name__} has no {view} projection" + + def to_dict(self) -> dict: + payload = {"kind": self.KIND} + for field_ in fields(self): + payload[field_.name] = _jsonable(getattr(self, field_.name)) + return payload + + +def feature_from_dict(d: dict) -> Feature: + """Build a feature from its dict form; the ``kind`` key selects the class.""" + if not isinstance(d, dict): + raise ValueError(f"expected a dict with a 'kind' key, got {type(d).__name__}.") + kind = str(d.get("kind", "")).strip().lower() + if kind not in _REGISTRY: + raise ValueError(f"unknown feature kind {d.get('kind')!r}. Known kinds: {FEATURE_KINDS}.") + cls = _REGISTRY[kind] + names = {field_.name for field_ in fields(cls)} + payload = {key: value for key, value in d.items() if key != "kind"} + unknown = sorted(set(payload) - names) + if unknown: + raise ValueError(f"{kind}: unknown key(s) {unknown}; it takes {sorted(names)}.") + try: + return cls(**{key: _tuplify(value) for key, value in payload.items()}) + except TypeError as exc: + raise ValueError(f"{kind}: {exc}") from exc + + +# -- revolved features ------------------------------------------------------- + + +@_register +@dataclass(frozen=True, kw_only=True) +class Chamfer(_Base): + """ISO 128-3 corner chamfer at an end or a step. ``size`` is the axial leg.""" + + KIND: ClassVar[str] = "chamfer" + at: str + size: float + angle: float = 45.0 + + def _legs(self) -> tuple[float, float]: + """(axial leg, radial leg). + + 45 degrees is special-cased to an exact 1.0 slope: + ``math.tan(math.radians(45))`` is 0.9999999999999999, and the + overwhelmingly common chamfer would otherwise carry that error into + every coordinate it produces. + """ + angle = float(self.angle) + slope = 1.0 if abs(angle - 45.0) < 1e-12 else math.tan(math.radians(angle)) + return float(self.size), float(self.size) * slope + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "Chamfer") + _number(self.size, "size") + angle = _number(self.angle, "angle") + if not 0.0 < angle < 90.0: + raise ValueError(f"angle: a chamfer angle is between 0 and 90 degrees, got {angle}.") + x, radius, direction = _anchor(self, part) + axial, radial = self._legs() + if radial >= radius: + raise ValueError( + f"size {self.size} at {self.at!r} cuts {radial:.3f} mm off a {radius:.3f} mm " + "radius: the chamfer is larger than the material it sits on." + ) + index = _anchor_segment(self, part) + segment_length = part.segments[index].length + if axial > segment_length + _EPS: + far = x + direction * segment_length + raise ValueError( + f"size {self.size} at {self.at!r} runs {axial:g} mm along the axis, past " + f"segment {index}'s {segment_length:g} mm: the chamfer would cross the step " + f"at x={far:g} and be anchored at a radius that segment does not have." + ) + del x + + def corner_edit(self, part) -> dict: + x, radius, direction = _anchor(self, _require_revolved(part, "Chamfer")) + axial, radial = self._legs() + if direction > 0: + before, after = (x, radius - radial), (x + axial, radius) + else: + before, after = (x - axial, radius), (x, radius - radial) + return {"corner": (x, radius), "before": before, "after": after, "arc": None} + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + if view != "side": + return () + part = _require_revolved(part, "Chamfer") + _, radius, _ = _anchor(self, part) + _, radial = self._legs() + return (Circle((0.0, 0.0), radius - radial, "visible"),) + + def removal_box(self, part): + edit = self.corner_edit(part) + xs = (edit["before"][0], edit["after"][0]) + ys = (edit["before"][1], edit["after"][1]) + return (min(xs), min(ys), max(xs), max(ys)) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + edit = self.corner_edit(part) + return ( + DimIntent( + kind="aligned", + p1=edit["before"], + p2=edit["after"], + text_override=f"{float(self.size):g}x{float(self.angle):g}°", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side") else f"a chamfer has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class Fillet(_Base): + """A tangent fillet radius in the inside corner of a step.""" + + KIND: ClassVar[str] = "fillet" + at: str + radius: float + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "Fillet") + radius = _number(self.radius, "radius") + kind, index = _parse_at(self.at, part) + if kind != "step": + raise ValueError( + f"at {self.at!r}: a fillet is defined at a step ('step:'); an end " + "corner takes a Chamfer." + ) + left = _radius_right_end(part, index) + right = _radius_left_end(part, index + 1) + if abs(left - right) < _EPS: + raise ValueError(f"at {self.at!r}: the two segments have the same diameter, no step.") + if radius >= abs(left - right): + raise ValueError( + f"radius {radius} is not smaller than the step height {abs(left - right):.3f} mm." + ) + shortest = min(part.segments[index].length, part.segments[index + 1].length) + if radius >= shortest: + raise ValueError( + f"radius {radius} is not smaller than the shortest segment {shortest}." + ) + + def corner_edit(self, part) -> dict: + part = _require_revolved(part, "Fillet") + _, index = _parse_at(self.at, part) + x = segment_bounds(part)[index][1] + left = _radius_right_end(part, index) + right = _radius_left_end(part, index + 1) + radius = float(self.radius) + if left < right: # step up going right: the small cylinder is on the left + corner = (x, left) + before = (x - radius, left) + after = (x, left + radius) + center = (x - radius, left + radius) + arc = Arc(center, radius, 270.0, 360.0, "visible") + else: # step down going right: the small cylinder is on the right + corner = (x, right) + before = (x, right + radius) + after = (x + radius, right) + center = (x + radius, right + radius) + arc = Arc(center, radius, 180.0, 270.0, "visible") + return {"corner": corner, "before": before, "after": after, "arc": arc} + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + edit = self.corner_edit(part) + return ( + DimIntent( + kind="radius", + p1=edit["arc"].center, + p2=edit["before"], + text_override=f"R{float(self.radius):g}", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + if view == "front": + return None + return "a step fillet projects onto the end view as a circle that coincides with the step" + + +@_register +@dataclass(frozen=True, kw_only=True) +class Keyway(_Base): + """DIN 6885-1 keyway on the +Y face of a segment. + + Width and depth come from ``engineering.keyway.keyway_dimensions`` unless + both are given. Because the slot is modelled on the +Y face, the front view + shows its floor and its two end walls as visible lines; the width edges + coincide with the silhouette and are not drawn twice. + """ + + KIND: ClassVar[str] = "keyway" + segment: int + length: float + offset: float = 0.0 + from_end: str = "left" + width: float | None = None + depth: float | None = None + + def _table(self, part) -> dict: + index = _segment_index(part, self.segment) + diameter = part.segments[index].d_outer + if self.width is not None and self.depth is not None: + return {"width": float(self.width), "depth_shaft": float(self.depth)} + try: + table = keyway_dimensions(diameter) + except ValueError as exc: + raise ValueError(f"DIN 6885: {exc}") from exc + return { + "width": float(self.width) if self.width is not None else table["width"], + "depth_shaft": float(self.depth) if self.depth is not None else table["depth_shaft"], + } + + def _span(self, part) -> tuple[float, float, float, float, float]: + index = _segment_index(part, self.segment) + x0, x1 = segment_bounds(part)[index] + table = self._table(part) + offset = float(self.offset) + length = float(self.length) + if str(self.from_end).strip().lower() == "right": + xa = x1 - offset - length + else: + xa = x0 + offset + radius = part.segments[index].d_outer / 2.0 + return xa, xa + length, radius, table["width"], table["depth_shaft"] + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "Keyway") + index = _segment_index(part, self.segment) + _number(self.length, "length") + _number(self.offset, "offset", positive=False) + if str(self.from_end).strip().lower() not in ("left", "right"): + raise ValueError(f"from_end: expected 'left' or 'right', got {self.from_end!r}.") + segment_length = part.segments[index].length + if float(self.offset) + float(self.length) > segment_length + _EPS: + raise ValueError( + f"length {self.length} + offset {self.offset} exceeds segment {index}'s " + f"{segment_length} mm." + ) + table = self._table(part) + radius = part.segments[index].d_outer / 2.0 + if table["depth_shaft"] >= radius: + raise ValueError( + f"depth {table['depth_shaft']} mm is not smaller than the radius {radius} mm." + ) + + def _notch(self, part) -> dict: + """The notch the slot cuts out of the round section, once. + + The end view draws it and a transverse cut face closes on it, so both + come out of this one calculation and cannot disagree. ``Arc`` is CCW + from start to end, so the shaft that SURVIVES the slot runs from the + left notch wall the long way round to the right one; emitting + (right -> left) would draw the ~31 degree cap over the opening instead + of the ~329 degrees of material. + """ + _xa, _xb, radius, width, depth = self._span(part) + half = width / 2.0 + top = math.sqrt(max(radius * radius - half * half, 0.0)) + floor = radius - depth + return { + "radius": radius, + "start_deg": math.degrees(math.atan2(top, -half)) % 360.0, + "end_deg": math.degrees(math.atan2(top, half)) % 360.0, + "path": ((-half, top), (-half, floor), (half, floor), (half, top)), + } + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_revolved(part, "Keyway") + xa, xb, radius, width, depth = self._span(part) + if view == "front": + floor = radius - depth + return ( + Line((xa, radius), (xa, floor), "visible"), + Line((xa, floor), (xb, floor), "visible"), + Line((xb, floor), (xb, radius), "visible"), + ) + if view == "side": + notch = self._notch(part) + path = notch["path"] + return ( + Arc((0.0, 0.0), radius, notch["start_deg"], notch["end_deg"], "visible"), + *(Line(path[index], path[index + 1], "visible") for index in range(len(path) - 1)), + ) + return () + + def cross_section(self, part, station: float) -> dict | None: + """The notch, when the transverse plane falls inside the slot's length.""" + part = _require_revolved(part, "Keyway") + xa, xb, _radius, _width, _depth = self._span(part) + if not xa - _EPS <= float(station) <= xb + _EPS: + return None + return self._notch(part) + + def removal_box(self, part): + xa, xb, radius, _, depth = self._span(part) + return (xa, radius - depth, xb, radius) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + xa, xb, radius, width, depth = self._span(part) + if view == "front": + return (DimIntent(kind="linear", p1=(xa, radius), p2=(xb, radius), feature=self.id),) + if view == "side": + floor = radius - depth + return ( + DimIntent( + kind="linear", + p1=(-width / 2.0, floor), + p2=(width / 2.0, floor), + feature=self.id, + ), + DimIntent( + kind="linear", + p1=(0.0, -radius), + p2=(0.0, floor), + text_override=f"{radius + floor:g}", + feature=self.id, + ), + ) + return () + + def omission(self, part, view: str) -> str | None: + if view in ("front", "side"): + return None + return "a keyway on the +Y face coincides with the outline in the top view" + + +@_register +@dataclass(frozen=True, kw_only=True) +class Thread(_Base): + """An ISO 261 thread drawn with the ISO 6410 representation.""" + + KIND: ClassVar[str] = "thread" + segment: int + designation: str + length: float + offset: float = 0.0 + from_end: str = "left" + internal: bool = False + + def _parsed(self) -> dict: + return parse_designation(self.designation) + + def _span(self, part) -> tuple[float, float, float]: + index = _segment_index(part, self.segment) + x0, x1 = segment_bounds(part)[index] + length = float(self.length) + if str(self.from_end).strip().lower() == "right": + xa = x1 - float(self.offset) - length + else: + xa = x0 + float(self.offset) + return xa, xa + length, self._parsed()["d"] + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "Thread") + index = _segment_index(part, self.segment) + parsed = self._parsed() + _number(self.length, "length") + _number(self.offset, "offset", positive=False) + segment = part.segments[index] + if float(self.offset) + float(self.length) > segment.length + _EPS: + raise ValueError( + f"length {self.length} + offset {self.offset} exceeds segment {index}'s " + f"{segment.length} mm." + ) + if self.internal: + # ISO 6410: a tapped hole is modelled with the *drilled* bore, i.e. + # the minor diameter at 0.8 x major, and the major is what the + # section hatch then runs to (radial_edit below). + wanted = MINOR_RATIO * parsed["d"] + if abs(wanted - segment.d_inner) > 0.01: + raise ValueError( + f"{parsed['designation']} internal is drawn with its minor diameter at " + f"{wanted:g} mm (ISO 6410, 0.8 x major) but segment {index}'s bore is " + f"{segment.d_inner:g} mm. Drill the segment to the minor diameter, or " + "name the thread that fits it." + ) + elif abs(parsed["d"] - segment.d_outer) > 0.01: + raise ValueError( + f"{parsed['designation']} has a major diameter of {parsed['d']:g} mm but " + f"segment {index}'s outside is {segment.d_outer:g} mm. Cut the segment to the " + "thread diameter, or name the thread that fits it." + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_revolved(part, "Thread") + xa, xb, d = self._span(part) + if view == "front": + return thread_axial_prims(x0=xa, length=xb - xa, d=d, internal=bool(self.internal)) + if view == "side": + return thread_end_prims(center=(0.0, 0.0), d=d, internal=bool(self.internal)) + return () + + def radial_edit(self, part) -> tuple[dict, ...]: + if not self.internal: + return () + # ISO 6410 section convention: the hatch of an internal thread runs to + # the major diameter, so the cut face's bore is the major, not the minor. + xa, xb, d = self._span(part) + return ({"kind": "inner", "x0": xa, "x1": xb, "radius": d / 2.0},) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + xa, _, d = self._span(part) + return ( + DimIntent( + kind="diameter", + p1=(xa, d / 2.0), + p2=(xa, -d / 2.0), + text_override=self._parsed()["designation"], + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side") else f"a thread has no {view} projection" + + @property + def minor_ratio(self) -> float: + return MINOR_RATIO + + +@_register +@dataclass(frozen=True, kw_only=True) +class Undercut(_Base): + """A DIN 509 relief groove drawn from the profile the standard publishes. + + ``profile`` is a sequence of ``(along, depth)`` pairs in millimetres, + measured from the shoulder corner into the smaller cylinder. With no + explicit profile the registered DIN 509 table is asked - and refuses by + name, because no row is transcribed in this build. Nothing is reconstructed. + """ + + KIND: ClassVar[str] = "undercut" + axisymmetric: ClassVar[bool] = True + at: str + form: str = "E" + profile: tuple[tuple[float, float], ...] | None = None + + def _profile(self, part) -> tuple[tuple[float, float], ...]: + if self.profile is not None: + return tuple((float(a), float(b)) for a, b in self.profile) + _, radius, _ = _shoulder(self, part) + return undercut_profile(self.form, radius * 2.0) + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "Undercut") + _parse_at(self.at, part) + profile = self._profile(part) + if len(profile) < 2: + raise ValueError("profile: a relief profile needs at least two (along, depth) pairs.") + previous = -math.inf + for index, (along, depth) in enumerate(profile): + _number(along, f"profile[{index}].along", positive=False) + _number(depth, f"profile[{index}].depth", positive=False) + if along < previous - _EPS: + raise ValueError(f"profile[{index}].along: the profile must run monotonically.") + previous = along + _, radius, _ = _shoulder(self, part) + deepest = max(depth for _, depth in profile) + if deepest >= radius: + raise ValueError( + f"profile: the deepest relief {deepest} mm is not smaller than the " + f"{radius} mm radius it is cut into." + ) + + def _points(self, part) -> tuple[tuple[float, float], ...]: + x, radius, direction = _shoulder(self, part) + return tuple( + (x + direction * along, radius - depth) for along, depth in self._profile(part) + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + if view != "front": + return () + return (Poly(self._points(_require_revolved(part, "Undercut")), False, "visible"),) + + def removal_box(self, part): + points = self._points(part) + xs = [p[0] for p in points] + ys = [p[1] for p in points] + return (min(xs), min(ys), max(xs), max(ys)) + + def omission(self, part, view: str) -> str | None: + return None if view == "front" else f"a relief groove has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class RetainingGroove(_Base): + """A DIN 471 (shaft) or DIN 472 (bore) retaining-ring groove.""" + + KIND: ClassVar[str] = "retaining_groove" + axisymmetric: ClassVar[bool] = True + x: float + d: float + where: str = "shaft" + m: float | None = None + d2: float | None = None + standard: str | None = None + + def _standard(self) -> str: + if self.standard: + return " ".join(str(self.standard).split()).upper() + return "DIN 471" if str(self.where).strip().lower() == "shaft" else "DIN 472" + + def _dims(self) -> dict: + if self.m is not None and self.d2 is not None: + return {"m": float(self.m), "d2": float(self.d2)} + return ring_groove_dims(self._standard(), float(self.d)) + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "RetainingGroove") + where = str(self.where).strip().lower() + if where not in ("shaft", "bore"): + raise ValueError(f"where: expected 'shaft' or 'bore', got {self.where!r}.") + x = _number(self.x, "x", positive=False) + _number(self.d, "d") + if not 0.0 <= x <= part_length(part) + _EPS: + raise ValueError(f"x {x} is outside the part (0 .. {part_length(part)} mm).") + dims = self._dims() + _number(dims["m"], "m") + _number(dims["d2"], "d2") + radius = outer_radius_at(part, x) + bore = inner_radius_at(part, x) + if where == "shaft" and dims["d2"] / 2.0 >= radius: + raise ValueError( + f"d2 {dims['d2']} mm does not cut into the {radius * 2.0:g} mm shaft at x={x}." + ) + if where == "bore" and dims["d2"] / 2.0 <= bore: + raise ValueError( + f"d2 {dims['d2']} mm does not cut into the {bore * 2.0:g} mm bore at x={x}." + ) + + def _span(self, part) -> tuple[float, float, float, float]: + dims = self._dims() + x = float(self.x) + half = dims["m"] / 2.0 + surface = ( + outer_radius_at(part, x) + if str(self.where).strip().lower() == "shaft" + else inner_radius_at(part, x) + ) + return x - half, x + half, dims["d2"] / 2.0, surface + + def radial_edit(self, part) -> tuple[dict, ...]: + x0, x1, groove_r, _ = self._span(part) + kind = "outer" if str(self.where).strip().lower() == "shaft" else "inner" + return ({"kind": kind, "x0": x0, "x1": x1, "radius": groove_r},) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + if view != "front": + return () + x0, x1, groove_r, surface = self._span(_require_revolved(part, "RetainingGroove")) + return ( + Line((x0, surface), (x0, groove_r), "visible"), + Line((x0, groove_r), (x1, groove_r), "visible"), + Line((x1, groove_r), (x1, surface), "visible"), + ) + + def removal_box(self, part): + x0, x1, groove_r, surface = self._span(part) + return (x0, min(groove_r, surface), x1, max(groove_r, surface)) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + x0, x1, groove_r, _ = self._span(part) + dims = self._dims() + return ( + DimIntent(kind="linear", p1=(x0, groove_r), p2=(x1, groove_r), feature=self.id), + DimIntent( + kind="diameter", + p1=(float(self.x), groove_r), + p2=(float(self.x), -groove_r), + text_override=f"⌀{dims['d2']:g} ({self._standard()})", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view == "front" else f"a ring groove has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class CentreHole(_Base): + """A DIN 332-1 centre hole, forms A and B. Form R is refused, not approximated.""" + + KIND: ClassVar[str] = "centre_hole" + at: str = "end" + form: str = "A" + size: float = 0.0 + d1: float | None = None + d2: float | None = None + d3: float | None = None + t: float | None = None + + def _dims(self) -> dict: + form = str(self.form).strip().upper() + explicit = self.d1 is not None and self.d2 is not None and self.t is not None + if form == "B": + explicit = explicit and self.d3 is not None + if explicit: + return { + "d1": float(self.d1), + "d2": float(self.d2), + "d3": float(self.d3) if self.d3 is not None else None, + "t": float(self.t), + } + return centre_hole_dims(self.form, float(self.size)) + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "CentreHole") + _parse_at(self.at, part, allow_step=False) + if str(self.form).strip().upper() == "R": + # the table module owns the wording; ask it so there is one message + centre_hole_dims("R", float(self.size)) + dims = self._dims() + _number(dims["d1"], "d1") + _number(dims["d2"], "d2") + _number(dims["t"], "t") + if dims["d2"] <= dims["d1"]: + raise ValueError(f"d2 {dims['d2']} must be larger than d1 {dims['d1']}.") + kind, _ = _parse_at(self.at, part, allow_step=False) + x = 0.0 if kind == "start" else part_length(part) + if dims["d2"] / 2.0 >= outer_radius_at(part, x): + raise ValueError( + f"d2 {dims['d2']} mm does not fit the {outer_radius_at(part, x) * 2.0:g} mm face." + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_revolved(part, "CentreHole") + dims = self._dims() + kind, _ = _parse_at(self.at, part, allow_step=False) + face = 0.0 if kind == "start" else part_length(part) + step = 1.0 if kind == "start" else -1.0 + r1, r2 = dims["d1"] / 2.0, dims["d2"] / 2.0 + cone = (r2 - r1) / math.tan(math.radians(30.0)) # 60 deg included angle + if view == "front": + prims: list[Prim] = [ + Line((face, r2), (face + step * cone, r1), "hidden"), + Line((face, -r2), (face + step * cone, -r1), "hidden"), + Line((face + step * cone, r1), (face + step * dims["t"], r1), "hidden"), + Line((face + step * cone, -r1), (face + step * dims["t"], -r1), "hidden"), + Line( + (face + step * dims["t"], -r1), + (face + step * dims["t"], r1), + "hidden", + ), + ] + if str(self.form).strip().upper() == "B" and dims.get("d3"): + r3 = dims["d3"] / 2.0 + guard = (r3 - r2) / math.tan(math.radians(60.0)) # 120 deg protective cone + prims.insert(0, Line((face, r3), (face + step * guard, r2), "hidden")) + prims.insert(1, Line((face, -r3), (face + step * guard, -r2), "hidden")) + return tuple(prims) + if view == "side": + circles: list[Prim] = [ + Circle((0.0, 0.0), r1, "hidden"), + Circle((0.0, 0.0), r2, "hidden"), + ] + if str(self.form).strip().upper() == "B" and dims.get("d3"): + circles.append(Circle((0.0, 0.0), dims["d3"] / 2.0, "hidden")) + return tuple(circles) + return () + + def removal_box(self, part): + dims = self._dims() + kind, _ = _parse_at(self.at, part, allow_step=False) + face = 0.0 if kind == "start" else part_length(part) + step = 1.0 if kind == "start" else -1.0 + far = face + step * dims["t"] + outer = max(dims["d2"], dims.get("d3") or 0.0) / 2.0 + return (min(face, far), -outer, max(face, far), outer) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side") else f"a centre hole has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class ORingGroove(_Base): + """An ISO 3601-2 radial O-ring housing. An axial (face) groove is refused.""" + + KIND: ClassVar[str] = "oring_groove" + axisymmetric: ClassVar[bool] = True + x: float + cord: float + where: str = "shaft" + housing: str = "radial" + b: float | None = None + h: float | None = None + + def _dims(self) -> dict: + if self.b is not None and self.h is not None: + return {"b": float(self.b), "h": float(self.h)} + return oring_groove_dims(float(self.cord), str(self.housing)) + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "ORingGroove") + if str(self.housing).strip().lower() not in ORING_KINDS: + raise ValueError(f"housing: expected one of {ORING_KINDS}, got {self.housing!r}.") + if str(self.housing).strip().lower() == "axial": + raise ValueError( + "housing 'axial': a face groove is not on the revolved silhouette and is " + "refused rather than drawn as if it were radial. Model the face as a " + "prismatic pocket." + ) + if str(self.where).strip().lower() not in ("shaft", "bore"): + raise ValueError(f"where: expected 'shaft' or 'bore', got {self.where!r}.") + x = _number(self.x, "x", positive=False) + _number(self.cord, "cord") + if not 0.0 <= x <= part_length(part) + _EPS: + raise ValueError(f"x {x} is outside the part (0 .. {part_length(part)} mm).") + dims = self._dims() + _number(dims["b"], "b") + _number(dims["h"], "h") + radius = outer_radius_at(part, x) + if str(self.where).strip().lower() == "shaft" and dims["h"] >= radius: + raise ValueError(f"h {dims['h']} mm is not smaller than the radius {radius} mm.") + + def _span(self, part) -> tuple[float, float, float, float]: + dims = self._dims() + x = float(self.x) + half = dims["b"] / 2.0 + if str(self.where).strip().lower() == "shaft": + surface = outer_radius_at(part, x) + return x - half, x + half, surface - dims["h"], surface + surface = inner_radius_at(part, x) + return x - half, x + half, surface + dims["h"], surface + + def radial_edit(self, part) -> tuple[dict, ...]: + x0, x1, groove_r, _ = self._span(part) + kind = "outer" if str(self.where).strip().lower() == "shaft" else "inner" + return ({"kind": kind, "x0": x0, "x1": x1, "radius": groove_r},) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + if view != "front": + return () + x0, x1, groove_r, surface = self._span(_require_revolved(part, "ORingGroove")) + return ( + Line((x0, surface), (x0, groove_r), "visible"), + Line((x0, groove_r), (x1, groove_r), "visible"), + Line((x1, groove_r), (x1, surface), "visible"), + ) + + def removal_box(self, part): + x0, x1, groove_r, surface = self._span(part) + return (x0, min(groove_r, surface), x1, max(groove_r, surface)) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + x0, x1, groove_r, _ = self._span(part) + return (DimIntent(kind="linear", p1=(x0, groove_r), p2=(x1, groove_r), feature=self.id),) + + def omission(self, part, view: str) -> str | None: + return None if view == "front" else f"an O-ring groove has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class CrossHole(_Base): + """A radial hole through (or into) a revolved part. + + Only a radial hole (``angle`` 90 degrees) is projected. An inclined cross + hole is refused rather than drawn as if it were radial. + """ + + KIND: ClassVar[str] = "cross_hole" + x: float + diameter: float + angle: float = 90.0 + depth: float | None = None + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "CrossHole") + x = _number(self.x, "x", positive=False) + diameter = _number(self.diameter, "diameter") + angle = _number(self.angle, "angle", positive=False) + if abs(angle - 90.0) > _EPS: + raise ValueError( + f"angle {angle}: only a radial cross hole (angle=90) is projected by this " + "engine; an inclined hole is refused rather than drawn as if it were radial." + ) + if not 0.0 <= x <= part_length(part) + _EPS: + raise ValueError(f"x {x} is outside the part (0 .. {part_length(part)} mm).") + radius = outer_radius_at(part, x) + if diameter >= 2.0 * radius: + raise ValueError( + f"diameter {diameter} mm is not smaller than the {2.0 * radius:g} mm outside " + f"diameter at x={x}." + ) + if self.depth is not None: + depth = _number(self.depth, "depth") + if depth > 2.0 * radius + _EPS: + raise ValueError(f"depth {depth} mm is deeper than the part at x={x}.") + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_revolved(part, "CrossHole") + x = float(self.x) + half = float(self.diameter) / 2.0 + radius = outer_radius_at(part, x) + if view == "front": + bottom = -radius if self.depth is None else radius - float(self.depth) + prims: list[Prim] = [ + Line((x - half, bottom), (x - half, radius), "hidden"), + Line((x + half, bottom), (x + half, radius), "hidden"), + ] + if self.depth is not None: + prims.append(Line((x - half, bottom), (x + half, bottom), "hidden")) + return tuple(prims) + if view == "side": + return ( + Line((-radius, half), (radius, half), "hidden"), + Line((-radius, -half), (radius, -half), "hidden"), + ) + return () + + def removal_box(self, part): + x = float(self.x) + half = float(self.diameter) / 2.0 + radius = outer_radius_at(part, x) + bottom = -radius if self.depth is None else radius - float(self.depth) + return (x - half, bottom, x + half, radius) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + x = float(self.x) + half = float(self.diameter) / 2.0 + return ( + DimIntent( + kind="diameter", + p1=(x - half, 0.0), + p2=(x + half, 0.0), + text_override=f"⌀{float(self.diameter):g}", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side") else f"a cross hole has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class HoleCircle(_Base): + """A bolt circle on a face: hidden pairs in the axial view, real holes in the end view.""" + + KIND: ClassVar[str] = "hole_circle" + x: float + pcd: float + count: int + diameter: float + start_angle: float = 0.0 + thread: str | None = None + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "HoleCircle") + x = _number(self.x, "x", positive=False) + pcd = _number(self.pcd, "pcd") + diameter = _number(self.diameter, "diameter") + _number(self.start_angle, "start_angle", positive=False) + if int(self.count) < 2: + raise ValueError(f"count: a bolt circle needs at least 2 holes, got {self.count}.") + if not 0.0 <= x <= part_length(part) + _EPS: + raise ValueError(f"x {x} is outside the part (0 .. {part_length(part)} mm).") + radius = outer_radius_at(part, x) + bore = inner_radius_at(part, x) + if pcd / 2.0 + diameter / 2.0 > radius + _EPS: + raise ValueError( + f"pcd {pcd} with ⌀{diameter} reaches {pcd / 2.0 + diameter / 2.0:.3f} mm, " + f"past the {radius:.3f} mm outside radius at x={x}." + ) + if pcd / 2.0 - diameter / 2.0 < bore - _EPS: + raise ValueError( + f"pcd {pcd} with ⌀{diameter} reaches {pcd / 2.0 - diameter / 2.0:.3f} mm, " + f"inside the {bore:.3f} mm bore at x={x}." + ) + if self.thread: + parse_designation(self.thread) + + def _centers(self) -> tuple[tuple[float, float], ...]: + count = int(self.count) + radius = float(self.pcd) / 2.0 + start = math.radians(float(self.start_angle)) + return tuple( + ( + radius * math.cos(start + 2.0 * math.pi * k / count), + radius * math.sin(start + 2.0 * math.pi * k / count), + ) + for k in range(count) + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_revolved(part, "HoleCircle") + pcd_r = float(self.pcd) / 2.0 + half = float(self.diameter) / 2.0 + if view == "front": + index, _, _, _ = _segment_at_index(part, float(self.x)) + x0, x1 = segment_bounds(part)[index] + prims: list[Prim] = [] + for sign in (1.0, -1.0): + for offset in (half, -half): + y = sign * (pcd_r + offset) + prims.append(Line((x0, y), (x1, y), "hidden")) + prims.append(Line((x0, sign * pcd_r), (x1, sign * pcd_r), "center")) + return tuple(prims) + if view == "side": + prims = [Circle((0.0, 0.0), pcd_r, "center")] + for cx, cy in self._centers(): + prims.append(Circle((cx, cy), half, "visible")) + prims.append(Line((cx - half - 2.0, cy), (cx + half + 2.0, cy), "center")) + prims.append(Line((cx, cy - half - 2.0), (cx, cy + half + 2.0), "center")) + return tuple(prims) + return () + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "side": + return () + pcd_r = float(self.pcd) / 2.0 + half = float(self.diameter) / 2.0 + first = self._centers()[0] + label = self.thread or f"⌀{float(self.diameter):g}" + return ( + DimIntent( + kind="diameter", + p1=(first[0] - half, first[1]), + p2=(first[0] + half, first[1]), + text_override=f"{int(self.count)}x {label}", + feature=self.id, + ), + DimIntent( + kind="diameter", + p1=(-pcd_r, 0.0), + p2=(pcd_r, 0.0), + text_override=f"⌀{float(self.pcd):g}", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side") else f"a bolt circle has no {view} projection" + + +def _segment_at_index(part: RevolvedPart, x: float) -> tuple[int, float, float, Any]: + bounds = zip(segment_bounds(part), part.segments, strict=True) + for index, ((x0, x1), segment) in enumerate(bounds): + if x0 - _EPS <= x <= x1 + _EPS: + return index, x0, x1, segment + raise ValueError(f"x={x} is outside the part (0 .. {part_length(part)} mm).") + + +@_register +@dataclass(frozen=True, kw_only=True) +class AxialBore(_Base): + """A bore drilled in from one end. Expressed through the radial profile.""" + + KIND: ClassVar[str] = "axial_bore" + at: str = "start" + diameter: float + depth: float | None = None + + def _span(self, part) -> tuple[float, float]: + kind, _ = _parse_at(self.at, part, allow_step=False) + length = part_length(part) + depth = length if self.depth is None else float(self.depth) + return (0.0, depth) if kind == "start" else (length - depth, length) + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "AxialBore") + _parse_at(self.at, part, allow_step=False) + diameter = _number(self.diameter, "diameter") + if self.depth is not None: + depth = _number(self.depth, "depth") + if depth > part_length(part) + _EPS: + raise ValueError( + f"depth {depth} mm is longer than the part {part_length(part)} mm." + ) + x0, x1 = self._span(part) + for x in (x0 + _EPS, (x0 + x1) / 2.0, x1 - _EPS): + if diameter >= 2.0 * outer_radius_at(part, x): + raise ValueError( + f"diameter {diameter} mm is not smaller than the " + f"{2.0 * outer_radius_at(part, x):g} mm outside diameter at x={x:.3f}." + ) + for other in getattr(part, "features", ()): + if other is self or not isinstance(other, AxialBore): + continue + ox0, ox1 = other._span(part) + if min(x1, ox1) - max(x0, ox0) > _EPS: + raise ValueError( + f"bores {self.id!r} and {other.id!r} both remove material over " + f"x {max(x0, ox0):.3f}..{min(x1, ox1):.3f}. A bore that changes diameter " + "is two segments, not two features." + ) + + def radial_edit(self, part) -> tuple[dict, ...]: + x0, x1 = self._span(part) + return ({"kind": "inner", "x0": x0, "x1": x1, "radius": float(self.diameter) / 2.0},) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_revolved(part, "AxialBore") + half = float(self.diameter) / 2.0 + kind, _ = _parse_at(self.at, part, allow_step=False) + x0, x1 = self._span(part) + if view == "front" and self.depth is not None: + bottom = x1 if kind == "start" else x0 + return (Line((bottom, -half), (bottom, half), "hidden"),) + if view == "side": + near = str(ctx.get("side", "right")).strip().lower() + open_here = (near == "left" and kind == "start") or (near == "right" and kind == "end") + return (Circle((0.0, 0.0), half, "visible" if open_here else "hidden"),) + return () + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "side": + return () + half = float(self.diameter) / 2.0 + return ( + DimIntent( + kind="diameter", + p1=(-half, 0.0), + p2=(half, 0.0), + text_override=f"⌀{float(self.diameter):g}", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None # the bore always reaches the drawing through the radial profile + + +@_register +@dataclass(frozen=True, kw_only=True) +class GearTeeth(_Base): + """Involute spur/helical teeth on a segment, from the repository's own generator.""" + + KIND: ClassVar[str] = "gear_teeth" + segment: int + module: float + teeth: int + pressure_angle: float = 20.0 + helix_angle: float = 0.0 + hand: str = "RH" + + def _radii(self) -> tuple[float, float, float]: + module = float(self.module) + teeth = int(self.teeth) + pitch = module * teeth / 2.0 + return pitch + module, pitch, pitch - 1.25 * module + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "GearTeeth") + index = _segment_index(part, self.segment) + _number(self.module, "module") + angle = _number(self.pressure_angle, "pressure_angle") + if int(self.teeth) < 6: + raise ValueError( + f"teeth: the involute generator needs at least 6 teeth, got {self.teeth}." + ) + if not 10.0 <= angle <= 35.0: + raise ValueError(f"pressure_angle {angle}: expected between 10 and 35 degrees.") + if str(self.hand).strip().upper() not in ("RH", "LH"): + raise ValueError(f"hand: expected 'RH' or 'LH', got {self.hand!r}.") + tip, _, _ = self._radii() + outside = part.segments[index].d_outer + if abs(tip * 2.0 - outside) > 0.01: + raise ValueError( + f"m={float(self.module):g} z={int(self.teeth)} gives a tip diameter of " + f"{tip * 2.0:g} mm, but segment {index} is {outside:g} mm. Cut the segment to " + "the tip diameter, or name the module and tooth count that fit it." + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_revolved(part, "GearTeeth") + index = _segment_index(part, self.segment) + _, pitch, root = self._radii() + if view == "front": + x0, x1 = segment_bounds(part)[index] + prims: list[Prim] = [ + Line((x0, root), (x1, root), "visible"), + Line((x0, -root), (x1, -root), "visible"), + Line((x0, pitch), (x1, pitch), "center"), + Line((x0, -pitch), (x1, -pitch), "center"), + ] + if abs(float(self.helix_angle)) > _EPS: + # ISO 2203: three thin lines at the helix angle mark the hand. + mid = (x0 + x1) / 2.0 + run = (x1 - x0) / 3.0 + rise = run * math.tan(math.radians(float(self.helix_angle))) + sign = 1.0 if str(self.hand).strip().upper() == "RH" else -1.0 + for offset in (-run, 0.0, run): + prims.append( + Line( + (mid + offset - run / 2.0, -pitch / 2.0), + (mid + offset + run / 2.0, -pitch / 2.0 + sign * rise), + "phantom", + ) + ) + return tuple(prims) + if view == "side": + outline = generate_full_gear_outline( + float(self.module), + int(self.teeth), + float(self.pressure_angle), + float(self.helix_angle), + str(self.hand).strip().upper(), + (0.0, 0.0), + ) + return ( + Poly(tuple(outline), True, "visible"), + Circle((0.0, 0.0), pitch, "center"), + ) + return () + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + tip, _, _ = self._radii() + if view != "side": + return () + return ( + DimIntent( + kind="diameter", + p1=(-tip, 0.0), + p2=(tip, 0.0), + text_override=(f"⌀{tip * 2.0:g} (m={float(self.module):g}, z={int(self.teeth)})"), + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side") else f"gear teeth have no {view} projection" + + +# -- prismatic features ------------------------------------------------------ +# +# View frames for a prismatic part: +# "front" : the outline's own XY +# "side" : u = part Y, v = z in [0, thickness] (looking along -X) +# "top" : u = part X, v = z in [0, thickness] (looking along -Y) +# +# THE MACHINED FACE IS v = thickness. The front view looks at the outline from +# +Z, so the face it shows -- the face a feature placed by its (x, y) is cut +# from -- is the top of the side and top views, and every blind feature hangs +# down from there toward v = 0. Hole, Pocket and anything added later must +# agree on this: a drawing whose pocket is milled from one face and whose blind +# hole is drilled from the other is silently wrong, and no validator can tell. + + +@_register +@dataclass(frozen=True, kw_only=True) +class Hole(_Base): + """A hole in a prismatic part, optionally counterbored, countersunk or tapped. + + Drilled from the machined face (``v = thickness``, the one the front view + looks at), so a blind hole hangs down from there and a counterbore steps + down from the same face -- the convention ``Pocket`` follows too. + """ + + KIND: ClassVar[str] = "hole" + x: float + y: float + diameter: float + depth: float | None = None + cbore_d: float | None = None + cbore_depth: float | None = None + csink_d: float | None = None + thread: str | None = None + + def validate(self, part) -> None: + super().validate(part) + part = _require_prismatic(part, "Hole") + _number(self.x, "x", positive=False) + _number(self.y, "y", positive=False) + diameter = _number(self.diameter, "diameter") + if not point_in_outline(part, float(self.x), float(self.y)): + raise ValueError( + f"({float(self.x):g}, {float(self.y):g}) is outside the part outline " + f"{outline_bbox(part)}." + ) + if self.depth is not None: + depth = _number(self.depth, "depth") + if depth > part.thickness + _EPS: + raise ValueError(f"depth {depth} is deeper than the {part.thickness} mm thickness.") + for name, value in (("cbore_d", self.cbore_d), ("csink_d", self.csink_d)): + if value is not None and _number(value, name) <= diameter: + raise ValueError(f"{name} {value} must be larger than the hole {diameter}.") + if self.cbore_d is not None and self.cbore_depth is None: + raise ValueError("cbore_depth: a counterbore needs its depth.") + if self.thread: + parsed = parse_designation(self.thread) + if abs(parsed["d"] - diameter) > 0.01: + raise ValueError( + f"{parsed['designation']} has a major diameter of {parsed['d']:g} mm but " + f"diameter is {diameter:g} mm." + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_prismatic(part, "Hole") + x, y = float(self.x), float(self.y) + half = float(self.diameter) / 2.0 + if view == "front": + prims: list[Prim] = [] + if self.thread: + minor = MINOR_RATIO * float(self.diameter) / 2.0 + prims.append(Circle((x, y), minor, "visible")) + prims.append(Arc((x, y), half, 90.0, 0.0, "visible")) + else: + prims.append(Circle((x, y), half, "visible")) + for extra in (self.cbore_d, self.csink_d): + if extra is not None: + prims.append(Circle((x, y), float(extra) / 2.0, "visible")) + extras = [float(v) / 2.0 for v in (self.cbore_d, self.csink_d) if v is not None] + reach = max([half, *extras]) + 3.0 + prims.append(Line((x - reach, y), (x + reach, y), "center")) + prims.append(Line((x, y - reach), (x, y + reach), "center")) + return tuple(prims) + if view in ("side", "top"): + u = y if view == "side" else x + face = part.thickness + depth = part.thickness if self.depth is None else float(self.depth) + floor = face - depth + prims = [ + Line((u - half, face), (u - half, floor), "hidden"), + Line((u + half, face), (u + half, floor), "hidden"), + ] + if self.depth is not None: + prims.append(Line((u - half, floor), (u + half, floor), "hidden")) + if self.cbore_d is not None: + cb = float(self.cbore_d) / 2.0 + cf = face - float(self.cbore_depth) + prims.append(Line((u - cb, face), (u - cb, cf), "hidden")) + prims.append(Line((u + cb, face), (u + cb, cf), "hidden")) + prims.append(Line((u - cb, cf), (u + cb, cf), "hidden")) + return tuple(prims) + return () + + def removal_box(self, part): + return _box_around(self._centre(), self._outer_radius()) + + def _centre(self) -> tuple[float, float]: + return (float(self.x), float(self.y)) + + def _outer_radius(self) -> float: + """The widest circle the plane can meet: the drill, or a counterbore or + countersink that is wider than it.""" + extras = [float(v) / 2.0 for v in (self.cbore_d, self.csink_d) if v is not None] + return max([float(self.diameter) / 2.0, *extras]) + + def cut_spans(self, part, p1, unit): + """The true chord of the drilled circle, not the square that bounds it. + + On an 80x50x8 plate with a 10 mm hole at (40, 25): a plane at y=29.9 + grazes the hole and cuts a 1.990 mm chord, leaving 624.08 mm2 of cut + face. The bounding box reports 10 mm at every offset and hatches + 560 mm2 - a 64 mm2 error, with an 8 mm gap drawn through solid metal. + """ + return _circle_chord(self._centre(), self._outer_radius(), p1, unit) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + x, y = float(self.x), float(self.y) + half = float(self.diameter) / 2.0 + label = self.thread or f"⌀{float(self.diameter):g}" + return ( + DimIntent( + kind="diameter", + p1=(x - half, y), + p2=(x + half, y), + text_override=label, + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side", "top") else f"a hole has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class Slot(_Base): + """An obround slot: two parallel flanks closed by two semicircular ends.""" + + KIND: ClassVar[str] = "slot" + x: float + y: float + length: float + width: float + angle: float = 0.0 + + def _ends(self) -> tuple[tuple[float, float], tuple[float, float], float]: + half_run = (float(self.length) - float(self.width)) / 2.0 + theta = math.radians(float(self.angle)) + ux, uy = math.cos(theta), math.sin(theta) + x, y = float(self.x), float(self.y) + return ( + (x - half_run * ux, y - half_run * uy), + (x + half_run * ux, y + half_run * uy), + float(self.width) / 2.0, + ) + + def validate(self, part) -> None: + super().validate(part) + part = _require_prismatic(part, "Slot") + _number(self.x, "x", positive=False) + _number(self.y, "y", positive=False) + length = _number(self.length, "length") + width = _number(self.width, "width") + _number(self.angle, "angle", positive=False) + if length <= width: + raise ValueError( + f"length {length} must be greater than width {width}; a slot as wide as it is " + "long is a hole." + ) + for point in self._ends()[:2]: + if not point_in_outline(part, point[0], point[1]): + raise ValueError( + f"slot end ({point[0]:g}, {point[1]:g}) is outside the part outline " + f"{outline_bbox(part)}." + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_prismatic(part, "Slot") + (c1, c2, half) = self._ends() + theta = math.radians(float(self.angle)) + nx, ny = -math.sin(theta), math.cos(theta) + if view == "front": + return ( + Line( + (c1[0] + half * nx, c1[1] + half * ny), + (c2[0] + half * nx, c2[1] + half * ny), + "visible", + ), + Line( + (c1[0] - half * nx, c1[1] - half * ny), + (c2[0] - half * nx, c2[1] - half * ny), + "visible", + ), + Arc( + c2, + half, + (float(self.angle) - 90.0) % 360.0, + (float(self.angle) + 90.0) % 360.0, + "visible", + ), + Arc( + c1, + half, + (float(self.angle) + 90.0) % 360.0, + (float(self.angle) + 270.0) % 360.0, + "visible", + ), + Line((c1[0], c1[1]), (c2[0], c2[1]), "center"), + ) + if view in ("side", "top"): + box = self.removal_box(part) + u0, u1 = (box[1], box[3]) if view == "side" else (box[0], box[2]) + return ( + Line((u0, 0.0), (u0, part.thickness), "hidden"), + Line((u1, 0.0), (u1, part.thickness), "hidden"), + ) + return () + + def removal_box(self, part): + (c1, c2, half) = self._ends() + xs = (c1[0] - half, c1[0] + half, c2[0] - half, c2[0] + half) + ys = (c1[1] - half, c1[1] + half, c2[1] - half, c2[1] + half) + return (min(xs), min(ys), max(xs), max(ys)) + + def cut_spans(self, part, p1, unit): + """The true chord of the obround: the union of its two end circles and + the rectangle between them. The bounding box of an angled slot is far + wider than the slot itself, so the box would erase material the slot + never touches.""" + (c1, c2, half) = self._ends() + middle = ((c1[0] + c2[0]) / 2.0, (c1[1] + c2[1]) / 2.0) + run = math.dist(c1, c2) + return _merge_spans( + [ + *_circle_chord(c1, half, p1, unit), + *_circle_chord(c2, half, p1, unit), + *_rotated_box_chord(middle, run / 2.0, half, float(self.angle), p1, unit), + ] + ) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + (c1, c2, half) = self._ends() + theta = math.radians(float(self.angle)) + nx, ny = -math.sin(theta), math.cos(theta) + return ( + DimIntent( + kind="aligned", + p1=(c1[0], c1[1]), + p2=(c2[0], c2[1]), + text_override=f"{float(self.length):g}", + feature=self.id, + ), + DimIntent( + kind="aligned", + p1=(c2[0] + half * nx, c2[1] + half * ny), + p2=(c2[0] - half * nx, c2[1] - half * ny), + text_override=f"{float(self.width):g}", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side", "top") else f"a slot has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class Pocket(_Base): + """A milled pocket. ``no_section_hatch`` marks a rib or web (ISO 128-3). + + Milled from the machined face (``v = thickness``), the same face ``Hole`` + drills from. + """ + + KIND: ClassVar[str] = "pocket" + outline: tuple[tuple[float, float], ...] + depth: float + no_section_hatch: bool = False + + def validate(self, part) -> None: + super().validate(part) + part = _require_prismatic(part, "Pocket") + depth = _number(self.depth, "depth") + if depth >= part.thickness - _EPS: + raise ValueError( + f"depth {depth} is not smaller than the {part.thickness} mm thickness; " + "a pocket that goes through is a Hole or a Slot." + ) + points = tuple((float(x), float(y)) for x, y in self.outline) + if len(points) < 3: + raise ValueError(f"outline: a pocket needs at least 3 vertices, got {len(points)}.") + from engineering.measure import is_self_intersecting + + if is_self_intersecting(points) is True: + raise ValueError("outline: the pocket outline crosses itself.") + for index, point in enumerate(points): + if not point_in_outline(part, point[0], point[1]): + raise ValueError( + f"outline[{index}] ({point[0]:g}, {point[1]:g}) is outside the part outline " + f"{outline_bbox(part)}." + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_prismatic(part, "Pocket") + points = tuple((float(x), float(y)) for x, y in self.outline) + if view == "front": + return (Poly(points, True, "visible"),) + if view in ("side", "top"): + box = self.removal_box(part) + u0, u1 = (box[1], box[3]) if view == "side" else (box[0], box[2]) + face = part.thickness + floor = face - float(self.depth) + return ( + Line((u0, face), (u0, floor), "hidden"), + Line((u0, floor), (u1, floor), "hidden"), + Line((u1, floor), (u1, face), "hidden"), + ) + return () + + def cut_spans(self, part, p1, unit): + """The chord of the pocket's own outline, not of the box around it: a + pocket is only rectangular when its outline is.""" + return _polygon_chord( + tuple((float(x), float(y)) for x, y in self.outline), + p1, + unit, + what=f"pocket {self.id!r}", + ) + + def removal_box(self, part): + xs = [float(x) for x, _ in self.outline] + ys = [float(y) for _, y in self.outline] + return (min(xs), min(ys), max(xs), max(ys)) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side", "top") else f"a pocket has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class CornerFillet(_Base): + """A rounded outline corner: two tangent points and the arc between them.""" + + KIND: ClassVar[str] = "corner_fillet" + vertex: int + radius: float + + def _frame(self, part): + points = _require_prismatic(part, "CornerFillet").outline + count = len(points) + index = int(self.vertex) + if not 0 <= index < count: + raise ValueError(f"vertex {index} is outside the outline (0-{count - 1}).") + v = points[index] + p = points[(index - 1) % count] + n = points[(index + 1) % count] + return v, p, n + + def validate(self, part) -> None: + super().validate(part) + radius = _number(self.radius, "radius") + v, p, n = self._frame(part) + edge_p = math.dist(v, p) + edge_n = math.dist(v, n) + u1 = ((p[0] - v[0]) / edge_p, (p[1] - v[1]) / edge_p) + u2 = ((n[0] - v[0]) / edge_n, (n[1] - v[1]) / edge_n) + cosine = max(-1.0, min(1.0, u1[0] * u2[0] + u1[1] * u2[1])) + half = math.acos(cosine) / 2.0 + if half < 1e-6 or abs(cosine + 1.0) < 1e-9: + raise ValueError( + f"vertex {self.vertex} is a straight run, there is no corner to round." + ) + tangent = radius / math.tan(half) + if tangent > min(edge_p, edge_n) / 2.0 + _EPS: + raise ValueError( + f"radius {radius} needs {tangent:.3f} mm of edge, more than half the shorter " + f"adjacent edge ({min(edge_p, edge_n):.3f} mm)." + ) + + def outline_edit(self, part) -> dict: + v, p, n = self._frame(part) + radius = float(self.radius) + edge_p = math.dist(v, p) + edge_n = math.dist(v, n) + u1 = ((p[0] - v[0]) / edge_p, (p[1] - v[1]) / edge_p) + u2 = ((n[0] - v[0]) / edge_n, (n[1] - v[1]) / edge_n) + cosine = max(-1.0, min(1.0, u1[0] * u2[0] + u1[1] * u2[1])) + half = math.acos(cosine) / 2.0 + tangent = radius / math.tan(half) + before = (v[0] + u1[0] * tangent, v[1] + u1[1] * tangent) + after = (v[0] + u2[0] * tangent, v[1] + u2[1] * tangent) + bisector = (u1[0] + u2[0], u1[1] + u2[1]) + norm = math.hypot(*bisector) + center = ( + v[0] + bisector[0] / norm * (radius / math.sin(half)), + v[1] + bisector[1] / norm * (radius / math.sin(half)), + ) + start = math.degrees(math.atan2(before[1] - center[1], before[0] - center[0])) % 360.0 + end = math.degrees(math.atan2(after[1] - center[1], after[0] - center[0])) % 360.0 + cross = u1[0] * u2[1] - u1[1] * u2[0] + if cross > 0: # the arc runs the other way round + start, end = end, start + return { + "vertex": int(self.vertex), + "before": before, + "after": after, + "arc": Arc(center, radius, start, end, "visible"), + } + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + edit = self.outline_edit(part) + return ( + DimIntent( + kind="radius", + p1=edit["arc"].center, + p2=edit["before"], + text_override=f"R{float(self.radius):g}", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view == "front" else f"a corner radius has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class CornerChamfer(_Base): + """A bevelled outline corner: two points and the straight run between them.""" + + KIND: ClassVar[str] = "corner_chamfer" + vertex: int + size: float + + def _frame(self, part): + points = _require_prismatic(part, "CornerChamfer").outline + count = len(points) + index = int(self.vertex) + if not 0 <= index < count: + raise ValueError(f"vertex {index} is outside the outline (0-{count - 1}).") + return points[index], points[(index - 1) % count], points[(index + 1) % count] + + def validate(self, part) -> None: + super().validate(part) + size = _number(self.size, "size") + v, p, n = self._frame(part) + shortest = min(math.dist(v, p), math.dist(v, n)) + if size > shortest / 2.0 + _EPS: + raise ValueError( + f"size {size} is more than half the shorter adjacent edge ({shortest:.3f} mm)." + ) + + def outline_edit(self, part) -> dict: + v, p, n = self._frame(part) + size = float(self.size) + edge_p = math.dist(v, p) + edge_n = math.dist(v, n) + before = (v[0] + (p[0] - v[0]) / edge_p * size, v[1] + (p[1] - v[1]) / edge_p * size) + after = (v[0] + (n[0] - v[0]) / edge_n * size, v[1] + (n[1] - v[1]) / edge_n * size) + return {"vertex": int(self.vertex), "before": before, "after": after, "arc": None} + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + edit = self.outline_edit(part) + return ( + DimIntent( + kind="aligned", + p1=edit["before"], + p2=edit["after"], + text_override=f"{float(self.size):g}x45°", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view == "front" else f"a corner chamfer has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class BendLine(_Base): + """A sheet-metal bend line, **representation only**. + + Spec section 2 puts flat patterns and bend allowance out of scope: no + developed length is computed here, because a wrong developed length is a + scrapped part. This feature draws the phantom line and its R/angle note and + claims nothing else. + """ + + KIND: ClassVar[str] = "bend_line" + p1: tuple[float, float] + p2: tuple[float, float] + angle: float + inside_radius: float + direction: str = "up" + + def validate(self, part) -> None: + super().validate(part) + _require_prismatic(part, "BendLine") + for name, point in (("p1", self.p1), ("p2", self.p2)): + if not isinstance(point, (tuple, list)) or len(point) != 2: + raise ValueError(f"{name}: expected [x, y], got {point!r}.") + _number(point[0], f"{name}.x", positive=False) + _number(point[1], f"{name}.y", positive=False) + if math.dist(tuple(self.p1), tuple(self.p2)) < _EPS: + raise ValueError("p1 and p2 are the same point; a bend line needs a direction.") + angle = _number(self.angle, "angle", positive=False) + if not 0.0 < abs(angle) <= 180.0: + raise ValueError(f"angle {angle}: a bend angle is between 0 and 180 degrees.") + _number(self.inside_radius, "inside_radius") + if str(self.direction).strip().lower() not in ("up", "down"): + raise ValueError(f"direction: expected 'up' or 'down', got {self.direction!r}.") + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + if view != "front": + return () + p1 = (float(self.p1[0]), float(self.p1[1])) + p2 = (float(self.p2[0]), float(self.p2[1])) + mid = ((p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0) + rotation = math.degrees(math.atan2(p2[1] - p1[1], p2[0] - p1[0])) + note = ( + f"R{float(self.inside_radius):g} {float(self.angle):g}° " + f"{str(self.direction).strip().upper()}" + ) + return ( + Line(p1, p2, "phantom"), + Text((mid[0], mid[1] + 2.0), note, 3.5, rotation, "text"), + ) + + def omission(self, part, view: str) -> str | None: + return None if view == "front" else f"a bend line has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class HolePattern(_Base): + """A linear, grid or polar array of one base hole. Expanded before validation.""" + + KIND: ClassVar[str] = "hole_pattern" + pattern: str + base: dict + count: int = 2 + spacing: float = 0.0 + count_y: int = 1 + spacing_y: float = 0.0 + pcd: float = 0.0 + start_angle: float = 0.0 + angle: float = 0.0 + + PATTERNS: ClassVar[tuple[str, ...]] = ("linear", "grid", "polar") + + def _base_fields(self) -> dict: + if not isinstance(self.base, dict): + raise ValueError( + f"base: expected a dict of Hole fields, got {type(self.base).__name__}." + ) + payload = {key: value for key, value in self.base.items() if key not in ("kind", "id")} + names = {field_.name for field_ in fields(Hole)} + unknown = sorted(set(payload) - names) + if unknown: + raise ValueError(f"base: unknown key(s) {unknown}; a Hole takes {sorted(names)}.") + for required in ("x", "y", "diameter"): + if required not in payload: + raise ValueError(f"base.{required}: a base hole needs x, y and diameter.") + return payload + + def expand(self) -> tuple[Hole, ...]: + kind = str(self.pattern).strip().lower() + if kind not in self.PATTERNS: + raise ValueError(f"pattern: expected one of {self.PATTERNS}, got {self.pattern!r}.") + base = self._base_fields() + x0, y0 = float(base["x"]), float(base["y"]) + count = int(self.count) + if count < 1: + raise ValueError(f"count: expected at least 1, got {count}.") + holes: list[Hole] = [] + if kind == "linear": + if float(self.spacing) <= _EPS: + raise ValueError("spacing: a linear pattern needs a spacing greater than zero.") + theta = math.radians(float(self.angle)) + for k in range(count): + holes.append( + Hole( + **{ + **base, + "id": f"{self.id}_{k}", + "x": x0 + k * float(self.spacing) * math.cos(theta), + "y": y0 + k * float(self.spacing) * math.sin(theta), + } + ) + ) + elif kind == "grid": + rows = int(self.count_y) + if rows < 1: + raise ValueError(f"count_y: expected at least 1, got {rows}.") + if count > 1 and float(self.spacing) <= _EPS: + raise ValueError("spacing: a grid with more than one column needs a spacing.") + if rows > 1 and float(self.spacing_y) <= _EPS: + raise ValueError("spacing_y: a grid with more than one row needs a spacing_y.") + for j in range(rows): + for i in range(count): + holes.append( + Hole( + **{ + **base, + "id": f"{self.id}_{i}_{j}", + "x": x0 + i * float(self.spacing), + "y": y0 + j * float(self.spacing_y), + } + ) + ) + else: + if float(self.pcd) <= _EPS: + raise ValueError("pcd: a polar pattern needs a pitch-circle diameter.") + if count < 2: + raise ValueError(f"count: a polar pattern needs at least 2 holes, got {count}.") + radius = float(self.pcd) / 2.0 + start = math.radians(float(self.start_angle)) + for k in range(count): + theta = start + 2.0 * math.pi * k / count + holes.append( + Hole( + **{ + **base, + "id": f"{self.id}_{k}", + "x": x0 + radius * math.cos(theta), + "y": y0 + radius * math.sin(theta), + } + ) + ) + return tuple(holes) + + def validate(self, part) -> None: # pragma: no cover - expanded before it reaches a part + super().validate(part) + for hole in self.expand(): + hole.validate(part) + + +FEATURE_KINDS: tuple[str, ...] = tuple(sorted(_REGISTRY)) diff --git a/engineering/mech/part.py b/engineering/mech/part.py new file mode 100644 index 0000000..dd74c1e --- /dev/null +++ b/engineering/mech/part.py @@ -0,0 +1,359 @@ +"""The mechanical part model: a profile plus a list of typed features. + +Pure: no backend, no async, no DXF. A part is either **revolved** (a stack of +segments along +X, the axis at y = 0, the left end at x = 0) or **prismatic** +(a closed outline in part-local XY plus a thickness along +Z). Every coordinate +here is part-local; `engineering/mech/draw.py` is the only module that maps +part-local to WCS. + +Everything is validated before anything is drawn (the repository's +validate-first rule), and every refusal names the offending path the way +`pid_from_spec` does: ``segments[2].d_outer: ...``. + +The feature surface is duck-typed on purpose, so this module does not import +`features.py` at module scope and the two are testable independently: + + feature.id -> str + feature.validate(part) -> None, ValueError naming what is wrong + feature.to_dict() -> dict accepted by feature_from_dict + feature.removal_box(part) -> (x0, y0, x1, y1) | None (optional) + feature.expand() -> tuple[Feature, ...] (optional) +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, replace +from typing import Any, NamedTuple + +from engineering.measure import is_self_intersecting +from engineering.mech.primitives import Pt +from engineering.mech.standards.materials import hatch_for + +_EPS = 1e-9 + + +class Segment(NamedTuple): + """One turned step of a revolved part, left to right along +X.""" + + length: float + d_outer: float + d_inner: float = 0.0 + taper_to: float | None = None + + +@dataclass(frozen=True) +class RevolvedPart: + name: str + segments: tuple[Segment, ...] + features: tuple[Any, ...] = () + material: str = "steel" + + +@dataclass(frozen=True) +class PrismaticPart: + name: str + outline: tuple[Pt, ...] + thickness: float + features: tuple[Any, ...] = () + material: str = "steel" + + +Part = RevolvedPart | PrismaticPart + +PART_KINDS: tuple[str, ...] = ("revolved", "prismatic") + +_SEGMENT_KEYS = frozenset({"length", "d_outer", "d_inner", "taper_to"}) + + +# -- number guards ----------------------------------------------------------- + + +def _finite(value: Any, path: str) -> float: + try: + number = float(value) + except (TypeError, ValueError): + raise ValueError(f"{path}: expected a number, got {value!r}.") from None + if not math.isfinite(number): + raise ValueError(f"{path}: {value!r} is not a finite number.") + return number + + +def _positive(value: Any, path: str) -> float: + number = _finite(value, path) + if number <= _EPS: + raise ValueError(f"{path}: must be greater than zero, got {number}.") + return number + + +def _non_negative(value: Any, path: str) -> float: + number = _finite(value, path) + if number < -_EPS: + raise ValueError(f"{path}: must not be negative, got {number}.") + return max(number, 0.0) + + +# -- geometry helpers (Tasks 3-7 read the part through these) ---------------- + + +def segment_bounds(part: RevolvedPart) -> tuple[tuple[float, float], ...]: + """``((x0, x1), ...)`` per segment, left to right from x = 0.""" + bounds: list[tuple[float, float]] = [] + x = 0.0 + for segment in part.segments: + bounds.append((x, x + segment.length)) + x += segment.length + return tuple(bounds) + + +def part_length(part: Part) -> float: + if isinstance(part, RevolvedPart): + return float(sum(segment.length for segment in part.segments)) + x0, _, x1, _ = outline_bbox(part) + return x1 - x0 + + +def part_max_diameter(part: RevolvedPart) -> float: + diameters = [segment.d_outer for segment in part.segments] + diameters += [s.taper_to for s in part.segments if s.taper_to is not None] + return float(max(diameters)) + + +def _segment_at(part: RevolvedPart, x: float) -> tuple[int, float, float, Segment]: + bounds = zip(segment_bounds(part), part.segments, strict=True) # one bound per segment + for index, ((x0, x1), segment) in enumerate(bounds): + if x0 - _EPS <= x <= x1 + _EPS: + return index, x0, x1, segment + raise ValueError(f"x={x} is outside the part (0 .. {part_length(part)} mm).") + + +def outer_radius_at(part: RevolvedPart, x: float) -> float: + """Outer radius at an axial station, honouring a segment's taper.""" + _, x0, x1, segment = _segment_at(part, float(x)) + if segment.taper_to is None: + return segment.d_outer / 2.0 + span = x1 - x0 + t = 0.0 if span <= _EPS else (float(x) - x0) / span + return (segment.d_outer + t * (segment.taper_to - segment.d_outer)) / 2.0 + + +def inner_radius_at(part: RevolvedPart, x: float) -> float: + """Bore radius at an axial station; 0.0 where the part is solid.""" + _, _, _, segment = _segment_at(part, float(x)) + return segment.d_inner / 2.0 + + +def outline_bbox(part: PrismaticPart) -> tuple[float, float, float, float]: + xs = [p[0] for p in part.outline] + ys = [p[1] for p in part.outline] + return (min(xs), min(ys), max(xs), max(ys)) + + +def point_in_outline(part: PrismaticPart, x: float, y: float) -> bool: + """Even-odd ray cast against the closed outline; a point on an edge counts as in.""" + points = part.outline + count = len(points) + inside = False + for i in range(count): + ax, ay = points[i] + bx, by = points[(i + 1) % count] + cross = (bx - ax) * (y - ay) - (by - ay) * (x - ax) + on_span = min(ax, bx) - _EPS <= x <= max(ax, bx) + _EPS + on_span = on_span and min(ay, by) - _EPS <= y <= max(ay, by) + _EPS + if abs(cross) < 1e-9 and on_span: + return True + if (ay > y) != (by > y): + t = (y - ay) / (by - ay) + if x < ax + t * (bx - ax): + inside = not inside + return inside + + +# -- construction ------------------------------------------------------------ + + +def _segment_from(index: int, raw: Any) -> Segment: + if not isinstance(raw, dict): + raise ValueError(f"segments[{index}]: expected a dict, got {type(raw).__name__}.") + unknown = set(raw) - _SEGMENT_KEYS + if unknown: + raise ValueError( + f"segments[{index}]: unknown key(s) {sorted(unknown)}; " + f"a segment takes {sorted(_SEGMENT_KEYS)}." + ) + length = _positive(raw.get("length"), f"segments[{index}].length") + d_outer = _positive(raw.get("d_outer"), f"segments[{index}].d_outer") + d_inner = _non_negative(raw.get("d_inner") or 0.0, f"segments[{index}].d_inner") + if d_inner >= d_outer - _EPS: + raise ValueError( + f"segments[{index}].d_inner: {d_inner} mm is not smaller than d_outer " + f"{d_outer} mm. A bore that changes diameter is two segments." + ) + taper_to = raw.get("taper_to") + if taper_to is not None: + taper_to = _positive(taper_to, f"segments[{index}].taper_to") + if taper_to <= d_inner + _EPS: + raise ValueError( + f"segments[{index}].taper_to: {taper_to} mm is not larger than the bore " + f"{d_inner} mm." + ) + return Segment(length, d_outer, d_inner, taper_to) + + +def _outline_from(raw: Any) -> tuple[Pt, ...]: + if not isinstance(raw, (list, tuple)) or not raw: + raise ValueError("outline: expected a non-empty list of [x, y] points.") + points: list[Pt] = [] + for index, item in enumerate(raw): + if not isinstance(item, (list, tuple)) or len(item) != 2: + raise ValueError(f"outline[{index}]: expected [x, y], got {item!r}.") + points.append( + ( + _finite(item[0], f"outline[{index}].x"), + _finite(item[1], f"outline[{index}].y"), + ) + ) + if len(points) > 1 and math.dist(points[0], points[-1]) < _EPS: + points = points[:-1] + distinct: list[Pt] = [] + for point in points: + if not distinct or math.dist(distinct[-1], point) > _EPS: + distinct.append(point) + if len(distinct) < 3: + raise ValueError( + f"outline: a closed outline needs at least 3 distinct vertices, got {len(distinct)}." + ) + if is_self_intersecting(distinct) is True: + raise ValueError( + "outline: the closed outline crosses itself. A crossed loop has no single " + "enclosed area, so neither the view engine nor the section hatch can be right." + ) + return tuple(distinct) + + +def _features_from(raw: Any) -> tuple[Any, ...]: + if raw is None: + return () + if not isinstance(raw, (list, tuple)): + raise ValueError("features: expected a list of feature dicts.") + if not raw: + # No feature dicts to build, so `features.py` is not imported at all: + # a featureless part stays usable while Task 3 is still unwritten, and + # the import stays local either way (features.py imports this module). + return () + from engineering.mech.features import feature_from_dict # local: avoids an import cycle + + built: list[Any] = [] + seen: set[str] = set() + for index, item in enumerate(raw): + if not isinstance(item, dict): + raise ValueError(f"features[{index}]: expected a dict with a 'kind' key.") + try: + feature = feature_from_dict(item) + expand = getattr(feature, "expand", None) + expanded = tuple(expand()) if callable(expand) else (feature,) + except ValueError as exc: + raise ValueError(f"features[{index}]: {exc}") from exc + for one in expanded: + if one.id in seen: + raise ValueError(f"features[{index}]: duplicate feature id {one.id!r}.") + seen.add(one.id) + built.append(one) + return tuple(built) + + +def validate_part(part: Part) -> None: + """Every feature validates against the part, and no two remove the same material.""" + for index, feature in enumerate(part.features): + try: + feature.validate(part) + except ValueError as exc: + raise ValueError(f"features[{index}] ({feature.id}): {exc}") from exc + + boxes: list[tuple[str, tuple[float, float, float, float]]] = [] + for feature in part.features: + getter = getattr(feature, "removal_box", None) + box = getter(part) if callable(getter) else None + if box is not None: + x0, y0, x1, y1 = (float(v) for v in box) + boxes.append((feature.id, (x0, y0, x1, y1))) + + for a in range(len(boxes)): + id_a, (ax0, ay0, ax1, ay1) = boxes[a] + for b in range(a + 1, len(boxes)): + id_b, (bx0, by0, bx1, by1) = boxes[b] + ox0, ox1 = max(ax0, bx0), min(ax1, bx1) + oy0, oy1 = max(ay0, by0), min(ay1, by1) + if ox1 - ox0 > _EPS and oy1 - oy0 > _EPS: + raise ValueError( + f"features {id_a!r} and {id_b!r} remove the same material " + f"(overlap x {ox0:.3f}..{ox1:.3f}, y {oy0:.3f}..{oy1:.3f}). " + "Move one of them, or merge them into a single feature." + ) + + +def build_part(spec: dict) -> Part: + """Validate a part spec and return the typed model. ValueError names the path.""" + if not isinstance(spec, dict): + raise ValueError(f"spec: expected a dict describing the part, got {type(spec).__name__}.") + kind = str(spec.get("kind", "")).strip().lower() + if kind not in PART_KINDS: + raise ValueError(f"kind: expected one of {PART_KINDS}, got {spec.get('kind')!r}.") + name = spec.get("name") + if not isinstance(name, str) or not name.strip(): + raise ValueError("name: a non-empty part name is required.") + material = str(spec.get("material", "steel")) + try: + hatch_for(material) + except ValueError as exc: + raise ValueError(f"material: {exc}") from exc + + if kind == "revolved": + raw_segments = spec.get("segments") + if not isinstance(raw_segments, (list, tuple)) or not raw_segments: + raise ValueError("segments: a revolved part needs at least one segment.") + part: Part = RevolvedPart( + name=name.strip(), + segments=tuple(_segment_from(i, s) for i, s in enumerate(raw_segments)), + material=material, + ) + else: + part = PrismaticPart( + name=name.strip(), + outline=_outline_from(spec.get("outline")), + thickness=_positive(spec.get("thickness"), "thickness"), + material=material, + ) + + part = replace(part, features=_features_from(spec.get("features"))) + validate_part(part) + return part + + +def part_to_dict(part: Part) -> dict: + """The dict `build_part` accepts back, unchanged.""" + common = { + "name": part.name, + "material": part.material, + "features": [feature.to_dict() for feature in part.features], + } + if isinstance(part, RevolvedPart): + return { + "kind": "revolved", + **common, + "segments": [ + { + "length": s.length, + "d_outer": s.d_outer, + "d_inner": s.d_inner, + "taper_to": s.taper_to, + } + for s in part.segments + ], + } + return { + "kind": "prismatic", + **common, + "outline": [[x, y] for x, y in part.outline], + "thickness": part.thickness, + } diff --git a/engineering/mech/standards/__init__.py b/engineering/mech/standards/__init__.py index b550ada..5bf0b8c 100644 --- a/engineering/mech/standards/__init__.py +++ b/engineering/mech/standards/__init__.py @@ -13,6 +13,12 @@ wrong row ships a wrong workshop drawing; a narrow table is merely narrow. variants at the same size (DIN 509 forms E and F, DIN 332 forms A/B/R). ``**kw`` selects among those variants and is refused when it selects none or more than one — an ambiguous lookup never quietly picks the first row. + +``rows`` may be **empty**: that is a table whose structure, coverage and SOURCE +ship before any row could be verified against the standard (DIN 509, DIN 471, +DIN 472, DIN 332-1 and ISO 3601-2 in this build). Registering it keeps the +later transcription a data edit, and every lookup against it is refused by +name — which is exactly the table rule, not an exception to it. """ from __future__ import annotations @@ -62,8 +68,11 @@ def register(standard: str, rows: dict, coverage: Coverage, source: str) -> None raise ValueError("standards.register: standard must be a non-empty name") if standard in _REGISTRY: raise ValueError(f"standards.register: {standard!r} is already registered") - if not rows: - raise ValueError(f"standards.register: {standard!r} registered no rows") + if not isinstance(rows, dict): + raise ValueError( + f"standards.register: {standard!r} must register a rows dict; an empty one is " + "the table whose structure ships before its rows are transcribed" + ) if not isinstance(source, str) or "table" not in source.lower(): raise ValueError( f"standards.register: {standard!r} needs a SOURCE naming the standard, its " diff --git a/engineering/mech/standards/centres.py b/engineering/mech/standards/centres.py new file mode 100644 index 0000000..75d305b --- /dev/null +++ b/engineering/mech/standards/centres.py @@ -0,0 +1,67 @@ +"""DIN 332-1 centre holes, forms A and B. + +SOURCE +------ +DIN 332-1:1986-04, *Centre holes 60 degrees - Forms R, A and B*, Table 1. + +COVERAGE +-------- +**No rows are transcribed in this build** (same rule as ``undercuts.py``). +Form R is not drawn by this build at all: its profile is an arc form whose +radius is not transcribed here, and it is refused outright rather than +approximated by the straight 60 degree cone of forms A and B. + +Extending is a data edit: fill ``ROWS_A`` / ``ROWS_B`` with +``{pilot_diameter_mm: {"d1": ..., "d2": ..., "d3": ..., "t": ...}}`` (``d3`` +only for form B's protective 120 degree countersink) and widen the coverage. +""" + +from __future__ import annotations + +from engineering.mech.standards import Coverage, register + +SOURCE = "DIN 332-1:1986-04, Table 1 (forms A and B) - NOT TRANSCRIBED in this build" + +#: The keys a DIN 332 row must carry; form A rows may omit "d3". +CENTRE_ROW_KEYS: tuple[str, ...] = ("d1", "d2", "d3", "t") + +FORMS: tuple[str, ...] = ("A", "B") + +ROWS_A: dict[float, dict] = {} +ROWS_B: dict[float, dict] = {} + +COVERAGE_A = Coverage(0.0, 0.0, "mm") +COVERAGE_B = Coverage(0.0, 0.0, "mm") + +register("DIN 332-A", ROWS_A, COVERAGE_A, SOURCE) +register("DIN 332-B", ROWS_B, COVERAGE_B, SOURCE) + +_ROWS = {"A": ROWS_A, "B": ROWS_B} + + +def centre_hole_dims(form: str, size: float) -> dict: + """``{"d1","d2","d3","t"}`` for a pilot diameter, or a named refusal.""" + key = str(form or "").strip().upper() + if key == "R": + raise ValueError( + "DIN 332 form R: this build draws forms A and B only. Form R's arc profile " + "is not transcribed here and is refused rather than approximated by the " + "straight 60 degree cone." + ) + if key not in FORMS: + raise ValueError(f"DIN 332: expected form A or B, got {form!r}.") + rows = _ROWS[key] + if not rows: + raise ValueError( + f"DIN 332 form {key}: no rows are transcribed in this build, so no centre hole " + f"can be looked up for d1 = {float(size):g} mm. Pass d1, d2 (and d3 for form B) " + f"and t explicitly, or transcribe {SOURCE.split(' - ')[0]} into " + "engineering/mech/standards/centres.py. Nothing is interpolated." + ) + pilot = float(size) + if pilot in rows: + row = rows[pilot] + return {name: (float(row[name]) if name in row else None) for name in CENTRE_ROW_KEYS} + raise ValueError( + f"DIN 332 form {key}: d1 = {pilot:g} mm has no transcribed row (sizes: {sorted(rows)})." + ) diff --git a/engineering/mech/standards/orings.py b/engineering/mech/standards/orings.py new file mode 100644 index 0000000..db02161 --- /dev/null +++ b/engineering/mech/standards/orings.py @@ -0,0 +1,54 @@ +"""ISO 3601-2 O-ring housing (groove) dimensions. + +SOURCE +------ +ISO 3601-2:2016, *Fluid power systems - O-rings - Part 2: Housing dimensions +for general applications*, Tables 1 and 2 (radial static housings). + +COVERAGE +-------- +**No rows are transcribed in this build** (same rule as ``undercuts.py``): the +groove width and depth per cord diameter decide whether the seal works, and +they are not written here from memory. Every lookup is refused by name. + +Extending is a data edit: fill ``ROWS`` with +``{cord_diameter_mm: {"b": groove_width_mm, "h": groove_depth_mm}}`` - the two +keys group S's ``std_feature_draw`` already reads - and widen ``COVERAGE``. +""" + +from __future__ import annotations + +from engineering.mech.standards import Coverage, register + +SOURCE = "ISO 3601-2:2016, Tables 1-2 (radial static housings) - NOT TRANSCRIBED in this build" + +#: The keys every ISO 3601-2 row must carry. +ORING_ROW_KEYS: tuple[str, ...] = ("b", "h") + +KINDS: tuple[str, ...] = ("radial", "axial") + +ROWS: dict[float, dict] = {} + +COVERAGE = Coverage(0.0, 0.0, "mm") + +register("ISO 3601-2", ROWS, COVERAGE, SOURCE) + + +def oring_groove_dims(cord: float, kind: str = "radial") -> dict: + """``{"b": width, "h": depth}`` for a cord diameter, or a named refusal.""" + key = str(kind or "").strip().lower() + if key not in KINDS: + raise ValueError(f"O-ring housing: expected kind 'radial' or 'axial', got {kind!r}.") + if not ROWS: + raise ValueError( + f"ISO 3601-2: no rows are transcribed in this build, so no {key} housing can be " + f"looked up for a {float(cord):g} mm cord. Pass b and h explicitly, or transcribe " + f"{SOURCE.split(' - ')[0]} into engineering/mech/standards/orings.py. " + "Nothing is interpolated." + ) + size = float(cord) + if size in ROWS: + return {name: float(ROWS[size][name]) for name in ORING_ROW_KEYS} + raise ValueError( + f"ISO 3601-2: cord {size:g} mm has no transcribed row (sizes: {sorted(ROWS)})." + ) diff --git a/engineering/mech/standards/rings.py b/engineering/mech/standards/rings.py new file mode 100644 index 0000000..01201b0 --- /dev/null +++ b/engineering/mech/standards/rings.py @@ -0,0 +1,60 @@ +"""DIN 471 / DIN 472 retaining-ring groove dimensions. + +SOURCE +------ +DIN 471:2011-04, *Retaining rings for shafts*, Table 1 (groove diameter d2 and +groove width m) and DIN 472:2011-04, *Retaining rings for bores*, Table 1. + +COVERAGE +-------- +**No rows are transcribed in this build**, for the reason given in +``undercuts.py``: the groove diameter is the number a wrong drawing gets wrong, +and it is not written here from memory. Every lookup is refused by name. + +Extending is a data edit: fill ``ROWS_471`` / ``ROWS_472`` with +``{nominal_shaft_or_bore_mm: {"m": groove_width_mm, "d2": groove_diameter_mm}}`` +and widen the coverage. Those two keys are the contract group S's +``std_feature_draw`` already reads. +""" + +from __future__ import annotations + +from engineering.mech.standards import Coverage, register + +SOURCE = ( + "DIN 471:2011-04 Table 1 (shafts) and DIN 472:2011-04 Table 1 (bores) " + "- NOT TRANSCRIBED in this build" +) + +#: The keys every DIN 471/472 row must carry. +RING_ROW_KEYS: tuple[str, ...] = ("m", "d2") + +ROWS_471: dict[float, dict] = {} +ROWS_472: dict[float, dict] = {} + +COVERAGE_471 = Coverage(0.0, 0.0, "mm") +COVERAGE_472 = Coverage(0.0, 0.0, "mm") + +register("DIN 471", ROWS_471, COVERAGE_471, SOURCE) +register("DIN 472", ROWS_472, COVERAGE_472, SOURCE) + +_ROWS = {"DIN 471": ROWS_471, "DIN 472": ROWS_472} + + +def ring_groove_dims(standard: str, d: float) -> dict: + """``{"m": ..., "d2": ...}`` for a nominal size, or a named refusal.""" + key = " ".join(str(standard or "").split()).upper() + if key not in _ROWS: + raise ValueError(f"retaining-ring groove: expected DIN 471 or DIN 472, got {standard!r}.") + rows = _ROWS[key] + if not rows: + raise ValueError( + f"{key}: no rows are transcribed in this build, so no groove can be looked up " + f"for d = {float(d):g} mm. Pass m and d2 explicitly, or transcribe " + f"{SOURCE.split(' - ')[0]} into engineering/mech/standards/rings.py. " + "Nothing is interpolated." + ) + size = float(d) + if size in rows: + return {name: float(rows[size][name]) for name in RING_ROW_KEYS} + raise ValueError(f"{key}: d = {size:g} mm has no transcribed row (sizes: {sorted(rows)}).") diff --git a/engineering/mech/standards/threads.py b/engineering/mech/standards/threads.py new file mode 100644 index 0000000..ff4612c --- /dev/null +++ b/engineering/mech/standards/threads.py @@ -0,0 +1,250 @@ +"""ISO 261 metric thread pitches and the ISO 6410 drawing representation. + +SOURCE +------ +**Pitches.** ISO 261:2022, *ISO general purpose metric screw threads - General +plan*, Table 1 (coarse pitch series) and Table 2 (fine pitch series). + +**Transcribed coverage.** The first-choice diameters M1.6 to M64, plus the +second-choice diameters M14, M18, M22, M27, M33, M39, M45, M52 and M60. The +small second-choice diameters (M1.8, M2.2, M3.5, M4.5, M5.5, M7, M9, M11), +every third-choice diameter, everything above M64 and the fine pitch series +below M8 are **not** transcribed and are refused by name rather than +interpolated - the table rule of this track. Where a diameter's fine series +here is shorter than the standard's, the pitch that is missing is refused by +name as well; widening it is a data edit in ``FINE`` with no code change. + +**Basic diameters.** ISO 68-1:1998 fundamental triangle, H = (sqrt(3)/2) * P, +with ISO 724's basic minor diameter d1 = d - 1.25*H = d - 1.082532*P and basic +pitch diameter d2 = d - 0.75*H = d - 0.649519*P. That is the standard's own +formula rather than transcribed table data, which is why it can be pinned +against published ISO 724 values instead of trusted: M20x2.5 reproduces +17.294 / 18.376 exactly, M10x1.5 reproduces 8.376 / 9.026, M8x1.25 reproduces +6.647 / 7.188. + +**Representation.** ISO 6410-1:1993, *Technical drawings - Screw threads and +threaded parts*: the minor diameter is drawn as a thin continuous line at +0.8 x the major diameter (`MINOR_RATIO`); in a view along the axis that same +circle is drawn as three quarters of a circle with the gap in one quadrant; +the thread run-out / thread-length limit is a line across the full major +diameter. An internal thread shown without a section is drawn hidden +throughout. +""" + +from __future__ import annotations + +import math +import re + +from engineering.mech.primitives import Arc, Line, Prim +from engineering.mech.standards import Coverage, register + +SOURCE_PITCH = ( + "ISO 261:2022 Table 1 (coarse) and Table 2 (fine); first-choice M1.6-M64 " + "plus second-choice M14, M18, M22, M27, M33, M39, M45, M52, M60" +) +SOURCE_BASIC = "ISO 68-1:1998 fundamental triangle; values pinned against ISO 724 Table 1" +SOURCE_REPRESENTATION = "ISO 6410-1:1993" + +#: ISO 6410-1: the minor diameter is represented at 0.8 x the major diameter. +MINOR_RATIO = 0.8 + +#: ISO 261 Table 1 - coarse pitch series, major diameter (mm) -> pitch (mm). +COARSE: dict[float, float] = { + 1.6: 0.35, + 2.0: 0.40, + 2.5: 0.45, + 3.0: 0.50, + 4.0: 0.70, + 5.0: 0.80, + 6.0: 1.00, + 8.0: 1.25, + 10.0: 1.50, + 12.0: 1.75, + 14.0: 2.00, + 16.0: 2.00, + 18.0: 2.50, + 20.0: 2.50, + 22.0: 2.50, + 24.0: 3.00, + 27.0: 3.00, + 30.0: 3.50, + 33.0: 3.50, + 36.0: 4.00, + 39.0: 4.00, + 42.0: 4.50, + 45.0: 4.50, + 48.0: 5.00, + 52.0: 5.00, + 56.0: 5.50, + 60.0: 5.50, + 64.0: 6.00, +} + +#: ISO 261 Table 2 - fine pitch series, major diameter (mm) -> pitches (mm), +#: coarsest first. Transcribed from M8 upwards only. +FINE: dict[float, tuple[float, ...]] = { + 8.0: (1.0, 0.75), + 10.0: (1.25, 1.0, 0.75), + 12.0: (1.5, 1.25, 1.0), + 14.0: (1.5, 1.0), + 16.0: (1.5, 1.0), + 18.0: (2.0, 1.5, 1.0), + 20.0: (2.0, 1.5, 1.0), + 22.0: (2.0, 1.5, 1.0), + 24.0: (2.0, 1.5, 1.0), + 27.0: (2.0, 1.5, 1.0), + 30.0: (2.0, 1.5, 1.0), + 33.0: (2.0, 1.5), + 36.0: (3.0, 2.0, 1.5), + 39.0: (3.0, 2.0, 1.5), + 42.0: (3.0, 2.0, 1.5), + 45.0: (3.0, 2.0, 1.5), + 48.0: (3.0, 2.0, 1.5), + 52.0: (3.0, 2.0, 1.5), + 56.0: (4.0, 3.0, 2.0, 1.5), + 60.0: (4.0, 3.0, 2.0, 1.5), + 64.0: (4.0, 3.0, 2.0, 1.5), +} + +DIAMETERS: tuple[float, ...] = tuple(sorted(COARSE)) + +COVERAGE = Coverage(DIAMETERS[0], DIAMETERS[-1], "mm") + +_DESIGNATION_RE = re.compile( + r"^\s*M\s*(\d+(?:[.,]\d+)?)\s*(?:[x×X]\s*(\d+(?:[.,]\d+)?))?\s*(?:-\s*(LH|RH))?\s*$", + re.IGNORECASE, +) + +register( + "ISO 261", + {d: {"pitch": COARSE[d], "fine": FINE.get(d, ())} for d in DIAMETERS}, + COVERAGE, + SOURCE_PITCH, +) + + +def _number(text: str) -> float: + return float(text.replace(",", ".")) + + +def _pitch_text(pitch: float) -> str: + """A pitch in millimetres, always carrying a decimal: 2.5, 2.0, 0.75.""" + text = f"{float(pitch):g}" + return text if "." in text else f"{text}.0" + + +def pitch_for(d: float, pitch: float | None = None) -> float: + """The coarse pitch for ``d``, or validate an explicit fine pitch against the series.""" + diameter = float(d) + if diameter not in COARSE: + raise ValueError( + f"ISO 261: M{diameter:g} is not in the transcribed table. " + f"Coverage is M{DIAMETERS[0]:g}-M{DIAMETERS[-1]:g}, the first-choice diameters plus " + "the second-choice M14, M18, M22, M27, M33, M39, M45, M52 and M60; the remaining " + "second-choice diameters and every third-choice diameter are not transcribed and " + "are not interpolated." + ) + if pitch is None: + return COARSE[diameter] + value = float(pitch) + if abs(value - COARSE[diameter]) < 1e-9: + return COARSE[diameter] + series = FINE.get(diameter, ()) + for candidate in series: + if abs(value - candidate) < 1e-9: + return candidate + known = ", ".join(_pitch_text(p) for p in (COARSE[diameter], *series)) + raise ValueError( + f"ISO 261: M{diameter:g} has no pitch {value:g} mm in the transcribed series. " + f"Transcribed pitches for M{diameter:g}: {known}." + ) + + +def parse_designation(designation: str) -> dict: + """``M20`` / ``M20x1.5`` / ``M20x1.5-LH`` -> d, pitch, series, hand.""" + match = _DESIGNATION_RE.match(str(designation or "")) + if not match: + raise ValueError( + f"designation {designation!r}: expected an ISO metric thread such as " + "'M20', 'M20x1.5' or 'M20x1.5-LH'." + ) + diameter = _number(match.group(1)) + raw_pitch = _number(match.group(2)) if match.group(2) else None + pitch = pitch_for(diameter, raw_pitch) + hand = "left" if (match.group(3) or "RH").upper() == "LH" else "right" + series = "coarse" if abs(pitch - COARSE[diameter]) < 1e-9 else "fine" + text = f"M{diameter:g}" if raw_pitch is None else f"M{diameter:g}x{pitch:g}" + if hand == "left": + text += "-LH" + return { + "designation": text, + "d": diameter, + "pitch": pitch, + "series": series, + "hand": hand, + } + + +def _h(pitch: float) -> float: + """ISO 68-1 fundamental triangle height.""" + return math.sqrt(3.0) / 2.0 * float(pitch) + + +def basic_minor_diameter(d: float, pitch: float) -> float: + """ISO 724 basic minor diameter d1 = d - 1.25 * H.""" + return float(d) - 1.25 * _h(pitch) + + +def basic_pitch_diameter(d: float, pitch: float) -> float: + """ISO 724 basic pitch diameter d2 = d - 0.75 * H.""" + return float(d) - 0.75 * _h(pitch) + + +def thread_axial_prims( + *, + x0: float, + length: float, + d: float, + internal: bool = False, + minor_ratio: float = MINOR_RATIO, + limit_line: bool = True, +) -> tuple[Prim, ...]: + """ISO 6410 representation in a view along the axis (axis on y = 0, +X along it). + + External: the two minor-diameter lines at 0.8 x d plus the thread-length + limit line across the full major diameter. Internal (not sectioned): the + same pair at the *major* diameter, hidden, because the drawn bore already + carries the minor. + """ + x1 = float(x0) + float(length) + role = "hidden" if internal else "visible" + radius = float(d) / 2.0 if internal else float(minor_ratio) * float(d) / 2.0 + prims: list[Prim] = [ + Line((float(x0), radius), (x1, radius), role), + Line((float(x0), -radius), (x1, -radius), role), + ] + if limit_line: + major = float(d) / 2.0 + prims.append(Line((x1, -major), (x1, major), role)) + return tuple(prims) + + +def thread_end_prims( + *, + center: tuple[float, float], + d: float, + internal: bool = False, + gap_center_deg: float = 45.0, + minor_ratio: float = MINOR_RATIO, +) -> tuple[Prim, ...]: + """ISO 6410 three-quarter circle for a view along the thread axis. + + External: three quarters of the *minor* circle. Internal: three quarters of + the *major* circle. The quarter-circle gap is centred on ``gap_center_deg`` + (45 deg = the upper-right quadrant, the usual placement). + """ + radius = float(d) / 2.0 if internal else float(minor_ratio) * float(d) / 2.0 + start = (float(gap_center_deg) + 45.0) % 360.0 + end = (start + 270.0) % 360.0 + return (Arc((float(center[0]), float(center[1])), radius, start, end, "visible"),) diff --git a/engineering/mech/standards/undercuts.py b/engineering/mech/standards/undercuts.py new file mode 100644 index 0000000..19b0508 --- /dev/null +++ b/engineering/mech/standards/undercuts.py @@ -0,0 +1,67 @@ +"""DIN 509 relief grooves (Freistiche), forms E and F. + +SOURCE +------ +DIN 509:1998-06, *Technische Zeichnungen - Freistiche*, Table 1 (forms E and F). + +COVERAGE +-------- +**No rows are transcribed in this build.** The track's table rule is absolute: +a row that cannot be verified against the standard's own table is not shipped, +because a wrong relief depth is a wrong workshop drawing while a narrow table +is merely narrow. Every lookup here is therefore refused by name and names the +route to fix it. + +Extending this module is a **data edit with no code change**: fill ``ROWS_E`` / +``ROWS_F`` with ``{shaft_diameter_mm: {"profile": [[along, depth], ...]}}`` and +widen ``COVERAGE_E`` / ``COVERAGE_F`` to the transcribed range. ``profile`` is +the standard's own published relief profile expressed as (distance along the +axis from the shoulder corner, depth below the smaller cylinder) pairs in +millimetres - the same ``profile`` key group S's ``std_feature_draw`` reads - +so nothing downstream ever reconstructs a profile it was not handed. +""" + +from __future__ import annotations + +from engineering.mech.standards import Coverage, register + +SOURCE = "DIN 509:1998-06, Table 1 (forms E and F) - NOT TRANSCRIBED in this build" + +#: The keys every DIN 509 row must carry for the drawing code to use it. +UNDERCUT_ROW_KEYS: tuple[str, ...] = ("profile",) + +FORMS: tuple[str, ...] = ("E", "F") + +ROWS_E: dict[float, dict] = {} +ROWS_F: dict[float, dict] = {} + +COVERAGE_E = Coverage(0.0, 0.0, "mm") +COVERAGE_F = Coverage(0.0, 0.0, "mm") + +register("DIN 509-E", ROWS_E, COVERAGE_E, SOURCE) +register("DIN 509-F", ROWS_F, COVERAGE_F, SOURCE) + +_ROWS = {"E": ROWS_E, "F": ROWS_F} + + +def undercut_profile(form: str, d: float) -> tuple[tuple[float, float], ...]: + """The published relief profile for a shaft diameter, or a named refusal.""" + key = str(form or "").strip().upper() + if key not in FORMS: + raise ValueError(f"DIN 509 defines forms E and F; got {form!r}.") + rows = _ROWS[key] + if not rows: + raise ValueError( + f"DIN 509 form {key}: no rows are transcribed in this build, so no relief " + f"profile can be looked up for d = {float(d):g} mm. Pass the profile " + "explicitly (Undercut(profile=[[along, depth], ...])), or transcribe " + f"{SOURCE.split(' - ')[0]} into engineering/mech/standards/undercuts.py. " + "Nothing is interpolated and no profile is invented." + ) + size = float(d) + if size in rows: + return tuple(tuple(float(v) for v in pair) for pair in rows[size]["profile"]) + raise ValueError( + f"DIN 509 form {key}: d = {size:g} mm has no transcribed row " + f"(transcribed sizes: {sorted(rows)})." + ) diff --git a/engineering/mech/views.py b/engineering/mech/views.py new file mode 100644 index 0000000..2666078 --- /dev/null +++ b/engineering/mech/views.py @@ -0,0 +1,1284 @@ +"""The view engine: a part model projected onto a plane, as primitive descriptions. + +Pure: no backend, no async, no DXF. A ``View`` carries the primitives, the +dimension intents its features asked for, the features it could **not** project +(the honesty rule of spec section 6 - nothing is silently dropped), and its own +bounding box, so the next view can be laid out against it. + +The one calculation everything comes out of for a revolved part is the **radial +profile**: the part cut into bands of constant (or linearly tapering) outer +radius and constant bore radius, with every feature's ``radial_edit`` applied. +The silhouette is that profile walked as a path; the section cut face (Task 5) +is the same profile walked as a closed loop. One calculation, so the outline +and the hatched area can never disagree. + +Line roles follow ISO 128 and are mapped to layers by +``engineering/mech/primitives.py``: the silhouette is ``visible``, a bore or a +feature behind material is ``hidden``, the axis and centre marks are +``center``, a bend line is ``phantom``. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any, NamedTuple + +from engineering.mech.features import Pocket as _PocketType +from engineering.mech.features import mirror_about_x +from engineering.mech.part import ( + PrismaticPart, + RevolvedPart, + outline_bbox, + part_length, + part_max_diameter, + segment_bounds, +) +from engineering.mech.primitives import ( + Arc, + Circle, + DimIntent, + HatchArea, + Line, + Poly, + Prim, + Pt, + Text, + bbox, + translate, +) +from engineering.mech.primitives import scale as scale_prims + +_EPS = 1e-9 +_SNAP = 6 # decimals used to match a corner vertex to a corner edit +#: Radii closer together than this are the same circle, in millimetres. Looser +#: than ``_EPS`` on purpose: it compares *measured* radii, not exact vertices. +_TOL = 1e-6 + +VIEW_KINDS: tuple[str, ...] = ("front", "side", "top", "section", "detail") +PROJECTIONS: tuple[str, ...] = ("first", "third") +SIDES: tuple[str, ...] = ("right", "left") + +#: How far the axis and the centre lines run past the material, as a fraction +#: of the part's largest dimension (ISO 128-23 asks for a short, even overrun). +AXIS_OVERRUN = 0.10 + +#: The cutting-plane record `section_line` (Task 11, group A) produces and this +#: engine consumes. `via` is optional and only meaningful for an offset plane. +SECTION_PLANE = { + "p1": (0.0, 0.0), + "p2": (0.0, 0.0), + "label": "A", + "direction": (0.0, -1.0), + "style": "full", +} + +SECTION_STYLES: tuple[str, ...] = ("full", "half", "offset", "revolved") + +#: A circular cut-face boundary has to be handed to the hatch as points. 180 +#: segments is a 2 degree chord: the boundary is never further than +#: r * (1 - cos(1 deg)) = 0.015% of the radius from the true circle, and the +#: area of the inscribed 180-gon is 0.0203% under the circle's. Both figures are +#: stated rather than hidden, and the same loop is what the hatch and any area +#: check read, so they cannot disagree with each other. +CIRCLE_SEGMENTS = 180 + + +@dataclass(frozen=True) +class View: + kind: str + prims: tuple[Prim, ...] + dims: tuple[DimIntent, ...] + omitted: tuple[dict, ...] + bbox: tuple[float, float, float, float] + label: str | None = None + scale: float = 1.0 + + +class Band(NamedTuple): + """One axial slice of a revolved part with a constant bore and a linear outside.""" + + x0: float + x1: float + r_in: float + r_out0: float + r_out1: float + + +# -- the radial profile ------------------------------------------------------ + + +def _band_radii(part: RevolvedPart, a: float, b: float) -> tuple[float, float, float]: + """``(r_out0, r_out1, r_in)`` of one band, read off the segment it lies in. + + The segment is resolved from the band's **midpoint** - a band never spans a + segment boundary, because every boundary is one of the cuts - and a taper is + then evaluated at the band's own two ends. Sampling a hair inside the band + instead (the obvious way to dodge the boundary ambiguity) writes the taper + slope times that inset into every coordinate the silhouette is built from: + a 30 -> 10 mm cone over 20 mm reports 14.99999 for a radius that is exactly + 15, and nothing downstream can tell that it is wrong. + """ + mid = (a + b) / 2.0 + found: tuple[float, float, Any] | None = None + for (x0, x1), segment in zip(segment_bounds(part), part.segments, strict=True): + if x0 - _EPS <= mid <= x1 + _EPS: + found = (x0, x1, segment) + break + if found is None: # pragma: no cover - the cuts always include every segment bound + raise ValueError(f"radial_profile: x={mid} is outside the part.") + x0, x1, segment = found + r_in = segment.d_inner / 2.0 + if segment.taper_to is None: + radius = segment.d_outer / 2.0 + return radius, radius, r_in + span = x1 - x0 + + def taper_at(x: float) -> float: + t = 0.0 if span <= _EPS else (x - x0) / span + return (segment.d_outer + t * (segment.taper_to - segment.d_outer)) / 2.0 + + return taper_at(a), taper_at(b), r_in + + +def radial_profile(part: RevolvedPart) -> tuple[Band, ...]: + """The part as bands, with every feature's ``radial_edit`` applied.""" + length = part_length(part) + cuts: set[float] = {0.0, length} + for x0, x1 in segment_bounds(part): + cuts.add(x0) + cuts.add(x1) + + edits: list[dict] = [] + for feature in part.features: + for edit in feature.radial_edit(part): + x0 = max(0.0, min(length, float(edit["x0"]))) + x1 = max(0.0, min(length, float(edit["x1"]))) + if x1 - x0 <= _EPS: + continue + edits.append( + {"kind": edit["kind"], "x0": x0, "x1": x1, "radius": float(edit["radius"])} + ) + cuts.add(x0) + cuts.add(x1) + + xs = sorted(cuts) + bands: list[Band] = [] + for a, b in zip(xs, xs[1:], strict=False): + if b - a <= _EPS: + continue + r_out0, r_out1, r_in = _band_radii(part, a, b) + for edit in edits: + if edit["x0"] - _EPS <= a and b <= edit["x1"] + _EPS: + if edit["kind"] == "outer": + r_out0 = min(r_out0, edit["radius"]) + r_out1 = min(r_out1, edit["radius"]) + else: + r_in = max(r_in, edit["radius"]) + bands.append(Band(a, b, r_in, r_out0, r_out1)) + return tuple(bands) + + +def _outer_path(bands: tuple[Band, ...]) -> list[Pt]: + """The +Y half-outline, from the left face at the bore, over the outside, down + to the right face at the bore. The end faces are *part of the path*, so a + chamfer or fillet at an end corner substitutes into them instead of being + drawn across them - which is what stops the overshoot the `untrimmed_corner` + critique focus looks for.""" + points: list[Pt] = [(bands[0].x0, bands[0].r_in), (bands[0].x0, bands[0].r_out0)] + for band in bands: + if math.dist(points[-1], (band.x0, band.r_out0)) > _EPS: + points.append((band.x0, band.r_out0)) + points.append((band.x1, band.r_out1)) + points.append((bands[-1].x1, bands[-1].r_in)) + return points + + +def _bore_path(bands: tuple[Band, ...]) -> list[list[Pt]]: + """One path per contiguous run of bored bands; a blind end is capped.""" + runs: list[list[Pt]] = [] + current: list[Pt] = [] + for band in bands: + if band.r_in <= _EPS: + if current: + runs.append(current) + current = [] + continue + if not current: + current = [(band.x0, band.r_in)] + elif math.dist(current[-1], (band.x0, band.r_in)) > _EPS: + current.append((band.x0, band.r_in)) + current.append((band.x1, band.r_in)) + if current: + runs.append(current) + return runs + + +def _key(point: Pt) -> tuple[float, float]: + return (round(float(point[0]), _SNAP), round(float(point[1]), _SNAP)) + + +def _emit_path(points: list[Pt], edits: list[dict], role: str, *, closed: bool = False): + """Walk a vertex path, substituting corner edits, and emit Lines and Arcs. + + Returns ``(prims, used)`` - ``used`` is the set of edit ids that actually + found their corner, so the caller can report the ones that did not. + """ + by_corner: dict[tuple[float, float], dict] = {} + for edit in edits: + by_corner[_key(edit["corner"])] = edit + + sequence: list[tuple[str, Any]] = [] + used: set[str] = set() + for point in points: + edit = by_corner.get(_key(point)) + if edit is None: + sequence.append(("pt", (float(point[0]), float(point[1])))) + continue + used.add(edit["owner"]) + sequence.append(("pt", edit["before"])) + if edit["arc"] is not None: + sequence.append(("arc", edit["arc"])) + sequence.append(("pt", edit["after"])) + + if closed and sequence: + sequence.append(sequence[0]) + + prims: list[Prim] = [] + previous: Pt | None = None + for tag, item in sequence: + if tag == "pt": + if previous is not None and math.dist(previous, item) > _EPS: + prims.append(Line(previous, item, role)) + previous = item + else: + prims.append(item) + previous = None + return tuple(prims), used + + +def _corner_edits(part: RevolvedPart) -> list[dict]: + """Every feature's silhouette corner substitution, tagged with its owner.""" + edits: list[dict] = [] + for feature in part.features: + edit = feature.corner_edit(part) + if edit is not None: + edits.append({**edit, "owner": feature.id}) + return edits + + +def _unplaced(edits: list[dict], used: set[str]) -> tuple[dict, ...]: + return tuple( + { + "feature": edit["owner"], + "reason": ( + "its silhouette corner was changed by another feature, so the " + "chamfer/fillet could not be placed" + ), + } + for edit in edits + if edit["owner"] not in used + ) + + +def silhouette_prims(part: RevolvedPart) -> tuple[tuple[Prim, ...], tuple[dict, ...]]: + """The mirrored half-outline (end faces included), the bore, and any unplaced edit.""" + bands = radial_profile(part) + edits = _corner_edits(part) + + upper, used = _emit_path(_outer_path(bands), edits, "visible") + prims: list[Prim] = list(upper) + list(mirror_about_x(upper)) + + for run in _bore_path(bands): + bore, _ = _emit_path(run, [], "hidden") + prims.extend(bore) + prims.extend(mirror_about_x(bore)) + + return tuple(prims), _unplaced(edits, used) + + +def section_profile_prims( + part: RevolvedPart, *, half: bool = False +) -> tuple[tuple[Prim, ...], tuple[dict, ...]]: + """The longitudinal **cut** outline of a revolved part. + + Every edge of this outline lies in the cutting plane, so every edge is a + continuous line: ISO 128-50 bounds a surface lying in the cutting plane + with a continuous wide line, and ISO 128-3 leaves hidden detail out of a + sectional view altogether. The bore wall is the case that matters - the + un-cut silhouette draws it dashed because material hides it, but in a + section it *is* the cut face's own boundary, and drawing the hatch loop and + a dashed line on the same coordinates is the one thing a section must not + do. ``half=True`` keeps the dashed bore on the -Y half, which is the half a + half section does not cut. + """ + bands = radial_profile(part) + edits = _corner_edits(part) + + upper, used = _emit_path(_outer_path(bands), edits, "visible") + prims: list[Prim] = list(upper) + list(mirror_about_x(upper)) + + for run in _bore_path(bands): + bore, _ = _emit_path(run, [], "visible") + prims.extend(bore) + mirrored = mirror_about_x(bore) + if half: + mirrored = tuple(prim._replace(role="hidden") for prim in mirrored) + prims.extend(mirrored) + + return tuple(prims), _unplaced(edits, used) + + +def outline_prims(part: PrismaticPart) -> tuple[tuple[Prim, ...], tuple[dict, ...]]: + """The closed outline with every corner fillet/chamfer substituted in.""" + edits: list[dict] = [] + for feature in part.features: + edit = feature.outline_edit(part) + if edit is None: + continue + corner = part.outline[int(edit["vertex"]) % len(part.outline)] + edits.append({**edit, "corner": corner, "owner": feature.id}) + + prims, used = _emit_path(list(part.outline), edits, "visible", closed=True) + unplaced = tuple( + {"feature": edit["owner"], "reason": "its outline vertex was already replaced"} + for edit in edits + if edit["owner"] not in used + ) + return prims, unplaced + + +# -- view construction ------------------------------------------------------- + + +def _axis_prims(part: RevolvedPart) -> tuple[Prim, ...]: + length = part_length(part) + over = max(length, part_max_diameter(part)) * AXIS_OVERRUN + return (Line((-over, 0.0), (length + over, 0.0), "center"),) + + +def _band_order(bands: tuple[Band, ...], side: str) -> list[Band]: + """The bands from the end the observer stands at, nearest first.""" + return list(reversed(bands)) if side == "right" else list(bands) + + +def _band_radii_near_first(band: Band, side: str) -> tuple[float, float]: + """A band's two outer radii, the one nearer the observer first. + + They differ only on a taper: ``r_out0`` sits at ``x0`` and ``r_out1`` at + ``x1``, so which of them faces the observer depends on the end they are + looking from. + """ + return (band.r_out1, band.r_out0) if side == "right" else (band.r_out0, band.r_out1) + + +def _front_max(bands: tuple[Band, ...], side: str, x: float) -> float: + """The largest outer radius of the material lying **between** ``x`` and the observer. + + This is the whole hidden-line rule for a view along the axis of a solid of + revolution: a circular edge of radius ``r`` at station ``x`` is drawn + continuous when ``r`` is larger than everything in front of it, because then + nothing can cover it, and dashed when it is not, because the material in + front does cover it. A step face whose circle is *larger* than the material + ahead of it is the one edge that cannot be occluded. + """ + best = 0.0 + for band in bands: + nearer = band.x0 >= x - _TOL if side == "right" else band.x1 <= x + _TOL + if nearer: + best = max(best, band.r_out0, band.r_out1) + return best + + +def _end_circles(part: RevolvedPart, side: str) -> tuple[Prim, ...]: + """The concentric circles the profile crosses at one end, roled by occlusion. + + Outer edges: walking from the observer, a radius larger than everything + already passed is ``visible``; a smaller one is behind that material and is + ``hidden`` - it is still drawn, dashed, because it is a real edge of the + part. Bore edges follow the mirror-image rule: you can see down a bore only + while it stays at least as wide, so a bore radius no larger than the + narrowest bore in front of it is ``visible`` and anything wider (or anything + behind solid material) is ``hidden``. + + Each distinct radius is emitted once: two coincident circles would be a + duplicate entity, which is what ``drawing_critique`` reports. + """ + bands = radial_profile(part) + order = _band_order(bands, side) + + prims: list[Prim] = [] + emitted: set[float] = set() + + def emit(radius: float, role: str) -> None: + key = round(float(radius), _SNAP) + if key <= _TOL or key in emitted: + return + emitted.add(key) + prims.append(Circle((0.0, 0.0), float(radius), role)) + + front_max = 0.0 + for band in order: + for radius in _band_radii_near_first(band, side): + emit(radius, "visible" if radius > front_max + _TOL else "hidden") + front_max = max(front_max, radius) + + open_r = math.inf + for band in order: + if band.r_in <= _TOL: + open_r = 0.0 # solid across the whole section: nothing behind is seen + continue + emit(band.r_in, "visible" if band.r_in <= open_r + _TOL else "hidden") + open_r = min(open_r, band.r_in) + + over = front_max * (1.0 + AXIS_OVERRUN) + prims.append(Line((-over, 0.0), (over, 0.0), "center")) + prims.append(Line((0.0, -over), (0.0, over), "center")) + return tuple(prims) + + +def _rectangle( + u0: float, u1: float, v0: float, v1: float, role: str = "visible" +) -> tuple[Prim, ...]: + return ( + Line((u0, v0), (u1, v0), role), + Line((u1, v0), (u1, v1), role), + Line((u1, v1), (u0, v1), role), + Line((u0, v1), (u0, v0), role), + ) + + +def _mirror_u(prims, axis: float) -> tuple[Prim, ...]: + """Reflect primitives about the vertical line ``u = axis`` (screen left <-> right). + + ``Text`` moves with the drawing but keeps its rotation: a mirrored label + would read backwards, and no drawing has ever wanted that. + """ + + def flip(point: Pt) -> Pt: + return (2.0 * axis - float(point[0]), float(point[1])) + + out: list[Prim] = [] + for prim in prims: + if isinstance(prim, Line): + out.append(Line(flip(prim.p1), flip(prim.p2), prim.role)) + elif isinstance(prim, Arc): + # reflection reverses the sweep, so the CCW arc runs from the + # reflected end angle to the reflected start angle + out.append( + Arc( + flip(prim.center), + prim.radius, + (180.0 - prim.end_deg) % 360.0, + (180.0 - prim.start_deg) % 360.0, + prim.role, + ) + ) + elif isinstance(prim, Circle): + out.append(Circle(flip(prim.center), prim.radius, prim.role)) + elif isinstance(prim, Poly): + out.append(Poly(tuple(flip(pt) for pt in prim.points), prim.closed, prim.role)) + elif isinstance(prim, HatchArea): + out.append( + HatchArea( + tuple(tuple(flip(pt) for pt in loop) for loop in prim.loops), prim.material + ) + ) + elif isinstance(prim, Text): + out.append(prim._replace(at=flip(prim.at))) + else: # pragma: no cover - Prim is a closed union + raise TypeError(f"_mirror_u: not a primitive: {prim!r}") + return tuple(out) + + +def _same_geometry(a: Prim, b: Prim) -> bool: + """True when two primitives would be drawn on top of each other, role aside. + + Exactly what ``engineering/critique.py::_check_duplicate_entities`` matches, + so anything this returns True for is a warning on the finished drawing. + """ + if isinstance(a, Line) and isinstance(b, Line): + forward = math.dist(a.p1, b.p1) <= _TOL and math.dist(a.p2, b.p2) <= _TOL + reverse = math.dist(a.p1, b.p2) <= _TOL and math.dist(a.p2, b.p1) <= _TOL + return forward or reverse + if isinstance(a, Circle) and isinstance(b, Circle): + return math.dist(a.center, b.center) <= _TOL and abs(a.radius - b.radius) <= _TOL + if isinstance(a, Arc) and isinstance(b, Arc): + return ( + math.dist(a.center, b.center) <= _TOL + and abs(a.radius - b.radius) <= _TOL + and abs((a.start_deg - b.start_deg) % 360.0) <= _TOL + and abs((a.end_deg - b.end_deg) % 360.0) <= _TOL + ) + return False + + +def _profile_owned(feature, part, prim: Prim, kind: str, body: list[Prim]) -> bool: + """True when the radial profile has already drawn this primitive. + + Two separate reasons, and both are the same principle - the profile is the + one calculation the silhouette comes out of, so a feature never draws it a + second time: + + * the primitive is coincident with something the body already emitted; or + * the feature edits the radial profile and this is a **silhouette** line of + a revolved front view. ``radial_profile`` already cut the notch, and the + feature's own idea of where the surface is can even contradict it - a + groove straddling a step reads one surface radius for a span that has two. + """ + if any(_same_geometry(prim, other) for other in body): + return True + if kind == "front" and getattr(prim, "role", None) == "visible": + return bool(feature.radial_edit(part)) + return False + + +def _end_view_role(feature, part, prim: Prim, bands: tuple[Band, ...], side: str) -> Prim: + """Re-role a feature's axis-centred end-view circle by the same occlusion rule. + + The feature knows its diameter, not what is standing in front of it. Its + axial station comes from ``removal_box``; a feature that does not report one + keeps its own verdict rather than having one guessed for it. + """ + if not isinstance(prim, Circle) or math.dist(prim.center, (0.0, 0.0)) > _TOL: + return prim + box = feature.removal_box(part) + if box is None: + return prim + x_near = float(box[2]) if side == "right" else float(box[0]) + covered = _front_max(bands, side, x_near) + return prim._replace(role="visible" if prim.radius > covered + _TOL else "hidden") + + +def _feature_layer( + part, + kind: str, + ctx: dict, + *, + body: list[Prim] | None = None, + bands: tuple[Band, ...] | None = None, +) -> tuple[list[Prim], list[DimIntent], list[dict]]: + drawn: list[Prim] = list(body or ()) + prims: list[Prim] = [] + dims: list[DimIntent] = [] + omitted: list[dict] = [] + end_view = bands is not None and kind == "side" + side = str(ctx.get("side", "right")).strip().lower() + for feature in part.features: + own = tuple(feature.prims(part, kind, ctx)) + if end_view: + own = tuple(_end_view_role(feature, part, prim, bands, side) for prim in own) + own = tuple(prim for prim in own if not _profile_owned(feature, part, prim, kind, drawn)) + if own and kind == "front" and getattr(feature, "axisymmetric", False): + own = tuple(own) + mirror_about_x(own) + prims.extend(own) + drawn.extend(own) + dims.extend(feature.dims(part, kind, ctx)) + if own: + continue + if feature.radial_edit(part) or feature.corner_edit(part) or feature.outline_edit(part): + continue + reason = feature.omission(part, kind) + if reason: + omitted.append({"feature": feature.id, "reason": reason}) + return prims, dims, omitted + + +def build_view( + part, + kind: str, + *, + side: str = "right", + plane: dict | None = None, + style: str = "full", + scale: float = 1.0, + projection: str = "first", + detail: dict | None = None, +) -> View: + """Project a part onto one view plane and return its primitive description. + + ``side`` is the end the observer stands at for an end view, and it decides + both what is in front of what and which way round the view reads. The + ``top`` view is always seen from above, so ``side`` does not apply to it. + """ + name = str(kind or "").strip().lower() + if name not in VIEW_KINDS: + raise ValueError(f"build_view: kind {kind!r} is not one of {VIEW_KINDS}.") + where = str(side or "right").strip().lower() + if where not in SIDES: + raise ValueError(f"build_view: side {side!r} is not one of {SIDES}.") + if name == "section": + return section_view(part, plane, style=style, scale=scale, side=where) + if name == "detail": + return detail_view(part, detail or {}, projection=projection, side=where) + + ctx = {"side": where, "scale": float(scale), "projection": str(projection)} + prims: list[Prim] = [] + omitted: list[dict] = [] + bands: tuple[Band, ...] | None = None + mirror_u: float | None = None + + if isinstance(part, RevolvedPart): + if name == "top": + raise ValueError( + "build_view: a revolved part's top view is identical to its front view; " + "use kind='front', or kind='section' for a cut." + ) + if name == "front": + body, unplaced = silhouette_prims(part) + prims.extend(body) + prims.extend(_axis_prims(part)) + omitted.extend(unplaced) + else: + bands = radial_profile(part) + prims.extend(_end_circles(part, where)) + elif isinstance(part, PrismaticPart): + x0, y0, x1, y1 = outline_bbox(part) + if name == "front": + body, unplaced = outline_prims(part) + prims.extend(body) + omitted.extend(unplaced) + elif name == "side": + prims.extend(_rectangle(y0, y1, 0.0, part.thickness)) + # An observer standing at -X sees screen-right = -Y, so the left + # view is the right view mirrored. The thickness rectangle spans + # y0..y1 either way and is its own mirror; the features are not. + if where == "left": + mirror_u = (y0 + y1) / 2.0 + else: + prims.extend(_rectangle(x0, x1, 0.0, part.thickness)) + else: # pragma: no cover - build_part only makes these two + raise ValueError(f"build_view: {type(part).__name__} is not a part model.") + + feature_prims, dims, feature_omitted = _feature_layer(part, name, ctx, body=prims, bands=bands) + if mirror_u is not None: + feature_prims = list(_mirror_u(feature_prims, mirror_u)) + prims.extend(feature_prims) + omitted.extend(feature_omitted) + + return View( + kind=name, + prims=tuple(prims), + dims=tuple(dims), + omitted=tuple(omitted), + bbox=bbox(prims) if prims else (0.0, 0.0, 0.0, 0.0), + label=None, + scale=float(scale), + ) + + +def layout_origin( + parent: View, + kind: str, + *, + side: str = "right", + projection: str = "first", + gap: float = 20.0, + child: View | None = None, +) -> Pt: + """Where a child view's bounding box lower-left corner goes, relative to its parent. + + First angle (ISO 128-30, the default here): the view obtained by looking + from the right is placed on the **left**, and the view from above is placed + **below**. Third angle flips both. With no ``child`` the child is assumed + the same size as the parent, which keeps the views aligned even before the + child has been built. + """ + projection_name = str(projection or "").strip().lower() + if projection_name not in PROJECTIONS: + raise ValueError(f"layout_origin: projection {projection!r} is not one of {PROJECTIONS}.") + px0, py0, px1, py1 = parent.bbox + if child is None: + width, height = px1 - px0, py1 - py0 + else: + cx0, cy0, cx1, cy1 = child.bbox + width, height = cx1 - cx0, cy1 - cy0 + mid_x = (px0 + px1) / 2.0 + mid_y = (py0 + py1) / 2.0 + first = projection_name == "first" + name = str(kind or "").strip().lower() + gap = float(gap) + + if name in ("side", "section"): + looking_from_right = str(side or "right").strip().lower() == "right" + to_the_left = looking_from_right == first + if to_the_left: + return (px0 - gap - width, mid_y - height / 2.0) + return (px1 + gap, mid_y - height / 2.0) + if name == "top": + if first: + return (mid_x - width / 2.0, py0 - gap - height) + return (mid_x - width / 2.0, py1 + gap) + if name == "detail": + return (px1 + gap, mid_y - height / 2.0) + raise ValueError( + f"layout_origin: kind {kind!r} has no projection position; expected " + "'side', 'top', 'section' or 'detail'." + ) + + +# -- sections ---------------------------------------------------------------- + + +def normalise_plane(plane: dict | None) -> dict: + """Fill the `SECTION_PLANE` shape and refuse a plane that has no direction.""" + if plane is None: + raise ValueError( + "section: a cutting plane is required - pass plane={'p1': ..., 'p2': ...}, " + "or take one from section_line()." + ) + if not isinstance(plane, dict): + raise ValueError(f"plane: expected a dict, got {type(plane).__name__}.") + unknown = sorted(set(plane) - set(SECTION_PLANE) - {"via"}) + if unknown: + raise ValueError(f"plane: unknown key(s) {unknown}; it takes {sorted(SECTION_PLANE)}.") + p1 = tuple(float(v) for v in plane.get("p1", SECTION_PLANE["p1"])) + p2 = tuple(float(v) for v in plane.get("p2", SECTION_PLANE["p2"])) + if math.dist(p1, p2) < 1e-6: + raise ValueError(f"plane: p1 and p2 are the same point, the plane has zero length ({p1}).") + style = str(plane.get("style", SECTION_PLANE["style"])).strip().lower() + if style not in SECTION_STYLES: + raise ValueError(f"plane.style: expected one of {SECTION_STYLES}, got {style!r}.") + via = tuple(tuple(float(v) for v in point) for point in plane.get("via", ())) + return { + "p1": p1, + "p2": p2, + "label": str(plane.get("label", SECTION_PLANE["label"])), + "direction": tuple(float(v) for v in plane.get("direction", SECTION_PLANE["direction"])), + "style": style, + "via": via, + } + + +def _inner_path(bands: tuple[Band, ...]) -> list[Pt]: + points: list[Pt] = [(bands[0].x0, bands[0].r_in)] + for band in bands: + if math.dist(points[-1], (band.x0, band.r_in)) > _EPS: + points.append((band.x0, band.r_in)) + points.append((band.x1, band.r_in)) + return points + + +def _arc_loop_points( + radius: float, start_deg: float, end_deg: float, *, center: Pt = (0.0, 0.0) +) -> tuple[Pt, ...]: + """An arc as points, at the same 2 degree chord ``CIRCLE_SEGMENTS`` states. + + Endpoints are exact, so a loop built from an arc plus a polyline closes on + the polyline's own corners. + """ + sweep = (float(end_deg) - float(start_deg)) % 360.0 + if sweep <= _EPS: + sweep = 360.0 + count = max(1, math.ceil(sweep / (360.0 / CIRCLE_SEGMENTS) - 1e-9)) + out: list[Pt] = [] + for step in range(count + 1): + angle = math.radians(float(start_deg) + sweep * step / count) + out.append((center[0] + radius * math.cos(angle), center[1] + radius * math.sin(angle))) + return tuple(out) + + +def _flatten_path(prims) -> tuple[Pt, ...]: + """A contiguous run of ``Line``/``Arc`` primitives as a point path. + + This is what makes the drawn outline and the hatched loop **one** + calculation rather than two that agree by inspection: the loop is the + emitted outline, flattened, so a chamfer or a fillet that substitutes a + silhouette corner substitutes it in the cut face too. + """ + points: list[Pt] = [] + + def push(point: Pt) -> None: + pt = (float(point[0]), float(point[1])) + if not points or math.dist(points[-1], pt) > _EPS: + points.append(pt) + + for prim in prims: + if isinstance(prim, Line): + push(prim.p1) + push(prim.p2) + elif isinstance(prim, Arc): + for point in _arc_loop_points( + prim.radius, prim.start_deg, prim.end_deg, center=prim.center + ): + push(point) + else: # pragma: no cover - only an outline path is ever flattened + raise TypeError(f"_flatten_path: {type(prim).__name__} is not a path primitive.") + return tuple(points) + + +def _circle_loop(radius: float, *, reverse: bool = False) -> tuple[Pt, ...]: + steps = range(CIRCLE_SEGMENTS - 1, -1, -1) if reverse else range(CIRCLE_SEGMENTS) + return tuple( + ( + radius * math.cos(2.0 * math.pi * k / CIRCLE_SEGMENTS), + radius * math.sin(2.0 * math.pi * k / CIRCLE_SEGMENTS), + ) + for k in steps + ) + + +def _is_longitudinal(plane: dict) -> bool: + """True when the cutting plane runs along the revolved axis (y = 0 at both ends).""" + return abs(plane["p1"][1]) < 1e-6 and abs(plane["p2"][1]) < 1e-6 + + +def _subtract(intervals: list[tuple[float, float]], cut: tuple[float, float]): + out: list[tuple[float, float]] = [] + low, high = cut + for a, b in intervals: + if high <= a + _EPS or low >= b - _EPS: + out.append((a, b)) + continue + if low > a + _EPS: + out.append((a, low)) + if high < b - _EPS: + out.append((high, b)) + return [(a, b) for a, b in out if b - a > 1e-9] + + +def _chords(outline, p1: Pt, unit: Pt) -> list[tuple[float, float]]: + """Parameters along `unit` from `p1` where the line is inside the outline. + + Crossings are paired in order, which is exact for a simple polygon whose + edges the line crosses transversally. A line that grazes a vertex produces + an odd crossing count and is refused by the caller rather than paired wrong. + """ + ts: list[float] = [] + count = len(outline) + for index in range(count): + ax, ay = outline[index] + bx, by = outline[(index + 1) % count] + ex, ey = bx - ax, by - ay + denominator = ex * unit[1] - ey * unit[0] + if abs(denominator) < 1e-12: + continue + s = ((p1[0] - ax) * unit[1] - (p1[1] - ay) * unit[0]) / denominator + if -1e-9 <= s <= 1.0 + 1e-9: + px, py = ax + s * ex, ay + s * ey + ts.append((px - p1[0]) * unit[0] + (py - p1[1]) * unit[1]) + ts = sorted({round(t, 9) for t in ts}) + if len(ts) % 2 != 0: + raise ValueError( + "section: the cutting plane grazes an outline vertex, so its crossings cannot be " + "paired. Move the plane off the vertex rather than accept a guessed cut face." + ) + return [(ts[i], ts[i + 1]) for i in range(0, len(ts), 2)] + + +def _plane_legs(plane: dict) -> list[tuple[Pt, Pt]]: + """The cutting legs of an offset plane. + + ISO 128-40: the offset itself is not drawn, so with ``via`` present the + odd-numbered legs are the transfer jogs and contribute nothing to the cut + face. + """ + points = [plane["p1"], *plane["via"], plane["p2"]] + legs = [(points[i], points[i + 1]) for i in range(len(points) - 1)] + if plane["via"]: + legs = [leg for index, leg in enumerate(legs) if index % 2 == 0] + return legs + + +def _prismatic_cut(part: PrismaticPart, plane: dict, style: str): + """``[(u0, u1, v0, v1, hatched), ...]`` - the cut rectangles along the plane.""" + legs = _plane_legs(plane) if style == "offset" else [(plane["p1"], plane["p2"])] + thickness = float(part.thickness) + rectangles: list[tuple[float, float, float, float, bool]] = [] + origin = 0.0 + for start, end in legs: + length = math.dist(start, end) + if length < 1e-9: + continue + unit = ((end[0] - start[0]) / length, (end[1] - start[1]) / length) + intervals = _chords(part.outline, start, unit) + intervals = [(a, b) for a, b in intervals if b > -1e-9 and a < length + 1e-9] + intervals = [(max(a, 0.0), min(b, length)) for a, b in intervals] + intervals = [(a, b) for a, b in intervals if b - a > 1e-9] + + pockets: list[tuple[tuple[float, float], float, bool]] = [] + for feature in part.features: + # the feature's own chord, never the box around it: a box punches + # the full width of a hole or an angled slot into the cut face + # wherever the plane passes, which is a wrong hatched area and a + # wrong drawn outline at the same time. + for span in feature.cut_spans(part, start, unit): + span = (max(span[0], 0.0), min(span[1], length)) + if span[1] - span[0] <= 1e-9: + continue + if isinstance(feature, _PocketType): + pockets.append( + (span, thickness - float(feature.depth), feature.no_section_hatch) + ) + else: + intervals = _subtract(intervals, span) + + for pocket_span, floor, unhatched in pockets: + intervals = _subtract(intervals, pocket_span) + rectangles.append( + (origin + pocket_span[0], origin + pocket_span[1], 0.0, floor, not unhatched) + ) + for a, b in intervals: + rectangles.append((origin + a, origin + b, 0.0, thickness, True)) + origin += length + if rectangles: + # the section view has its own frame: u = 0 at the first cut. + shift = min(u0 for u0, _u1, _v0, _v1, _hatched in rectangles) + rectangles = [ + (u0 - shift, u1 - shift, v0, v1, hatched) for u0, u1, v0, v1, hatched in rectangles + ] + return rectangles + + +def _band_at(bands: tuple[Band, ...], station: float) -> Band | None: + for band in bands: + if band.x0 - _TOL <= station <= band.x1 + _TOL: + return band + return None + + +def _represented_by_the_profile(feature, part, station: float) -> bool: + """True when the radial profile already carries this feature at ``station``. + + A groove, an undercut, an axial bore or an ISO 6410 internal thread edits + the profile, and the transverse cut face is read off that profile - so the + feature is in the face already and must not be reported missing from it. + """ + for edit in feature.radial_edit(part): + if float(edit["x0"]) - _TOL <= station <= float(edit["x1"]) + _TOL: + return True + return False + + +def _transverse_face(part: RevolvedPart, station: float) -> dict: + """One transverse cut face: its boundary primitives, its loops, its omissions. + + The radii come from ``radial_profile``, the same one calculation the + silhouette and the longitudinal cut face come out of, so a groove or an + internal thread at that station is in the face without being asked for. + A feature that is *not* axisymmetric is asked for its own ``cross_section`` + notch; one that the plane really passes through and that has no such record + is reported rather than hatched over - a keyed shaft whose cross section + came out as a plain hatched disc is exactly the drawing the honesty rule + exists to prevent. + """ + bands = radial_profile(part) + band = _band_at(bands, station) + if band is None: + raise ValueError( + f"section: the plane at x={station:g} is outside {part.name!r} " + f"(0 .. {part_length(part):g} mm)." + ) + span = band.x1 - band.x0 + fraction = 0.0 if span <= _EPS else (station - band.x0) / span + r_out = band.r_out0 + fraction * (band.r_out1 - band.r_out0) + r_in = band.r_in + + notch: dict | None = None + omitted: list[dict] = [] + for feature in part.features: + record = feature.cross_section(part, station) + if record is not None: + if notch is not None: + omitted.append( + { + "feature": feature.id, + "reason": ( + "the cut face already carries another feature's notch; this " + "engine cuts one notch per transverse face" + ), + } + ) + continue + measured = float(record["radius"]) + if abs(measured - r_out) > _TOL: + omitted.append( + { + "feature": feature.id, + "reason": ( + f"its notch was measured on a {2.0 * measured:g} mm surface but " + f"the cut face at x={station:g} is {2.0 * r_out:g} mm, so the " + "notch would not reach it" + ), + } + ) + continue + notch = record + continue + if _represented_by_the_profile(feature, part, station): + continue + box = feature.removal_box(part) + if box is not None and float(box[0]) - _TOL <= station <= float(box[2]) + _TOL: + omitted.append( + { + "feature": feature.id, + "reason": ( + f"the cutting plane at x={station:g} passes through it, but a " + f"{type(feature).__name__} has no transverse cut-face projection" + ), + } + ) + else: + omitted.append( + { + "feature": feature.id, + "reason": f"it is not on the cutting plane at x={station:g}", + } + ) + + if notch is None: + loops: list[tuple[tuple[Pt, ...], bool]] = [(_circle_loop(r_out), True)] + prims: list[Prim] = [Circle((0.0, 0.0), r_out, "visible")] + else: + path = tuple((float(x), float(y)) for x, y in notch["path"]) + start, end = float(notch["start_deg"]), float(notch["end_deg"]) + # the material arc plus the notch walls: one boundary, and the loop is + # that same boundary sampled, so the hatch cannot cover the notch. The + # arc ends where the path ends, so the path is walked backwards to + # close the loop, and both of its endpoints are already on the arc. + closing = tuple(reversed(path))[1:-1] + loops = [(_arc_loop_points(r_out, start, end) + closing, True)] + prims = [Arc((0.0, 0.0), r_out, start, end, "visible")] + prims.extend( + Line(path[index], path[index + 1], "visible") for index in range(len(path) - 1) + ) + if r_in > _EPS: + loops.append((_circle_loop(r_in, reverse=True), True)) + prims.append(Circle((0.0, 0.0), r_in, "visible")) + + return { + "r_out": r_out, + "r_in": r_in, + "loops": tuple(loops), + "prims": tuple(prims), + "omitted": tuple(omitted), + } + + +def cut_loops(part, plane: dict, *, style: str = "full"): + """The closed cut-face loops, each flagged with whether it is hatched.""" + plane = normalise_plane(plane) + name = str(style or "full").strip().lower() + if name not in SECTION_STYLES: + raise ValueError(f"style: expected one of {SECTION_STYLES}, got {style!r}.") + + if isinstance(part, RevolvedPart): + bands = radial_profile(part) + if _is_longitudinal(plane): + if name == "offset": + raise ValueError( + "section: an offset plane has no meaning along a revolved part's axis; " + "use style='full' (or a transverse plane for a cross section)." + ) + # the loop is the *emitted* outline flattened, so a chamfer or a + # fillet is cut out of the hatched area exactly where it is drawn + outline, _used = _emit_path(_outer_path(bands), _corner_edits(part), "visible") + upper = _flatten_path(outline) + tuple(reversed(_inner_path(bands))) + loops = [(upper, True)] + if name != "half": + loops.append((tuple((x, -y) for x, y in upper), True)) + return tuple(loops) + if name == "half": + raise ValueError( + "section: a half section is defined on a longitudinal plane through the axis; " + "a transverse plane takes style='full' or 'revolved'." + ) + return _transverse_face(part, plane["p1"][0])["loops"] + + if name in ("half", "revolved"): + raise ValueError( + f"section: a {name} section is defined on a revolved part; a prismatic part takes " + "style='full' or 'offset'." + ) + rectangles = _prismatic_cut(part, plane, name) + if not rectangles: + raise ValueError( + f"section: the plane {plane['p1']} -> {plane['p2']} does not cut {part.name!r}." + ) + return tuple( + (((u0, v0), (u1, v0), (u1, v1), (u0, v1)), hatched) + for u0, u1, v0, v1, hatched in rectangles + ) + + +def section_view( + part, plane: dict, *, style: str = "full", scale: float = 1.0, side: str = "right" +) -> View: + """A cut view: the cut faces as hatch areas, plus the geometry behind them.""" + from engineering.mech.standards.materials import hatch_for + + plane = normalise_plane(plane) + name = str(style or plane["style"]).strip().lower() + hatch_for(part.material) # refuse an unknown material before anything is built + ctx = {"side": side, "scale": float(scale), "projection": "first", "plane": plane} + + loops = cut_loops(part, plane, style=name) + prims: list[Prim] = [] + omitted: list[dict] = [] + dims: list[DimIntent] = [] + + if isinstance(part, RevolvedPart) and _is_longitudinal(plane): + body, unplaced = section_profile_prims(part, half=name == "half") + prims.extend(body) + prims.extend(_axis_prims(part)) + omitted.extend(unplaced) + for feature in part.features: + own = feature.cut_prims(part, plane, ctx) + if own and getattr(feature, "axisymmetric", False): + own = tuple(own) + mirror_about_x(own) + prims.extend(own) + dims.extend(feature.dims(part, "front", ctx)) + elif isinstance(part, RevolvedPart): + role = "phantom" if name == "revolved" else "visible" + face = _transverse_face(part, plane["p1"][0]) + prims.extend(prim._replace(role=role) for prim in face["prims"]) + omitted.extend(face["omitted"]) + over = face["r_out"] * (1.0 + AXIS_OVERRUN) + prims.append(Line((-over, 0.0), (over, 0.0), "center")) + prims.append(Line((0.0, -over), (0.0, over), "center")) + else: + for points, _hatched in loops: + xs = [p[0] for p in points] + ys = [p[1] for p in points] + prims.extend(_rectangle(min(xs), max(xs), min(ys), max(ys))) + + hatched = [points for points, flag in loops if flag] + if isinstance(part, RevolvedPart) and not _is_longitudinal(plane): + # a transverse cut face is one region with the bore as its island + if hatched: + prims.append(HatchArea(tuple(hatched), part.material)) + else: + for points in hatched: + prims.append(HatchArea((points,), part.material)) + + label = f"{plane['label']}-{plane['label']}" + return View( + kind="section", + prims=tuple(prims), + dims=tuple(dims), + omitted=tuple(omitted), + bbox=bbox(prims) if prims else (0.0, 0.0, 0.0, 0.0), + label=label, + scale=float(scale), + ) + + +# -- detail views ------------------------------------------------------------ + + +def _prim_bbox(prim) -> tuple[float, float, float, float]: + return bbox([prim]) + + +def _touches(prim, center: Pt, radius: float) -> bool: + x0, y0, x1, y1 = _prim_bbox(prim) + nearest_x = min(max(center[0], x0), x1) + nearest_y = min(max(center[1], y0), y1) + return math.dist((nearest_x, nearest_y), center) <= radius + 1e-9 + + +def _clip_segment(p1: Pt, p2: Pt, center: Pt, radius: float): + """The part of the segment inside the circle, or None.""" + dx, dy = p2[0] - p1[0], p2[1] - p1[1] + fx, fy = p1[0] - center[0], p1[1] - center[1] + a = dx * dx + dy * dy + if a < 1e-18: + return (p1, p2) if math.dist(p1, center) <= radius + 1e-9 else None + b = 2.0 * (fx * dx + fy * dy) + c = fx * fx + fy * fy - radius * radius + discriminant = b * b - 4.0 * a * c + if discriminant <= 0.0: + return None + root = math.sqrt(discriminant) + t0 = max(0.0, (-b - root) / (2.0 * a)) + t1 = min(1.0, (-b + root) / (2.0 * a)) + if t1 - t0 <= 1e-9: + return None + return ((p1[0] + t0 * dx, p1[1] + t0 * dy), (p1[0] + t1 * dx, p1[1] + t1 * dy)) + + +def _clip_to_circle(prim, center: Pt, radius: float) -> tuple[Prim, ...]: + """Straight geometry is trimmed to the detail circle; curved geometry is kept + whole when it touches it. + + Trimming an arc would mean solving its angular range against the circle, and + a detail that silently re-cuts an arc is worse than one that carries a + slightly long arc into the enlargement - so the rule is stated rather than + approximated, and a detail is meant to be drawn inside its circle. + """ + if isinstance(prim, Line): + clipped = _clip_segment(prim.p1, prim.p2, center, radius) + return (Line(clipped[0], clipped[1], prim.role),) if clipped else () + if isinstance(prim, Poly): + points = list(prim.points) + if prim.closed and points: + points.append(points[0]) + out: list[Prim] = [] + for index in range(len(points) - 1): + clipped = _clip_segment(points[index], points[index + 1], center, radius) + if clipped: + out.append(Line(clipped[0], clipped[1], prim.role)) + return tuple(out) + return (prim,) if _touches(prim, center, radius) else () + + +def detail_marker_prims(detail: dict) -> tuple[Prim, ...]: + """ISO 128-34 detail circle and its letter, for the **parent** view.""" + center = tuple(float(v) for v in detail["center"]) + radius = float(detail["radius"]) + label = str(detail.get("label", "A")) + return ( + Circle(center, radius, "visible"), + Text((center[0] + radius + 2.0, center[1] + radius + 2.0), label, 5.0, 0.0, "text"), + ) + + +def detail_view(part, detail: dict, *, projection: str = "first", side: str = "right") -> View: + """A circular region of a parent view, enlarged, with the ISO 128-34 labels. + + Straight geometry is trimmed to the circle and curved geometry that touches + it is kept whole (see :func:`_clip_to_circle`); every feature whose own + primitives all fall outside is reported in ``omitted``. + """ + if not isinstance(detail, dict): + raise ValueError(f"detail: expected a dict, got {type(detail).__name__}.") + if "radius" not in detail: + raise ValueError("detail.radius: a detail needs the radius of its circle.") + center = tuple(float(v) for v in detail.get("center", (0.0, 0.0))) + radius = float(detail["radius"]) + if radius <= _EPS: + raise ValueError(f"detail.radius: must be greater than zero, got {radius}.") + factor = float(detail.get("scale", 5.0)) + if factor <= _EPS: + raise ValueError(f"detail.scale: must be greater than zero, got {factor}.") + label = str(detail.get("label", "A")) + of = str(detail.get("of", "front")).strip().lower() + + parent = build_view(part, of, side=side, projection=projection) + kept: list[Prim] = [] + for prim in parent.prims: + kept.extend(_clip_to_circle(prim, center, radius)) + kept = tuple(kept) + if not kept: + raise ValueError( + f"detail: the circle at {center} r={radius} contains no geometry of the {of} view." + ) + + moved = translate(kept, -center[0], -center[1]) + enlarged = scale_prims(moved, factor, (0.0, 0.0)) + + omitted = list(parent.omitted) + for feature in part.features: + own = feature.prims(part, of, {"side": side, "scale": 1.0, "projection": projection}) + if own and not any(_touches(prim, center, radius) for prim in own): + omitted.append({"feature": feature.id, "reason": f"outside the detail circle {label}"}) + + return View( + kind="detail", + prims=tuple(enlarged), + dims=(), + omitted=tuple(omitted), + bbox=bbox(enlarged), + label=f"{label} ({factor:g}:1)", + scale=factor, + ) diff --git a/server.py b/server.py index 1635d7e..0871291 100644 --- a/server.py +++ b/server.py @@ -690,6 +690,7 @@ async def _registered_tool_count() -> int | None: # entity/drawing tags. Tools whose tags match none fall into "other". _GROUP_TAG_PRIORITY = ( "pid", + "mech", "style", "pagesetup", "environment", @@ -718,6 +719,7 @@ _GROUP_TAG_PRIORITY = ( # Map the winning tag to a human-readable group label for the breakdown. _GROUP_TAG_LABELS = { "pid": "pid", + "mech": "mech", "style": "styles", "pagesetup": "page_setup", "environment": "environment", @@ -960,7 +962,7 @@ SOLID_TOOL_NAMES = frozenset( # mechanically should not pay for the P&ID surface, and a client that never # talks to a live seat should not pay for the environment surface. Styles and # page setup (SECTION 18/19) are drafting essentials and stay in core. -TOOL_PACK_NAMES = ("core", "pid", "settings") +TOOL_PACK_NAMES = ("core", "pid", "settings", "mech") PACK_TOOL_NAMES: dict[str, frozenset[str]] = { "pid": frozenset( { @@ -1002,6 +1004,17 @@ PACK_TOOL_NAMES: dict[str, frozenset[str]] = { "system_prompt_message", } ), + # SECTION 21 - the mechanical part model and its view engine (tracks B+G). + "mech": frozenset( + { + "mech_part_draw", + "mech_view_add", + "mech_dimension_part", + "mech_hole_pattern", + "mech_part_from_spec", + "mech_part_inspect", + } + ), } _active_tool_profile: dict | None = None @@ -8123,6 +8136,437 @@ async def drawing_properties_set( return await _backend(ctx).drawing_properties_set(summary, custom) +# --------------------------------------------------------------------------- +# ── SECTION 21: Mechanical Parts (6 tools) ────────────────────────────────── +# --------------------------------------------------------------------------- + + +@cad_tool( + summary="Draw a machine part from a segment list or an outline plus typed features.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Mechanical: Draw Part", "destructiveHint": False}, + tags={"mech", "create"}, +) +async def mech_part_draw( + spec: Annotated[ + dict, + Field( + description=( + "The part: {kind: 'revolved'|'prismatic', name, material, " + "segments:[{length, d_outer, d_inner, taper_to}] | outline:[[x,y]] + thickness, " + "features:[{kind, id, ...}]}" + ) + ), + ], + at_x: Annotated[ + float, + Field( + default=0.0, + description="WCS X of the first view's lower-left corner (the rotation pivot)", + ), + ] = 0.0, + at_y: Annotated[ + float, + Field( + default=0.0, + description="WCS Y of the first view's lower-left corner (the rotation pivot)", + ), + ] = 0.0, + rotation: Annotated[ + float, + Field( + default=0.0, + description="Degrees CCW; turns every view and its dimensions about (at_x, at_y)", + ), + ] = 0.0, + views: Annotated[ + list[str] | None, + Field( + default=None, + description="Views to draw in order: front | side | top (default ['front'])", + ), + ] = None, + projection: Annotated[ + str, Field(default="first", description="first (ISO 128-30, default) | third") + ] = "first", + dimension: Annotated[ + bool, Field(default=True, description="Also dimension the part (ISO 129 + ISO 286 fits)") + ] = True, + style: Annotated[ + str, + Field(default="chain", description="Axial dimension style: chain | baseline | ordinate"), + ] = "chain", + ctx: Context = None, +) -> dict: + """One part model - a turned profile or a plate outline plus typed features - drawn as views. + + The model is written onto the drawing as `ACADMCP_MECH` XDATA on an anchor + POINT, so `mech_view_add` can add a section months later without the caller + re-describing the part, and `mech_part_inspect` reads it back. + + Refused **before anything is drawn**, by path: a non-finite or non-positive + number (`segments[2].d_outer`), a bore that is not smaller than its outside, + an outline with fewer than three distinct vertices or one that crosses + itself, an unknown material, a feature whose placement is off the part, two + features that would remove the same material (both names given), a thread + outside the transcribed ISO 261 table, a keyway outside DIN 6885's bore + range, gear teeth whose tip diameter disagrees with their segment. A DIN 509 + undercut, a DIN 471/472 groove, a DIN 332 centre hole and an ISO 3601-2 + O-ring groove need explicit dimensions in this build: those tables are not + transcribed and the lookup refuses by name rather than interpolate. A + feature that cannot be projected into a requested view is listed in + `omitted`, never dropped. + """ + from engineering.mech.draw import draw_part + from engineering.mech.part import build_part + + try: + part = build_part(spec) + except ValueError as exc: + raise ToolError(str(exc)) from exc + await ctx.info(f"mech_part_draw: {part.name} at ({at_x}, {at_y})") + try: + return await draw_part( + _backend(ctx), + part, + at=(at_x, at_y), + rotation=rotation, + views=tuple(views or ("front",)), + projection=projection, + dimension=dimension, + style=style, + ) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Add another view of a drawn part: side, top, section (full/half/offset) or detail.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Mechanical: Add View", "destructiveHint": False}, + tags={"mech", "create"}, +) +async def mech_view_add( + part_id: Annotated[ + str | None, + Field( + default=None, + description="Anchor handle from mech_part_draw; omit if the drawing holds one part", + ), + ] = None, + kind: Annotated[ + str, Field(default="section", description="front | side | top | section | detail") + ] = "section", + side: Annotated[ + str, Field(default="right", description="Which end an end view looks from: right | left") + ] = "right", + style: Annotated[ + str, + Field(default="full", description="Section style: full | half | offset | revolved"), + ] = "full", + plane: Annotated[ + dict | None, + Field( + default=None, + description="Cutting plane {p1:[x,y], p2:[x,y], label, direction:[dx,dy], via:[[x,y]]} - required for a section", + ), + ] = None, + detail: Annotated[ + dict | None, + Field( + default=None, + description="Detail region {center:[x,y], radius, scale, label, of:'front'} - required for a detail", + ), + ] = None, + gap: Annotated[ + float, Field(default=20.0, description="Gap between this view and its parent (mm)") + ] = 20.0, + scale: Annotated[ + float, + Field( + default=1.0, + description=( + "Must be 1.0: views are drawn full size (a detail takes its scale in " + "detail.scale; a sheet scale is the viewport's)" + ), + ), + ] = 1.0, + ctx: Context = None, +) -> dict: + """Another view of a part already on the drawing, laid out on its projection axis. + + The part model comes back out of the drawing's `ACADMCP_MECH` XDATA, so the + caller never re-describes it, and the placement follows the projection angle + recorded when the part was drawn (first angle by default: the view from the + right goes to the left). + + Refused by name: a section without a plane, or with a plane of zero length, + a plane that misses the part, or one that grazes an outline vertex so its + crossings cannot be paired; a half or offset section on the wrong kind of + plane (the message names the style that does apply); a detail without a + radius or with a scale of zero; an unknown part_id, and `part_id=None` on a + drawing that holds more than one part. + """ + from engineering.mech.draw import add_view, read_part + + backend = _backend(ctx) + try: + resolved = part_id or (await read_part(backend))["part_id"] + await ctx.info(f"mech_view_add: {kind} on {resolved}") + return await add_view( + backend, + resolved, + kind, + side=side, + style=style, + plane=plane, + detail=detail, + gap=gap, + scale=scale, + ) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Dimension a drawn part: ISO 129 dimensions, ISO 286 fits, a hole table.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Mechanical: Dimension Part", "destructiveHint": False}, + tags={"mech", "dimension"}, +) +async def mech_dimension_part( + part_id: Annotated[ + str | None, + Field( + default=None, + description="Anchor handle from mech_part_draw; omit if the drawing holds one part", + ), + ] = None, + style: Annotated[ + str, + Field(default="chain", description="Axial dimension style: chain | baseline | ordinate"), + ] = "chain", + hole_table_threshold: Annotated[ + int, + Field(default=8, description="Above this many holes the diameters move into a hole table"), + ] = 8, + fits: Annotated[ + dict | None, + Field( + default=None, + description="ISO 286 fit per feature id, e.g. {'bearing_seat': 'k6', 'bore': 'H7'}", + ), + ] = None, + views: Annotated[ + list[int] | None, + Field( + default=None, + description=( + "View indices to dimension, in mech_part_inspect order " + "(default: every view not yet dimensioned)" + ), + ), + ] = None, + ctx: Context = None, +) -> dict: + """Every dimension the part and its features ask for, laid out without overlaps. + + Axial lengths are chained, stacked from a baseline or measured from an + origin; diameters stack outside the silhouette; fillets get a radius. Each + measurement appears once (ISO 129-1): every view record carries a + `dimensioned` flag, so after a `mech_part_draw` that dimensioned the front + view and a `mech_view_add`, this call dimensions only the new view, and + `views` naming a view already dimensioned is refused by name. `fits` resolves an ISO 286 code + against the measured nominal through the repository's authored tables and + appends the code to the dimension text; an explicit `tol` on a feature's own + intent does the same through the ISO 129 path. + + Refused by name: a fit together with an explicit tolerance, a fit outside + the authored ISO 286 range (over 1 mm to 500 mm, IT4-IT11), a fit on an + intent with no measurable nominal, an unknown style, an unknown part_id, a + call when every view is already dimensioned (the message lists them), and + `part_id=None` on a drawing that holds more than one part. An angular intent + is reported in `skipped` rather than drawn, because a DimIntent carries no + vertex. + """ + from engineering.mech.draw import dimension_part, read_part + + backend = _backend(ctx) + try: + resolved = part_id or (await read_part(backend))["part_id"] + await ctx.info(f"mech_dimension_part: {resolved} ({style})") + return await dimension_part( + backend, + resolved, + style=style, + hole_table_threshold=hole_table_threshold, + fits=fits, + views=views, + ) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Draw a linear, grid or polar hole pattern with centre marks on any drawing.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Mechanical: Hole Pattern", "destructiveHint": False}, + tags={"mech", "create"}, +) +async def mech_hole_pattern( + pattern: Annotated[str, Field(description="linear | grid | polar")], + x: Annotated[float, Field(description="WCS X of the first hole (polar: the circle centre)")], + y: Annotated[float, Field(description="WCS Y of the first hole (polar: the circle centre)")], + diameter: Annotated[float, Field(description="Hole diameter in drawing units")], + count: Annotated[ + int, Field(default=2, description="Holes along the pattern (polar: around the circle)") + ] = 2, + spacing: Annotated[ + float, Field(default=0.0, description="Pitch between holes (linear, grid columns)") + ] = 0.0, + count_y: Annotated[int, Field(default=1, description="Rows, for a grid")] = 1, + spacing_y: Annotated[float, Field(default=0.0, description="Row pitch, for a grid")] = 0.0, + pcd: Annotated[ + float, Field(default=0.0, description="Pitch-circle diameter, for a polar pattern") + ] = 0.0, + start_angle: Annotated[ + float, Field(default=0.0, description="Angle of the first hole (polar), degrees CCW") + ] = 0.0, + angle: Annotated[ + float, Field(default=0.0, description="Direction of a linear pattern, degrees CCW") + ] = 0.0, + layer: Annotated[ + str | None, Field(default=None, description="Override the GEOMETRY layer for the circles") + ] = None, + centre_marks: Annotated[ + bool, Field(default=True, description="Draw ISO 128-23 centre marks") + ] = True, + dimension: Annotated[ + bool, Field(default=False, description="Add a '6x ⌀8' diameter callout") + ] = False, + ctx: Context = None, +) -> dict: + """A hole pattern on any drawing - the part model is not required. + + The same expansion `HolePattern` performs inside a part, exposed for a + drawing that was not built from the model. + + Refused before anything is drawn: an unknown pattern, a count below 1 (below + 2 for polar), a linear pattern with no spacing, a grid with more than one + column or row and no pitch for it, a polar pattern with no pitch-circle + diameter, and a non-finite or non-positive diameter. + """ + from engineering.mech.draw import draw_hole_pattern + + await ctx.info(f"mech_hole_pattern: {pattern} x{count}") + try: + return await draw_hole_pattern( + _backend(ctx), + pattern=pattern, + x=x, + y=y, + diameter=diameter, + count=count, + spacing=spacing, + count_y=count_y, + spacing_y=spacing_y, + pcd=pcd, + start_angle=start_angle, + angle=angle, + layer=layer, + centre_marks=centre_marks, + dimension=dimension, + ) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Draw a whole mechanical sheet - several parts and their views - in one transaction.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Mechanical: Sheet From Spec", "destructiveHint": False}, + tags={"mech", "create"}, +) +async def mech_part_from_spec( + spec: Annotated[ + dict, + Field( + description=( + "{sheet: {intent, sheet_size, scale, layer_set}, " + "parts: [{part: {...}, at: [x, y], rotation, views: ['front','side'], " + "projection, dimension, style}]}" + ) + ), + ], + ctx: Context = None, +) -> dict: + """Several parts, their views and their dimensions, drawn inside one transaction. + + Every part is validated before the transaction opens, so a refusal leaves + the drawing untouched and names the offending path (`parts[1]: + segments[0].d_outer: ...`). If a draw fails or the request is cancelled + part-way, the transaction is rolled back under an anyio shield, so a + cancelled call cannot leave half a sheet inside an open transaction. + + Refused by name: an empty `parts` list, any part refusal from + `mech_part_draw`, and a transaction that cannot be opened. + """ + from engineering.mech.draw import draw_from_spec + + await ctx.info(f"mech_part_from_spec: {len((spec or {}).get('parts') or [])} part(s)") + try: + return await draw_from_spec(_backend(ctx), spec) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Read the part model back off the drawing: segments, features, views, omissions.", + cost="read", +) +@mcp.tool( + annotations={"title": "Mechanical: Inspect Part", "readOnlyHint": True}, + tags={"mech", "query"}, +) +async def mech_part_inspect( + part_id: Annotated[ + str | None, + Field( + default=None, + description="Anchor handle from mech_part_draw; omit if the drawing holds one part", + ), + ] = None, + ctx: Context = None, +) -> dict: + """The `ACADMCP_MECH` payload a drawn part carries, decoded. + + Never modifies the drawing. Returns the part model exactly as + `mech_part_draw` accepts it (`part`), the views drawn so far with their + placements, and the payload version. + + Refused by name: an unknown part_id, a handle that carries no + `ACADMCP_MECH` payload, a corrupt or foreign payload, and `part_id=None` on + a drawing that holds no part or more than one (the message lists the + handles). + """ + from engineering.mech.draw import read_part + + try: + return await read_part(_backend(ctx), part_id) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_mech_dimension.py b/tests/test_mech_dimension.py new file mode 100644 index 0000000..23ee80d --- /dev/null +++ b/tests/test_mech_dimension.py @@ -0,0 +1,514 @@ +"""Dimension intents, ISO 286 fits, the hole table and the overlap-free layout. + +The layout test is the one that matters: it asserts the placed text rectangles +are pairwise disjoint, computed by the same helper the layout uses, so "no +overlapping dimension text" is a measured property rather than a claim. +""" + +from __future__ import annotations + +import dataclasses +import itertools + +import pytest + +from engineering.mech.dimension import ( + DIM_STYLES, + dimension_intents, + hole_table_rows, + intent_text, + is_reference, + layout_dimensions, + measured_value, + redundant_linear, + resolve_tolerance, + segment_key, + text_rects, +) +from engineering.mech.features import Hole, Keyway +from engineering.mech.part import PrismaticPart, RevolvedPart, Segment +from engineering.mech.primitives import DimIntent +from engineering.mech.views import build_view +from engineering.tolerances import build_dim_override + + +def shaft(*features) -> RevolvedPart: + return RevolvedPart( + name="shaft", + segments=(Segment(20.0, 25.0), Segment(40.0, 30.0), Segment(15.0, 20.0)), + features=tuple(features), + ) + + +def plate(*holes) -> PrismaticPart: + return PrismaticPart( + name="plate", + outline=((0.0, 0.0), (80.0, 0.0), (80.0, 50.0), (0.0, 50.0)), + thickness=8.0, + features=tuple(holes), + ) + + +def grid(count: int, diameter: float = 6.0): + return tuple(Hole(id=f"h{i}", x=5.0 + 7.0 * i, y=25.0, diameter=diameter) for i in range(count)) + + +def test_the_three_styles_are_the_ones_the_spec_names(): + assert DIM_STYLES == ("chain", "baseline", "ordinate") + + +def test_measured_value_and_text_follow_the_intent(): + linear = DimIntent(kind="linear", p1=(0.0, 0.0), p2=(20.0, 0.0)) + assert measured_value(linear) == 20.0 + assert intent_text(linear) == "20" + override = DimIntent(kind="diameter", p1=(-20.0, 0.0), p2=(20.0, 0.0), text_override="M20") + assert measured_value(override) == 40.0 + assert intent_text(override) == "M20" + + +# -- ISO 286 fits and ISO 129 tolerances ------------------------------------- + + +def test_a_h7_fit_on_a_40_mm_bore_resolves_to_plus_25_microns_over_zero(): + intent = DimIntent(kind="diameter", p1=(-20.0, 0.0), p2=(20.0, 0.0), fit="H7") + resolved = resolve_tolerance(intent) + assert resolved["tol_upper"] == pytest.approx(0.025) + assert resolved["tol_lower"] == pytest.approx(0.0) + assert resolved["tol_mode"] == "deviation" + assert resolved["text_override"] == "<> H7" + + +def test_a_shaft_fit_keeps_the_build_dim_override_sign_convention(): + intent = DimIntent(kind="diameter", p1=(-10.0, 0.0), p2=(10.0, 0.0), fit="g6") + resolved = resolve_tolerance(intent) + # ISO 286 g6 at 20 mm: es = -0.007, ei = -0.020; build_dim_override wants the + # lower deviation as a positive magnitude of the minus side. + assert resolved["tol_upper"] == pytest.approx(-0.007) + assert resolved["tol_lower"] == pytest.approx(0.020) + + +def test_an_explicit_tolerance_passes_through_and_is_validated(): + intent = DimIntent( + kind="linear", + p1=(0.0, 0.0), + p2=(30.0, 0.0), + tol={"mode": "symmetric", "upper": 0.1}, + ) + resolved = resolve_tolerance(intent) + assert resolved["tol_mode"] == "symmetric" + assert resolved["tol_upper"] == pytest.approx(0.1) + bad = DimIntent(kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol={"mode": "loose"}) + with pytest.raises(ValueError, match="tol_mode"): + resolve_tolerance(bad) + + +def test_a_fit_and_an_explicit_tolerance_together_are_refused(): + intent = DimIntent( + kind="diameter", + p1=(-20.0, 0.0), + p2=(20.0, 0.0), + fit="H7", + tol={"mode": "symmetric", "upper": 0.1}, + ) + with pytest.raises(ValueError, match="not both"): + resolve_tolerance(intent) + + +def test_a_fit_outside_the_iso_286_tables_is_refused_by_name(): + intent = DimIntent(kind="diameter", p1=(-400.0, 0.0), p2=(400.0, 0.0), fit="H7") + with pytest.raises(ValueError, match="ISO 286"): + resolve_tolerance(intent) + + +def test_a_fit_with_no_measurable_nominal_is_refused_rather_than_guessed(): + intent = DimIntent(kind="diameter", p1=(5.0, 0.0), p2=(5.0, 0.0), fit="H7") + with pytest.raises(ValueError, match="nominal"): + resolve_tolerance(intent) + + +@pytest.mark.parametrize( + "tol", + [ + {"mode": "symmetric", "upper": 0.05}, + {"mode": "deviation", "upper": 0.05, "lower": 0.02}, + {"mode": "limit", "upper": 30.05, "lower": 29.98}, + {"upper": 0.05, "lower": 0.05}, # the shape whose tolerance used to vanish + {"mode": "none", "upper": 0.05}, + {}, + ], +) +def test_a_tolerance_is_either_refused_or_reaches_the_dimension_variables(tol): + """Accepted and then written as nothing is the one answer that is not allowed. + + `build_dim_override` defaults a missing mode to "none" and answers with an + empty override, so a tol with no mode used to resolve, echo its own numbers + back and draw a dimension carrying no DIMTOL/DIMTP/DIMTM at all. + """ + intent = DimIntent(kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol=tol) + try: + resolved = resolve_tolerance(intent) + except ValueError: + return # refused up front, which is the other legal answer + override, _ = build_dim_override( + resolved["tol_upper"], + resolved["tol_lower"], + resolved["tol_mode"], + resolved["text_override"], + ) + assert override, f"{tol} resolved to {resolved} and wrote no dimension variable" + + +def test_a_tolerance_with_no_mode_is_refused_rather_than_silently_discarded(): + intent = DimIntent( + kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol={"upper": 0.05, "lower": 0.05} + ) + with pytest.raises(ValueError, match="needs an explicit 'mode'"): + resolve_tolerance(intent) + + +def test_an_empty_tol_is_refused_rather_than_read_as_no_tolerance(): + intent = DimIntent(kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol={}) + with pytest.raises(ValueError, match="mode"): + resolve_tolerance(intent) + + +def test_a_tol_key_outside_the_schema_is_refused_by_name(): + intent = DimIntent( + kind="linear", + p1=(0.0, 0.0), + p2=(30.0, 0.0), + tol={"mode": "symmetric", "upper": 0.05, "plus": 0.05}, + ) + with pytest.raises(ValueError, match="plus"): + resolve_tolerance(intent) + + +def test_deviations_under_a_mode_that_writes_none_are_refused(): + intent = DimIntent( + kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol={"mode": "none", "upper": 0.05} + ) + with pytest.raises(ValueError, match="writes no deviation"): + resolve_tolerance(intent) + + +def test_a_tol_that_is_not_a_dict_is_refused(): + intent = DimIntent(kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol=0.05) + with pytest.raises(ValueError, match="expected a dict"): + resolve_tolerance(intent) + + +# -- the ISO 286 route from the part to the dimension ------------------------ + + +def bush() -> RevolvedPart: + """A 60 mm bore housing: one segment, ⌀60 outside, ⌀40 bore.""" + return RevolvedPart(name="bush", segments=(Segment(60.0, 60.0, 40.0),)) + + +def test_a_fit_reaches_a_dimension_through_the_modules_own_pipeline(): + """`mech_iso286_on_dimension`, end to end: a ⌀40 H7 bore carries +0.025 / 0. + + `resolve_tolerance` always resolved a fit; nothing produced an intent that + carried one, so the headline claim was reachable only from a hand-built + DimIntent - which is what the tests used. + """ + part = bush() + view = build_view(part, "front") + intents = dimension_intents(part, view, fits={segment_key(0, bore=True): "H7"}) + fitted = [i for i in intents if i.fit] + assert len(fitted) == 1 + assert measured_value(fitted[0]) == pytest.approx(40.0) + resolved = resolve_tolerance(fitted[0]) + assert resolved["tol_upper"] == pytest.approx(0.025) + assert resolved["tol_lower"] == pytest.approx(0.0) + assert resolved["tol_mode"] == "deviation" + assert resolved["text_override"] == "<> H7" + # the fit has to survive the layout, or the number is right in the table + # and absent from the sheet + placed = layout_dimensions(view, intents) + assert [i.fit for i in placed if i.fit] == ["H7"] + + +def test_a_bearing_seat_takes_its_fit_by_segment(): + part = shaft() + view = build_view(part, "front") + intents = dimension_intents(part, view, fits={segment_key(1): "k6"}) + seat = [i for i in intents if i.fit] + assert [i.feature for i in seat] == ["segment[1]"] + assert measured_value(seat[0]) == pytest.approx(30.0) + assert intent_text(seat[0]) == "⌀30 k6" + + +def test_a_fit_on_a_feature_id_lands_on_that_features_dimension(): + part = plate(*grid(3)) + view = build_view(part, "front") + intents = dimension_intents(part, view, fits={"h1": "H7"}) + assert [i.feature for i in intents if i.fit] == ["h1"] + # a hole already names itself; the code is appended, never swallowed + hole = next(i for i in intents if i.fit) + assert intent_text(hole) == "⌀6 H7" + assert resolve_tolerance(hole)["text_override"] == "⌀6 H7" + + +def test_a_fit_widens_the_measured_label_so_the_layout_reserves_room(): + part = shaft() + view = build_view(part, "front") + plain = next(i for i in dimension_intents(part, view) if i.feature == segment_key(1)) + fitted = next( + i + for i in dimension_intents(part, view, fits={segment_key(1): "k6"}) + if i.feature == segment_key(1) + ) + + def width(intent): + rect = text_rects(layout_dimensions(view, [intent]))[0] + return rect[2] - rect[0] + + assert width(fitted) > width(plain) + + +def test_a_fit_key_that_names_nothing_is_refused_with_the_names_that_exist(): + part = shaft() + view = build_view(part, "front") + with pytest.raises(ValueError, match=r"segment\[0\]"): + dimension_intents(part, view, fits={"bearing_seat": "k6"}) + + +def test_a_malformed_fit_code_is_refused_where_it_is_attached(): + part = shaft() + view = build_view(part, "front") + with pytest.raises(ValueError, match="fit code"): + dimension_intents(part, view, fits={segment_key(1): "H"}) + + +def test_a_fit_on_a_reference_dimension_is_refused(): + part = shaft() + view = build_view(part, "front") + with pytest.raises(ValueError, match="reference"): + dimension_intents(part, view, fits={"part": "H7"}) + + +def test_a_fit_on_a_hole_that_moved_into_the_table_is_refused_by_name(): + part = plate(*grid(10)) + view = build_view(part, "front") + with pytest.raises(ValueError, match="hole table"): + dimension_intents(part, view, fits={"h1": "H7"}, hole_table_threshold=8) + + +# -- collecting the intents -------------------------------------------------- + + +def test_the_front_view_of_a_shaft_gets_its_overall_length_and_each_diameter_once(): + view = build_view(shaft(), "front") + intents = dimension_intents(shaft(), view) + lengths = [i for i in intents if i.kind == "linear"] + diameters = [i for i in intents if i.kind == "diameter"] + assert any(measured_value(i) == 75.0 for i in lengths) + assert sorted({round(measured_value(i), 6) for i in diameters}) == [20.0, 25.0, 30.0] + + +def test_chain_and_baseline_place_the_same_stations_differently(): + part = shaft() + view = build_view(part, "front") + chain = [i for i in dimension_intents(part, view, style="chain") if i.kind == "linear"] + baseline = [i for i in dimension_intents(part, view, style="baseline") if i.kind == "linear"] + assert {round(measured_value(i), 6) for i in chain} >= {20.0, 40.0, 15.0} + assert {round(measured_value(i), 6) for i in baseline} >= {20.0, 60.0, 75.0} + + +def test_ordinate_measures_every_station_from_the_origin(): + part = shaft() + view = build_view(part, "front") + intents = [i for i in dimension_intents(part, view, style="ordinate") if i.kind == "linear"] + assert all(i.p1[0] == 0.0 for i in intents) + + +def test_a_feature_intent_survives_collection_and_keeps_its_feature_id(): + part = shaft(Keyway(id="kw", segment=1, length=20.0)) + view = build_view(part, "front") + intents = dimension_intents(part, view) + assert any(i.feature == "kw" for i in intents) + + +def test_a_duplicate_intent_is_collapsed_rather_than_drawn_twice(): + """`_dedupe` against an intent the collector did not generate itself. + + Asserting uniqueness over what `dimension_intents` returns proves nothing - + it is the key `_dedupe` uniquifies. Feeding the duplicate in through the + view's own dims is what exercises the gate. + """ + part = shaft() + view = build_view(part, "front") + once = dimension_intents(part, view) + repeated = next(i for i in once if i.kind == "diameter") + doubled = dataclasses.replace(view, dims=view.dims + (repeated._replace(text_at=None),)) + assert len(dimension_intents(part, doubled)) == len(once) + + +def test_iso129_a_closed_chain_marks_the_overall_as_a_reference_dimension(): + """20 + 40 + 15 and 75 state the same distance twice; ISO 129-1 parenthesises one. + + The plan's own test asserts the 75 is present, which a bare fourth plain + DIMENSION satisfies. What matters is that it is drawn as an auxiliary + (reference) dimension. + """ + part = shaft() + view = build_view(part, "front") + linear = [i for i in dimension_intents(part, view, style="chain") if i.kind == "linear"] + assert sorted(round(measured_value(i), 6) for i in linear) == [15.0, 20.0, 40.0, 75.0] + overall = [i for i in linear if measured_value(i) == 75.0] + assert len(overall) == 1 + assert is_reference(overall[0]) + assert intent_text(overall[0]) == "(75)" + assert [i for i in linear if is_reference(i)] == overall + + +def test_iso129_no_plain_dimension_restates_a_measurement_the_others_give(): + """The real property, on every style and both part kinds.""" + parts = (shaft(Keyway(id="kw", segment=1, length=20.0)), plate(*grid(4))) + for style in DIM_STYLES: + for part in parts: + view = build_view(part, "front") + intents = dimension_intents(part, view, style=style) + plain = [i for i in intents if not is_reference(i)] + assert redundant_linear(plain) == (), f"{part.name} / {style}" + + +def test_the_redundancy_check_fires_instead_of_restating_its_own_post_condition(): + """A gate that cannot fail is not a gate: hand it the un-marked chain.""" + chain = [ + DimIntent(kind="linear", p1=(0.0, 0.0), p2=(20.0, 0.0)), + DimIntent(kind="linear", p1=(20.0, 0.0), p2=(60.0, 0.0)), + DimIntent(kind="linear", p1=(60.0, 0.0), p2=(75.0, 0.0)), + DimIntent(kind="linear", p1=(0.0, 0.0), p2=(75.0, 0.0)), + ] + assert redundant_linear(chain) == (3,) + baseline = [ + DimIntent(kind="linear", p1=(0.0, 0.0), p2=(20.0, 0.0)), + DimIntent(kind="linear", p1=(0.0, 0.0), p2=(60.0, 0.0)), + DimIntent(kind="linear", p1=(0.0, 0.0), p2=(75.0, 0.0)), + ] + assert redundant_linear(baseline) == () + # two horizontal chains at different heights measure different things + stacked = [ + DimIntent(kind="linear", p1=(0.0, 0.0), p2=(20.0, 0.0)), + DimIntent(kind="linear", p1=(20.0, 5.0), p2=(75.0, 5.0)), + DimIntent(kind="linear", p1=(0.0, 0.0), p2=(75.0, 0.0)), + ] + assert redundant_linear(stacked) == () + + +def test_baseline_states_every_station_once_so_nothing_is_parenthesised(): + part = shaft() + view = build_view(part, "front") + intents = dimension_intents(part, view, style="baseline") + assert not any(is_reference(i) for i in intents) + + +def test_a_prismatic_front_view_gets_its_two_overall_sizes(): + view = build_view(plate(), "front") + intents = dimension_intents(plate(), view) + assert sorted({round(measured_value(i), 6) for i in intents if i.kind == "linear"}) == [ + 50.0, + 80.0, + ] + + +# -- the hole table ---------------------------------------------------------- + + +def test_under_the_threshold_the_holes_keep_their_own_diameters_and_there_is_no_table(): + part = plate(*grid(4)) + view = build_view(part, "front") + intents = dimension_intents(part, view, hole_table_threshold=8) + assert sum(1 for i in intents if i.kind == "diameter") == 4 + assert hole_table_rows(part, view) == () + + +def test_over_the_threshold_the_hole_diameters_move_into_a_table(): + part = plate(*grid(10)) + view = build_view(part, "front") + intents = dimension_intents(part, view, hole_table_threshold=8) + assert not any(i.kind == "diameter" for i in intents) + rows = hole_table_rows(part, view) + assert len(rows) == 10 + assert {row["tag"] for row in rows} == {"A"} + assert rows[0]["qty"] == 10 + assert rows[0]["d"] == 6.0 + assert set(rows[0]) == {"tag", "x", "y", "d", "qty", "note"} + + +def test_two_hole_sizes_get_two_tags(): + part = plate( + *grid(6), *(Hole(id=f"b{i}", x=5.0 + 9.0 * i, y=40.0, diameter=10.0) for i in range(4)) + ) + view = build_view(part, "front") + rows = hole_table_rows(part, view) + assert {row["tag"] for row in rows} == {"A", "B"} + by_tag = {row["tag"]: row["qty"] for row in rows} + assert by_tag == {"A": 6, "B": 4} + + +def test_a_tapped_hole_carries_its_designation_in_the_note(): + part = plate( + *(Hole(id=f"t{i}", x=5.0 + 9.0 * i, y=25.0, diameter=10.0, thread="M10") for i in range(9)) + ) + view = build_view(part, "front") + rows = hole_table_rows(part, view) + assert rows[0]["note"] == "M10" + + +# -- the layout, and its overlap proof --------------------------------------- + + +def test_the_layout_places_every_intent_and_the_text_rectangles_are_disjoint(): + part = shaft(Keyway(id="kw", segment=1, length=20.0)) + view = build_view(part, "front") + intents = dimension_intents(part, view) + placed = layout_dimensions(view, intents) + assert len(placed) == len(intents) + assert all(intent.text_at is not None for intent in placed) + rects = text_rects(placed) + for a, b in itertools.combinations(rects, 2): + overlap_x = min(a[2], b[2]) - max(a[0], b[0]) + overlap_y = min(a[3], b[3]) - max(a[1], b[1]) + assert overlap_x <= 1e-9 or overlap_y <= 1e-9, f"{a} overlaps {b}" + + +def test_a_crowded_prismatic_view_still_lays_out_disjointly(): + part = plate(*grid(6)) + view = build_view(part, "front") + placed = layout_dimensions(view, dimension_intents(part, view, hole_table_threshold=100)) + rects = text_rects(placed) + for a, b in itertools.combinations(rects, 2): + overlap_x = min(a[2], b[2]) - max(a[0], b[0]) + overlap_y = min(a[3], b[3]) - max(a[1], b[1]) + assert overlap_x <= 1e-9 or overlap_y <= 1e-9, f"{a} overlaps {b}" + + +def test_horizontal_dimensions_stack_below_the_view_and_vertical_ones_to_its_left(): + part = shaft() + view = build_view(part, "front") + placed = layout_dimensions(view, dimension_intents(part, view), offset=10.0, step=8.0) + horizontal = [i for i in placed if i.kind == "linear" and abs(i.p1[1] - i.p2[1]) < 1e-9] + assert horizontal and all(i.text_at[1] <= view.bbox[1] - 10.0 + 1e-9 for i in horizontal) + diameters = [i for i in placed if i.kind == "diameter"] + assert diameters and all(i.text_at[0] >= view.bbox[2] + 10.0 - 1e-9 for i in diameters) + + +def test_the_step_is_never_smaller_than_the_text_it_has_to_clear(): + part = shaft() + view = build_view(part, "front") + placed = layout_dimensions(view, dimension_intents(part, view), offset=10.0, step=1.0) + rects = text_rects(placed) + for a, b in itertools.combinations(rects, 2): + overlap_x = min(a[2], b[2]) - max(a[0], b[0]) + overlap_y = min(a[3], b[3]) - max(a[1], b[1]) + assert overlap_x <= 1e-9 or overlap_y <= 1e-9 + + +def test_an_unknown_style_is_refused(): + part = shaft() + view = build_view(part, "front") + with pytest.raises(ValueError, match="chain"): + dimension_intents(part, view, style="freehand") diff --git a/tests/test_mech_draw.py b/tests/test_mech_draw.py new file mode 100644 index 0000000..800290b --- /dev/null +++ b/tests/test_mech_draw.py @@ -0,0 +1,494 @@ +"""The async layer: primitives to entities, the part payload, and the sheet. + +Headless ezdxf against a real document - the `backend` fixture in +tests/conftest.py. The COM paths are the same code (draw.py composes the +generic contract members), which is the whole point of the pattern +`gear_draw_*` and `titleblock_apply_iso_a3` already use. +""" + +from __future__ import annotations + +import pytest + +from engineering.mech.draw import ( + ANCHOR_LAYER, + add_view, + dimension_part, + draw_from_spec, + draw_hole_pattern, + draw_part, + draw_prims, + read_part, +) +from engineering.mech.part import build_part +from engineering.mech.primitives import Arc, Circle, HatchArea, Line, Poly, Text + +pytestmark = pytest.mark.asyncio + +SHAFT = { + "kind": "revolved", + "name": "shaft", + "material": "steel", + "segments": [ + {"length": 20.0, "d_outer": 25.0}, + {"length": 40.0, "d_outer": 30.0}, + {"length": 15.0, "d_outer": 20.0}, + ], + "features": [{"kind": "chamfer", "id": "c1", "at": "start", "size": 2.0}], +} + +PLATE = { + "kind": "prismatic", + "name": "plate", + "material": "aluminium", + "thickness": 8.0, + "outline": [[0.0, 0.0], [80.0, 0.0], [80.0, 50.0], [0.0, 50.0]], + "features": [{"kind": "hole", "id": "h1", "x": 40.0, "y": 25.0, "diameter": 10.0}], +} + + +async def entities(backend, type_filter=None, layer=None): + return await backend.entity_list(type_filter=type_filter, layer_filter=layer, limit=2000) + + +async def test_draw_prims_puts_every_role_on_its_own_layer(backend): + result = await draw_prims( + backend, + [ + Line((0.0, 0.0), (10.0, 0.0), "visible"), + Line((0.0, 5.0), (10.0, 5.0), "hidden"), + Line((-2.0, 2.5), (12.0, 2.5), "center"), + Circle((5.0, 2.5), 1.0, "visible"), + Arc((5.0, 2.5), 2.0, 0.0, 90.0, "visible"), + Poly(((0.0, 0.0), (10.0, 0.0), (10.0, 5.0)), True, "visible"), + Text((5.0, 8.0), "A", 3.5, 0.0, "text"), + HatchArea((((0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (0.0, 5.0)),), "steel"), + ], + ) + assert len(result["handles"]) == 8 + layers = {e.layer for e in await entities(backend)} + assert {"GEOMETRY", "HIDDEN", "CENTER", "TEXT", "HATCH"} <= layers + + +async def test_draw_prims_places_and_rotates_into_wcs(backend): + await draw_prims(backend, [Line((0.0, 0.0), (10.0, 0.0), "visible")], at=(100.0, 50.0)) + (line,) = await entities(backend, type_filter="LINE") + start = line.properties["start"] + assert (round(start[0], 6), round(start[1], 6)) == (100.0, 50.0) + + +async def test_draw_part_writes_the_model_back_onto_the_drawing(backend): + part = build_part(SHAFT) + result = await draw_part(backend, part, at=(50.0, 100.0)) + assert result["part_id"] + assert result["views"][0]["kind"] == "front" + assert result["handles"] + + anchors = await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER) + assert len(anchors) == 1 + assert anchors[0].handle == result["part_id"] + + read = await read_part(backend, result["part_id"]) + assert read["part"]["name"] == "shaft" + assert [f["id"] for f in read["part"]["features"]] == ["c1"] + assert build_part(read["part"]) == part + + +async def test_read_part_without_an_id_finds_the_only_part(backend): + part = build_part(SHAFT) + drawn = await draw_part(backend, part) + read = await read_part(backend) + assert read["part_id"] == drawn["part_id"] + + +async def test_read_part_refuses_when_there_is_more_than_one_and_no_id(backend): + await draw_part(backend, build_part(SHAFT), at=(0.0, 0.0)) + await draw_part(backend, build_part(PLATE), at=(200.0, 0.0)) + with pytest.raises(ValueError) as excinfo: + await read_part(backend) + assert "part_id" in str(excinfo.value) + + +async def test_two_views_are_placed_in_first_angle_positions(backend): + part = build_part(SHAFT) + result = await draw_part(backend, part, at=(0.0, 0.0), views=("front", "side")) + front, end = result["views"] + assert end["kind"] == "side" + # first angle: the view from the right sits to the LEFT of the front view + assert end["bbox"][2] <= front["bbox"][0] + 1e-6 + + +async def test_third_angle_puts_the_end_view_on_the_other_side(backend): + part = build_part(SHAFT) + result = await draw_part( + backend, part, at=(0.0, 0.0), views=("front", "side"), projection="third" + ) + front, end = result["views"] + assert end["bbox"][0] >= front["bbox"][2] - 1e-6 + + +async def test_add_view_reads_the_model_back_and_appends_a_section(backend): + part = build_part(SHAFT) + drawn = await draw_part(backend, part, at=(0.0, 0.0)) + added = await add_view( + backend, + drawn["part_id"], + "section", + plane={"p1": [0.0, 0.0], "p2": [100.0, 0.0], "label": "A"}, + ) + assert added["view"]["kind"] == "section" + assert added["view"]["label"] == "A-A" + assert len(await entities(backend, type_filter="HATCH")) == 2 + read = await read_part(backend, drawn["part_id"]) + assert [v["kind"] for v in read["views"]] == ["front", "section"] + + +async def test_a_detail_view_also_marks_its_circle_on_the_parent(backend): + part = build_part(SHAFT) + drawn = await draw_part(backend, part, at=(0.0, 0.0)) + added = await add_view( + backend, + drawn["part_id"], + "detail", + detail={"center": [20.0, 12.5], "radius": 8.0, "scale": 5.0, "label": "D"}, + ) + assert added["view"]["label"] == "D (5:1)" + texts = [e for e in await entities(backend, type_filter="TEXT")] + assert any(e.properties.get("text") == "D" for e in texts) + + +async def test_dimension_part_creates_real_dimension_entities(backend): + part = build_part(SHAFT) + drawn = await draw_part(backend, part, at=(0.0, 0.0), dimension=False) + assert not await entities(backend, type_filter="DIMENSION") + result = await dimension_part(backend, drawn["part_id"]) + assert result["count"] > 0 + dims = await entities(backend, type_filter="DIMENSION") + assert len(dims) == result["count"] + assert {e.layer for e in dims} == {"DIM"} + + +async def test_an_iso_286_fit_reaches_the_dimension_as_a_real_tolerance(backend): + """`fits` names a dimension, and 0.025 is H7 on the 40 mm bore. + + The key is the one `engineering/mech/dimension.segment_key` mints, not the + part's name: `dimension_intents` addresses the part's own diameters one at + a time (`segment[0]`, `segment[0].bore`) precisely so a fit lands on the + bore rather than on every dimension the part emits. Measured: H7 is + +0.025/0 at 40 mm, +0.030/0 at 60 mm and +0.021/0 at the 30 mm length. + """ + spec = { + "kind": "revolved", + "name": "bore", + "segments": [{"length": 30.0, "d_outer": 60.0, "d_inner": 40.0}], + "features": [], + } + part = build_part(spec) + drawn = await draw_part(backend, part, at=(0.0, 0.0), dimension=False) + result = await dimension_part(backend, drawn["part_id"], fits={"segment[0].bore": "H7"}) + assert result["count"] > 0 + assert any( + row.get("fit") == "H7" + and row["tol_upper"] == pytest.approx(0.025) + and row["tol_lower"] == pytest.approx(0.0) + and row["tol_mode"] == "deviation" + and "H7" in row["text_override"] + for row in result["resolved"] + ) + # and the outside diameter of the same segment is untouched + assert all( + row["tol_mode"] == "none" + for row in result["resolved"] + if row["feature"] != "segment[0].bore" + ) + + +async def test_a_fit_that_names_nothing_is_refused_with_the_names_that_exist(backend): + part = build_part(SHAFT) + drawn = await draw_part(backend, part, at=(0.0, 0.0), views=("front", "side"), dimension=False) + with pytest.raises(ValueError) as excinfo: + await dimension_part(backend, drawn["part_id"], fits={"bearing_seat": "k6"}) + assert "bearing_seat" in str(excinfo.value) + assert "segment[0]" in str(excinfo.value) + + +async def test_a_fit_on_one_view_is_not_refused_because_another_view_lacks_it(backend): + """A part drawn front + side: `segment[0]` is dimensioned in the front view + only, and the end view (which dimensions the *last* segment) must not turn + a correct sheet into a refusal.""" + part = build_part(SHAFT) + drawn = await draw_part(backend, part, at=(0.0, 0.0), views=("front", "side"), dimension=False) + result = await dimension_part(backend, drawn["part_id"], fits={"segment[0]": "k6"}) + assert any(row.get("fit") == "k6" for row in result["resolved"]) + + +async def test_a_hole_pattern_draws_its_holes_and_centre_marks(backend): + result = await draw_hole_pattern( + backend, pattern="polar", x=0.0, y=0.0, diameter=8.0, count=6, pcd=80.0 + ) + assert result["count"] == 6 + circles = await entities(backend, type_filter="CIRCLE") + assert len(circles) == 6 + assert len(await entities(backend, layer="CENTER")) >= 12 + + +async def test_a_hole_pattern_with_no_spacing_is_refused_before_anything_is_drawn(backend): + with pytest.raises(ValueError, match="spacing"): + await draw_hole_pattern(backend, pattern="linear", x=0.0, y=0.0, diameter=8.0, count=4) + assert not await entities(backend, type_filter="CIRCLE") + + +async def test_draw_from_spec_draws_a_whole_sheet_in_one_transaction(backend): + result = await draw_from_spec( + backend, + { + "sheet": {"intent": "mechanical sheet", "sheet_size": "A3", "scale": 1.0}, + "parts": [ + {"part": SHAFT, "at": [40.0, 200.0], "views": ["front", "side"]}, + {"part": PLATE, "at": [40.0, 60.0], "views": ["front"]}, + ], + }, + ) + assert len(result["parts"]) == 2 + assert len(await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER)) == 2 + + +async def test_draw_from_spec_validates_every_part_before_it_draws_anything(backend): + bad = dict(SHAFT, segments=[{"length": 20.0, "d_outer": float("inf")}]) + with pytest.raises(ValueError) as excinfo: + await draw_from_spec( + backend, + {"parts": [{"part": PLATE, "at": [0.0, 0.0]}, {"part": bad, "at": [200.0, 0.0]}]}, + ) + assert "parts[1]" in str(excinfo.value) + assert "segments[0].d_outer" in str(excinfo.value) + assert not await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER) + + +async def test_draw_from_spec_rolls_back_when_a_draw_fails_midway(backend, monkeypatch): + calls = {"n": 0} + real = backend.entity_create_line + + async def explode(*args, **kwargs): + calls["n"] += 1 + if calls["n"] > 3: + raise RuntimeError("the seat went away") + return await real(*args, **kwargs) + + monkeypatch.setattr(backend, "entity_create_line", explode) + with pytest.raises(RuntimeError, match="the seat went away"): + await draw_from_spec(backend, {"parts": [{"part": SHAFT, "at": [0.0, 0.0]}]}) + monkeypatch.undo() + assert not await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER) + assert not await entities(backend, type_filter="LINE") + + +# -- review round: rotation, single dimensioning, honest refusals ------------- + +TWO_STEP = { + "kind": "revolved", + "name": "shaft", + "material": "steel", + "segments": [{"length": 20.0, "d_outer": 25.0}, {"length": 40.0, "d_outer": 30.0}], + "features": [], +} + +TUBE = { + "kind": "revolved", + "name": "tube", + "material": "steel", + "segments": [{"length": 30.0, "d_outer": 60.0, "d_inner": 40.0}], + "features": [], +} + + +def _geometry_bbox(backend): + xs: list[float] = [] + ys: list[float] = [] + for e in backend._doc.modelspace(): + if e.dxftype() == "LINE": + xs += [e.dxf.start.x, e.dxf.end.x] + ys += [e.dxf.start.y, e.dxf.end.y] + return [min(xs), min(ys), max(xs), max(ys)] + + +def _defpoints(backend): + return sorted( + (round(e.dxf.get(name).x, 6), round(e.dxf.get(name).y, 6)) + for e in backend._doc.modelspace().query("DIMENSION") + for name in ("defpoint2", "defpoint3") + if e.dxf.hasattr(name) + ) + + +def _turn90(point, about): + return (round(about[0] - (point[1] - about[1]), 6), round(about[1] + (point[0] - about[0]), 6)) + + +async def test_rotation_turns_geometry_record_and_dimensions_about_at(backend): + """rotation=90 about at=(100, 100): the part, its recorded box and every + DIMENSION defpoint are the rotation=0 drawing turned about `at`.""" + from backends.ezdxf_backend import EzdxfBackend + + flat = EzdxfBackend() + await flat.connect() + await flat.drawing_new() + try: + await draw_part(flat, build_part(TWO_STEP), at=(100.0, 100.0), rotation=0.0) + expected = sorted(_turn90(p, (100.0, 100.0)) for p in _defpoints(flat)) + finally: + await flat.disconnect() + + result = await draw_part(backend, build_part(TWO_STEP), at=(100.0, 100.0), rotation=90.0) + geometry = _geometry_bbox(backend) + assert geometry == pytest.approx([70.0, 100.0, 100.0, 172.0]) + assert result["views"][0]["bbox"] == pytest.approx(geometry) + read = await read_part(backend, result["part_id"]) + assert read["views"][0]["bbox"] == pytest.approx(geometry) + assert read["views"][0]["frame_bbox"] == pytest.approx([100.0, 100.0, 172.0, 130.0]) + assert expected + assert _defpoints(backend) == expected + + +async def test_a_view_added_to_a_rotated_part_turns_with_it(backend): + drawn = await draw_part( + backend, build_part(TWO_STEP), at=(0.0, 0.0), rotation=90.0, dimension=False + ) + added = await add_view(backend, drawn["part_id"], "side") + # first angle: the side view sits left of the front in the part's frame, + # which is BELOW it once the sheet is turned 90 degrees CCW + front, side = drawn["views"][0]["bbox"], added["view"]["bbox"] + assert side[3] <= front[1] + 1e-6 + + +async def test_draw_then_add_view_then_dimension_never_duplicates(backend): + drawn = await draw_part(backend, build_part(SHAFT), at=(0.0, 0.0), dimension=True) + before = len(await entities(backend, type_filter="DIMENSION")) + assert before == drawn["dimensions"] > 0 + await add_view(backend, drawn["part_id"], "side") + result = await dimension_part(backend, drawn["part_id"]) + assert result["views_dimensioned"] == [1] + after = len(await entities(backend, type_filter="DIMENSION")) + assert after == before + result["count"] + assert result["count"] > 0 + + with pytest.raises(ValueError, match="already dimensioned"): + await dimension_part(backend, drawn["part_id"]) + with pytest.raises(ValueError, match=r"0 \(front\) already dimensioned"): + await dimension_part(backend, drawn["part_id"], views=[0]) + assert len(await entities(backend, type_filter="DIMENSION")) == after + + +async def test_dimension_part_twice_is_refused_not_stacked(backend): + drawn = await draw_part(backend, build_part(PLATE), at=(0.0, 0.0), dimension=False) + first = await dimension_part(backend, drawn["part_id"]) + with pytest.raises(ValueError, match="every view is already dimensioned"): + await dimension_part(backend, drawn["part_id"]) + assert len(await entities(backend, type_filter="DIMENSION")) == first["count"] + + +async def test_a_view_the_dimension_layer_cannot_dimension_is_reported_not_flagged(backend): + drawn = await draw_part(backend, build_part(SHAFT), at=(0.0, 0.0)) + await add_view( + backend, + drawn["part_id"], + "detail", + detail={"center": [20.0, 12.5], "radius": 8.0, "scale": 5.0, "label": "D"}, + ) + result = await dimension_part(backend, drawn["part_id"]) + assert result["views_dimensioned"] == [] + assert any(row.get("kind") == "detail" for row in result["skipped"]) + assert result["count"] == 0 + + +async def test_an_island_that_does_not_attach_removes_the_hatch_and_raises(backend, monkeypatch): + drawn = await draw_part(backend, build_part(TUBE), at=(0.0, 0.0), dimension=False) + real = backend.hatch_add_boundary + + async def refuse(handle, edges): + return await real(handle, []) + + monkeypatch.setattr(backend, "hatch_add_boundary", refuse) + with pytest.raises(ValueError, match="did not attach"): + await add_view( + backend, + drawn["part_id"], + "section", + plane={"p1": [15.0, -40.0], "p2": [15.0, 40.0], "label": "B"}, + ) + assert not await entities(backend, type_filter="HATCH") + + +async def test_an_engine_without_hatch_edge_paths_refuses_before_drawing(backend, monkeypatch): + from backends.base import CapabilityMap, FeatureCapability + from backends.capability import UnsupportedCapabilityError + + drawn = await draw_part(backend, build_part(TUBE), at=(0.0, 0.0), dimension=False) + count = len(await entities(backend)) + monkeypatch.setattr( + backend, + "capabilities", + lambda: CapabilityMap( + "com", {"hatch_edge_paths": FeatureCapability(False, reason="measured")} + ), + ) + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await add_view( + backend, + drawn["part_id"], + "section", + plane={"p1": [15.0, -40.0], "p2": [15.0, 40.0], "label": "B"}, + ) + assert excinfo.value.capability == "hatch_edge_paths" + assert len(await entities(backend)) == count + + +async def test_a_scale_that_would_only_be_recorded_is_refused(backend): + with pytest.raises(ValueError, match="full size"): + await draw_part(backend, build_part(PLATE), scale=0.5) + drawn = await draw_part(backend, build_part(PLATE), dimension=False) + with pytest.raises(ValueError, match="full size"): + await add_view(backend, drawn["part_id"], "side", scale=2.0) + + +async def test_a_second_added_view_steps_along_its_own_projection_axis(backend): + drawn = await draw_part( + backend, build_part(PLATE), at=(0.0, 0.0), dimension=False, projection="third" + ) + side = await add_view(backend, drawn["part_id"], "side") + section = await add_view( + backend, + drawn["part_id"], + "section", + plane={"p1": [0.0, 25.0], "p2": [80.0, 25.0], "label": "A"}, + ) + front = drawn["views"][0]["bbox"] + # third angle puts both to the RIGHT; the second steps further right, + # never back onto the front view + assert section["view"]["bbox"][0] >= side["view"]["bbox"][2] + 20.0 - 1e-6 + assert section["view"]["bbox"][0] > front[2] + + +async def test_a_top_view_sharing_the_x_range_is_not_shoved(backend): + drawn = await draw_part(backend, build_part(PLATE), at=(0.0, 0.0), dimension=False) + await add_view(backend, drawn["part_id"], "side") + top = await add_view(backend, drawn["part_id"], "top", gap=25.0) + front = drawn["views"][0]["bbox"] + assert top["view"]["bbox"][0] == pytest.approx(front[0]) + assert top["view"]["bbox"][2] == pytest.approx(front[2]) + + +@pytest.mark.parametrize("at", [[], [5.0], {"x": 1}, "0,0", [1.0, "a"]]) +async def test_a_malformed_at_is_refused_with_its_part_path(backend, at): + with pytest.raises(ValueError, match=r"parts\[0\]: at"): + await draw_from_spec(backend, {"parts": [{"part": PLATE, "at": at}]}) + assert not await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER) + + +async def test_a_transaction_that_cannot_open_is_a_value_error(backend, monkeypatch): + async def busy(): + return {"ok": False, "error": "a transaction is already open"} + + monkeypatch.setattr(backend, "transaction_begin", busy) + with pytest.raises(ValueError, match="could not open a transaction"): + await draw_from_spec(backend, {"parts": [{"part": PLATE, "at": [0.0, 0.0]}]}) diff --git a/tests/test_mech_features.py b/tests/test_mech_features.py new file mode 100644 index 0000000..b529d3f --- /dev/null +++ b/tests/test_mech_features.py @@ -0,0 +1,753 @@ +"""Every feature of spec section 5: validation, emitted primitives, dimensions. + +Geometry is asserted as emitted primitive tuples - counts, roles, coordinates +to 1e-9 - never as a screenshot. The two features with a real table +(`Keyway` -> DIN 6885 via engineering.keyway, `GearTeeth` -> the involute +generator in engineering.gear) are pinned against those modules rather than +against numbers restated here; the four not-transcribed tables are exercised +through their refusals. +""" + +from __future__ import annotations + +import math + +import pytest + +from engineering.gear import generate_full_gear_outline +from engineering.keyway import keyway_dimensions +from engineering.mech.features import ( + FEATURE_KINDS, + AxialBore, + BendLine, + CentreHole, + Chamfer, + CornerChamfer, + CornerFillet, + CrossHole, + Fillet, + GearTeeth, + Hole, + HoleCircle, + HolePattern, + Keyway, + ORingGroove, + Pocket, + RetainingGroove, + Slot, + Thread, + Undercut, + feature_from_dict, + mirror_about_x, +) +from engineering.mech.part import PrismaticPart, RevolvedPart, Segment, build_part +from engineering.mech.primitives import Arc, Circle, Line, Poly, Text, bbox + +CTX: dict = {"side": "right", "scale": 1.0, "projection": "first"} + + +def shaft(*features) -> RevolvedPart: + return RevolvedPart( + name="shaft", + segments=(Segment(20.0, 25.0), Segment(40.0, 30.0, 10.0), Segment(15.0, 20.0)), + features=tuple(features), + ) + + +def plate(*features) -> PrismaticPart: + return PrismaticPart( + name="plate", + outline=((0.0, 0.0), (80.0, 0.0), (80.0, 50.0), (0.0, 50.0)), + thickness=8.0, + features=tuple(features), + ) + + +def test_the_catalogue_is_the_nineteen_kinds_spec_section_5_names(): + assert FEATURE_KINDS == ( + "axial_bore", + "bend_line", + "centre_hole", + "chamfer", + "corner_chamfer", + "corner_fillet", + "cross_hole", + "fillet", + "gear_teeth", + "hole", + "hole_circle", + "hole_pattern", + "keyway", + "oring_groove", + "pocket", + "retaining_groove", + "slot", + "thread", + "undercut", + ) + + +def test_feature_from_dict_round_trips_every_kind_it_builds(): + payload = {"kind": "chamfer", "id": "c1", "at": "start", "size": 2.0, "angle": 45.0} + feature = feature_from_dict(payload) + assert isinstance(feature, Chamfer) + assert feature.to_dict() == payload + + +def test_feature_from_dict_refuses_an_unknown_kind_and_an_unknown_key(): + with pytest.raises(ValueError, match="unknown feature"): + feature_from_dict({"kind": "spline", "id": "s"}) + with pytest.raises(ValueError, match="unknown key"): + feature_from_dict({"kind": "chamfer", "id": "c", "at": "start", "size": 1.0, "deg": 30}) + + +def test_mirror_about_x_flips_every_primitive_type(): + line, arc, circle, poly, text = mirror_about_x( + [ + Line((0.0, 5.0), (10.0, 5.0)), + Arc((0.0, 4.0), 2.0, 30.0, 90.0), + Circle((1.0, 3.0), 2.0), + Poly(((0.0, 1.0), (2.0, 3.0)), True), + Text((1.0, 2.0), "A"), + ] + ) + assert line.p1 == (0.0, -5.0) and line.p2 == (10.0, -5.0) + assert arc.center == (0.0, -4.0) + assert (arc.start_deg, arc.end_deg) == (270.0, 330.0) + assert circle.center == (1.0, -3.0) + assert poly.points == ((0.0, -1.0), (2.0, -3.0)) + assert text.at == (1.0, -2.0) + + +# -- chamfer and fillet ------------------------------------------------------ + + +def test_a_45_degree_chamfer_at_the_start_replaces_the_corner_with_equal_legs(): + feature = Chamfer(id="c1", at="start", size=2.0) + part = shaft(feature) + edit = feature.corner_edit(part) + assert edit["corner"] == (0.0, 12.5) + assert edit["before"] == (0.0, 10.5) + assert edit["after"] == (2.0, 12.5) + assert edit["arc"] is None + assert feature.removal_box(part) == (0.0, 10.5, 2.0, 12.5) + + +def test_a_30_degree_chamfer_at_the_end_cuts_toward_minus_x(): + """The two replacement points sit on the two surfaces the corner joined. + + The silhouette runs left to right, so ``before`` is the point left on the + cylinder (x = 75 - size, r unchanged) and ``after`` is the point left on + the end face (x = 75, r reduced by the radial leg). Neither of them is the + corner itself. + """ + feature = Chamfer(id="c2", at="end", size=3.0, angle=30.0) + part = shaft(feature) + edit = feature.corner_edit(part) + radial = 3.0 * math.tan(math.radians(30.0)) + assert edit["corner"] == (75.0, 10.0) + assert edit["before"][0] == pytest.approx(72.0) + assert edit["before"][1] == pytest.approx(10.0) + assert edit["after"][0] == pytest.approx(75.0) + assert edit["after"][1] == pytest.approx(10.0 - radial) + assert edit["arc"] is None + + +def test_a_chamfer_shows_as_an_extra_circle_in_the_end_view(): + feature = Chamfer(id="c1", at="end", size=2.0) + part = shaft(feature) + (circle,) = feature.prims(part, "side", CTX) + assert isinstance(circle, Circle) + assert circle.radius == pytest.approx(8.0) + + +def test_a_chamfer_bigger_than_the_step_is_refused_by_name(): + feature = Chamfer(id="c1", at="start", size=40.0) + with pytest.raises(ValueError, match="c1|size"): + feature.validate(shaft(feature)) + + +def test_a_chamfer_whose_axial_leg_outruns_its_segment_is_refused_by_name(): + """The radial guard only bites near 45 deg; a shallow lead-in slips past it. + + The end segment of `shaft()` is 15 mm long at r = 10. A 30 mm chamfer at + 10 deg takes only 5.29 mm off the radius, so `radial >= radius` is happy, + and the old part-length bound (75 mm) was happy too - yet `before` landed + at x = 45, two segments away, anchored at a radius that segment does not + have. + """ + feature = Chamfer(id="c1", at="end", size=30.0, angle=10.0) + with pytest.raises(ValueError) as excinfo: + feature.validate(shaft(feature)) + message = str(excinfo.value) + assert "30" in message and "15" in message and "segment 2" in message + + +def test_a_shallow_chamfer_that_fits_inside_its_segment_is_still_accepted(): + """The new bound refuses only what leaves the segment; 14 < 15 mm stays.""" + feature = Chamfer(id="c1", at="end", size=14.0, angle=10.0) + part = shaft(feature) + feature.validate(part) + edit = feature.corner_edit(part) + assert edit["before"][0] == pytest.approx(61.0) + assert edit["before"][0] >= 60.0 # inside segment 2, which spans x 60..75 + + +def test_a_chamfer_at_a_step_is_bounded_by_the_segment_it_cuts_into(): + """`shaft()` steps UP at x = 20, so the chamfer cuts into segment 1 (40 mm). + + A 45 mm leg outruns that segment even though the part is 75 mm long. + """ + feature = Chamfer(id="c1", at="step:0", size=45.0, angle=5.0) + with pytest.raises(ValueError) as excinfo: + feature.validate(shaft(feature)) + assert "segment 1" in str(excinfo.value) and "40" in str(excinfo.value) + + +def test_a_fillet_at_a_step_up_emits_a_tangent_arc_in_the_lower_right_quadrant(): + feature = Fillet(id="f1", at="step:0", radius=2.0) + part = shaft(feature) + edit = feature.corner_edit(part) + assert edit["corner"] == (20.0, 12.5) + assert edit["before"] == (18.0, 12.5) + assert edit["after"] == (20.0, 14.5) + arc = edit["arc"] + assert isinstance(arc, Arc) + assert arc.center == (18.0, 14.5) + assert (arc.radius, arc.start_deg, arc.end_deg) == (2.0, 270.0, 360.0) + + +def test_a_fillet_at_a_step_down_puts_the_arc_in_the_lower_left_quadrant(): + feature = Fillet(id="f2", at="step:1", radius=1.5) + part = shaft(feature) + edit = feature.corner_edit(part) + assert edit["corner"] == (60.0, 10.0) + assert edit["before"] == (60.0, 11.5) + assert edit["after"] == (61.5, 10.0) + assert edit["arc"].center == (61.5, 11.5) + assert (edit["arc"].start_deg, edit["arc"].end_deg) == (180.0, 270.0) + + +def test_a_fillet_is_only_defined_at_a_step(): + feature = Fillet(id="f3", at="start", radius=1.0) + with pytest.raises(ValueError, match="step"): + feature.validate(shaft(feature)) + + +# -- keyway (DIN 6885 through engineering.keyway) ---------------------------- + + +def test_a_keyway_takes_its_width_and_depth_from_din_6885(): + feature = Keyway(id="kw", segment=1, length=25.0, offset=5.0) + part = shaft(feature) + feature.validate(part) + table = keyway_dimensions(30.0) + prims = feature.prims(part, "front", CTX) + assert len(prims) == 3 + assert all(isinstance(p, Line) and p.role == "visible" for p in prims) + bottom = 15.0 - table["depth_shaft"] + assert prims[0].p1 == (25.0, 15.0) and prims[0].p2 == (25.0, bottom) + assert prims[1].p1 == (25.0, bottom) and prims[1].p2 == (50.0, bottom) + assert prims[2].p1 == (50.0, bottom) and prims[2].p2 == (50.0, 15.0) + assert feature.removal_box(part) == (25.0, bottom, 50.0, 15.0) + + +def test_a_keyway_end_view_breaks_the_circle_and_draws_the_notch(): + """The arc is the shaft that SURVIVES the slot, not the cap over its mouth. + + `Arc` is CCW start -> end, so the sweep has to be the long way round: it + leaves the left notch wall, goes over the bottom of the shaft and arrives + at the right one. Emitting the two angles the other way round draws a short + chord-like cap straight across the keyway opening - geometrically a legal + arc, which is why only the sweep and the endpoints catch it. Measured on + AutoCAD 2026: the 30.93 deg arc is 8.098 mm long, the 329.07 deg one is + 86.150 mm, and the full r=15 circle is 94.248 mm. + """ + feature = Keyway(id="kw", segment=1, length=25.0) + part = shaft(feature) + prims = feature.prims(part, "side", CTX) + arcs = [p for p in prims if isinstance(p, Arc)] + lines = [p for p in prims if isinstance(p, Line)] + assert len(arcs) == 1 and len(lines) == 3 + width = keyway_dimensions(30.0)["width"] + assert lines[1].p1[0] == pytest.approx(-width / 2.0) + assert lines[1].p2[0] == pytest.approx(width / 2.0) + + arc = arcs[0] + radius = 15.0 + half = width / 2.0 + top = math.sqrt(radius * radius - half * half) + # start at the LEFT wall, end at the RIGHT one: everything but the opening. + assert arc.radius == pytest.approx(radius) + assert arc.start_deg == pytest.approx(math.degrees(math.atan2(top, -half))) + assert arc.end_deg == pytest.approx(math.degrees(math.atan2(top, half))) + sweep = (arc.end_deg - arc.start_deg) % 360.0 + assert sweep == pytest.approx(329.068, abs=1e-3) + assert math.radians(sweep) * radius == pytest.approx(86.150, abs=1e-3) + # and the end view therefore spans the whole shaft, not just the notch: + # full width and full depth, topped by the notch walls because the slot has + # taken the crown of the circle away. + assert bbox(prims) == pytest.approx((-radius, -radius, radius, top)) + + +def test_a_keyway_longer_than_its_segment_is_refused_by_name(): + feature = Keyway(id="kw", segment=1, length=50.0) + with pytest.raises(ValueError) as excinfo: + feature.validate(shaft(feature)) + assert "40" in str(excinfo.value) + + +def test_a_keyway_on_a_segment_that_does_not_exist_is_refused_by_range(): + feature = Keyway(id="kw", segment=5, length=10.0) + with pytest.raises(ValueError) as excinfo: + feature.validate(shaft(feature)) + assert "segments 0-2" in str(excinfo.value) + + +def test_a_keyway_outside_the_din_6885_bore_range_is_refused(): + part = RevolvedPart(name="pin", segments=(Segment(20.0, 4.0),)) + feature = Keyway(id="kw", segment=0, length=10.0) + with pytest.raises(ValueError, match="DIN 6885"): + feature.validate(part) + + +# -- thread ------------------------------------------------------------------ + + +def test_an_external_thread_draws_the_iso6410_minor_lines_and_the_limit_line(): + feature = Thread(id="th", segment=2, designation="M20", length=12.0) + part = shaft(feature) + feature.validate(part) + prims = feature.prims(part, "front", CTX) + assert len(prims) == 3 + assert prims[0].p1 == (60.0, 8.0) and prims[0].p2 == (72.0, 8.0) + assert prims[2].p1 == (72.0, -10.0) and prims[2].p2 == (72.0, 10.0) + + +def test_a_thread_whose_diameter_does_not_match_its_segment_is_refused_with_both_numbers(): + feature = Thread(id="th", segment=1, designation="M20", length=10.0) + with pytest.raises(ValueError) as excinfo: + feature.validate(shaft(feature)) + message = str(excinfo.value) + assert "M20" in message and "30" in message + + +def test_an_internal_thread_enlarges_the_bore_to_the_major_for_the_section_hatch(): + feature = Thread(id="th", segment=0, designation="M10", length=20.0, internal=True) + part = RevolvedPart( + name="boss", + segments=(Segment(30.0, 40.0, 8.0),), # drilled to the ISO 6410 minor, 0.8 x 10 + features=(feature,), + ) + feature.validate(part) + (edit,) = feature.radial_edit(part) + assert edit == {"kind": "inner", "x0": 0.0, "x1": 20.0, "radius": 5.0} + + +def test_a_thread_designation_outside_iso_261_is_refused_by_the_table(): + feature = Thread(id="th", segment=2, designation="M7", length=5.0) + with pytest.raises(ValueError, match="ISO 261"): + feature.validate(shaft(feature)) + + +# -- the four not-transcribed tables, through their features ----------------- + + +def test_an_undercut_without_an_explicit_profile_is_refused_by_din_509(): + feature = Undercut(id="uc", at="step:0", form="E") + with pytest.raises(ValueError, match="DIN 509"): + feature.validate(shaft(feature)) + + +def test_an_undercut_draws_exactly_the_profile_it_is_handed(): + profile = ((0.0, 0.0), (0.5, 0.4), (2.0, 0.4), (2.5, 0.0)) + feature = Undercut(id="uc", at="step:0", form="E", profile=profile) + part = shaft(feature) + feature.validate(part) + (poly,) = feature.prims(part, "front", CTX) + assert isinstance(poly, Poly) and poly.closed is False + assert poly.points[0] == (20.0, 12.5) + assert poly.points[1] == (19.5, 12.1) + assert poly.points[-1] == (17.5, 12.5) + assert feature.removal_box(part) == (17.5, 12.1, 20.0, 12.5) + + +def test_a_retaining_groove_without_explicit_dimensions_is_refused_by_din_471(): + feature = RetainingGroove(id="rg", x=30.0, d=30.0) + with pytest.raises(ValueError, match="DIN 471"): + feature.validate(shaft(feature)) + + +def test_a_retaining_groove_with_explicit_dimensions_cuts_the_profile(): + feature = RetainingGroove(id="rg", x=30.0, d=30.0, m=1.6, d2=28.6) + part = shaft(feature) + feature.validate(part) + (edit,) = feature.radial_edit(part) + assert edit["kind"] == "outer" + assert edit["radius"] == pytest.approx(14.3) + assert (edit["x0"], edit["x1"]) == (pytest.approx(29.2), pytest.approx(30.8)) + lines = feature.prims(part, "front", CTX) + assert len(lines) == 3 + assert lines[1].p1[1] == pytest.approx(14.3) + + +def test_a_centre_hole_without_explicit_dimensions_is_refused_by_din_332(): + feature = CentreHole(id="ch", at="end", form="A", size=2.5) + with pytest.raises(ValueError, match="DIN 332"): + feature.validate(shaft(feature)) + + +def test_a_centre_hole_form_r_is_refused_outright(): + feature = CentreHole(id="ch", at="end", form="R", size=2.5, d1=2.5, d2=5.3, t=5.0) + with pytest.raises(ValueError, match="form R"): + feature.validate(shaft(feature)) + + +def test_a_centre_hole_with_explicit_dimensions_draws_a_hidden_60_degree_cone(): + feature = CentreHole(id="ch", at="end", form="A", size=2.5, d1=2.5, d2=5.3, t=5.0) + part = shaft(feature) + feature.validate(part) + prims = feature.prims(part, "front", CTX) + assert {p.role for p in prims} == {"hidden"} + cone_depth = (5.3 - 2.5) / 2.0 / math.tan(math.radians(30.0)) + assert prims[0].p1 == (75.0, 2.65) + assert prims[0].p2[0] == pytest.approx(75.0 - cone_depth) + assert prims[0].p2[1] == pytest.approx(1.25) + + +def test_an_oring_groove_without_explicit_dimensions_is_refused_by_iso_3601(): + feature = ORingGroove(id="og", x=30.0, cord=3.53) + with pytest.raises(ValueError, match="ISO 3601-2"): + feature.validate(shaft(feature)) + + +def test_an_axial_oring_groove_is_refused_rather_than_drawn_on_the_silhouette(): + feature = ORingGroove(id="og", x=30.0, cord=3.53, housing="axial", b=4.7, h=2.7) + with pytest.raises(ValueError, match="face groove"): + feature.validate(shaft(feature)) + + +def test_an_oring_groove_round_trips_because_its_field_is_not_called_kind(): + """``to_dict`` writes the feature kind under ``kind``. + + A field of the same name would overwrite the discriminator - the payload + would come back as ``{"kind": "radial", ...}`` and ``feature_from_dict`` + would refuse it - so the housing kind is carried by ``housing``. + """ + feature = ORingGroove(id="og", x=30.0, cord=3.53, b=4.7, h=2.7) + payload = feature.to_dict() + assert payload["kind"] == "oring_groove" + assert payload["housing"] == "radial" + assert feature_from_dict(payload) == feature + + +# -- holes, bores, bolt circles, teeth --------------------------------------- + + +def test_a_cross_hole_draws_two_hidden_lines_through_the_silhouette(): + feature = CrossHole(id="xh", x=30.0, diameter=6.0) + part = shaft(feature) + feature.validate(part) + prims = feature.prims(part, "front", CTX) + assert len(prims) == 2 + assert {p.role for p in prims} == {"hidden"} + assert prims[0].p1 == (27.0, -15.0) and prims[0].p2 == (27.0, 15.0) + assert prims[1].p1 == (33.0, -15.0) and prims[1].p2 == (33.0, 15.0) + + +def test_an_inclined_cross_hole_is_refused_rather_than_drawn_as_if_radial(): + feature = CrossHole(id="xh", x=30.0, diameter=6.0, angle=45.0) + with pytest.raises(ValueError, match="angle"): + feature.validate(shaft(feature)) + + +def test_an_axial_bore_edits_the_radial_profile_and_is_not_flagged_as_omitted(): + feature = AxialBore(id="ab", at="start", diameter=8.0, depth=12.0) + part = shaft(feature) + feature.validate(part) + (edit,) = feature.radial_edit(part) + assert edit == {"kind": "inner", "x0": 0.0, "x1": 12.0, "radius": 4.0} + assert feature.removal_box(part) is None + + +def test_two_axial_bores_from_the_same_end_are_refused_with_both_names(): + a = AxialBore(id="ab1", at="start", diameter=8.0, depth=12.0) + b = AxialBore(id="ab2", at="start", diameter=6.0, depth=20.0) + part = shaft(a, b) + with pytest.raises(ValueError) as excinfo: + a.validate(part) + assert "ab1" in str(excinfo.value) and "ab2" in str(excinfo.value) + + +def test_a_bolt_circle_draws_its_holes_and_its_pitch_circle_in_the_end_view(): + feature = HoleCircle(id="bc", x=10.0, pcd=36.0, count=6, diameter=5.0) + part = RevolvedPart(name="flange", segments=(Segment(20.0, 60.0),), features=(feature,)) + feature.validate(part) + prims = feature.prims(part, "side", CTX) + holes = [p for p in prims if isinstance(p, Circle) and p.role == "visible"] + pitch = [p for p in prims if isinstance(p, Circle) and p.role == "center"] + assert len(holes) == 6 and len(pitch) == 1 + assert pitch[0].radius == pytest.approx(18.0) + assert holes[0].center == (pytest.approx(18.0), pytest.approx(0.0)) + assert holes[1].center[0] == pytest.approx(18.0 * math.cos(math.radians(60.0))) + + +def test_a_bolt_circle_that_does_not_fit_the_flange_is_refused_by_name(): + feature = HoleCircle(id="bc", x=10.0, pcd=58.0, count=6, diameter=5.0) + part = RevolvedPart(name="flange", segments=(Segment(20.0, 60.0),), features=(feature,)) + with pytest.raises(ValueError, match="bc|pcd"): + feature.validate(part) + + +def test_gear_teeth_reuse_the_repository_involute_generator(): + feature = GearTeeth(id="g1", segment=0, module=2.0, teeth=20) + part = RevolvedPart(name="pinion", segments=(Segment(15.0, 44.0),), features=(feature,)) + feature.validate(part) + (poly, pitch) = feature.prims(part, "side", CTX)[:2] + expected = generate_full_gear_outline(2.0, 20, 20.0, 0.0, "RH", (0.0, 0.0)) + assert poly.points == tuple(expected) + assert poly.closed is True + assert pitch.radius == pytest.approx(20.0) + + +def test_gear_teeth_whose_tip_diameter_disagrees_with_the_segment_are_refused(): + feature = GearTeeth(id="g1", segment=0, module=2.0, teeth=20) + part = RevolvedPart(name="pinion", segments=(Segment(15.0, 40.0),), features=(feature,)) + with pytest.raises(ValueError) as excinfo: + feature.validate(part) + assert "44" in str(excinfo.value) and "40" in str(excinfo.value) + + +# -- prismatic features ------------------------------------------------------ + + +def test_a_hole_draws_a_circle_in_true_shape_and_hidden_lines_in_the_side_view(): + feature = Hole(id="h1", x=20.0, y=25.0, diameter=10.0) + part = plate(feature) + feature.validate(part) + front = feature.prims(part, "front", CTX) + circle = next(p for p in front if isinstance(p, Circle)) + assert circle.center == (20.0, 25.0) and circle.radius == 5.0 + assert sum(1 for p in front if isinstance(p, Line) and p.role == "center") == 2 + side = feature.prims(part, "side", CTX) + assert {p.role for p in side} == {"hidden"} + # a through hole runs the whole thickness, drawn from the machined face down + assert side[0].p1 == (20.0, 8.0) and side[0].p2 == (20.0, 0.0) + top = feature.prims(part, "top", CTX) + assert top[0].p1 == (15.0, 8.0) + + +def test_a_blind_hole_and_a_pocket_are_cut_from_the_same_face(): + """Both enter at v = thickness - the face the front view looks at. + + Nothing downstream can catch a disagreement here: two blind features cut + from opposite faces of the same plate are each a legal drawing on their + own, `omission()` returns None for both, and the validator sees only + hidden lines inside the material. So it has to be pinned at the source. + """ + hole = Hole(id="h1", x=20.0, y=25.0, diameter=6.0, depth=5.0) + pocket = Pocket( + id="p1", + outline=((40.0, 10.0), (70.0, 10.0), (70.0, 40.0), (40.0, 40.0)), + depth=5.0, + ) + part = plate(hole, pocket) + hole.validate(part) + pocket.validate(part) + for view in ("side", "top"): + entry = {round(p.p1[1], 9) for p in hole.prims(part, view, CTX)} | { + round(p.p2[1], 9) for p in hole.prims(part, view, CTX) + } + pocket_entry = {round(p.p1[1], 9) for p in pocket.prims(part, view, CTX)} | { + round(p.p2[1], 9) for p in pocket.prims(part, view, CTX) + } + # the same two v values: the face at 8.0 and a floor 5 mm below it + assert entry == {8.0, 3.0} + assert pocket_entry == {8.0, 3.0} + (left, right, floor) = hole.prims(part, "side", CTX) + assert left.p1 == (22.0, 8.0) and left.p2 == (22.0, 3.0) + assert right.p1 == (28.0, 8.0) and right.p2 == (28.0, 3.0) + assert floor.p1 == (22.0, 3.0) and floor.p2 == (28.0, 3.0) + + +def test_a_counterbore_is_stepped_down_from_the_machined_face_too(): + feature = Hole(id="h1", x=20.0, y=25.0, diameter=6.0, cbore_d=12.0, cbore_depth=3.0) + part = plate(feature) + feature.validate(part) + # the side view runs u = part Y, so the hole sits at u = 25 and the + # counterbore walls at 25 +/- 6. + side = feature.prims(part, "side", CTX) + cbore = [p for p in side if {19.0, 31.0} & {p.p1[0], p.p2[0]}] + assert len(cbore) == 3 + assert {round(p.p1[1], 9) for p in cbore} | {round(p.p2[1], 9) for p in cbore} == {8.0, 5.0} + # and the bore itself still runs the full thickness from that same face + bore = [p for p in side if {22.0, 28.0} & {p.p1[0], p.p2[0]}] + assert [(p.p1, p.p2) for p in bore] == [ + ((22.0, 8.0), (22.0, 0.0)), + ((28.0, 8.0), (28.0, 0.0)), + ] + + +def test_a_hole_outside_the_outline_is_refused_by_name(): + feature = Hole(id="h1", x=200.0, y=25.0, diameter=10.0) + with pytest.raises(ValueError, match="outline"): + feature.validate(plate(feature)) + + +def test_a_tapped_hole_draws_the_minor_circle_and_a_three_quarter_major_arc(): + feature = Hole(id="h1", x=20.0, y=25.0, diameter=10.0, thread="M10") + part = plate(feature) + feature.validate(part) + front = feature.prims(part, "front", CTX) + circles = [p for p in front if isinstance(p, Circle)] + arcs = [p for p in front if isinstance(p, Arc)] + assert circles[0].radius == pytest.approx(4.0) + assert len(arcs) == 1 and arcs[0].radius == pytest.approx(5.0) + + +def test_a_slot_is_an_obround_of_two_lines_and_two_semicircles(): + feature = Slot(id="s1", x=40.0, y=25.0, length=30.0, width=10.0) + part = plate(feature) + feature.validate(part) + prims = feature.prims(part, "front", CTX) + lines = [p for p in prims if isinstance(p, Line) and p.role == "visible"] + arcs = [p for p in prims if isinstance(p, Arc)] + assert len(lines) == 2 and len(arcs) == 2 + assert lines[0].p1 == (pytest.approx(30.0), pytest.approx(30.0)) + assert arcs[0].center == (pytest.approx(50.0), pytest.approx(25.0)) + assert feature.removal_box(part) == (25.0, 20.0, 55.0, 30.0) + + +def test_a_slot_narrower_than_it_is_long_is_required(): + feature = Slot(id="s1", x=40.0, y=25.0, length=5.0, width=10.0) + with pytest.raises(ValueError, match="length"): + feature.validate(plate(feature)) + + +def test_a_pocket_can_declare_itself_unhatched_in_a_longitudinal_section(): + feature = Pocket( + id="p1", + outline=((10.0, 10.0), (30.0, 10.0), (30.0, 30.0), (10.0, 30.0)), + depth=4.0, + no_section_hatch=True, + ) + part = plate(feature) + feature.validate(part) + (poly,) = feature.prims(part, "front", CTX) + assert poly.closed is True and poly.role == "visible" + assert feature.no_section_hatch is True + assert feature.removal_box(part) == (10.0, 10.0, 30.0, 30.0) + + +def test_a_pocket_deeper_than_the_plate_is_refused(): + feature = Pocket( + id="p1", + outline=((10.0, 10.0), (30.0, 10.0), (30.0, 30.0)), + depth=20.0, + ) + with pytest.raises(ValueError, match="thickness"): + feature.validate(plate(feature)) + + +def test_a_corner_fillet_replaces_a_vertex_with_two_tangent_points_and_an_arc(): + feature = CornerFillet(id="cf", vertex=1, radius=10.0) + part = plate(feature) + feature.validate(part) + edit = feature.outline_edit(part) + assert edit["vertex"] == 1 + assert edit["before"] == (pytest.approx(70.0), pytest.approx(0.0)) + assert edit["after"] == (pytest.approx(80.0), pytest.approx(10.0)) + assert edit["arc"].center == (pytest.approx(70.0), pytest.approx(10.0)) + assert edit["arc"].radius == pytest.approx(10.0) + + +def test_a_corner_chamfer_replaces_a_vertex_with_two_points_and_no_arc(): + feature = CornerChamfer(id="cc", vertex=1, size=6.0) + part = plate(feature) + feature.validate(part) + edit = feature.outline_edit(part) + assert edit["before"] == (pytest.approx(74.0), pytest.approx(0.0)) + assert edit["after"] == (pytest.approx(80.0), pytest.approx(6.0)) + assert edit["arc"] is None + + +def test_a_corner_radius_larger_than_its_edges_is_refused(): + feature = CornerFillet(id="cf", vertex=1, radius=60.0) + with pytest.raises(ValueError, match="edge"): + feature.validate(plate(feature)) + + +def test_a_bend_line_is_representation_only_and_says_so(): + feature = BendLine(id="b1", p1=(0.0, 25.0), p2=(80.0, 25.0), angle=90.0, inside_radius=2.0) + part = plate(feature) + feature.validate(part) + line, text = feature.prims(part, "front", CTX) + assert isinstance(line, Line) and line.role == "phantom" + assert isinstance(text, Text) + assert "R2" in text.text and "90" in text.text + assert feature.dims(part, "front", CTX) == () + + +def test_a_hole_pattern_expands_before_it_reaches_the_part(): + spec = { + "kind": "prismatic", + "name": "plate", + "thickness": 8.0, + "outline": [[0.0, 0.0], [80.0, 0.0], [80.0, 50.0], [0.0, 50.0]], + "features": [ + { + "kind": "hole_pattern", + "id": "hp", + "pattern": "grid", + "base": {"x": 10.0, "y": 10.0, "diameter": 6.0}, + "count": 3, + "spacing": 20.0, + "count_y": 2, + "spacing_y": 25.0, + } + ], + } + part = build_part(spec) + assert len(part.features) == 6 + assert all(isinstance(f, Hole) for f in part.features) + assert [f.id for f in part.features][:3] == ["hp_0_0", "hp_1_0", "hp_2_0"] + assert part.features[2].x == pytest.approx(50.0) + assert part.features[3].y == pytest.approx(35.0) + + +def test_a_polar_hole_pattern_places_its_holes_on_the_pitch_circle(): + pattern = HolePattern( + id="hp", + pattern="polar", + base={"x": 40.0, "y": 25.0, "diameter": 5.0}, + count=4, + pcd=30.0, + ) + holes = pattern.expand() + assert len(holes) == 4 + assert holes[0].x == pytest.approx(55.0) and holes[0].y == pytest.approx(25.0) + assert holes[1].x == pytest.approx(40.0) and holes[1].y == pytest.approx(40.0) + + +def test_a_linear_pattern_without_a_spacing_is_refused(): + pattern = HolePattern( + id="hp", + pattern="linear", + base={"x": 10.0, "y": 10.0, "diameter": 6.0}, + count=3, + ) + with pytest.raises(ValueError, match="spacing"): + pattern.expand() + + +def test_every_feature_declares_its_dimension_intents_against_its_own_id(): + features = ( + Chamfer(id="c1", at="start", size=2.0), + Keyway(id="kw", segment=1, length=25.0), + Thread(id="th", segment=2, designation="M20", length=12.0), + CrossHole(id="xh", x=30.0, diameter=6.0), + ) + part = shaft(*features) + for feature in features: + for intent in feature.dims(part, "front", CTX): + assert intent.feature == feature.id diff --git a/tests/test_mech_part.py b/tests/test_mech_part.py new file mode 100644 index 0000000..59ee8a8 --- /dev/null +++ b/tests/test_mech_part.py @@ -0,0 +1,216 @@ +"""The part model: construction, round-trip and every refusal the spec names. + +Pure arithmetic - no backend, no DXF. The feature surface is exercised through +a local stub so this module's gate (placement on the part, two features +removing the same material) is pinned before `engineering/mech/features.py` +exists. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +from engineering.mech.part import ( + PrismaticPart, + RevolvedPart, + Segment, + build_part, + inner_radius_at, + outer_radius_at, + outline_bbox, + part_length, + part_max_diameter, + part_to_dict, + point_in_outline, + segment_bounds, + validate_part, +) + +EPS = 1e-9 + +SHAFT = { + "kind": "revolved", + "name": "shaft", + "material": "steel", + "segments": [ + {"length": 20.0, "d_outer": 25.0}, + {"length": 40.0, "d_outer": 30.0, "d_inner": 10.0}, + {"length": 15.0, "d_outer": 20.0, "taper_to": 16.0}, + ], +} + +PLATE = { + "kind": "prismatic", + "name": "plate", + "material": "aluminium", + "thickness": 8.0, + "outline": [[0.0, 0.0], [80.0, 0.0], [80.0, 50.0], [0.0, 50.0]], +} + + +@dataclass(frozen=True, kw_only=True) +class StubFeature: + """The duck-typed surface `build_part` uses, and nothing else.""" + + id: str + segment: int = 0 + box: tuple[float, float, float, float] | None = None + + def validate(self, part) -> None: + if not 0 <= self.segment < len(part.segments): + raise ValueError( + f"segment {self.segment} is outside the {len(part.segments)}-segment part " + f"(segments 0-{len(part.segments) - 1})." + ) + + def removal_box(self, part): + return self.box + + def to_dict(self) -> dict: + return {"kind": "stub", "id": self.id, "segment": self.segment} + + +def test_build_part_makes_a_revolved_part_with_typed_segments(): + part = build_part(SHAFT) + assert isinstance(part, RevolvedPart) + assert part.name == "shaft" + assert part.material == "steel" + assert part.segments[1] == Segment(40.0, 30.0, 10.0, None) + assert part.segments[2].taper_to == 16.0 + assert part.features == () + + +def test_build_part_makes_a_prismatic_part_and_drops_a_repeated_closing_vertex(): + closed = dict(PLATE, outline=PLATE["outline"] + [[0.0, 0.0]]) + part = build_part(closed) + assert isinstance(part, PrismaticPart) + assert len(part.outline) == 4 + assert part.thickness == 8.0 + assert outline_bbox(part) == (0.0, 0.0, 80.0, 50.0) + + +@pytest.mark.parametrize("spec", [SHAFT, PLATE]) +def test_part_to_dict_round_trips_through_build_part(spec): + part = build_part(spec) + assert build_part(part_to_dict(part)) == part + + +def test_geometry_helpers_report_the_real_numbers(): + part = build_part(SHAFT) + assert part_length(part) == 75.0 + assert part_max_diameter(part) == 30.0 + assert segment_bounds(part) == ((0.0, 20.0), (20.0, 60.0), (60.0, 75.0)) + assert outer_radius_at(part, 10.0) == 12.5 + assert inner_radius_at(part, 30.0) == 5.0 + assert inner_radius_at(part, 10.0) == 0.0 + # the taper runs 20 -> 16 mm over the last 15 mm, so its mid-point is 18 mm + assert outer_radius_at(part, 67.5) == pytest.approx(9.0, abs=EPS) + plate = build_part(PLATE) + assert part_length(plate) == 80.0 + assert point_in_outline(plate, 40.0, 25.0) is True + assert point_in_outline(plate, 90.0, 25.0) is False + + +@pytest.mark.parametrize("bad", [float("inf"), float("nan"), 0.0, -5.0]) +def test_a_non_finite_or_non_positive_number_is_refused_by_path(bad): + spec = dict(SHAFT, segments=[dict(s) for s in SHAFT["segments"]]) + spec["segments"][2]["d_outer"] = bad + with pytest.raises(ValueError) as excinfo: + build_part(spec) + assert "segments[2].d_outer" in str(excinfo.value) + + +def test_a_bore_that_is_not_smaller_than_the_outside_is_refused(): + spec = dict(SHAFT, segments=[dict(s) for s in SHAFT["segments"]]) + spec["segments"][1]["d_inner"] = 30.0 + with pytest.raises(ValueError) as excinfo: + build_part(spec) + assert "segments[1].d_inner" in str(excinfo.value) + assert "two segments" in str(excinfo.value) + + +def test_an_outline_with_fewer_than_three_distinct_vertices_is_refused(): + spec = dict(PLATE, outline=[[0.0, 0.0], [10.0, 0.0], [10.0, 0.0]]) + with pytest.raises(ValueError, match="at least 3 distinct vertices"): + build_part(spec) + + +def test_a_self_intersecting_outline_is_refused(): + spec = dict(PLATE, outline=[[0.0, 0.0], [10.0, 10.0], [10.0, 0.0], [0.0, 10.0]]) + with pytest.raises(ValueError, match="crosses itself"): + build_part(spec) + + +def test_an_unknown_material_is_refused(): + with pytest.raises(ValueError, match="material"): + build_part(dict(SHAFT, material="unobtainium")) + + +def test_a_feature_off_the_part_is_refused_by_its_own_name(): + part = RevolvedPart( + name="shaft", + segments=(Segment(20.0, 25.0), Segment(40.0, 30.0), Segment(15.0, 20.0)), + features=(StubFeature(id="keyway_1", segment=5),), + ) + with pytest.raises(ValueError) as excinfo: + validate_part(part) + message = str(excinfo.value) + assert "keyway_1" in message + assert "segment 5" in message + assert "segments 0-2" in message + + +def test_two_features_removing_the_same_material_are_refused_with_both_names(): + part = RevolvedPart( + name="shaft", + segments=(Segment(60.0, 30.0),), + features=( + StubFeature(id="groove_a", box=(10.0, 12.0, 14.0, 15.0)), + StubFeature(id="groove_b", box=(12.0, 13.0, 16.0, 15.0)), + ), + ) + with pytest.raises(ValueError) as excinfo: + validate_part(part) + message = str(excinfo.value) + assert "groove_a" in message and "groove_b" in message + assert "same material" in message + + +def test_features_that_only_touch_are_not_an_overlap(): + part = RevolvedPart( + name="shaft", + segments=(Segment(60.0, 30.0),), + features=( + StubFeature(id="groove_a", box=(10.0, 12.0, 14.0, 15.0)), + StubFeature(id="groove_b", box=(14.0, 12.0, 18.0, 15.0)), + ), + ) + validate_part(part) + + +def test_an_unknown_kind_is_refused(): + with pytest.raises(ValueError, match="kind"): + build_part(dict(SHAFT, kind="lofted")) + + +def test_an_unknown_segment_key_is_refused_by_name(): + spec = dict(SHAFT, segments=[{"length": 10.0, "d_outer": 20.0, "diameter": 20.0}]) + with pytest.raises(ValueError) as excinfo: + build_part(spec) + assert "segments[0]" in str(excinfo.value) + assert "diameter" in str(excinfo.value) + + +def test_a_duplicate_feature_id_is_refused(): + """The only test here that reaches `feature_from_dict` (Task 3).""" + spec = dict( + SHAFT, + features=[ + {"kind": "chamfer", "id": "c1", "at": "start", "size": 2.0}, + {"kind": "chamfer", "id": "c1", "at": "end", "size": 2.0}, + ], + ) + with pytest.raises(ValueError, match="duplicate feature id"): + build_part(spec) diff --git a/tests/test_mech_sections.py b/tests/test_mech_sections.py new file mode 100644 index 0000000..7c40863 --- /dev/null +++ b/tests/test_mech_sections.py @@ -0,0 +1,473 @@ +"""Sections, cut faces, material hatch and detail views. + +The cut face and the silhouette come out of the same radial profile, so the +tests assert that the hatched loop closes on the real geometry - including the +ISO 6410 rule that an internal thread's hatch runs to the major diameter, and +the ISO 128-3 rule that a rib cut longitudinally is not hatched. +""" + +from __future__ import annotations + +import math + +import pytest + +from engineering.mech.features import ( + AxialBore, + Chamfer, + CrossHole, + Hole, + Keyway, + Pocket, + RetainingGroove, + Slot, + Thread, +) +from engineering.mech.part import PrismaticPart, RevolvedPart, Segment +from engineering.mech.primitives import Arc, Circle, HatchArea, Line, Text +from engineering.mech.views import ( + CIRCLE_SEGMENTS, + SECTION_PLANE, + SECTION_STYLES, + VIEW_KINDS, + build_view, + cut_loops, + detail_marker_prims, + normalise_plane, + radial_profile, +) + +EPS = 1e-9 + + +def shaft(*features) -> RevolvedPart: + return RevolvedPart( + name="shaft", + segments=(Segment(20.0, 25.0), Segment(40.0, 30.0), Segment(15.0, 20.0)), + features=tuple(features), + ) + + +def plate(*features) -> PrismaticPart: + return PrismaticPart( + name="plate", + outline=((0.0, 0.0), (80.0, 0.0), (80.0, 50.0), (0.0, 50.0)), + thickness=8.0, + features=tuple(features), + ) + + +def axis_plane(**kw) -> dict: + return normalise_plane({"p1": (0.0, 0.0), "p2": (100.0, 0.0), **kw}) + + +def polygon_area(points) -> float: + total = 0.0 + count = len(points) + for i in range(count): + x1, y1 = points[i] + x2, y2 = points[(i + 1) % count] + total += x1 * y2 - x2 * y1 + return abs(total) / 2.0 + + +def test_the_engine_now_declares_five_kinds_and_four_section_styles(): + assert VIEW_KINDS == ("front", "side", "top", "section", "detail") + assert SECTION_STYLES == ("full", "half", "offset", "revolved") + assert set(SECTION_PLANE) == {"p1", "p2", "label", "direction", "style"} + + +def test_normalise_plane_fills_the_pinned_shape_and_refuses_a_zero_length_plane(): + plane = normalise_plane({"p1": (0.0, 0.0), "p2": (10.0, 0.0)}) + assert plane["label"] == "A" + assert plane["direction"] == (0.0, -1.0) + assert plane["style"] == "full" + with pytest.raises(ValueError, match="zero length"): + normalise_plane({"p1": (1.0, 1.0), "p2": (1.0, 1.0)}) + + +# -- revolved longitudinal sections ------------------------------------------ + + +def test_a_solid_shaft_full_section_hatches_both_halves_to_the_axis(): + loops = cut_loops(shaft(), axis_plane(), style="full") + assert len(loops) == 2 + assert all(hatched for _, hatched in loops) + upper, lower = (points for points, _ in loops) + assert max(y for _, y in upper) == 15.0 + assert min(y for _, y in upper) == 0.0 + assert min(y for _, y in lower) == -15.0 + # the cut face is the real cross-section: 20x12.5 + 40x15 + 15x10 per half + assert polygon_area(upper) == pytest.approx(20 * 12.5 + 40 * 15.0 + 15 * 10.0) + + +def test_a_bore_is_not_hatched_because_the_loop_closes_on_it(): + part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),)) + (upper, _), (lower, _) = cut_loops(part, axis_plane(), style="full") + assert polygon_area(upper) == pytest.approx(30.0 * (20.0 - 10.0)) + assert min(y for _, y in upper) == 10.0 + assert max(y for _, y in lower) == -10.0 + + +def test_a_half_section_hatches_one_half_only(): + loops = cut_loops(shaft(), axis_plane(style="half"), style="half") + assert len(loops) == 1 + assert min(y for _, y in loops[0][0]) == 0.0 + + +def test_iso6410_an_internal_thread_hatch_runs_to_the_major_diameter(): + thread = Thread(id="th", segment=0, designation="M20", length=25.0, internal=True) + # drilled to the ISO 6410 minor (0.8 x 20 = 16 mm); the hatch must stop at + # the 20 mm major, not at the drilled 16. + part = RevolvedPart(name="boss", segments=(Segment(30.0, 50.0, 16.0),), features=(thread,)) + bands = radial_profile(part) + threaded = [b for b in bands if b.x1 <= 25.0 + EPS] + assert {round(b.r_in, 6) for b in threaded} == {10.0} + unthreaded = [b for b in bands if b.x0 >= 25.0 - EPS] + assert {round(b.r_in, 6) for b in unthreaded} == {8.0} + (upper, _), _ = cut_loops(part, axis_plane(), style="full") + # The cut face closes on the major over the threaded span - and on the real + # drilled bore over the 5 mm that is drilled but not threaded. Asserting the + # minimum over the *whole* loop would be asserting that the drill does not + # exist beyond the thread, which is a different (and wrong) drawing. + assert min(y for x, y in upper if x < 25.0 - EPS) == pytest.approx(10.0) + assert min(y for x, y in upper if x > 25.0 + EPS) == pytest.approx(8.0) + + +def test_a_section_draws_its_own_cut_boundary_continuous_and_never_dashed(): + """ISO 128-50: a surface lying in the cutting plane is bounded by a + continuous wide line, and ISO 128-3 leaves hidden detail out of a sectional + view. The bore wall is the case that catches a section built out of the + un-cut silhouette: there the material hides the bore, so the silhouette + draws it dashed - but y = +-10 is the hatch loop's own boundary, and a + dashed line drawn on the hatch boundary means the outline and the hatched + area disagree at identical coordinates. + """ + part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),)) + view = build_view(part, "section", plane=axis_plane(), style="full") + assert "hidden" not in {getattr(prim, "role", None) for prim in view.prims} + bore = [ + line + for line in view.prims + if isinstance(line, Line) + and abs(abs(line.p1[1]) - 10.0) < EPS + and abs(abs(line.p2[1]) - 10.0) < EPS + ] + assert len(bore) == 2 + assert {line.role for line in bore} == {"visible"} + (upper, _), (lower, _) = cut_loops(part, axis_plane(), style="full") + assert min(y for _, y in upper) == pytest.approx(10.0) + assert max(y for _, y in lower) == pytest.approx(-10.0) + + +def test_a_half_section_keeps_its_dashed_bore_only_on_the_half_it_does_not_cut(): + part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),)) + view = build_view(part, "section", plane=axis_plane(style="half"), style="half") + hidden = [prim for prim in view.prims if getattr(prim, "role", None) == "hidden"] + assert hidden, "the un-sectioned half still shows its bore, dashed" + assert all(line.p1[1] < 0.0 and line.p2[1] < 0.0 for line in hidden) + assert all(abs(abs(line.p1[1]) - 10.0) < EPS for line in hidden) + # the cut half carries the hatch, and nothing dashed on it + ((upper, _),) = cut_loops(part, axis_plane(style="half"), style="half") + assert min(y for _, y in upper) == pytest.approx(10.0) + + +def test_a_chamfer_is_cut_out_of_the_hatched_area_exactly_where_it_is_drawn(): + """The loop is the emitted outline flattened, so a corner substitution + reaches the cut face too: a 2 mm 45 deg chamfer takes a 2 mm2 triangle off + the 1000 mm2 half face, and a loop built from the raw profile would hatch + over the chamfer it just drew.""" + (upper, _), _lower = cut_loops( + shaft(Chamfer(id="c1", at="start", size=2.0)), axis_plane(), style="full" + ) + assert polygon_area(upper) == pytest.approx(20 * 12.5 + 40 * 15.0 + 15 * 10.0 - 2.0) + + +def test_the_section_view_carries_a_hatch_area_per_loop_with_the_parts_material(): + view = build_view(shaft(), "section", plane=axis_plane(), style="full") + hatches = [p for p in view.prims if isinstance(p, HatchArea)] + assert len(hatches) == 2 + assert {h.material for h in hatches} == {"steel"} + assert view.label == "A-A" + + +def test_an_unknown_material_is_refused_before_a_section_is_built(): + part = RevolvedPart(name="x", segments=(Segment(10.0, 20.0),), material="unobtainium") + with pytest.raises(ValueError, match="unobtainium"): + build_view(part, "section", plane=axis_plane()) + + +def test_an_offset_plane_on_a_revolved_part_is_refused_with_the_alternative(): + with pytest.raises(ValueError) as excinfo: + build_view(shaft(), "section", plane=axis_plane(style="offset"), style="offset") + assert "style='full'" in str(excinfo.value) + + +# -- revolved transverse (cross) sections ------------------------------------ + + +def test_a_transverse_section_hatches_the_annulus_at_that_station(): + part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),)) + plane = normalise_plane({"p1": (15.0, -30.0), "p2": (15.0, 30.0), "label": "B"}) + view = build_view(part, "section", plane=plane, style="full") + hatches = [p for p in view.prims if isinstance(p, HatchArea)] + assert len(hatches) == 1 + outer, island = hatches[0].loops + assert len(outer) == CIRCLE_SEGMENTS and len(island) == CIRCLE_SEGMENTS + # An inscribed regular CIRCLE_SEGMENTS-gon is exactly + # 1 - (n / 2pi) sin(2pi / n) = 2.0307e-4 under the circle, so the tolerance + # is the flattening the constant declares and nothing looser. + flattening = 1.0 - (CIRCLE_SEGMENTS / (2.0 * math.pi)) * math.sin( + 2.0 * math.pi / CIRCLE_SEGMENTS + ) + assert flattening == pytest.approx(2.0307e-4, rel=1e-3) + assert polygon_area(outer) == pytest.approx(math.pi * 20.0**2, rel=2.1e-4) + assert polygon_area(island) == pytest.approx(math.pi * 10.0**2, rel=2.1e-4) + assert view.label == "B-B" + circles = [p for p in view.prims if isinstance(p, Circle)] + assert {round(c.radius, 6) for c in circles} == {20.0, 10.0} + + +def test_a_revolved_style_cross_section_draws_its_boundary_as_phantom(): + part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),)) + plane = normalise_plane({"p1": (15.0, -30.0), "p2": (15.0, 30.0)}) + view = build_view(part, "section", plane=plane, style="revolved") + circles = [p for p in view.prims if isinstance(p, Circle)] + assert {c.role for c in circles} == {"phantom"} + + +def keyed_shaft(*features) -> RevolvedPart: + """A plain 40 mm shaft; DIN 6885-1 gives its keyway 12 mm x 5 mm deep.""" + return RevolvedPart(name="keyed", segments=(Segment(60.0, 40.0),), features=tuple(features)) + + +def test_a_cross_section_through_a_keyway_cuts_the_notch_out_of_the_disc(): + """The commonest mechanical cross section there is. The plane at x=20 is + squarely inside the slot, so the face is a notched disc - not the plain + hatched circle a face built from the segment diameter alone produces.""" + keyway = Keyway(id="kw", segment=0, length=30.0, offset=5.0) + plane = normalise_plane({"p1": (20.0, -40.0), "p2": (20.0, 40.0)}) + view = build_view(keyed_shaft(keyway), "section", plane=plane, style="full") + assert view.omitted == () + arcs = [prim for prim in view.prims if isinstance(prim, Arc)] + assert len(arcs) == 1 and arcs[0].radius == pytest.approx(20.0) + assert not [prim for prim in view.prims if isinstance(prim, Circle)] + + radius, half, floor = 20.0, 6.0, 15.0 + top = math.sqrt(radius * radius - half * half) + # the disc, less the notch rectangle, less the cap the slot mouth removes + cap = radius * radius * math.acos(top / radius) - top * half + expected = math.pi * radius * radius - 2.0 * half * (top - floor) - cap + (hatch,) = [prim for prim in view.prims if isinstance(prim, HatchArea)] + assert polygon_area(hatch.loops[0]) == pytest.approx(expected, rel=2.1e-4) + # and it really is a notch: the whole disc is 56 mm2 larger + assert polygon_area(hatch.loops[0]) < math.pi * radius * radius - 50.0 + # the arc the face is bounded by is the arc the end view draws + assert arcs[0].start_deg == pytest.approx(math.degrees(math.atan2(top, -half)) % 360.0) + assert arcs[0].end_deg == pytest.approx(math.degrees(math.atan2(top, half)) % 360.0) + + +def test_a_cross_section_reports_the_feature_it_cannot_project(): + """The honesty rule, on the one view where silence is indistinguishable + from a correct drawing: the face is hatched either way.""" + cross = CrossHole(id="xh", x=20.0, diameter=6.0) + plane = normalise_plane({"p1": (20.0, -40.0), "p2": (20.0, 40.0)}) + view = build_view(keyed_shaft(cross), "section", plane=plane, style="full") + assert [entry["feature"] for entry in view.omitted] == ["xh"] + assert "transverse" in view.omitted[0]["reason"] + + +def test_a_feature_the_transverse_plane_misses_is_reported_as_off_the_plane(): + keyway = Keyway(id="kw", segment=0, length=30.0, offset=5.0) + plane = normalise_plane({"p1": (50.0, -40.0), "p2": (50.0, 40.0)}) + view = build_view(keyed_shaft(keyway), "section", plane=plane, style="full") + assert [entry["feature"] for entry in view.omitted] == ["kw"] + assert "not on the cutting plane" in view.omitted[0]["reason"] + + +def test_a_transverse_face_is_read_off_the_radial_profile_not_the_segment(): + """A groove is in the profile, so it is in the cross section - at its own + reduced diameter, and without being reported missing.""" + groove = RetainingGroove(id="rg", x=30.0, d=40.0, m=1.85, d2=37.5) + plane = normalise_plane({"p1": (30.0, -40.0), "p2": (30.0, 40.0)}) + view = build_view(keyed_shaft(groove), "section", plane=plane, style="full") + assert view.omitted == () + circles = [prim for prim in view.prims if isinstance(prim, Circle)] + assert {round(circle.radius, 6) for circle in circles} == {37.5 / 2.0} + + +def test_a_half_transverse_section_is_refused_with_its_reason(): + part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),)) + plane = normalise_plane({"p1": (15.0, -30.0), "p2": (15.0, 30.0)}) + with pytest.raises(ValueError, match="half section"): + build_view(part, "section", plane=plane, style="half") + + +# -- prismatic sections ------------------------------------------------------ + + +def test_a_prismatic_full_section_is_the_chord_times_the_thickness(): + plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0), "label": "C"}) + view = build_view(plate(), "section", plane=plane, style="full") + (hatch,) = [p for p in view.prims if isinstance(p, HatchArea)] + assert polygon_area(hatch.loops[0]) == pytest.approx(80.0 * 8.0) + assert view.bbox == (0.0, 0.0, 80.0, 8.0) + + +def test_a_through_hole_splits_the_cut_face_in_two(): + hole = Hole(id="h1", x=40.0, y=25.0, diameter=10.0) + plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)}) + view = build_view(plate(hole), "section", plane=plane, style="full") + hatches = [p for p in view.prims if isinstance(p, HatchArea)] + assert len(hatches) == 2 + total = sum(polygon_area(h.loops[0]) for h in hatches) + assert total == pytest.approx((80.0 - 10.0) * 8.0) + + +@pytest.mark.parametrize("offset", [0.0, 4.0, 4.9]) +def test_a_hole_is_cut_on_its_chord_and_not_on_the_box_around_it(offset): + """The gap a hole punches in a cut face is the chord the plane actually + cuts, and it shrinks to nothing as the plane leaves the centre. Removing + the hole by its bounding box reports the full 10 mm diameter at every + offset: 32 mm2 of missing cut face at 4 mm off centre and 64 mm2 at 4.9, + with an 8 mm gap drawn through solid metal where the plane merely grazes + the hole.""" + hole = Hole(id="h1", x=40.0, y=25.0, diameter=10.0) + plane = normalise_plane({"p1": (-10.0, 25.0 + offset), "p2": (100.0, 25.0 + offset)}) + view = build_view(plate(hole), "section", plane=plane, style="full") + chord = 2.0 * math.sqrt(5.0**2 - offset**2) + total = sum(polygon_area(prim.loops[0]) for prim in view.prims if isinstance(prim, HatchArea)) + assert total == pytest.approx((80.0 - chord) * 8.0) + if offset > EPS: + assert total != pytest.approx((80.0 - 10.0) * 8.0) + + +def test_a_plane_tangent_to_a_hole_leaves_the_cut_face_whole(): + hole = Hole(id="h1", x=40.0, y=25.0, diameter=10.0) + plane = normalise_plane({"p1": (-10.0, 30.0), "p2": (100.0, 30.0)}) + view = build_view(plate(hole), "section", plane=plane, style="full") + hatches = [prim for prim in view.prims if isinstance(prim, HatchArea)] + assert len(hatches) == 1 + assert polygon_area(hatches[0].loops[0]) == pytest.approx(80.0 * 8.0) + + +def test_an_angled_slot_is_cut_on_its_obround_and_not_on_the_box_around_it(): + """A 45 degree slot's bounding box is 24.14 mm across the cutting line; the + slot itself is 14.14. The box would erase 10 mm of material the slot never + touches.""" + slot = Slot(id="s1", x=40.0, y=25.0, length=30.0, width=10.0, angle=45.0) + plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)}) + view = build_view(plate(slot), "section", plane=plane, style="full") + total = sum(polygon_area(prim.loops[0]) for prim in view.prims if isinstance(prim, HatchArea)) + assert total == pytest.approx((80.0 - 10.0 * math.sqrt(2.0)) * 8.0) + + +def test_a_pocket_reduces_the_cut_face_to_the_remaining_thickness(): + pocket = Pocket( + id="p1", + outline=((20.0, 15.0), (60.0, 15.0), (60.0, 35.0), (20.0, 35.0)), + depth=3.0, + ) + plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)}) + view = build_view(plate(pocket), "section", plane=plane, style="full") + hatches = [p for p in view.prims if isinstance(p, HatchArea)] + total = sum(polygon_area(h.loops[0]) for h in hatches) + assert total == pytest.approx(40.0 * 8.0 + 40.0 * 5.0) + + +def test_iso128_3_a_rib_cut_longitudinally_is_drawn_but_not_hatched(): + rib = Pocket( + id="rib", + outline=((20.0, 15.0), (60.0, 15.0), (60.0, 35.0), (20.0, 35.0)), + depth=3.0, + no_section_hatch=True, + ) + plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)}) + loops = cut_loops(plate(rib), plane, style="full") + hatched = [points for points, flag in loops if flag] + unhatched = [points for points, flag in loops if not flag] + assert len(unhatched) == 1 + assert polygon_area(unhatched[0]) == pytest.approx(40.0 * 5.0) + assert sum(polygon_area(p) for p in hatched) == pytest.approx(40.0 * 8.0) + view = build_view(plate(rib), "section", plane=plane, style="full") + assert len([p for p in view.prims if isinstance(p, HatchArea)]) == 2 + + +def test_an_offset_prismatic_section_accumulates_along_the_dog_leg(): + plane = normalise_plane( + { + "p1": (-10.0, 15.0), + "p2": (100.0, 35.0), + "via": [[30.0, 15.0], [30.0, 35.0]], + "style": "offset", + } + ) + view = build_view(plate(), "section", plane=plane, style="offset") + hatches = [p for p in view.prims if isinstance(p, HatchArea)] + total = sum(polygon_area(h.loops[0]) for h in hatches) + assert total == pytest.approx(80.0 * 8.0) + + +def test_a_revolved_style_section_of_a_prismatic_part_is_refused(): + plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)}) + with pytest.raises(ValueError, match="revolved section"): + build_view(plate(), "section", plane=plane, style="revolved") + + +def test_a_plane_that_misses_the_part_is_refused_rather_than_hatched_empty(): + plane = normalise_plane({"p1": (-10.0, 200.0), "p2": (100.0, 200.0)}) + with pytest.raises(ValueError, match="does not cut"): + build_view(plate(), "section", plane=plane, style="full") + + +# -- detail views ------------------------------------------------------------ + + +def test_a_detail_scales_the_region_and_labels_both_places(): + detail = {"center": (20.0, 12.5), "radius": 8.0, "scale": 5.0, "label": "D"} + view = build_view(shaft(), "detail", detail=detail) + assert view.kind == "detail" + assert view.scale == 5.0 + assert view.label == "D (5:1)" + width = view.bbox[2] - view.bbox[0] + # straight geometry is trimmed to the circle, so the enlargement can never + # be wider than the circle itself at the detail scale + assert width <= 2.0 * 8.0 * 5.0 + 1e-6 + assert width > 8.0 * 5.0 + marker = detail_marker_prims(detail) + circle = next(p for p in marker if isinstance(p, Circle)) + text = next(p for p in marker if isinstance(p, Text)) + assert circle.center == (20.0, 12.5) and circle.radius == 8.0 + assert text.text == "D" + + +def test_a_detail_reports_the_features_that_fall_outside_its_circle(): + from engineering.mech.features import RetainingGroove + + groove = RetainingGroove(id="rg", x=70.0, d=20.0, m=1.6, d2=18.6) + detail = {"center": (5.0, 12.5), "radius": 6.0, "scale": 5.0, "label": "D"} + view = build_view(shaft(groove), "detail", detail=detail) + assert any(entry["feature"] == "rg" for entry in view.omitted) + assert "detail circle D" in next( + entry["reason"] for entry in view.omitted if entry["feature"] == "rg" + ) + + +def test_a_detail_without_a_radius_or_with_a_bad_scale_is_refused(): + with pytest.raises(ValueError, match="radius"): + build_view(shaft(), "detail", detail={"center": (20.0, 12.5), "scale": 5.0}) + with pytest.raises(ValueError, match="scale"): + build_view(shaft(), "detail", detail={"center": (20.0, 12.5), "radius": 8.0, "scale": 0.0}) + + +def test_a_section_without_a_plane_is_refused_by_name(): + with pytest.raises(ValueError, match="plane"): + build_view(shaft(), "section") + + +def test_an_axial_bore_shows_its_flat_bottom_in_the_section(): + bore = AxialBore(id="ab", at="start", diameter=8.0, depth=30.0) + view = build_view(shaft(bore), "section", plane=axis_plane(), style="full") + (hatch_upper, _hatch_lower) = [p for p in view.prims if isinstance(p, HatchArea)] + assert min(y for _, y in hatch_upper.loops[0]) == 0.0 + assert 4.0 in {round(y, 6) for _, y in hatch_upper.loops[0]} diff --git a/tests/test_mech_standards.py b/tests/test_mech_standards.py index 84ec877..d107e6a 100644 --- a/tests/test_mech_standards.py +++ b/tests/test_mech_standards.py @@ -89,6 +89,19 @@ def test_a_source_that_does_not_name_its_table_is_refused(): register("TEST 4", {1: {"a": 1}}, Coverage(1.0, 1.0), "made up") +def test_a_table_may_register_its_structure_before_its_rows_are_transcribed(): + """DIN 509, DIN 471/472, DIN 332-1 and ISO 3601-2 ship exactly this way.""" + register("TEST 5", {}, Coverage(0.0, 0.0), SOURCE) + assert "TEST 5" in standards() + with pytest.raises(ValueError, match="TEST 5"): + lookup("TEST 5", 12) + + +def test_a_rows_argument_that_is_not_a_dict_is_refused(): + with pytest.raises(ValueError, match="rows dict"): + register("TEST 6", [{"d": 1.0}], Coverage(1.0, 1.0), SOURCE) + + def test_registering_the_same_standard_twice_is_refused(): with pytest.raises(ValueError, match="already registered"): register("TEST 1", {10: {"d": 10.0}}, Coverage(10.0, 10.0), SOURCE) diff --git a/tests/test_mech_standards_coverage.py b/tests/test_mech_standards_coverage.py new file mode 100644 index 0000000..f5e95f1 --- /dev/null +++ b/tests/test_mech_standards_coverage.py @@ -0,0 +1,160 @@ +"""The four tables this build does NOT transcribe, and how they refuse. + +The track's table rule is absolute: a row that cannot be sourced against the +standard is not shipped, because a wrong relief depth or a wrong groove +diameter is a wrong workshop drawing, while a narrow table is merely narrow. +DIN 509, DIN 471/472, DIN 332-1 and ISO 3601-2 therefore ship with their +structure, their SOURCE and an empty row set, and every lookup is refused by +name and says what to transcribe. + +These tests are the gate that keeps that honest in both directions: the +refusal must name the standard and the transcription route, and the shape gate +must fail the moment somebody adds a row that is missing a column the drawing +code reads. +""" + +from __future__ import annotations + +import pytest + +from engineering.mech.standards import standards +from engineering.mech.standards.centres import ( + CENTRE_ROW_KEYS, + ROWS_A, + ROWS_B, + centre_hole_dims, +) +from engineering.mech.standards.centres import ( + SOURCE as SOURCE_CENTRES, +) +from engineering.mech.standards.orings import ( + ORING_ROW_KEYS, + oring_groove_dims, +) +from engineering.mech.standards.orings import ( + ROWS as ROWS_ORING, +) +from engineering.mech.standards.orings import ( + SOURCE as SOURCE_ORING, +) +from engineering.mech.standards.rings import ( + RING_ROW_KEYS, + ROWS_471, + ROWS_472, + ring_groove_dims, +) +from engineering.mech.standards.rings import ( + SOURCE as SOURCE_RINGS, +) +from engineering.mech.standards.undercuts import ( + ROWS_E, + ROWS_F, + UNDERCUT_ROW_KEYS, + undercut_profile, +) +from engineering.mech.standards.undercuts import ( + SOURCE as SOURCE_UNDERCUTS, +) + + +def test_every_table_is_registered_so_a_later_transcription_is_a_data_edit(): + registered = standards() + for name in ( + "DIN 509-E", + "DIN 509-F", + "DIN 471", + "DIN 472", + "DIN 332-A", + "DIN 332-B", + "ISO 3601-2", + ): + assert name in registered + + +@pytest.mark.parametrize( + ("source", "standard"), + [ + (SOURCE_UNDERCUTS, "DIN 509"), + (SOURCE_RINGS, "DIN 471"), + (SOURCE_CENTRES, "DIN 332"), + (SOURCE_ORING, "ISO 3601-2"), + ], +) +def test_every_source_names_its_standard_and_edition(source, standard): + assert standard in source + assert any(ch.isdigit() for ch in source.split(":")[-1]) + + +def test_no_row_is_shipped_in_this_build(): + assert ROWS_E == {} and ROWS_F == {} + assert ROWS_471 == {} and ROWS_472 == {} + assert ROWS_A == {} and ROWS_B == {} + assert ROWS_ORING == {} + + +def test_the_undercut_lookup_is_refused_by_name_and_says_what_to_transcribe(): + with pytest.raises(ValueError) as excinfo: + undercut_profile("E", 40.0) + message = str(excinfo.value) + assert "DIN 509" in message + assert "form E" in message + assert "40" in message + assert "undercuts.py" in message + + +def test_an_unknown_undercut_form_is_refused_before_the_coverage_refusal(): + with pytest.raises(ValueError, match="forms E and F"): + undercut_profile("G", 40.0) + + +def test_the_ring_groove_lookup_is_refused_by_name_for_shaft_and_bore(): + for standard in ("DIN 471", "DIN 472"): + with pytest.raises(ValueError) as excinfo: + ring_groove_dims(standard, 30.0) + assert standard in str(excinfo.value) + assert "rings.py" in str(excinfo.value) + with pytest.raises(ValueError, match="DIN 471 or DIN 472"): + ring_groove_dims("DIN 6799", 30.0) + + +def test_the_centre_hole_lookup_is_refused_by_name_and_form_r_is_refused_outright(): + with pytest.raises(ValueError) as excinfo: + centre_hole_dims("A", 2.5) + assert "DIN 332" in str(excinfo.value) + assert "centres.py" in str(excinfo.value) + with pytest.raises(ValueError) as excinfo: + centre_hole_dims("R", 2.5) + assert "form R" in str(excinfo.value) + + +def test_the_oring_lookup_is_refused_by_name(): + with pytest.raises(ValueError) as excinfo: + oring_groove_dims(3.53) + assert "ISO 3601-2" in str(excinfo.value) + assert "orings.py" in str(excinfo.value) + + +def test_the_row_key_contracts_are_the_ones_the_drawing_code_reads(): + assert UNDERCUT_ROW_KEYS == ("profile",) + assert RING_ROW_KEYS == ("m", "d2") + assert CENTRE_ROW_KEYS == ("d1", "d2", "d3", "t") + assert ORING_ROW_KEYS == ("b", "h") + + +@pytest.mark.parametrize( + ("rows", "keys"), + [ + (ROWS_E, ("profile",)), + (ROWS_F, ("profile",)), + (ROWS_471, ("m", "d2")), + (ROWS_472, ("m", "d2")), + (ROWS_A, ("d1", "d2", "t")), + (ROWS_B, ("d1", "d2", "d3", "t")), + (ROWS_ORING, ("b", "h")), + ], +) +def test_any_row_that_is_ever_added_must_carry_every_key_the_drawing_code_reads(rows, keys): + """Empty today; this gate fires the moment a row lands without a column.""" + for size, row in rows.items(): + missing = [key for key in keys if key not in row] + assert not missing, f"row {size} is missing {missing}" diff --git a/tests/test_mech_threads.py b/tests/test_mech_threads.py new file mode 100644 index 0000000..e42c761 --- /dev/null +++ b/tests/test_mech_threads.py @@ -0,0 +1,187 @@ +"""ISO 261 pitches, ISO 724 basic diameters and the ISO 6410 representation. + +Provenance: every asserted pitch is a value the module transcribed from +ISO 261:2022; every asserted basic diameter is a published ISO 724 value that +the ISO 68-1 formula has to reproduce exactly. The coverage refusals are +asserted at both ends, because a size outside the transcribed table must be +refused by name and never interpolated. +""" + +from __future__ import annotations + +import math + +import pytest + +from engineering.mech.primitives import Arc, Line +from engineering.mech.standards import lookup +from engineering.mech.standards.threads import ( + COARSE, + DIAMETERS, + FINE, + MINOR_RATIO, + basic_minor_diameter, + basic_pitch_diameter, + parse_designation, + pitch_for, + thread_axial_prims, + thread_end_prims, +) + + +@pytest.mark.parametrize( + ("d", "pitch"), + [ + (1.6, 0.35), + (2.0, 0.40), + (3.0, 0.50), + (5.0, 0.80), + (6.0, 1.00), + (8.0, 1.25), + (10.0, 1.50), + (12.0, 1.75), + (14.0, 2.00), + (16.0, 2.00), + (20.0, 2.50), + (24.0, 3.00), + (30.0, 3.50), + (33.0, 3.50), + (36.0, 4.00), + (42.0, 4.50), + (48.0, 5.00), + (56.0, 5.50), + (64.0, 6.00), + ], +) +def test_iso261_coarse_pitches_are_the_transcribed_values(d, pitch): + assert COARSE[d] == pitch + + +def test_the_transcribed_diameters_are_first_choice_plus_six_second_choice(): + assert DIAMETERS[0] == 1.6 + assert DIAMETERS[-1] == 64.0 + assert {14.0, 18.0, 22.0, 27.0, 33.0, 39.0} <= set(DIAMETERS) + # third-choice diameters are deliberately absent + assert 7.0 not in DIAMETERS + assert 9.0 not in DIAMETERS + assert 3.5 not in DIAMETERS + + +@pytest.mark.parametrize( + ("d", "pitches"), + [ + (8.0, (1.0, 0.75)), + (10.0, (1.25, 1.0, 0.75)), + (12.0, (1.5, 1.25, 1.0)), + (16.0, (1.5, 1.0)), + (20.0, (2.0, 1.5, 1.0)), + (24.0, (2.0, 1.5, 1.0)), + (36.0, (3.0, 2.0, 1.5)), + (64.0, (4.0, 3.0, 2.0, 1.5)), + ], +) +def test_iso261_fine_pitch_series(d, pitches): + assert FINE[d] == pitches + + +def test_a_bare_designation_resolves_to_the_coarse_pitch(): + assert parse_designation("M20") == { + "designation": "M20", + "d": 20.0, + "pitch": 2.5, + "series": "coarse", + "hand": "right", + } + + +def test_a_fine_designation_resolves_and_keeps_its_hand(): + parsed = parse_designation("M20x1.5-LH") + assert parsed["d"] == 20.0 + assert parsed["pitch"] == 1.5 + assert parsed["series"] == "fine" + assert parsed["hand"] == "left" + + +def test_a_diameter_outside_the_transcribed_table_is_refused_by_name(): + with pytest.raises(ValueError) as excinfo: + parse_designation("M7") + message = str(excinfo.value) + assert "ISO 261" in message + assert "M7" in message + with pytest.raises(ValueError, match="ISO 261"): + parse_designation("M70") + + +def test_a_fine_pitch_that_is_not_in_the_series_is_refused_and_lists_the_series(): + with pytest.raises(ValueError) as excinfo: + parse_designation("M20x1.25") + message = str(excinfo.value) + assert "M20" in message + assert "2.0" in message and "1.5" in message and "1.0" in message + + +def test_a_malformed_designation_is_refused(): + with pytest.raises(ValueError, match="designation"): + parse_designation("20mm") + + +def test_pitch_for_matches_parse_designation(): + assert pitch_for(20.0) == 2.5 + assert pitch_for(20.0, 1.5) == 1.5 + with pytest.raises(ValueError): + pitch_for(20.0, 1.25) + + +@pytest.mark.parametrize( + ("d", "pitch", "d1", "d2"), + [ + (20.0, 2.50, 17.294, 18.376), + (10.0, 1.50, 8.376, 9.026), + (8.0, 1.25, 6.647, 7.188), + (12.0, 1.75, 10.106, 10.863), + (6.0, 1.00, 4.917, 5.350), + ], +) +def test_iso68_1_reproduces_the_published_iso724_basic_diameters(d, pitch, d1, d2): + assert basic_minor_diameter(d, pitch) == pytest.approx(d1, abs=5e-4) + assert basic_pitch_diameter(d, pitch) == pytest.approx(d2, abs=5e-4) + + +def test_the_registry_carries_the_pitch_table_and_refuses_outside_its_coverage(): + assert lookup("ISO 261", 20.0)["pitch"] == 2.5 + with pytest.raises(ValueError, match="ISO 261"): + lookup("ISO 261", 70.0) + + +def test_iso6410_axial_representation_of_an_external_thread(): + prims = thread_axial_prims(x0=10.0, length=30.0, d=20.0) + lines = [p for p in prims if isinstance(p, Line)] + assert len(prims) == 3 and len(lines) == 3 + minor = MINOR_RATIO * 20.0 / 2.0 + assert {round(line.p1[1], 9) for line in lines[:2]} == {round(minor, 9), round(-minor, 9)} + assert lines[0].p1 == (10.0, minor) and lines[0].p2 == (40.0, minor) + assert lines[1].p1 == (10.0, -minor) and lines[1].p2 == (40.0, -minor) + # the thread-length limit line runs the full major diameter + assert lines[2].p1 == (40.0, -10.0) and lines[2].p2 == (40.0, 10.0) + assert {line.role for line in lines} == {"visible"} + + +def test_iso6410_axial_representation_of_an_internal_thread_is_hidden(): + prims = thread_axial_prims(x0=0.0, length=20.0, d=12.0, internal=True) + assert {p.role for p in prims} == {"hidden"} + assert prims[0].p1 == (0.0, 6.0) and prims[0].p2 == (20.0, 6.0) + + +def test_iso6410_end_view_is_three_quarters_of_a_circle(): + (arc,) = thread_end_prims(center=(0.0, 0.0), d=20.0) + assert isinstance(arc, Arc) + assert arc.radius == pytest.approx(8.0) + assert (arc.end_deg - arc.start_deg) % 360.0 == pytest.approx(270.0) + assert arc.start_deg == pytest.approx(90.0) + + +def test_the_end_view_gap_can_be_moved_and_stays_a_quarter(): + (arc,) = thread_end_prims(center=(0.0, 0.0), d=20.0, gap_center_deg=135.0) + assert arc.start_deg == pytest.approx(180.0) + assert (arc.end_deg - arc.start_deg) % 360.0 == pytest.approx(270.0) + assert math.isclose(arc.radius, 8.0) diff --git a/tests/test_mech_views.py b/tests/test_mech_views.py new file mode 100644 index 0000000..168e19b --- /dev/null +++ b/tests/test_mech_views.py @@ -0,0 +1,464 @@ +"""The orthographic view engine: silhouette, hidden lines, centre lines, layout. + +Everything is asserted as emitted primitive tuples - counts, roles, coordinates +to 1e-9. The radial profile is the single calculation both the silhouette and +(from Task 5) the section cut face come out of, so it is pinned first. +""" + +from __future__ import annotations + +from collections import Counter + +import pytest + +from engineering.mech.features import ( + AxialBore, + Chamfer, + CornerFillet, + CrossHole, + Fillet, + Hole, + Keyway, + ORingGroove, + RetainingGroove, +) +from engineering.mech.part import PrismaticPart, RevolvedPart, Segment +from engineering.mech.primitives import Arc, Circle, Line +from engineering.mech.views import ( + PROJECTIONS, + VIEW_KINDS, + Band, + View, + build_view, + layout_origin, + radial_profile, +) + +EPS = 1e-9 + + +def shaft(*features) -> RevolvedPart: + return RevolvedPart( + name="shaft", + segments=(Segment(20.0, 25.0), Segment(40.0, 30.0), Segment(15.0, 20.0)), + features=tuple(features), + ) + + +def plate(*features) -> PrismaticPart: + return PrismaticPart( + name="plate", + outline=((0.0, 0.0), (80.0, 0.0), (80.0, 50.0), (0.0, 50.0)), + thickness=8.0, + features=tuple(features), + ) + + +def roles(view: View, role: str): + return [p for p in view.prims if getattr(p, "role", None) == role] + + +def coincident(view: View) -> dict: + """Geometries drawn more than once - exactly what ``_check_duplicate_entities`` flags.""" + counts: Counter = Counter() + for prim in view.prims: + if isinstance(prim, Line): + counts[tuple(sorted((prim.p1, prim.p2)))] += 1 + elif isinstance(prim, Circle): + counts[(prim.center, round(prim.radius, 9))] += 1 + return {key: n for key, n in counts.items() if n > 1} + + +def test_the_engine_declares_three_orthographic_kinds_and_two_projections(): + # The three orthographic kinds come first; Task 5 appended the two cut kinds + # (tests/test_mech_sections.py pins the full tuple from the other side). + assert VIEW_KINDS[:3] == ("front", "side", "top") + assert VIEW_KINDS == ("front", "side", "top", "section", "detail") + assert PROJECTIONS == ("first", "third") + + +# -- the radial profile ------------------------------------------------------ + + +def test_a_plain_shaft_has_one_band_per_segment(): + bands = radial_profile(shaft()) + assert bands == ( + Band(0.0, 20.0, 0.0, 12.5, 12.5), + Band(20.0, 60.0, 0.0, 15.0, 15.0), + Band(60.0, 75.0, 0.0, 10.0, 10.0), + ) + + +def test_a_taper_is_carried_as_two_end_radii(): + part = RevolvedPart(name="cone", segments=(Segment(20.0, 30.0, 0.0, 10.0),)) + (band,) = radial_profile(part) + assert band.r_out0 == pytest.approx(15.0, abs=1e-6) + assert band.r_out1 == pytest.approx(5.0, abs=1e-6) + + +def test_a_ring_groove_cuts_a_band_out_of_the_outer_profile(): + groove = RetainingGroove(id="rg", x=30.0, d=30.0, m=1.6, d2=28.6) + bands = radial_profile(shaft(groove)) + # the cut band is the one INSIDE the 30 mm segment whose outside is pulled + # back; segments 0 (d25) and 2 (d20) are simply thinner and are not cuts. + cut = [b for b in bands if 20.0 <= b.x0 and b.x1 <= 60.0 and b.r_out0 < 15.0 - EPS] + assert len(cut) == 1 + assert cut[0].x0 == pytest.approx(29.2) + assert cut[0].x1 == pytest.approx(30.8) + assert cut[0].r_out0 == pytest.approx(14.3) + + +def test_an_axial_bore_raises_the_inner_profile_over_its_depth(): + bore = AxialBore(id="ab", at="start", diameter=8.0, depth=30.0) + bands = radial_profile(shaft(bore)) + bored = [b for b in bands if b.r_in > 0.0] + assert [(b.x0, b.x1) for b in bored] == [(0.0, 20.0), (20.0, 30.0)] + assert {b.r_in for b in bored} == {4.0} + + +# -- the revolved front view ------------------------------------------------- + + +def test_a_plain_shaft_front_view_is_a_mirrored_silhouette_on_an_axis(): + view = build_view(shaft(), "front") + assert view.kind == "front" + axis = roles(view, "center") + assert len(axis) == 1 + assert axis[0].p1[1] == 0.0 and axis[0].p2[1] == 0.0 + assert axis[0].p1[0] < 0.0 and axis[0].p2[0] > 75.0 + visible = roles(view, "visible") + # the +Y half-outline is 7 lines (left face, 3 cylinders, 2 steps, right face) + # and it is mirrored about the axis + assert len(visible) == 14 + ys = sorted({round(line.p1[1], 6) for line in visible}) + assert ys == [-15.0, -12.5, -10.0, 0.0, 10.0, 12.5, 15.0] + assert view.bbox == (pytest.approx(-7.5), -15.0, pytest.approx(82.5), 15.0) + + +def test_a_bore_reaches_the_front_view_as_hidden_lines(): + part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),)) + view = build_view(part, "front") + hidden = roles(view, "hidden") + assert len(hidden) == 2 + assert {round(line.p1[1], 6) for line in hidden} == {10.0, -10.0} + assert hidden[0].p1[0] == 0.0 and hidden[0].p2[0] == 30.0 + + +def test_a_chamfer_replaces_the_silhouette_corner_instead_of_being_drawn_over_it(): + chamfer = Chamfer(id="c1", at="start", size=2.0) + view = build_view(shaft(chamfer), "front") + upper = [line for line in roles(view, "visible") if line.p1[1] > 0 or line.p2[1] > 0] + starts = {(round(line.p1[0], 6), round(line.p1[1], 6)) for line in upper} + assert (0.0, 10.5) in starts + assert (0.0, 12.5) not in starts + assert any(line.p1 == (0.0, 10.5) and line.p2 == (2.0, 12.5) for line in upper) + + +def test_a_fillet_puts_a_tangent_arc_into_the_silhouette(): + fillet = Fillet(id="f1", at="step:0", radius=2.0) + view = build_view(shaft(fillet), "front") + arcs = [p for p in view.prims if isinstance(p, Arc)] + assert len(arcs) == 2 # the fillet and its mirror + upper = next(a for a in arcs if a.center[1] > 0) + assert upper.center == (18.0, 14.5) + assert (upper.radius, upper.start_deg, upper.end_deg) == (2.0, 270.0, 360.0) + + +def test_an_axisymmetric_feature_is_mirrored_and_a_keyway_is_not(): + groove = RetainingGroove(id="rg", x=30.0, d=30.0, m=1.6, d2=28.6) + keyway = Keyway(id="kw", segment=1, length=20.0, offset=5.0) + view = build_view(shaft(groove, keyway), "front") + groove_lines = [ + line for line in view.prims if isinstance(line, Line) and abs(abs(line.p1[1]) - 14.3) < 1e-6 + ] + assert {line.p1[1] > 0 for line in groove_lines} == {True, False} + floor = 15.0 - 4.0 # DIN 6885 depth_shaft for a 30 mm shaft + keyway_lines = [ + line for line in view.prims if isinstance(line, Line) and abs(line.p1[1] - floor) < 1e-6 + ] + assert keyway_lines and all(line.p1[1] > 0 for line in keyway_lines) + + +@pytest.mark.parametrize( + "groove", + [ + RetainingGroove(id="rg", x=30.0, d=30.0, m=1.6, d2=28.6), + ORingGroove(id="or", x=40.0, cord=3.0, b=4.1, h=2.3), + ], + ids=["retaining", "oring"], +) +def test_a_groove_is_the_notch_in_the_silhouette_and_is_not_drawn_a_second_time(groove): + """The spec's rule is `grooves cut a notch in the silhouette`. The notch is + the radial profile's; the feature does not redraw it. Two coincident LINEs + are what ``drawing_critique(focus=None)`` reports as a duplicate, and + premium rule 7 needs that report empty before ``drawing_finalize``.""" + view = build_view(shaft(groove), "front") + assert coincident(view) == {} + # the notch is still there, mirrored, once per side + floor = {round(abs(line.p1[1]), 6) for line in view.prims if isinstance(line, Line)} + assert (14.3 in floor) or (12.7 in floor) + assert view.omitted == () + + +def test_an_axial_bore_end_circle_is_drawn_once(): + """``_end_circles`` reads the bore off the same radial profile the feature + edited, so the feature's own circle would be the second copy of it.""" + view = build_view(shaft(AxialBore(id="ab", at="start", diameter=8.0, depth=30.0)), "side") + assert coincident(view) == {} + assert len([c for c in view.prims if isinstance(c, Circle) and abs(c.radius - 4.0) < EPS]) == 1 + + +def test_a_blind_bore_keeps_the_bottom_line_the_profile_cannot_draw(): + """Suppression is `the profile already drew this`, not `this feature edits + the profile` - the blind bottom is not on the radial profile at all.""" + view = build_view(shaft(AxialBore(id="ab", at="start", diameter=8.0, depth=30.0)), "front") + bottoms = [ + line + for line in roles(view, "hidden") + if isinstance(line, Line) and abs(line.p1[0] - 30.0) < EPS and abs(line.p2[0] - 30.0) < EPS + ] + assert len(bottoms) == 1 + assert {round(bottoms[0].p1[1], 6), round(bottoms[0].p2[1], 6)} == {-4.0, 4.0} + + +def test_a_groove_straddling_a_step_leaves_one_closed_silhouette(): + """The feature's own prims read a single surface radius for a span that has + two, so drawing them as well as the profile's notch put a groove floor + through solid material and left the +Y path with four odd-degree nodes. + An open path has exactly two: its two ends on the axis.""" + part = shaft(RetainingGroove(id="rg", x=20.0, d=30.0, m=1.6, d2=28.6)) + upper = [ + line + for line in roles(build_view(part, "front"), "visible") + if isinstance(line, Line) and line.p1[1] >= 0.0 and line.p2[1] >= 0.0 + ] + degree: Counter = Counter() + for line in upper: + degree[(round(line.p1[0], 6), round(line.p1[1], 6))] += 1 + degree[(round(line.p2[0], 6), round(line.p2[1], 6))] += 1 + assert sorted(node for node, n in degree.items() if n % 2) == [(0.0, 0.0), (75.0, 0.0)] + # nothing is drawn at the groove floor radius outside the band the profile cut + assert not [ + line + for line in upper + if abs(line.p1[1] - 14.3) < EPS and abs(line.p2[1] - 14.3) < EPS and line.p1[0] < 20.0 + ] + + +# -- the revolved end view --------------------------------------------------- + + +def test_the_end_view_hides_the_smaller_diameter_and_draws_the_step_face_continuous(): + """Ground truth by ray cast along the axis of the same solid of revolution + (viewer at +X, probe just inside each edge): + + edge r=10 at x=75 -> first surface at x=75 -> VISIBLE + edge r=15 at x=60 -> first surface at x=60 -> VISIBLE + edge r=12.5 at x=20 -> first surface at x=60 -> HIDDEN + + The annular step face at x=60 faces the observer and nothing at projected + radius 15-eps stands in front of it, so the 30 mm circle is continuous. The + 25 mm circle is the one that is genuinely covered, and it is still *drawn* - + dashed, not dropped. + """ + view = build_view(shaft(), "side", side="right") + circles = [p for p in view.prims if isinstance(p, Circle)] + radii = {(round(c.radius, 6), c.role) for c in circles} + assert radii == {(10.0, "visible"), (15.0, "visible"), (12.5, "hidden")} + assert len([p for p in view.prims if getattr(p, "role", None) == "center"]) == 2 + + +def test_looking_from_the_left_reverses_which_circle_is_visible(): + """From -X the near end is the 25 mm face; the 20 mm end is behind the 30 mm body.""" + view = build_view(shaft(), "side", side="left") + circles = [p for p in view.prims if isinstance(p, Circle)] + radii = {(round(c.radius, 6), c.role) for c in circles} + assert radii == {(12.5, "visible"), (15.0, "visible"), (10.0, "hidden")} + + +def test_a_feature_circle_at_the_far_end_is_hidden_behind_the_larger_body(): + """A chamfer at x=0 seen from +X sits behind the 30 mm band, so its circle + is dashed; seen from -X nothing is in front of it and it is continuous.""" + part = shaft(Chamfer(id="c1", at="start", size=2.0)) + right = { + (round(c.radius, 6), c.role) + for c in build_view(part, "side", side="right").prims + if isinstance(c, Circle) + } + left = { + (round(c.radius, 6), c.role) + for c in build_view(part, "side", side="left").prims + if isinstance(c, Circle) + } + assert (10.5, "hidden") in right + assert (10.5, "visible") in left + + +def test_a_bore_that_widens_behind_a_narrower_one_is_hidden(): + """Looking into the narrow end you see the small bore; the wider bore behind + it is covered by the wall of the narrow one. From the other end, both are + seen down the widening hole.""" + part = RevolvedPart( + name="stepped-bore", + segments=(Segment(20.0, 40.0, 10.0), Segment(20.0, 40.0, 20.0)), + ) + right = { + (round(c.radius, 6), c.role) + for c in build_view(part, "side", side="right").prims + if isinstance(c, Circle) + } + left = { + (round(c.radius, 6), c.role) + for c in build_view(part, "side", side="left").prims + if isinstance(c, Circle) + } + assert (10.0, "visible") in right and (5.0, "visible") in right + assert (5.0, "visible") in left and (10.0, "hidden") in left + + +def test_a_revolved_top_view_is_refused_because_it_repeats_the_front(): + with pytest.raises(ValueError, match="identical to its front view"): + build_view(shaft(), "top") + + +# -- the prismatic views ----------------------------------------------------- + + +def test_a_prismatic_front_view_is_the_closed_outline(): + view = build_view(plate(), "front") + lines = roles(view, "visible") + assert len(lines) == 4 + assert view.bbox == (0.0, 0.0, 80.0, 50.0) + + +def test_a_corner_fillet_replaces_an_outline_vertex_with_an_arc(): + view = build_view(plate(CornerFillet(id="cf", vertex=1, radius=10.0)), "front") + arcs = [p for p in view.prims if isinstance(p, Arc)] + # the substituted arc carries role "visible" too, so the straight edges are + # counted by type: 4 outline edges remain, two of them shortened. + lines = [p for p in roles(view, "visible") if isinstance(p, Line)] + assert len(arcs) == 1 + assert arcs[0].center == (pytest.approx(70.0), pytest.approx(10.0)) + assert len(lines) == 4 + assert not any(abs(line.p1[0] - 80.0) < EPS and abs(line.p1[1]) < EPS for line in lines) + + +def test_a_prismatic_side_view_is_the_thickness_rectangle_with_a_hidden_line_per_hole(): + # y=10, deliberately OFF the y=25 mirror axis of the 0..50 plate: a hole on + # the axis cannot tell a right-hand view from a left-hand one. + hole = Hole(id="h1", x=20.0, y=10.0, diameter=10.0) + view = build_view(plate(hole), "side") + visible = roles(view, "visible") + hidden = roles(view, "hidden") + assert len(visible) == 4 + assert view.bbox == (0.0, 0.0, 50.0, 8.0) + assert len(hidden) == 2 + assert {round(line.p1[0], 6) for line in hidden} == {5.0, 15.0} + + +def test_a_prismatic_left_side_view_is_the_right_view_mirrored(): + """An observer at -X sees screen-right = -Y, so the hole at y=10 in a plate + spanning y 0..50 lands at u = 50-15 .. 50-5. Accepting ``side='left'`` and + returning the right-hand view puts every feature on the wrong side of the + sheet with nothing in the drawing to reveal it.""" + hole = Hole(id="h1", x=20.0, y=10.0, diameter=10.0) + part = plate(hole) + right = { + round(line.p1[0], 6) for line in roles(build_view(part, "side", side="right"), "hidden") + } + left = {round(line.p1[0], 6) for line in roles(build_view(part, "side", side="left"), "hidden")} + assert right == {5.0, 15.0} + assert left == {35.0, 45.0} + # the thickness rectangle is its own mirror, so only the features move + assert build_view(part, "side", side="left").bbox == (0.0, 0.0, 50.0, 8.0) + + +def test_a_prismatic_top_view_is_always_seen_from_above(): + """``side`` names the end an *end* view is taken from; the top view is not + one, so it must not move with it.""" + part = plate(Hole(id="h1", x=20.0, y=10.0, diameter=10.0)) + from_right = { + round(line.p1[0], 6) for line in roles(build_view(part, "top", side="right"), "hidden") + } + from_left = { + round(line.p1[0], 6) for line in roles(build_view(part, "top", side="left"), "hidden") + } + assert from_right == from_left == {15.0, 25.0} + + +def test_a_prismatic_top_view_projects_the_other_axis(): + hole = Hole(id="h1", x=20.0, y=25.0, diameter=10.0) + view = build_view(plate(hole), "top") + assert view.bbox == (0.0, 0.0, 80.0, 8.0) + hidden = roles(view, "hidden") + assert {round(line.p1[0], 6) for line in hidden} == {15.0, 25.0} + + +# -- the honesty rule -------------------------------------------------------- + + +def test_a_feature_that_projects_into_the_view_is_not_reported_as_omitted(): + side = build_view(shaft(Chamfer(id="c1", at="start", size=2.0)), "side") + assert side.omitted == () + front = build_view(shaft(CrossHole(id="xh", x=30.0, diameter=6.0)), "front") + assert front.omitted == () + bore = build_view(shaft(AxialBore(id="ab", at="start", diameter=8.0, depth=12.0)), "front") + assert bore.omitted == () + + +def test_a_bend_line_is_omitted_from_a_side_view_with_a_reason(): + from engineering.mech.features import BendLine + + bend = BendLine(id="b1", p1=(0.0, 25.0), p2=(80.0, 25.0), angle=90.0, inside_radius=2.0) + view = build_view(plate(bend), "side") + assert len(view.omitted) == 1 + assert view.omitted[0]["feature"] == "b1" + assert "side" in view.omitted[0]["reason"] + + +def test_the_view_collects_the_dimension_intents_of_its_own_features(): + keyway = Keyway(id="kw", segment=1, length=20.0) + view = build_view(shaft(keyway), "front") + assert [intent.feature for intent in view.dims] == ["kw"] + + +def test_an_unknown_view_kind_is_refused_by_name(): + with pytest.raises(ValueError, match="front"): + build_view(shaft(), "isometric") + + +# -- projection layout ------------------------------------------------------- + + +def test_first_angle_puts_the_right_hand_view_on_the_left_and_the_top_below(): + front = build_view(shaft(), "front") + end = build_view(shaft(), "side") + left = layout_origin(front, "side", side="right", projection="first", child=end) + width = end.bbox[2] - end.bbox[0] + height = end.bbox[3] - end.bbox[1] + assert left[0] == pytest.approx(front.bbox[0] - 20.0 - width) + assert left[1] == pytest.approx((front.bbox[1] + front.bbox[3]) / 2.0 - height / 2.0) + below = layout_origin(front, "top", projection="first", child=end) + assert below[1] == pytest.approx(front.bbox[1] - 20.0 - height) + + +def test_third_angle_flips_both_placements(): + front = build_view(shaft(), "front") + end = build_view(shaft(), "side") + right = layout_origin(front, "side", side="right", projection="third", child=end) + assert right[0] == pytest.approx(front.bbox[2] + 20.0) + above = layout_origin(front, "top", projection="third", child=end) + assert above[1] == pytest.approx(front.bbox[3] + 20.0) + + +def test_without_a_child_the_layout_assumes_the_parent_size(): + front = build_view(shaft(), "front") + width = front.bbox[2] - front.bbox[0] + left = layout_origin(front, "side", side="right", projection="first") + assert left[0] == pytest.approx(front.bbox[0] - 20.0 - width) + + +def test_an_unknown_projection_is_refused(): + front = build_view(shaft(), "front") + with pytest.raises(ValueError, match="first"): + layout_origin(front, "side", projection="second") diff --git a/tests/test_tool_packs.py b/tests/test_tool_packs.py index fc370c2..e423f98 100644 --- a/tests/test_tool_packs.py +++ b/tests/test_tool_packs.py @@ -86,7 +86,16 @@ LEAN_SETTINGS_ESSENTIALS = { "textstyle_set_current", } -ALL_PACKS = ["core", "pid", "settings"] +ALL_PACKS = ["core", "pid", "settings", "mech"] +ALL_PACKS_SORTED = sorted(ALL_PACKS) +MECH_TOOLS = { + "mech_part_draw", + "mech_view_add", + "mech_dimension_part", + "mech_hole_pattern", + "mech_part_from_spec", + "mech_part_inspect", +} @pytest_asyncio.fixture(autouse=True) @@ -97,8 +106,9 @@ async def _restore(monkeypatch): def test_pack_registry_names_real_tools_and_only_them(): - assert server.TOOL_PACK_NAMES == ("core", "pid", "settings") + assert server.TOOL_PACK_NAMES == ("core", "pid", "settings", "mech") assert server.PACK_TOOL_NAMES["pid"] == frozenset(PID_TOOLS) + assert server.PACK_TOOL_NAMES["mech"] == frozenset(MECH_TOOLS) assert server.PACK_TOOL_NAMES["settings"] == frozenset(SETTINGS_TOOLS) assert len(SETTINGS_TOOLS) == 22 assert not (server.PACK_TOOL_NAMES["settings"] & server.PACK_TOOL_NAMES["pid"]) @@ -137,7 +147,7 @@ async def test_all_is_the_default_and_enables_every_pack(monkeypatch): info = await server._apply_tool_profile("full") disabled = set(info["disabled_tools"]) assert not ((PID_TOOLS | SETTINGS_TOOLS) & disabled) - assert info["tool_packs"]["enabled"] == ALL_PACKS + assert info["tool_packs"]["enabled"] == ALL_PACKS_SORTED async def test_unknown_pack_is_ignored_with_a_warning_and_core_cannot_be_dropped( @@ -184,7 +194,8 @@ async def test_the_three_track_e_sections_file_under_their_own_groups(): assert set(groups["styles"]) == STYLE_TOOLS assert set(groups["page_setup"]) == PAGE_SETUP_TOOLS assert set(groups["environment"]) == SETTINGS_TOOLS - assert len(groups) == 23 + assert set(groups["mech"]) == MECH_TOOLS + assert len(groups) == 24 async def test_system_about_reports_packs(): diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 4b55428..36edea0 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -527,8 +527,15 @@ async def test_tool_group_sizes_are_unchanged(): merge task tagged them, the four groups had filed those 28 tools under `layouts` / `templates` / `drawing` / `layers` / `view` / `system` by their secondary tags; those counts returned to their pre-track-E values - when the section tags landed.) Every other number here has been unchanged - since the snapshot was taken. + when the section tags landed.) `mech` appeared (0 -> 6) when v1.6's + SECTION 21 opened with the part model and its view engine + (`mech_part_draw` / `mech_view_add` / `mech_dimension_part` / + `mech_hole_pattern` / `mech_part_from_spec` / `mech_part_inspect`); the + `mech` tag is ranked directly after `pid` in `_GROUP_TAG_PRIORITY`, so + those tools file under `mech` rather than under their secondary `create` / + `dimension` / `query` tags. This snapshot is branch-local: the merge task + recomputes it once all four track B+G branches have landed. Every other + number here has been unchanged since the snapshot was taken. """ sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} assert sizes == { @@ -545,6 +552,7 @@ async def test_tool_group_sizes_are_unchanged(): "environment": 22, "layers": 14, "layouts": 12, + "mech": 6, "page_setup": 6, "pid": 9, "premium": 12, @@ -556,4 +564,4 @@ async def test_tool_group_sizes_are_unchanged(): "validation": 1, "view": 4, } - assert sum(sizes.values()) == 204 + assert sum(sizes.values()) == 210