Back to Safe Sand · Material studies
SOURCE / PINNED RELEASE

Made of little things.

Safe Sand · Material studies

Release
9101a9cd9fb4…
Author-recorded commit
75d98e1b8edb…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-7682e55a791

Archive hash verified: 6c8ddabeb31100e1…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/main.ts
import { storage, media, identity } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { Sandbox } from './engine/sandbox.js';
import { PRESETS, PRESET_ORDER } from './engine/presets.js';
import { parseSetup, type Setup } from './setup.js';
import './styles.css';

interface Material { name: string; swatch: string; description: string; trait: string; surface: string }
const materials = PRESETS as unknown as Record<string, Material>;
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const app = $('app');
const canvas = $<HTMLCanvasElement>('sand');
const dialog = $<HTMLDialogElement>('materialsDialog');
const storageKey = 'safe-sand.setup.v1';
// App-owned material and UI colors remain stable across host theme changes.
const FOLLOW_HOST_THEME = false;
app.dataset.themePolicy = FOLLOW_HOST_THEME ? 'host' : 'app';
let sandbox: Sandbox | null = null;
let statusTimer = 0;
let destroyed = false;
let saveBusy = false;
let offscreen = false;
let tiltPointer: number | null = null;

function status(message: string) {
  $('status').textContent = message;
  clearTimeout(statusTimer);
  statusTimer = window.setTimeout(() => { $('status').textContent = ''; }, 4500);
}

function fail(error: unknown) {
  $('errorMessage').textContent = error instanceof Error ? error.message : String(error);
  $('errorPanel').hidden = false;
  app.dataset.ready = 'false';
}

function sync() {
  if (!sandbox) return;
  const m = materials[sandbox.material];
  $('materialName').textContent = m.name;
  $('materialTrait').textContent = m.trait.toUpperCase();
  const hints: Record<string, string> = { push: 'Drag a finger through the grains.', pour: 'Hold to pour more grains.', rake: 'Drag to comb fine lines.', erase: 'Brush away a path.', magnet: 'Gather iron around your touch.', repel: 'Push iron away from your touch.', camera: 'Drag to orbit the tray.' };
  $('materialHint').textContent = hints[sandbox.tool];
  $('materialDescription').textContent = m.description;
  $('currentSwatch').style.backgroundColor = m.swatch;
  app.dataset.material = sandbox.material;
  app.dataset.surface = sandbox.surface;
  app.dataset.tool = sandbox.tool;
  for (const button of document.querySelectorAll<HTMLButtonElement>('[data-material]')) {
    if (button.tagName === 'BUTTON') button.setAttribute('aria-pressed', String(button.dataset.material === sandbox.material));
  }
  for (const button of document.querySelectorAll<HTMLButtonElement>('button[data-surface]')) {
    button.setAttribute('aria-pressed', String(button.dataset.surface === sandbox.surface));
  }
  $('pauseButton').setAttribute('aria-pressed', String(sandbox.paused));
  $('pauseButton').title = `${sandbox.paused ? 'Resume' : 'Pause'} simulation (P)`;
  $('pauseText').textContent = sandbox.paused ? 'Play' : 'Pause';
  $('pauseIcon').textContent = sandbox.paused ? '▷' : 'Ⅱ';
  $('stateBadge').hidden = !sandbox.paused;
  $('toolText').textContent = sandbox.tool === 'camera' ? 'Orbit' : sandbox.tool[0].toUpperCase() + sandbox.tool.slice(1);
  for (const button of document.querySelectorAll<HTMLButtonElement>('button[data-tool]')) {
    button.setAttribute('aria-pressed', String(button.dataset.tool === sandbox.tool));
    button.disabled = ['magnet', 'repel'].includes(button.dataset.tool!) && sandbox.material !== 'iron';
  }
  $<HTMLButtonElement>('undoButton').disabled = !sandbox.undoState;
  $<HTMLButtonElement>('stepButton').disabled = !sandbox.paused;
  $<HTMLSelectElement>('speed').value = String(sandbox.speed);
  $<HTMLInputElement>('pourRate').value = String(sandbox.flowRate);
  $('pourRateValue').textContent = `${Math.round(sandbox.flowRate / 1000)}k/s`;
  $<HTMLInputElement>('pourHeight').value = String(sandbox.pourHeight);
  $('pourHeightValue').textContent = String(sandbox.pourHeight);
  syncCamera();
  $<HTMLInputElement>('brush').value = String(sandbox.radius);
  $('brushValue').textContent = String(sandbox.radius);
  $<HTMLSelectElement>('amount').value = String(sandbox.amount);
  const sound = Boolean(sandbox.soundOn);
  const soundButton = $('soundButton');
  soundButton.setAttribute('aria-pressed', String(sound));
  soundButton.setAttribute('aria-label', sound ? 'Turn sound off' : 'Turn sound on');
  soundButton.title = !runtimeHasDomain('media') ? 'This host does not support sound' : sound ? 'Turn sound off' : 'Turn sound on';
  soundButton.querySelector('path')!.setAttribute('d', sound ? 'M4 9h4l5-4v14l-5-4H4zM17 8q5 4 0 8m3-11q8 7 0 14' : 'M4 9h4l5-4v14l-5-4H4zM17 9l4 6m0-6-4 6');
}

