bolajiev commited on
Commit
5f02e75
·
1 Parent(s): c3d2108

Add relational layout (stack/row/grid) + burger/snowman/tree templates to DSL

Browse files
Files changed (3) hide show
  1. compiler.py +68 -2
  2. llm.py +30 -0
  3. scene.py +195 -2
compiler.py CHANGED
@@ -7,8 +7,9 @@ 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
 
11
- from scene import Animation, Obj, Scene
12
 
13
 
14
  def _hex0x(color: str) -> str:
@@ -70,7 +71,7 @@ def material_js(o: Obj, style: str = "realistic") -> str:
70
 
71
  def objects_js(scene: Scene, style: str = "realistic") -> str:
72
  lines = []
73
- for i, o in enumerate(scene.objects):
74
  px, py, pz = o.position
75
  rx, ry, rz = o.rotation
76
  sx, sy, sz = o.scale
@@ -115,6 +116,71 @@ def animation_js(a: Animation) -> str:
115
  return ""
116
 
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  TEMPLATE = """<!DOCTYPE html>
119
  <html lang="en">
120
  <head>
 
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 _hex0x(color: str) -> str:
 
71
 
72
  def objects_js(scene: Scene, style: str = "realistic") -> str:
73
  lines = []
74
+ for i, o in enumerate(_flatten(scene.objects)):
75
  px, py, pz = o.position
76
  rx, ry, rz = o.rotation
77
  sx, sy, sz = o.scale
 
116
  return ""
117
 
118
 
119
+ # ---- Layout resolver: flattens containers into positioned Obj list ----
120
+
121
+ def _node_axis_size(node: Any, axis: int) -> float:
122
+ """Estimate the bounding-box size of a node along one axis (0=x,1=y,2=z)."""
123
+ if isinstance(node, Obj):
124
+ return _shape_extent(node.shape, node.params)[axis]
125
+ if isinstance(node, LayoutStack):
126
+ ai = {"x": 0, "y": 1, "z": 2}.get(node.axis, 1)
127
+ sizes = [_node_axis_size(c, ai) for c in node.children]
128
+ return sum(sizes) + max(0, len(sizes) - 1) * node.gap if sizes else 1.0
129
+ return 1.0
130
+
131
+
132
+ def _flatten(items: list, ox: float = 0.0, oy: float = 0.0, oz: float = 0.0) -> list:
133
+ """Recursively resolve layout containers into a flat list of positioned Obj."""
134
+ result = []
135
+ for item in items:
136
+ if isinstance(item, Obj):
137
+ result.append(item.model_copy(update={
138
+ "position": [item.position[0] + ox,
139
+ item.position[1] + oy,
140
+ item.position[2] + oz]
141
+ }))
142
+ elif isinstance(item, LayoutStack):
143
+ result.extend(_flatten_stack(item, ox, oy, oz))
144
+ elif isinstance(item, LayoutRow):
145
+ # Row = stack along x
146
+ fake = LayoutStack(axis="x", gap=item.gap,
147
+ position=item.position, children=item.children)
148
+ result.extend(_flatten_stack(fake, ox, oy, oz))
149
+ elif isinstance(item, LayoutGrid):
150
+ result.extend(_flatten_grid(item, ox, oy, oz))
151
+ return result
152
+
153
+
154
+ def _flatten_stack(stack: LayoutStack, ox: float, oy: float, oz: float) -> list:
155
+ ai = {"x": 0, "y": 1, "z": 2}.get(stack.axis, 1)
156
+ base = [stack.position[0] + ox, stack.position[1] + oy, stack.position[2] + oz]
157
+ sizes = [_node_axis_size(c, ai) for c in stack.children]
158
+ total = sum(sizes) + max(0, len(sizes) - 1) * stack.gap
159
+ cursor = -total / 2.0
160
+ result = []
161
+ for child, size in zip(stack.children, sizes):
162
+ off = list(base)
163
+ off[ai] += cursor + size / 2.0
164
+ cursor += size + stack.gap
165
+ result.extend(_flatten([child], *off))
166
+ return result
167
+
168
+
169
+ def _flatten_grid(grid: LayoutGrid, ox: float, oy: float, oz: float) -> list:
170
+ base = [grid.position[0] + ox, grid.position[1] + oy, grid.position[2] + oz]
171
+ cols = max(1, grid.cols)
172
+ rows = (len(grid.children) + cols - 1) // cols
173
+ sx = -(cols - 1) * grid.gap_x / 2.0
174
+ sz = -(rows - 1) * grid.gap_z / 2.0
175
+ result = []
176
+ for i, child in enumerate(grid.children):
177
+ off = [base[0] + sx + (i % cols) * grid.gap_x,
178
+ base[1],
179
+ base[2] + sz + (i // cols) * grid.gap_z]
180
+ result.extend(_flatten([child], *off))
181
+ return result
182
+
183
+
184
  TEMPLATE = """<!DOCTYPE html>
