2026-03-31 02:35:02 +02:00
|
|
|
import {
|
|
|
|
|
AmbientLight,
|
|
|
|
|
AxesHelper,
|
|
|
|
|
BoxGeometry,
|
|
|
|
|
CanvasTexture,
|
2026-03-31 03:06:48 +02:00
|
|
|
ConeGeometry,
|
|
|
|
|
CylinderGeometry,
|
2026-03-31 02:35:02 +02:00
|
|
|
DirectionalLight,
|
|
|
|
|
EdgesGeometry,
|
|
|
|
|
GridHelper,
|
|
|
|
|
Group,
|
|
|
|
|
LineBasicMaterial,
|
|
|
|
|
LineSegments,
|
|
|
|
|
Mesh,
|
|
|
|
|
MeshStandardMaterial,
|
2026-03-31 20:05:23 +02:00
|
|
|
Object3D,
|
2026-03-31 03:42:16 +02:00
|
|
|
Plane,
|
2026-03-31 02:35:02 +02:00
|
|
|
PerspectiveCamera,
|
2026-03-31 20:05:23 +02:00
|
|
|
PointLight,
|
|
|
|
|
Quaternion,
|
2026-03-31 02:35:02 +02:00
|
|
|
Raycaster,
|
|
|
|
|
Scene,
|
2026-03-31 05:52:30 +02:00
|
|
|
SphereGeometry,
|
2026-03-31 04:23:34 +02:00
|
|
|
Spherical,
|
2026-03-31 05:52:30 +02:00
|
|
|
TorusGeometry,
|
2026-03-31 20:05:23 +02:00
|
|
|
SpotLight,
|
2026-03-31 02:35:02 +02:00
|
|
|
Vector2,
|
|
|
|
|
Vector3,
|
|
|
|
|
WebGLRenderer
|
|
|
|
|
} from "three";
|
|
|
|
|
|
2026-03-31 17:39:56 +02:00
|
|
|
import { isBrushFaceSelected, isBrushSelected, isModelInstanceSelected, type EditorSelection } from "../core/selection";
|
2026-03-31 03:42:16 +02:00
|
|
|
import type { ToolMode } from "../core/tool-mode";
|
|
|
|
|
import type { Vec3 } from "../core/vector";
|
2026-03-31 17:39:56 +02:00
|
|
|
import { createModelInstanceRenderGroup, disposeModelInstance } from "../assets/model-instance-rendering";
|
|
|
|
|
import type { LoadedModelAsset } from "../assets/gltf-model-import";
|
2026-03-31 20:05:23 +02:00
|
|
|
import type { LoadedImageAsset } from "../assets/image-assets";
|
2026-03-31 17:39:56 +02:00
|
|
|
import type { ProjectAssetRecord } from "../assets/project-assets";
|
2026-03-31 17:44:47 +02:00
|
|
|
import { getModelInstances } from "../assets/model-instances";
|
2026-03-31 02:35:02 +02:00
|
|
|
import type { SceneDocument, WorldSettings } from "../document/scene-document";
|
2026-03-31 20:05:23 +02:00
|
|
|
import {
|
|
|
|
|
getEntityInstances,
|
|
|
|
|
type EntityInstance,
|
|
|
|
|
type PointLightEntity,
|
|
|
|
|
type SpotLightEntity
|
|
|
|
|
} from "../entities/entity-instances";
|
2026-03-31 03:42:16 +02:00
|
|
|
import { BOX_FACE_IDS, DEFAULT_BOX_BRUSH_SIZE, type BoxBrush, type BoxFaceId } from "../document/brushes";
|
2026-03-31 02:35:02 +02:00
|
|
|
import { applyBoxBrushFaceUvsToGeometry } from "../geometry/box-face-uvs";
|
2026-03-31 03:42:16 +02:00
|
|
|
import { DEFAULT_GRID_SIZE, snapValueToGrid } from "../geometry/grid-snapping";
|
2026-03-31 03:06:48 +02:00
|
|
|
import { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures";
|
2026-03-31 02:35:02 +02:00
|
|
|
import type { MaterialDef } from "../materials/starter-material-library";
|
2026-03-31 04:23:34 +02:00
|
|
|
import { resolveViewportFocusTarget } from "./viewport-focus";
|
2026-03-31 02:35:02 +02:00
|
|
|
|
|
|
|
|
interface BrushRenderObjects {
|
|
|
|
|
mesh: Mesh<BoxGeometry, MeshStandardMaterial[]>;
|
|
|
|
|
edges: LineSegments<EdgesGeometry, LineBasicMaterial>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const BRUSH_SELECTED_EDGE_COLOR = 0xf7d2aa;
|
|
|
|
|
const BRUSH_EDGE_COLOR = 0x0d1017;
|
|
|
|
|
const FALLBACK_FACE_COLOR = 0x747d89;
|
|
|
|
|
const SELECTED_FACE_FALLBACK_COLOR = 0xcf7b42;
|
|
|
|
|
const SELECTED_FACE_EMISSIVE = 0x4a2814;
|
2026-03-31 03:06:48 +02:00
|
|
|
const PLAYER_START_COLOR = 0x7cb7ff;
|
|
|
|
|
const PLAYER_START_SELECTED_COLOR = 0xf3be8f;
|
2026-03-31 05:52:30 +02:00
|
|
|
const SOUND_EMITTER_COLOR = 0x72d7c9;
|
|
|
|
|
const SOUND_EMITTER_SELECTED_COLOR = 0xf4d37d;
|
|
|
|
|
const TRIGGER_VOLUME_COLOR = 0x9f8cff;
|
|
|
|
|
const TRIGGER_VOLUME_SELECTED_COLOR = 0xf0b07f;
|
|
|
|
|
const TELEPORT_TARGET_COLOR = 0x7ee0ff;
|
|
|
|
|
const TELEPORT_TARGET_SELECTED_COLOR = 0xf6c48a;
|
|
|
|
|
const INTERACTABLE_COLOR = 0x92de7e;
|
|
|
|
|
const INTERACTABLE_SELECTED_COLOR = 0xf1cf7e;
|
2026-03-31 03:42:16 +02:00
|
|
|
const BOX_CREATE_PREVIEW_FILL = 0x89b6ff;
|
|
|
|
|
const BOX_CREATE_PREVIEW_EDGE = 0xf3be8f;
|
2026-03-31 04:23:34 +02:00
|
|
|
const MIN_CAMERA_DISTANCE = 1.5;
|
|
|
|
|
const MAX_CAMERA_DISTANCE = 400;
|
|
|
|
|
const ORBIT_ROTATION_SPEED = 0.0085;
|
|
|
|
|
const ZOOM_SPEED = 0.0014;
|
|
|
|
|
const MIN_POLAR_ANGLE = 0.12;
|
|
|
|
|
const MAX_POLAR_ANGLE = Math.PI - 0.12;
|
|
|
|
|
const FOCUS_MARGIN = 1.35;
|
2026-03-31 02:35:02 +02:00
|
|
|
|
2026-03-31 03:06:48 +02:00
|
|
|
interface CachedMaterialTexture {
|
|
|
|
|
signature: string;
|
|
|
|
|
texture: CanvasTexture;
|
2026-03-31 02:35:02 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 05:52:30 +02:00
|
|
|
interface EntityRenderObjects {
|
2026-03-31 03:06:48 +02:00
|
|
|
group: Group;
|
|
|
|
|
meshes: Mesh[];
|
2026-03-31 02:35:02 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 20:05:23 +02:00
|
|
|
interface LocalLightRenderObjects {
|
|
|
|
|
group: Group;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:35:02 +02:00
|
|
|
export class ViewportHost {
|
|
|
|
|
private readonly scene = new Scene();
|
|
|
|
|
private readonly camera = new PerspectiveCamera(60, 1, 0.1, 1000);
|
2026-03-31 05:10:46 +02:00
|
|
|
private readonly renderer = new WebGLRenderer({ antialias: true, alpha: true });
|
2026-03-31 04:23:40 +02:00
|
|
|
private readonly cameraTarget = new Vector3(0, 0, 0);
|
|
|
|
|
private readonly cameraOffset = new Vector3();
|
|
|
|
|
private readonly cameraForward = new Vector3();
|
|
|
|
|
private readonly cameraRight = new Vector3();
|
|
|
|
|
private readonly cameraUp = new Vector3();
|
|
|
|
|
private readonly cameraSpherical = new Spherical();
|
2026-03-31 02:35:02 +02:00
|
|
|
private readonly ambientLight = new AmbientLight();
|
|
|
|
|
private readonly sunLight = new DirectionalLight();
|
2026-03-31 20:05:23 +02:00
|
|
|
private readonly localLightGroup = new Group();
|
2026-03-31 02:35:02 +02:00
|
|
|
private readonly brushGroup = new Group();
|
2026-03-31 03:06:48 +02:00
|
|
|
private readonly entityGroup = new Group();
|
2026-03-31 17:39:56 +02:00
|
|
|
private readonly modelGroup = new Group();
|
2026-03-31 02:35:02 +02:00
|
|
|
private readonly raycaster = new Raycaster();
|
|
|
|
|
private readonly pointer = new Vector2();
|
2026-03-31 03:42:16 +02:00
|
|
|
private readonly boxCreateIntersection = new Vector3();
|
|
|
|
|
private readonly boxCreatePlane = new Plane(new Vector3(0, 1, 0), 0);
|
2026-03-31 02:35:02 +02:00
|
|
|
private readonly brushRenderObjects = new Map<string, BrushRenderObjects>();
|
2026-03-31 05:52:30 +02:00
|
|
|
private readonly entityRenderObjects = new Map<string, EntityRenderObjects>();
|
2026-03-31 20:05:23 +02:00
|
|
|
private readonly localLightRenderObjects = new Map<string, LocalLightRenderObjects>();
|
2026-03-31 17:39:56 +02:00
|
|
|
private readonly modelRenderObjects = new Map<string, Group>();
|
2026-03-31 02:35:02 +02:00
|
|
|
private readonly materialTextureCache = new Map<string, CachedMaterialTexture>();
|
2026-03-31 17:39:56 +02:00
|
|
|
private currentDocument: SceneDocument | null = null;
|
2026-03-31 20:05:23 +02:00
|
|
|
private currentWorld: WorldSettings | null = null;
|
2026-03-31 17:39:56 +02:00
|
|
|
private currentSelection: EditorSelection = {
|
|
|
|
|
kind: "none"
|
|
|
|
|
};
|
|
|
|
|
private projectAssets: Record<string, ProjectAssetRecord> = {};
|
|
|
|
|
private loadedModelAssets: Record<string, LoadedModelAsset> = {};
|
2026-03-31 20:05:23 +02:00
|
|
|
private loadedImageAssets: Record<string, LoadedImageAsset> = {};
|
2026-03-31 03:42:16 +02:00
|
|
|
private readonly boxCreatePreviewMesh = new Mesh(
|
|
|
|
|
new BoxGeometry(DEFAULT_BOX_BRUSH_SIZE.x, DEFAULT_BOX_BRUSH_SIZE.y, DEFAULT_BOX_BRUSH_SIZE.z),
|
|
|
|
|
new MeshStandardMaterial({
|
|
|
|
|
color: BOX_CREATE_PREVIEW_FILL,
|
|
|
|
|
emissive: BOX_CREATE_PREVIEW_FILL,
|
|
|
|
|
emissiveIntensity: 0.12,
|
|
|
|
|
roughness: 0.68,
|
|
|
|
|
metalness: 0.02,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: 0.22
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
private readonly boxCreatePreviewEdges = new LineSegments(
|
|
|
|
|
new EdgesGeometry(this.boxCreatePreviewMesh.geometry),
|
|
|
|
|
new LineBasicMaterial({
|
|
|
|
|
color: BOX_CREATE_PREVIEW_EDGE
|
|
|
|
|
})
|
|
|
|
|
);
|
2026-03-31 02:35:02 +02:00
|
|
|
private resizeObserver: ResizeObserver | null = null;
|
|
|
|
|
private animationFrame = 0;
|
|
|
|
|
private container: HTMLElement | null = null;
|
|
|
|
|
private brushSelectionChangeHandler: ((selection: EditorSelection) => void) | null = null;
|
2026-03-31 03:42:16 +02:00
|
|
|
private createBoxBrushHandler: ((center: Vec3) => void) | null = null;
|
|
|
|
|
private boxCreatePreviewHandler: ((center: Vec3 | null) => void) | null = null;
|
|
|
|
|
private toolMode: ToolMode = "select";
|
|
|
|
|
private lastBoxCreatePreviewCenter: Vec3 | null = null;
|
2026-03-31 04:23:40 +02:00
|
|
|
private activeCameraDragPointerId: number | null = null;
|
|
|
|
|
private lastCameraDragClientPosition: { x: number; y: number } | null = null;
|
2026-03-31 02:35:02 +02:00
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
|
this.camera.position.set(10, 9, 10);
|
2026-03-31 04:23:40 +02:00
|
|
|
this.camera.lookAt(this.cameraTarget);
|
|
|
|
|
this.updateCameraSphericalFromPose();
|
2026-03-31 02:35:02 +02:00
|
|
|
|
|
|
|
|
const gridHelper = new GridHelper(40, 40, 0xcf8354, 0x4e596b);
|
|
|
|
|
const axesHelper = new AxesHelper(2);
|
|
|
|
|
|
|
|
|
|
this.scene.add(gridHelper);
|
|
|
|
|
this.scene.add(axesHelper);
|
|
|
|
|
this.scene.add(this.ambientLight);
|
|
|
|
|
this.scene.add(this.sunLight);
|
2026-03-31 20:05:23 +02:00
|
|
|
this.scene.add(this.localLightGroup);
|
2026-03-31 02:35:02 +02:00
|
|
|
this.scene.add(this.brushGroup);
|
2026-03-31 03:06:48 +02:00
|
|
|
this.scene.add(this.entityGroup);
|
2026-03-31 17:39:56 +02:00
|
|
|
this.scene.add(this.modelGroup);
|
2026-03-31 03:42:16 +02:00
|
|
|
this.boxCreatePreviewMesh.visible = false;
|
|
|
|
|
this.boxCreatePreviewEdges.visible = false;
|
|
|
|
|
this.scene.add(this.boxCreatePreviewMesh);
|
|
|
|
|
this.scene.add(this.boxCreatePreviewEdges);
|
2026-03-31 02:35:02 +02:00
|
|
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
2026-03-31 05:10:46 +02:00
|
|
|
this.renderer.setClearAlpha(0);
|
2026-03-31 02:35:02 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mount(container: HTMLElement) {
|
|
|
|
|
this.container = container;
|
2026-03-31 04:23:57 +02:00
|
|
|
this.renderer.domElement.tabIndex = -1;
|
2026-03-31 02:35:02 +02:00
|
|
|
container.appendChild(this.renderer.domElement);
|
|
|
|
|
this.renderer.domElement.addEventListener("pointerdown", this.handlePointerDown);
|
2026-03-31 03:42:16 +02:00
|
|
|
this.renderer.domElement.addEventListener("pointermove", this.handlePointerMove);
|
2026-03-31 04:23:57 +02:00
|
|
|
this.renderer.domElement.addEventListener("pointerup", this.handlePointerUp);
|
|
|
|
|
this.renderer.domElement.addEventListener("pointercancel", this.handlePointerUp);
|
2026-03-31 03:42:16 +02:00
|
|
|
this.renderer.domElement.addEventListener("pointerleave", this.handlePointerLeave);
|
2026-03-31 04:23:57 +02:00
|
|
|
this.renderer.domElement.addEventListener("wheel", this.handleWheel, { passive: false });
|
|
|
|
|
this.renderer.domElement.addEventListener("auxclick", this.handleAuxClick);
|
2026-03-31 02:35:02 +02:00
|
|
|
this.resize();
|
|
|
|
|
|
|
|
|
|
this.resizeObserver = new ResizeObserver(() => {
|
|
|
|
|
this.resize();
|
|
|
|
|
});
|
|
|
|
|
this.resizeObserver.observe(container);
|
|
|
|
|
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateWorld(world: WorldSettings) {
|
2026-03-31 20:05:35 +02:00
|
|
|
this.currentWorld = world;
|
|
|
|
|
this.applyWorld();
|
2026-03-31 02:35:02 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateDocument(document: SceneDocument, selection: EditorSelection) {
|
2026-03-31 17:39:56 +02:00
|
|
|
this.currentDocument = document;
|
|
|
|
|
this.currentSelection = selection;
|
2026-03-31 20:05:40 +02:00
|
|
|
this.rebuildLocalLights(document);
|
2026-03-31 02:35:02 +02:00
|
|
|
this.rebuildBrushMeshes(document, selection);
|
2026-03-31 05:52:43 +02:00
|
|
|
this.rebuildEntityMarkers(document, selection);
|
2026-03-31 17:39:56 +02:00
|
|
|
this.rebuildModelInstances(document, selection);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateAssets(projectAssets: Record<string, ProjectAssetRecord>, loadedModelAssets: Record<string, LoadedModelAsset>) {
|
|
|
|
|
this.projectAssets = projectAssets;
|
|
|
|
|
this.loadedModelAssets = loadedModelAssets;
|
|
|
|
|
|
2026-03-31 20:05:35 +02:00
|
|
|
if (this.currentWorld !== null) {
|
|
|
|
|
this.applyWorld();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 17:39:56 +02:00
|
|
|
if (this.currentDocument !== null) {
|
|
|
|
|
this.rebuildModelInstances(this.currentDocument, this.currentSelection);
|
|
|
|
|
}
|
2026-03-31 02:35:02 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setBrushSelectionChangeHandler(handler: ((selection: EditorSelection) => void) | null) {
|
|
|
|
|
this.brushSelectionChangeHandler = handler;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:42:16 +02:00
|
|
|
setCreateBoxBrushHandler(handler: ((center: Vec3) => void) | null) {
|
|
|
|
|
this.createBoxBrushHandler = handler;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setBoxCreatePreviewHandler(handler: ((center: Vec3 | null) => void) | null) {
|
|
|
|
|
this.boxCreatePreviewHandler = handler;
|
|
|
|
|
handler?.(this.lastBoxCreatePreviewCenter);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setToolMode(toolMode: ToolMode) {
|
|
|
|
|
this.toolMode = toolMode;
|
|
|
|
|
|
|
|
|
|
if (toolMode !== "box-create") {
|
|
|
|
|
this.setBoxCreatePreview(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 04:23:57 +02:00
|
|
|
focusSelection(document: SceneDocument, selection: EditorSelection) {
|
|
|
|
|
const focusTarget = resolveViewportFocusTarget(document, selection);
|
|
|
|
|
|
|
|
|
|
if (focusTarget === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const verticalHalfFov = (this.camera.fov * Math.PI) / 360;
|
|
|
|
|
const horizontalHalfFov = Math.atan(Math.tan(verticalHalfFov) * Math.max(this.camera.aspect, 0.0001));
|
|
|
|
|
const fitAngle = Math.max(0.1, Math.min(verticalHalfFov, horizontalHalfFov));
|
|
|
|
|
const fitDistance = Math.min(
|
|
|
|
|
MAX_CAMERA_DISTANCE,
|
|
|
|
|
Math.max(MIN_CAMERA_DISTANCE, (focusTarget.radius / Math.sin(fitAngle)) * FOCUS_MARGIN)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
this.cameraTarget.set(focusTarget.center.x, focusTarget.center.y, focusTarget.center.z);
|
|
|
|
|
this.cameraSpherical.radius = fitDistance;
|
|
|
|
|
this.cameraSpherical.makeSafe();
|
|
|
|
|
this.applyCameraOrbitPose();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:35:02 +02:00
|
|
|
dispose() {
|
|
|
|
|
if (this.animationFrame !== 0) {
|
|
|
|
|
cancelAnimationFrame(this.animationFrame);
|
|
|
|
|
this.animationFrame = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.resizeObserver?.disconnect();
|
|
|
|
|
this.resizeObserver = null;
|
|
|
|
|
this.renderer.domElement.removeEventListener("pointerdown", this.handlePointerDown);
|
2026-03-31 03:42:16 +02:00
|
|
|
this.renderer.domElement.removeEventListener("pointermove", this.handlePointerMove);
|
2026-03-31 04:23:57 +02:00
|
|
|
this.renderer.domElement.removeEventListener("pointerup", this.handlePointerUp);
|
|
|
|
|
this.renderer.domElement.removeEventListener("pointercancel", this.handlePointerUp);
|
2026-03-31 03:42:16 +02:00
|
|
|
this.renderer.domElement.removeEventListener("pointerleave", this.handlePointerLeave);
|
2026-03-31 04:23:57 +02:00
|
|
|
this.renderer.domElement.removeEventListener("wheel", this.handleWheel);
|
|
|
|
|
this.renderer.domElement.removeEventListener("auxclick", this.handleAuxClick);
|
2026-03-31 20:05:35 +02:00
|
|
|
this.clearLocalLights();
|
2026-03-31 02:35:02 +02:00
|
|
|
this.clearBrushMeshes();
|
2026-03-31 05:52:43 +02:00
|
|
|
this.clearEntityMarkers();
|
2026-03-31 03:46:38 +02:00
|
|
|
this.boxCreatePreviewHandler = null;
|
2026-03-31 03:42:16 +02:00
|
|
|
this.setBoxCreatePreview(null);
|
2026-03-31 02:35:02 +02:00
|
|
|
|
|
|
|
|
for (const cachedTexture of this.materialTextureCache.values()) {
|
|
|
|
|
cachedTexture.texture.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.materialTextureCache.clear();
|
2026-03-31 03:42:16 +02:00
|
|
|
this.boxCreatePreviewMesh.geometry.dispose();
|
|
|
|
|
this.boxCreatePreviewMesh.material.dispose();
|
|
|
|
|
this.boxCreatePreviewEdges.geometry.dispose();
|
|
|
|
|
this.boxCreatePreviewEdges.material.dispose();
|
2026-03-31 02:35:02 +02:00
|
|
|
this.renderer.dispose();
|
|
|
|
|
|
|
|
|
|
if (this.container !== null && this.container.contains(this.renderer.domElement)) {
|
|
|
|
|
this.container.removeChild(this.renderer.domElement);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.container = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 04:23:57 +02:00
|
|
|
private updateCameraSphericalFromPose() {
|
|
|
|
|
this.cameraOffset.copy(this.camera.position).sub(this.cameraTarget);
|
|
|
|
|
this.cameraSpherical.setFromVector3(this.cameraOffset);
|
|
|
|
|
this.cameraSpherical.radius = Math.min(MAX_CAMERA_DISTANCE, Math.max(MIN_CAMERA_DISTANCE, this.cameraSpherical.radius));
|
|
|
|
|
this.cameraSpherical.phi = Math.min(MAX_POLAR_ANGLE, Math.max(MIN_POLAR_ANGLE, this.cameraSpherical.phi));
|
|
|
|
|
this.cameraSpherical.makeSafe();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private applyCameraOrbitPose() {
|
|
|
|
|
this.cameraSpherical.radius = Math.min(MAX_CAMERA_DISTANCE, Math.max(MIN_CAMERA_DISTANCE, this.cameraSpherical.radius));
|
|
|
|
|
this.cameraSpherical.phi = Math.min(MAX_POLAR_ANGLE, Math.max(MIN_POLAR_ANGLE, this.cameraSpherical.phi));
|
|
|
|
|
this.cameraSpherical.makeSafe();
|
|
|
|
|
this.cameraOffset.setFromSpherical(this.cameraSpherical);
|
|
|
|
|
this.camera.position.copy(this.cameraTarget).add(this.cameraOffset);
|
|
|
|
|
this.camera.lookAt(this.cameraTarget);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:35:02 +02:00
|
|
|
private rebuildBrushMeshes(document: SceneDocument, selection: EditorSelection) {
|
|
|
|
|
this.clearBrushMeshes();
|
|
|
|
|
|
|
|
|
|
for (const brush of Object.values(document.brushes)) {
|
|
|
|
|
const geometry = new BoxGeometry(brush.size.x, brush.size.y, brush.size.z);
|
|
|
|
|
applyBoxBrushFaceUvsToGeometry(geometry, brush);
|
|
|
|
|
|
|
|
|
|
const materials = BOX_FACE_IDS.map((faceId) =>
|
|
|
|
|
this.createFaceMaterial(
|
|
|
|
|
brush,
|
|
|
|
|
faceId,
|
|
|
|
|
document.materials[brush.faces[faceId].materialId ?? ""],
|
|
|
|
|
isBrushFaceSelected(selection, brush.id, faceId)
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
const mesh = new Mesh(geometry, materials);
|
|
|
|
|
const brushSelected = isBrushSelected(selection, brush.id);
|
|
|
|
|
|
|
|
|
|
mesh.position.set(brush.center.x, brush.center.y, brush.center.z);
|
|
|
|
|
mesh.userData.brushId = brush.id;
|
|
|
|
|
mesh.castShadow = false;
|
|
|
|
|
mesh.receiveShadow = false;
|
|
|
|
|
|
|
|
|
|
const edges = new LineSegments(
|
|
|
|
|
new EdgesGeometry(geometry),
|
|
|
|
|
new LineBasicMaterial({
|
|
|
|
|
color: brushSelected ? BRUSH_SELECTED_EDGE_COLOR : BRUSH_EDGE_COLOR
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
edges.position.copy(mesh.position);
|
|
|
|
|
|
|
|
|
|
this.brushGroup.add(mesh);
|
|
|
|
|
this.brushGroup.add(edges);
|
|
|
|
|
this.brushRenderObjects.set(brush.id, {
|
|
|
|
|
mesh,
|
|
|
|
|
edges
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 05:52:43 +02:00
|
|
|
private rebuildEntityMarkers(document: SceneDocument, selection: EditorSelection) {
|
|
|
|
|
this.clearEntityMarkers();
|
2026-03-31 03:06:48 +02:00
|
|
|
|
2026-03-31 05:52:43 +02:00
|
|
|
for (const entity of getEntityInstances(document.entities)) {
|
|
|
|
|
const selected = selection.kind === "entities" && selection.ids.includes(entity.id);
|
|
|
|
|
const renderObjects = this.createEntityRenderObjects(entity, selected);
|
2026-03-31 03:06:48 +02:00
|
|
|
|
2026-03-31 05:52:43 +02:00
|
|
|
this.entityGroup.add(renderObjects.group);
|
|
|
|
|
this.entityRenderObjects.set(entity.id, renderObjects);
|
2026-03-31 03:06:48 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 17:39:56 +02:00
|
|
|
private rebuildModelInstances(document: SceneDocument, selection: EditorSelection) {
|
|
|
|
|
this.clearModelInstances();
|
|
|
|
|
|
2026-03-31 17:44:47 +02:00
|
|
|
for (const modelInstance of getModelInstances(document.modelInstances)) {
|
2026-03-31 17:39:56 +02:00
|
|
|
const selected = isModelInstanceSelected(selection, modelInstance.id);
|
2026-03-31 17:49:44 +02:00
|
|
|
const asset = this.projectAssets[modelInstance.assetId];
|
2026-03-31 17:39:56 +02:00
|
|
|
const loadedAsset = this.loadedModelAssets[modelInstance.assetId];
|
|
|
|
|
const renderGroup = createModelInstanceRenderGroup(modelInstance, asset, loadedAsset, selected);
|
|
|
|
|
|
|
|
|
|
this.modelGroup.add(renderGroup);
|
|
|
|
|
this.modelRenderObjects.set(modelInstance.id, renderGroup);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 05:53:15 +02:00
|
|
|
private createEntityRenderObjects(entity: EntityInstance, selected: boolean): EntityRenderObjects {
|
|
|
|
|
switch (entity.kind) {
|
|
|
|
|
case "playerStart":
|
|
|
|
|
return this.createPlayerStartRenderObjects(entity.id, entity.position, entity.yawDegrees, selected);
|
|
|
|
|
case "soundEmitter":
|
|
|
|
|
return this.createSoundEmitterRenderObjects(entity.id, entity.position, entity.radius, selected);
|
|
|
|
|
case "triggerVolume":
|
|
|
|
|
return this.createTriggerVolumeRenderObjects(entity.id, entity.position, entity.size, selected);
|
|
|
|
|
case "teleportTarget":
|
|
|
|
|
return this.createTeleportTargetRenderObjects(entity.id, entity.position, entity.yawDegrees, selected);
|
|
|
|
|
case "interactable":
|
|
|
|
|
return this.createInteractableRenderObjects(entity.id, entity.position, entity.radius, selected);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private tagEntityMesh(mesh: Mesh, entityId: string, entityKind: EntityInstance["kind"], group: Group) {
|
|
|
|
|
mesh.userData.entityId = entityId;
|
|
|
|
|
mesh.userData.entityKind = entityKind;
|
|
|
|
|
group.add(mesh);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private createPlayerStartRenderObjects(entityId: string, position: Vec3, yawDegrees: number, selected: boolean): EntityRenderObjects {
|
|
|
|
|
const markerColor = selected ? PLAYER_START_SELECTED_COLOR : PLAYER_START_COLOR;
|
|
|
|
|
const group = new Group();
|
|
|
|
|
group.position.set(position.x, position.y, position.z);
|
|
|
|
|
group.rotation.y = (yawDegrees * Math.PI) / 180;
|
|
|
|
|
|
|
|
|
|
const base = new Mesh(
|
|
|
|
|
new CylinderGeometry(0.22, 0.22, 0.05, 18),
|
|
|
|
|
new MeshStandardMaterial({
|
|
|
|
|
color: markerColor,
|
|
|
|
|
emissive: markerColor,
|
|
|
|
|
emissiveIntensity: selected ? 0.18 : 0.08,
|
|
|
|
|
roughness: 0.35,
|
|
|
|
|
metalness: 0.08
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
base.position.y = 0.025;
|
|
|
|
|
|
|
|
|
|
const body = new Mesh(
|
|
|
|
|
new BoxGeometry(0.12, 0.12, 0.46),
|
|
|
|
|
new MeshStandardMaterial({
|
|
|
|
|
color: markerColor,
|
|
|
|
|
emissive: markerColor,
|
|
|
|
|
emissiveIntensity: selected ? 0.14 : 0.06,
|
|
|
|
|
roughness: 0.42,
|
|
|
|
|
metalness: 0.02
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
body.position.set(0, 0.16, 0.1);
|
|
|
|
|
|
|
|
|
|
const arrowHead = new Mesh(
|
|
|
|
|
new ConeGeometry(0.12, 0.28, 14),
|
|
|
|
|
new MeshStandardMaterial({
|
|
|
|
|
color: markerColor,
|
|
|
|
|
emissive: markerColor,
|
|
|
|
|
emissiveIntensity: selected ? 0.2 : 0.08,
|
|
|
|
|
roughness: 0.38,
|
|
|
|
|
metalness: 0.03
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
arrowHead.rotation.x = Math.PI * 0.5;
|
|
|
|
|
arrowHead.position.set(0, 0.16, 0.42);
|
|
|
|
|
|
|
|
|
|
for (const mesh of [base, body, arrowHead]) {
|
|
|
|
|
this.tagEntityMesh(mesh, entityId, "playerStart", group);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
group,
|
|
|
|
|
meshes: [base, body, arrowHead]
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private createSoundEmitterRenderObjects(entityId: string, position: Vec3, radius: number, selected: boolean): EntityRenderObjects {
|
|
|
|
|
const markerColor = selected ? SOUND_EMITTER_SELECTED_COLOR : SOUND_EMITTER_COLOR;
|
|
|
|
|
const displayRadius = Math.max(0.5, radius);
|
|
|
|
|
const group = new Group();
|
|
|
|
|
group.position.set(position.x, position.y, position.z);
|
|
|
|
|
|
|
|
|
|
const core = new Mesh(
|
|
|
|
|
new SphereGeometry(0.18, 16, 12),
|
|
|
|
|
new MeshStandardMaterial({
|
|
|
|
|
color: markerColor,
|
|
|
|
|
emissive: markerColor,
|
|
|
|
|
emissiveIntensity: selected ? 0.18 : 0.08,
|
|
|
|
|
roughness: 0.32,
|
|
|
|
|
metalness: 0.06
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const radiusShell = new Mesh(
|
|
|
|
|
new SphereGeometry(displayRadius, 16, 12),
|
|
|
|
|
new MeshStandardMaterial({
|
|
|
|
|
color: markerColor,
|
|
|
|
|
emissive: markerColor,
|
|
|
|
|
emissiveIntensity: selected ? 0.08 : 0.02,
|
|
|
|
|
roughness: 0.8,
|
|
|
|
|
metalness: 0,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: selected ? 0.16 : 0.08,
|
|
|
|
|
wireframe: true
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
for (const mesh of [core, radiusShell]) {
|
|
|
|
|
this.tagEntityMesh(mesh, entityId, "soundEmitter", group);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
group,
|
|
|
|
|
meshes: [core, radiusShell]
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private createTriggerVolumeRenderObjects(entityId: string, position: Vec3, size: Vec3, selected: boolean): EntityRenderObjects {
|
|
|
|
|
const markerColor = selected ? TRIGGER_VOLUME_SELECTED_COLOR : TRIGGER_VOLUME_COLOR;
|
|
|
|
|
const group = new Group();
|
|
|
|
|
group.position.set(position.x, position.y, position.z);
|
|
|
|
|
|
|
|
|
|
const fill = new Mesh(
|
|
|
|
|
new BoxGeometry(size.x, size.y, size.z),
|
|
|
|
|
new MeshStandardMaterial({
|
|
|
|
|
color: markerColor,
|
|
|
|
|
emissive: markerColor,
|
|
|
|
|
emissiveIntensity: selected ? 0.1 : 0.03,
|
|
|
|
|
roughness: 0.7,
|
|
|
|
|
metalness: 0,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: selected ? 0.2 : 0.1
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const outline = new Mesh(
|
|
|
|
|
new BoxGeometry(size.x, size.y, size.z),
|
|
|
|
|
new MeshStandardMaterial({
|
|
|
|
|
color: markerColor,
|
|
|
|
|
emissive: markerColor,
|
|
|
|
|
emissiveIntensity: selected ? 0.12 : 0.04,
|
|
|
|
|
roughness: 0.9,
|
|
|
|
|
metalness: 0,
|
|
|
|
|
wireframe: true,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: 0.95
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
for (const mesh of [fill, outline]) {
|
|
|
|
|
this.tagEntityMesh(mesh, entityId, "triggerVolume", group);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
group,
|
|
|
|
|
meshes: [fill, outline]
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private createTeleportTargetRenderObjects(entityId: string, position: Vec3, yawDegrees: number, selected: boolean): EntityRenderObjects {
|
|
|
|
|
const markerColor = selected ? TELEPORT_TARGET_SELECTED_COLOR : TELEPORT_TARGET_COLOR;
|
|
|
|
|
const group = new Group();
|
|
|
|
|
group.position.set(position.x, position.y, position.z);
|
|
|
|
|
group.rotation.y = (yawDegrees * Math.PI) / 180;
|
|
|
|
|
|
|
|
|
|
const ring = new Mesh(
|
|
|
|
|
new TorusGeometry(0.28, 0.045, 8, 24),
|
|
|
|
|
new MeshStandardMaterial({
|
|
|
|
|
color: markerColor,
|
|
|
|
|
emissive: markerColor,
|
|
|
|
|
emissiveIntensity: selected ? 0.18 : 0.08,
|
|
|
|
|
roughness: 0.42,
|
|
|
|
|
metalness: 0.04
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
ring.rotation.x = Math.PI * 0.5;
|
|
|
|
|
ring.position.y = 0.035;
|
|
|
|
|
|
|
|
|
|
const stem = new Mesh(
|
|
|
|
|
new CylinderGeometry(0.04, 0.04, 0.3, 12),
|
|
|
|
|
new MeshStandardMaterial({
|
|
|
|
|
color: markerColor,
|
|
|
|
|
emissive: markerColor,
|
|
|
|
|
emissiveIntensity: selected ? 0.12 : 0.04,
|
|
|
|
|
roughness: 0.45,
|
|
|
|
|
metalness: 0.02
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
stem.position.y = 0.15;
|
|
|
|
|
|
|
|
|
|
const arrowHead = new Mesh(
|
|
|
|
|
new ConeGeometry(0.12, 0.24, 14),
|
|
|
|
|
new MeshStandardMaterial({
|
|
|
|
|
color: markerColor,
|
|
|
|
|
emissive: markerColor,
|
|
|
|
|
emissiveIntensity: selected ? 0.18 : 0.06,
|
|
|
|
|
roughness: 0.36,
|
|
|
|
|
metalness: 0.03
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
arrowHead.rotation.x = Math.PI * 0.5;
|
|
|
|
|
arrowHead.position.set(0, 0.15, 0.34);
|
|
|
|
|
|
|
|
|
|
for (const mesh of [ring, stem, arrowHead]) {
|
|
|
|
|
this.tagEntityMesh(mesh, entityId, "teleportTarget", group);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
group,
|
|
|
|
|
meshes: [ring, stem, arrowHead]
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private createInteractableRenderObjects(entityId: string, position: Vec3, radius: number, selected: boolean): EntityRenderObjects {
|
|
|
|
|
const markerColor = selected ? INTERACTABLE_SELECTED_COLOR : INTERACTABLE_COLOR;
|
|
|
|
|
const displayRadius = Math.max(0.45, radius);
|
|
|
|
|
const group = new Group();
|
|
|
|
|
group.position.set(position.x, position.y, position.z);
|
|
|
|
|
|
|
|
|
|
const core = new Mesh(
|
|
|
|
|
new SphereGeometry(0.16, 12, 10),
|
|
|
|
|
new MeshStandardMaterial({
|
|
|
|
|
color: markerColor,
|
|
|
|
|
emissive: markerColor,
|
|
|
|
|
emissiveIntensity: selected ? 0.18 : 0.08,
|
|
|
|
|
roughness: 0.34,
|
|
|
|
|
metalness: 0.04
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const radiusRing = new Mesh(
|
|
|
|
|
new TorusGeometry(displayRadius, 0.03, 8, 32),
|
|
|
|
|
new MeshStandardMaterial({
|
|
|
|
|
color: markerColor,
|
|
|
|
|
emissive: markerColor,
|
|
|
|
|
emissiveIntensity: selected ? 0.1 : 0.04,
|
|
|
|
|
roughness: 0.55,
|
|
|
|
|
metalness: 0.02
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
radiusRing.rotation.x = Math.PI * 0.5;
|
|
|
|
|
|
|
|
|
|
for (const mesh of [core, radiusRing]) {
|
|
|
|
|
this.tagEntityMesh(mesh, entityId, "interactable", group);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
group,
|
|
|
|
|
meshes: [core, radiusRing]
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:35:02 +02:00
|
|
|
private createFaceMaterial(brush: BoxBrush, faceId: BoxFaceId, material: MaterialDef | undefined, selectedFace: boolean): MeshStandardMaterial {
|
|
|
|
|
const face = brush.faces[faceId];
|
|
|
|
|
|
|
|
|
|
if (material === undefined || face.materialId === null) {
|
|
|
|
|
return new MeshStandardMaterial({
|
|
|
|
|
color: selectedFace ? SELECTED_FACE_FALLBACK_COLOR : FALLBACK_FACE_COLOR,
|
|
|
|
|
emissive: selectedFace ? SELECTED_FACE_EMISSIVE : 0x000000,
|
|
|
|
|
emissiveIntensity: selectedFace ? 0.28 : 0,
|
|
|
|
|
roughness: 0.9,
|
|
|
|
|
metalness: 0.05
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new MeshStandardMaterial({
|
|
|
|
|
color: 0xffffff,
|
|
|
|
|
map: this.getOrCreateTexture(material),
|
|
|
|
|
emissive: selectedFace ? SELECTED_FACE_EMISSIVE : 0x000000,
|
|
|
|
|
emissiveIntensity: selectedFace ? 0.32 : 0,
|
|
|
|
|
roughness: 0.92,
|
|
|
|
|
metalness: 0.02
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getOrCreateTexture(material: MaterialDef): CanvasTexture {
|
2026-03-31 03:06:48 +02:00
|
|
|
const signature = createStarterMaterialSignature(material);
|
2026-03-31 02:35:02 +02:00
|
|
|
const cachedTexture = this.materialTextureCache.get(material.id);
|
|
|
|
|
|
|
|
|
|
if (cachedTexture !== undefined && cachedTexture.signature === signature) {
|
|
|
|
|
return cachedTexture.texture;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cachedTexture?.texture.dispose();
|
|
|
|
|
|
2026-03-31 03:06:48 +02:00
|
|
|
const texture = createStarterMaterialTexture(material);
|
2026-03-31 02:35:02 +02:00
|
|
|
|
|
|
|
|
this.materialTextureCache.set(material.id, {
|
|
|
|
|
signature,
|
|
|
|
|
texture
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return texture;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private clearBrushMeshes() {
|
|
|
|
|
for (const renderObjects of this.brushRenderObjects.values()) {
|
|
|
|
|
this.brushGroup.remove(renderObjects.mesh);
|
|
|
|
|
this.brushGroup.remove(renderObjects.edges);
|
|
|
|
|
renderObjects.mesh.geometry.dispose();
|
|
|
|
|
|
|
|
|
|
for (const material of renderObjects.mesh.material) {
|
|
|
|
|
material.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
renderObjects.edges.geometry.dispose();
|
|
|
|
|
renderObjects.edges.material.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.brushRenderObjects.clear();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 05:52:43 +02:00
|
|
|
private clearEntityMarkers() {
|
|
|
|
|
for (const renderObjects of this.entityRenderObjects.values()) {
|
2026-03-31 03:06:48 +02:00
|
|
|
this.entityGroup.remove(renderObjects.group);
|
|
|
|
|
|
|
|
|
|
for (const mesh of renderObjects.meshes) {
|
|
|
|
|
mesh.geometry.dispose();
|
2026-03-31 03:10:48 +02:00
|
|
|
|
|
|
|
|
if (Array.isArray(mesh.material)) {
|
|
|
|
|
for (const material of mesh.material) {
|
|
|
|
|
material.dispose();
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
mesh.material.dispose();
|
|
|
|
|
}
|
2026-03-31 03:06:48 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 05:52:43 +02:00
|
|
|
this.entityRenderObjects.clear();
|
2026-03-31 03:06:48 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 17:39:56 +02:00
|
|
|
private clearModelInstances() {
|
|
|
|
|
for (const renderGroup of this.modelRenderObjects.values()) {
|
|
|
|
|
this.modelGroup.remove(renderGroup);
|
|
|
|
|
disposeModelInstance(renderGroup);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.modelRenderObjects.clear();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:35:02 +02:00
|
|
|
private resize() {
|
|
|
|
|
if (this.container === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const width = this.container.clientWidth;
|
|
|
|
|
const height = this.container.clientHeight;
|
|
|
|
|
|
|
|
|
|
if (width === 0 || height === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.camera.aspect = width / height;
|
|
|
|
|
this.camera.updateProjectionMatrix();
|
|
|
|
|
this.renderer.setSize(width, height, false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private handlePointerDown = (event: PointerEvent) => {
|
2026-03-31 04:24:06 +02:00
|
|
|
if (event.button === 1) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
this.activeCameraDragPointerId = event.pointerId;
|
|
|
|
|
this.lastCameraDragClientPosition = {
|
|
|
|
|
x: event.clientX,
|
|
|
|
|
y: event.clientY
|
|
|
|
|
};
|
|
|
|
|
this.renderer.domElement.setPointerCapture(event.pointerId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event.button !== 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:44:17 +02:00
|
|
|
if (this.toolMode === "box-create") {
|
|
|
|
|
const previewCenter = this.getBoxCreatePreviewCenter(event);
|
|
|
|
|
|
|
|
|
|
if (previewCenter !== null) {
|
|
|
|
|
this.createBoxBrushHandler?.(previewCenter);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:35:02 +02:00
|
|
|
const bounds = this.renderer.domElement.getBoundingClientRect();
|
|
|
|
|
|
|
|
|
|
if (bounds.width === 0 || bounds.height === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
|
|
|
|
|
this.pointer.y = -(((event.clientY - bounds.top) / bounds.height) * 2 - 1);
|
|
|
|
|
|
|
|
|
|
this.raycaster.setFromCamera(this.pointer, this.camera);
|
|
|
|
|
|
|
|
|
|
const hits = this.raycaster.intersectObjects(
|
2026-03-31 03:06:48 +02:00
|
|
|
[
|
2026-03-31 05:52:43 +02:00
|
|
|
...Array.from(this.entityRenderObjects.values(), (renderObjects) => renderObjects.group),
|
2026-03-31 17:39:56 +02:00
|
|
|
...Array.from(this.modelRenderObjects.values()),
|
2026-03-31 03:06:48 +02:00
|
|
|
...Array.from(this.brushRenderObjects.values(), (renderObjects) => renderObjects.mesh)
|
|
|
|
|
],
|
|
|
|
|
true
|
2026-03-31 02:35:02 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (hits.length === 0) {
|
|
|
|
|
this.brushSelectionChangeHandler?.({
|
|
|
|
|
kind: "none"
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hit = hits[0];
|
2026-03-31 03:06:48 +02:00
|
|
|
const entityId = hit.object.userData.entityId;
|
|
|
|
|
|
|
|
|
|
if (typeof entityId === "string") {
|
|
|
|
|
this.brushSelectionChangeHandler?.({
|
|
|
|
|
kind: "entities",
|
|
|
|
|
ids: [entityId]
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 17:39:56 +02:00
|
|
|
const modelInstanceId = this.findModelInstanceId(hit.object);
|
|
|
|
|
|
|
|
|
|
if (modelInstanceId !== null) {
|
|
|
|
|
this.brushSelectionChangeHandler?.({
|
|
|
|
|
kind: "modelInstances",
|
|
|
|
|
ids: [modelInstanceId]
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:35:02 +02:00
|
|
|
const brushId = hit.object.userData.brushId;
|
|
|
|
|
const faceMaterialIndex = hit.face?.materialIndex;
|
|
|
|
|
const faceId = typeof faceMaterialIndex === "number" ? BOX_FACE_IDS[faceMaterialIndex] ?? null : null;
|
|
|
|
|
|
|
|
|
|
if (typeof brushId !== "string") {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (faceId !== null) {
|
|
|
|
|
this.brushSelectionChangeHandler?.({
|
|
|
|
|
kind: "brushFace",
|
|
|
|
|
brushId,
|
|
|
|
|
faceId
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.brushSelectionChangeHandler?.({
|
|
|
|
|
kind: "brushes",
|
|
|
|
|
ids: [brushId]
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-31 03:44:17 +02:00
|
|
|
private handlePointerMove = (event: PointerEvent) => {
|
2026-03-31 04:24:06 +02:00
|
|
|
if (this.activeCameraDragPointerId === event.pointerId && this.lastCameraDragClientPosition !== null) {
|
|
|
|
|
const deltaX = event.clientX - this.lastCameraDragClientPosition.x;
|
|
|
|
|
const deltaY = event.clientY - this.lastCameraDragClientPosition.y;
|
|
|
|
|
|
|
|
|
|
this.lastCameraDragClientPosition = {
|
|
|
|
|
x: event.clientX,
|
|
|
|
|
y: event.clientY
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (event.shiftKey) {
|
|
|
|
|
this.panCamera(deltaX, deltaY);
|
|
|
|
|
} else {
|
|
|
|
|
this.orbitCamera(deltaX, deltaY);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:44:17 +02:00
|
|
|
if (this.toolMode !== "box-create") {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.setBoxCreatePreview(this.getBoxCreatePreviewCenter(event));
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-31 04:24:06 +02:00
|
|
|
private handlePointerUp = (event: PointerEvent) => {
|
|
|
|
|
if (this.activeCameraDragPointerId !== event.pointerId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.renderer.domElement.hasPointerCapture(event.pointerId)) {
|
|
|
|
|
this.renderer.domElement.releasePointerCapture(event.pointerId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.activeCameraDragPointerId = null;
|
|
|
|
|
this.lastCameraDragClientPosition = null;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-31 03:44:17 +02:00
|
|
|
private handlePointerLeave = () => {
|
2026-03-31 04:24:06 +02:00
|
|
|
if (this.activeCameraDragPointerId !== null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:44:17 +02:00
|
|
|
if (this.toolMode !== "box-create") {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.setBoxCreatePreview(null);
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-31 04:24:06 +02:00
|
|
|
private handleWheel = (event: WheelEvent) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
this.cameraSpherical.radius = Math.min(
|
|
|
|
|
MAX_CAMERA_DISTANCE,
|
|
|
|
|
Math.max(MIN_CAMERA_DISTANCE, this.cameraSpherical.radius * Math.exp(event.deltaY * ZOOM_SPEED))
|
|
|
|
|
);
|
|
|
|
|
this.applyCameraOrbitPose();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private handleAuxClick = (event: MouseEvent) => {
|
|
|
|
|
if (event.button === 1) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-31 17:39:56 +02:00
|
|
|
private findModelInstanceId(object: Object3D): string | null {
|
|
|
|
|
let current: Object3D | null = object;
|
|
|
|
|
|
|
|
|
|
while (current !== null) {
|
|
|
|
|
const modelInstanceId = current.userData.modelInstanceId;
|
|
|
|
|
|
|
|
|
|
if (typeof modelInstanceId === "string") {
|
|
|
|
|
return modelInstanceId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
current = current.parent;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 04:24:06 +02:00
|
|
|
private orbitCamera(deltaX: number, deltaY: number) {
|
|
|
|
|
this.cameraSpherical.theta -= deltaX * ORBIT_ROTATION_SPEED;
|
2026-03-31 04:37:36 +02:00
|
|
|
this.cameraSpherical.phi -= deltaY * ORBIT_ROTATION_SPEED;
|
2026-03-31 04:24:06 +02:00
|
|
|
this.applyCameraOrbitPose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private panCamera(deltaX: number, deltaY: number) {
|
|
|
|
|
if (this.container === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const width = Math.max(1, this.container.clientWidth);
|
|
|
|
|
const height = Math.max(1, this.container.clientHeight);
|
|
|
|
|
const visibleHeight = 2 * Math.tan((this.camera.fov * Math.PI) / 360) * this.cameraSpherical.radius;
|
|
|
|
|
const visibleWidth = visibleHeight * Math.max(this.camera.aspect, 0.0001);
|
|
|
|
|
|
|
|
|
|
this.camera.getWorldDirection(this.cameraForward);
|
|
|
|
|
this.cameraRight.crossVectors(this.cameraForward, this.camera.up).normalize();
|
|
|
|
|
this.cameraUp.crossVectors(this.cameraRight, this.cameraForward).normalize();
|
|
|
|
|
|
|
|
|
|
this.cameraTarget
|
|
|
|
|
.addScaledVector(this.cameraRight, (-deltaX / width) * visibleWidth)
|
|
|
|
|
.addScaledVector(this.cameraUp, (deltaY / height) * visibleHeight);
|
|
|
|
|
|
|
|
|
|
this.applyCameraOrbitPose();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:44:17 +02:00
|
|
|
private getBoxCreatePreviewCenter(event: PointerEvent): Vec3 | null {
|
|
|
|
|
const bounds = this.renderer.domElement.getBoundingClientRect();
|
|
|
|
|
|
|
|
|
|
if (bounds.width === 0 || bounds.height === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
|
|
|
|
|
this.pointer.y = -(((event.clientY - bounds.top) / bounds.height) * 2 - 1);
|
|
|
|
|
this.raycaster.setFromCamera(this.pointer, this.camera);
|
|
|
|
|
|
|
|
|
|
if (this.raycaster.ray.intersectPlane(this.boxCreatePlane, this.boxCreateIntersection) === null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
x: snapValueToGrid(this.boxCreateIntersection.x, DEFAULT_GRID_SIZE),
|
|
|
|
|
y: DEFAULT_BOX_BRUSH_SIZE.y * 0.5,
|
|
|
|
|
z: snapValueToGrid(this.boxCreateIntersection.z, DEFAULT_GRID_SIZE)
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private setBoxCreatePreview(center: Vec3 | null) {
|
|
|
|
|
if (
|
|
|
|
|
(center === null && this.lastBoxCreatePreviewCenter === null) ||
|
|
|
|
|
(center !== null &&
|
|
|
|
|
this.lastBoxCreatePreviewCenter !== null &&
|
|
|
|
|
center.x === this.lastBoxCreatePreviewCenter.x &&
|
|
|
|
|
center.y === this.lastBoxCreatePreviewCenter.y &&
|
|
|
|
|
center.z === this.lastBoxCreatePreviewCenter.z)
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.lastBoxCreatePreviewCenter = center === null ? null : { ...center };
|
|
|
|
|
this.boxCreatePreviewMesh.visible = center !== null;
|
|
|
|
|
this.boxCreatePreviewEdges.visible = center !== null;
|
|
|
|
|
|
|
|
|
|
if (center !== null) {
|
|
|
|
|
this.boxCreatePreviewMesh.position.set(center.x, center.y, center.z);
|
|
|
|
|
this.boxCreatePreviewEdges.position.set(center.x, center.y, center.z);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.boxCreatePreviewHandler?.(this.lastBoxCreatePreviewCenter);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:35:02 +02:00
|
|
|
private render = () => {
|
|
|
|
|
this.animationFrame = window.requestAnimationFrame(this.render);
|
|
|
|
|
this.renderer.render(this.scene, this.camera);
|
|
|
|
|
};
|
|
|
|
|
}
|