From e753914ea0ad0417bfd688289996e04f981dc27c Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:08:39 +0100 Subject: [PATCH 1/5] fix(geometry): cut what stands in layers, so lips, aprons and stairs are reported The print check decided on the mesh which rims of an overhang hang from material that stands: a flood over vertices from the bed. A lip or an apron held by a cheek at one end stood all along, and a stair of small steps stood step on step, so ten overhangs of the review cases passed as printable. Closed bodies are now cut every 0.2 mm (layers.ts, clipper2-ts): of each cut only that is carried which lies over carried material below or reaches out no further than the overhang angle allows. A rim is asked along its length and split where the answer changes. Bridged triangles carry what is built on them; small and reported overhangs carry no more than the angle gives. Bridges, spans and the lying-hole rule stay on the faces as they were. Surfaces that are not closed have no outline to cut and keep the flood. Co-Authored-By: Claude Fable 5.2 --- packages/geometry/package.json | 1 + packages/geometry/src/layers.test.ts | 38 +++++ packages/geometry/src/layers.ts | 169 ++++++++++++++++++++ packages/geometry/src/overhang.ts | 73 +++++++-- packages/geometry/src/overhangCases.test.ts | 15 +- pnpm-lock.yaml | 3 + 6 files changed, 274 insertions(+), 25 deletions(-) create mode 100644 packages/geometry/src/layers.test.ts create mode 100644 packages/geometry/src/layers.ts 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/layers.test.ts b/packages/geometry/src/layers.test.ts new file mode 100644 index 00000000..24bf2060 --- /dev/null +++ b/packages/geometry/src/layers.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { Overhang, overhangLimits, overhangOf, smallOverhangReach } from './overhang'; +import { gridBody, meshOf, 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)))); +}; + +describe('what stands, cut in layers', () => { + 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 ribs = Array.from({ length: 260 }, (_, k): [number, number][] => [[4.6, k + 0.3], [4, k + 1]]).flat(); + const bolt = revolved([[0, 0], [4, 0], ...ribs, [0, 260]], [-1, ...ribs.map(() => 0), 1]); + let ms = Infinity; + let map = check(bolt); + for (let run = 0; run < 2; run += 1) { + const start = performance.now(); + map = check(bolt); + ms = Math.min(ms, performance.now() - start); + } + expect(bolt.index.length / 3).toBe(100_224); + expect(Math.round(map.area[Overhang.Support])).toBe(0); + expect(map.area[Overhang.Borderline]).toBeGreaterThan(4700); + // Measured 0.9 s on the build server (2026-11-12); the limit leaves room for a busy runner. + expect(ms).toBeLessThan(3000); + }); +}); diff --git a/packages/geometry/src/layers.ts b/packages/geometry/src/layers.ts new file mode 100644 index 00000000..a8255326 --- /dev/null +++ b/packages/geometry/src/layers.ts @@ -0,0 +1,169 @@ +import { 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. An overhang that is + * bridged carries what is built on it (`bridge`); one that is small, or reported, carries no more + * than the angle gives. + */ + +/** The height (mm) of one cut: 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; + +/** 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; +} + +export interface Layers { + /** Whether the material just below a point of the surface is carried. */ + held: (x: number, y: number, z: number) => boolean; + /** Triangles of a bridge with their highest point at `z`, as x, y per corner: what is built on them is carried. */ + bridge: (z: number, corners: readonly number[]) => void; +} + +/** + * 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. Null for a surface that is not closed: it has no outline to cut. + * Layers are cut as they are asked for, from the bed up. + */ +export function layersOf({ count, vertices, x, y, z, at }: Soup, bedZ: number, grow: number): Layers | null { + // Closed: every edge is walked as often one way as the other. + const net = new Map(); + for (let t = 0; t < count; t += 1) { + for (let k = 0; k < 3; k += 1) { + const a = at(t, k); + const b = at(t, (k + 1) % 3); + if (a === b) continue; + const key = a < b ? a * vertices + b : b * vertices + a; + net.set(key, (net.get(key) ?? 0) + (a < b ? 1 : -1)); + } + } + for (const value of net.values()) if (value !== 0) return null; + + const planeOf = (k: number) => bedZ + (k + 0.5) * LAYER; + /** The last cut below a height. */ + const below = (height: number) => Math.max(0, Math.floor((height - bedZ - 1e-3) / LAYER - 0.5)); + // Each triangle with the first and the last cut through it, in the order the cuts reach them. + const spans: { t: number; from: number; to: number }[] = []; + for (let t = 0; t < count; t += 1) { + const [za, zb, zc] = [z(at(t, 0)), z(at(t, 1)), z(at(t, 2))]; + const from = Math.floor((Math.min(za, zb, zc) - bedZ) / LAYER - 0.5) + 1; + const to = Math.floor((Math.max(za, zb, zc) - bedZ) / LAYER - 0.5); + if (to >= Math.max(0, from)) spans.push({ t, from: Math.max(0, from), to }); + } + spans.sort((a, b) => a.from - b.from); + let reached = 0; + let active: { t: number; from: number; to: number }[] = []; + + /** 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 plane = planeOf(k); + while (reached < spans.length && (spans[reached]?.from ?? Infinity) <= k) { + const span = spans[reached]; + if (span) active.push(span); + reached += 1; + } + active = active.filter((span) => span.to >= k); + const points = new Map(); + const next = new Map(); + for (const { t } of active) { + // Round the triangle as seen from outside, the cut runs from the edge that dips under the + // plane to the edge that rises through it: so the material lies to its left, however short it is. + let start = -1; + let end = -1; + for (let i = 0; i < 3; i += 1) { + const [u, v] = [at(t, i), at(t, (i + 1) % 3)]; + if (z(u) >= plane === z(v) >= plane) continue; + const [a, b] = u < v ? [u, v] : [v, u]; + 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) }); + } + if (z(u) >= plane) start = key; + else end = key; + } + if (start < 0 || end < 0) continue; + 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); + } + } + return paths.length > 0 ? union(paths, FillRule.NonZero) : []; + }; + + const carried: Paths64[] = []; + const bridges = new Map(); + const cut = (upTo: number) => { + while (carried.length <= upTo) { + const k = carried.length; + const body = outline(k); + const under = carried[k - 1]; + let held = !under ? body : under.length > 0 && body.length > 0 ? intersect(inflatePaths(under, grow * LAYER * SCALE, JoinType.Round, EndType.Polygon, 2, ON * SCALE), body, FillRule.NonZero) : []; + const bridged = bridges.get(k); + if (bridged && body.length > 0) held = union(held, intersect(bridged, body, FillRule.NonZero), FillRule.NonZero); + carried.push(held); + } + }; + + return { + held: (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 [sx, sy] = [px * SCALE, py * SCALE]; + let inside = false; + for (const path of carried[k] ?? []) { + 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)); + if (Math.hypot(sx - a.x - s * dx, sy - a.y - s * dy) <= near) return true; + if (a.y > sy !== b.y > sy && sx < a.x + ((sy - a.y) * dx) / dy) inside = !inside; + } + } + return inside; + }, + bridge: (height, corners) => { + const k = below(height) + 1; + // A cut that is made already is not made again: patches come lowest first. + if (k < carried.length) return; + const paths = bridges.get(k) ?? []; + for (let i = 0; i + 5 < corners.length; i += 6) { + const path: Path64 = [0, 2, 4].map((c) => ({ x: Math.round((corners[i + c] ?? 0) * SCALE), y: Math.round((corners[i + c + 1] ?? 0) * SCALE) })); + const [a, b, c] = path; + if (a && b && c && (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) < 0) path.reverse(); + paths.push(path); + } + bridges.set(k, paths); + }, + }; +} diff --git a/packages/geometry/src/overhang.ts b/packages/geometry/src/overhang.ts index 8af245f3..c5f949f9 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 { layersOf } from './layers'; + export const Overhang = { Fine: 0, Bed: 1, Borderline: 2, Support: 3 } as const; export type Overhang = (typeof Overhang)[keyof typeof Overhang]; @@ -139,7 +141,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 @@ -198,14 +202,45 @@ function smallPatches(positions: ArrayLike, index: ArrayLike, do } } - const standing = standingVertices({ count, vertices, x, y, z, at, down, isSupport, edges, reach, bedZ }); + // What stands: cut in layers where the body is closed, else followed along the surface. + const layers = layersOf({ count, vertices, x, y, z, at }, bedZ, Math.min(50, sinSupport / Math.sqrt(Math.max(1e-9, 1 - sinSupport * sinSupport))) + 1e-3); + 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, 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 (!layers) 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; + let last: boolean | undefined; + for (let i = 0; i <= pieces; i += 1) { + const anchor = i < pieces ? layers.held(...along((i + 0.5) / pieces)) : undefined; + if (anchor === last) continue; + if (last !== undefined) { + const [[ax, ay], [bx, by]] = [along(from / pieces), along(i / pieces)]; + rims.push({ ax, ay, bx, by, anchor: last, ease: last ? ease() : 0 }); + } + from = i; + last = anchor; + } + return rims; + }; + // 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 +249,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,15 +262,20 @@ 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)]); } } } + patches.push({ patch, ends, top }); + } + // Lowest first: what a bridge carries is known before the patches above it ask. + if (layers) patches.sort((a, b) => a.top - b.top); + 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))); 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); + const { held, bridged } = heldTriangles(patch, rims, { x, y, at, down, edges, edgeKey, reach, arch }, layers !== null); + for (const t of held) small.add(t); + if (layers && bridged.length > 0) layers.bridge(top, bridged.flatMap((t) => [0, 1, 2].flatMap((k) => [x(at(t, k)), y(at(t, k))]))); } return small; } @@ -272,7 +313,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 +455,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) => { @@ -538,7 +579,13 @@ 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 are bridged all over: a triangle that only lies near an anchor + // is a small overhang, and carries nothing. + const result = (held: number[]) => ({ + held, + bridged: wantBridged && direction ? held.filter((t) => !(nearAll.get(t) ?? false) || sampled(t).points.every(([px, py]) => bridged(px, py, direction[0], direction[1], sampled(t).span))) : [], + }); + 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 +606,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))); } /** diff --git a/packages/geometry/src/overhangCases.test.ts b/packages/geometry/src/overhangCases.test.ts index ab21be19..c84b89f8 100644 --- a/packages/geometry/src/overhangCases.test.ts +++ b/packages/geometry/src/overhangCases.test.ts @@ -288,20 +288,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', 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 From 56d75ed5c599bf468faec8e13bd500ee91ed5f80 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:21:35 +0100 Subject: [PATCH 2/5] test(geometry): the run time of the layer cut is held by its growth, not in seconds Co-Authored-By: Claude Fable 5.2 --- packages/geometry/src/layers.test.ts | 32 ++++++++++++++++++---------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/geometry/src/layers.test.ts b/packages/geometry/src/layers.test.ts index 24bf2060..f4461281 100644 --- a/packages/geometry/src/layers.test.ts +++ b/packages/geometry/src/layers.test.ts @@ -20,19 +20,29 @@ describe('what stands, cut in layers', () => { 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 ribs = Array.from({ length: 260 }, (_, k): [number, number][] => [[4.6, k + 0.3], [4, k + 1]]).flat(); - const bolt = revolved([[0, 0], [4, 0], ...ribs, [0, 260]], [-1, ...ribs.map(() => 0), 1]); - let ms = Infinity; - let map = check(bolt); - for (let run = 0; run < 2; run += 1) { - const start = performance.now(); - map = check(bolt); - ms = Math.min(ms, performance.now() - start); - } + 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); - // Measured 0.9 s on the build server (2026-11-12); the limit leaves room for a busy runner. - expect(ms).toBeLessThan(3000); + // 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); }); }); From 42c4de179c1e8ba5e6657c7d074db5fd9bc142ca Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:46:37 +0100 Subject: [PATCH 3/5] fix(geometry): report material that begins in the air, and cut layers only where needed Review of pull request 363. - The carried outline grew rounded, and every corner left an arc that was never taken out: a funnel of four flat sides took 33 s at 20 mm. It now grows mitred, as a slope of flat sides does, so a ledge over such a funnel hangs from its corners too. - A cut is worked out only where a face hangs over, a leaning face begins or an overhang below carries; elsewhere it is carried as a whole if the cut below is. A column 300 mm high costs what one of 30 mm does. - Material that begins in the air on faces that only lean (a rib with a pointed edge under a bridge, a keel, a blade) is reported: its leaning faces, as far as the layers below them are not carried. What hangs together with an overhang that is reported already is not counted twice. - A small overhang carries what stands on it once the material has risen as far as the angle needs to reach its tip: a wall on a small ledge stands, a stair of ledges still does not. - A triangle that comes twice no longer makes the body open. A mesh with a hole still falls back to the flood along the faces; the new field `layered` of the overhang map says which way was taken. Co-Authored-By: Claude Fable 5.2 --- packages/geometry/src/layers.test.ts | 45 +++++- packages/geometry/src/layers.ts | 143 ++++++++++++-------- packages/geometry/src/overhang.ts | 100 ++++++++++++-- packages/geometry/src/overhangCases.test.ts | 27 ++++ 4 files changed, 252 insertions(+), 63 deletions(-) diff --git a/packages/geometry/src/layers.test.ts b/packages/geometry/src/layers.test.ts index f4461281..ba5760e3 100644 --- a/packages/geometry/src/layers.test.ts +++ b/packages/geometry/src/layers.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { Overhang, overhangLimits, overhangOf, smallOverhangReach } from './overhang'; -import { gridBody, meshOf, revolved, type RawMesh } from './testShapes'; +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)); @@ -11,7 +11,50 @@ const headOnBridge = (span: number) => { 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('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. diff --git a/packages/geometry/src/layers.ts b/packages/geometry/src/layers.ts index a8255326..ffb4dc45 100644 --- a/packages/geometry/src/layers.ts +++ b/packages/geometry/src/layers.ts @@ -1,4 +1,4 @@ -import { EndType, FillRule, inflatePaths, intersect, JoinType, union, type Path64, type Paths64 } from 'clipper2-ts'; +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 @@ -6,8 +6,8 @@ import { EndType, FillRule, inflatePaths, intersect, JoinType, union, type Path6 * 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. An overhang that is - * bridged carries what is built on it (`bridge`); one that is small, or reported, carries no more - * than the angle gives. + * bridged carries what is built on it at once, a small one as soon as the material on it has risen + * as far as the angle would have needed to reach it (`carry`); one that is reported carries nothing. */ /** The height (mm) of one cut: the usual layer. */ @@ -16,6 +16,8 @@ export const LAYER = 0.2; 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; +/** Cuts per block of the index that finds the triangles of a cut. */ +const BLOCK = 4; /** Triangles on welded vertices. */ export interface Soup { @@ -30,56 +32,74 @@ export interface Soup { export interface Layers { /** Whether the material just below a point of the surface is carried. */ held: (x: number, y: number, z: number) => boolean; - /** Triangles of a bridge with their highest point at `z`, as x, y per corner: what is built on them is carried. */ - bridge: (z: number, corners: readonly number[]) => void; + /** + * Triangles of an overhang that holds, with their highest point at `z`, as x, y per corner: what + * is built on them is carried, once it has risen far enough for the angle to reach `out` mm. + */ + carry: (z: number, corners: readonly number[], out?: number) => void; } /** * 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. Null for a surface that is not closed: it has no outline to cut. - * Layers are cut as they are asked for, from the bed up. + * out per mm of height. `steep` names the triangles that hang over further than that, `lows` the + * heights where the surface has a lowest point that looks 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): Layers | null { - // Closed: every edge is walked as often one way as the other. +export function layersOf({ count, vertices, x, y, z, at }: Soup, bedZ: number, grow: number, steep: (t: number) => boolean, lows: readonly number[]): Layers | null { + // Each triangle once, and closed: every edge is walked as often one way as the other. + const once = new Map(); const net = new Map(); - for (let t = 0; t < count; t += 1) { - for (let k = 0; k < 3; k += 1) { - const a = at(t, k); - const b = at(t, (k + 1) % 3); - if (a === b) continue; - const key = a < b ? a * vertices + b : b * vertices + a; - net.set(key, (net.get(key) ?? 0) + (a < b ? 1 : -1)); - } - } - for (const value of net.values()) if (value !== 0) return null; - const planeOf = (k: number) => bedZ + (k + 0.5) * LAYER; /** The last cut below a height. */ const below = (height: number) => Math.max(0, Math.floor((height - bedZ - 1e-3) / LAYER - 0.5)); - // Each triangle with the first and the last cut through it, in the order the cuts reach them. - const spans: { t: number; from: number; to: number }[] = []; + // Each triangle with the first and the last cut through it, found by blocks of cuts. + const spans: [t: number, from: number, to: number][] = []; + const blocks: number[][] = []; + // The cuts with something below them that the angle alone does not carry. + const rough: boolean[] = []; + const roughen = (low: number, high: number) => { + const to = Math.ceil((high - bedZ + 1e-6) / LAYER - 0.5); + for (let k = Math.max(0, Math.ceil((low - bedZ - 1e-6) / LAYER - 0.5)); k <= to; k += 1) rough[k] = true; + }; for (let t = 0; t < count; t += 1) { - const [za, zb, zc] = [z(at(t, 0)), z(at(t, 1)), z(at(t, 2))]; - const from = Math.floor((Math.min(za, zb, zc) - bedZ) / LAYER - 0.5) + 1; - const to = Math.floor((Math.max(za, zb, zc) - bedZ) / LAYER - 0.5); - if (to >= Math.max(0, from)) spans.push({ t, from: Math.max(0, from), to }); + 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]); + for (const [u, v] of [[a, b], [b, c], [c, a]] as const) { + const key = u < v ? u * vertices + v : v * vertices + u; + net.set(key, (net.get(key) ?? 0) + (u < v ? 1 : -1)); + } + const [low, high] = [Math.min(z(a), z(b), z(c)), Math.max(z(a), z(b), z(c))]; + if (steep(t)) roughen(low, high); + const from = Math.max(0, Math.floor((low - bedZ) / LAYER - 0.5) + 1); + const to = Math.floor((high - bedZ) / LAYER - 0.5); + if (to < from) continue; + for (let block = Math.floor(from / BLOCK); block <= Math.floor(to / BLOCK); block += 1) (blocks[block] ??= []).push(spans.length); + spans.push([t, from, to]); } - spans.sort((a, b) => a.from - b.from); - let reached = 0; - let active: { t: number; from: number; to: number }[] = []; + for (const value of net.values()) if (value !== 0) return null; + for (const low of lows) roughen(low, low); + 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); - while (reached < spans.length && (spans[reached]?.from ?? Infinity) <= k) { - const span = spans[reached]; - if (span) active.push(span); - reached += 1; - } - active = active.filter((span) => span.to >= k); const points = new Map(); const next = new Map(); - for (const { t } of active) { + for (const span of blocks[Math.floor(k / BLOCK)] ?? []) { + const [t, from, to] = spans[span] ?? [0, 1, 0]; + if (k < from || k > to) continue; // Round the triangle as seen from outside, the cut runs from the edge that dips under the // plane to the edge that rises through it: so the material lies to its left, however short it is. let start = -1; @@ -114,20 +134,37 @@ export function layersOf({ count, vertices, x, y, z, at }: Soup, bedZ: number, g if (path.length >= 3) paths.push(path); } } - return paths.length > 0 ? union(paths, FillRule.NonZero) : []; + const cut = paths.length > 0 ? union(paths, FillRule.NonZero) : []; + outlines.set(k, cut); + return cut; }; - const carried: Paths64[] = []; - const bridges = new Map(); + // 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] ?? [])); + const added = new Map(); const cut = (upTo: number) => { - while (carried.length <= upTo) { - const k = carried.length; + for (let k = whole.length; k <= upTo; 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] && !rough[k])) { + whole.push(true); + continue; + } const body = outline(k); - const under = carried[k - 1]; - let held = !under ? body : under.length > 0 && body.length > 0 ? intersect(inflatePaths(under, grow * LAYER * SCALE, JoinType.Round, EndType.Polygon, 2, ON * SCALE), body, FillRule.NonZero) : []; - const bridged = bridges.get(k); - if (bridged && body.length > 0) held = union(held, intersect(bridged, body, FillRule.NonZero), FillRule.NonZero); - carried.push(held); + const under = carriedIn(k - 1); + const all = (held: Paths64) => areaPaths(body) - areaPaths(held) <= 1e-4 * SCALE * SCALE; + // Mitred, as a slope grows: its corners run ahead of its faces, and no arcs pile up cut by cut. + let held = under.length > 0 && body.length > 0 ? intersect(inflatePaths(under, grow * LAYER * SCALE, JoinType.Miter, EndType.Polygon, 4), body, FillRule.NonZero) : []; + // What an overhang below carries is added only where the angle has not reached already. + const more = added.get(k); + if (more && body.length > 0 && !all(held)) held = union(held, intersect(more, body, FillRule.NonZero), FillRule.NonZero); + if (all(held)) { + whole.push(true); + continue; + } + whole.push(false); + part[k] = held; } }; @@ -139,31 +176,31 @@ export function layersOf({ count, vertices, x, y, z, at }: Soup, bedZ: number, g const near = (Math.abs(pz - planeOf(k)) * grow + ON) * SCALE; const [sx, sy] = [px * SCALE, py * SCALE]; let inside = false; - for (const path of carried[k] ?? []) { + for (const path of carriedIn(k)) { 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)); - if (Math.hypot(sx - a.x - s * dx, sy - a.y - s * dy) <= near) return true; + if ((sx - a.x - s * dx) ** 2 + (sy - a.y - s * dy) ** 2 <= near * near) return true; if (a.y > sy !== b.y > sy && sx < a.x + ((sy - a.y) * dx) / dy) inside = !inside; } } return inside; }, - bridge: (height, corners) => { - const k = below(height) + 1; + carry: (height, corners, out = 0) => { + const k = below(height) + 1 + Math.ceil(out / (grow * LAYER)); // A cut that is made already is not made again: patches come lowest first. - if (k < carried.length) return; - const paths = bridges.get(k) ?? []; + if (k < whole.length) return; + const paths = added.get(k) ?? []; for (let i = 0; i + 5 < corners.length; i += 6) { const path: Path64 = [0, 2, 4].map((c) => ({ x: Math.round((corners[i + c] ?? 0) * SCALE), y: Math.round((corners[i + c + 1] ?? 0) * SCALE) })); const [a, b, c] = path; if (a && b && c && (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) < 0) path.reverse(); paths.push(path); } - bridges.set(k, paths); + added.set(k, paths); }, }; } diff --git a/packages/geometry/src/overhang.ts b/packages/geometry/src/overhang.ts index c5f949f9..f703608a 100644 --- a/packages/geometry/src/overhang.ts +++ b/packages/geometry/src/overhang.ts @@ -55,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 { @@ -115,6 +121,8 @@ 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) leans: nothing can begin on it in the air. */ +const LEAN = 0.05; /** 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. */ @@ -154,12 +162,13 @@ 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: Set; layered: boolean } { const small = new Set(); + const hanging = new Set(); 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(); @@ -203,7 +212,27 @@ function smallPatches(positions: ArrayLike, index: ArrayLike, do } // What stands: cut in layers where the body is closed, else followed along the surface. - const layers = layersOf({ count, vertices, x, y, z, at }, bedZ, Math.min(50, sinSupport / Math.sqrt(Math.max(1e-9, 1 - sinSupport * sinSupport))) + 1e-3); + // Where the surface begins from below without hanging over: the lowest points of faces that lean. + const lower = new Uint8Array(vertices); + // Lowest among the faces that look down: the foot of a wall beside it is no way down. + for (let t = 0; t < count; t += 1) { + if (!((down[t] ?? 0) > LEAN)) continue; + const least = Math.min(z(at(t, 0)), z(at(t, 1)), z(at(t, 2))); + for (let k = 0; k < 3; k += 1) if (z(at(t, k)) > least + WELD) lower[at(t, k)] = 1; + } + const begins = new Map(); + for (let t = 0; t < count; t += 1) { + if (!leans(t)) continue; + for (let k = 0; k < 3; k += 1) { + const w = at(t, k); + if (lower[w] || z(w) <= bedZ + BED_TOLERANCE) continue; + const list = begins.get(w); + if (list) list.push(t); + else begins.set(w, [t]); + } + } + const grow = Math.min(50, sinSupport / Math.sqrt(Math.max(1e-9, 1 - sinSupport * sinSupport))) + 1e-3; + const layers = layersOf({ count, vertices, x, y, z, at }, bedZ, grow, isSupport, [...begins.keys()].map(z)); 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 @@ -275,9 +304,61 @@ function smallPatches(positions: ArrayLike, index: ArrayLike, do if (!rims.some((rim) => rim.anchor)) continue; const { held, bridged } = heldTriangles(patch, rims, { x, y, at, down, edges, edgeKey, reach, arch }, layers !== null); for (const t of held) small.add(t); - if (layers && bridged.length > 0) layers.bridge(top, bridged.flatMap((t) => [0, 1, 2].flatMap((k) => [x(at(t, k)), y(at(t, k))]))); + if (!layers) continue; + // A bridge carries at once; a small overhang once the material on it has risen as far as the + // angle needs to reach its tip, so a wall on a small ledge stands and a stair of ledges does not. + const corners = (list: readonly number[]) => list.flatMap((t) => [0, 1, 2].flatMap((k) => [x(at(t, k)), y(at(t, k))])); + const spanning = new Set(bridged); + if (bridged.length > 0) layers.carry(top, corners(bridged)); + if (held.length > bridged.length) layers.carry(top, corners(held.filter((t) => !spanning.has(t))), reach); + } + // Material that begins in the air on faces that only lean: a rib with a pointed edge under a + // ceiling, a keel. No face of it hangs over, yet nothing is below its first layer. From each such + // beginning the leaning faces are followed as far as the layers below them are not carried. + // What hangs together with an overhang that is reported already (over undersides and walls, no + // face that looks up between them) is not reported a second time: the drip edge of a shelf. + if (layers) { + const group = new Int32Array(count).fill(-1); + const reported: boolean[] = []; + const groupOf = (start: number) => { + if ((group[start] ?? 0) >= 0) return group[start] ?? 0; + const id = reported.push(false) - 1; + const stack = [start]; + group[start] = id; + while (stack.length > 0) { + const t = stack.pop() ?? 0; + if (isSupport(t) && !small.has(t)) reported[id] = true; + for (let k = 0; k < 3; k += 1) { + const list = edges.get(edgeKey(at(t, k), at(t, (k + 1) % 3))) ?? []; + for (let i = 0; i < list.length; i += 2) { + const other = list[i] ?? 0; + if ((group[other] ?? 0) >= 0 || (down[other] ?? 0) < -LEAN) continue; + group[other] = id; + stack.push(other); + } + } + } + return id; + }; + const middle = (t: number, axis: (w: number) => number) => (axis(at(t, 0)) + axis(at(t, 1)) + axis(at(t, 2))) / 3; + const tried = new Set(); + // Asked per face, not at the lowest point: the edge of a keel is held at the feet it ends in. + for (const around of begins.values()) { + const stack = [...around]; + while (stack.length > 0) { + const t = stack.pop() ?? 0; + if (tried.has(t)) continue; + tried.add(t); + if (layers.held(middle(t, x), middle(t, y), middle(t, z)) || reported[groupOf(t)]) continue; + hanging.add(t); + for (let k = 0; k < 3; k += 1) { + const list = edges.get(edgeKey(at(t, k), at(t, (k + 1) % 3))) ?? []; + for (let i = 0; i < list.length; i += 2) if (leans(list[i] ?? 0)) stack.push(list[i] ?? 0); + } + } + } } - return small; + return { small, hanging, layered: layers !== null }; } /** @@ -655,11 +736,12 @@ 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) > LEAN && (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 Set(), layered: false }; triangles.forEach(({ kind, face, area: size }, t) => { - const counted = small.has(t) ? Overhang.Borderline : kind; + const counted = hanging.has(t) ? Overhang.Support : small.has(t) ? Overhang.Borderline : kind; area[counted] += size; if (counted === Overhang.Support) supportByFace[face] = (supportByFace[face] ?? 0) + size; }); - 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 c84b89f8..333607e7 100644 --- a/packages/geometry/src/overhangCases.test.ts +++ b/packages/geometry/src/overhangCases.test.ts @@ -144,6 +144,21 @@ 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 4 wide and 2 thick on a foot at each end, with a keel 5 deep under it whose edge ends 1 mm over the bed. +const FOOT: [number, number][] = [[0, 0], [4, 0], [4, 6], [4, 8], [0, 8], [0, 6]]; +const KEEL: [number, number][] = [[2, 1], [4, 6], [4, 8], [0, 8], [0, 6]]; +const FOOT_CAP = [at(FOOT, 0, 1, 2), at(FOOT, 0, 2, 3), at(FOOT, 0, 3, 4), at(FOOT, 0, 4, 5)]; +const BESIDE_KEEL: [number, number][][] = [[[0, 0], [4, 0], [2, 1]], [[0, 0], [2, 1], [0, 6]], [[4, 0], [4, 6], [2, 1]]]; +const keel = soup([...cap(FOOT_CAP, 0, false), ...sides(FOOT, 0, 2), ...cap(BESIDE_KEEL, 2, true), ...sides(KEEL, 2, 32), ...cap(BESIDE_KEEL, 32, false), ...sides(FOOT, 32, 34), ...cap(FOOT_CAP, 34, true)]); +// 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 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); +}; + // ---- the cases -------------------------------------------------------------------------------------------------- const cases: [string, () => RawMesh, Want][] = [ @@ -276,6 +291,18 @@ 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', () => meshOf(prismBody('Rib', [[0, 0], [2, 0], [2, 8], [3.8, 8], [6, 4], [8.2, 8], [10, 8], [10, 0], [12, 0], [12, 10], [0, 10]], 30)), 'reported'], + ['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)), 'reported'], + ['a keel 4 wide and 5 deep between two feet, its edge 1 mm over the bed', () => keel, 'reported'], + ['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)), 'reported'], + ['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 hole lying in a tube: what the tube reports beyond its outside alone. */ From 3167f2d504d0c227ae9de95389367a5bd18bc99f Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:17:12 +0100 Subject: [PATCH 4/5] fix(geometry): take what begins in the air from the layers alone, and cut along face rims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review of pull request 363. - What begins in the air no longer depends on the angle of its faces: the heights where the angle alone does not carry a layer now include every lowest point of a face that looks down and every ridge that runs out faster than the angle. A blade with flanks at 1.4° holds no ceiling, and a rib whose edge rises from a wall is found wherever it lies against the layers. - Faces that look down are reported for the share under which nothing is carried even with every overhang of the part propped up. This replaces the rule that left out whatever hung together with a reported overhang: a tiny reported patch could swallow a large find. The lips under a shelf are now counted beside the shelf; seven expected values rise by the area of those faces. - Two readings of the cuts: on a small overhang material stands at once, but is sound only once it has risen as far as the angle needs. An overhang hanging from material that is not sound has that much less reach. A slope from the tip of a small ledge is free, a stair of ledges is still reported. - The cut is made of the rims of flat faces, not of every triangle: a stair of 740 steps took 4.5 s and takes 0.09 s. - The corner of carried material runs ahead of its sides by 2.05 layers' reach at most (was 4), enough for a slope of three flat sides. Co-Authored-By: Claude Fable 5.2 --- packages/geometry/src/bridges.test.ts | 14 +- packages/geometry/src/layers.test.ts | 13 + packages/geometry/src/layers.ts | 306 ++++++++++++-------- packages/geometry/src/overhang.ts | 172 +++++------ packages/geometry/src/overhangCases.test.ts | 83 +++++- 5 files changed, 375 insertions(+), 213 deletions(-) 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 index ba5760e3..2457d137 100644 --- a/packages/geometry/src/layers.test.ts +++ b/packages/geometry/src/layers.test.ts @@ -41,6 +41,19 @@ describe('what stands, cut in layers', () => { 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]); diff --git a/packages/geometry/src/layers.ts b/packages/geometry/src/layers.ts index ffb4dc45..a75cd695 100644 --- a/packages/geometry/src/layers.ts +++ b/packages/geometry/src/layers.ts @@ -5,9 +5,9 @@ import { areaPaths, EndType, FillRule, inflatePaths, intersect, JoinType, union, * 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. An overhang that is - * bridged carries what is built on it at once, a small one as soon as the material on it has risen - * as far as the angle would have needed to reach it (`carry`); one that is reported carries nothing. + * 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 height (mm) of one cut: the usual layer. */ @@ -16,8 +16,14 @@ export const LAYER = 0.2; 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; -/** Cuts per block of the index that finds the triangles of a cut. */ +/** Cuts per block of the index that finds the edges of a cut. */ const BLOCK = 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; /** Triangles on welded vertices. */ export interface Soup { @@ -29,42 +35,42 @@ export interface Soup { at: (t: number, k: number) => number; } -export interface Layers { - /** Whether the material just below a point of the surface is carried. */ - held: (x: number, y: number, z: number) => boolean; +/** 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; /** - * Triangles of an overhang that holds, with their highest point at `z`, as x, y per corner: what - * is built on them is carried, once it has risen far enough for the angle to reach `out` mm. + * Triangles of an overhang with their highest point at `z`, as x, y per corner: what is built on + * them is carried, and what goes on from them within the angle. With `out`, only once the material + * has risen far enough for the angle to reach that many mm. */ carry: (z: number, corners: readonly number[], out?: number) => void; } +export interface Layers { + track: () => Carried; +} + /** * 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. `steep` names the triangles that hang over further than that, `lows` the - * heights where the surface has a lowest point that looks 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. + * 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, steep: (t: number) => boolean, lows: readonly number[]): Layers | null { - // Each triangle once, and closed: every edge is walked as often one way as the other. - const once = new Map(); - const net = new Map(); +export function layersOf({ count, vertices, x, y, z, at }: Soup, bedZ: number, grow: number, rough: Iterable): Layers | null { const planeOf = (k: number) => bedZ + (k + 0.5) * LAYER; /** The last cut below a height. */ const below = (height: number) => Math.max(0, Math.floor((height - bedZ - 1e-3) / LAYER - 0.5)); - // Each triangle with the first and the last cut through it, found by blocks of cuts. - const spans: [t: number, from: number, to: number][] = []; - const blocks: number[][] = []; - // The cuts with something below them that the angle alone does not carry. - const rough: boolean[] = []; - const roughen = (low: number, high: number) => { - const to = Math.ceil((high - bedZ + 1e-6) / LAYER - 0.5); - for (let k = Math.max(0, Math.ceil((low - bedZ - 1e-6) / LAYER - 0.5)); k <= to; k += 1) rough[k] = true; - }; + + // Each triangle once, with the triangles on each of its edges. + const once = new Map(); + const onEdge = new Map(); + const kept: number[] = []; 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; @@ -74,20 +80,70 @@ export function layersOf({ count, vertices, x, y, z, at }: Soup, bedZ: number, g if (thirds?.includes(third)) continue; if (thirds) thirds.push(third); else once.set(lowest * vertices + second, [third]); + kept.push(t); for (const [u, v] of [[a, b], [b, c], [c, a]] as const) { const key = u < v ? u * vertices + v : v * vertices + u; - net.set(key, (net.get(key) ?? 0) + (u < v ? 1 : -1)); + 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; + } + + // 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. + const normal = new Float64Array(count * 3); + 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; + const length = Math.hypot(...n); + if (length > 0) normal.set([n[0] / length, n[1] / length, n[2] / length], t * 3); + } + const inPlane = (s: number, t: number) => (normal[s * 3] ?? 0) * (normal[t * 3] ?? 0) + (normal[s * 3 + 1] ?? 0) * (normal[t * 3 + 1] ?? 0) + (normal[s * 3 + 2] ?? 0) * (normal[t * 3 + 2] ?? 0) > 1 - 1e-6; + const face = new Int32Array(count).fill(-1); + const find = (t: number): number => { + let root = t; + while ((face[root] ?? -1) >= 0) root = face[root] ?? 0; + while (t !== root) { + const up = face[t] ?? 0; + face[t] = root; + t = up; } - const [low, high] = [Math.min(z(a), z(b), z(c)), Math.max(z(a), z(b), z(c))]; - if (steep(t)) roughen(low, high); - const from = Math.max(0, Math.floor((low - bedZ) / LAYER - 0.5) + 1); - const to = Math.floor((high - bedZ) / LAYER - 0.5); + return root; + }; + for (const list of onEdge.values()) { + if (list.length !== 4 || !inPlane(list[0] ?? 0, list[2] ?? 0)) continue; + const [p, q] = [find(list[0] ?? 0), find(list[2] ?? 0)]; + if (p !== q) face[p] = q; + } + // 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 && find(list[0] ?? 0) === find(list[2] ?? 0)) continue; + const a = Math.floor(key / vertices); + const b = key % vertices; + const from = Math.max(0, Math.floor((Math.min(z(a), z(b)) - bedZ) / LAYER - 0.5) + 1); + const to = Math.floor((Math.max(z(a), z(b)) - bedZ) / LAYER - 0.5); if (to < from) continue; - for (let block = Math.floor(from / BLOCK); block <= Math.floor(to / BLOCK); block += 1) (blocks[block] ??= []).push(spans.length); - spans.push([t, from, to]); + 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, find(list[i] ?? 0), list[i + 1] ?? 0, from, to]); + } + } + const isRough: boolean[] = []; + for (const [low, high] of rough) { + const to = Math.ceil((high - bedZ + 1e-6) / LAYER - 0.5); + for (let k = Math.max(0, Math.ceil((low - bedZ - 1e-6) / LAYER - 0.5)); k <= to; k += 1) isRough[k] = true; } - for (const value of net.values()) if (value !== 0) return null; - for (const low of lows) roughen(low, low); 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. */ @@ -96,30 +152,38 @@ export function layersOf({ count, vertices, x, y, z, at }: Soup, bedZ: number, g 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 span of blocks[Math.floor(k / BLOCK)] ?? []) { - const [t, from, to] = spans[span] ?? [0, 1, 0]; - if (k < from || k > to) continue; - // Round the triangle as seen from outside, the cut runs from the edge that dips under the - // plane to the edge that rises through it: so the material lies to its left, however short it is. - let start = -1; - let end = -1; - for (let i = 0; i < 3; i += 1) { - const [u, v] = [at(t, i), at(t, (i + 1) % 3)]; - if (z(u) >= plane === z(v) >= plane) continue; - const [a, b] = u < v ? [u, v] : [v, u]; - 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) }); - } - if (z(u) >= plane) start = key; - else end = key; + 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)); } - if (start < 0 || end < 0) continue; - const list = next.get(start); - if (list) list.push(end); - else next.set(start, [end]); + 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) { @@ -139,68 +203,76 @@ export function layersOf({ count, vertices, x, y, z, at }: Soup, bedZ: number, g return cut; }; - // 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] ?? [])); - const added = new Map(); - const cut = (upTo: number) => { - for (let k = whole.length; k <= upTo; 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] && !rough[k])) { - whole.push(true); - continue; - } - const body = outline(k); - const under = carriedIn(k - 1); - const all = (held: Paths64) => areaPaths(body) - areaPaths(held) <= 1e-4 * SCALE * SCALE; - // Mitred, as a slope grows: its corners run ahead of its faces, and no arcs pile up cut by cut. - let held = under.length > 0 && body.length > 0 ? intersect(inflatePaths(under, grow * LAYER * SCALE, JoinType.Miter, EndType.Polygon, 4), body, FillRule.NonZero) : []; - // What an overhang below carries is added only where the angle has not reached already. - const more = added.get(k); - if (more && body.length > 0 && !all(held)) held = union(held, intersect(more, body, FillRule.NonZero), FillRule.NonZero); - if (all(held)) { - whole.push(true); - continue; - } - whole.push(false); - part[k] = held; - } - }; - - return { - held: (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 [sx, sy] = [px * SCALE, py * SCALE]; - let inside = false; - for (const path of carriedIn(k)) { - 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)); - if ((sx - a.x - s * dx) ** 2 + (sy - a.y - s * dy) ** 2 <= near * near) return true; - if (a.y > sy !== b.y > sy && sx < a.x + ((sy - a.y) * dx) / dy) inside = !inside; + 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 <= upTo; 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 body = outline(k); + const under = carriedIn(k - 1); + const all = (held: Paths64) => areaPaths(body) - areaPaths(held) <= 1e-4 * SCALE * SCALE; + // Mitred, as a slope of flat sides grows: its corners run ahead of its faces, and no arcs pile up cut by cut. + const reaching = (paths: Paths64, by: number) => intersect(by > 0 ? inflatePaths(paths, by * SCALE, JoinType.Miter, EndType.Polygon, MITRE) : paths, body, FillRule.NonZero); + let held = under.length > 0 && body.length > 0 ? reaching(under, grow * LAYER) : []; + // What an overhang below carries is added only where the angle has not reached already. + for (const [paths, grown] of added.get(k) ?? []) { + if (body.length === 0 || all(held)) break; + held = union(held, reaching(union(paths, FillRule.NonZero), grown), FillRule.NonZero); + } + whole.push(all(held)); + if (!whole[k]) part[k] = held; } - return inside; - }, - carry: (height, corners, out = 0) => { - const k = below(height) + 1 + Math.ceil(out / (grow * LAYER)); - // A cut that is made already is not made again: patches come lowest first. - if (k < whole.length) return; - const paths = added.get(k) ?? []; - for (let i = 0; i + 5 < corners.length; i += 6) { - const path: Path64 = [0, 2, 4].map((c) => ({ x: Math.round((corners[i + c] ?? 0) * SCALE), y: Math.round((corners[i + c + 1] ?? 0) * SCALE) })); - const [a, b, c] = path; - if (a && b && c && (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) < 0) path.reverse(); - paths.push(path); - } - added.set(k, paths); - }, + }; + 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 [sx, sy] = [px * SCALE, py * SCALE]; + let inside = false; + let least = Infinity; + for (const path of carriedIn(k)) { + 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 <= near * near) return 0; + if (a.y > sy !== b.y > sy && sx < a.x + ((sy - a.y) * dx) / dy) inside = !inside; + } + } + return inside ? 0 : (Math.sqrt(least) - near) / SCALE; + }, + carry: (height, corners, out = 0) => { + const k = below(height) + 1 + Math.ceil(out / (grow * LAYER)); + // A cut that is made already is not made again: patches come lowest first. + if (k < whole.length) return; + const paths: Paths64 = []; + for (let i = 0; i + 5 < corners.length; i += 6) { + const path: Path64 = [0, 2, 4].map((c) => ({ x: Math.round((corners[i + c] ?? 0) * SCALE), y: Math.round((corners[i + c + 1] ?? 0) * SCALE) })); + const [a, b, c] = path; + if (a && b && c && (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) < 0) path.reverse(); + paths.push(path); + } + // 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: [Paths64, number] = [paths, 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 }; } diff --git a/packages/geometry/src/overhang.ts b/packages/geometry/src/overhang.ts index f703608a..ab427a00 100644 --- a/packages/geometry/src/overhang.ts +++ b/packages/geometry/src/overhang.ts @@ -121,8 +121,10 @@ 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) leans: nothing can begin on it in the air. */ -const LEAN = 0.05; +/** 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; +/** Where a face is asked whether something is carried under it: its middle, and halfway from there to each corner. */ +const PROBES: [number, number, number][] = [[1 / 3, 1 / 3, 1 / 3], [2 / 3, 1 / 6, 1 / 6], [1 / 6, 2 / 3, 1 / 6], [1 / 6, 1 / 6, 2 / 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. */ @@ -142,6 +144,8 @@ interface Rim { bx: number; by: number; anchor: boolean; + /** How far (mm) a small overhang may hang from this rim. */ + reach: number; ease: number; } @@ -162,9 +166,10 @@ 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, leans: (t: number) => boolean, reach: number, arch: number, sinSupport: number, bedZ: number): { small: Set; hanging: Set; layered: boolean } { +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(); - const hanging = 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) || leans(t); @@ -212,27 +217,50 @@ function smallPatches(positions: ArrayLike, index: ArrayLike, do } // What stands: cut in layers where the body is closed, else followed along the surface. - // Where the surface begins from below without hanging over: the lowest points of faces that lean. + 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][] = []; + const looksDown = (t: number) => (down[t] ?? 0) > DOWN; const lower = new Uint8Array(vertices); - // Lowest among the faces that look down: the foot of a wall beside it is no way down. for (let t = 0; t < count; t += 1) { - if (!((down[t] ?? 0) > LEAN)) continue; - const least = Math.min(z(at(t, 0)), z(at(t, 1)), z(at(t, 2))); - for (let k = 0; k < 3; k += 1) if (z(at(t, k)) > least + WELD) lower[at(t, k)] = 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; } - const begins = new Map(); for (let t = 0; t < count; t += 1) { - if (!leans(t)) continue; - for (let k = 0; k < 3; k += 1) { - const w = at(t, k); - if (lower[w] || z(w) <= bedZ + BED_TOLERANCE) continue; - const list = begins.get(w); - if (list) list.push(t); - else begins.set(w, [t]); + if (!looksDown(t)) continue; + for (let k = 0; k < 3; k += 1) if (!lower[at(t, k)]) rough.push([z(at(t, k)), z(at(t, k))]); + } + 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 grow = Math.min(50, sinSupport / Math.sqrt(Math.max(1e-9, 1 - sinSupport * sinSupport))) + 1e-3; - const layers = layersOf({ count, vertices, x, y, z, at }, bedZ, grow, isSupport, [...begins.keys()].map(z)); + const layers = layersOf({ count, vertices, x, y, z, at }, bedZ, grow, rough); + // 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 @@ -240,24 +268,26 @@ function smallPatches(positions: ArrayLike, index: ArrayLike, do * 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, ease: anchor ? ease() : 0 }]; + 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 (!layers) return whole(standing?.(third) ?? 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; - let last: boolean | undefined; + // 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 anchor = i < pieces ? layers.held(...along((i + 0.5) / pieces)) : undefined; - if (anchor === last) continue; + 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, ease: last ? ease() : 0 }); + rims.push({ ax, ay, bx, by, anchor: last >= 0, reach: Math.max(0, last), ease: last >= 0 ? ease() : 0 }); } from = i; - last = anchor; + last = left; } return rims; }; @@ -297,65 +327,40 @@ function smallPatches(positions: ArrayLike, index: ArrayLike, do } patches.push({ patch, ends, top }); } - // Lowest first: what a bridge carries is known before the patches above it ask. + // Lowest first: what an overhang carries is known before the patches above it ask. if (layers) patches.sort((a, b) => a.top - b.top); + const corners = (list: readonly number[]) => list.flatMap((t) => [0, 1, 2].flatMap((k) => [x(at(t, k)), y(at(t, k))])); 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))); if (!rims.some((rim) => rim.anchor)) continue; const { held, bridged } = heldTriangles(patch, rims, { x, y, at, down, edges, edgeKey, reach, arch }, layers !== null); for (const t of held) small.add(t); - if (!layers) continue; - // A bridge carries at once; a small overhang once the material on it has risen as far as the - // angle needs to reach its tip, so a wall on a small ledge stands and a stair of ledges does not. - const corners = (list: readonly number[]) => list.flatMap((t) => [0, 1, 2].flatMap((k) => [x(at(t, k)), y(at(t, k))])); + if (!stands || !sound) continue; const spanning = new Set(bridged); - if (bridged.length > 0) layers.carry(top, corners(bridged)); - if (held.length > bridged.length) layers.carry(top, corners(held.filter((t) => !spanning.has(t))), reach); + const hung = held.filter((t) => !spanning.has(t)); + if (bridged.length > 0) { + stands.carry(top, corners(bridged)); + sound.carry(top, corners(bridged)); + } + if (hung.length > 0) { + stands.carry(top, corners(hung)); + sound.carry(top, corners(hung), reach); + } } - // Material that begins in the air on faces that only lean: a rib with a pointed edge under a - // ceiling, a keel. No face of it hangs over, yet nothing is below its first layer. From each such - // beginning the leaning faces are followed as far as the layers below them are not carried. - // What hangs together with an overhang that is reported already (over undersides and walls, no - // face that looks up between them) is not reported a second time: the drip edge of a shelf. + // 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. if (layers) { - const group = new Int32Array(count).fill(-1); - const reported: boolean[] = []; - const groupOf = (start: number) => { - if ((group[start] ?? 0) >= 0) return group[start] ?? 0; - const id = reported.push(false) - 1; - const stack = [start]; - group[start] = id; - while (stack.length > 0) { - const t = stack.pop() ?? 0; - if (isSupport(t) && !small.has(t)) reported[id] = true; - for (let k = 0; k < 3; k += 1) { - const list = edges.get(edgeKey(at(t, k), at(t, (k + 1) % 3))) ?? []; - for (let i = 0; i < list.length; i += 2) { - const other = list[i] ?? 0; - if ((group[other] ?? 0) >= 0 || (down[other] ?? 0) < -LEAN) continue; - group[other] = id; - stack.push(other); - } - } - } - return id; - }; - const middle = (t: number, axis: (w: number) => number) => (axis(at(t, 0)) + axis(at(t, 1)) + axis(at(t, 2))) / 3; - const tried = new Set(); - // Asked per face, not at the lowest point: the edge of a keel is held at the feet it ends in. - for (const around of begins.values()) { - const stack = [...around]; - while (stack.length > 0) { - const t = stack.pop() ?? 0; - if (tried.has(t)) continue; - tried.add(t); - if (layers.held(middle(t, x), middle(t, y), middle(t, z)) || reported[groupOf(t)]) continue; - hanging.add(t); - for (let k = 0; k < 3; k += 1) { - const list = edges.get(edgeKey(at(t, k), at(t, (k + 1) % 3))) ?? []; - for (let i = 0; i < list.length; i += 2) if (leans(list[i] ?? 0)) stack.push(list[i] ?? 0); - } - } + const propped = layers.track(); + for (const { patch, top } of patches) propped.carry(top, corners(patch)); + for (let t = 0; t < count; t += 1) { + if (!leans(t)) continue; + const [a, b, c] = [at(t, 0), at(t, 1), at(t, 2)]; + let free = 0; + for (const [wa, wb, wc] of PROBES) if (propped.gap(wa * x(a) + wb * x(b) + wc * x(c), wa * y(a) + wb * y(b) + wc * y(c), wa * z(a) + wb * z(b) + wc * z(c)) > 0) free += 1; + if (free > 0) hanging.set(t, free / PROBES.length); } } return { small, hanging, layered: layers !== null }; @@ -569,7 +574,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. @@ -736,12 +741,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 leans = (t: number) => (down[t] ?? 0) > LEAN && (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 Set(), layered: false }; + 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) => { - const counted = hanging.has(t) ? Overhang.Support : small.has(t) ? Overhang.Borderline : kind; - area[counted] += size; - if (counted === Overhang.Support) supportByFace[face] = (supportByFace[face] ?? 0) + size; + // 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 - free; + area[Overhang.Support] += free; + supportByFace[face] = (supportByFace[face] ?? 0) + free + (counted === Overhang.Support ? size - free : 0); }); return { vertices, area, supportByFace, layered }; } diff --git a/packages/geometry/src/overhangCases.test.ts b/packages/geometry/src/overhangCases.test.ts index 333607e7..eb266e71 100644 --- a/packages/geometry/src/overhangCases.test.ts +++ b/packages/geometry/src/overhangCases.test.ts @@ -153,6 +153,47 @@ const keel = soup([...cap(FOOT_CAP, 0, false), ...sides(FOOT, 0, 2), ...cap(BESI // 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); @@ -195,15 +236,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]], @@ -292,11 +336,28 @@ const cases: [string, () => RawMesh, Want][] = [ ['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', () => meshOf(prismBody('Rib', [[0, 0], [2, 0], [2, 8], [3.8, 8], [6, 4], [8.2, 8], [10, 8], [10, 0], [12, 0], [12, 10], [0, 10]], 30)), 'reported'], + ['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)), 'reported'], - ['a keel 4 wide and 5 deep between two feet, its edge 1 mm over the bed', () => keel, 'reported'], - ['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)), 'reported'], + ['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, [0.9 * 2 * 30 * Math.hypot(2, 5), 2 * 30 * Math.hypot(2, 5)]], + ['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'], From 22d23297afca86b22f4b46c3c4ca48d7ebb1a3c7 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Fri, 13 Nov 2026 06:21:38 +0100 Subject: [PATCH 5/5] fix(geometry): a beginning in the air is a piece of its own, and what hangs is read off the cuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A cut is carried as a whole only if no piece of it is without carried material and nothing but rounding is left over; a tip just under a cut no longer passes on an area tolerance. - Extra cuts just below and above the top of each overhang and each point where something begins, so the answer does not depend on where the part lies against the layers (stairs, rounds at bottom edges, the pin of a hinge over its bridge). - The share of a leaning face that hangs is measured along its cuts, not at four points: large triangles of a V trough or fin are reported. - Carried slivers thinner than a point no longer steer the mitred growth; flat faces grow from their largest triangle within a plane tolerance; the readings share cuts they agree on. - Tests: hanging tips over 0.00-0.20 mm, stairs over 0.00-0.20 mm, V troughs and fins, bottom rounds, the knuckles of a hinge on 45° webs, fine facets. The keel's expected area follows the wedges its feet carry (267 mm², was 291-323). Co-Authored-By: Claude Opus 5.5 --- packages/geometry/src/layers.ts | 265 ++++++++++++++------ packages/geometry/src/overhang.ts | 158 +++++++----- packages/geometry/src/overhangCases.test.ts | 138 +++++++++- packages/geometry/src/testShapes.ts | 19 +- 4 files changed, 441 insertions(+), 139 deletions(-) diff --git a/packages/geometry/src/layers.ts b/packages/geometry/src/layers.ts index a75cd695..779ab342 100644 --- a/packages/geometry/src/layers.ts +++ b/packages/geometry/src/layers.ts @@ -8,22 +8,35 @@ import { areaPaths, EndType, FillRule, inflatePaths, intersect, JoinType, union, * 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) of one cut: the usual layer. */ +/** 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 { @@ -35,20 +48,41 @@ export interface Soup { 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; /** - * Triangles of an overhang with their highest point at `z`, as x, y per corner: what is built on - * them is carried, and what goes on from them within the angle. With `out`, only once the material - * has risen far enough for the angle to reach that many mm. + * 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, corners: readonly number[], out?: number) => void; + 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; } /** @@ -62,15 +96,12 @@ export interface Layers { * 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): Layers | null { - const planeOf = (k: number) => bedZ + (k + 0.5) * LAYER; - /** The last cut below a height. */ - const below = (height: number) => Math.max(0, Math.floor((height - bedZ - 1e-3) / LAYER - 0.5)); - +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; @@ -81,6 +112,7 @@ export function layersOf({ count, vertices, x, y, z, at }: Soup, bedZ: number, g 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); @@ -96,54 +128,77 @@ export function layersOf({ count, vertices, x, y, z, at }: Soup, bedZ: number, g 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. + // 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; - const length = Math.hypot(...n); - if (length > 0) normal.set([n[0] / length, n[1] / length, n[2] / length], t * 3); + 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 inPlane = (s: number, t: number) => (normal[s * 3] ?? 0) * (normal[t * 3] ?? 0) + (normal[s * 3 + 1] ?? 0) * (normal[t * 3 + 1] ?? 0) + (normal[s * 3 + 2] ?? 0) * (normal[t * 3 + 2] ?? 0) > 1 - 1e-6; const face = new Int32Array(count).fill(-1); - const find = (t: number): number => { - let root = t; - while ((face[root] ?? -1) >= 0) root = face[root] ?? 0; - while (t !== root) { - const up = face[t] ?? 0; - face[t] = root; - t = up; + 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); + } } - return root; - }; - for (const list of onEdge.values()) { - if (list.length !== 4 || !inPlane(list[0] ?? 0, list[2] ?? 0)) continue; - const [p, q] = [find(list[0] ?? 0), find(list[2] ?? 0)]; - if (p !== q) face[p] = q; } // 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 && find(list[0] ?? 0) === find(list[2] ?? 0)) continue; + if (list.length === 4 && face[list[0] ?? 0] === face[list[2] ?? 0]) continue; const a = Math.floor(key / vertices); const b = key % vertices; - const from = Math.max(0, Math.floor((Math.min(z(a), z(b)) - bedZ) / LAYER - 0.5) + 1); - const to = Math.floor((Math.max(z(a), z(b)) - bedZ) / LAYER - 0.5); + // 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, find(list[i] ?? 0), list[i + 1] ?? 0, from, to]); + rims.push([a, b, face[list[i] ?? 0] ?? 0, list[i + 1] ?? 0, from, to]); } } - const isRough: boolean[] = []; - for (const [low, high] of rough) { - const to = Math.ceil((high - bedZ + 1e-6) / LAYER - 0.5); - for (let k = Math.max(0, Math.ceil((low - bedZ - 1e-6) / LAYER - 0.5)); k <= to; k += 1) isRough[k] = true; - } 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. */ @@ -203,33 +258,73 @@ export function layersOf({ count, vertices, x, y, z, at }: Soup, bedZ: number, g 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 added = new Map(); const cut = (upTo: number) => { - for (let k = whole.length; k <= upTo; k += 1) { + 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 body = outline(k); const under = carriedIn(k - 1); - const all = (held: Paths64) => areaPaths(body) - areaPaths(held) <= 1e-4 * SCALE * SCALE; + 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. - const reaching = (paths: Paths64, by: number) => intersect(by > 0 ? inflatePaths(paths, by * SCALE, JoinType.Miter, EndType.Polygon, MITRE) : paths, body, FillRule.NonZero); - let held = under.length > 0 && body.length > 0 ? reaching(under, grow * LAYER) : []; + // 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 [paths, grown] of added.get(k) ?? []) { + for (const [shape, grown] of more) { if (body.length === 0 || all(held)) break; - held = union(held, reaching(union(paths, FillRule.NonZero), grown), FillRule.NonZero); + 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 { @@ -238,41 +333,71 @@ export function layersOf({ count, vertices, x, y, z, at }: Soup, bedZ: number, g 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 [sx, sy] = [px * SCALE, py * SCALE]; - let inside = false; - let least = Infinity; - for (const path of carriedIn(k)) { - 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 <= near * near) return 0; - if (a.y > sy !== b.y > sy && sx < a.x + ((sy - a.y) * dx) / dy) inside = !inside; + 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; } - return inside ? 0 : (Math.sqrt(least) - near) / SCALE; + 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, corners, out = 0) => { - const k = below(height) + 1 + Math.ceil(out / (grow * LAYER)); + 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) return; - const paths: Paths64 = []; - for (let i = 0; i + 5 < corners.length; i += 6) { - const path: Path64 = [0, 2, 4].map((c) => ({ x: Math.round((corners[i + c] ?? 0) * SCALE), y: Math.round((corners[i + c + 1] ?? 0) * SCALE) })); - const [a, b, c] = path; - if (a && b && c && (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) < 0) path.reverse(); - paths.push(path); - } + 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: [Paths64, number] = [paths, out > 0 ? 0 : Math.max(0, planeOf(k) - height) * grow]; + 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 }; + 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 ab427a00..996d6632 100644 --- a/packages/geometry/src/overhang.ts +++ b/packages/geometry/src/overhang.ts @@ -6,7 +6,7 @@ * print direction from M7's orientation comes later. */ -import { layersOf } from './layers'; +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]; @@ -123,8 +123,7 @@ export const LYING_HOLE = 8; 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; -/** Where a face is asked whether something is carried under it: its middle, and halfway from there to each corner. */ -const PROBES: [number, number, number][] = [[1 / 3, 1 / 3, 1 / 3], [2 / 3, 1 / 6, 1 / 6], [1 / 6, 2 / 3, 1 / 6], [1 / 6, 1 / 6, 2 / 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. */ @@ -216,6 +215,44 @@ function smallPatches(positions: ArrayLike, index: ArrayLike, do } } + // 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; + const patch: number[] = []; + const ends: [number, number, number, number, number, number][] = []; + let top = -Infinity; + const queue = [start]; + seen[start] = 1; + while (queue.length > 0) { + const t = queue.pop() ?? 0; + patch.push(t); + 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; + const third = list[i + 1] ?? 0; + if (other === t) continue; + if (isSupport(other)) { + if (!seen[other]) { + seen[other] = 1; + queue.push(other); + } + continue; + } + ends.push([a, b, third, t, other, at(t, (k + 2) % 3)]); + } + } + } + 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 @@ -223,6 +260,10 @@ function smallPatches(positions: ArrayLike, index: ArrayLike, do // 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) { @@ -234,7 +275,12 @@ function smallPatches(positions: ArrayLike, index: ArrayLike, do } 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)]) rough.push([z(at(t, k)), z(at(t, k))]); + 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))); + } } for (const [id, list] of edges) { const a = Math.floor(id / vertices); @@ -254,7 +300,7 @@ function smallPatches(positions: ArrayLike, index: ArrayLike, do } 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); + 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 @@ -292,75 +338,72 @@ function smallPatches(positions: ArrayLike, index: ArrayLike, do return rims; }; - // 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; - const patch: number[] = []; - const ends: [number, number, number, number, number, number][] = []; - let top = -Infinity; - const queue = [start]; - seen[start] = 1; - while (queue.length > 0) { - const t = queue.pop() ?? 0; - patch.push(t); + /** 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 = 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; - const third = list[i + 1] ?? 0; - if (other === t) continue; - if (isSupport(other)) { - if (!seen[other]) { - seen[other] = 1; - queue.push(other); - } - continue; - } - ends.push([a, b, third, t, other, at(t, (k + 2) % 3)]); + 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); } } - patches.push({ patch, ends, top }); - } - // Lowest first: what an overhang carries is known before the patches above it ask. - if (layers) patches.sort((a, b) => a.top - b.top); - const corners = (list: readonly number[]) => list.flatMap((t) => [0, 1, 2].flatMap((k) => [x(at(t, k)), y(at(t, k))])); + 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))); - if (!rims.some((rim) => rim.anchor)) continue; - const { held, bridged } = heldTriangles(patch, rims, { x, y, at, down, edges, edgeKey, reach, arch }, layers !== null); + 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)); - if (bridged.length > 0) { - stands.carry(top, corners(bridged)); - sound.carry(top, corners(bridged)); + // 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 (hung.length > 0) { - stands.carry(top, corners(hung)); - sound.carry(top, corners(hung), reach); + 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. + // 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 propped = layers.track(); - for (const { patch, top } of patches) propped.carry(top, corners(patch)); + 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 [a, b, c] = [at(t, 0), at(t, 1), at(t, 2)]; - let free = 0; - for (const [wa, wb, wc] of PROBES) if (propped.gap(wa * x(a) + wb * x(b) + wc * x(c), wa * y(a) + wb * y(b) + wc * y(c), wa * z(a) + wb * z(b) + wc * z(c)) > 0) free += 1; - if (free > 0) hanging.set(t, free / PROBES.length); + 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 }; @@ -665,11 +708,12 @@ 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)); - // Of what holds, the triangles that are bridged all over: a triangle that only lies near an anchor - // is a small overhang, and carries nothing. + // 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) || sampled(t).points.every(([px, py]) => bridged(px, py, direction[0], direction[1], sampled(t).span))) : [], + bridged: wantBridged && direction ? held.filter((t) => !(nearAll.get(t) ?? false)) : [], }); if (direction ? (lost[best] ?? 0) === 0 : lostNear === 0) return result([...patch]); diff --git a/packages/geometry/src/overhangCases.test.ts b/packages/geometry/src/overhangCases.test.ts index eb266e71..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,12 +144,31 @@ 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 4 wide and 2 thick on a foot at each end, with a keel 5 deep under it whose edge ends 1 mm over the bed. -const FOOT: [number, number][] = [[0, 0], [4, 0], [4, 6], [4, 8], [0, 8], [0, 6]]; -const KEEL: [number, number][] = [[2, 1], [4, 6], [4, 8], [0, 8], [0, 6]]; -const FOOT_CAP = [at(FOOT, 0, 1, 2), at(FOOT, 0, 2, 3), at(FOOT, 0, 3, 4), at(FOOT, 0, 4, 5)]; -const BESIDE_KEEL: [number, number][][] = [[[0, 0], [4, 0], [2, 1]], [[0, 0], [2, 1], [0, 6]], [[4, 0], [4, 6], [2, 1]]]; -const keel = soup([...cap(FOOT_CAP, 0, false), ...sides(FOOT, 0, 2), ...cap(BESIDE_KEEL, 2, true), ...sides(KEEL, 2, 32), ...cap(BESIDE_KEEL, 32, false), ...sides(FOOT, 32, 34), ...cap(FOOT_CAP, 34, true)]); +/** + * 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))); @@ -200,6 +219,50 @@ const funnel = (n: number) => { 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][] = [ @@ -356,7 +419,7 @@ const cases: [string, () => RawMesh, Want][] = [ ['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, [0.9 * 2 * 30 * Math.hypot(2, 5), 2 * 30 * Math.hypot(2, 5)]], + ['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). @@ -364,6 +427,15 @@ const cases: [string, () => RawMesh, Want][] = [ ['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. */ @@ -412,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); +}