Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 150 additions & 2 deletions editor/src/editor/layout/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Grid } from "react-loader-spinner";

import { FaCheck } from "react-icons/fa6";
import { IoIosStats } from "react-icons/io";
import { LuMove3D, LuRotate3D, LuScale3D } from "react-icons/lu";
import { LuGrid3X3, LuMove3D, LuRotate3D, LuScale3D, LuScaling, LuRotateCw } from "react-icons/lu";
import { GiArrowCursor, GiTeapot, GiWireframeGlobe } from "react-icons/gi";

import {
Expand Down Expand Up @@ -38,6 +38,7 @@ import {

import { Button } from "../../ui/shadcn/ui/button";
import { Toggle } from "../../ui/shadcn/ui/toggle";
import { EditorInspectorNumberField } from "./inspector/fields/number";
import { Progress } from "../../ui/shadcn/ui/progress";
import { ToolbarRadioGroup, ToolbarRadioGroupItem } from "../../ui/shadcn/ui/toolbar-radio-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/shadcn/ui/select";
Expand All @@ -57,9 +58,16 @@ import { getCameraFocusPositionFor } from "../../tools/camera/focus";
import { ITweenConfiguration, Tween } from "../../tools/animation/tween";
import { checkProjectCachedCompressedTextures } from "../../tools/assets/ktx";
import { createSceneLink, getRootSceneLink } from "../../tools/scene/scene-link";
import {
defaultGizmoSnapPreferences,
gizmoSnapMinStep,
IGizmoSnapPreferences,
roundGizmoSnapSteps,
} from "../../tools/gizmo-snap-preferences";
import { UniqueNumber, waitNextAnimationFrame, waitUntil } from "../../tools/tools";
import { isSprite, isSpriteManagerNode, isSpriteMapNode } from "../../tools/guards/sprites";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "../../ui/shadcn/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "../../ui/shadcn/ui/popover";
import { isAbstractMesh, isAnyTransformNode, isCamera, isCollisionInstancedMesh, isCollisionMesh, isInstancedMesh, isLight, isMesh, isNode } from "../../tools/guards/nodes";

import { EditorCamera } from "../nodes/camera";
Expand Down Expand Up @@ -125,6 +133,8 @@ export interface IEditorPreviewState {
* "fit" means the canvas will fit the entire panel container.
*/
fixedDimensions: "720p" | "1080p" | "4k" | "fit";

gizmoSnap: IGizmoSnapPreferences;
}

export class EditorPreview extends Component<IEditorPreviewProps, IEditorPreviewState> {
Expand Down Expand Up @@ -178,6 +188,16 @@ export class EditorPreview extends Component<IEditorPreviewProps, IEditorPreview
private _workingCanvas: HTMLCanvasElement | null = null;
private _mainView: EngineView | null = null;

/**
* Mutable holder for gizmo snap step fields; EditorInspectorNumberField writes via setInspectorEffectivePropertyValue.
* Synced from state when rendering the gizmo snap toolbar.
*/
private _gizmoSnapNumberFields: Pick<IGizmoSnapPreferences, "translationStep" | "rotationStepDegrees" | "scaleStep"> = {
translationStep: 0,
rotationStepDegrees: 0,
scaleStep: 0,
};

/** @internal */
public _previewCamera: Camera | null = null;

Expand All @@ -195,6 +215,8 @@ export class EditorPreview extends Component<IEditorPreviewProps, IEditorPreview

playEnabled: false,
playSceneLoadingProgress: 0,

gizmoSnap: { ...defaultGizmoSnapPreferences },
};

ipcRenderer.on("gizmo:position", () => this.setActiveGizmo("position"));
Expand Down Expand Up @@ -527,6 +549,7 @@ export class EditorPreview extends Component<IEditorPreviewProps, IEditorPreview
this.camera.attachControl(true);

this.gizmo = new EditorPreviewGizmo(this.scene);
this.gizmo.setSnapPreferences(this.state.gizmoSnap);

this.engine.hideLoadingUI();
this._mainView = this.engine.registerView(this.canvas);
Expand Down Expand Up @@ -877,9 +900,130 @@ export class EditorPreview extends Component<IEditorPreviewProps, IEditorPreview
);
}

