Skip to content

Release 1.1.0 — metadata v1.1 (@meta/@ui), mechatron assembly integration, pure-python core split#43

Merged
rdevaul merged 28 commits into
mainfrom
feature/metadata-v1.1
Jul 10, 2026
Merged

Release 1.1.0 — metadata v1.1 (@meta/@ui), mechatron assembly integration, pure-python core split#43
rdevaul merged 28 commits into
mainfrom
feature/metadata-v1.1

Conversation

@rdevaul

@rdevaul rdevaul commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Next release version of yapCAD with substantial extensions to DSL functionality and BREP operations support, with DSL extensions to support annotation for assembly/mating metadata.

Highlights

  • @meta / @ui decorators — declarative command metadata (design/assembly/operation namespaces) + parameter presentation hints
  • Mechatron assembly integration — assembly builtins, GraphSnapshot export, datum-driven mates, off-axis feature datums (origin_mm)
  • Assembly load cases + bolt patternsLoadCase/BoltPattern registries on Assembly + FEA setup bridge
  • Checker warnings W310/311/312 for unknown/malformed @meta keys; AST-level MetadataTransform validation
  • .meta.yaml sidecar exporter; Basic/Staged resolver registry; process-aware mechatron export
  • Pure-python core / BREP splitpip install yapcad works without conda; has_brep() capability gate; one-time import warning when OCC absent; brep/full extras; requires_occ pytest marker
  • Test repairs — 22 OCC-present failures fixed (pythonocc 7.7.x API skew + a silent dedup fallback in edge selection); removed a never-runnable vestigial test module
  • Docs — DSL guide, release-notes refresh, myst_parser build fixes

Validation

  • Full suite (OCC present): 1148 passed / 0 failed / 23 skipped
  • Pure-python lane (OCC blocked): all green
  • Clean-venv wheel install: import OK, BREP warning fires once, has_brep() → False, 2D geom works
  • twine check: PASSED for sdist + wheel
  • Internal-project reference scan: clean

Release plan after merge: tag v1.1.0 on the merge commit → GitHub release with CHANGELOG notes → PyPI upload (resumes the existing yapCAD PyPI project, last published 0.6.1).

Jarvis (DML) added 28 commits May 6, 2026 16:35
Implements step 1 of the metadata v1.1 rollout (Option A):

- Bump default schema string from metadata-namespace-v0.1 to v1.1 across
  metadata.py, geometry_json.py serializer, and assembly STYLE_GUIDE.md
- Add assembly namespace helpers (RFC § 7):
    get_assembly_metadata, set_assembly,
    add_bolt_pattern, add_datum, add_surface, add_keepout
- Add operation namespace helpers (RFC § 8):
    get_operation_metadata, set_operation
- Validate enum vocabularies at write time:
    joint_kind, bolt-pattern ring, datum/surface/keepout kinds,
    fastener thread/head, operation kind/policy/feature_kind
- Fix latent _ensure_root bug: empty dicts were being replaced rather
  than populated in place, causing namespace mutations to be lost. The
  pre-existing v1.0 helpers happened to dodge this because they always
  routed through get_solid_metadata/get_surface_metadata which kept the
  reference alive in the entity list slot. The new direct-on-dict
  callsites surfaced the bug.
- Consolidate duplicate __all__ exports (the second one was shadowing
  the first and dropping half the public surface).

Tests: 13 metadata tests pass (6 existing + 7 new for v1.1 round-trip,
validation, no-create semantics, coexistence). Full DSL+IO test suite
(260 tests across geometry_json, dsl_lexer, dsl_parser, dsl_runtime,
dsl_checker, dsl_packaging, metadata_utils) is green. The 22 unrelated
test failures in test_brep_edge_select and test_phase2_builtins
pre-exist on origin/main.

Deferred to follow-up commits: _provenance sidecar emitter, DSL
grammar wiring (@bolt_pattern, @cutter, etc.), pluggable resolver
entry points.
Brings the workbench-v2 @ui parser work onto feature/metadata-v1.1 as the
canonical foundation, cleaned up and fully documented.

## What changed

### Parser (src/yapcad/dsl/parser.py)
- _parse_parameter() now recognises an optional @ui(...) decorator between the
  type annotation and the default value.
- New _parse_param_ui_hint(): parses @ui(key=value, ...) kwargs into a plain
  dict of JSON-safe literals.  Raises a clear error if a non-'ui' name is used.
- New _ui_hint_value(): reduces literals, unary-minus, and list literals to
  Python scalars; stringifies non-literal expressions as a fallback.

### AST (src/yapcad/dsl/ast.py)
- Parameter gains ui_hint: Optional[dict] = None.
  Documented as evaluator-transparent (ignored by interpreter and type-checker).

### Service (service/)
- schemas.py: add DslUiEvalRequest / DslUiEvalResponse models.
- routes/dsl.py:
    - /commands now includes ui_hint in each param dict when present.
    - New /ui_eval endpoint: evaluates a command and returns its scalar/list
      result (for @ui-driven commands that emit derived values, not geometry).

### Docs (docs/dsl_reference.md)
- New 'Parameter Decorators' section with full @ui(...) reference:
  recognised keys (widget, label, snap, min, max, step, group), syntax
  example, service API shape, and /ui_eval description.
- Cross-reference added in the 'Escape Hatches' section.

