diff --git a/.changeset/fuzzy-stems-fly.md b/.changeset/fuzzy-stems-fly.md new file mode 100644 index 0000000..873b8f0 --- /dev/null +++ b/.changeset/fuzzy-stems-fly.md @@ -0,0 +1,5 @@ +--- +'basekit': minor +--- + +Add printable, configurable flying stems with flat feet and peg or ball-joint miniature connections. diff --git a/README.md b/README.md index f60a511..068753a 100644 --- a/README.md +++ b/README.md @@ -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) -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. @@ -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. diff --git a/e2e/generator.spec.ts b/e2e/generator.spec.ts index 56e5cb7..6362a7f 100644 --- a/e2e/generator.spec.ts +++ b/e2e/generator.spec.ts @@ -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') diff --git a/src/App.tsx b/src/App.tsx index 39c0af5..b5d9b23 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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' @@ -78,6 +86,7 @@ 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' }, @@ -85,8 +94,17 @@ const ENGRAVING_PLACEMENTS = [ ] 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 ( @@ -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>(() => new Set()) - const [model, setModel] = useState<'base' | 'holder'>(modelForPath) + const [model, setModel] = useState(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(() => { @@ -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) } @@ -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, @@ -207,6 +229,7 @@ export function App() { model, base: config, holder, + stem, width: partWidth, length: partLength, }) @@ -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 = ( @@ -973,7 +995,94 @@ export function App() { ) - const panel = model === 'base' ? basePanel : holderPanel + + const stemPanel = ( + + + + ) + const panel = model === 'base' ? basePanel : model === 'holder' ? holderPanel : stemPanel return (
@@ -983,16 +1092,14 @@ export function App() { instead of standing beside the sheet. */} {!docked && ( - } - > + }> {/* A header row of its own, so the close button has somewhere to sit that is not on top of the first section heading. */} - {model === 'base' ? 'Base' : 'Holder'} settings + {modelLabel(model)} settings
{panel}
@@ -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) && (
+ + {config.connection === 'peg' ? ( + + ) : ( + + )} + + ) + } if (config.kind === 'holder') { const layout = holderLayout(config) const plan = holderPlan(config) diff --git a/src/components/Viewer.tsx b/src/components/Viewer.tsx index 233d219..e5ac6de 100644 --- a/src/components/Viewer.tsx +++ b/src/components/Viewer.tsx @@ -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', @@ -74,11 +74,12 @@ export function Viewer({ mesh, width, length, height, round, fitToPart = false } const shadowLight = useRef(null) const shadowsDirty = useRef(null) const cameraRef = useRef(null) + const controlsRef = useRef(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 @@ -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) @@ -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) @@ -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() } @@ -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" diff --git a/src/geometry/stem.test.ts b/src/geometry/stem.test.ts new file mode 100644 index 0000000..52dca24 --- /dev/null +++ b/src/geometry/stem.test.ts @@ -0,0 +1,79 @@ +import type { Mesh } from 'manifold-3d' +import { beforeAll, describe, expect, it } from 'vitest' +import { loadManifold } from './manifold' +import { buildFlightStem, CLASSIC_STEM_HEIGHTS, defaultFlightStemConfig, stemMaximumDiameter, stemName, stemOverallHeight } from './stem' + +let wasm: Awaited> + +beforeAll(async () => { + wasm = await loadManifold() +}) + +function bounds(mesh: Mesh) { + const { numProp, vertProperties: vertices } = mesh + const min = [Infinity, Infinity, Infinity] + const max = [-Infinity, -Infinity, -Infinity] + for (let i = 0; i < vertices.length; i += numProp) { + for (let axis = 0; axis < 3; axis++) { + min[axis] = Math.min(min[axis], vertices[i + axis]) + max[axis] = Math.max(max[axis], vertices[i + axis]) + } + } + return { min, size: max.map((value, axis) => value - min[axis]) } +} + +describe('buildFlightStem', () => { + it('uses the classic clear-stem dimensions by default', () => { + expect(defaultFlightStemConfig()).toMatchObject({ + bodyHeight: 15, + bodyDiameter: 4.8, + connection: 'peg', + modelPegDiameter: 1.8, + modelPegLength: 4, + ballDiameter: 4, + }) + }) + + it('builds the requested diameter and overall height on the print axis', () => { + const config = defaultFlightStemConfig() + const result = buildFlightStem(wasm, config) + const measured = bounds(result.mesh) + + expect(result.stats.solid).toBe(true) + expect(measured.min[2]).toBeCloseTo(0, 5) + expect(measured.size[0]).toBeCloseTo(config.bodyDiameter, 2) + expect(measured.size[1]).toBeCloseTo(config.bodyDiameter, 2) + expect(measured.size[2]).toBeCloseTo(stemOverallHeight(config), 5) + }) + + it.each(CLASSIC_STEM_HEIGHTS)('builds a %d mm classic stem', (bodyHeight) => { + const config = { ...defaultFlightStemConfig(), bodyHeight } + expect(buildFlightStem(wasm, config).stats.solid).toBe(true) + expect(stemName(config)).toBe(`flying-stem-${bodyHeight}mm`) + }) + + it('builds a ball-joint connection as one printable solid', () => { + const config = { ...defaultFlightStemConfig(), connection: 'ball' as const, ballDiameter: 6 } + const result = buildFlightStem(wasm, config) + const measured = bounds(result.mesh) + + expect(result.stats.solid).toBe(true) + expect(measured.size[0]).toBeCloseTo(stemMaximumDiameter(config), 2) + expect(measured.size[2]).toBeCloseTo(stemOverallHeight(config), 5) + expect(stemName(config)).toBe('flying-stem-15mm-ball') + }) + + it('has no coincident vertices after positional welding', () => { + const { mesh } = buildFlightStem(wasm, defaultFlightStemConfig()) + const positions = new Set() + for (let i = 0; i < mesh.vertProperties.length; i += mesh.numProp) { + const position = `${mesh.vertProperties[i]},${mesh.vertProperties[i + 1]},${mesh.vertProperties[i + 2]}` + expect(positions.has(position)).toBe(false) + positions.add(position) + } + }) + + it('rejects a body narrower than the model connection', () => { + expect(() => buildFlightStem(wasm, { ...defaultFlightStemConfig(), bodyDiameter: 1.5 })).toThrow(/at least as wide/) + }) +}) diff --git a/src/geometry/stem.ts b/src/geometry/stem.ts new file mode 100644 index 0000000..eb9c5a4 --- /dev/null +++ b/src/geometry/stem.ts @@ -0,0 +1,90 @@ +import type { Manifold, ManifoldToplevel } from 'manifold-3d' +import type { BuildResult } from './base' +import { trimNumber } from './outline' +import { previewSegmentsFor } from './quality' +import type { FlightStemConfig } from './types' + +const PLA_DENSITY = 1.24e-3 +const JOIN_OVERLAP = 0.05 + +export const CLASSIC_STEM_HEIGHTS = [15, 20, 30, 35] as const + +export function defaultFlightStemConfig(): FlightStemConfig { + return { + kind: 'stem', + bodyHeight: 15, + bodyDiameter: 4.8, + connection: 'peg', + modelPegDiameter: 1.8, + modelPegLength: 4, + ballDiameter: 4, + segments: previewSegmentsFor(4.8), + } +} + +export function stemOverallHeight(config: FlightStemConfig): number { + return config.bodyHeight + (config.connection === 'peg' ? config.modelPegLength : config.ballDiameter - JOIN_OVERLAP) +} + +export function stemName(config: FlightStemConfig): string { + const connection = config.connection === 'ball' ? '-ball' : '' + return `flying-stem-${trimNumber(config.bodyHeight)}mm${connection}` +} + +export const stemNeckDiameter = (config: FlightStemConfig): number => + config.connection === 'peg' ? config.modelPegDiameter : config.ballDiameter / 2 + +export const stemMaximumDiameter = (config: FlightStemConfig): number => + Math.max(config.bodyDiameter, config.connection === 'ball' ? config.ballDiameter : 0) + +export function buildFlightStem(wasm: ManifoldToplevel, config: FlightStemConfig): BuildResult { + const { Manifold } = wasm + const trash: Manifold[] = [] + const own = (value: Manifold) => { + trash.push(value) + return value + } + + try { + if (config.bodyHeight < 2) throw new Error('Flying-stem body must be at least 2 mm tall') + const neckDiameter = stemNeckDiameter(config) + if (config.bodyDiameter < neckDiameter) { + throw new Error('Flying-stem body must be at least as wide as its miniature connection') + } + if (config.connection === 'peg' && config.modelPegLength <= 0) { + throw new Error('Flying-stem mounting peg needs a positive length') + } + if (config.connection === 'ball' && config.ballDiameter <= 0) { + throw new Error('Flying-stem ball joint needs a positive diameter') + } + + const bodyRadius = config.bodyDiameter / 2 + const neckRadius = neckDiameter / 2 + const body = own(Manifold.cylinder(config.bodyHeight, bodyRadius, neckRadius, config.segments)) + const connection = + config.connection === 'peg' + ? own( + own(Manifold.cylinder(config.modelPegLength + JOIN_OVERLAP, neckRadius, neckRadius, config.segments)).translate([ + 0, + 0, + config.bodyHeight - JOIN_OVERLAP, + ]), + ) + : own( + own(Manifold.sphere(config.ballDiameter / 2, config.segments)).translate([ + 0, + 0, + config.bodyHeight + config.ballDiameter / 2 - JOIN_OVERLAP, + ]), + ) + const solid = own(Manifold.union([body, connection])) + const volume = solid.volume() + const triangles = solid.numTri() + return { + mesh: solid.getMesh(), + stats: { triangles, volume, grams: volume * PLA_DENSITY, solid: volume > 0 && triangles > 0 }, + } + } finally { + for (const value of trash) value.delete() + } +} diff --git a/src/geometry/types.ts b/src/geometry/types.ts index 74f083f..094b3a9 100644 --- a/src/geometry/types.ts +++ b/src/geometry/types.ts @@ -122,8 +122,20 @@ export interface HolderGroup { sides: number } +export interface FlightStemConfig { + kind: 'stem' + /** Height of the tapered body, excluding the miniature connection. */ + bodyHeight: number + bodyDiameter: number + connection: 'peg' | 'ball' + modelPegDiameter: number + modelPegLength: number + ballDiameter: number + segments: number +} + export interface BasePartConfig extends BaseConfig { kind?: 'base' } -export type PartConfig = BasePartConfig | HolderConfig +export type PartConfig = BasePartConfig | HolderConfig | FlightStemConfig diff --git a/src/lib/useExport.ts b/src/lib/useExport.ts index 4e72ee0..367325f 100644 --- a/src/lib/useExport.ts +++ b/src/lib/useExport.ts @@ -4,7 +4,8 @@ import { to3mf, toStl } from '@/geometry/exporters' import { holderName, holderPlan } from '@/geometry/holder' import { baseName } from '@/geometry/outline' import { exportSegmentsFor } from '@/geometry/quality' -import type { BaseConfig, HolderConfig, PartConfig } from '@/geometry/types' +import { stemName, stemOverallHeight } from '@/geometry/stem' +import type { BaseConfig, FlightStemConfig, HolderConfig, PartConfig } from '@/geometry/types' import posthog from '@/lib/posthog' import { buildMesh } from './buildMesh' import { asMeshLike, download } from './download' @@ -12,25 +13,31 @@ import { asMeshLike, download } from './download' type ExportFormat = 'stl' | '3mf' interface ExportOptions { - model: 'base' | 'holder' + model: 'base' | 'holder' | 'stem' base: BaseConfig holder: HolderConfig + stem: FlightStemConfig width: number length: number } -export function useExport({ model, base, holder, width, length }: ExportOptions) { +export function useExport({ model, base, holder, stem, width, length }: ExportOptions) { const [exporting, setExporting] = useState() const [error, setError] = useState() - const config: PartConfig = model === 'base' ? base : holder - const name = model === 'base' ? baseName(base) : holderName(holder) + const config: PartConfig = model === 'base' ? base : model === 'holder' ? holder : stem + const name = model === 'base' ? baseName(base) : model === 'holder' ? holderName(holder) : stemName(stem) const run = async (format: ExportFormat, operation: () => Promise): Promise => { setExporting(format) setError(undefined) try { const result = await operation() - posthog.capture(`${model}_exported`, { format, width, length, height: config.height }) + posthog.capture(`${model}_exported`, { + format, + width, + length, + height: config.kind === 'stem' ? stemOverallHeight(config) : config.height, + }) return result } catch (failure) { posthog.captureException(failure, { export_format: format, model }) diff --git a/src/lib/workspace.test.ts b/src/lib/workspace.test.ts index 4ed6c76..1129309 100644 --- a/src/lib/workspace.test.ts +++ b/src/lib/workspace.test.ts @@ -16,6 +16,7 @@ describe('workspace state', () => { expect(defaultWorkspace()).toMatchObject({ base: { width: 32, magnets: { patternVersion: 2 } }, holder: { kind: 'holder', groups: [{ width: 32 }], magnets: { patternVersion: 2 } }, + stem: { kind: 'stem', bodyHeight: 15, bodyDiameter: 4.8, connection: 'peg', modelPegDiameter: 1.8, ballDiameter: 4 }, }) }) @@ -110,6 +111,26 @@ describe('workspace state', () => { expect(loadWorkspace(storage).holder.edgeSpacing).toBe(1.5) }) + it('adds the flying-stem generator to saved workspaces', () => { + const storage = memoryStorage() + const legacy = JSON.parse(JSON.stringify(defaultWorkspace())) + delete legacy.stem + storage.setItem('mini-bases.workspace', JSON.stringify({ version: 4, workspace: legacy })) + + expect(loadWorkspace(storage).stem).toMatchObject({ kind: 'stem', bodyHeight: 15, bodyDiameter: 4.8, modelPegDiameter: 1.8 }) + }) + + it('adds ball-joint settings to saved flying stems', () => { + const storage = memoryStorage() + const legacy = JSON.parse(JSON.stringify(defaultWorkspace())) + legacy.stem.bodyHeight = 20 + delete legacy.stem.connection + delete legacy.stem.ballDiameter + storage.setItem('mini-bases.workspace', JSON.stringify({ version: 5, workspace: legacy })) + + expect(loadWorkspace(storage).stem).toMatchObject({ bodyHeight: 20, connection: 'peg', ballDiameter: 4 }) + }) + 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 8238a0b..44e0019 100644 --- a/src/lib/workspace.ts +++ b/src/lib/workspace.ts @@ -1,10 +1,11 @@ import { defaultHolderConfig } from '../geometry/holder' import { supportsFivePocketCross } from '../geometry/base' import { automaticMagnetCount, DEFAULT_PRESET, footprintKey, presetFor, ribCountFor } from '../geometry/presets' -import type { BaseConfig, HolderConfig } from '../geometry/types' +import { defaultFlightStemConfig } from '../geometry/stem' +import type { BaseConfig, FlightStemConfig, HolderConfig } from '../geometry/types' const WORKSPACE_KEY = 'mini-bases.workspace' -const WORKSPACE_VERSION = 4 +const WORKSPACE_VERSION = 6 interface SettingsStorage { getItem(key: string): string | null @@ -14,6 +15,7 @@ interface SettingsStorage { export interface WorkspaceState { base: BaseConfig holder: HolderConfig + stem: FlightStemConfig /** Values exposed by both generators have one canonical owner. */ shared: SharedSettings } @@ -107,7 +109,7 @@ export function synchronizeWorkspace(state: WorkspaceState): WorkspaceState { export function defaultWorkspace(): WorkspaceState { const base = presetFor(DEFAULT_PRESET) - return synchronizeWorkspace({ base, holder: defaultHolderConfig(), shared: sharedFromBase(base) }) + return synchronizeWorkspace({ base, holder: defaultHolderConfig(), stem: defaultFlightStemConfig(), shared: sharedFromBase(base) }) } export function loadWorkspace(storage: SettingsStorage): WorkspaceState { @@ -117,13 +119,24 @@ export function loadWorkspace(storage: SettingsStorage): WorkspaceState { const parsed = JSON.parse(saved) as { version?: unknown; workspace?: unknown } const workspace = parsed.version === 1 - ? migrateWorkspaceV3(migrateWorkspaceV2(migrateWorkspaceV1(parsed.workspace))) + ? migrateWorkspaceV5(migrateWorkspaceV4(migrateWorkspaceV3(migrateWorkspaceV2(migrateWorkspaceV1(parsed.workspace))))) : parsed.version === 2 - ? migrateWorkspaceV3(migrateWorkspaceV2(parsed.workspace)) + ? migrateWorkspaceV5(migrateWorkspaceV4(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) + ? migrateWorkspaceV5(migrateWorkspaceV4(migrateWorkspaceV3(parsed.workspace))) + : parsed.version === 4 + ? migrateWorkspaceV5(migrateWorkspaceV4(parsed.workspace)) + : parsed.version === 5 + ? migrateWorkspaceV5(parsed.workspace) + : parsed.workspace + if ( + parsed.version !== WORKSPACE_VERSION && + parsed.version !== 1 && + parsed.version !== 2 && + parsed.version !== 3 && + parsed.version !== 4 && + parsed.version !== 5 + ) return defaultWorkspace() if (!isWorkspaceState(workspace, defaultWorkspace())) return defaultWorkspace() const base = { ...workspace.base } as BaseConfig & { underside?: unknown } @@ -134,6 +147,19 @@ export function loadWorkspace(storage: SettingsStorage): WorkspaceState { } } +function migrateWorkspaceV5(value: unknown): unknown { + if (typeof value !== 'object' || value === null) return value + const workspace = value as Record + const stem = workspace.stem + if (typeof stem !== 'object' || stem === null) return value + return { ...workspace, stem: { ...defaultFlightStemConfig(), ...stem } } +} + +function migrateWorkspaceV4(value: unknown): unknown { + if (typeof value !== 'object' || value === null) return value + return { ...(value as Record), stem: defaultFlightStemConfig() } +} + function migrateWorkspaceV3(value: unknown): unknown { if (typeof value !== 'object' || value === null) return value const workspace = value as Record @@ -196,6 +222,8 @@ function isWorkspaceState(value: unknown, template: WorkspaceState): value is Wo ['round', 'oval', 'pill', 'rect', 'polygon'].includes(workspace.base.shape) && ['balanced', 'five-cross'].includes(workspace.shared.magnets.layout) && [1, 2].includes(workspace.shared.magnets.patternVersion) && + workspace.stem.kind === 'stem' && + ['peg', 'ball'].includes(workspace.stem.connection) && workspace.holder.groups.every((group) => ['round', 'oval', 'pill', 'rect', 'polygon'].includes(group.shape)) && Object.values(workspace.shared.magnetCounts).every((count) => typeof count === 'number' && Number.isFinite(count)) ) diff --git a/src/worker/geometry.worker.ts b/src/worker/geometry.worker.ts index 3242a91..529f69c 100644 --- a/src/worker/geometry.worker.ts +++ b/src/worker/geometry.worker.ts @@ -4,6 +4,7 @@ import fontUrl from '@/assets/fonts/oswald-700.woff?url' import { buildBase, type BuildResult } from '@/geometry/base' import { buildHolder } from '@/geometry/holder' import { loadManifold } from '@/geometry/manifold' +import { buildFlightStem } from '@/geometry/stem' import type { MeshData, WorkerReply, WorkerRequest } from './protocol' const ready = Promise.all([ @@ -25,7 +26,13 @@ self.onmessage = async (event: MessageEvent) => { const { id, config } = event.data try { const [wasm, font] = await ready - const mesh = toMeshData(config.kind === 'holder' ? buildHolder(wasm, config, font) : buildBase(wasm, config, font)) + const result = + config.kind === 'holder' + ? buildHolder(wasm, config, font) + : config.kind === 'stem' + ? buildFlightStem(wasm, config) + : buildBase(wasm, config, font) + const mesh = toMeshData(result) send({ id, kind: 'mesh', mesh }, [mesh.positions.buffer, mesh.indices.buffer]) } catch (error) { send({ id, kind: 'error', message: error instanceof Error ? error.message : String(error) }, [])