Warning, /firebird/packages/firebird-ng/display/src/lib/services/three-clipping-internals.spec.ts is written in an unsupported language. File is not indexed.
0001 /**
0002 * Canary tests pinning the three.js clipping internals that
0003 * `ThreeService.dropClippingShaderState()` compensates for.
0004 *
0005 * Built shader states bind clipping planes to the ARRAY INSTANCE their
0006 * ClippingContext held at build time, and the state cache is keyed by plane
0007 * COUNTS. ClippingContext.update() REPLACES its arrays whenever the parent
0008 * group chain changes (an outer clipping group toggled on or off), so a
0009 * toggle that returns to a previously-seen count shape would reuse a shader
0010 * bound to an orphaned array whose view-space values are never re-projected
0011 * again — the cut freezes to the camera. Firebird's answer is to dispose the
0012 * clipped geometry's materials on every clipping STRUCTURE change, which
0013 * releases their render objects and, through three's reference counting,
0014 * the built node states (see dropClippingShaderState in three.service.ts).
0015 *
0016 * These tests assert that three still behaves that way. If a three upgrade
0017 * makes the "replaces arrays" test fail, the upstream defect is likely fixed
0018 * — re-evaluate whether dropClippingShaderState() still needs to evict the
0019 * node states.
0020 *
0021 * The `three/src/...` import below is TEST-ONLY and deliberate: the class is
0022 * not exported from `three/webgpu`, and this spec never runs in the browser
0023 * bundle (where a second copy of three's node system would corrupt TSL
0024 * state). Do not copy this import into app code.
0025 */
0026 import ClippingContext from 'three/src/renderers/common/ClippingContext.js';
0027 import { Matrix4, Plane, Vector3 } from 'three';
0028
0029 type AnyCtx = {
0030 cacheKey: string;
0031 intersectionPlanes: { x: number; y: number; z: number; w: number }[];
0032 unionPlanes: { x: number; y: number; z: number; w: number }[];
0033 updateGlobal(scene: unknown, camera: unknown): void;
0034 getGroupContext(group: unknown): AnyCtx;
0035 };
0036
0037 function makeGroup(planes: Plane[], clipIntersection: boolean) {
0038 return { isClippingGroup: true, enabled: true, clipShadows: false, clipIntersection, clippingPlanes: planes };
0039 }
0040
0041 function makeCamera() {
0042 return { matrixWorldInverse: new Matrix4() };
0043 }
0044
0045 const SCENE = { overrideMaterial: null };
0046
0047 describe('three ClippingContext internals (pinned)', () => {
0048 it('REPLACES plane arrays when the parent group chain changes (why the node cache must be evicted)', () => {
0049 const root = new ClippingContext() as AnyCtx;
0050 root.updateGlobal(SCENE, makeCamera());
0051
0052 const zGroup = makeGroup([new Plane(new Vector3(0, 0, 1), 0)], false);
0053 const wedgeGroup = makeGroup(
0054 [new Plane(new Vector3(0, -1, 0), 0), new Plane(new Vector3(1, 0, 0), 0)],
0055 true,
0056 );
0057
0058 // Angular only: wedge context parented to the root.
0059 const wedgeCtx = root.getGroupContext(wedgeGroup);
0060 const intersection = wedgeCtx.intersectionPlanes; // what a built shader captures
0061 expect(intersection.length).toBe(2);
0062
0063 // Z group enabled: the wedge context is reparented under the Z context.
0064 // three resyncs by REPLACING both arrays — the captured `intersection`
0065 // instance is orphaned. A cached shader state keeps reading it.
0066 const zCtx = root.getGroupContext(zGroup);
0067 const wedgeCtxUnderZ = zCtx.getGroupContext(wedgeGroup);
0068 expect(wedgeCtxUnderZ).toBe(wedgeCtx); // one context per group, whatever the parent
0069 expect(wedgeCtx.intersectionPlanes).not.toBe(intersection);
0070 // If this starts failing (arrays kept stable), three fixed the defect:
0071 // re-evaluate dropClippingShaderState()'s eviction.
0072 });
0073
0074 it('re-projects plane VALUES into the current arrays on every update call (per-view value swaps)', () => {
0075 // RenderView.clipPlane relies on this: sequential per-view renders write
0076 // different values into ONE shared plane, and every render call
0077 // re-projects them with the current camera.
0078 const root = new ClippingContext() as AnyCtx;
0079 const camera = makeCamera();
0080 root.updateGlobal(SCENE, camera);
0081
0082 const group = makeGroup([new Plane(new Vector3(0, -1, 0), 0)], true);
0083 const ctx = root.getGroupContext(group);
0084 const held = ctx.intersectionPlanes;
0085 const before = held[0].w;
0086
0087 camera.matrixWorldInverse.makeTranslation(0, 100, 0);
0088 root.updateGlobal(SCENE, camera);
0089 root.getGroupContext(group); // per-render update path
0090
0091 expect(ctx.intersectionPlanes).toBe(held); // no structure change, no replacement
0092 expect(held[0].w).not.toBe(before);
0093 });
0094
0095 it('cache key carries the context id — sibling groups with equal shapes no longer collide', () => {
0096 // Through three r185 the key was plane COUNTS only ("0:1"), so two
0097 // sibling groups with the same shape shared one built shader state bound
0098 // to ONE group's plane array, and the other group clipped with the wrong
0099 // planes. three r186 prefixes the context id ("<id>:0:1"), which gives
0100 // every group its own state. ClippedGeometrySlice keeps its distinct
0101 // (1 intersection : 0 union) shape as a guard: if the two keys below
0102 // become equal again, that distinct shape is load-bearing once more.
0103 const root = new ClippingContext() as AnyCtx;
0104 root.updateGlobal(SCENE, makeCamera());
0105
0106 const groupA = makeGroup([new Plane(new Vector3(0, 0, 1), 0)], false);
0107 const groupB = makeGroup([new Plane(new Vector3(1, 0, 0), 500)], false);
0108 const ctxA = root.getGroupContext(groupA);
0109 const ctxB = root.getGroupContext(groupB);
0110 const countShape = (key: string) => key.split(':').slice(-2).join(':');
0111
0112 expect(ctxA).not.toBe(ctxB);
0113 expect(countShape(ctxA.cacheKey)).toBe(countShape(ctxB.cacheKey)); // same shape "0:1"
0114 expect(ctxA.cacheKey).not.toBe(ctxB.cacheKey); // distinct keys since r186
0115
0116 // The slice's intersection-mode single plane still has a distinct shape:
0117 const sliceLike = makeGroup([new Plane(new Vector3(0, -1, 0), 0)], true);
0118 expect(countShape(root.getGroupContext(sliceLike).cacheKey)).not.toBe(countShape(ctxA.cacheKey));
0119 });
0120 });