diff --git a/editor/src/editor/layout/preview.tsx b/editor/src/editor/layout/preview.tsx index b8a6afe8e..2bde3ca7c 100644 --- a/editor/src/editor/layout/preview.tsx +++ b/editor/src/editor/layout/preview.tsx @@ -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 { @@ -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"; @@ -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"; @@ -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 { @@ -178,6 +188,16 @@ export class EditorPreview extends Component = { + translationStep: 0, + rotationStepDegrees: 0, + scaleStep: 0, + }; + /** @internal */ public _previewCamera: Camera | null = null; @@ -195,6 +215,8 @@ export class EditorPreview extends Component this.setActiveGizmo("position")); @@ -527,6 +549,7 @@ export class EditorPreview extends Component 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 ( + + + + + +
+
+
Translation
+ + + this._commitGizmoSnap({ ...snap, translationEnabled: on })} + className={snapToggleClass(snap.translationEnabled)} + aria-label="Translation grid snap" + > + + + + Translation grid snap + +
+ bumpTranslation(v)} + /> +
+
+ +
+
Rotation
+ + + this._commitGizmoSnap({ ...snap, rotationEnabled: on })} + className={snapToggleClass(snap.rotationEnabled)} + aria-label="Rotation snap" + > + + + + Rotation snap (degrees) + +
+ bumpRotation(v)} + /> +
+
+ +
+
Scale
+ + + this._commitGizmoSnap({ ...snap, scaleEnabled: on })} + className={snapToggleClass(snap.scaleEnabled)} + aria-label="Scale snap" + > + + + + Scale snap (incremental step) + +
+ bumpScale(v)} + /> +
+
+
+
+
+ ); + } + private _getEditToolbar(): ReactNode { return ( -
+
{ diff --git a/editor/src/editor/layout/preview/gizmo.ts b/editor/src/editor/layout/preview/gizmo.ts index d4ab3ac60..1a4a2a039 100644 --- a/editor/src/editor/layout/preview/gizmo.ts +++ b/editor/src/editor/layout/preview/gizmo.ts @@ -7,6 +7,7 @@ import { RotationGizmo, ScaleGizmo, Scene, + Tools, UtilityLayerRenderer, Vector3, CameraGizmo, @@ -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"; @@ -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; @@ -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; + } } /** diff --git a/editor/src/project/load/load.tsx b/editor/src/project/load/load.tsx index e4daebda9..0002d72b4 100644 --- a/editor/src/project/load/load.tsx +++ b/editor/src/project/load/load.tsx @@ -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"; @@ -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, @@ -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; diff --git a/editor/src/project/save/save.tsx b/editor/src/project/save/save.tsx index afacf7615..6dec69b43 100644 --- a/editor/src/project/save/save.tsx +++ b/editor/src/project/save/save.tsx @@ -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) { diff --git a/editor/src/project/typings.ts b/editor/src/project/typings.ts index f9b4e2605..07d2a1b14 100644 --- a/editor/src/project/typings.ts +++ b/editor/src/project/typings.ts @@ -1,3 +1,5 @@ +import { IGizmoSnapPreferences } from "../tools/gizmo-snap-preferences"; + export interface IEditorProject { /** * The version of the editor that saved this project. @@ -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 { diff --git a/editor/src/tools/gizmo-snap-preferences.ts b/editor/src/tools/gizmo-snap-preferences.ts new file mode 100644 index 000000000..4c2b7dcce --- /dev/null +++ b/editor/src/tools/gizmo-snap-preferences.ts @@ -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, +};