From 1e2e4e4bc4c7baaea3443012025dee335c474673 Mon Sep 17 00:00:00 2001 From: David-Alexandre Chanel Date: Fri, 2 Oct 2026 16:04:04 +0200 Subject: [PATCH 01/12] Expose Pavel visual controls and per-emitter splat radius --- src/fluid.js | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 3 deletions(-) diff --git a/src/fluid.js b/src/fluid.js index 30e0691..f710f1a 100644 --- a/src/fluid.js +++ b/src/fluid.js @@ -70,6 +70,9 @@ if (!gl || !ext.formatRGBA || !ext.formatRG || !ext.formatR) { canvas, supported: false, clear () {}, + getConfig () { return {}; }, + randomSplats () {}, + setConfig () {}, update () {} }; } @@ -1021,7 +1024,7 @@ function applyEmitters (emitters) { const dx = correctDeltaX(Number(emitter.dx) || 0) * config.SPLAT_FORCE; const dy = correctDeltaY(Number(emitter.dy) || 0) * config.SPLAT_FORCE; const color = emitter.color || { r: 0, g: 0, b: 0 }; - splat(x, y, dx, dy, color); + splat(x, y, dx, dy, color, emitter.radius); } } @@ -1226,13 +1229,17 @@ function blur (target, temp, iterations) { } } -function splat (x, y, dx, dy, color) { +function splat (x, y, dx, dy, color, radiusOverride) { + const splatRadius = Number.isFinite(Number(radiusOverride)) + ? Number(radiusOverride) + : config.SPLAT_RADIUS; + splatProgram.bind(); gl.uniform1i(splatProgram.uniforms.uTarget, velocity.read.attach(0)); gl.uniform1f(splatProgram.uniforms.aspectRatio, canvas.width / canvas.height); gl.uniform2f(splatProgram.uniforms.point, x, y); gl.uniform3f(splatProgram.uniforms.color, dx, dy, 0.0); - gl.uniform1f(splatProgram.uniforms.radius, correctRadius(config.SPLAT_RADIUS / 100.0)); + gl.uniform1f(splatProgram.uniforms.radius, correctRadius(splatRadius / 100.0)); blit(velocity.write); velocity.swap(); @@ -1312,6 +1319,85 @@ function hashCode (s) { return hash; }; +function setConfig (next = {}) { + let reinitialize = false; + let updateDisplayKeywords = false; + + for (const [key, rawValue] of Object.entries(next)) { + if (!(key in config)) continue; + + const current = config[key]; + const value = typeof current === 'boolean' + ? Boolean(rawValue) + : Number(rawValue); + + if (typeof current !== 'boolean' && !Number.isFinite(value)) continue; + if (current === value) continue; + + config[key] = value; + + if (key === 'SIM_RESOLUTION' || key === 'DYE_RESOLUTION') + reinitialize = true; + if (key === 'SHADING' || key === 'BLOOM' || key === 'SUNRAYS') + updateDisplayKeywords = true; + } + + if (reinitialize) + initFramebuffers(); + if (updateDisplayKeywords) + updateKeywords(); +} + +function getConfig () { + return { ...config }; +} + +function randomSplats (amount = parseInt(Math.random() * 20) + 5) { + const count = Math.max(0, Math.floor(Number(amount) || 0)); + + for (let i = 0; i < count; i++) { + const color = generateColor(); + color.r *= 10.0; + color.g *= 10.0; + color.b *= 10.0; + const x = Math.random(); + const y = Math.random(); + const dx = 1000 * (Math.random() - 0.5); + const dy = 1000 * (Math.random() - 0.5); + splat(x, y, dx, dy, color); + } + + render(null); +} + +function generateColor () { + let c = HSVtoRGB(Math.random(), 1.0, 1.0); + c.r *= 0.15; + c.g *= 0.15; + c.b *= 0.15; + return c; +} + +function HSVtoRGB (h, s, v) { + let r, g, b, i, f, p, q, t; + i = Math.floor(h * 6); + f = h * 6 - i; + p = v * (1 - s); + q = v * (1 - f * s); + t = v * (1 - (1 - f) * s); + + switch (i % 6) { + case 0: r = v, g = t, b = p; break; + case 1: r = q, g = v, b = p; break; + case 2: r = p, g = v, b = t; break; + case 3: r = p, g = q, b = v; break; + case 4: r = t, g = p, b = v; break; + case 5: r = v, g = p, b = q; break; + } + + return { r, g, b }; +} + function clear () { gl.disable(gl.BLEND); const targets = [ @@ -1338,6 +1424,9 @@ return { canvas, supported: true, clear, + getConfig, + randomSplats, + setConfig, update }; } From 8c7f7c20813d5617c67b52addd7ee4ca0688f052 Mon Sep 17 00:00:00 2001 From: David-Alexandre Chanel Date: Fri, 2 Oct 2026 16:05:59 +0200 Subject: [PATCH 02/12] Add fluid presets and Augmenta splat input modes --- src/visuals.js | 370 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 304 insertions(+), 66 deletions(-) diff --git a/src/visuals.js b/src/visuals.js index 0dd26d5..25bfe74 100644 --- a/src/visuals.js +++ b/src/visuals.js @@ -7,14 +7,54 @@ const DEFAULT_SCENE = { maxY: 4 }; +const REFERENCE_DEFAULTS = Object.freeze({ + enabled: true, + splatInput: 'point-clouds', + dyeResolution: 1024, + simResolution: 128, + densityDissipation: 1, + velocityDissipation: 0.2, + pressure: 0.8, + curl: 30, + splatRadius: 0.25, + shading: true, + colorful: true, + paused: false, + bloom: true, + bloomIntensity: 0.8, + bloomThreshold: 0.6, + sunrays: true, + sunraysWeight: 1 +}); + const MAX_TOTAL_EMITTERS = 16; const MAX_EMITTERS_PER_CLOUD = 8; const MAX_QUEUED_EMITTERS = 32; const MAX_POINT_DELTA_UV = 0.08; const MIN_POINT_DELTA_UV = 0.00001; -const COLOR_UPDATE_SPEED = 10; const MIN_FRAME_DT = 1 / 120; const MAX_FRAME_DT = 1 / 10; +const COLOR_UPDATE_SPEED = 10; +const BOUNDING_BOX_RADIUS_MIN = 0.01; +const BOUNDING_BOX_RADIUS_MAX = 1; + +const FLUID_CONFIG_KEYS = Object.freeze({ + dyeResolution: 'DYE_RESOLUTION', + simResolution: 'SIM_RESOLUTION', + densityDissipation: 'DENSITY_DISSIPATION', + velocityDissipation: 'VELOCITY_DISSIPATION', + pressure: 'PRESSURE', + curl: 'CURL', + splatRadius: 'SPLAT_RADIUS', + shading: 'SHADING', + colorful: 'COLORFUL', + paused: 'PAUSED', + bloom: 'BLOOM', + bloomIntensity: 'BLOOM_INTENSITY', + bloomThreshold: 'BLOOM_THRESHOLD', + sunrays: 'SUNRAYS', + sunraysWeight: 'SUNRAYS_WEIGHT' +}); export function createVisuals(host) { const canvas = document.createElement('canvas'); @@ -24,12 +64,19 @@ export function createVisuals(host) { const fluid = safeCreateFluid(canvas); const views = new Map(); + const defaultOptions = effectiveDefaults(fluid.getConfig?.()); + const presets = Object.freeze({ + default: Object.freeze({ + label: 'Default', + options: Object.freeze({ ...defaultOptions }) + }) + }); let scene = { ...DEFAULT_SCENE }; + let options = { ...defaultOptions }; let pendingEmitters = []; let lastAnimationTime = performance.now(); let colorUpdateTimer = 0; - let enabled = true; function renderSetup(root, selectedSceneAddress) { const scenes = []; @@ -44,11 +91,13 @@ export function createVisuals(host) { function renderFrame(frame) { const sceneAddress = frame.getSceneInfo?.().getAddress?.() || ''; const now = performance.now(); - const clouds = []; + const objects = []; const active = new Set(); frame.getObjects?.().forEach((object, index) => { - if (!object.hasPointCloud?.()) return; + const hasCluster = Boolean(object.hasCluster?.()); + const hasPointCloud = Boolean(object.hasPointCloud?.()); + if (!hasCluster && !hasPointCloud) return; const id = object.getID?.(); const uuid = object.getUUID?.(); @@ -61,15 +110,18 @@ export function createVisuals(host) { views.set(key, view); } - const points = object.getPointCloud?.().getPointsData?.(); - if (!points?.length) return; - - let velocity = [0, 0, 0]; - if (object.hasCluster?.()) { - velocity = vector3(object.getCluster().getVelocity?.(), velocity); - } - - clouds.push({ view, points, velocity, now }); + const cluster = hasCluster ? object.getCluster() : undefined; + const cloud = hasPointCloud ? object.getPointCloud() : undefined; + const points = cloud?.getPointsData?.(); + + objects.push({ + view, + points, + centroid: cluster ? vector3(cluster.getCentroid?.(), undefined) : undefined, + size: cluster ? vector3(cluster.getBoundingBoxSize?.(), undefined) : undefined, + velocity: cluster ? vector3(cluster.getVelocity?.(), [0, 0, 0]) : [0, 0, 0], + now + }); }); for (const [key] of views) { @@ -78,15 +130,12 @@ export function createVisuals(host) { } } - if (!enabled || !clouds.length) return; + if (!options.enabled) return; - const perCloudBudget = emitterBudget(clouds.length); - const emitters = []; - - for (const cloud of clouds) { - emitters.push(...emittersForCloud(cloud, perCloudBudget, scene)); - } + const sources = emitterSources(objects, options.splatInput); + if (!sources.length) return; + const emitters = collectEmitters(sources, options.splatInput, scene); if (!emitters.length) return; pendingEmitters.push(...emitters); @@ -101,8 +150,8 @@ export function createVisuals(host) { updateColors(dt); - canvas.hidden = !enabled || !fluid.supported; - if (enabled && fluid.supported) { + canvas.hidden = !options.enabled || !fluid.supported; + if (options.enabled && fluid.supported) { const emitters = pendingEmitters; pendingEmitters = []; fluid.update(dt, emitters); @@ -114,6 +163,8 @@ export function createVisuals(host) { } function updateColors(dt) { + if (!options.colorful) return; + colorUpdateTimer += dt * COLOR_UPDATE_SPEED; if (colorUpdateTimer < 1) return; @@ -125,6 +176,41 @@ export function createVisuals(host) { } } + function setOptions(next = {}) { + const previousEnabled = options.enabled; + const previousInput = options.splatInput; + + options = { + ...options, + ...pickVisualOptions(next) + }; + + fluid.setConfig?.(fluidConfigFromOptions(next)); + + if (previousInput !== options.splatInput) resetInputTracking(); + if (previousEnabled !== options.enabled) reset(); + } + + function getOptions() { + return { ...options }; + } + + function getPreset(name) { + const preset = presets[name]; + return preset ? { label: preset.label, options: { ...preset.options } } : undefined; + } + + function getPresets() { + return Object.entries(presets).map(([name, preset]) => ({ + name, + label: preset.label + })); + } + + function randomSplats() { + fluid.randomSplats?.(); + } + function clearTracking() { views.clear(); pendingEmitters = []; @@ -136,22 +222,22 @@ export function createVisuals(host) { clearTracking(); } - function reset() { + function resetInputTracking() { pendingEmitters = []; - for (const view of views.values()) view.previousSamples = []; - fluid.clear(); + for (const view of views.values()) { + view.previousSamples = []; + view.previousCentroid = undefined; + view.lastFrameTime = undefined; + } } - function setOptions(next) { - if (typeof next?.enabled !== 'boolean') return; - - const wasEnabled = enabled; - enabled = next.enabled; - if (wasEnabled !== enabled) reset(); + function reset() { + resetInputTracking(); + fluid.clear(); } // Kept for compatibility with the Three.js-based application shell. - // Fluid input always comes from point clouds regardless of 3D display toggles. + // Debug display visibility is handled exclusively by viewer.js. function setVisibility() {} function setRightInset() {} @@ -160,6 +246,10 @@ export function createVisuals(host) { return { clearSetup, clearTracking, + getOptions, + getPreset, + getPresets, + randomSplats, renderFrame, renderSetup, reset, @@ -174,28 +264,47 @@ function createView(key) { key, colors: [], previousSamples: [], + previousCentroid: undefined, lastFrameTime: undefined }; } -function emittersForCloud({ view, points, velocity, now }, sampleCount, bounds) { +function emitterSources(objects, splatInput) { + const filtered = objects.filter((object) => { + if (splatInput === 'point-clouds') return object.points?.length; + return object.centroid && object.size; + }); + + return filtered.slice(0, MAX_TOTAL_EMITTERS); +} + +function collectEmitters(objects, splatInput, bounds) { + if (splatInput === 'point-clouds') { + const perCloudBudget = emitterBudget(objects.length); + return objects.flatMap((object) => ( + pointCloudEmitters(object, perCloudBudget, bounds) + )); + } + + return objects + .map((object) => centroidEmitter( + object, + bounds, + splatInput === 'bounding-box' + ? boundingBoxSplatRadius(object.size, bounds) + : undefined + )) + .filter(Boolean); +} + +function pointCloudEmitters({ view, points, velocity, now }, sampleCount, bounds) { const pointCount = Math.floor(points.length / 3); const indices = selectEmitterIndices(pointCount, sampleCount); - const frameDt = view.lastFrameTime === undefined - ? 1 / 30 - : clamp((now - view.lastFrameTime) / 1000, MIN_FRAME_DT, MAX_FRAME_DT); - view.lastFrameTime = now; + const frameDt = frameDelta(view, now); - while (view.colors.length < indices.length) view.colors.push(generateColor()); - view.colors.length = indices.length; - - const sceneWidth = Math.max(bounds.maxX - bounds.minX, 0.0001); - const sceneHeight = Math.max(bounds.maxY - bounds.minY, 0.0001); - const fallbackDelta = { - x: (Number(velocity[0]) || 0) * frameDt / sceneWidth, - y: (Number(velocity[1]) || 0) * frameDt / sceneHeight - }; + ensureColors(view, indices.length); + const fallbackDelta = velocityDelta(velocity, frameDt, bounds); const nextSamples = []; const emitters = []; @@ -209,28 +318,14 @@ function emittersForCloud({ view, points, velocity, now }, sampleCount, bounds) if (!uv || !insideUnitSquare(uv)) return; - const previous = view.previousSamples[slot]; - let dx = fallbackDelta.x; - let dy = fallbackDelta.y; - - if (previous) { - const pointDx = uv.x - previous.x; - const pointDy = uv.y - previous.y; - const pointDistance = Math.hypot(pointDx, pointDy); - - if (pointDistance <= MAX_POINT_DELTA_UV) { - dx = pointDx; - dy = pointDy; - } - } - - if (Math.hypot(dx, dy) <= MIN_POINT_DELTA_UV) return; + const delta = stableDelta(uv, view.previousSamples[slot], fallbackDelta); + if (Math.hypot(delta.x, delta.y) <= MIN_POINT_DELTA_UV) return; emitters.push({ x: uv.x, y: uv.y, - dx, - dy, + dx: delta.x, + dy: delta.y, color: view.colors[slot] }); }); @@ -239,6 +334,65 @@ function emittersForCloud({ view, points, velocity, now }, sampleCount, bounds) return emitters; } +function centroidEmitter({ view, centroid, velocity, now }, bounds, radius) { + const uv = projectPointToUv(centroid, bounds); + const frameDt = frameDelta(view, now); + const fallbackDelta = velocityDelta(velocity, frameDt, bounds); + + if (!uv || !insideUnitSquare(uv)) { + view.previousCentroid = uv; + return undefined; + } + + ensureColors(view, 1); + const delta = stableDelta(uv, view.previousCentroid, fallbackDelta); + view.previousCentroid = uv; + + if (Math.hypot(delta.x, delta.y) <= MIN_POINT_DELTA_UV) return undefined; + + return { + x: uv.x, + y: uv.y, + dx: delta.x, + dy: delta.y, + color: view.colors[0], + ...(Number.isFinite(radius) ? { radius } : {}) + }; +} + +function frameDelta(view, now) { + const dt = view.lastFrameTime === undefined + ? 1 / 30 + : clamp((now - view.lastFrameTime) / 1000, MIN_FRAME_DT, MAX_FRAME_DT); + view.lastFrameTime = now; + return dt; +} + +function velocityDelta(velocity, dt, bounds) { + const width = Math.max(bounds.maxX - bounds.minX, 0.0001); + const height = Math.max(bounds.maxY - bounds.minY, 0.0001); + + return { + x: (Number(velocity?.[0]) || 0) * dt / width, + y: (Number(velocity?.[1]) || 0) * dt / height + }; +} + +function stableDelta(current, previous, fallback) { + if (!previous) return fallback; + + const dx = current.x - previous.x; + const dy = current.y - previous.y; + if (Math.hypot(dx, dy) <= MAX_POINT_DELTA_UV) return { x: dx, y: dy }; + + return fallback; +} + +function ensureColors(view, count) { + while (view.colors.length < count) view.colors.push(generateColor()); + view.colors.length = count; +} + export function emitterBudget(cloudCount) { const count = Math.max(1, Number(cloudCount) || 1); return Math.max( @@ -276,6 +430,77 @@ export function projectPointToUv(point, bounds) { }; } +export function boundingBoxSplatRadius(size, bounds) { + if (!size || size.length < 2) return undefined; + + const width = Math.max(bounds.maxX - bounds.minX, 0.0001); + const height = Math.max(bounds.maxY - bounds.minY, 0.0001); + const widthUv = Math.abs(Number(size[0]) || 0) / width; + const heightUv = Math.abs(Number(size[1]) || 0) / height; + + // Pavel's splat radius is the Gaussian squared-distance denominator * 100. + // Use an area-equivalent projected radius so a tall human box does not + // explode into a full-screen splat while still scaling with box footprint. + const radiusUv = 0.5 * Math.sqrt(widthUv * heightUv); + return clamp( + radiusUv * radiusUv * 100, + BOUNDING_BOX_RADIUS_MIN, + BOUNDING_BOX_RADIUS_MAX + ); +} + +function effectiveDefaults(engineConfig = {}) { + return { + ...REFERENCE_DEFAULTS, + dyeResolution: finiteOr(engineConfig.DYE_RESOLUTION, REFERENCE_DEFAULTS.dyeResolution), + simResolution: finiteOr(engineConfig.SIM_RESOLUTION, REFERENCE_DEFAULTS.simResolution), + densityDissipation: finiteOr( + engineConfig.DENSITY_DISSIPATION, + REFERENCE_DEFAULTS.densityDissipation + ), + velocityDissipation: finiteOr( + engineConfig.VELOCITY_DISSIPATION, + REFERENCE_DEFAULTS.velocityDissipation + ), + pressure: finiteOr(engineConfig.PRESSURE, REFERENCE_DEFAULTS.pressure), + curl: finiteOr(engineConfig.CURL, REFERENCE_DEFAULTS.curl), + splatRadius: finiteOr(engineConfig.SPLAT_RADIUS, REFERENCE_DEFAULTS.splatRadius), + shading: booleanOr(engineConfig.SHADING, REFERENCE_DEFAULTS.shading), + colorful: booleanOr(engineConfig.COLORFUL, REFERENCE_DEFAULTS.colorful), + paused: booleanOr(engineConfig.PAUSED, REFERENCE_DEFAULTS.paused), + bloom: booleanOr(engineConfig.BLOOM, REFERENCE_DEFAULTS.bloom), + bloomIntensity: finiteOr( + engineConfig.BLOOM_INTENSITY, + REFERENCE_DEFAULTS.bloomIntensity + ), + bloomThreshold: finiteOr( + engineConfig.BLOOM_THRESHOLD, + REFERENCE_DEFAULTS.bloomThreshold + ), + sunrays: booleanOr(engineConfig.SUNRAYS, REFERENCE_DEFAULTS.sunrays), + sunraysWeight: finiteOr( + engineConfig.SUNRAYS_WEIGHT, + REFERENCE_DEFAULTS.sunraysWeight + ) + }; +} + +function pickVisualOptions(value) { + const result = {}; + for (const key of Object.keys(REFERENCE_DEFAULTS)) { + if (key in value) result[key] = value[key]; + } + return result; +} + +function fluidConfigFromOptions(value) { + const result = {}; + for (const [optionKey, configKey] of Object.entries(FLUID_CONFIG_KEYS)) { + if (optionKey in value) result[configKey] = value[optionKey]; + } + return result; +} + function sceneBounds(scenes, selectedSceneAddress) { const selected = selectedSceneAddress ? scenes.find((candidate) => candidate.getAddress?.() === selectedSceneAddress) @@ -343,6 +568,9 @@ function safeCreateFluid(canvas) { return { supported: false, clear() {}, + getConfig() { return {}; }, + randomSplats() {}, + setConfig() {}, update() {} }; } @@ -359,12 +587,22 @@ function walk(container, visit) { } function vector3(value, fallback) { - if (!value || value.length < 3) return [...fallback]; + if (!value || value.length < 3) return fallback ? [...fallback] : undefined; return [0, 1, 2].map((index) => ( - Number.isFinite(Number(value[index])) ? Number(value[index]) : fallback[index] + Number.isFinite(Number(value[index])) + ? Number(value[index]) + : fallback?.[index] )); } +function finiteOr(value, fallback) { + return Number.isFinite(Number(value)) ? Number(value) : fallback; +} + +function booleanOr(value, fallback) { + return typeof value === 'boolean' ? value : fallback; +} + function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } From b16f3eb5dc6efdaa3dece349a8efb428822127cf Mon Sep 17 00:00:00 2001 From: David-Alexandre Chanel Date: Fri, 2 Oct 2026 16:06:45 +0200 Subject: [PATCH 03/12] Add visual controls and move debug display into Augmenta data --- index.html | 204 ++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 162 insertions(+), 42 deletions(-) diff --git a/index.html b/index.html index 141e563..557b7d4 100644 --- a/index.html +++ b/index.html @@ -127,63 +127,161 @@

Augmenta Web Demo

-
+
- Display + Visuals - All scenes + Default
-
-
- - -
- + +
-
-
- - -
-
- - + + + +
+ + Simulation + Pavel defaults + +
+
+
+ + +
+
+ + +
+
+ + + + + + + + + + + +
+
+ + +
+
+ +
+
+ +
-
+
-
+
- Advanced - View helpers + Bloom + 0.8 -
- +
+ + + + +
- -
-
- - - - Visuals - - Fluid - +
+ + Sunrays + 1.0 + +
+ -
- -

PavelDoGreat defaults, driven by subsampled Augmenta point clouds.

+ +
+
+ +
+ + Augmenta + Point clouds + +
+
+ + +

Choose how Augmenta tracking drives fluid splats.

+
+
+
@@ -191,13 +289,35 @@

Augmenta Web Demo

- Live debug data + Augmenta data No data yet
-
+
+
+ + +
+ +
+ +
+
+ + + +
+
+ + +
+
+ +
From 5e6bb7cfb40ab3f87df80f4d987a7e92f61d6db9 Mon Sep 17 00:00:00 2001 From: David-Alexandre Chanel Date: Fri, 2 Oct 2026 16:09:11 +0200 Subject: [PATCH 04/12] Wire presets, Pavel controls, and Augmenta data settings --- src/main.js | 218 +++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 173 insertions(+), 45 deletions(-) diff --git a/src/main.js b/src/main.js index 87e831e..f731d7f 100644 --- a/src/main.js +++ b/src/main.js @@ -42,10 +42,20 @@ const ui = { debug: $('#debug-content'), clear: $('#clear'), resetCamera: $('#reset-camera'), scenes: $('#scenes'), sidebarToggle: $('#sidebar-toggle'), viewerTitle: $('.viewer-title'), connectionQrVisibility: $('.connection-qr-visibility'), connectionSection: $('#connection-section'), connectionAdvanced: $('#connection-advanced'), connectionAdvancedSummary: $('#connection-advanced-summary'), - displaySection: $('#display-section'), displayAdvanced: $('#display-advanced'), displaySectionSummary: $('#display-section-summary'), debugSection: $('#debug-section'), visualsSection: $('#visuals-section'), visualsSectionSummary: $('#visuals-section-summary'), appVersion: $('#app-version'), showClusters: $('#show-clusters'), showPoints: $('#show-points'), showScene: $('#show-scene'), showZones: $('#show-zones'), showVectors: $('#show-vectors'), - visualEnabled: $('#visual-enabled') + visualPreset: $('#visual-preset'), visualEnabled: $('#visual-enabled'), + visualDyeResolution: $('#visual-dye-resolution'), visualSimResolution: $('#visual-sim-resolution'), + visualDensityDissipation: $('#visual-density-dissipation'), visualDensityDissipationValue: $('#visual-density-dissipation-value'), + visualVelocityDissipation: $('#visual-velocity-dissipation'), visualVelocityDissipationValue: $('#visual-velocity-dissipation-value'), + visualPressure: $('#visual-pressure'), visualPressureValue: $('#visual-pressure-value'), + visualCurl: $('#visual-curl'), visualCurlValue: $('#visual-curl-value'), + visualSplatRadius: $('#visual-splat-radius'), visualSplatRadiusValue: $('#visual-splat-radius-value'), + visualShading: $('#visual-shading'), visualColorful: $('#visual-colorful'), visualPaused: $('#visual-paused'), + visualBloom: $('#visual-bloom'), visualBloomIntensity: $('#visual-bloom-intensity'), visualBloomIntensityValue: $('#visual-bloom-intensity-value'), + visualBloomThreshold: $('#visual-bloom-threshold'), visualBloomThresholdValue: $('#visual-bloom-threshold-value'), + visualSunrays: $('#visual-sunrays'), visualSunraysWeight: $('#visual-sunrays-weight'), visualSunraysWeightValue: $('#visual-sunrays-weight-value'), + visualRandomSplats: $('#visual-random-splats'), visualSplatInput: $('#visual-splat-input'), visualSplatInputSummary: $('#visual-splat-input-summary') }; ui.appVersion.textContent = `Version ${APP_VERSION}`; @@ -53,6 +63,7 @@ ui.appVersion.textContent = `Version ${APP_VERSION}`; const viewer = createViewer($('#canvas-host')); const visuals = createVisuals($('#canvas-host')); const debug = createDebugPanel(ui.summary, ui.debug); +const defaultVisualPreset = visuals.getPreset('default').options; let disconnectCleanupTimer; let cameraPreferenceSaveTimer; @@ -135,7 +146,7 @@ function savePreferences() { const value = { connection: localConnectionPreferences, - display: { + data: { clusters: ui.showClusters.checked, points: ui.showPoints.checked, sceneBounds: ui.showScene.checked, @@ -143,9 +154,7 @@ function savePreferences() { vectors: ui.showVectors.checked, scene: preferredSceneAddress }, - visuals: { - enabled: ui.visualEnabled.checked - }, + visuals: visualSettingsFromUi(), ui: { sidebarHidden: ui.app.classList.contains('sidebar-hidden'), sidebarWidth: Number.isFinite(sidebarWidth) ? sidebarWidth : SIDEBAR_MAX_WIDTH, @@ -153,10 +162,8 @@ function savePreferences() { sections: { connection: ui.connectionSection.open, connectionAdvanced: ui.connectionAdvanced.open, - display: ui.displaySection.open, - displayAdvanced: ui.displayAdvanced.open, visuals: ui.visualsSection.open, - debug: ui.debugSection.open + data: ui.debugSection.open } } }; @@ -186,7 +193,11 @@ function restorePreferences() { const savedPreferences = loadPreferences(); const connection = isObject(savedPreferences.connection) ? savedPreferences.connection : {}; const sharedConnection = readConnectionOptionsFromUrl(window.location.href); - const display = isObject(savedPreferences.display) ? savedPreferences.display : {}; + const dataPreferences = isObject(savedPreferences.data) + ? savedPreferences.data + : isObject(savedPreferences.display) + ? savedPreferences.display + : {}; const visualPreferences = isObject(savedPreferences.visuals) ? savedPreferences.visuals : {}; const uiPreferences = isObject(savedPreferences.ui) ? savedPreferences.ui : {}; const sectionPreferences = isObject(uiPreferences.sections) ? uiPreferences.sections : {}; @@ -198,37 +209,37 @@ function restorePreferences() { localConnectionPreferences = normalizeConnectionOptions(getConnectionSettings()); applyConnectionSettings(resolveConnectionOptions(localConnectionPreferences, sharedConnection)); - if (typeof display.clusters === 'boolean') ui.showClusters.checked = display.clusters; - if (typeof display.points === 'boolean') ui.showPoints.checked = display.points; - if (typeof display.sceneBounds === 'boolean') { - ui.showScene.checked = display.sceneBounds; - } else if (typeof display.zones === 'boolean') { + if (typeof dataPreferences.clusters === 'boolean') ui.showClusters.checked = dataPreferences.clusters; + if (typeof dataPreferences.points === 'boolean') ui.showPoints.checked = dataPreferences.points; + if (typeof dataPreferences.sceneBounds === 'boolean') { + ui.showScene.checked = dataPreferences.sceneBounds; + } else if (typeof dataPreferences.zones === 'boolean') { // Backward compatibility with the previous combined "Scene & zones" toggle. - ui.showScene.checked = display.zones; + ui.showScene.checked = dataPreferences.zones; } - if (typeof display.zones === 'boolean') ui.showZones.checked = display.zones; - if (typeof display.vectors === 'boolean') ui.showVectors.checked = display.vectors; - if (typeof display.scene === 'string' && display.scene) preferredSceneAddress = display.scene; - - if (typeof visualPreferences.enabled === 'boolean') { - ui.visualEnabled.checked = visualPreferences.enabled; + if (typeof dataPreferences.zones === 'boolean') ui.showZones.checked = dataPreferences.zones; + if (typeof dataPreferences.vectors === 'boolean') ui.showVectors.checked = dataPreferences.vectors; + if (typeof dataPreferences.scene === 'string' && dataPreferences.scene) { + preferredSceneAddress = dataPreferences.scene; } + writeVisualSettings({ + ...defaultVisualPreset, + ...visualPreferences + }); + const sidebarWidth = Number(uiPreferences.sidebarWidth); if (Number.isFinite(sidebarWidth)) setSidebarWidth(sidebarWidth); setSidebarHidden(uiPreferences.sidebarHidden === true, false); - for (const [key, element] of [ + for (const [key, element, legacyKey] of [ ['connection', ui.connectionSection], ['connectionAdvanced', ui.connectionAdvanced], - ['display', ui.displaySection], - ['displayAdvanced', ui.displayAdvanced], ['visuals', ui.visualsSection], - ['debug', ui.debugSection] + ['data', ui.debugSection, 'debug'] ]) { - if (typeof sectionPreferences[key] === 'boolean') { - element.open = sectionPreferences[key]; - } + const saved = sectionPreferences[key] ?? sectionPreferences[legacyKey]; + if (typeof saved === 'boolean') element.open = saved; } viewer.setCameraView(uiPreferences.cameraView); @@ -393,10 +404,7 @@ function zoneNameForAddress(address) { return parts.at(-1) || address || '—'; } -function updateDisplaySectionSummary() { - ui.displaySectionSummary.textContent = ui.scenes.selectedOptions[0]?.textContent - ?? (ui.scenes.disabled ? 'No scenes' : 'All scenes'); -} + function updateConnectionAdvancedSummary() { const protocolLabel = ui.protocol.selectedOptions[0]?.textContent?.split(' — ')[0] @@ -422,7 +430,6 @@ function syncSceneSelector() { || scenes.some((scene) => scene.getAddress() === requested); ui.scenes.value = stillAvailable ? requested : 'all'; ui.scenes.disabled = scenes.length === 0; - updateDisplaySectionSummary(); } function setSetup(root) { @@ -527,22 +534,105 @@ function toggleSimulation() { } function applyVisibility() { - const visibility = { + viewer.setVisibility({ clusters: ui.showClusters.checked, points: ui.showPoints.checked, scene: ui.showScene.checked, zones: ui.showZones.checked, vectors: ui.showVectors.checked + }); +} + +function visualSettingsFromUi() { + return { + enabled: ui.visualEnabled.checked, + splatInput: ui.visualSplatInput.value, + dyeResolution: Number(ui.visualDyeResolution.value), + simResolution: Number(ui.visualSimResolution.value), + densityDissipation: Number(ui.visualDensityDissipation.value), + velocityDissipation: Number(ui.visualVelocityDissipation.value), + pressure: Number(ui.visualPressure.value), + curl: Number(ui.visualCurl.value), + splatRadius: Number(ui.visualSplatRadius.value), + shading: ui.visualShading.checked, + colorful: ui.visualColorful.checked, + paused: ui.visualPaused.checked, + bloom: ui.visualBloom.checked, + bloomIntensity: Number(ui.visualBloomIntensity.value), + bloomThreshold: Number(ui.visualBloomThreshold.value), + sunrays: ui.visualSunrays.checked, + sunraysWeight: Number(ui.visualSunraysWeight.value) }; - viewer.setVisibility(visibility); - visuals.setVisibility(visibility); } -function applyVisualOptions() { - visuals.setOptions({ enabled: ui.visualEnabled.checked }); - ui.visualsSectionSummary.textContent = ui.visualEnabled.checked ? 'Fluid' : 'Off'; +function writeVisualSettings(settings) { + ui.visualEnabled.checked = settings.enabled; + ui.visualSplatInput.value = settings.splatInput; + ui.visualDyeResolution.value = String(settings.dyeResolution); + ui.visualSimResolution.value = String(settings.simResolution); + ui.visualDensityDissipation.value = String(settings.densityDissipation); + ui.visualVelocityDissipation.value = String(settings.velocityDissipation); + ui.visualPressure.value = String(settings.pressure); + ui.visualCurl.value = String(settings.curl); + ui.visualSplatRadius.value = String(settings.splatRadius); + ui.visualShading.checked = settings.shading; + ui.visualColorful.checked = settings.colorful; + ui.visualPaused.checked = settings.paused; + ui.visualBloom.checked = settings.bloom; + ui.visualBloomIntensity.value = String(settings.bloomIntensity); + ui.visualBloomThreshold.value = String(settings.bloomThreshold); + ui.visualSunrays.checked = settings.sunrays; + ui.visualSunraysWeight.value = String(settings.sunraysWeight); + updateVisualRangeOutputs(); +} + +function updateVisualRangeOutputs() { + for (const [input, output] of [ + [ui.visualDensityDissipation, ui.visualDensityDissipationValue], + [ui.visualVelocityDissipation, ui.visualVelocityDissipationValue], + [ui.visualPressure, ui.visualPressureValue], + [ui.visualCurl, ui.visualCurlValue], + [ui.visualSplatRadius, ui.visualSplatRadiusValue], + [ui.visualBloomIntensity, ui.visualBloomIntensityValue], + [ui.visualBloomThreshold, ui.visualBloomThresholdValue], + [ui.visualSunraysWeight, ui.visualSunraysWeightValue] + ]) { + output.value = input.value; + output.textContent = input.value; + } } +function visualSettingsEqual(left, right) { + return Object.keys(defaultVisualPreset).every((key) => { + const a = left[key]; + const b = right[key]; + return typeof a === 'number' && typeof b === 'number' + ? Math.abs(a - b) < 1e-9 + : a === b; + }); +} + +function updateVisualPresetState() { + const settings = visualSettingsFromUi(); + const isDefault = visualSettingsEqual(settings, defaultVisualPreset); + const customOption = ui.visualPreset.querySelector('option[value="custom"]'); + + customOption.hidden = isDefault; + customOption.disabled = isDefault; + ui.visualPreset.value = isDefault ? 'default' : 'custom'; + ui.visualsSectionSummary.textContent = settings.enabled + ? (isDefault ? 'Default' : 'Custom') + : 'Off'; + + ui.visualSplatInputSummary.textContent = + ui.visualSplatInput.selectedOptions[0]?.textContent ?? 'Point clouds'; +} + +function applyVisualOptions() { + updateVisualRangeOutputs(); + visuals.setOptions(visualSettingsFromUi()); + updateVisualPresetState(); +} viewer.setCameraChangeHandler(scheduleCameraPreferenceSave); ui.connect.addEventListener('click', toggleConnection); @@ -554,7 +644,6 @@ ui.resetCamera.addEventListener('click', () => { }); ui.scenes.addEventListener('change', () => { preferredSceneAddress = ui.scenes.value; - updateDisplaySectionSummary(); savePreferences(); renderSelectedScenes(); }); @@ -562,8 +651,6 @@ ui.scenes.addEventListener('change', () => { for (const section of [ ui.connectionSection, ui.connectionAdvanced, - ui.displaySection, - ui.displayAdvanced, ui.visualsSection ]) { section.addEventListener('toggle', savePreferences); @@ -755,17 +842,58 @@ ui.downsample.addEventListener('change', () => { savePreferences(); })); -ui.visualEnabled.addEventListener('change', () => { +const visualRangeInputs = [ + ui.visualDensityDissipation, + ui.visualVelocityDissipation, + ui.visualPressure, + ui.visualCurl, + ui.visualSplatRadius, + ui.visualBloomIntensity, + ui.visualBloomThreshold, + ui.visualSunraysWeight +]; + +const visualChangeInputs = [ + ui.visualEnabled, + ui.visualDyeResolution, + ui.visualSimResolution, + ui.visualShading, + ui.visualColorful, + ui.visualPaused, + ui.visualBloom, + ui.visualSunrays, + ui.visualSplatInput +]; + +for (const input of visualRangeInputs) { + input.addEventListener('input', applyVisualOptions); + input.addEventListener('change', savePreferences); +} + +for (const input of visualChangeInputs) { + input.addEventListener('change', () => { + applyVisualOptions(); + savePreferences(); + }); +} + +ui.visualPreset.addEventListener('change', () => { + const preset = visuals.getPreset(ui.visualPreset.value); + if (!preset) return; + + writeVisualSettings(preset.options); applyVisualOptions(); savePreferences(); }); +ui.visualRandomSplats.addEventListener('click', () => { + visuals.randomSplats(); +}); function syncPreferencesToUi() { restorePreferences(); applyVisibility(); applyVisualOptions(); updateConnectionAdvancedSummary(); - updateDisplaySectionSummary(); syncPanelCamera(false); refreshConnectionQr(); } From a21f514f73dd24f21301cbc1d925167038df26d1 Mon Sep 17 00:00:00 2001 From: David-Alexandre Chanel Date: Fri, 2 Oct 2026 16:09:36 +0200 Subject: [PATCH 05/12] Render Three.js debug data transparently above fluid visuals --- src/viewer.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/viewer.js b/src/viewer.js index d52b3cc..673f614 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -33,23 +33,26 @@ const PALETTE = [ export function createViewer(host) { const scene = new THREE.Scene(); - scene.background = new THREE.Color(0x0c0f14); + scene.background = null; scene.fog = new THREE.FogExp2(0x0c0f14, 0.014); const camera = new THREE.PerspectiveCamera(48, 1, 0.02, 500); - const renderer = new THREE.WebGLRenderer({ antialias: true }); + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setClearColor(0x000000, 0); renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.domElement.classList.add('debug-viewer-canvas'); host.appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); configureControls(controls); + const sceneHelperGroup = namedGroup(scene, 'Scene helpers'); const grid = new THREE.GridHelper(100, 100, 0x4d5668, 0x252b35); grid.material.transparent = true; grid.material.opacity = 0.52; grid.material.depthWrite = false; - scene.add(grid, new THREE.AxesHelper(1)); + sceneHelperGroup.add(grid, new THREE.AxesHelper(1)); const setupGroup = namedGroup(scene, 'Augmenta scene setup'); const clusterGroup = namedGroup(scene, 'Tracked clusters'); @@ -543,6 +546,7 @@ export function createViewer(host) { visibility.zones = zones; visibility.vectors = vectors; + sceneHelperGroup.visible = scene; clusterGroup.visible = clusters; pointGroup.visible = points; vectorGroup.visible = vectors; From 632a261d9cd817f2d28f04f48f7e67c59019459d Mon Sep 17 00:00:00 2001 From: David-Alexandre Chanel Date: Fri, 2 Oct 2026 16:10:03 +0200 Subject: [PATCH 06/12] Style fluid controls and transparent debug overlay --- src/styles.css | 90 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/src/styles.css b/src/styles.css index ef35ef2..0e2bd10 100644 --- a/src/styles.css +++ b/src/styles.css @@ -49,7 +49,7 @@ button, input, select { font: inherit; } #canvas-host .data-visuals { position: absolute; - z-index: 2; + z-index: 1; inset: 0; pointer-events: none; } @@ -58,6 +58,13 @@ button, input, select { font: inherit; } background: #000; } +#canvas-host .debug-viewer-canvas { + position: absolute; + z-index: 2; + inset: 0; + background: transparent; +} + .viewer-overlay { position: absolute; top: 0; @@ -68,6 +75,7 @@ button, input, select { font: inherit; } align-items: flex-start; justify-content: space-between; gap: 24px; + z-index: 8; pointer-events: none; background: linear-gradient(to bottom, rgba(9, 11, 15, .72), transparent); } @@ -427,6 +435,86 @@ button.subtle-button.active { -webkit-tap-highlight-color: transparent; } + +.visual-preset-field { + display: grid; + gap: 6px; + margin-bottom: var(--section-space-relaxed); +} + +.visual-master-toggle { + margin-bottom: var(--section-space); +} + +.visual-controls { + gap: var(--section-space-relaxed); +} + +.visual-control-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--section-space); +} + +.visual-range-control { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 5px 10px; + margin: 0; + color: #aab3c2; + font-size: 10px; + font-weight: 500; +} + +.visual-range-control > span { + min-width: 0; +} + +.visual-range-control output { + min-width: 34px; + color: #8791a2; + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 9.5px; + text-align: right; +} + +.visual-range-control input[type="range"] { + grid-column: 1 / -1; + width: 100%; + height: 16px; + min-height: 16px; + margin: 0; + padding: 0; + border: 0; + outline: none; + background: transparent; + box-shadow: none; + accent-color: var(--accent); +} + +.visual-range-control input[type="range"]:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 3px; +} + +.visual-toggle-grid { + margin-top: 2px; +} + +.visual-action-button { + width: 100%; +} + +.data-overlay-controls { + margin-bottom: var(--section-space-relaxed); +} + +.data-panel-actions { + margin-top: var(--section-space-relaxed); +} + .debug-content { display: grid; gap: 9px; } .empty-state { padding: 22px 14px; border: 1px dashed #303746; border-radius: 8px; color: #778194; text-align: center; font-size: 11px; line-height: 1.5; } From 6cb55d4cc9b3c39ce68d8351bbb3c39d8a916f09 Mon Sep 17 00:00:00 2001 From: David-Alexandre Chanel Date: Fri, 2 Oct 2026 16:11:18 +0200 Subject: [PATCH 07/12] Make visual preset selector data driven --- src/main.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main.js b/src/main.js index f731d7f..eb1698f 100644 --- a/src/main.js +++ b/src/main.js @@ -65,6 +65,8 @@ const visuals = createVisuals($('#canvas-host')); const debug = createDebugPanel(ui.summary, ui.debug); const defaultVisualPreset = visuals.getPreset('default').options; +populateVisualPresetOptions(); + let disconnectCleanupTimer; let cameraPreferenceSaveTimer; let sidebarHandleIdleTimer; @@ -543,6 +545,15 @@ function applyVisibility() { }); } +function populateVisualPresetOptions() { + ui.visualPreset.innerHTML = [ + ...visuals.getPresets().map(({ name, label }) => ( + `` + )), + '' + ].join(''); +} + function visualSettingsFromUi() { return { enabled: ui.visualEnabled.checked, From e5609989c969b1184d5e11a2c75f65e8a07502c6 Mon Sep 17 00:00:00 2001 From: David-Alexandre Chanel Date: Fri, 2 Oct 2026 16:11:49 +0200 Subject: [PATCH 08/12] Test visual controls, splat modes, and debug overlay --- tests/fluid-visuals.test.mjs | 67 ++++++++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/tests/fluid-visuals.test.mjs b/tests/fluid-visuals.test.mjs index c53b482..f228285 100644 --- a/tests/fluid-visuals.test.mjs +++ b/tests/fluid-visuals.test.mjs @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'; import test from 'node:test'; import { + boundingBoxSplatRadius, emitterBudget, projectPointToUv, selectEmitterIndices @@ -13,8 +14,7 @@ test('emitter budget stays bounded and shares capacity across clouds', () => { assert.equal(emitterBudget(2), 8); assert.equal(emitterBudget(3), 5); assert.equal(emitterBudget(6), 2); - assert.equal(emitterBudget(24), 1); - assert.equal(emitterBudget(100), 1); + assert.equal(emitterBudget(16), 1); }); test('point-cloud subsampling is deterministic and evenly distributed', () => { @@ -22,9 +22,9 @@ test('point-cloud subsampling is deterministic and evenly distributed', () => { assert.deepEqual(selectEmitterIndices(4, 12), [0, 1, 2, 3]); assert.deepEqual(selectEmitterIndices(0, 4), []); - const indices = selectEmitterIndices(1000, 12); - assert.equal(indices.length, 12); - assert.equal(new Set(indices).size, 12); + const indices = selectEmitterIndices(1000, 8); + assert.equal(indices.length, 8); + assert.equal(new Set(indices).size, 8); assert.ok(indices.every((index) => index >= 0 && index < 1000)); }); @@ -36,7 +36,15 @@ test('point positions map to fluid UV coordinates using scene bounds', () => { assert.equal(projectPointToUv([Number.NaN, 0, 0], bounds), undefined); }); -test('fluid engine preserves PavelDoGreat default visual parameters and removes pointer input', () => { +test('bounding-box splat radius follows projected box footprint', () => { + const bounds = { minX: -5, maxX: 5, minY: 0, maxY: 4 }; + + assert.equal(boundingBoxSplatRadius([0.5, 1.6, 0.5], bounds), 0.5); + assert.equal(boundingBoxSplatRadius([0.01, 0.01, 0.01], bounds), 0.01); + assert.equal(boundingBoxSplatRadius([10, 4, 1], bounds), 1); +}); + +test('fluid engine preserves Pavel defaults and exposes the GUI control API', () => { const source = readFileSync(new URL('../src/fluid.js', import.meta.url), 'utf8'); for (const expected of [ @@ -68,4 +76,51 @@ test('fluid engine preserves PavelDoGreat default visual parameters and removes assert.ok(!source.includes("canvas.addEventListener('mousedown'")); assert.ok(!source.includes("canvas.addEventListener('touchstart'")); assert.ok(source.includes('function applyEmitters (emitters)')); + assert.ok(source.includes('function setConfig (next = {})')); + assert.ok(source.includes('function randomSplats (amount =')); + assert.ok(source.includes('splat(x, y, dx, dy, color, emitter.radius)')); +}); + +test('Visuals exposes Pavel controls, presets, and Augmenta splat inputs', () => { + const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); + + for (const id of [ + 'visual-preset', + 'visual-dye-resolution', + 'visual-sim-resolution', + 'visual-density-dissipation', + 'visual-velocity-dissipation', + 'visual-pressure', + 'visual-curl', + 'visual-splat-radius', + 'visual-shading', + 'visual-colorful', + 'visual-paused', + 'visual-random-splats', + 'visual-bloom', + 'visual-bloom-intensity', + 'visual-bloom-threshold', + 'visual-sunrays', + 'visual-sunrays-weight', + 'visual-splat-input' + ]) { + assert.ok(html.includes(`id="${id}"`), `missing visual control: ${id}`); + } + + assert.ok(html.includes('')); + assert.ok(html.includes('')); + assert.ok(html.includes('')); + assert.ok(!html.includes('id="display-section"')); + assert.ok(html.includes('Augmenta data')); +}); + +test('Three.js debug renderer is transparent so selected data draws above fluid', () => { + const viewer = readFileSync(new URL('../src/viewer.js', import.meta.url), 'utf8'); + const styles = readFileSync(new URL('../src/styles.css', import.meta.url), 'utf8'); + + assert.ok(viewer.includes('new THREE.WebGLRenderer({ antialias: true, alpha: true })')); + assert.ok(viewer.includes("renderer.domElement.classList.add('debug-viewer-canvas')")); + assert.ok(viewer.includes('renderer.setClearColor(0x000000, 0)')); + assert.ok(viewer.includes('sceneHelperGroup.visible = scene')); + assert.ok(styles.includes('#canvas-host .debug-viewer-canvas')); }); From e01a06454828d9cb9959b36fb6094c10b37c228f Mon Sep 17 00:00:00 2001 From: David-Alexandre Chanel Date: Fri, 2 Oct 2026 16:11:58 +0200 Subject: [PATCH 09/12] Bump Web Demo to 0.4.0 --- src/app-info.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app-info.js b/src/app-info.js index a41a82e..47eec42 100644 --- a/src/app-info.js +++ b/src/app-info.js @@ -1 +1 @@ -export const APP_VERSION = '0.3.0'; +export const APP_VERSION = '0.4.0'; From cfcb87020d295f32e3673bb92149d1beea5ab317 Mon Sep 17 00:00:00 2001 From: David-Alexandre Chanel Date: Fri, 2 Oct 2026 16:12:16 +0200 Subject: [PATCH 10/12] Document visual controls, presets, and Augmenta data overlay --- README.md | 54 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 9395dbf..aa455f3 100644 --- a/README.md +++ b/README.md @@ -10,29 +10,47 @@ kept intact. ## Visuals -The default visual mode is now a direct adaptation of -[PavelDoGreat/WebGL-Fluid-Simulation](https://github.com/PavelDoGreat/WebGL-Fluid-Simulation) at upstream commit `a2d292931f19d9b3b9f564e23e6c32729d2121c3`. +The fluid renderer is a direct adaptation of +[PavelDoGreat/WebGL-Fluid-Simulation](https://github.com/PavelDoGreat/WebGL-Fluid-Simulation) +at upstream commit `a2d292931f19d9b3b9f564e23e6c32729d2121c3`. -The simulation keeps the reference project's default visual parameters and -rendering pipeline: 128 simulation resolution, 1024 dye resolution, density and -velocity dissipation, pressure solve, vorticity, shading, bloom, sunrays, and the -original dithering texture. +The Visuals panel exposes the same interactive parameters as the reference web +demo, except the Capture section: -The only intentional input change is the effector source: +- quality and simulation resolution +- density and velocity diffusion +- pressure, vorticity, and splat radius +- shading, colorful mode, pause, and random splats +- bloom enabled/intensity/threshold +- sunrays enabled/weight -- mouse and multitouch input are removed -- Augmenta point clouds are projected to the fluid canvas -- each cloud is evenly subsampled to a bounded number of emitters -- sampled point motion drives the same splat force path used by pointer motion -- emitter colors follow the reference simulation's colorful pointer behavior -- emitter count is shared across simultaneous point clouds to keep the effect - responsive and visually close to the original +The preset system currently ships with **Default**, which is the current +reference configuration. Any manual change is shown as **Custom**; additional +named presets can be added later without changing the control flow. -The fluid canvas is the primary visual when **Visuals → Fluid simulation** is -enabled. Disable it to reveal the original Three.js debug viewer underneath. +### Augmenta splat input -The adapted fluid engine lives in `src/fluid.js`; the point-cloud-to-emitter -bridge is isolated in `src/visuals.js`. +The **Visuals → Augmenta → Splat input** control selects how tracking drives the +same Pavel splat pipeline: + +- **Centroid** — one splat per tracked cluster, centered on its centroid +- **Point clouds** — evenly subsampled point-cloud points act as multiple splat + effectors +- **Bounding box** — one centroid splat whose radius is derived from the + projected bounding-box footprint + +Point and centroid motion provide the splat velocity; cluster velocity is used +as a fallback when point correspondence is not stable. + +## Augmenta data + +The former Display section and Live debug data section are combined as +**Augmenta data**. + +Scene selection and the Clusters, Point clouds, Scenes, Zones, and Velocity +vectors toggles use the original Three.js example renderer. Enabled overlays are +rendered transparently **above the fluid canvas**, so they can be used as live +debug information without replacing the visual simulation. ## Run From a1eb9a6f962e3d77204b2780307a92fbfbc750d4 Mon Sep 17 00:00:00 2001 From: David-Alexandre Chanel Date: Fri, 2 Oct 2026 16:13:55 +0200 Subject: [PATCH 11/12] Match current settings against any visual preset --- src/main.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/main.js b/src/main.js index eb1698f..d6d81e7 100644 --- a/src/main.js +++ b/src/main.js @@ -63,6 +63,7 @@ ui.appVersion.textContent = `Version ${APP_VERSION}`; const viewer = createViewer($('#canvas-host')); const visuals = createVisuals($('#canvas-host')); const debug = createDebugPanel(ui.summary, ui.debug); +const visualPresets = visuals.getPresets(); const defaultVisualPreset = visuals.getPreset('default').options; populateVisualPresetOptions(); @@ -547,7 +548,7 @@ function applyVisibility() { function populateVisualPresetOptions() { ui.visualPreset.innerHTML = [ - ...visuals.getPresets().map(({ name, label }) => ( + ...visualPresets.map(({ name, label }) => ( `` )), '' @@ -625,14 +626,18 @@ function visualSettingsEqual(left, right) { function updateVisualPresetState() { const settings = visualSettingsFromUi(); - const isDefault = visualSettingsEqual(settings, defaultVisualPreset); + const matchedPreset = visualPresets.find(({ name }) => { + const preset = visuals.getPreset(name); + return preset && visualSettingsEqual(settings, preset.options); + }); const customOption = ui.visualPreset.querySelector('option[value="custom"]'); + const isCustom = !matchedPreset; - customOption.hidden = isDefault; - customOption.disabled = isDefault; - ui.visualPreset.value = isDefault ? 'default' : 'custom'; + customOption.hidden = !isCustom; + customOption.disabled = !isCustom; + ui.visualPreset.value = matchedPreset?.name ?? 'custom'; ui.visualsSectionSummary.textContent = settings.enabled - ? (isDefault ? 'Default' : 'Custom') + ? (matchedPreset?.label ?? 'Custom') : 'Off'; ui.visualSplatInputSummary.textContent = From e18284b60c230462e88a0e50910c32eca8a0d650 Mon Sep 17 00:00:00 2001 From: David-Alexandre Chanel Date: Fri, 2 Oct 2026 16:14:46 +0200 Subject: [PATCH 12/12] Use tolerant assertion for derived splat radius --- tests/fluid-visuals.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fluid-visuals.test.mjs b/tests/fluid-visuals.test.mjs index f228285..29f963d 100644 --- a/tests/fluid-visuals.test.mjs +++ b/tests/fluid-visuals.test.mjs @@ -39,7 +39,7 @@ test('point positions map to fluid UV coordinates using scene bounds', () => { test('bounding-box splat radius follows projected box footprint', () => { const bounds = { minX: -5, maxX: 5, minY: 0, maxY: 4 }; - assert.equal(boundingBoxSplatRadius([0.5, 1.6, 0.5], bounds), 0.5); + assert.ok(Math.abs(boundingBoxSplatRadius([0.5, 1.6, 0.5], bounds) - 0.5) < 1e-9); assert.equal(boundingBoxSplatRadius([0.01, 0.01, 0.01], bounds), 0.01); assert.equal(boundingBoxSplatRadius([10, 4, 1], bounds), 1); });