function boot() {
  try {
    destroyed = true;
    sandbox?.dispose();
    destroyed = false;
    sandbox = new Sandbox(canvas, runtimeHasDomain('media') ? media : undefined, () => sync());
    sandbox.radius = 4.5;
    $('errorPanel').hidden = true;
    app.dataset.ready = 'true';
    sync();
    updateVisibility();
  } catch (error) { fail(error); }
}

for (const [index, key] of PRESET_ORDER.entries()) {
  const m = materials[key];
  const button = document.createElement('button');
  button.className = 'material';
  button.dataset.material = key;
  button.setAttribute('aria-pressed', String(key === 'beach'));
  button.title = m.description;
  const swatch = document.createElement('span');
  swatch.className = 'sample';
  swatch.style.setProperty('--swatch', m.swatch);
  swatch.setAttribute('aria-hidden', 'true');
  const label = document.createElement('span'); label.textContent = m.name;
  button.append(swatch, label);
  button.addEventListener('click', () => {
    if (!sandbox) return;
    sandbox.setMaterial(key);
    sandbox.setSurface(m.surface);
    sync();
    // A settled preview stays visible behind the picker; the pour plays on return.
    status(`${m.name} · ${m.trait.toLowerCase()}`);
  });
  $(index < 5 ? 'originalMaterials' : 'newMaterials').append(button);
}

const hasStorage = runtimeHasDomain('storage');
$<HTMLButtonElement>('soundButton').disabled = !runtimeHasDomain('media');
if (!runtimeHasDomain('media')) $('soundButton').title = 'This host does not support sound';
let identityListener: { close(): void } | undefined;
if (runtimeHasDomain('identity')) {
  try { identityListener = identity.onChanged(() => { sandbox?.resetSound(); sync(); }); } catch { /* Optional host subscription. */ }
}
$<HTMLButtonElement>('saveButton').disabled = !hasStorage;
$<HTMLButtonElement>('restoreButton').disabled = !hasStorage;
$('storageStatus').textContent = hasStorage ? 'Setup saves are available in this host.' : 'This host has no saved setups. All tray tools still work.';
$('storageStatus').dataset.domain = hasStorage ? 'storage: available' : 'storage: absent';

async function storageAction(action: 'save' | 'restore') {
  if (!sandbox || saveBusy || !runtimeHasDomain('storage')) return;
  saveBusy = true;
  $<HTMLButtonElement>('saveButton').disabled = true;
  $<HTMLButtonElement>('restoreButton').disabled = true;
  $('storageStatus').textContent = action === 'save' ? 'Saving setup…' : 'Restoring setup…';
  let timer = 0;
  try {
    const work = async () => {
      if (!sandbox) return;
      if (action === 'save') {
        const setup: Setup = { version: 1, material: sandbox.material, surface: sandbox.surface as Setup['surface'], radius: sandbox.radius, amount: sandbox.amount,
          tool: sandbox.tool, speed: sandbox.speed, pitch: sandbox.camera.pitch, yaw: sandbox.camera.yaw, flowRate: sandbox.flowRate, pourHeight: sandbox.pourHeight };
        await storage.setItem(storageKey, JSON.stringify(setup));
      } else {
        const raw = await storage.getItem(storageKey);
        if (!raw) throw new Error('No saved setup yet. Choose a material and save it first.');
        return parseSetup(raw, PRESET_ORDER);
      }
    };
    const setup = await Promise.race([work(), new Promise<never>((_, reject) => {
      timer = window.setTimeout(() => reject(new Error('Storage did not respond. Your tray is still usable.')), 5000);
    })]);
    if (setup) {
      sandbox.radius = setup.radius; sandbox.amount = setup.amount;
      sandbox.setMaterial(setup.material); sandbox.setSurface(setup.surface);
      sandbox.speed = setup.speed ?? 1; sandbox.flowRate = setup.flowRate ?? 12000; sandbox.pourHeight = setup.pourHeight ?? 12;
      sandbox.setCamera(setup.pitch ?? 0, setup.yaw ?? 0); sandbox.setTool(setup.tool ?? 'push'); sync();
    }
    $('storageStatus').textContent = action === 'save' ? 'Setup saved in this host.' : 'Saved setup restored.';
  } catch (error) {
    $('storageStatus').textContent = `${action === 'save' ? 'Setup was not saved. ' : ''}${error instanceof Error ? error.message : 'Storage is unavailable.'}`;
  } finally {
    clearTimeout(timer); saveBusy = false;
    $<HTMLButtonElement>('saveButton').disabled = false;
    $<HTMLButtonElement>('restoreButton').disabled = false;
  }
}

