diff --git a/examples/splat-painter/index.html b/examples/splat-painter/index.html index 40f30b4..4b7b34c 100644 --- a/examples/splat-painter/index.html +++ b/examples/splat-painter/index.html @@ -64,6 +64,8 @@ SpzWriter, unpackSplat, PackedSplats, + RgbaArray, + readRgbaArray, } from "@sparkjsdev/spark"; import * as THREE from "three"; import { getAssetFileURL } from "/examples/js/get-asset-url.js"; @@ -74,6 +76,7 @@ controlsEnabled: true, eraseEnabled: dyno.dynoBool(false), brushEnabled: dyno.dynoBool(false), + undoEnabled: dyno.dynoBool(false), brushDepth: dyno.dynoFloat(10.0), brushRadius: dyno.dynoFloat(0.05), brushOrigin: dyno.dynoVec3(new THREE.Vector3(0.0, 0.0, 0.0)), @@ -90,15 +93,18 @@ const assetID = "greyscale-bedroom.spz"; let currentSplatMesh = null; let currentFileName = "painted-splat"; + let currentSplatMeshOriginalRGBA = null; function brushDyno( brushEnabled, eraseEnabled, + undoEnabled, brushRadius, brushDepth, brushOrigin, brushDirection, brushColor, + originalRgba, ) { const flatColor = dyno.dynoVec3(new THREE.Vector3(1.0, 1.0, 1.0)); const luminanceThreshold = dyno.dynoFloat(0.1); @@ -106,19 +112,29 @@ if (!gsplat) { throw new Error("No gsplat input"); } - let { center, rgb, opacity } = dyno.splitGsplat(gsplat).outputs; + let { center, rgb, opacity, index } = dyno.splitGsplat(gsplat).outputs; const projectionAmplitude = dyno.dot(brushDirection, dyno.sub(center, brushOrigin)); const projectedCenter = dyno.add(brushOrigin, dyno.mul(brushDirection, projectionAmplitude)); const distance = dyno.length(dyno.sub(projectedCenter, center)); // distance from projected center to actual center const isInside = dyno.and(dyno.lessThan(distance, brushRadius), dyno.and(dyno.greaterThan(projectionAmplitude, dyno.dynoFloat(0.0)), dyno.lessThan(projectionAmplitude, brushDepth))); + + // Paint/erase mode: change RGB or alpha values for splats inside brush const luminanceOld = dyno.div(dyno.dot(rgb, flatColor), dyno.dynoFloat(3.0)); const luminanceNew = dyno.div(dyno.dot(brushColor, flatColor), dyno.dynoFloat(3.0)); const weightedRgb = dyno.mul(brushColor, dyno.div(luminanceOld, luminanceNew)); const isLuminanceAboveThreshold = dyno.greaterThan(luminanceOld, luminanceThreshold); - const newRgb = dyno.select(dyno.and(dyno.and(brushEnabled, isInside), isLuminanceAboveThreshold), weightedRgb, rgb); - const newOpacity = dyno.select(eraseEnabled, dyno.select(isInside, dyno.dynoFloat(0.0), opacity), opacity); + let newRgb = dyno.select(dyno.and(dyno.and(brushEnabled, isInside), isLuminanceAboveThreshold), weightedRgb, rgb); + let newOpacity = dyno.select(eraseEnabled, dyno.select(isInside, dyno.dynoFloat(0.0), opacity), opacity); + + // Undo mode: restore original RGBA for splats inside brush + const originalRgbaValue = readRgbaArray(originalRgba, index); + const originalRgbVec = dyno.vec3(originalRgbaValue); + const originalOpacityVal = dyno.swizzle(originalRgbaValue, "w"); + newRgb = dyno.select(dyno.and(undoEnabled, isInside), originalRgbVec, newRgb); + newOpacity = dyno.select(dyno.and(undoEnabled, isInside), originalOpacityVal, newOpacity); + gsplat = dyno.combineGsplat({ gsplat, rgb: newRgb, opacity: newOpacity }); return { gsplat }; }); @@ -128,11 +144,13 @@ url, brushEnabled, eraseEnabled, + undoEnabled, brushRadius, brushDepth, brushOrigin, brushDirection, brushColor, + originalRgba, ) { const splatMesh = new SplatMesh({ url: url, @@ -143,11 +161,13 @@ splatMesh.worldModifier = brushDyno( brushEnabled, eraseEnabled, + undoEnabled, brushRadius, brushDepth, brushOrigin, brushDirection, brushColor, + originalRgba, ); splatMesh.updateGenerator(); return splatMesh; @@ -193,19 +213,50 @@ } // Extract filename for export currentFileName = url.split("/").pop().split("?")[0].split(".")[0] || "painted-splat"; + + // Create an empty RgbaArray that will be populated after the mesh loads + currentSplatMeshOriginalRGBA = new RgbaArray(); + currentSplatMesh = await paintableSplatMesh( url, PARAMETERS.brushEnabled, PARAMETERS.eraseEnabled, + PARAMETERS.undoEnabled, PARAMETERS.brushRadius, PARAMETERS.brushDepth, PARAMETERS.brushOrigin, PARAMETERS.brushDirection, - PARAMETERS.brushColor + PARAMETERS.brushColor, + currentSplatMeshOriginalRGBA.dyno ); currentSplatMesh.quaternion.set(1, 0, 0, 0); scene.add(currentSplatMesh); + // Wait for the mesh to fully load before accessing packed data + await currentSplatMesh.initialized; + + // Extract original RGBA directly from the packed splats + currentSplatMeshOriginalRGBA = new RgbaArray(); + currentSplatMeshOriginalRGBA.fromPackedSplats({ + packedSplats: currentSplatMesh.packedSplats, + base: 0, + count: currentSplatMesh.packedSplats.numSplats, + renderer: renderer + }); + + // Update the world modifier with the populated original RGBA + currentSplatMesh.worldModifier = brushDyno( + PARAMETERS.brushEnabled, + PARAMETERS.eraseEnabled, + PARAMETERS.undoEnabled, + PARAMETERS.brushRadius, + PARAMETERS.brushDepth, + PARAMETERS.brushOrigin, + PARAMETERS.brushDirection, + PARAMETERS.brushColor, + currentSplatMeshOriginalRGBA.dyno + ); + currentSplatMesh.updateGenerator(); } await loadSplatFromFile(await getAssetFileURL(assetID)); @@ -288,7 +339,8 @@ // Instructions section const instructions = { brush: "Brush Mode", - erase: "Erase Mode", + erase: "Erase Mode", + undo: "Undo Mode", none: "View Mode", increase: "Increase Brush Size", decrease: "Decrease Brush Size", @@ -298,6 +350,7 @@ const instructionsFolder = gui.addFolder("Instructions"); instructionsFolder.add(instructions, "brush").name("1:").disable(); instructionsFolder.add(instructions, "erase").name("2:").disable(); + instructionsFolder.add(instructions, "undo").name("3:").disable(); instructionsFolder.add(instructions, "none").name("Esc:").disable(); instructionsFolder.add(instructions, "increase").name("=:").disable(); instructionsFolder.add(instructions, "decrease").name("-:").disable(); @@ -481,6 +534,7 @@ // Brush mode PARAMETERS.brushEnabled.value = true; PARAMETERS.eraseEnabled.value = false; + PARAMETERS.undoEnabled.value = false; PARAMETERS.controlsEnabled = false; controls.enabled = false; showModeOverlay('Paint Mode'); @@ -488,13 +542,23 @@ // Eraser mode PARAMETERS.brushEnabled.value = false; PARAMETERS.eraseEnabled.value = true; + PARAMETERS.undoEnabled.value = false; PARAMETERS.controlsEnabled = false; controls.enabled = false; showModeOverlay('Erase Mode'); + } else if (event.key === '3') { + // Undo mode + PARAMETERS.brushEnabled.value = false; + PARAMETERS.eraseEnabled.value = false; + PARAMETERS.undoEnabled.value = true; + PARAMETERS.controlsEnabled = false; + controls.enabled = false; + showModeOverlay('Undo Mode'); } else if (event.key === 'Escape') { // View mode PARAMETERS.brushEnabled.value = false; PARAMETERS.eraseEnabled.value = false; + PARAMETERS.undoEnabled.value = false; PARAMETERS.controlsEnabled = true; controls.enabled = true; showModeOverlay('View Mode'); diff --git a/src/index.ts b/src/index.ts index 73047f5..8288360 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ export { SparkViewpoint, type SparkViewpointOptions } from "./SparkViewpoint"; export * as dyno from "./dyno"; -export { RgbaArray } from "./RgbaArray"; +export { RgbaArray, readRgbaArray } from "./RgbaArray"; export { SplatLoader,