@arronqzy/react-view 1.0.12 → 1.0.14

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.
Files changed (31) hide show
  1. package/dist/styles.css +96 -4
  2. package/package.json +8 -7
  3. package/src/panel/ReactViewOnlinePreview.tsx +72 -20
  4. package/src/panel/ReactViewPanel.tsx +238 -126
  5. package/src/panel/ai/applyAssistantAction.ts +1 -0
  6. package/src/panel/components/BusyOverlay.tsx +25 -0
  7. package/src/panel/components/ElementsLayer.tsx +110 -68
  8. package/src/panel/components/MaterialSidebar.tsx +15 -0
  9. package/src/panel/components/MoveableLayer.tsx +8 -0
  10. package/src/panel/components/PanelCanvas.tsx +3 -0
  11. package/src/panel/components/PanelConfigSidebar.tsx +582 -443
  12. package/src/panel/components/SelectLayer.tsx +5 -0
  13. package/src/panel/components/WorkspaceConfigSidebar.tsx +9 -0
  14. package/src/panel/components/WorkspaceProjectNav.tsx +27 -4
  15. package/src/panel/components/config/PanelConfigScene3dSection.tsx +874 -0
  16. package/src/panel/components/scope-config/ScopeTemplateAutocompleteHost.tsx +1 -2
  17. package/src/panel/components/table/PanelConfigTableSection.tsx +82 -50
  18. package/src/panel/components/table/TableNodeContent.tsx +40 -15
  19. package/src/panel/hooks/usePanelElements.ts +13 -1
  20. package/src/panel/hooks/useWorkspaceProjects.ts +133 -66
  21. package/src/panel/library/workspace-project-cache.ts +8 -2
  22. package/src/panel/library/workspace-project-db.ts +29 -11
  23. package/src/panel/library/workspace-snapshot.ts +21 -4
  24. package/src/panel/scope/view-scope-store.ts +8 -0
  25. package/src/panel/types.ts +4 -3
  26. package/src/panel/utils/async-work.ts +178 -0
  27. package/src/panel/utils/chartOptionBuilder.ts +7 -1
  28. package/src/panel/utils/gridPlacement.ts +29 -0
  29. package/src/panel/utils/outputScale.ts +69 -0
  30. package/src/panel/utils/panelElementDefaults.ts +37 -2
  31. 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
+ }