Files
webeditor3d/src/viewport-three/viewport-host.ts

9767 lines
283 KiB
TypeScript

import {
AmbientLight,
AxesHelper,
BufferGeometry,
BoxGeometry,
CapsuleGeometry,
Color,
ConeGeometry,
CylinderGeometry,
DirectionalLight,
EdgesGeometry,
Euler,
GridHelper,
Group,
Line,
LineBasicMaterial,
LineSegments,
Material,
Matrix4,
Mesh,
MeshBasicMaterial,
MeshPhysicalMaterial,
MeshStandardMaterial,
Object3D,
OrthographicCamera,
Plane,
PerspectiveCamera,
PointLight,
Quaternion,
Raycaster,
Scene,
ShaderMaterial,
SphereGeometry,
Spherical,
TorusGeometry,
SpotLight,
TextureLoader,
Texture,
Vector2,
Vector3,
WebGLRenderTarget,
WebGLRenderer
} from "three";
import { EffectComposer } from "postprocessing";
import {
applyEditorSelectionClick,
areEditorSelectionsEqual,
isBrushEdgeSelected,
isBrushFaceSelected,
isBrushSelected,
isBrushVertexSelected,
isModelInstanceSelected,
isPathPointSelected,
isPathSelected,
isTerrainSelected,
type EditorSelection
} from "../core/selection";
import {
getTerrainBrushCommandLabel,
type ArmedTerrainBrushState,
type TerrainBrushStrokeCommit
} from "../core/terrain-brush";
import { getWhiteboxSelectionFeedbackLabel } from "../core/whitebox-selection-feedback";
import type { WhiteboxSelectionMode } from "../core/whitebox-selection-mode";
import {
cloneTransformSession,
createInactiveTransformSession,
createTransformPreviewFromTarget,
createTransformSession,
resolveTransformTarget,
supportsLocalTransformAxisConstraint,
supportsTransformOperation,
supportsTransformSurfaceSnapTarget,
supportsTransformAxisConstraint,
type ActiveTransformSession,
type TransformAxis,
type TransformAxisSpace,
type TransformPreview,
type TransformSessionState
} from "../core/transform-session";
import type { ToolMode } from "../core/tool-mode";
import type { Vec3 } from "../core/vector";
import {
createModelInstanceRenderGroup,
disposeModelInstance
} from "../assets/model-instance-rendering";
import type { LoadedModelAsset } from "../assets/gltf-model-import";
import type { LoadedImageAsset } from "../assets/image-assets";
import type { ProjectAssetRecord } from "../assets/project-assets";
import {
createModelInstance,
createModelInstancePlacementPosition,
DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES,
DEFAULT_MODEL_INSTANCE_SCALE,
getModelInstances,
type ModelInstance
} from "../assets/model-instances";
import type { SceneDocument } from "../document/scene-document";
import {
getScenePaths,
sampleScenePathPosition,
type ScenePath
} from "../document/paths";
import {
areTerrainsEqual,
cloneTerrain,
getTerrains,
type Terrain
} from "../document/terrains";
import {
areAdvancedRenderingSettingsEqual,
cloneAdvancedRenderingSettings,
type AdvancedRenderingSettings
} from "../document/world-settings";
import type { WorldSettings } from "../document/world-settings";
import {
createCameraRigEntity,
createNpcAlwaysPresence,
createNpcColliderSettings,
DEFAULT_CAMERA_RIG_TARGET_OFFSET,
DEFAULT_INTERACTABLE_RADIUS,
DEFAULT_NPC_YAW_DEGREES,
DEFAULT_PLAYER_START_BOX_SIZE,
DEFAULT_PLAYER_START_CAPSULE_HEIGHT,
DEFAULT_PLAYER_START_CAPSULE_RADIUS,
DEFAULT_PLAYER_START_EYE_HEIGHT,
DEFAULT_PLAYER_START_YAW_DEGREES,
DEFAULT_POINT_LIGHT_DISTANCE,
DEFAULT_SCENE_ENTRY_YAW_DEGREES,
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,
getNpcColliderHeight,
getPlayerStartColliderHeight,
getEntityInstances,
normalizeYawDegrees,
resolveCameraRigDocumentPosition,
resolveCameraRigDocumentLookTarget,
type CameraRigEntity,
type EntityInstance,
type NpcEntity,
type PlayerStartEntity,
type PointLightEntity,
type SpotLightEntity
} from "../entities/entity-instances";
import {
cloneBrushGeometry,
createConeBrush,
createRadialPrismBrush,
createTorusBrush,
createWedgeBrush,
deriveBrushSizeFromGeometry,
scaleBrushGeometryToSize,
updateBrush,
DEFAULT_BOX_BRUSH_SIZE,
DEFAULT_TORUS_BRUSH_SIZE,
type Brush,
type BrushGeometry,
type BoxBrush,
type WhiteboxEdgeId,
type WhiteboxFaceId,
type WhiteboxVertexId
} from "../document/brushes";
import {
getBrushEdgeAxis,
getBrushEdgeScaleAxes,
getBrushEdgeWorldSegment,
getBrushFaceAxis,
getBrushFaceWorldCenter,
getBrushLocalVertexPosition,
getBrushVertexWorldPosition,
transformBrushWorldPointToLocal,
transformBrushWorldVectorToLocal
} from "../geometry/whitebox-brush";
import { buildBoxBrushDerivedMeshData } from "../geometry/box-brush-mesh";
import { buildTerrainDerivedMeshData } from "../geometry/terrain-mesh";
import {
applyTerrainBrushStamp,
createTerrainBrushPreviewPoints,
getTerrainBrushStrokeSpacing
} from "../geometry/terrain-brush";
import {
getBrushEdgeIds,
getBrushEdgeVertexIds,
getBrushFaceIds,
getBrushFaceVertexIds,
getBrushVertexIds
} from "../geometry/whitebox-topology";
import { createModelColliderDebugGroup } from "../geometry/model-instance-collider-debug-mesh";
import { buildGeneratedModelCollider } from "../geometry/model-instance-collider-generation";
import { DEFAULT_GRID_SIZE, snapValueToGrid } from "../geometry/grid-snapping";
import {
createStarterMaterialSignature,
createStarterMaterialTextureSet,
disposeStarterMaterialTextureSet,
type StarterMaterialTextureSet
} from "../materials/starter-material-textures";
import type { MaterialDef } from "../materials/starter-material-library";
import {
applyAdvancedRenderingRenderableShadowFlags,
configureAdvancedRenderingShadowLight,
configureAdvancedRenderingRenderer,
createAdvancedRenderingComposer,
resolveBoxVolumeRenderPaths
} from "../rendering/advanced-rendering";
import {
fitCelestialDirectionalShadow,
resolveDominantCelestialShadowCaster
} from "../rendering/celestial-shadows";
import { createFogQualityMaterial } from "../rendering/fog-material";
import { updatePlanarReflectionCamera } from "../rendering/planar-reflection";
import {
createTerrainLayerBlendMaterial,
getTerrainLayerPreviewColor,
getTerrainLayerTexture
} from "../rendering/terrain-layer-material";
import {
resolveWorldCelestialBodiesState,
resolveWorldEnvironmentState,
WorldBackgroundRenderer
} from "../rendering/world-background-renderer";
import {
resolveWorldShaderSkyEnvironmentPhaseStates,
resolveWorldShaderSkyRenderState
} from "../rendering/world-shader-sky";
import {
createRendererPrecomputedShaderSkyEnvironmentCache,
type PrecomputedShaderSkyEnvironmentCache
} from "../rendering/precomputed-shader-sky-environment-cache";
import {
createRendererQuantizedEnvironmentBlendCache,
createRendererQuantizedPmremBlendCache,
type QuantizedEnvironmentBlendCache
} from "../rendering/quantized-environment-blend-cache";
import {
applyWhiteboxBevelToMaterial,
shouldApplyWhiteboxBevel
} from "../rendering/whitebox-bevel-material";
import {
ALL_RENDER_LAYER_MASK,
applyRendererRenderCategory,
applyRendererRenderCategoryFromMaterial,
enableCameraRendererRenderCategories,
enableObjectForAllRendererRenderCategories
} from "../rendering/render-layers";
import {
collectWaterContactPatches,
createWaterMaterial
} from "../rendering/water-material";
import {
resolveViewportDocumentBounds,
resolveViewportFocusTarget
} from "./viewport-focus";
import { createSoundEmitterMarkerMeshes } from "./viewport-entity-markers";
import {
resolveRuntimeTimeState,
resolveRuntimeDayNightWorldState,
type RuntimeClockState
} from "../runtime-three/runtime-project-time";
import { deriveBoxLightVolumePointLights } from "../runtime-three/light-volume-utils";
import type { RuntimeSceneDefinition } from "../runtime-three/runtime-scene-build";
import { resolveTransformPointerDownIntent } from "./transform-pointer-intent";
import { resolveDominantLocalAxisForWorldAxis } from "./transform-axis-mapping";
import {
SURFACE_SNAP_OFFSET,
applyRigidDeltaToTransformPreview,
computeSurfaceSnapDelta,
createAxisAlignedBoxSurfaceSnapSupportPoints,
createBrushSurfaceSnapSupportPoints,
createModelBoundingBoxSurfaceSnapSupportPoints,
resolveSurfaceSnapHitFromIntersections
} from "./transform-surface-snap";
import {
getViewportViewModeDefinition,
isOrthographicViewportViewMode,
type ViewportGridPlane,
type ViewportViewMode
} from "./viewport-view-modes";
import {
areViewportPanelCameraStatesEqual,
type ViewportDisplayMode,
type ViewportPanelCameraState,
type ViewportPanelId
} from "./viewport-layout";
import {
areViewportToolPreviewsEqual,
type CreationTarget,
type CreationViewportToolPreview,
type ViewportToolPreview
} from "./viewport-transient-state";
interface BrushRenderObjects {
mesh: Mesh<BufferGeometry, Material[]>;
faceIdsInOrder: WhiteboxFaceId[];
edges: LineSegments<EdgesGeometry, LineBasicMaterial>;
edgeHelpers: Array<{
id: WhiteboxEdgeId;
line: Line<BufferGeometry, LineBasicMaterial>;
}>;
vertexHelpers: Array<{
id: WhiteboxVertexId;
mesh: Mesh<SphereGeometry, MeshBasicMaterial>;
}>;
}
interface PathRenderObjects {
line: Line<BufferGeometry, LineBasicMaterial>;
pointMeshes: Array<{
pointId: string;
mesh: Mesh<SphereGeometry, MeshBasicMaterial>;
}>;
}
interface TerrainRenderObjects {
mesh: Mesh<BufferGeometry, Material>;
}
interface TerrainBrushHit {
terrainId: string;
point: Vec3;
}
interface LightVolumeRenderObjects {
group: Group;
lights: PointLight[];
}
interface ActiveTerrainBrushStroke {
pointerId: number;
previewTerrain: Terrain;
referenceHeight: number | null;
lastAppliedPoint: {
x: number;
z: number;
};
toolState: ArmedTerrainBrushState;
}
interface ViewportWaterSurfaceBinding {
brush: BoxBrush;
reflectionTextureUniform: { value: unknown } | null;
reflectionMatrixUniform: { value: Matrix4 } | null;
reflectionEnabledUniform: { value: number } | null;
reflectionRenderTarget: WebGLRenderTarget | null;
lastReflectionUpdateTime: number;
}
const BRUSH_SELECTED_EDGE_COLOR = 0xf7d2aa;
const BRUSH_HOVERED_EDGE_COLOR = 0xb7cbec;
const BRUSH_EDGE_COLOR = 0x0d1017;
const FALLBACK_FACE_COLOR = 0xf2ece2;
const HOVERED_FACE_FALLBACK_COLOR = 0xd9a56f;
const SELECTED_FACE_FALLBACK_COLOR = 0xcf7b42;
const HOVERED_FACE_EMISSIVE = 0x2f1d11;
const SELECTED_FACE_EMISSIVE = 0x4a2814;
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;
const PLAYER_START_COLOR = 0x7cb7ff;
const PLAYER_START_SELECTED_COLOR = 0xf3be8f;
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 SCENE_ENTRY_COLOR = 0x75f0d8;
const SCENE_ENTRY_SELECTED_COLOR = 0xf6c48a;
const CAMERA_RIG_COLOR = 0x86c3ff;
const CAMERA_RIG_SELECTED_COLOR = 0xf3be8f;
const NPC_COLOR = 0xa0df7a;
const NPC_SELECTED_COLOR = 0xf4cd83;
const INTERACTABLE_COLOR = 0x92de7e;
const INTERACTABLE_SELECTED_COLOR = 0xf1cf7e;
const PATH_COLOR = 0x4b82d6;
const PATH_HOVERED_COLOR = 0x86b6ff;
const PATH_SELECTED_COLOR = 0xf3be8f;
const PATH_POINT_COLOR = 0xb7cbec;
const PATH_POINT_HOVERED_COLOR = 0xf3be8f;
const PATH_POINT_SELECTED_COLOR = 0xcf7b42;
const PATH_POINT_RADIUS = 0.12;
const PATH_POINT_HOVERED_SCALE = 1.18;
const PATH_POINT_SELECTED_SCALE = 1.35;
const TERRAIN_BASE_COLOR = 0x708b57;
const TERRAIN_HOVERED_COLOR = 0x89a765;
const TERRAIN_SELECTED_COLOR = 0xe0c17f;
const TERRAIN_ACTIVE_COLOR = 0xf0d8a2;
const TERRAIN_ACTIVE_EMISSIVE = 0x5c4623;
const TERRAIN_SELECTED_EMISSIVE = 0x3f2d17;
const TERRAIN_HOVERED_EMISSIVE = 0x24311b;
const TERRAIN_BRUSH_PREVIEW_RAISE_COLOR = 0x8dd977;
const TERRAIN_BRUSH_PREVIEW_LOWER_COLOR = 0xe17b75;
const TERRAIN_BRUSH_PREVIEW_SMOOTH_COLOR = 0x7dbbf1;
const TERRAIN_BRUSH_PREVIEW_FLATTEN_COLOR = 0xf1d37d;
const TERRAIN_BRUSH_PREVIEW_PAINT_COLOR = 0x8eb9ff;
const TERRAIN_BRUSH_PREVIEW_OFFSET = 0.05;
const BOX_CREATE_PREVIEW_FILL = 0x89b6ff;
const BOX_CREATE_PREVIEW_EDGE = 0xf3be8f;
const PLACEMENT_PREVIEW_COLOR_HEX = "#89b6ff";
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;
const ORTHOGRAPHIC_CAMERA_DISTANCE = 100;
const ORTHOGRAPHIC_FRUSTUM_HEIGHT = 20;
const MIN_ORTHOGRAPHIC_ZOOM = 0.25;
const MAX_ORTHOGRAPHIC_ZOOM = 20;
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;
const GIZMO_RENDER_ORDER = 4_000;
const SCALE_SNAP_STEP = 0.1;
const MIN_SCALE_COMPONENT = 0.1;
const MIN_BOX_SIZE_COMPONENT = 0.01;
const WATER_REFLECTION_UPDATE_INTERVAL_MS = 96;
const VIEWPORT_GRID_VISUAL_SIZE = 400;
const VIEWPORT_GRID_VISUAL_DIVISIONS = 400;
interface CachedMaterialTexture {
signature: string;
textureSet: StarterMaterialTextureSet;
}
interface EntityRenderObjects {
group: Group;
meshes: Mesh[];
dispose?: () => void;
}
interface CameraRigPreviewRenderObjects {
previewGroup: Group;
trackLine: Line<BufferGeometry, LineBasicMaterial>;
trackStartMesh: Mesh<SphereGeometry, MeshBasicMaterial>;
trackEndMesh: Mesh<SphereGeometry, MeshBasicMaterial>;
railSpanLine: Line<BufferGeometry, LineBasicMaterial>;
railStartMesh: Mesh<SphereGeometry, MeshBasicMaterial>;
railEndMesh: Mesh<SphereGeometry, MeshBasicMaterial>;
}
interface LocalLightRenderObjects {
group: Group;
light: PointLight | SpotLight;
}
export class ViewportHost {
private readonly scene = new Scene();
private readonly worldBackgroundRenderer = new WorldBackgroundRenderer();
private readonly axesHelper = new AxesHelper(2);
private readonly perspectiveCamera = new PerspectiveCamera(60, 1, 0.1, 1000);
private readonly orthographicCamera = new OrthographicCamera(
-10,
10,
10,
-10,
0.1,
1000
);
private readonly renderer = new WebGLRenderer({
antialias: false,
alpha: true
});
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 transformAxisDelta = new Vector3();
private readonly fogLocalCameraPosition = new Vector3();
private readonly cameraSpherical = new Spherical();
private readonly gridHelpers: Record<ViewportGridPlane, GridHelper> = {
xz: new GridHelper(
VIEWPORT_GRID_VISUAL_SIZE,
VIEWPORT_GRID_VISUAL_DIVISIONS,
0xcf8354,
0x4e596b
),
xy: new GridHelper(
VIEWPORT_GRID_VISUAL_SIZE,
VIEWPORT_GRID_VISUAL_DIVISIONS,
0xcf8354,
0x4e596b
),
yz: new GridHelper(
VIEWPORT_GRID_VISUAL_SIZE,
VIEWPORT_GRID_VISUAL_DIVISIONS,
0xcf8354,
0x4e596b
)
};
private readonly ambientLight = new AmbientLight();
private readonly sunLight = new DirectionalLight();
private readonly moonLight = new DirectionalLight();
private readonly localLightGroup = new Group();
private readonly lightVolumeGroup = new Group();
private readonly brushGroup = new Group();
private readonly terrainGroup = new Group();
private readonly terrainBrushPreviewGroup = new Group();
private readonly terrainBrushPreviewLine = new Line(
new BufferGeometry(),
new LineBasicMaterial({
color: TERRAIN_BRUSH_PREVIEW_RAISE_COLOR,
depthTest: false
})
);
private readonly terrainBrushPreviewCenter = new Mesh(
new SphereGeometry(1, 12, 12),
new MeshBasicMaterial({
color: TERRAIN_BRUSH_PREVIEW_RAISE_COLOR,
depthTest: false
})
);
private readonly pathGroup = new Group();
private readonly entityGroup = new Group();
private readonly modelGroup = new Group();
private readonly waterReflectionCamera = new PerspectiveCamera();
private readonly raycaster = new Raycaster();
private readonly pointer = new Vector2();
private readonly boxCreateIntersection = new Vector3();
private readonly boxCreatePlane = new Plane(new Vector3(0, 1, 0), 0);
private readonly transformPlane = new Plane(new Vector3(0, 1, 0), 0);
private readonly transformIntersection = new Vector3();
private readonly transformGizmoGroup = new Group();
private readonly brushRenderObjects = new Map<string, BrushRenderObjects>();
private readonly terrainRenderObjects = new Map<
string,
TerrainRenderObjects
>();
private readonly pathRenderObjects = new Map<string, PathRenderObjects>();
private readonly entityRenderObjects = new Map<string, EntityRenderObjects>();
private readonly localLightRenderObjects = new Map<
string,
LocalLightRenderObjects
>();
private readonly lightVolumeRenderObjects = new Map<
string,
LightVolumeRenderObjects
>();
private readonly modelRenderObjects = new Map<string, Group>();
private readonly materialTextureCache = new Map<
string,
CachedMaterialTexture
>();
private readonly materialTextureLoader = new TextureLoader();
private readonly environmentBlendCache: QuantizedEnvironmentBlendCache;
private readonly shaderSkyEnvironmentBlendCache: QuantizedEnvironmentBlendCache;
private readonly shaderSkyEnvironmentCache: PrecomputedShaderSkyEnvironmentCache;
private currentDocument: SceneDocument | null = null;
private currentWorld: WorldSettings | null = null;
private currentSimulationScene: RuntimeSceneDefinition | null = null;
private currentSimulationClock: RuntimeClockState | null = null;
private currentCelestialShadowCaster: "sun" | "moon" | null = null;
private currentAdvancedRenderingSettings: AdvancedRenderingSettings | null =
null;
private advancedRenderingComposer: EffectComposer | null = null;
private currentSelection: EditorSelection = {
kind: "none"
};
private currentActiveSelectionId: string | null = null;
private hoveredSelection: EditorSelection = {
kind: "none"
};
private whiteboxSelectionMode: WhiteboxSelectionMode = "object";
private whiteboxSnapEnabled = true;
private whiteboxSnapStep = DEFAULT_GRID_SIZE;
private viewportGridVisible = true;
private projectAssets: Record<string, ProjectAssetRecord> = {};
private loadedModelAssets: Record<string, LoadedModelAsset> = {};
private loadedImageAssets: Record<string, LoadedImageAsset> = {};
private viewportSceneBounds: {
min: Vec3;
max: Vec3;
} | null = null;
private volumeTime = 0;
private previousFrameTime = 0;
private readonly volumeAnimatedUniforms: Array<{ value: number }> = [];
private readonly viewportWaterSurfaceBindings: ViewportWaterSurfaceBinding[] =
[];
private preservedViewportWaterReflectionTargets: Map<
string,
WebGLRenderTarget | null
> | null = null;
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
})
);
private resizeObserver: ResizeObserver | null = null;
private animationFrame = 0;
private renderEnabled = false;
private container: HTMLElement | null = null;
private brushSelectionChangeHandler:
| ((selection: EditorSelection) => void)
| null = null;
private whiteboxHoverLabelChangeHandler:
| ((label: string | null) => void)
| null = null;
private creationPreviewChangeHandler:
| ((toolPreview: ViewportToolPreview) => void)
| null = null;
private creationCommitHandler:
| ((toolPreview: CreationViewportToolPreview) => boolean)
| null = null;
private cameraStateChangeHandler:
| ((cameraState: ViewportPanelCameraState) => void)
| null = null;
private transformSessionChangeHandler:
| ((transformSession: TransformSessionState) => void)
| null = null;
private transformCommitHandler:
| ((transformSession: ActiveTransformSession) => void)
| null = null;
private transformCancelHandler: (() => void) | null = null;
private terrainBrushCommitHandler:
| ((commit: TerrainBrushStrokeCommit) => boolean)
| null = null;
private toolMode: ToolMode = "select";
private viewMode: ViewportViewMode = "perspective";
private displayMode: ViewportDisplayMode = "normal";
private panelId: ViewportPanelId = "topLeft";
private creationPreview: CreationViewportToolPreview | null = null;
private currentTerrainBrushState: ArmedTerrainBrushState | null = null;
private terrainBrushHover: TerrainBrushHit | null = null;
private activeTerrainBrushStroke: ActiveTerrainBrushStroke | null = null;
private creationPreviewTargetKey: string | null = null;
private creationPreviewObject: Group | null = null;
private currentTransformSession: TransformSessionState =
createInactiveTransformSession();
private activeCameraDragPointerId: number | null = null;
private lastCameraDragClientPosition: { x: number; y: number } | null = null;
private activeTransformDrag: {
pointerId: number;
sessionId: string;
axisConstraint: TransformAxis | null;
axisConstraintSpace: TransformAxisSpace;
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;
// 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;
constructor() {
enableCameraRendererRenderCategories(this.perspectiveCamera);
enableCameraRendererRenderCategories(this.orthographicCamera);
enableCameraRendererRenderCategories(this.waterReflectionCamera);
this.raycaster.layers.mask = ALL_RENDER_LAYER_MASK;
enableObjectForAllRendererRenderCategories(this.ambientLight);
enableObjectForAllRendererRenderCategories(this.sunLight);
enableObjectForAllRendererRenderCategories(this.moonLight);
applyRendererRenderCategory(this.axesHelper, "overlay");
for (const gridHelper of Object.values(this.gridHelpers)) {
applyRendererRenderCategory(gridHelper, "overlay");
}
applyRendererRenderCategory(this.boxCreatePreviewMesh, "overlay");
applyRendererRenderCategory(this.boxCreatePreviewEdges, "overlay");
this.perspectiveCamera.position.set(10, 9, 10);
this.perspectiveCamera.lookAt(this.cameraTarget);
this.updatePerspectiveCameraSphericalFromPose();
this.updateOrthographicCameraFrustum();
this.gridHelpers.xy.rotation.x = Math.PI * 0.5;
this.gridHelpers.yz.rotation.z = Math.PI * 0.5;
for (const gridHelper of Object.values(this.gridHelpers)) {
const gridMaterial = gridHelper.material as LineBasicMaterial;
const centerLineMaterial = (
gridHelper.children[0] as
| LineSegments<BufferGeometry, LineBasicMaterial>
| undefined
)?.material;
gridMaterial.transparent = true;
gridMaterial.opacity = 0.48;
if (centerLineMaterial !== undefined) {
centerLineMaterial.transparent = true;
centerLineMaterial.opacity = 0.8;
}
}
this.scene.add(this.gridHelpers.xz);
this.scene.add(this.gridHelpers.xy);
this.scene.add(this.gridHelpers.yz);
this.scene.add(this.axesHelper);
this.scene.add(this.ambientLight);
this.scene.add(this.sunLight);
this.scene.add(this.sunLight.target);
this.scene.add(this.moonLight);
this.scene.add(this.moonLight.target);
this.scene.add(this.localLightGroup);
this.scene.add(this.lightVolumeGroup);
this.scene.add(this.brushGroup);
this.scene.add(this.terrainGroup);
this.terrainBrushPreviewGroup.visible = false;
this.terrainBrushPreviewLine.frustumCulled = false;
this.terrainBrushPreviewCenter.frustumCulled = false;
this.terrainBrushPreviewCenter.renderOrder = 2;
this.terrainBrushPreviewGroup.add(this.terrainBrushPreviewLine);
this.terrainBrushPreviewGroup.add(this.terrainBrushPreviewCenter);
applyRendererRenderCategory(this.terrainBrushPreviewGroup, "overlay");
this.scene.add(this.terrainBrushPreviewGroup);
this.scene.add(this.pathGroup);
this.scene.add(this.entityGroup);
this.scene.add(this.modelGroup);
this.transformGizmoGroup.visible = false;
applyRendererRenderCategory(this.transformGizmoGroup, "overlay");
this.scene.add(this.transformGizmoGroup);
this.boxCreatePreviewMesh.visible = false;
this.boxCreatePreviewEdges.visible = false;
this.scene.add(this.boxCreatePreviewMesh);
this.scene.add(this.boxCreatePreviewEdges);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.setClearAlpha(0);
this.environmentBlendCache = createRendererQuantizedEnvironmentBlendCache(
this.renderer,
{
onTextureReady: () => {
this.applyWorld();
}
}
);
this.shaderSkyEnvironmentBlendCache =
createRendererQuantizedPmremBlendCache(this.renderer, {
onTextureReady: () => {
this.applyWorld();
}
});
this.shaderSkyEnvironmentCache =
createRendererPrecomputedShaderSkyEnvironmentCache(
this.renderer,
this.worldBackgroundRenderer,
{
phaseBlendTextureResolver: this.shaderSkyEnvironmentBlendCache,
captureSize: 32
}
);
this.moonLight.visible = false;
this.moonLight.intensity = 0;
this.applyViewModePose();
}
setPanelId(panelId: ViewportPanelId) {
this.panelId = panelId;
}
mount(container: HTMLElement) {
this.container = container;
this.renderer.domElement.tabIndex = -1;
container.appendChild(this.renderer.domElement);
this.renderer.domElement.addEventListener(
"pointerdown",
this.handlePointerDown
);
this.renderer.domElement.addEventListener(
"pointermove",
this.handlePointerMove
);
this.renderer.domElement.addEventListener(
"pointerup",
this.handlePointerUp
);
this.renderer.domElement.addEventListener(
"pointercancel",
this.handlePointerUp
);
this.renderer.domElement.addEventListener(
"pointerleave",
this.handlePointerLeave
);
this.renderer.domElement.addEventListener("wheel", this.handleWheel, {
passive: false
});
this.renderer.domElement.addEventListener("auxclick", this.handleAuxClick);
this.renderer.domElement.addEventListener(
"contextmenu",
this.handleContextMenu
);
window.addEventListener("pointermove", this.handleWindowPointerMove);
this.resize();
this.resizeObserver = new ResizeObserver(() => {
this.resize();
});
this.resizeObserver.observe(container);
if (this.renderEnabled) {
this.render();
}
}
setRenderEnabled(enabled: boolean) {
if (this.renderEnabled === enabled) {
return;
}
this.renderEnabled = enabled;
if (!enabled) {
if (this.animationFrame !== 0) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = 0;
}
this.previousFrameTime = 0;
return;
}
if (this.container !== null && this.animationFrame === 0) {
this.render();
}
}
updateWorld(world: WorldSettings) {
this.currentWorld = world;
this.applyWorld();
}
updateSimulation(
runtimeScene: RuntimeSceneDefinition | null,
clock: RuntimeClockState | null
) {
this.currentSimulationScene = runtimeScene;
this.currentSimulationClock = clock;
this.applyWorld();
if (this.currentDocument === null) {
return;
}
this.rebuildLocalLights(this.currentDocument);
this.rebuildLightVolumes(this.currentDocument);
this.rebuildEntityMarkers(this.currentDocument, this.currentSelection);
this.rebuildModelInstances(this.currentDocument, this.currentSelection);
}
updateSelection(selection: EditorSelection, activeSelectionId: string | null) {
const selectionChanged = !areEditorSelectionsEqual(
this.currentSelection,
selection
);
const activeSelectionChanged =
this.currentActiveSelectionId !== activeSelectionId;
this.currentSelection = selection;
this.currentActiveSelectionId = activeSelectionId;
if (!selectionChanged && !activeSelectionChanged) {
return;
}
this.activeTerrainBrushStroke = null;
this.setHoveredSelection({
kind: "none"
});
this.refreshSelectionPresentation();
}
updateDocument(document: SceneDocument) {
this.activeTerrainBrushStroke = null;
this.currentDocument = document;
this.viewportSceneBounds = resolveViewportDocumentBounds(document);
this.setHoveredSelection({
kind: "none"
});
this.rebuildLocalLights(document);
this.rebuildLightVolumes(document);
this.rebuildBrushMeshes(document, this.currentSelection);
this.rebuildTerrains(
document,
this.currentSelection,
this.currentActiveSelectionId
);
this.rebuildPaths(document, this.currentSelection);
this.rebuildEntityMarkers(document, this.currentSelection);
this.rebuildModelInstances(document, this.currentSelection);
this.applyTransformPreview();
this.syncTransformGizmo();
this.syncTerrainBrushPreview();
}
updateAssets(
projectAssets: Record<string, ProjectAssetRecord>,
loadedModelAssets: Record<string, LoadedModelAsset>,
loadedImageAssets: Record<string, LoadedImageAsset>
) {
this.projectAssets = projectAssets;
this.loadedModelAssets = loadedModelAssets;
this.loadedImageAssets = loadedImageAssets;
this.environmentBlendCache.clear();
if (this.currentWorld !== null) {
this.applyWorld();
}
if (this.currentDocument !== null) {
this.rebuildEntityMarkers(this.currentDocument, this.currentSelection);
this.rebuildModelInstances(this.currentDocument, this.currentSelection);
this.applyTransformPreview();
this.syncTransformGizmo();
}
if (
this.creationPreview?.target.kind === "model-instance" ||
(this.creationPreview?.target.kind === "entity" &&
this.creationPreview.target.entityKind === "npc")
) {
const currentPreview = this.creationPreview;
this.creationPreview = null;
this.clearCreationPreviewObject();
this.syncCreationPreview(currentPreview);
}
}
setBrushSelectionChangeHandler(
handler: ((selection: EditorSelection) => void) | null
) {
this.brushSelectionChangeHandler = handler;
}
setWhiteboxHoverLabelChangeHandler(
handler: ((label: string | null) => void) | null
) {
this.whiteboxHoverLabelChangeHandler = handler;
this.emitWhiteboxHoverLabelChange();
}
setCreationPreviewChangeHandler(
handler: ((toolPreview: ViewportToolPreview) => void) | null
) {
this.creationPreviewChangeHandler = handler;
}
setCreationCommitHandler(
handler: ((toolPreview: CreationViewportToolPreview) => boolean) | null
) {
this.creationCommitHandler = handler;
}
setCameraStateChangeHandler(
handler: ((cameraState: ViewportPanelCameraState) => void) | null
) {
this.cameraStateChangeHandler = handler;
}
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;
}
setTerrainBrushCommitHandler(
handler: ((commit: TerrainBrushStrokeCommit) => boolean) | null
) {
this.terrainBrushCommitHandler = handler;
}
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();
}
setCreationPreview(toolPreview: CreationViewportToolPreview | null) {
this.syncCreationPreview(toolPreview);
}
setWhiteboxSnapSettings(enabled: boolean, step: number) {
this.whiteboxSnapEnabled = enabled;
this.whiteboxSnapStep = step;
if (this.creationPreview !== null) {
this.syncCreationPreview(this.creationPreview);
}
this.applyTransformPreview();
}
setGridVisible(visible: boolean) {
if (this.viewportGridVisible === visible) {
return;
}
this.viewportGridVisible = visible;
this.updateGridPresentation();
}
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();
}
setTransformSession(transformSession: TransformSessionState) {
const previousTransformSession = this.currentTransformSession;
let rebuiltPreviewFromPointer = false;
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
};
}
if (
previousTransformSession.kind === "active" &&
this.currentTransformSession.kind === "active" &&
previousTransformSession.id === this.currentTransformSession.id &&
(previousTransformSession.surfaceSnapEnabled !==
this.currentTransformSession.surfaceSnapEnabled ||
previousTransformSession.axisConstraint !==
this.currentTransformSession.axisConstraint ||
previousTransformSession.axisConstraintSpace !==
this.currentTransformSession.axisConstraintSpace) &&
this.currentTransformSession.sourcePanelId === this.panelId &&
this.currentTransformSession.source !== "gizmo" &&
this.keyboardTransformPointerOrigin !== null &&
this.keyboardTransformPointerOrigin.sessionId ===
this.currentTransformSession.id &&
this.lastCanvasPointerPosition !== null
) {
this.currentTransformSession = this.buildTransformPreviewFromPointer(
this.currentTransformSession,
{
x: this.keyboardTransformPointerOrigin.clientX,
y: this.keyboardTransformPointerOrigin.clientY
},
this.lastCanvasPointerPosition,
this.currentTransformSession.axisConstraint,
this.currentTransformSession.axisConstraintSpace
);
rebuiltPreviewFromPointer = true;
}
this.applyTransformPreview();
this.syncTransformGizmo();
if (rebuiltPreviewFromPointer) {
this.transformSessionChangeHandler?.(this.currentTransformSession);
}
}
setToolMode(toolMode: ToolMode) {
this.toolMode = toolMode;
this.lastClickPointer = null;
this.lastClickSelectionKey = null;
this.setHoveredSelection({
kind: "none"
});
if (toolMode !== "select") {
this.cancelActiveTerrainBrushStroke(false);
this.setTerrainBrushHover(null);
} else {
this.syncTerrainBrushPreview();
}
if (toolMode !== "create") {
this.syncCreationPreview(null);
}
}
setTerrainBrushState(terrainBrushState: ArmedTerrainBrushState | null) {
const terrainChanged =
this.currentTerrainBrushState?.terrainId !== terrainBrushState?.terrainId;
const toolChanged =
this.currentTerrainBrushState?.tool !== terrainBrushState?.tool;
const layerChanged =
this.currentTerrainBrushState?.tool === "paint" &&
terrainBrushState?.tool === "paint"
? this.currentTerrainBrushState.layerIndex !==
terrainBrushState.layerIndex
: this.currentTerrainBrushState?.tool === "paint" ||
terrainBrushState?.tool === "paint";
this.currentTerrainBrushState = terrainBrushState;
if (
terrainChanged ||
toolChanged ||
layerChanged ||
terrainBrushState === null
) {
this.cancelActiveTerrainBrushStroke(false);
}
if (terrainBrushState === null || this.toolMode !== "select") {
this.setTerrainBrushHover(null);
return;
}
if (
this.terrainBrushHover !== null &&
this.terrainBrushHover.terrainId !== terrainBrushState.terrainId
) {
this.terrainBrushHover = null;
}
if (this.lastCanvasPointerPosition !== null) {
this.setTerrainBrushHover(
this.getTerrainBrushHitAtClientPosition(
this.lastCanvasPointerPosition.x,
this.lastCanvasPointerPosition.y
)
);
} else {
this.syncTerrainBrushPreview();
}
}
setViewMode(viewMode: ViewportViewMode) {
if (this.viewMode === viewMode) {
return;
}
this.viewMode = viewMode;
this.lastClickPointer = null;
this.lastClickSelectionKey = null;
this.setHoveredSelection({
kind: "none"
});
this.applyViewModePose();
this.syncTerrainBrushPreview();
if (this.currentAdvancedRenderingSettings !== null) {
this.syncAdvancedRenderingComposer(this.currentAdvancedRenderingSettings);
}
}
setDisplayMode(displayMode: ViewportDisplayMode) {
if (this.displayMode === displayMode) {
return;
}
this.displayMode = displayMode;
this.applyWorld();
if (this.currentDocument !== null) {
this.updateDocument(this.currentDocument);
}
}
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
);
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();
this.emitCameraStateChange();
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();
this.emitCameraStateChange();
}
dispose() {
if (this.animationFrame !== 0) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = 0;
}
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.renderer.domElement.removeEventListener(
"pointerdown",
this.handlePointerDown
);
this.renderer.domElement.removeEventListener(
"pointermove",
this.handlePointerMove
);
this.renderer.domElement.removeEventListener(
"pointerup",
this.handlePointerUp
);
this.renderer.domElement.removeEventListener(
"pointercancel",
this.handlePointerUp
);
this.renderer.domElement.removeEventListener(
"pointerleave",
this.handlePointerLeave
);
this.renderer.domElement.removeEventListener("wheel", this.handleWheel);
this.renderer.domElement.removeEventListener(
"auxclick",
this.handleAuxClick
);
this.renderer.domElement.removeEventListener(
"contextmenu",
this.handleContextMenu
);
window.removeEventListener("pointermove", this.handleWindowPointerMove);
this.clearLocalLights();
this.clearLightVolumes();
this.clearBrushMeshes();
this.clearTerrains();
this.clearPaths();
this.clearEntityMarkers();
this.creationPreviewChangeHandler = null;
this.creationCommitHandler = null;
this.cameraStateChangeHandler = null;
this.transformSessionChangeHandler = null;
this.transformCommitHandler = null;
this.transformCancelHandler = null;
this.currentTransformSession = createInactiveTransformSession();
this.clearTransformGizmo();
this.activeTransformDrag = null;
this.keyboardTransformPointerOrigin = null;
this.syncCreationPreview(null);
this.advancedRenderingComposer?.dispose();
this.advancedRenderingComposer = null;
this.currentAdvancedRenderingSettings = null;
this.renderer.autoClear = true;
for (const cachedTexture of this.materialTextureCache.values()) {
disposeStarterMaterialTextureSet(cachedTexture.textureSet);
}
this.materialTextureCache.clear();
this.boxCreatePreviewMesh.geometry.dispose();
this.boxCreatePreviewMesh.material.dispose();
this.boxCreatePreviewEdges.geometry.dispose();
this.boxCreatePreviewEdges.material.dispose();
this.terrainBrushPreviewLine.geometry.dispose();
this.terrainBrushPreviewLine.material.dispose();
this.terrainBrushPreviewCenter.geometry.dispose();
this.terrainBrushPreviewCenter.material.dispose();
this.environmentBlendCache.dispose();
this.shaderSkyEnvironmentBlendCache.dispose();
this.shaderSkyEnvironmentCache.dispose();
this.worldBackgroundRenderer.dispose();
this.renderer.forceContextLoss();
this.renderer.dispose();
if (
this.container !== null &&
this.container.contains(this.renderer.domElement)
) {
this.container.removeChild(this.renderer.domElement);
}
this.container = null;
}
private getActiveCamera() {
return this.viewMode === "perspective"
? this.perspectiveCamera
: this.orthographicCamera;
}
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());
}
private updatePerspectiveCameraSphericalFromPose() {
this.cameraOffset
.copy(this.perspectiveCamera.position)
.sub(this.cameraTarget);
this.cameraSpherical.setFromVector3(this.cameraOffset);
this.cameraSpherical.radius = Math.min(
MAX_CAMERA_DISTANCE,
Math.max(MIN_CAMERA_DISTANCE, this.cameraSpherical.radius)
);
this.cameraSpherical.phi = Math.min(
MAX_POLAR_ANGLE,
Math.max(MIN_POLAR_ANGLE, this.cameraSpherical.phi)
);
this.cameraSpherical.makeSafe();
}
private 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() {
this.cameraSpherical.radius = Math.min(
MAX_CAMERA_DISTANCE,
Math.max(MIN_CAMERA_DISTANCE, this.cameraSpherical.radius)
);
this.cameraSpherical.phi = Math.min(
MAX_POLAR_ANGLE,
Math.max(MIN_POLAR_ANGLE, this.cameraSpherical.phi)
);
this.cameraSpherical.makeSafe();
this.cameraOffset.setFromSpherical(this.cameraSpherical);
this.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() {
this.updateGridPresentation();
const definition = getViewportViewModeDefinition(this.viewMode);
if (definition.cameraType === "perspective") {
this.applyPerspectiveCameraPose();
return;
}
this.updateOrthographicCameraFrustum();
this.applyOrthographicCameraPose();
}
private updateGridPresentation() {
const definition = getViewportViewModeDefinition(this.viewMode);
const visibleGridPlane = this.viewportGridVisible
? definition.gridPlane
: null;
this.gridHelpers.xz.visible = visibleGridPlane === "xz";
this.gridHelpers.xy.visible = visibleGridPlane === "xy";
this.gridHelpers.yz.visible = visibleGridPlane === "yz";
this.updateGridPositioning();
}
private updateGridPositioning() {
const align = (value: number) =>
Math.round(value / DEFAULT_GRID_SIZE) * DEFAULT_GRID_SIZE;
this.gridHelpers.xz.position.set(
align(this.cameraTarget.x),
0,
align(this.cameraTarget.z)
);
this.gridHelpers.xy.position.set(
align(this.cameraTarget.x),
align(this.cameraTarget.y),
0
);
this.gridHelpers.yz.position.set(
0,
align(this.cameraTarget.y),
align(this.cameraTarget.z)
);
}
private createWireframeDisplayMaterial(
material: Material
): MeshBasicMaterial {
const source = material as Material & {
color?: { getHex(): number };
transparent?: boolean;
opacity?: number;
};
return new MeshBasicMaterial({
color: source.color?.getHex() ?? FALLBACK_FACE_COLOR,
wireframe: true,
transparent: source.transparent === true || (source.opacity ?? 1) < 1,
opacity: source.opacity ?? 1,
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)) {
const originalMaterials = maybeMesh.material;
maybeMesh.material = originalMaterials.map((material) =>
this.createWireframeDisplayMaterial(material)
);
for (const material of originalMaterials) {
material.dispose();
}
return;
}
const originalMaterial = maybeMesh.material;
maybeMesh.material =
this.createWireframeDisplayMaterial(originalMaterial);
originalMaterial.dispose();
});
}
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);
default:
return this.boxCreatePlane.set(new Vector3(0, 1, 0), 0);
}
}
private applyWorld() {
if (this.currentWorld === null) {
return;
}
const world = this.currentSimulationScene?.world ?? this.currentWorld;
const resolvedTime =
this.currentSimulationScene !== null &&
this.currentSimulationClock !== null
? resolveRuntimeTimeState(
this.currentSimulationScene.time,
this.currentSimulationClock
)
: null;
const resolvedWorld =
this.currentSimulationScene !== null &&
this.currentSimulationClock !== null
? resolveRuntimeDayNightWorldState(
world,
this.currentSimulationScene.time,
this.currentSimulationClock,
resolvedTime
)
: null;
const rendererSettings =
this.displayMode !== "normal"
? {
...cloneAdvancedRenderingSettings(world.advancedRendering),
enabled: false
}
: world.advancedRendering;
const displayedAmbientLight =
resolvedWorld?.ambientLight ?? world.ambientLight;
const displayedSunLight = resolvedWorld?.sunLight ?? world.sunLight;
const displayedMoonLight = resolvedWorld?.moonLight ?? null;
const displayedBackground = resolvedWorld?.background ?? world.background;
const backgroundTexture =
displayedBackground.mode === "image" &&
displayedBackground.assetId.trim().length > 0
? (this.loadedImageAssets[displayedBackground.assetId]?.texture ?? null)
: null;
const backgroundOverlayState =
resolvedWorld?.nightBackgroundOverlay === undefined ||
resolvedWorld?.nightBackgroundOverlay === null
? null
: {
texture:
this.loadedImageAssets[
resolvedWorld.nightBackgroundOverlay.assetId
]?.texture ?? null,
opacity: resolvedWorld.nightBackgroundOverlay.opacity,
environmentIntensity:
resolvedWorld.nightBackgroundOverlay.environmentIntensity
};
this.ambientLight.color.set(displayedAmbientLight.colorHex);
this.ambientLight.intensity = displayedAmbientLight.intensity;
this.currentCelestialShadowCaster =
resolveDominantCelestialShadowCaster(
displayedSunLight,
displayedMoonLight
)?.key ?? null;
this.sunLight.color.set(displayedSunLight.colorHex);
this.sunLight.intensity = displayedSunLight.intensity;
this.sunLight.position
.set(
displayedSunLight.direction.x,
displayedSunLight.direction.y,
displayedSunLight.direction.z
)
.normalize()
.multiplyScalar(18);
this.sunLight.target.position.set(0, 0, 0);
this.moonLight.visible = false;
this.moonLight.intensity = 0;
this.moonLight.target.position.set(0, 0, 0);
if (displayedMoonLight !== null) {
this.moonLight.color.set(displayedMoonLight.colorHex);
this.moonLight.intensity = displayedMoonLight.intensity;
this.moonLight.position
.set(
displayedMoonLight.direction.x,
displayedMoonLight.direction.y,
displayedMoonLight.direction.z
)
.normalize()
.multiplyScalar(16);
this.moonLight.target.position.set(0, 0, 0);
this.moonLight.visible =
this.displayMode !== "wireframe" && displayedMoonLight.intensity > 1e-4;
}
this.ambientLight.visible = this.displayMode !== "wireframe";
this.sunLight.visible =
this.displayMode !== "wireframe" && displayedSunLight.intensity > 1e-4;
this.localLightGroup.visible = this.displayMode !== "wireframe";
this.lightVolumeGroup.visible = this.displayMode !== "wireframe";
if (this.displayMode !== "normal") {
this.scene.background = null;
this.scene.environment = null;
this.scene.environmentIntensity = 1;
} else {
const celestialBodiesState = resolveWorldCelestialBodiesState(
world.showCelestialBodies,
displayedSunLight,
displayedMoonLight
);
const shaderSkyResolvedWorld = resolvedWorld ?? {
ambientLight: {
...world.ambientLight
},
sunLight: {
...world.sunLight,
direction: {
...world.sunLight.direction
}
},
moonLight: null,
background: world.background,
nightBackgroundOverlay: null,
daylightFactor: 1
};
const shaderSkyState =
world.background.mode === "shader"
? resolveWorldShaderSkyRenderState(
world,
shaderSkyResolvedWorld,
resolvedTime,
this.currentSimulationScene?.time ?? null
)
: null;
if (world.background.mode === "shader") {
this.shaderSkyEnvironmentCache.syncPhaseTextures(
resolveWorldShaderSkyEnvironmentPhaseStates(
world,
this.currentSimulationScene?.time ?? null
)
);
}
this.worldBackgroundRenderer.update(
displayedBackground,
backgroundTexture,
backgroundOverlayState,
celestialBodiesState,
shaderSkyState
);
const environmentState = resolveWorldEnvironmentState(
displayedBackground,
backgroundTexture,
backgroundOverlayState,
this.environmentBlendCache,
shaderSkyState,
this.shaderSkyEnvironmentCache
);
this.scene.background = null;
this.scene.environment = environmentState.texture;
this.scene.environmentIntensity = environmentState.intensity;
}
configureAdvancedRenderingRenderer(this.renderer, rendererSettings);
this.syncAdvancedRenderingComposer(rendererSettings);
this.applyShadowState();
}
private syncAdvancedRenderingComposer(settings: AdvancedRenderingSettings) {
const shouldUseComposer =
settings.enabled &&
this.displayMode === "normal" &&
this.viewMode === "perspective";
const settingsChanged =
this.currentAdvancedRenderingSettings === null ||
!areAdvancedRenderingSettingsEqual(
this.currentAdvancedRenderingSettings,
settings
);
if (!shouldUseComposer) {
if (this.advancedRenderingComposer !== null) {
this.advancedRenderingComposer.dispose();
this.advancedRenderingComposer = null;
}
this.currentAdvancedRenderingSettings = settings.enabled
? cloneAdvancedRenderingSettings(settings)
: null;
this.renderer.autoClear = true;
return;
}
if (this.advancedRenderingComposer !== null && !settingsChanged) {
return;
}
if (this.advancedRenderingComposer !== null) {
this.advancedRenderingComposer.dispose();
}
this.advancedRenderingComposer = createAdvancedRenderingComposer(
this.renderer,
this.scene,
this.perspectiveCamera,
settings,
this.worldBackgroundRenderer.scene
);
this.currentAdvancedRenderingSettings =
cloneAdvancedRenderingSettings(settings);
this.renderer.autoClear = false;
}
private applyShadowState() {
if (this.currentWorld === null) {
return;
}
const world = this.currentSimulationScene?.world ?? this.currentWorld;
const advancedRendering = world.advancedRendering;
const shadowsEnabled =
advancedRendering.enabled &&
advancedRendering.shadows.enabled &&
this.displayMode === "normal";
for (const renderObjects of this.brushRenderObjects.values()) {
applyAdvancedRenderingRenderableShadowFlags(
renderObjects.mesh,
shadowsEnabled
);
}
for (const renderObjects of this.terrainRenderObjects.values()) {
applyAdvancedRenderingRenderableShadowFlags(
renderObjects.mesh,
shadowsEnabled
);
}
for (const renderObjects of this.entityRenderObjects.values()) {
applyAdvancedRenderingRenderableShadowFlags(renderObjects.group, false);
}
for (const renderGroup of this.modelRenderObjects.values()) {
applyAdvancedRenderingRenderableShadowFlags(renderGroup, shadowsEnabled);
}
this.syncCelestialShadowState();
}
private resolveViewportShadowFocusTarget() {
if (this.viewMode === "perspective") {
return {
center: {
x: this.cameraTarget.x,
y: this.cameraTarget.y,
z: this.cameraTarget.z
},
radius: Math.max(
4,
this.perspectiveCamera.position.distanceTo(this.cameraTarget) * 0.25
)
};
}
const halfWidth =
(Math.abs(this.orthographicCamera.right - this.orthographicCamera.left) /
Math.max(this.orthographicCamera.zoom, 0.0001)) *
0.5;
const halfHeight =
(Math.abs(this.orthographicCamera.top - this.orthographicCamera.bottom) /
Math.max(this.orthographicCamera.zoom, 0.0001)) *
0.5;
return {
center: {
x: this.cameraTarget.x,
y: this.cameraTarget.y,
z: this.cameraTarget.z
},
radius: Math.max(3, Math.hypot(halfWidth, halfHeight) * 0.65)
};
}
private syncCelestialShadowState() {
if (this.currentWorld === null) {
return;
}
const world = this.currentSimulationScene?.world ?? this.currentWorld;
const advancedRendering = world.advancedRendering;
const shadowsEnabled =
advancedRendering.enabled &&
advancedRendering.shadows.enabled &&
this.displayMode === "normal";
for (const renderObjects of this.localLightRenderObjects.values()) {
configureAdvancedRenderingShadowLight(
renderObjects.light,
advancedRendering,
false
);
}
for (const renderObjects of this.lightVolumeRenderObjects.values()) {
for (const light of renderObjects.lights) {
configureAdvancedRenderingShadowLight(light, advancedRendering, false);
}
}
if (!shadowsEnabled || this.currentCelestialShadowCaster === null) {
configureAdvancedRenderingShadowLight(
this.sunLight,
advancedRendering,
false
);
configureAdvancedRenderingShadowLight(
this.moonLight,
advancedRendering,
false
);
return;
}
const activeCamera = this.getActiveCamera();
const activeLight =
this.currentCelestialShadowCaster === "moon"
? this.moonLight
: this.sunLight;
const lightDirection = activeLight.position
.clone()
.sub(activeLight.target.position)
.normalize();
const fit = fitCelestialDirectionalShadow({
activeCamera,
focusTarget: this.resolveViewportShadowFocusTarget(),
lightDirection: {
x: lightDirection.x,
y: lightDirection.y,
z: lightDirection.z
},
mapSize: advancedRendering.shadows.mapSize,
sceneBounds: this.viewportSceneBounds
});
if (fit === null) {
configureAdvancedRenderingShadowLight(
this.sunLight,
advancedRendering,
false
);
configureAdvancedRenderingShadowLight(
this.moonLight,
advancedRendering,
false
);
return;
}
configureAdvancedRenderingShadowLight(
this.sunLight,
advancedRendering,
this.currentCelestialShadowCaster === "sun",
this.currentCelestialShadowCaster === "sun" ? fit.normalBias : 0
);
configureAdvancedRenderingShadowLight(
this.moonLight,
advancedRendering,
this.currentCelestialShadowCaster === "moon",
this.currentCelestialShadowCaster === "moon" ? fit.normalBias : 0
);
activeLight.position.set(
fit.lightPosition.x,
fit.lightPosition.y,
fit.lightPosition.z
);
activeLight.target.position.set(
fit.targetPosition.x,
fit.targetPosition.y,
fit.targetPosition.z
);
activeLight.updateMatrixWorld();
activeLight.target.updateMatrixWorld();
const shadowCamera = activeLight.shadow.camera as OrthographicCamera;
shadowCamera.left = fit.cameraBounds.left;
shadowCamera.right = fit.cameraBounds.right;
shadowCamera.top = fit.cameraBounds.top;
shadowCamera.bottom = fit.cameraBounds.bottom;
shadowCamera.near = fit.cameraBounds.near;
shadowCamera.far = fit.cameraBounds.far;
shadowCamera.updateProjectionMatrix();
activeLight.shadow.needsUpdate = true;
}
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 createRotationQuaternion(rotationDegrees: Vec3): Quaternion {
return new Quaternion().setFromEuler(
new Euler(
(rotationDegrees.x * Math.PI) / 180,
(rotationDegrees.y * Math.PI) / 180,
(rotationDegrees.z * Math.PI) / 180,
"XYZ"
)
);
}
private getTransformTargetOrientation(
session: ActiveTransformSession
): Quaternion | null {
const target = session.target;
const preview = session.preview;
switch (target.kind) {
case "brush":
if (preview.kind !== "brush") {
return null;
}
return this.createRotationQuaternion(preview.rotationDegrees);
case "brushes":
if (preview.kind !== "brushes") {
return null;
}
const activeBrushPreview = preview.items.find(
(item) => item.brushId === target.activeBrushId
);
return this.createRotationQuaternion(
activeBrushPreview?.rotationDegrees ?? { x: 0, y: 0, z: 0 }
);
case "modelInstance":
if (preview.kind !== "modelInstance") {
return null;
}
return this.createRotationQuaternion(preview.rotationDegrees);
case "modelInstances":
if (preview.kind !== "modelInstances") {
return null;
}
const activeModelInstancePreview = preview.items.find(
(item) => item.modelInstanceId === target.activeModelInstanceId
);
return this.createRotationQuaternion(
activeModelInstancePreview?.rotationDegrees ?? { x: 0, y: 0, z: 0 }
);
case "pathPoint":
return null;
case "entity":
if (preview.kind !== "entity") {
return null;
}
switch (preview.rotation.kind) {
case "yaw":
return this.createRotationQuaternion({
x: 0,
y: preview.rotation.yawDegrees,
z: 0
});
case "direction":
return new Quaternion().setFromUnitVectors(
new Vector3(0, 1, 0),
new Vector3(
preview.rotation.direction.x,
preview.rotation.direction.y,
preview.rotation.direction.z
).normalize()
);
case "none":
return null;
}
case "entities":
if (preview.kind !== "entities") {
return null;
}
const activeEntityPreview = preview.items.find(
(item) => item.entityId === target.activeEntityId
);
if (activeEntityPreview === undefined) {
return null;
}
switch (activeEntityPreview.rotation.kind) {
case "yaw":
return this.createRotationQuaternion({
x: 0,
y:
activeEntityPreview.rotation.kind === "yaw"
? activeEntityPreview.rotation.yawDegrees
: 0,
z: 0
});
case "direction": {
const rotation = activeEntityPreview.rotation;
if (rotation?.kind !== "direction") {
return null;
}
return new Quaternion().setFromUnitVectors(
new Vector3(0, 1, 0),
new Vector3(
rotation.direction.x,
rotation.direction.y,
rotation.direction.z
).normalize()
);
}
case "none":
case undefined:
return null;
}
case "brushFace":
case "brushEdge":
case "brushVertex":
if (preview.kind !== "brush") {
return null;
}
return this.createRotationQuaternion(preview.rotationDegrees);
}
}
private getConstraintAxisWorldVector(
session: ActiveTransformSession,
axis: TransformAxis,
axisSpace: TransformAxisSpace
): Vector3 {
const worldAxis = this.axisVector(axis);
if (axisSpace !== "local") {
return worldAxis;
}
const orientation = this.getTransformTargetOrientation(session);
if (orientation === null) {
return worldAxis;
}
return worldAxis.applyQuaternion(orientation).normalize();
}
private getQuaternionEulerDegrees(quaternion: Quaternion): Vec3 {
const euler = new Euler().setFromQuaternion(quaternion, "XYZ");
return {
x: this.normalizeDegrees((euler.x * 180) / Math.PI),
y: this.normalizeDegrees((euler.y * 180) / Math.PI),
z: this.normalizeDegrees((euler.z * 180) / Math.PI)
};
}
private resolveObjectScaleConstraintAxis(
session: ActiveTransformSession,
worldAxis: TransformAxis
): TransformAxis {
if (
session.target.kind !== "brush" &&
session.target.kind !== "modelInstance"
) {
return worldAxis;
}
return resolveDominantLocalAxisForWorldAxis(
session.target.initialRotationDegrees,
worldAxis
);
}
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
);
}
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)
);
}
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 {
if (session.target.kind === "brushFace") {
const previewBrush = this.createPreviewBrushForSession(session);
return previewBrush === null
? "y"
: getBrushFaceAxis(previewBrush, session.target.faceId);
}
if (session.target.kind === "brushEdge") {
const previewBrush = this.createPreviewBrushForSession(session);
return previewBrush === null
? "y"
: getBrushEdgeAxis(previewBrush, session.target.edgeId);
}
if (
session.target.kind === "entity" &&
session.target.initialRotation.kind === "yaw"
) {
return "y";
}
return session.axisConstraint ?? "y";
}
private getTransformPivotPosition(session: ActiveTransformSession): Vec3 {
if (session.preview.kind === "brush") {
const previewBrush = this.createPreviewBrushForSession(session);
if (previewBrush !== null) {
if (session.target.kind === "brushFace") {
return getBrushFaceWorldCenter(previewBrush, session.target.faceId);
}
if (session.target.kind === "brushEdge") {
return getBrushEdgeWorldSegment(previewBrush, session.target.edgeId)
.center;
}
if (session.target.kind === "brushVertex") {
return getBrushVertexWorldPosition(
previewBrush,
session.target.vertexId
);
}
}
}
switch (session.preview.kind) {
case "brush":
return session.preview.center;
case "brushes":
return session.preview.pivot;
case "modelInstance":
return session.preview.position;
case "modelInstances":
return session.preview.pivot;
case "pathPoint":
return session.preview.position;
case "entity":
return session.preview.position;
case "entities":
return session.preview.pivot;
}
}
private createPreviewBrushForSession(
session: ActiveTransformSession
): Brush | 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) {
return null;
}
return updateBrush(currentBrush, {
center: {
...session.preview.center
},
rotationDegrees: {
...session.preview.rotationDegrees
},
size: {
...session.preview.size
},
geometry: cloneBrushGeometry(session.preview.geometry)
});
}
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;
}
private markTransformHandleObject<TObject extends Object3D>(
object: TObject
): TObject {
object.renderOrder = GIZMO_RENDER_ORDER;
object.traverse((child) => {
child.renderOrder = GIZMO_RENDER_ORDER;
});
applyRendererRenderCategory(object, "overlay");
return object;
}
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,
depthWrite: false,
depthTest: false
});
}
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);
return this.markTransformHandleObject(group);
}
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);
return this.markTransformHandleObject(group);
}
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);
return this.markTransformHandleObject(group);
}
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;
return this.markTransformHandleObject(mesh);
}
private isBrushDisplayedInViewport(brushId: string): boolean {
const brush = this.currentDocument?.brushes[brushId];
return brush?.enabled === true && brush.visible === true;
}
private isEntityDisplayedInViewport(entityId: string): boolean {
const entity = this.currentDocument?.entities[entityId];
return entity?.enabled === true && entity.visible === true;
}
private isModelInstanceDisplayedInViewport(modelInstanceId: string): boolean {
const modelInstance = this.currentDocument?.modelInstances[modelInstanceId];
return modelInstance?.enabled === true && modelInstance.visible === true;
}
private isPathDisplayedInViewport(pathId: string): boolean {
const path = this.currentDocument?.paths[pathId];
return path?.enabled === true && path.visible === true;
}
private isTransformTargetDisplayedInViewport(
session: ActiveTransformSession
): boolean {
switch (session.target.kind) {
case "brush":
case "brushFace":
case "brushEdge":
case "brushVertex":
return this.isBrushDisplayedInViewport(session.target.brushId);
case "brushes":
return session.target.items.every((item) =>
this.isBrushDisplayedInViewport(item.brushId)
);
case "pathPoint":
return this.isPathDisplayedInViewport(session.target.pathId);
case "entity":
return this.isEntityDisplayedInViewport(session.target.entityId);
case "entities":
return session.target.items.every((item) =>
this.isEntityDisplayedInViewport(item.entityId)
);
case "modelInstance":
return this.isModelInstanceDisplayedInViewport(
session.target.modelInstanceId
);
case "modelInstances":
return session.target.items.every((item) =>
this.isModelInstanceDisplayedInViewport(item.modelInstanceId)
);
}
}
private getDisplayedTransformSession(): ActiveTransformSession | null {
if (this.currentTransformSession.kind === "active") {
return this.isTransformTargetDisplayedInViewport(
this.currentTransformSession
)
? this.currentTransformSession
: null;
}
if (this.toolMode !== "select" || this.currentDocument === null) {
return null;
}
const transformTarget = resolveTransformTarget(
this.currentDocument,
this.currentSelection,
this.whiteboxSelectionMode,
this.currentActiveSelectionId
).target;
if (
transformTarget === null ||
!supportsTransformOperation(transformTarget, "translate")
) {
return null;
}
const selectionSession: ActiveTransformSession = {
kind: "active",
id: "__selection-translate-gizmo__",
source: "gizmo",
sourcePanelId: this.panelId,
operation: "translate",
surfaceSnapEnabled: false,
axisConstraint: null,
axisConstraintSpace: "world",
target: transformTarget,
preview: createTransformPreviewFromTarget(transformTarget)
};
return this.isTransformTargetDisplayedInViewport(selectionSession)
? selectionSession
: null;
}
private syncTransformGizmo() {
this.clearTransformGizmo();
const session = this.getDisplayedTransformSession();
if (session === null) {
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") {
for (const axis of ["x", "y", "z"] as const) {
if (!supportsTransformAxisConstraint(session, axis)) {
continue;
}
this.transformGizmoGroup.add(
this.createRotateHandle(axis, effectiveRotationAxis === axis)
);
}
} else if (
session.operation === "scale" &&
(session.target.kind === "modelInstance" ||
session.target.kind === "brush" ||
session.target.kind === "brushFace" ||
session.target.kind === "brushEdge")
) {
for (const axis of ["x", "y", "z"] as const) {
this.transformGizmoGroup.add(
this.createScaleHandle(axis, session.axisConstraint === axis)
);
}
this.transformGizmoGroup.add(
this.createUniformScaleHandle(session.axisConstraint === null)
);
}
this.transformGizmoGroup.visible =
this.transformGizmoGroup.children.length > 0;
this.updateTransformGizmoPose();
}
private updateTransformGizmoPose() {
const session = this.getDisplayedTransformSession();
if (session === null || !this.transformGizmoGroup.visible) {
return;
}
const pivot = this.getTransformPivotPosition(session);
const pivotVector = new Vector3(pivot.x, pivot.y, pivot.z);
this.transformGizmoGroup.position.copy(pivotVector);
this.transformGizmoGroup.quaternion.identity();
if (
session.axisConstraint !== null &&
session.axisConstraintSpace === "local" &&
supportsLocalTransformAxisConstraint(session, session.axisConstraint)
) {
const orientation = this.getTransformTargetOrientation(session);
if (orientation !== null) {
this.transformGizmoGroup.quaternion.copy(orientation);
}
}
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 getMovementDistanceAlongWorldAxis(
axisVector: Vector3,
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(axisVector.clone().normalize())
.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 getAxisMovementDistance(
axis: TransformAxis,
pivot: Vec3,
origin: { x: number; y: number },
current: { x: number; y: number },
session?: ActiveTransformSession,
axisSpace: TransformAxisSpace = "world"
): number {
const axisVector =
session === undefined
? this.axisVector(axis)
: this.getConstraintAxisWorldVector(session, axis, axisSpace);
return this.getMovementDistanceAlongWorldAxis(
axisVector,
pivot,
origin,
current
);
}
private buildTransformPreviewFromPointer(
session: ActiveTransformSession,
origin: { x: number; y: number },
current: { x: number; y: number },
axisConstraint: TransformAxis | null,
axisConstraintSpace: TransformAxisSpace
): ActiveTransformSession {
const nextSession = cloneTransformSession(
session
) as ActiveTransformSession;
nextSession.axisConstraint = axisConstraint;
nextSession.axisConstraintSpace =
axisConstraint === null ? "world" : axisConstraintSpace;
switch (session.operation) {
case "translate":
nextSession.preview = this.buildTranslatedPreview(
session,
origin,
current,
axisConstraint,
nextSession.axisConstraintSpace
);
return nextSession;
case "rotate":
nextSession.preview = this.buildRotatedPreview(
session,
origin,
current,
axisConstraint,
nextSession.axisConstraintSpace
);
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,
axisConstraintSpace: TransformAxisSpace
) {
if (
session.target.kind === "brushes" ||
session.target.kind === "entities" ||
session.target.kind === "modelInstances"
) {
const preview = this.buildBatchTranslatedPreview(
session,
origin,
current,
axisConstraint,
axisConstraintSpace
);
return this.applySurfaceSnapMoveToTranslatedPreview(
session,
preview,
current,
axisConstraint,
axisConstraintSpace
);
}
if (
session.target.kind === "brushFace" ||
session.target.kind === "brushEdge" ||
session.target.kind === "brushVertex"
) {
return this.buildComponentTranslatedBrushPreview(
session,
origin,
current,
axisConstraint,
axisConstraintSpace
);
}
const initialPosition =
session.target.kind === "brush"
? session.target.initialCenter
: session.target.kind === "modelInstance"
? session.target.initialPosition
: session.target.kind === "pathPoint"
? 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,
x: this.snapWhiteboxPositionValue(initialPosition.x + delta.x),
z: this.snapWhiteboxPositionValue(initialPosition.z + delta.z)
};
break;
case "front":
nextPosition = {
...initialPosition,
x: this.snapWhiteboxPositionValue(initialPosition.x + delta.x),
y: this.snapWhiteboxPositionValue(initialPosition.y + delta.y)
};
break;
case "side":
nextPosition = {
...initialPosition,
y: this.snapWhiteboxPositionValue(initialPosition.y + delta.y),
z: this.snapWhiteboxPositionValue(initialPosition.z + delta.z)
};
break;
}
}
} else if (
axisConstraintSpace === "local" &&
supportsLocalTransformAxisConstraint(session, axisConstraint)
) {
const axisVector = this.getConstraintAxisWorldVector(
session,
axisConstraint,
axisConstraintSpace
);
const axisDelta = this.getMovementDistanceAlongWorldAxis(
axisVector,
initialPosition,
origin,
current
);
const snappedAxisDelta = this.whiteboxSnapEnabled
? snapValueToGrid(axisDelta, this.whiteboxSnapStep)
: axisDelta;
this.transformAxisDelta.copy(axisVector).multiplyScalar(snappedAxisDelta);
nextPosition = {
x: initialPosition.x + this.transformAxisDelta.x,
y: initialPosition.y + this.transformAxisDelta.y,
z: initialPosition.z + this.transformAxisDelta.z
};
} else {
const axisDelta = this.getAxisMovementDistance(
axisConstraint,
initialPosition,
origin,
current
);
nextPosition = this.setAxisComponent(
nextPosition,
axisConstraint,
this.snapWhiteboxPositionValue(
this.getAxisComponent(initialPosition, axisConstraint) + axisDelta
)
);
}
let preview: TransformPreview;
if (session.target.kind === "brush") {
preview = {
kind: "brush" as const,
center: nextPosition,
rotationDegrees: {
...session.target.initialRotationDegrees
},
size: {
...session.target.initialSize
},
geometry: cloneBrushGeometry(session.target.initialGeometry)
};
} else if (session.target.kind === "modelInstance") {
preview = {
kind: "modelInstance" as const,
position: nextPosition,
rotationDegrees: {
...session.target.initialRotationDegrees
},
scale: {
...session.target.initialScale
}
};
} else if (session.target.kind === "pathPoint") {
preview = {
kind: "pathPoint" as const,
position: nextPosition
};
} else {
preview = {
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
}
};
}
return this.applySurfaceSnapMoveToTranslatedPreview(
session,
preview,
current,
axisConstraint,
axisConstraintSpace
);
}
private buildRotatedPreview(
session: ActiveTransformSession,
origin: { x: number; y: number },
current: { x: number; y: number },
axisConstraint: TransformAxis | null,
axisConstraintSpace: TransformAxisSpace
) {
if (
session.target.kind === "brushes" ||
session.target.kind === "entities" ||
session.target.kind === "modelInstances"
) {
return this.buildBatchRotatedPreview(
session,
origin,
current,
axisConstraint,
axisConstraintSpace
);
}
if (
session.target.kind === "brushFace" ||
session.target.kind === "brushEdge"
) {
return this.buildComponentRotatedBrushPreview(
session,
origin,
current,
axisConstraint
);
}
const effectiveAxis =
axisConstraint ?? this.getEffectiveRotationAxis(session);
const pointerDeltaDegrees =
(current.x - origin.x - (current.y - origin.y)) * 0.5;
const pointerDeltaRadians = (pointerDeltaDegrees * Math.PI) / 180;
if (session.target.kind === "brush") {
let nextRotationDegrees = {
...session.target.initialRotationDegrees
};
if (axisConstraint !== null) {
const initialOrientation = this.createRotationQuaternion(
session.target.initialRotationDegrees
);
const deltaRotation = new Quaternion().setFromAxisAngle(
this.axisVector(effectiveAxis),
pointerDeltaRadians
);
nextRotationDegrees = this.getQuaternionEulerDegrees(
axisConstraintSpace === "local" &&
supportsLocalTransformAxisConstraint(session, effectiveAxis)
? initialOrientation.multiply(deltaRotation)
: deltaRotation.multiply(initialOrientation)
);
} else {
nextRotationDegrees[effectiveAxis] = this.normalizeDegrees(
nextRotationDegrees[effectiveAxis] + pointerDeltaDegrees
);
}
return {
kind: "brush" as const,
center: {
...session.target.initialCenter
},
rotationDegrees: nextRotationDegrees,
size: {
...session.target.initialSize
},
geometry: cloneBrushGeometry(session.target.initialGeometry)
};
}
if (session.target.kind === "modelInstance") {
let nextRotationDegrees = {
...session.target.initialRotationDegrees
};
if (axisConstraint !== null) {
const initialOrientation = this.createRotationQuaternion(
session.target.initialRotationDegrees
);
const deltaRotation = new Quaternion().setFromAxisAngle(
this.axisVector(effectiveAxis),
pointerDeltaRadians
);
nextRotationDegrees = this.getQuaternionEulerDegrees(
axisConstraintSpace === "local" &&
supportsLocalTransformAxisConstraint(session, effectiveAxis)
? initialOrientation.multiply(deltaRotation)
: deltaRotation.multiply(initialOrientation)
);
} else {
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") {
if (
axisConstraint !== null &&
axisConstraintSpace === "local" &&
supportsLocalTransformAxisConstraint(session, effectiveAxis)
) {
const initialOrientation = this.createRotationQuaternion({
x: 0,
y: session.target.initialRotation.yawDegrees,
z: 0
});
const deltaRotation = new Quaternion().setFromAxisAngle(
this.axisVector("y"),
pointerDeltaRadians
);
const nextRotationDegrees = this.getQuaternionEulerDegrees(
initialOrientation.multiply(deltaRotation)
);
return {
kind: "entity" as const,
position: {
...session.target.initialPosition
},
rotation: {
kind: "yaw" as const,
yawDegrees: normalizeYawDegrees(nextRotationDegrees.y)
}
};
}
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 initialOrientation = new Quaternion().setFromUnitVectors(
new Vector3(0, 1, 0),
new Vector3(
session.target.initialRotation.direction.x,
session.target.initialRotation.direction.y,
session.target.initialRotation.direction.z
).normalize()
);
const deltaRotation = new Quaternion().setFromAxisAngle(
this.axisVector(effectiveAxis),
pointerDeltaRadians
);
const nextOrientation =
axisConstraint !== null &&
axisConstraintSpace === "local" &&
supportsLocalTransformAxisConstraint(session, effectiveAxis)
? initialOrientation.multiply(deltaRotation)
: deltaRotation.multiply(initialOrientation);
const direction = new Vector3(0, 1, 0)
.applyQuaternion(nextOrientation)
.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
) {
if (
session.target.kind === "brushes" ||
session.target.kind === "modelInstances"
) {
return this.buildBatchScaledPreview(
session,
origin,
current,
axisConstraint
);
}
if (
session.target.kind === "brushFace" ||
session.target.kind === "brushEdge"
) {
return this.buildComponentScaledBrushPreview(
session,
origin,
current,
axisConstraint
);
}
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 scaleAxis = this.resolveObjectScaleConstraintAxis(
session,
axisConstraint
);
const scaleFactor =
1 +
this.getAxisMovementDistance(
axisConstraint,
session.target.initialCenter,
origin,
current
) *
0.45;
nextSize[scaleAxis] = this.snapWhiteboxSizeValue(
session.target.initialSize[scaleAxis] * scaleFactor
);
}
return {
kind: "brush" as const,
center: {
...session.target.initialCenter
},
rotationDegrees: {
...session.target.initialRotationDegrees
},
size: nextSize,
geometry: scaleBrushGeometryToSize(
session.target.initialGeometry,
nextSize
)
};
}
if (session.target.kind !== "modelInstance") {
throw new Error(
"Scale previews are only supported for model instances and whitebox boxes."
);
}
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 scaleAxis = this.resolveObjectScaleConstraintAxis(
session,
axisConstraint
);
const scaleFactor =
1 +
this.getAxisMovementDistance(
axisConstraint,
session.target.initialPosition,
origin,
current
) *
0.45;
nextScale[scaleAxis] = this.snapScaleValue(
session.target.initialScale[scaleAxis] * scaleFactor
);
}
return {
kind: "modelInstance" as const,
position: {
...session.target.initialPosition
},
rotationDegrees: {
...session.target.initialRotationDegrees
},
scale: nextScale
};
}
private scalePositionAroundPivot(
position: Vec3,
pivot: Vec3,
scaleFactor: number,
axisConstraint: TransformAxis | null
): Vec3 {
if (axisConstraint === null) {
return {
x: this.snapWhiteboxPositionValue(
pivot.x + (position.x - pivot.x) * scaleFactor
),
y: this.snapWhiteboxPositionValue(
pivot.y + (position.y - pivot.y) * scaleFactor
),
z: this.snapWhiteboxPositionValue(
pivot.z + (position.z - pivot.z) * scaleFactor
)
};
}
return {
x:
axisConstraint === "x"
? this.snapWhiteboxPositionValue(
pivot.x + (position.x - pivot.x) * scaleFactor
)
: position.x,
y:
axisConstraint === "y"
? this.snapWhiteboxPositionValue(
pivot.y + (position.y - pivot.y) * scaleFactor
)
: position.y,
z:
axisConstraint === "z"
? this.snapWhiteboxPositionValue(
pivot.z + (position.z - pivot.z) * scaleFactor
)
: position.z
};
}
private buildBatchTranslatedPreview(
session: ActiveTransformSession,
origin: { x: number; y: number },
current: { x: number; y: number },
axisConstraint: TransformAxis | null,
axisConstraintSpace: TransformAxisSpace
) {
if (
session.target.kind !== "brushes" &&
session.target.kind !== "modelInstances" &&
session.target.kind !== "entities"
) {
throw new Error("Batch translate preview requires a batch target.");
}
const target = session.target;
const initialPivot = target.initialPivot;
let nextPivot = {
...initialPivot
};
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);
switch (this.viewMode) {
case "perspective":
case "top":
nextPivot = {
...initialPivot,
x: this.snapWhiteboxPositionValue(initialPivot.x + delta.x),
z: this.snapWhiteboxPositionValue(initialPivot.z + delta.z)
};
break;
case "front":
nextPivot = {
...initialPivot,
x: this.snapWhiteboxPositionValue(initialPivot.x + delta.x),
y: this.snapWhiteboxPositionValue(initialPivot.y + delta.y)
};
break;
case "side":
nextPivot = {
...initialPivot,
y: this.snapWhiteboxPositionValue(initialPivot.y + delta.y),
z: this.snapWhiteboxPositionValue(initialPivot.z + delta.z)
};
break;
}
}
} else if (
axisConstraintSpace === "local" &&
supportsLocalTransformAxisConstraint(session, axisConstraint)
) {
const axisVector = this.getConstraintAxisWorldVector(
session,
axisConstraint,
axisConstraintSpace
);
const axisDelta = this.getMovementDistanceAlongWorldAxis(
axisVector,
initialPivot,
origin,
current
);
const snappedAxisDelta = this.whiteboxSnapEnabled
? snapValueToGrid(axisDelta, this.whiteboxSnapStep)
: axisDelta;
const worldDelta = axisVector.clone().multiplyScalar(snappedAxisDelta);
nextPivot = {
x: initialPivot.x + worldDelta.x,
y: initialPivot.y + worldDelta.y,
z: initialPivot.z + worldDelta.z
};
} else {
const axisDelta = this.getAxisMovementDistance(
axisConstraint,
initialPivot,
origin,
current
);
nextPivot = this.setAxisComponent(
nextPivot,
axisConstraint,
this.snapWhiteboxPositionValue(
this.getAxisComponent(initialPivot, axisConstraint) + axisDelta
)
);
}
const worldDelta = {
x: nextPivot.x - initialPivot.x,
y: nextPivot.y - initialPivot.y,
z: nextPivot.z - initialPivot.z
};
if (target.kind === "brushes") {
return {
kind: "brushes" as const,
pivot: nextPivot,
items: target.items.map((item) => ({
brushId: item.brushId,
center: {
x: item.initialCenter.x + worldDelta.x,
y: item.initialCenter.y + worldDelta.y,
z: item.initialCenter.z + worldDelta.z
},
rotationDegrees: {
...item.initialRotationDegrees
},
size: {
...item.initialSize
},
geometry: cloneBrushGeometry(item.initialGeometry)
}))
};
}
if (target.kind === "modelInstances") {
return {
kind: "modelInstances" as const,
pivot: nextPivot,
items: target.items.map((item) => ({
modelInstanceId: item.modelInstanceId,
position: {
x: item.initialPosition.x + worldDelta.x,
y: item.initialPosition.y + worldDelta.y,
z: item.initialPosition.z + worldDelta.z
},
rotationDegrees: {
...item.initialRotationDegrees
},
scale: {
...item.initialScale
}
}))
};
}
return {
kind: "entities" as const,
pivot: nextPivot,
items: target.items.map((item) => ({
entityId: item.entityId,
position: {
x: item.initialPosition.x + worldDelta.x,
y: item.initialPosition.y + worldDelta.y,
z: item.initialPosition.z + worldDelta.z
},
rotation:
item.initialRotation.kind === "yaw"
? {
kind: "yaw" as const,
yawDegrees: item.initialRotation.yawDegrees
}
: item.initialRotation.kind === "direction"
? {
kind: "direction" as const,
direction: {
...item.initialRotation.direction
}
}
: {
kind: "none" as const
}
}))
};
}
private applySurfaceSnapMoveToTranslatedPreview(
session: ActiveTransformSession,
preview: TransformPreview,
current: { x: number; y: number },
axisConstraint: TransformAxis | null,
axisConstraintSpace: TransformAxisSpace
): TransformPreview {
if (
!session.surfaceSnapEnabled ||
!supportsTransformSurfaceSnapTarget(session.target)
) {
return preview;
}
const hit = this.getSurfaceSnapHitAtPointer(session, current);
if (hit === null) {
return preview;
}
const supportPoints = this.collectSurfaceSnapSupportPoints(
session,
preview
);
const axisVector =
axisConstraint === null
? null
: this.getConstraintAxisWorldVector(
session,
axisConstraint,
axisConstraintSpace
);
const delta = computeSurfaceSnapDelta({
supportPoints,
hit,
axisVector:
axisVector === null
? null
: {
x: axisVector.x,
y: axisVector.y,
z: axisVector.z
},
surfaceOffset: SURFACE_SNAP_OFFSET
});
return delta === null
? preview
: applyRigidDeltaToTransformPreview(preview, delta);
}
private getSurfaceSnapHitAtPointer(
session: ActiveTransformSession,
current: { x: number; y: number }
) {
if (!this.setPointerFromClientPosition(current.x, current.y)) {
return null;
}
const excludedIds = this.getSurfaceSnapExcludedIds(session);
const raycastObjects = this.getSurfaceSnapRaycastObjects(excludedIds);
if (raycastObjects.length === 0) {
return null;
}
this.raycaster.setFromCamera(this.pointer, this.getActiveCamera());
return resolveSurfaceSnapHitFromIntersections({
hits: this.raycaster.intersectObjects(raycastObjects, true),
rayDirection: {
x: this.raycaster.ray.direction.x,
y: this.raycaster.ray.direction.y,
z: this.raycaster.ray.direction.z
},
isObjectExcluded: (object) =>
this.isSurfaceSnapObjectExcluded(object, excludedIds)
});
}
private getSurfaceSnapExcludedIds(session: ActiveTransformSession) {
const brushIds = new Set<string>();
const entityIds = new Set<string>();
const modelInstanceIds = new Set<string>();
switch (session.target.kind) {
case "brush":
brushIds.add(session.target.brushId);
break;
case "brushes":
for (const item of session.target.items) {
brushIds.add(item.brushId);
}
break;
case "entity":
entityIds.add(session.target.entityId);
break;
case "entities":
for (const item of session.target.items) {
entityIds.add(item.entityId);
}
break;
case "modelInstance":
modelInstanceIds.add(session.target.modelInstanceId);
break;
case "modelInstances":
for (const item of session.target.items) {
modelInstanceIds.add(item.modelInstanceId);
}
break;
case "brushFace":
case "brushEdge":
case "brushVertex":
case "pathPoint":
break;
}
return {
brushIds,
entityIds,
modelInstanceIds
};
}
private isSurfaceSnapObjectExcluded(
object: Object3D,
excludedIds: {
brushIds: ReadonlySet<string>;
entityIds: ReadonlySet<string>;
modelInstanceIds: ReadonlySet<string>;
}
): boolean {
let current: Object3D | null = object;
while (current !== null) {
const brushId = current.userData.brushId;
if (typeof brushId === "string" && excludedIds.brushIds.has(brushId)) {
return true;
}
const entityId = current.userData.entityId;
if (typeof entityId === "string" && excludedIds.entityIds.has(entityId)) {
return true;
}
const modelInstanceId = current.userData.modelInstanceId;
if (
typeof modelInstanceId === "string" &&
excludedIds.modelInstanceIds.has(modelInstanceId)
) {
return true;
}
current = current.parent;
}
return false;
}
private getSurfaceSnapRaycastObjects(excludedIds: {
brushIds: ReadonlySet<string>;
entityIds: ReadonlySet<string>;
modelInstanceIds: ReadonlySet<string>;
}): Object3D[] {
const raycastObjects: Object3D[] = [];
for (const [brushId, renderObjects] of this.brushRenderObjects) {
if (excludedIds.brushIds.has(brushId)) {
continue;
}
raycastObjects.push(renderObjects.mesh);
}
if (this.currentDocument !== null) {
for (const [entityId, renderObjects] of this.entityRenderObjects) {
if (excludedIds.entityIds.has(entityId)) {
continue;
}
const entity = this.currentDocument.entities[entityId];
if (entity?.kind !== "triggerVolume") {
continue;
}
raycastObjects.push(renderObjects.group);
}
}
for (const [modelInstanceId, renderGroup] of this.modelRenderObjects) {
if (excludedIds.modelInstanceIds.has(modelInstanceId)) {
continue;
}
raycastObjects.push(renderGroup);
}
return raycastObjects;
}
private collectSurfaceSnapSupportPoints(
session: ActiveTransformSession,
preview: TransformPreview
): Vec3[] {
switch (session.target.kind) {
case "brush":
return preview.kind === "brush"
? createBrushSurfaceSnapSupportPoints(preview)
: [];
case "brushes":
return preview.kind === "brushes"
? preview.items.flatMap((item) =>
createBrushSurfaceSnapSupportPoints(item)
)
: [];
case "modelInstance":
return preview.kind === "modelInstance"
? createModelBoundingBoxSurfaceSnapSupportPoints({
position: preview.position,
rotationDegrees: preview.rotationDegrees,
scale: preview.scale,
boundingBox: this.getModelAssetBoundingBox(session.target.assetId)
})
: [];
case "modelInstances":
if (preview.kind !== "modelInstances") {
return [];
}
const modelInstancesTarget = session.target;
return preview.items.flatMap((item, index) =>
createModelBoundingBoxSurfaceSnapSupportPoints({
position: item.position,
rotationDegrees: item.rotationDegrees,
scale: item.scale,
boundingBox: this.getModelAssetBoundingBox(
modelInstancesTarget.items[index]?.assetId ?? ""
)
})
);
case "entity": {
if (
preview.kind !== "entity" ||
session.target.entityKind !== "triggerVolume" ||
this.currentDocument === null
) {
return [];
}
const entity = this.currentDocument.entities[session.target.entityId];
return entity?.kind === "triggerVolume"
? createAxisAlignedBoxSurfaceSnapSupportPoints(
preview.position,
entity.size
)
: [];
}
case "entities": {
if (preview.kind !== "entities" || this.currentDocument === null) {
return [];
}
const supportPoints: Vec3[] = [];
for (const [index, item] of session.target.items.entries()) {
if (item.entityKind !== "triggerVolume") {
continue;
}
const previewItem = preview.items[index];
if (previewItem === undefined) {
continue;
}
const entity = this.currentDocument.entities[item.entityId];
if (entity?.kind !== "triggerVolume") {
continue;
}
supportPoints.push(
...createAxisAlignedBoxSurfaceSnapSupportPoints(
previewItem.position,
entity.size
)
);
}
return supportPoints;
}
case "brushFace":
case "brushEdge":
case "brushVertex":
case "pathPoint":
return [];
}
}
private getModelAssetBoundingBox(assetId: string) {
const asset = this.projectAssets[assetId];
return asset?.kind === "model" ? asset.metadata.boundingBox : null;
}
private buildBatchRotatedPreview(
session: ActiveTransformSession,
origin: { x: number; y: number },
current: { x: number; y: number },
axisConstraint: TransformAxis | null,
axisConstraintSpace: TransformAxisSpace
) {
if (
session.target.kind !== "brushes" &&
session.target.kind !== "modelInstances" &&
session.target.kind !== "entities"
) {
throw new Error("Batch rotate preview requires a batch target.");
}
const target = session.target;
const effectiveAxis =
axisConstraint ?? this.getEffectiveRotationAxis(session);
const pointerDeltaDegrees =
(current.x - origin.x - (current.y - origin.y)) * 0.5;
const pointerDeltaRadians = (pointerDeltaDegrees * Math.PI) / 180;
const pivotWorld = target.initialPivot;
const rotationAxis =
axisConstraint !== null &&
axisConstraintSpace === "local" &&
supportsLocalTransformAxisConstraint(session, effectiveAxis)
? this.getConstraintAxisWorldVector(
session,
effectiveAxis,
axisConstraintSpace
)
: this.axisVector(effectiveAxis);
const normalizedRotationAxis = rotationAxis.clone().normalize();
const deltaRotation = new Quaternion().setFromAxisAngle(
normalizedRotationAxis,
pointerDeltaRadians
);
const pivotVector = new Vector3(pivotWorld.x, pivotWorld.y, pivotWorld.z);
if (target.kind === "brushes") {
return {
kind: "brushes" as const,
pivot: {
...pivotWorld
},
items: target.items.map((item) => {
const nextCenter = new Vector3(
item.initialCenter.x - pivotWorld.x,
item.initialCenter.y - pivotWorld.y,
item.initialCenter.z - pivotWorld.z
)
.applyAxisAngle(normalizedRotationAxis, pointerDeltaRadians)
.add(pivotVector);
let nextRotationDegrees = {
...item.initialRotationDegrees
};
if (axisConstraint === null) {
nextRotationDegrees[effectiveAxis] = this.normalizeDegrees(
nextRotationDegrees[effectiveAxis] + pointerDeltaDegrees
);
} else {
nextRotationDegrees = this.getQuaternionEulerDegrees(
deltaRotation
.clone()
.multiply(
this.createRotationQuaternion(item.initialRotationDegrees)
)
);
}
return {
brushId: item.brushId,
center: {
x: nextCenter.x,
y: nextCenter.y,
z: nextCenter.z
},
rotationDegrees: nextRotationDegrees,
size: {
...item.initialSize
},
geometry: cloneBrushGeometry(item.initialGeometry)
};
})
};
}
if (target.kind === "modelInstances") {
return {
kind: "modelInstances" as const,
pivot: {
...pivotWorld
},
items: target.items.map((item) => {
const nextPosition = new Vector3(
item.initialPosition.x - pivotWorld.x,
item.initialPosition.y - pivotWorld.y,
item.initialPosition.z - pivotWorld.z
)
.applyAxisAngle(normalizedRotationAxis, pointerDeltaRadians)
.add(pivotVector);
let nextRotationDegrees = {
...item.initialRotationDegrees
};
if (axisConstraint === null) {
nextRotationDegrees[effectiveAxis] = this.normalizeDegrees(
nextRotationDegrees[effectiveAxis] + pointerDeltaDegrees
);
} else {
nextRotationDegrees = this.getQuaternionEulerDegrees(
deltaRotation
.clone()
.multiply(
this.createRotationQuaternion(item.initialRotationDegrees)
)
);
}
return {
modelInstanceId: item.modelInstanceId,
position: {
x: nextPosition.x,
y: nextPosition.y,
z: nextPosition.z
},
rotationDegrees: nextRotationDegrees,
scale: {
...item.initialScale
}
};
})
};
}
return {
kind: "entities" as const,
pivot: {
...pivotWorld
},
items: target.items.map((item) => {
const nextPosition = new Vector3(
item.initialPosition.x - pivotWorld.x,
item.initialPosition.y - pivotWorld.y,
item.initialPosition.z - pivotWorld.z
)
.applyAxisAngle(normalizedRotationAxis, pointerDeltaRadians)
.add(pivotVector);
if (item.initialRotation.kind === "yaw") {
if (axisConstraint === null) {
return {
entityId: item.entityId,
position: {
x: nextPosition.x,
y: nextPosition.y,
z: nextPosition.z
},
rotation: {
kind: "yaw" as const,
yawDegrees: normalizeYawDegrees(
item.initialRotation.yawDegrees + pointerDeltaDegrees
)
}
};
}
const nextRotationDegrees = this.getQuaternionEulerDegrees(
deltaRotation.clone().multiply(
this.createRotationQuaternion({
x: 0,
y: item.initialRotation.yawDegrees,
z: 0
})
)
);
return {
entityId: item.entityId,
position: {
x: nextPosition.x,
y: nextPosition.y,
z: nextPosition.z
},
rotation: {
kind: "yaw" as const,
yawDegrees: normalizeYawDegrees(nextRotationDegrees.y)
}
};
}
if (item.initialRotation.kind === "direction") {
const direction = new Vector3(
item.initialRotation.direction.x,
item.initialRotation.direction.y,
item.initialRotation.direction.z
)
.applyQuaternion(deltaRotation)
.normalize();
return {
entityId: item.entityId,
position: {
x: nextPosition.x,
y: nextPosition.y,
z: nextPosition.z
},
rotation: {
kind: "direction" as const,
direction: {
x: direction.x,
y: direction.y,
z: direction.z
}
}
};
}
return {
entityId: item.entityId,
position: {
x: nextPosition.x,
y: nextPosition.y,
z: nextPosition.z
},
rotation: {
kind: "none" as const
}
};
})
};
}
private buildBatchScaledPreview(
session: ActiveTransformSession,
origin: { x: number; y: number },
current: { x: number; y: number },
axisConstraint: TransformAxis | null
) {
if (
session.target.kind !== "brushes" &&
session.target.kind !== "modelInstances"
) {
throw new Error("Batch scale preview requires a scalable batch target.");
}
const target = session.target;
const initialPivot = target.initialPivot;
const scaleFactor =
axisConstraint === null
? 1 + (current.x - origin.x - (current.y - origin.y)) * 0.01
: 1 +
this.getAxisMovementDistance(
axisConstraint,
initialPivot,
origin,
current
) *
0.45;
if (target.kind === "brushes") {
return {
kind: "brushes" as const,
pivot: {
...initialPivot
},
items: target.items.map((item) => {
const nextSize = {
...item.initialSize
};
if (axisConstraint === null) {
nextSize.x = this.snapWhiteboxSizeValue(
item.initialSize.x * scaleFactor
);
nextSize.y = this.snapWhiteboxSizeValue(
item.initialSize.y * scaleFactor
);
nextSize.z = this.snapWhiteboxSizeValue(
item.initialSize.z * scaleFactor
);
} else {
const scaleAxis = resolveDominantLocalAxisForWorldAxis(
item.initialRotationDegrees,
axisConstraint
);
nextSize[scaleAxis] = this.snapWhiteboxSizeValue(
item.initialSize[scaleAxis] * scaleFactor
);
}
return {
brushId: item.brushId,
center: this.scalePositionAroundPivot(
item.initialCenter,
initialPivot,
scaleFactor,
axisConstraint
),
rotationDegrees: {
...item.initialRotationDegrees
},
size: nextSize,
geometry: scaleBrushGeometryToSize(item.initialGeometry, nextSize)
};
})
};
}
return {
kind: "modelInstances" as const,
pivot: {
...initialPivot
},
items: target.items.map((item) => {
const nextScale = {
...item.initialScale
};
if (axisConstraint === null) {
nextScale.x = this.snapScaleValue(item.initialScale.x * scaleFactor);
nextScale.y = this.snapScaleValue(item.initialScale.y * scaleFactor);
nextScale.z = this.snapScaleValue(item.initialScale.z * scaleFactor);
} else {
const scaleAxis = resolveDominantLocalAxisForWorldAxis(
item.initialRotationDegrees,
axisConstraint
);
nextScale[scaleAxis] = this.snapScaleValue(
item.initialScale[scaleAxis] * scaleFactor
);
}
return {
modelInstanceId: item.modelInstanceId,
position: this.scalePositionAroundPivot(
item.initialPosition,
initialPivot,
scaleFactor,
axisConstraint
),
rotationDegrees: {
...item.initialRotationDegrees
},
scale: nextScale
};
})
};
}
private createTargetPreviewBrush(
session: ActiveTransformSession
): Brush | 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) {
return null;
}
return updateBrush(currentBrush, {
center: {
...session.target.initialCenter
},
rotationDegrees: {
...session.target.initialRotationDegrees
},
size: {
...session.target.initialSize
},
geometry: cloneBrushGeometry(session.target.initialGeometry)
});
}
private createBrushPreviewFromGeometry(
brush: Brush,
geometry: BrushGeometry
): {
kind: "brush";
center: Vec3;
rotationDegrees: Vec3;
size: Vec3;
geometry: BrushGeometry;
} {
const nextGeometry = cloneBrushGeometry(geometry);
return {
kind: "brush",
center: {
...brush.center
},
rotationDegrees: {
...brush.rotationDegrees
},
size: deriveBrushSizeFromGeometry(nextGeometry),
geometry: nextGeometry
};
}
private getComponentTargetVertexIds(
target: ActiveTransformSession["target"]
): WhiteboxVertexId[] {
if (
target.kind !== "brushFace" &&
target.kind !== "brushEdge" &&
target.kind !== "brushVertex"
) {
return [];
}
const brush = this.currentDocument?.brushes[target.brushId];
if (brush === undefined) {
return [];
}
switch (target.kind) {
case "brushFace":
return [...getBrushFaceVertexIds(brush, target.faceId)];
case "brushEdge": {
const [start, end] = getBrushEdgeVertexIds(brush, target.edgeId);
return [start, end];
}
case "brushVertex":
return [target.vertexId];
default:
return [];
}
}
private applyDeltaToVertices(
brush: Brush,
vertexIds: WhiteboxVertexId[],
delta: Vec3
): BrushGeometry {
const nextGeometry = cloneBrushGeometry(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;
}
private buildComponentTranslatedBrushPreview(
session: ActiveTransformSession,
origin: { x: number; y: number },
current: { x: number; y: number },
axisConstraint: TransformAxis | null,
axisConstraintSpace: TransformAxisSpace
) {
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 },
size: { ...initialBrush.size },
geometry: cloneBrushGeometry(initialBrush.geometry)
}
});
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 {
if (
axisConstraintSpace === "local" &&
supportsLocalTransformAxisConstraint(session, axisConstraint)
) {
const axisVector = this.getConstraintAxisWorldVector(
session,
axisConstraint,
axisConstraintSpace
);
const axisDelta = this.getMovementDistanceAlongWorldAxis(
axisVector,
initialPivot,
origin,
current
);
const snappedAxisDelta = this.whiteboxSnapEnabled
? snapValueToGrid(axisDelta, this.whiteboxSnapStep)
: axisDelta;
const localDelta = {
x: 0,
y: 0,
z: 0
};
localDelta[axisConstraint] = snappedAxisDelta;
const vertexIds = this.getComponentTargetVertexIds(session.target);
const nextGeometry = this.applyDeltaToVertices(
initialBrush,
vertexIds,
localDelta
);
return this.createBrushPreviewFromGeometry(initialBrush, nextGeometry);
}
const axisDelta = this.getAxisMovementDistance(
axisConstraint,
initialPivot,
origin,
current
);
worldDelta = this.setAxisComponent(worldDelta, axisConstraint, axisDelta);
}
const localDelta = transformBrushWorldVectorToLocal(
initialBrush,
worldDelta
);
const vertexIds = this.getComponentTargetVertexIds(session.target);
const nextGeometry = this.applyDeltaToVertices(
initialBrush,
vertexIds,
localDelta
);
return this.createBrushPreviewFromGeometry(initialBrush, nextGeometry);
}
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;
const pivotWorld = this.getTransformPivotPosition({
...session,
preview: {
kind: "brush",
center: { ...initialBrush.center },
rotationDegrees: { ...initialBrush.rotationDegrees },
size: { ...initialBrush.size },
geometry: cloneBrushGeometry(initialBrush.geometry)
}
});
const pivotLocal = transformBrushWorldPointToLocal(
initialBrush,
pivotWorld
);
const rotationAxis = this.axisVector(effectiveAxis).normalize();
const vertexIds = this.getComponentTargetVertexIds(session.target);
const nextGeometry = cloneBrushGeometry(initialBrush.geometry);
for (const vertexId of vertexIds) {
const vertex = getBrushLocalVertexPosition(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)
};
}
return this.createBrushPreviewFromGeometry(initialBrush, nextGeometry);
}
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."
);
}
const pivotWorld = this.getTransformPivotPosition({
...session,
preview: {
kind: "brush",
center: { ...initialBrush.center },
rotationDegrees: { ...initialBrush.rotationDegrees },
size: { ...initialBrush.size },
geometry: cloneBrushGeometry(initialBrush.geometry)
}
});
const pivotLocal = transformBrushWorldPointToLocal(
initialBrush,
pivotWorld
);
const nextGeometry = cloneBrushGeometry(initialBrush.geometry);
const vertexIds = this.getComponentTargetVertexIds(session.target);
if (session.target.kind === "brushFace") {
const axis =
axisConstraint ?? getBrushFaceAxis(initialBrush, session.target.faceId);
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
);
}
} else if (session.target.kind === "brushEdge") {
const affectedAxes = getBrushEdgeScaleAxes(
initialBrush,
session.target.edgeId
).filter((axis) => axisConstraint === null || axisConstraint === axis);
for (const axis of affectedAxes) {
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
);
}
}
}
return this.createBrushPreviewFromGeometry(initialBrush, nextGeometry);
}
private updateBrushRenderObjectGeometry(brush: Brush) {
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 = getBrushEdgeWorldSegment(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 = getBrushVertexWorldPosition(brush, vertexHelper.id);
vertexHelper.mesh.position.set(vertex.x, vertex.y, vertex.z);
}
}
private applyBrushRenderObjectTransform(
brushId: string,
center: Vec3,
rotationDegrees: Vec3
) {
const renderObjects = this.brushRenderObjects.get(brushId);
if (renderObjects === undefined) {
return;
}
renderObjects.mesh.position.set(center.x, center.y, center.z);
renderObjects.mesh.rotation.set(
(rotationDegrees.x * Math.PI) / 180,
(rotationDegrees.y * Math.PI) / 180,
(rotationDegrees.z * Math.PI) / 180
);
renderObjects.mesh.scale.set(1, 1, 1);
renderObjects.edges.position.set(center.x, center.y, center.z);
renderObjects.edges.rotation.set(
(rotationDegrees.x * Math.PI) / 180,
(rotationDegrees.y * Math.PI) / 180,
(rotationDegrees.z * Math.PI) / 180
);
renderObjects.edges.scale.set(1, 1, 1);
}
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 applyCameraRigGroupTransform(
group: Group,
entity: CameraRigEntity,
document: SceneDocument | null
) {
const authoredPosition =
document === null
? entity.rigType === "fixed"
? entity.position
: null
: resolveCameraRigDocumentPosition(
entity,
document.entities,
document.paths,
{
fallbackToPathStart: true
}
);
if (authoredPosition === null) {
group.position.set(0, 0, 0);
group.rotation.set(0, 0, 0);
group.quaternion.identity();
this.updateCameraRigPreview(group, entity, document, null);
return;
}
group.position.set(
authoredPosition.x,
authoredPosition.y,
authoredPosition.z
);
const lookTarget =
document === null
? null
: resolveCameraRigDocumentLookTarget(entity, document.entities);
if (lookTarget === null) {
group.rotation.set(0, 0, 0);
group.quaternion.identity();
this.updateCameraRigPreview(group, entity, document, authoredPosition);
return;
}
group.lookAt(lookTarget.x, lookTarget.y, lookTarget.z);
this.updateCameraRigPreview(group, entity, document, authoredPosition);
}
private updateCameraRigPreview(
group: Group,
entity: CameraRigEntity,
document: SceneDocument | null,
authoredPosition: Vec3 | null
) {
const preview = group.userData.cameraRigPreview as
| CameraRigPreviewRenderObjects
| undefined;
if (preview === undefined) {
return;
}
if (
authoredPosition === null ||
document === null ||
entity.rigType !== "rail" ||
entity.railPlacementMode !== "mapTargetBetweenPoints"
) {
preview.previewGroup.visible = false;
return;
}
const authoredPath = document.paths[entity.pathId] ?? null;
if (authoredPath === null) {
preview.previewGroup.visible = false;
return;
}
const toLocalPoint = (point: Vec3) =>
new Vector3(
point.x - authoredPosition.x,
point.y - authoredPosition.y,
point.z - authoredPosition.z
);
const trackStartPoint = toLocalPoint(entity.trackStartPoint);
const trackEndPoint = toLocalPoint(entity.trackEndPoint);
const railStartPoint = toLocalPoint(
sampleScenePathPosition(authoredPath, entity.railStartProgress)
);
const railEndPoint = toLocalPoint(
sampleScenePathPosition(authoredPath, entity.railEndProgress)
);
preview.previewGroup.visible = true;
preview.previewGroup.quaternion.copy(group.quaternion).invert();
preview.trackLine.geometry.setFromPoints([trackStartPoint, trackEndPoint]);
preview.trackStartMesh.position.copy(trackStartPoint);
preview.trackEndMesh.position.copy(trackEndPoint);
preview.railSpanLine.geometry.setFromPoints([railStartPoint, railEndPoint]);
preview.railStartMesh.position.copy(railStartPoint);
preview.railEndMesh.position.copy(railEndPoint);
}
private isSelectedRailCameraRigPathPreviewed(pathId: string): boolean {
if (
this.currentDocument === null ||
this.currentSelection.kind !== "entities"
) {
return false;
}
if (this.currentSelection.ids.length !== 1) {
return false;
}
const selectedEntity =
this.currentDocument.entities[this.currentSelection.ids[0]] ?? null;
return (
selectedEntity?.kind === "cameraRig" &&
selectedEntity.rigType === "rail" &&
selectedEntity.pathId === pathId
);
}
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 "cameraRig":
this.applyCameraRigGroupTransform(
renderObjects.group,
entity,
this.currentDocument
);
break;
case "spotLight":
this.applySpotLightGroupTransform(
renderObjects.group,
entity.position,
entity.direction
);
break;
case "playerStart":
case "sceneEntry":
case "npc":
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;
}
}
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;
}
}
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)) {
this.updateBrushRenderObjectGeometry(brush);
this.applyBrushRenderObjectTransform(
brush.id,
brush.center,
brush.rotationDegrees
);
}
for (const entity of getEntityInstances(this.currentDocument.entities)) {
this.applyEntityRenderObjectTransform(entity);
this.applyLocalLightRenderObjectTransform(entity);
}
for (const modelInstance of getModelInstances(
this.currentDocument.modelInstances
)) {
this.applyModelInstanceRenderObjectTransform(modelInstance);
}
for (const path of getScenePaths(this.currentDocument.paths)) {
this.updatePathRenderObjectState(path);
}
}
private applyTransformPreview() {
this.resetRenderObjectTransformsFromDocument();
if (this.currentTransformSession.kind !== "active") {
return;
}
switch (this.currentTransformSession.target.kind) {
case "brush":
case "brushFace":
case "brushEdge":
case "brushVertex":
if (this.currentTransformSession.preview.kind === "brush") {
const previewBrush = this.createPreviewBrushForSession(
this.currentTransformSession
);
if (previewBrush !== null) {
this.updateBrushRenderObjectGeometry(previewBrush);
}
this.applyBrushRenderObjectTransform(
this.currentTransformSession.target.brushId,
this.currentTransformSession.preview.center,
this.currentTransformSession.preview.rotationDegrees
);
}
break;
case "brushes":
if (this.currentTransformSession.preview.kind === "brushes") {
for (const previewItem of this.currentTransformSession.preview
.items) {
const brush = this.currentDocument?.brushes[previewItem.brushId];
if (brush === undefined) {
continue;
}
this.updateBrushRenderObjectGeometry(
updateBrush(brush, {
center: previewItem.center,
rotationDegrees: previewItem.rotationDegrees,
size: previewItem.size,
geometry: previewItem.geometry as never
})
);
this.applyBrushRenderObjectTransform(
previewItem.brushId,
previewItem.center,
previewItem.rotationDegrees
);
}
}
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 "modelInstances":
if (this.currentTransformSession.preview.kind === "modelInstances") {
for (const previewItem of this.currentTransformSession.preview
.items) {
const modelInstance =
this.currentDocument?.modelInstances[previewItem.modelInstanceId];
if (modelInstance === undefined) {
continue;
}
this.applyModelInstanceRenderObjectTransform(
createModelInstance({
...modelInstance,
position: previewItem.position,
rotationDegrees: previewItem.rotationDegrees,
scale: previewItem.scale
})
);
}
}
break;
case "pathPoint": {
const activeTransformSession = this.currentTransformSession;
if (
activeTransformSession.kind !== "active" ||
activeTransformSession.target.kind !== "pathPoint" ||
activeTransformSession.preview.kind !== "pathPoint" ||
this.currentDocument === null
) {
break;
}
const currentPath =
this.currentDocument.paths[activeTransformSession.target.pathId];
if (currentPath === undefined) {
break;
}
const previewPointId = activeTransformSession.target.pointId;
const previewPosition = activeTransformSession.preview.position;
this.updatePathRenderObjectState({
...currentPath,
points: currentPath.points.map((point) =>
point.id === previewPointId
? {
...point,
position: previewPosition
}
: point
)
});
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 "cameraRig":
this.applyEntityRenderObjectTransform(
currentEntity.rigType === "fixed"
? {
...currentEntity,
position: this.currentTransformSession.preview.position
}
: currentEntity
);
break;
case "pointLight":
case "soundEmitter":
case "triggerVolume":
case "interactable":
this.applyEntityRenderObjectTransform({
...currentEntity,
position: this.currentTransformSession.preview.position
});
this.applyLocalLightRenderObjectTransform({
...currentEntity,
position: this.currentTransformSession.preview.position
});
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
});
this.applyLocalLightRenderObjectTransform({
...currentEntity,
position: this.currentTransformSession.preview.position,
direction:
this.currentTransformSession.preview.rotation.kind ===
"direction"
? this.currentTransformSession.preview.rotation.direction
: currentEntity.direction
});
break;
case "playerStart":
case "sceneEntry":
case "npc":
case "teleportTarget":
this.applyEntityRenderObjectTransform({
...currentEntity,
position: this.currentTransformSession.preview.position,
yawDegrees:
this.currentTransformSession.preview.rotation.kind === "yaw"
? this.currentTransformSession.preview.rotation.yawDegrees
: currentEntity.yawDegrees
});
this.applyLocalLightRenderObjectTransform({
...currentEntity,
position: this.currentTransformSession.preview.position,
yawDegrees:
this.currentTransformSession.preview.rotation.kind === "yaw"
? this.currentTransformSession.preview.rotation.yawDegrees
: currentEntity.yawDegrees
});
break;
}
break;
}
case "entities":
if (
this.currentTransformSession.preview.kind !== "entities" ||
this.currentDocument === null
) {
break;
}
for (const previewItem of this.currentTransformSession.preview.items) {
const currentEntity =
this.currentDocument.entities[previewItem.entityId];
if (currentEntity === undefined) {
continue;
}
switch (currentEntity.kind) {
case "cameraRig":
this.applyEntityRenderObjectTransform(
currentEntity.rigType === "fixed"
? {
...currentEntity,
position: previewItem.position
}
: currentEntity
);
break;
case "pointLight":
case "soundEmitter":
case "triggerVolume":
case "interactable":
this.applyEntityRenderObjectTransform({
...currentEntity,
position: previewItem.position
});
this.applyLocalLightRenderObjectTransform({
...currentEntity,
position: previewItem.position
});
break;
case "spotLight":
this.applyEntityRenderObjectTransform({
...currentEntity,
position: previewItem.position,
direction:
previewItem.rotation.kind === "direction"
? previewItem.rotation.direction
: currentEntity.direction
});
this.applyLocalLightRenderObjectTransform({
...currentEntity,
position: previewItem.position,
direction:
previewItem.rotation.kind === "direction"
? previewItem.rotation.direction
: currentEntity.direction
});
break;
case "playerStart":
case "sceneEntry":
case "npc":
case "teleportTarget":
this.applyEntityRenderObjectTransform({
...currentEntity,
position: previewItem.position,
yawDegrees:
previewItem.rotation.kind === "yaw"
? previewItem.rotation.yawDegrees
: currentEntity.yawDegrees
});
break;
}
}
break;
}
}
private rebuildLocalLights(document: SceneDocument) {
this.clearLocalLights();
if (this.currentSimulationScene !== null) {
for (const pointLight of this.currentSimulationScene.localLights
.pointLights) {
const renderObjects = this.createPointLightRuntimeObjects(pointLight);
renderObjects.group.visible =
pointLight.enabled && this.displayMode !== "wireframe";
this.localLightGroup.add(renderObjects.group);
this.localLightRenderObjects.set(pointLight.entityId, renderObjects);
}
for (const spotLight of this.currentSimulationScene.localLights
.spotLights) {
const renderObjects = this.createSpotLightRuntimeObjects(spotLight);
renderObjects.group.visible =
spotLight.enabled && this.displayMode !== "wireframe";
this.localLightGroup.add(renderObjects.group);
this.localLightRenderObjects.set(spotLight.entityId, renderObjects);
}
this.applyShadowState();
return;
}
for (const entity of getEntityInstances(document.entities)) {
if (!entity.enabled) {
continue;
}
switch (entity.kind) {
case "pointLight": {
const renderObjects = this.createPointLightRuntimeObjects(entity);
this.localLightGroup.add(renderObjects.group);
this.localLightRenderObjects.set(entity.id, renderObjects);
break;
}
case "spotLight": {
const renderObjects = this.createSpotLightRuntimeObjects(entity);
this.localLightGroup.add(renderObjects.group);
this.localLightRenderObjects.set(entity.id, renderObjects);
break;
}
}
}
this.applyShadowState();
}
private rebuildLightVolumes(document: SceneDocument) {
this.clearLightVolumes();
if (this.currentSimulationScene !== null) {
for (const lightVolume of this.currentSimulationScene.volumes.light) {
const renderObjects = this.createLightVolumeRenderObjects(lightVolume);
renderObjects.group.visible =
lightVolume.enabled && this.displayMode !== "wireframe";
this.lightVolumeGroup.add(renderObjects.group);
this.lightVolumeRenderObjects.set(lightVolume.brushId, renderObjects);
}
return;
}
for (const brush of Object.values(document.brushes)) {
if (
!brush.enabled ||
!brush.visible ||
brush.kind !== "box" ||
brush.volume.mode !== "light"
) {
continue;
}
const renderObjects = this.createLightVolumeRenderObjects({
brushId: brush.id,
enabled: brush.visible,
center: brush.center,
rotationDegrees: brush.rotationDegrees,
size: brush.size,
colorHex: brush.volume.light.colorHex,
lights: deriveBoxLightVolumePointLights({
size: brush.size,
intensity: brush.volume.light.intensity,
padding: brush.volume.light.padding,
falloff: brush.volume.light.falloff
})
});
renderObjects.group.visible = this.displayMode !== "wireframe";
this.lightVolumeGroup.add(renderObjects.group);
this.lightVolumeRenderObjects.set(brush.id, renderObjects);
}
}
private rebuildBrushMeshes(
document: SceneDocument,
selection: EditorSelection
) {
this.clearBrushMeshes();
const volumeRenderPaths = resolveBoxVolumeRenderPaths(
document.world.advancedRendering
);
for (const brush of Object.values(document.brushes)) {
if (!brush.enabled || !brush.visible) {
continue;
}
const derivedMesh = buildBoxBrushDerivedMeshData(brush);
const geometry = derivedMesh.geometry;
const contactPatches =
brush.kind === "box" && brush.volume.mode === "water"
? this.collectViewportWaterContactPatches(document, brush)
: [];
const materials =
this.createFogMaterialSet(brush, volumeRenderPaths) ??
derivedMesh.faceIdsInOrder.map((faceId) =>
this.createFaceMaterial(
brush,
faceId,
document.materials[brush.faces[faceId].materialId ?? ""],
this.getFaceHighlightState(brush.id, faceId),
volumeRenderPaths,
contactPatches
)
);
const mesh = new Mesh(geometry, materials);
const brushSelected = isBrushSelected(selection, brush.id);
this.configureFogVolumeMesh(mesh, materials);
applyRendererRenderCategoryFromMaterial(mesh);
mesh.userData.brushId = brush.id;
mesh.castShadow = false;
mesh.receiveShadow = false;
const edges = new LineSegments(
new EdgesGeometry(geometry),
new LineBasicMaterial({
color: brushSelected ? BRUSH_SELECTED_EDGE_COLOR : BRUSH_EDGE_COLOR
})
);
edges.visible = this.displayMode !== "wireframe";
applyRendererRenderCategory(edges, "overlay");
const edgeHelpers = getBrushEdgeIds(brush).map((edgeId) =>
this.createEdgeHelper(brush, edgeId)
);
const vertexHelpers = getBrushVertexIds(brush).map((vertexId) =>
this.createVertexHelper(brush, vertexId)
);
this.brushGroup.add(mesh);
this.brushGroup.add(edges);
for (const edgeHelper of edgeHelpers) {
this.brushGroup.add(edgeHelper.line);
}
for (const vertexHelper of vertexHelpers) {
this.brushGroup.add(vertexHelper.mesh);
}
this.brushRenderObjects.set(brush.id, {
mesh,
faceIdsInOrder: derivedMesh.faceIdsInOrder,
edges,
edgeHelpers,
vertexHelpers
});
this.applyBrushRenderObjectTransform(
brush.id,
brush.center,
brush.rotationDegrees
);
}
this.refreshBrushPresentation();
this.applyShadowState();
}
private resolveTerrainLayerMaterial(
materialId: string | null
): MaterialDef | null {
if (materialId === null || this.currentDocument === null) {
return null;
}
return this.currentDocument.materials[materialId] ?? null;
}
private createTerrainMaterial(terrain: Terrain): Material {
const selected = isTerrainSelected(this.currentSelection, terrain.id);
const hovered = isTerrainSelected(this.hoveredSelection, terrain.id);
const active = selected && this.currentActiveSelectionId === terrain.id;
const color = active
? TERRAIN_ACTIVE_COLOR
: selected
? TERRAIN_SELECTED_COLOR
: hovered
? TERRAIN_HOVERED_COLOR
: TERRAIN_BASE_COLOR;
if (this.displayMode === "wireframe") {
return new MeshBasicMaterial({
color,
wireframe: true
});
}
const layerTextures = terrain.layers.map((layer) =>
getTerrainLayerTexture(
this.resolveTerrainLayerMaterial(layer.materialId),
(material) => this.getOrCreateTextureSet(material).baseColor
)
) as [Texture, Texture, Texture, Texture];
return createTerrainLayerBlendMaterial({
layerTextures,
emissiveHex: active
? TERRAIN_ACTIVE_EMISSIVE
: selected
? TERRAIN_SELECTED_EMISSIVE
: hovered
? TERRAIN_HOVERED_EMISSIVE
: 0x000000,
emissiveIntensity: active ? 0.26 : selected ? 0.18 : hovered ? 0.08 : 0
});
}
private rebuildTerrains(
document: SceneDocument,
_selection: EditorSelection,
_activeSelectionId: string | null
) {
this.clearTerrains();
for (const terrain of getTerrains(document.terrains)) {
const displayedTerrain =
this.getDisplayedTerrainState(terrain.id) ?? terrain;
if (!displayedTerrain.enabled || !displayedTerrain.visible) {
continue;
}
const derivedMesh = buildTerrainDerivedMeshData(displayedTerrain);
const mesh = new Mesh(
derivedMesh.geometry,
this.createTerrainMaterial(displayedTerrain)
);
mesh.position.set(
displayedTerrain.position.x,
displayedTerrain.position.y,
displayedTerrain.position.z
);
mesh.userData.terrainId = displayedTerrain.id;
mesh.castShadow = false;
mesh.receiveShadow = true;
applyRendererRenderCategory(mesh, "ao-world");
this.terrainGroup.add(mesh);
this.terrainRenderObjects.set(displayedTerrain.id, {
mesh
});
}
this.applyShadowState();
this.syncTerrainBrushPreview();
}
private createPathLineGeometry(path: ScenePath): BufferGeometry {
const points = path.points.map(
(point) =>
new Vector3(point.position.x, point.position.y, point.position.z)
);
if (path.loop && points.length > 1) {
points.push(
new Vector3(
path.points[0].position.x,
path.points[0].position.y,
path.points[0].position.z
)
);
}
return new BufferGeometry().setFromPoints(points);
}
private rebuildPaths(document: SceneDocument, selection: EditorSelection) {
this.clearPaths();
for (const path of getScenePaths(document.paths)) {
if (
!path.enabled ||
(!path.visible && !this.isSelectedRailCameraRigPathPreviewed(path.id))
) {
continue;
}
const line = new Line(
this.createPathLineGeometry(path),
new LineBasicMaterial({
color: isPathSelected(selection, path.id)
? PATH_SELECTED_COLOR
: PATH_COLOR
})
);
line.userData.pathId = path.id;
const pointMeshes = path.points.map((point) => {
const mesh = new Mesh(
new SphereGeometry(PATH_POINT_RADIUS, 12, 12),
new MeshBasicMaterial({
color: isPathSelected(selection, path.id)
? PATH_POINT_SELECTED_COLOR
: PATH_POINT_COLOR
})
);
mesh.position.set(point.position.x, point.position.y, point.position.z);
mesh.userData.pathId = path.id;
mesh.userData.pathPointId = point.id;
this.pathGroup.add(mesh);
return {
pointId: point.id,
mesh
};
});
this.pathGroup.add(line);
this.pathRenderObjects.set(path.id, {
line,
pointMeshes
});
}
applyRendererRenderCategory(this.pathGroup, "overlay");
this.refreshPathPresentation();
}
private updatePathRenderObjectState(path: ScenePath) {
const renderObjects = this.pathRenderObjects.get(path.id);
if (renderObjects === undefined) {
return;
}
renderObjects.line.geometry.dispose();
renderObjects.line.geometry = this.createPathLineGeometry(path);
for (const pointMesh of renderObjects.pointMeshes) {
const point = path.points.find(
(candidatePoint) => candidatePoint.id === pointMesh.pointId
);
if (point === undefined) {
continue;
}
pointMesh.mesh.position.set(
point.position.x,
point.position.y,
point.position.z
);
}
}
private configureFogVolumeMesh(
mesh: Mesh<BufferGeometry, Material[]>,
materials: Material[]
) {
const fogMaterials = Array.from(
new Set(
materials.filter(
(material): material is ShaderMaterial =>
material instanceof ShaderMaterial &&
material.uniforms["localCameraPosition"] !== undefined
)
)
);
if (fogMaterials.length === 0) {
return;
}
mesh.onBeforeRender = (_renderer, _scene, camera) => {
const localCameraPosition = mesh.worldToLocal(
this.fogLocalCameraPosition.copy(camera.position)
);
for (const material of fogMaterials) {
(
material.uniforms["localCameraPosition"] as { value: Vector3 }
).value.copy(localCameraPosition);
}
};
}
private createFogMaterialSet(
brush: Brush,
volumeRenderPaths: {
fog: "performance" | "quality";
water: "performance" | "quality";
}
): Material[] | null {
if (
brush.volume.mode !== "fog" ||
this.displayMode === "wireframe" ||
this.displayMode === "authoring"
) {
return null;
}
const faceIds = getBrushFaceIds(brush);
const highlightStates = faceIds.map((faceId) =>
this.getFaceHighlightState(brush.id, faceId)
);
const selectedFace = highlightStates.includes("selected");
const hoveredFace = !selectedFace && highlightStates.includes("hovered");
const quality = volumeRenderPaths.fog === "quality";
const densityBoost = selectedFace ? 1.08 : hoveredFace ? 1.04 : 1;
const opacityBoost = selectedFace ? 0.08 : hoveredFace ? 0.04 : 0;
if (quality) {
const fogMaterial = createFogQualityMaterial({
colorHex: brush.volume.fog.colorHex,
density: brush.volume.fog.density * densityBoost,
padding: brush.volume.fog.padding,
time: this.volumeTime,
halfSize: {
x: brush.size.x * 0.5,
y: brush.size.y * 0.5,
z: brush.size.z * 0.5
},
opacityMultiplier: densityBoost,
colorLift: selectedFace ? 0.08 : hoveredFace ? 0.04 : 0
});
this.volumeAnimatedUniforms.push(fogMaterial.animationUniform);
return faceIds.map(() => fogMaterial.material);
}
const baseOpacity = Math.max(
0.08,
Math.min(0.82, brush.volume.fog.density * 0.9 + 0.1)
);
const fogMaterial = new MeshStandardMaterial({
color: brush.volume.fog.colorHex,
emissive: brush.volume.fog.colorHex,
emissiveIntensity: 0.04,
roughness: 1,
metalness: 0,
transparent: true,
opacity: Math.min(0.92, baseOpacity + opacityBoost),
depthWrite: false
});
return faceIds.map(() => fogMaterial);
}
private rebuildEntityMarkers(
document: SceneDocument,
selection: EditorSelection
) {
this.clearEntityMarkers();
const runtimeNpcDefinitionsByEntityId = new Map(
(this.currentSimulationScene?.npcDefinitions ?? []).map((npc) => [
npc.entityId,
npc
])
);
const runtimeInteractablesByEntityId = new Map(
(this.currentSimulationScene?.entities.interactables ?? []).map(
(interactable) => [interactable.entityId, interactable]
)
);
for (const entity of getEntityInstances(document.entities)) {
if (!entity.enabled || !entity.visible) {
continue;
}
const selected =
selection.kind === "entities" && selection.ids.includes(entity.id);
let renderObjects: EntityRenderObjects | null = null;
switch (entity.kind) {
case "npc": {
const runtimeNpc = runtimeNpcDefinitionsByEntityId.get(entity.id);
if (runtimeNpc !== undefined) {
if (!runtimeNpc.active || !runtimeNpc.visible) {
continue;
}
renderObjects = this.createNpcRenderObjects(
{
...entity,
position: runtimeNpc.position,
yawDegrees: runtimeNpc.yawDegrees
},
selected
);
break;
}
renderObjects = this.createEntityRenderObjects(entity, selected);
break;
}
case "interactable": {
const runtimeInteractable =
runtimeInteractablesByEntityId.get(entity.id) ?? null;
renderObjects = this.createInteractableRenderObjects(
entity.id,
entity.position,
entity.radius,
selected,
runtimeInteractable?.interactionEnabled ?? entity.interactionEnabled
);
break;
}
default:
renderObjects = this.createEntityRenderObjects(entity, selected);
break;
}
if (this.displayMode === "wireframe") {
this.applyWireframePresentation(renderObjects.group);
}
applyRendererRenderCategory(renderObjects.group, "overlay");
this.entityGroup.add(renderObjects.group);
this.entityRenderObjects.set(entity.id, renderObjects);
}
this.applyShadowState();
}
private rebuildModelInstances(
document: SceneDocument,
selection: EditorSelection
) {
this.clearModelInstances();
const runtimeModelInstances =
this.currentSimulationScene?.modelInstances ?? null;
const authoredModelInstancesById = new Map(
getModelInstances(document.modelInstances).map((modelInstance) => [
modelInstance.id,
modelInstance
])
);
const displayedModelInstances =
runtimeModelInstances?.map((runtimeModelInstance) => {
const authoredModelInstance = authoredModelInstancesById.get(
runtimeModelInstance.instanceId
);
return createModelInstance({
id: runtimeModelInstance.instanceId,
assetId: runtimeModelInstance.assetId,
name: runtimeModelInstance.name ?? authoredModelInstance?.name,
enabled: authoredModelInstance?.enabled ?? true,
visible: runtimeModelInstance.visible,
position: runtimeModelInstance.position,
rotationDegrees: runtimeModelInstance.rotationDegrees,
scale: runtimeModelInstance.scale,
collision: authoredModelInstance?.collision ?? {
mode: "none",
visible: false
},
animationClipName: runtimeModelInstance.animationClipName,
animationAutoplay: runtimeModelInstance.animationAutoplay
});
}) ?? getModelInstances(document.modelInstances);
for (const modelInstance of displayedModelInstances) {
if (!modelInstance.enabled || !modelInstance.visible) {
continue;
}
const selected = isModelInstanceSelected(selection, modelInstance.id);
const asset = this.projectAssets[modelInstance.assetId];
const loadedAsset = this.loadedModelAssets[modelInstance.assetId];
const renderGroup = createModelInstanceRenderGroup(
modelInstance,
asset,
loadedAsset,
selected,
undefined,
this.displayMode === "wireframe" ? "wireframe" : "normal"
);
applyRendererRenderCategoryFromMaterial(renderGroup);
if (asset?.kind === "model" && modelInstance.collision.visible) {
try {
const generatedCollider = buildGeneratedModelCollider(
modelInstance,
asset,
loadedAsset
);
if (generatedCollider !== null) {
const colliderDebugGroup =
createModelColliderDebugGroup(generatedCollider);
applyRendererRenderCategory(colliderDebugGroup, "overlay");
renderGroup.add(colliderDebugGroup);
}
} catch {
// Validation surfaces unsupported collider modes; the viewport keeps rendering the model.
}
}
this.modelGroup.add(renderGroup);
this.modelRenderObjects.set(modelInstance.id, renderGroup);
}
this.applyShadowState();
}
private createEntityRenderObjects(
entity: EntityInstance,
selected: boolean
): EntityRenderObjects {
switch (entity.kind) {
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
);
case "cameraRig":
return this.createCameraRigRenderObjects(
entity,
selected,
this.currentDocument
);
case "playerStart":
return this.createPlayerStartRenderObjects(
entity.id,
entity.position,
entity.yawDegrees,
entity.collider,
selected
);
case "sceneEntry":
return this.createTeleportTargetRenderObjects(
entity.id,
entity.position,
entity.yawDegrees,
selected,
selected ? SCENE_ENTRY_SELECTED_COLOR : SCENE_ENTRY_COLOR
);
case "soundEmitter":
return this.createSoundEmitterRenderObjects(
entity.id,
entity.position,
entity.refDistance,
entity.maxDistance,
selected
);
case "npc":
return this.createNpcRenderObjects(entity, selected);
case "triggerVolume":
return this.createTriggerVolumeRenderObjects(
entity.id,
entity.position,
entity.size,
selected
);
case "teleportTarget":
return this.createTeleportTargetRenderObjects(
entity.id,
entity.position,
entity.yawDegrees,
selected
);
case "interactable":
return this.createInteractableRenderObjects(
entity.id,
entity.position,
entity.radius,
selected,
entity.interactionEnabled
);
}
}
private tagEntityMesh(
mesh: Mesh,
entityId: string,
entityKind: EntityInstance["kind"],
group: Group
) {
mesh.userData.entityId = entityId;
mesh.userData.entityKind = entityKind;
group.add(mesh);
}
private tagEntityGroup(
group: Group,
entityId: string,
entityKind: EntityInstance["kind"]
): Mesh[] {
const meshes: Mesh[] = [];
group.traverse((object) => {
if (!(object instanceof Mesh)) {
return;
}
object.userData.entityId = entityId;
object.userData.entityKind = entityKind;
meshes.push(object);
});
return meshes;
}
private createPointLightGizmoRenderObjects(
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
})
);
range.userData.nonPickable = true;
for (const mesh of [core, range]) {
this.tagEntityMesh(mesh, entityId, "pointLight", group);
}
return {
group,
meshes: [core, range]
};
}
private createSpotLightGizmoRenderObjects(
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;
cone.userData.nonPickable = true;
for (const mesh of [core, cone]) {
this.tagEntityMesh(mesh, entityId, "spotLight", group);
}
return {
group,
meshes: [core, cone]
};
}
private createSpotLightRuntimeObjects(
entity: Pick<
SpotLightEntity,
| "position"
| "direction"
| "colorHex"
| "intensity"
| "distance"
| "angleDegrees"
>
): LocalLightRenderObjects {
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);
enableObjectForAllRendererRenderCategories(group);
return {
group,
light
};
}
private createLightVolumeRenderObjects(lightVolume: {
brushId: string;
enabled: boolean;
center: Vec3;
rotationDegrees: Vec3;
size: Vec3;
colorHex: string;
lights: Array<{
localPosition: Vec3;
intensity: number;
distance: number;
decay: number;
}>;
}): LightVolumeRenderObjects {
const group = new Group();
const lights: PointLight[] = [];
group.position.set(
lightVolume.center.x,
lightVolume.center.y,
lightVolume.center.z
);
group.rotation.set(
(lightVolume.rotationDegrees.x * Math.PI) / 180,
(lightVolume.rotationDegrees.y * Math.PI) / 180,
(lightVolume.rotationDegrees.z * Math.PI) / 180
);
group.visible = lightVolume.enabled;
for (const derivedLight of lightVolume.lights) {
const light = new PointLight(
lightVolume.colorHex,
derivedLight.intensity,
derivedLight.distance,
derivedLight.decay
);
light.castShadow = false;
light.shadow.autoUpdate = false;
light.position.set(
derivedLight.localPosition.x,
derivedLight.localPosition.y,
derivedLight.localPosition.z
);
group.add(light);
lights.push(light);
}
enableObjectForAllRendererRenderCategories(group);
return {
group,
lights
};
}
private createPointLightRuntimeObjects(
entity: Pick<
PointLightEntity,
"position" | "colorHex" | "intensity" | "distance"
>
): LocalLightRenderObjects {
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);
enableObjectForAllRendererRenderCategories(group);
return {
group,
light
};
}
private createCameraRigRenderObjects(
entity: CameraRigEntity,
selected: boolean,
document: SceneDocument | null,
markerColor = selected ? CAMERA_RIG_SELECTED_COLOR : CAMERA_RIG_COLOR
): EntityRenderObjects {
const group = new Group();
this.applyCameraRigGroupTransform(group, entity, document);
const bodyMaterial = new MeshStandardMaterial({
color: markerColor,
emissive: markerColor,
emissiveIntensity: selected ? 0.18 : 0.08,
roughness: 0.34,
metalness: 0.04
});
const frustumMaterial = new MeshStandardMaterial({
color: markerColor,
emissive: markerColor,
emissiveIntensity: selected ? 0.08 : 0.03,
roughness: 0.85,
metalness: 0,
transparent: true,
opacity: selected ? 0.2 : 0.1,
wireframe: true
});
const body = new Mesh(new BoxGeometry(0.26, 0.16, 0.2), bodyMaterial);
body.position.set(0, 0, -0.12);
const lens = new Mesh(
new ConeGeometry(0.2, 0.45, 16, 1, true),
frustumMaterial
);
lens.rotation.x = -Math.PI * 0.5;
lens.position.set(0, 0, -0.38);
lens.userData.nonPickable = true;
for (const mesh of [body, lens]) {
this.tagEntityMesh(mesh, entity.id, "cameraRig", group);
}
let dispose: (() => void) | undefined;
if (selected && entity.rigType === "rail") {
const previewGroup = new Group();
previewGroup.visible = false;
previewGroup.userData.nonPickable = true;
const trackLine = new Line(
new BufferGeometry().setFromPoints([new Vector3(), new Vector3()]),
new LineBasicMaterial({
color: CAMERA_RIG_SELECTED_COLOR
})
);
const railSpanLine = new Line(
new BufferGeometry().setFromPoints([new Vector3(), new Vector3()]),
new LineBasicMaterial({
color: PATH_SELECTED_COLOR
})
);
const trackStartMesh = new Mesh(
new SphereGeometry(PATH_POINT_RADIUS * 0.7, 10, 10),
new MeshBasicMaterial({
color: CAMERA_RIG_SELECTED_COLOR
})
);
const trackEndMesh = new Mesh(
new SphereGeometry(PATH_POINT_RADIUS * 0.7, 10, 10),
new MeshBasicMaterial({
color: CAMERA_RIG_SELECTED_COLOR
})
);
const railStartMesh = new Mesh(
new SphereGeometry(PATH_POINT_RADIUS * 0.7, 10, 10),
new MeshBasicMaterial({
color: PATH_SELECTED_COLOR
})
);
const railEndMesh = new Mesh(
new SphereGeometry(PATH_POINT_RADIUS * 0.7, 10, 10),
new MeshBasicMaterial({
color: PATH_SELECTED_COLOR
})
);
for (const object of [
trackLine,
railSpanLine,
trackStartMesh,
trackEndMesh,
railStartMesh,
railEndMesh
]) {
object.userData.nonPickable = true;
previewGroup.add(object);
}
group.add(previewGroup);
group.userData.cameraRigPreview = {
previewGroup,
trackLine,
trackStartMesh,
trackEndMesh,
railSpanLine,
railStartMesh,
railEndMesh
} satisfies CameraRigPreviewRenderObjects;
this.applyCameraRigGroupTransform(group, entity, document);
dispose = () => {
for (const mesh of [
body,
lens,
trackStartMesh,
trackEndMesh,
railStartMesh,
railEndMesh
]) {
mesh.geometry.dispose();
mesh.material.dispose();
}
for (const line of [trackLine, railSpanLine]) {
line.geometry.dispose();
line.material.dispose();
}
};
}
return {
group,
meshes: [body, lens],
dispose
};
}
private createPlayerStartRenderObjects(
entityId: string,
position: Vec3,
yawDegrees: number,
collider: PlayerStartEntity["collider"],
selected: boolean
): EntityRenderObjects {
const markerColor = selected
? PLAYER_START_SELECTED_COLOR
: PLAYER_START_COLOR;
const group = new Group();
group.position.set(position.x, position.y, position.z);
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[] = [];
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;
}
const directionGroup = new Group();
directionGroup.rotation.y = (yawDegrees * Math.PI) / 180;
group.add(directionGroup);
const colliderTop = getPlayerStartColliderHeight(collider) ?? 0.18;
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);
arrowHead.rotation.x = Math.PI * 0.5;
arrowHead.position.set(0, colliderTop + 0.12, 0.28);
for (const mesh of [body, arrowHead]) {
this.tagEntityMesh(mesh, entityId, "playerStart", directionGroup);
meshes.push(mesh);
}
return {
group,
meshes
};
}
private createSoundEmitterRenderObjects(
entityId: string,
position: Vec3,
refDistance: number,
maxDistance: number,
selected: boolean,
markerColor = selected ? SOUND_EMITTER_SELECTED_COLOR : SOUND_EMITTER_COLOR
): EntityRenderObjects {
const displayRefDistance = Math.max(0.4, refDistance);
const displayMaxDistance = Math.max(displayRefDistance, maxDistance);
const group = new Group();
group.position.set(position.x, position.y, position.z);
const speakerMeshes = createSoundEmitterMarkerMeshes(markerColor, selected);
const refDistanceShell = new Mesh(
new SphereGeometry(displayRefDistance, 16, 12),
new MeshStandardMaterial({
color: markerColor,
emissive: markerColor,
emissiveIntensity: selected ? 0.1 : 0.03,
roughness: 0.8,
metalness: 0,
transparent: true,
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,
wireframe: true
})
);
maxDistanceShell.userData.nonPickable = true;
for (const mesh of [...speakerMeshes, refDistanceShell, maxDistanceShell]) {
this.tagEntityMesh(mesh, entityId, "soundEmitter", group);
}
return {
group,
meshes: [...speakerMeshes, refDistanceShell, maxDistanceShell]
};
}
private createNpcColliderRenderObjects(
entityId: string,
position: Vec3,
yawDegrees: number,
collider: NpcEntity["collider"],
selected: boolean,
markerColor = selected ? NPC_SELECTED_COLOR : NPC_COLOR
): EntityRenderObjects {
const group = new Group();
group.position.set(position.x, position.y, position.z);
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.46 : 0.28
});
const facingMaterial = new MeshStandardMaterial({
color: markerColor,
emissive: markerColor,
emissiveIntensity: selected ? 0.22 : 0.08,
roughness: 0.4,
metalness: 0.03
});
const meshes: Mesh[] = [];
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, "npc", 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, "npc", group);
meshes.push(collisionMesh);
break;
}
case "none":
break;
}
const facingGroup = new Group();
facingGroup.rotation.y = (yawDegrees * Math.PI) / 180;
group.add(facingGroup);
const colliderTop = getNpcColliderHeight(collider) ?? 0.18;
const body = new Mesh(new BoxGeometry(0.08, 0.08, 0.34), facingMaterial);
body.position.set(0, colliderTop + 0.12, 0.06);
const arrowHead = new Mesh(new ConeGeometry(0.1, 0.22, 14), facingMaterial);
arrowHead.rotation.x = Math.PI * 0.5;
arrowHead.position.set(0, colliderTop + 0.12, 0.28);
for (const mesh of [body, arrowHead]) {
this.tagEntityMesh(mesh, entityId, "npc", facingGroup);
meshes.push(mesh);
}
return {
group,
meshes
};
}
private createNpcRenderObjects(
entity: NpcEntity,
selected: boolean,
previewShellColor?: number
): EntityRenderObjects {
const asset =
entity.modelAssetId === null
? null
: (this.projectAssets[entity.modelAssetId] ?? null);
if (entity.modelAssetId !== null && asset?.kind === "model") {
const loadedAsset = this.loadedModelAssets[entity.modelAssetId];
const renderGroup = createModelInstanceRenderGroup(
{
id: entity.id,
kind: "modelInstance",
assetId: entity.modelAssetId,
name: entity.name,
visible: entity.visible,
enabled: entity.enabled,
position: entity.position,
rotationDegrees: {
x: 0,
y: entity.yawDegrees,
z: 0
},
scale: {
x: 1,
y: 1,
z: 1
},
collision: {
mode: "none",
visible: false
}
},
asset,
loadedAsset,
selected,
previewShellColor,
this.displayMode === "wireframe" ? "wireframe" : "normal"
);
return {
group: renderGroup,
meshes: this.tagEntityGroup(renderGroup, entity.id, "npc"),
dispose: () => {
disposeModelInstance(renderGroup);
}
};
}
return this.createNpcColliderRenderObjects(
entity.id,
entity.position,
entity.yawDegrees,
entity.collider,
selected,
previewShellColor ?? (selected ? NPC_SELECTED_COLOR : NPC_COLOR)
);
}
private createTriggerVolumeRenderObjects(
entityId: string,
position: Vec3,
size: Vec3,
selected: boolean,
markerColor = selected
? TRIGGER_VOLUME_SELECTED_COLOR
: TRIGGER_VOLUME_COLOR
): EntityRenderObjects {
const group = new Group();
group.position.set(position.x, position.y, position.z);
const fill = new Mesh(
new BoxGeometry(size.x, size.y, size.z),
new MeshStandardMaterial({
color: markerColor,
emissive: markerColor,
emissiveIntensity: selected ? 0.1 : 0.03,
roughness: 0.7,
metalness: 0,
transparent: true,
opacity: selected ? 0.2 : 0.1
})
);
const outline = new Mesh(
new BoxGeometry(size.x, size.y, size.z),
new MeshStandardMaterial({
color: markerColor,
emissive: markerColor,
emissiveIntensity: selected ? 0.12 : 0.04,
roughness: 0.9,
metalness: 0,
wireframe: true,
transparent: true,
opacity: 0.95
})
);
for (const mesh of [fill, outline]) {
this.tagEntityMesh(mesh, entityId, "triggerVolume", group);
}
return {
group,
meshes: [fill, outline]
};
}
private createTeleportTargetRenderObjects(
entityId: string,
position: Vec3,
yawDegrees: number,
selected: boolean,
markerColor = selected
? TELEPORT_TARGET_SELECTED_COLOR
: TELEPORT_TARGET_COLOR
): EntityRenderObjects {
const group = new Group();
group.position.set(position.x, position.y, position.z);
group.rotation.y = (yawDegrees * Math.PI) / 180;
const ring = new Mesh(
new TorusGeometry(0.28, 0.045, 8, 24),
new MeshStandardMaterial({
color: markerColor,
emissive: markerColor,
emissiveIntensity: selected ? 0.18 : 0.08,
roughness: 0.42,
metalness: 0.04
})
);
ring.rotation.x = Math.PI * 0.5;
ring.position.y = 0.035;
const stem = new Mesh(
new CylinderGeometry(0.04, 0.04, 0.3, 12),
new MeshStandardMaterial({
color: markerColor,
emissive: markerColor,
emissiveIntensity: selected ? 0.12 : 0.04,
roughness: 0.45,
metalness: 0.02
})
);
stem.position.y = 0.15;
const arrowHead = new Mesh(
new ConeGeometry(0.12, 0.24, 14),
new MeshStandardMaterial({
color: markerColor,
emissive: markerColor,
emissiveIntensity: selected ? 0.18 : 0.06,
roughness: 0.36,
metalness: 0.03
})
);
arrowHead.rotation.x = Math.PI * 0.5;
arrowHead.position.set(0, 0.15, 0.34);
for (const mesh of [ring, stem, arrowHead]) {
this.tagEntityMesh(mesh, entityId, "teleportTarget", group);
}
return {
group,
meshes: [ring, stem, arrowHead]
};
}
private createInteractableRenderObjects(
entityId: string,
position: Vec3,
radius: number,
selected: boolean,
interactionEnabled = true,
markerColor = selected ? INTERACTABLE_SELECTED_COLOR : INTERACTABLE_COLOR
): EntityRenderObjects {
const displayRadius = Math.max(0.45, radius);
const group = new Group();
group.position.set(position.x, position.y, position.z);
const inactiveOpacity = selected ? 0.72 : 0.42;
const core = new Mesh(
new SphereGeometry(0.16, 12, 10),
new MeshStandardMaterial({
color: markerColor,
emissive: markerColor,
emissiveIntensity: selected ? 0.18 : interactionEnabled ? 0.08 : 0.025,
roughness: 0.34,
metalness: 0.04,
transparent: !interactionEnabled,
opacity: interactionEnabled ? 1 : inactiveOpacity
})
);
const radiusRing = new Mesh(
new TorusGeometry(displayRadius, 0.03, 8, 32),
new MeshStandardMaterial({
color: markerColor,
emissive: markerColor,
emissiveIntensity: selected ? 0.1 : interactionEnabled ? 0.04 : 0.015,
roughness: 0.55,
metalness: 0.02,
transparent: !interactionEnabled,
opacity: interactionEnabled ? 1 : 0.32
})
);
radiusRing.rotation.x = Math.PI * 0.5;
radiusRing.userData.nonPickable = true;
for (const mesh of [core, radiusRing]) {
this.tagEntityMesh(mesh, entityId, "interactable", group);
}
return {
group,
meshes: [core, radiusRing]
};
}
private emitWhiteboxHoverLabelChange() {
const label =
this.currentDocument === null
? null
: getWhiteboxSelectionFeedbackLabel(
this.currentDocument,
this.hoveredSelection
);
this.whiteboxHoverLabelChangeHandler?.(label);
}
private refreshSelectionPresentation() {
if (this.currentDocument === null) {
return;
}
this.refreshBrushPresentation();
this.refreshTerrainPresentation();
this.rebuildPaths(this.currentDocument, this.currentSelection);
this.rebuildEntityMarkers(this.currentDocument, this.currentSelection);
this.rebuildModelInstances(this.currentDocument, this.currentSelection);
this.applyTransformPreview();
this.syncTransformGizmo();
this.syncTerrainBrushPreview();
}
private setHoveredSelection(selection: EditorSelection) {
if (areEditorSelectionsEqual(this.hoveredSelection, selection)) {
return;
}
this.hoveredSelection = selection;
this.refreshBrushPresentation();
this.refreshTerrainPresentation();
this.refreshPathPresentation();
this.emitWhiteboxHoverLabelChange();
}
private getFaceHighlightState(
brushId: string,
faceId: WhiteboxFaceId
): "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";
}
private getMaterialSwatchColorHex(
material: MaterialDef,
highlightState: "none" | "hovered" | "selected"
): number {
const swatchColor = new Color(material.swatchColorHex);
if (highlightState === "selected") {
swatchColor.lerp(new Color(SELECTED_FACE_FALLBACK_COLOR), 0.42);
} else if (highlightState === "hovered") {
swatchColor.lerp(new Color(HOVERED_FACE_FALLBACK_COLOR), 0.28);
}
return swatchColor.getHex();
}
private createFaceMaterial(
brush: Brush,
faceId: WhiteboxFaceId,
material: MaterialDef | undefined,
highlightState: "none" | "hovered" | "selected",
volumeRenderPaths: {
fog: "performance" | "quality";
water: "performance" | "quality";
},
contactPatches: ReturnType<typeof collectWaterContactPatches>
): Material {
const face = brush.faces[faceId];
const selectedFace = highlightState === "selected";
const hoveredFace = highlightState === "hovered";
const whiteboxBevelSettings = this.currentWorld?.advancedRendering;
if (brush.volume.mode === "water") {
if (brush.kind !== "box") {
throw new Error(
`Only whitebox boxes support water volume rendering (${brush.id}).`
);
}
const quality = volumeRenderPaths.water === "quality";
const baseOpacity = Math.max(
0.08,
Math.min(1, brush.volume.water.surfaceOpacity)
);
const isTopFace = faceId === "posY";
const opacityBoost = isTopFace ? 0.16 : 0;
const opacity = Math.min(
1,
baseOpacity +
opacityBoost +
(selectedFace ? 0.08 : hoveredFace ? 0.04 : 0)
);
const waterMaterial = createWaterMaterial({
colorHex: brush.volume.water.colorHex,
surfaceOpacity: brush.volume.water.surfaceOpacity,
waveStrength: brush.volume.water.waveStrength,
surfaceDisplacementEnabled:
brush.volume.water.surfaceDisplacementEnabled,
opacity,
quality,
wireframe: this.displayMode === "wireframe",
isTopFace,
time: this.volumeTime,
halfSize: {
x: brush.size.x * 0.5,
z: brush.size.z * 0.5
},
contactPatches,
reflection: {
texture: null,
enabled: isTopFace
}
});
if (waterMaterial.animationUniform !== null) {
this.volumeAnimatedUniforms.push(waterMaterial.animationUniform);
}
if (
isTopFace &&
waterMaterial.reflectionMatrixUniform !== null &&
waterMaterial.reflectionEnabledUniform !== null
) {
const preservedReflectionRenderTarget =
this.claimPreservedViewportWaterReflectionTarget(brush.id);
this.viewportWaterSurfaceBindings.push({
brush,
reflectionTextureUniform: waterMaterial.reflectionTextureUniform,
reflectionMatrixUniform: waterMaterial.reflectionMatrixUniform,
reflectionEnabledUniform: waterMaterial.reflectionEnabledUniform,
reflectionRenderTarget:
preservedReflectionRenderTarget ??
(this.getWaterReflectionMode() !== "none"
? this.createWaterReflectionRenderTarget()
: null),
lastReflectionUpdateTime: Number.NEGATIVE_INFINITY
});
}
return waterMaterial.material;
}
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
});
}
if (quality) {
const fogMaterial = createFogQualityMaterial({
colorHex: brush.volume.fog.colorHex,
density:
brush.volume.fog.density *
(selectedFace ? 1.12 : hoveredFace ? 1.06 : 1),
padding: brush.volume.fog.padding,
time: this.volumeTime,
halfSize: {
x: brush.size.x * 0.5,
y: brush.size.y * 0.5,
z: brush.size.z * 0.5
},
opacityMultiplier: selectedFace ? 1.12 : hoveredFace ? 1.06 : 1,
colorLift: selectedFace ? 0.08 : hoveredFace ? 0.04 : 0
});
this.volumeAnimatedUniforms.push(fogMaterial.animationUniform);
return fogMaterial.material;
}
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
});
}
if (brush.volume.mode === "light") {
const baseOpacity = this.displayMode === "authoring" ? 0.03 : 0;
const opacity =
baseOpacity + (selectedFace ? 0.14 : hoveredFace ? 0.08 : 0);
const lightMaterial = new MeshBasicMaterial({
color: brush.volume.light.colorHex,
transparent: true,
opacity,
depthWrite: false,
wireframe: this.displayMode === "wireframe"
});
lightMaterial.colorWrite = opacity > 0;
return lightMaterial;
}
if (this.displayMode === "authoring") {
const colorHex =
material === undefined || face.materialId === null
? selectedFace
? SELECTED_FACE_FALLBACK_COLOR
: hoveredFace
? HOVERED_FACE_FALLBACK_COLOR
: FALLBACK_FACE_COLOR
: this.getMaterialSwatchColorHex(material, highlightState);
return new MeshBasicMaterial({
color: colorHex,
transparent: true,
opacity: selectedFace ? 0.36 : hoveredFace ? 0.28 : 0.18,
wireframe: false
});
}
if (this.displayMode === "wireframe") {
const colorHex =
material === undefined || face.materialId === null
? selectedFace
? SELECTED_FACE_FALLBACK_COLOR
: hoveredFace
? HOVERED_FACE_FALLBACK_COLOR
: FALLBACK_FACE_COLOR
: this.getMaterialSwatchColorHex(material, highlightState);
return new MeshBasicMaterial({
color: colorHex,
wireframe: true,
transparent: true,
opacity: selectedFace ? 0.95 : hoveredFace ? 0.86 : 0.76,
depthWrite: false
});
}
if (material === undefined || face.materialId === null) {
const faceMaterial = new MeshStandardMaterial({
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,
roughness: 0.9,
metalness: 0.05
});
if (
whiteboxBevelSettings !== undefined &&
shouldApplyWhiteboxBevel(whiteboxBevelSettings)
) {
applyWhiteboxBevelToMaterial(
faceMaterial,
whiteboxBevelSettings.whiteboxBevel
);
}
return faceMaterial;
}
const textureSet = this.getOrCreateTextureSet(material);
const faceMaterial = new MeshPhysicalMaterial({
color: 0xffffff,
map: textureSet.baseColor,
normalMap: textureSet.normal,
roughnessMap: textureSet.roughness,
emissive: selectedFace
? SELECTED_FACE_EMISSIVE
: hoveredFace
? HOVERED_FACE_EMISSIVE
: 0x000000,
emissiveIntensity: selectedFace ? 0.32 : hoveredFace ? 0.18 : 0,
roughness: 1,
metalnessMap: textureSet.metallic,
metalness: textureSet.metallic === null ? 0.03 : 1,
specularColorMap: textureSet.specular,
specularColor: new Color(0xffffff),
specularIntensity: textureSet.specular === null ? 0.2 : 1
});
if (
whiteboxBevelSettings !== undefined &&
shouldApplyWhiteboxBevel(whiteboxBevelSettings)
) {
applyWhiteboxBevelToMaterial(
faceMaterial,
whiteboxBevelSettings.whiteboxBevel
);
}
return faceMaterial;
}
private getWaterReflectionMode() {
if (
this.currentWorld === null ||
!this.currentWorld.advancedRendering.enabled ||
this.currentWorld.advancedRendering.waterPath !== "quality" ||
this.displayMode !== "normal" ||
this.viewMode !== "perspective"
) {
return "none" as const;
}
return this.currentWorld.advancedRendering.waterReflectionMode;
}
private createWaterReflectionRenderTarget() {
const canvasWidth =
this.container?.clientWidth ?? this.renderer.domElement.width;
const canvasHeight =
this.container?.clientHeight ?? this.renderer.domElement.height;
const width = Math.max(128, Math.round(Math.max(canvasWidth, 512) * 0.5));
const height = Math.max(128, Math.round(Math.max(canvasHeight, 512) * 0.5));
return new WebGLRenderTarget(width, height);
}
private resizeWaterReflectionTargets() {
const canvasWidth =
this.container?.clientWidth ?? this.renderer.domElement.width;
const canvasHeight =
this.container?.clientHeight ?? this.renderer.domElement.height;
const width = Math.max(128, Math.round(Math.max(canvasWidth, 512) * 0.5));
const height = Math.max(128, Math.round(Math.max(canvasHeight, 512) * 0.5));
for (const binding of this.viewportWaterSurfaceBindings) {
binding.reflectionRenderTarget?.setSize(width, height);
binding.lastReflectionUpdateTime = Number.NEGATIVE_INFINITY;
}
}
private resetViewportWaterSurfaceBindings(preserveRenderTargets: boolean) {
const preservedReflectionTargets = new Map<
string,
WebGLRenderTarget | null
>();
this.volumeAnimatedUniforms.length = 0;
for (const binding of this.viewportWaterSurfaceBindings) {
if (
preserveRenderTargets &&
!preservedReflectionTargets.has(binding.brush.id)
) {
preservedReflectionTargets.set(
binding.brush.id,
binding.reflectionRenderTarget
);
continue;
}
binding.reflectionRenderTarget?.dispose();
}
this.viewportWaterSurfaceBindings.length = 0;
return preservedReflectionTargets;
}
private claimPreservedViewportWaterReflectionTarget(brushId: string) {
if (this.preservedViewportWaterReflectionTargets === null) {
return null;
}
const reflectionRenderTarget =
this.preservedViewportWaterReflectionTargets.get(brushId) ?? null;
this.preservedViewportWaterReflectionTargets.delete(brushId);
return reflectionRenderTarget;
}
private disposePreservedViewportWaterReflectionTargets() {
if (this.preservedViewportWaterReflectionTargets === null) {
return;
}
for (const reflectionRenderTarget of this.preservedViewportWaterReflectionTargets.values()) {
reflectionRenderTarget?.dispose();
}
this.preservedViewportWaterReflectionTargets = null;
}
private updateViewportWaterReflections() {
const activeCamera = this.getActiveCamera();
if (!(activeCamera instanceof PerspectiveCamera)) {
for (const binding of this.viewportWaterSurfaceBindings) {
if (binding.reflectionEnabledUniform !== null) {
binding.reflectionEnabledUniform.value = 0;
}
}
return;
}
const reflectionMode = this.getWaterReflectionMode();
const now = performance.now();
for (const binding of this.viewportWaterSurfaceBindings) {
if (
reflectionMode === "none" ||
binding.reflectionTextureUniform === null ||
binding.reflectionMatrixUniform === null ||
binding.reflectionEnabledUniform === null
) {
if (binding.reflectionEnabledUniform !== null) {
binding.reflectionEnabledUniform.value = 0;
}
continue;
}
if (binding.reflectionRenderTarget === null) {
binding.reflectionRenderTarget =
this.createWaterReflectionRenderTarget();
}
const canRenderReflection = updatePlanarReflectionCamera(
binding.brush,
activeCamera,
this.waterReflectionCamera,
binding.reflectionMatrixUniform.value
);
if (!canRenderReflection || binding.reflectionRenderTarget === null) {
binding.reflectionEnabledUniform.value = 0;
continue;
}
if (
binding.reflectionTextureUniform.value !== null &&
now - binding.lastReflectionUpdateTime <
WATER_REFLECTION_UPDATE_INTERVAL_MS
) {
binding.reflectionEnabledUniform.value = 0.36;
continue;
}
const hiddenObjects: Array<{ object: Object3D; visible: boolean }> = [];
const hiddenObjectSet = new Set<Object3D>();
const hideObject = (object: Object3D | null | undefined) => {
if (
object === null ||
object === undefined ||
hiddenObjectSet.has(object)
) {
return;
}
hiddenObjectSet.add(object);
hiddenObjects.push({ object, visible: object.visible });
object.visible = false;
};
for (const waterBinding of this.viewportWaterSurfaceBindings) {
const renderObjects = this.brushRenderObjects.get(
waterBinding.brush.id
);
if (renderObjects !== undefined) {
hideObject(renderObjects.mesh);
}
}
for (const renderObjects of this.brushRenderObjects.values()) {
hideObject(renderObjects.edges);
for (const edgeHelper of renderObjects.edgeHelpers) {
hideObject(edgeHelper.line);
}
for (const vertexHelper of renderObjects.vertexHelpers) {
hideObject(vertexHelper.mesh);
}
}
hideObject(this.axesHelper);
hideObject(this.gridHelpers.xz);
hideObject(this.gridHelpers.xy);
hideObject(this.gridHelpers.yz);
hideObject(this.entityGroup);
hideObject(this.transformGizmoGroup);
hideObject(this.boxCreatePreviewMesh);
hideObject(this.boxCreatePreviewEdges);
hideObject(this.creationPreviewObject);
if (reflectionMode === "world") {
hideObject(this.modelGroup);
}
const previousAutoClear = this.renderer.autoClear;
const previousRenderTarget = this.renderer.getRenderTarget();
const previousReflectionStates = this.viewportWaterSurfaceBindings.map(
(waterBinding) => ({
binding: waterBinding,
enabled: waterBinding.reflectionEnabledUniform?.value ?? 0,
texture: waterBinding.reflectionTextureUniform?.value ?? null
})
);
try {
for (const state of previousReflectionStates) {
if (state.binding.reflectionEnabledUniform !== null) {
state.binding.reflectionEnabledUniform.value = 0;
}
}
binding.reflectionTextureUniform.value = null;
this.renderer.autoClear = true;
this.renderer.setRenderTarget(binding.reflectionRenderTarget);
this.renderer.clear();
this.renderer.render(this.scene, this.waterReflectionCamera);
} finally {
this.renderer.setRenderTarget(previousRenderTarget);
this.renderer.autoClear = previousAutoClear;
for (const state of previousReflectionStates) {
if (state.binding.reflectionEnabledUniform !== null) {
state.binding.reflectionEnabledUniform.value = state.enabled;
}
if (state.binding.reflectionTextureUniform !== null) {
state.binding.reflectionTextureUniform.value = state.texture;
}
}
for (const hiddenObject of hiddenObjects) {
hiddenObject.object.visible = hiddenObject.visible;
}
}
binding.reflectionTextureUniform.value =
binding.reflectionRenderTarget.texture;
binding.reflectionEnabledUniform.value = 0.36;
binding.lastReflectionUpdateTime = now;
}
}
private getOrCreateTextureSet(material: MaterialDef) {
const signature = createStarterMaterialSignature(material);
const cachedTexture = this.materialTextureCache.get(material.id);
if (cachedTexture !== undefined && cachedTexture.signature === signature) {
return cachedTexture.textureSet;
}
if (cachedTexture !== undefined) {
disposeStarterMaterialTextureSet(cachedTexture.textureSet);
}
const textureSet = createStarterMaterialTextureSet(
material,
this.materialTextureLoader
);
this.materialTextureCache.set(material.id, {
signature,
textureSet
});
return textureSet;
}
private collectViewportWaterContactPatches(
document: SceneDocument,
waterBrush: BoxBrush
) {
const contactBounds: Parameters<typeof collectWaterContactPatches>[1] = [];
for (const brush of Object.values(document.brushes)) {
if (brush.id === waterBrush.id || brush.volume.mode !== "none") {
continue;
}
const derivedMesh = buildBoxBrushDerivedMeshData(brush);
contactBounds.push({
kind: "triangleMesh",
vertices: derivedMesh.colliderVertices,
indices: derivedMesh.colliderIndices,
transform: {
position: brush.center,
rotationDegrees: brush.rotationDegrees,
scale: {
x: 1,
y: 1,
z: 1
}
}
});
}
for (const terrain of getTerrains(document.terrains)) {
if (!terrain.enabled || !terrain.visible) {
continue;
}
const derivedMesh = buildTerrainDerivedMeshData(terrain);
contactBounds.push({
kind: "triangleMesh",
vertices: derivedMesh.positions,
indices: derivedMesh.indices,
mergeProfile: "aggressive",
transform: {
position: terrain.position,
rotationDegrees: {
x: 0,
y: 0,
z: 0
},
scale: {
x: 1,
y: 1,
z: 1
}
}
});
}
for (const modelInstance of getModelInstances(document.modelInstances)) {
if (modelInstance.collision.mode === "none") {
continue;
}
const asset = this.projectAssets[modelInstance.assetId];
if (asset?.kind !== "model") {
continue;
}
try {
const generatedCollider = buildGeneratedModelCollider(
modelInstance,
asset,
this.loadedModelAssets[modelInstance.assetId]
);
if (generatedCollider !== null) {
if (generatedCollider.kind === "trimesh") {
contactBounds.push({
kind: "triangleMesh",
vertices: generatedCollider.vertices,
indices: generatedCollider.indices,
mergeProfile: "aggressive",
transform: generatedCollider.transform
});
} else {
contactBounds.push(generatedCollider.worldBounds);
}
}
} catch {
// Validation already surfaces unsupported collider modes; the viewport keeps rendering.
}
}
return collectWaterContactPatches(
{
center: waterBrush.center,
rotationDegrees: waterBrush.rotationDegrees,
size: waterBrush.size
},
contactBounds,
this.getViewportWaterFoamContactLimit(waterBrush)
);
}
private getViewportWaterFoamContactLimit(brush: BoxBrush) {
return brush.volume.mode === "water"
? brush.volume.water.foamContactLimit
: 0;
}
private createEdgeHelper(
brush: Brush,
edgeId: WhiteboxEdgeId
): { id: WhiteboxEdgeId; line: Line<BufferGeometry, LineBasicMaterial> } {
const segment = getBrushEdgeWorldSegment(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;
applyRendererRenderCategory(line, "overlay");
return {
id: edgeId,
line
};
}
private createVertexHelper(
brush: Brush,
vertexId: WhiteboxVertexId
): { id: WhiteboxVertexId; mesh: Mesh<SphereGeometry, MeshBasicMaterial> } {
const position = getBrushVertexWorldPosition(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;
applyRendererRenderCategory(mesh, "overlay");
return {
id: vertexId,
mesh
};
}
private refreshBrushPresentation() {
if (this.currentDocument === null) {
return;
}
const volumeRenderPaths = resolveBoxVolumeRenderPaths(
this.currentDocument.world.advancedRendering
);
this.preservedViewportWaterReflectionTargets =
this.resetViewportWaterSurfaceBindings(true);
try {
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;
const contactPatches =
brush.kind === "box" && brush.volume.mode === "water"
? this.collectViewportWaterContactPatches(
this.currentDocument,
brush
)
: [];
renderObjects.mesh.material =
this.createFogMaterialSet(brush, volumeRenderPaths) ??
renderObjects.faceIdsInOrder.map((faceId) =>
this.createFaceMaterial(
brush,
faceId,
this.currentDocument?.materials[
brush.faces[faceId].materialId ?? ""
],
this.getFaceHighlightState(brush.id, faceId),
volumeRenderPaths,
contactPatches
)
);
this.configureFogVolumeMesh(
renderObjects.mesh,
renderObjects.mesh.material
);
applyRendererRenderCategoryFromMaterial(renderObjects.mesh);
this.disposeUniqueMaterials(previousMaterials);
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;
}
}
} finally {
this.disposePreservedViewportWaterReflectionTargets();
}
}
private refreshTerrainPresentation() {
if (this.currentDocument === null) {
return;
}
for (const terrain of Object.values(this.currentDocument.terrains)) {
const renderObjects = this.terrainRenderObjects.get(terrain.id);
if (renderObjects === undefined) {
continue;
}
const displayedTerrain =
this.getDisplayedTerrainState(terrain.id) ?? terrain;
const previousMaterial = renderObjects.mesh.material;
renderObjects.mesh.material =
this.createTerrainMaterial(displayedTerrain);
previousMaterial.dispose();
}
}
private getDisplayedTerrainState(terrainId: string): Terrain | null {
if (
this.activeTerrainBrushStroke !== null &&
this.activeTerrainBrushStroke.previewTerrain.id === terrainId
) {
return this.activeTerrainBrushStroke.previewTerrain;
}
return this.currentDocument?.terrains[terrainId] ?? null;
}
private rebuildDisplayedTerrainState() {
if (this.currentDocument === null) {
return;
}
this.rebuildTerrains(
this.currentDocument,
this.currentSelection,
this.currentActiveSelectionId
);
}
private isTerrainBrushActive(): boolean {
return (
this.toolMode === "select" &&
this.currentTerrainBrushState !== null &&
this.currentDocument !== null &&
this.currentSelection.kind === "terrains" &&
this.currentSelection.ids.length === 1 &&
this.currentSelection.ids[0] === this.currentTerrainBrushState.terrainId
);
}
private getTerrainBrushPreviewColor(
brushState: ArmedTerrainBrushState
): number {
switch (brushState.tool) {
case "raise":
return TERRAIN_BRUSH_PREVIEW_RAISE_COLOR;
case "lower":
return TERRAIN_BRUSH_PREVIEW_LOWER_COLOR;
case "smooth":
return TERRAIN_BRUSH_PREVIEW_SMOOTH_COLOR;
case "flatten":
return TERRAIN_BRUSH_PREVIEW_FLATTEN_COLOR;
case "paint": {
const terrain =
this.getDisplayedTerrainState(brushState.terrainId) ??
this.currentDocument?.terrains[brushState.terrainId] ??
null;
if (terrain === null) {
return TERRAIN_BRUSH_PREVIEW_PAINT_COLOR;
}
const terrainLayer = terrain.layers[brushState.layerIndex] ?? null;
const terrainMaterial =
terrainLayer === null
? null
: this.resolveTerrainLayerMaterial(terrainLayer.materialId);
return getTerrainLayerPreviewColor(terrainMaterial);
}
}
}
private setTerrainBrushHover(hit: TerrainBrushHit | null) {
this.terrainBrushHover = hit;
this.syncTerrainBrushPreview();
}
private syncTerrainBrushPreview() {
if (
!this.isTerrainBrushActive() ||
this.currentTerrainBrushState === null ||
this.terrainBrushHover === null ||
this.terrainBrushHover.terrainId !==
this.currentTerrainBrushState.terrainId
) {
this.terrainBrushPreviewGroup.visible = false;
return;
}
const terrain = this.getDisplayedTerrainState(
this.terrainBrushHover.terrainId
);
if (terrain === null) {
this.terrainBrushPreviewGroup.visible = false;
return;
}
const previewPoints = createTerrainBrushPreviewPoints(
terrain,
{
x: this.terrainBrushHover.point.x,
z: this.terrainBrushHover.point.z
},
this.currentTerrainBrushState.radius,
40,
TERRAIN_BRUSH_PREVIEW_OFFSET
).map((point) => new Vector3(point.x, point.y, point.z));
if (previewPoints.length < 2) {
this.terrainBrushPreviewGroup.visible = false;
return;
}
const previousGeometry = this.terrainBrushPreviewLine.geometry;
this.terrainBrushPreviewLine.geometry = new BufferGeometry().setFromPoints(
previewPoints
);
previousGeometry.dispose();
const previewColor = this.getTerrainBrushPreviewColor(
this.currentTerrainBrushState
);
(this.terrainBrushPreviewLine.material as LineBasicMaterial).color.setHex(
previewColor
);
(this.terrainBrushPreviewCenter.material as MeshBasicMaterial).color.setHex(
previewColor
);
this.terrainBrushPreviewCenter.position.set(
this.terrainBrushHover.point.x,
this.terrainBrushHover.point.y + TERRAIN_BRUSH_PREVIEW_OFFSET,
this.terrainBrushHover.point.z
);
this.terrainBrushPreviewCenter.scale.setScalar(
Math.max(0.08, this.currentTerrainBrushState.radius * 0.04)
);
this.terrainBrushPreviewGroup.visible = true;
}
private extractTerrainIdFromObject(object: Object3D): string | null {
let current: Object3D | null = object;
while (current !== null) {
const terrainId = current.userData.terrainId;
if (typeof terrainId === "string") {
return terrainId;
}
current = current.parent;
}
return null;
}
private getTerrainBrushRaycastObjects(): Object3D[] {
const raycastObjects: Object3D[] = [];
for (const renderObjects of this.brushRenderObjects.values()) {
raycastObjects.push(renderObjects.mesh);
}
if (this.currentDocument !== null) {
for (const [entityId, renderObjects] of this.entityRenderObjects) {
const entity = this.currentDocument.entities[entityId];
if (entity?.kind !== "triggerVolume") {
continue;
}
raycastObjects.push(renderObjects.group);
}
}
for (const renderObjects of this.terrainRenderObjects.values()) {
raycastObjects.push(renderObjects.mesh);
}
for (const renderGroup of this.modelRenderObjects.values()) {
raycastObjects.push(renderGroup);
}
return raycastObjects;
}
private getTerrainBrushHitAtClientPosition(
clientX: number,
clientY: number
): TerrainBrushHit | null {
if (
!this.isTerrainBrushActive() ||
this.currentTerrainBrushState === null ||
!this.setPointerFromClientPosition(clientX, clientY)
) {
return null;
}
const raycastObjects = this.getTerrainBrushRaycastObjects();
if (raycastObjects.length === 0) {
return null;
}
this.raycaster.setFromCamera(this.pointer, this.getActiveCamera());
const hit = this.raycaster.intersectObjects(raycastObjects, true)[0];
if (hit === undefined) {
return null;
}
const terrainId = this.extractTerrainIdFromObject(hit.object);
if (terrainId !== this.currentTerrainBrushState.terrainId) {
return null;
}
return {
terrainId,
point: {
x: hit.point.x,
y: hit.point.y,
z: hit.point.z
}
};
}
private applyTerrainBrushPoint(
terrain: Terrain,
point: {
x: number;
z: number;
},
toolState: ArmedTerrainBrushState,
referenceHeight: number | null
): Terrain {
return applyTerrainBrushStamp({
terrain,
center: point,
settings: toolState,
tool: toolState.tool,
referenceHeight,
layerIndex: toolState.tool === "paint" ? toolState.layerIndex : null
});
}
private applyTerrainBrushSegment(
terrain: Terrain,
from: {
x: number;
z: number;
},
to: {
x: number;
z: number;
},
toolState: ArmedTerrainBrushState,
referenceHeight: number | null
): {
terrain: Terrain;
lastAppliedPoint: {
x: number;
z: number;
};
} {
const spacing = getTerrainBrushStrokeSpacing(terrain, toolState);
const deltaX = to.x - from.x;
const deltaZ = to.z - from.z;
const distance = Math.hypot(deltaX, deltaZ);
if (distance < spacing) {
return {
terrain,
lastAppliedPoint: from
};
}
let nextTerrain = terrain;
let lastAppliedPoint = from;
const stepCount = Math.floor(distance / spacing);
for (let stepIndex = 1; stepIndex <= stepCount; stepIndex += 1) {
const t = Math.min(1, (stepIndex * spacing) / distance);
const point = {
x: from.x + deltaX * t,
z: from.z + deltaZ * t
};
nextTerrain = this.applyTerrainBrushPoint(
nextTerrain,
point,
toolState,
referenceHeight
);
lastAppliedPoint = point;
}
return {
terrain: nextTerrain,
lastAppliedPoint
};
}
private beginTerrainBrushStroke(event: PointerEvent): boolean {
if (
!this.isTerrainBrushActive() ||
this.currentTerrainBrushState === null
) {
return false;
}
event.preventDefault();
const hit = this.getTerrainBrushHitAtClientPosition(
event.clientX,
event.clientY
);
this.setTerrainBrushHover(hit);
if (hit === null) {
return true;
}
const terrain = this.getDisplayedTerrainState(hit.terrainId);
if (terrain === null) {
return true;
}
const referenceHeight =
this.currentTerrainBrushState.tool === "flatten"
? hit.point.y - terrain.position.y
: null;
const previewTerrain = this.applyTerrainBrushPoint(
terrain,
{
x: hit.point.x,
z: hit.point.z
},
this.currentTerrainBrushState,
referenceHeight
);
this.activeTerrainBrushStroke = {
pointerId: event.pointerId,
previewTerrain,
referenceHeight,
lastAppliedPoint: {
x: hit.point.x,
z: hit.point.z
},
toolState: this.currentTerrainBrushState
};
this.renderer.domElement.setPointerCapture(event.pointerId);
this.rebuildDisplayedTerrainState();
return true;
}
private continueTerrainBrushStroke(event: PointerEvent): boolean {
if (
this.activeTerrainBrushStroke === null ||
this.activeTerrainBrushStroke.pointerId !== event.pointerId
) {
return false;
}
const hit = this.getTerrainBrushHitAtClientPosition(
event.clientX,
event.clientY
);
this.setTerrainBrushHover(hit);
if (hit === null) {
return true;
}
const segmentResult = this.applyTerrainBrushSegment(
this.activeTerrainBrushStroke.previewTerrain,
this.activeTerrainBrushStroke.lastAppliedPoint,
{
x: hit.point.x,
z: hit.point.z
},
this.activeTerrainBrushStroke.toolState,
this.activeTerrainBrushStroke.referenceHeight
);
if (
!areTerrainsEqual(
segmentResult.terrain,
this.activeTerrainBrushStroke.previewTerrain
) ||
segmentResult.lastAppliedPoint.x !==
this.activeTerrainBrushStroke.lastAppliedPoint.x ||
segmentResult.lastAppliedPoint.z !==
this.activeTerrainBrushStroke.lastAppliedPoint.z
) {
this.activeTerrainBrushStroke = {
...this.activeTerrainBrushStroke,
previewTerrain: segmentResult.terrain,
lastAppliedPoint: segmentResult.lastAppliedPoint
};
this.rebuildDisplayedTerrainState();
}
return true;
}
private cancelActiveTerrainBrushStroke(rebuildTerrain: boolean) {
this.terrainBrushPreviewGroup.visible = false;
if (this.activeTerrainBrushStroke === null) {
return;
}
this.activeTerrainBrushStroke = null;
if (rebuildTerrain) {
this.rebuildDisplayedTerrainState();
}
}
private finishTerrainBrushStroke(event: PointerEvent): boolean {
if (
this.activeTerrainBrushStroke === null ||
this.activeTerrainBrushStroke.pointerId !== event.pointerId
) {
return false;
}
if (this.renderer.domElement.hasPointerCapture(event.pointerId)) {
this.renderer.domElement.releasePointerCapture(event.pointerId);
}
const cancelled = event.type === "pointercancel";
let finalPreviewTerrain = this.activeTerrainBrushStroke.previewTerrain;
if (!cancelled) {
const hit = this.getTerrainBrushHitAtClientPosition(
event.clientX,
event.clientY
);
if (hit !== null) {
const segmentResult = this.applyTerrainBrushSegment(
finalPreviewTerrain,
this.activeTerrainBrushStroke.lastAppliedPoint,
{
x: hit.point.x,
z: hit.point.z
},
this.activeTerrainBrushStroke.toolState,
this.activeTerrainBrushStroke.referenceHeight
);
finalPreviewTerrain = segmentResult.terrain;
if (
segmentResult.lastAppliedPoint.x !== hit.point.x ||
segmentResult.lastAppliedPoint.z !== hit.point.z
) {
finalPreviewTerrain = this.applyTerrainBrushPoint(
finalPreviewTerrain,
{
x: hit.point.x,
z: hit.point.z
},
this.activeTerrainBrushStroke.toolState,
this.activeTerrainBrushStroke.referenceHeight
);
}
}
}
const baseTerrain =
this.currentDocument?.terrains[
this.activeTerrainBrushStroke.toolState.terrainId
] ?? null;
const commit =
!cancelled &&
baseTerrain !== null &&
!areTerrainsEqual(baseTerrain, finalPreviewTerrain);
const toolState = this.activeTerrainBrushStroke.toolState;
this.activeTerrainBrushStroke = null;
this.terrainBrushPreviewGroup.visible = false;
if (!commit) {
this.rebuildDisplayedTerrainState();
return true;
}
const committed =
this.terrainBrushCommitHandler?.({
terrain: cloneTerrain(finalPreviewTerrain),
commandLabel: getTerrainBrushCommandLabel(toolState.tool),
tool: toolState.tool
}) === true;
if (!committed) {
this.rebuildDisplayedTerrainState();
}
return true;
}
private refreshPathPresentation() {
if (this.currentDocument === null) {
return;
}
for (const path of Object.values(this.currentDocument.paths)) {
const renderObjects = this.pathRenderObjects.get(path.id);
if (renderObjects === undefined) {
continue;
}
const selected = isPathSelected(this.currentSelection, path.id);
const hovered = isPathSelected(this.hoveredSelection, path.id);
renderObjects.line.material.color.setHex(
selected
? PATH_SELECTED_COLOR
: hovered
? PATH_HOVERED_COLOR
: PATH_COLOR
);
for (const pointMesh of renderObjects.pointMeshes) {
const pointSelected = isPathPointSelected(
this.currentSelection,
path.id,
pointMesh.pointId
);
const pointHovered = isPathPointSelected(
this.hoveredSelection,
path.id,
pointMesh.pointId
);
pointMesh.mesh.material.color.setHex(
pointSelected
? PATH_POINT_SELECTED_COLOR
: pointHovered || selected
? PATH_POINT_HOVERED_COLOR
: PATH_POINT_COLOR
);
pointMesh.mesh.scale.setScalar(
pointSelected
? PATH_POINT_SELECTED_SCALE
: pointHovered
? PATH_POINT_HOVERED_SCALE
: 1
);
}
}
}
private disposeUniqueMaterials(materials: Material[]) {
for (const material of new Set(materials)) {
material.dispose();
}
}
private clearLocalLights() {
for (const renderObjects of this.localLightRenderObjects.values()) {
this.localLightGroup.remove(renderObjects.group);
}
this.localLightRenderObjects.clear();
}
private clearLightVolumes() {
for (const renderObjects of this.lightVolumeRenderObjects.values()) {
this.lightVolumeGroup.remove(renderObjects.group);
}
this.lightVolumeRenderObjects.clear();
}
private clearBrushMeshes() {
for (const renderObjects of this.brushRenderObjects.values()) {
this.brushGroup.remove(renderObjects.mesh);
this.brushGroup.remove(renderObjects.edges);
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();
}
renderObjects.mesh.geometry.dispose();
this.disposeUniqueMaterials(renderObjects.mesh.material);
renderObjects.edges.geometry.dispose();
renderObjects.edges.material.dispose();
}
this.brushRenderObjects.clear();
this.disposePreservedViewportWaterReflectionTargets();
this.resetViewportWaterSurfaceBindings(false);
}
private clearPaths() {
for (const renderObjects of this.pathRenderObjects.values()) {
this.pathGroup.remove(renderObjects.line);
renderObjects.line.geometry.dispose();
renderObjects.line.material.dispose();
for (const pointMesh of renderObjects.pointMeshes) {
this.pathGroup.remove(pointMesh.mesh);
pointMesh.mesh.geometry.dispose();
pointMesh.mesh.material.dispose();
}
}
this.pathRenderObjects.clear();
}
private clearTerrains() {
for (const renderObjects of this.terrainRenderObjects.values()) {
this.terrainGroup.remove(renderObjects.mesh);
renderObjects.mesh.geometry.dispose();
renderObjects.mesh.material.dispose();
}
this.terrainRenderObjects.clear();
}
private clearEntityMarkers() {
for (const renderObjects of this.entityRenderObjects.values()) {
this.entityGroup.remove(renderObjects.group);
if (renderObjects.dispose !== undefined) {
renderObjects.dispose();
continue;
}
for (const mesh of renderObjects.meshes) {
mesh.geometry.dispose();
if (Array.isArray(mesh.material)) {
for (const material of mesh.material) {
material.dispose();
}
} else {
mesh.material.dispose();
}
}
}
this.entityRenderObjects.clear();
}
private clearModelInstances() {
for (const renderGroup of this.modelRenderObjects.values()) {
this.modelGroup.remove(renderGroup);
disposeModelInstance(renderGroup);
}
this.modelRenderObjects.clear();
}
private resize() {
if (this.container === null) {
return;
}
const width = this.container.clientWidth;
const height = this.container.clientHeight;
if (width === 0 || height === 0) {
return;
}
this.perspectiveCamera.aspect = width / height;
this.perspectiveCamera.updateProjectionMatrix();
this.updateOrthographicCameraFrustum();
this.orthographicCamera.updateProjectionMatrix();
this.renderer.setSize(width, height, false);
this.advancedRenderingComposer?.setSize(width, height);
this.resizeWaterReflectionTargets();
}
private pickTransformHandle(
event: PointerEvent
): { axisConstraint: TransformAxis | null } | null {
if (!this.transformGizmoGroup.visible) {
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;
}
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 "terrains":
return selection.ids.length === 1
? `terrain:${selection.ids[0]}`
: null;
case "paths":
return selection.ids.length === 1 ? `path:${selection.ids[0]}` : null;
case "pathPoint":
return `pathPoint:${selection.pathId}:${selection.pointId}`;
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 pathId = hit.object.userData.pathId;
const pathPointId = hit.object.userData.pathPointId;
const terrainId = hit.object.userData.terrainId;
if (typeof terrainId === "string") {
return {
kind: "terrains",
ids: [terrainId]
};
}
if (typeof pathId === "string" && typeof pathPointId === "string") {
return {
kind: "pathPoint",
pathId,
pointId: pathPointId
};
}
if (typeof pathId === "string") {
return {
kind: "paths",
ids: [pathId]
};
}
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 WhiteboxEdgeId
};
}
const brushVertexId = hit.object.userData.brushVertexId;
if (typeof brushVertexId === "string") {
return {
kind: "brushVertex",
brushId,
vertexId: brushVertexId as WhiteboxVertexId
};
}
if (this.whiteboxSelectionMode === "face") {
const faceMaterialIndex = hit.face?.materialIndex;
const renderObjects = this.brushRenderObjects.get(brushId);
const faceId =
typeof faceMaterialIndex === "number" && renderObjects !== undefined
? (renderObjects.faceIdsInOrder[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.pathRenderObjects.values(), (renderObjects) => [
renderObjects.line,
...renderObjects.pointMeshes.map((pointMesh) => pointMesh.mesh)
]).flat(),
...Array.from(
this.terrainRenderObjects.values(),
(renderObjects) => renderObjects.mesh
),
...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;
}
private handlePointerDown = (event: PointerEvent) => {
this.lastCanvasPointerPosition = {
x: event.clientX,
y: event.clientY
};
if (event.button === 1) {
event.preventDefault();
this.activeCameraDragPointerId = event.pointerId;
this.lastCameraDragClientPosition = {
x: event.clientX,
y: event.clientY
};
this.renderer.domElement.setPointerCapture(event.pointerId);
return;
}
if (event.button === 2) {
event.preventDefault();
if (this.currentTransformSession.kind === "active") {
this.transformCancelHandler?.();
}
return;
}
if (event.button !== 0) {
return;
}
const transformPointerIntent = resolveTransformPointerDownIntent(
this.currentTransformSession,
this.panelId
);
if (transformPointerIntent.commitActiveTransform) {
if (this.currentTransformSession.kind !== "active") {
throw new Error(
"Active transform intent resolved without an active session."
);
}
event.preventDefault();
this.transformCommitHandler?.(this.currentTransformSession);
return;
}
if (!transformPointerIntent.allowGizmoInteraction) {
return;
}
const transformHandle = this.pickTransformHandle(event);
const interactionSession = this.getDisplayedTransformSession();
if (transformHandle !== null && interactionSession !== null) {
event.preventDefault();
if (
transformHandle.axisConstraint !== null &&
!supportsTransformAxisConstraint(
interactionSession,
transformHandle.axisConstraint
)
) {
return;
}
const nextSession = this.buildTransformPreviewFromPointer(
createTransformSession({
source: "gizmo",
sourcePanelId: this.panelId,
operation: interactionSession.operation,
surfaceSnapEnabled: interactionSession.surfaceSnapEnabled,
axisConstraint: transformHandle.axisConstraint,
axisConstraintSpace:
transformHandle.axisConstraint === null
? "world"
: interactionSession.axisConstraintSpace,
target: interactionSession.target
}),
{
x: event.clientX,
y: event.clientY
},
{
x: event.clientX,
y: event.clientY
},
transformHandle.axisConstraint,
transformHandle.axisConstraint === null
? "world"
: interactionSession.axisConstraintSpace
);
this.currentTransformSession = nextSession;
this.applyTransformPreview();
this.syncTransformGizmo();
this.transformSessionChangeHandler?.(nextSession);
this.activeTransformDrag = {
pointerId: event.pointerId,
sessionId: nextSession.id,
axisConstraint: transformHandle.axisConstraint,
axisConstraintSpace: nextSession.axisConstraintSpace,
initialClientPosition: {
x: event.clientX,
y: event.clientY
}
};
this.renderer.domElement.setPointerCapture(event.pointerId);
return;
}
if (this.toolMode === "create" && this.creationPreview !== null) {
const previewCenter = this.getCreationPreviewCenter(
event,
this.creationPreview.target
);
const nextCreationPreview = {
...this.creationPreview,
center: previewCenter
};
this.syncCreationPreview(nextCreationPreview);
this.creationPreviewChangeHandler?.(nextCreationPreview);
if (previewCenter !== null) {
const committed =
this.creationCommitHandler?.(nextCreationPreview) === true;
if (committed) {
this.syncCreationPreview(null);
this.creationPreviewChangeHandler?.({ kind: "none" });
}
}
return;
}
if (this.beginTerrainBrushStroke(event)) {
return;
}
const candidates = this.getSelectionCandidates(event);
if (candidates.length === 0) {
this.lastClickPointer = null;
this.lastClickSelectionKey = null;
this.brushSelectionChangeHandler?.(
applyEditorSelectionClick(this.currentSelection, null, event.shiftKey)
);
return;
}
// 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;
}
}
this.lastClickPointer = { x: this.pointer.x, y: this.pointer.y };
const chosen = candidates[candidateIndex];
this.lastClickSelectionKey = chosen.key;
this.brushSelectionChangeHandler?.(
applyEditorSelectionClick(
this.currentSelection,
chosen.selection,
event.shiftKey
)
);
};
private handlePointerMove = (event: PointerEvent) => {
this.lastCanvasPointerPosition = {
x: event.clientX,
y: event.clientY
};
if (
this.activeCameraDragPointerId === event.pointerId &&
this.lastCameraDragClientPosition !== null
) {
const deltaX = event.clientX - this.lastCameraDragClientPosition.x;
const deltaY = event.clientY - this.lastCameraDragClientPosition.y;
this.lastCameraDragClientPosition = {
x: event.clientX,
y: event.clientY
};
if (this.viewMode === "perspective" && !event.shiftKey) {
this.orbitCamera(deltaX, deltaY);
} else {
this.panCamera(deltaX, deltaY);
}
return;
}
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.activeTransformDrag.axisConstraintSpace
);
this.currentTransformSession = nextSession;
this.applyTransformPreview();
this.syncTransformGizmo();
this.transformSessionChangeHandler?.(nextSession);
return;
}
if (this.continueTerrainBrushStroke(event)) {
return;
}
if (this.isTerrainBrushActive()) {
this.setHoveredSelection({
kind: "none"
});
this.setTerrainBrushHover(
this.getTerrainBrushHitAtClientPosition(event.clientX, event.clientY)
);
return;
}
if (this.toolMode === "select") {
const hoveredCandidate = this.getSelectionCandidates(event)[0]
?.selection ?? { kind: "none" };
this.setHoveredSelection(hoveredCandidate);
return;
}
this.setHoveredSelection({
kind: "none"
});
if (this.toolMode !== "create" || this.creationPreview === null) {
return;
}
const previewCenter = this.getCreationPreviewCenter(
event,
this.creationPreview.target
);
const nextCreationPreview = {
...this.creationPreview,
center: previewCenter
};
this.syncCreationPreview(nextCreationPreview);
this.creationPreviewChangeHandler?.(nextCreationPreview);
};
private handlePointerUp = (event: PointerEvent) => {
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;
}
if (this.finishTerrainBrushStroke(event)) {
return;
}
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;
this.emitCameraStateChange();
};
private handlePointerLeave = () => {
if (this.activeCameraDragPointerId !== null) {
return;
}
this.setHoveredSelection({
kind: "none"
});
this.setTerrainBrushHover(null);
// Keep the shared creation preview alive across panel boundaries; the next
// viewport panel will update it as the pointer continues moving.
};
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.axisConstraintSpace
);
this.currentTransformSession = nextSession;
this.applyTransformPreview();
this.syncTransformGizmo();
this.transformSessionChangeHandler?.(nextSession);
};
private handleWheel = (event: WheelEvent) => {
event.preventDefault();
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();
this.emitCameraStateChange();
return;
}
this.orthographicCamera.zoom = Math.min(
MAX_ORTHOGRAPHIC_ZOOM,
Math.max(
MIN_ORTHOGRAPHIC_ZOOM,
this.orthographicCamera.zoom * Math.exp(-event.deltaY * ZOOM_SPEED)
)
);
this.orthographicCamera.updateProjectionMatrix();
this.emitCameraStateChange();
};
private handleAuxClick = (event: MouseEvent) => {
if (event.button === 1 || event.button === 2) {
event.preventDefault();
}
};
private handleContextMenu = (event: MouseEvent) => {
event.preventDefault();
};
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;
}
private orbitCamera(deltaX: number, deltaY: number) {
this.cameraSpherical.theta -= deltaX * ORBIT_ROTATION_SPEED;
this.cameraSpherical.phi -= deltaY * ORBIT_ROTATION_SPEED;
this.applyPerspectiveCameraPose();
}
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);
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();
this.cameraUp
.crossVectors(this.cameraRight, this.cameraForward)
.normalize();
this.cameraTarget
.addScaledVector(this.cameraRight, (-deltaX / width) * visibleWidth)
.addScaledVector(this.cameraUp, (deltaY / height) * visibleHeight);
this.applyOrthographicCameraPose();
}
private getCreationPreviewCenter(
event: PointerEvent,
target: CreationTarget
): Vec3 | null {
switch (target.kind) {
case "box-brush":
case "wedge-brush":
case "cylinder-brush":
case "cone-brush":
return this.getBoxCreationPreviewCenter(event, DEFAULT_BOX_BRUSH_SIZE);
case "torus-brush":
return this.getBoxCreationPreviewCenter(
event,
DEFAULT_TORUS_BRUSH_SIZE
);
case "entity":
switch (target.entityKind) {
case "triggerVolume":
return this.getBoxCreationPreviewCenter(
event,
DEFAULT_TRIGGER_VOLUME_SIZE
);
case "cameraRig":
case "pointLight":
case "playerStart":
case "sceneEntry":
case "npc":
case "soundEmitter":
case "teleportTarget":
case "interactable":
case "spotLight":
return this.getPlanarCreationAnchor(event);
}
return null;
case "model-instance": {
const anchor = this.getPlanarCreationAnchor(event);
if (anchor === null) {
return null;
}
const asset = this.projectAssets[target.assetId];
if (asset === undefined || asset.kind !== "model") {
return null;
}
return createModelInstancePlacementPosition(asset, anchor);
}
}
}
private getPlanarCreationAnchor(event: PointerEvent): Vec3 | null {
const bounds = this.renderer.domElement.getBoundingClientRect();
if (bounds.width === 0 || bounds.height === 0) {
return null;
}
this.pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
this.pointer.y = -(((event.clientY - bounds.top) / bounds.height) * 2 - 1);
this.raycaster.setFromCamera(this.pointer, this.getActiveCamera());
if (
this.raycaster.ray.intersectPlane(
this.getBoxCreatePlane(),
this.boxCreateIntersection
) === null
) {
return null;
}
switch (this.viewMode) {
case "perspective":
case "top":
return {
x: this.snapWhiteboxPositionValue(this.boxCreateIntersection.x),
y: this.snapWhiteboxPositionValue(0),
z: this.snapWhiteboxPositionValue(this.boxCreateIntersection.z)
};
case "front":
return {
x: this.snapWhiteboxPositionValue(this.boxCreateIntersection.x),
y: this.snapWhiteboxPositionValue(this.boxCreateIntersection.y),
z: this.snapWhiteboxPositionValue(0)
};
case "side":
return {
x: this.snapWhiteboxPositionValue(0),
y: this.snapWhiteboxPositionValue(this.boxCreateIntersection.y),
z: this.snapWhiteboxPositionValue(this.boxCreateIntersection.z)
};
}
}
private getBoxCreationPreviewCenter(
event: PointerEvent,
size: Vec3
): Vec3 | null {
const bounds = this.renderer.domElement.getBoundingClientRect();
if (bounds.width === 0 || bounds.height === 0) {
return null;
}
this.pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
this.pointer.y = -(((event.clientY - bounds.top) / bounds.height) * 2 - 1);
this.raycaster.setFromCamera(this.pointer, this.getActiveCamera());
if (
this.raycaster.ray.intersectPlane(
this.getBoxCreatePlane(),
this.boxCreateIntersection
) === null
) {
return null;
}
switch (this.viewMode) {
case "perspective":
case "top":
return {
x: this.snapWhiteboxPositionValue(this.boxCreateIntersection.x),
y: this.snapWhiteboxPositionValue(size.y * 0.5),
z: this.snapWhiteboxPositionValue(this.boxCreateIntersection.z)
};
case "front":
return {
x: this.snapWhiteboxPositionValue(this.boxCreateIntersection.x),
y: this.snapWhiteboxPositionValue(this.boxCreateIntersection.y),
z: this.snapWhiteboxPositionValue(size.z * 0.5)
};
case "side":
return {
x: this.snapWhiteboxPositionValue(size.x * 0.5),
y: this.snapWhiteboxPositionValue(this.boxCreateIntersection.y),
z: this.snapWhiteboxPositionValue(this.boxCreateIntersection.z)
};
}
}
private getCreationPreviewTargetKey(target: CreationTarget): string {
switch (target.kind) {
case "box-brush":
return "box-brush";
case "wedge-brush":
return "wedge-brush";
case "cylinder-brush":
return `cylinder-brush:${target.sideCount}`;
case "cone-brush":
return `cone-brush:${target.sideCount}`;
case "torus-brush":
return `torus-brush:${target.majorSegmentCount}:${target.tubeSegmentCount}`;
case "entity":
return `entity:${target.entityKind}:${target.audioAssetId}:${target.modelAssetId}`;
case "model-instance":
return `model-instance:${target.assetId}`;
}
}
private clearCreationPreviewObject() {
if (this.creationPreviewObject === null) {
this.creationPreviewTargetKey = null;
return;
}
this.scene.remove(this.creationPreviewObject);
disposeModelInstance(this.creationPreviewObject);
this.creationPreviewObject = null;
this.creationPreviewTargetKey = null;
}
private createBrushCreationPreviewObject(brush: Brush): Group {
const geometry = buildBoxBrushDerivedMeshData(brush).geometry;
const group = new Group();
const mesh = new Mesh(
geometry,
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
})
);
const edges = new LineSegments(
new EdgesGeometry(geometry),
new LineBasicMaterial({
color: BOX_CREATE_PREVIEW_EDGE
})
);
group.add(mesh);
group.add(edges);
return group;
}
private createCreationPreviewObject(
toolPreview: CreationViewportToolPreview
): Group {
const previewPosition = toolPreview.center ?? {
x: 0,
y: 0,
z: 0
};
switch (toolPreview.target.kind) {
case "box-brush": {
const fallbackGroup = new Group();
fallbackGroup.visible = false;
return fallbackGroup;
}
case "wedge-brush":
return this.createBrushCreationPreviewObject(
createWedgeBrush({
center: previewPosition,
size: DEFAULT_BOX_BRUSH_SIZE
})
);
case "cylinder-brush":
return this.createBrushCreationPreviewObject(
createRadialPrismBrush({
center: previewPosition,
size: DEFAULT_BOX_BRUSH_SIZE,
sideCount: toolPreview.target.sideCount
})
);
case "cone-brush":
return this.createBrushCreationPreviewObject(
createConeBrush({
center: previewPosition,
size: DEFAULT_BOX_BRUSH_SIZE,
sideCount: toolPreview.target.sideCount
})
);
case "torus-brush":
return this.createBrushCreationPreviewObject(
createTorusBrush({
center: previewPosition,
size: DEFAULT_TORUS_BRUSH_SIZE,
majorSegmentCount: toolPreview.target.majorSegmentCount,
tubeSegmentCount: toolPreview.target.tubeSegmentCount
})
);
case "entity": {
let previewGroup: Group;
switch (toolPreview.target.entityKind) {
case "pointLight":
previewGroup = this.createPointLightGizmoRenderObjects(
"creation-preview",
previewPosition,
DEFAULT_POINT_LIGHT_DISTANCE,
PLACEMENT_PREVIEW_COLOR_HEX,
false
).group;
break;
case "spotLight":
previewGroup = this.createSpotLightGizmoRenderObjects(
"creation-preview",
previewPosition,
DEFAULT_SPOT_LIGHT_DIRECTION,
DEFAULT_SPOT_LIGHT_DISTANCE,
DEFAULT_SPOT_LIGHT_ANGLE_DEGREES,
PLACEMENT_PREVIEW_COLOR_HEX,
false
).group;
break;
case "cameraRig":
previewGroup = this.createCameraRigRenderObjects(
createCameraRigEntity({
id: "creation-preview",
position: previewPosition,
targetOffset: DEFAULT_CAMERA_RIG_TARGET_OFFSET
}),
false,
this.currentDocument,
BOX_CREATE_PREVIEW_FILL
).group;
break;
case "playerStart":
previewGroup = this.createPlayerStartRenderObjects(
"creation-preview",
previewPosition,
DEFAULT_PLAYER_START_YAW_DEGREES,
{
mode: "capsule",
eyeHeight: DEFAULT_PLAYER_START_EYE_HEIGHT,
capsuleRadius: DEFAULT_PLAYER_START_CAPSULE_RADIUS,
capsuleHeight: DEFAULT_PLAYER_START_CAPSULE_HEIGHT,
boxSize: DEFAULT_PLAYER_START_BOX_SIZE
},
false
).group;
break;
case "sceneEntry":
previewGroup = this.createTeleportTargetRenderObjects(
"creation-preview",
previewPosition,
DEFAULT_SCENE_ENTRY_YAW_DEGREES,
false,
BOX_CREATE_PREVIEW_FILL
).group;
break;
case "npc":
previewGroup = this.createNpcRenderObjects(
{
id: "creation-preview",
kind: "npc",
name: undefined,
visible: true,
enabled: true,
position: previewPosition,
yawDegrees: DEFAULT_NPC_YAW_DEGREES,
actorId: "creation-preview",
presence: createNpcAlwaysPresence(),
modelAssetId: toolPreview.target.modelAssetId ?? null,
dialogues: [],
defaultDialogueId: null,
collider: createNpcColliderSettings()
},
false,
BOX_CREATE_PREVIEW_FILL
).group;
break;
case "soundEmitter":
previewGroup = this.createSoundEmitterRenderObjects(
"creation-preview",
previewPosition,
DEFAULT_SOUND_EMITTER_REF_DISTANCE,
DEFAULT_SOUND_EMITTER_MAX_DISTANCE,
false,
BOX_CREATE_PREVIEW_FILL
).group;
break;
case "triggerVolume":
previewGroup = this.createTriggerVolumeRenderObjects(
"creation-preview",
previewPosition,
DEFAULT_TRIGGER_VOLUME_SIZE,
false,
BOX_CREATE_PREVIEW_FILL
).group;
break;
case "teleportTarget":
previewGroup = this.createTeleportTargetRenderObjects(
"creation-preview",
previewPosition,
DEFAULT_TELEPORT_TARGET_YAW_DEGREES,
false,
BOX_CREATE_PREVIEW_FILL
).group;
break;
case "interactable":
previewGroup = this.createInteractableRenderObjects(
"creation-preview",
previewPosition,
DEFAULT_INTERACTABLE_RADIUS,
false,
true,
BOX_CREATE_PREVIEW_FILL
).group;
break;
}
if (this.displayMode === "wireframe") {
this.applyWireframePresentation(previewGroup);
}
return previewGroup;
}
case "model-instance": {
const asset = this.projectAssets[toolPreview.target.assetId];
const loadedAsset = this.loadedModelAssets[toolPreview.target.assetId];
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
});
return createModelInstanceRenderGroup(
dummyModelInstance,
asset,
loadedAsset,
false,
BOX_CREATE_PREVIEW_FILL,
this.displayMode === "wireframe" ? "wireframe" : "normal"
);
}
}
throw new Error("Unsupported creation preview target.");
}
private syncCreationPreview(toolPreview: CreationViewportToolPreview | null) {
const currentToolPreview =
this.creationPreview === null
? { kind: "none" as const }
: this.creationPreview;
const nextToolPreview =
toolPreview === null ? { kind: "none" as const } : toolPreview;
if (areViewportToolPreviewsEqual(currentToolPreview, nextToolPreview)) {
return;
}
this.creationPreview =
toolPreview === null
? null
: {
kind: "create",
sourcePanelId: toolPreview.sourcePanelId,
target:
toolPreview.target.kind === "entity"
? {
kind: "entity",
entityKind: toolPreview.target.entityKind,
audioAssetId: toolPreview.target.audioAssetId,
modelAssetId: toolPreview.target.modelAssetId
}
: toolPreview.target.kind === "model-instance"
? {
kind: "model-instance",
assetId: toolPreview.target.assetId
}
: toolPreview.target.kind === "wedge-brush"
? {
kind: "wedge-brush"
}
: toolPreview.target.kind === "cylinder-brush"
? {
kind: "cylinder-brush",
sideCount: toolPreview.target.sideCount
}
: toolPreview.target.kind === "cone-brush"
? {
kind: "cone-brush",
sideCount: toolPreview.target.sideCount
}
: toolPreview.target.kind === "torus-brush"
? {
kind: "torus-brush",
majorSegmentCount:
toolPreview.target.majorSegmentCount,
tubeSegmentCount:
toolPreview.target.tubeSegmentCount
}
: {
kind: "box-brush"
},
center:
toolPreview.center === null ? null : { ...toolPreview.center }
};
if (toolPreview === null) {
this.boxCreatePreviewMesh.visible = false;
this.boxCreatePreviewEdges.visible = false;
this.clearCreationPreviewObject();
return;
}
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;
if (
this.creationPreviewObject !== null &&
this.creationPreviewTargetKey === nextTargetKey
) {
this.creationPreviewObject.visible = toolPreview.center !== null;
if (toolPreview.center !== null) {
this.creationPreviewObject.position.set(
toolPreview.center.x,
toolPreview.center.y,
toolPreview.center.z
);
}
this.creationPreviewTargetKey = nextTargetKey;
return;
}
this.clearCreationPreviewObject();
const creationPreviewObject = this.createCreationPreviewObject(toolPreview);
applyRendererRenderCategory(creationPreviewObject, "overlay");
creationPreviewObject.visible = toolPreview.center !== null;
this.scene.add(creationPreviewObject);
this.creationPreviewObject = creationPreviewObject;
this.creationPreviewTargetKey = nextTargetKey;
}
private render = () => {
if (!this.renderEnabled) {
this.animationFrame = 0;
return;
}
this.animationFrame = window.requestAnimationFrame(this.render);
this.updateGridPositioning();
this.updateTransformGizmoPose();
const now = performance.now();
const dt =
this.previousFrameTime === 0
? 0
: Math.min((now - this.previousFrameTime) / 1000, 1 / 20);
this.previousFrameTime = now;
this.volumeTime += dt;
for (const uniform of this.volumeAnimatedUniforms) {
uniform.value = this.volumeTime;
}
if (this.viewportWaterSurfaceBindings.length > 0) {
this.updateViewportWaterReflections();
}
this.syncCelestialShadowState();
if (this.advancedRenderingComposer !== null) {
this.worldBackgroundRenderer.syncToCamera(this.perspectiveCamera);
this.advancedRenderingComposer.render();
return;
}
const activeCamera = this.getActiveCamera();
const previousAutoClear = this.renderer.autoClear;
if (this.displayMode === "normal") {
this.worldBackgroundRenderer.syncToCamera(activeCamera);
this.renderer.autoClear = true;
this.renderer.clear();
this.renderer.render(this.worldBackgroundRenderer.scene, activeCamera);
this.renderer.autoClear = false;
this.renderer.render(this.scene, activeCamera);
this.renderer.autoClear = previousAutoClear;
return;
}
this.renderer.autoClear = true;
this.renderer.render(this.scene, activeCamera);
this.renderer.autoClear = previousAutoClear;
};
}