@instruktlabs/kiln 1.2.2 → 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 (214) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +74 -0
  4. package/README.md +7 -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/material-library.md +9 -5
  18. package/docs/procedural-surfaces-v3.md +276 -0
  19. package/docs/projects-and-live-review.md +1 -1
  20. package/docs/rendering.md +41 -0
  21. package/docs/sdk.md +53 -0
  22. package/docs/tools-operations.md +2 -2
  23. package/docs/tools.md +3 -3
  24. package/lib/asset-materials-node.d.ts +3 -3
  25. package/lib/asset-materials-node.js +29 -33
  26. package/lib/assets-node.d.ts +1 -1
  27. package/lib/assets-node.js +11 -4
  28. package/lib/assets.d.ts +4 -2
  29. package/lib/assets.js +61 -16
  30. package/lib/atlas-provenance.d.ts +86 -0
  31. package/lib/atlas-provenance.js +448 -0
  32. package/lib/character.d.ts +11 -2
  33. package/lib/character.js +90 -2
  34. package/lib/community-exporter.d.ts +3 -2
  35. package/lib/community-exporter.js +139 -3
  36. package/lib/discovery/helper-contracts.js +161 -7
  37. package/lib/discovery/helper-specs.js +49 -4
  38. package/lib/discovery/shapes.js +20 -7
  39. package/lib/engine-identity.d.ts +1 -1
  40. package/lib/engine-identity.js +1 -1
  41. package/lib/evaluator/authoring-diagnostic.d.ts +1 -1
  42. package/lib/evaluator/authoring-diagnostic.js +12 -0
  43. package/lib/evaluator/protocol.d.ts +2 -2
  44. package/lib/evaluator/protocol.js +16 -6
  45. package/lib/evaluator/subprocess.js +2 -2
  46. package/lib/exporter-node.js +9 -1
  47. package/lib/geometry-budget.d.ts +7 -0
  48. package/lib/geometry-budget.js +8 -0
  49. package/lib/gltf-texture-binding.d.ts +6 -0
  50. package/lib/gltf-texture-binding.js +54 -0
  51. package/lib/material-authoring-node.d.ts +6 -0
  52. package/lib/material-authoring-node.js +49 -0
  53. package/lib/material-authoring-schema.d.ts +871 -0
  54. package/lib/material-authoring-schema.js +310 -0
  55. package/lib/material-export.d.ts +5 -0
  56. package/lib/material-export.js +21 -0
  57. package/lib/material-library-node.d.ts +34 -8
  58. package/lib/material-library-node.js +691 -11
  59. package/lib/material-library.d.ts +3704 -4
  60. package/lib/material-library.js +272 -0
  61. package/lib/mcp-core.d.ts +1 -1
  62. package/lib/pinned-material-map.d.ts +33 -0
  63. package/lib/pinned-material-map.js +1 -0
  64. package/lib/portable-material-runtime.d.ts +5 -0
  65. package/lib/portable-material-runtime.js +160 -0
  66. package/lib/portable-sampler-runtime.d.ts +4 -0
  67. package/lib/portable-sampler-runtime.js +26 -0
  68. package/lib/portable-sampler.d.ts +11 -0
  69. package/lib/portable-sampler.js +42 -0
  70. package/lib/primitives.d.ts +15 -3
  71. package/lib/primitives.js +24 -4
  72. package/lib/procedural-material-v2.d.ts +2 -0
  73. package/lib/procedural-material-v2.js +2 -1
  74. package/lib/procedural-material-v3.d.ts +72 -0
  75. package/lib/procedural-material-v3.js +162 -0
  76. package/lib/procedural-texture-v2-compiler.d.ts +31 -0
  77. package/lib/procedural-texture-v2-compiler.js +172 -0
  78. package/lib/procedural-texture-v3-contract.d.ts +125 -0
  79. package/lib/procedural-texture-v3-contract.js +333 -0
  80. package/lib/procedural-texture-v3.d.ts +12 -0
  81. package/lib/procedural-texture-v3.js +165 -0
  82. package/lib/procedural-texture.d.ts +9 -31
  83. package/lib/procedural-texture.js +43 -170
  84. package/lib/project-bundle-node.d.ts +1 -1
  85. package/lib/project-bundle-node.js +70 -13
  86. package/lib/project-bundle.d.ts +91 -3
  87. package/lib/project-bundle.js +48 -7
  88. package/lib/qa/atlas.d.ts +21 -0
  89. package/lib/qa/atlas.js +474 -0
  90. package/lib/qa/index.d.ts +1 -0
  91. package/lib/qa/index.js +1 -0
  92. package/lib/qa/material.js +2 -0
  93. package/lib/qa/skin-integrity.d.ts +18 -0
  94. package/lib/qa/skin-integrity.js +221 -0
  95. package/lib/render-service-host.js +4 -7
  96. package/lib/render.d.ts +3 -3
  97. package/lib/render.js +108 -16
  98. package/lib/skin-binding.d.ts +20 -0
  99. package/lib/skin-binding.js +144 -0
  100. package/lib/texture-atlas-contract.d.ts +97 -0
  101. package/lib/texture-atlas-contract.js +414 -0
  102. package/lib/texture-atlas-pixels.d.ts +73 -0
  103. package/lib/texture-atlas-pixels.js +290 -0
  104. package/lib/texture-atlas.d.ts +10 -0
  105. package/lib/texture-atlas.js +143 -0
  106. package/lib/texture-bake.d.ts +3 -0
  107. package/lib/texture-bake.js +66 -11
  108. package/lib/texture-resolver.d.ts +22 -0
  109. package/lib/texture-resolver.js +24 -0
  110. package/lib/tools/materials.js +8 -7
  111. package/lib/tools/registry.js +5 -16
  112. package/lib/tools/review.js +1 -1
  113. package/lib/uv-data.d.ts +32 -0
  114. package/lib/uv-data.js +172 -0
  115. package/lib/uv-shapes.js +3 -0
  116. package/lib/uv.d.ts +9 -3
  117. package/lib/uv.js +53 -82
  118. package/lib/views/camera.d.ts +3 -3
  119. package/lib/views/camera.js +32 -28
  120. package/lib/views/face-policy.d.ts +9 -0
  121. package/lib/views/face-policy.js +16 -0
  122. package/lib/views/glb.js +218 -24
  123. package/lib/views/pose.d.ts +7 -1
  124. package/lib/views/pose.js +63 -12
  125. package/lib/views/raster.d.ts +6 -2
  126. package/lib/views/raster.js +49 -37
  127. package/lib/views/scene-copy.d.ts +6 -0
  128. package/lib/views/scene-copy.js +73 -0
  129. package/lib/views/scene-raster.js +2 -1
  130. package/lib/views/triangle-coverage.d.ts +8 -0
  131. package/lib/views/triangle-coverage.js +58 -0
  132. package/lib/workspace-node.js +7 -3
  133. package/lib/workspace.d.ts +2 -2
  134. package/package.json +3 -1
  135. package/plugin.json +1 -1
  136. package/plugins/kiln-engine/.claude-plugin/plugin.json +1 -1
  137. package/plugins/kiln-engine/README.md +3 -3
  138. package/plugins/kiln-engine/package-provenance.json +6 -6
  139. package/plugins/kiln-engine/plugin.json +1 -1
  140. package/plugins/kiln-engine/runtime.json +1 -1
  141. package/skills/kiln-author-asset/references/geometry-recipes.md +124 -0
  142. package/skills/kiln-author-asset/references/projects-and-materials.md +32 -2
  143. package/skills/kiln-compose-scene/references/runtime-scenes.md +19 -0
  144. package/skills/kiln-qa-asset/references/integration-checks.md +37 -2
  145. package/src/asset-materials-node.ts +38 -45
  146. package/src/assets-node.ts +22 -5
  147. package/src/assets.ts +61 -20
  148. package/src/atlas-provenance.ts +522 -0
  149. package/src/character.ts +107 -2
  150. package/src/community-exporter.ts +143 -3
  151. package/src/discovery/helper-contracts.ts +188 -7
  152. package/src/discovery/helper-specs.ts +69 -4
  153. package/src/discovery/shapes.ts +25 -9
  154. package/src/engine-identity.ts +1 -1
  155. package/src/evaluator/authoring-diagnostic.ts +19 -1
  156. package/src/evaluator/protocol.ts +19 -9
  157. package/src/evaluator/subprocess.ts +2 -2
  158. package/src/exporter-node.ts +12 -1
  159. package/src/generated/mcp-manifest.json +3 -3
  160. package/src/geometry-budget.ts +8 -0
  161. package/src/gltf-texture-binding.ts +60 -0
  162. package/src/material-authoring-node.ts +68 -0
  163. package/src/material-authoring-schema.ts +343 -0
  164. package/src/material-export.ts +25 -0
  165. package/src/material-library-node.ts +840 -16
  166. package/src/material-library.ts +348 -5
  167. package/src/pinned-material-map.ts +43 -0
  168. package/src/portable-material-runtime.ts +200 -0
  169. package/src/portable-sampler-runtime.ts +40 -0
  170. package/src/portable-sampler.ts +54 -0
  171. package/src/primitives.ts +98 -4
  172. package/src/procedural-material-v2.ts +2 -1
  173. package/src/procedural-material-v3.ts +224 -0
  174. package/src/procedural-texture-v2-compiler.ts +234 -0
  175. package/src/procedural-texture-v3-contract.ts +437 -0
  176. package/src/procedural-texture-v3.ts +220 -0
  177. package/src/procedural-texture.ts +61 -229
  178. package/src/project-bundle-node.ts +106 -14
  179. package/src/project-bundle.ts +61 -18
  180. package/src/project-cli.ts +29 -17
  181. package/src/qa/atlas.ts +597 -0
  182. package/src/qa/index.ts +1 -0
  183. package/src/qa/material.ts +2 -0
  184. package/src/qa/skin-integrity.ts +291 -0
  185. package/src/render-service-host.ts +4 -6
  186. package/src/render.ts +133 -15
  187. package/src/skin-binding.ts +225 -0
  188. package/src/texture-atlas-contract.ts +558 -0
  189. package/src/texture-atlas-pixels.ts +456 -0
  190. package/src/texture-atlas.ts +185 -0
  191. package/src/texture-bake.ts +88 -10
  192. package/src/texture-resolver.ts +55 -0
  193. package/src/tools/materials.ts +15 -11
  194. package/src/tools/registry.ts +5 -15
  195. package/src/tools/review.ts +1 -1
  196. package/src/uv-data.ts +238 -0
  197. package/src/uv-shapes.ts +3 -0
  198. package/src/uv.ts +73 -128
  199. package/src/viewer/app.ts +37 -0
  200. package/src/viewer/live-panel.ts +57 -1
  201. package/src/viewer/material-preview-panel.ts +140 -0
  202. package/src/viewer/material-preview.ts +138 -0
  203. package/src/viewer/materials-panel.ts +34 -5
  204. package/src/viewer/navigation.ts +2 -0
  205. package/src/views/camera.ts +37 -40
  206. package/src/views/face-policy.ts +20 -0
  207. package/src/views/glb.ts +294 -17
  208. package/src/views/pose.ts +71 -13
  209. package/src/views/raster.ts +73 -46
  210. package/src/views/scene-copy.ts +86 -0
  211. package/src/views/scene-raster.ts +2 -1
  212. package/src/views/triangle-coverage.ts +78 -0
  213. package/src/workspace-node.ts +11 -3
  214. package/src/workspace.ts +2 -2
