@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.
- package/package.json +7 -6
- package/src/panel/VueViewOnlinePreview.vue +28 -2
- package/src/panel/VueViewPanel.vue +120 -66
- package/src/panel/components/BusyOverlay.vue +24 -0
- package/src/panel/components/ConfigHintIcon.vue +10 -10
- package/src/panel/components/ElementsLayer.vue +51 -8
- package/src/panel/components/MaterialPreview.vue +8 -0
- package/src/panel/components/PanelConfigSidebar.vue +11 -0
- package/src/panel/components/WorkspaceConfigSidebar.vue +1 -0
- package/src/panel/components/WorkspaceProjectNav.vue +36 -8
- package/src/panel/components/config/ConfigColorField.vue +33 -9
- package/src/panel/components/config/PanelConfigAudioSection.vue +32 -18
- package/src/panel/components/config/PanelConfigImageSection.vue +13 -9
- package/src/panel/components/config/PanelConfigScene3dSection.vue +505 -0
- package/src/panel/components/config/PanelConfigStyleSections.vue +13 -9
- package/src/panel/components/config/PanelConfigTextSection.vue +20 -6
- package/src/panel/components/config/PanelConfigVideoSection.vue +13 -9
- package/src/panel/components/config/shared.ts +16 -21
- package/src/panel/components/elementsLayerNodes.ts +64 -59
- package/src/panel/components/materialSidebarData.ts +2 -0
- package/src/panel/components/scope-config/ScopeTemplateAutocompleteHost.vue +1 -1
- package/src/panel/components/table/PanelConfigTableSection.vue +39 -25
- package/src/panel/components/table/TableNodeContent.vue +36 -15
- package/src/panel/hooks/usePanelElements.ts +13 -1
- package/src/panel/hooks/useWorkspaceProjects.ts +128 -57
- package/src/panel/library/workspace-project-cache.ts +7 -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 -2
- package/src/panel/utils/async-work.ts +153 -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
|
@@ -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
|
|
7
|
-
const props =
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
: "#
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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>
|