@instruktlabs/kiln 1.2.1 → 1.2.3

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.
Files changed (219) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +86 -1
  4. package/README.md +18 -0
  5. package/dist/agent-run.mjs +6392 -1477
  6. package/dist/build.json +23 -23
  7. package/dist/cli.mjs +8453 -3407
  8. package/dist/evaluator-worker.mjs +16608 -12387
  9. package/dist/mcp-engine.mjs +6746 -1725
  10. package/dist/mcp-server.mjs +4 -4
  11. package/dist/viewer/app.js +203 -199
  12. package/dist/viewer/chat.html +209 -205
  13. package/dist/viewer/style.css +10 -0
  14. package/docs/atlas-provenance.md +23 -0
  15. package/docs/community-exporter.md +27 -0
  16. package/docs/export-profiles.md +20 -0
  17. package/docs/install.md +7 -6
  18. package/docs/material-library.md +9 -5
  19. package/docs/migration.md +16 -0
  20. package/docs/procedural-surfaces-v3.md +276 -0
  21. package/docs/projects-and-live-review.md +1 -1
  22. package/docs/rendering.md +41 -0
  23. package/docs/runtime.md +3 -3
  24. package/docs/sdk.md +55 -2
  25. package/docs/tools-operations.md +2 -2
  26. package/docs/tools.md +3 -3
  27. package/lib/asset-materials-node.d.ts +3 -3
  28. package/lib/asset-materials-node.js +29 -33
  29. package/lib/assets-node.d.ts +1 -1
  30. package/lib/assets-node.js +11 -4
  31. package/lib/assets.d.ts +4 -2
  32. package/lib/assets.js +61 -16
  33. package/lib/atlas-provenance.d.ts +86 -0
  34. package/lib/atlas-provenance.js +448 -0
  35. package/lib/character.d.ts +11 -2
  36. package/lib/character.js +90 -2
  37. package/lib/community-exporter.d.ts +3 -2
  38. package/lib/community-exporter.js +139 -3
  39. package/lib/discovery/helper-contracts.js +161 -7
  40. package/lib/discovery/helper-specs.js +49 -4
  41. package/lib/discovery/shapes.js +20 -7
  42. package/lib/engine-identity.d.ts +1 -1
  43. package/lib/engine-identity.js +1 -1
  44. package/lib/evaluator/authoring-diagnostic.d.ts +1 -1
  45. package/lib/evaluator/authoring-diagnostic.js +12 -0
  46. package/lib/evaluator/protocol.d.ts +2 -2
  47. package/lib/evaluator/protocol.js +16 -6
  48. package/lib/evaluator/subprocess.js +2 -2
  49. package/lib/exporter-node.js +9 -1
  50. package/lib/geometry-budget.d.ts +7 -0
  51. package/lib/geometry-budget.js +8 -0
  52. package/lib/gltf-texture-binding.d.ts +6 -0
  53. package/lib/gltf-texture-binding.js +54 -0
  54. package/lib/material-authoring-node.d.ts +6 -0
  55. package/lib/material-authoring-node.js +49 -0
  56. package/lib/material-authoring-schema.d.ts +871 -0
  57. package/lib/material-authoring-schema.js +310 -0
  58. package/lib/material-export.d.ts +5 -0
  59. package/lib/material-export.js +21 -0
  60. package/lib/material-library-node.d.ts +34 -8
  61. package/lib/material-library-node.js +691 -11
  62. package/lib/material-library.d.ts +3704 -4
  63. package/lib/material-library.js +272 -0
  64. package/lib/mcp-core.d.ts +1 -1
  65. package/lib/pinned-material-map.d.ts +33 -0
  66. package/lib/pinned-material-map.js +1 -0
  67. package/lib/portable-material-runtime.d.ts +5 -0
  68. package/lib/portable-material-runtime.js +160 -0
  69. package/lib/portable-sampler-runtime.d.ts +4 -0
  70. package/lib/portable-sampler-runtime.js +26 -0
  71. package/lib/portable-sampler.d.ts +11 -0
  72. package/lib/portable-sampler.js +42 -0
  73. package/lib/primitives.d.ts +15 -3
  74. package/lib/primitives.js +24 -4
  75. package/lib/procedural-material-v2.d.ts +2 -0
  76. package/lib/procedural-material-v2.js +2 -1
  77. package/lib/procedural-material-v3.d.ts +72 -0
  78. package/lib/procedural-material-v3.js +162 -0
  79. package/lib/procedural-texture-v2-compiler.d.ts +31 -0
  80. package/lib/procedural-texture-v2-compiler.js +172 -0
  81. package/lib/procedural-texture-v3-contract.d.ts +125 -0
  82. package/lib/procedural-texture-v3-contract.js +333 -0
  83. package/lib/procedural-texture-v3.d.ts +12 -0
  84. package/lib/procedural-texture-v3.js +165 -0
  85. package/lib/procedural-texture.d.ts +9 -31
  86. package/lib/procedural-texture.js +43 -170
  87. package/lib/project-bundle-node.d.ts +1 -1
  88. package/lib/project-bundle-node.js +70 -13
  89. package/lib/project-bundle.d.ts +91 -3
  90. package/lib/project-bundle.js +48 -7
  91. package/lib/qa/atlas.d.ts +21 -0
  92. package/lib/qa/atlas.js +474 -0
  93. package/lib/qa/index.d.ts +1 -0
  94. package/lib/qa/index.js +1 -0
  95. package/lib/qa/material.js +2 -0
  96. package/lib/qa/skin-integrity.d.ts +18 -0
  97. package/lib/qa/skin-integrity.js +221 -0
  98. package/lib/render-service-host.js +4 -7
  99. package/lib/render.d.ts +3 -3
  100. package/lib/render.js +108 -16
  101. package/lib/skin-binding.d.ts +20 -0
  102. package/lib/skin-binding.js +144 -0
  103. package/lib/texture-atlas-contract.d.ts +97 -0
  104. package/lib/texture-atlas-contract.js +414 -0
  105. package/lib/texture-atlas-pixels.d.ts +73 -0
  106. package/lib/texture-atlas-pixels.js +290 -0
  107. package/lib/texture-atlas.d.ts +10 -0
  108. package/lib/texture-atlas.js +143 -0
  109. package/lib/texture-bake.d.ts +3 -0
  110. package/lib/texture-bake.js +66 -11
  111. package/lib/texture-resolver.d.ts +22 -0
  112. package/lib/texture-resolver.js +24 -0
  113. package/lib/tools/materials.js +8 -7
  114. package/lib/tools/registry.js +5 -16
  115. package/lib/tools/review.js +1 -1
  116. package/lib/uv-data.d.ts +32 -0
  117. package/lib/uv-data.js +172 -0
  118. package/lib/uv-shapes.js +3 -0
  119. package/lib/uv.d.ts +9 -3
  120. package/lib/uv.js +53 -82
  121. package/lib/views/camera.d.ts +3 -3
  122. package/lib/views/camera.js +32 -28
  123. package/lib/views/face-policy.d.ts +9 -0
  124. package/lib/views/face-policy.js +16 -0
  125. package/lib/views/glb.js +218 -24
  126. package/lib/views/pose.d.ts +7 -1
  127. package/lib/views/pose.js +63 -12
  128. package/lib/views/raster.d.ts +6 -2
  129. package/lib/views/raster.js +49 -37
  130. package/lib/views/scene-copy.d.ts +6 -0
  131. package/lib/views/scene-copy.js +73 -0
  132. package/lib/views/scene-raster.js +2 -1
  133. package/lib/views/triangle-coverage.d.ts +8 -0
  134. package/lib/views/triangle-coverage.js +58 -0
  135. package/lib/workspace-node.js +7 -3
  136. package/lib/workspace.d.ts +2 -2
  137. package/package.json +3 -1
  138. package/plugin.json +1 -1
  139. package/plugins/kiln-engine/.claude-plugin/plugin.json +1 -1
  140. package/plugins/kiln-engine/README.md +3 -3
  141. package/plugins/kiln-engine/package-provenance.json +6 -6
  142. package/plugins/kiln-engine/plugin.json +1 -1
  143. package/plugins/kiln-engine/runtime.json +1 -1
  144. package/skills/kiln-author-asset/SKILL.md +1 -1
  145. package/skills/kiln-author-asset/references/camera-cli.md +13 -0
  146. package/skills/kiln-author-asset/references/geometry-recipes.md +134 -0
  147. package/skills/kiln-author-asset/references/projects-and-materials.md +32 -2
  148. package/skills/kiln-compose-scene/references/runtime-scenes.md +19 -0
  149. package/skills/kiln-qa-asset/references/integration-checks.md +37 -2
  150. package/src/asset-materials-node.ts +38 -45
  151. package/src/assets-node.ts +22 -5
  152. package/src/assets.ts +61 -20
  153. package/src/atlas-provenance.ts +522 -0
  154. package/src/character.ts +107 -2
  155. package/src/community-exporter.ts +143 -3
  156. package/src/discovery/helper-contracts.ts +188 -7
  157. package/src/discovery/helper-specs.ts +69 -4
  158. package/src/discovery/shapes.ts +25 -9
  159. package/src/engine-identity.ts +1 -1
  160. package/src/evaluator/authoring-diagnostic.ts +19 -1
  161. package/src/evaluator/protocol.ts +19 -9
  162. package/src/evaluator/subprocess.ts +2 -2
  163. package/src/exporter-node.ts +12 -1
  164. package/src/generated/mcp-manifest.json +3 -3
  165. package/src/geometry-budget.ts +8 -0
  166. package/src/gltf-texture-binding.ts +60 -0
  167. package/src/material-authoring-node.ts +68 -0
  168. package/src/material-authoring-schema.ts +343 -0
  169. package/src/material-export.ts +25 -0
  170. package/src/material-library-node.ts +840 -16
  171. package/src/material-library.ts +348 -5
  172. package/src/pinned-material-map.ts +43 -0
  173. package/src/portable-material-runtime.ts +200 -0
  174. package/src/portable-sampler-runtime.ts +40 -0
  175. package/src/portable-sampler.ts +54 -0
  176. package/src/primitives.ts +98 -4
  177. package/src/procedural-material-v2.ts +2 -1
  178. package/src/procedural-material-v3.ts +224 -0
  179. package/src/procedural-texture-v2-compiler.ts +234 -0
  180. package/src/procedural-texture-v3-contract.ts +437 -0
  181. package/src/procedural-texture-v3.ts +220 -0
  182. package/src/procedural-texture.ts +61 -229
  183. package/src/project-bundle-node.ts +106 -14
  184. package/src/project-bundle.ts +61 -18
  185. package/src/project-cli.ts +29 -17
  186. package/src/qa/atlas.ts +597 -0
  187. package/src/qa/index.ts +1 -0
  188. package/src/qa/material.ts +2 -0
  189. package/src/qa/skin-integrity.ts +291 -0
  190. package/src/render-service-host.ts +4 -6
  191. package/src/render.ts +133 -15
  192. package/src/skin-binding.ts +225 -0
  193. package/src/texture-atlas-contract.ts +558 -0
  194. package/src/texture-atlas-pixels.ts +456 -0
  195. package/src/texture-atlas.ts +185 -0
  196. package/src/texture-bake.ts +88 -10
  197. package/src/texture-resolver.ts +55 -0
  198. package/src/tools/materials.ts +15 -11
  199. package/src/tools/registry.ts +5 -15
  200. package/src/tools/review.ts +1 -1
  201. package/src/uv-data.ts +238 -0
  202. package/src/uv-shapes.ts +3 -0
  203. package/src/uv.ts +73 -128
  204. package/src/viewer/app.ts +37 -0
  205. package/src/viewer/live-panel.ts +57 -1
  206. package/src/viewer/material-preview-panel.ts +140 -0
  207. package/src/viewer/material-preview.ts +138 -0
  208. package/src/viewer/materials-panel.ts +34 -5
  209. package/src/viewer/navigation.ts +2 -0
  210. package/src/views/camera.ts +37 -40
  211. package/src/views/face-policy.ts +20 -0
  212. package/src/views/glb.ts +294 -17
  213. package/src/views/pose.ts +71 -13
  214. package/src/views/raster.ts +73 -46
  215. package/src/views/scene-copy.ts +86 -0
  216. package/src/views/scene-raster.ts +2 -1
  217. package/src/views/triangle-coverage.ts +78 -0
  218. package/src/workspace-node.ts +11 -3
  219. package/src/workspace.ts +2 -2