function closeMaterials() { dialog.close(); }
$('materialsButton').addEventListener('click', () => { closePanels(); sandbox?.clearInput(); dialog.showModal(); });
$('closeMaterials').addEventListener('click', closeMaterials);
$('returnButton').addEventListener('click', closeMaterials);
dialog.addEventListener('close', () => { canvas.focus({ preventScroll: true }); });
dialog.addEventListener('click', e => {
  if (e.target !== dialog) return;
  const r = dialog.getBoundingClientRect();
  if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) closeMaterials();
});
$('saveButton').addEventListener('click', () => { void storageAction('save'); });
$('restoreButton').addEventListener('click', () => { void storageAction('restore'); });
$('clearButton').addEventListener('click', () => { sandbox?.clear(); sync(); closeMaterials(); status('An empty tray. Try the Pour tool.'); });
$('pourButton').addEventListener('click', () => { sandbox?.pour(); sync(); status('Freshly poured. Make a little mess.'); });
$('pauseButton').addEventListener('click', () => { if (sandbox) sandbox.setPaused(!sandbox.paused); sync(); });
$('shakeButton').addEventListener('click', () => { if (sandbox?.paused) status('Resume the tray to shake.'); else sandbox?.shake(); });
$('toolButton').addEventListener('click', () => {
  const open = $('toolsPanel').hidden; closePanels();
  $('toolsPanel').hidden = !open; $('toolButton').setAttribute('aria-expanded', String(open));
});
function closePanels() {
  for (const [panel, button] of [['toolsPanel', 'toolButton'], ['cameraPanel', 'cameraButton'], ['tiltPanel', 'tiltButton']]) {
    $(panel).hidden = true; $(button).setAttribute('aria-expanded', 'false');
  }
  resetPad();
}
function selectTool(tool: string) {
  sandbox?.setTool(tool); sync(); closePanels(); canvas.focus({ preventScroll: true });
  if (sandbox?.tool === 'pour') status('Hold to pour, or focus the tray and hold F.');
  if (sandbox?.tool === 'camera') status('Drag to orbit. Select Push to sculpt again.');
}
for (const button of document.querySelectorAll<HTMLButtonElement>('button[data-tool]')) button.addEventListener('click', () => selectTool(button.dataset.tool!));
$('closeTools').addEventListener('click', closePanels);
$('cameraButton').addEventListener('click', () => {
  const open = $('cameraPanel').hidden; closePanels();
  $('cameraPanel').hidden = !open; $('cameraButton').setAttribute('aria-expanded', String(open));
});
$('closeCamera').addEventListener('click', closePanels);
function syncCamera() {
  if (!sandbox) return;
  $<HTMLInputElement>('cameraPitch').value = String(Math.round(sandbox.camera.pitch));
  $<HTMLInputElement>('cameraYaw').value = String(Math.round(sandbox.camera.yaw));
  $('pitchValue').textContent = `${Math.round(sandbox.camera.pitch)}°`;
  $('yawValue').textContent = `${Math.round(sandbox.camera.yaw)}°`;
}
for (const id of ['cameraPitch', 'cameraYaw']) $(id).addEventListener('input', () => {
  sandbox?.setCamera(Number($<HTMLInputElement>('cameraPitch').value), Number($<HTMLInputElement>('cameraYaw').value)); syncCamera();
});
$('overheadButton').addEventListener('click', () => { sandbox?.setCamera(0, 0); syncCamera(); });
$('angledButton').addEventListener('click', () => { sandbox?.setCamera(28, -18); syncCamera(); });
$('orbitButton').addEventListener('click', () => selectTool('camera'));
$('undoButton').addEventListener('click', () => { if (sandbox?.undo()) status('Last edit undone.'); sync(); });
$('stepButton').addEventListener('click', () => { sandbox?.singleStep(); sync(); });
$('speed').addEventListener('change', () => { if (sandbox) sandbox.speed = Number($<HTMLSelectElement>('speed').value); });
$('pourRate').addEventListener('input', () => { if (sandbox) sandbox.flowRate = Number($<HTMLInputElement>('pourRate').value); sync(); });
$('pourHeight').addEventListener('input', () => { if (sandbox) sandbox.pourHeight = Number($<HTMLInputElement>('pourHeight').value); sync(); });
$('soundButton').addEventListener('click', async () => {
  if (!sandbox) return;
  try { await sandbox.setSound(!sandbox.soundOn); sync(); }
  catch { await sandbox.setSound(false); sync(); status('Sound is unavailable in this browser.'); }
});
for (const button of document.querySelectorAll<HTMLButtonElement>('button[data-surface]')) {
  button.addEventListener('click', () => { sandbox?.setSurface(button.dataset.surface!); sync(); });
}
$<HTMLInputElement>('brush').addEventListener('input', e => {
  if (sandbox) sandbox.radius = Number((e.target as HTMLInputElement).value);
  sync();
});
$<HTMLSelectElement>('amount').addEventListener('change', e => {
  if (sandbox) sandbox.amount = Number((e.target as HTMLSelectElement).value);
});
function showTilt(show: boolean) {
  if (show) closePanels();
  $('tiltPanel').hidden = !show;
  $('tiltButton').setAttribute('aria-expanded', String(show));
  resetPad();
}
$('tiltButton').addEventListener('click', () => showTilt($('tiltPanel').hidden));
$('closeTilt').addEventListener('click', () => showTilt(false));
const pad = $('tiltPad');
function resetPad() { tiltPointer = null; if (sandbox) sandbox.pad = [0, 0]; $('tiltDot').style.transform = ''; }
function movePad(e: PointerEvent) {
  if (!sandbox) return;
  const r = pad.getBoundingClientRect();
  let x = (e.clientX - r.left - r.width / 2) / (r.width * 0.37);
  let y = (e.clientY - r.top - r.height / 2) / (r.height * 0.37);
  const length = Math.max(1, Math.hypot(x, y)); x /= length; y /= length;
  sandbox.pad = [x, y]; $('tiltDot').style.transform = `translate(${x * 35}px, ${y * 35}px)`;
}
pad.addEventListener('pointerdown', e => { tiltPointer = e.pointerId; pad.setPointerCapture(e.pointerId); pad.focus(); movePad(e); });
pad.addEventListener('pointermove', e => { if (tiltPointer === e.pointerId) movePad(e); });
for (const type of ['pointerup', 'pointercancel', 'lostpointercapture']) pad.addEventListener(type, resetPad);

