Skip to content

Commit 16a3f63

Browse files
committed
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.
1 parent ce7bbb2 commit 16a3f63

5 files changed

Lines changed: 404 additions & 28 deletions

File tree

editor/src/editor/dialogs/edit-preferences/edit-preferences.tsx

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,21 @@ import { Component, ReactNode } from "react";
33

44
import { Label } from "../../../ui/shadcn/ui/label";
55
import { Switch } from "../../../ui/shadcn/ui/switch";
6+
import { EditorInspectorNumberField } from "../../layout/inspector/fields/number";
67
import { Separator } from "../../../ui/shadcn/ui/separator";
78
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../ui/shadcn/ui/select";
89
import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "../../../ui/shadcn/ui/alert-dialog";
910

1011
import { trySetExperimentalFeaturesEnabledInLocalStorage } from "../../../tools/local-storage";
12+
import {
13+
GIZMO_SNAP_MIN_STEP,
14+
IGizmoSnapPreferences,
15+
loadGizmoSnapPreferences,
16+
roundGizmoSnapSteps,
17+
saveGizmoSnapPreferences,
18+
} from "../../../tools/gizmo-snap-preferences";
1119

1220
import { EditorInspectorKeyField } from "../../layout/inspector/fields/key";
13-
import { EditorInspectorNumberField } from "../../layout/inspector/fields/number";
1421

1522
import { Editor } from "../../main";
1623

