Files
autocad-mcp-pro/engineering/mech/xdata.py
T
Umut CANandClaude Opus 5 2bb8044144 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
2026-09-22 17:04:59 +03:00

98 lines
4.0 KiB
Python

"""``ACADMCP_MECH`` XDATA: the part model written onto the drawing.
The Track A codec (`engineering/pid/xdata.py`) with its limits made explicit.
XDATA strings cap at 255 characters and an entity carries at most 16 KB across
every application, so a payload is JSON-serialised ASCII-only and split into
1000 chunks under one 1001 application marker. `json.dumps(ensure_ascii=True)`
escapes every newline, so no chunk can carry a line separator into the DXF.
A payload that is not ours is *refused*, never decoded into something
plausible: a foreign application marker, a chunk stream that is not JSON, a
version this build does not read, or a kind outside `KINDS`. Three shapes are
written, and both the writer and every reader test them literally:
{"v": 1, "kind": "part", "id": "<hex>", "view": "front", "part": {...}}
{"v": 1, "kind": "std_part", "designation": "ISO 4014 - M12x60",
"standard": "ISO 4014", "size": "M12x60", "qty": 1, "material": "8.8"}
{"v": 1, "kind": "balloon", "item": 3, "targets": ["1A2B", ...]}
"""
from __future__ import annotations
import json
from collections.abc import Sequence
from backends.xdata_specs import MAX_BYTES, app_size
APP_ID = "ACADMCP_MECH"
PAYLOAD_VERSION = 1
CHUNK = 255
KINDS: tuple[str, ...] = ("part", "std_part", "balloon")
def _validate(payload: dict) -> dict:
if not isinstance(payload, dict):
raise TypeError(f"{APP_ID}: a payload must be a dict, got {type(payload).__name__}")
version = payload.get("v", PAYLOAD_VERSION)
if version != PAYLOAD_VERSION:
raise ValueError(
f"{APP_ID}: payload version {version!r}; this build writes and reads "
f"version {PAYLOAD_VERSION}"
)
kind = payload.get("kind")
if kind not in KINDS:
raise ValueError(f"{APP_ID}: unknown payload kind {kind!r}; kinds are {', '.join(KINDS)}")
return {**payload, "v": PAYLOAD_VERSION}
def encode(payload: dict) -> list[tuple[int, str]]:
"""``[(1001, APP_ID), (1000, chunk), ...]`` — refused before anything is written."""
checked = _validate(payload)
text = json.dumps(checked, separators=(",", ":"), sort_keys=True, ensure_ascii=True)
rows: list[tuple[int, str]] = [(1001, APP_ID)]
rows.extend((1000, text[i : i + CHUNK]) for i in range(0, len(text), CHUNK))
size = app_size(APP_ID, [(code, value) for code, value in rows if code != 1001])
if size > MAX_BYTES:
raise ValueError(
f"{APP_ID}: payload encodes to {size} bytes; the limit is 16 KB per entity "
"across every application"
)
return rows
def to_values(payload: dict) -> list[str]:
"""Just the chunk strings — what ``entity_set_xdata(handle, APP_ID, values)`` takes."""
return [value for code, value in encode(payload) if code == 1000]
def decode(rows: Sequence) -> dict:
"""``rows`` may be ``(code, value)`` pairs or the bare value list a backend
returns from ``entity_get_xdata``. Raises ValueError on anything foreign."""
if not rows:
raise ValueError(f"{APP_ID}: empty XDATA")
parts: list[str] = []
for row in rows:
if isinstance(row, str):
parts.append(row)
continue
try:
code, value = row
except (TypeError, ValueError):
raise ValueError(f"{APP_ID}: {row!r} is not an XDATA (code, value) row") from None
if code == 1001:
if str(value) != APP_ID:
raise ValueError(
f"{APP_ID}: this XDATA belongs to application {value!r}, not to us"
)
continue
if code != 1000:
raise ValueError(f"{APP_ID}: group code {code} is not a payload chunk")
parts.append(str(value))
try:
payload = json.loads("".join(parts))
except json.JSONDecodeError as exc:
raise ValueError(f"{APP_ID}: the chunk stream is not JSON ({exc.msg})") from exc
if not isinstance(payload, dict):
raise ValueError(f"{APP_ID}: the payload decoded to {type(payload).__name__}, not a dict")
return _validate(payload)