import { buildMesh, sphere } from "./mesh.js"; import { MAX_TRIANGLES, MAX_VERTICES } from "./topology.js"; export const MAX_PIECES = 16; const LIMIT = 8 * 1024 * 1024; const defaultPiece = (vertices, indices) => ({ id: "clay-1", name: "Clay", vertexStart: 0, vertexCount: vertices, indexStart: 0, indexCount: indices, color: [0.5, 0.3, 0.2], symmetryX: 0, }); export function ensureDocument(mesh) { const vertices = mesh.positions.length / 3; if (!(mesh.masks instanceof Float32Array) || mesh.masks.length !== vertices) mesh.masks = new Float32Array(vertices); if (!Array.isArray(mesh.pieces)) mesh.pieces = vertices || mesh.indices.length ? [defaultPiece(vertices, mesh.indices.length)] : []; return mesh; } function validate(data) { const p = data.positions, i = data.indices, m = data.masks, pieces = data.pieces; if ( !(p instanceof Float32Array) || !(i instanceof Uint32Array) || !(m instanceof Float32Array) || p.length % 3 || i.length % 3 || m.length !== p.length / 3 || p.length / 3 > MAX_VERTICES || i.length / 3 > MAX_TRIANGLES || ![...p].every(Number.isFinite) || [...p].some((x) => Math.abs(x) > 8) || [...m].some((x) => !Number.isFinite(x) || x < 0 || x > 1) || [...i].some((x) => x >= p.length / 3) || !Array.isArray(pieces) || pieces.length > MAX_PIECES ) throw new Error("Invalid sculpt document."); let vs = 0, is = 0; const ids = new Set(); for (const piece of pieces) { if ( !piece || typeof piece.id !== "string" || !piece.id || ids.has(piece.id) || typeof piece.name !== "string" || piece.vertexStart !== vs || piece.indexStart !== is || !Number.isInteger(piece.vertexCount) || !Number.isInteger(piece.indexCount) || piece.vertexCount < 0 || piece.indexCount < 0 || piece.indexCount % 3 || !Array.isArray(piece.color) || piece.color.length !== 3 || piece.color.some((x) => !Number.isFinite(x) || x < 0 || x > 1) || !Number.isFinite(piece.symmetryX) || Math.abs(piece.symmetryX) > 8 ) throw new Error("Invalid sculpt piece ranges."); const end = vs + piece.vertexCount; if (is + piece.indexCount > i.length || end > p.length / 3) throw new Error("Invalid sculpt piece ranges."); for (let k = is; k < is + piece.indexCount; k++) if (i[k] < vs || i[k] >= end) throw new Error("Piece index is outside its vertex range."); ids.add(piece.id); vs = end; is += piece.indexCount; } if ( vs !== p.length / 3 || is !== i.length || (!p.length && !i.length) !== (pieces.length === 0) ) throw new Error("Invalid sculpt piece ranges."); } const clonePieces = (pieces) => pieces.map((p) => ({ ...p, color: [...p.color] })); export function snapshot(mesh) { mesh = ensureDocument(mesh); const out = { level: Number.isInteger(mesh.level) ? mesh.level : 0, positions: mesh.positions.slice(), indices: mesh.indices.slice(), masks: mesh.masks.slice(), pieces: clonePieces(mesh.pieces), }; validate(out); return out; } export function hydrate(data) { const canonical = { level: data?.level ?? 0, positions: new Float32Array(data?.positions ?? []), indices: new Uint32Array(data?.indices ?? []), masks: new Float32Array(data?.masks ?? []), pieces: clonePieces(data?.pieces ?? []), }; validate(canonical); const mesh = buildMesh( canonical.positions.slice(), canonical.indices.slice(), canonical.level, canonical.masks.slice(), ); mesh.pieces = clonePieces(canonical.pieces); return mesh; } export function splitPiece(mesh, piece) { const s = snapshot(mesh), start = piece.vertexStart; const positions = s.positions.slice( start * 3, (start + piece.vertexCount) * 3, ); const indices = Uint32Array.from( s.indices.slice(piece.indexStart, piece.indexStart + piece.indexCount), (v) => v - start, ); const out = buildMesh( positions, indices, s.level, s.masks.slice(start, start + piece.vertexCount), ); out.pieces = [ { ...piece, vertexStart: 0, indexStart: 0, color: [...piece.color] }, ]; return out; } export function combinePieces(items) { if (items.length > MAX_PIECES) throw new Error("Document exceeds 16 pieces."); const sources = []; const snapshots = new Map(); let vertexCount = 0, indexCount = 0, level = null; for (const item of items) { let mesh = snapshots.get(item.mesh); if (!mesh) { mesh = snapshot(item.mesh); snapshots.set(item.mesh, mesh); } const requested = item.piece ?? mesh.pieces[0]; const source = mesh.pieces.find((piece) => piece.id === requested?.id); if (!source) throw new Error("Piece is not part of its source document."); vertexCount += source.vertexCount; indexCount += source.indexCount; if (vertexCount > MAX_VERTICES || indexCount / 3 > MAX_TRIANGLES) throw new Error("Mesh exceeds topology limits."); level = level === null ? mesh.level : level === mesh.level ? level : 0; sources.push({ mesh, source }); } const positions = new Float32Array(vertexCount * 3); const indices = new Uint32Array(indexCount); const masks = new Float32Array(vertexCount); const pieces = []; let vertexOffset = 0, indexOffset = 0; for (const { mesh, source } of sources) { positions.set( mesh.positions.subarray( source.vertexStart * 3, (source.vertexStart + source.vertexCount) * 3, ), vertexOffset * 3, ); masks.set( mesh.masks.subarray( source.vertexStart, source.vertexStart + source.vertexCount, ), vertexOffset, ); for (let k = 0; k < source.indexCount; k++) indices[indexOffset + k] = mesh.indices[source.indexStart + k] - source.vertexStart + vertexOffset; pieces.push({ ...source, color: [...source.color], vertexStart: vertexOffset, indexStart: indexOffset, }); vertexOffset += source.vertexCount; indexOffset += source.indexCount; } return hydrate({ level: level ?? 0, positions, indices, masks, pieces }); } const base64Encode = (bytes) => { if (typeof Buffer !== "undefined") return Buffer.from(bytes).toString("base64"); let s = ""; for (const byte of bytes) s += String.fromCharCode(byte); return btoa(s); }; const base64Decode = (text) => { if (typeof Buffer !== "undefined") return Uint8Array.from(Buffer.from(text, "base64")); return Uint8Array.from(atob(text), (c) => c.charCodeAt(0)); }; const encode = (typed) => { const bytes = new Uint8Array(typed.byteLength), view = new DataView(bytes.buffer); for (let i = 0; i < typed.length; i++) if (typed instanceof Float32Array) view.setFloat32(i * 4, typed[i], true); else view.setUint32(i * 4, typed[i], true); return base64Encode(bytes); }; const decode = (text, Type) => { const b = base64Decode(text); if (b.length % Type.BYTES_PER_ELEMENT) throw new Error("Invalid encoded array."); const out = new Type(b.length / Type.BYTES_PER_ELEMENT); const view = new DataView(b.buffer, b.byteOffset, b.byteLength); for (let i = 0; i < out.length; i++) out[i] = Type === Float32Array ? view.getFloat32(i * 4, true) : view.getUint32(i * 4, true); return out; }; export function saveDocument(mesh) { const s = snapshot(mesh); const text = JSON.stringify({ version: 2, level: s.level, positions: encode(s.positions), indices: encode(s.indices), masks: encode(s.masks), pieces: s.pieces, }); if (text.length > LIMIT) throw new Error("Project exceeds the 8 MiB limit."); return text; } export function loadDocument(text) { if (typeof text !== "string" || text.length > LIMIT) throw new Error("Project exceeds the 8 MiB limit."); const d = JSON.parse(text); if (d?.version === 1) { if ( !Number.isInteger(d.level) || d.level < 4 || d.level > 6 || !Array.isArray(d.positions) ) throw new Error("Invalid sculpt project."); const mesh = sphere(d.level); if ( d.positions.length !== mesh.positions.length || !d.positions.every((x) => Number.isFinite(x) && Math.abs(x) <= 8) ) throw new Error("Invalid sculpt project."); mesh.positions.set(d.positions); return hydrate(snapshot(ensureDocument(mesh))); } if ( d?.version !== 2 || typeof d.positions !== "string" || typeof d.indices !== "string" || typeof d.masks !== "string" ) throw new Error("Invalid sculpt project."); return hydrate({ level: d.level, positions: decode(d.positions, Float32Array), indices: decode(d.indices, Uint32Array), masks: decode(d.masks, Float32Array), pieces: d.pieces, }); }