@genex-ai/cli-demo 1.5.2-dev.398 → 1.6.0-dev.403

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,222 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "kind": "genex-asset",
4
+ "generatedAt": "2026-08-10T11:50:28.803Z",
5
+ "asset": {
6
+ "slug": "traffic-cone",
7
+ "name": "Traffic Cone",
8
+ "summary": "Highway safety cone, 0.7 m, square skirt base, two reflective collars.",
9
+ "tags": [
10
+ "prop",
11
+ "street",
12
+ "safety"
13
+ ],
14
+ "materialClass": "plastic",
15
+ "geometryClass": "revolved",
16
+ "difficulty": "easy",
17
+ "version": "1.0.0"
18
+ },
19
+ "license": {
20
+ "id": "MIT",
21
+ "holder": "Genex",
22
+ "year": 2026,
23
+ "notice": "Free to use, modify and ship, commercially included. Keep the copyright line in the file.",
24
+ "text": "MIT License\n\nCopyright (c) 2026 Genex\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
25
+ },
26
+ "source": {
27
+ "filename": "trafficCone.ts",
28
+ "language": "ts",
29
+ "entry": "createTrafficConeModel",
30
+ "optionsType": "TrafficConeOptions",
31
+ "imports": [
32
+ "three"
33
+ ],
34
+ "minThreeRevision": 160,
35
+ "builtAgainstThree": "0.185.1",
36
+ "bytes": 28780,
37
+ "sha256": "eee3f6c8cb2631a71e319f4b4b1a781c37f1a17d395423761890c170420ec48a",
38
+ "code": "// Traffic Cone - procedural Three.js asset.\n// MIT License - Copyright (c) 2026 Genex. Free to use, modify and ship.\n// Units: metres. Y-up, +Z forward. Origin at base centre (y = 0).\n// Requires: three >= r160. No other dependency.\n//\n// A one-piece moulded PVC highway cone: a square skirt whose top face morphs\n// into a circle, a hollow tapered shell rising out of it, and two wrapped\n// retroreflective sleeves with real thickness and lip edges. Every surface is\n// one revolved/lofted ring stack - no primitives, no CSG, no addons.\n//\n// Everything is built from four in-file pieces, in this order:\n// 1. mulberry32 + tiling value noise (all variance, seeded, no Math.random)\n// 2. DataTexture builders (mould stipple, scuff, prism lattice)\n// 3. loftRings() (the one geometry kernel, with creases)\n// 4. the profiles (the only place real-world sizes live)\n\nimport * as THREE from 'three';\n\n/** Detail steps. `standard` is what the roster's triangle band is measured on. */\ntype TrafficConeDetail = 'low' | 'standard' | 'high';\n\nexport interface TrafficConeOptions {\n seed?: number; // default 1; feeds the in-file mulberry32\n castShadow?: boolean; // default true\n receiveShadow?: boolean; // default true\n detail?: TrafficConeDetail; // default 'standard'\n wireframe?: boolean; // default false\n}\n\n/* ------------------------------------------------------------------------ *\n * 1. Determinism\n * ------------------------------------------------------------------------ */\n\n/**\n * mulberry32 - a 32-bit PRNG small enough to inline and good enough that two\n * builds of the same seed produce byte-identical vertex data. The whole asset\n * uses this; a bare `Math.random()` anywhere would make every measured number\n * a one-time coincidence.\n */\nfunction mulberry32(seed: number): () => number {\n let a = seed >>> 0;\n return () => {\n a = (a + 0x6d2b79f5) >>> 0;\n let t = a;\n t = Math.imul(t ^ (t >>> 15), t | 1);\n t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n };\n}\n\n/**\n * Derive an independent stream from the asset seed and a small stream index.\n * Deliberately NOT `mulberry32(seed + k)`: adjacent mulberry32 seeds correlate\n * in their first few outputs, which would make the scuff map and the grime\n * gradient visibly share a pattern.\n */\nfunction stream(seed: number, index: number): () => number {\n return mulberry32((Math.imul(seed, 0x9e3779b9) ^ Math.imul(index + 1, 0x85ebca6b)) >>> 0);\n}\n\nconst smoothstep = (t: number): number => t * t * (3 - 2 * t);\n\n/** A `cells x cells` grid of random values, sampled with wrap so it tiles. */\nfunction makeLattice(rand: () => number, cells: number): Float32Array {\n const out = new Float32Array(cells * cells);\n for (let i = 0; i < out.length; i++) out[i] = rand();\n return out;\n}\n\nfunction sampleLattice(lat: Float32Array, cells: number, u: number, v: number): number {\n const x = u * cells;\n const y = v * cells;\n const x0 = Math.floor(x);\n const y0 = Math.floor(y);\n const fx = smoothstep(x - x0);\n const fy = smoothstep(y - y0);\n const i0 = ((x0 % cells) + cells) % cells;\n const j0 = ((y0 % cells) + cells) % cells;\n const i1 = (i0 + 1) % cells;\n const j1 = (j0 + 1) % cells;\n const a = lat[j0 * cells + i0];\n const b = lat[j0 * cells + i1];\n const c = lat[j1 * cells + i0];\n const d = lat[j1 * cells + i1];\n return (a + (b - a) * fx) * (1 - fy) + (c + (d - c) * fx) * fy;\n}\n\n/** Tiling fractal noise in 0..1. Octave cell counts are powers of two so every\n * octave wraps on the same seam. */\nfunction makeFbm(rand: () => number, octaveCells: number[]): (u: number, v: number) => number {\n const lattices = octaveCells.map((cells) => makeLattice(rand, cells));\n let norm = 0;\n for (let o = 0; o < octaveCells.length; o++) norm += 1 / (1 << o);\n return (u, v) => {\n let sum = 0;\n for (let o = 0; o < octaveCells.length; o++) {\n sum += sampleLattice(lattices[o], octaveCells[o], u, v) / (1 << o);\n }\n return sum / norm;\n };\n}\n\n/* ------------------------------------------------------------------------ *\n * 2. Textures - DataTexture only\n * ------------------------------------------------------------------------ */\n// Every map here is a typed array. The canvas-backed texture class three also\n// offers is deliberately untouched: it needs a DOM, which would make this\n// module un-importable in a worker or under SSR, and a typed array draws the\n// same picture for nothing.\n\nfunction newDataTexture(data: Uint8Array, size: number, linear: boolean): THREE.DataTexture {\n const tex = new THREE.DataTexture(data, size, size, THREE.RGBAFormat, THREE.UnsignedByteType);\n // Normal and roughness data are raw numbers, never colour - leaving this at\n // NoColorSpace is what keeps the sRGB decode off them.\n tex.colorSpace = THREE.NoColorSpace;\n tex.wrapS = THREE.RepeatWrapping;\n tex.wrapT = THREE.RepeatWrapping;\n tex.magFilter = THREE.LinearFilter;\n tex.minFilter = linear ? THREE.LinearMipmapLinearFilter : THREE.NearestFilter;\n tex.generateMipmaps = linear;\n tex.anisotropy = 4;\n tex.needsUpdate = true;\n return tex;\n}\n\n/**\n * Encode a height field as a tangent-space normal map (OpenGL convention:\n * +Y is up, which is what three's `normalMap` expects).\n *\n * Central differences with wrap, so the map tiles as seamlessly as the height\n * field that fed it.\n *\n * `gain` is a unitless slope multiplier, NOT a relief height in metres: the\n * texel-to-metre ratio depends on the repeat the material happens to carry, so\n * there is no honest length to put here. Both call sites were tuned by\n * measuring the mean encoded tilt of the finished map, which is the number\n * that actually decides whether the surface reads.\n */\nfunction heightToNormalTexture(\n size: number,\n gain: number,\n height: (u: number, v: number) => number\n): THREE.DataTexture {\n const h = new Float32Array(size * size);\n for (let y = 0; y < size; y++) {\n for (let x = 0; x < size; x++) h[y * size + x] = height((x + 0.5) / size, (y + 0.5) / size);\n }\n const data = new Uint8Array(size * size * 4);\n const at = (x: number, y: number) => h[(((y % size) + size) % size) * size + (((x % size) + size) % size)];\n for (let y = 0; y < size; y++) {\n for (let x = 0; x < size; x++) {\n const dx = (at(x - 1, y) - at(x + 1, y)) * gain * size;\n const dy = (at(x, y - 1) - at(x, y + 1)) * gain * size;\n const len = Math.hypot(dx, dy, 1);\n const o = (y * size + x) * 4;\n data[o] = Math.round(((dx / len) * 0.5 + 0.5) * 255);\n data[o + 1] = Math.round(((dy / len) * 0.5 + 0.5) * 255);\n data[o + 2] = Math.round((1 / len) * 0.5 * 255 + 127.5);\n data[o + 3] = 255;\n }\n }\n return newDataTexture(data, size, true);\n}\n\n/**\n * A single-channel field written to R, G and B alike. three reads `.g` for\n * roughness; filling all three keeps the same texture usable as a metalness or\n * AO source by anyone who wants to rewire it.\n */\nfunction scalarTexture(size: number, field: (u: number, v: number) => number): THREE.DataTexture {\n const data = new Uint8Array(size * size * 4);\n for (let y = 0; y < size; y++) {\n for (let x = 0; x < size; x++) {\n const value = Math.max(0, Math.min(1, field((x + 0.5) / size, (y + 0.5) / size)));\n const byte = Math.round(value * 255);\n const o = (y * size + x) * 4;\n data[o] = byte;\n data[o + 1] = byte;\n data[o + 2] = byte;\n data[o + 3] = 255;\n }\n }\n return newDataTexture(data, size, true);\n}\n\n/** Symmetric 0..1 ramp - the tooth of the prismatic sheeting lattice. */\nconst triangleWave = (t: number): number => {\n const f = t - Math.floor(t);\n return f < 0.5 ? f * 2 : 2 - f * 2;\n};\n\n/* ------------------------------------------------------------------------ *\n * 3. The geometry kernel\n * ------------------------------------------------------------------------ */\n\n/** One sample of a ring outline, in the XZ plane. */\ninterface Pt2 {\n x: number;\n z: number;\n}\n\n/**\n * One cross-section of a lofted shell.\n *\n * `crease` splits the smoothing group at this ring: the ring is emitted twice,\n * once with the normal of the band below and once with the normal of the band\n * above. That is the difference between a moulded step and a soft blur, and it\n * is why the sleeve lips and the skirt's bottom edge read as edges.\n */\ninterface Ring {\n pts: Pt2[];\n y: number;\n crease?: boolean;\n}\n\n/** A circle of `segments` samples, centred on the axis. Radius 0 is a pole. */\nfunction circleRing(radius: number, segments: number): Pt2[] {\n const pts: Pt2[] = [];\n for (let i = 0; i < segments; i++) {\n const a = (i / segments) * Math.PI * 2;\n pts.push({ x: Math.cos(a) * radius, z: Math.sin(a) * radius });\n }\n return pts;\n}\n\n/**\n * A rounded square with `cornerSegments + 1` samples per corner.\n *\n * The corner CENTRES stay at ±(half - radius) whatever the inset, so calling\n * this with `(half - d, radius - d)` gives a true parallel offset of the same\n * outline - which is what makes the skirt's chamfer a chamfer rather than a\n * scaled copy that pinches at the corners.\n */\nfunction roundedSquareRing(half: number, radius: number, cornerSegments: number): Pt2[] {\n const c = half - radius;\n const centres: Pt2[] = [\n { x: c, z: c },\n { x: -c, z: c },\n { x: -c, z: -c },\n { x: c, z: -c },\n ];\n const pts: Pt2[] = [];\n for (let q = 0; q < 4; q++) {\n const start = q * (Math.PI / 2);\n for (let s = 0; s <= cornerSegments; s++) {\n const a = start + (s / cornerSegments) * (Math.PI / 2);\n pts.push({ x: centres[q].x + Math.cos(a) * radius, z: centres[q].z + Math.sin(a) * radius });\n }\n }\n return pts;\n}\n\n/**\n * Blend a ring outline toward a circle of `radius`, each sample moving along\n * its own bearing from the axis. `t = 0` keeps the outline, `t = 1` is the\n * circle - the skirt's top face uses two intermediate steps so the square\n * footprint becomes the round cone foot without a visible crease line.\n */\nfunction blendToCircle(pts: Pt2[], radius: number, t: number): Pt2[] {\n return pts.map((p) => {\n const len = Math.hypot(p.x, p.z) || 1;\n const target = { x: (p.x / len) * radius, z: (p.z / len) * radius };\n return { x: p.x + (target.x - p.x) * t, z: p.z + (target.z - p.z) * t };\n });\n}\n\ninterface LoftResult {\n geometry: THREE.BufferGeometry;\n /** Per-vertex height, handed back so the caller can paint a grime ramp\n * without re-deriving it from the position buffer. */\n heights: Float32Array;\n}\n\n/**\n * Loft a stack of rings into an indexed shell.\n *\n * Normals are analytic, never `computeVertexNormals()`: the face normal of a\n * band at sample i is `normalize(cross(dv, du))`, where `du` is the ring\n * tangent (averaged across both rings, so a pole ring still has one) and `dv`\n * is the step between the rings. Vertex normals average the two adjacent band\n * normals unless the ring is creased.\n *\n * `closed` wraps the last ring back onto the first - the cone shell is one\n * closed loop that climbs the outside, crosses the tip and comes back down the\n * inside, so its wall thickness is real geometry rather than a `side` flag.\n *\n * Pole rings (every sample at the axis) collapse their band to one triangle\n * per segment instead of a degenerate quad, and a pole-to-pole band is dropped\n * outright - that is where the apex round-over closes.\n */\nfunction loftRings(rings: Ring[], closed: boolean): LoftResult {\n const segments = rings[0].pts.length;\n const bandCount = closed ? rings.length : rings.length - 1;\n\n const isPole = rings.map((r) => r.pts.every((p) => Math.abs(p.x) < 1e-9 && Math.abs(p.z) < 1e-9));\n\n // Arc length along the profile drives v, so a texture keeps its real-world\n // scale across the flare, the taper and the rim instead of stretching. On a\n // closed loop the wrap band is measured too, and ring 0 is emitted a second\n // time at the full loop length - otherwise that last band runs its v\n // backwards to zero and squeezes the whole map into the rim.\n const arc: number[] = [0];\n for (let b = 0; b < bandCount; b++) {\n const a = rings[b];\n const c = rings[(b + 1) % rings.length];\n let d = 0;\n for (let i = 0; i < segments; i++) {\n d += Math.hypot(c.pts[i].x - a.pts[i].x, c.y - a.y, c.pts[i].z - a.pts[i].z);\n }\n arc.push(arc[b] + d / segments);\n }\n const arcTotal = arc[arc.length - 1] || 1;\n\n // Band normals, per band per sample.\n const bandNormals: THREE.Vector3[][] = [];\n const du = new THREE.Vector3();\n const dv = new THREE.Vector3();\n for (let b = 0; b < bandCount; b++) {\n const a = rings[b];\n const c = rings[(b + 1) % rings.length];\n const row: THREE.Vector3[] = [];\n for (let i = 0; i < segments; i++) {\n const prev = (i - 1 + segments) % segments;\n const next = (i + 1) % segments;\n du.set(\n (a.pts[next].x + c.pts[next].x - a.pts[prev].x - c.pts[prev].x) * 0.5,\n 0,\n (a.pts[next].z + c.pts[next].z - a.pts[prev].z - c.pts[prev].z) * 0.5\n );\n dv.set(c.pts[i].x - a.pts[i].x, c.y - a.y, c.pts[i].z - a.pts[i].z);\n const n = new THREE.Vector3().crossVectors(dv, du);\n if (n.lengthSq() < 1e-18) n.set(0, 1, 0);\n row.push(n.normalize());\n }\n bandNormals.push(row);\n }\n\n const positions: number[] = [];\n const normals: number[] = [];\n const uvs: number[] = [];\n const heights: number[] = [];\n const indices: number[] = [];\n\n // Emit one copy of a ring carrying `normal` - creased rings get two.\n // UVs are in METRES, not 0..1: u is the chord length around the ring and v is\n // the arc length along the profile, so one texture repeat covers the same\n // real distance on the skirt, the shell and the sleeves. Normalised UVs would\n // stretch the same map over a 1.4 m perimeter and a 0.15 m one and the mould\n // stipple would visibly change size between parts of one moulding.\n const emitRing = (ring: Ring, v: number, normal: THREE.Vector3[]): number => {\n const base = positions.length / 3;\n let u = 0;\n for (let i = 0; i <= segments; i++) {\n const p = ring.pts[i % segments];\n const n = normal[i % segments];\n if (i > 0) {\n const q = ring.pts[i - 1];\n u += Math.hypot(p.x - q.x, p.z - q.z);\n }\n positions.push(p.x, ring.y, p.z);\n normals.push(n.x, n.y, n.z);\n uvs.push(u, v);\n heights.push(ring.y);\n }\n return base;\n };\n\n const avg = (a: THREE.Vector3[], b: THREE.Vector3[]): THREE.Vector3[] =>\n a.map((n, i) => n.clone().add(b[i]).normalize());\n\n // Two indices per ring: the copy to use when the ring is the LOWER edge of a\n // band (it must carry that band's own normal) and the copy for when it is the\n // UPPER edge. Same vertex unless the ring is creased - or unless it is ring 0\n // of a closed loop, where the copies differ only in v.\n const asLower: number[] = [];\n const asUpper: number[] = [];\n for (let r = 0; r < rings.length; r++) {\n const below = closed || r > 0 ? bandNormals[(r - 1 + bandCount) % bandCount] : null;\n const above = closed || r < bandCount ? bandNormals[r % bandCount] : null;\n const v = arc[r];\n const split = rings[r].crease === true || (closed && r === 0);\n if (below && above && !split) {\n const base = emitRing(rings[r], v, avg(below, above));\n asLower.push(base);\n asUpper.push(base);\n } else if (below && above) {\n const blended = rings[r].crease === true ? null : avg(below, above);\n asUpper.push(emitRing(rings[r], closed && r === 0 ? arcTotal : v, blended ?? below));\n asLower.push(emitRing(rings[r], v, blended ?? above));\n } else {\n const base = emitRing(rings[r], v, (above ?? below)!);\n asLower.push(base);\n asUpper.push(base);\n }\n }\n\n for (let b = 0; b < bandCount; b++) {\n const nextRing = (b + 1) % rings.length;\n if (isPole[b] && isPole[nextRing]) continue; // the apex crossing\n const a = asLower[b];\n const c = asUpper[nextRing];\n for (let i = 0; i < segments; i++) {\n const a0 = a + i;\n const a1 = a + i + 1;\n const c0 = c + i;\n const c1 = c + i + 1;\n if (isPole[b]) {\n indices.push(a0, c0, c1);\n } else if (isPole[nextRing]) {\n indices.push(a0, c0, a1);\n } else {\n indices.push(a0, c0, c1, a0, c1, a1);\n }\n }\n }\n\n const geometry = new THREE.BufferGeometry();\n geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));\n geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));\n geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));\n geometry.setIndex(indices);\n geometry.computeBoundingBox();\n geometry.computeBoundingSphere();\n return { geometry, heights: new Float32Array(heights) };\n}\n\n/**\n * Paint a grey multiplier into a colour attribute: full brightness above\n * `clean`, dropping to `floor` at `dirty`, with a little seeded break-up so\n * the ramp never reads as a gradient decal. Values are greys with a faint cool\n * bias, so the multiply darkens toward road grime without shifting the hue of\n * whatever base colour the material carries.\n */\nfunction paintGrime(\n geometry: THREE.BufferGeometry,\n heights: Float32Array,\n dirty: number,\n clean: number,\n floor: number,\n rand: () => number\n): void {\n const colors = new Float32Array(heights.length * 3);\n for (let i = 0; i < heights.length; i++) {\n const t = Math.max(0, Math.min(1, (heights[i] - dirty) / (clean - dirty)));\n const k = floor + (1 - floor) * smoothstep(t);\n const jitter = 1 - rand() * 0.05;\n colors[i * 3] = k * jitter;\n colors[i * 3 + 1] = k * jitter * 1.012;\n colors[i * 3 + 2] = k * jitter * 1.04;\n }\n geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));\n}\n\n/* ------------------------------------------------------------------------ *\n * 4. The cone itself - the only place real-world sizes live\n * ------------------------------------------------------------------------ */\n\n/** Overall height, apex to ground. A 700 mm cone is the common motorway size. */\nconst HEIGHT = 0.7;\n/** Skirt footprint, both horizontal axes. */\nconst FOOTPRINT = 0.36;\n/** Moulded wall thickness of the shell. */\nconst WALL = 0.0045;\n\n// The straight taper is defined by its two ends, and BOTH the shell profile and\n// the sleeve radii are derived from them. Hand-typing a radius in two places is\n// how a collar ends up hovering a millimetre off the cone it is wrapped around.\nconst TAPER_FOOT = { r: 0.0955, y: 0.09 };\nconst TAPER_HEAD = { r: 0.0225, y: 0.68 };\n\nconst taperRadius = (y: number): number =>\n TAPER_FOOT.r +\n ((y - TAPER_FOOT.y) / (TAPER_HEAD.y - TAPER_FOOT.y)) * (TAPER_HEAD.r - TAPER_FOOT.r);\n\n/** Where each retroreflective sleeve sits, bottom and top, in metres. */\nconst SLEEVES: Array<[number, number]> = [\n [0.335, 0.415], // lower band, 80 mm\n [0.47, 0.59], // upper band, 120 mm - its top is 110 mm below the tip\n];\n\n/**\n * The shell: one closed profile that climbs the outside from the bottom rim,\n * rolls over the apex, and comes back down the inside to the rim again. The\n * apex is an ellipse of semi-axes 22.5 x 20 mm rather than a point - a real\n * cone is moulded with a blunt tip, and a pinched apex is the first thing that\n * gives away a lathe that ran its profile to r = 0.\n */\nfunction shellRings(segments: number): Ring[] {\n const mid = 0.3065; // one taper waypoint, so the grime ramp has something to land on\n const profile: Array<[number, number, boolean]> = [\n [0.1365, 0.015, true], // bottom rim, outer lip - buried inside the skirt bore\n [0.133, 0.0235, false], // emerges from the skirt's top lip\n [0.118, 0.036, false], // flare\n [0.106, 0.052, false],\n [0.0995, 0.07, false],\n [TAPER_FOOT.r, TAPER_FOOT.y, false], // flare settles into the straight taper\n [taperRadius(mid), mid, false],\n [TAPER_HEAD.r, TAPER_HEAD.y, false], // taper ends, round-over begins\n [0.0195, 0.69, false], // apex round-over\n [0.0129, 0.6964, false],\n [0.0058, 0.6993, false],\n [0, HEIGHT, false], // apex pole\n [0, 0.6935, false], // inner pole - the tip is moulded thicker than the wall\n [0.0126, 0.6845, false],\n [0.0179, 0.6745, false],\n [taperRadius(mid) - WALL, mid, false],\n [TAPER_FOOT.r - WALL, TAPER_FOOT.y, false],\n [0.0952, 0.07, false],\n [0.102, 0.052, false],\n [0.1145, 0.036, false],\n [0.1295, 0.0235, false],\n [0.132, 0.015, true], // inner rim; the loop closes across the 4.5 mm rim face\n ];\n return profile.map(([r, y, crease]) => ({ pts: circleRing(r, segments), y, crease }));\n}\n\n/**\n * The skirt: a rounded square that walks up a side wall, over a shoulder\n * chamfer, and then morphs into the circle the shell rises out of, before\n * turning down into a bore so the underside is hollow like the real moulding.\n *\n * The top lip (ring 4) is deliberately 1.8 mm NARROWER than the shell's outer\n * wall at that height, so the joint is covered by the shell rather than leaving\n * a reveal you can see down into.\n */\nfunction skirtRings(cornerSegments: number): Ring[] {\n const half = FOOTPRINT / 2;\n const outline = roundedSquareRing(half, 0.04, cornerSegments);\n const chamfer = roundedSquareRing(half - 0.0045, 0.0355, cornerSegments);\n const round = (radius: number) => blendToCircle(chamfer, radius, 1);\n return [\n { pts: outline, y: 0, crease: true }, // ground contact edge\n { pts: outline, y: 0.0135 }, // vertical side wall\n { pts: chamfer, y: 0.0195 }, // shoulder chamfer\n { pts: blendToCircle(chamfer, 0.156, 0.55), y: 0.0225 }, // top face, going round\n { pts: round(0.13), y: 0.0245, crease: true }, // lip, tucked under the shell\n { pts: round(0.1264), y: 0.0155 }, // bore wall\n { pts: round(0.123), y: 0.0045, crease: true }, // recessed underside\n ];\n}\n\n/**\n * One retroreflective sleeve: a wrapped band with real thickness and a rolled\n * lip top and bottom, sitting 1.6 mm proud of the taper. Its inner rings bite\n * 0.3 mm into the shell - same segment count, same bearings, so the sleeve's\n * facets are a uniform shrink of the shell's and cannot z-fight them.\n */\nfunction sleeveRings(yBottom: number, yTop: number, segments: number): Ring[] {\n const proud = 0.0016;\n const bite = 0.0003;\n const lip = 0.0006;\n return [\n { pts: circleRing(taperRadius(yBottom) - bite, segments), y: yBottom },\n { pts: circleRing(taperRadius(yBottom) + proud, segments), y: yBottom + lip, crease: true },\n { pts: circleRing(taperRadius(yTop) + proud, segments), y: yTop - lip, crease: true },\n { pts: circleRing(taperRadius(yTop) - bite, segments), y: yTop },\n ];\n}\n\ninterface DetailStep {\n segments: number;\n corners: number;\n plasticNormal: number;\n plasticRough: number;\n sleeveNormal: number;\n sleeveRough: number;\n}\n\nconst DETAIL_STEPS: Record<TrafficConeDetail, DetailStep> = {\n low: { segments: 16, corners: 3, plasticNormal: 128, plasticRough: 64, sleeveNormal: 64, sleeveRough: 32 },\n standard: { segments: 24, corners: 4, plasticNormal: 256, plasticRough: 128, sleeveNormal: 128, sleeveRough: 64 },\n high: { segments: 40, corners: 6, plasticNormal: 512, plasticRough: 256, sleeveNormal: 256, sleeveRough: 128 },\n};\n\n/**\n * Build a traffic cone.\n *\n * The returned Group's origin is the centre of its footprint at ground level,\n * so `cone.position.set(x, groundY, z)` puts it flat on the floor with no\n * offset maths. Nothing is added to any scene and no renderer state is touched\n * - the host owns the stage.\n */\nexport function createTrafficConeModel(options: TrafficConeOptions = {}): THREE.Group {\n const seed = options.seed ?? 1;\n const castShadow = options.castShadow ?? true;\n const receiveShadow = options.receiveShadow ?? true;\n const detail = DETAIL_STEPS[options.detail ?? 'standard'] ?? DETAIL_STEPS.standard;\n const wireframe = options.wireframe ?? false;\n\n // --- textures ---------------------------------------------------------\n const stipple = makeFbm(stream(seed, 1), [8, 16, 32]);\n const flow = makeFbm(stream(seed, 2), [4, 8]);\n const plasticNormal = heightToNormalTexture(detail.plasticNormal, 0.013, (u, v) => {\n // Mould stipple, plus the faint horizontal flow banding an injection tool\n // leaves behind as the melt front advances up the core.\n return stipple(u * 3, v * 3) * 0.6 + flow(u, v) * 0.24 + Math.sin(v * 37.7 + flow(u, v) * 5) * 0.06;\n });\n plasticNormal.repeat.set(11, 11);\n\n const wear = makeFbm(stream(seed, 3), [4, 8, 16]);\n const nicks = makeFbm(stream(seed, 4), [32]);\n const plasticRough = scalarTexture(detail.plasticRough, (u, v) => {\n const base = 0.86 - wear(u, v) * 0.22;\n // Polished nicks: where a cone has been kicked, the matte skin burnishes.\n return base - Math.max(0, nicks(u, v) - 0.72) * 0.53;\n });\n plasticRough.repeat.set(7, 7);\n\n const beads = makeFbm(stream(seed, 5), [16, 32]);\n const sleeveNormal = heightToNormalTexture(detail.sleeveNormal, 0.021, (u, v) => {\n // A corner-cube prism lattice: two crossed triangle ramps, minimum-blended\n // into pyramids, with a glass-bead speckle riding on top.\n const cells = 8;\n return Math.min(triangleWave(u * cells), triangleWave(v * cells)) * 0.7 + beads(u * 2, v * 2) * 0.3;\n });\n sleeveNormal.repeat.set(25, 25);\n\n const sparkle = makeFbm(stream(seed, 6), [32, 64]);\n const sleeveRough = scalarTexture(detail.sleeveRough, (u, v) => 0.95 - Math.max(0, sparkle(u, v) - 0.48) * 1.5);\n sleeveRough.repeat.set(25, 25);\n\n // --- materials --------------------------------------------------------\n // MeshStandardMaterial, not Physical: a cone is a prop a game places by the\n // dozen, and the clearcoat lobe that would buy a little extra sheen is not\n // worth the shader on every one of them. The sheen comes from the roughness\n // map instead.\n const plastic = new THREE.MeshStandardMaterial({\n name: 'traffic-cone-plastic',\n color: new THREE.Color(0xf1450f), // fluorescent PVC orange, faded a touch by the sun\n roughness: 0.82, // the map only ever multiplies this down\n metalness: 0,\n normalMap: plasticNormal,\n normalScale: new THREE.Vector2(0.35, 0.35),\n roughnessMap: plasticRough,\n vertexColors: true,\n wireframe,\n });\n\n const sleeve = new THREE.MeshStandardMaterial({\n name: 'traffic-cone-collar',\n color: new THREE.Color(0xe7edf1),\n roughness: 0.46,\n metalness: 0.08,\n normalMap: sleeveNormal,\n normalScale: new THREE.Vector2(0.55, 0.55),\n roughnessMap: sleeveRough,\n // Sheeting throws light back at whoever lit it. A game's lights cannot know\n // that, so a whisper of emissive keeps the bands readable in a dim scene\n // without making them glow in daylight.\n emissive: new THREE.Color(0x20262e),\n emissiveIntensity: 0.8,\n vertexColors: true,\n wireframe,\n });\n\n // --- meshes -----------------------------------------------------------\n const grime = stream(seed, 7);\n const shell = loftRings(shellRings(detail.segments), true);\n paintGrime(shell.geometry, shell.heights, 0.015, 0.16, 0.74, grime);\n\n const skirt = loftRings(skirtRings(detail.corners), true);\n paintGrime(skirt.geometry, skirt.heights, 0, 0.026, 0.6, grime);\n\n const root = new THREE.Group();\n root.name = 'traffic-cone';\n\n const geometries: THREE.BufferGeometry[] = [shell.geometry, skirt.geometry];\n const meshes: THREE.Mesh[] = [];\n const nodes: Record<string, THREE.Object3D> = {};\n\n const add = (name: string, geometry: THREE.BufferGeometry, material: THREE.Material) => {\n const mesh = new THREE.Mesh(geometry, material);\n mesh.name = name;\n mesh.castShadow = castShadow;\n mesh.receiveShadow = receiveShadow;\n root.add(mesh);\n meshes.push(mesh);\n nodes[name] = mesh;\n return mesh;\n };\n\n add('base', skirt.geometry, plastic);\n add('shell', shell.geometry, plastic);\n\n const sleeveNames = ['collarLower', 'collarUpper'];\n for (let i = 0; i < SLEEVES.length; i++) {\n const band = loftRings(sleeveRings(SLEEVES[i][0], SLEEVES[i][1], detail.segments), false);\n paintGrime(band.geometry, band.heights, SLEEVES[i][0], SLEEVES[i][0] + 0.03, 0.9, grime);\n geometries.push(band.geometry);\n add(sleeveNames[i], band.geometry, sleeve);\n }\n\n const materials: THREE.Material[] = [plastic, sleeve];\n const textures: THREE.Texture[] = [plasticNormal, plasticRough, sleeveNormal, sleeveRough];\n\n root.userData.assetRuntime = { nodes, meshes, materials, geometries };\n root.userData.assetInfo = {\n slug: 'traffic-cone',\n name: 'Traffic Cone',\n sizeMeters: [FOOTPRINT, HEIGHT, FOOTPRINT],\n units: 'meters',\n license: 'MIT',\n };\n root.userData.dispose = () => {\n for (const g of geometries) g.dispose();\n for (const t of textures) t.dispose();\n for (const m of materials) m.dispose();\n };\n\n return root;\n}\n"
39
+ },
40
+ "usage": {
41
+ "snippet": "import { createTrafficConeModel } from './trafficCone';\n\nconst trafficCone = createTrafficConeModel();\ntrafficCone.position.set(2, 0, -3);\nscene.add(trafficCone);",
42
+ "promptHint": "A 0.70 m road cone, 0.36 m square at the base. The origin is the centre of the footprint at ground level, so cone.position.set(x, groundY, z) drops it flat on your floor with no offset maths and no rotation - it is already Y-up. It builds four meshes and two materials; clone the whole Group to place more, which shares both geometry and materials already. Call root.userData.dispose() when the last one goes away - it owns four procedural textures. Options: seed (changes the scuff and grime pattern), detail ('low' 1,056 tris / 'standard' 1,528 / 'high' 2,472), castShadow, receiveShadow, wireframe."
43
+ },
44
+ "geometry": {
45
+ "triangles": 1528,
46
+ "vertices": 1110,
47
+ "meshes": 4,
48
+ "instancedMeshes": 0,
49
+ "drawCalls": 4,
50
+ "materials": 2,
51
+ "textures": 4,
52
+ "textureBytes": 409600
53
+ },
54
+ "space": {
55
+ "units": "meters",
56
+ "up": "+Y",
57
+ "forward": "+Z",
58
+ "originRule": "base-centre",
59
+ "boundingBox": {
60
+ "min": [
61
+ -0.18000000715255737,
62
+ 0,
63
+ -0.18000000715255737
64
+ ],
65
+ "max": [
66
+ 0.18000000715255737,
67
+ 0.699999988079071,
68
+ 0.18000000715255737
69
+ ]
70
+ },
71
+ "sizeMeters": [
72
+ 0.36000001430511475,
73
+ 0.699999988079071,
74
+ 0.36000001430511475
75
+ ],
76
+ "statedSizeMeters": [
77
+ 0.36,
78
+ 0.7,
79
+ 0.36
80
+ ],
81
+ "sizeDeltaPct": [
82
+ 0,
83
+ 0,
84
+ 0
85
+ ]
86
+ },
87
+ "gates": {
88
+ "runAt": "2026-08-10T11:50:33.386Z",
89
+ "runner": "asset-gates@1",
90
+ "allPassed": true,
91
+ "results": [
92
+ {
93
+ "id": "G13",
94
+ "name": "shared-viewer-parity",
95
+ "threshold": "sha256 of the shared viewer files equals the template's record",
96
+ "measured": "in sync",
97
+ "passed": true
98
+ },
99
+ {
100
+ "id": "G11",
101
+ "name": "self-containment",
102
+ "threshold": "imports === [\"three\"]; no document/window/fetch/TextureLoader/CanvasTexture/three-examples/http",
103
+ "measured": "three only",
104
+ "passed": true
105
+ },
106
+ {
107
+ "id": "G1",
108
+ "name": "triangle-band",
109
+ "threshold": "800..2000",
110
+ "measured": 1528,
111
+ "passed": true
112
+ },
113
+ {
114
+ "id": "G2",
115
+ "name": "draw-calls",
116
+ "threshold": "<= 8",
117
+ "measured": 4,
118
+ "passed": true
119
+ },
120
+ {
121
+ "id": "G3",
122
+ "name": "material-count",
123
+ "threshold": "<= 4",
124
+ "measured": 2,
125
+ "passed": true
126
+ },
127
+ {
128
+ "id": "G4",
129
+ "name": "stated-dims",
130
+ "threshold": "every axis within ±5% of asset.config.json",
131
+ "measured": "0% / 0% / 0%",
132
+ "passed": true
133
+ },
134
+ {
135
+ "id": "G5",
136
+ "name": "origin-base-centre",
137
+ "threshold": "|bbox.min.y| <= 1 mm; |centre.x|, |centre.z| <= 10 mm",
138
+ "measured": "min.y 0 mm · centre 0 / 0 mm",
139
+ "passed": true
140
+ },
141
+ {
142
+ "id": "G6",
143
+ "name": "numeric-sanity",
144
+ "threshold": "no NaN/Infinity in position/normal/uv; every boundingSphere.radius finite and > 0",
145
+ "measured": "clean",
146
+ "passed": true
147
+ },
148
+ {
149
+ "id": "G7",
150
+ "name": "console-clean",
151
+ "threshold": "zero console errors/warnings across load + 8-view render",
152
+ "measured": "clean",
153
+ "passed": true
154
+ },
155
+ {
156
+ "id": "G9",
157
+ "name": "multi-angle-coverage",
158
+ "threshold": "every view >= 15% of the widest view",
159
+ "measured": "min 99.3% over 8 views",
160
+ "passed": true
161
+ },
162
+ {
163
+ "id": "G16",
164
+ "name": "part-visibility",
165
+ "threshold": "every named part lights >= 16 px when viewed alone",
166
+ "measured": "4 part(s), dimmest 15490 px",
167
+ "passed": true
168
+ },
169
+ {
170
+ "id": "G17",
171
+ "name": "winding-vs-normals",
172
+ "threshold": "every mesh has <= 1% of triangles wound against their normal",
173
+ "measured": "4 mesh(es), worst 0%",
174
+ "passed": true
175
+ },
176
+ {
177
+ "id": "G10",
178
+ "name": "determinism",
179
+ "threshold": "stable source sha256 + identical vertex hash across two fresh evaluations, no bare Math.random()",
180
+ "measured": "vertexHash d624be0cd42afc00",
181
+ "passed": true
182
+ },
183
+ {
184
+ "id": "G12",
185
+ "name": "disposal",
186
+ "threshold": "renderer.info.memory returns to baseline after root.userData.dispose()",
187
+ "measured": "geometries 4 → 8 → 4; textures 5 → 9 → 5",
188
+ "passed": true
189
+ },
190
+ {
191
+ "id": "G15",
192
+ "name": "texture-budget",
193
+ "threshold": "<= 4096 KB total, no dimension > 1024",
194
+ "measured": "400 KB across 4 texture(s), max dimension 256",
195
+ "passed": true
196
+ },
197
+ {
198
+ "id": "G8",
199
+ "name": "paste-and-run",
200
+ "threshold": "scratch project with only three: tsc --noEmit --strict passes and the render lights >= 1 pixel",
201
+ "measured": "11816 non-background px, 0 console errors",
202
+ "passed": true
203
+ },
204
+ {
205
+ "id": "G14",
206
+ "name": "manifest-validity",
207
+ "threshold": "parses against the manifest schema, <= 256 KB, source.code non-empty",
208
+ "measured": "34.5 KB",
209
+ "passed": true
210
+ }
211
+ ]
212
+ },
213
+ "preview": {
214
+ "playUrl": "https://preview--traffic-cone.genex.technology/",
215
+ "views": [
216
+ "front",
217
+ "three-quarter",
218
+ "side",
219
+ "top"
220
+ ]
221
+ }
222
+ }
@@ -0,0 +1,200 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <meta name="color-scheme" content="dark" />
7
+ <title>Genex asset viewer</title>
8
+ <style>
9
+ /* The HUD is viewer chrome, not a game HUD: pure CSS, no sprites. */
10
+
11
+ /* The platform's own faces, self-hosted next to the bundle - the same two
12
+ files apps/web serves. No CDN and no Google Fonts: a game origin that
13
+ has to reach a third party for its typeface renders in the fallback for
14
+ the first second on every cold load, and renders in the fallback
15
+ forever the day that host is blocked. */
16
+ @font-face {
17
+ font-family: "Geist";
18
+ src: url("/fonts/Geist-variable.woff2") format("woff2");
19
+ font-weight: 100 900;
20
+ font-style: normal;
21
+ font-display: block;
22
+ }
23
+ @font-face {
24
+ font-family: "Geist Mono";
25
+ src: url("/fonts/GeistMono-variable.woff2") format("woff2");
26
+ font-weight: 100 900;
27
+ font-style: normal;
28
+ font-display: block;
29
+ }
30
+
31
+ :root {
32
+ /* The backdrop's CORNER colour, byte-identical to CORNER in
33
+ src/viewer/stage.js - the canvas does not reach under a notched
34
+ phone's safe-area inset, and two darks that are close but not equal
35
+ show up there as a seam. */
36
+ --bg: #16181d;
37
+ --ink: #f2f5f9;
38
+ --ink-dim: #9aa4b3;
39
+ --accent: #8fb6ff;
40
+ --sans: "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif;
41
+ --mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
42
+ /* Nothing sits on a plate any more, so every line is read directly off
43
+ the render. One soft drop shadow is what keeps it legible when the
44
+ asset behind it happens to be pale. */
45
+ --lift: 0 1px 2px rgba(0, 0, 0, 0.55), 0 2px 18px rgba(0, 0, 0, 0.45);
46
+ --pad: max(22px, env(safe-area-inset-left, 0px));
47
+ }
48
+ * {
49
+ box-sizing: border-box;
50
+ }
51
+ html,
52
+ body {
53
+ margin: 0;
54
+ height: 100%;
55
+ overflow: hidden;
56
+ background: var(--bg);
57
+ color: var(--ink);
58
+ font: 400 13px/1.45 var(--sans);
59
+ -webkit-font-smoothing: antialiased;
60
+ -moz-osx-font-smoothing: grayscale;
61
+ }
62
+ #stage {
63
+ position: fixed;
64
+ inset: 0;
65
+ touch-action: none;
66
+ }
67
+ /* The renderer calls setSize(w, h, false) so it owns the drawing buffer and
68
+ CSS owns layout. Without these two rules the canvas falls back to its
69
+ attribute size - the buffer resolution - and overflows the viewport by
70
+ exactly the device-pixel-ratio cap. */
71
+ #stage canvas {
72
+ display: block;
73
+ width: 100%;
74
+ height: 100%;
75
+ }
76
+ #hud {
77
+ position: fixed;
78
+ top: max(20px, env(safe-area-inset-top, 0px));
79
+ left: var(--pad);
80
+ /* Wide enough for the widest first chip row (a big asset's dimensions
81
+ plus its triangle count) and no wider - the second row is forced, so
82
+ there is nothing to gain from more. */
83
+ max-width: min(480px, calc(100vw - 2 * var(--pad)));
84
+ pointer-events: none;
85
+ }
86
+ #hud-name {
87
+ margin: 0;
88
+ font-family: var(--sans);
89
+ font-size: clamp(21px, 2.5vw, 31px);
90
+ font-weight: 500;
91
+ line-height: 1.12;
92
+ letter-spacing: -0.025em;
93
+ text-shadow: var(--lift);
94
+ }
95
+ #hud-summary {
96
+ margin-top: 7px;
97
+ max-width: 34ch;
98
+ color: var(--ink-dim);
99
+ font-size: 13.5px;
100
+ line-height: 1.45;
101
+ text-shadow: var(--lift);
102
+ }
103
+ /* Tags, not a sentence. The dot-separated run this replaces read fine on
104
+ one line and wrapped badly on every other width: the separators made
105
+ the whole row one long inline flow, so a break could land between a
106
+ label and its number, or leave `MIT` alone on a line of its own. A chip
107
+ is an atomic box - it wraps as a unit or not at all - and the tinted
108
+ glass gives the numbers their own surface without putting a plate
109
+ behind the title. */
110
+ #hud-rows {
111
+ margin-top: 13px;
112
+ display: flex;
113
+ flex-wrap: wrap;
114
+ /* Row gap is HALF the column gap on purpose. The zero-height break is a
115
+ flex line of its own, so the row gap lands twice between the two
116
+ visible rows - once above the break and once below it. 3px here is
117
+ the 6px the two rows actually sit apart. */
118
+ gap: 3px 6px;
119
+ }
120
+ /* Forces the row after it onto a new line. See lineBreak() in hud.js. */
121
+ .chip-break {
122
+ flex-basis: 100%;
123
+ height: 0;
124
+ }
125
+ .chip {
126
+ padding: 3px 9px;
127
+ border-radius: 999px;
128
+ background: rgba(232, 236, 242, 0.09);
129
+ backdrop-filter: blur(7px);
130
+ -webkit-backdrop-filter: blur(7px);
131
+ color: var(--ink-dim);
132
+ font: 400 11px/1.5 var(--mono);
133
+ letter-spacing: 0.01em;
134
+ white-space: nowrap;
135
+ }
136
+ .chip strong {
137
+ color: var(--ink);
138
+ font-weight: 500;
139
+ }
140
+ .chip--license {
141
+ background: rgba(143, 182, 255, 0.14);
142
+ color: var(--accent);
143
+ letter-spacing: 0.06em;
144
+ }
145
+ /* Centred, not left-aligned: the keys apply to the model, so they belong
146
+ under it rather than tucked into a corner the eye reads as chrome. */
147
+ #hint {
148
+ position: fixed;
149
+ left: 50%;
150
+ transform: translateX(-50%);
151
+ bottom: max(16px, env(safe-area-inset-bottom, 0px));
152
+ /* No text-wrap: balance. Balance equalises line lengths, which on a
153
+ phone folded six key hints into a narrow four-line column with the
154
+ full width of the screen sitting empty on both sides. Plain wrapping
155
+ uses the room it has. */
156
+ width: calc(100vw - 2 * var(--pad));
157
+ max-width: 760px;
158
+ text-align: center;
159
+ color: var(--ink-dim);
160
+ font: 400 11px/1.6 var(--mono);
161
+ letter-spacing: 0.01em;
162
+ text-shadow: var(--lift);
163
+ pointer-events: none;
164
+ }
165
+ #hint b {
166
+ color: var(--ink);
167
+ font-weight: 500;
168
+ }
169
+ #fault {
170
+ position: fixed;
171
+ inset: 0;
172
+ display: none;
173
+ place-content: center;
174
+ padding: 32px;
175
+ text-align: center;
176
+ color: #ffb4b4;
177
+ font: 400 13px/1.6 var(--mono);
178
+ white-space: pre-wrap;
179
+ }
180
+ @media (max-width: 560px) {
181
+ #hud-summary {
182
+ display: none;
183
+ }
184
+ }
185
+ </style>
186
+ </head>
187
+ <body>
188
+ <div id="stage"></div>
189
+ <div id="hud">
190
+ <h1 id="hud-name"> - </h1>
191
+ <div id="hud-summary"></div>
192
+ <div id="hud-rows"></div>
193
+ </div>
194
+ <div id="hint">
195
+ drag to orbit · scroll to zoom · <b>W</b> wireframe · <b>E</b> explode · <b>space</b> turntable · <b>R</b> reset
196
+ </div>
197
+ <div id="fault" role="alert"></div>
198
+ <script type="module" src="./src/main.js"></script>
199
+ </body>
200
+ </html>
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "{{slug}}",
3
+ "version": "{{version}}",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "{{name}} - a free MIT procedural Three.js asset, and the viewer that proves it.",
7
+ "scripts": {
8
+ "dev": "vite",
9
+ "build": "vite build",
10
+ "gates": "node tools/gates.mjs",
11
+ "stamp": "node tools/stamp-manifest.mjs",
12
+ "verify": "npm run build && npm run gates && npm run stamp",
13
+ "browser": "playwright-core install chromium"
14
+ },
15
+ "dependencies": {
16
+ "three": "0.185.1"
17
+ },
18
+ "devDependencies": {
19
+ "@types/three": "0.185.0",
20
+ "playwright-core": "1.51.1",
21
+ "typescript": "^5.8.0",
22
+ "vite": "^8.1.1"
23
+ }
24
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "templateVersion": "1.0.0",
3
+ "files": {
4
+ "index.html": "6c33ab9fa792aa213a59db325227aae8b7086a10820f9d3428941e1ead846024",
5
+ "vite.config.js": "255f13f0e8c202aa7c11d6e67397b9079ed771d4d2b3dd4771ce7fd2ae0d13e7",
6
+ "src/main.js": "ac7e2c4da2f0bac605425b7146580d59eb7f93a029ea6c5bfc7c6e1697fb0f72",
7
+ "src/viewer/stage.js": "dae8f765379d45a9836df901466d7376b94ae1a19107c5dcd611ff80fc09b75c",
8
+ "src/viewer/hud.js": "fe896457c8765357555d93af2d302b80fbb53f303b01ce1e21361a4862b4df60",
9
+ "src/viewer/gates-overlay.js": "95a429326f0bb584b56f835d10fc281c0d119dac68260a83a85fa949bb9b2fa1",
10
+ "tools/emit-manifest.mjs": "8e8590694421ee62300d4b1680f86693d264b08b91de0ea91721ab571983c525",
11
+ "tools/gates.mjs": "bcee3ea4c6ad875428a6a14e8042328fabc33adefeb12a1bd93f13a73836286d",
12
+ "tools/stamp-manifest.mjs": "7d0ef02d20e258208b10533f3818f163cc40e62c6172c8f1d989e51674c1407d"
13
+ }
14
+ }