173 lines
6.6 KiB
Python
173 lines
6.6 KiB
Python
"""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")))
|