|
64 | 64 | SpzWriter, |
65 | 65 | unpackSplat, |
66 | 66 | PackedSplats, |
| 67 | + RgbaArray, |
| 68 | + readRgbaArray, |
67 | 69 | } from "@sparkjsdev/spark"; |
68 | 70 | import * as THREE from "three"; |
69 | 71 | import { getAssetFileURL } from "/examples/js/get-asset-url.js"; |
|
74 | 76 | controlsEnabled: true, |
75 | 77 | eraseEnabled: dyno.dynoBool(false), |
76 | 78 | brushEnabled: dyno.dynoBool(false), |
| 79 | + undoEnabled: dyno.dynoBool(false), |
77 | 80 | brushDepth: dyno.dynoFloat(10.0), |
78 | 81 | brushRadius: dyno.dynoFloat(0.05), |
79 | 82 | brushOrigin: dyno.dynoVec3(new THREE.Vector3(0.0, 0.0, 0.0)), |
|
90 | 93 | const assetID = "greyscale-bedroom.spz"; |
91 | 94 | let currentSplatMesh = null; |
92 | 95 | let currentFileName = "painted-splat"; |
| 96 | + let currentSplatMeshOriginalRGBA = null; |
93 | 97 |
|
94 | 98 | function brushDyno( |
95 | 99 | brushEnabled, |
96 | 100 | eraseEnabled, |
| 101 | + undoEnabled, |
97 | 102 | brushRadius, |
98 | 103 | brushDepth, |
99 | 104 | brushOrigin, |
100 | 105 | brushDirection, |
101 | 106 | brushColor, |
| 107 | + originalRgba, |
102 | 108 | ) { |
103 | 109 | const flatColor = dyno.dynoVec3(new THREE.Vector3(1.0, 1.0, 1.0)); |
104 | 110 | const luminanceThreshold = dyno.dynoFloat(0.1); |
105 | 111 | return dyno.dynoBlock({ gsplat: dyno.Gsplat }, { gsplat: dyno.Gsplat }, ({ gsplat }) => { |
106 | 112 | if (!gsplat) { |
107 | 113 | throw new Error("No gsplat input"); |
108 | 114 | } |
109 | | - let { center, rgb, opacity } = dyno.splitGsplat(gsplat).outputs; |
| 115 | + let { center, rgb, opacity, index } = dyno.splitGsplat(gsplat).outputs; |
110 | 116 | const projectionAmplitude = dyno.dot(brushDirection, dyno.sub(center, brushOrigin)); |
111 | 117 | const projectedCenter = dyno.add(brushOrigin, dyno.mul(brushDirection, projectionAmplitude)); |
112 | 118 | const distance = dyno.length(dyno.sub(projectedCenter, center)); // distance from projected center to actual center |
113 | 119 | const isInside = dyno.and(dyno.lessThan(distance, brushRadius), |
114 | 120 | dyno.and(dyno.greaterThan(projectionAmplitude, dyno.dynoFloat(0.0)), |
115 | 121 | dyno.lessThan(projectionAmplitude, brushDepth))); |
| 122 | + |
| 123 | + // Paint/erase mode: change RGB or alpha values for splats inside brush |
116 | 124 | const luminanceOld = dyno.div(dyno.dot(rgb, flatColor), dyno.dynoFloat(3.0)); |
117 | 125 | const luminanceNew = dyno.div(dyno.dot(brushColor, flatColor), dyno.dynoFloat(3.0)); |
118 | 126 | const weightedRgb = dyno.mul(brushColor, dyno.div(luminanceOld, luminanceNew)); |
119 | 127 | 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 | + |
122 | 138 | gsplat = dyno.combineGsplat({ gsplat, rgb: newRgb, opacity: newOpacity }); |
123 | 139 | return { gsplat }; |
124 | 140 | }); |
|
128 | 144 | url, |
129 | 145 | brushEnabled, |
130 | 146 | eraseEnabled, |
| 147 | + undoEnabled, |
131 | 148 | brushRadius, |
132 | 149 | brushDepth, |
133 | 150 | brushOrigin, |
134 | 151 | brushDirection, |
135 | 152 | brushColor, |
| 153 | + originalRgba, |
136 | 154 | ) { |
137 | 155 | const splatMesh = new SplatMesh({ |
138 | 156 | url: url, |
|
143 | 161 | splatMesh.worldModifier = brushDyno( |
144 | 162 | brushEnabled, |
145 | 163 | eraseEnabled, |
| 164 | + undoEnabled, |
146 | 165 | brushRadius, |
147 | 166 | brushDepth, |
148 | 167 | brushOrigin, |
149 | 168 | brushDirection, |
150 | 169 | brushColor, |
| 170 | + originalRgba, |
151 | 171 | ); |
152 | 172 | splatMesh.updateGenerator(); |
153 | 173 | return splatMesh; |
|
193 | 213 | } |
194 | 214 | // Extract filename for export |
195 | 215 | 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 | + |
196 | 220 | currentSplatMesh = await paintableSplatMesh( |
197 | 221 | url, |
198 | 222 | PARAMETERS.brushEnabled, |
199 | 223 | PARAMETERS.eraseEnabled, |
| 224 | + PARAMETERS.undoEnabled, |
200 | 225 | PARAMETERS.brushRadius, |
201 | 226 | PARAMETERS.brushDepth, |
202 | 227 | PARAMETERS.brushOrigin, |
203 | 228 | PARAMETERS.brushDirection, |
204 | | - PARAMETERS.brushColor |
| 229 | + PARAMETERS.brushColor, |
| 230 | + currentSplatMeshOriginalRGBA.dyno |
205 | 231 | ); |
206 | 232 | currentSplatMesh.quaternion.set(1, 0, 0, 0); |
207 | 233 | scene.add(currentSplatMesh); |
208 | 234 |
|
| 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(); |
209 | 260 | } |
210 | 261 |
|
211 | 262 | await loadSplatFromFile(await getAssetFileURL(assetID)); |
|
288 | 339 | // Instructions section |
289 | 340 | const instructions = { |
290 | 341 | brush: "Brush Mode", |
291 | | - erase: "Erase Mode", |
| 342 | + erase: "Erase Mode", |
| 343 | + undo: "Undo Mode", |
292 | 344 | none: "View Mode", |
293 | 345 | increase: "Increase Brush Size", |
294 | 346 | decrease: "Decrease Brush Size", |
|
298 | 350 | const instructionsFolder = gui.addFolder("Instructions"); |
299 | 351 | instructionsFolder.add(instructions, "brush").name("1:").disable(); |
300 | 352 | instructionsFolder.add(instructions, "erase").name("2:").disable(); |
| 353 | + instructionsFolder.add(instructions, "undo").name("3:").disable(); |
301 | 354 | instructionsFolder.add(instructions, "none").name("Esc:").disable(); |
302 | 355 | instructionsFolder.add(instructions, "increase").name("=:").disable(); |
303 | 356 | instructionsFolder.add(instructions, "decrease").name("-:").disable(); |
|
481 | 534 | // Brush mode |
482 | 535 | PARAMETERS.brushEnabled.value = true; |
483 | 536 | PARAMETERS.eraseEnabled.value = false; |
| 537 | + PARAMETERS.undoEnabled.value = false; |
484 | 538 | PARAMETERS.controlsEnabled = false; |
485 | 539 | controls.enabled = false; |
486 | 540 | showModeOverlay('Paint Mode'); |
487 | 541 | } else if (event.key === '2') { |
488 | 542 | // Eraser mode |
489 | 543 | PARAMETERS.brushEnabled.value = false; |
490 | 544 | PARAMETERS.eraseEnabled.value = true; |
| 545 | + PARAMETERS.undoEnabled.value = false; |
491 | 546 | PARAMETERS.controlsEnabled = false; |
492 | 547 | controls.enabled = false; |
493 | 548 | 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'); |
494 | 557 | } else if (event.key === 'Escape') { |
495 | 558 | // View mode |
496 | 559 | PARAMETERS.brushEnabled.value = false; |
497 | 560 | PARAMETERS.eraseEnabled.value = false; |
| 561 | + PARAMETERS.undoEnabled.value = false; |
498 | 562 | PARAMETERS.controlsEnabled = true; |
499 | 563 | controls.enabled = true; |
500 | 564 | showModeOverlay('View Mode'); |
|
0 commit comments