bolajiev commited on
Commit
eebf7cf
·
1 Parent(s): f4d9111

Sprint 3: text3d node type via TextGeometry + FontLoader

Browse files

Add Text3DNode to scene DSL with fields: text (capped 24 chars, ASCII-only),
size, depth (clamped 0.05-1.0), bevel, plus full material/color/preset/position
fields shared with Obj/ExtrudeNode.

Compiler: FontLoader + TextGeometry addon imports added to the importmap. All
scene construction is now wrapped in an (async()=>{})() IIFE so FontLoader can
be awaited once; the single font load covers all text3d nodes in a scene, then
shadow-traverse and auto-frame run AFTER text is in the group. For no-text
scenes the IIFE resolves synchronously — zero latency change. The r160
TextGeometry parameter is 'height' (renamed to 'depth' at r163); pinned to
unpkg.com/three@0.160.0 for the typeface JSON URL.

text_section_js(): generates the async font-load block. objects_js() skips
Text3DNode (emitted by text_section_js instead). _flatten/_node_axis_size
handle Text3DNode as a leaf. Mixed extrude+text scenes work: extruded star is
mesh0 (sync), PRO text is mesh1 (async after font resolves).

llm.py: SYSTEM updated with text3d schema. Three FEWSHOT examples added:
HELLO word, gold number 1, star badge + PRO text overlay.

Files changed (3) hide show
  1. compiler.py +100 -39
  2. llm.py +68 -0
  3. scene.py +86 -2
compiler.py CHANGED
@@ -7,10 +7,13 @@ 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, 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:
@@ -139,9 +142,50 @@ def material_js(o: Obj, style: str = "realistic") -> str:
139
  f"metalness: {o.metalness}, roughness: {o.roughness} }})")
140
 
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  def objects_js(scene: Scene, style: str = "realistic") -> str:
143
  lines = []
144
  for i, node in enumerate(_flatten(scene.objects)):
 
 
145
  if isinstance(node, ExtrudeNode):
146
  lines.extend(extrude_js(node, i, style))
147
  else:
@@ -394,6 +438,12 @@ def _node_axis_size(node: Any, axis: int) -> float:
394
  return _shape_extent(node.shape, node.params)[axis]
395
  if isinstance(node, ExtrudeNode):
396
  return 1.5 # normalised to 1.5-unit max dimension at render time
 
 
 
 
 
 
397
  if isinstance(node, LayoutStack):
398
  ai = {"x": 0, "y": 1, "z": 2}.get(node.axis, 1)
399
  sizes = [_node_axis_size(c, ai) for c in node.children]
@@ -405,7 +455,7 @@ def _flatten(items: list, ox: float = 0.0, oy: float = 0.0, oz: float = 0.0) ->
405
  """Recursively resolve layout containers into a flat list of positioned leaf nodes."""
406
  result = []
407
  for item in items:
408
- if isinstance(item, (Obj, ExtrudeNode)):
409
  result.append(item.model_copy(update={
410
  "position": [item.position[0] + ox,
411
  item.position[1] + oy,
@@ -482,6 +532,8 @@ TEMPLATE = """<!DOCTYPE html>
482
  import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
483
  import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js';
484
  import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
 
 
485
 
486
  const scene = new THREE.Scene();
487
  scene.background = new THREE.Color('__BG__');
@@ -517,43 +569,8 @@ TEMPLATE = """<!DOCTYPE html>
517
  // ---- model accent point-lights ----
518
  __ACCENT_LIGHTS__
519
 
520
- // ---- objects ----
521
- __OBJECTS__
522
-
523
- // ---- shadow casting on every mesh ----
524
- if (__USE_SHADOWS__) {
525
- group.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
526
- }
527
-
528
- // ---- auto-frame camera + contact-shadow ground plane ----
529
- const _box = new THREE.Box3().setFromObject(group);
530
- if (!_box.isEmpty()) {
531
- const _sz = _box.getSize(new THREE.Vector3());
532
- const _ctr = _box.getCenter(new THREE.Vector3());
533
- const maxDim = Math.max(_sz.x, _sz.y, _sz.z) || 1;
534
- const fov = camera.fov * (Math.PI / 180);
535
- const dist = (maxDim / 2) / Math.tan(fov / 2) * 1.6;
536
- camera.position.set(_ctr.x + dist * 0.7, _ctr.y + dist * 0.45, _ctr.z + dist);
537
- camera.near = dist / 100;
538
- camera.far = dist * 100;
539
- camera.updateProjectionMatrix();
540
- controls.target.copy(_ctr);
541
- controls.update();
542
-
543
- if (__USE_SHADOWS__) {
544
- const gnd = new THREE.Mesh(
545
- new THREE.PlaneGeometry(maxDim * 8, maxDim * 8),
546
- new THREE.ShadowMaterial({ opacity: 0.35, transparent: true })
547
- );
548
- gnd.rotation.x = -Math.PI / 2;
549
- gnd.position.y = _box.min.y - 0.005;
550
- gnd.receiveShadow = true;
551
- scene.add(gnd);
552
- }
553
- }
554
-
555
  // ---- bloom + output post-processing ----
556
- const USE_BLOOM = __USE_BLOOM__;
557
  const BLOOM_STRENGTH = __BLOOM_STRENGTH__;
558
  const composer = new EffectComposer(renderer);
559
  composer.addPass(new RenderPass(scene, camera));
@@ -572,7 +589,6 @@ TEMPLATE = """<!DOCTYPE html>
572
  if (USE_BLOOM) composer.render();
573
  else renderer.render(scene, camera);
574
  }
575
- animate();
576
 
577
  window.addEventListener('resize', () => {
578
  camera.aspect = window.innerWidth / window.innerHeight;
@@ -580,6 +596,47 @@ TEMPLATE = """<!DOCTYPE html>
580
  renderer.setSize(window.innerWidth, window.innerHeight);
581
  composer.setSize(window.innerWidth, window.innerHeight);
582
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
583
  </script>
584
  </body>
585
  </html>
@@ -594,11 +651,15 @@ def compile_html(
594
  lighting: str = "studio",
595
  shadows: bool = True,
596
  ) -> str:
 
 
 
597
  html = TEMPLATE
598
  html = html.replace("__BG__", scene.background)
599
  html = html.replace("__PRESET_LIGHTS__", _preset_lights_js(lighting, shadows))
600
  html = html.replace("__ACCENT_LIGHTS__", _accent_lights_js(scene))
601
  html = html.replace("__OBJECTS__", objects_js(scene, style))
 
602
  html = html.replace("__ANIM__", animation_js(scene.animation))
603
  html = html.replace("__USE_BLOOM__", "true" if glow else "false")
604
  html = html.replace("__BLOOM_STRENGTH__", str(float(glow_strength)))
 
7
  from __future__ import annotations
8
 
9
  import base64
10
+ import json
11
  from typing import Any, Dict
12
 
13
  from scene import (Animation, ExtrudeNode, LayoutGrid, LayoutRow, LayoutStack,
14
+ Obj, Scene, Text3DNode, _shape_extent)
15
+
16
+ FONT_URL = "https://unpkg.com/three@0.160.0/examples/fonts/helvetiker_regular.typeface.json"
17
 
18
 
19
  def _col_str(color: str) -> str:
 
142
  f"metalness: {o.metalness}, roughness: {o.roughness} }})")
143
 
144
 
145
+ def text_section_js(nodes_with_idx: list, style: str = "realistic") -> str:
146
+ """Generate the async font-load block for all text3d nodes.
147
+
148
+ Returns an empty string when there are no text nodes (no font load, no await).
149
+ All text meshes are built inside a single loadAsync call so the font is fetched once.
150
+ r160 TextGeometry uses 'height' for extrusion depth (renamed to 'depth' at r163).
151
+ """
152
+ if not nodes_with_idx:
153
+ return ""
154
+ lines = [
155
+ f" const _font = await new FontLoader().loadAsync(",
156
+ f" '{FONT_URL}'",
157
+ f" );",
158
+ ]
159
+ for node, idx in nodes_with_idx:
160
+ mat = material_js(node, style)
161
+ txt = json.dumps(node.text)
162
+ bevel = "true" if node.bevel else "false"
163
+ px, py, pz = node.position
164
+ rx, ry, rz = node.rotation
165
+ sx, sy, sz = node.scale
166
+ lines += [
167
+ f" {{ // text3d: {node.text!r}",
168
+ f" const _geo = new TextGeometry({txt}, {{",
169
+ f" font: _font, size: {node.size}, height: {node.depth},",
170
+ f" curveSegments: 12, bevelEnabled: {bevel},",
171
+ f" bevelThickness: 0.02, bevelSize: 0.02, bevelSegments: 3",
172
+ f" }});",
173
+ f" _geo.center();",
174
+ f" const mesh{idx} = new THREE.Mesh(_geo, {mat});",
175
+ f" mesh{idx}.position.set({px}, {py}, {pz});",
176
+ f" mesh{idx}.rotation.set({rx}, {ry}, {rz});",
177
+ f" mesh{idx}.scale.set({sx}, {sy}, {sz});",
178
+ f" group.add(mesh{idx});",
179
+ f" }}",
180
+ ]
181
+ return "\n".join(lines)
182
+
183
+
184
  def objects_js(scene: Scene, style: str = "realistic") -> str:
185
  lines = []
186
  for i, node in enumerate(_flatten(scene.objects)):
187
+ if isinstance(node, Text3DNode):
188
+ continue # built in the async text section
189
  if isinstance(node, ExtrudeNode):
190
  lines.extend(extrude_js(node, i, style))
191
  else:
 
438
  return _shape_extent(node.shape, node.params)[axis]
439
  if isinstance(node, ExtrudeNode):
440
  return 1.5 # normalised to 1.5-unit max dimension at render time
441
+ if isinstance(node, Text3DNode):
442
+ if axis == 0: # x: rough width (0.6 units per char at size 1)
443
+ return max(node.size * len(node.text) * 0.6, node.size)
444
+ if axis == 1: # y: cap height
445
+ return node.size * 1.2
446
+ return node.depth + 0.05 # z
447
  if isinstance(node, LayoutStack):
448
  ai = {"x": 0, "y": 1, "z": 2}.get(node.axis, 1)
449
  sizes = [_node_axis_size(c, ai) for c in node.children]
 
455
  """Recursively resolve layout containers into a flat list of positioned leaf nodes."""
456
  result = []
457
  for item in items:
458
+ if isinstance(item, (Obj, ExtrudeNode, Text3DNode)):
459
  result.append(item.model_copy(update={
460
  "position": [item.position[0] + ox,
461
  item.position[1] + oy,
 
532
  import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
533
  import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js';
534
  import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
535
+ import { FontLoader } from 'three/addons/loaders/FontLoader.js';
536
+ import { TextGeometry } from 'three/addons/geometries/TextGeometry.js';
537
 
538
  const scene = new THREE.Scene();
539
  scene.background = new THREE.Color('__BG__');
 
569
  // ---- model accent point-lights ----
570
  __ACCENT_LIGHTS__
571
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
572
  // ---- bloom + output post-processing ----
573
+ const USE_BLOOM = __USE_BLOOM__;
574
  const BLOOM_STRENGTH = __BLOOM_STRENGTH__;
575
  const composer = new EffectComposer(renderer);
576
  composer.addPass(new RenderPass(scene, camera));
 
589
  if (USE_BLOOM) composer.render();
590
  else renderer.render(scene, camera);
591
  }
 
592
 
593
  window.addEventListener('resize', () => {
594
  camera.aspect = window.innerWidth / window.innerHeight;
 
596
  renderer.setSize(window.innerWidth, window.innerHeight);
597
  composer.setSize(window.innerWidth, window.innerHeight);
598
  });
599
+
600
+ // ---- build scene (async so FontLoader can be awaited for text3d nodes) ----
601
+ (async () => {
602
+ // ---- sync objects (primitives + extrude) ----
603
+ __OBJECTS__
604
+
605
+ __TEXT_SECTION__
606
+ // ---- shadow casting on every mesh ----
607
+ if (__USE_SHADOWS__) {
608
+ group.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
609
+ }
610
+
611
+ // ---- auto-frame camera + contact-shadow ground plane ----
612
+ const _box = new THREE.Box3().setFromObject(group);
613
+ if (!_box.isEmpty()) {
614
+ const _sz = _box.getSize(new THREE.Vector3());
615
+ const _ctr = _box.getCenter(new THREE.Vector3());
616
+ const maxDim = Math.max(_sz.x, _sz.y, _sz.z) || 1;
617
+ const fov = camera.fov * (Math.PI / 180);
618
+ const dist = (maxDim / 2) / Math.tan(fov / 2) * 1.6;
619
+ camera.position.set(_ctr.x + dist * 0.7, _ctr.y + dist * 0.45, _ctr.z + dist);
620
+ camera.near = dist / 100;
621
+ camera.far = dist * 100;
622
+ camera.updateProjectionMatrix();
623
+ controls.target.copy(_ctr);
624
+ controls.update();
625
+
626
+ if (__USE_SHADOWS__) {
627
+ const gnd = new THREE.Mesh(
628
+ new THREE.PlaneGeometry(maxDim * 8, maxDim * 8),
629
+ new THREE.ShadowMaterial({ opacity: 0.35, transparent: true })
630
+ );
631
+ gnd.rotation.x = -Math.PI / 2;
632
+ gnd.position.y = _box.min.y - 0.005;
633
+ gnd.receiveShadow = true;
634
+ scene.add(gnd);
635
+ }
636
+ }
637
+
638
+ animate();
639
+ })().catch(console.error);
640
  </script>
641
  </body>
642
  </html>
 
651
  lighting: str = "studio",
652
  shadows: bool = True,
653
  ) -> str:
654
+ flat = _flatten(scene.objects)
655
+ text_nodes = [(n, i) for i, n in enumerate(flat) if isinstance(n, Text3DNode)]
656
+
657
  html = TEMPLATE
658
  html = html.replace("__BG__", scene.background)
659
  html = html.replace("__PRESET_LIGHTS__", _preset_lights_js(lighting, shadows))
660
  html = html.replace("__ACCENT_LIGHTS__", _accent_lights_js(scene))
661
  html = html.replace("__OBJECTS__", objects_js(scene, style))
662
+ html = html.replace("__TEXT_SECTION__", text_section_js(text_nodes, style))
663
  html = html.replace("__ANIM__", animation_js(scene.animation))
664
  html = html.replace("__USE_BLOOM__", "true" if glow else "false")
665
  html = html.replace("__BLOOM_STRENGTH__", str(float(glow_strength)))
llm.py CHANGED
@@ -62,6 +62,22 @@ For stars, badges, shields, hearts, hexagons, coins, logos, or emblems use an EX
62
  "position": [x, y, z]
63
  }
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  Material preset notes:
66
  - gold/chrome: use for shiny metallic looks; color overrides the base hue
67
  - glass: transparent refractive; color adds a tint
@@ -264,6 +280,58 @@ FEWSHOT = [
264
  ],
265
  "animation": {"type": "rotate", "speed": 0.6, "axis": "y"},
266
  })},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  ]
