diff --git a/.changeset/calm-trays-mix.md b/.changeset/calm-trays-mix.md new file mode 100644 index 0000000..887e37c --- /dev/null +++ b/.changeset/calm-trays-mix.md @@ -0,0 +1,5 @@ +--- +'basekit': minor +--- + +Add universal magnetic trays and tray-compatible base pockets for mixing miniature sizes on one continuous Gridfinity deck. diff --git a/AGENTS.md b/AGENTS.md index a11437d..60fe487 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ Read [README.md](README.md) first for what the app does and what the controls me - **The edge profile must leave wall beside the recess floor.** The well is cut from the full-size inner outline while the outer loft is inset near the build plate. If that inset reaches the well outline where the floor begins, the cut severs the rim and only the ribs join it back. `maxProfileSize` samples the same profile steps as the loft, the UI caps against it, and `buildBase` rejects callers that bypass the control. - **Every magnet boss sits on a rib.** That is the invariant `base.test.ts` asserts across every preset, and the counts are chosen to hold it: a ring gets one spoke each, and a row gets an even count, because every magnet in a row shares the long axis so one spoke down it passes through all of them. `ribAngles` takes its phase from a boss bearing so the alignment is exact. It used to fall out of both series using the same `π/2 + 2πi/n`, which meant it held only when the counts happened to match, and flipped as the size changed. A rib on a boss gussets its root, prints as one connected feature instead of an island, and collects the clear floor into a few wide gaps, which is what lets the size label stay large. Phase makes almost no difference to floor stiffness — a boss is a 7mm inclusion in a plate tens of millimetres across. - **Automatic magnet counts combine holding demand with stability.** `automaticMagnetCount` starts from the existing 5×2mm balanced recommendation, scales demand by the practical `diameter² × thickness` strength proxy, applies a minimum of three magnets for a ring or two for a row, then rounds up to a supported balanced count. This assumes ordinary neodymium magnets; it is a recommendation, not a pull-force claim. A `magnetCounts` entry is a per-footprint manual override, while an absent entry means automatic. Every automatic count must appear in `MAGNET_CHOICES` and every rib count in `RIB_CHOICES`; tests assert both. +- **Universal trays and tray-compatible bases share one canonical staggered lattice.** The pitch belongs to workspace magnet settings and is projected into both generators. Compatible base pockets must be exact lattice nodes, split tray modules must preserve the assembled tray's global lattice phase, and ribs must still pass through every boss. The sparse default is 30mm; automatic compatible counts step down when a footprint cannot contain the recommended count at that pitch. Exterior tray edges also reserve half the configured smallest-base size, while internal module seams reserve only pocket clearance. Balanced and five-pocket-cross layouts remain valid but do not promise universal-tray alignment. - **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. diff --git a/README.md b/README.md index ba4f334..6fa5ba3 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ 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. +Universal trays provide a continuous alternative when a collection changes often. Choose the Gridfinity footprint and shared magnet-grid pitch, then generate bases with the tray-compatible pocket layout so every base magnet lands on the same staggered lattice. The sparse 30mm default reduces tray magnet count, and a smallest-base setting omits perimeter holes where a miniature could not fit on the deck. Automatic base layouts use only the compatible magnets that physically fit at that spacing. Large trays can export as printer-sized Gridfinity pieces while retaining walls remain only around the assembled perimeter. Existing balanced and five-pocket-cross bases remain supported, but cannot guarantee that more than one magnet aligns with a universal tray. + ## Designed for one job 🎯 - Round, oval, pill, rectangle, and regular polygon bases. diff --git a/e2e/generator.spec.ts b/e2e/generator.spec.ts index 8b35fd1..debf83a 100644 --- a/e2e/generator.spec.ts +++ b/e2e/generator.spec.ts @@ -301,6 +301,37 @@ test('loads the Gridfinity holder directly from its route', { tag: '@ci' }, asyn await expect(footer(page)).toContainText('holder-1x4-5x-round-32mm') }) +test('builds a universal magnetic tray without miniature slots', { tag: '@ci' }, async ({ page }) => { + await page.getByRole('link', { name: 'Holders' }).click() + await settled(page) + const previous = await triangles(page) + await pickChoice(page, 'Holder type', 'Universal grid') + await rebuilt(page, previous) + + await expect(page.getByText('Universal deck', { exact: true })).toBeVisible() + await expect(page.getByText('Miniatures', { exact: true })).not.toBeVisible() + await expect(page.getByLabel('Magnet pitch in mm')).toHaveValue('30') + await expect(page.getByLabel('Smallest base in mm')).toHaveValue('25') + await expect(page.getByRole('switch', { name: 'Tray magnets' })).toBeChecked() + await expect(footer(page)).toContainText('universal-tray-7x5-30mm-grid') + + const unsplit = await triangles(page) + await page.getByRole('switch', { name: 'Split tray into pieces' }).click() + await rebuilt(page, unsplit) + await expect(page.getByLabel('Maximum piece columns')).toHaveValue('3') + await expect(page.getByLabel('Maximum piece rows')).toHaveValue('3') + await expect(page.getByText(/Exports 6 Gridfinity-aligned pieces/)).toBeVisible() + await expect(page.getByRole('button', { name: 'Download STLs' })).toBeVisible() + + await page.getByLabel('Magnet pitch in mm').fill('14') + await page.getByLabel('Magnet pitch in mm').blur() + await expect(footer(page)).toContainText('universal-tray-7x5-14mm-grid') + await page.getByRole('link', { name: 'Bases' }).click() + await settled(page) + await expect(page.getByRole('combobox', { name: 'Pocket layout' })).toContainText('Tray-compatible') + await expect(page.getByLabel('Tray grid pitch in mm')).toHaveValue('14') +}) + test('updates integer holder inputs immediately without losing focus', async ({ page }) => { await page.getByRole('link', { name: 'Holders' }).click() await settled(page) diff --git a/src/App.tsx b/src/App.tsx index b9cf628..8c2dcf3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,7 +9,7 @@ import { ScrollArea } from '@/components/ui/scroll-area' import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet' import { TitleBlock } from '@/components/TitleBlock' import { Viewer } from '@/components/Viewer' -import { supportsFivePocketCross } from '@/geometry/base' +import { supportsFivePocketCross, trayCompatibleMagnetCounts } from '@/geometry/base' import { defaultHolderConfig, holderGroup, @@ -20,6 +20,7 @@ import { maxHolderMagnetThickness, maxHolderSlotDepth, minHolderHeight, + universalMagnetCenters, } from '@/geometry/holder' import { baseName, defaultLabel, footprint, isElongated, trimNumber } from '@/geometry/outline' import { @@ -69,6 +70,7 @@ const PROFILES: { value: EdgeProfile; label: string }[] = [ const MAGNET_LAYOUTS: { value: MagnetLayout; label: string }[] = [ { value: 'balanced', label: 'Balanced' }, + { value: 'lattice', label: 'Tray-compatible' }, { value: 'five-cross', label: 'Five-pocket cross' }, ] const counts = (values: number[]) => values.map((value) => ({ value, label: value === 0 ? 'None' : String(value) })) @@ -83,6 +85,10 @@ const ENGRAVING_PLACEMENTS = [ { value: 'slots' as const, label: 'In slots' }, { value: 'module' as const, label: 'On module' }, ] +const HOLDER_MODES = [ + { value: 'fitted' as const, label: 'Fitted slots' }, + { value: 'universal' as const, label: 'Universal grid' }, +] const BASE_DEFAULTS = presetFor(DEFAULT_PRESET) const HOLDER_DEFAULTS = defaultHolderConfig() @@ -165,6 +171,10 @@ export function App() { : { slotDepth: Math.min(next.slotDepth, Math.floor(maxHolderSlotDepth(next) / 0.5) * 0.5) }), }) const plan = useMemo(() => holderPlan(holder), [holder]) + const universalPocketCount = useMemo( + () => plan.modules.reduce((total, module) => total + universalMagnetCenters(module.config).length, 0), + [plan], + ) const requestedModels = useMemo(() => holder.groups.reduce((total, group) => total + group.quantity, 0), [holder.groups]) const fittedByGroup = useMemo(() => { const fitted = new Map() @@ -196,7 +206,7 @@ export function App() { }) const partWidth = model === 'base' ? width : holderSize.width const partLength = model === 'base' ? length : holderSize.length - const partHeight = model === 'base' ? config.height : holder.height + const partHeight = model === 'base' ? config.height : holder.height + (holder.mode === 'universal' ? holder.universal.rimHeight : 0) const partName = model === 'base' ? baseName(config) : holderName(holder) const { exporting, @@ -220,14 +230,15 @@ export function App() { const magnetCountValue: MagnetCountChoice = magnetCountOverride ?? AUTOMATIC_MAGNET_COUNT const magnetCountOptions: { value: MagnetCountChoice; label: string }[] = [ { value: AUTOMATIC_MAGNET_COUNT, label: `Auto · ${config.magnets.count}` }, - ...counts(MAGNET_CHOICES), + ...counts(config.magnets.layout === 'lattice' ? trayCompatibleMagnetCounts(config) : MAGNET_CHOICES), ] - const magnetLayoutOptions = - config.magnets.patternVersion === 1 || supportsFivePocketCross(config.shape, config.width) ? MAGNET_LAYOUTS : MAGNET_LAYOUTS.slice(0, 1) + const magnetLayoutOptions = MAGNET_LAYOUTS.filter( + ({ value }) => value !== 'five-cross' || config.magnets.patternVersion === 1 || supportsFivePocketCross(config.shape, config.width), + ) const holderSupportsFiveCross = holder.magnets.patternVersion === 1 || holder.groups.some((group) => supportsFivePocketCross(group.shape, group.width)) - const holderMagnetLayout = holderSupportsFiveCross ? holder.magnets.layout : 'balanced' - const holderMagnetLayoutOptions = holderSupportsFiveCross ? MAGNET_LAYOUTS : MAGNET_LAYOUTS.slice(0, 1) + const holderMagnetLayout = holderSupportsFiveCross || holder.magnets.layout !== 'five-cross' ? holder.magnets.layout : 'balanced' + const holderMagnetLayoutOptions = MAGNET_LAYOUTS.filter(({ value }) => value !== 'five-cross' || holderSupportsFiveCross) const loadPreset = (size: SizePreset) => { posthog.capture('base_size_selected', { size: size.label, shape: config.shape }) @@ -237,7 +248,7 @@ export function App() { } const setSharedMagnets = ( - changes: Partial>, + changes: Partial>, ) => { setWorkspace((current) => ({ ...current, @@ -380,6 +391,17 @@ export function App() { options={magnetLayoutOptions} onChange={(layout) => setSharedMagnets({ layout })} /> + {config.magnets.layout === 'lattice' && ( + setSharedMagnets({ latticePitch })} + /> + )} {config.magnets.layout !== 'five-cross' && ( {config.magnets.layout === 'five-cross' ? 'The cross always provides one centre and four outer pockets.' - : 'Automatic balances the footprint using the selected magnet dimensions.'} + : config.magnets.layout === 'lattice' + ? 'Every pocket lands on the same staggered lattice as a universal tray.' + : 'Automatic balances the footprint using the selected magnet dimensions.'} @@ -606,7 +630,30 @@ export function App() { const holderPanel = (