private _commitGizmoSnap(next: IGizmoSnapPreferences): void {
const normalized = roundGizmoSnapSteps(next);
this.setState({ gizmoSnap: normalized });
this.gizmo?.setSnapPreferences(normalized);
}

public updateGizmoSnapPreferences(prefs: IGizmoSnapPreferences): void {
this._commitGizmoSnap({ ...prefs });
}

private _getGizmoSnapToolbarControls(): ReactNode {
const snap = this.state.gizmoSnap;
const min = gizmoSnapMinStep;

this._gizmoSnapNumberFields.translationStep = snap.translationStep;
this._gizmoSnapNumberFields.rotationStepDegrees = snap.rotationStepDegrees;
this._gizmoSnapNumberFields.scaleStep = snap.scaleStep;

const bumpTranslation = (v: number) => this._commitGizmoSnap({ ...snap, translationStep: Math.max(min, v) });
const bumpRotation = (v: number) => this._commitGizmoSnap({ ...snap, rotationStepDegrees: Math.max(min, v) });
const bumpScale = (v: number) => this._commitGizmoSnap({ ...snap, scaleStep: Math.max(min, v) });

const snapRowClass = "grid grid-cols-[minmax(0,7rem)_auto_minmax(0,1fr)] items-center gap-3";
const snapToggleClass = (enabled: boolean) =>
`rounded-md border border-input h-9 w-9 px-0 shrink-0 justify-center shadow-sm ${enabled ? "bg-primary/20" : "bg-background"}`;

return (
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="outline" className="h-9 px-3 shrink-0 border-input bg-background shadow-sm">
Snap
</Button>
</PopoverTrigger>
<PopoverContent align="start" side="bottom" className="w-auto max-w-none min-w-[20rem] p-4">
<div className="flex flex-col gap-3">
<div className={snapRowClass}>
<div className="text-sm font-medium text-muted-foreground">Translation</div>
<Tooltip>
<TooltipTrigger asChild>
<Toggle
pressed={snap.translationEnabled}
onPressedChange={(on) => this._commitGizmoSnap({ ...snap, translationEnabled: on })}
className={snapToggleClass(snap.translationEnabled)}
aria-label="Translation grid snap"
>
<LuGrid3X3 className="h-4 w-4" />
</Toggle>
</TooltipTrigger>
<TooltipContent>Translation grid snap</TooltipContent>
</Tooltip>
<div className="min-w-0">
<EditorInspectorNumberField
object={this._gizmoSnapNumberFields}
property="translationStep"
noUndoRedo
step={0.01}
min={min}
onChange={(v) => bumpTranslation(v)}
/>
</div>
</div>

<div className={snapRowClass}>
<div className="text-sm font-medium text-muted-foreground">Rotation</div>
<Tooltip>
<TooltipTrigger asChild>
<Toggle
pressed={snap.rotationEnabled}
onPressedChange={(on) => this._commitGizmoSnap({ ...snap, rotationEnabled: on })}
className={snapToggleClass(snap.rotationEnabled)}
aria-label="Rotation snap"
>
<LuRotateCw className="h-4 w-4" />
</Toggle>
</TooltipTrigger>
<TooltipContent>Rotation snap (degrees)</TooltipContent>
</Tooltip>
<div className="min-w-0">
<EditorInspectorNumberField
object={this._gizmoSnapNumberFields}
property="rotationStepDegrees"
noUndoRedo
step={0.01}
min={min}
onChange={(v) => bumpRotation(v)}
/>
</div>
</div>

<div className={snapRowClass}>
<div className="text-sm font-medium text-muted-foreground">Scale</div>
<Tooltip>
<TooltipTrigger asChild>
<Toggle
pressed={snap.scaleEnabled}
onPressedChange={(on) => this._commitGizmoSnap({ ...snap, scaleEnabled: on })}
className={snapToggleClass(snap.scaleEnabled)}
aria-label="Scale snap"
>
<LuScaling className="h-4 w-4" />
</Toggle>
</TooltipTrigger>
<TooltipContent>Scale snap (incremental step)</TooltipContent>
</Tooltip>
<div className="min-w-0">
<EditorInspectorNumberField
object={this._gizmoSnapNumberFields}
property="scaleStep"
noUndoRedo
step={0.01}
min={min}
onChange={(v) => bumpScale(v)}
/>
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}

private _getEditToolbar(): ReactNode {
return (
<div className="flex gap-2 items-center h-10">
<div className="flex flex-wrap gap-2 items-center h-10">
<TooltipProvider>
<Select value={this.scene?.activeCamera?.id} onOpenChange={(o) => o && this.forceUpdate()} onValueChange={(v) => this._switchToCamera(v)}>
<SelectTrigger className="w-36 border-none bg-muted/50">
Expand Down Expand Up @@ -960,6 +1104,10 @@ export class EditorPreview extends Component<IEditorPreviewProps, IEditorPreview

<Separator orientation="vertical" className="mx-1 h-[24px]" />

{this._getGizmoSnapToolbarControls()}

<Separator orientation="vertical" className="mx-1 h-[24px]" />

<Select
value={this.gizmo?.getCoordinateMode().toString()}
onValueChange={(v) => {
Expand Down
32 changes: 32 additions & 0 deletions editor/src/editor/layout/preview/gizmo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
RotationGizmo,
ScaleGizmo,
Scene,
Tools,
UtilityLayerRenderer,
Vector3,
CameraGizmo,
Expand All @@ -15,6 +16,7 @@ import {
Sprite,
} from "babylonjs";

import { defaultGizmoSnapPreferences, IGizmoSnapPreferences } from "../../../tools/gizmo-snap-preferences";
import { isSprite } from "../../../tools/guards/sprites";
import { registerUndoRedo } from "../../../tools/undoredo";
import { isNodeLocked } from "../../../tools/node/metadata";
Expand Down Expand Up @@ -44,6 +46,8 @@ export class EditorPreviewGizmo {

private _spriteTransformNode: TransformNode;

private _snapPreferences: IGizmoSnapPreferences = { ...defaultGizmoSnapPreferences };

public constructor(scene: Scene) {
this._gizmosLayer = new UtilityLayerRenderer(scene);
this._gizmosLayer.utilityLayerScene.postProcessesEnabled = false;
Expand Down Expand Up @@ -105,6 +109,34 @@ export class EditorPreviewGizmo {
this._spriteTransformNode.billboardMode = this._scalingGizmo || this._rotationGizmo ? TransformNode.BILLBOARDMODE_ALL : TransformNode.BILLBOARDMODE_NONE;

this.setAttachedObject(this._attachedSprite ?? this._attachedNode);
this._applySnapToCurrentGizmos();
}

public getSnapPreferences(): IGizmoSnapPreferences {
return { ...this._snapPreferences };
}

public setSnapPreferences(prefs: IGizmoSnapPreferences): void {
this._snapPreferences = { ...prefs };
this._applySnapToCurrentGizmos();
}

private _applySnapToCurrentGizmos(): void {
if (this._positionGizmo) {
const enabled = this._snapPreferences.translationEnabled && this._snapPreferences.translationStep > 0;
this._positionGizmo.snapDistance = enabled ? this._snapPreferences.translationStep : 0;
}

if (this._rotationGizmo) {
const enabled = this._snapPreferences.rotationEnabled && this._snapPreferences.rotationStepDegrees > 0;
this._rotationGizmo.snapDistance = enabled ? Tools.ToRadians(this._snapPreferences.rotationStepDegrees) : 0;
}

if (this._scalingGizmo) {
const enabled = this._snapPreferences.scaleEnabled && this._snapPreferences.scaleStep > 0;
this._scalingGizmo.incrementalSnap = true;
this._scalingGizmo.snapDistance = enabled ? this._snapPreferences.scaleStep : 0;
}
}

/**
Expand Down
3 changes: 3 additions & 0 deletions editor/src/project/load/load.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { requirePlugin } from "../../tools/plugins/require";

import { EditorProjectPackageManager, IEditorProject } from "../typings";
import { projectConfiguration } from "../configuration";
import { defaultGizmoSnapPreferences, roundGizmoSnapSteps } from "../../tools/gizmo-snap-preferences";

import { loadScene } from "./scene";
import { LoadScenePrepareComponent } from "./prepare";
Expand All @@ -26,6 +27,7 @@ export async function loadProject(editor: Editor, path: string) {
const directory = dirname(path);
const project = (await readJSON(path, "utf-8")) as IEditorProject;
const packageManager = project.packageManager ?? "yarn";
const gizmoSnap = roundGizmoSnapSteps({ ...defaultGizmoSnapPreferences, ...project.gizmoSnap });

editor.setState({
packageManager,
Expand All @@ -38,6 +40,7 @@ export async function loadProject(editor: Editor, path: string) {
});

editor.layout.forceUpdate();
editor.layout.preview?.updateGizmoSnapPreferences(gizmoSnap);

projectConfiguration.compressedTexturesEnabled = project.compressedTexturesEnabled ?? false;

Expand Down
2 changes: 2 additions & 0 deletions editor/src/project/save/save.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export async function saveProjectConfiguration(editor: Editor) {

compressedTexturesEnabled: editor.state.compressedTexturesEnabled,
compressedTexturesEnabledInPreview: editor.state.compressedTexturesEnabledInPreview,

gizmoSnap: editor.layout.preview?.state.gizmoSnap,
};

if (!editor.props.editedScenePath) {
Expand Down
7 changes: 7 additions & 0 deletions editor/src/project/typings.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { IGizmoSnapPreferences } from "../tools/gizmo-snap-preferences";

export interface IEditorProject {
/**
* The version of the editor that saved this project.
Expand Down Expand Up @@ -26,6 +28,11 @@ export interface IEditorProject {
* The package manager being used by the project.
*/
packageManager?: EditorProjectPackageManager;

/**
* Gizmo snap preferences (translate / rotate / scale).
*/
gizmoSnap?: IGizmoSnapPreferences;
}

export interface IEditorProjectPlugin {
Expand Down
40 changes: 40 additions & 0 deletions editor/src/tools/gizmo-snap-preferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/** Minimum snap step (two-decimal increments cannot be smaller than 0.01). */
export const gizmoSnapMinStep = 0.01;

const snapDecimalRoundFactor = 100;

export interface IGizmoSnapPreferences {
translationEnabled: boolean;
translationStep: number;
rotationEnabled: boolean;
rotationStepDegrees: number;
scaleEnabled: boolean;
scaleStep: number;
}

/**
* Snap steps are stored and applied with at most two decimal places.
*/
export function roundGizmoSnapSteps(prefs: IGizmoSnapPreferences): IGizmoSnapPreferences {
const roundStep = (value: number): number => {
const clampedLow = Math.max(gizmoSnapMinStep, value);
const rounded = Math.round(clampedLow * snapDecimalRoundFactor) / snapDecimalRoundFactor;
return Math.max(gizmoSnapMinStep, rounded);
};

return {
...prefs,
translationStep: roundStep(prefs.translationStep),
rotationStepDegrees: roundStep(prefs.rotationStepDegrees),
scaleStep: roundStep(prefs.scaleStep),
};
}

export const defaultGizmoSnapPreferences: IGizmoSnapPreferences = {
translationEnabled: false,
translationStep: 1,
rotationEnabled: false,
rotationStepDegrees: 15,
scaleEnabled: false,
scaleStep: 0.25,
};