diff --git a/main/opengeometry-three/src/shapes/analytic-solid.ts b/main/opengeometry-three/src/shapes/analytic-solid.ts index def85c1..8648282 100644 --- a/main/opengeometry-three/src/shapes/analytic-solid.ts +++ b/main/opengeometry-three/src/shapes/analytic-solid.ts @@ -1,5 +1,5 @@ import * as THREE from "three"; -import { OGAnalyticBrep, tessellate_brep } from "../../../opengeometry/pkg/opengeometry"; +import { OGAnalyticBrep } from "../../../opengeometry/pkg/opengeometry"; import { getUUID } from "../utils/randomizer"; import { GEOMETRY_DEFLECTION_SHARE, type AnalyticTessellationData } from "../rendering/analytic-tessellation"; import { matrixToWorldTransform } from "../world/world-graph"; @@ -365,10 +365,7 @@ export class AnalyticSolid extends THREE.Group { throw new Error("STL deflection must be finite and positive"); } const placed = this.worldKernel(); - const result = tessellate_brep( - placed.get_brep_serialized(), - deflection * GEOMETRY_DEFLECTION_SHARE, - ); + const result = placed.tessellate(deflection * GEOMETRY_DEFLECTION_SHARE); try { const positions = result.positions(); const indices = result.indices(); @@ -502,8 +499,7 @@ export class AnalyticSolid extends THREE.Group { && (!Number.isSafeInteger(options.maxTriangles) || options.maxTriangles <= 0 || options.maxTriangles > 2_000_000)) { throw new Error("maxTriangles must be an integer between 1 and 2000000"); } - const result = tessellate_brep( - this.kernel.get_brep_serialized(), + const result = this.kernel.tessellate( deflection, options.maxTriangles === undefined ? undefined : JSON.stringify({ max_triangles: options.maxTriangles }), ); @@ -631,25 +627,93 @@ export class AnalyticSolid extends THREE.Group { } } +interface LocalBox { + lo: [number, number, number]; + hi: [number, number, number]; +} + +function arcBoxPointsOf(arc: Extract): [number, number][] { + const quarterTurn = Math.PI / 2; + const axisDirections = [[1, 0], [0, 1], [-1, 0], [0, -1]]; + const endAngle = arc.startAngle + arc.sweepAngle; + const points: [number, number][] = [arc.startAngle, endAngle].map((angle) => [ + arc.center[0] + arc.radius * Math.cos(angle), arc.center[1] + arc.radius * Math.sin(angle), + ]); + const firstQuarter = Math.ceil(Math.min(arc.startAngle, endAngle) / quarterTurn); + const lastStep = Number.isFinite(firstQuarter) + ? Math.min(Math.floor(Math.max(arc.startAngle, endAngle) / quarterTurn) - firstQuarter, 3) + : -1; + for (let step = 0; step <= lastStep; step += 1) { + const direction = axisDirections[(((firstQuarter + step) % 4) + 4) % 4]; + points.push([arc.center[0] + arc.radius * direction[0], arc.center[1] + arc.radius * direction[1]]); + } + return points; +} + +function pointBoxOf(options: AnalyticPrimitiveOptions): LocalBox | undefined { + const points: [number, number, number][] = options.kind === "polygonLoft" + ? [...options.lower, ...options.upper] + : options.kind === "planarPolyhedron" ? [...options.vertices] : []; + const addProfilePoint = (point: [number, number], height: number): void => { + points.push([point[0], point[1], 0], [point[0], point[1], height]); + }; + if (options.kind === "linearExtrusion") { + for (const loop of [options.outer, ...options.holes]) { + for (const point of loop) addProfilePoint(point, options.height); + } + } else if (options.kind === "arcEdgedExtrusion") { + for (const ring of [options.outer, ...(options.holes ?? [])]) { + for (const edge of ring) { + const corners = edge.kind === "line" ? [edge.from, edge.to] : arcBoxPointsOf(edge); + for (const corner of corners) addProfilePoint(corner, options.height); + } + } + } + if (points.length === 0) return undefined; + const box: LocalBox = { lo: [Infinity, Infinity, Infinity], hi: [-Infinity, -Infinity, -Infinity] }; + for (const point of points) { + for (let axis = 0; axis < 3; axis += 1) { + box.lo[axis] = Math.min(box.lo[axis], point[axis]); + box.hi[axis] = Math.max(box.hi[axis], point[axis]); + } + } + return box; +} + +function reachBoxOf(options: AnalyticPrimitiveOptions, box: LocalBox): LocalBox { + if (options.kind !== "arcEdgedExtrusion") return box; + const reachBox: LocalBox = { lo: [box.lo[0], box.lo[1], box.lo[2]], hi: [box.hi[0], box.hi[1], box.hi[2]] }; + for (const edge of options.outer) { + if (edge.kind === "line") continue; + for (const axis of [0, 1]) { + reachBox.lo[axis] = Math.min(reachBox.lo[axis], edge.center[axis] - edge.radius); + reachBox.hi[axis] = Math.max(reachBox.hi[axis], edge.center[axis] + edge.radius); + } + } + return reachBox; +} + +function worldReachOf(frame: AnalyticFrame, box: LocalBox): number { + const farthest = [0, 0, 0]; + for (const alongX of [box.lo[0], box.hi[0]]) { + for (const alongY of [box.lo[1], box.hi[1]]) { + for (const alongZ of [box.lo[2], box.hi[2]]) { + for (let axis = 0; axis < 3; axis += 1) { + const coordinate = frame.origin[axis] + alongX * frame.x[axis] + alongY * frame.y[axis] + alongZ * frame.z[axis]; + farthest[axis] = Math.max(farthest[axis], Math.abs(coordinate)); + } + } + } + } + return Math.hypot(farthest[0], farthest[1], farthest[2]); +} + function createPrimitiveKernel(options: AnalyticPrimitiveOptions, ogid: string): OGAnalyticBrep { - const frame = options.frame ?? (options.kind === "planarPolyhedron" || options.kind === "polygonLoft" + const frame: AnalyticFrame = options.frame ?? (options.kind === "planarPolyhedron" || options.kind === "polygonLoft" ? { origin: [0, 0, 0], x: [1, 0, 0], y: [0, 1, 0], z: [0, 0, 1] } : { origin: [0, 0, 0], x: [1, 0, 0], y: [0, 0, -1], z: [0, 1, 0] }); - const profileExtent = options.kind === "linearExtrusion" - ? [options.outer, ...options.holes].reduce((extent, loop) => ( - loop.reduce((loopExtent, point) => Math.max(loopExtent, Math.abs(point[0]), Math.abs(point[1])), extent) - ), 0) - : options.kind === "arcEdgedExtrusion" - ? [options.outer, ...(options.holes ?? [])].reduce((extent, ring) => ring.reduce((loopExtent, edge) => edge.kind === "line" - ? Math.max(loopExtent, ...edge.from.map(Math.abs), ...edge.to.map(Math.abs)) - : Math.max(loopExtent, Math.abs(edge.center[0]) + edge.radius, Math.abs(edge.center[1]) + edge.radius), extent), 0) - : 0; - const loftExtent = options.kind === "polygonLoft" - ? [...options.lower, ...options.upper].reduce((extent, point) => Math.max(extent, ...point.map(Math.abs)), 0) - : 0; - const polyhedronExtent = options.kind === "planarPolyhedron" - ? options.vertices.reduce((extent, point) => Math.max(extent, Math.abs(point[0]), Math.abs(point[1]), Math.abs(point[2])), 0) - : 0; + const pointBox = pointBoxOf(options); + const pointSize = pointBox ? Math.max(...pointBox.hi.map((high, axis) => high - pointBox.lo[axis])) : 0; const size = options.kind === "torus" ? 2 * (options.majorRadius + options.minorRadius) : options.kind === "coaxialCircleLoft" @@ -659,13 +723,13 @@ function createPrimitiveKernel(options: AnalyticPrimitiveOptions, ogid: string): : options.kind === "chamferedCuboid" ? Math.max(options.width, options.depth, options.height) : options.kind === "linearExtrusion" || options.kind === "arcEdgedExtrusion" - ? Math.max(options.height, profileExtent) + ? pointSize : options.kind === "polygonLoft" - ? loftExtent + ? pointSize : options.kind === "boxWithArchedOpening" ? Math.max(options.width, options.depth, options.height) : options.kind === "planarPolyhedron" - ? polyhedronExtent + ? pointSize : options.kind === "frustum" ? Math.max(2 * options.lowerRadius, 2 * options.upperRadius, options.height) : options.kind === "annularSectorExtrusion" || options.kind === "annularSectorExtrusionWithOpenings" || options.kind === "annularSectorExtrusionWithArchedOpening" @@ -675,7 +739,7 @@ function createPrimitiveKernel(options: AnalyticPrimitiveOptions, ogid: string): : options.kind === "sphere" ? 2 * options.radius : Math.max(2 * options.radius, options.height); - const coordinateFloor = Math.max(...frame.origin.map(Math.abs)) * 64 * Number.EPSILON; + const coordinateFloor = 128 * Number.EPSILON * worldReachOf(frame, pointBox ? reachBoxOf(options, pointBox) : { lo: [-size, -size, -size], hi: [size, size, size] }); const geometric = Math.max(1e-9, size * 1e-8, coordinateFloor); const accuracy = options.accuracy ?? { geometric, intersection: geometric / 4, tessellation: 0.001, exchange: 0.00001, diff --git a/main/opengeometry-three/tests/accuracy-rule.test.mjs b/main/opengeometry-three/tests/accuracy-rule.test.mjs new file mode 100644 index 0000000..51f989e --- /dev/null +++ b/main/opengeometry-three/tests/accuracy-rule.test.mjs @@ -0,0 +1,342 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { afterEach, before, test } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { AnalyticSolid, OpenGeometry } from "../../../dist/index.js"; + +const distFolder = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../dist"); +const consumerAxes = { x: [1, 0, 0], y: [0, 0, -1], z: [0, 1, 0] }; +const madeBodies = []; + +before(async () => { + if (typeof globalThis.document === "undefined") { + globalThis.document = { + createElement: () => ({ style: {}, setAttribute() {}, appendChild() {} }), + }; + } + const wasmBytes = await readFile(path.join(distFolder, "opengeometry", "pkg", "opengeometry_bg.wasm")); + await OpenGeometry.create({ wasmURL: wasmBytes }); +}); + +afterEach(() => { + for (const body of madeBodies.splice(0)) body.dispose(); +}); + +function keep(body) { + madeBodies.push(body); + return body; +} + +function prism(left, bottom, width, depth, height, extra = {}) { + return keep(new AnalyticSolid({ + kind: "linearExtrusion", + frame: { origin: [0, 0, 0], ...consumerAxes }, + outer: [[left, bottom], [left + width, bottom], [left + width, bottom + depth], [left, bottom + depth]], + holes: [], + height, + ...extra, + })); +} + +function wallAt(left, bottom, extra = {}) { + return prism(left, bottom, 5, 0.2, 3, extra); +} + +function movedOffIdentity(body) { + body.position.y = 0.001; + body.updateMatrixWorld(true); + return body; +} + +function assertRelative(actual, expected, relative, label) { + assert.ok( + Math.abs(actual - expected) <= relative * Math.abs(expected), + `${label}: ${actual} is not ${expected} within ${relative} relative`, + ); +} + +test("a wall gets the same default accuracy at every distance from the origin", (context) => { + const atOrigin = wallAt(0, 0).getBrepData().accuracy; + assertRelative(atOrigin.geometric, 5 * 1e-8, 1e-9, "wall at the origin"); + for (const [left, bottom] of [[100, 100], [5000, 5000], [1234.567, -4321.0123]]) { + const accuracy = wallAt(left, bottom).getBrepData().accuracy; + context.diagnostic(`wall at (${left}, ${bottom}): geometric ${accuracy.geometric}`); + assertRelative(accuracy.geometric, atOrigin.geometric, 1e-9, `wall at (${left}, ${bottom}) against the origin`); + assertRelative(accuracy.geometric, 5 * 1e-8, 1e-9, `wall at (${left}, ${bottom})`); + assert.equal(accuracy.intersection, accuracy.geometric / 4); + } +}); + +test("a body centred on the origin gets the accuracy of its full size", (context) => { + const accuracy = prism(-2.6, -2.1, 5.2, 4.2, 0.2).getBrepData().accuracy; + context.diagnostic(`slab 5.2 x 4.2 m centred on the origin: geometric ${accuracy.geometric}`); + assertRelative(accuracy.geometric, 5.2 * 1e-8, 1e-12, "slab centred on the origin"); +}); + +test("a small far body with a matrix passes the placement check of the kernel", (context) => { + const body = movedOffIdentity(prism(50000, 50000, 0.05, 0.02, 0.05)); + const local = body.getBrepData().accuracy.geometric; + const world = JSON.parse(body.getWorldBrepSerialized()).accuracy.geometric; + context.diagnostic(`0.05 m prism at (50000, 50000): geometric ${local}`); + assert.equal(world, local); +}); + +test("a cuboid far on the diagonal with a matrix passes the placement check of the kernel", (context) => { + const body = movedOffIdentity(keep(new AnalyticSolid({ + kind: "cuboid", + width: 1, + depth: 1, + height: 1, + frame: { origin: [3e6, 0, 3e6], ...consumerAxes }, + }))); + const world = JSON.parse(body.getWorldBrepSerialized()).accuracy.geometric; + context.diagnostic(`1 m cuboid at (3e6, 0, 3e6): geometric ${world}`); + assert.equal(world, body.getBrepData().accuracy.geometric); + assertRelative(world, 128 * Number.EPSILON * Math.hypot(3e6 + 1, 1, 3e6 + 1), 1e-12, "floor of the far cuboid"); +}); + +test("an explicit accuracy passes through unchanged", () => { + const accuracy = { geometric: 1e-7, intersection: 2.5e-8, tessellation: 0.001, exchange: 0.00001 }; + assert.deepEqual(wallAt(5000, 5000, { accuracy }).getBrepData().accuracy, accuracy); +}); + +test("a door cut of a far wall equals the cut at the origin after the translation", (context) => { + const cutAt = (left, bottom) => { + const cutter = prism(left + 1.5, bottom - 0.1, 0.9, 0.4, 2.1); + return keep(wallAt(left, bottom).subtract([cutter])).getBrepData().topology; + }; + const near = cutAt(0, 0); + const far = cutAt(5000, 5000); + const translation = [5000, 0, -5000]; + assert.equal(far.faces.length, near.faces.length); + assert.equal(far.edges.length, near.edges.length); + assert.equal(far.vertices.length, near.vertices.length); + let largest = 0; + far.vertices.forEach((vertex, index) => { + vertex.position.forEach((coordinate, axis) => { + const difference = Math.abs(coordinate - translation[axis] - near.vertices[index].position[axis]); + largest = Math.max(largest, difference); + }); + }); + context.diagnostic(`largest vertex difference ${largest} m over ${near.vertices.length} vertices`); + assert.ok(largest <= 1e-9, `largest vertex difference ${largest} m`); +}); + +const degrees = Math.PI / 180; + +function curvedWall({ start, sweep, origin = [0, 0, 0], center = [0, 0], clockwise = false }) { + const [inner, outer] = [80, 80.3]; + const at = (radius, angle) => [center[0] + radius * Math.cos(angle), center[1] + radius * Math.sin(angle)]; + const arc = (radius, startAngle, sweepAngle) => ({ kind: "arc", center, radius, startAngle, sweepAngle }); + const ring = [ + arc(outer, start, sweep), + { kind: "line", from: at(outer, start + sweep), to: at(inner, start + sweep) }, + arc(inner, start + sweep, -sweep), + { kind: "line", from: at(inner, start), to: at(outer, start) }, + ]; + const reversed = [...ring].reverse().map((edge) => (edge.kind === "line" + ? { kind: "line", from: edge.to, to: edge.from } + : arc(edge.radius, edge.startAngle + edge.sweepAngle, -edge.sweepAngle))); + const body = keep(new AnalyticSolid({ + kind: "arcEdgedExtrusion", + frame: { origin, ...consumerAxes }, + outer: clockwise ? reversed : ring, + height: 3, + })); + return body.getBrepData().accuracy.geometric; +} + +function boxRuleOf({ start, sweep, extremes }) { + const [inner, outer] = [80, 80.3]; + const corners = [start, start + sweep].flatMap((angle) => [inner, outer].map( + (radius) => [radius * Math.cos(angle), radius * Math.sin(angle)], + )); + const points = [...corners, ...extremes.map(([alongX, alongY]) => [alongX * outer, alongY * outer])]; + const side = (axis) => Math.max(...points.map((point) => point[axis])) - Math.min(...points.map((point) => point[axis])); + return Math.max(3, side(0), side(1)) * 1e-8; +} + +test("a gently curved wall gets the accuracy of its own box, near and far", (context) => { + const wall = { start: 40 * degrees, sweep: 10 * degrees }; + const expected = boxRuleOf({ ...wall, extremes: [] }); + const near = curvedWall(wall); + const far = curvedWall({ ...wall, origin: [5000, 0, 5000] }); + context.diagnostic(`radius 80 m, sweep 10 degrees: geometric ${near} near, ${far} at (5000, 5000)`); + assertRelative(near, expected, 1e-9, "curved wall at the origin"); + assertRelative(far, expected, 1e-9, "curved wall at (5000, 5000)"); + assert.ok(near < 1e-6); +}); + +test("a short arc beside an axis extreme gets the box of the arc", (context) => { + const cases = [ + { start: 80 * degrees, sweep: 20 * degrees, extremes: [[0, 1]] }, + { start: -10 * degrees, sweep: 20 * degrees, extremes: [[1, 0]] }, + { start: 170 * degrees, sweep: 20 * degrees, extremes: [[-1, 0]] }, + { start: 260 * degrees, sweep: 20 * degrees, extremes: [[0, -1]] }, + ]; + for (const wall of cases) { + const geometric = curvedWall(wall); + context.diagnostic(`sweep from ${Math.round(wall.start / degrees)} degrees: geometric ${geometric}`); + assertRelative(geometric, boxRuleOf(wall), 1e-9, `sweep from ${Math.round(wall.start / degrees)} degrees`); + } +}); + +test("an arc of more than a half turn is as wide as its circle where it passes both extremes", (context) => { + const wall = { start: -135 * degrees, sweep: 270 * degrees, extremes: [[1, 0], [0, 1], [0, -1]] }; + const geometric = curvedWall(wall); + context.diagnostic(`sweep of 270 degrees: geometric ${geometric}`); + assertRelative(geometric, 2 * 80.3 * 1e-8, 1e-9, "sweep of 270 degrees against the circle"); + assertRelative(geometric, boxRuleOf(wall), 1e-9, "sweep of 270 degrees"); +}); + +test("an arc gives the same box in both directions", () => { + for (const wall of [{ start: 40 * degrees, sweep: 10 * degrees, extremes: [] }, { start: 80 * degrees, sweep: 20 * degrees, extremes: [[0, 1]] }]) { + const expected = boxRuleOf(wall); + assertRelative(curvedWall(wall), expected, 1e-9, "counter-clockwise ring"); + assertRelative(curvedWall({ ...wall, clockwise: true }), expected, 1e-9, "clockwise ring"); + } +}); + +test("the box of an arc reaches each axis extreme that its sweep passes", (context) => { + const [radius, halfSweep, depth] = [80, 10 * degrees, 200]; + const missed = []; + for (const quarter of [0, 1, 2, 3]) { + const middle = quarter * 90 * degrees; + const turned = ([along, across]) => [ + along * Math.cos(middle) - across * Math.sin(middle), + along * Math.sin(middle) + across * Math.cos(middle), + ]; + const halfChord = radius * Math.sin(halfSweep); + const arcEnd = (side) => turned([radius * Math.cos(halfSweep), side * halfChord]); + const body = keep(new AnalyticSolid({ + kind: "arcEdgedExtrusion", + frame: { origin: [0, 0, 0], ...consumerAxes }, + outer: [ + { kind: "arc", center: [0, 0], radius, startAngle: middle - halfSweep, sweepAngle: 2 * halfSweep }, + { kind: "line", from: arcEnd(1), to: turned([-depth, halfChord]) }, + { kind: "line", from: turned([-depth, halfChord]), to: turned([-depth, -halfChord]) }, + { kind: "line", from: turned([-depth, -halfChord]), to: arcEnd(-1) }, + ], + height: 3, + })); + const geometric = body.getBrepData().accuracy.geometric; + context.diagnostic(`arc across the extreme at ${quarter * 90} degrees: geometric ${geometric}`); + if (Math.abs(geometric - (radius + depth) * 1e-8) > 1e-9 * geometric) missed.push(quarter * 90); + } + assert.deepEqual(missed, []); +}); + +function arcBodyInItsOwnProcess(startAngle, sweepAngle) { + const script = ` + import { readFile } from "node:fs/promises"; + import { AnalyticSolid, OpenGeometry } from ${JSON.stringify(pathToFileURL(path.join(distFolder, "index.js")).href)}; + globalThis.document ??= { createElement: () => ({ style: {}, setAttribute() {}, appendChild() {} }) }; + await OpenGeometry.create({ wasmURL: await readFile(${JSON.stringify(path.join(distFolder, "opengeometry", "pkg", "opengeometry_bg.wasm"))}) }); + const [startAngle, sweepAngle] = process.argv.slice(-2).map(Number); + const at = (angle) => [2 * Math.cos(angle), 2 * Math.sin(angle)]; + try { + new AnalyticSolid({ + kind: "arcEdgedExtrusion", + outer: [ + { kind: "arc", center: [0, 0], radius: 2, startAngle, sweepAngle }, + { kind: "line", from: at(startAngle + sweepAngle), to: at(startAngle) }, + ], + height: 3, + }).dispose(); + console.log("made"); + } catch (error) { + console.log(typeof error === "string" ? "kernel error " + error : "wrapper error " + String(error)); + } + `; + return spawnSync( + process.execPath, + ["--max-old-space-size=256", "--input-type=module", "-e", script, String(startAngle), String(sweepAngle)], + { encoding: "utf8", timeout: 20000 }, + ); +} + +test("an arc with a huge or infinite angle ends with a body or with the error of the kernel", (context) => { + const outcomes = [[1.5e16, 1], [1e17, 1], [1e300, 1], [0, -1e17], [Infinity, 1], [0, -Infinity]].map( + ([startAngle, sweepAngle]) => { + const child = arcBodyInItsOwnProcess(startAngle, sweepAngle); + const outcome = child.status === 0 ? child.stdout.trim() : `no result (status ${child.status}, signal ${child.signal})`; + context.diagnostic(`start ${startAngle}, sweep ${sweepAngle}: ${outcome.slice(0, 90)}`); + return outcome; + }, + ); + for (const outcome of outcomes) assert.match(outcome, /^(made|kernel error )/); + for (const outcome of outcomes.slice(-2)) assert.match(outcome, /^kernel error /); +}); + +test("a nearly straight arc body with a matrix passes the placement check of the kernel", (context) => { + const rejected = []; + for (const [chord, radius, thickness, height] of [[5, 1.6e6, 0.2, 3], [0.05, 3.2e4, 0.02, 0.05]]) { + const halfSweep = Math.asin(chord / 2 / radius); + const arcEnd = (side) => [-radius + radius * Math.cos(side * halfSweep), radius * Math.sin(side * halfSweep)]; + const body = movedOffIdentity(keep(new AnalyticSolid({ + kind: "arcEdgedExtrusion", + frame: { origin: [0, 0, 0], ...consumerAxes }, + outer: [ + { kind: "arc", center: [-radius, 0], radius, startAngle: -halfSweep, sweepAngle: 2 * halfSweep }, + { kind: "line", from: arcEnd(1), to: [-thickness, arcEnd(1)[1]] }, + { kind: "line", from: [-thickness, arcEnd(1)[1]], to: [-thickness, arcEnd(-1)[1]] }, + { kind: "line", from: [-thickness, arcEnd(-1)[1]], to: arcEnd(-1) }, + ], + height, + }))); + const local = body.getBrepData().accuracy.geometric; + context.diagnostic(`chord ${chord} m of a circle of radius ${radius} m: geometric ${local}`); + try { + assert.equal(JSON.parse(body.getWorldBrepSerialized()).accuracy.geometric, local); + } catch (error) { + rejected.push(`chord ${chord} m: ${String(error)}`); + } + } + assert.deepEqual(rejected, []); +}); + +test("a column gets the accuracy of its height", () => { + assertRelative(prism(-0.15, -0.15, 0.3, 0.3, 3).getBrepData().accuracy.geometric, 3 * 1e-8, 1e-12, "column"); +}); + +test("a polygon loft and a planar polyhedron get the accuracy of their largest side at every distance", (context) => { + const moved = (points, shift) => points.map((point) => [point[0] + shift, point[1] + shift, point[2]]); + for (const shift of [0, 5000]) { + const loft = keep(new AnalyticSolid({ + kind: "polygonLoft", + lower: moved([[0, 0, 0], [4, 0, 0], [4, 3, 0], [0, 3, 0]], shift), + upper: moved([[0, 0, 2.5], [4, 0, 2.5], [4, 3, 2.5], [0, 3, 2.5]], shift), + })).getBrepData().accuracy.geometric; + const polyhedron = keep(new AnalyticSolid({ + kind: "planarPolyhedron", + vertices: moved([[0, 0, 0], [4, 0, 0], [0, 3, 0], [0, 0, 2]], shift), + faces: [[0, 2, 1], [0, 1, 3], [1, 2, 3], [0, 3, 2]], + })).getBrepData().accuracy.geometric; + context.diagnostic(`shift ${shift} m: loft ${loft}, polyhedron ${polyhedron}`); + assertRelative(loft, 4 * 1e-8, 1e-9, `polygon loft shifted by ${shift} m`); + assertRelative(polyhedron, 4 * 1e-8, 1e-9, `planar polyhedron shifted by ${shift} m`); + } +}); + +test("an arc wall moved in its profile equals the wall moved by its frame", (context) => { + const wall = { start: 80 * degrees, sweep: 20 * degrees, extremes: [[0, 1]] }; + const byFrame = curvedWall({ ...wall, origin: [5000, 0, -5000] }); + const byProfile = curvedWall({ ...wall, center: [5000, 5000] }); + context.diagnostic(`moved by the frame ${byFrame}, moved in the profile ${byProfile}`); + assertRelative(byProfile, byFrame, 1e-9, "arc wall moved in its profile against the frame"); + assertRelative(byProfile, boxRuleOf(wall), 1e-9, "arc wall moved in its profile"); +}); + +test("a wall in a turned frame gets the accuracy of its own length", () => { + const turn = Math.SQRT1_2; + const body = keep(new AnalyticSolid({ + kind: "linearExtrusion", + frame: { origin: [5000, 0, 5000], x: [turn, 0, -turn], y: [-turn, 0, -turn], z: [0, 1, 0] }, + outer: [[0, 0], [5, 0], [5, 0.2], [0, 0.2]], + holes: [], + height: 3, + })); + assertRelative(body.getBrepData().accuracy.geometric, 5 * 1e-8, 1e-9, "wall in a frame turned by 45 degrees"); +}); diff --git a/main/opengeometry-three/tests/kernel-memory.mjs b/main/opengeometry-three/tests/kernel-memory.mjs new file mode 100644 index 0000000..99fafb8 --- /dev/null +++ b/main/opengeometry-three/tests/kernel-memory.mjs @@ -0,0 +1,36 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +export const PAGE_BYTES = 65536; +export const MEBIBYTE = 1024 * 1024; + +const packageFolder = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../dist/opengeometry/pkg"); + +export function reserveBytes(pages) { + return Math.min(Math.max(Math.floor(pages / 8) * PAGE_BYTES, MEBIBYTE), 64 * MEBIBYTE); +} + +export async function bootKernel() { + const wasmBytes = await readFile(path.join(packageFolder, "opengeometry_bg.wasm")); + const glue = await import(pathToFileURL(path.join(packageFolder, "opengeometry.js")).href); + let kernelExports; + const instantiate = globalThis.WebAssembly.instantiate; + globalThis.WebAssembly.instantiate = async (...parameters) => { + const result = await instantiate(...parameters); + kernelExports = (result.instance ?? result).exports; + return result; + }; + try { + await glue.default({ module_or_path: wasmBytes }); + } finally { + globalThis.WebAssembly.instantiate = instantiate; + } + const memory = kernelExports.memory; + return { + glue, + wasmBytes, + pages: () => memory.buffer.byteLength / PAGE_BYTES, + allocate: (bytes) => kernelExports.__wbindgen_malloc(bytes, 8) >>> 0, + }; +} diff --git a/main/opengeometry-three/tests/memory-growth.test.mjs b/main/opengeometry-three/tests/memory-growth.test.mjs new file mode 100644 index 0000000..da194b8 --- /dev/null +++ b/main/opengeometry-three/tests/memory-growth.test.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; +import { bootKernel, MEBIBYTE, PAGE_BYTES } from "./kernel-memory.mjs"; + +const BODY_COUNT = 5000; +const BYTES_AFTER_THE_BODIES_WITHOUT_A_RESERVE = 55508992; +const keptBodies = []; +const measured = { wasmBytes: undefined, afterOneBody: 0, afterAllBodies: 0, growthSteps: 0 }; + +before(async () => { + const kernel = await bootKernel(); + measured.wasmBytes = kernel.wasmBytes; + let lastPages = kernel.pages(); + for (let index = 0; index < BODY_COUNT; index += 1) { + keptBodies.push(kernel.glue.OGAnalyticBrep.from_primitive(JSON.stringify({ + kind: "cuboid", + id: `body-${index}`, + frame: { origin: [0, 0, 0], x: [1, 0, 0], y: [0, 0, -1], z: [0, 1, 0] }, + accuracy: { geometric: 1e-9, intersection: 1e-10, tessellation: 0.001, exchange: 1e-5 }, + size: [4, 3, 0.2], + }))); + if (kernel.pages() !== lastPages) { + measured.growthSteps += 1; + lastPages = kernel.pages(); + } + if (index === 0) measured.afterOneBody = lastPages * PAGE_BYTES; + } + measured.afterAllBodies = lastPages * PAGE_BYTES; +}); + +after(() => { + for (const body of keptBodies.splice(0)) body.free(); +}); + +function sectionsOf(bytes) { + let position = 8; + const unsigned = () => { + let value = 0; + let scale = 1; + let byte; + do { + byte = bytes[position]; + position += 1; + value += (byte & 0x7f) * scale; + scale *= 128; + } while (byte & 0x80); + return value; + }; + const limits = () => { + const flags = bytes[position]; + position += 1; + const initial = unsigned(); + return { flags, initial, maximum: flags & 1 ? unsigned() : undefined }; + }; + const memories = []; + const importedMemories = []; + while (position < bytes.length) { + const id = bytes[position]; + position += 1; + const size = unsigned(); + const end = position + size; + if (id === 5) { + for (let count = unsigned(); count > 0; count -= 1) memories.push(limits()); + } + if (id === 2) { + for (let count = unsigned(); count > 0; count -= 1) { + const moduleNameLength = unsigned(); + position += moduleNameLength; + const fieldNameLength = unsigned(); + position += fieldNameLength; + const kind = bytes[position]; + position += 1; + if (kind === 0) unsigned(); + if (kind === 1) { + position += 1; + limits(); + } + if (kind === 2) importedMemories.push(limits()); + if (kind === 3) position += 2; + } + } + position = end; + } + return { memories, importedMemories }; +} + +test("the kernel has one memory of its own, not shared and without a maximum", () => { + const { memories, importedMemories } = sectionsOf(measured.wasmBytes); + assert.equal(importedMemories.length, 0); + assert.equal(memories.length, 1); + assert.equal(memories[0].flags & 2, 0); + assert.equal(memories[0].maximum, undefined); +}); + +test("the memory grows in large steps", (context) => { + context.diagnostic(`${BODY_COUNT} kept bodies: ${measured.growthSteps} growth steps, ${(measured.afterAllBodies / MEBIBYTE).toFixed(2)} MiB (${measured.afterAllBodies} bytes)`); + assert.ok(measured.growthSteps <= 100, `${measured.growthSteps} growth steps`); +}); + +test("many small kept bodies need at most an eighth and one step more memory than without a reserve", () => { + const bound = 1.125 * BYTES_AFTER_THE_BODIES_WITHOUT_A_RESERVE + MEBIBYTE + PAGE_BYTES; + assert.ok(measured.afterAllBodies <= bound, `${measured.afterAllBodies} bytes against a bound of ${bound}`); +}); + +test("one body needs at most 4 MiB of memory", (context) => { + context.diagnostic(`one body: ${(measured.afterOneBody / MEBIBYTE).toFixed(2)} MiB`); + assert.ok(measured.afterOneBody <= 4 * MEBIBYTE, `${measured.afterOneBody} bytes`); +}); diff --git a/main/opengeometry-three/tests/memory-limit.test.mjs b/main/opengeometry-three/tests/memory-limit.test.mjs new file mode 100644 index 0000000..46085ab --- /dev/null +++ b/main/opengeometry-three/tests/memory-limit.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { bootKernel, MEBIBYTE, PAGE_BYTES } from "./kernel-memory.mjs"; + +const LIMIT_PAGES = 65536; + +test("a refused reserve leaves the kernel working up to the last page of the engine", async (context) => { + const kernel = await bootKernel(); + const pagesLeft = () => LIMIT_PAGES - kernel.pages(); + try { + while (pagesLeft() > 2100) { + kernel.allocate(Math.min(1024 * MEBIBYTE, (pagesLeft() - 2090) * PAGE_BYTES)); + } + } catch (error) { + context.skip(`this engine does not give 4 GiB of address space: ${String(error)}`); + return; + } + while (pagesLeft() > 1023) kernel.allocate((pagesLeft() - 1000) * PAGE_BYTES); + for (const bytes of [16 * MEBIBYTE, 4 * MEBIBYTE, MEBIBYTE, 256 * 1024, 64 * 1024, 16 * 1024, 4096]) { + let fitsTheFreeTop = true; + while (fitsTheFreeTop && bytes / PAGE_BYTES + 2 <= pagesLeft()) { + const pagesBefore = kernel.pages(); + kernel.allocate(bytes); + fitsTheFreeTop = kernel.pages() === pagesBefore; + } + } + if (pagesLeft() > 10) kernel.allocate((pagesLeft() - 8) * PAGE_BYTES); + const pagesAtTheRefusal = kernel.pages(); + const growthSteps = []; + let pagesBefore = pagesAtTheRefusal; + let smallBlocks = 0; + while (kernel.pages() < LIMIT_PAGES) { + kernel.allocate(1024); + smallBlocks += 1; + if (kernel.pages() !== pagesBefore) { + growthSteps.push(kernel.pages() - pagesBefore); + pagesBefore = kernel.pages(); + } + } + context.diagnostic(`${pagesAtTheRefusal} pages before the last small blocks, ${smallBlocks} blocks of 1 KiB, growth steps ${JSON.stringify(growthSteps)}, end ${kernel.pages()} pages`); + assert.ok(pagesAtTheRefusal > LIMIT_PAGES - 1024 && pagesAtTheRefusal < LIMIT_PAGES); + assert.deepEqual(growthSteps, new Array(LIMIT_PAGES - pagesAtTheRefusal).fill(1)); + assert.equal(kernel.pages(), LIMIT_PAGES); + assert.notEqual(kernel.allocate(1024), 0); +}); diff --git a/main/opengeometry-three/tests/memory-worst-case.test.mjs b/main/opengeometry-three/tests/memory-worst-case.test.mjs new file mode 100644 index 0000000..9274705 --- /dev/null +++ b/main/opengeometry-three/tests/memory-worst-case.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { bootKernel, MEBIBYTE, PAGE_BYTES, reserveBytes } from "./kernel-memory.mjs"; + +const roundedUp = (value, step) => Math.ceil(value / step) * step; + +function memoryWithoutAReserve(startPages) { + const heap = { pages: startPages, freeTop: 0, started: false }; + heap.allocate = (bytes) => { + const chunk = Math.max(16, roundedUp(bytes + 4, 8)); + if (!(chunk < heap.freeTop)) { + const grown = roundedUp(chunk + 48, PAGE_BYTES); + heap.pages += grown / PAGE_BYTES; + heap.freeTop += grown - (heap.started ? 0 : 40); + heap.started = true; + } + heap.freeTop -= chunk; + }; + return heap; +} + +test("one block larger than the free top leaves more than one reserve free, and at most two reserves and two pages", async (context) => { + const kernel = await bootKernel(); + const plain = memoryWithoutAReserve(kernel.pages()); + const allocate = (bytes) => { + kernel.allocate(bytes); + plain.allocate(bytes); + }; + let smallBytes = 0; + let pagesBefore = kernel.pages(); + let grew = false; + while (!(grew && smallBytes >= 16 * MEBIBYTE)) { + allocate(1024); + smallBytes += 1024; + grew = kernel.pages() !== pagesBefore; + pagesBefore = kernel.pages(); + } + const blockBytes = reserveBytes(kernel.pages()) + 2 * PAGE_BYTES; + allocate(blockBytes); + const reserve = reserveBytes(kernel.pages()); + const above = (kernel.pages() - plain.pages) * PAGE_BYTES; + context.diagnostic(`${(smallBytes / MEBIBYTE).toFixed(2)} MiB of 1 KiB blocks, then one block of ${(blockBytes / MEBIBYTE).toFixed(2)} MiB: ${kernel.pages()} pages, ${plain.pages} without a reserve, ${(above / MEBIBYTE).toFixed(2)} MiB above = ${(above / reserve).toFixed(2)} reserves of ${(reserve / MEBIBYTE).toFixed(2)} MiB = ${((100 * above) / (plain.pages * PAGE_BYTES)).toFixed(1)} %`); + assert.ok(above > reserve, `${above} bytes above, one reserve is ${reserve}`); + assert.ok(above <= 2 * reserve + 2 * PAGE_BYTES, `${above} bytes above, two reserves and two pages are ${2 * reserve + 2 * PAGE_BYTES}`); +}); diff --git a/main/opengeometry-three/tests/region-boolean.test.mjs b/main/opengeometry-three/tests/region-boolean.test.mjs new file mode 100644 index 0000000..073a725 --- /dev/null +++ b/main/opengeometry-three/tests/region-boolean.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { before, test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { booleanRegions2D, OpenGeometry } from "../../../dist/index.js"; + +const distFolder = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../dist"); + +before(async () => { + if (typeof globalThis.document === "undefined") { + globalThis.document = { + createElement: () => ({ style: {}, setAttribute() {}, appendChild() {} }), + }; + } + const wasmBytes = await readFile(path.join(distFolder, "opengeometry", "pkg", "opengeometry_bg.wasm")); + await OpenGeometry.create({ wasmURL: wasmBytes }); +}); + +function clockwise(x0, z0, x1, z1) { + return [[x0, z0], [x0, z1], [x1, z1], [x1, z0]].map(([x, z]) => ({ x, y: 0, z })); +} + +function region(x0, z0, x1, z1, hole) { + return { outer: clockwise(x0, z0, x1, z1), holes: hole ? [clockwise(...hole).reverse()] : [] }; +} + +function spanOf(ring) { + const along = ring.map((point) => point.x); + return `${Math.min(...along)}..${Math.max(...along)}`; +} + +function nestOf(regions) { + return regions + .map((item) => `${spanOf(item.outer)} [${item.holes.map(spanOf).sort().join(", ")}]`) + .sort(); +} + +test("two squares 0.05 mm apart unite to two regions", () => { + const united = booleanRegions2D([region(0, 0, 1, 1)], [region(1.00005, 0, 2.00005, 1)], "union"); + assert.equal(united.length, 2); + for (const item of united) { + assert.equal(item.outer.length, 4); + assert.equal(item.holes.length, 0); + } +}); + +test("an island in the hole of a frame keeps its own hole in both operand orders", () => { + const frame = region(0, 0, 10, 10, [1, 1, 9, 9]); + const island = region(3, 3, 7, 7, [4, 4, 6, 6]); + const expected = ["0..10 [1..9]", "3..7 [4..6]"]; + assert.deepEqual(nestOf(booleanRegions2D([frame], [island], "union")), expected); + assert.deepEqual(nestOf(booleanRegions2D([island], [frame], "union")), expected); + assert.deepEqual(nestOf(booleanRegions2D([frame, island], [], "union")), expected); +}); + +test("a boundary that cannot be closed is an error and not a ring", () => { + const long = region(0, 0, 1000, 1); + const besideItsEnd = region(999.9995, 1, 1000.5, 2); + assert.throws( + () => booleanRegions2D([long], [besideItsEnd], "union"), + (error) => String(error).includes("Boolean regions produced an open boundary"), + ); +}); diff --git a/main/opengeometry/src/analytic/booleans.rs b/main/opengeometry/src/analytic/booleans.rs index a57be1b..5cec66f 100644 --- a/main/opengeometry/src/analytic/booleans.rs +++ b/main/opengeometry/src/analytic/booleans.rs @@ -1683,7 +1683,7 @@ fn boolean_planar_extrusions( &b_contours, planar_operation, accuracy.intersection, - ); + )?; let mut out = BrepEnvelope::new(id, accuracy)?; for (region_index, region) in regions.iter().enumerate() { let (outer, holes) = region_profiles(region); @@ -13463,4 +13463,113 @@ mod tests { super::super::query::PointClassification::Outside ); } + + fn region_prism( + id: &str, + outer: [f64; 4], + hole: Option<[f64; 4]>, + geometric: f64, + ) -> BrepEnvelope { + let ring = |b: [f64; 4]| vec![[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]]; + primitives::linear_extrusion( + id.into(), + Frame3::IDENTITY, + ring(outer), + hole.map(ring).into_iter().collect(), + 3.0, + Accuracy { + geometric, + intersection: geometric / 4.0, + tessellation: 0.01, + exchange: 1e-5, + }, + ) + .unwrap() + } + + fn material_at(brep: &BrepEnvelope, points: &[Point3]) -> Vec { + points + .iter() + .map(|point| { + super::super::query::classify_point(brep, *point).unwrap() + == super::super::query::PointClassification::Inside + }) + .collect() + } + + #[test] + fn union_of_a_frame_prism_and_an_island_prism_keeps_each_hole_in_both_orders() { + let frame = region_prism( + "frame", + [0.0, 0.0, 10.0, 10.0], + Some([1.0, 1.0, 9.0, 9.0]), + 1e-7, + ); + let island = region_prism( + "island", + [3.0, 3.0, 7.0, 7.0], + Some([4.0, 4.0, 6.0, 6.0]), + 1e-7, + ); + for (first, second) in [(&frame, &island), (&island, &frame)] { + let united = boolean_brep(first, second, BooleanOp::Union, "united".into()) + .unwrap() + .brep; + assert_eq!(united.solids.len(), 2); + assert_eq!(united.topology.faces.len(), 20); + assert_eq!( + material_at( + &united, + &[ + [0.5, 0.5, 1.5], + [2.0, 2.0, 1.5], + [3.5, 3.5, 1.5], + [5.0, 5.0, 1.5] + ] + ), + [true, false, true, false] + ); + } + } + + #[test] + fn a_profile_hole_inside_the_old_probe_reach_of_its_edge_survives_a_planar_boolean() { + let host = region_prism( + "host", + [0.0, 0.0, 1.0, 1.0], + Some([2.0e-5, 0.2, 0.5, 0.8]), + 1e-6, + ); + let cutter = region_prism("cutter", [0.6, -0.5, 0.7, 1.5], None, 1e-6); + let points = [ + [1.0e-5, 0.5, 1.5], + [0.25, 0.5, 1.5], + [0.65, 0.5, 1.5], + [0.9, 0.5, 1.5], + ]; + let cut = boolean_brep(&host, &cutter, BooleanOp::Subtraction, "cut".into()) + .unwrap() + .brep; + assert_eq!(cut.solids.len(), 2); + assert_eq!(cut.topology.faces.len(), 16); + assert_eq!(material_at(&cut, &points), [true, false, false, true]); + let united = boolean_brep(&host, &cutter, BooleanOp::Union, "united".into()) + .unwrap() + .brep; + assert_eq!(united.solids.len(), 1); + assert_eq!(united.topology.faces.len(), 18); + assert_eq!(material_at(&united, &points), [true, false, true, true]); + } + + #[test] + fn a_region_boundary_that_cannot_be_closed_is_an_invalid_topology_error() { + let long = region_prism("long", [0.0, 0.0, 1000.0, 1.0], None, 1e-5); + let beside_its_end = region_prism("beside", [999.9995, 1.0, 1000.5, 2.0], None, 1e-5); + let result = boolean_brep(&long, &beside_its_end, BooleanOp::Union, "united".into()); + assert!( + matches!(&result, Err(GeometryError::InvalidTopology(message)) if message == "layered planar Boolean has an open boundary"), + "{:?}", + result.map(|result| result.brep.topology.faces.len()) + ); + } } diff --git a/main/opengeometry/src/analytic/planar_booleans.rs b/main/opengeometry/src/analytic/planar_booleans.rs index d93ecbe..df14481 100644 --- a/main/opengeometry/src/analytic/planar_booleans.rs +++ b/main/opengeometry/src/analytic/planar_booleans.rs @@ -19,7 +19,10 @@ use super::{ }; use crate::{ geometry::{ - boolean2d::{boolean_oriented_regions, regions_from_edges_by, PlanarBooleanOp, RingRegion}, + boolean2d::{ + boolean_oriented_regions, regions_from_edges_by, PlanarBooleanOp, RegionBooleanError, + RingRegion, + }, poly2d::{winding_number2, Pt2}, }, math::interval::Interval, @@ -100,6 +103,17 @@ fn loop_positions(brep: &BrepEnvelope, loop_id: u32) -> Result, Geom Ok(points) } +impl From for GeometryError { + fn from(error: RegionBooleanError) -> Self { + Self::InvalidTopology(match error { + RegionBooleanError::OpenBoundary => { + "layered planar Boolean has an open boundary".into() + } + RegionBooleanError::OrphanHole => error.to_string(), + }) + } +} + fn slice_regions( brep: &BrepEnvelope, frame: Frame3, @@ -187,7 +201,7 @@ fn slice_regions( &[], PlanarBooleanOp::Union, tolerance, - )) + )?) } fn side_sources( @@ -885,7 +899,7 @@ pub(super) fn subtract_layered_extrusions( }, PlanarBooleanOp::Subtraction, accuracy.intersection, - )) + )?) }) .collect::, _>>()?; let mut exposed_up = Vec::new(); @@ -898,13 +912,13 @@ pub(super) fn subtract_layered_extrusions( &above, PlanarBooleanOp::Subtraction, accuracy.intersection, - )); + )?); exposed_down.push(boolean_oriented_regions( &above, &below, PlanarBooleanOp::Subtraction, accuracy.intersection, - )); + )?); } let mut vertices = Vec::new(); for layer in &layers { @@ -1359,7 +1373,7 @@ pub(super) fn subtract_planar_polyhedra( &cut, PlanarBooleanOp::Subtraction, accuracy.intersection, - ) + )? } else { let mut edges = planar_section_segments(cutter, &cutter_faces, face.frame, accuracy.intersection)?; @@ -1427,7 +1441,7 @@ pub(super) fn subtract_planar_polyhedra( if let Some(error) = failure.into_inner() { return Err(error); } - regions + regions? }; for region in regions { pieces.push(PlanarFacet { @@ -1512,6 +1526,7 @@ pub(super) fn subtract_planar_polyhedra( if let Some(error) = failure.into_inner() { return Err(error); } + let regions = regions?; let reversed = Frame3 { origin: face.frame.origin, x: face.frame.x, diff --git a/main/opengeometry/src/analytic/tessellation.rs b/main/opengeometry/src/analytic/tessellation.rs index 4798e3c..ea60454 100644 --- a/main/opengeometry/src/analytic/tessellation.rs +++ b/main/opengeometry/src/analytic/tessellation.rs @@ -1,11 +1,11 @@ use super::{ geometry::{cross, dot, norm, scale, sub, Surface}, - topology::{BrepEnvelope, EdgeGeometry, Face, Orientation}, + topology::{BrepEnvelope, EdgeGeometry, Face, Orientation, Topology}, Curve, CurveGeometry, GeometryError, Point3, SurfaceGeometry, }; use crate::math::interval::Interval; use std::{ - collections::{HashMap, HashSet, VecDeque}, + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, hash::{DefaultHasher, Hasher}, io::Write, sync::Arc, @@ -1031,7 +1031,7 @@ fn general_curved_trimmed_face( .collect::>(); let surface = brep.geometry.surface(face.surface)?; for _ in 0..128 { - let mut split_edges = HashSet::new(); + let mut split_edges = BTreeSet::new(); for triangle in &triangles { for [a, b] in [ [triangle[0], triangle[1]], @@ -1610,34 +1610,112 @@ pub fn tessellate( Ok(mesh) } -struct HashWriter(DefaultHasher); +type CacheKey = [u64; 2]; + +struct HashWriter([DefaultHasher; 2]); +impl HashWriter { + fn new() -> Self { + let mut hashers = [DefaultHasher::new(), DefaultHasher::new()]; + hashers[0].write_u8(0); + hashers[1].write_u8(1); + Self(hashers) + } +} impl Write for HashWriter { fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.0.write(buf); + for hasher in &mut self.0 { + hasher.write(buf); + } Ok(buf.len()) } fn flush(&mut self) -> std::io::Result<()> { Ok(()) } } -struct CacheEntry { - body: u64, - signature: u64, - deflection: u64, +// Every field that `tessellate` reads is in the stream; a field left out must not change the result. +fn cache_key( + brep: &BrepEnvelope, + deflection: f64, max_triangles: usize, +) -> Result { + let BrepEnvelope { + schema_version, + id: _, + revision: _, + geometry, + topology, + solids, + accuracy, + quality, + } = brep; + let Topology { + vertices, + edges, + halfedges, + loops, + faces, + wires, + shells, + } = topology; + let faces = faces + .iter() + .map( + |Face { + id, + key: _, + surface, + sense, + trim, + shell_ref, + provenance: _, + }| (id, surface, sense, trim, shell_ref), + ) + .collect::>(); + let mut writer = HashWriter::new(); + serde_json::to_writer( + &mut writer, + &( + schema_version, + geometry, + vertices, + edges, + halfedges, + loops, + faces, + wires, + shells, + solids, + accuracy, + quality, + deflection.to_bits(), + max_triangles, + ), + ) + .map_err(|e| GeometryError::InvalidGeometry(e.to_string()))?; + Ok(writer.0.map(|hasher| hasher.finish())) +} +struct CacheEntry { mesh: Arc, + stamp: u64, } pub struct TessellationCache { - entries: VecDeque, + entries: HashMap, + recency: BTreeMap, + stamp: u64, + bytes: usize, max_bytes: usize, } impl TessellationCache { pub fn new(max_bytes: usize) -> Self { Self { - entries: VecDeque::new(), + entries: HashMap::new(), + recency: BTreeMap::new(), + stamp: 0, + bytes: 0, max_bytes, } } + // A hit can be the mesh of another body, so its `revision` is not the caller's; the caller stamps its own. pub fn tessellate( &mut self, brep: &BrepEnvelope, @@ -1645,49 +1723,35 @@ impl TessellationCache { max_triangles: usize, ) -> Result, GeometryError> { brep.validate()?; - let mut writer = HashWriter(DefaultHasher::new()); - serde_json::to_writer(&mut writer, brep) - .map_err(|e| GeometryError::InvalidGeometry(e.to_string()))?; - let signature = writer.0.finish(); - if let Some(i) = self.entries.iter().position(|e| { - e.signature == signature - && e.deflection == deflection.to_bits() - && e.max_triangles == max_triangles - }) { - let entry = self - .entries - .remove(i) - .ok_or_else(|| GeometryError::InvalidGeometry("cache entry missing".into()))?; - let mesh = Arc::clone(&entry.mesh); - self.entries.push_back(entry); - return Ok(mesh); + let key = cache_key(brep, deflection, max_triangles)?; + self.stamp += 1; + if let Some(entry) = self.entries.get_mut(&key) { + self.recency.remove(&entry.stamp); + entry.stamp = self.stamp; + self.recency.insert(self.stamp, key); + return Ok(Arc::clone(&entry.mesh)); } let mesh = Arc::new(tessellate(brep, deflection, max_triangles)?); if mesh.bytes() > self.max_bytes { return Ok(mesh); } - let mut hasher = DefaultHasher::new(); - hasher.write(brep.id.as_bytes()); - let body = hasher.finish(); - while self.entries.iter().filter(|e| e.body == body).count() >= 3 { - if let Some(i) = self.entries.iter().position(|e| e.body == body) { - self.entries.remove(i); - } else { + self.bytes += mesh.bytes(); + self.entries.insert( + key, + CacheEntry { + mesh: Arc::clone(&mesh), + stamp: self.stamp, + }, + ); + self.recency.insert(self.stamp, key); + while self.bytes > self.max_bytes { + let Some((_, oldest)) = self.recency.pop_first() else { break; + }; + if let Some(evicted) = self.entries.remove(&oldest) { + self.bytes -= evicted.mesh.bytes(); } } - while self.entries.iter().map(|e| e.mesh.bytes()).sum::() + mesh.bytes() - > self.max_bytes - { - self.entries.pop_front(); - } - self.entries.push_back(CacheEntry { - body, - signature, - deflection: deflection.to_bits(), - max_triangles, - mesh: Arc::clone(&mesh), - }); Ok(mesh) } pub fn len(&self) -> usize { @@ -1993,13 +2057,351 @@ mod tests { let a = cache.tessellate(&b, 0.1, 1_000_000).unwrap(); let again = cache.tessellate(&b, 0.1, 1_000_000).unwrap(); assert!(Arc::ptr_eq(&a, &again)); - b.topology.faces[0].key = "changed".into(); + b = primitives::cylinder("c".into(), Frame3::IDENTITY, 1.5, 2.0, accuracy()).unwrap(); let changed = cache.tessellate(&b, 0.1, 1_000_000).unwrap(); assert!(!Arc::ptr_eq(&a, &changed)); for d in [0.08, 0.06, 0.04, 0.02] { cache.tessellate(&b, d, 1_000_000).unwrap(); } - assert_eq!(cache.len(), 3); + assert_eq!(cache.len(), 6); assert!(tessellate(&b, 0.001, 1).is_err()); } + fn trimmed_curved_bodies() -> [BrepEnvelope; 2] { + let accuracy = Accuracy { + geometric: 6.3e-8, + intersection: 1.575e-8, + tessellation: 0.01, + exchange: 1e-5, + }; + let sector = primitives::annular_sector_extrusion( + "sector".into(), + Frame3 { + origin: [0.0, 0.0, 0.0], + x: [1.0, 0.0, 0.0], + y: [0.0, 0.0, -1.0], + z: [0.0, 1.0, 0.0], + }, + 3.0, + 0.3, + 3.0, + 0.8, + -1.6, + accuracy, + ) + .unwrap(); + let round = primitives::cylinder( + "round".into(), + Frame3 { + origin: [2.7, 1.5, 0.0], + x: [0.0, 0.0, -1.0], + y: [0.0, 1.0, 0.0], + z: [1.0, 0.0, 0.0], + }, + 0.5, + 0.6, + accuracy, + ) + .unwrap(); + let round_opening = crate::analytic::booleans::boolean_brep( + §or, + &round, + crate::analytic::booleans::BooleanOp::Subtraction, + "sector with a round opening".into(), + ) + .unwrap() + .brep; + let raised_cutout = primitives::annular_sector_extrusion_with_openings( + "sector with a raised cutout".into(), + Frame3::IDENTITY, + 3.0, + 0.3, + 3.0, + 0.0, + 2.0, + vec![primitives::AnnularSectorOpening { + id: "raised_cutout".into(), + angle: 1.0, + width: 0.8, + bottom: 0.9, + height: 1.2, + }], + accuracy, + ) + .unwrap(); + [round_opening, raised_cutout] + } + fn array_bits(mesh: &Tessellation) -> (Vec, Vec, Vec, [&Vec; 3]) { + ( + mesh.positions.iter().map(|v| v.to_bits()).collect(), + mesh.normals.iter().map(|v| v.to_bits()).collect(), + mesh.outline_positions.iter().map(|v| v.to_bits()).collect(), + [ + &mesh.indices, + &mesh.triangle_face_ids, + &mesh.outline_edge_ids, + ], + ) + } + fn triangle_set_hash(mesh: &Tessellation) -> u64 { + let corner = |id: u32| { + let at = id as usize * 3; + let position = mesh.positions[at..at + 3].iter().map(|v| v.to_bits()); + let normal = mesh.normals[at..at + 3] + .iter() + .map(|v| u64::from(v.to_bits())); + position.chain(normal).collect::>() + }; + let mut triangles = mesh + .indices + .chunks_exact(3) + .zip(&mesh.triangle_face_ids) + .map(|(ids, &face)| { + let mut corners = [corner(ids[0]), corner(ids[1]), corner(ids[2])]; + let first = (0..3).min_by_key(|&i| &corners[i]).unwrap_or(0); + corners.rotate_left(first); + (face, corners) + }) + .collect::>(); + triangles.sort(); + triangles + .iter() + .flat_map(|(face, corners)| { + std::iter::once(u64::from(*face)).chain(corners.iter().flatten().copied()) + }) + .flat_map(u64::to_le_bytes) + .fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3) + }) + } + #[test] + fn a_trimmed_curved_face_tessellates_to_the_same_arrays_each_time() { + let equal_runs = trimmed_curved_bodies().map(|body| { + [0.05, 0.01].map(|deflection| { + let first = tessellate(&body, deflection, 2_000_000).unwrap(); + (0..20) + .filter(|_| { + let again = tessellate(&body, deflection, 2_000_000).unwrap(); + array_bits(&first) == array_bits(&again) + }) + .count() + }) + }); + assert_eq!(equal_runs, [[20; 2]; 2]); + } + const TRIANGLE_SETS_ON_AARCH64_APPLE_DARWIN: [[u64; 2]; 2] = [ + [0xf0d9_a930_15e0_d334, 0x6617_beaa_d081_2100], + [0xcea2_8fc6_831d_672a, 0x8b42_bcc8_7643_6dbb], + ]; + #[test] + fn a_trimmed_curved_face_keeps_its_triangle_set() { + let hashes = trimmed_curved_bodies().map(|body| { + [0.05, 0.01].map(|deflection| { + triangle_set_hash(&tessellate(&body, deflection, 2_000_000).unwrap()) + }) + }); + if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + assert_eq!( + hashes, TRIANGLE_SETS_ON_AARCH64_APPLE_DARWIN, + "{hashes:#018x?}" + ); + } + } + fn wall(length: f64) -> BrepEnvelope { + primitives::cuboid( + "wall".into(), + Frame3::IDENTITY, + [length, 0.2, 3.0], + accuracy(), + ) + .unwrap() + } + fn assert_outside_the_key(change: impl Fn(&mut BrepEnvelope)) { + let original = wall(4.0); + let mut changed = original.clone(); + change(&mut changed); + assert_ne!(original.to_json().unwrap(), changed.to_json().unwrap()); + let fresh = tessellate(&original, 0.05, 1_000_000).unwrap(); + let fresh_changed = tessellate(&changed, 0.05, 1_000_000).unwrap(); + assert!(array_bits(&fresh) == array_bits(&fresh_changed)); + let mut cache = TessellationCache::new(16_000_000); + let first = cache.tessellate(&original, 0.05, 1_000_000).unwrap(); + let second = cache.tessellate(&changed, 0.05, 1_000_000).unwrap(); + assert!(Arc::ptr_eq(&first, &second)); + assert_eq!(cache.len(), 1); + } + #[test] + fn cache_hits_when_only_the_body_id_differs() { + assert_outside_the_key(|body| body.id = "rebuilt".into()); + } + #[test] + fn cache_hits_when_only_the_revision_differs() { + assert_outside_the_key(|body| body.revision = 7); + } + #[test] + fn cache_hits_when_only_a_face_key_differs() { + assert_outside_the_key(|body| body.topology.faces[0].key = "rebuilt:face".into()); + } + #[test] + fn cache_hits_when_only_a_provenance_entity_differs() { + assert_outside_the_key(|body| { + body.topology.faces[0].provenance.sources[0].entity = "rebuilt".into() + }); + } + #[test] + fn cache_hits_when_only_a_provenance_body_differs() { + assert_outside_the_key(|body| { + body.topology.faces[0].provenance.sources[0].body = "rebuilt".into() + }); + } + #[test] + fn cache_hits_when_only_a_provenance_key_differs() { + assert_outside_the_key(|body| { + body.topology.faces[0].provenance.sources[0].key = "rebuilt:face".into() + }); + } + #[test] + fn cache_hits_when_only_a_provenance_face_differs() { + assert_outside_the_key(|body| body.topology.faces[0].provenance.sources[0].face = 99); + } + #[test] + fn cache_hits_when_only_a_provenance_role_differs() { + assert_outside_the_key(|body| { + body.topology.faces[0].provenance.role = crate::analytic::topology::FaceRole::Split + }); + } + #[test] + fn cache_hits_when_only_a_provenance_reversed_flag_differs() { + assert_outside_the_key(|body| { + let provenance = &mut body.topology.faces[0].provenance; + provenance.reversed = !provenance.reversed; + }); + } + fn cache_miss_changes_the_result( + original: &BrepEnvelope, + change: impl Fn(&mut BrepEnvelope), + ) -> bool { + let mut changed = original.clone(); + change(&mut changed); + changed.validate().unwrap(); + let fresh = tessellate(original, 0.05, 1_000_000).unwrap(); + let fresh_changed = tessellate(&changed, 0.05, 1_000_000); + let mut cache = TessellationCache::new(16_000_000); + let first = cache.tessellate(original, 0.05, 1_000_000).unwrap(); + match (fresh_changed, cache.tessellate(&changed, 0.05, 1_000_000)) { + (Ok(fresh_changed), Ok(second)) => { + assert!(!Arc::ptr_eq(&first, &second)); + assert!(array_bits(&fresh_changed) == array_bits(&second)); + assert_eq!(cache.len(), 2); + array_bits(&fresh) != array_bits(&fresh_changed) + } + (Err(_), Err(_)) => { + assert_eq!(cache.len(), 1); + true + } + _ => panic!("the cache and a fresh tessellation disagree"), + } + } + fn assert_inside_the_key(original: &BrepEnvelope, change: impl Fn(&mut BrepEnvelope)) { + assert!(cache_miss_changes_the_result(original, change)); + } + fn unit_cylinder() -> BrepEnvelope { + primitives::cylinder("c".into(), Frame3::IDENTITY, 1.0, 2.0, accuracy()).unwrap() + } + #[test] + fn cache_misses_when_the_geometric_accuracy_differs() { + assert_inside_the_key(&wall(4.0), |body| body.accuracy.geometric = 0.03); + } + #[test] + fn cache_misses_when_the_intersection_accuracy_differs() { + cache_miss_changes_the_result(&wall(4.0), |body| body.accuracy.intersection = 5e-11); + } + #[test] + fn cache_misses_when_a_vertex_position_differs() { + assert_inside_the_key(&wall(4.0), |body| { + body.topology.vertices[0].position[0] += 2.5e-10 + }); + } + #[test] + fn cache_misses_when_a_face_sense_differs() { + assert_inside_the_key(&wall(4.0), |body| { + body.topology.faces[0].sense = Orientation::Reverse + }); + } + #[test] + fn cache_misses_when_a_face_trim_differs() { + assert_inside_the_key(&unit_cylinder(), |body| { + body.topology.faces[0].trim.uv_bounds[1] = Interval { lo: -0.5, hi: 2.5 } + }); + } + #[test] + fn cache_misses_when_a_curve_differs() { + assert_inside_the_key(&unit_cylinder(), |body| { + if let CurveGeometry::Circle { radius, .. } = &mut body.geometry.curves[0] { + *radius += 2.5e-10; + } + }); + } + #[test] + fn cache_misses_when_a_surface_differs() { + let sphere = primitives::sphere("s".into(), Frame3::IDENTITY, 1.0, accuracy()).unwrap(); + assert_inside_the_key(&sphere, |body| { + if let SurfaceGeometry::Sphere { radius, .. } = &mut body.geometry.surfaces[0] { + *radius += 2.5e-10; + } + }); + } + #[test] + fn cache_keeps_every_shape_of_one_body_id_under_the_byte_bound() { + let mut cache = TessellationCache::new(16_000_000); + for length in [1.0, 2.0, 3.0, 4.0, 5.0] { + cache.tessellate(&wall(length), 0.05, 1_000_000).unwrap(); + } + assert_eq!(cache.len(), 5); + } + #[test] + fn cache_counts_the_bytes_of_its_meshes_exactly() { + let size = tessellate(&wall(1.0), 0.05, 1_000_000).unwrap().bytes(); + for (max_bytes, kept) in [(3 * size, 3), (3 * size - 1, 2)] { + let mut cache = TessellationCache::new(max_bytes); + for length in [1.0, 2.0, 3.0] { + let mesh = cache.tessellate(&wall(length), 0.05, 1_000_000).unwrap(); + assert_eq!(mesh.bytes(), size); + } + assert_eq!(cache.len(), kept); + } + } + #[test] + fn cache_evicts_the_least_recently_used_mesh_and_a_hit_refreshes_it() { + let size = tessellate(&wall(1.0), 0.05, 1_000_000).unwrap().bytes(); + let mut cache = TessellationCache::new(3 * size); + let [first, second, third] = + [1.0, 2.0, 3.0].map(|length| cache.tessellate(&wall(length), 0.05, 1_000_000).unwrap()); + let hit = cache.tessellate(&wall(1.0), 0.05, 1_000_000).unwrap(); + assert!(Arc::ptr_eq(&first, &hit)); + cache.tessellate(&wall(4.0), 0.05, 1_000_000).unwrap(); + assert_eq!(cache.len(), 3); + let kept = cache.tessellate(&wall(3.0), 0.05, 1_000_000).unwrap(); + assert!(Arc::ptr_eq(&third, &kept)); + let refreshed = cache.tessellate(&wall(1.0), 0.05, 1_000_000).unwrap(); + assert!(Arc::ptr_eq(&first, &refreshed)); + let evicted = cache.tessellate(&wall(2.0), 0.05, 1_000_000).unwrap(); + assert!(!Arc::ptr_eq(&second, &evicted)); + assert_eq!(cache.len(), 3); + } + #[test] + fn cache_returns_but_does_not_keep_a_mesh_above_the_byte_bound() { + let size = tessellate(&wall(1.0), 0.05, 1_000_000).unwrap().bytes(); + let mut cache = TessellationCache::new(size - 1); + let mesh = cache.tessellate(&wall(1.0), 0.05, 1_000_000).unwrap(); + assert_eq!(mesh.bytes(), size); + assert!(cache.is_empty()); + } + #[test] + fn cache_key_is_two_different_hashes_of_one_shape() { + let key = cache_key(&wall(4.0), 0.05, 1_000_000).unwrap(); + assert_ne!(key[0], key[1]); + assert_eq!(key, cache_key(&wall(4.0), 0.05, 1_000_000).unwrap()); + assert_ne!(key, cache_key(&wall(4.0), 0.05, 999_999).unwrap()); + assert_ne!(key, cache_key(&wall(4.0), 0.04, 1_000_000).unwrap()); + } } diff --git a/main/opengeometry/src/analytic/wasm.rs b/main/opengeometry/src/analytic/wasm.rs index 0844d45..994702d 100644 --- a/main/opengeometry/src/analytic/wasm.rs +++ b/main/opengeometry/src/analytic/wasm.rs @@ -77,7 +77,10 @@ fn tessellate_cached( })? .tessellate(brep, deflection, max_triangles) })?; - Ok(OGAnalyticTessellation { mesh }) + Ok(OGAnalyticTessellation { + mesh, + revision: brep.revision, + }) } #[derive(Deserialize)] @@ -763,8 +766,13 @@ impl OGAnalyticBrep { .map_err(|e| js_error(GeometryError::InvalidGeometry(e.to_string()))) } - pub fn tessellate(&mut self, deflection: f64) -> Result { - tessellate_cached(&self.brep, deflection, DEFAULT_MAX_TRIANGLES).map_err(js_error) + pub fn tessellate( + &mut self, + deflection: f64, + options_json: Option, + ) -> Result { + let max_triangles = tessellation_limit(options_json).map_err(js_error)?; + tessellate_cached(&self.brep, deflection, max_triangles).map_err(js_error) } pub fn get_face_count(&self) -> u32 { @@ -798,6 +806,7 @@ impl OGAnalyticBrep { #[wasm_bindgen] pub struct OGAnalyticTessellation { mesh: Arc, + revision: u64, } #[wasm_bindgen] impl OGAnalyticTessellation { @@ -820,7 +829,7 @@ impl OGAnalyticTessellation { self.mesh.outline_edge_ids.clone() } pub fn revision(&self) -> u64 { - self.mesh.revision + self.revision } pub fn achieved_deflection(&self) -> f64 { self.mesh.achieved_deflection @@ -976,4 +985,280 @@ mod tests { assert!(tessellation_limit(Some(r#"{"unknown":1}"#.into())).is_err()); assert!(tessellation_limit(Some(r#"{"max_triangles":0}"#.into())).is_err()); } + + #[derive(Debug, PartialEq)] + struct MeshBits { + positions: Vec, + normals: Vec, + indices: Vec, + triangle_face_ids: Vec, + outline_positions: Vec, + outline_edge_ids: Vec, + achieved_deflection: u64, + } + impl MeshBits { + fn of_mesh(mesh: &Tessellation) -> Self { + Self { + positions: mesh.positions.iter().map(|v| v.to_bits()).collect(), + normals: mesh.normals.iter().map(|v| v.to_bits()).collect(), + indices: mesh.indices.clone(), + triangle_face_ids: mesh.triangle_face_ids.clone(), + outline_positions: mesh.outline_positions.iter().map(|v| v.to_bits()).collect(), + outline_edge_ids: mesh.outline_edge_ids.clone(), + achieved_deflection: mesh.achieved_deflection.to_bits(), + } + } + fn of_result(result: &OGAnalyticTessellation) -> Self { + Self { + positions: result.positions().iter().map(|v| v.to_bits()).collect(), + normals: result.normals().iter().map(|v| v.to_bits()).collect(), + indices: result.indices(), + triangle_face_ids: result.triangle_face_ids(), + outline_positions: result + .outline_positions() + .iter() + .map(|v| v.to_bits()) + .collect(), + outline_edge_ids: result.outline_edge_ids(), + achieved_deflection: result.achieved_deflection().to_bits(), + } + } + fn fnv1a(&self) -> u64 { + let wide = |values: &[u64]| -> Vec { + let length = (values.len() as u64).to_le_bytes(); + let bytes = values.iter().flat_map(|v| v.to_le_bytes()); + length.into_iter().chain(bytes).collect() + }; + let narrow = |values: &[u32]| -> Vec { + let length = (values.len() as u64).to_le_bytes(); + let bytes = values.iter().flat_map(|v| v.to_le_bytes()); + length.into_iter().chain(bytes).collect() + }; + [ + wide(&self.positions), + narrow(&self.normals), + narrow(&self.indices), + narrow(&self.triangle_face_ids), + wide(&self.outline_positions), + narrow(&self.outline_edge_ids), + ] + .concat() + .into_iter() + .fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3) + }) + } + } + + fn pinned_bodies() -> Vec<(&'static str, BrepEnvelope)> { + let accuracy = Accuracy { + geometric: 6.3e-8, + intersection: 1.575e-8, + tessellation: 0.01, + exchange: 1e-5, + }; + let vertical = Frame3 { + origin: [0.0, 0.0, 0.0], + x: [1.0, 0.0, 0.0], + y: [0.0, 0.0, -1.0], + z: [0.0, 1.0, 0.0], + }; + let cuboid = + primitives::cuboid("cuboid".into(), Frame3::IDENTITY, [4.0, 0.2, 3.0], accuracy) + .unwrap(); + let extrusion = primitives::linear_extrusion( + "extrusion".into(), + vertical, + vec![[0.0, 0.0], [5.0, 0.0], [5.0, 4.0], [2.5, 5.5], [0.0, 4.0]], + vec![vec![[1.0, 1.0], [2.0, 1.0], [2.0, 2.5], [1.0, 2.5]]], + 3.0, + accuracy, + ) + .unwrap(); + let sector = primitives::annular_sector_extrusion( + "sector".into(), + vertical, + 3.0, + 0.3, + 3.0, + 0.8, + -1.6, + accuracy, + ) + .unwrap(); + let round = primitives::cylinder( + "round".into(), + Frame3 { + origin: [2.7, 1.5, 0.0], + x: [0.0, 0.0, -1.0], + y: [0.0, 1.0, 0.0], + z: [1.0, 0.0, 0.0], + }, + 0.5, + 0.6, + accuracy, + ) + .unwrap(); + let sector_with_opening = + boolean_brep(§or, &round, BooleanOp::Subtraction, "sector-cut".into()) + .unwrap() + .brep; + let window = primitives::cuboid( + "window".into(), + Frame3 { + origin: [1.0, -0.4, 0.5], + ..Frame3::IDENTITY + }, + [1.0, 1.0, 2.0], + accuracy, + ) + .unwrap(); + let subtraction = boolean_brep(&cuboid, &window, BooleanOp::Subtraction, "cut".into()) + .unwrap() + .brep; + let placed = super::super::placement::placed( + &extrusion, + Frame3 { + origin: [12.5, -3.0, 40.0], + x: [0.0, 1.0, 0.0], + y: [-1.0, 0.0, 0.0], + z: [0.0, 0.0, 1.0], + }, + 1.0, + ) + .unwrap(); + let mut renamed = cuboid.clone(); + renamed.id = "renamed".into(); + renamed.revision = 7; + vec![ + ("cuboid", cuboid), + ("extrusion with a hole", extrusion), + ("annular sector with a round opening", sector_with_opening), + ("subtraction", subtraction), + ("placed extrusion", placed), + ("cuboid with another id and revision", renamed), + ] + } + + const PINNED_ON_AARCH64_APPLE_DARWIN: [[u64; 2]; 6] = [ + [0xcf7d_04ab_9499_4d99, 0xcf7d_04ab_9499_4d99], + [0x65d3_d4da_109d_6e6c, 0x65d3_d4da_109d_6e6c], + [0x23c3_0c17_fefc_56d8, 0x8a4e_444f_f03e_90b7], + [0x8a1a_848b_210e_60f4, 0x8a1a_848b_210e_60f4], + [0x7dd4_efe1_e278_14f8, 0x7dd4_efe1_e278_14f8], + [0xcf7d_04ab_9499_4d99, 0xcf7d_04ab_9499_4d99], + ]; + + #[test] + fn text_and_object_tessellation_give_the_pinned_triangles() { + let mut hashes = Vec::new(); + for (name, body) in pinned_bodies() { + let text = body.to_json().unwrap(); + let parsed = BrepEnvelope::from_json(&text).unwrap(); + let mut pair = [0; 2]; + for (slot, deflection) in pair.iter_mut().zip([0.05, 0.01]) { + let case = format!("{name} at {deflection}"); + let fresh = |brep: &BrepEnvelope| { + MeshBits::of_mesh( + &super::super::tessellation::tessellate( + brep, + deflection, + DEFAULT_MAX_TRIANGLES, + ) + .unwrap(), + ) + }; + let direct = fresh(&body); + assert_eq!(direct, fresh(&parsed), "{case}"); + let Ok(by_text) = tessellate_brep(&text, deflection, None) else { + panic!("{case}: the text entry failed"); + }; + assert_eq!(direct, MeshBits::of_result(&by_text), "{case}"); + assert_eq!(by_text.revision(), body.revision, "{case}"); + let (by_object, revision) = std::thread::scope(|scope| { + scope + .spawn(|| { + let mut object = OGAnalyticBrep::from_brep(body.clone()); + let Ok(result) = object.tessellate(deflection, None) else { + panic!("{case}: the object entry failed"); + }; + (MeshBits::of_result(&result), result.revision()) + }) + .join() + }) + .unwrap(); + assert_eq!(direct, by_object, "{case}"); + assert_eq!(revision, body.revision, "{case}"); + *slot = direct.fnv1a(); + } + hashes.push(pair); + } + assert_eq!(hashes[0], hashes[5]); + if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + assert_eq!(hashes, PINNED_ON_AARCH64_APPLE_DARWIN, "{hashes:#018x?}"); + } + } + + fn wall_box() -> BrepEnvelope { + let accuracy = Accuracy { + geometric: 1e-9, + intersection: 1e-10, + tessellation: 0.001, + exchange: 1e-5, + }; + primitives::cuboid("wall".into(), Frame3::IDENTITY, [4.0, 0.2, 3.0], accuracy).unwrap() + } + + #[test] + fn object_entry_reads_the_triangle_limit_from_its_options() { + let mut body = OGAnalyticBrep::from_brep(wall_box()); + let Ok(unlimited) = body.tessellate(0.01, None) else { + panic!("the object entry failed without options"); + }; + assert_eq!(unlimited.indices().len(), 36); + let Ok(limited) = body.tessellate(0.01, Some(r#"{"max_triangles":12}"#.into())) else { + panic!("the object entry failed at the exact triangle count"); + }; + assert_eq!(limited.indices(), unlimited.indices()); + assert!(!Arc::ptr_eq(&limited.mesh, &unlimited.mesh)); + assert_eq!( + tessellation_limit(Some(r#"{"max_triangles":11}"#.into())).unwrap(), + 11 + ); + assert!(matches!( + tessellate_cached(&wall_box(), 0.01, 11), + Err(GeometryError::LimitExceeded(_)) + )); + } + + #[test] + fn object_entry_tessellates_a_body_whose_text_is_above_the_parse_limit() { + let Ok(short) = OGAnalyticBrep::from_brep(wall_box()).tessellate(0.01, None) else { + panic!("the object entry failed"); + }; + let mut body = wall_box(); + body.id = "x".repeat(64 * 1024 * 1024); + assert!(matches!( + BrepEnvelope::from_json(&body.to_json().unwrap()), + Err(GeometryError::LimitExceeded(_)) + )); + let Ok(long) = OGAnalyticBrep::from_brep(body).tessellate(0.01, None) else { + panic!("the object entry failed for a body whose text is above 64 MiB"); + }; + assert_eq!(long.positions(), short.positions()); + assert_eq!(long.indices(), short.indices()); + } + + #[test] + fn equal_shapes_share_one_mesh_and_each_result_carries_its_own_revision() { + let first = wall_box(); + let mut second = first.clone(); + second.id = "rebuilt".into(); + second.revision = 9; + let first_result = tessellate_cached(&first, 0.02, DEFAULT_MAX_TRIANGLES).unwrap(); + let second_result = tessellate_cached(&second, 0.02, DEFAULT_MAX_TRIANGLES).unwrap(); + assert!(Arc::ptr_eq(&first_result.mesh, &second_result.mesh)); + assert_eq!(first_result.revision(), 0); + assert_eq!(second_result.revision(), 9); + } } diff --git a/main/opengeometry/src/geometry/boolean2d.rs b/main/opengeometry/src/geometry/boolean2d.rs index fc702b9..915be18 100644 --- a/main/opengeometry/src/geometry/boolean2d.rs +++ b/main/opengeometry/src/geometry/boolean2d.rs @@ -2,6 +2,62 @@ use crate::geometry::poly2d::*; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RegionBooleanError { + OpenBoundary, + OrphanHole, +} + +pub type RegionResult = Result, RegionBooleanError>; + +impl std::fmt::Display for RegionBooleanError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::OpenBoundary => "Boolean regions produced an open boundary", + Self::OrphanHole => "Boolean regions produced a hole outside every region", + }) + } +} + +fn distance_to_segment(point: Pt2, a: Pt2, b: Pt2) -> f64 { + let along = Pt2::new(b.x - a.x, b.z - a.z); + let length_squared = dot2(along, along); + let t = if length_squared > 0.0 { + (dot2(Pt2::new(point.x - a.x, point.z - a.z), along) / length_squared).clamp(0.0, 1.0) + } else { + 0.0 + }; + (a.x + t * along.x - point.x).hypot(a.z + t * along.z - point.z) +} + +fn probe_reach(mid: Pt2, len: f64, most: f64, sub_edges: &[Edge2], eps: f64) -> f64 { + let mut clearance = 2.0 * (len * 0.25).min(most); + for &(a, b) in sub_edges { + if a.x.min(b.x) - mid.x >= clearance + || mid.x - a.x.max(b.x) >= clearance + || a.z.min(b.z) - mid.z >= clearance + || mid.z - a.z.max(b.z) >= clearance + { + continue; + } + let distance = distance_to_segment(mid, a, b); + if distance > eps && distance < clearance { + clearance = distance; + } + } + clearance * 0.5 +} + +pub fn ring_is_degenerate(ring: &[Pt2], eps: f64) -> bool { + let perimeter: f64 = (0..ring.len()) + .map(|index| { + let next = ring[(index + 1) % ring.len()]; + (ring[index].x - next.x).hypot(ring[index].z - next.z) + }) + .sum(); + ring.len() < 3 || signed_area2(ring).abs() < eps * perimeter * 0.5 +} + /// Decompose a (possibly self-intersecting) closed ring into simple loops by /// splitting at every self-crossing (stack method). Port of `decomposeSelfIntersections`. /// The `HashMap` here is keyed by integer node id and only ever LOOKED UP (never @@ -87,7 +143,7 @@ pub fn decompose_self_intersections(ring: &[Pt2], eps: f64) -> Vec> { /// A point just off the midpoint of `loop`'s first usable edge, on the given side. /// Port of `edgeProbe2`. -pub fn edge_probe2(ring: &[Pt2], side: f64) -> Option { +pub fn edge_probe2(ring: &[Pt2], side: f64, sub_edges: &[Edge2], eps: f64) -> Option { for i in 0..ring.len() { let a = ring[i]; let b = ring[(i + 1) % ring.len()]; @@ -99,9 +155,9 @@ pub fn edge_probe2(ring: &[Pt2], side: f64) -> Option { } let nx = -ez / len; let nz = ex / len; - let d = (len * 0.25).min(1.0e-3); let mx = (a.x + b.x) / 2.0; let mz = (a.z + b.z) / 2.0; + let d = probe_reach(Pt2::new(mx, mz), len, 1.0e-3, sub_edges, eps); return Some(Pt2::new(mx + side * nx * d, mz + side * nz * d)); } None @@ -142,13 +198,20 @@ pub fn resolve_ring_nonzero(input: &[Pt2], eps: f64) -> Option { let loops: Vec> = decompose_self_intersections(&ring, eps) .into_iter() .map(|l| dedupe_ring(&l, eps)) - .filter(|l| l.len() >= 3 && signed_area2(l).abs() > eps) + .filter(|l| !ring_is_degenerate(l, eps)) + .collect(); + let sub_edges: Vec = loops + .iter() + .flat_map(|l| (0..l.len()).map(move |index| (l[index], l[(index + 1) % l.len()]))) .collect(); let mut outer_rings: Vec> = Vec::new(); let mut hole_rings: Vec> = Vec::new(); for l in loops { - let (Some(p_in), Some(p_out)) = (edge_probe2(&l, 1.0), edge_probe2(&l, -1.0)) else { + let (Some(p_in), Some(p_out)) = ( + edge_probe2(&l, 1.0, &sub_edges, eps), + edge_probe2(&l, -1.0, &sub_edges, eps), + ) else { continue; }; let filled_left = winding_number2(p_in, &ring) != 0; @@ -202,7 +265,7 @@ pub fn resolve_ring_nonzero(input: &[Pt2], eps: f64) -> Option { /// Union CCW polygons by the NONZERO-WINDING rule (the crossing cleanup the /// single-ring offset cannot do). -pub fn union_polygons_nonzero(polys: &[Vec], eps: f64) -> Vec { +pub fn union_polygons_nonzero(polys: &[Vec], eps: f64) -> RegionResult { winding_regions(polys, eps, |winding| winding != 0) } @@ -210,7 +273,7 @@ pub fn union_polygons_nonzero(polys: &[Vec], eps: f64) -> Vec { /// +1, each CW ring −1, and a point is filled when the sum is > 0. With one CCW /// base ring and CW cutters this is base-minus-cutters, keeping EVERY disjoint /// remainder (a clip can split the base into separate regions). -pub fn resolve_polygons_positive(polys: &[Vec], eps: f64) -> Vec { +pub fn resolve_polygons_positive(polys: &[Vec], eps: f64) -> RegionResult { winding_regions(polys, eps, |winding| winding > 0) } @@ -222,7 +285,7 @@ pub fn boolean_oriented_regions( b: &[Vec], operation: PlanarBooleanOp, eps: f64, -) -> Vec { +) -> RegionResult { let mut contours = Vec::with_capacity(a.len() + b.len()); contours.extend_from_slice(a); contours.extend_from_slice(b); @@ -245,17 +308,13 @@ pub fn boolean_oriented_regions( }) } -fn winding_regions(polys: &[Vec], eps: f64, filled: impl Fn(i32) -> bool) -> Vec { +fn winding_regions(polys: &[Vec], eps: f64, filled: impl Fn(i32) -> bool) -> RegionResult { winding_regions_by(polys, eps, |point| { filled(polys.iter().map(|poly| winding_number2(point, poly)).sum()) }) } -fn winding_regions_by( - polys: &[Vec], - eps: f64, - filled: impl Fn(Pt2) -> bool, -) -> Vec { +fn winding_regions_by(polys: &[Vec], eps: f64, filled: impl Fn(Pt2) -> bool) -> RegionResult { // 1. directed edges from all oriented polygons + the vertex set for T-touch splits. let mut edges: Vec = Vec::new(); for poly in polys { @@ -277,9 +336,9 @@ pub fn regions_from_edges_by( edges: &[Edge2], eps: f64, filled: impl Fn(Pt2) -> bool, -) -> Vec { +) -> RegionResult { if edges.is_empty() { - return Vec::new(); + return Ok(Vec::new()); } let verts: Vec = edges.iter().flat_map(|&(a, b)| [a, b]).collect(); @@ -328,11 +387,11 @@ pub fn regions_from_edges_by( if len < eps { continue; } - let probe = (len * 0.25).min(1.0e-4); let nx = -dz / len; let nz = dx / len; let mx = (a.x + b.x) / 2.0; let mz = (a.z + b.z) / 2.0; + let probe = probe_reach(Pt2::new(mx, mz), len, 1.0e-4, &split, eps); let left = filled(Pt2::new(mx + nx * probe, mz + nz * probe)); let right = filled(Pt2::new(mx - nx * probe, mz - nz * probe)); if left == right { @@ -369,7 +428,7 @@ pub fn regions_from_edges_by( fresh }); if boundary.is_empty() { - return Vec::new(); + return Ok(Vec::new()); } // 4. stitch into loops; at each vertex take the next outgoing edge by smallest @@ -386,10 +445,12 @@ pub fn regions_from_edges_by( } let mut loop_pts: Vec = Vec::new(); let mut cur = start; + let mut last = start; let mut guard = 0; while !used[cur] && guard <= boundary.len() { guard += 1; used[cur] = true; + last = cur; let (a, b) = boundary[cur]; loop_pts.push(a); let back = Pt2::new(a.x - b.x, a.z - b.z); // reverse incoming, from b toward a @@ -415,6 +476,9 @@ pub fn regions_from_edges_by( None => break, } } + if ends[last].1 != ends[start].0 { + return Err(RegionBooleanError::OpenBoundary); + } if loop_pts.len() >= 3 { raw_loops.push(loop_pts); } @@ -425,7 +489,7 @@ pub fn regions_from_edges_by( let mut holes: Vec> = Vec::new(); for raw in raw_loops { let ring = dedupe_ring(&raw, eps); - if ring.len() < 3 || signed_area2(&ring).abs() < eps { + if ring_is_degenerate(&ring, eps) { continue; } if signed_area2(&ring) > 0.0 { @@ -438,14 +502,19 @@ pub fn regions_from_edges_by( } } for hole in holes { - if let Some(region) = regions + regions .iter_mut() - .find(|region| ring_inside2(&hole, ®ion.outer, eps)) - { - region.holes.push(hole); - } + .filter(|region| ring_inside2(&hole, ®ion.outer, eps)) + .min_by(|first, second| { + signed_area2(&first.outer) + .abs() + .total_cmp(&signed_area2(&second.outer).abs()) + }) + .ok_or(RegionBooleanError::OrphanHole)? + .holes + .push(hole); } - regions + Ok(regions) } #[cfg(test)] @@ -476,7 +545,8 @@ mod tests { edges.push((p(1.0, 0.0), p(1.0, 2.0))); let regions = regions_from_edges_by(&edges, DEFAULT_EPS, |point| { point.x > 0.0 && point.x < 1.0 && point.z > 0.0 && point.z < 2.0 - }); + }) + .unwrap(); assert_eq!(regions.len(), 1); assert!((signed_area2(®ions[0].outer) - 2.0).abs() < 1.0e-9); } @@ -487,7 +557,7 @@ mod tests { fn positive_clip_inner_cutter_makes_a_hole() { let base = ccw_square(0.0, 0.0, 10.0, 10.0); let cutter = cw(ccw_square(4.0, 4.0, 6.0, 6.0)); - let regions = resolve_polygons_positive(&[base, cutter], DEFAULT_EPS); + let regions = resolve_polygons_positive(&[base, cutter], DEFAULT_EPS).unwrap(); assert_eq!(regions.len(), 1, "one annular region"); assert_eq!(regions[0].holes.len(), 1, "the cutter is its hole"); assert!((signed_area2(®ions[0].outer) - 100.0).abs() < 1e-6); @@ -503,7 +573,7 @@ mod tests { fn positive_clip_keeps_every_disjoint_remainder() { let base = ccw_square(0.0, 0.0, 10.0, 10.0); let cutter = cw(ccw_square(-1.0, 4.0, 11.0, 6.0)); - let regions = resolve_polygons_positive(&[base, cutter], DEFAULT_EPS); + let regions = resolve_polygons_positive(&[base, cutter], DEFAULT_EPS).unwrap(); assert_eq!(regions.len(), 2, "the band splits the base in two"); for region in ®ions { assert!( @@ -520,12 +590,16 @@ mod tests { fn positive_clip_total_cover_and_lone_cutter_are_empty() { let base = ccw_square(0.0, 0.0, 4.0, 4.0); let cutter = cw(ccw_square(-1.0, -1.0, 5.0, 5.0)); - assert!(resolve_polygons_positive(&[base, cutter], DEFAULT_EPS).is_empty()); + assert!(resolve_polygons_positive(&[base, cutter], DEFAULT_EPS) + .unwrap() + .is_empty()); let lone = cw(ccw_square(0.0, 0.0, 1.0, 1.0)); - assert!(resolve_polygons_positive(&[lone.clone()], DEFAULT_EPS).is_empty()); + assert!(resolve_polygons_positive(&[lone.clone()], DEFAULT_EPS) + .unwrap() + .is_empty()); assert_eq!( - union_polygons_nonzero(&[lone], DEFAULT_EPS).len(), + union_polygons_nonzero(&[lone], DEFAULT_EPS).unwrap().len(), 1, "contrast: the nonzero rule fills a lone CW ring" ); @@ -538,7 +612,7 @@ mod tests { let base = ccw_square(0.0, 0.0, 10.0, 10.0); let cutter_a = cw(ccw_square(-1.0, -1.0, 3.0, 3.0)); let cutter_b = cw(ccw_square(1.0, 1.0, 4.0, 2.0)); - let regions = resolve_polygons_positive(&[base, cutter_a, cutter_b], DEFAULT_EPS); + let regions = resolve_polygons_positive(&[base, cutter_a, cutter_b], DEFAULT_EPS).unwrap(); assert_eq!(regions.len(), 1); let region = ®ions[0]; assert!(region.holes.is_empty()); @@ -558,19 +632,19 @@ mod tests { ]; let b = vec![ccw_square(5.0, -1.0, 12.0, 5.0)]; - let union = boolean_oriented_regions(&a, &b, PlanarBooleanOp::Union, DEFAULT_EPS); + let union = boolean_oriented_regions(&a, &b, PlanarBooleanOp::Union, DEFAULT_EPS).unwrap(); assert_eq!(union.len(), 1); assert_eq!(union[0].holes.len(), 1); assert!((signed_area2(&union[0].holes[0]) + 3.0).abs() < 1e-6); let intersection = - boolean_oriented_regions(&a, &b, PlanarBooleanOp::Intersection, DEFAULT_EPS); + boolean_oriented_regions(&a, &b, PlanarBooleanOp::Intersection, DEFAULT_EPS).unwrap(); assert_eq!(intersection.len(), 1); assert!(intersection[0].holes.is_empty()); assert!((signed_area2(&intersection[0].outer) - 24.0).abs() < 1e-6); let subtraction = - boolean_oriented_regions(&a, &b, PlanarBooleanOp::Subtraction, DEFAULT_EPS); + boolean_oriented_regions(&a, &b, PlanarBooleanOp::Subtraction, DEFAULT_EPS).unwrap(); assert_eq!(subtraction.len(), 1); assert!(subtraction[0].holes.is_empty()); assert!( @@ -633,7 +707,8 @@ mod tests { std::slice::from_ref(wall), PlanarBooleanOp::Union, DEFAULT_EPS, - ); + ) + .unwrap(); merged = regions .iter() .flat_map(|region| { @@ -662,8 +737,168 @@ mod tests { let scale = 1.0e-4; let a = vec![ccw_square(0.0, 0.0, scale, scale)]; let b = vec![ccw_square(0.5 * scale, 0.0, 1.5 * scale, scale)]; - let result = boolean_oriented_regions(&a, &b, PlanarBooleanOp::Union, 1.0e-10); + let result = boolean_oriented_regions(&a, &b, PlanarBooleanOp::Union, 1.0e-10).unwrap(); assert_eq!(result.len(), 1); assert!((signed_area2(&result[0].outer) - 1.5 * scale * scale).abs() < 1.0e-14); } + + fn total_area(regions: &[RingRegion]) -> f64 { + regions + .iter() + .map(|region| { + signed_area2(®ion.outer).abs() + - region + .holes + .iter() + .map(|ring| signed_area2(ring).abs()) + .sum::() + }) + .sum() + } + + fn areas_of_all_operations(a: &[Vec], b: &[Vec]) -> [(usize, f64); 3] { + [ + PlanarBooleanOp::Union, + PlanarBooleanOp::Intersection, + PlanarBooleanOp::Subtraction, + ] + .map(|operation| { + let regions = boolean_oriented_regions(a, b, operation, DEFAULT_EPS).unwrap(); + (regions.len(), total_area(®ions)) + }) + } + + fn assert_counts_and_areas(got: [(usize, f64); 3], expected: [(usize, f64); 3]) { + for (index, (got, expected)) in got.into_iter().zip(expected).enumerate() { + assert_eq!(got.0, expected.0, "operation {index}"); + assert!( + (got.1 - expected.1).abs() < 1.0e-9, + "operation {index}: area {}, expected {}", + got.1, + expected.1 + ); + } + } + + #[test] + fn coincident_edges_of_two_operands_class_by_both_windings() { + let unit = vec![ccw_square(0.0, 0.0, 1.0, 1.0)]; + let beside = vec![ccw_square(1.0, 0.0, 2.0, 1.0)]; + assert_counts_and_areas( + areas_of_all_operations(&unit, &beside), + [(1, 2.0), (0, 0.0), (1, 1.0)], + ); + let within = vec![ccw_square(0.0, 0.0, 0.5, 1.0)]; + assert_counts_and_areas( + areas_of_all_operations(&unit, &within), + [(1, 1.0), (1, 0.5), (1, 0.5)], + ); + assert_counts_and_areas( + areas_of_all_operations(&unit, &unit), + [(1, 1.0), (1, 1.0), (0, 0.0)], + ); + } + + #[test] + fn a_zero_area_sliver_adds_nothing_inside_or_outside_a_region() { + let unit = vec![ccw_square(0.0, 0.0, 1.0, 1.0)]; + for sliver in [ + vec![p(0.2, 0.5), p(0.8, 0.5), p(0.5, 0.5)], + vec![p(1.5, 0.5), p(2.5, 0.5), p(2.0, 0.5)], + ] { + let regions = + boolean_oriented_regions(&unit, &[sliver], PlanarBooleanOp::Union, DEFAULT_EPS) + .unwrap(); + assert_eq!(regions.len(), 1); + assert_eq!(regions[0].outer.len(), 4); + assert!(regions[0].holes.is_empty()); + assert!((total_area(®ions) - 1.0).abs() < 1.0e-12); + } + } + + #[test] + fn a_t_junction_splits_the_edge_it_meets() { + let unit = vec![ccw_square(0.0, 0.0, 1.0, 1.0)]; + let stem = vec![ccw_square(0.25, 1.0, 0.75, 2.0)]; + let regions = + boolean_oriented_regions(&unit, &stem, PlanarBooleanOp::Union, DEFAULT_EPS).unwrap(); + assert_eq!(regions.len(), 1); + assert_eq!(regions[0].outer.len(), 8); + assert!((total_area(®ions) - 1.5).abs() < 1.0e-12); + } + + #[test] + fn a_clearance_below_twice_the_tolerance_still_classes_both_edges() { + let gap = 1.5 * DEFAULT_EPS; + let unit = vec![ccw_square(0.0, 0.0, 1.0, 1.0)]; + let beside = vec![ccw_square(1.0 + gap, 0.0, 2.0 + gap, 1.0)]; + assert_counts_and_areas( + areas_of_all_operations(&unit, &beside), + [(2, 2.0), (0, 0.0), (1, 1.0)], + ); + } + + #[test] + fn a_ring_is_degenerate_when_it_is_thinner_than_the_tolerance() { + let long = |width: f64| vec![ccw_square(0.0, 0.0, 10.0, width)]; + let thinner = boolean_oriented_regions( + &long(0.4 * DEFAULT_EPS), + &[], + PlanarBooleanOp::Union, + DEFAULT_EPS, + ) + .unwrap(); + assert!(thinner.is_empty()); + let wider = boolean_oriented_regions( + &long(4.0 * DEFAULT_EPS), + &[], + PlanarBooleanOp::Union, + DEFAULT_EPS, + ) + .unwrap(); + assert_eq!(wider.len(), 1); + assert!((total_area(&wider) - 40.0 * DEFAULT_EPS).abs() < 1.0e-12); + let small = boolean_oriented_regions( + &[ccw_square(0.0, 0.0, 3.0e-4, 3.0e-4)], + &[], + PlanarBooleanOp::Union, + DEFAULT_EPS, + ) + .unwrap(); + assert_eq!(small.len(), 1); + } + + fn edges_of(ring: &[Pt2]) -> Vec { + (0..ring.len()) + .map(|index| (ring[index], ring[(index + 1) % ring.len()])) + .collect() + } + + fn inside_the_unit_square(point: Pt2) -> bool { + point.x > 0.0 && point.x < 1.0 && point.z > 0.0 && point.z < 1.0 + } + + #[test] + fn an_open_chain_of_boundary_edges_is_an_error_and_not_a_ring() { + let mut edges = edges_of(&ccw_square(0.0, 0.0, 1.0, 1.0)); + edges.pop(); + assert_eq!( + regions_from_edges_by(&edges, DEFAULT_EPS, inside_the_unit_square).err(), + Some(RegionBooleanError::OpenBoundary) + ); + assert_eq!( + RegionBooleanError::OpenBoundary.to_string(), + "Boolean regions produced an open boundary" + ); + } + + #[test] + fn a_hole_that_no_region_contains_is_an_error() { + let edges = edges_of(&ccw_square(0.0, 0.0, 1.0, 1.0)); + assert_eq!( + regions_from_edges_by(&edges, DEFAULT_EPS, |point| !inside_the_unit_square(point)) + .err(), + Some(RegionBooleanError::OrphanHole) + ); + } } diff --git a/main/opengeometry/src/geometry/offset_regions.rs b/main/opengeometry/src/geometry/offset_regions.rs index 82ea72a..568ab9c 100644 --- a/main/opengeometry/src/geometry/offset_regions.rs +++ b/main/opengeometry/src/geometry/offset_regions.rs @@ -58,6 +58,7 @@ pub fn boolean_offset_regions( let b_contours = contours(b)?; Ok( boolean_oriented_regions(&a_contours, &b_contours, operation, DEFAULT_EPS) + .map_err(|error| error.to_string())? .into_iter() .map(|region| finalize_region(®ion.outer, ®ion.holes, base_y)) .collect(), @@ -144,6 +145,118 @@ mod planar_boolean_export_tests { .collect() } + fn square_region(low: f64, high: f64, hole: Option<(f64, f64)>) -> OffsetRegion { + let clockwise = + |low: f64, high: f64| ring(&[(low, low), (low, high), (high, high), (high, low)]); + let holes = hole + .map(|(low, high)| { + let mut counter_clockwise = clockwise(low, high); + counter_clockwise.reverse(); + counter_clockwise + }) + .into_iter() + .collect(); + (clockwise(low, high), holes) + } + + fn spans(regions: &[OffsetRegion]) -> Vec<((i64, i64), Vec<(i64, i64)>)> { + let span = |points: &Vec| { + let low = points + .iter() + .map(|point| point.x) + .fold(f64::INFINITY, f64::min); + let high = points + .iter() + .map(|point| point.x) + .fold(f64::NEG_INFINITY, f64::max); + ( + (low * 1000.0).round() as i64, + (high * 1000.0).round() as i64, + ) + }; + let mut all: Vec<_> = regions + .iter() + .map(|(outer, holes)| { + let mut inner: Vec<_> = holes.iter().map(span).collect(); + inner.sort_unstable(); + (span(outer), inner) + }) + .collect(); + all.sort(); + all + } + + #[test] + fn exported_boolean_gives_a_nested_hole_to_its_own_region_in_both_orders() { + let frame = square_region(0.0, 10.0, Some((1.0, 9.0))); + let island = square_region(3.0, 7.0, Some((4.0, 6.0))); + let expected = spans(&[frame.clone(), island.clone()]); + for (a, b) in [ + (vec![frame.clone()], vec![island.clone()]), + (vec![island.clone()], vec![frame.clone()]), + (vec![frame.clone(), island.clone()], Vec::new()), + ] { + let united = boolean_offset_regions(&a, &b, PlanarBooleanOp::Union).unwrap(); + assert_eq!(spans(&united), expected); + } + let levels = [ + square_region(0.0, 20.0, Some((1.0, 19.0))), + square_region(3.0, 17.0, Some((4.0, 16.0))), + square_region(6.0, 14.0, Some((7.0, 13.0))), + square_region(9.0, 11.0, None), + ]; + let united = + boolean_offset_regions(&levels[..1], &levels[1..], PlanarBooleanOp::Union).unwrap(); + assert_eq!(spans(&united), spans(&levels)); + } + + #[test] + fn exported_boolean_keeps_two_regions_across_a_gap_inside_the_old_probe_reach() { + let left = vec![( + ring(&[(0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (1.0, 0.0)]), + Vec::new(), + )]; + let right = vec![( + ring(&[ + (1.00005, 0.0), + (1.00005, 1.0), + (2.00005, 1.0), + (2.00005, 0.0), + ]), + Vec::new(), + )]; + let united = boolean_offset_regions(&left, &right, PlanarBooleanOp::Union).unwrap(); + assert_eq!(united.len(), 2); + for (outer, holes) in &united { + assert_eq!(outer.len(), 4); + assert!(holes.is_empty()); + assert!((signed_area_xz(outer) + 1.0).abs() < 1.0e-9); + } + } + + #[test] + fn exported_boolean_reports_a_boundary_that_cannot_be_closed() { + let long = vec![( + ring(&[(0.0, 0.0), (0.0, 1.0), (1000.0, 1.0), (1000.0, 0.0)]), + Vec::new(), + )]; + let beside_its_end = vec![( + ring(&[ + (999.9995, 1.0), + (999.9995, 2.0), + (1000.5, 2.0), + (1000.5, 1.0), + ]), + Vec::new(), + )]; + assert_eq!( + boolean_offset_regions(&long, &beside_its_end, PlanarBooleanOp::Union) + .err() + .as_deref(), + Some("Boolean regions produced an open boundary") + ); + } + #[test] fn exported_boolean_preserves_split_regions_and_winding() { let a = vec![( @@ -263,9 +376,9 @@ pub fn offset_polyline_regions( closed: bool, miter_limit: f64, eps: f64, -) -> Vec { +) -> Result, RegionBooleanError> { if width <= 0.0 || !width.is_finite() || centreline.len() < 2 { - return Vec::new(); + return Ok(Vec::new()); } let simplified = simplify_polyline(centreline, width / 2.0, closed); let base_y = simplified.first().map(|v| v.y).unwrap_or(0.0); @@ -281,17 +394,17 @@ pub fn offset_polyline_regions( if is_closed && self_intersects2(&pts, eps) { let half_width = width / 2.0; let bands = mitered_offset_bands(&pts, true, half_width, f64::INFINITY, eps); - return union_polygons_nonzero(&bands, eps) + return Ok(union_polygons_nonzero(&bands, eps)? .into_iter() .map(|region| { let outer = bevel_sharp_convex_pts(®ion.outer, half_width, miter_limit, eps); finalize_region(&outer, ®ion.holes, base_y) }) - .collect(); + .collect()); } // Simple centrelines: the single-ring analytic offset (one region, or none if degenerate). - match offset_outline(&simplified, width, is_closed, miter_limit, eps) { + let regions = match offset_outline(&simplified, width, is_closed, miter_limit, eps) { Some(outline) => { let outer = canonical_ring(&normalize_winding(&outline.outer, Winding::Cw)); let holes = outline @@ -302,7 +415,8 @@ pub fn offset_polyline_regions( vec![(outer, holes)] } None => Vec::new(), - } + }; + Ok(regions) } /// Convert a resolved 2D region (outer + holes, in Pt2) to an `OffsetRegion`: @@ -512,8 +626,9 @@ pub fn offset_ring_variable( } Ok(resolve_polygons_positive(&polys, eps) + .map_err(|error| error.to_string())? .into_iter() - .filter(|region| signed_area2(®ion.outer).abs() > eps) + .filter(|region| !ring_is_degenerate(®ion.outer, eps)) .map(|region| finalize_region(®ion.outer, ®ion.holes, base_y)) .collect()) } @@ -535,7 +650,7 @@ pub fn offset_polyline_group_regions( polylines: &[OffsetPolyline], miter_limit: f64, eps: f64, -) -> Vec { +) -> Result, RegionBooleanError> { let mut bands: Vec> = Vec::new(); let mut base_y = 0.0; let mut max_half = 0.0_f64; @@ -562,15 +677,15 @@ pub fn offset_polyline_group_regions( )); } if bands.is_empty() { - return Vec::new(); + return Ok(Vec::new()); } - union_polygons_nonzero(&bands, eps) + Ok(union_polygons_nonzero(&bands, eps)? .into_iter() .map(|region| { let outer = bevel_sharp_convex_pts(®ion.outer, max_half, miter_limit, eps); finalize_region(&outer, ®ion.holes, base_y) }) - .collect() + .collect()) } // ── wasm boundary ──────────────────────────────────────────────────────────── @@ -637,7 +752,8 @@ pub fn offset_polyline_regions_wasm( DEFAULT_MITER_LIMIT }; - let regions = offset_polyline_regions(¢reline, width, closed, limit, DEFAULT_EPS); + let regions = offset_polyline_regions(¢reline, width, closed, limit, DEFAULT_EPS) + .map_err(|error| JsValue::from_str(&error.to_string()))?; OGOffsetRegionsResult::from_regions(regions).map_err(|error| JsValue::from_str(&error)) } @@ -675,7 +791,8 @@ pub fn offset_polyline_group_regions_wasm( }) .collect(); - let regions = offset_polyline_group_regions(&polylines, DEFAULT_MITER_LIMIT, DEFAULT_EPS); + let regions = offset_polyline_group_regions(&polylines, DEFAULT_MITER_LIMIT, DEFAULT_EPS) + .map_err(|error| JsValue::from_str(&error.to_string()))?; OGOffsetRegionsResult::from_regions(regions).map_err(|error| JsValue::from_str(&error)) } @@ -769,13 +886,10 @@ mod tests { #[test] fn straight_offsets_by_half_width() { let centreline = vec![pt(0.0, 0.0), pt(2.0, 0.0)]; - let (outer, holes) = single(offset_polyline_regions( - ¢reline, - 0.3, - false, - DEFAULT_MITER_LIMIT, - DEFAULT_EPS, - )); + let (outer, holes) = single( + offset_polyline_regions(¢reline, 0.3, false, DEFAULT_MITER_LIMIT, DEFAULT_EPS) + .unwrap(), + ); assert_eq!(outer.len(), 4, "a straight stroke is a 4-point rectangle"); assert!(holes.is_empty(), "an open stroke has no holes"); // Centreline runs along +x at z=0; offsets should be at z = ±0.15. @@ -800,7 +914,8 @@ mod tests { closed: false, }, ]; - let regions = offset_polyline_group_regions(&polylines, DEFAULT_MITER_LIMIT, DEFAULT_EPS); + let regions = + offset_polyline_group_regions(&polylines, DEFAULT_MITER_LIMIT, DEFAULT_EPS).unwrap(); assert_eq!(regions.len(), 1, "an X-crossing merges into one region"); let (outer, holes) = ®ions[0]; assert!(holes.is_empty(), "a simple cross has no interior void"); @@ -825,13 +940,10 @@ mod tests { #[test] fn open_l_is_a_simple_ring() { let centreline = vec![pt(0.0, 0.0), pt(2.0, 0.0), pt(2.0, 2.0), pt(4.0, 2.0)]; - let (outer, holes) = single(offset_polyline_regions( - ¢reline, - 0.3, - false, - DEFAULT_MITER_LIMIT, - DEFAULT_EPS, - )); + let (outer, holes) = single( + offset_polyline_regions(¢reline, 0.3, false, DEFAULT_MITER_LIMIT, DEFAULT_EPS) + .unwrap(), + ); assert!(holes.is_empty(), "open chain has no holes"); let ring: Vec = outer.iter().map(|v| Pt2::new(v.x, v.z)).collect(); assert!(ring.len() >= 4, "ring has at least 4 points"); @@ -855,13 +967,10 @@ mod tests { pt(0.0, 4.0), pt(0.0, 0.0), ]; - let (outer, holes) = single(offset_polyline_regions( - ¢reline, - 0.4, - true, - DEFAULT_MITER_LIMIT, - DEFAULT_EPS, - )); + let (outer, holes) = single( + offset_polyline_regions(¢reline, 0.4, true, DEFAULT_MITER_LIMIT, DEFAULT_EPS) + .unwrap(), + ); assert!(outer.len() >= 4, "outer ring has at least 4 points"); assert_eq!( holes.len(), @@ -888,11 +997,13 @@ mod tests { }) }; let baseline = - offset_polyline_regions(¢reline, 0.3, true, DEFAULT_MITER_LIMIT, DEFAULT_EPS); + offset_polyline_regions(¢reline, 0.3, true, DEFAULT_MITER_LIMIT, DEFAULT_EPS) + .unwrap(); assert!(!baseline.is_empty(), "star offset builds"); for _ in 0..8 { let again = - offset_polyline_regions(¢reline, 0.3, true, DEFAULT_MITER_LIMIT, DEFAULT_EPS); + offset_polyline_regions(¢reline, 0.3, true, DEFAULT_MITER_LIMIT, DEFAULT_EPS) + .unwrap(); assert_eq!(baseline.len(), again.len(), "region count must be stable"); for ((o1, h1), (o2, h2)) in baseline.iter().zip(&again) { assert!(same(o1, o2), "outer ring must be identical across calls"); @@ -915,13 +1026,10 @@ mod tests { pt(0.0, 1.0), pt(0.0, 0.0), ]; - let (outer, holes) = single(offset_polyline_regions( - ¢reline, - 1.0, - true, - DEFAULT_MITER_LIMIT, - DEFAULT_EPS, - )); + let (outer, holes) = single( + offset_polyline_regions(¢reline, 1.0, true, DEFAULT_MITER_LIMIT, DEFAULT_EPS) + .unwrap(), + ); assert!( holes.is_empty(), "the interior void is consumed → no hole (solid)" @@ -949,7 +1057,8 @@ mod tests { pt(0.0, 3.0), pt(0.0, 0.0), ]; - let regions = offset_polyline_regions(&bowtie, 0.3, true, DEFAULT_MITER_LIMIT, DEFAULT_EPS); + let regions = + offset_polyline_regions(&bowtie, 0.3, true, DEFAULT_MITER_LIMIT, DEFAULT_EPS).unwrap(); assert!(!regions.is_empty(), "figure-8 builds"); for (outer, _holes) in ®ions { let ring: Vec = outer.iter().map(|v| Pt2::new(v.x, v.z)).collect(); @@ -988,13 +1097,10 @@ mod tests { #[test] fn acute_open_corner_bevels_and_stays_bounded() { let centreline = vec![pt(0.0, 0.0), pt(5.0, 0.0), pt(2.0, 1.0)]; - let (outer, holes) = single(offset_polyline_regions( - ¢reline, - 0.5, - false, - DEFAULT_MITER_LIMIT, - DEFAULT_EPS, - )); + let (outer, holes) = single( + offset_polyline_regions(¢reline, 0.5, false, DEFAULT_MITER_LIMIT, DEFAULT_EPS) + .unwrap(), + ); assert!(holes.is_empty(), "open stroke has no holes"); let ring: Vec = outer.iter().map(|v| Pt2::new(v.x, v.z)).collect(); assert!( @@ -1014,13 +1120,10 @@ mod tests { #[test] fn self_overlapping_open_path_resolves_simple() { let centreline = vec![pt(0.0, 0.0), pt(5.0, 0.0), pt(1.0, 0.4)]; - let (outer, _holes) = single(offset_polyline_regions( - ¢reline, - 0.3, - false, - DEFAULT_MITER_LIMIT, - DEFAULT_EPS, - )); + let (outer, _holes) = single( + offset_polyline_regions(¢reline, 0.3, false, DEFAULT_MITER_LIMIT, DEFAULT_EPS) + .unwrap(), + ); let ring: Vec = outer.iter().map(|v| Pt2::new(v.x, v.z)).collect(); assert!( !self_intersects2(&ring, DEFAULT_EPS), diff --git a/main/opengeometry/src/lib.rs b/main/opengeometry/src/lib.rs index 71f6b6f..35e84a8 100644 --- a/main/opengeometry/src/lib.rs +++ b/main/opengeometry/src/lib.rs @@ -51,3 +51,10 @@ pub mod freeform; pub mod tolerance; pub mod units; pub mod world; + +#[cfg(any(all(target_arch = "wasm32", target_os = "unknown"), test))] +mod memory; + +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +#[global_allocator] +static ALLOCATOR: memory::GrowAhead = memory::GrowAhead; diff --git a/main/opengeometry/src/memory.rs b/main/opengeometry/src/memory.rs new file mode 100644 index 0000000..5c51df6 --- /dev/null +++ b/main/opengeometry/src/memory.rs @@ -0,0 +1,113 @@ +const PAGE_BYTES: usize = 65_536; +const RESERVE_DIVISOR: usize = 8; +const LEAST_RESERVE: usize = 1024 * 1024; +const MOST_RESERVE: usize = 64 * 1024 * 1024; + +fn reserve_bytes(pages: usize) -> usize { + (pages / RESERVE_DIVISOR) + .saturating_mul(PAGE_BYTES) + .clamp(LEAST_RESERVE, MOST_RESERVE) +} + +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +pub use grow_ahead::GrowAhead; + +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +mod grow_ahead { + use super::reserve_bytes; + use core::arch::wasm32::memory_size; + use core::sync::atomic::{AtomicBool, Ordering}; + use std::alloc::{GlobalAlloc, Layout, System}; + + const RESERVE_ALIGNMENT: usize = 8; + + static RESERVE_OFF: AtomicBool = AtomicBool::new(false); + + pub struct GrowAhead; + + #[inline(never)] + unsafe fn system_alloc(layout: Layout) -> *mut u8 { + unsafe { System.alloc(layout) } + } + + #[inline(never)] + unsafe fn system_dealloc(block: *mut u8, layout: Layout) { + unsafe { System.dealloc(block, layout) } + } + + #[inline(never)] + fn reserve(bytes: usize) { + let Ok(layout) = Layout::from_size_align(bytes, RESERVE_ALIGNMENT) else { + return; + }; + let block = unsafe { system_alloc(layout) }; + if block.is_null() { + RESERVE_OFF.store(true, Ordering::Relaxed); + } else { + unsafe { system_dealloc(block, layout) }; + } + } + + fn reserve_after_growth(pages_before: usize) { + let pages = memory_size::<0>(); + if pages != pages_before && !RESERVE_OFF.load(Ordering::Relaxed) { + reserve(reserve_bytes(pages)); + } + } + + unsafe impl GlobalAlloc for GrowAhead { + #[inline(never)] + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let pages = memory_size::<0>(); + let block = unsafe { system_alloc(layout) }; + reserve_after_growth(pages); + block + } + + #[inline(never)] + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + let pages = memory_size::<0>(); + let block = unsafe { System.alloc_zeroed(layout) }; + reserve_after_growth(pages); + block + } + + #[inline(never)] + unsafe fn dealloc(&self, block: *mut u8, layout: Layout) { + unsafe { system_dealloc(block, layout) }; + } + + #[inline(never)] + unsafe fn realloc(&self, block: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + let pages = memory_size::<0>(); + let moved = unsafe { System.realloc(block, layout, new_size) }; + reserve_after_growth(pages); + moved + } + } +} + +#[cfg(test)] +mod tests { + use super::reserve_bytes; + + const MEBIBYTE: usize = 1024 * 1024; + + #[test] + fn reserve_is_an_eighth_of_the_memory_between_one_and_sixty_four_mebibytes() { + assert_eq!(reserve_bytes(19), MEBIBYTE); + assert_eq!(reserve_bytes(2560), 20 * MEBIBYTE); + assert_eq!(reserve_bytes(16_384), 64 * MEBIBYTE); + } + + #[test] + fn reserve_of_a_full_address_space_does_not_overflow() { + assert_eq!(reserve_bytes(65_536), 64 * MEBIBYTE); + } + + #[test] + fn reserve_is_bounded_for_every_page_count() { + assert_eq!(reserve_bytes(0), MEBIBYTE); + assert_eq!(reserve_bytes(usize::MAX), 64 * MEBIBYTE); + } +} diff --git a/main/opengeometry/tests/region_boolean_probe.rs b/main/opengeometry/tests/region_boolean_probe.rs new file mode 100644 index 0000000..2962238 --- /dev/null +++ b/main/opengeometry/tests/region_boolean_probe.rs @@ -0,0 +1,298 @@ +use opengeometry::geometry::boolean2d::{boolean_oriented_regions, PlanarBooleanOp, RingRegion}; +use opengeometry::geometry::poly2d::{signed_area2, Pt2, DEFAULT_EPS}; + +const GAPS_INSIDE_THE_OLD_REACH: [f64; 3] = [2.0e-6, 1.0e-5, 9.0e-5]; + +fn rectangle(x0: f64, z0: f64, x1: f64, z1: f64) -> Vec { + vec![ + Pt2::new(x0, z0), + Pt2::new(x1, z0), + Pt2::new(x1, z1), + Pt2::new(x0, z1), + ] +} + +fn hole(x0: f64, z0: f64, x1: f64, z1: f64) -> Vec { + let mut ring = rectangle(x0, z0, x1, z1); + ring.reverse(); + ring +} + +fn framed(low: f64, high: f64) -> Vec> { + vec![ + rectangle(low, low, high, high), + hole(low + 1.0, low + 1.0, high - 1.0, high - 1.0), + ] +} + +fn regions(a: &[Vec], b: &[Vec], operation: PlanarBooleanOp) -> Vec { + boolean_oriented_regions(a, b, operation, DEFAULT_EPS).unwrap() +} + +fn union(a: &[Vec], b: &[Vec]) -> Vec { + regions(a, b, PlanarBooleanOp::Union) +} + +fn region_area(region: &RingRegion) -> f64 { + signed_area2(®ion.outer).abs() + - region + .holes + .iter() + .map(|ring| signed_area2(ring).abs()) + .sum::() +} + +fn area(regions: &[RingRegion]) -> f64 { + regions.iter().map(region_area).sum() +} + +fn span(ring: &[Pt2]) -> (f64, f64) { + let low = ring + .iter() + .map(|point| point.x) + .fold(f64::INFINITY, f64::min); + let high = ring + .iter() + .map(|point| point.x) + .fold(f64::NEG_INFINITY, f64::max); + (low, high) +} + +type Nest = Vec<((f64, f64), Vec<(f64, f64)>)>; + +fn nest(regions: &[RingRegion]) -> Nest { + let mut spans: Nest = regions + .iter() + .map(|region| { + let mut holes: Vec<_> = region.holes.iter().map(|ring| span(ring)).collect(); + holes.sort_by(|first, second| first.0.total_cmp(&second.0)); + (span(®ion.outer), holes) + }) + .collect(); + spans.sort_by(|first, second| first.0 .0.total_cmp(&second.0 .0)); + spans +} + +fn assert_area(regions: &[RingRegion], expected: f64, case: &str) { + let got = area(regions); + assert!( + (got - expected).abs() <= 1.0e-9 * expected.max(1.0e-6), + "{case}: area {got}, expected {expected}" + ); +} + +#[test] +fn two_squares_with_a_small_gap_stay_two_regions_at_every_size_and_distance() { + for side in [1.0e-2, 1.0, 1.0e3] { + for far in [0.0, 100.0, 1000.0] { + for gap in GAPS_INSIDE_THE_OLD_REACH { + let case = format!("side {side}, at {far}, gap {gap}"); + let a = [rectangle(far, far, far + side, far + side)]; + let b = [rectangle( + far + side + gap, + far, + far + 2.0 * side + gap, + far + side, + )]; + let united = union(&a, &b); + assert_eq!(united.len(), 2, "{case}"); + assert!( + (area(&united) / (2.0 * side * side) - 1.0).abs() < 1.0e-6, + "{case}: area {}", + area(&united) + ); + assert!( + regions(&a, &b, PlanarBooleanOp::Intersection).is_empty(), + "{case}" + ); + let kept = regions(&a, &b, PlanarBooleanOp::Subtraction); + assert_eq!(kept.len(), 1, "{case}"); + assert_eq!(kept[0].outer.len(), 4, "{case}"); + } + } + } +} + +#[test] +fn squares_smaller_than_a_millimetre_are_kept_at_every_gap() { + for side in [3.0e-4, 1.0e-4] { + for gap in [2.0e-6, 1.0e-5, 9.0e-5, 1.0e-3, 1.0e-2] { + let united = union( + &[rectangle(0.0, 0.0, side, side)], + &[rectangle(side + gap, 0.0, 2.0 * side + gap, side)], + ); + assert_eq!(united.len(), 2, "side {side}, gap {gap}"); + assert!( + (area(&united) / (2.0 * side * side) - 1.0).abs() < 1.0e-6, + "side {side}, gap {gap}: area {}", + area(&united) + ); + } + } +} + +#[test] +fn a_strip_thinner_than_the_old_reach_is_a_region_of_its_own() { + for width in [3.0e-6, 1.0e-5, 9.0e-5] { + let strip = [rectangle(0.0, 0.0, 1.0, width)]; + let alone = union(&strip, &[]); + assert_eq!(alone.len(), 1, "width {width}"); + assert_area(&alone, width, "strip alone"); + let kept = regions(&strip, &[], PlanarBooleanOp::Subtraction); + assert_eq!(kept.len(), 1, "width {width}"); + assert_area(&kept, width, "strip minus nothing"); + } +} + +#[test] +fn a_thin_strip_and_a_square_combine_rightly_in_every_position() { + let width = 1.0e-5; + let unit = [rectangle(0.0, 0.0, 1.0, 1.0)]; + let on_the_top_edge = [rectangle(0.0, 1.0, 1.0, 1.0 + width)]; + let across = [rectangle(-0.5, 0.5, 1.5, 0.5 + width)]; + let above = [rectangle(0.0, 1.5, 1.0, 1.5 + width)]; + let inside = [rectangle(0.2, 0.5, 0.8, 0.5 + width)]; + type Expected = (usize, f64); + let cases: [(&str, &[Vec], Expected, Expected, Expected); 4] = [ + ( + "on the top edge", + &on_the_top_edge, + (1, 1.0 + width), + (0, 0.0), + (1, 1.0), + ), + ( + "across", + &across, + (1, 1.0 + width), + (1, width), + (2, 1.0 - width), + ), + ("above", &above, (2, 1.0 + width), (0, 0.0), (1, 1.0)), + ( + "inside", + &inside, + (1, 1.0), + (1, 0.6 * width), + (1, 1.0 - 0.6 * width), + ), + ]; + for (name, strip, united, common, cut) in cases { + for (operation, (count, expected)) in [ + (PlanarBooleanOp::Union, united), + (PlanarBooleanOp::Intersection, common), + (PlanarBooleanOp::Subtraction, cut), + ] { + let case = format!("strip {name}, {operation:?}"); + let result = regions(&unit, strip, operation); + assert_eq!(result.len(), count, "{case}"); + if count > 0 { + assert_area(&result, expected, &case); + } + } + } + let slot = regions(&unit, &inside, PlanarBooleanOp::Subtraction); + assert_eq!(slot[0].holes.len(), 1); +} + +#[test] +fn no_result_ring_is_an_open_chain_closed_by_a_straight_line() { + for gap in GAPS_INSIDE_THE_OLD_REACH { + let united = union( + &[rectangle(0.0, 0.0, 1.0, 1.0)], + &[rectangle(1.0 + gap, 0.0, 2.0 + gap, 1.0)], + ); + for region in &united { + assert_eq!(region.outer.len(), 4, "gap {gap}"); + assert!( + (signed_area2(®ion.outer) - 1.0).abs() < 1.0e-9, + "gap {gap}" + ); + } + } +} + +#[test] +fn a_hole_goes_to_the_innermost_region_that_holds_it_in_both_operand_orders() { + let frame = framed(0.0, 10.0); + let island = framed(3.0, 7.0); + let expected: Nest = vec![ + ((0.0, 10.0), vec![(1.0, 9.0)]), + ((3.0, 7.0), vec![(4.0, 6.0)]), + ]; + assert_eq!(nest(&union(&frame, &island)), expected); + assert_eq!(nest(&union(&island, &frame)), expected); + let both: Vec> = frame.iter().chain(&island).cloned().collect(); + assert_eq!(nest(&union(&both, &[])), expected); + for region in union(&frame, &island) { + assert!(region_area(®ion) > 0.0); + } +} + +#[test] +fn three_nested_rings_keep_their_own_holes_in_every_operation() { + let outer = framed(0.0, 20.0); + let middle = framed(3.0, 17.0); + let inner = framed(6.0, 14.0); + let core = rectangle(9.0, 9.0, 11.0, 11.0); + let expected: Nest = vec![ + ((0.0, 20.0), vec![(1.0, 19.0)]), + ((3.0, 17.0), vec![(4.0, 16.0)]), + ((6.0, 14.0), vec![(7.0, 13.0)]), + ((9.0, 11.0), vec![]), + ]; + let inside: Vec> = middle + .iter() + .chain(&inner) + .cloned() + .chain(std::iter::once(core.clone())) + .collect(); + assert_eq!(nest(&union(&outer, &inside)), expected); + assert_eq!(nest(&union(&inside, &outer)), expected); + let all: Vec> = outer.iter().chain(&inside).cloned().collect(); + let cover = [rectangle(0.0, 0.0, 20.0, 20.0)]; + let kept = regions(&all, &cover, PlanarBooleanOp::Intersection); + assert_eq!(nest(&kept), expected); + for region in &kept { + assert!(region_area(region) > 0.0); + } + let cut = regions(&cover, &middle, PlanarBooleanOp::Subtraction); + let expected_cut: Nest = vec![((0.0, 20.0), vec![(3.0, 17.0)]), ((4.0, 16.0), vec![])]; + assert_eq!(nest(&cut), expected_cut); +} + +#[test] +fn an_island_a_hundredth_of_a_millimetre_inside_a_hole_stays_a_region() { + let gap = 1.0e-5; + let frame = framed(0.0, 10.0); + let island = vec![ + rectangle(1.0 + gap, 1.0 + gap, 9.0 - gap, 9.0 - gap), + hole(4.0, 4.0, 6.0, 6.0), + ]; + let expected = 36.0 + (8.0 - 2.0 * gap) * (8.0 - 2.0 * gap) - 4.0; + for united in [union(&frame, &island), union(&island, &frame)] { + assert_eq!(united.len(), 2); + assert_area(&united, expected, "island near the hole boundary"); + assert!(united.iter().all(|region| region.holes.len() == 1)); + } +} + +#[test] +fn an_island_that_the_operation_clips_keeps_its_hole() { + let frame = framed(0.0, 10.0); + let island = framed(3.0, 7.0); + let nested: Vec> = frame.iter().chain(&island).cloned().collect(); + let window = [rectangle(-1.0, -1.0, 6.5, 11.0)]; + let clipped = regions(&nested, &window, PlanarBooleanOp::Intersection); + let expected: Nest = vec![((0.0, 6.5), vec![]), ((3.0, 6.5), vec![(4.0, 6.0)])]; + assert_eq!(nest(&clipped), expected); + assert_area(&clipped, 21.0 + 10.0, "the clipped nest"); + let notch = [rectangle(6.5, 6.5, 7.5, 7.5)]; + let notched = regions(&nested, ¬ch, PlanarBooleanOp::Subtraction); + let expected_notched: Nest = vec![ + ((0.0, 10.0), vec![(1.0, 9.0)]), + ((3.0, 7.0), vec![(4.0, 6.0)]), + ]; + assert_eq!(nest(¬ched), expected_notched); + assert_area(¬ched, 36.0 + 12.0 - 0.25, "the notched island"); +} diff --git a/package.json b/package.json index aa17bea..e10685b 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "dev": "vite", "test": "cargo test --manifest-path main/opengeometry/Cargo.toml && cargo test --examples --manifest-path main/opengeometry/Cargo.toml", + "test:wrapper": "node --test main/opengeometry-three/tests/*.test.mjs", "lint": "eslint main/opengeometry-three/src/**/*.ts --fix", "lint:check": "eslint main/opengeometry-three/src/**/*.ts", "clean-dist": "node ./scripts/clean-dist.mjs",