### Tests (tests/test_dsl_parser.py)
- TestParameterUiHint: 7 cases covering basic parse, numeric bounds, negative
  min, plain params, mixed params, real mounting_plate_2d.dsl round-trip, and
  wrong decorator name rejection.  All pass.

## Design notes
- @ui is ONLY valid on command parameters, not on top-level function defs.
  Top-level decorators remain @native-only.
- The evaluator is untouched — ui_hint is viewer metadata, not geometry logic.
- This is the stable base for the Step 3 metadata annotation work: @meta(...)
  and assembly namespace helpers will be layered on top of this same decorator
  infrastructure.
Adds @meta(...) as the canonical way to attach assembly/operation namespace
metadata to a command definition.  Layered on top of the @ui parameter
decorator infrastructure from the previous commit.

## What changed

### AST (src/yapcad/dsl/ast.py)
- FunctionDef gains meta_hint: Optional[dict] = None
  Merged dict from all @meta decorators on the command; evaluator-transparent.

### Parser (src/yapcad/dsl/parser.py)
- _parse_meta_decorator(): parses @meta(key=value, ...) into a flat dict.
  Supports both plain keys (label) and dotted namespace paths
  (assembly.joint_kind, operation.feature_kind).
- _parse_meta_key(): consumes IDENTIFIER (DOT IDENTIFIER)* to produce the
  dot-joined key string.  Accepts type-keyword tokens (surface, solid, float,
  ...) as key segments via _consume_identifier_or_type_keyword().
- _consume_identifier_or_type_keyword(): helper shared by _parse_meta_key.
- Module-level decorator loop: peeks at the token after '@' to route @meta
  decorators to _parse_meta_decorator() and all others to _parse_decorator().
  Multiple @meta decorators are merged with dict.update() — later wins on
  collision.  Non-meta decorators (@Native etc.) are unaffected.
- _parse_function_def(): accepts meta_hint kwarg, stores in FunctionDef.
- _parse_command(): legacy command path stores meta_hint when provided.

### Service (service/routes/dsl.py)
- /commands endpoint includes meta_hint in the command descriptor when present.
  The AST serialiser (ast_to_dict) emits it automatically via dataclasses.fields.

### Docs (docs/dsl_reference.md)
- New @meta(...) subsection in 'Parameter Decorators':
  key syntax table, dotted vs flat examples, namespace convention table
  (assembly.* / operation.*), service API JSON shape, evaluator-transparent note.
- Cross-reference in 'Escape Hatches' updated to mention @meta alongside @ui.

### Tests (tests/test_dsl_parser.py)
- TestCommandMetaHint: 11 cases covering single dotted, stacked merge, flat keys,
  mixed flat+dotted, collision resolution (later wins), numeric/bool values,
  negative numerics, type-keyword key segments, no-meta=None, combined with @ui
  on params, and multi-command isolation.  All 11 pass.
- Full parser + metadata + geometry_json suite: 76 passed, 2 skipped, 0 failures.

## Design notes
- Flat storage (dotted strings, not nested dicts) — namespace routing delegated
  to v1.1 helpers (get_assembly_metadata, get_operation_metadata).
