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.
This commit is contained in:
@@ -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 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 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 (
|
return (
|
||||||
<group position={[anchorX, anchorZ, anchorY]} rotation={[0, anchorRotation, 0]}>
|
<group position={[anchorX, anchorZ, anchorY]} rotation={[0, anchorRotation, 0]}>
|
||||||
|
|||||||
@@ -41,6 +41,18 @@ describe('pointAt', () => {
|
|||||||
expect(mid.x).toBeCloseTo(5);
|
expect(mid.x).toBeCloseTo(5);
|
||||||
expect(mid.angle).toBeCloseTo(0);
|
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', () => {
|
describe('stackingOffset', () => {
|
||||||
|
|||||||
@@ -375,7 +375,7 @@ function sampleArc(
|
|||||||
/** Sample density per curve segment. */
|
/** Sample density per curve segment. */
|
||||||
const SEGMENTS = 32;
|
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 } {
|
export function pointAt(path: SampledPath, distance: number): { x: number; y: number; angle: number } {
|
||||||
const { points, length } = path;
|
const { points, length } = path;
|
||||||
if (points.length === 0) return { x: 0, y: 0, angle: 0 };
|
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 u = seg > 0 ? (d - a.t) / seg : 0;
|
||||||
const x = a.x + (b.x - a.x) * u;
|
const x = a.x + (b.x - a.x) * u;
|
||||||
const y = a.y + (b.y - a.y) * 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 };
|
return { x, y, angle };
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ import { PartPlacement } from '../placement.js';
|
|||||||
import type { MountNode } from '../mount.js';
|
import type { MountNode } from '../mount.js';
|
||||||
import { MM_TO_WORLD } from '../part.js';
|
import { MM_TO_WORLD } from '../part.js';
|
||||||
import { SurfaceBounds } from './SurfaceBounds.js';
|
import { SurfaceBounds } from './SurfaceBounds.js';
|
||||||
|
import { StackingCurve } from './StackingCurve.js';
|
||||||
import { SurfaceNode } from './WorldSurfaceView.js';
|
import { SurfaceNode } from './WorldSurfaceView.js';
|
||||||
|
|
||||||
export function HudSurfaceView({
|
export function HudSurfaceView({
|
||||||
@@ -33,7 +34,14 @@ export function HudSurfaceView({
|
|||||||
distanceFactor={1}
|
distanceFactor={1}
|
||||||
style={{ pointerEvents: 'none' }}
|
style={{ pointerEvents: 'none' }}
|
||||||
>
|
>
|
||||||
{showSurface && <SurfaceBounds surface={node.surface} />}
|
{showSurface && (
|
||||||
|
<>
|
||||||
|
<SurfaceBounds surface={node.surface} />
|
||||||
|
{node.surface.layout.map((route, i) =>
|
||||||
|
route.stacking ? <StackingCurve key={i} route={route} /> : null,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{own.map((p) => (
|
{own.map((p) => (
|
||||||
<PartPlacement key={placementKey(p)} pkg={pkg} placement={p} />
|
<PartPlacement key={placementKey(p)} pkg={pkg} placement={p} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<group>
|
||||||
|
<Line points={points} color="#f59e0b" lineWidth={2} />
|
||||||
|
{/* Marker at the curve's start, showing its direction. */}
|
||||||
|
<mesh position={[start[0], 0.01, start[2]]}>
|
||||||
|
<sphereGeometry args={[0.01, 8, 8]} />
|
||||||
|
<meshBasicMaterial color="#f59e0b" />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import type { MountNode } from '../mount.js';
|
|||||||
import { MM_TO_WORLD, DEG_TO_RAD } from '../part.js';
|
import { MM_TO_WORLD, DEG_TO_RAD } from '../part.js';
|
||||||
import { SurfaceBounds } from './SurfaceBounds.js';
|
import { SurfaceBounds } from './SurfaceBounds.js';
|
||||||
import { SurfacePlane } from './SurfacePlane.js';
|
import { SurfacePlane } from './SurfacePlane.js';
|
||||||
|
import { StackingCurve } from './StackingCurve.js';
|
||||||
|
|
||||||
export function WorldSurfaceView({
|
export function WorldSurfaceView({
|
||||||
pkg,
|
pkg,
|
||||||
@@ -48,6 +49,9 @@ export function SurfaceNode({
|
|||||||
<>
|
<>
|
||||||
<SurfacePlane surface={node.surface} />
|
<SurfacePlane surface={node.surface} />
|
||||||
<SurfaceBounds surface={node.surface} />
|
<SurfaceBounds surface={node.surface} />
|
||||||
|
{node.surface.layout.map((route, i) =>
|
||||||
|
route.stacking ? <StackingCurve key={i} route={route} /> : null,
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{own.map((p) => (
|
{own.map((p) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user