Explorar o código

feat: 提供第三方数据导入功能

bill hai 4 meses
pai
achega
de4ef25c9f

+ 10 - 1
.env

@@ -1,3 +1,12 @@
 VITE_PRIMARY='#D8000A'
 VITE_TITLE='绘图'
-VITE_ENTRY='src/example/fuse/main.ts'
+VITE_ENTRY='src/example/fuse/main.ts'
+
+VITE_MESH_TEMP_URL='https://test.4dkankan.com/spg.html?m={m}&lang=zh'
+VITE_CLOUD_TEMP_URL='https://uat-laser.4dkankan.com/uat/index.html?m={m}&lang=zh'
+VITE_FUSE_TEMP_URL='https://test-mix3d.4dkankan.com/code/index.html?caseId={m}&app=1&share=1#/show/summary'
+
+VITE_MESH_OSS_URL='https://4dkk.4dage.com/'
+VITE_MESH_API_URL='https://test.4dkankan.com/'
+VITE_CLOUD_API_URL='https://uat-laser.4dkankan.com/'
+VITE_FUSE_API_URL='https://test-mix3d.4dkankan.com/'

+ 14 - 0
.env.development

@@ -0,0 +1,14 @@
+VITE_PRIMARY='#D8000A'
+VITE_TITLE='绘图'
+VITE_ENTRY='src/example/fuse/main.ts'
+
+VITE_MESH_TEMP_URL='https://test.4dkankan.com/spg.html?m={m}&lang=zh'
+VITE_CLOUD_TEMP_URL='https://uat-laser.4dkankan.com/uat/index.html?m={m}&lang=zh'
+VITE_FUSE_TEMP_URL='https://test-mix3d.4dkankan.com/code/index.html?caseId={m}&app=1&share=1#/show/summary'
+
+VITE_MESH_OSS_URL='/meshOSS'
+VITE_MESH_API_URL='/meshAPI'
+VITE_CLOUD_API_URL='/cloudAPI'
+VITE_FUSE_API_URL='/fuseAPI'
+
+VITE_TARGET='test'

+ 13 - 0
.env.test

@@ -0,0 +1,13 @@
+VITE_PRIMARY='#D8000A'
+VITE_TITLE='绘图'
+VITE_ENTRY='src/example/fuse/main.ts'
+
+VITE_MESH_TEMP_URL='https://test.4dkankan.com/spg.html?m={m}&lang=zh'
+VITE_CLOUD_TEMP_URL='https://uat-laser.4dkankan.com/uat/index.html?m={m}&lang=zh'
+VITE_FUSE_TEMP_URL='https://test-mix3d.4dkankan.com/code/index.html?caseId={m}&app=1&share=1#/show/summary'
+
+
+VITE_MESH_OSS_URL='https://4dkk.4dage.com/'
+VITE_MESH_API_URL='https://test.4dkankan.com/'
+VITE_CLOUD_API_URL='https://uat-laser.4dkankan.com/'
+VITE_FUSE_API_URL='https://test-mix3d.4dkankan.com/'

+ 8 - 1
src/core/components/group/group.vue

@@ -21,7 +21,14 @@ import { setShapeTransform } from "@/utils/shape.ts";
 import { DrawStoreBusArgs, useStore } from "../../store/index.ts";
 import { Transform } from "konva/lib/Util";
 import { useHistory } from "@/core/hook/use-history.ts";
-import { computed, nextTick, onUnmounted, ref, shallowRef, watchEffect } from "vue";
+import {
+  computed,
+  nextTick,
+  onUnmounted,
+  ref,
+  shallowRef,
+  watchEffect,
+} from "vue";
 import { useOperMode } from "@/core/hook/use-status.ts";
 import { EntityShape } from "@/deconstruction.js";
 import { Top } from "@element-plus/icons-vue";

+ 0 - 1
src/core/components/image/index.ts

@@ -57,7 +57,6 @@ export type ImageData = Partial<typeof defaultStyle> &
     cornerRadius: number;
     url: string;
     mat: number[];