- @meta is ONLY on commands/defs, never on parameters (that is @ui's domain).
- @Native co-existence: the decorator loop correctly distinguishes @meta from
  @Native by peeking at the identifier token value before consuming.
- Pre-existing bug: @Native on 'def' (vs native python {} block) was already
  broken before this commit; not in scope here.
New module yapcad.dsl.meta_apply closes the loop between the @meta(...) DSL
decorator (flat dict of dotted keys) and the v1.1 metadata namespace helpers
in yapcad.metadata.

## What changed

### New module (src/yapcad/dsl/meta_apply.py)
- apply_meta_hint(solid, meta_hint): main entry point.  Retrieves the solid's
  metadata dict via get_solid_metadata(create=True), then routes each key:
    - assembly.*  → _apply_assembly() → set_assembly() for scalars, verbatim
                    storage for unknown fields, ValueError for list fields
                    (bolt_patterns / datums / surfaces / keepouts) that require
                    dedicated helpers.
    - operation.* → _apply_operation() — per-field dispatch with individual
                    enum validation; avoids the set_operation() required-kind
                    constraint so fields can be applied in any dict iteration
                    order.
    - layer/tags  → _apply_root(), with tag list-append and string-coerce.
    - plain keys  → _apply_root() verbatim.
    - unknown.*   → stored under that namespace dict for forward-compat.
- apply_meta_hint_to_raw(meta, meta_hint): convenience variant for callers
  that already have the metadata dict rather than the raw solid.
- Bool coercion: "true"/"1"/"yes" strings → True for no_cut/through/consume.
- target_filter: accepts list or comma-separated string.

### DSL init (src/yapcad/dsl/__init__.py)
- apply_meta_hint and apply_meta_hint_to_raw exported from yapcad.dsl.

### Docs (docs/dsl_reference.md)
- v1.1 enum vocabulary table added to @meta namespace section:
  assembly.joint_kind, operation.kind, operation.policy, operation.feature_kind.
- Notes that values outside the enum set raise ValueError at apply time.

### Tests (tests/test_dsl_meta_apply.py)
- 29 cases: assembly scalar routing, assembly list-field rejection, operation
  per-field routing with enum validation, root fields (layer/tags/free-form),
  unknown namespace verbatim storage, combined/stacked hint, idempotency,
  empty-hint noop, TypeError on bad input, enum rejection for both namespaces,
  apply_meta_hint_to_raw variants, and full DSL round-trip (parse @meta →
  apply_meta_hint → assert metadata).  All 29 pass.

## Design notes
- operation.* fields are dispatched individually rather than via set_operation()
  to avoid the required-kind contract; each field is validated independently.
- Structured assembly list fields (bolt_patterns etc.) intentionally not
  supported via flat @meta — they require the dedicated add_*() helpers.
- forward-compat: unknown dotted namespaces stored verbatim, not rejected.
Closes the gap left by the meta_apply v1 work — the four assembly
list-shaped fields (bolt_patterns, datums, surfaces, keepouts) used to
raise ValueError when set via @meta, with a 'use the dedicated helpers
directly' pointer. Now @meta dispatches each list entry to the
corresponding yapcad.metadata helper.

src/yapcad/dsl/parser.py:
- _ui_hint_value now recurses into DictLiteral (previously stringified).
  Required for list-of-dict @meta values to survive parsing with shape.
- _parse_meta_decorator tolerates NEWLINE tokens between args and
  honors trailing commas before the closing ')'. Multi-line @meta(...)
  with list-of-dict values would otherwise error at
  'expected key name in @meta, found RPAREN'.

src/yapcad/dsl/meta_apply.py:
- _ASSEMBLY_LIST_HELPERS dispatch table maps each list-shaped field
  to its dedicated helper (add_bolt_pattern / add_datum / add_surface /
  add_keepout).
- _apply_assembly_list_field walks each entry, validates it's a dict,
  and forwards as kwargs to the helper. TypeErrors from helpers are
  re-wrapped with field name + index so DSL authors can pinpoint
  typo'd keys ('assembly.bolt_patterns[2] rejected: …').
- _apply_assembly dispatches list-shaped fields through the new path.
  Old 'raise ValueError(...)' is gone. Helper-raised ValueErrors
  (enum validation, missing keys, vector lengths) propagate unchanged.
- Module docstring documents the supported list-of-dict syntax with
  an end-to-end example.

tests/test_dsl_meta_apply.py:
- Replaced obsolete test_assembly_list_field_raises with two new
  tests for the new type-checking contract.
- New TestApplyMetaHintAssemblyListFields class with 10 cases
  covering all four list fields, error paths, and mixed
  list+scalar+root in a single hint dict.
- New TestDslRoundTrip::test_parse_and_apply_list_fields — full DSL
  source → parse → meta_hint → apply round-trip with multi-line
  @meta args and trailing commas.

.gitignore: added workbench/node_modules/ (was being swept by git add -A).

Verification:
- tests/test_dsl_meta_apply.py: 41 passed (12 new)
- tests/ -k 'dsl': 314 passed, 2 skipped (0 regressions)
- tests/ -k 'metadata': 17 passed

What this unlocks: DSL surface for Phase 1 of yapcad-assembly-integration
is expressible without new keywords. The Phase 1 'datum' keyword from
the integration plan remains on the roadmap as sugar that compiles down
to this same @meta shape; the underlying capability is in.
Exposes yapcad.assembly to the DSL. Five new builtins land enough of
the Assembly surface for DSL authors to declare, validate, and report
on multi-part assemblies without dropping into Python:

  assembly(name)                                 -> assembly handle
  add_part(asm, solid, name)                     -> asm  (chainable)
  add_mate(asm, kind, part_a, datum_a,
           part_b, datum_b)                      -> asm  (chainable)
  validate_assembly(asm)                         -> bool
  assembly_report(asm)                           -> string

add_part is the key integration point with the v1.1 metadata layer
that landed in 7b301ed / dced15e:

  @meta(
      assembly.datums=[
          {"id": "shaft", "kind": "axis", "direction": [0,0,1]},
          {"id": "neck", "kind": "bolt_circle", "R_mm": 149.4,
           "z_mm": 317.3, "direction": [0,0,1]},
      ],
  )
  command FORWARD_BULKHEAD() -> solid: ...

  let bulkhead = FORWARD_BULKHEAD()
  let asm = assembly("forward_section")
  add_part(asm, bulkhead, "bulkhead")  # datums auto-lifted from @meta

add_part reads assembly.datums from the solid's metadata and converts
each entry into a Datum on the resulting PartDefinition:
- v1.1 kind        Datum.DatumType
  axis             AXIS
  plane            PLANE
  bolt_circle      CIRCLE         (radius = R_mm)
  point            POINT
  frame            FRAME
  edge             (skipped \u2014 no Datum equivalent)
- Origin: [R_mm, 0, z_mm, 1] in part-local coordinates
- Direction: from metadata 'direction' if present, else default +Z
  (Datum validates AXIS/PLANE/CIRCLE require a non-zero vector)

Files:

src/yapcad/dsl/types.py:
+ ASSEMBLY = GeometricPrimitiveType("assembly", dimension=None)
  Opaque handle type for Assembly instances. Treated as a top-tier
  type so it cannot be confused with SOLID/SURFACE/etc.

src/yapcad/dsl/runtime/values.py:
+ assembly_val(asm)
  Constructor wrapping an Assembly instance as a Value with type=ASSEMBLY.

src/yapcad/dsl/runtime/builtins.py:
+ _register_assembly_functions() invoked from _register_all().
+ Lazy imports yapcad.assembly.* inside the method so DSL runtime
  startup is unaffected when assembly builtins are unused.
+ Helpers _datum_from_metadata_entry, _part_def_from_solid build
  PartDefinition from v1.1 metadata. Robust to malformed entries
  (missing id, unsupported kind \u2014 entry skipped silently).
+ Five BuiltinFunction registrations.

tests/test_dsl_assembly_builtins.py (NEW, 394 lines, 18 tests):
- TestAssemblyBuiltin: constructor returns Assembly handle with name.
- TestAddPartBuiltin (7 tests): bare add_part, axis/plane/bolt_circle
  datum lifting, unsupported-kind skip, id-less skip, duplicate-name
  AssemblyError.
- TestAddMateBuiltin (4 tests): concentric/revolute kinds, invalid-kind
  error with valid-kinds list, error message contents.
- TestValidateAndReportBuiltins (3 tests): empty / single-part valid,
  report contains assembly name.
- TestAssemblyBuiltinsEndToEnd (2 tests):
    * Two-part concentric assembly built entirely through builtins
      (datums lifted from @meta, mate added, validate=True, report
      mentions both parts).
    * add_mate fails fast (AssemblyError) when referencing an unknown
      part \u2014 better than waiting for validate_assembly to flag it.

Verification:

  tests/test_dsl_assembly_builtins.py            18/18 pass (new)
  tests/test_dsl_meta_apply.py                   41/41 pass (existing)
  pytest -k 'dsl'                              332 passed, 2 skipped
                                               (+18 vs pre, 0 regressions)
  pytest -k 'metadata'                          18/18 pass (no regressions)
  pytest -k 'assembly and not dsl'              7 passed / 19 errors
                                               (errors are pre-existing in
                                                test_assembly_constraint_validator;
                                                verified by stashing my changes
                                                and re-running \u2014 same 19 errors
                                                on pristine main)

What this unlocks (Phase 2 of yapcad-assembly-integration.md):

A part DSL file can now declare datums via @meta, and an assembly
DSL file can pull those parts together with mates \u2014 all without
manual Python. Validate at DSL eval time, get a textual report for
debugging, fail fast on bad references.

Roadmap from here:
- Phase 1b: 'datum'/'assembly'/'mate' sugar keywords (parser-only
  work that lowers to @meta + the builtins above).
- Phase 3: emit_assembly(asm) -> JSON consumable by the Mechatron
  assembly-graph MCP on port 8081.
- Phase 4: WBS auto-generation walking the validated assembly graph.
- Phase 5: live collision check in build pipeline.
The file (391 lines, 19 tests) was committed as part of
7ac3ba6 'feat: add multi-body assembly constraint system' on
2026-02-13. Every test in the file imported
`robot_printer.kinematic_chain` / `robot_printer.assembly_constraint_validator`,
which was a separate project the original author was developing in
parallel to yapCAD \u2014 never part of this repo.

Consequence: every test in the file errored at collection time with
`ModuleNotFoundError: No module named 'robot_printer'` since the
commit landed, including on a pristine yapCAD checkout. The file was
not testing yapCAD's own assembly system (which has its own working
coverage in test_assembly*.py); it was testing a downstream consumer's
validator that imported the yapCAD primitives. With the consumer
project unavailable in-tree, the tests have never run and never can.