185
  <html lang="en">
186
  <head>
llm.py CHANGED
@@ -97,6 +97,36 @@ FEWSHOT = [
97
  ],
98
  "animation": {"type": "float", "speed": 0.8, "axis": "y"},
99
  })},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  ]
101
 
102
  _tok = None
 
97
  ],
98
  "animation": {"type": "float", "speed": 0.8, "axis": "y"},
99
  })},
100
+ {"role": "user", "content": "a classic burger"},
101
+ {"role": "assistant", "content": json.dumps({
102
+ "background": "#1a1209",
103
+ "objects": [{
104
+ "type": "stack",
105
+ "axis": "y",
106
+ "gap": 0.02,
107
+ "children": [
108
+ {"shape": "sphere", "color": "#c8a96e", "params": {"radius": 0.45}},
109
+ {"shape": "cylinder", "color": "#3a8a3a", "params": {"radiusTop": 0.52, "radiusBottom": 0.52, "height": 0.1}},
110
+ {"shape": "cylinder", "color": "#5a3a1a", "params": {"radiusTop": 0.5, "radiusBottom": 0.5, "height": 0.18}},
111
+ {"shape": "cylinder", "color": "#c8a96e", "params": {"radiusTop": 0.52, "radiusBottom": 0.55, "height": 0.32}},
112
+ ]
113
+ }],
114
+ "lights": [
115
+ {"type": "ambient", "intensity": 0.5},
116
+ {"type": "directional", "intensity": 1.3, "position": [5, 8, 6]},
117
+ ],
118
+ "animation": {"type": "rotate", "speed": 0.8, "axis": "y"},
119
+ })},
120
+ {"role": "user", "content": "build me a snowman"},
121
+ {"role": "assistant", "content": json.dumps({
122
+ "background": "#0d1b2a",
123
+ "template": {"name": "snowman", "color_body": "#e8e8e8", "color_hat": "#1a1a1a"},
124
+ "lights": [
125
+ {"type": "ambient", "intensity": 0.5},
126
+ {"type": "directional", "intensity": 1.3, "position": [5, 8, 6]},
127
+ ],
128
+ "animation": {"type": "rotate", "speed": 0.5, "axis": "y"},
129
+ })},
130
  ]
131
 
132
  _tok = None
scene.py CHANGED
@@ -10,7 +10,7 @@ from __future__ import annotations
10
  import json
11
  import logging
12
  import re
13
- from typing import Any, Dict, List, Optional
14
 
15
  from pydantic import BaseModel, Field, field_validator
16
 
@@ -30,6 +30,30 @@ def _clamp(v: float, lo: float, hi: float) -> float:
30
  return max(lo, min(hi, v))
31
 
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  class Obj(BaseModel):
34
  shape: str = "box"
35
  position: List[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0])
