Skip to content

Commit 10a7541

Browse files
authored
add undo brush to splat painter example (#241)
* add undo mode to splat painter * cleanup undo mode with implicit typecast
1 parent bcea0a6 commit 10a7541

File tree

2 files changed

+70
-6
lines changed

2 files changed

+70
-6
lines changed

examples/splat-painter/index.html

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@
6464
SpzWriter,
6565
unpackSplat,
6666
PackedSplats,
67+
RgbaArray,
68+
readRgbaArray,
6769
} from "@sparkjsdev/spark";
6870
import * as THREE from "three";
6971
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
@@ -74,6 +76,7 @@
7476
controlsEnabled: true,
7577
eraseEnabled: dyno.dynoBool(false),
7678
brushEnabled: dyno.dynoBool(false),
79+
undoEnabled: dyno.dynoBool(false),
7780
brushDepth: dyno.dynoFloat(10.0),
7881
brushRadius: dyno.dynoFloat(0.05),
7982
brushOrigin: dyno.dynoVec3(new THREE.Vector3(0.0, 0.0, 0.0)),
@@ -90,35 +93,48 @@
9093
const assetID = "greyscale-bedroom.spz";
9194
let currentSplatMesh = null;
9295
let currentFileName = "painted-splat";
96+
let currentSplatMeshOriginalRGBA = null;
9397