Deletion path verified:
- File is recoverable from git (7ac3ba6 and any preceding tag).
- yapCAD's assembly system has independent test coverage:
  test_assembly_assembly.py, test_assembly_datum.py,
  test_assembly_face_mate.py, test_assembly_kinematic_*.py
  (7 passing tests in the 'assembly and not dsl' filter today).
- Two example files (examples/assembly_complete_workflow.py,
  examples/datum_example.py) reference `robot_printer/...` paths in
  geometry_source strings, but those are descriptive only \u2014 they
  parse and run today and are NOT touched by this commit.

Net effect on pytest output:
  pytest -k 'assembly and not dsl'
    before: 7 passed, 19 errors
    after:  7 passed, 0 errors
…ration)

Round-trip-validated against Mechatron's AssemblyGraph::load_json
(crates/assembly-graph/src/graph.rs save_json/load_json codec).

New module: yapcad.assembly.mechatron_export
  - to_mechatron_snapshot(assembly) -> dict
  - to_mechatron_json(assembly, *, indent=2) -> str
  Maps yapCAD's Assembly/PartDefinition/Datum/Mate onto Mechatron's
  Part/Datum/Interface/Joint/MateConstraint schema, including:
    * PascalCase DatumType (axis -> Axis, plane -> Plane, ...)
    * PascalCase Material enum with PETG fallback
    * homogeneous [x,y,z,w] -> Point3D/Vec3D {x,y,z}
    * yapCAD MateType -> mechatron Joint variant for motion mates
      (revolute, prismatic, spherical, rigid)
    * yapCAD MateType -> mechatron MateConstraint variant for
      semantic mates (concentric, coincident, tangent, angle)
    * yapCAD mate kinds without a Mechatron equivalent (parallel,
      perpendicular, distance, planar, ...) are dropped from
      mate_constraints rather than emitting unloadable JSON

New DSL builtin:
  - emit_assembly(asm) -> string : Mechatron-shaped JSON

Cleanup: deleted examples/assembly_complete_workflow.py and
examples/datum_example.py — both depended on the never-imported
robot_printer module (vestigial Jeremy-era artifacts; matches the
similarly-vestigial test file removed in cff854a).

