@call-me-sensei/toonlab 0.4.19 → 0.4.21

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 (123) hide show
  1. package/NPM-LIBRARY.md +230 -0
  2. package/README.md +78 -36
  3. package/agents/PROMPTS.md +91 -0
  4. package/agents/README.md +9 -14
  5. package/agents/claude/CLAUDE.md +8 -0
  6. package/agents/codex/AGENTS.md +161 -0
  7. package/agents/cursor/toonlab.mdc +37 -0
  8. package/agents/references/mcp-asset-discovery.md +12 -9
  9. package/agents/references/runtime-entry-points.md +5 -4
  10. package/agents/skills/claude/asset-sourcing/SKILL.md +9 -6
  11. package/agents/skills/claude/rock-ground-shaders/SKILL.md +16 -0
  12. package/agents/skills/claude/vegetation-sky/SKILL.md +7 -4
  13. package/agents/skills/codex/asset-sourcing/SKILL.md +9 -6
  14. package/agents/skills/codex/rock-ground-shaders/SKILL.md +16 -0
  15. package/agents/skills/codex/vegetation-sky/SKILL.md +7 -4
  16. package/cli/toonlab.mjs +3 -2
  17. package/database/fal-patina.mjs +131 -0
  18. package/database/generation-service.mjs +108 -7
  19. package/database/migrations/0007_creation_revisions.sql +80 -0
  20. package/database/migrations/0008_creation_revision_hardening.sql +98 -0
  21. package/database/migrations/0009_creation_revision_delete_integrity.sql +11 -0
  22. package/database/migrations/0010_external_catalog_assets.sql +28 -0
  23. package/database/providers.mjs +195 -16
  24. package/database/repository.mjs +518 -70
  25. package/database/seeds/catalog/0003_2026-08-open-assets.sql +159086 -0
  26. package/database/seeds/catalog/0004_2026-08-c7-rocks.sql +39405 -0
  27. package/mcp/public-catalog.mjs +4 -52
  28. package/mcp/server.mjs +175 -12
  29. package/mcp/vite-plugin.mjs +78 -2
  30. package/package.json +32 -12
  31. package/scripts/generate-catalog-seed.mjs +34 -7
  32. package/src/asset-policy/catalogLicenses.js +64 -6
  33. package/src/assetlib/loadImported.js +15 -1
  34. package/src/catalog/officialCatalogAssetRuntime.js +13 -2
  35. package/src/catalog/officialCatalogLod.js +34 -1
  36. package/src/catalog/officialCatalogPlacement.js +29 -14
  37. package/src/character/animationRetarget.js +2 -0
  38. package/src/character/characterRig.js +6 -1
  39. package/src/character/characterRuntime.js +175 -7
  40. package/src/environment/environmentMaterialAdapter.js +30 -0
  41. package/src/environment/environmentPresets.js +90 -0
  42. package/src/environment/environmentSunShadowPass.js +42 -0
  43. package/src/environment/toonLabSurfaceLighting.js +108 -10
  44. package/src/environment/urbanPropMaterial.js +104 -2
  45. package/src/lighting/lightingSystem.js +65 -10
  46. package/src/renderer/index.js +7 -0
  47. package/src/renderer/styleComparison.js +985 -0
  48. package/src/rock-shader/index.js +5 -0
  49. package/src/rock-shader/rockGeometryDetail.js +701 -0
  50. package/src/rock-shader/rockMaterial.js +782 -39
  51. package/src/rock-shader/rockRegionRuntime.js +185 -0
  52. package/src/rock-shader/rockSemanticMaterialRuntime.js +337 -0
  53. package/src/rock-shader/rockShaderRuntime.js +354 -67
  54. package/src/rock-shader/rockShaderSettings.js +246 -16
  55. package/src/rock-shader/rockTangentIntegrity.js +136 -0
  56. package/src/rock-shader/rockTextureIntegrity.js +115 -0
  57. package/src/rockgen/index.js +1 -0
  58. package/src/rockgen/rockDocument.js +27 -3
  59. package/src/rockgen/surface/c7GeologySurface.js +372 -0
  60. package/src/shaders-tsl/water.js +8 -0
  61. package/src/sky/cloudShadow.js +25 -1
  62. package/src/sky/skySystem.js +9 -0
  63. package/src/styles/index.js +23 -0
  64. package/src/styles/neutralStylePresets.js +712 -0
  65. package/src/styles/sceneStyleRuntime.js +51 -4
  66. package/src/styles/styleAdapters.js +13 -2
  67. package/src/styles/styleBundle.js +1 -1
  68. package/src/texgen/evaluateTexture.js +30 -3
  69. package/src/texgen/textureGenerators.js +63 -31
  70. package/src/texgen/textureSettings.js +25 -8
  71. package/src/toon/toonSettings.js +96 -2
  72. package/src/vegetation/branchTree.js +327 -42
  73. package/src/vegetation/index.js +24 -2
  74. package/src/vegetation/scatter.js +372 -1
  75. package/src/vegetation/stylizedTree.js +312 -126
  76. package/src/vegetation/stylizedTreeFoliage.js +229 -7
  77. package/src/vegetation/treeSurfaceTextures.js +328 -0
  78. package/src/version.js +1 -1
  79. package/src/water/waterSettings.js +15 -7
  80. package/types/asset-policy/catalogLicenses.d.ts +2 -0
  81. package/types/asset-policy/index.d.ts +2 -0
  82. package/types/assetlib/loadImported.d.ts +3 -1
  83. package/types/catalog/officialCatalogLod.d.ts +63 -5
  84. package/types/character/characterRig.d.ts +59 -0
  85. package/types/character/characterRuntime.d.ts +1 -0
  86. package/types/environment/environmentMaterialAdapter.d.ts +3 -1
  87. package/types/environment/toonLabSurfaceLighting.d.ts +20 -3
  88. package/types/index.d.ts +89 -7
  89. package/types/lighting/lightingSystem.d.ts +7 -0
  90. package/types/react/index.d.ts +20 -0
  91. package/types/renderer/index.d.ts +1 -0
  92. package/types/renderer/styleComparison.d.ts +409 -0
  93. package/types/rock-shader/index.d.ts +5 -0
  94. package/types/rock-shader/rockGeometryDetail.d.ts +300 -0
  95. package/types/rock-shader/rockMaterial.d.ts +3 -0
  96. package/types/rock-shader/rockRegionRuntime.d.ts +86 -0
  97. package/types/rock-shader/rockSemanticMaterialRuntime.d.ts +29 -0
  98. package/types/rock-shader/rockShaderRuntime.d.ts +2 -0
  99. package/types/rock-shader/rockShaderSettings.d.ts +19 -5
  100. package/types/rock-shader/rockTangentIntegrity.d.ts +51 -0
  101. package/types/rock-shader/rockTextureIntegrity.d.ts +61 -0
  102. package/types/rockgen/index.d.ts +1 -0
  103. package/types/rockgen/rockDocument.d.ts +29 -1
  104. package/types/rockgen/surface/c7GeologySurface.d.ts +8 -0
  105. package/types/sky/cloudShadow.d.ts +1 -0
  106. package/types/styles/index.d.ts +1 -0
  107. package/types/styles/neutralStylePresets.d.ts +944 -0
  108. package/types/styles/sceneStyleRuntime.d.ts +20 -0
  109. package/types/vegetation/branchTree.d.ts +4 -0
  110. package/types/vegetation/index.d.ts +9 -7
  111. package/types/vegetation/scatter.d.ts +1 -0
  112. package/types/vegetation/stylizedTree.d.ts +1 -2
  113. package/types/vegetation/stylizedTreeFoliage.d.ts +1 -0
  114. package/types/version.d.ts +1 -1
  115. package/agents/references/geology-playbook.md +0 -118
  116. package/agents/skills/claude/karst-cliff-construction/SKILL.md +0 -258
  117. package/agents/skills/claude/outdoor-world/SKILL.md +0 -492
  118. package/agents/skills/claude/outdoor-world/references/coastal-landform-formation.md +0 -100
  119. package/agents/skills/codex/karst-cliff-construction/SKILL.md +0 -258
  120. package/agents/skills/codex/outdoor-world/SKILL.md +0 -492
  121. package/agents/skills/codex/outdoor-world/references/coastal-landform-formation.md +0 -100
  122. package/src/vegetation/legacyTreePresets.js +0 -120
  123. package/types/vegetation/legacyTreePresets.d.ts +0 -22
@@ -0,0 +1,985 @@
1
+ // Style comparison — a single-load, dual-material A/B render path.
2
+ //
3
+ // A neutral-versus-styled wipe is a flagship ToonLab demonstration, and the
4
+ // only honest way to render one is to draw the SAME geometry buffers twice in
5
+ // the same frame with a different material treatment. Loading the subject
6
+ // twice (two GLB loads, two skeletons, two AnimationMixers, two scenes) cannot
7
+ // guarantee identical animation time or identical geometry, so the claim
8
+ // "only the shading differs" is not provable — it is merely plausible.
9
+ //
10
+ // This module makes the A/B a renderer feature instead of something every
11
+ // consumer re-implements incorrectly:
12
+ //
13
+ // const ab = createStyleComparison({ renderer, scene, camera });
14
+ // ab.track(character.carrier);
15
+ // ab.capture('neutral'); // before any style is applied
16
+ // applyToonShader(character.modelRoot, { preset: 'call_me_sensei' });
17
+ // ab.capture('toonlab'); // after
18
+ // ab.setVariants({ after: 'toonlab', before: 'neutral' });
19
+ // // per frame, INSTEAD of renderer.render(scene, camera):
20
+ // ab.render();
21
+ //
22
+ // One load, one skeleton, one animation clock, one camera, one light rig, one
23
+ // exposure. The two halves differ by material assignment and nothing else,
24
+ // because nothing else is touched between the two `renderer.render` calls.
25
+ //
26
+ // Why not the style transaction: `setTargetEnabled(id, false)` performs a full
27
+ // restoreMaterial traverse plus outline/fur child teardown and rebuild
28
+ // (src/styles/styleTransaction.js). That is the correct ONE-TIME primitive for
29
+ // building a variant, and far too expensive to run twice per frame at 60 fps.
30
+ // `capture()` is that one-time cost; `activate()` is a Map walk that assigns
31
+ // material references.
32
+ //
33
+ // Renderer support: any three.js renderer exposing setScissor/setScissorTest
34
+ // (WebGPURenderer on either backend, WebGLRenderer). The pixel-identity proof
35
+ // additionally needs `readRenderTargetPixelsAsync`.
36
+
37
+ import * as THREE from 'three';
38
+
39
+ /** Split axes. `vertical` is a vertical divider; `horizontal` is a horizontal one. */
40
+ export const STYLE_COMPARISON_AXES = Object.freeze(['horizontal', 'vertical']);
41
+
42
+ /**
43
+ * The renderer surface this module needs. Declared structurally rather than as
44
+ * `WebGPURenderer`, because that class lives in the `three/webgpu` entry point
45
+ * and is not exported from `three` — naming it here would make the generated
46
+ * declaration unresolvable and silently demote this module to a permissive
47
+ * `any` contract. WebGPURenderer (either backend) and WebGLRenderer both
48
+ * satisfy it.
49
+ *
50
+ * @typedef {object} StyleComparisonRenderer
51
+ * @property {(scene: THREE.Object3D, camera: THREE.Camera) => unknown} render
52
+ * @property {(x: number, y: number, width: number, height: number) => void} setScissor
53
+ * @property {(enabled: boolean) => void} setScissorTest
54
+ * @property {(target: THREE.Vector4) => THREE.Vector4} getScissor
55
+ * @property {() => boolean} getScissorTest
56
+ * @property {(target: THREE.RenderTarget|null) => void} setRenderTarget
57
+ * @property {() => THREE.RenderTarget|null} getRenderTarget
58
+ * @property {(target: THREE.Vector2|THREE.Vector4) => unknown} getSize
59
+ * @property {(color: THREE.Color) => THREE.Color} getClearColor
60
+ * @property {boolean} autoClear
61
+ * @property {boolean} autoClearColor
62
+ * @property {boolean} autoClearDepth
63
+ * @property {boolean} autoClearStencil
64
+ * @property {number} toneMappingExposure
65
+ *
66
+ * @typedef {'horizontal'|'vertical'} StyleComparisonAxis
67
+ *
68
+ * @typedef {THREE.RenderTarget|null} StyleComparisonTarget
69
+ *
70
+ * @typedef {object} StyleComparisonRect
71
+ * @property {number} height
72
+ * @property {number} width
73
+ * @property {number} x
74
+ * @property {number} y
75
+ *
76
+ * @typedef {object} StyleComparisonSize
77
+ * @property {number} height
78
+ * @property {number} width
79
+ *
80
+ * @typedef {object} StyleComparisonVariantPair
81
+ * @property {string} after Variant rendered full-frame.
82
+ * @property {string} before Variant rendered into the scissored region.
83
+ *
84
+ * @typedef {object} StyleComparisonRenderOptions
85
+ * @property {number} [split] Fraction of the frame showing `before`, 0..1.
86
+ * Defaults to the comparison's current split.
87
+ * @property {StyleComparisonTarget} [target] Render into this target instead of
88
+ * the canvas. Omit for the canvas.
89
+ *
90
+ * @typedef {object} StyleComparisonVariantRenderOptions
91
+ * @property {StyleComparisonTarget} [target]
92
+ *
93
+ * @typedef {object} StyleComparisonRenderResult
94
+ * @property {number} fraction
95
+ * @property {StyleComparisonSize} size
96
+ */
97
+
98
+ /**
99
+ * Child meshes a style treatment generates on top of the subject. They exist
100
+ * only in the styled variant, so a variant captured before the style was
101
+ * applied does not know them and must hide — never remove — them.
102
+ */
103
+ export const STYLE_COMPARISON_GENERATED_NODE_FLAGS = Object.freeze([
104
+ 'isToonOutline',
105
+ 'isToonFurShell',
106
+ ]);
107
+
108
+ function isRenderable(node) {
109
+ return Boolean(node?.isMesh || node?.isSkinnedMesh || node?.isPoints
110
+ || node?.isLine || node?.isSprite || node?.isInstancedMesh);
111
+ }
112
+
113
+ function materialList(material) {
114
+ if (!material) return [];
115
+ return Array.isArray(material) ? material : [material];
116
+ }
117
+
118
+ function clamp01(value) {
119
+ const number = Number(value);
120
+ if (!Number.isFinite(number)) return 0;
121
+ return Math.min(1, Math.max(0, number));
122
+ }
123
+
124
+ function captureNodeState(node) {
125
+ return {
126
+ castShadow: node.castShadow,
127
+ // Geometry, skeleton and morph influences are captured for the identity
128
+ // audit and are NEVER re-applied. A variant that changed them would not be
129
+ // a material treatment.
130
+ geometry: node.geometry ?? null,
131
+ material: Array.isArray(node.material) ? [...node.material] : (node.material ?? null),
132
+ morphTargetInfluences: node.morphTargetInfluences ?? null,
133
+ receiveShadow: node.receiveShadow,
134
+ renderOrder: node.renderOrder,
135
+ skeleton: node.skeleton ?? null,
136
+ visible: node.visible,
137
+ };
138
+ }
139
+
140
+ function applyNodeState(node, state) {
141
+ node.material = Array.isArray(state.material) ? [...state.material] : state.material;
142
+ node.castShadow = state.castShadow;
143
+ node.receiveShadow = state.receiveShadow;
144
+ node.renderOrder = state.renderOrder;
145
+ node.visible = state.visible;
146
+ }
147
+
148
+ function matrixSignature(matrix) {
149
+ return matrix ? matrix.elements.map((value) => value.toFixed(9)).join(',') : '';
150
+ }
151
+
152
+ function collectLightSignature(scene) {
153
+ const lights = [];
154
+ scene.traverse((node) => {
155
+ if (!node?.isLight) return;
156
+ node.updateWorldMatrix(true, false);
157
+ lights.push({
158
+ color: node.color ? node.color.getHex() : null,
159
+ intensity: Number(node.intensity ?? 0),
160
+ matrixWorld: matrixSignature(node.matrixWorld),
161
+ name: node.name || node.type,
162
+ shadowBias: node.shadow ? Number(node.shadow.bias ?? 0) : null,
163
+ type: node.type,
164
+ uuid: node.uuid,
165
+ });
166
+ });
167
+ lights.sort((a, b) => (a.uuid < b.uuid ? -1 : 1));
168
+ return lights;
169
+ }
170
+
171
+ function collectMixerSignature(roots) {
172
+ const mixers = [];
173
+ const seen = new Set();
174
+ for (const root of roots) {
175
+ root.traverse((node) => {
176
+ const mixer = node?.userData?.toonlabAnimationMixer;
177
+ if (mixer && !seen.has(mixer)) {
178
+ seen.add(mixer);
179
+ mixers.push({ time: Number(mixer.time ?? 0) });
180
+ }
181
+ });
182
+ }
183
+ return mixers;
184
+ }
185
+
186
+ /**
187
+ * Snapshot of everything the §11 contract requires to be equal across both
188
+ * halves: camera matrices, light transforms, exposure and render state, plus
189
+ * the animation clock of any registered mixer. Compared before and after a
190
+ * composite render — if the wipe moved any of them, the A/B is not honest.
191
+ */
192
+ export function captureComparisonFrameState({ camera, mixers = [], renderer, scene }) {
193
+ camera.updateMatrixWorld();
194
+ return {
195
+ camera: {
196
+ far: Number(camera.far ?? 0),
197
+ fov: Number(camera.fov ?? 0),
198
+ matrixWorld: matrixSignature(camera.matrixWorld),
199
+ matrixWorldInverse: matrixSignature(camera.matrixWorldInverse),
200
+ near: Number(camera.near ?? 0),
201
+ projectionMatrix: matrixSignature(camera.projectionMatrix),
202
+ zoom: Number(camera.zoom ?? 1),
203
+ },
204
+ lights: collectLightSignature(scene),
205
+ mixers: mixers.map((mixer) => ({ time: Number(mixer?.time ?? 0) })),
206
+ renderState: {
207
+ outputColorSpace: renderer.outputColorSpace,
208
+ shadowMapEnabled: Boolean(renderer.shadowMap?.enabled),
209
+ shadowMapType: renderer.shadowMap?.type ?? null,
210
+ toneMapping: renderer.toneMapping,
211
+ toneMappingExposure: Number(renderer.toneMappingExposure ?? 1),
212
+ },
213
+ scene: {
214
+ backgroundIntensity: Number(scene.backgroundIntensity ?? 1),
215
+ environmentIntensity: Number(scene.environmentIntensity ?? 1),
216
+ },
217
+ };
218
+ }
219
+
220
+ function diffFrameState(before, after, path = '', issues = []) {
221
+ if (before === after) return issues;
222
+ if (Array.isArray(before) || Array.isArray(after)) {
223
+ const a = Array.isArray(before) ? before : [];
224
+ const b = Array.isArray(after) ? after : [];
225
+ if (a.length !== b.length) issues.push(`${path}.length ${a.length} → ${b.length}`);
226
+ for (let index = 0; index < Math.min(a.length, b.length); index += 1) {
227
+ diffFrameState(a[index], b[index], `${path}[${index}]`, issues);
228
+ }
229
+ return issues;
230
+ }
231
+ if (before && after && typeof before === 'object' && typeof after === 'object') {
232
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
233
+ diffFrameState(before[key], after[key], path ? `${path}.${key}` : key, issues);
234
+ }
235
+ return issues;
236
+ }
237
+ if (before !== after) issues.push(`${path}: ${String(before)} → ${String(after)}`);
238
+ return issues;
239
+ }
240
+
241
+ /**
242
+ * Creates a single-load A/B comparison over one scene and one camera.
243
+ *
244
+ * @param {object} options
245
+ * @param {StyleComparisonRenderer} options.renderer
246
+ * @param {THREE.Scene} options.scene
247
+ * @param {THREE.Camera} options.camera
248
+ * @param {'vertical'|'horizontal'} [options.axis='vertical'] Divider orientation.
249
+ * `vertical` puts the `before` variant on the left; `horizontal` puts it on
250
+ * the bottom.
251
+ * @param {number} [options.split=0.5] Fraction of the frame showing `before`.
252
+ * @param {{after: string, before: string}} [options.variants]
253
+ * @param {boolean} [options.refreshRenderPasses=true] Call each tracked root's
254
+ * `userData.toonlabCharacterStyleIntegration.refresh()` after a swap so the
255
+ * depth prepass / self-shadow target binds the live materials.
256
+ */
257
+ export function createStyleComparison({
258
+ axis = 'vertical',
259
+ camera,
260
+ refreshRenderPasses = true,
261
+ renderer,
262
+ scene,
263
+ split = 0.5,
264
+ variants = { after: 'toonlab', before: 'neutral' },
265
+ } = {}) {
266
+ if (!renderer || typeof renderer.render !== 'function') {
267
+ throw new TypeError('createStyleComparison requires a three.js renderer.');
268
+ }
269
+ if (!scene?.isScene) throw new TypeError('createStyleComparison requires a THREE.Scene.');
270
+ if (!camera?.isCamera) throw new TypeError('createStyleComparison requires a THREE.Camera.');
271
+ if (!STYLE_COMPARISON_AXES.includes(axis)) {
272
+ throw new TypeError(`Unknown style comparison axis "${axis}".`);
273
+ }
274
+
275
+ /** @type {Set<THREE.Object3D>} */
276
+ const roots = new Set();
277
+ const variantRecords = new Map();
278
+ const stateTrackers = new Map();
279
+ /** Union of every renderable node seen by any capture. */
280
+ const knownNodes = new Set();
281
+ const mixers = new Set();
282
+
283
+ let currentAxis = axis;
284
+ let currentSplit = clamp01(split);
285
+ let activeVariantId = null;
286
+ let variantPair = { after: variants?.after ?? 'toonlab', before: variants?.before ?? 'neutral' };
287
+ let disposed = false;
288
+ /**
289
+ * §11 requires both halves to share exposure exactly. This module never
290
+ * touches `toneMappingExposure`, but a lighting system that rewrites it can
291
+ * (ToonLab's does — see D19-043), and a mid-frame rewrite would make one half
292
+ * brighter than the other with nothing in the image to explain it. Cheap to
293
+ * check, impossible to spot by eye, so it is checked every frame and reported
294
+ * rather than assumed.
295
+ */
296
+ let exposureDrift = null;
297
+ let exposureDriftWarned = false;
298
+
299
+ const scratchVector = new THREE.Vector4();
300
+ const scratchColor = new THREE.Color();
301
+
302
+ // THE COMPOSITE — variant A full-frame, variant B scissored over it.
303
+ //
304
+ // Three separate renderer defects had to be fixed to make this exact; each
305
+ // produced a plausible image and failed only under a pixel diff, which is
306
+ // why §11 asks for a proof and not a screenshot. See the deficiency log
307
+ // (D19-001) for the measurements.
308
+ //
309
+ // 1. three keeps TWO scissor rectangles. With a render target bound it
310
+ // reads the rect off `renderTarget.scissor` and ignores
311
+ // `renderer.setScissor` entirely, while still gating both on the CANVAS
312
+ // target's `scissorTest`. Setting one of the two is a silent no-op that
313
+ // renders a full frame.
314
+ // 2. A colour-attachment clear is ALWAYS attachment-wide — no graphics API
315
+ // scissors a clear — so the second pass must not clear colour, or it
316
+ // erases the first pass. Suppressing the clear instead leaves the first
317
+ // variant's pixels wherever it drew OUTSIDE the second variant's
318
+ // silhouette (a toon outline shell is exactly that), so the region is
319
+ // repainted by hand first.
320
+ // 3. `autoClearDepth = false` leaks into three's nested shadow-map passes:
321
+ // `resetRendererState` forces `autoClear` but not `autoClearDepth`, so
322
+ // the shadow map accumulates stale depth and shadow pixels drift with
323
+ // the split. Depth is therefore always cleared; the scissor already
324
+ // stops out-of-region colour writes, so it costs nothing.
325
+ const clearScene = new THREE.Scene();
326
+ // An NDC-unit orthographic camera, not a bare THREE.Camera — the renderer
327
+ // calls updateProjectionMatrix() on whatever it is handed.
328
+ const clearCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
329
+ const clearMaterial = new THREE.MeshBasicMaterial({
330
+ depthTest: false,
331
+ depthWrite: false,
332
+ fog: false,
333
+ toneMapped: false,
334
+ });
335
+ const clearQuad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), clearMaterial);
336
+ clearQuad.frustumCulled = false;
337
+ clearScene.add(clearQuad);
338
+
339
+ function paintScissorClear() {
340
+ if (scene.background?.isColor) scratchColor.copy(scene.background);
341
+ else renderer.getClearColor(scratchColor);
342
+ clearMaterial.color.copy(scratchColor);
343
+ clearMaterial.opacity = scene.background?.isColor ? 1 : (renderer.getClearAlpha?.() ?? 1);
344
+ clearMaterial.transparent = clearMaterial.opacity < 1;
345
+ // Runs with the caller's autoClear flags in force (colour off, depth on),
346
+ // so this single draw both repaints the scissored region and is the pass
347
+ // that clears depth for the variant render that follows it.
348
+ renderer.render(clearScene, clearCamera);
349
+ }
350
+
351
+ function assertLive() {
352
+ if (disposed) throw new Error('This style comparison has been disposed.');
353
+ }
354
+
355
+ function eachRenderable(callback) {
356
+ for (const root of roots) {
357
+ root.traverse((node) => {
358
+ if (isRenderable(node)) callback(node, root);
359
+ });
360
+ }
361
+ }
362
+
363
+ /** @param {StyleComparisonTarget} target @returns {StyleComparisonSize} */
364
+ function drawingBufferSize(target) {
365
+ if (target) return { height: target.height, width: target.width };
366
+ renderer.getSize(scratchVector);
367
+ const pixelRatio = renderer.getPixelRatio?.() ?? 1;
368
+ return {
369
+ height: Math.max(1, Math.round(scratchVector.y * pixelRatio)),
370
+ width: Math.max(1, Math.round(scratchVector.x * pixelRatio)),
371
+ };
372
+ }
373
+
374
+ /**
375
+ * The scissor rectangle covering the `before` half, in the renderer's
376
+ * bottom-left-origin pixel space. Exposed so a verifier compares exactly the
377
+ * region the wipe writes rather than re-deriving it.
378
+ */
379
+ /**
380
+ * @param {number} fraction
381
+ * @param {StyleComparisonSize} size
382
+ * @returns {StyleComparisonRect}
383
+ */
384
+ function scissorRectFor(fraction, size) {
385
+ const clamped = clamp01(fraction);
386
+ if (currentAxis === 'vertical') {
387
+ return {
388
+ height: size.height,
389
+ width: Math.round(size.width * clamped),
390
+ x: 0,
391
+ y: 0,
392
+ };
393
+ }
394
+ return {
395
+ height: Math.round(size.height * clamped),
396
+ width: size.width,
397
+ x: 0,
398
+ y: 0,
399
+ };
400
+ }
401
+
402
+ /**
403
+ * @param {THREE.Object3D} root
404
+ * @param {{mixer?: THREE.AnimationMixer|null}} [options]
405
+ * @returns {() => void} Untrack.
406
+ */
407
+ function track(root, { mixer = null } = {}) {
408
+ assertLive();
409
+ if (!root?.isObject3D) throw new TypeError('track() requires an Object3D.');
410
+ roots.add(root);
411
+ if (mixer) {
412
+ mixers.add(mixer);
413
+ root.userData.toonlabAnimationMixer = mixer;
414
+ }
415
+ return () => { roots.delete(root); };
416
+ }
417
+
418
+ /**
419
+ * Registers non-material scene state that legitimately differs between the
420
+ * two treatments — a neutral sky, a neutral fog colour, a neutral background.
421
+ * `capture()` records it per variant and `activate()` restores it. Exposure
422
+ * and tone mapping are deliberately NOT trackable: §11 requires both halves
423
+ * to share them, and `auditIdentity()` enforces that.
424
+ */
425
+ /**
426
+ * @param {string} id
427
+ * @param {{apply: (value: unknown) => void, capture: () => unknown}} handlers
428
+ * @returns {() => void} Untrack.
429
+ */
430
+ function trackState(id, { apply, capture }) {
431
+ assertLive();
432
+ if (typeof apply !== 'function' || typeof capture !== 'function') {
433
+ throw new TypeError(`trackState("${id}") needs both capture() and apply().`);
434
+ }
435
+ stateTrackers.set(id, { apply, capture });
436
+ return () => { stateTrackers.delete(id); };
437
+ }
438
+
439
+ /**
440
+ * @param {string} variantId
441
+ * @param {{label?: string}} [options]
442
+ * @returns {{id: string, label: string, materialCount: number, nodeCount: number}}
443
+ */
444
+ function capture(variantId, { label = variantId } = {}) {
445
+ assertLive();
446
+ const id = String(variantId ?? '').trim();
447
+ if (!id) throw new TypeError('capture() requires a variant id.');
448
+ const nodes = new Map();
449
+ eachRenderable((node) => {
450
+ nodes.set(node, captureNodeState(node));
451
+ knownNodes.add(node);
452
+ });
453
+ const states = new Map();
454
+ for (const [stateId, tracker] of stateTrackers) states.set(stateId, tracker.capture());
455
+ variantRecords.set(id, { id, label, nodes, states });
456
+ activeVariantId = id;
457
+ return {
458
+ id,
459
+ label,
460
+ nodeCount: nodes.size,
461
+ materialCount: new Set([...nodes.values()].flatMap(({ material }) => materialList(material))).size,
462
+ };
463
+ }
464
+
465
+ /**
466
+ * @param {string} variantId
467
+ * @param {{force?: boolean}} [options]
468
+ * @returns {object} The activated variant record.
469
+ */
470
+ function activate(variantId, { force = false } = {}) {
471
+ assertLive();
472
+ const record = variantRecords.get(variantId);
473
+ if (!record) throw new Error(`Unknown comparison variant "${variantId}".`);
474
+ if (!force && activeVariantId === variantId) return record;
475
+ for (const [node, state] of record.nodes) applyNodeState(node, state);
476
+ // Nodes a later style added (outlines, fur shells) are unknown to an
477
+ // earlier variant. Hide them; never remove them — removal is what makes
478
+ // the style transaction too expensive to run per frame.
479
+ for (const node of knownNodes) {
480
+ if (!record.nodes.has(node)) node.visible = false;
481
+ }
482
+ for (const [stateId, tracker] of stateTrackers) {
483
+ if (record.states.has(stateId)) tracker.apply(record.states.get(stateId));
484
+ }
485
+ if (refreshRenderPasses) {
486
+ for (const root of roots) {
487
+ root.userData?.toonlabCharacterStyleIntegration?.refresh?.();
488
+ }
489
+ }
490
+ activeVariantId = variantId;
491
+ return record;
492
+ }
493
+
494
+ /**
495
+ * @template T
496
+ * @param {StyleComparisonTarget|undefined} target
497
+ * @param {() => T} callback
498
+ * @returns {T}
499
+ */
500
+ function withRendererState(target, callback) {
501
+ const previousTarget = renderer.getRenderTarget();
502
+ const previousScissorTest = renderer.getScissorTest();
503
+ const previousScissor = new THREE.Vector4();
504
+ renderer.getScissor(previousScissor);
505
+ const previousAutoClear = {
506
+ autoClear: renderer.autoClear,
507
+ autoClearColor: renderer.autoClearColor,
508
+ autoClearDepth: renderer.autoClearDepth,
509
+ autoClearStencil: renderer.autoClearStencil,
510
+ };
511
+ const previousTargetScissor = target
512
+ ? { rect: target.scissor.clone(), test: target.scissorTest }
513
+ : null;
514
+ try {
515
+ if (target !== undefined) renderer.setRenderTarget(target ?? null);
516
+ return callback();
517
+ } finally {
518
+ renderer.setScissorTest(previousScissorTest);
519
+ renderer.setScissor(previousScissor.x, previousScissor.y, previousScissor.z, previousScissor.w);
520
+ Object.assign(renderer, previousAutoClear);
521
+ if (previousTargetScissor) {
522
+ target.scissor.copy(previousTargetScissor.rect);
523
+ target.scissorTest = previousTargetScissor.test;
524
+ }
525
+ if (target !== undefined) renderer.setRenderTarget(previousTarget);
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Installs a scissor rectangle on BOTH surfaces three tracks — see defect (1)
531
+ * above. Setting only `renderer.setScissor` renders a full frame into a
532
+ * render target with no error of any kind.
533
+ *
534
+ * @param {StyleComparisonTarget} target
535
+ * @param {StyleComparisonRect} rect
536
+ * @returns {void}
537
+ */
538
+ function setScissorFor(target, rect) {
539
+ if (target) {
540
+ target.scissor.set(rect.x, rect.y, rect.width, rect.height);
541
+ target.scissorTest = true;
542
+ } else {
543
+ renderer.setScissor(rect.x, rect.y, rect.width, rect.height);
544
+ }
545
+ renderer.setScissorTest(true);
546
+ }
547
+
548
+ /**
549
+ * Clears any scissor still set, on BOTH surfaces three tracks.
550
+ *
551
+ * three's renderer keeps two scissor rectangles: the canvas target's (set by
552
+ * `renderer.setScissor`) and each RenderTarget's own `target.scissor`. When a
553
+ * render target is bound it reads the rectangle off the TARGET and ignores
554
+ * `renderer.setScissor` entirely — while still gating both on the canvas
555
+ * target's `scissorTest` flag. Touching only one of the two is a silent no-op,
556
+ * so every variant render explicitly clears both before drawing: a stale
557
+ * host scissor would crop one half and nothing would report it.
558
+ */
559
+ /** @param {StyleComparisonTarget} target @returns {void} */
560
+ function clearScissorFor(target) {
561
+ if (target) target.scissorTest = false;
562
+ renderer.setScissorTest(false);
563
+ }
564
+
565
+ /**
566
+ * Renders one variant full-frame. The A/B halves in isolation.
567
+ *
568
+ * @param {string} variantId
569
+ * @param {StyleComparisonVariantRenderOptions} [options]
570
+ * @returns {void}
571
+ */
572
+ function renderVariant(variantId, { target } = {}) {
573
+ assertLive();
574
+ activate(variantId);
575
+ return withRendererState(target, () => {
576
+ clearScissorFor(target ?? null);
577
+ renderer.render(scene, camera);
578
+ });
579
+ }
580
+
581
+ /**
582
+ * Renders the composite wipe: the `after` variant full-frame, then the
583
+ * `before` variant scissored into the leading region. Nothing between the
584
+ * two `renderer.render` calls touches the camera, the lights, the exposure
585
+ * or any animation clock — which is precisely why the comparison is honest.
586
+ */
587
+ /** @param {StyleComparisonRenderOptions} [options] @returns {StyleComparisonRenderResult} */
588
+ function render({ split: splitOverride, target } = {}) {
589
+ assertLive();
590
+ const fraction = splitOverride === undefined ? currentSplit : clamp01(splitOverride);
591
+ const resolvedTarget = target ?? null;
592
+ const size = drawingBufferSize(resolvedTarget);
593
+ const exposureAtStart = renderer.toneMappingExposure;
594
+ const toneMappingAtStart = renderer.toneMapping;
595
+ const result = withRendererState(target, () => {
596
+ activate(variantPair.after);
597
+ clearScissorFor(resolvedTarget);
598
+ renderer.autoClear = true;
599
+ renderer.autoClearColor = true;
600
+ renderer.autoClearDepth = true;
601
+ renderer.render(scene, camera);
602
+ // NOTHING happens between the two renders: no clock advanced, no camera
603
+ // moved, no light touched, no exposure written. That gap is the whole A/B
604
+ // claim, and it is a scissor call wide.
605
+ if (fraction > 0) {
606
+ activate(variantPair.before);
607
+ setScissorFor(resolvedTarget, scissorRectFor(fraction, size));
608
+ renderer.autoClear = true;
609
+ renderer.autoClearColor = false;
610
+ renderer.autoClearDepth = true;
611
+ paintScissorClear();
612
+ renderer.render(scene, camera);
613
+ clearScissorFor(resolvedTarget);
614
+ }
615
+ return { fraction, size };
616
+ });
617
+
618
+ if (renderer.toneMappingExposure !== exposureAtStart
619
+ || renderer.toneMapping !== toneMappingAtStart) {
620
+ exposureDrift = {
621
+ exposure: { after: renderer.toneMappingExposure, before: exposureAtStart },
622
+ toneMapping: { after: renderer.toneMapping, before: toneMappingAtStart },
623
+ };
624
+ if (!exposureDriftWarned) {
625
+ exposureDriftWarned = true;
626
+ console.warn(
627
+ 'ToonLab style comparison: exposure or tone mapping changed DURING a composite render. '
628
+ + 'The two halves no longer share exposure, which invalidates the A/B. '
629
+ + 'Something outside this module is writing renderer.toneMappingExposure mid-frame.',
630
+ exposureDrift,
631
+ );
632
+ }
633
+ } else {
634
+ exposureDrift = null;
635
+ }
636
+ return result;
637
+ }
638
+
639
+ /**
640
+ * Structural proof that the two variants differ only by material treatment:
641
+ * the same geometry buffer, the same skeleton and the same morph influences
642
+ * on every tracked node, and exactly one shared camera / light rig /
643
+ * exposure. Cheap enough to assert every frame in a lab.
644
+ */
645
+ function auditIdentity() {
646
+ assertLive();
647
+ const issues = [];
648
+ const ids = [...variantRecords.keys()];
649
+ if (ids.length < 2) issues.push(`Only ${ids.length} variant(s) captured; a comparison needs two.`);
650
+ for (const required of [variantPair.before, variantPair.after]) {
651
+ if (!variantRecords.has(required)) issues.push(`Variant "${required}" was never captured.`);
652
+ }
653
+
654
+ let sharedNodes = 0;
655
+ let divergentMaterials = 0;
656
+ const [first, ...rest] = ids.map((id) => variantRecords.get(id));
657
+ if (first) {
658
+ for (const [node, state] of first.nodes) {
659
+ let shared = true;
660
+ for (const other of rest) {
661
+ const otherState = other.nodes.get(node);
662
+ if (!otherState) continue;
663
+ if (otherState.geometry !== state.geometry) {
664
+ issues.push(`Node "${node.name || node.uuid}" has a different geometry in "${other.id}".`);
665
+ shared = false;
666
+ }
667
+ if (otherState.skeleton !== state.skeleton) {
668
+ issues.push(`Node "${node.name || node.uuid}" has a different skeleton in "${other.id}".`);
669
+ shared = false;
670
+ }
671
+ if (otherState.morphTargetInfluences !== state.morphTargetInfluences) {
672
+ issues.push(`Node "${node.name || node.uuid}" has different morph influences in "${other.id}".`);
673
+ shared = false;
674
+ }
675
+ const a = materialList(state.material);
676
+ const b = materialList(otherState.material);
677
+ if (a.length !== b.length || a.some((material, index) => material !== b[index])) {
678
+ divergentMaterials += 1;
679
+ }
680
+ }
681
+ if (shared) sharedNodes += 1;
682
+ }
683
+ }
684
+ if (divergentMaterials === 0 && ids.length >= 2) {
685
+ issues.push('No tracked node has a different material between variants — nothing to compare.');
686
+ }
687
+ if (exposureDrift) {
688
+ issues.push(
689
+ `Exposure changed during the composite render (${exposureDrift.exposure.before} → ${exposureDrift.exposure.after}); the halves do not share exposure.`,
690
+ );
691
+ }
692
+
693
+ const mixerList = [...mixers];
694
+ const observedMixers = collectMixerSignature(roots);
695
+ return {
696
+ axis: currentAxis,
697
+ exposureDrift,
698
+ frameState: captureComparisonFrameState({
699
+ camera, mixers: mixerList, renderer, scene,
700
+ }),
701
+ issues,
702
+ mixerCount: mixerList.length || observedMixers.length,
703
+ ok: issues.length === 0,
704
+ sharedGeometryNodes: sharedNodes,
705
+ split: currentSplit,
706
+ trackedNodes: knownNodes.size,
707
+ trackedRoots: roots.size,
708
+ variants: ids,
709
+ variantsWithDivergentMaterials: divergentMaterials,
710
+ };
711
+ }
712
+
713
+ return {
714
+ activate,
715
+ auditIdentity,
716
+ capture,
717
+ get activeVariant() { return activeVariantId; },
718
+ /** Non-null when exposure or tone mapping moved during the last composite. */
719
+ get exposureDrift() { return exposureDrift; },
720
+ get axis() { return currentAxis; },
721
+ get camera() { return camera; },
722
+ get mixers() { return [...mixers]; },
723
+ get renderer() { return renderer; },
724
+ get roots() { return [...roots]; },
725
+ get scene() { return scene; },
726
+ get split() { return currentSplit; },
727
+ get variantIds() { return [...variantRecords.keys()]; },
728
+ get variants() { return { ...variantPair }; },
729
+ dispose() {
730
+ if (disposed) return;
731
+ disposed = true;
732
+ roots.clear();
733
+ variantRecords.clear();
734
+ stateTrackers.clear();
735
+ knownNodes.clear();
736
+ mixers.clear();
737
+ clearQuad.geometry.dispose();
738
+ clearMaterial.dispose();
739
+ },
740
+ render,
741
+ renderVariant,
742
+ /** @type {(fraction: number, size?: StyleComparisonSize) => StyleComparisonRect} */
743
+ scissorRectFor: (fraction, size) => scissorRectFor(fraction, size ?? drawingBufferSize(null)),
744
+ /** @param {StyleComparisonAxis} next @returns {StyleComparisonAxis} */
745
+ setAxis(next) {
746
+ if (!STYLE_COMPARISON_AXES.includes(next)) throw new TypeError(`Unknown axis "${next}".`);
747
+ currentAxis = next;
748
+ return currentAxis;
749
+ },
750
+ /** @param {number} next @returns {number} */
751
+ setSplit(next) {
752
+ currentSplit = clamp01(next);
753
+ return currentSplit;
754
+ },
755
+ /**
756
+ * @param {Partial<StyleComparisonVariantPair>} [pair]
757
+ * @returns {StyleComparisonVariantPair}
758
+ */
759
+ setVariants({ after, before } = {}) {
760
+ variantPair = {
761
+ after: after ?? variantPair.after,
762
+ before: before ?? variantPair.before,
763
+ };
764
+ return { ...variantPair };
765
+ },
766
+ track,
767
+ trackState,
768
+ };
769
+ }
770
+
771
+ function countDifferences(a, b, { height, region, tolerance = 0, width }) {
772
+ const x0 = region?.x ?? 0;
773
+ const y0 = region?.y ?? 0;
774
+ const x1 = Math.min(width, x0 + (region?.width ?? width));
775
+ const y1 = Math.min(height, y0 + (region?.height ?? height));
776
+ let differing = 0;
777
+ let maxDelta = 0;
778
+ for (let y = y0; y < y1; y += 1) {
779
+ for (let x = x0; x < x1; x += 1) {
780
+ const index = (y * width + x) * 4;
781
+ let delta = 0;
782
+ for (let channel = 0; channel < 4; channel += 1) {
783
+ delta = Math.max(delta, Math.abs(a[index + channel] - b[index + channel]));
784
+ }
785
+ if (delta > maxDelta) maxDelta = delta;
786
+ if (delta > tolerance) differing += 1;
787
+ }
788
+ }
789
+ const total = Math.max(1, (x1 - x0) * (y1 - y0));
790
+ return { differing, fraction: differing / total, maxDelta, total };
791
+ }
792
+
793
+ function differenceMask(a, b, { height, tolerance, width }) {
794
+ const mask = new Uint8Array(width * height);
795
+ for (let pixel = 0; pixel < mask.length; pixel += 1) {
796
+ const index = pixel * 4;
797
+ let delta = 0;
798
+ for (let channel = 0; channel < 4; channel += 1) {
799
+ delta = Math.max(delta, Math.abs(a[index + channel] - b[index + channel]));
800
+ }
801
+ if (delta > tolerance) mask[pixel] = 1;
802
+ }
803
+ return mask;
804
+ }
805
+
806
+ /**
807
+ * Proves that a comparison is a true renderer split of one framing, not two
808
+ * separately framed images — and that everything outside the treated subject
809
+ * is bit-identical between the halves.
810
+ *
811
+ * The five assertions:
812
+ *
813
+ * 1. `split = 0` is bit-identical to a full-frame render of the `after`
814
+ * variant.
815
+ * 2. `split = 1` is bit-identical to a full-frame render of the `before`
816
+ * variant.
817
+ * 3. At any intermediate split, the region inside the scissor is bit-identical
818
+ * to the SAME region of the `before` full frame, and the region outside it
819
+ * is bit-identical to the same region of the `after` full frame. A pixel at
820
+ * (x, y) in the wipe therefore equals that pixel in a full-frame render of
821
+ * its own variant — which is only possible if both halves share one camera.
822
+ * 4. Camera matrices, light transforms, exposure, tone mapping, shadow state
823
+ * and every registered animation clock are unchanged by the composite
824
+ * render.
825
+ * 5. Pixels that differ between the two variants lie inside the region the
826
+ * tracked subject affects (measured by rendering with the subject hidden),
827
+ * so nothing outside the intended material treatment moved.
828
+ *
829
+ * Deterministic and reusable: it renders into its own render target, so it does
830
+ * not depend on canvas size, page compositing or screenshot timing. This is the
831
+ * routine the filler register's equivalence test calls.
832
+ */
833
+ export async function verifyStyleComparisonIdentity(comparison, {
834
+ height = 540,
835
+ onStage = null,
836
+ splits = [0.25, 0.5, 0.75],
837
+ tolerance = 0,
838
+ width = 960,
839
+ } = {}) {
840
+ const stage = (name) => { onStage?.(name); };
841
+ if (typeof comparison?.render !== 'function') {
842
+ throw new TypeError('verifyStyleComparisonIdentity requires a style comparison.');
843
+ }
844
+ const { camera, renderer, scene } = comparison;
845
+ if (typeof renderer.readRenderTargetPixelsAsync !== 'function') {
846
+ throw new Error('Pixel-identity verification requires readRenderTargetPixelsAsync.');
847
+ }
848
+ const { after, before } = comparison.variants;
849
+ const target = new THREE.RenderTarget(width, height, {
850
+ depthBuffer: true,
851
+ format: THREE.RGBAFormat,
852
+ type: THREE.UnsignedByteType,
853
+ });
854
+ target.depthTexture = new THREE.DepthTexture(width, height);
855
+
856
+ const read = async () => {
857
+ const buffer = await renderer.readRenderTargetPixelsAsync(target, 0, 0, width, height);
858
+ return new Uint8Array(buffer.buffer ?? buffer);
859
+ };
860
+
861
+ const checks = [];
862
+ const record = (id, description, result, extra = {}) => {
863
+ checks.push({
864
+ description,
865
+ differingPixels: result.differing,
866
+ fraction: result.fraction,
867
+ id,
868
+ maxChannelDelta: result.maxDelta,
869
+ ok: result.differing === 0,
870
+ totalPixels: result.total,
871
+ ...extra,
872
+ });
873
+ };
874
+
875
+ try {
876
+ const stateBefore = captureComparisonFrameState({
877
+ camera, mixers: comparison.mixers, renderer, scene,
878
+ });
879
+
880
+ stage('render-before');
881
+ comparison.renderVariant(before, { target });
882
+ const beforePixels = await read();
883
+ stage('render-after');
884
+ comparison.renderVariant(after, { target });
885
+ const afterPixels = await read();
886
+
887
+ // The subject-free plate: what the frame looks like with nothing tracked
888
+ // in it. Any pixel the subject can possibly influence — including its
889
+ // shadows and its contribution to reflections — differs from this plate.
890
+ stage('render-plate');
891
+ const rootVisibility = comparison.roots.map((root) => [root, root.visible]);
892
+ for (const [root] of rootVisibility) root.visible = false;
893
+ renderer.setRenderTarget(target);
894
+ renderer.setScissorTest(false);
895
+ renderer.render(scene, camera);
896
+ renderer.setRenderTarget(null);
897
+ const platePixels = await read();
898
+ for (const [root, visible] of rootVisibility) root.visible = visible;
899
+
900
+ stage('wipe-0');
901
+ comparison.render({ split: 0, target });
902
+ record('split-0-equals-after', `split=0 is bit-identical to a full "${after}" frame`,
903
+ countDifferences(await read(), afterPixels, { height, tolerance, width }));
904
+
905
+ stage('wipe-1');
906
+ comparison.render({ split: 1, target });
907
+ record('split-1-equals-before', `split=1 is bit-identical to a full "${before}" frame`,
908
+ countDifferences(await read(), beforePixels, { height, tolerance, width }));
909
+
910
+ for (const split of splits) {
911
+ stage(`wipe-${split}`);
912
+ comparison.render({ split, target });
913
+ const wipe = await read();
914
+ const rect = comparison.scissorRectFor(split, { height, width });
915
+ const complement = comparison.axis === 'vertical'
916
+ ? { height, width: width - rect.width, x: rect.width, y: 0 }
917
+ : { height: height - rect.height, width, x: 0, y: rect.height };
918
+ record(`split-${split}-inside`,
919
+ `split=${split}: the scissored region equals the same region of "${before}"`,
920
+ countDifferences(wipe, beforePixels, { height, region: rect, tolerance, width }),
921
+ { region: rect, split });
922
+ record(`split-${split}-outside`,
923
+ `split=${split}: the region outside the scissor equals the same region of "${after}"`,
924
+ countDifferences(wipe, afterPixels, { height, region: complement, tolerance, width }),
925
+ { region: complement, split });
926
+ }
927
+
928
+ const stateAfter = captureComparisonFrameState({
929
+ camera, mixers: comparison.mixers, renderer, scene,
930
+ });
931
+ const frameStateIssues = diffFrameState(stateBefore, stateAfter);
932
+ checks.push({
933
+ description: 'camera matrices, light transforms, exposure and animation clocks are unchanged by the wipe',
934
+ differences: frameStateIssues,
935
+ id: 'frame-state-stable',
936
+ ok: frameStateIssues.length === 0,
937
+ });
938
+
939
+ stage('mask');
940
+ const treated = differenceMask(afterPixels, platePixels, { height, tolerance, width });
941
+ const treatedBefore = differenceMask(beforePixels, platePixels, { height, tolerance, width });
942
+ for (let pixel = 0; pixel < treated.length; pixel += 1) {
943
+ if (treatedBefore[pixel]) treated[pixel] = 1;
944
+ }
945
+ const variantDelta = differenceMask(afterPixels, beforePixels, { height, tolerance, width });
946
+ let outsideTreatment = 0;
947
+ let treatedPixels = 0;
948
+ let changedPixels = 0;
949
+ for (let pixel = 0; pixel < treated.length; pixel += 1) {
950
+ if (treated[pixel]) treatedPixels += 1;
951
+ if (!variantDelta[pixel]) continue;
952
+ changedPixels += 1;
953
+ if (!treated[pixel]) outsideTreatment += 1;
954
+ }
955
+ checks.push({
956
+ changedPixels,
957
+ description: 'every pixel that differs between the halves lies inside the treated subject',
958
+ differingPixels: outsideTreatment,
959
+ id: 'difference-confined-to-treatment',
960
+ ok: outsideTreatment === 0,
961
+ totalPixels: treated.length,
962
+ treatedPixels,
963
+ });
964
+
965
+ stage('structural');
966
+ const structural = comparison.auditIdentity();
967
+ checks.push({
968
+ description: 'both variants share geometry buffers, skeletons and morph influences',
969
+ differences: structural.issues,
970
+ id: 'structural-identity',
971
+ ok: structural.ok,
972
+ });
973
+
974
+ return {
975
+ checks,
976
+ ok: checks.every((check) => check.ok),
977
+ resolution: { height, width },
978
+ structural,
979
+ variants: { after, before },
980
+ };
981
+ } finally {
982
+ target.depthTexture?.dispose?.();
983
+ target.dispose();
984
+ }
985
+ }