From 21c48e44ee8789e409b97d4113d7100865f05dd0 Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Mon, 22 Jun 2026 14:22:15 +0300 Subject: [PATCH] object selection --- .../components/layers/LayeredThreeView.tsx | 5 +- .../layers/ObjectGeometriesLayer.tsx | 22 ++++-- .../layers/SceneEditor/ObjectClickTarget.tsx | 44 +++++++++++ .../SceneEditorObjectSelectionLayer.tsx | 78 +++++++++++++++++++ .../SceneEditor/SelectionBoundingBox.tsx | 44 +++++++++++ .../src/components/layers/StatsLayer.tsx | 15 ++-- webclient/src/rendering/ObjectGeometryList.ts | 78 +++++++++---------- .../src/state/viewLayers/objectSelection.ts | 28 +++++++ .../state/viewLayers/objectTypeGeometry.ts | 13 +++- webclient/src/state/viewState.ts | 2 +- 10 files changed, 267 insertions(+), 62 deletions(-) create mode 100644 webclient/src/components/layers/SceneEditor/ObjectClickTarget.tsx create mode 100644 webclient/src/components/layers/SceneEditor/SceneEditorObjectSelectionLayer.tsx create mode 100644 webclient/src/components/layers/SceneEditor/SelectionBoundingBox.tsx create mode 100644 webclient/src/state/viewLayers/objectSelection.ts diff --git a/webclient/src/components/layers/LayeredThreeView.tsx b/webclient/src/components/layers/LayeredThreeView.tsx index 7909a56..a294153 100644 --- a/webclient/src/components/layers/LayeredThreeView.tsx +++ b/webclient/src/components/layers/LayeredThreeView.tsx @@ -2,6 +2,7 @@ import { observer } from "mobx-react-lite"; import { ObjectGeometriesLayer } from "./ObjectGeometriesLayer"; import { Canvas } from "@react-three/fiber"; import { SceneEditorCameraLayer } from "./SceneEditor/SceneEditorGameraLayer"; +import { SceneEditorObjectSelectionLayer } from "./SceneEditor/SceneEditorObjectSelectionLayer"; import { LightsLayer } from "./LighsLayer"; import { StatsLayer } from './StatsLayer'; @@ -12,7 +13,9 @@ export const LayeredThreeView = observer(function () { {/* */} - + + + ; diff --git a/webclient/src/components/layers/ObjectGeometriesLayer.tsx b/webclient/src/components/layers/ObjectGeometriesLayer.tsx index 971f7fe..d7e8d76 100644 --- a/webclient/src/components/layers/ObjectGeometriesLayer.tsx +++ b/webclient/src/components/layers/ObjectGeometriesLayer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { createContext, useContext, useEffect, useRef } from "react"; import { useThree } from "@react-three/fiber"; import { ObjectGeometryList } from "../../rendering/ObjectGeometryList"; import { observer } from "mobx-react-lite"; @@ -6,22 +6,32 @@ import { state } from "../../state"; import { useLayerFrame } from "../../utils/hooks/useLayerFrame"; import { measuredRenderTime } from "../../utils/layerTimings"; -export const ObjectGeometriesLayer = observer(measuredRenderTime("ObjectGeometries", function () { - const geometries = state.view.objectGeometries; +export const ObjectGeometryListContext = createContext>( + { current: null }, +); + +export function useObjectGeometryList() { + return useContext(ObjectGeometryListContext); +} + +export const ObjectGeometriesLayer = observer(measuredRenderTime("ObjectGeometries", function ({ children }: { children?: React.ReactNode }) { + const typeGeometries = state.view.objectTypeGeometries; const threeScene = useThree(s => s.scene); const list = useRef(null); useEffect(() => { - const l = new ObjectGeometryList(threeScene, geometries); + const l = new ObjectGeometryList(threeScene, typeGeometries); list.current = l; return () => { l.dispose(); list.current = null; }; - }, [threeScene, geometries]); + }, [threeScene, typeGeometries]); useLayerFrame("ObjectGeometries", () => list.current?.tick()); - return null; + return + {children} + ; })); diff --git a/webclient/src/components/layers/SceneEditor/ObjectClickTarget.tsx b/webclient/src/components/layers/SceneEditor/ObjectClickTarget.tsx new file mode 100644 index 0000000..6ad9430 --- /dev/null +++ b/webclient/src/components/layers/SceneEditor/ObjectClickTarget.tsx @@ -0,0 +1,44 @@ +import { useMemo } from "react"; +import { state } from "../../../state"; +import type { ThreeEvent } from "@react-three/fiber"; +import type { ObjectGeometryViewItem } from "../../../state/viewLayers"; + +export function ObjectClickTarget({ item }: { item: ObjectGeometryViewItem }) { + const obj = item.object; + const bb = item.objectTypeGeometry.boundingBox; + + const size = useMemo(() => { + if (!bb) + return [1, 1, 1] as const; + + return [bb.max[0] - bb.min[0], bb.max[1] - bb.min[1], bb.max[2] - bb.min[2]] as const; + }, [bb]); + + const center = useMemo(() => { + if (!bb) + return [0.5, 0.5, 0.5] as const; + + return [ + (bb.min[0] + bb.max[0]) / 2, + (bb.min[1] + bb.max[1]) / 2, + (bb.min[2] + bb.max[2]) / 2, + ] as const; + }, [bb]); + + const handleClick = (e: ThreeEvent) => { + e.stopPropagation(); + state.worldEditor.setSelectedObject({ id: obj.id, editMode: "translate" }); + }; + + return + + + + + ; +} diff --git a/webclient/src/components/layers/SceneEditor/SceneEditorObjectSelectionLayer.tsx b/webclient/src/components/layers/SceneEditor/SceneEditorObjectSelectionLayer.tsx new file mode 100644 index 0000000..37e91d3 --- /dev/null +++ b/webclient/src/components/layers/SceneEditor/SceneEditorObjectSelectionLayer.tsx @@ -0,0 +1,78 @@ +import { useEffect, useCallback } from "react"; +import { useThree } from "@react-three/fiber"; +import * as THREE from "three"; +import { state } from "../../../state"; +import { observer } from "mobx-react-lite"; +import { SelectionBoundingBox } from "./SelectionBoundingBox"; +import { measuredRenderTime } from "../../../utils/layerTimings"; +import { useObjectGeometryList } from "../ObjectGeometriesLayer"; + +const raycaster = new THREE.Raycaster(); +const pointer = new THREE.Vector2(); + +export const SceneEditorObjectSelectionLayer = observer(measuredRenderTime( + "ObjectSelection", + function () { + const selection = state.view.selectedObject; + const camera = useThree(s => s.camera); + const scene = useThree(s => s.scene); + const gl = useThree(s => s.gl); + const size = useThree(s => s.size); + const geometryListRef = useObjectGeometryList(); + + const handlePointerDown = useCallback((e: PointerEvent) => { + if (e.button !== 0) + return; + + pointer.x = (e.offsetX / size.width) * 2 - 1; + pointer.y = -(e.offsetY / size.height) * 2 + 1; + + raycaster.setFromCamera(pointer, camera); + + const instancedMeshes = scene.children.filter( + (child): child is THREE.InstancedMesh => child instanceof THREE.InstancedMesh, + ); + + const intersections = raycaster.intersectObjects(instancedMeshes, false); + + if (intersections.length === 0) { + state.worldEditor.resetSelection(); + return; + } + + const hit = intersections[0]; + const mesh = hit.object as THREE.InstancedMesh; + const instanceId = hit.instanceId; + + if (instanceId === undefined) { + state.worldEditor.resetSelection(); + return; + } + + const geometryList = geometryListRef.current; + if (!geometryList) + return; + + const objectId = geometryList.getObjectIdByMesh(mesh, instanceId); + if (objectId) { + state.worldEditor.setSelectedObject({ id: objectId, editMode: "translate" }); + } else { + state.worldEditor.resetSelection(); + } + }, [camera, scene, size, geometryListRef]); + + useEffect(() => { + const domElement = gl.domElement; + domElement.addEventListener("pointerdown", handlePointerDown); + return () => { + domElement.removeEventListener("pointerdown", handlePointerDown); + }; + }, [gl, handlePointerDown]); + + return <> + {selection && + + } + ; + }, +)); diff --git a/webclient/src/components/layers/SceneEditor/SelectionBoundingBox.tsx b/webclient/src/components/layers/SceneEditor/SelectionBoundingBox.tsx new file mode 100644 index 0000000..bca730d --- /dev/null +++ b/webclient/src/components/layers/SceneEditor/SelectionBoundingBox.tsx @@ -0,0 +1,44 @@ +import { useMemo } from "react"; +import * as THREE from "three"; +import type { ObjectGeometryViewItem } from "../../../state/viewLayers"; + +export const SelectionBoundingBox = function({ item }: { item: ObjectGeometryViewItem }) { + const obj = item.object; + const bb = item.objectTypeGeometry.boundingBox; + + const geometry = useMemo(() => { + if (!bb) + return undefined; + + const box = new THREE.BoxGeometry( + bb.max[0] - bb.min[0], + bb.max[1] - bb.min[1], + bb.max[2] - bb.min[2], + ); + return new THREE.EdgesGeometry(box); + }, [bb]); + + const center = useMemo(() => { + if (!bb) + return [0.5, 0.5, 0.5] as const; + + return [ + (bb.min[0] + bb.max[0]) / 2, + (bb.min[1] + bb.max[1]) / 2, + (bb.min[2] + bb.max[2]) / 2, + ] as const; + }, [bb]); + + if (!geometry) + return null; + + return + + + + ; +}; diff --git a/webclient/src/components/layers/StatsLayer.tsx b/webclient/src/components/layers/StatsLayer.tsx index fe6d7e8..a539d46 100644 --- a/webclient/src/components/layers/StatsLayer.tsx +++ b/webclient/src/components/layers/StatsLayer.tsx @@ -17,7 +17,7 @@ export const StatsLayer = function () { bottom: "8px", right: "8px", color: "#0f0", - fontSize: "14px", + fontSize: "10px", fontFamily: "monospace", zIndex: "9999", pointerEvents: "none", @@ -50,15 +50,10 @@ export const StatsLayer = function () { for (const [name, entry] of Object.entries(layerTimings)) { const parts: string[] = []; - if (entry.frame.frames > 0) { - parts.push(`frame ${(entry.frame.acc / entry.frame.frames).toFixed(3)}`); - } - if (entry.render.frames > 0) { - parts.push(`render ${(entry.render.acc / entry.render.frames).toFixed(3)}`); - } - if (parts.length > 0) { - lines.push(`${name} ${parts.join(" ")} ms`); - } + parts.push(`frame ${(entry.frame.frames > 0 ? (entry.frame.acc / entry.frame.frames) : 0).toFixed(3)}`); + parts.push(`render ${(entry.render.frames > 0 ? (entry.render.acc / entry.render.frames) : 0).toFixed(3)}`); + lines.push(`${name} ${parts.join(" ")} ms`); + entry.frame.acc = 0; entry.frame.frames = 0; entry.render.acc = 0; diff --git a/webclient/src/rendering/ObjectGeometryList.ts b/webclient/src/rendering/ObjectGeometryList.ts index f220fcc..12d5490 100644 --- a/webclient/src/rendering/ObjectGeometryList.ts +++ b/webclient/src/rendering/ObjectGeometryList.ts @@ -1,37 +1,28 @@ import * as THREE from 'three'; import { MobxReconcilerMap, type ReconcilerMap } from '../utils/reconciler'; -import type { ObjectGeometryViewItem, ObjectTypeGeometryViewItem } from '../state/viewState'; +import type { ObjectTypeGeometryViewItem } from '../state/viewLayers'; export class ObjectGeometryList { - private items: ReconcilerMap; - private readonly typeRefCounts = new Map(); + private items: ReconcilerMap; private readonly matrixObj = new THREE.Object3D(); private readonly _p = new THREE.Vector3(); private readonly _e = new THREE.Euler(); private readonly _q = new THREE.Quaternion(); - constructor(scene: THREE.Scene, geometiries: Iterable) { - this.items = new MobxReconcilerMap({ - reaction: () => geometiries, - keyOf: (item) => item.object.id, + private readonly _meshToObjectIds = new Map(); + + constructor(scene: THREE.Scene, typeGeometries: Iterable) { + this.items = new MobxReconcilerMap({ + reaction: () => typeGeometries, + keyOf: (item) => item.objectType.id, initializeItem: (item) => { - const typeId = item.objectTypeGeometry.objectType.id; - const count = (this.typeRefCounts.get(typeId) ?? 0) + 1; - this.typeRefCounts.set(typeId, count); - if (count === 1) { - for (const mesh of item.objectTypeGeometry.meshes ?? []) - scene.add(mesh); - } + for (const mesh of item.meshes ?? []) + scene.add(mesh); }, disposeItem: (item) => { - const typeId = item.objectTypeGeometry.objectType.id; - const count = (this.typeRefCounts.get(typeId) ?? 1) - 1; - this.typeRefCounts.set(typeId, count); - if (count === 0) { - for (const mesh of item.objectTypeGeometry.meshes ?? []) - scene.remove(mesh); - } + for (const mesh of item.meshes ?? []) + scene.remove(mesh); }, }); } @@ -40,37 +31,40 @@ export class ObjectGeometryList { this.items?.dispose(); } - // Called once per frame — reads MobX observables directly outside any reactive - // context, so no subscriptions are created and no re-renders are triggered. + getObjectIdByMesh(mesh: THREE.InstancedMesh, instanceId: number): string | undefined { + return this._meshToObjectIds.get(mesh)?.[instanceId]; + } + tick(): void { - const touchedObjectTypes: Record = {}; + this._meshToObjectIds.clear(); - this.items.values.forEach((item) => { - const obj = item.object; + this.items.values.forEach((typeGeometry) => { + const instances = typeGeometry.instances; + const meshes = typeGeometry.meshes ?? []; + const objectIds: string[] = []; - this._p.set(...obj.position); - this._e.set(...obj.rotation); - this._q.setFromEuler(this._e); + instances.forEach((item, instanceIdx) => { + const obj = item.object; - const typeId = item.objectTypeGeometry.objectType.id; - if (!touchedObjectTypes[typeId]) - touchedObjectTypes[typeId] = { typeGeometry: item.objectTypeGeometry, instanceCount: 0 }; - const instanceIdx = touchedObjectTypes[typeId].instanceCount++; - - for (const mesh of item.objectTypeGeometry.meshes ?? []) { + this._p.set(...obj.position); + this._e.set(...obj.rotation); + this._q.setFromEuler(this._e); this.matrixObj.position.copy(this._p); this.matrixObj.quaternion.copy(this._q); this.matrixObj.scale.set(...obj.scale); this.matrixObj.updateMatrix(); - mesh.setMatrixAt(instanceIdx, this.matrixObj.matrix); - } - }); - for (const objectType of Object.values(touchedObjectTypes)) { - for (const mesh of objectType.typeGeometry.meshes ?? []) { - mesh.count = objectType.instanceCount; + objectIds[instanceIdx] = obj.id; + + for (const mesh of meshes) + mesh.setMatrixAt(instanceIdx, this.matrixObj.matrix); + }); + + for (const mesh of meshes) { + this._meshToObjectIds.set(mesh, objectIds); + mesh.count = instances.length; mesh.instanceMatrix.needsUpdate = true; } - } + }); } } diff --git a/webclient/src/state/viewLayers/objectSelection.ts b/webclient/src/state/viewLayers/objectSelection.ts new file mode 100644 index 0000000..6a4a498 --- /dev/null +++ b/webclient/src/state/viewLayers/objectSelection.ts @@ -0,0 +1,28 @@ +import { action, makeObservable, observable } from "mobx"; +import { nextObjectEditMode, type ObjectEditMode } from "../worldEditorState"; +import { ViewItem } from "./base"; +import type { ObjectGeometryViewItem } from "./objectGeometry"; + +export class ObjectSelectionViewItem extends ViewItem { + public readonly geometry: ObjectGeometryViewItem; + public mode: ObjectEditMode; + + constructor(geometry: ObjectGeometryViewItem, mode: ObjectEditMode) { + super(); + this.geometry = geometry; + this.mode = mode; + + makeObservable( + this, + { + geometry: observable.ref, + mode: true, + nextMode: action, + }, + ); + } + + public nextMode(): void { + this.mode = nextObjectEditMode(this.mode); + } +} diff --git a/webclient/src/state/viewLayers/objectTypeGeometry.ts b/webclient/src/state/viewLayers/objectTypeGeometry.ts index 1f0d5fd..545a6c0 100644 --- a/webclient/src/state/viewLayers/objectTypeGeometry.ts +++ b/webclient/src/state/viewLayers/objectTypeGeometry.ts @@ -1,14 +1,17 @@ import * as THREE from 'three'; import type { ObjectType, V3, Voxel, VoxelGroup } from "../../types"; import { ViewItem } from "./base"; -import { action, makeObservable, observable, reaction, type IReactionDisposer } from 'mobx'; +import { action, computed, makeObservable, observable, reaction, type IReactionDisposer } from 'mobx'; import { getObjectVoxelGroups } from '../../utils/graphics/voxelGroup'; import { buildVoxelGroupMesh } from '../../utils/graphics/voxelMesh'; import { state } from '../rootState'; import { buildObjectTrimesh } from '../../utils/graphics/mesh'; import { computeBoundingBox } from '../../utils/runtime'; +import type { ObjectGeometryViewItem } from './objectGeometry'; +import type { ViewState } from '../viewState'; export class ObjectTypeGeometryViewItem extends ViewItem { + public readonly owner: ViewState; public readonly objectType: ObjectType; public voxelGroups: VoxelGroup[] | undefined; @@ -18,8 +21,9 @@ export class ObjectTypeGeometryViewItem extends ViewItem { private readonly _disposeVoxelReaction: IReactionDisposer; - constructor(objectType: ObjectType) { + constructor(owner: ViewState, objectType: ObjectType) { super(); + this.owner = owner; this.objectType = objectType; makeObservable(this, { voxelGroups: observable.ref, @@ -27,6 +31,7 @@ export class ObjectTypeGeometryViewItem extends ViewItem { boundingBox: observable.ref, meshes: observable.ref, updateRuntimeData: action, + instances: computed, }); this._disposeVoxelReaction = reaction( () => objectType.voxels.map(v => `${v.position}-${v.typeId}`).join('|'), @@ -78,4 +83,8 @@ export class ObjectTypeGeometryViewItem extends ViewItem { this.colliderMesh = buildObjectTrimesh(this.objectType, state.world.data.voxelTypes); this.boundingBox = computeBoundingBox(this.objectType); } + + public get instances(): ObjectGeometryViewItem[] { + return this.owner.objectGeometries.values.filter((o) => o.objectTypeGeometry === this); + } } diff --git a/webclient/src/state/viewState.ts b/webclient/src/state/viewState.ts index aebec61..725cec9 100644 --- a/webclient/src/state/viewState.ts +++ b/webclient/src/state/viewState.ts @@ -25,7 +25,7 @@ export class ViewState { this.objectTypeGeometries = new MobxReconcilerMap({ reaction: () => Object.values(world.data.objectTypes), keyOf: (ot) => ot.id, - mapItem: (ot) => new ObjectTypeGeometryViewItem(ot), + mapItem: (ot) => new ObjectTypeGeometryViewItem(this, ot), updateItem: () => true, disposeItem: (item) => { item.dispose(); }, });