Spaces:
Running on Zero
Running on Zero
| """ | |
| Scene DSL: the constrained format the small model must emit. | |
| The model never writes Three.js directly. It emits this JSON, which we then | |
| validate + clamp + repair here, and compile to Three.js in compiler.py. | |
| That separation is what keeps the live preview from ever breaking. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| import re | |
| from typing import Any, Dict, List, Literal, Optional, Union | |
| from pydantic import BaseModel, Field, field_validator | |
| log = logging.getLogger(__name__) | |
| SHAPES = { | |
| "box", "sphere", "cylinder", "cone", "torus", "torusKnot", "plane", | |
| "tetrahedron", "icosahedron", "dodecahedron", "octahedron", | |
| "capsule", "ring", "circle", "tube", "roundedBox", | |
| } | |
| EXTRUDE_SHAPES = {"star", "heart", "hexagon", "badge", "shield"} | |
| MATERIALS = {"standard", "basic", "phong", "wireframe"} | |
| PRESET_NAMES = {"gold", "chrome", "glass", "neon", "matte", "plastic"} | |
| LIGHT_TYPES = {"ambient", "directional", "point"} | |
| ANIM_TYPES = {"none", "rotate", "float", "orbit"} | |
| HEX = re.compile(r"^#[0-9a-fA-F]{6}$") | |
| # ---- Color normalisation (Fix 1) ---- | |
| _SYNONYMS: Dict[str, str] = { | |
| "electric blue": "#7df9ff", "electricblue": "#7df9ff", | |
| "neon green": "#39ff14", "neongreen": "#39ff14", | |
| "neon": "#39ff14", | |
| "neon blue": "#4d4dff", "neonblue": "#4d4dff", | |
| "neon red": "#ff3131", "neonred": "#ff3131", | |
| "neon pink": "#ff6ec7", "neonpink": "#ff6ec7", | |
| "neon yellow": "#ffff00", "neonyellow": "#ffff00", | |
| "neon orange": "#ff6600", "neonorange": "#ff6600", | |
| } | |
| _CSS_COLORS: frozenset = frozenset({ | |
| "aliceblue", "antiquewhite", "aqua", "aquamarine", "azure", "beige", | |
| "bisque", "black", "blanchedalmond", "blue", "blueviolet", "brown", | |
| "burlywood", "cadetblue", "chartreuse", "chocolate", "coral", | |
| "cornflowerblue", "cornsilk", "crimson", "cyan", "darkblue", "darkcyan", | |
| "darkgoldenrod", "darkgray", "darkgreen", "darkgrey", "darkkhaki", | |
| "darkmagenta", "darkolivegreen", "darkorange", "darkorchid", "darkred", | |
| "darksalmon", "darkseagreen", "darkslateblue", "darkslategray", | |
| "darkslategrey", "darkturquoise", "darkviolet", "deeppink", "deepskyblue", | |
| "dimgray", "dimgrey", "dodgerblue", "firebrick", "floralwhite", | |
| "forestgreen", "fuchsia", "gainsboro", "ghostwhite", "gold", "goldenrod", | |
| "gray", "green", "greenyellow", "grey", "honeydew", "hotpink", | |
| "indianred", "indigo", "ivory", "khaki", "lavender", "lavenderblush", | |
| "lawngreen", "lemonchiffon", "lightblue", "lightcoral", "lightcyan", | |
| "lightgoldenrodyellow", "lightgray", "lightgreen", "lightgrey", | |
| "lightpink", "lightsalmon", "lightseagreen", "lightskyblue", | |
| "lightslategray", "lightslategrey", "lightsteelblue", "lightyellow", | |
| "lime", "limegreen", "linen", "magenta", "maroon", "mediumaquamarine", | |
| "mediumblue", "mediumorchid", "mediumpurple", "mediumseagreen", | |
| "mediumslateblue", "mediumspringgreen", "mediumturquoise", | |
| "mediumvioletred", "midnightblue", "mintcream", "mistyrose", "moccasin", | |
| "navajowhite", "navy", "oldlace", "olive", "olivedrab", "orange", | |
| "orangered", "orchid", "palegoldenrod", "palegreen", "paleturquoise", | |
| "palevioletred", "papayawhip", "peachpuff", "peru", "pink", "plum", | |
| "powderblue", "purple", "red", "rosybrown", "royalblue", "saddlebrown", | |
| "salmon", "sandybrown", "seagreen", "seashell", "sienna", "silver", | |
| "skyblue", "slateblue", "slategray", "slategrey", "snow", "springgreen", | |
| "steelblue", "tan", "teal", "thistle", "tomato", "turquoise", "violet", | |
| "wheat", "white", "whitesmoke", "yellow", "yellowgreen", | |
| }) | |
| _HEX_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$") | |
| _RGB_HSL_RE = re.compile( | |
| r"^(rgb|hsl)a?\(\s*[\d.]+%?\s*,\s*[\d.]+%?\s*,\s*[\d.]+%?\s*(?:,\s*[\d.]+)?\s*\)$" | |
| ) | |
| def _sanitize_color(v: str, default: str = "#888888") -> str: | |
| """Accept hex, rgb/hsl(), CSS/X11 names, and synonym map. Reject anything else.""" | |
| v = str(v).strip() | |
| lo = v.lower() | |
| if lo in _SYNONYMS: | |
| return _SYNONYMS[lo] | |
| collapsed = lo.replace(" ", "").replace("-", "") | |
| if collapsed in _SYNONYMS: | |
| return _SYNONYMS[collapsed] | |
| if _HEX_RE.match(v): | |
| return v | |
| if _RGB_HSL_RE.match(lo): | |
| return lo | |
| if collapsed in _CSS_COLORS: | |
| return collapsed | |
| log.warning("Unknown color %r, using default %s", v, default) | |
| return default | |
| def _clamp(v: float, lo: float, hi: float) -> float: | |
| return max(lo, min(hi, v)) | |
| def _shape_extent(shape: str, params: Dict[str, float]) -> tuple: | |
| """Return (width, height, depth) bounding box for layout size computations.""" | |
| def p(k, d): return float(params.get(k, d)) | |
| if shape == "box": | |
| return (p("width", 1.0), p("height", 1.0), p("depth", 1.0)) | |
| if shape == "sphere": | |
| d = p("radius", 0.6) * 2 | |
| return (d, d, d) | |
| if shape == "cylinder": | |
| r = max(p("radiusTop", 0.5), p("radiusBottom", 0.5)) * 2 | |
| return (r, p("height", 1.0), r) | |
| if shape == "cone": | |
| return (p("radius", 0.5) * 2, p("height", 1.0), p("radius", 0.5) * 2) | |
| if shape in ("torus", "torusKnot"): | |
| r = (p("radius", 0.5) + p("tube", 0.2)) * 2 | |
| return (r, r, r) | |
| if shape in ("tetrahedron", "icosahedron", "dodecahedron", "octahedron"): | |
| d = p("radius", 0.6) * 2 | |
| return (d, d, d) | |
| if shape == "plane": | |
| return (p("width", 5.0), 0.01, p("height", 5.0)) | |
| if shape == "capsule": | |
| d = p("radius", 0.4) * 2 | |
| return (d, p("length", 1.0) + d, d) | |
| if shape in ("ring", "circle"): | |
| r = p("outerRadius", p("radius", 0.6)) * 2 | |
| return (r, 0.01, r) | |
| if shape == "tube": | |
| return (1.0, 1.5, 1.0) | |
| if shape == "roundedBox": | |
| return (p("width", 1.0), p("height", 1.0), p("depth", 1.0)) | |
| return (1.0, 1.0, 1.0) | |
| class Obj(BaseModel): | |
| shape: str = "box" | |
| position: List[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0]) | |
| rotation: List[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0]) | |
| scale: List[float] = Field(default_factory=lambda: [1.0, 1.0, 1.0]) | |
| color: str = "#88ccff" | |
| material: str = "standard" | |
| preset: Optional[str] = None | |
| metalness: float = 0.3 | |
| roughness: float = 0.4 | |
| emissive: str = "#000000" | |
| params: Dict[str, float] = Field(default_factory=dict) | |
| def _shape(cls, v: Any) -> str: | |
| v = str(v) | |
| return v if v in SHAPES else "box" | |
| def _material(cls, v: Any) -> str: | |
| v = str(v) | |
| return v if v in MATERIALS else "standard" | |
| def _preset_field(cls, v: Any) -> Optional[str]: | |
| if v is None: | |
| return None | |
| v = str(v).lower().strip() | |
| return v if v in PRESET_NAMES else None | |
| def _vec3(cls, v: Any, info) -> List[float]: | |
| fill = 1.0 if info.field_name == "scale" else 0.0 | |
| out: List[float] = [] | |
| try: | |
| for x in list(v)[:3]: | |
| out.append(float(x)) | |
| except Exception: | |
| out = [] | |
| while len(out) < 3: | |
| out.append(fill) | |
| return out | |
| def _hex(cls, v: Any, info) -> str: | |
| default = "#88ccff" if info.field_name == "color" else "#000000" | |
| return _sanitize_color(str(v), default) | |
| def _unit(cls, v: Any) -> float: | |
| try: | |
| return _clamp(float(v), 0.0, 1.0) | |
| except Exception: | |
| return 0.4 | |
| def _params(cls, v: Any) -> Dict[str, float]: | |
| clean: Dict[str, float] = {} | |
| if isinstance(v, dict): | |
| for k, val in v.items(): | |
| try: | |
| clean[str(k)] = _clamp(float(val), -50.0, 50.0) | |
| except Exception: | |
| continue | |
| return clean | |
| class Light(BaseModel): | |
| type: str = "directional" | |
| color: str = "#ffffff" | |
| intensity: float = 1.0 | |
| position: List[float] = Field(default_factory=lambda: [5.0, 8.0, 6.0]) | |
| def _type(cls, v: Any) -> str: | |
| v = str(v) | |
| return v if v in LIGHT_TYPES else "directional" | |
| def _hex(cls, v: Any) -> str: | |
| return _sanitize_color(str(v), "#ffffff") | |
| def _intensity(cls, v: Any) -> float: | |
| try: | |
| return _clamp(float(v), 0.0, 10.0) | |
| except Exception: | |
| return 1.0 | |
| def _vec3(cls, v: Any) -> List[float]: | |
| out: List[float] = [] | |
| try: | |
| for x in list(v)[:3]: | |
| out.append(float(x)) | |
| except Exception: | |
| out = [] | |
| while len(out) < 3: | |
| out.append(5.0) | |
| return out | |
| class Animation(BaseModel): | |
| type: str = "rotate" | |
| speed: float = 1.0 | |
| axis: str = "y" | |
| def _type(cls, v: Any) -> str: | |
| v = str(v) | |
| return v if v in ANIM_TYPES else "rotate" | |
| def _speed(cls, v: Any) -> float: | |
| try: | |
| return _clamp(float(v), 0.0, 5.0) | |
| except Exception: | |
| return 1.0 | |
| def _axis(cls, v: Any) -> str: | |
| v = str(v) | |
| return v if v in {"x", "y", "z"} else "y" | |
| def _vec3_field(v: Any, default: float = 0.0) -> List[float]: | |
| out: List[float] = [] | |
| try: | |
| for x in list(v)[:3]: | |
| out.append(float(x)) | |
| except Exception: | |
| out = [] | |
| while len(out) < 3: | |
| out.append(default) | |
| return out | |
| class LayoutStack(BaseModel): | |
| """Stack children along an axis, centering the total extent at the node's position.""" | |
| type: Literal["stack"] = "stack" | |
| axis: str = "y" | |
| gap: float = 0.05 | |
| position: List[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0]) | |
| children: List[Any] = Field(default_factory=list) | |
| def _axis(cls, v: Any) -> str: | |
| return str(v) if str(v) in {"x", "y", "z"} else "y" | |
| def _gap(cls, v: Any) -> float: | |
| try: return max(0.0, float(v)) | |
| except Exception: return 0.05 | |
| def _pos(cls, v: Any) -> List[float]: | |
| return _vec3_field(v) | |
| def _children(cls, v: Any) -> List[Any]: | |
| return [_parse_scene_item(c) for c in v] if isinstance(v, list) else [] | |
| class LayoutRow(BaseModel): | |
| """Lay out children in a row along the x-axis.""" | |
| type: Literal["row"] = "row" | |
| gap: float = 0.3 | |
| position: List[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0]) | |
| children: List[Any] = Field(default_factory=list) | |
| def _gap(cls, v: Any) -> float: | |
| try: return max(0.0, float(v)) | |
| except Exception: return 0.3 | |
| def _pos(cls, v: Any) -> List[float]: | |
| return _vec3_field(v) | |
| def _children(cls, v: Any) -> List[Any]: | |
| return [_parse_scene_item(c) for c in v] if isinstance(v, list) else [] | |
| class LayoutGrid(BaseModel): | |
| """Lay out children in a grid on the x-z plane.""" | |
| type: Literal["grid"] = "grid" | |
| cols: int = 2 | |
| gap_x: float = 0.3 | |
| gap_z: float = 0.3 | |
| position: List[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0]) | |
| children: List[Any] = Field(default_factory=list) | |
| def _cols(cls, v: Any) -> int: | |
| try: return max(1, int(v)) | |
| except Exception: return 2 | |
| def _pos(cls, v: Any) -> List[float]: | |
| return _vec3_field(v) | |
| def _children(cls, v: Any) -> List[Any]: | |
| return [_parse_scene_item(c) for c in v] if isinstance(v, list) else [] | |
| class ExtrudeNode(BaseModel): | |
| """A 2-D shape path extruded into 3-D with a bevel.""" | |
| type: Literal["extrude"] = "extrude" | |
| shape: str = "badge" | |
| depth: float = 0.2 | |
| bevel: bool = True | |
| color: str = "#88ccff" | |
| material: str = "standard" | |
| preset: Optional[str] = None | |
| metalness: float = 0.3 | |
| roughness: float = 0.4 | |
| emissive: str = "#000000" | |
| position: List[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0]) | |
| rotation: List[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0]) | |
| scale: List[float] = Field(default_factory=lambda: [1.0, 1.0, 1.0]) | |
| def _shape(cls, v: Any) -> str: | |
| v = str(v).lower() | |
| return v if v in EXTRUDE_SHAPES else "badge" | |
| def _depth(cls, v: Any) -> float: | |
| try: | |
| return _clamp(float(v), 0.02, 2.0) | |
| except Exception: | |
| return 0.2 | |
| def _hex(cls, v: Any, info) -> str: | |
| default = "#88ccff" if info.field_name == "color" else "#000000" | |
| return _sanitize_color(str(v), default) | |
| def _material(cls, v: Any) -> str: | |
| v = str(v) | |
| return v if v in MATERIALS else "standard" | |
| def _preset_field(cls, v: Any) -> Optional[str]: | |
| if v is None: | |
| return None | |
| v = str(v).lower().strip() | |
| return v if v in PRESET_NAMES else None | |
| def _unit(cls, v: Any) -> float: | |
| try: | |
| return _clamp(float(v), 0.0, 1.0) | |
| except Exception: | |
| return 0.4 | |
| def _vec3(cls, v: Any, info) -> List[float]: | |
| fill = 1.0 if info.field_name == "scale" else 0.0 | |
| out: List[float] = [] | |
| try: | |
| for x in list(v)[:3]: | |
| out.append(float(x)) | |
| except Exception: | |
| out = [] | |
| while len(out) < 3: | |
| out.append(fill) | |
| return out | |
| class Text3DNode(BaseModel): | |
| """3-D text rendered via Three.js TextGeometry + FontLoader (Latin chars only).""" | |
| type: Literal["text3d"] = "text3d" | |
| text: str = "TEXT" | |
| size: float = 0.6 | |
| depth: float = 0.2 | |
| bevel: bool = True | |
| color: str = "#88ccff" | |
| material: str = "standard" | |
| preset: Optional[str] = None | |
| metalness: float = 0.3 | |
| roughness: float = 0.4 | |
| emissive: str = "#000000" | |
| position: List[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0]) | |
| rotation: List[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0]) | |
| scale: List[float] = Field(default_factory=lambda: [1.0, 1.0, 1.0]) | |
| def _text(cls, v: Any) -> str: | |
| v = "".join(c for c in str(v).strip() if c.isprintable() and ord(c) < 128)[:24] | |
| return v or "TEXT" | |
| def _size(cls, v: Any) -> float: | |
| try: | |
| return _clamp(float(v), 0.1, 4.0) | |
| except Exception: | |
| return 0.6 | |
| def _depth(cls, v: Any) -> float: | |
| try: | |
| return _clamp(float(v), 0.05, 1.0) | |
| except Exception: | |
| return 0.2 | |
| def _hex(cls, v: Any, info) -> str: | |
| default = "#88ccff" if info.field_name == "color" else "#000000" | |
| return _sanitize_color(str(v), default) | |
| def _material(cls, v: Any) -> str: | |
| v = str(v) | |
| return v if v in MATERIALS else "standard" | |
| def _preset_field(cls, v: Any) -> Optional[str]: | |
| if v is None: | |
| return None | |
| v = str(v).lower().strip() | |
| return v if v in PRESET_NAMES else None | |
| def _unit(cls, v: Any) -> float: | |
| try: | |
| return _clamp(float(v), 0.0, 1.0) | |
| except Exception: | |
| return 0.4 | |
| def _vec3(cls, v: Any, info) -> List[float]: | |
| fill = 1.0 if info.field_name == "scale" else 0.0 | |
| out: List[float] = [] | |
| try: | |
| for x in list(v)[:3]: | |
| out.append(float(x)) | |
| except Exception: | |
| out = [] | |
| while len(out) < 3: | |
| out.append(fill) | |
| return out | |
| LAYOUT_TYPES = {"none", "row", "column", "stack", "grid"} | |
| class GroupNode(BaseModel): | |
| """A group of child nodes with optional layout and group-level transform.""" | |
| type: Literal["group"] = "group" | |
| layout: str = "none" | |
| gap: float = 0.2 | |
| cols: int = 3 | |
| position: List[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0]) | |
| rotation: List[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0]) | |
| scale: List[float] = Field(default_factory=lambda: [1.0, 1.0, 1.0]) | |
| children: List[Any] = Field(default_factory=list) | |
| def _layout(cls, v: Any) -> str: | |
| v = str(v).lower().strip() | |
| return v if v in LAYOUT_TYPES else "none" | |
| def _gap(cls, v: Any) -> float: | |
| try: | |
| return _clamp(float(v), 0.0, 20.0) | |
| except Exception: | |
| return 0.2 | |
| def _cols(cls, v: Any) -> int: | |
| try: | |
| return max(1, min(int(v), 20)) | |
| except Exception: | |
| return 3 | |
| def _vec3(cls, v: Any, info) -> List[float]: | |
| fill = 1.0 if info.field_name == "scale" else 0.0 | |
| out: List[float] = [] | |
| try: | |
| for x in list(v)[:3]: | |
| out.append(float(x)) | |
| except Exception: | |
| out = [] | |
| while len(out) < 3: | |
| out.append(fill) | |
| return out | |
| def _children(cls, v: Any) -> List[Any]: | |
| return [_parse_scene_item(c) for c in v] if isinstance(v, list) else [] | |
| def _parse_scene_item(v: Any) -> Any: | |
| """Parse a dict (or existing model) into the correct scene node type.""" | |
| if isinstance(v, (Obj, LayoutStack, LayoutRow, LayoutGrid, ExtrudeNode, Text3DNode, GroupNode)): | |
| return v | |
| if not isinstance(v, dict): | |
| return Obj() | |
| t = v.get("type", "") | |
| try: | |
| if t == "group": | |
| return GroupNode(**v) | |
| if t == "stack": | |
| return LayoutStack(**v) | |
| if t == "row": | |
| return LayoutRow(**v) | |
| if t == "grid": | |
| return LayoutGrid(**v) | |
| if t == "extrude": | |
| return ExtrudeNode(**v) | |
| if t == "text3d": | |
| return Text3DNode(**v) | |
| return Obj(**v) | |
| except Exception: | |
| return Obj() | |
| # ---- Composite templates (deterministic Python expansions) ---- | |
| def _template_burger(params: Dict[str, Any]) -> List[Obj]: | |
| bun = params.get("color_bun", "#c8a96e") | |
| patty = params.get("color_patty", "#5a3a1a") | |
| lettuce = params.get("color_lettuce", "#3a8a3a") | |
| return [ | |
| Obj(shape="sphere", color=bun, position=[0, 0.65, 0], params={"radius": 0.45}, roughness=0.7), | |
| Obj(shape="cylinder", color=lettuce, position=[0, 0.28, 0], params={"radiusTop": 0.52, "radiusBottom": 0.52, "height": 0.1}, roughness=0.9), | |
| Obj(shape="cylinder", color=patty, position=[0, 0.12, 0], params={"radiusTop": 0.5, "radiusBottom": 0.5, "height": 0.18}, roughness=0.8), | |
| Obj(shape="cylinder", color=bun, position=[0, -0.18, 0], params={"radiusTop": 0.52, "radiusBottom": 0.55, "height": 0.32}, roughness=0.7), | |
| ] | |
| def _template_snowman(params: Dict[str, Any]) -> List[Obj]: | |
| body = params.get("color_body", "#e8e8e8") | |
| hat = params.get("color_hat", "#1a1a1a") | |
| return [ | |
| Obj(shape="sphere", color=body, position=[0, -0.55, 0], params={"radius": 0.5}, roughness=0.9), | |
| Obj(shape="sphere", color=body, position=[0, 0.2, 0], params={"radius": 0.35}, roughness=0.9), | |
| Obj(shape="sphere", color=body, position=[0, 0.82, 0], params={"radius": 0.24}, roughness=0.9), | |
| Obj(shape="cylinder", color=hat, position=[0, 1.18, 0], params={"radiusTop": 0.16, "radiusBottom": 0.26, "height": 0.34}), | |
| ] | |
| def _template_tree(params: Dict[str, Any]) -> List[Obj]: | |
| leaves = params.get("color_leaves", "#2e8b57") | |
| trunk = params.get("color_trunk", "#8b5a2b") | |
| return [ | |
| Obj(shape="cylinder", color=trunk, position=[0, -0.6, 0], params={"radiusTop": 0.12, "radiusBottom": 0.15, "height": 0.8}, roughness=0.9), | |
| Obj(shape="cone", color=leaves, position=[0, 0.2, 0], params={"radius": 0.7, "height": 1.0}, roughness=0.8), | |
| Obj(shape="cone", color=leaves, position=[0, 0.72, 0], params={"radius": 0.55, "height": 0.8}, roughness=0.8), | |
| Obj(shape="cone", color=leaves, position=[0, 1.14, 0], params={"radius": 0.4, "height": 0.65}, roughness=0.8), | |
| ] | |
| def _template_nested_spheres(params: Dict[str, Any]) -> List[Obj]: | |
| inner = params.get("color_inner", "red") | |
| outer = params.get("color_outer", "blue") | |
| return [ | |
| Obj(shape="sphere", color=outer, material="wireframe", | |
| position=[0, 0, 0], params={"radius": 0.8}), | |
| Obj(shape="sphere", color=inner, material="wireframe", | |
| position=[0, 0, 0], params={"radius": 0.45}), | |
| ] | |
| # Per-shape: safe text face width (world units after normalization to 1.5) | |
| # and a small y nudge so text sits in the visual body, not the tapered parts. | |
| _BADGE_SHAPE_PARAMS: Dict[str, Dict[str, float]] = { | |
| "star": {"safe_w": 0.85, "text_y": 0.0}, | |
| "heart": {"safe_w": 0.65, "text_y": 0.2}, | |
| "hexagon": {"safe_w": 1.1, "text_y": 0.0}, | |
| "badge": {"safe_w": 1.2, "text_y": 0.0}, | |
| "shield": {"safe_w": 0.9, "text_y": 0.1}, | |
| } | |
| _CHAR_W = 0.65 # helvetiker average char width per size=1.0 unit | |
| def _template_badge_with_text(params: Dict[str, Any]) -> List[Any]: | |
| """Deterministic badge+text: model supplies shape/text/colors; compiler sets layout.""" | |
| shape = str(params.get("shape", "star")).lower() | |
| if shape not in EXTRUDE_SHAPES: | |
| shape = "star" | |
| text_raw = str(params.get("text", "TEXT")) | |
| text = "".join(c for c in text_raw if c.isprintable() and ord(c) < 128)[:24].strip() or "TEXT" | |
| color_badge = _sanitize_color(str(params.get("color_badge", "#3a6bc4")), "#3a6bc4") | |
| color_text = _sanitize_color(str(params.get("color_text", "#ffffff")), "#ffffff") | |
| badge_metal = _clamp(float(params.get("metalness", 0.5)), 0.0, 1.0) | |
| badge_rough = _clamp(float(params.get("roughness", 0.25)), 0.0, 1.0) | |
| preset_badge = str(params.get("preset_badge", "")) or None | |
| if preset_badge not in PRESET_NAMES: | |
| preset_badge = None | |
| preset_text = str(params.get("preset_text", "")) or None | |
| if preset_text not in PRESET_NAMES: | |
| preset_text = None | |
| sp = _BADGE_SHAPE_PARAMS.get(shape, {"safe_w": 1.0, "text_y": 0.0}) | |
| # Scale text so it fills ~80 % of the badge face width | |
| text_size = round( | |
| _clamp(sp["safe_w"] * 0.8 / (max(1, len(text)) * _CHAR_W), 0.12, 0.55), 3 | |
| ) | |
| return [ | |
| ExtrudeNode( | |
| shape=shape, depth=0.15, bevel=True, | |
| color=color_badge, preset=preset_badge, | |
| metalness=badge_metal, roughness=badge_rough, | |
| emissive="#000000", position=[0.0, 0.0, 0.0], | |
| ), | |
| Text3DNode( | |
| text=text, size=text_size, depth=0.06, bevel=True, | |
| color=color_text, preset=preset_text, | |
| metalness=0.1, roughness=0.4, emissive="#000000", | |
| # z=0.15 always clears the badge front face (badge front ≈ 0.05–0.07 after scale) | |
| position=[0.0, sp["text_y"], 0.15], | |
| ), | |
| ] | |
| TEMPLATES: Dict[str, Any] = { | |
| "burger": _template_burger, | |
| "snowman": _template_snowman, | |
| "tree": _template_tree, | |
| "nested_spheres": _template_nested_spheres, | |
| "badge_with_text": _template_badge_with_text, | |
| } | |
| class Scene(BaseModel): | |
| background: str = "#0b0e14" | |
| objects: List[Union[Obj, LayoutStack, LayoutRow, LayoutGrid, ExtrudeNode, Text3DNode, GroupNode]] = Field(default_factory=list) | |
| lights: List[Light] = Field(default_factory=list) | |
| animation: Animation = Field(default_factory=Animation) | |
| def _bg(cls, v: Any) -> str: | |
| return _sanitize_color(str(v), "#0b0e14") | |
| def _objects(cls, v: Any) -> List[Any]: | |
| if not isinstance(v, list): | |
| return [] | |
| return [_parse_scene_item(item) for item in v] | |
| def extract_json(text: str) -> Optional[dict]: | |
| """Pull the first balanced JSON object out of a raw model response.""" | |
| if not text: | |
| return None | |
| text = text.strip() | |
| text = re.sub(r"^```(?:json)?", "", text).strip() | |
| text = re.sub(r"```$", "", text).strip() | |
| start = text.find("{") | |
| end = text.rfind("}") | |
| if start == -1 or end == -1 or end <= start: | |
| return None | |
| try: | |
| return json.loads(text[start:end + 1]) | |
| except Exception: | |
| return None | |
| def build_scene(data: Optional[dict]) -> Scene: | |
| """Validate/repair into a Scene, guaranteeing something renderable.""" | |
| if not isinstance(data, dict): | |
| log.warning("build_scene: no valid dict, using empty scene") | |
| data = {} | |
| # Expand named templates into flat object lists before schema validation | |
| tmpl = data.get("template") | |
| if isinstance(tmpl, dict) and not data.get("objects"): | |
| name = tmpl.get("name", "") | |
| if name in TEMPLATES: | |
| log.info("Expanding template: %s", name) | |
| data = {k: v for k, v in data.items() if k != "template"} | |
| data["objects"] = [o.model_dump() for o in TEMPLATES[name](tmpl)] | |
| try: | |
| scene = Scene(**data) | |
| except Exception as e: | |
| log.warning("Scene validation failed (%s), falling back to default", type(e).__name__) | |
| scene = Scene() | |
| if not scene.objects: | |
| log.warning("build_scene: no objects, inserting default box") | |
| scene.objects = [Obj()] | |
| if not scene.lights: | |
| log.warning("build_scene: no lights, inserting defaults") | |
| scene.lights = [ | |
| Light(type="ambient", intensity=0.5), | |
| Light(type="directional", intensity=1.3, position=[5, 8, 6]), | |
| ] | |
| return scene | |