From 50c6df48b52b5041ca82c536e71518bd8c29dc1b Mon Sep 17 00:00:00 2001 From: hypercross Date: Mon, 10 Aug 2026 00:40:56 +0800 Subject: [PATCH] fix(tabletop): align cards to the stacking curve Fix the tangent angle at the curve's start and rotate cards to follow the curve direction. Add a stacking curve visualization shown with surface debug. --- packages/tabletop/src/placement.tsx | 6 ++- packages/tabletop/src/stacking.test.ts | 12 ++++++ packages/tabletop/src/stacking.ts | 12 +++++- .../tabletop/src/surfaces/HudSurfaceView.tsx | 10 ++++- .../tabletop/src/surfaces/StackingCurve.tsx | 40 +++++++++++++++++++ .../src/surfaces/WorldSurfaceView.tsx | 4 ++ 6 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 packages/tabletop/src/surfaces/StackingCurve.tsx diff --git a/packages/tabletop/src/placement.tsx b/packages/tabletop/src/placement.tsx index 859cd15..99032be 100644 --- a/packages/tabletop/src/placement.tsx +++ b/packages/tabletop/src/placement.tsx @@ -28,7 +28,11 @@ export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Pla 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) * DEG_TO_RAD; + // The curve tangent `rotation` is the direction of travel; the card's long + // edge ends up along (sinR, cosR) in the surface plane, so it aligns with + // the tangent (cosφ, sinφ) when R = 90° − φ. + const anchorRotation = + ((candidate?.rotation ?? route.rotation ?? 0) - rotation) * DEG_TO_RAD; return ( diff --git a/packages/tabletop/src/stacking.test.ts b/packages/tabletop/src/stacking.test.ts index df84983..f9e965e 100644 --- a/packages/tabletop/src/stacking.test.ts +++ b/packages/tabletop/src/stacking.test.ts @@ -41,6 +41,18 @@ describe('pointAt', () => { expect(mid.x).toBeCloseTo(5); expect(mid.angle).toBeCloseTo(0); }); + + it('returns the tangent angle in degrees', () => { + const path = parsePath('M 0 0 L 10 10'); + expect(pointAt(path, 5).angle).toBeCloseTo(45); + const down = parsePath('M 0 0 L 0 10'); + expect(pointAt(down, 5).angle).toBeCloseTo(90); + }); + + it('returns the tangent angle at the start of the path', () => { + const path = parsePath('M 0 0 L 10 10'); + expect(pointAt(path, 0).angle).toBeCloseTo(45); + }); }); describe('stackingOffset', () => { diff --git a/packages/tabletop/src/stacking.ts b/packages/tabletop/src/stacking.ts index 185a0af..910d4b5 100644 --- a/packages/tabletop/src/stacking.ts +++ b/packages/tabletop/src/stacking.ts @@ -375,7 +375,7 @@ function sampleArc( /** Sample density per curve segment. */ const SEGMENTS = 32; -/** The point (and tangent angle) at a distance along a sampled path. */ +/** The point (and tangent angle in degrees) at a distance along a sampled path. */ export function pointAt(path: SampledPath, distance: number): { x: number; y: number; angle: number } { const { points, length } = path; if (points.length === 0) return { x: 0, y: 0, angle: 0 }; @@ -395,6 +395,14 @@ export function pointAt(path: SampledPath, distance: number): { x: number; y: nu const u = seg > 0 ? (d - a.t) / seg : 0; const x = a.x + (b.x - a.x) * u; const y = a.y + (b.y - a.y) * u; - const angle = Math.atan2(b.y - a.y, b.x - a.x); + // The tangent direction in degrees, matching the format's angle units. At + // the very start (lo === 0) the segment is degenerate (a === b), so fall + // back to the first segment's direction instead of a 0° angle. + const dx = b.x - a.x; + const dy = b.y - a.y; + const angle = + lo === 0 && points.length > 1 + ? (Math.atan2(points[1]!.y - points[0]!.y, points[1]!.x - points[0]!.x) * 180) / Math.PI + : (Math.atan2(dy, dx) * 180) / Math.PI; return { x, y, angle }; } \ No newline at end of file diff --git a/packages/tabletop/src/surfaces/HudSurfaceView.tsx b/packages/tabletop/src/surfaces/HudSurfaceView.tsx index 7f4e3d0..6ef5844 100644 --- a/packages/tabletop/src/surfaces/HudSurfaceView.tsx +++ b/packages/tabletop/src/surfaces/HudSurfaceView.tsx @@ -12,6 +12,7 @@ import { PartPlacement } from '../placement.js'; import type { MountNode } from '../mount.js'; import { MM_TO_WORLD } from '../part.js'; import { SurfaceBounds } from './SurfaceBounds.js'; +import { StackingCurve } from './StackingCurve.js'; import { SurfaceNode } from './WorldSurfaceView.js'; export function HudSurfaceView({ @@ -33,7 +34,14 @@ export function HudSurfaceView({ distanceFactor={1} style={{ pointerEvents: 'none' }} > - {showSurface && } + {showSurface && ( + <> + + {node.surface.layout.map((route, i) => + route.stacking ? : null, + )} + + )} {own.map((p) => ( ))} diff --git a/packages/tabletop/src/surfaces/StackingCurve.tsx b/packages/tabletop/src/surfaces/StackingCurve.tsx new file mode 100644 index 0000000..3db7f4a --- /dev/null +++ b/packages/tabletop/src/surfaces/StackingCurve.tsx @@ -0,0 +1,40 @@ +/** + * `StackingCurve` — a debug visualization of a route's stacking `curve`. + * + * Draws the SVG path (mm, converted to world units) as a line on the surface, + * plus a small marker at the curve's start. The curve is defined relative to + * the route's anchor, so the points are offset by the anchor's `x`/`y`. Used + * by the surface views when `showSurface` is enabled, so the curve's shape and + * direction are visible. + */ +import { Line } from '@react-three/drei'; +import type { Route } from '@tts/bgm'; +import { parsePath } from '../stacking.js'; +import { MM_TO_WORLD } from '../part.js'; + +export function StackingCurve({ route }: { route: Route }) { + if (!route.stacking?.curve) return null; + const path = parsePath(route.stacking.curve); + if (path.points.length === 0) return null; + + // Curve coords are in mm relative to the route anchor (x, y); map to world + // (x, z) and offset by the anchor, matching how parts are placed. + const points: [number, number, number][] = path.points.map((p) => [ + (route.x + p.x) * MM_TO_WORLD, + 0, + (route.y + p.y) * MM_TO_WORLD, + ]); + + const start = points[0]!; + + return ( + + + {/* Marker at the curve's start, showing its direction. */} + + + + + + ); +} \ No newline at end of file diff --git a/packages/tabletop/src/surfaces/WorldSurfaceView.tsx b/packages/tabletop/src/surfaces/WorldSurfaceView.tsx index 3add4bb..bca13fe 100644 --- a/packages/tabletop/src/surfaces/WorldSurfaceView.tsx +++ b/packages/tabletop/src/surfaces/WorldSurfaceView.tsx @@ -13,6 +13,7 @@ import type { MountNode } from '../mount.js'; import { MM_TO_WORLD, DEG_TO_RAD } from '../part.js'; import { SurfaceBounds } from './SurfaceBounds.js'; import { SurfacePlane } from './SurfacePlane.js'; +import { StackingCurve } from './StackingCurve.js'; export function WorldSurfaceView({ pkg, @@ -48,6 +49,9 @@ export function SurfaceNode({ <> + {node.surface.layout.map((route, i) => + route.stacking ? : null, + )} )} {own.map((p) => (