feat(all): 拖入花线迁移

This commit is contained in:
plum 2026-04-08 16:00:14 +08:00
parent 2605656dc4
commit 986da8ce42
5 changed files with 394 additions and 2 deletions

View File

@ -122,6 +122,9 @@ export default {
world: '世界坐标',
"Status monitoring":"状态监控",
},
path: {
"Path drawing: Left-click to add a point, and double-click to end the drawing": "路径绘制:左键单击添加点,双击结束绘制",
},
viewportInfo: {
Objects: '物体',
Vertices: '顶点',
@ -754,6 +757,7 @@ export default {
'Query failed': "查询失败",
'Related document': "相关文档",
Copy: "复制",
Finish: "完成",
Focus: '聚焦',
Support: '支持',
Upload: '上传',

View File

@ -0,0 +1,60 @@
import { defineStore } from "pinia";
import { store } from "@/store";
interface IPathDrawingState {
active: boolean;
template: Record<string, any> | null;
submit: ((payload: IPathDrawingResult) => void) | null;
}
export interface IPathDrawingResult {
worldPoints: Array<{ x: number; y: number; z: number }>;
origin: { x: number; y: number; z: number };
options: Record<string, any>;
}
export interface IPathDrawingRequest {
template?: Record<string, any> | null;
submit?: (payload: IPathDrawingResult) => void;
}
function isPathDrawingRequest(payload: Record<string, any> | IPathDrawingRequest) {
return Object.prototype.hasOwnProperty.call(payload, "template") || Object.prototype.hasOwnProperty.call(payload, "submit");
}
export const usePathDrawingStore = defineStore({
id: "pathDrawing",
state: (): IPathDrawingState => ({
active: false,
template: null,
submit: null,
}),
getters: {
isActive: state => state.active,
getTemplate: state => state.template,
getSubmit: state => state.submit,
},
actions: {
start(payload: Record<string, any> | IPathDrawingRequest) {
const request = isPathDrawingRequest(payload) ? payload : { template: payload };
this.template = request.template ? JSON.parse(JSON.stringify(request.template)) : null;
this.submit = typeof request.submit === "function" ? request.submit : null;
this.active = true;
},
cancel() {
this.active = false;
this.template = null;
this.submit = null;
},
finish() {
this.active = false;
this.template = null;
this.submit = null;
},
},
});
export function usePathDrawingStoreWithOut() {
return usePathDrawingStore(store);
}

View File

@ -46,6 +46,7 @@ import type { Ref } from "vue";
import { Box3, Vector3, type Object3D } from "three";
import { cpt } from "@/language";
import { useDragStore } from "@/store/modules/drag";
import { usePathDrawingStore } from "@/store/modules/pathDrawing";
import { screenToWorld } from "@/utils/common/scenes";
import { App, AddObjectCommand, Heatmap, Path, UIPanel, Water, type Preview } from "@astral3d/engine";
@ -59,6 +60,7 @@ interface ExpansionItem {
const searchText = inject("searchText") as Ref<string>;
const previewInfo = inject("previewInfo") as any;
const previewRef = inject("previewRef") as any;
const pathDrawingStore = usePathDrawingStore();
const activeSubCategory = ref("heatmap");
const subCategories = ref([
@ -514,6 +516,17 @@ function selectSubCategory(key: string) {
activeSubCategory.value = key;
}
function startPathDrawing(item: ExpansionItem) {
const options = JSON.parse(JSON.stringify(item.options || {}));
if (!options.name) {
options.name = getItemName(item);
}
if (pathDrawingStore.isActive) {
pathDrawingStore.cancel();
}
pathDrawingStore.start(options);
}
function getDefaultAddPosition(): number[] | undefined {
const container = window.viewer?.container;
if (!container) return undefined;
@ -580,8 +593,7 @@ function addToScene(item: ExpansionItem, position?: number[]) {
break;
}
case "path": {
const path = new Path(options);
App.execute(new AddObjectCommand(path), `Add Path: ${options.name}`);
startPathDrawing(item);
break;
}
case "uipanel": {
@ -603,6 +615,12 @@ function dragStart(item: ExpansionItem) {
function dragEnd() {
if (dragStore.getActionTarget !== "addToScene" || dragStore.endArea !== "Scene") return;
if (activeSubCategory.value === "path") {
startPathDrawing(dragStore.getData);
dragStore.setActionTarget("");
return;
}
const position = screenToWorld(dragStore.endPosition.x, dragStore.endPosition.y).toArray();
addToScene(dragStore.getData, position);

View File

@ -0,0 +1,307 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, watch } from "vue";
import * as THREE from "three";
import { AddObjectCommand, App, Hooks, Path, Utils, getDefaultPathOptions } from "@astral3d/engine";
import { t } from "@/language";
import type { IPathDrawingResult } from "@/store/modules/pathDrawing";
import { usePathDrawingStore } from "@/store/modules/pathDrawing";
const pathDrawingStore = usePathDrawingStore();
const isDrawing = computed(() => pathDrawingStore.isActive);
const submit = computed(() => pathDrawingStore.getSubmit);
let templateOptions: Record<string, any> | null = null;
let points: THREE.Vector3[] = [];
let origin: THREE.Vector3 | null = null;
let previewPath: Path | null = null;
let viewerRef: any = null;
let prevCursor = "";
const previewFlag = "__pathPreview";
const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
const tempNdc = new THREE.Vector2();
const onDownPosition = new THREE.Vector2();
const onUpPosition = new THREE.Vector2();
let isPointerDown = false;
let lastClickTime = 0;
const signal = Hooks.useSignal();
function haltSelectionOnIntersect() {
if (!isDrawing.value) return;
signal.halt("intersectionsDetected");
}
function setViewer(viewer: any) {
viewerRef = viewer;
}
function cloneTemplate() {
const current = pathDrawingStore.getTemplate;
templateOptions = current ? JSON.parse(JSON.stringify(current)) : null;
}
function cleanupPreview() {
let removed = false;
if (previewPath) {
previewPath.parent?.remove(previewPath);
previewPath.dispose?.();
previewPath = null;
removed = true;
}
const orphans: THREE.Object3D[] = [];
App.scene.traverse(child => {
if (child.userData?.[previewFlag]) {
orphans.push(child);
}
});
if (orphans.length > 0) {
orphans.forEach(child => {
child.parent?.remove(child);
(child as any).dispose?.();
});
removed = true;
}
if (removed) {
Hooks.useDispatchSignal("sceneGraphChanged");
}
}
function resetState() {
points = [];
origin = null;
isPointerDown = false;
lastClickTime = 0;
cleanupPreview();
}
function buildOptions(worldPoints: THREE.Vector3[], baseOrigin: THREE.Vector3) {
const options = getDefaultPathOptions();
if (templateOptions) {
Utils.deepAssign(options, templateOptions);
}
options.position = baseOrigin.toArray();
options.points = worldPoints.map(point => ({
x: point.x - baseOrigin.x,
y: point.y - baseOrigin.y,
z: point.z - baseOrigin.z,
}));
if (!options.name) options.name = "Path";
return options;
}
function buildResult(worldPoints: THREE.Vector3[], baseOrigin: THREE.Vector3): IPathDrawingResult {
return {
worldPoints: worldPoints.map(point => ({ x: point.x, y: point.y, z: point.z })),
origin: {
x: baseOrigin.x,
y: baseOrigin.y,
z: baseOrigin.z,
},
options: buildOptions(worldPoints, baseOrigin),
};
}
function updatePreview() {
if (!templateOptions) return;
if (points.length === 0) return;
if (!origin) {
origin = points[0].clone();
}
const options = buildOptions(points, origin);
if (!previewPath) {
const path = new Path(options);
path.ignore = true;
path.userData[previewFlag] = true;
previewPath = path;
App.scene.add(path);
} else {
previewPath.updateOptions({
mode: options.mode,
closed: options.closed,
cornerRadius: options.cornerRadius,
cornerSplit: options.cornerSplit,
path: options.path,
tube: options.tube,
position: options.position,
points: options.points,
});
}
Hooks.useDispatchSignal("sceneGraphChanged");
}
function getPickPoint(event: PointerEvent) {
const viewer = viewerRef || (window as any).viewer;
if (!viewer) return null;
const mouse = Utils.getMousePosition(viewer.container, event.clientX, event.clientY);
const intersects = viewer.getIntersects(new THREE.Vector2(mouse[0], mouse[1]));
if (intersects.length > 0) {
return intersects[0].point.clone();
}
tempNdc.set(mouse[0] * 2 - 1, -(mouse[1] * 2) + 1);
viewer.raycaster.setFromCamera(tempNdc, viewer.camera);
const hit = new THREE.Vector3();
return viewer.raycaster.ray.intersectPlane(groundPlane, hit) ? hit.clone() : null;
}
function addPoint(point: THREE.Vector3) {
points.push(point);
updatePreview();
}
function finishDrawing() {
if (!isDrawing.value) return;
if (points.length <= 2) {
cleanupPreview();
pathDrawingStore.cancel();
return;
}
const baseOrigin = origin || points[0];
const result = buildResult(points, baseOrigin);
if (submit.value) {
try {
submit.value(result);
} catch (error) {
console.error("[PathDrawingOverlay] path drawing submit failed", error);
}
} else {
const path = new Path(result.options);
App.execute(new AddObjectCommand(path), `Add Path: ${result.options.name}`);
}
cleanupPreview();
pathDrawingStore.finish();
}
function cancelDrawing() {
if (!isDrawing.value) return;
cleanupPreview();
pathDrawingStore.cancel();
}
function stopDrawing() {
unbindEvents();
resetState();
const viewer = viewerRef || (window as any).viewer;
if (viewer) {
viewer.container.style.cursor = prevCursor || "";
}
}
function bindEvents() {
const viewer = viewerRef || (window as any).viewer;
if (!viewer) return;
prevCursor = viewer.container.style.cursor || "";
viewer.container.style.cursor = "crosshair";
viewer.container.addEventListener("pointerdown", onPointerDown, true);
viewer.container.addEventListener("pointerup", onPointerUp, true);
viewer.container.addEventListener("dblclick", onDoubleClick, true);
}
function unbindEvents() {
const viewer = viewerRef || (window as any).viewer;
if (!viewer) return;
viewer.container.removeEventListener("pointerdown", onPointerDown, true);
viewer.container.removeEventListener("pointerup", onPointerUp, true);
viewer.container.removeEventListener("dblclick", onDoubleClick, true);
}
function onPointerDown(event: PointerEvent) {
if (!isDrawing.value) return;
if (event.button !== 0) return;
const viewer = viewerRef || (window as any).viewer;
if (!viewer) return;
const array = Utils.getMousePosition(viewer.container, event.clientX, event.clientY);
onDownPosition.fromArray(array);
isPointerDown = true;
}
function onPointerUp(event: PointerEvent) {
if (!isDrawing.value) return;
if (event.button !== 0) return;
if (!isPointerDown) return;
const viewer = viewerRef || (window as any).viewer;
if (!viewer) return;
const array = Utils.getMousePosition(viewer.container, event.clientX, event.clientY);
onUpPosition.fromArray(array);
isPointerDown = false;
if (onDownPosition.distanceTo(onUpPosition) !== 0) return;
const now = Date.now();
if (now - lastClickTime < 200) return;
lastClickTime = now;
const point = getPickPoint(event);
if (!point) return;
addPoint(point);
}
function onDoubleClick(event: MouseEvent) {
if (!isDrawing.value) return;
event.preventDefault();
event.stopPropagation();
finishDrawing();
}
watch(
() => isDrawing.value,
active => {
if (active) {
resetState();
cloneTemplate();
bindEvents();
} else {
stopDrawing();
}
}
);
onMounted(() => {
Hooks.useAddSignal("intersectionsDetected", haltSelectionOnIntersect, undefined, 999);
Hooks.useAddSignal("viewerInitCompleted", setViewer);
if ((window as any).viewer) {
setViewer((window as any).viewer);
}
});
onBeforeUnmount(() => {
Hooks.useRemoveSignal("intersectionsDetected", haltSelectionOnIntersect);
Hooks.useRemoveSignal("viewerInitCompleted", setViewer);
stopDrawing();
});
</script>
<template>
<n-card v-if="isDrawing" size="small" class="path-drawing-overlay">
<n-space size="small" align="center" @pointerdown.stop>
<n-text strong>
{{ t("layout.scene.path['Path drawing: Left-click to add a point, and double-click to end the drawing']") }}
</n-text>
<n-button size="small" type="primary" @click="finishDrawing">{{ t("other.Finish") }}</n-button>
<n-button size="small" @click="cancelDrawing">{{ t("other.Cancel") }}</n-button>
</n-space>
</n-card>
</template>
<style scoped lang="less">
.path-drawing-overlay {
position: absolute;
bottom: 10px;
left: 50%;
width: max-content;
transform: translateX(-50%);
z-index: 20;
}
</style>

View File

@ -11,6 +11,8 @@
<!-- IFC BIM 构件信息悬浮框 -->
<IFCProperties/>
<PathDrawingOverlay/>
</div>
</template>
@ -18,6 +20,7 @@
import {onMounted, ref, nextTick, onBeforeUnmount} from 'vue';
import {App,Viewer,Hooks} from "@astral3d/engine";
import Toolbar from "./Toolbar.vue";
import PathDrawingOverlay from "./PathDrawingOverlay.vue";
import {useGlobalConfigStore} from "@/store/modules/globalConfig";
import {usePluginStore} from "@/store/modules/plugin";
import {installBuiltinPlugin} from "@/plugin";