feat(tabletop): add state, setup, mounting, and stacking

Implement the tabletop library's game state store, setup seeding with
bare-type expansion, surface mount tree resolution, part placement, and
the stacking positioning process with a dependency-free SVG path helper.
Wire the public API and add unit plus vite integration tests.
This commit is contained in:
2026-08-09 22:34:18 +08:00
parent ef0695ed04
commit cd08e6af04
20 changed files with 1281 additions and 11 deletions
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { stackingOffset, parsePath, pointAt, NO_OFFSET } from './stacking.js';
describe('parsePath', () => {
it('measures a straight line', () => {
const path = parsePath('M 0 0 L 10 0');
expect(path.length).toBeCloseTo(10);
});
it('measures a cubic curve', () => {
const path = parsePath('M 0 0 C 20 -20 40 -20 60 0');
// Longer than the chord (60) but finite.
expect(path.length).toBeGreaterThan(60);
expect(path.length).toBeLessThan(80);
});
it('handles relative commands', () => {
const path = parsePath('m 0 0 l 10 0 l 0 10');
expect(path.length).toBeCloseTo(20);
});
it('supports h/v/z', () => {
const path = parsePath('M 0 0 H 10 V 10 Z');
// 10 right + 10 down + the diagonal back to the start (closes the triangle).
expect(path.length).toBeCloseTo(10 + 10 + Math.sqrt(200));
});
});
describe('pointAt', () => {
it('returns the start at distance 0 and end at full length', () => {
const path = parsePath('M 0 0 L 10 0');
expect(pointAt(path, 0)).toMatchObject({ x: 0, y: 0 });
const end = pointAt(path, path.length);
expect(end.x).toBeCloseTo(10);
expect(end.y).toBeCloseTo(0);
});
it('interpolates along the path', () => {
const path = parsePath('M 0 0 L 10 0');
const mid = pointAt(path, 5);
expect(mid.x).toBeCloseTo(5);
expect(mid.angle).toBeCloseTo(0);
});
});
describe('stackingOffset', () => {
it('returns no offset without a curve', () => {
expect(stackingOffset(undefined, 0, 3)).toBe(NO_OFFSET);
expect(stackingOffset({ limit: 5 }, 0, 3)).toBe(NO_OFFSET);
});
it('spreads parts evenly along a straight curve', () => {
const offset = stackingOffset({ curve: 'M 0 0 L 100 0' }, 1, 3);
// step = length / max(steps=1, 2) = 50; part 1 at 50.
expect(offset.x).toBeCloseTo(50);
expect(offset.y).toBeCloseTo(0);
});
it('aligns to center', () => {
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', align: 'center' }, 0, 3);
// span = 50 * 2 = 100; centered start = (100 - 100)/2 = 0.
expect(offset.x).toBeCloseTo(0);
});
it('aligns to end', () => {
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', align: 'end' }, 2, 3);
// start = 100 - 100 = 0; part 2 at 100.
expect(offset.x).toBeCloseTo(100);
});
it('respects a positive limit (first n)', () => {
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', limit: 2 }, 2, 4);
// Part 2 is beyond the first 2 shown -> not placed.
expect(offset).toBe(NO_OFFSET);
});
it('respects a negative limit (last n)', () => {
// Last 2 of 4 are indices 2,3. Part 2 is the first shown.
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', limit: -2 }, 2, 4);
expect(offset.x).toBeCloseTo(0);
});
it('uses steps to densify the curve', () => {
// steps=4, 3 parts -> step = 100 / max(4, 2) = 25.
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', steps: 4 }, 1, 3);
expect(offset.x).toBeCloseTo(25);
});
});