@@ -163,9 +187,163 @@ class Animation(BaseModel):
163
  return v if v in {"x", "y", "z"} else "y"
164
 
165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  class Scene(BaseModel):
167
  background: str = "#0b0e14"
168
- objects: List[Obj] = Field(default_factory=list)
169
  lights: List[Light] = Field(default_factory=list)
170
  animation: Animation = Field(default_factory=Animation)
171
 
@@ -175,6 +353,13 @@ class Scene(BaseModel):
175
  v = str(v)
176
  return v if HEX.match(v) else "#0b0e14"
177
 
 
 
 
 
 
 
 
178
 
179
  def extract_json(text: str) -> Optional[dict]:
180
  """Pull the first balanced JSON object out of a raw model response."""
@@ -198,6 +383,14 @@ def build_scene(data: Optional[dict]) -> Scene:
198
  if not isinstance(data, dict):
199
  log.warning("build_scene: no valid dict, using empty scene")
200
  data = {}
 
 
 
 
 
 
 
 
201
  try:
202
  scene = Scene(**data)
203
  except Exception as e:
 
10
  import json
11
  import logging
12
  import re
13
+ from typing import Any, Dict, List, Literal, Optional, Union
14
 
15
  from pydantic import BaseModel, Field, field_validator
16
 
 
30
  return max(lo, min(hi, v))
31
 
32
 
33
+ def _shape_extent(shape: str, params: Dict[str, float]) -> tuple:
34
+ """Return (width, height, depth) bounding box for layout size computations."""
35
+ def p(k, d): return float(params.get(k, d))
36
+ if shape == "box":
37
+ return (p("width", 1.0), p("height", 1.0), p("depth", 1.0))
38
+ if shape == "sphere":
39
+ d = p("radius", 0.6) * 2
40
+ return (d, d, d)
41
+ if shape == "cylinder":
42
+ r = max(p("radiusTop", 0.5), p("radiusBottom", 0.5)) * 2
43
+ return (r, p("height", 1.0), r)
44
+ if shape == "cone":
45
+ return (p("radius", 0.5) * 2, p("height", 1.0), p("radius", 0.5) * 2)
46
+ if shape in ("torus", "torusKnot"):
47
+ r = (p("radius", 0.5) + p("tube", 0.2)) * 2
48
+ return (r, r, r)
49
+ if shape in ("tetrahedron", "icosahedron", "dodecahedron", "octahedron"):
50
+ d = p("radius", 0.6) * 2
51
+ return (d, d, d)
52
+ if shape == "plane":
53
+ return (p("width", 5.0), 0.01, p("height", 5.0))
54
+ return (1.0, 1.0, 1.0)
55
+
56
+
57
  class Obj(BaseModel):
58
  shape: str = "box"
59
  position: List[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0])
 
187
  return v if v in {"x", "y", "z"} else "y"
188
 
189
 
