bolajiev commited on
Commit
82b7ebe
·
1 Parent(s): 1fe3079

Sprint 1: ExtrudeNode type — star, heart, hexagon, badge, shield

Browse files

scene.py:
- Add EXTRUDE_SHAPES enum set {"star","heart","hexagon","badge","shield"}
- Add ExtrudeNode Pydantic model: type discriminator, shape (validated + fallback
to badge), depth (clamped 0.02-2.0), bevel bool, full material/position fields
- Update _parse_scene_item to route type:"extrude" dicts to ExtrudeNode
- Extend Scene.objects union to include ExtrudeNode

compiler.py:
- SHAPE_LIBRARY: 5 path functions (_shp_star/_shp_heart/_shp_hexagon/_shp_badge/
_shp_shield) returning JS lines; all paths in unit-scale space (~2-unit max extent)
so bevel values (0.03) and depth are scale-consistent across shapes
- extrude_js(): emits a self-contained JS block scope with THREE.ExtrudeGeometry
(explicit: depth/bevelEnabled/bevelThickness 0.03/bevelSize 0.03/bevelSegments 3/
curveSegments 12/steps 1), geometry.center(), computeBoundingBox + uniform scale
to 1.5-unit max dimension so heart and star render at comparable sizes
- _flatten / _node_axis_size updated for ExtrudeNode (treated as 1.5-unit leaf)
- objects_js dispatches to extrude_js for ExtrudeNode, existing Obj path unchanged

llm.py:
- SYSTEM extended with extrude schema and usage hint
- Two new FEWSHOT examples: "a gold star badge" → extrude star (gold, high metalness),
"a shield emblem" → extrude shield (blue)

Files changed (3) hide show
  1. compiler.py +144 -15
  2. llm.py +51 -7
  3. scene.py +69 -2
compiler.py CHANGED
@@ -7,9 +7,10 @@ and the "copy code" tab, so what the user sees is exactly what they copy.
7
  from __future__ import annotations
8
 
9
  import base64
10
- from typing import Any
11
 
12
- from scene import Animation, LayoutGrid, LayoutRow, LayoutStack, Obj, Scene, _shape_extent
 
13
 
14
 
15
  def _col_str(color: str) -> str:
@@ -91,17 +92,20 @@ def material_js(o: Obj, style: str = "realistic") -> str:
91
 
92
  def objects_js(scene: Scene, style: str = "realistic") -> str:
93
  lines = []
94
- for i, o in enumerate(_flatten(scene.objects)):
95
- px, py, pz = o.position
96
- rx, ry, rz = o.rotation
97
- sx, sy, sz = o.scale
98
- lines += [
99
- f"const mesh{i} = new THREE.Mesh({geometry_js(o)}, {material_js(o, style)});",
100
- f"mesh{i}.position.set({px}, {py}, {pz});",
101
- f"mesh{i}.rotation.set({rx}, {ry}, {rz});",
102
- f"mesh{i}.scale.set({sx}, {sy}, {sz});",
103
- f"group.add(mesh{i});",
104
- ]
 
 
 
105
  return "\n ".join(lines)
106
 
107
 
@@ -210,12 +214,137 @@ def animation_js(a: Animation) -> str:
210
  return ""
211
 
212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  # ---- Layout resolver: flattens containers into positioned Obj list ----
214
 
215
  def _node_axis_size(node: Any, axis: int) -> float:
216
  """Estimate the bounding-box size of a node along one axis (0=x,1=y,2=z)."""
217
  if isinstance(node, Obj):
218
  return _shape_extent(node.shape, node.params)[axis]
 
 
219
  if isinstance(node, LayoutStack):
220
  ai = {"x": 0, "y": 1, "z": 2}.get(node.axis, 1)
221
  sizes = [_node_axis_size(c, ai) for c in node.children]
@@ -224,10 +353,10 @@ def _node_axis_size(node: Any, axis: int) -> float:
224
 
225
 
226
  def _flatten(items: list, ox: float = 0.0, oy: float = 0.0, oz: float = 0.0) -> list:
227
- """Recursively resolve layout containers into a flat list of positioned Obj."""
228
  result = []
229
  for item in items:
230
- if isinstance(item, Obj):
231
  result.append(item.model_copy(update={
232
  "position": [item.position[0] + ox,
233
  item.position[1] + oy,
 
7
  from __future__ import annotations
8
 
9
  import base64
10
+ from typing import Any, Dict
11
 
12
+ from scene import (Animation, ExtrudeNode, LayoutGrid, LayoutRow, LayoutStack,
13
+ Obj, Scene, _shape_extent)
14
 
15
 
16
  def _col_str(color: str) -> str:
 
92
 
93
  def objects_js(scene: Scene, style: str = "realistic") -> str:
94
  lines = []
95
+ for i, node in enumerate(_flatten(scene.objects)):
96
+ if isinstance(node, ExtrudeNode):
97
+ lines.extend(extrude_js(node, i, style))
98
+ else:
99
+ px, py, pz = node.position
100
+ rx, ry, rz = node.rotation
101
+ sx, sy, sz = node.scale
102
+ lines += [
103
+ f"const mesh{i} = new THREE.Mesh({geometry_js(node)}, {material_js(node, style)});",
104
+ f"mesh{i}.position.set({px}, {py}, {pz});",
105
+ f"mesh{i}.rotation.set({rx}, {ry}, {rz});",
106
+ f"mesh{i}.scale.set({sx}, {sy}, {sz});",
107
+ f"group.add(mesh{i});",
108
+ ]
109
  return "\n ".join(lines)
110
 
111
 
 
214
  return ""
215
 
216
 
217
+ # ---- Extrude shape-path library ----
218
+ # Each function returns a list of JS lines that build `const _shp = new THREE.Shape(); ...`
219
+ # All paths are defined in a unit-scale coordinate space (max extent ~2 units) so that
220
+ # bevel values (0.03) and depth (node.depth) are scale-consistent across all shapes.
221
+ # geometry.center() + uniform scale to 1.5 normalise every shape to the same apparent size.
222
+
223
+ def _shp_star() -> list:
224
+ return [
225
+ "const _shp = new THREE.Shape();",
226
+ "const _outerR = 1.0, _innerR = 0.4, _pts = 5;",
227
+ "for (let _i = 0; _i < _pts * 2; _i++) {",
228
+ " const _r = _i % 2 === 0 ? _outerR : _innerR;",
229
+ " const _a = (_i / (_pts * 2)) * Math.PI * 2 - Math.PI / 2;",
230
+ " _shp[_i === 0 ? 'moveTo' : 'lineTo'](Math.cos(_a) * _r, Math.sin(_a) * _r);",
231
+ "}",
232
+ "_shp.closePath();",
233
+ ]
234
+
235
+
236
+ def _shp_heart() -> list:
237
+ # Original Three.js docs heart path scaled by 1/50 → fits in ~[-0.6,1.6] × [0,1.9]
238
+ return [
239
+ "const _shp = new THREE.Shape();",
240
+ "_shp.moveTo(0.5, 0.5);",
241
+ "_shp.bezierCurveTo(0.5, 0.5, 0.4, 0, 0, 0);",
242
+ "_shp.bezierCurveTo(-0.6, 0, -0.6, 0.7, -0.6, 0.7);",
243
+ "_shp.bezierCurveTo(-0.6, 1.1, -0.2, 1.54, 0.5, 1.9);",
244
+ "_shp.bezierCurveTo(1.2, 1.54, 1.6, 1.1, 1.6, 0.7);",
245
+ "_shp.bezierCurveTo(1.6, 0.7, 1.6, 0, 1.0, 0);",
246
+ "_shp.bezierCurveTo(0.7, 0, 0.5, 0.5, 0.5, 0.5);",
247
+ ]
248
+
249
+
250
+ def _shp_hexagon() -> list:
251
+ return [
252
+ "const _shp = new THREE.Shape();",
253
+ "for (let _i = 0; _i < 6; _i++) {",
254
+ " const _a = (Math.PI / 3) * _i + Math.PI / 6;", # flat-top orientation
255
+ " _shp[_i === 0 ? 'moveTo' : 'lineTo'](Math.cos(_a), Math.sin(_a));",
256
+ "}",
257
+ "_shp.closePath();",
258
+ ]
259
+
260
+
261
+ def _shp_badge() -> list:
262
+ # Rounded rectangle — w=1.6, h=1.0, corner radius=0.25
263
+ return [
264
+ "const _shp = new THREE.Shape();",
265
+ "const _bw = 1.6, _bh = 1.0, _br = 0.25;",
266
+ "const _bx = -_bw / 2, _by = -_bh / 2;",
267
+ "_shp.moveTo(_bx, _by + _br);",
268
+ "_shp.lineTo(_bx, _by + _bh - _br);",
269
+ "_shp.quadraticCurveTo(_bx, _by + _bh, _bx + _br, _by + _bh);",
270
+ "_shp.lineTo(_bx + _bw - _br, _by + _bh);",
271
+ "_shp.quadraticCurveTo(_bx + _bw, _by + _bh, _bx + _bw, _by + _bh - _br);",
272
+ "_shp.lineTo(_bx + _bw, _by + _br);",
273
+ "_shp.quadraticCurveTo(_bx + _bw, _by, _bx + _bw - _br, _by);",
274
+ "_shp.lineTo(_bx + _br, _by);",
275
+ "_shp.quadraticCurveTo(_bx, _by, _bx, _by + _br);",
276
+ ]
277
+
278
+
279
+ def _shp_shield() -> list:
280
+ # Heraldic heater shield: flat top, two curved sides, tapers to bottom point
281
+ # x ∈ [-1, 1], y ∈ [-1.3, 1.0]
282
+ return [
283
+ "const _shp = new THREE.Shape();",
284
+ "_shp.moveTo(-1.0, 1.0);",
285
+ "_shp.lineTo( 1.0, 1.0);",
286
+ "_shp.lineTo( 1.0, 0.2);",
287
+ "_shp.quadraticCurveTo( 1.0, -0.8, 0.0, -1.3);",
288
+ "_shp.quadraticCurveTo(-1.0, -0.8, -1.0, 0.2);",
289
+ "_shp.lineTo(-1.0, 1.0);",
290
+ ]
291
+
292
+
293
+ SHAPE_LIBRARY: Dict[str, Any] = {
294
+ "star": _shp_star,
295
+ "heart": _shp_heart,
296
+ "hexagon": _shp_hexagon,
297
+ "badge": _shp_badge,
298
+ "shield": _shp_shield,
299
+ }
300
+
301
+
302
+ def extrude_js(node: ExtrudeNode, idx: int, style: str = "realistic") -> list:
303
+ """Return JS lines for one extruded shape mesh, self-contained in a block scope."""
304
+ shape_fn = SHAPE_LIBRARY.get(node.shape, _shp_badge)
305
+ # Duck-type: ExtrudeNode has the same material fields as Obj
306
+ mat = material_js(node, style) # type: ignore[arg-type]
307
+ bevel = "true" if node.bevel else "false"
308
+ depth = node.depth
309
+ px, py, pz = node.position
310
+ rx, ry, rz = node.rotation
311
+ sx, sy, sz = node.scale
312
+
313
+ lines = ["{ // extrude: " + node.shape]
314
+ lines.extend(" " + l for l in shape_fn())
315
+ lines += [
316
+ f" const _geo = new THREE.ExtrudeGeometry(_shp, {{",
317
+ f" depth: {depth}, bevelEnabled: {bevel},",
318
+ f" bevelThickness: 0.03, bevelSize: 0.03, bevelSegments: 3,",
319
+ f" curveSegments: 12, steps: 1",
320
+ f" }});",
321
+ f" _geo.center();",
322
+ f" _geo.computeBoundingBox();",
323
+ f" const _mxdim = Math.max(",
324
+ f" _geo.boundingBox.max.x - _geo.boundingBox.min.x,",
325
+ f" _geo.boundingBox.max.y - _geo.boundingBox.min.y,",
326
+ f" _geo.boundingBox.max.z - _geo.boundingBox.min.z",
327
+ f" ) || 1;",
328
+ f" const _nsc = 1.5 / _mxdim;",
329
+ f" _geo.scale(_nsc, _nsc, _nsc);",
330
+ f" const mesh{idx} = new THREE.Mesh(_geo, {mat});",
331
+ f" mesh{idx}.position.set({px}, {py}, {pz});",
332
+ f" mesh{idx}.rotation.set({rx}, {ry}, {rz});",
333
+ f" mesh{idx}.scale.set({sx}, {sy}, {sz});",
334
+ f" group.add(mesh{idx});",
335
+ "}",
336
+ ]
337
+ return lines
338
+
339
+
340
  # ---- Layout resolver: flattens containers into positioned Obj list ----
341
 
342
  def _node_axis_size(node: Any, axis: int) -> float:
343
  """Estimate the bounding-box size of a node along one axis (0=x,1=y,2=z)."""
344
  if isinstance(node, Obj):
345
  return _shape_extent(node.shape, node.params)[axis]
346
+ if isinstance(node, ExtrudeNode):
347
+ return 1.5 # normalised to 1.5-unit max dimension at render time
348
  if isinstance(node, LayoutStack):
349
  ai = {"x": 0, "y": 1, "z": 2}.get(node.axis, 1)
350
  sizes = [_node_axis_size(c, ai) for c in node.children]
 
353
 
354
 
355
  def _flatten(items: list, ox: float = 0.0, oy: float = 0.0, oz: float = 0.0) -> list:
356
+ """Recursively resolve layout containers into a flat list of positioned leaf nodes."""
357
  result = []
358
  for item in items:
359
+ if isinstance(item, (Obj, ExtrudeNode)):
360
  result.append(item.model_copy(update={
361
  "position": [item.position[0] + ox,
362
  item.position[1] + oy,
llm.py CHANGED
@@ -24,21 +24,21 @@ log = logging.getLogger(__name__)
24
  SYSTEM = """You convert a short scene description into a JSON scene graph for a 3D renderer.
25
  Output ONLY valid JSON. No markdown, no comments, no prose, no code fences.
26
 
27
- Schema:
28
  {
29
  "background": "#RRGGBB",
30
  "objects": [
31
  {
32
  "shape": "box|sphere|cylinder|cone|torus|torusKnot|plane|tetrahedron|icosahedron|dodecahedron|octahedron|capsule|ring|circle|tube|roundedBox",
33
  "position": [x, y, z],
34
- "rotation": [x, y, z], // radians, optional
35
- "scale": [x, y, z], // optional
36
  "color": "#RRGGBB",
37
  "material": "standard|basic|phong|wireframe",
38
- "metalness": 0.0-1.0, // optional
39
- "roughness": 0.0-1.0, // optional
40
- "emissive": "#RRGGBB", // optional
41
- "params": { } // shape sizes, e.g. {"radius":0.6,"tube":0.2}
42
  }
43
  ],
44
  "lights": [
@@ -47,6 +47,20 @@ Schema:
47
  "animation": {"type": "none|rotate|float|orbit", "speed": 1.0, "axis": "x|y|z"}
48
  }
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  Keep scenes small (1-4 objects). Center the composition near the origin."""
51
 
52
  FEWSHOT = [
@@ -167,6 +181,36 @@ FEWSHOT = [
167
  ],
168
  "animation": {"type": "rotate", "speed": 0.8, "axis": "y"},
169
  })},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  ]
171
 
172
  _tok = None
 
24
  SYSTEM = """You convert a short scene description into a JSON scene graph for a 3D renderer.
25
  Output ONLY valid JSON. No markdown, no comments, no prose, no code fences.
26
 
27
+ Schema — primitive object:
28
  {
29
  "background": "#RRGGBB",
30
  "objects": [
31
  {
32
  "shape": "box|sphere|cylinder|cone|torus|torusKnot|plane|tetrahedron|icosahedron|dodecahedron|octahedron|capsule|ring|circle|tube|roundedBox",
33
  "position": [x, y, z],
34
+ "rotation": [x, y, z],
35
+ "scale": [x, y, z],
36
  "color": "#RRGGBB",
37
  "material": "standard|basic|phong|wireframe",
38
+ "metalness": 0.0-1.0,
39
+ "roughness": 0.0-1.0,
40
+ "emissive": "#RRGGBB",
41
+ "params": {}
42
  }
43
  ],
44
  "lights": [
 
47
  "animation": {"type": "none|rotate|float|orbit", "speed": 1.0, "axis": "x|y|z"}
48
  }
49
 
50
+ For stars, badges, shields, hearts, hexagons, coins, logos, or emblems use an EXTRUDE object instead:
51
+ {
52
+ "type": "extrude",
53
+ "shape": "star|heart|hexagon|badge|shield",
54
+ "depth": 0.2,
55
+ "bevel": true,
56
+ "color": "#RRGGBB",
57
+ "material": "standard",
58
+ "metalness": 0.0-1.0,
59
+ "roughness": 0.0-1.0,
60
+ "emissive": "#RRGGBB",
61
+ "position": [x, y, z]
62
+ }
63
+
64
  Keep scenes small (1-4 objects). Center the composition near the origin."""
65
 
66
  FEWSHOT = [
 
181
  ],
182
  "animation": {"type": "rotate", "speed": 0.8, "axis": "y"},
183
  })},
184
+ {"role": "user", "content": "a gold star badge"},
185
+ {"role": "assistant", "content": json.dumps({
186
+ "background": "#1a1205",
187
+ "objects": [{
188
+ "type": "extrude", "shape": "star", "depth": 0.3, "bevel": True,
189
+ "color": "#ffd700", "material": "standard",
190
+ "metalness": 0.9, "roughness": 0.2, "emissive": "#332200",
191
+ "position": [0, 0, 0],
192
+ }],
193
+ "lights": [
194
+ {"type": "ambient", "intensity": 0.3},
195
+ {"type": "directional", "color": "#fff8e0", "intensity": 2.0, "position": [3, 5, 4]},
196
+ ],
197
+ "animation": {"type": "rotate", "speed": 0.6, "axis": "y"},
198
+ })},
199
+ {"role": "user", "content": "a shield emblem"},
200
+ {"role": "assistant", "content": json.dumps({
201
+ "background": "#0d1117",
202
+ "objects": [{
203
+ "type": "extrude", "shape": "shield", "depth": 0.25, "bevel": True,
204
+ "color": "#3a6bc4", "material": "standard",
205
+ "metalness": 0.5, "roughness": 0.3, "emissive": "#061833",
206
+ "position": [0, 0, 0],
207
+ }],
208
+ "lights": [
209
+ {"type": "ambient", "intensity": 0.4},
210
+ {"type": "directional", "color": "#ffffff", "intensity": 1.8, "position": [4, 6, 3]},
211
+ ],
212
+ "animation": {"type": "rotate", "speed": 0.5, "axis": "y"},
213
+ })},
214
  ]
215
 
216
  _tok = None
scene.py CHANGED
@@ -21,6 +21,7 @@ SHAPES = {
21
  "tetrahedron", "icosahedron", "dodecahedron", "octahedron",
22
  "capsule", "ring", "circle", "tube", "roundedBox",
23
  }
 
24
  MATERIALS = {"standard", "basic", "phong", "wireframe"}
25
  LIGHT_TYPES = {"ambient", "directional", "point"}
26
  ANIM_TYPES = {"none", "rotate", "float", "orbit"}
@@ -355,9 +356,73 @@ class LayoutGrid(BaseModel):
355
  return [_parse_scene_item(c) for c in v] if isinstance(v, list) else []
356
 
357
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
358
  def _parse_scene_item(v: Any) -> Any:
359
  """Parse a dict (or existing model) into the correct scene node type."""
360
- if isinstance(v, (Obj, LayoutStack, LayoutRow, LayoutGrid)):
361
  return v
362
  if not isinstance(v, dict):
363
  return Obj()
@@ -369,6 +434,8 @@ def _parse_scene_item(v: Any) -> Any:
369
  return LayoutRow(**v)
370
  if t == "grid":
371
  return LayoutGrid(**v)
 
 
372
  return Obj(**v)
373
  except Exception:
374
  return Obj()
@@ -431,7 +498,7 @@ TEMPLATES: Dict[str, Any] = {
431
 
432
  class Scene(BaseModel):
433
  background: str = "#0b0e14"
434
- objects: List[Union[Obj, LayoutStack, LayoutRow, LayoutGrid]] = Field(default_factory=list)
435
  lights: List[Light] = Field(default_factory=list)
436
  animation: Animation = Field(default_factory=Animation)
437
 
 
21
  "tetrahedron", "icosahedron", "dodecahedron", "octahedron",
22
  "capsule", "ring", "circle", "tube", "roundedBox",
23
  }
24
+ EXTRUDE_SHAPES = {"star", "heart", "hexagon", "badge", "shield"}
25
  MATERIALS = {"standard", "basic", "phong", "wireframe"}
26
  LIGHT_TYPES = {"ambient", "directional", "point"}
27
  ANIM_TYPES = {"none", "rotate", "float", "orbit"}
 
356
  return [_parse_scene_item(c) for c in v] if isinstance(v, list) else []
357
 
358
 
359
+ class ExtrudeNode(BaseModel):
360
+ """A 2-D shape path extruded into 3-D with a bevel."""
361
+ type: Literal["extrude"] = "extrude"
362
+ shape: str = "badge"
363
+ depth: float = 0.2
364
+ bevel: bool = True
365
+ color: str = "#88ccff"
366
+ material: str = "standard"
367
+ metalness: float = 0.3
368
+ roughness: float = 0.4
369
+ emissive: str = "#000000"
370
+ position: List[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0])
371
+ rotation: List[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0])
372
+ scale: List[float] = Field(default_factory=lambda: [1.0, 1.0, 1.0])
373
+
374
+ @field_validator("shape")
375
+ @classmethod
376
+ def _shape(cls, v: Any) -> str:
377
+ v = str(v).lower()
378
+ return v if v in EXTRUDE_SHAPES else "badge"
379
+
380
+ @field_validator("depth")
381
+ @classmethod
382
+ def _depth(cls, v: Any) -> float:
383
+ try:
384
+ return _clamp(float(v), 0.02, 2.0)
385
+ except Exception:
386
+ return 0.2
387
+
388
+ @field_validator("color", "emissive")
389
+ @classmethod
390
+ def _hex(cls, v: Any, info) -> str:
391
+ default = "#88ccff" if info.field_name == "color" else "#000000"
392
+ return _sanitize_color(str(v), default)
393
+
394
+ @field_validator("material")
395
+ @classmethod
396
+ def _material(cls, v: Any) -> str:
397
+ v = str(v)
398
+ return v if v in MATERIALS else "standard"
399
+
400
+ @field_validator("metalness", "roughness")
401
+ @classmethod
402
+ def _unit(cls, v: Any) -> float:
403
+ try:
404
+ return _clamp(float(v), 0.0, 1.0)
405
+ except Exception:
406
+ return 0.4
407
+
408
+ @field_validator("position", "rotation", "scale")
409
+ @classmethod
410
+ def _vec3(cls, v: Any, info) -> List[float]:
411
+ fill = 1.0 if info.field_name == "scale" else 0.0
412
+ out: List[float] = []
413
+ try:
414
+ for x in list(v)[:3]:
415
+ out.append(float(x))
416
+ except Exception:
417
+ out = []
418
+ while len(out) < 3:
419
+ out.append(fill)
420
+ return out
421
+
422
+
423
  def _parse_scene_item(v: Any) -> Any:
424
  """Parse a dict (or existing model) into the correct scene node type."""
425
+ if isinstance(v, (Obj, LayoutStack, LayoutRow, LayoutGrid, ExtrudeNode)):
426
  return v
427
  if not isinstance(v, dict):
428
  return Obj()
 
434
  return LayoutRow(**v)
435
  if t == "grid":
436
  return LayoutGrid(**v)
437
+ if t == "extrude":
438
+ return ExtrudeNode(**v)
439
  return Obj(**v)
440
  except Exception:
441
  return Obj()
 
498
 
499
  class Scene(BaseModel):
500
  background: str = "#0b0e14"
501
+ objects: List[Union[Obj, LayoutStack, LayoutRow, LayoutGrid, ExtrudeNode]] = Field(default_factory=list)
502
  lights: List[Light] = Field(default_factory=list)
503
  animation: Animation = Field(default_factory=Animation)
504