bill 4 týždňov pred
rodič
commit
944747dabc

+ 1 - 1
.env.fuse

@@ -6,7 +6,7 @@ VITE_MESH_OSS='https://4dkk.4dage.com/'
 VITE_MESH_API='https://www.4dkankan.com/'
 VITE_CLOUD_API='https://laser.4dkankan.com/backend/'
 VITE_FUSE_API='https://mix3d.4dkankan.com/'
-VITE_MESH_VIEW='https://www.4dkankan.com/spg.html?m={m}&lang=zh'
+VITE_MESH_VIEW='./static/kankan.html?m={m}&lang=zh&host=https://4dkk.4dage.com/v4/www&server=https://www.4dkankan.com&oss=https://4dkk.4dage.com/'
 VITE_CLOUD_VIEW='https://laser.4dkankan.com/index.html?m={m}&lang=zh'
 VITE_FUSE_VIEW='https://mix3d.4dkankan.com/'
 VITE_LOGIN_VIEW='https://test-mix3d.4dkankan.com/fire/?redirect={redirect}#login'

+ 1 - 1
.env.fusedev

@@ -9,7 +9,7 @@ VITE_MESH_API='/meshAPI/'
 VITE_CLOUD_API='/cloudAPI/'
 VITE_FUSE_API='/fuseAPI/'
 
-VITE_MESH_VIEW='https://test.4dkankan.com/spg.html?m={m}&lang=zh'
+VITE_MESH_VIEW='./static/kankan.html?m={m}&lang=zh&host=https://4dkk.4dage.com/v4-test/www&server=https://test.4dkankan.com&oss=https://4dkk.4dage.com/'
 VITE_CLOUD_VIEW='https://uat-laser.4dkankan.com/uat/index.html?m={m}&lang=zh'
 VITE_FUSE_VIEW='https://test-mix3d.4dkankan.com/'
 VITE_LOGIN_VIEW='https://test-mix3d.4dkankan.com/fire/?redirect={redirect}#login'

+ 1 - 1
.env.fusetest

@@ -9,7 +9,7 @@ VITE_MESH_API='https://test.4dkankan.com/'
 VITE_CLOUD_API='https://uat-laser.4dkankan.com/uat/'
 VITE_FUSE_API='https://test-mix3d.4dkankan.com/'
 
-VITE_MESH_VIEW='https://test.4dkankan.com/spg.html?m={m}&lang=zh'
+VITE_MESH_VIEW='./static/kankan.html?m={m}&lang=zh&host=https://4dkk.4dage.com/v4-test/www&server=https://test.4dkankan.com&oss=https://4dkk.4dage.com/'
 VITE_CLOUD_VIEW='https://uat-laser.4dkankan.com/uat/index.html?m={m}&lang=zh'
 VITE_FUSE_VIEW='https://test-mix3d.4dkankan.com/'
 VITE_LOGIN_VIEW='https://test-mix3d.4dkankan.com/fire/?redirect={redirect}#login'

+ 1 - 1
.env.jmdev

@@ -13,7 +13,7 @@ VITE_MESH_API='/meshAPI/'
 VITE_CLOUD_API='/cloudAPI/'
 VITE_FUSE_API='/fuseAPI/'
 
-VITE_MESH_VIEW='./static/kankan.html?m={m}&lang=zh&token={token}&host=https://survey.4dkankan.com'
+VITE_MESH_VIEW='./static/kankan.html?m={m}&lang=zh&token={token}&host=https://4dkk.4dage.com/v4-test/www/'
 VITE_CLOUD_VIEW='https://survey.4dkankan.com/swss/index.html?m={m}&lang=zh&token={token}'
 VITE_FUSE_VIEW='https://survey.4dkankan.com/code/'
 VITE_LOGIN_VIEW='https://survey.4dkankan.com/admin/index.html#/login?redirect={redirect}'

+ 5 - 3
public/static/kankan.html

@@ -124,6 +124,8 @@
   <script>
     const params = new URLSearchParams(location.search)
     let host = params.get('host') || ''
+    let server = params.get('server') || host
+    let oss = params.get('oss') || host + '/oss/'
 
     const loadLib = (url) => {
       const $script = document.createElement('script')
@@ -204,8 +206,8 @@
       kankan = new KanKan({
         dom: '#scene',
         num: params.get('m'),
-        server: host,
-        resource: host + '/oss/'
+        server: server,
+        resource: oss
       })
       kankan.render()
 
@@ -230,7 +232,7 @@
     const init = async () => {
       await loadLib(`/sdk/kankan-sdk-deps.js`)
       await loadLib(`/sdk/kankan-sdk.js`)
-      await setTimeout(() => {}, 300)
+      await setTimeout(() => { }, 300)
       initKankan()
     }
     init()

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

@@ -35,7 +35,7 @@ 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 } 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 { useForciblyShowItemIds, useStage } from "@/core/hook/use-global-vars.ts";

+ 2 - 2
src/core/components/line-icon/icon.vue

@@ -203,8 +203,8 @@ watch(
   },
   { immediate: true, flush: "post" }
 );
