"""Pure validation and parameter resolution for the FreeCAD recipe contract.""" from __future__ import annotations import copy import math from typing import Any MAX_STEPS = 128 MAX_PARAMETERS = 64 MAX_ENTITIES = 256 SUPPORTED_OPS = { "box", "cylinder", "cone", "sphere", "torus", "import_step", "union", "cut", "intersect", "translate", "rotate", "combine", } class RecipeError(ValueError): def __init__(self, code: str, detail: str = "") -> None: super().__init__(detail or code) self.code = code def _number(value: Any, parameters: dict[str, float], role: str) -> float: if isinstance(value, bool): raise RecipeError("FREECAD_VALUE_INVALID", role) if isinstance(value, (int, float)): result = float(value) elif isinstance(value, dict) and set(value) == {"parameter"}: name = value["parameter"] if not isinstance(name, str) or name not in parameters: raise RecipeError("FREECAD_PARAMETER_UNKNOWN", str(name)) result = parameters[name] else: raise RecipeError("FREECAD_VALUE_INVALID", role) if not math.isfinite(result) or abs(result) > 1_000_000: raise RecipeError("FREECAD_VALUE_OUT_OF_RANGE", role) return result def _resolve(value: Any, parameters: dict[str, float], role: str) -> Any: if isinstance(value, dict): if set(value) == {"parameter"}: return _number(value, parameters, role) return { key: _resolve(item, parameters, f"{role}.{key}") for key, item in value.items() } if isinstance(value, list): return [_resolve(item, parameters, f"{role}[]") for item in value] return value def normalize_model(model: dict[str, Any], input_bound: bool) -> dict[str, Any]: if model.get("type") != "recipe" or model.get("recipe_version") != 1: raise RecipeError("FREECAD_RECIPE_VERSION_UNSUPPORTED") raw_parameters = model.get("parameters") or {} if not isinstance(raw_parameters, dict) or len(raw_parameters) > MAX_PARAMETERS: raise RecipeError("FREECAD_PARAMETER_LIMIT_EXCEEDED") parameters = { name: _number(value, {}, f"parameters.{name}") for name, value in raw_parameters.items() } steps = model.get("steps") if not isinstance(steps, list) or not 1 <= len(steps) <= MAX_STEPS: raise RecipeError("FREECAD_STEP_LIMIT_EXCEEDED") resolved = copy.deepcopy(model) resolved["parameters"] = parameters resolved["steps"] = [ _resolve(step, parameters, f"steps[{index}]") for index, step in enumerate(steps) ] known: set[str] = set() imports = 0 estimated_entities: dict[str, int] = {} for step in resolved["steps"]: name = step.get("id") op = step.get("op") if not isinstance(name, str) or name in known: raise RecipeError("FREECAD_STEP_ID_DUPLICATE", str(name)) if op not in SUPPORTED_OPS: raise RecipeError("FREECAD_OPERATION_UNSUPPORTED", str(op)) references: list[str] = [] if op in {"union", "cut", "intersect"}: references = [step.get("target"), *(step.get("tools") or [])] elif op in {"translate", "rotate"}: references = [step.get("target")] elif op == "combine": references = list(step.get("targets") or []) if any(not isinstance(item, str) or item not in known for item in references): raise RecipeError("FREECAD_STEP_REFERENCE_UNKNOWN", name) if op == "import_step": imports += 1 if step.get("input") != "geometry" or not input_bound: raise RecipeError("FREECAD_STEP_INPUT_NOT_BOUND") if op == "box": _positive(step, ("length", "width", "height")) elif op == "cylinder": _positive(step, ("radius", "height")) _angle(step, "angle_degrees", 360.0, positive=True) elif op == "cone": _positive(step, ("height",)) _nonnegative(step, ("radius1", "radius2")) if step["radius1"] == 0 and step["radius2"] == 0: raise RecipeError("FREECAD_CONE_RADII_INVALID", name) _angle(step, "angle_degrees", 360.0, positive=True) elif op == "sphere": _positive(step, ("radius",)) _angle(step, "angle1_degrees", -90.0) _angle(step, "angle2_degrees", 90.0) _angle(step, "angle3_degrees", 360.0, positive=True) if step.get("angle1_degrees", -90.0) >= step.get("angle2_degrees", 90.0): raise RecipeError("FREECAD_SPHERE_ANGLES_INVALID", name) elif op == "torus": _positive(step, ("major_radius", "minor_radius")) if step["minor_radius"] >= step["major_radius"]: raise RecipeError("FREECAD_TORUS_RADII_INVALID", name) _angle(step, "angle1_degrees", -180.0) _angle(step, "angle2_degrees", 180.0) _angle(step, "angle3_degrees", 360.0, positive=True) elif op == "rotate": direction = step.get("axis_direction") or [] if ( len(direction) != 3 or math.sqrt(sum(float(item) ** 2 for item in direction)) <= 1e-12 ): raise RecipeError("FREECAD_ROTATION_AXIS_INVALID", name) if op == "combine": estimated_entities[name] = sum( estimated_entities[item] for item in references ) elif op in {"translate", "rotate"}: estimated_entities[name] = estimated_entities[references[0]] else: estimated_entities[name] = 1 if estimated_entities[name] > MAX_ENTITIES: raise RecipeError("FREECAD_ENTITY_LIMIT_EXCEEDED", name) known.add(name) if input_bound != (imports == 1): raise RecipeError("FREECAD_INPUT_USAGE_INVALID") if resolved.get("result") not in known: raise RecipeError("FREECAD_RESULT_UNKNOWN") return resolved def _positive(step: dict[str, Any], keys: tuple[str, ...]) -> None: if any(float(step[key]) <= 0 for key in keys): raise RecipeError("FREECAD_POSITIVE_VALUE_REQUIRED", str(step.get("id"))) def _nonnegative(step: dict[str, Any], keys: tuple[str, ...]) -> None: if any(float(step[key]) < 0 for key in keys): raise RecipeError("FREECAD_NONNEGATIVE_VALUE_REQUIRED", str(step.get("id"))) def _angle( step: dict[str, Any], key: str, default: float, positive: bool = False ) -> None: value = float(step.get(key, default)) if (positive and value <= 0) or abs(value) > 360: raise RecipeError("FREECAD_ANGLE_INVALID", str(step.get("id")))