@arronqzy/react-view 1.0.13 → 1.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/styles.css +96 -4
- package/package.json +11 -10
- package/src/panel/ReactViewOnlinePreview.tsx +31 -2
- package/src/panel/ReactViewPanel.tsx +186 -124
- package/src/panel/ai/applyAssistantAction.ts +1 -0
- package/src/panel/components/BusyOverlay.tsx +25 -0
- package/src/panel/components/ElementsLayer.tsx +110 -68
- package/src/panel/components/MaterialSidebar.tsx +15 -0
- package/src/panel/components/MoveableLayer.tsx +8 -0
- package/src/panel/components/PanelCanvas.tsx +3 -0
- package/src/panel/components/PanelConfigSidebar.tsx +140 -91
- package/src/panel/components/SelectLayer.tsx +5 -0
- package/src/panel/components/WorkspaceConfigSidebar.tsx +1 -0
- package/src/panel/components/WorkspaceProjectNav.tsx +27 -4
- package/src/panel/components/config/PanelConfigScene3dSection.tsx +874 -0
- package/src/panel/components/scope-config/ScopeTemplateAutocompleteHost.tsx +1 -2
- package/src/panel/components/table/PanelConfigTableSection.tsx +82 -50
- package/src/panel/components/table/TableNodeContent.tsx +40 -15
- package/src/panel/hooks/usePanelElements.ts +13 -1
- package/src/panel/hooks/useWorkspaceProjects.ts +133 -66
- package/src/panel/library/workspace-project-cache.ts +8 -2
- package/src/panel/library/workspace-project-db.ts +29 -11
- package/src/panel/library/workspace-snapshot.ts +21 -4
- package/src/panel/scope/view-scope-store.ts +8 -0
- package/src/panel/types.ts +4 -3
- package/src/panel/utils/async-work.ts +178 -0
- package/src/panel/utils/gridPlacement.ts +29 -0
- package/src/panel/utils/panelElementDefaults.ts +37 -1
- package/src/panel/utils/scope-autocomplete.ts +0 -114
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
import React, { useCallback, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useI18n } from "@arronqzy/i18n/react";
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
Checkbox,
|
|
6
|
+
Collapsible,
|
|
7
|
+
CollapsibleContent,
|
|
8
|
+
CollapsibleTrigger,
|
|
9
|
+
Input,
|
|
10
|
+
Select,
|
|
11
|
+
SelectContent,
|
|
12
|
+
SelectItem,
|
|
13
|
+
SelectTrigger,
|
|
14
|
+
SelectValue,
|
|
15
|
+
Textarea,
|
|
16
|
+
} from "@arronqzy/ui";
|
|
17
|
+
import {
|
|
18
|
+
makeScene3dObjectKey,
|
|
19
|
+
mergeScene3dConfig,
|
|
20
|
+
purposePresetPatch,
|
|
21
|
+
inferModelFormat,
|
|
22
|
+
type Scene3dConfig,
|
|
23
|
+
type Scene3dModelAnimationRule,
|
|
24
|
+
type Scene3dCameraAnimationRule,
|
|
25
|
+
type Scene3dObjectTag,
|
|
26
|
+
type Scene3dPivot,
|
|
27
|
+
type Scene3dPurpose,
|
|
28
|
+
type Scene3dEnvironmentPreset,
|
|
29
|
+
} from "@arronqzy/view-scene3d";
|
|
30
|
+
import type { PanelElement } from "../../types";
|
|
31
|
+
import { readFileAsDataUrl } from "../../utils/async-work";
|
|
32
|
+
import { randomId } from "../../utils/panelElementDefaults";
|
|
33
|
+
import { ConfigHintIcon } from "../ConfigHintIcon";
|
|
34
|
+
|
|
35
|
+
type UpdateElement = (
|
|
36
|
+
id: string,
|
|
37
|
+
patch: Partial<PanelElement>,
|
|
38
|
+
options?: { batchId?: string; meta?: Record<string, unknown> }
|
|
39
|
+
) => void;
|
|
40
|
+
|
|
41
|
+
export type PanelConfigScene3dSectionProps = {
|
|
42
|
+
element: PanelElement;
|
|
43
|
+
updateElement: UpdateElement;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type CameraAnimPreset = "orbit" | "pushIn" | "pullBack" | "truckLeft" | "craneUp";
|
|
47
|
+
|
|
48
|
+
function patchScene3d(
|
|
49
|
+
element: PanelElement,
|
|
50
|
+
updateElement: UpdateElement,
|
|
51
|
+
patch: Partial<Scene3dConfig>
|
|
52
|
+
) {
|
|
53
|
+
updateElement(element.id, {
|
|
54
|
+
scene3d: mergeScene3dConfig({ ...element.scene3d, ...patch }),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function PanelConfigScene3dSection({
|
|
59
|
+
element,
|
|
60
|
+
updateElement,
|
|
61
|
+
}: PanelConfigScene3dSectionProps) {
|
|
62
|
+
const { t } = useI18n();
|
|
63
|
+
const config = useMemo(
|
|
64
|
+
() => mergeScene3dConfig(element.scene3d),
|
|
65
|
+
[element.scene3d]
|
|
66
|
+
);
|
|
67
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
68
|
+
const [nameQuery, setNameQuery] = useState<Record<string, string>>({});
|
|
69
|
+
const [openTagKeys, setOpenTagKeys] = useState<Record<string, boolean>>({});
|
|
70
|
+
const patch = useCallback(
|
|
71
|
+
(next: Partial<Scene3dConfig>) => patchScene3d(element, updateElement, next),
|
|
72
|
+
[element, updateElement]
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const handleUploadModels = async (files: FileList | null) => {
|
|
76
|
+
if (!files?.length) return;
|
|
77
|
+
const nextModels = [...config.models];
|
|
78
|
+
for (const file of Array.from(files)) {
|
|
79
|
+
try {
|
|
80
|
+
const url = await readFileAsDataUrl(file, t("panel.messages.readModelFailed"), "model3d");
|
|
81
|
+
nextModels.push({
|
|
82
|
+
id: randomId("mdl"),
|
|
83
|
+
label: file.name.replace(/\.[^.]+$/, "") || file.name,
|
|
84
|
+
url,
|
|
85
|
+
fileName: file.name,
|
|
86
|
+
format: inferModelFormat(file.name),
|
|
87
|
+
});
|
|
88
|
+
} catch (error) {
|
|
89
|
+
window.alert(error instanceof Error ? error.message : t("panel.messages.readModelFailed"));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
patch({ models: nextModels });
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const updateModelAnimation = (
|
|
96
|
+
id: string,
|
|
97
|
+
next: Partial<Scene3dModelAnimationRule>
|
|
98
|
+
) => {
|
|
99
|
+
patch({
|
|
100
|
+
modelAnimations: config.modelAnimations.map((rule) =>
|
|
101
|
+
rule.id === id ? { ...rule, ...next } : rule
|
|
102
|
+
),
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const addModelAnimation = () => {
|
|
107
|
+
const firstModel = config.models[0];
|
|
108
|
+
const firstName = firstModel?.objectNames?.[0] ?? "";
|
|
109
|
+
const rule: Scene3dModelAnimationRule = {
|
|
110
|
+
id: randomId("anim"),
|
|
111
|
+
modelId: firstModel?.id ?? "",
|
|
112
|
+
objectName: firstName,
|
|
113
|
+
axis: "y",
|
|
114
|
+
direction: "clockwise",
|
|
115
|
+
pivot: "right",
|
|
116
|
+
angleDeg: 90,
|
|
117
|
+
durationSec: 1.2,
|
|
118
|
+
loop: false,
|
|
119
|
+
};
|
|
120
|
+
patch({ modelAnimations: [...config.modelAnimations, rule] });
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const updateCameraAnimation = (
|
|
124
|
+
id: string,
|
|
125
|
+
next: Partial<Scene3dCameraAnimationRule>
|
|
126
|
+
) => {
|
|
127
|
+
patch({
|
|
128
|
+
cameraAnimations: config.cameraAnimations.map((rule) =>
|
|
129
|
+
rule.id === id ? { ...rule, ...next } : rule
|
|
130
|
+
),
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const addCameraAnimationPreset = (preset: CameraAnimPreset) => {
|
|
135
|
+
const [px, py, pz] = config.camera.position;
|
|
136
|
+
const [tx, ty, tz] = config.camera.target;
|
|
137
|
+
const dx = tx - px;
|
|
138
|
+
const dy = ty - py;
|
|
139
|
+
const dz = tz - pz;
|
|
140
|
+
const dist = Math.max(0.001, Math.hypot(dx, dy, dz));
|
|
141
|
+
const nx = dx / dist;
|
|
142
|
+
const ny = dy / dist;
|
|
143
|
+
const nz = dz / dist;
|
|
144
|
+
let rule: Scene3dCameraAnimationRule;
|
|
145
|
+
|
|
146
|
+
if (preset === "pushIn") {
|
|
147
|
+
rule = {
|
|
148
|
+
id: randomId("cam"),
|
|
149
|
+
label: t("panel.config.scene3dCameraAnimPushIn"),
|
|
150
|
+
from: { ...config.camera },
|
|
151
|
+
to: {
|
|
152
|
+
...config.camera,
|
|
153
|
+
position: [px + nx * dist * 0.28, py + ny * dist * 0.28, pz + nz * dist * 0.28],
|
|
154
|
+
},
|
|
155
|
+
durationSec: 1.6,
|
|
156
|
+
loop: false,
|
|
157
|
+
};
|
|
158
|
+
} else if (preset === "pullBack") {
|
|
159
|
+
rule = {
|
|
160
|
+
id: randomId("cam"),
|
|
161
|
+
label: t("panel.config.scene3dCameraAnimPullBack"),
|
|
162
|
+
from: { ...config.camera },
|
|
163
|
+
to: {
|
|
164
|
+
...config.camera,
|
|
165
|
+
position: [px - nx * dist * 0.35, py - ny * dist * 0.35, pz - nz * dist * 0.35],
|
|
166
|
+
},
|
|
167
|
+
durationSec: 1.8,
|
|
168
|
+
loop: false,
|
|
169
|
+
};
|
|
170
|
+
} else if (preset === "truckLeft") {
|
|
171
|
+
rule = {
|
|
172
|
+
id: randomId("cam"),
|
|
173
|
+
label: t("panel.config.scene3dCameraAnimTruckLeft"),
|
|
174
|
+
from: { ...config.camera },
|
|
175
|
+
to: {
|
|
176
|
+
...config.camera,
|
|
177
|
+
position: [px - 1.6, py, pz],
|
|
178
|
+
target: [tx - 1.6, ty, tz],
|
|
179
|
+
},
|
|
180
|
+
durationSec: 1.8,
|
|
181
|
+
loop: false,
|
|
182
|
+
};
|
|
183
|
+
} else if (preset === "craneUp") {
|
|
184
|
+
rule = {
|
|
185
|
+
id: randomId("cam"),
|
|
186
|
+
label: t("panel.config.scene3dCameraAnimCraneUp"),
|
|
187
|
+
from: { ...config.camera },
|
|
188
|
+
to: {
|
|
189
|
+
...config.camera,
|
|
190
|
+
position: [px, py + 1.5, pz],
|
|
191
|
+
target: [tx, ty + 0.6, tz],
|
|
192
|
+
},
|
|
193
|
+
durationSec: 1.8,
|
|
194
|
+
loop: false,
|
|
195
|
+
};
|
|
196
|
+
} else {
|
|
197
|
+
rule = {
|
|
198
|
+
id: randomId("cam"),
|
|
199
|
+
label: t("panel.config.scene3dCameraAnimDefault"),
|
|
200
|
+
mode: "orbit",
|
|
201
|
+
orbitSpeedDegPerSec: 24,
|
|
202
|
+
from: { ...config.camera },
|
|
203
|
+
to: {
|
|
204
|
+
...config.camera,
|
|
205
|
+
position: [
|
|
206
|
+
px + Math.max(1.5, dist * 0.32),
|
|
207
|
+
py + Math.max(0.5, dist * 0.08),
|
|
208
|
+
pz + Math.max(1, dist * 0.2),
|
|
209
|
+
],
|
|
210
|
+
},
|
|
211
|
+
durationSec: 6,
|
|
212
|
+
loop: true,
|
|
213
|
+
pingPong: false,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
patch({ cameraAnimations: [...config.cameraAnimations, rule] });
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const addCameraAnimation = () => {
|
|
220
|
+
addCameraAnimationPreset("orbit");
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const PIVOTS: Scene3dPivot[] = [
|
|
224
|
+
"center",
|
|
225
|
+
"top",
|
|
226
|
+
"bottom",
|
|
227
|
+
"left",
|
|
228
|
+
"right",
|
|
229
|
+
"front",
|
|
230
|
+
"back",
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
const selectObject = (modelId: string, objectName: string) => {
|
|
234
|
+
const key = makeScene3dObjectKey(modelId, objectName);
|
|
235
|
+
patch({ selectedObjectKey: config.selectedObjectKey === key ? null : key });
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const toggleHidden = (modelId: string, objectName: string) => {
|
|
239
|
+
const key = makeScene3dObjectKey(modelId, objectName);
|
|
240
|
+
const hidden = new Set(config.hiddenObjectKeys ?? []);
|
|
241
|
+
if (hidden.has(key)) hidden.delete(key);
|
|
242
|
+
else hidden.add(key);
|
|
243
|
+
patch({
|
|
244
|
+
hiddenObjectKeys: [...hidden],
|
|
245
|
+
soloObjectKey: config.soloObjectKey === key ? null : config.soloObjectKey,
|
|
246
|
+
});
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const toggleSolo = (modelId: string, objectName: string) => {
|
|
250
|
+
const key = makeScene3dObjectKey(modelId, objectName);
|
|
251
|
+
patch({
|
|
252
|
+
soloObjectKey: config.soloObjectKey === key ? null : key,
|
|
253
|
+
selectedObjectKey: key,
|
|
254
|
+
});
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const upsertObjectTag = (
|
|
258
|
+
modelId: string,
|
|
259
|
+
objectName: string,
|
|
260
|
+
next: Partial<Scene3dObjectTag>
|
|
261
|
+
) => {
|
|
262
|
+
const tags = [...(config.objectTags ?? [])];
|
|
263
|
+
const index = tags.findIndex(
|
|
264
|
+
(tag) => tag.modelId === modelId && tag.objectName === objectName
|
|
265
|
+
);
|
|
266
|
+
if (index >= 0) {
|
|
267
|
+
tags[index] = { ...tags[index], ...next };
|
|
268
|
+
} else {
|
|
269
|
+
tags.push({
|
|
270
|
+
id: randomId("tag"),
|
|
271
|
+
modelId,
|
|
272
|
+
objectName,
|
|
273
|
+
html: "",
|
|
274
|
+
anchor: "top",
|
|
275
|
+
...next,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
patch({ objectTags: tags });
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
<div className="space-y-3">
|
|
283
|
+
<div className="space-y-1.5">
|
|
284
|
+
<div className="text-[11px] font-medium">{t("panel.config.groupScene3dPurpose")}</div>
|
|
285
|
+
<Select
|
|
286
|
+
value={config.presentation.purpose}
|
|
287
|
+
onValueChange={(value) => {
|
|
288
|
+
const purpose = value as Scene3dPurpose;
|
|
289
|
+
patch(purposePresetPatch(purpose));
|
|
290
|
+
}}
|
|
291
|
+
>
|
|
292
|
+
<SelectTrigger className="h-7 text-[11px]">
|
|
293
|
+
<SelectValue />
|
|
294
|
+
</SelectTrigger>
|
|
295
|
+
<SelectContent>
|
|
296
|
+
<SelectItem value="product">{t("panel.config.scene3dPurposeProduct")}</SelectItem>
|
|
297
|
+
<SelectItem value="twin">{t("panel.config.scene3dPurposeTwin")}</SelectItem>
|
|
298
|
+
<SelectItem value="sandbox">{t("panel.config.scene3dPurposeSandbox")}</SelectItem>
|
|
299
|
+
</SelectContent>
|
|
300
|
+
</Select>
|
|
301
|
+
<div className="text-[10px] text-muted-foreground">
|
|
302
|
+
{t("panel.config.scene3dPurposeHint")}
|
|
303
|
+
</div>
|
|
304
|
+
<label className="block space-y-0.5 text-[10px]">
|
|
305
|
+
<span>{t("panel.config.scene3dEnvironment")}</span>
|
|
306
|
+
<Select
|
|
307
|
+
value={config.presentation.environmentPreset}
|
|
308
|
+
onValueChange={(value) =>
|
|
309
|
+
patch({
|
|
310
|
+
presentation: {
|
|
311
|
+
...config.presentation,
|
|
312
|
+
environmentPreset: value as Scene3dEnvironmentPreset,
|
|
313
|
+
},
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
>
|
|
317
|
+
<SelectTrigger className="h-7 text-[11px]">
|
|
318
|
+
<SelectValue />
|
|
319
|
+
</SelectTrigger>
|
|
320
|
+
<SelectContent>
|
|
321
|
+
<SelectItem value="none">{t("common.none")}</SelectItem>
|
|
322
|
+
<SelectItem value="studio">Studio</SelectItem>
|
|
323
|
+
<SelectItem value="warehouse">Warehouse</SelectItem>
|
|
324
|
+
<SelectItem value="city">City</SelectItem>
|
|
325
|
+
<SelectItem value="sunset">Sunset</SelectItem>
|
|
326
|
+
<SelectItem value="apartment">Apartment</SelectItem>
|
|
327
|
+
<SelectItem value="forest">Forest</SelectItem>
|
|
328
|
+
</SelectContent>
|
|
329
|
+
</Select>
|
|
330
|
+
</label>
|
|
331
|
+
<label className="flex items-center gap-2 text-[11px]">
|
|
332
|
+
<Checkbox
|
|
333
|
+
checked={config.presentation.showContactShadows}
|
|
334
|
+
onCheckedChange={(checked) =>
|
|
335
|
+
patch({
|
|
336
|
+
presentation: {
|
|
337
|
+
...config.presentation,
|
|
338
|
+
showContactShadows: checked === true,
|
|
339
|
+
},
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
/>
|
|
343
|
+
{t("panel.config.scene3dContactShadows")}
|
|
344
|
+
</label>
|
|
345
|
+
<label className="flex items-center gap-2 text-[11px]">
|
|
346
|
+
<Checkbox
|
|
347
|
+
checked={config.presentation.showGrid}
|
|
348
|
+
onCheckedChange={(checked) =>
|
|
349
|
+
patch({
|
|
350
|
+
presentation: { ...config.presentation, showGrid: checked === true },
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
/>
|
|
354
|
+
{t("panel.config.scene3dShowGrid")}
|
|
355
|
+
</label>
|
|
356
|
+
</div>
|
|
357
|
+
|
|
358
|
+
<div className="flex items-center justify-between gap-2">
|
|
359
|
+
<div className="text-[11px] font-medium">{t("panel.config.groupScene3dModels")}</div>
|
|
360
|
+
<Button
|
|
361
|
+
type="button"
|
|
362
|
+
size="sm"
|
|
363
|
+
variant="outline"
|
|
364
|
+
className="h-7 text-[11px]"
|
|
365
|
+
onClick={() => fileInputRef.current?.click()}
|
|
366
|
+
>
|
|
367
|
+
{t("panel.config.scene3dUploadModels")}
|
|
368
|
+
</Button>
|
|
369
|
+
<input
|
|
370
|
+
ref={fileInputRef}
|
|
371
|
+
type="file"
|
|
372
|
+
accept=".glb,.gltf,.obj,.fbx,model/gltf-binary,model/gltf+json"
|
|
373
|
+
multiple
|
|
374
|
+
className="hidden"
|
|
375
|
+
onChange={(e) => {
|
|
376
|
+
void handleUploadModels(e.target.files);
|
|
377
|
+
e.currentTarget.value = "";
|
|
378
|
+
}}
|
|
379
|
+
/>
|
|
380
|
+
</div>
|
|
381
|
+
|
|
382
|
+
{config.models.length === 0 ? (
|
|
383
|
+
<div className="rounded border border-dashed border-border/70 px-2 py-3 text-[11px] text-muted-foreground">
|
|
384
|
+
{t("panel.config.scene3dNoModels")}
|
|
385
|
+
</div>
|
|
386
|
+
) : (
|
|
387
|
+
<div className="space-y-2">
|
|
388
|
+
{config.models.map((model) => (
|
|
389
|
+
<div key={model.id} className="rounded border border-border/60 p-2 space-y-1.5">
|
|
390
|
+
<div className="flex items-center gap-2">
|
|
391
|
+
<Input
|
|
392
|
+
value={model.label}
|
|
393
|
+
className="h-7 text-[11px]"
|
|
394
|
+
onChange={(e) =>
|
|
395
|
+
patch({
|
|
396
|
+
models: config.models.map((m) =>
|
|
397
|
+
m.id === model.id ? { ...m, label: e.target.value } : m
|
|
398
|
+
),
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
/>
|
|
402
|
+
<Button
|
|
403
|
+
type="button"
|
|
404
|
+
size="sm"
|
|
405
|
+
variant="ghost"
|
|
406
|
+
className="h-7 shrink-0 text-[11px]"
|
|
407
|
+
onClick={() =>
|
|
408
|
+
patch({
|
|
409
|
+
models: config.models.filter((m) => m.id !== model.id),
|
|
410
|
+
hiddenObjectKeys: (config.hiddenObjectKeys ?? []).filter(
|
|
411
|
+
(key) => !key.startsWith(`${model.id}|||`)
|
|
412
|
+
),
|
|
413
|
+
objectTags: (config.objectTags ?? []).filter(
|
|
414
|
+
(tag) => tag.modelId !== model.id
|
|
415
|
+
),
|
|
416
|
+
selectedObjectKey: config.selectedObjectKey?.startsWith(
|
|
417
|
+
`${model.id}|||`
|
|
418
|
+
)
|
|
419
|
+
? null
|
|
420
|
+
: config.selectedObjectKey,
|
|
421
|
+
soloObjectKey: config.soloObjectKey?.startsWith(`${model.id}|||`)
|
|
422
|
+
? null
|
|
423
|
+
: config.soloObjectKey,
|
|
424
|
+
})
|
|
425
|
+
}
|
|
426
|
+
>
|
|
427
|
+
{t("common.remove")}
|
|
428
|
+
</Button>
|
|
429
|
+
</div>
|
|
430
|
+
<div className="text-[10px] text-muted-foreground">{model.fileName ?? model.url.slice(0, 48)}</div>
|
|
431
|
+
<div className="grid grid-cols-3 gap-1">
|
|
432
|
+
{(["x", "y", "z"] as const).map((axis, index) => (
|
|
433
|
+
<Input
|
|
434
|
+
key={`${model.id}-pos-${axis}`}
|
|
435
|
+
type="number"
|
|
436
|
+
className="h-7 text-[10px]"
|
|
437
|
+
title={axis.toUpperCase()}
|
|
438
|
+
value={(model.position ?? [0, 0, 0])[index]}
|
|
439
|
+
onChange={(e) => {
|
|
440
|
+
const next = [...(model.position ?? [0, 0, 0])] as [number, number, number];
|
|
441
|
+
next[index] = Number(e.target.value) || 0;
|
|
442
|
+
patch({
|
|
443
|
+
models: config.models.map((m) =>
|
|
444
|
+
m.id === model.id ? { ...m, position: next } : m
|
|
445
|
+
),
|
|
446
|
+
});
|
|
447
|
+
}}
|
|
448
|
+
/>
|
|
449
|
+
))}
|
|
450
|
+
</div>
|
|
451
|
+
{model.objectNames?.length ? (
|
|
452
|
+
<Collapsible defaultOpen={model.objectNames.length <= 12}>
|
|
453
|
+
<CollapsibleTrigger className="flex w-full items-center justify-between rounded bg-muted/40 px-1.5 py-1 text-left text-[10px] text-muted-foreground hover:bg-muted/70">
|
|
454
|
+
<span>
|
|
455
|
+
{t("panel.config.scene3dObjectNames")} ·{" "}
|
|
456
|
+
{t("panel.config.scene3dObjectCount", {
|
|
457
|
+
n: model.objectNames.length,
|
|
458
|
+
})}
|
|
459
|
+
</span>
|
|
460
|
+
<span>▾</span>
|
|
461
|
+
</CollapsibleTrigger>
|
|
462
|
+
<CollapsibleContent className="mt-1 space-y-1">
|
|
463
|
+
<Input
|
|
464
|
+
className="h-6 text-[10px]"
|
|
465
|
+
placeholder={t("panel.config.scene3dSearchObject")}
|
|
466
|
+
value={nameQuery[model.id] ?? ""}
|
|
467
|
+
onChange={(e) =>
|
|
468
|
+
setNameQuery((prev) => ({
|
|
469
|
+
...prev,
|
|
470
|
+
[model.id]: e.target.value,
|
|
471
|
+
}))
|
|
472
|
+
}
|
|
473
|
+
/>
|
|
474
|
+
<div className="max-h-44 overflow-auto rounded border border-border/50">
|
|
475
|
+
{model.objectNames
|
|
476
|
+
.filter((name) => {
|
|
477
|
+
const q = (nameQuery[model.id] ?? "").trim().toLowerCase();
|
|
478
|
+
return !q || name.toLowerCase().includes(q);
|
|
479
|
+
})
|
|
480
|
+
.map((name) => {
|
|
481
|
+
const key = makeScene3dObjectKey(model.id, name);
|
|
482
|
+
const selected = config.selectedObjectKey === key;
|
|
483
|
+
const hidden = (config.hiddenObjectKeys ?? []).includes(key);
|
|
484
|
+
const solo = config.soloObjectKey === key;
|
|
485
|
+
const tag = (config.objectTags ?? []).find(
|
|
486
|
+
(item) =>
|
|
487
|
+
item.modelId === model.id && item.objectName === name
|
|
488
|
+
);
|
|
489
|
+
const tagOpen = Boolean(openTagKeys[key]);
|
|
490
|
+
return (
|
|
491
|
+
<div
|
|
492
|
+
key={key}
|
|
493
|
+
className={[
|
|
494
|
+
"border-b border-border/40 last:border-b-0",
|
|
495
|
+
selected ? "bg-sky-500/15" : "",
|
|
496
|
+
hidden && !solo ? "opacity-50" : "",
|
|
497
|
+
].join(" ")}
|
|
498
|
+
>
|
|
499
|
+
<div className="flex items-center gap-0.5 px-1 py-0.5">
|
|
500
|
+
<button
|
|
501
|
+
type="button"
|
|
502
|
+
className="min-w-0 flex-1 truncate px-1 text-left text-[10px] hover:text-foreground"
|
|
503
|
+
title={name}
|
|
504
|
+
onClick={() => selectObject(model.id, name)}
|
|
505
|
+
>
|
|
506
|
+
{name}
|
|
507
|
+
</button>
|
|
508
|
+
<button
|
|
509
|
+
type="button"
|
|
510
|
+
className="shrink-0 rounded px-1 text-[10px] text-muted-foreground hover:bg-muted"
|
|
511
|
+
title={
|
|
512
|
+
hidden
|
|
513
|
+
? t("panel.config.scene3dShowObject")
|
|
514
|
+
: t("panel.config.scene3dHideObject")
|
|
515
|
+
}
|
|
516
|
+
onClick={() => toggleHidden(model.id, name)}
|
|
517
|
+
>
|
|
518
|
+
{hidden
|
|
519
|
+
? t("panel.config.scene3dShowShort")
|
|
520
|
+
: t("panel.config.scene3dHideShort")}
|
|
521
|
+
</button>
|
|
522
|
+
<button
|
|
523
|
+
type="button"
|
|
524
|
+
className={[
|
|
525
|
+
"shrink-0 rounded px-1 text-[10px] hover:bg-muted",
|
|
526
|
+
solo
|
|
527
|
+
? "text-sky-600"
|
|
528
|
+
: "text-muted-foreground",
|
|
529
|
+
].join(" ")}
|
|
530
|
+
title={
|
|
531
|
+
solo
|
|
532
|
+
? t("panel.config.scene3dSoloOff")
|
|
533
|
+
: t("panel.config.scene3dSoloObject")
|
|
534
|
+
}
|
|
535
|
+
onClick={() => toggleSolo(model.id, name)}
|
|
536
|
+
>
|
|
537
|
+
{t("panel.config.scene3dSoloShort")}
|
|
538
|
+
</button>
|
|
539
|
+
<button
|
|
540
|
+
type="button"
|
|
541
|
+
className={[
|
|
542
|
+
"shrink-0 rounded px-1 text-[10px] hover:bg-muted",
|
|
543
|
+
tagOpen || tag?.html
|
|
544
|
+
? "text-sky-600"
|
|
545
|
+
: "text-muted-foreground",
|
|
546
|
+
].join(" ")}
|
|
547
|
+
title={t("panel.config.scene3dObjectTag")}
|
|
548
|
+
onClick={() => {
|
|
549
|
+
setOpenTagKeys((prev) => ({
|
|
550
|
+
...prev,
|
|
551
|
+
[key]: !prev[key],
|
|
552
|
+
}));
|
|
553
|
+
if (!tag) upsertObjectTag(model.id, name, {});
|
|
554
|
+
}}
|
|
555
|
+
>
|
|
556
|
+
{t("panel.config.scene3dTagShort")}
|
|
557
|
+
</button>
|
|
558
|
+
</div>
|
|
559
|
+
{tagOpen ? (
|
|
560
|
+
<div className="space-y-1 px-1 pb-1.5">
|
|
561
|
+
<Select
|
|
562
|
+
value={tag?.anchor ?? "top"}
|
|
563
|
+
onValueChange={(value) =>
|
|
564
|
+
upsertObjectTag(model.id, name, {
|
|
565
|
+
anchor: value as Scene3dPivot,
|
|
566
|
+
})
|
|
567
|
+
}
|
|
568
|
+
>
|
|
569
|
+
<SelectTrigger className="h-6 text-[10px]">
|
|
570
|
+
<SelectValue
|
|
571
|
+
placeholder={t(
|
|
572
|
+
"panel.config.scene3dObjectTagAnchor"
|
|
573
|
+
)}
|
|
574
|
+
/>
|
|
575
|
+
</SelectTrigger>
|
|
576
|
+
<SelectContent>
|
|
577
|
+
{PIVOTS.map((pivot) => (
|
|
578
|
+
<SelectItem key={pivot} value={pivot}>
|
|
579
|
+
{t(`panel.config.scene3dPivot_${pivot}`)}
|
|
580
|
+
</SelectItem>
|
|
581
|
+
))}
|
|
582
|
+
</SelectContent>
|
|
583
|
+
</Select>
|
|
584
|
+
<Textarea
|
|
585
|
+
className="min-h-[64px] text-[10px]"
|
|
586
|
+
placeholder={t(
|
|
587
|
+
"panel.config.scene3dObjectTagPlaceholder"
|
|
588
|
+
)}
|
|
589
|
+
value={tag?.html ?? ""}
|
|
590
|
+
onChange={(e) =>
|
|
591
|
+
upsertObjectTag(model.id, name, {
|
|
592
|
+
html: e.target.value,
|
|
593
|
+
})
|
|
594
|
+
}
|
|
595
|
+
/>
|
|
596
|
+
</div>
|
|
597
|
+
) : null}
|
|
598
|
+
</div>
|
|
599
|
+
);
|
|
600
|
+
})}
|
|
601
|
+
</div>
|
|
602
|
+
</CollapsibleContent>
|
|
603
|
+
</Collapsible>
|
|
604
|
+
) : (
|
|
605
|
+
<div className="text-[10px] text-muted-foreground">
|
|
606
|
+
{t("panel.config.scene3dNoObjectNames")}
|
|
607
|
+
</div>
|
|
608
|
+
)}
|
|
609
|
+
</div>
|
|
610
|
+
))}
|
|
611
|
+
</div>
|
|
612
|
+
)}
|
|
613
|
+
|
|
614
|
+
<div className="space-y-2">
|
|
615
|
+
<div className="text-[11px] font-medium">{t("panel.config.groupScene3dCamera")}</div>
|
|
616
|
+
<div className="grid grid-cols-3 gap-1.5">
|
|
617
|
+
{(["x", "y", "z"] as const).map((axis, index) => (
|
|
618
|
+
<label key={`pos-${axis}`} className="space-y-0.5 text-[10px]">
|
|
619
|
+
<span>{t("panel.config.scene3dCameraPos")} {axis.toUpperCase()}</span>
|
|
620
|
+
<Input
|
|
621
|
+
type="number"
|
|
622
|
+
className="h-7"
|
|
623
|
+
value={config.camera.position[index]}
|
|
624
|
+
onChange={(e) => {
|
|
625
|
+
const next = [...config.camera.position] as [number, number, number];
|
|
626
|
+
next[index] = Number(e.target.value) || 0;
|
|
627
|
+
patch({ camera: { ...config.camera, position: next } });
|
|
628
|
+
}}
|
|
629
|
+
/>
|
|
630
|
+
</label>
|
|
631
|
+
))}
|
|
632
|
+
</div>
|
|
633
|
+
<div className="grid grid-cols-3 gap-1.5">
|
|
634
|
+
{(["x", "y", "z"] as const).map((axis, index) => (
|
|
635
|
+
<label key={`tgt-${axis}`} className="space-y-0.5 text-[10px]">
|
|
636
|
+
<span>{t("panel.config.scene3dCameraTarget")} {axis.toUpperCase()}</span>
|
|
637
|
+
<Input
|
|
638
|
+
type="number"
|
|
639
|
+
className="h-7"
|
|
640
|
+
value={config.camera.target[index]}
|
|
641
|
+
onChange={(e) => {
|
|
642
|
+
const next = [...config.camera.target] as [number, number, number];
|
|
643
|
+
next[index] = Number(e.target.value) || 0;
|
|
644
|
+
patch({ camera: { ...config.camera, target: next } });
|
|
645
|
+
}}
|
|
646
|
+
/>
|
|
647
|
+
</label>
|
|
648
|
+
))}
|
|
649
|
+
</div>
|
|
650
|
+
<label className="block space-y-0.5 text-[10px]">
|
|
651
|
+
<span>{t("panel.config.scene3dCameraFov")}</span>
|
|
652
|
+
<Input
|
|
653
|
+
type="number"
|
|
654
|
+
className="h-7"
|
|
655
|
+
value={config.camera.fov}
|
|
656
|
+
onChange={(e) =>
|
|
657
|
+
patch({ camera: { ...config.camera, fov: Number(e.target.value) || 45 } })
|
|
658
|
+
}
|
|
659
|
+
/>
|
|
660
|
+
</label>
|
|
661
|
+
<div className="text-[10px] text-muted-foreground">{t("panel.config.scene3dCameraEditDesc")}</div>
|
|
662
|
+
</div>
|
|
663
|
+
|
|
664
|
+
<div className="space-y-2">
|
|
665
|
+
<div className="text-[11px] font-medium">{t("panel.config.groupScene3dInteraction")}</div>
|
|
666
|
+
<label className="flex items-center gap-2 text-[11px]">
|
|
667
|
+
<Checkbox
|
|
668
|
+
checked={config.orbit.enableMouseControl}
|
|
669
|
+
onCheckedChange={(checked) =>
|
|
670
|
+
patch({ orbit: { ...config.orbit, enableMouseControl: checked === true } })
|
|
671
|
+
}
|
|
672
|
+
/>
|
|
673
|
+
{t("panel.config.scene3dEnableMouseControl")}
|
|
674
|
+
<ConfigHintIcon label={t("panel.config.scene3dEnableMouseControl")}>
|
|
675
|
+
{t("panel.config.scene3dEnableMouseControlHint")}
|
|
676
|
+
</ConfigHintIcon>
|
|
677
|
+
</label>
|
|
678
|
+
<label className="flex items-center gap-2 text-[11px]">
|
|
679
|
+
<Checkbox
|
|
680
|
+
checked={config.orbit.autoRotate}
|
|
681
|
+
onCheckedChange={(checked) =>
|
|
682
|
+
patch({ orbit: { ...config.orbit, autoRotate: checked === true } })
|
|
683
|
+
}
|
|
684
|
+
/>
|
|
685
|
+
{t("panel.config.scene3dAutoRotate")}
|
|
686
|
+
</label>
|
|
687
|
+
<label className="flex items-center gap-2 text-[11px]">
|
|
688
|
+
<Checkbox
|
|
689
|
+
checked={config.presentation.clickToFocus}
|
|
690
|
+
onCheckedChange={(checked) =>
|
|
691
|
+
patch({
|
|
692
|
+
presentation: { ...config.presentation, clickToFocus: checked === true },
|
|
693
|
+
})
|
|
694
|
+
}
|
|
695
|
+
/>
|
|
696
|
+
{t("panel.config.scene3dClickToFocus")}
|
|
697
|
+
</label>
|
|
698
|
+
<label className="flex items-center gap-2 text-[11px]">
|
|
699
|
+
<Checkbox
|
|
700
|
+
checked={config.presentation.highlightOnHover}
|
|
701
|
+
onCheckedChange={(checked) =>
|
|
702
|
+
patch({
|
|
703
|
+
presentation: { ...config.presentation, highlightOnHover: checked === true },
|
|
704
|
+
})
|
|
705
|
+
}
|
|
706
|
+
/>
|
|
707
|
+
{t("panel.config.scene3dHighlightHover")}
|
|
708
|
+
</label>
|
|
709
|
+
</div>
|
|
710
|
+
|
|
711
|
+
<div className="space-y-2">
|
|
712
|
+
<div className="flex items-center justify-between">
|
|
713
|
+
<div className="text-[11px] font-medium">{t("panel.config.groupScene3dModelAnim")}</div>
|
|
714
|
+
<Button type="button" size="sm" variant="outline" className="h-7 text-[11px]" onClick={addModelAnimation}>
|
|
715
|
+
{t("common.add")}
|
|
716
|
+
</Button>
|
|
717
|
+
</div>
|
|
718
|
+
{config.modelAnimations.map((rule) => (
|
|
719
|
+
<div key={rule.id} className="space-y-1.5 rounded border border-border/60 p-2">
|
|
720
|
+
<div className="grid grid-cols-2 gap-1.5">
|
|
721
|
+
<label className="space-y-0.5 text-[10px]">
|
|
722
|
+
<span>{t("panel.config.scene3dAnimModel")}</span>
|
|
723
|
+
<Select
|
|
724
|
+
value={rule.modelId}
|
|
725
|
+
onValueChange={(value) => updateModelAnimation(rule.id, { modelId: value })}
|
|
726
|
+
>
|
|
727
|
+
<SelectTrigger className="h-7 text-[11px]">
|
|
728
|
+
<SelectValue />
|
|
729
|
+
</SelectTrigger>
|
|
730
|
+
<SelectContent>
|
|
731
|
+
{config.models.map((m) => (
|
|
732
|
+
<SelectItem key={m.id} value={m.id}>
|
|
733
|
+
{m.label}
|
|
734
|
+
</SelectItem>
|
|
735
|
+
))}
|
|
736
|
+
</SelectContent>
|
|
737
|
+
</Select>
|
|
738
|
+
</label>
|
|
739
|
+
<label className="space-y-0.5 text-[10px]">
|
|
740
|
+
<span>{t("panel.config.scene3dAnimObjectName")}</span>
|
|
741
|
+
<Select
|
|
742
|
+
value={rule.objectName}
|
|
743
|
+
onValueChange={(value) => updateModelAnimation(rule.id, { objectName: value })}
|
|
744
|
+
>
|
|
745
|
+
<SelectTrigger className="h-7 text-[11px]">
|
|
746
|
+
<SelectValue placeholder={t("panel.config.scene3dSelectObject")} />
|
|
747
|
+
</SelectTrigger>
|
|
748
|
+
<SelectContent>
|
|
749
|
+
{(config.models.find((m) => m.id === rule.modelId)?.objectNames ?? []).map(
|
|
750
|
+
(name) => (
|
|
751
|
+
<SelectItem key={name} value={name}>
|
|
752
|
+
{name}
|
|
753
|
+
</SelectItem>
|
|
754
|
+
)
|
|
755
|
+
)}
|
|
756
|
+
</SelectContent>
|
|
757
|
+
</Select>
|
|
758
|
+
</label>
|
|
759
|
+
</div>
|
|
760
|
+
<div className="grid grid-cols-3 gap-1.5">
|
|
761
|
+
<label className="space-y-0.5 text-[10px]">
|
|
762
|
+
<span>{t("panel.config.scene3dAnimAxis")}</span>
|
|
763
|
+
<Select value={rule.axis} onValueChange={(v) => updateModelAnimation(rule.id, { axis: v as typeof rule.axis })}>
|
|
764
|
+
<SelectTrigger className="h-7"><SelectValue /></SelectTrigger>
|
|
765
|
+
<SelectContent>
|
|
766
|
+
<SelectItem value="x">X</SelectItem>
|
|
767
|
+
<SelectItem value="y">Y</SelectItem>
|
|
768
|
+
<SelectItem value="z">Z</SelectItem>
|
|
769
|
+
</SelectContent>
|
|
770
|
+
</Select>
|
|
771
|
+
</label>
|
|
772
|
+
<label className="space-y-0.5 text-[10px]">
|
|
773
|
+
<span>{t("panel.config.scene3dAnimPivot")}</span>
|
|
774
|
+
<Select value={rule.pivot} onValueChange={(v) => updateModelAnimation(rule.id, { pivot: v as typeof rule.pivot })}>
|
|
775
|
+
<SelectTrigger className="h-7"><SelectValue /></SelectTrigger>
|
|
776
|
+
<SelectContent>
|
|
777
|
+
{(["center", "left", "right", "front", "back", "top", "bottom"] as const).map((p) => (
|
|
778
|
+
<SelectItem key={p} value={p}>{t(`panel.config.scene3dPivot_${p}`)}</SelectItem>
|
|
779
|
+
))}
|
|
780
|
+
</SelectContent>
|
|
781
|
+
</Select>
|
|
782
|
+
</label>
|
|
783
|
+
<label className="space-y-0.5 text-[10px]">
|
|
784
|
+
<span>{t("panel.config.scene3dAnimDirection")}</span>
|
|
785
|
+
<Select value={rule.direction} onValueChange={(v) => updateModelAnimation(rule.id, { direction: v as typeof rule.direction })}>
|
|
786
|
+
<SelectTrigger className="h-7"><SelectValue /></SelectTrigger>
|
|
787
|
+
<SelectContent>
|
|
788
|
+
<SelectItem value="clockwise">{t("panel.config.scene3dDirClockwise")}</SelectItem>
|
|
789
|
+
<SelectItem value="counterclockwise">{t("panel.config.scene3dDirCounter")}</SelectItem>
|
|
790
|
+
</SelectContent>
|
|
791
|
+
</Select>
|
|
792
|
+
</label>
|
|
793
|
+
</div>
|
|
794
|
+
<div className="grid grid-cols-3 gap-1.5">
|
|
795
|
+
<label className="space-y-0.5 text-[10px]">
|
|
796
|
+
<span>{t("panel.config.scene3dAnimAngle")}</span>
|
|
797
|
+
<Input type="number" className="h-7" value={rule.angleDeg} onChange={(e) => updateModelAnimation(rule.id, { angleDeg: Number(e.target.value) || 0 })} />
|
|
798
|
+
</label>
|
|
799
|
+
<label className="space-y-0.5 text-[10px]">
|
|
800
|
+
<span>{t("panel.config.scene3dAnimDuration")}</span>
|
|
801
|
+
<Input type="number" className="h-7" value={rule.durationSec} onChange={(e) => updateModelAnimation(rule.id, { durationSec: Number(e.target.value) || 0.1 })} />
|
|
802
|
+
</label>
|
|
803
|
+
<label className="flex items-end gap-2 pb-1 text-[11px]">
|
|
804
|
+
<Checkbox checked={rule.loop} onCheckedChange={(c) => updateModelAnimation(rule.id, { loop: c === true })} />
|
|
805
|
+
{t("panel.config.scene3dAnimLoop")}
|
|
806
|
+
</label>
|
|
807
|
+
</div>
|
|
808
|
+
<Button type="button" size="sm" variant="ghost" className="h-7 text-[11px]" onClick={() => patch({ modelAnimations: config.modelAnimations.filter((a) => a.id !== rule.id) })}>
|
|
809
|
+
{t("common.remove")}
|
|
810
|
+
</Button>
|
|
811
|
+
</div>
|
|
812
|
+
))}
|
|
813
|
+
</div>
|
|
814
|
+
|
|
815
|
+
<div className="space-y-2">
|
|
816
|
+
<div className="flex items-center justify-between">
|
|
817
|
+
<div className="text-[11px] font-medium">{t("panel.config.groupScene3dCameraAnim")}</div>
|
|
818
|
+
<Button type="button" size="sm" variant="outline" className="h-7 text-[11px]" onClick={addCameraAnimation}>
|
|
819
|
+
{t("common.add")}
|
|
820
|
+
</Button>
|
|
821
|
+
</div>
|
|
822
|
+
<div className="flex flex-wrap gap-1">
|
|
823
|
+
<Button type="button" size="sm" variant="outline" className="h-7 text-[11px]" onClick={() => addCameraAnimationPreset("orbit")}>{t("panel.config.scene3dCameraAnimDefault")}</Button>
|
|
824
|
+
<Button type="button" size="sm" variant="outline" className="h-7 text-[11px]" onClick={() => addCameraAnimationPreset("pushIn")}>{t("panel.config.scene3dCameraAnimPushIn")}</Button>
|
|
825
|
+
<Button type="button" size="sm" variant="outline" className="h-7 text-[11px]" onClick={() => addCameraAnimationPreset("pullBack")}>{t("panel.config.scene3dCameraAnimPullBack")}</Button>
|
|
826
|
+
<Button type="button" size="sm" variant="outline" className="h-7 text-[11px]" onClick={() => addCameraAnimationPreset("truckLeft")}>{t("panel.config.scene3dCameraAnimTruckLeft")}</Button>
|
|
827
|
+
<Button type="button" size="sm" variant="outline" className="h-7 text-[11px]" onClick={() => addCameraAnimationPreset("craneUp")}>{t("panel.config.scene3dCameraAnimCraneUp")}</Button>
|
|
828
|
+
</div>
|
|
829
|
+
{config.cameraAnimations.map((rule) => (
|
|
830
|
+
<div key={rule.id} className="rounded border border-border/60 p-2 text-[10px] space-y-1">
|
|
831
|
+
<div>{rule.label ?? rule.id}</div>
|
|
832
|
+
<div className="text-muted-foreground">{t("panel.config.scene3dCameraAnimDuration", { sec: rule.durationSec })}</div>
|
|
833
|
+
{rule.mode === "orbit" ? (
|
|
834
|
+
<label className="space-y-0.5 text-[10px] block">
|
|
835
|
+
<span>{t("panel.config.scene3dCameraAnimSpeed")}</span>
|
|
836
|
+
<Input
|
|
837
|
+
type="number"
|
|
838
|
+
className="h-7"
|
|
839
|
+
value={rule.orbitSpeedDegPerSec ?? 24}
|
|
840
|
+
onChange={(e) =>
|
|
841
|
+
updateCameraAnimation(rule.id, {
|
|
842
|
+
orbitSpeedDegPerSec: Number(e.target.value) || 0,
|
|
843
|
+
})
|
|
844
|
+
}
|
|
845
|
+
/>
|
|
846
|
+
</label>
|
|
847
|
+
) : (
|
|
848
|
+
<div className="grid grid-cols-3 gap-1.5">
|
|
849
|
+
{(["x", "y", "z"] as const).map((axis, index) => (
|
|
850
|
+
<label key={axis} className="space-y-0.5 text-[10px]">
|
|
851
|
+
<span>{t("panel.config.scene3dCameraAnimOffset")} {axis.toUpperCase()}</span>
|
|
852
|
+
<Input
|
|
853
|
+
type="number"
|
|
854
|
+
className="h-7"
|
|
855
|
+
value={Number((rule.to.position[index] - rule.from.position[index]).toFixed(3))}
|
|
856
|
+
onChange={(e) => {
|
|
857
|
+
const nextPos = [...rule.to.position] as [number, number, number];
|
|
858
|
+
nextPos[index] = rule.from.position[index] + (Number(e.target.value) || 0);
|
|
859
|
+
updateCameraAnimation(rule.id, { to: { ...rule.to, position: nextPos } });
|
|
860
|
+
}}
|
|
861
|
+
/>
|
|
862
|
+
</label>
|
|
863
|
+
))}
|
|
864
|
+
</div>
|
|
865
|
+
)}
|
|
866
|
+
<Button type="button" size="sm" variant="ghost" className="h-7 text-[11px]" onClick={() => patch({ cameraAnimations: config.cameraAnimations.filter((a) => a.id !== rule.id) })}>
|
|
867
|
+
{t("common.remove")}
|
|
868
|
+
</Button>
|
|
869
|
+
</div>
|
|
870
|
+
))}
|
|
871
|
+
</div>
|
|
872
|
+
</div>
|
|
873
|
+
);
|
|
874
|
+
}
|