|
60 | 60 | SparkRenderer, |
61 | 61 | SplatMesh, |
62 | 62 | RgbaArray, |
| 63 | + readRgbaArray, |
63 | 64 | SparkControls, |
| 65 | + SpzWriter, |
| 66 | + unpackSplat, |
| 67 | + PackedSplats, |
64 | 68 | } from "@sparkjsdev/spark"; |
65 | 69 | import * as THREE from "three"; |
66 | 70 | import { getAssetFileURL } from "../js/get-asset-url.js"; |
|
105 | 109 | const luminanceThreshold = dyno.dynoFloat(0.1); |
106 | 110 | return dyno.dynoBlock({ gsplat: dyno.Gsplat }, { gsplat: dyno.Gsplat }, ({ gsplat }) => { |
107 | 111 | if (!gsplat) { |
108 | | - throw new Error("No gsplat input"); |
| 112 | + throw new Error("No gsplat input"); |
109 | 113 | } |
110 | 114 | let { center, rgb, opacity, index } = dyno.splitGsplat(gsplat).outputs; |
111 | 115 | const projectionAmplitude = dyno.dot(brushDirection, dyno.sub(center, brushOrigin)); |
|
114 | 118 | const isInside = dyno.and(dyno.lessThan(distance, brushRadius), |
115 | 119 | dyno.and(dyno.greaterThan(projectionAmplitude, dyno.dynoFloat(0.0)), |
116 | 120 | dyno.lessThan(projectionAmplitude, brushDepth))); |
| 121 | +<<<<<<< HEAD |
117 | 122 |
|
| 123 | +======= |
| 124 | + |
| 125 | +>>>>>>> 1d58ed6 (Merge new commits from main into v2.0.0:) |
118 | 126 | // Paint/erase mode: change RGB or alpha values for splats inside brush |
119 | 127 | const luminanceOld = dyno.div(dyno.dot(rgb, flatColor), dyno.dynoFloat(3.0)); |
120 | 128 | const luminanceNew = dyno.div(dyno.dot(brushColor, flatColor), dyno.dynoFloat(3.0)); |
|
156 | 164 | splatMesh.worldModifier = brushDyno( |
157 | 165 | brushEnabled, |
158 | 166 | eraseEnabled, |
| 167 | + undoEnabled, |
159 | 168 | brushRadius, |
160 | 169 | brushDepth, |
161 | 170 | brushOrigin, |
162 | 171 | brushDirection, |
163 | 172 | brushColor, |
| 173 | + originalRgba, |
164 | 174 | ); |
165 | 175 | splatMesh.splatRgba = new RgbaArray().fromPackedSplats({ |
166 | 176 | packedSplats: splatMesh.packedSplats, |
|
212 | 222 | } |
213 | 223 | // Extract filename for export |
214 | 224 | currentFileName = url.split("/").pop().split("?")[0].split(".")[0] || "painted-splat"; |
215 | | - |
216 | 225 | // Create an empty RgbaArray that will be populated after the mesh loads |
217 | 226 | currentSplatMeshOriginalRGBA = new RgbaArray(); |
218 | | - |
219 | 227 | currentSplatMesh = await paintableSplatMesh( |
220 | 228 | url, |
221 | 229 | PARAMETERS.brushEnabled, |
|
230 | 238 | ); |
231 | 239 | currentSplatMesh.quaternion.set(1, 0, 0, 0); |
232 | 240 | scene.add(currentSplatMesh); |
| 241 | + |
| 242 | + // Wait for the mesh to fully load before accessing packed data |
| 243 | + await currentSplatMesh.initialized; |
233 | 244 |
|
| 245 | +<<<<<<< HEAD |
234 | 246 | // Wait for the mesh to fully load before accessing packed data |
235 | 247 | await currentSplatMesh.initialized; |
236 | 248 |
|
| 249 | +======= |
| 250 | +>>>>>>> 1d58ed6 (Merge new commits from main into v2.0.0:) |
237 | 251 | // Extract original RGBA directly from the packed splats |
238 | 252 | currentSplatMeshOriginalRGBA = new RgbaArray(); |
239 | 253 | currentSplatMeshOriginalRGBA.fromPackedSplats({ |
|
522 | 536 | }, |
523 | 537 | }; |
524 | 538 |
|
| 539 | + const ioFolder = gui.addFolder("I/O"); |
| 540 | + ioFolder.add(ioOptions, "loadFile").name("Load Splats (SPZ/PLY)"); |
| 541 | + ioFolder.add(ioOptions, "saveToSpz").name("Save Splats (SPZ)"); |
| 542 | + ioFolder.open(); |
| 543 | + |
| 544 | + |
| 545 | + // I/O functionality (load and export) |
| 546 | + const ioOptions = { |
| 547 | + filename: currentFileName, |
| 548 | + maxSh: 0, // Painted splats don't preserve SH data |
| 549 | + fractionalBits: 12, |
| 550 | + loadFile: () => { |
| 551 | + // Create file input element |
| 552 | + const input = document.createElement('input'); |
| 553 | + input.type = 'file'; |
| 554 | + input.accept = '.spz,.ply'; |
| 555 | + input.onchange = async (e) => { |
| 556 | + const file = e.target.files[0]; |
| 557 | + if (!file) return; |
| 558 | + |
| 559 | + // Create a blob URL from the file |
| 560 | + const url = URL.createObjectURL(file); |
| 561 | + |
| 562 | + try { |
| 563 | + await loadSplatFromFile(url); |
| 564 | + console.log("Loaded file:", file.name); |
| 565 | + // Update filename for export |
| 566 | + ioOptions.filename = file.name.split(".")[0] || "painted-splat"; |
| 567 | + } catch (error) { |
| 568 | + console.error("Error loading file:", error); |
| 569 | + alert("Failed to load file. Make sure it's a valid SPZ or PLY file."); |
| 570 | + } finally { |
| 571 | + // Clean up the object URL |
| 572 | + URL.revokeObjectURL(url); |
| 573 | + } |
| 574 | + }; |
| 575 | + input.click(); |
| 576 | + }, |
| 577 | + saveToSpz: async () => { |
| 578 | + if (!currentSplatMesh) { |
| 579 | + console.error("Export failed - currentSplatMesh:", !!currentSplatMesh); |
| 580 | + alert("No splat mesh loaded for export."); |
| 581 | + return; |
| 582 | + } |
| 583 | + |
| 584 | + try { |
| 585 | + console.log("Starting SPZ export with painted changes..."); |
| 586 | + |
| 587 | + if (!currentSplatMesh.splatRgba) { |
| 588 | + currentSplatMesh.splatRgba = spark.getRgba({ |
| 589 | + generator: currentSplatMesh, |
| 590 | + rgba: currentSplatMesh.splatRgba |
| 591 | + }); |
| 592 | + currentSplatMesh.updateGenerator(); |
| 593 | + } |
| 594 | + |
| 595 | + // const rgbaBytes = await spark.readRgba({ |
| 596 | + // generator: currentSplatMesh, |
| 597 | + // rgba: currentSplatMesh.splatRgba |
| 598 | + // }); |
| 599 | + const rgba = new RgbaArray(); |
| 600 | + rgba.render({ |
| 601 | + renderer, |
| 602 | + count: currentSplatMesh.packedSplats.numSplats, |
| 603 | + reader: dyno.dynoBlock({ index: "int" }, { rgba8: "vec4" }, ({ index }) => { |
| 604 | + const { gsplat } = currentSplatMesh.generator.apply({ index }); |
| 605 | + const { rgba } = dyno.splitGsplat(gsplat).outputs; |
| 606 | + return { rgba8: rgba }; |
| 607 | + }), |
| 608 | + }) |
| 609 | + const rgbaBytes = await rgba.read(); |
| 610 | + |
| 611 | + const ogSplats = currentSplatMesh.packedSplats; |
| 612 | + const totalSplats = ogSplats.numSplats; |
| 613 | + console.log("Total splats:", totalSplats); |
| 614 | + |
| 615 | + let nonZeroCount = 0; |
| 616 | + for (let i = 0; i < totalSplats; i++) { |
| 617 | + const opacity = rgbaBytes[i * 4 + 3] / 255; |
| 618 | + if (opacity > 0) { |
| 619 | + nonZeroCount++; |
| 620 | + } |
| 621 | + } |
| 622 | + |
| 623 | + // Create new PackedSplats with baked changes |
| 624 | + const newPackedSplats = new PackedSplats({ |
| 625 | + maxSplats: nonZeroCount, |
| 626 | + splatEncoding: ogSplats.splatEncoding, |
| 627 | + }); |
| 628 | + |
| 629 | + // Build splat array from baked RGBA |
| 630 | + let processedCount = 0; |
| 631 | + for (let i = 0; i < totalSplats; i++) { |
| 632 | + const rgbaOffset = i * 4; |
| 633 | + const opacity = rgbaBytes[rgbaOffset + 3] / 255; |
| 634 | + |
| 635 | + // Skip erased splats (zero opacity) |
| 636 | + if (opacity === 0) { |
| 637 | + continue; |
| 638 | + } |
| 639 | + |
| 640 | + // Unpack geometry from original packed array |
| 641 | + const unpacked = unpackSplat( |
| 642 | + ogSplats.packedArray, |
| 643 | + i, |
| 644 | + ogSplats.splatEncoding |
| 645 | + ); |
| 646 | + |
| 647 | + // Replace color/opacity with baked painted values |
| 648 | + unpacked.color.r = rgbaBytes[rgbaOffset + 0] / 255; |
| 649 | + unpacked.color.g = rgbaBytes[rgbaOffset + 1] / 255; |
| 650 | + unpacked.color.b = rgbaBytes[rgbaOffset + 2] / 255; |
| 651 | + unpacked.opacity = opacity; |
| 652 | + |
| 653 | + // Push to new PackedSplats |
| 654 | + newPackedSplats.pushSplat( |
| 655 | + unpacked.center, |
| 656 | + unpacked.scales, |
| 657 | + unpacked.quaternion, |
| 658 | + unpacked.opacity, |
| 659 | + unpacked.color |
| 660 | + ); |
| 661 | + |
| 662 | + processedCount++; |
| 663 | + } |
| 664 | + |
| 665 | + console.log(`Processed ${processedCount} splats`); |
| 666 | + |
| 667 | + // Now export the PackedSplats to SPZ |
| 668 | + console.log("Creating SPZ writer..."); |
| 669 | + const maxSh = ioOptions.maxSh; |
| 670 | + const spzWriter = new SpzWriter({ |
| 671 | + numSplats: nonZeroCount, |
| 672 | + shDegree: maxSh, |
| 673 | + fractionalBits: ioOptions.fractionalBits, |
| 674 | + flagAntiAlias: true, |
| 675 | + }); |
| 676 | + |
| 677 | + console.log("Writing splats to SPZ..."); |
| 678 | + // Iterate through the new packed array |
| 679 | + for (let i = 0; i < nonZeroCount; i++) { |
| 680 | + const unpacked = unpackSplat( |
| 681 | + newPackedSplats.packedArray, |
| 682 | + i, |
| 683 | + newPackedSplats.splatEncoding |
| 684 | + ); |
| 685 | + |
| 686 | + spzWriter.setCenter(i, unpacked.center.x, unpacked.center.y, unpacked.center.z); |
| 687 | + spzWriter.setScale(i, unpacked.scales.x, unpacked.scales.y, unpacked.scales.z); |
| 688 | + spzWriter.setQuat(i, unpacked.quaternion.x, unpacked.quaternion.y, unpacked.quaternion.z, unpacked.quaternion.w); |
| 689 | + spzWriter.setAlpha(i, unpacked.opacity); |
| 690 | + spzWriter.setRgb(i, unpacked.color.r, unpacked.color.g, unpacked.color.b); |
| 691 | + } |
| 692 | + const spzBytes = await spzWriter.finalize(); |
| 693 | + if (spzWriter.clippedCount > 0) { |
| 694 | + console.log(`Clipped ${spzWriter.clippedCount} splats. Consider decreasing fractional-bits from ${ioOptions.fractionalBits} to reduce clipping.`); |
| 695 | + } |
| 696 | + |
| 697 | + console.log("Creating download..."); |
| 698 | + const blob = new Blob([spzBytes], { type: "application/octet-stream" }); |
| 699 | + const url = URL.createObjectURL(blob); |
| 700 | + const a = document.createElement("a"); |
| 701 | + a.href = url; |
| 702 | + a.download = ioOptions.filename + "-painted.spz"; |
| 703 | + a.click(); |
| 704 | + URL.revokeObjectURL(url); |
| 705 | + |
| 706 | + console.log("SPZ file with painted changes downloaded successfully:", ioOptions.filename + "-painted.spz"); |
| 707 | + } catch (error) { |
| 708 | + console.error("Error exporting SPZ:", error); |
| 709 | + console.error("Error stack:", error.stack); |
| 710 | + } |
| 711 | + }, |
| 712 | + }; |
| 713 | + |
525 | 714 | const ioFolder = gui.addFolder("I/O"); |
526 | 715 | ioFolder.add(ioOptions, "loadFile").name("Load Splats (SPZ/PLY)"); |
527 | 716 | ioFolder.add(ioOptions, "saveToSpz").name("Save Splats (SPZ)"); |
|
530 | 719 |
|
531 | 720 | // Keyboard controls |
532 | 721 | window.addEventListener('keydown', (event) => { |
| 722 | +<<<<<<< HEAD |
533 | 723 | if (event.key === '1') { |
534 | 724 | // Brush mode |
535 | 725 | PARAMETERS.brushEnabled.value = true; |
|
554 | 744 | PARAMETERS.controlsEnabled = false; |
555 | 745 | controls.enabled = false; |
556 | 746 | showModeOverlay('Undo Mode'); |
| 747 | +======= |
| 748 | + if (event.key === '1') { |
| 749 | + // Brush mode |
| 750 | + PARAMETERS.brushEnabled.value = true; |
| 751 | + PARAMETERS.eraseEnabled.value = false; |
| 752 | + PARAMETERS.undoEnabled.value = false; |
| 753 | + PARAMETERS.controlsEnabled = false; |
| 754 | + controls.enabled = false; |
| 755 | + showModeOverlay('Paint Mode'); |
| 756 | + } else if (event.key === '2') { |
| 757 | + // Eraser mode |
| 758 | + PARAMETERS.brushEnabled.value = false; |
| 759 | + PARAMETERS.eraseEnabled.value = true; |
| 760 | + PARAMETERS.undoEnabled.value = false; |
| 761 | + PARAMETERS.controlsEnabled = false; |
| 762 | + controls.enabled = false; |
| 763 | + showModeOverlay('Erase Mode'); |
| 764 | + } else if (event.key === '3') { |
| 765 | + // Undo mode |
| 766 | + PARAMETERS.brushEnabled.value = false; |
| 767 | + PARAMETERS.eraseEnabled.value = false; |
| 768 | + PARAMETERS.undoEnabled.value = true; |
| 769 | + PARAMETERS.controlsEnabled = false; |
| 770 | + controls.enabled = false; |
| 771 | + showModeOverlay('Undo Mode'); |
| 772 | +>>>>>>> 1d58ed6 (Merge new commits from main into v2.0.0:) |
557 | 773 | } else if (event.key === 'Escape') { |
558 | 774 | // View mode |
559 | 775 | PARAMETERS.brushEnabled.value = false; |
|
0 commit comments