fix(tabletop): tilt cards uniformly about the y axis
Apply the same tilt to every stacked part and rotate it about its local Y axis instead of Z. Default tilt to 1 degree when a stacking strategy is present, and arc the poker deck along the surface's bottom edge.
This commit is contained in:
+5
-6
@@ -371,10 +371,9 @@ 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 degrees per shown part about the card's local Y (long)
|
- `tilt` — rotation in degrees applied to every shown part about the card's
|
||||||
axis. Each part tilts `tilt` more than the previous, fanning the stack so
|
local Y (long) axis. It applies even without a `curve`, so a bare `tilt`
|
||||||
its edges stay visible. It applies even without a `curve`, so a bare `tilt`
|
rotates a straight pile.
|
||||||
fans a straight pile.
|
|
||||||
- `zStart` / `zEnd` — the height (surface-normal) in mm at the start and end
|
- `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,
|
of the `curve`. The stack ramps linearly between them across its span,
|
||||||
lifting it in 3D. Requires a `curve`.
|
lifting it in 3D. Requires a `curve`.
|
||||||
@@ -389,8 +388,8 @@ layout:
|
|||||||
each `step length` apart.
|
each `step length` apart.
|
||||||
4. **Lift each part.** The part's height is `zStart + (zEnd − zStart) × u`,
|
4. **Lift each part.** The part's height is `zStart + (zEnd − zStart) × u`,
|
||||||
where `u` is its normalized position along the `curve`.
|
where `u` is its normalized position along the `curve`.
|
||||||
5. **Tilt each part.** Each part is rotated `tilt × # of parts before it`
|
5. **Tilt each part.** Every part is rotated `tilt` about its local Y (long)
|
||||||
about its local Y (long) axis.
|
axis.
|
||||||
|
|
||||||
### Edge cases
|
### Edge cases
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ interface GameState {
|
|||||||
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`
|
- `z` ramps linearly from `zStart` to `zEnd` across the curve's span; `tilt`
|
||||||
fans each shown part about its local Y (long) axis.
|
rotates 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`) ✅
|
||||||
|
|||||||
@@ -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` / `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.
|
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 rotation about the card's local Y (long) axis, applied to every part. `PartPlacement` consumes it.
|
||||||
|
|
||||||
## 5. usage
|
## 5. usage
|
||||||
|
|
||||||
|
|||||||
@@ -103,12 +103,11 @@ layout:
|
|||||||
y: 0
|
y: 0
|
||||||
rotation: 0
|
rotation: 0
|
||||||
stacking:
|
stacking:
|
||||||
curve: M 0 0 C 20 -20 40 -20 60 0
|
|
||||||
limit: 0
|
|
||||||
align: center
|
align: center
|
||||||
tilt: 0.015
|
|
||||||
zStart: 0
|
zStart: 0
|
||||||
zEnd: 10
|
#zEnd: 100
|
||||||
|
tilt: 1
|
||||||
|
curve: M -50 -200 C 50 -150 450 -150 550 -200
|
||||||
- route: /community/:slot
|
- route: /community/:slot
|
||||||
candidates:
|
candidates:
|
||||||
$variants: ./community.csv
|
$variants: ./community.csv
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Pla
|
|||||||
|
|
||||||
// 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. `z` raises the part along the
|
// 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.
|
// surface normal (world +Y); `tilt` rotates it about its local Y (long) axis.
|
||||||
// Angles are authored in degrees; three.js expects radians.
|
// Angles are authored in degrees; three.js expects radians.
|
||||||
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;
|
||||||
@@ -31,7 +31,7 @@ export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Pla
|
|||||||
return (
|
return (
|
||||||
<group position={[anchorX, anchorZ, 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, tilt * DEG_TO_RAD]}>
|
<group rotation={[-Math.PI / 2, tilt * DEG_TO_RAD, 0]}>
|
||||||
<PartView part={part} baseUrl={part.baseUrl} />
|
<PartView part={part} baseUrl={part.baseUrl} />
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ describe('pointAt', () => {
|
|||||||
describe('stackingOffset', () => {
|
describe('stackingOffset', () => {
|
||||||
it('returns no offset without a curve', () => {
|
it('returns no offset without a curve', () => {
|
||||||
expect(stackingOffset(undefined, 0, 3)).toBe(NO_OFFSET);
|
expect(stackingOffset(undefined, 0, 3)).toBe(NO_OFFSET);
|
||||||
expect(stackingOffset({ limit: 5 }, 0, 3)).toBe(NO_OFFSET);
|
// A stacking strategy defaults to a 1° tilt, so it's not the identity.
|
||||||
|
expect(stackingOffset({ limit: 5 }, 0, 3)).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('spreads parts evenly along a straight curve', () => {
|
it('spreads parts evenly along a straight curve', () => {
|
||||||
@@ -86,12 +87,12 @@ describe('stackingOffset', () => {
|
|||||||
expect(offset.x).toBeCloseTo(25);
|
expect(offset.x).toBeCloseTo(25);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tilts each part without a curve', () => {
|
it('tilts every part the same amount without a curve', () => {
|
||||||
const offset = stackingOffset({ tilt: 0.1 }, 2, 3);
|
const offset = stackingOffset({ tilt: 0.1 }, 2, 3);
|
||||||
expect(offset).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 0.2 });
|
expect(offset).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 0.1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tilts parts along the curve', () => {
|
it('tilts every part the same amount along the curve', () => {
|
||||||
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', tilt: 0.1 }, 1, 3);
|
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', tilt: 0.1 }, 1, 3);
|
||||||
expect(offset.x).toBeCloseTo(50);
|
expect(offset.x).toBeCloseTo(50);
|
||||||
expect(offset.tilt).toBeCloseTo(0.1);
|
expect(offset.tilt).toBeCloseTo(0.1);
|
||||||
@@ -113,7 +114,7 @@ describe('stackingOffset', () => {
|
|||||||
expect(last.z).toBeCloseTo(40);
|
expect(last.z).toBeCloseTo(40);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns no offset without a curve, tilt, or z ramp', () => {
|
it('returns no offset without a stacking strategy', () => {
|
||||||
expect(stackingOffset({ limit: 5 }, 0, 3)).toBe(NO_OFFSET);
|
expect(stackingOffset(undefined, 0, 3)).toBe(NO_OFFSET);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -43,9 +43,10 @@ export function stackingOffset(
|
|||||||
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
|
// `tilt` rotates each shown part about its local Y (long) axis by the same
|
||||||
// edges stay visible. It applies even without a curve.
|
// amount. It applies even without a curve. Defaults to 1° when a stacking
|
||||||
const tilt = (stacking?.tilt ?? 0) * shownIndex;
|
// strategy is present but doesn't specify a tilt.
|
||||||
|
const tilt = stacking?.tilt ?? (stacking ? 1 : 0);
|
||||||
|
|
||||||
// The horizontal position along the curve (or a straight pile when there's
|
// 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.
|
// no curve), plus the normalized progress used to ramp the z height.
|
||||||
|
|||||||
Reference in New Issue
Block a user