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:
@@ -10,24 +10,35 @@ import type { Package } from '@tts/bgm';
|
||||
import { useRenderState, placementKey } from '../state.js';
|
||||
import { PartPlacement } from '../placement.js';
|
||||
import type { MountNode } from '../mount.js';
|
||||
import { MM_TO_WORLD } from '../part.js';
|
||||
import { SurfaceBounds } from './SurfaceBounds.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 own = placements.filter((p) => p.surface === node.id);
|
||||
|
||||
return (
|
||||
<Html
|
||||
position={[node.x, 0, node.y]}
|
||||
position={[node.x * MM_TO_WORLD, 0, node.y * MM_TO_WORLD]}
|
||||
transform
|
||||
distanceFactor={1}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{showSurface && <SurfaceBounds surface={node.surface} />}
|
||||
{own.map((p) => (
|
||||
<PartPlacement key={placementKey(p)} pkg={pkg} placement={p} />
|
||||
))}
|
||||
{node.children.map((child) => (
|
||||
<SurfaceNode key={child.id} pkg={pkg} node={child} />
|
||||
<SurfaceNode key={child.id} pkg={pkg} node={child} showSurface={showSurface} />
|
||||
))}
|
||||
</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 { PartPlacement } from '../placement.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 }) {
|
||||
return <SurfaceNode pkg={pkg} node={node} />;
|
||||
export function WorldSurfaceView({
|
||||
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. */
|
||||
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 own = placements.filter((p) => p.surface === node.id);
|
||||
|
||||
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) => (
|
||||
<PartPlacement key={placementKey(p)} pkg={pkg} placement={p} />
|
||||
))}
|
||||
{node.children.map((child) => (
|
||||
<SurfaceNode key={child.id} pkg={pkg} node={child} />
|
||||
<SurfaceNode key={child.id} pkg={pkg} node={child} showSurface={showSurface} />
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user