diff --git a/packages/geometry/package.json b/packages/geometry/package.json index 01eb1604..3c1f72e0 100644 --- a/packages/geometry/package.json +++ b/packages/geometry/package.json @@ -16,6 +16,7 @@ "test": "vitest run" }, "dependencies": { + "clipper2-ts": "2.0.1-18", "fflate": "0.8.3", "three": "0.187.0", "three-mesh-bvh": "0.9.17" diff --git a/packages/geometry/src/bridges.test.ts b/packages/geometry/src/bridges.test.ts index 179fc518..29df02f5 100644 --- a/packages/geometry/src/bridges.test.ts +++ b/packages/geometry/src/bridges.test.ts @@ -51,6 +51,14 @@ const lips: Record = { 'a lip with a step of 0.5 mm that looks up': [[13, 13], [12.5, 13], [12.5, 12.5], [13.75, 11.25], [15, 12.5]], 'a bead whose back looks up': [[12, 14], [13.5, 12.5], [15, 14]], }; +// The shelf's 240 mm², and the faces of the lip at 45° that begin in the air under it: the lip +// needs its own support, whatever props the shelf (the layers, review of pull request 363). +const lipFindings: Record = { + 'a drip edge': { area: 325, faces: ['F1:s2', 'F1:s4'] }, + 'a hook curled back to the wall': { area: 410, faces: ['F1:s2', 'F1:s6', 'F1:s7'] }, + 'a lip with a step of 0.5 mm that looks up': { area: 346, faces: ['F1:s2', 'F1:s6', 'F1:s7'] }, + 'a bead whose back looks up': { area: 367, faces: ['F1:s2', 'F1:s4', 'F1:s5'] }, +}; // A tunnel under a round arch of diameter `d` on legs 4 mm high. const arch = (d: number, segments = 48) => prismBody('Arch', [[0, 0], [4, 0], ...Array.from({ length: segments + 1 }, (_, i): [number, number] => [4 + d / 2 - (d / 2) * Math.cos((i * Math.PI) / segments), 4 + (d / 2) * Math.sin((i * Math.PI) / segments)]), [4 + d, 0], [8 + d, 0], [8 + d, 10 + d / 2], [0, 10 + d / 2]], 30); @@ -87,7 +95,7 @@ describe('bridges, arms and what they hang from, on closed bodies', () => { it('hangs nothing from material that itself ends in the air', () => { // The lip going down from the far edge counted as a second wall, and the shelf as bridged; with a // face that looks up on the lip, the lip counted as standing. - for (const [name, lip] of Object.entries(lips)) expect(hanging(shelf(lip)), name).toEqual({ area: 240, faces: ['F1:s2'] }); + for (const [name, lip] of Object.entries(lips)) expect(hanging(shelf(lip)), name).toEqual(lipFindings[name]); expect(hanging(prismBody('Shelf', [[0, 0], [5, 0], [5, 16], [15, 16], [15, 20], [0, 20]], 30)).area).toBe(300); // A wall that stands on the floor of a box holds a bridge as one on the bed does, also where the // floor is reached only through the material under it. @@ -137,8 +145,8 @@ describe('bridges, arms and what they hang from, on closed bodies', () => { ['T-slot', slotted(true), 162], ['counterbore', ring(true), 20], ['bridge with a hole', holed(30), 15], - ['shelf with a drip edge', shelf(lips['a drip edge'] ?? []), 240], - ['shelf with a hook', shelf(lips['a hook curled back to the wall'] ?? []), 240], + ['shelf with a drip edge', shelf(lips['a drip edge'] ?? []), 240 + 30 * 2 * Math.SQRT2], + ['shelf with a hook', shelf(lips['a hook curled back to the wall'] ?? []), 240 + 2 * 30 * 2 * Math.SQRT2], ['sealed box', sealed, 0], ['hole Ø 7', lyingTube('Tube', 10, 3.5, 10, 96), turned(lyingTube('Tube', 10, 1.5, 10, 96))], ['hole Ø 8', lyingTube('Tube', 10, 4, 10, 96), turned(lyingTube('Tube', 10, 1.5, 10, 96))], diff --git a/packages/geometry/src/layers.test.ts b/packages/geometry/src/layers.test.ts new file mode 100644 index 00000000..2457d137 --- /dev/null +++ b/packages/geometry/src/layers.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; + +import { Overhang, overhangLimits, overhangOf, smallOverhangReach } from './overhang'; +import { gridBody, meshOf, prismBody, revolved, type RawMesh } from './testShapes'; + +const check = (mesh: RawMesh) => overhangOf({ ...mesh, normals: new Float32Array(mesh.positions.length) }, 0, overhangLimits(45), smallOverhangReach(0.4)); + +// Two walls `span` apart under a roof, a post 3 × 3 on the roof, and on the post a head 1.5 out to both sides. +const headOnBridge = (span: number) => { + const middle = 2 + span / 2; + return meshOf(gridBody('Head', [0, 2, middle - 3, middle - 1.5, middle + 1.5, middle + 3, 2 + span, 4 + span], [0, 10, 11.5, 14.5, 16, 30], [0, 8, 10, 14, 16], (i, j, k) => (k === 0 ? i === 0 || i === 6 : k === 1 || (i === 3 && j === 2) || (k === 3 && i >= 2 && i <= 4 && j === 2)))); +}; + +/** A funnel of four flat sides that opens upwards over `height` mm, its faces `degrees` from the vertical, with a ledge 1.5 mm out round its top. */ +const squareFunnel = (height: number, degrees: number) => { + const top = 5 + (height * Math.tan((degrees * Math.PI) / 180)) / Math.SQRT1_2; + return revolved([[0, 0], [5, 0], [top, height], [top + 1.5, height], [top + 1.5, height + 2], [0, height + 2]], [-1, 0, -1, 0, 1], 4); +}; +/** The fastest of three runs (ms): one run alone says more about the runner than about the check. */ +const fastest = (mesh: RawMesh) => { + let ms = Infinity; + for (let run = 0; run < 3; run += 1) { + const start = performance.now(); + check(mesh); + ms = Math.min(ms, performance.now() - start); + } + return ms; +}; + +describe('what stands, cut in layers', () => { + // Rounded as the carried outline grew, every corner left an arc that was never taken out again: + // 40 triangles took 0.8 s at 5 mm and 33 s at 20 mm (review of pull request 363). + it.each([45, 50])('cuts a funnel of four flat sides at %s° four times as high in no more than twelve times the time', { timeout: 120_000 }, (degrees) => { + // Under a millisecond the clock measures itself. + expect(fastest(squareFunnel(20, degrees))).toBeLessThan(Math.max(5, fastest(squareFunnel(5, degrees))) * 12); + }); + + it('cuts only where something hangs or begins: a column 300 mm high under a lip costs no more than one of 30', { timeout: 120_000 }, () => { + const column = (height: number) => revolved([[0, 0], [10, 0], [10, height - 2], [11.5, height - 2], [11.5, height], [0, height]], [-1, 0, -1, 0, 1], 160); + expect(check(column(300)).area[Overhang.Support]).toBeLessThan(1); + expect(fastest(column(300))).toBeLessThan(Math.max(5, fastest(column(30))) * 4); + }); + + it('cuts a stair of 740 steps in no more than twelve times the time of one of 185', { timeout: 120_000 }, () => { + // Every cut goes through every triangle of the stair's flat ends: 4.9 s for 300 mm (second review of pull request 363). + const stairOf = (steps: number) => { + const outline: [number, number][] = [[0, 0], [5, 0], [5, 4]]; + for (let step = 1; step <= steps; step += 1) outline.push([5 + step * 0.5, 3.6 + step * 0.4], [5 + step * 0.5, 4 + step * 0.4]); + const mesh = meshOf(prismBody('Stair', [...outline, [5 + steps * 0.5, 6 + steps * 0.4], [0, 6 + steps * 0.4]], 20)); + const [cos, sin] = [Math.cos(Math.PI / 12), Math.sin(Math.PI / 12)]; + const positions = Float32Array.from(mesh.positions, (_, i) => (i % 3 === 0 ? cos * (mesh.positions[i] ?? 0) - sin * (mesh.positions[i + 1] ?? 0) : i % 3 === 1 ? sin * (mesh.positions[i - 1] ?? 0) + cos * (mesh.positions[i] ?? 0) : (mesh.positions[i] ?? 0))); + return { ...mesh, positions }; + }; + expect(fastest(stairOf(740))).toBeLessThan(Math.max(5, fastest(stairOf(185))) * 12); + }); + + it('takes a triangle that comes twice, and says so when a mesh with a hole is followed along its faces instead', () => { + const outline: [number, number][] = [[0, 0], [5, 0], [5, 4]]; + for (let step = 1; step <= 20; step += 1) outline.push([5 + step * 1.7, 3 + step], [5 + step * 1.7, 4 + step]); + const stair = meshOf(prismBody('Stair', [...outline, [39, 26], [0, 26]], 20)); + const whole = check(stair); + expect(whole.layered).toBe(true); + expect(whole.area[Overhang.Support]).toBeCloseTo(19 * 34, 0); + const twice = check({ ...stair, index: Uint32Array.from([...stair.index, ...stair.index.slice(0, 3)]) }); + expect(twice.layered).toBe(true); + expect(twice.area[Overhang.Support]).toBeGreaterThanOrEqual(19 * 34); + // shortcut: with a triangle missing the stair passes as it did before the layers; `layered` says so. + expect(check({ ...stair, index: stair.index.slice(3) }).layered).toBe(false); + }); + + it('lets a bridge carry what is built on it, and a reported ceiling not', () => { + expect(check(headOnBridge(8)).area[Overhang.Support]).toBeLessThan(1); + // The ceiling of 12 × 30 and the two undersides of the head, 1.5 × 3 each. + expect(check(headOnBridge(12)).area[Overhang.Support]).toBeCloseTo(360 + 9, 0); + }); + + it('cuts a closed bolt of 260 ribs, 100 000 triangles, without taking seconds', { timeout: 120_000 }, () => { + // Ribs like the turns of a thread: 0.6 out over 0.3, back over 0.7; closed on the axis at both ends. + const boltOf = (count: number) => { + const ribs = Array.from({ length: count }, (_, k): [number, number][] => [[4.6, k + 0.3], [4, k + 1]]).flat(); + return revolved([[0, 0], [4, 0], ...ribs, [0, count]], [-1, ...ribs.map(() => 0), 1]); + }; + // The fastest of three runs: one run alone says more about the runner than about the check. + const timed = (mesh: RawMesh) => { + let ms = Infinity; + let map = check(mesh); + for (let run = 0; run < 3; run += 1) { + const start = performance.now(); + map = check(mesh); + ms = Math.min(ms, performance.now() - start); + } + return { ms, map }; + }; + const bolt = boltOf(260); + const quarter = timed(boltOf(65)); + const { ms, map } = timed(bolt); + expect(bolt.index.length / 3).toBe(100_224); + expect(Math.round(map.area[Overhang.Support])).toBe(0); + expect(map.area[Overhang.Borderline]).toBeGreaterThan(4700); + // Held by its growth, not in seconds (0.9 s on the build server, 3.5 s on a busy runner, 2026-11-12): + // four times the bolt may take twelve times as long; quadratic would be sixteen. + expect(ms / quarter.ms).toBeLessThan(12); + }); +}); diff --git a/packages/geometry/src/layers.ts b/packages/geometry/src/layers.ts new file mode 100644 index 00000000..779ab342 --- /dev/null +++ b/packages/geometry/src/layers.ts @@ -0,0 +1,403 @@ +import { areaPaths, EndType, FillRule, inflatePaths, intersect, JoinType, union, type Path64, type Paths64 } from 'clipper2-ts'; + +/** + * What stands, layer by layer, the way a slicer builds the part: a body is cut every `LAYER` mm, and + * of each cut only that is carried which lies over carried material of the cut below, or reaches out + * from it no further than the material's overhang angle allows. So being carried grows upwards at + * that angle and no faster: a lip hanging from a shelf stands only where a cheek holds it, a stair + * of steps that go out faster than they go up loses its footing step by step, and what begins in + * the air is not carried whatever the angle of its faces. An overhang carries what is built on it + * only where the caller says so (`carry`); the same cuts answer more than one such reading (`track`). + * + * The answer must not depend on where the part lies against the layers: besides the regular cuts + * there is one just below and one just above every height the caller names (`events`: the top of an + * overhang, where something begins), and the reach grows with the true height between two cuts. + */ + +/** The height (mm) between two regular cuts: the usual layer. */ +export const LAYER = 0.2; +/** Clipper works on whole numbers: tenths of a micrometre. */ +const SCALE = 1e4; +/** How far (mm) beside carried material a point still counts as on it: the rim of a face lies on the outline itself. */ +const ON = 0.02; +/** Half the width (mm) under which carried material counts as a point, not as an outline to grow from. */ +const THIN = 3e-3; +/** How far (mm) below and above an event its two cuts lie. */ +const BESIDE = 1e-3; +/** Cuts per block of the index that finds the edges of a cut. */ +const BLOCK = 4; +/** How far (mm) the corners of triangles may lie off one plane to be one flat face. */ +const FLAT = 2e-4; +/** + * How far a corner of carried material may run ahead of its sides, in layers' reach: the corner of a + * slope of three flat sides runs twice as fast as its faces, of four sides 1.4 times. A sharper + * corner is cut square, so carried material never jumps a gap wider than this. + */ +const MITRE = 2.05; +/** Along the cut of a face, how far apart (mm) it is asked whether something is carried there, and how often at most. */ +const STEP = 0.25; +const STEPS = 256; + +/** Triangles on welded vertices. */ +export interface Soup { + count: number; + vertices: number; + x: (w: number) => number; + y: (w: number) => number; + z: (w: number) => number; + at: (t: number, k: number) => number; +} + +/** The outline of an overhang seen from above, to say that it carries (`Layers.shape`). */ +export type Shape = Paths64; + +/** One reading of what is carried: the angle from the bed up, and the overhangs said to carry. */ +export interface Carried { + /** How far (mm) the material just below a point of the surface is from carried material; 0 when it is carried. */ + gap: (x: number, y: number, z: number) => number; + /** + * The share of a face (three corners) that is not carried: cut by cut along the layers through + * it, the length of its cut that does not lie on carried material. + */ + hanging: (corners: readonly (readonly [number, number, number])[]) => number; + /** + * An overhang with its highest point at `z`: what is built on it is carried, and what goes on + * from it within the angle. With `out`, only once the material has risen far enough for the + * angle to reach that many mm. + */ + carry: (z: number, shape: Shape, out?: number) => void; +} + +export interface Layers { + track: () => Carried; + /** Rings as x, y per corner; what they wind round, either way, is the shape. */ + shape: (rings: readonly (readonly number[])[]) => Shape; +} + +/** The number of sorted heights below `height`. */ +function countBelow(heights: Float64Array, height: number): number { + let [low, high] = [0, heights.length]; + while (low < high) { + const middle = (low + high) >> 1; + if ((heights[middle] ?? 0) < height) low = middle + 1; + else high = middle; + } + return low; +} + +/** + * The layers of a closed body on a bed at `bedZ`; `grow` is how far (mm) carried material may reach + * out per mm of height. `rough` names the heights (from, to) where the angle alone does not say that + * a cut is carried as the one below: faces that hang over further, ridges that run out faster, and + * the lowest points of faces that look down. Everywhere else a cut is carried as a whole if the one + * below is, and is not worked out at all unless it is asked for. + * Null for a surface that is not closed: it has no outline to cut. + * shortcut: one missing triangle makes the body open, and the caller falls back to its flood along + * the surface (a triangle that comes twice does not). Close the cut outlines over small gaps if + * meshes with holes must be cut too. + */ +export function layersOf({ count, vertices, x, y, z, at }: Soup, bedZ: number, grow: number, rough: Iterable, events: Iterable): Layers | null { + // Each triangle once, with the triangles on each of its edges. + const once = new Map(); + const onEdge = new Map(); + const kept: number[] = []; + let top = bedZ; + for (let t = 0; t < count; t += 1) { + const [a, b, c] = [at(t, 0), at(t, 1), at(t, 2)]; + if (a === b || b === c || c === a) continue; + const lowest = Math.min(a, b, c); + const [second, third] = lowest === a ? [b, c] : lowest === b ? [c, a] : [a, b]; + const thirds = once.get(lowest * vertices + second); + if (thirds?.includes(third)) continue; + if (thirds) thirds.push(third); + else once.set(lowest * vertices + second, [third]); + kept.push(t); + top = Math.max(top, z(a), z(b), z(c)); + for (const [u, v] of [[a, b], [b, c], [c, a]] as const) { + const key = u < v ? u * vertices + v : v * vertices + u; + const list = onEdge.get(key); + // The triangle, and whether it walks the edge from its lower-numbered end. + if (list) list.push(t, u < v ? 1 : 0); + else onEdge.set(key, [t, u < v ? 1 : 0]); + } + } + // Closed: every edge is walked as often one way as the other. + for (const list of onEdge.values()) { + let net = 0; + for (let i = 1; i < list.length; i += 2) net += list[i] ? 1 : -1; + if (net !== 0) return null; + } + + // The cuts: the regular ones, and one beside each event on either side. What begins less than + // half a layer over the bed lies in the first layer, which stands as a whole. + const heights: number[] = []; + for (let k = 0; bedZ + (k + 0.5) * LAYER <= top + LAYER; k += 1) heights.push(bedZ + (k + 0.5) * LAYER); + for (const event of events) for (const height of [event - BESIDE, event + BESIDE]) if (height > bedZ + LAYER / 2) heights.push(height); + heights.sort((p, q) => p - q); + const planes = Float64Array.from(heights.filter((height, i) => i === 0 || height - (heights[i - 1] ?? 0) > 1e-9)); + const planeOf = (k: number) => planes[k] ?? Infinity; + /** The last cut below a height. */ + const below = (height: number) => Math.max(0, countBelow(planes, height - 1e-9) - 1); + const isRough = new Uint8Array(planes.length + 1); + for (const [low, high] of rough) { + // From the first cut at or over the lower end to the first one over the upper end. + const to = Math.min(planes.length, countBelow(planes, high + 1e-9)); + for (let k = countBelow(planes, low - 1e-9); k <= to; k += 1) isRough[k] = 1; + } + + // Flat faces: triangles in one plane, joined over their edges. A cut passes through all the + // triangles of the flat end of a long stair, but enters and leaves the face only through its + // rim; so the cut is made of the rims of the flat faces, not of every triangle. A face grows from + // its largest triangle, and takes in only what lies in that triangle's plane: the facets of a + // fine cone turn a little each and are no flat face. + const normal = new Float64Array(count * 3); + const size = new Float64Array(count); + for (const t of kept) { + const [a, b, c] = [at(t, 0), at(t, 1), at(t, 2)]; + const n = [(y(b) - y(a)) * (z(c) - z(a)) - (z(b) - z(a)) * (y(c) - y(a)), (z(b) - z(a)) * (x(c) - x(a)) - (x(b) - x(a)) * (z(c) - z(a)), (x(b) - x(a)) * (y(c) - y(a)) - (y(b) - y(a)) * (x(c) - x(a))] as const; + size[t] = Math.hypot(...n); + if ((size[t] ?? 0) > 0) normal.set([n[0] / (size[t] ?? 1), n[1] / (size[t] ?? 1), n[2] / (size[t] ?? 1)], t * 3); + } + const face = new Int32Array(count).fill(-1); + for (const seed of [...kept].sort((p, q) => (size[q] ?? 0) - (size[p] ?? 0))) { + if ((face[seed] ?? 0) >= 0) continue; + face[seed] = seed; + if (!((size[seed] ?? 0) > 0)) continue; + const [nx, ny, nz] = [normal[seed * 3] ?? 0, normal[seed * 3 + 1] ?? 0, normal[seed * 3 + 2] ?? 0]; + const offset = nx * x(at(seed, 0)) + ny * y(at(seed, 0)) + nz * z(at(seed, 0)); + const stack = [seed]; + while (stack.length > 0) { + const t = stack.pop() ?? 0; + for (let i = 0; i < 3; i += 1) { + const [u, v] = [at(t, i), at(t, (i + 1) % 3)]; + const list = onEdge.get(u < v ? u * vertices + v : v * vertices + u) ?? []; + if (list.length !== 4) continue; + const other = list[0] === t ? (list[2] ?? 0) : (list[0] ?? 0); + if ((face[other] ?? 0) >= 0 || nx * (normal[other * 3] ?? 0) + ny * (normal[other * 3 + 1] ?? 0) + nz * (normal[other * 3 + 2] ?? 0) < 0) continue; + let flat = true; + for (let j = 0; j < 3 && flat; j += 1) flat = Math.abs(nx * x(at(other, j)) + ny * y(at(other, j)) + nz * z(at(other, j)) - offset) <= FLAT; + if (!flat) continue; + face[other] = seed; + stack.push(other); + } + } + } + // The rims: per edge and face on it the lower-numbered end, the other, the face, whether the face + // walks it from the first to the second, and the first and the last cut through it. + const rims: [a: number, b: number, face: number, forward: number, from: number, to: number][] = []; + const blocks: number[][] = []; + for (const [key, list] of onEdge) { + if (list.length === 4 && face[list[0] ?? 0] === face[list[2] ?? 0]) continue; + const a = Math.floor(key / vertices); + const b = key % vertices; + // A corner on a plane counts as above it: cut by the planes over the lower end, up to the upper end. + const from = countBelow(planes, Math.min(z(a), z(b)) + 1e-12); + const to = countBelow(planes, Math.max(z(a), z(b)) + 1e-12) - 1; + if (to < from) continue; + for (let i = 0; i < list.length; i += 2) { + for (let block = Math.floor(from / BLOCK); block <= Math.floor(to / BLOCK); block += 1) (blocks[block] ??= []).push(rims.length); + rims.push([a, b, face[list[i] ?? 0] ?? 0, list[i + 1] ?? 0, from, to]); + } + } + + const outlines = new Map(); + /** The outline of the body in one cut. A corner on the plane counts as above it, so the cut is closed. */ + const outline = (k: number): Paths64 => { + const known = outlines.get(k); + if (known) return known; + const plane = planeOf(k); + const points = new Map(); + // Per face the edges where its rim dips under the plane, and where it rises through it. + const crossed = new Map(); + for (const rim of blocks[Math.floor(k / BLOCK)] ?? []) { + const [a, b, f, forward, from, to] = rims[rim] ?? [0, 0, 0, 0, 1, 0]; + if (k < from || k > to || z(a) >= plane === z(b) >= plane) continue; + const key = a * vertices + b; + if (!points.has(key)) { + const s = (plane - z(a)) / (z(b) - z(a)); + points.set(key, { x: Math.round((x(a) + s * (x(b) - x(a))) * SCALE), y: Math.round((y(a) + s * (y(b) - y(a))) * SCALE) }); + } + const sides = crossed.get(f) ?? [[], []]; + if (sides[0].length + sides[1].length === 0) crossed.set(f, sides); + // Walked as the face walks it, the edge dips when it starts above the plane. + sides[z(a) >= plane === (forward === 1) ? 0 : 1].push(key); + } + // Round a face as seen from outside, the cut runs from where the rim dips under the plane to + // where it rises through it: so the material lies to its left, however short the cut is. + const next = new Map(); + for (const [f, [dips, rises]] of crossed) { + if (dips.length > 1) { + // A face that is cut more than once: along the cut (z × normal), each piece from a dip to the next rise. + const along = (key: number) => (points.get(key)?.x ?? 0) * -(normal[f * 3 + 1] ?? 0) + (points.get(key)?.y ?? 0) * (normal[f * 3] ?? 0); + dips.sort((p, q) => along(p) - along(q)); + rises.sort((p, q) => along(p) - along(q)); + } + dips.forEach((start, i) => { + const end = rises[i]; + if (end === undefined) return; + const list = next.get(start); + if (list) list.push(end); + else next.set(start, [end]); + }); + } + const paths: Paths64 = []; + for (const [start, list] of next) { + while (list.length > 0) { + const path: Path64 = []; + let node = start; + for (let out = next.get(node); out && out.length > 0; out = next.get(node)) { + const point = points.get(node); + if (point) path.push(point); + node = out.pop() ?? start; + } + if (path.length >= 3) paths.push(path); + } + } + const cut = paths.length > 0 ? union(paths, FillRule.NonZero) : []; + outlines.set(k, cut); + return cut; + }; + + /** How far (in clipper's units, squared) a point is from the rim of an area, and whether it lies in it. */ + const place = (paths: Paths64, sx: number, sy: number, enough: number): [inside: boolean, least: number] => { + let inside = false; + let least = Infinity; + for (const path of paths) { + for (let i = 0, j = path.length - 1; i < path.length; j = i, i += 1) { + const [a, b] = [path[i], path[j]]; + if (!a || !b) continue; + const [dx, dy] = [b.x - a.x, b.y - a.y]; + const length = dx * dx + dy * dy; + const s = length === 0 ? 0 : Math.max(0, Math.min(1, ((sx - a.x) * dx + (sy - a.y) * dy) / length)); + least = Math.min(least, (sx - a.x - s * dx) ** 2 + (sy - a.y - s * dy) ** 2); + if (least <= enough) return [true, least]; + if (a.y > sy !== b.y > sy && sx < a.x + ((sy - a.y) * dx) / dy) inside = !inside; + } + } + return [inside, least]; + }; + const on = (ON * SCALE) ** 2; + + // What the readings share: a cut that grows from the same carried cut below, with the same + // overhangs carrying, is worked out once. + const worked = new Map(); + const none: readonly [Shape, number][] = []; + + const track = (): Carried => { + // Per cut: whether all of it is carried, and if not, what of it is. + const whole: boolean[] = []; + const part: Paths64[] = []; + const carriedIn = (k: number) => (whole[k] ? outline(k) : (part[k] ?? [])); + // Per cut what overhangs below it carry, with how far (mm) each has grown at the angle by then. + const added = new Map(); + const cut = (upTo: number) => { + for (let k = whole.length; k <= Math.min(upTo, planes.length - 1); k += 1) { + // On the bed everything stands; over a cut that is carried, what only leans within the angle is too. + if (k === 0 || (whole[k - 1] && !isRough[k])) { + whole.push(true); + continue; + } + const under = carriedIn(k - 1); + const more = added.get(k) ?? none; + const known = worked.get(k)?.find((entry) => entry.under === under && entry.more.length === more.length && entry.more.every(([shape, grown], i) => { const other = more[i]; return shape === other?.[0] && grown === other[1]; })); + if (known) { + whole.push(known.whole); + if (!known.whole) part[k] = known.held; + continue; + } + const body = outline(k); + // All of the cut is carried when nothing of it is left over but the rounding of the cut, and + // no piece of it is without any carried material: what begins in the air is small in its + // first cut, but it is a piece of its own, and a sliver of it is no rounding. + const all = (held: Paths64) => areaPaths(body) - areaPaths(held) <= 1 && body.every((path) => !path[0] || place(held, path[0].x, path[0].y, on)[0]); + // Mitred, as a slope of flat sides grows: its corners run ahead of its faces, and no arcs pile up cut by cut. + // What is thinner than a point (THIN on each side) carries nothing on: the first cut through + // the edge of a keel is a sliver whose ends point anywhere, and mitred they would run ahead. + const reaching = (paths: Paths64, by: number) => intersect(by * SCALE >= 1 ? inflatePaths(inflatePaths(paths, -THIN * SCALE, JoinType.Miter, EndType.Polygon, MITRE), (by + THIN) * SCALE, JoinType.Miter, EndType.Polygon, MITRE) : paths, body, FillRule.NonZero); + let held = under.length > 0 && body.length > 0 ? reaching(under, grow * (planeOf(k) - planeOf(k - 1))) : []; + // What an overhang below carries is added only where the angle has not reached already. + for (const [shape, grown] of more) { + if (body.length === 0 || all(held)) break; + held = union(held, reaching(shape, grown), FillRule.NonZero); + } + whole.push(all(held)); + if (!whole[k]) part[k] = held; + const list = worked.get(k); + if (list) list.push({ under, more, whole: whole[k] ?? false, held }); + else worked.set(k, [{ under, more, whole: whole[k] ?? false, held }]); + } + }; + return { + gap: (px, py, pz) => { + const k = below(pz); + cut(k); + // The cut lies below the point; up to the point the material may have grown further. + const near = (Math.abs(pz - planeOf(k)) * grow + ON) * SCALE; + const [inside, least] = place(carriedIn(k), px * SCALE, py * SCALE, near * near); + return inside ? 0 : (Math.sqrt(least) - near) / SCALE; + }, + hanging: (corners) => { + const [low, high] = [Math.min(...corners.map((corner) => corner[2])), Math.max(...corners.map((corner) => corner[2]))]; + const [from, to] = [countBelow(planes, low + 1e-12), countBelow(planes, high + 1e-12) - 1]; + let [all, free] = [0, 0]; + for (let k = from; k <= to; k += 1) { + const plane = planeOf(k); + // The cut of the face in this plane: between the two of its edges that pass through it. + const ends: [number, number][] = []; + for (let i = 0; i < 3; i += 1) { + const [a, b] = [corners[i], corners[(i + 1) % 3]]; + if (!a || !b || a[2] >= plane === b[2] >= plane) continue; + const s = (plane - a[2]) / (b[2] - a[2]); + ends.push([a[0] + s * (b[0] - a[0]), a[1] + s * (b[1] - a[1])]); + } + const [start, end] = ends; + if (!start || !end) continue; + // Each cut stands for the heights halfway to the cuts beside it. + const length = Math.hypot(end[0] - start[0], end[1] - start[1]) * (Math.min(high, (plane + planeOf(k + 1)) / 2) - Math.max(low, (plane + (planes[k - 1] ?? bedZ)) / 2)); + all += length; + cut(k); + const [mx, my] = [((start[0] + end[0]) / 2) * SCALE, ((start[1] + end[1]) / 2) * SCALE]; + // A cut carried as a whole carries the face, unless the face is not in it: just over where + // something begins it is thinner than the cut can hold, and that hangs. + if (whole[k] && place(outline(k), mx, my, on)[0]) continue; + const steps = Math.max(1, Math.min(STEPS, Math.ceil(Math.hypot(end[0] - start[0], end[1] - start[1]) / STEP))); + let off = 0; + for (let i = 0; i < steps; i += 1) { + const s = (i + 0.5) / steps; + if (!place(carriedIn(k), (start[0] + s * (end[0] - start[0])) * SCALE, (start[1] + s * (end[1] - start[1])) * SCALE, on)[0]) off += 1; + } + free += (length * off) / steps; + } + if (all > 0) return free / all; + // A face that lies between two cuts: asked at its middle. + const middle = (axis: number) => ((corners[0]?.[axis] ?? 0) + (corners[1]?.[axis] ?? 0) + (corners[2]?.[axis] ?? 0)) / 3; + const k = below(middle(2)); + cut(k); + const near = (Math.abs(middle(2) - planeOf(k)) * grow + ON) * SCALE; + return place(carriedIn(k), middle(0) * SCALE, middle(1) * SCALE, near * near)[0] ? 0 : 1; + }, + carry: (height, shape, out = 0) => { + // The first cut over the overhang, or over the height at which the angle has reached far enough. + const k = out > 0 ? countBelow(planes, height + out / grow) : countBelow(planes, height + 1e-9); + // A cut that is made already is not made again: patches come lowest first. + if (k < whole.length || k >= planes.length) return; + // From the overhang up to the cut the material goes on at the angle; one that carries late has only its own outline. + const entry: [Shape, number] = [shape, out > 0 ? 0 : Math.max(0, planeOf(k) - height) * grow]; + const list = added.get(k); + if (list) list.push(entry); + else added.set(k, [entry]); + }, + }; + }; + return { + track, + shape: (rings) => { + const paths: Paths64 = rings.map((ring) => { + const path: Path64 = []; + for (let i = 0; i + 1 < ring.length; i += 2) path.push({ x: Math.round((ring[i] ?? 0) * SCALE), y: Math.round((ring[i + 1] ?? 0) * SCALE) }); + return path; + }); + return union(paths, FillRule.NonZero); + }, + }; +} diff --git a/packages/geometry/src/overhang.ts b/packages/geometry/src/overhang.ts index 8af245f3..996d6632 100644 --- a/packages/geometry/src/overhang.ts +++ b/packages/geometry/src/overhang.ts @@ -6,6 +6,8 @@ * print direction from M7's orientation comes later. */ +import { LAYER, layersOf } from './layers'; + export const Overhang = { Fine: 0, Bed: 1, Borderline: 2, Support: 3 } as const; export type Overhang = (typeof Overhang)[keyof typeof Overhang]; @@ -53,6 +55,12 @@ export interface OverhangMap { area: [number, number, number, number]; /** Area in mm² per B-rep face that needs support (the print check's findings, M7). */ supportByFace: Float64Array; + /** + * Whether what stands was cut in layers. False for a surface that is not closed (and without a + * reach): there it is followed along the faces, which lets a lip held at one end and a stair of + * small steps pass. + */ + layered: boolean; } export interface OverhangInput { @@ -113,6 +121,9 @@ export const LYING_HOLE = 8; /** A face that points down this straight (−z of its unit normal) is a ceiling that can be bridged. */ const LEVEL = 0.99; +/** A face that points down at least this much (−z of its unit normal) looks down; less is a wall, and the noise of one. */ +const DOWN = 1e-3; + /** A face that looks up at least this much (z of its unit normal) has material under it. */ const UP = 0.1; /** Just inside each corner of a triangle: a point on the rim itself belongs to both sides of it. */ @@ -132,6 +143,8 @@ interface Rim { bx: number; by: number; anchor: boolean; + /** How far (mm) a small overhang may hang from this rim. */ + reach: number; ease: number; } @@ -139,7 +152,9 @@ interface Rim { * Overhang triangles that are small, so that they need no support. * * A patch (overhang triangles joined by edges) hangs from its anchors: the rims where the surface - * goes on downwards into material that stands (`standing`). A point of it holds when it lies within + * goes on downwards into material that stands. What stands is cut in layers from the bed up + * (layers.ts); a surface that is not closed has no layers and is followed along its faces instead + * (`standingVertices`). A point of it holds when it lies within * `reach` (mm, across the bed) of an anchor, the way a ledge or a thread flank hangs from its wall, * or when it is bridged: across the patch, in the one direction the slicer would lay the bridge, * the nearest rim on both sides is an anchor and the two are no more than a span apart @@ -150,12 +165,14 @@ interface Rim { * bridge, an arm, the lips beside a slot or a hole. The rest of the patch stays out, so a hole in a * long bridge reports the stubs beside it, not the bridge. A patch with no anchor starts in the air. */ -function smallPatches(positions: ArrayLike, index: ArrayLike, down: ArrayLike, isSupport: (t: number) => boolean, reach: number, arch: number, sinSupport: number, bedZ: number): Set { +function smallPatches(positions: ArrayLike, index: ArrayLike, down: ArrayLike, isSupport: (t: number) => boolean, leans: (t: number) => boolean, reach: number, arch: number, sinSupport: number, bedZ: number): { small: Set; hanging: Map; layered: boolean } { const small = new Set(); + // Per triangle that begins in the air: the share of it under which nothing is carried. + const hanging = new Map(); const count = index.length / 3; let any = false; - for (let t = 0; t < count && !any; t += 1) any = isSupport(t); - if (!any) return small; + for (let t = 0; t < count && !any; t += 1) any = isSupport(t) || leans(t); + if (!any) return { small, hanging, layered: false }; // Render buffers repeat corners per face: weld them, so edges are shared between faces. const welded = new Map(); @@ -198,14 +215,14 @@ function smallPatches(positions: ArrayLike, index: ArrayLike, do } } - const standing = standingVertices({ count, vertices, x, y, z, at, down, isSupport, edges, reach, bedZ }); - + // The patches: support triangles joined by edges, each with the edges where it ends. + const patches: { patch: number[]; ends: [number, number, number, number, number, number][]; top: number }[] = []; const seen = new Uint8Array(count); for (let start = 0; start < count; start += 1) { if (seen[start] || !isSupport(start)) continue; - // One patch: support triangles joined by edges, and the rims where it ends. const patch: number[] = []; - const rims: Rim[] = []; + const ends: [number, number, number, number, number, number][] = []; + let top = -Infinity; const queue = [start]; seen[start] = 1; while (queue.length > 0) { @@ -214,6 +231,7 @@ function smallPatches(positions: ArrayLike, index: ArrayLike, do for (let k = 0; k < 3; k += 1) { const a = at(t, k); const b = at(t, (k + 1) % 3); + top = Math.max(top, z(a)); const list = edges.get(edgeKey(a, b)) ?? []; for (let i = 0; i < list.length; i += 2) { const other = list[i] ?? 0; @@ -226,17 +244,169 @@ function smallPatches(positions: ArrayLike, index: ArrayLike, do } continue; } - // The neighbour goes on downwards from this edge to material that stands, or the edge - // lies on the bed: the patch hangs from material here. - const anchor = (z(third) < Math.min(z(a), z(b)) - WELD && standing(third)) || Math.max(z(a), z(b)) <= bedZ + BED_TOLERANCE; - rims.push({ ax: x(a), ay: y(a), bx: x(b), by: y(b), anchor, ease: anchor ? easeOf(x(at(t, (k + 2) % 3)), y(at(t, (k + 2) % 3)), x(a), y(a), x(b), y(b), down[t] ?? 0, down[other] ?? 0, sinSupport) : 0 }); + ends.push([a, b, third, t, other, at(t, (k + 2) % 3)]); } } } - if (!rims.some((rim) => rim.anchor)) continue; - for (const t of heldTriangles(patch, rims, { x, y, at, down, edges, edgeKey, reach, arch })) small.add(t); + patches.push({ patch, ends, top }); + } + // Lowest first: what an overhang carries is known before the patches above it ask. + patches.sort((a, b) => a.top - b.top); + + // What stands: cut in layers where the body is closed, else followed along the surface. + const grow = Math.min(50, sinSupport / Math.sqrt(Math.max(1e-9, 1 - sinSupport * sinSupport))) + 1e-3; + // The heights where the angle of the faces alone does not say that a layer is carried as the one + // below it: faces that hang over, the lowest points of faces that look down (there something + // begins from below), and ridges under such faces that run out faster than the angle allows (the + // edge of a rib that rises from a wall, the corner of a funnel of flat sides). + const rough: [number, number][] = []; + // The heights that get a cut of their own on either side, so that the answer does not depend on + // where they lie between two layers: the top of each overhang, which carries from there, and + // where something begins. Not every corner of an overhang: a thread has a new height at each. + const events: number[] = patches.map(({ top }) => top); + const looksDown = (t: number) => (down[t] ?? 0) > DOWN; + const lower = new Uint8Array(vertices); + for (let t = 0; t < count; t += 1) { + const heights = [z(at(t, 0)), z(at(t, 1)), z(at(t, 2))]; + if (isSupport(t)) rough.push([Math.min(...heights), Math.max(...heights)]); + if (!looksDown(t)) continue; + // Lowest among the faces that look down: the foot of a wall beside it is no way down. + for (let k = 0; k < 3; k += 1) if ((heights[k] ?? 0) > Math.min(...heights) + WELD) lower[at(t, k)] = 1; + } + for (let t = 0; t < count; t += 1) { + if (!looksDown(t)) continue; + for (let k = 0; k < 3; k += 1) { + if (lower[at(t, k)]) continue; + // What begins at a point is too small to cut at first: looked at for a whole layer. + rough.push([z(at(t, k)), z(at(t, k)) + LAYER]); + events.push(z(at(t, k))); + } } - return small; + for (const [id, list] of edges) { + const a = Math.floor(id / vertices); + const b = id % vertices; + if (Math.hypot(x(b) - x(a), y(b) - y(a)) <= grow * Math.abs(z(b) - z(a))) continue; + let facing = false; + for (let i = 0; i < list.length; i += 2) facing ||= looksDown(list[i] ?? 0); + if (!facing) continue; + // Between two faces only a ridge counts: the far corner of the one lies behind the other. + if (list.length === 4) { + const [p, q, r] = [at(list[0] ?? 0, 0), at(list[0] ?? 0, 1), at(list[0] ?? 0, 2)]; + const far = list[3] ?? 0; + const normal = [(y(q) - y(p)) * (z(r) - z(p)) - (z(q) - z(p)) * (y(r) - y(p)), (z(q) - z(p)) * (x(r) - x(p)) - (x(q) - x(p)) * (z(r) - z(p)), (x(q) - x(p)) * (y(r) - y(p)) - (y(q) - y(p)) * (x(r) - x(p))] as const; + const out = [x(far) - x(p), y(far) - y(p), z(far) - z(p)] as const; + const behind = normal[0] * out[0] + normal[1] * out[1] + normal[2] * out[2]; + if (!(behind < -1e-7 * Math.hypot(...normal) * Math.hypot(...out))) continue; + } + rough.push([Math.min(z(a), z(b)), Math.max(z(a), z(b))]); + } + const layers = layersOf({ count, vertices, x, y, z, at }, bedZ, grow, rough, events); + // Two readings of the same cuts. What stands: a bridge and a small overhang carry what is built on + // them. What is sound: a small overhang carries only once the material on it has risen as far as + // the angle needs to reach its tip. An overhang that hangs from material that stands but is not + // sound has that much less reach: a wall on a small ledge stands, a stair of ledges does not. + const stands = layers?.track(); + const sound = layers?.track(); + const standing = layers ? undefined : standingVertices({ count, vertices, x, y, z, at, down, isSupport, edges, reach, bedZ }); + /** + * The rim of a patch along one edge, from `a` to `b`: it is an anchor where the neighbour goes on + * downwards (`third`) into material that stands, or where the edge lies on the bed. In layers that + * is asked along the edge, so a rim that is held at one end only is an anchor there only. + */ + const rimsOf = (a: number, b: number, third: number, ease: () => number): Rim[] => { + const whole = (anchor: boolean): Rim[] => [{ ax: x(a), ay: y(a), bx: x(b), by: y(b), anchor, reach: anchor ? reach : 0, ease: anchor ? ease() : 0 }]; + if (Math.max(z(a), z(b)) <= bedZ + BED_TOLERANCE) return whole(true); + if (!(z(third) < Math.min(z(a), z(b)) - WELD)) return whole(false); + if (!stands || !sound) return whole(standing?.(third) ?? false); + const pieces = Math.max(1, Math.min(64, Math.ceil(Math.hypot(x(b) - x(a), y(b) - y(a), z(b) - z(a)) / (reach / 2)))); + const along = (s: number): [number, number, number] => [x(a) + s * (x(b) - x(a)), y(a) + s * (y(b) - y(a)), z(a) + s * (z(b) - z(a))]; + const rims: Rim[] = []; + let from = 0; + // What a piece can hold: nothing where nothing stands, else the reach less the way to sound material. + let last: number | undefined; + for (let i = 0; i <= pieces; i += 1) { + const point = along((i + 0.5) / pieces); + const left = i === pieces ? undefined : stands.gap(...point) > 0 ? -1 : Math.max(0, reach - sound.gap(...point)); + if (left !== undefined && last !== undefined && Math.abs(left - last) < 1e-6) continue; + if (last !== undefined) { + const [[ax, ay], [bx, by]] = [along(from / pieces), along(i / pieces)]; + rims.push({ ax, ay, bx, by, anchor: last >= 0, reach: Math.max(0, last), ease: last >= 0 ? ease() : 0 }); + } + from = i; + last = left; + } + return rims; + }; + + /** The outline of some triangles seen from above: the edges that only one of them lies on, joined to rings. */ + const shapeOf = (list: readonly number[]) => { + const among = new Set(list); + const next = new Map(); + for (const t of list) { + for (let k = 0; k < 3; k += 1) { + const [a, b] = [at(t, k), at(t, (k + 1) % 3)]; + const sharing = edges.get(edgeKey(a, b)) ?? []; + let inner = false; + for (let i = 0; i < sharing.length; i += 2) inner ||= sharing[i] !== t && among.has(sharing[i] ?? -1); + if (inner) continue; + const ends = next.get(a); + if (ends) ends.push(b); + else next.set(a, [b]); + } + } + const rings: number[][] = []; + for (const [start, ends] of next) { + while (ends.length > 0) { + const ring: number[] = []; + let w = start; + for (let out = next.get(w); out && out.length > 0; out = next.get(w)) { + ring.push(x(w), y(w)); + w = out.pop() ?? start; + } + rings.push(ring); + } + } + return layers?.shape(rings) ?? []; + }; + // What every overhang would carry if it were propped up, lowest first. + const propped: [number, ReturnType][] = []; + for (const { patch, ends, top } of patches) { + const rims = ends.flatMap(([a, b, third, t, other, far]) => rimsOf(a, b, third, () => easeOf(x(far), y(far), x(a), y(a), x(b), y(b), down[t] ?? 0, down[other] ?? 0, sinSupport))); + const { held, bridged } = rims.some((rim) => rim.anchor) ? heldTriangles(patch, rims, { x, y, at, down, edges, edgeKey, reach, arch }, layers !== null) : { held: [], bridged: [] }; + for (const t of held) small.add(t); + if (!stands || !sound) continue; + const spanning = new Set(bridged); + const hung = held.filter((t) => !spanning.has(t)); + // The same outline for every reading, so that they share the cuts they agree on. + const [bridge, ledge] = [bridged.length > 0 ? shapeOf(bridged) : undefined, hung.length > 0 ? shapeOf(hung) : undefined]; + if (bridge) { + stands.carry(top, bridge); + sound.carry(top, bridge); + propped.push([top, bridge]); + } + if (ledge) { + stands.carry(top, ledge); + sound.carry(top, ledge, reach); + propped.push([top, ledge]); + } + if (held.length < patch.length) propped.push([top, shapeOf(patch.filter((t) => !small.has(t)))]); + } + // Material that begins in the air, whatever the angle of its faces: a rib with a pointed edge + // under a ceiling, a keel, a blade. No face of it hangs over, yet the layers find nothing carried + // below it. Its faces that look down are reported, as far as nothing is carried under them even + // with every overhang of the part propped up: the flanks of a tube lying on the bed rest on the + // support its underside gets, the drip edge under a shelf needs its own. How much of a face that + // is comes from the layers through it, not from points on it. + if (layers) { + const all = layers.track(); + for (const [top, shape] of propped) all.carry(top, shape); + for (let t = 0; t < count; t += 1) { + if (!leans(t)) continue; + const free = all.hanging([0, 1, 2].map((k) => [x(at(t, k)), y(at(t, k)), z(at(t, k))] as const)); + if (free > 0) hanging.set(t, free); + } + } + return { small, hanging, layered: layers !== null }; } /** @@ -272,7 +442,7 @@ interface Surface { } /** - * Which vertices stand on something, found in one flood from the bed: upwards along the surface, + * Which vertices stand on something, for a surface that is not closed, found in one flood from the bed: upwards along the surface, * never down, and across overhang only as far as a small overhang reaches (a thread flank carries * the next turn, a shelf carries neither a lip at its tip nor a wall on it). A face that looks up stands when the * underside of the material straight below it does: the floor of a box, the back of a hook not. @@ -414,7 +584,7 @@ interface PatchSurface { } /** The triangles of one patch that hold: near an anchor, or bridged in the patch's one direction. */ -function heldTriangles(patch: readonly number[], rims: readonly Rim[], { x, y, at, down, edges, edgeKey, reach, arch }: PatchSurface): number[] { +function heldTriangles(patch: readonly number[], rims: readonly Rim[], { x, y, at, down, edges, edgeKey, reach, arch }: PatchSurface, wantBridged: boolean): { held: number[]; bridged: number[] } { // The rims in grids in plan: what a point can hang from lies in the cells around it. The anchors // in cells as wide as a small overhang reaches, all rims in cells a bridge span wide. const gridOf = (cell: number, wanted: (rim: Rim) => boolean) => { @@ -447,7 +617,7 @@ function heldTriangles(patch: readonly number[], rims: readonly Rim[], { x, y, a const dy = rim.by - rim.ay; const length = dx * dx + dy * dy; const s = length === 0 ? 0 : Math.max(0, Math.min(1, ((px - rim.ax) * dx + (py - rim.ay) * dy) / length)); - return Math.hypot(px - rim.ax - s * dx, py - rim.ay - s * dy) <= reach; + return Math.hypot(px - rim.ax - s * dx, py - rim.ay - s * dy) <= rim.reach; }); // Along a line through the point the patch ends at the nearest rim ahead and behind. A bridge has // an anchor at both; a free edge or a hole on the way makes that side an arm. @@ -538,7 +708,14 @@ function heldTriangles(patch: readonly number[], rims: readonly Rim[], { x, y, a const best = lost.indexOf(Math.min(...lost)); const direction = directions[best]; const failed = (t: number) => (direction ? (fails.get(t)?.[best] ?? false) : !(nearAll.get(t) ?? false)); - if (direction ? (lost[best] ?? 0) === 0 : lostNear === 0) return [...patch]; + // Of what holds, the triangles that reach further than a small overhang does, so that they are + // bridged; a triangle that lies near an anchor all over counts as a small overhang, also where + // it is part of a narrow bridge. + const result = (held: number[]) => ({ + held, + bridged: wantBridged && direction ? held.filter((t) => !(nearAll.get(t) ?? false)) : [], + }); + if (direction ? (lost[best] ?? 0) === 0 : lostNear === 0) return result([...patch]); // What fails, and the arm it hangs on: the triangles next to it that lie near an anchor but are // no part of a bridge themselves. @@ -559,7 +736,7 @@ function heldTriangles(patch: readonly number[], rims: readonly Rim[], { x, y, a } } } - return patch.filter((t) => !hanging.has(t)); + return result(patch.filter((t) => !hanging.has(t))); } /** @@ -608,11 +785,15 @@ export function overhangOf(mesh: OverhangInput, bedZ: number, limits: OverhangLi } // The roof of a lying hole overhangs between the two lines where its wall passes the limit. const sinSupport = Math.sin(((limits.support + ANGLE_TOLERANCE) * Math.PI) / 180); - const small = reach > 0 ? smallPatches(positions, index, down, (t) => triangles[t]?.kind === Overhang.Support, reach, LYING_HOLE * sinSupport, sinSupport, bedZ) : new Set(); + const leans = (t: number) => (down[t] ?? 0) > DOWN && (triangles[t]?.kind === Overhang.Fine || triangles[t]?.kind === Overhang.Borderline); + const { small, hanging, layered } = reach > 0 ? smallPatches(positions, index, down, (t) => triangles[t]?.kind === Overhang.Support, leans, reach, LYING_HOLE * sinSupport, sinSupport, bedZ) : { small: new Set(), hanging: new Map(), layered: false }; triangles.forEach(({ kind, face, area: size }, t) => { + // A face that begins in the air needs support under the share of it that nothing carries. + const free = (hanging.get(t) ?? 0) * size; const counted = small.has(t) ? Overhang.Borderline : kind; - area[counted] += size; - if (counted === Overhang.Support) supportByFace[face] = (supportByFace[face] ?? 0) + size; + area[counted] += size - free; + area[Overhang.Support] += free; + supportByFace[face] = (supportByFace[face] ?? 0) + free + (counted === Overhang.Support ? size - free : 0); }); - return { vertices, area, supportByFace }; + return { vertices, area, supportByFace, layered }; } diff --git a/packages/geometry/src/overhangCases.test.ts b/packages/geometry/src/overhangCases.test.ts index ab21be19..cc3f44fe 100644 --- a/packages/geometry/src/overhangCases.test.ts +++ b/packages/geometry/src/overhangCases.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { Overhang, overhangLimits, overhangOf, smallOverhangReach } from './overhang'; -import { circle, gridBody, lyingTube, meshOf, prismBody, revolved, ringSurface, soup, type RawMesh } from './testShapes'; +import { circle, ears, gridBody, lathe, lyingTube, meshOf, prismBody, revolved, ringSurface, soup, type RawMesh } from './testShapes'; /** * Every case of the three reviews of the print check (pull request 353), each with what it should @@ -91,9 +91,9 @@ const apron = (length: number, cheeks: ('front' | 'back')[], hangs = true) => { return meshOf(gridBody('Apron', [0, 2, 10, 12], ys, [0, 4, 8, 10], (i, j, k) => k === 2 || i === 0 || cheek(j) || (hangs && i === 2 && k === 1))); }; /** Steps going out and up: `out` mm out, then `up` mm up along a riser that leans `lean` mm further out. */ -const stair = (out: number, up: number, steps: number, lean = 0) => { - const outline: [number, number][] = [[0, 0], [5, 0], [5, 4]]; - let [x, z] = [5, 4]; +const stair = (out: number, up: number, steps: number, lean = 0, base = 4) => { + const outline: [number, number][] = [[0, 0], [5, 0], [5, base]]; + let [x, z] = [5, base]; for (let s = 0; s < steps; s += 1) { x += out; outline.push([x, z]); @@ -144,6 +144,125 @@ const engraved = (deep: number, wide: number) => meshOf(gridBody('Engraved', [0, const snap = (nose: number) => meshOf(prismBody('Snap', [[0, 0], [20, 0], [20, 2], [12, 2], [12, 10], [12 + nose, 10], [12 + nose, 11], [12, 11 + nose], [12, 14], [10, 14], [10, 2], [0, 2]], 8)); const tube = (hole: number, segments = 96) => meshOf(lyingTube('Tube', 10, hole / 2, 10, segments)); +/** + * A deck `wide` and 2 thick on a foot at each end (or at one), with a keel `deep` under it, its edge `edge` over the + * bed, `length` between the feet: each flank two triangles. A V trough or fin when it is deep. + */ +const keelOf = (length: number, deep: number, wide: number, edge: number, feet: 1 | 2 = 2) => { + const top = edge + deep; + const foot: [number, number][] = [[0, 0], [wide, 0], [wide, top], [wide, top + 2], [0, top + 2], [0, top]]; + const keel: [number, number][] = [[wide / 2, edge], [wide, top], [wide, top + 2], [0, top + 2], [0, top]]; + const footCap = [at(foot, 0, 1, 2), at(foot, 0, 2, 3), at(foot, 0, 3, 4), at(foot, 0, 4, 5)]; + const beside: [number, number][][] = [[[0, 0], [wide, 0], [wide / 2, edge]], [[0, 0], [wide / 2, edge], [0, top]], [[wide, 0], [wide, top], [wide / 2, edge]]]; + const end = 2 + length; + const far = feet === 2 ? [...cap(beside, end, false), ...sides(foot, end, end + 2), ...cap(footCap, end + 2, true)] : cap([at(keel, 0, 1, 2), at(keel, 0, 2, 3), at(keel, 0, 3, 4)], end, true); + return soup([...cap(footCap, 0, false), ...sides(foot, 0, 2), ...cap(beside, 2, true), ...sides(keel, 2, end), ...far]); +}; +/** + * What of a keel's flanks hangs (mm²): each foot carries a wedge of them that widens at the angle (45° and the 1° of + * tolerance), and the rest begins in the air. The drawn keel 4 × 5 between feet 30 apart: 5/6 of its flanks. + */ +const keelHanging = (length: number, deep: number, wide: number, feet: 1 | 2 = 2) => { + const grow = Math.tan((46 * Math.PI) / 180); + // Of the flank's height, the part the wedges have not met yet, times the length they leave. + const met = Math.min(deep, length / (feet * grow)); + return 2 * (length * met - (feet * grow * met * met) / 2) * (Math.hypot(deep, wide / 2) / deep); +}; +const keel = keelOf(30, 5, 4, 1); +// A box in profile, 30 long: walls 1 mm thick that step 1.8 mm outwards 10 mm up on a floor 0.4 mm thick, under a roof or with a lip 1.5 mm out. +const steppedBox = meshOf(gridBody('Stepped', [0, 1, 1.8, 2.8, 8.8, 9.8, 10.6, 11.6], [0, 30], [0, 10, 10.4, 14, 15], (i, _j, k) => (k === 0 ? i === 2 || i === 4 : k === 1 ? i <= 2 || i >= 4 : k === 2 ? i === 0 || i === 6 : true))); +const steppedWall = meshOf(gridBody('Stepped', [-1.5, 0, 1, 1.8, 2.8], [0, 30], [0, 10, 10.4, 14, 15], (i, _j, k) => (k === 0 ? i === 3 : k === 1 ? i >= 1 : k === 2 ? i === 1 : i <= 1))); +// A portal of 8 mm with a V rib along under its roof (4.4 wide, 4 deep), and what else hangs on the same body. +const ribbed = (more: [number, number][] = [], strip: [number, number][] = []) => meshOf(prismBody('Rib', [[0, 0], [2, 0], [2, 8], ...strip, [3.8, 8], [6, 4], [8.2, 8], [10, 8], [10, 0], [12, 0], ...more, [0, 10]], 30)); +const RIB_FLANKS = 2 * 30 * Math.hypot(2.2, 4); +// A blade hanging along under a bridge of 8 mm, 20 long: `wide` at its root, `deep`, its edge 4 mm over the bed. +const blade = (wide: number, deep: number) => meshOf(prismBody('Blade', [[0, 0], [2, 0], [2, 4 + deep], [6 - wide / 2, 4 + deep], [6, 4], [6 + wide / 2, 4 + deep], [10, 4 + deep], [10, 0], [12, 0], [12, 6 + deep], [0, 6 + deep]], 20)); +// A plate 4.4 wide on an end wall, with a V rib under it whose edge rises 5° from the wall, beginning `low` mm over the bed. +const RISE = 30 * Math.tan((5 * Math.PI) / 180); +const risingRib = (low: number) => { + const wall: [number, number][] = [[0, 0], [4.4, 0], [4.4, 10], [4.4, 12], [0, 12], [0, 10]]; + const rib = (tip: number): [number, number][] => [[2.2, tip], [4.4, 10], [4.4, 12], [0, 12], [0, 10]]; + const [near, far] = [rib(low), rib(low + RISE)]; + const loft: Point[][] = near.flatMap(([x, z], i) => { + const j = (i + 1) % near.length; + const [a, b, c, d]: Point[] = [[x, 2, z], [near[j]?.[0] ?? 0, 2, near[j]?.[1] ?? 0], [far[j]?.[0] ?? 0, 32, far[j]?.[1] ?? 0], [far[i]?.[0] ?? 0, 32, far[i]?.[1] ?? 0]]; + return [[a ?? [0, 0, 0], c ?? [0, 0, 0], b ?? [0, 0, 0]], [a ?? [0, 0, 0], d ?? [0, 0, 0], c ?? [0, 0, 0]]]; + }); + const beside: [number, number][][] = [[[0, 0], [4.4, 0], [2.2, low]], [[0, 0], [2.2, low], [0, 10]], [[4.4, 0], [4.4, 10], [2.2, low]]]; + return soup([...cap([at(wall, 0, 1, 2), at(wall, 0, 2, 3), at(wall, 0, 3, 4), at(wall, 0, 4, 5)], 0, false), ...sides(wall, 0, 2), ...cap(beside, 2, true), ...loft, ...cap([at(far, 0, 1, 2), at(far, 0, 2, 3), at(far, 0, 3, 4)], 32, true)]); +}; +/** The two flanks of that rib (mm²). */ +const risingFlanks = (low: number) => { + const size = (a: Point, b: Point, c: Point) => Math.hypot((b[1] - a[1]) * (c[2] - a[2]) - (b[2] - a[2]) * (c[1] - a[1]), (b[2] - a[2]) * (c[0] - a[0]) - (b[0] - a[0]) * (c[2] - a[2]), (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])) / 2; + return 2 * (size([2.2, 2, low], [0, 2, 10], [0, 32, 10]) + size([2.2, 2, low], [0, 32, 10], [2.2, 32, low + RISE])); +}; +// A wall with a ledge `out` mm wide 5 mm up, and from the tip of the ledge a slope at 45° over 5 mm. +const slopeOnLedge = (out: number) => meshOf(prismBody('Slope', [[0, 0], [5, 0], [5, 5], [5 + out, 5], [10 + out, 10], [10 + out, 12], [0, 12]], 30)); + +// A column on the bed with a corner of 32° in plan, and a wedge floating in the air, its edge 0.6 mm from that corner. +const WEDGE: [number, number][] = [[10.6, 3], [13, 6], [10.6, 6]]; +const besideCorner = (() => { + const plan: [number, number][] = [[10, 0], [-5, 4.3], [-5, -4.3]]; + const lift = (i: number, z: number): Point => [plan[i % 3]?.[0] ?? 0, plan[i % 3]?.[1] ?? 0, z]; + const column: Point[][] = [[lift(0, 0), lift(2, 0), lift(1, 0)], [lift(0, 12), lift(1, 12), lift(2, 12)], ...[0, 1, 2].flatMap((i) => [[lift(i, 0), lift(i + 1, 0), lift(i + 1, 12)], [lift(i, 0), lift(i + 1, 12), lift(i, 12)]])]; + return soup([...column, ...cap([WEDGE], -5, false), ...sides(WEDGE, -5, 5), ...cap([WEDGE], 5, true)]); +})(); +/** A funnel whose outline is a star of five points, each layer 9 % wider than the one a millimetre below. */ +const starFunnel = (() => { + const star = (scale: number, z: number): Point[] => Array.from({ length: 10 }, (_, i) => [(i % 2 === 0 ? 10 : 4) * scale * Math.cos((i * Math.PI) / 5), (i % 2 === 0 ? 10 : 4) * scale * Math.sin((i * Math.PI) / 5), z]); + return ringSurface([star(0, 0), star(1, 0), star(1.45, 5), star(0, 5)], [-1, 0, 1]); +})(); + +/** A funnel of `n` flat sides at 45° that opens upwards over 5 mm, with a ledge 1.5 mm out round its top. */ +const funnel = (n: number) => { + const top = 5 + 5 / Math.cos(Math.PI / n); + return revolved([[0, 0], [5, 0], [top, 5], [top + 1.5, 5], [top + 1.5, 7], [0, 7]], [-1, 0, -1, 0, 1], n); +}; + +/** A stalactite or cone `deep` hanging from the ceiling of a round pocket of radius `pocket`, its tip `tip` over the bed. */ +const hangingCone = (radius: number, deep: number, tip: number, pocket: number, n = 48) => lathe([[0, tip], [radius, tip + deep], [pocket, tip + deep], [pocket, 0], [pocket + 2, 0], [pocket + 2, tip + deep + 2], [0, tip + deep + 2]], n); +/** A block 20 wide, 10 high and 30 long, its bottom edges rounded R `r` in `n` facets each, on a foot `foot` high as wide as its flat. */ +const rounded = (r: number, n: number, foot = 0) => + meshOf(prismBody('Rounded', [...(foot > 0 ? [[r, 0], [20 - r, 0]] as [number, number][] : []), ...arc(20 - r, foot + r, r, -90, 0, n), [20, foot + 10], [0, foot + 10], ...arc(r, foot + r, r, 180, 270, n)], 30)); +/** + * The knuckle of a lying hinge as mechanisms.ts builds it (web()), in (x, z) counter-clockwise: a barrel of radius `r` + * on the line `line` over the bed, its own wall `distance` from the line, and the web at 45° from where the barrel + * leans 45° on the far side down to that wall. + */ +const knuckleOutline = (r: number, distance: number, line: number): [number, number][] => { + const barrel = arc(0, line, r, -45, 180, 48); + return [[-distance - 10, 0], [-distance, 0], [-distance, line - r * Math.SQRT2 - distance], ...barrel, [-distance - 10, line]]; +}; +/** + * The fixed body of that hinge: knuckles 6.48 long, each on its web and a piece of wall, and between them the pin of + * radius r/2, cut flat underneath where it leans 45° (pinFlat()), a bridge 6.48 from knuckle to knuckle. + */ +const hingeRow = (r: number, distance: number, line: number, knuckles = 3, long = 6.48) => { + const knuckle = knuckleOutline(r, distance, line); + // 35 facets, so that none lies level with the top of the wall. + const pin = arc(0, line, r / 2, -45, 225, 35); + const face = (polygon: [number, number][], y: number, facing: 1 | -1): Point[][] => + ears(polygon).map(([a, b, c]) => { + const corner = (i: number): Point => [polygon[i]?.[0] ?? 0, y, polygon[i]?.[1] ?? 0]; + return facing < 0 ? [corner(a), corner(b), corner(c)] : [corner(a), corner(c), corner(b)]; + }); + // The end of a knuckle round the pin, in two pieces split from the flat of the pin to the web. + const corner = (ring: [number, number][], i: number): [number, number] => ring[i] ?? [0, 0]; + const [p0, pe, k2, k3] = [corner(pin, 0), corner(pin, pin.length - 1), corner(knuckle, 2), corner(knuckle, 3)]; + const around: [number, number][][] = [[k2, k3, p0, pe], [...knuckle.slice(3), ...knuckle.slice(0, 3), ...[...pin].reverse()]]; + const triangles: Point[][] = face(knuckle, 0, -1); + let y = 0; + for (let k = 0; k < knuckles; k += 1) { + triangles.push(...sides(knuckle, y, y + long)); + y += long; + if (k + 1 === knuckles) break; + triangles.push(...around.flatMap((piece) => face(piece, y, 1)), ...sides(pin, y, y + long)); + y += long; + triangles.push(...around.flatMap((piece) => face(piece, y, -1))); + } + return soup([...triangles, ...face(knuckle, y, 1)]); +}; + // ---- the cases -------------------------------------------------------------------------------------------------- const cases: [string, () => RawMesh, Want][] = [ @@ -180,15 +299,18 @@ const cases: [string, () => RawMesh, Want][] = [ ['bridge 8 with a hole 3 × 3, 100 long', () => holed(100), [10, 60]], // Nothing hangs from material that ends in the air (first, second and third review). ['shelf 10', () => meshOf(prismBody('Shelf', [[0, 0], [5, 0], [5, 16], [15, 16], [15, 20], [0, 20]], 30)), 300], - ['shelf 8 with a drip edge', () => shelf([[13, 14], [15, 16]]), 240], - ['shelf 8 with a hook curled back to the wall', () => shelf([[13, 13], [11, 13], [11, 12.5], [13, 10.5], [15, 12.5]]), 240], - ['shelf 8 with a lip with a step of 0.5 that looks up', () => shelf([[13, 13], [12.5, 13], [12.5, 12.5], [13.75, 11.25], [15, 12.5]]), 240], - ['shelf 8 with a bead whose back looks up', () => shelf([[12, 14], [13.5, 12.5], [15, 14]]), 240], + // The lips begin in the air: besides the shelf's 240 mm² their faces at 45° are reported, 30 mm long each + // (review of pull request 363; before the layers only faces over the limit could be). + ['shelf 8 with a drip edge', () => shelf([[13, 14], [15, 16]]), 240 + 30 * 2 * Math.SQRT2], + ['shelf 8 with a hook curled back to the wall', () => shelf([[13, 13], [11, 13], [11, 12.5], [13, 10.5], [15, 12.5]]), 240 + 2 * 30 * 2 * Math.SQRT2], + ['shelf 8 with a lip with a step of 0.5 that looks up', () => shelf([[13, 13], [12.5, 13], [12.5, 12.5], [13.75, 11.25], [15, 12.5]]), 240 + 2 * 30 * 1.25 * Math.SQRT2], + ['shelf 8 with a bead whose back looks up', () => shelf([[12, 14], [13.5, 12.5], [15, 14]]), 240 + 2 * 30 * 1.5 * Math.SQRT2], ['shelf 8 with an apron 6 down, 2 wide', () => shelf([[13, 10], [15, 10]]), 300], ['shelf 8 onto a second wall down to the bed', () => meshOf(prismBody('Portal', [[0, 0], [5, 0], [5, 16], [13, 16], [13, 0], [15, 0], [15, 20], [0, 20]], 30)), 'free'], - ['shelf 8 with a pointed drip edge, as triangles', () => dripOnly(30), 240], - ['the same with a cheek at one end, 30 long: the lip stands only at the cheek', () => dripCheek(30), [200, 245]], - ['the same, 100 long', () => dripCheek(100), [750, 805]], + ['shelf 8 with a pointed drip edge, as triangles', () => dripOnly(30), 240 + 30 * 2 * Math.SQRT2], + // The ranges of the review, plus the lip's face (2.83 mm² per mm of length). + ['the same with a cheek at one end, 30 long: the lip stands only at the cheek', () => dripCheek(30), [280, 330]], + ['the same, 100 long', () => dripCheek(100), [1030, 1090]], ['bridge 8 onto an apron hanging from the roof', () => apron(30, []), 300], ['the same with a cheek at the back', () => apron(30, ['back']), [270, 305]], ['the same with a cheek at the front', () => apron(30, ['front']), [270, 305]], @@ -276,6 +398,44 @@ const cases: [string, () => RawMesh, Want][] = [ ['snap hook, nose 1.5', () => snap(1.5), 'free'], ['snap hook, nose 2', () => snap(2), 16], ['snap hook, nose 2.5', () => snap(2.5), 20], + // Material that begins in the air on faces that only lean (review of pull request 363): no face hangs over, yet nothing is below. + ['a V rib along under a bridge of 8: 4.4 wide, 4 deep, its edge free', () => ribbed([[12, 10]]), RIB_FLANKS], + // What else is reported on the body takes nothing from the rib (second review of pull request 363). + ['the same with a ridge 0.8 wide and 1 deep hanging beside the rib', () => ribbed([[12, 10]], [[2.5, 8], [2.5, 7], [3.3, 7], [3.3, 8]]), RIB_FLANKS + 0.8 * 30 + 0.5 * 30], + ['the same rib on a body with a shelf 8 out', () => ribbed([[12, 6], [20, 6], [20, 10]]), RIB_FLANKS + 240], + // A blade with flanks nearly upright begins in the air all the same, and holds no ceiling beside it. + ['a blade 1 wide and 20 deep hanging along under a bridge of 8', () => blade(1, 20), 7 * 20 + 2 * 20 * Math.hypot(0.5, 20)], + ['a blade 0.6 wide and 10 deep', () => blade(0.6, 10), 7.4 * 20 + 2 * 20 * Math.hypot(0.3, 10)], + // The ceiling beside it and its bottom; its sides rest on the support the bottom gets. + ['a rib 1 wide with a flat bottom and sides drafted 2°, hanging along under a bridge of 8', () => meshOf(prismBody('Peg', [[0, 0], [2, 0], [2, 8], [5.36, 8], [5.5, 4], [6.5, 4], [6.64, 8], [10, 8], [10, 0], [12, 0], [12, 10], [0, 10]], 30)), 2 * 3.36 * 30 + 30], + // The edge of a rib that rises 5° from a wall: reported wherever it begins against the layers, from the bed too. + // The wall carries a wedge of each flank that widens at the angle, 9 mm at the root: about a seventh of it. + ...[0, 1, 1.08, 1.12, 1.2, 1.28].map((low): [string, () => RawMesh, Want] => [`a V rib whose edge rises 5° from an end wall, beginning ${String(low)} over the bed`, () => risingRib(low), [0.75 * risingFlanks(low), 0.92 * risingFlanks(low)]]), + // The corner of carried material runs ahead of its sides, as a slope of flat sides does, but jumps no gap. + ['a wedge floating 0.6 mm from the sharp corner of a column', () => besideCorner, 10 * Math.hypot(2.4, 3)], + ['a funnel in the outline of a star, its points running out at 42°', () => starFunnel, 'free'], + // A small ledge carries the slope that goes on from its tip. + ...[0.3, 0.5, 1, 1.5].map((out): [string, () => RawMesh, Want] => [`a slope at 45° from the tip of a ledge ${String(out)} out`, () => slopeOnLedge(out), 'free']), + ['a countersunk head: shank R 3, shoulder 0.5, cone at 45° to R 8.5', () => revolved([[0, 0], [3, 0], [3, 10], [3.5, 10], [8.5, 15], [8.5, 16], [0, 16]], [-1, 0, -1, 0, 0, 1]), 'free'], + ['the same rib with a flat bottom', () => meshOf(prismBody('Rib', [[0, 0], [2, 0], [2, 8], [3.8, 8], [3.8, 4], [8.2, 4], [8.2, 8], [10, 8], [10, 0], [12, 0], [12, 10], [0, 10]], 30)), 132], + ['a V rib under a bridge of 10: 6.4 wide, 6 deep', () => meshOf(prismBody('Rib', [[0, 0], [2, 0], [2, 8], [3.8, 8], [7, 2], [10.2, 8], [12, 8], [12, 0], [14, 0], [14, 10], [0, 10]], 30)), 2 * 30 * Math.hypot(3.2, 6)], + ['a keel 4 wide and 5 deep between two feet, its edge 1 mm over the bed', () => keel, keelHanging(30, 5, 4)], + ['a blade 1.5 wide hanging 10 deep, 0.3 beside a wall, pointed at 45°', () => meshOf(prismBody('Blade', [[0, 0], [2, 0], [2, 18], [2.3, 18], [2.3, 8.75], [3.05, 8], [3.8, 8.75], [3.8, 20], [0, 20]], 30)), 2 * 30 * 0.75 * Math.SQRT2], + ['a wedge standing on its edge on the bed', () => meshOf(prismBody('Wedge', [[2, 0], [4, 4], [0, 4]], 30)), 'free'], + // A small overhang carries what stands on it (the same review). + ['walls 1 mm thick that step 1.8 out on a floor of 0.4, under a roof of 9.6', () => steppedBox, 'free'], + ['a wall that steps 1.8 out on a floor of 0.4, with a lip 1.5 out at its top', () => steppedWall, 'free'], + // A slope of flat sides: its corners run out faster than its faces (the same review). + ...[6, 8, 96].map((n): [string, () => RawMesh, Want] => [`ledge 1.5 over a funnel of ${String(n)} sides at 45°`, () => funnel(n), 'free']), + // A V trough under a plate between two cheeks, its edge 4 over the bed: the flanks hang but for the wedges the + // cheeks carry, however large their two triangles each (fourth review of pull request 363: points on the + // triangles missed a strip along the edge, and all of it from 30 × 70 on). + ...([[30, 30, 40], [30, 50, 40], [30, 70, 40], [14, 26, 20], [14, 28, 20], [12, 24, 20], [20, 45, 30]] as const).map(([length, deep, wide]): [string, () => RawMesh, Want] => [`a V trough ${String(length)} long, ${String(deep)} deep and ${String(wide)} wide between two cheeks`, () => keelOf(length, deep, wide, 4), keelHanging(length, deep, wide)]), + ...([[8, 60], [15, 100], [8, 30]] as const).map(([length, deep]): [string, () => RawMesh, Want] => [`a V fin ${String(length)} long and ${String(deep)} deep on one cheek`, () => keelOf(length, deep, 4, 4, 1), keelHanging(length, deep, 4, 1)]), + // Bottom edges rounded on the bed: the part of the round past the angle is a small overhang, and the round on it stands. + ...([[1, 8], [1, 16], [1.5, 8], [1.5, 16], [2, 8], [2, 16], [2, 32], [2.5, 16]] as const).map(([r, n]): [string, () => RawMesh, Want] => [`a block with its bottom edges rounded R ${String(r)} in ${String(n)} facets`, () => rounded(r, n), 'free']), + // The fixed body of the hinge of a case (contract test G3): knuckles over the bed on webs at 45°, the pin flat underneath. + ...([3, 4] as const).map((r): [string, () => RawMesh, Want] => [`the knuckles of a hinge R ${String(r)} on webs at 45°, with the pin between them`, () => hingeRow(r, 2.5, 16), 'free']), ]; /** A hole lying in a tube: what the tube reports beyond its outside alone. */ @@ -288,20 +448,11 @@ const holes: [number, number, 'free' | 'reported'][] = [ /** * The cases the check still gets wrong (third review, measured on the check as committed): they - * run as expected failures, so that the one that comes right says so. The first ten are overhangs - * that pass as printable, the rest printable shapes that are reported. + * run as expected failures, so that the one that comes right says so. All are printable shapes that + * are reported; the overhangs that passed as printable (a lip or an apron held by a cheek only, the + * stairs) come right since what stands is cut in layers (layers.ts). */ const OPEN = new Set([ - 'the same with a cheek at one end, 30 long: the lip stands only at the cheek', - 'the same, 100 long', - 'the same with a cheek at the back', - 'an apron 60 long between two cheeks', - 'stair 1.7 per 1 (60°)', - 'stair 1.8 per 1 (61°)', - 'stair 1.7 per 0.2 (83°)', - 'stair 1.7 per 0.05 (88°), 40 steps', - 'stair 1 per 0.2 (79°), 40 steps', - 'stair 1.7 out, then a riser leaning 44°', 'L channel 8 wide', 'cross of two channels 8 wide', 'T of two channels 8 wide', @@ -333,6 +484,50 @@ describe('overhangs that hold and overhangs that do not: the cases of the review }); }); + // Where a part lies against the layers must not change the answer (fourth review of pull request 363): each of these + // is moved up in steps of 0.01 mm over a whole layer. + const lanes = Array.from({ length: 21 }, (_, i) => i / 100); + const sameIn = (build: (lift: number) => RawMesh, want: Want, lift = lanes) => { + const found = lift.map((step) => area(build(step))); + lift.forEach((step, i) => { + expect(meets(found[i] ?? 0, want), `${step.toFixed(3)} higher: ${(found[i] ?? 0).toFixed(1)} mm², should be ${JSON.stringify(want)}`).toBe(true); + }); + return found; + }; + // A point that begins in the air is a piece of its own in its first layer, however small: it does not stand on the + // tolerance of an area. Each hangs from the ceiling of a round pocket; with it the ceiling beside it is reported. + it.each([ + ['a stalactite R 1, 8 long, in a pocket of 8', 1, 8, 4, 48], + ['a cone R 2, 4 long', 2, 4, 4, 48], + ['a cone R 2, 2 long (45°)', 2, 2, 4, 48], + ['a cone R 1, 4 long, of 24 sides', 1, 4, 4, 24], + ['a stalactite R 0.5, 8 long, in a pocket of 4', 0.5, 8, 2, 48], + ] as const)('%s is reported wherever its tip lies against the layers', (_name, radius, deep, pocket, n) => { + const found = sameIn((lift) => hangingCone(radius, deep, 2 + lift, pocket, n), 'reported', [...lanes, ...lanes.map((lane) => lane + 0.005)]); + expect(Math.max(...found) - Math.min(...found)).toBeLessThanOrEqual(Math.max(0.5, Math.min(...found) * 0.03)); + }); + // A stair whose steps go out a little faster than the angle: the same answer however high its base. + it.each([ + [0.3, 0.25, 40, 'reported'], + [0.3, 0.25, 12, 'free'], + [1, 0.9, 8, 'free'], + [1, 0.9, 20, 'reported'], + ] as const)('a stair %s out per %s up, %s steps, gives the same however high it starts', (out, up, steps, want) => { + const found = sameIn((lift) => stair(out, up, steps, 0, 4 + lift), want); + expect(Math.max(...found) - Math.min(...found)).toBeLessThanOrEqual(Math.max(0.5, Math.min(...found) * 0.03)); + }); + it('a block rounded R 2 at its bottom edges, on a foot of any height, needs no support', () => { + sameIn((lift) => rounded(2, 16, 0.5 + lift * 3), 'free'); + }); + it.each([3, 4])('the knuckles of a hinge R %s on webs at 45° need no support, whatever the height of the line', (r) => { + sameIn((lift) => hingeRow(r, 2.5, 16 + lift), 'free'); + sameIn((lift) => meshOf(prismBody('Knuckle', knuckleOutline(r, 2.5, 16 + lift), 30)), 'free'); + }); + it('a very finely faceted cone on the bed and a column with a collar 1.5 out need no support', () => { + expect(area(revolved([[0, 0], [10, 0], [0, 10]], [-1, 0], 6000))).toBeLessThan(1); + expect(area(revolved([[0, 0], [5, 0], [5, 10], [6.5, 10], [6.5, 12], [0, 12]], [-1, 0, -1, 0, 1], 6000))).toBeLessThan(1); + }); + it.each(holes)('hole of %s mm lying in a tube, %s segments: %s', (diameter, segments, want) => { const outside = area(tube(3, segments)); for (const degrees of TURNS) { diff --git a/packages/geometry/src/testShapes.ts b/packages/geometry/src/testShapes.ts index f273d766..5cf276a1 100644 --- a/packages/geometry/src/testShapes.ts +++ b/packages/geometry/src/testShapes.ts @@ -135,7 +135,7 @@ export function prismBody(name: string, outline: readonly (readonly [number, num } /** The triangles of a simple polygon given counter-clockwise, by clipping ears: the outline need not be convex. */ -function ears(outline: readonly (readonly [number, number])[]): [number, number, number][] { +export function ears(outline: readonly (readonly [number, number])[]): [number, number, number][] { const turn = (o: number, a: number, b: number) => { const [ox, oz] = outline[o] ?? [0, 0]; const [ax, az] = outline[a] ?? [0, 0]; @@ -269,3 +269,20 @@ export function ringSurface(rings: readonly (readonly Point[])[], facing: readon export function revolved(profile: readonly (readonly [number, number])[], facing: readonly number[], n = 96): RawMesh { return ringSurface(profile.map(([radius, z]) => circle(radius, z, n)), facing); } + +/** + * A body of revolution round Z from a closed profile of [radius, z], counter-clockwise with the radius to the right: + * unlike `revolved` its faces may look towards the axis, as the wall of a pocket does. Corners on the axis close it. + */ +export function lathe(profile: readonly (readonly [number, number])[], n = 48): RawMesh { + const triangles: Point[][] = []; + const at = ([r, z]: readonly [number, number], k: number): Point => (r === 0 ? [0, 0, z] : [r * Math.cos((k * 2 * Math.PI) / n), r * Math.sin((k * 2 * Math.PI) / n), z]); + profile.forEach((p, i) => { + const q = profile[(i + 1) % profile.length] ?? p; + for (let k = 0; k < n; k += 1) { + if (p[0] !== 0) triangles.push([at(p, k), at(p, k + 1), at(q, k + 1)]); + if (q[0] !== 0) triangles.push([at(p, k), at(q, k + 1), at(q, k)]); + } + }); + return soup(triangles); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 075cc746..58f95680 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -346,6 +346,9 @@ importers: packages/geometry: dependencies: + clipper2-ts: + specifier: 2.0.1-18 + version: 2.0.1-18 fflate: specifier: 0.8.3 version: 0.8.3