190
+ def _vec3_field(v: Any, default: float = 0.0) -> List[float]:
191
+ out: List[float] = []
192
+ try:
193
+ for x in list(v)[:3]:
194
+ out.append(float(x))
195
+ except Exception:
196
+ out = []
197
+ while len(out) < 3:
198
+ out.append(default)
199
+ return out
200
+
201
+
202
+ class LayoutStack(BaseModel):
203
+ """Stack children along an axis, centering the total extent at the node's position."""
204
+ type: Literal["stack"] = "stack"
205
+ axis: str = "y"
206
+ gap: float = 0.05
207
+ position: List[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0])
208
+ children: List[Any] = Field(default_factory=list)
209
+
210
+ @field_validator("axis")
211
+ @classmethod
212
+ def _axis(cls, v: Any) -> str:
213
+ return str(v) if str(v) in {"x", "y", "z"} else "y"
214
+
215
+ @field_validator("gap")
216
+ @classmethod
217
+ def _gap(cls, v: Any) -> float:
218
+ try: return max(0.0, float(v))
219
+ except Exception: return 0.05
220
+
221
+ @field_validator("position", mode="before")
222
+ @classmethod
223
+ def _pos(cls, v: Any) -> List[float]:
224
+ return _vec3_field(v)
225
+
226
+ @field_validator("children", mode="before")
227
+ @classmethod
228
+ def _children(cls, v: Any) -> List[Any]:
229
+ return [_parse_scene_item(c) for c in v] if isinstance(v, list) else []
230
+
231
+
232
+ class LayoutRow(BaseModel):
233
+ """Lay out children in a row along the x-axis."""
234
+ type: Literal["row"] = "row"
235
+ gap: float = 0.3
236
+ position: List[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0])
237
+ children: List[Any] = Field(default_factory=list)
238
+
239
+ @field_validator("gap")
240
+ @classmethod
241
+ def _gap(cls, v: Any) -> float:
242
+ try: return max(0.0, float(v))
243
+ except Exception: return 0.3
244
+
245
+ @field_validator("position", mode="before")
246
+ @classmethod
247
+ def _pos(cls, v: Any) -> List[float]:
248
+ return _vec3_field(v)
249
+
250
+ @field_validator("children", mode="before")
251
+ @classmethod
252
+ def _children(cls, v: Any) -> List[Any]:
253
+ return [_parse_scene_item(c) for c in v] if isinstance(v, list) else []
254
+
255
+
256
+ class LayoutGrid(BaseModel):
257
+ """Lay out children in a grid on the x-z plane."""
258
+ type: Literal["grid"] = "grid"
259
+ cols: int = 2
260
+ gap_x: float = 0.3
261
+ gap_z: float = 0.3
262
+ position: List[float] = Field(default_factory=lambda: [0.0, 0.0, 0.0])
263
+ children: List[Any] = Field(default_factory=list)
264
+
265
+ @field_validator("cols")
266
+ @classmethod
267
+ def _cols(cls, v: Any) -> int:
268
+ try: return max(1, int(v))
269
+ except Exception: return 2
270
+
271
+ @field_validator("position", mode="before")
272
+ @classmethod
273
+ def _pos(cls, v: Any) -> List[float]:
274
+ return _vec3_field(v)
275
+
276
+ @field_validator("children", mode="before")
277
+ @classmethod
278
+ def _children(cls, v: Any) -> List[Any]:
279
+ return [_parse_scene_item(c) for c in v] if isinstance(v, list) else []
280
+
281
+
282
+ def _parse_scene_item(v: Any) -> Any:
283
+ """Parse a dict (or existing model) into the correct scene node type."""
284
+ if isinstance(v, (Obj, LayoutStack, LayoutRow, LayoutGrid)):
285
+ return v
286
+ if not isinstance(v, dict):
287
+ return Obj()
288
+ t = v.get("type", "")
289
+ try:
290
+ if t == "stack":
291
+ return LayoutStack(**v)
292
+ if t == "row":
293
+ return LayoutRow(**v)
294
+ if t == "grid":
295
+ return LayoutGrid(**v)
296
+ return Obj(**v)
297
+ except Exception:
298
+ return Obj()
299
+
300
+
301
+ # ---- Composite templates (deterministic Python expansions) ----
302
+
303
+ def _template_burger(params: Dict[str, Any]) -> List[Obj]:
304
+ bun = params.get("color_bun", "#c8a96e")
305
+ patty = params.get("color_patty", "#5a3a1a")
306
+ lettuce = params.get("color_lettuce", "#3a8a3a")
307
+ return [
308
+ Obj(shape="sphere", color=bun, position=[0, 0.65, 0], params={"radius": 0.45}, roughness=0.7),
309
+ Obj(shape="cylinder", color=lettuce, position=[0, 0.28, 0], params={"radiusTop": 0.52, "radiusBottom": 0.52, "height": 0.1}, roughness=0.9),
310
+ Obj(shape="cylinder", color=patty, position=[0, 0.12, 0], params={"radiusTop": 0.5, "radiusBottom": 0.5, "height": 0.18}, roughness=0.8),
311
+ Obj(shape="cylinder", color=bun, position=[0, -0.18, 0], params={"radiusTop": 0.52, "radiusBottom": 0.55, "height": 0.32}, roughness=0.7),
312
+ ]
313
+
314
+
315
+ def _template_snowman(params: Dict[str, Any]) -> List[Obj]:
316
+ body = params.get("color_body", "#e8e8e8")
317
+ hat = params.get("color_hat", "#1a1a1a")
318
+ return [
319
+ Obj(shape="sphere", color=body, position=[0, -0.55, 0], params={"radius": 0.5}, roughness=0.9),
320
+ Obj(shape="sphere", color=body, position=[0, 0.2, 0], params={"radius": 0.35}, roughness=0.9),
321
+ Obj(shape="sphere", color=body, position=[0, 0.82, 0], params={"radius": 0.24}, roughness=0.9),
322
+ Obj(shape="cylinder", color=hat, position=[0, 1.18, 0], params={"radiusTop": 0.16, "radiusBottom": 0.26, "height": 0.34}),
323
+ ]
324
+
325
+
326
+ def _template_tree(params: Dict[str, Any]) -> List[Obj]:
327
+ leaves = params.get("color_leaves", "#2e8b57")
328
+ trunk = params.get("color_trunk", "#8b5a2b")
329
+ return [
330
+ Obj(shape="cylinder", color=trunk, position=[0, -0.6, 0], params={"radiusTop": 0.12, "radiusBottom": 0.15, "height": 0.8}, roughness=0.9),
331
+ Obj(shape="cone", color=leaves, position=[0, 0.2, 0], params={"radius": 0.7, "height": 1.0}, roughness=0.8),
332
+ Obj(shape="cone", color=leaves, position=[0, 0.72, 0], params={"radius": 0.55, "height": 0.8}, roughness=0.8),
333
+ Obj(shape="cone", color=leaves, position=[0, 1.14, 0], params={"radius": 0.4, "height": 0.65}, roughness=0.8),
334
+ ]
335
+
336
+
337
+ TEMPLATES: Dict[str, Any] = {
338
+ "burger": _template_burger,
339
+ "snowman": _template_snowman,
340
+ "tree": _template_tree,
341
+ }
342
+
343
+
344
  class Scene(BaseModel):
