feat(tabletop): add tilt and zStart/zEnd stacking options
Replace the per-part lift with a local Y-axis tilt that fans the stack, and add zStart/zEnd to ramp the stack's height across the curve so it arches in 3D. Update the poker deck and docs accordingly.
This commit is contained in:
@@ -359,6 +359,9 @@ layout:
|
|||||||
limit: 5
|
limit: 5
|
||||||
align: center
|
align: center
|
||||||
steps: 4
|
steps: 4
|
||||||
|
tilt: 0.1
|
||||||
|
zStart: 0
|
||||||
|
zEnd: 30
|
||||||
```
|
```
|
||||||
|
|
||||||
- `curve` — an SVG path string to spread the content along, relative to the
|
- `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.
|
- `align` — `start`, `end`, or `center` of the curve.
|
||||||
- `steps` — the maximum number of parts per curve length unit. Defaults to
|
- `steps` — the maximum number of parts per curve length unit. Defaults to
|
||||||
`1`. See the positioning process below.
|
`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
|
#### positioning process
|
||||||
|
|
||||||
@@ -377,6 +387,10 @@ layout:
|
|||||||
`step length × (# of parts − 1)` on the curve.
|
`step length × (# of parts − 1)` on the curve.
|
||||||
3. **Place each part.** Part `#0` is at the start, the last part at the end,
|
3. **Place each part.** Part `#0` is at the start, the last part at the end,
|
||||||
each `step length` apart.
|
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
|
### Edge cases
|
||||||
|
|
||||||
|
|||||||
@@ -118,10 +118,12 @@ interface GameState {
|
|||||||
|
|
||||||
### 7. Stacking (`stacking.ts`) ✅
|
### 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
|
- Implements the format's positioning process (`bgm-format.md` §4): step
|
||||||
length from curve length / `max(steps, count-1)`, alignment (`start`/`end`/
|
length from curve length / `max(steps, count-1)`, alignment (`start`/`end`/
|
||||||
`center`), and `limit` (`0` all, `n` first n, `-n` last n).
|
`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).
|
- Curve length from an SVG path string (small helper; no new dep).
|
||||||
|
|
||||||
### 8. Public API (`index.ts`) ✅
|
### 8. Public API (`index.ts`) ✅
|
||||||
@@ -148,7 +150,8 @@ consumers share them (see Open decisions).
|
|||||||
|
|
||||||
- `state.ts` — derived render state: enabled surfaces, route matching,
|
- `state.ts` — derived render state: enabled surfaces, route matching,
|
||||||
candidate selection, stacking index/stackSize.
|
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.
|
- `setup.ts` — seeding + bare-type expansion.
|
||||||
- `mount.ts` — mount tree resolution (table/hud/child, children refs).
|
- `mount.ts` — mount tree resolution (table/hud/child, children refs).
|
||||||
- `partView.tsx` — geometry from a part def (size/fillet/crop), sprite UVs.
|
- `partView.tsx` — geometry from a part def (size/fillet/crop), sprite UVs.
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ the render map is per enabled surface: a piece may appear on more than one enabl
|
|||||||
|
|
||||||
## 4. stacking
|
## 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
|
## 5. usage
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ layout:
|
|||||||
curve: M 0 0 C 20 -20 40 -20 60 0
|
curve: M 0 0 C 20 -20 40 -20 60 0
|
||||||
limit: 0
|
limit: 0
|
||||||
align: center
|
align: center
|
||||||
|
tilt: 0.015
|
||||||
|
zStart: 0
|
||||||
|
zEnd: 10
|
||||||
- route: /community/:slot
|
- route: /community/:slot
|
||||||
candidates:
|
candidates:
|
||||||
$variants: ./community.csv
|
$variants: ./community.csv
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ const stacking = z.object({
|
|||||||
limit: z.number().optional(),
|
limit: z.number().optional(),
|
||||||
align: z.enum(['start', 'end', 'center']).optional(),
|
align: z.enum(['start', 'end', 'center']).optional(),
|
||||||
steps: z.number().optional(),
|
steps: z.number().optional(),
|
||||||
|
tilt: z.number().optional(),
|
||||||
|
zStart: z.number().optional(),
|
||||||
|
zEnd: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const route = z.object({
|
const route = z.object({
|
||||||
|
|||||||
@@ -107,6 +107,20 @@ export interface Stacking {
|
|||||||
align?: 'start' | 'end' | 'center';
|
align?: 'start' | 'end' | 'center';
|
||||||
/** Maximum parts per curve length unit; defaults to `1`. */
|
/** Maximum parts per curve length unit; defaults to `1`. */
|
||||||
steps?: number;
|
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. */
|
/** How a surface is mounted. `kind` selects the mount type. */
|
||||||
|
|||||||
@@ -17,18 +17,20 @@ export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Pla
|
|||||||
const part = pkg.parts.get(piece.split(':').slice(1).join(':'));
|
const part = pkg.parts.get(piece.split(':').slice(1).join(':'));
|
||||||
if (!part) return null;
|
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
|
// 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 anchorX = ((candidate?.x ?? route.x ?? 0) + x) * MM_TO_WORLD;
|
||||||
const anchorY = ((candidate?.y ?? route.y ?? 0) + y) * 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;
|
const anchorRotation = (candidate?.rotation ?? route.rotation ?? 0) + rotation;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={[anchorX, 0, anchorY]} rotation={[0, anchorRotation, 0]}>
|
<group position={[anchorX, anchorZ, anchorY]} rotation={[0, anchorRotation, 0]}>
|
||||||
{/* The part mesh extrudes along +Z; lay it flat so its face points up. */}
|
{/* The part mesh extrudes along +Z; lay it flat so its face points up. */}
|
||||||
<group rotation={[-Math.PI / 2, 0, 0]}>
|
<group rotation={[-Math.PI / 2, 0, tilt]}>
|
||||||
<PartView part={part} baseUrl={part.baseUrl} />
|
<PartView part={part} baseUrl={part.baseUrl} />
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -85,4 +85,35 @@ describe('stackingOffset', () => {
|
|||||||
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', steps: 4 }, 1, 3);
|
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', steps: 4 }, 1, 3);
|
||||||
expect(offset.x).toBeCloseTo(25);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -16,35 +16,50 @@ export interface StackOffset {
|
|||||||
y: number;
|
y: number;
|
||||||
/** Rotation in radians. */
|
/** Rotation in radians. */
|
||||||
rotation: number;
|
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. */
|
/** 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
|
* 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
|
* 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(
|
export function stackingOffset(
|
||||||
stacking: Stacking | undefined,
|
stacking: Stacking | undefined,
|
||||||
index: number,
|
index: number,
|
||||||
stackSize: number,
|
stackSize: number,
|
||||||
): StackOffset {
|
): 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
|
// `limit` selects which pieces are shown; the offset is computed over the
|
||||||
// shown span. `0` (or absent) shows all.
|
// shown span. `0` (or absent) shows all.
|
||||||
const shown = applyLimit(stacking.limit, stackSize);
|
const shown = applyLimit(stacking?.limit, stackSize);
|
||||||
const shownIndex = shown.indexOf(index);
|
const shownIndex = shown.indexOf(index);
|
||||||
if (shownIndex < 0) return NO_OFFSET;
|
if (shownIndex < 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;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
if (stacking?.curve) {
|
||||||
const curve = parsePath(stacking.curve);
|
const curve = parsePath(stacking.curve);
|
||||||
const length = curve.length;
|
const length = curve.length;
|
||||||
if (length <= 0) return NO_OFFSET;
|
if (length > 0) {
|
||||||
|
// Step length: curve length / max(steps, # parts − 1). A single part
|
||||||
// Step length: curve length / max(steps, # parts − 1). A single part sits
|
// sits at the start of the curve.
|
||||||
// at the start of the curve.
|
|
||||||
const steps = stacking.steps ?? 1;
|
const steps = stacking.steps ?? 1;
|
||||||
const span = Math.max(steps, shown.length - 1);
|
const span = Math.max(steps, shown.length - 1);
|
||||||
const step = length / span;
|
const step = length / span;
|
||||||
@@ -56,8 +71,22 @@ export function stackingOffset(
|
|||||||
else if (stacking.align === 'center') start = (length - spanLength) / 2;
|
else if (stacking.align === 'center') start = (length - spanLength) / 2;
|
||||||
|
|
||||||
const distance = start + shownIndex * step;
|
const distance = start + shownIndex * step;
|
||||||
const { x, y, angle } = pointAt(curve, distance);
|
const point = pointAt(curve, distance);
|
||||||
return { x, y, rotation: angle };
|
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. */
|
/** The stacking hook: memoized `stackingOffset` for a piece. */
|
||||||
|
|||||||
Reference in New Issue
Block a user