canvas.addEventListener('pointerdown', e => {
  if (!sandbox || e.button > 0) return;
  e.preventDefault(); canvas.focus({ preventScroll: true });
  if (sandbox.paused && sandbox.tool !== 'camera') { status('The tray is paused. Press Play to sculpt.'); return; }
  canvas.setPointerCapture(e.pointerId);
  const r = canvas.getBoundingClientRect(); sandbox.pointer(e.pointerId, e.clientX - r.left, e.clientY - r.top, true);
  sync();
});
canvas.addEventListener('pointermove', e => {
  if (!sandbox) return;
  const r = canvas.getBoundingClientRect(); sandbox.pointer(e.pointerId, e.clientX - r.left, e.clientY - r.top);
  if (sandbox.tool === 'camera') syncCamera();
});
canvas.addEventListener('pointerup', e => sandbox?.endPointer(e.pointerId));
canvas.addEventListener('pointercancel', e => sandbox?.endPointer(e.pointerId, true));
canvas.addEventListener('lostpointercapture', e => {
  const p = sandbox?.pointers.get(e.pointerId) as { ended?: boolean } | undefined;
  if (!p?.ended) sandbox?.endPointer(e.pointerId, true);
});
canvas.addEventListener('contextmenu', e => e.preventDefault());

// These are local interaction keys, only while canvas/pad owns focus.
for (const target of [canvas, pad]) {
  target.addEventListener('keydown', e => {
    if (!sandbox || dialog.open) return;
    const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
    if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'w', 'a', 's', 'd', 'f', ' '].includes(key)) {
      e.preventDefault();
      if (key === 'f' && !e.repeat && sandbox.tool === 'pour' && !sandbox.paused) { sandbox.checkpoint(); sync(); }
      sandbox.keys.add(key);
    } else if (!e.repeat && key === 'p') { sandbox.setPaused(!sandbox.paused); sync(); }
    else if (!e.repeat && key === 'r') { sandbox.pour(); sync(); }
    else if (!e.repeat && key === 'v') { sandbox.setCamera(sandbox.camera.pitch || sandbox.camera.yaw ? 0 : 28, sandbox.camera.pitch || sandbox.camera.yaw ? 0 : -18); syncCamera(); }
    else if (!e.repeat && (key === 'u' || ((e.ctrlKey || e.metaKey) && key === 'z'))) { e.preventDefault(); sandbox.undo(); sync(); }
    else if (!e.repeat && /^[1-7]$/.test(key)) selectTool(['push', 'pour', 'rake', 'erase', 'magnet', 'repel', 'camera'][Number(key) - 1]);
    else if (key === 'Escape') closePanels();
  });
  target.addEventListener('keyup', e => { sandbox?.keys.delete(e.key.length === 1 ? e.key.toLowerCase() : e.key); });
  target.addEventListener('blur', () => { sandbox?.clearInput(); resetPad(); });
}

