from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Literal, Mapping, Optional, Tuple, Union, cast
DSL_VERSION: Literal["1"] = "1"
class DslValidationError(ValueError):
pass
JsonObject = Dict[str, Any]
Vec3 = Tuple[float, float, float]
Rgba = Tuple[float, float, float, float]
def _is_num(x: Any) -> bool:
return isinstance(x, (int, float)) and not isinstance(x, bool)
def _require_dict(value: Any, *, path: str) -> JsonObject:
if not isinstance(value, dict):
raise DslValidationError(f"{path} must be an object")
return cast(JsonObject, value)
def _require_list(value: Any, *, path: str) -> List[Any]:
if not isinstance(value, list):
raise DslValidationError(f"{path} must be a list")
return value
def _require_str(value: Any, *, path: str, min_len: int = 1, max_len: int = 128) -> str:
if not isinstance(value, str):
raise DslValidationError(f"{path} must be a string")
v = value.strip()
if len(v) < min_len:
raise DslValidationError(f"{path} must be at least {min_len} characters")
if len(v) > max_len:
raise DslValidationError(f"{path} must be at most {max_len} characters")
return v
def _optional_str(value: Any, *, path: str, min_len: int = 1, max_len: int = 128) -> Optional[str]:
if value is None:
return None
return _require_str(value, path=path, min_len=min_len, max_len=max_len)
def _require_bool(value: Any, *, path: str) -> bool:
if not isinstance(value, bool):
raise DslValidationError(f"{path} must be a boolean")
return value
def _require_number(value: Any, *, path: str, min_value: float | None = None, max_value: float | None = None) -> float:
if not _is_num(value):
raise DslValidationError(f"{path} must be a number")
v = float(value)
if min_value is not None and v < min_value:
raise DslValidationError(f"{path} must be >= {min_value}")
if max_value is not None and v > max_value:
raise DslValidationError(f"{path} must be <= {max_value}")
return v
def _require_literal(value: Any, *, path: str, allowed: Iterable[str]) -> str:
if not isinstance(value, str):
raise DslValidationError(f"{path} must be a string")
if value not in set(allowed):
raise DslValidationError(f"{path} must be one of: {', '.join(sorted(set(allowed)))}")
return value
def _optional_literal(value: Any, *, path: str, allowed: Iterable[str]) -> Optional[str]:
if value is None:
return None
return _require_literal(value, path=path, allowed=allowed)
def _require_vec3(value: Any, *, path: str) -> Vec3:
arr = _require_list(value, path=path)
if len(arr) != 3:
raise DslValidationError(f"{path} must have length 3")
if not all(_is_num(x) for x in arr):
raise DslValidationError(f"{path} must contain numbers")
return (float(arr[0]), float(arr[1]), float(arr[2]))
def _optional_vec3(value: Any, *, path: str) -> Optional[Vec3]:
if value is None:
return None
return _require_vec3(value, path=path)
def _require_rgba(value: Any, *, path: str) -> Rgba:
arr = _require_list(value, path=path)
if len(arr) != 4:
raise DslValidationError(f"{path} must have length 4")
if not all(_is_num(x) for x in arr):
raise DslValidationError(f"{path} must contain numbers")
rgba = (float(arr[0]), float(arr[1]), float(arr[2]), float(arr[3]))
for i, c in enumerate(rgba):
if c < 0.0 or c > 1.0:
raise DslValidationError(f"{path}[{i}] must be between 0 and 1")
return rgba
def _optional_rgba(value: Any, *, path: str) -> Optional[Rgba]:
if value is None:
return None
return _require_rgba(value, path=path)
def _reject_unknown_keys(obj: Mapping[str, Any], *, path: str, allowed_keys: set[str]) -> None:
unknown = set(obj.keys()) - allowed_keys
if unknown:
raise DslValidationError(f"{path} contains unknown keys: {', '.join(sorted(unknown))}")
TransactionMode = Literal["none", "atomic"]
def validate_ops_request(payload: Any) -> JsonObject:
"""
Validate and normalize a DSL ops request (v1).
Returns a normalized dict that is safe to forward to Blender.
Raises DslValidationError on invalid input.
"""
obj = _require_dict(payload, path="$")
dsl_version = obj.get("dsl_version", DSL_VERSION)
if dsl_version != DSL_VERSION:
raise DslValidationError(f"$.dsl_version must be '{DSL_VERSION}'")
transaction = obj.get("transaction", "atomic")
transaction = cast(TransactionMode, _require_literal(transaction, path="$.transaction", allowed=("none", "atomic")))
dry_run = obj.get("dry_run", False)
dry_run = _require_bool(dry_run, path="$.dry_run")
ops_raw = obj.get("ops")
ops_list = _require_list(ops_raw, path="$.ops")
if len(ops_list) == 0:
raise DslValidationError("$.ops must not be empty")
if len(ops_list) > 200:
raise DslValidationError("$.ops exceeds max length (200)")
normalized_ops: List[JsonObject] = []
for idx, op_raw in enumerate(ops_list):
normalized_ops.append(validate_op(op_raw, path=f"$.ops[{idx}]"))
normalized: JsonObject = {
"dsl_version": DSL_VERSION,
"transaction": transaction,
"dry_run": dry_run,
"ops": normalized_ops,
}
_reject_unknown_keys(obj, path="$", allowed_keys=set(normalized.keys()) | {"dsl_version", "transaction", "dry_run", "ops"})
return normalized
def validate_op(op_raw: Any, *, path: str) -> JsonObject:
op = _require_dict(op_raw, path=path)
op_type = _require_literal(op.get("type"), path=f"{path}.type", allowed=ALLOWLISTED_OP_TYPES)
if op_type == "deselect_all":
_reject_unknown_keys(op, path=path, allowed_keys={"type"})
return {"type": "deselect_all"}
if op_type == "select":
allowed = {"type", "names", "mode", "active"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
names = _require_list(op.get("names"), path=f"{path}.names")
if len(names) == 0:
raise DslValidationError(f"{path}.names must not be empty")
if len(names) > 200:
raise DslValidationError(f"{path}.names exceeds max length (200)")
norm_names = [_require_str(n, path=f"{path}.names[{i}]") for i, n in enumerate(names)]
mode = _require_literal(op.get("mode"), path=f"{path}.mode", allowed=("replace", "add", "remove"))
active = _optional_str(op.get("active"), path=f"{path}.active")
return {"type": "select", "names": norm_names, "mode": mode, "active": active}
if op_type == "create_primitive":
allowed = {"type", "primitive", "name", "size", "location", "rotation", "scale"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
primitive = _require_literal(
op.get("primitive"),
path=f"{path}.primitive",
allowed=("cube", "uv_sphere", "ico_sphere", "cylinder", "cone", "plane", "torus"),
)
name = _optional_str(op.get("name"), path=f"{path}.name")
size = op.get("size")
size_f = _require_number(size, path=f"{path}.size", min_value=0.0001, max_value=1e6) if size is not None else 1.0
location = _optional_vec3(op.get("location"), path=f"{path}.location")
rotation = _optional_vec3(op.get("rotation"), path=f"{path}.rotation")
scale = _optional_vec3(op.get("scale"), path=f"{path}.scale")
return {
"type": "create_primitive",
"primitive": primitive,
"name": name,
"size": size_f,
"location": location,
"rotation": rotation,
"scale": scale,
}
if op_type in {"delete", "duplicate"}:
allowed = {"type", "names", "linked", "new_names"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
names = _require_list(op.get("names"), path=f"{path}.names")
if len(names) == 0:
raise DslValidationError(f"{path}.names must not be empty")
norm_names = [_require_str(n, path=f"{path}.names[{i}]") for i, n in enumerate(names)]
if op_type == "delete":
return {"type": "delete", "names": norm_names}
linked = op.get("linked", False)
linked_b = _require_bool(linked, path=f"{path}.linked")
new_names_raw = op.get("new_names")
new_names: Optional[List[str]] = None
if new_names_raw is not None:
nn = _require_list(new_names_raw, path=f"{path}.new_names")
new_names = [_require_str(n, path=f"{path}.new_names[{i}]") for i, n in enumerate(nn)]
if len(new_names) != len(norm_names):
raise DslValidationError(f"{path}.new_names length must match names length")
return {"type": "duplicate", "names": norm_names, "linked": linked_b, "new_names": new_names}
if op_type == "rename":
allowed = {"type", "from", "to"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
from_name = _require_str(op.get("from"), path=f"{path}.from")
to_name = _require_str(op.get("to"), path=f"{path}.to")
return {"type": "rename", "from": from_name, "to": to_name}
if op_type == "set_transform":
allowed = {"type", "name", "location", "rotation", "scale", "space"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
name = _require_str(op.get("name"), path=f"{path}.name")
location = _optional_vec3(op.get("location"), path=f"{path}.location")
rotation = _optional_vec3(op.get("rotation"), path=f"{path}.rotation")
scale = _optional_vec3(op.get("scale"), path=f"{path}.scale")
space = _optional_literal(op.get("space"), path=f"{path}.space", allowed=("world", "local")) or "world"
return {"type": "set_transform", "name": name, "location": location, "rotation": rotation, "scale": scale, "space": space}
if op_type == "apply_transform":
allowed = {"type", "name", "location", "rotation", "scale"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
name = _require_str(op.get("name"), path=f"{path}.name")
loc = _require_bool(op.get("location", False), path=f"{path}.location")
rot = _require_bool(op.get("rotation", False), path=f"{path}.rotation")
sca = _require_bool(op.get("scale", False), path=f"{path}.scale")
if not (loc or rot or sca):
raise DslValidationError(f"{path} must set at least one of location/rotation/scale to true")
return {"type": "apply_transform", "name": name, "location": loc, "rotation": rot, "scale": sca}
if op_type == "set_shading":
allowed = {"type", "names", "shade", "auto_smooth", "auto_smooth_angle"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
names = _require_list(op.get("names"), path=f"{path}.names")
if len(names) == 0:
raise DslValidationError(f"{path}.names must not be empty")
if len(names) > 200:
raise DslValidationError(f"{path}.names exceeds max length (200)")
shade = _require_literal(op.get("shade"), path=f"{path}.shade", allowed=("smooth", "flat"))
auto_smooth = op.get("auto_smooth")
auto_smooth_b = _require_bool(auto_smooth, path=f"{path}.auto_smooth") if auto_smooth is not None else None
angle = op.get("auto_smooth_angle")
angle_f = _require_number(angle, path=f"{path}.auto_smooth_angle", min_value=0.0, max_value=180.0) if angle is not None else None
return {
"type": "set_shading",
"names": [_require_str(n, path=f"{path}.names[{i}]") for i, n in enumerate(names)],
"shade": shade,
"auto_smooth": auto_smooth_b,
"auto_smooth_angle": angle_f,
}
if op_type == "recalculate_normals":
allowed = {"type", "names", "inside"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
names = _require_list(op.get("names"), path=f"{path}.names")
if len(names) == 0:
raise DslValidationError(f"{path}.names must not be empty")
inside = op.get("inside", False)
inside_b = _require_bool(inside, path=f"{path}.inside")
return {"type": "recalculate_normals", "names": [_require_str(n, path=f"{path}.names[{i}]") for i, n in enumerate(names)], "inside": inside_b}
if op_type == "merge_by_distance":
allowed = {"type", "names", "distance"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
names = _require_list(op.get("names"), path=f"{path}.names")
if len(names) == 0:
raise DslValidationError(f"{path}.names must not be empty")
dist = _require_number(op.get("distance"), path=f"{path}.distance", min_value=0.0, max_value=1e3)
return {"type": "merge_by_distance", "names": [_require_str(n, path=f"{path}.names[{i}]") for i, n in enumerate(names)], "distance": dist}
if op_type == "triangulate":
allowed = {"type", "names", "quad_method", "ngon_method"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
names = _require_list(op.get("names"), path=f"{path}.names")
if len(names) == 0:
raise DslValidationError(f"{path}.names must not be empty")
quad_method = _optional_literal(op.get("quad_method"), path=f"{path}.quad_method", allowed=("BEAUTY", "FIXED", "FIXED_ALTERNATE", "SHORTEST_DIAGONAL")) or "BEAUTY"
ngon_method = _optional_literal(op.get("ngon_method"), path=f"{path}.ngon_method", allowed=("BEAUTY", "CLIP")) or "BEAUTY"
return {
"type": "triangulate",
"names": [_require_str(n, path=f"{path}.names[{i}]") for i, n in enumerate(names)],
"quad_method": quad_method,
"ngon_method": ngon_method,
}
if op_type == "join_objects":
allowed = {"type", "names", "active", "new_name"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
names = _require_list(op.get("names"), path=f"{path}.names")
if len(names) < 2:
raise DslValidationError(f"{path}.names must include at least 2 objects")
if len(names) > 200:
raise DslValidationError(f"{path}.names exceeds max length (200)")
norm_names = [_require_str(n, path=f"{path}.names[{i}]") for i, n in enumerate(names)]
active = _optional_str(op.get("active"), path=f"{path}.active")
new_name = _optional_str(op.get("new_name"), path=f"{path}.new_name")
return {"type": "join_objects", "names": norm_names, "active": active, "new_name": new_name}
if op_type == "separate_mesh":
allowed = {"type", "name", "mode"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
name = _require_str(op.get("name"), path=f"{path}.name")
mode = _require_literal(op.get("mode"), path=f"{path}.mode", allowed=("loose", "material", "selected"))
return {"type": "separate_mesh", "name": name, "mode": mode}
if op_type == "convert_to_mesh":
allowed = {"type", "names", "keep_original"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
names = _require_list(op.get("names"), path=f"{path}.names")
if len(names) == 0:
raise DslValidationError(f"{path}.names must not be empty")
keep = op.get("keep_original", False)
keep_b = _require_bool(keep, path=f"{path}.keep_original")
return {"type": "convert_to_mesh", "names": [_require_str(n, path=f"{path}.names[{i}]") for i, n in enumerate(names)], "keep_original": keep_b}
if op_type == "set_visibility":
allowed = {"type", "names", "viewport", "render", "selectable"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
names = _require_list(op.get("names"), path=f"{path}.names")
if len(names) == 0:
raise DslValidationError(f"{path}.names must not be empty")
viewport = op.get("viewport")
render = op.get("render")
selectable = op.get("selectable")
viewport_b = _require_bool(viewport, path=f"{path}.viewport") if viewport is not None else None
render_b = _require_bool(render, path=f"{path}.render") if render is not None else None
selectable_b = _require_bool(selectable, path=f"{path}.selectable") if selectable is not None else None
if viewport_b is None and render_b is None and selectable_b is None:
raise DslValidationError(f"{path} must set at least one of viewport/render/selectable")
return {
"type": "set_visibility",
"names": [_require_str(n, path=f"{path}.names[{i}]") for i, n in enumerate(names)],
"viewport": viewport_b,
"render": render_b,
"selectable": selectable_b,
}
if op_type == "set_collection_visibility":
allowed = {"type", "collection", "viewport", "render"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
col = _require_str(op.get("collection"), path=f"{path}.collection")
viewport = op.get("viewport")
render = op.get("render")
viewport_b = _require_bool(viewport, path=f"{path}.viewport") if viewport is not None else None
render_b = _require_bool(render, path=f"{path}.render") if render is not None else None
if viewport_b is None and render_b is None:
raise DslValidationError(f"{path} must set at least one of viewport/render")
return {"type": "set_collection_visibility", "collection": col, "viewport": viewport_b, "render": render_b}
if op_type == "isolate_objects":
allowed = {"type", "names", "mode", "include_children", "render"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
mode = _require_literal(op.get("mode"), path=f"{path}.mode", allowed=("isolate", "clear"))
include_children = op.get("include_children", True)
include_children_b = _require_bool(include_children, path=f"{path}.include_children")
render = op.get("render")
render_b = _require_bool(render, path=f"{path}.render") if render is not None else None
names_norm: List[str] = []
if mode == "isolate":
names = _require_list(op.get("names"), path=f"{path}.names")
if len(names) == 0:
raise DslValidationError(f"{path}.names must not be empty when mode=isolate")
names_norm = [_require_str(n, path=f"{path}.names[{i}]") for i, n in enumerate(names)]
return {"type": "isolate_objects", "names": names_norm, "mode": mode, "include_children": include_children_b, "render": render_b}
if op_type in {"uv_smart_project", "uv_unwrap", "uv_pack_islands"}:
allowed = {"type", "names", "params"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
names = _require_list(op.get("names"), path=f"{path}.names")
if len(names) == 0:
raise DslValidationError(f"{path}.names must not be empty")
params = _require_dict(op.get("params") or {}, path=f"{path}.params")
# Keep params flexible; addon validates supported fields for the exact Blender version.
return {"type": op_type, "names": [_require_str(n, path=f"{path}.names[{i}]") for i, n in enumerate(names)], "params": params}
if op_type == "bake_maps":
allowed = {"type", "name", "bake_type", "output_path", "resolution", "margin", "samples", "use_selected_to_active", "cage_extrusion"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
name = _require_str(op.get("name"), path=f"{path}.name")
bake_type = _require_literal(op.get("bake_type"), path=f"{path}.bake_type", allowed=("AO", "NORMAL", "DIFFUSE", "ROUGHNESS", "EMIT"))
output_path = _require_str(op.get("output_path"), path=f"{path}.output_path", min_len=1, max_len=4096)
resolution = op.get("resolution")
res_i = int(_require_number(resolution, path=f"{path}.resolution", min_value=16, max_value=16384)) if resolution is not None else 1024
margin = op.get("margin")
margin_i = int(_require_number(margin, path=f"{path}.margin", min_value=0, max_value=64)) if margin is not None else 16
samples = op.get("samples")
samples_i = int(_require_number(samples, path=f"{path}.samples", min_value=1, max_value=100000)) if samples is not None else 64
use_sel = op.get("use_selected_to_active", False)
use_sel_b = _require_bool(use_sel, path=f"{path}.use_selected_to_active")
cage = op.get("cage_extrusion")
cage_f = _require_number(cage, path=f"{path}.cage_extrusion", min_value=0.0, max_value=10.0) if cage is not None else None
return {
"type": "bake_maps",
"name": name,
"bake_type": bake_type,
"output_path": output_path,
"resolution": res_i,
"margin": margin_i,
"samples": samples_i,
"use_selected_to_active": use_sel_b,
"cage_extrusion": cage_f,
}
if op_type == "camera_look_at":
allowed = {"type", "camera", "target", "roll"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
cam = _require_str(op.get("camera"), path=f"{path}.camera")
target = _require_vec3(op.get("target"), path=f"{path}.target")
roll = op.get("roll")
roll_f = _require_number(roll, path=f"{path}.roll", min_value=-180.0, max_value=180.0) if roll is not None else 0.0
return {"type": "camera_look_at", "camera": cam, "target": target, "roll": roll_f}
if op_type == "create_turntable_animation":
allowed = {"type", "name", "frame_start", "frame_end", "axis", "revolutions", "rig_name"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
name = _require_str(op.get("name"), path=f"{path}.name")
fs = int(_require_number(op.get("frame_start"), path=f"{path}.frame_start", min_value=-100000, max_value=100000))
fe = int(_require_number(op.get("frame_end"), path=f"{path}.frame_end", min_value=-100000, max_value=100000))
if fe <= fs:
raise DslValidationError(f"{path}.frame_end must be > frame_start")
axis = _optional_literal(op.get("axis"), path=f"{path}.axis", allowed=("X", "Y", "Z")) or "Z"
rev = op.get("revolutions")
rev_f = _require_number(rev, path=f"{path}.revolutions", min_value=-1000.0, max_value=1000.0) if rev is not None else 1.0
rig_name = _optional_str(op.get("rig_name"), path=f"{path}.rig_name")
return {"type": "create_turntable_animation", "name": name, "frame_start": fs, "frame_end": fe, "axis": axis, "revolutions": rev_f, "rig_name": rig_name}
if op_type == "boolean_operation":
allowed = {"type", "target", "cutter", "operation", "solver", "apply", "remove_cutter"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
target = _require_str(op.get("target"), path=f"{path}.target")
cutter = _require_str(op.get("cutter"), path=f"{path}.cutter")
operation = _require_literal(op.get("operation"), path=f"{path}.operation", allowed=("UNION", "DIFFERENCE", "INTERSECT"))
solver = _optional_literal(op.get("solver"), path=f"{path}.solver", allowed=("FAST", "EXACT")) or "FAST"
apply_b = _require_bool(op.get("apply", True), path=f"{path}.apply")
rm = op.get("remove_cutter", False)
rm_b = _require_bool(rm, path=f"{path}.remove_cutter")
return {"type": "boolean_operation", "target": target, "cutter": cutter, "operation": operation, "solver": solver, "apply": apply_b, "remove_cutter": rm_b}
if op_type == "purge_orphans":
allowed = {"type"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
return {"type": "purge_orphans"}
if op_type == "pack_external_data":
allowed = {"type"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
return {"type": "pack_external_data"}
if op_type == "save_blend":
allowed = {"type", "path", "compress", "copy"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
p = _require_str(op.get("path"), path=f"{path}.path", min_len=1, max_len=4096)
compress = op.get("compress", False)
compress_b = _require_bool(compress, path=f"{path}.compress")
copy = op.get("copy", False)
copy_b = _require_bool(copy, path=f"{path}.copy")
return {"type": "save_blend", "path": p, "compress": compress_b, "copy": copy_b}
if op_type == "snap_to_ground":
allowed = {"type", "names"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
names = _require_list(op.get("names"), path=f"{path}.names")
if len(names) == 0:
raise DslValidationError(f"{path}.names must not be empty")
return {"type": "snap_to_ground", "names": [_require_str(n, path=f"{path}.names[{i}]") for i, n in enumerate(names)]}
if op_type == "set_origin":
allowed = {"type", "name", "mode"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
name = _require_str(op.get("name"), path=f"{path}.name")
mode = _require_literal(
op.get("mode"),
path=f"{path}.mode",
allowed=("geometry_to_origin", "origin_to_geometry", "origin_to_3d_cursor"),
)
return {"type": "set_origin", "name": name, "mode": mode}
if op_type == "ensure_collection":
allowed = {"type", "name"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
name = _require_str(op.get("name"), path=f"{path}.name")
return {"type": "ensure_collection", "name": name}
if op_type == "move_to_collection":
allowed = {"type", "object", "collection"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
obj_name = _require_str(op.get("object"), path=f"{path}.object")
col_name = _require_str(op.get("collection"), path=f"{path}.collection")
return {"type": "move_to_collection", "object": obj_name, "collection": col_name}
if op_type == "set_parent":
allowed = {"type", "child", "parent"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
child = _require_str(op.get("child"), path=f"{path}.child")
parent = _require_str(op.get("parent"), path=f"{path}.parent")
return {"type": "set_parent", "child": child, "parent": parent}
if op_type == "clear_parent":
allowed = {"type", "names"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
names = _require_list(op.get("names"), path=f"{path}.names")
if len(names) == 0:
raise DslValidationError(f"{path}.names must not be empty")
return {"type": "clear_parent", "names": [_require_str(n, path=f"{path}.names[{i}]") for i, n in enumerate(names)]}
if op_type == "ensure_material":
allowed = {"type", "name", "model"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
name = _require_str(op.get("name"), path=f"{path}.name")
model = _require_literal(op.get("model"), path=f"{path}.model", allowed=("pbr", "unlit"))
return {"type": "ensure_material", "name": name, "model": model}
if op_type == "set_material_params":
allowed = {"type", "material", "params"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
mat = _require_str(op.get("material"), path=f"{path}.material")
params = _require_dict(op.get("params"), path=f"{path}.params")
# PBR params subset
allowed_params = {"baseColor", "metallic", "roughness", "emissionColor", "emissionStrength", "alpha"}
_reject_unknown_keys(params, path=f"{path}.params", allowed_keys=allowed_params)
norm: JsonObject = {}
if "baseColor" in params:
norm["baseColor"] = _require_rgba(params["baseColor"], path=f"{path}.params.baseColor")
if "metallic" in params:
norm["metallic"] = _require_number(params["metallic"], path=f"{path}.params.metallic", min_value=0.0, max_value=1.0)
if "roughness" in params:
norm["roughness"] = _require_number(params["roughness"], path=f"{path}.params.roughness", min_value=0.0, max_value=1.0)
if "emissionColor" in params:
norm["emissionColor"] = _require_rgba(params["emissionColor"], path=f"{path}.params.emissionColor")
if "emissionStrength" in params:
norm["emissionStrength"] = _require_number(params["emissionStrength"], path=f"{path}.params.emissionStrength", min_value=0.0, max_value=1e6)
if "alpha" in params:
norm["alpha"] = _require_number(params["alpha"], path=f"{path}.params.alpha", min_value=0.0, max_value=1.0)
return {"type": "set_material_params", "material": mat, "params": norm}
if op_type == "assign_material":
allowed = {"type", "object", "material", "slot"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
obj_name = _require_str(op.get("object"), path=f"{path}.object")
mat = _require_str(op.get("material"), path=f"{path}.material")
slot = op.get("slot")
slot_i = int(_require_number(slot, path=f"{path}.slot", min_value=0, max_value=1000)) if slot is not None else 0
return {"type": "assign_material", "object": obj_name, "material": mat, "slot": slot_i}
if op_type == "set_texture_maps":
allowed = {"type", "material", "maps"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
mat = _require_str(op.get("material"), path=f"{path}.material")
maps = _require_dict(op.get("maps"), path=f"{path}.maps")
allowed_map_keys = {"basecolor", "normal", "roughness", "metallic", "ao"}
_reject_unknown_keys(maps, path=f"{path}.maps", allowed_keys=allowed_map_keys)
norm_maps: JsonObject = {}
for k in allowed_map_keys:
if k in maps and maps[k] is not None:
norm_maps[k] = _require_str(maps[k], path=f"{path}.maps.{k}", min_len=1, max_len=4096)
if not norm_maps:
raise DslValidationError(f"{path}.maps must include at least one map path")
return {"type": "set_texture_maps", "material": mat, "maps": norm_maps}
if op_type == "create_light":
allowed = {"type", "light", "name", "location", "params"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
light = _require_literal(op.get("light"), path=f"{path}.light", allowed=("point", "sun", "spot", "area"))
name = _optional_str(op.get("name"), path=f"{path}.name")
location = _optional_vec3(op.get("location"), path=f"{path}.location")
params = op.get("params") or {}
params_obj = _require_dict(params, path=f"{path}.params")
# Keep params flexible, validated in add-on per light type.
return {"type": "create_light", "light": light, "name": name, "location": location, "params": params_obj}
if op_type == "set_light_params":
allowed = {"type", "name", "params"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
name = _require_str(op.get("name"), path=f"{path}.name")
params_obj = _require_dict(op.get("params") or {}, path=f"{path}.params")
return {"type": "set_light_params", "name": name, "params": params_obj}
if op_type == "create_camera":
allowed = {"type", "name", "location", "rotation", "params"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
name = _optional_str(op.get("name"), path=f"{path}.name")
location = _optional_vec3(op.get("location"), path=f"{path}.location")
rotation = _optional_vec3(op.get("rotation"), path=f"{path}.rotation")
params_obj = _require_dict(op.get("params") or {}, path=f"{path}.params")
return {"type": "create_camera", "name": name, "location": location, "rotation": rotation, "params": params_obj}
if op_type == "set_camera_params":
allowed = {"type", "name", "params"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
name = _require_str(op.get("name"), path=f"{path}.name")
params_obj = _require_dict(op.get("params") or {}, path=f"{path}.params")
# Keep params flexible; add-on validates supported fields.
return {"type": "set_camera_params", "name": name, "params": params_obj}
if op_type == "set_active_camera":
allowed = {"type", "name"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
name = _require_str(op.get("name"), path=f"{path}.name")
return {"type": "set_active_camera", "name": name}
if op_type == "frame_camera":
allowed = {"type", "camera", "objects", "margin"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
camera = _require_str(op.get("camera"), path=f"{path}.camera")
objects = _require_list(op.get("objects"), path=f"{path}.objects")
if len(objects) == 0:
raise DslValidationError(f"{path}.objects must not be empty")
norm_objects = [_require_str(o, path=f"{path}.objects[{i}]") for i, o in enumerate(objects)]
margin = op.get("margin")
margin_f = _require_number(margin, path=f"{path}.margin", min_value=0.0, max_value=10.0) if margin is not None else 0.1
return {"type": "frame_camera", "camera": camera, "objects": norm_objects, "margin": margin_f}
if op_type == "set_world_background":
allowed = {"type", "color", "strength"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
color = _optional_rgba(op.get("color"), path=f"{path}.color")
strength = op.get("strength")
strength_f = _require_number(strength, path=f"{path}.strength", min_value=0.0, max_value=1e6) if strength is not None else None
return {"type": "set_world_background", "color": color, "strength": strength_f}
if op_type == "set_world_hdri":
allowed = {"type", "source", "strength", "rotation"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
source = _require_dict(op.get("source"), path=f"{path}.source")
_reject_unknown_keys(source, path=f"{path}.source", allowed_keys={"polyhaven_id", "path", "url"})
poly_id = _optional_str(source.get("polyhaven_id"), path=f"{path}.source.polyhaven_id")
fpath = _optional_str(source.get("path"), path=f"{path}.source.path", min_len=1, max_len=4096)
url = _optional_str(source.get("url"), path=f"{path}.source.url", min_len=1, max_len=4096)
if sum(1 for x in (poly_id, fpath, url) if x) != 1:
raise DslValidationError(f"{path}.source must set exactly one of polyhaven_id/path/url")
strength = op.get("strength")
strength_f = _require_number(strength, path=f"{path}.strength", min_value=0.0, max_value=1e6) if strength is not None else 1.0
rotation = _optional_vec3(op.get("rotation"), path=f"{path}.rotation")
return {"type": "set_world_hdri", "source": {"polyhaven_id": poly_id, "path": fpath, "url": url}, "strength": strength_f, "rotation": rotation}
if op_type == "import_model":
allowed = {"type", "path", "format", "options"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
p = _require_str(op.get("path"), path=f"{path}.path", min_len=1, max_len=4096)
fmt = _require_literal(op.get("format"), path=f"{path}.format", allowed=("gltf", "glb", "obj", "fbx"))
options = _require_dict(op.get("options") or {}, path=f"{path}.options")
return {"type": "import_model", "path": p, "format": fmt, "options": options}
if op_type == "export_scene":
allowed = {"type", "path", "format", "options"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
p = _require_str(op.get("path"), path=f"{path}.path", min_len=1, max_len=4096)
fmt = _require_literal(op.get("format"), path=f"{path}.format", allowed=("gltf", "glb", "obj", "fbx"))
options = _require_dict(op.get("options") or {}, path=f"{path}.options")
return {"type": "export_scene", "path": p, "format": fmt, "options": options}
if op_type == "set_render_settings":
allowed = {"type", "engine", "resolution", "samples", "denoise", "color_management"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
engine = _require_literal(op.get("engine"), path=f"{path}.engine", allowed=("CYCLES", "BLENDER_EEVEE", "BLENDER_WORKBENCH"))
resolution = op.get("resolution")
res_norm: Optional[Tuple[int, int]] = None
if resolution is not None:
res_list = _require_list(resolution, path=f"{path}.resolution")
if len(res_list) != 2 or not all(_is_num(x) for x in res_list):
raise DslValidationError(f"{path}.resolution must be [width,height]")
w = int(float(res_list[0]))
h = int(float(res_list[1]))
if w <= 0 or h <= 0 or w > 16384 or h > 16384:
raise DslValidationError(f"{path}.resolution values out of range")
res_norm = (w, h)
samples = op.get("samples")
samples_i = int(_require_number(samples, path=f"{path}.samples", min_value=1, max_value=100000)) if samples is not None else None
denoise = op.get("denoise")
denoise_b = _require_bool(denoise, path=f"{path}.denoise") if denoise is not None else None
cm = _require_dict(op.get("color_management") or {}, path=f"{path}.color_management")
return {"type": "set_render_settings", "engine": engine, "resolution": res_norm, "samples": samples_i, "denoise": denoise_b, "color_management": cm}
if op_type == "render_still":
allowed = {"type", "output_path", "format"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
out = _optional_str(op.get("output_path"), path=f"{path}.output_path", min_len=1, max_len=4096)
fmt = _optional_literal(op.get("format"), path=f"{path}.format", allowed=("PNG", "JPEG")) or "PNG"
return {"type": "render_still", "output_path": out, "format": fmt}
if op_type == "render_animation":
allowed = {"type", "output_dir", "frame_start", "frame_end"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
out_dir = _require_str(op.get("output_dir"), path=f"{path}.output_dir", min_len=1, max_len=4096)
fs = int(_require_number(op.get("frame_start"), path=f"{path}.frame_start", min_value=-100000, max_value=100000))
fe = int(_require_number(op.get("frame_end"), path=f"{path}.frame_end", min_value=-100000, max_value=100000))
if fe < fs:
raise DslValidationError(f"{path}.frame_end must be >= frame_start")
return {"type": "render_animation", "output_dir": out_dir, "frame_start": fs, "frame_end": fe}
if op_type == "add_modifier":
allowed = {"type", "name", "modifier_type", "params"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
name = _require_str(op.get("name"), path=f"{path}.name")
mod_type = _require_literal(
op.get("modifier_type"),
path=f"{path}.modifier_type",
allowed=("subdivision", "bevel", "solidify", "mirror", "array", "decimate", "boolean", "ocean"),
)
params_obj = _require_dict(op.get("params") or {}, path=f"{path}.params")
return {"type": "add_modifier", "name": name, "modifier_type": mod_type, "params": params_obj}
if op_type in {"apply_modifier", "remove_modifier"}:
allowed = {"type", "name", "modifier_name"}
_reject_unknown_keys(op, path=path, allowed_keys=allowed)
name = _require_str(op.get("name"), path=f"{path}.name")
mod_name = _require_str(op.get("modifier_name"), path=f"{path}.modifier_name")
return {"type": op_type, "name": name, "modifier_name": mod_name}
# Should be unreachable due to allowlist check.
raise DslValidationError(f"{path}.type is not supported")
ALLOWLISTED_OP_TYPES: Tuple[str, ...] = (
# Selection
"deselect_all",
"select",
# Objects
"create_primitive",
"delete",
"duplicate",
"rename",
# Transforms
"set_transform",
"apply_transform",
"snap_to_ground",
"set_origin",
# Mesh utilities
"set_shading",
"recalculate_normals",
"merge_by_distance",
"triangulate",
"join_objects",
"separate_mesh",
"convert_to_mesh",
# Visibility / organization
"set_visibility",
"set_collection_visibility",
"isolate_objects",
# UV / baking
"uv_smart_project",
"uv_unwrap",
"uv_pack_islands",
"bake_maps",
# Collections / parenting
"ensure_collection",
"move_to_collection",
"set_parent",
"clear_parent",
# Materials
"ensure_material",
"set_material_params",
"assign_material",
"set_texture_maps",
# Camera / lights / world
"create_light",
"set_light_params",
"create_camera",
"set_camera_params",
"set_active_camera",
"frame_camera",
"camera_look_at",
"create_turntable_animation",
"set_world_background",
"set_world_hdri",
# Import/export
"import_model",
"export_scene",
# Rendering
"set_render_settings",
"render_still",
"render_animation",
# Modifiers
"add_modifier",
"remove_modifier",
"apply_modifier",
# Boolean convenience
"boolean_operation",
# Project hygiene
"purge_orphans",
"pack_external_data",
"save_blend",
)