Tests: tests/test_assembly_mechatron_export.py (24 cases) covers:
  - empty assembly shape
  - part materials + insertion order
  - all DatumType -> PascalCase mappings
  - homogeneous coordinate drop
  - all motion-mate -> Joint mappings (rigid/revolute/prismatic/spherical)
  - all semantic-mate -> MateConstraint mappings (concentric/coincident/tangent)
  - unmappable mate kinds dropped
  - DSL builtin end-to-end via @meta(assembly.datums=[...])
  - deterministic output

Round-trip validation: a one-shot ignored cargo test (not committed)
loaded the exporter output through AssemblyGraph::load_json and
asserted parts/interfaces materialized correctly. The schema mismatch
between my initial draft (free-form {kind,...}) and Mechatron's
#[serde(tag=type)] MateConstraint shape was caught and fixed before
landing.

Full suite: 373 passed, 2 skipped.
Fills three gaps that surfaced when wiring the assembly-export pipeline
to a real DSL file (the Hydra modular-robot arm at
hydra/designs/arm-segmented/design-a/arm_segmented_a.dsl):

1. Type-checker registrations (src/yapcad/dsl/symbols.py +
   src/yapcad/dsl/types.py)
   The Phase 2 builtins (assembly, add_part, add_mate,
   validate_assembly, assembly_report) and the Phase 3 emit_assembly
   were only registered with the runtime BuiltinRegistry. The
   type-checker has its own parallel SymbolTable that drives DSL
   parse-time validation, and it had no idea those functions or the
   'assembly' type existed. Result: every DSL file that used them
   failed type-checking with E270/E299 errors before the runtime
   could ever run. Registered the 6 builtins and exposed the
   ASSEMBLY type via BUILTIN_TYPES so authors can declare
   'asm: assembly = assembly("...")' the same way they declare
   'x: solid = ...'. The Phase 2 builtins tests bypassed the parser
   by calling call_builtin() directly, which masked this gap.

2. @meta hint -> emitted-solid bridge wired in the runtime
   (src/yapcad/dsl/runtime/interpreter.py)
   Phase 1c added meta_apply.apply_meta_hint to translate a
   FunctionDef.meta_hint (the dict the parser builds from
   @meta(...) decorators) into the v1.1 metadata namespace on a
   solid, but the runtime interpreter never called it. Result:
   @meta(assembly.datums=[...]) on a command parsed and
   type-checked fine but produced solids with empty assembly
   metadata, so add_part() saw zero datums to lift. Added
   Interpreter._apply_command_meta_hint and invoked it from both
   the top-level execute() path and from _call_command() (so the
   metadata also reaches nested calls like
   add_part(asm, SHOULDER(...), 'shoulder')).

3. Mechatron joint axis derived from datums
   (src/yapcad/assembly/mechatron_export.py)
   The Phase 3 exporter emitted Joint::Revolute / Joint::Prismatic
   with a hard-coded +Z axis placeholder, on the (untested)
   assumption that Mechatron's solver would re-derive the axis from
   datum geometry. AssemblyGraph::load_json does NOT do that — it
   trusts the JSON. Added _resolve_mate_axis() to look up the
   parent-side datum's direction vector on the assembly and use it
   as the joint axis. Falls back to +Z when the datum lacks a
   direction (preserving prior behaviour for edge cases). With this
   in place, the Hydra elbow's X-axis revolute joint round-trips as
   Joint::Revolute { axis: [1.0, 0.0, 0.0] } through Mechatron.

End-to-end verification (one-shot, not committed to either repo):
  yapCAD DSL (HYDRA_ARM_ASSEMBLY) → emit_assembly → 6.7 KB JSON →
  Mechatron AssemblyGraph::load_json → 4 parts, 6 interfaces,
  1 Revolute joint on the elbow with the correct X-axis.

Existing tests: 373 passed (DSL + metadata + assembly), no
regressions. The 24 unit tests for mechatron_export still cover
the +Z fallback path.
…tor, geoms, AxisAlign, context-aware Concentric

Took the Phase-3 exporter from "loads in Mechatron with warnings" to
"validates clean and forward-kinematics correctly" using the Hydra
arm as the test bed. Five changes:

1. **Merge same-pair mates into one Interface**
   When DSL authors stack mates between the same parts (e.g. concentric
   + coincident to fully constrain a Fixed mount), Mechatron expects
   them on one Interface, not several. The exporter now groups by
   (parent_part, child_part) in original order, picks the first
   motion mate (REVOLUTE / PRISMATIC / SPHERICAL / RIGID) as the
   Joint, and emits the rest as mate_constraints. yapCAD's previous
   12-mate Hydra spec collapses from 6 interfaces to 3.

