cylindrical frustum hit testing

This commit is contained in:
azykov@mail.ru 2026-05-21 18:39:42 +03:00
parent 0a750606ac
commit c8fdeafe3f
No known key found for this signature in database
12 changed files with 543 additions and 102 deletions

View File

@ -90,7 +90,7 @@ export const ThreeView = function (props: ThreeViewProps) {
const { scene, camera } = setupScene({ w: W, h: H });
cameraRef.current = camera;
props.sceneHelper.initialize(scene);
props.sceneHelper.initialize(scene, camera);
const handleWindowResize = () => {
const w = container.clientWidth;
@ -102,6 +102,13 @@ export const ThreeView = function (props: ThreeViewProps) {
window.addEventListener("resize", handleWindowResize);
handleHover = (e: InteractionMouseEventArgs) => {
const ht = props.sceneHelper.hitTest(
e.position,
e.screenSize,
);
console.log(JSON.stringify(ht.map((h) => h.object.userData)));
const hitTest = HitTestFactory.hitTest(
props.sceneHelper,
new THREE.Vector2(e.position.x, e.position.y),

View File

@ -1,6 +1,6 @@
import { useSceneHelper } from "../helpers/hooks/useSceneHelper";
import { state } from "../state/root";
import { ThreeView, type ThreeViewEventArgs, type ThreeViewMouseEventArgs } from "./ThreeVIew";
import { ThreeView, type ThreeViewEventArgs, type ThreeViewMouseEventArgs } from "./ThreeView";
export const Viewport = function () {
@ -9,10 +9,10 @@ export const Viewport = function () {
function handleMouseMove(e: ThreeViewMouseEventArgs) {
state.setHitTest(e.hitTest);
sceneHelper.clearPoints();
sceneHelper.clear();
if (e.hitTest.objects.length) {
e.hitTest.objects.forEach((obj) => {
sceneHelper.setPoint(obj.object.uuid, obj.point);
sceneHelper.showPoint(obj.object.uuid, obj.point);
})
// console.log(e.position);
// console.log(e.hitTest.objects.map((o) => o));
@ -25,6 +25,8 @@ export const Viewport = function () {
// console.log(hoveredFaceIds);
sceneHelper.setSelection(hoveredFaceIds);
sceneHelper.showMouseFrustum();
}
function handleDispose(e: ThreeViewEventArgs): void {

16
client/src/helpers/bvh.ts Normal file
View File

@ -0,0 +1,16 @@
import * as THREE from 'three';
import {
computeBoundsTree, disposeBoundsTree,
computeBatchedBoundsTree, disposeBatchedBoundsTree, acceleratedRaycast,
} from 'three-mesh-bvh';
// Add the extension functions
THREE.BufferGeometry.prototype.computeBoundsTree = computeBoundsTree;
THREE.BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree;
THREE.Mesh.prototype.raycast = acceleratedRaycast;
THREE.BatchedMesh.prototype.computeBoundsTree = computeBatchedBoundsTree;
THREE.BatchedMesh.prototype.disposeBoundsTree = disposeBatchedBoundsTree;
THREE.BatchedMesh.prototype.raycast = acceleratedRaycast;
export const dummy = {};

View File

@ -0,0 +1,84 @@
import * as THREE from 'three';
export class CircularFrustum {
public readonly apex = new THREE.Vector3(); // Cone apex (camera position)
public readonly axisNormalized = new THREE.Vector3(); // normalized (unit) axis direction (camera → screen point)
public halfAngle: number = 0; // Half-angle of the cone in radians
public cosHalfAngle: number = 0; // cos(halfAngle) — cached
public sinHalfAngle: number = 0; // sin(halfAngle) — cached
/**
* Build a CircularFrustum from a screen-space point and pixel threshold.
*
* @param screenPointNormalized point in normalised device coordinates (NDC) [-1, 1]
* @param camera PerspectiveCamera or OrthographicCamera
* @param thresholdPx Screen-space radius in pixels
* @param screenSize renderer viewport size in pixels (e.g. from viewport.getBoundingClientRect())
*/
public setFromScreenPoint(
screenPointNormalized: THREE.Vector2Like, // in [-1, 1] space
screenSize: THREE.Vector2Like,
camera: THREE.PerspectiveCamera | THREE.OrthographicCamera,
thresholdPx: number, // in screen pixels
): void {
// cone apex
camera.getWorldPosition(this.apex);
// cone central ray (axis)
const centerNDC = new THREE.Vector3(screenPointNormalized.x, screenPointNormalized.y, 0.5); // center
const centerWorld = centerNDC.clone().unproject(camera);
this.axisNormalized.copy(centerWorld.sub(this.apex)).normalize();
// frustum near circle bottommost point
const bottomNDC = centerNDC.clone().add({ x: 0, y: (thresholdPx / screenSize.y) * 2, z: 0 });
const bottomWorld = bottomNDC.clone().unproject(camera);
const downAxis = bottomWorld.sub(this.apex).normalize();
// cone half-angle
const halfAngle = Math.acos(THREE.MathUtils.clamp(this.axisNormalized.dot(downAxis), -1, 1));
this.set(this.apex, this.axisNormalized, halfAngle);
// console.log({
// screenPointNormalized,
// screenSize,
// thresholdPx,
// centerNDC: centerNDC.toArray(),
// centerWorld: centerWorld.toArray(),
// bottomNDC: bottomNDC.toArray(),
// bottomWorld: bottomWorld.toArray(),
// downAxis: downAxis.toArray(),
// apex: this.apex.toArray(),
// axisNormalized: this.axisNormalized,
// halfAngle,
// });
// console.log(this.apex.toArray());
}
public getCircleAtDepth(depth: number): { center: THREE.Vector3Like, radius: number } {
const center = this.apex.clone().addScaledVector(this.axisNormalized, depth);
const radius = Math.tan(this.halfAngle) * depth;
return {
center,
radius,
}
}
private set(apex: THREE.Vector3Like, axisNormalized: THREE.Vector3Like, halfAngle: number) {
this.apex.copy(apex);
this.axisNormalized.copy(axisNormalized);
this.halfAngle = halfAngle;
this.cosHalfAngle = Math.cos(this.halfAngle);
this.sinHalfAngle = Math.sin(this.halfAngle);
}
public transform(matrix: THREE.Matrix4): CircularFrustum {
const transformed = new CircularFrustum();
const localApex = this.apex.clone().applyMatrix4(matrix);
const localAxis = this.axisNormalized.clone().transformDirection(matrix).normalize();
transformed.set(localApex, localAxis, this.halfAngle);
return transformed;
}
}

View File

@ -0,0 +1,230 @@
import * as THREE from 'three';
import { CONTAINED, ExtendedTriangle, INTERSECTED, NOT_INTERSECTED } from 'three-mesh-bvh';
import { CircularFrustum } from './circularFrustum';
export type FrustumHitResult = {
object: THREE.Object3D;
point: THREE.Vector3; // world-space closest hit point
depth: number; // depth along frustum axis
triangle?: ExtendedTriangle; // only present when BVH was used
}
export type CircularFrustumIntersectionOptions = {
findAll?: boolean; // defaults to false
filter?: (object: THREE.Object3D) => boolean; // defaults to every object
}
export type IntersectionResult = 'NOT_INTERSECTED' | 'INTERSECTED' | 'CONTAINED';
export function intersectionResultToBvh(value: IntersectionResult): typeof NOT_INTERSECTED | typeof INTERSECTED | typeof CONTAINED {
switch (value) {
case 'NOT_INTERSECTED':
return NOT_INTERSECTED;
case 'INTERSECTED':
return INTERSECTED;
case 'CONTAINED':
return CONTAINED;
}
}
export class CircularFrustumIntersection {
public readonly frustum: CircularFrustum;
constructor(frustum: CircularFrustum) {
this.frustum = frustum;
}
public insersectsSphere(sphere: THREE.Sphere): 'NOT_INTERSECTED' | number {
return CircularFrustumIntersection.insersectsSphere(sphere, this.frustum);
}
/**
* sphere and frustum both should be in the same coordinate space (local or world)
*
* Uses the Barros / van den Bergen separating-axis approach:
* - Check whether the sphere centre is inside the cone (fast path)
* - Otherwise check the distance from the sphere centre to the
* nearest cone surface (lateral face + apex cap)
*
* @returns axial depth of sphere center or NOT_INTERSECTED
*/
public static insersectsSphere(sphere: THREE.Sphere, frustum: CircularFrustum): 'NOT_INTERSECTED' | number {
const toCenter = sphere.center.clone().sub(frustum.apex);
const axialDist = toCenter.dot(frustum.axisNormalized);
if (axialDist + sphere.radius < 0) // behind the apex entirely
return 'NOT_INTERSECTED';
const lateralDist = toCenter.clone().addScaledVector(frustum.axisNormalized, -axialDist).length();
const distToConeEdge = lateralDist * frustum.cosHalfAngle - axialDist * frustum.sinHalfAngle;
if (distToConeEdge > sphere.radius) // fully outside lateral surface
return 'NOT_INTERSECTED';
return axialDist;
}
public intersectsBox(box: THREE.Box3): IntersectionResult {
return CircularFrustumIntersection.intersectsBox(box, this.frustum);
}
// box and this.frustum both should be in the same coordinate space (local or world)
public static intersectsBox(box: THREE.Box3, frustum: CircularFrustum): IntersectionResult {
const sphere = new THREE.Sphere();
box.getBoundingSphere(sphere);
if (CircularFrustumIntersection.insersectsSphere(sphere, frustum) === 'NOT_INTERSECTED')
return 'NOT_INTERSECTED';
// Check if all 8 corners are inside — if so, CONTAINED
const corners = Array(8)
.fill(0)
.map((_, i) => new THREE.Vector3(
i & 1 ? box.max.x : box.min.x,
i & 2 ? box.max.y : box.min.y,
i & 4 ? box.max.z : box.min.z,
));
const allInside = corners.every((c) => CircularFrustumIntersection.pointAxialDepth(c, frustum) !== 'NOT_INTERSECTED');
return allInside
? 'CONTAINED'
: 'INTERSECTED';
}
public pointAxialDepth(point: THREE.Vector3): 'NOT_INTERSECTED' | number {
return CircularFrustumIntersection.pointAxialDepth(point, this.frustum);
}
public static pointAxialDepth(point: THREE.Vector3, frustum: CircularFrustum): 'NOT_INTERSECTED' | number {
const toPoint = point.clone().sub(frustum.apex);
const dist = toPoint.length();
if (dist === 0)
return 0;
const axialDist = toPoint.dot(frustum.axisNormalized);
const cosAngle = axialDist / dist;
return cosAngle >= frustum.cosHalfAngle
? axialDist
: 'NOT_INTERSECTED';
}
// ─── Local frustum construction ──────────────────────────────────────────────
/**
* Transform a world-space CircularFrustum into an object's local space.
* Note: halfAngle is only preserved exactly under uniform scale.
*/
private toObjectLocalSpace(invWorldMatrix: THREE.Matrix4): CircularFrustum {
return this.frustum.transform(invWorldMatrix);
}
public intersectMesh(
mesh: THREE.Mesh,
findAll: boolean,
): FrustumHitResult[] {
const geometry = mesh.geometry;
if (!geometry)
return [];
const matrix = mesh.matrixWorld;
const matrixInverted = matrix.clone().invert(); // world -> local matrix
const worldFrustum = this.frustum;
const localFrustum = this.toObjectLocalSpace(matrixInverted);
// quick check for bounding sphere
if (!geometry.boundingSphere)
geometry.computeBoundingSphere();
const boundingSphere = geometry.boundingSphere!.clone().applyMatrix4(matrix);
if (this.insersectsSphere(boundingSphere) === 'NOT_INTERSECTED')
return [];
const results: FrustumHitResult[] = [];
if (!geometry.boundsTree)
geometry.computeBoundsTree();
const bvh = geometry.boundsTree;
if (!bvh)
throw new Error('No BVH found for a mesh');
bvh.shapecast({
intersectsBounds: (box: THREE.Box3) => intersectionResultToBvh(CircularFrustumIntersection.intersectsBox(box, localFrustum)),
intersectsTriangle: (tri: ExtendedTriangle, _index: number, contained: boolean) => {
// If the whole node was CONTAINED, every triangle is inside — fast path
if (contained) {
const worldPoint = tri.a.clone().applyMatrix4(mesh.matrixWorld);
const depth = worldFrustum.axisNormalized.dot(worldPoint.clone().sub(worldFrustum.apex));
results.push({ object: mesh, point: worldPoint, depth, triangle: tri });
return !findAll; // stop if we only need first hit
}
// Test all three vertices; take the closest that's inside
let bestDepth = Infinity;
let bestLocal: THREE.Vector3 | null = null;
for (const v of [tri.a, tri.b, tri.c] as THREE.Vector3[]) {
const d = CircularFrustumIntersection.pointAxialDepth(v, localFrustum);
if (d !== 'NOT_INTERSECTED') {
if (d < bestDepth) {
bestDepth = d;
bestLocal = v;
}
}
}
// Also test closest point on triangle to the frustum axis ray
const ray = new THREE.Ray(localFrustum.apex, localFrustum.axisNormalized);
const closest = new THREE.Vector3();
tri.closestPointToPoint(ray.origin, closest); // ExtendedTriangle has this
const d = CircularFrustumIntersection.pointAxialDepth(closest, localFrustum);
if (d !== 'NOT_INTERSECTED') {
if (d < bestDepth) {
bestDepth = d;
bestLocal = closest;
}
}
if (bestLocal) {
const worldPoint = bestLocal.clone().applyMatrix4(mesh.matrixWorld);
const worldDepth = worldFrustum.axisNormalized.dot(worldPoint.clone().sub(worldFrustum.apex));
results.push({ object: mesh, point: worldPoint, depth: worldDepth, triangle: tri });
return !findAll;
}
return false;
},
});
// } else {
// // ── Fallback: bounding box only ───────────────────────────────────
// if (!geometry.boundingBox)
// geometry.computeBoundingBox();
// const worldBox = geometry.boundingBox!.clone().applyMatrix4(mesh.matrixWorld);
// const boxResult = this.intersectsBox(worldBox.clone());
// if (boxResult !== NOT_INTERSECTED) {
// const center = new THREE.Vector3();
// worldBox.getCenter(center);
// const depth = this.frustum.axis.dot(center.clone().sub(this.frustum.apex));
// results.push({ object: mesh, point: center, depth });
// }
// }
return results;
}
public intersectObject(
obj: THREE.Object3D,
options: CircularFrustumIntersectionOptions = {},
): FrustumHitResult[] {
const results: FrustumHitResult[] = [];
obj.traverseVisible((object) => {
if (options.filter && !options.filter(object))
return;
if (!(object instanceof THREE.Mesh))
return;
results.push(...this.intersectMesh(object, !!options.findAll));
});
// sort closest first
results.sort((a, b) => a.depth - b.depth);
return results;
}
}

View File

@ -1,19 +1,6 @@
import * as THREE from 'three';
import {
computeBoundsTree, disposeBoundsTree,
computeBatchedBoundsTree, disposeBatchedBoundsTree, acceleratedRaycast,
} from 'three-mesh-bvh';
import type { SceneSync } from '../layers/sceneSync';
import type { SceneHelper } from './sceneHelper';
// Add the extension functions
THREE.BufferGeometry.prototype.computeBoundsTree = computeBoundsTree;
THREE.BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree;
THREE.Mesh.prototype.raycast = acceleratedRaycast;
THREE.BatchedMesh.prototype.computeBoundsTree = computeBatchedBoundsTree;
THREE.BatchedMesh.prototype.disposeBoundsTree = disposeBatchedBoundsTree;
THREE.BatchedMesh.prototype.raycast = acceleratedRaycast;
import './bvh';
export type HitTest = {
objects: THREE.Intersection<THREE.Object3D>[];

View File

@ -1,11 +1,13 @@
import { useEffect, type RefObject } from "react";
import * as THREE from "three";
import { normalizeScreenPosition } from "../normalizeScreenPosition";
const CLICK_THRESHOLD = 2; // px
export type InteractionMouseEventArgs = {
position: { x: number, y: number },
pixelSize: { x: number, y: number },
position: THREE.Vector2Like,
screenSize: THREE.Vector2Like,
pixelSize: THREE.Vector2Like,
};
export type UseInteractionOptions = {
@ -57,14 +59,8 @@ export function useInteraction(
const onMouseUp = (e: MouseEvent) => {
isDragging = false;
if ((e.clientX - startX < CLICK_THRESHOLD) && (e.clientY - startY < CLICK_THRESHOLD)) {
const rect = target.getBoundingClientRect();
const position = {
x: ((e.clientX - rect.left) / rect.width) * 2 - 1,
y: -((e.clientY - rect.top) / rect.height) * 2 + 1,
};
options.onMouseClick?.({ position, pixelSize: { x: 2 / rect.width, y: 2 / rect.height } });
}
if ((e.clientX - startX < CLICK_THRESHOLD) && (e.clientY - startY < CLICK_THRESHOLD))
options.onMouseClick?.(normalizeScreenPosition({ x: e.clientX, y: e.clientY }, target));
};
const onMouseMove = (e: MouseEvent) => {
@ -84,12 +80,7 @@ export function useInteraction(
};
const onHover = (e: MouseEvent) => {
const rect = target.getBoundingClientRect();
const position = {
x: ((e.clientX - rect.left) / rect.width) * 2 - 1,
y: -((e.clientY - rect.top) / rect.height) * 2 + 1,
};
options.onMouseMove?.({ position, pixelSize: { x: 2 / rect.width, y: 2 / rect.height } });
options.onMouseMove?.(normalizeScreenPosition({ x: e.clientX, y: e.clientY }, target));
};
const onContextMenu = (e: Event) => e.preventDefault();

View File

@ -0,0 +1,27 @@
import type { Vector2Like } from "three";
export type NormalizedScreenPosition = {
position: Vector2Like,
screenSize: Vector2Like,
pixelSize: Vector2Like,
}
export function normalizeScreenPosition(pos: Vector2Like, viewport: DOMRect | HTMLElement): NormalizedScreenPosition {
let rect: DOMRect;
if (viewport instanceof DOMRect)
rect = viewport;
else
rect = viewport.getBoundingClientRect();
return {
position: {
x: ((pos.x - rect.left) / rect.width) * 2 - 1,
y: -((pos.y - rect.top) / rect.height) * 2 + 1,
},
screenSize: { x: rect.width, y: rect.height },
pixelSize: {
x: 2 / rect.width,
y: 2 / rect.height,
}
};
}

View File

@ -1,53 +0,0 @@
import * as THREE from "three";
export type Point3d = {
position: THREE.Vector3,
mesh: THREE.Mesh,
}
export class Point3dHelper {
private scene: THREE.Scene;
private readonly baseMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
private readonly markers: Record<string, Point3d> = {};
constructor(scene: THREE.Scene) {
this.scene = scene;
}
private ensure(id: string): Point3d {
if (!this.markers[id]) {
this.markers[id] = {
position: new THREE.Vector3(),
mesh: new THREE.Mesh(
new THREE.SphereGeometry(0.1, 8, 8),
this.baseMaterial,
),
};
this.scene.add(this.markers[id].mesh);
}
return this.markers[id];
}
private disposePoint(id: string) {
const point = this.markers[id];
if (point) {
this.scene.remove(point.mesh);
point.mesh.geometry.dispose();
delete (this.markers[id]);
}
}
public dispose() {
for (const id in this.markers)
this.disposePoint(id);
}
public set(id: string, position: THREE.Vector3Like) {
const point = this.ensure(id);
point.position.copy(position);
point.mesh.position.copy(position);
}
}

View File

@ -1,26 +1,60 @@
import type { Object3D, Object3DEventMap, Scene, Vector3 } from "three";
import { Point3dHelper } from "./point3dHelper";
import type { ColorRepresentation, Object3D, Object3DEventMap, OrthographicCamera, PerspectiveCamera, Scene, Vector2Like, Vector3, Vector3Like } from "three";
import { SceneSync } from "../layers/sceneSync";
import { GeometryCache } from "../layers/geometryCache";
import type { Id } from "../types";
import { CircularFrustumIntersection, type FrustumHitResult } from "./circularFrustumIntersect";
import { CircularFrustum } from "./circularFrustum";
import './bvh';
import { VolatileGeometryHelper, type VolatileGeometryOptions } from "./volatileGeometryHelper";
export class SceneHelper {
private sync: SceneSync | undefined;
private pointHelper: Point3dHelper | undefined;
private hints: VolatileGeometryHelper | undefined;
private camera: PerspectiveCamera | OrthographicCamera | undefined;
constructor() {
private mouseFrustum = new CircularFrustumIntersection(new CircularFrustum());
}
public initialize(scene: Scene) {
this.pointHelper = new Point3dHelper(scene);
public initialize(
scene: Scene,
camera: PerspectiveCamera | OrthographicCamera,
) {
this.hints = new VolatileGeometryHelper(scene, camera);
this.camera = camera;
this.sync = new SceneSync(scene, new GeometryCache());
this.sync.addWholeModel();
}
public buildMouseFrustum(
mouseNormalized: Vector2Like,
screenSize: Vector2Like,
radius: number = 15,
): void {
if (!this.camera)
throw new Error('Camera is not initialized');
this.mouseFrustum.frustum.setFromScreenPoint(
mouseNormalized,
screenSize,
this.camera,
radius,
);
}
public hitTest(
mouseNormalized: Vector2Like,
screenSize: Vector2Like,
): FrustumHitResult[] {
this.buildMouseFrustum(mouseNormalized, screenSize);
const result: FrustumHitResult[] = [];
for (const object of this.objects)
result.push(...this.mouseFrustum.intersectObject(object));
return result;
}
public setSelection(faceIds: Id[]) {
this.sync?.setSelected(faceIds);
}
@ -30,17 +64,40 @@ export class SceneHelper {
return this.sync?.meshes ?? [];
}
public setPoint(id: string, point: Vector3) {
this.pointHelper?.set(id, point);
public showHint(id: string, position: Vector3Like, options: VolatileGeometryOptions) {
this.hints?.set(id, position, options);
}
public clearPoints() {
this.pointHelper?.dispose();
public showPoint(id: string, position: Vector3Like, size: number = 0.05, color: ColorRepresentation = 0xffffff) {
this.hints?.set(id, position, { kind: "point", size, color });
}
public showCircle(id: string, position: Vector3Like, radius: number, thickness: number = 0.1, color: ColorRepresentation = 0xffffff) {
this.hints?.set(id, position, { kind: "circle", radius, thickness, color });
}
public showMouseFrustum() {
if (!this.camera)
throw new Error('Camera is not initialized');
const frustum = this.mouseFrustum.frustum;
const cameraDepth = this.camera.far - this.camera.near;
const nearDepth = this.camera.near + cameraDepth * 0.01;
const farDepth = this.camera.far - cameraDepth * 0.01;
const near = frustum.getCircleAtDepth(nearDepth);
const far = frustum.getCircleAtDepth(farDepth);
this.showCircle('hittest_near', near.center, near.radius, 0.05);
this.showCircle('hittest_far', far.center, far.radius, 0.1, 'red');
}
public clear() {
this.hints?.dispose();
}
public dispose() {
this.sync?.dispose();
this.clearPoints();
this.clear();
}
}

View File

@ -0,0 +1,92 @@
import * as THREE from "three";
export type VolatileGeometry = {
mesh: THREE.Mesh,
}
export type VolatileGeometryBaseOptions = {
color: THREE.ColorRepresentation,
}
export type VolatileGeometryPointOptions = VolatileGeometryBaseOptions & {
kind: 'point',
size: number,
}
export type VolatileGeometryCicleOptions = VolatileGeometryBaseOptions & {
kind: 'circle',
radius: number,
thickness: number,
}
export type VolatileGeometryOptions = VolatileGeometryPointOptions | VolatileGeometryCicleOptions;
export class VolatileGeometryHelper {
private scene: THREE.Scene;
private camera: THREE.PerspectiveCamera | THREE.OrthographicCamera;
private readonly baseMaterial = new THREE.MeshBasicMaterial({ color: 'red' });
private readonly markers: Record<string, VolatileGeometry> = {};
constructor(scene: THREE.Scene, camera: THREE.PerspectiveCamera | THREE.OrthographicCamera) {
this.scene = scene;
this.camera = camera;
}
private createGeometry(options: VolatileGeometryOptions): THREE.BufferGeometry {
switch (options.kind) {
case 'point':
return new THREE.SphereGeometry(options.size);
case 'circle':
return new THREE.TorusGeometry(options.radius, options.thickness * options.radius);
default:
throw new Error('Unknown volatile geometry type');
}
}
private ensure(id: string, options: VolatileGeometryOptions): VolatileGeometry {
if (!this.markers[id]) {
const material = this.baseMaterial.clone();
material.color.set(options.color);
this.markers[id] = {
mesh: new THREE.Mesh(
this.createGeometry(options),
material,
),
};
this.scene.add(this.markers[id].mesh);
}
return this.markers[id];
}
private disposeGeometry(id: string) {
const point = this.markers[id];
if (point) {
this.scene.remove(point.mesh);
point.mesh.geometry.dispose();
delete (this.markers[id]);
}
}
public dispose() {
for (const id in this.markers)
this.disposeGeometry(id);
}
public set(id: string, position: THREE.Vector3Like, options: VolatileGeometryOptions) {
const point = this.ensure(id, options);
// additional actions
switch (options.kind) {
case 'circle':
point.mesh.lookAt(this.camera.position);
break;
}
point.mesh.position.copy(position);
}
}

View File

@ -38,8 +38,9 @@ export class SceneSync {
const meshes = Geometry
.tessellateSolid(id)
.map(meshToDto);
for (const mesh of meshes)
this.addSolid(mesh);
this.addSolid(meshes[2]);
// for (const mesh of meshes)
// this.addSolid(mesh);
}
}