// Tests of the initial-shape features (no GPU needed): the text encoding, the // removal of the mean plane, the geometry of the individual features, and // the random configurations. import { test } from "node:test"; import assert from "node:assert/strict"; import { defaultParams, makeGrid, nodeWeight, nodeX, nodeY, type Grid, type PhysParams } from "../src/params.ts"; import { encodeFeatures, initialShape, parseFeatures, randomFeatures, shapeStats, type ShapeFeature } from "../src/shape.ts"; function withFeatures(features: ShapeFeature[], extra: Partial = {}): PhysParams { return { ...defaultParams, ...extra, features }; } /** Mass-weighted mean, x-moment, and y-moment of a field over the sheet, normalized by the total weight. */ function planeMoments(w0: ArrayLike, g: Grid): [number, number, number] { let sw = 0, s = 0, sx = 0, sy = 0; for (let j = 0; j < g.ny; j++) { for (let i = 0; i < g.nx; i++) { const wgt = nodeWeight(g, i, j); const v = w0[j * g.nx + i]; sw += wgt; s += wgt * v; sx += wgt * nodeX(g, i) * v; sy += wgt * nodeY(g, j) * v; } } return [s / sw, sx / sw, sy / sw]; } const everything = "bow:x,0.003,0.4;bow:y,-0.001,0;twist:0.0008;dent:0.3,0.7,0.03,0.0015;crease:0.6,0.4,25,-1.5,0.015;noise:11,0.0005,0.05"; test("features encode to text and back", () => { const f = parseFeatures(everything); assert.ok(f); assert.equal(f.length, 6); assert.equal(encodeFeatures(f), everything); assert.deepEqual(parseFeatures("none"), []); assert.deepEqual(parseFeatures(""), []); assert.equal(parseFeatures("nonsense"), null); // An unreadable entry is skipped, the rest is kept. assert.equal(encodeFeatures(parseFeatures("bow:z,1,0;twist:0.001")!), "twist:0.001"); assert.equal(encodeFeatures(defaultParams.features), "bow:x,0.003,0"); }); test("the mean plane is removed on the rectangle and on the ellipse", () => { for (const shape of ["rectangle", "ellipse"] as const) { const p = withFeatures(parseFeatures(everything)!, { shape }); const g = makeGrid(p); const w0 = initialShape(p, g); const [m, mx, my] = planeMoments(w0, g); const { peakToPeak } = shapeStats(w0, g); assert.ok(peakToPeak > 3e-3, `${shape}: the shape is not flat (${peakToPeak})`); assert.ok(Math.abs(m) < 1e-6 * peakToPeak, `${shape}: mean ${m}`); assert.ok(Math.abs(mx) < 1e-6 * peakToPeak * p.Lx, `${shape}: x tilt ${mx}`); assert.ok(Math.abs(my) < 1e-6 * peakToPeak * p.Ly, `${shape}: y tilt ${my}`); // Nodes outside the sheet carry no shape. for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) if (nodeWeight(g, i, j) === 0) assert.equal(w0[j * g.nx + i], 0); } }); test("a bow across the width sags by the given amount and is flat along the height", () => { const p = withFeatures([{ type: "bow", axis: "x", sag: 3e-3, offset: 0 }]); const g = makeGrid(p); const w0 = initialShape(p, g); const mid = Math.floor(g.ny / 2), ci = (g.nx - 1) / 2; const center = w0[mid * g.nx + Math.floor(ci)], edge = w0[mid * g.nx]; assert.ok(Math.abs(center - edge - 3e-3 * (1 - ((Math.floor(ci) - ci) * 2 / (g.nx - 1)) ** 2)) < 1e-9, "sag"); for (let j = 0; j < g.ny; j++) assert.ok(Math.abs(w0[j * g.nx + 3] - w0[3]) < 1e-12, "cylindrical"); // The apex offset moves the crest toward the right edge. const q = withFeatures([{ type: "bow", axis: "x", sag: 3e-3, offset: 0.5 }]); const v = initialShape(q, g); let best = 0; for (let i = 0; i < g.nx; i++) if (v[mid * g.nx + i] > v[mid * g.nx + best]) best = i; assert.ok(best > ci, "crest right of center"); }); test("a crease tilts the two sides by half the fold angle", () => { const fold = 2; const p = withFeatures([{ type: "crease", x: 0.5, y: 0.5, angle: 90, fold, width: 0 }]); const g = makeGrid(p); const w0 = initialShape(p, g); const { maxSlope } = shapeStats(w0, g); const expected = Math.tan((fold * Math.PI) / 360); assert.ok(Math.abs(maxSlope - expected) < 0.05 * expected, `slope ${maxSlope} vs ${expected}`); // A positive fold is a ridge: the line is the highest part after the plane is removed. const mid = Math.floor(g.ny / 2); const onLine = w0[mid * g.nx + Math.floor((g.nx - 1) / 2)]; assert.ok(onLine > w0[mid * g.nx] && onLine > w0[mid * g.nx + g.nx - 1], "ridge"); }); test("a dent has the requested height and radius", () => { const p = withFeatures([{ type: "dent", x: 0.5, y: 0.5, radius: 0.04, height: 2e-3 }], { spacing: 0.004 }); const g = makeGrid(p); const w0 = initialShape(p, g); const ci = Math.floor((g.nx - 1) / 2), cj = Math.floor((g.ny - 1) / 2); const center = w0[cj * g.nx + ci]; const far = w0[cj * g.nx]; assert.ok(Math.abs(center - far - 2e-3) < 1e-4, `height ${center - far}`); const atRadius = w0[cj * g.nx + ci + Math.round(0.04 / g.hx)]; assert.ok(Math.abs((atRadius - far) / (center - far) - Math.exp(-1)) < 0.03, "1/e at the radius"); }); test("the random field has the requested RMS and depends on the seed but not on the grid", () => { const p = withFeatures([{ type: "noise", seed: 5, rms: 1e-3, length: 0.06 }]); const g = makeGrid(p); const w0 = initialShape(p, g); let sw = 0, s2 = 0; for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) { const wgt = nodeWeight(g, i, j); sw += wgt; s2 += wgt * w0[j * g.nx + i] ** 2; } const rms = Math.sqrt(s2 / sw); // The plane removal takes a little of the variance away. assert.ok(rms > 0.8e-3 && rms <= 1.0e-3 + 1e-9, `rms ${rms}`); const other = initialShape(withFeatures([{ type: "noise", seed: 6, rms: 1e-3, length: 0.06 }]), g); let diff = 0; for (let k = 0; k < g.n; k++) diff = Math.max(diff, Math.abs(other[k] - w0[k])); assert.ok(diff > 0.5e-3, "another seed gives another field"); // The same seed on a finer grid gives the same field at the shared nodes. const fine = makeGrid({ ...p, spacing: 0.004 }); const wf = initialShape(p, fine); assert.equal(fine.nx, 2 * g.nx - 1); let err = 0; for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) err = Math.max(err, Math.abs(wf[2 * j * fine.nx + 2 * i] - w0[j * g.nx + i])); assert.ok(err < 2e-5, `grid dependence ${err}`); }); test("random configurations are readable and stay within the shallow-shell range", () => { let seed = 12345; const rnd = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296); const types = new Set(); for (let trial = 0; trial < 40; trial++) { const features = randomFeatures(defaultParams, 3e-3, rnd); for (const f of features) types.add(f.type); assert.equal(features[0].type, "bow"); const text = encodeFeatures(features); assert.equal(encodeFeatures(parseFeatures(text)!), text); const p = withFeatures(features); const g = makeGrid(p); const { peakToPeak, maxSlope } = shapeStats(initialShape(p, g), g); assert.ok(peakToPeak > 0.5e-3 && peakToPeak < 15e-3, `peak to peak ${peakToPeak}`); assert.ok(maxSlope < 0.1, `slope ${maxSlope}`); } for (const t of ["bow", "twist", "dent", "crease", "noise"]) assert.ok(types.has(t), `${t} was drawn`); });