@@ -28,6 +35,7 @@ export interface IEditorEditPreferencesComponentProps {
2835

2936
export interface IEditorEditPreferencesComponentState {
3037
theme: "light" | "dark";
38+
gizmoSnap: IGizmoSnapPreferences;
3139
}
3240

3341
export class EditorEditPreferencesComponent extends Component<IEditorEditPreferencesComponentProps, IEditorEditPreferencesComponentState> {
@@ -36,9 +44,16 @@ export class EditorEditPreferencesComponent extends Component<IEditorEditPrefere
3644

3745
this.state = {
3846
theme: document.body.classList.contains("dark") ? "dark" : "light",
47+
gizmoSnap: loadGizmoSnapPreferences(),
3948
};
4049
}
4150

51+
public componentDidUpdate(prevProps: IEditorEditPreferencesComponentProps): void {
52+
if (this.props.open && !prevProps.open) {
53+
this.setState({ gizmoSnap: loadGizmoSnapPreferences() });
54+
}
55+
}
56+
4257
public render(): ReactNode {
4358
return (
4459
<AlertDialog open={this.props.open}>
@@ -53,6 +68,8 @@ export class EditorEditPreferencesComponent extends Component<IEditorEditPrefere
5368
<Separator />
5469
{this._getCameraControlPreferences()}
5570
<Separator />
71+
{this._getGizmoSnapPreferencesSection()}
72+
<Separator />
5673
{this._getExperimentalComponent()}
5774
</div>
5875

@@ -176,6 +193,85 @@ export class EditorEditPreferencesComponent extends Component<IEditorEditPrefere
176193
);
177194
}
178195

196+
private _commitGizmoSnapFromPreferences(next: IGizmoSnapPreferences): void {
197+
const normalized = roundGizmoSnapSteps(next);
198+
this.setState({ gizmoSnap: normalized });
199+
const preview = this.props.editor.layout?.preview;
200+
if (preview) {
201+
preview.updateGizmoSnapPreferences(normalized);
202+
} else {
203+
saveGizmoSnapPreferences(normalized);
204+
}
205+
}
206+
207+
private _getGizmoSnapPreferencesSection(): ReactNode {
208+
const snap = this.state.gizmoSnap;
209+
const min = GIZMO_SNAP_MIN_STEP;
210+
211+
return (
212+
<div className="flex flex-col gap-[10px] w-full">
213+
<Label className="text-xl font-[400]">Gizmo snapping</Label>
214+
<p className="text-sm text-muted-foreground">Default translate, rotate, and scale snap for viewport gizmos (also editable in the preview toolbar).</p>
215+
216+
<div className="flex flex-col gap-3">
217+
<div className="flex flex-wrap gap-3 items-center">
218+
<div className="flex gap-2 items-center min-w-[140px]">
219+
<Switch checked={snap.translationEnabled} onCheckedChange={(on) => this._commitGizmoSnapFromPreferences({ ...snap, translationEnabled: on })} />
220+
<Label className="font-normal">Translation grid</Label>
221+
</div>
222+
<EditorInspectorNumberField
223+
controlledValue={snap.translationStep}
224+
noUndoRedo
225+
wrapperClassName="contents"
226+
inputClassName="h-9 w-28 rounded-md border border-input px-2 py-1 text-sm shadow-sm bg-background !w-28"
227+
title="Translation snap step (scene units); drag horizontally to adjust (hold Shift for ×10)"
228+
step={0.01}
229+
decimals={2}
230+
min={min}
231+
onChange={(v) => this._commitGizmoSnapFromPreferences({ ...snap, translationStep: v })}
232+
/>
233+
</div>
234+
235+
<div className="flex flex-wrap gap-3 items-center">
236+
<div className="flex gap-2 items-center min-w-[140px]">
237+
<Switch checked={snap.rotationEnabled} onCheckedChange={(on) => this._commitGizmoSnapFromPreferences({ ...snap, rotationEnabled: on })} />
238+
<Label className="font-normal">Rotation (°)</Label>
239+
</div>
240+
<EditorInspectorNumberField
241+
controlledValue={snap.rotationStepDegrees}
242+
noUndoRedo
243+
wrapperClassName="contents"
244+
inputClassName="h-9 w-28 rounded-md border border-input px-2 py-1 text-sm shadow-sm bg-background !w-28"
245+
title="Rotation snap step in degrees; drag horizontally to adjust (hold Shift for ×10)"
246+
step={0.01}
247+
decimals={2}
248+
min={min}
249+
onChange={(v) => this._commitGizmoSnapFromPreferences({ ...snap, rotationStepDegrees: v })}
250+
/>
251+
</div>
252+
253+
<div className="flex flex-wrap gap-3 items-center">
254+
<div className="flex gap-2 items-center min-w-[140px]">
255+
<Switch checked={snap.scaleEnabled} onCheckedChange={(on) => this._commitGizmoSnapFromPreferences({ ...snap, scaleEnabled: on })} />
256+
<Label className="font-normal">Scale (incremental)</Label>
257+
</div>
258+
<EditorInspectorNumberField
259+
controlledValue={snap.scaleStep}
260+
noUndoRedo
261+
wrapperClassName="contents"
262+
inputClassName="h-9 w-28 rounded-md border border-input px-2 py-1 text-sm shadow-sm bg-background !w-28"
263+
title="Scale snap step (additive); drag horizontally to adjust (hold Shift for ×10)"
264+
step={0.01}
265+
decimals={2}
266+
min={min}
267+
onChange={(v) => this._commitGizmoSnapFromPreferences({ ...snap, scaleStep: v })}
268+
/>
269+
</div>
270+
</div>
271+
</div>
272+
);
273+
}
274+
179275
private _saveCameraControls(): void {
180276
const camera = this.props.editor.layout?.preview?.camera;
181277
if (!camera) {

editor/src/editor/layout/inspector/fields/number.tsx

Lines changed: 69 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Scalar, Tools } from "babylonjs";
88
import Mexp from "math-expression-evaluator";
99

1010
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../../../../ui/shadcn/ui/tooltip";
11+
import { cn } from "../../../../ui/utils";
1112

1213
import { registerSimpleUndoRedo } from "../../../../tools/undoredo";
1314
import { getInspectorPropertyValue, setInspectorEffectivePropertyValue } from "../../../../tools/property";
@@ -16,7 +17,7 @@ import { IEditorInspectorFieldProps } from "./field";
1617

1718
const mexp = new Mexp();
1819

19-
export interface IEditorInspectorNumberFieldProps extends IEditorInspectorFieldProps {
20+
export interface IEditorInspectorNumberFieldProps extends Partial<IEditorInspectorFieldProps> {
2021
min?: number;
2122
max?: number;
2223

@@ -27,24 +28,42 @@ export interface IEditorInspectorNumberFieldProps extends IEditorInspectorFieldP
2728

2829
onChange?: (value: number) => void;
2930
onFinishChange?: (value: number, oldValue: number) => void;
31+
32+
/** When set, value is driven from React state; object/property and inspector mutation are skipped. */
33+
controlledValue?: number;
34+
/** Overrides fractional digits for display/scrub (defaults from step). */
35+
decimals?: number;
36+
37+
wrapperClassName?: string;
38+
inputClassName?: string;
39+
title?: string;
3040
}
3141

3242
export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldProps) {
43+
const isControlled = props.controlledValue !== undefined;
44+
3345
const [shiftDown, setShiftDown] = useState(false);
3446
const [pointerOver, setPointerOver] = useState(false);
3547

3648
const [warning, setWarning] = useState(false);
3749

3850
const step = props.step ?? 0.01;
39-
const digitCount = props.step?.toString().split(".")[1]?.length ?? 2;
51+
const digitCount = props.decimals ?? (props.step?.toString().split(".")[1]?.length ?? 2);
4052

41-
const [value, setValue] = useState<string>(getStartValue());
42-
const [oldValue, setOldValue] = useState<string>(getStartValue());
53+
const [value, setValue] = useState<string>(() => formatInitial());
54+
const [oldValue, setOldValue] = useState<string>(() => formatInitial());
55+
56+
function formatInitial(): string {
57+
const n = getStartValue();
58+
return typeof n === "number" && Number.isFinite(n) ? n.toFixed(digitCount) : String(n);
59+
}
4360

4461
useEffect(() => {
45-
setValue(getStartValue());
46-
setOldValue(getStartValue());
47-
}, [props.object, props.property, props.step]);
62+
const n = getStartValue();
63+
const s = typeof n === "number" && Number.isFinite(n) ? n.toFixed(digitCount) : String(n);
64+
setValue(s);
65+
setOldValue(s);
66+
}, isControlled ? [props.controlledValue, props.step, props.asDegrees, digitCount] : [props.object, props.property, props.step, props.asDegrees, digitCount]);
4867

4968
useEventListener("keydown", (ev) => {
5069
if (ev.key === "Shift") {
@@ -59,18 +78,23 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro
5978
});
6079

6180
function getStartValue() {
81+
if (isControlled) {
82+
let v = props.controlledValue as number;
83+
if (props.asDegrees) {
84+
v = Tools.ToDegrees(v);
85+
}
86+
return v;
87+
}
88+
89+
if (!props.object || !props.property) {
90+
return 0;
91+
}
92+
6293
let startValue = getInspectorPropertyValue(props.object, props.property) ?? 0;
6394
if (props.asDegrees) {
6495
startValue = Tools.ToDegrees(startValue);
6596
}
6697

67-
// Determine if the value should be fixed at "step" digit counts or kept as-is.
68-
// if (props.asDegrees) {
69-
// startValue = Tools.ToDegrees(startValue).toFixed(digitCount);
70-
// } else {
71-
// startValue = startValue.toFixed(digitCount);
72-
// }
73-
7498
return startValue;
7599
}
76100

@@ -108,7 +132,11 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro
108132
const ratio = hasMinMax ? getRatio() : 0;
109133

110134
return (
111-
<div className="flex gap-2 items-center px-2" onMouseOver={() => setPointerOver(true)} onMouseLeave={() => setPointerOver(false)}>
135+
<div
136+
className={cn("flex gap-2 items-center px-2", props.wrapperClassName)}
137+
onMouseOver={() => setPointerOver(true)}
138+
onMouseLeave={() => setPointerOver(false)}
139+
>
112140
{props.label && (
113141
<div className="flex items-center gap-2 w-1/3 text-ellipsis overflow-hidden whitespace-nowrap">
114142
<div
@@ -135,6 +163,7 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro
135163

136164
<input
137165
type="text"
166+
title={props.title}
138167
value={value}
139168
onChange={(ev) => {
140169
setValue(ev.currentTarget.value);
@@ -160,7 +189,9 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro
160189

161190
setWarning(false);
162191

163-
setInspectorEffectivePropertyValue(props.object, props.property, float);
192+
if (!isControlled && props.object && props.property) {
193+
setInspectorEffectivePropertyValue(props.object, props.property, float);
194+
}
164195
props.onChange?.(float);
165196
}
166197
}}
@@ -171,12 +202,12 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro
171202
? `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%)`
172203
: undefined,
173204
}}
174-
className={`
175-
px-5 py-2 rounded-lg bg-muted-foreground/10 outline-none ring-yellow-500
176-
${warning ? "ring-2 bg-background" : "ring-0"}
177-
${props.label ? "w-2/3" : "w-full"}
178-
transition-all duration-300 ease-in-out
179-
`}
205+
className={cn(
206+
"px-5 py-2 rounded-lg bg-muted-foreground/10 outline-none ring-yellow-500 transition-all duration-300 ease-in-out",
207+
warning ? "ring-2 bg-background" : "ring-0",
208+
props.label ? "w-2/3" : "w-full",
209+
props.inputClassName
210+
)}
180211
onKeyUp={(ev) => ev.key === "Enter" && ev.currentTarget.blur()}
181212
onBlur={(ev) => {
182213
if (ev.currentTarget.value !== oldValue) {
@@ -208,7 +239,7 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro
208239
newValueFloat = Tools.ToRadians(newValueFloat);
209240
}
210241

211-
if (!props.noUndoRedo) {
242+
if (!props.noUndoRedo && !isControlled && props.object && props.property) {
212243
registerSimpleUndoRedo({
213244
object: props.object,
214245
property: props.property,
@@ -275,7 +306,9 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro
275306
setWarning(false);
276307
setValue(v.toFixed(digitCount));
277308

278-
setInspectorEffectivePropertyValue(props.object, props.property, finalValue);
309+
if (!isControlled && props.object && props.property) {
310+
setInspectorEffectivePropertyValue(props.object, props.property, finalValue);
311+
}
279312
props.onChange?.(finalValue);
280313
})
281314
);
@@ -285,7 +318,7 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro
285318
(mouseUpListener = () => {
286319
document.exitPointerLock();
287320

288-
if (v !== oldV && !props.noUndoRedo) {
321+
if (v !== oldV && !props.noUndoRedo && !isControlled && props.object && props.property) {
289322
setValue(v.toFixed(digitCount));
290323

291324
let finalValue = v;
@@ -308,6 +341,17 @@ export function EditorInspectorNumberField(props: IEditorInspectorNumberFieldPro
308341

309342
props.onFinishChange?.(finalValue, oldValue);
310343
}
344+
} else if (v !== oldV && isControlled) {
345+
setValue(v.toFixed(digitCount));
346+
let finalValue = v;
347+
if (props.asDegrees) {
348+
finalValue = Tools.ToRadians(finalValue);
349+
}
350+
if (!isNaN(v) && !isNaN(oldV)) {
351+
const oldVal = props.asDegrees ? Tools.ToRadians(oldV) : oldV;
352+
setOldValue(v.toFixed(digitCount));
353+
props.onFinishChange?.(finalValue, oldVal);
354+
}
311355
}
312356

313357
document.body.style.cursor = "auto";

0 commit comments

Comments
 (0)