Compare commits
4
Commits
bc957cf40d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d279ec4123 | ||
|
|
d930d8172f | ||
|
|
347b676cf1 | ||
|
|
eb8aa0e9dc |
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",
|
||||||
|
|||||||
+214
-68
@@ -230,6 +230,9 @@ function pbrShader(
|
|||||||
transmission: boolean,
|
transmission: boolean,
|
||||||
textureInfo: ArrayLike<number>,
|
textureInfo: ArrayLike<number>,
|
||||||
extensionInfo: ArrayLike<number>,
|
extensionInfo: ArrayLike<number>,
|
||||||
|
sharedOcclusion: boolean,
|
||||||
|
lightMask: number,
|
||||||
|
directToCanvas: boolean,
|
||||||
) {
|
) {
|
||||||
const names = [
|
const names = [
|
||||||
"baseTexture",
|
"baseTexture",
|
||||||
@@ -306,11 +309,11 @@ struct VertexOutput {
|
|||||||
@group(0) @binding(5) var<storage, read> meshInfo: array<u32>;
|
@group(0) @binding(5) var<storage, read> meshInfo: array<u32>;
|
||||||
@group(0) @binding(6) var<storage, read> materials: array<vec4<f32>>;
|
@group(0) @binding(6) var<storage, read> materials: array<vec4<f32>>;
|
||||||
@group(0) @binding(7) var<storage, read> cameraMatrices: array<vec4<f32>>;
|
@group(0) @binding(7) var<storage, read> cameraMatrices: array<vec4<f32>>;
|
||||||
@group(0) @binding(8) var<storage, read> pointLights: array<vec4<f32>>;
|
${lightMask & 1 ? "@group(0) @binding(8) var<storage, read> pointLights: array<vec4<f32>>;" : ""}
|
||||||
@group(0) @binding(9) var<storage, read> spotLights: array<vec4<f32>>;
|
${lightMask & 2 ? "@group(0) @binding(9) var<storage, read> spotLights: array<vec4<f32>>;" : ""}
|
||||||
@group(0) @binding(10) var<storage, read> directionalLights: array<vec4<f32>>;
|
${lightMask & 4 ? "@group(0) @binding(10) var<storage, read> directionalLights: array<vec4<f32>>;" : ""}
|
||||||
${mask ? "@group(1) @binding(0) var materialSampler: sampler;" : ""}
|
${mask ? "@group(1) @binding(0) var materialSampler: sampler;" : ""}
|
||||||
${names.map((name, slot) => (texture(slot) ? `@group(1) @binding(${slot + 1}) var ${name}: texture_2d<f32>;` : "")).join("\n")}
|
${names.map((name, slot) => (texture(slot) && !(slot === 4 && sharedOcclusion) ? `@group(1) @binding(${slot + 1}) var ${name}: texture_2d<f32>;` : "")).join("\n")}
|
||||||
${transmission ? "@group(2) @binding(0) var sceneColor: texture_2d<f32>;\n@group(2) @binding(1) var sceneSampler: sampler;" : ""}
|
${transmission ? "@group(2) @binding(0) var sceneColor: texture_2d<f32>;\n@group(2) @binding(1) var sceneSampler: sampler;" : ""}
|
||||||
|
|
||||||
fn rotate(rotor: vec4<f32>, value: vec3<f32>) -> vec3<f32> {
|
fn rotate(rotor: vec4<f32>, value: vec3<f32>) -> vec3<f32> {
|
||||||
@@ -357,11 +360,10 @@ fn pow5(value: f32) -> f32 {
|
|||||||
return squared * squared * value;
|
return squared * squared * value;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn directContribution(normal: vec3<f32>, view: vec3<f32>, lightDirection: vec3<f32>, radiance: vec3<f32>, baseColor: vec3<f32>, metallic: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
|
fn directContribution(normal: vec3<f32>, view: vec3<f32>, lightDirection: vec3<f32>, radiance: vec3<f32>, diffuseColor: vec3<f32>, nDotV: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
|
||||||
let nDotL = max(dot(normal, lightDirection), 0.0);
|
let nDotL = max(dot(normal, lightDirection), 0.0);
|
||||||
if (nDotL <= 0.0) { return vec3(0.0); }
|
if (nDotL <= 0.0) { return vec3(0.0); }
|
||||||
let halfVector = normalize(lightDirection + view);
|
let halfVector = normalize(lightDirection + view);
|
||||||
let nDotV = max(dot(normal, view), 0.0001);
|
|
||||||
let nDotH = max(dot(normal, halfVector), 0.0);
|
let nDotH = max(dot(normal, halfVector), 0.0);
|
||||||
let vDotH = max(dot(view, halfVector), 0.0);
|
let vDotH = max(dot(view, halfVector), 0.0);
|
||||||
let fresnel = f0 + (vec3(1.0) - f0) * pow5(1.0 - vDotH);
|
let fresnel = f0 + (vec3(1.0) - f0) * pow5(1.0 - vDotH);
|
||||||
@@ -372,11 +374,14 @@ fn directContribution(normal: vec3<f32>, view: vec3<f32>, lightDirection: vec3<f
|
|||||||
let geometryV = nDotV / mix(nDotV, 1.0, alpha * 0.5);
|
let geometryV = nDotV / mix(nDotV, 1.0, alpha * 0.5);
|
||||||
let geometryL = nDotL / mix(nDotL, 1.0, alpha * 0.5);
|
let geometryL = nDotL / mix(nDotL, 1.0, alpha * 0.5);
|
||||||
let specular = distribution * geometryV * geometryL * fresnel / max(4.0 * nDotV * nDotL, 0.0001);
|
let specular = distribution * geometryV * geometryL * fresnel / max(4.0 * nDotV * nDotL, 0.0001);
|
||||||
let diffuse = baseColor * (vec3(1.0) - fresnel) * (1.0 - metallic) / 3.14159265;
|
let diffuse = diffuseColor * (vec3(1.0) - fresnel);
|
||||||
return (diffuse + specular) * radiance * nDotL;
|
return (diffuse + specular) * radiance * nDotL;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pointContribution(index: u32, normal: vec3<f32>, view: vec3<f32>, world: vec3<f32>, baseColor: vec3<f32>, metallic: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
|
${
|
||||||
|
lightMask & 1
|
||||||
|
? `
|
||||||
|
fn pointContribution(index: u32, normal: vec3<f32>, view: vec3<f32>, world: vec3<f32>, diffuseColor: vec3<f32>, nDotV: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
|
||||||
let colorIntensity = pointLights[index * 2u];
|
let colorIntensity = pointLights[index * 2u];
|
||||||
let state = pointLights[index * 2u + 1u];
|
let state = pointLights[index * 2u + 1u];
|
||||||
if (state.y == 0.0 || state.x <= 0.0) { return vec3(0.0); }
|
if (state.y == 0.0 || state.x <= 0.0) { return vec3(0.0); }
|
||||||
@@ -385,10 +390,16 @@ fn pointContribution(index: u32, normal: vec3<f32>, view: vec3<f32>, world: vec3
|
|||||||
let falloff = max(1.0 - distance / state.x, 0.0);
|
let falloff = max(1.0 - distance / state.x, 0.0);
|
||||||
let radiance = colorIntensity.rgb * colorIntensity.a * falloff * falloff * 0.08;
|
let radiance = colorIntensity.rgb * colorIntensity.a * falloff * falloff * 0.08;
|
||||||
let sourceRoughness = clamp(roughness + max(state.z, 0.0) / max(distance, 0.0001), roughness, 1.0);
|
let sourceRoughness = clamp(roughness + max(state.z, 0.0) / max(distance, 0.0001), roughness, 1.0);
|
||||||
return directContribution(normal, view, offset / max(distance, 0.0001), radiance, baseColor, metallic, sourceRoughness, f0);
|
return directContribution(normal, view, offset / max(distance, 0.0001), radiance, diffuseColor, nDotV, sourceRoughness, f0);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spotContribution(index: u32, normal: vec3<f32>, view: vec3<f32>, world: vec3<f32>, baseColor: vec3<f32>, metallic: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
|
${
|
||||||
|
lightMask & 2
|
||||||
|
? `
|
||||||
|
fn spotContribution(index: u32, normal: vec3<f32>, view: vec3<f32>, world: vec3<f32>, diffuseColor: vec3<f32>, nDotV: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
|
||||||
let colorIntensity = spotLights[index * 2u];
|
let colorIntensity = spotLights[index * 2u];
|
||||||
let state = spotLights[index * 2u + 1u];
|
let state = spotLights[index * 2u + 1u];
|
||||||
if (state.w == 0.0 || state.x <= 0.0) { return vec3(0.0); }
|
if (state.w == 0.0 || state.x <= 0.0) { return vec3(0.0); }
|
||||||
@@ -398,16 +409,27 @@ fn spotContribution(index: u32, normal: vec3<f32>, view: vec3<f32>, world: vec3<
|
|||||||
let cone = smoothstep(cos(state.z), cos(state.y), dot(direction, fromLight / max(distance, 0.0001)));
|
let cone = smoothstep(cos(state.z), cos(state.y), dot(direction, fromLight / max(distance, 0.0001)));
|
||||||
let falloff = max(1.0 - distance / state.x, 0.0);
|
let falloff = max(1.0 - distance / state.x, 0.0);
|
||||||
let radiance = colorIntensity.rgb * colorIntensity.a * cone * falloff * falloff * 0.08;
|
let radiance = colorIntensity.rgb * colorIntensity.a * cone * falloff * falloff * 0.08;
|
||||||
return directContribution(normal, view, -fromLight / max(distance, 0.0001), radiance, baseColor, metallic, roughness, f0);
|
return directContribution(normal, view, -fromLight / max(distance, 0.0001), radiance, diffuseColor, nDotV, roughness, f0);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
fn directionalContribution(index: u32, normal: vec3<f32>, view: vec3<f32>, baseColor: vec3<f32>, metallic: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
|
${
|
||||||
|
lightMask & 4
|
||||||
|
? `
|
||||||
|
fn directionalContribution(index: u32, normal: vec3<f32>, view: vec3<f32>, diffuseColor: vec3<f32>, nDotV: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
|
||||||
let colorIntensity = directionalLights[index * 2u];
|
let colorIntensity = directionalLights[index * 2u];
|
||||||
let state = directionalLights[index * 2u + 1u];
|
let state = directionalLights[index * 2u + 1u];
|
||||||
if (state.x == 0.0) { return vec3(0.0); }
|
if (state.x == 0.0) { return vec3(0.0); }
|
||||||
let lightDirection = normalize(rotate(rotors[index], vec3<f32>(0.0, 0.0, 1.0)));
|
let lightDirection = normalize(rotate(rotors[index], vec3<f32>(0.0, 0.0, 1.0)));
|
||||||
return directContribution(normal, view, lightDirection, colorIntensity.rgb * colorIntensity.a * 0.1, baseColor, metallic, roughness, f0);
|
return directContribution(normal, view, lightDirection, colorIntensity.rgb * colorIntensity.a * 0.1, diffuseColor, nDotV, roughness, f0);
|
||||||
}
|
}
|
||||||
|
`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
${directToCanvas ? canvasOutputShader : ""}
|
||||||
|
|
||||||
@fragment
|
@fragment
|
||||||
fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @location(0) vec4<f32> {
|
fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @location(0) vec4<f32> {
|
||||||
@@ -432,6 +454,7 @@ fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @l
|
|||||||
${normalTexture ? `let tangent = normalize(input.tangent.xyz - normal * dot(input.tangent.xyz, normal)); let bitangent = cross(normal, tangent) * input.tangent.w; let mapped = ${sample(2)}.xyz * 2.0 - 1.0; normal = normalize(mat3x3<f32>(tangent, bitangent, normal) * vec3(mapped.xy * extra.y, mapped.z));` : ""}
|
${normalTexture ? `let tangent = normalize(input.tangent.xyz - normal * dot(input.tangent.xyz, normal)); let bitangent = cross(normal, tangent) * input.tangent.w; let mapped = ${sample(2)}.xyz * 2.0 - 1.0; normal = normalize(mat3x3<f32>(tangent, bitangent, normal) * vec3(mapped.xy * extra.y, mapped.z));` : ""}
|
||||||
let view = normalize(cameraMatrices[4].xyz - input.world);
|
let view = normalize(cameraMatrices[4].xyz - input.world);
|
||||||
let nDotV = max(dot(normal, view), 0.0001);
|
let nDotV = max(dot(normal, view), 0.0001);
|
||||||
|
let diffuseColor = base.rgb * (1.0 - metallic) / 3.14159265;
|
||||||
let specularWeight = extension2.z * ${sample(11)}.a;
|
let specularWeight = extension2.z * ${sample(11)}.a;
|
||||||
let dielectricColor = vec3(${number(dielectric)} * specularWeight) * extension6.rgb * ${sample(12)}.rgb;
|
let dielectricColor = vec3(${number(dielectric)} * specularWeight) * extension6.rgb * ${sample(12)}.rgb;
|
||||||
var f0 = ${specularGlossiness ? "packedMaterial.rgb * extension3.rgb" : "mix(dielectricColor, base.rgb, metallic)"};
|
var f0 = ${specularGlossiness ? "packedMaterial.rgb * extension3.rgb" : "mix(dielectricColor, base.rgb, metallic)"};
|
||||||
@@ -448,30 +471,48 @@ fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @l
|
|||||||
let fresnelPower = pow5(1.0 - nDotV);
|
let fresnelPower = pow5(1.0 - nDotV);
|
||||||
let ambient = vec3<f32>(bitcast<f32>(clusters[${compactAmbientStart}]), bitcast<f32>(clusters[${compactAmbientStart + 1}]), bitcast<f32>(clusters[${compactAmbientStart + 2}]));
|
let ambient = vec3<f32>(bitcast<f32>(clusters[${compactAmbientStart}]), bitcast<f32>(clusters[${compactAmbientStart + 1}]), bitcast<f32>(clusters[${compactAmbientStart + 2}]));
|
||||||
var directLight = vec3<f32>(0.0);
|
var directLight = vec3<f32>(0.0);
|
||||||
|
${
|
||||||
|
lightMask & 1
|
||||||
|
? `
|
||||||
let pointCount = clusters[10];
|
let pointCount = clusters[10];
|
||||||
if (pointCount <= ${compactLightLimit}u) {
|
if (pointCount <= ${compactLightLimit}u) {
|
||||||
for (var light = 0u; light < pointCount; light++) {
|
for (var light = 0u; light < pointCount; light++) {
|
||||||
directLight += pointContribution(clusters[${compactPointStart}u + light], normal, view, input.world, base.rgb, metallic, roughness, f0);
|
directLight += pointContribution(clusters[${compactPointStart}u + light], normal, view, input.world, diffuseColor, nDotV, roughness, f0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (var light = 0u; light < arrayLength(&pointLights) / 2u; light++) {
|
for (var light = 0u; light < arrayLength(&pointLights) / 2u; light++) {
|
||||||
directLight += pointContribution(light, normal, view, input.world, base.rgb, metallic, roughness, f0);
|
directLight += pointContribution(light, normal, view, input.world, diffuseColor, nDotV, roughness, f0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
${
|
||||||
|
lightMask & 2
|
||||||
|
? `
|
||||||
let spotCount = clusters[${compactSpotCount}];
|
let spotCount = clusters[${compactSpotCount}];
|
||||||
if (spotCount <= ${compactLightLimit}u) {
|
if (spotCount <= ${compactLightLimit}u) {
|
||||||
for (var light = 0u; light < spotCount; light++) {
|
for (var light = 0u; light < spotCount; light++) {
|
||||||
directLight += spotContribution(clusters[${compactSpotStart}u + light], normal, view, input.world, base.rgb, metallic, roughness, f0);
|
directLight += spotContribution(clusters[${compactSpotStart}u + light], normal, view, input.world, diffuseColor, nDotV, roughness, f0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (var light = 0u; light < arrayLength(&spotLights) / 2u; light++) {
|
for (var light = 0u; light < arrayLength(&spotLights) / 2u; light++) {
|
||||||
directLight += spotContribution(light, normal, view, input.world, base.rgb, metallic, roughness, f0);
|
directLight += spotContribution(light, normal, view, input.world, diffuseColor, nDotV, roughness, f0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (var light = 0u; light < arrayLength(&directionalLights) / 2u; light++) {
|
`
|
||||||
directLight += directionalContribution(light, normal, view, base.rgb, metallic, roughness, f0);
|
: ""
|
||||||
}
|
}
|
||||||
let occlusion = mix(1.0, ${sample(4)}.r, extension2.w);
|
${
|
||||||
|
lightMask & 4
|
||||||
|
? `
|
||||||
|
for (var light = 0u; light < arrayLength(&directionalLights) / 2u; light++) {
|
||||||
|
directLight += directionalContribution(light, normal, view, diffuseColor, nDotV, roughness, f0);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
let occlusion = mix(1.0, ${sharedOcclusion ? "packedMaterial" : sample(4)}.r, extension2.w);
|
||||||
let emissive = vec3(properties.z, properties.w, extra.x) * ${sample(3, "vec4(1.0)")}.rgb * extension1.w;
|
let emissive = vec3(properties.z, properties.w, extra.x) * ${sample(3, "vec4(1.0)")}.rgb * extension1.w;
|
||||||
${sheen ? "let sheen = extension3.rgb * pow(1.0 - nDotV, mix(2.0, 8.0, extension3.w));" : ""}
|
${sheen ? "let sheen = extension3.rgb * pow(1.0 - nDotV, mix(2.0, 8.0, extension3.w));" : ""}
|
||||||
${
|
${
|
||||||
@@ -508,7 +549,7 @@ fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @l
|
|||||||
color = mix(color, transmitted, transmissionAmount);`
|
color = mix(color, transmitted, transmissionAmount);`
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
return vec4(color * accent.a, ${alphaMode === 2 ? "base.a" : "1.0"});
|
return ${directToCanvas ? "canvasOutput(vec4(color * accent.a, 1.0))" : `vec4(color * accent.a, ${alphaMode === 2 ? "base.a" : "1.0"})`};
|
||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,13 +600,23 @@ function effectFragment(kind: string, options: Record<string, unknown>) {
|
|||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toneMapping(toneMap: string) {
|
||||||
|
return toneMap === "reinhard"
|
||||||
|
? "color / (color + vec3(1.0))"
|
||||||
|
: toneMap === "linear"
|
||||||
|
? "clamp(color, vec3(0.0), vec3(1.0))"
|
||||||
|
: "clamp((color * (2.51 * color + vec3(0.03))) / (color * (2.43 * color + vec3(0.59)) + vec3(0.14)), vec3(0.0), vec3(1.0))";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match the intermediate rgba16float rounding before applying the same presentation transform.
|
||||||
|
const canvasOutputShader = /* wgsl */ `
|
||||||
|
fn canvasOutput(value: vec4<f32>) -> vec4<f32> {
|
||||||
|
let rounded = vec4(unpack2x16float(pack2x16float(value.rg)), unpack2x16float(pack2x16float(value.ba)));
|
||||||
|
let color = max(rounded.rgb, vec3(0.0));
|
||||||
|
return vec4(${toneMapping("aces")}, rounded.a);
|
||||||
|
}`;
|
||||||
|
|
||||||
function presentShader(toneMap: string, composite: "scene" | undefined) {
|
function presentShader(toneMap: string, composite: "scene" | undefined) {
|
||||||
const tone =
|
|
||||||
toneMap === "reinhard"
|
|
||||||
? "color / (color + vec3(1.0))"
|
|
||||||
: toneMap === "linear"
|
|
||||||
? "clamp(color, vec3(0.0), vec3(1.0))"
|
|
||||||
: "clamp((color * (2.51 * color + vec3(0.03))) / (color * (2.43 * color + vec3(0.59)) + vec3(0.14)), vec3(0.0), vec3(1.0))";
|
|
||||||
return `${fullscreenVertex}
|
return `${fullscreenVertex}
|
||||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||||
@group(0) @binding(1) var sourceSampler: sampler;
|
@group(0) @binding(1) var sourceSampler: sampler;
|
||||||
@@ -573,7 +624,7 @@ ${composite === "scene" ? "@group(0) @binding(2) var scene: texture_2d<f32>;" :
|
|||||||
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
|
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
let value = textureSample(source, sourceSampler, input.uv);
|
let value = textureSample(source, sourceSampler, input.uv);
|
||||||
let color = max(${composite === "scene" ? "value.rgb + textureSample(scene, sourceSampler, input.uv).rgb * (1.0 - value.a)" : "value.rgb"}, vec3(0.0));
|
let color = max(${composite === "scene" ? "value.rgb + textureSample(scene, sourceSampler, input.uv).rgb * (1.0 - value.a)" : "value.rgb"}, vec3(0.0));
|
||||||
return vec4(${tone}, ${composite ? "1.0" : "value.a"});
|
return vec4(${toneMapping(toneMap)}, ${composite ? "1.0" : "value.a"});
|
||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,6 +662,8 @@ export class Scene {
|
|||||||
readonly core: YawnCore;
|
readonly core: YawnCore;
|
||||||
readonly ready: Promise<this>;
|
readonly ready: Promise<this>;
|
||||||
readonly hdr: boolean;
|
readonly hdr: boolean;
|
||||||
|
/** Fuse tone mapping into opaque PBR draws when no linear-color consumers need HDR. */
|
||||||
|
readonly directToCanvas: boolean;
|
||||||
readonly clearColor: readonly [number, number, number, number];
|
readonly clearColor: readonly [number, number, number, number];
|
||||||
#graphUpdates = Promise.resolve();
|
#graphUpdates = Promise.resolve();
|
||||||
#renderGraph?: object;
|
#renderGraph?: object;
|
||||||
@@ -635,6 +688,11 @@ export class Scene {
|
|||||||
#signals?: Float32Array;
|
#signals?: Float32Array;
|
||||||
#arrays = new WeakMap<object, object>();
|
#arrays = new WeakMap<object, object>();
|
||||||
#views = new WeakMap<object, object>();
|
#views = new WeakMap<object, object>();
|
||||||
|
#lights = {
|
||||||
|
point: new Set<number>(),
|
||||||
|
spot: new Set<number>(),
|
||||||
|
directional: new Set<number>(),
|
||||||
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
@@ -643,6 +701,7 @@ export class Scene {
|
|||||||
debug?: boolean;
|
debug?: boolean;
|
||||||
fps?: number;
|
fps?: number;
|
||||||
hdr?: boolean;
|
hdr?: boolean;
|
||||||
|
directToCanvas?: boolean;
|
||||||
clearColor?: ArrayLike<number>;
|
clearColor?: ArrayLike<number>;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
@@ -653,6 +712,7 @@ export class Scene {
|
|||||||
)
|
)
|
||||||
throw new TypeError("Scene clearColor must contain four finite numbers");
|
throw new TypeError("Scene clearColor must contain four finite numbers");
|
||||||
this.hdr = options.hdr ?? true;
|
this.hdr = options.hdr ?? true;
|
||||||
|
this.directToCanvas = options.directToCanvas ?? false;
|
||||||
this.clearColor = clearColor as [number, number, number, number];
|
this.clearColor = clearColor as [number, number, number, number];
|
||||||
this.core = new YawnCore(canvas, {
|
this.core = new YawnCore(canvas, {
|
||||||
arenaBytes: options.arenaBytes,
|
arenaBytes: options.arenaBytes,
|
||||||
@@ -1171,6 +1231,21 @@ export class Scene {
|
|||||||
return this.updateRenderGraph();
|
return this.updateRenderGraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Light handles register their lifetime, not intensity; hot edits never select a new variant. */
|
||||||
|
setLight(
|
||||||
|
kind: "point" | "spot" | "directional",
|
||||||
|
id: number,
|
||||||
|
present: boolean,
|
||||||
|
) {
|
||||||
|
const lights = this.#lights[kind];
|
||||||
|
const hadType = lights.size > 0;
|
||||||
|
if (present) lights.add(id);
|
||||||
|
else lights.delete(id);
|
||||||
|
return hadType !== Boolean(lights.size)
|
||||||
|
? this.updateRenderGraph()
|
||||||
|
: Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
updateRenderGraph() {
|
updateRenderGraph() {
|
||||||
this.markDirty(true);
|
this.markDirty(true);
|
||||||
if (this.#graphBatchDepth) {
|
if (this.#graphBatchDepth) {
|
||||||
@@ -1327,16 +1402,38 @@ export class Scene {
|
|||||||
const hasTransmission = forwardDraws.some(
|
const hasTransmission = forwardDraws.some(
|
||||||
(draw) => this.array("materialExtensions").row(draw.material)[0] > 0,
|
(draw) => this.array("materialExtensions").row(draw.material)[0] > 0,
|
||||||
);
|
);
|
||||||
|
// Custom compute can author light rows directly, so retain the generic shader in that case.
|
||||||
|
const lightMask = this.#computePasses.size
|
||||||
|
? 7
|
||||||
|
: (this.#lights.point.size ? 1 : 0) |
|
||||||
|
(this.#lights.spot.size ? 2 : 0) |
|
||||||
|
(this.#lights.directional.size ? 4 : 0);
|
||||||
|
const directToCanvas =
|
||||||
|
this.directToCanvas &&
|
||||||
|
this.hdr &&
|
||||||
|
!this.#computePasses.size &&
|
||||||
|
!this.#effects.size &&
|
||||||
|
!this.#shaders.size &&
|
||||||
|
!hasTransmission &&
|
||||||
|
forwardDraws.length > 0 &&
|
||||||
|
forwardDraws.every(
|
||||||
|
(draw) =>
|
||||||
|
(Math.round(this.array("materials").row(draw.material)[11]) & 3) !==
|
||||||
|
2 &&
|
||||||
|
draw.geometry.data.has("normals") &&
|
||||||
|
draw.geometry.data.has("uvs"),
|
||||||
|
);
|
||||||
const compositeSceneColor =
|
const compositeSceneColor =
|
||||||
hasTransmission && this.#effects.size === 0 && this.#shaders.size === 0;
|
hasTransmission && this.#effects.size === 0 && this.#shaders.size === 0;
|
||||||
const hdrFormat = this.hdr ? "rgba16float" : "rgba8unorm";
|
const hdrFormat = this.hdr ? "rgba16float" : "rgba8unorm";
|
||||||
addTexture({
|
if (!directToCanvas)
|
||||||
id: "hdr",
|
addTexture({
|
||||||
format: hdrFormat,
|
id: "hdr",
|
||||||
size: ["canvas", "canvas", 1],
|
format: hdrFormat,
|
||||||
usage: ["render", "sampled"],
|
size: ["canvas", "canvas", 1],
|
||||||
transient: false,
|
usage: ["render", "sampled"],
|
||||||
});
|
transient: false,
|
||||||
|
});
|
||||||
addTexture({
|
addTexture({
|
||||||
id: "depth",
|
id: "depth",
|
||||||
format: "depth24plus",
|
format: "depth24plus",
|
||||||
@@ -1374,6 +1471,31 @@ export class Scene {
|
|||||||
|
|
||||||
let previous = computeIds.length ? computeIds : ["cluster-lights"];
|
let previous = computeIds.length ? computeIds : ["cluster-lights"];
|
||||||
const bucketRows = new Map<number, number[][]>();
|
const bucketRows = new Map<number, number[][]>();
|
||||||
|
if (directToCanvas) {
|
||||||
|
// Shares attachments with the forward draws, so core merges this constant background
|
||||||
|
// into their physical render pass. Use the same HDR rounding/tone mapping for the clear.
|
||||||
|
renderPipelines.push({
|
||||||
|
id: "canvas-background",
|
||||||
|
code: `${fullscreenVertex}\n${canvasOutputShader}\n@fragment fn fragment() -> @location(0) vec4<f32> { return canvasOutput(vec4<f32>(${this.clearColor.map((value) => (Number.isInteger(value) ? `${value}.0` : String(value))).join(", ")})); }`,
|
||||||
|
vertex: { entry: "vertex" },
|
||||||
|
fragment: { entry: "fragment", targets: [{ format: "canvas" }] },
|
||||||
|
depthStencil: {
|
||||||
|
format: "depth24plus",
|
||||||
|
depth_write_enabled: false,
|
||||||
|
depth_compare: "always",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
passes.push({
|
||||||
|
id: "canvas-background",
|
||||||
|
type: "render",
|
||||||
|
pipeline: "canvas-background",
|
||||||
|
after: previous,
|
||||||
|
color: [{ resource: "canvas", clear: [0, 0, 0, 0] }],
|
||||||
|
depth: { resource: "depth", clear: 1 },
|
||||||
|
draw: { vertices: 3 },
|
||||||
|
});
|
||||||
|
previous = ["canvas-background"];
|
||||||
|
}
|
||||||
if (!forwardDraws.length) {
|
if (!forwardDraws.length) {
|
||||||
renderPipelines.push({
|
renderPipelines.push({
|
||||||
id: "empty-forward",
|
id: "empty-forward",
|
||||||
@@ -1435,10 +1557,14 @@ export class Scene {
|
|||||||
sync: "manual",
|
sync: "manual",
|
||||||
});
|
});
|
||||||
const forwardPipelines = new Map<string, string>();
|
const forwardPipelines = new Map<string, string>();
|
||||||
let firstColor = true;
|
let firstColor = !directToCanvas;
|
||||||
let firstDepth = true;
|
let firstDepth = !directToCanvas;
|
||||||
let sceneColorReady = false;
|
let sceneColorReady = false;
|
||||||
let forwardTarget = compositeSceneColor ? "scene-color" : "hdr";
|
let forwardTarget = directToCanvas
|
||||||
|
? "canvas"
|
||||||
|
: compositeSceneColor
|
||||||
|
? "scene-color"
|
||||||
|
: "hdr";
|
||||||
const attachments = () => {
|
const attachments = () => {
|
||||||
const color = firstColor
|
const color = firstColor
|
||||||
? { clear: [...this.clearColor] }
|
? { clear: [...this.clearColor] }
|
||||||
@@ -1518,6 +1644,13 @@ export class Scene {
|
|||||||
if (extensionInfo[11] <= 0) mask &= ~(1 << 4);
|
if (extensionInfo[11] <= 0) mask &= ~(1 << 4);
|
||||||
if ((flags & 8) !== 0 && !transmitted) mask &= (1 << 0) | (1 << 3);
|
if ((flags & 8) !== 0 && !transmitted) mask &= (1 << 0) | (1 << 3);
|
||||||
const textureInfo = this.array("materialTextureInfo").row(material);
|
const textureInfo = this.array("materialTextureInfo").row(material);
|
||||||
|
// All material samples currently use one sampler. Reuse only identical texture/UV reads.
|
||||||
|
const sharedOcclusion =
|
||||||
|
Boolean(mask & (1 << 1) && mask & (1 << 4)) &&
|
||||||
|
materialTextures[1] === materialTextures[4] &&
|
||||||
|
Array.from(textureInfo)
|
||||||
|
.slice(8, 15)
|
||||||
|
.every((value, lane) => value === textureInfo[32 + lane]);
|
||||||
let clearCompositeLayer = false;
|
let clearCompositeLayer = false;
|
||||||
if (transmitted || (flags & 3) === 2) flushBuckets();
|
if (transmitted || (flags & 3) === 2) flushBuckets();
|
||||||
if (transmitted && !sceneColorReady) {
|
if (transmitted && !sceneColorReady) {
|
||||||
@@ -1558,7 +1691,16 @@ export class Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const code = detailed
|
const code = detailed
|
||||||
? pbrShader(mask, flags, transmitted, textureInfo, extensionInfo)
|
? pbrShader(
|
||||||
|
mask,
|
||||||
|
flags,
|
||||||
|
transmitted,
|
||||||
|
textureInfo,
|
||||||
|
extensionInfo,
|
||||||
|
sharedOcclusion,
|
||||||
|
lightMask,
|
||||||
|
directToCanvas,
|
||||||
|
)
|
||||||
: basicForwardShader;
|
: basicForwardShader;
|
||||||
const pipelineKey = `${flags}-${transmitted ? 1 : 0}\n${code}`;
|
const pipelineKey = `${flags}-${transmitted ? 1 : 0}\n${code}`;
|
||||||
let pipeline = forwardPipelines.get(pipelineKey);
|
let pipeline = forwardPipelines.get(pipelineKey);
|
||||||
@@ -1583,7 +1725,7 @@ export class Scene {
|
|||||||
entry: "fragment",
|
entry: "fragment",
|
||||||
targets: [
|
targets: [
|
||||||
{
|
{
|
||||||
format: hdrFormat,
|
format: directToCanvas ? "canvas" : hdrFormat,
|
||||||
...((flags & 3) === 2
|
...((flags & 3) === 2
|
||||||
? {
|
? {
|
||||||
blend: {
|
blend: {
|
||||||
@@ -1644,7 +1786,7 @@ export class Scene {
|
|||||||
binding: 10,
|
binding: 10,
|
||||||
resource: "directional-lights",
|
resource: "directional-lights",
|
||||||
},
|
},
|
||||||
]
|
].filter((_, index) => lightMask & (1 << index))
|
||||||
: []),
|
: []),
|
||||||
...(detailed && mask
|
...(detailed && mask
|
||||||
? [
|
? [
|
||||||
@@ -1656,7 +1798,9 @@ export class Scene {
|
|||||||
: "material-linear",
|
: "material-linear",
|
||||||
},
|
},
|
||||||
...materialTextures.flatMap((value, lane) =>
|
...materialTextures.flatMap((value, lane) =>
|
||||||
value && (mask & (1 << lane)) !== 0
|
value &&
|
||||||
|
(mask & (1 << lane)) !== 0 &&
|
||||||
|
!(lane === 4 && sharedOcclusion)
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
group: 1,
|
group: 1,
|
||||||
@@ -1770,32 +1914,34 @@ export class Scene {
|
|||||||
previous = [pipeline];
|
previous = [pipeline];
|
||||||
}
|
}
|
||||||
|
|
||||||
const toneMap = String(
|
if (!directToCanvas) {
|
||||||
[...this.#effects.values()].find(
|
const toneMap = String(
|
||||||
(effect) => effect.kind === "colorGrading",
|
[...this.#effects.values()].find(
|
||||||
)?.options.toneMap ?? "aces",
|
(effect) => effect.kind === "colorGrading",
|
||||||
);
|
)?.options.toneMap ?? "aces",
|
||||||
renderPipelines.push({
|
);
|
||||||
id: "present",
|
renderPipelines.push({
|
||||||
code: presentShader(toneMap, compositeSceneColor ? "scene" : undefined),
|
id: "present",
|
||||||
vertex: { entry: "vertex" },
|
code: presentShader(toneMap, compositeSceneColor ? "scene" : undefined),
|
||||||
fragment: { entry: "fragment", targets: [{ format: "canvas" }] },
|
vertex: { entry: "vertex" },
|
||||||
});
|
fragment: { entry: "fragment", targets: [{ format: "canvas" }] },
|
||||||
passes.push({
|
});
|
||||||
id: "present",
|
passes.push({
|
||||||
type: "render",
|
id: "present",
|
||||||
pipeline: "present",
|
type: "render",
|
||||||
after: previous,
|
pipeline: "present",
|
||||||
bindings: [
|
after: previous,
|
||||||
{ group: 0, binding: 0, resource: input },
|
bindings: [
|
||||||
{ group: 0, binding: 1, resource: "linear" },
|
{ group: 0, binding: 0, resource: input },
|
||||||
...(compositeSceneColor
|
{ group: 0, binding: 1, resource: "linear" },
|
||||||
? [{ group: 0, binding: 2, resource: "scene-color" }]
|
...(compositeSceneColor
|
||||||
: []),
|
? [{ group: 0, binding: 2, resource: "scene-color" }]
|
||||||
],
|
: []),
|
||||||
color: [{ resource: "canvas", clear: [...this.clearColor] }],
|
],
|
||||||
draw: { vertices: 3 },
|
color: [{ resource: "canvas", clear: [...this.clearColor] }],
|
||||||
});
|
draw: { vertices: 3 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const graph = {
|
const graph = {
|
||||||
id: "scene",
|
id: "scene",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,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,
|
||||||
@@ -329,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,6 +4,11 @@ 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,
|
bucketDraws,
|
||||||
createScene,
|
createScene,
|
||||||
@@ -34,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);
|
||||||
@@ -451,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"));
|
||||||
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+17
-6
@@ -1,3 +1,5 @@
|
|||||||
|
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 {
|
||||||
@@ -49,6 +51,8 @@ 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. */
|
/** Optional WebGPU features the device has (WebGPU spelling); populated once `ready` resolves. */
|
||||||
features = new Set();
|
features = new Set();
|
||||||
@@ -64,7 +68,7 @@ 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);
|
this.features = new Set(result.features);
|
||||||
for (const descriptor of result.rows) this.#arrays.set(
|
for (const descriptor of result.rows) this.#arrays.set(
|
||||||
@@ -195,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) {
|
||||||
@@ -213,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);
|
||||||
@@ -231,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();
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -302,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);
|
||||||
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
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;
|
||||||
|
|
||||||
@@ -31,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,
|
||||||
}
|
}
|
||||||
@@ -417,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()
|
||||||
@@ -438,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)
|
||||||
@@ -463,7 +459,7 @@ impl ProfileMap {
|
|||||||
.collect();
|
.collect();
|
||||||
drop(bytes);
|
drop(bytes);
|
||||||
self.readback.unmap();
|
self.readback.unmap();
|
||||||
profile
|
Some(profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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);
|
||||||
|
|||||||
+5
-6
@@ -1,7 +1,8 @@
|
|||||||
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.
|
// While a relocation is staged, every request except its commit waits here.
|
||||||
let awaitingCommit = false;
|
let awaitingCommit = false;
|
||||||
const queue = [];
|
const queue = [];
|
||||||
@@ -15,6 +16,8 @@ async function handle(message) {
|
|||||||
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");
|
||||||
@@ -70,11 +73,7 @@ async function handle(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");
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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[], features: string[] }` | Once only; initializes WASM/WebGPU. `features` lists optional device features that were available (`timestamp-query`, `indirect-first-instance`). |
|
| `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` | — | `{ 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` | `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}>` | — | `{ 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. |
|
| `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. |
|
| `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. |
|
||||||
@@ -23,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 are merged into one execution labelled `first … last (N draws)`. 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user