zcbot/windows-node/adapters/freecad.model.author@v1/recipe.py

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")))