2. **insertion_vector derivation**
   Mechatron's validator errors when an interface has no
   insertion_vector. The exporter now looks at the parent-side datum
   referenced by Coincident-style mates; if it is a PLANE or CIRCLE,
   the insertion direction is taken as -normal ("the child moves
   opposite the parent face's outward normal to seat against it").
   Falls back to no field when no PLANE/CIRCLE datum is available;
   Mechatron will warn but not reject.

3. **PartGeom export via @meta(assembly.geoms=[...])**
   New side-channel on PartDefinition that add_part populates from
   the assembly metadata namespace. The exporter converts each entry
   to a Mechatron PartGeom (Box / Cylinder / Sphere / Capsule / Mesh),
   handling the millimetre → metre unit conversion (PartGeom is in
   metres per MuJoCo SI convention while datum positions are in mm).
   Eliminates the "no visual geometry" warnings; cylindrical Hydra
   modules render in the dashboard as 60mm-OD cylinders.

4. **PARALLEL → AxisAlign**
   yapCAD's PARALLEL MateType (accepts AXIS/PLANE datums) now maps to
   Mechatron's AxisAlign MateConstraint variant. AxisAlign has a
   unilateral shape (child_datum + target_axis, no parent_datum), so
   the exporter special-cases it and reads target_axis from the
   parent-side datum's direction vector.

5. **Context-aware CONCENTRIC mapping**
   This is the subtle one. Mechatron's mate-DOF accounting:
     - Concentric:     3T + 2R, NO composite rule fires
     - AxisCoincident: 2T + 2R, but composite rule "AxisCoincident +
       Coincident => +1R axial lock" promotes total rotational DOFs
       constrained from 2 to 3
   For a Fixed mount, we WANT the composite rule to fire (so the
   joint comes out fully constrained at 6/6 DOFs). For a Revolute
   joint, we DON'T want it (the joint needs exactly 1 free rotational
   DOF; 6/6 mate-locked + 1 joint DOF = over-constrained).
   The exporter now picks the variant per mate group:
     - When the joint is Fixed: CONCENTRIC -> AxisCoincident
     - When the joint is motion: CONCENTRIC -> Concentric
   joint_is_motion is plumbed from _interface_for_mate_group
   into _mate_constraint_for_mate to support this.

Tests: 29 unit cases (added 5, refactored 2). Full yapcad regression
still passes. End-to-end Hydra round trip:

  yapCAD DSL (HYDRA_ARM_ASSEMBLY)
    -> mechatron AssemblyGraph::load_json
    -> assembly.validate           valid:True, 0 structural issues
    -> assembly.solve_all_mates    converged:True
    -> assembly.forward_kinematics
       shoulder  @ z=0
       upper_arm @ z=120
       elbow     @ z=240
       forearm   @ z=302 (or rotated about flex_axis at z=279 when
                          flex != 0; forearm z-axis tracks the joint
                          angle correctly: 0deg=[0,0,1], 45deg=[0,-.707,.707],
                          90deg=[0,-1,0])

Hydra DSL changes live in
hydra/designs/arm-segmented/design-a/arm_segmented_a.dsl (not part of
this commit; the Hydra repo isn't versioned).
…ff-axis features

The axisymmetric (R_mm, z_mm) origin convention sufficed for cylindrical
parts (bulkheads, tank domes, the Hydra arm) but breaks down for parts
whose features aren't on a single radial plane:

  - pentagonal/hexagonal robot body modules with N face-attachment points
  - chassis with off-axis mount bosses
  - any non-axisymmetric part

Added an additive 'origin_mm: [x, y, z]' kwarg to:

  - yapcad.metadata.add_datum() — accepts + persists the value into
    assembly.datums entries
  - _datum_from_metadata_entry in dsl/runtime/builtins.py — when
    origin_mm is present, overrides the (R_mm, 0, z_mm) derivation

Backward compatible: existing R_mm/z_mm entries are unaffected. When
both are provided, origin_mm wins (lets authors keep R_mm for the
circle-radius role while overriding centre placement).

Driven by Rich's directive that the Hydra body — pentagonal prism with
7 connector-face datums (5 on the rectangular sides + 2 on the
pentagonal end caps) — be authored as @meta-driven DSL. The new helper
lets a single .dsl define all 7 mount points without falling back to
hand-coded Python.
… FDM

Parts with process='WAAM', 'CNC', 'Cast', etc. now export correctly.
Previously all parts were exported as process='FDM' regardless.

Fix: read part_def.process attribute if set, fall back to 'FDM'.
…v1.1)

Wire @meta(...) hint validation into _check_command. The checker now
emits W-series warnings (not hard errors, per the RFC forward-compat
clause) for:
  W310 — unknown top-level namespace in a @meta key
  W311 — unknown field within the assembly namespace
  W312 — unknown field within the operation namespace

Known fields are imported from meta_apply's private sets so the
validator and the runtime stay in sync automatically.

All 92 checker + meta_apply + metadata tests pass. Pre-existing
brep-edge failures are unrelated (present on main).
…malisation (Step 4 — v1.1)

Adds src/yapcad/dsl/transforms/metadata.py, an AstTransform that runs
*before* interpretation and validates/normalises every FunctionDef.meta_hint
dict against the v1.1 assembly/operation namespace schema.

What it does:
  1. Validates enum fields at AST time (operation.kind, operation.policy,
     operation.feature_kind, assembly.joint_kind) — errors surfaced before
     any solid is built.
  2. Normalises coercible values in place:
     - bool-as-string ('true'/'false') → real bool (no_cut, through, consume)
     - operation.priority → float
     - operation.target_filter string/csv → list
  3. Cross-field consistency checks:
     - operation namespace without operation.kind → error
     - assembly.no_cut=true + operation.kind='subtract' → warning (contradictory)
     - operation without target_filter → info (will apply to ALL parents)
  4. Unknown namespaces / unknown fields → warning (forward-compat; stored verbatim)
  5. Assembly list-of-dict fields (bolt_patterns, datums, surfaces, keepouts)
     get structural checks (must be list of dicts); per-entry validation
     remains with meta_apply.py helpers at runtime.

Diagnostics are stored on the Module object under _metadata_transform_diagnostics
(accessible via get_transform_diagnostics(module)) and logged via the
yapcad.dsl.transforms.metadata logger.

