Skip to content

Commit 377e7de

Browse files
authored
Local prop override interface for scene tree GUI (#323)
* Add GUI for locally overriding scene node props * support infinity * Minor UX adjustments
1 parent 917f228 commit 377e7de

File tree

5 files changed

+321
-8
lines changed

5 files changed

+321
-8
lines changed

src/viser/client/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"dependencies": {
66
"@mantine/core": "^7.6.2",
77
"@mantine/dates": "^7.6.2",
8+
"@mantine/form": "^7.13.5",
89
"@mantine/hooks": "^7.6.2",
910
"@mantine/notifications": "^7.6.2",
1011
"@mantine/vanilla-extract": "^7.6.2",
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import { style } from "@vanilla-extract/css";
1+
import { globalStyle, style } from "@vanilla-extract/css";
22
import { vars } from "../AppTheme";
33

44
export const tableWrapper = style({
5-
border: "1px solid",
6-
borderColor: vars.colors.defaultBorder,
75
borderRadius: vars.radius.xs,
86
padding: "0.1em 0",
97
overflowX: "auto",
@@ -12,13 +10,33 @@ export const tableWrapper = style({
1210
gap: "0",
1311
});
1412

13+
export const propsWrapper = style({
14+
position: "relative",
15+
borderRadius: vars.radius.xs,
16+
border: "1px solid",
17+
borderColor: vars.colors.defaultBorder,
18+
padding: vars.spacing.xs,
19+
paddingTop: "1.5em",
20+
boxSizing: "border-box",
21+
margin: vars.spacing.xs,
22+
marginTop: "0.1em",
23+
overflowX: "auto",
24+
display: "flex",
25+
flexDirection: "column",
26+
gap: vars.spacing.xs,
27+
});
28+
1529
export const caretIcon = style({
1630
opacity: 0.5,
1731
height: "1em",
1832
width: "1em",
1933
transform: "translateY(0.1em)",
2034
});
2135

36+
export const editIconWrapper = style({
37+
opacity: "0",
38+
});
39+
2240
export const tableRow = style({
2341
display: "flex",
2442
alignItems: "center",
@@ -27,3 +45,7 @@ export const tableRow = style({
2745
lineHeight: "2em",
2846
fontSize: "0.875em",
2947
});
48+
49+
globalStyle(`${tableRow}:hover ${editIconWrapper}`, {
50+
opacity: "1.0",
51+
});

src/viser/client/src/ControlPanel/SceneTreeTable.tsx

+281-4
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,258 @@
1-
import { ViewerContext } from "../App";
2-
import { Box, ScrollArea, Tooltip } from "@mantine/core";
31
import {
42
IconCaretDown,
53
IconCaretRight,
64
IconEye,
75
IconEyeOff,
6+
IconPencil,
7+
IconDeviceFloppy,
8+
IconX,
89
} from "@tabler/icons-react";
910
import React from "react";
10-
import { caretIcon, tableRow, tableWrapper } from "./SceneTreeTable.css";
11+
import {
12+
caretIcon,
13+
editIconWrapper,
14+
propsWrapper,
15+
tableRow,
16+
tableWrapper,
17+
} from "./SceneTreeTable.css";
1118
import { useDisclosure } from "@mantine/hooks";
19+
import { useForm } from "@mantine/form";
20+
import { ViewerContext } from "../App";
21+
import {
22+
Box,
23+
Flex,
24+
ScrollArea,
25+
TextInput,
26+
Tooltip,
27+
ColorInput,
28+
} from "@mantine/core";
29+
30+
function EditNodeProps({
31+
nodeName,
32+
close,
33+
}: {
34+
nodeName: string;
35+
close: () => void;
36+
}) {
37+
const viewer = React.useContext(ViewerContext)!;
38+
const node = viewer.useSceneTree((state) => state.nodeFromName[nodeName]);
39+
const updateSceneNode = viewer.useSceneTree((state) => state.updateSceneNode);
40+
41+
if (node === undefined) {
42+
return null;
43+
}
44+
45+
// We'll use JSON, but add support for Infinity.
46+
// We use infinity for point cloud rendering norms.
47+
function stringify(value: any) {
48+
if (value == Number.POSITIVE_INFINITY) {
49+
return "Infinity";
50+
} else {
51+
return JSON.stringify(value);
52+
}
53+
}
54+
function parse(value: string) {
55+
if (value === "Infinity") {
56+
return Number.POSITIVE_INFINITY;
57+
} else {
58+
return JSON.parse(value);
59+
}
60+
}
61+
62+
const props = node.message.props;
63+
console.log(props);
64+
const initialValues = Object.fromEntries(
65+
Object.entries(props)
66+
.filter(([, value]) => !(value instanceof Uint8Array))
67+
.map(([key, value]) => [key, stringify(value)]),
68+
);
69+
70+
const form = useForm({
71+
initialValues: {
72+
...initialValues,
73+
},
74+
validate: {
75+
...Object.fromEntries(
76+
Object.keys(initialValues).map((key) => [
77+
key,
78+
(value: string) => {
79+
try {
80+
parse(value);
81+
return null;
82+
} catch (e) {
83+
return "Invalid JSON";
84+
}
85+
},
86+
]),
87+
),
88+
},
89+
});
90+
91+
const handleSubmit = (values: Record<string, string>) => {
92+
Object.entries(values).forEach(([key, value]) => {
93+
if (value !== initialValues[key]) {
94+
try {
95+
const parsedValue = parse(value);
96+
updateSceneNode(nodeName, { [key]: parsedValue });
97+
// Update the form value to match the parsed value
98+
form.setFieldValue(key, stringify(parsedValue));
99+
} catch (e) {
100+
console.error("Failed to parse JSON:", e);
101+
}
102+
}
103+
});
104+
};
105+
106+
return (
107+
<Box
108+
className={propsWrapper}
109+
component="form"
110+
onSubmit={form.onSubmit(handleSubmit)}
111+
>
112+
<Box
113+
style={{
114+
position: "absolute",
115+
top: "0.3em",
116+
right: "0.4em",
117+
}}
118+
>
119+
<Tooltip label={"Close props"}>
120+
<IconX
121+
style={{
122+
cursor: "pointer",
123+
width: "1em",
124+
height: "1em",
125+
display: "block",
126+
color: "--mantine-color-error",
127+
opacity: "0.7",
128+
}}
129+
onClick={(evt) => {
130+
evt.stopPropagation();
131+
close();
132+
}}
133+
/>
134+
</Tooltip>
135+
</Box>
136+
{Object.entries(props).map(([key, value]) => {
137+
if (value instanceof Uint8Array) {
138+
return null;
139+
}
140+
141+
const isDirty = form.values[key] !== initialValues[key];
142+
143+
return (
144+
<Flex key={key} align="center">
145+
<Box size="sm" fz="xs" style={{ flexGrow: "1" }}>
146+
{key.charAt(0).toUpperCase() + key.slice(1).split("_").join(" ")}
147+
</Box>
148+
<Flex gap="xs" w="9em">
149+
{(() => {
150+
// Check if this is a color property
151+
try {
152+
const parsedValue = parse(form.values[key]);
153+
const isColorProp =
154+
key.toLowerCase().includes("color") &&
155+
Array.isArray(parsedValue) &&
156+
parsedValue.length === 3 &&
157+
parsedValue.every((v) => typeof v === "number");
158+
159+
if (isColorProp) {
160+
// Convert RGB array [0-1] to hex color
161+
const rgbToHex = (r: number, g: number, b: number) => {
162+
const toHex = (n: number) => {
163+
const hex = Math.round(n).toString(16);
164+
return hex.length === 1 ? "0" + hex : hex;
165+
};
166+
return "#" + toHex(r) + toHex(g) + toHex(b);
167+
};
168+
169+
// Convert hex color to RGB array [0-1]
170+
const hexToRgb = (hex: string) => {
171+
const r = parseInt(hex.slice(1, 3), 16);
172+
const g = parseInt(hex.slice(3, 5), 16);
173+
const b = parseInt(hex.slice(5, 7), 16);
174+
return [r, g, b];
175+
};
176+
177+
return (
178+
<ColorInput
179+
size="xs"
180+
styles={{
181+
input: { height: "1.625rem", minHeight: "1.625rem" },
182+
// icon: { transform: "scale(0.8)" },
183+
}}
184+
w="100%"
185+
value={rgbToHex(
186+
parsedValue[0],
187+
parsedValue[1],
188+
parsedValue[2],
189+
)}
190+
onChange={(hex) => {
191+
const rgb = hexToRgb(hex);
192+
form.setFieldValue(key, stringify(rgb));
193+
form.onSubmit(handleSubmit)();
194+
}}
195+
onKeyDown={(e) => {
196+
if (e.key === "Enter") {
197+
e.preventDefault();
198+
form.onSubmit(handleSubmit)();
199+
}
200+
}}
201+
/>
202+
);
203+
}
204+
} catch (e) {
205+
// If parsing fails, fall back to TextInput
206+
}
207+
208+
// Default TextInput for non-color properties
209+
return (
210+
<TextInput
211+
size="xs"
212+
styles={{
213+
input: {
214+
height: "1.625rem",
215+
minHeight: "1.625rem",
216+
width: "100%",
217+
},
218+
// icon: { transform: "scale(0.8)" },
219+
}}
220+
w="100%"
221+
{...form.getInputProps(key)}
222+
onKeyDown={(e) => {
223+
if (e.key === "Enter") {
224+
e.preventDefault();
225+
form.onSubmit(handleSubmit)();
226+
}
227+
}}
228+
rightSection={
229+
<IconDeviceFloppy
230+
style={{
231+
width: "1rem",
232+
height: "1rem",
233+
opacity: isDirty ? 1.0 : 0.3,
234+
cursor: isDirty ? "pointer" : "default",
235+
}}
236+
onClick={() => {
237+
if (isDirty) {
238+
form.onSubmit(handleSubmit)();
239+
}
240+
}}
241+
/>
242+
}
243+
/>
244+
);
245+
})()}
246+
</Flex>
247+
</Flex>
248+
);
249+
})}
250+
<Box fz="xs" opacity="0.4">
251+
Changes can be overwritten by updates from the server.
252+
</Box>
253+
</Box>
254+
);
255+
}
12256

13257
/* Table for seeing an overview of the scene tree, toggling visibility, etc. * */
14258
export default function SceneTreeTable() {
@@ -74,6 +318,9 @@ const SceneTreeTableRow = React.memo(function SceneTreeTableRow(props: {
74318
const isVisibleEffective = isVisible && props.isParentVisible;
75319
const VisibleIcon = isVisible ? IconEye : IconEyeOff;
76320

321+
const [modalOpened, { open: openEditModal, close: closeEditModal }] =
322+
useDisclosure(false);
323+
77324
return (
78325
<>
79326
<Box
@@ -105,6 +352,7 @@ const SceneTreeTableRow = React.memo(function SceneTreeTableRow(props: {
105352
opacity: isVisibleEffective ? 0.85 : 0.25,
106353
width: "1.5em",
107354
height: "1.5em",
355+
display: "block",
108356
}}
109357
onClick={(evt) => {
110358
evt.stopPropagation();
@@ -113,7 +361,7 @@ const SceneTreeTableRow = React.memo(function SceneTreeTableRow(props: {
113361
/>
114362
</Tooltip>
115363
</Box>
116-
<Box>
364+
<Box style={{ flexGrow: "1" }}>
117365
{props.nodeName
118366
.split("/")
119367
.filter((part) => part.length > 0)
@@ -128,7 +376,36 @@ const SceneTreeTableRow = React.memo(function SceneTreeTableRow(props: {
128376
</span>
129377
))}
130378
</Box>
379+
{!modalOpened ? (
380+
<Box
381+
className={editIconWrapper}
382+
style={{
383+
width: "1.25em",
384+
height: "1.25em",
385+
display: "block",
386+
transition: "opacity 0.2s",
387+
}}
388+
>
389+
<Tooltip label={"Local props"}>
390+
<IconPencil
391+
style={{
392+
cursor: "pointer",
393+
width: "1.25em",
394+
height: "1.25em",
395+
display: "block",
396+
}}
397+
onClick={(evt) => {
398+
evt.stopPropagation();
399+
openEditModal();
400+
}}
401+
/>
402+
</Tooltip>
403+
</Box>
404+
) : null}
131405
</Box>
406+
{modalOpened ? (
407+
<EditNodeProps nodeName={props.nodeName} close={closeEditModal} />
408+
) : null}
132409
{expanded
133410
? childrenName.map((name) => (
134411
<SceneTreeTableRow

src/viser/client/src/ControlPanel/ServerControls.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ export default function ServerControls() {
139139
/>
140140
<Divider mt="xs" />
141141
<Box>
142-
<Text mb="0.2em" fw={500}>
142+
<Text mb="0.2em" fw={500} fz="sm">
143143
Scene tree
144144
</Text>
145145
<MemoizedTable />

0 commit comments

Comments
 (0)