Add pastel presets, piece selection and colors, mask brushes, subdivision and conservative decimation with bounded worker operations and mixed undo history. Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4b4-202c-73f1-984d-9a55c88f9c83 Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
288 lines
8.7 KiB
JavaScript
288 lines
8.7 KiB
JavaScript
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,
|
|
});
|
|
}
|