diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index e80b57c..2ec5fc3 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -1,6 +1,13 @@ { "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json", - "entry": ["src/site.{ts,tsx,js,jsx}"], + "entry": [ + "src/site.{ts,tsx,js,jsx}", + // Tool client modules load via an Astro + diff --git a/src/pages/tools/_3d-to-svg/emit.ts b/src/pages/tools/_3d-to-svg/emit.ts new file mode 100644 index 0000000..714c84f --- /dev/null +++ b/src/pages/tools/_3d-to-svg/emit.ts @@ -0,0 +1,287 @@ +import ClipperLib from 'clipper-lib'; + +import type {CulledScene, ShadedScene, Vec2} from './geometry'; +import {CANVAS} from './geometry'; + +/* + * Types. + */ + +export type EmitMode = 'flat' | 'shaded'; + +/* + * Constants. + */ + +/** + * The output is intentionally minimal: one fill, recolorable via currentColor. + * Accessibility (aria/title/role) is the consumer's call, so we emit none. + */ +const FILL = 'currentColor'; + +const CLIPPER_SCALE = 10; + +// Cel-shaded output: shadow → highlight opacity ramp over fixed bands. The floor +// stays high enough that even the brightest band reads (never near-transparent). +const BAND_OPACITY = [1, 0.85, 0.72, 0.6]; +const SHADE_CLOSE = 0.8; +const SHADE_TOLERANCE = 1.2; +const SHADE_MIN_AREA = 10; + +// Cel-shaded mode treats the bands as one opacity scale: the lightest step is a +// flat base over the whole silhouette (filling gaps), and every band is +// pre-divided so painting it over the base composites back onto that same scale. +// base + band·(1 − base) = target. The lightest band collapses into the base. +const SHADE_FLOOR = BAND_OPACITY[BAND_OPACITY.length - 1]; +const SHADE_BAND_OPACITY = BAND_OPACITY.map( + target => Math.round(((target - SHADE_FLOOR) / (1 - SHADE_FLOOR)) * 1000) / 1000 +); + +/* + * Helpers. + */ + +/** Emit a single- SVG string from a culled scene. */ +export function emitFlatSvg(scene: CulledScene): string { + const recentered = recenterRingToCanvas(flatRing(scene)); + if (ringArea(recentered) <= 3) { + throw new Error('Flat silhouette union produced no paths'); + } + return wrapSvg(``); +} + +/** Emit a cel-shaded SVG: shade bands over a solid base holding the lowest step. */ +export function emitShadedSvg(culled: CulledScene, shaded: ShadedScene): string { + const base = ``; + const layers = bandLayers(shaded, SHADE_BAND_OPACITY); + return wrapSvg(base + layers.join('')); +} + +/** + * Sort faces into opacity bands, stretching each pose's shade range across the + * full band spread so every angle reads with the same light→dark depth. + */ +function bucketFaces(scene: ShadedScene): Vec2[][][] { + let minShade = Infinity; + let maxShade = -Infinity; + for (const face of scene.faces) { + minShade = Math.min(minShade, face.shade); + maxShade = Math.max(maxShade, face.shade); + } + const range = maxShade - minShade || 1; + + const buckets: Vec2[][][] = BAND_OPACITY.map(() => []); + for (const face of scene.faces) { + const normalized = (face.shade - minShade) / range; + const band = Math.min(BAND_OPACITY.length - 1, Math.floor(normalized * BAND_OPACITY.length)); + buckets[band].push(face.tri.map(index => scene.centered[index])); + } + return buckets; +} + +/** + * The single largest flat-silhouette ring (no recentering), shared by the flat + * and combined outputs and aligned with the shaded bands' coordinate space. + */ +function flatRing(scene: CulledScene): Vec2[] { + const unioned = unionTriangles(scene.visible.map(face => face.tri.map(i => scene.centered[i]))); + const cleaned = ClipperLib.Clipper.CleanPolygons(unioned, 1); + const ranked = cleaned + .map(path => ({path, area: ringArea(fromClipperPath(path))})) + .sort((a, b) => b.area - a.area); + const [main] = ranked; + if (main === undefined) { + throw new Error('Flat silhouette union produced no paths'); + } + const buffered = offsetRing(fromClipperPath(main.path), 0.6); + const contracted = offsetRing(buffered, -0.6); + return simplifyRing(contracted, 0.8); +} + +/** + * One per shade band, darkest first; bands at opacity 0 are skipped + * (in combined mode the lightest band is already covered by the base). + */ +function bandLayers(scene: ShadedScene, opacities: readonly number[]): string[] { + const buckets = bucketFaces(scene); + const layers: string[] = []; + for (let band = 0; band < buckets.length; band++) { + if (opacities[band] <= 0) { + continue; + } + const rings = unionBand(buckets[band]); + if (rings.length === 0) { + continue; + } + const d = rings.map(ringToIntPath).join(' '); + layers.push(``); + } + return layers; +} + +function wrapSvg(body: string): string { + return `${body}\n`; +} + +/** + * Merge overlapping triangles into one filled region. Non-zero fill so the + * triangles' winding does not matter. + */ +function unionTriangles(triangles: readonly Vec2[][]): ClipperLib.Paths { + const clipper = new ClipperLib.Clipper(); + for (const triangle of triangles) { + const ring = ensureCounterClockwise(triangle.map(snapPoint)); + if (ringArea(ring) <= 0.05) { + continue; + } + clipper.AddPath(toClipperPath(ring), ClipperLib.PolyType.ptSubject, true); + } + + const unioned: ClipperLib.Paths = []; + clipper.Execute( + ClipperLib.ClipType.ctUnion, + unioned, + ClipperLib.PolyFillType.pftNonZero, + ClipperLib.PolyFillType.pftNonZero + ); + return unioned; +} + +/** + * Seal hairline gaps between a band's triangles by growing then shrinking the + * union, then simplify and drop specks too small to read. + */ +function unionBand(triangles: readonly Vec2[][]): Vec2[][] { + const grown = offsetClipperPaths(unionTriangles(triangles), SHADE_CLOSE * CLIPPER_SCALE); + const closed = offsetClipperPaths(grown, -SHADE_CLOSE * CLIPPER_SCALE); + return closed + .map(path => simplifyRing(fromClipperPath(path), SHADE_TOLERANCE)) + .filter(ring => ring.length >= 3 && ringArea(ring) >= SHADE_MIN_AREA); +} + +function ringToIntPath(ring: Vec2[]): string { + const [first, ...rest] = ring; + if (first === undefined) { + return ''; + } + return `M${Math.round(first[0])} ${Math.round(first[1])} ${rest.map(p => `L${Math.round(p[0])} ${Math.round(p[1])}`).join(' ')} Z`; +} + +function ensureCounterClockwise(ring: Vec2[]): Vec2[] { + return signedRingArea(ring) < 0 ? [...ring].reverse() : ring; +} + +function offsetRing(ring: Vec2[], distance: number): Vec2[] { + const path = toClipperPath(ring); + const offset = offsetClipperPaths([path], distance * CLIPPER_SCALE); + const [first] = offset; + if (first === undefined) { + return ring; + } + return fromClipperPath(first); +} + +function snapPoint(point: Vec2): Vec2 { + return [ + Math.round(point[0] * CLIPPER_SCALE) / CLIPPER_SCALE, + Math.round(point[1] * CLIPPER_SCALE) / CLIPPER_SCALE + ]; +} + +function toClipperPath(ring: Vec2[]): ClipperLib.Path { + return ring.map(([x, y]) => ({X: Math.round(x * CLIPPER_SCALE), Y: Math.round(y * CLIPPER_SCALE)})); +} + +function fromClipperPath(path: ClipperLib.Path): Vec2[] { + return path.map((point): Vec2 => [point.X / CLIPPER_SCALE, point.Y / CLIPPER_SCALE]); +} + +function offsetClipperPaths(paths: ClipperLib.Path[], delta: number): ClipperLib.Paths { + const offsetter = new ClipperLib.ClipperOffset(2, 0.25); + offsetter.AddPaths(paths, ClipperLib.JoinType.jtRound, ClipperLib.EndType.etClosedPolygon); + const solution: ClipperLib.Paths = []; + offsetter.Execute(solution, delta); + return solution; +} + +function simplifyRing(ring: Vec2[], tolerance: number): Vec2[] { + if (ring.length <= 3) { + return ring; + } + const closed = [...ring, ring[0]]; + const simplified = douglasPeucker(closed, tolerance); + return simplified.slice(0, -1); +} + +function douglasPeucker(points: Vec2[], tolerance: number): Vec2[] { + if (points.length <= 2) { + return points; + } + + let maxDistance = 0; + let index = 0; + const end = points.length - 1; + const start = points[0]; + const endPoint = points[end]; + + for (let i = 1; i < end; i++) { + const point = points[i]; + const distance = perpendicularDistance(point, start, endPoint); + if (distance > maxDistance) { + maxDistance = distance; + index = i; + } + } + + if (maxDistance > tolerance) { + const left = douglasPeucker(points.slice(0, index + 1), tolerance); + const right = douglasPeucker(points.slice(index), tolerance); + return [...left.slice(0, -1), ...right]; + } + + return [start, endPoint]; +} + +function perpendicularDistance(point: Vec2, start: Vec2, end: Vec2): number { + const dx = end[0] - start[0]; + const dy = end[1] - start[1]; + if (dx === 0 && dy === 0) { + return Math.hypot(point[0] - start[0], point[1] - start[1]); + } + const t = ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / (dx * dx + dy * dy); + const projX = start[0] + t * dx; + const projY = start[1] + t * dy; + return Math.hypot(point[0] - projX, point[1] - projY); +} + +function recenterRingToCanvas(ring: Vec2[]): Vec2[] { + const xs = ring.map(p => p[0]); + const ys = ring.map(p => p[1]); + const centerX = (Math.min(...xs) + Math.max(...xs)) / 2; + const centerY = (Math.min(...ys) + Math.max(...ys)) / 2; + const dx = CANVAS / 2 - centerX; + const dy = CANVAS / 2 - centerY; + return ring.map(([x, y]): Vec2 => [x + dx, y + dy]); +} + +function ringToPath(ring: Vec2[]): string { + const [first, ...rest] = ring; + if (first === undefined) { + return ''; + } + return `M${first[0].toFixed(1)} ${first[1].toFixed(1)} ${rest.map(p => `L${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' ')} Z`; +} + +function ringArea(ring: Vec2[]): number { + return Math.abs(signedRingArea(ring)) / 2; +} + +function signedRingArea(ring: Vec2[]): number { + let area = 0; + for (let i = 0; i < ring.length; i++) { + const current = ring[i]; + const next = ring[(i + 1) % ring.length]; + area += current[0] * next[1] - next[0] * current[1]; + } + return area; +} diff --git a/src/pages/tools/_3d-to-svg/geometry.ts b/src/pages/tools/_3d-to-svg/geometry.ts new file mode 100644 index 0000000..d771f67 --- /dev/null +++ b/src/pages/tools/_3d-to-svg/geometry.ts @@ -0,0 +1,423 @@ +/* + * Types. + */ + +export type AxisToken = '+X' | '-X' | '+Y' | '-Y' | '+Z' | '-Z'; + +export type RawPrimitive = { + positions: ArrayLike; + indices?: ArrayLike; + worldMatrix: ArrayLike; +}; + +export type Vec3 = [number, number, number]; +export type Mat3 = [Vec3, Vec3, Vec3]; +export type Vec2 = [number, number]; + +export type LoadedMesh = {vertices: Vec3[]; faces: [number, number, number][]}; +export type CulledScene = {visible: VisibleFace[]; centered: Vec2[]}; + +// Front-facing triangles tagged with a 0..1 Lambert shade for the cel-shaded output. +export type ShadedFace = {tri: [number, number, number]; shade: number}; +export type ShadedScene = {centered: Vec2[]; faces: ShadedFace[]}; + +type VisibleFace = { + tri: [number, number, number]; +}; + +/* + * Constants. + */ + +/** + * Head-on scan camera, looking down the model's forward axis (+X canonical). Being + * axis-aligned makes the world frame match the screen, so the X/Y/Z pose sliders + * read relative to what the viewer sees. The viewport shares this exact position. + */ +export const CAMPOS: Vec3 = [8, 0, 0]; +const FIT_PX = 430; + +/** Light for the cel-shaded output: high, to the left, toward the viewer. */ +const LIGHT: Vec3 = normalize([-3, -5, 7]); + +export const CANVAS = 512; + +/** Default forward/up axis remap for the bundled ship. */ +export const LOCKED_AXES: {forward: AxisToken; up: AxisToken} = {forward: '-X', up: '+Y'}; + +/** + * Default load pose: a gentle 3/4 view, reached from head-on by turning about the + * screen-up axis (world Z) and tilting about the screen-right axis (world Y). + */ +export const LOCKED_ORIENTATION: Mat3 = multiplyMat( + rotationAboutAxis([0, 0, 1], (-35 * Math.PI) / 180), + rotationAboutAxis([0, 1, 0], (28 * Math.PI) / 180) +); + +const AX: Record = { + '+X': [1, 0, 0], + '-X': [-1, 0, 0], + '+Y': [0, 1, 0], + '-Y': [0, -1, 0], + '+Z': [0, 0, 1], + '-Z': [0, 0, -1] +}; + +/** + * Grids tried fine→coarse: the finest that fits the budget wins. Dropping every + * Nth face (the naive approach) scatters the surface and punches holes in the + * silhouette union. Welding vertices onto a grid keeps the surface continuous. + */ +const CLUSTER_GRIDS = [128, 96, 64, 48, 32, 24, 16, 12, 8]; + +/* + * Helpers. + */ + +/** Build a normalized, pose-independent mesh from raw primitives. */ +export function buildMesh( + primitives: readonly RawPrimitive[], + forward: AxisToken, + up: AxisToken, + targetFaces: number +): LoadedMesh { + const rawVertices: Vec3[] = []; + const rawFaces: [number, number, number][] = []; + for (const primitive of primitives) { + accumulatePrimitive(primitive, rawVertices, rawFaces); + } + + if (rawVertices.length === 0 || rawFaces.length === 0) { + throw new Error('No mesh geometry found'); + } + + const remap = remapMatrix(forward, up); + const remapped = rawVertices.map(vertex => multiplyRowByMatTranspose(vertex, remap)); + const centroid = mean(remapped); + const centered = remapped.map(vertex => subtract(vertex, centroid)); + let maxAbs = 0; + for (const vertex of centered) { + maxAbs = Math.max(maxAbs, Math.abs(vertex[0]), Math.abs(vertex[1]), Math.abs(vertex[2])); + } + const normalizeScale = maxAbs === 0 ? 1 : 5 / maxAbs; + const normalized = centered.map(vertex => scale(vertex, normalizeScale)); + + return simplifyMesh(normalized, rawFaces, targetFaces); +} + +/** Project a mesh under an orientation into fitted canvas space. */ +export function projectAndCull(mesh: LoadedMesh, orientation: Mat3): CulledScene { + const {visible, centered} = project(mesh, orientation); + return {visible, centered}; +} + +/** Project front faces tagged with a Lambert shade for the cel-shaded output. */ +export function projectShaded(mesh: LoadedMesh, orientation: Mat3): ShadedScene { + const {posed, centered} = project(mesh, orientation); + const viewForward = normalize(scale(CAMPOS, -1)); + + const faces: ShadedFace[] = []; + for (const tri of mesh.faces) { + const points = tri.map(i => posed[i]); + const center = mean(points); + let normal = cross(subtract(points[1], points[0]), subtract(points[2], points[0])); + const normalLength = length(normal); + if (normalLength < 1e-12) { + continue; + } + normal = scale(normal, 1 / normalLength); + if (dot(normal, center) < 0) { + normal = scale(normal, -1); + } + if (dot(normal, viewForward) >= 0) { + continue; + } + faces.push({tri, shade: Math.max(0, dot(normal, LIGHT))}); + } + + return {centered, faces}; +} + +/** Shared projection + canvas fit used by both the flat and shaded outputs. */ +function project( + mesh: LoadedMesh, + orientation: Mat3 +): {posed: Vec3[]; visible: VisibleFace[]; centered: Vec2[]} { + const posed = mesh.vertices.map(vertex => multiplyRowByMatTranspose(vertex, orientation)); + + const viewForward = normalize(scale(CAMPOS, -1)); + const upAxis: Vec3 = [0, 0, 1]; + const right = normalize(cross(viewForward, upAxis)); + const up = normalize(cross(right, viewForward)); + + const projected = posed.map(vertex => { + const x = dot(vertex, right); + const y = -dot(vertex, up); + return [x, y] satisfies Vec2; + }); + + const visible = collectVisibleFaces(mesh.faces, posed); + + const fitPoints = visible.flatMap(face => face.tri.map(i => projected[i])); + const {mn, mx} = boundsOf(fitPoints); + const span = Math.max(mx[0] - mn[0], mx[1] - mn[1]) || 1; + const scaleFactor = FIT_PX / span; + const offset: Vec2 = [ + CANVAS / 2 - ((mn[0] + mx[0]) / 2) * scaleFactor, + CANVAS / 2 - ((mn[1] + mx[1]) / 2) * scaleFactor + ]; + + const centered = projected.map( + ([x, y]) => [x * scaleFactor + offset[0], y * scaleFactor + offset[1]] satisfies Vec2 + ); + + return {posed, visible, centered}; +} + +/** Axis-aligned bounds of a point set; looped to stay safe on very large meshes. */ +function boundsOf(points: readonly Vec2[]): {mn: Vec2; mx: Vec2} { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const [x, y] of points) { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + return {mn: [minX, minY], mx: [maxX, maxY]}; +} + +/** + * A primitive's faces index into its own vertices, so offset them by the running + * vertex count when merging everything into the shared vertex and face lists. + */ +function accumulatePrimitive( + primitive: RawPrimitive, + rawVertices: Vec3[], + rawFaces: [number, number, number][] +): void { + const {positions, indices, worldMatrix: m} = primitive; + const vertexOffset = rawVertices.length; + const vertexCount = positions.length / 3; + + for (let i = 0; i < vertexCount; i++) { + const localX = positions[i * 3]; + const localY = positions[i * 3 + 1]; + const localZ = positions[i * 3 + 2]; + rawVertices.push([ + m[0] * localX + m[4] * localY + m[8] * localZ + m[12], + m[1] * localX + m[5] * localY + m[9] * localZ + m[13], + m[2] * localX + m[6] * localY + m[10] * localZ + m[14] + ]); + } + + if (indices === undefined) { + for (let i = 0; i + 2 < vertexCount; i += 3) { + rawFaces.push([vertexOffset + i, vertexOffset + i + 1, vertexOffset + i + 2]); + } + return; + } + + for (let i = 0; i + 2 < indices.length; i += 3) { + rawFaces.push([vertexOffset + indices[i], vertexOffset + indices[i + 1], vertexOffset + indices[i + 2]]); + } +} + +/** A budget of 0 (or an already-small mesh) keeps every face untouched. */ +function simplifyMesh(vertices: Vec3[], faces: [number, number, number][], targetFaces: number): LoadedMesh { + if (targetFaces <= 0 || faces.length <= targetFaces) { + return {vertices, faces}; + } + const {mn, extent} = bounds3(vertices); + let coarsest = clusterMesh(vertices, faces, mn, extent, CLUSTER_GRIDS[0]); + for (const grid of CLUSTER_GRIDS) { + coarsest = clusterMesh(vertices, faces, mn, extent, grid); + if (coarsest.faces.length <= targetFaces) { + return coarsest; + } + } + return coarsest; +} + +/** + * Weld every vertex to its grid cell (cell centroid is the new vertex), then + * rebuild faces against those cells, dropping triangles that collapse to a line. + */ +function clusterMesh( + vertices: Vec3[], + faces: [number, number, number][], + mn: Vec3, + extent: Vec3, + grid: number +): LoadedMesh { + const cellIndex = new Map(); + const sums: Vec3[] = []; + const counts: number[] = []; + const vertexCell = new Int32Array(vertices.length); + + for (let i = 0; i < vertices.length; i++) { + const v = vertices[i]; + const ix = cellAxis(v[0], mn[0], extent[0], grid); + const iy = cellAxis(v[1], mn[1], extent[1], grid); + const iz = cellAxis(v[2], mn[2], extent[2], grid); + const key = ix + iy * grid + iz * grid * grid; + let idx = cellIndex.get(key); + if (idx === undefined) { + idx = sums.length; + cellIndex.set(key, idx); + sums.push([0, 0, 0]); + counts.push(0); + } + vertexCell[i] = idx; + sums[idx][0] += v[0]; + sums[idx][1] += v[1]; + sums[idx][2] += v[2]; + counts[idx] += 1; + } + + const newVertices = sums.map((sum, i) => scale(sum, 1 / counts[i])); + const newFaces: [number, number, number][] = []; + const seen = new Set(); + for (const tri of faces) { + const a = vertexCell[tri[0]]; + const b = vertexCell[tri[1]]; + const c = vertexCell[tri[2]]; + if (a === b || b === c || a === c) { + continue; + } + const sorted = [a, b, c].sort((p, q) => p - q); + const key = `${sorted[0]}_${sorted[1]}_${sorted[2]}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + newFaces.push([a, b, c]); + } + return {vertices: newVertices, faces: newFaces}; +} + +function cellAxis(value: number, min: number, extent: number, grid: number): number { + return Math.min(grid - 1, Math.max(0, Math.floor(((value - min) / extent) * grid))); +} + +function bounds3(vertices: readonly Vec3[]): {mn: Vec3; extent: Vec3} { + const mn: Vec3 = [Infinity, Infinity, Infinity]; + const mx: Vec3 = [-Infinity, -Infinity, -Infinity]; + for (const v of vertices) { + for (let axis = 0; axis < 3; axis++) { + mn[axis] = Math.min(mn[axis], v[axis]); + mx[axis] = Math.max(mx[axis], v[axis]); + } + } + // Guard zero-thickness axes (flat meshes) so the division stays finite. + const extent: Vec3 = [mx[0] - mn[0] || 1, mx[1] - mn[1] || 1, mx[2] - mn[2] || 1]; + return {mn, extent}; +} + +function remapMatrix(forward: AxisToken, up: AxisToken): Mat3 { + const fwd = AX[forward]; + const upVector = AX[up]; + const right = cross(fwd, upVector); + return [fwd, right, upVector]; +} + +/** Rodrigues rotation matrix R for a CCW turn of `angle` about a unit `axis`. */ +function rotationAboutAxis(axis: Vec3, angle: number): Mat3 { + const [x, y, z] = axis; + const c = Math.cos(angle); + const s = Math.sin(angle); + const t = 1 - c; + return [ + [t * x * x + c, t * x * y - s * z, t * x * z + s * y], + [t * x * y + s * z, t * y * y + c, t * y * z - s * x], + [t * x * z - s * y, t * y * z + s * x, t * z * z + c] + ]; +} + +/** + * The silhouette is the union of all projected faces, so keep every face that has + * real area — front and back both fill the outline. + */ +function collectVisibleFaces(faces: [number, number, number][], posed: Vec3[]): VisibleFace[] { + const visible: VisibleFace[] = []; + + for (const tri of faces) { + const points = tri.map(i => posed[i]); + const normal = cross(subtract(points[1], points[0]), subtract(points[2], points[0])); + if (length(normal) < 1e-12) { + continue; + } + visible.push({tri}); + } + + return visible; +} + +function multiplyRowByMatTranspose(vector: Vec3, matrix: Mat3): Vec3 { + return [ + vector[0] * matrix[0][0] + vector[1] * matrix[0][1] + vector[2] * matrix[0][2], + vector[0] * matrix[1][0] + vector[1] * matrix[1][1] + vector[2] * matrix[1][2], + vector[0] * matrix[2][0] + vector[1] * matrix[2][1] + vector[2] * matrix[2][2] + ]; +} + +function multiplyMat(left: Mat3, right: Mat3): Mat3 { + return [ + [ + left[0][0] * right[0][0] + left[0][1] * right[1][0] + left[0][2] * right[2][0], + left[0][0] * right[0][1] + left[0][1] * right[1][1] + left[0][2] * right[2][1], + left[0][0] * right[0][2] + left[0][1] * right[1][2] + left[0][2] * right[2][2] + ], + [ + left[1][0] * right[0][0] + left[1][1] * right[1][0] + left[1][2] * right[2][0], + left[1][0] * right[0][1] + left[1][1] * right[1][1] + left[1][2] * right[2][1], + left[1][0] * right[0][2] + left[1][1] * right[1][2] + left[1][2] * right[2][2] + ], + [ + left[2][0] * right[0][0] + left[2][1] * right[1][0] + left[2][2] * right[2][0], + left[2][0] * right[0][1] + left[2][1] * right[1][1] + left[2][2] * right[2][1], + left[2][0] * right[0][2] + left[2][1] * right[1][2] + left[2][2] * right[2][2] + ] + ]; +} + +function mean(vectors: Vec3[]): Vec3 { + const sum: Vec3 = [0, 0, 0]; + for (const vector of vectors) { + sum[0] += vector[0]; + sum[1] += vector[1]; + sum[2] += vector[2]; + } + return scale(sum, 1 / vectors.length); +} + +function subtract(left: Vec3, right: Vec3): Vec3 { + return [left[0] - right[0], left[1] - right[1], left[2] - right[2]]; +} + +function scale(vector: Vec3, factor: number): Vec3 { + return [vector[0] * factor, vector[1] * factor, vector[2] * factor]; +} + +function dot(left: Vec3, right: Vec3): number { + return left[0] * right[0] + left[1] * right[1] + left[2] * right[2]; +} + +function cross(left: Vec3, right: Vec3): Vec3 { + return [ + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0] + ]; +} + +function length(vector: Vec3): number { + return Math.hypot(vector[0], vector[1], vector[2]); +} + +function normalize(vector: Vec3): Vec3 { + const len = length(vector); + return len === 0 ? [0, 0, 0] : scale(vector, 1 / len); +} diff --git a/src/pages/tools/_3d-to-svg/pipeline.test.ts b/src/pages/tools/_3d-to-svg/pipeline.test.ts new file mode 100644 index 0000000..ea6c269 --- /dev/null +++ b/src/pages/tools/_3d-to-svg/pipeline.test.ts @@ -0,0 +1,147 @@ +import {describe, expect, it} from 'vitest'; + +import {emitFlatSvg, emitShadedSvg} from './emit'; +import type {Mat3, RawPrimitive} from './geometry'; +import {buildMesh, CAMPOS, LOCKED_AXES, LOCKED_ORIENTATION, projectAndCull, projectShaded} from './geometry'; + +/* + * Constants. + */ + +// Unit cube spanning [-1, 1] on each axis: 8 corners, 12 triangle faces. +const CUBE_POSITIONS = [-1, -1, -1, 1, -1, -1, 1, 1, -1, -1, 1, -1, -1, -1, 1, 1, -1, 1, 1, 1, 1, -1, 1, 1]; + +const CUBE_INDICES = [ + 0, 1, 2, 0, 2, 3, 4, 6, 5, 4, 7, 6, 0, 3, 7, 0, 7, 4, 1, 5, 6, 1, 6, 2, 0, 4, 5, 0, 5, 1, 3, 2, 6, 3, 6, 7 +]; + +const IDENTITY_MATRIX = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + +const IDENTITY: Mat3 = [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1] +]; + +const cube: RawPrimitive = { + positions: CUBE_POSITIONS, + indices: CUBE_INDICES, + worldMatrix: IDENTITY_MATRIX +}; + +/* + * Tests. + */ + +describe('buildMesh', () => { + it('keeps every vertex and face and normalizes to a 5-unit half-extent', () => { + const mesh = buildMesh([cube], '+X', '+Z', 0); + expect(mesh.vertices).toHaveLength(8); + expect(mesh.faces).toHaveLength(12); + const maxAbs = Math.max(...mesh.vertices.flatMap(vertex => vertex.map(Math.abs))); + expect(maxAbs).toBeCloseTo(5); + }); + + it('decimates a dense mesh toward a target budget by welding vertices', () => { + const dense = gridMesh(40); // 41×41 vertices, 3200 faces + const mesh = buildMesh([dense], '+X', '+Z', 200); + expect(mesh.faces.length).toBeLessThan(3200); + expect(mesh.faces.length).toBeGreaterThan(0); + // Welding collapses the 41×41 vertex grid onto far fewer cells. + expect(mesh.vertices.length).toBeLessThan(1681); + }); + + it('throws when no geometry is supplied', () => { + expect(() => buildMesh([], '+X', '+Z', 0)).toThrow(/no mesh geometry/i); + }); +}); + +describe('locked pose', () => { + it('keeps the bundled ship axes and a valid orientation', () => { + expect(LOCKED_AXES).toEqual({forward: '-X', up: '+Y'}); + expect(LOCKED_ORIENTATION).toHaveLength(3); + }); + + it('scans head-on so the pose sliders stay screen-aligned', () => { + // Exactly one non-zero component means the camera looks straight down a world + // axis, so the world frame matches the screen and X/Y/Z read as the viewer sees. + expect(CAMPOS.filter(component => component !== 0)).toHaveLength(1); + }); +}); + +describe('projectAndCull', () => { + it('keeps every non-degenerate face and projects them into canvas space', () => { + const mesh = buildMesh([cube], '+X', '+Z', 0); + const scene = projectAndCull(mesh, IDENTITY); + expect(scene.centered).toHaveLength(8); + expect(scene.visible).toHaveLength(12); + }); +}); + +describe('emitFlatSvg', () => { + const mesh = buildMesh([cube], '+X', '+Z', 0); + const scene = projectAndCull(mesh, LOCKED_ORIENTATION); + + it('emits a single neutral currentColor path in a 512 viewBox', () => { + const svg = emitFlatSvg(scene); + expect(svg).toContain('viewBox="0 0 512 512"'); + expect(svg).toContain('xmlns="http://www.w3.org/2000/svg"'); + expect(svg).toContain('fill="currentColor"'); + expect(svg).not.toContain('aria-hidden'); + expect(svg).not.toContain('role='); + expect(svg).not.toContain(''); + expect(svg.match(/ { + expect(emitFlatSvg(scene)).toBe(emitFlatSvg(scene)); + }); +}); + +describe('shaded output', () => { + const mesh = buildMesh([cube], '+X', '+Z', 0); + + it('tags front faces with a 0..1 shade', () => { + const scene = projectShaded(mesh, LOCKED_ORIENTATION); + expect(scene.centered).toHaveLength(8); + expect(scene.faces.length).toBeGreaterThan(0); + for (const face of scene.faces) { + expect(face.shade).toBeGreaterThanOrEqual(0); + expect(face.shade).toBeLessThanOrEqual(1); + } + }); + + it('layers shade bands over a solid base, all in currentColor', () => { + const svg = emitShadedSvg( + projectAndCull(mesh, LOCKED_ORIENTATION), + projectShaded(mesh, LOCKED_ORIENTATION) + ); + expect(svg).toContain('viewBox="0 0 512 512"'); + expect(svg).toContain('fill="currentColor"'); + expect(svg).toContain('fill-opacity="0.6"'); + expect((svg.match(/ block. +export const snippetPre = style({ + margin: 0, + padding: '1.1em 1.25em', + maxWidth: '100%', + maxHeight: '15rem', + overflowY: 'auto', + // The exported SVG is one long line. We wrap it instead of blowing out the width. + whiteSpace: 'pre-wrap', + overflowWrap: 'anywhere', + fontFamily: fonts.mono, + fontSize: '0.82em', + lineHeight: 1.55, + color: '#1f2328', + backgroundColor: '#ffffff', + border: `1px solid ${palette.border}`, + borderRadius: '10px', + '@media': { + [media.dark]: { + color: '#e1e4e8', + backgroundColor: '#24292e', + borderColor: palette.borderDark + } + }, + selectors: { + // While a model loads, centre a spinner where the SVG markup will land. + '&[data-loading="true"]': { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: '6rem' + }, + '&[data-loading="true"]::after': { + content: '""', + width: '2.25rem', + height: '2.25rem', + borderRadius: '50%', + border: `3px solid ${palette.border}`, + borderTopColor: palette.accent, + animation: `${spin} 0.8s linear infinite` + } + } +}); + +// Token colors mirror shiki's github-light / github-dark themes. +export const codeTag = style({color: '#116329', '@media': {[media.dark]: {color: '#7ee787'}}}); +export const codeAttr = style({color: '#0550ae', '@media': {[media.dark]: {color: '#79b8ff'}}}); +export const codeString = style({color: '#0a3069', '@media': {[media.dark]: {color: '#a5d6ff'}}}); + +// Icon copy button (gdex-style): fixed size so it never resizes on click. +export const copyButton = style({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + width: '2rem', + height: '2rem', + padding: 0, + color: palette.textMuted, + backgroundColor: palette.surface, + border: `1px solid ${palette.border}`, + borderRadius: '6px', + cursor: 'pointer', + '@media': { + [media.dark]: { + color: palette.textMutedDark, + backgroundColor: palette.surfaceDark, + borderColor: palette.borderDark + }, + [media.coarsePointer]: { + width: touchTargetMin, + height: touchTargetMin + } + }, + selectors: { + '&:hover': { + borderColor: palette.accent + }, + '&:focus-visible': { + outline: `2px solid ${palette.accent}`, + outlineOffset: '2px' + }, + '&[data-copied="true"]': { + color: palette.accent, + borderColor: palette.accent + } + } +}); + +// Tucked into the pane's top-right corner (inside the pane, outside the dashed +// scan frame). Borderless so it doesn't compete with the frame; the accent only +// shows on hover/focus. Rounded close to the pane's 12px so the corner nests. +export const loadButton = style({ + position: 'absolute', + top: '0.3rem', + right: '0.3rem', + zIndex: 3, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: '2rem', + height: '2rem', + padding: 0, + color: palette.textMuted, + background: 'transparent', + border: '1px solid transparent', + borderRadius: '10px', + cursor: 'pointer', + '@media': { + [media.dark]: { + color: palette.textMutedDark + }, + [media.coarsePointer]: { + width: touchTargetMin, + height: touchTargetMin + } + }, + selectors: { + '&:hover': { + color: palette.accent, + borderColor: palette.accent + }, + '&:focus-visible': { + outline: `2px solid ${palette.accent}`, + outlineOffset: '2px' + } + } +}); + +export const copyIconDefault = style({ + width: '16px', + height: '16px', + display: 'block', + selectors: { + [`${copyButton}[data-copied="true"] &`]: {display: 'none'} + } +}); + +export const copyIconCopied = style({ + width: '16px', + height: '16px', + display: 'none', + selectors: { + [`${copyButton}[data-copied="true"] &`]: {display: 'block'} + } +}); + +export const attribution = style({ + marginTop: '2.5rem', + fontSize: '0.85rem', + color: palette.textMuted, + '@media': { + [media.dark]: { + color: palette.textMutedDark + } + } +}); diff --git a/src/pages/tools/_3d-to-svg/studio.ts b/src/pages/tools/_3d-to-svg/studio.ts new file mode 100644 index 0000000..8ce6305 --- /dev/null +++ b/src/pages/tools/_3d-to-svg/studio.ts @@ -0,0 +1,969 @@ +import type * as THREE from 'three'; + +import type {EmitMode} from './emit'; +import type {AxisToken, LoadedMesh, Mat3, RawPrimitive, Vec3} from './geometry'; +import type {WorkerReply, WorkerRequest} from './protocol'; + +import {codeAttr, codeString, codeTag, ids, spinner} from './studio.css'; + +/* + * Types. + */ + +type ThreeModule = typeof THREE; + +type GeometryModule = typeof import('./geometry'); + +type Viewport = { + renderer: THREE.WebGLRenderer; + scene: THREE.Scene; + camera: THREE.OrthographicCamera; + group: THREE.Group; + display: THREE.Mesh | undefined; +}; + +type ModelFormat = 'glb' | 'obj' | 'stl'; + +/* + * Constants. + */ + +const DEFAULT_MODEL_URL = '/models/spaceship-fighter.glb'; + +const ANGLE_LIMIT = 180; + +const SNAP_STEP = 15; + +/** + * Cap clipper work for dense meshes via vertex-welding decimation (not face + * dropping, which would hole the silhouette). The sample ship (~11.5k) stays full. + */ +const FACE_BUDGET = 20000; + +// Idle showcase: drift through poses until the user takes over. +const IDLE_STEPS = 6; +const IDLE_SPEED_RAD = 0.6; +const IDLE_DWELL_MS = 1600; +const IDLE_ARRIVE_RAD = 0.02; + +/* + * Script. + */ + +if (document.getElementById(ids.viewport)?.closest('[data-studio-root]') instanceof HTMLElement) { + initStudio(); +} + +/* + * Hooks. + */ + +/** + * Wire the 3D to SVG page: drop/pose/preview/export. + * Registers DOM events, fetches the default model, mutates the page. + */ +function initStudio(): void { + const viewportEl = requireElement(ids.viewport); + const canvasEl = requireElement(ids.canvas); + const dropZoneEl = requireElement(ids.dropZone); + const fileInputEl = requireElement(ids.fileInput); + const loadingEl = requireElement(ids.loading); + const loadButtonEl = requireElement(ids.loadButton); + const statusEl = requireElement(ids.status); + const previewEl = requireElement(ids.preview); + + const xRange = requireElement(ids.x); + const yRange = requireElement(ids.y); + const zRange = requireElement(ids.z); + const xNumber = requireElement(ids.xNumber); + const yNumber = requireElement(ids.yNumber); + const zNumber = requireElement(ids.zNumber); + + const snapToggle = requireElement(ids.snap); + const resetButton = requireElement(ids.reset); + + const colorInput = requireElement(ids.color); + const modeGroup = requireElement(ids.mode); + const downloadButton = requireElement(ids.download); + + const snippetPre = requireElement(ids.snippet); + const snippetInline = requireElement(ids.snippetInline); + const copyInlineButton = requireElement(ids.copyInline); + + // Fixed axis remap: glTF is Y-up, so this is a sensible default for any model. + const forward: AxisToken = '-X'; + const up: AxisToken = '+Y'; + + // Canonical pose: one model-orientation quaternion, created once three loads. + let orientation: THREE.Quaternion | undefined; + + let geometry: GeometryModule | undefined; + let three: ThreeModule | undefined; + let primitives: readonly RawPrimitive[] | undefined; + let mesh: LoadedMesh | undefined; + let viewport: Viewport | undefined; + let currentSvg = ''; + + // The clipper silhouette runs in a worker. Coalesce so only the freshest + // pose is ever in flight and stale replies are dropped. + let worker: Worker | undefined; + let isWorkerBusy = false; + let hasQueuedEmit = false; + let requestSeq = 0; + let latestRequestId = 0; + + // Idle showcase runs until the first interaction (and never with reduced motion). + let isIdle = !window.matchMedia('(prefers-reduced-motion: reduce)').matches; + let idleRaf: number | undefined; + let idlePhase: 'dwell' | 'move' = 'dwell'; + let idleTargets: THREE.Quaternion[] = []; + let idleIndex = 0; + let idleDwellUntil = 0; + let idleLast = 0; + + /* + * Loading. + */ + + /** Async dynamic import of the clipper-free geometry module. */ + async function ensureGeometry(): Promise { + if (geometry === undefined) { + geometry = await import('./geometry'); + } + return geometry; + } + + /** Spawn the clipper worker lazily; its chunk carries clipper, not the page. */ + function ensureWorker(): Worker { + if (worker === undefined) { + worker = new Worker(new URL('./svg.worker.ts', import.meta.url), {type: 'module'}); + worker.onmessage = onWorkerMessage; + } + return worker; + } + + /** Network fetch of the default GLB, then loads it. */ + async function loadDefaultModel(): Promise { + showLoading(); + try { + const response = await fetch(DEFAULT_MODEL_URL); + if (!response.ok) { + throw new Error(`Failed to fetch model (${response.status})`); + } + await loadModel(await response.arrayBuffer(), 'glb'); + } catch (error) { + onLoadError(error); + } finally { + hideLoading(); + } + } + + /** Reads a chosen File and loads it, replacing the current model. */ + async function loadFile(file: File): Promise { + showLoading(); + try { + const format = formatFromName(file.name); + if (format === undefined) { + throw new Error('Unsupported file. Use a .glb, .obj, or .stl model.'); + } + await loadModel(await file.arrayBuffer(), format); + } catch (error) { + onLoadError(error); + } finally { + hideLoading(); + } + } + + /** + * Loading a model resets the output: clear the old SVG and spin the preview + * until the worker returns the first silhouette for the new model. + */ + function showLoading(): void { + loadingEl.dataset.hidden = 'false'; + currentSvg = ''; + previewEl.innerHTML = ``; + snippetInline.textContent = ''; + snippetPre.dataset.loading = 'true'; + setStatus('', false); + } + + function hideLoading(): void { + loadingEl.dataset.hidden = 'true'; + } + + /** On failure, surface the message and reveal the drop prompt so the user can retry. */ + function onLoadError(error: unknown): void { + setStatus(messageFrom(error), true); + dropZoneEl.dataset.hidden = 'false'; + } + + /** Parses the model, builds the mesh, and mounts the viewport. */ + async function loadModel(buffer: ArrayBuffer, format: ModelFormat): Promise { + const [threeModule, geometryModule] = await Promise.all([import('three'), ensureGeometry()]); + three = threeModule; + ensureWorker(); + + primitives = await parseModel(threeModule, buffer, format); + rebuildMesh(geometryModule); + + mountViewport(threeModule, geometryModule.CAMPOS); + rebuildDisplay(threeModule); + dropZoneEl.dataset.hidden = 'true'; + setStatus('', false); + // Every freshly loaded model starts at the default pose, then drifts. + orientation = orientationFromMat3(threeModule, geometryModule.LOCKED_ORIENTATION); + syncControls(); + applyPoseToViewport(); + requestEmit(); + restartIdle(); + } + + function rebuildMesh(geometryModule: GeometryModule): void { + if (primitives === undefined) { + return; + } + mesh = geometryModule.buildMesh(primitives, forward, up, FACE_BUDGET); + if (worker !== undefined) { + const message: WorkerRequest = {type: 'mesh', mesh}; + worker.postMessage(message); + } + } + + /* + * Viewport. + */ + + function mountViewport(three: ThreeModule, campos: Vec3): void { + if (viewport !== undefined) { + return; + } + + const renderer = new three.WebGLRenderer({canvas: canvasEl, antialias: true, alpha: true}); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + + const scene = new three.Scene(); + const camera = new three.OrthographicCamera(-6, 6, 6, -6, 0.1, 100); + // Share the silhouette's scan camera so the two panes always agree. + camera.up.set(0, 0, 1); + camera.position.set(campos[0], campos[1], campos[2]); + camera.lookAt(0, 0, 0); + + // Front-upper-left key light for legible 3D shading (preview only). + const key = new three.DirectionalLight(0xffffff, 2.2); + key.position.set(6, -6, 8); + const fill = new three.HemisphereLight(0xffffff, 0x404040, 1.1); + scene.add(key, fill); + + const group = new three.Group(); + scene.add(group); + + viewport = {renderer, scene, camera, group, display: undefined}; + resizeViewport(); + new ResizeObserver(resizeViewport).observe(viewportEl); + } + + function rebuildDisplay(three: ThreeModule): void { + if (viewport === undefined || mesh === undefined) { + return; + } + disposeDisplay(viewport); + + const {geometry, radius} = buildDisplayGeometry(three, mesh); + const display = new three.Mesh(geometry, makeDisplayMaterial(three, colorInput.value)); + viewport.group.add(display); + viewport.display = display; + fitCamera(radius); + } + + function fitCamera(radius: number): void { + if (viewport === undefined) { + return; + } + // ~1.2 keeps even the worst-case pose just inside the dashed scan frame. + const half = radius * 1.2; + viewport.camera.left = -half; + viewport.camera.right = half; + viewport.camera.top = half; + viewport.camera.bottom = -half; + viewport.camera.near = 0.1; + viewport.camera.far = radius * 8; + viewport.camera.updateProjectionMatrix(); + } + + function resizeViewport(): void { + if (viewport === undefined) { + return; + } + const size = Math.max(1, Math.floor(viewportEl.clientWidth)); + viewport.renderer.setSize(size, size, false); + applyPoseToViewport(); + } + + /** + * The viewport shows the model under the same orientation as the silhouette, so + * the two panes always agree (the camera itself is fixed, set once at mount). + */ + function applyPoseToViewport(): void { + if (viewport === undefined || orientation === undefined) { + return; + } + viewport.group.quaternion.copy(orientation); + viewport.renderer.render(viewport.scene, viewport.camera); + } + + /* + * Preview + output. + */ + + function emitContext(): {three: ThreeModule; orientation: THREE.Quaternion; worker: Worker} | undefined { + if (three === undefined || orientation === undefined || worker === undefined) { + return undefined; + } + return {three, orientation, worker}; + } + + /** The currently checked Style radio. Defaults to shaded if somehow none is. */ + function currentMode(): EmitMode { + const checked = modeGroup.querySelector('input:checked'); + return (checked?.value ?? 'shaded') as EmitMode; + } + + /** Ask the worker for a fresh silhouette, coalescing while one is in flight. */ + function requestEmit(): void { + const ctx = emitContext(); + if (ctx === undefined) { + return; + } + if (isWorkerBusy) { + hasQueuedEmit = true; + return; + } + requestSeq += 1; + latestRequestId = requestSeq; + isWorkerBusy = true; + hasQueuedEmit = false; + const message: WorkerRequest = { + type: 'emit', + id: requestSeq, + orientation: quatToMat3(ctx.three, ctx.orientation), + mode: currentMode() + }; + ctx.worker.postMessage(message); + } + + /** Applies the worker reply and runs the freshest pending request. */ + function onWorkerMessage(event: MessageEvent): void { + isWorkerBusy = false; + const reply = event.data; + if (reply.type === 'error') { + setStatus(reply.message, true); + } else if (reply.id === latestRequestId) { + applySvg(reply.svg); + } + if (hasQueuedEmit) { + requestEmit(); + } + } + + function applySvg(svg: string): void { + currentSvg = svg; + previewEl.innerHTML = svg; + const svgEl = previewEl.querySelector('svg'); + if (svgEl !== null) { + svgEl.setAttribute('width', '100%'); + svgEl.setAttribute('height', '100%'); + } + recolorPreview(); + updateSnippets(); + } + + /* + * Idle showcase. + */ + + /** + * Re-arm the idle showcase for a freshly loaded model (unless reduced motion), + * cancelling any drift still running from a previous model. + */ + function restartIdle(): void { + if (idleRaf !== undefined) { + cancelAnimationFrame(idleRaf); + idleRaf = undefined; + } + isIdle = !window.matchMedia('(prefers-reduced-motion: reduce)').matches; + maybeStartIdle(); + } + + /** Start drifting through poses, but only if the user has not already taken over. */ + function maybeStartIdle(): void { + if (!isIdle || three === undefined || orientation === undefined) { + return; + } + idleTargets = buildIdleTargets(three, orientation); + idleIndex = 0; + idlePhase = 'dwell'; + idleDwellUntil = performance.now() + IDLE_DWELL_MS; + idleLast = performance.now(); + idleRaf = requestAnimationFrame(idleTick); + } + + /** Stops the showcase for good once the user interacts. */ + function stopIdle(): void { + if (!isIdle) { + return; + } + isIdle = false; + if (idleRaf !== undefined) { + cancelAnimationFrame(idleRaf); + idleRaf = undefined; + } + } + + function idleTick(now: number): void { + if (!isIdle) { + return; + } + stepIdle(now); + idleRaf = requestAnimationFrame(idleTick); + } + + function stepIdle(now: number): void { + if (three === undefined || orientation === undefined) { + return; + } + if (idlePhase === 'dwell') { + stepIdleDwell(now); + } else { + stepIdleMove(now, orientation); + } + } + + function stepIdleDwell(now: number): void { + if (now >= idleDwellUntil) { + idlePhase = 'move'; + idleLast = now; + } + } + + /** Ease the 3D model toward the target; paint the SVG only once it settles. */ + function stepIdleMove(now: number, current: THREE.Quaternion): void { + const target = idleTargets[idleIndex]; + const dt = Math.min((now - idleLast) / 1000, 0.05); + idleLast = now; + current.rotateTowards(target, IDLE_SPEED_RAD * dt); + syncControls(); + applyPoseToViewport(); + if (current.angleTo(target) < IDLE_ARRIVE_RAD) { + requestEmit(); + idleIndex = (idleIndex + 1) % idleTargets.length; + idlePhase = 'dwell'; + idleDwellUntil = now + IDLE_DWELL_MS; + } + } + + /** The emitted SVG fills with currentColor, so the host's color tints the preview. */ + function recolorPreview(): void { + previewEl.style.color = colorInput.value; + recolorModel(); + } + + /** Keep the 3D model's material in sync with the picked color. */ + function recolorModel(): void { + if (viewport === undefined || viewport.display === undefined) { + return; + } + (viewport.display.material as THREE.MeshStandardMaterial).color.set(colorInput.value); + viewport.renderer.render(viewport.scene, viewport.camera); + } + + function updateSnippets(): void { + snippetPre.dataset.loading = 'false'; + snippetInline.innerHTML = highlightSvg(currentSvg.trim()); + } + + /* + * Pose controls. + */ + + /** Apply an orientation, sync the sliders, redraw, and re-emit. */ + function applyOrientation(next: THREE.Quaternion): void { + orientation = next; + syncControls(); + applyPoseToViewport(); + requestEmit(); + } + + /** Reflect the orientation back onto the Euler sliders and number fields. */ + function syncControls(): void { + if (orientation === undefined || three === undefined) { + return; + } + const euler = new three.Euler().setFromQuaternion(orientation, 'XYZ'); + const xValue = String(wrapAngle(radToDeg(euler.x))); + const yValue = String(wrapAngle(radToDeg(euler.y))); + const zValue = String(wrapAngle(radToDeg(euler.z))); + xRange.value = xValue; + yRange.value = yValue; + zRange.value = zValue; + setNumberValue(xNumber, xValue); + setNumberValue(yNumber, yValue); + setNumberValue(zNumber, zValue); + } + + /** Don't overwrite a number field while the user is typing/stepping in it. */ + function setNumberValue(input: HTMLInputElement, value: string): void { + if (document.activeElement !== input) { + input.value = value; + } + } + + function onSliderInput(): void { + if (three === undefined) { + return; + } + const euler = new three.Euler( + degToRad(maybeSnap(Number(xRange.value))), + degToRad(maybeSnap(Number(yRange.value))), + degToRad(maybeSnap(Number(zRange.value))), + 'XYZ' + ); + applyOrientation(new three.Quaternion().setFromEuler(euler)); + } + + /** A number field drives the same pose; mirror it into its slider, then apply. */ + function onNumberInput(slider: HTMLInputElement, numberInput: HTMLInputElement): void { + if (numberInput.value === '') { + return; + } + slider.value = numberInput.value; + onSliderInput(); + } + + function maybeSnap(degrees: number): number { + return snapToggle.checked ? Math.round(degrees / SNAP_STEP) * SNAP_STEP : degrees; + } + + /* + * Drag (arcball). + */ + + type Drag = { + pointerId: number; + start: THREE.Vector3; + orientation: THREE.Quaternion; + three: ThreeModule; + viewport: Viewport; + }; + let drag: Drag | undefined; + + /** Project a pointer onto a virtual unit sphere over the viewport. */ + function sphereVector(threeMod: ThreeModule, event: PointerEvent): THREE.Vector3 { + const rect = viewportEl.getBoundingClientRect(); + const nx = ((event.clientX - rect.left) / rect.width) * 2 - 1; + const ny = -(((event.clientY - rect.top) / rect.height) * 2 - 1); + const radiusSquared = nx * nx + ny * ny; + const nz = radiusSquared <= 1 ? Math.sqrt(1 - radiusSquared) : 0; + return new threeMod.Vector3(nx, ny, nz).normalize(); + } + + function onPointerDown(event: PointerEvent): void { + if (three === undefined || orientation === undefined || viewport === undefined) { + return; + } + drag = { + pointerId: event.pointerId, + start: sphereVector(three, event), + orientation: orientation.clone(), + three, + viewport + }; + viewportEl.dataset.dragging = 'true'; + viewportEl.setPointerCapture(event.pointerId); + } + + /** Rotate the model so the grabbed point tracks the cursor (Shoemake arcball). */ + function onPointerMove(event: PointerEvent): void { + if (drag === undefined || drag.pointerId !== event.pointerId) { + return; + } + const current = sphereVector(drag.three, event); + const deltaEye = new drag.three.Quaternion().setFromUnitVectors(drag.start, current); + const camera = drag.viewport.camera.quaternion; + const deltaWorld = camera.clone().multiply(deltaEye).multiply(camera.clone().invert()); + applyOrientation(deltaWorld.multiply(drag.orientation)); + } + + function onPointerUp(event: PointerEvent): void { + if (drag === undefined || drag.pointerId !== event.pointerId) { + return; + } + drag = undefined; + viewportEl.dataset.dragging = 'false'; + viewportEl.releasePointerCapture(event.pointerId); + if (snapToggle.checked) { + onSliderInput(); + } + } + + /* + * Events. + */ + + xRange.addEventListener('input', onSliderInput); + yRange.addEventListener('input', onSliderInput); + zRange.addEventListener('input', onSliderInput); + for (const [slider, numberInput] of [ + [xRange, xNumber], + [yRange, yNumber], + [zRange, zNumber] + ] as const) { + const apply = (): void => onNumberInput(slider, numberInput); + numberInput.addEventListener('input', apply); + numberInput.addEventListener('change', apply); + } + + snapToggle.addEventListener('change', onSliderInput); + // Reset returns to the model's default pose (the angle it loads at), not flat-zero. + resetButton.addEventListener('click', () => { + if (geometry !== undefined && three !== undefined) { + applyOrientation(orientationFromMat3(three, geometry.LOCKED_ORIENTATION)); + } + }); + + colorInput.addEventListener('input', recolorPreview); + modeGroup.addEventListener('change', requestEmit); + downloadButton.addEventListener('click', downloadSvg); + + copyInlineButton.addEventListener('click', () => + copyText(snippetInline.textContent ?? '', copyInlineButton) + ); + + // Any interaction anywhere in the tool retires the idle showcase. + const studioRoot = viewportEl.closest('[data-studio-root]'); + studioRoot?.addEventListener('pointerdown', stopIdle); + studioRoot?.addEventListener('input', stopIdle); + studioRoot?.addEventListener('change', stopIdle); + + viewportEl.addEventListener('pointerdown', onPointerDown); + viewportEl.addEventListener('pointermove', onPointerMove); + viewportEl.addEventListener('pointerup', onPointerUp); + viewportEl.addEventListener('pointercancel', onPointerUp); + + dropZoneEl.addEventListener('click', () => fileInputEl.click()); + // Sits over the viewport, so keep its click from also starting an arcball drag. + loadButtonEl.addEventListener('pointerdown', event => event.stopPropagation()); + loadButtonEl.addEventListener('click', () => fileInputEl.click()); + fileInputEl.addEventListener('change', () => { + const file = fileInputEl.files?.[0]; + if (file !== undefined) { + void loadFile(file); + } + // Clear so re-picking the same file still fires `change`. + fileInputEl.value = ''; + }); + + viewportEl.addEventListener('dragover', event => { + event.preventDefault(); + dropZoneEl.dataset.hidden = 'false'; + dropZoneEl.dataset.dragover = 'true'; + }); + viewportEl.addEventListener('dragleave', () => { + dropZoneEl.dataset.dragover = 'false'; + if (mesh !== undefined) { + dropZoneEl.dataset.hidden = 'true'; + } + }); + viewportEl.addEventListener('drop', event => { + event.preventDefault(); + dropZoneEl.dataset.dragover = 'false'; + const file = event.dataTransfer?.files?.[0]; + if (file !== undefined) { + void loadFile(file); + } else if (mesh !== undefined) { + dropZoneEl.dataset.hidden = 'true'; + } + }); + + /* + * Bootstrap. + */ + + void loadDefaultModel(); + + /* + * Local helpers. + */ + + /** Triggers a browser download of the current SVG. */ + function downloadSvg(): void { + if (currentSvg.length === 0) { + return; + } + const blob = new Blob([currentSvg], {type: 'image/svg+xml'}); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = 'icon.svg'; + anchor.click(); + URL.revokeObjectURL(url); + } + + function setStatus(message: string, isError: boolean): void { + statusEl.textContent = message; + statusEl.dataset.error = String(isError); + } +} + +/* + * Helpers. + */ + +function formatFromName(name: string): ModelFormat | undefined { + const lower = name.toLowerCase(); + if (lower.endsWith('.glb')) { + return 'glb'; + } + if (lower.endsWith('.obj')) { + return 'obj'; + } + if (lower.endsWith('.stl')) { + return 'stl'; + } + return undefined; +} + +/** + * Parse a model buffer with the loader for its format, returning raw primitives. + * Each loader is imported on demand so its code only ships when that format is used. + */ +async function parseModel( + three: ThreeModule, + buffer: ArrayBuffer, + format: ModelFormat +): Promise { + if (format === 'obj') { + const {OBJLoader} = await import('three/examples/jsm/loaders/OBJLoader.js'); + return extractPrimitives(new OBJLoader().parse(new TextDecoder().decode(buffer))); + } + if (format === 'stl') { + const {STLLoader} = await import('three/examples/jsm/loaders/STLLoader.js'); + return extractPrimitives(new three.Mesh(new STLLoader().parse(buffer))); + } + const {GLTFLoader} = await import('three/examples/jsm/loaders/GLTFLoader.js'); + const gltf = await new GLTFLoader().parseAsync(buffer, ''); + return extractPrimitives(gltf.scene); +} + +function extractPrimitives(scene: THREE.Object3D): RawPrimitive[] { + scene.updateMatrixWorld(true); + const primitives: RawPrimitive[] = []; + scene.traverse(object => { + const primitive = toPrimitive(object); + if (primitive !== undefined) { + primitives.push(primitive); + } + }); + + if (primitives.length === 0) { + throw new Error('No mesh geometry found in this file.'); + } + return primitives; +} + +function toPrimitive(object: THREE.Object3D): RawPrimitive | undefined { + const node = object as THREE.Mesh; + if (!node.isMesh) { + return undefined; + } + const geometry = node.geometry as THREE.BufferGeometry; + const position = geometry.getAttribute('position'); + if (position === undefined) { + return undefined; + } + const index = geometry.getIndex(); + return { + positions: packPositions(position), + indices: index === null ? undefined : (index.array as ArrayLike), + worldMatrix: node.matrixWorld.elements + }; +} + +/** Read x/y/z per vertex so interleaved/normalized attributes deinterleave correctly. */ +function packPositions(attribute: THREE.BufferAttribute | THREE.InterleavedBufferAttribute): Float32Array { + const packed = new Float32Array(attribute.count * 3); + for (let index = 0; index < attribute.count; index++) { + packed[index * 3] = attribute.getX(index); + packed[index * 3 + 1] = attribute.getY(index); + packed[index * 3 + 2] = attribute.getZ(index); + } + return packed; +} + +function buildDisplayGeometry( + three: ThreeModule, + mesh: LoadedMesh +): {geometry: THREE.BufferGeometry; radius: number} { + const geometry = new three.BufferGeometry(); + const positions = new Float32Array(mesh.vertices.length * 3); + let maxDistanceSq = 0; + for (let index = 0; index < mesh.vertices.length; index++) { + const [x, y, z] = mesh.vertices[index]; + positions[index * 3] = x; + positions[index * 3 + 1] = y; + positions[index * 3 + 2] = z; + maxDistanceSq = Math.max(maxDistanceSq, x * x + y * y + z * z); + } + geometry.setAttribute('position', new three.BufferAttribute(positions, 3)); + geometry.setIndex(mesh.faces.flat()); + geometry.computeVertexNormals(); + // Radius about the origin (the rotation/look-at center) so no pose can clip. + return {geometry, radius: Math.sqrt(maxDistanceSq) || 6}; +} + +function makeDisplayMaterial(three: ThreeModule, color: string): THREE.MeshStandardMaterial { + return new three.MeshStandardMaterial({ + color, + roughness: 0.55, + metalness: 0.1, + flatShading: true, + side: three.DoubleSide + }); +} + +function disposeDisplay(viewport: Viewport): void { + if (viewport.display !== undefined) { + viewport.group.remove(viewport.display); + viewport.display.geometry.dispose(); + } +} + +function requireElement(id: string): T { + const element = document.getElementById(id); + if (element === null) { + throw new Error(`Missing element #${id}`); + } + return element as T; +} + +function wrapAngle(value: number): number { + let wrapped = value; + while (wrapped > ANGLE_LIMIT) { + wrapped -= 2 * ANGLE_LIMIT; + } + while (wrapped < -ANGLE_LIMIT) { + wrapped += 2 * ANGLE_LIMIT; + } + return Math.round(wrapped); +} + +function degToRad(value: number): number { + return (value * Math.PI) / 180; +} + +function radToDeg(value: number): number { + return (value * 180) / Math.PI; +} + +function mat3ToMatrix4(three: ThreeModule, m: Mat3): THREE.Matrix4 { + return new three.Matrix4().set( + m[0][0], + m[0][1], + m[0][2], + 0, + m[1][0], + m[1][1], + m[1][2], + 0, + m[2][0], + m[2][1], + m[2][2], + 0, + 0, + 0, + 0, + 1 + ); +} + +function quatToMat3(three: ThreeModule, quaternion: THREE.Quaternion): Mat3 { + const e = new three.Matrix4().makeRotationFromQuaternion(quaternion).elements; + return [ + [e[0], e[4], e[8]], + [e[1], e[5], e[9]], + [e[2], e[6], e[10]] + ]; +} + +function orientationFromMat3(three: ThreeModule, m: Mat3): THREE.Quaternion { + return new three.Quaternion().setFromRotationMatrix(mat3ToMatrix4(three, m)); +} + +/** + * A loop of showcase orientations: orbit the base pose around vertical with a + * gentle pitch wobble so each stop shows a distinct silhouette. + */ +function buildIdleTargets(three: ThreeModule, base: THREE.Quaternion): THREE.Quaternion[] { + const up = new three.Vector3(0, 0, 1); + const side = new three.Vector3(1, 0, 0); + const targets: THREE.Quaternion[] = []; + for (let step = 1; step <= IDLE_STEPS; step++) { + const yaw = new three.Quaternion().setFromAxisAngle(up, (step / IDLE_STEPS) * Math.PI * 2); + const pitch = new three.Quaternion().setFromAxisAngle(side, Math.sin(step * 1.7) * 0.5); + targets.push(yaw.multiply(pitch).multiply(base.clone())); + } + return targets; +} + +/** Writes to the clipboard and flashes the button's copied state. */ +async function copyText(text: string, button: HTMLButtonElement): Promise { + try { + await navigator.clipboard.writeText(text); + button.dataset.copied = 'true'; + window.setTimeout(() => { + delete button.dataset.copied; + }, 1200); + } catch { + // Leave the button as-is if the clipboard is unavailable. + } +} + +function messageFrom(error: unknown): string { + return error instanceof Error ? error.message : 'Something went wrong.'; +} + +/** + * Lightweight highlighter for our own predictable SVG output: tags, attribute + * names, and quoted values. `textContent` of the result is still the raw SVG. + */ +function highlightSvg(svg: string): string { + return svg + .split(/(<[^>]*>)/) + .map(part => (part.startsWith('<') ? highlightTag(part) : escapeHtml(part))) + .join(''); +} + +function highlightTag(tag: string): string { + return tag.replace( + /(<\/?)([A-Za-z][\w-]*)|([A-Za-z][\w-]*)(=)("[^"]*")|(\/?>)/g, + (match, open, name, attr, equals, value, close) => { + if (name !== undefined) { + return escapeHtml(open) + span(codeTag, name); + } + if (attr !== undefined) { + return span(codeAttr, attr) + escapeHtml(equals) + span(codeString, value); + } + if (close !== undefined) { + return escapeHtml(close); + } + return escapeHtml(match); + } + ); +} + +function span(className: string, text: string): string { + return `${escapeHtml(text)}`; +} + +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>'); +} diff --git a/src/pages/tools/_3d-to-svg/svg.worker.ts b/src/pages/tools/_3d-to-svg/svg.worker.ts new file mode 100644 index 0000000..629d58e --- /dev/null +++ b/src/pages/tools/_3d-to-svg/svg.worker.ts @@ -0,0 +1,49 @@ +/// +import type {EmitMode} from './emit'; +import {emitFlatSvg, emitShadedSvg} from './emit'; +import type {LoadedMesh, Mat3} from './geometry'; +import {projectAndCull, projectShaded} from './geometry'; +import type {WorkerReply, WorkerRequest} from './protocol'; + +/* + * Script. + */ + +const ctx = self as unknown as DedicatedWorkerGlobalScope; + +let mesh: LoadedMesh | undefined; + +/** + * Run the clipper silhouette off the main thread. + * Holds the current mesh and posts SVG/error replies. + */ +ctx.onmessage = (event: MessageEvent): void => { + const request = event.data; + if (request.type === 'mesh') { + mesh = request.mesh; + return; + } + if (mesh === undefined) { + return; + } + ctx.postMessage(emitReply(mesh, request.id, request.orientation, request.mode)); +}; + +/* + * Helpers. + */ + +function emitReply(loaded: LoadedMesh, id: number, orientation: Mat3, mode: EmitMode): WorkerReply { + try { + return {type: 'svg', id, svg: renderSvg(loaded, orientation, mode)}; + } catch (error) { + return {type: 'error', id, message: error instanceof Error ? error.message : 'Silhouette failed'}; + } +} + +function renderSvg(loaded: LoadedMesh, orientation: Mat3, mode: EmitMode): string { + if (mode === 'shaded') { + return emitShadedSvg(projectAndCull(loaded, orientation), projectShaded(loaded, orientation)); + } + return emitFlatSvg(projectAndCull(loaded, orientation)); +} diff --git a/src/pages/tools/index.astro b/src/pages/tools/index.astro new file mode 100644 index 0000000..c48bfcc --- /dev/null +++ b/src/pages/tools/index.astro @@ -0,0 +1,25 @@ +--- +import BaseLayout from '../../components/BaseLayout.astro'; +import ProjectList from '../../components/ProjectList.astro'; +import Prose from '../../components/Prose.astro'; +import {tools} from '../../data/projects'; +import {formatTitle} from '../../site'; +import {sectionLead} from '../../styles/projects.css'; + +const pageDescription = + "A collection of web-based tools I've made. 3D to SVG turns a 3D model into an SVG icon."; +--- + + + + Tools + + A collection of web-based tools I've made. + + + + diff --git a/src/site.ts b/src/site.ts index fe6febd..1c8215e 100644 --- a/src/site.ts +++ b/src/site.ts @@ -17,5 +17,5 @@ export function formatTitle(pageTitle: string) { return `${siteTitle} | ${pageTitle}`; } -export {activeProjects, olderProjects, workspaceTools} from './data/projects'; +export {activeProjects, olderProjects, tools, workspaceTools} from './data/projects'; export type {Project, ProjectDetail, ProjectDetailPart} from './data/projects'; diff --git a/src/styles/global.css.ts b/src/styles/global.css.ts index 5473522..73af253 100644 --- a/src/styles/global.css.ts +++ b/src/styles/global.css.ts @@ -187,3 +187,21 @@ export const main = style({ } } }); + +// Wide container for interactive pages that escape the prose column (e.g. tools). +export const mainFullBleed = style({ + // Keep the side padding inside the 100% width so it never overflows the viewport. + boxSizing: 'border-box', + width: '100%', + maxWidth: '1200px', + margin: '0 auto', + padding: '2em 1.5em', + '@media': { + [media.content]: { + padding: '1.5em 1em' + }, + [media.narrow]: { + padding: '1.25em 0.75em' + } + } +}); diff --git a/tsconfig.json b/tsconfig.json index b8b0902..11ce4ef 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,11 @@ "noEmit": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + // `vp test` aliases `vitest` to the Vite+ test runner at runtime; mirror that for tsc/oxlint. + "paths": { + "vitest": ["./node_modules/@voidzero-dev/vite-plus-test/dist/index.d.ts"] + } }, "include": [".astro/types.d.ts", "**/*"], "exclude": ["dist"]
A collection of web-based tools I've made.