From a4759be5ac1f5c80a8e4069ddb102ed545455698 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 8 Aug 2026 15:12:11 +0800 Subject: [PATCH] feat(web): trace token shape and texture the whole mesh TokenViewer now traces the image's alpha channel via /trace and extrudes the silhouette with @tts/mesh, aligning the texture using the full image as the UV framing. The texture is applied to the whole mesh (top, back, and walls) instead of separate solid-white walls. --- apps/web/package.json | 1 + apps/web/src/api.ts | 20 ++- .../src/components/viewers/TokenViewer.tsx | 141 +++++++++++++++++- apps/web/vite.config.ts | 1 + pnpm-lock.yaml | 9 ++ 5 files changed, 166 insertions(+), 6 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index bc886ce..53a3ffb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "@tts/extract": "workspace:*", "@tts/mesh": "workspace:*", "@tts/shared": "workspace:*", + "bson": "^7.3.1", "react": "^19.2.8", "react-dom": "^19.2.8", "react-router-dom": "^7.18.2", diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 0e93955..51e65e3 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1,4 +1,5 @@ -import type { SearchResult, TTSMod } from '@tts/shared'; +import { deserialize } from 'bson'; +import type { SearchResult, TraceResult, TTSMod } from '@tts/shared'; const BASE = ''; @@ -17,6 +18,23 @@ export function searchWorkshop(q: string, page = 1): Promise { return getJson(`/search?${params.toString()}`); } +/** + * Trace an image into a vector shape, BSON-deserializing the proxy response. + * `mode` controls how the region is derived (`alpha`, `bw`, `color`). + */ +export async function traceImage( + url: string, + mode: 'alpha' | 'bw' | 'color' = 'alpha', +): Promise { + const params = new URLSearchParams({ url, mode }); + const res = await fetch(`${BASE}/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; +} + /** Fetch a full parsed TTS save. */ export function fetchMod(id: string, fileUrl?: string): Promise { const params = new URLSearchParams(); diff --git a/apps/web/src/components/viewers/TokenViewer.tsx b/apps/web/src/components/viewers/TokenViewer.tsx index 1bbb003..d2b046b 100644 --- a/apps/web/src/components/viewers/TokenViewer.tsx +++ b/apps/web/src/components/viewers/TokenViewer.tsx @@ -1,11 +1,24 @@ import { useTexture } from '@react-three/drei'; +import { useEffect, useMemo, useState } from 'react'; +import * as THREE from 'three'; import type { TTSObject } from '@tts/shared'; +import { + extrudeShape, + type ExtrudedGeometry, + type Shape, + type UVBounds, +} from '@tts/mesh'; +import { traceImage } from '../../api'; import Scene from './Scene'; import { assetUrl } from './assetUrl'; +const TOKEN_SIZE = 1.8; + /** - * A round token: a short cylinder with the texture on its top face. Uses - * `CustomImage.ImageURL`, with a neutral color when absent. + * A token: a short extruded shape with the texture on its top face. Uses + * `CustomImage.ImageURL` (falling back to `ImageSecondaryURL`), with a neutral + * color when absent. The footprint is traced from the image's alpha channel via + * the proxy `/trace` endpoint, so the token matches the artwork's silhouette. */ export default function TokenViewer({ object }: { object: TTSObject }) { const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL; @@ -20,10 +33,49 @@ export default function TokenViewer({ object }: { object: TTSObject }) { // Rendered inside the Canvas so `useTexture` can access the R3F store. function TokenMesh({ url, thickness }: { url?: string; thickness: number }) { - const texture = url ? useTexture(assetUrl(url)) : null; + const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null; + + // Trace the image's alpha channel into a shape. Falls back to a circle while + // loading or when there's no image / the trace fails. + const [trace, setTrace] = useState<{ + shape: { outline: number[][]; holes?: number[][][] }; + width: number; + height: number; + } | null>(null); + useEffect(() => { + let cancelled = false; + setTrace(null); + if (!url) return; + traceImage(url, 'alpha') + .then((result) => { + if (!cancelled && result.shape) { + setTrace({ + shape: result.shape, + width: result.width, + height: result.height, + }); + } + }) + .catch(() => { + if (!cancelled) setTrace(null); + }); + return () => { + cancelled = true; + }; + }, [url]); + + const geometry = useMemo(() => { + // The traced shape and its UV framing share the same transform, so the + // full image rectangle maps to the same bounds in mesh coordinates. + const shape = trace ? toMeshShape(trace) : circleShape(); + const uvBounds = trace ? toUvBounds(trace) : undefined; + return toGeometry( + extrudeShape(shape, { height: thickness, uvBounds }), + ); + }, [trace, thickness]); + return ( - - + ); +} + +/** Convert raw extruded arrays into a three.js `BufferGeometry`. */ +function toGeometry(extruded: ExtrudedGeometry) { + 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)); + return geo; +} + +/** A circle fallback when there's no image to trace. */ +function circleShape(): Shape { + const pts: number[][] = []; + for (let i = 0; i < 48; i++) { + const a = (i / 48) * Math.PI * 2; + pts.push([Math.cos(a) * (TOKEN_SIZE / 2), Math.sin(a) * (TOKEN_SIZE / 2)]); + } + return { outline: pts }; +} + +/** + * Convert a traced shape (image pixel coords, origin top-left, y-down) to a + * mesh `Shape` (y-up, centered at the origin). Flips the y-axis, scales to + * `TOKEN_SIZE`, centers the result, and normalizes winding so the outline is + * counter-clockwise and holes are clockwise (as `@tts/mesh` expects). + */ +function toMeshShape(trace: { + shape: { outline: number[][]; holes?: number[][][] }; + width: number; + height: number; +}): Shape { + const { shape, width, height } = trace; + const scale = TOKEN_SIZE / Math.max(width, height); + const ox = (width * scale) / 2; + const oy = (height * scale) / 2; + const transform = (pts: number[][]) => + pts.map(([x, y]) => [x! * scale - ox, (height - 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 the traced silhouette (which may be smaller than the + * image when there is transparent padding). + */ +function toUvBounds(trace: { + width: number; + height: number; +}): UVBounds { + const scale = TOKEN_SIZE / Math.max(trace.width, 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 a + * counter-clockwise ring (outline); false yields clockwise (hole). + */ +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/apps/web/vite.config.ts b/apps/web/vite.config.ts index 85fe1c2..508f973 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ '/items': 'http://localhost:3000', '/health': 'http://localhost:3000', '/asset': 'http://localhost:3000', + '/trace': 'http://localhost:3000', }, }, }); \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2795e3..68386f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: '@tts/shared': specifier: workspace:* version: link:../../packages/shared + bson: + specifier: ^7.3.1 + version: 7.3.1 react: specifier: ^19.2.8 version: 19.2.8 @@ -907,6 +910,10 @@ packages: resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==} engines: {node: '>=16.20.1'} + bson@7.3.1: + resolution: {integrity: sha512-h/C0qe6857pQhcSJHLfsR1uYGj98Ge3wKAD3Ed9KqH3wcVh+BM4Jq4xISD7vs9OPuT07n+q3QQVjslJ286j6ag==} + engines: {node: '>=20.19.0'} + buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -2101,6 +2108,8 @@ snapshots: bson@6.10.4: {} + bson@7.3.1: {} + buffer@6.0.3: dependencies: base64-js: 1.5.1