2026-03-31 02:35:02 +02:00
|
|
|
import {
|
|
|
|
|
AmbientLight,
|
|
|
|
|
AxesHelper,
|
2026-04-04 20:09:05 +02:00
|
|
|
BufferGeometry,
|
2026-03-31 02:35:02 +02:00
|
|
|
BoxGeometry,
|
|
|
|
|
CanvasTexture,
|
2026-04-04 15:54:35 +02:00
|
|
|
CapsuleGeometry,
|
2026-03-31 03:06:48 +02:00
|
|
|
ConeGeometry,
|
|
|
|
|
CylinderGeometry,
|
2026-03-31 02:35:02 +02:00
|
|
|
DirectionalLight,
|
|
|
|
|
EdgesGeometry,
|
|
|
|
|
GridHelper,
|
|
|
|
|
Group,
|
2026-04-04 20:09:05 +02:00
|
|
|
Line,
|
2026-03-31 02:35:02 +02:00
|
|
|
LineBasicMaterial,
|
|
|
|
|
LineSegments,
|
2026-04-04 19:07:42 +02:00
|
|
|
Material,
|
2026-03-31 02:35:02 +02:00
|
|
|
Mesh,
|
2026-04-02 22:32:21 +02:00
|
|
|
MeshBasicMaterial,
|
2026-03-31 02:35:02 +02:00
|
|
|
MeshStandardMaterial,
|
2026-03-31 20:05:23 +02:00
|
|
|
Object3D,
|
2026-04-02 22:17:21 +02:00
|
|
|
OrthographicCamera,
|
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-04-02 20:51:14 +02:00
|
|
|
import { EffectComposer } from "postprocessing";
|
2026-03-31 02:35:02 +02:00
|
|
|
|
2026-04-04 20:09:05 +02:00
|
|
|
import {
|
|
|
|
|
areEditorSelectionsEqual,
|
|
|
|
|
isBrushEdgeSelected,
|
|
|
|
|
isBrushFaceSelected,
|
|
|
|
|
isBrushSelected,
|
|
|
|
|
isBrushVertexSelected,
|
|
|
|
|
isModelInstanceSelected,
|
|
|
|
|
type EditorSelection
|
|
|
|
|
} from "../core/selection";
|
|
|
|
|
import { getWhiteboxSelectionFeedbackLabel } from "../core/whitebox-selection-feedback";
|
|
|
|
|
import type { WhiteboxSelectionMode } from "../core/whitebox-selection-mode";
|
2026-04-03 02:11:11 +02:00
|
|
|
import {
|
|
|
|
|
cloneTransformSession,
|
|
|
|
|
createInactiveTransformSession,
|
2026-04-03 02:34:55 +02:00
|
|
|
createTransformPreviewFromTarget,
|
2026-04-03 02:11:11 +02:00
|
|
|
createTransformSession,
|
2026-04-03 02:34:55 +02:00
|
|
|
resolveTransformTarget,
|
|
|
|
|
supportsTransformOperation,
|
2026-04-03 02:11:11 +02:00
|
|
|
supportsTransformAxisConstraint,
|
|
|
|
|
type ActiveTransformSession,
|
|
|
|
|
type TransformAxis,
|
|
|
|
|
type TransformSessionState
|
|
|
|
|
} from "../core/transform-session";
|
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-04-02 23:51:12 +02:00
|
|
|
import {
|
|
|
|
|
createModelInstance,
|
|
|
|
|
createModelInstancePlacementPosition,
|
|
|
|
|
DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES,
|
|
|
|
|
DEFAULT_MODEL_INSTANCE_SCALE,
|
2026-04-03 02:17:11 +02:00
|
|
|
getModelInstances,
|
|
|
|
|
type ModelInstance
|
2026-04-02 23:51:12 +02:00
|
|
|
} from "../assets/model-instances";
|
2026-04-02 20:58:28 +02:00
|
|
|
import type { SceneDocument } from "../document/scene-document";
|
2026-04-02 20:51:14 +02:00
|
|
|
import {
|
|
|
|
|
areAdvancedRenderingSettingsEqual,
|
|
|
|
|
cloneAdvancedRenderingSettings,
|
|
|
|
|
type AdvancedRenderingSettings
|
2026-04-02 20:58:28 +02:00
|
|
|
} from "../document/world-settings";
|
|
|
|
|
import type { WorldSettings } from "../document/world-settings";
|
2026-03-31 20:05:23 +02:00
|
|
|
import {
|
2026-04-02 23:51:12 +02:00
|
|
|
DEFAULT_INTERACTABLE_RADIUS,
|
2026-04-04 15:54:35 +02:00
|
|
|
DEFAULT_PLAYER_START_BOX_SIZE,
|
|
|
|
|
DEFAULT_PLAYER_START_CAPSULE_HEIGHT,
|
|
|
|
|
DEFAULT_PLAYER_START_CAPSULE_RADIUS,
|
2026-04-04 15:55:19 +02:00
|
|
|
DEFAULT_PLAYER_START_EYE_HEIGHT,
|
2026-04-02 23:51:12 +02:00
|
|
|
DEFAULT_PLAYER_START_YAW_DEGREES,
|
|
|
|
|
DEFAULT_POINT_LIGHT_DISTANCE,
|
|
|
|
|
DEFAULT_SOUND_EMITTER_MAX_DISTANCE,
|
|
|
|
|
DEFAULT_SOUND_EMITTER_REF_DISTANCE,
|
|
|
|
|
DEFAULT_SPOT_LIGHT_ANGLE_DEGREES,
|
|
|
|
|
DEFAULT_SPOT_LIGHT_DIRECTION,
|
|
|
|
|
DEFAULT_SPOT_LIGHT_DISTANCE,
|
|
|
|
|
DEFAULT_TELEPORT_TARGET_YAW_DEGREES,
|
|
|
|
|
DEFAULT_TRIGGER_VOLUME_SIZE,
|
2026-04-04 15:54:35 +02:00
|
|
|
getPlayerStartColliderHeight,
|
2026-03-31 20:05:23 +02:00
|
|
|
getEntityInstances,
|
2026-04-03 02:11:11 +02:00
|
|
|
normalizeYawDegrees,
|
2026-03-31 20:05:23 +02:00
|
|
|
type EntityInstance,
|
2026-04-04 15:54:35 +02:00
|
|
|
type PlayerStartEntity,
|
2026-03-31 20:05:23 +02:00
|
|
|
type PointLightEntity,
|
|
|
|
|
type SpotLightEntity
|
|
|
|
|
} from "../entities/entity-instances";
|
2026-04-04 20:09:05 +02:00
|
|
|
import {
|
|
|
|
|
BOX_EDGE_IDS,
|
|
|
|
|
BOX_FACE_IDS,
|
|
|
|
|
BOX_VERTEX_IDS,
|
2026-04-05 02:55:38 +02:00
|
|
|
cloneBoxBrushGeometry,
|
|
|
|
|
deriveBoxBrushSizeFromGeometry,
|
2026-04-05 02:56:50 +02:00
|
|
|
scaleBoxBrushGeometryToSize,
|
2026-04-04 20:09:05 +02:00
|
|
|
DEFAULT_BOX_BRUSH_SIZE,
|
|
|
|
|
type BoxBrush,
|
2026-04-05 02:55:38 +02:00
|
|
|
type BoxBrushGeometry,
|
2026-04-04 20:09:05 +02:00
|
|
|
type BoxEdgeId,
|
|
|
|
|
type BoxFaceId,
|
|
|
|
|
type BoxVertexId
|
|
|
|
|
} from "../document/brushes";
|
2026-04-05 01:56:31 +02:00
|
|
|
import {
|
|
|
|
|
getBoxBrushEdgeAxis,
|
|
|
|
|
getBoxBrushEdgeTransformMeta,
|
|
|
|
|
getBoxBrushEdgeWorldSegment,
|
|
|
|
|
getBoxBrushFaceAxis,
|
|
|
|
|
getBoxBrushFaceTransformMeta,
|
|
|
|
|
getBoxBrushFaceWorldCenter,
|
|
|
|
|
getBoxBrushVertexWorldPosition,
|
2026-04-05 02:55:38 +02:00
|
|
|
transformBoxBrushWorldPointToLocal,
|
2026-04-05 01:56:31 +02:00
|
|
|
transformBoxBrushWorldVectorToLocal
|
|
|
|
|
} from "../geometry/box-brush-components";
|
2026-04-05 02:55:38 +02:00
|
|
|
import {
|
|
|
|
|
buildBoxBrushDerivedMeshData,
|
|
|
|
|
getBoxBrushEdgeVertexIds,
|
|
|
|
|
getBoxBrushFaceVertexIds,
|
|
|
|
|
getBoxBrushLocalVertexPosition
|
|
|
|
|
} from "../geometry/box-brush-mesh";
|
2026-04-06 17:28:25 +02:00
|
|
|
import { getBoxBrushBounds } from "../geometry/box-brush";
|
2026-04-04 07:55:42 +02:00
|
|
|
import { createModelColliderDebugGroup } from "../geometry/model-instance-collider-debug-mesh";
|
2026-04-06 17:28:25 +02:00
|
|
|
import { buildGeneratedModelCollider, type GeneratedColliderBounds } from "../geometry/model-instance-collider-generation";
|
2026-04-04 19:29:34 +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-04-02 20:51:14 +02:00
|
|
|
import {
|
|
|
|
|
applyAdvancedRenderingLightShadowFlags,
|
|
|
|
|
applyAdvancedRenderingRenderableShadowFlags,
|
|
|
|
|
configureAdvancedRenderingRenderer,
|
2026-04-06 08:27:12 +02:00
|
|
|
createAdvancedRenderingComposer,
|
|
|
|
|
resolveBoxVolumeRenderPaths
|
2026-04-02 20:51:14 +02:00
|
|
|
} from "../rendering/advanced-rendering";
|
2026-04-06 17:28:25 +02:00
|
|
|
import { collectWaterContactPatches, createWaterMaterial } from "../rendering/water-material";
|
2026-03-31 04:23:34 +02:00
|
|
|
import { resolveViewportFocusTarget } from "./viewport-focus";
|
2026-04-02 20:27:50 +02:00
|
|
|
import { createSoundEmitterMarkerMeshes } from "./viewport-entity-markers";
|
2026-04-02 22:13:20 +02:00
|
|
|
import {
|
|
|
|
|
getViewportViewModeDefinition,
|
|
|
|
|
isOrthographicViewportViewMode,
|
|
|
|
|
type ViewportGridPlane,
|
|
|
|
|
type ViewportViewMode
|
|
|
|
|
} from "./viewport-view-modes";
|
2026-04-03 02:17:11 +02:00
|
|
|
import {
|
|
|
|
|
areViewportPanelCameraStatesEqual,
|
|
|
|
|
type ViewportDisplayMode,
|
|
|
|
|
type ViewportPanelCameraState,
|
|
|
|
|
type ViewportPanelId
|
|
|
|
|
} from "./viewport-layout";
|
2026-04-02 23:58:12 +02:00
|
|
|
import {
|
|
|
|
|
areViewportToolPreviewsEqual,
|
2026-04-03 00:21:14 +02:00
|
|
|
type CreationTarget,
|
|
|
|
|
type CreationViewportToolPreview,
|
2026-04-02 23:58:12 +02:00
|
|
|
type ViewportToolPreview
|
|
|
|
|
} from "./viewport-transient-state";
|
2026-03-31 02:35:02 +02:00
|
|
|
|
|
|
|
|
interface BrushRenderObjects {
|
2026-04-06 17:28:25 +02:00
|
|
|
mesh: Mesh<BufferGeometry, Material[]>;
|
2026-03-31 02:35:02 +02:00
|
|
|
edges: LineSegments<EdgesGeometry, LineBasicMaterial>;
|
2026-04-04 20:09:15 +02:00
|
|
|
edgeHelpers: Array<{
|
|
|
|
|
id: BoxEdgeId;
|
|
|
|
|
line: Line<BufferGeometry, LineBasicMaterial>;
|
|
|
|
|
}>;
|
|
|
|
|
vertexHelpers: Array<{
|
|
|
|
|
id: BoxVertexId;
|
|
|
|
|
mesh: Mesh<SphereGeometry, MeshBasicMaterial>;
|
|
|
|
|
}>;
|
2026-03-31 02:35:02 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const BRUSH_SELECTED_EDGE_COLOR = 0xf7d2aa;
|
2026-04-04 20:09:15 +02:00
|
|
|
const BRUSH_HOVERED_EDGE_COLOR = 0xb7cbec;
|
2026-03-31 02:35:02 +02:00
|
|
|
const BRUSH_EDGE_COLOR = 0x0d1017;
|
|
|
|
|
const FALLBACK_FACE_COLOR = 0x747d89;
|
2026-04-04 20:09:15 +02:00
|
|
|
const HOVERED_FACE_FALLBACK_COLOR = 0xd9a56f;
|
2026-03-31 02:35:02 +02:00
|
|
|
const SELECTED_FACE_FALLBACK_COLOR = 0xcf7b42;
|
2026-04-04 20:09:15 +02:00
|
|
|
const HOVERED_FACE_EMISSIVE = 0x2f1d11;
|
2026-03-31 02:35:02 +02:00
|
|
|
const SELECTED_FACE_EMISSIVE = 0x4a2814;
|
2026-04-04 20:09:15 +02:00
|
|
|
const WHITEBOX_COMPONENT_COLOR = 0xb7cbec;
|
|
|
|
|
const WHITEBOX_COMPONENT_HOVERED_COLOR = 0xf3be8f;
|
|
|
|
|
const WHITEBOX_COMPONENT_SELECTED_COLOR = 0xcf7b42;
|
|
|
|
|
const WHITEBOX_COMPONENT_DEFAULT_OPACITY = 0.42;
|
|
|
|
|
const WHITEBOX_COMPONENT_HOVERED_OPACITY = 0.94;
|
|
|
|
|
const WHITEBOX_COMPONENT_SELECTED_OPACITY = 1;
|
|
|
|
|
const WHITEBOX_VERTEX_RADIUS = 0.08;
|
|
|
|
|
const WHITEBOX_EDGE_PICK_THRESHOLD = 0.16;
|
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-04-02 23:58:12 +02:00
|
|
|
const PLACEMENT_PREVIEW_COLOR_HEX = "#89b6ff";
|
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-04-02 22:16:36 +02:00
|
|
|
const ORTHOGRAPHIC_CAMERA_DISTANCE = 100;
|
|
|
|
|
const ORTHOGRAPHIC_FRUSTUM_HEIGHT = 20;
|
|
|
|
|
const MIN_ORTHOGRAPHIC_ZOOM = 0.25;
|
|
|
|
|
const MAX_ORTHOGRAPHIC_ZOOM = 20;
|
2026-04-03 02:11:11 +02:00
|
|
|
const GIZMO_AXIS_COLORS: Record<TransformAxis, number> = {
|
|
|
|
|
x: 0xea655b,
|
|
|
|
|
y: 0x6ed06f,
|
|
|
|
|
z: 0x55a2ff
|
|
|
|
|
};
|
|
|
|
|
const GIZMO_ACTIVE_COLOR = 0xf7d2aa;
|
|
|
|
|
const GIZMO_INACTIVE_OPACITY = 0.82;
|
|
|
|
|
const GIZMO_ACTIVE_OPACITY = 1;
|
|
|
|
|
const GIZMO_TRANSLATE_LENGTH = 1.2;
|
|
|
|
|
const GIZMO_SCALE_LENGTH = 1;
|
|
|
|
|
const GIZMO_ROTATE_RADIUS = 1.05;
|
|
|
|
|
const GIZMO_ROTATE_TUBE = 0.035;
|
|
|
|
|
const GIZMO_PICK_THICKNESS = 0.18;
|
|
|
|
|
const GIZMO_PICK_RING_TUBE = 0.14;
|
|
|
|
|
const GIZMO_CENTER_HANDLE_SIZE = 0.16;
|
|
|
|
|
const GIZMO_SCREEN_SIZE_PERSPECTIVE = 0.11;
|
|
|
|
|
const GIZMO_SCREEN_SIZE_ORTHOGRAPHIC = 1.4;
|
2026-04-03 02:34:55 +02:00
|
|
|
const GIZMO_RENDER_ORDER = 4_000;
|
2026-04-03 02:11:11 +02:00
|
|
|
const SCALE_SNAP_STEP = 0.1;
|
|
|
|
|
const MIN_SCALE_COMPONENT = 0.1;
|
2026-04-04 19:28:19 +02:00
|
|
|
const MIN_BOX_SIZE_COMPONENT = 0.01;
|
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();
|
2026-04-02 22:16:36 +02:00
|
|
|
private readonly perspectiveCamera = new PerspectiveCamera(60, 1, 0.1, 1000);
|
|
|
|
|
private readonly orthographicCamera = new OrthographicCamera(-10, 10, 10, -10, 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-04-02 22:16:36 +02:00
|
|
|
private readonly gridHelpers: Record<ViewportGridPlane, GridHelper> = {
|
|
|
|
|
xz: new GridHelper(40, 40, 0xcf8354, 0x4e596b),
|
|
|
|
|
xy: new GridHelper(40, 40, 0xcf8354, 0x4e596b),
|
|
|
|
|
yz: new GridHelper(40, 40, 0xcf8354, 0x4e596b)
|
|
|
|
|
};
|
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-04-03 02:11:11 +02:00
|
|
|
private readonly transformPlane = new Plane(new Vector3(0, 1, 0), 0);
|
|
|
|
|
private readonly transformIntersection = new Vector3();
|
|
|
|
|
private readonly transformGizmoGroup = new Group();
|
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-04-02 20:51:20 +02:00
|
|
|
private currentAdvancedRenderingSettings: AdvancedRenderingSettings | null = null;
|
|
|
|
|
private advancedRenderingComposer: EffectComposer | null = null;
|
2026-03-31 17:39:56 +02:00
|
|
|
private currentSelection: EditorSelection = {
|
|
|
|
|
kind: "none"
|
|
|
|
|
};
|
2026-04-04 20:09:23 +02:00
|
|
|
private hoveredSelection: EditorSelection = {
|
|
|
|
|
kind: "none"
|
|
|
|
|
};
|
|
|
|
|
private whiteboxSelectionMode: WhiteboxSelectionMode = "object";
|
2026-04-04 19:28:19 +02:00
|
|
|
private whiteboxSnapEnabled = true;
|
|
|
|
|
private whiteboxSnapStep = DEFAULT_GRID_SIZE;
|
2026-03-31 17:39:56 +02:00
|
|
|
private projectAssets: Record<string, ProjectAssetRecord> = {};
|
|
|
|
|
private loadedModelAssets: Record<string, LoadedModelAsset> = {};
|
2026-03-31 20:05:23 +02:00
|
|
|
private loadedImageAssets: Record<string, LoadedImageAsset> = {};
|
2026-04-06 17:28:25 +02:00
|
|
|
private volumeTime = 0;
|
|
|
|
|
private previousFrameTime = 0;
|
|
|
|
|
private readonly volumeAnimatedUniforms: Array<{ value: number }> = [];
|
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-04-04 20:09:23 +02:00
|
|
|
private whiteboxHoverLabelChangeHandler: ((label: string | null) => void) | null = null;
|
2026-04-03 00:21:14 +02:00
|
|
|
private creationPreviewChangeHandler: ((toolPreview: ViewportToolPreview) => void) | null = null;
|
2026-04-03 00:32:05 +02:00
|
|
|
private creationCommitHandler: ((toolPreview: CreationViewportToolPreview) => boolean) | null = null;
|
2026-04-03 01:36:56 +02:00
|
|
|
private cameraStateChangeHandler: ((cameraState: ViewportPanelCameraState) => void) | null = null;
|
2026-04-03 02:11:11 +02:00
|
|
|
private transformSessionChangeHandler: ((transformSession: TransformSessionState) => void) | null = null;
|
|
|
|
|
private transformCommitHandler: ((transformSession: ActiveTransformSession) => void) | null = null;
|
|
|
|
|
private transformCancelHandler: (() => void) | null = null;
|
2026-03-31 03:42:16 +02:00
|
|
|
private toolMode: ToolMode = "select";
|
2026-04-02 22:16:36 +02:00
|
|
|
private viewMode: ViewportViewMode = "perspective";
|
2026-04-02 22:32:21 +02:00
|
|
|
private displayMode: ViewportDisplayMode = "normal";
|
2026-04-03 02:11:11 +02:00
|
|
|
private panelId: ViewportPanelId = "topLeft";
|
2026-04-03 00:21:14 +02:00
|
|
|
private creationPreview: CreationViewportToolPreview | null = null;
|
|
|
|
|
private creationPreviewTargetKey: string | null = null;
|
|
|
|
|
private creationPreviewObject: Group | null = null;
|
2026-04-03 02:11:11 +02:00
|
|
|
private currentTransformSession: TransformSessionState = createInactiveTransformSession();
|
2026-03-31 04:23:40 +02:00
|
|
|
private activeCameraDragPointerId: number | null = null;
|
|
|
|
|
private lastCameraDragClientPosition: { x: number; y: number } | null = null;
|
2026-04-03 02:11:11 +02:00
|
|
|
private activeTransformDrag:
|
|
|
|
|
| {
|
|
|
|
|
pointerId: number;
|
|
|
|
|
sessionId: string;
|
|
|
|
|
axisConstraint: TransformAxis | null;
|
|
|
|
|
initialClientPosition: {
|
|
|
|
|
x: number;
|
|
|
|
|
y: number;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
| null = null;
|
|
|
|
|
private lastCanvasPointerPosition: { x: number; y: number } | null = null;
|
|
|
|
|
private keyboardTransformPointerOrigin:
|
|
|
|
|
| {
|
|
|
|
|
sessionId: string;
|
|
|
|
|
clientX: number;
|
|
|
|
|
clientY: number;
|
|
|
|
|
}
|
|
|
|
|
| null = null;
|
2026-04-01 04:36:15 +02:00
|
|
|
// Click-through cycling: track the last click position and the last picked object
|
|
|
|
|
// so repeated clicks at the same spot cycle through overlapping objects.
|
|
|
|
|
private lastClickPointer: { x: number; y: number } | null = null;
|
|
|
|
|
private lastClickSelectionKey: string | null = null;
|
2026-03-31 02:35:02 +02:00
|
|
|
|
|
|
|
|
constructor() {
|
2026-04-02 22:11:00 +02:00
|
|
|
this.perspectiveCamera.position.set(10, 9, 10);
|
|
|
|
|
this.perspectiveCamera.lookAt(this.cameraTarget);
|
|
|
|
|
this.updatePerspectiveCameraSphericalFromPose();
|
|
|
|
|
this.updateOrthographicCameraFrustum();
|
2026-03-31 02:35:02 +02:00
|
|
|
|
|
|
|
|
const axesHelper = new AxesHelper(2);
|
|
|
|
|
|
2026-04-02 22:13:00 +02:00
|
|
|
this.gridHelpers.xy.rotation.x = Math.PI * 0.5;
|
|
|
|
|
this.gridHelpers.yz.rotation.z = Math.PI * 0.5;
|
2026-04-02 22:11:00 +02:00
|
|
|
this.gridHelpers.xz.visible = true;
|
|
|
|
|
this.gridHelpers.xy.visible = false;
|
|
|
|
|
this.gridHelpers.yz.visible = false;
|
|
|
|
|
|
|
|
|
|
this.scene.add(this.gridHelpers.xz);
|
|
|
|
|
this.scene.add(this.gridHelpers.xy);
|
|
|
|
|
this.scene.add(this.gridHelpers.yz);
|
2026-03-31 02:35:02 +02:00
|
|
|
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-04-03 02:11:11 +02:00
|
|
|
this.transformGizmoGroup.visible = false;
|
|
|
|
|
this.scene.add(this.transformGizmoGroup);
|
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-04-02 22:16:43 +02:00
|
|
|
this.applyViewModePose();
|
2026-03-31 02:35:02 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-03 02:11:11 +02:00
|
|
|
setPanelId(panelId: ViewportPanelId) {
|
|
|
|
|
this.panelId = panelId;
|
|
|
|
|
}
|
|
|
|
|
|
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-04-04 07:51:38 +02:00
|
|
|
this.renderer.domElement.addEventListener("contextmenu", this.handleContextMenu);
|
2026-04-03 02:34:55 +02:00
|
|
|
window.addEventListener("pointermove", this.handleWindowPointerMove);
|
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-04-04 20:09:41 +02:00
|
|
|
this.setHoveredSelection({
|
|
|
|
|
kind: "none"
|
|
|
|
|
});
|
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);
|
2026-04-03 02:11:11 +02:00
|
|
|
this.applyTransformPreview();
|
|
|
|
|
this.syncTransformGizmo();
|
2026-03-31 17:39:56 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 20:07:59 +02:00
|
|
|
updateAssets(
|
|
|
|
|
projectAssets: Record<string, ProjectAssetRecord>,
|
|
|
|
|
loadedModelAssets: Record<string, LoadedModelAsset>,
|
|
|
|
|
loadedImageAssets: Record<string, LoadedImageAsset>
|
|
|
|
|
) {
|
2026-03-31 17:39:56 +02:00
|
|
|
this.projectAssets = projectAssets;
|
|
|
|
|
this.loadedModelAssets = loadedModelAssets;
|
2026-03-31 20:07:59 +02:00
|
|
|
this.loadedImageAssets = loadedImageAssets;
|
2026-03-31 17:39:56 +02:00
|
|
|
|
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-04-03 02:11:43 +02:00
|
|
|
this.applyTransformPreview();
|
|
|
|
|
this.syncTransformGizmo();
|
2026-03-31 17:39:56 +02:00
|
|
|
}
|
2026-04-03 01:10:42 +02:00
|
|
|
|
|
|
|
|
if (this.creationPreview?.target.kind === "model-instance") {
|
|
|
|
|
const currentPreview = this.creationPreview;
|
|
|
|
|
this.creationPreview = null;
|
|
|
|
|
this.clearCreationPreviewObject();
|
|
|
|
|
this.syncCreationPreview(currentPreview);
|
|
|
|
|
}
|
2026-03-31 02:35:02 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setBrushSelectionChangeHandler(handler: ((selection: EditorSelection) => void) | null) {
|
|
|
|
|
this.brushSelectionChangeHandler = handler;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 20:09:41 +02:00
|
|
|
setWhiteboxHoverLabelChangeHandler(handler: ((label: string | null) => void) | null) {
|
|
|
|
|
this.whiteboxHoverLabelChangeHandler = handler;
|
|
|
|
|
this.emitWhiteboxHoverLabelChange();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 00:21:14 +02:00
|
|
|
setCreationPreviewChangeHandler(handler: ((toolPreview: ViewportToolPreview) => void) | null) {
|
|
|
|
|
this.creationPreviewChangeHandler = handler;
|
2026-03-31 03:42:16 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-03 00:32:05 +02:00
|
|
|
setCreationCommitHandler(handler: ((toolPreview: CreationViewportToolPreview) => boolean) | null) {
|
2026-04-03 00:21:14 +02:00
|
|
|
this.creationCommitHandler = handler;
|
2026-04-02 23:02:19 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-03 01:36:56 +02:00
|
|
|
setCameraStateChangeHandler(handler: ((cameraState: ViewportPanelCameraState) => void) | null) {
|
|
|
|
|
this.cameraStateChangeHandler = handler;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 02:11:11 +02:00
|
|
|
setTransformSessionChangeHandler(handler: ((transformSession: TransformSessionState) => void) | null) {
|
|
|
|
|
this.transformSessionChangeHandler = handler;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setTransformCommitHandler(handler: ((transformSession: ActiveTransformSession) => void) | null) {
|
|
|
|
|
this.transformCommitHandler = handler;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setTransformCancelHandler(handler: (() => void) | null) {
|
|
|
|
|
this.transformCancelHandler = handler;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 01:36:56 +02:00
|
|
|
setCameraState(cameraState: ViewportPanelCameraState) {
|
|
|
|
|
if (areViewportPanelCameraStatesEqual(this.createCameraStateSnapshot(), cameraState)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.cameraTarget.set(cameraState.target.x, cameraState.target.y, cameraState.target.z);
|
|
|
|
|
this.cameraSpherical.radius = cameraState.perspectiveOrbit.radius;
|
|
|
|
|
this.cameraSpherical.theta = cameraState.perspectiveOrbit.theta;
|
|
|
|
|
this.cameraSpherical.phi = cameraState.perspectiveOrbit.phi;
|
|
|
|
|
this.orthographicCamera.zoom = cameraState.orthographicZoom;
|
|
|
|
|
this.applyViewModePose();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 00:21:14 +02:00
|
|
|
setCreationPreview(toolPreview: CreationViewportToolPreview | null) {
|
|
|
|
|
this.syncCreationPreview(toolPreview);
|
2026-04-02 23:50:42 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-04 19:28:23 +02:00
|
|
|
setWhiteboxSnapSettings(enabled: boolean, step: number) {
|
|
|
|
|
this.whiteboxSnapEnabled = enabled;
|
|
|
|
|
this.whiteboxSnapStep = step;
|
|
|
|
|
|
|
|
|
|
if (this.creationPreview !== null) {
|
|
|
|
|
this.syncCreationPreview(this.creationPreview);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.applyTransformPreview();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 20:09:41 +02:00
|
|
|
setWhiteboxSelectionMode(mode: WhiteboxSelectionMode) {
|
|
|
|
|
if (this.whiteboxSelectionMode === mode) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.whiteboxSelectionMode = mode;
|
|
|
|
|
this.lastClickPointer = null;
|
|
|
|
|
this.lastClickSelectionKey = null;
|
|
|
|
|
this.setHoveredSelection({
|
|
|
|
|
kind: "none"
|
|
|
|
|
});
|
|
|
|
|
this.refreshBrushPresentation();
|
|
|
|
|
this.syncTransformGizmo();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 02:11:11 +02:00
|
|
|
setTransformSession(transformSession: TransformSessionState) {
|
|
|
|
|
this.currentTransformSession = cloneTransformSession(transformSession);
|
|
|
|
|
|
|
|
|
|
if (this.currentTransformSession.kind === "none") {
|
|
|
|
|
this.activeTransformDrag = null;
|
|
|
|
|
this.keyboardTransformPointerOrigin = null;
|
|
|
|
|
} else if (
|
|
|
|
|
this.currentTransformSession.sourcePanelId === this.panelId &&
|
|
|
|
|
this.currentTransformSession.source !== "gizmo" &&
|
|
|
|
|
(this.keyboardTransformPointerOrigin === null || this.keyboardTransformPointerOrigin.sessionId !== this.currentTransformSession.id)
|
|
|
|
|
) {
|
|
|
|
|
const pointerOrigin = this.getPointerOriginForTransformSession();
|
|
|
|
|
this.keyboardTransformPointerOrigin = {
|
|
|
|
|
sessionId: this.currentTransformSession.id,
|
|
|
|
|
clientX: pointerOrigin.x,
|
|
|
|
|
clientY: pointerOrigin.y
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.applyTransformPreview();
|
|
|
|
|
this.syncTransformGizmo();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:42:16 +02:00
|
|
|
setToolMode(toolMode: ToolMode) {
|
|
|
|
|
this.toolMode = toolMode;
|
2026-04-01 04:37:04 +02:00
|
|
|
this.lastClickPointer = null;
|
|
|
|
|
this.lastClickSelectionKey = null;
|
2026-04-04 20:09:41 +02:00
|
|
|
this.setHoveredSelection({
|
|
|
|
|
kind: "none"
|
|
|
|
|
});
|
2026-03-31 03:42:16 +02:00
|
|
|
|
2026-04-03 00:21:14 +02:00
|
|
|
if (toolMode !== "create") {
|
|
|
|
|
this.syncCreationPreview(null);
|
2026-04-02 23:50:42 +02:00
|
|
|
}
|
2026-03-31 03:42:16 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-02 22:11:33 +02:00
|
|
|
setViewMode(viewMode: ViewportViewMode) {
|
|
|
|
|
if (this.viewMode === viewMode) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.viewMode = viewMode;
|
|
|
|
|
this.lastClickPointer = null;
|
|
|
|
|
this.lastClickSelectionKey = null;
|
2026-04-04 20:09:41 +02:00
|
|
|
this.setHoveredSelection({
|
|
|
|
|
kind: "none"
|
|
|
|
|
});
|
2026-04-02 22:11:33 +02:00
|
|
|
|
|
|
|
|
this.applyViewModePose();
|
|
|
|
|
|
|
|
|
|
if (this.currentAdvancedRenderingSettings !== null) {
|
|
|
|
|
this.syncAdvancedRenderingComposer(this.currentAdvancedRenderingSettings);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 22:32:21 +02:00
|
|
|
setDisplayMode(displayMode: ViewportDisplayMode) {
|
|
|
|
|
if (this.displayMode === displayMode) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.displayMode = displayMode;
|
|
|
|
|
this.applyWorld();
|
|
|
|
|
|
|
|
|
|
if (this.currentDocument !== null) {
|
|
|
|
|
this.updateDocument(this.currentDocument, this.currentSelection);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 04:23:57 +02:00
|
|
|
focusSelection(document: SceneDocument, selection: EditorSelection) {
|
|
|
|
|
const focusTarget = resolveViewportFocusTarget(document, selection);
|
|
|
|
|
|
|
|
|
|
if (focusTarget === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.cameraTarget.set(focusTarget.center.x, focusTarget.center.y, focusTarget.center.z);
|
2026-04-02 22:11:33 +02:00
|
|
|
|
|
|
|
|
if (this.viewMode === "perspective") {
|
|
|
|
|
const verticalHalfFov = (this.perspectiveCamera.fov * Math.PI) / 360;
|
|
|
|
|
const horizontalHalfFov = Math.atan(Math.tan(verticalHalfFov) * Math.max(this.perspectiveCamera.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.cameraSpherical.radius = fitDistance;
|
|
|
|
|
this.cameraSpherical.makeSafe();
|
|
|
|
|
this.applyPerspectiveCameraPose();
|
2026-04-03 01:36:56 +02:00
|
|
|
this.emitCameraStateChange();
|
2026-04-02 22:11:33 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const containerWidth = Math.max(1, this.container?.clientWidth ?? 1);
|
|
|
|
|
const containerHeight = Math.max(1, this.container?.clientHeight ?? 1);
|
|
|
|
|
const aspect = containerWidth / containerHeight;
|
|
|
|
|
const visibleWidth = ORTHOGRAPHIC_FRUSTUM_HEIGHT * aspect;
|
|
|
|
|
const fitSize = Math.max(0.5, focusTarget.radius * 2 * FOCUS_MARGIN);
|
|
|
|
|
const fitZoom = Math.min(visibleWidth, ORTHOGRAPHIC_FRUSTUM_HEIGHT) / fitSize;
|
|
|
|
|
|
|
|
|
|
this.orthographicCamera.zoom = Math.min(MAX_ORTHOGRAPHIC_ZOOM, Math.max(MIN_ORTHOGRAPHIC_ZOOM, fitZoom));
|
|
|
|
|
this.applyOrthographicCameraPose();
|
2026-04-03 01:36:56 +02:00
|
|
|
this.emitCameraStateChange();
|
2026-03-31 04:23:57 +02:00
|
|
|
}
|
|
|
|
|
|
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-04-04 07:51:38 +02:00
|
|
|
this.renderer.domElement.removeEventListener("contextmenu", this.handleContextMenu);
|
2026-04-03 02:34:55 +02:00
|
|
|
window.removeEventListener("pointermove", this.handleWindowPointerMove);
|
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-04-03 00:21:14 +02:00
|
|
|
this.creationPreviewChangeHandler = null;
|
|
|
|
|
this.creationCommitHandler = null;
|
2026-04-03 01:36:56 +02:00
|
|
|
this.cameraStateChangeHandler = null;
|
2026-04-03 02:11:43 +02:00
|
|
|
this.transformSessionChangeHandler = null;
|
|
|
|
|
this.transformCommitHandler = null;
|
|
|
|
|
this.transformCancelHandler = null;
|
|
|
|
|
this.currentTransformSession = createInactiveTransformSession();
|
|
|
|
|
this.clearTransformGizmo();
|
|
|
|
|
this.activeTransformDrag = null;
|
|
|
|
|
this.keyboardTransformPointerOrigin = null;
|
2026-04-03 00:21:14 +02:00
|
|
|
this.syncCreationPreview(null);
|
2026-04-02 20:51:45 +02:00
|
|
|
this.advancedRenderingComposer?.dispose();
|
|
|
|
|
this.advancedRenderingComposer = null;
|
|
|
|
|
this.currentAdvancedRenderingSettings = null;
|
|
|
|
|
this.renderer.autoClear = true;
|
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-04-02 22:11:33 +02:00
|
|
|
private getActiveCamera() {
|
|
|
|
|
return this.viewMode === "perspective" ? this.perspectiveCamera : this.orthographicCamera;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 01:36:56 +02:00
|
|
|
private createCameraStateSnapshot(): ViewportPanelCameraState {
|
|
|
|
|
return {
|
|
|
|
|
target: {
|
|
|
|
|
x: this.cameraTarget.x,
|
|
|
|
|
y: this.cameraTarget.y,
|
|
|
|
|
z: this.cameraTarget.z
|
|
|
|
|
},
|
|
|
|
|
perspectiveOrbit: {
|
|
|
|
|
radius: this.cameraSpherical.radius,
|
|
|
|
|
theta: this.cameraSpherical.theta,
|
|
|
|
|
phi: this.cameraSpherical.phi
|
|
|
|
|
},
|
|
|
|
|
orthographicZoom: this.orthographicCamera.zoom
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private emitCameraStateChange() {
|
|
|
|
|
this.cameraStateChangeHandler?.(this.createCameraStateSnapshot());
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 22:11:33 +02:00
|
|
|
private updatePerspectiveCameraSphericalFromPose() {
|
|
|
|
|
this.cameraOffset.copy(this.perspectiveCamera.position).sub(this.cameraTarget);
|
2026-03-31 04:23:57 +02:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 22:11:33 +02:00
|
|
|
private updateOrthographicCameraFrustum() {
|
|
|
|
|
if (this.container === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const width = this.container.clientWidth;
|
|
|
|
|
const height = this.container.clientHeight;
|
|
|
|
|
|
|
|
|
|
if (width === 0 || height === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const aspect = width / height;
|
|
|
|
|
const halfHeight = ORTHOGRAPHIC_FRUSTUM_HEIGHT * 0.5;
|
|
|
|
|
const halfWidth = halfHeight * aspect;
|
|
|
|
|
|
|
|
|
|
this.orthographicCamera.left = -halfWidth;
|
|
|
|
|
this.orthographicCamera.right = halfWidth;
|
|
|
|
|
this.orthographicCamera.top = halfHeight;
|
|
|
|
|
this.orthographicCamera.bottom = -halfHeight;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private applyPerspectiveCameraPose() {
|
2026-03-31 04:23:57 +02:00
|
|
|
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);
|
2026-04-02 22:11:33 +02:00
|
|
|
this.perspectiveCamera.position.copy(this.cameraTarget).add(this.cameraOffset);
|
|
|
|
|
this.perspectiveCamera.lookAt(this.cameraTarget);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private applyOrthographicCameraPose() {
|
|
|
|
|
const definition = getViewportViewModeDefinition(this.viewMode);
|
|
|
|
|
|
|
|
|
|
if (!isOrthographicViewportViewMode(this.viewMode) || definition.cameraDirection === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.orthographicCamera.up.set(definition.cameraUp.x, definition.cameraUp.y, definition.cameraUp.z);
|
|
|
|
|
this.orthographicCamera.position.set(
|
|
|
|
|
this.cameraTarget.x + definition.cameraDirection.x * ORTHOGRAPHIC_CAMERA_DISTANCE,
|
|
|
|
|
this.cameraTarget.y + definition.cameraDirection.y * ORTHOGRAPHIC_CAMERA_DISTANCE,
|
|
|
|
|
this.cameraTarget.z + definition.cameraDirection.z * ORTHOGRAPHIC_CAMERA_DISTANCE
|
|
|
|
|
);
|
|
|
|
|
this.orthographicCamera.lookAt(this.cameraTarget);
|
|
|
|
|
this.orthographicCamera.zoom = Math.min(MAX_ORTHOGRAPHIC_ZOOM, Math.max(MIN_ORTHOGRAPHIC_ZOOM, this.orthographicCamera.zoom));
|
|
|
|
|
this.orthographicCamera.updateProjectionMatrix();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private applyViewModePose() {
|
|
|
|
|
const definition = getViewportViewModeDefinition(this.viewMode);
|
|
|
|
|
|
|
|
|
|
this.gridHelpers.xz.visible = definition.gridPlane === "xz";
|
|
|
|
|
this.gridHelpers.xy.visible = definition.gridPlane === "xy";
|
|
|
|
|
this.gridHelpers.yz.visible = definition.gridPlane === "yz";
|
|
|
|
|
|
|
|
|
|
if (definition.cameraType === "perspective") {
|
|
|
|
|
this.applyPerspectiveCameraPose();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.updateOrthographicCameraFrustum();
|
|
|
|
|
this.applyOrthographicCameraPose();
|
2026-03-31 04:23:57 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-04 19:07:42 +02:00
|
|
|
private createWireframeDisplayMaterial(material: Material): MeshBasicMaterial {
|
|
|
|
|
const source = material as Material & {
|
|
|
|
|
color?: { getHex(): number };
|
|
|
|
|
transparent?: boolean;
|
|
|
|
|
opacity?: number;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-04 19:06:06 +02:00
|
|
|
return new MeshBasicMaterial({
|
2026-04-04 19:07:42 +02:00
|
|
|
color: source.color?.getHex() ?? FALLBACK_FACE_COLOR,
|
2026-04-04 19:06:06 +02:00
|
|
|
wireframe: true,
|
2026-04-04 19:07:42 +02:00
|
|
|
transparent: source.transparent === true || (source.opacity ?? 1) < 1,
|
|
|
|
|
opacity: source.opacity ?? 1,
|
2026-04-04 19:06:06 +02:00
|
|
|
depthWrite: false
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private applyWireframePresentation(object: Object3D) {
|
|
|
|
|
object.traverse((child) => {
|
|
|
|
|
const maybeMesh = child as Mesh & { isMesh?: boolean };
|
|
|
|
|
|
|
|
|
|
if (maybeMesh.isMesh !== true) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (Array.isArray(maybeMesh.material)) {
|
2026-04-04 19:06:57 +02:00
|
|
|
const originalMaterials = maybeMesh.material;
|
|
|
|
|
maybeMesh.material = originalMaterials.map((material) => this.createWireframeDisplayMaterial(material));
|
|
|
|
|
for (const material of originalMaterials) {
|
|
|
|
|
material.dispose();
|
|
|
|
|
}
|
2026-04-04 19:06:06 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 19:06:57 +02:00
|
|
|
const originalMaterial = maybeMesh.material;
|
|
|
|
|
maybeMesh.material = this.createWireframeDisplayMaterial(originalMaterial);
|
|
|
|
|
originalMaterial.dispose();
|
2026-04-04 19:06:06 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 22:12:24 +02:00
|
|
|
private getBoxCreatePlane() {
|
|
|
|
|
switch (this.viewMode) {
|
|
|
|
|
case "perspective":
|
|
|
|
|
case "top":
|
|
|
|
|
return this.boxCreatePlane.set(new Vector3(0, 1, 0), 0);
|
|
|
|
|
case "front":
|
|
|
|
|
return this.boxCreatePlane.set(new Vector3(0, 0, 1), 0);
|
|
|
|
|
case "side":
|
|
|
|
|
return this.boxCreatePlane.set(new Vector3(1, 0, 0), 0);
|
2026-04-02 22:16:43 +02:00
|
|
|
default:
|
|
|
|
|
return this.boxCreatePlane.set(new Vector3(0, 1, 0), 0);
|
2026-04-02 22:12:24 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 20:05:58 +02:00
|
|
|
private applyWorld() {
|
|
|
|
|
if (this.currentWorld === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const world = this.currentWorld;
|
2026-04-02 22:36:18 +02:00
|
|
|
const rendererSettings =
|
2026-04-04 19:06:06 +02:00
|
|
|
this.displayMode !== "normal"
|
2026-04-02 22:36:18 +02:00
|
|
|
? {
|
|
|
|
|
...cloneAdvancedRenderingSettings(world.advancedRendering),
|
|
|
|
|
enabled: false
|
|
|
|
|
}
|
|
|
|
|
: world.advancedRendering;
|
2026-03-31 20:05:58 +02:00
|
|
|
this.ambientLight.color.set(world.ambientLight.colorHex);
|
|
|
|
|
this.ambientLight.intensity = world.ambientLight.intensity;
|
|
|
|
|
this.sunLight.color.set(world.sunLight.colorHex);
|
|
|
|
|
this.sunLight.intensity = world.sunLight.intensity;
|
|
|
|
|
this.sunLight.position.set(world.sunLight.direction.x, world.sunLight.direction.y, world.sunLight.direction.z).normalize().multiplyScalar(18);
|
2026-04-04 19:06:06 +02:00
|
|
|
this.ambientLight.visible = this.displayMode !== "wireframe";
|
|
|
|
|
this.sunLight.visible = this.displayMode !== "wireframe";
|
|
|
|
|
this.localLightGroup.visible = this.displayMode !== "wireframe";
|
2026-03-31 20:05:58 +02:00
|
|
|
|
2026-04-04 19:06:06 +02:00
|
|
|
if (this.displayMode !== "normal") {
|
2026-04-02 22:32:21 +02:00
|
|
|
this.scene.background = null;
|
|
|
|
|
this.scene.environment = null;
|
|
|
|
|
this.scene.environmentIntensity = 1;
|
|
|
|
|
} else if (world.background.mode === "image") {
|
2026-03-31 23:14:33 +02:00
|
|
|
const texture = this.loadedImageAssets[world.background.assetId]?.texture ?? null;
|
|
|
|
|
this.scene.background = texture;
|
|
|
|
|
this.scene.environment = texture;
|
|
|
|
|
this.scene.environmentIntensity = world.background.environmentIntensity;
|
2026-04-02 20:51:34 +02:00
|
|
|
} else {
|
|
|
|
|
this.scene.background = null;
|
|
|
|
|
this.scene.environment = null;
|
|
|
|
|
this.scene.environmentIntensity = 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 22:36:18 +02:00
|
|
|
configureAdvancedRenderingRenderer(this.renderer, rendererSettings);
|
|
|
|
|
this.syncAdvancedRenderingComposer(rendererSettings);
|
2026-04-02 20:51:34 +02:00
|
|
|
this.applyShadowState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private syncAdvancedRenderingComposer(settings: AdvancedRenderingSettings) {
|
2026-04-02 22:32:21 +02:00
|
|
|
const shouldUseComposer = settings.enabled && this.displayMode === "normal" && this.viewMode === "perspective";
|
2026-04-02 20:51:34 +02:00
|
|
|
const settingsChanged =
|
|
|
|
|
this.currentAdvancedRenderingSettings === null ||
|
|
|
|
|
!areAdvancedRenderingSettingsEqual(this.currentAdvancedRenderingSettings, settings);
|
|
|
|
|
|
|
|
|
|
if (!shouldUseComposer) {
|
|
|
|
|
if (this.advancedRenderingComposer !== null) {
|
|
|
|
|
this.advancedRenderingComposer.dispose();
|
|
|
|
|
this.advancedRenderingComposer = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 22:17:49 +02:00
|
|
|
this.currentAdvancedRenderingSettings = settings.enabled ? cloneAdvancedRenderingSettings(settings) : null;
|
2026-04-02 20:51:34 +02:00
|
|
|
this.renderer.autoClear = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 22:17:49 +02:00
|
|
|
if (this.advancedRenderingComposer !== null && !settingsChanged) {
|
2026-04-02 20:51:34 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.advancedRenderingComposer !== null) {
|
|
|
|
|
this.advancedRenderingComposer.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 22:17:49 +02:00
|
|
|
this.advancedRenderingComposer = createAdvancedRenderingComposer(this.renderer, this.scene, this.perspectiveCamera, settings);
|
2026-04-02 20:51:34 +02:00
|
|
|
this.currentAdvancedRenderingSettings = cloneAdvancedRenderingSettings(settings);
|
|
|
|
|
this.renderer.autoClear = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private applyShadowState() {
|
|
|
|
|
if (this.currentWorld === null) {
|
2026-03-31 20:05:58 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 20:51:34 +02:00
|
|
|
const advancedRendering = this.currentWorld.advancedRendering;
|
2026-04-02 22:34:11 +02:00
|
|
|
const shadowsEnabled = advancedRendering.enabled && advancedRendering.shadows.enabled && this.displayMode === "normal";
|
2026-04-02 22:39:44 +02:00
|
|
|
const shadowSettings =
|
|
|
|
|
this.displayMode === "normal"
|
|
|
|
|
? advancedRendering
|
|
|
|
|
: {
|
|
|
|
|
...advancedRendering,
|
|
|
|
|
enabled: false
|
|
|
|
|
};
|
2026-04-02 20:51:34 +02:00
|
|
|
|
2026-04-02 22:39:44 +02:00
|
|
|
applyAdvancedRenderingLightShadowFlags(this.sunLight, shadowSettings);
|
2026-04-02 20:51:34 +02:00
|
|
|
|
|
|
|
|
for (const renderObjects of this.localLightRenderObjects.values()) {
|
2026-04-02 22:39:44 +02:00
|
|
|
applyAdvancedRenderingLightShadowFlags(renderObjects.group, shadowSettings);
|
2026-04-02 20:51:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const renderObjects of this.brushRenderObjects.values()) {
|
|
|
|
|
applyAdvancedRenderingRenderableShadowFlags(renderObjects.mesh, shadowsEnabled);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const renderGroup of this.modelRenderObjects.values()) {
|
|
|
|
|
applyAdvancedRenderingRenderableShadowFlags(renderGroup, shadowsEnabled);
|
|
|
|
|
}
|
2026-03-31 20:05:58 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-03 02:12:44 +02:00
|
|
|
private getPointerOriginForTransformSession() {
|
|
|
|
|
if (this.lastCanvasPointerPosition !== null) {
|
|
|
|
|
return this.lastCanvasPointerPosition;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const bounds = this.renderer.domElement.getBoundingClientRect();
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
x: bounds.left + bounds.width * 0.5,
|
|
|
|
|
y: bounds.top + bounds.height * 0.5
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private axisVector(axis: TransformAxis): Vector3 {
|
|
|
|
|
switch (axis) {
|
|
|
|
|
case "x":
|
|
|
|
|
return new Vector3(1, 0, 0);
|
|
|
|
|
case "y":
|
|
|
|
|
return new Vector3(0, 1, 0);
|
|
|
|
|
case "z":
|
|
|
|
|
return new Vector3(0, 0, 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private normalizeDegrees(value: number): number {
|
|
|
|
|
const normalized = value % 360;
|
|
|
|
|
return normalized < 0 ? normalized + 360 : normalized;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private snapScaleValue(value: number): number {
|
|
|
|
|
return Math.max(MIN_SCALE_COMPONENT, Math.round(value / SCALE_SNAP_STEP) * SCALE_SNAP_STEP);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 19:28:33 +02:00
|
|
|
private snapWhiteboxPositionValue(value: number): number {
|
|
|
|
|
return this.whiteboxSnapEnabled ? snapValueToGrid(value, this.whiteboxSnapStep) : value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private snapWhiteboxSizeValue(value: number): number {
|
|
|
|
|
if (!Number.isFinite(value)) {
|
|
|
|
|
throw new Error("Whitebox box size values must be finite numbers.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.whiteboxSnapEnabled) {
|
|
|
|
|
return Math.max(MIN_BOX_SIZE_COMPONENT, Math.abs(value));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Math.max(MIN_BOX_SIZE_COMPONENT, snapValueToGrid(Math.abs(value), this.whiteboxSnapStep));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 02:12:44 +02:00
|
|
|
private getAxisComponent(vector: Vec3, axis: TransformAxis): number {
|
|
|
|
|
switch (axis) {
|
|
|
|
|
case "x":
|
|
|
|
|
return vector.x;
|
|
|
|
|
case "y":
|
|
|
|
|
return vector.y;
|
|
|
|
|
case "z":
|
|
|
|
|
return vector.z;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private setAxisComponent(vector: Vec3, axis: TransformAxis, value: number): Vec3 {
|
|
|
|
|
switch (axis) {
|
|
|
|
|
case "x":
|
|
|
|
|
return {
|
|
|
|
|
...vector,
|
|
|
|
|
x: value
|
|
|
|
|
};
|
|
|
|
|
case "y":
|
|
|
|
|
return {
|
|
|
|
|
...vector,
|
|
|
|
|
y: value
|
|
|
|
|
};
|
|
|
|
|
case "z":
|
|
|
|
|
return {
|
|
|
|
|
...vector,
|
|
|
|
|
z: value
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getEffectiveRotationAxis(session: ActiveTransformSession): TransformAxis {
|
2026-04-05 01:56:36 +02:00
|
|
|
if (session.target.kind === "brushFace") {
|
|
|
|
|
return getBoxBrushFaceAxis(session.target.faceId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (session.target.kind === "brushEdge") {
|
|
|
|
|
return getBoxBrushEdgeAxis(session.target.edgeId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 02:12:44 +02:00
|
|
|
if (session.target.kind === "entity" && session.target.initialRotation.kind === "yaw") {
|
|
|
|
|
return "y";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return session.axisConstraint ?? "y";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getTransformPivotPosition(session: ActiveTransformSession): Vec3 {
|
2026-04-05 01:56:36 +02:00
|
|
|
if (session.preview.kind === "brush") {
|
2026-04-05 01:56:59 +02:00
|
|
|
const previewBrush = this.createPreviewBrushForSession(session);
|
2026-04-05 01:56:36 +02:00
|
|
|
|
2026-04-05 01:56:59 +02:00
|
|
|
if (previewBrush !== null) {
|
|
|
|
|
if (session.target.kind === "brushFace") {
|
|
|
|
|
return getBoxBrushFaceWorldCenter(previewBrush, session.target.faceId);
|
|
|
|
|
}
|
2026-04-05 01:56:36 +02:00
|
|
|
|
2026-04-05 01:56:59 +02:00
|
|
|
if (session.target.kind === "brushEdge") {
|
|
|
|
|
return getBoxBrushEdgeWorldSegment(previewBrush, session.target.edgeId).center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (session.target.kind === "brushVertex") {
|
|
|
|
|
return getBoxBrushVertexWorldPosition(previewBrush, session.target.vertexId);
|
|
|
|
|
}
|
2026-04-05 01:56:36 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 02:12:44 +02:00
|
|
|
switch (session.preview.kind) {
|
|
|
|
|
case "brush":
|
|
|
|
|
return session.preview.center;
|
|
|
|
|
case "modelInstance":
|
|
|
|
|
return session.preview.position;
|
|
|
|
|
case "entity":
|
|
|
|
|
return session.preview.position;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 01:56:59 +02:00
|
|
|
private createPreviewBrushForSession(session: ActiveTransformSession): BoxBrush | null {
|
|
|
|
|
if (session.preview.kind !== "brush") {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
session.target.kind !== "brush" &&
|
|
|
|
|
session.target.kind !== "brushFace" &&
|
|
|
|
|
session.target.kind !== "brushEdge" &&
|
|
|
|
|
session.target.kind !== "brushVertex"
|
|
|
|
|
) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const currentBrush = this.currentDocument?.brushes[session.target.brushId];
|
|
|
|
|
|
|
|
|
|
if (currentBrush === undefined || currentBrush.kind !== "box") {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...currentBrush,
|
|
|
|
|
center: {
|
|
|
|
|
...session.preview.center
|
|
|
|
|
},
|
|
|
|
|
rotationDegrees: {
|
|
|
|
|
...session.preview.rotationDegrees
|
|
|
|
|
},
|
|
|
|
|
size: {
|
|
|
|
|
...session.preview.size
|
2026-04-05 02:58:22 +02:00
|
|
|
},
|
|
|
|
|
geometry: cloneBoxBrushGeometry(session.preview.geometry)
|
2026-04-05 01:56:59 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 02:12:44 +02:00
|
|
|
private clearTransformGizmo() {
|
|
|
|
|
for (const child of [...this.transformGizmoGroup.children]) {
|
|
|
|
|
this.transformGizmoGroup.remove(child);
|
|
|
|
|
|
|
|
|
|
child.traverse((object) => {
|
|
|
|
|
const maybeMesh = object as Mesh & { isMesh?: boolean };
|
|
|
|
|
|
|
|
|
|
if (maybeMesh.isMesh === true) {
|
|
|
|
|
maybeMesh.geometry.dispose();
|
|
|
|
|
|
|
|
|
|
if (Array.isArray(maybeMesh.material)) {
|
|
|
|
|
for (const material of maybeMesh.material) {
|
|
|
|
|
material.dispose();
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
maybeMesh.material.dispose();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.transformGizmoGroup.visible = false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 02:34:55 +02:00
|
|
|
private markTransformHandleObject<TObject extends Object3D>(object: TObject): TObject {
|
|
|
|
|
object.renderOrder = GIZMO_RENDER_ORDER;
|
|
|
|
|
|
|
|
|
|
object.traverse((child) => {
|
|
|
|
|
child.renderOrder = GIZMO_RENDER_ORDER;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return object;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 02:12:44 +02:00
|
|
|
private createTransformHandleMaterial(color: number, isActive: boolean, transparent = false) {
|
|
|
|
|
return new MeshBasicMaterial({
|
|
|
|
|
color,
|
|
|
|
|
transparent: transparent || isActive,
|
|
|
|
|
opacity: transparent ? 0.001 : isActive ? GIZMO_ACTIVE_OPACITY : GIZMO_INACTIVE_OPACITY,
|
2026-04-03 02:34:55 +02:00
|
|
|
depthWrite: false,
|
|
|
|
|
depthTest: false
|
2026-04-03 02:12:44 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private createTranslateHandle(axis: TransformAxis, isActive: boolean): Group {
|
|
|
|
|
const axisVector = this.axisVector(axis);
|
|
|
|
|
const color = isActive ? GIZMO_ACTIVE_COLOR : GIZMO_AXIS_COLORS[axis];
|
|
|
|
|
const group = new Group();
|
|
|
|
|
const line = new Mesh(
|
|
|
|
|
new CylinderGeometry(0.025, 0.025, GIZMO_TRANSLATE_LENGTH, 10),
|
|
|
|
|
this.createTransformHandleMaterial(color, isActive)
|
|
|
|
|
);
|
|
|
|
|
const arrow = new Mesh(
|
|
|
|
|
new ConeGeometry(0.09, 0.28, 12),
|
|
|
|
|
this.createTransformHandleMaterial(color, isActive)
|
|
|
|
|
);
|
|
|
|
|
const pick = new Mesh(
|
|
|
|
|
new CylinderGeometry(GIZMO_PICK_THICKNESS, GIZMO_PICK_THICKNESS, GIZMO_TRANSLATE_LENGTH + 0.36, 10),
|
|
|
|
|
this.createTransformHandleMaterial(color, isActive, true)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
line.position.copy(axisVector).multiplyScalar(GIZMO_TRANSLATE_LENGTH * 0.5);
|
|
|
|
|
arrow.position.copy(axisVector).multiplyScalar(GIZMO_TRANSLATE_LENGTH + 0.18);
|
|
|
|
|
pick.position.copy(axisVector).multiplyScalar((GIZMO_TRANSLATE_LENGTH + 0.36) * 0.5);
|
|
|
|
|
|
|
|
|
|
if (axis === "x") {
|
|
|
|
|
line.rotation.z = -Math.PI * 0.5;
|
|
|
|
|
arrow.rotation.z = -Math.PI * 0.5;
|
|
|
|
|
pick.rotation.z = -Math.PI * 0.5;
|
|
|
|
|
} else if (axis === "z") {
|
|
|
|
|
line.rotation.x = Math.PI * 0.5;
|
|
|
|
|
arrow.rotation.x = Math.PI * 0.5;
|
|
|
|
|
pick.rotation.x = Math.PI * 0.5;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pick.userData.transformAxisConstraint = axis;
|
|
|
|
|
|
|
|
|
|
group.add(line);
|
|
|
|
|
group.add(arrow);
|
|
|
|
|
group.add(pick);
|
2026-04-03 02:34:55 +02:00
|
|
|
return this.markTransformHandleObject(group);
|
2026-04-03 02:12:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private createRotateHandle(axis: TransformAxis, isActive: boolean): Group {
|
|
|
|
|
const color = isActive ? GIZMO_ACTIVE_COLOR : GIZMO_AXIS_COLORS[axis];
|
|
|
|
|
const group = new Group();
|
|
|
|
|
const ring = new Mesh(
|
|
|
|
|
new TorusGeometry(GIZMO_ROTATE_RADIUS, GIZMO_ROTATE_TUBE, 8, 48),
|
|
|
|
|
this.createTransformHandleMaterial(color, isActive)
|
|
|
|
|
);
|
|
|
|
|
const pick = new Mesh(
|
|
|
|
|
new TorusGeometry(GIZMO_ROTATE_RADIUS, GIZMO_PICK_RING_TUBE, 8, 36),
|
|
|
|
|
this.createTransformHandleMaterial(color, isActive, true)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (axis === "x") {
|
|
|
|
|
ring.rotation.y = Math.PI * 0.5;
|
|
|
|
|
pick.rotation.y = Math.PI * 0.5;
|
|
|
|
|
} else if (axis === "y") {
|
|
|
|
|
ring.rotation.x = Math.PI * 0.5;
|
|
|
|
|
pick.rotation.x = Math.PI * 0.5;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pick.userData.transformAxisConstraint = axis;
|
|
|
|
|
group.add(ring);
|
|
|
|
|
group.add(pick);
|
2026-04-03 02:34:55 +02:00
|
|
|
return this.markTransformHandleObject(group);
|
2026-04-03 02:12:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private createScaleHandle(axis: TransformAxis, isActive: boolean): Group {
|
|
|
|
|
const axisVector = this.axisVector(axis);
|
|
|
|
|
const color = isActive ? GIZMO_ACTIVE_COLOR : GIZMO_AXIS_COLORS[axis];
|
|
|
|
|
const group = new Group();
|
|
|
|
|
const line = new Mesh(
|
|
|
|
|
new CylinderGeometry(0.022, 0.022, GIZMO_SCALE_LENGTH, 10),
|
|
|
|
|
this.createTransformHandleMaterial(color, isActive)
|
|
|
|
|
);
|
|
|
|
|
const cube = new Mesh(
|
|
|
|
|
new BoxGeometry(0.16, 0.16, 0.16),
|
|
|
|
|
this.createTransformHandleMaterial(color, isActive)
|
|
|
|
|
);
|
|
|
|
|
const pick = new Mesh(
|
|
|
|
|
new CylinderGeometry(GIZMO_PICK_THICKNESS, GIZMO_PICK_THICKNESS, GIZMO_SCALE_LENGTH + 0.3, 10),
|
|
|
|
|
this.createTransformHandleMaterial(color, isActive, true)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
line.position.copy(axisVector).multiplyScalar(GIZMO_SCALE_LENGTH * 0.5);
|
|
|
|
|
cube.position.copy(axisVector).multiplyScalar(GIZMO_SCALE_LENGTH + 0.12);
|
|
|
|
|
pick.position.copy(axisVector).multiplyScalar((GIZMO_SCALE_LENGTH + 0.3) * 0.5);
|
|
|
|
|
|
|
|
|
|
if (axis === "x") {
|
|
|
|
|
line.rotation.z = -Math.PI * 0.5;
|
|
|
|
|
pick.rotation.z = -Math.PI * 0.5;
|
|
|
|
|
} else if (axis === "z") {
|
|
|
|
|
line.rotation.x = Math.PI * 0.5;
|
|
|
|
|
pick.rotation.x = Math.PI * 0.5;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pick.userData.transformAxisConstraint = axis;
|
|
|
|
|
|
|
|
|
|
group.add(line);
|
|
|
|
|
group.add(cube);
|
|
|
|
|
group.add(pick);
|
2026-04-03 02:34:55 +02:00
|
|
|
return this.markTransformHandleObject(group);
|
2026-04-03 02:12:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private createUniformScaleHandle(isActive: boolean): Mesh {
|
|
|
|
|
const mesh = new Mesh(
|
|
|
|
|
new BoxGeometry(GIZMO_CENTER_HANDLE_SIZE, GIZMO_CENTER_HANDLE_SIZE, GIZMO_CENTER_HANDLE_SIZE),
|
|
|
|
|
this.createTransformHandleMaterial(isActive ? GIZMO_ACTIVE_COLOR : 0xe6edf8, isActive)
|
|
|
|
|
);
|
|
|
|
|
mesh.userData.transformAxisConstraint = null;
|
2026-04-03 02:34:55 +02:00
|
|
|
return this.markTransformHandleObject(mesh);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getDisplayedTransformSession(): ActiveTransformSession | null {
|
|
|
|
|
if (this.currentTransformSession.kind === "active") {
|
|
|
|
|
return this.currentTransformSession;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.toolMode !== "select" || this.currentDocument === null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 20:09:56 +02:00
|
|
|
const transformTarget = resolveTransformTarget(this.currentDocument, this.currentSelection, this.whiteboxSelectionMode).target;
|
2026-04-03 02:34:55 +02:00
|
|
|
|
|
|
|
|
if (transformTarget === null || !supportsTransformOperation(transformTarget, "translate")) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
kind: "active",
|
|
|
|
|
id: "__selection-translate-gizmo__",
|
|
|
|
|
source: "gizmo",
|
|
|
|
|
sourcePanelId: this.panelId,
|
|
|
|
|
operation: "translate",
|
|
|
|
|
axisConstraint: null,
|
|
|
|
|
target: transformTarget,
|
|
|
|
|
preview: createTransformPreviewFromTarget(transformTarget)
|
|
|
|
|
};
|
2026-04-03 02:12:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private syncTransformGizmo() {
|
|
|
|
|
this.clearTransformGizmo();
|
|
|
|
|
|
2026-04-03 02:34:55 +02:00
|
|
|
const session = this.getDisplayedTransformSession();
|
|
|
|
|
|
|
|
|
|
if (session === null) {
|
2026-04-03 02:12:44 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const effectiveRotationAxis = session.operation === "rotate" ? this.getEffectiveRotationAxis(session) : null;
|
|
|
|
|
|
|
|
|
|
if (session.operation === "translate") {
|
|
|
|
|
this.transformGizmoGroup.add(this.createTranslateHandle("x", session.axisConstraint === "x"));
|
|
|
|
|
this.transformGizmoGroup.add(this.createTranslateHandle("y", session.axisConstraint === "y"));
|
|
|
|
|
this.transformGizmoGroup.add(this.createTranslateHandle("z", session.axisConstraint === "z"));
|
|
|
|
|
} else if (session.operation === "rotate") {
|
2026-04-03 02:14:19 +02:00
|
|
|
for (const axis of ["x", "y", "z"] as const) {
|
|
|
|
|
if (!supportsTransformAxisConstraint(session, axis)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.transformGizmoGroup.add(this.createRotateHandle(axis, effectiveRotationAxis === axis));
|
|
|
|
|
}
|
2026-04-05 01:58:00 +02:00
|
|
|
} else if (
|
|
|
|
|
session.operation === "scale" &&
|
|
|
|
|
(session.target.kind === "modelInstance" ||
|
|
|
|
|
session.target.kind === "brush" ||
|
|
|
|
|
session.target.kind === "brushFace" ||
|
|
|
|
|
session.target.kind === "brushEdge")
|
|
|
|
|
) {
|
2026-04-03 02:14:19 +02:00
|
|
|
for (const axis of ["x", "y", "z"] as const) {
|
|
|
|
|
this.transformGizmoGroup.add(this.createScaleHandle(axis, session.axisConstraint === axis));
|
|
|
|
|
}
|
2026-04-03 02:12:44 +02:00
|
|
|
this.transformGizmoGroup.add(this.createUniformScaleHandle(session.axisConstraint === null));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.transformGizmoGroup.visible = this.transformGizmoGroup.children.length > 0;
|
|
|
|
|
this.updateTransformGizmoPose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private updateTransformGizmoPose() {
|
2026-04-03 02:34:55 +02:00
|
|
|
const session = this.getDisplayedTransformSession();
|
|
|
|
|
|
|
|
|
|
if (session === null || !this.transformGizmoGroup.visible) {
|
2026-04-03 02:12:44 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 02:34:55 +02:00
|
|
|
const pivot = this.getTransformPivotPosition(session);
|
2026-04-03 02:12:44 +02:00
|
|
|
const pivotVector = new Vector3(pivot.x, pivot.y, pivot.z);
|
|
|
|
|
|
|
|
|
|
this.transformGizmoGroup.position.copy(pivotVector);
|
|
|
|
|
|
|
|
|
|
let scale = GIZMO_SCREEN_SIZE_ORTHOGRAPHIC / Math.max(this.orthographicCamera.zoom, 0.0001);
|
|
|
|
|
|
|
|
|
|
if (this.viewMode === "perspective") {
|
|
|
|
|
scale = Math.max(0.5, pivotVector.distanceTo(this.perspectiveCamera.position) * GIZMO_SCREEN_SIZE_PERSPECTIVE);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.transformGizmoGroup.scale.setScalar(scale);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getTransformPlaneForPivot(pivot: Vec3): Plane {
|
|
|
|
|
switch (this.viewMode) {
|
|
|
|
|
case "perspective":
|
|
|
|
|
case "top":
|
|
|
|
|
return this.transformPlane.set(new Vector3(0, 1, 0), -pivot.y);
|
|
|
|
|
case "front":
|
|
|
|
|
return this.transformPlane.set(new Vector3(0, 0, 1), -pivot.z);
|
|
|
|
|
case "side":
|
|
|
|
|
return this.transformPlane.set(new Vector3(1, 0, 0), -pivot.x);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private setPointerFromClientPosition(clientX: number, clientY: number): boolean {
|
|
|
|
|
const bounds = this.renderer.domElement.getBoundingClientRect();
|
|
|
|
|
|
|
|
|
|
if (bounds.width === 0 || bounds.height === 0) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.pointer.x = ((clientX - bounds.left) / bounds.width) * 2 - 1;
|
|
|
|
|
this.pointer.y = -(((clientY - bounds.top) / bounds.height) * 2 - 1);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getPointerPlaneIntersection(clientX: number, clientY: number, plane: Plane): Vector3 | null {
|
|
|
|
|
if (!this.setPointerFromClientPosition(clientX, clientY)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.raycaster.setFromCamera(this.pointer, this.getActiveCamera());
|
|
|
|
|
|
|
|
|
|
if (this.raycaster.ray.intersectPlane(plane, this.transformIntersection) === null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.transformIntersection.clone();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getFallbackWorldUnitsPerPixel(pivot: Vec3): number {
|
|
|
|
|
if (this.container === null) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const height = Math.max(1, this.container.clientHeight);
|
|
|
|
|
|
|
|
|
|
if (this.viewMode === "perspective") {
|
|
|
|
|
const pivotVector = new Vector3(pivot.x, pivot.y, pivot.z);
|
|
|
|
|
const distance = pivotVector.distanceTo(this.perspectiveCamera.position);
|
|
|
|
|
const visibleHeight = 2 * Math.tan((this.perspectiveCamera.fov * Math.PI) / 360) * distance;
|
|
|
|
|
return visibleHeight / height;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ORTHOGRAPHIC_FRUSTUM_HEIGHT / this.orthographicCamera.zoom / height;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getAxisMovementDistance(
|
|
|
|
|
axis: TransformAxis,
|
|
|
|
|
pivot: Vec3,
|
|
|
|
|
origin: { x: number; y: number },
|
|
|
|
|
current: { x: number; y: number }
|
|
|
|
|
): number {
|
|
|
|
|
const pivotVector = new Vector3(pivot.x, pivot.y, pivot.z);
|
|
|
|
|
const projectedStart = pivotVector.clone().project(this.getActiveCamera());
|
|
|
|
|
const projectedEnd = pivotVector.clone().add(this.axisVector(axis)).project(this.getActiveCamera());
|
|
|
|
|
const screenDelta = new Vector2(projectedEnd.x - projectedStart.x, projectedEnd.y - projectedStart.y);
|
|
|
|
|
const pointerDelta = new Vector2(current.x - origin.x, current.y - origin.y);
|
|
|
|
|
|
|
|
|
|
if (this.container !== null) {
|
|
|
|
|
screenDelta.set((screenDelta.x * this.container.clientWidth) * 0.5, (-screenDelta.y * this.container.clientHeight) * 0.5);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const axisLength = screenDelta.length();
|
|
|
|
|
|
|
|
|
|
if (axisLength >= 0.0001) {
|
|
|
|
|
screenDelta.normalize();
|
|
|
|
|
return pointerDelta.dot(screenDelta) / axisLength;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return -(current.y - origin.y) * this.getFallbackWorldUnitsPerPixel(pivot);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildTransformPreviewFromPointer(
|
|
|
|
|
session: ActiveTransformSession,
|
|
|
|
|
origin: { x: number; y: number },
|
|
|
|
|
current: { x: number; y: number },
|
|
|
|
|
axisConstraint: TransformAxis | null
|
|
|
|
|
): ActiveTransformSession {
|
|
|
|
|
const nextSession = cloneTransformSession(session) as ActiveTransformSession;
|
|
|
|
|
nextSession.axisConstraint = axisConstraint;
|
|
|
|
|
|
|
|
|
|
switch (session.operation) {
|
|
|
|
|
case "translate":
|
|
|
|
|
nextSession.preview = this.buildTranslatedPreview(session, origin, current, axisConstraint);
|
|
|
|
|
return nextSession;
|
|
|
|
|
case "rotate":
|
|
|
|
|
nextSession.preview = this.buildRotatedPreview(session, origin, current, axisConstraint);
|
|
|
|
|
return nextSession;
|
|
|
|
|
case "scale":
|
|
|
|
|
nextSession.preview = this.buildScaledPreview(session, origin, current, axisConstraint);
|
|
|
|
|
return nextSession;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildTranslatedPreview(
|
|
|
|
|
session: ActiveTransformSession,
|
|
|
|
|
origin: { x: number; y: number },
|
|
|
|
|
current: { x: number; y: number },
|
|
|
|
|
axisConstraint: TransformAxis | null
|
|
|
|
|
) {
|
2026-04-05 01:56:36 +02:00
|
|
|
if (session.target.kind === "brushFace" || session.target.kind === "brushEdge" || session.target.kind === "brushVertex") {
|
|
|
|
|
return this.buildComponentTranslatedBrushPreview(session, origin, current, axisConstraint);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 02:12:44 +02:00
|
|
|
const initialPosition =
|
|
|
|
|
session.target.kind === "brush" ? session.target.initialCenter : session.target.kind === "modelInstance" ? session.target.initialPosition : session.target.initialPosition;
|
|
|
|
|
let nextPosition = {
|
|
|
|
|
...initialPosition
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (axisConstraint === null) {
|
|
|
|
|
const plane = this.getTransformPlaneForPivot(initialPosition);
|
|
|
|
|
const startIntersection = this.getPointerPlaneIntersection(origin.x, origin.y, plane);
|
|
|
|
|
const currentIntersection = this.getPointerPlaneIntersection(current.x, current.y, plane);
|
|
|
|
|
|
|
|
|
|
if (startIntersection !== null && currentIntersection !== null) {
|
|
|
|
|
const delta = currentIntersection.sub(startIntersection);
|
|
|
|
|
|
|
|
|
|
switch (this.viewMode) {
|
|
|
|
|
case "perspective":
|
|
|
|
|
case "top":
|
|
|
|
|
nextPosition = {
|
|
|
|
|
...initialPosition,
|
2026-04-04 19:28:39 +02:00
|
|
|
x: this.snapWhiteboxPositionValue(initialPosition.x + delta.x),
|
|
|
|
|
z: this.snapWhiteboxPositionValue(initialPosition.z + delta.z)
|
2026-04-03 02:12:44 +02:00
|
|
|
};
|
|
|
|
|
break;
|
|
|
|
|
case "front":
|
|
|
|
|
nextPosition = {
|
|
|
|
|
...initialPosition,
|
2026-04-04 19:28:39 +02:00
|
|
|
x: this.snapWhiteboxPositionValue(initialPosition.x + delta.x),
|
|
|
|
|
y: this.snapWhiteboxPositionValue(initialPosition.y + delta.y)
|
2026-04-03 02:12:44 +02:00
|
|
|
};
|
|
|
|
|
break;
|
|
|
|
|
case "side":
|
|
|
|
|
nextPosition = {
|
|
|
|
|
...initialPosition,
|
2026-04-04 19:28:39 +02:00
|
|
|
y: this.snapWhiteboxPositionValue(initialPosition.y + delta.y),
|
|
|
|
|
z: this.snapWhiteboxPositionValue(initialPosition.z + delta.z)
|
2026-04-03 02:12:44 +02:00
|
|
|
};
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const axisDelta = this.getAxisMovementDistance(axisConstraint, initialPosition, origin, current);
|
|
|
|
|
nextPosition = this.setAxisComponent(
|
|
|
|
|
nextPosition,
|
|
|
|
|
axisConstraint,
|
2026-04-04 19:28:39 +02:00
|
|
|
this.snapWhiteboxPositionValue(this.getAxisComponent(initialPosition, axisConstraint) + axisDelta)
|
2026-04-03 02:12:44 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (session.target.kind === "brush") {
|
|
|
|
|
return {
|
|
|
|
|
kind: "brush" as const,
|
2026-04-04 19:28:39 +02:00
|
|
|
center: nextPosition,
|
|
|
|
|
rotationDegrees: {
|
|
|
|
|
...session.target.initialRotationDegrees
|
|
|
|
|
},
|
|
|
|
|
size: {
|
|
|
|
|
...session.target.initialSize
|
2026-04-05 02:56:50 +02:00
|
|
|
},
|
|
|
|
|
geometry: cloneBoxBrushGeometry(session.target.initialGeometry)
|
2026-04-03 02:12:44 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (session.target.kind === "modelInstance") {
|
|
|
|
|
return {
|
|
|
|
|
kind: "modelInstance" as const,
|
|
|
|
|
position: nextPosition,
|
|
|
|
|
rotationDegrees: {
|
|
|
|
|
...session.target.initialRotationDegrees
|
|
|
|
|
},
|
|
|
|
|
scale: {
|
|
|
|
|
...session.target.initialScale
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
kind: "entity" as const,
|
|
|
|
|
position: nextPosition,
|
|
|
|
|
rotation:
|
|
|
|
|
session.target.initialRotation.kind === "yaw"
|
|
|
|
|
? {
|
|
|
|
|
kind: "yaw" as const,
|
|
|
|
|
yawDegrees: session.target.initialRotation.yawDegrees
|
|
|
|
|
}
|
|
|
|
|
: session.target.initialRotation.kind === "direction"
|
|
|
|
|
? {
|
|
|
|
|
kind: "direction" as const,
|
|
|
|
|
direction: {
|
|
|
|
|
...session.target.initialRotation.direction
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
: {
|
|
|
|
|
kind: "none" as const
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildRotatedPreview(
|
|
|
|
|
session: ActiveTransformSession,
|
|
|
|
|
origin: { x: number; y: number },
|
|
|
|
|
current: { x: number; y: number },
|
|
|
|
|
axisConstraint: TransformAxis | null
|
|
|
|
|
) {
|
2026-04-05 01:56:36 +02:00
|
|
|
if (session.target.kind === "brushFace" || session.target.kind === "brushEdge") {
|
|
|
|
|
return this.buildComponentRotatedBrushPreview(session, origin, current, axisConstraint);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 02:12:44 +02:00
|
|
|
const effectiveAxis = axisConstraint ?? this.getEffectiveRotationAxis(session);
|
2026-04-04 19:28:47 +02:00
|
|
|
const pointerDeltaDegrees = (current.x - origin.x - (current.y - origin.y)) * 0.5;
|
|
|
|
|
|
|
|
|
|
if (session.target.kind === "brush") {
|
|
|
|
|
const nextRotationDegrees = {
|
|
|
|
|
...session.target.initialRotationDegrees
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
nextRotationDegrees[effectiveAxis] = this.normalizeDegrees(nextRotationDegrees[effectiveAxis] + pointerDeltaDegrees);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
kind: "brush" as const,
|
|
|
|
|
center: {
|
|
|
|
|
...session.target.initialCenter
|
|
|
|
|
},
|
|
|
|
|
rotationDegrees: nextRotationDegrees,
|
|
|
|
|
size: {
|
|
|
|
|
...session.target.initialSize
|
2026-04-05 02:56:50 +02:00
|
|
|
},
|
|
|
|
|
geometry: cloneBoxBrushGeometry(session.target.initialGeometry)
|
2026-04-04 19:28:47 +02:00
|
|
|
};
|
|
|
|
|
}
|
2026-04-03 02:12:44 +02:00
|
|
|
|
|
|
|
|
if (session.target.kind === "modelInstance") {
|
|
|
|
|
const nextRotationDegrees = {
|
|
|
|
|
...session.target.initialRotationDegrees
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
nextRotationDegrees[effectiveAxis] = this.normalizeDegrees(nextRotationDegrees[effectiveAxis] + pointerDeltaDegrees);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
kind: "modelInstance" as const,
|
|
|
|
|
position: {
|
|
|
|
|
...session.target.initialPosition
|
|
|
|
|
},
|
|
|
|
|
rotationDegrees: nextRotationDegrees,
|
|
|
|
|
scale: {
|
|
|
|
|
...session.target.initialScale
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (session.target.kind !== "entity") {
|
|
|
|
|
throw new Error("Rotation previews are only supported for model instances and rotatable entities.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (session.target.initialRotation.kind === "yaw") {
|
|
|
|
|
return {
|
|
|
|
|
kind: "entity" as const,
|
|
|
|
|
position: {
|
|
|
|
|
...session.target.initialPosition
|
|
|
|
|
},
|
|
|
|
|
rotation: {
|
|
|
|
|
kind: "yaw" as const,
|
|
|
|
|
yawDegrees: normalizeYawDegrees(session.target.initialRotation.yawDegrees + pointerDeltaDegrees)
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (session.target.initialRotation.kind === "direction") {
|
|
|
|
|
const direction = new Vector3(
|
|
|
|
|
session.target.initialRotation.direction.x,
|
|
|
|
|
session.target.initialRotation.direction.y,
|
|
|
|
|
session.target.initialRotation.direction.z
|
|
|
|
|
)
|
|
|
|
|
.normalize()
|
|
|
|
|
.applyAxisAngle(this.axisVector(effectiveAxis).normalize(), (pointerDeltaDegrees * Math.PI) / 180)
|
|
|
|
|
.normalize();
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
kind: "entity" as const,
|
|
|
|
|
position: {
|
|
|
|
|
...session.target.initialPosition
|
|
|
|
|
},
|
|
|
|
|
rotation: {
|
|
|
|
|
kind: "direction" as const,
|
|
|
|
|
direction: {
|
|
|
|
|
x: direction.x,
|
|
|
|
|
y: direction.y,
|
|
|
|
|
z: direction.z
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
kind: "entity" as const,
|
|
|
|
|
position: {
|
|
|
|
|
...session.target.initialPosition
|
|
|
|
|
},
|
|
|
|
|
rotation: {
|
|
|
|
|
kind: "none" as const
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildScaledPreview(
|
|
|
|
|
session: ActiveTransformSession,
|
|
|
|
|
origin: { x: number; y: number },
|
|
|
|
|
current: { x: number; y: number },
|
|
|
|
|
axisConstraint: TransformAxis | null
|
|
|
|
|
) {
|
2026-04-05 01:56:36 +02:00
|
|
|
if (session.target.kind === "brushFace" || session.target.kind === "brushEdge") {
|
|
|
|
|
return this.buildComponentScaledBrushPreview(session, origin, current, axisConstraint);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 19:28:53 +02:00
|
|
|
if (session.target.kind === "brush") {
|
|
|
|
|
const nextSize = {
|
|
|
|
|
...session.target.initialSize
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (axisConstraint === null) {
|
|
|
|
|
const uniformFactor = 1 + (current.x - origin.x - (current.y - origin.y)) * 0.01;
|
|
|
|
|
nextSize.x = this.snapWhiteboxSizeValue(session.target.initialSize.x * uniformFactor);
|
|
|
|
|
nextSize.y = this.snapWhiteboxSizeValue(session.target.initialSize.y * uniformFactor);
|
|
|
|
|
nextSize.z = this.snapWhiteboxSizeValue(session.target.initialSize.z * uniformFactor);
|
|
|
|
|
} else {
|
|
|
|
|
const scaleFactor = 1 + this.getAxisMovementDistance(axisConstraint, session.target.initialCenter, origin, current) * 0.45;
|
|
|
|
|
nextSize[axisConstraint] = this.snapWhiteboxSizeValue(session.target.initialSize[axisConstraint] * scaleFactor);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
kind: "brush" as const,
|
|
|
|
|
center: {
|
|
|
|
|
...session.target.initialCenter
|
|
|
|
|
},
|
|
|
|
|
rotationDegrees: {
|
|
|
|
|
...session.target.initialRotationDegrees
|
|
|
|
|
},
|
|
|
|
|
size: nextSize
|
2026-04-05 02:56:50 +02:00
|
|
|
,
|
|
|
|
|
geometry: scaleBoxBrushGeometryToSize(session.target.initialGeometry, nextSize)
|
2026-04-04 19:28:53 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 02:12:44 +02:00
|
|
|
if (session.target.kind !== "modelInstance") {
|
2026-04-04 19:28:53 +02:00
|
|
|
throw new Error("Scale previews are only supported for model instances and whitebox boxes.");
|
2026-04-03 02:12:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextScale = {
|
|
|
|
|
...session.target.initialScale
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (axisConstraint === null) {
|
|
|
|
|
const uniformFactor = 1 + (current.x - origin.x - (current.y - origin.y)) * 0.01;
|
|
|
|
|
nextScale.x = this.snapScaleValue(session.target.initialScale.x * uniformFactor);
|
|
|
|
|
nextScale.y = this.snapScaleValue(session.target.initialScale.y * uniformFactor);
|
|
|
|
|
nextScale.z = this.snapScaleValue(session.target.initialScale.z * uniformFactor);
|
|
|
|
|
} else {
|
|
|
|
|
const scaleFactor = 1 + this.getAxisMovementDistance(axisConstraint, session.target.initialPosition, origin, current) * 0.45;
|
|
|
|
|
nextScale[axisConstraint] = this.snapScaleValue(session.target.initialScale[axisConstraint] * scaleFactor);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
kind: "modelInstance" as const,
|
|
|
|
|
position: {
|
|
|
|
|
...session.target.initialPosition
|
|
|
|
|
},
|
|
|
|
|
rotationDegrees: {
|
|
|
|
|
...session.target.initialRotationDegrees
|
|
|
|
|
},
|
|
|
|
|
scale: nextScale
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 01:57:31 +02:00
|
|
|
private createTargetPreviewBrush(session: ActiveTransformSession): BoxBrush | null {
|
|
|
|
|
if (
|
|
|
|
|
session.target.kind !== "brush" &&
|
|
|
|
|
session.target.kind !== "brushFace" &&
|
|
|
|
|
session.target.kind !== "brushEdge" &&
|
|
|
|
|
session.target.kind !== "brushVertex"
|
|
|
|
|
) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const currentBrush = this.currentDocument?.brushes[session.target.brushId];
|
|
|
|
|
|
|
|
|
|
if (currentBrush === undefined || currentBrush.kind !== "box") {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...currentBrush,
|
|
|
|
|
center: {
|
|
|
|
|
...session.target.initialCenter
|
|
|
|
|
},
|
|
|
|
|
rotationDegrees: {
|
|
|
|
|
...session.target.initialRotationDegrees
|
|
|
|
|
},
|
|
|
|
|
size: {
|
|
|
|
|
...session.target.initialSize
|
2026-04-05 02:56:50 +02:00
|
|
|
},
|
|
|
|
|
geometry: cloneBoxBrushGeometry(session.target.initialGeometry)
|
2026-04-05 01:57:31 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 02:56:14 +02:00
|
|
|
private createBrushPreviewFromGeometry(brush: BoxBrush, geometry: BoxBrushGeometry): { kind: "brush"; center: Vec3; rotationDegrees: Vec3; size: Vec3; geometry: BoxBrushGeometry } {
|
|
|
|
|
const nextGeometry = cloneBoxBrushGeometry(geometry);
|
2026-04-05 01:57:31 +02:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
kind: "brush",
|
|
|
|
|
center: {
|
2026-04-05 02:56:14 +02:00
|
|
|
...brush.center
|
2026-04-05 01:57:31 +02:00
|
|
|
},
|
|
|
|
|
rotationDegrees: {
|
2026-04-05 02:56:14 +02:00
|
|
|
...brush.rotationDegrees
|
2026-04-05 01:57:31 +02:00
|
|
|
},
|
2026-04-05 02:56:14 +02:00
|
|
|
size: deriveBoxBrushSizeFromGeometry(nextGeometry),
|
|
|
|
|
geometry: nextGeometry
|
2026-04-05 01:57:31 +02:00
|
|
|
};
|
|
|
|
|
}
|
2026-04-05 02:58:22 +02:00
|
|
|
|
2026-04-05 02:56:14 +02:00
|
|
|
private getComponentTargetVertexIds(target: ActiveTransformSession["target"]): BoxVertexId[] {
|
|
|
|
|
switch (target.kind) {
|
|
|
|
|
case "brushFace":
|
|
|
|
|
return [...getBoxBrushFaceVertexIds(target.faceId)];
|
|
|
|
|
case "brushEdge": {
|
|
|
|
|
const [start, end] = getBoxBrushEdgeVertexIds(target.edgeId);
|
|
|
|
|
return [start, end];
|
2026-04-05 01:57:31 +02:00
|
|
|
}
|
2026-04-05 02:56:14 +02:00
|
|
|
case "brushVertex":
|
|
|
|
|
return [target.vertexId];
|
|
|
|
|
default:
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private applyDeltaToVertices(brush: BoxBrush, vertexIds: BoxVertexId[], delta: Vec3): BoxBrushGeometry {
|
|
|
|
|
const nextGeometry = cloneBoxBrushGeometry(brush.geometry);
|
|
|
|
|
|
|
|
|
|
for (const vertexId of vertexIds) {
|
|
|
|
|
const vertex = nextGeometry.vertices[vertexId];
|
|
|
|
|
vertex.x = this.snapWhiteboxPositionValue(vertex.x + delta.x);
|
|
|
|
|
vertex.y = this.snapWhiteboxPositionValue(vertex.y + delta.y);
|
|
|
|
|
vertex.z = this.snapWhiteboxPositionValue(vertex.z + delta.z);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nextGeometry;
|
2026-04-05 01:57:31 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildComponentTranslatedBrushPreview(
|
|
|
|
|
session: ActiveTransformSession,
|
|
|
|
|
origin: { x: number; y: number },
|
|
|
|
|
current: { x: number; y: number },
|
|
|
|
|
axisConstraint: TransformAxis | null
|
|
|
|
|
) {
|
|
|
|
|
const initialBrush = this.createTargetPreviewBrush(session);
|
|
|
|
|
|
|
|
|
|
if (initialBrush === null) {
|
|
|
|
|
throw new Error("Cannot build a component translation preview without a box brush target.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const initialPivot = this.getTransformPivotPosition({
|
|
|
|
|
...session,
|
|
|
|
|
preview: {
|
|
|
|
|
kind: "brush",
|
|
|
|
|
center: { ...initialBrush.center },
|
|
|
|
|
rotationDegrees: { ...initialBrush.rotationDegrees },
|
2026-04-05 02:59:03 +02:00
|
|
|
size: { ...initialBrush.size },
|
|
|
|
|
geometry: cloneBoxBrushGeometry(initialBrush.geometry)
|
2026-04-05 01:57:31 +02:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
let worldDelta = {
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
z: 0
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (axisConstraint === null) {
|
|
|
|
|
const plane = this.getTransformPlaneForPivot(initialPivot);
|
|
|
|
|
const startIntersection = this.getPointerPlaneIntersection(origin.x, origin.y, plane);
|
|
|
|
|
const currentIntersection = this.getPointerPlaneIntersection(current.x, current.y, plane);
|
|
|
|
|
|
|
|
|
|
if (startIntersection !== null && currentIntersection !== null) {
|
|
|
|
|
const delta = currentIntersection.sub(startIntersection);
|
|
|
|
|
worldDelta = {
|
|
|
|
|
x: delta.x,
|
|
|
|
|
y: delta.y,
|
|
|
|
|
z: delta.z
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const axisDelta = this.getAxisMovementDistance(axisConstraint, initialPivot, origin, current);
|
|
|
|
|
worldDelta = this.setAxisComponent(worldDelta, axisConstraint, axisDelta);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const localDelta = transformBoxBrushWorldVectorToLocal(initialBrush, worldDelta);
|
2026-04-05 02:56:14 +02:00
|
|
|
const vertexIds = this.getComponentTargetVertexIds(session.target);
|
|
|
|
|
const nextGeometry = this.applyDeltaToVertices(initialBrush, vertexIds, localDelta);
|
2026-04-05 01:57:31 +02:00
|
|
|
|
2026-04-05 02:56:14 +02:00
|
|
|
return this.createBrushPreviewFromGeometry(initialBrush, nextGeometry);
|
2026-04-05 01:57:31 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildComponentRotatedBrushPreview(
|
|
|
|
|
session: ActiveTransformSession,
|
|
|
|
|
origin: { x: number; y: number },
|
|
|
|
|
current: { x: number; y: number },
|
|
|
|
|
axisConstraint: TransformAxis | null
|
|
|
|
|
) {
|
|
|
|
|
const initialBrush = this.createTargetPreviewBrush(session);
|
|
|
|
|
|
|
|
|
|
if (initialBrush === null) {
|
|
|
|
|
throw new Error("Cannot build a component rotation preview without a box brush target.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const effectiveAxis = axisConstraint ?? this.getEffectiveRotationAxis(session);
|
|
|
|
|
const pointerDeltaDegrees = (current.x - origin.x - (current.y - origin.y)) * 0.5;
|
2026-04-05 02:56:14 +02:00
|
|
|
const pivotWorld = this.getTransformPivotPosition({
|
2026-04-05 01:57:31 +02:00
|
|
|
...session,
|
|
|
|
|
preview: {
|
|
|
|
|
kind: "brush",
|
|
|
|
|
center: { ...initialBrush.center },
|
|
|
|
|
rotationDegrees: { ...initialBrush.rotationDegrees },
|
2026-04-05 02:56:14 +02:00
|
|
|
size: { ...initialBrush.size },
|
|
|
|
|
geometry: cloneBoxBrushGeometry(initialBrush.geometry)
|
2026-04-05 01:57:31 +02:00
|
|
|
}
|
|
|
|
|
});
|
2026-04-05 02:56:14 +02:00
|
|
|
const pivotLocal = transformBoxBrushWorldPointToLocal(initialBrush, pivotWorld);
|
|
|
|
|
const rotationAxis = this.axisVector(effectiveAxis).normalize();
|
|
|
|
|
const vertexIds = this.getComponentTargetVertexIds(session.target);
|
|
|
|
|
const nextGeometry = cloneBoxBrushGeometry(initialBrush.geometry);
|
|
|
|
|
|
|
|
|
|
for (const vertexId of vertexIds) {
|
|
|
|
|
const vertex = getBoxBrushLocalVertexPosition(initialBrush, vertexId);
|
|
|
|
|
const next = new Vector3(vertex.x - pivotLocal.x, vertex.y - pivotLocal.y, vertex.z - pivotLocal.z)
|
|
|
|
|
.applyAxisAngle(rotationAxis, (pointerDeltaDegrees * Math.PI) / 180)
|
|
|
|
|
.add(new Vector3(pivotLocal.x, pivotLocal.y, pivotLocal.z));
|
|
|
|
|
nextGeometry.vertices[vertexId] = {
|
|
|
|
|
x: this.snapWhiteboxPositionValue(next.x),
|
|
|
|
|
y: this.snapWhiteboxPositionValue(next.y),
|
|
|
|
|
z: this.snapWhiteboxPositionValue(next.z)
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-04-05 01:57:31 +02:00
|
|
|
|
2026-04-05 02:56:14 +02:00
|
|
|
return this.createBrushPreviewFromGeometry(initialBrush, nextGeometry);
|
2026-04-05 01:57:31 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildComponentScaledBrushPreview(
|
|
|
|
|
session: ActiveTransformSession,
|
|
|
|
|
origin: { x: number; y: number },
|
|
|
|
|
current: { x: number; y: number },
|
|
|
|
|
axisConstraint: TransformAxis | null
|
|
|
|
|
) {
|
|
|
|
|
const initialBrush = this.createTargetPreviewBrush(session);
|
|
|
|
|
|
|
|
|
|
if (initialBrush === null) {
|
|
|
|
|
throw new Error("Cannot build a component scale preview without a box brush target.");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 02:56:14 +02:00
|
|
|
const pivotWorld = this.getTransformPivotPosition({
|
|
|
|
|
...session,
|
|
|
|
|
preview: {
|
|
|
|
|
kind: "brush",
|
|
|
|
|
center: { ...initialBrush.center },
|
|
|
|
|
rotationDegrees: { ...initialBrush.rotationDegrees },
|
|
|
|
|
size: { ...initialBrush.size },
|
|
|
|
|
geometry: cloneBoxBrushGeometry(initialBrush.geometry)
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
const pivotLocal = transformBoxBrushWorldPointToLocal(initialBrush, pivotWorld);
|
|
|
|
|
const nextGeometry = cloneBoxBrushGeometry(initialBrush.geometry);
|
|
|
|
|
const vertexIds = this.getComponentTargetVertexIds(session.target);
|
2026-04-05 01:57:31 +02:00
|
|
|
|
|
|
|
|
if (session.target.kind === "brushFace") {
|
|
|
|
|
const meta = getBoxBrushFaceTransformMeta(session.target.faceId);
|
2026-04-05 02:56:14 +02:00
|
|
|
const axis = axisConstraint ?? meta.axis;
|
|
|
|
|
const scaleFactor = 1 + this.getAxisMovementDistance(axis, pivotWorld, origin, current) * 0.45;
|
|
|
|
|
|
|
|
|
|
for (const vertexId of vertexIds) {
|
|
|
|
|
const vertex = nextGeometry.vertices[vertexId];
|
|
|
|
|
vertex[axis] = this.snapWhiteboxPositionValue(pivotLocal[axis] + (vertex[axis] - pivotLocal[axis]) * scaleFactor);
|
2026-04-05 01:57:31 +02:00
|
|
|
}
|
|
|
|
|
} else if (session.target.kind === "brushEdge") {
|
|
|
|
|
const meta = getBoxBrushEdgeTransformMeta(session.target.edgeId);
|
|
|
|
|
const affectedAxes = (["x", "y", "z"] as const).filter(
|
|
|
|
|
(axis) => meta.signs[axis] !== null && (axisConstraint === null || axisConstraint === axis)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
for (const axis of affectedAxes) {
|
2026-04-05 02:56:14 +02:00
|
|
|
const scaleFactor = 1 + this.getAxisMovementDistance(axis, pivotWorld, origin, current) * 0.45;
|
2026-04-05 01:57:31 +02:00
|
|
|
|
2026-04-05 02:56:14 +02:00
|
|
|
for (const vertexId of vertexIds) {
|
|
|
|
|
const vertex = nextGeometry.vertices[vertexId];
|
|
|
|
|
vertex[axis] = this.snapWhiteboxPositionValue(pivotLocal[axis] + (vertex[axis] - pivotLocal[axis]) * scaleFactor);
|
2026-04-05 01:57:31 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 02:56:14 +02:00
|
|
|
return this.createBrushPreviewFromGeometry(initialBrush, nextGeometry);
|
2026-04-05 01:57:31 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-05 02:56:50 +02:00
|
|
|
private updateBrushRenderObjectGeometry(brush: BoxBrush) {
|
|
|
|
|
const renderObjects = this.brushRenderObjects.get(brush.id);
|
|
|
|
|
|
|
|
|
|
if (renderObjects === undefined) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextGeometry = buildBoxBrushDerivedMeshData(brush).geometry;
|
|
|
|
|
renderObjects.mesh.geometry.dispose();
|
|
|
|
|
renderObjects.mesh.geometry = nextGeometry;
|
|
|
|
|
renderObjects.edges.geometry.dispose();
|
|
|
|
|
renderObjects.edges.geometry = new EdgesGeometry(nextGeometry);
|
|
|
|
|
|
|
|
|
|
for (const edgeHelper of renderObjects.edgeHelpers) {
|
|
|
|
|
const segment = getBoxBrushEdgeWorldSegment(brush, edgeHelper.id);
|
|
|
|
|
const nextEdgeGeometry = new BufferGeometry().setFromPoints([
|
|
|
|
|
new Vector3(segment.start.x, segment.start.y, segment.start.z),
|
|
|
|
|
new Vector3(segment.end.x, segment.end.y, segment.end.z)
|
|
|
|
|
]);
|
|
|
|
|
edgeHelper.line.geometry.dispose();
|
|
|
|
|
edgeHelper.line.geometry = nextEdgeGeometry;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const vertexHelper of renderObjects.vertexHelpers) {
|
|
|
|
|
const vertex = getBoxBrushVertexWorldPosition(brush, vertexHelper.id);
|
|
|
|
|
vertexHelper.mesh.position.set(vertex.x, vertex.y, vertex.z);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 02:57:07 +02:00
|
|
|
private applyBrushRenderObjectTransform(brushId: string, center: Vec3, rotationDegrees: Vec3) {
|
2026-04-03 02:12:44 +02:00
|
|
|
const renderObjects = this.brushRenderObjects.get(brushId);
|
|
|
|
|
|
2026-04-05 02:57:07 +02:00
|
|
|
if (renderObjects === undefined) {
|
2026-04-03 02:12:44 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
renderObjects.mesh.position.set(center.x, center.y, center.z);
|
2026-04-04 19:29:04 +02:00
|
|
|
renderObjects.mesh.rotation.set(
|
|
|
|
|
(rotationDegrees.x * Math.PI) / 180,
|
|
|
|
|
(rotationDegrees.y * Math.PI) / 180,
|
|
|
|
|
(rotationDegrees.z * Math.PI) / 180
|
|
|
|
|
);
|
2026-04-05 02:56:50 +02:00
|
|
|
renderObjects.mesh.scale.set(1, 1, 1);
|
2026-04-03 02:12:44 +02:00
|
|
|
renderObjects.edges.position.set(center.x, center.y, center.z);
|
2026-04-04 19:29:04 +02:00
|
|
|
renderObjects.edges.rotation.set(
|
|
|
|
|
(rotationDegrees.x * Math.PI) / 180,
|
|
|
|
|
(rotationDegrees.y * Math.PI) / 180,
|
|
|
|
|
(rotationDegrees.z * Math.PI) / 180
|
|
|
|
|
);
|
2026-04-05 02:56:50 +02:00
|
|
|
renderObjects.edges.scale.set(1, 1, 1);
|
2026-04-03 02:12:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private applySpotLightGroupTransform(group: Group, position: Vec3, direction: Vec3) {
|
|
|
|
|
const forward = new Vector3(direction.x, direction.y, direction.z).normalize();
|
|
|
|
|
const orientation = new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), forward);
|
|
|
|
|
group.position.set(position.x, position.y, position.z);
|
|
|
|
|
group.quaternion.copy(orientation);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private applyEntityRenderObjectTransform(entity: EntityInstance) {
|
|
|
|
|
const renderObjects = this.entityRenderObjects.get(entity.id);
|
|
|
|
|
|
|
|
|
|
if (renderObjects === undefined) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch (entity.kind) {
|
|
|
|
|
case "pointLight":
|
|
|
|
|
case "soundEmitter":
|
|
|
|
|
case "triggerVolume":
|
|
|
|
|
case "interactable":
|
|
|
|
|
renderObjects.group.position.set(entity.position.x, entity.position.y, entity.position.z);
|
|
|
|
|
renderObjects.group.rotation.set(0, 0, 0);
|
|
|
|
|
renderObjects.group.quaternion.identity();
|
|
|
|
|
break;
|
|
|
|
|
case "spotLight":
|
|
|
|
|
this.applySpotLightGroupTransform(renderObjects.group, entity.position, entity.direction);
|
|
|
|
|
break;
|
|
|
|
|
case "playerStart":
|
|
|
|
|
case "teleportTarget":
|
|
|
|
|
renderObjects.group.position.set(entity.position.x, entity.position.y, entity.position.z);
|
|
|
|
|
renderObjects.group.rotation.set(0, (entity.yawDegrees * Math.PI) / 180, 0);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 06:53:35 +02:00
|
|
|
private applyLocalLightRenderObjectTransform(entity: EntityInstance) {
|
|
|
|
|
const renderObjects = this.localLightRenderObjects.get(entity.id);
|
|
|
|
|
|
|
|
|
|
if (renderObjects === undefined) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch (entity.kind) {
|
|
|
|
|
case "pointLight":
|
|
|
|
|
renderObjects.group.position.set(entity.position.x, entity.position.y, entity.position.z);
|
|
|
|
|
renderObjects.group.rotation.set(0, 0, 0);
|
|
|
|
|
renderObjects.group.quaternion.identity();
|
|
|
|
|
break;
|
|
|
|
|
case "spotLight":
|
|
|
|
|
this.applySpotLightGroupTransform(renderObjects.group, entity.position, entity.direction);
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 02:12:44 +02:00
|
|
|
private applyModelInstanceRenderObjectTransform(modelInstance: ModelInstance) {
|
|
|
|
|
const renderGroup = this.modelRenderObjects.get(modelInstance.id);
|
|
|
|
|
|
|
|
|
|
if (renderGroup === undefined) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
renderGroup.position.set(modelInstance.position.x, modelInstance.position.y, modelInstance.position.z);
|
|
|
|
|
renderGroup.rotation.set(
|
|
|
|
|
(modelInstance.rotationDegrees.x * Math.PI) / 180,
|
|
|
|
|
(modelInstance.rotationDegrees.y * Math.PI) / 180,
|
|
|
|
|
(modelInstance.rotationDegrees.z * Math.PI) / 180
|
|
|
|
|
);
|
|
|
|
|
renderGroup.scale.set(modelInstance.scale.x, modelInstance.scale.y, modelInstance.scale.z);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private resetRenderObjectTransformsFromDocument() {
|
|
|
|
|
if (this.currentDocument === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const brush of Object.values(this.currentDocument.brushes)) {
|
2026-04-05 02:56:50 +02:00
|
|
|
this.updateBrushRenderObjectGeometry(brush);
|
2026-04-05 02:57:07 +02:00
|
|
|
this.applyBrushRenderObjectTransform(brush.id, brush.center, brush.rotationDegrees);
|
2026-04-03 02:12:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const entity of getEntityInstances(this.currentDocument.entities)) {
|
|
|
|
|
this.applyEntityRenderObjectTransform(entity);
|
2026-04-05 06:53:35 +02:00
|
|
|
this.applyLocalLightRenderObjectTransform(entity);
|
2026-04-03 02:12:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const modelInstance of getModelInstances(this.currentDocument.modelInstances)) {
|
|
|
|
|
this.applyModelInstanceRenderObjectTransform(modelInstance);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private applyTransformPreview() {
|
|
|
|
|
this.resetRenderObjectTransformsFromDocument();
|
|
|
|
|
|
|
|
|
|
if (this.currentTransformSession.kind !== "active") {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch (this.currentTransformSession.target.kind) {
|
|
|
|
|
case "brush":
|
2026-04-05 01:58:00 +02:00
|
|
|
case "brushFace":
|
|
|
|
|
case "brushEdge":
|
|
|
|
|
case "brushVertex":
|
2026-04-03 02:12:44 +02:00
|
|
|
if (this.currentTransformSession.preview.kind === "brush") {
|
2026-04-05 02:56:50 +02:00
|
|
|
const previewBrush = this.createPreviewBrushForSession(this.currentTransformSession);
|
|
|
|
|
|
|
|
|
|
if (previewBrush !== null) {
|
|
|
|
|
this.updateBrushRenderObjectGeometry(previewBrush);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 19:29:04 +02:00
|
|
|
this.applyBrushRenderObjectTransform(
|
|
|
|
|
this.currentTransformSession.target.brushId,
|
|
|
|
|
this.currentTransformSession.preview.center,
|
2026-04-05 02:57:07 +02:00
|
|
|
this.currentTransformSession.preview.rotationDegrees
|
2026-04-04 19:29:04 +02:00
|
|
|
);
|
2026-04-03 02:12:44 +02:00
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case "modelInstance":
|
|
|
|
|
if (this.currentTransformSession.preview.kind === "modelInstance") {
|
|
|
|
|
this.applyModelInstanceRenderObjectTransform({
|
|
|
|
|
...createModelInstance({
|
|
|
|
|
id: this.currentTransformSession.target.modelInstanceId,
|
|
|
|
|
assetId: this.currentTransformSession.target.assetId,
|
|
|
|
|
position: this.currentTransformSession.preview.position,
|
|
|
|
|
rotationDegrees: this.currentTransformSession.preview.rotationDegrees,
|
|
|
|
|
scale: this.currentTransformSession.preview.scale
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case "entity": {
|
|
|
|
|
if (this.currentTransformSession.preview.kind !== "entity" || this.currentDocument === null) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const currentEntity = this.currentDocument.entities[this.currentTransformSession.target.entityId];
|
|
|
|
|
|
|
|
|
|
if (currentEntity === undefined) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch (currentEntity.kind) {
|
|
|
|
|
case "pointLight":
|
|
|
|
|
case "soundEmitter":
|
|
|
|
|
case "triggerVolume":
|
|
|
|
|
case "interactable":
|
|
|
|
|
this.applyEntityRenderObjectTransform({
|
|
|
|
|
...currentEntity,
|
|
|
|
|
position: this.currentTransformSession.preview.position
|
|
|
|
|
});
|
2026-04-05 06:53:35 +02:00
|
|
|
this.applyLocalLightRenderObjectTransform({
|
|
|
|
|
...currentEntity,
|
|
|
|
|
position: this.currentTransformSession.preview.position
|
|
|
|
|
});
|
2026-04-03 02:12:44 +02:00
|
|
|
break;
|
|
|
|
|
case "spotLight":
|
|
|
|
|
this.applyEntityRenderObjectTransform({
|
|
|
|
|
...currentEntity,
|
|
|
|
|
position: this.currentTransformSession.preview.position,
|
|
|
|
|
direction:
|
|
|
|
|
this.currentTransformSession.preview.rotation.kind === "direction"
|
|
|
|
|
? this.currentTransformSession.preview.rotation.direction
|
|
|
|
|
: currentEntity.direction
|
|
|
|
|
});
|
2026-04-05 06:53:35 +02:00
|
|
|
this.applyLocalLightRenderObjectTransform({
|
|
|
|
|
...currentEntity,
|
|
|
|
|
position: this.currentTransformSession.preview.position,
|
|
|
|
|
direction:
|
|
|
|
|
this.currentTransformSession.preview.rotation.kind === "direction"
|
|
|
|
|
? this.currentTransformSession.preview.rotation.direction
|
|
|
|
|
: currentEntity.direction
|
|
|
|
|
});
|
2026-04-03 02:12:44 +02:00
|
|
|
break;
|
|
|
|
|
case "playerStart":
|
|
|
|
|
case "teleportTarget":
|
|
|
|
|
this.applyEntityRenderObjectTransform({
|
|
|
|
|
...currentEntity,
|
|
|
|
|
position: this.currentTransformSession.preview.position,
|
|
|
|
|
yawDegrees:
|
|
|
|
|
this.currentTransformSession.preview.rotation.kind === "yaw"
|
|
|
|
|
? this.currentTransformSession.preview.rotation.yawDegrees
|
|
|
|
|
: currentEntity.yawDegrees
|
|
|
|
|
});
|
2026-04-05 06:53:35 +02:00
|
|
|
this.applyLocalLightRenderObjectTransform({
|
|
|
|
|
...currentEntity,
|
|
|
|
|
position: this.currentTransformSession.preview.position,
|
|
|
|
|
yawDegrees:
|
|
|
|
|
this.currentTransformSession.preview.rotation.kind === "yaw"
|
|
|
|
|
? this.currentTransformSession.preview.rotation.yawDegrees
|
|
|
|
|
: currentEntity.yawDegrees
|
|
|
|
|
});
|
2026-04-03 02:12:44 +02:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 20:05:58 +02:00
|
|
|
private rebuildLocalLights(document: SceneDocument) {
|
|
|
|
|
this.clearLocalLights();
|
|
|
|
|
|
|
|
|
|
for (const entity of getEntityInstances(document.entities)) {
|
|
|
|
|
switch (entity.kind) {
|
|
|
|
|
case "pointLight": {
|
2026-03-31 20:06:13 +02:00
|
|
|
const renderObjects = this.createPointLightRuntimeObjects(entity);
|
2026-03-31 20:05:58 +02:00
|
|
|
this.localLightGroup.add(renderObjects.group);
|
|
|
|
|
this.localLightRenderObjects.set(entity.id, renderObjects);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case "spotLight": {
|
2026-03-31 20:06:13 +02:00
|
|
|
const renderObjects = this.createSpotLightRuntimeObjects(entity);
|
2026-03-31 20:05:58 +02:00
|
|
|
this.localLightGroup.add(renderObjects.group);
|
|
|
|
|
this.localLightRenderObjects.set(entity.id, renderObjects);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-02 20:51:34 +02:00
|
|
|
|
|
|
|
|
this.applyShadowState();
|
2026-03-31 20:05:58 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:35:02 +02:00
|
|
|
private rebuildBrushMeshes(document: SceneDocument, selection: EditorSelection) {
|
|
|
|
|
this.clearBrushMeshes();
|
2026-04-06 08:27:12 +02:00
|
|
|
const volumeRenderPaths = resolveBoxVolumeRenderPaths(document.world.advancedRendering);
|
2026-03-31 02:35:02 +02:00
|
|
|
|
|
|
|
|
for (const brush of Object.values(document.brushes)) {
|
2026-04-05 02:31:07 +02:00
|
|
|
const geometry = buildBoxBrushDerivedMeshData(brush).geometry;
|
2026-04-06 17:29:27 +02:00
|
|
|
const contactPatches =
|
|
|
|
|
brush.volume.mode === "water" ? this.collectViewportWaterContactPatches(document, brush.id, brush.center, brush.rotationDegrees, brush.size) : [];
|
2026-03-31 02:35:02 +02:00
|
|
|
|
|
|
|
|
const materials = BOX_FACE_IDS.map((faceId) =>
|
|
|
|
|
this.createFaceMaterial(
|
|
|
|
|
brush,
|
|
|
|
|
faceId,
|
|
|
|
|
document.materials[brush.faces[faceId].materialId ?? ""],
|
2026-04-06 08:27:12 +02:00
|
|
|
this.getFaceHighlightState(brush.id, faceId),
|
2026-04-06 17:29:27 +02:00
|
|
|
volumeRenderPaths,
|
|
|
|
|
contactPatches
|
2026-03-31 02:35:02 +02:00
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
const mesh = new Mesh(geometry, materials);
|
|
|
|
|
const brushSelected = isBrushSelected(selection, brush.id);
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
})
|
|
|
|
|
);
|
2026-04-04 19:07:08 +02:00
|
|
|
edges.visible = this.displayMode !== "wireframe";
|
2026-03-31 02:35:02 +02:00
|
|
|
|
2026-04-04 20:10:49 +02:00
|
|
|
const edgeHelpers = BOX_EDGE_IDS.map((edgeId) => this.createEdgeHelper(brush, edgeId));
|
|
|
|
|
const vertexHelpers = BOX_VERTEX_IDS.map((vertexId) => this.createVertexHelper(brush, vertexId));
|
|
|
|
|
|
2026-03-31 02:35:02 +02:00
|
|
|
this.brushGroup.add(mesh);
|
|
|
|
|
this.brushGroup.add(edges);
|
2026-04-04 20:10:49 +02:00
|
|
|
for (const edgeHelper of edgeHelpers) {
|
|
|
|
|
this.brushGroup.add(edgeHelper.line);
|
|
|
|
|
}
|
|
|
|
|
for (const vertexHelper of vertexHelpers) {
|
|
|
|
|
this.brushGroup.add(vertexHelper.mesh);
|
|
|
|
|
}
|
2026-03-31 02:35:02 +02:00
|
|
|
this.brushRenderObjects.set(brush.id, {
|
|
|
|
|
mesh,
|
2026-04-04 20:10:49 +02:00
|
|
|
edges,
|
|
|
|
|
edgeHelpers,
|
|
|
|
|
vertexHelpers
|
2026-03-31 02:35:02 +02:00
|
|
|
});
|
2026-04-05 02:57:15 +02:00
|
|
|
this.applyBrushRenderObjectTransform(brush.id, brush.center, brush.rotationDegrees);
|
2026-03-31 02:35:02 +02:00
|
|
|
}
|
2026-04-02 20:51:34 +02:00
|
|
|
|
2026-04-04 20:10:49 +02:00
|
|
|
this.refreshBrushPresentation();
|
2026-04-02 20:51:34 +02:00
|
|
|
this.applyShadowState();
|
2026-03-31 02:35:02 +02:00
|
|
|
}
|
|
|
|
|
|
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-04-04 19:06:06 +02:00
|
|
|
if (this.displayMode === "wireframe") {
|
|
|
|
|
this.applyWireframePresentation(renderObjects.group);
|
|
|
|
|
}
|
|
|
|
|
|
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];
|
2026-04-04 19:06:06 +02:00
|
|
|
const renderGroup = createModelInstanceRenderGroup(
|
|
|
|
|
modelInstance,
|
|
|
|
|
asset,
|
|
|
|
|
loadedAsset,
|
|
|
|
|
selected,
|
|
|
|
|
undefined,
|
|
|
|
|
this.displayMode === "wireframe" ? "wireframe" : "normal"
|
|
|
|
|
);
|
2026-03-31 17:39:56 +02:00
|
|
|
|
2026-04-04 07:55:42 +02:00
|
|
|
if (asset?.kind === "model" && modelInstance.collision.visible) {
|
|
|
|
|
try {
|
|
|
|
|
const generatedCollider = buildGeneratedModelCollider(modelInstance, asset, loadedAsset);
|
|
|
|
|
|
|
|
|
|
if (generatedCollider !== null) {
|
|
|
|
|
renderGroup.add(createModelColliderDebugGroup(generatedCollider));
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Validation surfaces unsupported collider modes; the viewport keeps rendering the model.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 17:39:56 +02:00
|
|
|
this.modelGroup.add(renderGroup);
|
|
|
|
|
this.modelRenderObjects.set(modelInstance.id, renderGroup);
|
|
|
|
|
}
|
2026-04-02 20:51:34 +02:00
|
|
|
|
|
|
|
|
this.applyShadowState();
|
2026-03-31 17:39:56 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 05:53:15 +02:00
|
|
|
private createEntityRenderObjects(entity: EntityInstance, selected: boolean): EntityRenderObjects {
|
|
|
|
|
switch (entity.kind) {
|
2026-03-31 20:05:58 +02:00
|
|
|
case "pointLight":
|
|
|
|
|
return this.createPointLightGizmoRenderObjects(entity.id, entity.position, entity.distance, entity.colorHex, selected);
|
|
|
|
|
case "spotLight":
|
|
|
|
|
return this.createSpotLightGizmoRenderObjects(
|
|
|
|
|
entity.id,
|
|
|
|
|
entity.position,
|
|
|
|
|
entity.direction,
|
|
|
|
|
entity.distance,
|
|
|
|
|
entity.angleDegrees,
|
|
|
|
|
entity.colorHex,
|
|
|
|
|
selected
|
|
|
|
|
);
|
2026-03-31 05:53:15 +02:00
|
|
|
case "playerStart":
|
2026-04-04 15:54:53 +02:00
|
|
|
return this.createPlayerStartRenderObjects(entity.id, entity.position, entity.yawDegrees, entity.collider, selected);
|
2026-03-31 05:53:15 +02:00
|
|
|
case "soundEmitter":
|
2026-04-02 19:46:08 +02:00
|
|
|
return this.createSoundEmitterRenderObjects(entity.id, entity.position, entity.refDistance, entity.maxDistance, selected);
|
2026-03-31 05:53:15 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 20:06:13 +02:00
|
|
|
private createPointLightGizmoRenderObjects(
|
2026-03-31 20:05:58 +02:00
|
|
|
entityId: string,
|
|
|
|
|
position: Vec3,
|
|
|
|
|
distance: number,
|
|
|
|
|
colorHex: string,
|
|
|
|
|
selected: boolean
|
|
|
|
|
): EntityRenderObjects {
|
|
|
|
|
const markerColor = colorHex;
|
|
|
|
|
const displayRadius = Math.max(0.5, distance);
|
|
|
|
|
const group = new Group();
|
|
|
|
|
group.position.set(position.x, position.y, position.z);
|
|
|
|
|
|
|
|
|
|
const core = new Mesh(
|
|
|
|
|
new SphereGeometry(0.16, 16, 12),
|
|
|
|
|
new MeshStandardMaterial({
|
|
|
|
|
color: markerColor,
|
|
|
|
|
emissive: markerColor,
|
|
|
|
|
emissiveIntensity: selected ? 0.22 : 0.1,
|
|
|
|
|
roughness: 0.28,
|
|
|
|
|
metalness: 0.05
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const range = new Mesh(
|
|
|
|
|
new SphereGeometry(displayRadius, 16, 12),
|
|
|
|
|
new MeshStandardMaterial({
|
|
|
|
|
color: markerColor,
|
|
|
|
|
emissive: markerColor,
|
|
|
|
|
emissiveIntensity: selected ? 0.08 : 0.03,
|
|
|
|
|
roughness: 0.85,
|
|
|
|
|
metalness: 0,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: selected ? 0.16 : 0.08,
|
|
|
|
|
wireframe: true
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-01 04:41:02 +02:00
|
|
|
range.userData.nonPickable = true;
|
|
|
|
|
|
2026-03-31 20:05:58 +02:00
|
|
|
for (const mesh of [core, range]) {
|
|
|
|
|
this.tagEntityMesh(mesh, entityId, "pointLight", group);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
group,
|
|
|
|
|
meshes: [core, range]
|
2026-04-01 04:41:02 +02:00
|
|
|
};
|
|
|
|
|
}
|
2026-03-31 20:05:58 +02:00
|
|
|
|
2026-03-31 20:06:13 +02:00
|
|
|
private createSpotLightGizmoRenderObjects(
|
2026-03-31 20:05:58 +02:00
|
|
|
entityId: string,
|
|
|
|
|
position: Vec3,
|
|
|
|
|
direction: Vec3,
|
|
|
|
|
distance: number,
|
|
|
|
|
angleDegrees: number,
|
|
|
|
|
colorHex: string,
|
|
|
|
|
selected: boolean
|
|
|
|
|
): EntityRenderObjects {
|
|
|
|
|
const markerColor = colorHex;
|
|
|
|
|
const group = new Group();
|
|
|
|
|
group.position.set(position.x, position.y, position.z);
|
|
|
|
|
|
|
|
|
|
const forward = new Vector3(direction.x, direction.y, direction.z).normalize();
|
|
|
|
|
const coneLength = Math.max(0.85, distance);
|
|
|
|
|
const coneRadius = Math.max(0.16, Math.tan((angleDegrees * Math.PI) / 360) * coneLength);
|
|
|
|
|
const orientation = new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), forward);
|
|
|
|
|
group.quaternion.copy(orientation);
|
|
|
|
|
|
|
|
|
|
const core = new Mesh(
|
|
|
|
|
new SphereGeometry(0.16, 14, 10),
|
|
|
|
|
new MeshStandardMaterial({
|
|
|
|
|
color: markerColor,
|
|
|
|
|
emissive: markerColor,
|
|
|
|
|
emissiveIntensity: selected ? 0.24 : 0.1,
|
|
|
|
|
roughness: 0.28,
|
|
|
|
|
metalness: 0.05
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const cone = new Mesh(
|
|
|
|
|
new CylinderGeometry(coneRadius, 0, coneLength, 20, 1, true),
|
|
|
|
|
new MeshStandardMaterial({
|
|
|
|
|
color: markerColor,
|
|
|
|
|
emissive: markerColor,
|
|
|
|
|
emissiveIntensity: selected ? 0.08 : 0.03,
|
|
|
|
|
roughness: 0.85,
|
|
|
|
|
metalness: 0,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: selected ? 0.16 : 0.08,
|
|
|
|
|
wireframe: true
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
cone.position.y = coneLength * 0.5;
|
2026-04-01 04:41:11 +02:00
|
|
|
cone.userData.nonPickable = true;
|
2026-03-31 20:05:58 +02:00
|
|
|
|
|
|
|
|
for (const mesh of [core, cone]) {
|
|
|
|
|
this.tagEntityMesh(mesh, entityId, "spotLight", group);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
group,
|
|
|
|
|
meshes: [core, cone]
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 20:06:13 +02:00
|
|
|
private createSpotLightRuntimeObjects(entity: SpotLightEntity): LocalLightRenderObjects {
|
2026-03-31 20:05:58 +02:00
|
|
|
const group = new Group();
|
|
|
|
|
const light = new SpotLight(entity.colorHex, entity.intensity, entity.distance, (entity.angleDegrees * Math.PI) / 180, 0.18, 1);
|
|
|
|
|
const direction = new Vector3(entity.direction.x, entity.direction.y, entity.direction.z).normalize();
|
|
|
|
|
const orientation = new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), direction);
|
|
|
|
|
|
|
|
|
|
group.position.set(entity.position.x, entity.position.y, entity.position.z);
|
|
|
|
|
group.quaternion.copy(orientation);
|
|
|
|
|
light.position.set(0, 0, 0);
|
|
|
|
|
light.target.position.set(0, 1, 0);
|
|
|
|
|
group.add(light);
|
|
|
|
|
group.add(light.target);
|
|
|
|
|
|
2026-03-31 20:06:13 +02:00
|
|
|
return {
|
|
|
|
|
group
|
|
|
|
|
};
|
2026-03-31 20:05:58 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 20:06:13 +02:00
|
|
|
private createPointLightRuntimeObjects(entity: PointLightEntity): LocalLightRenderObjects {
|
2026-03-31 20:05:58 +02:00
|
|
|
const group = new Group();
|
|
|
|
|
const light = new PointLight(entity.colorHex, entity.intensity, entity.distance);
|
|
|
|
|
|
|
|
|
|
group.position.set(entity.position.x, entity.position.y, entity.position.z);
|
|
|
|
|
light.position.set(0, 0, 0);
|
|
|
|
|
group.add(light);
|
|
|
|
|
|
|
|
|
|
return {
|
2026-03-31 20:06:24 +02:00
|
|
|
group
|
2026-03-31 20:05:58 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:54:53 +02:00
|
|
|
private createPlayerStartRenderObjects(
|
|
|
|
|
entityId: string,
|
|
|
|
|
position: Vec3,
|
|
|
|
|
yawDegrees: number,
|
|
|
|
|
collider: PlayerStartEntity["collider"],
|
|
|
|
|
selected: boolean
|
|
|
|
|
): EntityRenderObjects {
|
2026-03-31 05:53:15 +02:00
|
|
|
const markerColor = selected ? PLAYER_START_SELECTED_COLOR : PLAYER_START_COLOR;
|
|
|
|
|
const group = new Group();
|
|
|
|
|
group.position.set(position.x, position.y, position.z);
|
2026-04-04 15:54:53 +02:00
|
|
|
const colliderMaterial = new MeshStandardMaterial({
|
|
|
|
|
color: markerColor,
|
|
|
|
|
emissive: markerColor,
|
|
|
|
|
emissiveIntensity: selected ? 0.14 : 0.05,
|
|
|
|
|
roughness: 0.5,
|
|
|
|
|
metalness: 0.02,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: selected ? 0.4 : 0.24
|
|
|
|
|
});
|
|
|
|
|
const arrowMaterial = new MeshStandardMaterial({
|
|
|
|
|
color: markerColor,
|
|
|
|
|
emissive: markerColor,
|
|
|
|
|
emissiveIntensity: selected ? 0.2 : 0.08,
|
|
|
|
|
roughness: 0.38,
|
|
|
|
|
metalness: 0.03
|
|
|
|
|
});
|
|
|
|
|
const meshes: Mesh[] = [];
|
2026-03-31 05:53:15 +02:00
|
|
|
|
2026-04-04 15:54:53 +02:00
|
|
|
switch (collider.mode) {
|
|
|
|
|
case "capsule": {
|
|
|
|
|
const collisionMesh = new Mesh(
|
|
|
|
|
new CapsuleGeometry(collider.capsuleRadius, Math.max(0, collider.capsuleHeight - collider.capsuleRadius * 2), 6, 12),
|
|
|
|
|
colliderMaterial
|
|
|
|
|
);
|
|
|
|
|
collisionMesh.position.y = collider.capsuleHeight * 0.5;
|
|
|
|
|
this.tagEntityMesh(collisionMesh, entityId, "playerStart", group);
|
|
|
|
|
meshes.push(collisionMesh);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case "box": {
|
|
|
|
|
const collisionMesh = new Mesh(new BoxGeometry(collider.boxSize.x, collider.boxSize.y, collider.boxSize.z), colliderMaterial);
|
|
|
|
|
collisionMesh.position.y = collider.boxSize.y * 0.5;
|
|
|
|
|
this.tagEntityMesh(collisionMesh, entityId, "playerStart", group);
|
|
|
|
|
meshes.push(collisionMesh);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case "none":
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-03-31 05:53:15 +02:00
|
|
|
|
2026-04-04 15:54:53 +02:00
|
|
|
const directionGroup = new Group();
|
|
|
|
|
directionGroup.rotation.y = (yawDegrees * Math.PI) / 180;
|
|
|
|
|
group.add(directionGroup);
|
|
|
|
|
const colliderTop = getPlayerStartColliderHeight(collider) ?? 0.18;
|
2026-03-31 05:53:15 +02:00
|
|
|
|
2026-04-04 15:54:53 +02:00
|
|
|
const body = new Mesh(new BoxGeometry(0.08, 0.08, 0.34), arrowMaterial);
|
|
|
|
|
body.position.set(0, colliderTop + 0.12, 0.06);
|
|
|
|
|
|
|
|
|
|
const arrowHead = new Mesh(new ConeGeometry(0.1, 0.22, 14), arrowMaterial);
|
2026-03-31 05:53:15 +02:00
|
|
|
arrowHead.rotation.x = Math.PI * 0.5;
|
2026-04-04 15:54:53 +02:00
|
|
|
arrowHead.position.set(0, colliderTop + 0.12, 0.28);
|
2026-03-31 05:53:15 +02:00
|
|
|
|
2026-04-04 15:54:53 +02:00
|
|
|
for (const mesh of [body, arrowHead]) {
|
|
|
|
|
this.tagEntityMesh(mesh, entityId, "playerStart", directionGroup);
|
|
|
|
|
meshes.push(mesh);
|
2026-03-31 05:53:15 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
group,
|
2026-04-04 15:54:53 +02:00
|
|
|
meshes
|
2026-03-31 05:53:15 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 19:46:08 +02:00
|
|
|
private createSoundEmitterRenderObjects(
|
|
|
|
|
entityId: string,
|
|
|
|
|
position: Vec3,
|
|
|
|
|
refDistance: number,
|
|
|
|
|
maxDistance: number,
|
2026-04-02 23:51:12 +02:00
|
|
|
selected: boolean,
|
|
|
|
|
markerColor = selected ? SOUND_EMITTER_SELECTED_COLOR : SOUND_EMITTER_COLOR
|
2026-04-02 19:46:08 +02:00
|
|
|
): EntityRenderObjects {
|
|
|
|
|
const displayRefDistance = Math.max(0.4, refDistance);
|
|
|
|
|
const displayMaxDistance = Math.max(displayRefDistance, maxDistance);
|
2026-03-31 05:53:15 +02:00
|
|
|
const group = new Group();
|
|
|
|
|
group.position.set(position.x, position.y, position.z);
|
|
|
|
|
|
2026-04-02 20:27:50 +02:00
|
|
|
const speakerMeshes = createSoundEmitterMarkerMeshes(markerColor, selected);
|
2026-03-31 05:53:15 +02:00
|
|
|
|
2026-04-02 19:46:08 +02:00
|
|
|
const refDistanceShell = new Mesh(
|
|
|
|
|
new SphereGeometry(displayRefDistance, 16, 12),
|
2026-03-31 05:53:15 +02:00
|
|
|
new MeshStandardMaterial({
|
|
|
|
|
color: markerColor,
|
|
|
|
|
emissive: markerColor,
|
2026-04-02 19:46:08 +02:00
|
|
|
emissiveIntensity: selected ? 0.1 : 0.03,
|
2026-03-31 05:53:15 +02:00
|
|
|
roughness: 0.8,
|
|
|
|
|
metalness: 0,
|
|
|
|
|
transparent: true,
|
2026-04-02 19:46:08 +02:00
|
|
|
opacity: selected ? 0.18 : 0.09,
|
|
|
|
|
wireframe: true
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
refDistanceShell.userData.nonPickable = true;
|
|
|
|
|
|
|
|
|
|
const maxDistanceShell = new Mesh(
|
|
|
|
|
new SphereGeometry(displayMaxDistance, 16, 12),
|
|
|
|
|
new MeshStandardMaterial({
|
|
|
|
|
color: markerColor,
|
|
|
|
|
emissive: markerColor,
|
|
|
|
|
emissiveIntensity: selected ? 0.06 : 0.015,
|
|
|
|
|
roughness: 0.82,
|
|
|
|
|
metalness: 0,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: selected ? 0.12 : 0.06,
|
2026-03-31 05:53:15 +02:00
|
|
|
wireframe: true
|
|
|
|
|
})
|
|
|
|
|
);
|
2026-04-02 19:46:08 +02:00
|
|
|
maxDistanceShell.userData.nonPickable = true;
|
2026-03-31 05:53:15 +02:00
|
|
|
|
2026-04-02 20:27:50 +02:00
|
|
|
for (const mesh of [...speakerMeshes, refDistanceShell, maxDistanceShell]) {
|
2026-03-31 05:53:15 +02:00
|
|
|
this.tagEntityMesh(mesh, entityId, "soundEmitter", group);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
group,
|
2026-04-02 20:27:50 +02:00
|
|
|
meshes: [...speakerMeshes, refDistanceShell, maxDistanceShell]
|
2026-03-31 05:53:15 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 23:51:12 +02:00
|
|
|
private createTriggerVolumeRenderObjects(
|
|
|
|
|
entityId: string,
|
|
|
|
|
position: Vec3,
|
|
|
|
|
size: Vec3,
|
|
|
|
|
selected: boolean,
|
|
|
|
|
markerColor = selected ? TRIGGER_VOLUME_SELECTED_COLOR : TRIGGER_VOLUME_COLOR
|
|
|
|
|
): EntityRenderObjects {
|
2026-03-31 05:53:15 +02:00
|
|
|
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]
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 23:51:12 +02:00
|
|
|
private createTeleportTargetRenderObjects(
|
|
|
|
|
entityId: string,
|
|
|
|
|
position: Vec3,
|
|
|
|
|
yawDegrees: number,
|
|
|
|
|
selected: boolean,
|
|
|
|
|
markerColor = selected ? TELEPORT_TARGET_SELECTED_COLOR : TELEPORT_TARGET_COLOR
|
|
|
|
|
): EntityRenderObjects {
|
2026-03-31 05:53:15 +02:00
|
|
|
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]
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 23:51:12 +02:00
|
|
|
private createInteractableRenderObjects(
|
|
|
|
|
entityId: string,
|
|
|
|
|
position: Vec3,
|
|
|
|
|
radius: number,
|
|
|
|
|
selected: boolean,
|
|
|
|
|
markerColor = selected ? INTERACTABLE_SELECTED_COLOR : INTERACTABLE_COLOR
|
|
|
|
|
): EntityRenderObjects {
|
2026-03-31 05:53:15 +02:00
|
|
|
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;
|
2026-04-01 04:41:29 +02:00
|
|
|
radiusRing.userData.nonPickable = true;
|
2026-03-31 05:53:15 +02:00
|
|
|
|
|
|
|
|
for (const mesh of [core, radiusRing]) {
|
|
|
|
|
this.tagEntityMesh(mesh, entityId, "interactable", group);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
group,
|
|
|
|
|
meshes: [core, radiusRing]
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 20:10:07 +02:00
|
|
|
private emitWhiteboxHoverLabelChange() {
|
|
|
|
|
const label =
|
|
|
|
|
this.currentDocument === null ? null : getWhiteboxSelectionFeedbackLabel(this.currentDocument, this.hoveredSelection);
|
|
|
|
|
this.whiteboxHoverLabelChangeHandler?.(label);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private setHoveredSelection(selection: EditorSelection) {
|
|
|
|
|
if (areEditorSelectionsEqual(this.hoveredSelection, selection)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.hoveredSelection = selection;
|
|
|
|
|
this.refreshBrushPresentation();
|
|
|
|
|
this.emitWhiteboxHoverLabelChange();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getFaceHighlightState(brushId: string, faceId: BoxFaceId): "none" | "hovered" | "selected" {
|
|
|
|
|
if (isBrushFaceSelected(this.currentSelection, brushId, faceId)) {
|
|
|
|
|
return "selected";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.hoveredSelection.kind === "brushFace" && this.hoveredSelection.brushId === brushId && this.hoveredSelection.faceId === faceId) {
|
|
|
|
|
return "hovered";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return "none";
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 22:32:21 +02:00
|
|
|
private createFaceMaterial(
|
|
|
|
|
brush: BoxBrush,
|
|
|
|
|
faceId: BoxFaceId,
|
|
|
|
|
material: MaterialDef | undefined,
|
2026-04-06 08:27:12 +02:00
|
|
|
highlightState: "none" | "hovered" | "selected",
|
2026-04-06 17:29:27 +02:00
|
|
|
volumeRenderPaths: { fog: "performance" | "quality"; water: "performance" | "quality" },
|
|
|
|
|
contactPatches: ReturnType<typeof collectWaterContactPatches>
|
|
|
|
|
): Material {
|
2026-03-31 02:35:02 +02:00
|
|
|
const face = brush.faces[faceId];
|
2026-04-04 20:10:07 +02:00
|
|
|
const selectedFace = highlightState === "selected";
|
|
|
|
|
const hoveredFace = highlightState === "hovered";
|
|
|
|
|
const emphasizedFace = selectedFace || hoveredFace;
|
2026-03-31 02:35:02 +02:00
|
|
|
|
2026-04-06 08:27:12 +02:00
|
|
|
if (brush.volume.mode === "water") {
|
|
|
|
|
const quality = volumeRenderPaths.water === "quality";
|
|
|
|
|
const baseOpacity = Math.max(0.08, Math.min(1, brush.volume.water.surfaceOpacity));
|
|
|
|
|
const opacityBoost = faceId === "posY" ? 0.16 : 0;
|
|
|
|
|
const opacity = Math.min(1, baseOpacity + opacityBoost + (selectedFace ? 0.08 : hoveredFace ? 0.04 : 0));
|
|
|
|
|
|
2026-04-06 17:29:27 +02:00
|
|
|
const waterMaterial = createWaterMaterial({
|
|
|
|
|
colorHex: brush.volume.water.colorHex,
|
|
|
|
|
surfaceOpacity: brush.volume.water.surfaceOpacity,
|
|
|
|
|
waveStrength: brush.volume.water.waveStrength,
|
|
|
|
|
opacity,
|
|
|
|
|
quality,
|
|
|
|
|
wireframe: this.displayMode === "wireframe",
|
|
|
|
|
isTopFace: faceId === "posY",
|
|
|
|
|
time: this.volumeTime,
|
|
|
|
|
halfSize: {
|
|
|
|
|
x: brush.size.x * 0.5,
|
|
|
|
|
z: brush.size.z * 0.5
|
|
|
|
|
},
|
|
|
|
|
contactPatches
|
|
|
|
|
});
|
2026-04-06 08:27:12 +02:00
|
|
|
|
2026-04-06 17:29:27 +02:00
|
|
|
if (waterMaterial.animationUniform !== null) {
|
|
|
|
|
this.volumeAnimatedUniforms.push(waterMaterial.animationUniform);
|
2026-04-06 08:27:12 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 17:29:27 +02:00
|
|
|
return waterMaterial.material;
|
2026-04-06 08:27:12 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (brush.volume.mode === "fog") {
|
|
|
|
|
const quality = volumeRenderPaths.fog === "quality";
|
|
|
|
|
const baseOpacity = Math.max(0.08, Math.min(0.82, brush.volume.fog.density * (quality ? 0.65 : 0.9) + 0.1));
|
|
|
|
|
const opacity = Math.min(0.92, baseOpacity + (selectedFace ? 0.08 : hoveredFace ? 0.04 : 0));
|
|
|
|
|
|
|
|
|
|
if (this.displayMode === "wireframe") {
|
|
|
|
|
return new MeshBasicMaterial({
|
|
|
|
|
color: brush.volume.fog.colorHex,
|
|
|
|
|
wireframe: true,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: Math.min(1, opacity + 0.16),
|
|
|
|
|
depthWrite: false
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.displayMode === "authoring") {
|
|
|
|
|
return new MeshBasicMaterial({
|
|
|
|
|
color: brush.volume.fog.colorHex,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new MeshStandardMaterial({
|
|
|
|
|
color: brush.volume.fog.colorHex,
|
|
|
|
|
emissive: brush.volume.fog.colorHex,
|
|
|
|
|
emissiveIntensity: quality ? 0.08 : 0.04,
|
|
|
|
|
roughness: 1,
|
|
|
|
|
metalness: 0,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity,
|
|
|
|
|
depthWrite: false
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 22:32:21 +02:00
|
|
|
if (this.displayMode === "authoring") {
|
2026-04-04 20:10:07 +02:00
|
|
|
const colorHex =
|
|
|
|
|
material === undefined || face.materialId === null
|
|
|
|
|
? selectedFace
|
|
|
|
|
? SELECTED_FACE_FALLBACK_COLOR
|
|
|
|
|
: hoveredFace
|
|
|
|
|
? HOVERED_FACE_FALLBACK_COLOR
|
|
|
|
|
: FALLBACK_FACE_COLOR
|
|
|
|
|
: emphasizedFace
|
|
|
|
|
? material.accentColorHex
|
|
|
|
|
: material.baseColorHex;
|
2026-04-02 22:32:21 +02:00
|
|
|
|
|
|
|
|
return new MeshBasicMaterial({
|
|
|
|
|
color: colorHex,
|
|
|
|
|
transparent: true,
|
2026-04-04 20:10:07 +02:00
|
|
|
opacity: selectedFace ? 0.36 : hoveredFace ? 0.28 : 0.18,
|
2026-04-02 22:32:21 +02:00
|
|
|
wireframe: false
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 19:06:06 +02:00
|
|
|
if (this.displayMode === "wireframe") {
|
|
|
|
|
const colorHex =
|
|
|
|
|
material === undefined || face.materialId === null
|
|
|
|
|
? selectedFace
|
|
|
|
|
? SELECTED_FACE_FALLBACK_COLOR
|
2026-04-04 20:10:07 +02:00
|
|
|
: hoveredFace
|
|
|
|
|
? HOVERED_FACE_FALLBACK_COLOR
|
|
|
|
|
: FALLBACK_FACE_COLOR
|
|
|
|
|
: emphasizedFace
|
2026-04-04 19:06:06 +02:00
|
|
|
? material.accentColorHex
|
|
|
|
|
: material.baseColorHex;
|
|
|
|
|
|
|
|
|
|
return new MeshBasicMaterial({
|
|
|
|
|
color: colorHex,
|
|
|
|
|
wireframe: true,
|
|
|
|
|
transparent: true,
|
2026-04-04 20:10:07 +02:00
|
|
|
opacity: selectedFace ? 0.95 : hoveredFace ? 0.86 : 0.76,
|
2026-04-04 19:06:06 +02:00
|
|
|
depthWrite: false
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:35:02 +02:00
|
|
|
if (material === undefined || face.materialId === null) {
|
|
|
|
|
return new MeshStandardMaterial({
|
2026-04-04 20:10:07 +02:00
|
|
|
color: selectedFace ? SELECTED_FACE_FALLBACK_COLOR : hoveredFace ? HOVERED_FACE_FALLBACK_COLOR : FALLBACK_FACE_COLOR,
|
|
|
|
|
emissive: selectedFace ? SELECTED_FACE_EMISSIVE : hoveredFace ? HOVERED_FACE_EMISSIVE : 0x000000,
|
|
|
|
|
emissiveIntensity: selectedFace ? 0.28 : hoveredFace ? 0.18 : 0,
|
2026-03-31 02:35:02 +02:00
|
|
|
roughness: 0.9,
|
|
|
|
|
metalness: 0.05
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new MeshStandardMaterial({
|
|
|
|
|
color: 0xffffff,
|
|
|
|
|
map: this.getOrCreateTexture(material),
|
2026-04-04 20:10:07 +02:00
|
|
|
emissive: selectedFace ? SELECTED_FACE_EMISSIVE : hoveredFace ? HOVERED_FACE_EMISSIVE : 0x000000,
|
|
|
|
|
emissiveIntensity: selectedFace ? 0.32 : hoveredFace ? 0.18 : 0,
|
2026-03-31 02:35:02 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-04-04 20:10:35 +02:00
|
|
|
|
|
|
|
|
private createEdgeHelper(brush: BoxBrush, edgeId: BoxEdgeId): { id: BoxEdgeId; line: Line<BufferGeometry, LineBasicMaterial> } {
|
|
|
|
|
const segment = getBoxBrushEdgeWorldSegment(brush, edgeId);
|
|
|
|
|
const geometry = new BufferGeometry().setFromPoints([
|
|
|
|
|
new Vector3(segment.start.x, segment.start.y, segment.start.z),
|
|
|
|
|
new Vector3(segment.end.x, segment.end.y, segment.end.z)
|
|
|
|
|
]);
|
|
|
|
|
const line = new Line(
|
|
|
|
|
geometry,
|
|
|
|
|
new LineBasicMaterial({
|
|
|
|
|
color: WHITEBOX_COMPONENT_COLOR,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: WHITEBOX_COMPONENT_DEFAULT_OPACITY,
|
|
|
|
|
depthTest: false
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
line.userData.brushId = brush.id;
|
|
|
|
|
line.userData.brushEdgeId = edgeId;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: edgeId,
|
|
|
|
|
line
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private createVertexHelper(brush: BoxBrush, vertexId: BoxVertexId): { id: BoxVertexId; mesh: Mesh<SphereGeometry, MeshBasicMaterial> } {
|
|
|
|
|
const position = getBoxBrushVertexWorldPosition(brush, vertexId);
|
|
|
|
|
const mesh = new Mesh(
|
|
|
|
|
new SphereGeometry(WHITEBOX_VERTEX_RADIUS, 10, 8),
|
|
|
|
|
new MeshBasicMaterial({
|
|
|
|
|
color: WHITEBOX_COMPONENT_COLOR,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: WHITEBOX_COMPONENT_DEFAULT_OPACITY,
|
|
|
|
|
depthTest: false
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
mesh.position.set(position.x, position.y, position.z);
|
|
|
|
|
mesh.userData.brushId = brush.id;
|
|
|
|
|
mesh.userData.brushVertexId = vertexId;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: vertexId,
|
|
|
|
|
mesh
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private refreshBrushPresentation() {
|
|
|
|
|
if (this.currentDocument === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 08:27:12 +02:00
|
|
|
const volumeRenderPaths = resolveBoxVolumeRenderPaths(this.currentDocument.world.advancedRendering);
|
|
|
|
|
|
2026-04-04 20:10:35 +02:00
|
|
|
for (const brush of Object.values(this.currentDocument.brushes)) {
|
|
|
|
|
const renderObjects = this.brushRenderObjects.get(brush.id);
|
|
|
|
|
|
|
|
|
|
if (renderObjects === undefined) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const brushSelected = isBrushSelected(this.currentSelection, brush.id);
|
|
|
|
|
const brushHovered = this.hoveredSelection.kind === "brushes" && this.hoveredSelection.ids.includes(brush.id);
|
|
|
|
|
renderObjects.edges.material.color.setHex(
|
|
|
|
|
brushSelected ? BRUSH_SELECTED_EDGE_COLOR : brushHovered && this.whiteboxSelectionMode === "object" ? BRUSH_HOVERED_EDGE_COLOR : BRUSH_EDGE_COLOR
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const previousMaterials = renderObjects.mesh.material;
|
|
|
|
|
renderObjects.mesh.material = BOX_FACE_IDS.map((faceId) =>
|
|
|
|
|
this.createFaceMaterial(
|
|
|
|
|
brush,
|
|
|
|
|
faceId,
|
|
|
|
|
this.currentDocument?.materials[brush.faces[faceId].materialId ?? ""],
|
2026-04-06 08:27:12 +02:00
|
|
|
this.getFaceHighlightState(brush.id, faceId),
|
|
|
|
|
volumeRenderPaths
|
2026-04-04 20:10:35 +02:00
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
for (const material of previousMaterials) {
|
|
|
|
|
material.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hoveredEdgeId = this.hoveredSelection.kind === "brushEdge" && this.hoveredSelection.brushId === brush.id ? this.hoveredSelection.edgeId : null;
|
|
|
|
|
const hoveredVertexId = this.hoveredSelection.kind === "brushVertex" && this.hoveredSelection.brushId === brush.id ? this.hoveredSelection.vertexId : null;
|
|
|
|
|
|
|
|
|
|
for (const edgeHelper of renderObjects.edgeHelpers) {
|
|
|
|
|
const selected = isBrushEdgeSelected(this.currentSelection, brush.id, edgeHelper.id);
|
|
|
|
|
const hovered = hoveredEdgeId === edgeHelper.id;
|
|
|
|
|
|
|
|
|
|
edgeHelper.line.visible = this.whiteboxSelectionMode === "edge";
|
|
|
|
|
edgeHelper.line.material.color.setHex(
|
|
|
|
|
selected ? WHITEBOX_COMPONENT_SELECTED_COLOR : hovered ? WHITEBOX_COMPONENT_HOVERED_COLOR : WHITEBOX_COMPONENT_COLOR
|
|
|
|
|
);
|
|
|
|
|
edgeHelper.line.material.opacity = selected
|
|
|
|
|
? WHITEBOX_COMPONENT_SELECTED_OPACITY
|
|
|
|
|
: hovered
|
|
|
|
|
? WHITEBOX_COMPONENT_HOVERED_OPACITY
|
|
|
|
|
: WHITEBOX_COMPONENT_DEFAULT_OPACITY;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const vertexHelper of renderObjects.vertexHelpers) {
|
|
|
|
|
const selected = isBrushVertexSelected(this.currentSelection, brush.id, vertexHelper.id);
|
|
|
|
|
const hovered = hoveredVertexId === vertexHelper.id;
|
|
|
|
|
|
|
|
|
|
vertexHelper.mesh.visible = this.whiteboxSelectionMode === "vertex";
|
|
|
|
|
vertexHelper.mesh.material.color.setHex(
|
|
|
|
|
selected ? WHITEBOX_COMPONENT_SELECTED_COLOR : hovered ? WHITEBOX_COMPONENT_HOVERED_COLOR : WHITEBOX_COMPONENT_COLOR
|
|
|
|
|
);
|
|
|
|
|
vertexHelper.mesh.material.opacity = selected
|
|
|
|
|
? WHITEBOX_COMPONENT_SELECTED_OPACITY
|
|
|
|
|
: hovered
|
|
|
|
|
? WHITEBOX_COMPONENT_HOVERED_OPACITY
|
|
|
|
|
: WHITEBOX_COMPONENT_DEFAULT_OPACITY;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-31 02:35:02 +02:00
|
|
|
|
2026-03-31 20:06:31 +02:00
|
|
|
private clearLocalLights() {
|
|
|
|
|
for (const renderObjects of this.localLightRenderObjects.values()) {
|
|
|
|
|
this.localLightGroup.remove(renderObjects.group);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.localLightRenderObjects.clear();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:35:02 +02:00
|
|
|
private clearBrushMeshes() {
|
|
|
|
|
for (const renderObjects of this.brushRenderObjects.values()) {
|
|
|
|
|
this.brushGroup.remove(renderObjects.mesh);
|
|
|
|
|
this.brushGroup.remove(renderObjects.edges);
|
2026-04-04 20:10:49 +02:00
|
|
|
for (const edgeHelper of renderObjects.edgeHelpers) {
|
|
|
|
|
this.brushGroup.remove(edgeHelper.line);
|
|
|
|
|
edgeHelper.line.geometry.dispose();
|
|
|
|
|
edgeHelper.line.material.dispose();
|
|
|
|
|
}
|
|
|
|
|
for (const vertexHelper of renderObjects.vertexHelpers) {
|
|
|
|
|
this.brushGroup.remove(vertexHelper.mesh);
|
|
|
|
|
vertexHelper.mesh.geometry.dispose();
|
|
|
|
|
vertexHelper.mesh.material.dispose();
|
|
|
|
|
}
|
2026-03-31 02:35:02 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 22:12:01 +02:00
|
|
|
this.perspectiveCamera.aspect = width / height;
|
|
|
|
|
this.perspectiveCamera.updateProjectionMatrix();
|
|
|
|
|
this.updateOrthographicCameraFrustum();
|
|
|
|
|
this.orthographicCamera.updateProjectionMatrix();
|
2026-03-31 02:35:02 +02:00
|
|
|
this.renderer.setSize(width, height, false);
|
2026-04-02 20:51:45 +02:00
|
|
|
this.advancedRenderingComposer?.setSize(width, height);
|
2026-03-31 02:35:02 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-03 02:14:19 +02:00
|
|
|
private pickTransformHandle(event: PointerEvent): { axisConstraint: TransformAxis | null } | null {
|
2026-04-03 02:34:55 +02:00
|
|
|
if (!this.transformGizmoGroup.visible) {
|
2026-04-03 02:14:19 +02:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.setPointerFromClientPosition(event.clientX, event.clientY)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.raycaster.setFromCamera(this.pointer, this.getActiveCamera());
|
|
|
|
|
|
|
|
|
|
const hits = this.raycaster.intersectObjects(this.transformGizmoGroup.children, true);
|
|
|
|
|
|
|
|
|
|
for (const hit of hits) {
|
|
|
|
|
const axisConstraint = hit.object.userData.transformAxisConstraint;
|
|
|
|
|
|
|
|
|
|
if (axisConstraint === null || axisConstraint === "x" || axisConstraint === "y" || axisConstraint === "z") {
|
|
|
|
|
return {
|
|
|
|
|
axisConstraint
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 20:11:10 +02:00
|
|
|
private getBrushPickableObjects(): Object3D[] {
|
|
|
|
|
switch (this.whiteboxSelectionMode) {
|
|
|
|
|
case "object":
|
|
|
|
|
case "face":
|
|
|
|
|
return Array.from(this.brushRenderObjects.values(), (renderObjects) => renderObjects.mesh);
|
|
|
|
|
case "edge":
|
|
|
|
|
return Array.from(this.brushRenderObjects.values(), (renderObjects) => renderObjects.edgeHelpers.map((helper) => helper.line)).flat();
|
|
|
|
|
case "vertex":
|
|
|
|
|
return Array.from(this.brushRenderObjects.values(), (renderObjects) => renderObjects.vertexHelpers.map((helper) => helper.mesh)).flat();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private createSelectionKey(selection: EditorSelection): string | null {
|
|
|
|
|
switch (selection.kind) {
|
|
|
|
|
case "none":
|
|
|
|
|
return null;
|
|
|
|
|
case "brushes":
|
|
|
|
|
return selection.ids.length === 1 ? `brush:${selection.ids[0]}` : null;
|
|
|
|
|
case "brushFace":
|
|
|
|
|
return `brushFace:${selection.brushId}:${selection.faceId}`;
|
|
|
|
|
case "brushEdge":
|
|
|
|
|
return `brushEdge:${selection.brushId}:${selection.edgeId}`;
|
|
|
|
|
case "brushVertex":
|
|
|
|
|
return `brushVertex:${selection.brushId}:${selection.vertexId}`;
|
|
|
|
|
case "entities":
|
|
|
|
|
return selection.ids.length === 1 ? `entity:${selection.ids[0]}` : null;
|
|
|
|
|
case "modelInstances":
|
|
|
|
|
return selection.ids.length === 1 ? `model:${selection.ids[0]}` : null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private createSelectionFromHit(hit: { object: Object3D; face?: { materialIndex?: number } | null }): EditorSelection | null {
|
|
|
|
|
if (hit.object.userData.nonPickable === true) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const entityId = hit.object.userData.entityId;
|
|
|
|
|
if (typeof entityId === "string") {
|
|
|
|
|
return {
|
|
|
|
|
kind: "entities",
|
|
|
|
|
ids: [entityId]
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const modelInstanceId = this.findModelInstanceId(hit.object);
|
|
|
|
|
if (modelInstanceId !== null) {
|
|
|
|
|
return {
|
|
|
|
|
kind: "modelInstances",
|
|
|
|
|
ids: [modelInstanceId]
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const brushId = hit.object.userData.brushId;
|
|
|
|
|
|
|
|
|
|
if (typeof brushId !== "string") {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const brushEdgeId = hit.object.userData.brushEdgeId;
|
|
|
|
|
if (typeof brushEdgeId === "string") {
|
|
|
|
|
return {
|
|
|
|
|
kind: "brushEdge",
|
|
|
|
|
brushId,
|
|
|
|
|
edgeId: brushEdgeId as BoxEdgeId
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const brushVertexId = hit.object.userData.brushVertexId;
|
|
|
|
|
if (typeof brushVertexId === "string") {
|
|
|
|
|
return {
|
|
|
|
|
kind: "brushVertex",
|
|
|
|
|
brushId,
|
|
|
|
|
vertexId: brushVertexId as BoxVertexId
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.whiteboxSelectionMode === "face") {
|
|
|
|
|
const faceMaterialIndex = hit.face?.materialIndex;
|
|
|
|
|
const faceId = typeof faceMaterialIndex === "number" ? BOX_FACE_IDS[faceMaterialIndex] ?? null : null;
|
|
|
|
|
|
|
|
|
|
if (faceId === null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
kind: "brushFace",
|
|
|
|
|
brushId,
|
|
|
|
|
faceId
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.whiteboxSelectionMode === "object") {
|
|
|
|
|
return {
|
|
|
|
|
kind: "brushes",
|
|
|
|
|
ids: [brushId]
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getSelectionCandidates(event: PointerEvent): Array<{ key: string; selection: EditorSelection }> {
|
|
|
|
|
if (!this.setPointerFromClientPosition(event.clientX, event.clientY)) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.raycaster.setFromCamera(this.pointer, this.getActiveCamera());
|
|
|
|
|
this.raycaster.params.Line.threshold = this.whiteboxSelectionMode === "edge" ? WHITEBOX_EDGE_PICK_THRESHOLD : 1;
|
|
|
|
|
|
|
|
|
|
const hits = this.raycaster.intersectObjects(
|
|
|
|
|
[
|
|
|
|
|
...Array.from(this.entityRenderObjects.values(), (renderObjects) => renderObjects.group),
|
|
|
|
|
...Array.from(this.modelRenderObjects.values()),
|
|
|
|
|
...this.getBrushPickableObjects()
|
|
|
|
|
],
|
|
|
|
|
true
|
|
|
|
|
);
|
|
|
|
|
const candidates: Array<{ key: string; selection: EditorSelection }> = [];
|
|
|
|
|
const seenKeys = new Set<string>();
|
|
|
|
|
|
|
|
|
|
for (const hit of hits) {
|
|
|
|
|
const selection = this.createSelectionFromHit(hit);
|
|
|
|
|
|
|
|
|
|
if (selection === null) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const key = this.createSelectionKey(selection);
|
|
|
|
|
|
|
|
|
|
if (key === null || seenKeys.has(key)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
seenKeys.add(key);
|
|
|
|
|
candidates.push({
|
|
|
|
|
key,
|
|
|
|
|
selection
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return candidates;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:35:02 +02:00
|
|
|
private handlePointerDown = (event: PointerEvent) => {
|
2026-04-03 02:14:19 +02:00
|
|
|
this.lastCanvasPointerPosition = {
|
|
|
|
|
x: event.clientX,
|
|
|
|
|
y: event.clientY
|
|
|
|
|
};
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 07:51:38 +02:00
|
|
|
if (event.button === 2) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
|
|
if (this.currentTransformSession.kind === "active") {
|
|
|
|
|
this.transformCancelHandler?.();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 04:24:06 +02:00
|
|
|
if (event.button !== 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 02:34:55 +02:00
|
|
|
const transformHandle = this.pickTransformHandle(event);
|
|
|
|
|
const interactionSession =
|
|
|
|
|
this.currentTransformSession.kind === "active"
|
|
|
|
|
? this.currentTransformSession.sourcePanelId === this.panelId
|
|
|
|
|
? this.currentTransformSession
|
|
|
|
|
: null
|
|
|
|
|
: this.getDisplayedTransformSession();
|
|
|
|
|
|
|
|
|
|
if (transformHandle !== null && interactionSession !== null) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
transformHandle.axisConstraint !== null &&
|
|
|
|
|
!supportsTransformAxisConstraint(interactionSession, transformHandle.axisConstraint)
|
|
|
|
|
) {
|
2026-04-03 02:14:19 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 02:34:55 +02:00
|
|
|
const nextSession = this.buildTransformPreviewFromPointer(
|
|
|
|
|
createTransformSession({
|
|
|
|
|
source: "gizmo",
|
|
|
|
|
sourcePanelId: this.panelId,
|
|
|
|
|
operation: interactionSession.operation,
|
|
|
|
|
axisConstraint: transformHandle.axisConstraint,
|
|
|
|
|
target: interactionSession.target
|
|
|
|
|
}),
|
|
|
|
|
{
|
|
|
|
|
x: event.clientX,
|
|
|
|
|
y: event.clientY
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
x: event.clientX,
|
|
|
|
|
y: event.clientY
|
|
|
|
|
},
|
|
|
|
|
transformHandle.axisConstraint
|
|
|
|
|
);
|
2026-04-03 02:14:19 +02:00
|
|
|
|
2026-04-03 02:34:55 +02:00
|
|
|
this.currentTransformSession = nextSession;
|
|
|
|
|
this.applyTransformPreview();
|
|
|
|
|
this.syncTransformGizmo();
|
|
|
|
|
this.transformSessionChangeHandler?.(nextSession);
|
|
|
|
|
this.activeTransformDrag = {
|
|
|
|
|
pointerId: event.pointerId,
|
|
|
|
|
sessionId: nextSession.id,
|
|
|
|
|
axisConstraint: transformHandle.axisConstraint,
|
|
|
|
|
initialClientPosition: {
|
|
|
|
|
x: event.clientX,
|
|
|
|
|
y: event.clientY
|
2026-04-03 02:14:19 +02:00
|
|
|
}
|
2026-04-03 02:34:55 +02:00
|
|
|
};
|
|
|
|
|
this.renderer.domElement.setPointerCapture(event.pointerId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-03 02:14:19 +02:00
|
|
|
|
2026-04-03 02:34:55 +02:00
|
|
|
if (this.currentTransformSession.kind === "active") {
|
|
|
|
|
if (this.currentTransformSession.sourcePanelId !== this.panelId) {
|
2026-04-03 02:14:19 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.currentTransformSession.source !== "gizmo" || this.currentTransformSession.sourcePanelId === this.panelId) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
this.transformCommitHandler?.(this.currentTransformSession);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 00:21:55 +02:00
|
|
|
if (this.toolMode === "create" && this.creationPreview !== null) {
|
|
|
|
|
const previewCenter = this.getCreationPreviewCenter(event, this.creationPreview.target);
|
|
|
|
|
const nextCreationPreview = {
|
|
|
|
|
...this.creationPreview,
|
|
|
|
|
center: previewCenter
|
|
|
|
|
};
|
2026-03-31 03:44:17 +02:00
|
|
|
|
2026-04-03 00:21:55 +02:00
|
|
|
this.syncCreationPreview(nextCreationPreview);
|
|
|
|
|
this.creationPreviewChangeHandler?.(nextCreationPreview);
|
2026-04-02 23:50:42 +02:00
|
|
|
|
|
|
|
|
if (previewCenter !== null) {
|
2026-04-03 00:32:05 +02:00
|
|
|
const committed = this.creationCommitHandler?.(nextCreationPreview) === true;
|
|
|
|
|
|
|
|
|
|
if (committed) {
|
|
|
|
|
this.syncCreationPreview(null);
|
|
|
|
|
this.creationPreviewChangeHandler?.({ kind: "none" });
|
|
|
|
|
}
|
2026-04-02 23:50:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 20:11:27 +02:00
|
|
|
const candidates = this.getSelectionCandidates(event);
|
2026-03-31 02:35:02 +02:00
|
|
|
|
2026-04-04 20:11:27 +02:00
|
|
|
if (candidates.length === 0) {
|
2026-04-01 04:36:36 +02:00
|
|
|
this.lastClickPointer = null;
|
|
|
|
|
this.lastClickSelectionKey = null;
|
2026-03-31 02:35:02 +02:00
|
|
|
this.brushSelectionChangeHandler?.({
|
|
|
|
|
kind: "none"
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 04:36:36 +02:00
|
|
|
// Determine whether this click is at the same spot as the last one.
|
|
|
|
|
const POINTER_TOLERANCE = 0.01;
|
|
|
|
|
const isSameSpot =
|
|
|
|
|
this.lastClickPointer !== null &&
|
|
|
|
|
Math.abs(this.pointer.x - this.lastClickPointer.x) < POINTER_TOLERANCE &&
|
|
|
|
|
Math.abs(this.pointer.y - this.lastClickPointer.y) < POINTER_TOLERANCE;
|
|
|
|
|
|
|
|
|
|
let candidateIndex = 0;
|
|
|
|
|
|
|
|
|
|
if (isSameSpot && this.lastClickSelectionKey !== null) {
|
|
|
|
|
// Find where the previously selected item sits in the new hit list and advance by one.
|
|
|
|
|
const lastIndex = candidates.findIndex((c) => c.key === this.lastClickSelectionKey);
|
|
|
|
|
if (lastIndex !== -1) {
|
|
|
|
|
candidateIndex = (lastIndex + 1) % candidates.length;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-31 17:39:56 +02:00
|
|
|
|
2026-04-01 04:36:36 +02:00
|
|
|
this.lastClickPointer = { x: this.pointer.x, y: this.pointer.y };
|
|
|
|
|
|
|
|
|
|
const chosen = candidates[candidateIndex];
|
|
|
|
|
this.lastClickSelectionKey = chosen.key;
|
2026-04-04 20:11:27 +02:00
|
|
|
this.brushSelectionChangeHandler?.(chosen.selection);
|
2026-03-31 02:35:02 +02:00
|
|
|
};
|
|
|
|
|
|
2026-03-31 03:44:17 +02:00
|
|
|
private handlePointerMove = (event: PointerEvent) => {
|
2026-04-03 02:14:19 +02:00
|
|
|
this.lastCanvasPointerPosition = {
|
|
|
|
|
x: event.clientX,
|
|
|
|
|
y: event.clientY
|
|
|
|
|
};
|
|
|
|
|
|
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
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-02 22:12:01 +02:00
|
|
|
if (this.viewMode === "perspective" && !event.shiftKey) {
|
2026-03-31 04:24:06 +02:00
|
|
|
this.orbitCamera(deltaX, deltaY);
|
2026-04-02 22:12:01 +02:00
|
|
|
} else {
|
|
|
|
|
this.panCamera(deltaX, deltaY);
|
2026-03-31 04:24:06 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 02:14:19 +02:00
|
|
|
if (
|
|
|
|
|
this.activeTransformDrag !== null &&
|
|
|
|
|
this.activeTransformDrag.pointerId === event.pointerId &&
|
|
|
|
|
this.currentTransformSession.kind === "active" &&
|
|
|
|
|
this.currentTransformSession.id === this.activeTransformDrag.sessionId
|
|
|
|
|
) {
|
|
|
|
|
const nextSession = this.buildTransformPreviewFromPointer(
|
|
|
|
|
this.currentTransformSession,
|
|
|
|
|
this.activeTransformDrag.initialClientPosition,
|
|
|
|
|
{
|
|
|
|
|
x: event.clientX,
|
|
|
|
|
y: event.clientY
|
|
|
|
|
},
|
|
|
|
|
this.activeTransformDrag.axisConstraint
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
this.currentTransformSession = nextSession;
|
|
|
|
|
this.applyTransformPreview();
|
|
|
|
|
this.syncTransformGizmo();
|
|
|
|
|
this.transformSessionChangeHandler?.(nextSession);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 20:11:41 +02:00
|
|
|
if (this.toolMode === "select") {
|
|
|
|
|
const hoveredCandidate = this.getSelectionCandidates(event)[0]?.selection ?? { kind: "none" };
|
|
|
|
|
this.setHoveredSelection(hoveredCandidate);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.setHoveredSelection({
|
|
|
|
|
kind: "none"
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-03 00:21:55 +02:00
|
|
|
if (this.toolMode !== "create" || this.creationPreview === null) {
|
2026-03-31 03:44:17 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 00:21:55 +02:00
|
|
|
const previewCenter = this.getCreationPreviewCenter(event, this.creationPreview.target);
|
|
|
|
|
const nextCreationPreview = {
|
|
|
|
|
...this.creationPreview,
|
|
|
|
|
center: previewCenter
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.syncCreationPreview(nextCreationPreview);
|
|
|
|
|
this.creationPreviewChangeHandler?.(nextCreationPreview);
|
2026-03-31 03:44:17 +02:00
|
|
|
};
|
|
|
|
|
|
2026-03-31 04:24:06 +02:00
|
|
|
private handlePointerUp = (event: PointerEvent) => {
|
2026-04-03 02:14:19 +02:00
|
|
|
if (this.activeTransformDrag !== null && this.activeTransformDrag.pointerId === event.pointerId) {
|
|
|
|
|
if (this.renderer.domElement.hasPointerCapture(event.pointerId)) {
|
|
|
|
|
this.renderer.domElement.releasePointerCapture(event.pointerId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const completedSession = this.currentTransformSession.kind === "active" ? this.currentTransformSession : null;
|
|
|
|
|
this.activeTransformDrag = null;
|
|
|
|
|
|
|
|
|
|
if (completedSession !== null) {
|
|
|
|
|
if (event.type === "pointercancel") {
|
|
|
|
|
this.transformCancelHandler?.();
|
|
|
|
|
} else {
|
|
|
|
|
this.transformCommitHandler?.(completedSession);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 04:24:06 +02:00
|
|
|
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-04-03 01:36:56 +02:00
|
|
|
this.emitCameraStateChange();
|
2026-03-31 04:24:06 +02:00
|
|
|
};
|
|
|
|
|
|
2026-03-31 03:44:17 +02:00
|
|
|
private handlePointerLeave = () => {
|
2026-03-31 04:24:06 +02:00
|
|
|
if (this.activeCameraDragPointerId !== null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-03 00:26:27 +02:00
|
|
|
|
2026-04-04 20:11:41 +02:00
|
|
|
this.setHoveredSelection({
|
|
|
|
|
kind: "none"
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-03 00:26:27 +02:00
|
|
|
// Keep the shared creation preview alive across panel boundaries; the next
|
|
|
|
|
// viewport panel will update it as the pointer continues moving.
|
2026-03-31 03:44:17 +02:00
|
|
|
};
|
|
|
|
|
|
2026-04-03 02:34:55 +02:00
|
|
|
private handleWindowPointerMove = (event: PointerEvent) => {
|
|
|
|
|
if (
|
|
|
|
|
this.currentTransformSession.kind !== "active" ||
|
|
|
|
|
this.currentTransformSession.sourcePanelId !== this.panelId ||
|
|
|
|
|
this.currentTransformSession.source === "gizmo" ||
|
|
|
|
|
this.keyboardTransformPointerOrigin === null ||
|
|
|
|
|
this.keyboardTransformPointerOrigin.sessionId !== this.currentTransformSession.id
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextSession = this.buildTransformPreviewFromPointer(
|
|
|
|
|
this.currentTransformSession,
|
|
|
|
|
{
|
|
|
|
|
x: this.keyboardTransformPointerOrigin.clientX,
|
|
|
|
|
y: this.keyboardTransformPointerOrigin.clientY
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
x: event.clientX,
|
|
|
|
|
y: event.clientY
|
|
|
|
|
},
|
|
|
|
|
this.currentTransformSession.axisConstraint
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
this.currentTransformSession = nextSession;
|
|
|
|
|
this.applyTransformPreview();
|
|
|
|
|
this.syncTransformGizmo();
|
|
|
|
|
this.transformSessionChangeHandler?.(nextSession);
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-31 04:24:06 +02:00
|
|
|
private handleWheel = (event: WheelEvent) => {
|
|
|
|
|
event.preventDefault();
|
2026-04-02 22:12:01 +02:00
|
|
|
|
|
|
|
|
if (this.viewMode === "perspective") {
|
|
|
|
|
this.cameraSpherical.radius = Math.min(
|
|
|
|
|
MAX_CAMERA_DISTANCE,
|
|
|
|
|
Math.max(MIN_CAMERA_DISTANCE, this.cameraSpherical.radius * Math.exp(event.deltaY * ZOOM_SPEED))
|
|
|
|
|
);
|
|
|
|
|
this.applyPerspectiveCameraPose();
|
2026-04-03 01:36:56 +02:00
|
|
|
this.emitCameraStateChange();
|
2026-04-02 22:12:01 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.orthographicCamera.zoom = Math.min(
|
|
|
|
|
MAX_ORTHOGRAPHIC_ZOOM,
|
|
|
|
|
Math.max(MIN_ORTHOGRAPHIC_ZOOM, this.orthographicCamera.zoom * Math.exp(-event.deltaY * ZOOM_SPEED))
|
2026-03-31 04:24:06 +02:00
|
|
|
);
|
2026-04-02 22:12:01 +02:00
|
|
|
this.orthographicCamera.updateProjectionMatrix();
|
2026-04-03 01:36:56 +02:00
|
|
|
this.emitCameraStateChange();
|
2026-03-31 04:24:06 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private handleAuxClick = (event: MouseEvent) => {
|
2026-04-04 07:51:38 +02:00
|
|
|
if (event.button === 1 || event.button === 2) {
|
2026-03-31 04:24:06 +02:00
|
|
|
event.preventDefault();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-04 07:51:38 +02:00
|
|
|
private handleContextMenu = (event: MouseEvent) => {
|
|
|
|
|
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-04-02 22:17:21 +02:00
|
|
|
this.applyPerspectiveCameraPose();
|
2026-03-31 04:24:06 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
2026-04-02 22:12:01 +02:00
|
|
|
if (this.viewMode === "perspective") {
|
|
|
|
|
const visibleHeight = 2 * Math.tan((this.perspectiveCamera.fov * Math.PI) / 360) * this.cameraSpherical.radius;
|
|
|
|
|
const visibleWidth = visibleHeight * Math.max(this.perspectiveCamera.aspect, 0.0001);
|
|
|
|
|
|
|
|
|
|
this.perspectiveCamera.getWorldDirection(this.cameraForward);
|
|
|
|
|
this.cameraRight.crossVectors(this.cameraForward, this.perspectiveCamera.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.applyPerspectiveCameraPose();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const visibleHeight = ORTHOGRAPHIC_FRUSTUM_HEIGHT / this.orthographicCamera.zoom;
|
|
|
|
|
const visibleWidth = (this.orthographicCamera.right - this.orthographicCamera.left) / this.orthographicCamera.zoom;
|
|
|
|
|
|
|
|
|
|
this.orthographicCamera.getWorldDirection(this.cameraForward);
|
|
|
|
|
this.cameraRight.crossVectors(this.cameraForward, this.orthographicCamera.up).normalize();
|
2026-03-31 04:24:06 +02:00
|
|
|
this.cameraUp.crossVectors(this.cameraRight, this.cameraForward).normalize();
|
|
|
|
|
|
|
|
|
|
this.cameraTarget
|
|
|
|
|
.addScaledVector(this.cameraRight, (-deltaX / width) * visibleWidth)
|
|
|
|
|
.addScaledVector(this.cameraUp, (deltaY / height) * visibleHeight);
|
|
|
|
|
|
2026-04-02 22:12:01 +02:00
|
|
|
this.applyOrthographicCameraPose();
|
2026-03-31 04:24:06 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-03 00:21:55 +02:00
|
|
|
private getCreationPreviewCenter(event: PointerEvent, target: CreationTarget): Vec3 | null {
|
|
|
|
|
switch (target.kind) {
|
|
|
|
|
case "box-brush":
|
|
|
|
|
return this.getBoxCreationPreviewCenter(event, DEFAULT_BOX_BRUSH_SIZE);
|
|
|
|
|
case "entity":
|
|
|
|
|
switch (target.entityKind) {
|
|
|
|
|
case "triggerVolume":
|
|
|
|
|
return this.getBoxCreationPreviewCenter(event, DEFAULT_TRIGGER_VOLUME_SIZE);
|
|
|
|
|
case "pointLight":
|
|
|
|
|
case "playerStart":
|
|
|
|
|
case "soundEmitter":
|
|
|
|
|
case "teleportTarget":
|
|
|
|
|
case "interactable":
|
|
|
|
|
case "spotLight":
|
|
|
|
|
return this.getPlanarCreationAnchor(event);
|
|
|
|
|
}
|
|
|
|
|
case "model-instance": {
|
|
|
|
|
const anchor = this.getPlanarCreationAnchor(event);
|
2026-04-02 23:52:07 +02:00
|
|
|
|
2026-04-03 00:21:55 +02:00
|
|
|
if (anchor === null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-04-02 23:52:07 +02:00
|
|
|
|
2026-04-03 00:21:55 +02:00
|
|
|
const asset = this.projectAssets[target.assetId];
|
2026-04-02 23:52:07 +02:00
|
|
|
|
2026-04-03 00:21:55 +02:00
|
|
|
if (asset === undefined || asset.kind !== "model") {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-04-02 23:52:07 +02:00
|
|
|
|
2026-04-03 00:21:55 +02:00
|
|
|
return createModelInstancePlacementPosition(asset, anchor);
|
2026-04-02 23:52:07 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 00:21:55 +02:00
|
|
|
private getPlanarCreationAnchor(event: PointerEvent): Vec3 | null {
|
2026-03-31 03:44:17 +02:00
|
|
|
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);
|
|
|
|
|
|
2026-04-02 22:12:01 +02:00
|
|
|
this.raycaster.setFromCamera(this.pointer, this.getActiveCamera());
|
|
|
|
|
|
|
|
|
|
if (this.raycaster.ray.intersectPlane(this.getBoxCreatePlane(), this.boxCreateIntersection) === null) {
|
2026-03-31 03:44:17 +02:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 22:12:01 +02:00
|
|
|
switch (this.viewMode) {
|
|
|
|
|
case "perspective":
|
|
|
|
|
case "top":
|
|
|
|
|
return {
|
2026-04-04 19:29:19 +02:00
|
|
|
x: this.snapWhiteboxPositionValue(this.boxCreateIntersection.x),
|
|
|
|
|
y: this.snapWhiteboxPositionValue(0),
|
|
|
|
|
z: this.snapWhiteboxPositionValue(this.boxCreateIntersection.z)
|
2026-04-02 22:12:01 +02:00
|
|
|
};
|
|
|
|
|
case "front":
|
|
|
|
|
return {
|
2026-04-04 19:29:19 +02:00
|
|
|
x: this.snapWhiteboxPositionValue(this.boxCreateIntersection.x),
|
|
|
|
|
y: this.snapWhiteboxPositionValue(this.boxCreateIntersection.y),
|
|
|
|
|
z: this.snapWhiteboxPositionValue(0)
|
2026-04-02 22:12:01 +02:00
|
|
|
};
|
|
|
|
|
case "side":
|
|
|
|
|
return {
|
2026-04-04 19:29:19 +02:00
|
|
|
x: this.snapWhiteboxPositionValue(0),
|
|
|
|
|
y: this.snapWhiteboxPositionValue(this.boxCreateIntersection.y),
|
|
|
|
|
z: this.snapWhiteboxPositionValue(this.boxCreateIntersection.z)
|
2026-04-02 23:52:07 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 00:21:55 +02:00
|
|
|
private getBoxCreationPreviewCenter(event: PointerEvent, size: Vec3): Vec3 | null {
|
2026-04-02 23:52:07 +02:00
|
|
|
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.getActiveCamera());
|
|
|
|
|
|
|
|
|
|
if (this.raycaster.ray.intersectPlane(this.getBoxCreatePlane(), this.boxCreateIntersection) === null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch (this.viewMode) {
|
|
|
|
|
case "perspective":
|
|
|
|
|
case "top":
|
|
|
|
|
return {
|
2026-04-04 19:29:19 +02:00
|
|
|
x: this.snapWhiteboxPositionValue(this.boxCreateIntersection.x),
|
|
|
|
|
y: this.snapWhiteboxPositionValue(size.y * 0.5),
|
|
|
|
|
z: this.snapWhiteboxPositionValue(this.boxCreateIntersection.z)
|
2026-04-02 23:52:07 +02:00
|
|
|
};
|
|
|
|
|
case "front":
|
|
|
|
|
return {
|
2026-04-04 19:29:19 +02:00
|
|
|
x: this.snapWhiteboxPositionValue(this.boxCreateIntersection.x),
|
|
|
|
|
y: this.snapWhiteboxPositionValue(this.boxCreateIntersection.y),
|
|
|
|
|
z: this.snapWhiteboxPositionValue(size.z * 0.5)
|
2026-04-02 23:52:07 +02:00
|
|
|
};
|
|
|
|
|
case "side":
|
|
|
|
|
return {
|
2026-04-04 19:29:19 +02:00
|
|
|
x: this.snapWhiteboxPositionValue(size.x * 0.5),
|
|
|
|
|
y: this.snapWhiteboxPositionValue(this.boxCreateIntersection.y),
|
|
|
|
|
z: this.snapWhiteboxPositionValue(this.boxCreateIntersection.z)
|
2026-04-02 22:12:01 +02:00
|
|
|
};
|
|
|
|
|
}
|
2026-03-31 03:44:17 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-03 00:21:55 +02:00
|
|
|
private getCreationPreviewTargetKey(target: CreationTarget): string {
|
|
|
|
|
switch (target.kind) {
|
|
|
|
|
case "box-brush":
|
|
|
|
|
return "box-brush";
|
|
|
|
|
case "entity":
|
|
|
|
|
return `entity:${target.entityKind}:${target.audioAssetId}`;
|
|
|
|
|
case "model-instance":
|
|
|
|
|
return `model-instance:${target.assetId}`;
|
2026-04-02 23:52:07 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 00:21:55 +02:00
|
|
|
private clearCreationPreviewObject() {
|
|
|
|
|
if (this.creationPreviewObject === null) {
|
|
|
|
|
this.creationPreviewTargetKey = null;
|
2026-04-02 23:52:07 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 00:21:55 +02:00
|
|
|
this.scene.remove(this.creationPreviewObject);
|
|
|
|
|
disposeModelInstance(this.creationPreviewObject);
|
|
|
|
|
this.creationPreviewObject = null;
|
|
|
|
|
this.creationPreviewTargetKey = null;
|
2026-04-02 23:52:07 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-03 00:21:55 +02:00
|
|
|
private createCreationPreviewObject(toolPreview: CreationViewportToolPreview): Group {
|
2026-04-02 23:52:07 +02:00
|
|
|
const previewPosition = toolPreview.center ?? {
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
z: 0
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
switch (toolPreview.target.kind) {
|
2026-04-03 00:21:55 +02:00
|
|
|
case "box-brush": {
|
|
|
|
|
const fallbackGroup = new Group();
|
|
|
|
|
fallbackGroup.visible = false;
|
|
|
|
|
return fallbackGroup;
|
|
|
|
|
}
|
2026-04-04 19:06:33 +02:00
|
|
|
case "entity": {
|
|
|
|
|
let previewGroup: Group;
|
|
|
|
|
|
2026-04-02 23:52:07 +02:00
|
|
|
switch (toolPreview.target.entityKind) {
|
|
|
|
|
case "pointLight":
|
2026-04-04 19:06:33 +02:00
|
|
|
previewGroup = this.createPointLightGizmoRenderObjects(
|
2026-04-03 00:21:55 +02:00
|
|
|
"creation-preview",
|
2026-04-02 23:52:07 +02:00
|
|
|
previewPosition,
|
|
|
|
|
DEFAULT_POINT_LIGHT_DISTANCE,
|
2026-04-02 23:58:12 +02:00
|
|
|
PLACEMENT_PREVIEW_COLOR_HEX,
|
2026-04-02 23:52:07 +02:00
|
|
|
false
|
|
|
|
|
).group;
|
2026-04-04 19:06:33 +02:00
|
|
|
break;
|
2026-04-02 23:52:07 +02:00
|
|
|
case "spotLight":
|
2026-04-04 19:06:33 +02:00
|
|
|
previewGroup = this.createSpotLightGizmoRenderObjects(
|
2026-04-03 00:21:55 +02:00
|
|
|
"creation-preview",
|
2026-04-02 23:52:07 +02:00
|
|
|
previewPosition,
|
|
|
|
|
DEFAULT_SPOT_LIGHT_DIRECTION,
|
|
|
|
|
DEFAULT_SPOT_LIGHT_DISTANCE,
|
|
|
|
|
DEFAULT_SPOT_LIGHT_ANGLE_DEGREES,
|
2026-04-02 23:58:12 +02:00
|
|
|
PLACEMENT_PREVIEW_COLOR_HEX,
|
2026-04-02 23:52:07 +02:00
|
|
|
false
|
|
|
|
|
).group;
|
2026-04-04 19:06:33 +02:00
|
|
|
break;
|
2026-04-02 23:52:07 +02:00
|
|
|
case "playerStart":
|
2026-04-04 19:06:33 +02:00
|
|
|
previewGroup = this.createPlayerStartRenderObjects(
|
2026-04-04 15:55:14 +02:00
|
|
|
"creation-preview",
|
|
|
|
|
previewPosition,
|
|
|
|
|
DEFAULT_PLAYER_START_YAW_DEGREES,
|
|
|
|
|
{
|
|
|
|
|
mode: "capsule",
|
2026-04-04 15:55:19 +02:00
|
|
|
eyeHeight: DEFAULT_PLAYER_START_EYE_HEIGHT,
|
2026-04-04 15:55:14 +02:00
|
|
|
capsuleRadius: DEFAULT_PLAYER_START_CAPSULE_RADIUS,
|
|
|
|
|
capsuleHeight: DEFAULT_PLAYER_START_CAPSULE_HEIGHT,
|
|
|
|
|
boxSize: DEFAULT_PLAYER_START_BOX_SIZE
|
|
|
|
|
},
|
|
|
|
|
false
|
|
|
|
|
).group;
|
2026-04-04 19:06:33 +02:00
|
|
|
break;
|
2026-04-02 23:52:07 +02:00
|
|
|
case "soundEmitter":
|
2026-04-04 19:06:33 +02:00
|
|
|
previewGroup = this.createSoundEmitterRenderObjects(
|
2026-04-03 00:21:55 +02:00
|
|
|
"creation-preview",
|
2026-04-02 23:52:07 +02:00
|
|
|
previewPosition,
|
|
|
|
|
DEFAULT_SOUND_EMITTER_REF_DISTANCE,
|
|
|
|
|
DEFAULT_SOUND_EMITTER_MAX_DISTANCE,
|
|
|
|
|
false,
|
2026-04-02 23:58:41 +02:00
|
|
|
BOX_CREATE_PREVIEW_FILL
|
2026-04-02 23:52:07 +02:00
|
|
|
).group;
|
2026-04-04 19:06:33 +02:00
|
|
|
break;
|
2026-04-02 23:52:07 +02:00
|
|
|
case "triggerVolume":
|
2026-04-04 19:06:33 +02:00
|
|
|
previewGroup = this.createTriggerVolumeRenderObjects(
|
2026-04-03 00:21:55 +02:00
|
|
|
"creation-preview",
|
2026-04-02 23:52:07 +02:00
|
|
|
previewPosition,
|
|
|
|
|
DEFAULT_TRIGGER_VOLUME_SIZE,
|
|
|
|
|
false,
|
2026-04-02 23:58:41 +02:00
|
|
|
BOX_CREATE_PREVIEW_FILL
|
2026-04-02 23:52:07 +02:00
|
|
|
).group;
|
2026-04-04 19:06:33 +02:00
|
|
|
break;
|
2026-04-02 23:52:07 +02:00
|
|
|
case "teleportTarget":
|
2026-04-04 19:06:33 +02:00
|
|
|
previewGroup = this.createTeleportTargetRenderObjects(
|
2026-04-03 00:21:55 +02:00
|
|
|
"creation-preview",
|
2026-04-02 23:52:07 +02:00
|
|
|
previewPosition,
|
|
|
|
|
DEFAULT_TELEPORT_TARGET_YAW_DEGREES,
|
|
|
|
|
false,
|
2026-04-02 23:58:41 +02:00
|
|
|
BOX_CREATE_PREVIEW_FILL
|
2026-04-02 23:52:07 +02:00
|
|
|
).group;
|
2026-04-04 19:06:33 +02:00
|
|
|
break;
|
2026-04-02 23:52:07 +02:00
|
|
|
case "interactable":
|
2026-04-04 19:06:33 +02:00
|
|
|
previewGroup = this.createInteractableRenderObjects(
|
2026-04-03 00:21:55 +02:00
|
|
|
"creation-preview",
|
2026-04-02 23:52:07 +02:00
|
|
|
previewPosition,
|
|
|
|
|
DEFAULT_INTERACTABLE_RADIUS,
|
|
|
|
|
false,
|
2026-04-02 23:58:41 +02:00
|
|
|
BOX_CREATE_PREVIEW_FILL
|
2026-04-02 23:52:07 +02:00
|
|
|
).group;
|
2026-04-04 19:06:33 +02:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
if (this.displayMode === "wireframe") {
|
|
|
|
|
this.applyWireframePresentation(previewGroup);
|
2026-04-02 23:52:07 +02:00
|
|
|
}
|
2026-04-04 19:06:33 +02:00
|
|
|
|
|
|
|
|
return previewGroup;
|
|
|
|
|
}
|
2026-04-02 23:52:07 +02:00
|
|
|
case "model-instance": {
|
|
|
|
|
const asset = this.projectAssets[toolPreview.target.assetId];
|
2026-04-03 01:10:38 +02:00
|
|
|
const loadedAsset = this.loadedModelAssets[toolPreview.target.assetId];
|
2026-04-02 23:52:07 +02:00
|
|
|
|
|
|
|
|
if (asset === undefined || asset.kind !== "model") {
|
|
|
|
|
const fallbackGroup = new Group();
|
|
|
|
|
fallbackGroup.visible = false;
|
|
|
|
|
return fallbackGroup;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const dummyModelInstance = createModelInstance({
|
|
|
|
|
assetId: toolPreview.target.assetId,
|
|
|
|
|
position: previewPosition,
|
|
|
|
|
rotationDegrees: DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES,
|
|
|
|
|
scale: DEFAULT_MODEL_INSTANCE_SCALE
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-04 19:06:33 +02:00
|
|
|
return createModelInstanceRenderGroup(
|
|
|
|
|
dummyModelInstance,
|
|
|
|
|
asset,
|
|
|
|
|
loadedAsset,
|
|
|
|
|
false,
|
|
|
|
|
BOX_CREATE_PREVIEW_FILL,
|
|
|
|
|
this.displayMode === "wireframe" ? "wireframe" : "normal"
|
|
|
|
|
);
|
2026-04-02 23:52:07 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-02 23:58:12 +02:00
|
|
|
|
2026-04-03 00:21:55 +02:00
|
|
|
throw new Error("Unsupported creation preview target.");
|
2026-04-02 23:52:07 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-03 00:21:55 +02:00
|
|
|
private syncCreationPreview(toolPreview: CreationViewportToolPreview | null) {
|
|
|
|
|
const currentToolPreview = this.creationPreview === null ? { kind: "none" as const } : this.creationPreview;
|
2026-04-02 23:52:07 +02:00
|
|
|
const nextToolPreview = toolPreview === null ? { kind: "none" as const } : toolPreview;
|
|
|
|
|
|
|
|
|
|
if (areViewportToolPreviewsEqual(currentToolPreview, nextToolPreview)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 00:21:55 +02:00
|
|
|
this.creationPreview = toolPreview === null ? null : {
|
|
|
|
|
kind: "create",
|
2026-04-02 23:52:07 +02:00
|
|
|
sourcePanelId: toolPreview.sourcePanelId,
|
|
|
|
|
target:
|
|
|
|
|
toolPreview.target.kind === "entity"
|
|
|
|
|
? {
|
|
|
|
|
kind: "entity",
|
|
|
|
|
entityKind: toolPreview.target.entityKind,
|
|
|
|
|
audioAssetId: toolPreview.target.audioAssetId
|
|
|
|
|
}
|
2026-04-03 00:22:31 +02:00
|
|
|
: toolPreview.target.kind === "model-instance"
|
|
|
|
|
? {
|
|
|
|
|
kind: "model-instance",
|
|
|
|
|
assetId: toolPreview.target.assetId
|
|
|
|
|
}
|
|
|
|
|
: {
|
|
|
|
|
kind: "box-brush"
|
|
|
|
|
},
|
2026-04-02 23:52:07 +02:00
|
|
|
center: toolPreview.center === null ? null : { ...toolPreview.center }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (toolPreview === null) {
|
2026-04-03 00:21:55 +02:00
|
|
|
this.boxCreatePreviewMesh.visible = false;
|
|
|
|
|
this.boxCreatePreviewEdges.visible = false;
|
|
|
|
|
this.clearCreationPreviewObject();
|
2026-04-02 23:52:07 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 00:21:55 +02:00
|
|
|
if (toolPreview.target.kind === "box-brush") {
|
|
|
|
|
this.boxCreatePreviewMesh.visible = toolPreview.center !== null;
|
|
|
|
|
this.boxCreatePreviewEdges.visible = toolPreview.center !== null;
|
|
|
|
|
|
|
|
|
|
if (toolPreview.center !== null) {
|
|
|
|
|
this.boxCreatePreviewMesh.position.set(toolPreview.center.x, toolPreview.center.y, toolPreview.center.z);
|
|
|
|
|
this.boxCreatePreviewEdges.position.set(toolPreview.center.x, toolPreview.center.y, toolPreview.center.z);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.clearCreationPreviewObject();
|
|
|
|
|
this.creationPreviewTargetKey = null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextTargetKey = this.getCreationPreviewTargetKey(toolPreview.target);
|
|
|
|
|
|
|
|
|
|
this.boxCreatePreviewMesh.visible = false;
|
|
|
|
|
this.boxCreatePreviewEdges.visible = false;
|
2026-04-02 23:58:12 +02:00
|
|
|
|
2026-04-03 00:21:55 +02:00
|
|
|
if (this.creationPreviewObject !== null && this.creationPreviewTargetKey === nextTargetKey) {
|
|
|
|
|
this.creationPreviewObject.visible = toolPreview.center !== null;
|
2026-04-02 23:58:12 +02:00
|
|
|
|
|
|
|
|
if (toolPreview.center !== null) {
|
2026-04-03 00:21:55 +02:00
|
|
|
this.creationPreviewObject.position.set(toolPreview.center.x, toolPreview.center.y, toolPreview.center.z);
|
2026-04-02 23:58:12 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-03 00:21:55 +02:00
|
|
|
this.creationPreviewTargetKey = nextTargetKey;
|
2026-04-02 23:58:12 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 00:21:55 +02:00
|
|
|
this.clearCreationPreviewObject();
|
2026-04-02 23:58:12 +02:00
|
|
|
|
2026-04-03 00:21:55 +02:00
|
|
|
const creationPreviewObject = this.createCreationPreviewObject(toolPreview);
|
|
|
|
|
creationPreviewObject.visible = toolPreview.center !== null;
|
|
|
|
|
this.scene.add(creationPreviewObject);
|
|
|
|
|
this.creationPreviewObject = creationPreviewObject;
|
2026-04-03 00:22:31 +02:00
|
|
|
this.creationPreviewTargetKey = nextTargetKey;
|
2026-04-02 23:52:07 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:35:02 +02:00
|
|
|
private render = () => {
|
|
|
|
|
this.animationFrame = window.requestAnimationFrame(this.render);
|
2026-04-03 02:14:19 +02:00
|
|
|
this.updateTransformGizmoPose();
|
2026-04-02 20:51:45 +02:00
|
|
|
|
|
|
|
|
if (this.advancedRenderingComposer !== null) {
|
|
|
|
|
this.advancedRenderingComposer.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 22:12:01 +02:00
|
|
|
this.renderer.render(this.scene, this.getActiveCamera());
|
2026-03-31 02:35:02 +02:00
|
|
|
};
|
|
|
|
|
}
|