New:Socket for Asana Is Now Available.Learn more
Get Started

@glbforge/core

Package Overview
Dependencies
Maintainers
1
Versions
4
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@glbforge/core - npm Package Compare versions

Comparing version
0.2.0
to
0.3.0
+16
dist/extrude/layers.d.ts
/**
* 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);
}
+3
-0

@@ -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 {

@@ -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>;
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';

@@ -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';

@@ -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 });
}
}
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;

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.

{
"name": "@glbforge/core",
"version": "0.2.0",
"version": "0.3.0",
"type": "module",

@@ -5,0 +5,0 @@ "main": "dist/index.js",