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.
This commit is contained in:
@@ -21,6 +21,7 @@
|
|||||||
"@tts/extract": "workspace:*",
|
"@tts/extract": "workspace:*",
|
||||||
"@tts/mesh": "workspace:*",
|
"@tts/mesh": "workspace:*",
|
||||||
"@tts/shared": "workspace:*",
|
"@tts/shared": "workspace:*",
|
||||||
|
"bson": "^7.3.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-router-dom": "^7.18.2",
|
"react-router-dom": "^7.18.2",
|
||||||
|
|||||||
+19
-1
@@ -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 = '';
|
const BASE = '';
|
||||||
|
|
||||||
@@ -17,6 +18,23 @@ export function searchWorkshop(q: string, page = 1): Promise<SearchResult> {
|
|||||||
return getJson<SearchResult>(`/search?${params.toString()}`);
|
return getJson<SearchResult>(`/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<TraceResult> {
|
||||||
|
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. */
|
/** Fetch a full parsed TTS save. */
|
||||||
export function fetchMod(id: string, fileUrl?: string): Promise<TTSMod> {
|
export function fetchMod(id: string, fileUrl?: string): Promise<TTSMod> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
import { useTexture } from '@react-three/drei';
|
import { useTexture } from '@react-three/drei';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
import type { TTSObject } from '@tts/shared';
|
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 Scene from './Scene';
|
||||||
import { assetUrl } from './assetUrl';
|
import { assetUrl } from './assetUrl';
|
||||||
|
|
||||||
|
const TOKEN_SIZE = 1.8;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A round token: a short cylinder with the texture on its top face. Uses
|
* A token: a short extruded shape with the texture on its top face. Uses
|
||||||
* `CustomImage.ImageURL`, with a neutral color when absent.
|
* `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 }) {
|
export default function TokenViewer({ object }: { object: TTSObject }) {
|
||||||
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
|
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.
|
// Rendered inside the Canvas so `useTexture` can access the R3F store.
|
||||||
function TokenMesh({ url, thickness }: { url?: string; thickness: number }) {
|
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 (
|
return (
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
<mesh geometry={geometry}>
|
||||||
<cylinderGeometry args={[0.9, 0.9, thickness, 48]} />
|
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
color={texture ? '#ffffff' : '#52525b'}
|
color={texture ? '#ffffff' : '#52525b'}
|
||||||
map={texture ?? undefined}
|
map={texture ?? undefined}
|
||||||
@@ -32,3 +84,82 @@ function TokenMesh({ url, thickness }: { url?: string; thickness: number }) {
|
|||||||
</mesh>
|
</mesh>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
|||||||
'/items': 'http://localhost:3000',
|
'/items': 'http://localhost:3000',
|
||||||
'/health': 'http://localhost:3000',
|
'/health': 'http://localhost:3000',
|
||||||
'/asset': 'http://localhost:3000',
|
'/asset': 'http://localhost:3000',
|
||||||
|
'/trace': 'http://localhost:3000',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Generated
+9
@@ -84,6 +84,9 @@ importers:
|
|||||||
'@tts/shared':
|
'@tts/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
|
bson:
|
||||||
|
specifier: ^7.3.1
|
||||||
|
version: 7.3.1
|
||||||
react:
|
react:
|
||||||
specifier: ^19.2.8
|
specifier: ^19.2.8
|
||||||
version: 19.2.8
|
version: 19.2.8
|
||||||
@@ -907,6 +910,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==}
|
resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==}
|
||||||
engines: {node: '>=16.20.1'}
|
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:
|
buffer@6.0.3:
|
||||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||||
|
|
||||||
@@ -2101,6 +2108,8 @@ snapshots:
|
|||||||
|
|
||||||
bson@6.10.4: {}
|
bson@6.10.4: {}
|
||||||
|
|
||||||
|
bson@7.3.1: {}
|
||||||
|
|
||||||
buffer@6.0.3:
|
buffer@6.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
|
|||||||
Reference in New Issue
Block a user