From 7370713ab66b2e3e1e85596e02caf1c5d55b82eb Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Mon, 31 Aug 2026 15:52:35 +0300 Subject: [PATCH] feat(holder): add configurable holder-edge spacing - Default edge spacing to half the miniature spacing - Migrate saved workspaces to the new setting --- .changeset/calm-holders-space.md | 5 ++++ README.md | 2 +- e2e/generator.spec.ts | 19 ++++++++++++++- src/App.tsx | 17 ++++++++++++- src/geometry/holder.test.ts | 26 ++++++++++++++++++++ src/geometry/holder.ts | 42 +++++++++++++++++++++----------- src/geometry/types.ts | 4 ++- src/lib/workspace.test.ts | 10 ++++++++ src/lib/workspace.ts | 21 ++++++++++++---- 9 files changed, 123 insertions(+), 23 deletions(-) create mode 100644 .changeset/calm-holders-space.md diff --git a/.changeset/calm-holders-space.md b/.changeset/calm-holders-space.md new file mode 100644 index 0000000..fb8cbce --- /dev/null +++ b/.changeset/calm-holders-space.md @@ -0,0 +1,5 @@ +--- +'basekit': minor +--- + +Add configurable holder-edge spacing with a default of half the miniature spacing. diff --git a/README.md b/README.md index ba4f334..f60a511 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Built-in presets cover common Games Workshop, The Old World, Kings of War, and h ## Gridfinity holders 📦 -Add miniature groups, choose the available rows and columns, and BaseKit packs matching slots into printable Gridfinity modules. Groups can use standard or custom round, oval, pill, rectangle, and hex footprints. +Add miniature groups, choose the available rows and columns, and BaseKit packs matching slots into printable Gridfinity modules. Groups can use standard or custom round, oval, pill, rectangle, and hex footprints. Miniature and holder-edge spacing are independently adjustable, with the edge spacing defaulting to half the miniature spacing. 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. diff --git a/e2e/generator.spec.ts b/e2e/generator.spec.ts index 193be7c..56e5cb7 100644 --- a/e2e/generator.spec.ts +++ b/e2e/generator.spec.ts @@ -240,6 +240,23 @@ test('aligns toggle and dimension reset columns', async ({ page }) => { expect(toggle?.x).toBe(dimension?.x) }) +test('defaults holder-edge spacing to half the miniature spacing until customized', async ({ page }) => { + await page.getByRole('link', { name: 'Holders' }).click() + const between = page.getByLabel('Between miniatures in mm') + const edge = page.getByLabel('From holder edge in mm') + await expect(edge).toHaveValue('0.25') + + await between.fill('2') + await expect(edge).toHaveValue('1.00') + await edge.fill('2') + await between.fill('3') + await expect(edge).toHaveValue('2.00') + await page.getByRole('button', { name: /Reset From holder edge/ }).click() + await expect(edge).toHaveValue('1.50') + await between.fill('4') + await expect(edge).toHaveValue('2.00') +}) + test('keeps a long dimension label on one line when its reset appears', async ({ page }) => { const label = page.getByText('Magnet diameter clearance', { exact: true }) const before = await label.boundingBox() @@ -302,7 +319,7 @@ test('frames every slot in a tall holder', async ({ page }) => { await page.getByRole('combobox', { name: 'Standard base size 1' }).click() await page.getByRole('option', { name: /^50\b/ }).click() await expect(page.getByText('4/4 fitted')).toBeVisible() - await expect(across(page)).toHaveText('83.5 × 167.5') + await expect(across(page)).toHaveText('83.5 × 209.5') await expect(across(page)).toBeInViewport() }) diff --git a/src/App.tsx b/src/App.tsx index b9cf628..39c0af5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -822,7 +822,22 @@ export function App() { max={10} step={0.5} defaultValue={HOLDER_DEFAULTS.spacing} - onChange={(spacing) => setHolder({ ...holder, spacing })} + onChange={(spacing) => + setHolder({ + ...holder, + spacing, + edgeSpacing: holder.edgeSpacing === holder.spacing / 2 ? spacing / 2 : holder.edgeSpacing, + }) + } + /> + setHolder({ ...holder, edgeSpacing })} /> { expect(second).toMatchObject({ magnets: { depthClearance: 0.1 }, magnetCounts: {} }) }) + it('defaults holder-edge spacing to half the miniature spacing', () => { + const config = defaultHolderConfig() + expect(config.edgeSpacing).toBe(config.spacing / 2) + }) + it('uses a narrow 1×4 holder for five 32mm models', () => { const layout = holderLayout(defaultHolderConfig()) expect(layout).toMatchObject({ unitsWide: 1, unitsDeep: 4 }) @@ -239,6 +244,27 @@ describe('holderLayout', () => { } }) + it('keeps every slot recess away from the holder edge', () => { + const config = { + ...defaultHolderConfig(), + edgeSpacing: 3, + groups: [holderGroup('models-1', 5, { width: 32 }), holderGroup('models-2', 2, { width: 40 })], + } + const layout = holderLayout(config) + for (const point of layout.slotCenters) { + expect(Math.abs(point.x)).toBeLessThanOrEqual(layout.width / 2 - (point.width + config.slotClearance) / 2 - config.edgeSpacing + 1e-5) + expect(Math.abs(point.y)).toBeLessThanOrEqual( + layout.length / 2 - (point.length + config.slotClearance) / 2 - config.edgeSpacing + 1e-5, + ) + } + }) + + it('uses another Gridfinity column when the requested edge spacing needs it', () => { + const group = holderGroup('models-1', 1, { width: 40 }) + expect(holderLayout({ ...defaultHolderConfig(), groups: [group], edgeSpacing: 0.25 }).unitsWide).toBe(1) + expect(holderLayout({ ...defaultHolderConfig(), groups: [group], edgeSpacing: 0.75 }).unitsWide).toBe(2) + }) + it('reports no layout when the box constraints are too small', () => { const config = { ...defaultHolderConfig(), groups: [holderGroup('models-1', 2, { width: 90 })], maxColumns: 3, maxRows: 2 } expect(holderLayout(config).slotCenters).toHaveLength(0) diff --git a/src/geometry/holder.ts b/src/geometry/holder.ts index 7a4c334..52c39a6 100644 --- a/src/geometry/holder.ts +++ b/src/geometry/holder.ts @@ -173,10 +173,11 @@ function maxPossibleGroupQuantity( maxColumns: number, maxRows: number, spacing: number, + edgeSpacing: number, clearance: number, ) { - const width = maxColumns * GRID - GAP - const length = maxRows * GRID - GAP + const width = maxColumns * GRID - GAP - edgeSpacing * 2 + const length = maxRows * GRID - GAP - edgeSpacing * 2 const itemWidth = slotWidth(group) + clearance const itemLength = slotLength(group) + clearance if (itemWidth > width || itemLength > length) return 0 @@ -424,7 +425,9 @@ function boxPacking(items: HolderSlot[], width: number, length: number, spacing: const layoutCache = new Map() -function singleHolderLayout(config: Pick): HolderLayout { +function singleHolderLayout( + config: Pick, +): HolderLayout { const maxColumns = Math.max(1, Math.round(config.maxColumns)) const maxRows = Math.max(1, Math.round(config.maxRows)) const groups = config.groups @@ -433,7 +436,7 @@ function singleHolderLayout(config: Pick ({ ...group, id: `${group.id}-${index}`, x: 0, y: 0 })), ) .sort((a, b) => Math.max(slotWidth(b), slotLength(b)) - Math.max(slotWidth(a), slotLength(a))) - const key = `${maxColumns}:${maxRows}:${config.spacing}:${config.slotClearance}:${groups + const key = `${maxColumns}:${maxRows}:${config.spacing}:${config.edgeSpacing}:${config.slotClearance}:${groups .map((group) => `${group.quantity}x${group.shape}-${group.width}x${slotLength(group)}-${group.cornerRadius}-${group.sides}`) .join(',')}` const cached = layoutCache.get(key) if (cached) return cached const largestWidth = Math.max(0, ...slots.map(slotWidth)) - const minimumColumns = Math.max(1, Math.ceil((largestWidth + GAP) / GRID)) + const minimumColumns = Math.max(1, Math.ceil((largestWidth + config.slotClearance + config.edgeSpacing * 2 + GAP) / GRID)) let layout: HolderLayout | undefined for (let unitsWide = minimumColumns; unitsWide <= maxColumns && !layout; unitsWide++) { const width = unitsWide * GRID - GAP + const packingWidth = width - config.edgeSpacing * 2 for (let unitsDeep = 1; unitsDeep <= maxRows; unitsDeep++) { const length = unitsDeep * GRID - GAP - if (slots.some((slot) => slotWidth(slot) > width || slotLength(slot) > length)) continue + const packingLength = length - config.edgeSpacing * 2 + if ( + slots.some( + (slot) => slotWidth(slot) + config.slotClearance > packingWidth || slotLength(slot) + config.slotClearance > packingLength, + ) + ) + continue const circleSlots = slots.every( (slot) => (slot.shape === 'round' || slot.shape === 'polygon') && slotWidth(slot) === slotLength(slot), ) const packed = circleSlots ? relaxedPacking( slots.map((slot) => slotWidth(slot) + config.slotClearance), - width, - length, + packingWidth, + packingLength, config.spacing, )?.map((point, index) => ({ ...slots[index], @@ -474,12 +484,12 @@ function singleHolderLayout(config: Pick ({ ...slot, width: slot.width + config.slotClearance, length: slot.length + config.slotClearance })), - width, - length, + packingWidth, + packingLength, config.spacing, ) if (packed) { - const distributedSlots = distributed(packed, width, length) + const distributedSlots = distributed(packed, packingWidth, packingLength) layout = { unitsWide, unitsDeep, @@ -515,7 +525,7 @@ export function holderPlan(config: HolderConfig): HolderPlan { ...group, quantity: Math.min( Math.max(0, Math.round(group.quantity)), - maxPossibleGroupQuantity(group, columns, rows, config.spacing, config.slotClearance), + maxPossibleGroupQuantity(group, columns, rows, config.spacing, config.edgeSpacing, config.slotClearance), ), })) let layout: HolderLayout | undefined @@ -565,7 +575,10 @@ export function holderPlan(config: HolderConfig): HolderPlan { for (const group of config.groups) { const requested = Math.max(0, Math.round(group.quantity)) - let remaining = Math.min(requested, maxPossibleGroupQuantity(group, columns, rows, config.spacing, config.slotClearance)) + let remaining = Math.min( + requested, + maxPossibleGroupQuantity(group, columns, rows, config.spacing, config.edgeSpacing, config.slotClearance), + ) addOmitted(omitted, group, requested - remaining) while (true) { if (remaining <= 0) break @@ -636,6 +649,7 @@ export function defaultHolderConfig(): HolderConfig { splitGroups: true, engraving: { enabled: true, placement: 'slots' }, spacing: 0.5, + edgeSpacing: 0.25, slotClearance: 0.5, slotDepth: 3, height: 14, diff --git a/src/geometry/types.ts b/src/geometry/types.ts index e742a2a..74f083f 100644 --- a/src/geometry/types.ts +++ b/src/geometry/types.ts @@ -88,8 +88,10 @@ export interface HolderConfig { enabled: boolean placement: 'slots' | 'module' } - /** Edge-to-edge distance between nominal miniature bases. */ + /** Edge-to-edge distance between slot recesses. */ spacing: number + /** Minimum distance from a slot recess to the holder edge. */ + edgeSpacing: number /** Added to the diameter so bases lift out without binding. */ slotClearance: number slotDepth: number diff --git a/src/lib/workspace.test.ts b/src/lib/workspace.test.ts index b457eb5..4ed6c76 100644 --- a/src/lib/workspace.test.ts +++ b/src/lib/workspace.test.ts @@ -100,6 +100,16 @@ describe('workspace state', () => { expect(loadWorkspace(storage).base).not.toHaveProperty('underside') }) + it('adds half the miniature spacing at holder edges to saved workspaces', () => { + const storage = memoryStorage() + const legacy = JSON.parse(JSON.stringify(defaultWorkspace())) + legacy.holder.spacing = 3 + delete legacy.holder.edgeSpacing + storage.setItem('mini-bases.workspace', JSON.stringify({ version: 3, workspace: legacy })) + + expect(loadWorkspace(storage).holder.edgeSpacing).toBe(1.5) + }) + 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 046d217..8238a0b 100644 --- a/src/lib/workspace.ts +++ b/src/lib/workspace.ts @@ -4,7 +4,7 @@ import { automaticMagnetCount, DEFAULT_PRESET, footprintKey, presetFor, ribCount import type { BaseConfig, HolderConfig } from '../geometry/types' const WORKSPACE_KEY = 'mini-bases.workspace' -const WORKSPACE_VERSION = 3 +const WORKSPACE_VERSION = 4 interface SettingsStorage { getItem(key: string): string | null @@ -117,11 +117,14 @@ export function loadWorkspace(storage: SettingsStorage): WorkspaceState { const parsed = JSON.parse(saved) as { version?: unknown; workspace?: unknown } const workspace = parsed.version === 1 - ? migrateWorkspaceV2(migrateWorkspaceV1(parsed.workspace)) + ? migrateWorkspaceV3(migrateWorkspaceV2(migrateWorkspaceV1(parsed.workspace))) : parsed.version === 2 - ? migrateWorkspaceV2(parsed.workspace) - : parsed.workspace - if (parsed.version !== WORKSPACE_VERSION && parsed.version !== 1 && parsed.version !== 2) return defaultWorkspace() + ? migrateWorkspaceV3(migrateWorkspaceV2(parsed.workspace)) + : parsed.version === 3 + ? migrateWorkspaceV3(parsed.workspace) + : parsed.workspace + if (parsed.version !== WORKSPACE_VERSION && parsed.version !== 1 && parsed.version !== 2 && parsed.version !== 3) + return defaultWorkspace() if (!isWorkspaceState(workspace, defaultWorkspace())) return defaultWorkspace() const base = { ...workspace.base } as BaseConfig & { underside?: unknown } delete base.underside @@ -131,6 +134,14 @@ export function loadWorkspace(storage: SettingsStorage): WorkspaceState { } } +function migrateWorkspaceV3(value: unknown): unknown { + if (typeof value !== 'object' || value === null) return value + const workspace = value as Record + const holder = workspace.holder as Record | undefined + if (!holder || typeof holder.spacing !== 'number' || !Number.isFinite(holder.spacing)) return value + return { ...workspace, holder: { ...holder, edgeSpacing: holder.spacing / 2 } } +} + function migrateWorkspaceV2(value: unknown): unknown { if (typeof value !== 'object' || value === null) return value const workspace = value as Record