9498
function brushDyno(
9599
brushEnabled,
96100
eraseEnabled,
101+
undoEnabled,
97102
brushRadius,
98103
brushDepth,
99104
brushOrigin,
100105
brushDirection,
101106
brushColor,
107+
originalRgba,
102108
) {
103109
const flatColor = dyno.dynoVec3(new THREE.Vector3(1.0, 1.0, 1.0));
104110
const luminanceThreshold = dyno.dynoFloat(0.1);
105111
return dyno.dynoBlock({ gsplat: dyno.Gsplat }, { gsplat: dyno.Gsplat }, ({ gsplat }) => {
106112
if (!gsplat) {
107113
throw new Error("No gsplat input");
108114
}
109-
let { center, rgb, opacity } = dyno.splitGsplat(gsplat).outputs;
115+
let { center, rgb, opacity, index } = dyno.splitGsplat(gsplat).outputs;
110116
const projectionAmplitude = dyno.dot(brushDirection, dyno.sub(center, brushOrigin));
111117
const projectedCenter = dyno.add(brushOrigin, dyno.mul(brushDirection, projectionAmplitude));
112118
const distance = dyno.length(dyno.sub(projectedCenter, center)); // distance from projected center to actual center
113119
const isInside = dyno.and(dyno.lessThan(distance, brushRadius),
114120
dyno.and(dyno.greaterThan(projectionAmplitude, dyno.dynoFloat(0.0)),
115121
dyno.lessThan(projectionAmplitude, brushDepth)));
122+
123+
// Paint/erase mode: change RGB or alpha values for splats inside brush
116124
const luminanceOld = dyno.div(dyno.dot(rgb, flatColor), dyno.dynoFloat(3.0));
117125
const luminanceNew = dyno.div(dyno.dot(brushColor, flatColor), dyno.dynoFloat(3.0));
118126
const weightedRgb = dyno.mul(brushColor, dyno.div(luminanceOld, luminanceNew));
119127
const isLuminanceAboveThreshold = dyno.greaterThan(luminanceOld, luminanceThreshold);
120-
const newRgb = dyno.select(dyno.and(dyno.and(brushEnabled, isInside), isLuminanceAboveThreshold), weightedRgb, rgb);
121-
const newOpacity = dyno.select(eraseEnabled, dyno.select(isInside, dyno.dynoFloat(0.0), opacity), opacity);
128+
let newRgb = dyno.select(dyno.and(dyno.and(brushEnabled, isInside), isLuminanceAboveThreshold), weightedRgb, rgb);
129+
let newOpacity = dyno.select(eraseEnabled, dyno.select(isInside, dyno.dynoFloat(0.0), opacity), opacity);
130+
131+
// Undo mode: restore original RGBA for splats inside brush
132+
const originalRgbaValue = readRgbaArray(originalRgba, index);
133+
const originalRgbVec = dyno.vec3(originalRgbaValue);
134+
const originalOpacityVal = dyno.swizzle(originalRgbaValue, "w");
135+
newRgb = dyno.select(dyno.and(undoEnabled, isInside), originalRgbVec, newRgb);
136+
newOpacity = dyno.select(dyno.and(undoEnabled, isInside), originalOpacityVal, newOpacity);
137+
122138
gsplat = dyno.combineGsplat({ gsplat, rgb: newRgb, opacity: newOpacity });
123139
return { gsplat };
124140
});
@@ -128,11 +144,13 @@
128144
url,
129145
brushEnabled,
130146
eraseEnabled,
147+
undoEnabled,
131148
brushRadius,
132149
brushDepth,
133150
brushOrigin,
134151
brushDirection,
135152
brushColor,
153+
originalRgba,
136154
) {
137155
const splatMesh = new SplatMesh({
138156
url: url,
@@ -143,11 +161,13 @@
143161
splatMesh.worldModifier = brushDyno(
144162
brushEnabled,
145163
eraseEnabled,
164+
undoEnabled,
146165
brushRadius,
147166
brushDepth,
148167
brushOrigin,
149168
brushDirection,
150169
brushColor,
170+
originalRgba,
151171
);
152172
splatMesh.updateGenerator();
153173
return splatMesh;
@@ -193,19 +213,50 @@
193213
}
194214
// Extract filename for export
195215
currentFileName = url.split("/").pop().split("?")[0].split(".")[0] || "painted-splat";
216+
217+
// Create an empty RgbaArray that will be populated after the mesh loads
218+
currentSplatMeshOriginalRGBA = new RgbaArray();
219+
196220
currentSplatMesh = await paintableSplatMesh(
197221
url,
198222
PARAMETERS.brushEnabled,
199223
PARAMETERS.eraseEnabled,
224+
PARAMETERS.undoEnabled,
200225
PARAMETERS.brushRadius,
201226
PARAMETERS.brushDepth,
202227
PARAMETERS.brushOrigin,
203228
PARAMETERS.brushDirection,
204-
PARAMETERS.brushColor
229+
PARAMETERS.brushColor,
230+
currentSplatMeshOriginalRGBA.dyno
205231
);
206232
currentSplatMesh.quaternion.set(1, 0, 0, 0);
207233
scene.add(currentSplatMesh);
208234

235+
// Wait for the mesh to fully load before accessing packed data
236+
await currentSplatMesh.initialized;
237+
238+
// Extract original RGBA directly from the packed splats
239+
currentSplatMeshOriginalRGBA = new RgbaArray();
240+
currentSplatMeshOriginalRGBA.fromPackedSplats({
241+
packedSplats: currentSplatMesh.packedSplats,
242+
base: 0,
243+
count: currentSplatMesh.packedSplats.numSplats,
244+
renderer: renderer
245+
});
246+
247+
// Update the world modifier with the populated original RGBA
248+
currentSplatMesh.worldModifier = brushDyno(
249+
PARAMETERS.brushEnabled,
250+
PARAMETERS.eraseEnabled,
251+
PARAMETERS.undoEnabled,
252+
PARAMETERS.brushRadius,
253+
PARAMETERS.brushDepth,
254+
PARAMETERS.brushOrigin,
255+
PARAMETERS.brushDirection,
256+
PARAMETERS.brushColor,
257+
currentSplatMeshOriginalRGBA.dyno
258+
);
259+
currentSplatMesh.updateGenerator();
209260
}
210261

211262
await loadSplatFromFile(await getAssetFileURL(assetID));
@@ -288,7 +339,8 @@
288339
// Instructions section
289340
const instructions = {
290341
brush: "Brush Mode",
291-
erase: "Erase Mode",
342+
erase: "Erase Mode",
343+
undo: "Undo Mode",
292344
none: "View Mode",
293345
increase: "Increase Brush Size",
294346
decrease: "Decrease Brush Size",
@@ -298,6 +350,7 @@
298350
const instructionsFolder = gui.addFolder("Instructions");
299351
instructionsFolder.add(instructions, "brush").name("1:").disable();
300352
instructionsFolder.add(instructions, "erase").name("2:").disable();
353+
instructionsFolder.add(instructions, "undo").name("3:").disable();
301354
instructionsFolder.add(instructions, "none").name("Esc:").disable();
302355
instructionsFolder.add(instructions, "increase").name("=:").disable();
303356
instructionsFolder.add(instructions, "decrease").name("-:").disable();
@@ -481,20 +534,31 @@
481534
// Brush mode
482535
PARAMETERS.brushEnabled.value = true;
483536
PARAMETERS.eraseEnabled.value = false;
537+
PARAMETERS.undoEnabled.value = false;
484538
PARAMETERS.controlsEnabled = false;
485539
controls.enabled = false;
486540
showModeOverlay('Paint Mode');
487541
} else if (event.key === '2') {
488542
// Eraser mode
489543
PARAMETERS.brushEnabled.value = false;
490544
PARAMETERS.eraseEnabled.value = true;
545+
PARAMETERS.undoEnabled.value = false;
491546
PARAMETERS.controlsEnabled = false;
492547
controls.enabled = false;
493548
showModeOverlay('Erase Mode');
549+
} else if (event.key === '3') {
550+
// Undo mode
551+
PARAMETERS.brushEnabled.value = false;
552+
PARAMETERS.eraseEnabled.value = false;
553+
PARAMETERS.undoEnabled.value = true;
554+
PARAMETERS.controlsEnabled = false;
555+
controls.enabled = false;
556+
showModeOverlay('Undo Mode');
494557
} else if (event.key === 'Escape') {
495558
// View mode
496559
PARAMETERS.brushEnabled.value = false;
497560
PARAMETERS.eraseEnabled.value = false;
561+
PARAMETERS.undoEnabled.value = false;
498562
PARAMETERS.controlsEnabled = true;
499563
controls.enabled = true;
500564
showModeOverlay('View Mode');

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ export { SparkViewpoint, type SparkViewpointOptions } from "./SparkViewpoint";
33

44
export * as dyno from "./dyno";
55

6-
export { RgbaArray } from "./RgbaArray";
6+
export { RgbaArray, readRgbaArray } from "./RgbaArray";
77

88
export {
99
SplatLoader,

0 commit comments

Comments
 (0)