@arronqzy/vue-view 0.1.4 → 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 +6 -5
- package/src/panel/VueViewOnlinePreview.vue +55 -19
- package/src/panel/VueViewPanel.vue +123 -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 +32 -20
- package/src/panel/components/WorkspaceConfigSidebar.vue +11 -0
- package/src/panel/components/WorkspaceProjectNav.vue +36 -8
- package/src/panel/components/config/ConfigColorField.vue +33 -9
- package/src/panel/components/config/ConfigFieldGroup.vue +35 -6
- package/src/panel/components/config/PanelConfigAudioSection.vue +37 -19
- package/src/panel/components/config/PanelConfigChartSection.vue +101 -84
- package/src/panel/components/config/PanelConfigGeometrySection.vue +10 -2
- package/src/panel/components/config/PanelConfigImageSection.vue +18 -10
- package/src/panel/components/config/PanelConfigNodeInfo.vue +21 -12
- package/src/panel/components/config/PanelConfigReferenceSection.vue +5 -1
- package/src/panel/components/config/PanelConfigScene3dSection.vue +505 -0
- package/src/panel/components/config/PanelConfigStyleSections.vue +19 -11
- package/src/panel/components/config/PanelConfigTextSection.vue +25 -7
- 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/chartOptionBuilder.ts +7 -1
- package/src/panel/utils/gridPlacement.ts +29 -0
- package/src/panel/utils/outputScale.ts +56 -0
- package/src/panel/utils/panelElementDefaults.ts +37 -2
- package/src/panel/utils/scope-autocomplete.ts +0 -114
|
@@ -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
|
|
|
@@ -100,7 +104,11 @@ function onFileChange(e: Event) {
|
|
|
100
104
|
<span v-if="uploadStatus" class="text-[11px] text-gray-500">{{ uploadStatus }}</span>
|
|
101
105
|
</div>
|
|
102
106
|
</ConfigFieldGroup>
|
|
103
|
-
<ConfigFieldGroup
|
|
107
|
+
<ConfigFieldGroup
|
|
108
|
+
:title="t('panel.config.groupDisplayMode')"
|
|
109
|
+
collapsible
|
|
110
|
+
:default-open="false"
|
|
111
|
+
>
|
|
104
112
|
<label class="block space-y-1">
|
|
105
113
|
<div>{{ t("panel.config.objectFitFull") }}</div>
|
|
106
114
|
<Select
|
|
@@ -4,6 +4,7 @@ import { Checkbox, Input, InputNumber, Select } from "ant-design-vue";
|
|
|
4
4
|
import type { PanelElement, PanelLayer } from "../../types";
|
|
5
5
|
import { getPanelMessages } from "../../constants/messages";
|
|
6
6
|
import ConfigSection from "./ConfigSection.vue";
|
|
7
|
+
import ConfigFieldGroup from "./ConfigFieldGroup.vue";
|
|
7
8
|
|
|
8
9
|
const { t, locale } = useI18n();
|
|
9
10
|
const msgs = () => getPanelMessages(t);
|
|
@@ -52,7 +53,7 @@ function patch(patch: Partial<PanelElement>) {
|
|
|
52
53
|
@update:value="(v: string) => patch({ name: v || undefined })"
|
|
53
54
|
/>
|
|
54
55
|
</label>
|
|
55
|
-
<div class="grid grid-cols-
|
|
56
|
+
<div class="grid grid-cols-2 gap-2">
|
|
56
57
|
<label class="block space-y-1">
|
|
57
58
|
<div>X</div>
|
|
58
59
|
<InputNumber
|
|
@@ -74,17 +75,7 @@ function patch(patch: Partial<PanelElement>) {
|
|
|
74
75
|
/>
|
|
75
76
|
</label>
|
|
76
77
|
</div>
|
|
77
|
-
<div class="grid grid-cols-
|
|
78
|
-
<label class="block space-y-1">
|
|
79
|
-
<div>{{ t("panel.config.rotate") }}</div>
|
|
80
|
-
<InputNumber
|
|
81
|
-
size="small"
|
|
82
|
-
class="w-full"
|
|
83
|
-
:value="element.rotate ?? 0"
|
|
84
|
-
:disabled="!isEditable"
|
|
85
|
-
@update:value="(v) => { const n = Number(v); if (!Number.isNaN(n)) patch({ rotate: n }); }"
|
|
86
|
-
/>
|
|
87
|
-
</label>
|
|
78
|
+
<div class="grid grid-cols-2 gap-2">
|
|
88
79
|
<label class="block space-y-1">
|
|
89
80
|
<div>{{ t("panel.config.width") }}</div>
|
|
90
81
|
<InputNumber
|
|
@@ -108,6 +99,23 @@ function patch(patch: Partial<PanelElement>) {
|
|
|
108
99
|
/>
|
|
109
100
|
</label>
|
|
110
101
|
</div>
|
|
102
|
+
<ConfigFieldGroup
|
|
103
|
+
:title="t('panel.config.groupNodeMore')"
|
|
104
|
+
collapsible
|
|
105
|
+
:default-open="false"
|
|
106
|
+
>
|
|
107
|
+
<div class="grid grid-cols-2 gap-2">
|
|
108
|
+
<label class="block space-y-1">
|
|
109
|
+
<div>{{ t("panel.config.rotate") }}</div>
|
|
110
|
+
<InputNumber
|
|
111
|
+
size="small"
|
|
112
|
+
class="w-full"
|
|
113
|
+
:value="element.rotate ?? 0"
|
|
114
|
+
:disabled="!isEditable"
|
|
115
|
+
@update:value="(v) => { const n = Number(v); if (!Number.isNaN(n)) patch({ rotate: n }); }"
|
|
116
|
+
/>
|
|
117
|
+
</label>
|
|
118
|
+
</div>
|
|
111
119
|
<div class="space-y-1.5">
|
|
112
120
|
<div class="text-[11px] text-gray-500">{{ t("panel.config.nodeZOrder") }}</div>
|
|
113
121
|
<div class="text-[11px] text-gray-400">{{ t("panel.config.currentZIndex", { value: nodeZOrderLabel ?? "-" }) }}</div>
|
|
@@ -148,6 +156,7 @@ function patch(patch: Partial<PanelElement>) {
|
|
|
148
156
|
</div>
|
|
149
157
|
<div class="truncate text-gray-400">ID: {{ element.id }}</div>
|
|
150
158
|
<div class="text-gray-400">{{ t("panel.config.type") }}: {{ element.materialType ?? element.id }}</div>
|
|
159
|
+
</ConfigFieldGroup>
|
|
151
160
|
<div class="rounded-lg border border-gray-200/60 bg-white/80 px-2.5 py-2">
|
|
152
161
|
<label class="flex items-center gap-2">
|
|
153
162
|
<Checkbox
|
|
@@ -57,7 +57,11 @@ function patch(patch: Partial<PanelElement>) {
|
|
|
57
57
|
</Select>
|
|
58
58
|
</label>
|
|
59
59
|
</ConfigFieldGroup>
|
|
60
|
-
<ConfigFieldGroup
|
|
60
|
+
<ConfigFieldGroup
|
|
61
|
+
:title="t('panel.config.groupCopyStrategy')"
|
|
62
|
+
collapsible
|
|
63
|
+
:default-open="false"
|
|
64
|
+
>
|
|
61
65
|
<template #hint>
|
|
62
66
|
{{ t("panel.config.copyStrategyHint") }}
|
|
63
67
|
</template>
|
|
@@ -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>
|
|
@@ -41,15 +41,19 @@ function patchStyle(patch: Partial<PanelElementStyle>) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
async function handleUploadBackgroundImage(file: File) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
44
|
+
try {
|
|
45
|
+
const base64 = await readFileAsDataUrl(file, msgs().readImageFailed);
|
|
46
|
+
patchStyle({ backgroundImage: `url("${base64}")` });
|
|
47
|
+
uploadStatus.value = t("panel.config.uploadWrittenBase64");
|
|
48
|
+
const url = await uploadFileToRemote(file);
|
|
49
|
+
if (url) {
|
|
50
|
+
patchStyle({ backgroundImageRemoteUrl: url });
|
|
51
|
+
uploadStatus.value = t("panel.config.uploadServerAndBase64");
|
|
52
|
+
} else {
|
|
53
|
+
uploadStatus.value = t("panel.config.uploadServerFailedKeepBase64");
|
|
54
|
+
}
|
|
55
|
+
} catch (error) {
|
|
56
|
+
uploadStatus.value = error instanceof Error ? error.message : msgs().readImageFailed;
|
|
53
57
|
}
|
|
54
58
|
}
|
|
55
59
|
|
|
@@ -76,6 +80,12 @@ function onFileChange(e: Event) {
|
|
|
76
80
|
:disabled="!isEditable"
|
|
77
81
|
@update:value="(v) => patchStyle({ backgroundColor: v || undefined })"
|
|
78
82
|
/>
|
|
83
|
+
</ConfigFieldGroup>
|
|
84
|
+
<ConfigFieldGroup
|
|
85
|
+
:title="t('panel.config.groupBgLayout')"
|
|
86
|
+
collapsible
|
|
87
|
+
:default-open="false"
|
|
88
|
+
>
|
|
79
89
|
<label class="block space-y-1">
|
|
80
90
|
<div>{{ t("panel.scope.fieldStyleBackgroundImage") }}</div>
|
|
81
91
|
<Input
|
|
@@ -102,8 +112,6 @@ function onFileChange(e: Event) {
|
|
|
102
112
|
</label>
|
|
103
113
|
<span v-if="uploadStatus" class="text-[11px] text-gray-500">{{ uploadStatus }}</span>
|
|
104
114
|
</div>
|
|
105
|
-
</ConfigFieldGroup>
|
|
106
|
-
<ConfigFieldGroup :title="t('panel.config.groupBgLayout')">
|
|
107
115
|
<div class="grid grid-cols-2 gap-2">
|
|
108
116
|
<label class="block space-y-1">
|
|
109
117
|
<div>{{ t("panel.scope.fieldStyleBackgroundSize") }}</div>
|