Skip to content

Commit 487a5c3

Browse files
jeremymanningclaude
andcommitted
perf(print): smooth 3D-preview drag via rAF coalescing + drop holes mid-drag
The preview tore down and rebuilt the entire SVG (~1500 nodes for a canonical room) on every pointermove. With trackpads firing move events at up to 1 kHz that meant tens of thousands of DOM ops per second. Two changes: - requestAnimationFrame coalesces pointermove + wheel into one render per display frame. - While a drag is active we skip the holes pass (~1000 dots with depth-scaled radius) and per-tile labels (~150 text nodes), keeping only the wireframe + tile rectangles. Full quality returns on pointerup. The helper text was updated so users know the stars hide briefly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2fb7ab0 commit 487a5c3

1 file changed

Lines changed: 26 additions & 5 deletions

File tree

src/ui/print-mode/preview-3d.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,7 @@ export function mount3dPreview(host: HTMLElement, register: RegisterRefresh): vo
438438
const helper = document.createElement("p");
439439
helper.className = "print-mode-helper";
440440
helper.textContent =
441-
"View your room from the observer's eye. Drag to rotate; scroll to dolly forward/back. Each amber rectangle is one paper sheet you'll tape up.";
441+
"View your room from the observer's eye. Drag to rotate; scroll to dolly forward/back. Each amber rectangle is one paper sheet you'll tape up. (Stars hide while you drag for smooth motion; release to see them again.)";
442442
panel.append(helper);
443443

444444
// ---- Toolbar ----
@@ -489,6 +489,18 @@ export function mount3dPreview(host: HTMLElement, register: RegisterRefresh): vo
489489
let dragPointer: number | null = null;
490490
let lastDragX = 0;
491491
let lastDragY = 0;
492+
// requestAnimationFrame coalescing — pointermove fires at 1 kHz on
493+
// some trackpads; we cap to display refresh and drop quality
494+
// (no holes, no labels) while a drag is active so each frame stays
495+
// under a few hundred DOM nodes instead of ~1500.
496+
let pendingRAF: number | null = null;
497+
function scheduleRender(): void {
498+
if (pendingRAF !== null) return;
499+
pendingRAF = requestAnimationFrame(() => {
500+
pendingRAF = null;
501+
render();
502+
});
503+
}
492504

493505
function setStatus(msg: string): void {
494506
status.textContent = msg;
@@ -562,7 +574,7 @@ export function mount3dPreview(host: HTMLElement, register: RegisterRefresh): vo
562574
const PITCH_LIMIT = Math.PI / 2 - 0.01;
563575
if (camera.pitch > PITCH_LIMIT) camera.pitch = PITCH_LIMIT;
564576
if (camera.pitch < -PITCH_LIMIT) camera.pitch = -PITCH_LIMIT;
565-
render();
577+
scheduleRender();
566578
});
567579
const endDrag = (ev: PointerEvent): void => {
568580
if (dragPointer !== ev.pointerId) return;
@@ -572,6 +584,8 @@ export function mount3dPreview(host: HTMLElement, register: RegisterRefresh): vo
572584
} catch {
573585
/* ignore */
574586
}
587+
// Re-render at full quality (with holes + labels) on release.
588+
render();
575589
};
576590
svg.addEventListener("pointerup", endDrag);
577591
svg.addEventListener("pointercancel", endDrag);
@@ -613,13 +627,18 @@ export function mount3dPreview(host: HTMLElement, register: RegisterRefresh): vo
613627
}
614628
camera.posX = Math.min(Math.max(camera.posX, xMin + 50), xMax - 50);
615629
camera.posY = Math.min(Math.max(camera.posY, yMin + 50), yMax - 50);
616-
render();
630+
scheduleRender();
617631
},
618632
{ passive: false },
619633
);
620634

621635
// ---- Render ----
622636
function render(): void {
637+
// While the user is actively dragging we draw a "fast preview":
638+
// wireframe + tile rectangles only. Holes (~1000 dots, depth-scaled
639+
// radius) and per-tile labels are skipped because they dominate the
640+
// per-frame cost. Full quality returns on pointerup.
641+
const isDragging = dragPointer !== null;
623642
while (svg.firstChild) svg.removeChild(svg.firstChild);
624643

625644
// Background fill.
@@ -716,7 +735,9 @@ export function mount3dPreview(host: HTMLElement, register: RegisterRefresh): vo
716735
];
717736
projectAndDraw(tileGroup, corners3d, "rgba(240,192,64,0.85)", 1, true);
718737

719-
// Tile label at centre.
738+
// Tile label at centre — skipped during drag (text nodes are
739+
// cheap individually but we have ~150 of them per frame).
740+
if (isDragging) continue;
720741
const cx = (b.uMinMm + b.uMaxMm) / 2;
721742
const cv = (b.vMinMm + b.vMaxMm) / 2;
722743
const centre = surfaceUVToWorld(surface, cx, cv);
@@ -743,7 +764,7 @@ export function mount3dPreview(host: HTMLElement, register: RegisterRefresh): vo
743764
holeGroup.setAttribute("class", "print-mode-preview-3d-holes");
744765
svg.append(holeGroup);
745766

746-
if (scene) {
767+
if (scene && !isDragging) {
747768
const surfaceById = new Map<string, Surface>();
748769
for (const s of allSurfaces) surfaceById.set(s.id, s);
749770
// Use a small set to dedupe holes appearing in multiple tiles

0 commit comments

Comments
 (0)