From 6d0d4c54feaf05e2763731a89fb55cfa5be3ceeb Mon Sep 17 00:00:00 2001 From: mlugo-apx <37851545+mlugo-apx@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:22:05 -0400 Subject: [PATCH] Gridfinity: open a bin on any side, with no wall and no stacking lip there, so bins side by side make one run (#682) Co-Authored-By: Claude Opus 5.5 --- .agents/skills/layerling-mcp-skill/SKILL.md | 2 +- .claude/skills/layerling-mcp-skill/SKILL.md | 2 +- apps/web/src/components/WorkplaneViewport.tsx | 12 + .../components/workplane/ShapeInspector.tsx | 24 +- apps/web/src/lib/cadModifierTypes.ts | 4 + apps/web/src/lib/cadProfileExtrusion.ts | 4 + apps/web/src/lib/editorMeshCore.ts | 4 + apps/web/src/lib/gridfinityGeometry.ts | 252 ++++++++++++---- apps/web/src/lib/gridfinitySolid.ts | 58 +++- apps/web/src/lib/lylProject.ts | 2 +- apps/web/src/lib/mcpShapeSettings.ts | 2 +- apps/web/src/lib/messages.ar.ts | 5 + apps/web/src/lib/messages.cs.ts | 5 + apps/web/src/lib/messages.da.ts | 5 + apps/web/src/lib/messages.de.ts | 5 + apps/web/src/lib/messages.en.ts | 5 + apps/web/src/lib/messages.es.ts | 5 + apps/web/src/lib/messages.fr.ts | 5 + apps/web/src/lib/messages.hu.ts | 5 + apps/web/src/lib/messages.it.ts | 5 + apps/web/src/lib/messages.nl.ts | 5 + apps/web/src/lib/messages.pl.ts | 5 + apps/web/src/lib/messages.pt-br.ts | 5 + apps/web/src/lib/messages.ru.ts | 5 + apps/web/src/lib/messages.sl.ts | 5 + apps/web/src/lib/messages.tr.ts | 5 + apps/web/src/lib/shapeCatalog.ts | 8 + apps/web/src/lib/shapeDefaults.ts | 2 +- apps/web/src/lib/workplaneSettings.ts | 4 + apps/web/src/lib/workplaneShapes.ts | 4 + apps/web/src/types/layerling.ts | 16 + docs/CHANGELOG.md | 2 + docs/guide/ar/09-threads-and-mechanics.md | 2 + docs/guide/cs/09-threads-and-mechanics.md | 2 + docs/guide/de/09-gewinde-und-mechanik.md | 2 + docs/guide/en/09-threads-and-mechanics.md | 2 + docs/guide/es/09-threads-and-mechanics.md | 2 + docs/guide/fr/09-threads-and-mechanics.md | 2 + docs/guide/hu/09-threads-and-mechanics.md | 2 + docs/guide/nl/09-schroefdraad-en-mechanica.md | 2 + docs/guide/pl/09-threads-and-mechanics.md | 2 + docs/guide/ru/09-threads-and-mechanics.md | 2 + docs/guide/sl/09-navoji-in-mehanika.md | 2 + docs/skills/layerling-mcp-skill/SKILL.md | 2 +- docs/whatsNew-unreleased.json | 13 +- scripts/layerling-mcp-tools.mjs | 4 + tests/e2e/cadGridfinityShape.e2e.ts | 155 +++++++++- tests/e2e/gridfinityOpenSidesPanel.e2e.ts | 58 ++++ tests/unit/gridfinityGeometry.test.ts | 273 ++++++++++++++++++ 49 files changed, 931 insertions(+), 73 deletions(-) create mode 100644 tests/e2e/gridfinityOpenSidesPanel.e2e.ts diff --git a/.agents/skills/layerling-mcp-skill/SKILL.md b/.agents/skills/layerling-mcp-skill/SKILL.md index 2c768326f..b8d22418b 100644 --- a/.agents/skills/layerling-mcp-skill/SKILL.md +++ b/.agents/skills/layerling-mcp-skill/SKILL.md @@ -61,7 +61,7 @@ Useful tools: - `layerling_select_objects`: select ids in the live editor. - `layerling_delete_objects`: delete ids in the live editor, or delete the current selection when ids are omitted. -- `layerling_create_shape`: create any shape the editor has a tile for - `box`, `cube`, `cylinder`, `polygon`, `sphere`, `cone`, `pyramid`, `wedge`, `roundRoof`, `halfSphere`, `torus`, `tube`, `loft`, `text`, `thread`, `spring`, `gridfinity`, `gear` - or a `sketch` extrusion; a `sketch` with `stroke: { width, align, join, cap }` is built as a frame of that width round its outline (align `center`, `inside` or `outside`), as the editor's Stroke does, or with align `grow` as the area widened by that width. Size and everything beyond it are optional and fall back to what the editor uses when the same shape is placed by hand. A `thread` is a rod, a screw, a nut or a tapped hole, set through `threadRole`; its width and depth follow `threadDiameter` and are not given separately. For a standard size pass `threadSize` with its name from the editor's size menu - `M6`, `1/4"-20 UNC`, `G1/2` - instead of `threadDiameter` and `threadPitch`; a `settings` block read back with all three still works, a diameter or pitch that does not match the size is refused. `threadClearance` gives nuts and tapped holes room (default 0.2 mm); `threadBoltClearance` makes a rod or screw that much thinner in diameter (default 0), for a printed bolt that has to go into a metal nut. `threadProfile` `custom` is a symmetric trapezoid you shape yourself: `threadFlankAngle` (10 to 90 degrees between the two flanks, default 60) and `threadDepth` (mm, outer minus core radius; the core diameter is `threadDiameter` minus twice that), for example 90 degrees and a depth of 0.4 x the pitch for a thread that prints without supports. `threadAxialClearance` (default 0, at most a fifth of the pitch) gives nuts and tapped holes play along the axis: each tooth of the part gets that much thinner while the diameter stays, so a printed thread can turn freely without rattling sideways - try `threadClearance` 0.2 with `threadAxialClearance` 0.2. A `spring` winds right-hand like a usual compression spring; `springHand: "left"` winds it the other way. A G size is a parallel Whitworth pipe thread (BSPP, ISO 228-1, as on fittings for water, gas, hydraulics and pneumatics; G1 is 33.249 mm across with 11 threads per inch) and turns a `v` profile into `whitworth`, the 55 degree profile with rounded crest and root; an M, UNC or UNF size turns `whitworth` back into `v`. A cylinder without `sides` picks its own side count from the diameter, the way the editor does. A `teardrop` is the cross-section of a horizontal hole that prints without supports: `width` is the diameter of its round part, `depth` its length along the hole and `height` the full height up to the tip (about 1.207 x width gives the usual 90 degree tip; the editor's tip angle is derived from it). A `counterbore` (round head pocket, `screwHoleHeadDepth`) or `countersink` (cone, `screwHoleAngle`, 90 for a countersunk screw) is a cutter for a screw head: `width` is the head diameter, `screwHoleShaft` the shaft diameter, `height` the whole length, head end on top; set `hole` and let its top end a little above the surface. A `doubleCountersink` is a hole chamfered on both faces (#392): `width` is the head diameter at both ends, `screwHoleShaft` the shaft between the two cones, `screwHoleAngle` the cone angle (90 default), `height` the whole length; if two full cones do not fit, they meet in the middle at that angle and the shaft stays wider than set. Push it through a part as a `hole`. A `hinge` is a print-in-place hinge lying open flat: `width` is its length along the axis, `depth` both leaves together, `height` the knuckle diameter; `hingeKnuckles` (odd, default 5), `hingePinDiameter`, `hingeLeafThickness` and `hingeClearance` (default 0.4 mm, raise it if a printer fuses the parts) set the rest, and it prints in one piece; `hingePinless: true` leaves the printed pin out and runs the bore through every knuckle, for a pin of your own; `hingeAngle` (0 to 180, default 0) folds the second leaf up about the pin, 0 lying flat, 180 closed. A `gridfinity` is a bin or a baseplate for the Gridfinity storage system, to the published measures so it fits parts printed from other designs: `gridfinityType` `bin` (default) or `baseplate`. Its size is whole grid units of 42 mm, and any `width`, `depth` or `height` is rounded to them: a bin is 42 x units - 0.5 across and deep (41.5, 83.5 ... up to 6 units) and 7 mm per height unit (up to 20) plus 4.4 for the stacking lip (a 3-unit bin is 25.4 tall); a baseplate is 42 x units across and deep and 5 tall. On a bin, `gridfinityLip` (default true) is the stacking lip and `gridfinityMagnets` (default false) adds holes for 6 x 2 mm magnets under each foot; a baseplate has none. `gridfinityHalfGrid` (default false) puts a bin on a half-size grid: width and depth count cells of 21 mm (21 x units - 0.5, up to 12 units), each with its own foot, four to an ordinary baseplate's socket; with magnets each foot gets one hole. A bin of 3 height units or more takes label shelves along its inner walls (their top 0.85 mm below 7 mm per height unit, clear of the feet of a bin stacked on the lip): `gridfinityLabelBack`, `gridfinityLabelFront`, `gridfinityLabelLeft` and `gridfinityLabelRight` (default false, any together; where two meet they join), each with its own width, length and offset, as in `gridfinityLabelBackWidth` (mm out from the wall, default 14, at most about 17 in a bin of 3 units), `gridfinityLabelBackLength` (0, the default, runs it from wall to wall) and `gridfinityLabelBackOffset` (moves a set length along its wall from the middle; positive is +x on the back and front walls and +z on the sides), and likewise for Front, Left and Right; and `gridfinityLabelCorner` `round` (default) or `chamfer` with `gridfinityLabelCornerSize` (default 2, 0 for sharp corners) for the corners where a shelf meets a wall or another shelf. Any bin of 2 height units or more takes finger scoops, the floor rising along a quarter circle into the wall: `gridfinityScoopBack`, `gridfinityScoopFront`, `gridfinityScoopLeft` and `gridfinityScoopRight` (default false, any together), with one `gridfinityScoopRadius` (mm, at least 3.5; unset, it is half the height from the floor up to the lip's support, or to the top without the lip: 6.7 in a bin of 3 units with the lip); every floor edge of the bin is then rounded by 2.8 mm. `gridfinityFloorHeight` (mm from the underside, default 5.95) raises the floor of a bin, solid up to it, for a blank to cut shapes into: at most up to the lip's support (19.35 in a bin of 3 units), or the top without the lip; scoops start on it and shelves can reach less far out above it; both are left out when it leaves them no room. `gridfinityCompartmentsX` and `gridfinityCompartmentsZ` (default 1) divide a bin of 2 height units or more into compartments with dividers of the full height (their top 0.85 mm below 7 mm per height unit, like the shelves), up to 4 per grid unit each way and 40 in all, each compartment at least 6 mm wide; `gridfinityDividerThickness` is mm, 0.8 to 3 (default 1.2), and `gridfinityDividerPositionsX`/`gridfinityDividerPositionsZ` place the dividers in mm from the inside of the left or back wall to each divider's middle (unset or `[]`, evenly spaced; once set, that axis keeps them in mm when the bin is resized; giving a count puts that axis's dividers back evenly spaced unless its positions are given in the same call). Scoops repeat in every compartment against the wall or divider on their side, made smaller in a narrow compartment and left out where even 3.5 mm does not fit. A divided bin of 3 height units or more takes `gridfinityCompartmentLabels` instead of the wall shelves: a list of `{ column, row, side, length, align, width }`, one per compartment (column 0 the left, row 0 the back; side `back`, `front`, `left` or `right`, the wall or divider it stands against; length 10 to 100 per cent of that side; align `start`, `middle` or `end`; width mm, default 14), with the shelves' corner settings. A bin is an exact body, so the edge tool and STEP work on it. A `loft` ("Übergang" in German) joins an outline at the bottom to another at the top with a straight-ruled wall - a hose adapter, a square fan onto a round duct, a stand: `loftBottomOutline` and `loftTopOutline` are `round` (circle or ellipse), `rectangle` (corners rounded by `loftBottomCorner`/`loftTopCorner`) or `polygon` (`loftBottomSides`/`loftTopSides`), `loftBottomWidth`/`loftBottomDepth` and `loftTopWidth`/`loftTopDepth` size the ends, `loftOffsetX`/`loftOffsetZ` move the top sideways (the bottom stays put), and `loftWall` above 0 makes it a tube open at both ends. `loftSmooth` (0 to 1, default 0) lets the sides follow an S-curve between the ends instead of a straight line, for a hose adapter that flares smoothly; `loftTwist` turns the section evenly from bottom to top about the vertical (degrees, up to 360 either way), and `loftTiltX`/`loftTiltZ` tilt the top end (degrees, up to 45: positive raises its front +z edge / its right +x side); the sections tilt evenly on the way up and the highest point stays at the height. Its width and depth are what both ends need together and are not given separately; `settings` reports the ends as the body shows them. It is an exact body, so the edge tool and STEP work on it. A `gear` gets involute teeth that mesh (`gearProfile: "involute"`, the default for a new gear; `"simple"` is the plain straight teeth older designs have, set by `toothSize` and `toothWidth`). An involute gear is set like a gear generator: its `width` and `depth` are its outside diameter, module x (`teeth` + 2), so module 1.5 with 20 teeth is 33 - give both, and change them with `teeth` to keep the module. `gearPressureAngle` (default 20) and `gearBacklash` (play of the pair in mm, default 0.2, half taken off each gear) set the rest. `gearType` is `spur`, `helical`, `herringbone`, `bevel`, `internal` or `rack`: `herringbone` is two helical halves turning opposite ways (centres itself on its shaft, no axial thrust; same `helixAngle` and `helixQuality` as `helical`); `internal` is a ring gear with its teeth pointing in, the outer wheel of a planetary set - its width and depth are its outside, module x (teeth + 2.5) + 2 x `gearRim` (the rim round the teeth, default 3), and a pinion of the same module runs inside it at module x (teeth - pinion teeth) / 2; `rack` is a bar along x with its teeth towards +z, width teeth x pi x module so racks line up end to end, depth the bar's thickness (free above the teeth plus 0.5), and a gear rolls on it with its centre its teeth x module / 2 in front of the pitch line, a module behind the tips. Ring gear and rack take involute or round teeth, never simple ones, and have no centre hole. A `knurl` with `knurlPattern: "round"` has its straight grooves and ridges as a smooth wave of arcs - a grip for a knob whose number of grooves (`knurlCount`), depth and end chamfer are free, unlike a gear's. `gearProfile: "round"` gives round teeth made of arcs instead, like Tinkercad's Useful gear - forgiving on small printed gears and a smooth grip on a knob; they stand lower, so a round gear's outside diameter is module x (`teeth` + 1.2), and round gears mesh with round ones of the same module (no pressure angle, backlash as above). Two gears mesh with the same module and pressure angle at a centre distance of module x (teeth1 + teeth2) / 2 between their centres; for helical gears `helixAngle` is then the true helix angle, and a meshing pair takes the same angle with opposite signs. A `slot` (the capsule) can have one end smaller than the other, a belt guard round two pulleys: `slotEndRatio` is the small end's diameter as a share of the large one (0.1 to 1, default 1 for two equal ends). The large end sits at the start of the long side (-x, or -z when standing), its diameter is the short side, the small end touches the far end, and the sides run as tangents to both, so the centre distance is the long side less both radii. It stays an exact body. Every shape except gear, thread, spring, gridfinity, hinge, pyramid, bentTube, loft, teardrop, counterbore, countersink and ruler can be tapered through `taperTopWidth`, `taperTopDepth`, `taperBottomWidth` and `taperBottomDepth`, and twisted or leaned through `extrudeTwist`, `extrudeTopOffsetX` and `extrudeTopOffsetZ`; naming one value of a face pins the other one as it stands. +- `layerling_create_shape`: create any shape the editor has a tile for - `box`, `cube`, `cylinder`, `polygon`, `sphere`, `cone`, `pyramid`, `wedge`, `roundRoof`, `halfSphere`, `torus`, `tube`, `loft`, `text`, `thread`, `spring`, `gridfinity`, `gear` - or a `sketch` extrusion; a `sketch` with `stroke: { width, align, join, cap }` is built as a frame of that width round its outline (align `center`, `inside` or `outside`), as the editor's Stroke does, or with align `grow` as the area widened by that width. Size and everything beyond it are optional and fall back to what the editor uses when the same shape is placed by hand. A `thread` is a rod, a screw, a nut or a tapped hole, set through `threadRole`; its width and depth follow `threadDiameter` and are not given separately. For a standard size pass `threadSize` with its name from the editor's size menu - `M6`, `1/4"-20 UNC`, `G1/2` - instead of `threadDiameter` and `threadPitch`; a `settings` block read back with all three still works, a diameter or pitch that does not match the size is refused. `threadClearance` gives nuts and tapped holes room (default 0.2 mm); `threadBoltClearance` makes a rod or screw that much thinner in diameter (default 0), for a printed bolt that has to go into a metal nut. `threadProfile` `custom` is a symmetric trapezoid you shape yourself: `threadFlankAngle` (10 to 90 degrees between the two flanks, default 60) and `threadDepth` (mm, outer minus core radius; the core diameter is `threadDiameter` minus twice that), for example 90 degrees and a depth of 0.4 x the pitch for a thread that prints without supports. `threadAxialClearance` (default 0, at most a fifth of the pitch) gives nuts and tapped holes play along the axis: each tooth of the part gets that much thinner while the diameter stays, so a printed thread can turn freely without rattling sideways - try `threadClearance` 0.2 with `threadAxialClearance` 0.2. A `spring` winds right-hand like a usual compression spring; `springHand: "left"` winds it the other way. A G size is a parallel Whitworth pipe thread (BSPP, ISO 228-1, as on fittings for water, gas, hydraulics and pneumatics; G1 is 33.249 mm across with 11 threads per inch) and turns a `v` profile into `whitworth`, the 55 degree profile with rounded crest and root; an M, UNC or UNF size turns `whitworth` back into `v`. A cylinder without `sides` picks its own side count from the diameter, the way the editor does. A `teardrop` is the cross-section of a horizontal hole that prints without supports: `width` is the diameter of its round part, `depth` its length along the hole and `height` the full height up to the tip (about 1.207 x width gives the usual 90 degree tip; the editor's tip angle is derived from it). A `counterbore` (round head pocket, `screwHoleHeadDepth`) or `countersink` (cone, `screwHoleAngle`, 90 for a countersunk screw) is a cutter for a screw head: `width` is the head diameter, `screwHoleShaft` the shaft diameter, `height` the whole length, head end on top; set `hole` and let its top end a little above the surface. A `doubleCountersink` is a hole chamfered on both faces (#392): `width` is the head diameter at both ends, `screwHoleShaft` the shaft between the two cones, `screwHoleAngle` the cone angle (90 default), `height` the whole length; if two full cones do not fit, they meet in the middle at that angle and the shaft stays wider than set. Push it through a part as a `hole`. A `hinge` is a print-in-place hinge lying open flat: `width` is its length along the axis, `depth` both leaves together, `height` the knuckle diameter; `hingeKnuckles` (odd, default 5), `hingePinDiameter`, `hingeLeafThickness` and `hingeClearance` (default 0.4 mm, raise it if a printer fuses the parts) set the rest, and it prints in one piece; `hingePinless: true` leaves the printed pin out and runs the bore through every knuckle, for a pin of your own; `hingeAngle` (0 to 180, default 0) folds the second leaf up about the pin, 0 lying flat, 180 closed. A `gridfinity` is a bin or a baseplate for the Gridfinity storage system, to the published measures so it fits parts printed from other designs: `gridfinityType` `bin` (default) or `baseplate`. Its size is whole grid units of 42 mm, and any `width`, `depth` or `height` is rounded to them: a bin is 42 x units - 0.5 across and deep (41.5, 83.5 ... up to 6 units) and 7 mm per height unit (up to 20) plus 4.4 for the stacking lip (a 3-unit bin is 25.4 tall); a baseplate is 42 x units across and deep and 5 tall. On a bin, `gridfinityLip` (default true) is the stacking lip and `gridfinityMagnets` (default false) adds holes for 6 x 2 mm magnets under each foot; a baseplate has none. `gridfinityHalfGrid` (default false) puts a bin on a half-size grid: width and depth count cells of 21 mm (21 x units - 0.5, up to 12 units), each with its own foot, four to an ordinary baseplate's socket; with magnets each foot gets one hole. A bin of 3 height units or more takes label shelves along its inner walls (their top 0.85 mm below 7 mm per height unit, clear of the feet of a bin stacked on the lip): `gridfinityLabelBack`, `gridfinityLabelFront`, `gridfinityLabelLeft` and `gridfinityLabelRight` (default false, any together; where two meet they join), each with its own width, length and offset, as in `gridfinityLabelBackWidth` (mm out from the wall, default 14, at most about 17 in a bin of 3 units), `gridfinityLabelBackLength` (0, the default, runs it from wall to wall) and `gridfinityLabelBackOffset` (moves a set length along its wall from the middle; positive is +x on the back and front walls and +z on the sides), and likewise for Front, Left and Right; and `gridfinityLabelCorner` `round` (default) or `chamfer` with `gridfinityLabelCornerSize` (default 2, 0 for sharp corners) for the corners where a shelf meets a wall or another shelf. Any bin of 2 height units or more takes finger scoops, the floor rising along a quarter circle into the wall: `gridfinityScoopBack`, `gridfinityScoopFront`, `gridfinityScoopLeft` and `gridfinityScoopRight` (default false, any together), with one `gridfinityScoopRadius` (mm, at least 3.5; unset, it is half the height from the floor up to the lip's support, or to the top without the lip: 6.7 in a bin of 3 units with the lip); every floor edge of the bin is then rounded by 2.8 mm. `gridfinityFloorHeight` (mm from the underside, default 5.95) raises the floor of a bin, solid up to it, for a blank to cut shapes into: at most up to the lip's support (19.35 in a bin of 3 units), or the top without the lip; scoops start on it and shelves can reach less far out above it; both are left out when it leaves them no room. `gridfinityOpenBack`, `gridfinityOpenFront`, `gridfinityOpenLeft` and `gridfinityOpenRight` (default false, any together, all four for a tray) take away that side's wall and lip above the floor; the walls beside it run out to the outline and end square, so open bins side by side make one continuous run, the floor runs flat to the edge, and a shelf or scoop on an open side is not built, nor a compartment label. `gridfinityCompartmentsX` and `gridfinityCompartmentsZ` (default 1) divide a bin of 2 height units or more into compartments with dividers of the full height (their top 0.85 mm below 7 mm per height unit, like the shelves), up to 4 per grid unit each way and 40 in all, each compartment at least 6 mm wide; `gridfinityDividerThickness` is mm, 0.8 to 3 (default 1.2), and `gridfinityDividerPositionsX`/`gridfinityDividerPositionsZ` place the dividers in mm from the inside of the left or back wall to each divider's middle (unset or `[]`, evenly spaced; once set, that axis keeps them in mm when the bin is resized; giving a count puts that axis's dividers back evenly spaced unless its positions are given in the same call). Scoops repeat in every compartment against the wall or divider on their side, made smaller in a narrow compartment and left out where even 3.5 mm does not fit. A divided bin of 3 height units or more takes `gridfinityCompartmentLabels` instead of the wall shelves: a list of `{ column, row, side, length, align, width }`, one per compartment (column 0 the left, row 0 the back; side `back`, `front`, `left` or `right`, the wall or divider it stands against; length 10 to 100 per cent of that side; align `start`, `middle` or `end`; width mm, default 14), with the shelves' corner settings. A bin is an exact body, so the edge tool and STEP work on it. A `loft` ("Übergang" in German) joins an outline at the bottom to another at the top with a straight-ruled wall - a hose adapter, a square fan onto a round duct, a stand: `loftBottomOutline` and `loftTopOutline` are `round` (circle or ellipse), `rectangle` (corners rounded by `loftBottomCorner`/`loftTopCorner`) or `polygon` (`loftBottomSides`/`loftTopSides`), `loftBottomWidth`/`loftBottomDepth` and `loftTopWidth`/`loftTopDepth` size the ends, `loftOffsetX`/`loftOffsetZ` move the top sideways (the bottom stays put), and `loftWall` above 0 makes it a tube open at both ends. `loftSmooth` (0 to 1, default 0) lets the sides follow an S-curve between the ends instead of a straight line, for a hose adapter that flares smoothly; `loftTwist` turns the section evenly from bottom to top about the vertical (degrees, up to 360 either way), and `loftTiltX`/`loftTiltZ` tilt the top end (degrees, up to 45: positive raises its front +z edge / its right +x side); the sections tilt evenly on the way up and the highest point stays at the height. Its width and depth are what both ends need together and are not given separately; `settings` reports the ends as the body shows them. It is an exact body, so the edge tool and STEP work on it. A `gear` gets involute teeth that mesh (`gearProfile: "involute"`, the default for a new gear; `"simple"` is the plain straight teeth older designs have, set by `toothSize` and `toothWidth`). An involute gear is set like a gear generator: its `width` and `depth` are its outside diameter, module x (`teeth` + 2), so module 1.5 with 20 teeth is 33 - give both, and change them with `teeth` to keep the module. `gearPressureAngle` (default 20) and `gearBacklash` (play of the pair in mm, default 0.2, half taken off each gear) set the rest. `gearType` is `spur`, `helical`, `herringbone`, `bevel`, `internal` or `rack`: `herringbone` is two helical halves turning opposite ways (centres itself on its shaft, no axial thrust; same `helixAngle` and `helixQuality` as `helical`); `internal` is a ring gear with its teeth pointing in, the outer wheel of a planetary set - its width and depth are its outside, module x (teeth + 2.5) + 2 x `gearRim` (the rim round the teeth, default 3), and a pinion of the same module runs inside it at module x (teeth - pinion teeth) / 2; `rack` is a bar along x with its teeth towards +z, width teeth x pi x module so racks line up end to end, depth the bar's thickness (free above the teeth plus 0.5), and a gear rolls on it with its centre its teeth x module / 2 in front of the pitch line, a module behind the tips. Ring gear and rack take involute or round teeth, never simple ones, and have no centre hole. A `knurl` with `knurlPattern: "round"` has its straight grooves and ridges as a smooth wave of arcs - a grip for a knob whose number of grooves (`knurlCount`), depth and end chamfer are free, unlike a gear's. `gearProfile: "round"` gives round teeth made of arcs instead, like Tinkercad's Useful gear - forgiving on small printed gears and a smooth grip on a knob; they stand lower, so a round gear's outside diameter is module x (`teeth` + 1.2), and round gears mesh with round ones of the same module (no pressure angle, backlash as above). Two gears mesh with the same module and pressure angle at a centre distance of module x (teeth1 + teeth2) / 2 between their centres; for helical gears `helixAngle` is then the true helix angle, and a meshing pair takes the same angle with opposite signs. A `slot` (the capsule) can have one end smaller than the other, a belt guard round two pulleys: `slotEndRatio` is the small end's diameter as a share of the large one (0.1 to 1, default 1 for two equal ends). The large end sits at the start of the long side (-x, or -z when standing), its diameter is the short side, the small end touches the far end, and the sides run as tangents to both, so the centre distance is the long side less both radii. It stays an exact body. Every shape except gear, thread, spring, gridfinity, hinge, pyramid, bentTube, loft, teardrop, counterbore, countersink and ruler can be tapered through `taperTopWidth`, `taperTopDepth`, `taperBottomWidth` and `taperBottomDepth`, and twisted or leaned through `extrudeTwist`, `extrudeTopOffsetX` and `extrudeTopOffsetZ`; naming one value of a face pins the other one as it stands. - `layerling_import_file`: import a file as the import window does (STL, OBJ, 3MF, STEP, SVG or a ZIP of them), content as `text` or `base64`; an SVG with `keepPagePosition: true` keeps its place on its page (viewBox), so files of one page line up; `svgWidth` (mm) scales an SVG or a picture to that width on the way in; a coloured OBJ (vertex colours, or materials with `mtl`) or 3MF (its own colours, or the filaments of a Bambu Studio, OrcaSlicer or PrusaSlicer project) comes in as one body per colour; a 3MF with several objects comes in as one body per object (and per colour within it). Slicer modifiers, negative volumes and support blockers are left out. An SVG comes in as an extruded sketch body (4 mm high, reading from above as in the drawing) whose fill `layerling_update_object` changes with `stroke` and `silhouette`; only one with more than 2500 lines and curves comes in as a fixed mesh. - `layerling_import_mesh`: import STL-style mesh data into the editor. - `layerling_add_font`: add a font of one's own for texts from a TrueType, OpenType or WOFF file as `base64` (with its `fileName`); WOFF2 and .ttc collections are refused. It returns the font's `id` (`custom:...`) and `name`; use either as `font` on a text. The font stays in the browser the editor runs in; a saved design keeps only the outlines of the letters its texts use, so it opens anywhere, and new letters need the font there too. Check a font's licence before passing a design on. Installed system fonts can only be picked by hand in the editor (Chrome and Edge). diff --git a/.claude/skills/layerling-mcp-skill/SKILL.md b/.claude/skills/layerling-mcp-skill/SKILL.md index 2c768326f..b8d22418b 100644 --- a/.claude/skills/layerling-mcp-skill/SKILL.md +++ b/.claude/skills/layerling-mcp-skill/SKILL.md @@ -61,7 +61,7 @@ Useful tools: - `layerling_select_objects`: select ids in the live editor. - `layerling_delete_objects`: delete ids in the live editor, or delete the current selection when ids are omitted. -- `layerling_create_shape`: create any shape the editor has a tile for - `box`, `cube`, `cylinder`, `polygon`, `sphere`, `cone`, `pyramid`, `wedge`, `roundRoof`, `halfSphere`, `torus`, `tube`, `loft`, `text`, `thread`, `spring`, `gridfinity`, `gear` - or a `sketch` extrusion; a `sketch` with `stroke: { width, align, join, cap }` is built as a frame of that width round its outline (align `center`, `inside` or `outside`), as the editor's Stroke does, or with align `grow` as the area widened by that width. Size and everything beyond it are optional and fall back to what the editor uses when the same shape is placed by hand. A `thread` is a rod, a screw, a nut or a tapped hole, set through `threadRole`; its width and depth follow `threadDiameter` and are not given separately. For a standard size pass `threadSize` with its name from the editor's size menu - `M6`, `1/4"-20 UNC`, `G1/2` - instead of `threadDiameter` and `threadPitch`; a `settings` block read back with all three still works, a diameter or pitch that does not match the size is refused. `threadClearance` gives nuts and tapped holes room (default 0.2 mm); `threadBoltClearance` makes a rod or screw that much thinner in diameter (default 0), for a printed bolt that has to go into a metal nut. `threadProfile` `custom` is a symmetric trapezoid you shape yourself: `threadFlankAngle` (10 to 90 degrees between the two flanks, default 60) and `threadDepth` (mm, outer minus core radius; the core diameter is `threadDiameter` minus twice that), for example 90 degrees and a depth of 0.4 x the pitch for a thread that prints without supports. `threadAxialClearance` (default 0, at most a fifth of the pitch) gives nuts and tapped holes play along the axis: each tooth of the part gets that much thinner while the diameter stays, so a printed thread can turn freely without rattling sideways - try `threadClearance` 0.2 with `threadAxialClearance` 0.2. A `spring` winds right-hand like a usual compression spring; `springHand: "left"` winds it the other way. A G size is a parallel Whitworth pipe thread (BSPP, ISO 228-1, as on fittings for water, gas, hydraulics and pneumatics; G1 is 33.249 mm across with 11 threads per inch) and turns a `v` profile into `whitworth`, the 55 degree profile with rounded crest and root; an M, UNC or UNF size turns `whitworth` back into `v`. A cylinder without `sides` picks its own side count from the diameter, the way the editor does. A `teardrop` is the cross-section of a horizontal hole that prints without supports: `width` is the diameter of its round part, `depth` its length along the hole and `height` the full height up to the tip (about 1.207 x width gives the usual 90 degree tip; the editor's tip angle is derived from it). A `counterbore` (round head pocket, `screwHoleHeadDepth`) or `countersink` (cone, `screwHoleAngle`, 90 for a countersunk screw) is a cutter for a screw head: `width` is the head diameter, `screwHoleShaft` the shaft diameter, `height` the whole length, head end on top; set `hole` and let its top end a little above the surface. A `doubleCountersink` is a hole chamfered on both faces (#392): `width` is the head diameter at both ends, `screwHoleShaft` the shaft between the two cones, `screwHoleAngle` the cone angle (90 default), `height` the whole length; if two full cones do not fit, they meet in the middle at that angle and the shaft stays wider than set. Push it through a part as a `hole`. A `hinge` is a print-in-place hinge lying open flat: `width` is its length along the axis, `depth` both leaves together, `height` the knuckle diameter; `hingeKnuckles` (odd, default 5), `hingePinDiameter`, `hingeLeafThickness` and `hingeClearance` (default 0.4 mm, raise it if a printer fuses the parts) set the rest, and it prints in one piece; `hingePinless: true` leaves the printed pin out and runs the bore through every knuckle, for a pin of your own; `hingeAngle` (0 to 180, default 0) folds the second leaf up about the pin, 0 lying flat, 180 closed. A `gridfinity` is a bin or a baseplate for the Gridfinity storage system, to the published measures so it fits parts printed from other designs: `gridfinityType` `bin` (default) or `baseplate`. Its size is whole grid units of 42 mm, and any `width`, `depth` or `height` is rounded to them: a bin is 42 x units - 0.5 across and deep (41.5, 83.5 ... up to 6 units) and 7 mm per height unit (up to 20) plus 4.4 for the stacking lip (a 3-unit bin is 25.4 tall); a baseplate is 42 x units across and deep and 5 tall. On a bin, `gridfinityLip` (default true) is the stacking lip and `gridfinityMagnets` (default false) adds holes for 6 x 2 mm magnets under each foot; a baseplate has none. `gridfinityHalfGrid` (default false) puts a bin on a half-size grid: width and depth count cells of 21 mm (21 x units - 0.5, up to 12 units), each with its own foot, four to an ordinary baseplate's socket; with magnets each foot gets one hole. A bin of 3 height units or more takes label shelves along its inner walls (their top 0.85 mm below 7 mm per height unit, clear of the feet of a bin stacked on the lip): `gridfinityLabelBack`, `gridfinityLabelFront`, `gridfinityLabelLeft` and `gridfinityLabelRight` (default false, any together; where two meet they join), each with its own width, length and offset, as in `gridfinityLabelBackWidth` (mm out from the wall, default 14, at most about 17 in a bin of 3 units), `gridfinityLabelBackLength` (0, the default, runs it from wall to wall) and `gridfinityLabelBackOffset` (moves a set length along its wall from the middle; positive is +x on the back and front walls and +z on the sides), and likewise for Front, Left and Right; and `gridfinityLabelCorner` `round` (default) or `chamfer` with `gridfinityLabelCornerSize` (default 2, 0 for sharp corners) for the corners where a shelf meets a wall or another shelf. Any bin of 2 height units or more takes finger scoops, the floor rising along a quarter circle into the wall: `gridfinityScoopBack`, `gridfinityScoopFront`, `gridfinityScoopLeft` and `gridfinityScoopRight` (default false, any together), with one `gridfinityScoopRadius` (mm, at least 3.5; unset, it is half the height from the floor up to the lip's support, or to the top without the lip: 6.7 in a bin of 3 units with the lip); every floor edge of the bin is then rounded by 2.8 mm. `gridfinityFloorHeight` (mm from the underside, default 5.95) raises the floor of a bin, solid up to it, for a blank to cut shapes into: at most up to the lip's support (19.35 in a bin of 3 units), or the top without the lip; scoops start on it and shelves can reach less far out above it; both are left out when it leaves them no room. `gridfinityCompartmentsX` and `gridfinityCompartmentsZ` (default 1) divide a bin of 2 height units or more into compartments with dividers of the full height (their top 0.85 mm below 7 mm per height unit, like the shelves), up to 4 per grid unit each way and 40 in all, each compartment at least 6 mm wide; `gridfinityDividerThickness` is mm, 0.8 to 3 (default 1.2), and `gridfinityDividerPositionsX`/`gridfinityDividerPositionsZ` place the dividers in mm from the inside of the left or back wall to each divider's middle (unset or `[]`, evenly spaced; once set, that axis keeps them in mm when the bin is resized; giving a count puts that axis's dividers back evenly spaced unless its positions are given in the same call). Scoops repeat in every compartment against the wall or divider on their side, made smaller in a narrow compartment and left out where even 3.5 mm does not fit. A divided bin of 3 height units or more takes `gridfinityCompartmentLabels` instead of the wall shelves: a list of `{ column, row, side, length, align, width }`, one per compartment (column 0 the left, row 0 the back; side `back`, `front`, `left` or `right`, the wall or divider it stands against; length 10 to 100 per cent of that side; align `start`, `middle` or `end`; width mm, default 14), with the shelves' corner settings. A bin is an exact body, so the edge tool and STEP work on it. A `loft` ("Übergang" in German) joins an outline at the bottom to another at the top with a straight-ruled wall - a hose adapter, a square fan onto a round duct, a stand: `loftBottomOutline` and `loftTopOutline` are `round` (circle or ellipse), `rectangle` (corners rounded by `loftBottomCorner`/`loftTopCorner`) or `polygon` (`loftBottomSides`/`loftTopSides`), `loftBottomWidth`/`loftBottomDepth` and `loftTopWidth`/`loftTopDepth` size the ends, `loftOffsetX`/`loftOffsetZ` move the top sideways (the bottom stays put), and `loftWall` above 0 makes it a tube open at both ends. `loftSmooth` (0 to 1, default 0) lets the sides follow an S-curve between the ends instead of a straight line, for a hose adapter that flares smoothly; `loftTwist` turns the section evenly from bottom to top about the vertical (degrees, up to 360 either way), and `loftTiltX`/`loftTiltZ` tilt the top end (degrees, up to 45: positive raises its front +z edge / its right +x side); the sections tilt evenly on the way up and the highest point stays at the height. Its width and depth are what both ends need together and are not given separately; `settings` reports the ends as the body shows them. It is an exact body, so the edge tool and STEP work on it. A `gear` gets involute teeth that mesh (`gearProfile: "involute"`, the default for a new gear; `"simple"` is the plain straight teeth older designs have, set by `toothSize` and `toothWidth`). An involute gear is set like a gear generator: its `width` and `depth` are its outside diameter, module x (`teeth` + 2), so module 1.5 with 20 teeth is 33 - give both, and change them with `teeth` to keep the module. `gearPressureAngle` (default 20) and `gearBacklash` (play of the pair in mm, default 0.2, half taken off each gear) set the rest. `gearType` is `spur`, `helical`, `herringbone`, `bevel`, `internal` or `rack`: `herringbone` is two helical halves turning opposite ways (centres itself on its shaft, no axial thrust; same `helixAngle` and `helixQuality` as `helical`); `internal` is a ring gear with its teeth pointing in, the outer wheel of a planetary set - its width and depth are its outside, module x (teeth + 2.5) + 2 x `gearRim` (the rim round the teeth, default 3), and a pinion of the same module runs inside it at module x (teeth - pinion teeth) / 2; `rack` is a bar along x with its teeth towards +z, width teeth x pi x module so racks line up end to end, depth the bar's thickness (free above the teeth plus 0.5), and a gear rolls on it with its centre its teeth x module / 2 in front of the pitch line, a module behind the tips. Ring gear and rack take involute or round teeth, never simple ones, and have no centre hole. A `knurl` with `knurlPattern: "round"` has its straight grooves and ridges as a smooth wave of arcs - a grip for a knob whose number of grooves (`knurlCount`), depth and end chamfer are free, unlike a gear's. `gearProfile: "round"` gives round teeth made of arcs instead, like Tinkercad's Useful gear - forgiving on small printed gears and a smooth grip on a knob; they stand lower, so a round gear's outside diameter is module x (`teeth` + 1.2), and round gears mesh with round ones of the same module (no pressure angle, backlash as above). Two gears mesh with the same module and pressure angle at a centre distance of module x (teeth1 + teeth2) / 2 between their centres; for helical gears `helixAngle` is then the true helix angle, and a meshing pair takes the same angle with opposite signs. A `slot` (the capsule) can have one end smaller than the other, a belt guard round two pulleys: `slotEndRatio` is the small end's diameter as a share of the large one (0.1 to 1, default 1 for two equal ends). The large end sits at the start of the long side (-x, or -z when standing), its diameter is the short side, the small end touches the far end, and the sides run as tangents to both, so the centre distance is the long side less both radii. It stays an exact body. Every shape except gear, thread, spring, gridfinity, hinge, pyramid, bentTube, loft, teardrop, counterbore, countersink and ruler can be tapered through `taperTopWidth`, `taperTopDepth`, `taperBottomWidth` and `taperBottomDepth`, and twisted or leaned through `extrudeTwist`, `extrudeTopOffsetX` and `extrudeTopOffsetZ`; naming one value of a face pins the other one as it stands. +- `layerling_create_shape`: create any shape the editor has a tile for - `box`, `cube`, `cylinder`, `polygon`, `sphere`, `cone`, `pyramid`, `wedge`, `roundRoof`, `halfSphere`, `torus`, `tube`, `loft`, `text`, `thread`, `spring`, `gridfinity`, `gear` - or a `sketch` extrusion; a `sketch` with `stroke: { width, align, join, cap }` is built as a frame of that width round its outline (align `center`, `inside` or `outside`), as the editor's Stroke does, or with align `grow` as the area widened by that width. Size and everything beyond it are optional and fall back to what the editor uses when the same shape is placed by hand. A `thread` is a rod, a screw, a nut or a tapped hole, set through `threadRole`; its width and depth follow `threadDiameter` and are not given separately. For a standard size pass `threadSize` with its name from the editor's size menu - `M6`, `1/4"-20 UNC`, `G1/2` - instead of `threadDiameter` and `threadPitch`; a `settings` block read back with all three still works, a diameter or pitch that does not match the size is refused. `threadClearance` gives nuts and tapped holes room (default 0.2 mm); `threadBoltClearance` makes a rod or screw that much thinner in diameter (default 0), for a printed bolt that has to go into a metal nut. `threadProfile` `custom` is a symmetric trapezoid you shape yourself: `threadFlankAngle` (10 to 90 degrees between the two flanks, default 60) and `threadDepth` (mm, outer minus core radius; the core diameter is `threadDiameter` minus twice that), for example 90 degrees and a depth of 0.4 x the pitch for a thread that prints without supports. `threadAxialClearance` (default 0, at most a fifth of the pitch) gives nuts and tapped holes play along the axis: each tooth of the part gets that much thinner while the diameter stays, so a printed thread can turn freely without rattling sideways - try `threadClearance` 0.2 with `threadAxialClearance` 0.2. A `spring` winds right-hand like a usual compression spring; `springHand: "left"` winds it the other way. A G size is a parallel Whitworth pipe thread (BSPP, ISO 228-1, as on fittings for water, gas, hydraulics and pneumatics; G1 is 33.249 mm across with 11 threads per inch) and turns a `v` profile into `whitworth`, the 55 degree profile with rounded crest and root; an M, UNC or UNF size turns `whitworth` back into `v`. A cylinder without `sides` picks its own side count from the diameter, the way the editor does. A `teardrop` is the cross-section of a horizontal hole that prints without supports: `width` is the diameter of its round part, `depth` its length along the hole and `height` the full height up to the tip (about 1.207 x width gives the usual 90 degree tip; the editor's tip angle is derived from it). A `counterbore` (round head pocket, `screwHoleHeadDepth`) or `countersink` (cone, `screwHoleAngle`, 90 for a countersunk screw) is a cutter for a screw head: `width` is the head diameter, `screwHoleShaft` the shaft diameter, `height` the whole length, head end on top; set `hole` and let its top end a little above the surface. A `doubleCountersink` is a hole chamfered on both faces (#392): `width` is the head diameter at both ends, `screwHoleShaft` the shaft between the two cones, `screwHoleAngle` the cone angle (90 default), `height` the whole length; if two full cones do not fit, they meet in the middle at that angle and the shaft stays wider than set. Push it through a part as a `hole`. A `hinge` is a print-in-place hinge lying open flat: `width` is its length along the axis, `depth` both leaves together, `height` the knuckle diameter; `hingeKnuckles` (odd, default 5), `hingePinDiameter`, `hingeLeafThickness` and `hingeClearance` (default 0.4 mm, raise it if a printer fuses the parts) set the rest, and it prints in one piece; `hingePinless: true` leaves the printed pin out and runs the bore through every knuckle, for a pin of your own; `hingeAngle` (0 to 180, default 0) folds the second leaf up about the pin, 0 lying flat, 180 closed. A `gridfinity` is a bin or a baseplate for the Gridfinity storage system, to the published measures so it fits parts printed from other designs: `gridfinityType` `bin` (default) or `baseplate`. Its size is whole grid units of 42 mm, and any `width`, `depth` or `height` is rounded to them: a bin is 42 x units - 0.5 across and deep (41.5, 83.5 ... up to 6 units) and 7 mm per height unit (up to 20) plus 4.4 for the stacking lip (a 3-unit bin is 25.4 tall); a baseplate is 42 x units across and deep and 5 tall. On a bin, `gridfinityLip` (default true) is the stacking lip and `gridfinityMagnets` (default false) adds holes for 6 x 2 mm magnets under each foot; a baseplate has none. `gridfinityHalfGrid` (default false) puts a bin on a half-size grid: width and depth count cells of 21 mm (21 x units - 0.5, up to 12 units), each with its own foot, four to an ordinary baseplate's socket; with magnets each foot gets one hole. A bin of 3 height units or more takes label shelves along its inner walls (their top 0.85 mm below 7 mm per height unit, clear of the feet of a bin stacked on the lip): `gridfinityLabelBack`, `gridfinityLabelFront`, `gridfinityLabelLeft` and `gridfinityLabelRight` (default false, any together; where two meet they join), each with its own width, length and offset, as in `gridfinityLabelBackWidth` (mm out from the wall, default 14, at most about 17 in a bin of 3 units), `gridfinityLabelBackLength` (0, the default, runs it from wall to wall) and `gridfinityLabelBackOffset` (moves a set length along its wall from the middle; positive is +x on the back and front walls and +z on the sides), and likewise for Front, Left and Right; and `gridfinityLabelCorner` `round` (default) or `chamfer` with `gridfinityLabelCornerSize` (default 2, 0 for sharp corners) for the corners where a shelf meets a wall or another shelf. Any bin of 2 height units or more takes finger scoops, the floor rising along a quarter circle into the wall: `gridfinityScoopBack`, `gridfinityScoopFront`, `gridfinityScoopLeft` and `gridfinityScoopRight` (default false, any together), with one `gridfinityScoopRadius` (mm, at least 3.5; unset, it is half the height from the floor up to the lip's support, or to the top without the lip: 6.7 in a bin of 3 units with the lip); every floor edge of the bin is then rounded by 2.8 mm. `gridfinityFloorHeight` (mm from the underside, default 5.95) raises the floor of a bin, solid up to it, for a blank to cut shapes into: at most up to the lip's support (19.35 in a bin of 3 units), or the top without the lip; scoops start on it and shelves can reach less far out above it; both are left out when it leaves them no room. `gridfinityOpenBack`, `gridfinityOpenFront`, `gridfinityOpenLeft` and `gridfinityOpenRight` (default false, any together, all four for a tray) take away that side's wall and lip above the floor; the walls beside it run out to the outline and end square, so open bins side by side make one continuous run, the floor runs flat to the edge, and a shelf or scoop on an open side is not built, nor a compartment label. `gridfinityCompartmentsX` and `gridfinityCompartmentsZ` (default 1) divide a bin of 2 height units or more into compartments with dividers of the full height (their top 0.85 mm below 7 mm per height unit, like the shelves), up to 4 per grid unit each way and 40 in all, each compartment at least 6 mm wide; `gridfinityDividerThickness` is mm, 0.8 to 3 (default 1.2), and `gridfinityDividerPositionsX`/`gridfinityDividerPositionsZ` place the dividers in mm from the inside of the left or back wall to each divider's middle (unset or `[]`, evenly spaced; once set, that axis keeps them in mm when the bin is resized; giving a count puts that axis's dividers back evenly spaced unless its positions are given in the same call). Scoops repeat in every compartment against the wall or divider on their side, made smaller in a narrow compartment and left out where even 3.5 mm does not fit. A divided bin of 3 height units or more takes `gridfinityCompartmentLabels` instead of the wall shelves: a list of `{ column, row, side, length, align, width }`, one per compartment (column 0 the left, row 0 the back; side `back`, `front`, `left` or `right`, the wall or divider it stands against; length 10 to 100 per cent of that side; align `start`, `middle` or `end`; width mm, default 14), with the shelves' corner settings. A bin is an exact body, so the edge tool and STEP work on it. A `loft` ("Übergang" in German) joins an outline at the bottom to another at the top with a straight-ruled wall - a hose adapter, a square fan onto a round duct, a stand: `loftBottomOutline` and `loftTopOutline` are `round` (circle or ellipse), `rectangle` (corners rounded by `loftBottomCorner`/`loftTopCorner`) or `polygon` (`loftBottomSides`/`loftTopSides`), `loftBottomWidth`/`loftBottomDepth` and `loftTopWidth`/`loftTopDepth` size the ends, `loftOffsetX`/`loftOffsetZ` move the top sideways (the bottom stays put), and `loftWall` above 0 makes it a tube open at both ends. `loftSmooth` (0 to 1, default 0) lets the sides follow an S-curve between the ends instead of a straight line, for a hose adapter that flares smoothly; `loftTwist` turns the section evenly from bottom to top about the vertical (degrees, up to 360 either way), and `loftTiltX`/`loftTiltZ` tilt the top end (degrees, up to 45: positive raises its front +z edge / its right +x side); the sections tilt evenly on the way up and the highest point stays at the height. Its width and depth are what both ends need together and are not given separately; `settings` reports the ends as the body shows them. It is an exact body, so the edge tool and STEP work on it. A `gear` gets involute teeth that mesh (`gearProfile: "involute"`, the default for a new gear; `"simple"` is the plain straight teeth older designs have, set by `toothSize` and `toothWidth`). An involute gear is set like a gear generator: its `width` and `depth` are its outside diameter, module x (`teeth` + 2), so module 1.5 with 20 teeth is 33 - give both, and change them with `teeth` to keep the module. `gearPressureAngle` (default 20) and `gearBacklash` (play of the pair in mm, default 0.2, half taken off each gear) set the rest. `gearType` is `spur`, `helical`, `herringbone`, `bevel`, `internal` or `rack`: `herringbone` is two helical halves turning opposite ways (centres itself on its shaft, no axial thrust; same `helixAngle` and `helixQuality` as `helical`); `internal` is a ring gear with its teeth pointing in, the outer wheel of a planetary set - its width and depth are its outside, module x (teeth + 2.5) + 2 x `gearRim` (the rim round the teeth, default 3), and a pinion of the same module runs inside it at module x (teeth - pinion teeth) / 2; `rack` is a bar along x with its teeth towards +z, width teeth x pi x module so racks line up end to end, depth the bar's thickness (free above the teeth plus 0.5), and a gear rolls on it with its centre its teeth x module / 2 in front of the pitch line, a module behind the tips. Ring gear and rack take involute or round teeth, never simple ones, and have no centre hole. A `knurl` with `knurlPattern: "round"` has its straight grooves and ridges as a smooth wave of arcs - a grip for a knob whose number of grooves (`knurlCount`), depth and end chamfer are free, unlike a gear's. `gearProfile: "round"` gives round teeth made of arcs instead, like Tinkercad's Useful gear - forgiving on small printed gears and a smooth grip on a knob; they stand lower, so a round gear's outside diameter is module x (`teeth` + 1.2), and round gears mesh with round ones of the same module (no pressure angle, backlash as above). Two gears mesh with the same module and pressure angle at a centre distance of module x (teeth1 + teeth2) / 2 between their centres; for helical gears `helixAngle` is then the true helix angle, and a meshing pair takes the same angle with opposite signs. A `slot` (the capsule) can have one end smaller than the other, a belt guard round two pulleys: `slotEndRatio` is the small end's diameter as a share of the large one (0.1 to 1, default 1 for two equal ends). The large end sits at the start of the long side (-x, or -z when standing), its diameter is the short side, the small end touches the far end, and the sides run as tangents to both, so the centre distance is the long side less both radii. It stays an exact body. Every shape except gear, thread, spring, gridfinity, hinge, pyramid, bentTube, loft, teardrop, counterbore, countersink and ruler can be tapered through `taperTopWidth`, `taperTopDepth`, `taperBottomWidth` and `taperBottomDepth`, and twisted or leaned through `extrudeTwist`, `extrudeTopOffsetX` and `extrudeTopOffsetZ`; naming one value of a face pins the other one as it stands. - `layerling_import_file`: import a file as the import window does (STL, OBJ, 3MF, STEP, SVG or a ZIP of them), content as `text` or `base64`; an SVG with `keepPagePosition: true` keeps its place on its page (viewBox), so files of one page line up; `svgWidth` (mm) scales an SVG or a picture to that width on the way in; a coloured OBJ (vertex colours, or materials with `mtl`) or 3MF (its own colours, or the filaments of a Bambu Studio, OrcaSlicer or PrusaSlicer project) comes in as one body per colour; a 3MF with several objects comes in as one body per object (and per colour within it). Slicer modifiers, negative volumes and support blockers are left out. An SVG comes in as an extruded sketch body (4 mm high, reading from above as in the drawing) whose fill `layerling_update_object` changes with `stroke` and `silhouette`; only one with more than 2500 lines and curves comes in as a fixed mesh. - `layerling_import_mesh`: import STL-style mesh data into the editor. - `layerling_add_font`: add a font of one's own for texts from a TrueType, OpenType or WOFF file as `base64` (with its `fileName`); WOFF2 and .ttc collections are refused. It returns the font's `id` (`custom:...`) and `name`; use either as `font` on a text. The font stays in the browser the editor runs in; a saved design keeps only the outlines of the letters its texts use, so it opens anywhere, and new letters need the font there too. Check a font's licence before passing a design on. Installed system fonts can only be picked by hand in the editor (Chrome and Edge). diff --git a/apps/web/src/components/WorkplaneViewport.tsx b/apps/web/src/components/WorkplaneViewport.tsx index d9ebe9c95..a7292e1e1 100644 --- a/apps/web/src/components/WorkplaneViewport.tsx +++ b/apps/web/src/components/WorkplaneViewport.tsx @@ -1040,6 +1040,10 @@ function tapeShapeTopologyKey(shape: WorkplaneShape): string { gridfinityScoopRight: shape.gridfinityScoopRight, gridfinityScoopRadius: shape.gridfinityScoopRadius, gridfinityFloorHeight: shape.gridfinityFloorHeight, + gridfinityOpenBack: shape.gridfinityOpenBack, + gridfinityOpenFront: shape.gridfinityOpenFront, + gridfinityOpenLeft: shape.gridfinityOpenLeft, + gridfinityOpenRight: shape.gridfinityOpenRight, gridfinityCompartmentsX: shape.gridfinityCompartmentsX, gridfinityCompartmentsZ: shape.gridfinityCompartmentsZ, gridfinityDividerThickness: shape.gridfinityDividerThickness, @@ -1338,6 +1342,10 @@ function shapeGeometrySignature(shape: WorkplaneShape): string { gridfinityScoopRight: shape.gridfinityScoopRight, gridfinityScoopRadius: shape.gridfinityScoopRadius, gridfinityFloorHeight: shape.gridfinityFloorHeight, + gridfinityOpenBack: shape.gridfinityOpenBack, + gridfinityOpenFront: shape.gridfinityOpenFront, + gridfinityOpenLeft: shape.gridfinityOpenLeft, + gridfinityOpenRight: shape.gridfinityOpenRight, gridfinityCompartmentsX: shape.gridfinityCompartmentsX, gridfinityCompartmentsZ: shape.gridfinityCompartmentsZ, gridfinityDividerThickness: shape.gridfinityDividerThickness, @@ -13088,6 +13096,10 @@ function createShapeObject( gridfinityScoopRight: shape.gridfinityScoopRight, gridfinityScoopRadius: shape.gridfinityScoopRadius, gridfinityFloorHeight: shape.gridfinityFloorHeight, + gridfinityOpenBack: shape.gridfinityOpenBack, + gridfinityOpenFront: shape.gridfinityOpenFront, + gridfinityOpenLeft: shape.gridfinityOpenLeft, + gridfinityOpenRight: shape.gridfinityOpenRight, gridfinityCompartmentsX: shape.gridfinityCompartmentsX, gridfinityCompartmentsZ: shape.gridfinityCompartmentsZ, gridfinityDividerThickness: shape.gridfinityDividerThickness, diff --git a/apps/web/src/components/workplane/ShapeInspector.tsx b/apps/web/src/components/workplane/ShapeInspector.tsx index dc47c8902..25dd588b7 100644 --- a/apps/web/src/components/workplane/ShapeInspector.tsx +++ b/apps/web/src/components/workplane/ShapeInspector.tsx @@ -186,6 +186,7 @@ import { MAX_KNURL_ANGLE, MIN_KNURL_ANGLE, MIN_KNURL_COUNT, MIN_KNURL_DEPTH, knu import { DEFAULT_GRIDFINITY_LABEL_CORNER, GRIDFINITY_LABEL_WALL_KEYS, + GRIDFINITY_OPEN_WALL_KEYS, GRIDFINITY_SCOOP_WALL_KEYS, MAX_GRIDFINITY_DIVIDER_THICKNESS, MAX_GRIDFINITY_HEIGHT_UNITS, @@ -220,6 +221,7 @@ import { gridfinityLabelMaxWidth, gridfinityLabelWidth, gridfinityMaxUnits, + gridfinityOpenFits, gridfinityPlan, gridfinityScoopFits, gridfinityScoopMaxRadius, @@ -1000,6 +1002,20 @@ function getShapePropertiesWithAppLimits(shape: WorkplaneShape, onUpdate: ShapeI const maxFloor = gridfinityFloorMaxHeight(fields); properties.push({ id: "gridfinityFloorHeight", label: t("prop.gridfinityFloorHeight"), value: gridfinityFloorHeight(fields), min: MIN_GRIDFINITY_FLOOR_HEIGHT, max: maxFloor, step: 0.05, hint: t("gridfinity.floorHeightHint"), onChange: (value) => onUpdate({ gridfinityFloorHeight: clamp(value, MIN_GRIDFINITY_FLOOR_HEIGHT, maxFloor) }) }); } + const { open } = plan; + if (gridfinityOpenFits(fields)) { + // Open sides (#682): a switch for each side; an open side's shelf and scoop go with its wall. + const walls = [ + { wall: "back", label: "prop.gridfinityOpenBack" }, + { wall: "front", label: "prop.gridfinityOpenFront" }, + { wall: "left", label: "prop.gridfinityOpenLeft" }, + { wall: "right", label: "prop.gridfinityOpenRight" }, + ] as const; + walls.forEach(({ wall, label }) => { + const key = GRIDFINITY_OPEN_WALL_KEYS[wall]; + properties.push({ type: "toggle", id: key, label: t(label), value: shape[key] === true, onChange: (value) => onUpdate({ [key]: value }) }); + }); + } const compartments = gridfinityCompartments(fields); if (gridfinityLabelFits(fields)) { // Label shelves (#455): each wall's switch with its own width, length and position under it, then the corners they share. @@ -1011,7 +1027,7 @@ function getShapePropertiesWithAppLimits(shape: WorkplaneShape, onUpdate: ShapeI { wall: "right", label: "prop.gridfinityLabelRight" }, ] as const; walls.forEach(({ wall, label }) => { - if (compartments) return; + if (compartments || open[wall]) return; const keys = GRIDFINITY_LABEL_WALL_KEYS[wall]; const { on } = keys; properties.push({ type: "toggle", id: on, label: t(label), value: shape[on] === true, onChange: (value) => onUpdate({ [on]: value }) }); @@ -1031,7 +1047,7 @@ function getShapePropertiesWithAppLimits(shape: WorkplaneShape, onUpdate: ShapeI }); const shelves = compartments ? compartments.cells.some((cell) => gridfinityCompartmentLabel(fields, cell.column, cell.row)) - : walls.some(({ wall }) => shape[GRIDFINITY_LABEL_WALL_KEYS[wall].on] === true); + : walls.some(({ wall }) => shape[GRIDFINITY_LABEL_WALL_KEYS[wall].on] === true && !open[wall]); if (shelves) { properties.push( { @@ -1055,10 +1071,11 @@ function getShapePropertiesWithAppLimits(shape: WorkplaneShape, onUpdate: ShapeI { wall: "right", label: "prop.gridfinityScoopRight" }, ] as const; walls.forEach(({ wall, label }) => { + if (open[wall]) return; const key = GRIDFINITY_SCOOP_WALL_KEYS[wall]; properties.push({ type: "toggle", id: key, label: t(label), value: shape[key] === true, onChange: (value) => onUpdate({ [key]: value }) }); }); - if (walls.some(({ wall }) => shape[GRIDFINITY_SCOOP_WALL_KEYS[wall]] === true)) { + if (walls.some(({ wall }) => shape[GRIDFINITY_SCOOP_WALL_KEYS[wall]] === true && !open[wall])) { const max = Math.max(MIN_GRIDFINITY_SCOOP_RADIUS, gridfinityScoopMaxRadius(fields)); properties.push({ id: "gridfinityScoopRadius", label: t("prop.gridfinityScoopRadius"), value: gridfinityScoopRadius(fields), min: MIN_GRIDFINITY_SCOOP_RADIUS, max, step: 0.5, hint: gridfinityScoopsLeftOut(fields) ? t("gridfinity.scoopsLeftOutHint") : undefined, onChange: (value) => onUpdate({ gridfinityScoopRadius: clamp(value, MIN_GRIDFINITY_SCOOP_RADIUS, max) }) }); } @@ -4032,6 +4049,7 @@ function GridfinityCompartmentsCard({ label: t("prop.gridfinityCompartmentLabel"), value: stored?.side ?? "none", options: [{ value: "none", label: t("gridfinity.labelNone") }, ...sides.map(({ value, label }) => ({ value, label: t(label) }))], + hint: stored && cell.open[stored.side] ? t("gridfinity.compartmentLabelOpenHint") : undefined, onChange: (value) => { const side = GRIDFINITY_LABEL_SIDE_OPTIONS.find((option) => option.value === value)?.value; writeLabel(side ? { ...stored, column, row, side } : null); diff --git a/apps/web/src/lib/cadModifierTypes.ts b/apps/web/src/lib/cadModifierTypes.ts index f7e6cb97a..f42749544 100644 --- a/apps/web/src/lib/cadModifierTypes.ts +++ b/apps/web/src/lib/cadModifierTypes.ts @@ -308,6 +308,10 @@ export type CadModifierGridfinityPart = { gridfinityScoopRight?: boolean; gridfinityScoopRadius?: number; gridfinityFloorHeight?: number; + gridfinityOpenBack?: boolean; + gridfinityOpenFront?: boolean; + gridfinityOpenLeft?: boolean; + gridfinityOpenRight?: boolean; gridfinityCompartmentsX?: number; gridfinityCompartmentsZ?: number; gridfinityDividerThickness?: number; diff --git a/apps/web/src/lib/cadProfileExtrusion.ts b/apps/web/src/lib/cadProfileExtrusion.ts index a40c6edc3..9b268f3e1 100644 --- a/apps/web/src/lib/cadProfileExtrusion.ts +++ b/apps/web/src/lib/cadProfileExtrusion.ts @@ -1424,6 +1424,10 @@ export function cadModifierGridfinityForShape(shape: WorkplaneShape): CadModifie gridfinityScoopRight: shape.gridfinityScoopRight, gridfinityScoopRadius: shape.gridfinityScoopRadius, gridfinityFloorHeight: shape.gridfinityFloorHeight, + gridfinityOpenBack: shape.gridfinityOpenBack, + gridfinityOpenFront: shape.gridfinityOpenFront, + gridfinityOpenLeft: shape.gridfinityOpenLeft, + gridfinityOpenRight: shape.gridfinityOpenRight, gridfinityCompartmentsX: shape.gridfinityCompartmentsX, gridfinityCompartmentsZ: shape.gridfinityCompartmentsZ, gridfinityDividerThickness: shape.gridfinityDividerThickness, diff --git a/apps/web/src/lib/editorMeshCore.ts b/apps/web/src/lib/editorMeshCore.ts index 0f5f26348..14d5c2f35 100644 --- a/apps/web/src/lib/editorMeshCore.ts +++ b/apps/web/src/lib/editorMeshCore.ts @@ -514,6 +514,10 @@ function geometryMeshForShape(shape: WorkplaneShape): MeshData | null { gridfinityScoopRight: shape.gridfinityScoopRight, gridfinityScoopRadius: shape.gridfinityScoopRadius, gridfinityFloorHeight: shape.gridfinityFloorHeight, + gridfinityOpenBack: shape.gridfinityOpenBack, + gridfinityOpenFront: shape.gridfinityOpenFront, + gridfinityOpenLeft: shape.gridfinityOpenLeft, + gridfinityOpenRight: shape.gridfinityOpenRight, gridfinityCompartmentsX: shape.gridfinityCompartmentsX, gridfinityCompartmentsZ: shape.gridfinityCompartmentsZ, gridfinityDividerThickness: shape.gridfinityDividerThickness, diff --git a/apps/web/src/lib/gridfinityGeometry.ts b/apps/web/src/lib/gridfinityGeometry.ts index 368d4798a..c5cbc47d4 100644 --- a/apps/web/src/lib/gridfinityGeometry.ts +++ b/apps/web/src/lib/gridfinityGeometry.ts @@ -84,6 +84,16 @@ import type { GridfinityCompartmentLabel, GridfinityLabelCorner, GridfinityType, * their middle, as long as every compartment stays at least 6 mm wide: the * kernel's cut fails on narrower ones. A bin takes at most 4 compartments a * grid unit each way and 40 in all, which keep it quick to build. + * + * A bin can also be open on any of its sides (#682), so that bins standing side + * by side make one long run: above the floor an open side has no wall and no + * lip, and the walls beside it run straight out to the outline and end square + * there, 0.5 mm from the next bin as between any two. The corners on an open + * side are square from the top of the feet up, so the floor is flat out to the + * edge. The inside and the compartments are cut by cutters that run on past the + * outline far enough for their roundings to lie beyond it, and the pieces joined + * to the walls beside an open side are cut off at the outline. A shelf or a + * scoop set on an open side is not built. */ export const GRIDFINITY_GRID = 42; @@ -183,6 +193,10 @@ export type GridfinityShapeFields = Pick & { gridfinityScoopRight?: boolean; gridfinityScoopRadius?: number; gridfinityFloorHeight?: number; + gridfinityOpenBack?: boolean; + gridfinityOpenFront?: boolean; + gridfinityOpenLeft?: boolean; + gridfinityOpenRight?: boolean; gridfinityCompartmentsX?: number; gridfinityCompartmentsZ?: number; gridfinityDividerThickness?: number; @@ -302,10 +316,13 @@ export function gridfinityPlan(shape: GridfinityShapeFields) { : []; const top = count.y * GRIDFINITY_HEIGHT_UNIT; const floor = floorHeight(shape.gridfinityFloorHeight, { type, top, lip }); + // Any bin but a solid block can be open on a side (#682), with no wall there above the floor. + const fits = openFits({ type, top, lip, floor }); + const open = Object.fromEntries(LABEL_WALLS.map((wall) => [wall, fits && shape[GRIDFINITY_OPEN_WALL_KEYS[wall]] === true])) as Record; // A bin with a finger scoop has every inner floor edge rounded, and the scoop is the rounding made large on its wall. - const scooped = scoopFits({ type, top, lip, floor }) && LABEL_WALLS.some((wall) => shape[GRIDFINITY_SCOOP_WALL_KEYS[wall]] === true); + const scooped = scoopFits({ type, top, lip, floor }) && LABEL_WALLS.some((wall) => shape[GRIDFINITY_SCOOP_WALL_KEYS[wall]] === true && !open[wall]); const floorRounding = scooped ? SCOOP_FLOOR_ROUNDING : 0; - return { type, count, lip, magnets, halfGrid: pitch !== GRIDFINITY_GRID, pitch, size, cells, magnetHoles, top, floor, floorRounding }; + return { type, count, lip, magnets, halfGrid: pitch !== GRIDFINITY_GRID, pitch, size, cells, magnetHoles, top, floor, floorRounding, open }; } export type GridfinityPlan = ReturnType; @@ -409,8 +426,12 @@ export function gridfinityLabelCornerOutline(c: GridfinityLabelCornerShape) { export type GridfinityLabelPieces = { top: number; floor: number; - /** The bin's inside the pieces are cut to: half sizes and corner radius of the inner wall. */ - inside: { hx: number; hz: number; radius: number }; + /** + * The bin's inside the pieces are cut to: half sizes and corner radius of the inner wall, and the open sides it reaches + * out through to the outline, the wall's 0.95 mm further (the exact body cuts them to that; the display mesh runs the + * inside on and cuts them off at the outline); only when a side is open, so a closed bin's pieces are as before. + */ + inside: { hx: number; hz: number; radius: number; open?: Record }; /** Each shelf, with the round corners taken off the front of a shelf that stops short of the walls. */ shelves: Array<{ profile: GridfinityLabelProfile; notches: GridfinityLabelCornerShape[] }>; /** The rounding or chamfer where a shelf meets a wall or another shelf, trimmed to lie under the shelves it joins. */ @@ -462,7 +483,9 @@ export function gridfinityLabelMaxWidth(shape: GridfinityShapeFields, wall: Grid const { hx, hz } = labelInside(plan); const size = gridfinityLabelCornerSize(shape); const max = labelFloorWidth(plan); - const [half, facing] = wall === "back" || wall === "front" ? [hz, wall === "back" ? shape.gridfinityLabelFront : shape.gridfinityLabelBack] : [hx, wall === "left" ? shape.gridfinityLabelRight : shape.gridfinityLabelLeft]; + // A shelf set on an open side is not built, so it faces nothing. + const on = (side: GridfinityWall) => shape[GRIDFINITY_LABEL_WALL_KEYS[side].on] === true && !plan.open[side]; + const [half, facing] = wall === "back" || wall === "front" ? [hz, on(wall === "back" ? "front" : "back")] : [hx, on(wall === "left" ? "right" : "left")]; return Math.min(max, facing ? half - 1 - size : 2 * half - 2 - size); } @@ -518,7 +541,8 @@ export function gridfinityLabelPieces(shape: GridfinityShapeFields): GridfinityL // A bin with dividers has labels in its compartments instead (#554); its shelves' settings are kept for a bin without. if (!gridfinityLabelFits(shape) || gridfinityCompartments(shape)) return null; const plan = gridfinityPlan(shape); - const walls = LABEL_WALLS.filter((wall) => shape[GRIDFINITY_LABEL_WALL_KEYS[wall].on] === true); + // An open side has no wall for a shelf (#682); its setting is kept for when it is closed again. + const walls = LABEL_WALLS.filter((wall) => shape[GRIDFINITY_LABEL_WALL_KEYS[wall].on] === true && !plan.open[wall]); if (walls.length === 0) return null; const size = gridfinityLabelCornerSize(shape); const round = (shape.gridfinityLabelCorner ?? DEFAULT_GRIDFINITY_LABEL_CORNER) !== "chamfer"; @@ -535,7 +559,7 @@ export function gridfinityLabelPieces(shape: GridfinityShapeFields): GridfinityL const centre = clamp(sign * offset, -along + length / 2, along - length / 2); return { centre, length, short: [centre - length / 2 > -along + snap, centre + length / 2 < along - snap] }; }; - return labelPiecesIn({ hx, hz, walls, widthOf, spanOf, size, round, top: plan.top - LABEL_DROP, floor: plan.floor }); + return labelPiecesIn({ hx, hz, walls, widthOf, spanOf, size, round, top: plan.top - LABEL_DROP, floor: plan.floor, open: plan.open }); } /** A shelf's middle and length along its wall, and whether each end (0 at u = -along, 1 at u = along) stops short of the wall there. */ @@ -543,9 +567,10 @@ type LabelSpan = { centre: number; length: number; short: boolean[] }; /** * The shelves on the given walls of an inside of these half sizes as pieces, each with its span and width: how they - * end, meet one another and the walls, and the corner pieces between them. + * end, meet one another and the walls, and the corner pieces between them. An end at an open side runs out through it + * as into a wall, and is cut off at the outline. */ -function labelPiecesIn({ hx, hz, walls, widthOf, spanOf, size, round, top, floor }: { +function labelPiecesIn({ hx, hz, walls, widthOf, spanOf, size, round, top, floor, open }: { hx: number; hz: number; walls: GridfinityWall[]; @@ -555,6 +580,7 @@ function labelPiecesIn({ hx, hz, walls, widthOf, spanOf, size, round, top, floor round: boolean; top: number; floor: number; + open?: Record; }): GridfinityLabelPieces { const alongOf = (wall: GridfinityWall) => (wall === "back" || wall === "front" ? hx : hz); const spans = new Map(walls.map((wall) => [wall, spanOf(wall, alongOf(wall))])); @@ -641,6 +667,8 @@ function labelPiecesIn({ hx, hz, walls, widthOf, spanOf, size, round, top, floor if (meetSize <= 0) return; [0, 1].forEach((end) => { if (free(wall, end)) return; + // An open side has no wall to meet. + if (open?.[LABEL_SIDE_WALLS[wall][end]]) return; const { side, sideEnd, on } = across(wall, end); const meets = on && !free(side, sideEnd) && covered(wall, end) && covered(side, sideEnd); const sideWidth = meets ? widthOf(side) : 0; @@ -662,7 +690,7 @@ function labelPiecesIn({ hx, hz, walls, widthOf, spanOf, size, round, top, floor corners.push({ at: point((end === 0 ? -along : along) + inward * sideWidth, width), a: direction(wall, inward, 0), b: direction(wall, 0, 1), size: cornerSize, round, under: strips, reachA: LABEL_PAST, reachB: 0 }); }); }); - return { top, floor, inside: { hx, hz, radius: GRIDFINITY_BIN_RADIUS - GRIDFINITY_WALL }, shelves, corners }; + return { top, floor, inside: { hx, hz, radius: GRIDFINITY_BIN_RADIUS - GRIDFINITY_WALL, ...(open && LABEL_WALLS.some((wall) => open[wall]) ? { open } : {}) }, shelves, corners }; } /** The shelf's cross-section, wall side first: top, the straight strip at the front, the underside back down to the wall. */ @@ -724,7 +752,7 @@ function scoopFits(plan: { type: GridfinityType; top: number; lip: boolean; floo export function gridfinityScoopMaxRadius(shape: GridfinityShapeFields) { const plan = gridfinityPlan(shape); const { hx, hz } = labelInside(plan); - const on = (wall: GridfinityWall) => shape[GRIDFINITY_SCOOP_WALL_KEYS[wall]] === true; + const on = (wall: GridfinityWall) => shape[GRIDFINITY_SCOOP_WALL_KEYS[wall]] === true && !plan.open[wall]; const room = (wall: GridfinityWall) => { const [half, facing] = wall === "back" || wall === "front" ? [hz, on(wall === "back" ? "front" : "back")] : [hx, on(wall === "left" ? "right" : "left")]; return facing ? half - 1 : 2 * half - 2; @@ -760,16 +788,20 @@ export type GridfinityScoopPieces = { /** The bin's finger scoops as pieces for the mesh and the exact body, or null when it has none. */ export function gridfinityScoopPieces(shape: GridfinityShapeFields): GridfinityScoopPieces | null { if (!gridfinityScoopFits(shape)) return null; - const walls = LABEL_WALLS.filter((wall) => shape[GRIDFINITY_SCOOP_WALL_KEYS[wall]] === true); - if (walls.length === 0) return null; const plan = gridfinityPlan(shape); + const { open } = plan; + // An open side has no wall for a scoop (#682); its setting is kept for when it is closed again. + const walls = LABEL_WALLS.filter((wall) => shape[GRIDFINITY_SCOOP_WALL_KEYS[wall]] === true && !open[wall]); + if (walls.length === 0) return null; const { hx, hz } = labelInside(plan); const floor = plan.floor; const chosen = gridfinityScoopRadius(shape); const radius = Math.abs(chosen - SCOOP_CORNER_RADIUS) < SCOOP_CORNER_CLEAR ? SCOOP_CORNER_RADIUS - SCOOP_CORNER_CLEAR : chosen; const scoops = walls.map((wall): GridfinityScoop => { const along = wall === "back" || wall === "front" ? hx : hz; - return { wall, from: -along - LABEL_PAST, to: along + LABEL_PAST }; + // An end at an open side runs as far past the outline as the other runs into the wall. + const [start, end] = LABEL_SIDE_WALLS[wall].map((side) => (open[side] ? GRIDFINITY_WALL : 0)); + return { wall, from: -along - start - LABEL_PAST, to: along + end + LABEL_PAST }; }); return { floor, radius, centre: [radius - SCOOP_SINK, floor + radius - SCOOP_SINK], inside: { hx, hz, radius: GRIDFINITY_BIN_RADIUS - GRIDFINITY_WALL }, scoops }; } @@ -796,6 +828,41 @@ export function gridfinityScoopPoint(pieces: GridfinityScoopPieces, scoop: Gridf return [p.x, y, p.z]; } +/** Each side's open switch (#682). */ +export const GRIDFINITY_OPEN_WALL_KEYS = { back: "gridfinityOpenBack", front: "gridfinityOpenFront", left: "gridfinityOpenLeft", right: "gridfinityOpenRight" } as const; +/** How far a cutter that runs out through an open side stays straight past the outline before its rounding starts. */ +const OPEN_CLEAR = 2; +/** + * How far the inside's cutter and a compartment's run out through an open side: far enough that their 3.75 mm corners + * and the floor's 2.8 mm rounding lie OPEN_CLEAR beyond the outline, where the bin is not. Scoops reach 1 mm past it + * (LABEL_PAST) and shelves 1 mm past the inside, as into a wall, so none of them ends on a rounding of the cutter. + */ +export const GRIDFINITY_OPEN_REACH = GRIDFINITY_BIN_RADIUS + OPEN_CLEAR; + +/** Whether a bin has sides to open: every bin but a solid block, one without the lip whose floor is raised to the top. */ +function openFits({ type, top, lip, floor }: { type: GridfinityType; top: number; lip: boolean; floor: number }) { + return type === "bin" && floor < (lip ? top + GRIDFINITY_LIP_HEIGHT : top); +} + +/** Whether a bin of these settings has sides to open, for the settings window. */ +export function gridfinityOpenFits(shape: GridfinityShapeFields) { + return openFits(gridfinityPlan(shape)); +} + +/** Which of a rectangle's corners are square: those touching an open side, where it runs straight out. */ +export function gridfinityOpenCorners(open: Partial> = {}) { + return { backLeft: Boolean(open.back || open.left), backRight: Boolean(open.back || open.right), frontLeft: Boolean(open.front || open.left), frontRight: Boolean(open.front || open.right) }; +} + +/** + * A rounded rectangle's half sizes and middle when it runs `reach` further out through its open sides and keeps the + * others where they are: the inside's cutter, a compartment's, or the inside the pieces are cut to. + */ +export function gridfinityOpenGrown(hx: number, hz: number, open: Partial>, reach: number) { + const [left, right, back, front] = (["left", "right", "back", "front"] as const).map((wall) => (open[wall] ? reach : 0)); + return { hx: hx + (left + right) / 2, hz: hz + (back + front) / 2, x: (right - left) / 2, z: (front - back) / 2 }; +} + /** Across (x) or deep (z), for the compartments and the dividers between them. */ export type GridfinityAxis = "x" | "z"; @@ -902,6 +969,8 @@ export type GridfinityCompartment = { hx: number; hz: number; onWall: Record; + /** The bin's open sides it lies against (#682). */ + open: Record; }; /** @@ -930,6 +999,7 @@ export function gridfinityCompartments(shape: GridfinityShapeFields) { hx: (x1 - x0) / 2, hz: (z1 - z0) / 2, onWall: { back: row === 0, front: row === counts.z - 1, left: column === 0, right: column === counts.x - 1 }, + open: { back: row === 0 && plan.open.back, front: row === counts.z - 1 && plan.open.front, left: column === 0 && plan.open.left, right: column === counts.x - 1 && plan.open.right }, }); } } @@ -948,7 +1018,7 @@ export type GridfinityCompartmentPieces = { thickness: number; floorRounding: number; /** Each compartment, its inside, its scoops and its label in its own frame, centred on its middle. */ - cells: Array<{ column: number; row: number; centre: FlatPoint; onWall: Record; inside: { hx: number; hz: number; radius: number }; scoops: GridfinityScoopPieces | null; labels: GridfinityLabelPieces | null }>; + cells: Array<{ column: number; row: number; centre: FlatPoint; onWall: Record; open: Record; inside: { hx: number; hz: number; radius: number }; scoops: GridfinityScoopPieces | null; labels: GridfinityLabelPieces | null }>; }; /** @@ -956,7 +1026,7 @@ export type GridfinityCompartmentPieces = { * the radius made smaller where it would leave less than 2 mm of floor, as the bin's is. Where even the smallest * radius would leave less, that way's scoops are left out, both of a facing pair, as a bin too low has none. */ -function compartmentScoops(pieces: GridfinityScoopPieces, hx: number, hz: number): GridfinityScoopPieces | null { +function compartmentScoops(pieces: GridfinityScoopPieces, hx: number, hz: number, open?: Record): GridfinityScoopPieces | null { const walls = pieces.scoops.map((scoop) => scoop.wall); const ways = ([[hz, ["back", "front"]], [hx, ["left", "right"]]] as const).map(([half, pair]) => { const on = pair.filter((wall) => walls.includes(wall)); @@ -967,7 +1037,9 @@ function compartmentScoops(pieces: GridfinityScoopPieces, hx: number, hz: number const radius = Math.abs(chosen - SCOOP_CORNER_RADIUS) < SCOOP_CORNER_CLEAR ? SCOOP_CORNER_RADIUS - SCOOP_CORNER_CLEAR : chosen; const scoops = walls.filter((wall) => ways.some((way) => (way.on as readonly GridfinityWall[]).includes(wall))).map((wall): GridfinityScoop => { const along = wall === "back" || wall === "front" ? hx : hz; - return { wall, from: -along - LABEL_PAST, to: along + LABEL_PAST }; + // An end at the bin's open side runs past the outline, as the bin's own scoops do. + const [start, end] = LABEL_SIDE_WALLS[wall].map((side) => (open?.[side] ? GRIDFINITY_WALL : 0)); + return { wall, from: -along - start - LABEL_PAST, to: along + end + LABEL_PAST }; }); return { floor: pieces.floor, radius, centre: [radius - SCOOP_SINK, pieces.floor + radius - SCOOP_SINK], inside: { hx, hz, radius: GRIDFINITY_BIN_RADIUS - GRIDFINITY_WALL }, scoops }; } @@ -989,8 +1061,12 @@ export function gridfinityCompartmentCornerSize(shape: GridfinityShapeFields, ce return clamp(gridfinityLabelCornerSize(shape), 0, Math.max(0, Math.min(MAX_GRIDFINITY_LABEL_CORNER_SIZE, Math.min(cell.hx, cell.hz) - 1 - MIN_GRIDFINITY_LABEL_WIDTH))); } -/** The widest label a compartment takes on a side: its underside above the floor, and 6 mm of the compartment left open in front of it and its corner. */ -export function gridfinityCompartmentLabelMaxWidth(shape: GridfinityShapeFields, cell: { hx: number; hz: number }, side: GridfinityWall) { +/** + * The widest label a compartment takes on a side: its underside above the floor, and 6 mm of the compartment left open + * in front of it and its corner. None on the bin's open side, which has no wall for it. + */ +export function gridfinityCompartmentLabelMaxWidth(shape: GridfinityShapeFields, cell: { hx: number; hz: number; open?: Record }, side: GridfinityWall) { + if (cell.open?.[side]) return 0; const across = 2 * (side === "back" || side === "front" ? cell.hz : cell.hx); return Math.min(labelFloorWidth(gridfinityPlan(shape)), across - COMPARTMENT_LABEL_OPEN - gridfinityCompartmentCornerSize(shape, cell)); } @@ -1050,7 +1126,7 @@ function compartmentLabelPieces(shape: GridfinityShapeFields, cell: GridfinityCo const to = short[1] ? half - snapped(half - centre - length / 2) : centre + length / 2; return { centre: (from + to) / 2, length: to - from, short }; }; - return labelPiecesIn({ hx: cell.hx, hz: cell.hz, walls: [side], widthOf: () => width, spanOf, size, round, top: plan.top - LABEL_DROP, floor: plan.floor }); + return labelPiecesIn({ hx: cell.hx, hz: cell.hz, walls: [side], widthOf: () => width, spanOf, size, round, top: plan.top - LABEL_DROP, floor: plan.floor, open: cell.open }); } /** @@ -1073,8 +1149,9 @@ export function gridfinityCompartmentPieces(shape: GridfinityShapeFields): Gridf row: cell.row, centre: cell.centre, onWall: cell.onWall, + open: cell.open, inside: { hx: cell.hx, hz: cell.hz, radius: GRIDFINITY_BIN_RADIUS - GRIDFINITY_WALL }, - scoops: scoops && compartmentScoops(scoops, cell.hx, cell.hz), + scoops: scoops && compartmentScoops(scoops, cell.hx, cell.hz, cell.open), labels: compartmentLabelPieces(shape, cell), })), }; @@ -1091,10 +1168,13 @@ export function gridfinityScoopsLeftOut(shape: GridfinityShapeFields) { /** * The least volume the cutter over the dividers' top fused with the compartments' cutters can have, from their own * volumes: each compartment's cutter overlaps the one above by at most its section times the height it reaches over - * the top. Less is a compartment the kernel's fuse lost. + * the top, with the length it runs out through an open side. Less is a compartment the kernel's fuse lost. */ export function gridfinityCompartmentCutBound(pieces: GridfinityCompartmentPieces, volumes: { box: number; cutters: number[] }) { - return pieces.cells.reduce((sum, { inside: { hx, hz, radius } }, i) => sum + volumes.cutters[i] - (4 * hx * hz - (4 - Math.PI) * radius ** 2) * GRIDFINITY_DIVIDER_CUTTER_OVERLAP, volumes.box); + return pieces.cells.reduce((sum, { inside: { hx, hz, radius }, open }, i) => { + const grown = gridfinityOpenGrown(hx, hz, open, GRIDFINITY_OPEN_REACH); + return sum + volumes.cutters[i] - (4 * grown.hx * grown.hz - (4 - Math.PI) * radius ** 2) * GRIDFINITY_DIVIDER_CUTTER_OVERLAP; + }, volumes.box); } /** @@ -1106,8 +1186,10 @@ export function gridfinityCompartmentCutBound(pieces: GridfinityCompartmentPiece export function gridfinityDividerBound(plan: GridfinityPlan, pieces: GridfinityCompartmentPieces) { const { hx, hz } = labelInside(plan); const support = floorMaxHeight(plan); - const rounded = (x: number, z: number) => 4 * x * z - (4 - Math.PI) * (GRIDFINITY_BIN_RADIUS - GRIDFINITY_WALL) ** 2; - const area = pieces.cells.reduce((rest, cell) => rest - rounded(cell.inside.hx, cell.inside.hz), rounded(hx, hz)); + // A corner touching an open side is square: the inside's cutter and a compartment's run straight out there. What the + // dividers add between the inner wall's line and the outline is left out. + const rounded = (x: number, z: number, open: Record) => 4 * x * z - ((4 - Math.PI) * (GRIDFINITY_BIN_RADIUS - GRIDFINITY_WALL) ** 2 * Object.values(gridfinityOpenCorners(open)).filter((square) => !square).length) / 4; + const area = pieces.cells.reduce((rest, cell) => rest - rounded(cell.inside.hx, cell.inside.hz, cell.open), rounded(hx, hz, plan.open)); const lean = Math.max(0, pieces.top - support); // Above the support's foot: the inside kept clear of it less each compartment's rectangle there, and back the rounded // corners where two dividers meet, 6 mm or more from the walls. @@ -1143,8 +1225,9 @@ export function gridfinityCompartmentFloorBound(plan: GridfinityPlan, pieces: Gr return pieces.cells.reduce((sum, cell) => { // Along each side a fillet's section; in each corner a quarter column less an eighth of a ball, at a wall only the // part under the support's foot, where its section at y above the floor is a quarter circle of r - y. - const sides = LABEL_WALLS.reduce((total, side) => total + (cell.onWall[side] ? atWall : fillet) * (2 * (side === "back" || side === "front" ? cell.inside.hx : cell.inside.hz) - 2 * r), 0); - const rounding = corners.reduce((total, [a, b]) => total + (Math.PI * (r ** 3 - (r - (cell.onWall[a] || cell.onWall[b] ? Math.min(under, r) : r)) ** 3)) / 12, sides); + // An open side has a flat floor, rounded only beyond the outline, along it and in its corners. + const sides = LABEL_WALLS.reduce((total, side) => total + (cell.open[side] ? 0 : cell.onWall[side] ? atWall : fillet) * (2 * (side === "back" || side === "front" ? cell.inside.hx : cell.inside.hz) - 2 * r), 0); + const rounding = corners.reduce((total, [a, b]) => total + (cell.open[a] || cell.open[b] ? 0 : (Math.PI * (r ** 3 - (r - (cell.onWall[a] || cell.onWall[b] ? Math.min(under, r) : r)) ** 3)) / 12), sides); const scoops = cell.scoops; if (!scoops) return sum + rounding; const [cv, cy] = scoops.centre; @@ -1291,27 +1374,31 @@ export function gridfinityWallProfile(plan: GridfinityPlan, floorRounding = plan return profile; } -type LoopPoint = { x: number; z: number; nx: number; nz: number }; +type LoopPoint = { x: number; z: number; nx: number; nz: number; wall?: GridfinityWall }; /** * A rounded rectangle centred on (cx, cz), half sizes hx and hz, corner * radius `radius`, set in by `inset` with its corner centres kept. The points * on the straight sides are the corner ends plus `breaksX` (on the two sides * along x) and `breaksZ`, so that outlines of one family match point for - * point. + * point. With `open`, an open side stays on the outline at every inset and the + * corners it touches are square, each point says which side it is on, and a + * closed side keeps the same points at every inset, as its ends do not move + * along it (#682). */ -function roundedLoop(cx: number, cz: number, hx: number, hz: number, radius: number, inset: number, breaksX: number[] = [], breaksZ: number[] = []): LoopPoint[] { +function roundedLoop(cx: number, cz: number, hx: number, hz: number, radius: number, inset: number, breaksX: number[] = [], breaksZ: number[] = [], open?: Record): LoopPoint[] { const ax = hx - radius; const az = hz - radius; const r = radius - inset; const points: LoopPoint[] = []; - const side = (fromX: number, fromZ: number, toX: number, toZ: number, nx: number, nz: number, breaks: number[]) => { - points.push({ x: fromX, z: fromZ, nx, nz }); + const side = (fromX: number, fromZ: number, toX: number, toZ: number, nx: number, nz: number, breaks: number[], wall: GridfinityWall) => { + const tag = open ? { wall } : {}; + points.push({ x: fromX, z: fromZ, nx, nz, ...tag }); const along = fromX !== toX; const ordered = breaks.filter((b) => (along ? b > Math.min(fromX, toX) + 1e-9 && b < Math.max(fromX, toX) - 1e-9 : b > Math.min(fromZ, toZ) + 1e-9 && b < Math.max(fromZ, toZ) - 1e-9)); ordered.sort((a, b) => (along ? (toX > fromX ? a - b : b - a) : toZ > fromZ ? a - b : b - a)); - ordered.forEach((b) => points.push(along ? { x: b, z: fromZ, nx, nz } : { x: fromX, z: b, nx, nz })); - points.push({ x: toX, z: toZ, nx, nz }); + ordered.forEach((b) => points.push(along ? { x: b, z: fromZ, nx, nz, ...tag } : { x: fromX, z: b, nx, nz, ...tag })); + points.push({ x: toX, z: toZ, nx, nz, ...tag }); }; const corner = (ox: number, oz: number, from: number) => { for (let k = 1; k < CORNER_STEPS; k += 1) { @@ -1319,14 +1406,16 @@ function roundedLoop(cx: number, cz: number, hx: number, hz: number, radius: num points.push({ x: ox + r * Math.cos(angle), z: oz + r * Math.sin(angle), nx: Math.cos(angle), nz: Math.sin(angle) }); } }; - side(cx - ax, cz - az - r, cx + ax, cz - az - r, 0, -1, breaksX); - corner(cx + ax, cz - az, -Math.PI / 2); - side(cx + ax + r, cz - az, cx + ax + r, cz + az, 1, 0, breaksZ); - corner(cx + ax, cz + az, 0); - side(cx + ax, cz + az + r, cx - ax, cz + az + r, 0, 1, breaksX); - corner(cx - ax, cz + az, Math.PI / 2); - side(cx - ax - r, cz + az, cx - ax - r, cz - az, -1, 0, breaksZ); - corner(cx - ax, cz - az, Math.PI); + const [x0, x1, z0, z1] = [open?.left ? cx - hx : cx - ax - r, open?.right ? cx + hx : cx + ax + r, open?.back ? cz - hz : cz - az - r, open?.front ? cz + hz : cz + az + r]; + const square = gridfinityOpenCorners(open); + side(square.backLeft ? x0 : cx - ax, z0, square.backRight ? x1 : cx + ax, z0, 0, -1, breaksX, "back"); + if (!square.backRight) corner(cx + ax, cz - az, -Math.PI / 2); + side(x1, square.backRight ? z0 : cz - az, x1, square.frontRight ? z1 : cz + az, 1, 0, breaksZ, "right"); + if (!square.frontRight) corner(cx + ax, cz + az, 0); + side(square.frontRight ? x1 : cx + ax, z1, square.frontLeft ? x0 : cx - ax, z1, 0, 1, breaksX, "front"); + if (!square.frontLeft) corner(cx - ax, cz + az, Math.PI / 2); + side(x0, square.frontLeft ? z1 : cz + az, x0, square.backLeft ? z0 : cz - az, -1, 0, breaksZ, "left"); + if (!square.backLeft) corner(cx - ax, cz - az, Math.PI); return points; } @@ -1353,7 +1442,7 @@ export function gridfinityTriangles(shape: GridfinityShapeFields) { const at = (p: { x: number; z: number }, y: number): Vec3 => [p.x, y, p.z]; /** Strips between the outlines of a profile; `outside` is +1 where the material is inside the outline, -1 where it is outside (a socket). */ - const sweep = (loop: (inset: number) => LoopPoint[], profile: GridfinityStep[], outside: 1 | -1) => { + const sweep = (loop: (inset: number) => LoopPoint[], profile: GridfinityStep[], outside: 1 | -1, wrap = true) => { for (let s = 0; s + 1 < profile.length; s += 1) { const [i0, y0] = profile[s]; const [i1, y1] = profile[s + 1]; @@ -1366,6 +1455,8 @@ export function gridfinityTriangles(shape: GridfinityShapeFields) { const nOut = dy * outside; const nY = -dOut * outside; a.forEach((p, k) => { + // A run of walls between open sides ends at its last point. + if (!wrap && k + 1 === a.length) return; const q = a[(k + 1) % a.length]; const pb = b[k]; const qb = b[(k + 1) % b.length]; @@ -1389,6 +1480,7 @@ export function gridfinityTriangles(shape: GridfinityShapeFields) { * cell's corner is the body's own rounded corner there is no point to fan * from: with `outer` (the body's outline in that cell) the strip between the * two arcs fills it, without it the arcs are the same and nothing is left. + * A body corner made square by an open side is fanned like any other. */ const ring = (inner: LoopPoint[], box: Box, y: number, up: 1 | -1, outer?: LoopPoint[]) => { const per = inner.length / 4; @@ -1411,7 +1503,7 @@ export function gridfinityTriangles(shape: GridfinityShapeFields) { const arc = [end, ...inner.slice(s * per + 2, (s + 1) * per), next]; const sx = end.nx + next.nx > 0 ? 1 : -1; const sz = end.nz + next.nz > 0 ? 1 : -1; - if (box.edge(sx, "x") && box.edge(sz, "z")) { + if (box.edge(sx, "x") && box.edge(sz, "z") && !plan.open[sx > 0 ? "right" : "left"] && !plan.open[sz > 0 ? "front" : "back"]) { if (!outer) continue; const outerArc = [outer[s * per + 1], ...outer.slice(s * per + 2, (s + 1) * per), outer[((s + 1) % 4) * per]]; for (let k = 0; k + 1 < arc.length; k += 1) { @@ -1450,6 +1542,7 @@ export function gridfinityTriangles(shape: GridfinityShapeFields) { const breaksX = breaks(count.x); const breaksZ = breaks(count.z); const outline = (inset: number) => roundedLoop(0, 0, size.width / 2, size.depth / 2, radius, inset, breaksX, breaksZ); + const openOutline = (inset: number) => roundedLoop(0, 0, size.width / 2, size.depth / 2, radius, inset, breaksX, breaksZ, plan.open); const cellOf = (cell: (typeof cells)[number], inset: number) => roundedLoop(cell.x, cell.z, cellHalf, cellHalf, radius, inset); // A cell's box, cut back to the body's outline at the edge; edge() says whether a side is the body's own. const cellBox = (cell: (typeof cells)[number]): Box => ({ @@ -1460,12 +1553,55 @@ export function gridfinityTriangles(shape: GridfinityShapeFields) { edge: (sign, axis) => (axis === "x" ? (sign > 0 ? cell.i === count.x - 1 : cell.i === 0) : sign > 0 ? cell.j === count.z - 1 : cell.j === 0), }); + /** + * A bin with a side open (#682): the wall swept along each run of closed sides from a square corner on one open side + * to one on the next, its end in the open side's plane together with the floor's edge there, and the floor out to + * the open sides' outline. + */ + const openBin = (wall: GridfinityStep[]) => { + const closed = (p: LoopPoint) => !(p.wall && plan.open[p.wall]); + const runsOf = (loop: LoopPoint[]) => { + const start = loop.findIndex((p, k) => !closed(p) && closed(loop[(k + 1) % loop.length])); + const turned = [...loop.slice(start + 1), ...loop.slice(0, start + 1)]; + const runs: LoopPoint[][] = []; + turned.forEach((p, k) => { + if (!closed(p)) return; + if (k === 0 || !closed(turned[k - 1])) runs.push([]); + runs[runs.length - 1].push(p); + }); + return runs; + }; + const runs = runsOf(openOutline(0)).length; + for (let n = 0; n < runs; n += 1) sweep((inset) => runsOf(openOutline(inset))[n], wall, 1, false); + const [floorInset, floorY] = wall[wall.length - 1]; + const along = (inset: number, side: GridfinityWall) => openOutline(inset).filter((p) => p.wall === side); + LABEL_WALLS.filter((side) => plan.open[side]).forEach((side) => { + // From the foot's top along the open side, up the end of the wall beside it, over the top and down to the floor, + // back along the floor's edge and down the other wall's end; straight up where the side beside it is open too. + const end = (k: 0 | 1) => (plan.open[LABEL_SIDE_WALLS[side][k]] ? [] : wall.map(([inset, y]) => { + const points = along(inset, side); + return at(points[k === 0 ? 0 : points.length - 1], y); + })); + const points = [...along(0, side).map((p) => at(p, GRIDFINITY_FOOT_HEIGHT)), ...end(1), ...along(floorInset, side).map((p) => at(p, floorY)).reverse(), ...end(0).reverse()]; + const face = points.filter((p, k) => { + const q = points[(k + 1) % points.length]; + return Math.hypot(p[0] - q[0], p[1] - q[1], p[2] - q[2]) > 1e-9; + }); + const across = side === "back" || side === "front"; + const outward: Vec3 = across ? [0, 0, side === "front" ? 1 : -1] : [side === "right" ? 1 : -1, 0, 0]; + THREE.ShapeUtils.triangulateShape(face.map((p) => new THREE.Vector2(across ? p[0] : p[2], p[1])), []).forEach(([a, b, c]) => push(face[a], face[b], face[c], outward)); + }); + cap(distinctPoints(openOutline(floorInset)), [], floorY, 1); + }; + if (plan.type === "bin") { const wall = gridfinityWallProfile(plan); - sweep(outline, wall, 1); + const opened = LABEL_WALLS.some((side) => plan.open[side]); + if (opened) openBin(wall); + else sweep(outline, wall, 1); const [floorInset, floorY] = wall[wall.length - 1]; // A floor rounded as far as the inner corners' radius has corners of no radius, whose points coincide. - cap(distinctPoints(outline(floorInset)), [], floorY, 1); + if (!opened) cap(distinctPoints(outline(floorInset)), [], floorY, 1); cells.forEach((cell) => { sweep((inset) => cellOf(cell, inset), GRIDFINITY_FOOT, 1); ring(cellOf(cell, 0), cellBox(cell), GRIDFINITY_FOOT_HEIGHT, -1); @@ -1506,6 +1642,11 @@ const LABEL_INSIDE_OVERLAP = 0.05; * pinch. Far below anything printed; the exact body has true arcs and is not moved. */ const LABEL_MESH_NUDGE: Vec3 = [1.1e-3, -1.7e-3, 0.5e-3]; +/** + * At an open side (#682) the pieces are cut off this far inside the outline: cut exactly at it, a moved piece's end + * lies in the plane of the bin's open end and Manifold leaves a sliver between them. + */ +const LABEL_OPEN_TRIM = 1e-3; /** Slivers thinner than this, left where pieces meet exactly on the bin's own vertices, are removed. */ const LABEL_SLIVER = 1e-4; @@ -1555,7 +1696,11 @@ export function gridfinityLabelledTriangles(runtime: ManifoldToplevel, shape: Gr return keep(standing.translate([x, y0, z])); }; const r = radius + LABEL_INSIDE_OVERLAP; - const inside = keep(Manifold.hull([post(-hx + radius, -hz + radius, r), post(hx - radius, -hz + radius, r), post(hx - radius, hz - radius, r), post(-hx + radius, hz - radius, r)])); + // Through an open side the inside runs out as the exact body's cutter does, and every piece is cut off at the outline (#682). + const { open, size } = gridfinityPlan(shape); + const grown = gridfinityOpenGrown(hx, hz, open, GRIDFINITY_OPEN_REACH); + const inside = keep(Manifold.hull([[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([sx, sz]) => post(grown.x + sx * (grown.hx - radius), grown.z + sz * (grown.hz - radius), r)))); + const trim = LABEL_WALLS.some((wall) => open[wall]) ? keep(Manifold.cube([size.width - 2 * LABEL_OPEN_TRIM, 4 * size.height, size.depth - 2 * LABEL_OPEN_TRIM], true)) : null; const profileOf = (of: GridfinityLabelPieces) => (p: GridfinityLabelProfile) => { const { start, end } = gridfinityLabelProfileEnds(of, p); return keep(Manifold.hull([...start, ...end])); @@ -1579,7 +1724,8 @@ export function gridfinityLabelledTriangles(runtime: ManifoldToplevel, shape: Gr const r = c.floorRounding; const reach = c.top + GRIDFINITY_DIVIDER_CUTTER_OVERLAP - (c.floor + r); const cutters = c.cells.map((cell) => { - const corners = [[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([sx, sz]): Vec3 => [sx * (cell.inside.hx - r), c.floor + r, sz * (cell.inside.hz - r)]); + const grown = gridfinityOpenGrown(cell.inside.hx, cell.inside.hz, cell.open, GRIDFINITY_OPEN_REACH); + const corners = [[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([sx, sz]): Vec3 => [grown.x + sx * (grown.hx - r), c.floor + r, grown.z + sz * (grown.hz - r)]); const ball = (at: Vec3) => keep(keep(Manifold.sphere(r, LABEL_CIRCLE_SEGMENTS)).translate(at)); const column = (at: Vec3) => keep(keep(keep(Manifold.cylinder(reach, r, r, LABEL_CIRCLE_SEGMENTS)).rotate([-90, 0, 0])).translate(at)); const cutter = keep(Manifold.hull([...corners.map(ball), ...corners.map(column)])); @@ -1598,15 +1744,19 @@ export function gridfinityLabelledTriangles(runtime: ManifoldToplevel, shape: Gr // Each compartment's label, cut to its compartment's inside as the shelves are to the bin's, moved into place. const compartmentLabels = (c: GridfinityCompartmentPieces) => c.cells.flatMap((cell) => { if (!cell.labels) return []; - const { hx: cx, hz: cz, radius: cr } = cell.inside; - const own = keep(Manifold.hull([post(-cx + cr, -cz + cr, cr + LABEL_INSIDE_OVERLAP), post(cx - cr, -cz + cr, cr + LABEL_INSIDE_OVERLAP), post(cx - cr, cz - cr, cr + LABEL_INSIDE_OVERLAP), post(-cx + cr, cz - cr, cr + LABEL_INSIDE_OVERLAP)])); + const { radius: cr } = cell.inside; + const { hx: cx, hz: cz, x: ox, z: oz } = gridfinityOpenGrown(cell.inside.hx, cell.inside.hz, cell.open, GRIDFINITY_OPEN_REACH); + const own = keep(Manifold.hull([[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([sx, sz]) => post(ox + sx * (cx - cr), oz + sz * (cz - cr), cr + LABEL_INSIDE_OVERLAP)))); return labelParts(cell.labels).map((part) => keep(keep(part.intersect(own)).translate([cell.centre.x, 0, cell.centre.z]))); }); const parts = [ ...(pieces ? labelParts(pieces) : []), ...(scoopPieces?.scoops ?? []).map((scoop) => scoopPrism(scoopPieces!, scoop)), ...(compartments ? [dividers(compartments), ...compartmentLabels(compartments)] : []), - ].map((part) => keep(keep(part.intersect(inside)).translate(LABEL_MESH_NUDGE))); + ].map((part) => { + const kept = keep(keep(part.intersect(inside)).translate(LABEL_MESH_NUDGE)); + return trim ? keep(kept.intersect(trim)) : kept; + }); const result = keep(keep(Manifold.union([bin, ...parts])).simplify(LABEL_SLIVER)); if (result.status() !== "NoError") return base; const out = result.getMesh(); diff --git a/apps/web/src/lib/gridfinitySolid.ts b/apps/web/src/lib/gridfinitySolid.ts index ab0f8904e..488c89329 100644 --- a/apps/web/src/lib/gridfinitySolid.ts +++ b/apps/web/src/lib/gridfinitySolid.ts @@ -11,7 +11,9 @@ import { GRIDFINITY_GRID, GRIDFINITY_MAGNET_DEPTH, GRIDFINITY_MAGNET_DIAMETER, + GRIDFINITY_OPEN_REACH, GRIDFINITY_SOCKET, + GRIDFINITY_WALL, type GridfinityCompartmentPieces, type GridfinityLabelCornerShape, type GridfinityLabelPieces, @@ -26,6 +28,8 @@ import { gridfinityLabelCornerOutline, gridfinityLabelPieces, gridfinityLabelProfileEnds, + gridfinityOpenCorners, + gridfinityOpenGrown, gridfinityPlan, gridfinityScoopPieces, gridfinityScoopPoint, @@ -52,6 +56,11 @@ import { * its compartments, each with a rounded floor and its own scoops, and what stays * between them is the dividers. Its labels are the compartments' own, each cut to * its compartment and joined as the shelves are. + * + * A bin with an open side (#682) has its block square in the corners there, + * and the inside's cutter and the compartments' run out through it far enough + * that their roundings, and the floor's, lie beyond the block; the shelves are + * cut to an inside that reaches out to the outline there. */ /** z up to y up: (x, y, z) -> (x, z, -y). */ @@ -72,13 +81,28 @@ function oneSolid(cad: OcctKernel, shape: ShapeHandle, what: string) { /** * A rounded rectangle at height z, centred on (cx, cz) of the shape's frame, * as a closed wire. The shape's depth (z) is the kernel's -y, so the turn up - * at the end puts it back. + * at the end puts it back. A `square` corner is one point where its two sides + * meet, with no arc. */ -function roundedWire(cad: OcctKernel, cx: number, cz: number, hx: number, hz: number, radius: number, z: number) { +function roundedWire(cad: OcctKernel, cx: number, cz: number, hx: number, hz: number, radius: number, z: number, square?: ReturnType) { const at = (x: number, depth: number) => ({ x: cx + x, y: -(cz + depth), z }); const ax = hx - radius; const az = hz - radius; const k = radius * Math.SQRT1_2; + if (square && Object.values(square).some(Boolean)) { + const corners = [ + { square: square.backRight, start: at(ax, -hz), middle: at(ax + k, -az - k), end: at(hx, -az), point: at(hx, -hz) }, + { square: square.frontRight, start: at(hx, az), middle: at(ax + k, az + k), end: at(ax, hz), point: at(hx, hz) }, + { square: square.frontLeft, start: at(-ax, hz), middle: at(-ax - k, az + k), end: at(-hx, az), point: at(-hx, hz) }, + { square: square.backLeft, start: at(-hx, -az), middle: at(-ax - k, -az - k), end: at(-ax, -hz), point: at(-hx, -hz) }, + ]; + // Each side from the corner before it, then the corner's arc unless it is square: the same edges in the same order. + return cad.makeWire(corners.flatMap((corner, i) => { + const before = corners[(i + 3) % 4]; + const side = cad.makeLineEdge(before.square ? before.point : before.end, corner.square ? corner.point : corner.start); + return corner.square ? [side] : [side, cad.makeArcEdge(corner.start, corner.middle, corner.end)]; + })); + } return cad.makeWire([ cad.makeLineEdge(at(-ax, -hz), at(ax, -hz)), cad.makeArcEdge(at(ax, -hz), at(ax + k, -az - k), at(hx, -az)), @@ -96,8 +120,8 @@ function steppedSolid(cad: OcctKernel, cx: number, cz: number, hx: number, hz: n return cad.loft(steps.map(([inset, y]) => roundedWire(cad, cx, cz, hx - inset, hz - inset, radius - inset, y)), true, true); } -function prism(cad: OcctKernel, hx: number, hz: number, radius: number, from: number, to: number) { - return cad.extrude(cad.makeFace(roundedWire(cad, 0, 0, hx, hz, radius, from)), 0, 0, to - from); +function prism(cad: OcctKernel, hx: number, hz: number, radius: number, from: number, to: number, cx = 0, cz = 0, square?: ReturnType) { + return cad.extrude(cad.makeFace(roundedWire(cad, cx, cz, hx, hz, radius, from, square)), 0, 0, to - from); } function binSolid(cad: OcctKernel, plan: GridfinityPlan, scoops: GridfinityScoopPieces | null, compartments: GridfinityCompartmentPieces | null) { @@ -108,12 +132,18 @@ function binSolid(cad: OcctKernel, plan: GridfinityPlan, scoops: GridfinityScoop const wall = gridfinityWallProfile(plan, 0); const wallTop = wall[1][1]; const feet = plan.cells.map((cell) => steppedSolid(cad, cell.x, cell.z, cellHalf, cellHalf, GRIDFINITY_BIN_RADIUS, GRIDFINITY_FOOT)); - let body = oneSolid(cad, cad.fuseAll([...feet, prism(cad, hx, hz, GRIDFINITY_BIN_RADIUS, GRIDFINITY_FOOT_HEIGHT, wallTop)]), "bin"); + const { open } = plan; + // The corners touching an open side are square from the feet up, so the floor runs flat out to the edge there too. + const square = gridfinityOpenCorners(open); + const opened = Object.values(square).some(Boolean); + let body = oneSolid(cad, cad.fuseAll([...feet, prism(cad, hx, hz, GRIDFINITY_BIN_RADIUS, GRIDFINITY_FOOT_HEIGHT, wallTop, 0, 0, square)]), "bin"); // The inside, from the floor up: the wall's inner steps in rising order, then out past the top. const inside: GridfinityStep[] = wall.slice(2).reverse(); if (plan.lip) inside.push([0, wallTop], [0, wallTop + PAST]); else inside.push([inside[inside.length - 1][0], wallTop + PAST]); - let cutter = steppedSolid(cad, 0, 0, hx, hz, GRIDFINITY_BIN_RADIUS, inside); + // Through an open side it runs on until its corners, and the floor's rounding, lie 2 mm beyond the outline. + const grown = gridfinityOpenGrown(hx, hz, open, GRIDFINITY_OPEN_REACH); + let cutter = steppedSolid(cad, grown.x, grown.z, grown.hx, grown.hz, GRIDFINITY_BIN_RADIUS, inside); const loft = cutter; if (compartments) cutter = compartmentCutter(cad, loft, compartments, hx, hz, wallTop); else if (plan.floorRounding > 0) { @@ -133,7 +163,9 @@ function binSolid(cad: OcctKernel, plan: GridfinityPlan, scoops: GridfinityScoop const [inset] = inside[inside.length - 1]; const over = (4 * (hx - inset) * (hz - inset) - (4 - Math.PI) * (GRIDFINITY_BIN_RADIUS - inset) ** 2) * PAST; const bound = gridfinityDividerBound(plan, compartments) + gridfinityCompartmentFloorBound(plan, compartments); - if (cad.getVolume(body) < cad.getVolume(block) - cad.getVolume(loft) + over + bound - PIECE_MISSED) throw new Error("The Gridfinity bin's dividers could not be cut"); + // Through an open side the inside runs on past the block, so what it would leave is measured by cutting it. + const plain = opened ? cad.getVolume(cad.cut(block, loft)) : cad.getVolume(block) - cad.getVolume(loft) + over; + if (cad.getVolume(body) < plain + bound - PIECE_MISSED) throw new Error("The Gridfinity bin's dividers could not be cut"); } if (plan.magnetHoles.length > 0) { const holes = plan.magnetHoles.map((hole) => cad.translate(cad.makeCylinder(GRIDFINITY_MAGNET_DIAMETER / 2, GRIDFINITY_MAGNET_DEPTH + PAST), hole.x, -hole.z, -PAST)); @@ -150,7 +182,9 @@ function binSolid(cad: OcctKernel, plan: GridfinityPlan, scoops: GridfinityScoop function compartmentCutter(cad: OcctKernel, loft: ShapeHandle, pieces: GridfinityCompartmentPieces, hx: number, hz: number, wallTop: number) { const above = cad.makeBoxFromCorners({ x: -hx - 1, y: -hz - 1, z: pieces.top }, { x: hx + 1, y: hz + 1, z: wallTop + 1 }); const cutters = pieces.cells.map((cell) => { - let cutter = prism(cad, cell.inside.hx, cell.inside.hz, cell.inside.radius, pieces.floor, pieces.top + GRIDFINITY_DIVIDER_CUTTER_OVERLAP); + // A compartment on an open side runs out through it as the inside does. + const grown = gridfinityOpenGrown(cell.inside.hx, cell.inside.hz, cell.open, GRIDFINITY_OPEN_REACH); + let cutter = prism(cad, grown.hx, grown.hz, cell.inside.radius, pieces.floor, pieces.top + GRIDFINITY_DIVIDER_CUTTER_OVERLAP, grown.x, grown.z); const floorEdges = cad.getSubShapes(cutter, "edge").filter((edge) => { const box = cad.getBoundingBox(edge); return Math.abs(box.zmin - pieces.floor) < 1e-6 && Math.abs(box.zmax - pieces.floor) < 1e-6; @@ -183,11 +217,13 @@ function polygonFace(cad: OcctKernel, points: Array<{ x: number; y: number; z: n /** The label shelves and their corner pieces, in the shape's own frame (y up), each cut to the bin's inside. */ function labelSolids(cad: OcctKernel, pieces: GridfinityLabelPieces) { - const { hx, hz, radius } = pieces.inside; + const { hx, hz, radius, open = {} } = pieces.inside; const y0 = pieces.floor; const y1 = pieces.top; - // The inside: the inner wall's rounded rectangle from the floor up to the shelves' top. - const inside = cad.transform(prism(cad, hx, hz, radius, y0, y1), Z_TO_Y_UP); + // The inside: the inner wall's rounded rectangle from the floor up to the shelves' top, out to the outline and square + // in the corners at an open side, so a shelf beside it ends flush with the wall's end. + const grown = gridfinityOpenGrown(hx, hz, open, GRIDFINITY_WALL); + const inside = cad.transform(prism(cad, grown.hx, grown.hz, radius, y0, y1, grown.x, grown.z, gridfinityOpenCorners(open)), Z_TO_Y_UP); const profile = (p: GridfinityLabelProfile) => { const { start, end } = gridfinityLabelProfileEnds(pieces, p); const face = polygonFace(cad, start.map(([x, y, z]) => ({ x, y, z }))); diff --git a/apps/web/src/lib/lylProject.ts b/apps/web/src/lib/lylProject.ts index 0de0c4dca..0023372e9 100644 --- a/apps/web/src/lib/lylProject.ts +++ b/apps/web/src/lib/lylProject.ts @@ -1545,7 +1545,7 @@ function validateShapeDefinition(definition: Record, label: str if (definition.gridfinityType !== undefined && definition.gridfinityType !== "bin" && definition.gridfinityType !== "baseplate") { throw new Error(`${label}.gridfinityType is invalid`); } - for (const key of ["gridfinityLip", "gridfinityMagnets", "gridfinityHalfGrid", "gridfinityLabelBack", "gridfinityLabelFront", "gridfinityLabelLeft", "gridfinityLabelRight", "gridfinityScoopBack", "gridfinityScoopFront", "gridfinityScoopLeft", "gridfinityScoopRight", "hingePinless"] as const) { + for (const key of ["gridfinityLip", "gridfinityMagnets", "gridfinityHalfGrid", "gridfinityLabelBack", "gridfinityLabelFront", "gridfinityLabelLeft", "gridfinityLabelRight", "gridfinityScoopBack", "gridfinityScoopFront", "gridfinityScoopLeft", "gridfinityScoopRight", "gridfinityOpenBack", "gridfinityOpenFront", "gridfinityOpenLeft", "gridfinityOpenRight", "hingePinless"] as const) { if (definition[key] !== undefined && typeof definition[key] !== "boolean") throw new Error(`${label}.${key} must be true or false`); } for (const key of ["gridfinityLabelBackWidth", "gridfinityLabelBackLength", "gridfinityLabelBackOffset", "gridfinityLabelFrontWidth", "gridfinityLabelFrontLength", "gridfinityLabelFrontOffset", "gridfinityLabelLeftWidth", "gridfinityLabelLeftLength", "gridfinityLabelLeftOffset", "gridfinityLabelRightWidth", "gridfinityLabelRightLength", "gridfinityLabelRightOffset", "gridfinityLabelCornerSize", "gridfinityScoopRadius", "gridfinityFloorHeight", "gridfinityCompartmentsX", "gridfinityCompartmentsZ", "gridfinityDividerThickness"] as const) { diff --git a/apps/web/src/lib/mcpShapeSettings.ts b/apps/web/src/lib/mcpShapeSettings.ts index 23ceefe51..a85353269 100644 --- a/apps/web/src/lib/mcpShapeSettings.ts +++ b/apps/web/src/lib/mcpShapeSettings.ts @@ -37,7 +37,7 @@ export const MCP_SHAPE_SETTING_KEYS = [ "hingeKnuckles", "hingePinDiameter", "hingeLeafThickness", "hingeClearance", "hingePinless", "hingeAngle", "gridfinityType", "gridfinityLip", "gridfinityMagnets", "gridfinityHalfGrid", "gridfinityLabelBack", "gridfinityLabelFront", "gridfinityLabelLeft", "gridfinityLabelRight", "gridfinityLabelBackWidth", "gridfinityLabelBackLength", "gridfinityLabelBackOffset", "gridfinityLabelFrontWidth", "gridfinityLabelFrontLength", "gridfinityLabelFrontOffset", "gridfinityLabelLeftWidth", "gridfinityLabelLeftLength", "gridfinityLabelLeftOffset", "gridfinityLabelRightWidth", "gridfinityLabelRightLength", "gridfinityLabelRightOffset", "gridfinityLabelCorner", "gridfinityLabelCornerSize", - "gridfinityScoopBack", "gridfinityScoopFront", "gridfinityScoopLeft", "gridfinityScoopRight", "gridfinityScoopRadius", "gridfinityFloorHeight", + "gridfinityScoopBack", "gridfinityScoopFront", "gridfinityScoopLeft", "gridfinityScoopRight", "gridfinityScoopRadius", "gridfinityFloorHeight", "gridfinityOpenBack", "gridfinityOpenFront", "gridfinityOpenLeft", "gridfinityOpenRight", "gridfinityCompartmentsX", "gridfinityCompartmentsZ", "gridfinityDividerThickness", "gridfinityDividerPositionsX", "gridfinityDividerPositionsZ", "gridfinityCompartmentLabels", "knurlPattern", "knurlCount", "knurlDepth", "knurlAngle", "knurlChamfer", "dovetailNeckWidth", "dovetailClearance", diff --git a/apps/web/src/lib/messages.ar.ts b/apps/web/src/lib/messages.ar.ts index 9042d60e2..b86f74cf7 100644 --- a/apps/web/src/lib/messages.ar.ts +++ b/apps/web/src/lib/messages.ar.ts @@ -2018,6 +2018,10 @@ export const MESSAGES_AR: Partial> = { "gridfinity.halfGridHint": "تحسب الوحدات في العرض والعمق خلايا من 21 مم، لكل منها قدم خاصة بها؛ وتقف أربع منها في تجويف واحد من لوح الشبكة.", "prop.gridfinityFloorHeight": "ارتفاع الأرضية", "gridfinity.floorHeightHint": "حتى هذا الارتفاع، مقيسًا من الأسفل، تكون العلبة مصمتة: قطعة خام تقطع فيها الأشكال.", + "prop.gridfinityOpenBack": "الجانب الخلفي مفتوح", + "prop.gridfinityOpenFront": "الجانب الأمامي مفتوح", + "prop.gridfinityOpenLeft": "الجانب الأيسر مفتوح", + "prop.gridfinityOpenRight": "الجانب الأيمن مفتوح", "prop.gridfinityLabelBack": "رف الملصق في الخلف", "prop.gridfinityLabelFront": "رف الملصق في الأمام", "prop.gridfinityLabelLeft": "رف الملصق على اليسار", @@ -2058,6 +2062,7 @@ export const MESSAGES_AR: Partial> = { "prop.gridfinityCompartmentLabelLength": "طول الرف (%)", "gridfinity.compartmentLabelLengthHint": "عند 100 يمتد الرف على طول الجانب كله.", "gridfinity.compartmentLabelTooShortHint": "هذا الرف أقصر من 4 مم فيُترك.", + "gridfinity.compartmentLabelOpenHint": "هذا الجانب من العلبة مفتوح وليس فيه جدار للرف، فيُترك الرف.", "gridfinity.alignLeft": "يسار", "gridfinity.alignMiddle": "وسط", "gridfinity.alignRight": "يمين", diff --git a/apps/web/src/lib/messages.cs.ts b/apps/web/src/lib/messages.cs.ts index 252133260..379f45b0a 100644 --- a/apps/web/src/lib/messages.cs.ts +++ b/apps/web/src/lib/messages.cs.ts @@ -2032,6 +2032,10 @@ export const MESSAGES_CS: Record = { "gridfinity.halfGridHint": "Jednotky do šířky a do hloubky počítají buňky po 21 mm, každou s vlastní nožkou; čtyři stojí v jednom lůžku nosné desky.", "prop.gridfinityFloorHeight": "Výška dna", "gridfinity.floorHeightHint": "Až do této výšky, měřeno od spodku, je krabička plná: polotovar, do kterého vyříznete tvary.", + "prop.gridfinityOpenBack": "Otevřeno vzadu", + "prop.gridfinityOpenFront": "Otevřeno vpředu", + "prop.gridfinityOpenLeft": "Otevřeno vlevo", + "prop.gridfinityOpenRight": "Otevřeno vpravo", "prop.gridfinityLabelBack": "Polička na štítek vzadu", "prop.gridfinityLabelFront": "Polička na štítek vpředu", "prop.gridfinityLabelLeft": "Polička na štítek vlevo", @@ -2072,6 +2076,7 @@ export const MESSAGES_CS: Record = { "prop.gridfinityCompartmentLabelLength": "Délka poličky (%)", "gridfinity.compartmentLabelLengthHint": "Při 100 vede polička po celé délce strany.", "gridfinity.compartmentLabelTooShortHint": "Tato polička je kratší než 4 mm a vynechá se.", + "gridfinity.compartmentLabelOpenHint": "Tato strana krabičky je otevřená a nemá stěnu pro poličku, proto se polička vynechá.", "gridfinity.alignLeft": "Vlevo", "gridfinity.alignMiddle": "Uprostřed", "gridfinity.alignRight": "Vpravo", diff --git a/apps/web/src/lib/messages.da.ts b/apps/web/src/lib/messages.da.ts index 12a4156ca..400d8bb36 100644 --- a/apps/web/src/lib/messages.da.ts +++ b/apps/web/src/lib/messages.da.ts @@ -1933,6 +1933,10 @@ export const MESSAGES_DA: Partial> = { "gridfinity.halfGridHint": "Enheder i bredden og dybden tæller celler på 21 mm, hver med sin egen fod; fire står i én fordybning i bundpladen.", "prop.gridfinityFloorHeight": "Bundens højde", "gridfinity.floorHeightHint": "Op til denne højde, målt fra undersiden, er bakken massiv: et emne, du kan skære former ud i.", + "prop.gridfinityOpenBack": "Åben bagtil", + "prop.gridfinityOpenFront": "Åben fortil", + "prop.gridfinityOpenLeft": "Åben til venstre", + "prop.gridfinityOpenRight": "Åben til højre", "prop.gridfinityLabelBack": "Etiketliste bagtil", "prop.gridfinityLabelFront": "Etiketliste fortil", "prop.gridfinityLabelLeft": "Etiketliste til venstre", @@ -1973,6 +1977,7 @@ export const MESSAGES_DA: Partial> = { "prop.gridfinityCompartmentLabelLength": "Listens længde (%)", "gridfinity.compartmentLabelLengthHint": "Ved 100 går listen langs hele siden.", "gridfinity.compartmentLabelTooShortHint": "Denne liste er kortere end 4 mm og udelades.", + "gridfinity.compartmentLabelOpenHint": "Denne side af bakken er åben og har ingen væg til listen, så listen udelades.", "gridfinity.alignLeft": "Venstre", "gridfinity.alignMiddle": "Midt", "gridfinity.alignRight": "Højre", diff --git a/apps/web/src/lib/messages.de.ts b/apps/web/src/lib/messages.de.ts index 53651d1cb..97b74de35 100644 --- a/apps/web/src/lib/messages.de.ts +++ b/apps/web/src/lib/messages.de.ts @@ -2022,6 +2022,10 @@ export const MESSAGES_DE: Record = { "gridfinity.halfGridHint": "Einheiten breit und tief zählen Zellen von 21 mm, jede mit einem eigenen Fuß; vier stehen in einer Mulde der Bodenplatte.", "prop.gridfinityFloorHeight": "Bodenhöhe", "gridfinity.floorHeightHint": "Bis zu dieser Höhe, von der Unterseite an gemessen, ist der Behälter massiv: ein Rohling, in den du Formen schneidest.", + "prop.gridfinityOpenBack": "Hinten offen", + "prop.gridfinityOpenFront": "Vorne offen", + "prop.gridfinityOpenLeft": "Links offen", + "prop.gridfinityOpenRight": "Rechts offen", "prop.gridfinityLabelBack": "Etikettenleiste hinten", "prop.gridfinityLabelFront": "Etikettenleiste vorne", "prop.gridfinityLabelLeft": "Etikettenleiste links", @@ -2062,6 +2066,7 @@ export const MESSAGES_DE: Record = { "prop.gridfinityCompartmentLabelLength": "Länge der Leiste (%)", "gridfinity.compartmentLabelLengthHint": "Bei 100 läuft die Leiste über die ganze Seite.", "gridfinity.compartmentLabelTooShortHint": "Diese Leiste ist kürzer als 4 mm und entfällt.", + "gridfinity.compartmentLabelOpenHint": "Diese Seite des Behälters ist offen und hat keine Wand, daher entfällt die Leiste.", "gridfinity.alignLeft": "Links", "gridfinity.alignMiddle": "Mitte", "gridfinity.alignRight": "Rechts", diff --git a/apps/web/src/lib/messages.en.ts b/apps/web/src/lib/messages.en.ts index 08e64ce34..b6dc862ef 100644 --- a/apps/web/src/lib/messages.en.ts +++ b/apps/web/src/lib/messages.en.ts @@ -2023,6 +2023,10 @@ export const MESSAGES_EN = { "gridfinity.halfGridHint": "Units across and deep count cells of 21 mm, each with a foot of its own; four stand in one socket of a baseplate.", "prop.gridfinityFloorHeight": "Floor height", "gridfinity.floorHeightHint": "The bin is solid from its underside up to this height, a blank to cut shapes into.", + "prop.gridfinityOpenBack": "Open at the back", + "prop.gridfinityOpenFront": "Open at the front", + "prop.gridfinityOpenLeft": "Open on the left", + "prop.gridfinityOpenRight": "Open on the right", "prop.gridfinityLabelBack": "Label shelf at the back", "prop.gridfinityLabelFront": "Label shelf at the front", "prop.gridfinityLabelLeft": "Label shelf on the left", @@ -2063,6 +2067,7 @@ export const MESSAGES_EN = { "prop.gridfinityCompartmentLabelLength": "Shelf length (%)", "gridfinity.compartmentLabelLengthHint": "100 runs the shelf the full length of its side.", "gridfinity.compartmentLabelTooShortHint": "This shelf is shorter than 4 mm and is left out.", + "gridfinity.compartmentLabelOpenHint": "This side of the bin is open and has no wall, so the shelf is left out.", "gridfinity.alignLeft": "Left", "gridfinity.alignMiddle": "Middle", "gridfinity.alignRight": "Right", diff --git a/apps/web/src/lib/messages.es.ts b/apps/web/src/lib/messages.es.ts index 08cef6be6..7af7f6f2d 100644 --- a/apps/web/src/lib/messages.es.ts +++ b/apps/web/src/lib/messages.es.ts @@ -2025,6 +2025,10 @@ export const MESSAGES_ES: Partial> = { "gridfinity.halfGridHint": "Las unidades de ancho y de fondo cuentan celdas de 21 mm, cada una con su propio pie; cuatro caben en un hueco de la placa base.", "prop.gridfinityFloorHeight": "Altura del fondo", "gridfinity.floorHeightHint": "La caja es maciza desde la base hasta esta altura: un bloque en bruto en el que recortar formas.", + "prop.gridfinityOpenBack": "Abierta detrás", + "prop.gridfinityOpenFront": "Abierta delante", + "prop.gridfinityOpenLeft": "Abierta a la izquierda", + "prop.gridfinityOpenRight": "Abierta a la derecha", "prop.gridfinityLabelBack": "Repisa para etiqueta detrás", "prop.gridfinityLabelFront": "Repisa para etiqueta delante", "prop.gridfinityLabelLeft": "Repisa para etiqueta a la izquierda", @@ -2065,6 +2069,7 @@ export const MESSAGES_ES: Partial> = { "prop.gridfinityCompartmentLabelLength": "Longitud de la repisa (%)", "gridfinity.compartmentLabelLengthHint": "Con 100, la repisa ocupa todo su lado.", "gridfinity.compartmentLabelTooShortHint": "Esta repisa mide menos de 4 mm y se omite.", + "gridfinity.compartmentLabelOpenHint": "Este lado de la caja está abierto y no tiene pared, así que la repisa se omite.", "gridfinity.alignLeft": "Izquierda", "gridfinity.alignMiddle": "Centro", "gridfinity.alignRight": "Derecha", diff --git a/apps/web/src/lib/messages.fr.ts b/apps/web/src/lib/messages.fr.ts index 1b2e8f51d..5c40e6352 100644 --- a/apps/web/src/lib/messages.fr.ts +++ b/apps/web/src/lib/messages.fr.ts @@ -2008,6 +2008,10 @@ export const MESSAGES_FR: Partial> = { "gridfinity.halfGridHint": "Les unités en largeur et en profondeur comptent des cellules de 21 mm, chacune avec son propre pied ; quatre se posent dans un logement du socle.", "prop.gridfinityFloorHeight": "Hauteur du fond", "gridfinity.floorHeightHint": "Jusqu'à cette hauteur, mesurée depuis le dessous, le bac est plein : une ébauche dans laquelle découper des formes.", + "prop.gridfinityOpenBack": "Ouvert à l'arrière", + "prop.gridfinityOpenFront": "Ouvert à l'avant", + "prop.gridfinityOpenLeft": "Ouvert à gauche", + "prop.gridfinityOpenRight": "Ouvert à droite", "prop.gridfinityLabelBack": "Tablette d'étiquette à l'arrière", "prop.gridfinityLabelFront": "Tablette d'étiquette à l'avant", "prop.gridfinityLabelLeft": "Tablette d'étiquette à gauche", @@ -2048,6 +2052,7 @@ export const MESSAGES_FR: Partial> = { "prop.gridfinityCompartmentLabelLength": "Longueur (%)", "gridfinity.compartmentLabelLengthHint": "À 100, la tablette occupe toute la longueur du côté.", "gridfinity.compartmentLabelTooShortHint": "Cette tablette fait moins de 4 mm et est omise.", + "gridfinity.compartmentLabelOpenHint": "Ce côté du bac est ouvert et n'a pas de paroi pour la tablette, qui est donc omise.", "gridfinity.alignLeft": "Gauche", "gridfinity.alignMiddle": "Milieu", "gridfinity.alignRight": "Droite", diff --git a/apps/web/src/lib/messages.hu.ts b/apps/web/src/lib/messages.hu.ts index 12e199322..d1a484996 100644 --- a/apps/web/src/lib/messages.hu.ts +++ b/apps/web/src/lib/messages.hu.ts @@ -1967,6 +1967,10 @@ export const MESSAGES_HU: Partial> = { "gridfinity.halfGridHint": "A szélességi és mélységi egységek 21 mm-es cellákat számolnak, mindegyiknek saját lába van; négy áll az alapkeret egy fészkében.", "prop.gridfinityFloorHeight": "Fenékmagasság", "gridfinity.floorHeightHint": "Eddig a magasságig, az aljától mérve, a rekesz tömör: nyersdarab, amelybe formákat vághatsz.", + "prop.gridfinityOpenBack": "Nyitott hátul", + "prop.gridfinityOpenFront": "Nyitott elöl", + "prop.gridfinityOpenLeft": "Nyitott a bal oldalon", + "prop.gridfinityOpenRight": "Nyitott a jobb oldalon", "prop.gridfinityLabelBack": "Címkepolc hátul", "prop.gridfinityLabelFront": "Címkepolc elöl", "prop.gridfinityLabelLeft": "Címkepolc a bal oldalon", @@ -2007,6 +2011,7 @@ export const MESSAGES_HU: Partial> = { "prop.gridfinityCompartmentLabelLength": "Polc hossza (%)", "gridfinity.compartmentLabelLengthHint": "100 esetén a polc az oldal teljes hosszán fut.", "gridfinity.compartmentLabelTooShortHint": "Ez a polc 4 mm-nél rövidebb, ezért elmarad.", + "gridfinity.compartmentLabelOpenHint": "A rekesz ezen oldala nyitott, nincs fala a polcnak, ezért a polc elmarad.", "gridfinity.alignLeft": "Balra", "gridfinity.alignMiddle": "Középen", "gridfinity.alignRight": "Jobbra", diff --git a/apps/web/src/lib/messages.it.ts b/apps/web/src/lib/messages.it.ts index 150eab0b1..d6e7f882a 100644 --- a/apps/web/src/lib/messages.it.ts +++ b/apps/web/src/lib/messages.it.ts @@ -1927,6 +1927,10 @@ export const MESSAGES_IT: Partial> = { "gridfinity.halfGridHint": "Le unità in larghezza e in profondità contano celle da 21 mm, ognuna con il proprio piede; quattro stanno in un alloggio della base a griglia.", "prop.gridfinityFloorHeight": "Altezza del fondo", "gridfinity.floorHeightHint": "Fino a questa altezza, misurata dal lato inferiore, il contenitore è pieno: un grezzo in cui ritagliare forme.", + "prop.gridfinityOpenBack": "Aperto dietro", + "prop.gridfinityOpenFront": "Aperto davanti", + "prop.gridfinityOpenLeft": "Aperto a sinistra", + "prop.gridfinityOpenRight": "Aperto a destra", "prop.gridfinityLabelBack": "Ripiano per etichetta dietro", "prop.gridfinityLabelFront": "Ripiano per etichetta davanti", "prop.gridfinityLabelLeft": "Ripiano per etichetta a sinistra", @@ -1967,6 +1971,7 @@ export const MESSAGES_IT: Partial> = { "prop.gridfinityCompartmentLabelLength": "Lunghezza ripiano (%)", "gridfinity.compartmentLabelLengthHint": "Con 100 il ripiano occupa tutta la lunghezza del lato.", "gridfinity.compartmentLabelTooShortHint": "Questo ripiano è più corto di 4 mm e viene omesso.", + "gridfinity.compartmentLabelOpenHint": "Questo lato del contenitore è aperto e non ha una parete per il ripiano, che quindi viene omesso.", "gridfinity.alignLeft": "Sinistra", "gridfinity.alignMiddle": "Centro", "gridfinity.alignRight": "Destra", diff --git a/apps/web/src/lib/messages.nl.ts b/apps/web/src/lib/messages.nl.ts index 39b20d358..ec4d08c4e 100644 --- a/apps/web/src/lib/messages.nl.ts +++ b/apps/web/src/lib/messages.nl.ts @@ -2022,6 +2022,10 @@ export const MESSAGES_NL: Record = { "gridfinity.halfGridHint": "Eenheden breed en diep tellen cellen van 21 mm, elk met een eigen voet; vier staan in één vak van een draagplaat.", "prop.gridfinityFloorHeight": "Bodemhoogte", "gridfinity.floorHeightHint": "Tot deze hoogte, gemeten vanaf de onderkant, is de bak massief: een blok om vormen in uit te snijden.", + "prop.gridfinityOpenBack": "Achter open", + "prop.gridfinityOpenFront": "Voor open", + "prop.gridfinityOpenLeft": "Links open", + "prop.gridfinityOpenRight": "Rechts open", "prop.gridfinityLabelBack": "Labelplank achter", "prop.gridfinityLabelFront": "Labelplank voor", "prop.gridfinityLabelLeft": "Labelplank links", @@ -2062,6 +2066,7 @@ export const MESSAGES_NL: Record = { "prop.gridfinityCompartmentLabelLength": "Planklengte (%)", "gridfinity.compartmentLabelLengthHint": "Bij 100 loopt de plank over de hele zijde.", "gridfinity.compartmentLabelTooShortHint": "Deze plank is korter dan 4 mm en valt weg.", + "gridfinity.compartmentLabelOpenHint": "Deze zijde van de bak is open en heeft geen wand voor de plank, dus de plank valt weg.", "gridfinity.alignLeft": "Links", "gridfinity.alignMiddle": "Midden", "gridfinity.alignRight": "Rechts", diff --git a/apps/web/src/lib/messages.pl.ts b/apps/web/src/lib/messages.pl.ts index 48079679c..37ddb8968 100644 --- a/apps/web/src/lib/messages.pl.ts +++ b/apps/web/src/lib/messages.pl.ts @@ -2028,6 +2028,10 @@ export const MESSAGES_PL: Partial> = { "gridfinity.halfGridHint": "Jednostki wszerz i w głąb liczą komórki po 21 mm, każdą z własną stopką; cztery stoją w jednym gnieździe podstawki.", "prop.gridfinityFloorHeight": "Wysokość dna", "gridfinity.floorHeightHint": "Do tej wysokości, mierząc od spodu, pojemnik jest pełny: półfabrykat, w którym wytniesz kształty.", + "prop.gridfinityOpenBack": "Otwarty z tyłu", + "prop.gridfinityOpenFront": "Otwarty z przodu", + "prop.gridfinityOpenLeft": "Otwarty z lewej", + "prop.gridfinityOpenRight": "Otwarty z prawej", "prop.gridfinityLabelBack": "Półka na etykietę z tyłu", "prop.gridfinityLabelFront": "Półka na etykietę z przodu", "prop.gridfinityLabelLeft": "Półka na etykietę z lewej", @@ -2068,6 +2072,7 @@ export const MESSAGES_PL: Partial> = { "prop.gridfinityCompartmentLabelLength": "Długość półki (%)", "gridfinity.compartmentLabelLengthHint": "Przy 100 półka biegnie przez całą długość boku.", "gridfinity.compartmentLabelTooShortHint": "Ta półka jest krótsza niż 4 mm i zostaje pominięta.", + "gridfinity.compartmentLabelOpenHint": "Ta strona pojemnika jest otwarta i nie ma ściany na półkę, więc półka zostaje pominięta.", "gridfinity.alignLeft": "Z lewej", "gridfinity.alignMiddle": "Pośrodku", "gridfinity.alignRight": "Z prawej", diff --git a/apps/web/src/lib/messages.pt-br.ts b/apps/web/src/lib/messages.pt-br.ts index 4aca2ef07..951551d2b 100644 --- a/apps/web/src/lib/messages.pt-br.ts +++ b/apps/web/src/lib/messages.pt-br.ts @@ -663,6 +663,10 @@ export const MESSAGES_PT_BR: Partial> = { "prop.gridfinityMagnets": "Furos para ímãs", "prop.gridfinityHalfGrid": "Grade de meio tamanho (21 mm)", "prop.gridfinityFloorHeight": "Altura do fundo", + "prop.gridfinityOpenBack": "Aberta atrás", + "prop.gridfinityOpenFront": "Aberta na frente", + "prop.gridfinityOpenLeft": "Aberta à esquerda", + "prop.gridfinityOpenRight": "Aberta à direita", "prop.gridfinityLabelBack": "Apoio de etiqueta atrás", "prop.gridfinityLabelFront": "Apoio de etiqueta na frente", "prop.gridfinityLabelLeft": "Apoio de etiqueta à esquerda", @@ -700,6 +704,7 @@ export const MESSAGES_PT_BR: Partial> = { "prop.gridfinityCompartmentLabelLength": "Comprimento do apoio (%)", "gridfinity.compartmentLabelLengthHint": "100 leva o apoio por todo o comprimento do lado.", "gridfinity.compartmentLabelTooShortHint": "Este apoio tem menos de 4 mm e fica de fora.", + "gridfinity.compartmentLabelOpenHint": "Este lado da caixa está aberto e não tem parede para o apoio, que fica de fora.", "gridfinity.alignLeft": "Esquerda", "gridfinity.alignMiddle": "Meio", "gridfinity.alignRight": "Direita", diff --git a/apps/web/src/lib/messages.ru.ts b/apps/web/src/lib/messages.ru.ts index 0a348bcbf..6187901bb 100644 --- a/apps/web/src/lib/messages.ru.ts +++ b/apps/web/src/lib/messages.ru.ts @@ -2015,6 +2015,10 @@ export const MESSAGES_RU: Partial> = { "gridfinity.halfGridHint": "Ширина и глубина считаются в клетках по 21 мм, у каждой своя ножка; четыре встают в одно гнездо базовой плиты.", "prop.gridfinityFloorHeight": "Высота дна", "gridfinity.floorHeightHint": "До этой высоты, считая от низа, контейнер сплошной: заготовка, в которой можно вырезать формы.", + "prop.gridfinityOpenBack": "Открыт сзади", + "prop.gridfinityOpenFront": "Открыт спереди", + "prop.gridfinityOpenLeft": "Открыт слева", + "prop.gridfinityOpenRight": "Открыт справа", "prop.gridfinityLabelBack": "Полка для этикетки сзади", "prop.gridfinityLabelFront": "Полка для этикетки спереди", "prop.gridfinityLabelLeft": "Полка для этикетки слева", @@ -2055,6 +2059,7 @@ export const MESSAGES_RU: Partial> = { "prop.gridfinityCompartmentLabelLength": "Длина полки (%)", "gridfinity.compartmentLabelLengthHint": "При 100 полка идёт по всей длине стороны.", "gridfinity.compartmentLabelTooShortHint": "Эта полка короче 4 мм и не строится.", + "gridfinity.compartmentLabelOpenHint": "Эта сторона контейнера открыта: стенки для полки нет, поэтому полка не строится.", "gridfinity.alignLeft": "Слева", "gridfinity.alignMiddle": "Посередине", "gridfinity.alignRight": "Справа", diff --git a/apps/web/src/lib/messages.sl.ts b/apps/web/src/lib/messages.sl.ts index 2279ba613..7eab50086 100644 --- a/apps/web/src/lib/messages.sl.ts +++ b/apps/web/src/lib/messages.sl.ts @@ -1472,6 +1472,10 @@ export const MESSAGES_SL: Partial> = { "gridfinity.halfGridHint": "Enote v širino in globino štejejo celice po 21 mm, vsako s svojo nogo; štiri stojijo v enem ležišču podstavka.", "prop.gridfinityFloorHeight": "Višina dna", "gridfinity.floorHeightHint": "Do te višine, merjeno od spodnje strani, je posoda polna: surovec, v katerega izrežeš oblike.", + "prop.gridfinityOpenBack": "Odprto zadaj", + "prop.gridfinityOpenFront": "Odprto spredaj", + "prop.gridfinityOpenLeft": "Odprto levo", + "prop.gridfinityOpenRight": "Odprto desno", "prop.gridfinityLabelBack": "Polička za nalepko zadaj", "prop.gridfinityLabelFront": "Polička za nalepko spredaj", "prop.gridfinityLabelLeft": "Polička za nalepko levo", @@ -1512,6 +1516,7 @@ export const MESSAGES_SL: Partial> = { "prop.gridfinityCompartmentLabelLength": "Dolžina poličke (%)", "gridfinity.compartmentLabelLengthHint": "Pri 100 polička teče po vsej dolžini stranice.", "gridfinity.compartmentLabelTooShortHint": "Ta polička je krajša od 4 mm in odpade.", + "gridfinity.compartmentLabelOpenHint": "Ta stran posode je odprta in nima stene za poličko, zato polička odpade.", "gridfinity.alignLeft": "Levo", "gridfinity.alignMiddle": "Na sredini", "gridfinity.alignRight": "Desno", diff --git a/apps/web/src/lib/messages.tr.ts b/apps/web/src/lib/messages.tr.ts index 1f5fc6252..2c1df2f07 100644 --- a/apps/web/src/lib/messages.tr.ts +++ b/apps/web/src/lib/messages.tr.ts @@ -1967,6 +1967,10 @@ export const MESSAGES_TR: Partial> = { "gridfinity.halfGridHint": "Genişlik ve derinlik birimleri 21 mm'lik hücreleri sayar, her birinin kendi ayağı vardır; dördü taban plakasının bir yuvasında durur.", "prop.gridfinityFloorHeight": "Zemin yüksekliği", "gridfinity.floorHeightHint": "Alt yüzeyden ölçülen bu yüksekliğe kadar kutu doludur: içine şekiller oyabileceğin bir ham parça.", + "prop.gridfinityOpenBack": "Arkası açık", + "prop.gridfinityOpenFront": "Önü açık", + "prop.gridfinityOpenLeft": "Solu açık", + "prop.gridfinityOpenRight": "Sağı açık", "prop.gridfinityLabelBack": "Arkada etiket rafı", "prop.gridfinityLabelFront": "Önde etiket rafı", "prop.gridfinityLabelLeft": "Solda etiket rafı", @@ -2007,6 +2011,7 @@ export const MESSAGES_TR: Partial> = { "prop.gridfinityCompartmentLabelLength": "Raf uzunluğu (%)", "gridfinity.compartmentLabelLengthHint": "100 iken raf kenarın tüm uzunluğu boyunca uzanır.", "gridfinity.compartmentLabelTooShortHint": "Bu raf 4 mm'den kısa olduğu için yapılmaz.", + "gridfinity.compartmentLabelOpenHint": "Kutunun bu tarafı açık, raf için duvar olmadığından raf yapılmaz.", "gridfinity.alignLeft": "Sol", "gridfinity.alignMiddle": "Orta", "gridfinity.alignRight": "Sağ", diff --git a/apps/web/src/lib/shapeCatalog.ts b/apps/web/src/lib/shapeCatalog.ts index e56456b62..bf0501f36 100644 --- a/apps/web/src/lib/shapeCatalog.ts +++ b/apps/web/src/lib/shapeCatalog.ts @@ -681,6 +681,10 @@ export function sceneShape(shape: Partial & Pick { } }); }); + +describe("exact Gridfinity bins with every side closed, unchanged by open sides", () => { + it("builds the same solids as before open sides came in, also with every side switched off", () => { + // Taken before open sides came in (#682): volume, faces and edges of the one solid. + const closed = { gridfinityOpenBack: false, gridfinityOpenFront: false, gridfinityOpenLeft: false, gridfinityOpenRight: false }; + for (const [part, volume, faces, edges] of [ + [bin(), 23248.7382, 100, 210], + [bin({ gridfinityScoopFront: true }), 24249.8297, 108, 228], + [bin({ gridfinityLabelBack: true, gridfinityLabelLeft: true, gridfinityLabelRight: true }), 35470.5361, 127, 285], + [bin({ gridfinityCompartmentsX: 3, gridfinityCompartmentsZ: 2, gridfinityScoopFront: true, gridfinityCompartmentLabels: [{ column: 1, row: 1, side: "back" }] }), 29877.6412, 203, 481], + [bin({ gridfinityLip: false, height: 21 }), 21312.6174, 69, 154], + ] as const) { + for (const each of [part, { ...part, ...closed }]) { + const context = JSON.stringify(each); + const solid = gridfinityPartSolid(cad, each); + expect(Math.abs(Math.abs(cad.getVolume(solid)) - volume), context).toBeLessThan(1e-4); + expect(cad.isSolid(solid) ? 1 : cad.getSubShapes(solid, "solid").length, context).toBe(1); + expect(cad.getSubShapes(solid, "face"), context).toHaveLength(faces); + expect(cad.getSubShapes(solid, "edge"), context).toHaveLength(edges); + } + } + }); +}); + +describe("exact Gridfinity bins with open sides", () => { + const all = { gridfinityOpenBack: true, gridfinityOpenFront: true, gridfinityOpenLeft: true, gridfinityOpenRight: true }; + const keys = Object.values(GRIDFINITY_OPEN_WALL_KEYS); + /** Every one of the 15 ways to open one side or more. */ + const openings = Array.from({ length: 15 }, (_, k) => Object.fromEntries(keys.filter((_, i) => (k + 1) & (1 << i)).map((key) => [key, true])) as Partial); + /** One valid solid without edges under 0.01 mm, with the mesh's volume and box. */ + const expectOpen = (part: CadModifierGridfinityPart) => { + const context = JSON.stringify(part); + const solid = gridfinityPartSolid(cad, part); + expect(cad.isValid(solid), context).toBe(true); + expect(cad.isSolid(solid) ? 1 : cad.getSubShapes(solid, "solid").length, context).toBe(1); + const exact = Math.abs(cad.getVolume(solid)); + expect(Math.abs(exact - meshVolume(part)) / exact, context).toBeLessThan(0.002); + const box = cad.getBoundingBox(solid); + const { min, max } = meshBounds(part); + [box.xmin - min[0], box.ymin - min[1], box.zmin - min[2], box.xmax - max[0], box.ymax - max[1], box.zmax - max[2]].forEach((d) => expect(Math.abs(d), context).toBeLessThan(0.01)); + expect(cad.getSubShapes(solid, "edge").filter((edge) => cad.getLength(edge) > 1e-9 && cad.getLength(edge) < 0.01), context).toHaveLength(0); + return solid; + }; + + it("builds every open bin as one valid solid with the mesh's volume and box, with the lip and without", () => { + for (const opening of openings) { + for (const extra of [{}, { gridfinityLip: false, height: 21 }, { gridfinityMagnets: true }]) expectOpen(bin({ ...opening, ...extra })); + } + }); + + it("builds every open bin on the half-size grid, on a raised floor and 1 height unit high", () => { + for (const opening of openings) { + for (const extra of [{ gridfinityHalfGrid: true, width: 20.5, depth: 41.5 }, { gridfinityFloorHeight: 12.5 }, { height: 7 + GRIDFINITY_LIP_HEIGHT }]) expectOpen(bin({ ...opening, ...extra })); + } + }); + + it("takes the wall and the lip off an open side, as calculated by hand", () => { + // As for the mesh, with true arcs (k = 1 - pi / 4): open at the front, 124.6367 for the square outer corners less + // 1815.2595 of wall and 57.9846 of the inner corners' rounding. All four open: the feet, 13729.1472, and the floor on them. + for (const [fields, volume] of [ + [{}, 23248.7382], + [{ gridfinityOpenFront: true }, 21500.1308], + [{ gridfinityOpenLeft: true }, 22443.3983], + [{ gridfinityOpenFront: true, gridfinityOpenBack: true }, 19751.5235], + [{ gridfinityOpenFront: true, gridfinityOpenLeft: true }, 20631.4418], + [all, 13729.1472 + 83.5 * 41.5 * 1.2], + ] as const) { + expect(Math.abs(cad.getVolume(gridfinityPartSolid(cad, bin(fields)))), JSON.stringify(fields)).toBeCloseTo(volume, 1); + } + }); + + it("runs the walls beside an open side out to the outline, where they end square, and leaves only the floor between them", () => { + const solid = gridfinityPartSolid(cad, bin({ gridfinityOpenFront: true })); + // A column in the left wall 0.2 mm in from the outline at the front is full from the floor up to under the lip's top. + expect(material(solid, -41.75 + 0.45, 20.75 - 0.2, 6, 24.5)).toBeCloseTo(0.09 * 18.5, 3); + // Between the walls the same column holds only the floor, 1.2 mm on the feet. + expect(material(solid, 0, 20.75 - 0.2, 4.75, 25.4)).toBeCloseTo(0.09 * 1.2, 3); + }); + + it("stands two open bins side by side 0.5 mm apart, as two closed bins stand", () => { + for (const [a, b, along] of [[{ gridfinityOpenRight: true }, { gridfinityOpenLeft: true }, "x"], [{ gridfinityOpenFront: true }, { gridfinityOpenBack: true }, "z"]] as const) { + const first = gridfinityPartSolid(cad, bin({ width: 41.5, ...a })); + const second = gridfinityPartSolid(cad, bin({ width: 41.5, ...b })); + const at = (d: number) => (along === "x" ? cad.translate(second, d, 0, 0) : cad.translate(second, 0, 0, d)); + expect(overlap(first, at(42)), along).toBeLessThan(0.01); + expect(overlap(first, at(41)), along).toBeGreaterThan(1); + // The walls' ends either side of the gap: the first bin's ends at 20.75, the second's start at 21.25. + const column = (solid: ShapeHandle, d: number) => (along === "x" ? material(solid, d, -20.3, 6, 24) : material(solid, -20.3, d, 6, 24)); + expect(column(first, 20.6), along).toBeGreaterThan(0.5); + expect(column(at(42), 21.4), along).toBeGreaterThan(0.5); + expect(column(first, 21) + column(at(42), 21), along).toBe(0); + } + }); + + it("seats an open bin in the baseplate 0.25 mm up, its square corners on the plate's top", () => { + for (const [binSize, plateSize] of [[41.5, 42], [83.5, 84]]) { + const b = gridfinityPartSolid(cad, bin({ width: binSize, depth: binSize, gridfinityOpenFront: true })); + const p = gridfinityPartSolid(cad, plate({ width: plateSize, depth: plateSize })); + expect(overlap(cad.translate(b, 0, 0.25, 0), p)).toBeLessThan(0.01); + } + }); + + it("keeps the floor flat out to an open side beside a scoop, which runs out with its wall", () => { + const solid = expectOpen(bin({ gridfinityScoopLeft: true, gridfinityOpenFront: true })); + expect(material(solid, 0, 20.6, 4.75, 25.4)).toBeCloseTo(0.09 * 1.2, 3); + // 1.35 to 1.65 mm out from the left wall the scoop is full to 8.20 and empty above 8.57, past the front wall's place too. + expect(material(solid, -40.8 + 1.5, 20.6, 6, 8.1)).toBeCloseTo(0.09 * 2.1, 3); + expect(material(solid, -40.8 + 1.5, 20.6, 8.7, 12)).toBe(0); + }); + + it("runs shelves and dividers out to an open side", () => { + // The left shelf's 1.2 mm strip under its top at 20.15, at the front edge. + const shelf = expectOpen(bin({ gridfinityLabelLeft: true, gridfinityOpenFront: true })); + expect(material(shelf, -40.8 + 4, 20.6, 19.05, 20.05)).toBeCloseTo(0.09, 3); + // The dividers at x = -13.8 and z = 0 stand to their top at 20.15 at the front edge; the front row's floors are flat there. + const divided = expectOpen(bin({ gridfinityCompartmentsX: 3, gridfinityCompartmentsZ: 2, gridfinityOpenFront: true })); + expect(material(divided, -13.8, 20.6, 6, 20)).toBeCloseTo(0.09 * 14, 3); + expect(material(divided, -27.6, 20.6, 4.75, 25.4)).toBeCloseTo(0.09 * 1.2, 3); + }); + + it("builds open bins with scoops, shelves, compartments and their labels as one valid solid with the mesh's volume", () => { + const labels = [{ column: 0, row: 0, side: "left" as const }, { column: 1, row: 1, side: "back" as const }, { column: 2, row: 1, side: "right" as const }, { column: 1, row: 0, side: "back" as const, length: 50 }]; + for (const opening of [{ gridfinityOpenFront: true }, { gridfinityOpenLeft: true }, { gridfinityOpenFront: true, gridfinityOpenBack: true }, { gridfinityOpenFront: true, gridfinityOpenRight: true }, { gridfinityOpenBack: true, gridfinityOpenFront: true, gridfinityOpenLeft: true }, all]) { + expectOpen(bin({ ...opening, gridfinityScoopBack: true, gridfinityScoopFront: true, gridfinityScoopLeft: true, gridfinityScoopRight: true })); + expectOpen(bin({ ...opening, gridfinityLabelBack: true, gridfinityLabelFront: true, gridfinityLabelLeft: true, gridfinityLabelRight: true })); + expectOpen(bin({ ...opening, gridfinityLabelBack: true, gridfinityLabelLeftLength: 20, gridfinityLabelLeft: true, gridfinityLabelCorner: "chamfer" })); + expectOpen(bin({ ...opening, gridfinityCompartmentsX: 3, gridfinityCompartmentsZ: 2, gridfinityScoopFront: true, gridfinityScoopLeft: true, gridfinityCompartmentLabels: labels })); + expectOpen(bin({ ...opening, gridfinityCompartmentsX: 3, gridfinityCompartmentsZ: 2, gridfinityLip: false, height: 21, gridfinityMagnets: true })); + } + }, 300_000); + + it("takes a fillet and a chamfer on the sharp top of an open bin's lip", () => { + const solid = gridfinityPartSolid(cad, bin({ gridfinityOpenFront: true })); + const top = cad.getBoundingBox(solid).ymax; + const edges = cad.getSubShapes(solid, "edge").filter((edge) => cad.getBoundingBox(edge).ymin > top - 1e-6); + expect(edges.length).toBeGreaterThan(0); + const filleted = cad.fillet(solid, edges, 0.5); + expect(cad.isValid(filleted)).toBe(true); + expect(Math.abs(cad.getVolume(filleted))).toBeLessThan(Math.abs(cad.getVolume(solid))); + expect(cad.isValid(cad.chamfer(solid, edges, 0.5))).toBe(true); + }); + + it("refuses an open bin whose dividers the kernel lost instead of building it without them", () => { + // A cutter that comes back as the whole inside cuts the dividers away with it. The inside runs out past the open side, + // so only the bin cut to it is a sound measure of what is left without dividers. + const common = vi.spyOn(cad, "common").mockImplementationOnce((_, inside) => inside); + try { + expect(() => gridfinityPartSolid(cad, bin({ gridfinityCompartmentsX: 3, gridfinityCompartmentsZ: 2, gridfinityOpenFront: true }))).toThrow("The Gridfinity bin's dividers could not be cut"); + } finally { + common.mockRestore(); + } + }); +}); diff --git a/tests/e2e/gridfinityOpenSidesPanel.e2e.ts b/tests/e2e/gridfinityOpenSidesPanel.e2e.ts new file mode 100644 index 000000000..04d03684d --- /dev/null +++ b/tests/e2e/gridfinityOpenSidesPanel.e2e.ts @@ -0,0 +1,58 @@ +// @vitest-environment jsdom +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { beforeAll, describe, expect, it } from "vitest"; +import { ShapeInspector } from "@/components/workplane/ShapeInspector"; +import { makeShapeFromAsset, toolbarShapeAssets } from "@/lib/shapeCatalog"; +import { DEFAULT_SNAP_GRID, DEFAULT_WORKPLANE_WORKSPACE } from "@/lib/workplaneSettings"; +import type { WorkplaneShape } from "@/types/layerling"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +/** + * A Gridfinity bin's open sides in its settings (#682): a switch for each side, none on a baseplate + * or a solid block, an open side's shelf and scoop switches hidden with their settings, and a note + * under a compartment's label shelf chosen on an open side. + */ +beforeAll(() => { + // jsdom lays nothing out; the panel only scrolls itself back to the top. + Element.prototype.scrollTo ??= () => {}; +}); + +/** The settings panel's text for a Gridfinity part with these fields, unmounted again once read. */ +function panelText(fields: Partial) { + const asset = toolbarShapeAssets.find((entry) => entry.kind === "gridfinity")!; + const shape = { ...makeShapeFromAsset(asset, { x: 0, z: 0 }), ...fields } as WorkplaneShape; + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + act(() => { + root.render(createElement(ShapeInspector, { shape, snap: DEFAULT_SNAP_GRID, snapOpen: false, workspace: DEFAULT_WORKPLANE_WORKSPACE, onUpdate: () => {}, onSnapChange: () => {}, onSnapOpenChange: () => {} })); + }); + const text = container.textContent ?? ""; + act(() => root.unmount()); + container.remove(); + return text; +} + +const bin = { width: 83.5, depth: 41.5, height: 25.4, gridfinityType: "bin" as const, gridfinityLip: true }; + +describe("a Gridfinity bin's open sides in its settings", () => { + it("has a switch for each side and hides an open side's shelf and scoop with their settings", () => { + const text = panelText({ ...bin, gridfinityOpenFront: true, gridfinityLabelFront: true, gridfinityScoopFront: true }); + for (const side of ["Open at the back", "Open at the front", "Open on the left", "Open on the right"]) expect(text.split(side).length - 1, side).toBe(1); + expect(text).toContain("Label shelf at the back"); + // The front's shelf and scoop are still set, but neither is built, so their switches and settings go. + for (const gone of ["Label shelf at the front", "Finger scoop at the front", "Shelf corners", "Scoop radius"]) expect(text, gone).not.toContain(gone); + }); + + it("has no switches on a baseplate or a solid block", () => { + expect(panelText({ gridfinityType: "baseplate", gridfinityOpenFront: true })).not.toContain("Open at the front"); + expect(panelText({ ...bin, height: 21, gridfinityLip: false, gridfinityFloorHeight: 21 })).not.toContain("Open at the front"); + }); + + it("notes that a compartment's label shelf chosen on an open side is left out", () => { + const text = panelText({ ...bin, gridfinityCompartmentsX: 2, gridfinityOpenFront: true, gridfinityCompartmentLabels: [{ column: 0, row: 0, side: "front" }] }); + expect(text).toContain("This side of the bin is open and has no wall, so the shelf is left out."); + }); +}); diff --git a/tests/unit/gridfinityGeometry.test.ts b/tests/unit/gridfinityGeometry.test.ts index 13ccea352..574c19949 100644 --- a/tests/unit/gridfinityGeometry.test.ts +++ b/tests/unit/gridfinityGeometry.test.ts @@ -15,6 +15,7 @@ import type { WorkplaneShape } from "@/types/layerling"; import { GRIDFINITY_LABEL_WALL_KEYS, GRIDFINITY_LIP_HEIGHT, + GRIDFINITY_OPEN_WALL_KEYS, GRIDFINITY_SCOOP_WALL_KEYS, type GridfinityLabelCornerShape, type GridfinityLabelProfile, @@ -43,11 +44,14 @@ import { gridfinityLabelFits, gridfinityLabelLength, gridfinityLabelLimits, + gridfinityLabelMaxWidth, gridfinityLabelledTriangles, gridfinityLabelPieces, gridfinityListPatch, + gridfinityOpenFits, gridfinityPlan, gridfinityScoopFits, + gridfinityScoopMaxRadius, gridfinityScoopPieces, gridfinityScoopRadius, gridfinityScoopsLeftOut, @@ -1292,6 +1296,11 @@ describe("gridfinity bins without compartments, unchanged by them", () => { expect(labelled.length, context).toBe(labelledTriangles); expect(signedVolume(labelled), context).toBeCloseTo(labelledVolume, 3); expect(shelvesOf(fields), context).toEqual(shelves); + // Every side's open switch stored as off builds the same (#682). + const closed = { ...fields, gridfinityOpenBack: false, gridfinityOpenFront: false, gridfinityOpenLeft: false, gridfinityOpenRight: false }; + expect(gridfinityTriangles(bin(closed)), context).toEqual(plain); + expect(gridfinityLabelledTriangles(runtime, bin(closed)), context).toEqual(labelled); + expect(shelvesOf(closed), context).toEqual(shelves); } const baseplate = gridfinityTriangles(plate()); expect(baseplate.length).toBe(812); @@ -1300,6 +1309,48 @@ describe("gridfinity bins without compartments, unchanged by them", () => { }); }); +describe("gridfinity bins with every side closed, unchanged by open sides", { timeout: 30_000 }, () => { + let runtime: ManifoldToplevel; + + beforeAll(async () => { + runtime = await manifoldModule(); + runtime.setup(); + }); + + const closed = { gridfinityOpenBack: false, gridfinityOpenFront: false, gridfinityOpenLeft: false, gridfinityOpenRight: false }; + + it("builds the same compartments, dividers and bounds as before open sides came in, also with every side switched off", () => { + // Taken before open sides came in (#682): triangles and volume of the display mesh, the dividers' and the floors' + // bounds, and each compartment's middle and half sizes. + const even = [[-27.6, -10.2, 13.2, 9.6], [0, -10.2, 13.2, 9.6], [27.6, -10.2, 13.2, 9.6], [-27.6, 10.2, 13.2, 9.6], [0, 10.2, 13.2, 9.6], [27.6, 10.2, 13.2, 9.6]]; + for (const [fields, triangles, volume, dividers, floors, cells] of [ + [{ gridfinityScoopFront: true }, 5052, 28365.4391, 3156.200072676, 1791.343643112, even], + [{ gridfinityScoopFront: true, gridfinityCompartmentLabels: [{ column: 1, row: 1, side: "back" }] }, 5170, 29894.8093, 3156.200072676, 1791.343643112, even], + [ + { gridfinityDividerPositionsX: [10, 60.3], gridfinityDividerPositionsZ: [25.5] }, 3640, 27274.3485, 3156.200072676, 832.455566956, + [[-36.1, -7.35, 4.7, 12.45], [-5.65, -7.35, 24.55, 12.45], [30.45, -7.35, 10.35, 12.45], [-36.1, 13.05, 4.7, 6.75], [-5.65, 13.05, 24.55, 6.75], [30.45, 13.05, 10.35, 6.75]], + ], + [{ gridfinityLip: false, height: 21 }, 3446, 25348.1282, 3176.959865306, 832.455566956, even], + ] as const) { + for (const shape of [bin({ gridfinityCompartmentsX: 3, gridfinityCompartmentsZ: 2, ...fields }), bin({ gridfinityCompartmentsX: 3, gridfinityCompartmentsZ: 2, ...fields, ...closed })]) { + const context = JSON.stringify(shape); + const mesh = gridfinityLabelledTriangles(runtime, shape) as V[][]; + expect(mesh.length, context).toBe(triangles); + expect(signedVolume(mesh), context).toBeCloseTo(volume, 3); + const pieces = gridfinityCompartmentPieces(shape)!; + expect(pieces.cells.map((cell) => [cell.centre.x, cell.centre.z, cell.inside.hx, cell.inside.hz].map((n) => Number(n.toFixed(9)) + 0)), context).toEqual(cells); + expect(gridfinityDividerBound(gridfinityPlan(shape), pieces), context).toBeCloseTo(dividers, 8); + expect(gridfinityCompartmentFloorBound(gridfinityPlan(shape), pieces), context).toBeCloseTo(floors, 8); + } + } + }); + + it("leaves a solid block as it was with a side switched on: it has no sides to open", () => { + const block = { gridfinityLip: false, height: 21, gridfinityFloorHeight: 21 }; + expect(gridfinityTriangles(bin({ ...block, gridfinityOpenFront: true }))).toStrictEqual(gridfinityTriangles(bin(block))); + }); +}); + // Each case builds whole bins through Manifold; on a slow CI runner one took longer than the default 5 s (PR #657). describe("gridfinity dividers", { timeout: 30_000 }, () => { let runtime: ManifoldToplevel; @@ -1913,3 +1964,225 @@ describe("gridfinity dividers", { timeout: 30_000 }, () => { expect(normalizeGridfinityDividerPositions(JSON.parse(String(settings!.gridfinityDividerPositionsX)))).toEqual([25, 55]); }); }); + +describe("gridfinity open sides", { timeout: 60_000 }, () => { + let runtime: ManifoldToplevel; + + beforeAll(async () => { + runtime = await manifoldModule(); + runtime.setup(); + }); + + /** Every one of the 15 ways to open one side or more, each as the switches it sets. */ + const openings = Array.from({ length: 15 }, (_, k) => Object.fromEntries(Object.values(GRIDFINITY_OPEN_WALL_KEYS).filter((_, i) => (k + 1) & (1 << i)).map((key) => [key, true]))); + const all = { gridfinityOpenBack: true, gridfinityOpenFront: true, gridfinityOpenLeft: true, gridfinityOpenRight: true }; + + /** The mesh is closed, wound outwards, one piece and inside the bin's outline; the outline itself is reached, and the top unless every side is open. */ + const expectOpen = (fields: Record) => { + const shape = bin(fields); + const mesh = gridfinityLabelledTriangles(runtime, shape) as V[][]; + const context = JSON.stringify(fields); + expect(edgeProblems(mesh), context).toBe(0); + expect(components(mesh), context).toBe(1); + expect(signedVolume(mesh), context).toBeGreaterThan(0); + const { size } = gridfinityPlan(shape); + const { min, max } = bounds(mesh); + [min[0] + size.width / 2, max[0] - size.width / 2, min[2] + size.depth / 2, max[2] - size.depth / 2, min[1]].forEach((d) => expect(Math.abs(d), context).toBeLessThan(1e-6)); + expect(max[1], context).toBeLessThan(size.height + 1e-5); + if (!Object.values(gridfinityPlan(shape).open).every(Boolean)) expect(max[1], context).toBeGreaterThan(size.height - 2e-3); + return mesh; + }; + + it("tells every open side apart and keeps them all through a saved project", async () => { + const settings: Record = {}; + Object.values(GRIDFINITY_OPEN_WALL_KEYS).forEach((key) => Object.assign(settings, { [key]: true })); + const open = canonicalizeShape(binShape(settings)); + Object.keys(settings).forEach((key) => { + expect(workplaneShapesEqual(open, { ...open, [key]: false })).toBe(false); + }); + const save = (shapes: WorkplaneShape[]) => exportLylProject({ + projectId: "open", + projectName: "Open", + createdAt: 1_700_000_000_000, + modifiedAt: 1_700_000_100_000, + shapes, + history: [editorHistoryEntry(shapes, [])], + historyIndex: 0, + assets: [], + workspace: DEFAULT_WORKPLANE_WORKSPACE, + snapGrid: DEFAULT_SNAP_GRID, + placementElevation: 0, + }); + const [loaded] = (await importLylProject(await save([open]))).shapes; + expect(workplaneShapesEqual(loaded, open)).toBe(true); + expect(loaded).toMatchObject(settings); + expect(sceneShape(open)).toMatchObject(settings); + expect(cadModifierGridfinityForShape(open)).toMatchObject(settings); + expect(normalizeShapeCustomizations({ gridfinity: settings }, {})).toMatchObject({ gridfinity: settings }); + expect(shapeDefaultsFromShape(open)).toMatchObject(settings); + const asset = toolbarShapeAssets.find((entry) => entry.kind === "gridfinity")!; + expect(makeShapeFromAsset(asset, { x: 0, z: 0 }, settings)).toMatchObject(settings); + for (const key of Object.keys(settings)) { + await expect(importLylProject(await save([{ ...open, [key]: "yes" } as WorkplaneShape])), key).rejects.toThrow(`${key} must be true or false`); + // The mesh that booleans and exports use leaves that side open, as calculated by hand in "takes the wall and the lip off". + const { vertices, faces } = meshForShape(canonicalizeShape(binShape({ [key]: true }))); + const volume = signedVolume(faces.map((face) => face.map((i) => vertices[i]) as V[])); + expect(volume, key).toBeCloseTo(key === "gridfinityOpenBack" || key === "gridfinityOpenFront" ? 21497.68 : 22440.95, 1); + } + }); + + it("opens a bin's sides at any floor with the lip, and without it until the floor reaches the top, and never a baseplate's", () => { + const none = { back: false, front: false, left: false, right: false }; + expect(gridfinityPlan(bin({ gridfinityOpenFront: true, gridfinityOpenLeft: true })).open).toEqual({ ...none, front: true, left: true }); + // false and unset are the same. + expect(gridfinityPlan(bin({ gridfinityOpenFront: false })).open).toEqual(none); + // With the lip the floor stops at the support's foot, 19.35, and the lip is left on the other sides. + for (const floor of [undefined, 12.5, 19.35]) expect(gridfinityOpenFits(bin({ gridfinityFloorHeight: floor })), `${floor}`).toBe(true); + // Without the lip a floor of 20.9 leaves 0.1 mm of wall; 20.95 is raised onto the top, a solid block. + expect(gridfinityOpenFits(bin({ gridfinityLip: false, height: 21, gridfinityFloorHeight: 20.9 }))).toBe(true); + expect(gridfinityOpenFits(bin({ gridfinityLip: false, height: 21, gridfinityFloorHeight: 20.95 }))).toBe(false); + expect(gridfinityPlan(bin({ gridfinityLip: false, height: 21, gridfinityFloorHeight: 20.95, ...all })).open).toEqual(none); + expect(gridfinityOpenFits(plate())).toBe(false); + expect(gridfinityPlan(plate(all)).open).toEqual(none); + }); + + it("builds no scoop or shelf on an open side, and keeps their settings for when it is closed again", () => { + const scooped = { gridfinityScoopFront: true }; + expect(gridfinityScoopPieces(bin({ ...scooped, gridfinityOpenFront: true }))).toBeNull(); + // Nor the floor's rounding, which only a built scoop brings. + expect(gridfinityPlan(bin({ ...scooped, gridfinityOpenFront: true })).floorRounding).toBe(0); + expect(gridfinityScoopPieces(bin({ ...scooped, gridfinityOpenFront: false }))).toStrictEqual(gridfinityScoopPieces(bin(scooped))); + const shelves = { gridfinityLabelFront: true, gridfinityLabelLeft: true }; + const open = piecesOf({ ...shelves, gridfinityOpenFront: true }); + // The left shelf runs out past the front, 1 mm past the inside as into a wall, with no corner piece at that end. + expect(open.shelves.map(({ profile }) => [profile.wall, profile.width, profile.from, profile.to])).toEqual([["left", 14, -20.8, 20.8]]); + expect(cornersAt(open)).toEqual([[-26.8, -19.8]]); + expect(piecesOf({ ...shelves, gridfinityOpenFront: false })).toStrictEqual(piecesOf(shelves)); + }); + + it("leaves a shelf or a scoop the room it has with no shelf or scoop facing it, when that side is open", () => { + expect(gridfinityLabelMaxWidth(bin({ gridfinityLabelBack: true, gridfinityLabelFront: true, gridfinityOpenFront: true }), "back")).toBe(gridfinityLabelMaxWidth(bin({ gridfinityLabelBack: true }), "back")); + expect(gridfinityLabelMaxWidth(bin({ gridfinityLabelBack: true }), "back")).toBeGreaterThan(gridfinityLabelMaxWidth(bin({ gridfinityLabelBack: true, gridfinityLabelFront: true }), "back")); + // 6 height units, so the 2 mm of floor between two facing scoops is what limits them: 18.8 mm, alone 33.9. + const tall = { height: 42 + GRIDFINITY_LIP_HEIGHT, gridfinityScoopBack: true }; + expect(gridfinityScoopMaxRadius(bin({ ...tall, gridfinityScoopFront: true }))).toBeCloseTo(18.8, 9); + expect(gridfinityScoopMaxRadius(bin({ ...tall, gridfinityScoopFront: true, gridfinityOpenFront: true }))).toBeCloseTo(33.9, 9); + }); + + it("runs the scoops beside an open side out past the bin's outline, to be cut off there", () => { + // The left wall's scoop from 1 mm past the outline at the front, 19.8 + 0.95 + 1, to 1 mm into the back wall. + const ends = ({ scoops }: { scoops: Array<{ wall: string; from: number; to: number }> }) => scoops.map(({ wall, from, to }) => [wall, Number(from.toFixed(9)), Number(to.toFixed(9))]); + expect(ends(gridfinityScoopPieces(bin({ gridfinityScoopLeft: true, gridfinityOpenFront: true }))!)).toEqual([["left", -21.75, 20.8]]); + // So does a compartment's against the open side: 9.6 + 0.95 + 1. + const cells = gridfinityCompartmentPieces(bin({ gridfinityCompartmentsX: 3, gridfinityCompartmentsZ: 2, gridfinityScoopLeft: true, gridfinityOpenFront: true }))!.cells; + const front = cells.find((cell) => cell.column === 0 && cell.row === 1)!; + expect(front.open).toEqual({ back: false, front: true, left: false, right: false }); + expect(ends(front.scoops!)).toEqual([["left", -11.55, 10.6]]); + expect(ends(cells.find((cell) => cell.column === 0 && cell.row === 0)!.scoops!)).toEqual([["left", -10.6, 10.6]]); + }); + + it("offers a compartment no label on an open side, and keeps one facing a divider", () => { + const shape = bin({ gridfinityCompartmentsX: 3, gridfinityCompartmentsZ: 2, gridfinityOpenFront: true, gridfinityCompartmentLabels: [{ column: 1, row: 1, side: "front" }, { column: 1, row: 0, side: "front" }] }); + expect(gridfinityCompartmentLabel(shape, 1, 1)).toBeNull(); + expect(gridfinityCompartmentLabel(shape, 1, 0)).not.toBeNull(); + const cells = gridfinityCompartments(shape)!.cells; + expect(gridfinityCompartmentLabelMaxWidth(shape, cells.find((cell) => cell.column === 1 && cell.row === 1)!, "front")).toBe(0); + expect(gridfinityCompartmentLabelMaxWidth(shape, cells.find((cell) => cell.column === 1 && cell.row === 0)!, "front")).toBeGreaterThan(4); + }); + + it("draws every open bin closed, in one piece and within its outline, with the lip and without, and one unit high", () => { + for (const lip of [{}, { gridfinityLip: false, height: 21 }]) { + const low = { height: "gridfinityLip" in lip ? 7 : 7 + GRIDFINITY_LIP_HEIGHT }; + for (const opening of openings) { + for (const extra of [{}, { gridfinityMagnets: true }, { gridfinityHalfGrid: true, width: 20.5, depth: 41.5 }, { gridfinityFloorHeight: 12.5 }, low]) expectOpen({ ...lip, ...opening, ...extra }); + } + } + }); + + it("draws an open bin closed where the pieces beside an open side are cut off at the outline", () => { + // Cut off exactly at the outline, a piece's end lay in the plane of the bin's open end, with a sliver between them. + for (const [x, scoops] of [[3, { gridfinityScoopRight: true }], [3, { gridfinityScoopFront: true, gridfinityScoopLeft: true, gridfinityScoopRight: true }], [2, { gridfinityScoopRight: true }]] as const) { + expectOpen({ width: 83.5, depth: 41.5, height: 21 + GRIDFINITY_LIP_HEIGHT, gridfinityCompartmentsX: x, ...scoops, gridfinityOpenFront: true, gridfinityOpenLeft: true }); + } + }); + + it("draws every open bin with scoops on every wall and with label shelves on every wall", () => { + // The shelves in a bin of 4 height units. + for (const [lip, tall] of [[{}, 28 + GRIDFINITY_LIP_HEIGHT], [{ gridfinityLip: false, height: 21 }, 28]] as const) { + for (const opening of openings) { + expectOpen({ ...lip, ...opening, gridfinityScoopBack: true, gridfinityScoopFront: true, gridfinityScoopLeft: true, gridfinityScoopRight: true }); + expectOpen({ ...lip, ...opening, height: tall, gridfinityLabelBack: true, gridfinityLabelFront: true, gridfinityLabelLeft: true, gridfinityLabelRight: true }); + } + } + }); + + it("draws every open bin of 3 x 2 compartments with a front scoop and labels, holding its dividers", () => { + const labels = [{ column: 0, row: 0, side: "left" }, { column: 1, row: 1, side: "back" }, { column: 2, row: 1, side: "right" }, { column: 1, row: 0, side: "back" }]; + for (const lip of [{}, { gridfinityLip: false, height: 21 }]) { + for (const opening of openings) { + const fields = { ...lip, ...opening, gridfinityCompartmentsX: 3, gridfinityCompartmentsZ: 2, gridfinityScoopFront: true, gridfinityCompartmentLabels: labels }; + const mesh = expectOpen(fields); + const shape = bin(fields); + expect(signedVolume(mesh), JSON.stringify(fields)).toBeGreaterThan(signedVolume(gridfinityTriangles(shape) as V[][]) + gridfinityDividerBound(gridfinityPlan(shape), gridfinityCompartmentPieces(shape)!)); + } + } + }); + + it("draws the shelves, scoops and dividers beside an open side out to its outline", () => { + // Across the bin 0.15 mm in from the open front's outline, z = 20.6, past where its wall would stand at 19.8. + const near = (xs: number[], expected: number[]) => { + expect(xs.length).toBe(expected.length); + xs.forEach((x, i) => expect(Math.abs(x - expected[i]), `${xs}`).toBeLessThan(2e-3)); + }; + // The left shelf's strip under its top at 20.15, out to its front 14 mm from the wall; the right wall there is the lip's + // support, 0.15 mm up its 45 degrees from 19.35. + near(crossings(gridfinityLabelledTriangles(runtime, bin({ gridfinityLabelLeft: true, gridfinityOpenFront: true })) as V[][], 19.5, 20.6), [-41.75, -26.8, 40.65, 41.75]); + // The left scoop's quarter circle (r 6.7, centre 6.68 out and 12.63 up) at 7 mm: 3.048 mm out from the wall. (The right + // wall there stands on the floor's rounding, which the scoop brings.) + near(crossings(gridfinityLabelledTriangles(runtime, bin({ gridfinityScoopLeft: true, gridfinityOpenFront: true })) as V[][], 7, 20.6).slice(0, 2), [-41.75, -40.8 + 6.68 - Math.sqrt(6.7 ** 2 - 5.63 ** 2)]); + // The dividers of 1.2 mm at x = -13.8 and 13.8. + near(crossings(gridfinityLabelledTriangles(runtime, bin({ gridfinityCompartmentsX: 3, gridfinityOpenFront: true })) as V[][], 12, 20.6), [-41.75, -40.8, -14.4, -13.2, 13.2, 14.4, 40.8, 41.75]); + // A compartment's label against the right wall runs out the same, its strip 14 mm from the wall; the left wall is the lip's support. + const labelled = bin({ gridfinityCompartmentsX: 3, gridfinityOpenFront: true, gridfinityCompartmentLabels: [{ column: 2, row: 0, side: "right" }] }); + near(crossings(gridfinityLabelledTriangles(runtime, labelled) as V[][], 19.5, 20.6), [-41.75, -40.65, -14.4, -13.2, 13.2, 14.4, 26.8, 41.75]); + // Low down, where a label's underside meets the wall beside an open side, it reaches the wall as it does mid-bin. + const low = bin({ gridfinityCompartmentsX: 3, gridfinityOpenBack: true, gridfinityCompartmentLabels: [{ column: 0, row: 0, side: "left" }] }); + near(crossings(gridfinityLabelledTriangles(runtime, low) as V[][], 9.05, -20.6), [-41.75, -40.423, -14.4, -13.2, 13.2, 14.4, 40.8, 41.75]); + }); + + it("takes the wall and the lip off an open side, as calculated by hand", () => { + // A bin of 2 x 1 x 3 with the lip, 83.5 x 41.5, corners 3.75 (k = 1 - 4 sin(pi / 16) for the mesh's 8 segments), floor + // 5.95, top 21, wall top 25.4. The wall's inset i above the floor, 0.95 up to 19.35, the support out to 2.6 at 21, the lip + // 1.9 from 21.7 to 23.5 and out to 0 at 25.4, holds A = 22.45875 mm² for each mm of straight wall, and its square 30.02304. + // Open at the front: the front wall between the side walls' inner faces, 83.5 A - 2 x 30.02304 = 1815.2595 mm³, and the + // two inner corners' rounding, 2 k (3.75 - i)² up the wall = 59.3455, go; the two outer corners, square from the feet up, + // gain 2 k 3.75² x 20.65 = 127.5620. 23244.7224 - 1747.0430 = 21497.6794. Open on the left the same with 41.5 for 83.5. + // All four open: the feet, 13728.2601, and the floor on them, 83.5 x 41.5 x 1.2. + for (const [fields, volume] of [ + [{}, 23244.7224], + [{ gridfinityOpenFront: true }, 21497.6794], + [{ gridfinityOpenLeft: true }, 22440.9469], + [{ gridfinityOpenFront: true, gridfinityOpenBack: true }, 19750.6363], + [{ gridfinityOpenFront: true, gridfinityOpenLeft: true }, 20629.7725], + [all, 13728.2601 + 83.5 * 41.5 * 1.2], + ] as const) { + expect(signedVolume(gridfinityTriangles(bin(fields)) as V[][]), JSON.stringify(fields)).toBeCloseTo(volume, 3); + } + }); + + it("bounds the cut, the dividers and the floors of a bin with a side open without the material it no longer has", () => { + const c = (4 - Math.PI) * 2.8 ** 2; + // A front-open 3 x 2: the front row's cutters run 5.75 mm out through the open side, so their section over the top is + // 26.4 x (19.2 + 5.75), all four corners still rounded at its far end. + const divided = gridfinityCompartmentPieces(bin({ gridfinityCompartmentsX: 3, gridfinityCompartmentsZ: 2, gridfinityOpenFront: true }))!; + expect(gridfinityCompartmentCutBound(divided, { box: 1000, cutters: [100, 100, 100, 100, 100, 100] })).toBeCloseTo(1600 - 0.3 * (3 * (26.4 * 19.2 - c) + 3 * (26.4 * 24.95 - c)), 9); + // One row of three across, open at the front, without the lip: the inside of 81.6 x 39.6 with its two back corners + // rounded, less three compartments of 26.4 x 39.6 with their two back corners rounded and square at the front, 14.2 high. + const row = bin({ gridfinityCompartmentsX: 3, gridfinityOpenFront: true, gridfinityLip: false, height: 21 }); + expect(gridfinityDividerBound(gridfinityPlan(row), gridfinityCompartmentPieces(row)!)).toBeCloseTo((81.6 * 39.6 - c / 2 - 3 * (26.4 * 39.6 - c / 2)) * 14.2, 6); + // Each compartment's floor rounding: no fillet along the open side and none in its two corners there; 20.8 mm of + // fillet at the back, 34 along each side, and the two back corners of pi r³ / 12. + const fillet = 2.8 ** 2 * (1 - Math.PI / 4); + expect(gridfinityCompartmentFloorBound(gridfinityPlan(row), gridfinityCompartmentPieces(row)!)).toBeCloseTo(3 * (88.8 * fillet + (Math.PI * 2.8 ** 3) / 6), 6); + }); +});