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>
|
</div>
|
||||||
|
|
||||||
> **v1.5 release snapshot:** 204 tools · 8 resources · 5 prompt templates · 3284 collected tests.
|
> **v1.5 release snapshot:** 210 tools · 8 resources · 5 prompt templates · 3284 collected tests.
|
||||||
> 204 is the **registered** count; a default install advertises 199 over `tools/list`,
|
> 210 is the **registered** count; a default install advertises 205 over `tools/list`,
|
||||||
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
||||||
|
|
||||||
## Why this exists
|
## 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) |
|
| 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 |
|
| 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.
|
**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",
|
"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,
|
"schema_version": 1,
|
||||||
"package_version": "1.5.1",
|
"package_version": "1.5.1",
|
||||||
"totals": {
|
"totals": {
|
||||||
"tools": 204,
|
"tools": 210,
|
||||||
"resources": 8,
|
"resources": 8,
|
||||||
"prompts": 5,
|
"prompts": 5,
|
||||||
"groups": 23
|
"groups": 24
|
||||||
},
|
},
|
||||||
"groups": {
|
"groups": {
|
||||||
"analysis": 12,
|
"analysis": 12,
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"environment": 22,
|
"environment": 22,
|
||||||
"layers": 14,
|
"layers": 14,
|
||||||
"layouts": 12,
|
"layouts": 12,
|
||||||
|
"mech": 6,
|
||||||
"page_setup": 6,
|
"page_setup": 6,
|
||||||
"pid": 9,
|
"pid": 9,
|
||||||
"premium": 12,
|
"premium": 12,
|
||||||
@@ -1968,6 +1969,90 @@
|
|||||||
"readOnlyHint": false
|
"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",
|
"name": "mleaderstyle_create",
|
||||||
"group": "styles",
|
"group": "styles",
|
||||||
|
|||||||
@@ -6,6 +6,73 @@ and alphabetical — keep every side's names.
|
|||||||
|
|
||||||
from __future__ import annotations
|
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 (
|
from .primitives import (
|
||||||
ROLE_LAYER,
|
ROLE_LAYER,
|
||||||
ROLES,
|
ROLES,
|
||||||
@@ -26,24 +93,121 @@ from .primitives import (
|
|||||||
scale,
|
scale,
|
||||||
translate,
|
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__ = [
|
__all__ = [
|
||||||
"ROLES",
|
"ANCHOR_LAYER",
|
||||||
"ROLE_LAYER",
|
|
||||||
"Arc",
|
"Arc",
|
||||||
|
"AxialBore",
|
||||||
|
"Band",
|
||||||
|
"BendLine",
|
||||||
|
"CIRCLE_SEGMENTS",
|
||||||
|
"CentreHole",
|
||||||
|
"Chamfer",
|
||||||
"Circle",
|
"Circle",
|
||||||
|
"CornerChamfer",
|
||||||
|
"CornerFillet",
|
||||||
|
"CrossHole",
|
||||||
|
"DIM_STYLES",
|
||||||
"DimIntent",
|
"DimIntent",
|
||||||
|
"FEATURE_KINDS",
|
||||||
|
"Feature",
|
||||||
|
"Fillet",
|
||||||
|
"GearTeeth",
|
||||||
"HatchArea",
|
"HatchArea",
|
||||||
|
"Hole",
|
||||||
|
"HoleCircle",
|
||||||
|
"HolePattern",
|
||||||
|
"Keyway",
|
||||||
"Line",
|
"Line",
|
||||||
|
"ORingGroove",
|
||||||
|
"PART_KINDS",
|
||||||
|
"PROJECTIONS",
|
||||||
|
"Pocket",
|
||||||
"Poly",
|
"Poly",
|
||||||
"Prim",
|
"Prim",
|
||||||
|
"PrismaticPart",
|
||||||
"Pt",
|
"Pt",
|
||||||
|
"REFERENCE_FORMAT",
|
||||||
|
"ROLES",
|
||||||
|
"ROLE_LAYER",
|
||||||
|
"RetainingGroove",
|
||||||
|
"RevolvedPart",
|
||||||
"Role",
|
"Role",
|
||||||
|
"SECTION_PLANE",
|
||||||
|
"SECTION_STYLES",
|
||||||
|
"Segment",
|
||||||
|
"Slot",
|
||||||
|
"TEXT_WIDTH_FACTOR",
|
||||||
|
"TOL_KEYS",
|
||||||
"Text",
|
"Text",
|
||||||
|
"Thread",
|
||||||
|
"Undercut",
|
||||||
|
"VIEW_KINDS",
|
||||||
|
"View",
|
||||||
|
"add_view",
|
||||||
"bbox",
|
"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",
|
"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",
|
"points_of",
|
||||||
|
"radial_profile",
|
||||||
|
"read_part",
|
||||||
|
"redundant_linear",
|
||||||
|
"resolve_tolerance",
|
||||||
"rotate",
|
"rotate",
|
||||||
"scale",
|
"scale",
|
||||||
|
"section_profile_prims",
|
||||||
|
"section_view",
|
||||||
|
"segment_bounds",
|
||||||
|
"segment_key",
|
||||||
|
"silhouette_prims",
|
||||||
|
"text_rects",
|
||||||
"translate",
|
"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``
|
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
|
selects among those variants and is refused when it selects none or more than
|
||||||
one — an ambiguous lookup never quietly picks the first row.
|
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
|
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")
|
raise ValueError("standards.register: standard must be a non-empty name")
|
||||||
if standard in _REGISTRY:
|
if standard in _REGISTRY:
|
||||||
raise ValueError(f"standards.register: {standard!r} is already registered")
|
raise ValueError(f"standards.register: {standard!r} is already registered")
|
||||||
if not rows:
|
if not isinstance(rows, dict):
|
||||||
raise ValueError(f"standards.register: {standard!r} registered no rows")
|
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():
|
if not isinstance(source, str) or "table" not in source.lower():
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"standards.register: {standard!r} needs a SOURCE naming the standard, its "
|
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".
|
# entity/drawing tags. Tools whose tags match none fall into "other".
|
||||||
_GROUP_TAG_PRIORITY = (
|
_GROUP_TAG_PRIORITY = (
|
||||||
"pid",
|
"pid",
|
||||||
|
"mech",
|
||||||
"style",
|
"style",
|
||||||
"pagesetup",
|
"pagesetup",
|
||||||
"environment",
|
"environment",
|
||||||
@@ -718,6 +719,7 @@ _GROUP_TAG_PRIORITY = (
|
|||||||
# Map the winning tag to a human-readable group label for the breakdown.
|
# Map the winning tag to a human-readable group label for the breakdown.
|
||||||
_GROUP_TAG_LABELS = {
|
_GROUP_TAG_LABELS = {
|
||||||
"pid": "pid",
|
"pid": "pid",
|
||||||
|
"mech": "mech",
|
||||||
"style": "styles",
|
"style": "styles",
|
||||||
"pagesetup": "page_setup",
|
"pagesetup": "page_setup",
|
||||||
"environment": "environment",
|
"environment": "environment",
|
||||||
@@ -960,7 +962,7 @@ SOLID_TOOL_NAMES = frozenset(
|
|||||||
# mechanically should not pay for the P&ID surface, and a client that never
|
# 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
|
# 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.
|
# 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]] = {
|
PACK_TOOL_NAMES: dict[str, frozenset[str]] = {
|
||||||
"pid": frozenset(
|
"pid": frozenset(
|
||||||
{
|
{
|
||||||
@@ -1002,6 +1004,17 @@ PACK_TOOL_NAMES: dict[str, frozenset[str]] = {
|
|||||||
"system_prompt_message",
|
"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
|
_active_tool_profile: dict | None = None
|
||||||
@@ -8123,6 +8136,437 @@ async def drawing_properties_set(
|
|||||||
return await _backend(ctx).drawing_properties_set(summary, custom)
|
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 ───────────────────────────────────────────────────────────────
|
# ── 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")
|
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():
|
def test_registering_the_same_standard_twice_is_refused():
|
||||||
with pytest.raises(ValueError, match="already registered"):
|
with pytest.raises(ValueError, match="already registered"):
|
||||||
register("TEST 1", {10: {"d": 10.0}}, Coverage(10.0, 10.0), SOURCE)
|
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",
|
"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)
|
@pytest_asyncio.fixture(autouse=True)
|
||||||
@@ -97,8 +106,9 @@ async def _restore(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_pack_registry_names_real_tools_and_only_them():
|
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["pid"] == frozenset(PID_TOOLS)
|
||||||
|
assert server.PACK_TOOL_NAMES["mech"] == frozenset(MECH_TOOLS)
|
||||||
assert server.PACK_TOOL_NAMES["settings"] == frozenset(SETTINGS_TOOLS)
|
assert server.PACK_TOOL_NAMES["settings"] == frozenset(SETTINGS_TOOLS)
|
||||||
assert len(SETTINGS_TOOLS) == 22
|
assert len(SETTINGS_TOOLS) == 22
|
||||||
assert not (server.PACK_TOOL_NAMES["settings"] & server.PACK_TOOL_NAMES["pid"])
|
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")
|
info = await server._apply_tool_profile("full")
|
||||||
disabled = set(info["disabled_tools"])
|
disabled = set(info["disabled_tools"])
|
||||||
assert not ((PID_TOOLS | SETTINGS_TOOLS) & disabled)
|
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(
|
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["styles"]) == STYLE_TOOLS
|
||||||
assert set(groups["page_setup"]) == PAGE_SETUP_TOOLS
|
assert set(groups["page_setup"]) == PAGE_SETUP_TOOLS
|
||||||
assert set(groups["environment"]) == SETTINGS_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():
|
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
|
merge task tagged them, the four groups had filed those 28 tools under
|
||||||
`layouts` / `templates` / `drawing` / `layers` / `view` / `system` by
|
`layouts` / `templates` / `drawing` / `layers` / `view` / `system` by
|
||||||
their secondary tags; those counts returned to their pre-track-E values
|
their secondary tags; those counts returned to their pre-track-E values
|
||||||
when the section tags landed.) Every other number here has been unchanged
|
when the section tags landed.) `mech` appeared (0 -> 6) when v1.6's
|
||||||
since the snapshot was taken.
|
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()}
|
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||||
assert sizes == {
|
assert sizes == {
|
||||||
@@ -545,6 +552,7 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
"environment": 22,
|
"environment": 22,
|
||||||
"layers": 14,
|
"layers": 14,
|
||||||
"layouts": 12,
|
"layouts": 12,
|
||||||
|
"mech": 6,
|
||||||
"page_setup": 6,
|
"page_setup": 6,
|
||||||
"pid": 9,
|
"pid": 9,
|
||||||
"premium": 12,
|
"premium": 12,
|
||||||
@@ -556,4 +564,4 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
"validation": 1,
|
"validation": 1,
|
||||||
"view": 4,
|
"view": 4,
|
||||||
}
|
}
|
||||||
assert sum(sizes.values()) == 204
|
assert sum(sizes.values()) == 210
|
||||||
|
|||||||
Reference in New Issue
Block a user