bolajiev commited on
Commit ·
e16ccbe
1
Parent(s): f0a5709
Four targeted fixes: color pass-through, re-render on style/motion, auto-reroll, nested-spheres template
Browse files- scene.py: replace strict hex-only validator with _sanitize_color() — accepts CSS
named colors (red, blue, forestgreen, …), synonym map (electric blue→#7df9ff,
neon→#39ff14), rgb()/hsl(), and 3/4/8-digit hex; falls back to default on unknown
- scene.py: add _template_nested_spheres (outer blue wireframe r=0.8, inner red r=0.45)
- compiler.py: emit new THREE.Color("value") instead of 0xRRGGBB so CSS names render
- app.py: gr.State caches last Scene; style/motion/glow changes call lightweight
rerender() with no LLM invocation
- app.py: auto-reroll once on bad JSON before falling back to mock
- app.py: prompts matching "sphere inside" / "nested sphere" route to template
- app.py +75 -18
- compiler.py +6 -5
- scene.py +87 -11
app.py
CHANGED
|
@@ -15,7 +15,7 @@ import gradio as gr
|
|
| 15 |
|
| 16 |
from compiler import compile_html, iframe
|
| 17 |
from llm import MAX_PROMPT_CHARS, mock_scene_json, run_llm
|
| 18 |
-
from scene import build_scene, extract_json
|
| 19 |
|
| 20 |
logging.basicConfig(
|
| 21 |
level=logging.INFO,
|
|
@@ -51,34 +51,81 @@ EXAMPLES = [
|
|
| 51 |
]
|
| 52 |
|
| 53 |
|
|
|
|
|
|
|
|
|
|
| 54 |
def generate(prompt: str, glow: bool, glow_strength: float, style: str, motion: str):
|
| 55 |
prompt = (prompt or "").strip()
|
| 56 |
if not prompt:
|
| 57 |
-
return gr.update(), gr.update(), gr.update(), "Type a prompt first."
|
| 58 |
if len(prompt) > MAX_PROMPT_CHARS:
|
| 59 |
-
return gr.update(), gr.update(), gr.update(), f"Prompt too long — keep it under {MAX_PROMPT_CHARS} chars."
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
-
scene = build_scene(extract_json(raw))
|
| 70 |
if motion != "auto":
|
| 71 |
scene.animation.type = motion
|
| 72 |
html = compile_html(scene, glow=glow, glow_strength=glow_strength, style=style)
|
| 73 |
pretty = json.dumps(scene.model_dump(), indent=2)
|
| 74 |
-
return iframe(html), html, pretty, note
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
|
| 77 |
def _initial():
|
| 78 |
raw = mock_scene_json("a glass torus knot floating in the dark")
|
| 79 |
scene = build_scene(extract_json(raw))
|
| 80 |
html = compile_html(scene)
|
| 81 |
-
return iframe(html), html, json.dumps(scene.model_dump(), indent=2), "Demo scene."
|
| 82 |
|
| 83 |
|
| 84 |
_LOGO = os.path.exists("logo.png")
|
|
@@ -126,10 +173,20 @@ with gr.Blocks(title="ThreeGen", theme=gr.themes.Soft()) as demo:
|
|
| 126 |
with gr.Accordion("Scene JSON (raw model output)", open=False):
|
| 127 |
scene_json = gr.Code(language="json", label="scene graph")
|
| 128 |
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
|
| 135 |
if __name__ == "__main__":
|
|
|
|
| 15 |
|
| 16 |
from compiler import compile_html, iframe
|
| 17 |
from llm import MAX_PROMPT_CHARS, mock_scene_json, run_llm
|
| 18 |
+
from scene import TEMPLATES, build_scene, extract_json
|
| 19 |
|
| 20 |
logging.basicConfig(
|
| 21 |
level=logging.INFO,
|
|
|
|
| 51 |
]
|
| 52 |
|
| 53 |
|
| 54 |
+
_NESTED_SPHERE_PATTERNS = ("wireframe sphere inside", "sphere inside", "nested sphere")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
def generate(prompt: str, glow: bool, glow_strength: float, style: str, motion: str):
|
| 58 |
prompt = (prompt or "").strip()
|
| 59 |
if not prompt:
|
| 60 |
+
return gr.update(), gr.update(), gr.update(), "Type a prompt first.", None
|
| 61 |
if len(prompt) > MAX_PROMPT_CHARS:
|
| 62 |
+
return gr.update(), gr.update(), gr.update(), f"Prompt too long — keep it under {MAX_PROMPT_CHARS} chars.", None
|
| 63 |
+
|
| 64 |
+
lo = prompt.lower()
|
| 65 |
+
|
| 66 |
+
# Fix 4: route nested-sphere prompts to the hardcoded template
|
| 67 |
+
if any(pat in lo for pat in _NESTED_SPHERE_PATTERNS):
|
| 68 |
+
from scene import Light, Animation
|
| 69 |
+
scene = build_scene({
|
| 70 |
+
"background": "#0b0e14",
|
| 71 |
+
"objects": [o.model_dump() for o in TEMPLATES["nested_spheres"]({})],
|
| 72 |
+
"lights": [
|
| 73 |
+
{"type": "ambient", "intensity": 0.5},
|
| 74 |
+
{"type": "directional", "intensity": 1.3, "position": [5, 8, 6]},
|
| 75 |
+
],
|
| 76 |
+
"animation": {"type": "rotate", "speed": 0.8, "axis": "y"},
|
| 77 |
+
})
|
| 78 |
+
note = "Template (nested spheres)."
|
| 79 |
+
else:
|
| 80 |
+
# Fix 3: generate, then reroll once if the output is unparseable
|
| 81 |
+
try:
|
| 82 |
+
if os.environ.get("MOCK"):
|
| 83 |
+
raw = mock_scene_json(prompt)
|
| 84 |
+
else:
|
| 85 |
+
raw = _generate_json(prompt)
|
| 86 |
+
if extract_json(raw) is None:
|
| 87 |
+
log.warning("Bad JSON on first attempt — retrying")
|
| 88 |
+
raw = _generate_json(prompt)
|
| 89 |
+
note = "Rendered."
|
| 90 |
+
except Exception as e:
|
| 91 |
+
log.error("LLM error, falling back to mock: %s", e, exc_info=True)
|
| 92 |
+
raw = mock_scene_json(prompt)
|
| 93 |
+
note = f"Fallback ({type(e).__name__})."
|
| 94 |
+
|
| 95 |
+
parsed = extract_json(raw)
|
| 96 |
+
if parsed is None:
|
| 97 |
+
log.warning("Both LLM attempts returned bad JSON — using mock")
|
| 98 |
+
raw = mock_scene_json(prompt)
|
| 99 |
+
note = "Fallback (bad JSON)."
|
| 100 |
+
parsed = extract_json(raw)
|
| 101 |
+
|
| 102 |
+
scene = build_scene(parsed)
|
| 103 |
|
|
|
|
| 104 |
if motion != "auto":
|
| 105 |
scene.animation.type = motion
|
| 106 |
html = compile_html(scene, glow=glow, glow_strength=glow_strength, style=style)
|
| 107 |
pretty = json.dumps(scene.model_dump(), indent=2)
|
| 108 |
+
return iframe(html), html, pretty, note, scene
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# Fix 2: lightweight recompile from cached scene (no LLM call)
|
| 112 |
+
def rerender(scene, glow: bool, glow_strength: float, style: str, motion: str):
|
| 113 |
+
if scene is None:
|
| 114 |
+
return gr.update(), gr.update(), "Generate a scene first."
|
| 115 |
+
if motion != "auto":
|
| 116 |
+
s = scene.model_copy(deep=True)
|
| 117 |
+
s.animation.type = motion
|
| 118 |
+
else:
|
| 119 |
+
s = scene
|
| 120 |
+
html = compile_html(s, glow=glow, glow_strength=glow_strength, style=style)
|
| 121 |
+
return iframe(html), html, "Re-rendered."
|
| 122 |
|
| 123 |
|
| 124 |
def _initial():
|
| 125 |
raw = mock_scene_json("a glass torus knot floating in the dark")
|
| 126 |
scene = build_scene(extract_json(raw))
|
| 127 |
html = compile_html(scene)
|
| 128 |
+
return iframe(html), html, json.dumps(scene.model_dump(), indent=2), "Demo scene.", scene
|
| 129 |
|
| 130 |
|
| 131 |
_LOGO = os.path.exists("logo.png")
|
|
|
|
| 173 |
with gr.Accordion("Scene JSON (raw model output)", open=False):
|
| 174 |
scene_json = gr.Code(language="json", label="scene graph")
|
| 175 |
|
| 176 |
+
scene_state = gr.State(None)
|
| 177 |
+
|
| 178 |
+
gen_inputs = [prompt, glow, glow_strength, style, motion]
|
| 179 |
+
gen_outputs = [preview, code, scene_json, status, scene_state]
|
| 180 |
+
btn.click(generate, gen_inputs, gen_outputs)
|
| 181 |
+
prompt.submit(generate, gen_inputs, gen_outputs)
|
| 182 |
+
demo.load(_initial, None, gen_outputs)
|
| 183 |
+
|
| 184 |
+
re_inputs = [scene_state, glow, glow_strength, style, motion]
|
| 185 |
+
re_outputs = [preview, code, status]
|
| 186 |
+
style.change(rerender, re_inputs, re_outputs)
|
| 187 |
+
motion.change(rerender, re_inputs, re_outputs)
|
| 188 |
+
glow.change(rerender, re_inputs, re_outputs)
|
| 189 |
+
glow_strength.change(rerender, re_inputs, re_outputs)
|
| 190 |
|
| 191 |
|
| 192 |
if __name__ == "__main__":
|
compiler.py
CHANGED
|
@@ -12,8 +12,9 @@ from typing import Any
|
|
| 12 |
from scene import Animation, LayoutGrid, LayoutRow, LayoutStack, Obj, Scene, _shape_extent
|
| 13 |
|
| 14 |
|
| 15 |
-
def
|
| 16 |
-
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
def geometry_js(o: Obj) -> str:
|
|
@@ -68,8 +69,8 @@ def geometry_js(o: Obj) -> str:
|
|
| 68 |
|
| 69 |
|
| 70 |
def material_js(o: Obj, style: str = "realistic") -> str:
|
| 71 |
-
col =
|
| 72 |
-
emi =
|
| 73 |
if style == "wireframe":
|
| 74 |
return f"new THREE.MeshStandardMaterial({{ color: {col}, wireframe: true }})"
|
| 75 |
if style == "toon":
|
|
@@ -107,7 +108,7 @@ def objects_js(scene: Scene, style: str = "realistic") -> str:
|
|
| 107 |
def lights_js(scene: Scene) -> str:
|
| 108 |
lines = []
|
| 109 |
for l in scene.lights:
|
| 110 |
-
c =
|
| 111 |
x, y, z = l.position
|
| 112 |
if l.type == "ambient":
|
| 113 |
lines.append(f"scene.add(new THREE.AmbientLight({c}, {l.intensity}));")
|
|
|
|
| 12 |
from scene import Animation, LayoutGrid, LayoutRow, LayoutStack, Obj, Scene, _shape_extent
|
| 13 |
|
| 14 |
|
| 15 |
+
def _col_str(color: str) -> str:
|
| 16 |
+
"""Wrap a sanitized color value for JS: 'new THREE.Color("<value>")'."""
|
| 17 |
+
return f'new THREE.Color("{color}")'
|
| 18 |
|
| 19 |
|
| 20 |
def geometry_js(o: Obj) -> str:
|
|
|
|
| 69 |
|
| 70 |
|
| 71 |
def material_js(o: Obj, style: str = "realistic") -> str:
|
| 72 |
+
col = _col_str(o.color)
|
| 73 |
+
emi = _col_str(o.emissive)
|
| 74 |
if style == "wireframe":
|
| 75 |
return f"new THREE.MeshStandardMaterial({{ color: {col}, wireframe: true }})"
|
| 76 |
if style == "toon":
|
|
|
|
| 108 |
def lights_js(scene: Scene) -> str:
|
| 109 |
lines = []
|
| 110 |
for l in scene.lights:
|
| 111 |
+
c = f'"{l.color}"'
|
| 112 |
x, y, z = l.position
|
| 113 |
if l.type == "ambient":
|
| 114 |
lines.append(f"scene.add(new THREE.AmbientLight({c}, {l.intensity}));")
|
scene.py
CHANGED
|
@@ -26,6 +26,74 @@ LIGHT_TYPES = {"ambient", "directional", "point"}
|
|
| 26 |
ANIM_TYPES = {"none", "rotate", "float", "orbit"}
|
| 27 |
HEX = re.compile(r"^#[0-9a-fA-F]{6}$")
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
def _clamp(v: float, lo: float, hi: float) -> float:
|
| 31 |
return max(lo, min(hi, v))
|
|
@@ -106,10 +174,8 @@ class Obj(BaseModel):
|
|
| 106 |
@field_validator("color", "emissive")
|
| 107 |
@classmethod
|
| 108 |
def _hex(cls, v: Any, info) -> str:
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
return v
|
| 112 |
-
return "#88ccff" if info.field_name == "color" else "#000000"
|
| 113 |
|
| 114 |
@field_validator("metalness", "roughness")
|
| 115 |
@classmethod
|
|
@@ -147,8 +213,7 @@ class Light(BaseModel):
|
|
| 147 |
@field_validator("color")
|
| 148 |
@classmethod
|
| 149 |
def _hex(cls, v: Any) -> str:
|
| 150 |
-
|
| 151 |
-
return v if HEX.match(v) else "#ffffff"
|
| 152 |
|
| 153 |
@field_validator("intensity")
|
| 154 |
@classmethod
|
|
@@ -345,10 +410,22 @@ def _template_tree(params: Dict[str, Any]) -> List[Obj]:
|
|
| 345 |
]
|
| 346 |
|
| 347 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
TEMPLATES: Dict[str, Any] = {
|
| 349 |
-
"burger":
|
| 350 |
-
"snowman":
|
| 351 |
-
"tree":
|
|
|
|
| 352 |
}
|
| 353 |
|
| 354 |
|
|
@@ -361,8 +438,7 @@ class Scene(BaseModel):
|
|
| 361 |
@field_validator("background")
|
| 362 |
@classmethod
|
| 363 |
def _bg(cls, v: Any) -> str:
|
| 364 |
-
|
| 365 |
-
return v if HEX.match(v) else "#0b0e14"
|
| 366 |
|
| 367 |
@field_validator("objects", mode="before")
|
| 368 |
@classmethod
|
|
|
|
| 26 |
ANIM_TYPES = {"none", "rotate", "float", "orbit"}
|
| 27 |
HEX = re.compile(r"^#[0-9a-fA-F]{6}$")
|
| 28 |
|
| 29 |
+
# ---- Color normalisation (Fix 1) ----
|
| 30 |
+
|
| 31 |
+
_SYNONYMS: Dict[str, str] = {
|
| 32 |
+
"electric blue": "#7df9ff", "electricblue": "#7df9ff",
|
| 33 |
+
"neon green": "#39ff14", "neongreen": "#39ff14",
|
| 34 |
+
"neon": "#39ff14",
|
| 35 |
+
"neon blue": "#4d4dff", "neonblue": "#4d4dff",
|
| 36 |
+
"neon red": "#ff3131", "neonred": "#ff3131",
|
| 37 |
+
"neon pink": "#ff6ec7", "neonpink": "#ff6ec7",
|
| 38 |
+
"neon yellow": "#ffff00", "neonyellow": "#ffff00",
|
| 39 |
+
"neon orange": "#ff6600", "neonorange": "#ff6600",
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
_CSS_COLORS: frozenset = frozenset({
|
| 43 |
+
"aliceblue", "antiquewhite", "aqua", "aquamarine", "azure", "beige",
|
| 44 |
+
"bisque", "black", "blanchedalmond", "blue", "blueviolet", "brown",
|
| 45 |
+
"burlywood", "cadetblue", "chartreuse", "chocolate", "coral",
|
| 46 |
+
"cornflowerblue", "cornsilk", "crimson", "cyan", "darkblue", "darkcyan",
|
| 47 |
+
"darkgoldenrod", "darkgray", "darkgreen", "darkgrey", "darkkhaki",
|
| 48 |
+
"darkmagenta", "darkolivegreen", "darkorange", "darkorchid", "darkred",
|
| 49 |
+
"darksalmon", "darkseagreen", "darkslateblue", "darkslategray",
|
| 50 |
+
"darkslategrey", "darkturquoise", "darkviolet", "deeppink", "deepskyblue",
|
| 51 |
+
"dimgray", "dimgrey", "dodgerblue", "firebrick", "floralwhite",
|
| 52 |
+
"forestgreen", "fuchsia", "gainsboro", "ghostwhite", "gold", "goldenrod",
|
| 53 |
+
"gray", "green", "greenyellow", "grey", "honeydew", "hotpink",
|
| 54 |
+
"indianred", "indigo", "ivory", "khaki", "lavender", "lavenderblush",
|
| 55 |
+
"lawngreen", "lemonchiffon", "lightblue", "lightcoral", "lightcyan",
|
| 56 |
+
"lightgoldenrodyellow", "lightgray", "lightgreen", "lightgrey",
|
| 57 |
+
"lightpink", "lightsalmon", "lightseagreen", "lightskyblue",
|
| 58 |
+
"lightslategray", "lightslategrey", "lightsteelblue", "lightyellow",
|
| 59 |
+
"lime", "limegreen", "linen", "magenta", "maroon", "mediumaquamarine",
|
| 60 |
+
"mediumblue", "mediumorchid", "mediumpurple", "mediumseagreen",
|
| 61 |
+
"mediumslateblue", "mediumspringgreen", "mediumturquoise",
|
| 62 |
+
"mediumvioletred", "midnightblue", "mintcream", "mistyrose", "moccasin",
|
| 63 |
+
"navajowhite", "navy", "oldlace", "olive", "olivedrab", "orange",
|
| 64 |
+
"orangered", "orchid", "palegoldenrod", "palegreen", "paleturquoise",
|
| 65 |
+
"palevioletred", "papayawhip", "peachpuff", "peru", "pink", "plum",
|
| 66 |
+
"powderblue", "purple", "red", "rosybrown", "royalblue", "saddlebrown",
|
| 67 |
+
"salmon", "sandybrown", "seagreen", "seashell", "sienna", "silver",
|
| 68 |
+
"skyblue", "slateblue", "slategray", "slategrey", "snow", "springgreen",
|
| 69 |
+
"steelblue", "tan", "teal", "thistle", "tomato", "turquoise", "violet",
|
| 70 |
+
"wheat", "white", "whitesmoke", "yellow", "yellowgreen",
|
| 71 |
+
})
|
| 72 |
+
|
| 73 |
+
_HEX_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$")
|
| 74 |
+
_RGB_HSL_RE = re.compile(
|
| 75 |
+
r"^(rgb|hsl)a?\(\s*[\d.]+%?\s*,\s*[\d.]+%?\s*,\s*[\d.]+%?\s*(?:,\s*[\d.]+)?\s*\)$"
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _sanitize_color(v: str, default: str = "#888888") -> str:
|
| 80 |
+
"""Accept hex, rgb/hsl(), CSS/X11 names, and synonym map. Reject anything else."""
|
| 81 |
+
v = str(v).strip()
|
| 82 |
+
lo = v.lower()
|
| 83 |
+
if lo in _SYNONYMS:
|
| 84 |
+
return _SYNONYMS[lo]
|
| 85 |
+
collapsed = lo.replace(" ", "").replace("-", "")
|
| 86 |
+
if collapsed in _SYNONYMS:
|
| 87 |
+
return _SYNONYMS[collapsed]
|
| 88 |
+
if _HEX_RE.match(v):
|
| 89 |
+
return v
|
| 90 |
+
if _RGB_HSL_RE.match(lo):
|
| 91 |
+
return lo
|
| 92 |
+
if collapsed in _CSS_COLORS:
|
| 93 |
+
return collapsed
|
| 94 |
+
log.warning("Unknown color %r, using default %s", v, default)
|
| 95 |
+
return default
|
| 96 |
+
|
| 97 |
|
| 98 |
def _clamp(v: float, lo: float, hi: float) -> float:
|
| 99 |
return max(lo, min(hi, v))
|
|
|
|
| 174 |
@field_validator("color", "emissive")
|
| 175 |
@classmethod
|
| 176 |
def _hex(cls, v: Any, info) -> str:
|
| 177 |
+
default = "#88ccff" if info.field_name == "color" else "#000000"
|
| 178 |
+
return _sanitize_color(str(v), default)
|
|
|
|
|
|
|
| 179 |
|
| 180 |
@field_validator("metalness", "roughness")
|
| 181 |
@classmethod
|
|
|
|
| 213 |
@field_validator("color")
|
| 214 |
@classmethod
|
| 215 |
def _hex(cls, v: Any) -> str:
|
| 216 |
+
return _sanitize_color(str(v), "#ffffff")
|
|
|
|
| 217 |
|
| 218 |
@field_validator("intensity")
|
| 219 |
@classmethod
|
|
|
|
| 410 |
]
|
| 411 |
|
| 412 |
|
| 413 |
+
def _template_nested_spheres(params: Dict[str, Any]) -> List[Obj]:
|
| 414 |
+
inner = params.get("color_inner", "red")
|
| 415 |
+
outer = params.get("color_outer", "blue")
|
| 416 |
+
return [
|
| 417 |
+
Obj(shape="sphere", color=outer, material="wireframe",
|
| 418 |
+
position=[0, 0, 0], params={"radius": 0.8}),
|
| 419 |
+
Obj(shape="sphere", color=inner, material="wireframe",
|
| 420 |
+
position=[0, 0, 0], params={"radius": 0.45}),
|
| 421 |
+
]
|
| 422 |
+
|
| 423 |
+
|
| 424 |
TEMPLATES: Dict[str, Any] = {
|
| 425 |
+
"burger": _template_burger,
|
| 426 |
+
"snowman": _template_snowman,
|
| 427 |
+
"tree": _template_tree,
|
| 428 |
+
"nested_spheres": _template_nested_spheres,
|
| 429 |
}
|
| 430 |
|
| 431 |
|
|
|
|
| 438 |
@field_validator("background")
|
| 439 |
@classmethod
|
| 440 |
def _bg(cls, v: Any) -> str:
|
| 441 |
+
return _sanitize_color(str(v), "#0b0e14")
|
|
|
|
| 442 |
|
| 443 |
@field_validator("objects", mode="before")
|
| 444 |
@classmethod
|