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

Add printable, configurable flying stems with flat feet and peg or ball-joint miniature connections.
3 changes: 2 additions & 1 deletion 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 miniatures 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, 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`.

Everything runs in the browser. Models are built locally and nothing is uploaded.

Expand Down Expand Up @@ -38,6 +38,7 @@ Modules export as separate STL files in one archive or separate build plates in

- 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.
- Exact size labels, filenames, dimensions, and high-quality exports.
- Browser-saved settings with shared base and holder preferences.
Expand Down
23 changes: 23 additions & 0 deletions e2e/generator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,29 @@ test('shares the size label preference between bases and holders', async ({ page
await expect(page.getByRole('switch', { name: 'Size labels' })).toBeChecked()
})

test('builds a matching printable flying stem', async ({ page }) => {
const before = await triangles(page)
await page.getByRole('link', { name: 'Stems' }).click()
await rebuilt(page, before)

await expect(across(page)).toHaveText('Ø4.8')
await expect(tall(page)).toHaveText('19')
await expect(footer(page)).toContainText('flying-stem-15mm')
await expect(footer(page)).toContainText('Ø1.8 × 4 mm')
await pickChoice(page, 'Stem height', '20 mm')
await expect(tall(page)).toHaveText('24')
await expect(footer(page)).toContainText('flying-stem-20mm')

const pegTriangles = await triangles(page)
await pickChoice(page, 'Connection', 'Ball joint')
await rebuilt(page, pegTriangles)
await expect(page.getByLabel('Ball diameter in mm')).toHaveValue('4.0')
await expect(tall(page)).toHaveText('23.95')
await expect(footer(page)).toContainText('flying-stem-20mm-ball')
await expect(footer(page)).toContainText('Ball joint')
await expect(footer(page)).toContainText('Ø4 mm')
})

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
147 changes: 127 additions & 20 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,15 @@ import {
type SizePreset,
} from '@/geometry/presets'
import { maxProfileSize } from '@/geometry/profile'
import type { BaseConfig, EdgeProfile, HolderConfig, MagnetLayout, ShapeKind } from '@/geometry/types'
import {
CLASSIC_STEM_HEIGHTS,
defaultFlightStemConfig,
stemMaximumDiameter,
stemName,
stemNeckDiameter,
stemOverallHeight,
} from '@/geometry/stem'
import type { BaseConfig, EdgeProfile, FlightStemConfig, HolderConfig, MagnetLayout, ShapeKind } from '@/geometry/types'
import { useExport } from '@/lib/useExport'
import { useGenerator } from '@/lib/useGenerator'
import { useMediaQuery } from '@/lib/useMediaQuery'
Expand Down Expand Up @@ -78,15 +86,25 @@ const RIB_COUNTS = counts(RIB_CHOICES)
const MODELS = [
{ value: 'base' as const, label: 'Bases', href: '/' },
{ value: 'holder' as const, label: 'Holders', href: '/holders' },
{ value: 'stem' as const, label: 'Stems', href: '/stems' },
]
const ENGRAVING_PLACEMENTS = [
{ value: 'slots' as const, label: 'In slots' },
{ value: 'module' as const, label: 'On module' },
]
const BASE_DEFAULTS = presetFor(DEFAULT_PRESET)
const HOLDER_DEFAULTS = defaultHolderConfig()
const STEM_DEFAULTS = defaultFlightStemConfig()
const STEM_HEIGHTS = CLASSIC_STEM_HEIGHTS.map((value) => ({ value, label: `${value} mm` }))
const STEM_CONNECTIONS = [
{ value: 'peg' as const, label: 'Peg' },
{ value: 'ball' as const, label: 'Ball joint' },
]
type Generator = (typeof MODELS)[number]['value']

const modelForPath = (): 'base' | 'holder' => (window.location.pathname === '/holders' ? 'holder' : 'base')
const modelForPath = (): Generator =>
window.location.pathname === '/holders' ? 'holder' : window.location.pathname === '/stems' ? 'stem' : 'base'
const modelLabel = (model: Generator) => (model === 'base' ? 'Base' : model === 'holder' ? 'Holder' : 'Stem')

function RepositoryLink() {
return (
Expand All @@ -108,21 +126,24 @@ export function App() {
const [workspace, setWorkspaceState] = useState(() => loadWorkspace(window.localStorage))
const config = workspace.base
const holder = workspace.holder
const stem = workspace.stem
const setWorkspace = (next: WorkspaceState | ((current: WorkspaceState) => WorkspaceState)) =>
setWorkspaceState((current) => synchronizeWorkspace(typeof next === 'function' ? next(current) : next))
const setConfig = (next: BaseConfig | ((current: BaseConfig) => BaseConfig)) =>
setWorkspace((current) => ({ ...current, base: typeof next === 'function' ? next(current.base) : next }))
const setHolder = (next: HolderConfig | ((current: HolderConfig) => HolderConfig)) =>
setWorkspace((current) => ({ ...current, holder: typeof next === 'function' ? next(current.holder) : next }))
const setStem = (next: FlightStemConfig | ((current: FlightStemConfig) => FlightStemConfig)) =>
setWorkspace((current) => ({ ...current, stem: typeof next === 'function' ? next(current.stem) : next }))
const [customBaseSize, setCustomBaseSize] = useState(() => {
const { width, length } = footprint(workspace.base)
return !SIZES_BY_SHAPE[workspace.base.shape].some((size) => size.width === width && (size.length ?? size.width) === length)
})
const [customHolderGroups, setCustomHolderGroups] = useState<Set<string>>(() => new Set())
const [model, setModel] = useState<'base' | 'holder'>(modelForPath)
const [model, setModel] = useState<Generator>(modelForPath)
// Tailwind's `md`, the width at which the panel stops needing to slide in.
const docked = useMediaQuery('(min-width: 48rem)')
const partConfig = model === 'base' ? config : holder
const partConfig = model === 'base' ? config : model === 'holder' ? holder : stem
const { preview, error } = useGenerator(partConfig)

useEffect(() => {
Expand All @@ -132,15 +153,15 @@ export function App() {
}, [])

useEffect(() => {
document.title = model === 'holder' ? 'BaseKit — Holders' : 'BaseKit — Bases'
document.title = `BaseKit — ${model === 'base' ? 'Bases' : model === 'holder' ? 'Holders' : 'Flying Stems'}`
}, [model])

useEffect(() => saveWorkspace(window.localStorage, workspace), [workspace])

const changeModel = (next: 'base' | 'holder') => {
const changeModel = (next: Generator) => {
if (next === model) return
posthog.capture('generator_selected', { generator: next })
window.history.pushState(null, '', next === 'holder' ? '/holders' : '/')
window.history.pushState(null, '', next === 'holder' ? '/holders' : next === 'stem' ? '/stems' : '/')
setModel(next)
}

Expand Down Expand Up @@ -194,10 +215,11 @@ export function App() {
next.delete(id)
return next
})
const partWidth = model === 'base' ? width : holderSize.width
const partLength = model === 'base' ? length : holderSize.length
const partHeight = model === 'base' ? config.height : holder.height
const partName = model === 'base' ? baseName(config) : holderName(holder)
const stemDiameter = stemMaximumDiameter(stem)
const partWidth = model === 'base' ? width : model === 'holder' ? holderSize.width : stemDiameter
const partLength = model === 'base' ? length : model === 'holder' ? holderSize.length : stemDiameter
const partHeight = model === 'base' ? config.height : model === 'holder' ? holder.height : stemOverallHeight(stem)
const partName = model === 'base' ? baseName(config) : model === 'holder' ? holderName(holder) : stemName(stem)
const {
exporting,
error: exportError,
Expand All @@ -207,6 +229,7 @@ export function App() {
model,
base: config,
holder,
stem,
width: partWidth,
length: partLength,
})
Expand All @@ -232,8 +255,7 @@ export function App() {
const loadPreset = (size: SizePreset) => {
posthog.capture('base_size_selected', { size: size.label, shape: config.shape })
setCustomBaseSize(false)
const next = presetFor(size, config.magnets.maxCount, config.magnets.patternVersion)
setConfig(next)
setConfig(presetFor(size, config.magnets.maxCount, config.magnets.patternVersion))
}

const setSharedMagnets = (
Expand Down Expand Up @@ -973,7 +995,94 @@ export function App() {
</aside>
</ScrollArea>
)
const panel = model === 'base' ? basePanel : holderPanel

const stemPanel = (
<ScrollArea className="h-full w-81 max-w-[85vw] shrink-0 border-border bg-card md:border-r">
<aside aria-label="Stem settings" className="pb-4 [counter-reset:schedule]">
<Section
title="Stem"
aside={<span className="readout text-xs text-muted-foreground">{trimNumber(stemOverallHeight(stem))}mm overall</span>}
>
<Choice
label="Stem height"
value={stem.bodyHeight}
defaultValue={STEM_DEFAULTS.bodyHeight}
options={STEM_HEIGHTS}
onChange={(bodyHeight) => {
posthog.capture('flight_stem_height_selected', { body_height: bodyHeight })
setStem({ ...stem, bodyHeight })
}}
/>
<Dimension
label="Body diameter"
value={stem.bodyDiameter}
min={stemNeckDiameter(stem)}
max={8}
step={0.1}
defaultValue={STEM_DEFAULTS.bodyDiameter}
onChange={(bodyDiameter) => setStem({ ...stem, bodyDiameter })}
/>
<FieldDescription>Print upright from the flat foot, then glue it directly to the base.</FieldDescription>
</Section>

<Section
title="Miniature Connection"
aside={
<span className="readout text-xs text-muted-foreground">
Ø{trimNumber(stem.connection === 'peg' ? stem.modelPegDiameter : stem.ballDiameter)}
</span>
}
>
<Choice
label="Connection"
value={stem.connection}
defaultValue={STEM_DEFAULTS.connection}
options={STEM_CONNECTIONS}
onChange={(connection) => {
posthog.capture('flight_stem_connection_selected', { connection })
const next = { ...stem, connection }
setStem({ ...next, bodyDiameter: Math.max(next.bodyDiameter, stemNeckDiameter(next)) })
}}
/>
{stem.connection === 'peg' ? (
<>
<Dimension
label="Model peg diameter"
value={stem.modelPegDiameter}
min={1}
max={Math.min(4, stem.bodyDiameter)}
step={0.1}
defaultValue={STEM_DEFAULTS.modelPegDiameter}
onChange={(modelPegDiameter) => setStem({ ...stem, modelPegDiameter })}
/>
<Dimension
label="Model peg length"
value={stem.modelPegLength}
min={1}
max={8}
step={0.1}
defaultValue={STEM_DEFAULTS.modelPegLength}
onChange={(modelPegLength) => setStem({ ...stem, modelPegLength })}
/>
</>
) : (
<Dimension
label="Ball diameter"
value={stem.ballDiameter}
min={2}
max={Math.min(8, stem.bodyDiameter * 2)}
step={0.1}
defaultValue={STEM_DEFAULTS.ballDiameter}
onChange={(ballDiameter) => setStem({ ...stem, ballDiameter })}
/>
)}
</Section>

<RepositoryLink />
</aside>
</ScrollArea>
)
const panel = model === 'base' ? basePanel : model === 'holder' ? holderPanel : stemPanel

return (
<div className="flex h-full flex-col bg-background">
Expand All @@ -983,16 +1092,14 @@ export function App() {
instead of standing beside the sheet. */}
{!docked && (
<Sheet>
<SheetTrigger
render={<Button size="icon-sm" variant="outline" aria-label={`${model === 'base' ? 'Base' : 'Holder'} settings`} />}
>
<SheetTrigger render={<Button size="icon-sm" variant="outline" aria-label={`${modelLabel(model)} settings`} />}>
<PanelLeft />
</SheetTrigger>
<SheetContent side="left" className="max-w-[85vw] gap-0 p-0 data-[side=left]:w-81">
{/* A header row of its own, so the close button has somewhere to sit
that is not on top of the first section heading. */}
<SheetHeader className="shrink-0 border-b border-border px-5 py-3.5">
<SheetTitle className="note">{model === 'base' ? 'Base' : 'Holder'} settings</SheetTitle>
<SheetTitle className="note">{modelLabel(model)} settings</SheetTitle>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col">{panel}</div>
</SheetContent>
Expand Down Expand Up @@ -1045,8 +1152,8 @@ export function App() {
width={partWidth}
length={partLength}
height={partHeight}
round={model === 'base' && !elongated}
fitToPart={model === 'holder'}
round={model === 'stem' || (model === 'base' && !elongated)}
fitToPart={model !== 'base'}
/>
{(error || exportError) && (
<div
Expand Down
13 changes: 13 additions & 0 deletions src/components/TitleBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Badge } from '@/components/ui/badge'
import { defaultLabel, trimNumber } from '@/geometry/outline'
import { holderGroupLabel, holderLayout, holderMagnetPocketCount, holderPlan } from '@/geometry/holder'
import { stemOverallHeight } from '@/geometry/stem'
import type { PartConfig } from '@/geometry/types'

interface Props {
Expand Down Expand Up @@ -28,6 +29,18 @@ function Row({ label, value }: { label: string; value: string }) {
* replaces a status bar rather than adding to one.
*/
export function TitleBlock({ config, status, name }: Props) {
if (config.kind === 'stem') {
return (
<TitleFrame status={status} name={name}>
<Row label="Overall" value={`${trimNumber(stemOverallHeight(config))} mm`} />
{config.connection === 'peg' ? (
<Row label="Model peg" value={`Ø${trimNumber(config.modelPegDiameter)} × ${trimNumber(config.modelPegLength)} mm`} />
) : (
<Row label="Ball joint" value={`Ø${trimNumber(config.ballDiameter)} mm`} />
)}
</TitleFrame>
)
}
if (config.kind === 'holder') {
const layout = holderLayout(config)
const plan = holderPlan(config)
Expand Down
20 changes: 15 additions & 5 deletions src/components/Viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ function framingDistance(aspect: number, footprint = REFERENCE_FOOTPRINT): numbe
}

/** Steep enough to look down into the well, where the size label and supports are. */
const VIEW_DIRECTION = new THREE.Vector3(0.39, -0.54, 0.74)
const VIEW_DIRECTION = new THREE.Vector3(0.39, -0.54, 0.74).normalize()

const CORNERS = [
'top-0 left-0 border-t border-l',
Expand Down Expand Up @@ -74,11 +74,12 @@ export function Viewer({ mesh, width, length, height, round, fitToPart = false }
const shadowLight = useRef<THREE.DirectionalLight>(null)
const shadowsDirty = useRef<THREE.WebGLRenderer>(null)
const cameraRef = useRef<THREE.PerspectiveCamera>(null)
const controlsRef = useRef<OrbitControls>(null)
const held = useRef(false)
const shouldFit = useRef(fitToPart)
const framingFootprint = useRef(REFERENCE_FOOTPRINT)
shouldFit.current = fitToPart
framingFootprint.current = fitToPart ? Math.max(width, length) : REFERENCE_FOOTPRINT
framingFootprint.current = fitToPart ? Math.max(width, length, height) : REFERENCE_FOOTPRINT

useEffect(() => {
const container = host.current
Expand Down Expand Up @@ -116,6 +117,7 @@ export function Viewer({ mesh, width, length, height, round, fitToPart = false }
* carrying on over the top.
*/
const controls = new OrbitControls(camera, renderer.domElement)
controlsRef.current = controls
controls.enableDamping = true
controls.dampingFactor = 0.08
controls.target.set(0, 0, 2)
Expand Down Expand Up @@ -174,7 +176,9 @@ export function Viewer({ mesh, width, length, height, round, fitToPart = false }
renderer.setSize(w, h)
camera.aspect = w / Math.max(h, 1)
camera.updateProjectionMatrix()
if (!held.current) camera.position.setLength(framingDistance(camera.aspect, framingFootprint.current))
if (!held.current) {
camera.position.copy(controls.target).addScaledVector(VIEW_DIRECTION, framingDistance(camera.aspect, framingFootprint.current))
}
}
resize()
const observer = new ResizeObserver(resize)
Expand Down Expand Up @@ -283,7 +287,7 @@ export function Viewer({ mesh, width, length, height, round, fitToPart = false }
// density as a 180mm one — the label is smallest exactly where the base is.
const light = shadowLight.current
if (light) {
const reach = Math.max(width, length) * 0.75
const reach = Math.max(width, length, height) * 0.75
Object.assign(light.shadow.camera, { left: -reach, right: reach, top: reach, bottom: -reach })
light.shadow.camera.updateProjectionMatrix()
}
Expand All @@ -299,7 +303,13 @@ export function Viewer({ mesh, width, length, height, round, fitToPart = false }
group.userData = { halfWidth: width / 2, halfLength: length / 2, height }

const camera = cameraRef.current
if (shouldFit.current && camera && !held.current) camera.position.setLength(framingDistance(camera.aspect, Math.max(width, length)))
const controls = controlsRef.current
if (camera && controls && !held.current) {
controls.target.set(0, 0, height / 2)
const footprint = shouldFit.current ? Math.max(width, length, height) : REFERENCE_FOOTPRINT
camera.position.copy(controls.target).addScaledVector(VIEW_DIRECTION, framingDistance(camera.aspect, footprint))
controls.update()
}

// The triangle count of what is actually in the scene, which is the only
// honest signal that a rebuild has landed — the status word reads "ready"
Expand Down
Loading
Loading