feat(tabletop): show surface bounds and lay parts flat

Add a toggleable surface plane, bounds outline, and constant-size HTML
label. Scale placement and mount anchors to world units so parts land on
the surface, and rotate parts to face up.
This commit is contained in:
2026-08-09 23:04:51 +08:00
parent abf901418b
commit 5808e15c45
6 changed files with 116 additions and 11 deletions
+2
View File
@@ -33,6 +33,8 @@ export { resolveMountTree } from './mount.js';
export type { MountNode, MountTree } from './mount.js'; export type { MountNode, MountTree } from './mount.js';
export { WorldSurfaceView, SurfaceNode } from './surfaces/WorldSurfaceView.js'; export { WorldSurfaceView, SurfaceNode } from './surfaces/WorldSurfaceView.js';
export { HudSurfaceView } from './surfaces/HudSurfaceView.js'; export { HudSurfaceView } from './surfaces/HudSurfaceView.js';
export { SurfaceBounds } from './surfaces/SurfaceBounds.js';
export { SurfacePlane } from './surfaces/SurfacePlane.js';
// Part placement. // Part placement.
export { PartPlacement } from './placement.js'; export { PartPlacement } from './placement.js';
+8 -2
View File
@@ -9,6 +9,7 @@ import type { Package } from '@tts/bgm';
import { useStacking } from './stacking.js'; import { useStacking } from './stacking.js';
import type { Placement } from './state.js'; import type { Placement } from './state.js';
import { PartView } from './partView.js'; import { PartView } from './partView.js';
import { MM_TO_WORLD } from './part.js';
export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Placement }) { export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Placement }) {
const { route, candidate, piece, index, stackSize } = placement; const { route, candidate, piece, index, stackSize } = placement;
@@ -18,13 +19,18 @@ export function PartPlacement({ pkg, placement }: { pkg: Package; placement: Pla
const { x, y, rotation } = useStacking(route.stacking, index, stackSize); const { x, y, rotation } = useStacking(route.stacking, index, stackSize);
const anchorX = (candidate?.x ?? route.x ?? 0) + x; // Route anchors and stacking offsets are in mm; convert to world units so
const anchorY = (candidate?.y ?? route.y ?? 0) + y; // parts land on the (world-scaled) surface.
const anchorX = ((candidate?.x ?? route.x ?? 0) + x) * MM_TO_WORLD;
const anchorY = ((candidate?.y ?? route.y ?? 0) + y) * 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, 0, 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]}>
<PartView part={part} baseUrl={part.baseUrl} /> <PartView part={part} baseUrl={part.baseUrl} />
</group> </group>
</group>
); );
} }
@@ -10,24 +10,35 @@ import type { Package } from '@tts/bgm';
import { useRenderState, placementKey } from '../state.js'; import { useRenderState, placementKey } from '../state.js';
import { PartPlacement } from '../placement.js'; 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 { SurfaceBounds } from './SurfaceBounds.js';
import { SurfaceNode } from './WorldSurfaceView.js'; import { SurfaceNode } from './WorldSurfaceView.js';
export function HudSurfaceView({ pkg, node }: { pkg: Package; node: MountNode }) { export function HudSurfaceView({
pkg,
node,
showSurface = false,
}: {
pkg: Package;
node: MountNode;
showSurface?: boolean;
}) {
const placements = useRenderState(pkg); const placements = useRenderState(pkg);
const own = placements.filter((p) => p.surface === node.id); const own = placements.filter((p) => p.surface === node.id);
return ( return (
<Html <Html
position={[node.x, 0, node.y]} position={[node.x * MM_TO_WORLD, 0, node.y * MM_TO_WORLD]}
transform transform
distanceFactor={1} distanceFactor={1}
style={{ pointerEvents: 'none' }} style={{ pointerEvents: 'none' }}
> >
{showSurface && <SurfaceBounds surface={node.surface} />}
{own.map((p) => ( {own.map((p) => (
<PartPlacement key={placementKey(p)} pkg={pkg} placement={p} /> <PartPlacement key={placementKey(p)} pkg={pkg} placement={p} />
))} ))}
{node.children.map((child) => ( {node.children.map((child) => (
<SurfaceNode key={child.id} pkg={pkg} node={child} /> <SurfaceNode key={child.id} pkg={pkg} node={child} showSurface={showSurface} />
))} ))}
</Html> </Html>
); );
@@ -0,0 +1,34 @@
/**
* `SurfaceBounds` — a debug visualization of a surface's area.
*
* Draws a rectangle outline at the surface's reference `size` (mm, converted
* to world units) centered on the mount anchor, plus an HTML label with the
* surface's name. Used by the surface views when `showSurface` is enabled.
*/
import { Html, Line } from '@react-three/drei';
import type { Surface } from '@tts/bgm';
import { MM_TO_WORLD } from '../part.js';
export function SurfaceBounds({ surface }: { surface: Surface }) {
const [w, h] = surface.size ?? [0, 0];
const hw = (w * MM_TO_WORLD) / 2;
const hh = (h * MM_TO_WORLD) / 2;
const points: [number, number, number][] = [
[-hw, 0, -hh],
[hw, 0, -hh],
[hw, 0, hh],
[-hw, 0, hh],
[-hw, 0, -hh],
];
return (
<group>
{surface.size && <Line points={points} color="#22d3ee" lineWidth={1} />}
<Html position={[0, 0.05, 0]} center style={{ pointerEvents: 'none' }}>
<div className="rounded bg-zinc-900/80 px-1.5 py-0.5 text-[10px] text-cyan-300">
{surface.type}#{surface.id}
</div>
</Html>
</group>
);
}
@@ -0,0 +1,24 @@
/**
* `SurfacePlane` — a visible table surface.
*
* A flat rectangle at the surface's reference `size` (mm, converted to world
* units), centered on the mount anchor. Gives the parts a scale reference and
* reads as the table the game is played on. Rendered only when `showSurface`
* is enabled.
*/
import type { Surface } from '@tts/bgm';
import { MM_TO_WORLD } from '../part.js';
export function SurfacePlane({ surface }: { surface: Surface }) {
if (!surface.size) return null;
const [w, h] = surface.size;
const width = w * MM_TO_WORLD;
const height = h * MM_TO_WORLD;
return (
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[width, height]} />
<meshStandardMaterial color="#1c1f26" roughness={0.9} metalness={0} />
</mesh>
);
}
@@ -10,23 +10,51 @@ import type { Package } from '@tts/bgm';
import { useRenderState, placementKey } from '../state.js'; import { useRenderState, placementKey } from '../state.js';
import { PartPlacement } from '../placement.js'; 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 { SurfaceBounds } from './SurfaceBounds.js';
import { SurfacePlane } from './SurfacePlane.js';
export function WorldSurfaceView({ pkg, node }: { pkg: Package; node: MountNode }) { export function WorldSurfaceView({
return <SurfaceNode pkg={pkg} node={node} />; pkg,
node,
showSurface = false,
}: {
pkg: Package;
node: MountNode;
showSurface?: boolean;
}) {
return <SurfaceNode pkg={pkg} node={node} showSurface={showSurface} />;
} }
/** Render a mount node at its anchor, placing parts and recursing into children. */ /** Render a mount node at its anchor, placing parts and recursing into children. */
export function SurfaceNode({ pkg, node }: { pkg: Package; node: MountNode }) { export function SurfaceNode({
pkg,
node,
showSurface = false,
}: {
pkg: Package;
node: MountNode;
showSurface?: boolean;
}) {
const placements = useRenderState(pkg); const placements = useRenderState(pkg);
const own = placements.filter((p) => p.surface === node.id); const own = placements.filter((p) => p.surface === node.id);
return ( return (
<group position={[node.x, 0, node.y]} rotation={[0, node.rotation, 0]}> <group
position={[node.x * MM_TO_WORLD, 0, node.y * MM_TO_WORLD]}
rotation={[0, node.rotation, 0]}
>
{showSurface && (
<>
<SurfacePlane surface={node.surface} />
<SurfaceBounds surface={node.surface} />
</>
)}
{own.map((p) => ( {own.map((p) => (
<PartPlacement key={placementKey(p)} pkg={pkg} placement={p} /> <PartPlacement key={placementKey(p)} pkg={pkg} placement={p} />
))} ))}
{node.children.map((child) => ( {node.children.map((child) => (
<SurfaceNode key={child.id} pkg={pkg} node={child} /> <SurfaceNode key={child.id} pkg={pkg} node={child} showSurface={showSurface} />
))} ))}
</group> </group>
); );