@arronqzy/vue-view 0.1.6 → 0.1.7

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 (34) hide show
  1. package/package.json +7 -6
  2. package/src/panel/VueViewOnlinePreview.vue +28 -2
  3. package/src/panel/VueViewPanel.vue +120 -66
  4. package/src/panel/components/BusyOverlay.vue +24 -0
  5. package/src/panel/components/ConfigHintIcon.vue +10 -10
  6. package/src/panel/components/ElementsLayer.vue +51 -8
  7. package/src/panel/components/MaterialPreview.vue +8 -0
  8. package/src/panel/components/PanelConfigSidebar.vue +11 -0
  9. package/src/panel/components/WorkspaceConfigSidebar.vue +1 -0
  10. package/src/panel/components/WorkspaceProjectNav.vue +36 -8
  11. package/src/panel/components/config/ConfigColorField.vue +33 -9
  12. package/src/panel/components/config/PanelConfigAudioSection.vue +32 -18
  13. package/src/panel/components/config/PanelConfigImageSection.vue +13 -9
  14. package/src/panel/components/config/PanelConfigScene3dSection.vue +505 -0
  15. package/src/panel/components/config/PanelConfigStyleSections.vue +13 -9
  16. package/src/panel/components/config/PanelConfigTextSection.vue +20 -6
  17. package/src/panel/components/config/PanelConfigVideoSection.vue +13 -9
  18. package/src/panel/components/config/shared.ts +16 -21
  19. package/src/panel/components/elementsLayerNodes.ts +64 -59
  20. package/src/panel/components/materialSidebarData.ts +2 -0
  21. package/src/panel/components/scope-config/ScopeTemplateAutocompleteHost.vue +1 -1
  22. package/src/panel/components/table/PanelConfigTableSection.vue +39 -25
  23. package/src/panel/components/table/TableNodeContent.vue +36 -15
  24. package/src/panel/hooks/usePanelElements.ts +13 -1
  25. package/src/panel/hooks/useWorkspaceProjects.ts +128 -57
  26. package/src/panel/library/workspace-project-cache.ts +7 -2
  27. package/src/panel/library/workspace-project-db.ts +29 -11
  28. package/src/panel/library/workspace-snapshot.ts +21 -4
  29. package/src/panel/scope/view-scope-store.ts +8 -0
  30. package/src/panel/types.ts +4 -2
  31. package/src/panel/utils/async-work.ts +153 -0
  32. package/src/panel/utils/gridPlacement.ts +29 -0
  33. package/src/panel/utils/panelElementDefaults.ts +37 -1
  34. package/src/panel/utils/scope-autocomplete.ts +0 -114
@@ -1,23 +1,35 @@
1
1
  <script setup lang="ts">
2
2
  import { useI18n } from "@arronqzy/i18n/vue";
3
3
  import { computed } from "vue";
4
- import { Input } from "ant-design-vue";
4
+ import { Input, Switch } from "ant-design-vue";
5
+ import { isCssTransparent } from "@arronqzy/view-table";
5
6
 
6
- const { t, locale } = useI18n();
7
- const props = defineProps<{
8
- label: string;
9
- value: string;
10
- disabled?: boolean;
11
- }>();
7
+ const { t } = useI18n();
8
+ const props = withDefaults(
9
+ defineProps<{
10
+ label: string;
11
+ value: string;
12
+ disabled?: boolean;
13
+ allowTransparent?: boolean;
14
+ fallbackHex?: string;
15
+ }>(),
16
+ { allowTransparent: false, fallbackHex: "#ffffff" }
17
+ );
12
18
 
13
19
  const emit = defineEmits<{
14
20
  "update:value": [value: string];
15
21
  }>();
16
22
 
23
+ const transparent = computed(
24
+ () => props.allowTransparent && isCssTransparent(props.value)
25
+ );
26
+
17
27
  const pickerValue = computed(() =>
18
28
  /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(props.value || "")
19
29
  ? props.value
20
- : "#000000"
30
+ : props.fallbackHex.startsWith("#")
31
+ ? props.fallbackHex
32
+ : "#000000"
21
33
  );
22
34
 
