Compare commits
11
Commits
e7546e4b78
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d279ec4123 | ||
|
|
d930d8172f | ||
|
|
347b676cf1 | ||
|
|
eb8aa0e9dc | ||
|
|
bc957cf40d | ||
|
|
a4f3eeddd6 | ||
|
|
f324ff6bb2 | ||
|
|
92638e6c9f | ||
|
|
31d3ae21ff | ||
|
|
49c80a209e | ||
|
|
dda602c4a2 |
Generated
+10
@@ -117,6 +117,15 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-channel"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-core"
|
name = "futures-core"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
@@ -717,6 +726,7 @@ dependencies = [
|
|||||||
name = "yawn-core"
|
name = "yawn-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export type GraphBuffer = {
|
|||||||
id: string;
|
id: string;
|
||||||
array: string;
|
array: string;
|
||||||
usage?: string[];
|
usage?: string[];
|
||||||
sync?: "frame" | "loadout";
|
sync?: "frame" | "manual";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GraphTexture = {
|
export type GraphTexture = {
|
||||||
|
|||||||
+27
-36
@@ -12,18 +12,17 @@ export const VertexKinds = Object.freeze([
|
|||||||
"indices",
|
"indices",
|
||||||
] as const);
|
] as const);
|
||||||
export type VertexKind = (typeof VertexKinds)[number];
|
export type VertexKind = (typeof VertexKinds)[number];
|
||||||
|
export type VertexStreams = Partial<Record<VertexKind, ArrayLike<number>>>;
|
||||||
export type MeshOptions = NodeOptions & {
|
export type MeshOptions = NodeOptions & {
|
||||||
geometryId?: number;
|
geometryId?: number;
|
||||||
material?: PBRMaterial;
|
material?: PBRMaterial;
|
||||||
vertexData?: Partial<Record<VertexKind, ArrayLike<number>>>;
|
vertexData?: VertexStreams;
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A renderable Node; clones share geometry until either clone mutates vertex data. */
|
/** A renderable Node; clones share geometry until either clone mutates vertex data. */
|
||||||
export class Mesh extends Node {
|
export class Mesh extends Node {
|
||||||
geometryId: number;
|
geometryId: number;
|
||||||
vertexCount = 0;
|
|
||||||
indexCount = 0;
|
|
||||||
instanceOf = -1;
|
instanceOf = -1;
|
||||||
readonly faceMaterials = new Map<number, number>();
|
readonly faceMaterials = new Map<number, number>();
|
||||||
#registered = false;
|
#registered = false;
|
||||||
@@ -34,13 +33,6 @@ export class Mesh extends Node {
|
|||||||
const nodeReady = this.ready;
|
const nodeReady = this.ready;
|
||||||
this.ready = nodeReady.then(async () => {
|
this.ready = nodeReady.then(async () => {
|
||||||
if (options.material) await options.material.ready;
|
if (options.material) await options.material.ready;
|
||||||
await scene.ensureRows(`mesh.${this.id}.faceMaterials`, 1, 16, "u32");
|
|
||||||
this.vertexCount =
|
|
||||||
Number(scene.geometryData(this.geometryId, "positions")?.length ?? 0) /
|
|
||||||
3;
|
|
||||||
this.indexCount = Number(
|
|
||||||
scene.geometryData(this.geometryId, "indices")?.length ?? 0,
|
|
||||||
);
|
|
||||||
this.instanceOf =
|
this.instanceOf =
|
||||||
options.geometryId === undefined ? this.id : this.geometryId;
|
options.geometryId === undefined ? this.id : this.geometryId;
|
||||||
scene
|
scene
|
||||||
@@ -51,8 +43,8 @@ export class Mesh extends Node {
|
|||||||
options.visible === false ? 0 : 1,
|
options.visible === false ? 0 : 1,
|
||||||
this.instanceOf,
|
this.instanceOf,
|
||||||
]);
|
]);
|
||||||
for (const [kind, data] of Object.entries(options.vertexData ?? {}))
|
if (options.vertexData)
|
||||||
await this.#setVertexData(kind as VertexKind, data!, false);
|
await this.#setVertexData(options.vertexData, false);
|
||||||
this.#writeBounds();
|
this.#writeBounds();
|
||||||
this.#registered = true;
|
this.#registered = true;
|
||||||
await scene.registerMesh(this);
|
await scene.registerMesh(this);
|
||||||
@@ -60,6 +52,14 @@ export class Mesh extends Node {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get vertexCount() {
|
||||||
|
return this.scene.geometry(this.geometryId).vertexCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
get indexCount() {
|
||||||
|
return this.scene.geometry(this.geometryId).indexCount;
|
||||||
|
}
|
||||||
|
|
||||||
get isVisible() {
|
get isVisible() {
|
||||||
if (this.id < 0) throw new Error("Await mesh.ready before reading it");
|
if (this.id < 0) throw new Error("Await mesh.ready before reading it");
|
||||||
return this.scene.array("meshInfo").row(this.id)[2] !== 0;
|
return this.scene.array("meshInfo").row(this.id)[2] !== 0;
|
||||||
@@ -87,20 +87,22 @@ export class Mesh extends Node {
|
|||||||
return new Mesh(this.scene, { ...options, geometryId: this.geometryId });
|
return new Mesh(this.scene, { ...options, geometryId: this.geometryId });
|
||||||
}
|
}
|
||||||
|
|
||||||
async setVertexData(kind: VertexKind, data: ArrayLike<number>) {
|
/** Replaces one stream, or several at once, repacking the geometry a single time. */
|
||||||
if (!VertexKinds.includes(kind))
|
async setVertexData(
|
||||||
throw new TypeError(`Unknown vertex kind: ${kind}`);
|
kind: VertexKind | VertexStreams,
|
||||||
|
data?: ArrayLike<number>,
|
||||||
|
) {
|
||||||
|
const streams = typeof kind === "string" ? { [kind]: data } : kind;
|
||||||
|
for (const name of Object.keys(streams))
|
||||||
|
if (!VertexKinds.includes(name as VertexKind))
|
||||||
|
throw new TypeError(`Unknown vertex kind: ${name}`);
|
||||||
await this.ready;
|
await this.ready;
|
||||||
await this.#setVertexData(kind, data, true);
|
await this.#setVertexData(streams, true);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
async #setVertexData(
|
async #setVertexData(streams: VertexStreams, makeUnique: boolean) {
|
||||||
kind: VertexKind,
|
if (makeUnique && this.scene.geometry(this.geometryId).references > 1) {
|
||||||
data: ArrayLike<number>,
|
|
||||||
makeUnique: boolean,
|
|
||||||
) {
|
|
||||||
if (makeUnique && this.scene.geometryReferences(this.geometryId) > 1) {
|
|
||||||
const original = this.geometryId;
|
const original = this.geometryId;
|
||||||
this.geometryId = await this.scene.cloneGeometry(original);
|
this.geometryId = await this.scene.cloneGeometry(original);
|
||||||
this.scene.releaseGeometry(original);
|
this.scene.releaseGeometry(original);
|
||||||
@@ -117,10 +119,8 @@ export class Mesh extends Node {
|
|||||||
this.id,
|
this.id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
if (kind === "positions") this.vertexCount = data.length / 3;
|
await this.scene.setVertexData(this.geometryId, streams, makeUnique);
|
||||||
if (kind === "indices") this.indexCount = data.length;
|
if (streams.positions) this.#writeBounds();
|
||||||
await this.scene.setVertexData(this.geometryId, kind, data, makeUnique);
|
|
||||||
if (kind === "positions") this.#writeBounds();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setMaterialForFaces(material: PBRMaterial, faces: number | number[]) {
|
async setMaterialForFaces(material: PBRMaterial, faces: number | number[]) {
|
||||||
@@ -128,19 +128,11 @@ export class Mesh extends Node {
|
|||||||
if (material.scene !== this.scene)
|
if (material.scene !== this.scene)
|
||||||
throw new Error("Material must belong to the same Scene");
|
throw new Error("Material must belong to the same Scene");
|
||||||
const list = Array.isArray(faces) ? faces : [faces];
|
const list = Array.isArray(faces) ? faces : [faces];
|
||||||
const maximum = Math.max(...list);
|
|
||||||
const array = await this.scene.ensureRows(
|
|
||||||
`mesh.${this.id}.faceMaterials`,
|
|
||||||
maximum + 1,
|
|
||||||
16,
|
|
||||||
"u32",
|
|
||||||
);
|
|
||||||
this.scene.markDirty(true);
|
|
||||||
for (const face of list) {
|
for (const face of list) {
|
||||||
if (!Number.isInteger(face) || face < 0) throw new RangeError("face");
|
if (!Number.isInteger(face) || face < 0) throw new RangeError("face");
|
||||||
array.row(face)[0] = material.id + 1;
|
|
||||||
this.faceMaterials.set(face, material.id);
|
this.faceMaterials.set(face, material.id);
|
||||||
}
|
}
|
||||||
|
this.scene.markDirty(true);
|
||||||
await this.scene.updateRenderGraph();
|
await this.scene.updateRenderGraph();
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -168,7 +160,6 @@ export class Mesh extends Node {
|
|||||||
this.#registered = false;
|
this.#registered = false;
|
||||||
await this.scene.unregisterMesh(this);
|
await this.scene.unregisterMesh(this);
|
||||||
}
|
}
|
||||||
await this.scene.core.deleteRows(`mesh.${this.id}.faceMaterials`);
|
|
||||||
await super.dispose();
|
await super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+853
-606
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,9 @@ export class ArcRotateCamera extends Camera {
|
|||||||
#controls?: ArcRotateControls;
|
#controls?: ArcRotateControls;
|
||||||
#pointer?: { x: number; y: number; button: number };
|
#pointer?: { x: number; y: number; button: number };
|
||||||
#frame = 0;
|
#frame = 0;
|
||||||
|
#inertiaFrame = 0;
|
||||||
|
#lastFrame = 0;
|
||||||
|
#motion = [0, 0, 0, 0, 0]; // orbit x/y, pan x/y, logarithmic zoom
|
||||||
|
|
||||||
constructor(scene: Scene, options: ArcRotateCameraOptions = {}) {
|
constructor(scene: Scene, options: ArcRotateCameraOptions = {}) {
|
||||||
super(scene, options);
|
super(scene, options);
|
||||||
@@ -116,6 +119,9 @@ export class ArcRotateCamera extends Camera {
|
|||||||
}
|
}
|
||||||
if (this.#frame) cancelAnimationFrame(this.#frame);
|
if (this.#frame) cancelAnimationFrame(this.#frame);
|
||||||
this.#frame = 0;
|
this.#frame = 0;
|
||||||
|
if (this.#inertiaFrame) cancelAnimationFrame(this.#inertiaFrame);
|
||||||
|
this.#inertiaFrame = 0;
|
||||||
|
this.#motion.fill(0);
|
||||||
this.#pointer = undefined;
|
this.#pointer = undefined;
|
||||||
this.#controls = undefined;
|
this.#controls = undefined;
|
||||||
}
|
}
|
||||||
@@ -161,7 +167,14 @@ export class ArcRotateCamera extends Camera {
|
|||||||
const y = event.clientY - this.#pointer.y;
|
const y = event.clientY - this.#pointer.y;
|
||||||
this.#pointer.x = event.clientX;
|
this.#pointer.x = event.clientX;
|
||||||
this.#pointer.y = event.clientY;
|
this.#pointer.y = event.clientY;
|
||||||
if (this.#pointer.button === 2) {
|
const offset = this.#pointer.button === 2 ? 2 : 0;
|
||||||
|
this.#motion[offset] += x;
|
||||||
|
this.#motion[offset + 1] += y;
|
||||||
|
this.#scheduleInertia();
|
||||||
|
};
|
||||||
|
|
||||||
|
#applyDrag(x: number, y: number, pan: boolean) {
|
||||||
|
if (pan) {
|
||||||
const row = this.cameraRow();
|
const row = this.cameraRow();
|
||||||
this.#updateTransform(() => {
|
this.#updateTransform(() => {
|
||||||
const element = this.#controls!.element;
|
const element = this.#controls!.element;
|
||||||
@@ -184,26 +197,46 @@ export class ArcRotateCamera extends Camera {
|
|||||||
row[18] -= horizontal * sinAlpha + vertical * cosBeta * cosAlpha;
|
row[18] -= horizontal * sinAlpha + vertical * cosBeta * cosAlpha;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const speed = this.#controls.orbitSpeed ?? 0.005;
|
const speed = this.#controls?.orbitSpeed ?? 0.005;
|
||||||
const row = this.cameraRow();
|
const row = this.cameraRow();
|
||||||
this.#updateTransform(() => {
|
this.#updateTransform(() => {
|
||||||
row[13] -= x * speed;
|
row[13] -= x * speed;
|
||||||
row[14] = Math.min(
|
row[14] = Math.min(
|
||||||
Math.PI - 0.001,
|
Math.PI - 0.001,
|
||||||
Math.max(0.001, row[14] + y * speed),
|
Math.max(0.001, row[14] - y * speed),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
#wheel = (event: WheelEvent) => {
|
#wheel = (event: WheelEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const row = this.cameraRow();
|
this.#motion[4] += event.deltaY * (this.#controls?.zoomSpeed ?? 0.001);
|
||||||
this.#updateTransform(() => {
|
this.#scheduleInertia();
|
||||||
row[15] = Math.max(
|
};
|
||||||
0.01,
|
|
||||||
row[15] * Math.exp(event.deltaY * (this.#controls?.zoomSpeed ?? 0.001)),
|
#scheduleInertia() {
|
||||||
);
|
if (this.#inertiaFrame) return;
|
||||||
|
this.#lastFrame = performance.now();
|
||||||
|
this.#inertiaFrame = requestAnimationFrame(this.#tickInertia);
|
||||||
|
}
|
||||||
|
|
||||||
|
#tickInertia = (now: number) => {
|
||||||
|
this.#inertiaFrame = 0;
|
||||||
|
const fraction = -Math.expm1(-Math.max(0, now - this.#lastFrame) / 80);
|
||||||
|
this.#lastFrame = now;
|
||||||
|
const step = this.#motion.map((remaining, index) => {
|
||||||
|
const value =
|
||||||
|
Math.abs(remaining) < 0.00001 ? remaining : remaining * fraction;
|
||||||
|
this.#motion[index] -= value;
|
||||||
|
return value;
|
||||||
});
|
});
|
||||||
|
this.batchTransformChanges(() => {
|
||||||
|
if (step[0] || step[1]) this.#applyDrag(step[0], step[1], false);
|
||||||
|
if (step[2] || step[3]) this.#applyDrag(step[2], step[3], true);
|
||||||
|
if (step[4]) this.radius *= Math.exp(step[4]);
|
||||||
|
});
|
||||||
|
if (this.#motion.some(Boolean))
|
||||||
|
this.#inertiaFrame = requestAnimationFrame(this.#tickInertia);
|
||||||
};
|
};
|
||||||
#pollController = () => {
|
#pollController = () => {
|
||||||
const pad = navigator.getGamepads?.().find(Boolean);
|
const pad = navigator.getGamepads?.().find(Boolean);
|
||||||
|
|||||||
@@ -45,6 +45,20 @@ export async function importGltf(scene: Scene, url: string | URL) {
|
|||||||
await scene.reserve({
|
await scene.reserve({
|
||||||
nodes: result.primitives.length,
|
nodes: result.primitives.length,
|
||||||
materials: result.materials.length,
|
materials: result.materials.length,
|
||||||
|
vertices: result.primitives.reduce(
|
||||||
|
(total: number, primitive: any) => total + primitive.positions.length / 3,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
// Each geometry's index span is padded to whole rows of four.
|
||||||
|
indices: result.primitives.reduce(
|
||||||
|
(total: number, primitive: any) =>
|
||||||
|
total +
|
||||||
|
Math.ceil(
|
||||||
|
(primitive.indices?.length ?? primitive.positions.length / 3) / 4,
|
||||||
|
) *
|
||||||
|
4,
|
||||||
|
0,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
let textures: Texture[] = [];
|
let textures: Texture[] = [];
|
||||||
const imported = await scene.batchGraphUpdates(async () => {
|
const imported = await scene.batchGraphUpdates(async () => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export class DirectionalLight extends Node {
|
|||||||
this.ready = nodeReady.then(async () => {
|
this.ready = nodeReady.then(async () => {
|
||||||
const array = await scene.ensureRows("directionalLights", this.id + 1, 32, "f32");
|
const array = await scene.ensureRows("directionalLights", this.id + 1, 32, "f32");
|
||||||
array.row(this.id).set([...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1, 1, 0, 0, 0]);
|
array.row(this.id).set([...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1, 1, 0, 0, 0]);
|
||||||
|
await scene.setLight("directional", this.id, true);
|
||||||
return this;
|
return this;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -21,6 +22,7 @@ export class DirectionalLight extends Node {
|
|||||||
await this.ready;
|
await this.ready;
|
||||||
if (this.disposed) return;
|
if (this.disposed) return;
|
||||||
this.scene.array("directionalLights").row(this.id).fill(0);
|
this.scene.array("directionalLights").row(this.id).fill(0);
|
||||||
|
await this.scene.setLight("directional", this.id, false);
|
||||||
await super.dispose();
|
await super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export class PointLight extends Node {
|
|||||||
options.radius ?? 0,
|
options.radius ?? 0,
|
||||||
0,
|
0,
|
||||||
]);
|
]);
|
||||||
|
await scene.setLight("point", this.id, true);
|
||||||
return this;
|
return this;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -62,6 +63,7 @@ export class PointLight extends Node {
|
|||||||
await this.ready;
|
await this.ready;
|
||||||
if (this.disposed) return;
|
if (this.disposed) return;
|
||||||
this.scene.array("pointLights").row(this.id).fill(0);
|
this.scene.array("pointLights").row(this.id).fill(0);
|
||||||
|
await this.scene.setLight("point", this.id, false);
|
||||||
await super.dispose();
|
await super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export class SpotLight extends Node {
|
|||||||
...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1,
|
...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1,
|
||||||
options.range ?? 10, options.innerAngle ?? 0.35, options.outerAngle ?? 0.7, 1,
|
options.range ?? 10, options.innerAngle ?? 0.35, options.outerAngle ?? 0.7, 1,
|
||||||
]);
|
]);
|
||||||
|
await scene.setLight("spot", this.id, true);
|
||||||
return this;
|
return this;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -32,6 +33,7 @@ export class SpotLight extends Node {
|
|||||||
await this.ready;
|
await this.ready;
|
||||||
if (this.disposed) return;
|
if (this.disposed) return;
|
||||||
this.scene.array("spotLights").row(this.id).fill(0);
|
this.scene.array("spotLights").row(this.id).fill(0);
|
||||||
|
await this.scene.setLight("spot", this.id, false);
|
||||||
await super.dispose();
|
await super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ function deserialize(serialized: string) {
|
|||||||
|
|
||||||
export class YawnCore {
|
export class YawnCore {
|
||||||
readonly ready = Promise.resolve();
|
readonly ready = Promise.resolve();
|
||||||
|
readonly features = new Set(["timestamp-query", "indirect-first-instance"]);
|
||||||
readonly graphs: any[] = [];
|
readonly graphs: any[] = [];
|
||||||
readonly serializedGraphs: string[] = [];
|
readonly serializedGraphs: string[] = [];
|
||||||
readonly loadouts: string[] = [];
|
readonly loadouts: string[] = [];
|
||||||
@@ -171,11 +172,11 @@ export class YawnCore {
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
allocateObject(name: string) {
|
allocateObject(name: string, count = 1) {
|
||||||
const id = this.#nextObjects.get(name) ?? 0;
|
const id = this.#nextObjects.get(name) ?? 0;
|
||||||
this.#nextObjects.set(name, id + 1);
|
this.#nextObjects.set(name, id + count);
|
||||||
const rows = this.array(name);
|
const rows = this.array(name);
|
||||||
rows.resize(id + 1);
|
rows.resize(id + count);
|
||||||
return Promise.resolve(id);
|
return Promise.resolve(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +184,10 @@ export class YawnCore {
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncRows() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
array(name: string) {
|
array(name: string) {
|
||||||
const rows = this.#arrays.get(name);
|
const rows = this.#arrays.get(name);
|
||||||
if (!rows) throw new Error(`UNKNOWN_ARRAY: ${name}`);
|
if (!rows) throw new Error(`UNKNOWN_ARRAY: ${name}`);
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ import { ArcRotateCamera } from "../src/camera/ArcRotateCamera";
|
|||||||
import { PointLight } from "../src/lights/PointLight";
|
import { PointLight } from "../src/lights/PointLight";
|
||||||
import { PBRMaterial } from "../src/materials/PBRMaterial";
|
import { PBRMaterial } from "../src/materials/PBRMaterial";
|
||||||
import type { Texture } from "../src/materials/Texture";
|
import type { Texture } from "../src/materials/Texture";
|
||||||
import { createScene, testCore, triangle } from "./scene-helpers";
|
import {
|
||||||
|
bucketDraws,
|
||||||
|
createScene,
|
||||||
|
latestGraph,
|
||||||
|
testCore,
|
||||||
|
triangle,
|
||||||
|
} from "./scene-helpers";
|
||||||
|
|
||||||
function close(actual: ArrayLike<number>, expected: ArrayLike<number>) {
|
function close(actual: ArrayLike<number>, expected: ArrayLike<number>) {
|
||||||
assert.equal(actual.length, expected.length);
|
assert.equal(actual.length, expected.length);
|
||||||
@@ -139,6 +145,13 @@ test("Mesh packs geometry and bounds, then clones with copy-on-write", async ()
|
|||||||
assert.equal(mesh.indexCount, 3);
|
assert.equal(mesh.indexCount, 3);
|
||||||
close(scene.geometryData(mesh.geometryId, "positions")!, triangle.positions);
|
close(scene.geometryData(mesh.geometryId, "positions")!, triangle.positions);
|
||||||
close(scene.array("bounds").row(mesh.id), [-1, -1, 0, 0, 1, 1, 0, 0]);
|
close(scene.array("bounds").row(mesh.id), [-1, -1, 0, 0, 1, 1, 0, 0]);
|
||||||
|
// Vertex 1 lives in the shared interleaved array: position, normal, uv, uv2, tangent, colour.
|
||||||
|
const geometry = scene.geometry(mesh.geometryId);
|
||||||
|
close(
|
||||||
|
scene.array("vertices").row(geometry.vertexRow + 1),
|
||||||
|
[1, -1, 0, 0, 0, 0, 1, 0, 1, 0, 0.9, 0.1, 1, 0, 0, 1, 0, 1, 0, 1],
|
||||||
|
);
|
||||||
|
close(scene.array("indices").row(geometry.indexRow), [0, 1, 2, 0]);
|
||||||
|
|
||||||
const clone = mesh.clone({ position: [2, 0, 0] });
|
const clone = mesh.clone({ position: [2, 0, 0] });
|
||||||
await clone.ready;
|
await clone.ready;
|
||||||
@@ -155,9 +168,81 @@ test("Mesh packs geometry and bounds, then clones with copy-on-write", async ()
|
|||||||
await clone.setMaterialForFaces(firstMaterial, 0);
|
await clone.setMaterialForFaces(firstMaterial, 0);
|
||||||
assert.equal(clone.materialId, secondMaterial.id);
|
assert.equal(clone.materialId, secondMaterial.id);
|
||||||
assert.equal(clone.faceMaterials.get(0), firstMaterial.id);
|
assert.equal(clone.faceMaterials.get(0), firstMaterial.id);
|
||||||
assert.equal(
|
});
|
||||||
scene.array(`mesh.${clone.id}.faceMaterials`).row(0)[0],
|
|
||||||
firstMaterial.id + 1,
|
test("geometry spans validate before mutating, move intact, and inhibit rendering until published", async () => {
|
||||||
|
const scene = await createScene();
|
||||||
|
const material = new PBRMaterial(scene);
|
||||||
|
await material.ready;
|
||||||
|
const first = new Mesh(scene, { material, vertexData: triangle });
|
||||||
|
const second = new Mesh(scene, { material, vertexData: triangle });
|
||||||
|
await Promise.all([first.ready, second.ready]);
|
||||||
|
const core = testCore(scene);
|
||||||
|
const before = {
|
||||||
|
graphs: core.graphs.length,
|
||||||
|
first: { ...scene.geometry(first.geometryId) },
|
||||||
|
second: { ...scene.geometry(second.geometryId) },
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
first.setVertexData("indices", [0, 1, 3]),
|
||||||
|
/VERTEX_DATA: indices\[2\]/u,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
first.setVertexData("indices", [0, 1, 1.5]),
|
||||||
|
/VERTEX_DATA: indices\[2\]/u,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
first.setVertexData("normals", [0, 0, 1]),
|
||||||
|
/VERTEX_DATA: normals/u,
|
||||||
|
);
|
||||||
|
assert.deepEqual({ ...scene.geometry(first.geometryId) }, before.first);
|
||||||
|
close(scene.geometryData(first.geometryId, "indices")!, [0, 1, 2]);
|
||||||
|
assert.equal(core.graphs.length, before.graphs);
|
||||||
|
|
||||||
|
// Hold the older loadout switch open while the first mesh grows past its span.
|
||||||
|
let releaseSwitch!: () => void;
|
||||||
|
const switching = new Promise<void>((resolve) => (releaseSwitch = resolve));
|
||||||
|
const switchLoadout = core.switchLoadout.bind(core);
|
||||||
|
core.switchLoadout = (id) => switching.then(() => switchLoadout(id));
|
||||||
|
const older = scene.updateRenderGraph();
|
||||||
|
const grown = first.setVertexData({
|
||||||
|
positions: [0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0],
|
||||||
|
normals: [0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
|
||||||
|
tangents: Array(16).fill(0),
|
||||||
|
uvs: [0, 0, 1, 0, 0, 1, 1, 1],
|
||||||
|
uvs2: [0, 0, 1, 0, 0, 1, 1, 1],
|
||||||
|
colors: Array(16).fill(1),
|
||||||
|
indices: [0, 1, 2, 2, 1, 3],
|
||||||
|
});
|
||||||
|
const signals = core.array("signals").row(0);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve));
|
||||||
|
assert.equal(signals[4], 1);
|
||||||
|
assert.equal(core.loadouts.length, before.graphs);
|
||||||
|
releaseSwitch();
|
||||||
|
await older;
|
||||||
|
assert.equal(signals[4], 1);
|
||||||
|
core.switchLoadout = switchLoadout;
|
||||||
|
await grown;
|
||||||
|
assert.equal(signals[4], 0);
|
||||||
|
assert.equal(signals[5], 1);
|
||||||
|
|
||||||
|
const moved = scene.geometry(first.geometryId);
|
||||||
|
assert.equal(moved.vertexCount, 4);
|
||||||
|
assert.notEqual(moved.vertexRow, before.first.vertexRow);
|
||||||
|
assert.deepEqual({ ...scene.geometry(second.geometryId) }, before.second);
|
||||||
|
close(
|
||||||
|
scene.array("vertices").row(before.second.vertexRow + 1),
|
||||||
|
[1, -1, 0, 0, 0, 0, 1, 0, 1, 0, 0.9, 0.1, 1, 0, 0, 1, 0, 1, 0, 1],
|
||||||
|
);
|
||||||
|
close(scene.array("indices").row(moved.indexRow), [0, 1, 2, 2]);
|
||||||
|
const draws = bucketDraws(scene, latestGraph(scene));
|
||||||
|
assert.deepEqual(
|
||||||
|
draws.map((draw) => [draw.baseVertex, draw.firstIndex, draw.indices]),
|
||||||
|
[
|
||||||
|
[moved.vertexRow, moved.indexRow * 4, 6],
|
||||||
|
[before.second.vertexRow, before.second.indexRow * 4, 3],
|
||||||
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -207,7 +292,31 @@ test("Node, ArcRotateCamera, and light handles update their shared rows", async
|
|||||||
assert.equal(scene.array("pointLights").row(light.id)[6], 3);
|
assert.equal(scene.array("pointLights").row(light.id)[6], 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ArcRotateCamera pans one projected world pixel per pointer pixel", async () => {
|
test("ArcRotateCamera damps pan, orbit and zoom with reversed vertical orbit", async (t) => {
|
||||||
|
let now = 0;
|
||||||
|
let next = 1;
|
||||||
|
const frames = new Map<number, FrameRequestCallback>();
|
||||||
|
const originalRequest = globalThis.requestAnimationFrame;
|
||||||
|
const originalCancel = globalThis.cancelAnimationFrame;
|
||||||
|
t.mock.method(performance, "now", () => now);
|
||||||
|
globalThis.requestAnimationFrame = (callback) => {
|
||||||
|
const id = next++;
|
||||||
|
frames.set(id, callback);
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
globalThis.cancelAnimationFrame = (id) => {
|
||||||
|
frames.delete(id);
|
||||||
|
};
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.requestAnimationFrame = originalRequest;
|
||||||
|
globalThis.cancelAnimationFrame = originalCancel;
|
||||||
|
});
|
||||||
|
const advance = (ms: number) => {
|
||||||
|
now += ms;
|
||||||
|
const callbacks = [...frames.values()];
|
||||||
|
frames.clear();
|
||||||
|
for (const callback of callbacks) callback(now);
|
||||||
|
};
|
||||||
const listeners = new Map<string, Set<(event: any) => void>>();
|
const listeners = new Map<string, Set<(event: any) => void>>();
|
||||||
const element = {
|
const element = {
|
||||||
clientWidth: 400,
|
clientWidth: 400,
|
||||||
@@ -244,7 +353,43 @@ test("ArcRotateCamera pans one projected world pixel per pointer pixel", async (
|
|||||||
pointerId: 1,
|
pointerId: 1,
|
||||||
});
|
});
|
||||||
dispatch("pointermove", { clientX: 120, clientY: 110 });
|
dispatch("pointermove", { clientX: 120, clientY: 110 });
|
||||||
|
dispatch("pointerup", {});
|
||||||
|
advance(80);
|
||||||
|
close(camera.position, [-2 * (1 - Math.exp(-1)), 1 - Math.exp(-1), 10]);
|
||||||
|
advance(1600);
|
||||||
close(camera.position, [-2, 1, 10]);
|
close(camera.position, [-2, 1, 10]);
|
||||||
|
advance(16);
|
||||||
|
assert.equal(frames.size, 0);
|
||||||
|
const orbit = () => {
|
||||||
|
dispatch("pointerdown", {
|
||||||
|
button: 0,
|
||||||
|
clientX: 100,
|
||||||
|
clientY: 100,
|
||||||
|
pointerId: 1,
|
||||||
|
});
|
||||||
|
dispatch("pointermove", { clientX: 140, clientY: 120 });
|
||||||
|
dispatch("pointerup", {});
|
||||||
|
};
|
||||||
|
orbit();
|
||||||
|
advance(40);
|
||||||
|
const released = camera.alpha;
|
||||||
|
advance(40);
|
||||||
|
assert.ok(camera.alpha < released);
|
||||||
|
const pose = [camera.alpha, camera.beta];
|
||||||
|
close(pose, [
|
||||||
|
-0.2 * (1 - Math.exp(-1)),
|
||||||
|
Math.PI / 2 - 0.1 * (1 - Math.exp(-1)),
|
||||||
|
]);
|
||||||
camera.detachControls();
|
camera.detachControls();
|
||||||
|
camera.alpha = 0;
|
||||||
|
camera.beta = Math.PI / 2;
|
||||||
|
camera.attachControls({ element });
|
||||||
|
orbit();
|
||||||
|
advance(80);
|
||||||
|
close([camera.alpha, camera.beta], pose);
|
||||||
|
dispatch("wheel", { deltaY: 100 });
|
||||||
|
advance(80);
|
||||||
|
close([camera.radius], [10 * Math.exp(0.1 * (1 - Math.exp(-1)))]);
|
||||||
|
camera.detachControls();
|
||||||
|
assert.equal(frames.size, 0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { Mesh } from "../src/Mesh";
|
|||||||
import { FXAA } from "../src/PostProcesses/FXAA";
|
import { FXAA } from "../src/PostProcesses/FXAA";
|
||||||
import { PBRMaterial } from "../src/materials/PBRMaterial";
|
import { PBRMaterial } from "../src/materials/PBRMaterial";
|
||||||
import { Texture } from "../src/materials/Texture";
|
import { Texture } from "../src/materials/Texture";
|
||||||
|
import { ShaderMaterial } from "../src/materials/ShaderMaterial";
|
||||||
|
import { PointLight } from "../src/lights/PointLight";
|
||||||
|
import { SpotLight } from "../src/lights/SpotLight";
|
||||||
|
import { DirectionalLight } from "../src/lights/DirectionalLight";
|
||||||
|
import { ComputePass } from "../src/ComputePass";
|
||||||
import {
|
import {
|
||||||
|
bucketDraws,
|
||||||
createScene,
|
createScene,
|
||||||
detailedMesh,
|
detailedMesh,
|
||||||
forwardPasses,
|
forwardPasses,
|
||||||
@@ -33,6 +39,11 @@ test("empty scenes use neutral authored defaults without placeholder geometry",
|
|||||||
|
|
||||||
test("opaque PBR graph has no implicit environment and compacts sparse lights", async () => {
|
test("opaque PBR graph has no implicit environment and compacts sparse lights", async () => {
|
||||||
const scene = await createScene();
|
const scene = await createScene();
|
||||||
|
await Promise.all([
|
||||||
|
new PointLight(scene).ready,
|
||||||
|
new SpotLight(scene).ready,
|
||||||
|
new DirectionalLight(scene).ready,
|
||||||
|
]);
|
||||||
const material = new PBRMaterial(scene);
|
const material = new PBRMaterial(scene);
|
||||||
await material.ready;
|
await material.ready;
|
||||||
const mesh = detailedMesh(scene, material);
|
const mesh = detailedMesh(scene, material);
|
||||||
@@ -91,15 +102,16 @@ test("materials with identical shader features share one pipeline", async () =>
|
|||||||
await Promise.all([firstMesh.ready, secondMesh.ready]);
|
await Promise.all([firstMesh.ready, secondMesh.ready]);
|
||||||
|
|
||||||
const graph = latestGraph(scene);
|
const graph = latestGraph(scene);
|
||||||
const passes = forwardPasses(graph);
|
const draws = bucketDraws(scene, graph);
|
||||||
assert.equal(pbrPipelines(graph).length, 1);
|
assert.equal(pbrPipelines(graph).length, 1);
|
||||||
assert.equal(passes.length, 2);
|
// One indirect bucket pass holds both meshes: same pipeline and bind set.
|
||||||
|
assert.equal(forwardPasses(graph).length, 1);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
passes.map((pass: any) => pass.draw.firstInstance & 0xffff),
|
draws.map((draw) => draw.firstInstance & 0xffff),
|
||||||
[firstMesh.id, secondMesh.id],
|
[firstMesh.id, secondMesh.id],
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
passes.map((pass: any) => (pass.draw.firstInstance >>> 16) - 1),
|
draws.map((draw) => (draw.firstInstance >>> 16) - 1),
|
||||||
[firstMaterial.id, secondMaterial.id],
|
[firstMaterial.id, secondMaterial.id],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -315,7 +327,107 @@ test("batchGraphUpdates compiles once for multiple mesh registrations", async ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(testCore(scene).graphs.length - before, 1);
|
assert.equal(testCore(scene).graphs.length - before, 1);
|
||||||
assert.equal(forwardPasses(latestGraph(scene)).length, 2);
|
assert.equal(forwardPasses(latestGraph(scene)).length, 1);
|
||||||
|
assert.equal(bucketDraws(scene, latestGraph(scene)).length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("draw buckets persist per bind set, zero their inactive tail, and stay blocked while compiling", async () => {
|
||||||
|
const scene = await createScene();
|
||||||
|
const core = testCore(scene);
|
||||||
|
const plain = new PBRMaterial(scene);
|
||||||
|
const texture = new Texture(scene, { size: [1, 1, 1] });
|
||||||
|
await texture.ready;
|
||||||
|
const textured = new PBRMaterial(scene, { baseColorTexture: texture });
|
||||||
|
await Promise.all([plain.ready, textured.ready]);
|
||||||
|
const meshes: Mesh[] = [];
|
||||||
|
for (let index = 0; index < 3; index++)
|
||||||
|
meshes.push(await detailedMesh(scene, plain).ready);
|
||||||
|
|
||||||
|
// Three plain meshes fill one bucket; the array grew geometrically from 1 → 2 → 4 rows.
|
||||||
|
let graph = latestGraph(scene);
|
||||||
|
const bucket = graph.resources.buffers.find((buffer: any) =>
|
||||||
|
buffer.id.startsWith("draws-"),
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
{ usage: bucket.usage, sync: bucket.sync },
|
||||||
|
{ usage: ["indirect"], sync: "manual" },
|
||||||
|
);
|
||||||
|
assert.equal(core.array(bucket.array).rows, 4);
|
||||||
|
assert.equal(bucketDraws(scene, graph).length, 3);
|
||||||
|
assert.equal(forwardPasses(graph).length, 1);
|
||||||
|
|
||||||
|
// Moving one mesh to a textured material creates a second bucket; the first keeps two rows
|
||||||
|
// and its freed tail is zero (instanceCount 0) so the GPU skips it.
|
||||||
|
meshes[1].material = textured;
|
||||||
|
await meshes[1].ready;
|
||||||
|
await scene.updateRenderGraph();
|
||||||
|
graph = latestGraph(scene);
|
||||||
|
assert.equal(forwardPasses(graph).length, 2);
|
||||||
|
const draws = bucketDraws(scene, graph);
|
||||||
|
assert.deepEqual(
|
||||||
|
draws.map((draw) => [draw.pass, draw.firstInstance & 0xffff]),
|
||||||
|
[
|
||||||
|
["forward-bucket-0", meshes[0].id],
|
||||||
|
["forward-bucket-0", meshes[2].id],
|
||||||
|
["forward-bucket-1", meshes[1].id],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
[...core.array(bucket.array).view.subarray(16, 32)],
|
||||||
|
Array(16).fill(0),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Back to plain: the second bucket vanishes from the graph, the first regains its row.
|
||||||
|
meshes[1].material = plain;
|
||||||
|
await meshes[1].ready;
|
||||||
|
await scene.updateRenderGraph();
|
||||||
|
graph = latestGraph(scene);
|
||||||
|
assert.equal(forwardPasses(graph).length, 1);
|
||||||
|
assert.equal(bucketDraws(scene, graph).length, 3);
|
||||||
|
|
||||||
|
// A compile that starts after an earlier switch cleared signal 6 re-asserts it before
|
||||||
|
// touching bucket rows, so no frame renders the old graph with new bucket contents.
|
||||||
|
const signals = core.array("signals").row(0);
|
||||||
|
const switchLoadout = core.switchLoadout.bind(core);
|
||||||
|
let observed = -1;
|
||||||
|
core.switchLoadout = async (id) => {
|
||||||
|
await switchLoadout(id);
|
||||||
|
signals[6] = 0;
|
||||||
|
core.switchLoadout = async (next) => {
|
||||||
|
observed = signals[6];
|
||||||
|
await switchLoadout(next);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
await Promise.all([scene.updateRenderGraph(), scene.updateRenderGraph()]);
|
||||||
|
assert.equal(observed, 1);
|
||||||
|
core.switchLoadout = switchLoadout;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opaque buckets render into the composited layer ahead of transmission", async () => {
|
||||||
|
const scene = await createScene();
|
||||||
|
const opaque = new PBRMaterial(scene);
|
||||||
|
const glass = new PBRMaterial(scene, { transmission: 0.9 });
|
||||||
|
await Promise.all([opaque.ready, glass.ready]);
|
||||||
|
const solid = detailedMesh(scene, opaque);
|
||||||
|
const window = detailedMesh(scene, glass);
|
||||||
|
await Promise.all([solid.ready, window.ready]);
|
||||||
|
|
||||||
|
const passes = forwardPasses(latestGraph(scene));
|
||||||
|
assert.deepEqual(
|
||||||
|
passes.map((pass: any) => [
|
||||||
|
pass.id,
|
||||||
|
pass.color[0].resource,
|
||||||
|
Boolean(pass.indirect),
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
["forward-bucket-0", "scene-color", true],
|
||||||
|
[
|
||||||
|
`forward-${window.id}-${scene.geometry(window.geometryId).indexRow * 4}`,
|
||||||
|
"hdr",
|
||||||
|
false,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("render graph snapshots expose isolated scene-owned declarations", async () => {
|
test("render graph snapshots expose isolated scene-owned declarations", async () => {
|
||||||
@@ -349,3 +461,151 @@ test("serialized render graphs compile into explicitly selectable loadouts", asy
|
|||||||
assert.equal(loadoutId, `loadout-${before + 1}`);
|
assert.equal(loadoutId, `loadout-${before + 1}`);
|
||||||
assert.equal(core.loadouts.at(-1), loadoutId);
|
assert.equal(core.loadouts.at(-1), loadoutId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("packed ORM reads are shared only for the same texture and UV transform", async () => {
|
||||||
|
const scene = await createScene();
|
||||||
|
const orm = new Texture(scene, { size: [1, 1, 1] });
|
||||||
|
const other = new Texture(scene, { size: [1, 1, 1] });
|
||||||
|
await Promise.all([orm.ready, other.ready]);
|
||||||
|
for (const [occlusion, info, shared] of [
|
||||||
|
[orm, {}, true],
|
||||||
|
[other, {}, false],
|
||||||
|
[orm, { texCoord: 1 }, false],
|
||||||
|
[orm, { offset: [0.25, -0.1] }, false],
|
||||||
|
[orm, { scale: [2, 1] }, false],
|
||||||
|
[orm, { rotation: 0.4 }, false],
|
||||||
|
] as const) {
|
||||||
|
const material = new PBRMaterial(scene, {
|
||||||
|
metallicRoughnessTexture: orm,
|
||||||
|
occlusionTexture: occlusion,
|
||||||
|
textureInfos: [{}, {}, {}, {}, info],
|
||||||
|
});
|
||||||
|
await material.ready;
|
||||||
|
const mesh = await detailedMesh(scene, material).ready;
|
||||||
|
const graph = latestGraph(scene);
|
||||||
|
const pass = forwardPasses(graph).at(-1);
|
||||||
|
const code = renderPipeline(graph, pass.pipeline).code;
|
||||||
|
assert.equal(code.includes("mix(1.0, packedMaterial.r"), shared);
|
||||||
|
assert.equal(code.includes("textureSample(occlusionTexture"), !shared);
|
||||||
|
assert.equal(
|
||||||
|
pass.bindings.some((b: any) => b.group === 1 && b.binding === 5),
|
||||||
|
!shared,
|
||||||
|
);
|
||||||
|
await mesh.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("light variants follow first/last light lifetimes and keep hot edits rebuild-free", async () => {
|
||||||
|
const scene = await createScene();
|
||||||
|
const material = await new PBRMaterial(scene).ready;
|
||||||
|
await detailedMesh(scene, material).ready;
|
||||||
|
const code = () => pbrPipelines(latestGraph(scene))[0].code;
|
||||||
|
assert.doesNotMatch(code(), /fn (point|spot|directional)Contribution/);
|
||||||
|
const first = await new PointLight(scene, { intensity: 0 }).ready;
|
||||||
|
assert.match(code(), /fn pointContribution/);
|
||||||
|
assert.doesNotMatch(code(), /fn (spot|directional)Contribution/);
|
||||||
|
assert.equal(code().match(/let nDotV =/g)?.length, 1);
|
||||||
|
assert.match(code(), /let diffuseColor = base.rgb \* \(1.0 - metallic\)/);
|
||||||
|
const before = testCore(scene).graphs.length;
|
||||||
|
first.intensity = 3;
|
||||||
|
first.radius = 0.4;
|
||||||
|
const second = await new PointLight(scene).ready;
|
||||||
|
await first.dispose();
|
||||||
|
assert.equal(testCore(scene).graphs.length, before);
|
||||||
|
assert.match(code(), /fn pointContribution/);
|
||||||
|
const spot = await new SpotLight(scene).ready;
|
||||||
|
const directional = await new DirectionalLight(scene).ready;
|
||||||
|
assert.match(code(), /fn spotContribution/);
|
||||||
|
assert.match(code(), /fn directionalContribution/);
|
||||||
|
await second.dispose();
|
||||||
|
assert.doesNotMatch(code(), /fn pointContribution/);
|
||||||
|
await spot.dispose();
|
||||||
|
await directional.dispose();
|
||||||
|
assert.doesNotMatch(code(), /fn (point|spot|directional)Contribution/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("direct canvas loadout preserves clear/depth and falls back for linear-color consumers", async () => {
|
||||||
|
const scene = await createScene({
|
||||||
|
directToCanvas: true,
|
||||||
|
clearColor: [0.02, 0.09, 0.3, 0.5],
|
||||||
|
});
|
||||||
|
const material = await new PBRMaterial(scene).ready;
|
||||||
|
const mesh = await detailedMesh(scene, material).ready;
|
||||||
|
const assertDirect = () => {
|
||||||
|
const graph = latestGraph(scene);
|
||||||
|
assert.equal(
|
||||||
|
graph.resources.textures.some((t: any) => t.id === "hdr"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
graph.passes.some((p: any) => p.id === "present"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const background = graph.passes.find(
|
||||||
|
(p: any) => p.id === "canvas-background",
|
||||||
|
);
|
||||||
|
assert.deepEqual(background.depth, { resource: "depth", clear: 1 });
|
||||||
|
assert.match(
|
||||||
|
renderPipeline(graph, "canvas-background").code,
|
||||||
|
/canvasOutput\(vec4<f32>\(0.02, 0.09, 0.3, 0.5\)\)/,
|
||||||
|
);
|
||||||
|
const forward = forwardPasses(graph)[0];
|
||||||
|
assert.deepEqual(forward.color, [{ resource: "canvas", load: "load" }]);
|
||||||
|
assert.deepEqual(forward.depth, { resource: "depth", load: "load" });
|
||||||
|
assert.equal(pbrPipelines(graph)[0].fragment.targets[0].format, "canvas");
|
||||||
|
assert.match(pbrPipelines(graph)[0].code, /unpack2x16float\(pack2x16float/);
|
||||||
|
};
|
||||||
|
const assertHdr = () =>
|
||||||
|
assert.ok(renderPipeline(latestGraph(scene), "present"));
|
||||||
|
assertDirect();
|
||||||
|
const effect = new FXAA(scene);
|
||||||
|
await effect.ready;
|
||||||
|
assertHdr();
|
||||||
|
await effect.dispose();
|
||||||
|
assertDirect();
|
||||||
|
for (const options of [{ flags: 2 }, { transmission: 0.5 }]) {
|
||||||
|
const special = await new PBRMaterial(scene, options).ready;
|
||||||
|
const extra = await detailedMesh(scene, special).ready;
|
||||||
|
assertHdr();
|
||||||
|
await extra.dispose();
|
||||||
|
assertDirect();
|
||||||
|
}
|
||||||
|
const compute = new ComputePass({
|
||||||
|
code: "@compute @workgroup_size(1) fn main() {}",
|
||||||
|
});
|
||||||
|
await scene.addComputePass(compute);
|
||||||
|
assertHdr();
|
||||||
|
assert.match(
|
||||||
|
pbrPipelines(latestGraph(scene))[0].code,
|
||||||
|
/fn directionalContribution/,
|
||||||
|
);
|
||||||
|
await scene.removeComputePass(compute.id);
|
||||||
|
assertDirect();
|
||||||
|
const custom = new ShaderMaterial(scene, { code: "custom WGSL" });
|
||||||
|
await custom.ready;
|
||||||
|
assertHdr();
|
||||||
|
await custom.dispose();
|
||||||
|
assertDirect();
|
||||||
|
const basic = await new Mesh(scene, {
|
||||||
|
vertexData: { positions: triangle.positions, indices: triangle.indices },
|
||||||
|
}).ready;
|
||||||
|
assertHdr();
|
||||||
|
await basic.dispose();
|
||||||
|
assertDirect();
|
||||||
|
const mask = await new PBRMaterial(scene, { flags: 1 }).ready;
|
||||||
|
const masked = await detailedMesh(scene, mask).ready;
|
||||||
|
assertDirect();
|
||||||
|
assert.match(
|
||||||
|
pbrPipelines(latestGraph(scene)).find((p: any) =>
|
||||||
|
p.code.includes("discard"),
|
||||||
|
).code,
|
||||||
|
/canvasOutput/,
|
||||||
|
);
|
||||||
|
await masked.dispose();
|
||||||
|
await mesh.dispose();
|
||||||
|
assertHdr();
|
||||||
|
const ldr = await createScene({ directToCanvas: true, hdr: false });
|
||||||
|
const ldrMaterial = await new PBRMaterial(ldr).ready;
|
||||||
|
await detailedMesh(ldr, ldrMaterial).ready;
|
||||||
|
assert.ok(renderPipeline(latestGraph(ldr), "present"));
|
||||||
|
});
|
||||||
|
|||||||
@@ -53,3 +53,29 @@ export function detailedMesh(
|
|||||||
) {
|
) {
|
||||||
return new Mesh(scene, { material, vertexData });
|
return new Mesh(scene, { material, vertexData });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Decodes the active indirect rows of every forward bucket pass, in pass order. */
|
||||||
|
export function bucketDraws(scene: Scene, graph: any) {
|
||||||
|
return forwardPasses(graph)
|
||||||
|
.filter((pass: any) => pass.indirect)
|
||||||
|
.flatMap((pass: any) => {
|
||||||
|
const buffer = graph.resources.buffers.find(
|
||||||
|
(buffer: any) => buffer.id === pass.indirect.resource,
|
||||||
|
);
|
||||||
|
const view = scene.core.array(buffer.array).view;
|
||||||
|
const draws = [];
|
||||||
|
for (let row = 0; row * 8 < view.length; row++) {
|
||||||
|
const [indices, instances, firstIndex, baseVertex, firstInstance] =
|
||||||
|
view.subarray(row * 8, row * 8 + 5);
|
||||||
|
if (instances)
|
||||||
|
draws.push({
|
||||||
|
pass: pass.id,
|
||||||
|
indices,
|
||||||
|
firstIndex,
|
||||||
|
baseVertex,
|
||||||
|
firstInstance,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return draws;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ uses headed Chromium on a private Xvfb display and SwiftShader WebGPU at a fixed
|
|||||||
The first run downloads roughly 215 MB of GLBs into
|
The first run downloads roughly 215 MB of GLBs into
|
||||||
`~/.cache/yawn/gltf-sample-models`. Set `YAWN_GLTF_FIXTURES` to use another
|
`~/.cache/yawn/gltf-sample-models`. Set `YAWN_GLTF_FIXTURES` to use another
|
||||||
cache directory, or `YAWN_SCREENSHOT_MODELS=Box,BoomBox` to run a subset.
|
cache directory, or `YAWN_SCREENSHOT_MODELS=Box,BoomBox` to run a subset.
|
||||||
|
`YAWN_SCREENSHOT_BASELINES` points at another reference directory (for example
|
||||||
|
one captured from a known-good commit on a machine whose software renderer does
|
||||||
|
not match the reviewed references), and `YAWN_SCREENSHOT_SETTLE_MS` (default
|
||||||
|
1200) is how long the viewer gets to settle after the model loads before the
|
||||||
|
capture; raise it on slow software renderers.
|
||||||
Failed full-size captures and visual diffs are written to `output/`.
|
Failed full-size captures and visual diffs are written to `output/`.
|
||||||
|
|
||||||
To intentionally replace references after reviewing full-size renders, run:
|
To intentionally replace references after reviewing full-size renders, run:
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ import { promisify } from "node:util";
|
|||||||
const execute = promisify(execFile);
|
const execute = promisify(execFile);
|
||||||
const directory = dirname(fileURLToPath(import.meta.url));
|
const directory = dirname(fileURLToPath(import.meta.url));
|
||||||
const root = resolve(directory, "../../../..");
|
const root = resolve(directory, "../../../..");
|
||||||
const baselines = join(directory, "baselines");
|
const baselines = resolve(
|
||||||
|
process.env.YAWN_SCREENSHOT_BASELINES ?? join(directory, "baselines"),
|
||||||
|
);
|
||||||
|
const settleMilliseconds = Number(process.env.YAWN_SCREENSHOT_SETTLE_MS ?? 1200);
|
||||||
const output = resolve(
|
const output = resolve(
|
||||||
process.env.YAWN_SCREENSHOT_OUTPUT ?? join(directory, "output"),
|
process.env.YAWN_SCREENSHOT_OUTPUT ?? join(directory, "output"),
|
||||||
);
|
);
|
||||||
@@ -44,7 +47,7 @@ const update = process.env.UPDATE_SCREENSHOTS === "1";
|
|||||||
const session = `yawn-gltf-screenshots-${process.pid}`;
|
const session = `yawn-gltf-screenshots-${process.pid}`;
|
||||||
const temporary = await mkdtemp(join(tmpdir(), "yawn-gltf-screenshots-"));
|
const temporary = await mkdtemp(join(tmpdir(), "yawn-gltf-screenshots-"));
|
||||||
const imageSize = { width: 250, height: 180 };
|
const imageSize = { width: 250, height: 180 };
|
||||||
const allModels = (await readdir(baselines))
|
const allModels = (await readdir(join(directory, "baselines")))
|
||||||
.filter((name) => name.endsWith(".png") && name !== "initial.png")
|
.filter((name) => name.endsWith(".png") && name !== "initial.png")
|
||||||
.map((name) => basename(name, ".png"))
|
.map((name) => basename(name, ".png"))
|
||||||
.sort()
|
.sort()
|
||||||
@@ -366,7 +369,7 @@ for (const model of models) {
|
|||||||
);
|
);
|
||||||
await browser("upload", "#file-picker", model.path);
|
await browser("upload", "#file-picker", model.path);
|
||||||
await browser("wait", "--fn", "window.__yawnScreenshotReady === true");
|
await browser("wait", "--fn", "window.__yawnScreenshotReady === true");
|
||||||
await browser("wait", "1200");
|
await browser("wait", String(settleMilliseconds));
|
||||||
|
|
||||||
const state = await browser(
|
const state = await browser(
|
||||||
"eval",
|
"eval",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ crate-type = ["cdylib"]
|
|||||||
path = "lib.rs"
|
path = "lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
futures-channel = "0.3"
|
||||||
js-sys = "0.3"
|
js-sys = "0.3"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
+68
-25
@@ -1,6 +1,10 @@
|
|||||||
|
import { SharedProfile } from "./profile.js";
|
||||||
|
|
||||||
const views = Object.freeze({ f32: Float32Array, u32: Uint32Array, i32: Int32Array });
|
const views = Object.freeze({ f32: Float32Array, u32: Uint32Array, i32: Int32Array });
|
||||||
|
|
||||||
export class SharedRows {
|
export class SharedRows {
|
||||||
|
#view;
|
||||||
|
|
||||||
constructor(buffer, descriptor) {
|
constructor(buffer, descriptor) {
|
||||||
if (!(buffer instanceof SharedArrayBuffer)) throw new TypeError("ROWS_DESCRIPTOR");
|
if (!(buffer instanceof SharedArrayBuffer)) throw new TypeError("ROWS_DESCRIPTOR");
|
||||||
this.buffer = buffer;
|
this.buffer = buffer;
|
||||||
@@ -11,6 +15,8 @@ export class SharedRows {
|
|||||||
if (!views[descriptor.format] || descriptor.name !== (this.descriptor?.name ?? descriptor.name))
|
if (!views[descriptor.format] || descriptor.name !== (this.descriptor?.name ?? descriptor.name))
|
||||||
throw new TypeError("ROWS_DESCRIPTOR");
|
throw new TypeError("ROWS_DESCRIPTOR");
|
||||||
this.descriptor = Object.freeze(descriptor);
|
this.descriptor = Object.freeze(descriptor);
|
||||||
|
const View = views[descriptor.format];
|
||||||
|
this.#view = new View(this.buffer, descriptor.offset, descriptor.bytes / View.BYTES_PER_ELEMENT);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,10 +24,8 @@ export class SharedRows {
|
|||||||
get rows() { return this.descriptor.rows; }
|
get rows() { return this.descriptor.rows; }
|
||||||
get stride() { return this.descriptor.stride; }
|
get stride() { return this.descriptor.stride; }
|
||||||
get format() { return this.descriptor.format; }
|
get format() { return this.descriptor.format; }
|
||||||
get view() {
|
/** One typed view over the whole array; replaced only when the descriptor changes. */
|
||||||
const View = views[this.format];
|
get view() { return this.#view; }
|
||||||
return new View(this.buffer, this.descriptor.offset, this.descriptor.bytes / View.BYTES_PER_ELEMENT);
|
|
||||||
}
|
|
||||||
|
|
||||||
row(index) {
|
row(index) {
|
||||||
if (!Number.isInteger(index) || index < 0 || index >= this.rows) throw new RangeError("ROW_RANGE");
|
if (!Number.isInteger(index) || index < 0 || index >= this.rows) throw new RangeError("ROW_RANGE");
|
||||||
@@ -47,7 +51,11 @@ export class YawnCore {
|
|||||||
#arrays = new Map();
|
#arrays = new Map();
|
||||||
#pending = new Map();
|
#pending = new Map();
|
||||||
#profileListeners = new Set();
|
#profileListeners = new Set();
|
||||||
|
#profile = new SharedProfile();
|
||||||
|
#profileTimer;
|
||||||
#next = 1;
|
#next = 1;
|
||||||
|
/** Optional WebGPU features the device has (WebGPU spelling); populated once `ready` resolves. */
|
||||||
|
features = new Set();
|
||||||
|
|
||||||
constructor(canvas, { arenaBytes = 64 * 1024 * 1024, debug = false, workerFactory } = {}) {
|
constructor(canvas, { arenaBytes = 64 * 1024 * 1024, debug = false, workerFactory } = {}) {
|
||||||
if (!canvas) throw new TypeError("canvas is required");
|
if (!canvas) throw new TypeError("canvas is required");
|
||||||
@@ -60,8 +68,9 @@ export class YawnCore {
|
|||||||
this.#worker.addEventListener("messageerror", () => this.#fail("WORKER_ERROR"));
|
this.#worker.addEventListener("messageerror", () => this.#fail("WORKER_ERROR"));
|
||||||
this.#worker.start?.();
|
this.#worker.start?.();
|
||||||
const offscreen = canvas.transferControlToOffscreen?.() ?? canvas;
|
const offscreen = canvas.transferControlToOffscreen?.() ?? canvas;
|
||||||
this.ready = this.#request("init", { canvas: offscreen, arenaBytes }, [offscreen]).then(async result => {
|
this.ready = this.#request("init", { canvas: offscreen, arenaBytes, profileBuffer: this.#profile.buffer }, [offscreen]).then(async result => {
|
||||||
this.#buffer = result.buffer;
|
this.#buffer = result.buffer;
|
||||||
|
this.features = new Set(result.features);
|
||||||
for (const descriptor of result.rows) this.#arrays.set(
|
for (const descriptor of result.rows) this.#arrays.set(
|
||||||
descriptor.name,
|
descriptor.name,
|
||||||
new SharedRows(this.#buffer, descriptor),
|
new SharedRows(this.#buffer, descriptor),
|
||||||
@@ -72,23 +81,17 @@ export class YawnCore {
|
|||||||
|
|
||||||
async createRows({ name, rows, stride, format }) {
|
async createRows({ name, rows, stride, format }) {
|
||||||
await this.ready;
|
await this.ready;
|
||||||
const descriptor = await this.#request("create-rows", { name, rows, stride, format });
|
const result = await this.#request("create-rows", { name, rows, stride, format });
|
||||||
const array = this.#arrays.get(name)?.update(descriptor)
|
return this.#adopt(result.rows, result.commit ? [name] : []);
|
||||||
?? new SharedRows(this.#buffer, descriptor);
|
|
||||||
this.#arrays.set(name, array);
|
|
||||||
return array;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRowsBatch(rows) {
|
async createRowsBatch(rows) {
|
||||||
await this.ready;
|
await this.ready;
|
||||||
if (!Array.isArray(rows) || !rows.length) throw new TypeError("ROWS");
|
if (!Array.isArray(rows) || !rows.length) throw new TypeError("ROWS");
|
||||||
const descriptors = await this.#request("create-rows-batch", { rows });
|
const result = await this.#request("create-rows-batch", { rows });
|
||||||
return descriptors.map(descriptor => {
|
const arrays = result.rows.map(descriptor => this.#adopt(descriptor));
|
||||||
const array = this.#arrays.get(descriptor.name)?.update(descriptor)
|
this.#commit(result.commit);
|
||||||
?? new SharedRows(this.#buffer, descriptor);
|
return arrays;
|
||||||
this.#arrays.set(descriptor.name, array);
|
|
||||||
return array;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteRows(name) {
|
async deleteRows(name) {
|
||||||
@@ -97,18 +100,51 @@ export class YawnCore {
|
|||||||
this.#arrays.delete(name);
|
this.#arrays.delete(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
async allocateObject(name) {
|
/** Reserves `count` contiguous rows and resolves with the first row ID. */
|
||||||
|
async allocateObject(name, count = 1) {
|
||||||
await this.ready;
|
await this.ready;
|
||||||
const { id, rows } = await this.#request("allocate-object", { name });
|
const { id, rows, commit } = await this.#request("allocate-object", { name, count });
|
||||||
this.#arrays.get(name).update(rows);
|
this.#adopt(rows, commit ? [name] : []);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes a descriptor the worker returned. When the rows moved, this thread owns the copy:
|
||||||
|
* the old bytes are copied synchronously into the new region and the worker is told to commit
|
||||||
|
* before anything else can be requested, so no write to the array is ever lost.
|
||||||
|
*/
|
||||||
|
#adopt(descriptor, commit = []) {
|
||||||
|
const current = this.#arrays.get(descriptor.name);
|
||||||
|
if (!current) {
|
||||||
|
const array = new SharedRows(this.#buffer, descriptor);
|
||||||
|
this.#arrays.set(descriptor.name, array);
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
if (descriptor.offset !== current.descriptor.offset) {
|
||||||
|
const { offset, bytes } = current.descriptor;
|
||||||
|
new Uint8Array(this.#buffer, descriptor.offset, bytes).set(new Uint8Array(this.#buffer, offset, bytes));
|
||||||
|
}
|
||||||
|
current.update(descriptor);
|
||||||
|
this.#commit(commit);
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
#commit(names) {
|
||||||
|
if (!names.length) return;
|
||||||
|
this.#request("commit-rows", { names }).catch(error => this.#fail(error.code));
|
||||||
|
}
|
||||||
|
|
||||||
async deleteObject(name, id) {
|
async deleteObject(name, id) {
|
||||||
await this.ready;
|
await this.ready;
|
||||||
return this.#request("delete-object", { name, id });
|
return this.#request("delete-object", { name, id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Uploads rows `first .. first + count` of `name` to every active GPU buffer bound to that array. */
|
||||||
|
async syncRows(name, first, count) {
|
||||||
|
await this.ready;
|
||||||
|
return this.#request("sync-rows", { name, first, count });
|
||||||
|
}
|
||||||
|
|
||||||
async compileGraph(serialized) {
|
async compileGraph(serialized) {
|
||||||
await this.ready;
|
await this.ready;
|
||||||
return this.#request("compile-graph", { serialized });
|
return this.#request("compile-graph", { serialized });
|
||||||
@@ -163,7 +199,17 @@ export class YawnCore {
|
|||||||
onProfile(listener) {
|
onProfile(listener) {
|
||||||
if (typeof listener !== "function") throw new TypeError("PROFILE_LISTENER");
|
if (typeof listener !== "function") throw new TypeError("PROFILE_LISTENER");
|
||||||
this.#profileListeners.add(listener);
|
this.#profileListeners.add(listener);
|
||||||
return () => this.#profileListeners.delete(listener);
|
if (!this.#profileTimer) this.#profileTimer = setInterval(() => {
|
||||||
|
const stats = this.#profile.read();
|
||||||
|
if (stats) for (const listener of this.#profileListeners) listener(stats);
|
||||||
|
}, 100);
|
||||||
|
return () => {
|
||||||
|
this.#profileListeners.delete(listener);
|
||||||
|
if (!this.#profileListeners.size) {
|
||||||
|
clearInterval(this.#profileTimer);
|
||||||
|
this.#profileTimer = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
array(name) {
|
array(name) {
|
||||||
@@ -181,10 +227,6 @@ export class YawnCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#message(message) {
|
#message(message) {
|
||||||
if (message?.type === "profile") {
|
|
||||||
for (const listener of this.#profileListeners) listener(message.stats);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pending = this.#pending.get(message?.request);
|
const pending = this.#pending.get(message?.request);
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
this.#pending.delete(message.request);
|
this.#pending.delete(message.request);
|
||||||
@@ -199,6 +241,7 @@ export class YawnCore {
|
|||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
this.#fail("DISPOSED");
|
this.#fail("DISPOSED");
|
||||||
|
clearInterval(this.#profileTimer);
|
||||||
this.#profileListeners.clear();
|
this.#profileListeners.clear();
|
||||||
this.#worker.terminate();
|
this.#worker.terminate();
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-33
@@ -1,28 +1,46 @@
|
|||||||
|
//! The arena and allocator compile natively for `cargo test`; everything touching WebGPU is wasm-only.
|
||||||
|
#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
#[path = "render_graph/compiler.rs"]
|
#[path = "render_graph/compiler.rs"]
|
||||||
mod compiler;
|
mod compiler;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
#[path = "renderer/wgpu.rs"]
|
#[path = "renderer/wgpu.rs"]
|
||||||
mod gpu;
|
mod gpu;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
#[path = "render_graph/gpu_resource.rs"]
|
#[path = "render_graph/gpu_resource.rs"]
|
||||||
mod gpu_resource;
|
mod gpu_resource;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
#[path = "render_graph/render_graph.rs"]
|
#[path = "render_graph/render_graph.rs"]
|
||||||
mod graph;
|
mod graph;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
#[path = "renderer/render.rs"]
|
#[path = "renderer/render.rs"]
|
||||||
mod render;
|
mod render;
|
||||||
mod render_data;
|
mod render_data;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
#[path = "render_graph/store.rs"]
|
#[path = "render_graph/store.rs"]
|
||||||
mod store;
|
mod store;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
use gpu::Wgpu;
|
use gpu::Wgpu;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
use render::RenderLoop;
|
use render::RenderLoop;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
use render_data::RenderData;
|
use render_data::RenderData;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
use store::Store;
|
use store::Store;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct RowRequest {
|
struct RowRequest {
|
||||||
name: String,
|
name: String,
|
||||||
@@ -31,6 +49,7 @@ struct RowRequest {
|
|||||||
format: String,
|
format: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub struct Core {
|
pub struct Core {
|
||||||
data: Rc<RefCell<RenderData>>,
|
data: Rc<RefCell<RenderData>>,
|
||||||
@@ -39,6 +58,7 @@ pub struct Core {
|
|||||||
render: Rc<RenderLoop>,
|
render: Rc<RenderLoop>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
impl Core {
|
impl Core {
|
||||||
#[wasm_bindgen(constructor)]
|
#[wasm_bindgen(constructor)]
|
||||||
@@ -67,6 +87,14 @@ impl Core {
|
|||||||
serde_json::to_string(&self.data.borrow().descriptors()).unwrap()
|
serde_json::to_string(&self.data.borrow().descriptors()).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Optional WebGPU features the device was created with (for example `indirect-first-instance`).
|
||||||
|
pub fn features(&self) -> String {
|
||||||
|
let gpu = self.gpu.borrow();
|
||||||
|
serde_json::to_string(&gpu.as_ref().map(|gpu| &gpu.features[..]).unwrap_or(&[])).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates or grows an array. When `commit` is set the rows moved: the caller must copy the old
|
||||||
|
/// bytes into the returned descriptor and send `commit_rows` before any other request.
|
||||||
pub fn create_rows(
|
pub fn create_rows(
|
||||||
&self,
|
&self,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -74,20 +102,18 @@ impl Core {
|
|||||||
stride: u32,
|
stride: u32,
|
||||||
format: String,
|
format: String,
|
||||||
) -> Result<String, JsError> {
|
) -> Result<String, JsError> {
|
||||||
let rows = self
|
let (rows, moved) = self
|
||||||
.data
|
.data
|
||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
.create_rows(name, rows, stride, format)
|
.create_rows(name, rows, stride, format)
|
||||||
.map_err(JsError::new)?;
|
.map_err(JsError::new)?;
|
||||||
if let Some(gpu) = self.gpu.borrow().as_ref() {
|
if !moved {
|
||||||
self.store
|
self.refresh_rows(&rows.name)?;
|
||||||
.borrow_mut()
|
|
||||||
.refresh_rows(&rows.name, gpu, &self.data.borrow())
|
|
||||||
.map_err(|error| JsError::new(&error))?;
|
|
||||||
}
|
}
|
||||||
Ok(serde_json::to_string(&rows).unwrap())
|
Ok(serde_json::json!({ "rows": rows, "commit": moved }).to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like `create_rows` for several arrays; `commit` lists the arrays that moved.
|
||||||
pub fn create_rows_batch(&self, source: &str) -> Result<String, JsError> {
|
pub fn create_rows_batch(&self, source: &str) -> Result<String, JsError> {
|
||||||
let requests: Vec<RowRequest> =
|
let requests: Vec<RowRequest> =
|
||||||
serde_json::from_str(source).map_err(|_| JsError::new("ROWS"))?;
|
serde_json::from_str(source).map_err(|_| JsError::new("ROWS"))?;
|
||||||
@@ -97,25 +123,66 @@ impl Core {
|
|||||||
let refresh = requests
|
let refresh = requests
|
||||||
.iter()
|
.iter()
|
||||||
.any(|request| self.store.borrow().uses_rows(&request.name));
|
.any(|request| self.store.borrow().uses_rows(&request.name));
|
||||||
let descriptors = {
|
let results = {
|
||||||
let mut data = self.data.borrow_mut();
|
let mut data = self.data.borrow_mut();
|
||||||
requests
|
let results = requests
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|request| {
|
.map(|request| {
|
||||||
data.create_rows(request.name, request.rows, request.stride, request.format)
|
data.create_rows(request.name, request.rows, request.stride, request.format)
|
||||||
.map_err(JsError::new)
|
|
||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>, _>>()?
|
.collect::<Result<Vec<_>, _>>();
|
||||||
};
|
if results.is_err() {
|
||||||
if refresh {
|
data.discard_pending();
|
||||||
if let Some(gpu) = self.gpu.borrow().as_ref() {
|
|
||||||
self.store
|
|
||||||
.borrow_mut()
|
|
||||||
.refresh(gpu, &self.data.borrow())
|
|
||||||
.map_err(|error| JsError::new(&error))?;
|
|
||||||
}
|
}
|
||||||
|
results.map_err(JsError::new)?
|
||||||
|
};
|
||||||
|
let commit = results
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, moved)| *moved)
|
||||||
|
.map(|(rows, _)| rows.name.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if refresh && results.iter().any(|(_, moved)| !moved) {
|
||||||
|
self.refresh()?;
|
||||||
}
|
}
|
||||||
Ok(serde_json::to_string(&descriptors).unwrap())
|
let descriptors = results
|
||||||
|
.into_iter()
|
||||||
|
.map(|(rows, _)| rows)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Ok(serde_json::json!({ "rows": descriptors, "commit": commit }).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publishes relocations the caller finished copying, then rebuilds the GPU side once.
|
||||||
|
pub fn commit_rows(&self, source: &str) -> Result<(), JsError> {
|
||||||
|
let names: Vec<String> = serde_json::from_str(source).map_err(|_| JsError::new("ROWS"))?;
|
||||||
|
self.data
|
||||||
|
.borrow_mut()
|
||||||
|
.commit(&names)
|
||||||
|
.map_err(JsError::new)?;
|
||||||
|
if names.iter().any(|name| self.store.borrow().uses_rows(name)) {
|
||||||
|
self.refresh()?;
|
||||||
|
}
|
||||||
|
self.data.borrow_mut().mark_dirty();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_rows(&self, name: &str) -> Result<(), JsError> {
|
||||||
|
if let Some(gpu) = self.gpu.borrow().as_ref() {
|
||||||
|
self.store
|
||||||
|
.borrow_mut()
|
||||||
|
.refresh_rows(name, gpu, &self.data.borrow())
|
||||||
|
.map_err(|error| JsError::new(&error))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh(&self) -> Result<(), JsError> {
|
||||||
|
if let Some(gpu) = self.gpu.borrow().as_ref() {
|
||||||
|
self.store
|
||||||
|
.borrow_mut()
|
||||||
|
.refresh(gpu, &self.data.borrow())
|
||||||
|
.map_err(|error| JsError::new(&error))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_rows(&self, name: String) -> Result<(), JsError> {
|
pub fn delete_rows(&self, name: String) -> Result<(), JsError> {
|
||||||
@@ -128,27 +195,23 @@ impl Core {
|
|||||||
.map_err(JsError::new)
|
.map_err(JsError::new)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn allocate_object(&self, name: &str) -> Result<String, JsError> {
|
/// Reserves rows; `commit` is set when the array moved, exactly like `create_rows`.
|
||||||
let (id, grew) = self
|
pub fn allocate_object(&self, name: &str, count: u32) -> Result<String, JsError> {
|
||||||
|
let (id, grew, moved) = self
|
||||||
.data
|
.data
|
||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
.allocate_object(name)
|
.allocate_object(name, count)
|
||||||
.map_err(JsError::new)?;
|
.map_err(JsError::new)?;
|
||||||
let rows = self
|
let rows = self
|
||||||
.data
|
.data
|
||||||
.borrow()
|
.borrow()
|
||||||
.rows(name)
|
.staged(name)
|
||||||
.ok_or_else(|| JsError::new("ROWS_UNKNOWN"))?
|
.ok_or_else(|| JsError::new("ROWS_UNKNOWN"))?
|
||||||
.clone();
|
.clone();
|
||||||
if grew {
|
if grew && !moved {
|
||||||
if let Some(gpu) = self.gpu.borrow().as_ref() {
|
self.refresh_rows(name)?;
|
||||||
self.store
|
|
||||||
.borrow_mut()
|
|
||||||
.refresh_rows(name, gpu, &self.data.borrow())
|
|
||||||
.map_err(|error| JsError::new(&error))?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(serde_json::json!({ "id": id, "rows": rows }).to_string())
|
Ok(serde_json::json!({ "id": id, "rows": rows, "commit": moved }).to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_object(&self, name: &str, id: u32) -> Result<(), JsError> {
|
pub fn delete_object(&self, name: &str, id: u32) -> Result<(), JsError> {
|
||||||
@@ -160,6 +223,20 @@ impl Core {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Uploads rows `first .. first + count` of `name` to every active GPU buffer bound to it.
|
||||||
|
pub fn sync_rows(&self, name: &str, first: u32, count: u32) -> Result<(), JsError> {
|
||||||
|
let gpu = self.gpu.borrow();
|
||||||
|
let gpu = gpu
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| JsError::new("WEBGPU_UNINITIALIZED"))?;
|
||||||
|
self.store
|
||||||
|
.borrow()
|
||||||
|
.sync_rows(name, first, count, gpu, &self.data.borrow())
|
||||||
|
.map_err(|error| JsError::new(&error))?;
|
||||||
|
self.data.borrow_mut().mark_dirty();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn compile_graph(&self, source: &str) -> Result<String, JsError> {
|
pub fn compile_graph(&self, source: &str) -> Result<String, JsError> {
|
||||||
let graph = compiler::compile(source).map_err(JsError::new)?;
|
let graph = compiler::compile(source).map_err(JsError::new)?;
|
||||||
Ok(self.store.borrow_mut().save(graph))
|
Ok(self.store.borrow_mut().save(graph))
|
||||||
@@ -225,7 +302,8 @@ impl Core {
|
|||||||
supported
|
supported
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn take_profile(&self) -> Option<String> {
|
/// Installs the worker-local sink that publishes completed GPU timings into shared memory.
|
||||||
self.render.take_profile()
|
pub fn on_profile(&self, callback: js_sys::Function) {
|
||||||
|
self.render.on_profile(callback);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -4,5 +4,13 @@
|
|||||||
"description": "Rust/WASM render data and render graph core",
|
"description": "Rust/WASM render data and render graph core",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": "./index.js",
|
"exports": "./index.js",
|
||||||
"files": ["index.js", "worker.js", "pkg"]
|
"scripts": {
|
||||||
|
"test": "node --test profile.test.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"index.js",
|
||||||
|
"worker.js",
|
||||||
|
"profile.js",
|
||||||
|
"pkg"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Single-writer/single-reader latest-sample mailbox. Header: state, UTF-8 byte length.
|
||||||
|
// State 0 = empty, 1 = owned by a reader/writer, 2 = complete sample.
|
||||||
|
// Neither side waits: an unread sample can be replaced, and a busy publication is skipped.
|
||||||
|
export class SharedProfile {
|
||||||
|
constructor(buffer = new SharedArrayBuffer(1024 * 1024)) {
|
||||||
|
this.buffer = buffer;
|
||||||
|
this.header = new Int32Array(buffer, 0, 2);
|
||||||
|
this.bytes = new Uint8Array(buffer, 8);
|
||||||
|
this.encoder = new TextEncoder();
|
||||||
|
this.decoder = new TextDecoder();
|
||||||
|
}
|
||||||
|
|
||||||
|
write(json) {
|
||||||
|
const bytes = this.encoder.encode(json);
|
||||||
|
if (bytes.length > this.bytes.length) return false;
|
||||||
|
const state = Atomics.load(this.header, 0);
|
||||||
|
if (
|
||||||
|
state === 1 ||
|
||||||
|
Atomics.compareExchange(this.header, 0, state, 1) !== state
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
this.bytes.set(bytes);
|
||||||
|
this.header[1] = bytes.length;
|
||||||
|
Atomics.store(this.header, 0, 2);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
read() {
|
||||||
|
if (Atomics.compareExchange(this.header, 0, 2, 1) !== 2) return;
|
||||||
|
let bytes;
|
||||||
|
try {
|
||||||
|
bytes = this.bytes.slice(0, this.header[1]);
|
||||||
|
} finally {
|
||||||
|
Atomics.store(this.header, 0, 0);
|
||||||
|
}
|
||||||
|
return JSON.parse(this.decoder.decode(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
Atomics.compareExchange(this.header, 0, 2, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { Worker } from "node:worker_threads";
|
||||||
|
import { SharedProfile } from "./profile.js";
|
||||||
|
import { YawnCore } from "./index.js";
|
||||||
|
|
||||||
|
test("shared profiles replace unread samples and exclude overlapping reads/writes", () => {
|
||||||
|
const writer = new SharedProfile(new SharedArrayBuffer(512));
|
||||||
|
const reader = new SharedProfile(writer.buffer);
|
||||||
|
assert.equal(reader.read(), undefined);
|
||||||
|
writer.write(JSON.stringify({ frame: 1, name: "GPU → SAB" }));
|
||||||
|
writer.write(JSON.stringify({ frame: 2, name: "light + opaque" }));
|
||||||
|
assert.deepEqual(reader.read(), { frame: 2, name: "light + opaque" });
|
||||||
|
assert.equal(reader.read(), undefined);
|
||||||
|
Atomics.store(writer.header, 0, 1);
|
||||||
|
assert.equal(writer.write('{"frame":3}'), false);
|
||||||
|
assert.equal(reader.read(), undefined);
|
||||||
|
Atomics.store(writer.header, 0, 0);
|
||||||
|
assert.equal(writer.write(" ".repeat(513)), false);
|
||||||
|
assert.equal(writer.write('{"frame":4}'), true);
|
||||||
|
writer.clear();
|
||||||
|
assert.equal(reader.read(), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("concurrent shared profile publication never mixes samples", async () => {
|
||||||
|
const reader = new SharedProfile();
|
||||||
|
const done = new Int32Array(new SharedArrayBuffer(4));
|
||||||
|
const worker = new Worker(
|
||||||
|
`
|
||||||
|
const {workerData} = require('node:worker_threads');
|
||||||
|
(async () => {
|
||||||
|
const {SharedProfile} = await import(workerData.module);
|
||||||
|
const writer = new SharedProfile(workerData.buffer);
|
||||||
|
for (let frame = 1; frame <= 2000; frame++) {
|
||||||
|
const json = JSON.stringify({frame, values: Array(128).fill(frame * 3 + 7), name: 'GPU → SAB'});
|
||||||
|
while (!writer.write(json)) await new Promise(r => setImmediate(r));
|
||||||
|
}
|
||||||
|
Atomics.store(new Int32Array(workerData.done), 0, 1);
|
||||||
|
})();
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
eval: true,
|
||||||
|
workerData: {
|
||||||
|
module: new URL("./profile.js", import.meta.url).href,
|
||||||
|
buffer: reader.buffer,
|
||||||
|
done: done.buffer,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const exit = new Promise((resolve, reject) => {
|
||||||
|
worker.once("error", reject);
|
||||||
|
worker.once("exit", (code) =>
|
||||||
|
code ? reject(new Error(`Worker exit ${code}`)) : resolve(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
let last = 0;
|
||||||
|
while (!Atomics.load(done, 0) || Atomics.load(reader.header, 0) === 2) {
|
||||||
|
const sample = reader.read();
|
||||||
|
if (sample) {
|
||||||
|
assert.ok(sample.frame > last);
|
||||||
|
assert.deepEqual(sample.values, Array(128).fill(sample.frame * 3 + 7));
|
||||||
|
assert.equal(sample.name, "GPU → SAB");
|
||||||
|
last = sample.frame;
|
||||||
|
}
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
}
|
||||||
|
await exit;
|
||||||
|
assert.equal(last, 2000);
|
||||||
|
} finally {
|
||||||
|
await worker.terminate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("onProfile reads SAB without result messages and stops on unsubscribe/dispose", async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ["setInterval"] });
|
||||||
|
const messages = [];
|
||||||
|
let receive;
|
||||||
|
let writer;
|
||||||
|
const worker = {
|
||||||
|
addEventListener(type, listener) {
|
||||||
|
if (type === "message") receive = listener;
|
||||||
|
},
|
||||||
|
postMessage(message) {
|
||||||
|
messages.push(message.type);
|
||||||
|
writer = new SharedProfile(message.profileBuffer);
|
||||||
|
queueMicrotask(() =>
|
||||||
|
receive({
|
||||||
|
data: {
|
||||||
|
request: message.request,
|
||||||
|
result: {
|
||||||
|
buffer: new SharedArrayBuffer(64),
|
||||||
|
features: [],
|
||||||
|
rows: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
terminate() {},
|
||||||
|
};
|
||||||
|
const core = new YawnCore({}, { workerFactory: () => worker });
|
||||||
|
await core.ready;
|
||||||
|
const samples = [];
|
||||||
|
const stop = core.onProfile((sample) => samples.push(sample));
|
||||||
|
writer.write('{"frame":9,"milliseconds":1.25}');
|
||||||
|
t.mock.timers.tick(99);
|
||||||
|
assert.equal(samples.length, 0);
|
||||||
|
t.mock.timers.tick(1);
|
||||||
|
assert.deepEqual(samples, [{ frame: 9, milliseconds: 1.25 }]);
|
||||||
|
t.mock.timers.tick(100);
|
||||||
|
assert.equal(samples.length, 1);
|
||||||
|
assert.deepEqual(messages, ["init"]);
|
||||||
|
stop();
|
||||||
|
writer.write('{"frame":10}');
|
||||||
|
t.mock.timers.tick(100);
|
||||||
|
assert.equal(samples.length, 1);
|
||||||
|
core.onProfile((sample) => samples.push(sample));
|
||||||
|
core.dispose();
|
||||||
|
t.mock.timers.tick(100);
|
||||||
|
assert.equal(samples.length, 1);
|
||||||
|
});
|
||||||
+171
-100
@@ -1,10 +1,68 @@
|
|||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
const ALIGNMENT: u32 = 64;
|
const ALIGNMENT: u32 = 64;
|
||||||
|
|
||||||
#[derive(Clone, Serialize)]
|
/// First-fit interval allocator over `u32` offsets; adjacent free ranges coalesce on release.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FreeList(BTreeMap<u32, u32>);
|
||||||
|
|
||||||
|
impl FreeList {
|
||||||
|
fn allocate(&mut self, size: u32, align: u32) -> Option<u32> {
|
||||||
|
let (block, block_size, offset) = self.0.iter().find_map(|(&block, &block_size)| {
|
||||||
|
let offset = block.checked_add(align - 1)? & !(align - 1);
|
||||||
|
(offset.checked_add(size)? <= block + block_size).then_some((block, block_size, offset))
|
||||||
|
})?;
|
||||||
|
self.0.remove(&block);
|
||||||
|
if offset > block {
|
||||||
|
self.0.insert(block, offset - block);
|
||||||
|
}
|
||||||
|
let end = offset + size;
|
||||||
|
if end < block + block_size {
|
||||||
|
self.0.insert(end, block + block_size - end);
|
||||||
|
}
|
||||||
|
Some(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claims exactly `[offset, offset + size)` when that range is entirely free.
|
||||||
|
fn take(&mut self, offset: u32, size: u32) -> bool {
|
||||||
|
let Some((&block, &block_size)) = self.0.range(..=offset).next_back() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if offset + size > block + block_size {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.0.remove(&block);
|
||||||
|
if offset > block {
|
||||||
|
self.0.insert(block, offset - block);
|
||||||
|
}
|
||||||
|
let end = offset + size;
|
||||||
|
if end < block + block_size {
|
||||||
|
self.0.insert(end, block + block_size - end);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release(&mut self, mut offset: u32, mut size: u32) {
|
||||||
|
if let Some((&previous, &previous_size)) = self.0.range(..offset).next_back() {
|
||||||
|
if previous + previous_size == offset {
|
||||||
|
self.0.remove(&previous);
|
||||||
|
offset = previous;
|
||||||
|
size += previous_size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((&next, &next_size)) = self.0.range(offset..).next() {
|
||||||
|
if offset + size == next {
|
||||||
|
self.0.remove(&next);
|
||||||
|
size += next_size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.0.insert(offset, size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||||
pub struct Rows {
|
pub struct Rows {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub rows: u32,
|
pub rows: u32,
|
||||||
@@ -14,20 +72,22 @@ pub struct Rows {
|
|||||||
pub bytes: u32,
|
pub bytes: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Row spans handed out by `allocate_object`, keyed by their first row.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Slots {
|
struct Slots {
|
||||||
active: HashSet<u32>,
|
free: FreeList,
|
||||||
free: Vec<u32>,
|
spans: HashMap<u32, u32>,
|
||||||
next: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct RenderData {
|
pub struct RenderData {
|
||||||
arena: Box<[u8]>,
|
arena: Box<[u8]>,
|
||||||
arena_start: usize,
|
arena_start: usize,
|
||||||
base: u32,
|
base: u32,
|
||||||
free: BTreeMap<u32, u32>,
|
free: FreeList,
|
||||||
rows: HashMap<String, Rows>,
|
rows: HashMap<String, Rows>,
|
||||||
slots: HashMap<String, Slots>,
|
slots: HashMap<String, Slots>,
|
||||||
|
/// Relocated descriptors the writer has not committed yet; `rows` keeps the old storage live.
|
||||||
|
pending: HashMap<String, Rows>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenderData {
|
impl RenderData {
|
||||||
@@ -42,22 +102,25 @@ impl RenderData {
|
|||||||
arena,
|
arena,
|
||||||
arena_start: aligned - pointer,
|
arena_start: aligned - pointer,
|
||||||
base: aligned as u32,
|
base: aligned as u32,
|
||||||
free: BTreeMap::from([(0, capacity)]),
|
free: FreeList(BTreeMap::from([(0, capacity)])),
|
||||||
rows: HashMap::new(),
|
rows: HashMap::new(),
|
||||||
slots: HashMap::new(),
|
slots: HashMap::new(),
|
||||||
|
pending: HashMap::new(),
|
||||||
};
|
};
|
||||||
data.create_rows("signals".into(), 1, 32, "f32".into())?;
|
data.create_rows("signals".into(), 1, 32, "f32".into())?;
|
||||||
data.write_signal(5, 1.0);
|
data.write_signal(5, 1.0);
|
||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates or grows an array. The flag is set when the rows moved: the returned descriptor
|
||||||
|
/// is then staged and the writer must copy the old bytes into it and `commit` it.
|
||||||
pub fn create_rows(
|
pub fn create_rows(
|
||||||
&mut self,
|
&mut self,
|
||||||
name: String,
|
name: String,
|
||||||
rows: u32,
|
rows: u32,
|
||||||
stride: u32,
|
stride: u32,
|
||||||
format: String,
|
format: String,
|
||||||
) -> Result<Rows, &'static str> {
|
) -> Result<(Rows, bool), &'static str> {
|
||||||
if name.is_empty()
|
if name.is_empty()
|
||||||
|| rows == 0
|
|| rows == 0
|
||||||
|| stride < 16
|
|| stride < 16
|
||||||
@@ -71,12 +134,19 @@ impl RenderData {
|
|||||||
return Err("ROWS");
|
return Err("ROWS");
|
||||||
}
|
}
|
||||||
if rows <= current.rows {
|
if rows <= current.rows {
|
||||||
return Ok(current);
|
return Ok((current, false));
|
||||||
}
|
}
|
||||||
if name == "signals" {
|
if name == "signals" {
|
||||||
return Err("ROWS_BUILTIN");
|
return Err("ROWS_BUILTIN");
|
||||||
}
|
}
|
||||||
return self.grow_rows(current, rows);
|
let added = rows - current.rows;
|
||||||
|
let (descriptor, moved) = self.grow_rows(current, rows)?;
|
||||||
|
self.slots
|
||||||
|
.get_mut(&descriptor.name)
|
||||||
|
.unwrap()
|
||||||
|
.free
|
||||||
|
.release(descriptor.rows - added, added);
|
||||||
|
return Ok((descriptor, moved));
|
||||||
}
|
}
|
||||||
let bytes = rows.checked_mul(stride).ok_or("ARENA_OOM")?;
|
let bytes = rows.checked_mul(stride).ok_or("ARENA_OOM")?;
|
||||||
let offset = self.reserve(bytes)?;
|
let offset = self.reserve(bytes)?;
|
||||||
@@ -91,18 +161,23 @@ impl RenderData {
|
|||||||
bytes,
|
bytes,
|
||||||
};
|
};
|
||||||
self.rows.insert(name, descriptor.clone());
|
self.rows.insert(name, descriptor.clone());
|
||||||
self.slots.insert(descriptor.name.clone(), Slots::default());
|
let mut slots = Slots::default();
|
||||||
Ok(descriptor)
|
slots.free.release(0, rows);
|
||||||
|
self.slots.insert(descriptor.name.clone(), slots);
|
||||||
|
Ok((descriptor, false))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_rows(&mut self, name: &str) -> Result<(), &'static str> {
|
pub fn delete_rows(&mut self, name: &str) -> Result<(), &'static str> {
|
||||||
if name == "signals" {
|
if name == "signals" {
|
||||||
return Err("ROWS_BUILTIN");
|
return Err("ROWS_BUILTIN");
|
||||||
}
|
}
|
||||||
|
if self.pending.contains_key(name) {
|
||||||
|
return Err("ROWS_PENDING");
|
||||||
|
}
|
||||||
if self
|
if self
|
||||||
.slots
|
.slots
|
||||||
.get(name)
|
.get(name)
|
||||||
.is_some_and(|slots| !slots.active.is_empty())
|
.is_some_and(|slots| !slots.spans.is_empty())
|
||||||
{
|
{
|
||||||
return Err("ROWS_ACTIVE");
|
return Err("ROWS_ACTIVE");
|
||||||
}
|
}
|
||||||
@@ -128,75 +203,111 @@ impl RenderData {
|
|||||||
Some(&self.arena[start..start + rows.bytes as usize])
|
Some(&self.arena[start..start + rows.bytes as usize])
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn allocate_object(&mut self, name: &str) -> Result<(u32, bool), &'static str> {
|
/// Reserves `count` contiguous rows and returns the first one; grows geometrically when full.
|
||||||
|
/// The growth flags mirror `create_rows`: `(grew, moved)`.
|
||||||
|
pub fn allocate_object(
|
||||||
|
&mut self,
|
||||||
|
name: &str,
|
||||||
|
count: u32,
|
||||||
|
) -> Result<(u32, bool, bool), &'static str> {
|
||||||
if name == "signals" {
|
if name == "signals" {
|
||||||
return Err("ROWS_BUILTIN");
|
return Err("ROWS_BUILTIN");
|
||||||
}
|
}
|
||||||
let slots = self.slots.get(name).ok_or("ROWS_UNKNOWN")?;
|
if count == 0 {
|
||||||
let reused = slots.free.last().copied();
|
return Err("ROWS");
|
||||||
let id = reused.unwrap_or(slots.next);
|
|
||||||
let next = id.checked_add(1).ok_or("OBJECT_LIMIT")?;
|
|
||||||
let capacity = self.rows[name].rows;
|
|
||||||
let grew = id >= capacity;
|
|
||||||
if grew {
|
|
||||||
let descriptor = self.rows[name].clone();
|
|
||||||
self.grow_rows(descriptor, next)?;
|
|
||||||
}
|
}
|
||||||
let slots = self.slots.get_mut(name).unwrap();
|
let slots = self.slots.get_mut(name).ok_or("ROWS_UNKNOWN")?;
|
||||||
if reused.is_some() {
|
let mut grew = false;
|
||||||
slots.free.pop();
|
let mut moved = false;
|
||||||
} else {
|
let id = match slots.free.allocate(count, 1) {
|
||||||
slots.next = next;
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
let descriptor = self.rows[name].clone();
|
||||||
|
let rows = descriptor
|
||||||
|
.rows
|
||||||
|
.checked_mul(2)
|
||||||
|
.ok_or("OBJECT_LIMIT")?
|
||||||
|
.max(descriptor.rows.checked_add(count).ok_or("OBJECT_LIMIT")?);
|
||||||
|
let old_rows = descriptor.rows;
|
||||||
|
moved = self.grow_rows(descriptor, rows)?.1;
|
||||||
|
grew = true;
|
||||||
|
let slots = self.slots.get_mut(name).unwrap();
|
||||||
|
slots.free.release(old_rows, rows - old_rows);
|
||||||
|
slots.free.allocate(count, 1).unwrap()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.slots.get_mut(name).unwrap().spans.insert(id, count);
|
||||||
|
Ok((id, grew, moved))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The descriptor the writer should use: staged rows win over committed ones.
|
||||||
|
pub fn staged(&self, name: &str) -> Option<&Rows> {
|
||||||
|
self.pending.get(name).or_else(|| self.rows.get(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publishes staged relocations after the writer copied the old bytes into them.
|
||||||
|
pub fn commit(&mut self, names: &[String]) -> Result<(), &'static str> {
|
||||||
|
for name in names {
|
||||||
|
let staged = self.pending.remove(name).ok_or("ROWS_PENDING")?;
|
||||||
|
let old = self.rows.insert(name.clone(), staged).unwrap();
|
||||||
|
self.release(old.offset - self.base, old.bytes);
|
||||||
|
}
|
||||||
|
if !self.pending.is_empty() {
|
||||||
|
return Err("ROWS_PENDING");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops every staged relocation, for example when a batch fails half-way.
|
||||||
|
pub fn discard_pending(&mut self) {
|
||||||
|
for staged in std::mem::take(&mut self.pending).into_values() {
|
||||||
|
self.release(staged.offset - self.base, staged.bytes);
|
||||||
|
let rows = self.rows[&staged.name].rows;
|
||||||
|
let slots = self.slots.get_mut(&staged.name).unwrap();
|
||||||
|
assert!(slots.free.take(rows, staged.rows - rows));
|
||||||
}
|
}
|
||||||
slots.active.insert(id);
|
|
||||||
Ok((id, grew))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_object(&mut self, name: &str, id: u32) -> Result<(), &'static str> {
|
pub fn delete_object(&mut self, name: &str, id: u32) -> Result<(), &'static str> {
|
||||||
let slots = self.slots.get_mut(name).ok_or("ROWS_UNKNOWN")?;
|
let slots = self.slots.get_mut(name).ok_or("ROWS_UNKNOWN")?;
|
||||||
if !slots.active.remove(&id) {
|
let count = slots.spans.remove(&id).ok_or("OBJECT_UNKNOWN")?;
|
||||||
return Err("OBJECT_UNKNOWN");
|
slots.free.release(id, count);
|
||||||
}
|
|
||||||
let rows = self.rows[name].clone();
|
let rows = self.rows[name].clone();
|
||||||
let start = self.arena_start + (rows.offset - self.base + id * rows.stride) as usize;
|
let start = self.arena_start + (rows.offset - self.base + id * rows.stride) as usize;
|
||||||
self.arena[start..start + rows.stride as usize].fill(0);
|
self.arena[start..start + (count * rows.stride) as usize].fill(0);
|
||||||
slots.free.push(id);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn grow_rows(&mut self, mut descriptor: Rows, rows: u32) -> Result<Rows, &'static str> {
|
/// Extends in place when the next arena block is free; otherwise stages a zeroed region for
|
||||||
|
/// the writer to fill and `commit`, because only the writer knows when its last write landed.
|
||||||
|
fn grow_rows(&mut self, mut descriptor: Rows, rows: u32) -> Result<(Rows, bool), &'static str> {
|
||||||
|
if self.pending.contains_key(&descriptor.name) {
|
||||||
|
return Err("ROWS_PENDING");
|
||||||
|
}
|
||||||
let bytes = rows.checked_mul(descriptor.stride).ok_or("ARENA_OOM")?;
|
let bytes = rows.checked_mul(descriptor.stride).ok_or("ARENA_OOM")?;
|
||||||
let old_offset = descriptor.offset - self.base;
|
let old_offset = descriptor.offset - self.base;
|
||||||
let old_end = old_offset + descriptor.bytes;
|
let old_end = old_offset + descriptor.bytes;
|
||||||
let extra = bytes - descriptor.bytes;
|
let extra = bytes - descriptor.bytes;
|
||||||
if self
|
let moved = !self.free.take(old_end, extra);
|
||||||
.free
|
if moved {
|
||||||
.get(&old_end)
|
|
||||||
.is_some_and(|available| *available >= extra)
|
|
||||||
{
|
|
||||||
let available = self.free.remove(&old_end).unwrap();
|
|
||||||
if available > extra {
|
|
||||||
self.free.insert(old_end + extra, available - extra);
|
|
||||||
}
|
|
||||||
self.arena[self.arena_start + old_end as usize
|
|
||||||
..self.arena_start + (old_end + extra) as usize]
|
|
||||||
.fill(0);
|
|
||||||
} else {
|
|
||||||
let offset = self.reserve(bytes)?;
|
let offset = self.reserve(bytes)?;
|
||||||
let start = self.arena_start + offset as usize;
|
let start = self.arena_start + offset as usize;
|
||||||
self.arena[start..start + bytes as usize].fill(0);
|
self.arena[start..start + bytes as usize].fill(0);
|
||||||
self.arena.copy_within(
|
|
||||||
self.arena_start + old_offset as usize..self.arena_start + old_end as usize,
|
|
||||||
start,
|
|
||||||
);
|
|
||||||
self.release(old_offset, descriptor.bytes);
|
|
||||||
descriptor.offset = self.base + offset;
|
descriptor.offset = self.base + offset;
|
||||||
|
} else {
|
||||||
|
self.arena[self.arena_start + old_end as usize
|
||||||
|
..self.arena_start + (old_end + extra) as usize]
|
||||||
|
.fill(0);
|
||||||
}
|
}
|
||||||
descriptor.rows = rows;
|
descriptor.rows = rows;
|
||||||
descriptor.bytes = bytes;
|
descriptor.bytes = bytes;
|
||||||
self.rows
|
if moved {
|
||||||
.insert(descriptor.name.clone(), descriptor.clone());
|
&mut self.pending
|
||||||
Ok(descriptor)
|
} else {
|
||||||
|
&mut self.rows
|
||||||
|
}
|
||||||
|
.insert(descriptor.name.clone(), descriptor.clone());
|
||||||
|
Ok((descriptor, moved))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_signals(&mut self, delta: f32, frame: u32, elapsed: f32, fps: u32) {
|
pub fn update_signals(&mut self, delta: f32, frame: u32, elapsed: f32, fps: u32) {
|
||||||
@@ -244,54 +355,14 @@ impl RenderData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn reserve(&mut self, bytes: u32) -> Result<u32, &'static str> {
|
fn reserve(&mut self, bytes: u32) -> Result<u32, &'static str> {
|
||||||
let (block, block_bytes, offset) = self
|
self.free.allocate(bytes, ALIGNMENT).ok_or("ARENA_OOM")
|
||||||
.free
|
|
||||||
.iter()
|
|
||||||
.find_map(|(&block, &block_bytes)| {
|
|
||||||
let offset = align(block)?;
|
|
||||||
(offset.checked_add(bytes)? <= block.checked_add(block_bytes)?).then_some((
|
|
||||||
block,
|
|
||||||
block_bytes,
|
|
||||||
offset,
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.ok_or("ARENA_OOM")?;
|
|
||||||
self.free.remove(&block);
|
|
||||||
if offset > block {
|
|
||||||
self.free.insert(block, offset - block);
|
|
||||||
}
|
|
||||||
let end = offset + bytes;
|
|
||||||
let block_end = block + block_bytes;
|
|
||||||
if end < block_end {
|
|
||||||
self.free.insert(end, block_end - end);
|
|
||||||
}
|
|
||||||
Ok(offset)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn release(&mut self, mut offset: u32, mut bytes: u32) {
|
fn release(&mut self, offset: u32, bytes: u32) {
|
||||||
if let Some((&previous, &previous_bytes)) = self.free.range(..offset).next_back() {
|
self.free.release(offset, bytes);
|
||||||
if previous + previous_bytes == offset {
|
|
||||||
self.free.remove(&previous);
|
|
||||||
offset = previous;
|
|
||||||
bytes += previous_bytes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some((&next, &next_bytes)) = self.free.range(offset..).next() {
|
|
||||||
if offset + bytes == next {
|
|
||||||
self.free.remove(&next);
|
|
||||||
bytes += next_bytes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.free.insert(offset, bytes);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn align(value: u32) -> Option<u32> {
|
|
||||||
value
|
|
||||||
.checked_add(ALIGNMENT - 1)
|
|
||||||
.map(|value| value & !(ALIGNMENT - 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "render_data_tests.rs"]
|
#[path = "render_data_tests.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -30,3 +30,119 @@ fn skip_and_bundle_signals_hold_dirty_work() {
|
|||||||
data.loadout_ready();
|
data.loadout_ready();
|
||||||
assert!(data.begin_render());
|
assert!(data.begin_render());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rows_of(data: &RenderData, name: &str) -> u32 {
|
||||||
|
data.rows(name).unwrap().rows
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spans_reuse_coalesced_gaps_before_growing() {
|
||||||
|
let mut data = RenderData::new(4096).unwrap();
|
||||||
|
data.create_rows("v".into(), 8, 16, "f32".into()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(data.allocate_object("v", 2).unwrap(), (0, false, false));
|
||||||
|
assert_eq!(data.allocate_object("v", 3).unwrap(), (2, false, false));
|
||||||
|
assert_eq!(data.allocate_object("v", 3).unwrap(), (5, false, false));
|
||||||
|
assert_eq!(rows_of(&data, "v"), 8);
|
||||||
|
|
||||||
|
// Freeing the two middle spans leaves one coalesced hole of 6 rows.
|
||||||
|
data.delete_object("v", 2).unwrap();
|
||||||
|
data.delete_object("v", 5).unwrap();
|
||||||
|
assert_eq!(data.delete_object("v", 5), Err("OBJECT_UNKNOWN"));
|
||||||
|
assert_eq!(data.allocate_object("v", 6).unwrap(), (2, false, false));
|
||||||
|
assert_eq!(rows_of(&data, "v"), 8);
|
||||||
|
|
||||||
|
// A span that does not fit doubles capacity instead of growing to the exact need; the only
|
||||||
|
// array in the arena extends in place, so nothing is staged.
|
||||||
|
assert_eq!(data.allocate_object("v", 1).unwrap(), (8, true, false));
|
||||||
|
assert_eq!(rows_of(&data, "v"), 16);
|
||||||
|
assert_eq!(data.allocate_object("v", 40).unwrap(), (9, true, false));
|
||||||
|
assert_eq!(rows_of(&data, "v"), 56);
|
||||||
|
assert_eq!(data.allocate_object("v", 0), Err("ROWS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_a_span_zeroes_every_row_and_keeps_neighbours() {
|
||||||
|
let mut data = RenderData::new(4096).unwrap();
|
||||||
|
data.create_rows("v".into(), 4, 16, "u32".into()).unwrap();
|
||||||
|
let (first, ..) = data.allocate_object("v", 2).unwrap();
|
||||||
|
let (second, ..) = data.allocate_object("v", 1).unwrap();
|
||||||
|
let base = (data.rows("v").unwrap().offset - data.base) as usize + data.arena_start;
|
||||||
|
data.arena[base..base + 48].fill(0xAB);
|
||||||
|
|
||||||
|
data.delete_object("v", first).unwrap();
|
||||||
|
assert!(data.bytes("v").unwrap()[..32].iter().all(|byte| *byte == 0));
|
||||||
|
assert!(data.bytes("v").unwrap()[32..48]
|
||||||
|
.iter()
|
||||||
|
.all(|byte| *byte == 0xAB));
|
||||||
|
assert_eq!(data.delete_rows("v"), Err("ROWS_ACTIVE"));
|
||||||
|
data.delete_object("v", second).unwrap();
|
||||||
|
data.delete_rows("v").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relocation_is_staged_until_the_writer_commits() {
|
||||||
|
let mut data = RenderData::new(4096).unwrap();
|
||||||
|
data.create_rows("a".into(), 1, 16, "u32".into()).unwrap();
|
||||||
|
data.create_rows("b".into(), 1, 16, "u32".into()).unwrap();
|
||||||
|
assert_eq!(data.allocate_object("a", 1).unwrap(), (0, false, false));
|
||||||
|
let old = data.rows("a").unwrap().clone();
|
||||||
|
let base = (old.offset - data.base) as usize + data.arena_start;
|
||||||
|
data.arena[base..base + 16].fill(0xCD);
|
||||||
|
|
||||||
|
// "b" sits right behind "a", so growing "a" must move it: the worker stages a zeroed region
|
||||||
|
// and keeps rendering from (and the arena owning) the old bytes.
|
||||||
|
let (staged, moved) = data.create_rows("a".into(), 5, 16, "u32".into()).unwrap();
|
||||||
|
assert!(moved);
|
||||||
|
assert_eq!(staged.rows, 5);
|
||||||
|
assert_ne!(staged.offset, old.offset);
|
||||||
|
assert_eq!(data.rows("a").unwrap().offset, old.offset);
|
||||||
|
assert_eq!(data.staged("a").unwrap().offset, staged.offset);
|
||||||
|
assert_eq!(
|
||||||
|
data.create_rows("a".into(), 6, 16, "u32".into()),
|
||||||
|
Err("ROWS_PENDING")
|
||||||
|
);
|
||||||
|
assert_eq!(data.delete_rows("a"), Err("ROWS_PENDING"));
|
||||||
|
assert!(!data.free.take(old.offset - data.base, old.bytes));
|
||||||
|
|
||||||
|
// The writer keeps writing the old rows until it copies them over and commits.
|
||||||
|
data.arena[base + 4..base + 8].fill(0xEF);
|
||||||
|
let target = (staged.offset - data.base) as usize + data.arena_start;
|
||||||
|
data.arena.copy_within(base..base + 16, target);
|
||||||
|
data.commit(&["a".into()]).unwrap();
|
||||||
|
assert_eq!(data.rows("a").unwrap().offset, staged.offset);
|
||||||
|
assert_eq!(
|
||||||
|
&data.bytes("a").unwrap()[..8],
|
||||||
|
&[0xCD, 0xCD, 0xCD, 0xCD, 0xEF, 0xEF, 0xEF, 0xEF]
|
||||||
|
);
|
||||||
|
assert!(data.bytes("a").unwrap()[16..].iter().all(|byte| *byte == 0));
|
||||||
|
assert!(data.free.take(old.offset - data.base, old.bytes));
|
||||||
|
assert_eq!(data.commit(&["a".into()]), Err("ROWS_PENDING"));
|
||||||
|
|
||||||
|
// Newly staged rows already feed the span allocator; the writer owns the staged region.
|
||||||
|
data.create_rows("c".into(), 1, 16, "u32".into()).unwrap();
|
||||||
|
assert_eq!(data.allocate_object("a", 4).unwrap(), (1, false, false));
|
||||||
|
let (id, grew, moved) = data.allocate_object("a", 1).unwrap();
|
||||||
|
assert_eq!((id, grew, moved), (5, true, true));
|
||||||
|
assert_eq!(data.staged("a").unwrap().rows, 10);
|
||||||
|
assert_eq!(data.rows("a").unwrap().rows, 5);
|
||||||
|
data.commit(&["a".into()]).unwrap();
|
||||||
|
assert_eq!(rows_of(&data, "a"), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discarding_a_staged_relocation_restores_capacity() {
|
||||||
|
let mut data = RenderData::new(4096).unwrap();
|
||||||
|
data.create_rows("a".into(), 2, 16, "u32".into()).unwrap();
|
||||||
|
data.create_rows("b".into(), 1, 16, "u32".into()).unwrap();
|
||||||
|
assert_eq!(data.allocate_object("a", 2).unwrap(), (0, false, false));
|
||||||
|
let (staged, moved) = data.create_rows("a".into(), 8, 16, "u32".into()).unwrap();
|
||||||
|
assert!(moved);
|
||||||
|
|
||||||
|
data.discard_pending();
|
||||||
|
assert_eq!(rows_of(&data, "a"), 2);
|
||||||
|
assert!(data.free.take(staged.offset - data.base, staged.bytes));
|
||||||
|
// Rows 2..8 must not be handed out: they only existed in the discarded region.
|
||||||
|
assert_eq!(data.allocate_object("a", 1).unwrap(), (2, true, false));
|
||||||
|
assert_eq!(rows_of(&data, "a"), 4);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::num::NonZeroU64;
|
use std::num::NonZeroU64;
|
||||||
use std::sync::atomic::{AtomicU8, Ordering};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use futures_channel::oneshot;
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::gpu::Wgpu;
|
use crate::gpu::Wgpu;
|
||||||
use crate::graph::{Binding, Execution, Extent, Pass, RenderGraph, RenderPipeline, Texture};
|
use crate::graph::{Binding, Execution, Extent, Pass, RenderGraph, RenderPipeline, Texture};
|
||||||
use crate::render_data::RenderData;
|
use crate::render_data::RenderData;
|
||||||
|
use crate::store::Loadout;
|
||||||
|
|
||||||
pub struct GpuResources {
|
pub struct GpuResources {
|
||||||
pub buffers: HashMap<String, GpuBuffer>,
|
pub buffers: HashMap<String, GpuBuffer>,
|
||||||
@@ -30,7 +31,7 @@ pub struct GpuProfiler {
|
|||||||
|
|
||||||
pub struct ProfileMap {
|
pub struct ProfileMap {
|
||||||
readback: Arc<wgpu::Buffer>,
|
readback: Arc<wgpu::Buffer>,
|
||||||
state: Arc<AtomicU8>,
|
completion: oneshot::Receiver<Result<(), wgpu::BufferAsyncError>>,
|
||||||
labels: Vec<String>,
|
labels: Vec<String>,
|
||||||
timestamp_period: f32,
|
timestamp_period: f32,
|
||||||
}
|
}
|
||||||
@@ -38,6 +39,7 @@ pub struct ProfileMap {
|
|||||||
pub struct GpuBuffer {
|
pub struct GpuBuffer {
|
||||||
pub buffer: wgpu::Buffer,
|
pub buffer: wgpu::Buffer,
|
||||||
pub source: String,
|
pub source: String,
|
||||||
|
pub usage: wgpu::BufferUsages,
|
||||||
pub sync_each_frame: bool,
|
pub sync_each_frame: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,28 +67,48 @@ pub enum GpuPass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl GpuResources {
|
impl GpuResources {
|
||||||
|
/// Builds the GPU side of `graph`. Buffers whose source array, size, and usage are unchanged
|
||||||
|
/// and pipelines whose declaration is unchanged are taken over from `previous` instead of being
|
||||||
|
/// recreated and re-uploaded; bind groups and bundles are always rebuilt.
|
||||||
pub fn activate(
|
pub fn activate(
|
||||||
graph: &RenderGraph,
|
graph: &RenderGraph,
|
||||||
gpu: &Wgpu,
|
gpu: &Wgpu,
|
||||||
data: &RenderData,
|
data: &RenderData,
|
||||||
previous: Option<&Self>,
|
previous: Option<&Loadout>,
|
||||||
) -> Result<Self, String> {
|
) -> Result<Self, String> {
|
||||||
let mut buffers = HashMap::new();
|
let mut buffers = HashMap::new();
|
||||||
for source in &graph.resources.buffers {
|
for source in &graph.resources.buffers {
|
||||||
let rows = data.rows(&source.array).ok_or("GRAPH_ARRAY_UNKNOWN")?;
|
let rows = data.rows(&source.array).ok_or("GRAPH_ARRAY_UNKNOWN")?;
|
||||||
let buffer = gpu.device.create_buffer(&wgpu::BufferDescriptor {
|
let size = u64::from(rows.bytes.max(4));
|
||||||
label: Some(&source.id),
|
let usage = buffer_usage(&source.usage)?;
|
||||||
size: u64::from(rows.bytes.max(4)),
|
let reused = previous
|
||||||
usage: buffer_usage(&source.usage)?,
|
.and_then(|previous| previous.resources.buffers.get(&source.id))
|
||||||
mapped_at_creation: false,
|
.filter(|buffer| {
|
||||||
});
|
buffer.source == source.array
|
||||||
gpu.queue
|
&& buffer.usage == usage
|
||||||
.write_buffer(&buffer, 0, data.bytes(&source.array).unwrap());
|
&& buffer.buffer.size() == size
|
||||||
|
})
|
||||||
|
.map(|buffer| buffer.buffer.clone());
|
||||||
|
let buffer = match reused {
|
||||||
|
Some(buffer) => buffer,
|
||||||
|
None => {
|
||||||
|
let buffer = gpu.device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some(&source.id),
|
||||||
|
size,
|
||||||
|
usage,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
gpu.queue
|
||||||
|
.write_buffer(&buffer, 0, data.bytes(&source.array).unwrap());
|
||||||
|
buffer
|
||||||
|
}
|
||||||
|
};
|
||||||
buffers.insert(
|
buffers.insert(
|
||||||
source.id.clone(),
|
source.id.clone(),
|
||||||
GpuBuffer {
|
GpuBuffer {
|
||||||
buffer,
|
buffer,
|
||||||
source: source.array.clone(),
|
source: source.array.clone(),
|
||||||
|
usage,
|
||||||
sync_each_frame: source.sync == "frame",
|
sync_each_frame: source.sync == "frame",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -102,11 +124,12 @@ impl GpuResources {
|
|||||||
let key = source.key()?;
|
let key = source.key()?;
|
||||||
if !source.transient {
|
if !source.transient {
|
||||||
if let Some(texture) = previous
|
if let Some(texture) = previous
|
||||||
.and_then(|resources| {
|
.and_then(|previous| {
|
||||||
resources
|
previous
|
||||||
|
.resources
|
||||||
.texture_slots
|
.texture_slots
|
||||||
.get(&source.id)
|
.get(&source.id)
|
||||||
.map(|slot| &resources.textures[*slot])
|
.map(|slot| &previous.resources.textures[*slot])
|
||||||
})
|
})
|
||||||
.filter(|texture| texture.key == key)
|
.filter(|texture| texture.key == key)
|
||||||
{
|
{
|
||||||
@@ -147,7 +170,23 @@ impl GpuResources {
|
|||||||
.render
|
.render
|
||||||
.iter()
|
.iter()
|
||||||
.map(|source| {
|
.map(|source| {
|
||||||
create_render_pipeline(source, gpu).map(|pipeline| (source.id.clone(), pipeline))
|
let reused = previous.and_then(|previous| {
|
||||||
|
previous
|
||||||
|
.graph
|
||||||
|
.pipelines
|
||||||
|
.render
|
||||||
|
.iter()
|
||||||
|
.find(|candidate| candidate.same_as(source))
|
||||||
|
.and_then(|candidate| {
|
||||||
|
previous.resources.render_pipelines.get(&candidate.id)
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
});
|
||||||
|
match reused {
|
||||||
|
Some(pipeline) => Ok(pipeline),
|
||||||
|
None => create_render_pipeline(source, gpu),
|
||||||
|
}
|
||||||
|
.map(|pipeline| (source.id.clone(), pipeline))
|
||||||
})
|
})
|
||||||
.collect::<Result<HashMap<_, _>, _>>()?;
|
.collect::<Result<HashMap<_, _>, _>>()?;
|
||||||
let compute_pipelines = graph
|
let compute_pipelines = graph
|
||||||
@@ -155,6 +194,21 @@ impl GpuResources {
|
|||||||
.compute
|
.compute
|
||||||
.iter()
|
.iter()
|
||||||
.map(|source| {
|
.map(|source| {
|
||||||
|
let reused = previous.and_then(|previous| {
|
||||||
|
previous
|
||||||
|
.graph
|
||||||
|
.pipelines
|
||||||
|
.compute
|
||||||
|
.iter()
|
||||||
|
.find(|candidate| candidate.same_as(source))
|
||||||
|
.and_then(|candidate| {
|
||||||
|
previous.resources.compute_pipelines.get(&candidate.id)
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
});
|
||||||
|
if let Some(pipeline) = reused {
|
||||||
|
return Ok::<_, String>((source.id.clone(), pipeline));
|
||||||
|
}
|
||||||
let module = gpu
|
let module = gpu
|
||||||
.device
|
.device
|
||||||
.create_shader_module(wgpu::ShaderModuleDescriptor {
|
.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
@@ -171,7 +225,7 @@ impl GpuResources {
|
|||||||
compilation_options: Default::default(),
|
compilation_options: Default::default(),
|
||||||
cache: None,
|
cache: None,
|
||||||
});
|
});
|
||||||
Ok::<_, String>((source.id.clone(), pipeline))
|
Ok((source.id.clone(), pipeline))
|
||||||
})
|
})
|
||||||
.collect::<Result<HashMap<_, _>, _>>()?;
|
.collect::<Result<HashMap<_, _>, _>>()?;
|
||||||
|
|
||||||
@@ -212,22 +266,14 @@ impl GpuResources {
|
|||||||
for execution in &graph.executions {
|
for execution in &graph.executions {
|
||||||
let compiled = match execution {
|
let compiled = match execution {
|
||||||
Execution::Render(passes) => {
|
Execution::Render(passes) => {
|
||||||
let (bundle, draws) = resources.render_bundle(graph, passes, gpu)?;
|
let (bundle, draws) = resources.render_bundle(graph, passes, gpu, data)?;
|
||||||
|
let first = &graph.passes[passes[0]].id;
|
||||||
|
let last = &graph.passes[*passes.last().unwrap()].id;
|
||||||
GpuPass::Render {
|
GpuPass::Render {
|
||||||
label: if passes.len() == 1 {
|
label: if passes.len() == 1 {
|
||||||
graph.passes[passes[0]].id.clone()
|
format!("{first} ({draws} draws)")
|
||||||
} else if passes
|
|
||||||
.iter()
|
|
||||||
.all(|index| graph.passes[*index].id.starts_with("forward-"))
|
|
||||||
{
|
|
||||||
format!("Forward ({draws} draws)")
|
|
||||||
} else if passes
|
|
||||||
.iter()
|
|
||||||
.all(|index| graph.passes[*index].id.starts_with("depth-"))
|
|
||||||
{
|
|
||||||
format!("Depth ({draws} draws)")
|
|
||||||
} else {
|
} else {
|
||||||
format!("Render ({draws} draws)")
|
format!("{first} … {last} ({draws} draws)")
|
||||||
},
|
},
|
||||||
first: passes[0],
|
first: passes[0],
|
||||||
last: *passes.last().unwrap(),
|
last: *passes.last().unwrap(),
|
||||||
@@ -259,6 +305,28 @@ impl GpuResources {
|
|||||||
Ok(resources)
|
Ok(resources)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copies rows `first .. first + count` of a source array into every GPU buffer bound to it.
|
||||||
|
pub fn sync_rows(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
first: u32,
|
||||||
|
count: u32,
|
||||||
|
gpu: &Wgpu,
|
||||||
|
data: &RenderData,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let rows = data.rows(name).ok_or("ROWS_UNKNOWN")?;
|
||||||
|
let end = first
|
||||||
|
.checked_add(count)
|
||||||
|
.filter(|end| *end <= rows.rows)
|
||||||
|
.ok_or("ROW_RANGE")?;
|
||||||
|
let start = (first * rows.stride) as usize;
|
||||||
|
let bytes = &data.bytes(name).unwrap()[start..(end * rows.stride) as usize];
|
||||||
|
for buffer in self.buffers.values().filter(|buffer| buffer.source == name) {
|
||||||
|
gpu.queue.write_buffer(&buffer.buffer, start as u64, bytes);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn texture_view(&self, id: &str) -> Option<&wgpu::TextureView> {
|
pub fn texture_view(&self, id: &str) -> Option<&wgpu::TextureView> {
|
||||||
self.texture_slots
|
self.texture_slots
|
||||||
.get(id)
|
.get(id)
|
||||||
@@ -349,16 +417,15 @@ impl GpuResources {
|
|||||||
|
|
||||||
pub fn map_profile(&self, timestamp_period: f32) -> Option<ProfileMap> {
|
pub fn map_profile(&self, timestamp_period: f32) -> Option<ProfileMap> {
|
||||||
let profiler = self.profiler.as_ref()?;
|
let profiler = self.profiler.as_ref()?;
|
||||||
let state = Arc::new(AtomicU8::new(0));
|
let (complete, completion) = oneshot::channel();
|
||||||
let callback = state.clone();
|
|
||||||
profiler
|
profiler
|
||||||
.readback
|
.readback
|
||||||
.map_async(wgpu::MapMode::Read, .., move |result| {
|
.map_async(wgpu::MapMode::Read, .., move |result| {
|
||||||
callback.store(if result.is_ok() { 1 } else { 2 }, Ordering::Release);
|
let _ = complete.send(result);
|
||||||
});
|
});
|
||||||
Some(ProfileMap {
|
Some(ProfileMap {
|
||||||
readback: profiler.readback.clone(),
|
readback: profiler.readback.clone(),
|
||||||
state,
|
completion,
|
||||||
labels: self
|
labels: self
|
||||||
.passes
|
.passes
|
||||||
.iter()
|
.iter()
|
||||||
@@ -370,11 +437,8 @@ impl GpuResources {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ProfileMap {
|
impl ProfileMap {
|
||||||
pub fn state(&self) -> u8 {
|
pub async fn read(self) -> Option<Vec<(String, f64)>> {
|
||||||
self.state.load(Ordering::Acquire)
|
self.completion.await.ok()?.ok()?;
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read(self) -> Vec<(String, f64)> {
|
|
||||||
let bytes = self.readback.get_mapped_range(..);
|
let bytes = self.readback.get_mapped_range(..);
|
||||||
let values = bytes
|
let values = bytes
|
||||||
.chunks_exact(8)
|
.chunks_exact(8)
|
||||||
@@ -395,7 +459,7 @@ impl ProfileMap {
|
|||||||
.collect();
|
.collect();
|
||||||
drop(bytes);
|
drop(bytes);
|
||||||
self.readback.unmap();
|
self.readback.unmap();
|
||||||
profile
|
Some(profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,6 +469,7 @@ impl GpuResources {
|
|||||||
graph: &RenderGraph,
|
graph: &RenderGraph,
|
||||||
passes: &[usize],
|
passes: &[usize],
|
||||||
gpu: &Wgpu,
|
gpu: &Wgpu,
|
||||||
|
data: &RenderData,
|
||||||
) -> Result<(wgpu::RenderBundle, usize), String> {
|
) -> Result<(wgpu::RenderBundle, usize), String> {
|
||||||
let pass = &graph.passes[passes[0]];
|
let pass = &graph.passes[passes[0]];
|
||||||
let declaration = graph
|
let declaration = graph
|
||||||
@@ -441,9 +506,8 @@ impl GpuResources {
|
|||||||
let mut previous_pipeline = None;
|
let mut previous_pipeline = None;
|
||||||
let mut previous_bindings: Option<&[Binding]> = None;
|
let mut previous_bindings: Option<&[Binding]> = None;
|
||||||
let mut draws = 0;
|
let mut draws = 0;
|
||||||
let mut at = 0;
|
for &at in passes {
|
||||||
while at < passes.len() {
|
let pass = &graph.passes[at];
|
||||||
let pass = &graph.passes[passes[at]];
|
|
||||||
let pipeline = self
|
let pipeline = self
|
||||||
.render_pipelines
|
.render_pipelines
|
||||||
.get(&pass.pipeline)
|
.get(&pass.pipeline)
|
||||||
@@ -469,7 +533,7 @@ impl GpuResources {
|
|||||||
.buffer;
|
.buffer;
|
||||||
encoder.set_vertex_buffer(binding.slot, buffer.slice(binding.offset..));
|
encoder.set_vertex_buffer(binding.slot, buffer.slice(binding.offset..));
|
||||||
}
|
}
|
||||||
if let Some(binding) = &pass.index_buffer {
|
let indexed = if let Some(binding) = &pass.index_buffer {
|
||||||
let buffer = &self
|
let buffer = &self
|
||||||
.buffers
|
.buffers
|
||||||
.get(&binding.resource)
|
.get(&binding.resource)
|
||||||
@@ -479,26 +543,53 @@ impl GpuResources {
|
|||||||
buffer.slice(binding.offset..),
|
buffer.slice(binding.offset..),
|
||||||
parse(&binding.format, "GRAPH_INDEX_FORMAT")?,
|
parse(&binding.format, "GRAPH_INDEX_FORMAT")?,
|
||||||
);
|
);
|
||||||
let mut instances = pass.draw.instances;
|
true
|
||||||
while at + 1 < passes.len()
|
|
||||||
&& can_instance(pass, &graph.passes[passes[at + 1]], instances)
|
|
||||||
{
|
|
||||||
at += 1;
|
|
||||||
instances += graph.passes[passes[at]].draw.instances;
|
|
||||||
}
|
|
||||||
encoder.draw_indexed(
|
|
||||||
pass.draw.first_index..pass.draw.first_index + pass.draw.indices,
|
|
||||||
pass.draw.base_vertex,
|
|
||||||
pass.draw.first_instance..pass.draw.first_instance + instances,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
encoder.draw(
|
false
|
||||||
pass.draw.first_vertex..pass.draw.first_vertex + pass.draw.vertices,
|
};
|
||||||
pass.draw.first_instance..pass.draw.first_instance + pass.draw.instances,
|
match (&pass.indirect, indexed) {
|
||||||
);
|
(Some(indirect), indexed) => {
|
||||||
|
let buffer = self
|
||||||
|
.buffers
|
||||||
|
.get(&indirect.resource)
|
||||||
|
.ok_or("GRAPH_RESOURCE_UNKNOWN")?;
|
||||||
|
let rows = data.rows(&buffer.source).ok_or("GRAPH_ARRAY_UNKNOWN")?;
|
||||||
|
let end = match indirect.count {
|
||||||
|
Some(count) => indirect.first.checked_add(count),
|
||||||
|
None => Some(rows.rows),
|
||||||
|
}
|
||||||
|
.filter(|end| indirect.first <= *end && *end <= rows.rows)
|
||||||
|
.ok_or("GRAPH_INDIRECT")?;
|
||||||
|
if rows.stride < if indexed { 20 } else { 16 } {
|
||||||
|
return Err("GRAPH_INDIRECT".into());
|
||||||
|
}
|
||||||
|
let count = end - indirect.first;
|
||||||
|
for row in indirect.first..end {
|
||||||
|
let offset = u64::from(row) * u64::from(rows.stride);
|
||||||
|
if indexed {
|
||||||
|
encoder.draw_indexed_indirect(&buffer.buffer, offset);
|
||||||
|
} else {
|
||||||
|
encoder.draw_indirect(&buffer.buffer, offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
draws += count as usize;
|
||||||
|
}
|
||||||
|
(None, true) => {
|
||||||
|
encoder.draw_indexed(
|
||||||
|
pass.draw.first_index..pass.draw.first_index + pass.draw.indices,
|
||||||
|
pass.draw.base_vertex,
|
||||||
|
pass.draw.first_instance..pass.draw.first_instance + pass.draw.instances,
|
||||||
|
);
|
||||||
|
draws += 1;
|
||||||
|
}
|
||||||
|
(None, false) => {
|
||||||
|
encoder.draw(
|
||||||
|
pass.draw.first_vertex..pass.draw.first_vertex + pass.draw.vertices,
|
||||||
|
pass.draw.first_instance..pass.draw.first_instance + pass.draw.instances,
|
||||||
|
);
|
||||||
|
draws += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
draws += 1;
|
|
||||||
at += 1;
|
|
||||||
}
|
}
|
||||||
Ok((
|
Ok((
|
||||||
encoder.finish(&wgpu::RenderBundleDescriptor {
|
encoder.finish(&wgpu::RenderBundleDescriptor {
|
||||||
@@ -554,18 +645,6 @@ impl GpuResources {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn can_instance(first: &Pass, next: &Pass, instances: u32) -> bool {
|
|
||||||
first.pipeline == next.pipeline
|
|
||||||
&& first.bindings == next.bindings
|
|
||||||
&& first.vertex_buffers == next.vertex_buffers
|
|
||||||
&& first.index_buffer == next.index_buffer
|
|
||||||
&& first.draw.indices != 0
|
|
||||||
&& first.draw.indices == next.draw.indices
|
|
||||||
&& first.draw.first_index == next.draw.first_index
|
|
||||||
&& first.draw.base_vertex == next.draw.base_vertex
|
|
||||||
&& first.draw.first_instance + instances == next.draw.first_instance
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GpuPass {
|
impl GpuPass {
|
||||||
fn label(&self) -> &str {
|
fn label(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ pub struct PipelineDeclarations {
|
|||||||
pub compute: Vec<ComputePipeline>,
|
pub compute: Vec<ComputePipeline>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Deserialize)]
|
#[derive(Clone, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct RenderPipeline {
|
pub struct RenderPipeline {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -102,7 +102,7 @@ pub struct RenderPipeline {
|
|||||||
pub multisample: Value,
|
pub multisample: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Deserialize)]
|
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
||||||
pub struct ComputePipeline {
|
pub struct ComputePipeline {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub code: String,
|
pub code: String,
|
||||||
@@ -110,7 +110,7 @@ pub struct ComputePipeline {
|
|||||||
pub entry: String,
|
pub entry: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Deserialize)]
|
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
||||||
pub struct VertexStage {
|
pub struct VertexStage {
|
||||||
#[serde(default = "vertex_entry")]
|
#[serde(default = "vertex_entry")]
|
||||||
pub entry: String,
|
pub entry: String,
|
||||||
@@ -127,7 +127,7 @@ impl Default for VertexStage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default, Deserialize)]
|
#[derive(Clone, Default, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct VertexBuffer {
|
pub struct VertexBuffer {
|
||||||
pub array_stride: u64,
|
pub array_stride: u64,
|
||||||
@@ -137,7 +137,7 @@ pub struct VertexBuffer {
|
|||||||
pub attributes: Vec<VertexAttribute>,
|
pub attributes: Vec<VertexAttribute>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Deserialize)]
|
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct VertexAttribute {
|
pub struct VertexAttribute {
|
||||||
pub format: String,
|
pub format: String,
|
||||||
@@ -145,7 +145,7 @@ pub struct VertexAttribute {
|
|||||||
pub shader_location: u32,
|
pub shader_location: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Deserialize)]
|
#[derive(Clone, Deserialize, PartialEq)]
|
||||||
pub struct FragmentStage {
|
pub struct FragmentStage {
|
||||||
#[serde(default = "fragment_entry")]
|
#[serde(default = "fragment_entry")]
|
||||||
pub entry: String,
|
pub entry: String,
|
||||||
@@ -162,7 +162,7 @@ impl Default for FragmentStage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Deserialize)]
|
#[derive(Clone, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct FragmentTarget {
|
pub struct FragmentTarget {
|
||||||
pub format: String,
|
pub format: String,
|
||||||
@@ -192,10 +192,23 @@ pub struct Pass {
|
|||||||
pub index_buffer: Option<IndexBinding>,
|
pub index_buffer: Option<IndexBinding>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub draw: Draw,
|
pub draw: Draw,
|
||||||
|
pub indirect: Option<IndirectBinding>,
|
||||||
#[serde(default = "dispatch")]
|
#[serde(default = "dispatch")]
|
||||||
pub dispatch: [u32; 3],
|
pub dispatch: [u32; 3],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Draw commands read from rows of an `indirect` buffer: one command per row starting at
|
||||||
|
/// `first`, `count` rows (default: every row to the end of the array). The command occupies the
|
||||||
|
/// first 16 bytes (non-indexed) or 20 bytes (indexed) of each row; the remainder of the row is
|
||||||
|
/// free for the caller.
|
||||||
|
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct IndirectBinding {
|
||||||
|
pub resource: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub first: u32,
|
||||||
|
pub count: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
||||||
pub struct Binding {
|
pub struct Binding {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -344,6 +357,17 @@ impl RenderGraph {
|
|||||||
"compute" => _ = compute_pipelines.insert(pass.pipeline.clone()),
|
"compute" => _ = compute_pipelines.insert(pass.pipeline.clone()),
|
||||||
_ => return Err("GRAPH_PASS"),
|
_ => return Err("GRAPH_PASS"),
|
||||||
}
|
}
|
||||||
|
if let Some(indirect) = &pass.indirect {
|
||||||
|
let usable = self
|
||||||
|
.resources
|
||||||
|
.buffers
|
||||||
|
.iter()
|
||||||
|
.find(|buffer| buffer.id == indirect.resource)
|
||||||
|
.is_some_and(|buffer| buffer.usage.iter().any(|usage| usage == "indirect"));
|
||||||
|
if !usable || pass.draw != Draw::default() {
|
||||||
|
return Err("GRAPH_INDIRECT");
|
||||||
|
}
|
||||||
|
}
|
||||||
for id in pass.resources() {
|
for id in pass.resources() {
|
||||||
used.insert(id.to_owned());
|
used.insert(id.to_owned());
|
||||||
lifetimes
|
lifetimes
|
||||||
@@ -495,7 +519,7 @@ impl RenderGraph {
|
|||||||
.resources
|
.resources
|
||||||
.buffers
|
.buffers
|
||||||
.iter()
|
.iter()
|
||||||
.any(|buffer| !matches!(buffer.sync.as_str(), "frame" | "loadout"))
|
.any(|buffer| !matches!(buffer.sync.as_str(), "frame" | "manual"))
|
||||||
{
|
{
|
||||||
return Err("GRAPH_BUFFER_SYNC");
|
return Err("GRAPH_BUFFER_SYNC");
|
||||||
}
|
}
|
||||||
@@ -532,10 +556,29 @@ impl Pass {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|value| value.resource.as_str()),
|
.map(|value| value.resource.as_str()),
|
||||||
)
|
)
|
||||||
|
.chain(self.indirect.iter().map(|value| value.resource.as_str()))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl RenderPipeline {
|
||||||
|
/// Same GPU pipeline regardless of declaration ID.
|
||||||
|
pub(crate) fn same_as(&self, other: &Self) -> bool {
|
||||||
|
self.code == other.code
|
||||||
|
&& self.vertex == other.vertex
|
||||||
|
&& self.fragment == other.fragment
|
||||||
|
&& self.primitive == other.primitive
|
||||||
|
&& self.depth_stencil == other.depth_stencil
|
||||||
|
&& self.multisample == other.multisample
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ComputePipeline {
|
||||||
|
pub(crate) fn same_as(&self, other: &Self) -> bool {
|
||||||
|
self.code == other.code && self.entry == other.entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Texture {
|
impl Texture {
|
||||||
pub(crate) fn key(&self) -> Result<String, &'static str> {
|
pub(crate) fn key(&self) -> Result<String, &'static str> {
|
||||||
let mut usage = self.usage.clone();
|
let mut usage = self.usage.clone();
|
||||||
|
|||||||
+16
-12
@@ -26,12 +26,7 @@ impl Store {
|
|||||||
|
|
||||||
pub fn switch(&mut self, id: &str, gpu: &Wgpu, data: &RenderData) -> Result<(), String> {
|
pub fn switch(&mut self, id: &str, gpu: &Wgpu, data: &RenderData) -> Result<(), String> {
|
||||||
let graph = self.graphs.get(id).ok_or("GRAPH_UNKNOWN")?.clone();
|
let graph = self.graphs.get(id).ok_or("GRAPH_UNKNOWN")?.clone();
|
||||||
let mut resources = GpuResources::activate(
|
let mut resources = GpuResources::activate(&graph, gpu, data, self.active.as_ref())?;
|
||||||
&graph,
|
|
||||||
gpu,
|
|
||||||
data,
|
|
||||||
self.active.as_ref().map(|active| &active.resources),
|
|
||||||
)?;
|
|
||||||
for (name, levels) in &self.texture_sources {
|
for (name, levels) in &self.texture_sources {
|
||||||
for (mip_level, image) in levels {
|
for (mip_level, image) in levels {
|
||||||
if resources.needs_upload(name, *mip_level) {
|
if resources.needs_upload(name, *mip_level) {
|
||||||
@@ -72,6 +67,20 @@ impl Store {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn sync_rows(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
first: u32,
|
||||||
|
count: u32,
|
||||||
|
gpu: &Wgpu,
|
||||||
|
data: &RenderData,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
match &self.active {
|
||||||
|
Some(active) => active.resources.sync_rows(name, first, count, gpu, data),
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn active_mut(&mut self) -> Option<&mut Loadout> {
|
pub fn active_mut(&mut self) -> Option<&mut Loadout> {
|
||||||
self.active.as_mut()
|
self.active.as_mut()
|
||||||
}
|
}
|
||||||
@@ -104,12 +113,7 @@ impl Store {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let graph = active.graph.clone();
|
let graph = active.graph.clone();
|
||||||
let mut resources = GpuResources::activate(
|
let mut resources = GpuResources::activate(&graph, gpu, data, self.active.as_ref())?;
|
||||||
&graph,
|
|
||||||
gpu,
|
|
||||||
data,
|
|
||||||
self.active.as_ref().map(|active| &active.resources),
|
|
||||||
)?;
|
|
||||||
for (name, levels) in &self.texture_sources {
|
for (name, levels) in &self.texture_sources {
|
||||||
for (mip_level, image) in levels {
|
for (mip_level, image) in levels {
|
||||||
if resources.needs_upload(name, *mip_level) {
|
if resources.needs_upload(name, *mip_level) {
|
||||||
|
|||||||
+28
-39
@@ -10,26 +10,13 @@ use crate::render_data::RenderData;
|
|||||||
use crate::store::{Loadout, Store};
|
use crate::store::{Loadout, Store};
|
||||||
|
|
||||||
#[wasm_bindgen(inline_js = r#"
|
#[wasm_bindgen(inline_js = r#"
|
||||||
const channel = new MessageChannel();
|
|
||||||
const ready = [];
|
|
||||||
channel.port1.onmessage = () => ready.shift()?.();
|
|
||||||
|
|
||||||
export function nextFrame() {
|
export function nextFrame() {
|
||||||
return new Promise(resolve => requestAnimationFrame(resolve));
|
return new Promise(resolve => requestAnimationFrame(resolve));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function yieldTask() {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
ready.push(resolve);
|
|
||||||
channel.port2.postMessage(0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"#)]
|
"#)]
|
||||||
extern "C" {
|
extern "C" {
|
||||||
#[wasm_bindgen(js_name = nextFrame)]
|
#[wasm_bindgen(js_name = nextFrame)]
|
||||||
async fn next_frame();
|
async fn next_frame();
|
||||||
#[wasm_bindgen(js_name = yieldTask)]
|
|
||||||
async fn yield_task();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct RenderLoop {
|
pub struct RenderLoop {
|
||||||
@@ -42,7 +29,8 @@ pub struct RenderLoop {
|
|||||||
profiling: Cell<bool>,
|
profiling: Cell<bool>,
|
||||||
profile_pending: Cell<bool>,
|
profile_pending: Cell<bool>,
|
||||||
profile_after: Cell<f64>,
|
profile_after: Cell<f64>,
|
||||||
profile: RefCell<Option<String>>,
|
profile_callback: RefCell<Option<js_sys::Function>>,
|
||||||
|
profile_generation: Cell<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenderLoop {
|
impl RenderLoop {
|
||||||
@@ -57,7 +45,8 @@ impl RenderLoop {
|
|||||||
profiling: Cell::new(false),
|
profiling: Cell::new(false),
|
||||||
profile_pending: Cell::new(false),
|
profile_pending: Cell::new(false),
|
||||||
profile_after: Cell::new(0.0),
|
profile_after: Cell::new(0.0),
|
||||||
profile: RefCell::new(None),
|
profile_callback: RefCell::new(None),
|
||||||
|
profile_generation: Cell::new(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,13 +70,12 @@ impl RenderLoop {
|
|||||||
pub fn set_profiling(&self, enabled: bool) {
|
pub fn set_profiling(&self, enabled: bool) {
|
||||||
self.profiling.set(enabled);
|
self.profiling.set(enabled);
|
||||||
self.profile_after.set(0.0);
|
self.profile_after.set(0.0);
|
||||||
if !enabled {
|
self.profile_generation
|
||||||
self.profile.borrow_mut().take();
|
.set(self.profile_generation.get().wrapping_add(1));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn take_profile(&self) -> Option<String> {
|
pub fn on_profile(&self, callback: js_sys::Function) {
|
||||||
self.profile.borrow_mut().take()
|
*self.profile_callback.borrow_mut() = Some(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start(
|
pub fn start(
|
||||||
@@ -152,15 +140,12 @@ impl RenderLoop {
|
|||||||
};
|
};
|
||||||
if let Some((profile, adapter, width, height)) = submission {
|
if let Some((profile, adapter, width, height)) = submission {
|
||||||
control.profile_pending.set(true);
|
control.profile_pending.set(true);
|
||||||
control.profile_after.set(started + 250.0);
|
control.profile_after.set(started + 100.0);
|
||||||
|
let generation = control.profile_generation.get();
|
||||||
let control = control.clone();
|
let control = control.clone();
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
while profile.state() == 0 {
|
if let Some(passes) = profile.read().await {
|
||||||
yield_task().await;
|
let passes = passes
|
||||||
}
|
|
||||||
if profile.state() == 1 {
|
|
||||||
let passes = profile
|
|
||||||
.read()
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(name, milliseconds)| {
|
.map(|(name, milliseconds)| {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
@@ -173,18 +158,22 @@ impl RenderLoop {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|pass| pass["milliseconds"].as_f64())
|
.filter_map(|pass| pass["milliseconds"].as_f64())
|
||||||
.sum::<f64>();
|
.sum::<f64>();
|
||||||
if control.profiling.get() {
|
if control.profiling.get()
|
||||||
*control.profile.borrow_mut() = Some(
|
&& control.profile_generation.get() == generation
|
||||||
serde_json::json!({
|
{
|
||||||
"frame": frame,
|
let sample = serde_json::json!({
|
||||||
"milliseconds": milliseconds,
|
"frame": frame,
|
||||||
"readbackMilliseconds": js_sys::Date::now() - started,
|
"milliseconds": milliseconds,
|
||||||
"adapter": adapter,
|
"readbackMilliseconds": js_sys::Date::now() - started,
|
||||||
"canvas": { "width": width, "height": height },
|
"adapter": adapter,
|
||||||
"passes": passes,
|
"canvas": { "width": width, "height": height },
|
||||||
})
|
"passes": passes,
|
||||||
.to_string(),
|
})
|
||||||
);
|
.to_string();
|
||||||
|
if let Some(callback) = control.profile_callback.borrow().as_ref() {
|
||||||
|
let _ = callback
|
||||||
|
.call1(&wasm_bindgen::JsValue::NULL, &sample.into());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
control.profile_pending.set(false);
|
control.profile_pending.set(false);
|
||||||
|
|||||||
+21
-1
@@ -8,9 +8,19 @@ pub struct Wgpu {
|
|||||||
pub width: u32,
|
pub width: u32,
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
pub timestamp_queries: bool,
|
pub timestamp_queries: bool,
|
||||||
|
/// Optional features the device was created with, in WebGPU spelling.
|
||||||
|
pub features: Vec<&'static str>,
|
||||||
pub adapter: String,
|
pub adapter: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OPTIONAL_FEATURES: [(wgpu::Features, &str); 2] = [
|
||||||
|
(wgpu::Features::TIMESTAMP_QUERY, "timestamp-query"),
|
||||||
|
(
|
||||||
|
wgpu::Features::INDIRECT_FIRST_INSTANCE,
|
||||||
|
"indirect-first-instance",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
impl Wgpu {
|
impl Wgpu {
|
||||||
pub async fn new(canvas: web_sys::OffscreenCanvas) -> Result<Self, String> {
|
pub async fn new(canvas: web_sys::OffscreenCanvas) -> Result<Self, String> {
|
||||||
let width = canvas.width();
|
let width = canvas.width();
|
||||||
@@ -33,7 +43,12 @@ impl Wgpu {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|_| "WEBGPU_UNAVAILABLE")?;
|
.map_err(|_| "WEBGPU_UNAVAILABLE")?;
|
||||||
let required_features = adapter.features() & wgpu::Features::TIMESTAMP_QUERY;
|
let required_features = OPTIONAL_FEATURES
|
||||||
|
.iter()
|
||||||
|
.fold(wgpu::Features::empty(), |features, (feature, _)| {
|
||||||
|
features | *feature
|
||||||
|
})
|
||||||
|
& adapter.features();
|
||||||
let info = adapter.get_info();
|
let info = adapter.get_info();
|
||||||
let adapter_name = if info.name.is_empty() {
|
let adapter_name = if info.name.is_empty() {
|
||||||
format!("{:?} · {:?}", info.device_type, info.backend)
|
format!("{:?} · {:?}", info.device_type, info.backend)
|
||||||
@@ -65,6 +80,11 @@ impl Wgpu {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
timestamp_queries: required_features.contains(wgpu::Features::TIMESTAMP_QUERY),
|
timestamp_queries: required_features.contains(wgpu::Features::TIMESTAMP_QUERY),
|
||||||
|
features: OPTIONAL_FEATURES
|
||||||
|
.iter()
|
||||||
|
.filter(|(feature, _)| required_features.contains(*feature))
|
||||||
|
.map(|(_, name)| *name)
|
||||||
|
.collect(),
|
||||||
adapter: adapter_name,
|
adapter: adapter_name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-9
@@ -1,39 +1,55 @@
|
|||||||
import initWasm, { Core } from "./pkg/yawn_core.js";
|
import initWasm, { Core } from "./pkg/yawn_core.js";
|
||||||
|
import { SharedProfile } from "./profile.js";
|
||||||
|
|
||||||
let core;
|
let core;
|
||||||
let profileTimer;
|
let profile;
|
||||||
|
// While a relocation is staged, every request except its commit waits here.
|
||||||
|
let awaitingCommit = false;
|
||||||
|
const queue = [];
|
||||||
|
|
||||||
const fail = code => { throw new Error(code); };
|
const fail = code => { throw new Error(code); };
|
||||||
|
|
||||||
addEventListener("message", async ({ data: message }) => {
|
async function handle(message) {
|
||||||
try {
|
try {
|
||||||
let result;
|
let result;
|
||||||
if (message?.type === "init") {
|
if (message?.type === "init") {
|
||||||
if (core || !(message.canvas instanceof OffscreenCanvas)) fail("INIT");
|
if (core || !(message.canvas instanceof OffscreenCanvas)) fail("INIT");
|
||||||
const wasm = await initWasm();
|
const wasm = await initWasm();
|
||||||
core = new Core(message.arenaBytes);
|
core = new Core(message.arenaBytes);
|
||||||
|
profile = new SharedProfile(message.profileBuffer);
|
||||||
|
core.on_profile(json => profile.write(json));
|
||||||
await core.initialize(message.canvas);
|
await core.initialize(message.canvas);
|
||||||
const buffer = wasm.memory.buffer;
|
const buffer = wasm.memory.buffer;
|
||||||
if (!(buffer instanceof SharedArrayBuffer)) fail("WASM_MEMORY_NOT_SHARED");
|
if (!(buffer instanceof SharedArrayBuffer)) fail("WASM_MEMORY_NOT_SHARED");
|
||||||
result = { buffer, rows: JSON.parse(core.rows()) };
|
result = { buffer, rows: JSON.parse(core.rows()), features: JSON.parse(core.features()) };
|
||||||
} else {
|
} else {
|
||||||
if (!core) fail("UNINITIALIZED");
|
if (!core) fail("UNINITIALIZED");
|
||||||
switch (message?.type) {
|
switch (message?.type) {
|
||||||
case "create-rows":
|
case "create-rows":
|
||||||
result = JSON.parse(core.create_rows(message.name, message.rows, message.stride, message.format));
|
result = JSON.parse(core.create_rows(message.name, message.rows, message.stride, message.format));
|
||||||
|
awaitingCommit = result.commit;
|
||||||
break;
|
break;
|
||||||
case "create-rows-batch":
|
case "create-rows-batch":
|
||||||
result = JSON.parse(core.create_rows_batch(JSON.stringify(message.rows)));
|
result = JSON.parse(core.create_rows_batch(JSON.stringify(message.rows)));
|
||||||
|
awaitingCommit = result.commit.length > 0;
|
||||||
|
break;
|
||||||
|
case "commit-rows":
|
||||||
|
awaitingCommit = false;
|
||||||
|
core.commit_rows(JSON.stringify(message.names));
|
||||||
break;
|
break;
|
||||||
case "delete-rows":
|
case "delete-rows":
|
||||||
core.delete_rows(message.name);
|
core.delete_rows(message.name);
|
||||||
break;
|
break;
|
||||||
case "allocate-object":
|
case "allocate-object":
|
||||||
result = JSON.parse(core.allocate_object(message.name));
|
result = JSON.parse(core.allocate_object(message.name, message.count ?? 1));
|
||||||
|
awaitingCommit = result.commit;
|
||||||
break;
|
break;
|
||||||
case "delete-object":
|
case "delete-object":
|
||||||
core.delete_object(message.name, message.id);
|
core.delete_object(message.name, message.id);
|
||||||
break;
|
break;
|
||||||
|
case "sync-rows":
|
||||||
|
core.sync_rows(message.name, message.first ?? 0, message.count);
|
||||||
|
break;
|
||||||
case "compile-graph":
|
case "compile-graph":
|
||||||
result = core.compile_graph(message.serialized);
|
result = core.compile_graph(message.serialized);
|
||||||
break;
|
break;
|
||||||
@@ -57,11 +73,7 @@ addEventListener("message", async ({ data: message }) => {
|
|||||||
break;
|
break;
|
||||||
case "set-profiler":
|
case "set-profiler":
|
||||||
result = core.set_profiler(Boolean(message.enabled));
|
result = core.set_profiler(Boolean(message.enabled));
|
||||||
clearInterval(profileTimer);
|
profile.clear();
|
||||||
profileTimer = result && message.enabled ? setInterval(() => {
|
|
||||||
const stats = core.take_profile();
|
|
||||||
if (stats) postMessage({ type: "profile", stats: JSON.parse(stats) });
|
|
||||||
}, 250) : undefined;
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
fail("MESSAGE");
|
fail("MESSAGE");
|
||||||
@@ -71,4 +83,13 @@ addEventListener("message", async ({ data: message }) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
postMessage({ request: message?.request, error: error?.message ?? "CORE_ERROR" });
|
postMessage({ request: message?.request, error: error?.message ?? "CORE_ERROR" });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener("message", async ({ data: message }) => {
|
||||||
|
if (awaitingCommit && message?.type !== "commit-rows") {
|
||||||
|
queue.push(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handle(message);
|
||||||
|
while (!awaitingCommit && queue.length) await handle(queue.shift());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const { buffer, rows } = await request(
|
|||||||
|
|
||||||
Requests are `{ type: string, request: any, ...fields }`. Success is `{ request, result }`; void operations have `result: undefined`. Failure is `{ request, error: string }`. The worker merely echoes `request`, so use unique values. Requests can complete out of order. An unknown message type returns `MESSAGE`; all non-init operations before init return `UNINITIALIZED`.
|
Requests are `{ type: string, request: any, ...fields }`. Success is `{ request, result }`; void operations have `result: undefined`. Failure is `{ request, error: string }`. The worker merely echoes `request`, so use unique values. Requests can complete out of order. An unknown message type returns `MESSAGE`; all non-init operations before init return `UNINITIALIZED`.
|
||||||
|
|
||||||
`init` is one-shot per worker. Its `canvas` **must** be an `OffscreenCanvas` and must appear in the transfer list. `arenaBytes` reaches a Rust `u32`; use an integer from 64 through `2^32 - 64` in practical JS calls. The result contains the shared WASM memory buffer and all initial row descriptors (currently `signals`). Do not transfer the `SharedArrayBuffer`.
|
`init` is one-shot per worker. Its `canvas` **must** be an `OffscreenCanvas` and must appear in the transfer list. `arenaBytes` reaches a Rust `u32`; use an integer from 64 through `2^32 - 64` in practical JS calls. The result contains the shared WASM memory buffer, all initial row descriptors (currently `signals`), and the optional device `features` that were granted. Do not transfer the `SharedArrayBuffer`.
|
||||||
|
|
||||||
## Shutdown
|
## Shutdown
|
||||||
|
|
||||||
|
|||||||
@@ -25,11 +25,15 @@ function rowFor(buffer, d, index) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The buffer stays the same, but a growing row array may relocate. Replace the cached descriptor—and recreate cached views—after every `create-rows`, batch result, and `allocate-object` result. `allocate-object` always returns `{ id, rows: descriptor }`, even without growth. Never infer an address from a prior descriptor.
|
The buffer stays the same, but a growing row array may relocate. Replace the cached descriptor—and recreate cached views—after every `create-rows`, batch result, and `allocate-object` result. `allocate-object` always returns `{ id, rows: descriptor, commit }`, even without growth. Never infer an address from a prior descriptor.
|
||||||
|
|
||||||
|
### Relocation is owned by the writer
|
||||||
|
|
||||||
|
The worker never copies rows. When growth cannot extend in place it reserves a zeroed region, returns that descriptor with `commit: true` (`create-rows`, `allocate-object`) or the moved names in `commit` (`create-rows-batch`), and keeps rendering from—and the arena owning—the old rows. The main thread is the only writer, so it is the only party that knows its last write landed: on receiving the result it copies the old bytes into the new region, swaps its descriptor, and sends `commit-rows { names }`. The worker then publishes the new descriptor, frees the old rows, and rebuilds the GPU buffers bound to them. Until that commit arrives the worker queues every other request, so a staged relocation is a short, exclusive transaction and no SAB write made at any moment is lost. `index.js` does all of this inside `createRows`, `createRowsBatch`, and `allocateObject`; a raw client must copy and commit synchronously in the same task that received the result.
|
||||||
|
|
||||||
## Slots
|
## Slots
|
||||||
|
|
||||||
Creating rows establishes capacity but does not allocate objects. `allocate-object` returns a zero-based row ID. IDs increase until capacity is exhausted; allocation then grows to `id + 1`. Deleted IDs are kept in a LIFO free list and reused. `delete-object` zeroes the entire row and releases the ID. It rejects an inactive/duplicate ID. A row array cannot be deleted while any IDs are active, while `signals` cannot be allocated or deleted, and an array used by the active graph cannot be deleted.
|
Creating rows establishes capacity but does not allocate objects. `allocate-object` reserves `count` contiguous rows (default 1) and returns the zero-based first row as the ID. Spans are placed first-fit into the lowest free gap, so deleted rows are reused before capacity grows; when nothing fits, capacity doubles (or grows to fit the span, whichever is larger) so repeated allocations rarely relocate. Rows added by an explicit `create-rows` growth join the same free list. `delete-object` zeroes the whole span and releases it. It rejects an inactive/duplicate ID. A row array cannot be deleted while any IDs are active, while `signals` cannot be allocated or deleted, and an array used by the active graph cannot be deleted.
|
||||||
|
|
||||||
Capacity does not shrink. Deletion makes descriptors/views invalid for application use even though stale bytes may remain in memory.
|
Capacity does not shrink. Deletion makes descriptors/views invalid for application use even though stale bytes may remain in memory.
|
||||||
|
|
||||||
@@ -73,8 +77,10 @@ async function replaceBundle(mutate, compileAndSwitch) {
|
|||||||
|
|
||||||
The signals are invalidation state, not a lock or transaction boundary. If multiple threads can write one logical update, coordinate those writers so core cannot observe a partially updated value. Core marks lane 5 after object deletion, texture upload, and a successful switch, but direct SAB writes do not. Always publish lane 5 after direct data writes.
|
The signals are invalidation state, not a lock or transaction boundary. If multiple threads can write one logical update, coordinate those writers so core cannot observe a partially updated value. Core marks lane 5 after object deletion, texture upload, and a successful switch, but direct SAB writes do not. Always publish lane 5 after direct data writes.
|
||||||
|
|
||||||
## Frame-sync versus loadout-sync
|
## Frame-sync versus manual-sync
|
||||||
|
|
||||||
A graph buffer's `sync` defaults to `"frame"`. Before each actual render, the full named row array is copied SAB → GPU, so ordinary value edits only require `sabDirty`.
|
A graph buffer's `sync` defaults to `"frame"`. Before each actual render, the full named row array is copied SAB → GPU, so ordinary value edits only require `sabDirty`. Use it for small, frequently changing arrays (transforms, lights, draw commands).
|
||||||
|
|
||||||
`sync: "loadout"` is copied only when GPU resources are activated: on `switch-loadout`, and when core refreshes an active graph because a used row array grows/is recreated. Direct edits afterward do **not** reach that GPU buffer. To change loadout-synced content reliably, set `bundleDirty`, mutate, and switch a compiled replacement loadout (the replacement may use a newly compiled graph with the same schema). Structural changes affecting buffer size, bindings, offsets, draw ranges, passes, pipelines, or attachment/resource declarations also require bundle invalidation and a graph/loadout switch. This distinction matters because render commands are precompiled into render bundles.
|
`sync: "manual"` is copied in full only when its GPU buffer is created: on the first `switch-loadout` that declares it, and whenever the source array grows/relocates or the declaration's usage changes. Later SAB edits reach the GPU only through `sync-rows { name, first, count }`, which uploads that row range into every active buffer bound to the array and marks the frame dirty. Use it for large, mostly static arrays such as vertex and index data: write the rows, then `sync-rows` exactly that span.
|
||||||
|
|
||||||
|
GPU resources survive graph switches when nothing about them changed: a buffer is reused when its declaration ID keeps the same source array, byte size, and usage; a render or compute pipeline is reused when a previous declaration had identical code and state (IDs may differ); non-transient textures with the same descriptor are kept. Bind groups and render bundles are always rebuilt. Structural changes affecting bindings, offsets, draw ranges, passes, pipelines, or attachment/resource declarations require bundle invalidation and a graph/loadout switch because render commands are precompiled into render bundles.
|
||||||
|
|||||||
@@ -23,12 +23,13 @@ Worker failures are string codes in `{ request, error }`. GPU/browser validation
|
|||||||
| `ROWS_BUILTIN` | Attempt to grow/delete/allocate from `signals`. |
|
| `ROWS_BUILTIN` | Attempt to grow/delete/allocate from `signals`. |
|
||||||
| `ROWS_UNKNOWN` | Row name does not exist (also possible during frame upload). |
|
| `ROWS_UNKNOWN` | Row name does not exist (also possible during frame upload). |
|
||||||
| `ROWS_ACTIVE` | Delete blocked by active object IDs or active graph reference. |
|
| `ROWS_ACTIVE` | Delete blocked by active object IDs or active graph reference. |
|
||||||
|
| `ROWS_PENDING` | A relocation is staged and uncommitted: growing or deleting that array again, or a `commit-rows` that names a non-staged array or leaves one staged. |
|
||||||
| `ARENA_OOM` | Byte multiplication overflow or no aligned free arena block. |
|
| `ARENA_OOM` | Byte multiplication overflow or no aligned free arena block. |
|
||||||
| `OBJECT_LIMIT` | Slot ID cannot increment past u32. |
|
| `OBJECT_LIMIT` | Slot ID cannot increment past u32. |
|
||||||
| `OBJECT_UNKNOWN` | Delete ID is not active for that row array. |
|
| `OBJECT_UNKNOWN` | Delete ID is not active for that row array. |
|
||||||
| `FPS` | FPS exceeds 1000. |
|
| `FPS` | FPS exceeds 1000. |
|
||||||
|
|
||||||
On row growth, consume the returned descriptor before the next access. Batch creation is not rollback-safe. Do not retry allocation blindly after an uncertain transport outcome: it may allocate a second slot.
|
On row growth, consume the returned descriptor before the next access and commit relocations synchronously. In-place batch creations are not rolled back on a later failure. Do not retry allocation blindly after an uncertain transport outcome: it may allocate a second slot.
|
||||||
|
|
||||||
## Graph parse and planning
|
## Graph parse and planning
|
||||||
|
|
||||||
@@ -40,7 +41,8 @@ On row growth, consume the returned descriptor before the next access. Batch cre
|
|||||||
| `GRAPH_DEPENDENCY` / `GRAPH_CYCLE` | Missing `after` ID / dependency cycle. |
|
| `GRAPH_DEPENDENCY` / `GRAPH_CYCLE` | Missing `after` ID / dependency cycle. |
|
||||||
| `GRAPH_RESOURCE` | Empty/duplicate resource ID or texture planning serialization failure. |
|
| `GRAPH_RESOURCE` | Empty/duplicate resource ID or texture planning serialization failure. |
|
||||||
| `GRAPH_PIPELINE` | Empty/duplicate/missing or wrong-kind pipeline. |
|
| `GRAPH_PIPELINE` | Empty/duplicate/missing or wrong-kind pipeline. |
|
||||||
| `GRAPH_BUFFER_SYNC` | Buffer sync is not `frame` or `loadout`. |
|
| `GRAPH_BUFFER_SYNC` | Buffer sync is not `frame` or `manual`. |
|
||||||
|
| `GRAPH_INDIRECT` | Indirect pass also sets `draw`, names a buffer without `indirect` usage, has a row stride below 16/20 bytes, or its row range exceeds the array. |
|
||||||
| `GRAPH_UNKNOWN` | Switch ID has not been compiled/stored. |
|
| `GRAPH_UNKNOWN` | Switch ID has not been compiled/stored. |
|
||||||
| `GRAPH_ARRAY_UNKNOWN` | Buffer declaration names no existing row array at activation. |
|
| `GRAPH_ARRAY_UNKNOWN` | Buffer declaration names no existing row array at activation. |
|
||||||
| `GRAPH_RESOURCE_UNKNOWN` | Pass binding or vertex/index resource cannot be resolved. |
|
| `GRAPH_RESOURCE_UNKNOWN` | Pass binding or vertex/index resource cannot be resolved. |
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ Resource IDs must be nonempty and unique across buffers, textures, and samplers.
|
|||||||
{ id: string required,
|
{ id: string required,
|
||||||
array: string required, // existing shared-row name at switch time
|
array: string required, // existing shared-row name at switch time
|
||||||
usage: string[] = [],
|
usage: string[] = [],
|
||||||
sync: "frame" | "loadout" = "frame" }
|
sync: "frame" | "manual" = "frame" }
|
||||||
```
|
```
|
||||||
|
|
||||||
Allowed usage names: `uniform`, `storage`, `vertex`, `index`, `indirect`, `copySrc`. `COPY_DST` is always added. Include every way the graph uses the buffer. The allocation is at least 4 bytes, but data copied is the row descriptor's full `bytes`.
|
Allowed usage names: `uniform`, `storage`, `vertex`, `index`, `indirect`, `copySrc`. `COPY_DST` is always added. Include every way the graph uses the buffer. The allocation is at least 4 bytes, but data copied is the row descriptor's full `bytes`.
|
||||||
@@ -96,6 +96,7 @@ Pass {
|
|||||||
vertexBuffers: VertexBinding[] = [],
|
vertexBuffers: VertexBinding[] = [],
|
||||||
indexBuffer?: IndexBinding,
|
indexBuffer?: IndexBinding,
|
||||||
draw: Draw = {},
|
draw: Draw = {},
|
||||||
|
indirect?: IndirectBinding,
|
||||||
dispatch: [u32,u32,u32] = [1,1,1]
|
dispatch: [u32,u32,u32] = [1,1,1]
|
||||||
}
|
}
|
||||||
Binding { group: u32 = 0, binding: u32 required, resource: string required,
|
Binding { group: u32 = 0, binding: u32 required, resource: string required,
|
||||||
@@ -106,9 +107,12 @@ DepthAttachment { resource: string required, clear: number = 1,
|
|||||||
load: "clear"|"load" = "clear", store: "store"|"discard" = "store" }
|
load: "clear"|"load" = "clear", store: "store"|"discard" = "store" }
|
||||||
VertexBinding { slot: u32 = 0, resource: string required, offset: u64 = 0 }
|
VertexBinding { slot: u32 = 0, resource: string required, offset: u64 = 0 }
|
||||||
IndexBinding { resource: string required, format: "uint16"|"uint32" = "uint32", offset: u64 = 0 }
|
IndexBinding { resource: string required, format: "uint16"|"uint32" = "uint32", offset: u64 = 0 }
|
||||||
|
IndirectBinding { resource: string required, first: u32 = 0, count?: u32 }
|
||||||
Draw { vertices: u32 = 3, indices: u32 = 0, instances: u32 = 1,
|
Draw { vertices: u32 = 3, indices: u32 = 0, instances: u32 = 1,
|
||||||
firstVertex: u32 = 0, firstIndex: u32 = 0, baseVertex: i32 = 0,
|
firstVertex: u32 = 0, firstIndex: u32 = 0, baseVertex: i32 = 0,
|
||||||
firstInstance: u32 = 0 }
|
firstInstance: u32 = 0 }
|
||||||
```
|
```
|
||||||
|
|
||||||
Render passes use `draw`; when `indexBuffer` exists, the indexed path uses `indices` (so set it nonzero), `firstIndex`, and `baseVertex`. Otherwise it uses `vertices` and `firstVertex`. Both use instance fields. Compute passes use `dispatch`. A pipeline ID is validated against the pass kind when the loadout is built. Binding offset/size and vertex/index offsets are bytes and must satisfy WebGPU alignment and range rules. Attachments require matching usages and formats; sampled/storage bindings likewise require matching texture usage, while WGSL determines binding type and visibility.
|
Render passes use `draw`; when `indexBuffer` exists, the indexed path uses `indices` (so set it nonzero), `firstIndex`, and `baseVertex`. Otherwise it uses `vertices` and `firstVertex`. Both use instance fields. Compute passes use `dispatch`. A pipeline ID is validated against the pass kind when the loadout is built. Binding offset/size and vertex/index offsets are bytes and must satisfy WebGPU alignment and range rules. Attachments require matching usages and formats; sampled/storage bindings likewise require matching texture usage, while WGSL determines binding type and visibility.
|
||||||
|
|
||||||
|
`indirect` replaces `draw` (which must stay at its defaults) with one draw command per row of a buffer declared with `indirect` usage: rows `first .. first + count`, where `count` defaults to every row through the end of the array at activation time. Each row begins with the WebGPU command — `[vertexCount, instanceCount, firstVertex, firstInstance]` for non-indexed passes, `[indexCount, instanceCount, firstIndex, baseVertex: i32, firstInstance]` when `indexBuffer` exists — so the row stride must be at least 16 or 20 bytes; the rest of the row is caller-owned. A zero `instanceCount` skips the row cheaply, so callers can keep spare rows and toggle draws without recompiling. Nonzero `firstInstance` requires the device feature `indirect-first-instance` (see `init` result `features`); without it WebGPU silently draws nothing for that command.
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ All fields below are top-level beside `type` and `request`. Except for `init` an
|
|||||||
|
|
||||||
| `type` | Fields | Transfer list | Success `result` | Constraints / effects |
|
| `type` | Fields | Transfer list | Success `result` | Constraints / effects |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `init` | `canvas: OffscreenCanvas`, `arenaBytes: number` | `[canvas]` | `{ buffer: SharedArrayBuffer, rows: Descriptor[] }` | Once only; initializes WASM/WebGPU. |
|
| `init` | `canvas: OffscreenCanvas`, `arenaBytes: number`, `profileBuffer: SharedArrayBuffer` | `[canvas]` | `{ buffer: SharedArrayBuffer, rows: Descriptor[], features: string[] }` | Once only; initializes WASM/WebGPU. `features` lists optional device features that were available (`timestamp-query`, `indirect-first-instance`). `profileBuffer` is shared, not transferred. |
|
||||||
| `create-rows` | `name`, `rows`, `stride`, `format` | — | descriptor | Nonempty name; rows > 0; stride ≥16 and multiple of 16; format f32/u32/i32. Existing name may only retain format/stride and grow. |
|
| `create-rows` | `name`, `rows`, `stride`, `format` | — | `{ rows: descriptor, commit: boolean }` | Nonempty name; rows > 0; stride ≥16 and multiple of 16; format f32/u32/i32. Existing name may only retain format/stride and grow. `commit: true` means the rows moved: copy the old bytes into the descriptor and send `commit-rows`. |
|
||||||
| `create-rows-batch` | `rows: Array<{name,rows,stride,format}>` | — | descriptor array in input order | Nonempty. Sequential, **not transactional**: earlier creations can survive a later failure. Active GPU resources refresh once after successful batch. |
|
| `create-rows-batch` | `rows: Array<{name,rows,stride,format}>` | — | `{ rows: descriptor[] (input order), commit: string[] }` | Nonempty. Sequential; in-place creations/growths survive a later failure, staged relocations are discarded. `commit` lists the arrays that moved. Active GPU resources refresh once for in-place growth. |
|
||||||
|
| `commit-rows` | `names: string[]` | — | `undefined` | Publishes staged relocations after the caller copied them. Must name every staged array. Frees the old rows, refreshes bound GPU buffers once, sets `sabDirty`. While a relocation is staged the worker queues every other request until this arrives. |
|
||||||
| `delete-rows` | `name` | — | `undefined` | Not `signals`, not active slots, not referenced by active graph. |
|
| `delete-rows` | `name` | — | `undefined` | Not `signals`, not active slots, not referenced by active graph. |
|
||||||
| `allocate-object` | `name` | — | `{ id, rows: descriptor }` | Not `signals`; may grow/relocate. |
|
| `allocate-object` | `name`, `count: u32 = 1` | — | `{ id, rows: descriptor, commit: boolean }` | Not `signals`; `count` > 0; reserves `count` contiguous rows starting at `id`; may grow. `commit` as in `create-rows`. |
|
||||||
| `delete-object` | `name`, `id: u32` | — | `undefined` | ID must currently be active; row is zeroed. |
|
| `delete-object` | `name`, `id: u32` | — | `undefined` | ID must be the first row of an active span; the whole span is zeroed. |
|
||||||
|
| `sync-rows` | `name`, `first: u32 = 0`, `count: u32` | — | `undefined` | Uploads rows `first .. first + count` of the array into every active GPU buffer bound to it (any `sync`) and sets `sabDirty`. `ROW_RANGE` when the span exceeds the array. No-op without an active loadout. |
|
||||||
| `compile-graph` | `serialized: string` | — | graph ID string | Parses and stores; same ID replaces previously stored graph. Does not activate it. |
|
| `compile-graph` | `serialized: string` | — | graph ID string | Parses and stores; same ID replaces previously stored graph. Does not activate it. |
|
||||||
| `switch-loadout` | `id` | — | `undefined` | Builds GPU resources and activates stored graph. Clears `bundleDirty`, sets `sabDirty`. |
|
| `switch-loadout` | `id` | — | `undefined` | Builds GPU resources and activates stored graph. Clears `bundleDirty`, sets `sabDirty`. |
|
||||||
| `upload-texture` | `name`, `mipLevel: u32`, `image: ImageBitmap` | `[image]` | `undefined` | Uploads immediately if active graph has the texture and caches source for future switches. Source extent must fit destination/mip. Sets dirty. |
|
| `upload-texture` | `name`, `mipLevel: u32`, `image: ImageBitmap` | `[image]` | `undefined` | Uploads immediately if active graph has the texture and caches source for future switches. Source extent must fit destination/mip. Sets dirty. |
|
||||||
@@ -21,25 +23,26 @@ All fields below are top-level beside `type` and `request`. Except for `init` an
|
|||||||
|
|
||||||
Rust/WASM numeric conversion applies to `u32` fields; callers should send finite nonnegative integers in range rather than rely on coercion.
|
Rust/WASM numeric conversion applies to `u32` fields; callers should send finite nonnegative integers in range rather than rely on coercion.
|
||||||
|
|
||||||
## Profiler events
|
## Shared profiler results
|
||||||
|
|
||||||
When enabled and supported, the worker polls every 250 ms and may send an unsolicited message with **no request field**:
|
When enabled and supported, GPU timestamp queries are sampled at most once every 100 ms, with only one readback in flight. Completion of `map_async` wakes an awaiting future; neither the worker nor render loop polls for GPU completion. Rendering continues independently.
|
||||||
|
|
||||||
|
Completed results are published into `profileBuffer`, not sent as worker messages. `YawnCore.onProfile` reads this buffer locally every 100 ms while listeners exist and delivers the same stats shape:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
{
|
{
|
||||||
type: "profile",
|
|
||||||
stats: {
|
|
||||||
frame: 42,
|
frame: 42,
|
||||||
milliseconds: 0.31,
|
milliseconds: 0.31,
|
||||||
readbackMilliseconds: 4.8,
|
readbackMilliseconds: 4.8,
|
||||||
adapter: "Adapter name · DeviceType · Backend",
|
adapter: "Adapter name · DeviceType · Backend",
|
||||||
canvas: { width: 1280, height: 720 },
|
canvas: { width: 1280, height: 720 },
|
||||||
passes: [{ name: "triangle", milliseconds: 0.31 }]
|
passes: [{ name: "triangle", milliseconds: 0.31 }]
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
One pass timing corresponds to a compiled execution; compatible adjacent render passes may be merged and labeled together. Samples are throttled and asynchronous, so they are diagnostics, not one event per frame. Disabling clears pending published statistics and the worker polling interval.
|
One pass timing corresponds to a compiled execution; compatible adjacent render passes are merged into one execution labelled `first … last (N draws)`. `milliseconds` is GPU time; `readbackMilliseconds` includes the asynchronous wait. Disabling clears published statistics and suppresses results from older profiling sessions.
|
||||||
|
|
||||||
|
The default mailbox is a separate 1 MiB SAB. Its first two `Int32` values are ownership state and UTF-8 JSON length; the payload starts at byte 8. State 0 means empty, 1 means owned by a reader or writer, and 2 means a complete sample. Both sides claim ownership using `Atomics.compareExchange` and never wait. An unread sample is replaced by the latest sample; a publication during a read, or larger than the payload capacity, is skipped. The reader copies the bytes before releasing ownership, so it cannot observe a partially written result. Initialization and profiler enable/disable remain ordinary control requests; results never use `postMessage`.
|
||||||
|
|
||||||
## Texture lifecycle
|
## Texture lifecycle
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
"server": "go run ./server",
|
"server": "go run ./server",
|
||||||
"coredocs": "vitepress dev coredocs --host 0.0.0.0 --port ${PORT:-8080}",
|
"coredocs": "vitepress dev coredocs --host 0.0.0.0 --port ${PORT:-8080}",
|
||||||
"build:coredocs": "vitepress build coredocs",
|
"build:coredocs": "vitepress build coredocs",
|
||||||
"test": "npm run test:handles && npm run test:fxnode-rg",
|
"test": "npm test --workspace @yawn/core && npm run test:handles && npm run test:fxnode-rg",
|
||||||
"test:handles": "npm test --workspace @yawn/handles",
|
"test:handles": "npm test --workspace @yawn/handles",
|
||||||
"test:fxnode-rg": "npm test --workspace @yawn/fxnode-rg",
|
"test:fxnode-rg": "npm test --workspace @yawn/fxnode-rg",
|
||||||
"test:handles:screenshots": "npm run test:screenshots --workspace @yawn/handles"
|
"test:handles:screenshots": "npm run test:screenshots --workspace @yawn/handles"
|
||||||
|
|||||||
+5
-1
@@ -36,7 +36,7 @@ func newApplication(store *playgroundStore, packages map[string]memoryAsset, mon
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pages := make(map[string][]byte)
|
pages := make(map[string][]byte)
|
||||||
for _, name := range []string{"home.html", "docs.html", "playground.html", "gltf.html"} {
|
for _, name := range []string{"home.html", "docs.html", "playground.html", "gltf.html", "assets/sculpt/index.html"} {
|
||||||
content, err := webFiles.ReadFile("web/" + name)
|
content, err := webFiles.ReadFile("web/" + name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -89,6 +89,10 @@ func (app *application) routes() http.Handler {
|
|||||||
mux.HandleFunc("/playground/", app.servePlayground)
|
mux.HandleFunc("/playground/", app.servePlayground)
|
||||||
mux.HandleFunc("/view/gltf", app.servePage("gltf.html"))
|
mux.HandleFunc("/view/gltf", app.servePage("gltf.html"))
|
||||||
mux.HandleFunc("/view/gltf/", app.servePage("gltf.html"))
|
mux.HandleFunc("/view/gltf/", app.servePage("gltf.html"))
|
||||||
|
mux.HandleFunc("/sculpt", app.servePage("assets/sculpt/index.html"))
|
||||||
|
mux.HandleFunc("/sculpt/", app.servePage("assets/sculpt/index.html"))
|
||||||
|
mux.HandleFunc("/view/sculpt", app.servePage("assets/sculpt/index.html"))
|
||||||
|
mux.HandleFunc("/view/sculpt/", app.servePage("assets/sculpt/index.html"))
|
||||||
mux.HandleFunc("/", app.serveHome)
|
mux.HandleFunc("/", app.serveHome)
|
||||||
return app.headers(mux)
|
return app.headers(mux)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,6 +217,46 @@ func TestSavedPlaygroundPageRoute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSculptRoutesAndAssets(t *testing.T) {
|
||||||
|
routes := testApplication(t).routes()
|
||||||
|
for _, path := range []string{"/sculpt", "/sculpt/", "/view/sculpt", "/view/sculpt/", "/assets/sculpt/app.js", "/assets/sculpt/styles.css"} {
|
||||||
|
t.Run(path, func(t *testing.T) {
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
routes.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", response.Code)
|
||||||
|
}
|
||||||
|
for name, expected := range map[string]string{
|
||||||
|
"Cross-Origin-Opener-Policy": "same-origin",
|
||||||
|
"Cross-Origin-Embedder-Policy": "require-corp",
|
||||||
|
} {
|
||||||
|
if got := response.Header().Get(name); got != expected {
|
||||||
|
t.Fatalf("%s = %q, want %q", name, got, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch path {
|
||||||
|
case "/assets/sculpt/app.js", "/assets/sculpt/styles.css":
|
||||||
|
expected := "text/javascript; charset=utf-8"
|
||||||
|
if filepath.Ext(path) == ".css" {
|
||||||
|
expected = "text/css; charset=utf-8"
|
||||||
|
}
|
||||||
|
if response.Header().Get("Content-Type") != expected || response.Body.Len() == 0 {
|
||||||
|
t.Fatalf("missing asset or wrong content type: %q", response.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if got := response.Header().Get("Content-Type"); got != "text/html; charset=utf-8" {
|
||||||
|
t.Fatalf("content type = %q", got)
|
||||||
|
}
|
||||||
|
for _, content := range []string{"<title>Sculpt — Yawn</title>", `src="/assets/sculpt/app.js"`, `href="/assets/sculpt/styles.css"`, `"@yawn/core": "/pkg/core.js"`} {
|
||||||
|
if !bytes.Contains(response.Body.Bytes(), []byte(content)) {
|
||||||
|
t.Fatalf("sculpt page missing %q", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGltfViewerPageRoute(t *testing.T) {
|
func TestGltfViewerPageRoute(t *testing.T) {
|
||||||
request := httptest.NewRequest(http.MethodGet, "/view/gltf", nil)
|
request := httptest.NewRequest(http.MethodGet, "/view/gltf", nil)
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
|
|||||||
+10
-10
@@ -34,12 +34,20 @@ const fxNodeImports = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const coreWorker = await build({
|
||||||
|
...browser,
|
||||||
|
entryPoints: [path.join(root, "core", "worker.js")],
|
||||||
|
external: ["./pkg/yawn_core.js"],
|
||||||
|
write: false,
|
||||||
|
});
|
||||||
|
const coreWorkerSource = coreWorker.outputFiles[0].text;
|
||||||
|
|
||||||
const workerFiles = new Map([
|
const workerFiles = new Map([
|
||||||
[
|
[
|
||||||
path.join(root, "core", "index.js"),
|
path.join(root, "core", "index.js"),
|
||||||
{
|
{
|
||||||
url: "./worker.js",
|
url: "./worker.js",
|
||||||
source: await fs.readFile(path.join(root, "core", "worker.js"), "utf8"),
|
source: coreWorkerSource,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -113,15 +121,7 @@ await Promise.all([
|
|||||||
entryPoints: [path.join(fxNodeSource, "worker", "fxnode.worker.ts")],
|
entryPoints: [path.join(fxNodeSource, "worker", "fxnode.worker.ts")],
|
||||||
outfile: path.join(output, "fxnode-worker.js"),
|
outfile: path.join(output, "fxnode-worker.js"),
|
||||||
}),
|
}),
|
||||||
build({
|
fs.writeFile(path.join(output, "worker.js"), coreWorkerSource),
|
||||||
bundle: false,
|
|
||||||
entryPoints: [path.join(root, "core", "worker.js")],
|
|
||||||
format: "esm",
|
|
||||||
legalComments: "none",
|
|
||||||
minify: true,
|
|
||||||
outfile: path.join(output, "worker.js"),
|
|
||||||
target: "es2022",
|
|
||||||
}),
|
|
||||||
build({
|
build({
|
||||||
...browser,
|
...browser,
|
||||||
entryPoints: [
|
entryPoints: [
|
||||||
|
|||||||
+15
-10
@@ -224,6 +224,7 @@ async function openFile(file) {
|
|||||||
scene = new Scene(canvas, {
|
scene = new Scene(canvas, {
|
||||||
arenaBytes: 384 * 1024 * 1024,
|
arenaBytes: 384 * 1024 * 1024,
|
||||||
hdr: true,
|
hdr: true,
|
||||||
|
directToCanvas: true,
|
||||||
clearColor: [0.025, 0.035, 0.045, 1],
|
clearColor: [0.025, 0.035, 0.045, 1],
|
||||||
});
|
});
|
||||||
await scene.ready;
|
await scene.ready;
|
||||||
@@ -266,16 +267,20 @@ async function openFile(file) {
|
|||||||
);
|
);
|
||||||
await Promise.all([...lights.map((light) => light.ready), camera.ready]);
|
await Promise.all([...lights.map((light) => light.ready), camera.ready]);
|
||||||
|
|
||||||
elements.renderValue.textContent = "— ms";
|
// Diagnostic A/B: omit queries and readback, not just the timing badge.
|
||||||
elements.renderTime.hidden = false;
|
let stopProfile;
|
||||||
const stopProfile = scene.core.onProfile((profile) => {
|
if (new URLSearchParams(location.search).get("profile") !== "off") {
|
||||||
elements.renderValue.textContent = `${profile.milliseconds.toFixed(2)} ms`;
|
elements.renderValue.textContent = "— ms";
|
||||||
});
|
elements.renderTime.hidden = false;
|
||||||
const profiling = await scene.core.setProfiler(true);
|
stopProfile = scene.core.onProfile((profile) => {
|
||||||
if (!profiling)
|
elements.renderValue.textContent = `${profile.milliseconds.toFixed(2)} ms`;
|
||||||
elements.renderTime.title =
|
});
|
||||||
"GPU timestamp queries are not supported by this adapter.";
|
const profiling = await scene.core.setProfiler(true);
|
||||||
else elements.renderTime.removeAttribute("title");
|
if (!profiling)
|
||||||
|
elements.renderTime.title =
|
||||||
|
"GPU timestamp queries are not supported by this adapter.";
|
||||||
|
else elements.renderTime.removeAttribute("title");
|
||||||
|
}
|
||||||
|
|
||||||
activeViewer = {
|
activeViewer = {
|
||||||
camera,
|
camera,
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Yawn Sculpt
|
||||||
|
|
||||||
|
Open `/sculpt` on the Yawn server (`npm run server`); `/view/sculpt` remains a compatibility alias. The production Docker image embeds this example, so the existing main-branch deployment poller publishes it at `/sculpt` with each deployment. All example code lives here; the only integration outside this folder is the embedded-page route in `server/app.go`. Rendering imports **only `@yawn/core`**. No handles, third-party scene graph, or remote document storage.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
Five geometry brushes: Draw, Inflate, Smooth, Flatten, Grab; Mask paints protected regions and its erase mode removes protection. All geometry brushes respect masks. X symmetry reflects about each piece's stored local plane and merges displacement without double deposition. Pen pressure modulates strength through `clamp(gain × pressure^gamma, 0, 1)`; pressure-radius is optional. Mouse/touch use constant pressure. Settings and temporary Shift-smooth / Alt-invert are captured at stroke start. Grab uses a fixed camera-facing drag plane and stroke-start positions.
|
||||||
|
|
||||||
|
The Clay popover adds Sphere, Cube, Cylinder, Cone and Torus presets, each with a newly chosen pastel material. Select a piece in the list to sculpt, recolor or delete it; even the last piece can be deleted. Additions are placed beside existing clay. Detail & mask contains Clear/Invert mask, Subdivide (linear 4× triangle split, interpolating masks), and Decimate (best-effort 50% reduction). Decimation freezes masked neighborhoods and boundary vertices and rejects topology-breaking collapses. It may achieve less reduction, or none. There are no piece transform handles.
|
||||||
|
|
||||||
|
The compact left icon rail contains brushes, brush settings, undo/redo and Settings. Hover or tap the settings icons to reveal their panels; tap inside to keep one open. Projects and palm rejection live in Settings, with less-used pen/display controls in disclosures. Panels overlay the canvas without resizing it and close on viewport resize. Orientation changes preserve apparent zoom, pan and rotation.
|
||||||
|
|
||||||
|
Drag the top-right XYZ gizmo to orbit, or its neighboring pan/zoom icons to move the view. Click an axis endpoint to align the camera; click the facing endpoint again to flip sides. The scene is Y-up. Navigation captures its pointer outside the controls and never sculpts; Escape, lost capture, blur and viewport changes restore the starting view. Navigation waits for an unfinished sculpt transaction to settle. Axis buttons and navigation controls also support keyboard use.
|
||||||
|
|
||||||
|
Controls use the locally vendored Basecoat 1.0.2 standalone stylesheet (shadcn-style native HTML components); provenance and license are in `vendor/basecoat/`. No React, UI JavaScript runtime, CDN, or additional build step. Exclude the unmodified vendor stylesheet from formatters. `styles.css` handles layout/density; `Menus` remains the visibility controller.
|
||||||
|
|
||||||
|
Palm rejection defaults on: canvas touch is ignored, including outside-menu dismissal, while touch controls remain usable. With rejection off, one finger sculpts and two fingers pan/pinch, rolling back any provisional one-finger sculpt. Right-drag rotates, middle-drag pans, and the wheel zooms. Escape, blur, canceled input and unexpected capture loss roll back the stroke. Undo/redo includes whole strokes, masks, colors and structural operations. Native v2 `.yawn-sculpt` projects preserve topology, pieces, colors and masks (not history or settings), and v1 sphere projects still open. OBJ exports geometry and piece groups, not masks/materials. Download before leaving—there is no automatic persistence.
|
||||||
|
|
||||||
|
## Performance bounds and limitations
|
||||||
|
|
||||||
|
| New sphere | Vertices | Triangles | History cap |
|
||||||
|
| ---------- | -------: | --------: | ----------: |
|
||||||
|
| Simple | 2,562 | 5,120 | 16 MiB |
|
||||||
|
| Balanced | 10,242 | 20,480 | 16 MiB |
|
||||||
|
| Fine | 40,962 | 81,920 | 16 MiB |
|
||||||
|
|
||||||
|
History is capped by bytes and 50 entries, with a separate bounded active-stroke position snapshot. A refitted triangle BVH accelerates picking and brush neighborhoods. Affected normals include all incident faces at brush boundaries. Display quality changes pixel budgets (~0.6/1/2 MP) and frame caps (30/60/60), never mesh density. The 8 MiB shared-row arena is **not** the application's total memory budget.
|
||||||
|
|
||||||
|
Documents are capped at 16 pieces, 65,536 vertices and 131,072 triangles. Colors and masks use the existing 32-byte GPU vertex stride and one draw call. Topology algorithms run in a disposable worker with cancellation and a bounded decimation budget; accepted structural edits rebuild main-thread adjacency/BVH and recreate the renderer/stroke worker. This infrequent installation can still pause on constrained devices; it does not add topology work to the stroke path. Resize, failure or Cancel reject pending work. Renderer failure after commit preserves the new document for Save, Undo and Retry.
|
||||||
|
|
||||||
|
Input occupies **one 64-byte `sculptSample` row in core's SAB**, not a sample queue. Main publishes the latest endpoint/pressure; subtracting the previously consumed endpoint gives the accumulated movement. Atomic version tokens protect a coherent snapshot and retire only the consumed update, leaving concurrent newer input pending. A dedicated worker samples this slot with its own `requestAnimationFrame` and performs brush raycasts, deformation, normals and BVH refits. It interpolates straight segments between consumed endpoints; this frame-level approximation does not retain intermediate bends or pressure changes. Grab separately preserves the first effective pressure that establishes its influence.
|
||||||
|
|
||||||
|
The worker retains at most one unfinished segment plus the latest shared endpoint. Resumable interpolation yields after a soft 4 ms budget or 64 brush attempts; an unfinished segment exceeding 250 ms cancels the provisional stroke. One synchronous dab can exceed the budget, especially for large brushes and Detail. Shared memory avoids an input message queue, not the CPU cost of sculpting. Worker and renderer animation frames are independently scheduled, not a same-frame presentation guarantee.
|
||||||
|
|
||||||
|
Worker geometry packets are immutable, transferred, and single-flight; main applies them to its document mirror and acknowledges them. GPU uploads retain their separate single-flight staging guard. Initial picking, document/history operations, and one face-normal/BVH maintenance pass at stroke completion remain on main. Completed geometry/history survive renderer or sculpt-worker failure; unfinished work rolls back. Resize recreates both workers and the shared row because core does not expose surface resizing. Viewport rectangle changes cancel unfinished strokes immediately; new strokes wait for cancellation acknowledgement and the matching renderer size. The sculpt worker stops its animation loop between strokes; core's renderer still has its own loop.
|
||||||
|
|
||||||
|
Requires WebGPU, OffscreenCanvas, shared memory, cross-origin isolation, and dedicated-worker animation frames. Observable initialization/upload failures preserve the document and expose Retry plus Save/Export; core does not expose comprehensive device-loss detection. A front-facing brush filter is not geodesic/occlusion isolation on folded surfaces. This remains **not Nomad parity**: no remesh, dynamic topology during strokes, layers, texture painting, piece transforms or arbitrary mesh import. Coordinates are bounded to ±8 model units.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node --test server/web/assets/sculpt/*.test.js
|
||||||
|
go test ./server
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests exercise topology, brute-force picking comparisons, local normals, pressure, symmetry, history, projects, resumable sampling, input transactions and staged uploads. Benchmark output is orb CPU timing, not a low-end hardware or frame-rate guarantee. Physical pen feel, palm behavior and constrained-device performance require real-device testing.
|
||||||
|
|
||||||
|
On a fresh page, `await (await import('/assets/sculpt/pieces-check.js')).check()` exercises all presets, selection, color/mask history, replacement-topology sculpting, subdivision/decimation, empty projects and real operation-worker delivery races. It also injects pre/post-commit renderer failures. Like the original browser check, it stubs pointer capture and restores hooks afterward; it is not physical pen testing.
|
||||||
|
|
||||||
|
In a fresh browser page, `await (await import('/assets/sculpt/latency-check.js')).check()` replays Draw and Grab at 1, 4, and 16 coalesced samples per animation frame on Balanced-density spheres. It verifies zero main-thread dabs and fixed shared-input storage, and reports main input/packet handling, uploads, release-to-Save-enabled delay and cancellations separately. This synthetic check does not measure physical pen-to-display latency. Unit tests include a real two-thread SAB stress test and deterministic delayed-publication/cancellation races.
|
||||||
|
|
||||||
|
For visual checks in the orb, use `agent-browser --headed --webgpu`: this environment's headless WebGPU presentation can be blank even when rendering succeeds. Inspect desktop, narrow portrait/landscape, settings open, error, and modified/undo/redo states. Test save/reopen and rejected files. Use the page's Help for all gestures and shortcuts.
|
||||||
|
|
||||||
|
Run `await (await import('/assets/sculpt/browser-check.js')).check()` on a fresh page for transaction and UI regressions. This harness stubs pointer capture; native acceptance must use a separate fresh page. Through the browser CDP endpoint (`agent-browser get cdp-url`), use `Input.dispatchMouseEvent` with `pointerType: 'pen'` to drag each navigation control beyond its bounds. Assert actual `hasPointerCapture(event.pointerId)`, release outside, and verify Escape restores all camera fields. During a visibly deforming stroke, focus Center view, send Escape with `Input.dispatchKeyEvent`, release the pen, and verify exact geometry and unchanged history after settlement.
|
||||||
|
|
||||||
|
For rail scrolling, enable `Emulation.setTouchEmulationEnabled` and set a 640×360 CSS-pixel viewport. Verify `(pointer: coarse)` and `scrollHeight > clientHeight`, then use `Input.dispatchTouchEvent` to swipe upward from the center of a brush button. Assert increasing rail `scrollTop`, unchanged selected brush and geometry, and successful tapping of Settings at the bottom. Temporarily overriding rail buttons to `touch-action: none` should reproduce zero scrolling. Do not substitute dispatched DOM pointer events, wheel scrolling, or assigning `scrollTop` for this native gesture check. Browser emulation is not physical-tablet verification.
|
||||||
@@ -0,0 +1,786 @@
|
|||||||
|
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";
|
||||||
|
import { snapshot } from "./document.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.state,
|
||||||
|
fileBusy = false;
|
||||||
|
let operation = null,
|
||||||
|
piecesSignature = "";
|
||||||
|
const brushes = ["draw", "inflate", "smooth", "flatten", "grab", "mask"];
|
||||||
|
let viewNavigation, cameraAspect, messageTimer;
|
||||||
|
const input = new Input(viewport, {
|
||||||
|
engine: () => engine,
|
||||||
|
processor: () => renderer.processor,
|
||||||
|
camera,
|
||||||
|
settings,
|
||||||
|
ready: () =>
|
||||||
|
!!renderer &&
|
||||||
|
!initializing &&
|
||||||
|
!fileBusy &&
|
||||||
|
!operation &&
|
||||||
|
!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 &&
|
||||||
|
!operation &&
|
||||||
|
sizeKey() === lastSize &&
|
||||||
|
!document.querySelector("dialog[open]"),
|
||||||
|
start: () => {
|
||||||
|
menus.close(false);
|
||||||
|
cursor(null);
|
||||||
|
},
|
||||||
|
changed: updateUI,
|
||||||
|
invalidate,
|
||||||
|
});
|
||||||
|
function interactionBusy() {
|
||||||
|
return input.busy || !!viewNavigation?.busy || !!operation;
|
||||||
|
}
|
||||||
|
const colorHex = (color) =>
|
||||||
|
"#" +
|
||||||
|
color
|
||||||
|
.map((x) =>
|
||||||
|
Math.round(x ** (1 / 2.2) * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0"),
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
function updatePieces(busy) {
|
||||||
|
const pieces = engine.mesh.pieces;
|
||||||
|
const signature = JSON.stringify([pieces, engine.activeId, busy]);
|
||||||
|
if (signature !== piecesSignature) {
|
||||||
|
piecesSignature = signature;
|
||||||
|
$("piece-list").replaceChildren(
|
||||||
|
...pieces.map((piece) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.className = "btn piece-row";
|
||||||
|
button.setAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
String(piece.id === engine.activeId),
|
||||||
|
);
|
||||||
|
button.dataset.piece = piece.id;
|
||||||
|
button.disabled = busy;
|
||||||
|
const swatch = document.createElement("i");
|
||||||
|
swatch.style.background = colorHex(piece.color);
|
||||||
|
button.append(swatch, document.createTextNode(piece.name));
|
||||||
|
button.onclick = () => {
|
||||||
|
if (interactionBusy() || initializing || fileBusy) return;
|
||||||
|
engine.activeId = piece.id;
|
||||||
|
updateUI();
|
||||||
|
};
|
||||||
|
return button;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const active = pieces.find((p) => p.id === engine.activeId);
|
||||||
|
for (const id of [
|
||||||
|
"delete-piece",
|
||||||
|
"piece-color",
|
||||||
|
"subdivide",
|
||||||
|
"decimate",
|
||||||
|
"clear-mask",
|
||||||
|
"invert-mask",
|
||||||
|
])
|
||||||
|
$(id).disabled = busy || !active;
|
||||||
|
if (active) $("piece-color").value = colorHex(active.color);
|
||||||
|
document
|
||||||
|
.querySelectorAll("[data-preset]")
|
||||||
|
.forEach((b) => (b.disabled = busy || pieces.length >= 16));
|
||||||
|
$("empty-clay").hidden = !!pieces.length || busy;
|
||||||
|
}
|
||||||
|
function settleOperation(job) {
|
||||||
|
if (!job || operation !== job) return false;
|
||||||
|
clearTimeout(job.timer);
|
||||||
|
job.worker.terminate();
|
||||||
|
operation = null;
|
||||||
|
$("clay-progress").hidden = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function cancelOperation() {
|
||||||
|
if (settleOperation(operation)) updateUI();
|
||||||
|
}
|
||||||
|
function clayOperation(action, options = {}) {
|
||||||
|
if (interactionBusy() || initializing || fileBusy) return;
|
||||||
|
const worker = new Worker(new URL("./operations.js", import.meta.url), {
|
||||||
|
type: "module",
|
||||||
|
name: "clay-topology",
|
||||||
|
});
|
||||||
|
const job = (operation = {
|
||||||
|
worker,
|
||||||
|
engine,
|
||||||
|
state: engine.state,
|
||||||
|
generation,
|
||||||
|
size: sizeKey(),
|
||||||
|
});
|
||||||
|
const finish = () => {
|
||||||
|
if (!settleOperation(job)) return false;
|
||||||
|
updateUI();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
job.timer = setTimeout(() => {
|
||||||
|
if (finish())
|
||||||
|
message("That operation took too long. Your clay is unchanged.");
|
||||||
|
}, 20000);
|
||||||
|
worker.onerror = worker.onmessageerror = () => {
|
||||||
|
if (finish()) message("Could not edit this piece. Your clay is unchanged.");
|
||||||
|
};
|
||||||
|
worker.onmessage = ({ data }) => {
|
||||||
|
if (operation !== job) return;
|
||||||
|
if (
|
||||||
|
engine !== job.engine ||
|
||||||
|
engine.state !== job.state ||
|
||||||
|
generation !== job.generation ||
|
||||||
|
sizeKey() !== job.size
|
||||||
|
) {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.error) {
|
||||||
|
finish();
|
||||||
|
message(data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const changed = engine.install(data.document, data.activeId);
|
||||||
|
finish();
|
||||||
|
checkModified();
|
||||||
|
if (changed) {
|
||||||
|
if (action === "add") frameModel();
|
||||||
|
recreate();
|
||||||
|
}
|
||||||
|
message(data.note || (changed ? "Clay updated" : "No change needed"));
|
||||||
|
} catch (error) {
|
||||||
|
finish();
|
||||||
|
message(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$("clay-progress").hidden = false;
|
||||||
|
$("operation-label").textContent =
|
||||||
|
action === "decimate"
|
||||||
|
? "Simplifying clay…"
|
||||||
|
: action === "subdivide"
|
||||||
|
? "Adding detail…"
|
||||||
|
: "Updating clay…";
|
||||||
|
updateUI();
|
||||||
|
try {
|
||||||
|
const document = snapshot(engine.mesh);
|
||||||
|
worker.postMessage(
|
||||||
|
{ document, action, activeId: engine.activeId, options },
|
||||||
|
[
|
||||||
|
document.positions.buffer,
|
||||||
|
document.indices.buffer,
|
||||||
|
document.masks.buffer,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (finish()) message(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$("cancel-operation").onclick = cancelOperation;
|
||||||
|
window.addEventListener(
|
||||||
|
"keydown",
|
||||||
|
(e) => {
|
||||||
|
if (e.key === "Escape" && !document.querySelector("dialog[open]"))
|
||||||
|
cancelOperation();
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
$("add-empty").onclick = () => $("pieces-toggle").click();
|
||||||
|
$("delete-piece").onclick = () => clayOperation("delete");
|
||||||
|
$("subdivide").onclick = () => clayOperation("subdivide");
|
||||||
|
$("decimate").onclick = () => clayOperation("decimate");
|
||||||
|
document.querySelectorAll("[data-preset]").forEach(
|
||||||
|
(b) =>
|
||||||
|
(b.onclick = () => {
|
||||||
|
// Store the randomly chosen material once; undo/redo never rerolls it.
|
||||||
|
const hue = Math.random() * 6;
|
||||||
|
const rgb = [0, 4, 2].map((offset) => {
|
||||||
|
const k = (hue + offset) % 6;
|
||||||
|
return (0.88 - 0.24 * Math.max(0, Math.min(k, 4 - k, 1))) ** 2.2;
|
||||||
|
});
|
||||||
|
clayOperation("add", {
|
||||||
|
preset: b.dataset.preset,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
color: rgb,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
$("piece-color").onchange = () => {
|
||||||
|
if (interactionBusy() || initializing || !engine.activeId) return;
|
||||||
|
const hex = $("piece-color").value;
|
||||||
|
engine.setColor(
|
||||||
|
[1, 3, 5].map((i) => (parseInt(hex.slice(i, i + 2), 16) / 255) ** 2.2),
|
||||||
|
);
|
||||||
|
checkModified();
|
||||||
|
invalidate();
|
||||||
|
};
|
||||||
|
for (const id of ["clear-mask", "invert-mask"])
|
||||||
|
$(id).onclick = () => {
|
||||||
|
if (interactionBusy() || initializing) return;
|
||||||
|
engine.editMask(id === "invert-mask");
|
||||||
|
checkModified();
|
||||||
|
invalidate();
|
||||||
|
};
|
||||||
|
$("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.state !== saved;
|
||||||
|
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;
|
||||||
|
updatePieces(busy);
|
||||||
|
if (pendingResize && !resizeTimer && !interactionBusy() && !initializing) {
|
||||||
|
const token = generation;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (
|
||||||
|
token === generation &&
|
||||||
|
pendingResize &&
|
||||||
|
!resizeTimer &&
|
||||||
|
!interactionBusy() &&
|
||||||
|
!initializing
|
||||||
|
)
|
||||||
|
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) {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = 0;
|
||||||
|
pendingResize = false;
|
||||||
|
settleOperation(operation);
|
||||||
|
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, (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;
|
||||||
|
if (!engine.mesh.indices.length) {
|
||||||
|
camera.target = [0, 0, 0];
|
||||||
|
camera.zoom = 1.5;
|
||||||
|
invalidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
saved = engine.state;
|
||||||
|
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.state;
|
||||||
|
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 = () => {
|
||||||
|
const mesh = engine.mesh;
|
||||||
|
if (!interactionBusy() && !initializing && engine.restore()) {
|
||||||
|
checkModified();
|
||||||
|
if (engine.mesh !== mesh) recreate();
|
||||||
|
invalidate();
|
||||||
|
message("Undo");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$("redo").onclick = () => {
|
||||||
|
const mesh = engine.mesh;
|
||||||
|
if (!interactionBusy() && !initializing && engine.restore(true)) {
|
||||||
|
checkModified();
|
||||||
|
if (engine.mesh !== mesh) recreate();
|
||||||
|
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", "mask"].includes(name);
|
||||||
|
$("invert-label").textContent = name === "mask" ? "Erase mask" : "Subtract";
|
||||||
|
}
|
||||||
|
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 = () => {
|
||||||
|
if (interactionBusy() || initializing || !engine.activeId) return;
|
||||||
|
engine.setColor(b.dataset.color.split(",").map(Number));
|
||||||
|
checkModified();
|
||||||
|
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);
|
||||||
|
if (operation && operation.size !== sizeKey()) cancelOperation();
|
||||||
|
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-6]$/.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();
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
// Run explicitly in a fresh /sculpt page:
|
||||||
|
// await (await import('/assets/sculpt/browser-check.js')).check()
|
||||||
|
// Synthetic input checks logic, not physical pen feel. Hooks are always restored.
|
||||||
|
import { Renderer } from "./renderer.js";
|
||||||
|
import { Navigation } from "./navigation.js";
|
||||||
|
|
||||||
|
export async function check() {
|
||||||
|
const $ = (id) => document.getElementById(id),
|
||||||
|
results = [];
|
||||||
|
const assert = (value, message) => {
|
||||||
|
if (!value) throw new Error(message);
|
||||||
|
};
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const wait = async (fn) => {
|
||||||
|
const start = performance.now();
|
||||||
|
while (!fn()) {
|
||||||
|
if (performance.now() - start > 15000)
|
||||||
|
throw new Error("Timed out waiting for UI");
|
||||||
|
await sleep(25);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const viewport = $("viewport"),
|
||||||
|
capture = viewport.setPointerCapture,
|
||||||
|
createURL = URL.createObjectURL,
|
||||||
|
publish = Renderer.prototype.publish;
|
||||||
|
const navRoot = $("view-navigation"),
|
||||||
|
navCapture = navRoot.setPointerCapture,
|
||||||
|
navRender = Navigation.prototype.render;
|
||||||
|
let nav;
|
||||||
|
navRoot.setPointerCapture = () => {};
|
||||||
|
Navigation.prototype.render = function () {
|
||||||
|
nav = this;
|
||||||
|
return navRender.call(this);
|
||||||
|
};
|
||||||
|
let blob,
|
||||||
|
engine,
|
||||||
|
camera,
|
||||||
|
processor,
|
||||||
|
publications = 0,
|
||||||
|
failNext = false;
|
||||||
|
viewport.setPointerCapture = () => {};
|
||||||
|
URL.createObjectURL = function (value) {
|
||||||
|
blob = value;
|
||||||
|
return createURL.call(this, value);
|
||||||
|
};
|
||||||
|
Renderer.prototype.publish = function (...args) {
|
||||||
|
engine = args[0];
|
||||||
|
camera = args[1];
|
||||||
|
processor = this.processor;
|
||||||
|
publications++;
|
||||||
|
if (failNext) {
|
||||||
|
failNext = false;
|
||||||
|
return Promise.reject(new Error("Synthetic upload failure"));
|
||||||
|
}
|
||||||
|
return publish.apply(this, args);
|
||||||
|
};
|
||||||
|
const saved = async () => {
|
||||||
|
await wait(() => !$("save").disabled);
|
||||||
|
$("save").click();
|
||||||
|
return JSON.parse(await blob.text());
|
||||||
|
};
|
||||||
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
const pointer = (type, x, y, pressure = 0.7, pointerType = "pen", id = 7) => {
|
||||||
|
const r = viewport.getBoundingClientRect();
|
||||||
|
viewport.querySelector("canvas").dispatchEvent(
|
||||||
|
new PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
pointerId: id,
|
||||||
|
pointerType,
|
||||||
|
clientX: r.left + r.width * x,
|
||||||
|
clientY: r.top + r.height * y,
|
||||||
|
pressure,
|
||||||
|
button: 0,
|
||||||
|
buttons: type === "pointerup" ? 0 : 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const stroke = async (p) => {
|
||||||
|
pointer("pointerdown", 0.55, 0.44, p);
|
||||||
|
pointer("pointermove", 0.59, 0.51, p);
|
||||||
|
pointer("pointerup", 0.59, 0.51, 0);
|
||||||
|
await wait(() => !$("save").disabled);
|
||||||
|
await sleep(100);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await wait(() => $("loading").hidden && $("error").hidden);
|
||||||
|
const initialCanvas = viewport.querySelector("canvas");
|
||||||
|
const bounds = JSON.stringify(viewport.getBoundingClientRect());
|
||||||
|
assert(
|
||||||
|
$("tools-panel").hidden && $("inspector").hidden,
|
||||||
|
"Panels must start closed",
|
||||||
|
);
|
||||||
|
const hover = (id, type = "pointerenter") =>
|
||||||
|
$(id).dispatchEvent(
|
||||||
|
new PointerEvent(type, { pointerType: "pen", buttons: 0 }),
|
||||||
|
);
|
||||||
|
hover("tools-toggle");
|
||||||
|
assert(!$("tools-panel").hidden, "Pen hover did not reveal brushes");
|
||||||
|
const palette = $("tools-panel").getBoundingClientRect();
|
||||||
|
assert(
|
||||||
|
palette.left >= 50 && palette.width <= 216,
|
||||||
|
"Brush popover overlaps rail or has oversized width",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
document.querySelectorAll(".tool-rail [data-brush] svg").length === 6,
|
||||||
|
"Brush rail must include five sculpt brushes and Mask",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
$("radius").style.getPropertyValue("--slider-value").startsWith("32.14"),
|
||||||
|
"Basecoat size fill is not synchronized",
|
||||||
|
);
|
||||||
|
pointer("pointerdown", 0.8, 0.8, 1, "touch", 91);
|
||||||
|
pointer("pointermove", 0.82, 0.81, 1, "touch", 91);
|
||||||
|
pointer("pointerup", 0.82, 0.81, 0, "touch", 91);
|
||||||
|
assert(!$("tools-panel").hidden, "Rejected palm dismissed hover tools");
|
||||||
|
hover("tools-toggle", "pointerleave");
|
||||||
|
await sleep(100);
|
||||||
|
hover("tools-panel");
|
||||||
|
await sleep(300);
|
||||||
|
assert(!$("tools-panel").hidden, "Crossing panel gap closed tools");
|
||||||
|
hover("tools-panel", "pointerleave");
|
||||||
|
await sleep(300);
|
||||||
|
assert($("tools-panel").hidden, "Hover panel did not dismiss");
|
||||||
|
$("settings-toggle").click();
|
||||||
|
hover("settings-toggle", "pointerleave");
|
||||||
|
await sleep(300);
|
||||||
|
assert(!$("inspector").hidden, "Tapped panel did not stay open");
|
||||||
|
pointer("pointerdown", 0.2, 0.8, 1, "touch", 91);
|
||||||
|
pointer("pointerup", 0.2, 0.8, 0, "touch", 91);
|
||||||
|
assert(!$("inspector").hidden, "Rejected palm dismissed pinned settings");
|
||||||
|
$("pen-settings").open = true;
|
||||||
|
assert(
|
||||||
|
$("reset-pressure").getBoundingClientRect().bottom <=
|
||||||
|
$("pen-settings").nextElementSibling.getBoundingClientRect().top,
|
||||||
|
"Expanded pen controls overlap display disclosure",
|
||||||
|
);
|
||||||
|
$("gain").dispatchEvent(
|
||||||
|
new PointerEvent("pointerdown", { pointerType: "pen", bubbles: true }),
|
||||||
|
);
|
||||||
|
pointer("pointerdown", 0.2, 0.8, 1, "touch", 91);
|
||||||
|
assert(
|
||||||
|
!$("inspector").hidden,
|
||||||
|
"Rejected palm interrupted slider interaction",
|
||||||
|
);
|
||||||
|
pointer("pointerup", 0.2, 0.8, 0, "touch", 91);
|
||||||
|
$("gain").focus();
|
||||||
|
$("gain").dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
$("inspector").hidden && document.activeElement === $("settings-toggle"),
|
||||||
|
"Escape did not restore launcher focus",
|
||||||
|
);
|
||||||
|
$("pen-settings").open = false;
|
||||||
|
assert(
|
||||||
|
initialCanvas === viewport.querySelector("canvas") &&
|
||||||
|
bounds === JSON.stringify(viewport.getBoundingClientRect()),
|
||||||
|
"Menus resized/replaced canvas",
|
||||||
|
);
|
||||||
|
results.push(
|
||||||
|
"pen hover / gap grace / auto-hide / tap pin / palm-safe panels and slider / Escape focus / unchanged canvas",
|
||||||
|
);
|
||||||
|
$("new").click();
|
||||||
|
$("density").value = "4";
|
||||||
|
$("new-dialog").close("create");
|
||||||
|
await wait(
|
||||||
|
() =>
|
||||||
|
viewport.querySelector("canvas") !== initialCanvas &&
|
||||||
|
$("loading").hidden &&
|
||||||
|
!$("new").disabled,
|
||||||
|
);
|
||||||
|
await sleep(100);
|
||||||
|
const original = await saved();
|
||||||
|
await stroke(0);
|
||||||
|
assert(same(original, await saved()), "Zero pen pressure altered geometry");
|
||||||
|
results.push("zero pen pressure");
|
||||||
|
await stroke(0.8);
|
||||||
|
const edited = await saved();
|
||||||
|
assert(!same(original, edited), "Pen stroke did not deform mesh");
|
||||||
|
assert(!$("undo").disabled, "Undo unavailable");
|
||||||
|
$("undo").click();
|
||||||
|
assert(same(original, await saved()), "Undo not exact");
|
||||||
|
$("redo").click();
|
||||||
|
assert(same(edited, await saved()), "Redo not exact");
|
||||||
|
results.push("pen sculpt / exact undo / redo");
|
||||||
|
pointer("pointerdown", 0.48, 0.5, 0.9);
|
||||||
|
await sleep(100);
|
||||||
|
pointer("pointermove", 0.52, 0.55, 0.9);
|
||||||
|
viewport.dispatchEvent(new PointerEvent("pointercancel", { pointerId: 7 }));
|
||||||
|
assert(same(edited, await saved()), "Cancel did not restore mesh");
|
||||||
|
results.push("pointer cancellation");
|
||||||
|
for (const settling of [false, true]) {
|
||||||
|
const before = engine.mesh.positions.slice();
|
||||||
|
const history = [...engine.undoStack];
|
||||||
|
const redoHistory = [...engine.redoStack];
|
||||||
|
pointer("pointerdown", 0.48, 0.5, 0.9);
|
||||||
|
pointer("pointermove", 0.56, 0.55, 0.9);
|
||||||
|
await wait(() => engine.mesh.positions.some((v, i) => v !== before[i]));
|
||||||
|
$("frame").focus();
|
||||||
|
if (settling) pointer("pointerup", 0.56, 0.55, 0);
|
||||||
|
$("frame").dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", {
|
||||||
|
key: "Escape",
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
engine.mesh.positions.every((v, i) => v === before[i]),
|
||||||
|
"Focused navigation swallowed sculpt cancellation",
|
||||||
|
);
|
||||||
|
if (!settling) pointer("pointerup", 0.56, 0.55, 0);
|
||||||
|
await wait(() => !$("save").disabled);
|
||||||
|
assert(
|
||||||
|
engine.mesh.positions.every((v, i) => v === before[i]) &&
|
||||||
|
engine.undoStack.length === history.length &&
|
||||||
|
engine.undoStack.every((v, i) => v === history[i]) &&
|
||||||
|
engine.redoStack.length === redoHistory.length &&
|
||||||
|
engine.redoStack.every((v, i) => v === redoHistory[i]),
|
||||||
|
"Focused navigation cancellation changed geometry/history after settlement",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
results.push(
|
||||||
|
"focused navigation Escape rolls back active and settling sculpt",
|
||||||
|
);
|
||||||
|
assert($("palm-rejection").checked, "Palm rejection must default on");
|
||||||
|
await sleep(100);
|
||||||
|
const beforePalm = structuredClone(camera);
|
||||||
|
pointer("pointerdown", 0.48, 0.5, 1, "touch", 1);
|
||||||
|
pointer("pointermove", 0.58, 0.54, 1, "touch", 1);
|
||||||
|
pointer("pointerup", 0.58, 0.54, 0, "touch", 1);
|
||||||
|
await sleep(100);
|
||||||
|
assert(
|
||||||
|
same(beforePalm, camera) && same(edited, await saved()),
|
||||||
|
"Palm changed canvas",
|
||||||
|
);
|
||||||
|
results.push("default palm rejection");
|
||||||
|
$("palm-rejection").checked = false;
|
||||||
|
$("palm-rejection").dispatchEvent(new Event("change"));
|
||||||
|
pointer("pointerdown", 0.48, 0.5, 1, "touch", 1);
|
||||||
|
await sleep(100);
|
||||||
|
pointer("pointerdown", 0.56, 0.52, 1, "touch", 2);
|
||||||
|
pointer("pointermove", 0.58, 0.54, 1, "touch", 2);
|
||||||
|
pointer("pointerup", 0.48, 0.5, 0, "touch", 1);
|
||||||
|
pointer("pointerup", 0.58, 0.54, 0, "touch", 2);
|
||||||
|
assert(same(edited, await saved()), "Pinch left sculpt marks");
|
||||||
|
await wait(() => camera.zoom !== beforePalm.zoom);
|
||||||
|
$("palm-rejection").checked = true;
|
||||||
|
$("palm-rejection").dispatchEvent(new Event("change"));
|
||||||
|
results.push("two-touch rollback/navigation");
|
||||||
|
$("quality").value = "economy";
|
||||||
|
$("quality").dispatchEvent(new Event("change"));
|
||||||
|
await wait(() => $("loading").hidden && !$("quality").disabled);
|
||||||
|
assert(same(edited, await saved()), "Quality changed geometry");
|
||||||
|
assert(!$("undo").disabled, "Quality lost history");
|
||||||
|
results.push("quality preserves mesh/history");
|
||||||
|
const oldCanvas = viewport.querySelector("canvas");
|
||||||
|
viewport.style.width = "85%";
|
||||||
|
await wait(
|
||||||
|
() =>
|
||||||
|
viewport.querySelector("canvas") !== oldCanvas && $("loading").hidden,
|
||||||
|
);
|
||||||
|
assert(same(edited, await saved()), "Resize changed geometry");
|
||||||
|
assert(!$("undo").disabled, "Resize lost history");
|
||||||
|
const smallCanvas = viewport.querySelector("canvas");
|
||||||
|
viewport.style.width = "";
|
||||||
|
await wait(
|
||||||
|
() =>
|
||||||
|
viewport.querySelector("canvas") !== smallCanvas && $("loading").hidden,
|
||||||
|
);
|
||||||
|
results.push("resize preserves mesh/history");
|
||||||
|
const positions = engine.mesh.positions.slice(),
|
||||||
|
undo = [...engine.undoStack],
|
||||||
|
redo = [...engine.redoStack],
|
||||||
|
bytes = engine.historyBytes;
|
||||||
|
pointer("pointerdown", 0.48, 0.5, 0.9);
|
||||||
|
pointer("pointermove", 0.56, 0.55, 0.9);
|
||||||
|
await wait(() => engine.mesh.positions.some((v, i) => v !== positions[i]));
|
||||||
|
const beforeResize = viewport.querySelector("canvas");
|
||||||
|
viewport.style.width = "90%";
|
||||||
|
// Release in this task, before ResizeObserver or the 200 ms debounce runs.
|
||||||
|
pointer("pointerup", 0.56, 0.55, 0);
|
||||||
|
const exact = () =>
|
||||||
|
engine.mesh.positions.every((v, i) => v === positions[i]) &&
|
||||||
|
engine.undoStack.length === undo.length &&
|
||||||
|
engine.undoStack.every((v, i) => v === undo[i]) &&
|
||||||
|
engine.redoStack.length === redo.length &&
|
||||||
|
engine.redoStack.every((v, i) => v === redo[i]) &&
|
||||||
|
engine.historyBytes === bytes;
|
||||||
|
assert(
|
||||||
|
exact(),
|
||||||
|
"Resize/release committed or retained provisional geometry",
|
||||||
|
);
|
||||||
|
pointer("pointerdown", 0.5, 0.5, 0.9);
|
||||||
|
assert(!engine.stroke, "Stroke started against a stale renderer size");
|
||||||
|
pointer("pointerup", 0.5, 0.5, 0);
|
||||||
|
await wait(
|
||||||
|
() =>
|
||||||
|
viewport.querySelector("canvas") !== beforeResize &&
|
||||||
|
$("loading").hidden,
|
||||||
|
);
|
||||||
|
assert(exact(), "Resize recreation changed history or geometry");
|
||||||
|
await stroke(0.8);
|
||||||
|
assert(
|
||||||
|
!same(edited, await saved()),
|
||||||
|
"New stroke failed after resize recovery",
|
||||||
|
);
|
||||||
|
$("undo").click();
|
||||||
|
assert(same(edited, await saved()), "Recovery stroke undo failed");
|
||||||
|
const resizedCanvas = viewport.querySelector("canvas");
|
||||||
|
viewport.style.width = "";
|
||||||
|
await wait(
|
||||||
|
() =>
|
||||||
|
viewport.querySelector("canvas") !== resizedCanvas &&
|
||||||
|
$("loading").hidden,
|
||||||
|
);
|
||||||
|
results.push(
|
||||||
|
"resize during stroke: immediate exact rollback / stale-start gate / recovery",
|
||||||
|
);
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.items.add(new File(['{"version":42}'], "invalid.yawn-sculpt"));
|
||||||
|
$("file").files = dt.files;
|
||||||
|
$("file").dispatchEvent(new Event("change"));
|
||||||
|
await wait(() => !$("open").disabled);
|
||||||
|
assert(same(edited, await saved()), "Invalid project replaced geometry");
|
||||||
|
results.push("invalid project preservation");
|
||||||
|
const native = JSON.stringify(edited),
|
||||||
|
valid = new DataTransfer();
|
||||||
|
valid.items.add(new File([native], "roundtrip.yawn-sculpt"));
|
||||||
|
$("file").files = valid.files;
|
||||||
|
$("file").dispatchEvent(new Event("change"));
|
||||||
|
await wait(() => !$("open").disabled && $("loading").hidden);
|
||||||
|
assert(same(edited, await saved()), "Project roundtrip changed geometry");
|
||||||
|
results.push("native project roundtrip");
|
||||||
|
$("export").click();
|
||||||
|
const obj = await blob.text();
|
||||||
|
assert(
|
||||||
|
obj.split("\n").filter((l) => l.startsWith("v ")).length === 2562,
|
||||||
|
"OBJ vertex count",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
obj.split("\n").filter((l) => l.startsWith("f ")).length === 5120,
|
||||||
|
"OBJ face count",
|
||||||
|
);
|
||||||
|
results.push("OBJ export");
|
||||||
|
const navPointer = (mode, type, x = 100, y = 100) =>
|
||||||
|
document.querySelector(`[data-nav="${mode}"]`).dispatchEvent(
|
||||||
|
new PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
pointerType: "pen",
|
||||||
|
pointerId: 90,
|
||||||
|
button: 0,
|
||||||
|
clientX: x,
|
||||||
|
clientY: y,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await wait(() => nav);
|
||||||
|
for (const mode of ["orbit", "pan", "zoom"]) {
|
||||||
|
const before = structuredClone(nav.host.camera);
|
||||||
|
navPointer(mode, "pointerdown");
|
||||||
|
assert(nav.busy, "Navigation did not capture gesture");
|
||||||
|
pointer("pointerdown", 0.5, 0.5);
|
||||||
|
assert(!engine.stroke, "Canvas sculpt started during navigation");
|
||||||
|
viewport.dispatchEvent(
|
||||||
|
new WheelEvent("wheel", {
|
||||||
|
deltaY: 200,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert(same(nav.host.camera, before), "Wheel competed with navigation");
|
||||||
|
navPointer(mode, "pointermove", 126, 119);
|
||||||
|
assert(!same(nav.host.camera, before), "Navigation did not move camera");
|
||||||
|
navRoot.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!nav.busy && same(nav.host.camera, before),
|
||||||
|
"Escape did not restore camera",
|
||||||
|
);
|
||||||
|
navPointer(mode, "pointerdown");
|
||||||
|
navPointer(mode, "pointerup", 126, 119);
|
||||||
|
assert(
|
||||||
|
!nav.busy && !same(nav.host.camera, before),
|
||||||
|
"Navigation release did not finish",
|
||||||
|
);
|
||||||
|
await sleep(80);
|
||||||
|
}
|
||||||
|
const navBefore = structuredClone(nav.host.camera);
|
||||||
|
pointer("pointerdown", 0.5, 0.5);
|
||||||
|
navPointer("orbit", "pointerdown");
|
||||||
|
assert(!nav.busy, "Navigation started during sculpt");
|
||||||
|
pointer("pointerup", 0.5, 0.5, 0);
|
||||||
|
navPointer("orbit", "pointerdown");
|
||||||
|
assert(!nav.busy, "Navigation started during worker settlement");
|
||||||
|
await wait(() => !$("save").disabled);
|
||||||
|
$("undo").click();
|
||||||
|
assert(
|
||||||
|
same(nav.host.camera, navBefore),
|
||||||
|
"Rejected navigation changed camera",
|
||||||
|
);
|
||||||
|
const viewBefore = structuredClone(camera),
|
||||||
|
aspectBefore = viewport.clientWidth / viewport.clientHeight;
|
||||||
|
const shortZoom = viewBefore.zoom * Math.min(1, aspectBefore);
|
||||||
|
const resizeTo = async (width, height) => {
|
||||||
|
const previous = viewport.querySelector("canvas");
|
||||||
|
viewport.style.width = width;
|
||||||
|
viewport.style.height = height;
|
||||||
|
await wait(
|
||||||
|
() =>
|
||||||
|
viewport.querySelector("canvas") !== previous && $("loading").hidden,
|
||||||
|
);
|
||||||
|
await sleep(100);
|
||||||
|
};
|
||||||
|
await resizeTo("600px", "1000px");
|
||||||
|
assert(
|
||||||
|
Math.abs(camera.zoom * 0.6 - shortZoom) < 1e-9,
|
||||||
|
"Portrait changed apparent zoom",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
same(camera.target, viewBefore.target) &&
|
||||||
|
camera.yaw === viewBefore.yaw &&
|
||||||
|
camera.pitch === viewBefore.pitch,
|
||||||
|
"Orientation reset camera pose",
|
||||||
|
);
|
||||||
|
await resizeTo("", "");
|
||||||
|
assert(
|
||||||
|
Math.abs(camera.zoom - viewBefore.zoom) < 1e-9,
|
||||||
|
"Orientation roundtrip changed zoom",
|
||||||
|
);
|
||||||
|
assert(same(edited, await saved()), "Navigation changed geometry");
|
||||||
|
document.querySelector('[data-brush="draw"]').click();
|
||||||
|
results.push(
|
||||||
|
"direct navigation / mutual exclusion / Escape rollback / orientation / geometry intact",
|
||||||
|
);
|
||||||
|
await sleep(300);
|
||||||
|
const idle = publications;
|
||||||
|
await sleep(500);
|
||||||
|
assert(publications === idle, "Idle repeated publications");
|
||||||
|
results.push("idle: zero publications over 500 ms");
|
||||||
|
failNext = true;
|
||||||
|
$("frame").click();
|
||||||
|
await wait(() => !$("error").hidden);
|
||||||
|
assert(!$("save").disabled, "Error disabled save");
|
||||||
|
assert(same(edited, await saved()), "Upload failure lost geometry");
|
||||||
|
$("retry").click();
|
||||||
|
await wait(() => $("loading").hidden && $("error").hidden);
|
||||||
|
assert(same(edited, await saved()), "Retry lost geometry");
|
||||||
|
results.push("upload failure / save / retry");
|
||||||
|
const recovered = engine.mesh.positions.slice();
|
||||||
|
pointer("pointerdown", 0.5, 0.5, 0.9);
|
||||||
|
await wait(() => engine.mesh.positions.some((v, i) => v !== recovered[i]));
|
||||||
|
// Publication captures the renderer's client after recreation; geometry can
|
||||||
|
// arrive first. Never inject into the previous, already-disposed worker.
|
||||||
|
await wait(() => processor && !processor.disposed && processor.job);
|
||||||
|
const oldProcessor = processor;
|
||||||
|
oldProcessor.worker.dispatchEvent(new Event("error"));
|
||||||
|
await wait(() => !$("error").hidden && !$("save").disabled);
|
||||||
|
assert(
|
||||||
|
same(edited, await saved()),
|
||||||
|
"Worker failure retained provisional geometry",
|
||||||
|
);
|
||||||
|
assert(oldProcessor.disposed, "Failed worker was not terminated");
|
||||||
|
$("retry").click();
|
||||||
|
await wait(
|
||||||
|
() =>
|
||||||
|
$("loading").hidden && $("error").hidden && processor !== oldProcessor,
|
||||||
|
);
|
||||||
|
await stroke(0.8);
|
||||||
|
assert(
|
||||||
|
!same(edited, await saved()),
|
||||||
|
"New worker failed to sculpt after retry",
|
||||||
|
);
|
||||||
|
$("undo").click();
|
||||||
|
assert(same(edited, await saved()), "Recovered worker undo failed");
|
||||||
|
results.push(
|
||||||
|
"sculpt worker failure / exact rollback / new worker / sculpt / undo",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
passed: results.length,
|
||||||
|
checks: results,
|
||||||
|
publications,
|
||||||
|
physicalPenVerified: false,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
viewport.setPointerCapture = capture;
|
||||||
|
URL.createObjectURL = createURL;
|
||||||
|
Renderer.prototype.publish = publish;
|
||||||
|
navRoot.setPointerCapture = navCapture;
|
||||||
|
Navigation.prototype.render = navRender;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { add, mul, sub, dot, cross, unit, point } from "./math.js";
|
||||||
|
|
||||||
|
export function triangleHit(mesh, f, origin, direction) {
|
||||||
|
const ids = mesh.indices.subarray(f * 3, f * 3 + 3);
|
||||||
|
const a = point(mesh.positions, ids[0]);
|
||||||
|
const e1 = sub(point(mesh.positions, ids[1]), a),
|
||||||
|
e2 = sub(point(mesh.positions, ids[2]), a);
|
||||||
|
const h = cross(direction, e2),
|
||||||
|
det = dot(e1, h);
|
||||||
|
if (Math.abs(det) < 1e-9) return null;
|
||||||
|
const s = sub(origin, a),
|
||||||
|
u = dot(s, h) / det;
|
||||||
|
if (u < 0 || u > 1) return null;
|
||||||
|
const q = cross(s, e1),
|
||||||
|
v = dot(direction, q) / det;
|
||||||
|
if (v < 0 || u + v > 1) return null;
|
||||||
|
const t = dot(e2, q) / det;
|
||||||
|
return t > 0
|
||||||
|
? {
|
||||||
|
t,
|
||||||
|
position: add(origin, mul(direction, t)),
|
||||||
|
normal: unit(cross(e1, e2)),
|
||||||
|
face: f,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BVH {
|
||||||
|
constructor(mesh) {
|
||||||
|
this.mesh = mesh;
|
||||||
|
this.leaves = new Array(mesh.indices.length / 3);
|
||||||
|
const centers = Array.from({ length: this.leaves.length }, (_, f) =>
|
||||||
|
[0, 1, 2].map(
|
||||||
|
(k) =>
|
||||||
|
(mesh.positions[mesh.indices[f * 3] * 3 + k] +
|
||||||
|
mesh.positions[mesh.indices[f * 3 + 1] * 3 + k] +
|
||||||
|
mesh.positions[mesh.indices[f * 3 + 2] * 3 + k]) /
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const build = (faces, parent = null) => {
|
||||||
|
const node = { parent, min: [0, 0, 0], max: [0, 0, 0] };
|
||||||
|
if (faces.length <= 8) {
|
||||||
|
node.faces = faces;
|
||||||
|
for (const f of faces) this.leaves[f] = node;
|
||||||
|
} else {
|
||||||
|
const spans = [0, 1, 2].map((k) => {
|
||||||
|
let lo = Infinity,
|
||||||
|
hi = -Infinity;
|
||||||
|
for (const f of faces) {
|
||||||
|
lo = Math.min(lo, centers[f][k]);
|
||||||
|
hi = Math.max(hi, centers[f][k]);
|
||||||
|
}
|
||||||
|
return hi - lo;
|
||||||
|
});
|
||||||
|
const axis = spans.indexOf(Math.max(...spans));
|
||||||
|
faces.sort((a, b) => centers[a][axis] - centers[b][axis]);
|
||||||
|
const mid = faces.length >> 1;
|
||||||
|
node.left = build(faces.slice(0, mid), node);
|
||||||
|
node.right = build(faces.slice(mid), node);
|
||||||
|
}
|
||||||
|
this.bounds(node);
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
this.root = this.leaves.length
|
||||||
|
? build(Array.from({ length: this.leaves.length }, (_, i) => i))
|
||||||
|
: null;
|
||||||
|
this.marks = new Uint32Array(mesh.positions.length / 3);
|
||||||
|
this.generation = 0;
|
||||||
|
}
|
||||||
|
bounds(node) {
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
let lo = Infinity,
|
||||||
|
hi = -Infinity;
|
||||||
|
if (node.faces)
|
||||||
|
for (const f of node.faces)
|
||||||
|
for (let j = 0; j < 3; j++) {
|
||||||
|
const v = this.mesh.positions[this.mesh.indices[f * 3 + j] * 3 + k];
|
||||||
|
lo = Math.min(lo, v);
|
||||||
|
hi = Math.max(hi, v);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
lo = Math.min(node.left.min[k], node.right.min[k]);
|
||||||
|
hi = Math.max(node.left.max[k], node.right.max[k]);
|
||||||
|
}
|
||||||
|
node.min[k] = lo;
|
||||||
|
node.max[k] = hi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refit(faces) {
|
||||||
|
if (!this.root) return;
|
||||||
|
const dirty = new Set(faces.map((f) => this.leaves[f]));
|
||||||
|
for (const leaf of dirty) this.bounds(leaf);
|
||||||
|
let parents = new Set([...dirty].map((n) => n.parent).filter(Boolean));
|
||||||
|
while (parents.size) {
|
||||||
|
const next = new Set();
|
||||||
|
for (const n of parents) {
|
||||||
|
this.bounds(n);
|
||||||
|
if (n.parent) next.add(n.parent);
|
||||||
|
}
|
||||||
|
parents = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raycast(origin, direction) {
|
||||||
|
if (!this.root) return null;
|
||||||
|
let best = null;
|
||||||
|
const entry = (n) => {
|
||||||
|
let near = 0,
|
||||||
|
far = best?.t ?? Infinity;
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
if (Math.abs(direction[k]) < 1e-12) {
|
||||||
|
if (origin[k] < n.min[k] || origin[k] > n.max[k]) return Infinity;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const a = (n.min[k] - origin[k]) / direction[k],
|
||||||
|
b = (n.max[k] - origin[k]) / direction[k];
|
||||||
|
near = Math.max(near, Math.min(a, b));
|
||||||
|
far = Math.min(far, Math.max(a, b));
|
||||||
|
if (near > far) return Infinity;
|
||||||
|
}
|
||||||
|
return near;
|
||||||
|
};
|
||||||
|
const visit = (n) => {
|
||||||
|
if (entry(n) === Infinity) return;
|
||||||
|
if (n.faces) {
|
||||||
|
for (const f of n.faces) {
|
||||||
|
const hit = triangleHit(this.mesh, f, origin, direction);
|
||||||
|
if (hit && (!best || hit.t < best.t)) best = hit;
|
||||||
|
}
|
||||||
|
} else if (entry(n.left) < entry(n.right)) {
|
||||||
|
visit(n.left);
|
||||||
|
visit(n.right);
|
||||||
|
} else {
|
||||||
|
visit(n.right);
|
||||||
|
visit(n.left);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(this.root);
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
within(center, radius) {
|
||||||
|
if (!this.root) return [];
|
||||||
|
const result = [],
|
||||||
|
gen = ++this.generation,
|
||||||
|
r2 = radius * radius;
|
||||||
|
const visit = (n) => {
|
||||||
|
let d = 0;
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
const e = Math.max(n.min[k] - center[k], 0, center[k] - n.max[k]);
|
||||||
|
d += e * e;
|
||||||
|
}
|
||||||
|
if (d > r2) return;
|
||||||
|
if (n.faces)
|
||||||
|
for (const f of n.faces)
|
||||||
|
for (let j = 0; j < 3; j++) {
|
||||||
|
const v = this.mesh.indices[f * 3 + j];
|
||||||
|
if (this.marks[v] === gen) continue;
|
||||||
|
this.marks[v] = gen;
|
||||||
|
const p = this.mesh.positions;
|
||||||
|
const d2 =
|
||||||
|
(p[v * 3] - center[0]) ** 2 +
|
||||||
|
(p[v * 3 + 1] - center[1]) ** 2 +
|
||||||
|
(p[v * 3 + 2] - center[2]) ** 2;
|
||||||
|
if (d2 < r2) result.push(v);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
visit(n.left);
|
||||||
|
visit(n.right);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(this.root);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { buildMesh, sphere } from "./mesh.js";
|
||||||
|
import { BVH } from "./bvh.js";
|
||||||
|
import { Sculpt } from "./engine.js";
|
||||||
|
import {
|
||||||
|
ensureDocument,
|
||||||
|
snapshot,
|
||||||
|
hydrate,
|
||||||
|
splitPiece,
|
||||||
|
combinePieces,
|
||||||
|
saveDocument,
|
||||||
|
loadDocument,
|
||||||
|
} from "./document.js";
|
||||||
|
|
||||||
|
test("document v2 roundtrip owns canonical arrays and supports empty documents", () => {
|
||||||
|
const mesh = ensureDocument(sphere(4));
|
||||||
|
mesh.masks[3] = 0.75;
|
||||||
|
const copy = loadDocument(saveDocument(mesh));
|
||||||
|
assert.deepEqual(snapshot(copy), snapshot(mesh));
|
||||||
|
copy.positions[0] = 7;
|
||||||
|
assert.notEqual(copy.positions[0], mesh.positions[0]);
|
||||||
|
const empty = buildMesh([], [], 0);
|
||||||
|
empty.pieces = [];
|
||||||
|
assert.equal(loadDocument(saveDocument(empty)).positions.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("piece splitting localizes indices and combination restores contiguous ranges", () => {
|
||||||
|
const a = ensureDocument(
|
||||||
|
buildMesh([0, 0, 0, 1, 0, 0, 0, 1, 0], [0, 1, 2], 0),
|
||||||
|
);
|
||||||
|
a.pieces[0].id = "a";
|
||||||
|
const b = hydrate(snapshot(a));
|
||||||
|
b.pieces[0].id = "b";
|
||||||
|
const combined = combinePieces([
|
||||||
|
{ mesh: a, piece: a.pieces[0] },
|
||||||
|
{ mesh: b, piece: b.pieces[0] },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
combined.pieces.map((p) => [p.vertexStart, p.indexStart]),
|
||||||
|
[
|
||||||
|
[0, 0],
|
||||||
|
[3, 3],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
splitPiece(combined, combined.pieces[1]).indices,
|
||||||
|
new Uint32Array([0, 1, 2]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("combining a level 6 sphere avoids argument limits and preserves its level", () => {
|
||||||
|
const mesh = ensureDocument(sphere(6));
|
||||||
|
mesh.masks[40000] = 0.625;
|
||||||
|
const combined = combinePieces([{ mesh, piece: mesh.pieces[0] }]);
|
||||||
|
assert.equal(combined.indices.length, 245760);
|
||||||
|
assert.equal(combined.level, 6);
|
||||||
|
assert.deepEqual(snapshot(combined), snapshot(mesh));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("combination preflights aggregate limits and accepts no pieces", () => {
|
||||||
|
const mesh = ensureDocument(sphere(6));
|
||||||
|
const other = hydrate(snapshot(mesh));
|
||||||
|
other.pieces[0].id = "other";
|
||||||
|
assert.throws(
|
||||||
|
() => combinePieces([{ mesh }, { mesh: other }]),
|
||||||
|
/topology limits/,
|
||||||
|
);
|
||||||
|
const empty = combinePieces([]);
|
||||||
|
assert.equal(empty.positions.length, 0);
|
||||||
|
assert.deepEqual(empty.pieces, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hydrate rejects invalid ranges, cross-piece indices, and coordinates", () => {
|
||||||
|
const base = snapshot(
|
||||||
|
ensureDocument(buildMesh([0, 0, 0, 1, 0, 0, 0, 1, 0], [0, 1, 2], 0)),
|
||||||
|
);
|
||||||
|
assert.throws(() =>
|
||||||
|
hydrate({
|
||||||
|
...base,
|
||||||
|
positions: Float32Array.from([9, ...base.positions.slice(1)]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.throws(() =>
|
||||||
|
hydrate({ ...base, pieces: [{ ...base.pieces[0], vertexCount: 2 }] }),
|
||||||
|
);
|
||||||
|
const left = hydrate(base),
|
||||||
|
right = hydrate(base);
|
||||||
|
right.pieces[0].id = "other";
|
||||||
|
const two = combinePieces([
|
||||||
|
{ mesh: left, piece: left.pieces[0] },
|
||||||
|
{ mesh: right, piece: right.pieces[0] },
|
||||||
|
]);
|
||||||
|
const bad = snapshot(two);
|
||||||
|
bad.indices[3] = 0;
|
||||||
|
assert.throws(() => hydrate(bad), /outside/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("canonical no-op installs preserve redo and every live identity", () => {
|
||||||
|
const sculpt = new Sculpt(sphere(4));
|
||||||
|
sculpt.setColor([0.2, 0.4, 0.6]);
|
||||||
|
sculpt.restore();
|
||||||
|
const identities = {
|
||||||
|
mesh: sculpt.mesh,
|
||||||
|
bvh: sculpt.bvh,
|
||||||
|
dirty: sculpt.dirty,
|
||||||
|
undo: sculpt.undoStack,
|
||||||
|
redo: sculpt.redoStack,
|
||||||
|
};
|
||||||
|
const values = {
|
||||||
|
state: sculpt.state,
|
||||||
|
nextState: sculpt.nextState,
|
||||||
|
revision: sculpt.revision,
|
||||||
|
};
|
||||||
|
assert.equal(sculpt.install(hydrate(snapshot(sculpt.mesh))), false);
|
||||||
|
for (const [name, value] of Object.entries(identities))
|
||||||
|
assert.equal(
|
||||||
|
sculpt[
|
||||||
|
name === "undo" ? "undoStack" : name === "redo" ? "redoStack" : name
|
||||||
|
],
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
state: sculpt.state,
|
||||||
|
nextState: sculpt.nextState,
|
||||||
|
revision: sculpt.revision,
|
||||||
|
},
|
||||||
|
values,
|
||||||
|
);
|
||||||
|
assert.equal(sculpt.redoStack.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("structural install and restore failures are atomic", () => {
|
||||||
|
const sculpt = new Sculpt(sphere(4));
|
||||||
|
const changed = hydrate(snapshot(sculpt.mesh));
|
||||||
|
changed.positions[0] += 0.01;
|
||||||
|
const before = () => ({
|
||||||
|
mesh: sculpt.mesh,
|
||||||
|
bvh: sculpt.bvh,
|
||||||
|
dirty: sculpt.dirty,
|
||||||
|
undo: sculpt.undoStack,
|
||||||
|
redo: sculpt.redoStack,
|
||||||
|
state: sculpt.state,
|
||||||
|
nextState: sculpt.nextState,
|
||||||
|
revision: sculpt.revision,
|
||||||
|
});
|
||||||
|
const assertSame = (value) => {
|
||||||
|
assert.equal(sculpt.mesh, value.mesh);
|
||||||
|
assert.equal(sculpt.bvh, value.bvh);
|
||||||
|
assert.equal(sculpt.dirty, value.dirty);
|
||||||
|
assert.equal(sculpt.undoStack, value.undo);
|
||||||
|
assert.equal(sculpt.redoStack, value.redo);
|
||||||
|
assert.equal(sculpt.state, value.state);
|
||||||
|
assert.equal(sculpt.nextState, value.nextState);
|
||||||
|
assert.equal(sculpt.revision, value.revision);
|
||||||
|
};
|
||||||
|
const originalBounds = BVH.prototype.bounds;
|
||||||
|
BVH.prototype.bounds = () => {
|
||||||
|
throw new Error("injected BVH failure");
|
||||||
|
};
|
||||||
|
const installState = before();
|
||||||
|
try {
|
||||||
|
assert.throws(() => sculpt.install(changed), /injected BVH failure/);
|
||||||
|
} finally {
|
||||||
|
BVH.prototype.bounds = originalBounds;
|
||||||
|
}
|
||||||
|
assertSame(installState);
|
||||||
|
assert.equal(sculpt.install(changed), true);
|
||||||
|
sculpt.undoStack.at(-1).before.activeId = "missing";
|
||||||
|
const restoreState = before();
|
||||||
|
assert.throws(() => sculpt.restore(), /active piece/);
|
||||||
|
assertSame(restoreState);
|
||||||
|
});
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
import { sphere, updateNormals } from "./mesh.js";
|
||||||
|
import { BVH } from "./bvh.js";
|
||||||
|
import { point, sub, dot, clamp } from "./math.js";
|
||||||
|
import {
|
||||||
|
ensureDocument,
|
||||||
|
snapshot,
|
||||||
|
hydrate,
|
||||||
|
saveDocument,
|
||||||
|
loadDocument,
|
||||||
|
} from "./document.js";
|
||||||
|
|
||||||
|
export const pressureValue = (pressure, gain = 1, gamma = 1) =>
|
||||||
|
clamp(gain * clamp(pressure, 0, 1) ** gamma, 0, 1);
|
||||||
|
export const defaults = {
|
||||||
|
brush: "draw",
|
||||||
|
radius: 0.22,
|
||||||
|
strength: 0.45,
|
||||||
|
gain: 1,
|
||||||
|
gamma: 1,
|
||||||
|
symmetry: true,
|
||||||
|
invert: false,
|
||||||
|
pressureRadius: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistedJson = (value) =>
|
||||||
|
JSON.stringify(value, (_key, child) => {
|
||||||
|
if (!child || Array.isArray(child) || typeof child !== "object")
|
||||||
|
return child;
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.keys(child)
|
||||||
|
.sort()
|
||||||
|
.filter((key) => child[key] !== undefined)
|
||||||
|
.map((key) => [key, child[key]]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const canonicalEqual = (a, b) => {
|
||||||
|
if (
|
||||||
|
a.level !== b.level ||
|
||||||
|
a.positions.length !== b.positions.length ||
|
||||||
|
a.indices.length !== b.indices.length ||
|
||||||
|
a.masks.length !== b.masks.length ||
|
||||||
|
a.pieces.length !== b.pieces.length
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
for (const name of ["positions", "indices", "masks"])
|
||||||
|
for (let i = 0; i < a[name].length; i++)
|
||||||
|
if (a[name][i] !== b[name][i]) return false;
|
||||||
|
return persistedJson(a.pieces) === persistedJson(b.pieces);
|
||||||
|
};
|
||||||
|
const snapshotBytes = (value) =>
|
||||||
|
value.positions.byteLength +
|
||||||
|
value.indices.byteLength +
|
||||||
|
value.masks.byteLength +
|
||||||
|
8 +
|
||||||
|
new TextEncoder().encode(JSON.stringify(value.pieces)).byteLength;
|
||||||
|
const allDirty = (mesh) =>
|
||||||
|
new Set(
|
||||||
|
Array.from({ length: mesh.positions.length / 3 }, (_, vertex) => vertex),
|
||||||
|
);
|
||||||
|
|
||||||
|
export class Sculpt {
|
||||||
|
constructor(mesh = sphere(), historyCap = 16 * 1024 * 1024) {
|
||||||
|
this.mesh = ensureDocument(mesh);
|
||||||
|
this.bvh = new BVH(mesh);
|
||||||
|
this.undoStack = [];
|
||||||
|
this.redoStack = [];
|
||||||
|
this.historyCap = historyCap;
|
||||||
|
this.revision = 0;
|
||||||
|
this.state = 0;
|
||||||
|
this.nextState = 1;
|
||||||
|
this.activeId = this.mesh.pieces[0]?.id ?? null;
|
||||||
|
this.dirty = new Set();
|
||||||
|
this.markAll();
|
||||||
|
this.stroke = null;
|
||||||
|
}
|
||||||
|
markAll() {
|
||||||
|
for (let v = 0; v < this.mesh.positions.length / 3; v++) this.dirty.add(v);
|
||||||
|
this.revision++;
|
||||||
|
}
|
||||||
|
get historyBytes() {
|
||||||
|
return [...this.undoStack, ...this.redoStack].reduce(
|
||||||
|
(sum, e) => sum + (e.bytes ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
commit(entry) {
|
||||||
|
entry.beforeState = this.state;
|
||||||
|
entry.afterState = this.nextState++;
|
||||||
|
this.state = entry.afterState;
|
||||||
|
this.redoStack = [];
|
||||||
|
this.undoStack.push(entry);
|
||||||
|
while (this.undoStack.length > 50 || this.historyBytes > this.historyCap)
|
||||||
|
this.undoStack.shift();
|
||||||
|
}
|
||||||
|
begin(settings) {
|
||||||
|
if (this.stroke) throw new Error("A stroke is already active.");
|
||||||
|
const requested = settings?.activeId;
|
||||||
|
if (requested && this.mesh.pieces.some((p) => p.id === requested))
|
||||||
|
this.activeId = requested;
|
||||||
|
const piece = this.mesh.pieces.find((p) => p.id === this.activeId);
|
||||||
|
this.stroke = {
|
||||||
|
settings: { ...defaults, ...settings },
|
||||||
|
piece,
|
||||||
|
before: this.mesh.positions.slice(),
|
||||||
|
beforeMasks: this.mesh.masks.slice(),
|
||||||
|
touched: new Set(),
|
||||||
|
grab: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
changed(ids) {
|
||||||
|
if (!ids.length) return;
|
||||||
|
const { faces, vertices } = updateNormals(this.mesh, ids);
|
||||||
|
this.bvh.refit(faces);
|
||||||
|
for (const v of vertices) this.dirty.add(v);
|
||||||
|
this.revision++;
|
||||||
|
}
|
||||||
|
dab(hit, rawPressure = 1, drag = [0, 0, 0]) {
|
||||||
|
if (!this.stroke || !hit) return;
|
||||||
|
const { settings: s, piece } = this.stroke;
|
||||||
|
if (
|
||||||
|
!piece ||
|
||||||
|
(hit.face != null &&
|
||||||
|
(hit.face * 3 < piece.indexStart ||
|
||||||
|
hit.face * 3 >= piece.indexStart + piece.indexCount))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const pressure = pressureValue(rawPressure, s.gain, s.gamma);
|
||||||
|
if (pressure === 0) return;
|
||||||
|
const radius = s.radius * (s.pressureRadius ? 0.25 + 0.75 * pressure : 1);
|
||||||
|
const p = this.mesh.positions,
|
||||||
|
n = this.mesh.normals;
|
||||||
|
// Merge reflected influence instead of applying two sequential dabs at the seam.
|
||||||
|
let affected = this.stroke.grab;
|
||||||
|
if (!affected) {
|
||||||
|
affected = new Map();
|
||||||
|
for (const mirror of s.symmetry ? [1, -1] : [1]) {
|
||||||
|
const center = [
|
||||||
|
piece.symmetryX + (hit.position[0] - piece.symmetryX) * mirror,
|
||||||
|
hit.position[1],
|
||||||
|
hit.position[2],
|
||||||
|
];
|
||||||
|
const normal = [hit.normal[0] * mirror, hit.normal[1], hit.normal[2]];
|
||||||
|
for (const v of this.bvh.within(center, radius)) {
|
||||||
|
if (
|
||||||
|
v < piece.vertexStart ||
|
||||||
|
v >= piece.vertexStart + piece.vertexCount
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
if (dot(point(n, v), normal) < 0.05) continue;
|
||||||
|
const delta = sub(point(p, v), center),
|
||||||
|
weight = (1 - dot(delta, delta) / (radius * radius)) ** 2;
|
||||||
|
if (!affected.has(v)) affected.set(v, []);
|
||||||
|
affected.get(v).push({ weight, center, normal, mirror });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (s.brush === "grab") this.stroke.grab = affected;
|
||||||
|
}
|
||||||
|
const moved = [],
|
||||||
|
updates = [];
|
||||||
|
for (const [v, contributions] of affected) {
|
||||||
|
const position = point(p, v),
|
||||||
|
displacement = [0, 0, 0];
|
||||||
|
let total = 0,
|
||||||
|
envelope = 0;
|
||||||
|
for (const { weight, center, normal, mirror } of contributions) {
|
||||||
|
total += weight;
|
||||||
|
envelope = Math.max(envelope, weight);
|
||||||
|
let delta;
|
||||||
|
if (s.brush === "smooth") {
|
||||||
|
const neighbors = this.mesh.neighbors[v];
|
||||||
|
delta = [0, 1, 2].map((k) => {
|
||||||
|
let sum = 0;
|
||||||
|
for (const neighbor of neighbors) sum += p[neighbor * 3 + k];
|
||||||
|
return sum / neighbors.length - position[k];
|
||||||
|
});
|
||||||
|
} else if (s.brush === "flatten") {
|
||||||
|
const distance = dot(sub(position, center), normal);
|
||||||
|
delta = normal.map((x) => -x * distance);
|
||||||
|
} else if (s.brush === "grab") {
|
||||||
|
delta = drag.map((x, k) => x * (k === 0 ? mirror : 1));
|
||||||
|
} else {
|
||||||
|
const direction = s.brush === "inflate" ? point(n, v) : normal;
|
||||||
|
delta = direction.map((x) => x * radius * 0.12 * (s.invert ? -1 : 1));
|
||||||
|
}
|
||||||
|
for (let k = 0; k < 3; k++) displacement[k] += delta[k] * weight;
|
||||||
|
}
|
||||||
|
if (s.brush === "mask") {
|
||||||
|
const old = this.mesh.masks[v];
|
||||||
|
const direction = s.invert ? -1 : 1;
|
||||||
|
const next = clamp(
|
||||||
|
old + direction * s.strength * pressure * envelope,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
if (Math.fround(next) !== old) {
|
||||||
|
this.mesh.masks[v] = next;
|
||||||
|
this.stroke.touched.add(v);
|
||||||
|
this.dirty.add(v);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const base = s.brush === "grab" ? point(this.stroke.before, v) : position;
|
||||||
|
let next = base.map(
|
||||||
|
(x, k) =>
|
||||||
|
x +
|
||||||
|
(displacement[k] *
|
||||||
|
s.strength *
|
||||||
|
pressure *
|
||||||
|
envelope *
|
||||||
|
(1 - this.mesh.masks[v])) /
|
||||||
|
total,
|
||||||
|
);
|
||||||
|
if (s.symmetry && this.stroke.before[v * 3] === piece.symmetryX)
|
||||||
|
next[0] = piece.symmetryX;
|
||||||
|
// Bounded document coordinates keep camera and project validation meaningful.
|
||||||
|
next = next.map((x) => clamp(x, -8, 8));
|
||||||
|
if (next.some((x, k) => Math.fround(x) !== position[k])) {
|
||||||
|
moved.push(v);
|
||||||
|
updates.push(...next);
|
||||||
|
this.stroke.touched.add(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Jacobi update: smoothing reads only the pre-dab mesh, independent of iteration order.
|
||||||
|
for (let i = 0; i < moved.length; i++)
|
||||||
|
p.set(updates.slice(i * 3, i * 3 + 3), moved[i] * 3);
|
||||||
|
if (s.brush === "mask") {
|
||||||
|
if (this.stroke.touched.size) this.revision++;
|
||||||
|
} else this.changed(moved);
|
||||||
|
}
|
||||||
|
end(cancel = false) {
|
||||||
|
if (!this.stroke) return;
|
||||||
|
const { before, beforeMasks, touched, settings } = this.stroke;
|
||||||
|
this.stroke = null;
|
||||||
|
const maskStroke = settings.brush === "mask";
|
||||||
|
const ids = Uint32Array.from(
|
||||||
|
[...touched].filter((v) =>
|
||||||
|
maskStroke
|
||||||
|
? beforeMasks[v] !== this.mesh.masks[v]
|
||||||
|
: [0, 1, 2].some(
|
||||||
|
(k) => before[v * 3 + k] !== this.mesh.positions[v * 3 + k],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (cancel) {
|
||||||
|
if (maskStroke) {
|
||||||
|
for (const v of ids) {
|
||||||
|
this.mesh.masks[v] = beforeMasks[v];
|
||||||
|
this.dirty.add(v);
|
||||||
|
}
|
||||||
|
if (ids.length) this.revision++;
|
||||||
|
} else {
|
||||||
|
for (const v of ids)
|
||||||
|
this.mesh.positions.set(before.subarray(v * 3, v * 3 + 3), v * 3);
|
||||||
|
this.changed(Array.from(ids));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ids.length) return;
|
||||||
|
const width = maskStroke ? 1 : 3;
|
||||||
|
const entry = {
|
||||||
|
kind: maskStroke ? "mask" : "positions",
|
||||||
|
ids,
|
||||||
|
before: new Float32Array(ids.length * width),
|
||||||
|
after: new Float32Array(ids.length * width),
|
||||||
|
};
|
||||||
|
ids.forEach((v, i) => {
|
||||||
|
if (maskStroke) {
|
||||||
|
entry.before[i] = beforeMasks[v];
|
||||||
|
entry.after[i] = this.mesh.masks[v];
|
||||||
|
} else {
|
||||||
|
entry.before.set(before.subarray(v * 3, v * 3 + 3), i * 3);
|
||||||
|
entry.after.set(this.mesh.positions.subarray(v * 3, v * 3 + 3), i * 3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
entry.bytes =
|
||||||
|
entry.ids.byteLength + entry.before.byteLength + entry.after.byteLength;
|
||||||
|
this.commit(entry);
|
||||||
|
}
|
||||||
|
restore(redo = false) {
|
||||||
|
if (this.stroke) return false;
|
||||||
|
const source = redo ? this.redoStack : this.undoStack,
|
||||||
|
target = redo ? this.undoStack : this.redoStack,
|
||||||
|
entry = source[source.length - 1];
|
||||||
|
if (!entry) return false;
|
||||||
|
const expectedState = redo ? entry.beforeState : entry.afterState;
|
||||||
|
const nextState = redo ? entry.afterState : entry.beforeState;
|
||||||
|
if (
|
||||||
|
!Number.isInteger(expectedState) ||
|
||||||
|
!Number.isInteger(nextState) ||
|
||||||
|
this.state !== expectedState
|
||||||
|
)
|
||||||
|
throw new Error("Invalid history entry.");
|
||||||
|
let prepared = null;
|
||||||
|
if (entry.kind === "structural") {
|
||||||
|
const value = redo ? entry.after : entry.before;
|
||||||
|
const mesh = hydrate(value?.mesh);
|
||||||
|
if (
|
||||||
|
value.activeId !== null &&
|
||||||
|
!mesh.pieces.some((piece) => piece.id === value.activeId)
|
||||||
|
)
|
||||||
|
throw new Error("Invalid history active piece.");
|
||||||
|
prepared = {
|
||||||
|
mesh,
|
||||||
|
activeId: value.activeId,
|
||||||
|
bvh: new BVH(mesh),
|
||||||
|
dirty: allDirty(mesh),
|
||||||
|
};
|
||||||
|
} else if (entry.kind === "color") {
|
||||||
|
const piece = this.mesh.pieces.find((p) => p.id === entry.id);
|
||||||
|
const color = redo ? entry.after : entry.before;
|
||||||
|
if (
|
||||||
|
!piece ||
|
||||||
|
!Array.isArray(color) ||
|
||||||
|
color.length !== 3 ||
|
||||||
|
color.some((x) => !Number.isFinite(x) || x < 0 || x > 1)
|
||||||
|
)
|
||||||
|
throw new Error("Invalid history entry.");
|
||||||
|
piece.color = [...color];
|
||||||
|
for (
|
||||||
|
let v = piece.vertexStart;
|
||||||
|
v < piece.vertexStart + piece.vertexCount;
|
||||||
|
v++
|
||||||
|
)
|
||||||
|
this.dirty.add(v);
|
||||||
|
this.revision++;
|
||||||
|
} else {
|
||||||
|
const values = redo ? entry.after : entry.before;
|
||||||
|
const width =
|
||||||
|
entry.kind === "mask" ? 1 : entry.kind === "positions" ? 3 : 0;
|
||||||
|
if (
|
||||||
|
!(entry.ids instanceof Uint32Array) ||
|
||||||
|
!(values instanceof Float32Array) ||
|
||||||
|
!width ||
|
||||||
|
values.length !== entry.ids.length * width ||
|
||||||
|
[...entry.ids].some((v) => v >= this.mesh.positions.length / 3) ||
|
||||||
|
[...values].some((x) => !Number.isFinite(x)) ||
|
||||||
|
(entry.kind === "mask" && [...values].some((x) => x < 0 || x > 1))
|
||||||
|
)
|
||||||
|
throw new Error("Invalid history entry.");
|
||||||
|
if (entry.kind === "mask") {
|
||||||
|
entry.ids.forEach((v, i) => {
|
||||||
|
this.mesh.masks[v] = values[i];
|
||||||
|
this.dirty.add(v);
|
||||||
|
});
|
||||||
|
this.revision++;
|
||||||
|
} else {
|
||||||
|
entry.ids.forEach((v, i) =>
|
||||||
|
this.mesh.positions.set(values.subarray(i * 3, i * 3 + 3), v * 3),
|
||||||
|
);
|
||||||
|
this.changed(Array.from(entry.ids));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (prepared) {
|
||||||
|
this.mesh = prepared.mesh;
|
||||||
|
this.activeId = prepared.activeId;
|
||||||
|
this.bvh = prepared.bvh;
|
||||||
|
this.dirty = prepared.dirty;
|
||||||
|
this.revision++;
|
||||||
|
}
|
||||||
|
source.pop();
|
||||||
|
target.push(entry);
|
||||||
|
this.state = nextState;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
install(candidateMesh, selectedId = this.activeId) {
|
||||||
|
if (this.stroke)
|
||||||
|
throw new Error("Cannot install topology during a stroke.");
|
||||||
|
const next = snapshot(candidateMesh),
|
||||||
|
current = snapshot(this.mesh);
|
||||||
|
if (canonicalEqual(current, next)) return false;
|
||||||
|
const before = { mesh: current, activeId: this.activeId };
|
||||||
|
const activeId = next.pieces.some((p) => p.id === selectedId)
|
||||||
|
? selectedId
|
||||||
|
: (next.pieces[0]?.id ?? null);
|
||||||
|
const mesh = hydrate(next),
|
||||||
|
bvh = new BVH(mesh),
|
||||||
|
dirty = allDirty(mesh);
|
||||||
|
const entry = {
|
||||||
|
kind: "structural",
|
||||||
|
before,
|
||||||
|
after: { mesh: next, activeId },
|
||||||
|
bytes: snapshotBytes(before.mesh) + snapshotBytes(next),
|
||||||
|
};
|
||||||
|
this.mesh = mesh;
|
||||||
|
this.activeId = activeId;
|
||||||
|
this.bvh = bvh;
|
||||||
|
this.dirty = dirty;
|
||||||
|
this.revision++;
|
||||||
|
this.commit(entry);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
setColor(color) {
|
||||||
|
const piece = this.mesh.pieces.find((p) => p.id === this.activeId);
|
||||||
|
if (
|
||||||
|
!piece ||
|
||||||
|
!Array.isArray(color) ||
|
||||||
|
color.length !== 3 ||
|
||||||
|
color.some((x) => !Number.isFinite(x) || x < 0 || x > 1)
|
||||||
|
)
|
||||||
|
throw new Error("Invalid piece color.");
|
||||||
|
if (color.every((x, i) => x === piece.color[i])) return false;
|
||||||
|
const before = [...piece.color];
|
||||||
|
piece.color = [...color];
|
||||||
|
for (
|
||||||
|
let v = piece.vertexStart;
|
||||||
|
v < piece.vertexStart + piece.vertexCount;
|
||||||
|
v++
|
||||||
|
)
|
||||||
|
this.dirty.add(v);
|
||||||
|
this.revision++;
|
||||||
|
this.commit({
|
||||||
|
kind: "color",
|
||||||
|
id: piece.id,
|
||||||
|
before,
|
||||||
|
after: [...color],
|
||||||
|
bytes: 24,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
editMask(invert = false) {
|
||||||
|
const piece = this.mesh.pieces.find((p) => p.id === this.activeId);
|
||||||
|
if (!piece) return false;
|
||||||
|
const ids = [],
|
||||||
|
before = [],
|
||||||
|
after = [];
|
||||||
|
for (
|
||||||
|
let v = piece.vertexStart;
|
||||||
|
v < piece.vertexStart + piece.vertexCount;
|
||||||
|
v++
|
||||||
|
) {
|
||||||
|
const n = invert ? 1 - this.mesh.masks[v] : 0;
|
||||||
|
if (n !== this.mesh.masks[v]) {
|
||||||
|
ids.push(v);
|
||||||
|
before.push(this.mesh.masks[v]);
|
||||||
|
after.push(n);
|
||||||
|
this.mesh.masks[v] = n;
|
||||||
|
this.dirty.add(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ids.length) return false;
|
||||||
|
const entry = {
|
||||||
|
kind: "mask",
|
||||||
|
ids: Uint32Array.from(ids),
|
||||||
|
before: Float32Array.from(before),
|
||||||
|
after: Float32Array.from(after),
|
||||||
|
};
|
||||||
|
entry.bytes =
|
||||||
|
entry.ids.byteLength + entry.before.byteLength + entry.after.byteLength;
|
||||||
|
this.revision++;
|
||||||
|
this.commit(entry);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screen-space arc-length sampler. Call break() when the ray misses the surface.
|
||||||
|
export class StrokePath {
|
||||||
|
constructor(spacing, emit) {
|
||||||
|
this.spacing = spacing;
|
||||||
|
this.emit = emit;
|
||||||
|
this.break();
|
||||||
|
}
|
||||||
|
break() {
|
||||||
|
this.generation = (this.generation ?? 0) + 1;
|
||||||
|
this.last = null;
|
||||||
|
this.remaining = 0;
|
||||||
|
}
|
||||||
|
push(sample, budget = Infinity) {
|
||||||
|
const generation = this.generation;
|
||||||
|
if (!this.last) {
|
||||||
|
this.last = sample;
|
||||||
|
this.remaining = this.spacing;
|
||||||
|
this.emit(sample);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const a = this.last,
|
||||||
|
dx = sample.x - a.x,
|
||||||
|
dy = sample.y - a.y,
|
||||||
|
length = Math.hypot(dx, dy);
|
||||||
|
let distance = this.remaining;
|
||||||
|
let emitted = 0;
|
||||||
|
while (distance <= length && length > 0) {
|
||||||
|
const t = distance / length;
|
||||||
|
const current = {
|
||||||
|
x: a.x + dx * t,
|
||||||
|
y: a.y + dy * t,
|
||||||
|
pressure: a.pressure + (sample.pressure - a.pressure) * t,
|
||||||
|
};
|
||||||
|
this.emit(current);
|
||||||
|
if (this.generation !== generation) return true;
|
||||||
|
distance += this.spacing;
|
||||||
|
if (++emitted >= budget && distance <= length) {
|
||||||
|
this.last = current;
|
||||||
|
this.remaining = this.spacing;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.remaining = distance - length;
|
||||||
|
this.last = sample;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveProject = saveDocument;
|
||||||
|
export const loadProject = loadDocument;
|
||||||
|
export function exportOBJ(mesh) {
|
||||||
|
const lines = ["# Yawn Sculpt — local mesh export"];
|
||||||
|
for (let i = 0; i < mesh.positions.length; i += 3)
|
||||||
|
lines.push(
|
||||||
|
`v ${mesh.positions[i]} ${mesh.positions[i + 1]} ${mesh.positions[i + 2]}`,
|
||||||
|
);
|
||||||
|
const pieces = ensureDocument(mesh).pieces;
|
||||||
|
for (const piece of pieces) {
|
||||||
|
lines.push(`g ${piece.name.replace(/\s+/g, "_")}`);
|
||||||
|
for (
|
||||||
|
let i = piece.indexStart;
|
||||||
|
i < piece.indexStart + piece.indexCount;
|
||||||
|
i += 3
|
||||||
|
)
|
||||||
|
lines.push(
|
||||||
|
`f ${mesh.indices[i] + 1} ${mesh.indices[i + 1] + 1} ${mesh.indices[i + 2] + 1}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { sphere, updateNormals } from "./mesh.js";
|
||||||
|
import { triangleHit } from "./bvh.js";
|
||||||
|
import {
|
||||||
|
Sculpt,
|
||||||
|
pressureValue,
|
||||||
|
StrokePath,
|
||||||
|
saveProject,
|
||||||
|
loadProject,
|
||||||
|
exportOBJ,
|
||||||
|
} from "./engine.js";
|
||||||
|
import { point, dot, unit, clampZoom } from "./math.js";
|
||||||
|
|
||||||
|
const hit = { position: [0.19, 0.11, 0.97], normal: unit([0.19, 0.11, 0.97]) };
|
||||||
|
const near = (a, b, epsilon = 1e-6) =>
|
||||||
|
assert.ok(Math.abs(a - b) < epsilon, `${a} != ${b}`);
|
||||||
|
test("zoom limits use apparent scale, including portrait values outside landscape limits", () => {
|
||||||
|
const portrait = { width: 600, height: 1000 },
|
||||||
|
landscape = { width: 1000, height: 600 };
|
||||||
|
near(clampZoom(20, portrait), 20);
|
||||||
|
near(clampZoom(30, portrait), 14 / 0.6);
|
||||||
|
near(clampZoom(0.1, portrait), 0.2 / 0.6);
|
||||||
|
near(clampZoom(20, landscape), 14);
|
||||||
|
near(clampZoom(0.1, landscape), 0.2);
|
||||||
|
});
|
||||||
|
test("welded outward sphere has two faces per edge and expected topology", () => {
|
||||||
|
const m = sphere(4),
|
||||||
|
edges = new Map();
|
||||||
|
assert.equal(m.positions.length / 3, 2562);
|
||||||
|
assert.equal(m.indices.length / 3, 5120);
|
||||||
|
for (let f = 0; f < m.indices.length / 3; f++) {
|
||||||
|
const ids = m.indices.subarray(f * 3, f * 3 + 3);
|
||||||
|
assert.ok(dot(point(m.faceNormals, f), point(m.positions, ids[0])) > 0);
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
const a = ids[k],
|
||||||
|
b = ids[(k + 1) % 3],
|
||||||
|
key = [Math.min(a, b), Math.max(a, b)].join(":");
|
||||||
|
edges.set(key, (edges.get(key) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok([...edges.values()].every((n) => n === 2));
|
||||||
|
});
|
||||||
|
test("BVH agrees with brute-force picking and sphere queries after edits and undo", () => {
|
||||||
|
const s = new Sculpt(sphere(4));
|
||||||
|
const check = () => {
|
||||||
|
for (const origin of [
|
||||||
|
[0.19, 0.11, 4],
|
||||||
|
[-0.31, 0.42, 4],
|
||||||
|
[1.4, 0, 4],
|
||||||
|
[0.01, -0.99, 4],
|
||||||
|
]) {
|
||||||
|
const direction = [0, 0, -1];
|
||||||
|
let expected = null;
|
||||||
|
for (let f = 0; f < s.mesh.indices.length / 3; f++) {
|
||||||
|
const h = triangleHit(s.mesh, f, origin, direction);
|
||||||
|
if (h && (!expected || h.t < expected.t)) expected = h;
|
||||||
|
}
|
||||||
|
const actual = s.bvh.raycast(origin, direction);
|
||||||
|
assert.equal(!!actual, !!expected);
|
||||||
|
if (actual) near(actual.t, expected.t);
|
||||||
|
}
|
||||||
|
const expected = [];
|
||||||
|
for (let v = 0; v < s.mesh.positions.length / 3; v++)
|
||||||
|
if (
|
||||||
|
Math.hypot(
|
||||||
|
...point(s.mesh.positions, v).map((x, k) => x - hit.position[k]),
|
||||||
|
) < 0.37
|
||||||
|
)
|
||||||
|
expected.push(v);
|
||||||
|
assert.deepEqual(
|
||||||
|
s.bvh.within(hit.position, 0.37).sort((a, b) => a - b),
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
s.begin({ radius: 0.37 });
|
||||||
|
for (let i = 0; i < 12; i++) s.dab(hit, 0.7);
|
||||||
|
s.end();
|
||||||
|
check();
|
||||||
|
s.restore();
|
||||||
|
check();
|
||||||
|
});
|
||||||
|
test("local normals equal full recomputation including brush boundary", () => {
|
||||||
|
const s = new Sculpt(sphere(4));
|
||||||
|
s.begin({ radius: 0.29, symmetry: false });
|
||||||
|
s.dab(hit);
|
||||||
|
s.end();
|
||||||
|
const local = s.mesh.normals.slice();
|
||||||
|
updateNormals(s.mesh);
|
||||||
|
local.forEach((v, i) => near(v, s.mesh.normals[i]));
|
||||||
|
});
|
||||||
|
test("pressure zero is a no-op; nonlinear pressure has the independently expected displacement ratio", () => {
|
||||||
|
assert.equal(pressureValue(0, 2, 0.5), 0);
|
||||||
|
assert.equal(pressureValue(0.5, 1, 2), 0.25);
|
||||||
|
const a = new Sculpt(sphere(4)),
|
||||||
|
b = new Sculpt(sphere(4));
|
||||||
|
const before = a.mesh.positions.slice();
|
||||||
|
a.begin({ symmetry: false });
|
||||||
|
a.dab(hit, 0);
|
||||||
|
a.end();
|
||||||
|
assert.equal(a.undoStack.length, 0);
|
||||||
|
for (const [s, p] of [
|
||||||
|
[a, 0.5],
|
||||||
|
[b, 1],
|
||||||
|
]) {
|
||||||
|
s.begin({ symmetry: false, gamma: 2 });
|
||||||
|
s.dab(hit, p);
|
||||||
|
s.end();
|
||||||
|
}
|
||||||
|
let da = 0,
|
||||||
|
db = 0;
|
||||||
|
before.forEach((x, i) => {
|
||||||
|
da += Math.abs(a.mesh.positions[i] - x);
|
||||||
|
db += Math.abs(b.mesh.positions[i] - x);
|
||||||
|
});
|
||||||
|
near(da / db, 0.25, 1e-4);
|
||||||
|
});
|
||||||
|
test("symmetry does not double strength on seam", () => {
|
||||||
|
const a = new Sculpt(sphere(4)),
|
||||||
|
b = new Sculpt(sphere(4));
|
||||||
|
for (const [s, symmetry] of [
|
||||||
|
[a, false],
|
||||||
|
[b, true],
|
||||||
|
]) {
|
||||||
|
s.begin({ symmetry });
|
||||||
|
s.dab({ position: [0, 0, 1], normal: [0, 0, 1] });
|
||||||
|
s.end();
|
||||||
|
}
|
||||||
|
assert.deepEqual(a.mesh.positions, b.mesh.positions);
|
||||||
|
});
|
||||||
|
test("grab uses stroke origin, not repeated accumulated deltas; smooth is order independent", () => {
|
||||||
|
const a = new Sculpt(sphere(4)),
|
||||||
|
b = new Sculpt(sphere(4));
|
||||||
|
a.begin({ brush: "grab", symmetry: false });
|
||||||
|
b.begin({ brush: "grab", symmetry: false });
|
||||||
|
a.dab(hit, 1, [0.1, 0.04, 0]);
|
||||||
|
a.dab(hit, 1, [0.2, 0.08, 0]);
|
||||||
|
b.dab(hit, 1, [0.2, 0.08, 0]);
|
||||||
|
assert.deepEqual(a.mesh.positions, b.mesh.positions);
|
||||||
|
a.end();
|
||||||
|
b.end();
|
||||||
|
const original = b.bvh.within.bind(b.bvh);
|
||||||
|
b.bvh.within = (...args) => original(...args).reverse();
|
||||||
|
for (const s of [a, b]) {
|
||||||
|
s.begin({ brush: "smooth", symmetry: false });
|
||||||
|
s.dab(hit);
|
||||||
|
s.end();
|
||||||
|
}
|
||||||
|
assert.deepEqual(a.mesh.positions, b.mesh.positions);
|
||||||
|
});
|
||||||
|
test("exact history, cancel, redo truncation and byte cap", () => {
|
||||||
|
const s = new Sculpt(sphere(4), 12000),
|
||||||
|
original = s.mesh.positions.slice();
|
||||||
|
s.begin({});
|
||||||
|
s.dab(hit);
|
||||||
|
s.end();
|
||||||
|
const after = s.mesh.positions.slice();
|
||||||
|
assert.ok(s.historyBytes > 0);
|
||||||
|
s.restore();
|
||||||
|
assert.deepEqual(s.mesh.positions, original);
|
||||||
|
s.restore(true);
|
||||||
|
assert.deepEqual(s.mesh.positions, after);
|
||||||
|
s.begin({});
|
||||||
|
s.dab(hit);
|
||||||
|
s.end(true);
|
||||||
|
assert.deepEqual(s.mesh.positions, after);
|
||||||
|
assert.equal(s.undoStack.length, 1);
|
||||||
|
s.restore();
|
||||||
|
s.begin({ brush: "inflate" });
|
||||||
|
s.dab(hit);
|
||||||
|
s.end();
|
||||||
|
assert.equal(s.redoStack.length, 0);
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
s.begin({});
|
||||||
|
s.dab(hit);
|
||||||
|
s.end();
|
||||||
|
}
|
||||||
|
assert.ok(s.historyBytes <= 12000);
|
||||||
|
assert.ok(s.undoStack.length <= 50);
|
||||||
|
});
|
||||||
|
test("arc-length and interpolated pressure do not depend on event frequency", () => {
|
||||||
|
const run = (step) => {
|
||||||
|
const samples = [];
|
||||||
|
const path = new StrokePath(7, (p) => samples.push(p));
|
||||||
|
for (let x = 0; x <= 100; x += step)
|
||||||
|
path.push({ x, y: 0, pressure: x / 100 });
|
||||||
|
return samples;
|
||||||
|
};
|
||||||
|
const a = run(1),
|
||||||
|
b = run(25);
|
||||||
|
assert.equal(a.length, 15);
|
||||||
|
assert.equal(a.length, b.length);
|
||||||
|
a.forEach((p, i) => {
|
||||||
|
near(p.x, b[i].x);
|
||||||
|
near(p.pressure, b[i].pressure);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test("project roundtrip, malformed project rejection, OBJ indices", () => {
|
||||||
|
const s = new Sculpt(sphere(4));
|
||||||
|
s.begin({});
|
||||||
|
s.dab(hit);
|
||||||
|
s.end();
|
||||||
|
const mesh = loadProject(saveProject(s.mesh));
|
||||||
|
assert.deepEqual(mesh.positions, s.mesh.positions);
|
||||||
|
for (const data of [
|
||||||
|
{ version: 2 },
|
||||||
|
{ version: 1, level: 99, positions: [] },
|
||||||
|
{ version: 1, level: 4, positions: ["x"] },
|
||||||
|
])
|
||||||
|
assert.throws(() => loadProject(JSON.stringify(data)));
|
||||||
|
const obj = exportOBJ(mesh).split("\n");
|
||||||
|
assert.equal(obj.filter((l) => l.startsWith("v ")).length, 2562);
|
||||||
|
assert.equal(obj.filter((l) => l.startsWith("f ")).length, 5120);
|
||||||
|
});
|
||||||
|
test("directional symmetry and Grab strength preserve the seam and overlap envelope", () => {
|
||||||
|
const center = { position: [0, 0, 1], normal: [0, 0, 1] };
|
||||||
|
const a = new Sculpt(sphere(4)),
|
||||||
|
b = new Sculpt(sphere(4));
|
||||||
|
const before = a.mesh.positions.slice();
|
||||||
|
a.begin({ brush: "grab", symmetry: true, strength: 1 });
|
||||||
|
a.dab(center, 1, [0.2, 0, 0]);
|
||||||
|
a.end();
|
||||||
|
assert.deepEqual(a.mesh.positions, before);
|
||||||
|
for (const [s, symmetry] of [
|
||||||
|
[a, true],
|
||||||
|
[b, false],
|
||||||
|
]) {
|
||||||
|
s.begin({ brush: "grab", symmetry, strength: 0.5 });
|
||||||
|
s.dab(center, 1, [0, 0.12, 0.08]);
|
||||||
|
s.end();
|
||||||
|
}
|
||||||
|
assert.deepEqual(a.mesh.positions, b.mesh.positions);
|
||||||
|
const s = new Sculpt(sphere(4));
|
||||||
|
s.dirty.clear();
|
||||||
|
const revision = s.revision;
|
||||||
|
s.begin({ brush: "grab", strength: 0 });
|
||||||
|
s.dab(hit, 1, [0.3, 0.1, 0.2]);
|
||||||
|
s.end();
|
||||||
|
assert.equal(s.revision, revision);
|
||||||
|
assert.equal(s.dirty.size, 0);
|
||||||
|
assert.equal(s.undoStack.length, 0);
|
||||||
|
const original = s.mesh.positions.slice(),
|
||||||
|
radius = 0.4,
|
||||||
|
drag = [0.13, 0.09, 0.05];
|
||||||
|
s.begin({ brush: "grab", strength: 0.5, symmetry: true, radius });
|
||||||
|
s.dab(hit, 0.8, drag);
|
||||||
|
s.end();
|
||||||
|
let overlaps = 0;
|
||||||
|
for (let v = 0; v < original.length / 3; v++) {
|
||||||
|
const p = point(original, v),
|
||||||
|
normal = point(sphereReference.normals, v);
|
||||||
|
const weights = [1, -1].map((m) => {
|
||||||
|
const c = [hit.position[0] * m, ...hit.position.slice(1)],
|
||||||
|
n = [hit.normal[0] * m, ...hit.normal.slice(1)];
|
||||||
|
const d2 = p.reduce((sum, x, k) => sum + (x - c[k]) ** 2, 0);
|
||||||
|
return d2 < radius ** 2 && dot(normal, n) >= 0.05
|
||||||
|
? (1 - d2 / radius ** 2) ** 2
|
||||||
|
: 0;
|
||||||
|
});
|
||||||
|
if (weights.every((w) => w > 0) && Math.abs(weights[0] - weights[1]) > 1e-4)
|
||||||
|
overlaps++;
|
||||||
|
const total = weights[0] + weights[1];
|
||||||
|
if (!total) continue;
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
const blend = k === 0 ? weights[0] - weights[1] : total;
|
||||||
|
const expected =
|
||||||
|
k === 0 && p[0] === 0
|
||||||
|
? 0
|
||||||
|
: p[k] + (drag[k] * 0.5 * 0.8 * Math.max(...weights) * blend) / total;
|
||||||
|
near(s.mesh.positions[v * 3 + k], expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(overlaps > 0);
|
||||||
|
});
|
||||||
|
const sphereReference = sphere(4);
|
||||||
|
test("breaking inside the sampler callback aborts interpolation and resets continuity", () => {
|
||||||
|
const samples = [];
|
||||||
|
let path;
|
||||||
|
path = new StrokePath(7, (p) => {
|
||||||
|
samples.push(Math.round(p.x));
|
||||||
|
if (p.x >= 14) path.break();
|
||||||
|
});
|
||||||
|
path.push({ x: 0, y: 0, pressure: 1 });
|
||||||
|
path.push({ x: 100, y: 0, pressure: 1 });
|
||||||
|
assert.deepEqual(samples, [0, 7, 14]);
|
||||||
|
assert.equal(path.last, null);
|
||||||
|
path.push({ x: 101, y: 0, pressure: 0.3 });
|
||||||
|
assert.deepEqual(samples, [0, 7, 14, 101]);
|
||||||
|
});
|
||||||
|
test("history count eviction retains exactly the latest fifty real commits", () => {
|
||||||
|
const s = new Sculpt(sphere(4), 64 * 1024 * 1024),
|
||||||
|
snapshots = [];
|
||||||
|
for (let i = 0; i < 55; i++) {
|
||||||
|
const previous = s.mesh.positions.slice();
|
||||||
|
const h = s.bvh.raycast([0.19, 0.11, 4], [0, 0, -1]);
|
||||||
|
s.begin({ brush: "grab", strength: 1, symmetry: false });
|
||||||
|
s.dab(h, 1, [0.001, 0.0005, 0]);
|
||||||
|
s.end();
|
||||||
|
assert.notDeepEqual(s.mesh.positions, previous);
|
||||||
|
snapshots.push(s.mesh.positions.slice());
|
||||||
|
assert.ok(s.historyBytes < 64 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
assert.equal(s.undoStack.length, 50);
|
||||||
|
for (let i = 0; i < 50; i++) assert.equal(s.restore(), true);
|
||||||
|
assert.deepEqual(s.mesh.positions, snapshots[4]);
|
||||||
|
assert.equal(s.restore(), false);
|
||||||
|
for (let i = 0; i < 50; i++) assert.equal(s.restore(true), true);
|
||||||
|
assert.deepEqual(s.mesh.positions, snapshots[54]);
|
||||||
|
});
|
||||||
|
test("bounded-resolution benchmark (CPU only, not a device guarantee)", () => {
|
||||||
|
for (const level of [4, 5, 6]) {
|
||||||
|
const s = new Sculpt(sphere(level));
|
||||||
|
s.dirty.clear();
|
||||||
|
const times = [];
|
||||||
|
s.begin({ radius: 0.22 });
|
||||||
|
for (let i = 0; i < 35; i++) {
|
||||||
|
const start = performance.now();
|
||||||
|
const h = s.bvh.raycast([0.19, 0.11, 4], [0, 0, -1]);
|
||||||
|
s.dab(h, 0.65);
|
||||||
|
times.push(performance.now() - start);
|
||||||
|
}
|
||||||
|
s.end();
|
||||||
|
times.sort((a, b) => a - b);
|
||||||
|
let lo = Infinity,
|
||||||
|
hi = -1;
|
||||||
|
for (const v of s.dirty) {
|
||||||
|
lo = Math.min(lo, v);
|
||||||
|
hi = Math.max(hi, v);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
level,
|
||||||
|
vertices: s.mesh.positions.length / 3,
|
||||||
|
p50ms: times[17].toFixed(2),
|
||||||
|
p95ms: times[33].toFixed(2),
|
||||||
|
uploadBytes: (hi - lo + 1) * 32,
|
||||||
|
historyBytes: s.historyBytes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width,initial-scale=1,viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
<meta name="theme-color" content="#20242b" />
|
||||||
|
<meta name="description" content="A quiet space to sculpt with your pen." />
|
||||||
|
<title>Sculpt — Yawn</title>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/sculpt/vendor/basecoat/basecoat.min.css"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="/assets/sculpt/styles.css" />
|
||||||
|
<script type="importmap">
|
||||||
|
{ "imports": { "@yawn/core": "/pkg/core.js" } }
|
||||||
|
</script>
|
||||||
|
<script type="module" src="/assets/sculpt/app.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="viewport" tabindex="0" aria-label="Sculpting canvas">
|
||||||
|
<canvas aria-label="Your sculpt"></canvas>
|
||||||
|
<div id="cursor" hidden aria-hidden="true"><i></i></div>
|
||||||
|
<div id="empty-clay" class="canvas-message" hidden>
|
||||||
|
<h1>A fresh canvas.</h1>
|
||||||
|
<p>Add a piece of clay to begin.</p>
|
||||||
|
<button id="add-empty" class="btn">Add clay</button>
|
||||||
|
</div>
|
||||||
|
<div id="clay-progress" class="canvas-message" role="status" hidden>
|
||||||
|
<h1 id="operation-label">Updating clay…</h1>
|
||||||
|
<p>Your other pieces stay unchanged.</p>
|
||||||
|
<button id="cancel-operation" class="btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<div id="loading" class="canvas-message" role="status">
|
||||||
|
<h1>Preparing your clay.</h1>
|
||||||
|
<p>A moment to get ready.</p>
|
||||||
|
</div>
|
||||||
|
<div id="error" class="canvas-message" role="alert" hidden>
|
||||||
|
<h1>Your sculpt is safe.</h1>
|
||||||
|
<p id="error-detail"></p>
|
||||||
|
<button id="retry" class="btn">Try again</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<nav class="tool-rail" aria-label="Sculpt tools">
|
||||||
|
<button
|
||||||
|
id="pieces-toggle"
|
||||||
|
class="btn icon-button launcher"
|
||||||
|
aria-label="Clay pieces"
|
||||||
|
title="Clay pieces"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="pieces-panel"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m12 3 9 5-9 5-9-5zm-9 9 9 5 9-5M3 16l9 5 9-5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="brushes" role="group" aria-label="Brush">
|
||||||
|
<button
|
||||||
|
class="btn icon-button"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-brush="draw"
|
||||||
|
aria-pressed="true"
|
||||||
|
aria-label="Draw"
|
||||||
|
title="Draw · 1"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m14 4 6 6M5 15 16 4a2.8 2.8 0 0 1 4 4L9 19l-5 1z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn icon-button"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-brush="inflate"
|
||||||
|
aria-pressed="false"
|
||||||
|
aria-label="Inflate"
|
||||||
|
title="Inflate · 2"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="8" />
|
||||||
|
<path d="M12 7v10M7 12h10" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn icon-button"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-brush="smooth"
|
||||||
|
aria-pressed="false"
|
||||||
|
aria-label="Smooth"
|
||||||
|
title="Smooth · 3"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M3 8c6-7 12 7 18 0M3 12c6-7 12 7 18 0M3 16c6-7 12 7 18 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn icon-button"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-brush="flatten"
|
||||||
|
aria-pressed="false"
|
||||||
|
aria-label="Flatten"
|
||||||
|
title="Flatten · 4"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m3 16 5-7h13l-5 7zM5 20h14M12 2v4m-2-2 2 2 2-2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn icon-button"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-brush="grab"
|
||||||
|
aria-pressed="false"
|
||||||
|
aria-label="Grab"
|
||||||
|
title="Grab · 5"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M8 12V5a2 2 0 0 1 4 0v7-8a2 2 0 0 1 4 0v8-5a2 2 0 0 1 4 0v9c0 4-3 6-7 6-3 0-5-2-6-4l-3-5c-1-2 2-3 3-1l1 1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn icon-button"
|
||||||
|
data-brush="mask"
|
||||||
|
aria-pressed="false"
|
||||||
|
aria-label="Mask"
|
||||||
|
title="Mask · 6"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4 5h16v8c0 5-8 8-8 8s-8-3-8-8zM12 5v16" />
|
||||||
|
<path d="M6 8h4M6 11h4M7 14h3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="rail-group">
|
||||||
|
<button
|
||||||
|
id="tools-toggle"
|
||||||
|
class="btn icon-button launcher"
|
||||||
|
data-variant="ghost"
|
||||||
|
aria-label="Brush settings"
|
||||||
|
title="Brush settings"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="tools-panel"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4 7h16M4 17h16M8 4v6M16 14v6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="undo"
|
||||||
|
class="btn icon-button"
|
||||||
|
data-variant="ghost"
|
||||||
|
aria-label="Undo"
|
||||||
|
title="Undo"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m8 4-5 5 5 5M3 9h10a7 7 0 0 1 0 14" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="redo"
|
||||||
|
class="btn icon-button"
|
||||||
|
data-variant="ghost"
|
||||||
|
aria-label="Redo"
|
||||||
|
title="Redo"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m16 4 5 5-5 5M21 9H11a7 7 0 0 0 0 14" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="settings-toggle"
|
||||||
|
class="btn icon-button launcher"
|
||||||
|
data-variant="ghost"
|
||||||
|
aria-label="Settings and projects"
|
||||||
|
title="Settings and projects"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="inspector"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M9 3h6l1 3 3 1 2 5-2 5-3 1-1 3H9l-1-3-3-1-2-5 2-5 3-1z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
<div id="view-navigation" aria-label="View navigation">
|
||||||
|
<div
|
||||||
|
id="orbit-gizmo"
|
||||||
|
data-nav="orbit"
|
||||||
|
tabindex="0"
|
||||||
|
role="group"
|
||||||
|
aria-label="Orbit view. Drag to rotate, or use arrow keys."
|
||||||
|
title="Drag to orbit · click an axis to align"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 104 104" aria-hidden="true">
|
||||||
|
<line class="axis-line" x1="52" y1="52" />
|
||||||
|
<line class="axis-line" x1="52" y1="52" />
|
||||||
|
<line class="axis-line" x1="52" y1="52" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="view-buttons">
|
||||||
|
<button
|
||||||
|
class="btn icon-button"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-nav="pan"
|
||||||
|
aria-label="Pan view. Drag or use arrow keys."
|
||||||
|
title="Drag to pan"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M12 3v18M3 12h18m-12-6 3-3 3 3m-6 12 3 3 3-3M6 9l-3 3 3 3m12-6 3 3-3 3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn icon-button"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-nav="zoom"
|
||||||
|
aria-label="Zoom view. Drag up or down, or use arrow keys."
|
||||||
|
title="Drag to zoom"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="10" cy="10" r="6" />
|
||||||
|
<path d="m15 15 6 6M10 7v6M7 10h6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="frame"
|
||||||
|
class="btn icon-button"
|
||||||
|
data-variant="ghost"
|
||||||
|
aria-label="Center view"
|
||||||
|
title="Center view · F"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M8 3H3v5m13-5h5v5M3 16v5h5m8 0h5v-5" />
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section
|
||||||
|
id="tools-panel"
|
||||||
|
class="panel tools-panel"
|
||||||
|
aria-label="Brush settings"
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2 id="brush-title">Draw</h2>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-size="icon-sm"
|
||||||
|
data-close
|
||||||
|
aria-label="Close brushes"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="control-section">
|
||||||
|
<label for="radius"
|
||||||
|
>Size <output id="radius-value" for="radius"></output></label
|
||||||
|
><input
|
||||||
|
class="input"
|
||||||
|
id="radius"
|
||||||
|
type="range"
|
||||||
|
min="0.04"
|
||||||
|
max="0.6"
|
||||||
|
step="0.01"
|
||||||
|
value="0.22"
|
||||||
|
/>
|
||||||
|
<label for="strength"
|
||||||
|
>Strength <output id="strength-value" for="strength"></output></label
|
||||||
|
><input
|
||||||
|
class="input"
|
||||||
|
id="strength"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
value="0.45"
|
||||||
|
/>
|
||||||
|
<label class="toggle-row" for="invert"
|
||||||
|
><span id="invert-label">Subtract</span
|
||||||
|
><input class="input" role="switch" id="invert" type="checkbox"
|
||||||
|
/></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="pieces-panel"
|
||||||
|
class="panel pieces-panel"
|
||||||
|
aria-label="Clay pieces"
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>Clay</h2>
|
||||||
|
<button class="btn" data-close aria-label="Close clay pieces">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="piece-list" aria-label="Select a piece"></div>
|
||||||
|
<div class="piece-material">
|
||||||
|
<label for="piece-color">Color</label
|
||||||
|
><input id="piece-color" type="color" aria-label="Piece color" />
|
||||||
|
<button
|
||||||
|
id="delete-piece"
|
||||||
|
class="btn"
|
||||||
|
aria-label="Delete selected piece"
|
||||||
|
title="Delete selected piece"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="preset-grid" aria-label="Add clay preset">
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
data-preset="sphere"
|
||||||
|
aria-label="Add sphere"
|
||||||
|
title="Add sphere"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<ellipse cx="12" cy="12" rx="4" ry="9" />
|
||||||
|
<path d="M3 12h18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
data-preset="cube"
|
||||||
|
aria-label="Add cube"
|
||||||
|
title="Add cube"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m12 2 9 5v10l-9 5-9-5V7zm0 10 9-5M12 12 3 7m9 5v10" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
data-preset="cylinder"
|
||||||
|
aria-label="Add cylinder"
|
||||||
|
title="Add cylinder"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<ellipse cx="12" cy="5" rx="8" ry="3" />
|
||||||
|
<path d="M4 5v14c0 4 16 4 16 0V5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
data-preset="cone"
|
||||||
|
aria-label="Add cone"
|
||||||
|
title="Add cone"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m4 18 8-16 8 16" />
|
||||||
|
<ellipse cx="12" cy="18" rx="8" ry="3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
data-preset="torus"
|
||||||
|
aria-label="Add torus"
|
||||||
|
title="Add torus"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<ellipse cx="12" cy="12" rx="10" ry="7" />
|
||||||
|
<ellipse cx="12" cy="10" rx="4" ry="2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<details class="disclosure">
|
||||||
|
<summary>Detail & mask</summary>
|
||||||
|
<div class="piece-actions">
|
||||||
|
<button id="subdivide" class="btn">Subdivide</button
|
||||||
|
><button id="decimate" class="btn">Decimate</button>
|
||||||
|
<button id="clear-mask" class="btn">Clear mask</button
|
||||||
|
><button id="invert-mask" class="btn">Invert mask</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">
|
||||||
|
Subdivide adds resolution. Decimate reduces detail and preserves
|
||||||
|
masked areas.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<span id="modified" class="modified-label" hidden>Unsaved</span>
|
||||||
|
<aside
|
||||||
|
id="inspector"
|
||||||
|
class="panel settings-panel"
|
||||||
|
aria-label="Settings and projects"
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-size="icon-sm"
|
||||||
|
data-close
|
||||||
|
aria-label="Close settings"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p id="document-name" class="project-name">Untitled sphere</p>
|
||||||
|
<div class="project-actions">
|
||||||
|
<button id="save" class="btn">Download project</button
|
||||||
|
><button class="btn" data-variant="outline" id="open">Open</button
|
||||||
|
><button class="btn" data-variant="outline" id="new">New</button
|
||||||
|
><button class="btn" data-variant="outline" id="export">Export</button>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-row palm-row" for="palm-rejection"
|
||||||
|
><span>Palm rejection<small>Ignore touch on the canvas</small></span
|
||||||
|
><input
|
||||||
|
class="input"
|
||||||
|
role="switch"
|
||||||
|
id="palm-rejection"
|
||||||
|
type="checkbox"
|
||||||
|
checked
|
||||||
|
/></label>
|
||||||
|
<details id="pen-settings">
|
||||||
|
<summary>Pen response</summary>
|
||||||
|
<div class="disclosure-content">
|
||||||
|
<label for="gain"
|
||||||
|
>Sensitivity <output id="gain-value" for="gain"></output></label
|
||||||
|
><input
|
||||||
|
class="input"
|
||||||
|
id="gain"
|
||||||
|
type="range"
|
||||||
|
min="0.25"
|
||||||
|
max="2"
|
||||||
|
step="0.05"
|
||||||
|
value="1"
|
||||||
|
/>
|
||||||
|
<label for="gamma"
|
||||||
|
>Response <output id="gamma-value" for="gamma"></output></label
|
||||||
|
><input
|
||||||
|
class="input"
|
||||||
|
id="gamma"
|
||||||
|
type="range"
|
||||||
|
min="0.25"
|
||||||
|
max="3"
|
||||||
|
step="0.05"
|
||||||
|
value="1"
|
||||||
|
/>
|
||||||
|
<div class="pressure-meter" aria-label="Pen pressure">
|
||||||
|
<div id="pressure-fill"></div>
|
||||||
|
</div>
|
||||||
|
<span id="pen-state" class="hint">Try your pen on the canvas</span>
|
||||||
|
<svg
|
||||||
|
id="curve"
|
||||||
|
viewBox="0 0 200 54"
|
||||||
|
role="img"
|
||||||
|
aria-label="Pressure response"
|
||||||
|
>
|
||||||
|
<path class="curve-grid" d="M0 53H200M0 27H200M100 0V54" />
|
||||||
|
<path id="curve-line" d="M0 53L200 1" />
|
||||||
|
</svg>
|
||||||
|
<label class="toggle-row" for="pressureRadius"
|
||||||
|
><span>Pressure changes size</span
|
||||||
|
><input
|
||||||
|
class="input"
|
||||||
|
role="switch"
|
||||||
|
id="pressureRadius"
|
||||||
|
type="checkbox"
|
||||||
|
/></label>
|
||||||
|
<button id="reset-pressure" class="btn wide" data-variant="outline">
|
||||||
|
Reset pen settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>Sculpt & display</summary>
|
||||||
|
<div class="disclosure-content">
|
||||||
|
<label class="toggle-row" for="symmetry"
|
||||||
|
><span>Mirror left & right</span
|
||||||
|
><input
|
||||||
|
class="input"
|
||||||
|
role="switch"
|
||||||
|
id="symmetry"
|
||||||
|
type="checkbox"
|
||||||
|
checked
|
||||||
|
/></label>
|
||||||
|
<label for="quality">Display</label
|
||||||
|
><select class="select" id="quality">
|
||||||
|
<option value="economy">Battery saver</option>
|
||||||
|
<option value="balanced" selected>Balanced</option>
|
||||||
|
<option value="detail">Sharper</option>
|
||||||
|
</select>
|
||||||
|
<div class="swatches" role="group" aria-label="Clay color">
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-color="0.5,0.3,0.2"
|
||||||
|
style="--swatch: #bb8d70"
|
||||||
|
aria-label="Terracotta clay"
|
||||||
|
aria-pressed="true"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-color="0.42,0.48,0.48"
|
||||||
|
style="--swatch: #aab9b7"
|
||||||
|
aria-label="Stone clay"
|
||||||
|
aria-pressed="false"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-color="0.21,0.32,0.43"
|
||||||
|
style="--swatch: #718fa9"
|
||||||
|
aria-label="Slate clay"
|
||||||
|
aria-pressed="false"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<div class="settings-footer">
|
||||||
|
<button id="fullscreen" class="btn" data-variant="ghost" hidden>
|
||||||
|
Full screen</button
|
||||||
|
><button id="help" class="btn" data-variant="ghost">Help</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div
|
||||||
|
id="status"
|
||||||
|
class="status-toast"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
></div>
|
||||||
|
<input
|
||||||
|
id="file"
|
||||||
|
type="file"
|
||||||
|
accept=".yawn-sculpt,application/json"
|
||||||
|
hidden
|
||||||
|
/>
|
||||||
|
<dialog id="new-dialog" class="dialog" aria-labelledby="new-title">
|
||||||
|
<form method="dialog">
|
||||||
|
<header>
|
||||||
|
<h2 id="new-title">Fresh clay</h2>
|
||||||
|
<p id="new-warning">Choose how much detail you want.</p>
|
||||||
|
</header>
|
||||||
|
<section>
|
||||||
|
<label for="density">Detail</label
|
||||||
|
><select class="select" id="density">
|
||||||
|
<option value="4">Simple</option>
|
||||||
|
<option value="5" selected>Balanced</option>
|
||||||
|
<option value="6">Fine</option>
|
||||||
|
</select>
|
||||||
|
<p class="hint">Simple feels lighter. Fine allows smaller details.</p>
|
||||||
|
</section>
|
||||||
|
<footer>
|
||||||
|
<button class="btn" data-variant="outline" value="cancel">
|
||||||
|
Cancel</button
|
||||||
|
><button value="create" class="btn">Create sphere</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
<dialog id="help-dialog" class="dialog" aria-labelledby="help-title">
|
||||||
|
<form method="dialog">
|
||||||
|
<header>
|
||||||
|
<h2 id="help-title">Make something.</h2>
|
||||||
|
<p>Press lightly for a soft touch, harder for a deeper stroke.</p>
|
||||||
|
</header>
|
||||||
|
<section>
|
||||||
|
<dl>
|
||||||
|
<dt>Find your tools</dt>
|
||||||
|
<dd>
|
||||||
|
Choose a brush in the left toolbar. The sliders button opens brush
|
||||||
|
settings; the gear opens projects and settings.
|
||||||
|
</dd>
|
||||||
|
<dt>Move around</dt>
|
||||||
|
<dd>
|
||||||
|
Drag the colored axis gizmo to orbit. Click an axis for a
|
||||||
|
straight-on view. Drag the pan or magnifier icons to move or zoom.
|
||||||
|
Arrow keys work when these controls are focused. Escape cancels a
|
||||||
|
drag. The scene uses Y as its up axis.
|
||||||
|
</dd>
|
||||||
|
<dt>Rest your hand</dt>
|
||||||
|
<dd>
|
||||||
|
Palm rejection is on. Turn it off in Settings to draw with a
|
||||||
|
finger or move with two fingers.
|
||||||
|
</dd>
|
||||||
|
<dt>Build with clay</dt>
|
||||||
|
<dd>
|
||||||
|
Open Clay at the top of the toolbar. Add a preset, select its row,
|
||||||
|
then change its color or delete it. Each new piece gets a pastel
|
||||||
|
color.
|
||||||
|
</dd>
|
||||||
|
<dt>Protect and refine</dt>
|
||||||
|
<dd>
|
||||||
|
Mask paints protected areas; use Erase mask in brush settings to
|
||||||
|
remove it. Clay → Detail & mask contains Clear, Invert,
|
||||||
|
Subdivide and Decimate. Subdivide adds resolution without
|
||||||
|
smoothing. Decimate preserves masked areas and may reduce less
|
||||||
|
than half.
|
||||||
|
</dd>
|
||||||
|
<dt>Keep your work</dt>
|
||||||
|
<dd>
|
||||||
|
Download your project before leaving. Open it here to keep
|
||||||
|
sculpting later.
|
||||||
|
</dd>
|
||||||
|
<dt>With a mouse</dt>
|
||||||
|
<dd>Right drag to rotate, middle drag to pan, scroll to zoom.</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
<footer><button value="close" class="btn">Got it</button></footer>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
import { cameraBasis, add, mul, clamp, clampZoom } from "./math.js";
|
||||||
|
|
||||||
|
export class Input {
|
||||||
|
constructor(viewport, host) {
|
||||||
|
this.viewport = viewport;
|
||||||
|
this.host = host;
|
||||||
|
this.pointers = new Map();
|
||||||
|
this.active = null;
|
||||||
|
this.ending = false;
|
||||||
|
viewport.addEventListener("pointerdown", (e) => this.down(e));
|
||||||
|
viewport.addEventListener("pointermove", (e) => this.move(e));
|
||||||
|
viewport.addEventListener("pointerup", (e) => this.up(e));
|
||||||
|
viewport.addEventListener("pointercancel", (e) => {
|
||||||
|
const t = this.transaction;
|
||||||
|
if (
|
||||||
|
this.pointers.has(e.pointerId) ||
|
||||||
|
t?.pendingDown?.pointerId === e.pointerId ||
|
||||||
|
(t && !t.canceling && t.active.id === e.pointerId)
|
||||||
|
)
|
||||||
|
this.cancel();
|
||||||
|
});
|
||||||
|
viewport.addEventListener("lostpointercapture", (e) => {
|
||||||
|
if (
|
||||||
|
this.pointers.has(e.pointerId) ||
|
||||||
|
this.transaction?.pendingDown?.pointerId === e.pointerId
|
||||||
|
)
|
||||||
|
this.cancel();
|
||||||
|
});
|
||||||
|
viewport.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||||
|
viewport.addEventListener(
|
||||||
|
"wheel",
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (this.active || !host.ready()) return;
|
||||||
|
host.camera.zoom = clampZoom(
|
||||||
|
host.camera.zoom * Math.exp(e.deltaY * 0.001),
|
||||||
|
viewport.getBoundingClientRect(),
|
||||||
|
);
|
||||||
|
host.invalidate();
|
||||||
|
},
|
||||||
|
{ passive: false },
|
||||||
|
);
|
||||||
|
viewport.addEventListener("pointerleave", () => {
|
||||||
|
if (!this.active) host.cursor(null);
|
||||||
|
});
|
||||||
|
window.addEventListener("blur", () => this.cancel());
|
||||||
|
}
|
||||||
|
get busy() {
|
||||||
|
return !!this.active || !!this.transaction;
|
||||||
|
}
|
||||||
|
rejectsTouch(e) {
|
||||||
|
return (
|
||||||
|
e.pointerType === "touch" &&
|
||||||
|
(this.host.palmRejection() || this.active?.type === "pen")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
checkViewport(rect = this.viewport.getBoundingClientRect()) {
|
||||||
|
const owner = this.transaction?.pendingDown ?? this.active;
|
||||||
|
if (
|
||||||
|
rect.width <= 0 ||
|
||||||
|
rect.height <= 0 ||
|
||||||
|
(owner &&
|
||||||
|
(owner.canvas !== this.viewport.querySelector("canvas") ||
|
||||||
|
["left", "top", "width", "height"].some(
|
||||||
|
(key) => rect[key] !== owner.rect[key],
|
||||||
|
)))
|
||||||
|
) {
|
||||||
|
if (owner) {
|
||||||
|
this.cancel();
|
||||||
|
this.host.message(
|
||||||
|
"Viewport changed · current stroke canceled; completed work preserved",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
sample(e, r = this.active?.rect ?? this.viewport.getBoundingClientRect()) {
|
||||||
|
return {
|
||||||
|
x: e.clientX - r.left,
|
||||||
|
y: e.clientY - r.top,
|
||||||
|
pressure: e.pointerType === "pen" ? e.pressure : 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
world(
|
||||||
|
sample,
|
||||||
|
r = this.active?.rect ?? this.viewport.getBoundingClientRect(),
|
||||||
|
) {
|
||||||
|
const c = this.host.camera,
|
||||||
|
b = cameraBasis(c.yaw, c.pitch);
|
||||||
|
return add(
|
||||||
|
c.target,
|
||||||
|
add(
|
||||||
|
mul(
|
||||||
|
b.right,
|
||||||
|
(((sample.x / r.width) * 2 - 1) * c.zoom * r.width) / r.height,
|
||||||
|
),
|
||||||
|
mul(b.up, (1 - (sample.y / r.height) * 2) * c.zoom),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
pick(sample, rect) {
|
||||||
|
const b = cameraBasis(this.host.camera.yaw, this.host.camera.pitch);
|
||||||
|
const hit = this.host
|
||||||
|
.engine()
|
||||||
|
.bvh.raycast(
|
||||||
|
add(this.world(sample, rect), mul(b.forward, 24)),
|
||||||
|
mul(b.forward, -1),
|
||||||
|
);
|
||||||
|
if (!hit) return null;
|
||||||
|
const engine = this.host.engine();
|
||||||
|
const piece = engine.mesh.pieces?.find((p) => p.id === engine.activeId);
|
||||||
|
return piece &&
|
||||||
|
hit.face * 3 >= piece.indexStart &&
|
||||||
|
hit.face * 3 < piece.indexStart + piece.indexCount
|
||||||
|
? hit
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
down(e) {
|
||||||
|
if (this.rejectsTouch(e)) return;
|
||||||
|
const canvas = this.viewport.querySelector("canvas");
|
||||||
|
if (e.target !== canvas) return;
|
||||||
|
if (!this.checkViewport()) return;
|
||||||
|
if (!this.host.ready() || this.ending) return;
|
||||||
|
if (this.pointers.has(e.pointerId) || this.pointers.size >= 2) return;
|
||||||
|
e.preventDefault();
|
||||||
|
this.viewport.focus({ preventScroll: true });
|
||||||
|
this.viewport.setPointerCapture(e.pointerId);
|
||||||
|
const rect = this.viewport.getBoundingClientRect();
|
||||||
|
if (e.pointerType === "pen" && this.active?.type === "touch") {
|
||||||
|
this.cancel();
|
||||||
|
if (this.transaction) {
|
||||||
|
this.transaction.pendingDown = {
|
||||||
|
pointerId: e.pointerId,
|
||||||
|
pointerType: e.pointerType,
|
||||||
|
clientX: e.clientX,
|
||||||
|
clientY: e.clientY,
|
||||||
|
pressure: e.pressure,
|
||||||
|
button: e.button,
|
||||||
|
shiftKey: e.shiftKey,
|
||||||
|
altKey: e.altKey,
|
||||||
|
rect,
|
||||||
|
canvas,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.start(e, rect, canvas);
|
||||||
|
}
|
||||||
|
start(e, rect, canvas) {
|
||||||
|
const sample = this.sample(e, rect);
|
||||||
|
this.pointers.set(e.pointerId, sample);
|
||||||
|
if (this.pointers.size > 1) {
|
||||||
|
if (this.active?.mode === "sculpt") {
|
||||||
|
this.cancelStroke();
|
||||||
|
}
|
||||||
|
this.active = {
|
||||||
|
mode: "gesture",
|
||||||
|
id: e.pointerId,
|
||||||
|
type: "touch",
|
||||||
|
rect,
|
||||||
|
canvas,
|
||||||
|
};
|
||||||
|
this.gesture = this.touchPair();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const action =
|
||||||
|
e.button === 1
|
||||||
|
? "pan"
|
||||||
|
: e.button === 2
|
||||||
|
? "rotate"
|
||||||
|
: this.host.navigation();
|
||||||
|
const navigate = action !== "sculpt";
|
||||||
|
const hit = navigate ? null : this.pick(sample, rect);
|
||||||
|
this.active = {
|
||||||
|
id: e.pointerId,
|
||||||
|
type: e.pointerType,
|
||||||
|
mode: navigate || !hit ? "orbit" : "sculpt",
|
||||||
|
action,
|
||||||
|
start: sample,
|
||||||
|
last: sample,
|
||||||
|
hit,
|
||||||
|
rect,
|
||||||
|
canvas,
|
||||||
|
};
|
||||||
|
if (this.active.mode === "sculpt") {
|
||||||
|
const settings = {
|
||||||
|
...this.host.settings,
|
||||||
|
activeId: this.host.engine().activeId,
|
||||||
|
};
|
||||||
|
if (e.shiftKey) settings.brush = "smooth";
|
||||||
|
if (e.altKey) settings.invert = !settings.invert;
|
||||||
|
const engine = this.host.engine(),
|
||||||
|
client = this.host.processor();
|
||||||
|
engine.begin(settings);
|
||||||
|
const transaction = {
|
||||||
|
engine,
|
||||||
|
client,
|
||||||
|
active: this.active,
|
||||||
|
canceling: false,
|
||||||
|
};
|
||||||
|
this.transaction = transaction;
|
||||||
|
client
|
||||||
|
.begin(
|
||||||
|
{
|
||||||
|
positions: engine.mesh.positions.slice(),
|
||||||
|
masks: engine.mesh.masks.slice(),
|
||||||
|
activeId: engine.activeId,
|
||||||
|
settings,
|
||||||
|
camera: structuredClone(this.host.camera),
|
||||||
|
rect: {
|
||||||
|
left: rect.left,
|
||||||
|
top: rect.top,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
},
|
||||||
|
hit,
|
||||||
|
sample,
|
||||||
|
},
|
||||||
|
(packet) => {
|
||||||
|
this.checkViewport();
|
||||||
|
if (this.transaction !== transaction || transaction.canceling)
|
||||||
|
return;
|
||||||
|
const { vertices, masks, rows, touched } = packet;
|
||||||
|
if (packet.type === "mask") {
|
||||||
|
rows.forEach((v, i) => {
|
||||||
|
engine.mesh.masks[v] = masks[i];
|
||||||
|
engine.dirty.add(v);
|
||||||
|
});
|
||||||
|
for (const v of touched) engine.stroke.touched.add(v);
|
||||||
|
engine.revision++;
|
||||||
|
this.host.invalidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let row = 0; row < rows.length; row++) {
|
||||||
|
const v = rows[row],
|
||||||
|
i = row * 8,
|
||||||
|
j = v * 3,
|
||||||
|
p = engine.mesh.positions,
|
||||||
|
n = engine.mesh.normals;
|
||||||
|
p[j] = vertices[i];
|
||||||
|
p[j + 1] = vertices[i + 1];
|
||||||
|
p[j + 2] = vertices[i + 2];
|
||||||
|
n[j] = vertices[i + 4];
|
||||||
|
n[j + 1] = vertices[i + 5];
|
||||||
|
n[j + 2] = vertices[i + 6];
|
||||||
|
engine.dirty.add(v);
|
||||||
|
}
|
||||||
|
for (const v of touched) engine.stroke.touched.add(v);
|
||||||
|
engine.revision++;
|
||||||
|
this.host.invalidate();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
(result) => this.settle(transaction, result),
|
||||||
|
() => {
|
||||||
|
// StrokeClient reports worker failure once through the renderer's
|
||||||
|
// error handler; this continuation only settles document ownership.
|
||||||
|
this.settle(transaction, { canceled: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.host.changed();
|
||||||
|
}
|
||||||
|
touchPair() {
|
||||||
|
const [a, b] = [...this.pointers.values()];
|
||||||
|
return a && b
|
||||||
|
? {
|
||||||
|
x: (a.x + b.x) / 2,
|
||||||
|
y: (a.y + b.y) / 2,
|
||||||
|
distance: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
pan(dx, dy) {
|
||||||
|
const c = this.host.camera,
|
||||||
|
b = cameraBasis(c.yaw, c.pitch),
|
||||||
|
scale = (2 * c.zoom) / this.active.rect.height;
|
||||||
|
c.target = add(
|
||||||
|
c.target,
|
||||||
|
add(mul(b.right, -dx * scale), mul(b.up, dy * scale)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
move(e) {
|
||||||
|
if (this.host.blocked?.()) return;
|
||||||
|
if (this.rejectsTouch(e)) return;
|
||||||
|
if (!this.checkViewport()) return;
|
||||||
|
if (!this.active && e.target.tagName !== "CANVAS") {
|
||||||
|
this.host.cursor(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sample = this.sample(e);
|
||||||
|
this.host.cursor(sample);
|
||||||
|
this.host.pressure(e.pointerType === "pen" ? sample.pressure : null);
|
||||||
|
if (!this.active || (this.ending && this.active.mode === "sculpt")) return;
|
||||||
|
if (!this.pointers.has(e.pointerId)) return;
|
||||||
|
this.pointers.set(e.pointerId, sample);
|
||||||
|
if (this.active.mode === "gesture") {
|
||||||
|
const next = this.touchPair();
|
||||||
|
if (next && this.gesture) {
|
||||||
|
this.pan(next.x - this.gesture.x, next.y - this.gesture.y);
|
||||||
|
this.host.camera.zoom = clampZoom(
|
||||||
|
(this.host.camera.zoom * this.gesture.distance) / next.distance,
|
||||||
|
this.active.rect,
|
||||||
|
);
|
||||||
|
this.host.invalidate();
|
||||||
|
}
|
||||||
|
this.gesture = next;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.pointerId !== this.active.id) return;
|
||||||
|
if (this.active.mode === "sculpt") {
|
||||||
|
const samples = e.getCoalescedEvents?.() ?? [];
|
||||||
|
// Only Grab needs to observe the first effective pressure in the batch;
|
||||||
|
// writes overwrite a single slot, never enqueue samples.
|
||||||
|
if (this.host.engine().stroke.settings.brush === "grab")
|
||||||
|
for (const event of samples.length ? samples : [e])
|
||||||
|
this.transaction.client.write(this.sample(event));
|
||||||
|
else this.transaction.client.write(this.sample(samples.at(-1) ?? e));
|
||||||
|
} else {
|
||||||
|
const dx = sample.x - this.active.last.x,
|
||||||
|
dy = sample.y - this.active.last.y;
|
||||||
|
if (this.active.action === "pan") this.pan(dx, dy);
|
||||||
|
else if (this.active.action === "zoom")
|
||||||
|
this.host.camera.zoom = clampZoom(
|
||||||
|
this.host.camera.zoom * Math.exp(dy * 0.008),
|
||||||
|
this.active.rect,
|
||||||
|
);
|
||||||
|
else {
|
||||||
|
this.host.camera.yaw -= dx * 0.008;
|
||||||
|
this.host.camera.pitch = clamp(
|
||||||
|
this.host.camera.pitch + dy * 0.008,
|
||||||
|
-Math.PI / 2,
|
||||||
|
Math.PI / 2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.host.invalidate();
|
||||||
|
}
|
||||||
|
if (this.active) this.active.last = sample;
|
||||||
|
}
|
||||||
|
up(e) {
|
||||||
|
if (e.pointerType === "touch" && this.host.palmRejection()) return;
|
||||||
|
if (this.transaction?.pendingDown?.pointerId === e.pointerId) {
|
||||||
|
this.transaction.pendingDown = null;
|
||||||
|
this.release(e.pointerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.checkViewport()) return;
|
||||||
|
this.pointers.delete(e.pointerId);
|
||||||
|
this.release(e.pointerId);
|
||||||
|
if (!this.active) return;
|
||||||
|
if (this.active.mode === "gesture") {
|
||||||
|
if (!this.pointers.size) {
|
||||||
|
this.active = null;
|
||||||
|
this.host.changed();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.pointerId !== this.active.id) return;
|
||||||
|
if (this.active.mode === "sculpt") {
|
||||||
|
this.ending = true;
|
||||||
|
this.transaction.client.end();
|
||||||
|
} else {
|
||||||
|
this.active = null;
|
||||||
|
this.host.changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settle(transaction, result) {
|
||||||
|
if (this.transaction !== transaction) return;
|
||||||
|
// Resize may precede the observer callback or this completion message.
|
||||||
|
this.checkViewport();
|
||||||
|
const engine = transaction.engine;
|
||||||
|
if (result.canceled || transaction.canceling) {
|
||||||
|
engine.end(true);
|
||||||
|
if (!transaction.canceling) {
|
||||||
|
const ids = [...this.pointers.keys()];
|
||||||
|
this.pointers.clear();
|
||||||
|
for (const id of ids) this.release(id);
|
||||||
|
}
|
||||||
|
this.host.invalidate();
|
||||||
|
} else {
|
||||||
|
// Repair main's face-normal cache and picking BVH only at the boundary.
|
||||||
|
if (engine.stroke.settings.brush !== "mask")
|
||||||
|
engine.changed([...engine.stroke.touched]);
|
||||||
|
engine.end();
|
||||||
|
this.host.committed();
|
||||||
|
}
|
||||||
|
const pending = transaction.pendingDown;
|
||||||
|
this.transaction = null;
|
||||||
|
if (this.active === transaction.active) this.active = null;
|
||||||
|
this.ending = false;
|
||||||
|
if (result.message) this.host.message(result.message);
|
||||||
|
if (pending) {
|
||||||
|
if (
|
||||||
|
this.host.ready() &&
|
||||||
|
this.host.engine() === engine &&
|
||||||
|
this.host.processor() === transaction.client &&
|
||||||
|
this.viewport.hasPointerCapture(pending.pointerId)
|
||||||
|
)
|
||||||
|
this.start(pending, pending.rect, pending.canvas);
|
||||||
|
else this.release(pending.pointerId);
|
||||||
|
}
|
||||||
|
this.host.changed();
|
||||||
|
}
|
||||||
|
cancelStroke() {
|
||||||
|
const transaction = this.transaction;
|
||||||
|
if (!transaction || transaction.canceling) return;
|
||||||
|
transaction.canceling = true;
|
||||||
|
this.ending = true;
|
||||||
|
transaction.client.cancel();
|
||||||
|
transaction.engine.end(true);
|
||||||
|
this.host.invalidate();
|
||||||
|
}
|
||||||
|
cancel() {
|
||||||
|
const ids = [...this.pointers.keys()];
|
||||||
|
if (this.transaction?.pendingDown) {
|
||||||
|
ids.push(this.transaction.pendingDown.pointerId);
|
||||||
|
this.transaction.pendingDown = null;
|
||||||
|
}
|
||||||
|
this.cancelStroke();
|
||||||
|
this.pointers.clear();
|
||||||
|
this.active = null;
|
||||||
|
for (const id of ids) this.release(id);
|
||||||
|
this.host.changed();
|
||||||
|
}
|
||||||
|
release(id) {
|
||||||
|
if (this.viewport.hasPointerCapture(id))
|
||||||
|
this.viewport.releasePointerCapture(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { Input } from "./input.js";
|
||||||
|
import { Sculpt, defaults } from "./engine.js";
|
||||||
|
import { sphere, updateNormals } from "./mesh.js";
|
||||||
|
import { Renderer } from "./renderer.js";
|
||||||
|
import { StrokeClient } from "./stroke-client.js";
|
||||||
|
import { StrokeProcessor } from "./stroke-worker.js";
|
||||||
|
import { Mailbox } from "./mailbox.js";
|
||||||
|
|
||||||
|
// Exercise the real client protocol, with frame delivery under test control.
|
||||||
|
// Structured clone/transfer and asynchronous messages match worker ownership.
|
||||||
|
class ControlledWorker {
|
||||||
|
postMessage(data, transfer = []) {
|
||||||
|
const copy = structuredClone(data, { transfer });
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (this.dead) return;
|
||||||
|
if (copy.type === "init") {
|
||||||
|
this.processor = new StrokeProcessor(
|
||||||
|
sphere(copy.level),
|
||||||
|
new Mailbox(copy.row),
|
||||||
|
(message, transfers = []) => {
|
||||||
|
const value = structuredClone(message, { transfer: transfers });
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (!this.dead) this.onmessage({ data: value });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
() => 0,
|
||||||
|
);
|
||||||
|
this.onmessage({ data: { type: "ready" } });
|
||||||
|
} else if (copy.type === "begin") this.processor.begin(copy);
|
||||||
|
else if (copy.type === "end") this.processor.end(copy.id, copy.version);
|
||||||
|
else if (copy.type === "cancel") this.processor.cancel(copy.id);
|
||||||
|
else if (copy.type === "ack") this.processor.acknowledge(copy.packet);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
terminate() {
|
||||||
|
this.dead = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
async function setup(t) {
|
||||||
|
const oldWorker = globalThis.Worker,
|
||||||
|
oldWindow = globalThis.window;
|
||||||
|
globalThis.Worker = ControlledWorker;
|
||||||
|
globalThis.window = { addEventListener() {} };
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.Worker = oldWorker;
|
||||||
|
globalThis.window = oldWindow;
|
||||||
|
client.dispose();
|
||||||
|
});
|
||||||
|
const row = { buffer: new SharedArrayBuffer(64), descriptor: { offset: 0 } };
|
||||||
|
row.share = () => ({ buffer: row.buffer, descriptor: row.descriptor });
|
||||||
|
const client = new StrokeClient(row, 4, (error) => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
await client.ready;
|
||||||
|
const engine = new Sculpt(sphere(4)),
|
||||||
|
listeners = new Map();
|
||||||
|
let commits = 0,
|
||||||
|
message = "";
|
||||||
|
const rect = { left: 0, top: 0, width: 800, height: 600 };
|
||||||
|
const captures = new Set();
|
||||||
|
const viewport = {
|
||||||
|
canvas: { tagName: "CANVAS" },
|
||||||
|
querySelector() {
|
||||||
|
return this.canvas;
|
||||||
|
},
|
||||||
|
addEventListener: (name, fn) => listeners.set(name, fn),
|
||||||
|
focus() {},
|
||||||
|
setPointerCapture(id) {
|
||||||
|
captures.add(id);
|
||||||
|
},
|
||||||
|
hasPointerCapture(id) {
|
||||||
|
return captures.has(id);
|
||||||
|
},
|
||||||
|
releasePointerCapture(id) {
|
||||||
|
captures.delete(id);
|
||||||
|
listeners.get("lostpointercapture")({ pointerId: id });
|
||||||
|
},
|
||||||
|
getBoundingClientRect: () => ({ ...rect }),
|
||||||
|
};
|
||||||
|
const host = {
|
||||||
|
engine: () => engine,
|
||||||
|
processor: () => client,
|
||||||
|
error: (e) => {
|
||||||
|
throw e;
|
||||||
|
},
|
||||||
|
camera: { yaw: 0, pitch: 0, zoom: 1.5, target: [0, 0, 0] },
|
||||||
|
settings: { ...defaults, symmetry: false },
|
||||||
|
ready: () => true,
|
||||||
|
navigation: () => "sculpt",
|
||||||
|
palmRejection: () => false,
|
||||||
|
schedule() {},
|
||||||
|
invalidate() {},
|
||||||
|
changed() {},
|
||||||
|
committed() {
|
||||||
|
commits++;
|
||||||
|
},
|
||||||
|
message(text) {
|
||||||
|
message = text;
|
||||||
|
},
|
||||||
|
cursor() {},
|
||||||
|
pressure() {},
|
||||||
|
};
|
||||||
|
const input = new Input(viewport, host);
|
||||||
|
const event = (x = 400, y = 300, type = "pen", pressure = 0.7, id = 1) => ({
|
||||||
|
clientX: x,
|
||||||
|
clientY: y,
|
||||||
|
pointerType: type,
|
||||||
|
pressure,
|
||||||
|
pointerId: id,
|
||||||
|
button: 0,
|
||||||
|
target: viewport.canvas,
|
||||||
|
preventDefault() {},
|
||||||
|
});
|
||||||
|
const frame = async () => {
|
||||||
|
await flush();
|
||||||
|
client.worker.processor.frame();
|
||||||
|
await flush();
|
||||||
|
};
|
||||||
|
const finish = async () => {
|
||||||
|
let count = 0;
|
||||||
|
while (input.transaction) {
|
||||||
|
await frame();
|
||||||
|
assert.ok(++count < 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
input,
|
||||||
|
engine,
|
||||||
|
host,
|
||||||
|
client,
|
||||||
|
viewport,
|
||||||
|
captures,
|
||||||
|
event,
|
||||||
|
rect,
|
||||||
|
listeners,
|
||||||
|
frame,
|
||||||
|
finish,
|
||||||
|
get commits() {
|
||||||
|
return commits;
|
||||||
|
},
|
||||||
|
get message() {
|
||||||
|
return message;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("palm rejection ignores touch before capture, focus, cursor or geometry; pen still works", async (t) => {
|
||||||
|
const a = await setup(t),
|
||||||
|
before = a.engine.mesh.positions.slice();
|
||||||
|
a.host.palmRejection = () => true;
|
||||||
|
a.viewport.focus =
|
||||||
|
a.host.cursor =
|
||||||
|
a.host.pressure =
|
||||||
|
() => {
|
||||||
|
throw Error("Rejected palm reached UI");
|
||||||
|
};
|
||||||
|
a.input.down(a.event(400, 300, "touch"));
|
||||||
|
a.input.move(a.event(460, 330, "touch"));
|
||||||
|
a.input.up(a.event(460, 330, "touch"));
|
||||||
|
assert.equal(a.captures.size, 0);
|
||||||
|
assert.equal(a.input.busy, false);
|
||||||
|
assert.deepEqual(a.engine.mesh.positions, before);
|
||||||
|
a.viewport.focus = a.host.cursor = a.host.pressure = () => {};
|
||||||
|
a.input.down(a.event());
|
||||||
|
a.input.up(a.event());
|
||||||
|
await a.finish();
|
||||||
|
assert.notDeepEqual(a.engine.mesh.positions, before);
|
||||||
|
});
|
||||||
|
test("pen navigation snapshots rotate/pan/zoom without sculpting", async (t) => {
|
||||||
|
const a = await setup(t),
|
||||||
|
before = a.engine.mesh.positions.slice();
|
||||||
|
a.host.palmRejection = () => true;
|
||||||
|
for (const mode of ["rotate", "pan", "zoom"]) {
|
||||||
|
a.host.camera = { yaw: 0, pitch: 0, zoom: 1.5, target: [0, 0, 0] };
|
||||||
|
a.host.navigation = () => mode;
|
||||||
|
a.input.down(a.event());
|
||||||
|
a.host.navigation = () => "sculpt";
|
||||||
|
a.input.move(a.event(440, 320));
|
||||||
|
a.input.up(a.event(440, 320));
|
||||||
|
const c = a.host.camera;
|
||||||
|
assert.equal(c.yaw, mode === "rotate" ? -0.32 : 0);
|
||||||
|
assert.equal(c.pitch, mode === "rotate" ? 0.16 : 0);
|
||||||
|
assert.deepEqual(c.target, mode === "pan" ? [-0.2, 0.1, 0] : [0, 0, 0]);
|
||||||
|
assert.equal(c.zoom, mode === "zoom" ? 1.5 * Math.exp(0.16) : 1.5);
|
||||||
|
assert.equal(a.input.busy, false);
|
||||||
|
}
|
||||||
|
assert.deepEqual(a.engine.mesh.positions, before);
|
||||||
|
assert.equal(a.engine.undoStack.length, 0);
|
||||||
|
});
|
||||||
|
test("zero pen is a no-op; release flushes newest mouse movement and commits once", async (t) => {
|
||||||
|
const a = await setup(t),
|
||||||
|
before = a.engine.mesh.positions.slice();
|
||||||
|
a.input.down(a.event(400, 300, "pen", 0));
|
||||||
|
a.input.up(a.event());
|
||||||
|
await a.finish();
|
||||||
|
assert.deepEqual(a.engine.mesh.positions, before);
|
||||||
|
assert.equal(a.engine.undoStack.length, 0);
|
||||||
|
a.input.down(a.event(400, 300, "mouse", 0));
|
||||||
|
a.input.move(a.event(520, 300, "mouse", 0));
|
||||||
|
a.input.up(a.event(520, 300, "mouse", 0));
|
||||||
|
assert.equal(a.engine.undoStack.length, 0);
|
||||||
|
await a.finish();
|
||||||
|
assert.notDeepEqual(a.engine.mesh.positions, before);
|
||||||
|
assert.equal(a.engine.undoStack.length, 1);
|
||||||
|
assert.equal(a.commits, 2);
|
||||||
|
a.input.up(a.event());
|
||||||
|
assert.equal(a.commits, 2);
|
||||||
|
});
|
||||||
|
test("hundreds of moves occupy one slot, no main-thread dabs or per-frame BVH maintenance", async (t) => {
|
||||||
|
const a = await setup(t);
|
||||||
|
let changed = 0;
|
||||||
|
const original = a.engine.changed.bind(a.engine);
|
||||||
|
a.engine.changed = (ids) => {
|
||||||
|
changed++;
|
||||||
|
original(ids);
|
||||||
|
};
|
||||||
|
a.engine.dab = () => assert.fail("main thread performed a dab");
|
||||||
|
a.input.down(a.event());
|
||||||
|
for (let i = 0; i < 1000; i++) a.input.move(a.event(400 + i / 10, 310));
|
||||||
|
assert.equal(a.input.queue, undefined);
|
||||||
|
assert.equal(a.client.mailbox.words.byteLength, 64);
|
||||||
|
await a.frame();
|
||||||
|
await a.frame();
|
||||||
|
assert.equal(changed, 0);
|
||||||
|
a.input.up(a.event());
|
||||||
|
await a.finish();
|
||||||
|
assert.equal(changed, 1);
|
||||||
|
assert.equal(a.engine.undoStack.length, 1);
|
||||||
|
});
|
||||||
|
test("cancel rejects late geometry and rolls back without another animation frame", async (t) => {
|
||||||
|
const a = await setup(t),
|
||||||
|
before = a.engine.mesh.positions.slice();
|
||||||
|
a.input.down(a.event());
|
||||||
|
await a.frame();
|
||||||
|
assert.notDeepEqual(a.engine.mesh.positions, before);
|
||||||
|
a.input.move(a.event(480, 310));
|
||||||
|
await flush();
|
||||||
|
a.client.worker.processor.frame(); // geometry message is now in flight
|
||||||
|
a.input.cancel();
|
||||||
|
assert.deepEqual(a.engine.mesh.positions, before);
|
||||||
|
assert.equal(a.input.busy, true);
|
||||||
|
await flush();
|
||||||
|
assert.equal(a.input.busy, false);
|
||||||
|
assert.deepEqual(a.engine.mesh.positions, before);
|
||||||
|
assert.equal(a.engine.undoStack.length, 0);
|
||||||
|
});
|
||||||
|
test("cancel wins over a completion that has not yet committed on main", async (t) => {
|
||||||
|
const a = await setup(t),
|
||||||
|
before = a.engine.mesh.positions.slice();
|
||||||
|
a.input.down(a.event());
|
||||||
|
await a.frame();
|
||||||
|
a.input.up(a.event());
|
||||||
|
await flush();
|
||||||
|
a.client.worker.processor.frame();
|
||||||
|
a.input.cancel();
|
||||||
|
await flush();
|
||||||
|
assert.deepEqual(a.engine.mesh.positions, before);
|
||||||
|
assert.equal(a.engine.undoStack.length, 0);
|
||||||
|
assert.equal(a.commits, 0);
|
||||||
|
});
|
||||||
|
test("touch takeover, pen palm rejection, capture loss and pen-over-touch handoff", async (t) => {
|
||||||
|
const a = await setup(t),
|
||||||
|
before = a.engine.mesh.positions.slice();
|
||||||
|
a.input.down(a.event(400, 300, "touch", 1, 1));
|
||||||
|
await a.frame();
|
||||||
|
a.input.down(a.event(450, 320, "touch", 1, 2));
|
||||||
|
assert.deepEqual(a.engine.mesh.positions, before);
|
||||||
|
assert.equal(a.input.active.mode, "gesture");
|
||||||
|
a.input.move(a.event(500, 340, "touch", 1, 2));
|
||||||
|
assert.notEqual(a.host.camera.zoom, 1.5);
|
||||||
|
a.input.up(a.event(400, 300, "touch", 0, 1));
|
||||||
|
a.input.up(a.event(500, 340, "touch", 0, 2));
|
||||||
|
await flush();
|
||||||
|
assert.equal(a.input.busy, false);
|
||||||
|
a.input.down(a.event(400, 300, "pen", 0.7, 3));
|
||||||
|
a.input.down(a.event(440, 310, "touch", 1, 4));
|
||||||
|
assert.equal(a.input.active.id, 3);
|
||||||
|
await a.frame();
|
||||||
|
a.listeners.get("lostpointercapture")({ pointerId: 3 });
|
||||||
|
await flush();
|
||||||
|
assert.equal(a.input.busy, false);
|
||||||
|
a.input.down(a.event(400, 300, "touch", 1, 5));
|
||||||
|
await a.frame();
|
||||||
|
a.input.down(a.event(400, 300, "pen", 0.8, 6));
|
||||||
|
await flush();
|
||||||
|
assert.equal(a.input.active.id, 6);
|
||||||
|
a.input.up(a.event(400, 300, "pen", 0, 6));
|
||||||
|
await a.finish();
|
||||||
|
assert.equal(a.engine.undoStack.length, 1);
|
||||||
|
});
|
||||||
|
test("resize before release, geometry delivery, or completion preserves existing undo and redo", async (t) => {
|
||||||
|
const a = await setup(t);
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
a.input.down(a.event(400 + i * 20, 300));
|
||||||
|
a.input.up(a.event());
|
||||||
|
await a.finish();
|
||||||
|
}
|
||||||
|
a.engine.restore();
|
||||||
|
const before = a.engine.mesh.positions.slice(),
|
||||||
|
undo = [...a.engine.undoStack],
|
||||||
|
redo = [...a.engine.redoStack];
|
||||||
|
for (const mode of ["release", "packet", "complete", "grab"]) {
|
||||||
|
a.host.settings.brush = mode === "grab" ? "grab" : "draw";
|
||||||
|
a.input.down(a.event());
|
||||||
|
a.input.move(a.event(440, 320));
|
||||||
|
await a.frame();
|
||||||
|
await a.frame();
|
||||||
|
assert.notDeepEqual(a.engine.mesh.positions, before);
|
||||||
|
if (mode === "complete") a.input.up(a.event());
|
||||||
|
a.rect.width = 650;
|
||||||
|
if (mode === "packet") {
|
||||||
|
a.input.move(a.event(470, 330));
|
||||||
|
} else a.input.up(a.event());
|
||||||
|
await a.finish();
|
||||||
|
assert.deepEqual(a.engine.mesh.positions, before);
|
||||||
|
assert.deepEqual(a.engine.undoStack, undo);
|
||||||
|
assert.deepEqual(a.engine.redoStack, redo);
|
||||||
|
a.rect.width = 800;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("commits repair face-normal caches; undo/redo and adjacent strokes agree with full normals", async (t) => {
|
||||||
|
const a = await setup(t);
|
||||||
|
for (const x of [400, 430, 450]) {
|
||||||
|
a.input.down(a.event(x, 300));
|
||||||
|
a.input.move(a.event(x + 35, 330));
|
||||||
|
a.input.up(a.event());
|
||||||
|
await a.finish();
|
||||||
|
const expected = sphere(4);
|
||||||
|
expected.positions.set(a.engine.mesh.positions);
|
||||||
|
updateNormals(expected);
|
||||||
|
assert.deepEqual(a.engine.mesh.faceNormals, expected.faceNormals);
|
||||||
|
assert.deepEqual(a.engine.mesh.normals, expected.normals);
|
||||||
|
}
|
||||||
|
for (const redo of [false, true]) {
|
||||||
|
a.engine.restore(redo);
|
||||||
|
const expected = sphere(4);
|
||||||
|
expected.positions.set(a.engine.mesh.positions);
|
||||||
|
updateNormals(expected);
|
||||||
|
assert.deepEqual(a.engine.mesh.normals, expected.normals);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("worker termination rolls back provisional geometry and late packets cannot revive it", async (t) => {
|
||||||
|
const a = await setup(t),
|
||||||
|
before = a.engine.mesh.positions.slice();
|
||||||
|
a.input.down(a.event());
|
||||||
|
await a.frame();
|
||||||
|
a.client.dispose();
|
||||||
|
await flush();
|
||||||
|
assert.deepEqual(a.engine.mesh.positions, before);
|
||||||
|
assert.equal(a.input.busy, false);
|
||||||
|
assert.equal(a.engine.undoStack.length, 0);
|
||||||
|
});
|
||||||
|
test("pending pen termination and stale canvas cannot revive a canceled contact", async (t) => {
|
||||||
|
const a = await setup(t),
|
||||||
|
before = a.engine.mesh.positions.slice();
|
||||||
|
for (const termination of [
|
||||||
|
"pointercancel",
|
||||||
|
"lostpointercapture",
|
||||||
|
"pointerup",
|
||||||
|
"resize",
|
||||||
|
"canvas",
|
||||||
|
"unready",
|
||||||
|
"continuation",
|
||||||
|
]) {
|
||||||
|
a.input.down(a.event(400, 300, "touch", 1, 1));
|
||||||
|
await a.frame();
|
||||||
|
const pen = a.event(400, 300, "pen", 0.8, 2);
|
||||||
|
let prevented = false;
|
||||||
|
pen.preventDefault = () => {
|
||||||
|
prevented = true;
|
||||||
|
};
|
||||||
|
a.input.down(pen);
|
||||||
|
assert.ok(prevented);
|
||||||
|
assert.ok(a.captures.has(2));
|
||||||
|
const id = a.client.nextId;
|
||||||
|
if (termination === "pointerup") a.input.up(a.event(400, 300, "pen", 0, 2));
|
||||||
|
else if (termination === "resize") a.rect.left = 10;
|
||||||
|
else if (termination === "canvas")
|
||||||
|
a.viewport.canvas = { tagName: "CANVAS" };
|
||||||
|
else if (termination === "unready") a.host.ready = () => false;
|
||||||
|
else if (termination === "continuation") {
|
||||||
|
a.client.message({ type: "canceled", id: a.client.job.id });
|
||||||
|
a.listeners.get("pointercancel")({ pointerId: 2 });
|
||||||
|
} else a.listeners.get(termination)({ pointerId: 2 });
|
||||||
|
await flush();
|
||||||
|
assert.equal(a.client.nextId, id);
|
||||||
|
assert.equal(a.input.busy, false);
|
||||||
|
assert.equal(a.captures.size, 0);
|
||||||
|
assert.equal(a.engine.stroke, null);
|
||||||
|
assert.deepEqual(a.engine.mesh.positions, before);
|
||||||
|
assert.equal(a.engine.undoStack.length, 0);
|
||||||
|
a.rect.left = 0;
|
||||||
|
a.host.ready = () => true;
|
||||||
|
}
|
||||||
|
// A late old-touch cancellation cannot take ownership from the live pen.
|
||||||
|
a.input.down(a.event(400, 300, "touch", 1, 1));
|
||||||
|
await a.frame();
|
||||||
|
a.input.down(a.event(400, 300, "pen", 0.8, 2));
|
||||||
|
a.listeners.get("pointercancel")({ pointerId: 1 });
|
||||||
|
await flush();
|
||||||
|
assert.equal(a.input.active.id, 2);
|
||||||
|
a.input.up(a.event(400, 300, "pen", 0, 2));
|
||||||
|
await a.finish();
|
||||||
|
assert.equal(a.engine.undoStack.length, 1);
|
||||||
|
// DOM controls never acquire canvas capture or prevent touch defaults.
|
||||||
|
let prevented = false;
|
||||||
|
a.input.down({
|
||||||
|
...a.event(),
|
||||||
|
pointerType: "touch",
|
||||||
|
target: { tagName: "BUTTON" },
|
||||||
|
preventDefault: () => {
|
||||||
|
prevented = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(prevented, false);
|
||||||
|
assert.equal(a.captures.size, 0);
|
||||||
|
});
|
||||||
|
test("renderer staging remains immutable while upload is in flight", async () => {
|
||||||
|
const s = new Sculpt(sphere(4)),
|
||||||
|
r = new Renderer();
|
||||||
|
r.mesh = s.mesh;
|
||||||
|
r.canvas = { width: 800, height: 600 };
|
||||||
|
r.vertices = { view: new Float32Array((s.mesh.positions.length / 3) * 8) };
|
||||||
|
r.camera = { view: new Float32Array(20) };
|
||||||
|
const waits = [];
|
||||||
|
r.core = {
|
||||||
|
syncRows: () =>
|
||||||
|
new Promise((resolve, reject) => waits.push({ resolve, reject })),
|
||||||
|
};
|
||||||
|
const camera = {
|
||||||
|
yaw: 0,
|
||||||
|
pitch: 0,
|
||||||
|
zoom: 1.5,
|
||||||
|
target: [0, 0, 0],
|
||||||
|
color: [0.5, 0.3, 0.2],
|
||||||
|
};
|
||||||
|
const pending = r.publish(s, camera),
|
||||||
|
staged = r.vertices.view.slice();
|
||||||
|
s.begin({});
|
||||||
|
s.dab({ position: [0, 0, 1], normal: [0, 0, 1] });
|
||||||
|
s.end();
|
||||||
|
assert.equal(await r.publish(s, camera), false);
|
||||||
|
assert.deepEqual(r.vertices.view, staged);
|
||||||
|
waits.shift().resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
waits.shift().resolve();
|
||||||
|
await pending;
|
||||||
|
assert.ok(s.dirty.size > 0);
|
||||||
|
const failure = r.publish(s, camera);
|
||||||
|
waits.shift().reject(new Error("upload"));
|
||||||
|
await assert.rejects(failure, /upload/);
|
||||||
|
assert.equal(r.busy, false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Explicit browser benchmark; never imported by the application.
|
||||||
|
// await (await import('/assets/sculpt/latency-check.js')).check()
|
||||||
|
import { Input } from "./input.js";
|
||||||
|
import { Renderer } from "./renderer.js";
|
||||||
|
import { StrokeClient } from "./stroke-client.js";
|
||||||
|
import { Sculpt } from "./engine.js";
|
||||||
|
|
||||||
|
export async function check() {
|
||||||
|
const viewport = document.getElementById("viewport");
|
||||||
|
const move = Input.prototype.move,
|
||||||
|
publish = Renderer.prototype.publish,
|
||||||
|
message = StrokeClient.prototype.message,
|
||||||
|
dab = Sculpt.prototype.dab;
|
||||||
|
const capture = viewport.setPointerCapture;
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
const frame = () => new Promise(requestAnimationFrame);
|
||||||
|
const wait = async (predicate) => {
|
||||||
|
const start = performance.now();
|
||||||
|
while (!predicate()) {
|
||||||
|
if (performance.now() - start > 15000)
|
||||||
|
throw new Error("Latency check timed out");
|
||||||
|
await sleep(10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let input,
|
||||||
|
durations = [],
|
||||||
|
uploads = [],
|
||||||
|
packets = [],
|
||||||
|
mainDabs = 0,
|
||||||
|
canceled = false;
|
||||||
|
Input.prototype.move = function (event) {
|
||||||
|
input = this;
|
||||||
|
const start = performance.now();
|
||||||
|
const result = move.call(this, event);
|
||||||
|
durations.push(performance.now() - start);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
Sculpt.prototype.dab = function (...args) {
|
||||||
|
mainDabs++;
|
||||||
|
return dab.apply(this, args);
|
||||||
|
};
|
||||||
|
StrokeClient.prototype.message = function (data) {
|
||||||
|
const start = performance.now();
|
||||||
|
const result = message.call(this, data);
|
||||||
|
if (data.type === "geometry") packets.push(performance.now() - start);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
Renderer.prototype.publish = async function (...args) {
|
||||||
|
const start = performance.now();
|
||||||
|
try {
|
||||||
|
return await publish.apply(this, args);
|
||||||
|
} finally {
|
||||||
|
uploads.push(performance.now() - start);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
viewport.setPointerCapture = () => {};
|
||||||
|
const pointer = (type, x, y, pressure = 0.8) => {
|
||||||
|
const r = viewport.getBoundingClientRect();
|
||||||
|
return new PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
pointerId: 7,
|
||||||
|
pointerType: "pen",
|
||||||
|
clientX: r.left + r.width / 2 + x,
|
||||||
|
clientY: r.top + r.height / 2 + y,
|
||||||
|
pressure,
|
||||||
|
button: 0,
|
||||||
|
buttons: type === "pointerup" ? 0 : 1,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const send = (event) => viewport.querySelector("canvas").dispatchEvent(event);
|
||||||
|
const percentile = (values, p) =>
|
||||||
|
[...values].sort((a, b) => a - b)[
|
||||||
|
Math.min(values.length - 1, Math.floor(values.length * p))
|
||||||
|
] ?? 0;
|
||||||
|
const results = [];
|
||||||
|
try {
|
||||||
|
for (const brush of ["draw", "grab"])
|
||||||
|
for (const perFrame of [1, 4, 16]) {
|
||||||
|
await wait(
|
||||||
|
() =>
|
||||||
|
document.getElementById("loading").hidden &&
|
||||||
|
!document.getElementById("new").disabled,
|
||||||
|
);
|
||||||
|
const old = viewport.querySelector("canvas");
|
||||||
|
document.getElementById("new").click();
|
||||||
|
document.getElementById("density").value = "5";
|
||||||
|
document.getElementById("new-dialog").close("create");
|
||||||
|
await wait(
|
||||||
|
() =>
|
||||||
|
old !== viewport.querySelector("canvas") &&
|
||||||
|
document.getElementById("loading").hidden,
|
||||||
|
);
|
||||||
|
document.querySelector(`[data-brush="${brush}"]`).click();
|
||||||
|
await sleep(300);
|
||||||
|
durations = [];
|
||||||
|
uploads = [];
|
||||||
|
packets = [];
|
||||||
|
mainDabs = 0;
|
||||||
|
canceled = false;
|
||||||
|
send(pointer("pointerdown", -80, -30));
|
||||||
|
for (let f = 0; f < 60; f++) {
|
||||||
|
await frame();
|
||||||
|
const samples = Array.from({ length: perFrame }, (_, i) => {
|
||||||
|
const t = (f + (i + 1) / perFrame) / 60;
|
||||||
|
return pointer(
|
||||||
|
"pointermove",
|
||||||
|
-80 + 160 * t,
|
||||||
|
-30 + 30 * Math.sin(t * Math.PI),
|
||||||
|
0.3 + 0.6 * t,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const event = samples.at(-1);
|
||||||
|
Object.defineProperty(event, "getCoalescedEvents", {
|
||||||
|
value: () => samples,
|
||||||
|
});
|
||||||
|
send(event);
|
||||||
|
if (input && !input.busy) canceled = true;
|
||||||
|
}
|
||||||
|
const release = performance.now();
|
||||||
|
send(pointer("pointerup", 80, -30, 0));
|
||||||
|
await wait(() => !document.getElementById("save").disabled);
|
||||||
|
const releaseMs = performance.now() - release;
|
||||||
|
const undoEntries = input.host.engine().undoStack.length;
|
||||||
|
canceled ||= undoEntries !== 1;
|
||||||
|
if (canceled || mainDabs || input.queue !== undefined)
|
||||||
|
throw new Error("Worker input regression");
|
||||||
|
results.push({
|
||||||
|
brush,
|
||||||
|
samplesPerFrame: perFrame,
|
||||||
|
sharedInputBytes: input.host.processor().mailbox.words.byteLength,
|
||||||
|
mainDabs,
|
||||||
|
canceled,
|
||||||
|
releaseMs,
|
||||||
|
mainInputP95: percentile(durations, 0.95),
|
||||||
|
mainPacketP95: percentile(packets, 0.95),
|
||||||
|
uploadP95: percentile(uploads, 0.95),
|
||||||
|
undoEntries,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
} finally {
|
||||||
|
Input.prototype.move = move;
|
||||||
|
StrokeClient.prototype.message = message;
|
||||||
|
Sculpt.prototype.dab = dab;
|
||||||
|
Renderer.prototype.publish = publish;
|
||||||
|
viewport.setPointerCapture = capture;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// One latest target in a core shared row. Versions are snapshot tokens, not a
|
||||||
|
// sample count. The producer never touches the consumer's acknowledgement.
|
||||||
|
export class Mailbox {
|
||||||
|
constructor({ buffer, descriptor }) {
|
||||||
|
this.words = new Int32Array(buffer, descriptor.offset, 16);
|
||||||
|
this.scratch = new ArrayBuffer(4);
|
||||||
|
this.bits = new Int32Array(this.scratch);
|
||||||
|
this.float = new Float32Array(this.scratch);
|
||||||
|
}
|
||||||
|
encode(value) {
|
||||||
|
this.float[0] = value;
|
||||||
|
return this.bits[0];
|
||||||
|
}
|
||||||
|
decode(value) {
|
||||||
|
this.bits[0] = value;
|
||||||
|
return this.float[0];
|
||||||
|
}
|
||||||
|
write(stroke, sample, seed) {
|
||||||
|
const version = (Atomics.load(this.words, 0) + 2) | 0;
|
||||||
|
Atomics.store(this.words, 0, version - 1);
|
||||||
|
Atomics.store(this.words, 2, stroke);
|
||||||
|
Atomics.store(this.words, 3, this.encode(sample.x));
|
||||||
|
Atomics.store(this.words, 4, this.encode(sample.y));
|
||||||
|
Atomics.store(this.words, 5, this.encode(sample.pressure));
|
||||||
|
Atomics.store(this.words, 6, this.encode(seed ?? 0));
|
||||||
|
Atomics.store(this.words, 7, seed === undefined ? 0 : 1);
|
||||||
|
Atomics.store(this.words, 0, version);
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
read(stroke) {
|
||||||
|
const a = Atomics.load(this.words, 0);
|
||||||
|
if (a & 1 || a === Atomics.load(this.words, 1)) return null;
|
||||||
|
const id = Atomics.load(this.words, 2);
|
||||||
|
const x = this.decode(Atomics.load(this.words, 3));
|
||||||
|
const y = this.decode(Atomics.load(this.words, 4));
|
||||||
|
const pressure = this.decode(Atomics.load(this.words, 5));
|
||||||
|
const seed = this.decode(Atomics.load(this.words, 6));
|
||||||
|
const hasSeed = Atomics.load(this.words, 7);
|
||||||
|
if (a !== Atomics.load(this.words, 0) || id !== stroke) return null;
|
||||||
|
return { version: a, x, y, pressure, seed: hasSeed ? seed : undefined };
|
||||||
|
}
|
||||||
|
acknowledge(version) {
|
||||||
|
Atomics.store(this.words, 1, version);
|
||||||
|
}
|
||||||
|
get processed() {
|
||||||
|
return Atomics.load(this.words, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export const add = (a, b) => a.map((v, i) => v + b[i]);
|
||||||
|
export const sub = (a, b) => a.map((v, i) => v - b[i]);
|
||||||
|
export const mul = (a, s) => a.map((v) => v * s);
|
||||||
|
export const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||||
|
export const cross = (a, b) => [
|
||||||
|
a[1] * b[2] - a[2] * b[1],
|
||||||
|
a[2] * b[0] - a[0] * b[2],
|
||||||
|
a[0] * b[1] - a[1] * b[0],
|
||||||
|
];
|
||||||
|
export const unit = (a) => mul(a, 1 / (Math.hypot(...a) || 1));
|
||||||
|
export const point = (p, i) => Array.from(p.subarray(i * 3, i * 3 + 3));
|
||||||
|
export const clamp = (n, a, b) => Math.max(a, Math.min(b, n));
|
||||||
|
|
||||||
|
// Bound apparent scale along the viewport's shorter side, in either orientation.
|
||||||
|
export function clampZoom(zoom, rect) {
|
||||||
|
const scale = Math.min(1, rect.width / rect.height);
|
||||||
|
return clamp(zoom * scale, 0.2, 14) / scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orthographic camera: the same basis drives rendering, rays and grab planes.
|
||||||
|
export function cameraBasis(yaw, pitch) {
|
||||||
|
const forward = [
|
||||||
|
Math.sin(yaw) * Math.cos(pitch),
|
||||||
|
Math.sin(pitch),
|
||||||
|
Math.cos(yaw) * Math.cos(pitch),
|
||||||
|
];
|
||||||
|
// Analytic basis remains defined at the top/bottom cardinal views.
|
||||||
|
const right = [Math.cos(yaw), 0, -Math.sin(yaw)];
|
||||||
|
return { right, up: cross(forward, right), forward };
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Panels overlay the canvas: revealing tools never resizes or replaces it.
|
||||||
|
export class Menus {
|
||||||
|
constructor(canOpen, ignoreOutsidePointer) {
|
||||||
|
this.canOpen = canOpen;
|
||||||
|
this.items = [
|
||||||
|
["pieces-toggle", "pieces-panel"],
|
||||||
|
["tools-toggle", "tools-panel"],
|
||||||
|
["settings-toggle", "inspector"],
|
||||||
|
].map(([button, panel]) => ({
|
||||||
|
button: document.getElementById(button),
|
||||||
|
panel: document.getElementById(panel),
|
||||||
|
}));
|
||||||
|
for (const item of this.items) {
|
||||||
|
item.button.addEventListener("pointerenter", (e) => {
|
||||||
|
if (["pen", "mouse"].includes(e.pointerType) && e.buttons === 0)
|
||||||
|
this.open(item);
|
||||||
|
});
|
||||||
|
item.button.addEventListener("click", () => {
|
||||||
|
if (this.current === item && this.pinned) this.close();
|
||||||
|
else this.open(item, true);
|
||||||
|
});
|
||||||
|
item.button.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
this.open(item, true, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const el of [item.button, item.panel]) {
|
||||||
|
el.addEventListener("pointerenter", () => clearTimeout(this.timer));
|
||||||
|
el.addEventListener("pointerleave", () => {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
if (!this.pinned) this.close(false);
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
item.panel.addEventListener("pointerdown", () => {
|
||||||
|
this.pinned = true;
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
});
|
||||||
|
item.panel.addEventListener("focusin", () => {
|
||||||
|
this.pinned = true;
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
});
|
||||||
|
item.panel.querySelector("[data-close]").onclick = () => this.close();
|
||||||
|
}
|
||||||
|
document.addEventListener(
|
||||||
|
"pointerdown",
|
||||||
|
(e) => {
|
||||||
|
if (
|
||||||
|
this.current &&
|
||||||
|
!ignoreOutsidePointer(e) &&
|
||||||
|
!this.current.panel.contains(e.target) &&
|
||||||
|
!this.current.button.contains(e.target)
|
||||||
|
)
|
||||||
|
this.close(false);
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
document.addEventListener(
|
||||||
|
"keydown",
|
||||||
|
(e) => {
|
||||||
|
if (
|
||||||
|
e.key === "Escape" &&
|
||||||
|
this.current &&
|
||||||
|
!document.querySelector("dialog[open]")
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
window.addEventListener("resize", () => this.close(false));
|
||||||
|
}
|
||||||
|
open(item, pin = false, focus = false) {
|
||||||
|
if (!this.canOpen() || document.querySelector("dialog[open]")) return;
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
if (this.current !== item) {
|
||||||
|
this.close(false);
|
||||||
|
this.current = item;
|
||||||
|
this.pinned = false;
|
||||||
|
}
|
||||||
|
this.pinned ||= pin;
|
||||||
|
item.panel.hidden = false;
|
||||||
|
const top = Math.max(
|
||||||
|
12,
|
||||||
|
Math.min(
|
||||||
|
item.button.getBoundingClientRect().top,
|
||||||
|
window.innerHeight - item.panel.offsetHeight - 12,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
item.panel.style.top = `${top}px`;
|
||||||
|
item.panel.style.maxHeight = `calc(100dvh - ${top + 12}px)`;
|
||||||
|
item.button.setAttribute("aria-expanded", "true");
|
||||||
|
if (focus)
|
||||||
|
item.panel.querySelector("button:not(:disabled),input,select")?.focus();
|
||||||
|
}
|
||||||
|
close(restore = true) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
if (!this.current) return;
|
||||||
|
const item = this.current;
|
||||||
|
this.current = null;
|
||||||
|
this.pinned = false;
|
||||||
|
item.panel.hidden = true;
|
||||||
|
item.button.setAttribute("aria-expanded", "false");
|
||||||
|
if (restore) item.button.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { unit } from "./math.js";
|
||||||
|
|
||||||
|
export function buildMesh(positions, indices, level = 5, masks) {
|
||||||
|
positions =
|
||||||
|
positions instanceof Float32Array
|
||||||
|
? positions
|
||||||
|
: new Float32Array(positions.flat ? positions.flat(Infinity) : positions);
|
||||||
|
indices =
|
||||||
|
indices instanceof Uint32Array
|
||||||
|
? indices
|
||||||
|
: new Uint32Array(indices.flat ? indices.flat(Infinity) : indices);
|
||||||
|
if (positions.length % 3 || indices.length % 3)
|
||||||
|
throw new Error(
|
||||||
|
"Mesh positions and indices must contain complete triples.",
|
||||||
|
);
|
||||||
|
const vertexCount = positions.length / 3;
|
||||||
|
const incident = Array.from({ length: vertexCount }, () => []);
|
||||||
|
const neighbors = Array.from({ length: vertexCount }, () => new Set());
|
||||||
|
for (let f = 0; f < indices.length / 3; f++) {
|
||||||
|
const face = indices.subarray(f * 3, f * 3 + 3);
|
||||||
|
for (const v of face) {
|
||||||
|
if (v >= vertexCount) throw new Error("Mesh index is out of range.");
|
||||||
|
incident[v].push(f);
|
||||||
|
for (const other of face) if (other !== v) neighbors[v].add(other);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (masks && masks.length !== vertexCount)
|
||||||
|
throw new Error("Mask count must match vertex count.");
|
||||||
|
const mesh = {
|
||||||
|
level,
|
||||||
|
positions,
|
||||||
|
indices,
|
||||||
|
masks:
|
||||||
|
masks instanceof Float32Array
|
||||||
|
? masks
|
||||||
|
: new Float32Array(masks || vertexCount),
|
||||||
|
normals: new Float32Array(positions.length),
|
||||||
|
faceNormals: new Float32Array(indices.length),
|
||||||
|
incident: incident.map((a) => Uint32Array.from(a)),
|
||||||
|
neighbors: neighbors.map((a) => Uint32Array.from(a)),
|
||||||
|
faceMarks: new Uint32Array(indices.length / 3),
|
||||||
|
vertexMarks: new Uint32Array(vertexCount),
|
||||||
|
generation: 0,
|
||||||
|
};
|
||||||
|
updateNormals(mesh);
|
||||||
|
return mesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sphere(level = 5) {
|
||||||
|
if (!Number.isInteger(level) || level < 4 || level > 6)
|
||||||
|
throw new Error("Choose a supported resolution.");
|
||||||
|
const t = (1 + Math.sqrt(5)) / 2;
|
||||||
|
const vertices = [
|
||||||
|
[-1, t, 0],
|
||||||
|
[1, t, 0],
|
||||||
|
[-1, -t, 0],
|
||||||
|
[1, -t, 0],
|
||||||
|
[0, -1, t],
|
||||||
|
[0, 1, t],
|
||||||
|
[0, -1, -t],
|
||||||
|
[0, 1, -t],
|
||||||
|
[t, 0, -1],
|
||||||
|
[t, 0, 1],
|
||||||
|
[-t, 0, -1],
|
||||||
|
[-t, 0, 1],
|
||||||
|
].map(unit);
|
||||||
|
let faces = [
|
||||||
|
[0, 11, 5],
|
||||||
|
[0, 5, 1],
|
||||||
|
[0, 1, 7],
|
||||||
|
[0, 7, 10],
|
||||||
|
[0, 10, 11],
|
||||||
|
[1, 5, 9],
|
||||||
|
[5, 11, 4],
|
||||||
|
[11, 10, 2],
|
||||||
|
[10, 7, 6],
|
||||||
|
[7, 1, 8],
|
||||||
|
[3, 9, 4],
|
||||||
|
[3, 4, 2],
|
||||||
|
[3, 2, 6],
|
||||||
|
[3, 6, 8],
|
||||||
|
[3, 8, 9],
|
||||||
|
[4, 9, 5],
|
||||||
|
[2, 4, 11],
|
||||||
|
[6, 2, 10],
|
||||||
|
[8, 6, 7],
|
||||||
|
[9, 8, 1],
|
||||||
|
];
|
||||||
|
for (let l = 0; l < level; l++) {
|
||||||
|
const cache = new Map();
|
||||||
|
const midpoint = (a, b) => {
|
||||||
|
const key = Math.min(a, b) * 100000 + Math.max(a, b);
|
||||||
|
if (!cache.has(key)) {
|
||||||
|
cache.set(key, vertices.length);
|
||||||
|
vertices.push(unit(vertices[a].map((v, i) => v + vertices[b][i])));
|
||||||
|
}
|
||||||
|
return cache.get(key);
|
||||||
|
};
|
||||||
|
faces = faces.flatMap(([a, b, c]) => {
|
||||||
|
const ab = midpoint(a, b),
|
||||||
|
bc = midpoint(b, c),
|
||||||
|
ca = midpoint(c, a);
|
||||||
|
return [
|
||||||
|
[a, ab, ca],
|
||||||
|
[b, bc, ab],
|
||||||
|
[c, ca, bc],
|
||||||
|
[ab, bc, ca],
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const positions = new Float32Array(vertices.flat());
|
||||||
|
const indices = new Uint32Array(faces.flat());
|
||||||
|
return buildMesh(positions, indices, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include the unchanged incident faces when recomputing a boundary vertex normal.
|
||||||
|
export function updateNormals(mesh, moved) {
|
||||||
|
const { positions: p, indices: idx, normals: n, faceNormals: fn } = mesh;
|
||||||
|
const faces = [],
|
||||||
|
vertices = [];
|
||||||
|
const gen = ++mesh.generation;
|
||||||
|
if (moved) {
|
||||||
|
for (const v of moved)
|
||||||
|
for (const f of mesh.incident[v]) {
|
||||||
|
if (mesh.faceMarks[f] !== gen) {
|
||||||
|
mesh.faceMarks[f] = gen;
|
||||||
|
faces.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else for (let f = 0; f < idx.length / 3; f++) faces.push(f);
|
||||||
|
for (const f of faces) {
|
||||||
|
const a = idx[f * 3],
|
||||||
|
b = idx[f * 3 + 1],
|
||||||
|
c = idx[f * 3 + 2];
|
||||||
|
const ax = p[b * 3] - p[a * 3],
|
||||||
|
ay = p[b * 3 + 1] - p[a * 3 + 1],
|
||||||
|
az = p[b * 3 + 2] - p[a * 3 + 2];
|
||||||
|
const bx = p[c * 3] - p[a * 3],
|
||||||
|
by = p[c * 3 + 1] - p[a * 3 + 1],
|
||||||
|
bz = p[c * 3 + 2] - p[a * 3 + 2];
|
||||||
|
fn[f * 3] = ay * bz - az * by;
|
||||||
|
fn[f * 3 + 1] = az * bx - ax * bz;
|
||||||
|
fn[f * 3 + 2] = ax * by - ay * bx;
|
||||||
|
for (let j = 0; j < 3; j++) {
|
||||||
|
const v = idx[f * 3 + j];
|
||||||
|
if (mesh.vertexMarks[v] !== gen) {
|
||||||
|
mesh.vertexMarks[v] = gen;
|
||||||
|
vertices.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const v of vertices) {
|
||||||
|
let x = 0,
|
||||||
|
y = 0,
|
||||||
|
z = 0;
|
||||||
|
for (const f of mesh.incident[v]) {
|
||||||
|
x += fn[f * 3];
|
||||||
|
y += fn[f * 3 + 1];
|
||||||
|
z += fn[f * 3 + 2];
|
||||||
|
}
|
||||||
|
const length = Math.hypot(x, y, z) || 1;
|
||||||
|
n[v * 3] = x / length;
|
||||||
|
n[v * 3 + 1] = y / length;
|
||||||
|
n[v * 3 + 2] = z / length;
|
||||||
|
}
|
||||||
|
return { faces, vertices };
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { cameraBasis, clamp, clampZoom, dot } from "./math.js";
|
||||||
|
|
||||||
|
export const axes = [
|
||||||
|
{
|
||||||
|
name: "X",
|
||||||
|
vector: [1, 0, 0],
|
||||||
|
yaw: Math.PI / 2,
|
||||||
|
pitch: 0,
|
||||||
|
color: "#ed5265",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Y",
|
||||||
|
vector: [0, 1, 0],
|
||||||
|
yaw: 0,
|
||||||
|
pitch: Math.PI / 2,
|
||||||
|
color: "#91be38",
|
||||||
|
},
|
||||||
|
{ name: "Z", vector: [0, 0, 1], yaw: 0, pitch: 0, color: "#438cdd" },
|
||||||
|
{
|
||||||
|
name: "−X",
|
||||||
|
vector: [-1, 0, 0],
|
||||||
|
yaw: -Math.PI / 2,
|
||||||
|
pitch: 0,
|
||||||
|
color: "#ed5265",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "−Y",
|
||||||
|
vector: [0, -1, 0],
|
||||||
|
yaw: 0,
|
||||||
|
pitch: -Math.PI / 2,
|
||||||
|
color: "#91be38",
|
||||||
|
},
|
||||||
|
{ name: "−Z", vector: [0, 0, -1], yaw: Math.PI, pitch: 0, color: "#438cdd" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function dragView(camera, start, action, dx, dy, rect) {
|
||||||
|
if (action === "orbit") {
|
||||||
|
camera.yaw = start.yaw - dx * 0.008;
|
||||||
|
camera.pitch = clamp(start.pitch + dy * 0.008, -Math.PI / 2, Math.PI / 2);
|
||||||
|
} else if (action === "pan") {
|
||||||
|
const { right, up } = cameraBasis(start.yaw, start.pitch);
|
||||||
|
const scale = (2 * start.zoom) / rect.height;
|
||||||
|
camera.target = start.target.map(
|
||||||
|
(v, k) => v + (-dx * right[k] + dy * up[k]) * scale,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
camera.zoom = clampZoom(start.zoom * Math.exp(dy * 0.008), rect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOM-only navigation; the camera remains owned by the app, never by the gizmo.
|
||||||
|
export class Navigation {
|
||||||
|
constructor(root, viewport, host) {
|
||||||
|
this.root = root;
|
||||||
|
this.viewport = viewport;
|
||||||
|
this.host = host;
|
||||||
|
this.gizmo = root.querySelector("#orbit-gizmo");
|
||||||
|
this.lines = root.querySelectorAll(".axis-line");
|
||||||
|
this.buttons = axes.map((axis, i) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.className = "axis";
|
||||||
|
button.type = "button";
|
||||||
|
button.dataset.axis = i;
|
||||||
|
button.setAttribute("aria-label", `View from ${axis.name}`);
|
||||||
|
button.title = `View from ${axis.name}`;
|
||||||
|
button.textContent = i < 3 ? axis.name : "";
|
||||||
|
button.style.setProperty("--axis-color", axis.color);
|
||||||
|
button.addEventListener("click", (e) => {
|
||||||
|
if (e.detail === 0 && !this.busy && host.ready()) this.snap(i);
|
||||||
|
});
|
||||||
|
this.gizmo.append(button);
|
||||||
|
return button;
|
||||||
|
});
|
||||||
|
root.addEventListener("pointerdown", (e) => this.down(e));
|
||||||
|
root.addEventListener("pointermove", (e) => this.move(e));
|
||||||
|
root.addEventListener("pointerup", (e) => this.up(e));
|
||||||
|
for (const event of ["pointercancel", "lostpointercapture"])
|
||||||
|
root.addEventListener(event, (e) => {
|
||||||
|
if (this.active?.id === e.pointerId) this.cancel();
|
||||||
|
});
|
||||||
|
root.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||||
|
root.addEventListener("keydown", (e) => this.key(e));
|
||||||
|
window.addEventListener("blur", () => this.cancel());
|
||||||
|
window.addEventListener("resize", () => this.checkViewport());
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.hidden) this.cancel();
|
||||||
|
});
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
get busy() {
|
||||||
|
return !!this.active;
|
||||||
|
}
|
||||||
|
down(e) {
|
||||||
|
const control = e.target.closest("[data-nav]");
|
||||||
|
if (!control || this.busy || !this.host.ready() || e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
this.host.start();
|
||||||
|
const c = this.host.camera;
|
||||||
|
this.active = {
|
||||||
|
id: e.pointerId,
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
action: control.dataset.nav,
|
||||||
|
axis: e.target.closest("[data-axis]")?.dataset.axis,
|
||||||
|
moved: false,
|
||||||
|
rect: this.viewport.getBoundingClientRect(),
|
||||||
|
start: {
|
||||||
|
yaw: c.yaw,
|
||||||
|
pitch: c.pitch,
|
||||||
|
zoom: c.zoom,
|
||||||
|
target: c.target.slice(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.root.setPointerCapture(e.pointerId);
|
||||||
|
control.focus({ preventScroll: true });
|
||||||
|
this.host.changed();
|
||||||
|
}
|
||||||
|
checkViewport() {
|
||||||
|
if (!this.active) return true;
|
||||||
|
const rect = this.viewport.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
["left", "top", "width", "height"].some(
|
||||||
|
(k) => rect[k] !== this.active.rect[k],
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.cancel();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
move(e) {
|
||||||
|
if (this.active?.id !== e.pointerId || !this.checkViewport()) return;
|
||||||
|
const a = this.active,
|
||||||
|
dx = e.clientX - a.x,
|
||||||
|
dy = e.clientY - a.y;
|
||||||
|
a.moved ||= Math.hypot(dx, dy) >= 4;
|
||||||
|
if (a.moved) {
|
||||||
|
dragView(this.host.camera, a.start, a.action, dx, dy, a.rect);
|
||||||
|
this.host.invalidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
up(e) {
|
||||||
|
if (this.active?.id !== e.pointerId) return;
|
||||||
|
this.move(e);
|
||||||
|
const a = this.active;
|
||||||
|
if (!a) return;
|
||||||
|
this.active = null;
|
||||||
|
if (this.root.hasPointerCapture(a.id))
|
||||||
|
this.root.releasePointerCapture(a.id);
|
||||||
|
if (!a.moved && a.axis !== undefined) this.snap(Number(a.axis));
|
||||||
|
this.host.changed();
|
||||||
|
}
|
||||||
|
cancel() {
|
||||||
|
const a = this.active;
|
||||||
|
if (!a) return;
|
||||||
|
this.active = null;
|
||||||
|
Object.assign(this.host.camera, a.start);
|
||||||
|
if (this.root.hasPointerCapture(a.id))
|
||||||
|
this.root.releasePointerCapture(a.id);
|
||||||
|
this.host.invalidate();
|
||||||
|
this.host.changed();
|
||||||
|
}
|
||||||
|
snap(i) {
|
||||||
|
// Clicking the already-facing endpoint flips to the opposite view, making
|
||||||
|
// the occluded endpoint reachable when two axes project to the same point.
|
||||||
|
if (
|
||||||
|
dot(
|
||||||
|
cameraBasis(this.host.camera.yaw, this.host.camera.pitch).forward,
|
||||||
|
axes[i].vector,
|
||||||
|
) > 0.999999
|
||||||
|
)
|
||||||
|
i = (i + 3) % 6;
|
||||||
|
const { yaw, pitch } = axes[i];
|
||||||
|
Object.assign(this.host.camera, { yaw, pitch });
|
||||||
|
this.host.invalidate();
|
||||||
|
}
|
||||||
|
key(e) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
if (!this.busy) return;
|
||||||
|
this.cancel();
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const action = e.target.closest("[data-nav]")?.dataset.nav;
|
||||||
|
if (
|
||||||
|
!action ||
|
||||||
|
![
|
||||||
|
"ArrowLeft",
|
||||||
|
"ArrowRight",
|
||||||
|
"ArrowUp",
|
||||||
|
"ArrowDown",
|
||||||
|
"+",
|
||||||
|
"=",
|
||||||
|
"-",
|
||||||
|
].includes(e.key)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (this.busy || !this.host.ready()) return;
|
||||||
|
const dx = e.key === "ArrowLeft" ? -12 : e.key === "ArrowRight" ? 12 : 0;
|
||||||
|
const dy = ["ArrowUp", "+", "="].includes(e.key)
|
||||||
|
? -12
|
||||||
|
: ["ArrowDown", "-"].includes(e.key)
|
||||||
|
? 12
|
||||||
|
: 0;
|
||||||
|
const c = this.host.camera;
|
||||||
|
dragView(
|
||||||
|
c,
|
||||||
|
{ ...c, target: c.target.slice() },
|
||||||
|
action,
|
||||||
|
dx,
|
||||||
|
dy,
|
||||||
|
this.viewport.getBoundingClientRect(),
|
||||||
|
);
|
||||||
|
this.host.invalidate();
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
const basis = cameraBasis(this.host.camera.yaw, this.host.camera.pitch);
|
||||||
|
axes.forEach((axis, i) => {
|
||||||
|
const x = 52 + 34 * dot(axis.vector, basis.right),
|
||||||
|
y = 52 - 34 * dot(axis.vector, basis.up),
|
||||||
|
depth = dot(axis.vector, basis.forward);
|
||||||
|
const b = this.buttons[i];
|
||||||
|
b.style.left = `${x}px`;
|
||||||
|
b.style.top = `${y}px`;
|
||||||
|
b.style.zIndex = String(10 + Math.round(depth * 5));
|
||||||
|
b.classList.toggle("rear", i >= 3);
|
||||||
|
if (i < 3) {
|
||||||
|
this.lines[i].setAttribute("x2", x);
|
||||||
|
this.lines[i].setAttribute("y2", y);
|
||||||
|
this.lines[i].style.stroke = axis.color;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { Navigation, axes, dragView } from "./navigation.js";
|
||||||
|
import { cameraBasis, dot } from "./math.js";
|
||||||
|
const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-10, `${a} != ${b}`);
|
||||||
|
test("all six cardinal cameras and pole departures have orthonormal finite bases", () => {
|
||||||
|
for (const a of axes) {
|
||||||
|
const b = cameraBasis(a.yaw, a.pitch);
|
||||||
|
a.vector.forEach((v, k) => near(b.forward[k], v));
|
||||||
|
near(dot(b.right, b.right), 1);
|
||||||
|
near(dot(b.up, b.up), 1);
|
||||||
|
near(dot(b.right, b.up), 0);
|
||||||
|
const c = {};
|
||||||
|
dragView(c, { yaw: a.yaw, pitch: a.pitch }, "orbit", 7, -3, {
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
});
|
||||||
|
assert.ok(cameraBasis(c.yaw, c.pitch).up.every(Number.isFinite));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("drag uses snapshot totals, asymmetric pan, bounded zoom and no target drift", () => {
|
||||||
|
const start = { yaw: 0, pitch: 0, zoom: 2, target: [3, 4, 5] },
|
||||||
|
c = structuredClone(start),
|
||||||
|
rect = { width: 800, height: 400 };
|
||||||
|
dragView(c, start, "pan", 30, -10, rect);
|
||||||
|
assert.deepEqual(c.target, [2.7, 3.9, 5]);
|
||||||
|
dragView(c, start, "pan", 60, 20, rect);
|
||||||
|
assert.deepEqual(c.target, [2.4, 4.2, 5]);
|
||||||
|
dragView(c, start, "orbit", 25, -10, rect);
|
||||||
|
near(c.yaw, -0.2);
|
||||||
|
near(c.pitch, -0.08);
|
||||||
|
assert.deepEqual(c.target, [2.4, 4.2, 5]);
|
||||||
|
dragView(c, start, "zoom", 0, 50, rect);
|
||||||
|
near(c.zoom, 2 * Math.exp(0.4));
|
||||||
|
dragView(c, start, "zoom", 0, 1e5, rect);
|
||||||
|
near(c.zoom, 14);
|
||||||
|
});
|
||||||
|
function fixture() {
|
||||||
|
const n = Object.create(Navigation.prototype),
|
||||||
|
captures = new Set();
|
||||||
|
let ready = true;
|
||||||
|
const rect = { left: 0, top: 0, width: 800, height: 600 },
|
||||||
|
camera = { yaw: 0.3, pitch: 0.2, zoom: 2, target: [1, 2, 3] };
|
||||||
|
n.host = {
|
||||||
|
camera,
|
||||||
|
ready: () => ready,
|
||||||
|
start() {},
|
||||||
|
changed() {},
|
||||||
|
invalidate() {},
|
||||||
|
};
|
||||||
|
n.viewport = { getBoundingClientRect: () => ({ ...rect }) };
|
||||||
|
n.root = {
|
||||||
|
setPointerCapture: (id) => captures.add(id),
|
||||||
|
hasPointerCapture: (id) => captures.has(id),
|
||||||
|
releasePointerCapture: (id) => {
|
||||||
|
captures.delete(id);
|
||||||
|
if (n.active?.id === id) n.cancel();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const event = (x = 100, y = 100, id = 1, axis) => ({
|
||||||
|
pointerId: id,
|
||||||
|
clientX: x,
|
||||||
|
clientY: y,
|
||||||
|
button: 0,
|
||||||
|
preventDefault() {},
|
||||||
|
target: {
|
||||||
|
closest: (s) =>
|
||||||
|
s === "[data-nav]"
|
||||||
|
? { dataset: { nav: "orbit" }, focus() {} }
|
||||||
|
: axis === undefined
|
||||||
|
? null
|
||||||
|
: { dataset: { axis } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { n, camera, rect, captures, event, ready: (v) => (ready = v) };
|
||||||
|
}
|
||||||
|
test("navigation admission, single pointer, release coordinates, cancel and viewport rollback", () => {
|
||||||
|
const { n, camera, rect, captures, event, ready } = fixture(),
|
||||||
|
before = structuredClone(camera);
|
||||||
|
ready(false);
|
||||||
|
n.down(event());
|
||||||
|
assert.equal(n.busy, false);
|
||||||
|
ready(true);
|
||||||
|
n.down(event());
|
||||||
|
n.move(event(120, 110, 2));
|
||||||
|
assert.deepEqual(camera, before);
|
||||||
|
n.up(event(125, 115));
|
||||||
|
near(camera.yaw, 0.1);
|
||||||
|
near(camera.pitch, 0.32);
|
||||||
|
assert.equal(n.busy, false);
|
||||||
|
assert.equal(captures.size, 0);
|
||||||
|
const committed = structuredClone(camera);
|
||||||
|
n.down(event());
|
||||||
|
n.move(event(180, 140));
|
||||||
|
n.cancel();
|
||||||
|
assert.deepEqual(camera, committed);
|
||||||
|
n.down(event());
|
||||||
|
n.move(event(200, 160));
|
||||||
|
rect.width = 700;
|
||||||
|
n.up(event(210, 170));
|
||||||
|
assert.deepEqual(camera, committed);
|
||||||
|
assert.equal(n.busy, false);
|
||||||
|
});
|
||||||
|
test("Escape is consumed only by an active navigation gesture", () => {
|
||||||
|
const { n, camera, captures, event, ready } = fixture();
|
||||||
|
const before = structuredClone(camera);
|
||||||
|
let prevented = 0,
|
||||||
|
stopped = 0;
|
||||||
|
const escape = {
|
||||||
|
key: "Escape",
|
||||||
|
preventDefault: () => prevented++,
|
||||||
|
stopPropagation: () => stopped++,
|
||||||
|
};
|
||||||
|
n.key(escape);
|
||||||
|
ready(false);
|
||||||
|
n.key(escape);
|
||||||
|
assert.equal(prevented, 0);
|
||||||
|
assert.equal(stopped, 0);
|
||||||
|
assert.deepEqual(camera, before);
|
||||||
|
ready(true);
|
||||||
|
n.down(event());
|
||||||
|
n.move(event(180, 140));
|
||||||
|
n.key(escape);
|
||||||
|
n.up(event(200, 160));
|
||||||
|
assert.deepEqual(camera, before);
|
||||||
|
assert.equal(n.busy, false);
|
||||||
|
assert.equal(captures.size, 0);
|
||||||
|
assert.equal(prevented, 1);
|
||||||
|
assert.equal(stopped, 1);
|
||||||
|
});
|
||||||
|
test("axis clicks snap/flip without changing zoom or target; drags never snap", () => {
|
||||||
|
const { n, camera, event } = fixture();
|
||||||
|
n.down(event(100, 100, 1, "0"));
|
||||||
|
n.up(event(101, 101));
|
||||||
|
near(camera.yaw, Math.PI / 2);
|
||||||
|
near(camera.pitch, 0);
|
||||||
|
n.down(event(100, 100, 1, "0"));
|
||||||
|
n.up(event(100, 100));
|
||||||
|
near(camera.yaw, -Math.PI / 2);
|
||||||
|
assert.deepEqual(camera.target, [1, 2, 3]);
|
||||||
|
near(camera.zoom, 2);
|
||||||
|
n.down(event(100, 100, 1, "1"));
|
||||||
|
n.up(event(140, 115));
|
||||||
|
near(camera.yaw, -Math.PI / 2 - 0.32);
|
||||||
|
near(camera.pitch, 0.12);
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { preset, subdivide, decimate } from "./topology.js";
|
||||||
|
import {
|
||||||
|
hydrate,
|
||||||
|
snapshot,
|
||||||
|
ensureDocument,
|
||||||
|
splitPiece,
|
||||||
|
combinePieces,
|
||||||
|
} from "./document.js";
|
||||||
|
|
||||||
|
function bounds(mesh, piece) {
|
||||||
|
const min = [Infinity, Infinity, Infinity],
|
||||||
|
max = [-Infinity, -Infinity, -Infinity];
|
||||||
|
for (
|
||||||
|
let v = piece.vertexStart;
|
||||||
|
v < piece.vertexStart + piece.vertexCount;
|
||||||
|
v++
|
||||||
|
)
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
min[k] = Math.min(min[k], mesh.positions[v * 3 + k]);
|
||||||
|
max[k] = Math.max(max[k], mesh.positions[v * 3 + k]);
|
||||||
|
}
|
||||||
|
return { min, max };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs on owned document copies in a disposable worker. Cancel terminates it.
|
||||||
|
export function operate(data, action, activeId, options = {}) {
|
||||||
|
const mesh = hydrate(data);
|
||||||
|
const active = mesh.pieces.find((p) => p.id === activeId);
|
||||||
|
let items = mesh.pieces.map((piece) => ({ mesh, piece }));
|
||||||
|
let note = "";
|
||||||
|
if (action === "add") {
|
||||||
|
if (items.length >= 16)
|
||||||
|
throw new Error("Keep up to 16 pieces in a project.");
|
||||||
|
const added = ensureDocument(preset(options.preset));
|
||||||
|
const piece = added.pieces[0];
|
||||||
|
Object.assign(piece, {
|
||||||
|
id: options.id,
|
||||||
|
name: options.preset[0].toUpperCase() + options.preset.slice(1),
|
||||||
|
color: options.color,
|
||||||
|
});
|
||||||
|
const boxes = mesh.pieces.map((p) => bounds(mesh, p));
|
||||||
|
const shape = bounds(added, piece);
|
||||||
|
// Compact deterministic placement; keep new clay separate and within bounds.
|
||||||
|
const candidates = [];
|
||||||
|
if (active) {
|
||||||
|
const b = bounds(mesh, active);
|
||||||
|
candidates.push([
|
||||||
|
b.max[0] - shape.min[0] + 0.15,
|
||||||
|
(b.min[1] + b.max[1]) / 2,
|
||||||
|
(b.min[2] + b.max[2]) / 2,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
for (let y = 0; y <= 6; y += 2.5)
|
||||||
|
for (let x = 0; x <= 6; x += 2.5)
|
||||||
|
for (const sx of x ? [1, -1] : [1])
|
||||||
|
for (const sy of y ? [1, -1] : [1])
|
||||||
|
candidates.push([x * sx, y * sy, 0]);
|
||||||
|
const offset = candidates.find((o) => {
|
||||||
|
const lo = shape.min.map((x, k) => x + o[k]),
|
||||||
|
hi = shape.max.map((x, k) => x + o[k]);
|
||||||
|
return (
|
||||||
|
lo.every((x) => x >= -8) &&
|
||||||
|
hi.every((x) => x <= 8) &&
|
||||||
|
boxes.every((b) =>
|
||||||
|
lo.some((x, k) => x > b.max[k] + 0.1 || hi[k] < b.min[k] - 0.1),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (!offset)
|
||||||
|
throw new Error("No room for another piece. Delete a piece first.");
|
||||||
|
for (let i = 0; i < added.positions.length; i++)
|
||||||
|
added.positions[i] += offset[i % 3];
|
||||||
|
piece.symmetryX = Math.fround(offset[0]);
|
||||||
|
items.push({ mesh: added, piece });
|
||||||
|
activeId = piece.id;
|
||||||
|
} else {
|
||||||
|
if (!active) throw new Error("Select a piece of clay first.");
|
||||||
|
if (action === "delete")
|
||||||
|
items = items.filter((i) => i.piece.id !== activeId);
|
||||||
|
else if (action === "subdivide" || action === "decimate") {
|
||||||
|
const source = splitPiece(mesh, active);
|
||||||
|
const result =
|
||||||
|
action === "subdivide" ? subdivide(source) : decimate(source);
|
||||||
|
result.level = mesh.level;
|
||||||
|
const piece = {
|
||||||
|
...active,
|
||||||
|
vertexStart: 0,
|
||||||
|
indexStart: 0,
|
||||||
|
vertexCount: result.positions.length / 3,
|
||||||
|
indexCount: result.indices.length,
|
||||||
|
};
|
||||||
|
result.pieces = [piece];
|
||||||
|
items = items.map((i) =>
|
||||||
|
i.piece.id === activeId ? { mesh: result, piece } : i,
|
||||||
|
);
|
||||||
|
if (action === "decimate")
|
||||||
|
note = `${Math.round((1 - result.indices.length / source.indices.length) * 100)}% fewer triangles`;
|
||||||
|
} else throw new Error("Unknown clay operation.");
|
||||||
|
}
|
||||||
|
const combined = combinePieces(items);
|
||||||
|
combined.level = mesh.level;
|
||||||
|
return {
|
||||||
|
document: snapshot(combined),
|
||||||
|
activeId: combined.pieces.some((p) => p.id === activeId)
|
||||||
|
? activeId
|
||||||
|
: (combined.pieces[0]?.id ?? null),
|
||||||
|
note,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof self !== "undefined" && typeof document === "undefined")
|
||||||
|
self.onmessage = ({ data }) => {
|
||||||
|
try {
|
||||||
|
const result = operate(
|
||||||
|
data.document,
|
||||||
|
data.action,
|
||||||
|
data.activeId,
|
||||||
|
data.options,
|
||||||
|
);
|
||||||
|
self.postMessage(result, [
|
||||||
|
result.document.positions.buffer,
|
||||||
|
result.document.indices.buffer,
|
||||||
|
result.document.masks.buffer,
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
self.postMessage({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { sphere, buildMesh } from "./mesh.js";
|
||||||
|
import { snapshot, ensureDocument, splitPiece } from "./document.js";
|
||||||
|
import { operate } from "./operations.js";
|
||||||
|
import { Sculpt } from "./engine.js";
|
||||||
|
import { decimate } from "./topology.js";
|
||||||
|
|
||||||
|
test("add, color, mask, subdivision, decimation and delete round-trip mixed history", () => {
|
||||||
|
const engine = new Sculpt(sphere(4));
|
||||||
|
const original = snapshot(engine.mesh);
|
||||||
|
const add = operate(original, "add", engine.activeId, {
|
||||||
|
preset: "torus",
|
||||||
|
id: "ring",
|
||||||
|
color: [0.7, 0.4, 0.5],
|
||||||
|
});
|
||||||
|
engine.install(add.document, add.activeId);
|
||||||
|
assert.equal(engine.activeId, "ring");
|
||||||
|
assert.equal(engine.mesh.pieces.length, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
engine.mesh.positions.subarray(0, original.positions.length),
|
||||||
|
original.positions,
|
||||||
|
);
|
||||||
|
engine.setColor([0.3, 0.6, 0.7]);
|
||||||
|
engine.editMask(true);
|
||||||
|
const beforeSubdivision = snapshot(engine.mesh);
|
||||||
|
const divided = operate(beforeSubdivision, "subdivide", "ring");
|
||||||
|
engine.install(divided.document);
|
||||||
|
assert.equal(
|
||||||
|
engine.mesh.pieces[1].indexCount,
|
||||||
|
beforeSubdivision.pieces[1].indexCount * 4,
|
||||||
|
);
|
||||||
|
const masked = snapshot(engine.mesh);
|
||||||
|
const unchanged = operate(masked, "decimate", "ring");
|
||||||
|
assert.equal(engine.install(unchanged.document), false);
|
||||||
|
engine.editMask();
|
||||||
|
const simplified = operate(snapshot(engine.mesh), "decimate", "ring");
|
||||||
|
assert.ok(simplified.document.indices.length < engine.mesh.indices.length);
|
||||||
|
engine.install(simplified.document);
|
||||||
|
const removed = operate(snapshot(engine.mesh), "delete", "ring");
|
||||||
|
engine.install(removed.document, removed.activeId);
|
||||||
|
assert.equal(engine.mesh.pieces.length, 1);
|
||||||
|
const final = snapshot(engine.mesh),
|
||||||
|
finalState = engine.state;
|
||||||
|
while (engine.restore()) {}
|
||||||
|
assert.deepEqual(snapshot(engine.mesh), original);
|
||||||
|
assert.equal(engine.state, 0);
|
||||||
|
while (engine.restore(true)) {}
|
||||||
|
assert.deepEqual(snapshot(engine.mesh), final);
|
||||||
|
assert.equal(engine.state, finalState);
|
||||||
|
const empty = operate(snapshot(engine.mesh), "delete", engine.activeId);
|
||||||
|
engine.install(empty.document, empty.activeId);
|
||||||
|
assert.equal(engine.activeId, null);
|
||||||
|
assert.equal(engine.mesh.positions.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("piece sculpting and local symmetry cannot change neighboring clay", () => {
|
||||||
|
const first = ensureDocument(sphere(4));
|
||||||
|
const result = operate(snapshot(first), "add", "clay-1", {
|
||||||
|
preset: "sphere",
|
||||||
|
id: "second",
|
||||||
|
color: [0.5, 0.7, 0.4],
|
||||||
|
});
|
||||||
|
const e = new Sculpt();
|
||||||
|
e.install(result.document, "second");
|
||||||
|
const before = e.mesh.positions.slice(),
|
||||||
|
piece = e.mesh.pieces[1];
|
||||||
|
assert.equal(piece.symmetryX, Math.fround(2.15));
|
||||||
|
const seam = [];
|
||||||
|
for (
|
||||||
|
let v = piece.vertexStart;
|
||||||
|
v < piece.vertexStart + piece.vertexCount;
|
||||||
|
v++
|
||||||
|
)
|
||||||
|
if (before[v * 3] === piece.symmetryX) seam.push(v);
|
||||||
|
assert.ok(seam.length > 0);
|
||||||
|
const hit = e.bvh.raycast([piece.symmetryX + 0.2, 0, 5], [0, 0, -1]);
|
||||||
|
e.begin({ brush: "draw", radius: 0.5, symmetry: true });
|
||||||
|
e.dab(hit);
|
||||||
|
e.end();
|
||||||
|
for (const v of seam) assert.equal(e.mesh.positions[v * 3], piece.symmetryX);
|
||||||
|
assert.deepEqual(
|
||||||
|
e.mesh.positions.subarray(0, first.positions.length),
|
||||||
|
before.subarray(0, first.positions.length),
|
||||||
|
);
|
||||||
|
assert.notDeepEqual(
|
||||||
|
splitPiece(e.mesh, piece).positions,
|
||||||
|
before.subarray(piece.vertexStart * 3),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decimator keeps tetrahedron components and open boundary fan intact", () => {
|
||||||
|
const tetra = [1, 1, 1, -1, -1, 1, -1, 1, -1, 1, -1, -1];
|
||||||
|
const faces = [0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3];
|
||||||
|
const mesh = buildMesh(
|
||||||
|
[...tetra, ...tetra.map((x, i) => x + (i % 3 === 0 ? 4 : 0))],
|
||||||
|
[...faces, ...faces.map((i) => i + 4)],
|
||||||
|
);
|
||||||
|
const result = decimate(mesh);
|
||||||
|
assert.deepEqual(result.indices, mesh.indices);
|
||||||
|
assert.deepEqual(result.positions, mesh.positions);
|
||||||
|
const p = [0, 0, 0],
|
||||||
|
f = [];
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
p.push(Math.cos((i * Math.PI) / 3), Math.sin((i * Math.PI) / 3), 0);
|
||||||
|
f.push(0, i + 1, ((i + 1) % 6) + 1);
|
||||||
|
}
|
||||||
|
const fan = buildMesh(p, f),
|
||||||
|
reduced = decimate(fan);
|
||||||
|
assert.deepEqual(reduced.positions, fan.positions);
|
||||||
|
assert.deepEqual(reduced.indices, fan.indices);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every geometry brush respects protection; mask erase and cancellation are exact", () => {
|
||||||
|
const e = new Sculpt(sphere(4));
|
||||||
|
const hit = e.bvh.raycast([0.2, 0.1, 5], [0, 0, -1]);
|
||||||
|
const positions = e.mesh.positions.slice(),
|
||||||
|
normals = e.mesh.normals.slice();
|
||||||
|
e.editMask(true);
|
||||||
|
for (const brush of ["draw", "inflate", "smooth", "flatten", "grab"]) {
|
||||||
|
e.begin({ brush, strength: 1, radius: 0.5 });
|
||||||
|
e.dab(hit, 1, [0.3, -0.2, 0.1]);
|
||||||
|
e.end();
|
||||||
|
assert.deepEqual(e.mesh.positions, positions, brush);
|
||||||
|
}
|
||||||
|
const protectedMasks = e.mesh.masks.slice();
|
||||||
|
const geometryChange = e.changed;
|
||||||
|
e.changed = () => {
|
||||||
|
throw new Error("Mask edit performed geometry maintenance");
|
||||||
|
};
|
||||||
|
e.begin({ brush: "mask", invert: true, radius: 0.5 });
|
||||||
|
e.dab(hit);
|
||||||
|
e.end(true);
|
||||||
|
assert.deepEqual(e.mesh.masks, protectedMasks);
|
||||||
|
e.begin({ brush: "mask", invert: true, radius: 0.5 });
|
||||||
|
e.dab(hit);
|
||||||
|
e.end();
|
||||||
|
assert.ok(e.mesh.masks.some((x) => x < 1));
|
||||||
|
const erased = e.mesh.masks.slice();
|
||||||
|
e.restore();
|
||||||
|
assert.deepEqual(e.mesh.masks, protectedMasks);
|
||||||
|
e.restore(true);
|
||||||
|
assert.deepEqual(e.mesh.masks, erased);
|
||||||
|
e.editMask();
|
||||||
|
assert.ok(e.mesh.masks.every((x) => x === 0));
|
||||||
|
e.restore();
|
||||||
|
assert.deepEqual(e.mesh.masks, erased);
|
||||||
|
assert.deepEqual(e.mesh.normals, normals);
|
||||||
|
assert.deepEqual(e.mesh.positions, positions);
|
||||||
|
e.changed = geometryChange;
|
||||||
|
});
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
// Explicit browser integration check on a fresh /sculpt page; no permanent hooks.
|
||||||
|
import { Renderer } from "./renderer.js";
|
||||||
|
import { snapshot, loadDocument, saveDocument } from "./document.js";
|
||||||
|
import { cameraBasis, dot, sub } from "./math.js";
|
||||||
|
|
||||||
|
export async function check() {
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const assert = (ok, message) => {
|
||||||
|
if (!ok) throw new Error(message);
|
||||||
|
};
|
||||||
|
const wait = async (fn) => {
|
||||||
|
for (let i = 0; i < 600; i++) {
|
||||||
|
if (fn()) return;
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
throw new Error("Timed out: " + fn);
|
||||||
|
};
|
||||||
|
const ready = () =>
|
||||||
|
$("loading").hidden &&
|
||||||
|
$("clay-progress").hidden &&
|
||||||
|
!$("new").disabled &&
|
||||||
|
$("error").hidden;
|
||||||
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
const publish = Renderer.prototype.publish,
|
||||||
|
capture = $("viewport").setPointerCapture;
|
||||||
|
let engine, camera, processor;
|
||||||
|
Renderer.prototype.publish = function (e, c) {
|
||||||
|
engine = e;
|
||||||
|
camera = c;
|
||||||
|
processor = this.processor;
|
||||||
|
return publish.call(this, e, c);
|
||||||
|
};
|
||||||
|
$("viewport").setPointerCapture = () => {};
|
||||||
|
const command = async (id) => {
|
||||||
|
$(id).click();
|
||||||
|
await wait(ready);
|
||||||
|
await sleep(120);
|
||||||
|
};
|
||||||
|
const stroke = async (brush) => {
|
||||||
|
document.querySelector(`[data-brush="${brush}"]`).click();
|
||||||
|
const p = engine.mesh.pieces.find((p) => p.id === engine.activeId);
|
||||||
|
const center = [0, 0, 0];
|
||||||
|
for (let v = p.vertexStart; v < p.vertexStart + p.vertexCount; v++)
|
||||||
|
for (let k = 0; k < 3; k++)
|
||||||
|
center[k] += engine.mesh.positions[v * 3 + k] / p.vertexCount;
|
||||||
|
center[0] += 0.15;
|
||||||
|
const rect = $("viewport").getBoundingClientRect(),
|
||||||
|
basis = cameraBasis(camera.yaw, camera.pitch),
|
||||||
|
d = sub(center, camera.target);
|
||||||
|
const x =
|
||||||
|
rect.left +
|
||||||
|
rect.width / 2 +
|
||||||
|
(dot(d, basis.right) * rect.height) / (2 * camera.zoom);
|
||||||
|
const y =
|
||||||
|
rect.top +
|
||||||
|
rect.height / 2 -
|
||||||
|
(dot(d, basis.up) * rect.height) / (2 * camera.zoom);
|
||||||
|
const pointer = (type, dx = 0) =>
|
||||||
|
$("viewport")
|
||||||
|
.querySelector("canvas")
|
||||||
|
.dispatchEvent(
|
||||||
|
new PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
pointerId: 9,
|
||||||
|
pointerType: "pen",
|
||||||
|
button: 0,
|
||||||
|
buttons: type === "pointerup" ? 0 : 1,
|
||||||
|
pressure: 0.8,
|
||||||
|
clientX: x + dx,
|
||||||
|
clientY: y,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
pointer("pointerdown");
|
||||||
|
pointer("pointermove", 14);
|
||||||
|
await sleep(150);
|
||||||
|
pointer("pointerup", 14);
|
||||||
|
await wait(ready);
|
||||||
|
await sleep(100);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await wait(ready);
|
||||||
|
$("frame").click();
|
||||||
|
await wait(() => !!engine);
|
||||||
|
const initial = snapshot(engine.mesh);
|
||||||
|
await command("delete-piece");
|
||||||
|
assert(
|
||||||
|
engine.mesh.pieces.length === 0 && !$("empty-clay").hidden,
|
||||||
|
"Delete last piece failed",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
loadDocument(saveDocument(engine.mesh)).pieces.length === 0,
|
||||||
|
"Empty roundtrip failed",
|
||||||
|
);
|
||||||
|
await command("undo");
|
||||||
|
assert(same(snapshot(engine.mesh), initial), "Delete undo lost clay");
|
||||||
|
await stroke("mask");
|
||||||
|
assert(
|
||||||
|
engine.mesh.masks.some((x) => x > 0),
|
||||||
|
"Worker mask brush did not paint",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
same(engine.mesh.positions, initial.positions),
|
||||||
|
"Mask moved geometry",
|
||||||
|
);
|
||||||
|
await command("undo");
|
||||||
|
assert(
|
||||||
|
engine.mesh.masks.every((x) => x === 0),
|
||||||
|
"Mask undo not exact",
|
||||||
|
);
|
||||||
|
for (const name of ["sphere", "cube", "cylinder", "cone", "torus"]) {
|
||||||
|
const count = engine.mesh.pieces.length;
|
||||||
|
document.querySelector(`[data-preset="${name}"]`).click();
|
||||||
|
await wait(ready);
|
||||||
|
await sleep(120);
|
||||||
|
assert(engine.mesh.pieces.length === count + 1, `Add ${name} failed`);
|
||||||
|
assert(
|
||||||
|
engine.activeId === engine.mesh.pieces.at(-1).id,
|
||||||
|
"Addition was not selected",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
new Set(engine.mesh.pieces.slice(1).map((p) => JSON.stringify(p.color)))
|
||||||
|
.size === 5,
|
||||||
|
"Pastels were not individually chosen",
|
||||||
|
);
|
||||||
|
const cube = engine.mesh.pieces.find((p) => p.name === "Cube");
|
||||||
|
document.querySelector(`[data-piece="${cube.id}"]`).click();
|
||||||
|
$("piece-color").value = "#99bbee";
|
||||||
|
$("piece-color").dispatchEvent(new Event("change"));
|
||||||
|
const colored = snapshot(engine.mesh);
|
||||||
|
await command("undo");
|
||||||
|
assert(
|
||||||
|
!same(
|
||||||
|
engine.mesh.pieces.find((p) => p.id === cube.id).color,
|
||||||
|
colored.pieces.find((p) => p.id === cube.id).color,
|
||||||
|
),
|
||||||
|
"Color undo failed",
|
||||||
|
);
|
||||||
|
await command("redo");
|
||||||
|
assert(same(snapshot(engine.mesh), colored), "Color redo failed");
|
||||||
|
const count = cube.indexCount;
|
||||||
|
await command("subdivide");
|
||||||
|
assert(
|
||||||
|
engine.mesh.pieces.find((p) => p.id === cube.id).indexCount === count * 4,
|
||||||
|
"Subdivision did not split triangles",
|
||||||
|
);
|
||||||
|
const subdivided = snapshot(engine.mesh);
|
||||||
|
await stroke("draw");
|
||||||
|
assert(
|
||||||
|
!same(engine.mesh.positions, subdivided.positions),
|
||||||
|
"Cannot sculpt replacement topology",
|
||||||
|
);
|
||||||
|
await command("undo");
|
||||||
|
assert(
|
||||||
|
same(snapshot(engine.mesh), subdivided),
|
||||||
|
"Post-topology stroke undo failed",
|
||||||
|
);
|
||||||
|
await command("invert-mask");
|
||||||
|
const masked = snapshot(engine.mesh),
|
||||||
|
state = engine.state;
|
||||||
|
await stroke("draw");
|
||||||
|
assert(same(snapshot(engine.mesh), masked), "Fully masked piece moved");
|
||||||
|
await command("decimate");
|
||||||
|
assert(
|
||||||
|
engine.state === state && same(snapshot(engine.mesh), masked),
|
||||||
|
"Masked decimation was not a no-op",
|
||||||
|
);
|
||||||
|
await command("clear-mask");
|
||||||
|
await command("decimate");
|
||||||
|
assert(
|
||||||
|
engine.mesh.pieces.find((p) => p.id === cube.id).indexCount < count * 4,
|
||||||
|
"Decimation did not reduce triangles",
|
||||||
|
);
|
||||||
|
const beforeCancel = snapshot(engine.mesh),
|
||||||
|
beforeState = engine.state;
|
||||||
|
$("subdivide").click();
|
||||||
|
$("cancel-operation").click();
|
||||||
|
await sleep(300);
|
||||||
|
assert(
|
||||||
|
ready() &&
|
||||||
|
engine.state === beforeState &&
|
||||||
|
same(snapshot(engine.mesh), beforeCancel),
|
||||||
|
"Canceled topology changed clay",
|
||||||
|
);
|
||||||
|
const loaded = loadDocument(saveDocument(engine.mesh));
|
||||||
|
assert(
|
||||||
|
same(snapshot(loaded), snapshot(engine.mesh)),
|
||||||
|
"Mixed scene project roundtrip failed",
|
||||||
|
);
|
||||||
|
// Hold real worker results to exercise races without production hooks.
|
||||||
|
const NativeWorker = window.Worker,
|
||||||
|
create = Renderer.create;
|
||||||
|
const jobs = [];
|
||||||
|
window.Worker = class {
|
||||||
|
constructor(url, options) {
|
||||||
|
if (!String(url).endsWith("/operations.js"))
|
||||||
|
return new NativeWorker(url, options);
|
||||||
|
this.inner = new NativeWorker(url, options);
|
||||||
|
this.inner.onmessage = (event) => {
|
||||||
|
this.packet = event;
|
||||||
|
};
|
||||||
|
this.inner.onerror = (event) => this.onerror?.(event);
|
||||||
|
jobs.push(this);
|
||||||
|
}
|
||||||
|
postMessage(...args) {
|
||||||
|
this.inner.postMessage(...args);
|
||||||
|
}
|
||||||
|
terminate() {
|
||||||
|
this.terminated = true;
|
||||||
|
this.inner.terminate();
|
||||||
|
}
|
||||||
|
deliver() {
|
||||||
|
this.onmessage(this.packet);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const pending = async () => {
|
||||||
|
$("delete-piece").click();
|
||||||
|
const job = jobs.at(-1);
|
||||||
|
await wait(() => !!job.packet);
|
||||||
|
return job;
|
||||||
|
};
|
||||||
|
const baseline = saveDocument(engine.mesh),
|
||||||
|
baselineState = engine.state;
|
||||||
|
const unchanged = () =>
|
||||||
|
saveDocument(engine.mesh) === baseline && engine.state === baselineState;
|
||||||
|
try {
|
||||||
|
const a = await pending();
|
||||||
|
$("cancel-operation").click();
|
||||||
|
const b = await pending();
|
||||||
|
const status = $("status").textContent;
|
||||||
|
a.deliver();
|
||||||
|
a.onerror();
|
||||||
|
assert(
|
||||||
|
!$("clay-progress").hidden &&
|
||||||
|
unchanged() &&
|
||||||
|
$("status").textContent === status,
|
||||||
|
"Old operation affected new job",
|
||||||
|
);
|
||||||
|
$("cancel-operation").click();
|
||||||
|
const c = await pending();
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
assert(!c.terminated, "Same-size resize canceled operation");
|
||||||
|
$("viewport").style.width = "90%";
|
||||||
|
c.deliver(); // Deliver before ResizeObserver: receipt must detect stale size.
|
||||||
|
assert(c.terminated && unchanged(), "Stale viewport result installed");
|
||||||
|
await wait(ready);
|
||||||
|
$("viewport").style.width = "";
|
||||||
|
await sleep(350);
|
||||||
|
await wait(ready);
|
||||||
|
const d = await pending();
|
||||||
|
d.onmessageerror();
|
||||||
|
assert(
|
||||||
|
d.terminated && unchanged() && $("clay-progress").hidden,
|
||||||
|
"Message error stranded operation",
|
||||||
|
);
|
||||||
|
$("pieces-toggle").click();
|
||||||
|
const e = await pending();
|
||||||
|
$("delete-piece").dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
e.terminated && $("pieces-panel").hidden && unchanged(),
|
||||||
|
"Panel Escape did not cancel operation",
|
||||||
|
);
|
||||||
|
const f = await pending();
|
||||||
|
processor.fail(new Error("Synthetic failure during topology"));
|
||||||
|
f.deliver();
|
||||||
|
await sleep(350);
|
||||||
|
assert(
|
||||||
|
f.terminated &&
|
||||||
|
!$("error").hidden &&
|
||||||
|
!$("save").disabled &&
|
||||||
|
unchanged(),
|
||||||
|
"Canvas failure allowed pending topology commit/restart",
|
||||||
|
);
|
||||||
|
await command("retry");
|
||||||
|
const g = await pending();
|
||||||
|
Renderer.create = async () => {
|
||||||
|
throw new Error("Synthetic post-commit startup failure");
|
||||||
|
};
|
||||||
|
g.deliver();
|
||||||
|
await wait(() => !$("error").hidden && !$("new").disabled);
|
||||||
|
assert(
|
||||||
|
engine.state !== baselineState && !$("save").disabled,
|
||||||
|
"Post-commit failure lost document or Save",
|
||||||
|
);
|
||||||
|
const committed = saveDocument(engine.mesh);
|
||||||
|
assert(
|
||||||
|
loadDocument(committed).pieces.length ===
|
||||||
|
loadDocument(baseline).pieces.length - 1,
|
||||||
|
"Saved document missed committed deletion",
|
||||||
|
);
|
||||||
|
Renderer.create = create;
|
||||||
|
await command("undo");
|
||||||
|
assert(
|
||||||
|
unchanged(),
|
||||||
|
"Undo after renderer failure did not restore document",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
window.Worker = NativeWorker;
|
||||||
|
Renderer.create = create;
|
||||||
|
for (const job of jobs) job.terminate();
|
||||||
|
$("viewport").style.width = "";
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
passed: 21,
|
||||||
|
checks:
|
||||||
|
"preset add/selection/pastels, delete last/undo, mask worker/undo, color history, subdivision, replacement-topology sculpt/undo, full protection, masked decimation no-op, clear, decimation, cancellation, mixed project roundtrip; stale jobs, same-size resize, changed-size receipt, message error, panel Escape, canvas failure, post-commit failure/save/undo",
|
||||||
|
physicalTabletVerified: false,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
Renderer.prototype.publish = publish;
|
||||||
|
$("viewport").setPointerCapture = capture;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { YawnCore } from "@yawn/core";
|
||||||
|
import { cameraBasis } from "./math.js";
|
||||||
|
|
||||||
|
function wire(v) {
|
||||||
|
if (Array.isArray(v)) return `(array ${v.map(wire).join(" ")})`;
|
||||||
|
if (v && typeof v === "object")
|
||||||
|
return `(object ${Object.entries(v)
|
||||||
|
.map(([k, x]) => `(field ${JSON.stringify(k)} ${wire(x)})`)
|
||||||
|
.join(" ")})`;
|
||||||
|
return JSON.stringify(v);
|
||||||
|
}
|
||||||
|
const shader = `
|
||||||
|
struct Camera { right:vec4f, up:vec4f, forward:vec4f, center:vec4f, color:vec4f }
|
||||||
|
@group(0) @binding(0) var<uniform> camera:Camera;
|
||||||
|
struct Out { @builtin(position) position:vec4f, @location(0) normal:vec3f, @location(1) mask:f32, @location(2) material:vec3f }
|
||||||
|
@vertex fn vertex(@location(0) p:vec3f, @location(1) mask:f32, @location(2) n:vec3f, @location(3) material:vec4f)->Out {
|
||||||
|
let d=p-camera.center.xyz;
|
||||||
|
var o:Out;
|
||||||
|
o.position=vec4f(dot(d,camera.right.xyz)/camera.right.w,dot(d,camera.up.xyz)/camera.up.w,0.5-dot(d,camera.forward.xyz)/32.0,1.0);
|
||||||
|
o.normal=vec3f(dot(n,camera.right.xyz),dot(n,camera.up.xyz),dot(n,camera.forward.xyz));o.mask=mask;o.material=material.xyz;return o;
|
||||||
|
}
|
||||||
|
@fragment fn fragment(o:Out)->@location(0) vec4f {
|
||||||
|
let n=normalize(o.normal);
|
||||||
|
let key=max(dot(n,normalize(vec3f(-0.5,0.8,1.0))),0.0);
|
||||||
|
let fill=max(dot(n,normalize(vec3f(0.8,0.0,0.4))),0.0);
|
||||||
|
let rim=pow(1.0-max(n.z,0.0),3.0);
|
||||||
|
let spec=pow(max(dot(n,normalize(vec3f(-0.3,0.45,1.0))),0.0),48.0);
|
||||||
|
let base=mix(o.material,o.material*0.22,clamp(o.mask,0.0,1.0));
|
||||||
|
let color=base*(0.24+key*0.65+fill*0.16)+vec3f(0.12,0.14,0.16)*rim+vec3f(0.12)*spec;
|
||||||
|
return vec4f(pow(color,vec3f(1.0/2.2)),1.0);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
export class Renderer {
|
||||||
|
static async create(canvas, mesh, camera, quality = "balanced") {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const pixels =
|
||||||
|
quality === "economy" ? 600000 : quality === "detail" ? 2000000 : 1000000;
|
||||||
|
const scale = Math.min(
|
||||||
|
devicePixelRatio,
|
||||||
|
quality === "economy" ? 1 : quality === "detail" ? 2 : 1.5,
|
||||||
|
Math.sqrt(pixels / (rect.width * rect.height)),
|
||||||
|
);
|
||||||
|
canvas.width = Math.max(1, Math.round(rect.width * scale));
|
||||||
|
canvas.height = Math.max(1, Math.round(rect.height * scale));
|
||||||
|
const r = new Renderer();
|
||||||
|
r.canvas = canvas;
|
||||||
|
r.mesh = mesh;
|
||||||
|
const bytes =
|
||||||
|
(mesh.positions.length / 3) * 32 +
|
||||||
|
Math.ceil(mesh.indices.length / 4) * 16 +
|
||||||
|
80 +
|
||||||
|
64 +
|
||||||
|
256;
|
||||||
|
if (bytes > 8 * 1024 * 1024)
|
||||||
|
throw new Error("Mesh exceeds the render arena");
|
||||||
|
r.core = new YawnCore(canvas, { arenaBytes: 8 * 1024 * 1024 });
|
||||||
|
let timedOut = false;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
r.dispose();
|
||||||
|
}, 20000);
|
||||||
|
try {
|
||||||
|
await r.core.ready;
|
||||||
|
[r.vertices, r.indices, r.camera, r.sample] =
|
||||||
|
await r.core.createRowsBatch([
|
||||||
|
{
|
||||||
|
name: "sculptVertices",
|
||||||
|
rows: Math.max(1, mesh.positions.length / 3),
|
||||||
|
stride: 32,
|
||||||
|
format: "f32",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sculptIndices",
|
||||||
|
rows: Math.max(1, Math.ceil(mesh.indices.length / 4)),
|
||||||
|
stride: 16,
|
||||||
|
format: "u32",
|
||||||
|
},
|
||||||
|
{ name: "sculptCamera", rows: 1, stride: 80, format: "f32" },
|
||||||
|
{ name: "sculptSample", rows: 1, stride: 64, format: "i32" },
|
||||||
|
]);
|
||||||
|
r.sample.view.fill(0);
|
||||||
|
r.indices.view.set(mesh.indices);
|
||||||
|
r.writeVertices(0, mesh.positions.length / 3);
|
||||||
|
r.writeCamera(camera);
|
||||||
|
const graph = {
|
||||||
|
id: "sculpt",
|
||||||
|
resources: {
|
||||||
|
buffers: [
|
||||||
|
{
|
||||||
|
id: "vertices",
|
||||||
|
array: "sculptVertices",
|
||||||
|
usage: ["vertex"],
|
||||||
|
sync: "manual",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "indices",
|
||||||
|
array: "sculptIndices",
|
||||||
|
usage: ["index"],
|
||||||
|
sync: "manual",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "camera",
|
||||||
|
array: "sculptCamera",
|
||||||
|
usage: ["uniform"],
|
||||||
|
sync: "manual",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
textures: [
|
||||||
|
{
|
||||||
|
id: "depth",
|
||||||
|
format: "depth24plus",
|
||||||
|
size: ["canvas", "canvas"],
|
||||||
|
usage: ["render"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
pipelines: {
|
||||||
|
render: [
|
||||||
|
{
|
||||||
|
id: "clay",
|
||||||
|
code: shader,
|
||||||
|
vertex: {
|
||||||
|
buffers: [
|
||||||
|
{
|
||||||
|
arrayStride: 32,
|
||||||
|
attributes: [
|
||||||
|
{ format: "float32x3", offset: 0, shaderLocation: 0 },
|
||||||
|
{ format: "float32", offset: 12, shaderLocation: 1 },
|
||||||
|
{ format: "float32x3", offset: 16, shaderLocation: 2 },
|
||||||
|
{ format: "unorm8x4", offset: 28, shaderLocation: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fragment: { targets: [{ format: "canvas" }] },
|
||||||
|
primitive: {
|
||||||
|
topology: "triangle-list",
|
||||||
|
frontFace: "ccw",
|
||||||
|
cullMode: "back",
|
||||||
|
conservative: false,
|
||||||
|
},
|
||||||
|
depthStencil: {
|
||||||
|
format: "depth24plus",
|
||||||
|
depth_write_enabled: true,
|
||||||
|
depth_compare: "less",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
passes: [
|
||||||
|
{
|
||||||
|
id: "sculpt",
|
||||||
|
type: "render",
|
||||||
|
pipeline: "clay",
|
||||||
|
bindings: [{ binding: 0, resource: "camera" }],
|
||||||
|
color: [{ resource: "canvas", clear: [0.12, 0.14, 0.17, 1] }],
|
||||||
|
depth: { resource: "depth" },
|
||||||
|
vertexBuffers: [{ resource: "vertices" }],
|
||||||
|
indexBuffer: { resource: "indices" },
|
||||||
|
draw: { indices: mesh.indices.length, instances: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await r.core.switchLoadout(
|
||||||
|
await r.core.compileGraph(`(yawn-graph 1 ${wire(graph)})`),
|
||||||
|
);
|
||||||
|
await r.core.setFps(quality === "economy" ? 30 : 60);
|
||||||
|
await r.core.play();
|
||||||
|
return r;
|
||||||
|
} catch (error) {
|
||||||
|
r.dispose();
|
||||||
|
if (timedOut) throw new Error("Renderer initialization timed out");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeVertices(first, count) {
|
||||||
|
const p = this.mesh.positions,
|
||||||
|
n = this.mesh.normals,
|
||||||
|
v = this.vertices.view,
|
||||||
|
bytes = new Uint8Array(v.buffer, v.byteOffset, v.byteLength),
|
||||||
|
pieces = this.mesh.pieces ?? [];
|
||||||
|
let pieceIndex = pieces.findIndex(
|
||||||
|
(piece) => first < piece.vertexStart + piece.vertexCount,
|
||||||
|
);
|
||||||
|
for (let i = first; i < first + count; i++) {
|
||||||
|
const a = i * 3,
|
||||||
|
b = i * 8;
|
||||||
|
v[b] = p[a];
|
||||||
|
v[b + 1] = p[a + 1];
|
||||||
|
v[b + 2] = p[a + 2];
|
||||||
|
v[b + 3] = this.mesh.masks?.[i] ?? 0;
|
||||||
|
v[b + 4] = n[a];
|
||||||
|
v[b + 5] = n[a + 1];
|
||||||
|
v[b + 6] = n[a + 2];
|
||||||
|
while (
|
||||||
|
pieceIndex >= 0 &&
|
||||||
|
pieceIndex < pieces.length - 1 &&
|
||||||
|
i >= pieces[pieceIndex].vertexStart + pieces[pieceIndex].vertexCount
|
||||||
|
)
|
||||||
|
pieceIndex++;
|
||||||
|
const piece = pieces[pieceIndex];
|
||||||
|
const color = piece?.color ?? [0.5, 0.3, 0.2];
|
||||||
|
for (let k = 0; k < 3; k++)
|
||||||
|
bytes[b * 4 + 28 + k] = Math.round(color[k] * 255);
|
||||||
|
bytes[b * 4 + 31] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeCamera(camera) {
|
||||||
|
const { right, up, forward } = cameraBasis(camera.yaw, camera.pitch);
|
||||||
|
const aspect = this.canvas.width / this.canvas.height;
|
||||||
|
this.camera.view.set([
|
||||||
|
...right,
|
||||||
|
camera.zoom * aspect,
|
||||||
|
...up,
|
||||||
|
camera.zoom,
|
||||||
|
...forward,
|
||||||
|
0,
|
||||||
|
...camera.target,
|
||||||
|
0,
|
||||||
|
...camera.color,
|
||||||
|
0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
// One acknowledged publication at a time: shared staging bytes are immutable in flight.
|
||||||
|
async publish(engine, camera) {
|
||||||
|
if (this.busy || this.disposed) return false;
|
||||||
|
this.busy = true;
|
||||||
|
const timeout = setTimeout(() => this.dispose(), 10000);
|
||||||
|
try {
|
||||||
|
let first = Infinity,
|
||||||
|
last = -1;
|
||||||
|
for (const v of engine.dirty) {
|
||||||
|
first = Math.min(first, v);
|
||||||
|
last = Math.max(last, v);
|
||||||
|
}
|
||||||
|
engine.dirty.clear();
|
||||||
|
if (last >= first) {
|
||||||
|
this.writeVertices(first, last - first + 1);
|
||||||
|
await this.core.syncRows("sculptVertices", first, last - first + 1);
|
||||||
|
}
|
||||||
|
if (this.disposed) return false;
|
||||||
|
this.writeCamera(camera);
|
||||||
|
await this.core.syncRows("sculptCamera", 0, 1);
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
this.busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dispose() {
|
||||||
|
this.disposed = true;
|
||||||
|
this.processor?.dispose();
|
||||||
|
this.core?.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { Mailbox } from "./mailbox.js";
|
||||||
|
import { pressureValue } from "./engine.js";
|
||||||
|
|
||||||
|
export class StrokeClient {
|
||||||
|
constructor(row, mesh, onError) {
|
||||||
|
this.mailbox = new Mailbox(row.share());
|
||||||
|
this.worker = new Worker(new URL("./stroke-worker.js", import.meta.url), {
|
||||||
|
type: "module",
|
||||||
|
name: "sculpt-samples",
|
||||||
|
});
|
||||||
|
this.nextId = 0;
|
||||||
|
this.onError = onError;
|
||||||
|
this.ready = new Promise((resolve, reject) => {
|
||||||
|
this.resolveReady = resolve;
|
||||||
|
this.rejectReady = reject;
|
||||||
|
});
|
||||||
|
this.timer = setTimeout(
|
||||||
|
() => this.fail(new Error("Sculpt worker initialization timed out")),
|
||||||
|
20000,
|
||||||
|
);
|
||||||
|
this.worker.onmessage = ({ data }) => this.message(data);
|
||||||
|
this.worker.onerror = () => this.fail(new Error("Sculpt worker stopped"));
|
||||||
|
this.worker.onmessageerror = () =>
|
||||||
|
this.fail(new Error("Sculpt worker message failed"));
|
||||||
|
const topology =
|
||||||
|
typeof mesh === "number"
|
||||||
|
? { level: mesh }
|
||||||
|
: {
|
||||||
|
mesh: {
|
||||||
|
level: mesh.level,
|
||||||
|
positions: mesh.positions.slice(),
|
||||||
|
indices: mesh.indices.slice(),
|
||||||
|
masks: mesh.masks.slice(),
|
||||||
|
pieces: structuredClone(mesh.pieces),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.worker.postMessage({ type: "init", row: row.share(), ...topology });
|
||||||
|
}
|
||||||
|
begin(data, apply) {
|
||||||
|
if (this.job || this.disposed)
|
||||||
|
throw new Error("Sculpt worker is unavailable");
|
||||||
|
const id = ++this.nextId;
|
||||||
|
const promise = new Promise((resolve, reject) => {
|
||||||
|
this.job = { id, settings: data.settings, apply, resolve, reject };
|
||||||
|
});
|
||||||
|
this.write(data.sample);
|
||||||
|
this.worker.postMessage(
|
||||||
|
{ type: "begin", ...data, id, version: this.version },
|
||||||
|
[data.positions.buffer, ...(data.masks ? [data.masks.buffer] : [])],
|
||||||
|
);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
write(sample) {
|
||||||
|
const j = this.job;
|
||||||
|
if (!j || j.canceling) return;
|
||||||
|
if (j.settings.brush === "grab") {
|
||||||
|
if (
|
||||||
|
pressureValue(sample.pressure, j.settings.gain, j.settings.gamma) ===
|
||||||
|
0 &&
|
||||||
|
this.version !== undefined &&
|
||||||
|
j.hasSample
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
if (
|
||||||
|
pressureValue(sample.pressure, j.settings.gain, j.settings.gamma) > 0 &&
|
||||||
|
j.seed === undefined
|
||||||
|
)
|
||||||
|
j.seed = sample.pressure;
|
||||||
|
}
|
||||||
|
this.version = this.mailbox.write(j.id, sample, j.seed);
|
||||||
|
j.hasSample = true;
|
||||||
|
}
|
||||||
|
end() {
|
||||||
|
if (!this.job || this.job.canceling) return;
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: "end",
|
||||||
|
id: this.job.id,
|
||||||
|
version: this.version,
|
||||||
|
});
|
||||||
|
this.armTimeout();
|
||||||
|
}
|
||||||
|
cancel() {
|
||||||
|
if (!this.job) return;
|
||||||
|
this.job.canceling = true;
|
||||||
|
this.worker.postMessage({ type: "cancel", id: this.job.id });
|
||||||
|
this.armTimeout();
|
||||||
|
}
|
||||||
|
armTimeout() {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = setTimeout(
|
||||||
|
() => this.fail(new Error("Sculpt worker did not settle")),
|
||||||
|
10000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
message(data) {
|
||||||
|
if (this.disposed) return;
|
||||||
|
if (data.type === "ready") {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.resolveReady();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.type === "error") {
|
||||||
|
this.fail(new Error(data.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const j = this.job;
|
||||||
|
if (data.type === "geometry" || data.type === "mask") {
|
||||||
|
if (j?.id === data.id && !j.canceling) j.apply(data);
|
||||||
|
this.worker.postMessage({ type: "ack", packet: data.packet });
|
||||||
|
} else if (
|
||||||
|
j?.id === data.id &&
|
||||||
|
["complete", "canceled"].includes(data.type)
|
||||||
|
) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.job = null;
|
||||||
|
j.resolve({
|
||||||
|
canceled: j.canceling || data.type === "canceled",
|
||||||
|
message: data.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fail(error) {
|
||||||
|
if (this.disposed) return;
|
||||||
|
this.rejectReady(error);
|
||||||
|
this.job?.reject(error);
|
||||||
|
this.job = null;
|
||||||
|
this.dispose();
|
||||||
|
this.onError(error);
|
||||||
|
}
|
||||||
|
dispose() {
|
||||||
|
this.disposed = true;
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.worker.terminate();
|
||||||
|
this.rejectReady(new Error("Sculpt worker disposed"));
|
||||||
|
this.job?.resolve({ canceled: true });
|
||||||
|
this.job = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { Sculpt, StrokePath, pressureValue } from "./engine.js";
|
||||||
|
import { sphere, updateNormals } from "./mesh.js";
|
||||||
|
import { cameraBasis, add, mul, sub } from "./math.js";
|
||||||
|
import { Mailbox } from "./mailbox.js";
|
||||||
|
import { hydrate } from "./document.js";
|
||||||
|
|
||||||
|
// Sole owner of brush-time geometry work. Exported for deterministic tests;
|
||||||
|
// the browser entry below only supplies worker rAF and postMessage.
|
||||||
|
export class StrokeProcessor {
|
||||||
|
constructor(mesh, mailbox, send, clock = () => performance.now()) {
|
||||||
|
this.engine = new Sculpt(mesh);
|
||||||
|
this.mailbox = mailbox;
|
||||||
|
this.send = send;
|
||||||
|
this.clock = clock;
|
||||||
|
this.packet = 0;
|
||||||
|
this.outstanding = 0;
|
||||||
|
this.job = null;
|
||||||
|
}
|
||||||
|
begin(data) {
|
||||||
|
if (this.job) throw new Error("Previous stroke has not settled");
|
||||||
|
const e = this.engine;
|
||||||
|
const moved = [];
|
||||||
|
for (let v = 0; v < data.positions.length / 3; v++) {
|
||||||
|
const i = v * 3,
|
||||||
|
p = e.mesh.positions;
|
||||||
|
if (
|
||||||
|
p[i] !== data.positions[i] ||
|
||||||
|
p[i + 1] !== data.positions[i + 1] ||
|
||||||
|
p[i + 2] !== data.positions[i + 2]
|
||||||
|
)
|
||||||
|
moved.push(v);
|
||||||
|
}
|
||||||
|
e.mesh.positions.set(data.positions);
|
||||||
|
if (data.masks) e.mesh.masks.set(data.masks);
|
||||||
|
if (data.activeId) e.activeId = data.activeId;
|
||||||
|
if (moved.length) {
|
||||||
|
const { faces } = updateNormals(e.mesh, moved);
|
||||||
|
e.bvh.refit(faces);
|
||||||
|
}
|
||||||
|
e.dirty.clear();
|
||||||
|
e.begin({ ...data.settings, activeId: data.activeId });
|
||||||
|
const basis = cameraBasis(data.camera.yaw, data.camera.pitch);
|
||||||
|
this.job = {
|
||||||
|
...data,
|
||||||
|
basis,
|
||||||
|
target: { ...data.sample, version: data.version },
|
||||||
|
admittedAt: this.clock(),
|
||||||
|
ending: false,
|
||||||
|
attempts: 0,
|
||||||
|
};
|
||||||
|
this.path = new StrokePath(
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
((data.settings.radius * data.rect.height) / (2 * data.camera.zoom)) *
|
||||||
|
0.15,
|
||||||
|
),
|
||||||
|
(sample) => {
|
||||||
|
this.job.attempts++;
|
||||||
|
const hit = e.bvh.raycast(
|
||||||
|
add(this.world(sample), mul(basis.forward, 24)),
|
||||||
|
mul(basis.forward, -1),
|
||||||
|
);
|
||||||
|
const piece = e.mesh.pieces.find((p) => p.id === e.activeId);
|
||||||
|
if (
|
||||||
|
!hit ||
|
||||||
|
!piece ||
|
||||||
|
hit.face * 3 < piece.indexStart ||
|
||||||
|
hit.face * 3 >= piece.indexStart + piece.indexCount
|
||||||
|
)
|
||||||
|
this.path.break();
|
||||||
|
else e.dab(hit, sample.pressure);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.origin = this.world(data.sample);
|
||||||
|
}
|
||||||
|
world(sample) {
|
||||||
|
const { camera: c, rect: r, basis: b } = this.job;
|
||||||
|
return add(
|
||||||
|
c.target,
|
||||||
|
add(
|
||||||
|
mul(b.right, ((2 * sample.x - r.width) * c.zoom) / r.height),
|
||||||
|
mul(b.up, (1 - (2 * sample.y) / r.height) * c.zoom),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
end(id, version) {
|
||||||
|
if (this.job?.id === id) {
|
||||||
|
this.job.ending = true;
|
||||||
|
this.job.finalVersion = version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancel(id, message) {
|
||||||
|
if (this.job?.id === id) {
|
||||||
|
this.job = null;
|
||||||
|
this.engine.stroke = null;
|
||||||
|
this.engine.dirty.clear();
|
||||||
|
this.outstanding = 0;
|
||||||
|
}
|
||||||
|
this.send({ type: "canceled", id, message });
|
||||||
|
}
|
||||||
|
acknowledge(packet) {
|
||||||
|
if (packet === this.outstanding) this.outstanding = 0;
|
||||||
|
}
|
||||||
|
publish() {
|
||||||
|
const e = this.engine;
|
||||||
|
if (!this.job || this.outstanding || !e.dirty.size) return;
|
||||||
|
const rows = Uint32Array.from(e.dirty);
|
||||||
|
if (this.job.settings.brush === "mask") {
|
||||||
|
const masks = Float32Array.from(rows, (v) => e.mesh.masks[v]);
|
||||||
|
const touched = Uint32Array.from(e.stroke.touched);
|
||||||
|
e.dirty.clear();
|
||||||
|
this.outstanding = ++this.packet;
|
||||||
|
this.send(
|
||||||
|
{
|
||||||
|
type: "mask",
|
||||||
|
id: this.job.id,
|
||||||
|
packet: this.packet,
|
||||||
|
masks,
|
||||||
|
rows,
|
||||||
|
touched,
|
||||||
|
},
|
||||||
|
[masks.buffer, rows.buffer, touched.buffer],
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const vertices = new Float32Array(rows.length * 8);
|
||||||
|
for (let row = 0; row < rows.length; row++) {
|
||||||
|
const i = rows[row] * 3,
|
||||||
|
j = row * 8,
|
||||||
|
p = e.mesh.positions,
|
||||||
|
n = e.mesh.normals;
|
||||||
|
vertices[j] = p[i];
|
||||||
|
vertices[j + 1] = p[i + 1];
|
||||||
|
vertices[j + 2] = p[i + 2];
|
||||||
|
vertices[j + 4] = n[i];
|
||||||
|
vertices[j + 5] = n[i + 1];
|
||||||
|
vertices[j + 6] = n[i + 2];
|
||||||
|
}
|
||||||
|
const touched = Uint32Array.from(e.stroke.touched);
|
||||||
|
e.dirty.clear();
|
||||||
|
this.outstanding = ++this.packet;
|
||||||
|
this.send(
|
||||||
|
{
|
||||||
|
type: "geometry",
|
||||||
|
id: this.job.id,
|
||||||
|
packet: this.packet,
|
||||||
|
vertices,
|
||||||
|
rows,
|
||||||
|
touched,
|
||||||
|
},
|
||||||
|
[vertices.buffer, rows.buffer, touched.buffer],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
frame() {
|
||||||
|
const j = this.job;
|
||||||
|
if (!j) return false;
|
||||||
|
const start = this.clock();
|
||||||
|
j.attempts = 0;
|
||||||
|
if (!j.target) {
|
||||||
|
j.target = this.mailbox.read(j.id);
|
||||||
|
j.admittedAt = start;
|
||||||
|
}
|
||||||
|
if (j.target) {
|
||||||
|
const sample = j.target;
|
||||||
|
if (j.settings.brush === "grab") {
|
||||||
|
// Influence is fixed by the first effective pressure, even if newer
|
||||||
|
// samples replace that seed before this worker gets a frame.
|
||||||
|
const seed =
|
||||||
|
sample.seed ??
|
||||||
|
(pressureValue(sample.pressure, j.settings.gain, j.settings.gamma) > 0
|
||||||
|
? sample.pressure
|
||||||
|
: undefined);
|
||||||
|
if (this.engine.stroke.grab === null && seed !== undefined)
|
||||||
|
this.engine.dab(j.hit, seed, [0, 0, 0]);
|
||||||
|
this.engine.dab(
|
||||||
|
j.hit,
|
||||||
|
sample.pressure,
|
||||||
|
sub(this.world(sample), this.origin),
|
||||||
|
);
|
||||||
|
this.mailbox.acknowledge(sample.version);
|
||||||
|
j.target = null;
|
||||||
|
} else {
|
||||||
|
let iterations = 0;
|
||||||
|
while (
|
||||||
|
j.target &&
|
||||||
|
j.attempts < 64 &&
|
||||||
|
(iterations === 0 || this.clock() - start < 4)
|
||||||
|
) {
|
||||||
|
iterations++;
|
||||||
|
if (this.path.push(sample, 1)) {
|
||||||
|
this.mailbox.acknowledge(sample.version);
|
||||||
|
j.target = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (j.target && this.clock() - j.admittedAt > 250) {
|
||||||
|
this.cancel(
|
||||||
|
j.id,
|
||||||
|
"Stroke canceled because editing fell behind. Previous strokes preserved. Try a smaller brush.",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.publish();
|
||||||
|
if (
|
||||||
|
j.ending &&
|
||||||
|
!j.target &&
|
||||||
|
this.mailbox.processed === j.finalVersion &&
|
||||||
|
!this.outstanding &&
|
||||||
|
!this.engine.dirty.size
|
||||||
|
) {
|
||||||
|
this.engine.stroke = null;
|
||||||
|
this.job = null;
|
||||||
|
this.send({ type: "complete", id: j.id });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof self !== "undefined" && typeof document === "undefined") {
|
||||||
|
let processor,
|
||||||
|
frame = 0;
|
||||||
|
const schedule = () => {
|
||||||
|
if (!frame && processor?.job)
|
||||||
|
frame = requestAnimationFrame(() => {
|
||||||
|
frame = 0;
|
||||||
|
try {
|
||||||
|
if (processor.frame()) schedule();
|
||||||
|
} catch (error) {
|
||||||
|
self.postMessage({ type: "error", message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
self.onmessage = ({ data }) => {
|
||||||
|
try {
|
||||||
|
if (data.type === "init") {
|
||||||
|
if (typeof requestAnimationFrame !== "function")
|
||||||
|
throw new Error(
|
||||||
|
"Sculpt worker requires worker requestAnimationFrame support",
|
||||||
|
);
|
||||||
|
processor = new StrokeProcessor(
|
||||||
|
data.mesh ? hydrate(data.mesh) : sphere(data.level),
|
||||||
|
new Mailbox(data.row),
|
||||||
|
(message, transfer = []) => self.postMessage(message, transfer),
|
||||||
|
);
|
||||||
|
self.postMessage({ type: "ready" });
|
||||||
|
} else if (data.type === "begin") {
|
||||||
|
processor.begin(data);
|
||||||
|
schedule();
|
||||||
|
} else if (data.type === "end") {
|
||||||
|
processor.end(data.id, data.version);
|
||||||
|
schedule();
|
||||||
|
} else if (data.type === "ack") {
|
||||||
|
processor.acknowledge(data.packet);
|
||||||
|
schedule();
|
||||||
|
} else if (data.type === "cancel") {
|
||||||
|
if (frame) cancelAnimationFrame(frame);
|
||||||
|
frame = 0;
|
||||||
|
processor.cancel(data.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
self.postMessage({ type: "error", message: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,596 @@
|
|||||||
|
/* Basecoat owns controls; these rules own the sculpt workspace and density. */
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family:
|
||||||
|
ui-sans-serif,
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
#viewport {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: none;
|
||||||
|
outline: none;
|
||||||
|
background: #20242b;
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
summary {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.tool-rail {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 4;
|
||||||
|
top: max(12px, env(safe-area-inset-top));
|
||||||
|
left: max(8px, env(safe-area-inset-left));
|
||||||
|
max-height: calc(100dvh - 24px - env(safe-area-inset-bottom));
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.tool-rail .brushes,
|
||||||
|
.rail-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 1px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #22252bd9;
|
||||||
|
}
|
||||||
|
.icon-button.btn {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #d4d7db;
|
||||||
|
background: #22252bd9;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.tool-rail .icon-button.btn {
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
.icon-button svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.icon-button.btn:hover {
|
||||||
|
background: #43484f;
|
||||||
|
}
|
||||||
|
.tool-rail .btn[aria-pressed="true"] {
|
||||||
|
color: #fff;
|
||||||
|
background: #386fa9;
|
||||||
|
}
|
||||||
|
.tool-rail > .btn {
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
#view-navigation {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 4;
|
||||||
|
right: max(12px, env(safe-area-inset-right));
|
||||||
|
top: max(12px, env(safe-area-inset-top));
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
#orbit-gizmo {
|
||||||
|
position: relative;
|
||||||
|
width: 104px;
|
||||||
|
height: 104px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
#orbit-gizmo:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
#orbit-gizmo > svg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 104px;
|
||||||
|
height: 104px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.axis-line {
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
.axis {
|
||||||
|
position: absolute;
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--axis-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: var(--axis-color);
|
||||||
|
color: #15202c;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.axis.rear {
|
||||||
|
background: #30343bec;
|
||||||
|
}
|
||||||
|
.axis:hover {
|
||||||
|
filter: brightness(1.3);
|
||||||
|
}
|
||||||
|
.axis:focus-visible,
|
||||||
|
#orbit-gizmo:focus-visible {
|
||||||
|
outline: 2px solid white;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
.view-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.view-buttons .btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.modified-label {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 3;
|
||||||
|
top: max(8px, env(safe-area-inset-top));
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #b7bac1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.modified-label {
|
||||||
|
left: 62px;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 5;
|
||||||
|
top: max(12px, env(safe-area-inset-top));
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--popover);
|
||||||
|
color: var(--popover-foreground);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 8px 24px #0003;
|
||||||
|
max-height: calc(
|
||||||
|
100dvh - max(24px, env(safe-area-inset-top)) -
|
||||||
|
max(12px, env(safe-area-inset-bottom))
|
||||||
|
);
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
.tools-panel {
|
||||||
|
left: max(58px, calc(env(safe-area-inset-left) + 50px));
|
||||||
|
width: min(216px, calc(100vw - 74px));
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.settings-panel {
|
||||||
|
left: max(58px, calc(env(safe-area-inset-left) + 50px));
|
||||||
|
width: min(264px, calc(100vw - 74px));
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.pieces-panel {
|
||||||
|
left: max(58px, calc(env(safe-area-inset-left) + 50px));
|
||||||
|
width: min(248px, calc(100vw - 74px));
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.pieces-panel .btn {
|
||||||
|
background: #262a30;
|
||||||
|
color: #d4d7db;
|
||||||
|
}
|
||||||
|
.pieces-panel .btn:hover {
|
||||||
|
background: #343b45;
|
||||||
|
}
|
||||||
|
#piece-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.piece-row.btn {
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pieces-panel .piece-row[aria-pressed="true"] {
|
||||||
|
background: #386fa9;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.piece-row i {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.piece-material {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 10px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.piece-material .btn {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
#piece-color {
|
||||||
|
width: 30px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.preset-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 3px;
|
||||||
|
margin-block: 10px;
|
||||||
|
}
|
||||||
|
.preset-grid .btn {
|
||||||
|
padding: 5px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
.preset-grid svg {
|
||||||
|
width: 23px;
|
||||||
|
height: 23px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.4;
|
||||||
|
}
|
||||||
|
.piece-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 5px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
.panel-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 28px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.panel-heading h2 {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.panel .btn {
|
||||||
|
height: 30px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding-inline: 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.panel-heading .btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.brushes {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.brushes .btn {
|
||||||
|
justify-content: space-between;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.brushes .btn span {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.panel [aria-pressed="true"] {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-foreground);
|
||||||
|
box-shadow: inset 2px 0 0 #dedee4;
|
||||||
|
}
|
||||||
|
.control-section {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
output {
|
||||||
|
float: right;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 10px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.panel input[type="range"] {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 24px;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 32px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.toggle-row small {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
.toggle-row input {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.history,
|
||||||
|
.navigation,
|
||||||
|
.project-actions,
|
||||||
|
.settings-footer {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.history,
|
||||||
|
.settings-footer {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.navigation {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
.wide {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.project-name {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 11px;
|
||||||
|
margin: 4px 0 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.project-actions {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
#save {
|
||||||
|
grid-column: 1/-1;
|
||||||
|
}
|
||||||
|
.palm-row {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
details {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
/* Avoid transient overlapping controls during the library's height animation. */
|
||||||
|
details::details-content {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 36px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
summary::after {
|
||||||
|
content: "+";
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
details[open] > summary::after {
|
||||||
|
content: "−";
|
||||||
|
}
|
||||||
|
summary:focus-visible {
|
||||||
|
outline: 2px solid var(--ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.disclosure-content {
|
||||||
|
padding: 4px 0 12px;
|
||||||
|
}
|
||||||
|
.settings-footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
.select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.panel .select {
|
||||||
|
height: 32px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.pressure-meter {
|
||||||
|
height: 3px;
|
||||||
|
background: var(--muted);
|
||||||
|
margin: 8px 0;
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#pressure-fill {
|
||||||
|
width: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--foreground);
|
||||||
|
}
|
||||||
|
#curve {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.curve-grid {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--border);
|
||||||
|
}
|
||||||
|
.curve-grid,
|
||||||
|
#curve-line {
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
#curve-line {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--foreground);
|
||||||
|
}
|
||||||
|
.swatches {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.swatches .btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.swatches .btn::before {
|
||||||
|
content: "";
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: var(--swatch);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.swatches .btn[aria-pressed="true"] {
|
||||||
|
border-color: var(--muted-foreground);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
#cursor {
|
||||||
|
position: absolute;
|
||||||
|
border: 1px solid #d0efdc;
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
#cursor i {
|
||||||
|
position: absolute;
|
||||||
|
width: 3px;
|
||||||
|
height: 3px;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d0efdc;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
.status-toast {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 8;
|
||||||
|
bottom: max(12px, env(safe-area-inset-bottom));
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
max-width: min(400px, calc(100vw - 32px));
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--popover);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--popover-foreground);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.status-toast.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.canvas-message {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: min(340px, calc(100% - 48px));
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--popover-foreground);
|
||||||
|
background: var(--popover);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.canvas-message h1 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.canvas-message p {
|
||||||
|
margin: 10px 0 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.dialog > form {
|
||||||
|
width: min(380px, calc(100vw - 32px));
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.dialog label {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.dialog .hint {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.dialog dl {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.dialog dt {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.dialog dd {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
@media (any-pointer: coarse) {
|
||||||
|
.icon-button.btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
.view-buttons .btn {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
.panel .btn {
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
.toggle-row,
|
||||||
|
summary {
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
.panel input[type="range"] {
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.btn,
|
||||||
|
.dialog,
|
||||||
|
.dialog > * {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import { buildMesh, sphere } from "./mesh.js";
|
||||||
|
|
||||||
|
export const MAX_VERTICES = 65536;
|
||||||
|
export const MAX_TRIANGLES = 131072;
|
||||||
|
|
||||||
|
function checked(positions, indices, level = 0, masks) {
|
||||||
|
if (positions.length / 3 > MAX_VERTICES || indices.length / 3 > MAX_TRIANGLES)
|
||||||
|
throw new Error("Mesh exceeds topology limits.");
|
||||||
|
return buildMesh(positions, indices, level, masks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gridSurface(slices, stacks, point) {
|
||||||
|
const p = [],
|
||||||
|
i = [];
|
||||||
|
for (let y = 0; y < stacks; y++)
|
||||||
|
for (let x = 0; x < slices; x++) p.push(...point(x / slices, y / stacks));
|
||||||
|
const at = (x, y) => y * slices + ((x + slices) % slices);
|
||||||
|
for (let y = 0; y < stacks; y++)
|
||||||
|
for (let x = 0; x < slices; x++) {
|
||||||
|
const a = at(x, y),
|
||||||
|
b = at(x + 1, y),
|
||||||
|
c = at(x + 1, (y + 1) % stacks),
|
||||||
|
d = at(x, (y + 1) % stacks);
|
||||||
|
i.push(a, b, c, a, c, d);
|
||||||
|
}
|
||||||
|
return [p, i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function cubeMesh(n) {
|
||||||
|
const p = [],
|
||||||
|
faces = [],
|
||||||
|
map = new Map();
|
||||||
|
const vertex = (v) => {
|
||||||
|
const key = v.join(",");
|
||||||
|
if (!map.has(key)) {
|
||||||
|
map.set(key, p.length / 3);
|
||||||
|
p.push(...v.map((x) => x / n));
|
||||||
|
}
|
||||||
|
return map.get(key);
|
||||||
|
};
|
||||||
|
const sides = [
|
||||||
|
[
|
||||||
|
[n, -n, -n],
|
||||||
|
[0, 2 * n, 0],
|
||||||
|
[0, 0, 2 * n],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[-n, -n, n],
|
||||||
|
[0, 2 * n, 0],
|
||||||
|
[0, 0, -2 * n],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[-n, n, -n],
|
||||||
|
[0, 0, 2 * n],
|
||||||
|
[2 * n, 0, 0],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[-n, -n, n],
|
||||||
|
[0, 0, -2 * n],
|
||||||
|
[2 * n, 0, 0],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[-n, -n, -n],
|
||||||
|
[0, 2 * n, 0],
|
||||||
|
[2 * n, 0, 0],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[n, -n, n],
|
||||||
|
[0, 2 * n, 0],
|
||||||
|
[-2 * n, 0, 0],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
for (const [o, u, v] of sides)
|
||||||
|
for (let y = 0; y < n; y++)
|
||||||
|
for (let x = 0; x < n; x++) {
|
||||||
|
const q = (xx, yy) =>
|
||||||
|
vertex(o.map((z, k) => z + (u[k] * xx) / n + (v[k] * yy) / n));
|
||||||
|
const a = q(x, y),
|
||||||
|
b = q(x + 1, y),
|
||||||
|
c = q(x + 1, y + 1),
|
||||||
|
d = q(x, y + 1);
|
||||||
|
faces.push(a, b, c, a, c, d);
|
||||||
|
}
|
||||||
|
return [p, faces];
|
||||||
|
}
|
||||||
|
|
||||||
|
function radial(kind, slices, rings) {
|
||||||
|
const p = [],
|
||||||
|
idx = [];
|
||||||
|
const cone = kind === "cone";
|
||||||
|
for (let y = 0; y <= (cone ? rings - 1 : rings); y++) {
|
||||||
|
const t = y / rings,
|
||||||
|
r = cone ? 1 - t : 1,
|
||||||
|
z = -1 + 2 * t;
|
||||||
|
for (let x = 0; x < slices; x++) {
|
||||||
|
const a = (2 * Math.PI * x) / slices;
|
||||||
|
p.push(r * Math.cos(a), r * Math.sin(a), z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let y = 0; y < rings - (cone ? 1 : 0); y++)
|
||||||
|
for (let x = 0; x < slices; x++) {
|
||||||
|
const a = y * slices + x,
|
||||||
|
b = y * slices + ((x + 1) % slices),
|
||||||
|
c = (y + 1) * slices + ((x + 1) % slices),
|
||||||
|
d = (y + 1) * slices + x;
|
||||||
|
idx.push(a, b, c, a, c, d);
|
||||||
|
}
|
||||||
|
if (cone) {
|
||||||
|
const apex = p.length / 3,
|
||||||
|
o = (rings - 1) * slices;
|
||||||
|
p.push(0, 0, 1);
|
||||||
|
for (let x = 0; x < slices; x++)
|
||||||
|
idx.push(o + x, o + ((x + 1) % slices), apex);
|
||||||
|
}
|
||||||
|
const bottom = p.length / 3;
|
||||||
|
p.push(0, 0, -1);
|
||||||
|
for (let x = 0; x < slices; x++) idx.push(bottom, (x + 1) % slices, x);
|
||||||
|
if (!cone) {
|
||||||
|
const top = p.length / 3;
|
||||||
|
p.push(0, 0, 1);
|
||||||
|
const o = rings * slices;
|
||||||
|
for (let x = 0; x < slices; x++)
|
||||||
|
idx.push(top, o + x, o + ((x + 1) % slices));
|
||||||
|
}
|
||||||
|
return [p, idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preset(name, level = 4) {
|
||||||
|
if (!Number.isInteger(level) || level < 1 || level > 6)
|
||||||
|
throw new Error("Choose a supported resolution.");
|
||||||
|
if (name === "sphere") return sphere(level);
|
||||||
|
let data;
|
||||||
|
const scale = 2 ** level;
|
||||||
|
if (name === "cube") data = cubeMesh(scale);
|
||||||
|
else if (name === "cylinder" || name === "cone")
|
||||||
|
data = radial(name, scale * 4, scale + 8);
|
||||||
|
else if (name === "torus")
|
||||||
|
data = gridSurface(scale * 4, scale * 2, (u, v) => {
|
||||||
|
const a = 2 * Math.PI * u,
|
||||||
|
b = 2 * Math.PI * v,
|
||||||
|
r = 0.32,
|
||||||
|
R = 0.68;
|
||||||
|
return [
|
||||||
|
(R + r * Math.cos(b)) * Math.cos(a),
|
||||||
|
(R + r * Math.cos(b)) * Math.sin(a),
|
||||||
|
r * Math.sin(b),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
else throw new Error(`Unknown topology preset: ${name}`);
|
||||||
|
return checked(data[0], data[1], level);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subdivide(mesh) {
|
||||||
|
const tri = mesh.indices.length / 3,
|
||||||
|
vertices = mesh.positions.length / 3;
|
||||||
|
const edges = new Map();
|
||||||
|
for (let k = 0; k < mesh.indices.length; k += 3)
|
||||||
|
for (const [a, b] of [
|
||||||
|
[mesh.indices[k], mesh.indices[k + 1]],
|
||||||
|
[mesh.indices[k + 1], mesh.indices[k + 2]],
|
||||||
|
[mesh.indices[k + 2], mesh.indices[k]],
|
||||||
|
])
|
||||||
|
edges.set(a < b ? `${a},${b}` : `${b},${a}`, 0);
|
||||||
|
if (vertices + edges.size > MAX_VERTICES || tri * 4 > MAX_TRIANGLES)
|
||||||
|
throw new Error("Subdivision exceeds topology limits.");
|
||||||
|
const p = Array.from(mesh.positions),
|
||||||
|
masks = Array.from(mesh.masks || new Float32Array(vertices));
|
||||||
|
for (const key of edges.keys()) {
|
||||||
|
const [a, b] = key.split(",").map(Number),
|
||||||
|
v = p.length / 3;
|
||||||
|
edges.set(key, v);
|
||||||
|
for (let j = 0; j < 3; j++) p.push((p[a * 3 + j] + p[b * 3 + j]) / 2);
|
||||||
|
masks.push((masks[a] + masks[b]) / 2);
|
||||||
|
}
|
||||||
|
const mid = (a, b) => edges.get(a < b ? `${a},${b}` : `${b},${a}`),
|
||||||
|
out = [];
|
||||||
|
for (let k = 0; k < mesh.indices.length; k += 3) {
|
||||||
|
const a = mesh.indices[k],
|
||||||
|
b = mesh.indices[k + 1],
|
||||||
|
c = mesh.indices[k + 2],
|
||||||
|
ab = mid(a, b),
|
||||||
|
bc = mid(b, c),
|
||||||
|
ca = mid(c, a);
|
||||||
|
out.push(a, ab, ca, b, bc, ab, c, ca, bc, ab, bc, ca);
|
||||||
|
}
|
||||||
|
return checked(p, out, mesh.level + 1, masks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function topology(indices, count) {
|
||||||
|
const neighbors = Array.from({ length: count }, () => new Set()),
|
||||||
|
incident = Array.from({ length: count }, () => []),
|
||||||
|
edgeFaces = new Map(),
|
||||||
|
faces = new Set();
|
||||||
|
let valid = true;
|
||||||
|
for (let k = 0; k < indices.length; k += 3) {
|
||||||
|
const face = [indices[k], indices[k + 1], indices[k + 2]],
|
||||||
|
faceKey = [...face].sort((a, b) => a - b).join(",");
|
||||||
|
if (new Set(face).size !== 3 || faces.has(faceKey)) valid = false;
|
||||||
|
faces.add(faceKey);
|
||||||
|
for (const v of face) incident[v].push(k / 3);
|
||||||
|
for (const [a, b] of [
|
||||||
|
[indices[k], indices[k + 1]],
|
||||||
|
[indices[k + 1], indices[k + 2]],
|
||||||
|
[indices[k + 2], indices[k]],
|
||||||
|
]) {
|
||||||
|
neighbors[a].add(b);
|
||||||
|
neighbors[b].add(a);
|
||||||
|
const key = a < b ? `${a},${b}` : `${b},${a}`;
|
||||||
|
const list = edgeFaces.get(key) || [];
|
||||||
|
list.push(k / 3);
|
||||||
|
edgeFaces.set(key, list);
|
||||||
|
if (list.length > 2) valid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A manifold vertex link is one cycle (interior) or one path (boundary).
|
||||||
|
// Edge incidence alone does not detect two components touching at a vertex.
|
||||||
|
for (let v = 0; valid && v < count; v++) {
|
||||||
|
if (!incident[v].length) continue;
|
||||||
|
const link = new Map();
|
||||||
|
for (const f of incident[v]) {
|
||||||
|
const other = indices.slice(f * 3, f * 3 + 3).filter((x) => x !== v);
|
||||||
|
if (other.length !== 2) {
|
||||||
|
valid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!link.has(other[0])) link.set(other[0], new Set());
|
||||||
|
if (!link.has(other[1])) link.set(other[1], new Set());
|
||||||
|
link.get(other[0]).add(other[1]);
|
||||||
|
link.get(other[1]).add(other[0]);
|
||||||
|
}
|
||||||
|
if (!valid) break;
|
||||||
|
const boundary = [...neighbors[v]].filter((n) => {
|
||||||
|
const key = v < n ? `${v},${n}` : `${n},${v}`;
|
||||||
|
return edgeFaces.get(key).length === 1;
|
||||||
|
});
|
||||||
|
const degrees = [...link.values()].map((x) => x.size);
|
||||||
|
if (
|
||||||
|
degrees.some((d) => d < 1 || d > 2) ||
|
||||||
|
(boundary.length === 0
|
||||||
|
? degrees.some((d) => d !== 2)
|
||||||
|
: boundary.length !== 2 || degrees.filter((d) => d === 1).length !== 2)
|
||||||
|
) {
|
||||||
|
valid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const reached = new Set(),
|
||||||
|
pending = [link.keys().next().value];
|
||||||
|
while (pending.length) {
|
||||||
|
const x = pending.pop();
|
||||||
|
if (reached.has(x)) continue;
|
||||||
|
reached.add(x);
|
||||||
|
for (const n of link.get(x)) pending.push(n);
|
||||||
|
}
|
||||||
|
if (reached.size !== link.size) valid = false;
|
||||||
|
}
|
||||||
|
return { neighbors, incident, edgeFaces, valid };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decimate(mesh, ratio = 0.5) {
|
||||||
|
if (!Number.isFinite(ratio) || ratio <= 0 || ratio > 1)
|
||||||
|
throw new Error("Ratio must be in (0, 1].");
|
||||||
|
let p = Array.from(mesh.positions),
|
||||||
|
idx = Array.from(mesh.indices),
|
||||||
|
masks = Array.from(mesh.masks || new Float32Array(p.length / 3));
|
||||||
|
const target = Math.max(4, Math.floor((idx.length / 3) * ratio)),
|
||||||
|
deadline = Date.now() + 2500;
|
||||||
|
let accepted = 0,
|
||||||
|
work = 0;
|
||||||
|
const MAX_PASSES = 64,
|
||||||
|
MAX_WORK = 1000000;
|
||||||
|
for (
|
||||||
|
let pass = 0;
|
||||||
|
pass < MAX_PASSES && idx.length / 3 > target && Date.now() < deadline;
|
||||||
|
pass++
|
||||||
|
) {
|
||||||
|
const { neighbors, incident, edgeFaces, valid } = topology(
|
||||||
|
idx,
|
||||||
|
p.length / 3,
|
||||||
|
),
|
||||||
|
protectedVertices = new Set();
|
||||||
|
if (!valid) break;
|
||||||
|
for (const [key, faces] of edgeFaces)
|
||||||
|
if (faces.length === 1)
|
||||||
|
for (const v of key.split(",").map(Number)) protectedVertices.add(v);
|
||||||
|
masks.forEach((m, v) => {
|
||||||
|
if (m !== 0) {
|
||||||
|
protectedVertices.add(v);
|
||||||
|
for (const n of neighbors[v]) protectedVertices.add(n);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const candidates = [];
|
||||||
|
for (const [key, edgeIncident] of edgeFaces) {
|
||||||
|
if (Date.now() >= deadline || work++ >= MAX_WORK) break;
|
||||||
|
if (edgeIncident.length === 2) {
|
||||||
|
const [a, b] = key.split(",").map(Number);
|
||||||
|
if (protectedVertices.has(a) || protectedVertices.has(b)) continue;
|
||||||
|
const opposite = edgeIncident.map((f) =>
|
||||||
|
idx.slice(f * 3, f * 3 + 3).find((v) => v !== a && v !== b),
|
||||||
|
),
|
||||||
|
common = [...neighbors[a]].filter((v) => neighbors[b].has(v));
|
||||||
|
if (
|
||||||
|
new Set(opposite).size !== 2 ||
|
||||||
|
common.length !== 2 ||
|
||||||
|
common.some((v) => !opposite.includes(v))
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
let d = 0;
|
||||||
|
for (let j = 0; j < 3; j++) d += (p[a * 3 + j] - p[b * 3 + j]) ** 2;
|
||||||
|
candidates.push([d, a, b]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.sort((a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]);
|
||||||
|
const replace = new Map(),
|
||||||
|
locked = new Set();
|
||||||
|
let removed = 0;
|
||||||
|
for (const [, a, b] of candidates) {
|
||||||
|
if (Date.now() >= deadline || work++ >= MAX_WORK) break;
|
||||||
|
if (idx.length / 3 - removed * 2 <= target) break;
|
||||||
|
if (locked.has(a) || locked.has(b)) continue;
|
||||||
|
const pos = [0, 1, 2].map((j) =>
|
||||||
|
Math.fround((p[a * 3 + j] + p[b * 3 + j]) / 2),
|
||||||
|
),
|
||||||
|
localFaces = [...new Set([...incident[a], ...incident[b]])],
|
||||||
|
edgeKey = a < b ? `${a},${b}` : `${b},${a}`,
|
||||||
|
omitted = new Set(edgeFaces.get(edgeKey)),
|
||||||
|
seen = new Set();
|
||||||
|
let candidateValid = true;
|
||||||
|
for (const f of localFaces) {
|
||||||
|
if (Date.now() >= deadline || work++ >= MAX_WORK) {
|
||||||
|
candidateValid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const k = f * 3;
|
||||||
|
if (omitted.has(f)) continue;
|
||||||
|
const vs = [idx[k], idx[k + 1], idx[k + 2]].map((v) =>
|
||||||
|
v === b ? a : v,
|
||||||
|
);
|
||||||
|
const triangleKey = [...vs].sort((x, y) => x - y).join(",");
|
||||||
|
if (new Set(vs).size < 3 || seen.has(triangleKey)) {
|
||||||
|
candidateValid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
seen.add(triangleKey);
|
||||||
|
const q = vs.map((v) => (v === a ? pos : p.slice(v * 3, v * 3 + 3)));
|
||||||
|
const ux = q[1][0] - q[0][0],
|
||||||
|
uy = q[1][1] - q[0][1],
|
||||||
|
uz = q[1][2] - q[0][2],
|
||||||
|
vx = q[2][0] - q[0][0],
|
||||||
|
vy = q[2][1] - q[0][1],
|
||||||
|
vz = q[2][2] - q[0][2];
|
||||||
|
const nx = uy * vz - uz * vy,
|
||||||
|
ny = uz * vx - ux * vz,
|
||||||
|
nz = ux * vy - uy * vx;
|
||||||
|
const ox = meshLikeNormal(p, idx, k);
|
||||||
|
if (
|
||||||
|
nx * nx + ny * ny + nz * nz < 1e-16 ||
|
||||||
|
nx * ox[0] + ny * ox[1] + nz * ox[2] <= 0
|
||||||
|
) {
|
||||||
|
candidateValid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!candidateValid) continue;
|
||||||
|
replace.set(b, a);
|
||||||
|
p.splice(a * 3, 3, ...pos);
|
||||||
|
locked.add(a);
|
||||||
|
locked.add(b);
|
||||||
|
for (const n of neighbors[a]) locked.add(n);
|
||||||
|
for (const n of neighbors[b]) locked.add(n);
|
||||||
|
removed++;
|
||||||
|
accepted++;
|
||||||
|
}
|
||||||
|
if (!replace.size) break;
|
||||||
|
idx = idx.map((v) => replace.get(v) ?? v);
|
||||||
|
const seen = new Set();
|
||||||
|
idx = idx.reduce((o, v, k) => {
|
||||||
|
if (k % 3) return o;
|
||||||
|
const f = idx.slice(k, k + 3);
|
||||||
|
if (new Set(f).size < 3) return o;
|
||||||
|
const key = [...f].sort((a, b) => a - b).join(",");
|
||||||
|
// Candidate validation guarantees this. Never use duplicate removal to
|
||||||
|
// repair a collapse which violated the link condition.
|
||||||
|
if (seen.has(key))
|
||||||
|
throw new Error("Decimation produced duplicate faces.");
|
||||||
|
seen.add(key);
|
||||||
|
o.push(...f);
|
||||||
|
return o;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
if (!accepted)
|
||||||
|
return checked(mesh.positions, mesh.indices, mesh.level, mesh.masks);
|
||||||
|
const used = [...new Set(idx)].sort((a, b) => a - b),
|
||||||
|
remap = new Map(used.map((v, i) => [v, i])),
|
||||||
|
outp = [],
|
||||||
|
outm = [];
|
||||||
|
for (const v of used) {
|
||||||
|
outp.push(...p.slice(v * 3, v * 3 + 3));
|
||||||
|
outm.push(masks[v]);
|
||||||
|
}
|
||||||
|
return checked(
|
||||||
|
outp,
|
||||||
|
idx.map((v) => remap.get(v)),
|
||||||
|
mesh.level,
|
||||||
|
outm,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function meshLikeNormal(p, idx, k) {
|
||||||
|
const a = idx[k],
|
||||||
|
b = idx[k + 1],
|
||||||
|
c = idx[k + 2],
|
||||||
|
ux = p[b * 3] - p[a * 3],
|
||||||
|
uy = p[b * 3 + 1] - p[a * 3 + 1],
|
||||||
|
uz = p[b * 3 + 2] - p[a * 3 + 2],
|
||||||
|
vx = p[c * 3] - p[a * 3],
|
||||||
|
vy = p[c * 3 + 1] - p[a * 3 + 1],
|
||||||
|
vz = p[c * 3 + 2] - p[a * 3 + 2];
|
||||||
|
return [uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx];
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { buildMesh } from "./mesh.js";
|
||||||
|
import {
|
||||||
|
MAX_TRIANGLES,
|
||||||
|
MAX_VERTICES,
|
||||||
|
decimate,
|
||||||
|
preset,
|
||||||
|
subdivide,
|
||||||
|
} from "./topology.js";
|
||||||
|
|
||||||
|
function inspect(mesh) {
|
||||||
|
const edges = new Map();
|
||||||
|
let volume = 0;
|
||||||
|
for (let k = 0; k < mesh.indices.length; k += 3) {
|
||||||
|
const face = Array.from(mesh.indices.subarray(k, k + 3));
|
||||||
|
assert.equal(new Set(face).size, 3);
|
||||||
|
const [a, b, c] = face;
|
||||||
|
const p = mesh.positions;
|
||||||
|
volume +=
|
||||||
|
(p[a * 3] * (p[b * 3 + 1] * p[c * 3 + 2] - p[b * 3 + 2] * p[c * 3 + 1]) +
|
||||||
|
p[a * 3 + 1] * (p[b * 3 + 2] * p[c * 3] - p[b * 3] * p[c * 3 + 2]) +
|
||||||
|
p[a * 3 + 2] * (p[b * 3] * p[c * 3 + 1] - p[b * 3 + 1] * p[c * 3])) /
|
||||||
|
6;
|
||||||
|
for (const [x, y] of [
|
||||||
|
[a, b],
|
||||||
|
[b, c],
|
||||||
|
[c, a],
|
||||||
|
]) {
|
||||||
|
const key = x < y ? `${x},${y}` : `${y},${x}`;
|
||||||
|
edges.set(key, (edges.get(key) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok([...mesh.positions, ...mesh.normals].every(Number.isFinite));
|
||||||
|
assert.ok([...edges.values()].every((count) => count === 2));
|
||||||
|
assert.ok(
|
||||||
|
volume > 0,
|
||||||
|
`expected outward winding, signed volume was ${volume}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("presets are bounded, outward, closed manifold meshes", () => {
|
||||||
|
for (const name of ["sphere", "cube", "cylinder", "cone", "torus"]) {
|
||||||
|
const mesh = preset(name);
|
||||||
|
assert.ok(mesh.positions.length / 3 <= MAX_VERTICES);
|
||||||
|
assert.ok(mesh.indices.length / 3 <= MAX_TRIANGLES);
|
||||||
|
assert.ok(mesh.indices.length / 3 >= 2000);
|
||||||
|
assert.equal(mesh.masks.length, mesh.positions.length / 3);
|
||||||
|
inspect(mesh);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subdivision shares midpoints and interpolates masks without changing winding", () => {
|
||||||
|
const tetra = buildMesh(
|
||||||
|
[1, 1, 1, -1, -1, 1, -1, 1, -1, 1, -1, -1],
|
||||||
|
[0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3],
|
||||||
|
0,
|
||||||
|
[0, 1, 0.5, 0],
|
||||||
|
);
|
||||||
|
const result = subdivide(tetra);
|
||||||
|
assert.equal(result.indices.length, tetra.indices.length * 4);
|
||||||
|
assert.equal(result.positions.length / 3, 10); // 4 vertices + 6 unique edges.
|
||||||
|
const midpoint =
|
||||||
|
Array.from(result.positions).findIndex(
|
||||||
|
(_, k, p) =>
|
||||||
|
k % 3 === 0 && p[k] === 0 && p[k + 1] === 0 && p[k + 2] === 1,
|
||||||
|
) / 3;
|
||||||
|
assert.equal(result.masks[midpoint], 0.5);
|
||||||
|
assert.deepEqual(
|
||||||
|
Array.from(result.positions.slice(0, 12)),
|
||||||
|
Array.from(tetra.positions),
|
||||||
|
);
|
||||||
|
inspect(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decimation keeps a closed mesh and protects masked neighborhoods", () => {
|
||||||
|
const source = preset("sphere", 4);
|
||||||
|
source.masks[0] = 1;
|
||||||
|
const protectedFaces = Array.from(source.incident[0], (f) =>
|
||||||
|
Array.from(source.indices.slice(f * 3, f * 3 + 3)).map((v) =>
|
||||||
|
Array.from(source.positions.slice(v * 3, v * 3 + 3)).join(","),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const result = decimate(source, 0.75);
|
||||||
|
assert.ok(result.indices.length < source.indices.length);
|
||||||
|
inspect(result);
|
||||||
|
const coordinates = new Set();
|
||||||
|
for (let v = 0; v < result.positions.length / 3; v++)
|
||||||
|
coordinates.add(
|
||||||
|
Array.from(result.positions.slice(v * 3, v * 3 + 3)).join(","),
|
||||||
|
);
|
||||||
|
assert.ok(protectedFaces.flat().every((point) => coordinates.has(point)));
|
||||||
|
assert.equal([...result.masks].filter(Boolean).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fully masked decimation is a no-op", () => {
|
||||||
|
const source = preset("torus", 2);
|
||||||
|
source.masks.fill(1);
|
||||||
|
const result = decimate(source, 0.25);
|
||||||
|
assert.deepEqual(result.indices, source.indices);
|
||||||
|
assert.deepEqual(result.positions, source.positions);
|
||||||
|
assert.deepEqual(result.masks, source.masks);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subdivision rejects cap overflow before allocating output", () => {
|
||||||
|
const positions = new Float32Array((MAX_VERTICES - 1) * 3);
|
||||||
|
const mesh = buildMesh(positions, new Uint32Array([0, 1, 2]));
|
||||||
|
assert.throws(() => subdivide(mesh), /limits/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Ronan Berder
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Basecoat 1.0.2
|
||||||
|
|
||||||
|
`basecoat.min.css` is the unmodified `dist/basecoat.cdn.min.css` (Vega style)
|
||||||
|
from the npm package `basecoat-css@1.0.2`, licensed under MIT (see LICENSE.md).
|
||||||
|
Source: https://www.npmjs.com/package/basecoat-css/v/1.0.2
|
||||||
|
Documentation: https://basecoatui.com/
|
||||||
|
|
||||||
|
SHA-256: `8123677adb9bba43be3298e1543bcc5fc763e8cda3d32dc74c806046a3537ca0`
|
||||||
|
|
||||||
|
Served locally through Go's embedded assets. No CDN, Tailwind build, or Basecoat
|
||||||
|
JavaScript runtime is required. Native controls and the existing Menus controller
|
||||||
|
own interactions. Do not format or modify the upstream stylesheet.
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,259 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { Worker } from "node:worker_threads";
|
||||||
|
import { Mailbox } from "./mailbox.js";
|
||||||
|
import { StrokeProcessor } from "./stroke-worker.js";
|
||||||
|
import { Sculpt, defaults, StrokePath } from "./engine.js";
|
||||||
|
import { sphere } from "./mesh.js";
|
||||||
|
|
||||||
|
const row = () => ({
|
||||||
|
buffer: new SharedArrayBuffer(128),
|
||||||
|
descriptor: { offset: 64 },
|
||||||
|
});
|
||||||
|
test("mailbox overwrites one slot, keeps a newer publication pending, wraps and rejects torn reads", (t) => {
|
||||||
|
const m = new Mailbox(row()),
|
||||||
|
p = { x: 12, y: -8, pressure: 0.5 };
|
||||||
|
const first = m.write(7, p);
|
||||||
|
assert.deepEqual(m.read(7), { ...p, seed: undefined, version: first });
|
||||||
|
const second = m.write(7, { x: 20, y: 3, pressure: 0.75 });
|
||||||
|
m.acknowledge(first);
|
||||||
|
assert.equal(m.read(7).version, second);
|
||||||
|
m.acknowledge(second);
|
||||||
|
assert.equal(m.read(7), null);
|
||||||
|
Atomics.store(m.words, 0, 0x7ffffffe);
|
||||||
|
const wrapped = m.write(7, p);
|
||||||
|
assert.equal(wrapped, -2147483648);
|
||||||
|
assert.equal(m.read(7).x, 12);
|
||||||
|
const load = Atomics.load;
|
||||||
|
let changed = false;
|
||||||
|
t.mock.method(Atomics, "load", (array, index) => {
|
||||||
|
const v = load(array, index);
|
||||||
|
if (array === m.words && index === 5 && !changed) {
|
||||||
|
changed = true;
|
||||||
|
m.write(7, { x: 99, y: 100, pressure: 0.2 });
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
});
|
||||||
|
assert.equal(m.read(7), null);
|
||||||
|
Atomics.store(m.words, 0, 11);
|
||||||
|
assert.equal(m.read(7), null);
|
||||||
|
});
|
||||||
|
test("actual concurrent SAB writer and reader never observe mixed coordinates", async (t) => {
|
||||||
|
const shared = row(),
|
||||||
|
m = new Mailbox(shared),
|
||||||
|
control = new Int32Array(shared.buffer, 0, 1);
|
||||||
|
const code = `import {parentPort,workerData} from 'node:worker_threads';
|
||||||
|
const {Mailbox}=await import(workerData.module);
|
||||||
|
const m=new Mailbox(workerData.row),control=new Int32Array(workerData.row.buffer,0,1);
|
||||||
|
let reads=0,last=0;
|
||||||
|
parentPort.postMessage('ready');
|
||||||
|
while(!Atomics.load(control,0)||last!==100000){
|
||||||
|
const p=m.read(7);if(!p)continue;
|
||||||
|
if(p.y!==-p.x||p.pressure!==p.x*2||p.seed!==p.x*3)throw new Error('torn tuple');
|
||||||
|
last=p.x;reads++;m.acknowledge(p.version);
|
||||||
|
}parentPort.postMessage({reads,last});`;
|
||||||
|
const worker = new Worker(
|
||||||
|
new URL("data:text/javascript," + encodeURIComponent(code)),
|
||||||
|
{
|
||||||
|
workerData: {
|
||||||
|
module: new URL("./mailbox.js", import.meta.url).href,
|
||||||
|
row: shared,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
t.after(() => worker.terminate());
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
worker.once("message", resolve);
|
||||||
|
worker.once("error", reject);
|
||||||
|
});
|
||||||
|
const result = new Promise((resolve, reject) => {
|
||||||
|
worker.once("message", resolve);
|
||||||
|
worker.once("error", reject);
|
||||||
|
});
|
||||||
|
for (let i = 1; i <= 100000; i++) {
|
||||||
|
m.write(7, { x: i, y: -i, pressure: i * 2 }, i * 3);
|
||||||
|
if (i % 1000 === 0) await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
Atomics.store(control, 0, 1);
|
||||||
|
const out = await result;
|
||||||
|
assert.ok(out.reads > 0);
|
||||||
|
assert.equal(out.last, 100000);
|
||||||
|
});
|
||||||
|
function setup(settings = {}, sample = { x: 400, y: 300, pressure: 0.7 }) {
|
||||||
|
const mailbox = new Mailbox(row()),
|
||||||
|
messages = [],
|
||||||
|
mesh = sphere(4);
|
||||||
|
let now = 0;
|
||||||
|
const processor = new StrokeProcessor(
|
||||||
|
mesh,
|
||||||
|
mailbox,
|
||||||
|
(m) => messages.push(m),
|
||||||
|
() => now,
|
||||||
|
);
|
||||||
|
const version = mailbox.write(1, sample);
|
||||||
|
processor.begin({
|
||||||
|
id: 1,
|
||||||
|
version,
|
||||||
|
sample,
|
||||||
|
positions: mesh.positions.slice(),
|
||||||
|
settings: { ...defaults, symmetry: false, ...settings },
|
||||||
|
camera: { yaw: 0, pitch: 0, zoom: 1.5, target: [0, 0, 0] },
|
||||||
|
rect: { width: 800, height: 600 },
|
||||||
|
hit: { position: [0, 0, 1], normal: [0, 0, 1] },
|
||||||
|
});
|
||||||
|
const ack = () => {
|
||||||
|
for (const m of messages)
|
||||||
|
if (m.type === "geometry") processor.acknowledge(m.packet);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
processor,
|
||||||
|
mailbox,
|
||||||
|
messages,
|
||||||
|
ack,
|
||||||
|
advance: (ms) => {
|
||||||
|
now += ms;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
test("mask packets are single-flight and canceled geometry is repaired before masking", () => {
|
||||||
|
const { processor, mailbox, messages } = setup();
|
||||||
|
// Cancel intentionally leaves provisional worker geometry.
|
||||||
|
const before = processor.engine.mesh.positions.slice();
|
||||||
|
processor.frame();
|
||||||
|
assert.notDeepEqual(processor.engine.mesh.positions, before);
|
||||||
|
processor.cancel(1);
|
||||||
|
const sample = { x: 420, y: 290, pressure: 0.8 };
|
||||||
|
const version = mailbox.write(2, sample);
|
||||||
|
processor.begin({
|
||||||
|
id: 2,
|
||||||
|
version,
|
||||||
|
sample,
|
||||||
|
positions: before,
|
||||||
|
settings: { ...defaults, brush: "mask", symmetry: false },
|
||||||
|
camera: { yaw: 0, pitch: 0, zoom: 1.5, target: [0, 0, 0] },
|
||||||
|
rect: { width: 800, height: 600 },
|
||||||
|
hit: { position: [0, 0, 1], normal: [0, 0, 1] },
|
||||||
|
});
|
||||||
|
// Independently rebuild at the fixture's actual level.
|
||||||
|
const fresh = new Sculpt(sphere(processor.engine.mesh.level));
|
||||||
|
assert.deepEqual(processor.engine.mesh.positions, fresh.mesh.positions);
|
||||||
|
assert.deepEqual(processor.engine.mesh.normals, fresh.mesh.normals);
|
||||||
|
const start = messages.length;
|
||||||
|
processor.frame();
|
||||||
|
const packet = messages.at(-1);
|
||||||
|
assert.equal(packet.type, "mask");
|
||||||
|
const values = packet.masks.slice();
|
||||||
|
const end = mailbox.write(2, { x: 450, y: 310, pressure: 0.9 });
|
||||||
|
processor.end(2, end);
|
||||||
|
processor.frame();
|
||||||
|
assert.equal(messages.length, start + 1);
|
||||||
|
assert.deepEqual(packet.masks, values);
|
||||||
|
processor.acknowledge(packet.packet);
|
||||||
|
processor.frame();
|
||||||
|
const last = messages.at(-1);
|
||||||
|
assert.equal(last.type, "mask");
|
||||||
|
processor.acknowledge(last.packet);
|
||||||
|
processor.frame();
|
||||||
|
assert.equal(messages.at(-1).type, "complete");
|
||||||
|
assert.deepEqual(processor.engine.mesh.positions, before);
|
||||||
|
});
|
||||||
|
test("latest frame target equals feeding only that target, intermediate samples create no hidden queue", () => {
|
||||||
|
const a = setup(),
|
||||||
|
b = setup();
|
||||||
|
a.processor.frame();
|
||||||
|
b.processor.frame();
|
||||||
|
a.ack();
|
||||||
|
b.ack();
|
||||||
|
for (let i = 0; i < 10000; i++)
|
||||||
|
a.mailbox.write(1, {
|
||||||
|
x: 410 + i / 100,
|
||||||
|
y: 300 + Math.sin(i) * 30,
|
||||||
|
pressure: 0.4,
|
||||||
|
});
|
||||||
|
const end = { x: 470, y: 310, pressure: 0.8 };
|
||||||
|
const version = a.mailbox.write(1, end);
|
||||||
|
b.mailbox.write(1, end);
|
||||||
|
a.processor.frame();
|
||||||
|
b.processor.frame();
|
||||||
|
assert.deepEqual(
|
||||||
|
a.processor.engine.mesh.positions,
|
||||||
|
b.processor.engine.mesh.positions,
|
||||||
|
);
|
||||||
|
assert.equal(a.mailbox.processed, version);
|
||||||
|
assert.equal(a.processor.job.target, null);
|
||||||
|
});
|
||||||
|
test("Grab seeds pressure-radius from first effective pressure and ignores a zero-pressure tail", () => {
|
||||||
|
const a = setup(
|
||||||
|
{ brush: "grab", pressureRadius: true },
|
||||||
|
{ x: 400, y: 300, pressure: 0 },
|
||||||
|
);
|
||||||
|
a.processor.frame();
|
||||||
|
const version = a.mailbox.write(1, { x: 460, y: 310, pressure: 0.8 }, 0.25);
|
||||||
|
a.processor.frame();
|
||||||
|
const expected = new Sculpt(sphere(4));
|
||||||
|
expected.begin({ brush: "grab", pressureRadius: true, symmetry: false });
|
||||||
|
expected.dab({ position: [0, 0, 1], normal: [0, 0, 1] }, 0.25, [0, 0, 0]);
|
||||||
|
expected.dab(
|
||||||
|
{ position: [0, 0, 1], normal: [0, 0, 1] },
|
||||||
|
Math.fround(0.8),
|
||||||
|
[0.3, -0.05, 0],
|
||||||
|
);
|
||||||
|
assert.deepEqual(a.processor.engine.mesh.positions, expected.mesh.positions);
|
||||||
|
assert.equal(a.mailbox.processed, version);
|
||||||
|
});
|
||||||
|
test("one immutable output in flight, new dirty work survives acknowledgement, end waits for applied output", () => {
|
||||||
|
const a = setup();
|
||||||
|
a.processor.frame();
|
||||||
|
const first = a.messages.find((m) => m.type === "geometry"),
|
||||||
|
bytes = first.vertices.slice();
|
||||||
|
const version = a.mailbox.write(1, { x: 470, y: 310, pressure: 0.9 });
|
||||||
|
a.processor.end(1, version);
|
||||||
|
a.processor.frame();
|
||||||
|
assert.deepEqual(first.vertices, bytes);
|
||||||
|
assert.equal(a.messages.filter((m) => m.type === "geometry").length, 1);
|
||||||
|
assert.ok(a.processor.engine.dirty.size);
|
||||||
|
assert.ok(!a.messages.some((m) => m.type === "complete"));
|
||||||
|
a.processor.acknowledge(first.packet);
|
||||||
|
a.processor.frame();
|
||||||
|
const second = a.messages.filter((m) => m.type === "geometry").at(-1);
|
||||||
|
assert.notEqual(second.packet, first.packet);
|
||||||
|
a.processor.acknowledge(first.packet);
|
||||||
|
assert.equal(a.processor.outstanding, second.packet);
|
||||||
|
a.processor.acknowledge(second.packet);
|
||||||
|
a.processor.frame();
|
||||||
|
assert.equal(a.messages.at(-1).type, "complete");
|
||||||
|
});
|
||||||
|
test("worker bounds real work; unfinished segment age is not reset by release or newer samples", () => {
|
||||||
|
const a = setup();
|
||||||
|
a.processor.frame();
|
||||||
|
a.ack();
|
||||||
|
const dab = a.processor.engine.dab.bind(a.processor.engine);
|
||||||
|
let calls = 0;
|
||||||
|
a.processor.engine.dab = (...args) => {
|
||||||
|
calls++;
|
||||||
|
a.advance(5);
|
||||||
|
dab(...args);
|
||||||
|
};
|
||||||
|
a.mailbox.write(1, { x: 590, y: 300, pressure: 0.8 });
|
||||||
|
a.processor.frame();
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
assert.ok(a.processor.job.target);
|
||||||
|
a.advance(250);
|
||||||
|
const latest = a.mailbox.write(1, { x: 610, y: 305, pressure: 0.9 });
|
||||||
|
a.processor.end(1, latest);
|
||||||
|
a.processor.frame();
|
||||||
|
assert.equal(a.processor.job, null);
|
||||||
|
assert.equal(a.messages.at(-1).type, "canceled");
|
||||||
|
});
|
||||||
|
test("resuming a segment preserves independently computed positions and pressure", () => {
|
||||||
|
const emitted = [],
|
||||||
|
path = new StrokePath(5, (p) => emitted.push(p));
|
||||||
|
path.push({ x: 0, y: 0, pressure: 0.2 });
|
||||||
|
while (!path.push({ x: 24, y: 18, pressure: 0.8 }, 1)) {}
|
||||||
|
assert.equal(emitted.length, 7);
|
||||||
|
emitted.forEach((p, i) => {
|
||||||
|
assert.ok(Math.abs(p.x - 4 * i) < 1e-10);
|
||||||
|
assert.ok(Math.abs(p.y - 3 * i) < 1e-10);
|
||||||
|
assert.ok(Math.abs(p.pressure - (0.2 + 0.1 * i)) < 1e-10);
|
||||||
|
});
|
||||||
|
});
|
||||||
Vendored
+14
-5
@@ -87,7 +87,12 @@ declare module "@yawn/handles" {
|
|||||||
stride: number,
|
stride: number,
|
||||||
format: "f32" | "u32" | "i32",
|
format: "f32" | "u32" | "i32",
|
||||||
): Promise<SharedRows>;
|
): Promise<SharedRows>;
|
||||||
reserve(additional: { nodes?: number; materials?: number }): Promise<void>;
|
reserve(additional: {
|
||||||
|
nodes?: number;
|
||||||
|
materials?: number;
|
||||||
|
vertices?: number;
|
||||||
|
indices?: number;
|
||||||
|
}): Promise<void>;
|
||||||
batchWrites<T>(operation: () => T): T;
|
batchWrites<T>(operation: () => T): T;
|
||||||
batchGraphUpdates<T>(operation: () => T | Promise<T>): Promise<T>;
|
batchGraphUpdates<T>(operation: () => T | Promise<T>): Promise<T>;
|
||||||
compileRenderGraph(serialized: string): Promise<string>;
|
compileRenderGraph(serialized: string): Promise<string>;
|
||||||
@@ -149,18 +154,22 @@ declare module "@yawn/handles" {
|
|||||||
export interface MeshOptions extends NodeOptions {
|
export interface MeshOptions extends NodeOptions {
|
||||||
geometryId?: number;
|
geometryId?: number;
|
||||||
material?: PBRMaterial;
|
material?: PBRMaterial;
|
||||||
vertexData?: Partial<Record<VertexKind, ArrayLike<number>>>;
|
vertexData?: VertexStreams;
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
}
|
}
|
||||||
|
export type VertexStreams = Partial<Record<VertexKind, ArrayLike<number>>>;
|
||||||
export class Mesh extends Node {
|
export class Mesh extends Node {
|
||||||
constructor(scene: Scene, options?: MeshOptions);
|
constructor(scene: Scene, options?: MeshOptions);
|
||||||
geometryId: number;
|
geometryId: number;
|
||||||
vertexCount: number;
|
readonly vertexCount: number;
|
||||||
indexCount: number;
|
readonly indexCount: number;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
material: PBRMaterial;
|
material: PBRMaterial;
|
||||||
clone(options?: Omit<MeshOptions, "geometryId" | "vertexData">): Mesh;
|
clone(options?: Omit<MeshOptions, "geometryId" | "vertexData">): Mesh;
|
||||||
setVertexData(kind: VertexKind, data: ArrayLike<number>): Promise<this>;
|
setVertexData(
|
||||||
|
kind: VertexKind | VertexStreams,
|
||||||
|
data?: ArrayLike<number>,
|
||||||
|
): Promise<this>;
|
||||||
setMaterialForFaces(
|
setMaterialForFaces(
|
||||||
material: PBRMaterial,
|
material: PBRMaterial,
|
||||||
faces: number | number[],
|
faces: number | number[],
|
||||||
|
|||||||
Reference in New Issue
Block a user