import { Sculpt, defaults, pressureValue, saveProject, loadProject, exportOBJ, } from "./engine.js"; import { sphere } from "./mesh.js"; import { Renderer } from "./renderer.js"; import { Input } from "./input.js"; import { StrokeClient } from "./stroke-client.js"; import { clamp, clampZoom, cameraBasis } from "./math.js"; import { Menus } from "./menus.js"; import { Navigation } from "./navigation.js"; const $ = (id) => document.getElementById(id), viewport = $("viewport"); const settings = { ...defaults }, camera = { yaw: 0, pitch: 0, zoom: 1.5, target: [0, 0, 0], color: [0.5, 0.3, 0.2], }; let engine = new Sculpt(), renderer = null, initializing = false, pendingResize = false, frameRequest = 0, publishing = false; let viewRevision = 0, published = -1, generation = 0, lastSize = "", modified = false, saved = engine.mesh.positions.slice(), fileBusy = false; const brushes = ["draw", "inflate", "smooth", "flatten", "grab"]; let viewNavigation, cameraAspect, messageTimer; const input = new Input(viewport, { engine: () => engine, processor: () => renderer.processor, camera, settings, ready: () => !!renderer && !initializing && !fileBusy && !viewNavigation?.busy && sizeKey() === lastSize, blocked: () => !!viewNavigation?.busy, navigation: () => "sculpt", palmRejection: () => $("palm-rejection").checked, schedule, invalidate, changed: updateUI, committed: checkModified, message, cursor, pressure, }); const menus = new Menus( () => !interactionBusy(), (e) => e.target === viewport.querySelector("canvas") && input.rejectsTouch(e), ); viewNavigation = new Navigation($("view-navigation"), viewport, { camera, ready: () => !!renderer && !initializing && !fileBusy && !input.busy && sizeKey() === lastSize && !document.querySelector("dialog[open]"), start: () => { menus.close(false); cursor(null); }, changed: updateUI, invalidate, }); function interactionBusy() { return input.busy || !!viewNavigation?.busy; } $("palm-rejection").onchange = () => { if ($("palm-rejection").checked && input.active?.type === "touch") input.cancel(); }; function message(text) { $("status").textContent = text; $("status").classList.add("visible"); clearTimeout(messageTimer); messageTimer = setTimeout( () => $("status").classList.remove("visible"), 3500, ); } function checkModified() { modified = !engine.mesh.positions.every((v, i) => v === saved[i]); updateUI(); } function updateUI() { const busy = interactionBusy() || initializing || fileBusy; $("undo").disabled = busy || !engine.undoStack.length; $("redo").disabled = busy || !engine.redoStack.length; for (const id of ["new", "open", "quality"]) $(id).disabled = busy; for (const id of ["save", "export"]) $(id).disabled = interactionBusy() || fileBusy; $("modified").hidden = !modified; if (pendingResize && !resizeTimer && !interactionBusy() && !initializing) { queueMicrotask(() => { if (pendingResize && !resizeTimer) recreate(); }); } } function cursor(sample) { const el = $("cursor"); el.hidden = !sample || !renderer || initializing || !!viewNavigation?.busy; if (!sample) return; const s = engine.stroke?.settings ?? settings; const p = pressureValue(sample.pressure, s.gain, s.gamma), radius = (s.radius * (s.pressureRadius ? 0.25 + 0.75 * p : 1) * viewport.clientHeight) / (2 * camera.zoom); el.style.width = el.style.height = `${radius * 2}px`; el.style.left = `${sample.x}px`; el.style.top = `${sample.y}px`; } function pressure(value) { $("pen-state").textContent = value === null ? "Mouse / touch" : `Pen · ${Math.round(value * 100)}%`; $("pressure-fill").style.width = `${value === null ? 0 : pressureValue(value, settings.gain, settings.gamma) * 100}%`; } function invalidate() { viewRevision++; schedule(); } function schedule() { if (!frameRequest) frameRequest = requestAnimationFrame(tick); } function fail(error) { viewNavigation?.cancel(); generation++; renderer?.dispose(); renderer = null; engine.markAll(); input.cancel(); $("loading").hidden = true; $("error").hidden = false; console.error(error); $("error-detail").textContent = "The canvas could not start. Try again, or open this page in an up-to-date browser. You can still download your project in Settings."; message("Canvas paused. Your completed work is still here."); updateUI(); } function tick() { frameRequest = 0; viewNavigation.checkViewport(); viewNavigation.render(); input.checkViewport(); if ( renderer && !initializing && !publishing && (published !== viewRevision || engine.dirty.size) ) { const active = renderer, token = generation, revision = viewRevision; const snapshot = { ...camera, target: camera.target.slice(), color: camera.color.slice(), }; publishing = true; active .publish(engine, snapshot) .then(() => { if (token === generation) published = revision; }) .catch((error) => { if (token === generation) fail(error); }) .finally(() => { publishing = false; if ( renderer && !initializing && (published !== viewRevision || engine.dirty.size) ) schedule(); }); } } function sizeKey() { const rect = viewport.getBoundingClientRect(); return `${rect.width}:${rect.height}:${devicePixelRatio}:${$("quality").value}`; } async function recreate() { if (interactionBusy() || initializing) { pendingResize = true; return; } if (!viewport.clientWidth || !viewport.clientHeight) return; const aspect = viewport.clientWidth / viewport.clientHeight; if (cameraAspect) camera.zoom *= Math.min(1, cameraAspect) / Math.min(1, aspect); cameraAspect = aspect; clearTimeout(resizeTimer); resizeTimer = 0; pendingResize = false; initializing = true; const token = ++generation; lastSize = sizeKey(); renderer?.dispose(); renderer = null; $("loading").hidden = false; $("error").hidden = true; cursor(null); updateUI(); try { if (!crossOriginIsolated || typeof SharedArrayBuffer === "undefined") throw new Error("Cross-origin isolation is required"); if (!navigator.gpu) throw new Error("WebGPU is not available"); const canvas = document.createElement("canvas"); canvas.setAttribute("aria-label", "3D sculpt"); viewport.querySelector("canvas").replaceWith(canvas); const next = await Renderer.create( canvas, engine.mesh, camera, $("quality").value, ); if (token !== generation) { next.dispose(); return; } renderer = next; next.processor = new StrokeClient( next.sample, engine.mesh.level, (error) => { if (token === generation) fail(error); }, ); await next.processor.ready; if (token !== generation) { next.dispose(); return; } engine.markAll(); published = -1; $("loading").hidden = true; if (sizeKey() !== lastSize) resized(); else message("Ready to sculpt"); } catch (error) { if (token === generation) fail(error); } finally { initializing = false; updateUI(); if (renderer) invalidate(); } } function frameModel() { cameraAspect = viewport.clientWidth / viewport.clientHeight; const bounds = engine.bvh.root; camera.target = bounds.min.map((v, k) => (v + bounds.max[k]) / 2); const { right, up } = cameraBasis(camera.yaw, camera.pitch); const half = bounds.max.map((v, k) => (v - bounds.min[k]) / 2); const width = half.reduce((sum, v, k) => sum + v * Math.abs(right[k]), 0); const height = half.reduce((sum, v, k) => sum + v * Math.abs(up[k]), 0); camera.zoom = Math.max( 0.2, 1.45 * Math.max(height, (width * viewport.clientHeight) / viewport.clientWidth), ); invalidate(); } function replace(mesh, name) { engine = new Sculpt(mesh, 2 ** (mesh.level - 4) * 8 * 1048576); saved = mesh.positions.slice(); modified = false; $("document-name").textContent = name; camera.yaw = 0; camera.pitch = 0; frameModel(); recreate(); updateUI(); } function download(text, name, type) { const url = URL.createObjectURL(new Blob([text], { type })); const a = document.createElement("a"); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); } $("save").onclick = () => { if (interactionBusy() || fileBusy) return; download(saveProject(engine.mesh), "sculpt.yawn-sculpt", "application/json"); saved = engine.mesh.positions.slice(); modified = false; updateUI(); message("Project downloaded"); }; $("export").onclick = () => { if (interactionBusy() || fileBusy) return; download(exportOBJ(engine.mesh), "sculpt.obj", "text/plain"); message("Model downloaded"); }; $("open").onclick = () => { if (!interactionBusy() && !initializing) $("file").click(); }; $("file").onchange = async () => { const file = $("file").files[0]; $("file").value = ""; if (!file || interactionBusy() || initializing) return; fileBusy = true; updateUI(); try { if (file.size > 8 * 1048576) throw new Error("Project exceeds the 8 MiB limit"); const mesh = loadProject(await file.text()); if ( modified && !confirm( "Replace your current sculpt? Download it first if you want to keep it.", ) ) return; replace(mesh, file.name.replace(/\.yawn-sculpt$/, "")); } catch (error) { console.error(error); message( "Could not open this project. Choose a project downloaded from Sculpt.", ); } finally { fileBusy = false; updateUI(); } }; $("new").onclick = () => { if (interactionBusy() || initializing) return; $("new-warning").textContent = modified ? "This replaces your unsaved sculpt. Save it first to keep it." : "Choose how much detail you want."; $("new-dialog").returnValue = "cancel"; $("new-dialog").showModal(); }; $("new-dialog").addEventListener("close", () => { if ($("new-dialog").returnValue === "create") replace(sphere(Number($("density").value)), "Untitled sphere"); }); $("help").onclick = () => $("help-dialog").showModal(); $("undo").onclick = () => { if (!interactionBusy() && engine.restore()) { checkModified(); invalidate(); message("Undo"); } }; $("redo").onclick = () => { if (!interactionBusy() && engine.restore(true)) { checkModified(); invalidate(); message("Redo"); } }; $("frame").onclick = () => { if (!interactionBusy()) frameModel(); }; function chooseBrush(name) { if (interactionBusy()) return; settings.brush = name; document .querySelectorAll("[data-brush]") .forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.brush === name)), ); $("brush-title").textContent = name[0].toUpperCase() + name.slice(1); $("invert").disabled = !["draw", "inflate"].includes(name); } document .querySelectorAll("[data-brush]") .forEach((b) => (b.onclick = () => chooseBrush(b.dataset.brush))); function settingsUI() { for (const id of ["radius", "strength", "gain", "gamma"]) { settings[id] = Number($(id).value); $(id).style.setProperty( "--slider-value", `${(100 * (settings[id] - Number($(id).min))) / (Number($(id).max) - Number($(id).min))}%`, ); $(id + "-value").textContent = id === "radius" ? `${Math.round((settings[id] / 0.6) * 100)}%` : id === "strength" ? `${Math.round(settings[id] * 100)}%` : settings[id].toFixed(2) + (id === "gain" ? "×" : ""); } for (const id of ["symmetry", "invert", "pressureRadius"]) settings[id] = $(id).checked; $("curve-line").setAttribute( "d", Array.from( { length: 41 }, (_, i) => `${i ? "L" : "M"}${i * 5} ${53 - 52 * pressureValue(i / 40, settings.gain, settings.gamma)}`, ).join(" "), ); } for (const id of [ "radius", "strength", "gain", "gamma", "symmetry", "invert", "pressureRadius", ]) $(id).addEventListener("input", settingsUI); $("reset-pressure").onclick = () => { $("gain").value = "1"; $("gamma").value = "1"; $("pressureRadius").checked = false; settingsUI(); }; document.querySelectorAll("[data-color]").forEach( (b) => (b.onclick = () => { camera.color = b.dataset.color.split(",").map(Number); document .querySelectorAll("[data-color]") .forEach((x) => x.setAttribute("aria-pressed", String(x === b))); invalidate(); }), ); $("fullscreen").hidden = !document.fullscreenEnabled; $("fullscreen").onclick = async () => { try { if (document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); } catch { message("Full screen isn't available in this browser."); } }; document.addEventListener("fullscreenchange", () => { $("fullscreen").textContent = document.fullscreenElement ? "Exit full screen" : "Full screen"; }); $("quality").onchange = () => { pendingResize = true; recreate(); }; $("retry").onclick = recreate; let resizeTimer; function resized() { viewNavigation.checkViewport(); clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { resizeTimer = 0; if (sizeKey() !== lastSize) { pendingResize = true; recreate(); } }, 200); const unchanged = input.checkViewport(); if (sizeKey() !== lastSize) { cursor(null); if (unchanged) message("Updating viewport…"); } } new ResizeObserver(resized).observe(viewport); window.addEventListener("resize", resized); function watchDpr() { const query = matchMedia(`(resolution: ${devicePixelRatio}dppx)`); query.addEventListener( "change", () => { resized(); watchDpr(); }, { once: true }, ); } watchDpr(); window.addEventListener("beforeunload", (e) => { if (modified || engine.stroke) { e.preventDefault(); e.returnValue = ""; } }); document.addEventListener("visibilitychange", () => { if (document.hidden) input.cancel(); const active = renderer, token = generation; if (!active) return; (document.hidden ? active.core.pause() : active.core.play()) .then(() => { if (token === generation && !document.hidden) invalidate(); }) .catch((error) => { if (token === generation) fail(error); }); }); window.addEventListener("keydown", (e) => { if (e.key === "Escape" && !document.querySelector("dialog[open]")) { viewNavigation.cancel(); input.cancel(); return; } if ( document.querySelector("dialog[open]") || e.target.closest?.(".panel,.tool-rail,#view-navigation") || /INPUT|SELECT|TEXTAREA/.test(e.target.tagName) || e.target.isContentEditable ) return; if (interactionBusy()) return; const key = e.key.toLowerCase(), mod = e.ctrlKey || e.metaKey; if (mod && ["z", "s", "o", "y"].includes(key)) { e.preventDefault(); $( key === "s" ? "save" : key === "o" ? "open" : key === "y" || e.shiftKey ? "redo" : "undo", ).click(); return; } if (mod || e.altKey) return; if (/^[1-5]$/.test(key)) chooseBrush(brushes[Number(key) - 1]); else if (key === "f") $("frame").click(); else if (key === "?") $("help").click(); else if (key === "[" || key === "]") { $("radius").value = String( clamp(settings.radius + (key === "]" ? 0.01 : -0.01), 0.04, 0.6), ); settingsUI(); } else if ( ["arrowleft", "arrowright", "arrowup", "arrowdown", "+", "=", "-"].includes( key, ) ) { e.preventDefault(); if (key === "arrowleft") camera.yaw -= 0.1; else if (key === "arrowright") camera.yaw += 0.1; else if (key === "arrowup") camera.pitch = clamp(camera.pitch + 0.1, -Math.PI / 2, Math.PI / 2); else if (key === "arrowdown") camera.pitch = clamp(camera.pitch - 0.1, -Math.PI / 2, Math.PI / 2); else camera.zoom = clampZoom( camera.zoom * (key === "-" ? 1.1 : 0.9), viewport.getBoundingClientRect(), ); invalidate(); } }); settingsUI(); frameModel(); updateUI(); recreate();