// Test subjects, generated rather than downloaded. // // A splat reconstruction of a real object would do, but every published one we // could use carries research-only or unstated terms, so the shapes here are made // from scratch: each returns gaussian centres plus per-kernel sigma, which is all // the simulation and the renderer need. function rngFrom(seed) { let s = seed >>> 0 || 1; return () => { s ^= s << 13; s >>>= 0; s ^= s >> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; }; } /** Fill a shape by rejection sampling, so density is uniform in volume. */ function fill(inside, count, bounds, rng) { const pos = [], [lo, hi] = bounds; let guard = 0; while (pos.length < count * 3 && guard < count * 400) { guard++; const x = lo[0] + rng() * (hi[0] - lo[0]); const y = lo[1] + rng() * (hi[1] - lo[1]); const z = lo[2] + rng() * (hi[2] - lo[2]); if (inside(x, y, z)) pos.push(x, y, z); } return new Float32Array(pos); } const SHAPES = { cube: { label: 'Cube', make: (rng, n) => fill((x, y, z) => Math.abs(x - 0.5) < 0.13 && Math.abs(z - 0.5) < 0.13 && y > 0.52 && y < 0.78, n, [[0.3, 0.45, 0.3], [0.7, 0.85, 0.7]], rng), }, ball: { label: 'Ball', make: (rng, n) => fill((x, y, z) => (x - 0.5) ** 2 + (y - 0.66) ** 2 + (z - 0.5) ** 2 < 0.145 ** 2, n, [[0.34, 0.5, 0.34], [0.66, 0.82, 0.66]], rng), }, column: { label: 'Column', make: (rng, n) => fill((x, y, z) => (x - 0.5) ** 2 + (z - 0.5) ** 2 < 0.09 ** 2 && y > 0.2 && y < 0.86, n, [[0.4, 0.2, 0.4], [0.6, 0.86, 0.6]], rng), }, arch: { label: 'Arch', make: (rng, n) => fill((x, y, z) => { if (Math.abs(z - 0.5) > 0.09) return false; const r = Math.hypot(x - 0.5, y - 0.42); if (y > 0.42) return r > 0.14 && r < 0.24; return Math.abs(Math.abs(x - 0.5) - 0.19) < 0.05 && y > 0.2; }, n, [[0.24, 0.2, 0.4], [0.76, 0.7, 0.6]], rng), }, tower: { label: 'Tower', make: (rng, n) => fill((x, y, z) => { const t = (y - 0.2) / 0.62; if (t < 0 || t > 1) return false; const half = 0.16 * (1 - t * 0.55); return Math.abs(x - 0.5) < half && Math.abs(z - 0.5) < half; }, n, [[0.3, 0.2, 0.3], [0.7, 0.85, 0.7]], rng), }, }; export const SHAPE_LIST = Object.entries(SHAPES).map(([id, s]) => ({ id, label: s.label })); /** * Build a gaussian cloud for a shape. Sigma is tied to the spacing implied by the * particle count so the kernels just overlap — the same reason a splat * reconstruction looks solid rather than like beads. */ export function makeAsset(shape = 'cube', count = 4200, seed = 7) { const rng = rngFrom(seed); const spec = SHAPES[shape] || SHAPES.cube; const pos = spec.make(rng, count); const n = pos.length / 3; const sigma = new Float32Array(n * 3); const base = 0.5 * Math.cbrt(0.02 / Math.max(1, n)); // ~volume per particle for (let i = 0; i < n; i++) { const s = base * (2.6 + rng() * 0.8); sigma[i * 3] = s; sigma[i * 3 + 1] = s; sigma[i * 3 + 2] = s; } // Colour is banded by height so deformation is legible: a uniform blob tells // you nothing about which part of it stretched. const color = new Float32Array(n * 3); let minY = Infinity, maxY = -Infinity; for (let i = 0; i < n; i++) { minY = Math.min(minY, pos[i * 3 + 1]); maxY = Math.max(maxY, pos[i * 3 + 1]); } for (let i = 0; i < n; i++) { const t = (pos[i * 3 + 1] - minY) / Math.max(1e-6, maxY - minY); color[i * 3] = 0.55 + 0.4 * t; color[i * 3 + 1] = 0.62 + 0.22 * Math.sin(t * 6.28); color[i * 3 + 2] = 0.95 - 0.45 * t; } return { positions: pos, sigma, color, count: n }; }