diff --git a/apps/web/package.json b/apps/web/package.json index 2dac665..5ae94e1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/pages/PartPage.tsx b/apps/web/src/pages/PartPage.tsx index e523748..f2d5014 100644 --- a/apps/web/src/pages/PartPage.tsx +++ b/apps/web/src/pages/PartPage.tsx @@ -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` : ''}

+ {/* 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. */} + + + + + + +
             {JSON.stringify(found, null, 2)}
           
diff --git a/apps/web/src/tabletopHttp.ts b/apps/web/src/tabletopHttp.ts new file mode 100644 index 0000000..ebef2de --- /dev/null +++ b/apps/web/src/tabletopHttp.ts @@ -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, +}; \ No newline at end of file diff --git a/packages/tabletop/package.json b/packages/tabletop/package.json new file mode 100644 index 0000000..f5e4c57 --- /dev/null +++ b/packages/tabletop/package.json @@ -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" + } +} \ No newline at end of file diff --git a/packages/tabletop/src/ErrorBoundary.tsx b/packages/tabletop/src/ErrorBoundary.tsx new file mode 100644 index 0000000..c95daa0 --- /dev/null +++ b/packages/tabletop/src/ErrorBoundary.tsx @@ -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 { + 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) + : ; + } + return this.props.children; + } +} + +function DefaultFallback({ error }: { error: Error }) { + return ( +
+

Couldn't render this part

+

+ {error.message} +

+ {error.stack && ( +
+          {error.stack}
+        
+ )} +
+ ); +} \ No newline at end of file diff --git a/packages/tabletop/src/http.test.ts b/packages/tabletop/src/http.test.ts new file mode 100644 index 0000000..115d2c8 --- /dev/null +++ b/packages/tabletop/src/http.test.ts @@ -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'); + }); +}); \ No newline at end of file diff --git a/packages/tabletop/src/http.ts b/packages/tabletop/src/http.ts new file mode 100644 index 0000000..2b304ac --- /dev/null +++ b/packages/tabletop/src/http.ts @@ -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 { + 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; +} \ No newline at end of file diff --git a/packages/tabletop/src/index.ts b/packages/tabletop/src/index.ts new file mode 100644 index 0000000..dee3a27 --- /dev/null +++ b/packages/tabletop/src/index.ts @@ -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'; \ No newline at end of file diff --git a/packages/tabletop/src/part.test.ts b/packages/tabletop/src/part.test.ts new file mode 100644 index 0000000..79900c5 --- /dev/null +++ b/packages/tabletop/src/part.test.ts @@ -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 }); + }); +}); \ No newline at end of file diff --git a/packages/tabletop/src/part.ts b/packages/tabletop/src/part.ts new file mode 100644 index 0000000..a7b04fe --- /dev/null +++ b/packages/tabletop/src/part.ts @@ -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; +} \ No newline at end of file diff --git a/packages/tabletop/src/partView.tsx b/packages/tabletop/src/partView.tsx new file mode 100644 index 0000000..b2e34aa --- /dev/null +++ b/packages/tabletop/src/partView.tsx @@ -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>(); + +/** + * 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 ``) 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 ; +} + +/** 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 ( + + + + + + + + + + + + ); +} + +// Shared geometry cache so repeated parts reuse buffers. +const geometryCache = new Map(); + +/** 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; +} \ No newline at end of file diff --git a/packages/tabletop/src/provider.tsx b/packages/tabletop/src/provider.tsx new file mode 100644 index 0000000..633c248 --- /dev/null +++ b/packages/tabletop/src/provider.tsx @@ -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; + +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(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 {children}; +} + +/** The active HTTP handlers for the current subtree. */ +export function useTabletopHttp(): TabletopHttp { + return useContext(TabletopHttpContext); +} \ No newline at end of file diff --git a/packages/tabletop/tsconfig.json b/packages/tabletop/tsconfig.json new file mode 100644 index 0000000..3ce634c --- /dev/null +++ b/packages/tabletop/tsconfig.json @@ -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"] +} \ No newline at end of file diff --git a/packages/tabletop/vitest.config.ts b/packages/tabletop/vitest.config.ts new file mode 100644 index 0000000..2479fb2 --- /dev/null +++ b/packages/tabletop/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}); \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 664df80..27bf14a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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':