268
 
269
  _tok = None
 
62
  "position": [x, y, z]
63
  }
64
 
65
+ For 3D text (words, numbers, short labels) use a TEXT3D object (Latin chars only, max 24 chars):
66
+ {
67
+ "type": "text3d",
68
+ "text": "HELLO",
69
+ "size": 0.8,
70
+ "depth": 0.2,
71
+ "bevel": true,
72
+ "color": "#RRGGBB",
73
+ "preset": "gold|chrome|glass|neon|matte|plastic",
74
+ "metalness": 0.0-1.0,
75
+ "roughness": 0.0-1.0,
76
+ "position": [x, y, z]
77
+ }
78
+ Use text3d for: single words, numbers, initials, short labels (max 2 text objects per scene).
79
+ Combine text3d with extrude for badges with text (position text slightly in front: z+0.3).
80
+
81
  Material preset notes:
82
  - gold/chrome: use for shiny metallic looks; color overrides the base hue
83
  - glass: transparent refractive; color adds a tint
 
280
  ],
281
  "animation": {"type": "rotate", "speed": 0.6, "axis": "y"},
282
  })},
283
+ {"role": "user", "content": "the word HELLO in 3D"},
284
+ {"role": "assistant", "content": json.dumps({
285
+ "background": "#0b0e14",
286
+ "objects": [{
287
+ "type": "text3d", "text": "HELLO",
288
+ "size": 0.8, "depth": 0.2, "bevel": True,
289
+ "color": "#88ccff", "metalness": 0.3, "roughness": 0.4,
290
+ "position": [0, 0, 0],
291
+ }],
292
+ "lights": [
293
+ {"type": "ambient", "intensity": 0.4},
294
+ {"type": "directional", "color": "#ffffff", "intensity": 2.0, "position": [5, 8, 5]},
295
+ ],
296
+ "animation": {"type": "rotate", "speed": 0.5, "axis": "y"},
297
+ })},
298
+ {"role": "user", "content": "a gold 3D number 1"},
299
+ {"role": "assistant", "content": json.dumps({
300
+ "background": "#1a1205",
301
+ "objects": [{
302
+ "type": "text3d", "text": "1",
303
+ "size": 1.2, "depth": 0.3, "bevel": True,
304
+ "color": "#ffd700", "preset": "gold",
305
+ "position": [0, 0, 0],
306
+ }],
307
+ "lights": [
308
+ {"type": "ambient", "intensity": 0.3},
309
+ {"type": "directional", "color": "#fff8e0", "intensity": 2.5, "position": [4, 6, 4]},
310
+ ],
311
+ "animation": {"type": "rotate", "speed": 0.5, "axis": "y"},
312
+ })},
313
+ {"role": "user", "content": "a star badge with the text PRO"},
314
+ {"role": "assistant", "content": json.dumps({
315
+ "background": "#0d1117",
316
+ "objects": [
317
+ {
318
+ "type": "extrude", "shape": "star", "depth": 0.15, "bevel": True,
319
+ "color": "#3a6bc4", "metalness": 0.7, "roughness": 0.2,
320
+ "position": [0, 0, 0],
321
+ },
322
+ {
323
+ "type": "text3d", "text": "PRO",
324
+ "size": 0.35, "depth": 0.1, "bevel": True,
325
+ "color": "#ffffff", "metalness": 0.1, "roughness": 0.4,
326
+ "position": [0, -0.15, 0.3],
327
+ },
328
+ ],
329
+ "lights": [
330
+ {"type": "ambient", "intensity": 0.4},
331
+ {"type": "directional", "color": "#ffffff", "intensity": 2.0, "position": [3, 5, 4]},
332
+ ],
333
+ "animation": {"type": "rotate", "speed": 0.5, "axis": "y"},
334
+ })},
335
  ]
