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/calm-holders-space.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'basekit': minor
---

Add configurable holder-edge spacing with a default of half the miniature spacing.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
19 changes: 18 additions & 1 deletion e2e/generator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
})

Expand Down
17 changes: 16 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}
/>
<Dimension
label="From holder edge"
value={holder.edgeSpacing}
min={0}
max={10}
step={0.05}
defaultValue={holder.spacing / 2}
onChange={(edgeSpacing) => setHolder({ ...holder, edgeSpacing })}
/>
<ToggleSetting
label="Split into modules"
Expand Down
26 changes: 26 additions & 0 deletions src/geometry/holder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ describe('holderLayout', () => {
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 })
Expand Down Expand Up @@ -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)
Expand Down
42 changes: 28 additions & 14 deletions src/geometry/holder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -424,7 +425,9 @@ function boxPacking(items: HolderSlot[], width: number, length: number, spacing:

const layoutCache = new Map<string, HolderLayout>()

function singleHolderLayout(config: Pick<HolderConfig, 'groups' | 'maxColumns' | 'maxRows' | 'spacing' | 'slotClearance'>): HolderLayout {
function singleHolderLayout(
config: Pick<HolderConfig, 'groups' | 'maxColumns' | 'maxRows' | 'spacing' | 'edgeSpacing' | 'slotClearance'>,
): HolderLayout {
const maxColumns = Math.max(1, Math.round(config.maxColumns))
const maxRows = Math.max(1, Math.round(config.maxRows))
const groups = config.groups
Expand All @@ -433,7 +436,7 @@ function singleHolderLayout(config: Pick<HolderConfig, 'groups' | 'maxColumns' |
...group,
quantity: Math.min(
Math.round(group.quantity),
maxPossibleGroupQuantity(group, maxColumns, maxRows, config.spacing, config.slotClearance),
maxPossibleGroupQuantity(group, maxColumns, maxRows, config.spacing, config.edgeSpacing, config.slotClearance),
),
length: slotLength(group),
}))
Expand All @@ -443,27 +446,34 @@ function singleHolderLayout(config: Pick<HolderConfig, 'groups' | 'maxColumns' |
Array.from({ length: group.quantity }, (_, index): HolderSlot => ({ ...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],
Expand All @@ -474,12 +484,12 @@ function singleHolderLayout(config: Pick<HolderConfig, 'groups' | 'maxColumns' |
}))
: boxPacking(
slots.map((slot) => ({ ...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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion src/geometry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/lib/workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
21 changes: 16 additions & 5 deletions src/lib/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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<string, unknown>
const holder = workspace.holder as Record<string, unknown> | 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<string, unknown>
Expand Down
Loading