[READ-ONLY] Mirror of https://github.com/flo-bit/shapecraft. flo-bit.dev/shapecraft/
0

Configure Feed

Select the types of activity you want to include in your feed.

commit

+1569 -58
+2
demo/main.ts
··· 17 17 rock, rockSchema, rockPresets, 18 18 sharpRock, sharpRockSchema, sharpRockPresets, 19 19 blockRock, blockRockSchema, blockRockPresets, 20 + mushroom, mushroomSchema, mushroomPresets, 20 21 } from '../src/generators' 21 22 import { createEditor } from './editor/editor' 22 23 import type { OptionSchema } from '../src/core/schema' ··· 44 45 rock: { label: 'Rock', gen: rock, schema: rockSchema, presets: rockPresets, sizeKey: 'size', defaultSize: 0.6 }, 45 46 sharp: { label: 'Sharp', gen: sharpRock, schema: sharpRockSchema, presets: sharpRockPresets, sizeKey: 'size', defaultSize: 1.0 }, 46 47 block: { label: 'Blocks', gen: blockRock, schema: blockRockSchema, presets: blockRockPresets, sizeKey: 'size', defaultSize: 0.7 }, 48 + mushroom: { label: 'Mushroom', gen: mushroom, schema: mushroomSchema, presets: mushroomPresets, sizeKey: 'height', defaultSize: 0.35 }, 47 49 } 48 50 49 51 // --- Renderer ---
+1
src/generators/vegetation/fungi/index.ts
··· 1 + export { mushroom, mushroomSchema, mushroomPresets, type MushroomOptions } from './mushroom'
+154
src/generators/vegetation/fungi/mushroom.ts
··· 1 + import { sphere } from '../../../primitives' 2 + import { merge } from '../../../ops' 3 + import { setup, trunk, foliageBlob, facetShade, scatterOnSurface } from '../../../build' 4 + import { pickRandom } from '../../../color' 5 + import { UberNoise } from '../../../noise' 6 + import type { Mesh } from '../../../core/mesh' 7 + import type { OptionSchema, OptionInput } from '../../../core/schema' 8 + 9 + export const mushroomSchema = { 10 + seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, 11 + height: { type: 'range', default: 0.35, min: 0.1, max: 1.2, step: 0.05, label: 'Stem Height' }, 12 + stemRadius: { type: 'range', default: 0.045, min: 0.015, max: 0.15, step: 0.005, label: 'Stem Radius' }, 13 + lean: { type: 'range', default: 0.1, min: 0, max: 0.4, step: 0.02, label: 'Lean' }, 14 + capRadius: { type: 'range', default: 0.16, min: 0.05, max: 0.5, step: 0.01, label: 'Cap Radius' }, 15 + capSquash: { type: 'range', default: 0.6, min: 0.25, max: 1.2, step: 0.05, label: 'Cap Squash' }, 16 + capCurl: { type: 'range', default: 0.35, min: 0, max: 0.8, step: 0.05, label: 'Cap Curl' }, 17 + capNoise: { type: 'range', default: 0.12, min: 0, max: 0.5, step: 0.02, label: 'Cap Lumpiness' }, 18 + count: { type: 'integer', default: 1, min: 1, max: 9, label: 'Cluster' }, 19 + spread: { type: 'range', default: 1.0, min: 0.4, max: 2, step: 0.05, label: 'Spread' }, 20 + spots: { type: 'integer', default: 0, min: 0, max: 20, label: 'Spots' }, 21 + spotColor: { type: 'color', default: '#f2efe6', label: 'Spot Color' }, 22 + capColors: { type: 'color-array', default: ['#9c6b3f', '#a87844', '#8a5a32'], min: 1, max: 6, label: 'Cap Colors' }, 23 + gillColor: { type: 'color', default: '#e8dcc2', label: 'Gill Color' }, 24 + stemColors: { type: 'color-array', default: ['#ddd2bc', '#cfc4ae'], min: 1, max: 4, label: 'Stem Colors' }, 25 + } satisfies OptionSchema 26 + 27 + export type MushroomOptions = Partial<OptionInput<typeof mushroomSchema>> & { preset?: string } 28 + 29 + export const mushroomPresets: Record<string, Partial<MushroomOptions>> = { 30 + default: {}, 31 + toadstool: { 32 + capColors: ['#c43a2e', '#b33327', '#cf4334'], 33 + spots: 9, 34 + capSquash: 0.7, 35 + stemColors: ['#efe9da', '#e2dccb'], 36 + gillColor: '#efe7d2', 37 + }, 38 + chanterelle: { 39 + capColors: ['#e0a83c', '#d49a32', '#e8b54a'], 40 + capSquash: 0.3, 41 + capCurl: 0.1, 42 + gillColor: '#dba939', 43 + stemColors: ['#e3b34e', '#d6a843'], 44 + }, 45 + cluster: { 46 + count: 6, 47 + height: 0.28, 48 + capRadius: 0.1, 49 + capSquash: 0.75, 50 + capColors: ['#b08d57', '#a3814e', '#bd9a62'], 51 + }, 52 + inkcap: { 53 + height: 0.55, 54 + stemRadius: 0.03, 55 + capRadius: 0.11, 56 + capSquash: 1.1, 57 + capCurl: 0.65, 58 + capColors: ['#8d8678', '#999285', '#7e776b'], 59 + gillColor: '#55504a', 60 + stemColors: ['#e6e2d8', '#d8d4ca'], 61 + }, 62 + } 63 + 64 + export function mushroom(options: MushroomOptions = {}): Mesh { 65 + const { o, rng } = setup(mushroomSchema, options, mushroomPresets) 66 + const shapeRng = rng.stream('shape') 67 + const colorRng = rng.stream('color') 68 + const spotRng = rng.stream('spot') 69 + 70 + const colorNoise = new UberNoise({ seed: colorRng.seed(), scale: 4 }) 71 + const gill = pickRandom(o.gillColor, colorRng) 72 + 73 + const parts: Mesh[] = [] 74 + let anchorR = 0 75 + for (let i = 0; i < o.count; i++) { 76 + // Tallest mushroom at the center, smaller ones huddled around it. 77 + const rank = i / Math.max(1, o.count - 1) 78 + const scale = (1 - 0.4 * rank) * shapeRng.float(0.85, 1.15) 79 + const height = o.height * scale 80 + const capR = o.capRadius * scale * shapeRng.float(0.9, 1.1) 81 + const stemR = o.stemRadius * scale 82 + 83 + if (i === 0) anchorR = capR 84 + const placeAngle = ((i - 1) / Math.max(1, o.count - 1)) * Math.PI * 2 + shapeRng.float(-0.5, 0.5) 85 + const dist = i === 0 ? 0 : (anchorR + capR) * o.spread * shapeRng.float(0.9, 1.1) 86 + const px = Math.cos(placeAngle) * dist 87 + const pz = Math.sin(placeAngle) * dist 88 + 89 + const leanAngle = shapeRng.float(0, Math.PI * 2) 90 + const leanX = Math.cos(leanAngle) * o.lean * height 91 + const leanZ = Math.sin(leanAngle) * o.lean * height 92 + 93 + parts.push(trunk({ 94 + height, 95 + baseRadius: stemR * 1.25, 96 + topRadius: stemR, 97 + taper: 2, 98 + lean: [leanX, leanZ], 99 + noiseSeed: shapeRng.seed(), 100 + noiseScale: 5, 101 + noiseAmount: 0.12, 102 + segments: 6, 103 + heightSegments: 3, 104 + colors: o.stemColors, 105 + }).translate(px, 0, pz)) 106 + 107 + // Cap: a squashed blob with everything below the curl plane clamped flat — 108 + // the dome reads as the cap, the flat clamp as the gilled underside. 109 + const sy = capR * o.capSquash 110 + const cutY = -sy * o.capCurl 111 + const capBase = pickRandom(o.capColors, colorRng) 112 + const capShade = facetShade({ base: capBase, noise: colorNoise, ambient: 0.55, range: 0.45, noiseAmount: 0.1 }) 113 + const gillShade = facetShade({ base: gill, ambient: 0.7, range: 0.3 }) 114 + 115 + const cap = foliageBlob({ 116 + radius: capR, 117 + detail: capR * 0.45, 118 + noiseSeed: shapeRng.seed(), 119 + noiseScale: 1.2, 120 + noiseOctaves: 2, 121 + noiseAmount: o.capNoise, 122 + jitter: capR * 0.02, 123 + jitterSeed: shapeRng.seed(), 124 + }) 125 + .scale(1, o.capSquash, 1) 126 + .warp((p) => (p[1] < cutY ? [p[0], cutY, p[2]] : p)) 127 + .translate(px + leanX, height - cutY - capR * 0.06, pz + leanZ) 128 + .faceColor((c, n) => (n[1] < -0.35 ? gillShade(c, n) : capShade(c, n))) 129 + parts.push(cap) 130 + 131 + // Optional fly-agaric spots: small flattened flecks sitting on the cap dome. 132 + if (o.spots > 0) { 133 + const points = scatterOnSurface(cap, o.spots, { rng: spotRng, minNormalY: 0.35 }) 134 + for (const { position, normal } of points) { 135 + const s = capR * 0.14 * (0.7 + spotRng() * 0.6) 136 + let spot = sphere({ radius: s, widthSegments: 5, heightSegments: 3 }).scale(1, 0.45, 1) 137 + // Tilt the flattened fleck so it lies flush with the cap surface. 138 + const tilt = Math.acos(Math.max(-1, Math.min(1, normal[1]))) 139 + if (tilt > 1e-3) spot = spot.rotate([normal[2], 0, -normal[0]], tilt) 140 + parts.push( 141 + spot 142 + .translate( 143 + position[0] + normal[0] * s * 0.15, 144 + position[1] + normal[1] * s * 0.15, 145 + position[2] + normal[2] * s * 0.15, 146 + ) 147 + .vertexColor(o.spotColor), 148 + ) 149 + } 150 + } 151 + } 152 + 153 + return merge(...parts) 154 + }
+1
src/generators/vegetation/index.ts
··· 1 1 export * from './trees' 2 2 export * from './shrubs' 3 3 export * from './plants' 4 + export * from './fungi'
+5 -2
src/generators/vegetation/trees/leafy-tree.ts
··· 1 - import { merge, snow as applySnow } from '../../../ops' 1 + import { merge, snow as applySnow, decimate } from '../../../ops' 2 2 import { setup, branches, foliageBlob, facetShade, heightShade, metaballs, type MetaBall } from '../../../build' 3 3 import { pickRandom } from '../../../color' 4 4 import { UberNoise } from '../../../noise' ··· 17 17 taper: { type: 'range', default: 0.5, min: 0.3, max: 1.2, step: 0.05, label: 'Taper' }, 18 18 leafSize: { type: 'range', default: 0.55, min: 0.2, max: 1.2, step: 0.05, label: 'Leaf Cluster Size' }, 19 19 blobCanopy: { type: 'boolean', default: false, label: 'Merge Canopy' }, 20 + blobDetail: { type: 'range', default: 0.4, min: 0.1, max: 1, step: 0.05, label: 'Canopy Detail' }, 20 21 canopyNoise: { type: 'range', default: 0.5, min: 0, max: 1.2, step: 0.05, label: 'Canopy Noise' }, 21 22 jitter: { type: 'range', default: 0.05, min: 0, max: 0.15, step: 0.005, label: 'Jitter' }, 22 23 snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors' }, ··· 91 92 center: tip.position, 92 93 radius: o.leafSize * (0.7 + leafRng() * 0.6), 93 94 })) 95 + let canopy = metaballs(balls, { resolution: 28, isolevel: 0.5, support: 2.0 }) 96 + if (o.blobDetail < 1) canopy = decimate(canopy, { ratio: o.blobDetail }) 94 97 parts.push( 95 - metaballs(balls, { resolution: 26, isolevel: 0.5, support: 2.0 }) 98 + canopy 96 99 .jitter(o.leafSize * o.jitter, { seed: leafRng.seed() }) 97 100 .faceColor(shadeFor(pickRandom(o.leafColors, colorRng))), 98 101 )
+3 -2
src/index.ts
··· 6 6 export { box, sphere, cylinder, plane, cone, torus, icosphere } from './primitives' 7 7 8 8 // Operations 9 - export { merge, center, clone, loft, tube, thicken, snow } from './ops' 10 - export type { SnowOptions } from './ops' 9 + export { merge, center, clone, loft, tube, thicken, snow, decimate } from './ops' 10 + export type { SnowOptions, DecimateOptions } from './ops' 11 11 12 12 // Modifiers 13 13 export { twist, bend, taper } from './modifiers' ··· 46 46 export { rock, rockSchema, rockPresets } from './generators' 47 47 export { sharpRock, sharpRockSchema, sharpRockPresets } from './generators' 48 48 export { blockRock, blockRockSchema, blockRockPresets } from './generators' 49 + export { mushroom, mushroomSchema, mushroomPresets } from './generators'
+3 -3
src/noise/uber-noise.ts
··· 2 2 type NoiseDerivFunction2D, 3 3 type NoiseDerivFunction3D, 4 4 type NoiseDerivFunction4D, 5 - NoiseFunction2D, 6 - NoiseFunction3D, 7 - NoiseFunction4D, 5 + type NoiseFunction2D, 6 + type NoiseFunction3D, 7 + type NoiseFunction4D, 8 8 createNoise2DWithDerivatives, 9 9 createNoise3DWithDerivatives, 10 10 createNoise4DWithDerivatives,
+39
src/ops/decimate.ts
··· 1 + import * as THREE from 'three' 2 + import { SimplifyModifier } from 'three/examples/jsm/modifiers/SimplifyModifier.js' 3 + import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js' 4 + import { Mesh } from '../core/mesh' 5 + 6 + export interface DecimateOptions { 7 + /** Fraction of vertices to KEEP (0–1). Default 0.5. Ignored if `count` is given. */ 8 + ratio?: number 9 + /** Explicit target vertex count. */ 10 + count?: number 11 + } 12 + 13 + /** 14 + * Reduce a mesh's triangle count via quadric-error edge collapse (Three's 15 + * SimplifyModifier), preserving the silhouette. Returns position+normal geometry only — 16 + * baked vertex colors/UVs are dropped, so re-apply procedural coloring afterwards. Ideal 17 + * for thinning dense isosurfaces (metaballs/surface nets) and for building LODs. 18 + */ 19 + export function decimate(mesh: Mesh, options: DecimateOptions = {}): Mesh { 20 + const src = mesh.geometry 21 + const srcPos = src.getAttribute('position') 22 + if (!srcPos) return mesh.clone() 23 + 24 + // Weld by position only — flat (per-face) normals would otherwise block welding and 25 + // leave the simplifier with no shared edges to collapse. 26 + const posGeo = new THREE.BufferGeometry() 27 + posGeo.setAttribute('position', new THREE.BufferAttribute((srcPos.array as Float32Array).slice(), 3)) 28 + const index = src.getIndex() 29 + if (index) posGeo.setIndex(new THREE.BufferAttribute((index.array as Uint32Array | Uint16Array).slice(), 1)) 30 + const welded = mergeVertices(posGeo) 31 + 32 + const vcount = welded.getAttribute('position').count 33 + const target = Math.max(4, Math.min(vcount, options.count ?? Math.floor(vcount * (options.ratio ?? 0.5)))) 34 + const toRemove = vcount - target 35 + 36 + const out = toRemove > 0 ? new SimplifyModifier().modify(welded, toRemove) : welded 37 + out.computeVertexNormals() 38 + return new Mesh(out) 39 + }
+1
src/ops/index.ts
··· 4 4 export { loft, tube } from './loft' 5 5 export { thicken } from './thicken' 6 6 export { snow, type SnowOptions } from './snow' 7 + export { decimate, type DecimateOptions } from './decimate'
+3 -1
tests/generators.test.ts
··· 1 1 import { describe, it, expect } from 'vitest' 2 - import { tree, pine, palm, bush, grass, fern, flower, deadTree, leafyTree, rock, sharpRock, blockRock } from '../src/generators' 2 + import { tree, pine, palm, bush, grass, fern, flower, deadTree, leafyTree, rock, sharpRock, blockRock, mushroom } from '../src/generators' 3 3 import type { Mesh } from '../src/core/mesh' 4 4 5 5 const generators = [ ··· 15 15 { name: 'rock', gen: rock }, 16 16 { name: 'sharp', gen: sharpRock }, 17 17 { name: 'block', gen: blockRock }, 18 + { name: 'mushroom', gen: mushroom }, 18 19 ] as const 19 20 20 21 // Generators that support snow (trees, shrubs, rocks). Grass/ferns don't take snow options. ··· 35 36 rock: { verts: 1440 }, 36 37 sharp: { verts: 696 }, 37 38 block: { verts: 432 }, 39 + mushroom: { verts: 1584 }, 38 40 } 39 41 40 42 function allFinite(m: Mesh): boolean {
+19 -1
tests/ops.test.ts
··· 1 1 import { describe, it, expect } from 'vitest' 2 2 import * as THREE from 'three' 3 3 import { box, sphere } from '../src/primitives' 4 - import { merge, center, clone } from '../src/ops' 4 + import { merge, center, clone, decimate } from '../src/ops' 5 5 6 6 describe('ops', () => { 7 7 it('merge combines vertex counts', () => { ··· 35 35 const c = clone(b) 36 36 expect(c.vertexCount).toBe(b.vertexCount) 37 37 expect(c.geometry).not.toBe(b.geometry) 38 + }) 39 + 40 + it('decimate reduces face count while preserving the silhouette', () => { 41 + const s = sphere({ radius: 1, widthSegments: 32, heightSegments: 24 }) 42 + const before = s.faceCount 43 + const d = decimate(s, { ratio: 0.3 }) 44 + expect(d.faceCount).toBeLessThan(before) 45 + expect(d.vertexCount).toBeGreaterThan(0) 46 + // Bounding box (silhouette) stays close to the original sphere. 47 + const bb = d.boundingBox 48 + expect(bb.max.x).toBeGreaterThan(0.85) 49 + expect(bb.max.x).toBeLessThan(1.15) 50 + }) 51 + 52 + it('decimate hits an explicit target count', () => { 53 + const s = sphere({ radius: 1, widthSegments: 24, heightSegments: 16 }) 54 + const d = decimate(s, { count: 80 }) 55 + expect(d.vertexCount).toBeLessThanOrEqual(120) 38 56 }) 39 57 })
+8
webapp/package.json
··· 18 18 }, 19 19 "devDependencies": { 20 20 "@eslint/js": "^10.0.1", 21 + "@lucide/svelte": "^1.17.0", 21 22 "@playwright/test": "^1.60.0", 22 23 "@sveltejs/adapter-auto": "^7.0.1", 23 24 "@sveltejs/kit": "^2.63.0", ··· 25 26 "@tailwindcss/forms": "^0.5.11", 26 27 "@tailwindcss/typography": "^0.5.19", 27 28 "@tailwindcss/vite": "^4.3.0", 29 + "@threlte/core": "^8.5.16", 30 + "@threlte/extras": "^9.21.0", 28 31 "@types/node": "^22", 32 + "@types/three": "0.170.0", 29 33 "@vitest/browser-playwright": "^4.1.8", 30 34 "eslint": "^10.4.1", 31 35 "eslint-config-prettier": "^10.1.8", ··· 43 47 "vite": "^8.0.16", 44 48 "vitest": "^4.1.8", 45 49 "vitest-browser-svelte": "^2.1.1" 50 + }, 51 + "dependencies": { 52 + "culori": "^4.0.2", 53 + "three": "0.170.0" 46 54 } 47 55 }
+247
webapp/pnpm-lock.yaml
··· 7 7 importers: 8 8 9 9 .: 10 + dependencies: 11 + culori: 12 + specifier: ^4.0.2 13 + version: 4.0.2 14 + three: 15 + specifier: 0.170.0 16 + version: 0.170.0 10 17 devDependencies: 11 18 '@eslint/js': 12 19 specifier: ^10.0.1 13 20 version: 10.0.1(eslint@10.4.1(jiti@2.7.0)) 21 + '@lucide/svelte': 22 + specifier: ^1.17.0 23 + version: 1.17.0(svelte@5.56.3(@typescript-eslint/types@8.61.0)) 14 24 '@playwright/test': 15 25 specifier: ^1.60.0 16 26 version: 1.60.0 ··· 32 42 '@tailwindcss/vite': 33 43 specifier: ^4.3.0 34 44 version: 4.3.0(vite@8.0.16(@types/node@22.19.20)(jiti@2.7.0)) 45 + '@threlte/core': 46 + specifier: ^8.5.16 47 + version: 8.5.16(svelte@5.56.3(@typescript-eslint/types@8.61.0))(three@0.170.0) 48 + '@threlte/extras': 49 + specifier: ^9.21.0 50 + version: 9.21.0(@types/three@0.170.0)(svelte@5.56.3(@typescript-eslint/types@8.61.0))(three@0.170.0) 35 51 '@types/node': 36 52 specifier: ^22 37 53 version: 22.19.20 54 + '@types/three': 55 + specifier: 0.170.0 56 + version: 0.170.0 38 57 '@vitest/browser-playwright': 39 58 specifier: ^4.1.8 40 59 version: 4.1.8(playwright@1.60.0)(vite@8.0.16(@types/node@22.19.20)(jiti@2.7.0))(vitest@4.1.8) ··· 175 194 176 195 '@jridgewell/trace-mapping@0.3.31': 177 196 resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} 197 + 198 + '@lucide/svelte@1.17.0': 199 + resolution: {integrity: sha512-q06YCFBN5CO8cd1ADmLCxWRVMVb7xxvHzqC0lvNoxGa+FLW6Cd1Y1AOxgbQk4Iwe68vkAMCRveNHint4WoaVKg==} 200 + peerDependencies: 201 + svelte: ^5 178 202 179 203 '@napi-rs/wasm-runtime@1.1.4': 180 204 resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} ··· 431 455 peerDependencies: 432 456 svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 433 457 458 + '@threejs-kit/instanced-sprite-mesh@2.5.1': 459 + resolution: {integrity: sha512-pmt1ALRhbHhCJQTj2FuthH6PeLIeaM4hOuS2JO3kWSwlnvx/9xuUkjFR3JOi/myMqsH7pSsLIROSaBxDfttjeA==} 460 + peerDependencies: 461 + three: '>=0.170.0' 462 + 463 + '@threlte/core@8.5.16': 464 + resolution: {integrity: sha512-+aa9Zgz0/Qat82H0hTm1pTEtS2BIP1Nfu7pKaei+3+TvDqER0YnZ4hdgI9fYU9Z5r1lrzY3JxMyrdISp4B0kfw==} 465 + peerDependencies: 466 + svelte: '>=5' 467 + three: '>=0.160' 468 + 469 + '@threlte/extras@9.21.0': 470 + resolution: {integrity: sha512-ZkBx4IH+7vcxVM09Rex9XJNMKKbQt4JZ5J/3TnBd/Fge8IzSgl0ydgn70riDf/8tDLMDID6VdZPPrlr5vCHHJA==} 471 + peerDependencies: 472 + svelte: '>=5' 473 + three: '>=0.160' 474 + 475 + '@tweenjs/tween.js@23.1.3': 476 + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} 477 + 434 478 '@tybys/wasm-util@0.10.2': 435 479 resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} 436 480 ··· 455 499 '@types/node@22.19.20': 456 500 resolution: {integrity: sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==} 457 501 502 + '@types/stats.js@0.17.4': 503 + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} 504 + 505 + '@types/three@0.170.0': 506 + resolution: {integrity: sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==} 507 + 458 508 '@types/trusted-types@2.0.7': 459 509 resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} 510 + 511 + '@types/webxr@0.5.24': 512 + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} 460 513 461 514 '@typescript-eslint/eslint-plugin@8.61.0': 462 515 resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==} ··· 557 610 '@vitest/utils@4.1.8': 558 611 resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} 559 612 613 + '@webgpu/types@0.1.70': 614 + resolution: {integrity: sha512-LFiNHHKMvmAEvwVew3JLJmTdShhbdwRFSImUshGhE2mGE8ybQzIo63l5uRp+YKnNx+8Qno8Kf6gN+DKMreIJCA==} 615 + 560 616 acorn-jsx@5.3.2: 561 617 resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} 562 618 peerDependencies: ··· 586 642 resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} 587 643 engines: {node: 18 || 20 || >=22} 588 644 645 + bidi-js@1.0.3: 646 + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} 647 + 589 648 brace-expansion@5.0.6: 590 649 resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} 591 650 engines: {node: 18 || 20 || >=22} 592 651 652 + camera-controls@3.1.2: 653 + resolution: {integrity: sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==} 654 + engines: {node: '>=22.0.0', npm: '>=10.5.1'} 655 + peerDependencies: 656 + three: '>=0.126.1' 657 + 593 658 chai@6.2.2: 594 659 resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} 595 660 engines: {node: '>=18'} ··· 617 682 resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} 618 683 engines: {node: '>=4'} 619 684 hasBin: true 685 + 686 + culori@4.0.2: 687 + resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==} 688 + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 620 689 621 690 debug@4.4.3: 622 691 resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} ··· 640 709 641 710 devalue@5.8.1: 642 711 resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} 712 + 713 + diet-sprite@0.0.1: 714 + resolution: {integrity: sha512-zSHI2WDAn1wJqJYxcmjWfJv3Iw8oL9reQIbEyx2x2/EZ4/qmUTIo8/5qOCurnAcq61EwtJJaZ0XTy2NRYqpB5A==} 715 + 716 + earcut@2.2.4: 717 + resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} 643 718 644 719 enhanced-resolve@5.23.0: 645 720 resolution: {integrity: sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==} ··· 757 832 peerDependenciesMeta: 758 833 picomatch: 759 834 optional: true 835 + 836 + fflate@0.8.3: 837 + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} 760 838 761 839 file-entry-cache@8.0.0: 762 840 resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} ··· 932 1010 resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} 933 1011 engines: {node: '>=10'} 934 1012 1013 + maath@0.10.8: 1014 + resolution: {integrity: sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==} 1015 + peerDependencies: 1016 + '@types/three': '>=0.134.0' 1017 + three: '>=0.134.0' 1018 + 935 1019 magic-string@0.30.21: 936 1020 resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 1021 + 1022 + meshoptimizer@0.18.1: 1023 + resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==} 937 1024 938 1025 mini-svg-data-uri@1.4.4: 939 1026 resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} ··· 1125 1212 resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} 1126 1213 engines: {node: '>= 14.18.0'} 1127 1214 1215 + require-from-string@2.0.2: 1216 + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} 1217 + engines: {node: '>=0.10.0'} 1218 + 1128 1219 rolldown@1.0.3: 1129 1220 resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} 1130 1221 engines: {node: ^20.19.0 || >=22.12.0} ··· 1195 1286 resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} 1196 1287 engines: {node: '>=6'} 1197 1288 1289 + three-instanced-uniforms-mesh@0.52.4: 1290 + resolution: {integrity: sha512-YwDBy05hfKZQtU+Rp0KyDf9yH4GxfhxMbVt9OYruxdgLfPwmDG5oAbGoW0DrKtKZSM3BfFcCiejiOHCjFBTeng==} 1291 + peerDependencies: 1292 + three: '>=0.125.0' 1293 + 1294 + three-mesh-bvh@0.9.10: 1295 + resolution: {integrity: sha512-UOlTgPIeqUURcwaG8knxvBaruwZlC4X3/WSHEFO7rYvMVv/YNUrkfFEszvfj36pXV88dCHoHNnIp0PifkirnTQ==} 1296 + peerDependencies: 1297 + three: '>= 0.159.0' 1298 + 1299 + three-perf@1.0.11: 1300 + resolution: {integrity: sha512-OgBpZjwL+csQKGKZjpkH/QHdbGFMxqngMbSEJeSnVNfXDYd6On7WHNv/GhUZH4YxIpNMwMahBWrNnsJvnbSJHQ==} 1301 + peerDependencies: 1302 + three: '>=0.170' 1303 + 1304 + three-viewport-gizmo@2.2.0: 1305 + resolution: {integrity: sha512-Jo9Liur1rUmdKk75FZumLU/+hbF+RtJHi1qsKZpntjKlCYScK6tjbYoqvJ9M+IJphrlQJF5oReFW7Sambh0N4Q==} 1306 + peerDependencies: 1307 + three: '>=0.162.0 <1.0.0' 1308 + 1309 + three@0.170.0: 1310 + resolution: {integrity: sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==} 1311 + 1198 1312 tinybench@2.9.0: 1199 1313 resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} 1200 1314 ··· 1214 1328 resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} 1215 1329 engines: {node: '>=6'} 1216 1330 1331 + troika-three-text@0.52.4: 1332 + resolution: {integrity: sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==} 1333 + peerDependencies: 1334 + three: '>=0.125.0' 1335 + 1336 + troika-three-utils@0.52.4: 1337 + resolution: {integrity: sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==} 1338 + peerDependencies: 1339 + three: '>=0.125.0' 1340 + 1341 + troika-worker-utils@0.52.0: 1342 + resolution: {integrity: sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==} 1343 + 1217 1344 ts-api-utils@2.5.0: 1218 1345 resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} 1219 1346 engines: {node: '>=18.12'} ··· 1222 1349 1223 1350 tslib@2.8.1: 1224 1351 resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} 1352 + 1353 + tweakpane@3.1.10: 1354 + resolution: {integrity: sha512-rqwnl/pUa7+inhI2E9ayGTqqP0EPOOn/wVvSWjZsRbZUItzNShny7pzwL3hVlaN4m9t/aZhsP0aFQ9U5VVR2VQ==} 1225 1355 1226 1356 type-check@0.4.0: 1227 1357 resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} ··· 1346 1476 jsdom: 1347 1477 optional: true 1348 1478 1479 + webgl-sdf-generator@1.1.1: 1480 + resolution: {integrity: sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==} 1481 + 1349 1482 which@2.0.2: 1350 1483 resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} 1351 1484 engines: {node: '>= 8'} ··· 1471 1604 dependencies: 1472 1605 '@jridgewell/resolve-uri': 3.1.2 1473 1606 '@jridgewell/sourcemap-codec': 1.5.5 1607 + 1608 + '@lucide/svelte@1.17.0(svelte@5.56.3(@typescript-eslint/types@8.61.0))': 1609 + dependencies: 1610 + svelte: 5.56.3(@typescript-eslint/types@8.61.0) 1474 1611 1475 1612 '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': 1476 1613 dependencies: ··· 1661 1798 dependencies: 1662 1799 svelte: 5.56.3(@typescript-eslint/types@8.61.0) 1663 1800 1801 + '@threejs-kit/instanced-sprite-mesh@2.5.1(@types/three@0.170.0)(three@0.170.0)': 1802 + dependencies: 1803 + diet-sprite: 0.0.1 1804 + earcut: 2.2.4 1805 + maath: 0.10.8(@types/three@0.170.0)(three@0.170.0) 1806 + three: 0.170.0 1807 + three-instanced-uniforms-mesh: 0.52.4(three@0.170.0) 1808 + troika-three-utils: 0.52.4(three@0.170.0) 1809 + transitivePeerDependencies: 1810 + - '@types/three' 1811 + 1812 + '@threlte/core@8.5.16(svelte@5.56.3(@typescript-eslint/types@8.61.0))(three@0.170.0)': 1813 + dependencies: 1814 + svelte: 5.56.3(@typescript-eslint/types@8.61.0) 1815 + three: 0.170.0 1816 + 1817 + '@threlte/extras@9.21.0(@types/three@0.170.0)(svelte@5.56.3(@typescript-eslint/types@8.61.0))(three@0.170.0)': 1818 + dependencies: 1819 + '@threejs-kit/instanced-sprite-mesh': 2.5.1(@types/three@0.170.0)(three@0.170.0) 1820 + camera-controls: 3.1.2(three@0.170.0) 1821 + svelte: 5.56.3(@typescript-eslint/types@8.61.0) 1822 + three: 0.170.0 1823 + three-mesh-bvh: 0.9.10(three@0.170.0) 1824 + three-perf: 1.0.11(three@0.170.0) 1825 + three-viewport-gizmo: 2.2.0(three@0.170.0) 1826 + troika-three-text: 0.52.4(three@0.170.0) 1827 + transitivePeerDependencies: 1828 + - '@types/three' 1829 + 1830 + '@tweenjs/tween.js@23.1.3': {} 1831 + 1664 1832 '@tybys/wasm-util@0.10.2': 1665 1833 dependencies: 1666 1834 tslib: 2.8.1 ··· 1685 1853 dependencies: 1686 1854 undici-types: 6.21.0 1687 1855 1856 + '@types/stats.js@0.17.4': {} 1857 + 1858 + '@types/three@0.170.0': 1859 + dependencies: 1860 + '@tweenjs/tween.js': 23.1.3 1861 + '@types/stats.js': 0.17.4 1862 + '@types/webxr': 0.5.24 1863 + '@webgpu/types': 0.1.70 1864 + fflate: 0.8.3 1865 + meshoptimizer: 0.18.1 1866 + 1688 1867 '@types/trusted-types@2.0.7': {} 1868 + 1869 + '@types/webxr@0.5.24': {} 1689 1870 1690 1871 '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3)': 1691 1872 dependencies: ··· 1849 2030 convert-source-map: 2.0.0 1850 2031 tinyrainbow: 3.1.0 1851 2032 2033 + '@webgpu/types@0.1.70': {} 2034 + 1852 2035 acorn-jsx@5.3.2(acorn@8.16.0): 1853 2036 dependencies: 1854 2037 acorn: 8.16.0 ··· 1870 2053 1871 2054 balanced-match@4.0.4: {} 1872 2055 2056 + bidi-js@1.0.3: 2057 + dependencies: 2058 + require-from-string: 2.0.2 2059 + 1873 2060 brace-expansion@5.0.6: 1874 2061 dependencies: 1875 2062 balanced-match: 4.0.4 1876 2063 2064 + camera-controls@3.1.2(three@0.170.0): 2065 + dependencies: 2066 + three: 0.170.0 2067 + 1877 2068 chai@6.2.2: {} 1878 2069 1879 2070 chokidar@4.0.3: ··· 1894 2085 1895 2086 cssesc@3.0.0: {} 1896 2087 2088 + culori@4.0.2: {} 2089 + 1897 2090 debug@4.4.3: 1898 2091 dependencies: 1899 2092 ms: 2.1.3 ··· 1906 2099 1907 2100 devalue@5.8.1: {} 1908 2101 2102 + diet-sprite@0.0.1: {} 2103 + 2104 + earcut@2.2.4: {} 2105 + 1909 2106 enhanced-resolve@5.23.0: 1910 2107 dependencies: 1911 2108 graceful-fs: 4.2.11 ··· 2040 2237 optionalDependencies: 2041 2238 picomatch: 4.0.4 2042 2239 2240 + fflate@0.8.3: {} 2241 + 2043 2242 file-entry-cache@8.0.0: 2044 2243 dependencies: 2045 2244 flat-cache: 4.0.1 ··· 2168 2367 dependencies: 2169 2368 p-locate: 5.0.0 2170 2369 2370 + maath@0.10.8(@types/three@0.170.0)(three@0.170.0): 2371 + dependencies: 2372 + '@types/three': 0.170.0 2373 + three: 0.170.0 2374 + 2171 2375 magic-string@0.30.21: 2172 2376 dependencies: 2173 2377 '@jridgewell/sourcemap-codec': 1.5.5 2378 + 2379 + meshoptimizer@0.18.1: {} 2174 2380 2175 2381 mini-svg-data-uri@1.4.4: {} 2176 2382 ··· 2277 2483 2278 2484 readdirp@4.1.2: {} 2279 2485 2486 + require-from-string@2.0.2: {} 2487 + 2280 2488 rolldown@1.0.3: 2281 2489 dependencies: 2282 2490 '@oxc-project/types': 0.133.0 ··· 2376 2584 2377 2585 tapable@2.3.3: {} 2378 2586 2587 + three-instanced-uniforms-mesh@0.52.4(three@0.170.0): 2588 + dependencies: 2589 + three: 0.170.0 2590 + troika-three-utils: 0.52.4(three@0.170.0) 2591 + 2592 + three-mesh-bvh@0.9.10(three@0.170.0): 2593 + dependencies: 2594 + three: 0.170.0 2595 + 2596 + three-perf@1.0.11(three@0.170.0): 2597 + dependencies: 2598 + three: 0.170.0 2599 + troika-three-text: 0.52.4(three@0.170.0) 2600 + tweakpane: 3.1.10 2601 + 2602 + three-viewport-gizmo@2.2.0(three@0.170.0): 2603 + dependencies: 2604 + three: 0.170.0 2605 + 2606 + three@0.170.0: {} 2607 + 2379 2608 tinybench@2.9.0: {} 2380 2609 2381 2610 tinyexec@1.2.4: {} ··· 2389 2618 2390 2619 totalist@3.0.1: {} 2391 2620 2621 + troika-three-text@0.52.4(three@0.170.0): 2622 + dependencies: 2623 + bidi-js: 1.0.3 2624 + three: 0.170.0 2625 + troika-three-utils: 0.52.4(three@0.170.0) 2626 + troika-worker-utils: 0.52.0 2627 + webgl-sdf-generator: 1.1.1 2628 + 2629 + troika-three-utils@0.52.4(three@0.170.0): 2630 + dependencies: 2631 + three: 0.170.0 2632 + 2633 + troika-worker-utils@0.52.0: {} 2634 + 2392 2635 ts-api-utils@2.5.0(typescript@6.0.3): 2393 2636 dependencies: 2394 2637 typescript: 6.0.3 2395 2638 2396 2639 tslib@2.8.1: 2397 2640 optional: true 2641 + 2642 + tweakpane@3.1.10: {} 2398 2643 2399 2644 type-check@0.4.0: 2400 2645 dependencies: ··· 2470 2715 '@vitest/browser-playwright': 4.1.8(playwright@1.60.0)(vite@8.0.16(@types/node@22.19.20)(jiti@2.7.0))(vitest@4.1.8) 2471 2716 transitivePeerDependencies: 2472 2717 - msw 2718 + 2719 + webgl-sdf-generator@1.1.1: {} 2473 2720 2474 2721 which@2.0.2: 2475 2722 dependencies:
+7 -1
webapp/src/app.html
··· 4 4 <meta charset="utf-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 6 <meta name="text-scale" content="scale" /> 7 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 8 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 9 + <link 10 + href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" 11 + rel="stylesheet" 12 + /> 7 13 %sveltekit.head% 8 14 </head> 9 - <body data-sveltekit-preload-data="hover"> 15 + <body data-sveltekit-preload-data="hover" style="background: #0d0e11"> 10 16 <div style="display: contents">%sveltekit.body%</div> 11 17 </body> 12 18 </html>
+101
webapp/src/lib/components/ParamPanel.svelte
··· 1 + <script lang="ts"> 2 + import { Download, RotateCcw } from '@lucide/svelte'; 3 + import type { GeneratorEntry } from '$lib/registry'; 4 + import { displayValue, getPreset, resetParams, setParam, setPreset } from '$lib/editor.svelte'; 5 + import SliderControl from './controls/SliderControl.svelte'; 6 + import ToggleControl from './controls/ToggleControl.svelte'; 7 + import SelectControl from './controls/SelectControl.svelte'; 8 + import ColorControl from './controls/ColorControl.svelte'; 9 + import ColorArrayControl from './controls/ColorArrayControl.svelte'; 10 + 11 + let { entry, onexport }: { entry: GeneratorEntry; onexport: () => void } = $props(); 12 + 13 + const presetNames = $derived(Object.keys(entry.presets)); 14 + </script> 15 + 16 + <aside class="flex w-84 shrink-0 flex-col border-l border-line bg-panel"> 17 + <div class="flex items-center justify-between px-4 pt-[15px] pb-[11px]"> 18 + <h3 class="text-[15px] font-bold tracking-tight">Parameters</h3> 19 + <button 20 + type="button" 21 + class="grid size-7.5 place-items-center rounded-[9px] border border-line2 bg-panel2 text-muted hover:text-ink" 22 + title="Reset to defaults" 23 + onclick={() => resetParams(entry.id)} 24 + > 25 + <RotateCcw size={15} /> 26 + </button> 27 + </div> 28 + {#if presetNames.length > 1} 29 + <div class="mx-3.5 mb-3.5"> 30 + <select 31 + class="h-9.5 w-full rounded-[9px] border-line2 bg-panel2 text-[13px] font-semibold text-ink focus:border-accent focus:ring-0" 32 + value={getPreset(entry.id)} 33 + onchange={(e) => setPreset(entry.id, e.currentTarget.value)} 34 + > 35 + {#each presetNames as p (p)} 36 + <option value={p}>{p}</option> 37 + {/each} 38 + </select> 39 + </div> 40 + {/if} 41 + <div class="flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto px-4 pb-4"> 42 + {#each Object.entries(entry.schema) as [key, def] (entry.id + ':' + key)} 43 + {#if def.type === 'range'} 44 + <SliderControl 45 + label={def.label ?? key} 46 + value={displayValue(entry, key) as number | [number, number]} 47 + min={def.min} 48 + max={def.max} 49 + step={def.step ?? 0.01} 50 + onchange={(v) => setParam(entry.id, key, v)} 51 + /> 52 + {:else if def.type === 'integer'} 53 + <SliderControl 54 + label={def.label ?? key} 55 + value={displayValue(entry, key) as number | [number, number]} 56 + min={def.min} 57 + max={def.max} 58 + step={1} 59 + onchange={(v) => setParam(entry.id, key, v)} 60 + /> 61 + {:else if def.type === 'boolean'} 62 + <ToggleControl 63 + label={def.label ?? key} 64 + value={displayValue(entry, key) as boolean} 65 + onchange={(v) => setParam(entry.id, key, v)} 66 + /> 67 + {:else if def.type === 'select'} 68 + <SelectControl 69 + label={def.label ?? key} 70 + value={displayValue(entry, key) as string} 71 + options={def.options} 72 + onchange={(v) => setParam(entry.id, key, v)} 73 + /> 74 + {:else if def.type === 'color'} 75 + <ColorControl 76 + label={def.label ?? key} 77 + value={displayValue(entry, key) as string} 78 + onchange={(v) => setParam(entry.id, key, v)} 79 + /> 80 + {:else if def.type === 'color-array'} 81 + <ColorArrayControl 82 + label={def.label ?? key} 83 + colors={displayValue(entry, key) as string[]} 84 + min={def.min} 85 + max={def.max} 86 + onchange={(v) => setParam(entry.id, key, v)} 87 + /> 88 + {/if} 89 + {/each} 90 + </div> 91 + <div class="flex flex-col gap-[7px] border-t border-line px-3.5 py-3"> 92 + <button 93 + type="button" 94 + class="flex h-10 w-full items-center justify-center gap-1.5 rounded-[9px] bg-accent text-[13px] font-bold text-accent-ink hover:bg-[#b6f37e]" 95 + onclick={onexport} 96 + > 97 + <Download size={17} />Export GLB 98 + </button> 99 + <div class="mono text-center text-[11px] text-faint">binary glTF · vertex colors</div> 100 + </div> 101 + </aside>
+29
webapp/src/lib/components/Rail.svelte
··· 1 + <script lang="ts"> 2 + import { goto } from '$app/navigation'; 3 + import { resolve } from '$app/paths'; 4 + import { TreeDeciduous, Sprout, Mountain } from '@lucide/svelte'; 5 + import { CATEGORIES, generatorsInCategory, type CategoryId } from '$lib/registry'; 6 + 7 + let { active }: { active: CategoryId } = $props(); 8 + 9 + const icons = { trees: TreeDeciduous, plants: Sprout, rocks: Mountain }; 10 + </script> 11 + 12 + <nav class="flex w-15 shrink-0 flex-col items-center gap-1.5 border-r border-line bg-panel py-3"> 13 + {#each CATEGORIES as c (c.id)} 14 + {@const CategoryIcon = icons[c.id]} 15 + <button 16 + type="button" 17 + class="relative grid size-10 place-items-center rounded-[11px] {active === c.id 18 + ? 'bg-accent2 text-accent' 19 + : 'text-faint hover:bg-panel2 hover:text-ink'}" 20 + title={c.label} 21 + onclick={() => goto(resolve('/edit/[id]', { id: generatorsInCategory(c.id)[0].id }))} 22 + > 23 + {#if active === c.id} 24 + <span class="absolute top-2 bottom-2 -left-2.5 w-[3px] rounded-sm bg-accent"></span> 25 + {/if} 26 + <CategoryIcon size={20} /> 27 + </button> 28 + {/each} 29 + </nav>
+102
webapp/src/lib/components/Scene.svelte
··· 1 + <script lang="ts"> 2 + import * as THREE from 'three'; 3 + import { T, useThrelte } from '@threlte/core'; 4 + import { OrbitControls } from '@threlte/extras'; 5 + import { plane } from 'shapecraft'; 6 + import { fbm } from 'shapecraft/noise'; 7 + import { heightGradient } from 'shapecraft/color'; 8 + import { toThreeMesh } from 'shapecraft/three'; 9 + 10 + let { 11 + model, 12 + viewDistance, 13 + showGrid, 14 + wireframe 15 + }: { model: THREE.Mesh | null; viewDistance: number; showGrid: boolean; wireframe: boolean } = 16 + $props(); 17 + 18 + const { scene, invalidate } = useThrelte(); 19 + 20 + // Material mutation happens outside Threlte's prop system, so re-render manually. 21 + $effect(() => { 22 + if (model) { 23 + (model.material as THREE.MeshStandardMaterial).wireframe = wireframe; 24 + invalidate(); 25 + } 26 + }); 27 + scene.background = new THREE.Color(0x8eb3d9); 28 + scene.fog = new THREE.FogExp2(0x8eb3d9, 0.022); 29 + 30 + // Sky dome with a simple vertical gradient 31 + const skyGeo = new THREE.SphereGeometry(50, 16, 12); 32 + const skyPos = skyGeo.getAttribute('position'); 33 + const skyColors = new Float32Array(skyPos.count * 3); 34 + for (let i = 0; i < skyPos.count; i++) { 35 + const t = Math.max(0, skyPos.getY(i) / 50); 36 + skyColors[i * 3] = 0.55 + (0.2 - 0.55) * t; 37 + skyColors[i * 3 + 1] = 0.7 + (0.35 - 0.7) * t; 38 + skyColors[i * 3 + 2] = 0.85 + (0.65 - 0.85) * t; 39 + } 40 + skyGeo.setAttribute('color', new THREE.BufferAttribute(skyColors, 3)); 41 + const sky = new THREE.Mesh( 42 + skyGeo, 43 + new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.BackSide }) 44 + ); 45 + 46 + // Gently rolling ground, built with shapecraft itself 47 + const groundNoise = fbm({ seed: 7, octaves: 3, scale: 0.15, min: 0, max: 0.3 }); 48 + const ground = toThreeMesh( 49 + plane({ size: 30, segments: 60 }) 50 + .displace((pos: number[]) => groundNoise.get(pos[0], pos[2])) 51 + .vertexColor( 52 + heightGradient([ 53 + [-0.05, [0.25, 0.38, 0.12]], 54 + [0.1, [0.3, 0.45, 0.15]], 55 + [0.25, [0.35, 0.42, 0.18]] 56 + ]) 57 + ) 58 + ); 59 + ground.receiveShadow = true; 60 + const modelY = groundNoise.get(0, 0); 61 + 62 + const camPos = $derived([viewDistance * 0.6, viewDistance * 0.5, viewDistance] as [ 63 + number, 64 + number, 65 + number 66 + ]); 67 + const target = $derived([0, viewDistance * 0.16, 0] as [number, number, number]); 68 + </script> 69 + 70 + <T.PerspectiveCamera makeDefault position={camPos} fov={50}> 71 + <OrbitControls enableDamping dampingFactor={0.08} maxPolarAngle={Math.PI / 2 - 0.05} {target} /> 72 + </T.PerspectiveCamera> 73 + 74 + <T.HemisphereLight args={[0x87ceeb, 0x3a5a2a, 0.6]} /> 75 + <T.DirectionalLight 76 + position={[8, 12, 5]} 77 + intensity={1.4} 78 + color={0xfff4e0} 79 + castShadow 80 + oncreate={(sun) => { 81 + sun.shadow.mapSize.set(2048, 2048); 82 + sun.shadow.camera.left = -12; 83 + sun.shadow.camera.right = 12; 84 + sun.shadow.camera.top = 12; 85 + sun.shadow.camera.bottom = -12; 86 + sun.shadow.camera.near = 0.01; 87 + sun.shadow.camera.far = 40; 88 + sun.shadow.bias = -0.001; 89 + }} 90 + /> 91 + <T.DirectionalLight position={[-5, 4, -3]} intensity={0.3} color={0xb0c4de} /> 92 + 93 + <T is={sky} /> 94 + <T is={ground} /> 95 + 96 + {#if showGrid} 97 + <T.GridHelper args={[30, 30, 0xe8f0f8, 0xc4d2de]} position.y={0.32} /> 98 + {/if} 99 + 100 + {#if model} 101 + <T is={model} position.y={modelY} castShadow receiveShadow /> 102 + {/if}
+45
webapp/src/lib/components/TopBar.svelte
··· 1 + <script lang="ts"> 2 + import { Sparkles, Dices, Download } from '@lucide/svelte'; 3 + import { CATEGORIES, type GeneratorEntry } from '$lib/registry'; 4 + 5 + let { 6 + entry, 7 + ondice, 8 + onexport 9 + }: { entry: GeneratorEntry; ondice: () => void; onexport: () => void } = $props(); 10 + 11 + const category = $derived(CATEGORIES.find((c) => c.id === entry.category)!); 12 + </script> 13 + 14 + <header class="flex h-13 shrink-0 items-center gap-3.5 border-b border-line bg-panel px-4"> 15 + <div class="flex items-center gap-2 font-bold tracking-tight"> 16 + <span 17 + class="grid size-6 place-items-center rounded-[7px] bg-gradient-to-br from-accent to-[#6db83e] text-accent-ink" 18 + > 19 + <Sparkles size={15} strokeWidth={2} /> 20 + </span> 21 + shapecraft 22 + </div> 23 + <div class="h-5.5 w-px bg-line2"></div> 24 + <div class="flex items-center gap-2 text-[13px] text-muted"> 25 + <b class="font-semibold text-ink">{category.label}</b> 26 + <span class="text-faint">/</span> 27 + <b class="font-semibold text-accent">{entry.label}</b> 28 + </div> 29 + <div class="flex-1"></div> 30 + <button 31 + type="button" 32 + class="grid size-8.5 place-items-center rounded-[9px] border border-line2 bg-panel2 text-muted hover:border-[#3c424d] hover:text-ink" 33 + title="Randomize seed" 34 + onclick={ondice} 35 + > 36 + <Dices size={18} /> 37 + </button> 38 + <button 39 + type="button" 40 + class="flex h-8.5 items-center gap-1.5 rounded-[9px] bg-accent px-3.5 text-[13px] font-semibold text-accent-ink hover:bg-[#b6f37e]" 41 + onclick={onexport} 42 + > 43 + <Download size={16} />Export GLB 44 + </button> 45 + </header>
+61
webapp/src/lib/components/TypeList.svelte
··· 1 + <script lang="ts"> 2 + import { Search } from '@lucide/svelte'; 3 + import { resolve } from '$app/paths'; 4 + import { CATEGORIES, generatorsInCategory, type GeneratorEntry } from '$lib/registry'; 5 + 6 + let { entry }: { entry: GeneratorEntry } = $props(); 7 + 8 + let query = $state(''); 9 + 10 + const category = $derived(CATEGORIES.find((c) => c.id === entry.category)!); 11 + const all = $derived(generatorsInCategory(entry.category)); 12 + const types = $derived(all.filter((g) => g.label.toLowerCase().includes(query.toLowerCase()))); 13 + 14 + const hues: Record<string, number> = { trees: 120, plants: 95, rocks: 30 }; 15 + </script> 16 + 17 + <aside class="flex w-57 shrink-0 flex-col border-r border-line bg-panel"> 18 + <div class="flex items-center justify-between px-4 pt-4 pb-2.5"> 19 + <h3 class="text-[15px] font-bold tracking-tight">{category.label}</h3> 20 + <span class="rounded-full border border-line2 bg-panel2 px-2 py-0.5 text-[11px] text-muted"> 21 + {all.length} types 22 + </span> 23 + </div> 24 + <div 25 + class="mx-3 mb-2.5 flex h-8.5 items-center gap-2 rounded-[9px] border border-line2 bg-bg px-2.5" 26 + > 27 + <Search size={15} class="shrink-0 text-faint" /> 28 + <input 29 + type="text" 30 + class="w-full border-none bg-transparent p-0 text-[13px] text-ink placeholder:text-faint focus:ring-0" 31 + placeholder="Search types…" 32 + bind:value={query} 33 + /> 34 + </div> 35 + <div class="flex min-h-0 flex-col gap-[3px] overflow-y-auto px-2 py-1"> 36 + {#each types as g, i (g.id)} 37 + <a 38 + href={resolve('/edit/[id]', { id: g.id })} 39 + class="flex items-center gap-2.5 rounded-[10px] px-2 py-[7px] {g.id === entry.id 40 + ? 'bg-panel2 shadow-[inset_0_0_0_1px_var(--color-line2)]' 41 + : 'hover:bg-panel2'}" 42 + > 43 + <span 44 + class="mono grid h-7.5 w-9.5 shrink-0 place-items-center rounded-[7px] text-[9px] font-semibold text-white/70 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)]" 45 + style="background:linear-gradient(135deg, hsl({hues[g.category]} 28% {40 - 46 + i * 2}%), hsl({hues[g.category]} 34% {24 - i}%))" 47 + > 48 + {g.label.slice(0, 3).toUpperCase()} 49 + </span> 50 + <span> 51 + <span class="block text-[13.5px] font-semibold {g.id === entry.id ? 'text-accent' : ''}"> 52 + {g.label} 53 + </span> 54 + <span class="mono block text-[11px] text-faint"> 55 + {Object.keys(g.schema).length} params 56 + </span> 57 + </span> 58 + </a> 59 + {/each} 60 + </div> 61 + </aside>
+96
webapp/src/lib/components/Viewport.svelte
··· 1 + <script lang="ts"> 2 + import type * as THREE from 'three'; 3 + import { Canvas } from '@threlte/core'; 4 + import { Box, Dices, Grid3x3, TreeDeciduous, Sprout, Mountain } from '@lucide/svelte'; 5 + import type { GeneratorEntry } from '$lib/registry'; 6 + import Scene from './Scene.svelte'; 7 + 8 + let { 9 + model, 10 + entry, 11 + seed, 12 + tris, 13 + error, 14 + ondice 15 + }: { 16 + model: THREE.Mesh | null; 17 + entry: GeneratorEntry; 18 + seed: number | undefined; 19 + tris: number; 20 + error: string | null; 21 + ondice: () => void; 22 + } = $props(); 23 + 24 + let showGrid = $state(false); 25 + let wireframe = $state(false); 26 + 27 + const icons = { trees: TreeDeciduous, plants: Sprout, rocks: Mountain }; 28 + const CategoryIcon = $derived(icons[entry.category]); 29 + 30 + const float = 31 + 'absolute rounded-xl border border-white/10 bg-[rgba(16,18,22,0.66)] backdrop-blur-[10px]'; 32 + </script> 33 + 34 + <div class="relative min-w-0 flex-1 overflow-hidden"> 35 + <Canvas> 36 + <Scene {model} viewDistance={entry.viewDistance} {showGrid} {wireframe} /> 37 + </Canvas> 38 + 39 + <!-- title card --> 40 + <div class="{float} top-4 left-4 flex items-center gap-2.5 px-3.5 py-2.5"> 41 + <span class="grid size-7.5 place-items-center rounded-lg bg-accent2 text-accent"> 42 + <CategoryIcon size={17} /> 43 + </span> 44 + <div> 45 + <h4 class="text-sm font-bold text-white">{entry.label}</h4> 46 + <div class="mono text-[11px] text-white/60"> 47 + seed {seed ?? '–'} · {tris.toLocaleString()} tris 48 + </div> 49 + </div> 50 + </div> 51 + 52 + <!-- view tools --> 53 + <div class="{float} top-4 right-4 flex flex-col gap-[3px] p-[5px]"> 54 + <button 55 + type="button" 56 + class="grid size-8.5 place-items-center rounded-lg {showGrid 57 + ? 'bg-accent text-accent-ink' 58 + : 'text-white/70 hover:bg-white/10 hover:text-white'}" 59 + title="Toggle grid" 60 + onclick={() => (showGrid = !showGrid)} 61 + > 62 + <Grid3x3 size={17} /> 63 + </button> 64 + <button 65 + type="button" 66 + class="grid size-8.5 place-items-center rounded-lg {wireframe 67 + ? 'bg-accent text-accent-ink' 68 + : 'text-white/70 hover:bg-white/10 hover:text-white'}" 69 + title="Toggle wireframe" 70 + onclick={() => (wireframe = !wireframe)} 71 + > 72 + <Box size={17} /> 73 + </button> 74 + </div> 75 + 76 + <!-- status --> 77 + <div class="{float} bottom-4 left-4 flex items-center gap-2.5 px-3 py-[7px]"> 78 + {#if error} 79 + <span class="size-1.5 rounded-full bg-red-400"></span> 80 + <span class="mono text-[11px] text-red-300">{error}</span> 81 + {:else} 82 + <span class="size-1.5 rounded-full bg-accent"></span> 83 + <span class="mono text-[11px] text-white/80">{tris.toLocaleString()} tris · GLB ready</span> 84 + {/if} 85 + </div> 86 + 87 + <!-- randomize --> 88 + <button 89 + type="button" 90 + class="absolute bottom-4 left-1/2 grid size-10.5 -translate-x-1/2 place-items-center rounded-[10px] bg-accent text-accent-ink shadow-lg hover:bg-[#b6f37e]" 91 + title="Randomize seed" 92 + onclick={ondice} 93 + > 94 + <Dices size={20} /> 95 + </button> 96 + </div>
+73
webapp/src/lib/components/controls/ColorArrayControl.svelte
··· 1 + <script lang="ts"> 2 + import { Plus, X } from '@lucide/svelte'; 3 + 4 + let { 5 + label, 6 + colors, 7 + min = 0, 8 + max = 6, 9 + onchange 10 + }: { 11 + label: string; 12 + colors: string[]; 13 + min?: number; 14 + max?: number; 15 + onchange: (v: string[]) => void; 16 + } = $props(); 17 + 18 + function setColor(i: number, c: string) { 19 + onchange(colors.map((x, j) => (j === i ? c : x))); 20 + } 21 + function remove(i: number) { 22 + onchange(colors.filter((_, j) => j !== i)); 23 + } 24 + function add() { 25 + onchange([...colors, colors[colors.length - 1] ?? '#8a9078']); 26 + } 27 + </script> 28 + 29 + <div> 30 + <div class="mb-2 flex items-baseline justify-between"> 31 + <span class="text-[12.5px] font-medium text-muted">{label}</span> 32 + {#if colors.length === 0} 33 + <span class="text-xs text-faint">off</span> 34 + {/if} 35 + </div> 36 + <div class="flex flex-wrap items-center gap-1.5"> 37 + {#each colors as c, i (i)} 38 + <span class="group relative"> 39 + <label 40 + class="block size-6.5 cursor-pointer rounded-[7px] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.14)]" 41 + style="background:{c}" 42 + > 43 + <input 44 + type="color" 45 + class="absolute inset-0 size-full cursor-pointer border-none p-0 opacity-0" 46 + value={c} 47 + oninput={(e) => setColor(i, e.currentTarget.value)} 48 + /> 49 + </label> 50 + {#if colors.length > min} 51 + <button 52 + type="button" 53 + class="absolute -top-1.5 -right-1.5 hidden size-4 place-items-center rounded-full bg-line2 text-muted group-hover:grid hover:text-ink" 54 + title="Remove color" 55 + onclick={() => remove(i)} 56 + > 57 + <X size={10} /> 58 + </button> 59 + {/if} 60 + </span> 61 + {/each} 62 + {#if colors.length < max} 63 + <button 64 + type="button" 65 + class="grid size-6.5 place-items-center rounded-[7px] border border-dashed border-line2 text-muted hover:text-ink" 66 + title="Add color" 67 + onclick={add} 68 + > 69 + <Plus size={14} /> 70 + </button> 71 + {/if} 72 + </div> 73 + </div>
+22
webapp/src/lib/components/controls/ColorControl.svelte
··· 1 + <script lang="ts"> 2 + let { label, value, onchange }: { label: string; value: string; onchange: (v: string) => void } = 3 + $props(); 4 + </script> 5 + 6 + <div class="flex items-center justify-between"> 7 + <span class="text-[12.5px] font-medium text-muted">{label}</span> 8 + <div class="flex items-center gap-2"> 9 + <span class="mono text-xs text-faint">{value}</span> 10 + <label 11 + class="relative block size-6.5 cursor-pointer rounded-[7px] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.14)]" 12 + style="background:{value}" 13 + > 14 + <input 15 + type="color" 16 + class="absolute inset-0 size-full cursor-pointer border-none p-0 opacity-0" 17 + {value} 18 + oninput={(e) => onchange(e.currentTarget.value)} 19 + /> 20 + </label> 21 + </div> 22 + </div>
+21
webapp/src/lib/components/controls/SelectControl.svelte
··· 1 + <script lang="ts"> 2 + let { 3 + label, 4 + value, 5 + options, 6 + onchange 7 + }: { label: string; value: string; options: string[]; onchange: (v: string) => void } = $props(); 8 + </script> 9 + 10 + <div class="flex items-center justify-between gap-3"> 11 + <span class="text-[12.5px] font-medium text-muted">{label}</span> 12 + <select 13 + class="h-8 min-w-28 rounded-lg border-line2 bg-panel2 py-0 pl-3 text-[13px] font-semibold text-ink focus:border-accent focus:ring-0" 14 + {value} 15 + onchange={(e) => onchange(e.currentTarget.value)} 16 + > 17 + {#each options as o (o)} 18 + <option value={o}>{o}</option> 19 + {/each} 20 + </select> 21 + </div>
+137
webapp/src/lib/components/controls/SliderControl.svelte
··· 1 + <script lang="ts"> 2 + let { 3 + label, 4 + value, 5 + min, 6 + max, 7 + step = 0.01, 8 + onchange 9 + }: { 10 + label: string; 11 + value: number | [number, number]; 12 + min: number; 13 + max: number; 14 + step?: number; 15 + onchange: (v: number | [number, number]) => void; 16 + } = $props(); 17 + 18 + const isRange = $derived(Array.isArray(value)); 19 + const lo = $derived(Array.isArray(value) ? value[0] : value); 20 + const hi = $derived(Array.isArray(value) ? value[1] : value); 21 + 22 + const pct = (v: number) => ((v - min) / (max - min)) * 100; 23 + 24 + const decimals = $derived(step >= 1 ? 0 : step >= 0.1 ? 1 : step >= 0.01 ? 2 : 3); 25 + const fmt = (v: number) => v.toFixed(decimals); 26 + </script> 27 + 28 + <div> 29 + <div class="mb-2 flex items-baseline justify-between"> 30 + <span class="text-[12.5px] font-medium text-muted">{label}</span> 31 + <span class="mono text-xs text-ink">{isRange ? `${fmt(lo)} – ${fmt(hi)}` : fmt(lo)}</span> 32 + </div> 33 + <div class="rng" class:single={!isRange}> 34 + <div class="rng-track"></div> 35 + <div class="rng-fill" style="left:{isRange ? pct(lo) : 0}%; right:{100 - pct(hi)}%"></div> 36 + {#if isRange} 37 + <input 38 + type="range" 39 + {min} 40 + {max} 41 + {step} 42 + value={lo} 43 + oninput={(e) => onchange([Math.min(+e.currentTarget.value, hi), hi])} 44 + /> 45 + <input 46 + type="range" 47 + {min} 48 + {max} 49 + {step} 50 + value={hi} 51 + oninput={(e) => onchange([lo, Math.max(+e.currentTarget.value, lo)])} 52 + /> 53 + {:else} 54 + <input 55 + type="range" 56 + {min} 57 + {max} 58 + {step} 59 + value={lo} 60 + oninput={(e) => onchange(+e.currentTarget.value)} 61 + /> 62 + {/if} 63 + </div> 64 + </div> 65 + 66 + <style> 67 + .rng { 68 + position: relative; 69 + height: 22px; 70 + } 71 + .rng-track { 72 + position: absolute; 73 + left: 0; 74 + right: 0; 75 + top: 9px; 76 + height: 4px; 77 + border-radius: 3px; 78 + background: var(--color-line2); 79 + } 80 + .rng-fill { 81 + position: absolute; 82 + top: 9px; 83 + height: 4px; 84 + border-radius: 3px; 85 + background: var(--color-accent); 86 + } 87 + .rng input[type='range'] { 88 + -webkit-appearance: none; 89 + appearance: none; 90 + position: absolute; 91 + inset: 0; 92 + width: 100%; 93 + height: 22px; 94 + margin: 0; 95 + padding: 0; 96 + background: transparent; 97 + pointer-events: none; 98 + } 99 + .rng.single input[type='range'] { 100 + pointer-events: auto; 101 + } 102 + .rng input[type='range']:focus { 103 + outline: none; 104 + box-shadow: none; 105 + } 106 + .rng input[type='range']::-webkit-slider-runnable-track { 107 + background: transparent; 108 + height: 22px; 109 + } 110 + .rng input[type='range']::-webkit-slider-thumb { 111 + -webkit-appearance: none; 112 + pointer-events: auto; 113 + width: 14px; 114 + height: 14px; 115 + margin-top: 4px; 116 + border-radius: 50%; 117 + border: none; 118 + background: #eaf6dc; 119 + box-shadow: 120 + 0 0 0 3px rgb(166 232 106 / 0.25), 121 + 0 1px 3px rgb(0 0 0 / 0.45); 122 + cursor: grab; 123 + } 124 + .rng input[type='range']:active::-webkit-slider-thumb { 125 + cursor: grabbing; 126 + } 127 + .rng input[type='range']::-moz-range-thumb { 128 + pointer-events: auto; 129 + width: 14px; 130 + height: 14px; 131 + border-radius: 50%; 132 + border: none; 133 + background: #eaf6dc; 134 + box-shadow: 0 0 0 3px rgb(166 232 106 / 0.25); 135 + cursor: grab; 136 + } 137 + </style>
+26
webapp/src/lib/components/controls/ToggleControl.svelte
··· 1 + <script lang="ts"> 2 + import { Check } from '@lucide/svelte'; 3 + 4 + let { 5 + label, 6 + value, 7 + onchange 8 + }: { label: string; value: boolean; onchange: (v: boolean) => void } = $props(); 9 + </script> 10 + 11 + <button 12 + type="button" 13 + role="switch" 14 + aria-checked={value} 15 + class="flex items-center gap-2.5 py-0.5" 16 + onclick={() => onchange(!value)} 17 + > 18 + <span 19 + class="grid size-4.5 place-items-center rounded-md border-[1.5px] {value 20 + ? 'border-accent bg-accent text-accent-ink' 21 + : 'border-line2 text-transparent'}" 22 + > 23 + <Check size={13} strokeWidth={3} /> 24 + </span> 25 + <span class="text-[13px] font-semibold">{label}</span> 26 + </button>
+54
webapp/src/lib/editor.svelte.ts
··· 1 + import type { GeneratorEntry } from './registry'; 2 + 3 + /** 4 + * Editor state, kept at module level keyed by generator id so parameter 5 + * tweaks survive switching between generators within a session. 6 + */ 7 + const overridesById: Record<string, Record<string, unknown>> = $state({}); 8 + const presetById: Record<string, string> = $state({}); 9 + 10 + export function getOverrides(id: string): Record<string, unknown> { 11 + if (!overridesById[id]) overridesById[id] = {}; 12 + return overridesById[id]; 13 + } 14 + 15 + export function getPreset(id: string): string { 16 + return presetById[id] ?? 'default'; 17 + } 18 + 19 + export function setPreset(id: string, preset: string) { 20 + presetById[id] = preset; 21 + // A preset is a fresh baseline: drop manual overrides so its values show through. 22 + overridesById[id] = {}; 23 + } 24 + 25 + export function setParam(id: string, key: string, value: unknown) { 26 + getOverrides(id); 27 + overridesById[id][key] = value; 28 + } 29 + 30 + export function resetParams(id: string) { 31 + overridesById[id] = {}; 32 + presetById[id] = 'default'; 33 + } 34 + 35 + /** The value the UI should display for a param: override → preset → schema default. */ 36 + export function displayValue(entry: GeneratorEntry, key: string): unknown { 37 + const override = overridesById[entry.id]?.[key]; 38 + if (override !== undefined) return override; 39 + const preset = entry.presets[getPreset(entry.id)]; 40 + if (preset && preset[key] !== undefined) return preset[key]; 41 + return entry.schema[key].default; 42 + } 43 + 44 + /** Options object passed to the generator function. */ 45 + export function buildOptions(entry: GeneratorEntry): Record<string, unknown> { 46 + return { ...overridesById[entry.id], preset: getPreset(entry.id) }; 47 + } 48 + 49 + export function randomizeSeed(entry: GeneratorEntry) { 50 + const def = entry.schema.seed; 51 + if (!def || (def.type !== 'integer' && def.type !== 'range')) return; 52 + const seed = Math.floor(def.min + Math.random() * (def.max - def.min + 1)); 53 + setParam(entry.id, 'seed', seed); 54 + }
+14
webapp/src/lib/export-glb.ts
··· 1 + import type * as THREE from 'three'; 2 + import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'; 3 + 4 + export async function exportGLB(object: THREE.Object3D, filename: string) { 5 + const exporter = new GLTFExporter(); 6 + const result = await exporter.parseAsync(object, { binary: true }); 7 + const blob = new Blob([result as ArrayBuffer], { type: 'model/gltf-binary' }); 8 + const url = URL.createObjectURL(blob); 9 + const a = document.createElement('a'); 10 + a.href = url; 11 + a.download = filename.endsWith('.glb') ? filename : `${filename}.glb`; 12 + a.click(); 13 + URL.revokeObjectURL(url); 14 + }
+183
webapp/src/lib/registry.ts
··· 1 + import type { OptionSchema } from 'shapecraft/core/schema'; 2 + import type { Mesh } from 'shapecraft/core/mesh'; 3 + import { 4 + tree, 5 + treeSchema, 6 + treePresets, 7 + pine, 8 + pineSchema, 9 + pinePresets, 10 + palm, 11 + palmSchema, 12 + palmPresets, 13 + bush, 14 + bushSchema, 15 + bushPresets, 16 + grass, 17 + grassSchema, 18 + grassPresets, 19 + fern, 20 + fernSchema, 21 + fernPresets, 22 + flower, 23 + flowerSchema, 24 + flowerPresets, 25 + deadTree, 26 + deadTreeSchema, 27 + deadTreePresets, 28 + leafyTree, 29 + leafyTreeSchema, 30 + leafyTreePresets, 31 + rock, 32 + rockSchema, 33 + rockPresets, 34 + sharpRock, 35 + sharpRockSchema, 36 + sharpRockPresets, 37 + blockRock, 38 + blockRockSchema, 39 + blockRockPresets 40 + } from 'shapecraft/generators'; 41 + 42 + export type CategoryId = 'trees' | 'plants' | 'rocks'; 43 + 44 + export interface Category { 45 + id: CategoryId; 46 + label: string; 47 + } 48 + 49 + export const CATEGORIES: Category[] = [ 50 + { id: 'trees', label: 'Trees' }, 51 + { id: 'plants', label: 'Plants' }, 52 + { id: 'rocks', label: 'Rocks' } 53 + ]; 54 + 55 + export interface GeneratorEntry { 56 + id: string; 57 + label: string; 58 + category: CategoryId; 59 + gen: (opts: Record<string, unknown>) => Mesh; 60 + schema: OptionSchema; 61 + presets: Record<string, Record<string, unknown>>; 62 + /** Rough camera distance that frames the default model nicely */ 63 + viewDistance: number; 64 + } 65 + 66 + export const GENERATORS: GeneratorEntry[] = [ 67 + { 68 + id: 'common', 69 + label: 'Common Tree', 70 + category: 'trees', 71 + gen: tree, 72 + schema: treeSchema, 73 + presets: treePresets, 74 + viewDistance: 7 75 + }, 76 + { 77 + id: 'leafy', 78 + label: 'Leafy Tree', 79 + category: 'trees', 80 + gen: leafyTree, 81 + schema: leafyTreeSchema, 82 + presets: leafyTreePresets, 83 + viewDistance: 7 84 + }, 85 + { 86 + id: 'pine', 87 + label: 'Pine', 88 + category: 'trees', 89 + gen: pine, 90 + schema: pineSchema, 91 + presets: pinePresets, 92 + viewDistance: 8 93 + }, 94 + { 95 + id: 'palm', 96 + label: 'Palm', 97 + category: 'trees', 98 + gen: palm, 99 + schema: palmSchema, 100 + presets: palmPresets, 101 + viewDistance: 9 102 + }, 103 + { 104 + id: 'dead', 105 + label: 'Dead Tree', 106 + category: 'trees', 107 + gen: deadTree, 108 + schema: deadTreeSchema, 109 + presets: deadTreePresets, 110 + viewDistance: 8 111 + }, 112 + { 113 + id: 'bush', 114 + label: 'Bush', 115 + category: 'plants', 116 + gen: bush, 117 + schema: bushSchema, 118 + presets: bushPresets, 119 + viewDistance: 3 120 + }, 121 + { 122 + id: 'grass', 123 + label: 'Grass', 124 + category: 'plants', 125 + gen: grass, 126 + schema: grassSchema, 127 + presets: grassPresets, 128 + viewDistance: 2.5 129 + }, 130 + { 131 + id: 'fern', 132 + label: 'Fern', 133 + category: 'plants', 134 + gen: fern, 135 + schema: fernSchema, 136 + presets: fernPresets, 137 + viewDistance: 3.5 138 + }, 139 + { 140 + id: 'flower', 141 + label: 'Flower', 142 + category: 'plants', 143 + gen: flower, 144 + schema: flowerSchema, 145 + presets: flowerPresets, 146 + viewDistance: 2.5 147 + }, 148 + { 149 + id: 'rock', 150 + label: 'Rock', 151 + category: 'rocks', 152 + gen: rock, 153 + schema: rockSchema, 154 + presets: rockPresets, 155 + viewDistance: 3 156 + }, 157 + { 158 + id: 'sharp', 159 + label: 'Sharp Rock', 160 + category: 'rocks', 161 + gen: sharpRock, 162 + schema: sharpRockSchema, 163 + presets: sharpRockPresets, 164 + viewDistance: 4 165 + }, 166 + { 167 + id: 'block', 168 + label: 'Block Rock', 169 + category: 'rocks', 170 + gen: blockRock, 171 + schema: blockRockSchema, 172 + presets: blockRockPresets, 173 + viewDistance: 3.5 174 + } 175 + ]; 176 + 177 + export function getGenerator(id: string): GeneratorEntry | undefined { 178 + return GENERATORS.find((g) => g.id === id); 179 + } 180 + 181 + export function generatorsInCategory(category: CategoryId): GeneratorEntry[] { 182 + return GENERATORS.filter((g) => g.category === category); 183 + }
-8
webapp/src/lib/vitest-examples/Welcome.svelte
··· 1 - <script> 2 - import { greet } from './greet'; 3 - 4 - let { host = 'SvelteKit', guest = 'Vitest' } = $props(); 5 - </script> 6 - 7 - <h1>{greet(host)}</h1> 8 - <p>{greet(guest)}</p>
-15
webapp/src/lib/vitest-examples/Welcome.svelte.spec.ts
··· 1 - import { page } from 'vitest/browser'; 2 - import { describe, expect, it } from 'vitest'; 3 - import { render } from 'vitest-browser-svelte'; 4 - import Welcome from './Welcome.svelte'; 5 - 6 - describe('Welcome.svelte', () => { 7 - it('renders greetings for host and guest', async () => { 8 - render(Welcome, { host: 'SvelteKit', guest: 'Vitest' }); 9 - 10 - await expect 11 - .element(page.getByRole('heading', { level: 1 })) 12 - .toHaveTextContent('Hello, SvelteKit!'); 13 - await expect.element(page.getByText('Hello, Vitest!')).toBeInTheDocument(); 14 - }); 15 - });
-8
webapp/src/lib/vitest-examples/greet.spec.ts
··· 1 - import { describe, it, expect } from 'vitest'; 2 - import { greet } from './greet'; 3 - 4 - describe('greet', () => { 5 - it('returns a greeting', () => { 6 - expect(greet('Svelte')).toBe('Hello, Svelte!'); 7 - }); 8 - });
-3
webapp/src/lib/vitest-examples/greet.ts
··· 1 - export function greet(name: string): string { 2 - return 'Hello, ' + name + '!'; 3 - }
+2
webapp/src/routes/+layout.ts
··· 1 + // Pure client-side tool: three.js / WebGL has no use for SSR here. 2 + export const ssr = false;
+1 -2
webapp/src/routes/+page.svelte
··· 1 - <h1>Welcome to SvelteKit</h1> 2 - <p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p> 1 + <!-- Redirects to the editor in +page.ts -->
+5
webapp/src/routes/+page.ts
··· 1 + import { redirect } from '@sveltejs/kit'; 2 + 3 + export function load() { 4 + redirect(307, '/edit/common'); 5 + }
-5
webapp/src/routes/demo/+page.svelte
··· 1 - <script lang="ts"> 2 - import { resolve } from '$app/paths'; 3 - </script> 4 - 5 - <a href={resolve('/demo/playwright')}>playwright</a>
-1
webapp/src/routes/demo/playwright/+page.svelte
··· 1 - <h1>Playwright e2e test demo</h1>
-6
webapp/src/routes/demo/playwright/page.svelte.e2e.ts
··· 1 - import { expect, test } from '@playwright/test'; 2 - 3 - test('has expected h1', async ({ page }) => { 4 - await page.goto('/demo/playwright'); 5 - await expect(page.locator('h1')).toBeVisible(); 6 - });
+48
webapp/src/routes/edit/[id]/+page.svelte
··· 1 + <script lang="ts"> 2 + import { page } from '$app/state'; 3 + import { toThreeMesh } from 'shapecraft/three'; 4 + import { getGenerator } from '$lib/registry'; 5 + import { buildOptions, displayValue, randomizeSeed } from '$lib/editor.svelte'; 6 + import { exportGLB } from '$lib/export-glb'; 7 + import TopBar from '$lib/components/TopBar.svelte'; 8 + import Rail from '$lib/components/Rail.svelte'; 9 + import TypeList from '$lib/components/TypeList.svelte'; 10 + import Viewport from '$lib/components/Viewport.svelte'; 11 + import ParamPanel from '$lib/components/ParamPanel.svelte'; 12 + 13 + const entry = $derived(getGenerator(page.params.id!)!); 14 + 15 + const result = $derived.by(() => { 16 + try { 17 + return { model: toThreeMesh(entry.gen(buildOptions(entry))), error: null }; 18 + } catch (e) { 19 + return { model: null, error: e instanceof Error ? e.message : String(e) }; 20 + } 21 + }); 22 + const model = $derived(result.model); 23 + const tris = $derived( 24 + model ? (model.geometry.index?.count ?? model.geometry.getAttribute('position').count) / 3 : 0 25 + ); 26 + const seed = $derived(displayValue(entry, 'seed') as number | undefined); 27 + 28 + function dice() { 29 + randomizeSeed(entry); 30 + } 31 + function doExport() { 32 + if (model) exportGLB(model, `${entry.id}-seed-${seed ?? 0}`); 33 + } 34 + </script> 35 + 36 + <svelte:head> 37 + <title>{entry.label} · shapecraft</title> 38 + </svelte:head> 39 + 40 + <div class="flex h-dvh flex-col"> 41 + <TopBar {entry} ondice={dice} onexport={doExport} /> 42 + <div class="flex min-h-0 flex-1"> 43 + <Rail active={entry.category} /> 44 + <TypeList {entry} /> 45 + <Viewport {model} {entry} {seed} {tris} error={result.error} ondice={dice} /> 46 + <ParamPanel {entry} onexport={doExport} /> 47 + </div> 48 + </div>
+7
webapp/src/routes/edit/[id]/+page.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import { getGenerator } from '$lib/registry'; 3 + import type { PageLoad } from './$types'; 4 + 5 + export const load: PageLoad = ({ params }) => { 6 + if (!getGenerator(params.id)) error(404, `Unknown generator "${params.id}"`); 7 + };
+37
webapp/src/routes/layout.css
··· 1 1 @import 'tailwindcss'; 2 2 @plugin '@tailwindcss/forms'; 3 3 @plugin '@tailwindcss/typography'; 4 + 5 + /* Atelier (dirA) design tokens */ 6 + @theme { 7 + --color-bg: #0d0e11; 8 + --color-panel: #14161b; 9 + --color-panel2: #191c22; 10 + --color-line: #242830; 11 + --color-line2: #2f343d; 12 + --color-ink: #e9eaed; 13 + --color-muted: #969ba5; 14 + --color-faint: #666c76; 15 + --color-accent: #a6e86a; 16 + --color-accent2: #3c5526; 17 + --color-accent-ink: #16270a; 18 + 19 + --font-sans: 'Hanken Grotesk', system-ui, sans-serif; 20 + --font-mono: 'JetBrains Mono', ui-monospace, monospace; 21 + } 22 + 23 + html, 24 + body { 25 + height: 100%; 26 + } 27 + 28 + body { 29 + background: var(--color-bg); 30 + color: var(--color-ink); 31 + font-family: var(--font-sans); 32 + font-size: 14px; 33 + -webkit-font-smoothing: antialiased; 34 + overflow: hidden; 35 + } 36 + 37 + .mono { 38 + font-family: var(--font-mono); 39 + font-feature-settings: 'tnum'; 40 + }
+12
webapp/vite.config.ts
··· 14 14 filename.split(/[/\\]/).includes('node_modules') ? undefined : true 15 15 }, 16 16 17 + // Consume the shapecraft library as TS source so generator edits hot-reload in the viewport. 18 + alias: { 19 + shapecraft: '../src', 20 + 'shapecraft/*': '../src/*' 21 + }, 22 + 17 23 // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. 18 24 // If your environment is not supported, or you settled on a specific environment, switch out the adapter. 19 25 // See https://svelte.dev/docs/kit/adapters for more information about adapters. 20 26 adapter: adapter() 21 27 }) 22 28 ], 29 + server: { 30 + fs: { 31 + // Allow serving shapecraft source from one level up 32 + allow: ['..'] 33 + } 34 + }, 23 35 test: { 24 36 expect: { requireAssertions: true }, 25 37 projects: [