Warning, /firebird/packages/firebird-ng/display/src/lib/services/picking.spec.ts is written in an unsupported language. File is not indexed.
0001 import {
0002 BoxGeometry,
0003 BufferGeometry,
0004 DoubleSide,
0005 Group,
0006 InstancedMesh,
0007 Mesh,
0008 MeshBasicMaterial,
0009 Plane,
0010 Raycaster,
0011 SphereGeometry,
0012 Vector3,
0013 } from 'three';
0014 import { acceleratedRaycast } from 'three-mesh-bvh';
0015 import {
0016 buildPickingBvh,
0017 clipPlaneSetsOf,
0018 collectBvhCandidates,
0019 intersectVisible,
0020 isBvhCandidate,
0021 isClickGesture,
0022 isPointClipped,
0023 setWedgePlanes,
0024 useAcceleratedRaycast,
0025 } from './picking';
0026
0027 const material = new MeshBasicMaterial();
0028
0029 /** A unit box centered on the Z axis at `z`. */
0030 function boxAt(z: number, name: string): Mesh {
0031 const mesh = new Mesh(new BoxGeometry(1, 1, 1), material);
0032 mesh.position.set(0, 0, z);
0033 mesh.name = name;
0034 mesh.updateMatrixWorld(true);
0035 return mesh;
0036 }
0037
0038 /** A ray from z = 100 looking down -Z through the origin. */
0039 function rayDownZ(): Raycaster {
0040 return new Raycaster(new Vector3(0, 0, 100), new Vector3(0, 0, -1));
0041 }
0042
0043 describe('clip test (picking matches what the renderer clips)', () => {
0044 // The wedge as ThreeService builds it: start 90, opening 90 removes the
0045 // quadrant x < 0, y > 0 (intersection mode: behind BOTH planes).
0046 function quarterWedge() {
0047 const a = new Plane();
0048 const b = new Plane();
0049 const clipIntersection = setWedgePlanes(a, b, 90, 90);
0050 return { a, b, clipIntersection };
0051 }
0052
0053 it('a wedge under 180 degrees clips only the region behind both planes', () => {
0054 const { a, b, clipIntersection } = quarterWedge();
0055 expect(clipIntersection).toBe(true);
0056 const planes = clipPlaneSetsOf([{ enabled: true, clipIntersection, clippingPlanes: [a, b] }]);
0057
0058 expect(isPointClipped(new Vector3(-5, 5, 0), planes)).toBe(true); // removed quadrant
0059 expect(isPointClipped(new Vector3(5, 5, 0), planes)).toBe(false); // visible
0060 expect(isPointClipped(new Vector3(-5, -5, 0), planes)).toBe(false); // visible
0061 expect(isPointClipped(new Vector3(5, -5, 0), planes)).toBe(false); // visible
0062 });
0063
0064 it('a wedge of 180 degrees or more clips on either plane (union mode)', () => {
0065 const a = new Plane();
0066 const b = new Plane();
0067 const clipIntersection = setWedgePlanes(a, b, 0, 180);
0068 expect(clipIntersection).toBe(false);
0069 const planes = clipPlaneSetsOf([{ enabled: true, clipIntersection, clippingPlanes: [a, b] }]);
0070 // Start 0, opening 180: plane A keeps y <= 0, plane B keeps y <= 0 too.
0071 expect(isPointClipped(new Vector3(3, 5, 0), planes)).toBe(true);
0072 expect(isPointClipped(new Vector3(3, -5, 0), planes)).toBe(false);
0073 });
0074
0075 it('nested groups combine: the Z plane (union) clips on its own, disabled groups never clip', () => {
0076 const { a, b, clipIntersection } = quarterWedge();
0077 const zPlane = new Plane(new Vector3(0, 0, 1), 0); // keeps z >= 0
0078 const planes = clipPlaneSetsOf([
0079 { enabled: true, clipIntersection: false, clippingPlanes: [zPlane] },
0080 { enabled: true, clipIntersection, clippingPlanes: [a, b] },
0081 ]);
0082 expect(planes.union).toEqual([zPlane]);
0083 expect(planes.intersection).toEqual([a, b]);
0084 expect(isPointClipped(new Vector3(5, 5, -1), planes)).toBe(true); // behind the Z plane
0085 expect(isPointClipped(new Vector3(5, 5, 1), planes)).toBe(false);
0086
0087 const disabled = clipPlaneSetsOf([
0088 { enabled: false, clipIntersection: false, clippingPlanes: [zPlane] },
0089 { enabled: false, clipIntersection, clippingPlanes: [a, b] },
0090 ]);
0091 expect(isPointClipped(new Vector3(-5, 5, -1), disabled)).toBe(false);
0092 });
0093
0094 it('a single intersection-mode plane (the per-view slice cut) clips like one plane', () => {
0095 const cut = new Plane(new Vector3(1, 0, 0), 0); // keeps x >= 0
0096 const planes = clipPlaneSetsOf([{ enabled: true, clipIntersection: true, clippingPlanes: [cut] }]);
0097 expect(isPointClipped(new Vector3(-1, 0, 0), planes)).toBe(true);
0098 expect(isPointClipped(new Vector3(1, 0, 0), planes)).toBe(false);
0099 });
0100 });
0101
0102 describe('intersectVisible', () => {
0103 it('skips objects under an invisible ancestor, so hidden pieces do not block visible ones', () => {
0104 const root = new Group();
0105 const hiddenPiece = new Group();
0106 hiddenPiece.visible = false;
0107 hiddenPiece.add(boxAt(10, 'hidden-front'));
0108 root.add(hiddenPiece);
0109 root.add(boxAt(0, 'visible-back'));
0110 root.updateMatrixWorld(true);
0111
0112 const hits = intersectVisible(rayDownZ(), [root]);
0113 expect(hits.length).toBeGreaterThan(0);
0114 expect(new Set(hits.map(hit => hit.object.name))).toEqual(new Set(['visible-back']));
0115 });
0116
0117 it('returns hits sorted by distance across roots, and nothing from an invisible root', () => {
0118 const near = new Group();
0119 near.add(boxAt(20, 'near'));
0120 const far = new Group();
0121 far.add(boxAt(-20, 'far'));
0122 const hidden = new Group();
0123 hidden.visible = false;
0124 hidden.add(boxAt(50, 'hidden'));
0125 for (const group of [near, far, hidden]) group.updateMatrixWorld(true);
0126
0127 const names = intersectVisible(rayDownZ(), [far, hidden, near]).map(hit => hit.object.name);
0128 expect(names[0]).toBe('near');
0129 expect(names).not.toContain('hidden');
0130 expect(names.indexOf('far')).toBeGreaterThan(names.lastIndexOf('near'));
0131 });
0132 });
0133
0134 describe('isClickGesture', () => {
0135 it('accepts a release within the travel threshold and rejects a drag', () => {
0136 const down = { clientX: 100, clientY: 100 };
0137 expect(isClickGesture(down, { clientX: 100, clientY: 100 })).toBe(true);
0138 expect(isClickGesture(down, { clientX: 103, clientY: 104 })).toBe(true); // 5 px
0139 expect(isClickGesture(down, { clientX: 104, clientY: 104 })).toBe(false); // 5.7 px
0140 expect(isClickGesture(down, { clientX: 160, clientY: 100 })).toBe(false); // an orbit
0141 });
0142 });
0143
0144 describe('picking BVH', () => {
0145 it('accepts plain meshes and rejects instanced meshes, fat lines and position-less geometry', () => {
0146 expect(isBvhCandidate(new Mesh(new BoxGeometry(), material))).toBe(true);
0147 expect(isBvhCandidate(new InstancedMesh(new BoxGeometry(), material, 4))).toBe(false);
0148 const fatLine = new Mesh(new BoxGeometry(), material) as Mesh & { isLineSegments2?: boolean };
0149 fatLine.isLineSegments2 = true;
0150 expect(isBvhCandidate(fatLine)).toBe(false);
0151 expect(isBvhCandidate(new Mesh(new BufferGeometry(), material))).toBe(false);
0152 expect(isBvhCandidate(new Group())).toBe(false);
0153 });
0154
0155 it('collects candidates of a subtree and routes them, and only them, through the accelerated raycast', () => {
0156 const root = new Group();
0157 const mesh = new Mesh(new BoxGeometry(), material);
0158 const instanced = new InstancedMesh(new BoxGeometry(), material, 2);
0159 root.add(mesh, instanced);
0160 expect(collectBvhCandidates(root)).toEqual([mesh]);
0161
0162 useAcceleratedRaycast(root);
0163 expect(mesh.raycast).toBe(acceleratedRaycast);
0164 expect(instanced.raycast).toBe(InstancedMesh.prototype.raycast);
0165 // Never a prototype patch: other meshes keep three's raycast.
0166 expect(Mesh.prototype.raycast).not.toBe(acceleratedRaycast);
0167 });
0168
0169 it('builds once per geometry, leaves the index buffer untouched and finds the same hits', () => {
0170 const geometry = new SphereGeometry(5, 32, 16);
0171 const indexBefore = geometry.index!.array.slice();
0172 const doubleSided = new MeshBasicMaterial({ side: DoubleSide });
0173 const plain = new Mesh(geometry.clone(), doubleSided);
0174 const accelerated = new Mesh(geometry, doubleSided);
0175 const copy = accelerated.clone(); // shares the geometry, as slice copies do
0176 for (const mesh of [plain, accelerated, copy]) mesh.updateMatrixWorld(true);
0177
0178 expect(buildPickingBvh(accelerated)).toBe(true);
0179 expect(buildPickingBvh(copy)).toBe(false); // shared geometry: one BVH
0180 expect(copy.geometry.boundsTree).toBe(accelerated.geometry.boundsTree);
0181 expect([...geometry.index!.array]).toEqual([...indexBefore]);
0182
0183 const ray = new Raycaster(new Vector3(0.3, 0.2, 20), new Vector3(0, 0, -1));
0184 const expected = ray.intersectObject(plain).map(hit => hit.distance.toFixed(6));
0185 const actual = ray.intersectObject(accelerated).map(hit => hit.distance.toFixed(6));
0186 expect(actual).toEqual(expected);
0187 expect(actual.length).toBe(2); // in and out through the double-sided sphere
0188 });
0189
0190 it('leaves non-indexed geometry non-indexed', () => {
0191 const geometry = new BoxGeometry().toNonIndexed();
0192 const mesh = new Mesh(geometry, material);
0193 buildPickingBvh(mesh);
0194 expect(geometry.index).toBeNull();
0195 expect(geometry.boundsTree).toBeDefined();
0196 });
0197 });