-
-if (props.data.type === "align-bottom") {
+operateMenus.splice(3, 1);
+if (props.data.type === "align-bottom" || props.data.type === "align-bottom-fix") {
   operateMenus.splice(
     operateMenus.length - 1,
     0,

+ 2 - 2
src/core/components/line/single-line.vue

@@ -25,7 +25,7 @@
   <component
     v-if="renderer"
     :is="renderer?.Component"
-    :opacity="drawProps ? 0.7 : 1"
+    :opacity="drawProps ? 0.2 : 1"
     :stroke="isDrawIng ? themeColor : style.stroke"
     :getShapeAttrib="getShapeAttrib"
     :data="data"
@@ -83,7 +83,7 @@
 <script lang="ts" setup>
 import EditLine from "../share/edit-line.vue";
 import singlePoint from "./single-point.vue";
-import { computed, ref, watchEffect } from "vue";
+import { computed, onUnmounted, ref, watchEffect } from "vue";
 import { getMouseStyle, LineData, LineDataLine, shapeName, renderer } from "./index.ts";
 import { onlyId } from "@/utils/shared.ts";
 import { Pos } from "@/utils/math.ts";

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

@@ -218,7 +218,9 @@ export const useComponentDescribes = <T extends { id: string }>(
   });
   watchEffect((onCleanup) => {
     gdescs.set(data.value, descs.value);
-    onCleanup(() => gdescs.del(data.value.id));
+    onCleanup(() => {
+      gdescs.del(data.value.id)
+    });
   });
   watch(
     descs,

+ 14 - 1
src/core/hook/use-group.ts

@@ -5,14 +5,27 @@ import { inRevise, mergeFuns } from "@/utils/shared";
 
 export const useGlobalDescribes = installGlobalVar(() => {
   const shapesDescribes: Record<string, PropertyDescribes> = reactive({});
+  const shapesSetCount: Record<string, number> = {};
   const data: Record<string, { id: string }> = reactive({});
   return {
     set(item: { id: string }, descs: PropertyDescribes) {
+      if (item.id in shapesSetCount) {
+        shapesSetCount[item.id]++
+      } else {
+        shapesSetCount[item.id] = 1
+      }
       shapesDescribes[item.id] = descs;
       data[item.id] = item;
     },
     del(id: string) {
-      delete shapesDescribes[id];
+      if (id in shapesSetCount) {
+        shapesSetCount[id]--
+      }
+      if (shapesSetCount[id] === 0) {
+        delete shapesDescribes[id];
+        delete shapesSetCount[id]
+        delete data[id]
+      }
     },
     get(id: string) {
       return (

+ 29 - 3
src/core/renderer-three/components/line-icon/index.vue

@@ -7,26 +7,30 @@ import {
   LineIconData,
 } from "@/core/components/line-icon";
 import { useRender, useStageProps, useTree } from "../../hook/use-stage";
-import { fullMesh, getLevel, getModel } from "../resource";
-import { Group, Matrix4 } from "three";
-import { computed, ref, watch, watchEffect } from "vue";
+import { fullMesh, getLevel, getModel, getModelSwitch, SwitchResult } from "../resource";
+import { Group, Matrix4, Object3D } from "three";
+import { computed, ref, shallowRef, watch, watchEffect } from "vue";
 import { lineCenter, lineVector, vector2IncludedAngle } from "@/utils/math";
 import { setMat } from "../../util";
+import { useInteraction } from "../../hook/use-event";
 
 const props = defineProps<{ data: LineIconData }>();
 const render = useRender();
 
 const group = new Group();
+const model = shallowRef<Object3D>();
 watch(
   () => props.data.url,
   async (type, _, onCleanup) => {
     let typeModel = await getModel(type);
     if (typeModel && type === props.data.url) {
       typeModel = typeModel.clone();
+      model.value = typeModel;
       group.add(typeModel);
       render();
       onCleanup(() => {
         group.remove(typeModel!);
+        model.value = undefined;
         render();
       });
     }
@@ -122,5 +126,27 @@ watch(
   { immediate: true }
 );
 
+const modelSwitchFactory = computed(() => getModelSwitch(props.data.url));
+let stopAnimation: SwitchResult | null = null;
+if (modelSwitchFactory.value) {
+  const modelSwitch = computed(
+    () => model.value && modelSwitchFactory.value(model.value, render)
+  );
+  let open = false;
+  useInteraction(
+    group,
+    () => {
+      if (modelSwitch.value) {
+        stopAnimation && stopAnimation();
+        stopAnimation = modelSwitch.value!((open = !open));
+        stopAnimation.promise.finally(() => {
+          stopAnimation = null;
+        });
+      }
+    },
+    "dblclick"
+  );
+}
+
 useTree().value = group;
 </script>

+ 32 - 14
src/core/renderer-three/components/line/single-line.vue

@@ -8,8 +8,8 @@ import {
   Color,
   DoubleSide,
   ExtrudeGeometry,
+  Group,
   Mesh,
-  MeshPhongMaterial,
   Shape,
 } from "three";
 import { computed, onUnmounted, Ref, ref, watch, watchEffect } from "vue";
@@ -32,15 +32,16 @@ const polygon = computed(() => props.getExtendPolygon(props.line));
 const points = computed(() => getLinePoints(props.data, props.line));
 const gd = useDrawHook(() => useGetDiffLineIconPolygons(props.line, points));
 const polygons = computed(() => gd.diff(polygon.value));
-const geometry = ref() as Ref<BufferGeometry>;
+const wallGeo = ref() as Ref<BufferGeometry>;
+const skirtingGeo = ref() as Ref<BufferGeometry>;
+const skirtingHeight = 20
 const sProps = useStageProps();
 
 watch(
   polygons,
   debounce(() => {
-    if (geometry.value) {
-      geometry.value.dispose();
-    }
+    wallGeo.value && wallGeo.value.dispose();
+    skirtingGeo.value && skirtingGeo.value.dispose();
     const polyGeos = polygons.value.map((poly) => {
       const shape = new Shape();
       shape.moveTo(poly[0].x, poly[0].y);
@@ -48,17 +49,29 @@ watch(
         shape.lineTo(poly[i].x, poly[i].y);
       }
       shape.lineTo(poly[poly.length - 1].x, poly[poly.length - 1].y);
-      const geo = new ExtrudeGeometry(shape, {
+      const wallGeo = new ExtrudeGeometry(shape, {
         depth: sProps.value.height,
         bevelEnabled: false,
         steps: 1,
       });
-      return geo;
+      const skirtingGeo = new ExtrudeGeometry(shape, {
+        depth: skirtingHeight,
+        bevelEnabled: false,
+        steps: 1,
+      });
+      return {wall: wallGeo, skirting: skirtingGeo};
     });
 
-    geometry.value = BufferGeometryUtils.mergeGeometries(polyGeos);
-    geometry.value.rotateX(Math.PI / 2).translate(0, sProps.value.height, 0);
-    polyGeos.forEach((geo) => geo.dispose());
+    wallGeo.value = BufferGeometryUtils.mergeGeometries(polyGeos.map(item => item.wall));
+    wallGeo.value.rotateX(Math.PI / 2).translate(0, sProps.value.height, 0);
+
+    skirtingGeo.value = BufferGeometryUtils.mergeGeometries(polyGeos.map(item => item.skirting));
+    skirtingGeo.value.rotateX(Math.PI / 2).translate(0, skirtingHeight, 0);
+
+    polyGeos.forEach((geo) => {
+      geo.wall.dispose()
+      geo.skirting.dispose()
+    });
   }),
   { immediate: true }
 );
@@ -73,11 +86,12 @@ watchEffect(() => {
   render();
 });
 
+const group = new Group();
 const mesh = new Mesh(undefined, material);
 mesh.castShadow = true;
 mesh.receiveShadow = true;
 watchEffect(() => {
-  mesh.geometry = geometry.value;
+  mesh.geometry = wallGeo.value;
   render();
 });
 
@@ -86,10 +100,14 @@ onUnmounted(() => {
   mesh.geometry?.dispose();
 });
 
-const tree = useTree();
 watchEffect(() => {
-  if (geometry.value) {
-    tree.value = mesh;
+  if (wallGeo.value) {
+    group.add(mesh);
+  } else {
+    group.remove(mesh);
   }
 });
+
+const tree = useTree();
+tree.value = group;
 </script>

+ 241 - 4
src/core/renderer-three/components/resource.ts

@@ -1,14 +1,15 @@
+import { animation } from "@/core/hook/use-animation";
 import {
   Box3,
   BoxGeometry,
   Color,
-  DirectionalLight,
   DoubleSide,
   Mesh,
   MeshPhongMaterial,
   MeshPhysicalMaterial,
   MeshStandardMaterial,
   Object3D,
+  Quaternion,
   Vector3,
 } from "three";
 import { GLTFLoader } from "three/examples/jsm/Addons.js";
@@ -48,6 +49,58 @@ const resources: Record<string, () => Promise<Object3D>> = {
     return await normalized(gltf.scene);
   },
   "piaochuang.svg": async () => {
+    const gltf = await gltfLoader.loadAsync("bay_window/scene.gltf");
+    gltf.scene.scale.setX(-1);
+    const names = ["01_glass_0", "02_glass_0", "03_glass_0", "04_glass_0"]
+    gltf.scene.traverse((node: any) => {
+      if (names.includes(node.name)) {
+        node.material = new MeshPhysicalMaterial({
+          color: 0xffffff, // 浅灰色(可根据需求调整,如0xcccccc)
+          metalness: 0.1, // 轻微金属感(增强反射)
+          roughness: 0.01, // 表面光滑度(0-1,越小越光滑)
+          transmission: 1, // 透光率(模拟玻璃透光,需环境光遮蔽和光源支持)
+          opacity: 1, // 透明度(与transmission配合使用)
+          transparent: true, // 启用透明
+          side: DoubleSide, // 双面渲染(玻璃通常需要)
+          ior: 0, // 折射率(玻璃约为1.5)
+          clearcoat: 0.5, // 可选:表面清漆层(增强反光)
+        });
+      }
+    })
+    let copyModel: Object3D
+    let parent: Object3D
+    gltf.scene.traverse((node: any) => {
+      if (node.name === "01") {
+        copyModel = node.clone()
+        parent = node.parent
+      }
+    })
+
+    copyModel!.scale.add({x: 0, y: 0.06, z: -0.1})
+    const left = copyModel!.clone()
+    left.name = "05"
+    left.rotation.y = -Math.PI / 2
+    left.position.set(-170, 0, 50)
+    parent!.add(left)
+
+
+    const right = copyModel!
+    right.name = "06"
+    right.rotation.y = Math.PI / 2
+    right.position.set(170, 0, 50)
+    parent!.add(right)
+    
+    const model = await normalized(gltf.scene);
+    // model.scale.add(({x: 0.015, y: 0, z: 0}))
+    // model.position.add({x: 0, y: 0, z: 0})
+    left.scale.add({x: -0.3, y: 0, z: 0})
+    left.position.add({x: -7, y: 0, z: -16})
+
+    right.scale.add({x: -0.3, y: 0, z: 0})
+    right.position.add({x: 7, y: 0, z: -16})
+    return model
+  },
+  "piaochuang1.svg": async () => {
     const gltf = await gltfLoader.loadAsync("window_1/scene.gltf");
     gltf.scene.rotateY(Math.PI);
     gltf.scene.traverse((node: any) => {
@@ -143,7 +196,7 @@ const resources: Record<string, () => Promise<Object3D>> = {
     models.forEach((m) => m.parent?.remove(m));
 
     const model = await normalized(gltf.scene);
-    model.position.setY(model.position.y - 0.131);
+    model.position.setY(model.position.y);
     return model;
   },
   "SingleBed.svg": async () => {
@@ -238,7 +291,7 @@ const resources: Record<string, () => Promise<Object3D>> = {
   "Chair.svg": async () => {
     const gltf = await gltfLoader.loadAsync("psx_chair/scene.gltf");
     const model = await normalized(gltf.scene, undefined);
-    model.scale.add({ x: 0, y: 0.3, z: 0 });
+    model.position.add({x: 0, y: -0.1, z: 0})
     return model;
   },
   "TV.svg": async () => {
@@ -281,6 +334,7 @@ const resources: Record<string, () => Promise<Object3D>> = {
     const gltf = await gltfLoader.loadAsync(
       "low_poly_bedside_table/scene.gltf"
     );
+    gltf.scene.rotateY(Math.PI);
     const model = await normalized(gltf.scene, undefined);
     model.traverse((child: any) => {
       if (child.isMesh) {
@@ -338,7 +392,7 @@ const resources: Record<string, () => Promise<Object3D>> = {
     const model = await normalized(gltf.scene);
     model.traverse((child: any) => {
       if (child.isMesh) {
-        child.material.emissive = new Color(0x222222)
+        child.material.emissive = new Color(0x222222);
       }
     });
     return model;
@@ -417,6 +471,189 @@ export const levelResources: Record<
   },
 };
 
+export type ModelSwitch = (open: boolean) => ReturnType<typeof animation>;
+export type SwitchResult = ReturnType<ModelSwitch>;
+export const switchResources: Record<
+  string,
+  (model: Object3D, render: () => void) => ModelSwitch
+> = {
+  "men_l.svg": (model, render) => {
+    let node: Object3D;
+    model.traverse((child) => {
+      if (child.name === "Plane001") {
+        node = child;
+      }
+    });
+
+    return (open: boolean) =>
+      animation(
+        { z: node!.rotation.z },
+        { z: open ? -Math.PI / 2 : 0 },
+        (data) => {
+          node.rotation.z = data.z;
+          render();
+        }
+      );
+  },
+
+  "luodichuang.svg": (model, render) => {
+    let nodes: Object3D[] = [];
+    const initVals = [121.44782257080078, -121.4478];
+    const finalVals = [58, -58];
+    model.traverse((child) => {
+      if (child.name === "01") {
+        nodes[0] = child;
+      } else if (child.name === "04") {
+        nodes[1] = child;
+      }
+    });
+
+    return (open: boolean) =>
+      animation(
+        nodes.map((node) => node.position.x),
+        open ? finalVals : initVals,
+        (data) => {
+          nodes.forEach((node, i) => (node.position.x = data[i]));
+          render();
+        }
+      );
+  },
+  "chuang.svg": (model, render) => {
+    let nodes: Object3D[] = [];
+    model.traverse((child) => {
+      if (child.name === "L") {
+        nodes[0] = child;
+      } else if (child.name === "R") {
+        nodes[1] = child;
+      }
+    });
+    const initVals = nodes.map((item) => ({
+      position: item.position.clone(),
+      quat: item.quaternion.clone(),
+    }));
+    const changes = [
+      { origin: new Vector3(108, 0, 0), angle: -Math.PI / 3 },
+      { origin: new Vector3(-108, 0, 0), angle: Math.PI / 3 },
+    ];
+    const finalVals = initVals.map((item, i) => {
+      const { origin, angle } = changes[i];
+      const qua = new Quaternion().setFromAxisAngle(
+        { x: 0, y: 1, z: 0 },
+        angle
+      );
+      const finalPosition = item.position
+        .clone()
+        .sub(origin)
+        .applyQuaternion(qua)
+        .add(origin);
+      const finalQua = item.quat.clone().multiply(qua);
+      return {
+        position: finalPosition,
+        quat: finalQua,
+      };
+    });
+
+    return (open: boolean) =>
+      animation(
+        nodes.map((node) => ({
+          position: node.position,
+          quat: node.quaternion,
+        })),
+        open ? finalVals : initVals,
+        (data) => {
+          nodes.forEach((node, i) => {
+            node.position.copy(data[i].position);
+            node.quaternion.copy(data[i].quat);
+          });
+          render();
+        }
+      );
+  },
+  "yimen.svg": (model, render) => {
+    let node: Object3D;
+    model.traverse((child) => {
+      if (child.name === "16668_84x96_Slider_Door-Black_V1001_0") {
+        node = child;
+      }
+    });
+
+    const initVal = node!.position.x;
+    const finalVal = initVal - 100;
+    return (open: boolean) =>
+      animation(
+        { x: node.position.x },
+        { x: open ? finalVal : initVal },
+        (data) => {
+          node.position.setX(data.x);
+          render();
+        }
+      );
+  },
+
+  "piaochuang.svg": (model, render) => {
+    let nodes: Object3D[] = [];
+    const names = ["01", "02", "03", "04"]
+    model.traverse((child) => {
+      if (names.includes(child.name)) {
+        nodes.push(child)
+      }
+    });
+
+    const initVals = nodes.map((item) => ({
+      position: item.position.clone(),
+      quat: item.quaternion.clone(),
+    }));
+    const changes = [
+      { origin: new Vector3(80, 0, 0), angle: Math.PI / 3 },
+      { origin: new Vector3(80, 0, 0), angle: -Math.PI / 3 },
+      { origin: new Vector3(-80, 0, 0), angle: Math.PI / 3 },
+      { origin: new Vector3(-80, 0, 0), angle: -Math.PI / 3 },
+    ];
+    const finalVals = initVals.map((item, i) => {
+      const { origin, angle } = changes[i];
+      const qua = new Quaternion().setFromAxisAngle(
+        { x: 0, y: 1, z: 0 },
+        angle
+      );
+      const finalPosition = item.position
+        .clone()
+        .sub(origin)
+        .applyQuaternion(qua)
+        .add(origin);
+      const finalQua = item.quat.clone().multiply(qua);
+      return {
+        position: finalPosition,
+        quat: finalQua,
+        // quat: item.quat
+      };
+    });
+
+    return (open: boolean) =>
+      animation(
+        nodes.map((node) => ({
+          position: node.position,
+          quat: node.quaternion,
+        })),
+        open ? finalVals : initVals,
+        (data) => {
+          nodes.forEach((node, i) => {
+            node.position.copy(data[i].position);
+            node.quaternion.copy(data[i].quat);
+          });
+          render();
+        }
+      );
+  },
+};
+
+export const getModelSwitch = (type: string) => {
+  const ndx = type.lastIndexOf("/");
+  if (~ndx) {
+    type = type.substring(ndx + 1);
+  }
+  return switchResources[type];
+};
+
 export const getLevel = (type: string, fullHeight: number) => {
   const ndx = type.lastIndexOf("/");
   if (~ndx) {

+ 5 - 23
src/core/renderer-three/components/text/index.vue

@@ -13,18 +13,12 @@ import {
   PlaneGeometry,
   Vector3,
 } from "three";
-import {
-  useCamera,
-  useCursor,
-  useRender,
-  useStageProps,
-  useTree,
-} from "../../hook/use-stage";
+import { useCamera, useRender, useStageProps, useTree } from "../../hook/use-stage";
 import { TextData } from "@/core/components/text";
 import { computed, onUnmounted, ref, watch, watchEffect } from "vue";
 import { Transform } from "konva/lib/Util";
 import { Size } from "@/utils/math";
-import { useHoverEventRegister, useMouseEventRegister } from "../../hook/use-event";
+import { useInteraction } from "../../hook/use-event";
 import { useFlyRoaming } from "../../hook/use-controls";
 
 const props = defineProps<{ data: TextData }>();
@@ -115,22 +109,10 @@ const material = new MeshBasicMaterial({
 });
 const text = new Mesh(geo, material);
 
-const hoverRegister = useHoverEventRegister();
-const cursor = useCursor();
-let leave: () => void;
-hoverRegister(text, (hover) => {
-  if (hover) {
-    leave = cursor.push("pointer");
-  } else {
-    leave && leave();
-  }
-});
-
 const flyRoaming = useFlyRoaming();
-const mouseRegister = useMouseEventRegister();
-mouseRegister(text, "click", ({ point }) => {
-  flyRoaming(point.clone().setY(0));
-});
+useInteraction(text, ({ point }) =>
+  flyRoaming(new Vector3(point.x, sProps.value.height, point.z))
+);
 
 const sProps = useStageProps();
 const dec = computed(() => new Transform(props.data.mat).decompose());

+ 2 - 1
src/core/renderer-three/container.ts

@@ -1 +1,2 @@
-export const subgroupName = 'subgroup'
+export const subgroupName = 'subgroup'
+export const ceilingName = 'ceiling'

+ 32 - 0
src/core/renderer-three/env/ceiling.vue

@@ -0,0 +1,32 @@
+<template></template>
+
+<script lang="ts" setup>
+import { BackSide, Group, Mesh, MeshPhongMaterial, PlaneGeometry } from "three";
+import { useStageProps, useTree } from "../hook/use-stage";
+import { ceilingName } from "../container";
+import { watchEffect } from "vue";
+import { useLineBBox } from "../hook/use-getter";
+
+const geometry = new PlaneGeometry(1, 1, 1, 1);
+const material = new MeshPhongMaterial({
+  color: 0xffffff,
+  side: BackSide,
+});
+const mesh = new Mesh(geometry, material);
+mesh.rotateX(-Math.PI / 2);
+mesh.receiveShadow = true;
+mesh.castShadow = true;
+mesh.name = ceilingName;
+
+const ceiling = new Group();
+const sProps = useStageProps();
+const bbox = useLineBBox();
+
+watchEffect(() => {
+  mesh.scale.set(bbox.value.size.x, bbox.value.size.y, 1);
+  mesh.position.set(bbox.value.center.x, sProps.value.height + 0.01, bbox.value.center.y);
+});
+ceiling.add(mesh);
+
+useTree().value = ceiling;
+</script>

+ 78 - 7
src/core/renderer-three/env/ground.vue

@@ -1,18 +1,89 @@
 <template></template>
 
 <script lang="ts" setup>
-import { DoubleSide, Mesh, MeshPhongMaterial, PlaneGeometry } from "three";
-import { useTree } from "../hook/use-stage";
+import {
+  FrontSide,
+  Mesh,
+  MeshPhysicalMaterial,
+  PlaneGeometry,
+  RepeatWrapping,
+  TextureLoader,
+  Vector2,
+} from "three";
+import { useRender, useTree } from "../hook/use-stage";
 import { subgroupName } from "../container";
+import { useLineBBox } from "../hook/use-getter";
+import { EXRLoader } from "three/examples/jsm/Addons.js";
+import { pickPromise } from "@/utils/shared";
 
-const geometry = new PlaneGeometry(10000, 10000, 1, 1);
-const material = new MeshPhongMaterial({
-  color: 0xffffff,
-  side: DoubleSide,
+const geometry = new PlaneGeometry(1, 1);
+const padding = 100;
+const tileSize = 100;
+const bbox = useLineBBox();
+const size = bbox.value.size.clone().add({ x: padding * 2, y: padding * 2 });
+const tileCount = size.clone().multiplyScalar(1 / tileSize);
+
+const texloads: Promise<void>[] = [];
+const loadTex = (loader: TextureLoader | EXRLoader, url: string) => {
+  const { promise, resolve, reject } = pickPromise<void>();
+  texloads.push(promise);
+  const tex = loader.load(
+    `/static/models/texture/ground/${url}`,
+    () => resolve(),
+    undefined,
+    reject
+  );
+
+  tex.wrapS = RepeatWrapping;
+  tex.wrapT = RepeatWrapping;
+  tex.repeat.copy(tileCount);
+
+  return tex;
+};
+
+const texLoader = new TextureLoader();
+const exrLoader = new EXRLoader();
+
+const diffuseMap = loadTex(texLoader, "laminate_floor_02_diff_1k.jpg");
+const displacementMap = loadTex(texLoader, "laminate_floor_02_disp_1k.png");
+const normalMap = loadTex(exrLoader, "laminate_floor_02_nor_gl_1k.exr");
+const roughnessMap = loadTex(exrLoader, "laminate_floor_02_rough_1k.exr");
+
+const render = useRender();
+Promise.all(texloads).then(render);
+
+const material = new MeshPhysicalMaterial({
+  // 基础属性
+  color: 0xffffff, // 基础颜色(会被贴图覆盖)
+  map: diffuseMap, // 颜色贴图
+
+  // 物理渲染属性
+  roughness: 0.7, // 基础粗糙度(会被粗糙度贴图调整)
+  metalness: 0.0, // 木材无金属性
+  clearcoat: 0.1, // 轻微清漆效果
+  clearcoatRoughness: 0.2,
+
+  // 贴图增强
+  normalMap: normalMap, // 法线贴图
+  normalScale: new Vector2(1, 1), // 法线强度
+
+  displacementMap: displacementMap, // 置换贴图
+  displacementScale: 0.05, // 置换强度(根据实际效果调整)
+  displacementBias: -0.02,
+
+  roughnessMap: roughnessMap, // 粗糙度贴图
+
+  // 其他设置
+  side: FrontSide,
+  transparent: false,
+  premultipliedAlpha: false,
 });
+
 const ground = new Mesh(geometry, material);
-ground.rotateX(-Math.PI / 2);
 ground.receiveShadow = true;
 ground.name = subgroupName;
+ground.scale.set(size.x, size.y, 1);
+ground.rotateX(-Math.PI / 2);
+
 useTree().value = ground;
 </script>

+ 73 - 19
src/core/renderer-three/hook/use-controls.ts

@@ -1,21 +1,20 @@
-import { ref, watch, watchEffect } from "vue";
+import { computed, ref, watch, watchEffect } from "vue";
 import {
   installThreeGlobalVar,
   useCamera,
   useContainer,
   useRender,
+  useStageProps,
 } from "./use-stage";
 import { OrbitControls } from "three/examples/jsm/Addons.js";
 import { listener } from "@/utils/event";
-import {  Vector3 } from "three";
+import { Intersection, Matrix3, Vector3 } from "three";
 import { mergeFuns } from "@/utils/shared";
 import { useMouseEventRegister } from "./use-event";
-import { subgroupName } from "../container";
 import { useCameraAnimation } from "./use-animation";
-import {
-  getMoveDirectrionByKeys,
-  useFigureMoveCollision,
-} from "./use-move";
+import { getMoveDirectrionByKeys, useFigureMoveCollision } from "./use-move";
+import { useGetIntersectObject } from "./use-getter";
+import { subgroupName } from "../container";
 
 const useModelControls = () => {
   const container = useContainer();
@@ -88,10 +87,10 @@ const useRoamingControls = () => {
     controls.target.copy(direction.add(camera.position));
     controls.update();
   };
-  
+
   const { direction, onDestory: onDownDestory } = getMoveDirectrionByKeys();
   const move = useFigureMoveCollision(camera, direction, syncCamera);
-  move.pause()
+  move.pause();
 
   const controls = new OrbitControls(camera);
   controls.rotateSpeed = -0.3;
@@ -129,11 +128,11 @@ const useRoamingControls = () => {
       prevOrigin = camera.position.clone();
       prevDire = camera.getWorldDirection(new Vector3());
       controls.enabled = false;
-      move.pause()
+      move.pause();
     },
     enable() {
       controls.enabled = true;
-      move.continue()
+      move.continue();
       render();
     },
     get current() {
@@ -193,15 +192,48 @@ export const useFlyRoaming = installThreeGlobalVar(() => {
   const camera = useCamera();
   const { type, value: controls } = useControls();
   const cameraAnimation = useCameraAnimation();
+  const getIntersectObject = useGetIntersectObject();
+  const normalMatrix = new Matrix3();
+  
+  const bottom = new Vector3(0, -1, 0);
+  return async (point: Intersection | Vector3) => {
+    let intersect: Intersection;
+    if ("point" in point) {
+      intersect = point;
+    } else {
+      point = point.clone();
+      const objects = getIntersectObject(point, bottom);
+      if (!objects.length) {
+        throw "当前位置无法漫游";
+      }
 
-  return async (point: Vector3) => {
-    type.value = undefined;
-    const position = point.clone().add({ x: 0, y: roamingEysHeight, z: 0 });
+      intersect = objects[0];
+    }
+
+    const normal = intersect.face?.normal.clone();
+    if (!normal) {
+      throw "当前位置无法漫游";
+    }
+    normalMatrix.getNormalMatrix(intersect.object.matrixWorld);
+    normal.applyMatrix3(normalMatrix).normalize();
+
+    if (normal.y < 0.8) {
+      throw "当前位置无法漫游";
+    }
+    if (type.value !== 'roaming') {
+      type.value = undefined
+    } else {
+      controls.value?.disable()
+    }
+    const position = intersect.point
+      .clone()
+      .add({ x: 0, y: roamingEysHeight, z: 0 });
     const direction = camera.getWorldDirection(new Vector3());
     const target = position.clone().add(direction);
     await cameraAnimation(position, target);
     type.value = "roaming";
-    controls.value!.syncCamera();
+    controls.value?.enable()
+    controls.value?.syncCamera();
   };
 });
 
@@ -212,12 +244,18 @@ export const useFlyModel = installThreeGlobalVar(() => {
 
   return async (set: { point?: Vector3; direction?: Vector3 } = {}) => {
     type.value = "model";
-    const prev = controls.value!.current;
+    const prev = controls.value?.current;
     if (!set.point) {
-      set.point = prev.prevOrigin || camera.position.clone();
+      set.point =
+        prev?.prevOrigin ||
+        camera.position.clone() ||
+        new Vector3(400, 400, 400);
     }
     if (!set.direction) {
-      set.direction = prev.prevDire || camera.getWorldDirection(new Vector3());
+      set.direction =
+        prev?.prevDire ||
+        camera.getWorldDirection(new Vector3()) ||
+        camera.getWorldDirection(new Vector3());
     }
 
     const direction = set.direction;
@@ -232,6 +270,15 @@ export const installAutoSwitchControls = installThreeGlobalVar(() => {
   const mouseRegister = useMouseEventRegister();
   const flyRoaming = useFlyRoaming();
   const flyModel = useFlyModel();
+  const sProps = useStageProps();
+  const canFlyTypes = ["line", "icon"] as const;
+  const canFlyNames = computed(() => {
+    const names = canFlyTypes.flatMap((type) =>
+      sProps.value.draw.store.getTypeItems(type).map((item) => item.id)
+    )
+    names.push(subgroupName)
+    return names
+  });
 
   const onDestroy = mergeFuns(
     listener(document.documentElement, "keydown", (ev) => {
@@ -239,7 +286,14 @@ export const installAutoSwitchControls = installThreeGlobalVar(() => {
         flyModel();
       }
     }),
-    mouseRegister(subgroupName, "dblclick", ({ point }) => flyRoaming(point))
+    watchEffect((onCleanup) => {
+      const cleanups = canFlyNames.value.map((name) =>
+        mouseRegister(name, "dblclick", (object) => {
+          flyRoaming(object);
+        })
+      );
+      onCleanup(mergeFuns(cleanups))
+    })
   );
   type.value = "model";
 

+ 56 - 23
src/core/renderer-three/hook/use-event.ts

@@ -1,7 +1,12 @@
 import { Intersection, Object3D, Vector2 } from "three";
-import { installThreeGlobalVar, useContainer, useScene } from "./use-stage";
+import {
+  installThreeGlobalVar,
+  useContainer,
+  useCursor,
+  useScene,
+} from "./use-stage";
 import { listener } from "@/utils/event";
-import { markRaw, reactive, shallowRef, watch } from "vue";
+import { markRaw, onUnmounted, reactive, shallowRef, watch } from "vue";
 import { useGetIntersectObjectByPixel } from "./use-getter";
 import { diffArrayChange, mergeFuns } from "@/utils/shared";
 import { globalWatch } from "@/core/hook/use-global-vars";
@@ -63,10 +68,9 @@ const useRegisterFactory = <T extends UIEvent>(
       (keys, oldKey = []) => {
         const { added, deleted } = diffArrayChange(keys, oldKey);
         added.forEach((key) => {
-          mounted[key] = mountRegister(
-            key,
-            (e) => (currentEvent.value = [key, e])
-          );
+          mounted[key] = mountRegister(key, (e) => {
+            currentEvent.value = [key, e];
+          });
         });
         deleted.forEach((key) => {
           if (key in mounted) {
@@ -167,38 +171,67 @@ export const useMouseEventRegister = installThreeGlobalVar(() => {
 });
 
 export const useHoverEventRegister = installThreeGlobalVar(() => {
-  type Item =  [string | Object3D, (hover: boolean) => void]
+  type Item = [string | Object3D, (hover: boolean) => void];
   const register = useMouseEventRegister();
   const scene = useScene();
-  const checks = [] as Array<Item>
-  const prevs = [] as boolean[]
+  const checks = [] as Array<Item>;
+  const prevs = [] as boolean[];
 
   const stopListener = register(scene, "mousemove", ({ object }) => {
     checks.forEach(([node, cb], ndx) => {
       const hover = treeIncludes(node, object);
       if (prevs[ndx] !== hover) {
-        cb(hover)
-        prevs[ndx] = hover
+        cb(hover);
+        prevs[ndx] = hover;
       }
-    })
+    });
   });
 
-  const hoverRegister = (tree: string | Object3D, cb: (hover: boolean) => void) => {
-    const item: Item = [tree, cb]
-    checks.push(item)
-    prevs.push(false)
+  const hoverRegister = (
+    tree: string | Object3D,
+    cb: (hover: boolean) => void
+  ) => {
+    const item: Item = [tree, cb];
+    checks.push(item);
+    prevs.push(false);
 
     return () => {
-      const ndx = checks.indexOf(item)
+      const ndx = checks.indexOf(item);
       if (~ndx) {
-        checks.splice(ndx, 1)
-        prevs.splice(ndx, 1)
+        checks.splice(ndx, 1);
+        prevs.splice(ndx, 1);
       }
-    }
-  }
+    };
+  };
 
   return {
     var: hoverRegister,
-    onDestroy: stopListener
-  }
+    onDestroy: stopListener,
+  };
 });
+
+export const useInteraction = (
+  object: Object3D,
+  cb: (data: Intersection) => void,
+  type: ExtractMouseEventKeys<GlobalEventHandlersEventMap, MouseEvent> = 'click'
+) => {
+  const hoverRegister = useHoverEventRegister();
+  const mouseRegister = useMouseEventRegister();
+  const cursor = useCursor();
+  let leave: () => void;
+
+  const cleanups = [
+    hoverRegister(object, (hover) => {
+      if (hover) {
+        leave = cursor.push("pointer");
+      } else {
+        leave && leave();
+      }
+    }),
+    mouseRegister(object, type, cb),
+    () => {
+      leave && leave()
+    }
+  ];
+  onUnmounted(mergeFuns(cleanups))
+};

+ 43 - 9
src/core/renderer-three/hook/use-getter.ts

@@ -1,6 +1,12 @@
 import { Raycaster, Vector2, Vector3 } from "three";
-import { installThreeGlobalVar, useCamera, useContainer, useScene } from "./use-stage";
-
+import {
+  installThreeGlobalVar,
+  useCamera,
+  useContainer,
+  useScene,
+  useStageProps,
+} from "./use-stage";
+import { computed } from "vue";
 export const useRaycaster = installThreeGlobalVar(() => new Raycaster());
 
 export const useGetIntersectObject = () => {
@@ -9,9 +15,14 @@ export const useGetIntersectObject = () => {
 
   return (origin: Vector3, direction: Vector3, far = 10000, near = 0) => {
     raycaster.set(origin, direction);
-    raycaster.far = far
-    raycaster.near = near
-    return raycaster.intersectObject(scene);
+    const oldFar = raycaster.far;
+    const oldNear = raycaster.near;
+    raycaster.far = far;
+    raycaster.near = near;
+    const objects = raycaster.intersectObject(scene);
+    raycaster.far = oldFar;
+    raycaster.near = oldNear;
+    return objects;
   };
 };
 
@@ -19,15 +30,38 @@ export const useGetIntersectObjectByPixel = () => {
   const scene = useScene();
   const raycaster = useRaycaster();
   const camera = useCamera();
-  const container = useContainer()
+  const container = useContainer();
 
   return (pixel: Vector2) => {
     if (container.value) {
-      pixel = pixel.clone()
-      pixel.setX((pixel.x / container.value.offsetWidth) * 2 - 1)
-      pixel.setY(-(pixel.y / container.value.offsetHeight) * 2 + 1)
+      pixel = pixel.clone();
+      pixel.setX((pixel.x / container.value.offsetWidth) * 2 - 1);
+      pixel.setY(-(pixel.y / container.value.offsetHeight) * 2 + 1);
     }
     raycaster.setFromCamera(pixel, camera);
     return raycaster.intersectObject(scene);
   };
 };
+
+export const useLineBBox = installThreeGlobalVar(() => {
+  const store = useStageProps().value.draw.store;
+  const lines = computed(() => store.getTypeItems("line"));
+  return computed(() => {
+    const min: Vector2 = new Vector2(99999, 99999);
+    const max: Vector2 = new Vector2(-99999, -99999);
+    for (const line of lines.value) {
+      for (const p of line.points) {
+        min.x = Math.min(min.x, p.x);
+        min.y = Math.min(min.y, p.y);
+        max.x = Math.max(max.x, p.x);
+        max.y = Math.max(max.y, p.y);
+      }
+    }
+    return {
+      min,
+      max,
+      center: max.clone().add(min).multiplyScalar(0.5),
+      size: max.clone().sub(min),
+    };
+  });
+});

+ 22 - 15
src/core/renderer-three/hook/use-move.ts

@@ -1,8 +1,10 @@
 import { getDownKeys } from "@/core/hook/use-global-vars";
 import { frameInterval, mergeFuns } from "@/utils/shared";
 import { Object3D, Vector2, Vector3 } from "three";
-import { ref, Ref, ShallowRef, shallowRef, watch } from "vue";
-import { useGetIntersectObject, useRaycaster } from "./use-getter";
+import { ref, Ref, shallowRef, watch } from "vue";
+import { useGetIntersectObject } from "./use-getter";
+import { zeroEq } from "@/utils/math";
+import { ceilingName } from "../container";
 
 export const getMoveDirectrionByKeys = () => {
   const { var: keys, onDestroy: onDownDestory } = getDownKeys();
@@ -42,7 +44,7 @@ export const getMoveDirectrionByKeys = () => {
   };
 };
 
-export const jumpFactory = (initY = 0, endY = initY, jumpForce = 5) => {
+export const jumpFactory = (initY = 0, endY = initY, jumpForce = 4) => {
   const gravity = -0.3; // 重力加速度
   let velocityY = jumpForce; // 垂直速度
   let y = initY;
@@ -75,8 +77,8 @@ export const useFigureMoveCollision = (
 ) => {
   const getIntersect = useGetIntersectObject();
   const pause = ref(false);
-  const rangeCount = 10;
-  const offset = 10;
+  const rangeCount = 4;
+  const offset = 30;
   const rangeHeight: number[] = [];
   for (let i = 0; i < rangeCount; i++) {
     rangeHeight.push((i / (rangeCount - 1)) * (height - offset) + offset);
@@ -84,11 +86,12 @@ export const useFigureMoveCollision = (
 
   const getJumpPoint = (position = figure.position) => {
     const objects = getIntersect(position, jumpRayDire, height);
-    return objects.length > 0 ? objects[0].point : 0;
+    
+    return objects.length > 0 && objects[0].object.name !== ceilingName ? objects[0].point : 0;
   };
 
   const jumpRayDire = new Vector3(0, -1, 0);
-  const jump = (velocityY = 5) => {
+  const jump = (velocityY = 4) => {
     const updateJump = jumpFactory(figure.position.y, height, velocityY);
     return () => {
       const j = updateJump();
@@ -145,7 +148,7 @@ export const useFigureMoveCollision = (
   };
 
   let stopJump: (() => void) | null = null;
-  const startJump = (velocityY = 5) => {
+  const startJump = (velocityY = 4) => {
     const update = jump(velocityY);
     stopJump = frameInterval(() => {
       if (update()) {
@@ -174,8 +177,12 @@ export const useFigureMoveCollision = (
     stopMove = frameInterval(() => {
       move(new Vector2(dire.x, dire.z));
       render();
-      if (!stopJump) {
-        // console.log(getJumpPoint());
+      if (
+        !stopJump &&
+        !zeroEq(figure.position.y - height) &&
+        !getJumpPoint(new Vector3().copy(figure.position).sub({x: 0, y: 0.1, z: 0}))
+      ) {
+        startJump(0);
       }
     });
   });
@@ -187,14 +194,14 @@ export const useFigureMoveCollision = (
       stopMove && stopMove();
     },
     pause: () => {
-      pause.value = true
+      pause.value = true;
       stopJump && stopJump();
       stopMove && stopMove();
-      stopJump = null
-      stopMove = null
+      stopJump = null;
+      stopMove = null;
     },
     continue() {
-      pause.value = false
-    }
+      pause.value = false;
+    },
   };
 };

+ 2 - 0
src/core/renderer-three/renderer.vue

@@ -1,5 +1,6 @@
 <template>
   <div class="three-container" ref="container">
+    <Ceiling />
     <Ground />
     <Light />
     <template v-for="(com, key) in components" :key="key">
@@ -15,6 +16,7 @@
 
 <script lang="ts" setup>
 import Ground from "./env/ground.vue";
+import Ceiling from "./env/ceiling.vue";
 import Light from "./env/light.vue";
 import { getCurrentInstance, onUnmounted } from "vue";
 import {

+ 12 - 7
src/example/components/slide/slide.vue

@@ -3,7 +3,7 @@
     <el-menu
       :default-active="active"
       class="slide-menu"
-      @select="selectHandler"
+      @select="(val) => selectHandler(val, true)"
       collapse
       :popper-offset="0"
       :popper-class="childType || 'slide-menu-poper'"
@@ -97,16 +97,19 @@ watch(active, (a) => {
   }
 });
 
-const selectHandler = async (val: string) => {
+let immedSelect = false;
+const selectHandler = async (val: string, immed = false) => {
+  if (val === active.value && !immedSelect && immed) {
+    return;
+  }
+  immedSelect = immed;
+
   if (active.value) {
-    console.log(val, active.value);
     props.draw.quitDrawShape();
-    console.log(active.value);
     if (active.value === val) {
       active.value = undefined;
       return;
     }
-    console.log(val, active.value);
   }
   await nextTick();
   active.value = val;
@@ -121,8 +124,10 @@ const selectHandler = async (val: string) => {
     }
   } else if (menu.payload) {
     props.draw.enterDrawShape(menu.payload.type, menu.payload.preset, menu.single);
-  } else if (menu.mount && hoverMenu.value.ref) {
-    hoverMenu.value.ref.selectDefault();
+  } else if (menu.mount) {
+    await nextTick();
+    active.value = undefined;
+    // hoverMenu.value.ref.selectDefault();
   }
 };
 

+ 14 - 3
src/example/constant.ts

@@ -5,7 +5,13 @@ export type IconItem = {
   icon: string;
   name: string;
   color?: string;
-  parse?: { fill?: string; stroke?: string; type?: LineIconData['type'], height?: number };
+  parse?: {
+    fill?: string;
+    stroke?: string;
+    type?: LineIconData["type"];
+    height?: number;
+    width?: number;
+  };
 };
 export type IconGroup = {
   name: string;
@@ -40,7 +46,12 @@ export const iconGroups: IconGroup[] = [
             name: "双开门",
             parse: { type: "align-bottom" },
           },
-          { wall: true, icon: "yimen", name: "移门", parse: { type: "full" } },
+          {
+             wall: true,
+            icon: "yimen",
+            name: "移门",
+            parse: { type: "full" },
+          },
           { wall: true, icon: "yakou", name: "哑口", parse: { type: "full" } },
         ],
       },
@@ -52,7 +63,7 @@ export const iconGroups: IconGroup[] = [
             wall: true,
             icon: "piaochuang",
             name: "飘窗",
-            parse: { type: "align-bottom-fix", height: 70 },
+            parse: { type: "align-bottom-fix", width: 180, height: 70 },
           },
           {
             wall: true,

+ 0 - 1
src/example/dialog/basemap/gd-map/selectAMapImage.vue

@@ -205,7 +205,6 @@ const getBoxSize = () => {
 const submit = () => {
   return new Promise<BasemapInfo>((resolve, reject) => {
     if (mapEl.value) {
-      console.log("ha?");
       let info = undefined;
       if (__showMarker) {
         const pos = __showMarker.getPosition();

+ 15 - 0
src/utils/shared.ts

@@ -535,3 +535,18 @@ export const trackFlag = (trackCallback?: (flag: number) => void) => {
     },
   };
 };
+
+export const pickPromise = <T>() => {
+  let resolve: (data: T) => void
+  let reject: (reason?: any) => void
+  const promise = new Promise<T>((_resolve, _reject) => {
+    resolve = _resolve
+    reject = _reject
+  })
+
+  return {
+    promise,
+    resolve: resolve!,
+    reject: reject!
+  }
+}