function updateVisibility() {
  if (!sandbox) return;
  sandbox.visible = !document.hidden;
  sandbox.skipped = offscreen || app.clientWidth < 280 || app.clientHeight < 240;
  if ((app.clientWidth < 280 || app.clientHeight < 240) && dialog.open) dialog.close();
  if (!sandbox.visible || sandbox.skipped) sandbox.suspend(); else sandbox.schedule();
}
const resizeObserver = new ResizeObserver(() => { try { sandbox?.resize(); updateVisibility(); } catch (error) { fail(error); } });
resizeObserver.observe(app);
$('scene').addEventListener('contentvisibilityautostatechange', e => { offscreen = (e as Event & { skipped: boolean }).skipped; updateVisibility(); });
let intersectionObserver: IntersectionObserver | null = null;
if (!('contentVisibility' in document.documentElement.style)) {
  intersectionObserver = new IntersectionObserver(entries => { offscreen = !entries[0].isIntersecting; updateVisibility(); });
  intersectionObserver.observe(app);
}
document.addEventListener('visibilitychange', updateVisibility);
window.addEventListener('blur', () => { sandbox?.suspend(); resetPad(); });
window.addEventListener('focus', updateVisibility);
canvas.addEventListener('webglcontextlost', e => {
  e.preventDefault(); if (destroyed) return;
  if (sandbox) { sandbox.live = false; sandbox.suspend(); }
  $('errorTitle').textContent = 'The GPU paused the tray';
  fail(new Error('The graphics context was lost. Restore the tray with Try again; a fresh pour will replace it.'));
});
canvas.addEventListener('webglcontextrestored', () => { if (!destroyed) boot(); });
$('retryButton').addEventListener('click', () => {
  // Restore a lost context before rebuilding; no iframe navigation is needed.
  const gl = canvas.getContext('webgl2');
  if (gl?.isContextLost()) gl.getExtension('WEBGL_lose_context')?.restoreContext();
  else boot();
});
window.addEventListener('pagehide', () => {
  destroyed = true; clearTimeout(statusTimer); resizeObserver.disconnect(); intersectionObserver?.disconnect(); identityListener?.close(); sandbox?.dispose();
}, { once: true });

// Read-only diagnostics, useful for verifying GPU movement and live resize.
Object.assign(window, { sand: {
  info: () => sandbox?.info(),
  sample: () => {
    if (!sandbox) return null;
    const { A, B } = sandbox.readParticles();
    let count = 0, x = 0, y = 0, speed = 0, finite = true;
    for (let i = 0; i < B.length; i += 4) {
      if (B[i + 3] < 0.5) continue;
      count++; x += A[i]; y += A[i + 1]; speed += Math.hypot(A[i + 2], A[i + 3]);
      if (!Number.isFinite(A[i] + A[i + 1] + B[i])) finite = false;
    }
    return { count, x: x / (count || 1), y: y / (count || 1), speed: speed / (count || 1), finite };
  },
} });
boot();