Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-trays-paint.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,14 +27,16 @@ 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.
- Flat shading in the preview is deliberate: Manifold shares vertices across hard edges, so averaged normals round off the wall and flatten the embossed number.

## 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 <name>`; 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.
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
</div>

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.

Expand All @@ -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 🔒

Expand Down
105 changes: 105 additions & 0 deletions e2e/generator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
Expand Down Expand Up @@ -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')
Expand Down
Loading
Loading