Skip to content

Commit 705ea90

Browse files
agviegasclaude
andcommitted
fix(FragmentsModels): geometry-accurate rectangleRaycast selection
rectangleRaycast only tested item bounding boxes, so crossing selection (fullyIncluded:false) over-selected concave items whose AABB spans empty space (e.g. a C-shaped roof's box covering the inner courtyard), and window selection (fullyIncluded:true) under-selected concave items whose geometry is inside but whose AABB pokes out. Add a narrow phase after the broad-phase box test: per candidate, build a transient per-representation BVH (cached per call, like the section/clip generator) and test the real geometry against the selection frustum in the item's local space. Crossing keeps items whose triangles intersect the frustum (exact via plane clipping); window keeps items whose every vertex is inside; AABB-fully-inside items fast-accept; a time budget caps the worst case. Closes #229 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 29d6179 commit 705ea90

1 file changed

Lines changed: 261 additions & 4 deletions

File tree

packages/fragments/src/FragmentsModels/src/virtual-model/virtual-controllers/raycast-controller.ts

Lines changed: 261 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import * as THREE from "three";
2-
import { SnappingClass } from "../../model/model-types";
2+
import { CurrentLod, SnappingClass } from "../../model/model-types";
33
import { VirtualBoxController } from "../../bounding-boxes";
44
import { VirtualTilesController, VirtualMeshManager } from "..";
5-
import { TransformHelper, CameraUtils, PlanesUtils } from "../../utils";
5+
import {
6+
TransformHelper,
7+
CameraUtils,
8+
PlanesUtils,
9+
MiscHelper,
10+
} from "../../utils";
611
import { Representation, Sample, Model, Meshes } from "../../../../Schema";
712
import { ItemConfigController } from "./item-config-controller";
813

@@ -124,11 +129,263 @@ export class RaycastController {
124129
if (!lookup) {
125130
return [];
126131
}
127-
const itemIds = lookup.collideFrustum(planes, frustum, fullyInside);
128-
const raycastedItemIds = this.filterVisible(itemIds);
132+
// Always gather the touching superset (fullyInside=false). Both modes then
133+
// narrow-phase on real geometry: a concave item can be fully inside the
134+
// selection even when its AABB is not, and can have its AABB touch the
135+
// selection while no geometry does. The broad-phase box test alone is wrong
136+
// for both.
137+
const itemIds = lookup.collideFrustum(planes, frustum, false);
138+
let raycastedItemIds = this.filterVisible(itemIds);
139+
if (raycastedItemIds.length) {
140+
raycastedItemIds = this.narrowPhaseFrustum(
141+
raycastedItemIds,
142+
frustum,
143+
planes,
144+
fullyInside,
145+
);
146+
}
129147
return this.localIdsFromItemIds(raycastedItemIds);
130148
}
131149

150+
// Filters broad-phase sample candidates by testing their real geometry
151+
// against the selection frustum (+ clipping planes). Mirrors the section/clip
152+
// generator: builds a transient BVH per representation (cached for this call)
153+
// and shapecasts it; the frustum is moved into each sample's local space so
154+
// instanced items that share one local geometry are handled by transform.
155+
// fullyInside === true keeps only items whose geometry is entirely inside;
156+
// false keeps items whose geometry touches the selection.
157+
private narrowPhaseFrustum(
158+
sampleIds: number[],
159+
frustum: THREE.Frustum,
160+
clipPlanes: THREE.Plane[],
161+
fullyInside: boolean,
162+
): number[] {
163+
const worldPlanes =
164+
clipPlanes && clipPlanes.length
165+
? [...frustum.planes, ...clipPlanes]
166+
: frustum.planes;
167+
const geomCache = new Map<number, THREE.BufferGeometry[]>();
168+
const result: number[] = [];
169+
const start = performance.now();
170+
let exceeded = false;
171+
172+
for (const sampleId of sampleIds) {
173+
// If we run out of time budget, keep the remaining broad-phase results
174+
// rather than dropping them (over-select beats losing a selection).
175+
if (exceeded) {
176+
result.push(sampleId);
177+
continue;
178+
}
179+
const box = this._boxes.get(sampleId);
180+
// Fast accept: AABB fully inside the selection volume => all geometry is
181+
// inside too, so it is selected in both modes.
182+
if (CameraUtils.isIncluded(box, worldPlanes)) {
183+
result.push(sampleId);
184+
continue;
185+
}
186+
if (
187+
this.sampleMatchesFrustum(
188+
sampleId,
189+
frustum,
190+
clipPlanes,
191+
fullyInside,
192+
geomCache,
193+
)
194+
) {
195+
result.push(sampleId);
196+
}
197+
exceeded = this.isTimeExceeded(start);
198+
}
199+
200+
for (const [, geometries] of geomCache) {
201+
for (const geometry of geometries) {
202+
// @ts-ignore three-mesh-bvh prototype augmentation
203+
geometry.disposeBoundsTree?.();
204+
geometry.dispose();
205+
}
206+
}
207+
return result;
208+
}
209+
210+
private sampleMatchesFrustum(
211+
sampleId: number,
212+
frustum: THREE.Frustum,
213+
clipPlanes: THREE.Plane[],
214+
fullyInside: boolean,
215+
geomCache: Map<number, THREE.BufferGeometry[]>,
216+
): boolean {
217+
const sample = this._meshes.samples(sampleId, this._temp.sample);
218+
if (!sample) return !fullyInside;
219+
const reprId = sample.representation();
220+
221+
// Resolve the sample's world transform before building geometry (the build
222+
// path reuses shared scratch state).
223+
TransformHelper.get(this._temp.sample, this._meshes, this._temp.m1);
224+
this._temp.m2.copy(this._temp.m1).invert();
225+
226+
let geometries = geomCache.get(reprId);
227+
if (!geometries) {
228+
geometries = this.buildSampleGeometries(sampleId);
229+
geomCache.set(reprId, geometries);
230+
}
231+
// No triangle geometry (e.g. curve-only representations). For crossing keep
232+
// the broad-phase result; for window we cannot confirm full containment, so
233+
// drop it rather than over-select.
234+
if (geometries.length === 0) return !fullyInside;
235+
236+
const localPlanes = this.toLocalPlanes(frustum, clipPlanes, this._temp.m2);
237+
238+
if (fullyInside) {
239+
// Window: every vertex must be inside the frustum (exact for a convex
240+
// frustum, since all geometry is a convex combination of its vertices).
241+
for (const geometry of geometries) {
242+
if (!this.geometryFullyInside(geometry, localPlanes)) {
243+
return false;
244+
}
245+
}
246+
return true;
247+
}
248+
249+
// Crossing: any triangle intersecting the frustum is enough.
250+
for (const geometry of geometries) {
251+
if (this.geometryIntersectsPlanes(geometry, localPlanes)) {
252+
return true;
253+
}
254+
}
255+
return false;
256+
}
257+
258+
// True only if every vertex of the geometry is inside every plane.
259+
private geometryFullyInside(
260+
geometry: THREE.BufferGeometry,
261+
planes: THREE.Plane[],
262+
): boolean {
263+
const position = geometry.getAttribute("position");
264+
const array = position.array as ArrayLike<number>;
265+
const vertex = this._temp.v1;
266+
for (let i = 0; i < array.length; i += 3) {
267+
vertex.set(array[i], array[i + 1], array[i + 2]);
268+
for (const plane of planes) {
269+
if (plane.distanceToPoint(vertex) < 0) {
270+
return false;
271+
}
272+
}
273+
}
274+
return true;
275+
}
276+
277+
private buildSampleGeometries(sampleId: number): THREE.BufferGeometry[] {
278+
const geometries: THREE.BufferGeometry[] = [];
279+
const sampleGeom = this._tiles.fetchSample(sampleId, CurrentLod.GEOMETRY);
280+
MiscHelper.forEach(sampleGeom.geometries, (geometryData: any) => {
281+
if (!geometryData.indexBuffer || !geometryData.positionBuffer) {
282+
return;
283+
}
284+
const geometry = new THREE.BufferGeometry();
285+
geometry.setIndex(Array.from(geometryData.indexBuffer));
286+
geometry.setAttribute(
287+
"position",
288+
new THREE.BufferAttribute(geometryData.positionBuffer, 3),
289+
);
290+
// @ts-ignore three-mesh-bvh prototype augmentation
291+
geometry.computeBoundsTree();
292+
geometries.push(geometry);
293+
});
294+
return geometries;
295+
}
296+
297+
private toLocalPlanes(
298+
frustum: THREE.Frustum,
299+
clipPlanes: THREE.Plane[],
300+
toLocal: THREE.Matrix4,
301+
): THREE.Plane[] {
302+
const local: THREE.Plane[] = [];
303+
this.pushLocalPlanes(frustum.planes, toLocal, local);
304+
if (clipPlanes) {
305+
this.pushLocalPlanes(clipPlanes, toLocal, local);
306+
}
307+
return local;
308+
}
309+
310+
private pushLocalPlanes(
311+
planes: THREE.Plane[],
312+
toLocal: THREE.Matrix4,
313+
out: THREE.Plane[],
314+
): void {
315+
for (const plane of planes) {
316+
// The perspective selection frustum's far plane has constant = Infinity
317+
// (it extends to infinity). Transforming it produces a NaN plane (its
318+
// coplanar point is at infinity), which would clip away everything. It
319+
// constrains nothing, so skip any non-finite plane.
320+
if (!Number.isFinite(plane.constant)) {
321+
continue;
322+
}
323+
out.push(new THREE.Plane().copy(plane).applyMatrix4(toLocal));
324+
}
325+
}
326+
327+
private geometryIntersectsPlanes(
328+
geometry: THREE.BufferGeometry,
329+
planes: THREE.Plane[],
330+
): boolean {
331+
let hit = false;
332+
// @ts-ignore three-mesh-bvh prototype augmentation
333+
geometry.boundsTree.shapecast({
334+
intersectsBounds: (box: THREE.Box3) => CameraUtils.collides(box, planes),
335+
intersectsTriangle: (tri: THREE.Triangle) => {
336+
if (this.triangleIntersectsFrustum(tri, planes)) {
337+
hit = true;
338+
return true; // stop traversal on first intersecting triangle
339+
}
340+
return false;
341+
},
342+
});
343+
return hit;
344+
}
345+
346+
// Exact triangle-vs-frustum test by clipping. The frustum is the intersection
347+
// of its plane half-spaces, so clipping the triangle polygon against every
348+
// plane (Sutherland-Hodgman) yields exactly triangle ∩ frustum. Non-empty
349+
// result means they really intersect. This avoids the false positives a
350+
// "not fully outside any single plane" test gives on large triangles.
351+
private triangleIntersectsFrustum(
352+
tri: THREE.Triangle,
353+
planes: THREE.Plane[],
354+
): boolean {
355+
let poly: THREE.Vector3[] = [tri.a, tri.b, tri.c];
356+
for (const plane of planes) {
357+
poly = this.clipPolygonByPlane(poly, plane);
358+
if (poly.length === 0) {
359+
return false;
360+
}
361+
}
362+
return poly.length > 0;
363+
}
364+
365+
// Clips a convex polygon to the inside (distance >= 0) half-space of a plane.
366+
private clipPolygonByPlane(
367+
poly: THREE.Vector3[],
368+
plane: THREE.Plane,
369+
): THREE.Vector3[] {
370+
const out: THREE.Vector3[] = [];
371+
const count = poly.length;
372+
for (let i = 0; i < count; i++) {
373+
const current = poly[i];
374+
const next = poly[(i + 1) % count];
375+
const dCurrent = plane.distanceToPoint(current);
376+
const dNext = plane.distanceToPoint(next);
377+
if (dCurrent >= 0) {
378+
out.push(current);
379+
}
380+
// Edge crosses the plane: add the intersection point.
381+
if (dCurrent >= 0 !== dNext >= 0) {
382+
const t = dCurrent / (dCurrent - dNext);
383+
out.push(new THREE.Vector3().lerpVectors(current, next, t));
384+
}
385+
}
386+
return out;
387+
}
388+
132389
private snapCastEdges(data: CastData, snaps: Snap[]) {
133390
const results: any[] = [];
134391
const pointSnap = snaps.includes(SnappingClass.POINT);

0 commit comments

Comments
 (0)