Spaces:
Running on Zero
Running on Zero
| """ | |
| 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 | |
| # Each entry: (Text3DNode, js_var_name, js_parent_var) | |
| 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 overrides per-object material field in realistic mode | |
| preset_mat = _preset_mat_js(o) | |
| if preset_mat: | |
| return preset_mat | |
| # realistic — honour per-object material field | |
| 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) | |
| # Keep old name as alias for any callers that reference it directly | |
| 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) | |
| # Old layout containers (LayoutStack/Row/Grid): flatten then compile children | |
| 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": | |
| # Hemisphere gives the diffuse overcast feel; dim directional provides gentle shadows | |
| 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": | |
| # Synthwave: dark ambient + cyan/magenta/purple points | |
| 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": | |
| # Single hard key, barely-there fill, almost-black ambient | |
| 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));' | |
| ) | |
| # default: studio — 3-point (key + fill + rim + ambient) | |
| 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 "" | |
| # ---- Extrude shape-path library ---- | |
| # Each function returns a list of JS lines that build `const _shp = new THREE.Shape(); ...` | |
| # All paths are defined in a unit-scale coordinate space (max extent ~2 units) so that | |
| # bevel values (0.03) and depth (node.depth) are scale-consistent across all shapes. | |
| # geometry.center() + uniform scale to 1.5 normalise every shape to the same apparent size. | |
| 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: | |
| # Original Three.js docs heart path scaled by 1/50 → fits in ~[-0.6,1.6] × [0,1.9] | |
| 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;", # flat-top orientation | |
| " _shp[_i === 0 ? 'moveTo' : 'lineTo'](Math.cos(_a), Math.sin(_a));", | |
| "}", | |
| "_shp.closePath();", | |
| ] | |
| def _shp_badge() -> list: | |
| # Rounded rectangle — w=1.6, h=1.0, corner radius=0.25 | |
| 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: | |
| # Heraldic heater shield: flat top, two curved sides, tapers to bottom point | |
| # x ∈ [-1, 1], y ∈ [-1.3, 1.0] | |
| 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) # type: ignore[arg-type] | |
| 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 | |
| # ---- Layout resolver ---- | |
| 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 # normalised to 1.5-unit max dimension at render time | |
| if isinstance(node, Text3DNode): | |
| if axis == 0: # x: rough width (0.6 units per char at size 1) | |
| return max(node.size * len(node.text) * 0.6, node.size) | |
| if axis == 1: # y: cap height | |
| return node.size * 1.2 | |
| return node.depth + 0.05 # z | |
| 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): | |
| # Flatten GroupNode: apply layout + group position offset (rotation ignored) | |
| 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>' | |
| ) | |