Add clay pieces, masking, and topology editing to sculpt
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>
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user