feat(mech): shared foundation - primitives, XDATA codec, standards registry, ISO 128-50 materials
engineering/mech/ opens with the four modules every track-B/G branch imports, so the four worktrees can be cut from this commit and none of them has to invent a name the others will conflict with. - primitives.py: the typed descriptions the pure modules emit (Line, Arc, Circle, Poly, HatchArea, Text, DimIntent) with bbox / translate / rotate / scale, and ROLE_LAYER - the one place an ISO 128 line role becomes a layer name, so no module hardcodes a layer. The arc bbox sweeps its quadrants: a 0-180 arc reports y=+r, not its endpoints, which is what keeps the view layout from stacking two views on top of each other. - xdata.py: the ACADMCP_MECH codec in Track A's shape - a 1001 application marker, 1000 chunks of at most 255 ASCII characters, the 16 KB per-entity ceiling enforced before anything is written, and a foreign application, a corrupt chunk stream, a wrong version or an unknown kind refused with ValueError instead of decoded into something plausible. - standards/__init__.py: the table registry. A size outside the coverage is refused against the coverage; a size inside it that is not a listed row is refused against the rows. Nothing is interpolated, an ambiguous variant is never resolved by taking the first row, and a registration whose SOURCE does not name the table it was transcribed from is refused. - standards/materials.py: the section-hatch map for nine materials. ISO 128-50 standardises only the general hatching (narrow continuous lines at 45 degrees) - that is acadiso.pat ANSI31 and what engineering/gear.py already draws for steel. The distinguishing symbols are ASME Y14.2M's section lining as shipped in AutoCAD 2026's acadiso.pat, read off the installed file; cast iron's departure from ASME and the absence of any mechanical symbol for concrete and wood are stated in the rows themselves. Angles are 0.0 because the patterns already carry 45 degrees, and the two architectural patterns' scales are derived from the ANSI31 line spacing and recomputed by the test rather than chosen. No tool is registered here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5
parent
a8482a5f0d
commit
2bb8044144
@@ -0,0 +1,136 @@
|
||||
"""Registry for the authored standards tables under ``engineering/mech/standards``.
|
||||
|
||||
Every table registers itself at import with the standard's name, its rows, the
|
||||
coverage it was transcribed for and a SOURCE line naming standard, edition and
|
||||
table. ``lookup`` returns a row or refuses by name: a size outside the coverage
|
||||
is refused against the coverage, and a size *inside* it that is not a listed row
|
||||
is refused against the rows. Nothing is ever interpolated — the rule
|
||||
`engineering/fits.py` (ISO 286) already follows, applied to every table here. A
|
||||
wrong row ships a wrong workshop drawing; a narrow table is merely narrow.
|
||||
|
||||
``rows`` maps a size key (a number in millimetres, or a designation like
|
||||
``"M12"``) to a row dict, or to a tuple of row dicts when the standard has
|
||||
variants at the same size (DIN 509 forms E and F, DIN 332 forms A/B/R). ``**kw``
|
||||
selects among those variants and is refused when it selects none or more than
|
||||
one — an ambiguous lookup never quietly picks the first row.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
class Coverage(NamedTuple):
|
||||
low: float
|
||||
high: float
|
||||
unit: str = "mm"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Table:
|
||||
standard: str
|
||||
rows: dict
|
||||
coverage: Coverage
|
||||
source: str
|
||||
|
||||
|
||||
_REGISTRY: dict[str, _Table] = {}
|
||||
|
||||
|
||||
def _key(value):
|
||||
if isinstance(value, bool):
|
||||
raise TypeError("standards: a size key cannot be a bool")
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
if isinstance(value, str):
|
||||
return value.strip().upper()
|
||||
raise TypeError(f"standards: a size key must be a number or a designation, got {value!r}")
|
||||
|
||||
|
||||
def _variants(value) -> tuple[dict, ...]:
|
||||
if isinstance(value, dict):
|
||||
return (dict(value),)
|
||||
if isinstance(value, (list, tuple)) and value and all(isinstance(v, dict) for v in value):
|
||||
return tuple(dict(v) for v in value)
|
||||
raise TypeError("standards: a row must be a dict, or a non-empty tuple of dicts for variants")
|
||||
|
||||
|
||||
def register(standard: str, rows: dict, coverage: Coverage, source: str) -> None:
|
||||
"""Add a table. Refuses a duplicate name and a SOURCE that names no table."""
|
||||
if not isinstance(standard, str) or not standard.strip():
|
||||
raise ValueError("standards.register: standard must be a non-empty name")
|
||||
if standard in _REGISTRY:
|
||||
raise ValueError(f"standards.register: {standard!r} is already registered")
|
||||
if not rows:
|
||||
raise ValueError(f"standards.register: {standard!r} registered no rows")
|
||||
if not isinstance(source, str) or "table" not in source.lower():
|
||||
raise ValueError(
|
||||
f"standards.register: {standard!r} needs a SOURCE naming the standard, its "
|
||||
"edition and the table its rows were transcribed from"
|
||||
)
|
||||
_REGISTRY[standard] = _Table(
|
||||
standard, {_key(k): _variants(v) for k, v in rows.items()}, coverage, source
|
||||
)
|
||||
|
||||
|
||||
def standards() -> tuple[str, ...]:
|
||||
return tuple(sorted(_REGISTRY))
|
||||
|
||||
|
||||
def _table(standard: str) -> _Table:
|
||||
table = _REGISTRY.get(standard)
|
||||
if table is None:
|
||||
known = ", ".join(standards()) or "(none registered)"
|
||||
raise ValueError(f"standards: {standard!r} is not registered; known: {known}")
|
||||
return table
|
||||
|
||||
|
||||
def coverage_of(standard: str) -> Coverage:
|
||||
return _table(standard).coverage
|
||||
|
||||
|
||||
def source_of(standard: str) -> str:
|
||||
return _table(standard).source
|
||||
|
||||
|
||||
def _num(value: float) -> str:
|
||||
return str(int(value)) if float(value).is_integer() else str(value)
|
||||
|
||||
|
||||
def _listing(table: _Table) -> str:
|
||||
keys = sorted(table.rows, key=lambda k: (isinstance(k, str), k))
|
||||
shown = [k if isinstance(k, str) else _num(k) for k in keys[:12]]
|
||||
return ", ".join(shown) + (" ..." if len(keys) > 12 else "")
|
||||
|
||||
|
||||
def _kw_text(kw: dict) -> str:
|
||||
return ", ".join(f"{k}={v!r}" for k, v in sorted(kw.items()))
|
||||
|
||||
|
||||
def lookup(standard: str, size, **kw) -> dict:
|
||||
"""One row, or a ValueError naming the standard and what it does cover."""
|
||||
table = _table(standard)
|
||||
key = _key(size)
|
||||
rows = table.rows.get(key)
|
||||
if rows is None:
|
||||
if isinstance(key, float) and not (table.coverage.low <= key <= table.coverage.high):
|
||||
unit = table.coverage.unit
|
||||
raise ValueError(
|
||||
f"{standard} covers {_num(table.coverage.low)}-{_num(table.coverage.high)} "
|
||||
f"{unit}; {_num(key)} {unit} is outside the table"
|
||||
)
|
||||
raise ValueError(f"{standard} has no row for {size!r}; the table lists {_listing(table)}")
|
||||
matches = [row for row in rows if all(row.get(k) == v for k, v in kw.items())]
|
||||
if not matches:
|
||||
offered = sorted({str(row.get(k)) for row in rows for k in kw})
|
||||
raise ValueError(
|
||||
f"{standard} has no {_kw_text(kw)} row for {size!r}; it lists {', '.join(offered)}"
|
||||
)
|
||||
if len(matches) > 1:
|
||||
discriminators = sorted({k for row in rows for k in row} - set(kw))
|
||||
raise ValueError(
|
||||
f"{standard} row for {size!r} is ambiguous: {len(matches)} variants match; "
|
||||
f"narrow it with one of {', '.join(discriminators)}"
|
||||
)
|
||||
return dict(matches[0])
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Material -> section hatch (pattern, angle, scale).
|
||||
|
||||
SOURCE, stated exactly, because it is not one document:
|
||||
|
||||
* ISO 128-50:2020, clause 5 (representation of areas on cuts and sections)
|
||||
standardises the *general* hatching only — narrow continuous lines, drawn
|
||||
preferably at 45 degrees to the principal outline — and leaves distinguishing
|
||||
materials to other conventions. ``ANSI31`` in AutoCAD 2026's ``acadiso.pat``
|
||||
is exactly that line set (45 deg, 2.245 mm apart), and it is what
|
||||
``engineering/gear.py`` already draws for a steel section, so `steel` keeps
|
||||
it.
|
||||
* The distinguishing symbols are ASME Y14.2M's section-lining symbols as
|
||||
shipped in ``acadiso.pat``, whose own descriptions name the material — read
|
||||
off the installed file on this machine (AutoCAD 2026,
|
||||
``R25.1/enu/Support/acadiso.pat``): ``*ANSI32,ANSI Steel``,
|
||||
``*ANSI33,ANSI Bronze, Brass, Copper``, ``*ANSI34,ANSI Plastic, Rubber``,
|
||||
``*ANSI37,ANSI Lead, Zinc, Magnesium, Sound/Heat/Elec Insulation``,
|
||||
``*ANSI38,ANSI Aluminum``.
|
||||
* ``cast_iron`` is the one deliberate departure and says so in its own row:
|
||||
ASME's cast-iron symbol is ANSI31, which this map reserves for the ISO
|
||||
general hatching, so cast iron takes ANSI32 to stay *distinguishable* on the
|
||||
sheet. That is a repository convention, not a standard symbol — the material
|
||||
is identified by the parts-list entry, never by the pattern alone.
|
||||
* ``concrete`` and ``wood`` have no mechanical section symbol in either ISO
|
||||
128-50 or ASME Y14.2M. They use AutoCAD's own ``AR-CONC`` and ``JIS_WOOD``
|
||||
(``*JIS_WOOD, WOOD JIS A 0150``), declared as such in their source line.
|
||||
|
||||
``angle`` is the rotation *added* to the pattern, and it is 0.0 everywhere: the
|
||||
ANSI3x definitions already carry 45 degrees, so an angle of 45 would draw steel
|
||||
hatching vertically. ``scale`` is 1.0 for the ANSI family (one scaled set) and,
|
||||
for the two architectural patterns, is derived so the finest line in the
|
||||
pattern matches ANSI31's 2.245 mm spacing — AR-CONC is drawn at building scale
|
||||
and JIS_WOOD at 0.5 mm, so 1.0 would be unreadable on a 40 mm part. Both
|
||||
derived numbers are recomputed from ezdxf's own pattern definitions by
|
||||
``tests/test_mech_materials.py`` rather than trusted here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
MATERIAL_TABLE: dict[str, dict] = {
|
||||
"steel": {
|
||||
"pattern": "ANSI31",
|
||||
"angle": 0.0,
|
||||
"scale": 1.0,
|
||||
"source": (
|
||||
"ISO 128-50:2020 general hatching (45 deg narrow continuous lines) = acadiso.pat ANSI31"
|
||||
),
|
||||
},
|
||||
"cast_iron": {
|
||||
"pattern": "ANSI32",
|
||||
"angle": 0.0,
|
||||
"scale": 1.0,
|
||||
"source": (
|
||||
"acadiso.pat ANSI32 (ASME Y14.2M steel symbol); repository convention so cast "
|
||||
"iron stays distinguishable from the ISO general hatching reserved for steel"
|
||||
),
|
||||
},
|
||||
"aluminium": {
|
||||
"pattern": "ANSI38",
|
||||
"angle": 0.0,
|
||||
"scale": 1.0,
|
||||
"source": "ASME Y14.2M section lining = acadiso.pat *ANSI38,ANSI Aluminum",
|
||||
},
|
||||
"copper_alloy": {
|
||||
"pattern": "ANSI33",
|
||||
"angle": 0.0,
|
||||
"scale": 1.0,
|
||||
"source": "ASME Y14.2M section lining = acadiso.pat *ANSI33,ANSI Bronze, Brass, Copper",
|
||||
},
|
||||
"plastic": {
|
||||
"pattern": "ANSI34",
|
||||
"angle": 0.0,
|
||||
"scale": 1.0,
|
||||
"source": "ASME Y14.2M section lining = acadiso.pat *ANSI34,ANSI Plastic, Rubber",
|
||||
},
|
||||
"rubber": {
|
||||
"pattern": "ANSI34",
|
||||
"angle": 0.0,
|
||||
"scale": 1.0,
|
||||
"source": "ASME Y14.2M section lining = acadiso.pat *ANSI34,ANSI Plastic, Rubber",
|
||||
},
|
||||
"concrete": {
|
||||
"pattern": "AR-CONC",
|
||||
"angle": 0.0,
|
||||
"scale": 0.392,
|
||||
"source": (
|
||||
"no mechanical section symbol in ISO 128-50 or ASME Y14.2M; acadiso.pat "
|
||||
"*AR-CONC, random dot and stone pattern, scaled to the ANSI31 line spacing"
|
||||
),
|
||||
},
|
||||
"wood": {
|
||||
"pattern": "JIS_WOOD",
|
||||
"angle": 0.0,
|
||||
"scale": 4.49,
|
||||
"source": (
|
||||
"no mechanical section symbol in ISO 128-50 or ASME Y14.2M; acadiso.pat "
|
||||
"*JIS_WOOD, WOOD JIS A 0150, scaled to the ANSI31 line spacing"
|
||||
),
|
||||
},
|
||||
"insulation": {
|
||||
"pattern": "ANSI37",
|
||||
"angle": 0.0,
|
||||
"scale": 1.0,
|
||||
"source": (
|
||||
"ASME Y14.2M section lining = acadiso.pat *ANSI37,ANSI Lead, Zinc, Magnesium, "
|
||||
"Sound/Heat/Elec Insulation"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
MATERIALS: tuple[str, ...] = tuple(MATERIAL_TABLE)
|
||||
|
||||
#: Spellings a caller will reasonably type, mapped to a canonical name.
|
||||
ALIASES: dict[str, str] = {
|
||||
"aluminum": "aluminium",
|
||||
"alu": "aluminium",
|
||||
"brass": "copper_alloy",
|
||||
"bronze": "copper_alloy",
|
||||
"copper": "copper_alloy",
|
||||
"cast_steel": "steel",
|
||||
"grey_cast_iron": "cast_iron",
|
||||
"gg": "cast_iron",
|
||||
"ductile_iron": "cast_iron",
|
||||
"polymer": "plastic",
|
||||
"elastomer": "rubber",
|
||||
}
|
||||
|
||||
|
||||
def _canonical(material: str) -> str:
|
||||
if not isinstance(material, str):
|
||||
raise TypeError(f"material must be a name, got {type(material).__name__}")
|
||||
name = material.strip().lower().replace("-", "_").replace(" ", "_")
|
||||
name = ALIASES.get(name, name)
|
||||
if name not in MATERIAL_TABLE:
|
||||
raise ValueError(
|
||||
f"unknown material {material!r}; the ISO 128-50 section-hatch map covers "
|
||||
f"{', '.join(MATERIALS)}"
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def hatch_for(material: str) -> dict:
|
||||
"""``{"pattern", "angle", "scale"}`` for a material. Unknown names are refused."""
|
||||
row = MATERIAL_TABLE[_canonical(material)]
|
||||
return {"pattern": row["pattern"], "angle": row["angle"], "scale": row["scale"]}
|
||||
Reference in New Issue
Block a user