"""Apply @meta decorator hints from the DSL to yapCAD geometry metadata.
This module bridges the gap between the DSL's ``@meta(...)`` command decorator
(which produces a flat ``meta_hint`` dict on a ``FunctionDef`` node) and the
v1.1 metadata namespace helpers in ``yapcad.metadata``.
Typical usage — called by the DSL evaluator or service layer after a command
emits a solid::
from yapcad.dsl.meta_apply import apply_meta_hint
if fn.meta_hint:
apply_meta_hint(solid, fn.meta_hint)
The function is **idempotent and additive**: calling it multiple times with
the same hints merges cleanly because the underlying namespace helpers use
``setdefault`` / field-level writes, not wholesale replacement.
Namespace routing
-----------------
Dotted keys in the hint dict are split on the first dot to determine the
namespace, then the remainder becomes the field name:
``assembly.joint_kind`` → assembly namespace, field ``joint_kind``
``operation.kind`` → operation namespace, field ``kind``
``layer`` → root metadata field ``layer``
``tags`` → root metadata ``tags`` list (value appended)
Unknown namespaces or unrecognised field names within a known namespace are
stored verbatim in the namespace dict rather than raising an error. This
keeps the DSL forward-compatible: a field defined in a future v1.2 spec won't
break existing parsers.
List-of-dict assembly fields
----------------------------
The ``@meta`` decorator additionally accepts the four structured assembly
list fields as lists of dict literals. Each entry is forwarded to the
corresponding ``yapcad.metadata`` helper, so the same validation (enum
checks, required-key checks, vector length checks) applies whether the
caller writes Python or DSL::
@meta(
assembly.datums=[
{id="neck_face", kind="axis", ring="upper", R_mm=151.9, z_mm=330.0},
{id="bore", kind="axis", ring="upper", R_mm=151.9, z_mm=325.0},
],
assembly.bolt_patterns=[
{id="primary", ring="upper", R_mm=149.4, z_mm=317.3, count=8,
fastener={kind="heatset", size="1/4-20"}},
],
assembly.surfaces=[{id="mate_top", kind="mating", mate_to="nosecone.base_bore"}],
assembly.keepouts=[{id="clearance_zone", kind="volume", reason="approach"}],
)
command FORWARD_BULKHEAD(...) -> solid:
...
Known unsupported field shapes still raise ``ValueError`` with the name of
the field so callers can fall back to the Python helpers.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from yapcad.metadata import (
get_solid_metadata,
_ensure_root,
set_assembly,
set_operation,
get_assembly_metadata,
get_operation_metadata,
add_bolt_pattern,
add_datum,
add_surface,
add_keepout,
)
# ---------------------------------------------------------------------------
# Assembly namespace field routing
# ---------------------------------------------------------------------------
# Fields accepted as kwargs by set_assembly() — passed through directly.
_ASSEMBLY_SCALAR_FIELDS = {"joint_kind", "no_cut"}
# List-of-dict fields with a dedicated helper. Each value in the @meta dict
# is expected to be a list of dicts; each dict is forwarded as kwargs to the
# corresponding helper. The helpers apply field-level validation (enum
# checks, vector-length checks, required-key checks) so identical errors
# surface whether the caller writes Python or DSL.
_ASSEMBLY_LIST_HELPERS = {
"bolt_patterns": add_bolt_pattern,
"datums": add_datum,
"surfaces": add_surface,
"keepouts": add_keepout,
}
def _apply_assembly_list_field(
meta: Dict[str, Any],
field: str,
value: Any,
) -> None:
"""Forward each entry in ``value`` to the dedicated helper for ``field``.
Args:
meta: Mutable metadata dict.
field: One of ``bolt_patterns``, ``datums``, ``surfaces``, ``keepouts``.
value: List of dicts; each dict is **kwargs for the helper.
Raises:
TypeError: If ``value`` is not a list, or any entry is not a dict.
ValueError: Propagated from the helper for invalid field values
(e.g. enum mismatch, missing required key).
"""
helper = _ASSEMBLY_LIST_HELPERS[field]
if not isinstance(value, list):
raise TypeError(
f"assembly.{field} must be a list of entries, "
f"got {type(value).__name__}"
)
for idx, entry in enumerate(value):
if not isinstance(entry, dict):
raise TypeError(
f"assembly.{field}[{idx}] must be a dict, "
f"got {type(entry).__name__}"
)
try:
helper(meta, **entry)
except TypeError as exc:
# Surface a more helpful message than "got an unexpected keyword
# argument" — helps DSL authors find the typo'd key fast.
raise TypeError(
f"assembly.{field}[{idx}] rejected: {exc}"
) from exc
def _apply_assembly(meta: Dict[str, Any], field: str, value: Any) -> None:
"""Route a single ``assembly.<field>`` hint into the metadata dict."""
if field in _ASSEMBLY_SCALAR_FIELDS:
# Convert bool-like strings for convenience ("true" → True)
if field == "no_cut" and isinstance(value, str):
value = value.lower() in ("true", "1", "yes")
set_assembly(meta, **{field: value})
elif field in _ASSEMBLY_LIST_HELPERS:
_apply_assembly_list_field(meta, field, value)
else:
# Unknown field — store verbatim for forward-compatibility
section = get_assembly_metadata(meta, create=True)
section[field] = value
# ---------------------------------------------------------------------------
# Operation namespace field routing
# ---------------------------------------------------------------------------
# Fields accepted as kwargs by set_operation() — passed through directly.
# (target_filter is a list, handled separately)
_OPERATION_SCALAR_FIELDS = {
"kind", "priority", "through", "consume", "policy",
"feature_id", "feature_kind", "stage",
}
def _apply_operation(meta: Dict[str, Any], field: str, value: Any) -> None:
"""Route a single ``operation.<field>`` hint into the metadata dict.
``set_operation()`` requires ``kind`` to be present on every call. To
support per-field application from a flat dict (where ``kind`` may have
been set in a previous iteration), we use ``set_operation`` only when
``kind`` is the field being set; for all other scalar fields we write
directly to the namespace section after enum validation.
"""
from yapcad.metadata import _validate_enum, _OPERATION_KINDS, _OPERATION_POLICIES, _FEATURE_KINDS
if field == "target_filter":
# Accept a list value or a comma-separated string
if isinstance(value, str):
value = [v.strip() for v in value.split(",") if v.strip()]
elif not isinstance(value, list):
value = [str(value)]
section = get_operation_metadata(meta, create=True)
section["target_filter"] = value
elif field == "kind":
# kind is required by set_operation — call it directly
_validate_enum(value, _OPERATION_KINDS, field="operation.kind")
section = get_operation_metadata(meta, create=True)
section["kind"] = value
elif field == "policy":
_validate_enum(value, _OPERATION_POLICIES, field="operation.policy")
section = get_operation_metadata(meta, create=True)
section["policy"] = value
elif field == "feature_kind":
_validate_enum(value, _FEATURE_KINDS, field="operation.feature_kind")
section = get_operation_metadata(meta, create=True)
section["feature_kind"] = value
elif field in ("through", "consume"):
if isinstance(value, str):
value = value.lower() in ("true", "1", "yes")
section = get_operation_metadata(meta, create=True)
section[field] = bool(value)
elif field == "priority":
section = get_operation_metadata(meta, create=True)
section["priority"] = float(value)
elif field in ("feature_id", "stage"):
section = get_operation_metadata(meta, create=True)
section[field] = str(value)
else:
# Unknown field — store verbatim for forward-compat
section = get_operation_metadata(meta, create=True)
section[field] = value
# ---------------------------------------------------------------------------
# Root-level field routing
# ---------------------------------------------------------------------------
def _apply_root(meta: Dict[str, Any], field: str, value: Any) -> None:
"""Route a plain (non-namespaced) hint key into the metadata root."""
root = _ensure_root(meta)
if field == "layer":
root["layer"] = str(value)
elif field == "tags":
# Append a single tag string or extend with a list
tags: List[str] = root.setdefault("tags", [])
if isinstance(value, list):
tags.extend(str(t) for t in value)
else:
tags.append(str(value))
else:
# Free-form root field — store verbatim
root[field] = value
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
__all__ = [
"apply_meta_hint",
"apply_meta_hint_to_raw",
]