From 16a3f63a554892c501937454ea8ba09a35bfc69f Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Mon, 30 Mar 2026 16:37:35 -0700 Subject: [PATCH 1/6] feat: viewport gizmo grid snapping (translate, rotate, scale) Add localStorage-backed snap preferences, UE-style toolbar toggles with inspector-style scrub fields, and optional Edit Preferences section. Wire Babylon PositionGizmo, RotationGizmo, and ScaleGizmo snapDistance. --- .../edit-preferences/edit-preferences.tsx | 98 +++++++++++++- .../editor/layout/inspector/fields/number.tsx | 94 +++++++++---- editor/src/editor/layout/preview.tsx | 126 +++++++++++++++++- editor/src/editor/layout/preview/gizmo.ts | 32 +++++ editor/src/tools/gizmo-snap-preferences.ts | 82 ++++++++++++ 5 files changed, 404 insertions(+), 28 deletions(-) create mode 100644 editor/src/tools/gizmo-snap-preferences.ts diff --git a/editor/src/editor/dialogs/edit-preferences/edit-preferences.tsx b/editor/src/editor/dialogs/edit-preferences/edit-preferences.tsx index bee5ca074..b9c613de4 100644 --- a/editor/src/editor/dialogs/edit-preferences/edit-preferences.tsx +++ b/editor/src/editor/dialogs/edit-preferences/edit-preferences.tsx @@ -3,14 +3,21 @@ import { Component, ReactNode } from "react"; import { Label } from "../../../ui/shadcn/ui/label"; import { Switch } from "../../../ui/shadcn/ui/switch"; +import { EditorInspectorNumberField } from "../../layout/inspector/fields/number"; import { Separator } from "../../../ui/shadcn/ui/separator"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../ui/shadcn/ui/select"; import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "../../../ui/shadcn/ui/alert-dialog"; import { trySetExperimentalFeaturesEnabledInLocalStorage } from "../../../tools/local-storage"; +import { + GIZMO_SNAP_MIN_STEP, + IGizmoSnapPreferences, + loadGizmoSnapPreferences, + roundGizmoSnapSteps, + saveGizmoSnapPreferences, +} from "../../../tools/gizmo-snap-preferences"; import { EditorInspectorKeyField } from "../../layout/inspector/fields/key"; -import { EditorInspectorNumberField } from "../../layout/inspector/fields/number"; import { Editor } from "../../main"; @@ -28,6 +35,7 @@ export interface IEditorEditPreferencesComponentProps { export interface IEditorEditPreferencesComponentState { theme: "light" | "dark"; + gizmoSnap: IGizmoSnapPreferences; } export class EditorEditPreferencesComponent extends Component { @@ -36,9 +44,16 @@ export class EditorEditPreferencesComponent extends Component @@ -53,6 +68,8 @@ export class EditorEditPreferencesComponent extends Component {this._getCameraControlPreferences()} + {this._getGizmoSnapPreferencesSection()} + {this._getExperimentalComponent()} @@ -176,6 +193,85 @@ export class EditorEditPreferencesComponent extends Component + +

Default translate, rotate, and scale snap for viewport gizmos (also editable in the preview toolbar).

