auto-git:

[change] src/app/App.tsx
 [change] src/app/editor-store.ts
 [change] src/core/transform-session.ts
 [change] src/viewport-three/ViewportCanvas.tsx
 [change] src/viewport-three/ViewportPanel.tsx
 [change] src/viewport-three/viewport-host.ts
 [change] tests/unit/viewport-canvas.test.tsx
This commit is contained in:
2026-04-11 02:44:48 +02:00
parent 9950444027
commit 4a66cccd79
9 changed files with 7825 additions and 3324 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,10 @@ import {
type TransformAxisSpace, type TransformAxisSpace,
type TransformSessionState type TransformSessionState
} from "../core/transform-session"; } from "../core/transform-session";
import { createEmptySceneDocument, type SceneDocument } from "../document/scene-document"; import {
createEmptySceneDocument,
type SceneDocument
} from "../document/scene-document";
import { import {
DEFAULT_SCENE_DRAFT_STORAGE_KEY, DEFAULT_SCENE_DRAFT_STORAGE_KEY,
type LoadSceneDocumentDraftResult, type LoadSceneDocumentDraftResult,
@@ -24,7 +27,10 @@ import {
type SaveSceneDocumentDraftResult, type SaveSceneDocumentDraftResult,
saveSceneDocumentDraft saveSceneDocumentDraft
} from "../serialization/local-draft-storage"; } from "../serialization/local-draft-storage";
import { parseSceneDocumentJson, serializeSceneDocument } from "../serialization/scene-document-json"; import {
parseSceneDocumentJson,
serializeSceneDocument
} from "../serialization/scene-document-json";
import type { ViewportViewMode } from "../viewport-three/viewport-view-modes"; import type { ViewportViewMode } from "../viewport-three/viewport-view-modes";
import { import {
areViewportToolPreviewsEqual, areViewportToolPreviewsEqual,
@@ -110,7 +116,9 @@ export class EditorStore {
}; };
constructor(options: EditorStoreOptions = {}) { constructor(options: EditorStoreOptions = {}) {
const initialViewportLayoutState = cloneViewportLayoutState(options.initialViewportLayoutState ?? createDefaultViewportLayoutState()); const initialViewportLayoutState = cloneViewportLayoutState(
options.initialViewportLayoutState ?? createDefaultViewportLayoutState()
);
this.document = options.initialDocument ?? createEmptySceneDocument(); this.document = options.initialDocument ?? createEmptySceneDocument();
this.viewportLayoutMode = initialViewportLayoutState.layoutMode; this.viewportLayoutMode = initialViewportLayoutState.layoutMode;
@@ -143,14 +151,22 @@ export class EditorStore {
this.toolMode = toolMode; this.toolMode = toolMode;
if (!isViewportToolPreviewCompatible(toolMode, this.viewportTransientState.toolPreview)) { if (
!isViewportToolPreviewCompatible(
toolMode,
this.viewportTransientState.toolPreview
)
) {
this.viewportTransientState = { this.viewportTransientState = {
...this.viewportTransientState, ...this.viewportTransientState,
toolPreview: createDefaultViewportTransientState().toolPreview toolPreview: createDefaultViewportTransientState().toolPreview
}; };
} }
if (toolMode !== "select" && this.viewportTransientState.transformSession.kind !== "none") { if (
toolMode !== "select" &&
this.viewportTransientState.transformSession.kind !== "none"
) {
this.viewportTransientState = { this.viewportTransientState = {
...this.viewportTransientState, ...this.viewportTransientState,
transformSession: createInactiveTransformSession() transformSession: createInactiveTransformSession()
@@ -178,7 +194,10 @@ export class EditorStore {
this.emit(); this.emit();
} }
setViewportPanelViewMode(panelId: ViewportPanelId, viewMode: ViewportViewMode) { setViewportPanelViewMode(
panelId: ViewportPanelId,
viewMode: ViewportViewMode
) {
if (this.viewportPanels[panelId].viewMode === viewMode) { if (this.viewportPanels[panelId].viewMode === viewMode) {
return; return;
} }
@@ -193,7 +212,10 @@ export class EditorStore {
this.emit(); this.emit();
} }
setViewportPanelDisplayMode(panelId: ViewportPanelId, displayMode: ViewportDisplayMode) { setViewportPanelDisplayMode(
panelId: ViewportPanelId,
displayMode: ViewportDisplayMode
) {
if (this.viewportPanels[panelId].displayMode === displayMode) { if (this.viewportPanels[panelId].displayMode === displayMode) {
return; return;
} }
@@ -208,8 +230,16 @@ export class EditorStore {
this.emit(); this.emit();
} }
setViewportPanelCameraState(panelId: ViewportPanelId, cameraState: ViewportPanelCameraState) { setViewportPanelCameraState(
if (areViewportPanelCameraStatesEqual(this.viewportPanels[panelId].cameraState, cameraState)) { panelId: ViewportPanelId,
cameraState: ViewportPanelCameraState
) {
if (
areViewportPanelCameraStatesEqual(
this.viewportPanels[panelId].cameraState,
cameraState
)
) {
return; return;
} }
@@ -224,7 +254,10 @@ export class EditorStore {
} }
setViewportQuadSplit(viewportQuadSplit: ViewportQuadSplit) { setViewportQuadSplit(viewportQuadSplit: ViewportQuadSplit) {
if (this.viewportQuadSplit.x === viewportQuadSplit.x && this.viewportQuadSplit.y === viewportQuadSplit.y) { if (
this.viewportQuadSplit.x === viewportQuadSplit.x &&
this.viewportQuadSplit.y === viewportQuadSplit.y
) {
return; return;
} }
@@ -238,7 +271,12 @@ export class EditorStore {
setViewportToolPreview(toolPreview: ViewportToolPreview) { setViewportToolPreview(toolPreview: ViewportToolPreview) {
const nextToolPreview = cloneViewportToolPreview(toolPreview); const nextToolPreview = cloneViewportToolPreview(toolPreview);
if (areViewportToolPreviewsEqual(this.viewportTransientState.toolPreview, nextToolPreview)) { if (
areViewportToolPreviewsEqual(
this.viewportTransientState.toolPreview,
nextToolPreview
)
) {
return; return;
} }
@@ -256,7 +294,10 @@ export class EditorStore {
return; return;
} }
if (sourcePanelId !== undefined && currentToolPreview.sourcePanelId !== sourcePanelId) { if (
sourcePanelId !== undefined &&
currentToolPreview.sourcePanelId !== sourcePanelId
) {
return; return;
} }
@@ -270,7 +311,12 @@ export class EditorStore {
setTransformSession(transformSession: TransformSessionState) { setTransformSession(transformSession: TransformSessionState) {
const nextTransformSession = cloneTransformSession(transformSession); const nextTransformSession = cloneTransformSession(transformSession);
if (areTransformSessionsEqual(this.viewportTransientState.transformSession, nextTransformSession)) { if (
areTransformSessionsEqual(
this.viewportTransientState.transformSession,
nextTransformSession
)
) {
return; return;
} }
@@ -293,14 +339,19 @@ export class EditorStore {
this.emit(); this.emit();
} }
setTransformAxisConstraint(axisConstraint: TransformAxis | null, axisConstraintSpace: TransformAxisSpace = "world") { setTransformAxisConstraint(
axisConstraint: TransformAxis | null,
axisConstraintSpace: TransformAxisSpace = "world"
) {
if (this.viewportTransientState.transformSession.kind !== "active") { if (this.viewportTransientState.transformSession.kind !== "active") {
return; return;
} }
if ( if (
this.viewportTransientState.transformSession.axisConstraint === axisConstraint && this.viewportTransientState.transformSession.axisConstraint ===
this.viewportTransientState.transformSession.axisConstraintSpace === axisConstraintSpace axisConstraint &&
this.viewportTransientState.transformSession.axisConstraintSpace ===
axisConstraintSpace
) { ) {
return; return;
} }
@@ -308,7 +359,9 @@ export class EditorStore {
this.viewportTransientState = { this.viewportTransientState = {
...this.viewportTransientState, ...this.viewportTransientState,
transformSession: { transformSession: {
...(cloneTransformSession(this.viewportTransientState.transformSession) as Extract<TransformSessionState, { kind: "active" }>), ...(cloneTransformSession(
this.viewportTransientState.transformSession
) as Extract<TransformSessionState, { kind: "active" }>),
axisConstraint, axisConstraint,
axisConstraintSpace axisConstraintSpace
} }
@@ -345,7 +398,10 @@ export class EditorStore {
} }
setSelection(selection: EditorSelection) { setSelection(selection: EditorSelection) {
if (this.viewportTransientState.transformSession.kind === "active" && !areEditorSelectionsEqual(this.selection, selection)) { if (
this.viewportTransientState.transformSession.kind === "active" &&
!areEditorSelectionsEqual(this.selection, selection)
) {
this.viewportTransientState = { this.viewportTransientState = {
...this.viewportTransientState, ...this.viewportTransientState,
transformSession: createInactiveTransformSession() transformSession: createInactiveTransformSession()
@@ -369,7 +425,10 @@ export class EditorStore {
} }
this.whiteboxSelectionMode = mode; this.whiteboxSelectionMode = mode;
this.selection = normalizeSelectionForWhiteboxSelectionMode(this.selection, mode); this.selection = normalizeSelectionForWhiteboxSelectionMode(
this.selection,
mode
);
this.emit(); this.emit();
} }
@@ -462,7 +521,12 @@ export class EditorStore {
}; };
} }
return saveSceneDocumentDraft(this.storage, this.document, this.createViewportLayoutState(), this.storageKey); return saveSceneDocumentDraft(
this.storage,
this.document,
this.createViewportLayoutState(),
this.storageKey
);
} }
loadDraft(): EditorDraftLoadResult { loadDraft(): EditorDraftLoadResult {
@@ -517,7 +581,8 @@ export class EditorStore {
} }
private applyViewportLayoutState(viewportLayoutState: ViewportLayoutState) { private applyViewportLayoutState(viewportLayoutState: ViewportLayoutState) {
const nextViewportLayoutState = cloneViewportLayoutState(viewportLayoutState); const nextViewportLayoutState =
cloneViewportLayoutState(viewportLayoutState);
this.viewportLayoutMode = nextViewportLayoutState.layoutMode; this.viewportLayoutMode = nextViewportLayoutState.layoutMode;
this.activeViewportPanelId = nextViewportLayoutState.activePanelId; this.activeViewportPanelId = nextViewportLayoutState.activePanelId;

View File

@@ -20,7 +20,10 @@ import {
type EntityInstance, type EntityInstance,
type EntityKind type EntityKind
} from "../entities/entity-instances"; } from "../entities/entity-instances";
import { cloneModelInstance, getModelInstanceKindLabel } from "../assets/model-instances"; import {
cloneModelInstance,
getModelInstanceKindLabel
} from "../assets/model-instances";
import type { ViewportPanelId } from "../viewport-three/viewport-layout"; import type { ViewportPanelId } from "../viewport-three/viewport-layout";
export type TransformOperation = "translate" | "rotate" | "scale"; export type TransformOperation = "translate" | "rotate" | "scale";
@@ -42,7 +45,10 @@ export interface NoEntityRotationState {
kind: "none"; kind: "none";
} }
export type EntityTransformRotationState = NoEntityRotationState | YawEntityRotationState | DirectionEntityRotationState; export type EntityTransformRotationState =
| NoEntityRotationState
| YawEntityRotationState
| DirectionEntityRotationState;
export interface BrushTransformTarget { export interface BrushTransformTarget {
kind: "brush"; kind: "brush";
@@ -116,7 +122,10 @@ export interface BrushTransformPreview {
geometry: BoxBrushGeometry; geometry: BoxBrushGeometry;
} }
function areBrushGeometriesEqual(left: BoxBrushGeometry, right: BoxBrushGeometry): boolean { function areBrushGeometriesEqual(
left: BoxBrushGeometry,
right: BoxBrushGeometry
): boolean {
return BOX_VERTEX_IDS.every((vertexId) => { return BOX_VERTEX_IDS.every((vertexId) => {
const leftVertex = left.vertices[vertexId]; const leftVertex = left.vertices[vertexId];
const rightVertex = right.vertices[vertexId]; const rightVertex = right.vertices[vertexId];
@@ -137,7 +146,10 @@ export interface EntityTransformPreview {
rotation: EntityTransformRotationState; rotation: EntityTransformRotationState;
} }
export type TransformPreview = BrushTransformPreview | ModelInstanceTransformPreview | EntityTransformPreview; export type TransformPreview =
| BrushTransformPreview
| ModelInstanceTransformPreview
| EntityTransformPreview;
export interface ActiveTransformSession { export interface ActiveTransformSession {
kind: "active"; kind: "active";
@@ -170,7 +182,9 @@ function areVec3Equal(left: Vec3, right: Vec3): boolean {
return left.x === right.x && left.y === right.y && left.z === right.z; return left.x === right.x && left.y === right.y && left.z === right.z;
} }
function cloneEntityTransformRotationState(rotation: EntityTransformRotationState): EntityTransformRotationState { function cloneEntityTransformRotationState(
rotation: EntityTransformRotationState
): EntityTransformRotationState {
switch (rotation.kind) { switch (rotation.kind) {
case "none": case "none":
return { return {
@@ -189,7 +203,10 @@ function cloneEntityTransformRotationState(rotation: EntityTransformRotationStat
} }
} }
function areEntityTransformRotationsEqual(left: EntityTransformRotationState, right: EntityTransformRotationState): boolean { function areEntityTransformRotationsEqual(
left: EntityTransformRotationState,
right: EntityTransformRotationState
): boolean {
if (left.kind !== right.kind) { if (left.kind !== right.kind) {
return false; return false;
} }
@@ -200,7 +217,10 @@ function areEntityTransformRotationsEqual(left: EntityTransformRotationState, ri
case "yaw": case "yaw":
return right.kind === "yaw" && left.yawDegrees === right.yawDegrees; return right.kind === "yaw" && left.yawDegrees === right.yawDegrees;
case "direction": case "direction":
return right.kind === "direction" && areVec3Equal(left.direction, right.direction); return (
right.kind === "direction" &&
areVec3Equal(left.direction, right.direction)
);
} }
} }
@@ -266,12 +286,16 @@ export function cloneTransformTarget(target: TransformTarget): TransformTarget {
entityId: target.entityId, entityId: target.entityId,
entityKind: target.entityKind, entityKind: target.entityKind,
initialPosition: cloneVec3(target.initialPosition), initialPosition: cloneVec3(target.initialPosition),
initialRotation: cloneEntityTransformRotationState(target.initialRotation) initialRotation: cloneEntityTransformRotationState(
target.initialRotation
)
}; };
} }
} }
export function cloneTransformPreview(preview: TransformPreview): TransformPreview { export function cloneTransformPreview(
preview: TransformPreview
): TransformPreview {
switch (preview.kind) { switch (preview.kind) {
case "brush": case "brush":
return { return {
@@ -297,7 +321,9 @@ export function cloneTransformPreview(preview: TransformPreview): TransformPrevi
} }
} }
export function cloneTransformSession(session: TransformSessionState): TransformSessionState { export function cloneTransformSession(
session: TransformSessionState
): TransformSessionState {
if (session.kind === "none") { if (session.kind === "none") {
return session; return session;
} }
@@ -315,7 +341,10 @@ export function cloneTransformSession(session: TransformSessionState): Transform
}; };
} }
export function areTransformSessionsEqual(left: TransformSessionState, right: TransformSessionState): boolean { export function areTransformSessionsEqual(
left: TransformSessionState,
right: TransformSessionState
): boolean {
if (left.kind !== right.kind) { if (left.kind !== right.kind) {
return false; return false;
} }
@@ -336,7 +365,10 @@ export function areTransformSessionsEqual(left: TransformSessionState, right: Tr
); );
} }
function areTransformTargetsEqual(left: TransformTarget, right: TransformTarget): boolean { function areTransformTargetsEqual(
left: TransformTarget,
right: TransformTarget
): boolean {
if (left.kind !== right.kind) { if (left.kind !== right.kind) {
return false; return false;
} }
@@ -347,7 +379,10 @@ function areTransformTargetsEqual(left: TransformTarget, right: TransformTarget)
right.kind === "brush" && right.kind === "brush" &&
left.brushId === right.brushId && left.brushId === right.brushId &&
areVec3Equal(left.initialCenter, right.initialCenter) && areVec3Equal(left.initialCenter, right.initialCenter) &&
areVec3Equal(left.initialRotationDegrees, right.initialRotationDegrees) && areVec3Equal(
left.initialRotationDegrees,
right.initialRotationDegrees
) &&
areVec3Equal(left.initialSize, right.initialSize) && areVec3Equal(left.initialSize, right.initialSize) &&
areBrushGeometriesEqual(left.initialGeometry, right.initialGeometry) areBrushGeometriesEqual(left.initialGeometry, right.initialGeometry)
); );
@@ -357,7 +392,10 @@ function areTransformTargetsEqual(left: TransformTarget, right: TransformTarget)
left.brushId === right.brushId && left.brushId === right.brushId &&
left.faceId === right.faceId && left.faceId === right.faceId &&
areVec3Equal(left.initialCenter, right.initialCenter) && areVec3Equal(left.initialCenter, right.initialCenter) &&
areVec3Equal(left.initialRotationDegrees, right.initialRotationDegrees) && areVec3Equal(
left.initialRotationDegrees,
right.initialRotationDegrees
) &&
areVec3Equal(left.initialSize, right.initialSize) && areVec3Equal(left.initialSize, right.initialSize) &&
areBrushGeometriesEqual(left.initialGeometry, right.initialGeometry) areBrushGeometriesEqual(left.initialGeometry, right.initialGeometry)
); );
@@ -367,7 +405,10 @@ function areTransformTargetsEqual(left: TransformTarget, right: TransformTarget)
left.brushId === right.brushId && left.brushId === right.brushId &&
left.edgeId === right.edgeId && left.edgeId === right.edgeId &&
areVec3Equal(left.initialCenter, right.initialCenter) && areVec3Equal(left.initialCenter, right.initialCenter) &&
areVec3Equal(left.initialRotationDegrees, right.initialRotationDegrees) && areVec3Equal(
left.initialRotationDegrees,
right.initialRotationDegrees
) &&
areVec3Equal(left.initialSize, right.initialSize) && areVec3Equal(left.initialSize, right.initialSize) &&
areBrushGeometriesEqual(left.initialGeometry, right.initialGeometry) areBrushGeometriesEqual(left.initialGeometry, right.initialGeometry)
); );
@@ -377,7 +418,10 @@ function areTransformTargetsEqual(left: TransformTarget, right: TransformTarget)
left.brushId === right.brushId && left.brushId === right.brushId &&
left.vertexId === right.vertexId && left.vertexId === right.vertexId &&
areVec3Equal(left.initialCenter, right.initialCenter) && areVec3Equal(left.initialCenter, right.initialCenter) &&
areVec3Equal(left.initialRotationDegrees, right.initialRotationDegrees) && areVec3Equal(
left.initialRotationDegrees,
right.initialRotationDegrees
) &&
areVec3Equal(left.initialSize, right.initialSize) && areVec3Equal(left.initialSize, right.initialSize) &&
areBrushGeometriesEqual(left.initialGeometry, right.initialGeometry) areBrushGeometriesEqual(left.initialGeometry, right.initialGeometry)
); );
@@ -387,7 +431,10 @@ function areTransformTargetsEqual(left: TransformTarget, right: TransformTarget)
left.modelInstanceId === right.modelInstanceId && left.modelInstanceId === right.modelInstanceId &&
left.assetId === right.assetId && left.assetId === right.assetId &&
areVec3Equal(left.initialPosition, right.initialPosition) && areVec3Equal(left.initialPosition, right.initialPosition) &&
areVec3Equal(left.initialRotationDegrees, right.initialRotationDegrees) && areVec3Equal(
left.initialRotationDegrees,
right.initialRotationDegrees
) &&
areVec3Equal(left.initialScale, right.initialScale) areVec3Equal(left.initialScale, right.initialScale)
); );
case "entity": case "entity":
@@ -396,12 +443,18 @@ function areTransformTargetsEqual(left: TransformTarget, right: TransformTarget)
left.entityId === right.entityId && left.entityId === right.entityId &&
left.entityKind === right.entityKind && left.entityKind === right.entityKind &&
areVec3Equal(left.initialPosition, right.initialPosition) && areVec3Equal(left.initialPosition, right.initialPosition) &&
areEntityTransformRotationsEqual(left.initialRotation, right.initialRotation) areEntityTransformRotationsEqual(
left.initialRotation,
right.initialRotation
)
); );
} }
} }
function areTransformPreviewsEqual(left: TransformPreview, right: TransformPreview): boolean { function areTransformPreviewsEqual(
left: TransformPreview,
right: TransformPreview
): boolean {
if (left.kind !== right.kind) { if (left.kind !== right.kind) {
return false; return false;
} }
@@ -423,7 +476,11 @@ function areTransformPreviewsEqual(left: TransformPreview, right: TransformPrevi
areVec3Equal(left.scale, right.scale) areVec3Equal(left.scale, right.scale)
); );
case "entity": case "entity":
return right.kind === "entity" && areVec3Equal(left.position, right.position) && areEntityTransformRotationsEqual(left.rotation, right.rotation); return (
right.kind === "entity" &&
areVec3Equal(left.position, right.position) &&
areEntityTransformRotationsEqual(left.rotation, right.rotation)
);
} }
} }
@@ -448,7 +505,9 @@ export function createTransformSession(options: {
}; };
} }
export function createTransformPreviewFromTarget(target: TransformTarget): TransformPreview { export function createTransformPreviewFromTarget(
target: TransformTarget
): TransformPreview {
switch (target.kind) { switch (target.kind) {
case "brush": case "brush":
case "brushFace": case "brushFace":
@@ -477,7 +536,9 @@ export function createTransformPreviewFromTarget(target: TransformTarget): Trans
} }
} }
export function doesTransformSessionChangeTarget(session: ActiveTransformSession): boolean { export function doesTransformSessionChangeTarget(
session: ActiveTransformSession
): boolean {
switch (session.target.kind) { switch (session.target.kind) {
case "brush": case "brush":
case "brushFace": case "brushFace":
@@ -486,27 +547,47 @@ export function doesTransformSessionChangeTarget(session: ActiveTransformSession
return ( return (
session.preview.kind === "brush" && session.preview.kind === "brush" &&
(!areVec3Equal(session.preview.center, session.target.initialCenter) || (!areVec3Equal(session.preview.center, session.target.initialCenter) ||
!areVec3Equal(session.preview.rotationDegrees, session.target.initialRotationDegrees) || !areVec3Equal(
session.preview.rotationDegrees,
session.target.initialRotationDegrees
) ||
!areVec3Equal(session.preview.size, session.target.initialSize) || !areVec3Equal(session.preview.size, session.target.initialSize) ||
!areBrushGeometriesEqual(session.preview.geometry, session.target.initialGeometry)) !areBrushGeometriesEqual(
session.preview.geometry,
session.target.initialGeometry
))
); );
case "modelInstance": case "modelInstance":
return ( return (
session.preview.kind === "modelInstance" && session.preview.kind === "modelInstance" &&
(!areVec3Equal(session.preview.position, session.target.initialPosition) || (!areVec3Equal(
!areVec3Equal(session.preview.rotationDegrees, session.target.initialRotationDegrees) || session.preview.position,
session.target.initialPosition
) ||
!areVec3Equal(
session.preview.rotationDegrees,
session.target.initialRotationDegrees
) ||
!areVec3Equal(session.preview.scale, session.target.initialScale)) !areVec3Equal(session.preview.scale, session.target.initialScale))
); );
case "entity": case "entity":
return ( return (
session.preview.kind === "entity" && session.preview.kind === "entity" &&
(!areVec3Equal(session.preview.position, session.target.initialPosition) || (!areVec3Equal(
!areEntityTransformRotationsEqual(session.preview.rotation, session.target.initialRotation)) session.preview.position,
session.target.initialPosition
) ||
!areEntityTransformRotationsEqual(
session.preview.rotation,
session.target.initialRotation
))
); );
} }
} }
export function getTransformOperationLabel(operation: TransformOperation): string { export function getTransformOperationLabel(
operation: TransformOperation
): string {
switch (operation) { switch (operation) {
case "translate": case "translate":
return "Move"; return "Move";
@@ -521,7 +602,9 @@ export function getTransformAxisLabel(axis: TransformAxis): string {
return axis.toUpperCase(); return axis.toUpperCase();
} }
export function getTransformAxisSpaceLabel(axisSpace: TransformAxisSpace): string { export function getTransformAxisSpaceLabel(
axisSpace: TransformAxisSpace
): string {
switch (axisSpace) { switch (axisSpace) {
case "world": case "world":
return "World"; return "World";
@@ -547,7 +630,9 @@ export function getTransformTargetLabel(target: TransformTarget): string {
} }
} }
export function getSupportedTransformOperations(target: TransformTarget): TransformOperation[] { export function getSupportedTransformOperations(
target: TransformTarget
): TransformOperation[] {
switch (target.kind) { switch (target.kind) {
case "brush": case "brush":
case "brushFace": case "brushFace":
@@ -558,25 +643,43 @@ export function getSupportedTransformOperations(target: TransformTarget): Transf
case "modelInstance": case "modelInstance":
return ["translate", "rotate", "scale"]; return ["translate", "rotate", "scale"];
case "entity": case "entity":
return target.initialRotation.kind === "none" ? ["translate"] : ["translate", "rotate"]; return target.initialRotation.kind === "none"
? ["translate"]
: ["translate", "rotate"];
} }
} }
export function supportsTransformOperation(target: TransformTarget, operation: TransformOperation): boolean { export function supportsTransformOperation(
target: TransformTarget,
operation: TransformOperation
): boolean {
return getSupportedTransformOperations(target).includes(operation); return getSupportedTransformOperations(target).includes(operation);
} }
export function supportsTransformAxisConstraint(session: ActiveTransformSession, axis: TransformAxis): boolean { export function supportsTransformAxisConstraint(
session: ActiveTransformSession,
axis: TransformAxis
): boolean {
switch (session.operation) { switch (session.operation) {
case "translate": case "translate":
return true; return true;
case "scale": case "scale":
if (session.target.kind === "modelInstance" || session.target.kind === "brush" || session.target.kind === "brushVertex") { if (
session.target.kind === "modelInstance" ||
session.target.kind === "brush" ||
session.target.kind === "brushVertex"
) {
return session.target.kind !== "brushVertex"; return session.target.kind !== "brushVertex";
} }
if (session.target.kind === "brushFace") { if (session.target.kind === "brushFace") {
const normalAxis = session.target.faceId === "posX" || session.target.faceId === "negX" ? "x" : session.target.faceId === "posY" || session.target.faceId === "negY" ? "y" : "z"; const normalAxis =
session.target.faceId === "posX" || session.target.faceId === "negX"
? "x"
: session.target.faceId === "posY" ||
session.target.faceId === "negY"
? "y"
: "z";
return axis === normalAxis; return axis === normalAxis;
} }
@@ -594,12 +697,21 @@ export function supportsTransformAxisConstraint(session: ActiveTransformSession,
return false; return false;
case "rotate": case "rotate":
if (session.target.kind === "entity" && session.target.initialRotation.kind === "yaw") { if (
session.target.kind === "entity" &&
session.target.initialRotation.kind === "yaw"
) {
return axis === "y"; return axis === "y";
} }
if (session.target.kind === "brushFace") { if (session.target.kind === "brushFace") {
const normalAxis = session.target.faceId === "posX" || session.target.faceId === "negX" ? "x" : session.target.faceId === "posY" || session.target.faceId === "negY" ? "y" : "z"; const normalAxis =
session.target.faceId === "posX" || session.target.faceId === "negX"
? "x"
: session.target.faceId === "posY" ||
session.target.faceId === "negY"
? "y"
: "z";
return axis === normalAxis; return axis === normalAxis;
} }
@@ -623,7 +735,10 @@ export function supportsTransformAxisConstraint(session: ActiveTransformSession,
} }
} }
export function supportsLocalTransformAxisConstraint(session: ActiveTransformSession, axis: TransformAxis): boolean { export function supportsLocalTransformAxisConstraint(
session: ActiveTransformSession,
axis: TransformAxis
): boolean {
if (!supportsTransformAxisConstraint(session, axis)) { if (!supportsTransformAxisConstraint(session, axis)) {
return false; return false;
} }
@@ -645,7 +760,9 @@ export function supportsLocalTransformAxisConstraint(session: ActiveTransformSes
} }
} }
function resolveEntityRotation(entity: EntityInstance): EntityTransformRotationState { function resolveEntityRotation(
entity: EntityInstance
): EntityTransformRotationState {
switch (entity.kind) { switch (entity.kind) {
case "playerStart": case "playerStart":
case "teleportTarget": case "teleportTarget":
@@ -668,7 +785,10 @@ function resolveEntityRotation(entity: EntityInstance): EntityTransformRotationS
} }
} }
function createBrushTransformTarget(document: SceneDocument, brushId: string): TransformTargetResolution { function createBrushTransformTarget(
document: SceneDocument,
brushId: string
): TransformTargetResolution {
const brush = document.brushes[brushId]; const brush = document.brushes[brushId];
if (brush === undefined || brush.kind !== "box") { if (brush === undefined || brush.kind !== "box") {
@@ -691,10 +811,17 @@ function createBrushTransformTarget(document: SceneDocument, brushId: string): T
}; };
} }
function createBrushFaceTransformTarget(document: SceneDocument, brushId: string, faceId: BoxFaceId): TransformTargetResolution { function createBrushFaceTransformTarget(
document: SceneDocument,
brushId: string,
faceId: BoxFaceId
): TransformTargetResolution {
const brushResolution = createBrushTransformTarget(document, brushId); const brushResolution = createBrushTransformTarget(document, brushId);
if (brushResolution.target === null || brushResolution.target.kind !== "brush") { if (
brushResolution.target === null ||
brushResolution.target.kind !== "brush"
) {
return brushResolution; return brushResolution;
} }
@@ -704,18 +831,29 @@ function createBrushFaceTransformTarget(document: SceneDocument, brushId: string
brushId, brushId,
faceId, faceId,
initialCenter: cloneVec3(brushResolution.target.initialCenter), initialCenter: cloneVec3(brushResolution.target.initialCenter),
initialRotationDegrees: cloneVec3(brushResolution.target.initialRotationDegrees), initialRotationDegrees: cloneVec3(
brushResolution.target.initialRotationDegrees
),
initialSize: cloneVec3(brushResolution.target.initialSize), initialSize: cloneVec3(brushResolution.target.initialSize),
initialGeometry: cloneBoxBrushGeometry(brushResolution.target.initialGeometry) initialGeometry: cloneBoxBrushGeometry(
brushResolution.target.initialGeometry
)
}, },
message: null message: null
}; };
} }
function createBrushEdgeTransformTarget(document: SceneDocument, brushId: string, edgeId: BoxEdgeId): TransformTargetResolution { function createBrushEdgeTransformTarget(
document: SceneDocument,
brushId: string,
edgeId: BoxEdgeId
): TransformTargetResolution {
const brushResolution = createBrushTransformTarget(document, brushId); const brushResolution = createBrushTransformTarget(document, brushId);
if (brushResolution.target === null || brushResolution.target.kind !== "brush") { if (
brushResolution.target === null ||
brushResolution.target.kind !== "brush"
) {
return brushResolution; return brushResolution;
} }
@@ -725,18 +863,29 @@ function createBrushEdgeTransformTarget(document: SceneDocument, brushId: string
brushId, brushId,
edgeId, edgeId,
initialCenter: cloneVec3(brushResolution.target.initialCenter), initialCenter: cloneVec3(brushResolution.target.initialCenter),
initialRotationDegrees: cloneVec3(brushResolution.target.initialRotationDegrees), initialRotationDegrees: cloneVec3(
brushResolution.target.initialRotationDegrees
),
initialSize: cloneVec3(brushResolution.target.initialSize), initialSize: cloneVec3(brushResolution.target.initialSize),
initialGeometry: cloneBoxBrushGeometry(brushResolution.target.initialGeometry) initialGeometry: cloneBoxBrushGeometry(
brushResolution.target.initialGeometry
)
}, },
message: null message: null
}; };
} }
function createBrushVertexTransformTarget(document: SceneDocument, brushId: string, vertexId: BoxVertexId): TransformTargetResolution { function createBrushVertexTransformTarget(
document: SceneDocument,
brushId: string,
vertexId: BoxVertexId
): TransformTargetResolution {
const brushResolution = createBrushTransformTarget(document, brushId); const brushResolution = createBrushTransformTarget(document, brushId);
if (brushResolution.target === null || brushResolution.target.kind !== "brush") { if (
brushResolution.target === null ||
brushResolution.target.kind !== "brush"
) {
return brushResolution; return brushResolution;
} }
@@ -746,15 +895,22 @@ function createBrushVertexTransformTarget(document: SceneDocument, brushId: stri
brushId, brushId,
vertexId, vertexId,
initialCenter: cloneVec3(brushResolution.target.initialCenter), initialCenter: cloneVec3(brushResolution.target.initialCenter),
initialRotationDegrees: cloneVec3(brushResolution.target.initialRotationDegrees), initialRotationDegrees: cloneVec3(
brushResolution.target.initialRotationDegrees
),
initialSize: cloneVec3(brushResolution.target.initialSize), initialSize: cloneVec3(brushResolution.target.initialSize),
initialGeometry: cloneBoxBrushGeometry(brushResolution.target.initialGeometry) initialGeometry: cloneBoxBrushGeometry(
brushResolution.target.initialGeometry
)
}, },
message: null message: null
}; };
} }
function createEntityTransformTarget(document: SceneDocument, entityId: string): TransformTargetResolution { function createEntityTransformTarget(
document: SceneDocument,
entityId: string
): TransformTargetResolution {
const entity = document.entities[entityId]; const entity = document.entities[entityId];
if (entity === undefined) { if (entity === undefined) {
@@ -778,7 +934,10 @@ function createEntityTransformTarget(document: SceneDocument, entityId: string):
}; };
} }
function createModelInstanceTransformTarget(document: SceneDocument, modelInstanceId: string): TransformTargetResolution { function createModelInstanceTransformTarget(
document: SceneDocument,
modelInstanceId: string
): TransformTargetResolution {
const modelInstance = document.modelInstances[modelInstanceId]; const modelInstance = document.modelInstances[modelInstanceId];
if (modelInstance === undefined) { if (modelInstance === undefined) {
@@ -812,7 +971,8 @@ export function resolveTransformTarget(
case "none": case "none":
return { return {
target: null, target: null,
message: "Select a single brush, entity, or model instance before transforming it." message:
"Select a single brush, entity, or model instance before transforming it."
}; };
case "brushFace": case "brushFace":
if (whiteboxSelectionMode !== "face") { if (whiteboxSelectionMode !== "face") {
@@ -822,7 +982,11 @@ export function resolveTransformTarget(
}; };
} }
return createBrushFaceTransformTarget(document, selection.brushId, selection.faceId); return createBrushFaceTransformTarget(
document,
selection.brushId,
selection.faceId
);
case "brushEdge": case "brushEdge":
if (whiteboxSelectionMode !== "edge") { if (whiteboxSelectionMode !== "edge") {
return { return {
@@ -831,16 +995,25 @@ export function resolveTransformTarget(
}; };
} }
return createBrushEdgeTransformTarget(document, selection.brushId, selection.edgeId); return createBrushEdgeTransformTarget(
document,
selection.brushId,
selection.edgeId
);
case "brushVertex": case "brushVertex":
if (whiteboxSelectionMode !== "vertex") { if (whiteboxSelectionMode !== "vertex") {
return { return {
target: null, target: null,
message: "Switch to Vertex mode to transform a selected whitebox vertex." message:
"Switch to Vertex mode to transform a selected whitebox vertex."
}; };
} }
return createBrushVertexTransformTarget(document, selection.brushId, selection.vertexId); return createBrushVertexTransformTarget(
document,
selection.brushId,
selection.vertexId
);
case "brushes": case "brushes":
if (whiteboxSelectionMode !== "object") { if (whiteboxSelectionMode !== "object") {
return { return {

View File

@@ -6,7 +6,10 @@ import type { ProjectAssetRecord } from "../assets/project-assets";
import type { EditorSelection } from "../core/selection"; import type { EditorSelection } from "../core/selection";
import { getWhiteboxSelectionFeedbackLabel } from "../core/whitebox-selection-feedback"; import { getWhiteboxSelectionFeedbackLabel } from "../core/whitebox-selection-feedback";
import type { ToolMode } from "../core/tool-mode"; import type { ToolMode } from "../core/tool-mode";
import { getWhiteboxSelectionModeLabel, type WhiteboxSelectionMode } from "../core/whitebox-selection-mode"; import {
getWhiteboxSelectionModeLabel,
type WhiteboxSelectionMode
} from "../core/whitebox-selection-mode";
import type { Vec3 } from "../core/vector"; import type { Vec3 } from "../core/vector";
import { import {
getTransformAxisLabel, getTransformAxisLabel,
@@ -28,7 +31,10 @@ import {
getViewportViewModeLabel, getViewportViewModeLabel,
type ViewportViewMode type ViewportViewMode
} from "./viewport-view-modes"; } from "./viewport-view-modes";
import type { CreationViewportToolPreview, ViewportToolPreview } from "./viewport-transient-state"; import type {
CreationViewportToolPreview,
ViewportToolPreview
} from "./viewport-transient-state";
import { ViewportHost } from "./viewport-host"; import { ViewportHost } from "./viewport-host";
@@ -97,7 +103,9 @@ export function ViewportCanvas({
const hostRef = useRef<ViewportHost | null>(null); const hostRef = useRef<ViewportHost | null>(null);
const shouldRenderPanel = layoutMode === "quad" || isActivePanel; const shouldRenderPanel = layoutMode === "quad" || isActivePanel;
const [viewportMessage, setViewportMessage] = useState<string | null>(null); const [viewportMessage, setViewportMessage] = useState<string | null>(null);
const [hoveredWhiteboxLabel, setHoveredWhiteboxLabel] = useState<string | null>(null); const [hoveredWhiteboxLabel, setHoveredWhiteboxLabel] = useState<
string | null
>(null);
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
@@ -119,7 +127,10 @@ export function ViewportCanvas({
hostRef.current = null; hostRef.current = null;
}; };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Viewport initialization failed."; const message =
error instanceof Error
? error.message
: "Viewport initialization failed.";
setViewportMessage(`Viewport initialization failed: ${message}`); setViewportMessage(`Viewport initialization failed: ${message}`);
return; return;
} }
@@ -138,11 +149,18 @@ export function ViewportCanvas({
}, [world]); }, [world]);
useEffect(() => { useEffect(() => {
hostRef.current?.updateAssets(projectAssets, loadedModelAssets, loadedImageAssets); hostRef.current?.updateAssets(
projectAssets,
loadedModelAssets,
loadedImageAssets
);
}, [projectAssets, loadedModelAssets, loadedImageAssets]); }, [projectAssets, loadedModelAssets, loadedImageAssets]);
useEffect(() => { useEffect(() => {
hostRef.current?.setWhiteboxSnapSettings(whiteboxSnapEnabled, whiteboxSnapStep); hostRef.current?.setWhiteboxSnapSettings(
whiteboxSnapEnabled,
whiteboxSnapStep
);
}, [whiteboxSnapEnabled, whiteboxSnapStep]); }, [whiteboxSnapEnabled, whiteboxSnapStep]);
useEffect(() => { useEffect(() => {
@@ -174,7 +192,9 @@ export function ViewportCanvas({
}, [onSelectionChange]); }, [onSelectionChange]);
useEffect(() => { useEffect(() => {
hostRef.current?.setWhiteboxHoverLabelChangeHandler(setHoveredWhiteboxLabel); hostRef.current?.setWhiteboxHoverLabelChangeHandler(
setHoveredWhiteboxLabel
);
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -215,7 +235,11 @@ export function ViewportCanvas({
}, [toolMode]); }, [toolMode]);
useEffect(() => { useEffect(() => {
hostRef.current?.setCreationPreview(toolMode === "create" && toolPreview.kind === "create" ? toolPreview : null); hostRef.current?.setCreationPreview(
toolMode === "create" && toolPreview.kind === "create"
? toolPreview
: null
);
}, [toolMode, toolPreview]); }, [toolMode, toolPreview]);
useEffect(() => { useEffect(() => {
@@ -230,12 +254,23 @@ export function ViewportCanvas({
hostRef.current?.focusSelection(sceneDocument, focusSelection); hostRef.current?.focusSelection(sceneDocument, focusSelection);
}, [focusRequestId, focusSelection, sceneDocument]); }, [focusRequestId, focusSelection, sceneDocument]);
const previewVisible = toolMode === "create" && toolPreview.kind === "create" && toolPreview.center !== null; const previewVisible =
toolMode === "create" &&
toolPreview.kind === "create" &&
toolPreview.center !== null;
const transformPreviewVisible = transformSession.kind === "active"; const transformPreviewVisible = transformSession.kind === "active";
const selectionModeVisible = toolMode === "select"; const selectionModeVisible = toolMode === "select";
const selectedWhiteboxLabel = selectionModeVisible ? getWhiteboxSelectionFeedbackLabel(sceneDocument, selection) : null; const selectedWhiteboxLabel = selectionModeVisible
? getWhiteboxSelectionFeedbackLabel(sceneDocument, selection)
: null;
const showViewModeOverlay = layoutMode === "quad"; const showViewModeOverlay = layoutMode === "quad";
const showOverlay = showViewModeOverlay || selectionModeVisible || previewVisible || transformPreviewVisible || selectedWhiteboxLabel !== null || hoveredWhiteboxLabel !== null; const showOverlay =
showViewModeOverlay ||
selectionModeVisible ||
previewVisible ||
transformPreviewVisible ||
selectedWhiteboxLabel !== null ||
hoveredWhiteboxLabel !== null;
return ( return (
<div <div
@@ -250,14 +285,25 @@ export function ViewportCanvas({
backgroundColor: "#000000", backgroundColor: "#000000",
backgroundImage: "none" backgroundImage: "none"
} }
: createWorldBackgroundStyle(world.background, world.background.mode === "image" ? loadedImageAssets[world.background.assetId]?.sourceUrl ?? null : null) : createWorldBackgroundStyle(
world.background,
world.background.mode === "image"
? (loadedImageAssets[world.background.assetId]?.sourceUrl ??
null)
: null
)
} }
> >
{!showOverlay ? null : ( {!showOverlay ? null : (
<div className="viewport-canvas__overlay" data-testid={`viewport-overlay-${panelId}`}> <div
className="viewport-canvas__overlay"
data-testid={`viewport-overlay-${panelId}`}
>
{!showViewModeOverlay ? null : ( {!showViewModeOverlay ? null : (
<div className="viewport-canvas__overlay-badges"> <div className="viewport-canvas__overlay-badges">
<div className="viewport-canvas__overlay-badge viewport-canvas__overlay-badge--view">{getViewportViewModeLabel(viewMode)}</div> <div className="viewport-canvas__overlay-badge viewport-canvas__overlay-badge--view">
{getViewportViewModeLabel(viewMode)}
</div>
{!selectionModeVisible ? null : ( {!selectionModeVisible ? null : (
<div <div
className="viewport-canvas__overlay-badge viewport-canvas__overlay-badge--selection" className="viewport-canvas__overlay-badge viewport-canvas__overlay-badge--selection"
@@ -279,12 +325,19 @@ export function ViewportCanvas({
</div> </div>
)} )}
{!previewVisible ? null : ( {!previewVisible ? null : (
<div className="viewport-canvas__overlay-preview" data-testid={`viewport-snap-preview-${panelId}`}> <div
Preview: {(toolPreview.center as Vec3).x}, {(toolPreview.center as Vec3).y}, {(toolPreview.center as Vec3).z} className="viewport-canvas__overlay-preview"
data-testid={`viewport-snap-preview-${panelId}`}
>
Preview: {(toolPreview.center as Vec3).x},{" "}
{(toolPreview.center as Vec3).y}, {(toolPreview.center as Vec3).z}
</div> </div>
)} )}
{!transformPreviewVisible ? null : ( {!transformPreviewVisible ? null : (
<div className="viewport-canvas__overlay-preview" data-testid={`viewport-transform-preview-${panelId}`}> <div
className="viewport-canvas__overlay-preview"
data-testid={`viewport-transform-preview-${panelId}`}
>
{transformSession.kind !== "active" {transformSession.kind !== "active"
? null ? null
: `${transformSession.operation}${ : `${transformSession.operation}${
@@ -297,12 +350,18 @@ export function ViewportCanvas({
</div> </div>
)} )}
{selectedWhiteboxLabel === null ? null : ( {selectedWhiteboxLabel === null ? null : (
<div className="viewport-canvas__overlay-preview" data-testid={`viewport-selected-whitebox-${panelId}`}> <div
className="viewport-canvas__overlay-preview"
data-testid={`viewport-selected-whitebox-${panelId}`}
>
Selected: {selectedWhiteboxLabel} Selected: {selectedWhiteboxLabel}
</div> </div>
)} )}
{hoveredWhiteboxLabel === null ? null : ( {hoveredWhiteboxLabel === null ? null : (
<div className="viewport-canvas__overlay-preview" data-testid={`viewport-hovered-whitebox-${panelId}`}> <div
className="viewport-canvas__overlay-preview"
data-testid={`viewport-hovered-whitebox-${panelId}`}
>
Hover: {hoveredWhiteboxLabel} Hover: {hoveredWhiteboxLabel}
</div> </div>
)} )}
@@ -311,7 +370,9 @@ export function ViewportCanvas({
{viewportMessage === null ? null : ( {viewportMessage === null ? null : (
<div className="viewport-canvas__fallback" role="status"> <div className="viewport-canvas__fallback" role="status">
<div className="viewport-canvas__fallback-title">Viewport Unavailable</div> <div className="viewport-canvas__fallback-title">
Viewport Unavailable
</div>
<div>{viewportMessage}</div> <div>{viewportMessage}</div>
{toolMode !== "create" || toolPreview.kind !== "create" ? null : ( {toolMode !== "create" || toolPreview.kind !== "create" ? null : (
<button <button

View File

@@ -10,14 +10,24 @@ import {
type ViewportPanelId, type ViewportPanelId,
type ViewportPanelState type ViewportPanelState
} from "./viewport-layout"; } from "./viewport-layout";
import { VIEWPORT_VIEW_MODES, getViewportViewModeLabel, type ViewportViewMode } from "./viewport-view-modes"; import {
import type { CreationViewportToolPreview, ViewportToolPreview } from "./viewport-transient-state"; VIEWPORT_VIEW_MODES,
getViewportViewModeLabel,
type ViewportViewMode
} from "./viewport-view-modes";
import type {
CreationViewportToolPreview,
ViewportToolPreview
} from "./viewport-transient-state";
import type { LoadedModelAsset } from "../assets/gltf-model-import"; import type { LoadedModelAsset } from "../assets/gltf-model-import";
import type { LoadedImageAsset } from "../assets/image-assets"; import type { LoadedImageAsset } from "../assets/image-assets";
import type { ProjectAssetRecord } from "../assets/project-assets"; import type { ProjectAssetRecord } from "../assets/project-assets";
import type { EditorSelection } from "../core/selection"; import type { EditorSelection } from "../core/selection";
import type { WhiteboxSelectionMode } from "../core/whitebox-selection-mode"; import type { WhiteboxSelectionMode } from "../core/whitebox-selection-mode";
import type { ActiveTransformSession, TransformSessionState } from "../core/transform-session"; import type {
ActiveTransformSession,
TransformSessionState
} from "../core/transform-session";
import type { ToolMode } from "../core/tool-mode"; import type { ToolMode } from "../core/tool-mode";
import type { SceneDocument } from "../document/scene-document"; import type { SceneDocument } from "../document/scene-document";
import type { WorldSettings } from "../document/world-settings"; import type { WorldSettings } from "../document/world-settings";
@@ -46,8 +56,14 @@ interface ViewportPanelProps {
focusRequestId: number; focusRequestId: number;
focusSelection: EditorSelection; focusSelection: EditorSelection;
onActivatePanel(panelId: ViewportPanelId): void; onActivatePanel(panelId: ViewportPanelId): void;
onSetPanelViewMode(panelId: ViewportPanelId, viewMode: ViewportViewMode): void; onSetPanelViewMode(
onSetPanelDisplayMode(panelId: ViewportPanelId, displayMode: ViewportDisplayMode): void; panelId: ViewportPanelId,
viewMode: ViewportViewMode
): void;
onSetPanelDisplayMode(
panelId: ViewportPanelId,
displayMode: ViewportDisplayMode
): void;
onCommitCreation(toolPreview: CreationViewportToolPreview): boolean; onCommitCreation(toolPreview: CreationViewportToolPreview): boolean;
onCameraStateChange(cameraState: ViewportPanelCameraState): void; onCameraStateChange(cameraState: ViewportPanelCameraState): void;
onToolPreviewChange(toolPreview: ViewportToolPreview): void; onToolPreviewChange(toolPreview: ViewportToolPreview): void;
@@ -110,9 +126,14 @@ export function ViewportPanel({
{layoutMode !== "quad" ? null : ( {layoutMode !== "quad" ? null : (
<div className="viewport-panel__meta"> <div className="viewport-panel__meta">
<div className="viewport-panel__title-row"> <div className="viewport-panel__title-row">
<div className="viewport-panel__title">{getViewportPanelLabel(panelId)}</div> <div className="viewport-panel__title">
{getViewportPanelLabel(panelId)}
</div>
{!isActive ? null : ( {!isActive ? null : (
<div className="viewport-panel__active-badge" data-testid={`viewport-panel-active-badge-${panelId}`}> <div
className="viewport-panel__active-badge"
data-testid={`viewport-panel-active-badge-${panelId}`}
>
Active Active
</div> </div>
)} )}
@@ -120,7 +141,11 @@ export function ViewportPanel({
</div> </div>
)} )}
<div className="viewport-panel__controls"> <div className="viewport-panel__controls">
<div className="viewport-panel__control-group" role="group" aria-label={`${getViewportPanelLabel(panelId)} view mode`}> <div
className="viewport-panel__control-group"
role="group"
aria-label={`${getViewportPanelLabel(panelId)} view mode`}
>
{VIEWPORT_VIEW_MODES.map((viewMode) => ( {VIEWPORT_VIEW_MODES.map((viewMode) => (
<button <button
key={viewMode} key={viewMode}
@@ -135,19 +160,25 @@ export function ViewportPanel({
))} ))}
</div> </div>
<div className="viewport-panel__control-group" role="group" aria-label={`${getViewportPanelLabel(panelId)} display mode`}> <div
{(["normal", "authoring", "wireframe"] as const).map((displayMode) => ( className="viewport-panel__control-group"
<button role="group"
key={displayMode} aria-label={`${getViewportPanelLabel(panelId)} display mode`}
className={`viewport-panel__button ${panelState.displayMode === displayMode ? "viewport-panel__button--active" : ""}`} >
type="button" {(["normal", "authoring", "wireframe"] as const).map(
data-testid={`viewport-panel-${panelId}-display-${displayMode}`} (displayMode) => (
aria-pressed={panelState.displayMode === displayMode} <button
onClick={() => onSetPanelDisplayMode(panelId, displayMode)} key={displayMode}
> className={`viewport-panel__button ${panelState.displayMode === displayMode ? "viewport-panel__button--active" : ""}`}
{getViewportDisplayModeLabel(displayMode)} type="button"
</button> data-testid={`viewport-panel-${panelId}-display-${displayMode}`}
))} aria-pressed={panelState.displayMode === displayMode}
onClick={() => onSetPanelDisplayMode(panelId, displayMode)}
>
{getViewportDisplayModeLabel(displayMode)}
</button>
)
)}
</div> </div>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest";
import { createEditorStore } from "../../src/app/editor-store"; import { createEditorStore } from "../../src/app/editor-store";
import { createModelInstance } from "../../src/assets/model-instances"; import { createModelInstance } from "../../src/assets/model-instances";
import { createProjectAssetStorageKey, type ModelAssetRecord } from "../../src/assets/project-assets"; import {
createProjectAssetStorageKey,
type ModelAssetRecord
} from "../../src/assets/project-assets";
import { createCommitTransformSessionCommand } from "../../src/commands/commit-transform-session-command"; import { createCommitTransformSessionCommand } from "../../src/commands/commit-transform-session-command";
import { import {
createTransformSession, createTransformSession,
@@ -11,7 +14,10 @@ import {
supportsTransformAxisConstraint, supportsTransformAxisConstraint,
supportsTransformOperation supportsTransformOperation
} from "../../src/core/transform-session"; } from "../../src/core/transform-session";
import { cloneBoxBrushGeometry, createBoxBrush } from "../../src/document/brushes"; import {
cloneBoxBrushGeometry,
createBoxBrush
} from "../../src/document/brushes";
import { createEmptySceneDocument } from "../../src/document/scene-document"; import { createEmptySceneDocument } from "../../src/document/scene-document";
import { createPlayerStartEntity } from "../../src/entities/entity-instances"; import { createPlayerStartEntity } from "../../src/entities/entity-instances";
import { getBoxBrushLocalVertexPosition } from "../../src/geometry/box-brush-mesh"; import { getBoxBrushLocalVertexPosition } from "../../src/geometry/box-brush-mesh";
@@ -137,17 +143,62 @@ describe("transform session commit commands", () => {
initialSize: brush.size initialSize: brush.size
}); });
expect(objectResolved.target).not.toBeNull(); expect(objectResolved.target).not.toBeNull();
expect(supportsTransformOperation(objectResolved.target as NonNullable<typeof objectResolved.target>, "translate")).toBe(true); expect(
expect(supportsTransformOperation(objectResolved.target as NonNullable<typeof objectResolved.target>, "rotate")).toBe(true); supportsTransformOperation(
expect(supportsTransformOperation(objectResolved.target as NonNullable<typeof objectResolved.target>, "scale")).toBe(true); objectResolved.target as NonNullable<typeof objectResolved.target>,
"translate"
)
).toBe(true);
expect(
supportsTransformOperation(
objectResolved.target as NonNullable<typeof objectResolved.target>,
"rotate"
)
).toBe(true);
expect(
supportsTransformOperation(
objectResolved.target as NonNullable<typeof objectResolved.target>,
"scale"
)
).toBe(true);
expect(supportsTransformOperation(faceResolved.target as NonNullable<typeof faceResolved.target>, "translate")).toBe(true); expect(
expect(supportsTransformOperation(faceResolved.target as NonNullable<typeof faceResolved.target>, "rotate")).toBe(true); supportsTransformOperation(
expect(supportsTransformOperation(faceResolved.target as NonNullable<typeof faceResolved.target>, "scale")).toBe(true); faceResolved.target as NonNullable<typeof faceResolved.target>,
"translate"
)
).toBe(true);
expect(
supportsTransformOperation(
faceResolved.target as NonNullable<typeof faceResolved.target>,
"rotate"
)
).toBe(true);
expect(
supportsTransformOperation(
faceResolved.target as NonNullable<typeof faceResolved.target>,
"scale"
)
).toBe(true);
expect(supportsTransformOperation(vertexResolved.target as NonNullable<typeof vertexResolved.target>, "translate")).toBe(true); expect(
expect(supportsTransformOperation(vertexResolved.target as NonNullable<typeof vertexResolved.target>, "rotate")).toBe(false); supportsTransformOperation(
expect(supportsTransformOperation(vertexResolved.target as NonNullable<typeof vertexResolved.target>, "scale")).toBe(false); vertexResolved.target as NonNullable<typeof vertexResolved.target>,
"translate"
)
).toBe(true);
expect(
supportsTransformOperation(
vertexResolved.target as NonNullable<typeof vertexResolved.target>,
"rotate"
)
).toBe(false);
expect(
supportsTransformOperation(
vertexResolved.target as NonNullable<typeof vertexResolved.target>,
"scale"
)
).toBe(false);
}); });
it("applies axis-constraint rules across object and component transform sessions", () => { it("applies axis-constraint rules across object and component transform sessions", () => {
@@ -228,9 +279,15 @@ describe("transform session commit commands", () => {
expect(supportsTransformAxisConstraint(edgeScaleSession, "y")).toBe(false); expect(supportsTransformAxisConstraint(edgeScaleSession, "y")).toBe(false);
expect(supportsTransformAxisConstraint(edgeScaleSession, "z")).toBe(true); expect(supportsTransformAxisConstraint(edgeScaleSession, "z")).toBe(true);
expect(supportsTransformAxisConstraint(vertexTranslateSession, "x")).toBe(true); expect(supportsTransformAxisConstraint(vertexTranslateSession, "x")).toBe(
expect(supportsTransformAxisConstraint(vertexTranslateSession, "y")).toBe(true); true
expect(supportsTransformAxisConstraint(vertexTranslateSession, "z")).toBe(true); );
expect(supportsTransformAxisConstraint(vertexTranslateSession, "y")).toBe(
true
);
expect(supportsTransformAxisConstraint(vertexTranslateSession, "z")).toBe(
true
);
}); });
it("only enables local axis toggling on supported transform targets", () => { it("only enables local axis toggling on supported transform targets", () => {
@@ -311,10 +368,18 @@ describe("transform session commit commands", () => {
target: entityTarget target: entityTarget
}); });
expect(supportsLocalTransformAxisConstraint(brushTranslateSession, "z")).toBe(true); expect(
expect(supportsLocalTransformAxisConstraint(brushScaleSession, "z")).toBe(false); supportsLocalTransformAxisConstraint(brushTranslateSession, "z")
expect(supportsLocalTransformAxisConstraint(faceRotateSession, "x")).toBe(false); ).toBe(true);
expect(supportsLocalTransformAxisConstraint(entityTranslateSession, "x")).toBe(true); expect(supportsLocalTransformAxisConstraint(brushScaleSession, "z")).toBe(
false
);
expect(supportsLocalTransformAxisConstraint(faceRotateSession, "x")).toBe(
false
);
expect(
supportsLocalTransformAxisConstraint(entityTranslateSession, "x")
).toBe(true);
}); });
it("commits whitebox box rotate and scale transforms with undo and redo", () => { it("commits whitebox box rotate and scale transforms with undo and redo", () => {
@@ -371,13 +436,20 @@ describe("transform session commit commands", () => {
geometry: target.initialGeometry geometry: target.initialGeometry
}; };
store.executeCommand(createCommitTransformSessionCommand(store.getState().document, rotateSession)); store.executeCommand(
createCommitTransformSessionCommand(
store.getState().document,
rotateSession
)
);
expect(store.getState().document.brushes[brush.id].rotationDegrees).toEqual({ expect(store.getState().document.brushes[brush.id].rotationDegrees).toEqual(
x: 0, {
y: 37.5, x: 0,
z: 12.5 y: 37.5,
}); z: 12.5
}
);
const scaleTarget = resolveTransformTarget(store.getState().document, { const scaleTarget = resolveTransformTarget(store.getState().document, {
kind: "brushes", kind: "brushes",
@@ -385,7 +457,9 @@ describe("transform session commit commands", () => {
}).target; }).target;
if (scaleTarget === null || scaleTarget.kind !== "brush") { if (scaleTarget === null || scaleTarget.kind !== "brush") {
throw new Error("Expected a whitebox box transform target after rotation."); throw new Error(
"Expected a whitebox box transform target after rotation."
);
} }
const scaleSession = createTransformSession({ const scaleSession = createTransformSession({
@@ -413,7 +487,12 @@ describe("transform session commit commands", () => {
geometry: scaleTarget.initialGeometry geometry: scaleTarget.initialGeometry
}; };
store.executeCommand(createCommitTransformSessionCommand(store.getState().document, scaleSession)); store.executeCommand(
createCommitTransformSessionCommand(
store.getState().document,
scaleSession
)
);
expect(store.getState().document.brushes[brush.id]).toMatchObject({ expect(store.getState().document.brushes[brush.id]).toMatchObject({
rotationDegrees: { rotationDegrees: {
@@ -504,7 +583,9 @@ describe("transform session commit commands", () => {
geometry: createBoxBrush({ size: { x: 3, y: 2, z: 2 } }).geometry geometry: createBoxBrush({ size: { x: 3, y: 2, z: 2 } }).geometry
}; };
store.executeCommand(createCommitTransformSessionCommand(store.getState().document, session)); store.executeCommand(
createCommitTransformSessionCommand(store.getState().document, session)
);
expect(store.getState().document.brushes[brush.id]).toMatchObject({ expect(store.getState().document.brushes[brush.id]).toMatchObject({
center: { x: 0.5, y: 1, z: 0 }, center: { x: 0.5, y: 1, z: 0 },
@@ -569,7 +650,9 @@ describe("transform session commit commands", () => {
geometry: createBoxBrush({ size: { x: 3, y: 3, z: 3 } }).geometry geometry: createBoxBrush({ size: { x: 3, y: 3, z: 3 } }).geometry
}; };
store.executeCommand(createCommitTransformSessionCommand(store.getState().document, session)); store.executeCommand(
createCommitTransformSessionCommand(store.getState().document, session)
);
expect(store.getState().document.brushes[brush.id]).toMatchObject({ expect(store.getState().document.brushes[brush.id]).toMatchObject({
center: { x: 0.5, y: 1.5, z: 0.5 }, center: { x: 0.5, y: 1.5, z: 0.5 },
@@ -640,11 +723,17 @@ describe("transform session commit commands", () => {
geometry: deformedGeometry geometry: deformedGeometry
}; };
store.executeCommand(createCommitTransformSessionCommand(store.getState().document, session)); store.executeCommand(
createCommitTransformSessionCommand(store.getState().document, session)
);
const committedBrush = store.getState().document.brushes[brush.id]; const committedBrush = store.getState().document.brushes[brush.id];
expect(getBoxBrushLocalVertexPosition(committedBrush, "posX_posY_posZ").x).toBe(2); expect(
expect(getBoxBrushLocalVertexPosition(committedBrush, "posX_posY_negZ").x).toBe(1); getBoxBrushLocalVertexPosition(committedBrush, "posX_posY_posZ").x
).toBe(2);
expect(
getBoxBrushLocalVertexPosition(committedBrush, "posX_posY_negZ").x
).toBe(1);
}); });
it("commits a model instance translate/rotate/scale transform with undo and redo", () => { it("commits a model instance translate/rotate/scale transform with undo and redo", () => {
@@ -713,9 +802,13 @@ describe("transform session commit commands", () => {
} }
}; };
store.executeCommand(createCommitTransformSessionCommand(store.getState().document, session)); store.executeCommand(
createCommitTransformSessionCommand(store.getState().document, session)
);
expect(store.getState().document.modelInstances[modelInstance.id]).toMatchObject({ expect(
store.getState().document.modelInstances[modelInstance.id]
).toMatchObject({
position: { position: {
x: 4, x: 4,
y: 1, y: 1,
@@ -734,10 +827,14 @@ describe("transform session commit commands", () => {
}); });
expect(store.undo()).toBe(true); expect(store.undo()).toBe(true);
expect(store.getState().document.modelInstances[modelInstance.id]).toEqual(modelInstance); expect(store.getState().document.modelInstances[modelInstance.id]).toEqual(
modelInstance
);
expect(store.redo()).toBe(true); expect(store.redo()).toBe(true);
expect(store.getState().document.modelInstances[modelInstance.id]).toMatchObject({ expect(
store.getState().document.modelInstances[modelInstance.id]
).toMatchObject({
position: { position: {
x: 4, x: 4,
y: 1, y: 1,
@@ -803,7 +900,9 @@ describe("transform session commit commands", () => {
} }
}; };
store.executeCommand(createCommitTransformSessionCommand(store.getState().document, session)); store.executeCommand(
createCommitTransformSessionCommand(store.getState().document, session)
);
expect(store.getState().document.entities[playerStart.id]).toMatchObject({ expect(store.getState().document.entities[playerStart.id]).toMatchObject({
position: { position: {
@@ -815,7 +914,9 @@ describe("transform session commit commands", () => {
}); });
expect(store.undo()).toBe(true); expect(store.undo()).toBe(true);
expect(store.getState().document.entities[playerStart.id]).toEqual(playerStart); expect(store.getState().document.entities[playerStart.id]).toEqual(
playerStart
);
expect(store.redo()).toBe(true); expect(store.redo()).toBe(true);
expect(store.getState().document.entities[playerStart.id]).toMatchObject({ expect(store.getState().document.entities[playerStart.id]).toMatchObject({

View File

@@ -5,8 +5,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "../../src/app/App"; import { App } from "../../src/app/App";
import { createEditorStore } from "../../src/app/editor-store"; import { createEditorStore } from "../../src/app/editor-store";
import { createModelInstance } from "../../src/assets/model-instances"; import { createModelInstance } from "../../src/assets/model-instances";
import { createProjectAssetStorageKey, type ModelAssetRecord } from "../../src/assets/project-assets"; import {
import type { ActiveTransformSession, TransformSessionState } from "../../src/core/transform-session"; createProjectAssetStorageKey,
type ModelAssetRecord
} from "../../src/assets/project-assets";
import type {
ActiveTransformSession,
TransformSessionState
} from "../../src/core/transform-session";
import { createBoxBrush } from "../../src/document/brushes"; import { createBoxBrush } from "../../src/document/brushes";
import { createEmptySceneDocument } from "../../src/document/scene-document"; import { createEmptySceneDocument } from "../../src/document/scene-document";
import { createPlayerStartEntity } from "../../src/entities/entity-instances"; import { createPlayerStartEntity } from "../../src/entities/entity-instances";
@@ -132,7 +138,9 @@ const modelAsset = {
} satisfies ModelAssetRecord; } satisfies ModelAssetRecord;
function getTopLeftViewportHost() { function getTopLeftViewportHost() {
const viewportHost = viewportHostInstances.find((instance) => instance.panelId === "topLeft"); const viewportHost = viewportHostInstances.find(
(instance) => instance.panelId === "topLeft"
);
if (viewportHost === undefined) { if (viewportHost === undefined) {
throw new Error("Top-left viewport host was not mounted."); throw new Error("Top-left viewport host was not mounted.");
@@ -193,7 +201,9 @@ async function renderTransformFixtureApp() {
await waitFor(() => { await waitFor(() => {
expect(viewportHostInstances.length).toBeGreaterThan(0); expect(viewportHostInstances.length).toBeGreaterThan(0);
expect(getTopLeftViewportHost().setTransformCommitHandler).toHaveBeenCalled(); expect(
getTopLeftViewportHost().setTransformCommitHandler
).toHaveBeenCalled();
}); });
return { return {
@@ -215,8 +225,11 @@ async function renderQuadTransformFixtureApp() {
return fixture; return fixture;
} }
function getLatestTransformSession(store: ReturnType<typeof createEditorStore>): ActiveTransformSession { function getLatestTransformSession(
const transformSession = store.getState().viewportTransientState.transformSession; store: ReturnType<typeof createEditorStore>
): ActiveTransformSession {
const transformSession =
store.getState().viewportTransientState.transformSession;
if (transformSession.kind !== "active") { if (transformSession.kind !== "active") {
throw new Error("Expected an active transform session."); throw new Error("Expected an active transform session.");
@@ -229,7 +242,9 @@ function emitTransformPreview(
viewportHost: ReturnType<typeof getTopLeftViewportHost>, viewportHost: ReturnType<typeof getTopLeftViewportHost>,
transformSession: ActiveTransformSession transformSession: ActiveTransformSession
) { ) {
const handler = viewportHost.setTransformSessionChangeHandler.mock.calls.at(-1)?.[0] as ((transformSession: TransformSessionState) => void) | undefined; const handler = viewportHost.setTransformSessionChangeHandler.mock.calls.at(
-1
)?.[0] as ((transformSession: TransformSessionState) => void) | undefined;
if (handler === undefined) { if (handler === undefined) {
throw new Error("Transform session change handler was not registered."); throw new Error("Transform session change handler was not registered.");
@@ -240,8 +255,13 @@ function emitTransformPreview(
}); });
} }
function commitTransform(viewportHost: ReturnType<typeof getTopLeftViewportHost>, transformSession: ActiveTransformSession) { function commitTransform(
const handler = viewportHost.setTransformCommitHandler.mock.calls.at(-1)?.[0] as ((transformSession: ActiveTransformSession) => void) | undefined; viewportHost: ReturnType<typeof getTopLeftViewportHost>,
transformSession: ActiveTransformSession
) {
const handler = viewportHost.setTransformCommitHandler.mock.calls.at(
-1
)?.[0] as ((transformSession: ActiveTransformSession) => void) | undefined;
if (handler === undefined) { if (handler === undefined) {
throw new Error("Transform commit handler was not registered."); throw new Error("Transform commit handler was not registered.");
@@ -252,8 +272,13 @@ function commitTransform(viewportHost: ReturnType<typeof getTopLeftViewportHost>
}); });
} }
function emitCameraStateChange(viewportHost: ReturnType<typeof getTopLeftViewportHost>, cameraState: ViewportPanelCameraState) { function emitCameraStateChange(
const handler = viewportHost.setCameraStateChangeHandler.mock.calls.at(-1)?.[0] as ((cameraState: ViewportPanelCameraState) => void) | undefined; viewportHost: ReturnType<typeof getTopLeftViewportHost>,
cameraState: ViewportPanelCameraState
) {
const handler = viewportHost.setCameraStateChangeHandler.mock.calls.at(
-1
)?.[0] as ((cameraState: ViewportPanelCameraState) => void) | undefined;
if (handler === undefined) { if (handler === undefined) {
throw new Error("Camera state change handler was not registered."); throw new Error("Camera state change handler was not registered.");
@@ -267,7 +292,9 @@ function emitCameraStateChange(viewportHost: ReturnType<typeof getTopLeftViewpor
describe("transform foundation integration", () => { describe("transform foundation integration", () => {
beforeEach(() => { beforeEach(() => {
viewportHostInstances.length = 0; viewportHostInstances.length = 0;
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => ({}) as never); vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(
() => ({}) as never
);
}); });
afterEach(() => { afterEach(() => {
@@ -278,7 +305,9 @@ describe("transform foundation integration", () => {
const { store, brush, viewportHost } = await renderTransformFixtureApp(); const { store, brush, viewportHost } = await renderTransformFixtureApp();
await act(async () => { await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /^Brush Transform Fixture$/ })); fireEvent.click(
screen.getByRole("button", { name: /^Brush Transform Fixture$/ })
);
}); });
fireEvent.keyDown(window, { fireEvent.keyDown(window, {
@@ -286,7 +315,9 @@ describe("transform foundation integration", () => {
code: "KeyG" code: "KeyG"
}); });
expect(store.getState().viewportTransientState.transformSession).toMatchObject({ expect(
store.getState().viewportTransientState.transformSession
).toMatchObject({
kind: "active", kind: "active",
operation: "translate", operation: "translate",
axisConstraint: null, axisConstraint: null,
@@ -301,7 +332,9 @@ describe("transform foundation integration", () => {
code: "KeyX" code: "KeyX"
}); });
expect(store.getState().viewportTransientState.transformSession).toMatchObject({ expect(
store.getState().viewportTransientState.transformSession
).toMatchObject({
kind: "active", kind: "active",
axisConstraint: "x" axisConstraint: "x"
}); });
@@ -342,7 +375,9 @@ describe("transform foundation integration", () => {
const { store, brush, viewportHost } = await renderTransformFixtureApp(); const { store, brush, viewportHost } = await renderTransformFixtureApp();
await act(async () => { await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /^Brush Transform Fixture$/ })); fireEvent.click(
screen.getByRole("button", { name: /^Brush Transform Fixture$/ })
);
}); });
fireEvent.click(screen.getByTestId("transform-rotate-button")); fireEvent.click(screen.getByTestId("transform-rotate-button"));
@@ -369,11 +404,13 @@ describe("transform foundation integration", () => {
emitTransformPreview(viewportHost, rotatePreviewSession); emitTransformPreview(viewportHost, rotatePreviewSession);
commitTransform(viewportHost, rotatePreviewSession); commitTransform(viewportHost, rotatePreviewSession);
expect(store.getState().document.brushes[brush.id].rotationDegrees).toEqual({ expect(store.getState().document.brushes[brush.id].rotationDegrees).toEqual(
x: 0, {
y: 37.5, x: 0,
z: 12.5 y: 37.5,
}); z: 12.5
}
);
fireEvent.click(screen.getByTestId("transform-scale-button")); fireEvent.click(screen.getByTestId("transform-scale-button"));
@@ -425,7 +462,9 @@ describe("transform foundation integration", () => {
const { store, brush } = await renderTransformFixtureApp(); const { store, brush } = await renderTransformFixtureApp();
await act(async () => { await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /^Brush Transform Fixture$/ })); fireEvent.click(
screen.getByRole("button", { name: /^Brush Transform Fixture$/ })
);
}); });
expect(screen.getByTestId("transform-translate-button")).not.toBeDisabled(); expect(screen.getByTestId("transform-translate-button")).not.toBeDisabled();
@@ -480,10 +519,13 @@ describe("transform foundation integration", () => {
}); });
it("moves an entity through the shared transform controller", async () => { it("moves an entity through the shared transform controller", async () => {
const { store, playerStart, viewportHost } = await renderTransformFixtureApp(); const { store, playerStart, viewportHost } =
await renderTransformFixtureApp();
await act(async () => { await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /^Player Start Fixture$/ })); fireEvent.click(
screen.getByRole("button", { name: /^Player Start Fixture$/ })
);
}); });
fireEvent.keyDown(window, { fireEvent.keyDown(window, {
@@ -520,10 +562,13 @@ describe("transform foundation integration", () => {
}); });
it("cancels an active transform with Escape without committing preview changes", async () => { it("cancels an active transform with Escape without committing preview changes", async () => {
const { store, playerStart, viewportHost } = await renderTransformFixtureApp(); const { store, playerStart, viewportHost } =
await renderTransformFixtureApp();
await act(async () => { await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /^Player Start Fixture$/ })); fireEvent.click(
screen.getByRole("button", { name: /^Player Start Fixture$/ })
);
}); });
fireEvent.keyDown(window, { fireEvent.keyDown(window, {
@@ -555,14 +600,19 @@ describe("transform foundation integration", () => {
expect(store.getState().viewportTransientState.transformSession).toEqual({ expect(store.getState().viewportTransientState.transformSession).toEqual({
kind: "none" kind: "none"
}); });
expect(store.getState().document.entities[playerStart.id]).toEqual(playerStart); expect(store.getState().document.entities[playerStart.id]).toEqual(
playerStart
);
}); });
it("moves a model instance through the shared transform controller", async () => { it("moves a model instance through the shared transform controller", async () => {
const { store, modelInstance, viewportHost } = await renderTransformFixtureApp(); const { store, modelInstance, viewportHost } =
await renderTransformFixtureApp();
await act(async () => { await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /^Model Transform Fixture$/ })); fireEvent.click(
screen.getByRole("button", { name: /^Model Transform Fixture$/ })
);
}); });
fireEvent.keyDown(window, { fireEvent.keyDown(window, {
@@ -591,7 +641,9 @@ describe("transform foundation integration", () => {
emitTransformPreview(viewportHost, previewSession); emitTransformPreview(viewportHost, previewSession);
commitTransform(viewportHost, previewSession); commitTransform(viewportHost, previewSession);
expect(store.getState().document.modelInstances[modelInstance.id]).toMatchObject({ expect(
store.getState().document.modelInstances[modelInstance.id]
).toMatchObject({
position: { position: {
x: -1, x: -1,
y: 0, y: 0,
@@ -604,7 +656,9 @@ describe("transform foundation integration", () => {
const { store, brush } = await renderQuadTransformFixtureApp(); const { store, brush } = await renderQuadTransformFixtureApp();
await act(async () => { await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /^Brush Transform Fixture$/ })); fireEvent.click(
screen.getByRole("button", { name: /^Brush Transform Fixture$/ })
);
}); });
fireEvent.pointerMove(screen.getByTestId("viewport-panel-bottomRight"), { fireEvent.pointerMove(screen.getByTestId("viewport-panel-bottomRight"), {
@@ -617,7 +671,9 @@ describe("transform foundation integration", () => {
}); });
expect(store.getState().activeViewportPanelId).toBe("bottomRight"); expect(store.getState().activeViewportPanelId).toBe("bottomRight");
expect(store.getState().viewportTransientState.transformSession).toMatchObject({ expect(
store.getState().viewportTransientState.transformSession
).toMatchObject({
kind: "active", kind: "active",
operation: "translate", operation: "translate",
sourcePanelId: "bottomRight", sourcePanelId: "bottomRight",
@@ -632,7 +688,9 @@ describe("transform foundation integration", () => {
const { store } = await renderTransformFixtureApp(); const { store } = await renderTransformFixtureApp();
await act(async () => { await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /^Brush Transform Fixture$/ })); fireEvent.click(
screen.getByRole("button", { name: /^Brush Transform Fixture$/ })
);
}); });
fireEvent.keyDown(window, { fireEvent.keyDown(window, {
@@ -644,7 +702,9 @@ describe("transform foundation integration", () => {
code: "KeyZ" code: "KeyZ"
}); });
expect(store.getState().viewportTransientState.transformSession).toMatchObject({ expect(
store.getState().viewportTransientState.transformSession
).toMatchObject({
kind: "active", kind: "active",
axisConstraint: "z", axisConstraint: "z",
axisConstraintSpace: "world" axisConstraintSpace: "world"
@@ -655,12 +715,16 @@ describe("transform foundation integration", () => {
code: "KeyZ" code: "KeyZ"
}); });
expect(store.getState().viewportTransientState.transformSession).toMatchObject({ expect(
store.getState().viewportTransientState.transformSession
).toMatchObject({
kind: "active", kind: "active",
axisConstraint: "z", axisConstraint: "z",
axisConstraintSpace: "local" axisConstraintSpace: "local"
}); });
expect(screen.getByText(/constrained move to local z\./i)).toBeInTheDocument(); expect(
screen.getByText(/constrained move to local z\./i)
).toBeInTheDocument();
}); });
it("keeps the persisted viewport camera state stable across transform commit, cancel, and delete", async () => { it("keeps the persisted viewport camera state stable across transform commit, cancel, and delete", async () => {
@@ -681,10 +745,14 @@ describe("transform foundation integration", () => {
emitCameraStateChange(viewportHost, persistedCameraState); emitCameraStateChange(viewportHost, persistedCameraState);
expect(store.getState().viewportPanels.topLeft.cameraState).toEqual(persistedCameraState); expect(store.getState().viewportPanels.topLeft.cameraState).toEqual(
persistedCameraState
);
await act(async () => { await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /^Brush Transform Fixture$/ })); fireEvent.click(
screen.getByRole("button", { name: /^Brush Transform Fixture$/ })
);
}); });
fireEvent.keyDown(window, { fireEvent.keyDown(window, {
@@ -713,9 +781,13 @@ describe("transform foundation integration", () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(viewportHost.setCameraState.mock.calls.length).toBeGreaterThan(commitCameraCallCount); expect(viewportHost.setCameraState.mock.calls.length).toBeGreaterThan(
commitCameraCallCount
);
}); });
expect(viewportHost.setCameraState.mock.calls.at(-1)?.[0]).toEqual(persistedCameraState); expect(viewportHost.setCameraState.mock.calls.at(-1)?.[0]).toEqual(
persistedCameraState
);
fireEvent.keyDown(window, { fireEvent.keyDown(window, {
key: "g", key: "g",
@@ -730,9 +802,13 @@ describe("transform foundation integration", () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(viewportHost.setCameraState.mock.calls.length).toBeGreaterThan(cancelCameraCallCount); expect(viewportHost.setCameraState.mock.calls.length).toBeGreaterThan(
cancelCameraCallCount
);
}); });
expect(viewportHost.setCameraState.mock.calls.at(-1)?.[0]).toEqual(persistedCameraState); expect(viewportHost.setCameraState.mock.calls.at(-1)?.[0]).toEqual(
persistedCameraState
);
vi.spyOn(window, "confirm").mockReturnValue(true); vi.spyOn(window, "confirm").mockReturnValue(true);
const deleteCameraCallCount = viewportHost.setCameraState.mock.calls.length; const deleteCameraCallCount = viewportHost.setCameraState.mock.calls.length;
@@ -743,9 +819,13 @@ describe("transform foundation integration", () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(viewportHost.setCameraState.mock.calls.length).toBeGreaterThan(deleteCameraCallCount); expect(viewportHost.setCameraState.mock.calls.length).toBeGreaterThan(
deleteCameraCallCount
);
}); });
expect(viewportHost.setCameraState.mock.calls.at(-1)?.[0]).toEqual(persistedCameraState); expect(viewportHost.setCameraState.mock.calls.at(-1)?.[0]).toEqual(
persistedCameraState
);
}); });
it("toggles viewport grid visibility through the shared viewport host path", async () => { it("toggles viewport grid visibility through the shared viewport host path", async () => {
@@ -756,7 +836,9 @@ describe("transform foundation integration", () => {
fireEvent.click(screen.getByTestId("viewport-grid-toggle")); fireEvent.click(screen.getByTestId("viewport-grid-toggle"));
await waitFor(() => { await waitFor(() => {
expect(viewportHost.setGridVisible.mock.calls.length).toBeGreaterThan(initialCallCount); expect(viewportHost.setGridVisible.mock.calls.length).toBeGreaterThan(
initialCallCount
);
}); });
expect(viewportHost.setGridVisible.mock.calls.at(-1)?.[0]).toBe(false); expect(viewportHost.setGridVisible.mock.calls.at(-1)?.[0]).toBe(false);
expect(screen.getByText(/viewport grid hidden\./i)).toBeInTheDocument(); expect(screen.getByText(/viewport grid hidden\./i)).toBeInTheDocument();

View File

@@ -1,11 +1,21 @@
import { render, waitFor } from "@testing-library/react"; import { render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createInactiveTransformSession, type ActiveTransformSession, type TransformSessionState } from "../../src/core/transform-session"; import {
createInactiveTransformSession,
type ActiveTransformSession,
type TransformSessionState
} from "../../src/core/transform-session";
import { createEmptySceneDocument } from "../../src/document/scene-document"; import { createEmptySceneDocument } from "../../src/document/scene-document";
import { ViewportCanvas } from "../../src/viewport-three/ViewportCanvas"; import { ViewportCanvas } from "../../src/viewport-three/ViewportCanvas";
import { createDefaultViewportPanelCameraState, type ViewportPanelCameraState } from "../../src/viewport-three/viewport-layout"; import {
import type { CreationViewportToolPreview, ViewportToolPreview } from "../../src/viewport-three/viewport-transient-state"; createDefaultViewportPanelCameraState,
type ViewportPanelCameraState
} from "../../src/viewport-three/viewport-layout";
import type {
CreationViewportToolPreview,
ViewportToolPreview
} from "../../src/viewport-three/viewport-transient-state";
const { MockViewportHost, viewportHostInstances } = vi.hoisted(() => { const { MockViewportHost, viewportHostInstances } = vi.hoisted(() => {
const viewportHostInstances: Array<{ const viewportHostInstances: Array<{
@@ -81,7 +91,9 @@ vi.mock("../../src/viewport-three/viewport-host", () => ({
describe("ViewportCanvas", () => { describe("ViewportCanvas", () => {
beforeEach(() => { beforeEach(() => {
viewportHostInstances.length = 0; viewportHostInstances.length = 0;
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => ({}) as never); vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(
() => ({}) as never
);
}); });
afterEach(() => { afterEach(() => {
@@ -100,10 +112,18 @@ describe("ViewportCanvas", () => {
center: null center: null
}; };
const onCommitCreation = vi.fn(() => true); const onCommitCreation = vi.fn(() => true);
const onCameraStateChange = vi.fn((_cameraState: ViewportPanelCameraState) => undefined); const onCameraStateChange = vi.fn(
const onToolPreviewChange = vi.fn((_toolPreview: ViewportToolPreview) => undefined); (_cameraState: ViewportPanelCameraState) => undefined
const onTransformSessionChange = vi.fn((_transformSession: TransformSessionState) => undefined); );
const onTransformCommit = vi.fn((_transformSession: ActiveTransformSession) => undefined); const onToolPreviewChange = vi.fn(
(_toolPreview: ViewportToolPreview) => undefined
);
const onTransformSessionChange = vi.fn(
(_transformSession: TransformSessionState) => undefined
);
const onTransformCommit = vi.fn(
(_transformSession: ActiveTransformSession) => undefined
);
const onTransformCancel = vi.fn(() => undefined); const onTransformCancel = vi.fn(() => undefined);
const onSelectionChange = vi.fn(); const onSelectionChange = vi.fn();
@@ -142,10 +162,13 @@ describe("ViewportCanvas", () => {
await waitFor(() => { await waitFor(() => {
expect(viewportHostInstances).toHaveLength(1); expect(viewportHostInstances).toHaveLength(1);
expect(viewportHostInstances[0].setCreationCommitHandler).toHaveBeenCalledTimes(1); expect(
viewportHostInstances[0].setCreationCommitHandler
).toHaveBeenCalledTimes(1);
}); });
const registeredHandler = viewportHostInstances[0].setCreationCommitHandler.mock.calls[0][0] as ( const registeredHandler = viewportHostInstances[0].setCreationCommitHandler
.mock.calls[0][0] as (
toolPreview: CreationViewportToolPreview toolPreview: CreationViewportToolPreview
) => boolean; ) => boolean;
@@ -156,7 +179,9 @@ describe("ViewportCanvas", () => {
it("applies and subscribes to persisted camera state through the viewport host", async () => { it("applies and subscribes to persisted camera state through the viewport host", async () => {
const sceneDocument = createEmptySceneDocument(); const sceneDocument = createEmptySceneDocument();
const cameraState = createDefaultViewportPanelCameraState(); const cameraState = createDefaultViewportPanelCameraState();
const onCameraStateChange = vi.fn((_cameraState: ViewportPanelCameraState) => undefined); const onCameraStateChange = vi.fn(
(_cameraState: ViewportPanelCameraState) => undefined
);
render( render(
<ViewportCanvas <ViewportCanvas
@@ -193,8 +218,12 @@ describe("ViewportCanvas", () => {
await waitFor(() => { await waitFor(() => {
expect(viewportHostInstances).toHaveLength(1); expect(viewportHostInstances).toHaveLength(1);
expect(viewportHostInstances[0].setCameraState).toHaveBeenCalledWith(cameraState); expect(viewportHostInstances[0].setCameraState).toHaveBeenCalledWith(
expect(viewportHostInstances[0].setCameraStateChangeHandler).toHaveBeenCalledTimes(1); cameraState
);
expect(
viewportHostInstances[0].setCameraStateChangeHandler
).toHaveBeenCalledTimes(1);
}); });
}); });
}); });