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

Files changed (3) hide show
  1. app.py +75 -18
  2. compiler.py +6 -5
  3. 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
- try:
62
- raw = mock_scene_json(prompt) if os.environ.get("MOCK") else _generate_json(prompt)
63
- note = "Rendered."
64
- except Exception as e:
65
- log.error("LLM error, falling back to mock: %s", e, exc_info=True)
66
- raw = mock_scene_json(prompt)
67
- note = f"Fallback ({type(e).__name__})."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- outputs = [preview, code, scene_json, status]
130
- btn.click(generate, [prompt, glow, glow_strength, style, motion], outputs)
131
- prompt.submit(generate, [prompt, glow, glow_strength, style, motion], outputs)
132
- demo.load(_initial, None, outputs)
 
 
 
 
 
 
 
 
 
 
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 _hex0x(color: str) -> str:
16
- return "0x" + color[1:]
 
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 = _hex0x(o.color)
72
- emi = _hex0x(o.emissive)
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 = _hex0x(l.color)
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
- v = str(v)
110
- if HEX.match(v):
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
- v = str(v)
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": _template_burger,
350
- "snowman": _template_snowman,
351
- "tree": _template_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
- v = str(v)
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