diff --git a/bins/index.html b/bins/index.html index 8643b9b..30e9ccc 100644 --- a/bins/index.html +++ b/bins/index.html @@ -2895,6 +2895,105 @@

Your drawers

return clean.length >= 3 ? clean : null; } +/* Sutherland–Hodgman against a convex polygon wound counter-clockwise, as the plate's + outline is: the shape cut to what is inside it, with a new point where one of its sides + crosses one of the polygon's. A shape that is all inside comes back as it is, the same + array, so a caller can tell that nothing was cut; null where nothing of it is. */ +function clipToConvex(pts, poly) { + const EPS = 1e-9; + const side = (a, b, p) => (b[0]-a[0]) * (p[1]-a[1]) - (b[1]-a[1]) * (p[0]-a[0]); + let out = pts; + for (let k = 0; k < poly.length; k++) { + const a = poly[k], b = poly[(k + 1) % poly.length]; + if (out.every((p) => side(a, b, p) >= -EPS)) continue; + const inp = out; out = []; + for (let i = 0; i < inp.length; i++) { + const cur = inp[i], prev = inp[(i + inp.length - 1) % inp.length]; + const sc = side(a, b, cur), sp = side(a, b, prev); + const cut = () => [prev[0] + (cur[0]-prev[0]) * sp / (sp - sc), prev[1] + (cur[1]-prev[1]) * sp / (sp - sc)]; + if (sc >= -EPS) { if (sp < -EPS) out.push(cut()); out.push(cur); } + else if (sp >= -EPS) out.push(cut()); + } + // a point on the line is in, so a side that ends on it crosses it where it ends + out = out.filter((p, i) => { + const q = out[(i + out.length - 1) % out.length]; + return Math.abs(p[0] - q[0]) > 1e-7 || Math.abs(p[1] - q[1]) > 1e-7; + }); + if (out.length < 3) return null; + } + return out; +} + +/* A corner boss's outline cut to the plate's, for the bosses at its rounded corners + (buildPiece, #86). A shape that is inside the outline comes back as the same array, so + a boss that never stood out past a corner is the boss it was, byte for byte. So does + one that stands out by under FLUSH, 0.01 mm, five welds (VTOL, 2 microns) and a tenth of + the thinnest layer a slicer cuts: clipped, its new points would stand a few microns from + the cell's own, and the weld takes two such points for one or for two as it comes. A + plate at 34 mm with screws and margins of 1.17 mm, where the corner stands out 0.0022, + had 3 open edges that the base lacked, from a boss with a point every 2.4 microns round + the arc's point at 45 degrees. + * + * Clipped to the outline, the boss's outer corner takes the outline's own chords, as many + * sides round the corner as the plate has there, with the chords' ends: the points of the + * arc that lie inside the boss. The corner cell's wall stands on those same points. A + * chord of the plate is a side of the boss, and the boss's bottom edge along it is the + * wall's, at the bed, so it is used four times (37 more edges a plate at 42 mm, with no + * margin). So a side of the clipped boss that runs along a chord has a point put in the + * middle of it. It is the same solid, and its edges are not the wall's. That is a part of + * a chord as well as a whole one: where a margin's region ends on the cell's edge, it + * shares the piece of the chord between that edge and the next point of the arc (2 more + * edges a plate at 36 mm, radius 2, margins of 0.55 and 0.52 mm, 0.049 mm long). Not a + * side under SHORT, 0.03 mm: its middle would stand a few welds (VTOL, 2 microns) from its + * ends, and a corner that stands out by a hair leaves two of them beside the arc's point + * (0.01 mm each at margins of 1.1682 mm, where the weld read 6 to 8 open edges). + * + * And the clipped shape starts where earTriangulate wants it to. That takes the ear at the + * first point again and again, so it fans every triangle of a convex shape from its last + * point. When the corner stands out by a hair, the clip leaves a side a few hundredths of + * a millimetre long beside it (0.023 mm at margins of 1.71 and 0.72 mm, where it stands out + * 0.010), and a fan from the far end of the boss's side that meets it makes a triangle + * 12.5 mm long and 0.012 wide. A plane of a pocket's cut crosses that triangle's long edge + * and the side 2.4 microns apart at a point 2 mm along, the weld keeps two, and the wall + * takes one point and the cap the other: 3 open edges. That happened to 5 of the 142 + * corners that stood out 0.005 to 0.02 mm in a scan of margins at 42 mm, and snapping the + * clip's points to their neighbours only moved which. So the shape is wound counter- + * clockwise, as earTriangulate takes it, and ends at its point farthest from the new + * ones, which is the apex: the triangles the new points make are across the shape from + * it, and none of them has a long edge along a side. */ +function clipToPlate(pts, outline) { + const clipped = clipToConvex(pts, outline); + if (!clipped || clipped === pts) return pts; + const FLUSH = 0.01, SHORT = 0.03; + const gap = (p, q) => Math.hypot(p[0] - q[0], p[1] - q[1]); + // how far the shape stands out: along the normal of the chord it is farthest past + const beyond = Math.max(...outline.map((a, k) => { + const b = outline[(k + 1) % outline.length]; + return Math.max(...pts.map((p) => -((b[0]-a[0]) * (p[1]-a[1]) - (b[1]-a[1]) * (p[0]-a[0])) / gap(a, b))); + })); + if (beyond < FLUSH) return pts; + const along = (a, b, p) => Math.abs((b[0]-a[0]) * (p[1]-a[1]) - (b[1]-a[1]) * (p[0]-a[0])) < 1e-7 * gap(a, b); + // the outline's chords: the sides that run along neither x nor y + const chords = []; + outline.forEach((a, k) => { + const b = outline[(k + 1) % outline.length]; + if (Math.abs(a[0] - b[0]) > 1e-9 && Math.abs(a[1] - b[1]) > 1e-9) chords.push([a, b]); + }); + const halved = []; + clipped.forEach((p, k) => { + const q = clipped[(k + 1) % clipped.length]; + halved.push(p); + if (gap(p, q) >= SHORT && chords.some(([a, b]) => along(a, b, p) && along(a, b, q))) + halved.push([(p[0] + q[0]) / 2, (p[1] + q[1]) / 2]); + }); + const ccw = polyArea2D(halved) > 0 ? halved : halved.reverse(); + const fresh = ccw.filter((p) => !pts.some((q) => gap(p, q) < 1e-9)); + const mid = [fresh.reduce((t, p) => t + p[0], 0) / fresh.length, fresh.reduce((t, p) => t + p[1], 0) / fresh.length]; + let far = 0; + ccw.forEach((p, i) => { if (gap(p, mid) > gap(ccw[far], mid)) far = i; }); + return ccw.slice(far + 1).concat(ccw.slice(0, far + 1)); +} + // ---------- layout ---------- /* The biggest grid the page will build in one go, a 1.26 m square at the spec pitch. It lives here because computeLayout has to honour it too (see the 'plates' split); the @@ -4968,15 +5067,16 @@

Your drawers

// one cutter for every mounting site on the piece, built once and moved into place const cellFastener = ((cfg.magnets || cfg.screws) && solidBase) ? fastenerCutter(cfg, pad - cfg.magnetH, pad + 0.02, H + 0.5) : null; - // the same cutter turned a 28th of a turn, half a facet of its 14-sided bores, for a - // cell whose pockets come out open the first time (see the fastener cut below); built - // the first time one does - let turned = null; - const turnedFastener = () => turned || (turned = cellFastener.map((p) => { + // a cutter turned a 28th of a turn about its axis, half a facet of its 14-sided bores + const turn28 = (cut) => cut.map((p) => { const ca = Math.cos(Math.PI/14), sa = Math.sin(Math.PI/14); const turn = (v) => [v[0]*ca - v[1]*sa, v[0]*sa + v[1]*ca, v[2]]; return { verts: p.verts.map(turn), plane: { n: turn(p.plane.n), w: p.plane.w } }; - })); + }); + // the cells' cutter turned so, for a cell whose pockets come out open the first time + // (see the fastener cut below); built the first time one does + let turned = null; + const turnedFastener = () => turned || (turned = turn28(cellFastener)); // the corners of its walls where they stand on the bottom face, and on the socket floor // (a magnet pocket from above, a screw's shank), about its axis (fanCentre) const wallsAt = (z) => { @@ -5077,10 +5177,13 @@

Your drawers

const atL = piece.cellX0 === 0, atR = piece.cellX0 + piece.nx === layout.nx; const atF = piece.cellY0 === 0, atB = piece.cellY0 + piece.ny === layout.ny; const round = { ll: atL && atF, lr: atR && atF, ur: atR && atB, ul: atL && atB }; + // each corner rounded, its point, its arc's centre and radius: for the corner bosses (#86) + const arcs = []; function corner(cx, cy, a0, doRound, key) { const rc = rOf(key); if (!doRound || rc <= 0.01) return [[cx, cy]]; const ccx = cx + (cx < 1 ? rc : -rc), ccy = cy + (cy < 1 ? rc : -rc); + arcs.push({ cx, cy, ox: ccx, oy: ccy, r: rc }); const out = []; for (let k = 0; k <= NARC; k++) { const a = (a0 + 90*k/NARC) * Math.PI/180; @@ -5938,6 +6041,81 @@

Your drawers

arc.push([bossW - rIn + rIn*Math.cos(a), bossW - rIn + rIn*Math.sin(a)]); } const onEdge = (x, lim) => Math.abs(x) < 1e-6 || Math.abs(x - lim) < 1e-6; + /* A boss at a rounded corner of the plate follows the corner's arc (#86). Its outer + corner is the cell's, square, and the plate is rounded there, so with no margin or a + small one the corner stood out past the arc: a square tab as tall as the boss, up to + 2.6 mm, r(√2 − 1) out at an outer radius r, 1.66 mm at the default 4 and 0.95 mm with + margins of 0.5 mm. It stays inside once both margins pass 1.17 mm, or once the other + reaches 4 mm when one is 0, and a boss that does is not touched: its bytes are the + ones it had, as are a boss's that stands out by under a hundredth of a millimetre. + The rest are cut to the outline, however a joint has shaped them (clipToPlate). No + housing reaches a boss at a rounded corner of the plate, since a joint's cuts sit + where four cells meet, so the bosses a housing reaches are cut too only to be sure + of it. + * + Cut only where the cut boss is no worse than the square one, and otherwise the boss + is the square one, built as it always was. Two things can make it worse. + * + A pocket that the arc comes near. The pockets sit holeOffset from the cell's centre + whatever the pitch, 2 mm in from the cell's edges at 30 mm, and mountLimits holds a + pocket MOUNT_WALL inside its cell's edges, which the square boss stands on. The arc + cuts across the cell's corner, and below about 34 mm (26 mm and twice the radius) + it passes nearer the site than the edges do: a 1.8 mm magnet at 30 mm, no margin, + a 4.88 mm corner, broke through it, a window in the plate's outer wall 1.2 mm along + the arc and up to 2 mm tall that the square tab had covered, with the mesh closed + round it so that no edge count saw it; and where the pocket's wall came within + microns of the arc's chords its cut came out with faces turned over (6 folds a + plate at 29.5 mm with 0.2 mm margins and at 33 mm with a 6.9 mm corner). So a boss + is cut only where each of its bores, as wide as its widest corner, stays MOUNT_WALL + inside the arc as well, the circle the chords are drawn on, the wall mountLimits + leaves every pocket on the plate; and a joint's cut reaching it MOUNT_WALL clear of + the corner's square. + * + And a cut that comes out worse. The cut boss's cap is fanned another way than the + square one's, across a dozen more points round the arc, and its pocket's planes + cross those spokes a few microns from where something else does: the same lottery + as any pocket's (cutAgain), drawn again. At the defaults with screws, 36.42 mm, + margins of 0, 0.433, 1 and 0, the boss at the plate's corner came out with 3 open + edges on its underside, and cutAgain's tries left them all; so did three plates at + 29.5 to 33 mm with a 2 mm corner, where the square boss had built closed. So the cut + boss is taken only if it comes out closed with nothing turned over: cut again as + cutAgain takes it when it is either, then with its cutter turned a 28th of a turn + (as the cells' are, pocketTries), which closed all four, and if neither comes out + right, or one throws, the boss is the square one, as it always was. A boss is its + own closed shell, so a boss that is closed with nothing turned over leaves its piece + no more open and no more folded than the square one did: shells that each close can + only share an edge an even number of times. They do share edges (the corner cell's + wall stands on the same points of the arc), used four times; see ENGINE.md. */ + // how far a point stands inside the nearest rounded corner's arc, as a circle + const fromArc = (x, y) => { + let d = Infinity; + for (const { cx, cy, ox, oy, r } of arcs) { + const ux = Math.sign(cx - ox), uy = Math.sign(cy - oy), dx = x - ox, dy = y - oy; + d = Math.min(d, dx*ux >= 0 && dy*uy >= 0 ? r - Math.hypot(dx, dy) + : Math.min(Math.hypot(dx - ux*r, dy), Math.hypot(dx, dy - uy*r))); // past its ends + } + return d; + }; + // the widest of a site's bores, out to its corners, built about the origin + const boreR = bossFastener ? Math.max(...bossFastener.flatMap((p) => p.verts.map((v) => Math.hypot(v[0], v[1])))) : 0; + const roomy = (sites, ks) => + sites.every((bs) => fromArc(bs.ccx + bs.sx*off, bs.ccy + bs.sy*off) - boreR > MOUNT_WALL - 1e-6) && + ks.every((k) => { + const [x0, y0, x1, y1] = cutBoxes[k], w = MOUNT_WALL; + return arcs.every(({ cx, cy, ox, oy }) => !meets([x0 - w, y0 - w, x1 + w, y1 + w], + [Math.min(cx, ox), Math.min(cy, oy), Math.max(cx, ox), Math.max(cy, oy)])); + }); + // a boss built by `build` from its square outline, or from that cut to the plate's + const inPlate = (square, sites, build, ks = [], fastener = bossFastener) => { + const clipped = clipToPlate(square, outline); + if (clipped !== square && roomy(sites, ks)) + for (const f of fastener ? [fastener, turn28(fastener)] : [null]) { + let boss; + try { boss = build(clipped, f, true); } catch (e) { continue; } + if (!boss.open && !boss.turned) return boss; + } + return build(square, fastener, false); + }; for (let n = 0; n < all.length; n++) { if (root(n) !== n) continue; const { ccx, ccy, sx, sy, cxr, cyr } = all[n]; @@ -5952,22 +6130,25 @@

Your drawers

pts.push([bossW - rIn + rIn*Math.cos(a), bossW - rIn + rIn*Math.sin(a)]); } pts.push([0, bossW]); - const world = pts.map(([u, v]) => [cxr - sx*u, cyr - sy*v]); - let boss = extrudePoly(world, 0, bossH); - /* A boss's pocket is a lottery of its own, so it is cut again (cutAgain) when it - comes out open. Default screws in one-cell pieces left 3 to 6 open edges on a - boss's underside at 22 of the 901 pitches from 34 to 60 mm that take them, and a - 9.1 mm magnet at 39.46 mm left 12 a piece; cut again, none of those leaks. Open - only: a pocket that comes out closed with a face turned over (13 more of those - pitches) has the fold unfoldFinished lays out again, and every one of those - pieces came out watertight and oriented, so it is kept as it was, as is every - pocket that comes out closed. */ - if (bossFastener) { - const pocket = movePolys(bossFastener, ccx + sx*off, ccy + sy*off); - const first = csgSubtract(boss, pocket); - boss = first.open ? cutAgain(boss, pocket, first) : first; - } - shells.push(boss); + shells.push(inPlate(pts.map(([u, v]) => [cxr - sx*u, cyr - sy*v]), [all[n]], (world, fastener, clipped) => { + let boss = extrudePoly(world, 0, bossH); + /* A boss's pocket is a lottery of its own, so it is cut again (cutAgain) when it + comes out open. Default screws in one-cell pieces left 3 to 6 open edges on a + boss's underside at 22 of the 901 pitches from 34 to 60 mm that take them, and a + 9.1 mm magnet at 39.46 mm left 12 a piece; cut again, none of those leaks. Open + only: a pocket that comes out closed with a face turned over (13 more of those + pitches) has the fold unfoldFinished lays out again, and every one of those + pieces came out watertight and oriented, so it is kept as it was, as is every + pocket that comes out closed. A boss cut to a rounded corner is taken only if it + comes out with nothing turned over either (inPlate), so it is cut again for that + too: it has no bytes of its own to keep. */ + if (fastener) { + const pocket = movePolys(fastener, ccx + sx*off, ccy + sy*off); + const first = csgSubtract(boss, pocket); + boss = first.open || clipped && first.turned ? cutAgain(boss, pocket, first) : first; + } + return boss; + })); continue; } /* In the boss's own frame u runs into the cell along x from its corner and v along @@ -6021,43 +6202,44 @@

Your drawers

const level = ks.filter((k) => Math.abs(cutTops[k] - bossH) < MOUNT_LEVEL); const top = level.length ? Math.max(...level.map((k) => cutTops[k])) + MOUNT_LEVEL : bossH; const fastener = top === bossH ? bossFastener : fastenerCutter(cfg, top - cfg.magnetH, top + 0.5, top + 0.5); - for (const { outline, sites } of solids) { - let boss = extrudePoly(outline, 0, top); - if (fastener) { - const pockets = [].concat(...sites.map((bs) => - movePolys(fastener, bs.ccx + bs.sx*off, bs.ccy + bs.sy*off))); - boss = cutAgain(boss, pockets, csgSubtract(boss, pockets)); - } - /* cutAgain's tries are not always enough here. A wall puzzle key at 36.92 mm with - screws (a 2 mm hole, a 7.1 x 0.8 head), four cells square, kept a cut with a - face turned over in the pair of bosses by the front edge's key, of A2 and of B2, - after every try: three folds on the bed a piece, about one build in 3,600 of - bosses a joint reaches. Moved two NUDGEs the other way along y it came out - clean. So a cut that every try leaves open or turned is taken again moved two - NUDGEs the other way along one axis, then the other, then both, and the first - that comes out closed with nothing turned over is kept; if none does, the first - stands. Two NUDGEs, not more: each stands as far from the cell's walls as the - first, and none nearer the pockets (see mountLimits). A cut that comes out right - is built exactly as before. */ - const b = flatBox(boss); - const cutAt = (a, c) => ks.flatMap((k) => - meets(cutBoxes[k], b) ? movePolys(fromBelow[k], -a*sx*NUDGE, -c*sy*NUDGE) : []); - const cut = cutAt(2, 2); - if (cut.length) { - const solid = boss; - boss = cutAgain(solid, cut, csgSubtract(solid, cut)); - for (const [a, c] of [[2, -2], [-2, 2], [-2, -2]]) { - if (!boss.open && !boss.turned) break; - let again; - try { - const alt = cutAt(a, c); - again = cutAgain(solid, alt, csgSubtract(solid, alt)); - } catch (e) { continue; } // the first cut stands - if (!again.open && !again.turned) boss = again; + for (const { outline: square, sites } of solids) + shells.push(inPlate(square, sites, (outline, fastener) => { + let boss = extrudePoly(outline, 0, top); + if (fastener) { + const pockets = [].concat(...sites.map((bs) => + movePolys(fastener, bs.ccx + bs.sx*off, bs.ccy + bs.sy*off))); + boss = cutAgain(boss, pockets, csgSubtract(boss, pockets)); } - } - shells.push(boss); - } + /* cutAgain's tries are not always enough here. A wall puzzle key at 36.92 mm with + screws (a 2 mm hole, a 7.1 x 0.8 head), four cells square, kept a cut with a + face turned over in the pair of bosses by the front edge's key, of A2 and of B2, + after every try: three folds on the bed a piece, about one build in 3,600 of + bosses a joint reaches. Moved two NUDGEs the other way along y it came out + clean. So a cut that every try leaves open or turned is taken again moved two + NUDGEs the other way along one axis, then the other, then both, and the first + that comes out closed with nothing turned over is kept; if none does, the first + stands. Two NUDGEs, not more: each stands as far from the cell's walls as the + first, and none nearer the pockets (see mountLimits). A cut that comes out right + is built exactly as before. */ + const b = flatBox(boss); + const cutAt = (a, c) => ks.flatMap((k) => + meets(cutBoxes[k], b) ? movePolys(fromBelow[k], -a*sx*NUDGE, -c*sy*NUDGE) : []); + const cut = cutAt(2, 2); + if (cut.length) { + const solid = boss; + boss = cutAgain(solid, cut, csgSubtract(solid, cut)); + for (const [a, c] of [[2, -2], [-2, 2], [-2, -2]]) { + if (!boss.open && !boss.turned) break; + let again; + try { + const alt = cutAt(a, c); + again = cutAgain(solid, alt, csgSubtract(solid, alt)); + } catch (e) { continue; } // the first cut stands + if (!again.open && !again.turned) boss = again; + } + } + return boss; + }, ks, fastener)); } /* The socket's rim, cut away under a boss whose pocket it stands in (#83). With nothing under the sockets a cell is its rim alone, from the bed up: 2.85 mm in from the diff --git a/docs/ENGINE.md b/docs/ENGINE.md index 83dbb0b..5919dbf 100644 --- a/docs/ENGINE.md +++ b/docs/ENGINE.md @@ -458,6 +458,121 @@ at 38 (9.7 to 7.3); from 40 mm the boss is the tighter cap. A magnet from beneat screw's head lose nothing, and a pocket the rim does not reach is built as it was, byte for byte. +A boss meets a rounded corner of the plate by being cut to it (`clipToPlate`, #86). A boss +is a quarter square from its cell's corner, built square whatever the plate does there, +and the plate's outline rounds each corner it owns with `NARC` (10) chords. So with no +margin, or a small one, the boss's corner stood out past the arc: a tab as tall as the +boss, up to 2.6 mm, r(√2 − 1) out at an outer radius r (1.66 mm at the default 4, 0.83 at +2, 1.99 at 4.8, at every pitch), 0.95 mm with margins of 0.5 mm. It stays inside once both +margins pass 1.17 mm, or once one is none and the other reaches 4 mm, and a boss that does +is not touched. The rest are clipped to the outline itself (Sutherland–Hodgman, +`clipToConvex`) wherever that leaves the plate no worse than the square boss (below), so +the boss's outer corner takes the outline's chords: as many sides round the corner as the +plate has there, on the same points. A boss's pocket, its cut and a joint's housing are +made from the clipped boss as they were from the square one. One +that stands out by under `FLUSH`, 0.01 mm, is not clipped either: its new points would +stand a few microns from the cell's own, under the weld's reach, and at margins of 1.17 mm +(0.0022 out) a plate at 34 mm with screws had 3 open edges the base lacked. + +Three things about the clip are there because the plain one cost edges. The corner cell's +wall stands on the same points as the clipped boss, so a chord of the plate that is a side +of the boss is the wall's face for face, and its bottom edge at the bed is used four +times: 37 more edges a plate at 42 mm with no margin (40 to 77). A side of the clipped +boss that runs along a chord has a point put in the middle of it, the same solid with +edges the wall does not share. That is true of a part of a chord as well as a whole one, +where a margin's region ends on the cell's edge and shares the rest of the chord with the +boss: 2 more edges at 36 mm, radius 2, margins of 0.55 and 0.52 mm (the side is 0.049 mm). +Not of a side under 0.03 mm (`SHORT`), whose middle would stand a few welds from its ends: +a corner that stands out by a hair leaves two such sides beside the arc's point, and a +point in the middle of each left 6 to 8 open edges with magnets at margins of 1.1682 to +1.1692 mm. + +The third is where the clipped boss's points start. `earTriangulate` fans a convex shape +from its last point, and when the corner stands out by a hair (0.005 to 0.02 mm) the clip +leaves a side a few hundredths of a millimetre long beside it. A fan from the far end of +the side that meets it makes a triangle 12.5 mm long and a few microns wide. A plane of the +pocket's cut crosses its long edge and the side 2.4 microns apart, the weld keeps two, and +the wall takes one point and the cap the other: 3 open edges a piece, in 5 of the 142 +designs a scan of margins at 42 mm found in that band. Snapping the clip's points to their +neighbours only moved which designs. So the clipped shape is wound counter-clockwise and +ends at its point farthest from the new ones, the fan's apex, and none of the thin +triangles has a long edge along a side. + +A boss is cut only where the cut one is no worse than the square one, and is otherwise the +square one, built as it always was, tab and all. Two things made it worse (the review of +#90). One is a bore the arc comes near. A cell's pockets sit `holeOffset` from its centre +whatever the pitch, s = pitch/2 − 13 in from the cell's edges (2 mm at 30), and +`mountLimits` holds every bore `MOUNT_WALL` (1 mm) inside those edges, which the square +boss stands on. With no margin the arc passes r − (r − s)√2 from the site, nearer than the +edges once s < r, below a pitch of 26 mm plus twice the radius: 1.17 mm at 30 mm with the +default corner and 0.81 mm at 4.88. A 1.8 mm magnet there broke through, a window in the +outer wall 1.2 mm along the arc and up to 2 mm tall that the tab had covered, with every +edge round it closed, so no edge count saw it (the audit reads the wall for it now); and +pockets that stood at the arc came out with faces turned over (6 folds at 29.5 mm with +margins of 0.2 mm and a 1.3 mm magnet, and at 33 mm with the largest corner and a 4.8 mm +magnet). So a boss is cut only where each of its bores, out to its widest corner, stands +`MOUNT_WALL` inside the arc as well, measured to the circle the chords are drawn on (a +site at the arc's centre keeps the wall it has to the cell's edges, though the chords sag +0.006 mm inside the circle at a 2 mm corner), and where no joint's cut comes within +`MOUNT_WALL` of the corner's square (none does, see below; the test stands guard). In the +review's sweeps (7,649 designs, 5,952 built, 3,737 with a boss out by 0.01 mm or more) that +kept the square boss on every corner of 268 and on some corners of 91 more, at pitches of +29.5 to 37 mm with a corner of 3 mm or more, mostly with no margin and a bore at its cap, +which keeps it below a pitch of 26 mm plus twice the radius; in 42 of them a bore would +have broken through. The other way, refusing such sizes in `mountLimits`, would have +taken magnets and screws off plates the page builds today (with no margin at 30 mm and +the default corner a bore has 0.17 mm of room) to remove a tab the plate had always had. + +The other is the lottery. The cut boss's cap is fanned from its far corner to every point +the arc puts on it, and a spoke 15 or 16 mm long can pass within a micron of a corner of +the pocket's mouth, where the weld loses a sliver: 3 open edges at the bed or the top, at +the defaults with screws at 36.42 mm and on three plates with a 2 mm corner from 29.5 to +33 mm, where the square boss built closed, and none of `cutAgain`'s tries closed them. So +the cut boss is cut again when it comes out turned over as well as open, then with its +cutter turned a 28th of a turn, as a cell's pockets are, which closed those four; if it +still comes out open or turned over, or a try throws, the boss is the square one. In the +same sweeps 11,760 bosses were cut, 6 needed the turned cutter (3 open edges, or 2 to 4 +faces turned over, the first time) and none fell back; in 4,000 one-cell plates with four +rounded corners each, 18 of 10,878, and none. A boss is a closed shell of its own, so one +that comes out closed with nothing turned over leaves its piece no more open and no more +folded than the square one: shells that each close can only add an even count to an edge +they share. That is why the test is on the boss and not on the cell or the piece. It reads +what `csgSubtract` already reports, so a boss that comes out right the first time costs +nothing more, and one that is not cut costs nothing at all. + +What it leaves is one kind of bad edge, which the audit names and pins by its count: **a +cut boss and the shell beside it on the same points of the arc, edges used four times**. +The shell beside it is the corner cell's, its wall or the margin's region it carries. Both +are closed and their faces at the edge lie in one plane and face the same way, so the edge +is used four times and never once, and nothing folds; a slicer's outline of the layer is +the same region either way. It comes three ways. A side along a chord under `SHORT`, left +without a point in its middle (above), shares its bottom edge with the wall, and every +chord is under it at a corner under about 0.19 mm (2r sin 4.5° < 0.03): 9 or 10 such +edges a corner, 14 to 34 at 0.09 mm. The point put in the middle of a chord can land where +the side of a margin's region, 0.1 mm past the margin into the cell, crosses that chord, +and the half chord from the arc's point is then both shells' (36 mm, a 2 mm corner, +margins of 0.2 and 0.3 mm: 2 more). And a joint's cut from beneath splits the cell's walls +at its ceiling (2.0 mm for a wall key's recess) where a plane of the boss's pocket splits +the boss's at the same height, so the two share the vertical edges at the arc's points from +the bed up: two pieces with a wall bowtie, puzzle key or snap clip, or an H-clip, from +beneath. In the review's sweeps 69 of the 3,469 designs the clip changed have more of +them, 517 in all, none open: 60 the third way (1 to 22 each in the sweeps, every one with a +joint from beneath; a two-piece 2 x 2 with a wall bowtie at 40 mm has 26 more than main), +7 the first (9 or 10 each, a corner of its own of 0.09 or 0.19 mm) and 2 the second (1 +each). None of them is cheap to keep off soundly: a point in the middle of a +side under `SHORT` opened edges (above), moving the chord's point off its middle only +moves which margins it lands on, and the third would need the boss to stand off the +cell's walls along the chords, a change to how the outline and the boss share points. +None was made. + +The other way to keep a boss inside, stopping it a `BLOAT` short of the arc as it stops +short of the piece's edge, was tried and left: the rim's box over it (#83) has to be +clipped too, or the outer wall has a 0.05 mm slit where the box reaches it, and that clip +drew open edges at pitches 30 to 34.5 mm in pieces whose pockets run in under the rim. No +joint's housing reaches a boss at a rounded corner of a plate a page can ask for, since a +housing is cut where four cells meet; the paired and housed bosses are clipped all the +same, so that `clipToPlate` is the one place a boss meets the outline. + `connector: 'puzzle'` used to be listed here as the first kind, and it is **no longer open anywhere**. It is worth reading how, because it needed two unrelated fixes and the lesson generalises: **an even count and an odd count on the same case are two separate diff --git a/index.html b/index.html index 4791f5a..5d6492d 100644 --- a/index.html +++ b/index.html @@ -2683,6 +2683,105 @@

Your drawers

return clean.length >= 3 ? clean : null; } +/* Sutherland–Hodgman against a convex polygon wound counter-clockwise, as the plate's + outline is: the shape cut to what is inside it, with a new point where one of its sides + crosses one of the polygon's. A shape that is all inside comes back as it is, the same + array, so a caller can tell that nothing was cut; null where nothing of it is. */ +function clipToConvex(pts, poly) { + const EPS = 1e-9; + const side = (a, b, p) => (b[0]-a[0]) * (p[1]-a[1]) - (b[1]-a[1]) * (p[0]-a[0]); + let out = pts; + for (let k = 0; k < poly.length; k++) { + const a = poly[k], b = poly[(k + 1) % poly.length]; + if (out.every((p) => side(a, b, p) >= -EPS)) continue; + const inp = out; out = []; + for (let i = 0; i < inp.length; i++) { + const cur = inp[i], prev = inp[(i + inp.length - 1) % inp.length]; + const sc = side(a, b, cur), sp = side(a, b, prev); + const cut = () => [prev[0] + (cur[0]-prev[0]) * sp / (sp - sc), prev[1] + (cur[1]-prev[1]) * sp / (sp - sc)]; + if (sc >= -EPS) { if (sp < -EPS) out.push(cut()); out.push(cur); } + else if (sp >= -EPS) out.push(cut()); + } + // a point on the line is in, so a side that ends on it crosses it where it ends + out = out.filter((p, i) => { + const q = out[(i + out.length - 1) % out.length]; + return Math.abs(p[0] - q[0]) > 1e-7 || Math.abs(p[1] - q[1]) > 1e-7; + }); + if (out.length < 3) return null; + } + return out; +} + +/* A corner boss's outline cut to the plate's, for the bosses at its rounded corners + (buildPiece, #86). A shape that is inside the outline comes back as the same array, so + a boss that never stood out past a corner is the boss it was, byte for byte. So does + one that stands out by under FLUSH, 0.01 mm, five welds (VTOL, 2 microns) and a tenth of + the thinnest layer a slicer cuts: clipped, its new points would stand a few microns from + the cell's own, and the weld takes two such points for one or for two as it comes. A + plate at 34 mm with screws and margins of 1.17 mm, where the corner stands out 0.0022, + had 3 open edges that the base lacked, from a boss with a point every 2.4 microns round + the arc's point at 45 degrees. + * + * Clipped to the outline, the boss's outer corner takes the outline's own chords, as many + * sides round the corner as the plate has there, with the chords' ends: the points of the + * arc that lie inside the boss. The corner cell's wall stands on those same points. A + * chord of the plate is a side of the boss, and the boss's bottom edge along it is the + * wall's, at the bed, so it is used four times (37 more edges a plate at 42 mm, with no + * margin). So a side of the clipped boss that runs along a chord has a point put in the + * middle of it. It is the same solid, and its edges are not the wall's. That is a part of + * a chord as well as a whole one: where a margin's region ends on the cell's edge, it + * shares the piece of the chord between that edge and the next point of the arc (2 more + * edges a plate at 36 mm, radius 2, margins of 0.55 and 0.52 mm, 0.049 mm long). Not a + * side under SHORT, 0.03 mm: its middle would stand a few welds (VTOL, 2 microns) from its + * ends, and a corner that stands out by a hair leaves two of them beside the arc's point + * (0.01 mm each at margins of 1.1682 mm, where the weld read 6 to 8 open edges). + * + * And the clipped shape starts where earTriangulate wants it to. That takes the ear at the + * first point again and again, so it fans every triangle of a convex shape from its last + * point. When the corner stands out by a hair, the clip leaves a side a few hundredths of + * a millimetre long beside it (0.023 mm at margins of 1.71 and 0.72 mm, where it stands out + * 0.010), and a fan from the far end of the boss's side that meets it makes a triangle + * 12.5 mm long and 0.012 wide. A plane of a pocket's cut crosses that triangle's long edge + * and the side 2.4 microns apart at a point 2 mm along, the weld keeps two, and the wall + * takes one point and the cap the other: 3 open edges. That happened to 5 of the 142 + * corners that stood out 0.005 to 0.02 mm in a scan of margins at 42 mm, and snapping the + * clip's points to their neighbours only moved which. So the shape is wound counter- + * clockwise, as earTriangulate takes it, and ends at its point farthest from the new + * ones, which is the apex: the triangles the new points make are across the shape from + * it, and none of them has a long edge along a side. */ +function clipToPlate(pts, outline) { + const clipped = clipToConvex(pts, outline); + if (!clipped || clipped === pts) return pts; + const FLUSH = 0.01, SHORT = 0.03; + const gap = (p, q) => Math.hypot(p[0] - q[0], p[1] - q[1]); + // how far the shape stands out: along the normal of the chord it is farthest past + const beyond = Math.max(...outline.map((a, k) => { + const b = outline[(k + 1) % outline.length]; + return Math.max(...pts.map((p) => -((b[0]-a[0]) * (p[1]-a[1]) - (b[1]-a[1]) * (p[0]-a[0])) / gap(a, b))); + })); + if (beyond < FLUSH) return pts; + const along = (a, b, p) => Math.abs((b[0]-a[0]) * (p[1]-a[1]) - (b[1]-a[1]) * (p[0]-a[0])) < 1e-7 * gap(a, b); + // the outline's chords: the sides that run along neither x nor y + const chords = []; + outline.forEach((a, k) => { + const b = outline[(k + 1) % outline.length]; + if (Math.abs(a[0] - b[0]) > 1e-9 && Math.abs(a[1] - b[1]) > 1e-9) chords.push([a, b]); + }); + const halved = []; + clipped.forEach((p, k) => { + const q = clipped[(k + 1) % clipped.length]; + halved.push(p); + if (gap(p, q) >= SHORT && chords.some(([a, b]) => along(a, b, p) && along(a, b, q))) + halved.push([(p[0] + q[0]) / 2, (p[1] + q[1]) / 2]); + }); + const ccw = polyArea2D(halved) > 0 ? halved : halved.reverse(); + const fresh = ccw.filter((p) => !pts.some((q) => gap(p, q) < 1e-9)); + const mid = [fresh.reduce((t, p) => t + p[0], 0) / fresh.length, fresh.reduce((t, p) => t + p[1], 0) / fresh.length]; + let far = 0; + ccw.forEach((p, i) => { if (gap(p, mid) > gap(ccw[far], mid)) far = i; }); + return ccw.slice(far + 1).concat(ccw.slice(0, far + 1)); +} + // ---------- layout ---------- /* The biggest grid the page will build in one go, a 1.26 m square at the spec pitch. It lives here because computeLayout has to honour it too (see the 'plates' split); the @@ -4756,15 +4855,16 @@

Your drawers

// one cutter for every mounting site on the piece, built once and moved into place const cellFastener = ((cfg.magnets || cfg.screws) && solidBase) ? fastenerCutter(cfg, pad - cfg.magnetH, pad + 0.02, H + 0.5) : null; - // the same cutter turned a 28th of a turn, half a facet of its 14-sided bores, for a - // cell whose pockets come out open the first time (see the fastener cut below); built - // the first time one does - let turned = null; - const turnedFastener = () => turned || (turned = cellFastener.map((p) => { + // a cutter turned a 28th of a turn about its axis, half a facet of its 14-sided bores + const turn28 = (cut) => cut.map((p) => { const ca = Math.cos(Math.PI/14), sa = Math.sin(Math.PI/14); const turn = (v) => [v[0]*ca - v[1]*sa, v[0]*sa + v[1]*ca, v[2]]; return { verts: p.verts.map(turn), plane: { n: turn(p.plane.n), w: p.plane.w } }; - })); + }); + // the cells' cutter turned so, for a cell whose pockets come out open the first time + // (see the fastener cut below); built the first time one does + let turned = null; + const turnedFastener = () => turned || (turned = turn28(cellFastener)); // the corners of its walls where they stand on the bottom face, and on the socket floor // (a magnet pocket from above, a screw's shank), about its axis (fanCentre) const wallsAt = (z) => { @@ -4865,10 +4965,13 @@

Your drawers

const atL = piece.cellX0 === 0, atR = piece.cellX0 + piece.nx === layout.nx; const atF = piece.cellY0 === 0, atB = piece.cellY0 + piece.ny === layout.ny; const round = { ll: atL && atF, lr: atR && atF, ur: atR && atB, ul: atL && atB }; + // each corner rounded, its point, its arc's centre and radius: for the corner bosses (#86) + const arcs = []; function corner(cx, cy, a0, doRound, key) { const rc = rOf(key); if (!doRound || rc <= 0.01) return [[cx, cy]]; const ccx = cx + (cx < 1 ? rc : -rc), ccy = cy + (cy < 1 ? rc : -rc); + arcs.push({ cx, cy, ox: ccx, oy: ccy, r: rc }); const out = []; for (let k = 0; k <= NARC; k++) { const a = (a0 + 90*k/NARC) * Math.PI/180; @@ -5726,6 +5829,81 @@

Your drawers

arc.push([bossW - rIn + rIn*Math.cos(a), bossW - rIn + rIn*Math.sin(a)]); } const onEdge = (x, lim) => Math.abs(x) < 1e-6 || Math.abs(x - lim) < 1e-6; + /* A boss at a rounded corner of the plate follows the corner's arc (#86). Its outer + corner is the cell's, square, and the plate is rounded there, so with no margin or a + small one the corner stood out past the arc: a square tab as tall as the boss, up to + 2.6 mm, r(√2 − 1) out at an outer radius r, 1.66 mm at the default 4 and 0.95 mm with + margins of 0.5 mm. It stays inside once both margins pass 1.17 mm, or once the other + reaches 4 mm when one is 0, and a boss that does is not touched: its bytes are the + ones it had, as are a boss's that stands out by under a hundredth of a millimetre. + The rest are cut to the outline, however a joint has shaped them (clipToPlate). No + housing reaches a boss at a rounded corner of the plate, since a joint's cuts sit + where four cells meet, so the bosses a housing reaches are cut too only to be sure + of it. + * + Cut only where the cut boss is no worse than the square one, and otherwise the boss + is the square one, built as it always was. Two things can make it worse. + * + A pocket that the arc comes near. The pockets sit holeOffset from the cell's centre + whatever the pitch, 2 mm in from the cell's edges at 30 mm, and mountLimits holds a + pocket MOUNT_WALL inside its cell's edges, which the square boss stands on. The arc + cuts across the cell's corner, and below about 34 mm (26 mm and twice the radius) + it passes nearer the site than the edges do: a 1.8 mm magnet at 30 mm, no margin, + a 4.88 mm corner, broke through it, a window in the plate's outer wall 1.2 mm along + the arc and up to 2 mm tall that the square tab had covered, with the mesh closed + round it so that no edge count saw it; and where the pocket's wall came within + microns of the arc's chords its cut came out with faces turned over (6 folds a + plate at 29.5 mm with 0.2 mm margins and at 33 mm with a 6.9 mm corner). So a boss + is cut only where each of its bores, as wide as its widest corner, stays MOUNT_WALL + inside the arc as well, the circle the chords are drawn on, the wall mountLimits + leaves every pocket on the plate; and a joint's cut reaching it MOUNT_WALL clear of + the corner's square. + * + And a cut that comes out worse. The cut boss's cap is fanned another way than the + square one's, across a dozen more points round the arc, and its pocket's planes + cross those spokes a few microns from where something else does: the same lottery + as any pocket's (cutAgain), drawn again. At the defaults with screws, 36.42 mm, + margins of 0, 0.433, 1 and 0, the boss at the plate's corner came out with 3 open + edges on its underside, and cutAgain's tries left them all; so did three plates at + 29.5 to 33 mm with a 2 mm corner, where the square boss had built closed. So the cut + boss is taken only if it comes out closed with nothing turned over: cut again as + cutAgain takes it when it is either, then with its cutter turned a 28th of a turn + (as the cells' are, pocketTries), which closed all four, and if neither comes out + right, or one throws, the boss is the square one, as it always was. A boss is its + own closed shell, so a boss that is closed with nothing turned over leaves its piece + no more open and no more folded than the square one did: shells that each close can + only share an edge an even number of times. They do share edges (the corner cell's + wall stands on the same points of the arc), used four times; see ENGINE.md. */ + // how far a point stands inside the nearest rounded corner's arc, as a circle + const fromArc = (x, y) => { + let d = Infinity; + for (const { cx, cy, ox, oy, r } of arcs) { + const ux = Math.sign(cx - ox), uy = Math.sign(cy - oy), dx = x - ox, dy = y - oy; + d = Math.min(d, dx*ux >= 0 && dy*uy >= 0 ? r - Math.hypot(dx, dy) + : Math.min(Math.hypot(dx - ux*r, dy), Math.hypot(dx, dy - uy*r))); // past its ends + } + return d; + }; + // the widest of a site's bores, out to its corners, built about the origin + const boreR = bossFastener ? Math.max(...bossFastener.flatMap((p) => p.verts.map((v) => Math.hypot(v[0], v[1])))) : 0; + const roomy = (sites, ks) => + sites.every((bs) => fromArc(bs.ccx + bs.sx*off, bs.ccy + bs.sy*off) - boreR > MOUNT_WALL - 1e-6) && + ks.every((k) => { + const [x0, y0, x1, y1] = cutBoxes[k], w = MOUNT_WALL; + return arcs.every(({ cx, cy, ox, oy }) => !meets([x0 - w, y0 - w, x1 + w, y1 + w], + [Math.min(cx, ox), Math.min(cy, oy), Math.max(cx, ox), Math.max(cy, oy)])); + }); + // a boss built by `build` from its square outline, or from that cut to the plate's + const inPlate = (square, sites, build, ks = [], fastener = bossFastener) => { + const clipped = clipToPlate(square, outline); + if (clipped !== square && roomy(sites, ks)) + for (const f of fastener ? [fastener, turn28(fastener)] : [null]) { + let boss; + try { boss = build(clipped, f, true); } catch (e) { continue; } + if (!boss.open && !boss.turned) return boss; + } + return build(square, fastener, false); + }; for (let n = 0; n < all.length; n++) { if (root(n) !== n) continue; const { ccx, ccy, sx, sy, cxr, cyr } = all[n]; @@ -5740,22 +5918,25 @@

Your drawers

pts.push([bossW - rIn + rIn*Math.cos(a), bossW - rIn + rIn*Math.sin(a)]); } pts.push([0, bossW]); - const world = pts.map(([u, v]) => [cxr - sx*u, cyr - sy*v]); - let boss = extrudePoly(world, 0, bossH); - /* A boss's pocket is a lottery of its own, so it is cut again (cutAgain) when it - comes out open. Default screws in one-cell pieces left 3 to 6 open edges on a - boss's underside at 22 of the 901 pitches from 34 to 60 mm that take them, and a - 9.1 mm magnet at 39.46 mm left 12 a piece; cut again, none of those leaks. Open - only: a pocket that comes out closed with a face turned over (13 more of those - pitches) has the fold unfoldFinished lays out again, and every one of those - pieces came out watertight and oriented, so it is kept as it was, as is every - pocket that comes out closed. */ - if (bossFastener) { - const pocket = movePolys(bossFastener, ccx + sx*off, ccy + sy*off); - const first = csgSubtract(boss, pocket); - boss = first.open ? cutAgain(boss, pocket, first) : first; - } - shells.push(boss); + shells.push(inPlate(pts.map(([u, v]) => [cxr - sx*u, cyr - sy*v]), [all[n]], (world, fastener, clipped) => { + let boss = extrudePoly(world, 0, bossH); + /* A boss's pocket is a lottery of its own, so it is cut again (cutAgain) when it + comes out open. Default screws in one-cell pieces left 3 to 6 open edges on a + boss's underside at 22 of the 901 pitches from 34 to 60 mm that take them, and a + 9.1 mm magnet at 39.46 mm left 12 a piece; cut again, none of those leaks. Open + only: a pocket that comes out closed with a face turned over (13 more of those + pitches) has the fold unfoldFinished lays out again, and every one of those + pieces came out watertight and oriented, so it is kept as it was, as is every + pocket that comes out closed. A boss cut to a rounded corner is taken only if it + comes out with nothing turned over either (inPlate), so it is cut again for that + too: it has no bytes of its own to keep. */ + if (fastener) { + const pocket = movePolys(fastener, ccx + sx*off, ccy + sy*off); + const first = csgSubtract(boss, pocket); + boss = first.open || clipped && first.turned ? cutAgain(boss, pocket, first) : first; + } + return boss; + })); continue; } /* In the boss's own frame u runs into the cell along x from its corner and v along @@ -5809,43 +5990,44 @@

Your drawers

const level = ks.filter((k) => Math.abs(cutTops[k] - bossH) < MOUNT_LEVEL); const top = level.length ? Math.max(...level.map((k) => cutTops[k])) + MOUNT_LEVEL : bossH; const fastener = top === bossH ? bossFastener : fastenerCutter(cfg, top - cfg.magnetH, top + 0.5, top + 0.5); - for (const { outline, sites } of solids) { - let boss = extrudePoly(outline, 0, top); - if (fastener) { - const pockets = [].concat(...sites.map((bs) => - movePolys(fastener, bs.ccx + bs.sx*off, bs.ccy + bs.sy*off))); - boss = cutAgain(boss, pockets, csgSubtract(boss, pockets)); - } - /* cutAgain's tries are not always enough here. A wall puzzle key at 36.92 mm with - screws (a 2 mm hole, a 7.1 x 0.8 head), four cells square, kept a cut with a - face turned over in the pair of bosses by the front edge's key, of A2 and of B2, - after every try: three folds on the bed a piece, about one build in 3,600 of - bosses a joint reaches. Moved two NUDGEs the other way along y it came out - clean. So a cut that every try leaves open or turned is taken again moved two - NUDGEs the other way along one axis, then the other, then both, and the first - that comes out closed with nothing turned over is kept; if none does, the first - stands. Two NUDGEs, not more: each stands as far from the cell's walls as the - first, and none nearer the pockets (see mountLimits). A cut that comes out right - is built exactly as before. */ - const b = flatBox(boss); - const cutAt = (a, c) => ks.flatMap((k) => - meets(cutBoxes[k], b) ? movePolys(fromBelow[k], -a*sx*NUDGE, -c*sy*NUDGE) : []); - const cut = cutAt(2, 2); - if (cut.length) { - const solid = boss; - boss = cutAgain(solid, cut, csgSubtract(solid, cut)); - for (const [a, c] of [[2, -2], [-2, 2], [-2, -2]]) { - if (!boss.open && !boss.turned) break; - let again; - try { - const alt = cutAt(a, c); - again = cutAgain(solid, alt, csgSubtract(solid, alt)); - } catch (e) { continue; } // the first cut stands - if (!again.open && !again.turned) boss = again; + for (const { outline: square, sites } of solids) + shells.push(inPlate(square, sites, (outline, fastener) => { + let boss = extrudePoly(outline, 0, top); + if (fastener) { + const pockets = [].concat(...sites.map((bs) => + movePolys(fastener, bs.ccx + bs.sx*off, bs.ccy + bs.sy*off))); + boss = cutAgain(boss, pockets, csgSubtract(boss, pockets)); } - } - shells.push(boss); - } + /* cutAgain's tries are not always enough here. A wall puzzle key at 36.92 mm with + screws (a 2 mm hole, a 7.1 x 0.8 head), four cells square, kept a cut with a + face turned over in the pair of bosses by the front edge's key, of A2 and of B2, + after every try: three folds on the bed a piece, about one build in 3,600 of + bosses a joint reaches. Moved two NUDGEs the other way along y it came out + clean. So a cut that every try leaves open or turned is taken again moved two + NUDGEs the other way along one axis, then the other, then both, and the first + that comes out closed with nothing turned over is kept; if none does, the first + stands. Two NUDGEs, not more: each stands as far from the cell's walls as the + first, and none nearer the pockets (see mountLimits). A cut that comes out right + is built exactly as before. */ + const b = flatBox(boss); + const cutAt = (a, c) => ks.flatMap((k) => + meets(cutBoxes[k], b) ? movePolys(fromBelow[k], -a*sx*NUDGE, -c*sy*NUDGE) : []); + const cut = cutAt(2, 2); + if (cut.length) { + const solid = boss; + boss = cutAgain(solid, cut, csgSubtract(solid, cut)); + for (const [a, c] of [[2, -2], [-2, 2], [-2, -2]]) { + if (!boss.open && !boss.turned) break; + let again; + try { + const alt = cutAt(a, c); + again = cutAgain(solid, alt, csgSubtract(solid, alt)); + } catch (e) { continue; } // the first cut stands + if (!again.open && !again.turned) boss = again; + } + } + return boss; + }, ks, fastener)); } /* The socket's rim, cut away under a boss whose pocket it stands in (#83). With nothing under the sockets a cell is its rim alone, from the bed up: 2.85 mm in from the diff --git a/src/core.js b/src/core.js index a4c0af3..2fe72da 100644 --- a/src/core.js +++ b/src/core.js @@ -1289,6 +1289,105 @@ function clipToRect(pts, x0, y0, x1, y1) { return clean.length >= 3 ? clean : null; } +/* Sutherland–Hodgman against a convex polygon wound counter-clockwise, as the plate's + outline is: the shape cut to what is inside it, with a new point where one of its sides + crosses one of the polygon's. A shape that is all inside comes back as it is, the same + array, so a caller can tell that nothing was cut; null where nothing of it is. */ +function clipToConvex(pts, poly) { + const EPS = 1e-9; + const side = (a, b, p) => (b[0]-a[0]) * (p[1]-a[1]) - (b[1]-a[1]) * (p[0]-a[0]); + let out = pts; + for (let k = 0; k < poly.length; k++) { + const a = poly[k], b = poly[(k + 1) % poly.length]; + if (out.every((p) => side(a, b, p) >= -EPS)) continue; + const inp = out; out = []; + for (let i = 0; i < inp.length; i++) { + const cur = inp[i], prev = inp[(i + inp.length - 1) % inp.length]; + const sc = side(a, b, cur), sp = side(a, b, prev); + const cut = () => [prev[0] + (cur[0]-prev[0]) * sp / (sp - sc), prev[1] + (cur[1]-prev[1]) * sp / (sp - sc)]; + if (sc >= -EPS) { if (sp < -EPS) out.push(cut()); out.push(cur); } + else if (sp >= -EPS) out.push(cut()); + } + // a point on the line is in, so a side that ends on it crosses it where it ends + out = out.filter((p, i) => { + const q = out[(i + out.length - 1) % out.length]; + return Math.abs(p[0] - q[0]) > 1e-7 || Math.abs(p[1] - q[1]) > 1e-7; + }); + if (out.length < 3) return null; + } + return out; +} + +/* A corner boss's outline cut to the plate's, for the bosses at its rounded corners + (buildPiece, #86). A shape that is inside the outline comes back as the same array, so + a boss that never stood out past a corner is the boss it was, byte for byte. So does + one that stands out by under FLUSH, 0.01 mm, five welds (VTOL, 2 microns) and a tenth of + the thinnest layer a slicer cuts: clipped, its new points would stand a few microns from + the cell's own, and the weld takes two such points for one or for two as it comes. A + plate at 34 mm with screws and margins of 1.17 mm, where the corner stands out 0.0022, + had 3 open edges that the base lacked, from a boss with a point every 2.4 microns round + the arc's point at 45 degrees. + * + * Clipped to the outline, the boss's outer corner takes the outline's own chords, as many + * sides round the corner as the plate has there, with the chords' ends: the points of the + * arc that lie inside the boss. The corner cell's wall stands on those same points. A + * chord of the plate is a side of the boss, and the boss's bottom edge along it is the + * wall's, at the bed, so it is used four times (37 more edges a plate at 42 mm, with no + * margin). So a side of the clipped boss that runs along a chord has a point put in the + * middle of it. It is the same solid, and its edges are not the wall's. That is a part of + * a chord as well as a whole one: where a margin's region ends on the cell's edge, it + * shares the piece of the chord between that edge and the next point of the arc (2 more + * edges a plate at 36 mm, radius 2, margins of 0.55 and 0.52 mm, 0.049 mm long). Not a + * side under SHORT, 0.03 mm: its middle would stand a few welds (VTOL, 2 microns) from its + * ends, and a corner that stands out by a hair leaves two of them beside the arc's point + * (0.01 mm each at margins of 1.1682 mm, where the weld read 6 to 8 open edges). + * + * And the clipped shape starts where earTriangulate wants it to. That takes the ear at the + * first point again and again, so it fans every triangle of a convex shape from its last + * point. When the corner stands out by a hair, the clip leaves a side a few hundredths of + * a millimetre long beside it (0.023 mm at margins of 1.71 and 0.72 mm, where it stands out + * 0.010), and a fan from the far end of the boss's side that meets it makes a triangle + * 12.5 mm long and 0.012 wide. A plane of a pocket's cut crosses that triangle's long edge + * and the side 2.4 microns apart at a point 2 mm along, the weld keeps two, and the wall + * takes one point and the cap the other: 3 open edges. That happened to 5 of the 142 + * corners that stood out 0.005 to 0.02 mm in a scan of margins at 42 mm, and snapping the + * clip's points to their neighbours only moved which. So the shape is wound counter- + * clockwise, as earTriangulate takes it, and ends at its point farthest from the new + * ones, which is the apex: the triangles the new points make are across the shape from + * it, and none of them has a long edge along a side. */ +function clipToPlate(pts, outline) { + const clipped = clipToConvex(pts, outline); + if (!clipped || clipped === pts) return pts; + const FLUSH = 0.01, SHORT = 0.03; + const gap = (p, q) => Math.hypot(p[0] - q[0], p[1] - q[1]); + // how far the shape stands out: along the normal of the chord it is farthest past + const beyond = Math.max(...outline.map((a, k) => { + const b = outline[(k + 1) % outline.length]; + return Math.max(...pts.map((p) => -((b[0]-a[0]) * (p[1]-a[1]) - (b[1]-a[1]) * (p[0]-a[0])) / gap(a, b))); + })); + if (beyond < FLUSH) return pts; + const along = (a, b, p) => Math.abs((b[0]-a[0]) * (p[1]-a[1]) - (b[1]-a[1]) * (p[0]-a[0])) < 1e-7 * gap(a, b); + // the outline's chords: the sides that run along neither x nor y + const chords = []; + outline.forEach((a, k) => { + const b = outline[(k + 1) % outline.length]; + if (Math.abs(a[0] - b[0]) > 1e-9 && Math.abs(a[1] - b[1]) > 1e-9) chords.push([a, b]); + }); + const halved = []; + clipped.forEach((p, k) => { + const q = clipped[(k + 1) % clipped.length]; + halved.push(p); + if (gap(p, q) >= SHORT && chords.some(([a, b]) => along(a, b, p) && along(a, b, q))) + halved.push([(p[0] + q[0]) / 2, (p[1] + q[1]) / 2]); + }); + const ccw = polyArea2D(halved) > 0 ? halved : halved.reverse(); + const fresh = ccw.filter((p) => !pts.some((q) => gap(p, q) < 1e-9)); + const mid = [fresh.reduce((t, p) => t + p[0], 0) / fresh.length, fresh.reduce((t, p) => t + p[1], 0) / fresh.length]; + let far = 0; + ccw.forEach((p, i) => { if (gap(p, mid) > gap(ccw[far], mid)) far = i; }); + return ccw.slice(far + 1).concat(ccw.slice(0, far + 1)); +} + // ---------- layout ---------- /* The biggest grid the page will build in one go, a 1.26 m square at the spec pitch. It lives here because computeLayout has to honour it too (see the 'plates' split); the @@ -3362,15 +3461,16 @@ function buildPiece(cfg, layout, piece, onStatus) { // one cutter for every mounting site on the piece, built once and moved into place const cellFastener = ((cfg.magnets || cfg.screws) && solidBase) ? fastenerCutter(cfg, pad - cfg.magnetH, pad + 0.02, H + 0.5) : null; - // the same cutter turned a 28th of a turn, half a facet of its 14-sided bores, for a - // cell whose pockets come out open the first time (see the fastener cut below); built - // the first time one does - let turned = null; - const turnedFastener = () => turned || (turned = cellFastener.map((p) => { + // a cutter turned a 28th of a turn about its axis, half a facet of its 14-sided bores + const turn28 = (cut) => cut.map((p) => { const ca = Math.cos(Math.PI/14), sa = Math.sin(Math.PI/14); const turn = (v) => [v[0]*ca - v[1]*sa, v[0]*sa + v[1]*ca, v[2]]; return { verts: p.verts.map(turn), plane: { n: turn(p.plane.n), w: p.plane.w } }; - })); + }); + // the cells' cutter turned so, for a cell whose pockets come out open the first time + // (see the fastener cut below); built the first time one does + let turned = null; + const turnedFastener = () => turned || (turned = turn28(cellFastener)); // the corners of its walls where they stand on the bottom face, and on the socket floor // (a magnet pocket from above, a screw's shank), about its axis (fanCentre) const wallsAt = (z) => { @@ -3471,10 +3571,13 @@ function buildPiece(cfg, layout, piece, onStatus) { const atL = piece.cellX0 === 0, atR = piece.cellX0 + piece.nx === layout.nx; const atF = piece.cellY0 === 0, atB = piece.cellY0 + piece.ny === layout.ny; const round = { ll: atL && atF, lr: atR && atF, ur: atR && atB, ul: atL && atB }; + // each corner rounded, its point, its arc's centre and radius: for the corner bosses (#86) + const arcs = []; function corner(cx, cy, a0, doRound, key) { const rc = rOf(key); if (!doRound || rc <= 0.01) return [[cx, cy]]; const ccx = cx + (cx < 1 ? rc : -rc), ccy = cy + (cy < 1 ? rc : -rc); + arcs.push({ cx, cy, ox: ccx, oy: ccy, r: rc }); const out = []; for (let k = 0; k <= NARC; k++) { const a = (a0 + 90*k/NARC) * Math.PI/180; @@ -4332,6 +4435,81 @@ function buildPiece(cfg, layout, piece, onStatus) { arc.push([bossW - rIn + rIn*Math.cos(a), bossW - rIn + rIn*Math.sin(a)]); } const onEdge = (x, lim) => Math.abs(x) < 1e-6 || Math.abs(x - lim) < 1e-6; + /* A boss at a rounded corner of the plate follows the corner's arc (#86). Its outer + corner is the cell's, square, and the plate is rounded there, so with no margin or a + small one the corner stood out past the arc: a square tab as tall as the boss, up to + 2.6 mm, r(√2 − 1) out at an outer radius r, 1.66 mm at the default 4 and 0.95 mm with + margins of 0.5 mm. It stays inside once both margins pass 1.17 mm, or once the other + reaches 4 mm when one is 0, and a boss that does is not touched: its bytes are the + ones it had, as are a boss's that stands out by under a hundredth of a millimetre. + The rest are cut to the outline, however a joint has shaped them (clipToPlate). No + housing reaches a boss at a rounded corner of the plate, since a joint's cuts sit + where four cells meet, so the bosses a housing reaches are cut too only to be sure + of it. + * + Cut only where the cut boss is no worse than the square one, and otherwise the boss + is the square one, built as it always was. Two things can make it worse. + * + A pocket that the arc comes near. The pockets sit holeOffset from the cell's centre + whatever the pitch, 2 mm in from the cell's edges at 30 mm, and mountLimits holds a + pocket MOUNT_WALL inside its cell's edges, which the square boss stands on. The arc + cuts across the cell's corner, and below about 34 mm (26 mm and twice the radius) + it passes nearer the site than the edges do: a 1.8 mm magnet at 30 mm, no margin, + a 4.88 mm corner, broke through it, a window in the plate's outer wall 1.2 mm along + the arc and up to 2 mm tall that the square tab had covered, with the mesh closed + round it so that no edge count saw it; and where the pocket's wall came within + microns of the arc's chords its cut came out with faces turned over (6 folds a + plate at 29.5 mm with 0.2 mm margins and at 33 mm with a 6.9 mm corner). So a boss + is cut only where each of its bores, as wide as its widest corner, stays MOUNT_WALL + inside the arc as well, the circle the chords are drawn on, the wall mountLimits + leaves every pocket on the plate; and a joint's cut reaching it MOUNT_WALL clear of + the corner's square. + * + And a cut that comes out worse. The cut boss's cap is fanned another way than the + square one's, across a dozen more points round the arc, and its pocket's planes + cross those spokes a few microns from where something else does: the same lottery + as any pocket's (cutAgain), drawn again. At the defaults with screws, 36.42 mm, + margins of 0, 0.433, 1 and 0, the boss at the plate's corner came out with 3 open + edges on its underside, and cutAgain's tries left them all; so did three plates at + 29.5 to 33 mm with a 2 mm corner, where the square boss had built closed. So the cut + boss is taken only if it comes out closed with nothing turned over: cut again as + cutAgain takes it when it is either, then with its cutter turned a 28th of a turn + (as the cells' are, pocketTries), which closed all four, and if neither comes out + right, or one throws, the boss is the square one, as it always was. A boss is its + own closed shell, so a boss that is closed with nothing turned over leaves its piece + no more open and no more folded than the square one did: shells that each close can + only share an edge an even number of times. They do share edges (the corner cell's + wall stands on the same points of the arc), used four times; see ENGINE.md. */ + // how far a point stands inside the nearest rounded corner's arc, as a circle + const fromArc = (x, y) => { + let d = Infinity; + for (const { cx, cy, ox, oy, r } of arcs) { + const ux = Math.sign(cx - ox), uy = Math.sign(cy - oy), dx = x - ox, dy = y - oy; + d = Math.min(d, dx*ux >= 0 && dy*uy >= 0 ? r - Math.hypot(dx, dy) + : Math.min(Math.hypot(dx - ux*r, dy), Math.hypot(dx, dy - uy*r))); // past its ends + } + return d; + }; + // the widest of a site's bores, out to its corners, built about the origin + const boreR = bossFastener ? Math.max(...bossFastener.flatMap((p) => p.verts.map((v) => Math.hypot(v[0], v[1])))) : 0; + const roomy = (sites, ks) => + sites.every((bs) => fromArc(bs.ccx + bs.sx*off, bs.ccy + bs.sy*off) - boreR > MOUNT_WALL - 1e-6) && + ks.every((k) => { + const [x0, y0, x1, y1] = cutBoxes[k], w = MOUNT_WALL; + return arcs.every(({ cx, cy, ox, oy }) => !meets([x0 - w, y0 - w, x1 + w, y1 + w], + [Math.min(cx, ox), Math.min(cy, oy), Math.max(cx, ox), Math.max(cy, oy)])); + }); + // a boss built by `build` from its square outline, or from that cut to the plate's + const inPlate = (square, sites, build, ks = [], fastener = bossFastener) => { + const clipped = clipToPlate(square, outline); + if (clipped !== square && roomy(sites, ks)) + for (const f of fastener ? [fastener, turn28(fastener)] : [null]) { + let boss; + try { boss = build(clipped, f, true); } catch (e) { continue; } + if (!boss.open && !boss.turned) return boss; + } + return build(square, fastener, false); + }; for (let n = 0; n < all.length; n++) { if (root(n) !== n) continue; const { ccx, ccy, sx, sy, cxr, cyr } = all[n]; @@ -4346,22 +4524,25 @@ function buildPiece(cfg, layout, piece, onStatus) { pts.push([bossW - rIn + rIn*Math.cos(a), bossW - rIn + rIn*Math.sin(a)]); } pts.push([0, bossW]); - const world = pts.map(([u, v]) => [cxr - sx*u, cyr - sy*v]); - let boss = extrudePoly(world, 0, bossH); - /* A boss's pocket is a lottery of its own, so it is cut again (cutAgain) when it - comes out open. Default screws in one-cell pieces left 3 to 6 open edges on a - boss's underside at 22 of the 901 pitches from 34 to 60 mm that take them, and a - 9.1 mm magnet at 39.46 mm left 12 a piece; cut again, none of those leaks. Open - only: a pocket that comes out closed with a face turned over (13 more of those - pitches) has the fold unfoldFinished lays out again, and every one of those - pieces came out watertight and oriented, so it is kept as it was, as is every - pocket that comes out closed. */ - if (bossFastener) { - const pocket = movePolys(bossFastener, ccx + sx*off, ccy + sy*off); - const first = csgSubtract(boss, pocket); - boss = first.open ? cutAgain(boss, pocket, first) : first; - } - shells.push(boss); + shells.push(inPlate(pts.map(([u, v]) => [cxr - sx*u, cyr - sy*v]), [all[n]], (world, fastener, clipped) => { + let boss = extrudePoly(world, 0, bossH); + /* A boss's pocket is a lottery of its own, so it is cut again (cutAgain) when it + comes out open. Default screws in one-cell pieces left 3 to 6 open edges on a + boss's underside at 22 of the 901 pitches from 34 to 60 mm that take them, and a + 9.1 mm magnet at 39.46 mm left 12 a piece; cut again, none of those leaks. Open + only: a pocket that comes out closed with a face turned over (13 more of those + pitches) has the fold unfoldFinished lays out again, and every one of those + pieces came out watertight and oriented, so it is kept as it was, as is every + pocket that comes out closed. A boss cut to a rounded corner is taken only if it + comes out with nothing turned over either (inPlate), so it is cut again for that + too: it has no bytes of its own to keep. */ + if (fastener) { + const pocket = movePolys(fastener, ccx + sx*off, ccy + sy*off); + const first = csgSubtract(boss, pocket); + boss = first.open || clipped && first.turned ? cutAgain(boss, pocket, first) : first; + } + return boss; + })); continue; } /* In the boss's own frame u runs into the cell along x from its corner and v along @@ -4415,43 +4596,44 @@ function buildPiece(cfg, layout, piece, onStatus) { const level = ks.filter((k) => Math.abs(cutTops[k] - bossH) < MOUNT_LEVEL); const top = level.length ? Math.max(...level.map((k) => cutTops[k])) + MOUNT_LEVEL : bossH; const fastener = top === bossH ? bossFastener : fastenerCutter(cfg, top - cfg.magnetH, top + 0.5, top + 0.5); - for (const { outline, sites } of solids) { - let boss = extrudePoly(outline, 0, top); - if (fastener) { - const pockets = [].concat(...sites.map((bs) => - movePolys(fastener, bs.ccx + bs.sx*off, bs.ccy + bs.sy*off))); - boss = cutAgain(boss, pockets, csgSubtract(boss, pockets)); - } - /* cutAgain's tries are not always enough here. A wall puzzle key at 36.92 mm with - screws (a 2 mm hole, a 7.1 x 0.8 head), four cells square, kept a cut with a - face turned over in the pair of bosses by the front edge's key, of A2 and of B2, - after every try: three folds on the bed a piece, about one build in 3,600 of - bosses a joint reaches. Moved two NUDGEs the other way along y it came out - clean. So a cut that every try leaves open or turned is taken again moved two - NUDGEs the other way along one axis, then the other, then both, and the first - that comes out closed with nothing turned over is kept; if none does, the first - stands. Two NUDGEs, not more: each stands as far from the cell's walls as the - first, and none nearer the pockets (see mountLimits). A cut that comes out right - is built exactly as before. */ - const b = flatBox(boss); - const cutAt = (a, c) => ks.flatMap((k) => - meets(cutBoxes[k], b) ? movePolys(fromBelow[k], -a*sx*NUDGE, -c*sy*NUDGE) : []); - const cut = cutAt(2, 2); - if (cut.length) { - const solid = boss; - boss = cutAgain(solid, cut, csgSubtract(solid, cut)); - for (const [a, c] of [[2, -2], [-2, 2], [-2, -2]]) { - if (!boss.open && !boss.turned) break; - let again; - try { - const alt = cutAt(a, c); - again = cutAgain(solid, alt, csgSubtract(solid, alt)); - } catch (e) { continue; } // the first cut stands - if (!again.open && !again.turned) boss = again; + for (const { outline: square, sites } of solids) + shells.push(inPlate(square, sites, (outline, fastener) => { + let boss = extrudePoly(outline, 0, top); + if (fastener) { + const pockets = [].concat(...sites.map((bs) => + movePolys(fastener, bs.ccx + bs.sx*off, bs.ccy + bs.sy*off))); + boss = cutAgain(boss, pockets, csgSubtract(boss, pockets)); } - } - shells.push(boss); - } + /* cutAgain's tries are not always enough here. A wall puzzle key at 36.92 mm with + screws (a 2 mm hole, a 7.1 x 0.8 head), four cells square, kept a cut with a + face turned over in the pair of bosses by the front edge's key, of A2 and of B2, + after every try: three folds on the bed a piece, about one build in 3,600 of + bosses a joint reaches. Moved two NUDGEs the other way along y it came out + clean. So a cut that every try leaves open or turned is taken again moved two + NUDGEs the other way along one axis, then the other, then both, and the first + that comes out closed with nothing turned over is kept; if none does, the first + stands. Two NUDGEs, not more: each stands as far from the cell's walls as the + first, and none nearer the pockets (see mountLimits). A cut that comes out right + is built exactly as before. */ + const b = flatBox(boss); + const cutAt = (a, c) => ks.flatMap((k) => + meets(cutBoxes[k], b) ? movePolys(fromBelow[k], -a*sx*NUDGE, -c*sy*NUDGE) : []); + const cut = cutAt(2, 2); + if (cut.length) { + const solid = boss; + boss = cutAgain(solid, cut, csgSubtract(solid, cut)); + for (const [a, c] of [[2, -2], [-2, 2], [-2, -2]]) { + if (!boss.open && !boss.turned) break; + let again; + try { + const alt = cutAt(a, c); + again = cutAgain(solid, alt, csgSubtract(solid, alt)); + } catch (e) { continue; } // the first cut stands + if (!again.open && !again.turned) boss = again; + } + } + return boss; + }, ks, fastener)); } /* The socket's rim, cut away under a boss whose pocket it stands in (#83). With nothing under the sockets a cell is its rim alone, from the bed up: 2.85 mm in from the diff --git a/sw.js b/sw.js index 09c78df..b21fca6 100644 --- a/sw.js +++ b/sw.js @@ -52,7 +52,7 @@ load from this site, and the app's icons and manifest, as paths from the root. VERSION is a hash of all of those files, names and bytes, and of this script as it is served, FILES filled in and everything else but VERSION itself. */ -const VERSION = "9c3c3ab35a1f"; +const VERSION = "f53559bbfe6d"; const FILES = [ "./", "bins/", diff --git a/test/plate-audit.js b/test/plate-audit.js index 0b8aee1..3a1ad12 100644 --- a/test/plate-audit.js +++ b/test/plate-audit.js @@ -2571,34 +2571,34 @@ console.log('\nthe other limits, built at their ends:'); that leaves in a housing, 3.4 microns thick, is not a fill. Every pocket has to be empty from its mouth to its floor. And the edges two bosses share where they meet, used four times (quarantined above), may not be more than the same plate has with no joint. */ -console.log('\ncorner bosses beside a joint cut from beneath:'); -{ - // along a vertical line, the faces above a height, each counted by which way it faces, - // add up to how many shells that height is inside - const columns = (polys) => { - const cells = new Map(), key = (i, j) => i * 100003 + j; - for (const t of G.polysToTriangles(polys)) { - const xs = t.map((v) => v[0]), ys = t.map((v) => v[1]); - for (let i = Math.floor(Math.min(...xs)); i <= Math.floor(Math.max(...xs)); i++) - for (let j = Math.floor(Math.min(...ys)); j <= Math.floor(Math.max(...ys)); j++) { - if (!cells.has(key(i, j))) cells.set(key(i, j), []); - cells.get(key(i, j)).push(t); - } - } - return (x, y) => { - const cross = []; - for (const [a, b, c] of cells.get(key(Math.floor(x), Math.floor(y))) || []) { - const n = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); - if (Math.abs(n) < 1e-12) continue; - const d = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]); - const l1 = ((b[1] - c[1]) * (x - c[0]) + (c[0] - b[0]) * (y - c[1])) / d; - const l2 = ((c[1] - a[1]) * (x - c[0]) + (a[0] - c[0]) * (y - c[1])) / d; - if (l1 < 0 || l2 < 0 || l1 + l2 > 1) continue; - cross.push([l1 * a[2] + l2 * b[2] + (1 - l1 - l2) * c[2], Math.sign(n)]); +// along a vertical line, the faces above a height, each counted by which way it faces, +// add up to how many shells that height is inside +function columns(polys) { + const cells = new Map(), key = (i, j) => i * 100003 + j; + for (const t of G.polysToTriangles(polys)) { + const xs = t.map((v) => v[0]), ys = t.map((v) => v[1]); + for (let i = Math.floor(Math.min(...xs)); i <= Math.floor(Math.max(...xs)); i++) + for (let j = Math.floor(Math.min(...ys)); j <= Math.floor(Math.max(...ys)); j++) { + if (!cells.has(key(i, j))) cells.set(key(i, j), []); + cells.get(key(i, j)).push(t); } - return (z) => cross.reduce((w, [zc, s]) => zc > z ? w + s : w, 0); - }; + } + return (x, y) => { + const cross = []; + for (const [a, b, c] of cells.get(key(Math.floor(x), Math.floor(y))) || []) { + const n = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); + if (Math.abs(n) < 1e-12) continue; + const d = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]); + const l1 = ((b[1] - c[1]) * (x - c[0]) + (c[0] - b[0]) * (y - c[1])) / d; + const l2 = ((c[1] - a[1]) * (x - c[0]) + (a[0] - c[0]) * (y - c[1])) / d; + if (l1 < 0 || l2 < 0 || l1 + l2 > 1) continue; + cross.push([l1 * a[2] + l2 * b[2] + (1 - l1 - l2) * c[2], Math.sign(n)]); + } + return (z) => cross.reduce((w, [zc, s]) => zc > z ? w + s : w, 0); }; +} +console.log('\ncorner bosses beside a joint cut from beneath:'); +{ const h = 0.3, dz = 0.1; const zs = Array.from({ length: 40 }, (_, k) => (k + 0.5) * dz); const at = { pitch: 42, drawerW: 168, drawerD: 84, bedW: 100, bedD: 400 }; @@ -2906,6 +2906,213 @@ console.log('\ncorner bosses beside a joint cut from beneath:'); } } +/* A corner boss stays inside a rounded corner of the plate (#86). + * + * A corner boss is a quarter square from its cell's corner, and the plate rounds the + * corners it owns. The cell's corner is the plate's only with no margin, but a boss was + * never cut to the arc, so with no margin or a small one its square corner stood out past + * it, a tab as tall as the boss, up to 2.6 mm: r(√2 − 1) out at an outer radius r with no + * margin (1.66 mm at the default 4, 0.83 at 2, 1.99 at 4.8, at every pitch) and 0.95 mm + * with margins of 0.5. It stays inside once both margins pass 1.17 mm, or once the other + * reaches 4 mm when one is none. Nothing above could see it: a square tab on a rounded + * corner is as closed and faces outwards as well as a rounded one, and the rows that build + * corner bosses with no margin and a rounded corner quarantine their edges by name. + * + * So it is measured off the mesh, as how far past the arc the farthest vertex of a piece + * stands in the square of each corner the plate rounds (0 or less is inside). That has to + * be nothing on the page that showed it and with margins of 0.5 mm, and the default + * drawer's 6 and 1 mm are a control. The clip to the arc must not cost the plate an edge + * either: a boss that follows the arc stands on the same points the corner cell's wall + * does, and the plate may not have more edges used four times than it has with square + * corners (clipToPlate), but for the kind named below. One row is the exception to + * "nothing past the outline": margins of 1.17 mm, where the corner stands out 0.0022 mm + * and clipToPlate leaves it, since the clip's points would stand 2.4 microns apart and the + * weld took them for one or two as it came (3 open edges at 34 mm with screws); that row + * allows 0.01 mm and no open edge, as does one beside it with a corner 0.0008 mm out. + * + * And the cut boss has to be no worse than the square one, which neither count shows. Cut, + * a boss's pocket can break through the arc: at 30 mm with a 4.88 mm corner and a 1.8 mm + * magnet the plate had a window in its outer wall, 1.2 mm along the arc and up to 2 mm + * tall, that the square tab had covered, with every edge round it closed. So every row + * reads the wall as well, points just inside the arc that no shell holds with none outside + * them either, and may have none; and may have no open edge and no fold, since a cut boss's + * pocket is a lottery of its own: the defaults with screws at 36.42 mm, and three plates + * with a 2 mm corner at 29.5 to 33 mm, had 3 open edges where the square boss built closed, + * and two whose pocket stood at the arc had 6 folds. buildPiece takes the cut boss only + * where its bores stand MOUNT_WALL inside the arc and it comes out closed with nothing + * turned over, and builds the square one otherwise. The rows marked so must still have + * the square boss (the farthest vertex out by 0.01 mm or more); the four with the lottery + * are cut, their pockets cut again turned a 28th of a turn. + * + * One kind of bad edge is named here and pinned, rather than held to square corners: + * edges a cut boss shares with the shell beside it, the corner cell's wall or a margin's + * region, where both stand on the same points of the arc. Both shells are closed and their + * faces there lie in one plane and face the same way, so each such edge is used four times + * and none once. It comes three ways: a side along a chord under SHORT, which clipToPlate + * leaves whole, so its bottom edge is the wall's (every chord, under a corner of about + * 0.19 mm); a half chord whose middle point lands on a margin region's side, which shares + * it; and the vertical edges at the arc's points from the bed up, where a joint's cut from + * beneath (a wall key's recess, 2 mm, or an H-clip's) splits the cell's walls and a plane + * of the boss's pocket the boss's at the same height. In the sweeps for the review of #90, + * 69 of the 3,469 designs the clip changed had more of them, never open: 60 the last way, + * 7 the first and 2 the second. A row of that kind may have more bad edges than square + * corners, by no more than it has on file, and none open; one with none more is to be + * unpinned. + * + * Last, each measure has to read what it was written to find: the old boss, a square tab + * on the corner, laid on the plate; and a skeleton plate's outer wall, which is open below + * 2.5 mm by design (skeletonCellRegion). */ +console.log('\na corner boss stays inside a rounded plate corner:'); +{ + const NARC = 10; + // every corner these rows round has the plate's radius: none has one of its own or a half cell + const rcOf = (cfg) => Math.min(cfg.outerRadius, ((cfg.topCutoff + cfg.socketRadius) * Math.SQRT2 - + cfg.socketRadius - 0.2) / (Math.SQRT2 - 1)); + // the corners of the plate each piece of a design rounds: the corner, and the way into the plate + const rounded = (r) => r.L.pieces.map((pc) => { + const W = pc.mL + pc.nx * r.cfg.pitch + pc.mR, D = pc.mF + pc.ny * r.cfg.pitch + pc.mB; + const atL = pc.cellX0 === 0, atR = pc.cellX0 + pc.nx === r.L.nx; + const atF = pc.cellY0 === 0, atB = pc.cellY0 + pc.ny === r.L.ny; + if (rcOf(r.cfg) <= 0.01) return []; + return [[atL && atF, 0, 0, 1, 1], [atR && atF, W, 0, -1, 1], [atR && atB, W, D, -1, -1], [atL && atB, 0, D, 1, -1]] + .filter(([owns]) => owns).map(([, cx, cy, sx, sy]) => ({ cx, cy, sx, sy })); + }); + /* The farthest any vertex stands past the outline at a corner the plate rounds, and how many corners there were. + The outline's arc is NARC = 10 chords, which sag inside the circle by up to rc (1 − cos 4.5°), 0.012 mm at + 4: a vertex is past it by how far it stands beyond the chord that covers its angle round the arc's centre. + `extra` is laid on the first piece's corner at the origin. */ + const past = (r, extra) => { + const rc = rcOf(r.cfg), step = 90 / NARC; + let worst = 0, corners = 0; + rounded(r).forEach((cs, i) => { + for (const { cx, cy, sx, sy } of cs) { + corners++; + const ox = cx + sx*rc, oy = cy + sy*rc; + for (const p of extra && i === 0 && cx === 0 && cy === 0 ? r.pieces[i].concat(extra) : r.pieces[i]) + for (const v of p.verts) { + if ((v[0] - cx) * sx > rc + 1e-9 || (v[1] - cy) * sy > rc + 1e-9) continue; + const a = -sx * (v[0] - ox), b = -sy * (v[1] - oy); // across the quadrant the arc covers + const phi = Math.max(0, Math.atan2(b, a) * 180 / Math.PI), k = Math.min(NARC - 1, Math.floor(phi / step)); + worst = Math.max(worst, Math.hypot(a, b) * Math.cos((phi - (k + 0.5) * step) * Math.PI / 180) - + rc * Math.cos(step / 2 * Math.PI / 180)); + } + } + }); + return { worst, corners }; + }; + /* A window in the plate's outer wall at a rounded corner: a point 0.01 mm inside a chord of the arc, at + every tenth of each chord and every 0.1 mm up, that no shell holds, with no shell within 3 mm outside it + either (columns, above). The square boss stood outside the arc and covered whatever was behind it. */ + const windows = (r) => { + const rc = rcOf(r.cfg); + let n = 0; + rounded(r).forEach((cs, i) => { + if (!cs.length) return; + const A = columns(r.pieces[i]); + let top = 0; + for (const p of r.pieces[i]) for (const v of p.verts) top = Math.max(top, v[2]); + for (const { cx, cy, sx, sy } of cs) { + // the arc's points as buildPiece lays them, round the quadrant that faces the corner + const ox = cx + sx*rc, oy = cy + sy*rc, a0 = Math.atan2(-sy, -sx) - Math.PI / 4; + const at = (k) => [ox + rc * Math.cos(a0 + k * Math.PI / 2 / NARC), oy + rc * Math.sin(a0 + k * Math.PI / 2 / NARC)]; + for (let k = 0; k < NARC; k++) { + const a = at(k), b = at(k + 1), mx = (a[0] + b[0]) / 2 - ox, my = (a[1] + b[1]) / 2 - oy; + const nx = -mx / Math.hypot(mx, my), ny = -my / Math.hypot(mx, my); // into the plate + for (let f = 0.05; f < 1; f += 0.1) { + const px = a[0] + f * (b[0] - a[0]), py = a[1] + f * (b[1] - a[1]); + const inside = A(px + 0.01 * nx, py + 0.01 * ny); + let outside = null; + for (let z = 0.05; z < top; z += 0.1) { + if (inside(z) >= 1) continue; + outside = outside || Array.from({ length: 30 }, (_, s) => A(px - (0.02 + 0.1 * s) * nx, py - (0.02 + 0.1 * s) * ny)); + if (!outside.some((col) => col(z) >= 1)) n++; + } + } + } + } + }); + return n; + }; + const page = { pitch: 42, drawerW: 84, drawerD: 84, bedW: 256, bedD: 256, connector: 'none', baseMode: 'bosses' }; + const ROWS = [ + ['the page: 84 x 84 mm at 42, no margin, magnets', { ...page, magnets: true }], + ['0.5 mm margins, a 4.8 mm corner, 3 x 2 in two pieces at 36, dovetails, magnets and screws', + { pitch: 36, drawerW: 109, drawerD: 73, mLeft: 0.5, mRight: 0.5, mFront: 0.5, mBack: 0.5, bedW: 76, bedD: 400, + connector: 'dovetail', baseMode: 'bosses', magnets: true, screws: true, outerRadius: 4.8 }], + ['no margin at 34.5, screws, whose pockets run in under the rim', + { ...page, pitch: 34.5, drawerW: 69, drawerD: 69, screws: true }], + ['the default drawer\'s margins, 6 and 1 mm, magnets (control)', + { ...page, drawerW: 96, drawerD: 86, mLeft: 6, mRight: 6, mFront: 1, mBack: 1, magnets: true }], + // the corner stands out 0.0022 mm, under the 0.01 clipToPlate leaves: clipped, it had 3 open edges here + ['margins of 1.17 mm at 34 mm, screws, the corner a hair out (left as it is)', + { ...page, pitch: 34, drawerW: 70.34, drawerD: 70.34, mLeft: 1.17, mRight: 1.17, mFront: 1.17, mBack: 1.17, screws: true }, + { slack: 0.01 }], + // cut, with its pocket cut again turned a 28th of a turn: cut first, the boss had 3 open edges on its underside + ['the defaults with screws at 36.42 mm, margins of 0, 0.433, 1 and 0, tight', + { ...page, pitch: 36.42, drawerW: 73.273, drawerD: 73.84, mRight: 0.433, mFront: 1, tolerance: 'tight', + screws: true, screwHoleD: 3, screwHeadD: 5.3 }], + // and its back right corner 0.0008 mm out, which clipToPlate leaves + ['a 2 mm corner at 30 mm, a 1.8 mm magnet, margins of 1, 0, 0 and 1.99', + { ...page, pitch: 30, drawerW: 61, drawerD: 61.99, mLeft: 1, mBack: 1.99, outerRadius: 2, magnets: true, magnetD: 1.8 }, + { slack: 0.01 }], + ['a 2 mm corner at 33 mm, cutoff 1, a 1.1 mm screw under a 3.5 mm head, margins of 1, 0, 0.575 and 1, tight', + { ...page, pitch: 33, drawerW: 67, drawerD: 67.575, mLeft: 1, mFront: 0.575, mBack: 1, outerRadius: 2, topCutoff: 1, + tolerance: 'tight', screws: true, screwHoleD: 1.1, screwHeadD: 3.5 }], + ['a 2 mm corner at 29.5 mm, a 1.2 mm magnet, margins of 1, 0.963, 1 and 0, loose', + { ...page, pitch: 29.5, drawerW: 60.963, drawerD: 60, mLeft: 1, mRight: 0.963, mFront: 1, outerRadius: 2, + tolerance: 'loose', magnets: true, magnetD: 1.2 }], + // left square: the pocket comes nearer the arc than MOUNT_WALL (clipped, a window, or folds beside one) + ['a 4.88 mm corner at 30 mm, a 1.8 mm magnet, no margin (left square: the pocket reaches the arc)', + { ...page, pitch: 30, drawerW: 60, drawerD: 60, outerRadius: 4.88, magnets: true, magnetD: 1.8 }, { square: true }], + ['a 4.88 mm corner at 29.5 mm, margins of 0.2 mm, a 1.3 mm magnet (left square)', + { ...page, pitch: 29.5, drawerW: 59.4, drawerD: 59.4, mLeft: 0.2, mRight: 0.2, mFront: 0.2, mBack: 0.2, + outerRadius: 4.88, magnets: true, magnetD: 1.3 }, { square: true }], + ['the largest corner at 33 mm, cutoff 1, a 4.8 mm magnet, no margin (left square)', + { ...page, pitch: 33, drawerW: 66, drawerD: 66, outerRadius: 1000, topCutoff: 1, magnets: true, magnetD: 4.8 }, + { square: true }], + // the kind named above, each way it comes, at what it has on file + ['a 0.09 mm corner at 40.76 mm, a 0.8 mm margin, magnets and screws (every chord under SHORT)', + { ...page, pitch: 40.76, drawerW: 82.318, drawerD: 81.52, mLeft: 0.798, outerRadius: 0.09, magnets: true, screws: true, + magnetD: 8, screwHoleD: 5.8, screwHeadD: 6.3 }, { known: 20 }], + ['a 2 mm corner at 36 mm, margins of 0.2 and 0.3, magnets and screws (a half chord on a margin\'s side)', + { ...page, pitch: 36, drawerW: 72.2, drawerD: 72.3, mLeft: 0.2, mBack: 0.3, outerRadius: 2, magnets: true, screws: true, + magnetD: 5 }, { known: 2 }], + ['two pieces at 40 mm, a wall bowtie from beneath, a 4.88 mm corner, 6 x 2 mm magnets (split at 2 mm)', + { ...page, pitch: 40, drawerW: 80, drawerD: 80, bedW: 45, bedD: 400, connector: 'bowtie', keyMount: 'wall', + keyInsert: 'bottom', outerRadius: 4.88, magnets: true, magnetD: 6, magnetH: 2 }, { known: 24 }], + ]; + const KIND = 'a cut boss and the shell beside it on the same points of the arc, edges used four times'; + for (const [name, over, how = {}] of ROWS) { + const r = buildAll(over), flat = buildAll({ ...over, outerRadius: 0 }); + const m = past(r), win = windows(r); + const turned = r.pieces.map(checkOrientation).filter((o) => !o.ok); + const more = r.bad - flat.bad; + const shape = how.square ? m.worst >= 0.01 : m.worst < (how.slack || 1e-6); + const edges = how.known ? more > 0 && more <= how.known : more <= 0; + const good = shape && m.corners >= 4 && !r.open && !turned.length && !win && edges; + const note = !how.known ? '' : more <= 0 ? ' NOW CLEAN: unpin it' : more > how.known + ? ` WORSE than the ${how.known} more on file for: ${KIND}` : ` known: ${KIND}`; + console.log(` ${name}: ${m.corners} rounded corners, the farthest vertex ${m.worst < 1e-6 ? 'inside' : m.worst.toFixed(4) + ' mm past'} the outline; ` + + `${r.bad} bad edges against ${flat.bad} with square corners, ${r.open} open` + + `${turned.length ? ', ' + orientationNote(turned[0]) : ''}, ${win} points of wall open${note}${good ? '' : ' FAIL'}`); + if (!good) bad++; + } + // the old boss: a square tab on the corner, 3 mm a side and 2.6 tall, laid on the page's plate + const pg = buildAll(ROWS[0][1]); + const old = past(pg, G.extrudePoly([[0, 0], [3, 0], [3, 3], [0, 3]], 0, 2.6)); + // the tab's corner is the diagonal's point of the arc, a vertex of the chords: r(√2 − 1) from it, along a chord's normal + const want = rcOf(pg.cfg) * (Math.SQRT2 - 1) * Math.cos(Math.PI / (4 * NARC)); + const live = Math.abs(old.worst - want) < 1e-3; + console.log(` the measure on the old boss, a square tab laid on the corner: ${old.worst.toFixed(3)} mm past the outline of a ${rcOf(pg.cfg)} mm corner, ` + + `r(√2 − 1) cos 4.5° = ${want.toFixed(3)}${live ? '' : ' THE MEASURE DOES NOT SEE IT'}`); + if (!live) bad++; + // and the wall's: a skeleton plate leaves its outer wall open under the socket's straight walls on purpose + const sk = windows(buildAll({ ...page, baseMode: 'solid', plateStyle: 'skeleton' })); + console.log(` the wall's measure on a skeleton plate, open below 2.5 mm by design: ${sk} points of wall open` + + (sk ? '' : ' THE MEASURE DOES NOT SEE IT')); + if (!sk) bad++; +} + /* The fit clearance at its ceiling, for every joint and every pitch band. * * connClrCeiling's answer depends on the joint, which way its key goes in and the pitch,