| """ |
| Deterministic compiler: validated Scene -> clean, standalone Three.js HTML. |
| |
| The SAME html string is used for both the live preview (wrapped in an iframe) |
| and the "copy code" tab, so what the user sees is exactly what they copy. |
| """ |
| from __future__ import annotations |
|
|
| import base64 |
| import json |
| from typing import Any, Dict |
|
|
| from scene import (Animation, ExtrudeNode, GroupNode, LayoutGrid, LayoutRow, |
| LayoutStack, Obj, Scene, Text3DNode, _shape_extent) |
|
|
| FONT_URL = "https://unpkg.com/three@0.160.0/examples/fonts/helvetiker_regular.typeface.json" |
|
|
|
|
| class _Ctx: |
| """Mutable compilation context threaded through recursive scene-graph traversal.""" |
| __slots__ = ("style", "_idx", "text_jobs") |
|
|
| def __init__(self, style: str) -> None: |
| self.style = style |
| self._idx = 0 |
| |
| self.text_jobs: list = [] |
|
|
| def nxt(self) -> int: |
| i = self._idx |
| self._idx += 1 |
| return i |
|
|
|
|
| def _col_str(color: str) -> str: |
| """Wrap a sanitized color value for JS: 'new THREE.Color("<value>")'.""" |
| return f'new THREE.Color("{color}")' |
|
|
|
|
| def geometry_js(o: Obj) -> str: |
| p = o.params or {} |
| def g(k: str, d: float) -> float: |
| return float(p.get(k, d)) |
|
|
| s = o.shape |
| if s == "box": |
| return f"new THREE.BoxGeometry({g('width', 1)}, {g('height', 1)}, {g('depth', 1)})" |
| if s == "sphere": |
| return f"new THREE.SphereGeometry({g('radius', 0.6)}, 32, 16)" |
| if s == "cylinder": |
| return (f"new THREE.CylinderGeometry({g('radiusTop', 0.5)}, " |
| f"{g('radiusBottom', 0.5)}, {g('height', 1)}, 32)") |
| if s == "cone": |
| return f"new THREE.ConeGeometry({g('radius', 0.5)}, {g('height', 1)}, 32)" |
| if s == "torus": |
| return f"new THREE.TorusGeometry({g('radius', 0.5)}, {g('tube', 0.2)}, 16, 48)" |
| if s == "torusKnot": |
| return f"new THREE.TorusKnotGeometry({g('radius', 0.5)}, {g('tube', 0.15)}, 100, 16)" |
| if s == "plane": |
| return f"new THREE.PlaneGeometry({g('width', 5)}, {g('height', 5)})" |
| poly = { |
| "tetrahedron": "TetrahedronGeometry", |
| "icosahedron": "IcosahedronGeometry", |
| "dodecahedron": "DodecahedronGeometry", |
| "octahedron": "OctahedronGeometry", |
| } |
| if s in poly: |
| return f"new THREE.{poly[s]}({g('radius', 0.6)}, 0)" |
| if s == "capsule": |
| return f"new THREE.CapsuleGeometry({g('radius', 0.4)}, {g('length', 1.0)}, 8, 16)" |
| if s == "ring": |
| return f"new THREE.RingGeometry({g('innerRadius', 0.3)}, {g('outerRadius', 0.6)}, 32)" |
| if s == "circle": |
| return f"new THREE.CircleGeometry({g('radius', 0.6)}, 32)" |
| if s == "tube": |
| r = g('radius', 0.1) |
| return ( |
| f"(()=>{{ const _c=new THREE.CatmullRomCurve3([" |
| f"new THREE.Vector3(-0.4,-0.6,0)," |
| f"new THREE.Vector3(0.4,-0.2,0)," |
| f"new THREE.Vector3(-0.4,0.2,0)," |
| f"new THREE.Vector3(0.4,0.6,0)" |
| f"]); return new THREE.TubeGeometry(_c,64,{r},12,false); }})()" |
| ) |
| if s == "roundedBox": |
| return (f"new RoundedBoxGeometry({g('width', 1.0)}, {g('height', 1.0)}, " |
| f"{g('depth', 1.0)}, 4, {g('radius', 0.1)})") |
| return "new THREE.BoxGeometry(1, 1, 1)" |
|
|
|
|
| def _preset_mat_js(node) -> str | None: |
| """Return a JS material string for a named preset, or None if no preset is set. |
| |
| color override: if node.color differs from the default (#88ccff), use it; |
| otherwise fall back to each preset's canonical color so it looks right out of the box. |
| For neon, node.color is the emissive glow color regardless. |
| """ |
| preset = getattr(node, "preset", None) |
| if not preset: |
| return None |
| col = _col_str(node.color) |
| has_custom = node.color != "#88ccff" |
|
|
| if preset == "gold": |
| c = col if has_custom else _col_str("#ffd700") |
| return (f"new THREE.MeshPhysicalMaterial({{ color: {c}, " |
| f"metalness: 1.0, roughness: 0.25, clearcoat: 1.0, clearcoatRoughness: 0.1 }})") |
|
|
| if preset == "chrome": |
| c = col if has_custom else _col_str("#ffffff") |
| return (f"new THREE.MeshPhysicalMaterial({{ color: {c}, " |
| f"metalness: 1.0, roughness: 0.05, clearcoat: 1.0, clearcoatRoughness: 0.05 }})") |
|
|
| if preset == "glass": |
| return (f"new THREE.MeshPhysicalMaterial({{ color: {col}, " |
| f"metalness: 0.0, roughness: 0.05, " |
| f"transmission: 1.0, thickness: 0.5, ior: 1.5, " |
| f"transparent: true, opacity: 1.0 }})") |
|
|
| if preset == "neon": |
| return (f"new THREE.MeshStandardMaterial({{ " |
| f"color: new THREE.Color('#000000'), " |
| f"emissive: {col}, emissiveIntensity: 2.0 }})") |
|
|
| if preset == "matte": |
| return (f"new THREE.MeshStandardMaterial({{ color: {col}, " |
| f"metalness: 0.0, roughness: 0.9 }})") |
|
|
| if preset == "plastic": |
| return (f"new THREE.MeshPhysicalMaterial({{ color: {col}, " |
| f"metalness: 0.0, roughness: 0.4, clearcoat: 0.5, clearcoatRoughness: 0.3 }})") |
|
|
| return None |
|
|
|
|
| def material_js(o: Obj, style: str = "realistic") -> str: |
| col = _col_str(o.color) |
| emi = _col_str(o.emissive) |
| if style == "wireframe": |
| return f"new THREE.MeshStandardMaterial({{ color: {col}, wireframe: true }})" |
| if style == "toon": |
| return f"new THREE.MeshToonMaterial({{ color: {col}, emissive: {emi} }})" |
| if style == "flat": |
| return (f"new THREE.MeshStandardMaterial({{ color: {col}, emissive: {emi}, " |
| f"metalness: {o.metalness}, roughness: {o.roughness}, flatShading: true }})") |
| |
| preset_mat = _preset_mat_js(o) |
| if preset_mat: |
| return preset_mat |
| |
| if o.material == "basic": |
| return f"new THREE.MeshBasicMaterial({{ color: {col} }})" |
| if o.material == "phong": |
| return f"new THREE.MeshPhongMaterial({{ color: {col}, emissive: {emi}, shininess: 80 }})" |
| if o.material == "wireframe": |
| return f"new THREE.MeshStandardMaterial({{ color: {col}, wireframe: true }})" |
| return (f"new THREE.MeshStandardMaterial({{ color: {col}, emissive: {emi}, " |
| f"metalness: {o.metalness}, roughness: {o.roughness} }})") |
|
|
|
|
| def _text_jobs_js(jobs: list, style: str = "realistic") -> str: |
| """Emit async font-load block for text3d nodes collected during compilation. |
| |
| jobs: [(Text3DNode, js_var_name, js_parent_var), ...] |
| r160 TextGeometry uses 'height' for extrusion depth (renamed 'depth' at r163). |
| Returns empty string when no text nodes exist (no await, no font fetch). |
| """ |
| if not jobs: |
| return "" |
| lines = [ |
| f" const _font = await new FontLoader().loadAsync(", |
| f" '{FONT_URL}'", |
| f" );", |
| ] |
| for node, var, par in jobs: |
| mat = material_js(node, style) |
| txt = json.dumps(node.text) |
| bevel = "true" if node.bevel else "false" |
| px, py, pz = node.position |
| rx, ry, rz = node.rotation |
| sx, sy, sz = node.scale |
| lines += [ |
| f" {{ // text3d: {node.text!r}", |
| f" const _geo = new TextGeometry({txt}, {{", |
| f" font: _font, size: {node.size}, height: {node.depth},", |
| f" curveSegments: 12, bevelEnabled: {bevel},", |
| f" bevelThickness: 0.02, bevelSize: 0.02, bevelSegments: 3", |
| f" }});", |
| f" _geo.center();", |
| f" const {var} = new THREE.Mesh(_geo, {mat});", |
| f" {var}.position.set({px}, {py}, {pz});", |
| f" {var}.rotation.set({rx}, {ry}, {rz});", |
| f" {var}.scale.set({sx}, {sy}, {sz});", |
| f" {par}.add({var});", |
| f" }}", |
| ] |
| return "\n".join(lines) |
|
|
|
|
| |
| def text_section_js(nodes_with_idx: list, style: str = "realistic") -> str: |
| jobs = [(n, f"mesh{i}", "group") for n, i in nodes_with_idx] |
| return _text_jobs_js(jobs, style) |
|
|
|
|
| def _obj_js(node: Obj, idx: int, style: str, parent: str = "group") -> list: |
| """Return JS lines for a single primitive mesh.""" |
| px, py, pz = node.position |
| rx, ry, rz = node.rotation |
| sx, sy, sz = node.scale |
| return [ |
| f"const mesh{idx} = new THREE.Mesh({geometry_js(node)}, {material_js(node, style)});", |
| f"mesh{idx}.position.set({px}, {py}, {pz});", |
| f"mesh{idx}.rotation.set({rx}, {ry}, {rz});", |
| f"mesh{idx}.scale.set({sx}, {sy}, {sz});", |
| f"{parent}.add(mesh{idx});", |
| ] |
|
|
|
|
| def _compile_node(node: Any, parent: str, ctx: _Ctx) -> list: |
| """Recursively compile one scene node to JS lines, collecting text jobs in ctx.""" |
| if isinstance(node, GroupNode): |
| return _compile_group(node, parent, ctx) |
| if isinstance(node, Text3DNode): |
| idx = ctx.nxt() |
| ctx.text_jobs.append((node, f"mesh{idx}", parent)) |
| return [] |
| if isinstance(node, ExtrudeNode): |
| return extrude_js(node, ctx.nxt(), ctx.style, parent) |
| if isinstance(node, Obj): |
| return _obj_js(node, ctx.nxt(), ctx.style, parent) |
| |
| lines: list = [] |
| for n in _flatten([node]): |
| lines.extend(_compile_node(n, parent, ctx)) |
| return lines |
|
|
|
|
| def _compile_group(node: GroupNode, parent: str, ctx: _Ctx) -> list: |
| """Emit a THREE.Group() with children laid out and group transform applied.""" |
| idx = ctx.nxt() |
| gvar = f"grp{idx}" |
| lines: list = [f"const {gvar} = new THREE.Group();"] |
| for child in _group_layout_children(node): |
| lines.extend(_compile_node(child, gvar, ctx)) |
| px, py, pz = node.position |
| rx, ry, rz = node.rotation |
| sx, sy, sz = node.scale |
| lines += [ |
| f"{gvar}.position.set({px}, {py}, {pz});", |
| f"{gvar}.rotation.set({rx}, {ry}, {rz});", |
| f"{gvar}.scale.set({sx}, {sy}, {sz});", |
| f"{parent}.add({gvar});", |
| ] |
| return lines |
|
|
|
|
| def objects_js(scene: Scene, ctx: _Ctx) -> str: |
| """Compile all scene objects using the context for index + text-job tracking.""" |
| lines: list = [] |
| for node in scene.objects: |
| lines.extend(_compile_node(node, "group", ctx)) |
| return "\n ".join(lines) |
|
|
|
|
| def lights_js(scene: Scene) -> str: |
| """Kept for backwards compat / tests; compile_html uses preset lighting instead.""" |
| lines = [] |
| for l in scene.lights: |
| c = f'"{l.color}"' |
| x, y, z = l.position |
| if l.type == "ambient": |
| lines.append(f"scene.add(new THREE.AmbientLight({c}, {l.intensity}));") |
| elif l.type == "point": |
| lines.append( |
| f"{{ const L = new THREE.PointLight({c}, {l.intensity}); " |
| f"L.position.set({x}, {y}, {z}); scene.add(L); }}") |
| else: |
| lines.append( |
| f"{{ const L = new THREE.DirectionalLight({c}, {l.intensity}); " |
| f"L.position.set({x}, {y}, {z}); scene.add(L); }}") |
| return "\n ".join(lines) |
|
|
|
|
| def _shadow_config_js() -> str: |
| return ( |
| "L.castShadow = true; " |
| "L.shadow.mapSize.setScalar(1024); " |
| "L.shadow.camera.near = 0.5; L.shadow.camera.far = 28; " |
| "L.shadow.camera.left = -7; L.shadow.camera.right = 7; " |
| "L.shadow.camera.top = 7; L.shadow.camera.bottom = -7; " |
| "L.shadow.bias = -0.001;" |
| ) |
|
|
|
|
| def _preset_lights_js(preset: str, shadows: bool) -> str: |
| """Return JS that adds preset lighting to the scene. Key light casts shadows if enabled.""" |
| sc = _shadow_config_js() if shadows else "" |
|
|
| if preset == "soft": |
| |
| return ( |
| 'scene.add(new THREE.HemisphereLight("#b1e1ff", "#4a3828", 1.2));\n' |
| ' scene.add(new THREE.AmbientLight("#ffffff", 0.35));\n' |
| f' {{ const L = new THREE.DirectionalLight("#ffffff", 0.25); ' |
| f'L.position.set(3, 6, 3); {sc} scene.add(L); }}' |
| ) |
|
|
| if preset == "neon": |
| |
| return ( |
| 'scene.add(new THREE.AmbientLight("#06060f", 0.4));\n' |
| ' { const L = new THREE.PointLight("#00ffff", 4.0); ' |
| 'L.position.set(-3, 2, 3); scene.add(L); }\n' |
| ' { const L = new THREE.PointLight("#ff00ff", 4.0); ' |
| 'L.position.set(3, -1, -2); scene.add(L); }\n' |
| ' { const L = new THREE.PointLight("#8800ff", 2.0); ' |
| 'L.position.set(0, 5, -3); scene.add(L); }' |
| ) |
|
|
| if preset == "dramatic": |
| |
| return ( |
| f'{{ const L = new THREE.DirectionalLight("#fff4e8", 4.5); ' |
| f'L.position.set(8, 10, 3); {sc} scene.add(L); }}\n' |
| ' { const L = new THREE.DirectionalLight("#1a2040", 0.15); ' |
| 'L.position.set(-8, -2, -5); scene.add(L); }\n' |
| ' scene.add(new THREE.AmbientLight("#020205", 0.08));' |
| ) |
|
|
| |
| return ( |
| f'{{ const L = new THREE.DirectionalLight("#ffffff", 2.5); ' |
| f'L.position.set(5, 8, 5); {sc} scene.add(L); }}\n' |
| ' { const L = new THREE.DirectionalLight("#b8d4ff", 0.4); ' |
| 'L.position.set(-5, 2, -3); scene.add(L); }\n' |
| ' { const L = new THREE.DirectionalLight("#ffeedd", 0.6); ' |
| 'L.position.set(-2, 4, -8); scene.add(L); }\n' |
| ' scene.add(new THREE.AmbientLight("#ffffff", 0.25));' |
| ) |
|
|
|
|
| def _accent_lights_js(scene: Scene) -> str: |
| """Emit only model-specified point lights at 70% intensity as colour accents.""" |
| lines = [] |
| for l in scene.lights: |
| if l.type == "point": |
| x, y, z = l.position |
| c = f'"{l.color}"' |
| intensity = round(l.intensity * 0.7, 3) |
| lines.append( |
| f"{{ const L = new THREE.PointLight({c}, {intensity}); " |
| f"L.position.set({x}, {y}, {z}); scene.add(L); }}" |
| ) |
| return "\n ".join(lines) |
|
|
|
|
| def animation_js(a: Animation) -> str: |
| if a.type == "none": |
| return "" |
| if a.type == "rotate": |
| return f"group.rotation.{a.axis} += 0.01 * {a.speed};" |
| if a.type == "float": |
| return f"group.position.y = Math.sin(t * {a.speed}) * 0.3;" |
| if a.type == "orbit": |
| return (f"group.rotation.y += 0.01 * {a.speed}; " |
| f"group.position.x = Math.sin(t * {a.speed}) * 0.5;") |
| return "" |
|
|
|
|
| |
| |
| |
| |
| |
|
|
| def _shp_star() -> list: |
| return [ |
| "const _shp = new THREE.Shape();", |
| "const _outerR = 1.0, _innerR = 0.4, _pts = 5;", |
| "for (let _i = 0; _i < _pts * 2; _i++) {", |
| " const _r = _i % 2 === 0 ? _outerR : _innerR;", |
| " const _a = (_i / (_pts * 2)) * Math.PI * 2 - Math.PI / 2;", |
| " _shp[_i === 0 ? 'moveTo' : 'lineTo'](Math.cos(_a) * _r, Math.sin(_a) * _r);", |
| "}", |
| "_shp.closePath();", |
| ] |
|
|
|
|
| def _shp_heart() -> list: |
| |
| return [ |
| "const _shp = new THREE.Shape();", |
| "_shp.moveTo(0.5, 0.5);", |
| "_shp.bezierCurveTo(0.5, 0.5, 0.4, 0, 0, 0);", |
| "_shp.bezierCurveTo(-0.6, 0, -0.6, 0.7, -0.6, 0.7);", |
| "_shp.bezierCurveTo(-0.6, 1.1, -0.2, 1.54, 0.5, 1.9);", |
| "_shp.bezierCurveTo(1.2, 1.54, 1.6, 1.1, 1.6, 0.7);", |
| "_shp.bezierCurveTo(1.6, 0.7, 1.6, 0, 1.0, 0);", |
| "_shp.bezierCurveTo(0.7, 0, 0.5, 0.5, 0.5, 0.5);", |
| ] |
|
|
|
|
| def _shp_hexagon() -> list: |
| return [ |
| "const _shp = new THREE.Shape();", |
| "for (let _i = 0; _i < 6; _i++) {", |
| " const _a = (Math.PI / 3) * _i + Math.PI / 6;", |
| " _shp[_i === 0 ? 'moveTo' : 'lineTo'](Math.cos(_a), Math.sin(_a));", |
| "}", |
| "_shp.closePath();", |
| ] |
|
|
|
|
| def _shp_badge() -> list: |
| |
| return [ |
| "const _shp = new THREE.Shape();", |
| "const _bw = 1.6, _bh = 1.0, _br = 0.25;", |
| "const _bx = -_bw / 2, _by = -_bh / 2;", |
| "_shp.moveTo(_bx, _by + _br);", |
| "_shp.lineTo(_bx, _by + _bh - _br);", |
| "_shp.quadraticCurveTo(_bx, _by + _bh, _bx + _br, _by + _bh);", |
| "_shp.lineTo(_bx + _bw - _br, _by + _bh);", |
| "_shp.quadraticCurveTo(_bx + _bw, _by + _bh, _bx + _bw, _by + _bh - _br);", |
| "_shp.lineTo(_bx + _bw, _by + _br);", |
| "_shp.quadraticCurveTo(_bx + _bw, _by, _bx + _bw - _br, _by);", |
| "_shp.lineTo(_bx + _br, _by);", |
| "_shp.quadraticCurveTo(_bx, _by, _bx, _by + _br);", |
| ] |
|
|
|
|
| def _shp_shield() -> list: |
| |
| |
| return [ |
| "const _shp = new THREE.Shape();", |
| "_shp.moveTo(-1.0, 1.0);", |
| "_shp.lineTo( 1.0, 1.0);", |
| "_shp.lineTo( 1.0, 0.2);", |
| "_shp.quadraticCurveTo( 1.0, -0.8, 0.0, -1.3);", |
| "_shp.quadraticCurveTo(-1.0, -0.8, -1.0, 0.2);", |
| "_shp.lineTo(-1.0, 1.0);", |
| ] |
|
|
|
|
| SHAPE_LIBRARY: Dict[str, Any] = { |
| "star": _shp_star, |
| "heart": _shp_heart, |
| "hexagon": _shp_hexagon, |
| "badge": _shp_badge, |
| "shield": _shp_shield, |
| } |
|
|
|
|
| def extrude_js(node: ExtrudeNode, idx: int, style: str = "realistic", |
| parent: str = "group") -> list: |
| """Return JS lines for one extruded shape mesh, self-contained in a block scope.""" |
| shape_fn = SHAPE_LIBRARY.get(node.shape, _shp_badge) |
| mat = material_js(node, style) |
| bevel = "true" if node.bevel else "false" |
| depth = node.depth |
| px, py, pz = node.position |
| rx, ry, rz = node.rotation |
| sx, sy, sz = node.scale |
|
|
| lines = ["{ // extrude: " + node.shape] |
| lines.extend(" " + l for l in shape_fn()) |
| lines += [ |
| f" const _geo = new THREE.ExtrudeGeometry(_shp, {{", |
| f" depth: {depth}, bevelEnabled: {bevel},", |
| f" bevelThickness: 0.03, bevelSize: 0.03, bevelSegments: 3,", |
| f" curveSegments: 12, steps: 1", |
| f" }});", |
| f" _geo.center();", |
| f" _geo.computeBoundingBox();", |
| f" const _mxdim = Math.max(", |
| f" _geo.boundingBox.max.x - _geo.boundingBox.min.x,", |
| f" _geo.boundingBox.max.y - _geo.boundingBox.min.y,", |
| f" _geo.boundingBox.max.z - _geo.boundingBox.min.z", |
| f" ) || 1;", |
| f" const _nsc = 1.5 / _mxdim;", |
| f" _geo.scale(_nsc, _nsc, _nsc);", |
| f" const mesh{idx} = new THREE.Mesh(_geo, {mat});", |
| f" mesh{idx}.position.set({px}, {py}, {pz});", |
| f" mesh{idx}.rotation.set({rx}, {ry}, {rz});", |
| f" mesh{idx}.scale.set({sx}, {sy}, {sz});", |
| f" {parent}.add(mesh{idx});", |
| "}", |
| ] |
| return lines |
|
|
|
|
| |
|
|
| def _offset_node(node: Any, axis: int, delta: float) -> Any: |
| """Return a copy of node with position[axis] shifted by delta.""" |
| pos = list(node.position) |
| pos[axis] += delta |
| return node.model_copy(update={"position": pos}) |
|
|
|
|
| def _node_axis_size(node: Any, axis: int) -> float: |
| """Estimate the bounding-box size of a node along one axis (0=x,1=y,2=z).""" |
| if isinstance(node, Obj): |
| return _shape_extent(node.shape, node.params)[axis] |
| if isinstance(node, ExtrudeNode): |
| return 1.5 |
| if isinstance(node, Text3DNode): |
| if axis == 0: |
| return max(node.size * len(node.text) * 0.6, node.size) |
| if axis == 1: |
| return node.size * 1.2 |
| return node.depth + 0.05 |
| if isinstance(node, LayoutStack): |
| ai = {"x": 0, "y": 1, "z": 2}.get(node.axis, 1) |
| sizes = [_node_axis_size(c, ai) for c in node.children] |
| return sum(sizes) + max(0, len(sizes) - 1) * node.gap if sizes else 1.0 |
| if isinstance(node, GroupNode): |
| if not node.children: |
| return 1.0 |
| sizes = [_node_axis_size(c, axis) for c in node.children] |
| if node.layout == "row" and axis == 0: |
| return sum(sizes) + max(0, len(node.children) - 1) * node.gap |
| if node.layout in ("column", "stack") and axis == 1: |
| return sum(sizes) + max(0, len(node.children) - 1) * node.gap |
| return max(sizes) |
| return 1.0 |
|
|
|
|
| def _group_layout_children(node: GroupNode) -> list: |
| """Return children repositioned according to node.layout.""" |
| children = node.children |
| if not children or node.layout == "none": |
| return children |
| if node.layout == "row": |
| return _layout_axis_children(children, axis=0, gap=node.gap) |
| if node.layout in ("column", "stack"): |
| return _layout_axis_children(children, axis=1, gap=node.gap) |
| if node.layout == "grid": |
| return _layout_grid_children(children, cols=node.cols, gap=node.gap) |
| return children |
|
|
|
|
| def _layout_axis_children(children: list, axis: int, gap: float) -> list: |
| """Center children along one axis with gap spacing between them.""" |
| sizes = [_node_axis_size(c, axis) for c in children] |
| total = sum(sizes) + max(0, len(sizes) - 1) * gap |
| cursor = -total / 2.0 |
| result = [] |
| for child, size in zip(children, sizes): |
| result.append(_offset_node(child, axis, cursor + size / 2.0)) |
| cursor += size + gap |
| return result |
|
|
|
|
| def _layout_grid_children(children: list, cols: int, gap: float) -> list: |
| """Lay out children in a grid on the x-z plane, centered at origin.""" |
| cols = max(1, cols) |
| rows = (len(children) + cols - 1) // cols |
| result = [] |
| for i, child in enumerate(children): |
| x = (i % cols - (cols - 1) / 2.0) * gap |
| z = (i // cols - (rows - 1) / 2.0) * gap |
| result.append(_offset_node(_offset_node(child, 0, x), 2, z)) |
| return result |
|
|
|
|
| def _flatten(items: list, ox: float = 0.0, oy: float = 0.0, oz: float = 0.0) -> list: |
| """Recursively resolve layout containers into a flat list of positioned leaf nodes.""" |
| result = [] |
| for item in items: |
| if isinstance(item, (Obj, ExtrudeNode, Text3DNode)): |
| result.append(item.model_copy(update={ |
| "position": [item.position[0] + ox, |
| item.position[1] + oy, |
| item.position[2] + oz] |
| })) |
| elif isinstance(item, LayoutStack): |
| result.extend(_flatten_stack(item, ox, oy, oz)) |
| elif isinstance(item, LayoutRow): |
| fake = LayoutStack(axis="x", gap=item.gap, |
| position=item.position, children=item.children) |
| result.extend(_flatten_stack(fake, ox, oy, oz)) |
| elif isinstance(item, LayoutGrid): |
| result.extend(_flatten_grid(item, ox, oy, oz)) |
| elif isinstance(item, GroupNode): |
| |
| gx = item.position[0] + ox |
| gy = item.position[1] + oy |
| gz = item.position[2] + oz |
| result.extend(_flatten(_group_layout_children(item), gx, gy, gz)) |
| return result |
|
|
|
|
| def _flatten_stack(stack: LayoutStack, ox: float, oy: float, oz: float) -> list: |
| ai = {"x": 0, "y": 1, "z": 2}.get(stack.axis, 1) |
| base = [stack.position[0] + ox, stack.position[1] + oy, stack.position[2] + oz] |
| sizes = [_node_axis_size(c, ai) for c in stack.children] |
| total = sum(sizes) + max(0, len(sizes) - 1) * stack.gap |
| cursor = -total / 2.0 |
| result = [] |
| for child, size in zip(stack.children, sizes): |
| off = list(base) |
| off[ai] += cursor + size / 2.0 |
| cursor += size + stack.gap |
| result.extend(_flatten([child], *off)) |
| return result |
|
|
|
|
| def _flatten_grid(grid: LayoutGrid, ox: float, oy: float, oz: float) -> list: |
| base = [grid.position[0] + ox, grid.position[1] + oy, grid.position[2] + oz] |
| cols = max(1, grid.cols) |
| rows = (len(grid.children) + cols - 1) // cols |
| sx = -(cols - 1) * grid.gap_x / 2.0 |
| sz = -(rows - 1) * grid.gap_z / 2.0 |
| result = [] |
| for i, child in enumerate(grid.children): |
| off = [base[0] + sx + (i % cols) * grid.gap_x, |
| base[1], |
| base[2] + sz + (i // cols) * grid.gap_z] |
| result.extend(_flatten([child], *off)) |
| return result |
|
|
|
|
| TEMPLATE = """<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| <title>ThreeGen preview</title> |
| <style> |
| html, body { margin: 0; height: 100%; overflow: hidden; background: __BG__; } |
| canvas { display: block; } |
| </style> |
| <script type="importmap"> |
| { |
| "imports": { |
| "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js", |
| "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/" |
| } |
| } |
| </script> |
| </head> |
| <body> |
| <script type="module"> |
| import * as THREE from 'three'; |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; |
| import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'; |
| import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'; |
| import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'; |
| import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'; |
| import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js'; |
| import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; |
| import { FontLoader } from 'three/addons/loaders/FontLoader.js'; |
| import { TextGeometry } from 'three/addons/geometries/TextGeometry.js'; |
| |
| const scene = new THREE.Scene(); |
| scene.background = new THREE.Color('__BG__'); |
| |
| const camera = new THREE.PerspectiveCamera( |
| 50, window.innerWidth / window.innerHeight, 0.1, 100); |
| camera.position.set(3, 2, 4); |
| |
| const renderer = new THREE.WebGLRenderer({ antialias: true }); |
| renderer.setSize(window.innerWidth, window.innerHeight); |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; |
| renderer.toneMappingExposure = 1.1; |
| renderer.outputColorSpace = THREE.SRGBColorSpace; |
| renderer.shadowMap.enabled = __USE_SHADOWS__; |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; |
| document.body.appendChild(renderer.domElement); |
| |
| // ---- environment map: gives metals/glass real reflections ---- |
| const pmrem = new THREE.PMREMGenerator(renderer); |
| scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture; |
| pmrem.dispose(); |
| |
| const controls = new OrbitControls(camera, renderer.domElement); |
| controls.enableDamping = true; |
| |
| const group = new THREE.Group(); |
| scene.add(group); |
| |
| // ---- lighting preset ---- |
| __PRESET_LIGHTS__ |
| |
| // ---- model accent point-lights ---- |
| __ACCENT_LIGHTS__ |
| |
| // ---- bloom + output post-processing ---- |
| const USE_BLOOM = __USE_BLOOM__; |
| const BLOOM_STRENGTH = __BLOOM_STRENGTH__; |
| const composer = new EffectComposer(renderer); |
| composer.addPass(new RenderPass(scene, camera)); |
| composer.addPass(new UnrealBloomPass( |
| new THREE.Vector2(window.innerWidth, window.innerHeight), |
| BLOOM_STRENGTH, 0.4, 0.0 |
| )); |
| composer.addPass(new OutputPass()); |
| |
| const clock = new THREE.Clock(); |
| function animate() { |
| requestAnimationFrame(animate); |
| const t = clock.getElapsedTime(); |
| __ANIM__ |
| controls.update(); |
| if (USE_BLOOM) composer.render(); |
| else renderer.render(scene, camera); |
| } |
| |
| window.addEventListener('resize', () => { |
| camera.aspect = window.innerWidth / window.innerHeight; |
| camera.updateProjectionMatrix(); |
| renderer.setSize(window.innerWidth, window.innerHeight); |
| composer.setSize(window.innerWidth, window.innerHeight); |
| }); |
| |
| // ---- build scene (async so FontLoader can be awaited for text3d nodes) ---- |
| (async () => { |
| // ---- sync objects (primitives + extrude) ---- |
| __OBJECTS__ |
| |
| __TEXT_SECTION__ |
| // ---- shadow casting on every mesh ---- |
| if (__USE_SHADOWS__) { |
| group.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } }); |
| } |
| |
| // ---- auto-frame camera + contact-shadow ground plane ---- |
| const _box = new THREE.Box3().setFromObject(group); |
| if (!_box.isEmpty()) { |
| const _sz = _box.getSize(new THREE.Vector3()); |
| const _ctr = _box.getCenter(new THREE.Vector3()); |
| const maxDim = Math.max(_sz.x, _sz.y, _sz.z) || 1; |
| const fov = camera.fov * (Math.PI / 180); |
| const dist = (maxDim / 2) / Math.tan(fov / 2) * 1.6; |
| camera.position.set(_ctr.x + dist * 0.7, _ctr.y + dist * 0.45, _ctr.z + dist); |
| camera.near = dist / 100; |
| camera.far = dist * 100; |
| camera.updateProjectionMatrix(); |
| controls.target.copy(_ctr); |
| controls.update(); |
| |
| if (__USE_SHADOWS__) { |
| const gnd = new THREE.Mesh( |
| new THREE.PlaneGeometry(maxDim * 8, maxDim * 8), |
| new THREE.ShadowMaterial({ opacity: 0.35, transparent: true }) |
| ); |
| gnd.rotation.x = -Math.PI / 2; |
| gnd.position.y = _box.min.y - 0.005; |
| gnd.receiveShadow = true; |
| scene.add(gnd); |
| } |
| } |
| |
| animate(); |
| })().catch(console.error); |
| </script> |
| </body> |
| </html> |
| """ |
|
|
|
|
| def compile_html( |
| scene: Scene, |
| glow: bool = True, |
| glow_strength: float = 0.9, |
| style: str = "realistic", |
| lighting: str = "studio", |
| shadows: bool = True, |
| ) -> str: |
| ctx = _Ctx(style) |
|
|
| html = TEMPLATE |
| html = html.replace("__BG__", scene.background) |
| html = html.replace("__PRESET_LIGHTS__", _preset_lights_js(lighting, shadows)) |
| html = html.replace("__ACCENT_LIGHTS__", _accent_lights_js(scene)) |
| html = html.replace("__OBJECTS__", objects_js(scene, ctx)) |
| html = html.replace("__TEXT_SECTION__", _text_jobs_js(ctx.text_jobs, style)) |
| html = html.replace("__ANIM__", animation_js(scene.animation)) |
| html = html.replace("__USE_BLOOM__", "true" if glow else "false") |
| html = html.replace("__BLOOM_STRENGTH__", str(float(glow_strength))) |
| html = html.replace("__USE_SHADOWS__", "true" if shadows else "false") |
| return html |
|
|
|
|
| def iframe(html: str, height: int = 460) -> str: |
| """Wrap the standalone HTML as a data-URI iframe for gr.HTML preview.""" |
| b64 = base64.b64encode(html.encode("utf-8")).decode("ascii") |
| return ( |
| f'<iframe src="data:text/html;base64,{b64}" ' |
| f'style="width:100%;height:{height}px;border:0;border-radius:12px;" ' |
| f'sandbox="allow-scripts allow-same-origin"></iframe>' |
| ) |
|
|