The transform is idempotent: running twice produces identical AST state.
Coercions write back into the live meta_hint dict so the runtime
apply_meta_hint call in the interpreter trusts the normalised values.

Also exports MetadataTransform, MetaDiagnostic, get_transform_diagnostics
from yapcad.dsl.transforms.__init__.

Tests: 65 tests in test_metadata_transform.py, all pass.
Combined with Steps 2+3: 157/157 metadata-layer tests green.
…v1.1)

- New src/yapcad/io/meta_yaml.py: dump_metadata_yaml(), load_metadata_yaml(), sidecar_path_for()
  - Writes <part>.meta.yaml alongside STEP/STL exports
  - _provenance block: yapcad_version, exported_at, source_dsl + sha256, source_command
  - No-op when part carries no metadata
  - Requires PyYAML (ImportError with install hint if absent)
- write_step_with_meta() in io/step.py — thin wrapper that calls dump_metadata_yaml after write_step
- write_stl_with_meta() in io/stl.py — same for STL
- Both exported from yapcad.io.__init__
- 15/15 tests green (round-trip, provenance sha256, backwards compat v1.0, path computation,
  exporter hooks, emit_sidecar=False skip, no-metadata no-op)
… — v1.1)

Implements RFC §16 pluggable assembly resolver strategy.

Changes:
- src/yapcad/assembly/resolver.py (new)
  - AssemblyResolver ABC with shared _collect_cutters/_validate_cutters/_apply_boolean helpers
  - BasicResolver: bucket → sort by priority → apply sequentially per target (spec §8.2)
  - StagedResolver: multi-pass resolver; operations tagged with stage= processed in
    declared order; operations missing stage= default to last stage
  - KinematicResolver: v1.2 placeholder (raises NotImplementedError)
  - RESOLVER_REGISTRY keyed by 'basic'/'staged'; external resolvers via
    yapcad.assembly_resolvers entry points (mirrors boolean/ENGINE_REGISTRY)
  - resolve_assembly() convenience function; YAPCAD_ASSEMBLY_RESOLVER env var
    selection (mirrors YAPCAD_BOOLEAN_ENGINE)
  - Correct consume=True semantics: consumed cutters dropped from output parts

- src/yapcad/assembly/__init__.py
  - Export all resolver public symbols

- tests/test_assembly_resolver.py (new, 29 tests all green)
  - BasicResolver: pass-through, single cut, consume/retain, priority order,
    multi-target, multi-cutter, error/policy (strict/warn/ignore), boolean failure
  - StagedResolver: stage order, unknown stage fallback, no-stage fallback, empty-
    stages validation, single-stage equivalence to basic
  - KinematicResolver placeholder check
  - resolve_assembly dispatch: env var selection, env var errors, explicit override
  - Registry: built-in names, get_resolver, unknown name
  - RFC §12 acceptance criteria: priority ordering, consume=False retention, no_cut

Resolvers are first-class public API. Mechatron may supply its own resolver via
the yapcad.assembly_resolvers entry point without forking yapCAD.
…med-arg fix + OCC resolver fallback

Step 7 of RFC §11 migration plan — forward bulkhead as proof case.

New files (in agentic-1 repo, not tracked here):
  designs/forward-bulkhead/forward_bulkhead_cutouts_v1.dsl
  designs/forward-bulkhead/forward_bulkhead_assembly_v1.yaml

yapCAD changes:
- interpreter.py: _eval_function_call now resolves named_arguments into
  positional order when calling intra-module commands. Fixes the gap where
  RADIAL_BOX_CUTTER(tang_w=101.6, ...) was called with args=[] because
  _eval_function_call only iterated call.arguments (positional), silently
  dropping call.named_arguments. RFC §3 decorator-sugar gap noted in DSL.
- resolver.py: _apply_boolean now falls back to direct BRepAlgoAPI_Cut/
  Common/Fuse when geom3d.boolean3d is not importable. Fixes resolver in
  yapcad-brep env (OCC available, geom3d module not yet wired).
- test_step7_forward_bulkhead_migration.py: 11 tests covering:
  1. Parse + checker (4 tests)
  2. Runtime execution + metadata (3 tests including volume + position)
  3. Resolver integration: BasicResolver applies 3 subtracts in priority
     order, volume decreases, applied_operations in [lox_man_vent,
     main_chute_door, fwd_accessory] order
  4. meta.yaml round-trip
  5. Cross-check gate: CUTOUTS-GHOST-SPEC.json FB entries match DSL

All 11 pass on yapcad-brep.

Next: Step 8 — repo-wide migration; retire FB entries from CUTOUTS-GHOST-SPEC.json.
Add structural load-case and bolt-pattern support to Assembly:
- yapcad.assembly.load_case: LoadCase / LoadAttach / BoltPattern dataclasses
- Assembly.add_load_case/get_load_case and add_bolt_pattern/get_bolt_pattern
- yapcad.package.analysis.mechatron_fea_setup: reads bolt_patterns and
  load_cases from a mechatron graph.json and produces per-bolt world coords,
  bolt-spring stiffness lookup, and resolved load-attach descriptors for a
  downstream FEA solver (per-hole-datum path + legacy PCD fallback).

Part of the metadata-v1.1 mechatron assembly integration (Phases 3-4).
…tures

Prepare the test suite for public release by removing references to the
internal design project and replacing live-design dependencies with generic,
self-contained fixtures:

- tests/fixtures/sample_assembly_graph.json: synthetic assembly graph (generic
  widget parts) driving test_mechatron_fea_setup.py — no longer reads a live
  external graph.json. Assertions are deterministic against invented data.
- tests/fixtures/enclosure_shell_cutouts_v1.dsl: generic @meta subtract-cutter
  DSL. test_step7 renamed to test_step7_subtract_cutter_migration.py and now
  validates the public cutter/resolver/meta.yaml capability against the fixture
  instead of an internal DSL + spec file. Drops the internal-only
  retirement-gate cross-check test.
- test_meta_yaml_exporter.py: scrub project-specific tags to neutral values.

Verified: grep for internal project/path references across src/ and tests/
returns nothing.
yapCAD's core is pure Python; solid-modeling (BREP) features layer on top of
OpenCASCADE via pythonocc-core, which is distributed via conda-forge (not
reliably pip-installable).

- pyproject.toml: add [project.optional-dependencies] 'brep' (pythonocc-core
  >=7.7) and 'full' extras, with a comment documenting that OCC comes from
  conda. Core 'dependencies' remain OCC-free.
- src/yapcad/__init__.py: expose yapcad.has_brep() (lazily calls
  yapcad.brep.occ_available()) so downstream can branch on OCC availability
  without eagerly importing brep at package load.
- pytest.ini: register 'requires_occ' marker so the pure-python lane runs via
  pytest -m 'not requires_occ'.
- tests/conftest.py: auto-tag OCC-guarded tests (skipif reason mentions OCC)
  with requires_occ, so the marker stays in sync without touching every file.
…EBOARD_ROOT

The /api/whiteboard/save and /api/whiteboard/read routes previously hardcoded a
local ~/Projects/whiteboard path. Gate the feature behind the
YAPCAD_WHITEBOARD_ROOT environment variable: when unset, both routes return 501
and never touch the filesystem — there is no default path baked into the
release. When set, it points at a git working tree and the feature works as
before (with the existing path-traversal guards).
- service/routes/dsl.py: expanded DSL execution routes (metadata-aware run,
  schema-validated request/response models).
- service/models/schemas.py: request/response schema additions for the DSL
  endpoints.
- service/main.py: wire up the routes.
- docs/dsl_guide.md: DSL usage guide.
Document the metadata-v1.1 feature set (@meta/@ui decorators, checker warnings
W310/311/312, AST MetadataTransform, .meta.yaml sidecar, resolver registry,
mechatron assembly integration Phases 2-4, off-axis feature datums, assembly
load_case/bolt_pattern, FEA setup bridge) and the pure-python-core / brep
optional-dependency packaging split.
When yapCAD is imported without pythonocc-core present, emit a single,
suppressible YapcadBrepUnavailableWarning (a UserWarning subclass) explaining
that BREP/boolean/STEP/solid features are unavailable and how to enable them via
conda-forge. Fires once, never when OCC is present, and probing failures are
swallowed so import is always safe. Supports the pure-Python PyPI install tier.
Prepare the pure-Python core for its first PyPI publication:
- pyproject.toml: enrich PyPI-facing metadata — keywords, full classifier set
  (Dev Status, audiences, Python 3.10-3.12, Topic :: Scientific/Engineering,
  3D Modeling), and project.urls (Repository, Issues, Changelog). Drop the
  License classifier (superseded by the SPDX license expression per PEP 639).
  Distribution name normalizes to 'yapcad'.
- README.rst: document the two install tiers prominently (pip pure-Python core
  with a limitation callout vs conda + pythonocc-core for full BREP); note the
  import-time capability warning; drop the stale 'python setup.py install'.
- CHANGELOG.rst: note first PyPI release of the pure-Python core + the
  install-time capability warning.

Validated: python -m build produces yapcad-1.1.0 sdist+wheel; twine check
PASSES both; clean-venv pip install imports, warns, has_brep()==False, and 2D/
DSL ops work.
… compat

Root causes (all version-skew against pythonocc-core 7.7.2, not geometry bugs):

1. brep_edge_select._get_unique_edges: called TopExp.MapShapes_s() (7.8+/OCP
   naming) inside a try/except ImportError that silently fell back to the
   TopExp_Explorer path, which returns each edge once per adjacent face.
   Result: every edge duplicated (box reported 24 edges, not 12), breaking
   all 20 edge-selection tests. Now tries 7.7.x lowercase topexp.MapShapes()
   first, falls back to the _s static style, and no longer masks failures
   with a silent explorer fallback. Also handles Extent()/Size() rename.

2. test_phase2_builtins: imported GProp/BRepGProp from OCP (the CadQuery
   binding, not installed here) and passed yapCAD list-solids directly to
   VolumeProperties_s. Rewritten to use yapcad.geom3d.volumeof() which
   handles both bindings and solid types. Also fixed a latent test bug:
   sphere() takes a diameter, so the shell-vs-sphere comparison must use
   sphere(30.0), not sphere(15.0) — the old comparison passed a half-size
   sphere and would fail for any correct shell volume.

3. Removed tests/test_assembly_constraint_validator.py: imports
   robot_printer.kinematic_chain, a module that has never existed in this
   repo (vestigial import from an internal project). All 19 tests errored
   at collection since introduction.
@rdevaul
rdevaul merged commit 1e6d80c into main Jul 10, 2026
3 checks passed
@rdevaul
rdevaul deleted the feature/metadata-v1.1 branch July 10, 2026 01:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant