|
1 | 1 | import * as THREE from "three"; |
2 | | -import { SnappingClass } from "../../model/model-types"; |
| 2 | +import { CurrentLod, SnappingClass } from "../../model/model-types"; |
3 | 3 | import { VirtualBoxController } from "../../bounding-boxes"; |
4 | 4 | import { VirtualTilesController, VirtualMeshManager } from ".."; |
5 | | -import { TransformHelper, CameraUtils, PlanesUtils } from "../../utils"; |
| 5 | +import { |
| 6 | + TransformHelper, |
| 7 | + CameraUtils, |
| 8 | + PlanesUtils, |
| 9 | + MiscHelper, |
| 10 | +} from "../../utils"; |
6 | 11 | import { Representation, Sample, Model, Meshes } from "../../../../Schema"; |
7 | 12 | import { ItemConfigController } from "./item-config-controller"; |
8 | 13 |
|
@@ -124,11 +129,263 @@ export class RaycastController { |
124 | 129 | if (!lookup) { |
125 | 130 | return []; |
126 | 131 | } |
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 | + } |
129 | 147 | return this.localIdsFromItemIds(raycastedItemIds); |
130 | 148 | } |
131 | 149 |
|
| 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 | + |
132 | 389 | private snapCastEdges(data: CastData, snaps: Snap[]) { |
133 | 390 | const results: any[] = []; |
134 | 391 | const pointSnap = snaps.includes(SnappingClass.POINT); |
|
0 commit comments