diff --git a/docs/bgm-format.md b/docs/bgm-format.md index 5594770..fe23ca5 100644 --- a/docs/bgm-format.md +++ b/docs/bgm-format.md @@ -359,6 +359,9 @@ layout: limit: 5 align: center steps: 4 + tilt: 0.1 + zStart: 0 + zEnd: 30 ``` - `curve` — an SVG path string to spread the content along, relative to the @@ -368,6 +371,13 @@ layout: - `align` — `start`, `end`, or `center` of the curve. - `steps` — the maximum number of parts per curve length unit. Defaults to `1`. See the positioning process below. +- `tilt` — rotation in radians per shown part about the card's local Y (long) + axis. Each part tilts `tilt` more than the previous, fanning the stack so + its edges stay visible. It applies even without a `curve`, so a bare `tilt` + fans a straight pile. +- `zStart` / `zEnd` — the height (surface-normal) in mm at the start and end + of the `curve`. The stack ramps linearly between them across its span, + lifting it in 3D. Requires a `curve`. #### positioning process @@ -377,6 +387,10 @@ layout: `step length × (# of parts − 1)` on the curve. 3. **Place each part.** Part `#0` is at the start, the last part at the end, each `step length` apart. +4. **Lift each part.** The part's height is `zStart + (zEnd − zStart) × u`, + where `u` is its normalized position along the `curve`. +5. **Tilt each part.** Each part is rotated `tilt × # of parts before it` + about its local Y (long) axis. ### Edge cases diff --git a/docs/bgm-tabletop-plan.md b/docs/bgm-tabletop-plan.md index 07cfcc4..40c6b80 100644 --- a/docs/bgm-tabletop-plan.md +++ b/docs/bgm-tabletop-plan.md @@ -118,10 +118,12 @@ interface GameState { ### 7. Stacking (`stacking.ts`) ✅ -- `useStacking(route.stacking, index, stackSize)` → `{ offset, rotation }`. +- `useStacking(route.stacking, index, stackSize)` → `{ x, y, rotation, z, tilt }`. - Implements the format's positioning process (`bgm-format.md` §4): step length from curve length / `max(steps, count-1)`, alignment (`start`/`end`/ `center`), and `limit` (`0` all, `n` first n, `-n` last n). +- `z` ramps linearly from `zStart` to `zEnd` across the curve's span; `tilt` + fans each shown part about its local Y (long) axis. - Curve length from an SVG path string (small helper; no new dep). ### 8. Public API (`index.ts`) ✅ @@ -148,7 +150,8 @@ consumers share them (see Open decisions). - `state.ts` — derived render state: enabled surfaces, route matching, candidate selection, stacking index/stackSize. -- `stacking.ts` — positioning process: step length, alignment, limit. +- `stacking.ts` — positioning process: step length, alignment, limit, z ramp, + tilt. - `setup.ts` — seeding + bare-type expansion. - `mount.ts` — mount tree resolution (table/hud/child, children refs). - `partView.tsx` — geometry from a part def (size/fillet/crop), sprite UVs. diff --git a/docs/bgm-tabletop.md b/docs/bgm-tabletop.md index e322186..cdb92bf 100644 --- a/docs/bgm-tabletop.md +++ b/docs/bgm-tabletop.md @@ -40,7 +40,7 @@ the render map is per enabled surface: a piece may appear on more than one enabl ## 4. stacking -the format's stacking strategy (curve / limit / align / steps, see `bgm-format.md` §4) is implemented as a hook, e.g. `useStacking(route.stacking, index, stackSize)`, returning the offset/rotation to apply to a piece. `PartPlacement` consumes it. +the format's stacking strategy (`curve` / `limit` / `align` / `steps` / `tilt` / `zStart` / `zEnd`, see `bgm-format.md` §4) is implemented as a hook, e.g. `useStacking(route.stacking, index, stackSize)`, returning the offset/rotation to apply to a piece: `{ x, y, rotation, z, tilt }`. `x`/`y`/`rotation` come from the `curve`; `z` is the surface-normal height ramped from `zStart` to `zEnd`; `tilt` is the per-part fan about the card's local Y (long) axis. `PartPlacement` consumes it. ## 5. usage diff --git a/games/poker/poker.md b/games/poker/poker.md index 9c59d3d..b6423c4 100644 --- a/games/poker/poker.md +++ b/games/poker/poker.md @@ -106,6 +106,9 @@ layout: curve: M 0 0 C 20 -20 40 -20 60 0 limit: 0 align: center + tilt: 0.015 + zStart: 0 + zEnd: 10 - route: /community/:slot candidates: $variants: ./community.csv diff --git a/packages/bgm/src/schemas.ts b/packages/bgm/src/schemas.ts index 94c6286..934cdf8 100644 --- a/packages/bgm/src/schemas.ts +++ b/packages/bgm/src/schemas.ts @@ -17,6 +17,9 @@ const stacking = z.object({ limit: z.number().optional(), align: z.enum(['start', 'end', 'center']).optional(), steps: z.number().optional(), + tilt: z.number().optional(), + zStart: z.number().optional(), + zEnd: z.number().optional(), }); const route = z.object({ diff --git a/packages/bgm/src/types.ts b/packages/bgm/src/types.ts index 4898a85..f0a0fe4 100644 --- a/packages/bgm/src/types.ts +++ b/packages/bgm/src/types.ts @@ -107,6 +107,20 @@ export interface Stacking { align?: 'start' | 'end' | 'center'; /** Maximum parts per curve length unit; defaults to `1`. */ steps?: number; + /** + * Rotation in radians per shown part about the card's local Y (long) axis. + * Each part tilts `tilt` more than the previous, fanning the stack so its + * edges stay visible. Works with or without a `curve`. + */ + tilt?: number; + /** + * Height (surface-normal) in mm at the start of the `curve`. The stack + * ramps linearly to `zEnd` across its span, lifting it in 3D. Requires a + * `curve`. + */ + zStart?: number; + /** Height (surface-normal) in mm at the end of the `curve`. */ + zEnd?: number; } /** How a surface is mounted. `kind` selects the mount type. */ diff --git a/packages/tabletop/src/placement.tsx b/packages/tabletop/src/placement.tsx index e91fea0..ff6090b 100644 --- a/packages/tabletop/src/placement.tsx +++ b/packages/tabletop/src/placement.tsx @@ -17,20 +17,22 @@ export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Pla const part = pkg.parts.get(piece.split(':').slice(1).join(':')); if (!part) return null; - const { x, y, rotation } = useStacking(route.stacking, index, stackSize); + const { x, y, rotation, z, tilt } = useStacking(route.stacking, index, stackSize); // Route anchors and stacking offsets are in mm; convert to world units so - // parts land on the (world-scaled) surface. + // parts land on the (world-scaled) surface. `z` raises the part along the + // surface normal (world +Y); `tilt` fans it about its local Y (long) axis. const anchorX = ((candidate?.x ?? route.x ?? 0) + x) * MM_TO_WORLD; const anchorY = ((candidate?.y ?? route.y ?? 0) + y) * MM_TO_WORLD; + const anchorZ = z * MM_TO_WORLD; const anchorRotation = (candidate?.rotation ?? route.rotation ?? 0) + rotation; return ( - + {/* The part mesh extrudes along +Z; lay it flat so its face points up. */} - + ); -} \ No newline at end of file +} diff --git a/packages/tabletop/src/stacking.test.ts b/packages/tabletop/src/stacking.test.ts index 94946b8..8937e92 100644 --- a/packages/tabletop/src/stacking.test.ts +++ b/packages/tabletop/src/stacking.test.ts @@ -85,4 +85,35 @@ describe('stackingOffset', () => { const offset = stackingOffset({ curve: 'M 0 0 L 100 0', steps: 4 }, 1, 3); expect(offset.x).toBeCloseTo(25); }); + + it('tilts each part without a curve', () => { + const offset = stackingOffset({ tilt: 0.1 }, 2, 3); + expect(offset).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 0.2 }); + }); + + it('tilts parts along the curve', () => { + const offset = stackingOffset({ curve: 'M 0 0 L 100 0', tilt: 0.1 }, 1, 3); + expect(offset.x).toBeCloseTo(50); + expect(offset.tilt).toBeCloseTo(0.1); + }); + + it('tilts only the shown parts', () => { + // limit 2 shows indices 0,1; index 2 is dropped. + expect(stackingOffset({ tilt: 0.1, limit: 2 }, 2, 4)).toBe(NO_OFFSET); + expect(stackingOffset({ tilt: 0.1, limit: 2 }, 1, 4).tilt).toBeCloseTo(0.1); + }); + + it('ramps z from zStart to zEnd across the curve', () => { + // 3 parts on a 100-long curve: u = 0, 0.5, 1. z ramps 0 -> 40. + const first = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 0, 3); + const mid = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 1, 3); + const last = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 2, 3); + expect(first.z).toBeCloseTo(0); + expect(mid.z).toBeCloseTo(20); + expect(last.z).toBeCloseTo(40); + }); + + it('returns no offset without a curve, tilt, or z ramp', () => { + expect(stackingOffset({ limit: 5 }, 0, 3)).toBe(NO_OFFSET); + }); }); \ No newline at end of file diff --git a/packages/tabletop/src/stacking.ts b/packages/tabletop/src/stacking.ts index f517464..3c86292 100644 --- a/packages/tabletop/src/stacking.ts +++ b/packages/tabletop/src/stacking.ts @@ -16,48 +16,77 @@ export interface StackOffset { y: number; /** Rotation in radians. */ rotation: number; + /** Vertical (surface-normal) offset from the anchor, in mm. */ + z: number; + /** Rotation in radians about the card's local Y (long) axis. */ + tilt: number; } /** The identity offset: no stacking applied. */ -export const NO_OFFSET: StackOffset = { x: 0, y: 0, rotation: 0 }; +export const NO_OFFSET: StackOffset = { x: 0, y: 0, rotation: 0, z: 0, tilt: 0 }; /** * Compute the offset/rotation for the piece at `index` of a `stackSize`-piece * stack, given the route's stacking strategy. Returns `NO_OFFSET` when there's - * no curve or the stack is empty. + * no curve, no tilt, and no z profile, or the stack is empty. */ export function stackingOffset( stacking: Stacking | undefined, index: number, stackSize: number, ): StackOffset { - if (!stacking?.curve || stackSize <= 0) return NO_OFFSET; + if (stackSize <= 0) return NO_OFFSET; // `limit` selects which pieces are shown; the offset is computed over the // shown span. `0` (or absent) shows all. - const shown = applyLimit(stacking.limit, stackSize); + const shown = applyLimit(stacking?.limit, stackSize); const shownIndex = shown.indexOf(index); if (shownIndex < 0) return NO_OFFSET; - const curve = parsePath(stacking.curve); - const length = curve.length; - if (length <= 0) return NO_OFFSET; + // `tilt` fans each shown part about its local Y (long) axis, so the stack's + // edges stay visible. It applies even without a curve. + const tilt = (stacking?.tilt ?? 0) * shownIndex; - // Step length: curve length / max(steps, # parts − 1). A single part sits - // at the start of the curve. - const steps = stacking.steps ?? 1; - const span = Math.max(steps, shown.length - 1); - const step = length / span; + // The horizontal position along the curve (or a straight pile when there's + // no curve), plus the normalized progress used to ramp the z height. + let x = 0; + let y = 0; + let rotation = 0; + let u = shown.length > 1 ? shownIndex / (shown.length - 1) : 0; - // Alignment: how far the whole span is inset from the curve's start. - const spanLength = step * (shown.length - 1); - let start = 0; - if (stacking.align === 'end') start = length - spanLength; - else if (stacking.align === 'center') start = (length - spanLength) / 2; + if (stacking?.curve) { + const curve = parsePath(stacking.curve); + const length = curve.length; + if (length > 0) { + // Step length: curve length / max(steps, # parts − 1). A single part + // sits at the start of the curve. + const steps = stacking.steps ?? 1; + const span = Math.max(steps, shown.length - 1); + const step = length / span; - const distance = start + shownIndex * step; - const { x, y, angle } = pointAt(curve, distance); - return { x, y, rotation: angle }; + // Alignment: how far the whole span is inset from the curve's start. + const spanLength = step * (shown.length - 1); + let start = 0; + if (stacking.align === 'end') start = length - spanLength; + else if (stacking.align === 'center') start = (length - spanLength) / 2; + + const distance = start + shownIndex * step; + const point = pointAt(curve, distance); + x = point.x; + y = point.y; + rotation = point.angle; + u = distance / length; + } + } + + // The z height ramps linearly from `zStart` to `zEnd` across the curve's + // span, lifting the stack in 3D. + const zStart = stacking?.zStart ?? 0; + const zEnd = stacking?.zEnd ?? 0; + const z = zStart + (zEnd - zStart) * u; + + if (x === 0 && y === 0 && rotation === 0 && z === 0 && tilt === 0) return NO_OFFSET; + return { x, y, rotation, z, tilt }; } /** The stacking hook: memoized `stackingOffset` for a piece. */