336
 
337
  _tok = None
scene.py CHANGED
@@ -439,9 +439,91 @@ class ExtrudeNode(BaseModel):
439
  return out
440
 
441
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
442
  def _parse_scene_item(v: Any) -> Any:
443
  """Parse a dict (or existing model) into the correct scene node type."""
444
- if isinstance(v, (Obj, LayoutStack, LayoutRow, LayoutGrid, ExtrudeNode)):
445
  return v
446
  if not isinstance(v, dict):
447
  return Obj()
@@ -455,6 +537,8 @@ def _parse_scene_item(v: Any) -> Any:
455
  return LayoutGrid(**v)
456
  if t == "extrude":
457
  return ExtrudeNode(**v)
 
 
458
  return Obj(**v)
459
  except Exception:
460
  return Obj()
@@ -517,7 +601,7 @@ TEMPLATES: Dict[str, Any] = {
517
 
518
  class Scene(BaseModel):
519
  background: str = "#0b0e14"
520
- objects: List[Union[Obj, LayoutStack, LayoutRow, LayoutGrid, ExtrudeNode]] = Field(default_factory=list)
521
  lights: List[Light] = Field(default_factory=list)
522
  animation: Animation = Field(default_factory=Animation)
523
 
 
439
  return out
440
 
441
 
442
+ class Text3DNode(BaseModel):
443
+ """3-D text rendered via Three.js TextGeometry + FontLoader (Latin chars only)."""
444
+ type: Literal["text3d"] = "text3d"
445
+ text: str = "TEXT"
446
+ size: float = 0.6
447
+ depth: float = 0.2
448
+ bevel: bool = True
449
+ color: str = "#88ccff"
450
+ material: str = "standard"
451
+ preset: Optional[str] = None
452
+ metalness: float = 0.3
453
+ roughness: float = 0.4
454
+ emissive: str = "#000000"
455
+ position: List[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0])
456
+ rotation: List[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0])
457
+ scale: List[float] = Field(default_factory=lambda: [1.0, 1.0, 1.0])
458
+
459
+ @field_validator("text")
460
+ @classmethod
461
+ def _text(cls, v: Any) -> str:
462
+ v = "".join(c for c in str(v).strip() if c.isprintable() and ord(c) < 128)[:24]
463
+ return v or "TEXT"
464
+
465
+ @field_validator("size")
466
+ @classmethod
467
+ def _size(cls, v: Any) -> float:
468
+ try:
469
+ return _clamp(float(v), 0.1, 4.0)
470
+ except Exception:
471
+ return 0.6
472
+
473
+ @field_validator("depth")
474
+ @classmethod
475
+ def _depth(cls, v: Any) -> float:
476
+ try:
477
+ return _clamp(float(v), 0.05, 1.0)
478
+ except Exception:
479
+ return 0.2
480
+
481
+ @field_validator("color", "emissive")
482
+ @classmethod
483
+ def _hex(cls, v: Any, info) -> str:
484
+ default = "#88ccff" if info.field_name == "color" else "#000000"
485
+ return _sanitize_color(str(v), default)
486
+
487
+ @field_validator("material")
488
+ @classmethod
489
+ def _material(cls, v: Any) -> str:
490
+ v = str(v)
491
+ return v if v in MATERIALS else "standard"
492
+
493
+ @field_validator("preset")
494
+ @classmethod
495
+ def _preset_field(cls, v: Any) -> Optional[str]:
496
+ if v is None:
497
+ return None
498
+ v = str(v).lower().strip()
499
+ return v if v in PRESET_NAMES else None
500
+
501
+ @field_validator("metalness", "roughness")
502
+ @classmethod
503
+ def _unit(cls, v: Any) -> float:
504
+ try:
505
+ return _clamp(float(v), 0.0, 1.0)
506
+ except Exception:
507
+ return 0.4
508
+
509
+ @field_validator("position", "rotation", "scale")
510
+ @classmethod
511
+ def _vec3(cls, v: Any, info) -> List[float]:
512
+ fill = 1.0 if info.field_name == "scale" else 0.0
513
+ out: List[float] = []
514
+ try:
515
+ for x in list(v)[:3]:
516
+ out.append(float(x))
517
+ except Exception:
518
+ out = []
519
+ while len(out) < 3:
520
+ out.append(fill)
521
+ return out
522
+
523
+
524
  def _parse_scene_item(v: Any) -> Any:
525
  """Parse a dict (or existing model) into the correct scene node type."""
526
+ if isinstance(v, (Obj, LayoutStack, LayoutRow, LayoutGrid, ExtrudeNode, Text3DNode)):
527
  return v
528
  if not isinstance(v, dict):
529
  return Obj()
 
537
  return LayoutGrid(**v)
538
  if t == "extrude":
539
  return ExtrudeNode(**v)
540
+ if t == "text3d":
541
+ return Text3DNode(**v)
542
  return Obj(**v)
543
  except Exception:
544
  return Obj()
 
601
 
602
  class Scene(BaseModel):
603
  background: str = "#0b0e14"
604
+ objects: List[Union[Obj, LayoutStack, LayoutRow, LayoutGrid, ExtrudeNode, Text3DNode]] = Field(default_factory=list)
605
  lights: List[Light] = Field(default_factory=list)
606
  animation: Animation = Field(default_factory=Animation)
607