345
  background: str = "#0b0e14"
346
+ objects: List[Union[Obj, LayoutStack, LayoutRow, LayoutGrid]] = Field(default_factory=list)
347
  lights: List[Light] = Field(default_factory=list)
348
  animation: Animation = Field(default_factory=Animation)
349
 
 
353
  v = str(v)
354
  return v if HEX.match(v) else "#0b0e14"
355
 
356
+ @field_validator("objects", mode="before")
357
+ @classmethod
358
+ def _objects(cls, v: Any) -> List[Any]:
359
+ if not isinstance(v, list):
360
+ return []
361
+ return [_parse_scene_item(item) for item in v]
362
+
363
 
364
  def extract_json(text: str) -> Optional[dict]:
365
  """Pull the first balanced JSON object out of a raw model response."""
 
383
  if not isinstance(data, dict):
384
  log.warning("build_scene: no valid dict, using empty scene")
385
  data = {}
386
+ # Expand named templates into flat object lists before schema validation
387
+ tmpl = data.get("template")
388
+ if isinstance(tmpl, dict) and not data.get("objects"):
389
+ name = tmpl.get("name", "")
390
+ if name in TEMPLATES:
391
+ log.info("Expanding template: %s", name)
392
+ data = {k: v for k, v in data.items() if k != "template"}
393
+ data["objects"] = [o.model_dump() for o in TEMPLATES[name](tmpl)]
394
  try:
395
  scene = Scene(**data)
396
  except Exception as e: