diff --git a/.changeset/bright-trays-paint.md b/.changeset/bright-trays-paint.md new file mode 100644 index 0000000..b78ffe1 --- /dev/null +++ b/.changeset/bright-trays-paint.md @@ -0,0 +1,5 @@ +--- +'basekit': minor +--- + +Add low-profile magnetic spray trays with interleaved pocket grids, configurable glue-on handles, and engraved centre marks for batch priming mixed miniature sizes. diff --git a/AGENTS.md b/AGENTS.md index a11437d..df4d350 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Read [README.md](README.md) first for what the app does and what the controls me - `just dev` — Vite on port 5173. - `just test` — Vitest over `src/**/*.test.ts`. Geometry tests run in Node against the same builder the browser uses, so they need no browser. - `just e2e` builds and runs Playwright; `just e2e-run` reuses the current `dist/`. Install the browser once with `just e2e-install`. -- `just samples [round|oval]` — writes one STL per preset for inspection outside the browser. +- `just samples [round|oval|painting]` — writes preset base STLs or the default painting tray for inspection outside the browser. - Lint and format are oxlint + oxfmt, not ESLint/Prettier. Warnings are denied in CI. ## Load-bearing rules @@ -27,6 +27,8 @@ Read [README.md](README.md) first for what the app does and what the controls me - **Label bearings come from the gaps between everything solid**, ribs and bosses together, widest first. Bisecting the ribs alone worked only while ribs and bosses shared a bearing; the moment they did not, a rib bisector aimed straight at a boss. Bases and holders both use `src/geometry/label.ts`, so changes to fitting rules affect both generators. - **The size label must never disappear silently.** If it cannot be placed it is simply absent from the model, with no error, so changes to `fitLabel` need a test that a cramped base still gains volume from its label. - **Magnets are flush with the face that meets the tray, whatever their thickness.** The bore is cut down from `z = height` by `magnets.thickness + magnets.depthClearance`, leaving optional room for adhesive behind a magnet held flush during assembly. Anchoring the bore at `floorThickness` instead looks like it puts the magnet "on the floor", but that floor is the opposite face of the base, so it strands the magnet as far from the tray as the geometry allows. `base.test.ts` asserts the bore's ceiling is `z = height` at every thickness. +- **Painting trays are interleaved magnet grids, not miniature holders.** Rows and columns define the primary pockets, with one offset pocket at the centre of every four adjacent positions. The diagonal pockets give mixed base sizes more placement options; do not pack miniature footprints or add surrounding recesses. +- **The painting-tray handle is a separate configurable glue-on grip.** Round, oval-barrel, flared, and pistol shapes can vary in width, length, lean, end softening, and ribs while staying upright and support-free. Its flat end meets the tray underside directly, without a mounting plate. Keep the grip free of integrated finger shields or guard flanges; that approach obscures the clean grip and was rejected. STL export puts tray and handle in a ZIP and 3MF export gives each part its own build plate. Do not export the grip fused to the tray: a handle extending beneath the tray cannot print support-free in the tray's orientation. - **Sizes are exact.** A 28.5mm base reads `28.5` everywhere — size label, footer, filename. Never round a size for display; `trimNumber` drops trailing zeros without rounding. - **Exports always use a 1µm chord tolerance for circular geometry.** `exportSegmentsFor` derives the segment count from the diameter and round edge profiles inherit the tolerance implied by that count. The preview deliberately uses `previewSegmentsFor` and a lighter mesh; `buildMesh` runs an isolated high-quality build when either export button is pressed. Do not restore a quality control or a fixed export segment count: either makes exported tolerance depend on the size again. - **Worker meshes must be copied before transfer.** `getMesh()` may hand back arrays viewing WASM memory, and transferring that buffer would detach the heap. @@ -34,7 +36,7 @@ Read [README.md](README.md) first for what the app does and what the controls me ## UI -- **Global settings used by both generators are workspace settings.** Their canonical values live in `WorkspaceState.shared`, and `synchronizeWorkspace` projects them into the base and holder geometry configs. The complete workspace is persisted in browser storage. Never add matching values that write each config independently. Base size-label visibility and holder size engraving are the same preference; magnet dimensions, per-footprint count overrides, pocket-pattern version, wall thickness and magnet boss wall are also shared. Pattern version 1 preserves the legacy five-pocket cross; balanced saved counts migrate to version 2 overrides. Similar names do not imply shared geometry: base height and holder height describe different printed parts, while holder shape and size belong to each miniature group rather than being global. +- **Global settings used by multiple generators are workspace settings.** Their canonical values live in `WorkspaceState.shared`, and `synchronizeWorkspace` projects them into the base, holder, and painting-tray geometry configs. The complete workspace is persisted in browser storage. Never add matching values that write each config independently. Base size-label visibility and holder size engraving are the same preference; magnet dimensions, per-footprint count overrides, pocket-pattern version, wall thickness and magnet boss wall are also shared. Pattern version 1 preserves the legacy five-pocket cross; balanced saved counts migrate to version 2 overrides. Similar names do not imply shared geometry: base height, holder height, and painting-tray thickness describe different printed parts. Holder shape and size belong to each miniature group, while painting-tray rows, columns, and spacing are independent. - Components come from shadcn's Base UI registry (`components.json`, style `base-nova`). Add them with `pnpm dlx shadcn@latest add `; treat `src/components/ui/**` as vendored and never hand-patch it — a handful of lint rules are switched off for that folder instead, so re-adding a component is always safe. Anything vendored but unused is deleted; re-add it when it is needed. - **Reach for a registry component before writing one.** `src/components/controls.tsx` holds only the app-specific compositions — `Section`, `Dimension`, `Choice`, `CompactChoice`, `ToggleSetting` and `SizeSelect` — and each is a thin wrapper over `Field`, `InputGroup`, `Select` or `Switch`. Every one of those started life hand-rolled, and the hand-rolled versions were the parts that lacked keyboard support. - **The semantic token mapping lives in `src/styles.css`, not in the `shadcn/tailwind.css` import**, which only carries keyframes. Without the `@theme inline` block mapping `--color-background: var(--background)` and friends, utilities like `bg-background` and `border-border` are simply never generated and the app renders unstyled. After touching the theme, check the built CSS actually contains them rather than trusting the page to look right in dev. @@ -58,6 +60,8 @@ Read [README.md](README.md) first for what the app does and what the controls me - **The key light sits at ~45° off vertical, and it casts shadows.** Everything worth seeing here is a shallow step off a flat floor — the size label is 0.6mm — so from a plan view the emboss and the floor beneath it share a normal and shade identically: with overhead lighting the number simply vanished. Any off-vertical light replaces that with a cast shadow, which reads from any angle. Lower is not better: at 26° the well wall threw its shadow halfway across a small base for no gain in legibility. The shadow frustum is fitted to the part on every geometry swap, so a 25mm base gets the same texel density as a 180mm one. - **The shadow map is drawn once per geometry swap, not per frame.** `shadowMap.autoUpdate` is off and `needsUpdate` is set alongside the mesh, because only the camera moves. Leaving it on cost 13fps against 121. - **The camera never moves as the base resizes**, so a 180 really is seven times a 25 on screen. An earlier version pulled back to keep large parts framed, which cancelled the growth exactly: past 120mm the distance scaled with the part and nothing appeared to change. Bases past about 100mm run off the frame, which is deliberate — scrolling out is the viewer's call. +- **Each generator owns its camera view.** Switching generators saves that page's orbit and zoom, then restores the destination page's last view or its own initial framing. Never carry one generator's camera straight into another: a useful close view of a base can put an entire holder or spray tray off-screen. +- **The spray-tray camera orbits around the handle axis, not the tray or an assumed positive-Z bounding box.** Its assembled handle extends below `z = 0`, so the target follows the handle centreline at half its length, including lean and pistol-grip curvature. Its dimension leaders likewise use the assembly's real negative `minZ` and tray-top `maxZ`. - **Framing fits the narrower axis, and only until the viewer takes the camera.** The field of view is vertical, so fitting on height alone put a 60mm base at twice the width of an upright phone. `framingDistance` divides by the smaller of the two half-extents. It is reapplied on viewport resize while `held` is false, and `held` flips on the first `start` event from the controls — after that a window resize or a rotating phone leaves the view exactly where it was put. - **Rotation is an orbit with the up axis pinned, and the polar angle left unclamped.** `TrackballControls` was tried so the same axis could roll over and over, but tumbling with no fixed horizon made it hard to tell which way up the part was, so it went back. Unclamped means dragging one way reaches a plan view of the recess and dragging the other passes the horizon to the face the model stands on, each stopping at its pole rather than carrying on over the top. Do not restore `maxPolarAngle = Math.PI / 2`: both faces are worth seeing. - **A weak upward bounce light exists only so the underside is not black**, since the sky-to-ground hemisphere leaves downward faces nearly unlit. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b11d859..958b100 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,6 +29,7 @@ Geometry changes need inspection outside the preview. Write sample STLs with: ```sh just samples just samples oval +just samples painting ``` Load at least one affected export in a slicer or mesh checker. The geometry tests run against the same DOM-free builder as the browser, but an exported file is the final product. diff --git a/README.md b/README.md index 068753a..d07c2ad 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![Build](https://img.shields.io/github/actions/workflow/status/richardsolomou/basekit/ci.yml?branch=main)](https://github.com/richardsolomou/basekit/actions/workflows/ci.yml) [![License](https://img.shields.io/github/license/richardsolomou/basekit)](LICENSE) -BaseKit makes support-free STL and 3MF files for tabletop miniature bases, matching flying stems, and Gridfinity holders for storing them. Pick a standard footprint or enter an exact one, choose the magnets you have, and export a model ready for the slicer. A base's size is embossed inside, so a loose print still tells you what it is: a `28.5` base says `28.5`, not `29`. +BaseKit makes support-free STL and 3MF files for tabletop miniature bases, matching flying stems, Gridfinity holders, and low-profile spray trays. Pick a standard footprint or enter an exact one, choose the magnets you have, and export a model ready for the slicer. A base's size is embossed inside, so a loose print still tells you what it is: a `28.5` base says `28.5`, not `29`. Everything runs in the browser. Models are built locally and nothing is uploaded. @@ -34,16 +34,23 @@ Add miniature groups, choose the available rows and columns, and BaseKit packs m Modules export as separate STL files in one archive or separate build plates in one 3MF. You can combine groups into one holder, engrave sizes in each slot or once per module, and add matching magnet pockets. Requests that do not fit report the omitted models without blocking the rest of the plan. +## Spray trays 🎨 + +Build a low-profile magnetic platform for batch priming and spray painting. Its top-opening pockets form an interleaved grid, with a second offset pocket between every four primary positions. The default 50mm centre spacing leaves clearance between two 32mm bases in neighbouring primary and diagonal positions. Adjust the rows, columns, centre spacing, and edge margin; the extra diagonal positions suit mixed base sizes while the flat, rimless surface keeps every base edge exposed to spray. + +Choose a round, oval-barrel, flared, or pistol grip, then adjust its width, length, and lean angle. The default grip is 100mm long, and optional end softening and shallow ribs further tune the feel without adding supports. A shallow engraved `+` extends beyond the configured grip footprint on the tray underside, keeping the centre visible while the flat end is positioned for glue. STL downloads contain separate tray and handle files, while 3MF downloads put each support-free part on its own build plate. + ## Designed for one job 🎯 - Round, oval, pill, rectangle, and regular polygon bases. - Hollow undersides with automatic ribs and magnet layouts. - Printable 15, 20, 30, and 35mm flying stems with adjustable peg or ball-joint connections. -- Balanced or five-pocket cross magnet arrangements shared by bases and holders. +- Shared magnet dimensions across bases, holders, and spray trays. +- Balanced or five-pocket cross magnet arrangements for bases and holders. - Exact size labels, filenames, dimensions, and high-quality exports. - Browser-saved settings with shared base and holder preferences. -BaseKit generates bases and holders for them. It does not sculpt miniatures, add textures or heightmaps, slice models, or control a printer. +BaseKit generates bases and accessories for using them. It does not sculpt miniatures, add textures or heightmaps, slice models, or control a printer. ## Private by design 🔒 diff --git a/e2e/generator.spec.ts b/e2e/generator.spec.ts index 6362a7f..34d2236 100644 --- a/e2e/generator.spec.ts +++ b/e2e/generator.spec.ts @@ -79,6 +79,69 @@ test('keeps the 3D canvas out of the observed viewer layout', { tag: '@ci' }, as expect(resizeErrors).toEqual([]) }) +test('keeps a separate camera view for each generator', async ({ page }) => { + const canvas = page.locator('main canvas') + const dimensionPath = page.locator('#dim-across') + const cameraView = () => drawn(page).getAttribute('data-camera-view') + const cameraDifference = async (expected: string | null) => { + const actual = await cameraView() + if (!expected || !actual) return Infinity + const expectedValues = expected.split(',').map(Number) + return Math.max(...actual.split(',').map((value, index) => Math.abs(Number(value) - expectedValues[index]))) + } + const settledCamera = async () => { + let previous: string | null = null + let stableSamples = 0 + await expect + .poll( + async () => { + const current = await cameraView() + stableSamples = current === previous ? stableSamples + 1 : 0 + previous = current + return stableSamples + }, + { timeout: 15_000, intervals: [100] }, + ) + .toBeGreaterThanOrEqual(3) + return cameraView() + } + await expect(dimensionPath).toHaveAttribute('d', /^M /) + await expect(drawn(page)).toHaveAttribute('data-camera-view', /,/) + + const baseDefault = await cameraView() + const canvasBounds = await canvas.boundingBox() + if (!canvasBounds) throw new Error('3D canvas has no bounds') + const cameraX = canvasBounds.x + canvasBounds.width / 2 + const cameraY = canvasBounds.y + canvasBounds.height / 2 + await page.mouse.move(cameraX, cameraY) + await page.mouse.down() + await page.mouse.move(cameraX + 120, cameraY + 80, { steps: 10 }) + await page.mouse.up() + await expect.poll(cameraView).not.toBe(baseDefault) + const baseView = await settledCamera() + + const baseTriangles = await triangles(page) + await page.getByRole('link', { name: 'Holders' }).click() + await rebuilt(page, baseTriangles) + const holderDefault = await cameraView() + await page.mouse.move(cameraX, cameraY) + await page.mouse.down() + await page.mouse.move(cameraX - 90, cameraY - 60, { steps: 10 }) + await page.mouse.up() + await expect.poll(cameraView).not.toBe(holderDefault) + const holderView = await settledCamera() + + const holderTriangles = await triangles(page) + await page.getByRole('link', { name: 'Bases' }).click() + await rebuilt(page, holderTriangles) + await expect.poll(() => cameraDifference(baseView)).toBeLessThan(0.02) + + const restoredBaseTriangles = await triangles(page) + await page.getByRole('link', { name: 'Holders' }).click() + await rebuilt(page, restoredBaseTriangles) + await expect.poll(() => cameraDifference(holderView)).toBeLessThan(0.02) +}) + test('links to the source repository', async ({ page }) => { await expect(page.getByRole('link', { name: 'GitHub' })).toHaveAttribute('href', 'https://github.com/richardsolomou/basekit') }) @@ -251,6 +314,48 @@ test('builds a matching printable flying stem', async ({ page }) => { await expect(footer(page)).toContainText('Ø4 mm') }) +test('builds a low-profile spray tray with an interleaved magnet grid', async ({ page }) => { + const before = await triangles(page) + await page.getByRole('link', { name: 'Spray tray' }).click() + await rebuilt(page, before) + + await expect(page).toHaveURL(/\/spray-tray$/) + await expect(page.getByRole('complementary', { name: 'Spray tray settings' })).toBeVisible() + await expect.poll(async () => Number((await drawn(page).getAttribute('data-camera-view'))?.split(',')[5])).toBeCloseTo(-49.9, 1) + await expect(tall(page)).toHaveText('102.9') + await expect(footer(page)).toContainText('painting-tray-4x4-174x174mm') + await expect(footer(page)).toContainText('4×4 + 3×3 · 50 mm pitch') + await expect(footer(page)).toContainText('25 × 5.2 mm hole') + await expect(footer(page)).toContainText('Round · Straight · 28 × 100 mm') + await expect(page.getByText('Slots', { exact: true })).toHaveCount(0) + + const roundTriangles = await triangles(page) + await pickChoice(page, 'Grip shape', 'Oval barrel') + await rebuilt(page, roundTriangles) + await page.getByLabel('Grip length in mm').fill('110') + await page.getByLabel('Grip length in mm').press('Enter') + await expect(tall(page)).toHaveText('112.9') + await expect(footer(page)).toContainText('Oval barrel · Straight · 28 × 110 mm') + + const pending = page.waitForEvent('download') + await page.getByRole('button', { name: 'Download STL' }).click() + const saved = await pending + expect(saved.suggestedFilename()).toBe('painting-tray-4x4-174x174mm.zip') + const path = await saved.path() + if (!path) throw new Error('download has no local path') + expect(Object.keys(unzipSync(await readFile(path))).sort()).toEqual([ + 'painting-tray-4x4-174x174mm-handle-oval-28x110mm-0deg.stl', + 'painting-tray-4x4-174x174mm.stl', + ]) + + const gridTriangles = await triangles(page) + await page.getByLabel('Columns in ', { exact: true }).fill('5') + await page.getByLabel('Columns in ', { exact: true }).press('Enter') + await rebuilt(page, gridTriangles) + await expect(footer(page)).toContainText('5×4 + 4×3 · 50 mm pitch') + await expect(footer(page)).toContainText('32 × 5.2 mm hole') +}) + test('aligns toggle and dimension reset columns', async ({ page }) => { await page.getByRole('link', { name: 'Holders' }).click() await page.getByLabel('Between miniatures in mm').fill('1.5') diff --git a/scripts/exportSamples.ts b/scripts/exportSamples.ts index 114305f..6eb4d93 100644 --- a/scripts/exportSamples.ts +++ b/scripts/exportSamples.ts @@ -1,6 +1,6 @@ /** * Writes a sample STL per preset size so the geometry can be inspected outside the - * browser. Usage: pnpm samples [outDir] [round|oval] + * browser. Usage: pnpm samples [outDir] [round|oval|painting] */ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' @@ -10,6 +10,14 @@ import { buildBase } from '../src/geometry/base' import { toStl } from '../src/geometry/exporters' import { loadManifold } from '../src/geometry/manifold' import { baseName } from '../src/geometry/outline' +import { + buildPaintingHandle, + buildPaintingTray, + defaultPaintingTrayConfig, + paintingHandleConfig, + paintingHandleName, + paintingTrayName, +} from '../src/geometry/paintingTray' import { OVAL_SIZES, presetFor, ROUND_SIZES } from '../src/geometry/presets' const FONT_PATH = 'src/assets/fonts/oswald-700.woff' @@ -21,10 +29,25 @@ const [outDir = 'samples', family = 'round'] = process.argv.slice(2) const sizes = family === 'oval' ? OVAL_SIZES : ROUND_SIZES const wasm = await loadManifold() +mkdirSync(outDir, { recursive: true }) + +if (family === 'painting') { + const config = defaultPaintingTrayConfig() + const parts = [ + { ...buildPaintingTray(wasm, { ...config, assembly: false }), name: paintingTrayName(config) }, + { ...buildPaintingHandle(wasm, paintingHandleConfig(config)), name: paintingHandleName(config) }, + ] + for (const { mesh, stats, name } of parts) { + const filename = `${name}.stl` + writeFileSync(join(outDir, filename), toStl(mesh, filename)) + console.log(`${filename} ${stats.triangles} tris ${stats.volume.toFixed(0)}mm3 ${stats.grams.toFixed(2)}g`) + } + process.exit(0) +} + const bytes = readFileSync(FONT_PATH) const font = parse(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)) -mkdirSync(outDir, { recursive: true }) for (const size of sizes) { const config = presetFor(size) const { mesh, stats } = buildBase(wasm, config, font) diff --git a/src/App.tsx b/src/App.tsx index b5d9b23..0ffe667 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ -import { Box, ChevronDown, ChevronUp, Code2, Download, PanelLeft, Plus, Trash2 } from 'lucide-react' +import { Box, Code2, Download, PanelLeft } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' -import { Choice, CompactChoice, Dimension, Section, SizeSelect, ToggleSetting } from '@/components/controls' +import { Choice, Dimension, Section, SizeSelect, ToggleSetting } from '@/components/controls' +import { MiniatureGroupsEditor } from '@/components/MiniatureGroupsEditor' import { Button, buttonVariants } from '@/components/ui/button' import { ButtonGroup } from '@/components/ui/button-group' import { Field, FieldDescription, FieldLabel } from '@/components/ui/field' @@ -12,7 +13,6 @@ import { Viewer } from '@/components/Viewer' import { supportsFivePocketCross } from '@/geometry/base' import { defaultHolderConfig, - holderGroup, holderGroupLabel, holderLayout, holderName, @@ -36,6 +36,19 @@ import { type SizePreset, } from '@/geometry/presets' import { maxProfileSize } from '@/geometry/profile' +import { + defaultPaintingTrayConfig, + minimumPaintingTrayEdgeMargin, + minimumPaintingTrayHeight, + minimumPaintingTraySpacing, + paintingHandleAxisCenter, + paintingHandleDescription, + paintingTrayAssemblyHeight, + paintingTrayAssemblyMinZ, + paintingTrayLayout, + paintingTrayMagnetPocketCount, + paintingTrayName, +} from '@/geometry/paintingTray' import { CLASSIC_STEM_HEIGHTS, defaultFlightStemConfig, @@ -44,7 +57,17 @@ import { stemNeckDiameter, stemOverallHeight, } from '@/geometry/stem' -import type { BaseConfig, EdgeProfile, FlightStemConfig, HolderConfig, MagnetLayout, ShapeKind } from '@/geometry/types' +import type { + BaseConfig, + EdgeProfile, + FlightStemConfig, + HolderConfig, + HolderGroup, + MagnetLayout, + PaintingHandleShape, + PaintingTrayConfig, + ShapeKind, +} from '@/geometry/types' import { useExport } from '@/lib/useExport' import { useGenerator } from '@/lib/useGenerator' import { useMediaQuery } from '@/lib/useMediaQuery' @@ -59,15 +82,8 @@ const SHAPES: { value: ShapeKind; label: string }[] = [ { value: 'polygon', label: 'Hex' }, ] -const HOLDER_SIZE_PRESETS = Object.values(SIZES_BY_SHAPE).flat() const CUSTOM_HOLDER_SIZE = 'custom' -function holderSizePreset(group: { shape: ShapeKind; width: number; length: number }) { - return HOLDER_SIZE_PRESETS.find( - (size) => size.shape === group.shape && size.width === group.width && (size.length ?? size.width) === group.length, - ) -} - const PROFILES: { value: EdgeProfile; label: string }[] = [ { value: 'taper', label: 'Taper' }, { value: 'bevel', label: 'Bevel' }, @@ -84,9 +100,10 @@ const AUTOMATIC_MAGNET_COUNT = 'auto' type MagnetCountChoice = number | typeof AUTOMATIC_MAGNET_COUNT const RIB_COUNTS = counts(RIB_CHOICES) const MODELS = [ - { value: 'base' as const, label: 'Bases', href: '/' }, - { value: 'holder' as const, label: 'Holders', href: '/holders' }, - { value: 'stem' as const, label: 'Stems', href: '/stems' }, + { value: 'base' as const, label: 'Bases', mobileLabel: 'Bases', href: '/' }, + { value: 'holder' as const, label: 'Holders', mobileLabel: 'Holders', href: '/holders' }, + { value: 'painting' as const, label: 'Spray tray', mobileLabel: 'Spray', href: '/spray-tray' }, + { value: 'stem' as const, label: 'Stems', mobileLabel: 'Stems', href: '/stems' }, ] const ENGRAVING_PLACEMENTS = [ { value: 'slots' as const, label: 'In slots' }, @@ -94,6 +111,13 @@ const ENGRAVING_PLACEMENTS = [ ] const BASE_DEFAULTS = presetFor(DEFAULT_PRESET) const HOLDER_DEFAULTS = defaultHolderConfig() +const PAINTING_DEFAULTS = defaultPaintingTrayConfig() +const PAINTING_HANDLE_SHAPES: { value: PaintingHandleShape; label: string }[] = [ + { value: 'round', label: 'Round' }, + { value: 'oval', label: 'Oval barrel' }, + { value: 'flared', label: 'Flared base' }, + { value: 'pistol', label: 'Pistol grip' }, +] const STEM_DEFAULTS = defaultFlightStemConfig() const STEM_HEIGHTS = CLASSIC_STEM_HEIGHTS.map((value) => ({ value, label: `${value} mm` })) const STEM_CONNECTIONS = [ @@ -103,8 +127,23 @@ const STEM_CONNECTIONS = [ type Generator = (typeof MODELS)[number]['value'] const modelForPath = (): Generator => - window.location.pathname === '/holders' ? 'holder' : window.location.pathname === '/stems' ? 'stem' : 'base' -const modelLabel = (model: Generator) => (model === 'base' ? 'Base' : model === 'holder' ? 'Holder' : 'Stem') + window.location.pathname === '/holders' + ? 'holder' + : window.location.pathname === '/spray-tray' + ? 'painting' + : window.location.pathname === '/stems' + ? 'stem' + : 'base' +const modelLabel = (model: Generator) => + model === 'base' ? 'Base' : model === 'holder' ? 'Holder' : model === 'painting' ? 'Spray tray' : 'Stem' + +function fittedCounts(modules: { config: { groups: HolderGroup[] } }[]) { + const fitted = new Map() + for (const module of modules) { + for (const group of module.config.groups) fitted.set(group.id, (fitted.get(group.id) ?? 0) + group.quantity) + } + return fitted +} function RepositoryLink() { return ( @@ -126,6 +165,7 @@ export function App() { const [workspace, setWorkspaceState] = useState(() => loadWorkspace(window.localStorage)) const config = workspace.base const holder = workspace.holder + const paintingTray = workspace.paintingTray const stem = workspace.stem const setWorkspace = (next: WorkspaceState | ((current: WorkspaceState) => WorkspaceState)) => setWorkspaceState((current) => synchronizeWorkspace(typeof next === 'function' ? next(current) : next)) @@ -133,17 +173,21 @@ export function App() { setWorkspace((current) => ({ ...current, base: typeof next === 'function' ? next(current.base) : next })) const setHolder = (next: HolderConfig | ((current: HolderConfig) => HolderConfig)) => setWorkspace((current) => ({ ...current, holder: typeof next === 'function' ? next(current.holder) : next })) + const setPaintingTray = (next: PaintingTrayConfig | ((current: PaintingTrayConfig) => PaintingTrayConfig)) => + setWorkspace((current) => ({ + ...current, + paintingTray: typeof next === 'function' ? next(current.paintingTray) : next, + })) const setStem = (next: FlightStemConfig | ((current: FlightStemConfig) => FlightStemConfig)) => setWorkspace((current) => ({ ...current, stem: typeof next === 'function' ? next(current.stem) : next })) const [customBaseSize, setCustomBaseSize] = useState(() => { const { width, length } = footprint(workspace.base) return !SIZES_BY_SHAPE[workspace.base.shape].some((size) => size.width === width && (size.length ?? size.width) === length) }) - const [customHolderGroups, setCustomHolderGroups] = useState>(() => new Set()) const [model, setModel] = useState(modelForPath) // Tailwind's `md`, the width at which the panel stops needing to slide in. const docked = useMediaQuery('(min-width: 48rem)') - const partConfig = model === 'base' ? config : model === 'holder' ? holder : stem + const partConfig = model === 'base' ? config : model === 'holder' ? holder : model === 'painting' ? paintingTray : stem const { preview, error } = useGenerator(partConfig) useEffect(() => { @@ -153,7 +197,9 @@ export function App() { }, []) useEffect(() => { - document.title = `BaseKit — ${model === 'base' ? 'Bases' : model === 'holder' ? 'Holders' : 'Flying Stems'}` + document.title = `BaseKit — ${ + model === 'base' ? 'Bases' : model === 'holder' ? 'Holders' : model === 'painting' ? 'Spray Trays' : 'Flying Stems' + }` }, [model]) useEffect(() => saveWorkspace(window.localStorage, workspace), [workspace]) @@ -161,7 +207,11 @@ export function App() { const changeModel = (next: Generator) => { if (next === model) return posthog.capture('generator_selected', { generator: next }) - window.history.pushState(null, '', next === 'holder' ? '/holders' : next === 'stem' ? '/stems' : '/') + window.history.pushState( + null, + '', + next === 'holder' ? '/holders' : next === 'painting' ? '/spray-tray' : next === 'stem' ? '/stems' : '/', + ) setModel(next) } @@ -173,6 +223,7 @@ export function App() { }) const { width, length } = footprint(config) const holderSize = useMemo(() => holderLayout(holder), [holder]) + const paintingSize = useMemo(() => paintingTrayLayout(paintingTray), [paintingTray]) const maxSlotDepth = Math.max(1, Math.floor(maxHolderSlotDepth(holder) / 0.5) * 0.5) const maxBaseMagnetThickness = Math.max(0.5, config.height - config.floorThickness) - config.magnets.depthClearance const maxSharedMagnetThickness = Math.max(0.5, Math.min(maxBaseMagnetThickness, Math.floor(maxHolderMagnetThickness(holder) * 10) / 10)) @@ -187,39 +238,28 @@ export function App() { }) const plan = useMemo(() => holderPlan(holder), [holder]) const requestedModels = useMemo(() => holder.groups.reduce((total, group) => total + group.quantity, 0), [holder.groups]) - const fittedByGroup = useMemo(() => { - const fitted = new Map() - for (const module of plan.modules) { - for (const group of module.config.groups) fitted.set(group.id, (fitted.get(group.id) ?? 0) + group.quantity) - } - return fitted - }, [plan]) - const moveGroup = (index: number, direction: -1 | 1) => { - const target = index + direction - if (target < 0 || target >= holder.groups.length) return - const groups = [...holder.groups] - const current = groups[index] - groups[index] = groups[target] - groups[target] = current - setHolder({ ...holder, groups }) - } - const showCustomHolderGroup = (id: string) => - setCustomHolderGroups((current) => { - const next = new Set(current) - next.add(id) - return next - }) - const hideCustomHolderGroup = (id: string) => - setCustomHolderGroups((current) => { - const next = new Set(current) - next.delete(id) - return next - }) + const fittedByGroup = useMemo(() => fittedCounts(plan.modules), [plan]) const stemDiameter = stemMaximumDiameter(stem) - const partWidth = model === 'base' ? width : model === 'holder' ? holderSize.width : stemDiameter - const partLength = model === 'base' ? length : model === 'holder' ? holderSize.length : stemDiameter - const partHeight = model === 'base' ? config.height : model === 'holder' ? holder.height : stemOverallHeight(stem) - const partName = model === 'base' ? baseName(config) : model === 'holder' ? holderName(holder) : stemName(stem) + const partWidth = + model === 'base' ? width : model === 'holder' ? holderSize.width : model === 'painting' ? paintingSize.width : stemDiameter + const partLength = + model === 'base' ? length : model === 'holder' ? holderSize.length : model === 'painting' ? paintingSize.length : stemDiameter + const partHeight = + model === 'base' + ? config.height + : model === 'holder' + ? holder.height + : model === 'painting' + ? paintingTrayAssemblyHeight(paintingTray) + : stemOverallHeight(stem) + const partName = + model === 'base' + ? baseName(config) + : model === 'holder' + ? holderName(holder) + : model === 'painting' + ? paintingTrayName(paintingTray) + : stemName(stem) const { exporting, error: exportError, @@ -229,6 +269,7 @@ export function App() { model, base: config, holder, + paintingTray, stem, width: partWidth, length: partLength, @@ -636,177 +677,11 @@ export function App() { } > -

Priority runs from top to bottom.

-
- Qty - Shape - Size -
- {holder.groups.map((group, index) => { - const groupStandard = holderSizePreset(group) - const customOpen = customHolderGroups.has(group.id) || !groupStandard - const fitted = fittedByGroup.get(group.id) ?? 0 - const missing = group.quantity - fitted - return ( -
0 ? 'border-destructive/50' : 'border-border' - }`} - > - { - const groups = holder.groups.map((entry, groupIndex) => - groupIndex === index ? { ...group, quantity: Math.round(quantity) } : entry, - ) - setHolder({ ...holder, groups }) - }} - /> - { - hideCustomHolderGroup(group.id) - setHolder({ - ...holder, - groups: holder.groups.map((entry, groupIndex) => - groupIndex === index ? holderGroup(group.id, group.quantity, { shape }) : entry, - ), - }) - }} - /> - ({ value: size.label, use: size.use })), - { value: CUSTOM_HOLDER_SIZE, label: 'Custom', use: 'exact dimensions' }, - ]} - onChange={(value) => { - if (value === CUSTOM_HOLDER_SIZE) { - showCustomHolderGroup(group.id) - return - } - const size = SIZES_BY_SHAPE[group.shape].find((candidate) => candidate.label === value) - if (!size) return - hideCustomHolderGroup(group.id) - setHolder({ - ...holder, - groups: holder.groups.map((entry, groupIndex) => - groupIndex === index - ? holderGroup(group.id, group.quantity, { - shape: size.shape, - width: size.width, - length: size.length ?? size.width, - }) - : entry, - ), - }) - }} - /> - {customOpen && ( -
- - setHolder({ - ...holder, - groups: holder.groups.map((entry, groupIndex) => - groupIndex === index - ? { ...group, width: baseWidth, length: isElongated(group.shape) ? group.length : baseWidth } - : entry, - ), - }) - } - /> - {isElongated(group.shape) && ( - - setHolder({ - ...holder, - groups: holder.groups.map((entry, groupIndex) => - groupIndex === index ? { ...group, length: baseLength } : entry, - ), - }) - } - /> - )} -
- )} -
- {missing > 0 && ( -

- {fitted === 0 ? `None of ${group.quantity} fit` : `Only ${fitted} of ${group.quantity} fit`} -

- )} -
- - - -
-
-
- ) - })} - + setHolder({ ...holder, groups })} + />
) + const paintingPanel = ( + + + + ) + const stemPanel = ( ) - const panel = model === 'base' ? basePanel : model === 'holder' ? holderPanel : stemPanel + const panel = model === 'base' ? basePanel : model === 'holder' ? holderPanel : model === 'painting' ? paintingPanel : stemPanel return (
-
+
{/* Same panel, same order; on a narrow screen it slides in from the left instead of standing beside the sheet. */} {!docked && ( @@ -1105,25 +1152,28 @@ export function App() { )} -

- BK - - BaseKit - +

+ BaseKit

@@ -1148,10 +1198,13 @@ export function App() {
diff --git a/src/components/MiniatureGroupsEditor.tsx b/src/components/MiniatureGroupsEditor.tsx new file mode 100644 index 0000000..8ed300f --- /dev/null +++ b/src/components/MiniatureGroupsEditor.tsx @@ -0,0 +1,198 @@ +import { ChevronDown, ChevronUp, Plus, Trash2 } from 'lucide-react' +import { useState } from 'react' +import { CompactChoice, Dimension, SizeSelect } from '@/components/controls' +import { Button } from '@/components/ui/button' +import { holderGroup } from '@/geometry/holder' +import { isElongated } from '@/geometry/outline' +import { SIZES_BY_SHAPE } from '@/geometry/presets' +import type { HolderGroup, ShapeKind } from '@/geometry/types' +import posthog from '@/lib/posthog' + +const SHAPES: { value: ShapeKind; label: string }[] = [ + { value: 'round', label: 'Round' }, + { value: 'oval', label: 'Oval' }, + { value: 'pill', label: 'Pill' }, + { value: 'rect', label: 'Rectangle' }, + { value: 'polygon', label: 'Hex' }, +] +const SIZE_PRESETS = Object.values(SIZES_BY_SHAPE).flat() +const CUSTOM_SIZE = 'custom' + +function sizePreset(group: Pick) { + return SIZE_PRESETS.find( + (size) => size.shape === group.shape && size.width === group.width && (size.length ?? size.width) === group.length, + ) +} + +interface Props { + groups: HolderGroup[] + fittedByGroup: Map + onChange: (groups: HolderGroup[]) => void +} + +export function MiniatureGroupsEditor({ groups, fittedByGroup, onChange }: Props) { + const [customGroups, setCustomGroups] = useState>(() => new Set()) + const setCustom = (id: string, custom: boolean) => + setCustomGroups((current) => { + const next = new Set(current) + if (custom) next.add(id) + else next.delete(id) + return next + }) + const replace = (index: number, group: HolderGroup) => onChange(groups.map((entry, groupIndex) => (groupIndex === index ? group : entry))) + const move = (index: number, direction: -1 | 1) => { + const target = index + direction + if (target < 0 || target >= groups.length) return + const next = [...groups] + const current = next[index] + next[index] = next[target] + next[target] = current + onChange(next) + } + + return ( + <> +

Priority runs from top to bottom.

+
+ Qty + Shape + Size +
+ {groups.map((group, index) => { + const standard = sizePreset(group) + const customOpen = customGroups.has(group.id) || !standard + const fitted = fittedByGroup.get(group.id) ?? 0 + const missing = group.quantity - fitted + return ( +
0 ? 'border-destructive/50' : 'border-border' + }`} + > + replace(index, { ...group, quantity: Math.round(quantity) })} + /> + { + setCustom(group.id, false) + replace(index, holderGroup(group.id, group.quantity, { shape })) + }} + /> + ({ value: size.label, use: size.use })), + { value: CUSTOM_SIZE, label: 'Custom', use: 'exact dimensions' }, + ]} + onChange={(value) => { + if (value === CUSTOM_SIZE) { + setCustom(group.id, true) + return + } + const size = SIZES_BY_SHAPE[group.shape].find((candidate) => candidate.label === value) + if (!size) return + setCustom(group.id, false) + replace( + index, + holderGroup(group.id, group.quantity, { + shape: size.shape, + width: size.width, + length: size.length ?? size.width, + }), + ) + }} + /> + {customOpen && ( +
+ replace(index, { ...group, width, length: isElongated(group.shape) ? group.length : width })} + /> + {isElongated(group.shape) && ( + replace(index, { ...group, length })} + /> + )} +
+ )} +
+ {missing > 0 && ( +

+ {fitted === 0 ? `None of ${group.quantity} fit` : `Only ${fitted} of ${group.quantity} fit`} +

+ )} +
+ + + +
+
+
+ ) + })} + + + ) +} diff --git a/src/components/TitleBlock.tsx b/src/components/TitleBlock.tsx index 91f3886..453297e 100644 --- a/src/components/TitleBlock.tsx +++ b/src/components/TitleBlock.tsx @@ -1,5 +1,6 @@ import { Badge } from '@/components/ui/badge' import { defaultLabel, trimNumber } from '@/geometry/outline' +import { paintingHandleDescription, paintingTrayLayout, paintingTrayMagnetPocketCount } from '@/geometry/paintingTray' import { holderGroupLabel, holderLayout, holderMagnetPocketCount, holderPlan } from '@/geometry/holder' import { stemOverallHeight } from '@/geometry/stem' import type { PartConfig } from '@/geometry/types' @@ -57,6 +58,21 @@ export function TitleBlock({ config, status, name }: Props) { ) } + if (config.kind === 'painting-tray') { + const layout = paintingTrayLayout(config) + const pocket = trimNumber(config.magnets.diameter + config.magnets.clearance) + return ( + + + + + + + ) + } const pocket = trimNumber(config.magnets.diameter + config.magnets.clearance) const pocketDepth = trimNumber(config.magnets.thickness + config.magnets.depthClearance) diff --git a/src/components/Viewer.tsx b/src/components/Viewer.tsx index e5ac6de..ca2dd45 100644 --- a/src/components/Viewer.tsx +++ b/src/components/Viewer.tsx @@ -59,15 +59,25 @@ function themeColor(name: string, fallback: string): THREE.Color { } interface Props { + viewKey: string mesh?: MeshData width: number length: number height: number + minZ?: number + orbitTarget?: readonly [number, number, number] round: boolean fitToPart?: boolean } -export function Viewer({ mesh, width, length, height, round, fitToPart = false }: Props) { +interface CameraView { + held: boolean + position: THREE.Vector3 + target: THREE.Vector3 + zoom: number +} + +export function Viewer({ viewKey, mesh, width, length, height, minZ = 0, orbitTarget, round, fitToPart = false }: Props) { const host = useRef(null) const overlay = useRef(null) const part = useRef(null) @@ -76,8 +86,11 @@ export function Viewer({ mesh, width, length, height, round, fitToPart = false } const cameraRef = useRef(null) const controlsRef = useRef(null) const held = useRef(false) + const activeViewKey = useRef(viewKey) + const cameraViews = useRef(new Map()) const shouldFit = useRef(fitToPart) const framingFootprint = useRef(REFERENCE_FOOTPRINT) + const [targetX, targetY, targetZ] = orbitTarget ?? [0, 0, minZ + height / 2] shouldFit.current = fitToPart framingFootprint.current = fitToPart ? Math.max(width, length, height) : REFERENCE_FOOTPRINT @@ -162,9 +175,18 @@ export function Viewer({ mesh, width, length, height, round, fitToPart = false } // Until the viewer touches the camera it stays framed on the reference // footprint, which is what makes a window resize or a phone rotating do the // sensible thing. After that the view is theirs and nothing moves it. - controls.addEventListener('start', () => { + const holdCamera = () => { held.current = true - }) + } + const publishCamera = () => { + container.dataset.cameraView = [...camera.position.toArray(), ...controls.target.toArray(), camera.zoom] + .map((value) => value.toFixed(2)) + .join(',') + } + controls.addEventListener('start', holdCamera) + controls.addEventListener('change', publishCamera) + renderer.domElement.addEventListener('wheel', holdCamera, { passive: true }) + publishCamera() let lastWidth = -1 let lastHeight = -1 @@ -205,7 +227,8 @@ export function Viewer({ mesh, width, length, height, round, fitToPart = false } const svg = overlay.current const halfW = group.userData.halfWidth ?? 0 const halfL = group.userData.halfLength ?? 0 - const h = group.userData.height ?? 0 + const lower = group.userData.minZ ?? 0 + const upper = group.userData.maxZ ?? 0 if (svg && halfW > 0) { // Dimensions hang off the projected silhouette, so they stay outside the part // and stay horizontal or vertical at any orbit, the way a drawing does it. @@ -216,7 +239,7 @@ export function Viewer({ mesh, width, length, height, round, fitToPart = false } let top = Infinity let rightAngle = rim[0] for (const point of rim) { - const [x, y] = project(halfW * point.cos, halfL * point.sin, h) + const [x, y] = project(halfW * point.cos, halfL * point.sin, upper) if (x < left) left = x if (x > right) { right = x @@ -225,8 +248,8 @@ export function Viewer({ mesh, width, length, height, round, fitToPart = false } if (y < top) top = y } const rail = top - LIFT - const [, bottomY] = project(halfW * rightAngle.cos, halfL * rightAngle.sin, 0) - const [, topY] = project(halfW * rightAngle.cos, halfL * rightAngle.sin, h) + const [, bottomY] = project(halfW * rightAngle.cos, halfL * rightAngle.sin, lower) + const [, topY] = project(halfW * rightAngle.cos, halfL * rightAngle.sin, upper) const column = right + OUT const set = (id: string, d: string) => svg.querySelector(`#${id}`)?.setAttribute('d', d) @@ -250,6 +273,9 @@ export function Viewer({ mesh, width, length, height, round, fitToPart = false } return () => { cancelAnimationFrame(raf) observer.disconnect() + renderer.domElement.removeEventListener('wheel', holdCamera) + controls.removeEventListener('start', holdCamera) + controls.removeEventListener('change', publishCamera) controls.dispose() renderer.dispose() renderer.domElement.remove() @@ -300,12 +326,12 @@ export function Viewer({ mesh, width, length, height, round, fitToPart = false } ) group.add(edges) - group.userData = { halfWidth: width / 2, halfLength: length / 2, height } + group.userData = { halfWidth: width / 2, halfLength: length / 2, minZ, maxZ: minZ + height } const camera = cameraRef.current const controls = controlsRef.current if (camera && controls && !held.current) { - controls.target.set(0, 0, height / 2) + controls.target.set(targetX, targetY, targetZ) const footprint = shouldFit.current ? Math.max(width, length, height) : REFERENCE_FOOTPRINT camera.position.copy(controls.target).addScaledVector(VIEW_DIRECTION, framingDistance(camera.aspect, footprint)) controls.update() @@ -315,7 +341,38 @@ export function Viewer({ mesh, width, length, height, round, fitToPart = false } // honest signal that a rebuild has landed — the status word reads "ready" // from the build before the one being waited on. The e2e suite polls it. if (host.current) host.current.dataset.triangles = String(mesh.indices.length / 3) - }, [mesh, width, length, height]) + }, [mesh, width, length, height, minZ, targetX, targetY, targetZ]) + + useEffect(() => { + const camera = cameraRef.current + const controls = controlsRef.current + if (!camera || !controls || activeViewKey.current === viewKey) return + + cameraViews.current.set(activeViewKey.current, { + held: held.current, + position: camera.position.clone(), + target: controls.target.clone(), + zoom: camera.zoom, + }) + activeViewKey.current = viewKey + + const saved = cameraViews.current.get(viewKey) + if (saved?.held) { + held.current = true + camera.position.copy(saved.position) + controls.target.copy(saved.target) + camera.zoom = saved.zoom + camera.updateProjectionMatrix() + } else { + held.current = false + camera.zoom = 1 + camera.updateProjectionMatrix() + controls.target.set(targetX, targetY, targetZ) + const footprint = fitToPart ? Math.max(width, length, height) : REFERENCE_FOOTPRINT + camera.position.copy(controls.target).addScaledVector(VIEW_DIRECTION, framingDistance(camera.aspect, footprint)) + } + controls.update() + }, [viewKey, width, length, height, fitToPart, targetX, targetY, targetZ]) const across = round ? `Ø${trimNumber(width)}` : `${trimNumber(width)} × ${trimNumber(length)}` diff --git a/src/geometry/paintingTray.test.ts b/src/geometry/paintingTray.test.ts new file mode 100644 index 0000000..8f71cbc --- /dev/null +++ b/src/geometry/paintingTray.test.ts @@ -0,0 +1,148 @@ +import type { Mesh } from 'manifold-3d' +import { beforeAll, describe, expect, it } from 'vitest' +import { loadManifold } from './manifold' +import { + buildPaintingHandle, + buildPaintingTray, + defaultPaintingTrayConfig, + minimumPaintingTrayEdgeMargin, + minimumPaintingTrayHeight, + minimumPaintingTraySpacing, + paintingHandleAxisCenter, + paintingHandleConfig, + paintingTrayAssemblyHeight, + paintingTrayAssemblyMinZ, + paintingTrayCenterMarkSpan, + paintingTrayLayout, + paintingTrayMagnetPocketCount, +} from './paintingTray' + +let wasm: Awaited> + +beforeAll(async () => { + wasm = await loadManifold() +}) + +function bounds(mesh: Mesh) { + const { numProp, vertProperties } = mesh + const min = [Infinity, Infinity, Infinity] + const max = [-Infinity, -Infinity, -Infinity] + for (let index = 0; index < vertProperties.length; index += numProp) { + for (let axis = 0; axis < 3; axis++) { + min[axis] = Math.min(min[axis], vertProperties[index + axis]) + max[axis] = Math.max(max[axis], vertProperties[index + axis]) + } + } + return { min, size: max.map((value, axis) => value - min[axis]) } +} + +describe('painting tray', () => { + it('places intermediate pockets diagonally between the default four-by-four grid', () => { + const config = defaultPaintingTrayConfig() + const layout = paintingTrayLayout(config) + + expect(layout).toMatchObject({ columns: 4, rows: 4, width: 174, length: 174 }) + expect(layout.magnetCenters).toHaveLength(25) + expect(layout.magnetCenters).toContainEqual({ x: -50, y: -50 }) + expect(layout.magnetCenters).toContainEqual({ x: 0, y: 0 }) + expect(layout.magnetCenters).toContainEqual({ x: 50, y: 50 }) + expect(paintingTrayMagnetPocketCount(config)).toBe(25) + }) + + it('builds a low solid tray with one top-opening pocket', () => { + const defaults = defaultPaintingTrayConfig() + const config = { ...defaults, columns: 1, rows: 1, assembly: false } + const result = buildPaintingTray(wasm, config) + const measured = bounds(result.mesh) + const pocketRadius = (config.magnets.diameter + config.magnets.clearance) / 2 + let pocketBottom = Infinity + for (let index = 0; index < result.mesh.vertProperties.length; index += result.mesh.numProp) { + const x = result.mesh.vertProperties[index] + const y = result.mesh.vertProperties[index + 1] + const z = result.mesh.vertProperties[index + 2] + if (Math.hypot(x, y) <= pocketRadius + 0.05 && z > 0.4) { + pocketBottom = Math.min(pocketBottom, z) + } + } + + expect(result.stats.solid).toBe(true) + expect(measured.min[2]).toBeCloseTo(0, 5) + expect(measured.size).toEqual([24, 24, 3]) + expect(paintingTrayMagnetPocketCount(config)).toBe(1) + expect(pocketBottom).toBeCloseTo(config.height - config.magnets.thickness - config.magnets.depthClearance, 2) + }) + + it('shows the glue-on grip beneath the assembled tray', () => { + const config = defaultPaintingTrayConfig() + const measured = bounds(buildPaintingTray(wasm, config).mesh) + + expect(measured.min[2]).toBeCloseTo(-99.9, 1) + expect(measured.size[2]).toBeCloseTo(paintingTrayAssemblyHeight(config), 1) + }) + + it('centres the spray-tray orbit on the handle axis', () => { + const config = defaultPaintingTrayConfig() + const angled = { ...config, handle: { ...config.handle, shape: 'pistol' as const, angle: 20 } } + + expect(paintingHandleAxisCenter(config)).toEqual([0, 0, -49.9]) + expect(paintingHandleAxisCenter(angled)[0]).toBeCloseTo(-24.36, 2) + expect(paintingTrayAssemblyMinZ(config)).toBeCloseTo(-99.9, 5) + }) + + it('engraves a centre mark past the handle footprint', () => { + const defaults = defaultPaintingTrayConfig() + const config = { ...defaults, assembly: false } + const mesh = buildPaintingTray(wasm, config).mesh + const hasMarkFloor = Array.from({ length: mesh.vertProperties.length / mesh.numProp }, (_, index) => index * mesh.numProp).some( + (index) => + Math.abs(mesh.vertProperties[index]) < 1 && + Math.abs(mesh.vertProperties[index + 1]) < 1 && + Math.abs(mesh.vertProperties[index + 2] - 0.3) < 0.01, + ) + + expect(hasMarkFloor).toBe(true) + expect(paintingTrayCenterMarkSpan(config)).toBeGreaterThan(config.handle.width) + }) + + it.each(['round', 'oval', 'flared', 'pistol'] as const)('builds the %s handle as a printable solid', (shape) => { + const config = defaultPaintingTrayConfig() + const result = buildPaintingHandle(wasm, paintingHandleConfig({ ...config, handle: { ...config.handle, shape } })) + const measured = bounds(result.mesh) + + expect(result.stats.solid).toBe(true) + expect(measured.min[2]).toBeCloseTo(0, 5) + expect(measured.size[2]).toBeCloseTo(config.handle.length, 1) + }) + + it('uses the configured handle length and angle', () => { + const config = defaultPaintingTrayConfig() + const handle = { ...config.handle, length: 110, angle: 30 } + const measured = bounds(buildPaintingHandle(wasm, paintingHandleConfig({ ...config, handle })).mesh) + + expect(measured.size[2]).toBeCloseTo(110, 1) + expect(measured.size[0]).toBeGreaterThan(80) + }) + + it('adds material for grip ribs', () => { + const config = defaultPaintingTrayConfig() + const plain = buildPaintingHandle(wasm, paintingHandleConfig(config)).stats.volume + const ribbed = buildPaintingHandle(wasm, paintingHandleConfig({ ...config, handle: { ...config.handle, ribs: true } })).stats.volume + + expect(ribbed).toBeGreaterThan(plain) + }) + + it('rejects a tray that leaves too little material below its magnet pockets', () => { + const config = defaultPaintingTrayConfig() + expect(() => buildPaintingTray(wasm, { ...config, height: minimumPaintingTrayHeight(config) - 0.01 })).toThrow(/too little material/) + }) + + it('rejects a grid without enough material between adjacent pockets', () => { + const config = defaultPaintingTrayConfig() + expect(() => buildPaintingTray(wasm, { ...config, spacing: minimumPaintingTraySpacing(config) - 0.01 })).toThrow(/diagonal pockets/) + }) + + it('rejects a grid without enough material between pockets and the edge', () => { + const config = defaultPaintingTrayConfig() + expect(() => buildPaintingTray(wasm, { ...config, edgeMargin: minimumPaintingTrayEdgeMargin(config) - 0.01 })).toThrow(/outer pockets/) + }) +}) diff --git a/src/geometry/paintingTray.ts b/src/geometry/paintingTray.ts new file mode 100644 index 0000000..df6b9fc --- /dev/null +++ b/src/geometry/paintingTray.ts @@ -0,0 +1,299 @@ +import type { CrossSection, Manifold, ManifoldToplevel } from 'manifold-3d' +import { defaultHolderConfig, type HolderBuildResult } from './holder' +import { trimNumber } from './outline' +import { curveTolerance, segmentsForTolerance } from './quality' +import type { PaintingHandleConfig, PaintingHandleShape, PaintingTrayConfig } from './types' + +const CORNER_RADIUS = 6 +const MIN_FLOOR_THICKNESS = 0.6 +const MIN_POCKET_WEB = 0.8 +const PLA_DENSITY = 1.24e-3 +const HANDLE_PREVIEW_OVERLAP = 0.1 +const HANDLE_RIB_COUNT = 8 +const CENTER_MARK_DEPTH = 0.3 +const CENTER_MARK_WIDTH = 1 +const CENTER_MARK_CLEARANCE = 8 +const HANDLE_SHAPE_LABELS: Record = { + round: 'Round', + oval: 'Oval barrel', + flared: 'Flared', + pistol: 'Pistol', +} + +export interface PaintingTrayLayout { + columns: number + rows: number + width: number + length: number + magnetCenters: { x: number; y: number }[] +} + +export function paintingTrayLayout(config: PaintingTrayConfig): PaintingTrayLayout { + const columns = Math.max(1, Math.round(config.columns)) + const rows = Math.max(1, Math.round(config.rows)) + const width = (columns - 1) * config.spacing + config.edgeMargin * 2 + const length = (rows - 1) * config.spacing + config.edgeMargin * 2 + const primaryCenters = Array.from({ length: rows }, (_rowValue, row) => + Array.from({ length: columns }, (_columnValue, column) => ({ + x: (column - (columns - 1) / 2) * config.spacing, + y: (row - (rows - 1) / 2) * config.spacing, + })), + ).flat() + const intermediateCenters = Array.from({ length: rows - 1 }, (_rowValue, row) => + Array.from({ length: columns - 1 }, (_columnValue, column) => ({ + x: (column - (columns - 2) / 2) * config.spacing, + y: (row - (rows - 2) / 2) * config.spacing, + })), + ).flat() + const magnetCenters = [...primaryCenters, ...intermediateCenters] + return { columns, rows, width, length, magnetCenters } +} + +export function paintingTrayMagnetPocketCount(config: PaintingTrayConfig): number { + return paintingTrayLayout(config).magnetCenters.length +} + +export function paintingTrayCenterMarkSpan(config: PaintingTrayConfig): number { + const ribAllowance = config.handle.ribs ? Math.min(2.4, config.handle.width * 0.08) : 0 + return config.handle.width + ribAllowance + CENTER_MARK_CLEARANCE +} + +export function paintingHandleConfig(config: PaintingTrayConfig): PaintingHandleConfig { + return { + kind: 'painting-handle', + handle: { ...config.handle }, + segments: config.segments, + } +} + +function handleDepth(config: PaintingHandleConfig): number { + return config.handle.shape === 'oval' ? config.handle.width * 0.75 : config.handle.width +} + +function handleShapeScale(shape: PaintingHandleShape): number { + return shape === 'oval' ? 1.12 : shape === 'flared' ? 1.25 : shape === 'pistol' ? 1.08 : 1 +} + +export function paintingHandleDimensions(config: PaintingHandleConfig) { + const { handle } = config + const scale = handleShapeScale(handle.shape) + const ribAllowance = handle.ribs ? Math.min(2.4, handle.width * 0.08) : 0 + const offset = handle.length * Math.tan((handle.angle * Math.PI) / 180) + const curve = handle.shape === 'pistol' ? handle.width * 0.22 : 0 + return { + width: handle.width * scale + ribAllowance + Math.abs(offset) + curve, + length: handleDepth(config) * scale + ribAllowance, + height: handle.length, + } +} + +export function paintingTrayAssemblyHeight(config: PaintingTrayConfig): number { + return config.height + paintingHandleDimensions(paintingHandleConfig(config)).height - HANDLE_PREVIEW_OVERLAP +} + +export function paintingHandleAxisCenter(config: PaintingTrayConfig): [number, number, number] { + const { handle } = config + const leanOffset = (handle.length * Math.tan((handle.angle * Math.PI) / 180)) / 2 + const curveOffset = handle.shape === 'pistol' ? handle.width * 0.22 : 0 + const horizontal = leanOffset + curveOffset + return [horizontal === 0 ? 0 : -horizontal, 0, HANDLE_PREVIEW_OVERLAP - handle.length / 2] +} + +export function paintingTrayAssemblyMinZ(config: PaintingTrayConfig): number { + return HANDLE_PREVIEW_OVERLAP - config.handle.length +} + +export function paintingHandleDescription(config: PaintingTrayConfig): string { + const { handle } = config + const angle = handle.angle === 0 ? 'Straight' : `${trimNumber(handle.angle)}° lean` + return `${HANDLE_SHAPE_LABELS[handle.shape]} · ${angle} · ${trimNumber(handle.width)} × ${trimNumber(handle.length)} mm` +} + +export function minimumPaintingTrayHeight(config: { + magnets: Pick +}): number { + return config.magnets.thickness + config.magnets.depthClearance + MIN_FLOOR_THICKNESS + CENTER_MARK_DEPTH +} + +export function minimumPaintingTraySpacing(config: { magnets: Pick }): number { + return (config.magnets.diameter + config.magnets.clearance + MIN_POCKET_WEB) * Math.SQRT2 +} + +export function minimumPaintingTrayEdgeMargin(config: { magnets: Pick }): number { + return (config.magnets.diameter + config.magnets.clearance) / 2 + MIN_POCKET_WEB +} + +export function defaultPaintingTrayConfig(): PaintingTrayConfig { + const holder = defaultHolderConfig() + return { + kind: 'painting-tray', + columns: 4, + rows: 4, + spacing: 50, + edgeMargin: 12, + height: 3, + magnets: { ...holder.magnets, enabled: true }, + handle: { + shape: 'round', + length: 100, + width: 28, + angle: 0, + roundedEnd: false, + ribs: false, + }, + segments: 160, + } +} + +export function paintingTrayName(config: PaintingTrayConfig): string { + const layout = paintingTrayLayout(config) + return `painting-tray-${layout.columns}x${layout.rows}-${trimNumber(layout.width)}x${trimNumber(layout.length)}mm` +} + +export function paintingHandleName(config: PaintingTrayConfig): string { + const { handle } = config + const options = [handle.ribs ? 'ribbed' : '', handle.roundedEnd ? 'rounded' : ''].filter(Boolean).join('-') + return `${paintingTrayName(config)}-handle-${handle.shape}-${trimNumber(handle.width)}x${trimNumber(handle.length)}mm-${trimNumber(handle.angle)}deg${options ? `-${options}` : ''}` +} + +type Owner = void }>(value: T) => T + +function roundedRectangle( + CrossSection: ManifoldToplevel['CrossSection'], + width: number, + length: number, + radius: number, + segments: number, + section: Owner, +): CrossSection { + const core = section(CrossSection.square([width - radius * 2, length - radius * 2], true)) + return section(core.offset(radius, 'Round', 2, segments)) +} + +function paintingHandleSolid( + wasm: ManifoldToplevel, + config: PaintingHandleConfig, + section: Owner, + solidOf: Owner, + segmentsFor: (diameter: number, previewMinimum: number) => number, +): Manifold { + const { CrossSection } = wasm + const { handle } = config + const depth = handleDepth(config) + const circle = section(CrossSection.circle(handle.width / 2, segmentsFor(handle.width, 32))) + const base = depth === handle.width ? circle : section(circle.scale([1, depth / handle.width])) + let profile = base + if (handle.ribs) { + const ribRadius = Math.min(1.2, handle.width * 0.04) + const ribSegments = segmentsFor(ribRadius * 2, 12) + const ribs = Array.from({ length: HANDLE_RIB_COUNT }, (_, index) => { + const angle = (index * 2 * Math.PI) / HANDLE_RIB_COUNT + const rib = section(CrossSection.circle(ribRadius, ribSegments)) + return section(rib.translate([(handle.width / 2) * Math.cos(angle), (depth / 2) * Math.sin(angle)])) + }) + profile = section(CrossSection.union([base, ...ribs])) + } + const needsShaping = handle.shape !== 'round' || handle.roundedEnd + const grip = solidOf(profile.extrude(handle.length, needsShaping ? 20 : 0)) + const lean = Math.tan((handle.angle * Math.PI) / 180) + const leaning = solidOf( + grip.warp((vertex) => { + const position = vertex[2] / handle.length + const swell = + handle.shape === 'oval' + ? 1 + 0.12 * Math.sin(Math.PI * position) + : handle.shape === 'flared' + ? 1 + 0.25 * Math.max(0, 1 - position / 0.25) + : handle.shape === 'pistol' + ? 1 + 0.08 * Math.sin(Math.PI * position) + : 1 + const softenedEnd = handle.roundedEnd && position < 0.05 ? 0.86 + 2.8 * position : 1 + vertex[0] *= swell * softenedEnd + vertex[1] *= swell * softenedEnd + vertex[0] += vertex[2] * lean + if (handle.shape === 'pistol') vertex[0] -= handle.width * 0.22 * Math.sin(Math.PI * position) + }), + ) + return solidOf(leaning.translate([-handle.length * lean, 0, -handle.length])) +} + +export function buildPaintingHandle(wasm: ManifoldToplevel, config: PaintingHandleConfig): HolderBuildResult { + const trash: { delete: () => void }[] = [] + const own: Owner = (value) => { + trash.push(value) + return value + } + + try { + const dimensions = paintingHandleDimensions(config) + const tolerance = curveTolerance(Math.max(dimensions.width, dimensions.length), config.segments) + const segmentsFor = (diameter: number, previewMinimum: number) => + Math.max(config.segments <= 256 ? previewMinimum : 3, segmentsForTolerance(diameter, tolerance)) + const assembled = paintingHandleSolid(wasm, config, own, own, segmentsFor) + const bounds = assembled.boundingBox() + const printable = own(assembled.translate([-(bounds.min[0] + bounds.max[0]) / 2, -(bounds.min[1] + bounds.max[1]) / 2, -bounds.min[2]])) + const volume = printable.volume() + const triangles = printable.numTri() + return { mesh: printable.getMesh(), stats: { triangles, volume, grams: volume * PLA_DENSITY, solid: volume > 0 && triangles > 0 } } + } finally { + for (const value of trash) value.delete() + } +} + +export function buildPaintingTray(wasm: ManifoldToplevel, config: PaintingTrayConfig): HolderBuildResult { + const { CrossSection, Manifold } = wasm + const trash: { delete: () => void }[] = [] + const own: Owner = (value) => { + trash.push(value) + return value + } + const section = (value: CrossSection) => own(value) + const solidOf = (value: Manifold) => own(value) + + try { + if (config.height < minimumPaintingTrayHeight(config)) { + throw new Error('Tray thickness leaves too little material below the magnet pockets') + } + if (config.spacing < minimumPaintingTraySpacing(config)) { + throw new Error('Magnet pitch leaves too little material between diagonal pockets') + } + if (config.edgeMargin < minimumPaintingTrayEdgeMargin(config)) { + throw new Error('Edge margin leaves too little material beside the outer pockets') + } + + const layout = paintingTrayLayout(config) + const tolerance = curveTolerance(Math.max(layout.width, layout.length), config.segments) + const segmentsFor = (diameter: number, previewMinimum: number) => + Math.max(config.segments <= 256 ? previewMinimum : 3, segmentsForTolerance(diameter, tolerance)) + + const radius = Math.min(CORNER_RADIUS, layout.width / 2 - 0.01, layout.length / 2 - 0.01) + const outline = roundedRectangle(CrossSection, layout.width, layout.length, radius, segmentsFor(radius * 2, 32), own) + const plate = solidOf(outline.extrude(config.height)) + const centerMarkSpan = paintingTrayCenterMarkSpan(config) + const centerMarkHorizontal = section(CrossSection.square([centerMarkSpan, CENTER_MARK_WIDTH], true)) + const centerMarkVertical = section(CrossSection.square([CENTER_MARK_WIDTH, centerMarkSpan], true)) + const centerMark = section(CrossSection.union([centerMarkHorizontal, centerMarkVertical])) + const centerMarkDrill = solidOf(centerMark.extrude(CENTER_MARK_DEPTH + 0.001)) + const centerMarkCutter = solidOf(centerMarkDrill.translate([0, 0, -0.001])) + const markedPlate = solidOf(Manifold.difference([plate, centerMarkCutter])) + const pocketRadius = (config.magnets.diameter + config.magnets.clearance) / 2 + const pocket = section(CrossSection.circle(pocketRadius, segmentsFor(pocketRadius * 2, 32))) + const pocketOutlines = layout.magnetCenters.map((center) => section(pocket.translate([center.x, center.y]))) + const pockets = section(CrossSection.union(pocketOutlines)) + const pocketDepth = config.magnets.thickness + config.magnets.depthClearance + const drill = solidOf(pockets.extrude(pocketDepth + 0.001)) + const cutter = solidOf(drill.translate([0, 0, config.height - pocketDepth])) + const tray = solidOf(Manifold.difference([markedPlate, cutter])) + let solid = tray + if (config.assembly !== false) { + const handle = paintingHandleSolid(wasm, paintingHandleConfig(config), own, own, segmentsFor) + const attachedHandle = solidOf(handle.translate([0, 0, HANDLE_PREVIEW_OVERLAP])) + solid = solidOf(Manifold.union([tray, attachedHandle])) + } + const volume = solid.volume() + const triangles = solid.numTri() + return { mesh: solid.getMesh(), stats: { triangles, volume, grams: volume * PLA_DENSITY, solid: volume > 0 && triangles > 0 } } + } finally { + for (const value of trash) value.delete() + } +} diff --git a/src/geometry/types.ts b/src/geometry/types.ts index 094b3a9..9a419de 100644 --- a/src/geometry/types.ts +++ b/src/geometry/types.ts @@ -122,6 +122,39 @@ export interface HolderGroup { sides: number } +export type PaintingHandleShape = 'round' | 'oval' | 'flared' | 'pistol' + +export interface PaintingHandleSettings { + shape: PaintingHandleShape + length: number + width: number + angle: number + roundedEnd: boolean + ribs: boolean +} + +export interface PaintingTrayConfig { + kind: 'painting-tray' + columns: number + rows: number + /** Centre-to-centre distance between magnet pockets. */ + spacing: number + /** Distance from an outer magnet centre to the tray edge. */ + edgeMargin: number + height: number + magnets: HolderConfig['magnets'] + handle: PaintingHandleSettings + segments: number + /** Export-only: omit the detachable handle from the assembled preview mesh. */ + assembly?: boolean +} + +export interface PaintingHandleConfig { + kind: 'painting-handle' + handle: PaintingHandleSettings + segments: number +} + export interface FlightStemConfig { kind: 'stem' /** Height of the tapered body, excluding the miniature connection. */ @@ -138,4 +171,5 @@ export interface BasePartConfig extends BaseConfig { kind?: 'base' } -export type PartConfig = BasePartConfig | HolderConfig | FlightStemConfig +export type PartConfig = BasePartConfig | HolderConfig | PaintingTrayConfig | FlightStemConfig +export type BuildConfig = PartConfig | PaintingHandleConfig diff --git a/src/lib/buildMesh.ts b/src/lib/buildMesh.ts index fbfa0d7..c361f46 100644 --- a/src/lib/buildMesh.ts +++ b/src/lib/buildMesh.ts @@ -1,8 +1,8 @@ -import type { PartConfig } from '@/geometry/types' +import type { BuildConfig } from '@/geometry/types' import type { MeshData, WorkerReply, WorkerRequest } from '@/worker/protocol' /** Builds one mesh in an isolated worker, used by exports without replacing the preview. */ -export function buildMesh(config: PartConfig): Promise { +export function buildMesh(config: BuildConfig): Promise { const worker = new Worker(new URL('../worker/geometry.worker.ts', import.meta.url), { type: 'module' }) const id = 1 diff --git a/src/lib/useExport.ts b/src/lib/useExport.ts index 367325f..7b26648 100644 --- a/src/lib/useExport.ts +++ b/src/lib/useExport.ts @@ -3,9 +3,10 @@ import { zipSync } from 'fflate' import { to3mf, toStl } from '@/geometry/exporters' import { holderName, holderPlan } from '@/geometry/holder' import { baseName } from '@/geometry/outline' +import { paintingHandleConfig, paintingHandleDimensions, paintingHandleName, paintingTrayName } from '@/geometry/paintingTray' import { exportSegmentsFor } from '@/geometry/quality' import { stemName, stemOverallHeight } from '@/geometry/stem' -import type { BaseConfig, FlightStemConfig, HolderConfig, PartConfig } from '@/geometry/types' +import type { BaseConfig, FlightStemConfig, HolderConfig, PaintingTrayConfig, PartConfig } from '@/geometry/types' import posthog from '@/lib/posthog' import { buildMesh } from './buildMesh' import { asMeshLike, download } from './download' @@ -13,19 +14,27 @@ import { asMeshLike, download } from './download' type ExportFormat = 'stl' | '3mf' interface ExportOptions { - model: 'base' | 'holder' | 'stem' + model: 'base' | 'holder' | 'painting' | 'stem' base: BaseConfig holder: HolderConfig + paintingTray: PaintingTrayConfig stem: FlightStemConfig width: number length: number } -export function useExport({ model, base, holder, stem, width, length }: ExportOptions) { +export function useExport({ model, base, holder, paintingTray, stem, width, length }: ExportOptions) { const [exporting, setExporting] = useState() const [error, setError] = useState() - const config: PartConfig = model === 'base' ? base : model === 'holder' ? holder : stem - const name = model === 'base' ? baseName(base) : model === 'holder' ? holderName(holder) : stemName(stem) + const config: PartConfig = model === 'base' ? base : model === 'holder' ? holder : model === 'painting' ? paintingTray : stem + const name = + model === 'base' + ? baseName(base) + : model === 'holder' + ? holderName(holder) + : model === 'painting' + ? paintingTrayName(paintingTray) + : stemName(stem) const run = async (format: ExportFormat, operation: () => Promise): Promise => { setExporting(format) @@ -55,9 +64,27 @@ export function useExport({ model, base, holder, stem, width, length }: ExportOp buildMesh({ ...module.config, segments: exportSegmentsFor(Math.max(module.layout.width, module.layout.length)) }), ), ) + const buildPaintingParts = async () => { + const handleConfig = paintingHandleConfig(paintingTray) + const handleSize = paintingHandleDimensions(handleConfig) + const [trayMesh, handleMesh] = await Promise.all([ + buildMesh({ ...paintingTray, assembly: false, segments: exportSegmentsFor(Math.max(width, length)) }), + buildMesh({ ...handleConfig, segments: exportSegmentsFor(Math.max(handleSize.width, handleSize.length)) }), + ]) + return [ + { mesh: asMeshLike(trayMesh), name: paintingTrayName(paintingTray) }, + { mesh: asMeshLike(handleMesh), name: paintingHandleName(paintingTray) }, + ] + } const exportStl = () => run('stl', async () => { + if (model === 'painting') { + const parts = await buildPaintingParts() + const files = Object.fromEntries(parts.map((part) => [`${part.name}.stl`, toStl(part.mesh, part.name)])) + download(`${name}.zip`, zipSync(files)) + return + } if (plan && plan.modules.length > 1) { const meshes = await buildModules() const files = Object.fromEntries( @@ -76,6 +103,10 @@ export function useExport({ model, base, holder, stem, width, length }: ExportOp const export3mf = () => run('3mf', async () => { + if (model === 'painting') { + download(`${name}.3mf`, to3mf(await buildPaintingParts(), true)) + return + } if (plan && plan.modules.length > 1) { const meshes = await buildModules() const modules = meshes.map((mesh, index) => ({ diff --git a/src/lib/workspace.test.ts b/src/lib/workspace.test.ts index 1129309..a3729d5 100644 --- a/src/lib/workspace.test.ts +++ b/src/lib/workspace.test.ts @@ -12,10 +12,26 @@ function memoryStorage() { } describe('workspace state', () => { - it('starts both generators at their defaults', () => { + it('starts every generator at its defaults', () => { expect(defaultWorkspace()).toMatchObject({ base: { width: 32, magnets: { patternVersion: 2 } }, holder: { kind: 'holder', groups: [{ width: 32 }], magnets: { patternVersion: 2 } }, + paintingTray: { + kind: 'painting-tray', + columns: 4, + rows: 4, + spacing: 50, + edgeMargin: 12, + magnets: { patternVersion: 2 }, + handle: { + shape: 'round', + length: 100, + width: 28, + angle: 0, + roundedEnd: false, + ribs: false, + }, + }, stem: { kind: 'stem', bodyHeight: 15, bodyDiameter: 4.8, connection: 'peg', modelPegDiameter: 1.8, ballDiameter: 4 }, }) }) @@ -44,6 +60,7 @@ describe('workspace state', () => { }) expect(synchronized.base.magnets).toMatchObject(synchronized.shared.magnets) expect(synchronized.holder.magnets).toMatchObject(synchronized.shared.magnets) + expect(synchronized.paintingTray.magnets).toMatchObject(synchronized.shared.magnets) expect({ base: synchronized.base.magnets.count, ribs: synchronized.base.ribs.count, holder: synchronized.holder.magnetCounts }).toEqual( { base: 5, @@ -59,6 +76,18 @@ describe('workspace state', () => { }).toEqual({ baseWall: 2.5, baseBoss: 1.1, holderWall: 2.5, holderBoss: 1.1 }) }) + it('grows the painting tray around larger shared magnet pockets', () => { + const state = defaultWorkspace() + state.paintingTray.spacing = 5 + state.paintingTray.edgeMargin = 1 + state.shared.magnets.diameter = 8 + state.shared.magnets.clearance = 0.6 + state.shared.magnets.thickness = 3 + state.shared.magnets.depthClearance = 0.2 + + expect(synchronizeWorkspace(state).paintingTray).toMatchObject({ height: 4.1, spacing: 13.3, edgeMargin: 5.1 }) + }) + it('limits new five-pocket crosses to round bases at least 50mm wide', () => { const state = defaultWorkspace() state.shared.magnets.layout = 'five-cross' @@ -131,6 +160,23 @@ describe('workspace state', () => { expect(loadWorkspace(storage).stem).toMatchObject({ bodyHeight: 20, connection: 'peg', ballDiameter: 4 }) }) + it('adds the painting-tray generator to saved workspaces', () => { + const storage = memoryStorage() + const legacy = JSON.parse(JSON.stringify(defaultWorkspace())) + delete legacy.paintingTray + storage.setItem('mini-bases.workspace', JSON.stringify({ version: 6, workspace: legacy })) + + expect(loadWorkspace(storage).paintingTray).toMatchObject({ + kind: 'painting-tray', + columns: 4, + rows: 4, + spacing: 50, + edgeMargin: 12, + height: 3, + handle: { shape: 'round', length: 100 }, + }) + }) + it('preserves saved count and layout behavior as the legacy pocket pattern', () => { const storage = memoryStorage() const workspace = defaultWorkspace() diff --git a/src/lib/workspace.ts b/src/lib/workspace.ts index 44e0019..f5679d8 100644 --- a/src/lib/workspace.ts +++ b/src/lib/workspace.ts @@ -1,11 +1,17 @@ import { defaultHolderConfig } from '../geometry/holder' +import { + defaultPaintingTrayConfig, + minimumPaintingTrayEdgeMargin, + minimumPaintingTrayHeight, + minimumPaintingTraySpacing, +} from '../geometry/paintingTray' import { supportsFivePocketCross } from '../geometry/base' import { automaticMagnetCount, DEFAULT_PRESET, footprintKey, presetFor, ribCountFor } from '../geometry/presets' import { defaultFlightStemConfig } from '../geometry/stem' -import type { BaseConfig, FlightStemConfig, HolderConfig } from '../geometry/types' +import type { BaseConfig, FlightStemConfig, HolderConfig, PaintingTrayConfig } from '../geometry/types' const WORKSPACE_KEY = 'mini-bases.workspace' -const WORKSPACE_VERSION = 6 +const WORKSPACE_VERSION = 7 interface SettingsStorage { getItem(key: string): string | null @@ -15,8 +21,9 @@ interface SettingsStorage { export interface WorkspaceState { base: BaseConfig holder: HolderConfig + paintingTray: PaintingTrayConfig stem: FlightStemConfig - /** Values exposed by both generators have one canonical owner. */ + /** Values exposed by multiple generators have one canonical owner. */ shared: SharedSettings } @@ -104,12 +111,28 @@ export function synchronizeWorkspace(state: WorkspaceState): WorkspaceState { engraving: { ...state.holder.engraving, enabled: shared.labelsEnabled }, magnets: { ...state.holder.magnets, ...shared.magnets }, }, + paintingTray: { + ...state.paintingTray, + height: Math.max(state.paintingTray.height, Math.ceil((minimumPaintingTrayHeight({ magnets: shared.magnets }) - 1e-6) * 10) / 10), + spacing: Math.max(state.paintingTray.spacing, Math.ceil((minimumPaintingTraySpacing({ magnets: shared.magnets }) - 1e-6) * 10) / 10), + edgeMargin: Math.max( + state.paintingTray.edgeMargin, + Math.ceil((minimumPaintingTrayEdgeMargin({ magnets: shared.magnets }) - 1e-6) * 10) / 10, + ), + magnets: { ...state.paintingTray.magnets, ...shared.magnets, enabled: true }, + }, } } export function defaultWorkspace(): WorkspaceState { const base = presetFor(DEFAULT_PRESET) - return synchronizeWorkspace({ base, holder: defaultHolderConfig(), stem: defaultFlightStemConfig(), shared: sharedFromBase(base) }) + return synchronizeWorkspace({ + base, + holder: defaultHolderConfig(), + paintingTray: defaultPaintingTrayConfig(), + stem: defaultFlightStemConfig(), + shared: sharedFromBase(base), + }) } export function loadWorkspace(storage: SettingsStorage): WorkspaceState { @@ -119,23 +142,28 @@ export function loadWorkspace(storage: SettingsStorage): WorkspaceState { const parsed = JSON.parse(saved) as { version?: unknown; workspace?: unknown } const workspace = parsed.version === 1 - ? migrateWorkspaceV5(migrateWorkspaceV4(migrateWorkspaceV3(migrateWorkspaceV2(migrateWorkspaceV1(parsed.workspace))))) + ? migrateWorkspaceV6( + migrateWorkspaceV5(migrateWorkspaceV4(migrateWorkspaceV3(migrateWorkspaceV2(migrateWorkspaceV1(parsed.workspace))))), + ) : parsed.version === 2 - ? migrateWorkspaceV5(migrateWorkspaceV4(migrateWorkspaceV3(migrateWorkspaceV2(parsed.workspace)))) + ? migrateWorkspaceV6(migrateWorkspaceV5(migrateWorkspaceV4(migrateWorkspaceV3(migrateWorkspaceV2(parsed.workspace))))) : parsed.version === 3 - ? migrateWorkspaceV5(migrateWorkspaceV4(migrateWorkspaceV3(parsed.workspace))) + ? migrateWorkspaceV6(migrateWorkspaceV5(migrateWorkspaceV4(migrateWorkspaceV3(parsed.workspace)))) : parsed.version === 4 - ? migrateWorkspaceV5(migrateWorkspaceV4(parsed.workspace)) + ? migrateWorkspaceV6(migrateWorkspaceV5(migrateWorkspaceV4(parsed.workspace))) : parsed.version === 5 - ? migrateWorkspaceV5(parsed.workspace) - : parsed.workspace + ? migrateWorkspaceV6(migrateWorkspaceV5(parsed.workspace)) + : parsed.version === 6 + ? migrateWorkspaceV6(parsed.workspace) + : parsed.workspace if ( parsed.version !== WORKSPACE_VERSION && parsed.version !== 1 && parsed.version !== 2 && parsed.version !== 3 && parsed.version !== 4 && - parsed.version !== 5 + parsed.version !== 5 && + parsed.version !== 6 ) return defaultWorkspace() if (!isWorkspaceState(workspace, defaultWorkspace())) return defaultWorkspace() @@ -147,6 +175,11 @@ export function loadWorkspace(storage: SettingsStorage): WorkspaceState { } } +function migrateWorkspaceV6(value: unknown): unknown { + if (typeof value !== 'object' || value === null) return value + return { ...(value as Record), paintingTray: defaultPaintingTrayConfig() } +} + function migrateWorkspaceV5(value: unknown): unknown { if (typeof value !== 'object' || value === null) return value const workspace = value as Record @@ -223,6 +256,8 @@ function isWorkspaceState(value: unknown, template: WorkspaceState): value is Wo ['balanced', 'five-cross'].includes(workspace.shared.magnets.layout) && [1, 2].includes(workspace.shared.magnets.patternVersion) && workspace.stem.kind === 'stem' && + workspace.paintingTray.kind === 'painting-tray' && + ['round', 'oval', 'flared', 'pistol'].includes(workspace.paintingTray.handle.shape) && ['peg', 'ball'].includes(workspace.stem.connection) && workspace.holder.groups.every((group) => ['round', 'oval', 'pill', 'rect', 'polygon'].includes(group.shape)) && Object.values(workspace.shared.magnetCounts).every((count) => typeof count === 'number' && Number.isFinite(count)) diff --git a/src/worker/geometry.worker.ts b/src/worker/geometry.worker.ts index 529f69c..8ef056e 100644 --- a/src/worker/geometry.worker.ts +++ b/src/worker/geometry.worker.ts @@ -4,6 +4,7 @@ import fontUrl from '@/assets/fonts/oswald-700.woff?url' import { buildBase, type BuildResult } from '@/geometry/base' import { buildHolder } from '@/geometry/holder' import { loadManifold } from '@/geometry/manifold' +import { buildPaintingHandle, buildPaintingTray } from '@/geometry/paintingTray' import { buildFlightStem } from '@/geometry/stem' import type { MeshData, WorkerReply, WorkerRequest } from './protocol' @@ -29,9 +30,13 @@ self.onmessage = async (event: MessageEvent) => { const result = config.kind === 'holder' ? buildHolder(wasm, config, font) - : config.kind === 'stem' - ? buildFlightStem(wasm, config) - : buildBase(wasm, config, font) + : config.kind === 'painting-handle' + ? buildPaintingHandle(wasm, config) + : config.kind === 'painting-tray' + ? buildPaintingTray(wasm, config) + : config.kind === 'stem' + ? buildFlightStem(wasm, config) + : buildBase(wasm, config, font) const mesh = toMeshData(result) send({ id, kind: 'mesh', mesh }, [mesh.positions.buffer, mesh.indices.buffer]) } catch (error) { diff --git a/src/worker/protocol.ts b/src/worker/protocol.ts index e9126b1..4c867a6 100644 --- a/src/worker/protocol.ts +++ b/src/worker/protocol.ts @@ -1,4 +1,4 @@ -import type { PartConfig } from '@/geometry/types' +import type { BuildConfig } from '@/geometry/types' export interface MeshData { positions: Float32Array @@ -8,7 +8,7 @@ export interface MeshData { /** One config in, one preview out; there is nothing else to ask the worker for. */ export interface WorkerRequest { id: number - config: PartConfig + config: BuildConfig } export type WorkerReply = { id: number; kind: 'mesh'; mesh: MeshData } | { id: number; kind: 'error'; message: string }