three-cad-viewer 4.2.0 → 4.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/studio-manager.d.ts +0 -1
- package/dist/core/types.d.ts +1 -22
- package/dist/index.d.ts +1 -1
- package/dist/rendering/texture-cache.d.ts +11 -50
- package/dist/scene/nestedgroup.d.ts +1 -2
- package/dist/three-cad-viewer.esm.js +32 -650
- package/dist/three-cad-viewer.esm.js.map +1 -1
- package/dist/three-cad-viewer.esm.min.js +3 -3
- package/dist/three-cad-viewer.js +32 -650
- package/dist/three-cad-viewer.min.js +3 -3
- package/package.json +1 -1
- package/src/_version.ts +1 -1
- package/src/core/studio-manager.ts +4 -34
- package/src/core/types.ts +1 -27
- package/src/index.ts +0 -1
- package/src/rendering/material-factory.ts +12 -8
- package/src/rendering/material-presets.ts +0 -14
- package/src/rendering/texture-cache.ts +16 -712
- package/src/scene/nestedgroup.ts +0 -7
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import * as THREE from "three";
|
|
2
|
-
import type { TextureEntry } from "../core/types.js";
|
|
3
2
|
import { gpuTracker } from "../utils/gpu-tracker.js";
|
|
4
3
|
import { logger } from "../utils/logger.js";
|
|
5
4
|
|
|
@@ -7,23 +6,6 @@ import { logger } from "../utils/logger.js";
|
|
|
7
6
|
// Constants
|
|
8
7
|
// =============================================================================
|
|
9
8
|
|
|
10
|
-
/** Size of procedurally generated builtin textures (pixels) */
|
|
11
|
-
const BUILTIN_SIZE = 256;
|
|
12
|
-
|
|
13
|
-
/** Names of all supported builtin procedural textures */
|
|
14
|
-
const BUILTIN_NAMES = [
|
|
15
|
-
"brushed",
|
|
16
|
-
"knurled",
|
|
17
|
-
"sandblasted",
|
|
18
|
-
"hammered",
|
|
19
|
-
"checker",
|
|
20
|
-
"wood-dark",
|
|
21
|
-
"leather",
|
|
22
|
-
"fabric-weave",
|
|
23
|
-
] as const;
|
|
24
|
-
|
|
25
|
-
type BuiltinName = (typeof BUILTIN_NAMES)[number];
|
|
26
|
-
|
|
27
9
|
/**
|
|
28
10
|
* Texture fields that carry sRGB color data.
|
|
29
11
|
*
|
|
@@ -53,514 +35,6 @@ const THREEJS_SRGB_MAPS = new Set([
|
|
|
53
35
|
"specularColorMap",
|
|
54
36
|
]);
|
|
55
37
|
|
|
56
|
-
// =============================================================================
|
|
57
|
-
// Builtin Procedural Texture Generators
|
|
58
|
-
// =============================================================================
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Create a 2D canvas context of the given size.
|
|
62
|
-
*/
|
|
63
|
-
function createCanvas(size: number): {
|
|
64
|
-
canvas: HTMLCanvasElement | OffscreenCanvas;
|
|
65
|
-
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
|
66
|
-
} {
|
|
67
|
-
// Prefer OffscreenCanvas when available (Web Workers, modern browsers)
|
|
68
|
-
if (typeof OffscreenCanvas !== "undefined") {
|
|
69
|
-
const canvas = new OffscreenCanvas(size, size);
|
|
70
|
-
const ctx = canvas.getContext("2d")!;
|
|
71
|
-
return { canvas, ctx };
|
|
72
|
-
}
|
|
73
|
-
const canvas = document.createElement("canvas");
|
|
74
|
-
canvas.width = size;
|
|
75
|
-
canvas.height = size;
|
|
76
|
-
const ctx = canvas.getContext("2d")!;
|
|
77
|
-
return { canvas, ctx };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Simple pseudo-random number generator (mulberry32) for deterministic output.
|
|
82
|
-
* Ensures builtin textures look identical across sessions.
|
|
83
|
-
*/
|
|
84
|
-
function mulberry32(seed: number): () => number {
|
|
85
|
-
return () => {
|
|
86
|
-
seed |= 0;
|
|
87
|
-
seed = (seed + 0x6d2b79f5) | 0;
|
|
88
|
-
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
|
89
|
-
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
90
|
-
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Generate a brushed-metal normal map.
|
|
96
|
-
*
|
|
97
|
-
* Creates horizontal noise streaks simulating directional brushing.
|
|
98
|
-
* The streaks run along the X axis with slight vertical variation.
|
|
99
|
-
*/
|
|
100
|
-
function generateBrushed(size: number): HTMLCanvasElement | OffscreenCanvas {
|
|
101
|
-
const { canvas, ctx } = createCanvas(size);
|
|
102
|
-
const imageData = ctx.createImageData(size, size);
|
|
103
|
-
const data = imageData.data;
|
|
104
|
-
const rng = mulberry32(42);
|
|
105
|
-
|
|
106
|
-
// Generate per-row intensity variation (horizontal streaks)
|
|
107
|
-
const rowIntensity = new Float32Array(size);
|
|
108
|
-
for (let y = 0; y < size; y++) {
|
|
109
|
-
rowIntensity[y] = 0.3 + rng() * 0.7;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
for (let y = 0; y < size; y++) {
|
|
113
|
-
for (let x = 0; x < size; x++) {
|
|
114
|
-
const idx = (y * size + x) * 4;
|
|
115
|
-
|
|
116
|
-
// High-frequency horizontal noise for brush lines
|
|
117
|
-
const noise = (rng() - 0.5) * 0.15 * rowIntensity[y];
|
|
118
|
-
|
|
119
|
-
// Slight vertical gradient perturbation
|
|
120
|
-
const yNoise = (rng() - 0.5) * 0.05;
|
|
121
|
-
|
|
122
|
-
// Normal map encoding: (0.5, 0.5, 1.0) = flat
|
|
123
|
-
data[idx] = Math.round((0.5 + noise) * 255); // R: tangent X
|
|
124
|
-
data[idx + 1] = Math.round((0.5 + yNoise) * 255); // G: tangent Y
|
|
125
|
-
data[idx + 2] = 255; // B: tangent Z (flat)
|
|
126
|
-
data[idx + 3] = 255; // A: opaque
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
ctx.putImageData(imageData, 0, 0);
|
|
131
|
-
return canvas;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Generate a diamond knurl pattern normal map.
|
|
136
|
-
*
|
|
137
|
-
* Creates a repeating diamond grid pattern typical of knurled metal surfaces.
|
|
138
|
-
*/
|
|
139
|
-
function generateKnurled(size: number): HTMLCanvasElement | OffscreenCanvas {
|
|
140
|
-
const { canvas, ctx } = createCanvas(size);
|
|
141
|
-
const imageData = ctx.createImageData(size, size);
|
|
142
|
-
const data = imageData.data;
|
|
143
|
-
|
|
144
|
-
const diamonds = 16; // Number of diamond repeats
|
|
145
|
-
const step = size / diamonds;
|
|
146
|
-
|
|
147
|
-
for (let y = 0; y < size; y++) {
|
|
148
|
-
for (let x = 0; x < size; x++) {
|
|
149
|
-
const idx = (y * size + x) * 4;
|
|
150
|
-
|
|
151
|
-
// Diamond pattern using modular arithmetic
|
|
152
|
-
const dx = ((x % step) / step) * 2 - 1; // -1 to 1 within cell
|
|
153
|
-
const dy = ((y % step) / step) * 2 - 1;
|
|
154
|
-
|
|
155
|
-
// Diamond distance (L1 norm)
|
|
156
|
-
const dist = Math.abs(dx) + Math.abs(dy);
|
|
157
|
-
|
|
158
|
-
// Gradient direction for normal
|
|
159
|
-
const sx = dx > 0 ? 1 : -1;
|
|
160
|
-
const sy = dy > 0 ? 1 : -1;
|
|
161
|
-
|
|
162
|
-
// Smooth pyramid shape
|
|
163
|
-
const strength = 0.3;
|
|
164
|
-
const nx = dist < 1 ? sx * strength * (1 - dist) : 0;
|
|
165
|
-
const ny = dist < 1 ? sy * strength * (1 - dist) : 0;
|
|
166
|
-
|
|
167
|
-
data[idx] = Math.round((0.5 + nx) * 255);
|
|
168
|
-
data[idx + 1] = Math.round((0.5 + ny) * 255);
|
|
169
|
-
data[idx + 2] = 255;
|
|
170
|
-
data[idx + 3] = 255;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
ctx.putImageData(imageData, 0, 0);
|
|
175
|
-
return canvas;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Generate a sandblasted surface normal map.
|
|
180
|
-
*
|
|
181
|
-
* Creates fine random grain using layered noise at different frequencies,
|
|
182
|
-
* simulating a sandblasted or bead-blasted metal surface.
|
|
183
|
-
*/
|
|
184
|
-
function generateSandblasted(size: number): HTMLCanvasElement | OffscreenCanvas {
|
|
185
|
-
const { canvas, ctx } = createCanvas(size);
|
|
186
|
-
const imageData = ctx.createImageData(size, size);
|
|
187
|
-
const data = imageData.data;
|
|
188
|
-
const rng = mulberry32(137);
|
|
189
|
-
|
|
190
|
-
// Generate a height field with multi-octave noise
|
|
191
|
-
const heights = new Float32Array(size * size);
|
|
192
|
-
for (let i = 0; i < heights.length; i++) {
|
|
193
|
-
heights[i] = rng() * 0.5 + rng() * 0.3 + rng() * 0.2;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Derive normals from height field via finite differences
|
|
197
|
-
const strength = 0.2;
|
|
198
|
-
for (let y = 0; y < size; y++) {
|
|
199
|
-
for (let x = 0; x < size; x++) {
|
|
200
|
-
const idx = (y * size + x) * 4;
|
|
201
|
-
|
|
202
|
-
const xp = (x + 1) % size;
|
|
203
|
-
const xm = (x - 1 + size) % size;
|
|
204
|
-
const yp = (y + 1) % size;
|
|
205
|
-
const ym = (y - 1 + size) % size;
|
|
206
|
-
|
|
207
|
-
const dhdx = (heights[y * size + xp] - heights[y * size + xm]) * 0.5;
|
|
208
|
-
const dhdy = (heights[yp * size + x] - heights[ym * size + x]) * 0.5;
|
|
209
|
-
|
|
210
|
-
data[idx] = Math.round((0.5 - dhdx * strength) * 255);
|
|
211
|
-
data[idx + 1] = Math.round((0.5 - dhdy * strength) * 255);
|
|
212
|
-
data[idx + 2] = 255;
|
|
213
|
-
data[idx + 3] = 255;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
ctx.putImageData(imageData, 0, 0);
|
|
218
|
-
return canvas;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Generate a hammered/peened surface normal map.
|
|
223
|
-
*
|
|
224
|
-
* Creates random crater bumps simulating a hand-hammered metal surface.
|
|
225
|
-
*/
|
|
226
|
-
function generateHammered(size: number): HTMLCanvasElement | OffscreenCanvas {
|
|
227
|
-
const { canvas, ctx } = createCanvas(size);
|
|
228
|
-
const imageData = ctx.createImageData(size, size);
|
|
229
|
-
const data = imageData.data;
|
|
230
|
-
const rng = mulberry32(314);
|
|
231
|
-
|
|
232
|
-
// Generate a height field with random circular craters
|
|
233
|
-
const heights = new Float32Array(size * size);
|
|
234
|
-
const craterCount = 80;
|
|
235
|
-
|
|
236
|
-
for (let c = 0; c < craterCount; c++) {
|
|
237
|
-
const cx = rng() * size;
|
|
238
|
-
const cy = rng() * size;
|
|
239
|
-
const radius = 8 + rng() * 16;
|
|
240
|
-
const depth = 0.3 + rng() * 0.7;
|
|
241
|
-
|
|
242
|
-
// Stamp crater (with wrapping for tileable texture)
|
|
243
|
-
const r2 = radius * radius;
|
|
244
|
-
for (let dy = -radius; dy <= radius; dy++) {
|
|
245
|
-
for (let dx = -radius; dx <= radius; dx++) {
|
|
246
|
-
const d2 = dx * dx + dy * dy;
|
|
247
|
-
if (d2 < r2) {
|
|
248
|
-
const px = ((Math.round(cx + dx) % size) + size) % size;
|
|
249
|
-
const py = ((Math.round(cy + dy) % size) + size) % size;
|
|
250
|
-
// Smooth hemisphere shape
|
|
251
|
-
const t = 1 - d2 / r2;
|
|
252
|
-
heights[py * size + px] += depth * t * t;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// Derive normals from height field
|
|
259
|
-
const strength = 0.25;
|
|
260
|
-
for (let y = 0; y < size; y++) {
|
|
261
|
-
for (let x = 0; x < size; x++) {
|
|
262
|
-
const idx = (y * size + x) * 4;
|
|
263
|
-
|
|
264
|
-
const xp = (x + 1) % size;
|
|
265
|
-
const xm = (x - 1 + size) % size;
|
|
266
|
-
const yp = (y + 1) % size;
|
|
267
|
-
const ym = (y - 1 + size) % size;
|
|
268
|
-
|
|
269
|
-
const dhdx = (heights[y * size + xp] - heights[y * size + xm]) * 0.5;
|
|
270
|
-
const dhdy = (heights[yp * size + x] - heights[ym * size + x]) * 0.5;
|
|
271
|
-
|
|
272
|
-
data[idx] = Math.round(Math.max(0, Math.min(255, (0.5 - dhdx * strength) * 255)));
|
|
273
|
-
data[idx + 1] = Math.round(Math.max(0, Math.min(255, (0.5 - dhdy * strength) * 255)));
|
|
274
|
-
data[idx + 2] = 255;
|
|
275
|
-
data[idx + 3] = 255;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
ctx.putImageData(imageData, 0, 0);
|
|
280
|
-
return canvas;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Generate a black/white checkerboard texture.
|
|
285
|
-
*
|
|
286
|
-
* Useful for UV debugging and as a base color texture for testing.
|
|
287
|
-
*/
|
|
288
|
-
function generateChecker(size: number): HTMLCanvasElement | OffscreenCanvas {
|
|
289
|
-
const { canvas, ctx } = createCanvas(size);
|
|
290
|
-
const squares = 8; // 8x8 checkerboard
|
|
291
|
-
const step = size / squares;
|
|
292
|
-
|
|
293
|
-
ctx.fillStyle = "#ffffff";
|
|
294
|
-
ctx.fillRect(0, 0, size, size);
|
|
295
|
-
|
|
296
|
-
ctx.fillStyle = "#000000";
|
|
297
|
-
for (let row = 0; row < squares; row++) {
|
|
298
|
-
for (let col = 0; col < squares; col++) {
|
|
299
|
-
if ((row + col) % 2 === 0) {
|
|
300
|
-
ctx.fillRect(col * step, row * step, step, step);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
return canvas;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* Generate a dark wood grain base color texture.
|
|
310
|
-
*
|
|
311
|
-
* Creates concentric growth rings with noise perturbation,
|
|
312
|
-
* in warm walnut/mahogany tones. Intended as a baseColorTexture (sRGB).
|
|
313
|
-
*/
|
|
314
|
-
function generateWoodDark(size: number): HTMLCanvasElement | OffscreenCanvas {
|
|
315
|
-
const { canvas, ctx } = createCanvas(size);
|
|
316
|
-
const imageData = ctx.createImageData(size, size);
|
|
317
|
-
const data = imageData.data;
|
|
318
|
-
const rng = mulberry32(73);
|
|
319
|
-
|
|
320
|
-
// Pre-generate a noise field for grain perturbation
|
|
321
|
-
const noise = new Float32Array(size * size);
|
|
322
|
-
for (let i = 0; i < noise.length; i++) {
|
|
323
|
-
noise[i] = rng();
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// Smooth the noise (simple box blur, 2 passes)
|
|
327
|
-
const tmp = new Float32Array(size * size);
|
|
328
|
-
for (let pass = 0; pass < 2; pass++) {
|
|
329
|
-
const src = pass === 0 ? noise : tmp;
|
|
330
|
-
const dst = pass === 0 ? tmp : noise;
|
|
331
|
-
const k = 3;
|
|
332
|
-
for (let y = 0; y < size; y++) {
|
|
333
|
-
for (let x = 0; x < size; x++) {
|
|
334
|
-
let sum = 0;
|
|
335
|
-
let count = 0;
|
|
336
|
-
for (let dy = -k; dy <= k; dy++) {
|
|
337
|
-
for (let dx = -k; dx <= k; dx++) {
|
|
338
|
-
const px = (x + dx + size) % size;
|
|
339
|
-
const py = (y + dy + size) % size;
|
|
340
|
-
sum += src[py * size + px];
|
|
341
|
-
count++;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
dst[y * size + x] = sum / count;
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// Wood color palette (sRGB, will be decoded by Three.js)
|
|
350
|
-
// Dark grain lines: ~[80, 45, 22] Light wood body: ~[145, 90, 48]
|
|
351
|
-
const darkR = 80, darkG = 45, darkB = 22;
|
|
352
|
-
const lightR = 145, lightG = 90, lightB = 48;
|
|
353
|
-
|
|
354
|
-
// Ring center (offset from image center for asymmetry)
|
|
355
|
-
const cx = size * 0.45;
|
|
356
|
-
const cy = size * 0.52;
|
|
357
|
-
|
|
358
|
-
const ringScale = 0.08; // Controls ring spacing
|
|
359
|
-
|
|
360
|
-
for (let y = 0; y < size; y++) {
|
|
361
|
-
for (let x = 0; x < size; x++) {
|
|
362
|
-
const idx = (y * size + x) * 4;
|
|
363
|
-
|
|
364
|
-
// Distance from center with noise distortion
|
|
365
|
-
const n = noise[y * size + x];
|
|
366
|
-
const dx = x - cx + (n - 0.5) * 30;
|
|
367
|
-
const dy = y - cy + (n - 0.5) * 15;
|
|
368
|
-
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
369
|
-
|
|
370
|
-
// Growth ring pattern (sinusoidal)
|
|
371
|
-
const ring = Math.sin(dist * ringScale * Math.PI * 2);
|
|
372
|
-
// Remap from [-1,1] to [0,1]
|
|
373
|
-
const t = ring * 0.5 + 0.5;
|
|
374
|
-
// sqrt biases toward light — dark lines stay thin, brown dominates
|
|
375
|
-
const ringFactor = Math.sqrt(t);
|
|
376
|
-
|
|
377
|
-
// Add fine-grain noise for fiber texture
|
|
378
|
-
const fineNoise = (rng() - 0.5) * 12;
|
|
379
|
-
|
|
380
|
-
// Interpolate between dark and light
|
|
381
|
-
const r = Math.round(darkR + (lightR - darkR) * ringFactor + fineNoise);
|
|
382
|
-
const g = Math.round(darkG + (lightG - darkG) * ringFactor + fineNoise * 0.6);
|
|
383
|
-
const b = Math.round(darkB + (lightB - darkB) * ringFactor + fineNoise * 0.3);
|
|
384
|
-
|
|
385
|
-
data[idx] = Math.max(0, Math.min(255, r));
|
|
386
|
-
data[idx + 1] = Math.max(0, Math.min(255, g));
|
|
387
|
-
data[idx + 2] = Math.max(0, Math.min(255, b));
|
|
388
|
-
data[idx + 3] = 255;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
ctx.putImageData(imageData, 0, 0);
|
|
393
|
-
return canvas;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
/**
|
|
397
|
-
* Generate a leather pebble-grain normal map.
|
|
398
|
-
*
|
|
399
|
-
* Creates irregular rounded bumps (Voronoi-like cells) typical of
|
|
400
|
-
* top-grain or pebbled leather. Each cell has a smooth dome shape
|
|
401
|
-
* with slight creases between cells.
|
|
402
|
-
*/
|
|
403
|
-
function generateLeather(size: number): HTMLCanvasElement | OffscreenCanvas {
|
|
404
|
-
const { canvas, ctx } = createCanvas(size);
|
|
405
|
-
const imageData = ctx.createImageData(size, size);
|
|
406
|
-
const data = imageData.data;
|
|
407
|
-
const rng = mulberry32(217);
|
|
408
|
-
|
|
409
|
-
// Scatter seed points for Voronoi cells (pebbles)
|
|
410
|
-
const cellCount = 180;
|
|
411
|
-
const seeds: Array<{ x: number; y: number }> = [];
|
|
412
|
-
for (let i = 0; i < cellCount; i++) {
|
|
413
|
-
seeds.push({ x: rng() * size, y: rng() * size });
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// Build a height field from Voronoi distance
|
|
417
|
-
const heights = new Float32Array(size * size);
|
|
418
|
-
|
|
419
|
-
for (let y = 0; y < size; y++) {
|
|
420
|
-
for (let x = 0; x < size; x++) {
|
|
421
|
-
// Find distance to nearest seed (with wrapping for tileability)
|
|
422
|
-
let minDist = Infinity;
|
|
423
|
-
for (const s of seeds) {
|
|
424
|
-
let dx = Math.abs(x - s.x);
|
|
425
|
-
let dy = Math.abs(y - s.y);
|
|
426
|
-
if (dx > size / 2) dx = size - dx;
|
|
427
|
-
if (dy > size / 2) dy = size - dy;
|
|
428
|
-
const d = Math.sqrt(dx * dx + dy * dy);
|
|
429
|
-
if (d < minDist) minDist = d;
|
|
430
|
-
}
|
|
431
|
-
// Invert: closer to seed = higher (dome center)
|
|
432
|
-
// Normalize roughly by expected average cell radius
|
|
433
|
-
const avgRadius = size / Math.sqrt(cellCount);
|
|
434
|
-
const t = Math.min(minDist / avgRadius, 1.0);
|
|
435
|
-
// Smooth dome falloff: 1 at center, 0 at edge
|
|
436
|
-
heights[y * size + x] = (1 - t * t) * 0.8 + rng() * 0.05;
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// Derive normals from height field via finite differences
|
|
441
|
-
const strength = 0.35;
|
|
442
|
-
for (let y = 0; y < size; y++) {
|
|
443
|
-
for (let x = 0; x < size; x++) {
|
|
444
|
-
const idx = (y * size + x) * 4;
|
|
445
|
-
|
|
446
|
-
const xp = (x + 1) % size;
|
|
447
|
-
const xm = (x - 1 + size) % size;
|
|
448
|
-
const yp = (y + 1) % size;
|
|
449
|
-
const ym = (y - 1 + size) % size;
|
|
450
|
-
|
|
451
|
-
const dhdx = (heights[y * size + xp] - heights[y * size + xm]) * 0.5;
|
|
452
|
-
const dhdy = (heights[yp * size + x] - heights[ym * size + x]) * 0.5;
|
|
453
|
-
|
|
454
|
-
data[idx] = Math.round(Math.max(0, Math.min(255, (0.5 - dhdx * strength) * 255)));
|
|
455
|
-
data[idx + 1] = Math.round(Math.max(0, Math.min(255, (0.5 - dhdy * strength) * 255)));
|
|
456
|
-
data[idx + 2] = 255;
|
|
457
|
-
data[idx + 3] = 255;
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
ctx.putImageData(imageData, 0, 0);
|
|
462
|
-
return canvas;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* Generate a fabric twill-weave normal map.
|
|
467
|
-
*
|
|
468
|
-
* Creates a repeating over-under weave pattern with slightly raised
|
|
469
|
-
* warp/weft threads and recessed gaps, typical of upholstery fabric.
|
|
470
|
-
*/
|
|
471
|
-
function generateFabricWeave(size: number): HTMLCanvasElement | OffscreenCanvas {
|
|
472
|
-
const { canvas, ctx } = createCanvas(size);
|
|
473
|
-
const imageData = ctx.createImageData(size, size);
|
|
474
|
-
const data = imageData.data;
|
|
475
|
-
const rng = mulberry32(159);
|
|
476
|
-
|
|
477
|
-
// Thread parameters
|
|
478
|
-
const threadCount = 32; // threads per axis
|
|
479
|
-
const cellSize = size / threadCount;
|
|
480
|
-
|
|
481
|
-
// Build height field: each cell is either warp-over or weft-over
|
|
482
|
-
const heights = new Float32Array(size * size);
|
|
483
|
-
|
|
484
|
-
for (let y = 0; y < size; y++) {
|
|
485
|
-
for (let x = 0; x < size; x++) {
|
|
486
|
-
const cx = Math.floor(x / cellSize);
|
|
487
|
-
const cy = Math.floor(y / cellSize);
|
|
488
|
-
|
|
489
|
-
// Position within the cell [0, 1]
|
|
490
|
-
const lx = (x % cellSize) / cellSize;
|
|
491
|
-
const ly = (y % cellSize) / cellSize;
|
|
492
|
-
|
|
493
|
-
// Twill pattern: diagonal shift (2/1 twill)
|
|
494
|
-
const isWarpOver = ((cx + cy) % 3) < 2;
|
|
495
|
-
|
|
496
|
-
// Thread profile: rounded bump across the thread width
|
|
497
|
-
// Warp threads run vertically (bump shape across x)
|
|
498
|
-
// Weft threads run horizontally (bump shape across y)
|
|
499
|
-
const warpProfile = Math.sin(lx * Math.PI);
|
|
500
|
-
const weftProfile = Math.sin(ly * Math.PI);
|
|
501
|
-
|
|
502
|
-
// Gap between threads (edges of cells are lower)
|
|
503
|
-
const edgeFalloff = Math.min(
|
|
504
|
-
Math.sin(lx * Math.PI),
|
|
505
|
-
Math.sin(ly * Math.PI),
|
|
506
|
-
);
|
|
507
|
-
|
|
508
|
-
let h: number;
|
|
509
|
-
if (isWarpOver) {
|
|
510
|
-
// Warp on top: height from warp profile
|
|
511
|
-
h = 0.5 + warpProfile * 0.4 * edgeFalloff;
|
|
512
|
-
} else {
|
|
513
|
-
// Weft on top: height from weft profile
|
|
514
|
-
h = 0.5 + weftProfile * 0.4 * edgeFalloff;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
// Add subtle noise for fabric irregularity
|
|
518
|
-
h += (rng() - 0.5) * 0.04;
|
|
519
|
-
|
|
520
|
-
heights[y * size + x] = h;
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
// Derive normals from height field
|
|
525
|
-
const strength = 0.3;
|
|
526
|
-
for (let y = 0; y < size; y++) {
|
|
527
|
-
for (let x = 0; x < size; x++) {
|
|
528
|
-
const idx = (y * size + x) * 4;
|
|
529
|
-
|
|
530
|
-
const xp = (x + 1) % size;
|
|
531
|
-
const xm = (x - 1 + size) % size;
|
|
532
|
-
const yp = (y + 1) % size;
|
|
533
|
-
const ym = (y - 1 + size) % size;
|
|
534
|
-
|
|
535
|
-
const dhdx = (heights[y * size + xp] - heights[y * size + xm]) * 0.5;
|
|
536
|
-
const dhdy = (heights[yp * size + x] - heights[ym * size + x]) * 0.5;
|
|
537
|
-
|
|
538
|
-
data[idx] = Math.round(Math.max(0, Math.min(255, (0.5 - dhdx * strength) * 255)));
|
|
539
|
-
data[idx + 1] = Math.round(Math.max(0, Math.min(255, (0.5 - dhdy * strength) * 255)));
|
|
540
|
-
data[idx + 2] = 255;
|
|
541
|
-
data[idx + 3] = 255;
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
ctx.putImageData(imageData, 0, 0);
|
|
546
|
-
return canvas;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
/** Map of builtin texture names to their generator functions */
|
|
550
|
-
const BUILTIN_GENERATORS: Record<
|
|
551
|
-
BuiltinName,
|
|
552
|
-
(size: number) => HTMLCanvasElement | OffscreenCanvas
|
|
553
|
-
> = {
|
|
554
|
-
brushed: generateBrushed,
|
|
555
|
-
knurled: generateKnurled,
|
|
556
|
-
sandblasted: generateSandblasted,
|
|
557
|
-
hammered: generateHammered,
|
|
558
|
-
checker: generateChecker,
|
|
559
|
-
"wood-dark": generateWoodDark,
|
|
560
|
-
leather: generateLeather,
|
|
561
|
-
"fabric-weave": generateFabricWeave,
|
|
562
|
-
};
|
|
563
|
-
|
|
564
38
|
// =============================================================================
|
|
565
39
|
// TextureCache
|
|
566
40
|
// =============================================================================
|
|
@@ -573,58 +47,40 @@ const BUILTIN_GENERATORS: Record<
|
|
|
573
47
|
* Only TextureCache.dispose() / disposeFull() disposes GPU texture resources.
|
|
574
48
|
*
|
|
575
49
|
* Resolution order for texture reference strings:
|
|
576
|
-
* 1. `
|
|
577
|
-
* 2.
|
|
578
|
-
* 3. `data:` prefix -- treat as data URI, load directly
|
|
579
|
-
* 4. Otherwise -- treat as URL, resolve relative to HTML page
|
|
50
|
+
* 1. `data:` prefix -- treat as data URI, load directly
|
|
51
|
+
* 2. Otherwise -- treat as URL, resolve relative to HTML page
|
|
580
52
|
*
|
|
581
53
|
* Features:
|
|
582
|
-
* - Two-tier caching: user textures (disposed on clear) + builtin textures
|
|
583
|
-
* (persistent, only disposed on viewer teardown)
|
|
584
54
|
* - In-flight promise deduplication (no duplicate loads for the same key)
|
|
585
55
|
* - Correct colorSpace assignment per texture semantic role
|
|
586
56
|
* - GPU resource tracking via gpuTracker
|
|
587
57
|
*/
|
|
588
58
|
class TextureCache {
|
|
589
|
-
/**
|
|
59
|
+
/** Textures cache (disposed on clear/dispose, rebuilt per shape data) */
|
|
590
60
|
private _cache: Map<string, THREE.Texture> = new Map();
|
|
591
61
|
|
|
592
|
-
/** Built-in procedural textures (persistent, only disposed via disposeFull) */
|
|
593
|
-
private _builtinCache: Map<string, THREE.Texture> = new Map();
|
|
594
|
-
|
|
595
62
|
/** In-flight load promises keyed by cache key */
|
|
596
63
|
private _inflight: Map<string, Promise<THREE.Texture>> = new Map();
|
|
597
64
|
|
|
598
|
-
/** Root-level textures table from shape data */
|
|
599
|
-
private _texturesTable: Record<string, TextureEntry> = {};
|
|
600
|
-
|
|
601
65
|
/** THREE.TextureLoader instance (created lazily) */
|
|
602
66
|
private _textureLoader: THREE.TextureLoader | null = null;
|
|
603
67
|
|
|
604
68
|
/** Whether this cache has been fully disposed */
|
|
605
69
|
private _disposed = false;
|
|
606
70
|
|
|
71
|
+
/** Max anisotropic filtering level.
|
|
72
|
+
* Default 16 covers most GPUs; clamped by the driver if unsupported. */
|
|
73
|
+
maxAnisotropy = 16;
|
|
74
|
+
|
|
607
75
|
// ---------------------------------------------------------------------------
|
|
608
76
|
// Public API
|
|
609
77
|
// ---------------------------------------------------------------------------
|
|
610
78
|
|
|
611
|
-
/**
|
|
612
|
-
* Set or update the root-level textures table.
|
|
613
|
-
*
|
|
614
|
-
* Called when new shape data is loaded. The table maps string keys to
|
|
615
|
-
* TextureEntry objects (embedded base64 data or URL references).
|
|
616
|
-
*
|
|
617
|
-
* @param table - The textures table from root Shapes node, or undefined to clear
|
|
618
|
-
*/
|
|
619
|
-
setTexturesTable(table: Record<string, TextureEntry> | undefined): void {
|
|
620
|
-
this._texturesTable = table ?? {};
|
|
621
|
-
}
|
|
622
|
-
|
|
623
79
|
/**
|
|
624
80
|
* Resolve a texture reference string and return a cached or newly loaded
|
|
625
81
|
* THREE.Texture with the correct colorSpace set.
|
|
626
82
|
*
|
|
627
|
-
* @param ref - Texture reference string (
|
|
83
|
+
* @param ref - Texture reference string (table key, data URI, or URL)
|
|
628
84
|
* @param textureRole - The texture role name (MaterialAppearance field name or proxy role)
|
|
629
85
|
* (e.g. "baseColorTexture", "normalTexture"). Used to determine colorSpace.
|
|
630
86
|
* @returns The resolved THREE.Texture, or null if the reference is invalid
|
|
@@ -648,180 +104,45 @@ class TextureCache {
|
|
|
648
104
|
? THREE.SRGBColorSpace
|
|
649
105
|
: THREE.LinearSRGBColorSpace;
|
|
650
106
|
|
|
651
|
-
// 1.
|
|
652
|
-
if (ref.startsWith("builtin:")) {
|
|
653
|
-
return this._getBuiltin(ref, colorSpace);
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
// 2. Look up in textures table
|
|
657
|
-
const tableEntry = this._texturesTable[ref];
|
|
658
|
-
if (tableEntry) {
|
|
659
|
-
return this._getFromTable(ref, tableEntry, colorSpace);
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
// 3. data: prefix (data URI)
|
|
107
|
+
// 1. data: prefix (data URI)
|
|
663
108
|
if (ref.startsWith("data:")) {
|
|
664
109
|
return this._getFromDataUri(ref, colorSpace);
|
|
665
110
|
}
|
|
666
111
|
|
|
667
|
-
//
|
|
112
|
+
// 2. URL (relative to HTML page)
|
|
668
113
|
return this._getFromUrl(ref, colorSpace);
|
|
669
114
|
}
|
|
670
115
|
|
|
671
116
|
/**
|
|
672
|
-
*
|
|
673
|
-
*/
|
|
674
|
-
isBuiltin(ref: string): boolean {
|
|
675
|
-
return ref.startsWith("builtin:");
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
/**
|
|
679
|
-
* Dispose user textures (called on viewer.clear() when shape data is replaced).
|
|
117
|
+
* Dispose textures (called on viewer.clear() when shape data is replaced).
|
|
680
118
|
*
|
|
681
|
-
* Disposes all textures in the
|
|
682
|
-
* The builtin procedural texture cache is preserved.
|
|
119
|
+
* Disposes all textures in the cache and clears in-flight promises.
|
|
683
120
|
*/
|
|
684
121
|
dispose(): void {
|
|
685
|
-
// Dispose user textures
|
|
686
122
|
for (const [key, texture] of this._cache) {
|
|
687
123
|
gpuTracker.untrack("texture", texture);
|
|
688
124
|
texture.dispose();
|
|
689
|
-
logger.debug(`Disposed
|
|
125
|
+
logger.debug(`Disposed texture: ${key}`);
|
|
690
126
|
}
|
|
691
127
|
this._cache.clear();
|
|
692
128
|
|
|
693
129
|
// Clear in-flight promises (they may resolve but won't be used)
|
|
694
130
|
this._inflight.clear();
|
|
695
|
-
|
|
696
|
-
// Clear the textures table (will be set again with new shape data)
|
|
697
|
-
this._texturesTable = {};
|
|
698
131
|
}
|
|
699
132
|
|
|
700
133
|
/**
|
|
701
|
-
* Dispose all textures
|
|
134
|
+
* Dispose all textures.
|
|
702
135
|
*
|
|
703
136
|
* Called on viewer.dispose() when the viewer is fully torn down.
|
|
704
137
|
* After this call, the TextureCache cannot be used again.
|
|
705
138
|
*/
|
|
706
139
|
disposeFull(): void {
|
|
707
140
|
this._disposed = true;
|
|
708
|
-
|
|
709
|
-
// Dispose user textures first
|
|
710
141
|
this.dispose();
|
|
711
|
-
|
|
712
|
-
// Dispose builtin textures
|
|
713
|
-
for (const [key, texture] of this._builtinCache) {
|
|
714
|
-
gpuTracker.untrack("texture", texture);
|
|
715
|
-
texture.dispose();
|
|
716
|
-
logger.debug(`Disposed builtin texture: ${key}`);
|
|
717
|
-
}
|
|
718
|
-
this._builtinCache.clear();
|
|
719
|
-
|
|
720
|
-
// Null the texture loader reference
|
|
721
142
|
this._textureLoader = null;
|
|
722
|
-
|
|
723
143
|
logger.debug("TextureCache fully disposed");
|
|
724
144
|
}
|
|
725
145
|
|
|
726
|
-
// ---------------------------------------------------------------------------
|
|
727
|
-
// Private: Builtin procedural textures
|
|
728
|
-
// ---------------------------------------------------------------------------
|
|
729
|
-
|
|
730
|
-
/**
|
|
731
|
-
* Get or generate a builtin procedural texture.
|
|
732
|
-
*
|
|
733
|
-
* Builtin textures are cached in the persistent `_builtinCache` and survive
|
|
734
|
-
* `dispose()` calls. They are only freed via `disposeFull()`.
|
|
735
|
-
*/
|
|
736
|
-
private _getBuiltin(
|
|
737
|
-
ref: string,
|
|
738
|
-
colorSpace: THREE.ColorSpace,
|
|
739
|
-
): THREE.Texture | null {
|
|
740
|
-
// Extract name after "builtin:" prefix
|
|
741
|
-
const name = ref.slice(8) as BuiltinName;
|
|
742
|
-
|
|
743
|
-
if (!BUILTIN_GENERATORS[name]) {
|
|
744
|
-
logger.warn(`Unknown builtin texture: "${ref}". Available: ${BUILTIN_NAMES.join(", ")}`);
|
|
745
|
-
return null;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
// Check builtin cache
|
|
749
|
-
const cached = this._builtinCache.get(ref);
|
|
750
|
-
if (cached) {
|
|
751
|
-
// TODO: If the same builtin is used in different roles (sRGB vs Linear),
|
|
752
|
-
// mutating colorSpace in-place would affect other materials. This is
|
|
753
|
-
// unlikely for builtins (almost always normal maps) but could be fixed
|
|
754
|
-
// with a composite cache key (ref + colorSpace) if needed.
|
|
755
|
-
cached.colorSpace = colorSpace;
|
|
756
|
-
return cached;
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
// Generate the procedural texture
|
|
760
|
-
const canvas = BUILTIN_GENERATORS[name](BUILTIN_SIZE);
|
|
761
|
-
const texture = new THREE.CanvasTexture(canvas as TexImageSource);
|
|
762
|
-
texture.wrapS = THREE.RepeatWrapping;
|
|
763
|
-
texture.wrapT = THREE.RepeatWrapping;
|
|
764
|
-
texture.colorSpace = colorSpace;
|
|
765
|
-
texture.needsUpdate = true;
|
|
766
|
-
|
|
767
|
-
// Cache in the persistent builtin cache
|
|
768
|
-
this._builtinCache.set(ref, texture);
|
|
769
|
-
gpuTracker.trackTexture(texture, `Builtin procedural texture: ${ref}`);
|
|
770
|
-
logger.debug(`Generated builtin texture: ${ref}`);
|
|
771
|
-
|
|
772
|
-
return texture;
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
// ---------------------------------------------------------------------------
|
|
776
|
-
// Private: Table entry loading
|
|
777
|
-
// ---------------------------------------------------------------------------
|
|
778
|
-
|
|
779
|
-
/**
|
|
780
|
-
* Load a texture from the root-level textures table entry.
|
|
781
|
-
*
|
|
782
|
-
* Handles both embedded (base64 data) and URL-referenced entries.
|
|
783
|
-
*/
|
|
784
|
-
private async _getFromTable(
|
|
785
|
-
key: string,
|
|
786
|
-
entry: TextureEntry,
|
|
787
|
-
colorSpace: THREE.ColorSpace,
|
|
788
|
-
): Promise<THREE.Texture | null> {
|
|
789
|
-
// Check user cache
|
|
790
|
-
// TODO: If the same texture table entry is used in different roles
|
|
791
|
-
// (sRGB vs Linear), mutating colorSpace in-place would affect other
|
|
792
|
-
// materials. Could be fixed with a composite cache key (key + colorSpace).
|
|
793
|
-
const cached = this._cache.get(key);
|
|
794
|
-
if (cached) {
|
|
795
|
-
cached.colorSpace = colorSpace;
|
|
796
|
-
return cached;
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
// Check in-flight
|
|
800
|
-
const inflight = this._inflight.get(key);
|
|
801
|
-
if (inflight) {
|
|
802
|
-
const texture = await inflight;
|
|
803
|
-
texture.colorSpace = colorSpace;
|
|
804
|
-
return texture;
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
// Resolve table entry to a loadable source
|
|
808
|
-
if (entry.data && entry.format) {
|
|
809
|
-
// Embedded: construct data URI from base64 data
|
|
810
|
-
const mimeType = this._formatToMime(entry.format);
|
|
811
|
-
const dataUri = `data:${mimeType};base64,${entry.data}`;
|
|
812
|
-
return this._loadAndCache(key, dataUri, colorSpace);
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
if (entry.url) {
|
|
816
|
-
// URL reference
|
|
817
|
-
return this._loadAndCache(key, entry.url, colorSpace);
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
// Invalid entry (neither data nor url)
|
|
821
|
-
logger.warn(`Texture table entry "${key}" has neither data nor url, skipping`);
|
|
822
|
-
return null;
|
|
823
|
-
}
|
|
824
|
-
|
|
825
146
|
// ---------------------------------------------------------------------------
|
|
826
147
|
// Private: Data URI loading
|
|
827
148
|
// ---------------------------------------------------------------------------
|
|
@@ -955,6 +276,7 @@ class TextureCache {
|
|
|
955
276
|
texture.colorSpace = colorSpace;
|
|
956
277
|
texture.wrapS = THREE.RepeatWrapping;
|
|
957
278
|
texture.wrapT = THREE.RepeatWrapping;
|
|
279
|
+
texture.anisotropy = this.maxAnisotropy;
|
|
958
280
|
resolve(texture);
|
|
959
281
|
},
|
|
960
282
|
undefined, // onProgress (not used)
|
|
@@ -984,24 +306,6 @@ class TextureCache {
|
|
|
984
306
|
return this._textureLoader;
|
|
985
307
|
}
|
|
986
308
|
|
|
987
|
-
/**
|
|
988
|
-
* Convert a format string (e.g. "png", "jpg", "webp") to a MIME type.
|
|
989
|
-
*/
|
|
990
|
-
private _formatToMime(format: string): string {
|
|
991
|
-
switch (format.toLowerCase()) {
|
|
992
|
-
case "jpg":
|
|
993
|
-
case "jpeg":
|
|
994
|
-
return "image/jpeg";
|
|
995
|
-
case "png":
|
|
996
|
-
return "image/png";
|
|
997
|
-
case "webp":
|
|
998
|
-
return "image/webp";
|
|
999
|
-
default:
|
|
1000
|
-
// Default to PNG for unknown formats
|
|
1001
|
-
logger.warn(`Unknown texture format "${format}", defaulting to image/png`);
|
|
1002
|
-
return "image/png";
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
309
|
}
|
|
1006
310
|
|
|
1007
311
|
/**
|
|
@@ -1017,4 +321,4 @@ function getColorSpaceForMap(mapName: string): THREE.ColorSpace {
|
|
|
1017
321
|
return THREEJS_SRGB_MAPS.has(mapName) ? THREE.SRGBColorSpace : THREE.LinearSRGBColorSpace;
|
|
1018
322
|
}
|
|
1019
323
|
|
|
1020
|
-
export { TextureCache, SRGB_TEXTURE_ROLES,
|
|
324
|
+
export { TextureCache, SRGB_TEXTURE_ROLES, getColorSpaceForMap };
|