@@ -1,12 +1,14 @@
1
1
  /// <reference lib="dom" />
2
- import type { MaterialManifestV1 } from '../material-library';
2
+ import type { MaterialManifest, MaterialManifestV1 } from '../material-library';
3
+ import { materialApiBase, materialViewerSelection } from './material-preview';
4
+ import { materialPreviewPanel } from './material-preview-panel';
3
5
  import type { MaterialPresetSummary } from '../material-presets';
4
6
  import type { ProjectRevision } from '../projects';
5
7
  import { el, node, request, notice, empty, recordDetails } from './dom';
6
8
  import { materialPresetRequest } from './material-preset-form';
7
9
 
8
10
  export function mountMaterials(updated: () => Promise<void>) {
9
- let materials: MaterialManifestV1[] = [];
11
+ let materials: MaterialManifest[] = [];
10
12
  let projects: ProjectRevision[] = [];
11
13
  let presets: MaterialPresetSummary[] = [];
12
14
  const projectSelect = el<HTMLSelectElement>('materials-project');
@@ -121,8 +123,7 @@ export function mountMaterials(updated: () => Promise<void>) {
121
123
  submit.disabled = false;
122
124
  }
123
125
  };
124
- const base = (material: MaterialManifestV1) =>
125
- `/api/materials/${encodeURIComponent(material.materialId)}/${encodeURIComponent(material.revisionId)}`;
126
+ const base = materialApiBase;
126
127
  function render() {
127
128
  const project = projects.find((p) => p.projectId === projectSelect.value);
128
129
  const dependencies = el('material-dependencies');
@@ -268,6 +269,24 @@ export function mountMaterials(updated: () => Promise<void>) {
268
269
  maps.append(link);
269
270
  }
270
271
  card.append(maps);
272
+ const inspection = node('details', undefined, 'material-map-inspection');
273
+ inspection.append(node('summary', 'Inspect maps and atlas regions'));
274
+ let inspected = false;
275
+ inspection.ontoggle = () => {
276
+ if (inspection.open && !inspected) {
277
+ inspected = true;
278
+ inspection.append(materialPreviewPanel(material));
279
+ }
280
+ };
281
+ const selected = materialViewerSelection(location.search);
282
+ if (
283
+ selected?.materialId === material.materialId &&
284
+ selected.revisionId === material.revisionId
285
+ ) {
286
+ inspection.open = true;
287
+ card.dataset.selectedMaterial = 'true';
288
+ }
289
+ card.append(inspection);
271
290
  const locked = project?.materialDependencies.find(
272
291
  (d) => d.resourceId === material.materialId,
273
292
  );
@@ -314,8 +333,18 @@ export function mountMaterials(updated: () => Promise<void>) {
314
333
  }
315
334
  }
316
335
  const refresh = async () => {
317
- materials = (await request<{ materials: MaterialManifestV1[] }>('/api/materials')).materials;
336
+ materials = (await request<{ materials: MaterialManifest[] }>('/api/materials')).materials;
318
337
  render();
338
+ const selected = materialViewerSelection(location.search);
339
+ if (
340
+ selected &&
341
+ !materials.some(
342
+ (m) => m.materialId === selected.materialId && m.revisionId === selected.revisionId,
343
+ )
344
+ )
345
+ notice(
346
+ `Exact material revision unavailable: ${selected.materialId} at ${selected.revisionId}. No newer revision was selected.`,
347
+ );
319
348
  };
320
349
  el('material-search').oninput = render;
321
350
  projectSelect.onchange = render;
@@ -1,8 +1,10 @@
1
+ import { materialViewerSelection } from './material-preview';
1
2
  export const workspaceSections = ['projects', 'live', 'library', 'materials'] as const;
2
3
 
3
4
  export function initialWorkspaceSection(search: string, remembered: string | null | undefined) {
4
5
  const params = new URLSearchParams(search);
5
6
  if (params.has('asset') || params.has('open')) return 'library';
7
+ if (materialViewerSelection(search)) return 'materials';
6
8
  return remembered && workspaceSections.some((section) => section === remembered)
7
9
  ? remembered
8
10
  : 'library';
@@ -1,6 +1,9 @@
1
1
  import { Vector3, Matrix4, Euler, Group, type Object3D } from 'three';
2
2
  import { collectTriangles, compositingOrder, measureBounds, orbitDir } from './raster';
3
3
  import { resolveBackdrop, type BackdropId } from './background';
4
+ import { cloneReviewScene } from './scene-copy';
5
+ import { visibleNormalSign } from './face-policy';
6
+ import { prepareTriangleCoverage } from './triangle-coverage';
4
7
 
5
8
  export type CameraVec3 = [number, number, number];
6
9
  export type CameraBounds = { min: CameraVec3; max: CameraVec3 };
@@ -409,7 +412,11 @@ export function resolveAssetCamera(root: unknown, shot: CameraShotV1 = {}): Reso
409
412
  measureBounds(root),
410
413
  );
411
414
  if (request.projection === 'orthographic')
412
- camera = { ...fit, near: request.near ?? fit.near, far: request.far ?? fit.far };
415
+ camera = {
416
+ ...fit,
417
+ near: request.near ?? fit.near,
418
+ far: request.far ?? fit.far,
419
+ };
413
420
  else {
414
421
  const fov = finite(request.fovDeg ?? 50, 'fovDeg');
415
422
  if (fov <= 0 || fov >= 180) throw new Error('invalid fovDeg');
@@ -487,9 +494,9 @@ export async function withCameraVisibility<T>(
487
494
  * A derivative GLB
488
495
  * serialized from a scene with hidden nodes (authored, or isolated by
489
496
  * {@link withCameraVisibility}) would ship them to the GPU. Every node with
490
- * `visible = false` leaves with its subtree, which is what three draws. Returns the root
491
- * itself when nothing is hidden, else a pruned clone; the caller's scene is never mutated and
492
- * restores `.visible` itself.
497
+ * `visible = false` removes drawing from its subtree. Required skeleton ancestors remain
498
+ * as transforms without drawing payloads. Returns the root itself when nothing is hidden,
499
+ * else an independent pruned hierarchy/skeleton; the caller restores `.visible` itself.
493
500
  */
494
501
  export function withoutHiddenMeshes(root: Object3D): Object3D {
495
502
  if (root.visible === false) {
@@ -504,13 +511,7 @@ export function withoutHiddenMeshes(root: Object3D): Object3D {
504
511
  if (hidden(node)) any = true;
505
512
  });
506
513
  if (!any) return root;
507
- const copy = root.clone(true);
508
- const drop: Object3D[] = [];
509
- copy.traverse((node) => {
510
- if (hidden(node)) drop.push(node);
511
- });
512
- for (const node of drop) node.removeFromParent();
513
- return copy;
514
+ return cloneReviewScene(root, { omitHidden: true });
514
515
  }
515
516
  /** Flat geometry renderer with exact orthographic/perspective projection and near/far clipping. */
516
517
  export function rasterizeCamera(
@@ -531,6 +532,7 @@ export function rasterizeCamera(
531
532
  const out = new Uint8Array(size * size * 3);
532
533
  for (let i = 0; i < size * size; i++) out.set(bg, i * 3);
533
534
  const depth = new Float64Array(size * size).fill(Infinity);
535
+ const weights = new Float64Array(3);
534
536
  const clip = (points: Vector3[], plane: number, near: boolean) => {
535
537
  const result: Vector3[] = [];
536
538
  for (let i = 0; i < points.length; i++) {
@@ -539,7 +541,14 @@ export function rasterizeCamera(
539
541
  const insideA = near ? a.z >= plane : a.z <= plane,
540
542
  insideB = near ? b.z >= plane : b.z <= plane;
541
543
  if (insideA) result.push(a);
542
- if (insideA !== insideB) result.push(a.clone().lerp(b, (plane - a.z) / (b.z - a.z)));
544
+ if (insideA !== insideB) {
545
+ // Shared intersections must round identically for either edge direction.
546
+ const low = a.z < b.z ? a : b,
547
+ high = a.z < b.z ? b : a;
548
+ const intersection = low.clone().lerp(high, (plane - low.z) / (high.z - low.z));
549
+ intersection.z = plane;
550
+ result.push(intersection);
551
+ }
543
552
  }
544
553
  return result;
545
554
  };
@@ -558,15 +567,16 @@ export function rasterizeCamera(
558
567
  .clone()
559
568
  .sub(world[0]!)
560
569
  .cross(world[2]!.clone().sub(world[0]!))
561
- .normalize();
562
- if (
563
- backfaceCull &&
564
- !tri.doubleSided &&
565
- normal.dot(camera.projection === 'perspective' ? position.clone().sub(world[0]!) : z) <= 0
566
- )
567
- continue;
570
+ .normalize()
571
+ .multiplyScalar(tri.windingParity);
572
+ const normalSign = visibleNormalSign(
573
+ normal.dot(camera.projection === 'perspective' ? position.clone().sub(world[0]!) : z),
574
+ tri.side,
575
+ backfaceCull,
576
+ );
577
+ if (normalSign === 0) continue;
568
578
  if (tri.alpha <= 0) continue;
569
- const light = Math.min(1, 0.25 + 1.1 * Math.max(0, normal.dot(key)));
579
+ const light = Math.min(1, 0.25 + 1.1 * Math.max(0, normal.dot(key) * normalSign));
570
580
  const color = tri.color.map((c) => Math.round(srgb(c * light) * 255));
571
581
  let polygon = world.map((p) => {
572
582
  const d = p.clone().sub(position);
@@ -586,28 +596,15 @@ export function rasterizeCamera(
586
596
  ];
587
597
  });
588
598
  const [a, b, c] = screen as [number[], number[], number[]];
589
- const area = (b[0]! - a[0]!) * (c[1]! - a[1]!) - (b[1]! - a[1]!) * (c[0]! - a[0]!);
590
- if (Math.abs(area) < 1e-9) continue;
591
- for (
592
- let py = Math.max(0, Math.floor(Math.min(...screen.map((s) => s[1]!))));
593
- py <= Math.min(size - 1, Math.ceil(Math.max(...screen.map((s) => s[1]!))));
594
- py++
595
- )
596
- for (
597
- let px = Math.max(0, Math.floor(Math.min(...screen.map((s) => s[0]!))));
598
- px <= Math.min(size - 1, Math.ceil(Math.max(...screen.map((s) => s[0]!))));
599
- px++
600
- ) {
601
- const w0 =
602
- ((b[0]! - a[0]!) * (py + 0.5 - a[1]!) - (b[1]! - a[1]!) * (px + 0.5 - a[0]!)) / area,
603
- w1 =
604
- ((c[0]! - b[0]!) * (py + 0.5 - b[1]!) - (c[1]! - b[1]!) * (px + 0.5 - b[0]!)) / area,
605
- w2 = 1 - w0 - w1;
606
- if (w0 < 0 || w1 < 0 || w2 < 0) continue;
599
+ const triangle = prepareTriangleCoverage(a[0]!, a[1]!, b[0]!, b[1]!, c[0]!, c[1]!, size);
600
+ if (!triangle) continue;
601
+ for (let py = triangle.minY; py <= triangle.maxY; py++)
602
+ for (let px = triangle.minX; px <= triangle.maxX; px++) {
603
+ if (!triangle.sample(px + 0.5, py + 0.5, weights)) continue;
607
604
  const d =
608
605
  camera.projection === 'perspective'
609
- ? 1 / (w1 / p[0]!.z + w2 / p[1]!.z + w0 / p[2]!.z)
610
- : w1 * p[0]!.z + w2 * p[1]!.z + w0 * p[2]!.z;
606
+ ? 1 / (weights[0]! / p[0]!.z + weights[1]! / p[1]!.z + weights[2]! / p[2]!.z)
607
+ : weights[0]! * p[0]!.z + weights[1]! * p[1]!.z + weights[2]! * p[2]!.z;
611
608
  const index = py * size + px;
612
609
  if (d >= depth[index]!) continue;
613
610
  depth[index] = d;
@@ -0,0 +1,20 @@
1
+ /** Standard material sides, kept numeric for cross-realm and flat GLB inputs. */
2
+ export type SurfaceSide = 0 | 1 | 2;
3
+
4
+ /** Match Three's affine front-face convention; a singular transform does not flip.
5
+ * Collapsed triangles are discarded by rasterization. No inverse is needed. */
6
+ export function worldWindingParity(m: ArrayLike<number>): 1 | -1 {
7
+ const determinant =
8
+ m[0]! * (m[5]! * m[10]! - m[9]! * m[6]!) -
9
+ m[4]! * (m[1]! * m[10]! - m[9]! * m[2]!) +
10
+ m[8]! * (m[1]! * m[6]! - m[5]! * m[2]!);
11
+ return determinant < 0 ? -1 : 1;
12
+ }
13
+
14
+ /** Facing uses deformed triangle winding corrected for the mesh world transform.
15
+ * Zero means culled; +/-1 orients the visible flat lighting normal. Disabling
16
+ * culling is a diagnostic two-sided override, matching GPU review derivatives. */
17
+ export function visibleNormalSign(facing: number, side: SurfaceSide, cull: boolean): -1 | 0 | 1 {
18
+ if (cull && ((side === 0 && facing <= 0) || (side === 1 && facing >= 0))) return 0;
19
+ return facing < 0 ? -1 : 1;
20
+ }