23
35
  function onTextChange(v: string) {
@@ -28,12 +40,23 @@ function onColorInput(e: Event) {
28
40
  const target = e.target as HTMLInputElement;
29
41
  emit("update:value", target.value);
30
42
  }
43
+
44
+ function onTransparentChange(v: unknown) {
45
+ emit("update:value", v === true ? "transparent" : props.fallbackHex);
46
+ }
31
47
  </script>
32
48
 
33
49
  <template>
34
50
  <label class="block space-y-1.5">
35
51
  <div class="text-[11px] text-gray-500">{{ label }}</div>
36
- <div class="flex items-center gap-2">
52
+ <div v-if="allowTransparent" class="flex items-center gap-2">
53
+ <Switch size="small" :checked="transparent" :disabled="disabled" @update:checked="onTransparentChange" />
54
+ <span class="text-[11px] text-gray-600">{{ t("panel.config.tableBgTransparent") }}</span>
55
+ </div>
56
+ <div v-if="transparent" class="text-[10px] text-gray-500">
57
+ {{ t("panel.config.tableBgTransparentHint") }}
58
+ </div>
59
+ <div v-else class="flex items-center gap-2">
37
60
  <Input
38
61
  size="small"
39
62
  :value="value"
@@ -52,3 +75,4 @@ function onColorInput(e: Event) {
52
75
  </div>
53
76
  </label>
54
77
  </template>
78
+
@@ -44,22 +44,30 @@ function patch(patch: Partial<PanelElement>) {
44
44
  }
45
45
 
46
46
  async function handleUploadAudioFile(file: File) {
47
- const base64 = await readFileAsDataUrl(file, msgs().readAudioFailed);
48
- patch({ audioSrc: base64 });
49
- audioStatus.value = msgs().audioLocalSaved;
50
- const url = await uploadFileToRemote(file);
51
- if (url) {
52
- patch({ audioRemoteUrl: url });
53
- audioStatus.value = msgs().audioRemoteUploaded;
54
- } else {
55
- audioStatus.value = msgs().audioServerUploadFailed;
47
+ try {
48
+ const base64 = await readFileAsDataUrl(file, msgs().readAudioFailed, "audio");
49
+ patch({ audioSrc: base64 });
50
+ audioStatus.value = msgs().audioLocalSaved;
51
+ const url = await uploadFileToRemote(file);
52
+ if (url) {
53
+ patch({ audioRemoteUrl: url });
54
+ audioStatus.value = msgs().audioRemoteUploaded;
55
+ } else {
56
+ audioStatus.value = msgs().audioServerUploadFailed;
57
+ }
58
+ } catch (error) {
59
+ audioStatus.value = error instanceof Error ? error.message : msgs().readAudioFailed;
56
60
  }
57
61
  }
58
62
 
59
63
  async function handleUploadAudioPoster(file: File) {
60
- const base64 = await readFileAsDataUrl(file, msgs().readImageFailed);
61
- patch({ audioPosterImage: base64 });
62
- audioStatus.value = msgs().audioPosterSet;
64
+ try {
65
+ const base64 = await readFileAsDataUrl(file, msgs().readImageFailed);
66
+ patch({ audioPosterImage: base64 });
67
+ audioStatus.value = msgs().audioPosterSet;
68
+ } catch (error) {
69
+ audioStatus.value = error instanceof Error ? error.message : msgs().readImageFailed;
70
+ }
63
71
  }
64
72
 
65
73
  function stopRecordingAudio() {
@@ -85,12 +93,18 @@ async function startRecordingAudio() {
85
93
  const blob = new Blob(audioChunksRef.value, {
86
94
  type: recorder.mimeType || "audio/webm",
87
95
  });
88
- const dataUrl = await readFileAsDataUrl(
89
- new File([blob], "recording.webm", { type: blob.type }),
90
- msgs().readRecordAudioFailed
91
- );
92
- patch({ audioSrc: dataUrl });
93
- audioStatus.value = msgs().audioRecordSaved;
96
+ try {
97
+ const dataUrl = await readFileAsDataUrl(
98
+ new File([blob], "recording.webm", { type: blob.type }),
99
+ msgs().readRecordAudioFailed,
100
+ "audio"
101
+ );
102
+ patch({ audioSrc: dataUrl });
103
+ audioStatus.value = msgs().audioRecordSaved;
104
+ } catch (error) {
105
+ audioStatus.value =
106
+ error instanceof Error ? error.message : msgs().readRecordAudioFailed;
107
+ }
94
108
  recordStreamRef.value?.getTracks().forEach((track) => track.stop());
95
109
  recordStreamRef.value = null;
96
110
  recorderRef.value = null;
@@ -37,15 +37,19 @@ function patchStyle(patch: Record<string, string | undefined>) {
37
37
  }
38
38
 
39
39
  async function handleUploadImage(file: File) {
40
- const base64 = await readFileAsDataUrl(file, msgs().readImageFailed);
41
- patchStyle({ backgroundImage: `url("${base64}")` });
42
- uploadStatus.value = t("panel.config.uploadWrittenBase64");
43
- const url = await uploadFileToRemote(file);
44
- if (url) {
45
- patchStyle({ backgroundImageRemoteUrl: url });
46
- uploadStatus.value = t("panel.config.uploadServerAndBase64");
47
- } else {
48
- uploadStatus.value = t("panel.config.uploadServerFailedKeepBase64");
40
+ try {
41
+ const base64 = await readFileAsDataUrl(file, msgs().readImageFailed);
42
+ patchStyle({ backgroundImage: `url("${base64}")` });
43
+ uploadStatus.value = t("panel.config.uploadWrittenBase64");
44
+ const url = await uploadFileToRemote(file);
45
+ if (url) {
46
+ patchStyle({ backgroundImageRemoteUrl: url });
47
+ uploadStatus.value = t("panel.config.uploadServerAndBase64");
48
+ } else {
49
+ uploadStatus.value = t("panel.config.uploadServerFailedKeepBase64");
50
+ }
51
+ } catch (error) {
52
+ uploadStatus.value = error instanceof Error ? error.message : msgs().readImageFailed;
49
53
  }
50
54
  }
51
55
 
@@ -0,0 +1,505 @@
1
+ <script setup lang="ts">
2
+ import { computed, reactive, useTemplateRef } from "vue";
3
+ import { useI18n } from "@arronqzy/i18n/vue";
4
+ import { Button, Checkbox, Input, Select } from "ant-design-vue";
5
+ import {
6
+ inferModelFormat,
7
+ makeScene3dObjectKey,
8
+ mergeScene3dConfig,
9
+ purposePresetPatch,
10
+ type Scene3dCameraAnimationRule,
11
+ type Scene3dConfig,
12
+ type Scene3dModelAnimationRule,
13
+ type Scene3dObjectTag,
14
+ type Scene3dPivot,
15
+ type Scene3dPurpose,
16
+ } from "@arronqzy/view-scene3d";
17
+ import type { PanelElement } from "../../types";
18
+ import { readFileAsDataUrl } from "../../utils/async-work";
19
+ import { randomId } from "../../utils/panelElementDefaults";
20
+ import ConfigFieldGroup from "./ConfigFieldGroup.vue";
21
+ import ConfigSection from "./ConfigSection.vue";
22
+ import ConfigHintIcon from "../ConfigHintIcon.vue";
23
+
24
+ const { t } = useI18n();
25
+
26
+ const props = defineProps<{
27
+ element: PanelElement;
28
+ isEditable: boolean;
29
+ open: boolean;
30
+ forceOpen?: boolean;
31
+ updateElement: (
32
+ id: string,
33
+ patch: Partial<PanelElement>,
34
+ options?: { batchId?: string; meta?: Record<string, unknown> }
35
+ ) => void;
36
+ }>();
37
+
38
+ const emit = defineEmits<{ "update:open": [value: boolean] }>();
39
+
40
+ const fileInputRef = useTemplateRef<HTMLInputElement>("fileInputRef");
41
+ const config = computed(() => mergeScene3dConfig(props.element.scene3d));
42
+ type CameraAnimPreset = "orbit" | "pushIn" | "pullBack" | "truckLeft" | "craneUp";
43
+
44
+ function patch(next: Partial<Scene3dConfig>) {
45
+ props.updateElement(props.element.id, {
46
+ scene3d: mergeScene3dConfig({ ...props.element.scene3d, ...next }),
47
+ });
48
+ }
49
+
50
+ async function handleUploadModels(files: FileList | null) {
51
+ if (!files?.length) return;
52
+ const nextModels = [...config.value.models];
53
+ for (const file of Array.from(files)) {
54
+ try {
55
+ const url = await readFileAsDataUrl(file, t("panel.messages.readModelFailed"), "model3d");
56
+ nextModels.push({
57
+ id: randomId("mdl"),
58
+ label: file.name.replace(/\.[^.]+$/, "") || file.name,
59
+ url,
60
+ fileName: file.name,
61
+ format: inferModelFormat(file.name),
62
+ });
63
+ } catch (error) {
64
+ window.alert(error instanceof Error ? error.message : t("panel.messages.readModelFailed"));
65
+ }
66
+ }
67
+ patch({ models: nextModels });
68
+ }
69
+
70
+ function updateModelAnimation(id: string, next: Partial<Scene3dModelAnimationRule>) {
71
+ patch({
72
+ modelAnimations: config.value.modelAnimations.map((rule) =>
73
+ rule.id === id ? { ...rule, ...next } : rule
74
+ ),
75
+ });
76
+ }
77
+
78
+ function addModelAnimation() {
79
+ const firstModel = config.value.models[0];
80
+ const rule: Scene3dModelAnimationRule = {
81
+ id: randomId("anim"),
82
+ modelId: firstModel?.id ?? "",
83
+ objectName: firstModel?.objectNames?.[0] ?? "",
84
+ axis: "y",
85
+ direction: "clockwise",
86
+ pivot: "right",
87
+ angleDeg: 90,
88
+ durationSec: 1.2,
89
+ loop: false,
90
+ };
91
+ patch({ modelAnimations: [...config.value.modelAnimations, rule] });
92
+ }
93
+
94
+ function updateCameraAnimation(id: string, next: Partial<Scene3dCameraAnimationRule>) {
95
+ patch({
96
+ cameraAnimations: config.value.cameraAnimations.map((rule) =>
97
+ rule.id === id ? { ...rule, ...next } : rule
98
+ ),
99
+ });
100
+ }
101
+
102
+ function updateCameraAnimationOffset(
103
+ rule: Scene3dCameraAnimationRule,
104
+ index: number,
105
+ value: string
106
+ ) {
107
+ const nextPos: [number, number, number] = [
108
+ rule.to.position[0],
109
+ rule.to.position[1],
110
+ rule.to.position[2],
111
+ ];
112
+ nextPos[index] = rule.from.position[index] + (Number(value) || 0);
113
+ updateCameraAnimation(rule.id, { to: { ...rule.to, position: nextPos } });
114
+ }
115
+
116
+ function addCameraAnimationPreset(preset: CameraAnimPreset) {
117
+ const [px, py, pz] = config.value.camera.position;
118
+ const [tx, ty, tz] = config.value.camera.target;
119
+ const dx = tx - px;
120
+ const dy = ty - py;
121
+ const dz = tz - pz;
122
+ const dist = Math.max(0.001, Math.hypot(dx, dy, dz));
123
+ const nx = dx / dist;
124
+ const ny = dy / dist;
125
+ const nz = dz / dist;
126
+ let rule: Scene3dCameraAnimationRule;
127
+
128
+ if (preset === "pushIn") {
129
+ rule = {
130
+ id: randomId("cam"),
131
+ label: t("panel.config.scene3dCameraAnimPushIn"),
132
+ from: { ...config.value.camera },
133
+ to: {
134
+ ...config.value.camera,
135
+ position: [px + nx * dist * 0.28, py + ny * dist * 0.28, pz + nz * dist * 0.28],
136
+ },
137
+ durationSec: 1.6,
138
+ loop: false,
139
+ };
140
+ } else if (preset === "pullBack") {
141
+ rule = {
142
+ id: randomId("cam"),
143
+ label: t("panel.config.scene3dCameraAnimPullBack"),
144
+ from: { ...config.value.camera },
145
+ to: {
146
+ ...config.value.camera,
147
+ position: [px - nx * dist * 0.35, py - ny * dist * 0.35, pz - nz * dist * 0.35],
148
+ },
149
+ durationSec: 1.8,
150
+ loop: false,
151
+ };
152
+ } else if (preset === "truckLeft") {
153
+ rule = {
154
+ id: randomId("cam"),
155
+ label: t("panel.config.scene3dCameraAnimTruckLeft"),
156
+ from: { ...config.value.camera },
157
+ to: {
158
+ ...config.value.camera,
159
+ position: [px - 1.6, py, pz],
160
+ target: [tx - 1.6, ty, tz],
161
+ },
162
+ durationSec: 1.8,
163
+ loop: false,
164
+ };
165
+ } else if (preset === "craneUp") {
166
+ rule = {
167
+ id: randomId("cam"),
168
+ label: t("panel.config.scene3dCameraAnimCraneUp"),
169
+ from: { ...config.value.camera },
170
+ to: {
171
+ ...config.value.camera,
172
+ position: [px, py + 1.5, pz],
173
+ target: [tx, ty + 0.6, tz],
174
+ },
175
+ durationSec: 1.8,
176
+ loop: false,
177
+ };
178
+ } else {
179
+ rule = {
180
+ id: randomId("cam"),
181
+ label: t("panel.config.scene3dCameraAnimDefault"),
182
+ mode: "orbit",
183
+ orbitSpeedDegPerSec: 24,
184
+ from: { ...config.value.camera },
185
+ to: {
186
+ ...config.value.camera,
187
+ position: [
188
+ px + Math.max(1.5, dist * 0.32),
189
+ py + Math.max(0.5, dist * 0.08),
190
+ pz + Math.max(1, dist * 0.2),
191
+ ],
192
+ },
193
+ durationSec: 6,
194
+ loop: true,
195
+ pingPong: false,
196
+ };
197
+ }
198
+ patch({ cameraAnimations: [...config.value.cameraAnimations, rule] });
199
+ }
200
+
201
+ function addCameraAnimation() {
202
+ addCameraAnimationPreset("orbit");
203
+ }
204
+
205
+ const nameQuery = reactive<Record<string, string>>({});
206
+ const openTagKeys = reactive<Record<string, boolean>>({});
207
+ const PIVOTS: Scene3dPivot[] = ["center", "top", "bottom", "left", "right", "front", "back"];
208
+
209
+ function filteredObjectNames(modelId: string, names: string[]) {
210
+ const q = (nameQuery[modelId] ?? "").trim().toLowerCase();
211
+ if (!q) return names;
212
+ return names.filter((name) => name.toLowerCase().includes(q));
213
+ }
214
+
215
+ function selectObject(modelId: string, objectName: string) {
216
+ const key = makeScene3dObjectKey(modelId, objectName);
217
+ patch({ selectedObjectKey: config.value.selectedObjectKey === key ? null : key });
218
+ }
219
+
220
+ function toggleHidden(modelId: string, objectName: string) {
221
+ const key = makeScene3dObjectKey(modelId, objectName);
222
+ const hidden = new Set(config.value.hiddenObjectKeys ?? []);
223
+ if (hidden.has(key)) hidden.delete(key);
224
+ else hidden.add(key);
225
+ patch({
226
+ hiddenObjectKeys: [...hidden],
227
+ soloObjectKey: config.value.soloObjectKey === key ? null : config.value.soloObjectKey,
228
+ });
229
+ }
230
+
231
+ function toggleSolo(modelId: string, objectName: string) {
232
+ const key = makeScene3dObjectKey(modelId, objectName);
233
+ patch({
234
+ soloObjectKey: config.value.soloObjectKey === key ? null : key,
235
+ selectedObjectKey: key,
236
+ });
237
+ }
238
+
239
+ function objectTag(modelId: string, objectName: string) {
240
+ return (config.value.objectTags ?? []).find(
241
+ (tag) => tag.modelId === modelId && tag.objectName === objectName
242
+ );
243
+ }
244
+
245
+ function upsertObjectTag(modelId: string, objectName: string, next: Partial<Scene3dObjectTag>) {
246
+ const tags = [...(config.value.objectTags ?? [])];
247
+ const index = tags.findIndex((tag) => tag.modelId === modelId && tag.objectName === objectName);
248
+ if (index >= 0) tags[index] = { ...tags[index], ...next };
249
+ else {
250
+ tags.push({
251
+ id: randomId("tag"),
252
+ modelId,
253
+ objectName,
254
+ html: "",
255
+ anchor: "top",
256
+ ...next,
257
+ });
258
+ }
259
+ patch({ objectTags: tags });
260
+ }
261
+
262
+ function toggleTagEditor(modelId: string, objectName: string) {
263
+ const key = makeScene3dObjectKey(modelId, objectName);
264
+ openTagKeys[key] = !openTagKeys[key];
265
+ if (!objectTag(modelId, objectName)) upsertObjectTag(modelId, objectName, {});
266
+ }
267
+
268
+ function isSelected(modelId: string, objectName: string) {
269
+ return config.value.selectedObjectKey === makeScene3dObjectKey(modelId, objectName);
270
+ }
271
+
272
+ function isHidden(modelId: string, objectName: string) {
273
+ return (config.value.hiddenObjectKeys ?? []).includes(makeScene3dObjectKey(modelId, objectName));
274
+ }
275
+
276
+ function isSolo(modelId: string, objectName: string) {
277
+ return config.value.soloObjectKey === makeScene3dObjectKey(modelId, objectName);
278
+ }
279
+
280
+ function isTagOpen(modelId: string, objectName: string) {
281
+ return Boolean(openTagKeys[makeScene3dObjectKey(modelId, objectName)]);
282
+ }
283
+
284
+ function setNameQuery(modelId: string, value: string) {
285
+ nameQuery[modelId] = value;
286
+ }
287
+
288
+ function updateTagAnchor(modelId: string, objectName: string, value: string) {
289
+ upsertObjectTag(modelId, objectName, { anchor: value as Scene3dPivot });
290
+ }
291
+
292
+ function updateTagHtml(modelId: string, objectName: string, value: string) {
293
+ upsertObjectTag(modelId, objectName, { html: value });
294
+ }
295
+
296
+ function onTagHtmlInput(modelId: string, objectName: string, event: Event) {
297
+ const target = event.target;
298
+ if (target instanceof HTMLTextAreaElement) updateTagHtml(modelId, objectName, target.value);
299
+ }
300
+ </script>
301
+
302
+ <template>
303
+ <ConfigSection
304
+ :title="t('panel.config.sectionScene3d')"
305
+ :open="open"
306
+ :force-open="forceOpen"
307
+ @update:open="emit('update:open', $event)"
308
+ >
309
+ <ConfigFieldGroup :title="t('panel.config.groupScene3dPurpose')">
310
+ <Select
311
+ size="small"
312
+ class="w-full"
313
+ :disabled="!isEditable"
314
+ :value="config.presentation.purpose"
315
+ :options="[
316
+ { value: 'product', label: t('panel.config.scene3dPurposeProduct') },
317
+ { value: 'twin', label: t('panel.config.scene3dPurposeTwin') },
318
+ { value: 'sandbox', label: t('panel.config.scene3dPurposeSandbox') },
319
+ ]"
320
+ @update:value="(v) => patch(purposePresetPatch(String(v) as Scene3dPurpose))"
321
+ />
322
+ <div class="text-[10px] text-gray-500">{{ t("panel.config.scene3dPurposeHint") }}</div>
323
+ </ConfigFieldGroup>
324
+
325
+ <ConfigFieldGroup :title="t('panel.config.groupScene3dModels')">
326
+ <div class="flex justify-end">
327
+ <Button size="small" :disabled="!isEditable" @click="fileInputRef?.click()">
328
+ {{ t("panel.config.scene3dUploadModels") }}
329
+ </Button>
330
+ <input
331
+ ref="fileInputRef"
332
+ type="file"
333
+ accept=".glb,.gltf,.obj,.fbx"
334
+ multiple
335
+ class="hidden"
336
+ @change="(e) => { void handleUploadModels((e.target as HTMLInputElement).files); (e.target as HTMLInputElement).value = ''; }"
337
+ />
338
+ </div>
339
+ <div v-if="config.models.length === 0" class="text-[11px] text-gray-500">
340
+ {{ t("panel.config.scene3dNoModels") }}
341
+ </div>
342
+ <div v-for="model in config.models" :key="model.id" class="space-y-1 rounded border border-gray-200 p-2">
343
+ <Input
344
+ size="small"
345
+ :disabled="!isEditable"
346
+ :value="model.label"
347
+ @update:value="(v: string) => patch({ models: config.models.map((m) => m.id === model.id ? { ...m, label: v } : m) })"
348
+ />
349
+ <div v-if="model.objectNames?.length" class="space-y-1">
350
+ <details :open="model.objectNames.length <= 12">
351
+ <summary class="cursor-pointer text-[10px] text-gray-500">
352
+ {{ t("panel.config.scene3dObjectNames") }} · {{ t("panel.config.scene3dObjectCount", { n: model.objectNames.length }) }}
353
+ </summary>
354
+ <Input
355
+ size="small"
356
+ class="mt-1"
357
+ :disabled="!isEditable"
358
+ :placeholder="t('panel.config.scene3dSearchObject')"
359
+ :value="nameQuery[model.id] ?? ''"
360
+ @update:value="(v) => setNameQuery(model.id, String(v ?? ''))"
361
+ />
362
+ <div class="mt-1 max-h-44 overflow-auto rounded border border-gray-200">
363
+ <div
364
+ v-for="name in filteredObjectNames(model.id, model.objectNames)"
365
+ :key="model.id + name"
366
+ class="border-b border-gray-100 last:border-b-0"
367
+ :class="{
368
+ 'bg-sky-50': isSelected(model.id, name),
369
+ 'opacity-50': isHidden(model.id, name) && !isSolo(model.id, name),
370
+ }"
371
+ >
372
+ <div class="flex items-center gap-0.5 px-1 py-0.5">
373
+ <button type="button" class="min-w-0 flex-1 truncate text-left text-[10px]" @click="selectObject(model.id, name)">{{ name }}</button>
374
+ <Button size="small" type="text" :disabled="!isEditable" @click="toggleHidden(model.id, name)">
375
+ {{ isHidden(model.id, name) ? t("panel.config.scene3dShowShort") : t("panel.config.scene3dHideShort") }}
376
+ </Button>
377
+ <Button size="small" type="text" :disabled="!isEditable" @click="toggleSolo(model.id, name)">
378
+ {{ t("panel.config.scene3dSoloShort") }}
379
+ </Button>
380
+ <Button size="small" type="text" :disabled="!isEditable" @click="toggleTagEditor(model.id, name)">
381
+ {{ t("panel.config.scene3dTagShort") }}
382
+ </Button>
383
+ </div>
384
+ <div v-if="isTagOpen(model.id, name)" class="space-y-1 px-1 pb-1.5">
385
+ <Select
386
+ size="small"
387
+ class="w-full"
388
+ :disabled="!isEditable"
389
+ :value="objectTag(model.id, name)?.anchor ?? 'top'"
390
+ :options="PIVOTS.map((p) => ({ value: p, label: t(`panel.config.scene3dPivot_${p}`) }))"
391
+ @update:value="(v) => updateTagAnchor(model.id, name, String(v))"
392
+ />
393
+ <textarea
394
+ class="min-h-[64px] w-full rounded border border-gray-200 p-1 text-[10px]"
395
+ :disabled="!isEditable"
396
+ :placeholder="t('panel.config.scene3dObjectTagPlaceholder')"
397
+ :value="objectTag(model.id, name)?.html ?? ''"
398
+ @input="(e) => onTagHtmlInput(model.id, name, e)"
399
+ />
400
+ </div>
401
+ </div>
402
+ </div>
403
+ </details>
404
+ </div>
405
+ <div v-else class="text-[10px] text-gray-500">{{ t("panel.config.scene3dNoObjectNames") }}</div>
406
+ <Button size="small" type="text" :disabled="!isEditable" @click="patch({ models: config.models.filter((m) => m.id !== model.id) })">
407
+ {{ t("common.remove") }}
408
+ </Button>
409
+ </div>
410
+ </ConfigFieldGroup>
411
+
412
+ <ConfigFieldGroup :title="t('panel.config.groupScene3dCamera')">
413
+ <div class="text-[10px] text-gray-500">{{ t("panel.config.scene3dCameraEditDesc") }}</div>
414
+ <div class="grid grid-cols-3 gap-1">
415
+ <Input
416
+ v-for="(axis, index) in ['x', 'y', 'z']"
417
+ :key="'pos-' + axis"
418
+ size="small"
419
+ type="number"
420
+ :disabled="!isEditable"
421
+ :value="String(config.camera.position[index])"
422
+ @update:value="(v: string) => {
423
+ const next = [...config.camera.position] as [number, number, number];
424
+ next[index] = Number(v) || 0;
425
+ patch({ camera: { ...config.camera, position: next } });
426
+ }"
427
+ />
428
+ </div>
429
+ <Checkbox
430
+ :checked="config.orbit.enableMouseControl"
431
+ :disabled="!isEditable"
432
+ @update:checked="(v) => patch({ orbit: { ...config.orbit, enableMouseControl: Boolean(v) } })"
433
+ >
434
+ {{ t("panel.config.scene3dEnableMouseControl") }}
435
+ <ConfigHintIcon :label="t('panel.config.scene3dEnableMouseControl')">
436
+ {{ t("panel.config.scene3dEnableMouseControlHint") }}
437
+ </ConfigHintIcon>
438
+ </Checkbox>
439
+ </ConfigFieldGroup>
440
+
441
+ <ConfigFieldGroup :title="t('panel.config.groupScene3dModelAnim')">
442
+ <Button size="small" :disabled="!isEditable" @click="addModelAnimation">{{ t("common.add") }}</Button>
443
+ <div v-for="rule in config.modelAnimations" :key="rule.id" class="mt-2 space-y-1 rounded border border-gray-200 p-2">
444
+ <Select
445
+ size="small"
446
+ class="w-full"
447
+ :disabled="!isEditable"
448
+ :value="rule.modelId"
449
+ :options="config.models.map((m) => ({ value: m.id, label: m.label }))"
450
+ @update:value="(v) => updateModelAnimation(rule.id, { modelId: String(v) })"
451
+ />
452
+ <Select
453
+ size="small"
454
+ class="w-full"
455
+ :disabled="!isEditable"
456
+ :value="rule.objectName"
457
+ :options="(config.models.find((m) => m.id === rule.modelId)?.objectNames ?? []).map((n) => ({ value: n, label: n }))"
458
+ @update:value="(v) => updateModelAnimation(rule.id, { objectName: String(v) })"
459
+ />
460
+ <div class="grid grid-cols-3 gap-1">
461
+ <Select size="small" :disabled="!isEditable" :value="rule.axis" :options="[{value:'x',label:'X'},{value:'y',label:'Y'},{value:'z',label:'Z'}]" @update:value="(v) => updateModelAnimation(rule.id, { axis: v as 'x'|'y'|'z' })" />
462
+ <Select size="small" :disabled="!isEditable" :value="rule.pivot" :options="['center','left','right','front','back','top','bottom'].map((p) => ({ value: p, label: t(`panel.config.scene3dPivot_${p}`) }))" @update:value="(v) => updateModelAnimation(rule.id, { pivot: v as typeof rule.pivot })" />
463
+ <Select size="small" :disabled="!isEditable" :value="rule.direction" :options="[{value:'clockwise',label:t('panel.config.scene3dDirClockwise')},{value:'counterclockwise',label:t('panel.config.scene3dDirCounter')}]" @update:value="(v) => updateModelAnimation(rule.id, { direction: v as typeof rule.direction })" />
464
+ </div>
465
+ <Input size="small" type="number" :disabled="!isEditable" :value="String(rule.angleDeg)" @update:value="(v) => updateModelAnimation(rule.id, { angleDeg: Number(v) || 0 })" />
466
+ <Button size="small" type="text" :disabled="!isEditable" @click="patch({ modelAnimations: config.modelAnimations.filter((a) => a.id !== rule.id) })">{{ t("common.remove") }}</Button>
467
+ </div>
468
+ </ConfigFieldGroup>
469
+
470
+ <ConfigFieldGroup :title="t('panel.config.groupScene3dCameraAnim')">
471
+ <Button size="small" :disabled="!isEditable" @click="addCameraAnimation">{{ t("common.add") }}</Button>
472
+ <div class="mt-2 flex flex-wrap gap-1">
473
+ <Button size="small" :disabled="!isEditable" @click="addCameraAnimationPreset('orbit')">{{ t("panel.config.scene3dCameraAnimDefault") }}</Button>
474
+ <Button size="small" :disabled="!isEditable" @click="addCameraAnimationPreset('pushIn')">{{ t("panel.config.scene3dCameraAnimPushIn") }}</Button>
475
+ <Button size="small" :disabled="!isEditable" @click="addCameraAnimationPreset('pullBack')">{{ t("panel.config.scene3dCameraAnimPullBack") }}</Button>
476
+ <Button size="small" :disabled="!isEditable" @click="addCameraAnimationPreset('truckLeft')">{{ t("panel.config.scene3dCameraAnimTruckLeft") }}</Button>
477
+ <Button size="small" :disabled="!isEditable" @click="addCameraAnimationPreset('craneUp')">{{ t("panel.config.scene3dCameraAnimCraneUp") }}</Button>
478
+ </div>
479
+ <div v-for="rule in config.cameraAnimations" :key="rule.id" class="mt-2 text-[10px] text-gray-600">
480
+ <div>{{ rule.label ?? rule.id }} · {{ t("panel.config.scene3dCameraAnimDuration", { sec: rule.durationSec }) }}</div>
481
+ <div v-if="rule.mode === 'orbit'" class="mt-1 grid grid-cols-1 gap-1">
482
+ <Input
483
+ size="small"
484
+ type="number"
485
+ :disabled="!isEditable"
486
+ :value="String(rule.orbitSpeedDegPerSec ?? 24)"
487
+ @update:value="(v) => updateCameraAnimation(rule.id, { orbitSpeedDegPerSec: Number(v) || 0 })"
488
+ />
489
+ </div>
490
+ <div v-else class="mt-1 grid grid-cols-3 gap-1">
491
+ <Input
492
+ v-for="(axis, index) in ['x', 'y', 'z']"
493
+ :key="'cam-offset-' + axis"
494
+ size="small"
495
+ type="number"
496
+ :disabled="!isEditable"
497
+ :value="String(Number((rule.to.position[index] - rule.from.position[index]).toFixed(3)))"
498
+ @update:value="(v) => updateCameraAnimationOffset(rule, index, v)"
499
+ />
500
+ </div>
501
+ <Button size="small" type="text" :disabled="!isEditable" @click="patch({ cameraAnimations: config.cameraAnimations.filter((a) => a.id !== rule.id) })">{{ t("common.remove") }}</Button>
502
+ </div>
503
+ </ConfigFieldGroup>
504
+ </ConfigSection>
505
+ </template>