-    proportion?: { scale: number; unit?: string };
   };
 
 export const interactiveToData: InteractiveTo<"image"> = ({

+ 10 - 2
src/core/helper/layers.vue

@@ -4,8 +4,12 @@
     :ref="(e: any) => shape = e?.shape"
   />
   <HoverTriggle :target="shape">
-    <ElMenu class="menu-layout">
-      <ElMenuItem v-for="layer in store.layers" @click="store.setCurrentLayer(layer)">
+    <ElMenu class="menu-layout" :default-active="store.currentLayer">
+      <ElMenuItem
+        v-for="layer in store.layers"
+        @click="store.setCurrentLayer(layer)"
+        :index="layer"
+      >
         <span>{{ layer }}</span>
       </ElMenuItem>
     </ElMenu>
@@ -78,6 +82,10 @@ const store = useStore();
     align-items: center;
     padding: 5px 16px 5px 6px !important;
     color: var(--el-text-color-regular);
+
+    &.is-active {
+      color: var(--el-color-primary);
+    }
   }
   .el-menu-item [class^="el-icon"] {
     margin: 0;

+ 3 - 3
src/core/hook/use-component.ts

@@ -5,7 +5,6 @@ import {
   isRef,
   markRaw,
   nextTick,
-  onMounted,
   onUnmounted,
   reactive,
   Ref,
@@ -285,8 +284,9 @@ export const useComponentsAttach = <T>(
   for (const type of types) {
     cleanups.push(
       globalWatch(
-        () => store.getTypeItems(type),
-        (items, _, onCleanup) => {
+        () => store.getTypeItems(type).length,
+        (_a, _, onCleanup) => {
+          const items = store.getTypeItems(type)
           if (!items) return;
           for (const item of items) {
             const attachWatchStop = watchEffect((onCleanup) => {

+ 2 - 2
src/core/hook/use-debugger.ts

@@ -3,5 +3,5 @@ import { installGlobalVar } from "./use-global-vars";
 import { Pos } from "@/utils/math";
 
 export const useTestPoints = installGlobalVar(() => {
-  return ref<Pos[]>([])
-})
+  return ref<Pos[]>([]);
+});

+ 1 - 1
src/core/hook/use-interactive.ts

@@ -172,7 +172,7 @@ export const useInteractiveAreas = ({
           tempArea = [point] as unknown as Area;
         }
       }),
-      listener(dom, "pointerup", (ev) => {
+      listener(document.documentElement, "pointerup", (ev) => {
         if (downed) {
           mode.del(Mode.draging);
         } 

+ 1 - 5
src/core/hook/use-proportion.ts

@@ -4,14 +4,10 @@ import { installGlobalVar } from "./use-global-vars";
 
 export const useProportion = installGlobalVar(() => {
   const store = useStore();
-  const proportion = computed(() => {
-    const image = store.getTypeItems("image").find((item) => !!item.proportion);
-    return image?.proportion || { scale: 1, unit: "" };
-  });
+  const proportion = computed(() => store.config.proportion);
 
   const transform = (width: number) => {
     return Math.floor(width * proportion.value.scale).toString() + (proportion.value.unit || "");
-    // return width.toString()
   }
 
   return {

+ 38 - 45
src/core/hook/use-selection.ts

@@ -15,11 +15,20 @@ import { themeColor } from "@/constant/help-style";
 import { dragListener } from "@/utils/event";
 import { Layer } from "konva/lib/Layer";
 import { useOperMode } from "./use-status";
-import { computed, markRaw, nextTick, reactive, ref, watch, watchEffect } from "vue";
+import {
+  computed,
+  markRaw,
+  nextTick,
+  reactive,
+  ref,
+  watch,
+  watchEffect,
+} from "vue";
 import { EntityShape } from "@/deconstruction";
 import { Util } from "konva/lib/Util";
 import { useViewerInvertTransform } from "./use-viewer";
 import {
+  debounce,
   diffArrayChange,
   mergeFuns,
   onlyId,
@@ -66,7 +75,7 @@ const normalSelectIds = (
     );
     nIds.push(...addIds);
   }
-  return nIds
+  return nIds;
 };
 
 export const normalSelectShapes = (
@@ -118,12 +127,11 @@ export const useSelection = installGlobalVar(() => {
     const boxRect = box.getClientRect();
     selections.value = [];
 
-
     for (let i = 0; i < shapeBoxs.length; i++) {
       if (Util.haveIntersection(boxRect, shapeBoxs[i]))
         selections.value.push(shapes[i]);
     }
-  }
+  };
 
   const init = (dom: HTMLDivElement, layer: Layer) => {
     const stopListener = dragListener(dom, {
@@ -137,7 +145,7 @@ export const useSelection = installGlobalVar(() => {
       move({ end }) {
         box.width(end.x - box.x());
         box.height(end.y - box.y());
-        updateSelections()
+        updateSelections();
       },
       up() {
         selections.value = undefined;
@@ -154,27 +162,14 @@ export const useSelection = installGlobalVar(() => {
     shapes = getChildren();
     shapeBoxs = shapes.map((shape) => shape.getClientRect());
   };
-  const shpaesChange = () => {
-    updateInitData()
-    updateSelections()
-  }
 
-  const store = useStore()
   const stopWatch = globalWatch(
     () => operMode.value.mulSelection,
     (mulSelection, _, onCleanup) => {
       if (!mulSelection) return;
       const dom = stage.value?.getNode().container()!;
       updateInitData();
-
-      const unInit = init(dom, layer.value!);
-      store.bus.on('dataChangeAfter', shpaesChange)
-      store.bus.on('delItemAfter', shpaesChange)
-      onCleanup(() => {
-        unInit()
-        store.bus.off('dataChangeAfter', shpaesChange)
-        store.bus.off('delItemAfter', shpaesChange)
-      })
+      onCleanup(init(dom, layer.value!));
     }
   );
 
@@ -286,25 +281,24 @@ export const useWatchSelectionGroup = () => {
 export const useSelectionRevise = () => {
   const mParts = useMountParts();
   const status = useMouseShapesStatus();
+  const store = useStore();
   const { addShapes, delShapes, watchSelectionGroup } =
     useWatchSelectionGroup();
 
   useSelectionShowIcons();
 
   const getFormatChildren = useGetFormalChildren();
-  const mouseSelects = computed(() => {
+  const filterSelect = debounce(() => {
     const children = getFormatChildren();
-    const selectShapes = status.selects.filter((shape) => {
-      return children.includes(shape);
-    });
-    return selectShapes;
-  });
-
-  watchEffect(() => {
-    if (mouseSelects.value.length !== status.selects.length) {
-      status.selects = mouseSelects.value;
-    }
-  });
+    const mouseSelects = status.selects.filter((shape) =>
+      children.includes(shape)
+    );
+    status.selects = mouseSelects;
+  }, 16);
+  store.bus.on("delItemAfter", filterSelect);
+  store.bus.on("clearAfter", filterSelect);
+  store.bus.on("dataChangeAfter", filterSelect);
+  store.bus.on("setCurrentLayerAfter", filterSelect);
 
   const rectSelects = useSelection();
   let initSelections: EntityShape[] = [];
@@ -313,7 +307,7 @@ export const useSelectionRevise = () => {
     () => rectSelects.value && [...rectSelects.value],
     (rectSelects, oldRectSelects) => {
       if (!oldRectSelects) {
-        initSelections = [...mouseSelects.value];
+        initSelections = [...status.selects];
         stopWatchSelectionGroup();
       } else if (!rectSelects) {
         initSelections = [];
@@ -340,7 +334,7 @@ export const useSelectionRevise = () => {
   const layer = useFormalLayer();
   watch(
     () => [!!ids.value.length, operMode.value.mulSelection],
-    () => {
+    (_a, _b) => {
       const groupShape = layer.value?.findOne<Group>(`#${groupConfig.id}`);
       if (!groupShape) return;
       if (ids.value.length && !operMode.value.mulSelection) {
@@ -352,32 +346,31 @@ export const useSelectionRevise = () => {
   );
 
   const stage = useStage();
-  const store = useStore();
   const history = useHistory();
   const showItemId = useForciblyShowItemIds();
   watchEffect((onCleanup) => {
     if (!ids.value.length) return;
     const props = {
       data: { ...groupConfig, ids: ids.value },
+      key: groupConfig.id,
       onUpdateShape(data: GroupData) {
-        status.selects
-        data.ids
+        status.selects;
+        data.ids;
       },
       onDelShape() {
-        console.log('delShape')
-        status.selects = []
+        status.selects = [];
       },
       onAddShape(data: GroupData) {
         history.onceTrack(() => {
           const ids = data.ids;
-          const cIds = ids.filter(id => store.getType(id) !== "group")
+          const cIds = ids.filter((id) => store.getType(id) !== "group");
 
-          const groups = store.typeItems.group
-          const exists = groups?.some(group => {
-            if (group.ids.length !== cIds.length) return false
-            const diff = diffArrayChange(group.ids, cIds)
-            return diff.added.length === 0 && diff.deleted.length == 0
-          })
+          const groups = store.typeItems.group;
+          const exists = groups?.some((group) => {
+            if (group.ids.length !== cIds.length) return false;
+            const diff = diffArrayChange(group.ids, cIds);
+            return diff.added.length === 0 && diff.deleted.length == 0;
+          });
           if (exists) return;
 
           let selects = new Set(status.selects);

+ 21 - 14
src/core/hook/use-viewer.ts

@@ -1,5 +1,5 @@
 import { Viewer } from "../viewer.ts";
-import { computed, ref, watch } from "vue";
+import { computed, ref, watch, watchEffect } from "vue";
 import { dragListener, scaleListener } from "../../utils/event.ts";
 import {
   globalWatch,
@@ -10,33 +10,40 @@ import { useCan } from './use-status'
 import { mergeFuns } from "../../utils/shared.ts";
 import { Transform } from "konva/lib/Util";
 import { lineLen } from "@/utils/math.ts";
+import { useResize } from "./use-event.ts";
 
 export const useViewer = installGlobalVar(() => {
   const stage = useStage();
   const viewer = new Viewer();
   const can = useCan()
+  const size = useResize()
   const transform = ref(new Transform());
 
   const init = (dom: HTMLDivElement) => {
-    const dragDestroy = dragListener(dom, {
-      move: ({ end, prev }) => {
+    const onDestroy = mergeFuns(
+      dragListener(dom, {
+        move: ({ end, prev }) => {
+          if (can.viewMode) {
+            viewer.movePixel({ x: end.x - prev.x, y: end.y - prev.y }); 
+          }
+        },
+        notPrevent: true,
+      }),
+      scaleListener(dom, (info) => {
         if (can.viewMode) {
-          viewer.movePixel({ x: end.x - prev.x, y: end.y - prev.y }); 
+          viewer.scalePixel(info.center, info.scale);
         }
-      },
-      notPrevent: true,
-    });
-    const scaleDestroy = scaleListener(dom, (info) => {
-      if (can.viewMode) {
-        viewer.scalePixel(info.center, info.scale);
-      }
-    });
+      }),
+      watchEffect(() => {
+        size.value && viewer.setSize(size.value)
+      })
+    )
+
     viewer.bus.on("transformChange", (newTransform) => {
-      // console.log(newTransform.m)
       transform.value = newTransform;
     });
     transform.value = viewer.transform;
-    return mergeFuns(dragDestroy, scaleDestroy);
+    return onDestroy;
   };
 
   return {

+ 1 - 0
src/core/renderer/renderer.vue

@@ -31,6 +31,7 @@
             <component
               v-for="part in mountParts.value"
               :is="part.comp"
+              :key="part.props.key"
               v-bind="part.props"
             />
           </v-group>

+ 3 - 3
src/core/store/index.ts

@@ -53,7 +53,6 @@ export const useStore = installGlobalVar(() => {
     store.$patch((state) => {
       store.bus.emit('dataChangeBefore')
       state.data = data;
-      console.log(data)
       nextTick(() => store.bus.emit("dataChangeAfter"))
     });
   });
@@ -66,7 +65,8 @@ export const useStore = installGlobalVar(() => {
     "setItem",
     "setConfig",
     "delLayer",
-    "addLayer"
+    "addLayer",
+    "clear"
   ];
   const emitActions = [
     ...trackActions,
@@ -88,7 +88,7 @@ export const useStore = installGlobalVar(() => {
       return
     }
 
-    if (prevLayer && prevLayer !== store.currentLayer) {
+    if (prevLayer && prevLayer !== store.currentLayer ) {
       history.push(
         JSON.stringify({...store.data, __currentLayer: store.currentLayer})
       );

+ 11 - 3
src/core/store/store.ts

@@ -8,6 +8,7 @@ const sortFn = (
 ) => a.zIndex - b.zIndex || a.createTime - b.createTime;
 
 export type StoreConfig = {
+  proportion: { scale: number, unit: string }
   compass: {
     rotation: number;
     url?: string;
@@ -20,11 +21,12 @@ export type StoreData = {
 };
 const defConfig: StoreData["config"] = {
   compass: { rotation: 0 },
+  proportion: {scale: 1, unit: ''}
 };
 
 export const getEmptyStoreData = (): StoreData => {
   return {
-    layers: {},
+    layers: { [defaultLayer]: {} },
     config: { ...defConfig },
     __currentLayer: defaultLayer,
   };
@@ -151,9 +153,12 @@ export const useStoreRaw = defineStore("draw-data", {
         }
       }
     },
-    setConfig(config: StoreData["config"]) {
+    setConfig(config: Partial<StoreData["config"]>) {
       this.$patch((state) => {
-        state.data.config = config;
+        state.data.config = {
+          ...state.data.config,
+          ...config
+        };
       });
     },
     setCurrentLayer(layer: string) {
@@ -163,6 +168,9 @@ export const useStoreRaw = defineStore("draw-data", {
         this.data.__currentLayer = layer
       }
     },
+    clear() {
+      this.data.layers[this.currentLayer] = {}
+    },
     addLayer(layer: string) {
       if (this.layers.includes(layer)) {
         throw `已存在${layer}层`

+ 134 - 152
src/core/viewer.ts

@@ -1,156 +1,138 @@
 import { Transform } from "konva/lib/Util";
-import { Pos } from "../utils/math.ts";
+import { Pos, Size } from "../utils/math.ts";
 import { alignPortMat } from "@/utils/align-port.ts";
-import mitt from 'mitt'
-
-export type ViewerProps = {
-	size?: number[];
-	bound?: number[];
-	padding: number | number[];
-	retain: boolean;
-};
+import mitt from "mitt";
+import { IRect } from "konva/lib/types";
+import { MathUtils } from "three";
 
 export class Viewer {
-	props: ViewerProps;
-	viewMat: Transform;
-	partMat: Transform = new Transform();
-	bus = mitt<{ transformChange: Transform }>()
-
-	constructor(props: Partial<ViewerProps> = {}) {
-		this.props = {
-			padding: 0,
-			retain: true,
-			...props,
-		}
-		this.viewMat = new Transform();
-	}
-
-	get bound() {
-		return this.props.bound
-	}
-
-	setBound(bound: number[], size?: number[], padding?: number | number[], retain?: boolean) {
-		this.props.bound = bound
-		if (padding) {
-			this.props.padding = padding
-		}
-		if (retain) {
-			this.props.retain = retain
-		}
-		if (size) {
-			this.props.size = size
-		}
-		padding = this.props.padding
-		retain = this.props.retain
-		size = this.props.size
-
-		if (!size) {
-			throw '缺少视窗size'
-		}
-
-		this.partMat = alignPortMat(
-			[
-				{x: bound[0], y: bound[1]},
-				{x: bound[2], y: bound[3]},
-			],
-			[
-				{x: 0, y: 0},
-				{x: size[0], y: size[1]},
-			],
-			retain,
-			typeof padding === "number"
-				? padding
-				: {x: padding[0], y: padding[1]}
-		);
-	}
-
-	move(position: Pos, initMat = this.viewMat) {
-		this.mutMat(new Transform().translate(position.x, position.y), initMat);
-	}
-
-	movePixel(position: Pos, initMat = this.viewMat) {
-		if (isNaN(position.x) || isNaN(position.y)) {
-			console.error(`无效移动位置${position.x} ${position.y}`)
-			return;
-		}
-
-		const mat = initMat.copy().invert()
-		const p1 = mat.point({x: 0, y: 0})
-		const p2 = mat.point(position)
-		this.move({x: p2.x - p1.x, y: p2.y - p1.y})
-
-		// const info = initMat.decompose()
-		// const tf = new Transform()
-		// tf.rotate(info.rotation)
-		// tf.scale(info.scaleX, info.scaleY)
-		// this.move(tf.invert().point(position), this.viewMat)
-	}
-
-
-	scale(center: Pos, scale: number, initMat = this.viewMat) {
-		const base = initMat.decompose().scaleX
-		if (base * scale < 0.001 || base * scale > 1000) {
-			console.error('缩放范围0.001~1000 已超过范围无法缩放')
-			return;
-		}
-		if (isNaN(center.x) || isNaN(center.y)) {
-			console.error(`无效中心点${center.x} ${center.y}`)
-			return;
-		}
-
-		this.mutMat(
-			new Transform()
-				.translate(center.x, center.y)
-				.multiply(
-					new Transform()
-						.scale(scale, scale)
-						.multiply(new Transform().translate(-center.x, -center.y))
-				),
-			initMat
-		);
-	}
-
-	scalePixel(center: Pos, scale: number, initMat = this.viewMat) {
-		const pos = initMat.copy().invert().point(center)
-		this.scale(pos, scale, initMat)
-	}
-
-	rotate(center: Pos, angleRad: number, initMat = this.viewMat) {
-		this.mutMat(
-			new Transform()
-				.translate(center.x, center.y)
-				.multiply(
-					new Transform()
-						.rotate(angleRad)
-						.multiply(new Transform().translate(-center.x, -center.y))
-				),
-			initMat
-		);
-	}
-
-	rotatePixel(center: Pos, angleRad: number, initMat = this.viewMat) {
-		const pos = initMat.copy().invert().point(center)
-		this.rotate(pos, angleRad, initMat)
-	}
-
-	mutMat(mat: Transform, initMat = this.viewMat) {
-		// this.setViewMat(mat.copy().multiply(initMat))
-		this.setViewMat(initMat.copy().multiply(mat))
-	}
-
-	setViewMat(mat: number[] | Transform) {
-		if (mat instanceof Transform) {
-			this.viewMat = mat.copy();
-		} else {
-			this.viewMat = new Transform(mat)
-		}
-		this.bus.emit('transformChange', this.transform)
-	}
-
-	get transform() {
-		return this.partMat.copy().multiply(this.viewMat);
-	}
-	get current() {
-		return this.viewMat.decompose()
-	}
-}
+  size?: Size;
+  viewMat: Transform;
+  bus = mitt<{ transformChange: Transform }>();
+
+  constructor() {
+    this.viewMat = new Transform();
+  }
+
+  setSize(size: Size) {
+    this.size = size;
+  }
+
+  setBound({
+    targetBound,
+    size,
+    padding = 0,
+  }: {
+    targetBound: IRect;
+    size?: Size;
+    padding?: number;
+  }) {
+    size = size || this.size;
+    if (!size) {
+      throw "size属性未设置";
+    }
+
+    const selfBound = {
+      x: 0,
+      y: 0,
+      width: size.width - padding * 2,
+      height: size.height - padding * 2,
+    };
+    const mat = new Transform()
+      .translate(padding, padding)
+      .multiply(alignPortMat(selfBound, targetBound, true));
+    
+    const rotate = this.viewMat.decompose().rotation
+    this.setViewMat(mat);
+    this.rotatePixel({x: size.width / 2, y: size.height / 2}, MathUtils.degToRad(rotate))
+  }
+
+  move(position: Pos, initMat = this.viewMat) {
+    this.mutMat(new Transform().translate(position.x, position.y), initMat);
+  }
+
+  movePixel(position: Pos, initMat = this.viewMat) {
+    if (isNaN(position.x) || isNaN(position.y)) {
+      console.error(`无效移动位置${position.x} ${position.y}`);
+      return;
+    }
+
+    const mat = initMat.copy().invert();
+    const p1 = mat.point({ x: 0, y: 0 });
+    const p2 = mat.point(position);
+    this.move({ x: p2.x - p1.x, y: p2.y - p1.y });
+
+    // const info = initMat.decompose()
+    // const tf = new Transform()
+    // tf.rotate(info.rotation)
+    // tf.scale(info.scaleX, info.scaleY)
+    // this.move(tf.invert().point(position), this.viewMat)
+  }
+
+  scale(center: Pos, scale: number, initMat = this.viewMat) {
+    const base = initMat.decompose().scaleX;
+    if (base * scale < 0.001 || base * scale > 1000) {
+      console.error("缩放范围0.001~1000 已超过范围无法缩放");
+      return;
+    }
+    if (isNaN(center.x) || isNaN(center.y)) {
+      console.error(`无效中心点${center.x} ${center.y}`);
+      return;
+    }
+
+    this.mutMat(
+      new Transform()
+        .translate(center.x, center.y)
+        .multiply(
+          new Transform()
+            .scale(scale, scale)
+            .multiply(new Transform().translate(-center.x, -center.y))
+        ),
+      initMat
+    );
+  }
+
+  scalePixel(center: Pos, scale: number, initMat = this.viewMat) {
+    const pos = initMat.copy().invert().point(center);
+    this.scale(pos, scale, initMat);
+  }
+
+  rotate(center: Pos, angleRad: number, initMat = this.viewMat) {
+    this.mutMat(
+      new Transform()
+        .translate(center.x, center.y)
+        .multiply(
+          new Transform()
+            .rotate(angleRad)
+            .multiply(new Transform().translate(-center.x, -center.y))
+        ),
+      initMat
+    );
+  }
+
+  rotatePixel(center: Pos, angleRad: number, initMat = this.viewMat) {
+    const pos = initMat.copy().invert().point(center);
+    this.rotate(pos, angleRad, initMat);
+  }
+
+  mutMat(mat: Transform, initMat = this.viewMat) {
+    // this.setViewMat(mat.copy().multiply(initMat))
+    this.setViewMat(initMat.copy().multiply(mat));
+  }
+
+  setViewMat(mat: number[] | Transform) {
+    if (mat instanceof Transform) {
+      this.viewMat = mat.copy();
+    } else {
+      this.viewMat = new Transform(mat);
+    }
+    this.bus.emit("transformChange", this.transform);
+  }
+
+  get transform() {
+    return this.viewMat.copy();
+  }
+  get current() {
+    return this.viewMat.decompose();
+  }
+}

+ 0 - 44
src/example/basemap/dialog.vue

@@ -1,44 +0,0 @@
-<template>
-  <ElDialog
-    :title="props.title"
-    v-model="props.visiable"
-    :width="props.width"
-    :height="props.height"
-    @closed="showContent = false"
-  >
-    <div v-if="showContent">
-      <component :is="props.content" ref="contentRef" v-bind="props.args" />
-    </div>
-    <template #footer>
-      <div class="dialog-footer">
-        <el-button @click="props.cancel">取消</el-button>
-        <el-button type="primary" @click="submit"> 确定 </el-button>
-      </div>
-    </template>
-  </ElDialog>
-</template>
-
-<script lang="ts" setup>
-import { ElDialog, ElButton } from "element-plus";
-import { ref, watch } from "vue";
-import { props } from ".";
-
-const contentRef = ref<any>(null);
-const showContent = ref(false);
-
-watch(
-  () => props.visiable,
-  () => {
-    if (props.visiable) {
-      showContent.value = true;
-    }
-  }
-);
-
-const submit = async () => {
-  try {
-    const info = await contentRef.value.submit();
-    props.submit(info);
-  } catch {}
-};
-</script>

+ 64 - 0
src/example/fuse/dialog/ai/ai.vue

@@ -0,0 +1,64 @@
+<template>
+  <div style="padding: 20px 20px 40px">
+    <VR v-model:value="scene" class="vr-layout" />
+    <div v-if="scene" class="tagging-layout">
+      <span>请选择图例范围</span>
+      <el-checkbox-group v-model="syncTags" style="width: 200px">
+        <el-checkbox
+          :label="item.label"
+          :value="item.value"
+          v-for="item in options[scene.type]"
+        />
+      </el-checkbox-group>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch } from "vue";
+import VR from "../vr/vr.vue";
+import { ElCheckboxGroup, ElCheckbox } from "element-plus";
+import {
+  lineGets,
+  Scene,
+  SCENE_TYPE,
+  taggingGets,
+} from "../../platform-resource";
+import { AIExposeData } from ".";
+
+const scene = ref<Scene>();
+const options = {
+  [SCENE_TYPE.mesh]: [
+    { value: "signage", label: "指示牌" },
+    { value: "hot", label: "多媒体标签" },
+  ],
+  [SCENE_TYPE.cloud]: [{ value: "hot", label: "热点" }],
+  [SCENE_TYPE.fuse]: [{ value: "hot", label: "标签" }],
+};
+const syncTags = ref<string[]>([]);
+watch(scene, () => {
+  if (!scene.value) {
+    syncTags.value = [];
+  } else {
+    syncTags.value = options[scene.value.type].map((item) => item.value);
+  }
+});
+
+defineExpose({
+  submit: async (): Promise<AIExposeData> => {
+    if (!scene.value) return { taggings: [], floors: [] };
+    const taggings = await taggingGets[scene.value.type](scene.value, syncTags.value);
+    const floors = await lineGets[scene.value.type](scene.value);
+    return { taggings, floors };
+  },
+});
+</script>
+
+<style lang="scss" scoped>
+.vr-layout {
+  padding: 0 !important;
+}
+.tagging-layout {
+  margin-top: 20px;
+}
+</style>

+ 35 - 0
src/example/fuse/dialog/ai/index.ts

@@ -0,0 +1,35 @@
+import { markRaw, reactive } from "vue";
+import AI from "./ai.vue";
+import { SceneFloors, Tagings } from "../../platform-resource";
+
+type Props = {
+  title: string;
+  width: string;
+  visiable: boolean;
+  height?: string;
+  content: any;
+  submit: (info: AIExposeData) => void;
+  cancel: () => void;
+};
+
+export type AIExposeData = {taggings: Tagings, floors: SceneFloors }
+
+export const props = reactive({
+  title: "全景VR",
+  width: "500px",
+}) as Props;
+
+export const selectAI = () =>
+  new Promise<AIExposeData>((resolve, reject) => {
+    props.content = markRaw(AI);
+    props.title = "AI导入";
+    props.visiable = true;
+    props.submit = (info) => {
+      resolve(info);
+      props.visiable = false
+    };
+    props.cancel = () => {
+      reject("cancel");
+      props.visiable = false
+    };
+  });

src/example/basemap/gd-map/selectAMapImage.vue → src/example/fuse/dialog/basemap/gd-map/selectAMapImage.vue


+ 0 - 2
src/example/basemap/index.ts

@@ -40,5 +40,3 @@ export const getAMapInfo = (args?: Props['args']) =>
       props.visiable = false
     };
   });
-
-export { default as Mount } from "./dialog.vue";

+ 62 - 0
src/example/fuse/dialog/dialog.vue

@@ -0,0 +1,62 @@
+<template>
+  <ElDialog
+    v-for="(props, i) in dialogsProps"
+    :title="props.title"
+    v-model="props.visiable"
+    :width="props.width"
+    :height="props.height"
+    @closed="showContents[i] = false"
+  >
+    <div v-if="showContents[i]">
+      <component
+        :is="props.content"
+        :ref="(ref: any) => contentRefs[i] = ref"
+        v-bind="(props as any).args"
+      />
+    </div>
+    <template #footer>
+      <div class="dialog-footer">
+        <el-button @click="props.cancel">取消</el-button>
+        <el-button type="primary" @click="submits[i]()"> 确定 </el-button>
+      </div>
+    </template>
+  </ElDialog>
+</template>
+
+<script lang="ts" setup>
+import { ElDialog, ElButton } from "element-plus";
+import { reactive, ref, shallowRef, watch, watchEffect } from "vue";
+import { props as baseMapProps } from "./basemap";
+import { props as VRProps } from "./vr";
+import { props as AIProps } from "./ai";
+import { mergeFuns } from "@/utils/shared";
+
+const dialogsProps = reactive([baseMapProps, VRProps, AIProps]);
+const contentRefs = ref<any[]>([]);
+const showContents = ref<boolean[]>([]);
+const submits = shallowRef<(() => any)[]>([]);
+
+watchEffect((onCleanup) => {
+  const cleanups = dialogsProps.map((props, i) => {
+    contentRefs.value[i] = null;
+    showContents.value[i] = false;
+    submits.value[i] = async () => {
+      try {
+        const info = await contentRefs.value[i].submit();
+        props.submit(info);
+      } catch (e) {
+        console.error(e);
+      }
+    };
+    return watch(
+      () => props.visiable,
+      () => {
+        if (props.visiable) {
+          showContents.value[i] = true;
+        }
+      }
+    );
+  });
+  onCleanup(mergeFuns(cleanups));
+});
+</script>

+ 35 - 0
src/example/fuse/dialog/vr/index.ts

@@ -0,0 +1,35 @@
+import { markRaw, reactive } from "vue";
+import VR from "./vr.vue";
+import { Scene } from "../../platform-resource";
+
+type Props = {
+  title: string;
+  width: string;
+  visiable: boolean;
+  height?: string;
+  content: any;
+  submit: (info: Scene) => void;
+  cancel: () => void;
+};
+export const props = reactive({
+  title: "全景VR",
+  width: "500px",
+}) as Props;
+
+export const selectScene = () =>
+  new Promise<Scene>((resolve, reject) => {
+    props.content = markRaw(VR);
+    props.title = "全景VR";
+    props.visiable = true;
+    props.submit = (info) => {
+      resolve(info);
+      props.visiable = false;
+    };
+    props.cancel = () => {
+      reject("cancel");
+      props.visiable = false;
+    };
+  });
+
+
+  

+ 65 - 0
src/example/fuse/dialog/vr/test.ts

@@ -0,0 +1,65 @@
+import { SCENE_TYPE } from "../../platform-resource";
+
+let testToken =
+  "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMzYzMTI2MjkyNiIsImxvZ2luVHlwZSI6InVzZXIiLCJ1c2VyTmFtZSI6IjEzNjMxMjYyOTI2IiwiaWF0IjoxNzQ0MDc2NDg5LCJqdGkiOiI5MTczYTYzOC00ZjcyLTRlMDgtOWNmMy1lMjA5OWQ4YWU0ZjUifQ.A6wuCF7XO-RK1d-cpePt9j6z_R96_JELZUbkvMnBJ_Y";
+
+const body = {
+  pageNum: 1,
+  pageSize: 50,
+  cameraId: null,
+  searchKey: "",
+  cameraType: null,
+  isSetData: true,
+  folderId: "",
+  isOldMu: false,
+  keywordType: "sceneName",
+  folderType: "0",
+  sceneType: null,
+  sceneSource: "1",
+  endTime: "",
+  startTime: "",
+  sceneName: "1",
+};
+export const getMeshSceneList = async (key = "", token: string = testToken): Promise<any[]> => {
+  try {
+    const list = await fetch(
+      import.meta.env.VITE_MESH_API_URL + "ucenter/user/scene/getOnlySceneList",
+      {
+        method: "POST",
+        headers: { token, "content-type": "application/json;charset=UTF-8" },
+        body: JSON.stringify({ ...body, searchKey: key, sceneName: key }),
+      }
+    )
+      .then((res) => res.json())
+      .then((res) => res.data.list)
+      .then((list) =>
+        list.map((item: any) => ({
+          ...item,
+          id: item.id,
+          type: SCENE_TYPE.mesh,
+          m: item.num,
+          title: item.sceneName,
+        }))
+      );
+    return list;
+  } catch {
+    testToken = await login();
+    return await getMeshSceneList(key, testToken);
+  }
+};
+
+const login = async () => {
+  const body = {
+    password: "RtAax6waVucGVuZzEyMw==dGl1VWqhSnT78LntHR",
+    phoneNum: "13631262926",
+    randomcode: "1234",
+    rememberMe: false,
+  };
+  return fetch(import.meta.env.VITE_MESH_API_URL + "ucenter/sso/user/login", {
+    method: "POST",
+    headers: { "content-type": "application/json;charset=UTF-8" },
+    body: JSON.stringify(body),
+  })
+    .then((res) => res.json())
+    .then((res) => res.data.token);
+};

+ 87 - 0
src/example/fuse/dialog/vr/vr.vue

@@ -0,0 +1,87 @@
+<template>
+  <div style="padding: 20px 20px 40px">
+    <el-select
+      :model-value="value?.id"
+      @update:model-value="(id) => (value = scenes.find((scene) => scene.id === id))"
+      filterable
+      remote
+      reserve-keyword
+      placeholder="请选择场景"
+      clearable
+      :remote-method="searchSenects"
+      remote-show-suffix
+      :loading="loading"
+    >
+      <el-option
+        v-for="item in scenes"
+        :key="item.m"
+        :label="SceneTypeNames[item.type] + '-' + item.title"
+        :value="item.id"
+      />
+    </el-select>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ElSelect, ElOption } from "element-plus";
+import { computed, ref } from "vue";
+import { asyncTimeout } from "@/utils/shared";
+import { Scene, SCENE_TYPE, SceneTypeNames } from "../../platform-resource";
+import { getMeshSceneList } from "./test";
+
+const props = defineProps<{ value?: Scene }>();
+const emit = defineEmits<{ (e: "update:value", val: Scene): void }>();
+
+const _value = ref<Scene>();
+const value = computed({
+  get: () => {
+    if (props.value !== undefined) {
+      return props.value;
+    } else {
+      return _value.value;
+    }
+  },
+  set: (val: Scene) => {
+    _value.value = val;
+    emit("update:value", val);
+  },
+});
+
+const loading = ref(false);
+const scenes = ref<Scene[]>([]);
+let oldKeyword: string;
+const searchSenects = async (keyword: string) => {
+  if (oldKeyword! === keyword) return;
+  oldKeyword = keyword;
+
+  loading.value = true;
+  await asyncTimeout(1000);
+  scenes.value = await getMeshSceneList(keyword);
+  scenes.value.unshift(
+    {
+      m: "KK-t-ThCAj9SjuKB",
+      title: "mesh250个点位",
+      id: "-11",
+      type: SCENE_TYPE.mesh,
+    },
+    {
+      m: "SG-t-1YWXq7Tb27z",
+      title: "mesh500个点位",
+      id: "-12",
+      type: SCENE_TYPE.mesh,
+    },
+    {
+      m: "SS-t-hCh1HyrQkr",
+      title: "mesh500个点位-1",
+      id: "-13",
+      type: SCENE_TYPE.mesh,
+    }
+  );
+  loading.value = false;
+};
+
+searchSenects("");
+defineExpose({
+  submit: () => value.value,
+});
+</script>

+ 204 - 0
src/example/fuse/platform-draw.ts

@@ -0,0 +1,204 @@
+import { genBound } from "@/utils/shared";
+import { AIExposeData } from "./dialog/ai";
+import { Draw } from "./views/use-draw";
+import { SceneFloor } from "./platform-resource";
+import { getBaseItem } from "@/core/components/util";
+import { LineData } from "@/core/components/line";
+import { Transform } from "konva/lib/Util";
+
+const scaleResource = (info: AIExposeData, scale: number) => {
+  const taggings = info.taggings.map((item) => ({
+    ...item,
+    position: {
+      x: item.position.x * scale,
+      y: item.position.y * scale,
+      z: item.position.z * scale,
+    },
+  }));
+
+  const floors = info.floors.map((item) => ({
+    ...item,
+    geos: item.geos.map((geo) =>
+      geo.map((p) => ({ x: p.x * scale, y: p.y * scale, z: p.z * scale }))
+    ),
+    box: item.box && {
+      ...item.box,
+      bound: {
+        x_min: item.box.bound.x_min * scale,
+        x_max: item.box.bound.x_max * scale,
+        y_min: item.box.bound.y_min * scale,
+        y_max: item.box.bound.y_max * scale,
+        z_min: item.box.bound.z_min * scale,
+        z_max: item.box.bound.z_max * scale,
+      },
+    },
+  }));
+
+  return {
+    taggings,
+    floors,
+  };
+};
+
+const getResourceLayers = (data: AIExposeData) => {
+  return data.floors
+    .map((floor) => {
+      let box: SceneFloor["box"];
+      if (!floor.box || !floor.box.bound.x_max) {
+        const xs = floor.geos.flatMap((item) => item.map((p) => p.x));
+        const ys = floor.geos.flatMap((item) => item.map((p) => p.y));
+        const zs = floor.geos.flatMap((item) => item.map((p) => p.z));
+        console.log(floor, xs, ys, zs);
+        box = {
+          bound: {
+            x_min: Math.min(...xs),
+            x_max: Math.max(...xs),
+            y_min: Math.min(...ys),
+            y_max: Math.max(...ys),
+            z_min: Math.min(...zs),
+            z_max: Math.max(...ys),
+          },
+          scale: 1,
+          rotate: 0,
+        };
+      } else {
+        box = floor.box;
+      }
+
+      return {
+        ...floor,
+        box,
+        taggings: data.taggings
+          .filter(
+            (item) =>
+              item.position.z > box.bound.z_min &&
+              item.position.z <= box.bound.z_max
+          )
+          .map((item) => ({
+            ...item,
+            position: { x: item.position.x, y: item.position.y },
+          })),
+        geos: floor.geos.map((item) => item.map((p) => ({ x: p.x, y: p.y }))),
+      };
+    })
+    .filter((floor) => floor.taggings.length > 0 || floor.geos.length > 0);
+};
+
+const drawLayerResource = (
+  layerResource: ReturnType<typeof getResourceLayers>[number],
+  draw: Draw
+) => {
+  const bound = genBound();
+  const images: any[] = [];
+
+  if (layerResource.thumb) {
+    const box = layerResource.box;
+    const width = box.bound.x_max - box.bound.x_min;
+    const height = box.bound.y_max - box.bound.y_min;
+    console.log(width, height, box.bound);
+    const mat = new Transform().translate(
+      box.bound.x_min + width / 2,
+      box.bound.y_min + height / 2
+    );
+    // .rotate(-MathUtils.degToRad(floor.box.rotate));
+    const thumb = {
+      ...getBaseItem(),
+      url: layerResource.thumb,
+      mat: mat.m,
+      width,
+      lock: true,
+      height,
+      cornerRadius: 0,
+    };
+    draw.store.addItem("image", thumb);
+    images.push(thumb);
+  }
+
+  images.push(
+    ...layerResource.taggings.map((item) => {
+      bound.update(item.position);
+      return {
+        ...getBaseItem(),
+        url: item.url,
+        lock: true,
+        mat: [1, 0, 0, 1, item.position.x, item.position.y],
+        width: 30,
+        height: 30,
+        cornerRadius: 0,
+      };
+    })
+  );
+
+  draw.store.addItems(
+    "image",
+    images.filter((item) => !item.url.includes(".svg"))
+  );
+  draw.store.addItems(
+    "icon",
+    images.filter((item) => item.url.includes(".svg"))
+  );
+
+  const geos: LineData[] = [];
+  layerResource.geos.forEach((item) => {
+    bound.update(item);
+    geos.push({
+      ...getBaseItem(),
+      lock: true,
+      attitude: [1, 0, 0, 1, 0, 0],
+      points: item,
+    });
+  });
+  draw.store.addItems("line", geos);
+
+  draw.store.addItem("group", {
+    ...getBaseItem(),
+    ids: [...images, ...geos].map((item) => item.id),
+  });
+
+  return bound.get();
+};
+
+const unitScale = 100;
+const unit = "cm";
+export const drawPlatformResource = (data: AIExposeData, draw: Draw) => {
+  const layers = getResourceLayers(scaleResource(data, unitScale));
+  const layerBounds: ReturnType<typeof drawLayerResource>[] = [];
+
+  draw.history.onceTrack(() => {
+    draw.store.setConfig({ proportion: { scale: 1, unit } });
+    for (const layer of layers) {
+      if (!draw.store.layers.includes(layer.name)) {
+        draw.store.addLayer(layer.name);
+      }
+      draw.store.setCurrentLayer(layer.name);
+      layerBounds.push(drawLayerResource(layer, draw)!);
+    }
+  });
+
+  if (layerBounds.length === 0 || !draw.viewer.size) return;
+
+  const flyBound = layerBounds[layerBounds.length - 1]!;
+  const size = draw.viewer.size;
+
+  if (flyBound.width < 10 || flyBound.height < 10) {
+    draw.viewer.setViewMat([
+      1,
+      0,
+      0,
+      1,
+      flyBound.center.x + size.width / 2,
+      flyBound.center.y + size.height / 2,
+    ]);
+  } else {
+    const viewWidth = Math.max(flyBound.width, size.width);
+    const viewHeight = Math.max(flyBound.height, size.height);
+    const padding = Math.max(
+      Math.min(
+        (viewWidth - flyBound.width) / 2,
+        (viewHeight - flyBound.height) / 2
+      ),
+      40
+    );
+    draw.viewer.setBound({ targetBound: flyBound, padding });
+  }
+};

+ 221 - 0
src/example/fuse/platform-resource.ts

@@ -0,0 +1,221 @@
+import { Pos } from "@/utils/math";
+import { extractConnectedSegments } from "@/utils/polygon";
+import { validNum } from "@/utils/shared";
+
+export enum SCENE_TYPE {
+  fuse,
+  mesh,
+  cloud,
+}
+export type Scene = {
+  type: SCENE_TYPE;
+  m: string;
+  title: string;
+  id: string;
+};
+
+export const SceneTypeNames = {
+  [SCENE_TYPE.fuse]: "融合场景",
+  [SCENE_TYPE.mesh]: "Mesh场景",
+  [SCENE_TYPE.cloud]: "点云场景",
+};
+
+export const SceneTypeTempUrls = {
+  [SCENE_TYPE.fuse]: import.meta.env.VITE_FUSE_TEMP_URL,
+  [SCENE_TYPE.mesh]: import.meta.env.VITE_MESH_TEMP_URL,
+  [SCENE_TYPE.cloud]: import.meta.env.VITE_CLOUD_TEMP_URL,
+};
+
+export const SceneTypeApiUrls = {
+  [SCENE_TYPE.fuse]: import.meta.env.VITE_FUSE_API_URL,
+  [SCENE_TYPE.mesh]: import.meta.env.VITE_MESH_OSS_URL,
+  [SCENE_TYPE.cloud]: import.meta.env.VITE_CLOUD_API_URL,
+};
+
+export const getSceneApi = (sceneType: SCENE_TYPE, url: string) => {
+  try {
+    return new URL(url, SceneTypeApiUrls[sceneType]).toString();
+  } catch {
+    return SceneTypeApiUrls[sceneType] + url;
+  }
+};
+
+
+export type Taging = {
+  url: string;
+  position: Pos & {z: number};
+};
+
+export type Tagings = Taging[];
+
+export const taggingGets = {
+  [SCENE_TYPE.fuse]: async (scene: Scene, options: string[]) => {
+    if (!options.includes("hot")) return [];
+
+    const reqOpts = { headers: { share: "1" } };
+    const icons = await fetch(
+      getSceneApi(scene.type, `/fusion/edit/hotIcon/list?caseId=${scene.m}`),
+      reqOpts
+    )
+      .then((res) => res.json())
+      .then((res) => res.data)
+      .catch(() => []);
+
+    const tagTypes: any[] = await fetch(
+      getSceneApi(scene.type, `/fusion/caseTag/allList?caseId=${scene.m}`),
+      reqOpts
+    )
+      .then((res) => res.json())
+      .then((res) => res.data)
+      .catch(() => []);
+
+    const tags: Tagings = [];
+    const reqs = tagTypes.map((type) =>
+      fetch(
+        getSceneApi(
+          scene.type,
+          `/fusion/caseTagPoint/allList?tagId=${type.tagId}`
+        ),
+        reqOpts
+      )
+        .then((res) => res.json())
+        .then((res) => res.data)
+        .then((items) => {
+          items.forEach((item: any) => {
+            tags.push({
+              url: icons.find((icon: any) => icon.iconId === type.hotIconId)
+                ?.iconUrl,
+              position: JSON.parse(item.tagPoint),
+            });
+          });
+        })
+        .catch(() => [])
+    );
+    await Promise.all(reqs);
+    return tags;
+  },
+
+  [SCENE_TYPE.cloud]: async (scene: Scene, options: string[]) => {
+    if (!options.includes("hot")) return [];
+
+    const tags: Tagings = await fetch(
+      getSceneApi(scene.type, `/laser/poi/${scene.m}/list`)
+    )
+      .then((res) => res.json())
+      .then((res) => res.data.list)
+      .then((pois) =>
+        pois.map((poi: any) => ({
+          url: poi.hotStyleAtom.icon,
+          position: poi.dataset_location,
+        }))
+      )
+      .catch(() => []);
+    return tags;
+  },
+  [SCENE_TYPE.mesh]: async (scene: Scene, options: string[]) => {
+    const tags: Tagings = [];
+    if (options.includes("hot")) {
+      const medias = await fetch(
+        getSceneApi(scene.type, `/scene_view_data/${scene.m}/user/hot.json`)
+      )
+        .then((res) => res.json())
+        .catch(() => []);
+
+      medias.forEach((media: any) => {
+        if (!validNum(media.position.x) || !validNum(media.position.y)) return;
+        tags.push({
+          url: getSceneApi(
+            scene.type,
+            `/v4-test/www/sdk/images/tag/${media.icon}`
+          ),
+          position: media.position,
+        });
+      });
+    }
+
+    if (options.includes("signage")) {
+      const signages = await fetch(
+        getSceneApi(
+          scene.type,
+          `/scene_view_data/${scene.m}/user/billboards.json`
+        )
+      )
+        .then((res) => res.json())
+        .catch(() => []);
+
+      signages.forEach((signage: any) => {
+        if (!validNum(signage.pos.x) || !validNum(signage.pos.y)) return;
+        tags.push({
+          url: getSceneApi(
+            scene.type,
+            `/v4-test/www/sdk/images/billboard/${signage.icon}.png`
+          ),
+          position: signage.pos,
+        });
+      });
+    }
+    return tags;
+  },
+};
+
+export type SceneFloor = {
+  name: string;
+  geos: (Pos & { z: number })[][];
+  thumb?: string;
+  box?: {
+    bound: {
+      x_min: number;
+      x_max: number;
+      y_min: number;
+      y_max: number;
+      z_min: number
+      z_max: number
+    };
+    rotate: number;
+    scale: number;
+  };
+};
+
+export type SceneFloors = SceneFloor[];
+
+export const lineGets = {
+  [SCENE_TYPE.fuse]: async (scene: Scene) => {
+    const tags = await taggingGets[SCENE_TYPE.fuse](scene, ["hot"]);
+    return { name: "1楼", geos: [tags.map((item) => item.position)] };
+  },
+  [SCENE_TYPE.mesh]: async (scene: Scene) => {
+    const url = getSceneApi(
+      scene.type,
+      `/scene_view_data/${scene.m}/data/floorplan_cad.json?_=${Date.now()}`
+    );
+
+    const { floors } = await fetch(url)
+      .then((res) => res.json())
+      .catch(() => ({ floors: [] }));
+    const data = floors.map((floor: any) => ({
+      name: floor.name,
+      thumb: `https://4dkk.4dage.com/scene_view_data/${scene.m}/images/floor_${floor.id}.png`,
+      box: {
+        bound: {
+          ...floor.cadInfo.cadBoundingBox,
+          y_min: -floor.cadInfo.cadBoundingBox.y_max,
+          y_max: -floor.cadInfo.cadBoundingBox.y_min,
+        },
+        rotate: floor.cadInfo.res,
+        scale: floor.cadInfo.currentScale,
+      },
+      geos: extractConnectedSegments(floor.segment).map((geo) =>
+        geo.map((id) => {
+          const p = floor["vertex-xy"].find((item: any) => item.id === id);
+          return { x: p.x, y: -p.y } as Pos;
+        })
+      ),
+    }));
+    console.log(data);
+    return data;
+  },
+  [SCENE_TYPE.cloud]: async (scene: Scene) => {
+    const tags = await taggingGets[SCENE_TYPE.cloud](scene, ["hot"]);
+    return { name: "1楼", geos: [tags.map((item) => item.position)] };
+  },
+};

+ 23 - 4
src/example/fuse/views/header/header.vue

@@ -21,7 +21,7 @@
         </span>
       </div>
       <div>
-        <span class="operate" @click="draw.history.clearCurrent()">
+        <span class="operate" @click="draw.store.clear()">
           清除<el-icon><Plus /></el-icon>
         </span>
         <span class="operate" @click="rotateView">
@@ -35,7 +35,7 @@
         </span>
       </div>
       <div>
-        <span class="operate">
+        <span class="operate" @click="aiHandler">
           ai导入<el-icon><Plus /></el-icon>
         </span>
         <span class="operate" @click="bgFileInput?.click()">
@@ -47,7 +47,7 @@
             @change="(ev: any) => setBGImage(ev.target.files[0])"
           />
         </span>
-        <span class="operate">
+        <span class="operate" @click="selectVRHandler">
           VR<el-icon><Plus /></el-icon>
         </span>
       </div>
@@ -67,11 +67,20 @@ import { ref } from "vue";
 import { Plus } from "@element-plus/icons-vue";
 import { Transform } from "konva/lib/Util";
 import { animation } from "@/core/hook/use-animation.ts";
+import { selectScene } from "@/example/fuse/dialog/vr/index.ts";
+import { selectAI } from "@/example/fuse/dialog/ai/index.ts";
+import { Scene } from "../../platform-resource.ts";
+import { drawPlatformResource } from "../../platform-draw.ts";
 
 const draw = useDraw();
 const bgFileInput = ref<HTMLInputElement | null>(null);
 
-const emit = defineEmits<{ (e: "full"): void; (e: "save"): any; (e: "expose"): void }>();
+const emit = defineEmits<{
+  (e: "full"): void;
+  (e: "save"): any;
+  (e: "expose"): void;
+  (e: "selectVR", v: Scene): void;
+}>();
 
 const setBGImage = (file: File) => {
   draw.addShape(
@@ -103,6 +112,16 @@ const initView = () => {
     draw.viewer.setViewMat(dec);
   });
 };
+
+const selectVRHandler = async () => {
+  const scene = await selectScene();
+  emit("selectVR", scene);
+};
+
+const aiHandler = async () => {
+  const aiData = await selectAI();
+  drawPlatformResource(aiData, draw);
+};
 </script>
 
 <style lang="scss" scoped>

+ 12 - 6
src/example/fuse/views/home.vue

@@ -1,25 +1,28 @@
 <template>
-  <BasemapMount />
   <div class="layout" :class="{ full }">
     <Header
       class="header"
       v-if="draw"
       @full="fullHandler"
+      @selectVR="(scene) => (vrScene = scene)"
       @save="() => draw && saveData(draw.getData())"
     />
     <div class="container">
       <Slide class="slide" v-if="draw" />
       <div class="content" ref="drawEle">
-        <DrawBoard v-if="drawEle && show" :handler-resource="uploadResourse" ref="draw" />
+        <DrawBoard v-if="drawEle" :handler-resource="uploadResourse" ref="draw" />
+        <ShowVR :scene="vrScene" v-if="vrScene" ref="vr" @close="vrScene = undefined" />
       </div>
     </div>
   </div>
+  <Dialog />
 </template>
 
 <script lang="ts" setup>
 import Header from "./header/header.vue";
+import ShowVR from "./show-vr.vue";
 import Slide from "./slide/slide.vue";
-import { Mount as BasemapMount } from "@/example/basemap";
+import Dialog from "../dialog/dialog.vue";
 import { onUnmounted, ref, watch } from "vue";
 import { DrawExpose, DrawBoard } from "@/index";
 import { installDraw } from "./use-draw.ts";
@@ -27,6 +30,7 @@ import { listener } from "@/utils/event.ts";
 import { ElMessage } from "element-plus";
 import { startAnimation } from "@/utils/shared.ts";
 import { saveData, getData, uploadResourse, getViewport } from "./req.ts";
+import { Scene } from "../platform-resource.ts";
 
 const drawEle = ref<HTMLDivElement | null>(null);
 const draw = ref<DrawExpose>();
@@ -40,11 +44,13 @@ const initData = async () => {
 };
 initData();
 
+const vr = ref();
 const full = ref(false);
 watch(full, (_f1, _f2, onCleanup) => {
   onCleanup(
     startAnimation(() => {
       draw.value?.updateSize();
+      vr.value?.refresh();
     }, 400)
   );
 });
@@ -60,7 +66,7 @@ onUnmounted(
   })
 );
 
-const show = ref(true);
+const vrScene = ref<Scene>();
 </script>
 
 <style lang="scss" scoped>
@@ -99,9 +105,9 @@ const show = ref(true);
   }
 
   .content {
-    width: calc(100% - 70px);
+    position: relative;
+    width: calc(100% - 70px - var(--left));
   }
-
   &.full {
     --top: calc(-1 * #{global.$headerSize});
     --left: calc(-1 * #{global.$slideSize});

+ 142 - 0
src/example/fuse/views/show-vr.vue

@@ -0,0 +1,142 @@
+<template>
+  <div class="vr" :style="{ width: width + 'px', height: height + 'px' }" ref="vrRef">
+    <div class="header" ref="headerRef">
+      <span>VR全景</span>
+      <el-icon class="close" @click="$emit('close')"><Close /></el-icon>
+    </div>
+    <div class="content" :style="{ pointerEvents: downPos ? 'none' : 'all' }">
+      <iframe :src="tempStrFill(SceneTypeTempUrls[scene.type], scene)" />
+      <el-icon class="full" @click="fullHandler"><FullScreen /></el-icon>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ElIcon } from "element-plus";
+import { Close, FullScreen } from "@element-plus/icons-vue";
+import { dragListener, listener } from "@/utils/event";
+import { onUnmounted, ref, watch, watchEffect } from "vue";
+import { Pos } from "@/utils/math";
+import { mergeFuns, tempStrFill } from "@/utils/shared";
+import { Scene, SceneTypeTempUrls } from "../platform-resource";
+
+defineProps<{ scene: Scene }>();
+defineEmits<{ (e: "close"): void }>();
+
+const headerRef = ref<HTMLDivElement>();
+const vrRef = ref<HTMLDivElement>();
+const width = ref(295);
+const height = ref(175);
+
+let isFull = false;
+const fullHandler = () => {
+  if (isFull) {
+    width.value /= 3;
+    height.value /= 3;
+  } else {
+    width.value *= 3;
+    height.value *= 3;
+  }
+  isFull = !isFull;
+};
+
+let unMoveHandler: () => void;
+const move = { x: 0, y: 0 };
+const downPos = ref<Pos>();
+const initMoveHandler = () => {
+  unMoveHandler && unMoveHandler();
+  const syncPosition = () => {
+    // move.x = Math.max(Math.min(move.x, 0), width.value - bound.width);
+    move.y = Math.min(Math.max(move.y, 0), bound.height - height.value);
+    move.x = Math.min(Math.max(move.x, 0), bound.width - width.value);
+    vrRef.value!.style.right = move.x + "px";
+    vrRef.value!.style.top = move.y + "px";
+  };
+
+  const parent = vrRef.value!.parentElement!;
+  const bound = {
+    x: 0,
+    y: 0,
+    width: parent.offsetWidth,
+    height: parent.offsetHeight,
+  };
+  syncPosition();
+
+  unMoveHandler = dragListener(headerRef.value!, {
+    down() {
+      downPos.value = { ...move };
+    },
+    move(info) {
+      move.x = downPos.value!.x + (info.start.x - info.end.x);
+      move.y = downPos.value!.y + (info.end.y - info.start.y);
+      syncPosition();
+    },
+    up() {
+      downPos.value = undefined;
+    },
+    attrib: "page",
+  });
+};
+
+watchEffect(() => {
+  if (headerRef.value && vrRef.value) {
+    setTimeout(initMoveHandler, 100);
+  }
+});
+watch([width, height], initMoveHandler);
+onUnmounted(
+  mergeFuns(
+    listener(window, "resize", initMoveHandler),
+    () => unMoveHandler && unMoveHandler()
+  )
+);
+defineExpose({ refresh: initMoveHandler });
+</script>
+
+<style lang="scss" scoped>
+.vr {
+  background: #fff;
+  position: absolute;
+  z-index: 1;
+  display: flex;
+  flex-direction: column;
+
+  .header {
+    height: 25px;
+    flex: 0 0 auto;
+    display: flex;
+    cursor: move;
+    align-items: center;
+    justify-content: space-between;
+    padding: 0 5px;
+    font-size: 14px;
+
+    .close {
+      color: #000;
+      font-size: 16px;
+      cursor: pointer;
+    }
+  }
+
+  .content {
+    flex: 1;
+    position: relative;
+    .full {
+      box-shadow: 0 0 5px #000;
+      right: 5px;
+      bottom: 5px;
+      color: #fff;
+      position: absolute;
+      cursor: pointer;
+      font-size: 18px;
+    }
+  }
+  iframe {
+    margin: 0;
+    padding: 0;
+    border: none;
+    width: 100%;
+    height: 100%;
+  }
+}
+</style>

+ 2 - 3
src/example/fuse/views/slide/handler-menu.ts

@@ -1,6 +1,6 @@
 import { v4 as uuid } from "uuid";
 import { Draw } from "../use-draw";
-import { getAMapInfo } from "@/example/basemap";
+import { getAMapInfo } from "@/example/fuse/dialog/basemap";
 import { getImageSize } from "@/utils/shape";
 
 const setPaper = (draw: Draw, p: number[], scale: number) => {
@@ -31,7 +31,6 @@ export const handlerMenus = [
         handler: async (draw: Draw) => {
           const info = await getAMapInfo()
           const size = await getImageSize(info.blob)
-          console.log(info.ratio)
           let proportion = { scale: info.ratio * 100, unit: "mm" }
           if (info.ratio > 1000) {
             proportion = { scale: info.ratio / 1000, unit: "km" }
@@ -46,11 +45,11 @@ export const handlerMenus = [
                 ...size,
                 url: URL.createObjectURL(info.blob),
                 zIndex: -1,
-                proportion: proportion,
               },
               { x: window.innerWidth / 2, y: window.innerHeight / 2 },
               true
             );
+            draw.store.setConfig({ proportion })
           })
         }
       }

+ 27 - 21
src/utils/align-port.ts

@@ -1,5 +1,6 @@
 import { Transform } from "konva/lib/Util";
-import { vector, Pos } from "./math.ts";
+import { Pos } from "./math.ts";
+import { IRect } from "konva/lib/types";
 
 /**
  * 创建对齐端口矩阵
@@ -10,27 +11,32 @@ import { vector, Pos } from "./math.ts";
  * @returns
  */
 export const alignPortMat = (
-	targetView: Pos[],
-	originView: Pos[],
-	retainScale = false,
-	padding: Pos | number = 0
+  selfBound: IRect,
+  targetBound: IRect,
+  retainScale = false,
 ) => {
-	padding = typeof padding === "number" ? { x: padding, y: padding } : padding;
 
-	const pad = vector(padding);
-	const real = vector(originView[1]).sub(originView[0]);
-	const screen = vector(targetView[1]).sub(targetView[0]);
-	const scale = screen.clone().sub(pad.multiplyScalar(2)).divide(real);
+  const scale = {
+    x: (selfBound.width ) / targetBound.width,
+    y: (selfBound.height ) / targetBound.height,
+  };
+  const offset = {
+    x: selfBound.x - targetBound.x, 
+    y: selfBound.y - targetBound.y
+  }
+  
+  // 选择较小的比例以保持内容比例
+  if (retainScale) {
+    if (scale.x > scale.y) {
+      scale.x = scale.y
+      offset.x += ((selfBound.width - targetBound.width * scale.x) / scale.x) / 2
+    } else if (scale.y > scale.x) {
+      scale.y = scale.x
+      offset.y += ((selfBound.height - targetBound.height * scale.y) / scale.y) / 2
+    }
+  }
 
-	if (retainScale) {
-		scale.x = scale.y = Math.min(scale.x, scale.y); // 选择较小的比例以保持内容比例
-	}
-
-	const offset = screen
-		.clone()
-		.sub(real.clone().multiply(scale))
-		.divideScalar(2)
-		.sub(real.clone().multiply(scale));
-
-	return new Transform().translate(offset.x, offset.y).scale(scale.x, scale.y);
+  return new Transform()
+    .scale(scale.x, scale.y)
+    .translate(offset.x, offset.y);
 };

+ 9 - 5
src/utils/event.ts

@@ -55,12 +55,14 @@ type DragProps = {
 	move?: (info: Record<'start' | 'prev' | 'end', Pos> & {ev: PointerEvent}) => void,
 	down?: (pos: Pos, ev: PointerEvent) => void,
 	up?: (pos: Pos, ev: PointerEvent) => void,
-	notPrevent?: boolean
+	notPrevent?: boolean,
+	attrib?: 'offset' | 'page'
 }
 export const dragListener = (dom: HTMLElement, props: DragProps | DragProps['move'] = {}) => {
 	if (typeof props === 'function') {
-		props = { move: props }
+		props = { move: props, }
 	}
+	const attrib = props.attrib || 'offset'
 	const { move, up, down } = props
 	const mount = document.documentElement
 
@@ -68,7 +70,7 @@ export const dragListener = (dom: HTMLElement, props: DragProps | DragProps['mov
 
 	let moveHandler: any, endHandler: any
 	const downHandler = (ev: PointerEvent) => {
-		const start = getOffset(ev, dom)
+		const start = attrib === 'offset' ? getOffset(ev, dom) : { x: ev.pageX, y: ev.pageY }
 		let prev = start
 		down && down(start, ev)
 		props.notPrevent || ev.preventDefault();
@@ -78,17 +80,19 @@ export const dragListener = (dom: HTMLElement, props: DragProps | DragProps['mov
         endHandler()
         return;
       }
-			const end = getOffset(ev, dom)
+			const end = attrib === 'offset' ? getOffset(ev, dom) : { x: ev.pageX, y: ev.pageY }
 			move!({start, end, prev, ev})
 			prev = end
 
 			props.notPrevent || ev.preventDefault();
 		}
 		endHandler = (ev: PointerEvent) => {
-			up && up(getOffset(ev, dom), ev)
+			const uend = attrib === 'offset' ? getOffset(ev, dom) : { x: ev.pageX, y: ev.pageY }
+			up && up(uend, ev)
 			mount.removeEventListener('pointermove', moveHandler);
 			mount.removeEventListener('pointerup', endHandler);
 			props.notPrevent || ev.preventDefault();
+			moveHandler = endHandler = undefined
 		}
 	
 		

+ 120 - 0
src/utils/polygon.ts

@@ -0,0 +1,120 @@
+/**
+ * 多条选段 由点id组合 确定多边形
+ * @param segments 
+ * @returns 
+ */
+export function extractConnectedSegments(
+  segments: { a: number; b: number }[]
+): number[][] {
+  // 1. 构建无向图邻接表
+  const graph: Record<number, number[]> = {};
+  for (const { a, b } of segments) {
+    if (!graph[a]) graph[a] = [];
+    if (!graph[b]) graph[b] = [];
+    graph[a].push(b);
+    graph[b].push(a);
+  }
+
+  // 2. 边访问记录
+  const visitedEdges = new Set<string>();
+  const edgeKey = (a: number, b: number) => (a < b ? `${a},${b}` : `${b},${a}`);
+
+  // 3. 结果收集
+  const result: number[][] = [];
+  const polygonSet = new Set<string>();
+
+  // 4. 多边形标准化函数(按点集合的排序字符串标识)
+  const normalizePolygon = (polygon: number[]) => {
+    const points = [...new Set(polygon.slice(0, -1))].sort((a, b) => a - b);
+    return JSON.stringify(points);
+  };
+
+  // 5. 查找闭合环的迭代DFS实现
+  function findPolygons(start: number) {
+    const stack: {
+      current: number;
+      path: number[];
+      visited: Set<string>;
+    }[] = [{ current: start, path: [start], visited: new Set() }];
+
+    while (stack.length > 0) {
+      const { current, path, visited } = stack.pop()!;
+
+      for (const neighbor of graph[current]) {
+        const edge = edgeKey(current, neighbor);
+
+        // 跳过已访问的边
+        if (visited.has(edge)) continue;
+
+        // 发现闭合环
+        if (neighbor === start && path.length > 2) {
+          const closedPath = [...path, start];
+          const polyKey = normalizePolygon(closedPath);
+
+          if (!polygonSet.has(polyKey)) {
+            polygonSet.add(polyKey);
+            result.push(closedPath);
+          }
+          continue;
+        }
+
+        // 避免重复访问点(起点除外)
+        if (neighbor !== start && path.includes(neighbor)) continue;
+
+        const newVisited = new Set(visited);
+        newVisited.add(edge);
+        stack.push({
+          current: neighbor,
+          path: [...path, neighbor],
+          visited: newVisited,
+        });
+      }
+    }
+  }
+
+  // 6. 查找所有闭合环
+  for (const node in graph) {
+    const nodeId = Number(node);
+    if (graph[nodeId].length >= 2) {
+      // 至少需要两个连接才能形成环
+      findPolygons(nodeId);
+    }
+  }
+
+  // 7. 查找开放路径
+  const visitedNodes = new Set<number>();
+  for (const node in graph) {
+    const nodeId = Number(node);
+    if (visitedNodes.has(nodeId)) continue;
+
+    // 开放路径必须从端点开始(度数为1的点)
+    if (graph[nodeId].length === 1) {
+      const path: number[] = [];
+      let current: number | null = nodeId;
+      let prev: number | null = null;
+
+      while (current !== null) {
+        visitedNodes.add(current);
+        path.push(current);
+
+        // 找到下一个未访问的相邻点
+        let next: number | null = null;
+        for (const neighbor of graph[current]) {
+          if (neighbor !== prev && !visitedNodes.has(neighbor)) {
+            next = neighbor;
+            break;
+          }
+        }
+
+        prev = current;
+        current = next;
+      }
+
+      if (path.length >= 2) {
+        result.push(path);
+      }
+    }
+  }
+
+  return result;
+}

+ 55 - 0
src/utils/shared.ts

@@ -328,3 +328,58 @@ export const analysisGPS = (temp: string) => {
     }
   }
 };
+
+export const tempStrFill = (tempStr: string, fill: Record<string, any>) => {
+  const regex = /\{(.*?)\}/g;
+  let str = "";
+  let ndx = 0;
+  let matches;
+  while ((matches = regex.exec(tempStr)) !== null) {
+    if (!(matches[1] in fill)) continue;
+
+    str += tempStr.substring(ndx, matches.index) + fill[matches[1]];
+    ndx = matches.index + matches[0].length;
+  }
+  str += tempStr.substring(ndx, tempStr.length);
+  return str;
+};
+
+export const genBound = () => {
+  let seted = false;
+  let minX = Number.MAX_VALUE,
+    minY = Number.MAX_VALUE,
+    maxX = Number.MIN_VALUE,
+    maxY = Number.MIN_VALUE;
+
+  return {
+    update(points: Pos | Pos[]) {
+      console.log(points)
+      points = Array.isArray(points) ? points : [points];
+      points.forEach((pos) => {
+        seted = true;
+        minX = Math.min(pos.x, minX);
+        minY = Math.min(pos.y, minY);
+        maxX = Math.max(pos.x, maxX);
+        maxY = Math.max(pos.y, maxY);
+      });
+    },
+    get() {
+      if (!seted) return null;
+      return {
+        x: minX,
+        y: minY,
+        width: maxX - minX,
+        height: maxY - minY,
+        maxX,
+        maxY,
+        center: {
+          x: (minX + maxX) / 2,
+          y: (minY + maxY) / 2,
+        },
+      };
+    },
+  };
+};
+
+
+export const validNum = (num: any) => typeof num === 'number' && !Number.isNaN(num)

+ 8 - 0
src/vite-env.d.ts

@@ -2,6 +2,14 @@
 
 interface ImportMetaEnv {
   readonly VITE_PRIMARY: string
+  readonly VITE_MESH_TEMP_URL: string
+  readonly VITE_CLOUD_TEMP_URL: string
+  readonly VITE_FUSE_TEMP_URL: string
+
+  readonly VITE_MESH_OSS_URL: string
+  readonly VITE_MESH_API_URL: string
+  readonly VITE_CLOUD_API_URL: string
+  readonly VITE_FUSE_API_URL: string
 }
 
 interface ImportMeta {

+ 19 - 0
vite.config.ts

@@ -8,6 +8,24 @@ import { version } from './package.json'
 export default ({ mode }: any) => {
 	const env = loadEnv(mode, process.cwd())
 
+  let proxy
+  if (mode === 'development') {
+    const apiEnv = loadEnv(env.VITE_TARGET, process.cwd())
+    const getProxy = (prev: string, api: string) => ({
+      target: api,
+      changeOrigin: true,
+      rewrite: (path: any) => path.replace(prev, '')
+    })
+
+    proxy = {
+      [env.VITE_MESH_OSS_URL]: getProxy(env.VITE_MESH_OSS_URL, apiEnv.VITE_MESH_OSS_URL),
+      [env.VITE_MESH_API_URL]: getProxy(env.VITE_MESH_API_URL, apiEnv.VITE_MESH_API_URL),
+      [env.VITE_CLOUD_API_URL]: getProxy(env.VITE_CLOUD_API_URL, apiEnv.VITE_CLOUD_API_URL),
+      [env.VITE_FUSE_API_URL]: getProxy(env.VITE_FUSE_API_URL, apiEnv.VITE_FUSE_API_URL),
+    }
+    console.log(proxy)
+  }
+
   return defineConfig({
     resolve: {
       alias: {
@@ -34,6 +52,7 @@ export default ({ mode }: any) => {
       port: 9010,
       open: true,
       host: "0.0.0.0",
+      proxy: proxy,
     },
     plugins: [
       createHtmlPlugin({