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:
2026-08-09 23:36:42 +08:00
parent 2da04940c2
commit c5d6dff12d
9 changed files with 127 additions and 28 deletions
+3
View File
@@ -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({
+14
View File
@@ -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. */
+7 -5
View File
@@ -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 (
<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. */}
<group rotation={[-Math.PI / 2, 0, 0]}>
<group rotation={[-Math.PI / 2, 0, tilt]}>
<PartView part={part} baseUrl={part.baseUrl} />
</group>
</group>
);
}
}
+31
View File
@@ -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);
});
});
+49 -20
View File
@@ -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. */