+ +
+
+
+ this._commitGizmoSnapFromPreferences({ ...snap, translationEnabled: on })} /> + +
+ this._commitGizmoSnapFromPreferences({ ...snap, translationStep: v })} + /> +
+ +
+
+ this._commitGizmoSnapFromPreferences({ ...snap, rotationEnabled: on })} /> + +
+ this._commitGizmoSnapFromPreferences({ ...snap, rotationStepDegrees: v })} + /> +
+ +
+
+ this._commitGizmoSnapFromPreferences({ ...snap, scaleEnabled: on })} /> + +
+ this._commitGizmoSnapFromPreferences({ ...snap, scaleStep: v })} + /> +
+
+ + ); + } + private _saveCameraControls(): void { const camera = this.props.editor.layout?.preview?.camera; if (!camera) { diff --git a/editor/src/editor/layout/inspector/fields/number.tsx b/editor/src/editor/layout/inspector/fields/number.tsx index 9cb28b007..9e9177fed 100644 --- a/editor/src/editor/layout/inspector/fields/number.tsx +++ b/editor/src/editor/layout/inspector/fields/number.tsx @@ -8,6 +8,7 @@ import { Scalar, Tools } from "babylonjs"; import Mexp from "math-expression-evaluator"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../../../../ui/shadcn/ui/tooltip"; +import { cn } from "../../../../ui/utils"; import { registerSimpleUndoRedo } from "../../../../tools/undoredo"; import { getInspectorPropertyValue, setInspectorEffectivePropertyValue } from "../../../../tools/property"; @@ -16,7 +17,7 @@ import { IEditorInspectorFieldProps } from "./field"; const mexp = new Mexp(); -export interface IEditorInspectorNumberFieldProps extends IEditorInspectorFieldProps { +export interface IEditorInspectorNumberFieldProps extends Partial { min?: number; max?: number; @@ -27,24 +28,42 @@ export interface IEditorInspectorNumberFieldProps extends IEditorInspectorFieldP onChange?: (value: number) => void; onFinishChange?: (value: number, oldValue: number) => void; + + /** When set, value is driven from React state; object/property and inspector mutation are skipped. */ + controlledValue?: number; + /** Overrides fractional digits for display/scrub (defaults from step). */ + decimals?: number; + + wrapperClassName?: string; + inputClassName?: string; + title?: string; } export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldProps) { + const isControlled = props.controlledValue !== undefined; + const [shiftDown, setShiftDown] = useState(false); const [pointerOver, setPointerOver] = useState(false); const [warning, setWarning] = useState(false); const step = props.step ?? 0.01; - const digitCount = props.step?.toString().split(".")[1]?.length ?? 2; + const digitCount = props.decimals ?? (props.step?.toString().split(".")[1]?.length ?? 2); - const [value, setValue] = useState(getStartValue()); - const [oldValue, setOldValue] = useState(getStartValue()); + const [value, setValue] = useState(() => formatInitial()); + const [oldValue, setOldValue] = useState(() => formatInitial()); + + function formatInitial(): string { + const n = getStartValue(); + return typeof n === "number" && Number.isFinite(n) ? n.toFixed(digitCount) : String(n); + } useEffect(() => { - setValue(getStartValue()); - setOldValue(getStartValue()); - }, [props.object, props.property, props.step]); + const n = getStartValue(); + const s = typeof n === "number" && Number.isFinite(n) ? n.toFixed(digitCount) : String(n); + setValue(s); + setOldValue(s); + }, isControlled ? [props.controlledValue, props.step, props.asDegrees, digitCount] : [props.object, props.property, props.step, props.asDegrees, digitCount]); useEventListener("keydown", (ev) => { if (ev.key === "Shift") { @@ -59,18 +78,23 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro }); function getStartValue() { + if (isControlled) { + let v = props.controlledValue as number; + if (props.asDegrees) { + v = Tools.ToDegrees(v); + } + return v; + } + + if (!props.object || !props.property) { + return 0; + } + let startValue = getInspectorPropertyValue(props.object, props.property) ?? 0; if (props.asDegrees) { startValue = Tools.ToDegrees(startValue); } - // Determine if the value should be fixed at "step" digit counts or kept as-is. - // if (props.asDegrees) { - // startValue = Tools.ToDegrees(startValue).toFixed(digitCount); - // } else { - // startValue = startValue.toFixed(digitCount); - // } - return startValue; } @@ -108,7 +132,11 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro const ratio = hasMinMax ? getRatio() : 0; return ( -
setPointerOver(true)} onMouseLeave={() => setPointerOver(false)}> +
setPointerOver(true)} + onMouseLeave={() => setPointerOver(false)} + > {props.label && (
{ setValue(ev.currentTarget.value); @@ -160,7 +189,9 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro setWarning(false); - setInspectorEffectivePropertyValue(props.object, props.property, float); + if (!isControlled && props.object && props.property) { + setInspectorEffectivePropertyValue(props.object, props.property, float); + } props.onChange?.(float); } }} @@ -171,12 +202,12 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro ? `linear-gradient(to right, hsl(var(--muted-foreground) / 0.5) ${ratio}%, hsl(var(--muted-foreground) / 0.1) ${ratio}%, hsl(var(--muted-foreground) / 0.1) 100%)` : undefined, }} - className={` - px-5 py-2 rounded-lg bg-muted-foreground/10 outline-none ring-yellow-500 - ${warning ? "ring-2 bg-background" : "ring-0"} - ${props.label ? "w-2/3" : "w-full"} - transition-all duration-300 ease-in-out - `} + className={cn( + "px-5 py-2 rounded-lg bg-muted-foreground/10 outline-none ring-yellow-500 transition-all duration-300 ease-in-out", + warning ? "ring-2 bg-background" : "ring-0", + props.label ? "w-2/3" : "w-full", + props.inputClassName + )} onKeyUp={(ev) => ev.key === "Enter" && ev.currentTarget.blur()} onBlur={(ev) => { if (ev.currentTarget.value !== oldValue) { @@ -208,7 +239,7 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro newValueFloat = Tools.ToRadians(newValueFloat); } - if (!props.noUndoRedo) { + if (!props.noUndoRedo && !isControlled && props.object && props.property) { registerSimpleUndoRedo({ object: props.object, property: props.property, @@ -275,7 +306,9 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro setWarning(false); setValue(v.toFixed(digitCount)); - setInspectorEffectivePropertyValue(props.object, props.property, finalValue); + if (!isControlled && props.object && props.property) { + setInspectorEffectivePropertyValue(props.object, props.property, finalValue); + } props.onChange?.(finalValue); }) ); @@ -285,7 +318,7 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro (mouseUpListener = () => { document.exitPointerLock(); - if (v !== oldV && !props.noUndoRedo) { + if (v !== oldV && !props.noUndoRedo && !isControlled && props.object && props.property) { setValue(v.toFixed(digitCount)); let finalValue = v; @@ -308,6 +341,17 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro props.onFinishChange?.(finalValue, oldValue); } + } else if (v !== oldV && isControlled) { + setValue(v.toFixed(digitCount)); + let finalValue = v; + if (props.asDegrees) { + finalValue = Tools.ToRadians(finalValue); + } + if (!isNaN(v) && !isNaN(oldV)) { + const oldVal = props.asDegrees ? Tools.ToRadians(oldV) : oldV; + setOldValue(v.toFixed(digitCount)); + props.onFinishChange?.(finalValue, oldVal); + } } document.body.style.cursor = "auto"; diff --git a/editor/src/editor/layout/preview.tsx b/editor/src/editor/layout/preview.tsx index b8a6afe8e..b40a95f95 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,6 +58,13 @@ 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 { + GIZMO_SNAP_MIN_STEP, + IGizmoSnapPreferences, + loadGizmoSnapPreferences, + roundGizmoSnapSteps, + saveGizmoSnapPreferences, +} 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"; @@ -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 { @@ -195,6 +205,8 @@ export class EditorPreview extends Component this.setActiveGizmo("position")); @@ -527,6 +539,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) }); + + return ( + <> +
+ + + this._commitGizmoSnap({ ...snap, translationEnabled: on })} + className={`rounded-none border-0 h-9 min-w-9 px-2 shrink-0 ${snap.translationEnabled ? "bg-primary/20" : ""}`} + aria-label="Translation grid snap" + > + + + + Translation grid snap + + bumpTranslation(v)} + /> +
+ +
+ + + this._commitGizmoSnap({ ...snap, rotationEnabled: on })} + className={`rounded-none border-0 h-9 min-w-9 px-2 shrink-0 ${snap.rotationEnabled ? "bg-primary/20" : ""}`} + aria-label="Rotation snap" + > + + + + Rotation snap (degrees) + + bumpRotation(v)} + /> +
+ +
+ + + this._commitGizmoSnap({ ...snap, scaleEnabled: on })} + className={`rounded-none border-0 h-9 min-w-9 px-2 shrink-0 ${snap.scaleEnabled ? "bg-primary/20" : ""}`} + 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..dc8613810 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 { DEFAULT_GIZMO_SNAP_PREFERENCES, 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 = { ...DEFAULT_GIZMO_SNAP_PREFERENCES }; + 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/tools/gizmo-snap-preferences.ts b/editor/src/tools/gizmo-snap-preferences.ts new file mode 100644 index 000000000..9d30d9799 --- /dev/null +++ b/editor/src/tools/gizmo-snap-preferences.ts @@ -0,0 +1,82 @@ +export const EDITOR_GIZMO_SNAP_STORAGE_KEY = "editor-gizmo-snap"; + +/** Minimum snap step (two-decimal increments cannot be smaller than 0.01). */ +export const GIZMO_SNAP_MIN_STEP = 0.01; + +const SNAP_DECIMAL_ROUND_FACTOR = 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(GIZMO_SNAP_MIN_STEP, value); + const rounded = Math.round(clampedLow * SNAP_DECIMAL_ROUND_FACTOR) / SNAP_DECIMAL_ROUND_FACTOR; + return Math.max(GIZMO_SNAP_MIN_STEP, rounded); + }; + + return { + ...prefs, + translationStep: roundStep(prefs.translationStep), + rotationStepDegrees: roundStep(prefs.rotationStepDegrees), + scaleStep: roundStep(prefs.scaleStep), + }; +} + +export const DEFAULT_GIZMO_SNAP_PREFERENCES: IGizmoSnapPreferences = { + translationEnabled: false, + translationStep: 1, + rotationEnabled: false, + rotationStepDegrees: 15, + scaleEnabled: false, + scaleStep: 0.25, +}; + +function clampPositive(value: number, fallback: number): number { + if (!Number.isFinite(value) || value <= 0) { + return fallback; + } + return value; +} + +function asBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function asNumber(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +export function loadGizmoSnapPreferences(): IGizmoSnapPreferences { + try { + const raw = localStorage.getItem(EDITOR_GIZMO_SNAP_STORAGE_KEY); + if (!raw) { + return roundGizmoSnapSteps({ ...DEFAULT_GIZMO_SNAP_PREFERENCES }); + } + const parsed = JSON.parse(raw) as Partial; + const base = DEFAULT_GIZMO_SNAP_PREFERENCES; + return roundGizmoSnapSteps({ + translationEnabled: asBoolean(parsed.translationEnabled, base.translationEnabled), + translationStep: clampPositive(asNumber(parsed.translationStep, base.translationStep), base.translationStep), + rotationEnabled: asBoolean(parsed.rotationEnabled, base.rotationEnabled), + rotationStepDegrees: clampPositive(asNumber(parsed.rotationStepDegrees, base.rotationStepDegrees), base.rotationStepDegrees), + scaleEnabled: asBoolean(parsed.scaleEnabled, base.scaleEnabled), + scaleStep: clampPositive(asNumber(parsed.scaleStep, base.scaleStep), base.scaleStep), + }); + } catch { + return roundGizmoSnapSteps({ ...DEFAULT_GIZMO_SNAP_PREFERENCES }); + } +} + +export function saveGizmoSnapPreferences(prefs: IGizmoSnapPreferences): void { + localStorage.setItem(EDITOR_GIZMO_SNAP_STORAGE_KEY, JSON.stringify(roundGizmoSnapSteps(prefs))); +} From 62790343325feb0e3aebdbd3c1205d7d7d6fe3c0 Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Tue, 31 Mar 2026 11:53:14 -0700 Subject: [PATCH 2/6] Refactoring --- .../edit-preferences/edit-preferences.tsx | 4 ++-- .../editor/layout/inspector/fields/number.tsx | 5 +--- editor/src/editor/layout/preview.tsx | 4 ++-- editor/src/editor/layout/preview/gizmo.ts | 4 ++-- editor/src/tools/gizmo-snap-preferences.ts | 24 +++++++++---------- 5 files changed, 19 insertions(+), 22 deletions(-) diff --git a/editor/src/editor/dialogs/edit-preferences/edit-preferences.tsx b/editor/src/editor/dialogs/edit-preferences/edit-preferences.tsx index b9c613de4..cd5842869 100644 --- a/editor/src/editor/dialogs/edit-preferences/edit-preferences.tsx +++ b/editor/src/editor/dialogs/edit-preferences/edit-preferences.tsx @@ -10,7 +10,7 @@ import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogFooter, import { trySetExperimentalFeaturesEnabledInLocalStorage } from "../../../tools/local-storage"; import { - GIZMO_SNAP_MIN_STEP, + gizmoSnapMinStep, IGizmoSnapPreferences, loadGizmoSnapPreferences, roundGizmoSnapSteps, @@ -206,7 +206,7 @@ export class EditorEditPreferencesComponent extends Component diff --git a/editor/src/editor/layout/inspector/fields/number.tsx b/editor/src/editor/layout/inspector/fields/number.tsx index 9e9177fed..80f9c1945 100644 --- a/editor/src/editor/layout/inspector/fields/number.tsx +++ b/editor/src/editor/layout/inspector/fields/number.tsx @@ -31,9 +31,6 @@ export interface IEditorInspectorNumberFieldProps extends Partial(() => formatInitial()); const [oldValue, setOldValue] = useState(() => formatInitial()); diff --git a/editor/src/editor/layout/preview.tsx b/editor/src/editor/layout/preview.tsx index b40a95f95..74c2dae29 100644 --- a/editor/src/editor/layout/preview.tsx +++ b/editor/src/editor/layout/preview.tsx @@ -59,7 +59,7 @@ import { ITweenConfiguration, Tween } from "../../tools/animation/tween"; import { checkProjectCachedCompressedTextures } from "../../tools/assets/ktx"; import { createSceneLink, getRootSceneLink } from "../../tools/scene/scene-link"; import { - GIZMO_SNAP_MIN_STEP, + gizmoSnapMinStep, IGizmoSnapPreferences, loadGizmoSnapPreferences, roundGizmoSnapSteps, @@ -903,7 +903,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) }); diff --git a/editor/src/editor/layout/preview/gizmo.ts b/editor/src/editor/layout/preview/gizmo.ts index dc8613810..1a4a2a039 100644 --- a/editor/src/editor/layout/preview/gizmo.ts +++ b/editor/src/editor/layout/preview/gizmo.ts @@ -16,7 +16,7 @@ import { Sprite, } from "babylonjs"; -import { DEFAULT_GIZMO_SNAP_PREFERENCES, IGizmoSnapPreferences } from "../../../tools/gizmo-snap-preferences"; +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"; @@ -46,7 +46,7 @@ export class EditorPreviewGizmo { private _spriteTransformNode: TransformNode; - private _snapPreferences: IGizmoSnapPreferences = { ...DEFAULT_GIZMO_SNAP_PREFERENCES }; + private _snapPreferences: IGizmoSnapPreferences = { ...defaultGizmoSnapPreferences }; public constructor(scene: Scene) { this._gizmosLayer = new UtilityLayerRenderer(scene); diff --git a/editor/src/tools/gizmo-snap-preferences.ts b/editor/src/tools/gizmo-snap-preferences.ts index 9d30d9799..c04ae9f8c 100644 --- a/editor/src/tools/gizmo-snap-preferences.ts +++ b/editor/src/tools/gizmo-snap-preferences.ts @@ -1,9 +1,9 @@ -export const EDITOR_GIZMO_SNAP_STORAGE_KEY = "editor-gizmo-snap"; +export const editorGizmoSnapStorageKey = "editor-gizmo-snap"; /** Minimum snap step (two-decimal increments cannot be smaller than 0.01). */ -export const GIZMO_SNAP_MIN_STEP = 0.01; +export const gizmoSnapMinStep = 0.01; -const SNAP_DECIMAL_ROUND_FACTOR = 100; +const snapDecimalRoundFactor = 100; export interface IGizmoSnapPreferences { translationEnabled: boolean; @@ -19,9 +19,9 @@ export interface IGizmoSnapPreferences { */ export function roundGizmoSnapSteps(prefs: IGizmoSnapPreferences): IGizmoSnapPreferences { const roundStep = (value: number): number => { - const clampedLow = Math.max(GIZMO_SNAP_MIN_STEP, value); - const rounded = Math.round(clampedLow * SNAP_DECIMAL_ROUND_FACTOR) / SNAP_DECIMAL_ROUND_FACTOR; - return Math.max(GIZMO_SNAP_MIN_STEP, rounded); + const clampedLow = Math.max(gizmoSnapMinStep, value); + const rounded = Math.round(clampedLow * snapDecimalRoundFactor) / snapDecimalRoundFactor; + return Math.max(gizmoSnapMinStep, rounded); }; return { @@ -32,7 +32,7 @@ export function roundGizmoSnapSteps(prefs: IGizmoSnapPreferences): IGizmoSnapPre }; } -export const DEFAULT_GIZMO_SNAP_PREFERENCES: IGizmoSnapPreferences = { +export const defaultGizmoSnapPreferences: IGizmoSnapPreferences = { translationEnabled: false, translationStep: 1, rotationEnabled: false, @@ -58,12 +58,12 @@ function asNumber(value: unknown, fallback: number): number { export function loadGizmoSnapPreferences(): IGizmoSnapPreferences { try { - const raw = localStorage.getItem(EDITOR_GIZMO_SNAP_STORAGE_KEY); + const raw = localStorage.getItem(editorGizmoSnapStorageKey); if (!raw) { - return roundGizmoSnapSteps({ ...DEFAULT_GIZMO_SNAP_PREFERENCES }); + return roundGizmoSnapSteps({ ...defaultGizmoSnapPreferences }); } const parsed = JSON.parse(raw) as Partial; - const base = DEFAULT_GIZMO_SNAP_PREFERENCES; + const base = defaultGizmoSnapPreferences; return roundGizmoSnapSteps({ translationEnabled: asBoolean(parsed.translationEnabled, base.translationEnabled), translationStep: clampPositive(asNumber(parsed.translationStep, base.translationStep), base.translationStep), @@ -73,10 +73,10 @@ export function loadGizmoSnapPreferences(): IGizmoSnapPreferences { scaleStep: clampPositive(asNumber(parsed.scaleStep, base.scaleStep), base.scaleStep), }); } catch { - return roundGizmoSnapSteps({ ...DEFAULT_GIZMO_SNAP_PREFERENCES }); + return roundGizmoSnapSteps({ ...defaultGizmoSnapPreferences }); } } export function saveGizmoSnapPreferences(prefs: IGizmoSnapPreferences): void { - localStorage.setItem(EDITOR_GIZMO_SNAP_STORAGE_KEY, JSON.stringify(roundGizmoSnapSteps(prefs))); + localStorage.setItem(editorGizmoSnapStorageKey, JSON.stringify(roundGizmoSnapSteps(prefs))); } From 3743f8f58a8dae6a8f01ac68350ebd7c501a017a Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Tue, 31 Mar 2026 12:04:22 -0700 Subject: [PATCH 3/6] Remove snap from preferences --- .../edit-preferences/edit-preferences.tsx | 568 ++++++++---------- editor/src/editor/layout/preview.tsx | 12 +- editor/src/editor/main.tsx | 7 + editor/src/project/load/load.tsx | 4 + editor/src/tools/gizmo-snap-preferences.ts | 42 -- 5 files changed, 252 insertions(+), 381 deletions(-) diff --git a/editor/src/editor/dialogs/edit-preferences/edit-preferences.tsx b/editor/src/editor/dialogs/edit-preferences/edit-preferences.tsx index cd5842869..5c698f7bb 100644 --- a/editor/src/editor/dialogs/edit-preferences/edit-preferences.tsx +++ b/editor/src/editor/dialogs/edit-preferences/edit-preferences.tsx @@ -1,332 +1,236 @@ -import { ipcRenderer } from "electron"; -import { Component, ReactNode } from "react"; - -import { Label } from "../../../ui/shadcn/ui/label"; -import { Switch } from "../../../ui/shadcn/ui/switch"; -import { EditorInspectorNumberField } from "../../layout/inspector/fields/number"; -import { Separator } from "../../../ui/shadcn/ui/separator"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../ui/shadcn/ui/select"; -import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "../../../ui/shadcn/ui/alert-dialog"; - -import { trySetExperimentalFeaturesEnabledInLocalStorage } from "../../../tools/local-storage"; -import { - gizmoSnapMinStep, - IGizmoSnapPreferences, - loadGizmoSnapPreferences, - roundGizmoSnapSteps, - saveGizmoSnapPreferences, -} from "../../../tools/gizmo-snap-preferences"; - -import { EditorInspectorKeyField } from "../../layout/inspector/fields/key"; - -import { Editor } from "../../main"; - -export interface IEditorEditPreferencesComponentProps { - /** - * Defines the editor reference. - */ - editor: Editor; - /** - * Defines if the dialog is open. - */ - open: boolean; - onClose: () => void; -} - -export interface IEditorEditPreferencesComponentState { - theme: "light" | "dark"; - gizmoSnap: IGizmoSnapPreferences; -} - -export class EditorEditPreferencesComponent extends Component { - public constructor(props: IEditorEditPreferencesComponentProps) { - super(props); - - this.state = { - theme: document.body.classList.contains("dark") ? "dark" : "light", - gizmoSnap: loadGizmoSnapPreferences(), - }; - } - - public componentDidUpdate(prevProps: IEditorEditPreferencesComponentProps): void { - if (this.props.open && !prevProps.open) { - this.setState({ gizmoSnap: loadGizmoSnapPreferences() }); - } - } - - public render(): ReactNode { - return ( - - - - Edit Preferences - - -
- - {this._getThemesComponent()} - - {this._getCameraControlPreferences()} - - {this._getGizmoSnapPreferencesSection()} - - {this._getExperimentalComponent()} -
- - - this.props.onClose()}>Close - -
-
- ); - } - - private _getThemesComponent(): ReactNode { - return ( -
-
- - -
-
- ); - } - - private _getCameraControlPreferences(): ReactNode { - const camera = this.props.editor.layout?.preview?.camera; - if (!camera) { - return false; - } - - return ( -
-
-
- - - { - camera.keysUp = [v]; - this._saveCameraControls(); - }} - /> - { - camera.keysDown = [v]; - this._saveCameraControls(); - }} - /> - - { - camera.keysLeft = [v]; - this._saveCameraControls(); - }} - /> - { - camera.keysRight = [v]; - this._saveCameraControls(); - }} - /> - - { - camera.keysUpward = [v]; - this._saveCameraControls(); - }} - /> - { - camera.keysDownward = [v]; - this._saveCameraControls(); - }} - /> - - { - this._saveCameraControls(); - }} - /> -
-
-
- ); - } - - private _commitGizmoSnapFromPreferences(next: IGizmoSnapPreferences): void { - const normalized = roundGizmoSnapSteps(next); - this.setState({ gizmoSnap: normalized }); - const preview = this.props.editor.layout?.preview; - if (preview) { - preview.updateGizmoSnapPreferences(normalized); - } else { - saveGizmoSnapPreferences(normalized); - } - } - - private _getGizmoSnapPreferencesSection(): ReactNode { - const snap = this.state.gizmoSnap; - const min = gizmoSnapMinStep; - - return ( -
- -

Default translate, rotate, and scale snap for viewport gizmos (also editable in the preview toolbar).

- -
-
-
- this._commitGizmoSnapFromPreferences({ ...snap, translationEnabled: on })} /> - -
- this._commitGizmoSnapFromPreferences({ ...snap, translationStep: v })} - /> -
- -
-
- this._commitGizmoSnapFromPreferences({ ...snap, rotationEnabled: on })} /> - -
- this._commitGizmoSnapFromPreferences({ ...snap, rotationStepDegrees: v })} - /> -
- -
-
- this._commitGizmoSnapFromPreferences({ ...snap, scaleEnabled: on })} /> - -
- this._commitGizmoSnapFromPreferences({ ...snap, scaleStep: v })} - /> -
-
-
- ); - } - - private _saveCameraControls(): void { - const camera = this.props.editor.layout?.preview?.camera; - if (!camera) { - return; - } - - try { - localStorage.setItem( - "editor-camera-controls", - JSON.stringify({ - keysUp: camera.keysUp, - keysDown: camera.keysDown, - keysLeft: camera.keysLeft, - keysRight: camera.keysRight, - keysUpward: camera.keysUpward, - keysDownward: camera.keysDownward, - panSensitivityMultiplier: camera.panSensitivityMultiplier, - }) - ); - } catch (e) { - this.props.editor.layout.console.error("Failed to write editor's camera controls configuration."); - if (e.message) { - this.props.editor.layout.console.error(e.message); - } - } - } - - private _getExperimentalComponent(): ReactNode { - return ( -
-
- -
- { - this.props.editor.setState({ enableExperimentalFeatures: v }); - - trySetExperimentalFeaturesEnabledInLocalStorage(v); - - ipcRenderer.send("editor:setup-menu", { enableExperimentalFeatures: v }); - - this.props.editor.layout.graph.refresh(); - this.props.editor.layout.assets.refresh(); - this.props.editor.layout.preview.forceUpdate(); - this.props.editor.layout.inspector.forceUpdate(); - this.props.editor.layout.animations.forceUpdate(); - - this.props.editor.layout.removeLayoutTab("marketplace"); - }} - /> - Enable experimental features -
-
-
- ); - } -} +import { ipcRenderer } from "electron"; +import { Component, ReactNode } from "react"; + +import { Label } from "../../../ui/shadcn/ui/label"; +import { Switch } from "../../../ui/shadcn/ui/switch"; +import { EditorInspectorNumberField } from "../../layout/inspector/fields/number"; +import { Separator } from "../../../ui/shadcn/ui/separator"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../ui/shadcn/ui/select"; +import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "../../../ui/shadcn/ui/alert-dialog"; + +import { trySetExperimentalFeaturesEnabledInLocalStorage } from "../../../tools/local-storage"; + +import { EditorInspectorKeyField } from "../../layout/inspector/fields/key"; + +import { Editor } from "../../main"; + +export interface IEditorEditPreferencesComponentProps { + /** + * Defines the editor reference. + */ + editor: Editor; + /** + * Defines if the dialog is open. + */ + open: boolean; + onClose: () => void; +} + +export interface IEditorEditPreferencesComponentState { + theme: "light" | "dark"; +} + +export class EditorEditPreferencesComponent extends Component { + public constructor(props: IEditorEditPreferencesComponentProps) { + super(props); + + this.state = { + theme: document.body.classList.contains("dark") ? "dark" : "light", + }; + } + + public render(): ReactNode { + return ( + + + + Edit Preferences + + +
+ + {this._getThemesComponent()} + + {this._getCameraControlPreferences()} + + {this._getExperimentalComponent()} +
+ + + this.props.onClose()}>Close + +
+
+ ); + } + + private _getThemesComponent(): ReactNode { + return ( +
+
+ + +
+
+ ); + } + + private _getCameraControlPreferences(): ReactNode { + const camera = this.props.editor.layout?.preview?.camera; + if (!camera) { + return false; + } + + return ( +
+
+
+ + + { + camera.keysUp = [v]; + this._saveCameraControls(); + }} + /> + { + camera.keysDown = [v]; + this._saveCameraControls(); + }} + /> + + { + camera.keysLeft = [v]; + this._saveCameraControls(); + }} + /> + { + camera.keysRight = [v]; + this._saveCameraControls(); + }} + /> + + { + camera.keysUpward = [v]; + this._saveCameraControls(); + }} + /> + { + camera.keysDownward = [v]; + this._saveCameraControls(); + }} + /> + + { + this._saveCameraControls(); + }} + /> +
+
+
+ ); + } + + private _saveCameraControls(): void { + const camera = this.props.editor.layout?.preview?.camera; + if (!camera) { + return; + } + + try { + localStorage.setItem( + "editor-camera-controls", + JSON.stringify({ + keysUp: camera.keysUp, + keysDown: camera.keysDown, + keysLeft: camera.keysLeft, + keysRight: camera.keysRight, + keysUpward: camera.keysUpward, + keysDownward: camera.keysDownward, + panSensitivityMultiplier: camera.panSensitivityMultiplier, + }) + ); + } catch (e) { + this.props.editor.layout.console.error("Failed to write editor's camera controls configuration."); + if (e.message) { + this.props.editor.layout.console.error(e.message); + } + } + } + + private _getExperimentalComponent(): ReactNode { + return ( +
+
+ +
+ { + this.props.editor.setState({ enableExperimentalFeatures: v }); + + trySetExperimentalFeaturesEnabledInLocalStorage(v); + + ipcRenderer.send("editor:setup-menu", { enableExperimentalFeatures: v }); + + this.props.editor.layout.graph.refresh(); + this.props.editor.layout.assets.refresh(); + this.props.editor.layout.preview.forceUpdate(); + this.props.editor.layout.inspector.forceUpdate(); + this.props.editor.layout.animations.forceUpdate(); + + this.props.editor.layout.removeLayoutTab("marketplace"); + }} + /> + Enable experimental features +
+
+
+ ); + } +} diff --git a/editor/src/editor/layout/preview.tsx b/editor/src/editor/layout/preview.tsx index 74c2dae29..df9bc1f6d 100644 --- a/editor/src/editor/layout/preview.tsx +++ b/editor/src/editor/layout/preview.tsx @@ -61,9 +61,7 @@ import { createSceneLink, getRootSceneLink } from "../../tools/scene/scene-link" import { gizmoSnapMinStep, IGizmoSnapPreferences, - loadGizmoSnapPreferences, roundGizmoSnapSteps, - saveGizmoSnapPreferences, } from "../../tools/gizmo-snap-preferences"; import { UniqueNumber, waitNextAnimationFrame, waitUntil } from "../../tools/tools"; import { isSprite, isSpriteManagerNode, isSpriteMapNode } from "../../tools/guards/sprites"; @@ -206,7 +204,7 @@ export class EditorPreview extends Component this.setActiveGizmo("position")); @@ -892,8 +890,8 @@ export class EditorPreview extends Component bumpTranslation(v)} /> @@ -959,7 +957,7 @@ export class EditorPreview extends Component bumpRotation(v)} /> @@ -986,7 +984,7 @@ export class EditorPreview extends Component bumpScale(v)} /> diff --git a/editor/src/editor/main.tsx b/editor/src/editor/main.tsx index c8cb6c134..a78a54bed 100644 --- a/editor/src/editor/main.tsx +++ b/editor/src/editor/main.tsx @@ -20,6 +20,7 @@ import { loadProject } from "../project/load/load"; import { startProjectDevProcess } from "../project/run"; import { exportProject } from "../project/export/export"; import { EditorProjectPackageManager } from "../project/typings"; +import { defaultGizmoSnapPreferences, IGizmoSnapPreferences } from "../tools/gizmo-snap-preferences"; import { disposeVLSPostProcess } from "./rendering/vls"; import { disposeSSRRenderingPipeline } from "./rendering/ssr"; @@ -97,6 +98,11 @@ export interface IEditorState { */ compressedTexturesEnabledInPreview: boolean; + /** + * Gizmo snap for the current session (translate, rotate, scale); not persisted in the project file. + */ + gizmoSnap: IGizmoSnapPreferences; + /** * Defines wether or not experimental features are enabled. */ @@ -155,6 +161,7 @@ export class Editor extends Component { compressedTexturesEnabled: false, compressedTexturesEnabledInPreview: false, + gizmoSnap: { ...defaultGizmoSnapPreferences }, enableExperimentalFeatures: tryGetExperimentalFeaturesEnabledFromLocalStorage(), openedTabs: [], diff --git a/editor/src/project/load/load.tsx b/editor/src/project/load/load.tsx index e4daebda9..6ebb1dccc 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 }); editor.setState({ packageManager, @@ -35,9 +37,11 @@ export async function loadProject(editor: Editor, path: string) { compressedTexturesEnabled: project.compressedTexturesEnabled ?? false, compressedTexturesEnabledInPreview: project.compressedTexturesEnabledInPreview ?? false, + gizmoSnap, }); editor.layout.forceUpdate(); + editor.layout.preview?.updateGizmoSnapPreferences(gizmoSnap); projectConfiguration.compressedTexturesEnabled = project.compressedTexturesEnabled ?? false; diff --git a/editor/src/tools/gizmo-snap-preferences.ts b/editor/src/tools/gizmo-snap-preferences.ts index c04ae9f8c..4c2b7dcce 100644 --- a/editor/src/tools/gizmo-snap-preferences.ts +++ b/editor/src/tools/gizmo-snap-preferences.ts @@ -1,5 +1,3 @@ -export const editorGizmoSnapStorageKey = "editor-gizmo-snap"; - /** Minimum snap step (two-decimal increments cannot be smaller than 0.01). */ export const gizmoSnapMinStep = 0.01; @@ -40,43 +38,3 @@ export const defaultGizmoSnapPreferences: IGizmoSnapPreferences = { scaleEnabled: false, scaleStep: 0.25, }; - -function clampPositive(value: number, fallback: number): number { - if (!Number.isFinite(value) || value <= 0) { - return fallback; - } - return value; -} - -function asBoolean(value: unknown, fallback: boolean): boolean { - return typeof value === "boolean" ? value : fallback; -} - -function asNumber(value: unknown, fallback: number): number { - return typeof value === "number" && Number.isFinite(value) ? value : fallback; -} - -export function loadGizmoSnapPreferences(): IGizmoSnapPreferences { - try { - const raw = localStorage.getItem(editorGizmoSnapStorageKey); - if (!raw) { - return roundGizmoSnapSteps({ ...defaultGizmoSnapPreferences }); - } - const parsed = JSON.parse(raw) as Partial; - const base = defaultGizmoSnapPreferences; - return roundGizmoSnapSteps({ - translationEnabled: asBoolean(parsed.translationEnabled, base.translationEnabled), - translationStep: clampPositive(asNumber(parsed.translationStep, base.translationStep), base.translationStep), - rotationEnabled: asBoolean(parsed.rotationEnabled, base.rotationEnabled), - rotationStepDegrees: clampPositive(asNumber(parsed.rotationStepDegrees, base.rotationStepDegrees), base.rotationStepDegrees), - scaleEnabled: asBoolean(parsed.scaleEnabled, base.scaleEnabled), - scaleStep: clampPositive(asNumber(parsed.scaleStep, base.scaleStep), base.scaleStep), - }); - } catch { - return roundGizmoSnapSteps({ ...defaultGizmoSnapPreferences }); - } -} - -export function saveGizmoSnapPreferences(prefs: IGizmoSnapPreferences): void { - localStorage.setItem(editorGizmoSnapStorageKey, JSON.stringify(roundGizmoSnapSteps(prefs))); -} From c814a222dcd5321b456ca513712cd267d322fabf Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Wed, 1 Apr 2026 15:45:56 -0700 Subject: [PATCH 4/6] Rollback unwanted changes --- .../edit-preferences/edit-preferences.tsx | 472 +++++++++--------- .../editor/layout/inspector/fields/number.tsx | 89 +--- editor/src/editor/layout/preview.tsx | 77 +-- 3 files changed, 304 insertions(+), 334 deletions(-) diff --git a/editor/src/editor/dialogs/edit-preferences/edit-preferences.tsx b/editor/src/editor/dialogs/edit-preferences/edit-preferences.tsx index 5c698f7bb..bee5ca074 100644 --- a/editor/src/editor/dialogs/edit-preferences/edit-preferences.tsx +++ b/editor/src/editor/dialogs/edit-preferences/edit-preferences.tsx @@ -1,236 +1,236 @@ -import { ipcRenderer } from "electron"; -import { Component, ReactNode } from "react"; - -import { Label } from "../../../ui/shadcn/ui/label"; -import { Switch } from "../../../ui/shadcn/ui/switch"; -import { EditorInspectorNumberField } from "../../layout/inspector/fields/number"; -import { Separator } from "../../../ui/shadcn/ui/separator"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../ui/shadcn/ui/select"; -import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "../../../ui/shadcn/ui/alert-dialog"; - -import { trySetExperimentalFeaturesEnabledInLocalStorage } from "../../../tools/local-storage"; - -import { EditorInspectorKeyField } from "../../layout/inspector/fields/key"; - -import { Editor } from "../../main"; - -export interface IEditorEditPreferencesComponentProps { - /** - * Defines the editor reference. - */ - editor: Editor; - /** - * Defines if the dialog is open. - */ - open: boolean; - onClose: () => void; -} - -export interface IEditorEditPreferencesComponentState { - theme: "light" | "dark"; -} - -export class EditorEditPreferencesComponent extends Component { - public constructor(props: IEditorEditPreferencesComponentProps) { - super(props); - - this.state = { - theme: document.body.classList.contains("dark") ? "dark" : "light", - }; - } - - public render(): ReactNode { - return ( - - - - Edit Preferences - - -
- - {this._getThemesComponent()} - - {this._getCameraControlPreferences()} - - {this._getExperimentalComponent()} -
- - - this.props.onClose()}>Close - -
-
- ); - } - - private _getThemesComponent(): ReactNode { - return ( -
-
- - -
-
- ); - } - - private _getCameraControlPreferences(): ReactNode { - const camera = this.props.editor.layout?.preview?.camera; - if (!camera) { - return false; - } - - return ( -
-
-
- - - { - camera.keysUp = [v]; - this._saveCameraControls(); - }} - /> - { - camera.keysDown = [v]; - this._saveCameraControls(); - }} - /> - - { - camera.keysLeft = [v]; - this._saveCameraControls(); - }} - /> - { - camera.keysRight = [v]; - this._saveCameraControls(); - }} - /> - - { - camera.keysUpward = [v]; - this._saveCameraControls(); - }} - /> - { - camera.keysDownward = [v]; - this._saveCameraControls(); - }} - /> - - { - this._saveCameraControls(); - }} - /> -
-
-
- ); - } - - private _saveCameraControls(): void { - const camera = this.props.editor.layout?.preview?.camera; - if (!camera) { - return; - } - - try { - localStorage.setItem( - "editor-camera-controls", - JSON.stringify({ - keysUp: camera.keysUp, - keysDown: camera.keysDown, - keysLeft: camera.keysLeft, - keysRight: camera.keysRight, - keysUpward: camera.keysUpward, - keysDownward: camera.keysDownward, - panSensitivityMultiplier: camera.panSensitivityMultiplier, - }) - ); - } catch (e) { - this.props.editor.layout.console.error("Failed to write editor's camera controls configuration."); - if (e.message) { - this.props.editor.layout.console.error(e.message); - } - } - } - - private _getExperimentalComponent(): ReactNode { - return ( -
-
- -
- { - this.props.editor.setState({ enableExperimentalFeatures: v }); - - trySetExperimentalFeaturesEnabledInLocalStorage(v); - - ipcRenderer.send("editor:setup-menu", { enableExperimentalFeatures: v }); - - this.props.editor.layout.graph.refresh(); - this.props.editor.layout.assets.refresh(); - this.props.editor.layout.preview.forceUpdate(); - this.props.editor.layout.inspector.forceUpdate(); - this.props.editor.layout.animations.forceUpdate(); - - this.props.editor.layout.removeLayoutTab("marketplace"); - }} - /> - Enable experimental features -
-
-
- ); - } -} +import { ipcRenderer } from "electron"; +import { Component, ReactNode } from "react"; + +import { Label } from "../../../ui/shadcn/ui/label"; +import { Switch } from "../../../ui/shadcn/ui/switch"; +import { Separator } from "../../../ui/shadcn/ui/separator"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../ui/shadcn/ui/select"; +import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "../../../ui/shadcn/ui/alert-dialog"; + +import { trySetExperimentalFeaturesEnabledInLocalStorage } from "../../../tools/local-storage"; + +import { EditorInspectorKeyField } from "../../layout/inspector/fields/key"; +import { EditorInspectorNumberField } from "../../layout/inspector/fields/number"; + +import { Editor } from "../../main"; + +export interface IEditorEditPreferencesComponentProps { + /** + * Defines the editor reference. + */ + editor: Editor; + /** + * Defines if the dialog is open. + */ + open: boolean; + onClose: () => void; +} + +export interface IEditorEditPreferencesComponentState { + theme: "light" | "dark"; +} + +export class EditorEditPreferencesComponent extends Component { + public constructor(props: IEditorEditPreferencesComponentProps) { + super(props); + + this.state = { + theme: document.body.classList.contains("dark") ? "dark" : "light", + }; + } + + public render(): ReactNode { + return ( + + + + Edit Preferences + + +
+ + {this._getThemesComponent()} + + {this._getCameraControlPreferences()} + + {this._getExperimentalComponent()} +
+ + + this.props.onClose()}>Close + +
+
+ ); + } + + private _getThemesComponent(): ReactNode { + return ( +
+
+ + +
+
+ ); + } + + private _getCameraControlPreferences(): ReactNode { + const camera = this.props.editor.layout?.preview?.camera; + if (!camera) { + return false; + } + + return ( +
+
+
+ + + { + camera.keysUp = [v]; + this._saveCameraControls(); + }} + /> + { + camera.keysDown = [v]; + this._saveCameraControls(); + }} + /> + + { + camera.keysLeft = [v]; + this._saveCameraControls(); + }} + /> + { + camera.keysRight = [v]; + this._saveCameraControls(); + }} + /> + + { + camera.keysUpward = [v]; + this._saveCameraControls(); + }} + /> + { + camera.keysDownward = [v]; + this._saveCameraControls(); + }} + /> + + { + this._saveCameraControls(); + }} + /> +
+
+
+ ); + } + + private _saveCameraControls(): void { + const camera = this.props.editor.layout?.preview?.camera; + if (!camera) { + return; + } + + try { + localStorage.setItem( + "editor-camera-controls", + JSON.stringify({ + keysUp: camera.keysUp, + keysDown: camera.keysDown, + keysLeft: camera.keysLeft, + keysRight: camera.keysRight, + keysUpward: camera.keysUpward, + keysDownward: camera.keysDownward, + panSensitivityMultiplier: camera.panSensitivityMultiplier, + }) + ); + } catch (e) { + this.props.editor.layout.console.error("Failed to write editor's camera controls configuration."); + if (e.message) { + this.props.editor.layout.console.error(e.message); + } + } + } + + private _getExperimentalComponent(): ReactNode { + return ( +
+
+ +
+ { + this.props.editor.setState({ enableExperimentalFeatures: v }); + + trySetExperimentalFeaturesEnabledInLocalStorage(v); + + ipcRenderer.send("editor:setup-menu", { enableExperimentalFeatures: v }); + + this.props.editor.layout.graph.refresh(); + this.props.editor.layout.assets.refresh(); + this.props.editor.layout.preview.forceUpdate(); + this.props.editor.layout.inspector.forceUpdate(); + this.props.editor.layout.animations.forceUpdate(); + + this.props.editor.layout.removeLayoutTab("marketplace"); + }} + /> + Enable experimental features +
+
+
+ ); + } +} diff --git a/editor/src/editor/layout/inspector/fields/number.tsx b/editor/src/editor/layout/inspector/fields/number.tsx index 80f9c1945..9cb28b007 100644 --- a/editor/src/editor/layout/inspector/fields/number.tsx +++ b/editor/src/editor/layout/inspector/fields/number.tsx @@ -8,7 +8,6 @@ import { Scalar, Tools } from "babylonjs"; import Mexp from "math-expression-evaluator"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../../../../ui/shadcn/ui/tooltip"; -import { cn } from "../../../../ui/utils"; import { registerSimpleUndoRedo } from "../../../../tools/undoredo"; import { getInspectorPropertyValue, setInspectorEffectivePropertyValue } from "../../../../tools/property"; @@ -17,7 +16,7 @@ import { IEditorInspectorFieldProps } from "./field"; const mexp = new Mexp(); -export interface IEditorInspectorNumberFieldProps extends Partial { +export interface IEditorInspectorNumberFieldProps extends IEditorInspectorFieldProps { min?: number; max?: number; @@ -28,17 +27,9 @@ export interface IEditorInspectorNumberFieldProps extends Partial void; onFinishChange?: (value: number, oldValue: number) => void; - - /** When set, value is driven from React state; object/property and inspector mutation are skipped. */ - controlledValue?: number; - wrapperClassName?: string; - inputClassName?: string; - title?: string; } export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldProps) { - const isControlled = props.controlledValue !== undefined; - const [shiftDown, setShiftDown] = useState(false); const [pointerOver, setPointerOver] = useState(false); @@ -47,20 +38,13 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro const step = props.step ?? 0.01; const digitCount = props.step?.toString().split(".")[1]?.length ?? 2; - const [value, setValue] = useState(() => formatInitial()); - const [oldValue, setOldValue] = useState(() => formatInitial()); - - function formatInitial(): string { - const n = getStartValue(); - return typeof n === "number" && Number.isFinite(n) ? n.toFixed(digitCount) : String(n); - } + const [value, setValue] = useState(getStartValue()); + const [oldValue, setOldValue] = useState(getStartValue()); useEffect(() => { - const n = getStartValue(); - const s = typeof n === "number" && Number.isFinite(n) ? n.toFixed(digitCount) : String(n); - setValue(s); - setOldValue(s); - }, isControlled ? [props.controlledValue, props.step, props.asDegrees, digitCount] : [props.object, props.property, props.step, props.asDegrees, digitCount]); + setValue(getStartValue()); + setOldValue(getStartValue()); + }, [props.object, props.property, props.step]); useEventListener("keydown", (ev) => { if (ev.key === "Shift") { @@ -75,23 +59,18 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro }); function getStartValue() { - if (isControlled) { - let v = props.controlledValue as number; - if (props.asDegrees) { - v = Tools.ToDegrees(v); - } - return v; - } - - if (!props.object || !props.property) { - return 0; - } - let startValue = getInspectorPropertyValue(props.object, props.property) ?? 0; if (props.asDegrees) { startValue = Tools.ToDegrees(startValue); } + // Determine if the value should be fixed at "step" digit counts or kept as-is. + // if (props.asDegrees) { + // startValue = Tools.ToDegrees(startValue).toFixed(digitCount); + // } else { + // startValue = startValue.toFixed(digitCount); + // } + return startValue; } @@ -129,11 +108,7 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro const ratio = hasMinMax ? getRatio() : 0; return ( -
setPointerOver(true)} - onMouseLeave={() => setPointerOver(false)} - > +
setPointerOver(true)} onMouseLeave={() => setPointerOver(false)}> {props.label && (
{ setValue(ev.currentTarget.value); @@ -186,9 +160,7 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro setWarning(false); - if (!isControlled && props.object && props.property) { - setInspectorEffectivePropertyValue(props.object, props.property, float); - } + setInspectorEffectivePropertyValue(props.object, props.property, float); props.onChange?.(float); } }} @@ -199,12 +171,12 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro ? `linear-gradient(to right, hsl(var(--muted-foreground) / 0.5) ${ratio}%, hsl(var(--muted-foreground) / 0.1) ${ratio}%, hsl(var(--muted-foreground) / 0.1) 100%)` : undefined, }} - className={cn( - "px-5 py-2 rounded-lg bg-muted-foreground/10 outline-none ring-yellow-500 transition-all duration-300 ease-in-out", - warning ? "ring-2 bg-background" : "ring-0", - props.label ? "w-2/3" : "w-full", - props.inputClassName - )} + className={` + px-5 py-2 rounded-lg bg-muted-foreground/10 outline-none ring-yellow-500 + ${warning ? "ring-2 bg-background" : "ring-0"} + ${props.label ? "w-2/3" : "w-full"} + transition-all duration-300 ease-in-out + `} onKeyUp={(ev) => ev.key === "Enter" && ev.currentTarget.blur()} onBlur={(ev) => { if (ev.currentTarget.value !== oldValue) { @@ -236,7 +208,7 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro newValueFloat = Tools.ToRadians(newValueFloat); } - if (!props.noUndoRedo && !isControlled && props.object && props.property) { + if (!props.noUndoRedo) { registerSimpleUndoRedo({ object: props.object, property: props.property, @@ -303,9 +275,7 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro setWarning(false); setValue(v.toFixed(digitCount)); - if (!isControlled && props.object && props.property) { - setInspectorEffectivePropertyValue(props.object, props.property, finalValue); - } + setInspectorEffectivePropertyValue(props.object, props.property, finalValue); props.onChange?.(finalValue); }) ); @@ -315,7 +285,7 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro (mouseUpListener = () => { document.exitPointerLock(); - if (v !== oldV && !props.noUndoRedo && !isControlled && props.object && props.property) { + if (v !== oldV && !props.noUndoRedo) { setValue(v.toFixed(digitCount)); let finalValue = v; @@ -338,17 +308,6 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro props.onFinishChange?.(finalValue, oldValue); } - } else if (v !== oldV && isControlled) { - setValue(v.toFixed(digitCount)); - let finalValue = v; - if (props.asDegrees) { - finalValue = Tools.ToRadians(finalValue); - } - if (!isNaN(v) && !isNaN(oldV)) { - const oldVal = props.asDegrees ? Tools.ToRadians(oldV) : oldV; - setOldValue(v.toFixed(digitCount)); - props.onFinishChange?.(finalValue, oldVal); - } } document.body.style.cursor = "auto"; diff --git a/editor/src/editor/layout/preview.tsx b/editor/src/editor/layout/preview.tsx index df9bc1f6d..e9607b847 100644 --- a/editor/src/editor/layout/preview.tsx +++ b/editor/src/editor/layout/preview.tsx @@ -186,6 +186,16 @@ export class EditorPreview extends Component = { + translationStep: 0, + rotationStepDegrees: 0, + scaleStep: 0, + }; + /** @internal */ public _previewCamera: Camera | null = null; @@ -903,6 +913,10 @@ 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) }); @@ -923,17 +937,16 @@ export class EditorPreview extends Component Translation grid snap - bumpTranslation(v)} - /> +
+ bumpTranslation(v)} + /> +
@@ -950,17 +963,16 @@ export class EditorPreview extends Component Rotation snap (degrees) - bumpRotation(v)} - /> +
+ bumpRotation(v)} + /> +
@@ -977,17 +989,16 @@ export class EditorPreview extends Component Scale snap (incremental step) - bumpScale(v)} - /> +
+ bumpScale(v)} + /> +
); From e3fdc54c137476a44ae18d8c0687f5e508a3a332 Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Fri, 3 Apr 2026 12:06:10 -0700 Subject: [PATCH 5/6] Store gizmo preferences for project --- editor/src/editor/layout/preview.tsx | 4 ++-- editor/src/editor/main.tsx | 7 ------- editor/src/project/load/load.tsx | 3 +-- editor/src/project/save/save.tsx | 2 ++ editor/src/project/typings.ts | 7 +++++++ 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/editor/src/editor/layout/preview.tsx b/editor/src/editor/layout/preview.tsx index e9607b847..a00e9d70a 100644 --- a/editor/src/editor/layout/preview.tsx +++ b/editor/src/editor/layout/preview.tsx @@ -59,6 +59,7 @@ 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, @@ -214,7 +215,7 @@ export class EditorPreview extends Component this.setActiveGizmo("position")); @@ -901,7 +902,6 @@ export class EditorPreview extends Component { compressedTexturesEnabled: false, compressedTexturesEnabledInPreview: false, - gizmoSnap: { ...defaultGizmoSnapPreferences }, enableExperimentalFeatures: tryGetExperimentalFeaturesEnabledFromLocalStorage(), openedTabs: [], diff --git a/editor/src/project/load/load.tsx b/editor/src/project/load/load.tsx index 6ebb1dccc..0002d72b4 100644 --- a/editor/src/project/load/load.tsx +++ b/editor/src/project/load/load.tsx @@ -27,7 +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 }); + const gizmoSnap = roundGizmoSnapSteps({ ...defaultGizmoSnapPreferences, ...project.gizmoSnap }); editor.setState({ packageManager, @@ -37,7 +37,6 @@ export async function loadProject(editor: Editor, path: string) { compressedTexturesEnabled: project.compressedTexturesEnabled ?? false, compressedTexturesEnabledInPreview: project.compressedTexturesEnabledInPreview ?? false, - gizmoSnap, }); editor.layout.forceUpdate(); 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 { From 1d2d95978644f2430637e8004edb709dea006266 Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Fri, 3 Apr 2026 13:07:48 -0700 Subject: [PATCH 6/6] Snap popover --- editor/src/editor/layout/preview.tsx | 173 +++++++++++++++------------ 1 file changed, 95 insertions(+), 78 deletions(-) diff --git a/editor/src/editor/layout/preview.tsx b/editor/src/editor/layout/preview.tsx index a00e9d70a..2bde3ca7c 100644 --- a/editor/src/editor/layout/preview.tsx +++ b/editor/src/editor/layout/preview.tsx @@ -67,6 +67,7 @@ import { 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"; @@ -921,86 +922,102 @@ export class EditorPreview extends Component this._commitGizmoSnap({ ...snap, rotationStepDegrees: Math.max(min, v) }); const bumpScale = (v: number) => this._commitGizmoSnap({ ...snap, scaleStep: Math.max(min, v) }); - return ( - <> -
- - - this._commitGizmoSnap({ ...snap, translationEnabled: on })} - className={`rounded-none border-0 h-9 min-w-9 px-2 shrink-0 ${snap.translationEnabled ? "bg-primary/20" : ""}`} - aria-label="Translation grid snap" - > - - - - Translation grid snap - -
- bumpTranslation(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"}`; -
- - - this._commitGizmoSnap({ ...snap, rotationEnabled: on })} - className={`rounded-none border-0 h-9 min-w-9 px-2 shrink-0 ${snap.rotationEnabled ? "bg-primary/20" : ""}`} - aria-label="Rotation snap" - > - - - - Rotation snap (degrees) - -
- bumpRotation(v)} - /> -
-
- -
- - - this._commitGizmoSnap({ ...snap, scaleEnabled: on })} - className={`rounded-none border-0 h-9 min-w-9 px-2 shrink-0 ${snap.scaleEnabled ? "bg-primary/20" : ""}`} - aria-label="Scale snap" - > - - - - Scale snap (incremental step) - -
- bumpScale(v)} - /> + 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)} + /> +
+
-
- + + ); }