@@ -6,13 +6,13 @@
6
6
  * kiln-studio agent-runtime container ships without a browser
7
7
  * (PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1) and Playwright is unsupported under
8
8
  * Bun on Windows (see imposter/bake.ts). headless-gl is WebGL1-only while
9
- * three >= r163 requires WebGL2. So: a dependency-free scanline z-buffer
9
+ * three >= r163 requires WebGL2. So: a CPU scanline z-buffer
10
10
  * rasterizer over the executed scene. Flat lambert, base color only — enough
11
11
  * fidelity for silhouette / proportion / orientation / contact QA, which is
12
12
  * what the vision loop checks.
13
13
  *
14
14
  * Scene access is strictly duck-typed (`.isMesh`, `matrixWorld.elements`,
15
- * position attribute arrays) because sandbox-created objects live in a
15
+ * vertex readers and position attributes) because sandbox-created objects live in a
16
16
  * different module realm — see executeKilnCode in ../render.ts.
17
17
  *
18
18
  * Lighting matches scripts/visual-audit.ts: key directional light from
@@ -20,7 +20,10 @@
20
20
  * the offline audit grid) so inverted winding shows up as missing faces.
21
21
  */
22
22
 
23
+ import { Vector3 } from 'three';
23
24
  import { resolveBackdrop, type BackdropId } from './background';
