merge: tracks B+G group M
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
@@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
||||
|
||||
</div>
|
||||
|
||||
> **v1.5 release snapshot:** 204 tools · 8 resources · 5 prompt templates · 3284 collected tests.
|
||||
> 204 is the **registered** count; a default install advertises 199 over `tools/list`,
|
||||
> **v1.5 release snapshot:** 210 tools · 8 resources · 5 prompt templates · 3284 collected tests.
|
||||
> 210 is the **registered** count; a default install advertises 205 over `tools/list`,
|
||||
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
||||
|
||||
## Why this exists
|
||||
@@ -117,7 +117,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
|
||||
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
|
||||
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
|
||||
|
||||
<sub>204 tools in 23 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools and the 22 environment tools (<code>TOOL_PACKS=core,settings</code> keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
|
||||
<sub>210 tools in 24 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools and the 22 environment tools (<code>TOOL_PACKS=core,settings</code> keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
|
||||
|
||||
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,650 @@
|
||||
"""Dimension intents for a view, laid out so no two texts overlap.
|
||||
|
||||
Pure: no backend, no async, no DXF. The features declare what they want
|
||||
dimensioned; this module adds the part's own sizes, removes the duplicates
|
||||
(ISO 129-1: each dimension appears once), resolves ISO 286 fits and ISO 129
|
||||
tolerances through the modules that already own them, and then *places* the
|
||||
texts.
|
||||
|
||||
The placement is not a matter of taste. Every intent's text is measured into a
|
||||
rectangle by :func:`text_rects`, and an intent is put on the lowest free level
|
||||
whose rectangle does not overlap one already placed on that level. Two
|
||||
rectangles are disjoint when they are separated along *either* axis, so the
|
||||
layout separates them along both in turn: within a level, along the axis the
|
||||
level runs in; between levels, along the axis the levels stack in. The second
|
||||
one is why the level pitch is clamped to the extent of the text *in the
|
||||
stacking direction* - which is the text height for the row of dimensions under
|
||||
the view, and the text **width** for the column of diameters beside it. A pitch
|
||||
clamped only to the height would let two 9.45 mm wide diameter labels sit
|
||||
8 mm apart on the same centreline, overlapping by 1.45 mm.
|
||||
|
||||
The test asserts pairwise disjointness with the same helper, which makes "no
|
||||
overlapping dimension text" measured rather than claimed - and it is the same
|
||||
property the repository's `dim_overlap` critique focus checks on the finished
|
||||
drawing.
|
||||
|
||||
Three rules the module enforces rather than assumes:
|
||||
|
||||
*ISO 286 fits reach the pipeline.* :func:`dimension_intents` takes
|
||||
``fits={key: code}`` and stamps the code onto the intent it names, so a bearing
|
||||
seat gets its k6 without anyone hand-building a ``DimIntent``. The part's own
|
||||
diameters are addressable by segment (``"segment[1]"``, ``"segment[1].bore"``);
|
||||
a feature's are addressable by its id. An unknown key is refused by name - a fit
|
||||
that lands nowhere is a manufacturing requirement lost in silence.
|
||||
|
||||
*An explicit tolerance is validated as a whole.* ``DimIntent.tol`` is
|
||||
``{"mode", "upper", "lower"}`` and nothing else. ``mode`` is required, because
|
||||
``build_dim_override`` reads a missing mode as ``"none"`` and returns an empty
|
||||
override: a +-0.05 with no ``mode`` used to resolve, report its own numbers back
|
||||
and then draw a dimension carrying no tolerance at all.
|
||||
|
||||
*ISO 129-1: each measurement appears once.* A closed chain plus the overall
|
||||
states the overall twice. :func:`redundant_linear` finds the span that the
|
||||
others tile end to end, and :func:`dimension_intents` marks it as an auxiliary
|
||||
(reference) dimension - ``(<>)``, drawn in parentheses - rather than emitting a
|
||||
fourth plain DIMENSION that contradicts nothing and adds nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from engineering.fits import fit_lookup, parse_fit_code
|
||||
from engineering.mech.features import Hole
|
||||
from engineering.mech.part import (
|
||||
PrismaticPart,
|
||||
RevolvedPart,
|
||||
outline_bbox,
|
||||
part_length,
|
||||
segment_bounds,
|
||||
)
|
||||
from engineering.mech.primitives import DimIntent
|
||||
from engineering.tolerances import TOL_MODES, build_dim_override
|
||||
|
||||
_EPS = 1e-9
|
||||
|
||||
DIM_STYLES: tuple[str, ...] = ("chain", "baseline", "ordinate")
|
||||
|
||||
#: Mean glyph width as a fraction of the text height, for measuring a label
|
||||
#: into a rectangle. Deliberately generous: over-estimating the width can only
|
||||
#: push two dimensions further apart.
|
||||
TEXT_WIDTH_FACTOR = 0.7
|
||||
|
||||
#: Padding added around a measured label, in text heights.
|
||||
TEXT_PADDING = 0.6
|
||||
|
||||
#: How an auxiliary (reference) dimension is written - ISO 129-1 puts a
|
||||
#: measurement that is already given elsewhere in parentheses. ``<>`` is the
|
||||
#: DIMENSION text placeholder for the measured value, on both engines.
|
||||
REFERENCE_FORMAT = "(<>)"
|
||||
|
||||
#: The only keys ``DimIntent.tol`` accepts.
|
||||
TOL_KEYS: frozenset[str] = frozenset({"mode", "upper", "lower"})
|
||||
|
||||
#: Tolerance modes that write no deviation, so carrying upper/lower with them
|
||||
#: would silently discard the numbers.
|
||||
_NO_DEVIATION_MODES: frozenset[str] = frozenset({"none", "basic"})
|
||||
|
||||
|
||||
def measured_value(intent: DimIntent) -> float:
|
||||
"""What the dimension measures, in drawing units."""
|
||||
p1, p2 = intent.p1, intent.p2
|
||||
if intent.kind == "linear":
|
||||
dx, dy = abs(p2[0] - p1[0]), abs(p2[1] - p1[1])
|
||||
return dx if dx >= dy else dy
|
||||
return math.dist(p1, p2)
|
||||
|
||||
|
||||
def _format(value: float) -> str:
|
||||
return f"{round(value, 3):g}"
|
||||
|
||||
|
||||
def _with_fit(text: str, fit: str | None) -> str:
|
||||
"""Append an ISO 286 code to a dimension's text, once.
|
||||
|
||||
A feature that already names itself (a hole's ``⌀6``) keeps its own text and
|
||||
gains the code; without this the deviations would be written and the code
|
||||
the inspector reads would not be on the sheet.
|
||||
"""
|
||||
code = (fit or "").strip()
|
||||
if not code or code in text:
|
||||
return text
|
||||
return f"{text} {code}"
|
||||
|
||||
|
||||
def _measured_text(intent: DimIntent) -> str:
|
||||
if intent.kind == "diameter":
|
||||
return f"⌀{_format(measured_value(intent))}"
|
||||
if intent.kind == "radius":
|
||||
return f"R{_format(measured_value(intent))}"
|
||||
return _format(measured_value(intent))
|
||||
|
||||
|
||||
def intent_text(intent: DimIntent) -> str:
|
||||
"""The text a dimension will carry, as the engine will render it.
|
||||
|
||||
``<>`` is the DIMENSION placeholder for the measured value, so an override
|
||||
of ``"(<>)"`` reads ``"(75)"`` and a resolved fit's ``"<> H7"`` reads
|
||||
``"⌀40 H7"``. Expanding it here is what makes :func:`text_rects` measure the
|
||||
string that is actually drawn - a label measured as ``"<> H7"`` and drawn as
|
||||
``"⌀40 H7"`` is a layout computed against the wrong width.
|
||||
"""
|
||||
measured = _measured_text(intent)
|
||||
base = intent.text_override.replace("<>", measured) if intent.text_override else measured
|
||||
# The fit code is appended to the text by `resolve_tolerance`; the rectangle
|
||||
# has to reserve room for it before the layout runs.
|
||||
return _with_fit(base, intent.fit)
|
||||
|
||||
|
||||
def is_reference(intent: DimIntent) -> bool:
|
||||
"""True for an auxiliary (reference) dimension - ISO 129-1 parentheses."""
|
||||
override = intent.text_override or ""
|
||||
return override.startswith("(") and override.endswith(")")
|
||||
|
||||
|
||||
def _text_size(intent: DimIntent, height: float) -> tuple[float, float]:
|
||||
"""``(width, height)`` of the rectangle this intent's label occupies."""
|
||||
label = intent_text(intent)
|
||||
return (
|
||||
(len(label) * TEXT_WIDTH_FACTOR + TEXT_PADDING) * height,
|
||||
height * (1.0 + TEXT_PADDING),
|
||||
)
|
||||
|
||||
|
||||
def text_rects(intents, *, height: float = 3.5):
|
||||
"""``(x0, y0, x1, y1)`` per placed intent, from its text and its ``text_at``."""
|
||||
rects = []
|
||||
for intent in intents:
|
||||
if intent.text_at is None:
|
||||
raise ValueError(
|
||||
f"text_rects: intent {intent.feature or intent.kind!r} has no text_at; "
|
||||
"run layout_dimensions first."
|
||||
)
|
||||
width, tall = _text_size(intent, height)
|
||||
cx, cy = float(intent.text_at[0]), float(intent.text_at[1])
|
||||
rects.append((cx - width / 2.0, cy - tall / 2.0, cx + width / 2.0, cy + tall / 2.0))
|
||||
return tuple(rects)
|
||||
|
||||
|
||||
# -- tolerances --------------------------------------------------------------
|
||||
|
||||
|
||||
def _explicit_tol(intent: DimIntent) -> tuple[str, float | None, float | None]:
|
||||
"""Validate ``intent.tol`` as a whole and return ``(mode, upper, lower)``.
|
||||
|
||||
``build_dim_override`` alone is not enough: it defaults a missing mode to
|
||||
``"none"`` and answers with an empty override, so ``{"upper": 0.05,
|
||||
"lower": 0.05}`` used to resolve, echo its own numbers back and draw a
|
||||
dimension with no DIMTOL/DIMTP/DIMTM at all. A tolerance that disappears
|
||||
between the spec and the sheet is the silent wrong value this repository
|
||||
refuses, so the schema is checked here, by name.
|
||||
"""
|
||||
who = intent.feature or intent.kind
|
||||
tol = intent.tol
|
||||
if not isinstance(tol, dict):
|
||||
raise ValueError(
|
||||
f"{who}.tol: expected a dict with keys {sorted(TOL_KEYS)}, got {type(tol).__name__}."
|
||||
)
|
||||
unknown = set(tol) - TOL_KEYS
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"{who}.tol: unknown key(s) {sorted(unknown)}; a tolerance takes {sorted(TOL_KEYS)}."
|
||||
)
|
||||
if "mode" not in tol:
|
||||
raise ValueError(
|
||||
f"{who}.tol: needs an explicit 'mode' (one of {list(TOL_MODES)}). Without it the "
|
||||
"deviations are carried in the payload and no tolerance reaches the drawing."
|
||||
)
|
||||
mode = str(tol["mode"]).strip().lower()
|
||||
upper = tol.get("upper")
|
||||
lower = tol.get("lower")
|
||||
if mode in _NO_DEVIATION_MODES and (upper is not None or lower is not None):
|
||||
raise ValueError(
|
||||
f"{who}.tol: tol_mode={mode!r} writes no deviation, but upper={upper!r} / "
|
||||
f"lower={lower!r} were given. Use 'symmetric', 'deviation' or 'limit', or drop "
|
||||
"the values."
|
||||
)
|
||||
return mode, upper, lower
|
||||
|
||||
|
||||
def resolve_tolerance(intent: DimIntent, nominal: float | None = None) -> dict:
|
||||
"""Turn an intent's ``fit`` / ``tol`` into the backend dimension call's arguments.
|
||||
|
||||
Mirrors `server.py::_fit_to_tolerances`, including its sign convention:
|
||||
``build_dim_override`` reads ``tol_lower`` as the *magnitude* of the minus
|
||||
deviation, so a resolved fit passes ``-deviation.lower_mm``.
|
||||
"""
|
||||
has_tol = intent.tol is not None
|
||||
if intent.fit and has_tol:
|
||||
raise ValueError(
|
||||
f"{intent.feature or intent.kind}: pass either fit=<ISO 286 code> or an explicit "
|
||||
"tol, not both."
|
||||
)
|
||||
if intent.fit:
|
||||
size = float(nominal) if nominal is not None else measured_value(intent)
|
||||
if size <= _EPS:
|
||||
raise ValueError(
|
||||
f"{intent.feature or intent.kind}: fit {intent.fit!r} needs a nominal size, and "
|
||||
f"the intent measures {size}. Pass nominal= explicitly."
|
||||
)
|
||||
deviation = fit_lookup(intent.fit, size)
|
||||
return {
|
||||
"tol_upper": deviation.upper_mm,
|
||||
"tol_lower": -deviation.lower_mm,
|
||||
"tol_mode": "deviation",
|
||||
"text_override": _with_fit(intent.text_override or "<>", deviation.code),
|
||||
}
|
||||
if has_tol:
|
||||
mode, upper, lower = _explicit_tol(intent)
|
||||
build_dim_override(upper, lower, mode, intent.text_override) # validates, or raises
|
||||
return {
|
||||
"tol_upper": upper,
|
||||
"tol_lower": lower,
|
||||
"tol_mode": mode,
|
||||
"text_override": intent.text_override,
|
||||
}
|
||||
return {
|
||||
"tol_upper": None,
|
||||
"tol_lower": None,
|
||||
"tol_mode": "none",
|
||||
"text_override": intent.text_override,
|
||||
}
|
||||
|
||||
|
||||
# -- collecting the intents --------------------------------------------------
|
||||
|
||||
|
||||
def _dedupe(intents) -> tuple[DimIntent, ...]:
|
||||
seen: set[tuple] = set()
|
||||
out: list[DimIntent] = []
|
||||
for intent in intents:
|
||||
key = (
|
||||
intent.kind,
|
||||
round(float(intent.p1[0]), 6),
|
||||
round(float(intent.p1[1]), 6),
|
||||
round(float(intent.p2[0]), 6),
|
||||
round(float(intent.p2[1]), 6),
|
||||
)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(intent)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def _linear_span(intent: DimIntent) -> tuple[str, float, float, float] | None:
|
||||
"""``(axis, level, lo, hi)`` of an axis-parallel linear dimension, else None.
|
||||
|
||||
``level`` is the coordinate the dimension does *not* run along, so two
|
||||
horizontal dimensions at different heights are never compared with each
|
||||
other. A skewed linear intent has no span: it is not part of a chain.
|
||||
"""
|
||||
if intent.kind != "linear":
|
||||
return None
|
||||
(ax, ay), (bx, by) = intent.p1, intent.p2
|
||||
if abs(bx - ax) >= abs(by - ay):
|
||||
if abs(by - ay) > _EPS:
|
||||
return None
|
||||
return ("x", round(float(ay), 6), round(min(ax, bx), 6), round(max(ax, bx), 6))
|
||||
if abs(bx - ax) > _EPS:
|
||||
return None
|
||||
return ("y", round(float(ax), 6), round(min(ay, by), 6), round(max(ay, by), 6))
|
||||
|
||||
|
||||
def redundant_linear(intents) -> tuple[int, ...]:
|
||||
"""Indices of the linear intents that shorter ones already tile end to end.
|
||||
|
||||
ISO 129-1: a measurement appears once. A closed chain (20 + 40 + 15) that
|
||||
also carries the overall (75) states the overall twice, and the second
|
||||
statement has to be an auxiliary (reference) dimension or be left off.
|
||||
|
||||
The error is one-sided on purpose. A tiling that is found is a real one -
|
||||
the pieces butt, in order, from ``lo`` to ``hi`` on the same axis and level
|
||||
- so a dimension is never called redundant when it is not. A tiling that
|
||||
the greedy walk misses simply leaves the dimension plain, which is the way
|
||||
round the repository's critique rule wants: a check that misses a bad
|
||||
drawing costs a review, a check that calls a good drawing bad gets
|
||||
switched off.
|
||||
"""
|
||||
groups: dict[tuple[str, float], list[tuple[float, float, int]]] = {}
|
||||
for index, intent in enumerate(intents):
|
||||
span = _linear_span(intent)
|
||||
if span is None:
|
||||
continue
|
||||
axis, level, lo, hi = span
|
||||
groups.setdefault((axis, level), []).append((lo, hi, index))
|
||||
|
||||
out: list[int] = []
|
||||
for group in groups.values():
|
||||
for lo, hi, index in group:
|
||||
pieces = sorted(
|
||||
(a, b)
|
||||
for a, b, other in group
|
||||
if other != index
|
||||
and a >= lo - 1e-6
|
||||
and b <= hi + 1e-6
|
||||
and (b - a) < (hi - lo) - 1e-6
|
||||
)
|
||||
cursor = lo
|
||||
for a, b in pieces:
|
||||
if abs(a - cursor) <= 1e-6:
|
||||
cursor = b
|
||||
if cursor > lo + 1e-6 and abs(cursor - hi) <= 1e-6:
|
||||
out.append(index)
|
||||
return tuple(sorted(out))
|
||||
|
||||
|
||||
def _mark_references(intents: list[DimIntent]) -> list[DimIntent]:
|
||||
"""Put the redundant measurements in parentheses, ISO 129-1."""
|
||||
for index in redundant_linear(intents):
|
||||
intent = intents[index]
|
||||
if intent.text_override is None:
|
||||
intents[index] = intent._replace(text_override=REFERENCE_FORMAT)
|
||||
return intents
|
||||
|
||||
|
||||
def _apply_fits(intents: list[DimIntent], fits, *, table_ids: frozenset[str]) -> list[DimIntent]:
|
||||
"""Stamp each ``{key: ISO 286 code}`` onto the intent its key names.
|
||||
|
||||
Without this the ``fit`` branch of :func:`resolve_tolerance` is reachable
|
||||
only from a hand-built ``DimIntent``: no feature and no segment carries a
|
||||
fit, so the ISO 286 path would be real in the table and absent from every
|
||||
drawing the module actually produces.
|
||||
"""
|
||||
if not fits:
|
||||
return intents
|
||||
if not isinstance(fits, dict):
|
||||
raise ValueError(
|
||||
f"fits: expected a dict of {{key: ISO 286 code}}, got {type(fits).__name__}."
|
||||
)
|
||||
|
||||
available = {intent.feature for intent in intents if intent.feature}
|
||||
by_key: dict[str, list[int]] = {}
|
||||
for index, intent in enumerate(intents):
|
||||
if intent.feature:
|
||||
by_key.setdefault(intent.feature, []).append(index)
|
||||
|
||||
for key, code in fits.items():
|
||||
name = str(key)
|
||||
if name not in available:
|
||||
if name in table_ids:
|
||||
raise ValueError(
|
||||
f"fits[{name!r}]: that hole's diameter moved into the hole table, which has "
|
||||
"no tolerance column. Raise hole_table_threshold, or dimension the hole "
|
||||
"directly."
|
||||
)
|
||||
raise ValueError(
|
||||
f"fits[{name!r}]: nothing in this view is dimensioned under that name. "
|
||||
f"Available: {sorted(available)}."
|
||||
)
|
||||
parse_fit_code(str(code)) # refuses a typo here, not at draw time
|
||||
for index in by_key[name]:
|
||||
intent = intents[index]
|
||||
if is_reference(intent):
|
||||
raise ValueError(
|
||||
f"fits[{name!r}]: {intent_text(intent)} is an auxiliary (reference) "
|
||||
"dimension - ISO 129-1 gives it no tolerance. Put the fit on the "
|
||||
"dimension that states the size."
|
||||
)
|
||||
if intent.tol is not None:
|
||||
raise ValueError(
|
||||
f"fits[{name!r}]: that dimension already carries an explicit tol. Pass "
|
||||
"either fit=<ISO 286 code> or an explicit tol, not both."
|
||||
)
|
||||
intents[index] = intent._replace(fit=str(code))
|
||||
return intents
|
||||
|
||||
|
||||
def _revolved_axial(part: RevolvedPart, style: str) -> list[DimIntent]:
|
||||
stations = [0.0] + [x1 for _, x1 in segment_bounds(part)]
|
||||
intents: list[DimIntent] = []
|
||||
if style == "chain":
|
||||
for a, b in zip(stations, stations[1:], strict=False):
|
||||
intents.append(DimIntent(kind="linear", p1=(a, 0.0), p2=(b, 0.0), feature="part"))
|
||||
intents.append(
|
||||
DimIntent(kind="linear", p1=(0.0, 0.0), p2=(part_length(part), 0.0), feature="part")
|
||||
)
|
||||
else: # baseline and ordinate both measure from the left face
|
||||
for station in stations[1:]:
|
||||
intents.append(
|
||||
DimIntent(kind="linear", p1=(0.0, 0.0), p2=(station, 0.0), feature="part")
|
||||
)
|
||||
return intents
|
||||
|
||||
|
||||
def segment_key(index: int, *, bore: bool = False) -> str:
|
||||
"""The ``fits`` key that names one segment's outer diameter, or its bore.
|
||||
|
||||
The part's own diameters have to be addressable one at a time: "ISO 286
|
||||
fits on the bearing seats" means a code on *segment 1*, not on every
|
||||
dimension the part emits.
|
||||
"""
|
||||
return f"segment[{int(index)}].bore" if bore else f"segment[{int(index)}]"
|
||||
|
||||
|
||||
def _revolved_diameters(part: RevolvedPart) -> list[DimIntent]:
|
||||
intents: list[DimIntent] = []
|
||||
bounds = zip(segment_bounds(part), part.segments, strict=True)
|
||||
for index, ((x0, x1), segment) in enumerate(bounds):
|
||||
mid = (x0 + x1) / 2.0
|
||||
radius = segment.d_outer / 2.0
|
||||
intents.append(
|
||||
DimIntent(
|
||||
kind="diameter",
|
||||
p1=(mid, radius),
|
||||
p2=(mid, -radius),
|
||||
feature=segment_key(index),
|
||||
)
|
||||
)
|
||||
if segment.d_inner > _EPS:
|
||||
bore = segment.d_inner / 2.0
|
||||
intents.append(
|
||||
DimIntent(
|
||||
kind="diameter",
|
||||
p1=(mid, bore),
|
||||
p2=(mid, -bore),
|
||||
feature=segment_key(index, bore=True),
|
||||
)
|
||||
)
|
||||
return intents
|
||||
|
||||
|
||||
def _hole_groups(part) -> dict[tuple, list[Hole]]:
|
||||
groups: dict[tuple, list[Hole]] = {}
|
||||
for feature in getattr(part, "features", ()):
|
||||
if not isinstance(feature, Hole):
|
||||
continue
|
||||
key = (
|
||||
round(float(feature.diameter), 6),
|
||||
feature.thread or "",
|
||||
None if feature.cbore_d is None else round(float(feature.cbore_d), 6),
|
||||
None if feature.depth is None else round(float(feature.depth), 6),
|
||||
)
|
||||
groups.setdefault(key, []).append(feature)
|
||||
return groups
|
||||
|
||||
|
||||
def dimension_intents(
|
||||
part,
|
||||
view,
|
||||
*,
|
||||
style: str = "chain",
|
||||
hole_table_threshold: int = 8,
|
||||
fits: dict | None = None,
|
||||
) -> tuple[DimIntent, ...]:
|
||||
"""Every dimension this view wants: the features' own, plus the part's sizes.
|
||||
|
||||
``fits`` maps a dimension's name to an ISO 286 code - a feature id, or one
|
||||
of the part's own diameters by :func:`segment_key` (``"segment[1]"``,
|
||||
``"segment[1].bore"``). The code is stamped onto the matching intent, so
|
||||
:func:`resolve_tolerance` resolves it and :func:`layout_dimensions` reserves
|
||||
room for the widened text. A key that names nothing is refused with the
|
||||
names that exist.
|
||||
|
||||
ISO 129-1 is applied last: a measurement the other dimensions already tile
|
||||
end to end is marked as an auxiliary (reference) dimension, in parentheses,
|
||||
instead of being drawn a second time as a plain DIMENSION.
|
||||
"""
|
||||
name = str(style or "").strip().lower()
|
||||
if name not in DIM_STYLES:
|
||||
raise ValueError(f"style: expected one of {DIM_STYLES}, got {style!r}.")
|
||||
|
||||
intents: list[DimIntent] = list(view.dims)
|
||||
kind = "front" if view.kind == "section" else view.kind
|
||||
|
||||
if isinstance(part, RevolvedPart):
|
||||
if kind == "front":
|
||||
intents.extend(_revolved_axial(part, name))
|
||||
intents.extend(_revolved_diameters(part))
|
||||
elif kind == "side":
|
||||
last = len(part.segments) - 1
|
||||
near = part.segments[last]
|
||||
radius = near.d_outer / 2.0
|
||||
intents.append(
|
||||
DimIntent(
|
||||
kind="diameter",
|
||||
p1=(-radius, 0.0),
|
||||
p2=(radius, 0.0),
|
||||
feature=segment_key(last),
|
||||
)
|
||||
)
|
||||
if near.d_inner > _EPS:
|
||||
bore = near.d_inner / 2.0
|
||||
intents.append(
|
||||
DimIntent(
|
||||
kind="diameter",
|
||||
p1=(-bore, 0.0),
|
||||
p2=(bore, 0.0),
|
||||
feature=segment_key(last, bore=True),
|
||||
)
|
||||
)
|
||||
elif isinstance(part, PrismaticPart):
|
||||
x0, y0, x1, y1 = outline_bbox(part)
|
||||
if kind == "front":
|
||||
intents.append(DimIntent(kind="linear", p1=(x0, y0), p2=(x1, y0), feature="part"))
|
||||
intents.append(DimIntent(kind="linear", p1=(x0, y0), p2=(x0, y1), feature="part"))
|
||||
else:
|
||||
# An edge view sees the thickness, measured at the near edge of
|
||||
# whichever outline axis this view runs along.
|
||||
u0 = y0 if kind == "side" else x0
|
||||
intents.append(
|
||||
DimIntent(
|
||||
kind="linear",
|
||||
p1=(u0, 0.0),
|
||||
p2=(u0, float(part.thickness)),
|
||||
feature="part",
|
||||
)
|
||||
)
|
||||
|
||||
groups = _hole_groups(part)
|
||||
total_holes = sum(len(holes) for holes in groups.values())
|
||||
table_ids: frozenset[str] = frozenset()
|
||||
if total_holes > int(hole_table_threshold):
|
||||
table_ids = frozenset(hole.id for holes in groups.values() for hole in holes)
|
||||
intents = [i for i in intents if i.feature not in table_ids]
|
||||
|
||||
collected = _mark_references(list(_dedupe(intents)))
|
||||
return tuple(_apply_fits(collected, fits, table_ids=table_ids))
|
||||
|
||||
|
||||
def hole_table_rows(part, view, *, hole_table_threshold: int = 8) -> tuple[dict, ...]:
|
||||
"""One row per hole, tagged and counted by group - empty under the threshold.
|
||||
|
||||
``view`` is part of the pinned signature and is accepted unused, so a later
|
||||
view-specific filter (a hole that does not appear in this view) has one
|
||||
place to go.
|
||||
"""
|
||||
del view
|
||||
groups = _hole_groups(part)
|
||||
total = sum(len(holes) for holes in groups.values())
|
||||
if total <= int(hole_table_threshold):
|
||||
return ()
|
||||
rows: list[dict] = []
|
||||
for index, key in enumerate(sorted(groups, key=lambda k: (k[0], k[1]))):
|
||||
tag = chr(ord("A") + index)
|
||||
holes = groups[key]
|
||||
diameter, thread, cbore, depth = key
|
||||
note_parts = []
|
||||
if thread:
|
||||
note_parts.append(str(thread))
|
||||
if cbore is not None:
|
||||
note_parts.append(f"c'bore ⌀{cbore:g}")
|
||||
if depth is not None:
|
||||
note_parts.append(f"deep {depth:g}")
|
||||
note = " ".join(note_parts)
|
||||
for hole in holes:
|
||||
rows.append(
|
||||
{
|
||||
"tag": tag,
|
||||
"x": float(hole.x),
|
||||
"y": float(hole.y),
|
||||
"d": float(diameter),
|
||||
"qty": len(holes),
|
||||
"note": note,
|
||||
}
|
||||
)
|
||||
return tuple(rows)
|
||||
|
||||
|
||||
# -- the layout --------------------------------------------------------------
|
||||
|
||||
|
||||
def _axis_of(intent: DimIntent) -> str:
|
||||
""" "bottom" (u = x, level moves -y), "left" (u = y, level moves -x) or "right"."""
|
||||
if intent.kind in ("diameter", "radius", "angular"):
|
||||
return "right"
|
||||
dx = abs(intent.p2[0] - intent.p1[0])
|
||||
dy = abs(intent.p2[1] - intent.p1[1])
|
||||
return "bottom" if dx >= dy else "left"
|
||||
|
||||
|
||||
def layout_dimensions(
|
||||
view, intents, *, offset: float = 10.0, step: float = 8.0, height: float = 3.5
|
||||
) -> tuple[DimIntent, ...]:
|
||||
"""Give every intent a ``text_at`` such that no two text rectangles overlap."""
|
||||
x0, y0, x1, y1 = view.bbox
|
||||
del y1
|
||||
|
||||
# Measure first: a level's pitch has to clear the widest text that stacks
|
||||
# across it, and that is the text WIDTH on the left/right axes, where the
|
||||
# levels march in x, not the text height.
|
||||
measured: list[tuple[DimIntent, str, float, float]] = []
|
||||
across: dict[str, float] = {}
|
||||
for intent in intents:
|
||||
axis = _axis_of(intent)
|
||||
width, tall = _text_size(intent, height)
|
||||
# ``along`` is the extent parallel to the level, ``across`` the extent
|
||||
# perpendicular to it - the one successive levels have to clear.
|
||||
along, over = (width, tall) if axis == "bottom" else (tall, width)
|
||||
measured.append((intent, axis, along, over))
|
||||
across[axis] = max(across.get(axis, 0.0), over)
|
||||
pitch = {axis: max(float(step), value + 1.0) for axis, value in across.items()}
|
||||
|
||||
occupied: dict[tuple[str, int], list[tuple[float, float]]] = {}
|
||||
placed: list[DimIntent] = []
|
||||
|
||||
for intent, axis, along, _over in measured:
|
||||
if axis == "bottom":
|
||||
center = (float(intent.p1[0]) + float(intent.p2[0])) / 2.0
|
||||
else:
|
||||
center = (float(intent.p1[1]) + float(intent.p2[1])) / 2.0
|
||||
span = (center - along / 2.0, center + along / 2.0)
|
||||
|
||||
level = 0
|
||||
while True:
|
||||
taken = occupied.setdefault((axis, level), [])
|
||||
if all(span[1] <= a + 1e-9 or span[0] >= b - 1e-9 for a, b in taken):
|
||||
taken.append(span)
|
||||
break
|
||||
level += 1
|
||||
|
||||
distance = float(offset) + level * pitch[axis]
|
||||
if axis == "bottom":
|
||||
text_at = (center, y0 - distance)
|
||||
elif axis == "left":
|
||||
text_at = (x0 - distance, center)
|
||||
else:
|
||||
text_at = (x1 + distance, center)
|
||||
placed.append(intent._replace(text_at=text_at))
|
||||
|
||||
return tuple(placed)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}
|
||||
@@ -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 "
|
||||
|
||||
@@ -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)})."
|
||||
)
|
||||
@@ -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)})."
|
||||
)
|
||||
@@ -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)}).")
|
||||
@@ -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"),)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""DIN 509 relief grooves (Freistiche), forms E and F.
|
||||
|
||||
SOURCE
|
||||
------
|
||||
DIN 509:1998-06, *Technische Zeichnungen - Freistiche*, Table 1 (forms E and F).
|
||||
|
||||
COVERAGE
|
||||
--------
|
||||
**No rows are transcribed in this build.** The track's table rule is absolute:
|
||||
a row that cannot be verified against the standard's own table is not shipped,
|
||||
because a wrong relief depth is a wrong workshop drawing while a narrow table
|
||||
is merely narrow. Every lookup here is therefore refused by name and names the
|
||||
route to fix it.
|
||||
|
||||
Extending this module is a **data edit with no code change**: fill ``ROWS_E`` /
|
||||
``ROWS_F`` with ``{shaft_diameter_mm: {"profile": [[along, depth], ...]}}`` and
|
||||
widen ``COVERAGE_E`` / ``COVERAGE_F`` to the transcribed range. ``profile`` is
|
||||
the standard's own published relief profile expressed as (distance along the
|
||||
axis from the shoulder corner, depth below the smaller cylinder) pairs in
|
||||
millimetres - the same ``profile`` key group S's ``std_feature_draw`` reads -
|
||||
so nothing downstream ever reconstructs a profile it was not handed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from engineering.mech.standards import Coverage, register
|
||||
|
||||
SOURCE = "DIN 509:1998-06, Table 1 (forms E and F) - NOT TRANSCRIBED in this build"
|
||||
|
||||
#: The keys every DIN 509 row must carry for the drawing code to use it.
|
||||
UNDERCUT_ROW_KEYS: tuple[str, ...] = ("profile",)
|
||||
|
||||
FORMS: tuple[str, ...] = ("E", "F")
|
||||
|
||||
ROWS_E: dict[float, dict] = {}
|
||||
ROWS_F: dict[float, dict] = {}
|
||||
|
||||
COVERAGE_E = Coverage(0.0, 0.0, "mm")
|
||||
COVERAGE_F = Coverage(0.0, 0.0, "mm")
|
||||
|
||||
register("DIN 509-E", ROWS_E, COVERAGE_E, SOURCE)
|
||||
register("DIN 509-F", ROWS_F, COVERAGE_F, SOURCE)
|
||||
|
||||
_ROWS = {"E": ROWS_E, "F": ROWS_F}
|
||||
|
||||
|
||||
def undercut_profile(form: str, d: float) -> tuple[tuple[float, float], ...]:
|
||||
"""The published relief profile for a shaft diameter, or a named refusal."""
|
||||
key = str(form or "").strip().upper()
|
||||
if key not in FORMS:
|
||||
raise ValueError(f"DIN 509 defines forms E and F; got {form!r}.")
|
||||
rows = _ROWS[key]
|
||||
if not rows:
|
||||
raise ValueError(
|
||||
f"DIN 509 form {key}: no rows are transcribed in this build, so no relief "
|
||||
f"profile can be looked up for d = {float(d):g} mm. Pass the profile "
|
||||
"explicitly (Undercut(profile=[[along, depth], ...])), or transcribe "
|
||||
f"{SOURCE.split(' - ')[0]} into engineering/mech/standards/undercuts.py. "
|
||||
"Nothing is interpolated and no profile is invented."
|
||||
)
|
||||
size = float(d)
|
||||
if size in rows:
|
||||
return tuple(tuple(float(v) for v in pair) for pair in rows[size]["profile"])
|
||||
raise ValueError(
|
||||
f"DIN 509 form {key}: d = {size:g} mm has no transcribed row "
|
||||
f"(transcribed sizes: {sorted(rows)})."
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 ───────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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")
|
||||
@@ -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]}]})
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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]}
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user