From e7a84b64872cd620e43b131f4618b6267c7ab716 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Sat, 15 Aug 2026 14:30:31 +0300 Subject: [PATCH 1/5] Add universal magnetic tray prototype Add a continuous Gridfinity deck with configurable square or staggered magnet grids and an optional retaining rim. Preserve fitted holders and migrate saved workspaces safely. Generated-By: PostHog Desktop Task-Id: 63090379-316f-46ce-ba66-b38d39c59627 --- .changeset/calm-trays-mix.md | 5 + README.md | 2 + e2e/generator.spec.ts | 14 +++ src/App.tsx | 237 ++++++++++++++++++++++++----------- src/components/controls.tsx | 4 +- src/geometry/holder.test.ts | 49 ++++++++ src/geometry/holder.ts | 95 +++++++++++++- src/geometry/types.ts | 9 ++ src/lib/workspace.test.ts | 14 +++ src/lib/workspace.ts | 21 +++- 10 files changed, 363 insertions(+), 87 deletions(-) create mode 100644 .changeset/calm-trays-mix.md diff --git a/.changeset/calm-trays-mix.md b/.changeset/calm-trays-mix.md new file mode 100644 index 0000000..e38ea0b --- /dev/null +++ b/.changeset/calm-trays-mix.md @@ -0,0 +1,5 @@ +--- +'basekit': minor +--- + +Add universal magnetic trays for mixing miniature base sizes on one continuous Gridfinity deck. diff --git a/README.md b/README.md index ba4f334..45b0853 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, magnet-grid pitch and square or staggered layout; every base size then shares the same flat deck instead of occupying a fitted slot. An optional perimeter rim catches models during sudden movement. Discrete tray magnets only hold a base strongly where their fields overlap, so prototype a small tray before committing to a container-sized magnet count. + ## 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..4f4c749 100644 --- a/e2e/generator.spec.ts +++ b/e2e/generator.spec.ts @@ -301,6 +301,20 @@ 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('15') + await expect(page.getByRole('switch', { name: 'Tray magnets' })).toBeChecked() + await expect(footer(page)).toContainText('universal-tray-7x5-15mm-grid') +}) + 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..39373c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import { maxHolderMagnetThickness, maxHolderSlotDepth, minHolderHeight, + universalMagnetCenters, } from '@/geometry/holder' import { baseName, defaultLabel, footprint, isElongated, trimNumber } from '@/geometry/outline' import { @@ -83,6 +84,14 @@ 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 UNIVERSAL_GRID_LAYOUTS = [ + { value: 'staggered' as const, label: 'Staggered' }, + { value: 'square' as const, label: 'Square' }, +] const BASE_DEFAULTS = presetFor(DEFAULT_PRESET) const HOLDER_DEFAULTS = defaultHolderConfig() @@ -196,7 +205,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, @@ -606,7 +615,25 @@ export function App() { const holderPanel = (