Sprint 1: ExtrudeNode type — star, heart, hexagon, badge, shield
Browse filesscene.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)
- compiler.py +144 -15
- llm.py +51 -7
- scene.py +69 -2
|
@@ -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,
|
|
|
|
| 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,
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 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
|
| 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,
|
|
@@ -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],
|
| 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,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
|
|
@@ -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 |
|