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(); },
});