@glbforge/core
Advanced tools
| /** | ||
| * Color quantization for layered extrusion: k-means over the solid pixels, | ||
| * then a 3x3 majority filter over the label map — anti-aliased edge pixels | ||
| * otherwise form thin halo rings between color regions. | ||
| */ | ||
| export interface Quantization { | ||
| /** Per-pixel cluster index (-1 outside the solid mask). */ | ||
| labels: Int16Array; | ||
| /** Cluster colors, sRGB 0-255. */ | ||
| colors: Array<[number, number, number]>; | ||
| /** Solid-pixel count per cluster. */ | ||
| counts: number[]; | ||
| } | ||
| export declare function quantizeColors(px: Uint8Array | Buffer, mask: Uint8Array, width: number, height: number, k: number): Quantization; | ||
| /** sRGB 0-255 -> linear 0-1 (glTF baseColorFactor space). */ | ||
| export declare function srgbToLinear(value: number): number; |
| /** | ||
| * Color quantization for layered extrusion: k-means over the solid pixels, | ||
| * then a 3x3 majority filter over the label map — anti-aliased edge pixels | ||
| * otherwise form thin halo rings between color regions. | ||
| */ | ||
| export function quantizeColors(px, mask, width, height, k) { | ||
| const total = width * height; | ||
| // Sample for fitting (cap ~40k points for speed). | ||
| const solidIdx = []; | ||
| for (let i = 0; i < total; i++) | ||
| if (mask[i]) | ||
| solidIdx.push(i); | ||
| if (solidIdx.length === 0) { | ||
| return { labels: new Int16Array(total).fill(-1), colors: [], counts: [] }; | ||
| } | ||
| const stride = Math.max(1, Math.floor(solidIdx.length / 40_000)); | ||
| const samples = []; | ||
| for (let s = 0; s < solidIdx.length; s += stride) | ||
| samples.push(solidIdx[s]); | ||
| // Init centroids spread along luminance order (stable, no RNG). | ||
| const byLuma = [...samples].sort((a, b) => { | ||
| const la = px[a * 4] * 0.2126 + px[a * 4 + 1] * 0.7152 + px[a * 4 + 2] * 0.0722; | ||
| const lb = px[b * 4] * 0.2126 + px[b * 4 + 1] * 0.7152 + px[b * 4 + 2] * 0.0722; | ||
| return la - lb; | ||
| }); | ||
| const centroids = []; | ||
| for (let c = 0; c < k; c++) { | ||
| const i = byLuma[Math.floor(((c + 0.5) / k) * byLuma.length)]; | ||
| centroids.push([px[i * 4], px[i * 4 + 1], px[i * 4 + 2]]); | ||
| } | ||
| const nearest = (r, g, b) => { | ||
| let best = 0, bestDist = Infinity; | ||
| for (let c = 0; c < centroids.length; c++) { | ||
| const dr = r - centroids[c][0], dg = g - centroids[c][1], db = b - centroids[c][2]; | ||
| const dist = dr * dr + dg * dg + db * db; | ||
| if (dist < bestDist) { | ||
| bestDist = dist; | ||
| best = c; | ||
| } | ||
| } | ||
| return best; | ||
| }; | ||
| for (let iter = 0; iter < 12; iter++) { | ||
| const sums = centroids.map(() => [0, 0, 0, 0]); | ||
| for (const i of samples) { | ||
| const c = nearest(px[i * 4], px[i * 4 + 1], px[i * 4 + 2]); | ||
| sums[c][0] += px[i * 4]; | ||
| sums[c][1] += px[i * 4 + 1]; | ||
| sums[c][2] += px[i * 4 + 2]; | ||
| sums[c][3]++; | ||
| } | ||
| let moved = 0; | ||
| for (let c = 0; c < centroids.length; c++) { | ||
| if (!sums[c][3]) | ||
| continue; | ||
| const next = [ | ||
| sums[c][0] / sums[c][3], sums[c][1] / sums[c][3], sums[c][2] / sums[c][3], | ||
| ]; | ||
| moved += Math.abs(next[0] - centroids[c][0]) + Math.abs(next[1] - centroids[c][1]) + Math.abs(next[2] - centroids[c][2]); | ||
| centroids[c] = next; | ||
| } | ||
| if (moved < 1) | ||
| break; | ||
| } | ||
| // Assign every solid pixel. | ||
| const labels = new Int16Array(total).fill(-1); | ||
| for (const i of solidIdx) { | ||
| labels[i] = nearest(px[i * 4], px[i * 4 + 1], px[i * 4 + 2]); | ||
| } | ||
| // 3x3 majority filter (2 passes): removes AA halos and speckle. | ||
| for (let pass = 0; pass < 2; pass++) { | ||
| const prev = Int16Array.from(labels); | ||
| for (let y = 0; y < height; y++) { | ||
| for (let x = 0; x < width; x++) { | ||
| const i = y * width + x; | ||
| if (prev[i] < 0) | ||
| continue; | ||
| const votes = new Map(); | ||
| for (let dy = -1; dy <= 1; dy++) { | ||
| for (let dx = -1; dx <= 1; dx++) { | ||
| const nx = x + dx, ny = y + dy; | ||
| if (nx < 0 || ny < 0 || nx >= width || ny >= height) | ||
| continue; | ||
| const lab = prev[ny * width + nx]; | ||
| if (lab >= 0) | ||
| votes.set(lab, (votes.get(lab) ?? 0) + 1); | ||
| } | ||
| } | ||
| let best = prev[i], bestVotes = 0; | ||
| for (const [lab, n] of votes) | ||
| if (n > bestVotes) { | ||
| bestVotes = n; | ||
| best = lab; | ||
| } | ||
| labels[i] = best; | ||
| } | ||
| } | ||
| } | ||
| const counts = centroids.map(() => 0); | ||
| for (const i of solidIdx) | ||
| if (labels[i] >= 0) | ||
| counts[labels[i]]++; | ||
| return { | ||
| labels, | ||
| colors: centroids.map((c) => [Math.round(c[0]), Math.round(c[1]), Math.round(c[2])]), | ||
| counts, | ||
| }; | ||
| } | ||
| /** sRGB 0-255 -> linear 0-1 (glTF baseColorFactor space). */ | ||
| export function srgbToLinear(value) { | ||
| const c = value / 255; | ||
| return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); | ||
| } |
| /** | ||
| * Exact Euclidean distance transform (Felzenszwalb & Huttenlocher): | ||
| * distance in pixels from each solid pixel to the nearest outside pixel. | ||
| * Drives pillow/relief height profiles. | ||
| */ | ||
| /** Distance (px) from each pixel to the nearest zero-mask pixel. */ | ||
| export declare function distanceTransform(mask: Uint8Array, width: number, height: number): Float32Array; | ||
| /** Bilinear sample of the distance field at fractional pixel coords. */ | ||
| export declare function sampleDistance(dist: Float32Array, width: number, height: number, x: number, y: number): number; |
| /** | ||
| * Exact Euclidean distance transform (Felzenszwalb & Huttenlocher): | ||
| * distance in pixels from each solid pixel to the nearest outside pixel. | ||
| * Drives pillow/relief height profiles. | ||
| */ | ||
| const INF = 1e20; | ||
| function edt1d(f, n, d) { | ||
| const v = new Int32Array(n); | ||
| const z = new Float64Array(n + 1); | ||
| let k = 0; | ||
| v[0] = 0; | ||
| z[0] = -INF; | ||
| z[1] = INF; | ||
| for (let q = 1; q < n; q++) { | ||
| let s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]); | ||
| while (s <= z[k]) { | ||
| k--; | ||
| s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]); | ||
| } | ||
| k++; | ||
| v[k] = q; | ||
| z[k] = s; | ||
| z[k + 1] = INF; | ||
| } | ||
| k = 0; | ||
| for (let q = 0; q < n; q++) { | ||
| while (z[k + 1] < q) | ||
| k++; | ||
| d[q] = (q - v[k]) * (q - v[k]) + f[v[k]]; | ||
| } | ||
| } | ||
| /** Distance (px) from each pixel to the nearest zero-mask pixel. */ | ||
| export function distanceTransform(mask, width, height) { | ||
| const grid = new Float64Array(width * height); | ||
| for (let i = 0; i < grid.length; i++) | ||
| grid[i] = mask[i] ? INF : 0; | ||
| const f = new Float64Array(Math.max(width, height)); | ||
| const d = new Float64Array(Math.max(width, height)); | ||
| // Columns. | ||
| for (let x = 0; x < width; x++) { | ||
| for (let y = 0; y < height; y++) | ||
| f[y] = grid[y * width + x]; | ||
| edt1d(f, height, d); | ||
| for (let y = 0; y < height; y++) | ||
| grid[y * width + x] = d[y]; | ||
| } | ||
| // Rows. | ||
| for (let y = 0; y < height; y++) { | ||
| for (let x = 0; x < width; x++) | ||
| f[x] = grid[y * width + x]; | ||
| edt1d(f, width, d); | ||
| for (let x = 0; x < width; x++) | ||
| grid[y * width + x] = d[x]; | ||
| } | ||
| const out = new Float32Array(width * height); | ||
| for (let i = 0; i < out.length; i++) | ||
| out[i] = Math.sqrt(grid[i]); | ||
| return out; | ||
| } | ||
| /** Bilinear sample of the distance field at fractional pixel coords. */ | ||
| export function sampleDistance(dist, width, height, x, y) { | ||
| const cx = Math.min(Math.max(x, 0), width - 1.001); | ||
| const cy = Math.min(Math.max(y, 0), height - 1.001); | ||
| const x0 = Math.floor(cx), y0 = Math.floor(cy); | ||
| const fx = cx - x0, fy = cy - y0; | ||
| const i = y0 * width + x0; | ||
| return (dist[i] * (1 - fx) * (1 - fy) + | ||
| dist[i + 1] * fx * (1 - fy) + | ||
| dist[i + width] * (1 - fx) * fy + | ||
| dist[i + width + 1] * fx * fy); | ||
| } |
@@ -15,2 +15,5 @@ import { Document } from '@gltf-transform/core'; | ||
| imageHeight: number; | ||
| /** Pillow/relief: extra front-cap height (meters) at trace coords (x, y). | ||
| * Must be ~0 along contours so walls stay sealed. Disables the bevel. */ | ||
| frontHeightFn?: (x: number, y: number) => number; | ||
| } | ||
@@ -17,0 +20,0 @@ export interface ExtrudeStats { |
+125
-1
@@ -20,3 +20,3 @@ import earcut from 'earcut'; | ||
| const hz = depth / 2; | ||
| const bevel = Math.min(opts.bevel ?? 0, depth * 0.49); | ||
| const bevel = opts.frontHeightFn ? 0 : Math.min(opts.bevel ?? 0, depth * 0.49); | ||
| const bevelPx = bevel / scale; | ||
@@ -163,2 +163,7 @@ const segments = Math.max(1, Math.round(opts.bevelSegments ?? 3)); | ||
| const tris = earcut(flat, holeIndices.length ? holeIndices : undefined); | ||
| if (nz === 1 && opts.frontHeightFn) { | ||
| // Pillow front cap: re-tessellate densely, displace, smooth-shade. | ||
| buildDisplacedCap(tris, globalIds, holeIndices, hz, opts.frontHeightFn); | ||
| continue; | ||
| } | ||
| for (let t = 0; t < tris.length; t += 3) { | ||
@@ -172,2 +177,121 @@ let [a, b, c] = [globalIds[tris[t]], globalIds[tris[t + 1]], globalIds[tris[t + 2]]]; | ||
| } | ||
| /** | ||
| * Densified, displaced front cap. Uniform 4:1 subdivision (no T-junctions | ||
| * by construction) in TRACE coordinates, then each vertex is lifted by the | ||
| * height function. Rim vertices reuse the existing strip-top ids so the | ||
| * cap stays sealed to the walls; the height function is ~0 there anyway. | ||
| */ | ||
| function buildDisplacedCap(tris, rimIds, holeStarts, zBase, heightFn) { | ||
| // Recover trace coords for the rim ring from world positions (invert toWorld). | ||
| const traceXY = []; | ||
| for (const id of rimIds) { | ||
| traceXY.push(positions[id * 3] / scale + cx, cy - positions[id * 3 + 1] / scale); | ||
| } | ||
| let verts = traceXY; // [x, y] per vertex, trace space | ||
| let faces = [...tris]; | ||
| // Vertex ids: first rimIds.length map to existing ids; new ones appended. | ||
| const isRim = (i) => i < rimIds.length; | ||
| // Ring (contour) edges must NEVER split: the wall quads keep whole | ||
| // edges, so splitting the cap's rim would create T-junction cracks. | ||
| const edgeKey = (a, b) => (a < b ? a * 1e7 + b : b * 1e7 + a); | ||
| const ringEdges = new Set(); | ||
| const starts = [0, ...holeStarts, rimIds.length]; | ||
| for (let r = 0; r < starts.length - 1; r++) { | ||
| for (let i = starts[r]; i < starts[r + 1]; i++) { | ||
| const j = i + 1 === starts[r + 1] ? starts[r] : i + 1; | ||
| ringEdges.add(edgeKey(i, j)); | ||
| } | ||
| } | ||
| const ROUNDS = verts.length / 2 < 600 ? 4 : 3; | ||
| const MAX_TRIS = 120_000; | ||
| for (let round = 0; round < ROUNDS && (faces.length / 3) * 4 <= MAX_TRIS; round++) { | ||
| const mid = new Map(); | ||
| const nextFaces = []; | ||
| const midpoint = (a, b) => { | ||
| const key = edgeKey(a, b); | ||
| if (ringEdges.has(key)) | ||
| return null; | ||
| const hit = mid.get(key); | ||
| if (hit !== undefined) | ||
| return hit; | ||
| const idx = verts.length / 2; | ||
| verts.push((verts[a * 2] + verts[b * 2]) / 2, (verts[a * 2 + 1] + verts[b * 2 + 1]) / 2); | ||
| mid.set(key, idx); | ||
| return idx; | ||
| }; | ||
| for (let t = 0; t < faces.length; t += 3) { | ||
| const [a, b, c] = [faces[t], faces[t + 1], faces[t + 2]]; | ||
| const ab = midpoint(a, b), bc = midpoint(b, c), ca = midpoint(c, a); | ||
| const splits = [ab, bc, ca].filter((m) => m !== null).length; | ||
| if (splits === 3) { | ||
| nextFaces.push(a, ab, ca, ab, b, bc, ca, bc, c, ab, bc, ca); | ||
| } | ||
| else if (splits === 2) { | ||
| // Rotate so the unsplit edge is (a, b). | ||
| let [p, q, r2, m1, m2] = ab === null | ||
| ? [a, b, c, bc, ca] | ||
| : bc === null | ||
| ? [b, c, a, ca, ab] | ||
| : [c, a, b, ab, bc]; | ||
| nextFaces.push(p, q, m1, p, m1, m2, m2, m1, r2); | ||
| } | ||
| else if (splits === 1) { | ||
| const m = (ab ?? bc ?? ca); | ||
| if (ab !== null) | ||
| nextFaces.push(a, m, c, m, b, c); | ||
| else if (bc !== null) | ||
| nextFaces.push(b, m, a, m, c, a); | ||
| else | ||
| nextFaces.push(c, m, b, m, a, b); | ||
| } | ||
| else { | ||
| nextFaces.push(a, b, c); | ||
| } | ||
| } | ||
| faces = nextFaces; | ||
| } | ||
| // Emit vertices: rim ring reuses existing ids (sealed to walls); new | ||
| // interior/midpoint vertices are pushed with displaced z. | ||
| const emitted = []; | ||
| for (let i = 0; i < verts.length / 2; i++) { | ||
| if (isRim(i)) { | ||
| emitted.push(rimIds[i]); | ||
| } | ||
| else { | ||
| const x = verts[i * 2], y = verts[i * 2 + 1]; | ||
| emitted.push(pushVert(x, y, zBase + heightFn(x, y), [0, 0, 1])); | ||
| } | ||
| } | ||
| // Faces (winding normalized against +z), collecting for normal pass. | ||
| const capFaces = []; | ||
| for (let t = 0; t < faces.length; t += 3) { | ||
| let [a, b, c] = [emitted[faces[t]], emitted[faces[t + 1]], emitted[faces[t + 2]]]; | ||
| if (Math.sign(triNormalZ(positions, a, b, c)) !== 1) | ||
| [b, c] = [c, b]; | ||
| indices.push(a, b, c); | ||
| capFaces.push(a, b, c); | ||
| } | ||
| // Smooth normals over the displaced surface (area-weighted). | ||
| const acc = new Map(); | ||
| for (let t = 0; t < capFaces.length; t += 3) { | ||
| const [a, b, c] = [capFaces[t], capFaces[t + 1], capFaces[t + 2]]; | ||
| const ax = positions[a * 3], ay = positions[a * 3 + 1], az = positions[a * 3 + 2]; | ||
| const ux = positions[b * 3] - ax, uy = positions[b * 3 + 1] - ay, uz = positions[b * 3 + 2] - az; | ||
| const vx = positions[c * 3] - ax, vy = positions[c * 3 + 1] - ay, vz = positions[c * 3 + 2] - az; | ||
| const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nzc = ux * vy - uy * vx; | ||
| for (const vId of [a, b, c]) { | ||
| const cur = acc.get(vId) ?? [0, 0, 0]; | ||
| cur[0] += nx; | ||
| cur[1] += ny; | ||
| cur[2] += nzc; | ||
| acc.set(vId, cur); | ||
| } | ||
| } | ||
| for (const [vId, n] of acc) { | ||
| const len = Math.hypot(n[0], n[1], n[2]) || 1; | ||
| normals[vId * 3] = n[0] / len; | ||
| normals[vId * 3 + 1] = n[1] / len; | ||
| normals[vId * 3 + 2] = n[2] / len; | ||
| } | ||
| } | ||
| stitchCracks(positions, normals, indices); | ||
@@ -174,0 +298,0 @@ return { |
@@ -18,2 +18,13 @@ import { Document } from '@gltf-transform/core'; | ||
| bevelSegments?: number; | ||
| /** Layered color extrusion: quantize into this many color layers (2-6). | ||
| * Each layer extrudes at a stepped depth with a flat material in its | ||
| * cluster color — the "layered acrylic" look. Omit/0 = single layer. */ | ||
| layers?: number; | ||
| /** Extra depth per layer (meters). Default depth * 0.5. */ | ||
| layerStep?: number; | ||
| /** Pillow relief: puffy-sticker dome height (meters) on the front face. | ||
| * 0/omit = flat. Supersedes bevel (the pillow IS the rounded profile). */ | ||
| pillow?: number; | ||
| /** Material preset applied to all forge materials. */ | ||
| preset?: 'enamel' | 'chrome' | 'neon' | 'acrylic' | 'rubber'; | ||
| /** Project the source image onto the mesh as baseColor. Default true. */ | ||
@@ -25,3 +36,14 @@ texture?: boolean; | ||
| roughness?: number; | ||
| /** Pre-encoded artwork to project as baseColor (browser path; Node's | ||
| * extrudeImage generates this via sharp automatically). */ | ||
| textureBytes?: { | ||
| bytes: Uint8Array; | ||
| mimeType: string; | ||
| }; | ||
| } | ||
| export interface LayerInfo { | ||
| color: [number, number, number]; | ||
| depth: number; | ||
| triangles: number; | ||
| } | ||
| export interface ExtrudeResult { | ||
@@ -33,10 +55,16 @@ doc: Document; | ||
| traceHeight: number; | ||
| layerInfo?: LayerInfo[]; | ||
| }; | ||
| } | ||
| /** | ||
| * Turn a logo/graphic image into an extruded 3D GLB document. | ||
| * Turn a logo/graphic image into an extruded 3D GLB document (Node entry). | ||
| * Accepts PNG/JPEG/WebP — and SVG, which sharp rasterizes at high density | ||
| * before tracing (the marching-squares grid is the accuracy limit either | ||
| * way, so rasterized vectors lose nothing at trace resolution). | ||
| * before tracing. Browsers decode with canvas and call extrudeFromRgba. | ||
| */ | ||
| export declare function extrudeImage(imageBytes: Uint8Array, opts?: ExtrudeOptions): Promise<ExtrudeResult>; | ||
| /** | ||
| * Pure, environment-agnostic extrusion from decoded RGBA pixels (row-major, | ||
| * 4 bytes/px). This is the whole pipeline minus image decoding — safe in | ||
| * browsers, workers, and Node alike. | ||
| */ | ||
| export declare function extrudeFromRgba(px: Uint8Array, tw: number, th: number, opts?: ExtrudeOptions): Promise<ExtrudeResult>; |
+193
-39
| import { Document } from '@gltf-transform/core'; | ||
| import sharp from 'sharp'; | ||
| import { pointInLoop as pointInLoopPub, traceMask } from './trace.js'; | ||
| import { pointInLoop, traceMask } from './trace.js'; | ||
| import { buildExtrusion } from './build.js'; | ||
| import { quantizeColors, srgbToLinear } from './layers.js'; | ||
| import { distanceTransform, sampleDistance } from './relief.js'; | ||
| import { KHRMaterialsTransmission } from '@gltf-transform/extensions'; | ||
| const TRACE_MAX = 1024; // tracing resolution cap; texture keeps up to 2048 | ||
| /** | ||
| * Turn a logo/graphic image into an extruded 3D GLB document. | ||
| * Turn a logo/graphic image into an extruded 3D GLB document (Node entry). | ||
| * Accepts PNG/JPEG/WebP — and SVG, which sharp rasterizes at high density | ||
| * before tracing (the marching-squares grid is the accuracy limit either | ||
| * way, so rasterized vectors lose nothing at trace resolution). | ||
| * before tracing. Browsers decode with canvas and call extrudeFromRgba. | ||
| */ | ||
| export async function extrudeImage(imageBytes, opts = {}) { | ||
| const sharp = (await import('sharp')).default; | ||
| // SVG inputs get rasterized generously so the trace grid is saturated. | ||
@@ -21,5 +23,2 @@ const isSvg = looksLikeSvg(imageBytes); | ||
| } | ||
| const meta = await sharp(imageBytes).metadata(); | ||
| const hasAlpha = meta.hasAlpha ?? false; | ||
| const mode = opts.mode ?? (hasAlpha ? 'alpha' : 'luma'); | ||
| const raw = await sharp(imageBytes) | ||
@@ -30,4 +29,27 @@ .resize(TRACE_MAX, TRACE_MAX, { fit: 'inside', withoutEnlargement: true }) | ||
| .toBuffer({ resolveWithObject: true }); | ||
| const { width: tw, height: th } = raw.info; | ||
| const px = raw.data; | ||
| let textureBytes = opts.textureBytes; | ||
| if (opts.texture !== false && !textureBytes) { | ||
| const png = await sharp(imageBytes) | ||
| .resize(2048, 2048, { fit: 'inside', withoutEnlargement: true }) | ||
| .png() | ||
| .toBuffer(); | ||
| textureBytes = { bytes: new Uint8Array(png), mimeType: 'image/png' }; | ||
| } | ||
| return extrudeFromRgba(new Uint8Array(raw.data), raw.info.width, raw.info.height, { ...opts, textureBytes }); | ||
| } | ||
| /** | ||
| * Pure, environment-agnostic extrusion from decoded RGBA pixels (row-major, | ||
| * 4 bytes/px). This is the whole pipeline minus image decoding — safe in | ||
| * browsers, workers, and Node alike. | ||
| */ | ||
| export async function extrudeFromRgba(px, tw, th, opts = {}) { | ||
| // Auto mode: alpha if the alpha channel actually varies. | ||
| let hasAlpha = false; | ||
| for (let i = 3; i < px.length; i += 4) { | ||
| if (px[i] < 250) { | ||
| hasAlpha = true; | ||
| break; | ||
| } | ||
| } | ||
| const mode = opts.mode ?? (hasAlpha ? 'alpha' : 'luma'); | ||
| const mask = new Uint8Array(tw * th); | ||
@@ -46,23 +68,3 @@ if (mode === 'alpha') { | ||
| } | ||
| let loops = traceMask(mask, tw, th, { simplify: opts.simplify ?? 1.2 }); | ||
| // Drop specks (< 0.005% of image area) — antialiasing noise, not shapes. | ||
| const minArea = tw * th * 0.00005; | ||
| loops = loops.filter((l) => l.area >= minArea); | ||
| // Re-derive nesting after filtering (parents may be gone). | ||
| loops.forEach((l, i) => { | ||
| l.depth = 0; | ||
| l.parent = -1; | ||
| // recomputed below | ||
| }); | ||
| for (let i = 0; i < loops.length; i++) { | ||
| const containers = []; | ||
| for (let j = 0; j < loops.length; j++) { | ||
| if (i !== j && pointInLoopPub(loops[i].points[0], loops[j].points)) | ||
| containers.push(j); | ||
| } | ||
| loops[i].depth = containers.length; | ||
| if (containers.length) { | ||
| loops[i].parent = containers.reduce((best, j) => loops[j].area < loops[best].area ? j : best, containers[0]); | ||
| } | ||
| } | ||
| const loops = cleanLoops(traceMask(mask, tw, th, { simplify: opts.simplify ?? 1.2 }), tw, th); | ||
| if (loops.length > 150) { | ||
@@ -78,2 +80,5 @@ throw new Error(`Traced ${loops.length} contours — this looks like a photograph or a noisy mask, ` + | ||
| } | ||
| if (opts.layers && opts.layers >= 2) { | ||
| return extrudeLayered(px, mask, tw, th, mode, opts); | ||
| } | ||
| const doc = new Document(); | ||
@@ -88,2 +93,3 @@ doc.createBuffer(); | ||
| imageHeight: th, | ||
| frontHeightFn: makeHeightFn(opts, mask, tw, th), | ||
| }); | ||
@@ -95,11 +101,14 @@ const material = doc | ||
| .setDoubleSided(false); | ||
| if (opts.texture !== false) { | ||
| // Re-encode the source as PNG (capped at 2048) and project it via the | ||
| // pixel-space UVs — gradients and glows survive without any painting. | ||
| const png = await sharp(imageBytes) | ||
| .resize(2048, 2048, { fit: 'inside', withoutEnlargement: true }) | ||
| .png() | ||
| .toBuffer(); | ||
| const texture = doc.createTexture('source').setImage(png).setMimeType('image/png'); | ||
| applyPreset(material, opts.preset, null); | ||
| if (opts.texture !== false && opts.textureBytes) { | ||
| // Project the source artwork via the pixel-space UVs — gradients and | ||
| // glows survive without any painting. | ||
| const texture = doc.createTexture('source') | ||
| .setImage(opts.textureBytes.bytes) | ||
| .setMimeType(opts.textureBytes.mimeType); | ||
| material.setBaseColorTexture(texture); | ||
| if (opts.preset === 'neon') { | ||
| // Glow the artwork itself. | ||
| material.setEmissiveTexture(texture).setEmissiveFactor([1, 1, 1]); | ||
| } | ||
| } | ||
@@ -131,1 +140,146 @@ else if (opts.color) { | ||
| } | ||
| /** Drop specks and re-derive containment nesting after filtering. */ | ||
| function cleanLoops(loops, width, height) { | ||
| const minArea = width * height * 0.00005; | ||
| const kept = loops.filter((l) => l.area >= minArea); | ||
| for (let i = 0; i < kept.length; i++) { | ||
| const containers = []; | ||
| for (let j = 0; j < kept.length; j++) { | ||
| if (i !== j && pointInLoop(kept[i].points[0], kept[j].points)) | ||
| containers.push(j); | ||
| } | ||
| kept[i].depth = containers.length; | ||
| kept[i].parent = containers.length | ||
| ? containers.reduce((best, j) => (kept[j].area < kept[best].area ? j : best), containers[0]) | ||
| : -1; | ||
| } | ||
| return kept; | ||
| } | ||
| /** | ||
| * Layered color extrusion: cluster the artwork's colors, trace each color | ||
| * region, and extrude each at a stepped depth (backs coplanar). One | ||
| * primitive + flat material per layer; larger-area colors sit lower so | ||
| * details pop forward. | ||
| */ | ||
| async function extrudeLayered(px, mask, tw, th, mode, opts) { | ||
| const k = Math.min(6, Math.max(2, opts.layers)); | ||
| const { labels, colors, counts } = quantizeColors(px, mask, tw, th, k); | ||
| const width = opts.width ?? 1; | ||
| const baseDepth = opts.depth ?? width * 0.08; | ||
| const step = opts.layerStep ?? baseDepth * 0.5; | ||
| // Larger-area clusters are backdrop; smaller ones pop forward. | ||
| const order = colors | ||
| .map((_, c) => c) | ||
| .filter((c) => counts[c] > 0) | ||
| .sort((a, b) => counts[b] - counts[a]); | ||
| const doc = new Document(); | ||
| doc.createBuffer(); | ||
| const scene = doc.createScene('scene'); | ||
| const stats = { | ||
| loops: 0, outerLoops: 0, holes: 0, triangles: 0, vertices: 0, | ||
| mode, traceWidth: tw, traceHeight: th, | ||
| layerInfo: [], | ||
| }; | ||
| let totalContours = 0; | ||
| for (const [layerIdx, cluster] of order.entries()) { | ||
| const layerMask = new Uint8Array(tw * th); | ||
| for (let i = 0; i < layerMask.length; i++) | ||
| layerMask[i] = labels[i] === cluster ? 1 : 0; | ||
| const loops = cleanLoops(traceMask(layerMask, tw, th, { simplify: opts.simplify ?? 1.2 }), tw, th); | ||
| totalContours += loops.length; | ||
| if (totalContours > 300) { | ||
| throw new Error('Layered tracing produced too many contours — the image looks photographic. ' + | ||
| 'Use fewer layers, a cleaner graphic, or Meshy image-to-3D for photos.'); | ||
| } | ||
| if (loops.filter((l) => l.depth % 2 === 0).length === 0) | ||
| continue; | ||
| const depth = baseDepth + layerIdx * step; | ||
| const geo = buildExtrusion(doc, loops, { | ||
| width: opts.width, | ||
| depth, | ||
| bevel: opts.bevel, | ||
| bevelSegments: opts.bevelSegments, | ||
| imageWidth: tw, | ||
| imageHeight: th, | ||
| frontHeightFn: makeHeightFn(opts, layerMask, tw, th), | ||
| }); | ||
| const [r, g, b] = colors[cluster]; | ||
| const linear = [srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)]; | ||
| const material = doc | ||
| .createMaterial(`layer-${layerIdx}`) | ||
| .setBaseColorFactor([...linear, 1]) | ||
| .setMetallicFactor(opts.metallic ?? 0) | ||
| .setRoughnessFactor(opts.roughness ?? 0.45); | ||
| applyPreset(material, opts.preset, linear); | ||
| const buffer = doc.getRoot().listBuffers()[0]; | ||
| const prim = doc | ||
| .createPrimitive() | ||
| .setAttribute('POSITION', doc.createAccessor().setType('VEC3').setArray(geo.positions).setBuffer(buffer)) | ||
| .setAttribute('NORMAL', doc.createAccessor().setType('VEC3').setArray(geo.normals).setBuffer(buffer)) | ||
| .setAttribute('TEXCOORD_0', doc.createAccessor().setType('VEC2').setArray(geo.uvs).setBuffer(buffer)) | ||
| .setIndices(doc.createAccessor().setType('SCALAR').setArray(geo.indices).setBuffer(buffer)) | ||
| .setMaterial(material); | ||
| const mesh = doc.createMesh(`layer-${layerIdx}`).addPrimitive(prim); | ||
| // Backs coplanar: each build centers on its own depth, so shift by half | ||
| // the extra depth this layer has over the base layer. | ||
| const node = doc.createNode(`layer-${layerIdx}`).setMesh(mesh) | ||
| .setTranslation([0, 0, (depth - baseDepth) / 2]); | ||
| scene.addChild(node); | ||
| stats.loops += geo.stats.loops; | ||
| stats.outerLoops += geo.stats.outerLoops; | ||
| stats.holes += geo.stats.holes; | ||
| stats.triangles += geo.stats.triangles; | ||
| stats.vertices += geo.stats.vertices; | ||
| stats.layerInfo.push({ color: colors[cluster], depth, triangles: geo.stats.triangles }); | ||
| } | ||
| if (stats.layerInfo.length === 0) { | ||
| throw new Error('No layers produced any shapes — try fewer layers or a different threshold.'); | ||
| } | ||
| doc.getRoot().getAsset().generator = 'glbforge extrude'; | ||
| return { doc, stats }; | ||
| } | ||
| /** Pillow height function over a mask: H * sqrt(min(D, R)/R), 0 at edges. */ | ||
| function makeHeightFn(opts, mask, tw, th) { | ||
| const heightM = opts.pillow ?? 0; | ||
| if (heightM <= 0) | ||
| return undefined; | ||
| const width = opts.width ?? 1; | ||
| const scale = width / tw; // meters per trace px | ||
| const rolloffPx = Math.max(4, heightM / scale); | ||
| const dist = distanceTransform(mask, tw, th); | ||
| return (x, y) => { | ||
| const d = sampleDistance(dist, tw, th, x, y); | ||
| return heightM * Math.sqrt(Math.min(d, rolloffPx) / rolloffPx); | ||
| }; | ||
| } | ||
| /** Forge material presets. Layered materials pass their cluster color. */ | ||
| function applyPreset(material, preset, layerColor) { | ||
| if (!preset) | ||
| return; | ||
| switch (preset) { | ||
| case 'enamel': | ||
| material.setMetallicFactor(0.85).setRoughnessFactor(0.25); | ||
| break; | ||
| case 'chrome': | ||
| material.setMetallicFactor(1).setRoughnessFactor(0.08); | ||
| break; | ||
| case 'rubber': | ||
| material.setMetallicFactor(0).setRoughnessFactor(0.95); | ||
| break; | ||
| case 'neon': | ||
| material.setRoughnessFactor(0.4); | ||
| if (layerColor) { | ||
| material.setEmissiveFactor(layerColor); | ||
| material.setBaseColorFactor([layerColor[0] * 0.15, layerColor[1] * 0.15, layerColor[2] * 0.15, 1]); | ||
| } | ||
| // Textured neon is wired at the texture-assignment site. | ||
| break; | ||
| case 'acrylic': { | ||
| const document = Document.fromGraph(material.getGraph()); | ||
| const transmission = document.createExtension(KHRMaterialsTransmission); | ||
| material.setExtension('KHR_materials_transmission', transmission.createTransmission().setTransmissionFactor(0.85)); | ||
| material.setRoughnessFactor(0.1).setMetallicFactor(0); | ||
| break; | ||
| } | ||
| } | ||
| } |
+2
-2
@@ -5,7 +5,7 @@ export * from './types.js'; | ||
| export { runRules, RULE_IDS } from './rules.js'; | ||
| export { optimize, type OptimizeOptions, type OptimizeSummary } from './optimize.js'; | ||
| export { optimize, type OptimizeOptions, type OptimizeSummary, type TextureEncoder } from './optimize.js'; | ||
| export { createNodeIO } from './io.js'; | ||
| export { extrudeImage, type ExtrudeOptions, type ExtrudeResult } from './extrude/index.js'; | ||
| export { extrudeImage, extrudeFromRgba, type ExtrudeOptions, type ExtrudeResult, type LayerInfo } from './extrude/index.js'; | ||
| export { detectKtx2Encoder, ktx2Compress, type Ktx2Encoder } from './ktx2.js'; | ||
| export { stripMaterials } from './optimize.js'; | ||
| export { toStl, type StlOptions, type StlResult } from './stl.js'; |
+1
-1
@@ -7,5 +7,5 @@ export * from './types.js'; | ||
| export { createNodeIO } from './io.js'; | ||
| export { extrudeImage } from './extrude/index.js'; | ||
| export { extrudeImage, extrudeFromRgba } from './extrude/index.js'; | ||
| export { detectKtx2Encoder, ktx2Compress } from './ktx2.js'; | ||
| export { stripMaterials } from './optimize.js'; | ||
| export { toStl } from './stl.js'; |
+17
-13
@@ -1,10 +0,12 @@ | ||
| import { execFile } from 'node:child_process'; | ||
| import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
| import { promisify } from 'node:util'; | ||
| import { KHRTextureBasisu } from '@gltf-transform/extensions'; | ||
| import { listTextureSlots } from '@gltf-transform/functions'; | ||
| import sharp from 'sharp'; | ||
| const run = promisify(execFile); | ||
| // Node-only dependencies load lazily so this module can sit in a browser | ||
| // bundle unexecuted (KTX2 encoding requires local CLIs regardless). | ||
| async function nodeDeps() { | ||
| const [{ execFile }, { promisify }, fs, os, path, sharp] = await Promise.all([ | ||
| import('node:child_process'), import('node:util'), import('node:fs/promises'), | ||
| import('node:os'), import('node:path'), import('sharp'), | ||
| ]); | ||
| return { run: promisify(execFile), fs, os, path, sharp: sharp.default }; | ||
| } | ||
| /** | ||
@@ -15,2 +17,3 @@ * Find an available KTX2 encoder CLI. `basisu` (Binomial, `brew install | ||
| export async function detectKtx2Encoder() { | ||
| const { run } = await nodeDeps(); | ||
| for (const [bin, args] of [['basisu', ['-version']], ['toktx', ['--version']]]) { | ||
@@ -45,3 +48,4 @@ try { | ||
| return 0; | ||
| const workDir = await mkdtemp(join(tmpdir(), 'glbforge-ktx2-')); | ||
| const { run, fs, os, path, sharp } = await nodeDeps(); | ||
| const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'glbforge-ktx2-')); | ||
| try { | ||
@@ -59,5 +63,5 @@ for (const [i, texture] of textures.entries()) { | ||
| const height = Math.max(4, Math.floor(((meta.height ?? 4) * scale) / 4) * 4); | ||
| const pngPath = join(workDir, `t${i}.png`); | ||
| const ktxPath = join(workDir, `t${i}.ktx2`); | ||
| await writeFile(pngPath, await image.resize(width, height, { fit: 'fill' }).png().toBuffer()); | ||
| const pngPath = path.join(workDir, `t${i}.png`); | ||
| const ktxPath = path.join(workDir, `t${i}.ktx2`); | ||
| await fs.writeFile(pngPath, await image.resize(width, height, { fit: 'fill' }).png().toBuffer()); | ||
| const args = encoder === 'basisu' | ||
@@ -79,3 +83,3 @@ ? [ | ||
| await run(encoder, args); | ||
| const ktxBytes = await readFile(ktxPath); | ||
| const ktxBytes = await fs.readFile(ktxPath); | ||
| texture.setImage(new Uint8Array(ktxBytes)).setMimeType('image/ktx2'); | ||
@@ -89,4 +93,4 @@ opts.log?.(`ktx2 (${isNormal ? 'uastc' : 'etc1s'}): ${texture.getName() || 't' + i} ` + | ||
| finally { | ||
| await rm(workDir, { recursive: true, force: true }); | ||
| await fs.rm(workDir, { recursive: true, force: true }); | ||
| } | ||
| } |
+19
-0
| import { Document } from '@gltf-transform/core'; | ||
| import type { Profile } from './types.js'; | ||
| /** | ||
| * Environment-specific texture recompressor. Given the encoded source image | ||
| * and its material slots, return re-encoded bytes (resized to maxSize) or | ||
| * null to leave the texture untouched. Node's default uses sharp; browsers | ||
| * supply a canvas-based encoder. | ||
| */ | ||
| export type TextureEncoder = (input: { | ||
| bytes: Uint8Array; | ||
| mimeType: string; | ||
| slots: string[]; | ||
| }, target: { | ||
| maxSize: number; | ||
| }) => Promise<{ | ||
| bytes: Uint8Array; | ||
| mimeType: string; | ||
| } | null>; | ||
| export interface OptimizeOptions { | ||
@@ -11,2 +27,5 @@ profile: Profile; | ||
| textureFormat?: 'webp' | 'ktx2'; | ||
| /** Custom texture recompressor (browser environments). Overrides the | ||
| * sharp-based default; ignored when textureFormat is 'ktx2'. */ | ||
| textureEncoder?: TextureEncoder; | ||
| /** Skip meshopt compression (emit plain quantized GLB). */ | ||
@@ -13,0 +32,0 @@ compress?: boolean; |
+19
-1
| import { dedup, flatten, join, palette, prune, simplify, textureCompress, meshopt, weld, } from '@gltf-transform/functions'; | ||
| import { MeshoptEncoder, MeshoptSimplifier } from 'meshoptimizer'; | ||
| import sharp from 'sharp'; | ||
| function countTriangles(doc) { | ||
@@ -140,4 +139,23 @@ let tris = 0; | ||
| } | ||
| else if (opts.textures !== false && doc.getRoot().listTextures().length > 0 && opts.textureEncoder) { | ||
| // Environment-supplied encoder (e.g. canvas in the browser). | ||
| const cap = opts.profile.maxTextureSize; | ||
| const { listTextureSlots } = await import('@gltf-transform/functions'); | ||
| let encoded = 0; | ||
| for (const texture of doc.getRoot().listTextures()) { | ||
| const image = texture.getImage(); | ||
| if (!image || texture.getMimeType() === 'image/ktx2') | ||
| continue; | ||
| const result = await opts.textureEncoder({ bytes: image, mimeType: texture.getMimeType(), slots: listTextureSlots(texture) }, { maxSize: cap }); | ||
| if (result) { | ||
| texture.setImage(result.bytes).setMimeType(result.mimeType); | ||
| encoded++; | ||
| } | ||
| } | ||
| if (encoded > 0) | ||
| steps.push(`textures -> re-encoded x${encoded} @ ${cap}px`); | ||
| } | ||
| else if (opts.textures !== false && doc.getRoot().listTextures().length > 0) { | ||
| const cap = opts.profile.maxTextureSize; | ||
| const sharp = (await import('sharp')).default; | ||
| // Normal maps get near-lossless encoding: lossy artifacts in a normal | ||
@@ -144,0 +162,0 @@ // map show up as shading noise, not subtle color shifts. |
+1
-1
| { | ||
| "name": "@glbforge/core", | ||
| "version": "0.2.0", | ||
| "version": "0.3.0", | ||
| "type": "module", | ||
@@ -5,0 +5,0 @@ "main": "dist/index.js", |
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
114022
27.61%35
12.9%2606
27.31%0
-100%0
-100%