25
+ import { visibleNormalSign, worldWindingParity, type SurfaceSide } from './face-policy';
26
+ import { prepareTriangleCoverage } from './triangle-coverage';
24
27
 
25
28
  export interface ViewSpec {
26
29
  name: string;
@@ -118,7 +121,10 @@ export interface RasterOptions {
118
121
  * (a sideways/forward-sliding walk would look stationary). The scene's own
119
122
  * geometry is still drawn; only the camera center/zoom come from this box.
120
123
  */
121
- frameBounds?: { min: [number, number, number]; max: [number, number, number] };
124
+ frameBounds?: {
125
+ min: [number, number, number];
126
+ max: [number, number, number];
127
+ };
122
128
  /** Named backdrop to clear to. Omit for the default; see `views/background.ts`. */
123
129
  backdrop?: BackdropId;
124
130
  }
@@ -146,6 +152,9 @@ interface DuckAttribute {
146
152
  array: ArrayLike<number>;
147
153
  itemSize: number;
148
154
  count: number;
155
+ getX?(index: number): number;
156
+ getY?(index: number): number;
157
+ getZ?(index: number): number;
149
158
  }
150
159
  interface DuckGeometry {
151
160
  getAttribute(name: string): DuckAttribute | undefined;
@@ -159,6 +168,7 @@ interface DuckMaterial {
159
168
  /** Effective glTF base-color alpha after OPAQUE/MASK/BLEND handling. */
160
169
  opacity?: number;
161
170
  doubleSided?: boolean;
171
+ side?: number;
162
172
  visible?: boolean;
163
173
  }
164
174
  interface DuckMesh {
@@ -167,20 +177,50 @@ interface DuckMesh {
167
177
  geometry?: DuckGeometry;
168
178
  material?: DuckMaterial | DuckMaterial[];
169
179
  matrixWorld?: { elements: ArrayLike<number> };
180
+ getVertexPosition?(index: number, target: Vector3): Vector3;
181
+ skeleton?: { bones: DuckObject3D[] };
170
182
  }
171
183
  interface DuckObject3D {
172
184
  visible?: boolean;
173
185
  children?: DuckObject3D[];
174
186
  updateMatrixWorld?(force?: boolean): void;
187
+ updateWorldMatrix?(updateParents: boolean, updateChildren: boolean): void;
175
188
  traverse?(cb: (obj: unknown) => void): void;
176
189
  }
177
190
 
191
+ function updateSurfaceMatrices(root: DuckObject3D): void {
192
+ // Isolated part measurements may start below a changed parent. Keep the normal
193
+ // updateMatrixWorld dispatch too: SkinnedMesh refreshes its bind inverse there.
194
+ root.updateWorldMatrix?.(true, false);
195
+ root.updateMatrixWorld?.(true);
196
+ }
197
+
198
+ /** Three's own vertex reader applies morphs, then skinning, without changing the
199
+ * source buffers. A local Vector3 works across realms; no class identity test is
200
+ * needed. Plain GLB duck geometry retains its deterministic array fallback. */
201
+ function surfaceVertexReader(mesh: DuckMesh, position: DuckAttribute): (index: number) => Vector3 {
202
+ for (const bone of mesh.skeleton?.bones ?? []) bone.updateWorldMatrix?.(true, false);
203
+ const vertex = new Vector3();
204
+ if (mesh.getVertexPosition) return (index) => mesh.getVertexPosition!(index, vertex);
205
+ if (position.getX && position.getY && position.getZ)
206
+ return (index) =>
207
+ vertex.set(position.getX!(index), position.getY!(index), position.getZ!(index));
208
+ return (index) =>
209
+ vertex.set(
210
+ position.array[index * 3]!,
211
+ position.array[index * 3 + 1]!,
212
+ position.array[index * 3 + 2]!,
213
+ );
214
+ }
215
+
178
216
  interface Tri {
179
217
  // World-space vertices, flattened [x0,y0,z0, x1,y1,z1, x2,y2,z2]
180
218
  v: Float64Array;
181
219
  color: [number, number, number];
182
220
  alpha: number;
183
221
  doubleSided: boolean;
222
+ side: SurfaceSide;
223
+ windingParity: 1 | -1;
184
224
  }
185
225
 
186
226
  /**
@@ -241,7 +281,7 @@ export function collectTriangles(root: DuckObject3D): {
241
281
  tris: Tri[];
242
282
  bbox: { min: Vec3; max: Vec3 };
243
283
  } {
244
- root.updateMatrixWorld?.(true);
284
+ updateSurfaceMatrices(root);
245
285
 
246
286
  const tris: Tri[] = [];
247
287
  const min: Vec3 = [Infinity, Infinity, Infinity];
@@ -277,13 +317,12 @@ export function collectTriangles(root: DuckObject3D): {
277
317
 
278
318
  const m = mesh.matrixWorld?.elements;
279
319
  if (!m) return;
320
+ const windingParity = worldWindingParity(m);
280
321
 
281
- const arr = pos.array;
322
+ const readVertex = surfaceVertexReader(mesh, pos);
282
323
  const world = new Float64Array(pos.count * 3);
283
324
  for (let i = 0; i < pos.count; i++) {
284
- const x = arr[i * 3]!;
285
- const y = arr[i * 3 + 1]!;
286
- const z = arr[i * 3 + 2]!;
325
+ const { x, y, z } = readVertex(i);
287
326
  const wx = m[0]! * x + m[4]! * y + m[8]! * z + m[12]!;
288
327
  const wy = m[1]! * x + m[5]! * y + m[9]! * z + m[13]!;
289
328
  const wz = m[2]! * x + m[6]! * y + m[10]! * z + m[14]!;
@@ -309,11 +348,15 @@ export function collectTriangles(root: DuckObject3D): {
309
348
  v[6] = world[c2 * 3]!;
310
349
  v[7] = world[c2 * 3 + 1]!;
311
350
  v[8] = world[c2 * 3 + 2]!;
351
+ const side =
352
+ material?.doubleSided === true || material?.side === 2 ? 2 : material?.side === 1 ? 1 : 0;
312
353
  tris.push({
313
354
  v,
314
355
  color: colorOf(material),
315
356
  alpha: clamp01(material?.opacity ?? 1),
316
- doubleSided: material?.doubleSided === true,
357
+ doubleSided: side === 2,
358
+ side,
359
+ windingParity,
317
360
  });
318
361
  };
319
362
 
@@ -410,6 +453,7 @@ export function rasterizeView(
410
453
  const sx = new Float64Array(3);
411
454
  const sy = new Float64Array(3);
412
455
  const sz = new Float64Array(3);
456
+ const weights = new Float64Array(3);
413
457
 
414
458
  // Orthographic: distance from the viewer runs against the view axis `z`.
415
459
  const ordered = compositingOrder(
@@ -423,10 +467,15 @@ export function rasterizeView(
423
467
  const n = cross(e1, e2);
424
468
  const nLen = Math.hypot(n[0], n[1], n[2]);
425
469
  if (nLen < 1e-12) continue;
426
- const N: Vec3 = [n[0] / nLen, n[1] / nLen, n[2] / nLen];
470
+ const N: Vec3 = [
471
+ (n[0] / nLen) * tri.windingParity,
472
+ (n[1] / nLen) * tri.windingParity,
473
+ (n[2] / nLen) * tri.windingParity,
474
+ ];
427
475
 
428
476
  const facing = dot(N, z);
429
- if (cull && facing <= 0 && !tri.doubleSided) continue;
477
+ const normalSign = visibleNormalSign(facing, tri.side, cull);
478
+ if (normalSign === 0) continue;
430
479
  if (tri.alpha <= 0) continue;
431
480
 
432
481
  // Project to screen space.
@@ -442,43 +491,20 @@ export function rasterizeView(
442
491
 
443
492
  // Flat lambert with the audit-grid light rig, in linear space, then the
444
493
  // sRGB output transform (matching what a Three.js renderer would write).
445
- const lambert = Math.max(0, dot(N, KEY_DIR));
494
+ const lambert = Math.max(0, dot(N, KEY_DIR) * normalSign);
446
495
  const lit = Math.min(1, AMBIENT + KEY_INTENSITY * lambert);
447
496
  const r = Math.round(linearToSrgb(tri.color[0] * lit) * 255);
448
497
  const g = Math.round(linearToSrgb(tri.color[1] * lit) * 255);
449
498
  const b = Math.round(linearToSrgb(tri.color[2] * lit) * 255);
450
499
 
451
- // Rasterize via edge functions.
452
- const minX = Math.max(0, Math.floor(Math.min(sx[0]!, sx[1]!, sx[2]!)));
453
- const maxX = Math.min(size - 1, Math.ceil(Math.max(sx[0]!, sx[1]!, sx[2]!)));
454
- const minY = Math.max(0, Math.floor(Math.min(sy[0]!, sy[1]!, sy[2]!)));
455
- const maxY = Math.min(size - 1, Math.ceil(Math.max(sy[0]!, sy[1]!, sy[2]!)));
456
- if (minX > maxX || minY > maxY) continue;
457
-
458
- const ax = sx[0]!,
459
- ay = sy[0]!,
460
- bx2 = sx[1]!,
461
- by = sy[1]!,
462
- cx = sx[2]!,
463
- cy = sy[2]!;
464
- const area = (bx2 - ax) * (cy - ay) - (by - ay) * (cx - ax);
465
- if (Math.abs(area) < 1e-9) continue;
466
- const invArea = 1 / area;
500
+ const triangle = prepareTriangleCoverage(sx[0]!, sy[0]!, sx[1]!, sy[1]!, sx[2]!, sy[2]!, size);
501
+ if (!triangle) continue;
502
+ const { minX, maxX, minY, maxY } = triangle;
467
503
 
468
504
  for (let py = minY; py <= maxY; py++) {
469
505
  for (let px = minX; px <= maxX; px++) {
470
- const cxp = px + 0.5;
471
- const cyp = py + 0.5;
472
- const w0 = ((bx2 - ax) * (cyp - ay) - (by - ay) * (cxp - ax)) * invArea;
473
- const w1 = ((cx - bx2) * (cyp - by) - (cy - by) * (cxp - bx2)) * invArea;
474
- const w2 = 1 - w0 - w1;
475
- // Accept either winding on screen (back-face culling already happened
476
- // in world space) — check all barycentrics share the area's sign space.
477
- if (w0 < 0 || w1 < 0 || w2 < 0) continue;
478
- // Barycentric weights map: w0 → vertex C, w1 → vertex A, w2 → vertex B
479
- // for this edge-function arrangement; depth interpolation just needs a
480
- // consistent convex combination, so order doesn't matter for QA views.
481
- const depth = w1 * sz[0]! + w2 * sz[1]! + w0 * sz[2]!;
506
+ if (!triangle.sample(px + 0.5, py + 0.5, weights)) continue;
507
+ const depth = weights[0]! * sz[0]! + weights[1]! * sz[1]! + weights[2]! * sz[2]!;
482
508
  const pi = py * size + px;
483
509
  if (depth <= zbuf[pi]!) continue;
484
510
  zbuf[pi] = depth;
@@ -525,7 +551,7 @@ export function measurePartBounds(root: unknown): ReturnType<typeof measureBound
525
551
  export function createPartBoundsReader(
526
552
  root: unknown,
527
553
  ): (node: unknown) => ReturnType<typeof measureBounds> | null {
528
- (root as DuckObject3D).updateMatrixWorld?.(true);
554
+ updateSurfaceMatrices(root as DuckObject3D);
529
555
  const own = new Map<unknown, { min: Vec3; max: Vec3; tris: number } | null>();
530
556
  const meshBounds = (obj: unknown) => {
531
557
  if (own.has(obj)) return own.get(obj)!;
@@ -536,11 +562,9 @@ export function createPartBoundsReader(
536
562
  if (pos && pos.itemSize === 3 && m) {
537
563
  const min: Vec3 = [Infinity, Infinity, Infinity];
538
564
  const max: Vec3 = [-Infinity, -Infinity, -Infinity];
539
- const arr = pos.array;
565
+ const readVertex = surfaceVertexReader(mesh, pos);
540
566
  for (let i = 0; i < pos.count; i++) {
541
- const x = arr[i * 3]!,
542
- y = arr[i * 3 + 1]!,
543
- z = arr[i * 3 + 2]!;
567
+ const { x, y, z } = readVertex(i);
544
568
  const w: Vec3 = [
545
569
  m[0]! * x + m[4]! * y + m[8]! * z + m[12]!,
546
570
  m[1]! * x + m[5]! * y + m[9]! * z + m[13]!,
@@ -614,7 +638,10 @@ export function hideNodeInScene(
614
638
  let hidden = 0;
615
639
  const r = root as DuckObject3D;
616
640
  r.traverse?.((obj) => {
617
- const node = obj as { name?: string; traverse?(cb: (o: unknown) => void): void };
641
+ const node = obj as {
642
+ name?: string;
643
+ traverse?(cb: (o: unknown) => void): void;
644
+ };
618
645
  if (!node.name || !test(node.name)) return;
619
646
  hidden++;
620
647
  // Flip the matched node and its whole subtree (the per-mesh cull means hiding
@@ -0,0 +1,86 @@
1
+ import {
2
+ DoubleSide,
3
+ Group,
4
+ Skeleton,
5
+ type Bone,
6
+ type Material,
7
+ type Mesh,
8
+ type Object3D,
9
+ type SkinnedMesh,
10
+ } from 'three';
11
+
12
+ /** Owned review hierarchy and skeletons; immutable geometry/textures stay shared. */
13
+ export function cloneReviewScene(
14
+ root: Object3D,
15
+ options: { omitHidden?: boolean; doubleSided?: boolean } = {},
16
+ ): Object3D {
17
+ const visible = new Set<Object3D>();
18
+ if (options.omitHidden) root.traverseVisible((node) => visible.add(node));
19
+ else root.traverse((node) => visible.add(node));
20
+ const retained = new Set(visible);
21
+ const hierarchy = new Set<Object3D>();
22
+ root.traverse((node) => hierarchy.add(node));
23
+
24
+ // Visibility hides drawing, not the transforms used by another mesh's skeleton.
25
+ for (const node of visible) {
26
+ const mesh = node as SkinnedMesh;
27
+ if (!mesh.isSkinnedMesh) continue;
28
+ if (!mesh.skeleton) throw new Error(`Review skin ${mesh.name} has no skeleton.`);
29
+ for (const bone of mesh.skeleton.bones) {
30
+ if (!hierarchy.has(bone)) {
31
+ throw new Error(
32
+ `Skin bone ${bone?.name ?? '(missing)'} is outside the review hierarchy. Include its ancestors in the review root.`,
33
+ );
34
+ }
35
+ for (let ancestor: Object3D | null = bone; ancestor; ancestor = ancestor.parent) {
36
+ retained.add(ancestor);
37
+ if (ancestor === root) break;
38
+ }
39
+ }
40
+ }
41
+
42
+ const copies = new Map<Object3D, Object3D>();
43
+ const copyNode = (node: Object3D): Object3D => {
44
+ // A hidden mesh/light can be a joint ancestor. Keep its transform as a group,
45
+ // not its drawing payload. Referenced bones must retain their Bone identity.
46
+ const copy =
47
+ !visible.has(node) && !(node as Bone).isBone
48
+ ? new Group().copy(node, false)
49
+ : node.clone(false);
50
+ copies.set(node, copy);
51
+ if (options.omitHidden) copy.visible = true;
52
+ for (const child of node.children) if (retained.has(child)) copy.add(copyNode(child));
53
+ return copy;
54
+ };
55
+ if (!retained.has(root)) {
56
+ const empty = new Group();
57
+ empty.name = root.name;
58
+ return empty;
59
+ }
60
+ const copy = copyNode(root);
61
+ for (const [source, target] of copies) {
62
+ const sourceSkin = source as SkinnedMesh;
63
+ const targetSkin = target as SkinnedMesh;
64
+ if (targetSkin.isSkinnedMesh) {
65
+ // SkinnedMesh.copy already preserves bind mode/matrices and morph weights.
66
+ // Reconnect only to copied bones, including detached skeletons. Keep inverse
67
+ // matrices independent too, so later edits cannot mutate the retained source.
68
+ targetSkin.skeleton = new Skeleton(
69
+ sourceSkin.skeleton.bones.map((bone) => copies.get(bone) as Bone),
70
+ sourceSkin.skeleton.boneInverses.map((matrix) => matrix.clone()),
71
+ );
72
+ }
73
+ const mesh = target as Mesh;
74
+ if (options.doubleSided && mesh.isMesh) {
75
+ const prepare = (material: Material) => {
76
+ const owned = material.clone();
77
+ owned.side = DoubleSide;
78
+ return owned;
79
+ };
80
+ mesh.material = Array.isArray(mesh.material)
81
+ ? mesh.material.map(prepare)
82
+ : prepare(mesh.material);
83
+ }
84
+ }
85
+ return copy;
86
+ }
@@ -23,6 +23,7 @@ import {
23
23
  import { loadGlbReviewScene } from './glb';
24
24
  import { encodePng } from './png';
25
25
  import { rasterizeView, type ViewSpec } from './raster';
26
+ import { cloneReviewScene } from './scene-copy';
26
27
 
27
28
  const DEG = Math.PI / 180;
28
29
  const PAD = 4;
@@ -103,7 +104,7 @@ export async function rasterizeComposedScene(
103
104
  base = (await loadGlbReviewScene(glb)).root;
104
105
  baseByInput.set(key, base);
105
106
  }
106
- const inst = base.clone(true);
107
+ const inst = cloneReviewScene(base);
107
108
  inst.position.set(part.pos[0], part.pos[1], part.pos[2]);
108
109
  inst.rotation.set(0, part.rotYDeg * DEG, 0);
109
110
  inst.scale.setScalar(part.scale);
@@ -0,0 +1,78 @@
1
+ /** Pixel-centre coverage shared by the flat CPU renderers. No shading/culling policy. */
2
+ interface Edge {
3
+ x: number;
4
+ y: number;
5
+ dx: number;
6
+ dy: number;
7
+ sign: number;
8
+ inclusive: boolean;
9
+ }
10
+
11
+ function edge(ax: number, ay: number, bx: number, by: number, orientation: number): Edge {
12
+ const dx = (bx - ax) * orientation;
13
+ const dy = (by - ay) * orientation;
14
+ // Screen Y grows downward. For positive area, top/left edges own exact ties.
15
+ const inclusive = dy < 0 || (dy === 0 && dx > 0);
16
+ // Both triangles evaluate a shared edge from the SAME endpoint. Merely reversing
17
+ // (B-A)x(P-A) can round differently from (A-B)x(P-B), causing cracks/overlap.
18
+ const forward = ax < bx || (ax === bx && ay <= by);
19
+ return forward
20
+ ? { x: ax, y: ay, dx: bx - ax, dy: by - ay, sign: orientation, inclusive }
21
+ : { x: bx, y: by, dx: ax - bx, dy: ay - by, sign: -orientation, inclusive };
22
+ }
23
+
24
+ function value(e: Edge, x: number, y: number): number {
25
+ return (e.dx * (y - e.y) - e.dy * (x - e.x)) * e.sign;
26
+ }
27
+
28
+ export function prepareTriangleCoverage(
29
+ ax: number,
30
+ ay: number,
31
+ bx: number,
32
+ by: number,
33
+ cx: number,
34
+ cy: number,
35
+ size: number,
36
+ ) {
37
+ if (![ax, ay, bx, by, cx, cy].every(Number.isFinite)) return null;
38
+ const area = value(edge(ax, ay, bx, by, 1), cx, cy);
39
+ if (!Number.isFinite(area) || area === 0) return null;
40
+ const orientation = area < 0 ? -1 : 1;
41
+ const a = edge(bx, by, cx, cy, orientation);
42
+ const b = edge(cx, cy, ax, ay, orientation);
43
+ const c = edge(ax, ay, bx, by, orientation);
44
+ const minX = Math.max(0, Math.floor(Math.min(ax, bx, cx)));
45
+ const maxX = Math.min(size - 1, Math.ceil(Math.max(ax, bx, cx)));
46
+ const minY = Math.max(0, Math.floor(Math.min(ay, by, cy)));
47
+ const maxY = Math.min(size - 1, Math.ceil(Math.max(ay, by, cy)));
48
+ if (minX > maxX || minY > maxY) return null;
49
+ return {
50
+ minX,
51
+ maxX,
52
+ minY,
53
+ maxY,
54
+ /** Writes A/B/C weights into caller-owned storage; no per-pixel allocations. */
55
+ sample(x: number, y: number, weights: Float64Array): boolean {
56
+ const wa = value(a, x, y),
57
+ wb = value(b, x, y),
58
+ wc = value(c, x, y);
59
+ if (
60
+ wa < 0 ||
61
+ wb < 0 ||
62
+ wc < 0 ||
63
+ (wa === 0 && !a.inclusive) ||
64
+ (wb === 0 && !b.inclusive) ||
65
+ (wc === 0 && !c.inclusive)
66
+ )
67
+ return false;
68
+ // Evaluate all three edges directly. Deriving one as 1-wa-wb was the source
69
+ // of false negative coverage. Tie ownership never biases interpolation.
70
+ const sum = wa + wb + wc;
71
+ if (!(sum > 0) || !Number.isFinite(sum)) return false;
72
+ weights[0] = wa / sum;
73
+ weights[1] = wb / sum;
74
+ weights[2] = wc / sum;
75
+ return true;
76
+ },
77
+ };
78
+ }
@@ -1,7 +1,11 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { join } from 'node:path';
3
3
  import { FileProjectStore } from './projects-node';
4
- import { FileMaterialLibrary, createMaterialLibraryPayload } from './material-library-node';
4
+ import {
5
+ FileMaterialLibrary,
6
+ createMaterialLibraryPayload,
7
+ resolveMaterialRecordClosure,
8
+ } from './material-library-node';
5
9
  import {
6
10
  workspaceSelectionSchema,
7
11
  type WorkspaceBinding,
@@ -68,12 +72,12 @@ export class FileWorkspace implements WorkspacePort {
68
72
  throw new Error(
69
73
  `An evaluation may resolve at most ${MATERIAL_LIBRARY_LIMITS.maxPayloadRecords} material resources; select a smaller dependency closure.`,
70
74
  );
71
- const materials = await Promise.all(
75
+ const manifests = await Promise.all(
72
76
  materialDependencies.map(async (dependency) => {
73
77
  if (dependency.sha256 !== dependency.revisionId)
74
78
  throw new Error(`Material lock hash mismatch: ${dependency.resourceId}`);
75
79
  try {
76
- return await this.materials.read(dependency.resourceId, dependency.revisionId);
80
+ return await this.materials.readManifest(dependency.resourceId, dependency.revisionId);
77
81
  } catch (error) {
78
82
  throw new Error(
79
83
  `Locked material unavailable: ${dependency.resourceId} at ${dependency.revisionId}`,
@@ -82,6 +86,10 @@ export class FileWorkspace implements WorkspacePort {
82
86
  }
83
87
  }),
84
88
  );
89
+ const materials = await resolveMaterialRecordClosure(manifests, {
90
+ readManifest: (id, revision) => this.materials.readManifest(id, revision),
91
+ read: (id, revision) => this.materials.readRecordFiles(id, revision),
92
+ });
85
93
  const materialResources = await createMaterialLibraryPayload(materials);
86
94
  return this.active.run(
87
95
  { ...(project ? { project } : {}), materialDependencies, materialResources },
package/src/workspace.ts CHANGED
@@ -6,12 +6,12 @@ import {
6
6
  type MaterialDependency,
7
7
  type ProjectRevision,
8
8
  } from './projects';
9
- import type { MaterialLibraryPayloadV1 } from './material-library';
9
+ import type { MaterialLibraryPayload } from './material-library';
10
10
 
11
11
  export interface WorkspaceBinding {
12
12
  project?: ProjectRevision;
13
13
  materialDependencies: MaterialDependency[];
14
- materialResources: MaterialLibraryPayloadV1;
14
+ materialResources: MaterialLibraryPayload;
15
15
  }
16
16
  export const workspaceSelectionSchema = z
17
17
  .object({