From a0c359b96453b34b877482f403dc15412972b3b5 Mon Sep 17 00:00:00 2001 From: Kevin Blackburn-Matzen Date: Sat, 18 Jul 2026 21:11:33 -0700 Subject: [PATCH 1/2] Stop tracking tsconfig.tsbuildinfo Fixes #61. It is TypeScript's incremental-build cache, not source. `npm run build` runs `tsc -b`, which rewrites it, so anyone who builds locally gets a dirty working tree and the file lands in unrelated diffs as churn. Its contents are machine-specific, so concurrent developers would conflict on it continuously. Nothing consumes the committed copy -- tsc -b regenerates it when absent, at the cost of one non-incremental build. dist/ and test-results/ were already ignored, so this was the only tracked build artifact. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + tsconfig.tsbuildinfo | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 tsconfig.tsbuildinfo diff --git a/.gitignore b/.gitignore index 7c1cad8..a9130ac 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ data/ test-results/ playwright-report/ .env.swp +tsconfig.tsbuildinfo diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo deleted file mode 100644 index 7938cb0..0000000 --- a/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/auth/oauth.ts","./src/components/auth/loginpage.tsx","./src/components/chat/chatdrawer.tsx","./src/components/landing/herodemo.tsx","./src/components/landing/landingpage.tsx","./src/components/mobile/bottomsheet.tsx","./src/components/mobile/mobilepanel.tsx","./src/components/projects/importproject.tsx","./src/components/projects/projectlist.tsx","./src/components/properties/numberinput.tsx","./src/components/properties/propertypanel.tsx","./src/components/settings/settingspage.tsx","./src/components/share/sharedviewer.tsx","./src/components/toolbar/toolbar.tsx","./src/components/tree/addnodemenu.tsx","./src/components/tree/componentlibrary.tsx","./src/components/tree/nodetreepanel.tsx","./src/components/tree/partspalette.tsx","./src/components/tree/treenode.tsx","./src/components/ui/appmodals.tsx","./src/components/ui/cookieconsent.tsx","./src/components/ui/errorboundary.tsx","./src/components/viewport/dimensionlabels.tsx","./src/components/viewport/shortcutoverlay.tsx","./src/components/viewport/viewport.tsx","./src/components/viewport/viewporttoolbar.tsx","./src/engine/gizmocontroller.ts","./src/engine/outlinepass.ts","./src/engine/sdfmesh.ts","./src/engine/threeengine.ts","./src/engine/engineref.ts","./src/engine/sdfpicking.ts","./src/engine/useevaluator.ts","./src/engine/workerbridge.ts","./src/llm/llmservice.ts","./src/llm/parseresponse.test.ts","./src/llm/parseresponse.ts","./src/llm/systemprompt.ts","./src/storage/github.ts","./src/storage/google.ts","./src/storage/index.ts","./src/storage/thumbnailcache.ts","./src/storage/types.ts","./src/store/authstore.ts","./src/store/chatstore.ts","./src/store/consent.ts","./src/store/localpersist.ts","./src/store/modalstore.ts","./src/store/modelerstore.test.ts","./src/store/modelerstore.ts","./src/store/projectstore.ts","./src/store/viewportstore.ts","./src/test/setup.ts","./src/types/geometry.ts","./src/types/operations.test.ts","./src/types/operations.ts","./src/utils/download.ts","./src/utils/thumbnail.ts","./src/worker/exporters.ts","./src/worker/sdfworker.ts","./src/worker/stlexporter.test.ts","./src/worker/stlexporter.ts","./src/worker/sdf/bounds.test.ts","./src/worker/sdf/bounds.ts","./src/worker/sdf/codegen.test.ts","./src/worker/sdf/codegen.ts","./src/worker/sdf/convert.ts","./src/worker/sdf/dualcontour.test.ts","./src/worker/sdf/dualcontour.ts","./src/worker/sdf/evaluate.test.ts","./src/worker/sdf/evaluate.ts","./src/worker/sdf/marchingcubes.test.ts","./src/worker/sdf/marchingcubes.ts","./src/worker/sdf/simplify.test.ts","./src/worker/sdf/simplify.ts","./src/worker/sdf/tables.ts","./src/worker/sdf/types.ts"],"version":"5.9.3"} \ No newline at end of file From c40e94312c78d04252e8d775783fc73093ad2a1f Mon Sep 17 00:00:00 2001 From: Kevin Blackburn-Matzen Date: Sat, 18 Jul 2026 21:24:42 -0700 Subject: [PATCH 2/2] Add property-based tests over randomly generated SDF trees Part of #52 -- the CPU-side half. The differential CPU-vs-GLSL testing that issue also proposes needs a headless WebGL context and is not included. evaluate.test.ts checks hand-picked points on hand-built shapes. These check invariants that must hold for any tree: 1-Lipschitz (globally and near the surface), finiteness, union commutative / idempotent / exactly min at k=0, translation commuting with evaluation, and smooth union never exceeding sharp union. The Lipschitz bound is the load-bearing one. Sphere tracing steps by the returned distance, so a field that over-reports makes the march overshoot the surface -- which is how thin walls turn into holes in a render. Deterministic by construction: a seeded mulberry32 PRNG rather than a fuzzing library, so failures reproduce exactly, CI cannot flake, and no dependency is added. The generator is restricted to shapes that claim exactness. ellipsoid and cone are approximations in their usual formulations, patterns break the bound at cell boundaries, and text is piecewise -- asserting exactness of those would produce noise rather than findings. The exclusions are documented with reasons in the file. Two guards against the tests passing for the wrong reason: - The near-surface test bisects to the zero level set, because random points in an 8-unit cube mostly land far from any surface where the field is smooth and the check is weak. - It asserts probed > 50, so a bisection that never finds surfaces fails rather than silently skipping every sample. Verified by mutation: over-reporting sphere distance by 1.5x in evaluate.ts makes both Lipschitz tests fail at ratios of 1.500x, 1.497x, 1.496x, and the failure output names the offending trees. Without that check, "8 tests pass" would not be distinguishable from "8 tests that assert nothing". No bugs found. The evaluator is sound on every property tested. Co-Authored-By: Claude Opus 4.8 --- src/worker/sdf/properties.test.ts | 255 ++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 src/worker/sdf/properties.test.ts diff --git a/src/worker/sdf/properties.test.ts b/src/worker/sdf/properties.test.ts new file mode 100644 index 0000000..515ba6d --- /dev/null +++ b/src/worker/sdf/properties.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect } from 'vitest'; +import { evaluateSDF } from './evaluate'; +import type { SDFNode, Vec3 } from './types'; + +/** + * Property-based tests over randomly generated trees (part of #52). + * + * The existing evaluate.test.ts checks hand-picked points on hand-built + * shapes. These check invariants that must hold for *any* tree, which is + * where the cases nobody thought to write live. + * + * Deterministic by construction: a seeded PRNG rather than a fuzzing library, + * so a failure reproduces exactly and CI never flakes. No new dependency. + */ + +/** mulberry32 — small, fast, good enough for test input generation. */ +function rng(seed: number) { + return () => { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Primitives and combinators restricted to those that should preserve the + * Lipschitz bound — i.e. exact distance fields, not bounds or approximations. + * + * Deliberately excluded, with reasons: + * ellipsoid — the standard sdEllipsoid is a lower bound, not exact + * cone — likewise approximate in the usual formulation + * scale — non-uniform scaling does not preserve distance + * patterns — domain repetition breaks the bound near cell boundaries + * text — glyph geometry is piecewise and not claimed exact + * + * Narrowing here is not hiding failures: an approximate field is a legitimate + * design choice, and asserting exactness of it would produce noise rather + * than findings. What matters is that the shapes claiming to be exact are. + */ +function randomExactTree(r: () => number, depth: number): SDFNode { + const leaf = (): SDFNode => { + const pick = Math.floor(r() * 5); + const dim = () => 0.5 + r() * 3; + if (pick === 0) return { kind: 'sphere', radius: dim() }; + if (pick === 1) return { kind: 'box', size: [dim(), dim(), dim()] as Vec3 }; + if (pick === 2) return { kind: 'cylinder', radius: dim(), height: dim() }; + if (pick === 3) return { kind: 'torus', major: 1 + r() * 2, minor: 0.2 + r() }; + return { kind: 'capsule', radius: dim(), height: dim() }; + }; + + if (depth <= 0) return leaf(); + + const pick = Math.floor(r() * 7); + const child = () => randomExactTree(r, depth - 1); + + switch (pick) { + case 0: return { kind: 'union', a: child(), b: child(), k: 0 }; + case 1: return { kind: 'intersect', a: child(), b: child(), k: 0 }; + case 2: return { kind: 'subtract', a: child(), b: child(), k: 0 }; + case 3: return { kind: 'offset', child: child(), distance: (r() - 0.5) * 2 }; + case 4: return { kind: 'round', child: child(), radius: r() * 0.5 }; + case 5: return { kind: 'shell', child: child(), thickness: 0.1 + r() * 0.5 }; + default: return { + kind: 'transform', child: child(), + tx: (r() - 0.5) * 4, ty: (r() - 0.5) * 4, tz: (r() - 0.5) * 4, + rx: r() * Math.PI, ry: r() * Math.PI, rz: r() * Math.PI, + sx: 1, sy: 1, sz: 1, + }; + } +} + +const point = (r: () => number, spread = 8): Vec3 => + [(r() - 0.5) * spread, (r() - 0.5) * spread, (r() - 0.5) * spread]; + +/** + * Bisect to a point on the surface. Random points in an 8-unit cube land far + * from any surface, where the field is smooth and well-behaved — so a + * Lipschitz check on them is a much weaker probe than it looks. Violations + * cluster at the zero level set and at the seams where booleans switch + * branches, so that is where the samples need to be. + */ +function findSurfacePoint(tree: SDFNode, r: () => number): Vec3 | null { + let inside: Vec3 | null = null; + let outside: Vec3 | null = null; + for (let i = 0; i < 400 && (!inside || !outside); i++) { + const p = point(r, 10); + const v = evaluateSDF(tree, p); + if (!Number.isFinite(v)) continue; + if (v < 0 && !inside) inside = p; + if (v > 0 && !outside) outside = p; + } + if (!inside || !outside) return null; + + let lo = inside; + let hi = outside; + for (let i = 0; i < 50; i++) { + const mid: Vec3 = [(lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2]; + if (evaluateSDF(tree, mid) < 0) lo = mid; else hi = mid; + } + return lo; +} + +const dist = (a: Vec3, b: Vec3) => + Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); + +describe('SDF invariants over random trees', () => { + it('is 1-Lipschitz: |f(a) - f(b)| <= |a - b|', () => { + // Sphere tracing steps by the returned distance. If the field reports a + // distance larger than the true one, the march overshoots the surface and + // the render shows holes — thin walls being the usual casualty. This is + // the single most load-bearing property of an SDF. + const r = rng(0x5eed); + const violations: string[] = []; + + for (let i = 0; i < 300; i++) { + const tree = randomExactTree(r, 2 + Math.floor(r() * 2)); + for (let j = 0; j < 20; j++) { + const a = point(r); + const b = point(r); + const fa = evaluateSDF(tree, a); + const fb = evaluateSDF(tree, b); + if (!Number.isFinite(fa) || !Number.isFinite(fb)) continue; + const ratio = Math.abs(fa - fb) / Math.max(dist(a, b), 1e-9); + // 1e-6 slack for float accumulation through deep trees. + if (ratio > 1 + 1e-6) { + violations.push(`ratio ${ratio.toFixed(4)} on ${JSON.stringify(tree).slice(0, 120)}`); + } + } + } + + expect(violations.slice(0, 3)).toEqual([]); + }); + + it('produces finite values everywhere', () => { + const r = rng(0xf17e); + for (let i = 0; i < 300; i++) { + const tree = randomExactTree(r, 2 + Math.floor(r() * 2)); + for (let j = 0; j < 20; j++) { + expect(Number.isFinite(evaluateSDF(tree, point(r)))).toBe(true); + } + } + }); + + it('union is commutative', () => { + const r = rng(0xc0); + for (let i = 0; i < 200; i++) { + const a = randomExactTree(r, 2); + const b = randomExactTree(r, 2); + const p = point(r); + const ab = evaluateSDF({ kind: 'union', a, b, k: 0 }, p); + const ba = evaluateSDF({ kind: 'union', a: b, b: a, k: 0 }, p); + expect(ab).toBeCloseTo(ba, 10); + } + }); + + it('union and intersect are idempotent', () => { + const r = rng(0x1de); + for (let i = 0; i < 200; i++) { + const a = randomExactTree(r, 2); + const p = point(r); + const self = evaluateSDF(a, p); + expect(evaluateSDF({ kind: 'union', a, b: a, k: 0 }, p)).toBeCloseTo(self, 10); + expect(evaluateSDF({ kind: 'intersect', a, b: a, k: 0 }, p)).toBeCloseTo(self, 10); + } + }); + + it('union is exactly the pointwise minimum at k = 0', () => { + const r = rng(0x11); + for (let i = 0; i < 200; i++) { + const a = randomExactTree(r, 2); + const b = randomExactTree(r, 2); + const p = point(r); + const expected = Math.min(evaluateSDF(a, p), evaluateSDF(b, p)); + expect(evaluateSDF({ kind: 'union', a, b, k: 0 }, p)).toBeCloseTo(expected, 10); + } + }); + + it('translation commutes with evaluation', () => { + // Evaluating a translated shape at p must equal evaluating the original + // at p - t. This catches sign errors in the inverse transform, which are + // easy to write and produce a shape that moves the wrong way. + const r = rng(0x71a); + for (let i = 0; i < 200; i++) { + const child = randomExactTree(r, 2); + const t = point(r, 6); + const moved: SDFNode = { + kind: 'transform', child, + tx: t[0], ty: t[1], tz: t[2], + rx: 0, ry: 0, rz: 0, sx: 1, sy: 1, sz: 1, + }; + const p = point(r); + const shifted: Vec3 = [p[0] - t[0], p[1] - t[1], p[2] - t[2]]; + expect(evaluateSDF(moved, p)).toBeCloseTo(evaluateSDF(child, shifted), 8); + } + }); + + it('smooth union never exceeds the sharp union', () => { + // Smoothing may only pull the surface outward (more negative), never + // inward — otherwise a blend would carve material away rather than add + // a fillet. + const r = rng(0x53000); + for (let i = 0; i < 200; i++) { + const a = randomExactTree(r, 2); + const b = randomExactTree(r, 2); + const p = point(r); + const k = 0.1 + r() * 0.8; + const sharp = evaluateSDF({ kind: 'union', a, b, k: 0 }, p); + const smooth = evaluateSDF({ kind: 'union', a, b, k }, p); + expect(smooth).toBeLessThanOrEqual(sharp + 1e-9); + } + }); + it('is 1-Lipschitz near the surface', () => { + // The sharp version of the test above. Samples are taken within a small + // radius of a bisected surface point, so they straddle the zero level set + // and the boolean seams where the bound is actually at risk. + const r = rng(0x5017ace); + const violations: { ratio: number; tree: string }[] = []; + let probed = 0; + + for (let i = 0; i < 250; i++) { + const tree = randomExactTree(r, 2 + Math.floor(r() * 2)); + const surf = findSurfacePoint(tree, r); + if (!surf) continue; + probed++; + + for (let j = 0; j < 40; j++) { + const jitter = (scale: number): Vec3 => [ + surf[0] + (r() - 0.5) * scale, + surf[1] + (r() - 0.5) * scale, + surf[2] + (r() - 0.5) * scale, + ]; + const scale = 0.001 + r() * 0.5; + const a = jitter(scale); + const b = jitter(scale); + const fa = evaluateSDF(tree, a); + const fb = evaluateSDF(tree, b); + if (!Number.isFinite(fa) || !Number.isFinite(fb)) continue; + const d = dist(a, b); + if (d < 1e-9) continue; + const ratio = Math.abs(fa - fb) / d; + if (ratio > 1 + 1e-6) { + violations.push({ ratio, tree: JSON.stringify(tree) }); + } + } + } + + // Guard against the test passing because it never probed anything. + expect(probed).toBeGreaterThan(50); + + const worst = violations.sort((x, y) => y.ratio - x.ratio).slice(0, 3); + expect(worst.map((v) => `${v.ratio.toFixed(3)}x ${v.tree.slice(0, 200)}`)).toEqual([]); + }); +});