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:
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user