feat(tabletop): inject proxy handlers from web app

This commit is contained in:
2026-08-09 21:46:36 +08:00
parent eefce5487d
commit 521519ce3d
15 changed files with 670 additions and 0 deletions
+1
View File
@@ -22,6 +22,7 @@
"@tts/mesh": "workspace:*",
"@tts/shared": "workspace:*",
"@tts/bgm": "workspace:*",
"@tts/tabletop": "workspace:*",
"bson": "^7.3.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
+13
View File
@@ -1,7 +1,10 @@
import { useParams } from 'react-router-dom';
import { PartView, ErrorBoundary, TabletopProvider } from '@tts/tabletop';
import Breadcrumbs from '../components/Breadcrumbs';
import PackageMissing from '../components/PackageMissing';
import Scene from '../components/viewers/Scene';
import { findPackage } from './bgm';
import { tabletopHttp } from '../tabletopHttp';
/** Detail view for a single part within a package. */
export default function PartPage() {
@@ -35,6 +38,16 @@ export default function PartPage() {
{found.fillet ? ` · fillet ${found.fillet}mm` : ''}
</p>
</div>
{/* The boundary wraps the Canvas (Scene), not PartView, so its HTML
fallback renders outside the r3f namespace. The provider wires the
library's proxy calls to the web app's proxy. */}
<ErrorBoundary>
<TabletopProvider http={tabletopHttp}>
<Scene>
<PartView part={found} baseUrl={found.baseUrl} />
</Scene>
</TabletopProvider>
</ErrorBoundary>
<pre className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-xs text-zinc-400">
{JSON.stringify(found, null, 2)}
</pre>
+13
View File
@@ -0,0 +1,13 @@
import type { TabletopHttp } from '@tts/tabletop';
import { traceImage as apiTraceImage } from './api';
import { assetUrl as viewerAssetUrl } from './components/viewers/assetUrl';
/**
* The tabletop library's HTTP handlers, wired to the web app's proxy. The
* library defaults to the conventional `/asset` and `/trace` paths, but the web
* app provides its own so the coupling is explicit and testable.
*/
export const tabletopHttp: TabletopHttp = {
assetUrl: viewerAssetUrl,
traceImage: apiTraceImage,
};
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@tts/tabletop",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"lint": "echo \"no lint configured\""
},
"dependencies": {
"@react-three/drei": "^10.7.8",
"@react-three/fiber": "^9.7.0",
"@react-three/postprocessing": "^3.0.4",
"@tts/bgm": "workspace:*",
"@tts/mesh": "workspace:*",
"bson": "^7.3.1",
"react": "^19.2.8",
"three": "^0.185.1",
"zustand": "^5.0.14"
},
"devDependencies": {
"@types/react": "^19.2.18",
"@types/three": "^0.185.4",
"typescript": "^5.7.2",
"vitest": "^4.1.10"
}
}
+54
View File
@@ -0,0 +1,54 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
interface Props {
children: ReactNode;
/** Optional fallback rendered in place of `children` when an error is caught. */
fallback?: (error: Error) => ReactNode;
}
interface State {
error: Error | null;
}
/**
* Catches render errors in its subtree and renders a fallback instead of
* letting them crash the whole page. Used to isolate failures in part viewers
* (e.g. WebGL context loss, texture/trace load errors).
*/
export default class ErrorBoundary extends Component<Props, State> {
override state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
override componentDidCatch(error: Error, info: ErrorInfo) {
// Surface the error for debugging without breaking the UI.
console.error('Part viewer error:', error, info.componentStack);
}
override render() {
if (this.state.error) {
return this.props.fallback
? this.props.fallback(this.state.error)
: <DefaultFallback error={this.state.error} />;
}
return this.props.children;
}
}
function DefaultFallback({ error }: { error: Error }) {
return (
<div className="flex h-80 flex-col items-center justify-center gap-2 overflow-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4 text-center">
<p className="text-sm font-medium text-zinc-200">Couldn't render this part</p>
<p className="max-w-sm wrap-break-word font-mono text-xs text-zinc-400">
{error.message}
</p>
{error.stack && (
<pre className="mt-2 max-h-40 w-full overflow-auto whitespace-pre-wrap text-left font-mono text-[10px] leading-relaxed text-zinc-600">
{error.stack}
</pre>
)}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { resolveAssetUrl } from './http.js';
describe('resolveAssetUrl', () => {
it('passes absolute urls through unchanged', () => {
expect(resolveAssetUrl('https://example.com/a.png', 'harbor/parts/')).toBe(
'https://example.com/a.png',
);
expect(resolveAssetUrl('data:image/png;base64,AAAA', 'harbor/parts/')).toBe(
'data:image/png;base64,AAAA',
);
});
it('resolves a relative path against baseUrl', () => {
expect(resolveAssetUrl('./assets/tokens.png', 'harbor/parts/')).toBe(
'harbor/parts/assets/tokens.png',
);
});
it('returns the relative path as-is without a baseUrl', () => {
expect(resolveAssetUrl('./assets/tokens.png')).toBe('./assets/tokens.png');
});
});
+53
View File
@@ -0,0 +1,53 @@
/**
* HTTP helpers for loading part assets. The only coupling to the host app's
* HTTP surface: the asset proxy (`/asset`) and the trace endpoint (`/trace`).
* Kept as plain functions so they're testable without react-three.
*/
import { deserialize } from 'bson';
/**
* Resolve a part's asset URL. Absolute URLs (http/https, data:, blob:) pass
* through unchanged; relative paths are resolved against `baseUrl` — the
* directory of the part's source file (the json/yaml/markdown that defined
* it). Without a `baseUrl`, a relative path is returned as-is.
*/
export function resolveAssetUrl(url: string, baseUrl?: string): string {
if (/^(https?:|data:|blob:)/i.test(url)) return url;
if (!baseUrl) return url;
// `baseUrl` is a path relative to the games root (no scheme), so join rather
// than resolve as a URL. A fake origin lets `new URL` normalize `./` and
// `../`; the origin is stripped from the result.
const resolved = new URL(url, `http://localhost/${baseUrl}`).pathname;
return resolved.replace(/^\/+/, '');
}
/** Route an external asset URL through the proxy so three.js can load it. */
export function assetUrl(url: string): string {
return `/asset?url=${encodeURIComponent(url)}`;
}
export interface TraceResult {
width: number;
height: number;
shape?: { outline: number[][]; holes?: number[][][] };
}
/**
* Trace an image into a vector shape, BSON-deserializing the proxy response.
* `mode` controls how the region is derived (`alpha`, `bw`, `color`); a
* non-zero `offset` insets (negative) or outsets (positive) the shape.
*/
export async function traceImage(
url: string,
mode: 'alpha' | 'bw' | 'color' = 'alpha',
offset?: number,
): Promise<TraceResult> {
const params = new URLSearchParams({ url, mode });
if (offset !== undefined) params.set('offset', String(offset));
const res = await fetch(`/trace?${params.toString()}`);
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(body?.error ?? `Trace failed (${res.status})`);
}
return deserialize(new Uint8Array(await res.arrayBuffer())) as TraceResult;
}
+12
View File
@@ -0,0 +1,12 @@
export { PartView, PartMesh } from './partView.js';
export { default as ErrorBoundary } from './ErrorBoundary.js';
export { TabletopProvider, useTabletopHttp } from './provider.js';
export type { TabletopHttp, AssetUrlFn, TraceImageFn } from './provider.js';
export {
partDimensions,
spriteUvFromCrop,
fallbackShape,
traceToShape,
traceToUvBounds,
} from './part.js';
export { resolveAssetUrl, assetUrl, traceImage } from './http.js';
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import type { Part } from '@tts/bgm';
import {
partDimensions,
spriteUvFromCrop,
fallbackShape,
traceToShape,
traceToUvBounds,
} from './part.js';
describe('partDimensions', () => {
it('converts mm size to world units', () => {
const part: Part = { type: 'card', id: 'as', size: [63, 88, 3] };
expect(partDimensions(part)).toEqual({ width: 63 / 30, height: 88 / 30, depth: 3 / 30 });
});
it('defaults when no size', () => {
const part: Part = { type: 'token', id: 'wood' };
expect(partDimensions(part)).toEqual({ width: 2, height: 2, depth: 0.1 });
});
});
describe('spriteUvFromCrop', () => {
it('returns full-image UVs without a crop', () => {
expect(spriteUvFromCrop(undefined)).toEqual({ repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 });
});
it('selects a sprite cell from a crop', () => {
// [col, row, cols, rows] — cell at col 1, row 2 of a 13x5 sheet.
expect(spriteUvFromCrop([1, 2, 13, 5])).toEqual({
repeatX: 1 / 13,
repeatY: 1 / 5,
offsetX: 1 / 13,
offsetY: 1 - 3 / 5,
});
});
});
describe('fallbackShape', () => {
it('returns a rect without a fillet', () => {
const part: Part = { type: 'tile', id: 'x', size: [40, 20, 3] };
const shape = fallbackShape(part, 40 / 30, 20 / 30);
expect(shape.outline).toHaveLength(4);
});
it('returns a rounded rect with a fillet', () => {
const part: Part = { type: 'card', id: 'as', size: [63, 88, 3], fillet: 2 };
const shape = fallbackShape(part, 63 / 30, 88 / 30);
// 4 corners x 4 segments.
expect(shape.outline.length).toBeGreaterThan(4);
});
});
describe('traceToShape', () => {
it('flips y and centers the traced shape', () => {
const trace = {
shape: { outline: [[0, 0], [10, 0], [10, 10], [0, 10]] },
width: 10,
height: 10,
};
const shape = traceToShape(trace, 2, 2);
// Centered at origin, scaled to fit the 2x2 box. The y-flip reverses
// winding, so the outline is normalized to CCW (order may differ).
expect(shape.outline).toHaveLength(4);
expect(shape.outline).toEqual(
expect.arrayContaining([
[-1, 1],
[1, 1],
[1, -1],
[-1, -1],
]),
);
});
});
describe('traceToUvBounds', () => {
it('maps the full image rectangle to the part box', () => {
const bounds = traceToUvBounds({ width: 10, height: 20 }, 2, 4);
expect(bounds).toEqual({ minX: -1, minY: -2, maxX: 1, maxY: 2 });
});
});
+112
View File
@@ -0,0 +1,112 @@
/**
* Pure helpers for rendering a bgm `Part` as a mesh. Kept free of react-three
* so they can be unit-tested in a plain node environment (mirroring the web
* app's `cardResolution.ts`).
*/
import type { Part, Crop } from '@tts/bgm';
import { rectShape, roundedRectShape, type Shape } from '@tts/mesh';
/** Scale from mm (the format's `size` unit) to world units. */
export const MM_TO_WORLD = 1 / 30;
/** Default part size `[w, h, d]` in mm when a part has no `size`. */
const DEFAULT_SIZE: [number, number, number] = [60, 60, 3];
/** A part's dimensions in world units, derived from its `size` (mm). */
export function partDimensions(part: Part): { width: number; height: number; depth: number } {
const [w, h, d] = part.size ?? DEFAULT_SIZE;
return {
width: w * MM_TO_WORLD,
height: h * MM_TO_WORLD,
depth: d * MM_TO_WORLD,
};
}
/**
* UV repeat/offset that selects a single sprite from a sheet, given a crop
* `[col, row, cols, rows]` that divides the image into a `cols` x `rows` grid
* and picks the cell at `[col, row]`. Row 0 is the top of the image (v=1), so
* the offset counts down from 1. Without a crop, the whole image is shown.
*/
export function spriteUvFromCrop(
crop: Crop | undefined,
): { repeatX: number; repeatY: number; offsetX: number; offsetY: number } {
if (!crop) return { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 };
const [col, row, cols, rows] = crop;
return {
repeatX: 1 / cols,
repeatY: 1 / rows,
offsetX: col / cols,
offsetY: 1 - (row + 1) / rows,
};
}
/**
* The fallback footprint for a part without a `shape` sprite: a rounded rect
* when `fillet` is set, otherwise a plain rect, sized to the part's world
* dimensions. The fillet (mm) is converted to world units and clamped to half
* the smaller dimension.
*/
export function fallbackShape(part: Part, width: number, height: number): Shape {
const fillet = (part.fillet ?? 0) * MM_TO_WORLD;
if (fillet > 0) {
return roundedRectShape(width, height, Math.min(fillet, Math.min(width, height) / 2));
}
return rectShape(width, height);
}
/**
* Convert a traced shape (image pixel coords, origin top-left, y-down) to a
* mesh `Shape` (y-up, centered at the origin), scaled to fit the part's world
* `width` x `height` box. Flips the y-axis, centers the result, and normalizes
* winding so the outline is counter-clockwise and holes are clockwise (as
* `@tts/mesh` expects).
*/
export function traceToShape(
trace: { shape: { outline: number[][]; holes?: number[][][] }; width: number; height: number },
width: number,
height: number,
): Shape {
const { shape, width: tw, height: th } = trace;
const scale = Math.min(width / tw, height / th);
const ox = (tw * scale) / 2;
const oy = (th * scale) / 2;
const transform = (pts: number[][]) => pts.map(([x, y]) => [x! * scale - ox, (th - y!) * scale - oy]);
return {
outline: normalizeWinding(transform(shape.outline), true),
holes: shape.holes?.map((h) => normalizeWinding(transform(h), false)),
};
}
/**
* The full image rectangle, in mesh coordinates, used as the UV framing so the
* texture aligns with a traced silhouette (which may be smaller than the image
* when there is transparent padding).
*/
export function traceToUvBounds(
trace: { width: number; height: number },
width: number,
height: number,
): { minX: number; minY: number; maxX: number; maxY: number } {
const scale = Math.min(width / trace.width, height / trace.height);
const ox = (trace.width * scale) / 2;
const oy = (trace.height * scale) / 2;
return { minX: -ox, minY: -oy, maxX: ox, maxY: oy };
}
/** Ensure a ring has the requested winding. `ccw` true yields CCW (outline). */
function normalizeWinding(pts: number[][], ccw: boolean): number[][] {
const isCcw = signedArea(pts) > 0;
return isCcw === ccw ? pts : [...pts].reverse();
}
/** Signed area of a polygon; positive means counter-clockwise. */
function signedArea(points: number[][]): number {
let area = 0;
for (let i = 0; i < points.length; i++) {
const [x1, y1] = points[i]!;
const [x2, y2] = points[(i + 1) % points.length]!;
area += x1! * y2! - x2! * y1!;
}
return area / 2;
}
+160
View File
@@ -0,0 +1,160 @@
/**
* `PartView` — render a bgm `Part` definition as a 3D mesh.
*
* Builds the mesh from the part's definition using `@tts/mesh` geometry:
* - `size` → world dimensions; `fillet` → corner radius.
* - `face`/`faceCrop`/`back`/`backCrop` → textures with sprite UVs.
* - `shape` → traced silhouette (via the proxy `/trace`); otherwise a
* rect/rounded-rect fallback.
*
* A standalone component library: it reuses `@tts/mesh` geometry, not the web
* app's viewers. The trace endpoint and asset proxy are the only coupling to
* the host app's HTTP surface.
*/
import { useTexture } from '@react-three/drei';
import { useMemo } from 'react';
import * as THREE from 'three';
import type { Part } from '@tts/bgm';
import { extrudeShapeParts, type ExtrudedGeometry, type UVBounds } from '@tts/mesh';
import {
fallbackShape,
partDimensions,
spriteUvFromCrop,
traceToShape,
traceToUvBounds,
} from './part.js';
import { resolveAssetUrl } from './http.js';
import { useTabletopHttp, type TraceImageFn } from './provider.js';
interface TraceData {
shape: { outline: number[][]; holes?: number[][][] };
width: number;
height: number;
}
// Cache traces by URL so Suspense doesn't re-issue the request on every render.
// A URL maps to either a pending promise (while loading) or the resolved value.
const traceCache = new Map<string, TraceData | null | Promise<TraceData | null>>();
/**
* Suspend on the alpha trace for `url`, resolving to the traced shape (or null
* when there's no URL / the trace fails). Throws the cached promise only while
* it's pending; once resolved, the value is returned directly.
*/
export function useTrace(url: string | undefined, traceImage: TraceImageFn): TraceData | null {
if (!url) return null;
const cached = traceCache.get(url);
if (cached === undefined) {
const promise = traceImage(url, 'alpha', -2).then((result) => {
const value: TraceData | null = result.shape
? { shape: result.shape, width: result.width, height: result.height }
: null;
traceCache.set(url, value);
return value;
});
traceCache.set(url, promise);
throw promise;
}
if (cached instanceof Promise) throw cached;
return cached;
}
/** A 1x1 transparent placeholder so `useTexture` always receives a URL. */
const FALLBACK_URL =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
/**
* Render a single part as a mesh. `PartView` is a canvas-internal mesh
* component — wrap it (or its containing `<Canvas>`) in `ErrorBoundary` to
* isolate render failures, since an HTML fallback can't render inside a canvas.
* `baseUrl` resolves relative asset paths against the part's source file
* directory (see `resolveAssetUrl`).
*/
export function PartView({ part, baseUrl }: { part: Part; baseUrl?: string }) {
return <PartMesh part={part} baseUrl={baseUrl} />;
}
/** The part mesh, exported for composition into a shared scene. */
export function PartMesh({ part, baseUrl }: { part: Part; baseUrl?: string }) {
const { assetUrl, traceImage } = useTabletopHttp();
const { width, height, depth } = partDimensions(part);
const faceUrl = part.face ? resolveAssetUrl(part.face, baseUrl) : undefined;
const backUrl = part.back ? resolveAssetUrl(part.back, baseUrl) : undefined;
const shapeUrl = part.shape ? resolveAssetUrl(part.shape, baseUrl) : undefined;
// Always call both hooks so the hook count is stable across renders.
const face = useTexture(faceUrl ? assetUrl(faceUrl) : FALLBACK_URL);
const back = useTexture(backUrl ? assetUrl(backUrl) : FALLBACK_URL);
const trace = useTrace(shapeUrl, traceImage);
const { frontGeo, backGeo, wallsGeo } = useMemo(() => {
const shape = trace ? traceToShape(trace, width, height) : fallbackShape(part, width, height);
const uvBounds: UVBounds | undefined = trace ? traceToUvBounds(trace, width, height) : undefined;
const parts = extrudeShapeParts(shape, { height: depth, uvBounds });
const key = `part:${part.type}#${part.id}:${width}:${height}:${depth}`;
return {
frontGeo: toGeometry(parts.front, key + ':front'),
backGeo: toGeometry(parts.back, key + ':back'),
wallsGeo: toGeometry(parts.walls, key + ':walls'),
};
}, [part, trace, width, height, depth]);
// Face texture: the sprite cell from the sheet (or the full image when there
// is no crop). Cloned so the sprite offset/repeat don't leak into other parts
// that share the same sheet URL.
const faceMap = useMemo(() => {
if (!faceUrl) return null;
const tex = face.clone();
const { repeatX, repeatY, offsetX, offsetY } = spriteUvFromCrop(part.faceCrop);
tex.repeat.set(repeatX, repeatY);
tex.offset.set(offsetX, offsetY);
return tex;
}, [faceUrl, face, part.faceCrop]);
// Back texture: the sprite cell (or full image), flipped so it reads
// correctly instead of being mirrored on the back face.
const backMap = useMemo(() => {
if (!backUrl) return null;
const tex = back.clone();
const { repeatX, repeatY, offsetX, offsetY } = spriteUvFromCrop(part.backCrop);
tex.repeat.set(repeatX, repeatY);
tex.offset.set(offsetX, offsetY);
tex.repeat.x = -tex.repeat.x;
tex.offset.x -= tex.repeat.x;
return tex;
}, [backUrl, back, part.backCrop]);
const wallColor = new THREE.Color('#ffffff');
return (
<group>
<mesh geometry={frontGeo}>
<meshStandardMaterial color={faceMap ? '#ffffff' : '#52525b'} map={faceMap ?? undefined} roughness={0.6} />
</mesh>
<mesh geometry={backGeo}>
<meshStandardMaterial color={backMap ? '#ffffff' : '#52525b'} map={backMap ?? undefined} roughness={0.6} />
</mesh>
<mesh geometry={wallsGeo}>
<meshStandardMaterial color={wallColor} roughness={0.6} />
</mesh>
</group>
);
}
// Shared geometry cache so repeated parts reuse buffers.
const geometryCache = new Map<string, THREE.BufferGeometry>();
/** Convert raw extruded arrays into a cached three.js `BufferGeometry`. */
function toGeometry(extruded: ExtrudedGeometry, key: string): THREE.BufferGeometry {
const cached = geometryCache.get(key);
if (cached) return cached;
const { positions, normals, uvs, indices } = extruded;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
geo.setIndex(new THREE.BufferAttribute(indices, 1));
geometryCache.set(key, geo);
return geo;
}
+47
View File
@@ -0,0 +1,47 @@
import { createContext, useContext, type ReactNode } from 'react';
import { assetUrl as defaultAssetUrl, traceImage as defaultTraceImage, type TraceResult } from './http.js';
/** Build a proxy URL for an external asset (texture, model, etc.). */
export type AssetUrlFn = (url: string) => string;
/** Trace an image into a vector shape via the proxy. */
export type TraceImageFn = (
url: string,
mode?: 'alpha' | 'bw' | 'color',
offset?: number,
) => Promise<TraceResult>;
export interface TabletopHttp {
/** Route an asset URL through the host's proxy. */
assetUrl: AssetUrlFn;
/** Trace an image into a shape via the host's proxy. */
traceImage: TraceImageFn;
}
const defaultHttp: TabletopHttp = {
assetUrl: defaultAssetUrl,
traceImage: defaultTraceImage,
};
const TabletopHttpContext = createContext<TabletopHttp>(defaultHttp);
/**
* Provide the HTTP handlers the tabletop library uses to reach the host's
* proxy (`/asset` and `/trace`). The library defaults to its own handlers that
* hit the conventional proxy paths; a host app can override them (e.g. to point
* at a different base URL or inject auth).
*/
export function TabletopProvider({
http = defaultHttp,
children,
}: {
http?: TabletopHttp;
children: ReactNode;
}) {
return <TabletopHttpContext.Provider value={http}>{children}</TabletopHttpContext.Provider>;
}
/** The active HTTP handlers for the current subtree. */
export function useTabletopHttp(): TabletopHttp {
return useContext(TabletopHttpContext);
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
});
+46
View File
@@ -93,6 +93,9 @@ importers:
'@tts/shared':
specifier: workspace:*
version: link:../../packages/shared
'@tts/tabletop':
specifier: workspace:*
version: link:../../packages/tabletop
bson:
specifier: ^7.3.1
version: 7.3.1
@@ -216,6 +219,49 @@ importers:
specifier: ^5.7.2
version: 5.9.3
packages/tabletop:
dependencies:
'@react-three/drei':
specifier: ^10.7.8
version: 10.7.8(@react-three/fiber@9.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(three@0.185.1))(@types/react@19.2.18)(@types/three@0.185.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(three@0.185.1)
'@react-three/fiber':
specifier: ^9.7.0
version: 9.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(three@0.185.1)
'@react-three/postprocessing':
specifier: ^3.0.4
version: 3.0.4(@react-three/fiber@9.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(three@0.185.1))(@types/three@0.185.4)(react@19.2.8)(three@0.185.1)
'@tts/bgm':
specifier: workspace:*
version: link:../bgm
'@tts/mesh':
specifier: workspace:*
version: link:../mesh
bson:
specifier: ^7.3.1
version: 7.3.1
react:
specifier: ^19.2.8
version: 19.2.8
three:
specifier: ^0.185.1
version: 0.185.1
zustand:
specifier: ^5.0.14
version: 5.0.14(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))
devDependencies:
'@types/react':
specifier: ^19.2.18
version: 19.2.18
'@types/three':
specifier: ^0.185.4
version: 0.185.4
typescript:
specifier: ^5.7.2
version: 5.9.3
vitest:
specifier: ^4.1.10
version: 4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)(yaml@2.9.0))
packages/tts:
dependencies:
'@tts/shared':