# 绘图编辑拾取工具

## 实现原理

工具`Tool`有一个“状态图”，即[`StateNode`](https://vjmap.com/map3d/api/classes/StateNode.html)，其中包含工具（例如选择工具或绘制工具）的逻辑。用户交互（例如移动光标）将根据哪些节点处于活动状态而产生不同的状态变化。

每个节点都可以处于活动状态或非活动状态。每个状态节点也可能有零个或多个子节点。当某个状态处于活动状态时，如果该状态有子节点，则其子节点中必须有一个（且只有一个）处于活动状态。当状态节点从其父节点接收到事件时，它有机会在将事件传递给其活动子节点之前处理该事件。节点可以以任何方式处理事件：它可以忽略事件、更新存储中的记录或运行转换，以更改哪些状态节点处于活动状态。

当用户交互通过App将事件方法发送到工具时，此事件将被发送到工具的根状态节点[rootState](https://vjmap.com/map3d/api/classes/App.html#rootState)，然后向下传递通过下面的活动状态，直到到达叶节点或其中一个节点响应。

### Tool工具

`vjmap3d` 编辑器带有一些内置**核心工具**：[缺省工具](https://vjmap.com/map3d/api/classes/DefaultTool.html)、[绘图工具](https://vjmap.com/map3d/api/classes/DrawShapeTool.html)、[拾取工具](https://vjmap.com/map3d/api/classes/PickTool.html)和[变换控制器工具](https://vjmap.com/map3d/api/classes/TransformTool.html)。这些工具始终添加到状态图中。

![](https://vjmap.com/blogimages/image-20240602163850959.png)

### 工具切换

您可以使用[app.setCurrentTool](https://vjmap.com/map3d/api/classes/App.html#setCurrentTool)更改当前活动工具。

```ts
app.setCurrentTool('draw')
```

您可以使用活动工具 ID 的路径进行工具状态切换。如直接进入绘制线工具状态

```ts
app.setCurrentTool('draw.drawline')
```

### 工具实现规范

每个工具都有一个**id**。这用于在状态图中识别它。

```ts
class MyTool extends StateNode {
	static override id = 'my-tool'
}
```

工具可以包含**子项**。例如，[绘图工具](https://vjmap.com/map3d/api/classes/DrawShapeTool.html)的子状态有：`idle`、`drawpoint`、`drawline`、`drawpolygon`和`drawedit`。如果某个状态有子项，那么它也必须有一个`initial`状态，这样它才知道从哪个状态开始。

```ts
class MyIdleState extends StateNode {
	static override id = 'my-idle-state'
}

class MyPointingState extends StateNode {
	static override id = 'my-pointing-state'
}

class MyTool extends StateNode {
	static override id = 'my-tool'
	static override initial = 'my-idle-state'
	// children 必须是返回类数组的函数；写成数组会在构造时抛 "children is not a function"
	static override children = () => [MyIdleState, MyPointingState]
}
```

### 事件处理

从根节点开始，每个节点将首先处理事件，然后将事件传递给其当前活动的子状态。此过程持续到以下任一情况：在没有任何子节点的状态下处理事件；或者以产生转换的方式处理事件。

**父节点会在子节点前处理事件**

```ts
class MyIdleState extends StateNode {
	static override id = 'my-idle-state'

	onPointerDown(info) {
		console.log('world')
	}
}

class MyTool extends StateNode {
	static override id = 'my-tool'
	static override initial = 'my-idle-state'
	static override children = () => [MyIdleState]

	onPointerDown(info) {
		console.log('hello')
	}
}
```

使用上面的例子，当前工具为`MyTool`的`MyIdleState`时，有点击事件`onPointerDown`发生时，将事件传递给`MyTool`，则将首先调用`MyTool.onPointerDown`的方法，然后再调用的`MyIdleState.onPointerDown`的方法。

```ts
class MyIdleState extends StateNode {
	static override id = 'my-idle-state'

	onPointerDown = (info) => {
		console.log("this won't run")
	}
}

class MyTool extends StateNode {
	static override id = 'my-tool'
	static override initial = 'my-idle-state'
	static override children = () => [MyIdleState]

	onPointerDown = (info) => {
		// 注意：'select' 是 pick 工具的子状态而非根工具，setCurrentTool('select') 会抛
		// "root - no child state exists with the id select"。应写 'pick' 或 'pick.select'。
		this.app.setCurrentTool('pick') // StateNode 内通过 this.app（或 this.editor）访问 App
	}
}
```

如果`MyTool`的`onPointerDown`处理程序产生了转换（改变了任何活动状态），那么事件链条将停止，并且不会处理该事件`MyIdleState`。

### Path
您可以通过[app.getStatePath](https://vjmap.com/map3d/api/classes/App.html#getStatePath) 获取当前的活动状态“路径” 。

您可以通过 检查一条路径是否处于活动状态[`app.stateIsIn`](https://vjmap.com/map3d/api/classes/App.html#stateIsIn)，或者通过 检查多条路径是否处于活动状态[`app.stateInAny`](https://vjmap.com/map3d/api/classes/App.html#stateInAny)。（这些都是 `app` 实例上的方法，不是静态方法。）

> ⚠️ **运行时限制**：状态节点的当前子状态存放在响应式 `ref` 里，经代理读取时下层子状态取不到——`getStatePath()` 实际**只返回顶层工具 id**（如 `'draw'`，未设置过任何工具时为 `undefined`），`stateIsIn`/`stateInAny` 传带点的子路径（如 `'pick.idle'`、`'draw.drawpoint'`）**恒为 false**。判断子状态是否激活请用 `app.getStateDescendant(path)?.getIsActive()`（按 `children` 逐级查找，结果可靠）。`setCurrentTool('draw.drawpoint')` 这类带点路径的切换不受影响。

```ts
app.getStatePath() // 'draw'（只有顶层工具 id）

app.stateIsIn('draw') // true
app.stateInAny('draw', 'pick') // true（只用顶层 id）
app.getStateDescendant('draw.drawpoint')?.getIsActive() // 判断子状态
```
[`App.getCurrentToolId`](https://vjmap.com/map3d/api/classes/App.html#getCurrentToolId)可以获取当前选择的工具。

## 常用工具

### [缺省工具](https://vjmap.com/map3d/api/classes/DefaultTool.html)

缺省工具下不会执行任何动作。

- 缺省工具id: "default"
- 子状态节点: "idle"

### [绘图工具](https://vjmap.com/map3d/api/classes/DrawShapeTool.html)

- 缺省工具id: "draw"

|子状态节点id|描述|
|:---|:---|
|idle|初始空闲|
|drawpoint|绘制点|
|drawline|绘制线|
|drawpolygon|绘制多边形|
|drawedit|编辑|

### [拾取工具](https://vjmap.com/map3d/api/classes/PickTool.html)

- 缺省工具id: "pick"

|子状态节点id|描述|
|:---|:---|
|idle|初始空闲|
|select|选择对象|

### [变换控制器工具](https://vjmap.com/map3d/api/classes/TransformTool.html)

- 缺省工具id: "transform"

|子状态节点id|描述|
|:---|:---|
|idle|初始空闲|
|edit|控制物体变换|

### 示例

#### 绘制点
```js
app.setCurrentTool("draw.drawpoint", {
    color: 0xffffff,
    size: 20
})
```

#### 绘制线
```js
app.setCurrentTool("draw.drawline", {
    color: Math.random() * 0xffffff,
    lineWidth: +((Math.random() * 6).toFixed(0)) + 1
})
```

#### 绘制多边形
```js
app.setCurrentTool("draw.drawpolygon", {
    color: Math.random() * 0xffffff
})
```

#### 编辑形状
```js
app.setCurrentTool("draw.drawedit")
```

#### 控制物体变换
```js
app.setCurrentTool("transform")
```

#### 拾取对象
```js
 let opts = {
    pickCallBack: (param) => {
        console.log(param.oldPick) // ⚠️ 运行时恒为 null（实现缺陷），需要上一次拾取结果请自行记录 curPick
        console.log(param.curPick)
        if (param.curPick) app.logInfo("拾取到了", "success")
    }
}
app.setCurrentTool("pick", opts)
```

#### 取消操作
```js
app.setCurrentTool("default")
```

<mapFrame>
<pre>
let svc = new vjmap3d.Service(env.serviceUrl, env.accessToken);
let app = new vjmap3d.App(svc, {
    container: "map", // 容器id
    stat: { show: true, left: "0" },
    scene: {
        gridHelper: { visible: true } // 是否显示坐标网格
    },
    camera: {  // 相机设置
        viewHelper: { enable: true, position: "leftBottom" } // 是否显示视角指示器
    }
})
for(let i = 0; i < 3; i++) {
    let box = new THREE.BoxGeometry(Math.random() * 3 + 0.5, Math.random() * 3 + 0.5, Math.random() * 3 + 0.5)
    const material = new THREE.MeshStandardMaterial({
        color: vjmap3d.randColor()
    });
    let mesh = new THREE.Mesh(box, material);
    mesh.position.copy(vjmap3d.toVector3(vjmap3d.randPoint3D({maxX: 5, maxY: 2, maxZ: 5, minX: -5, minY: -2, minZ: -5})))
    let entity = vjmap3d.Entity.fromObject3d(mesh);
    entity.addTo(app);
}
const ui = await app.getConfigPane({ title: "工具", style: { width: "250px"}});
let gui = new vjmap3d.UiPanelJsonConfig();
gui.addButton("绘制点", () => {
    app.setCurrentTool("draw.drawpoint", {
        color: 0xffffff,
        size: 20
    })
})
gui.addButton("绘制线", () => {
    app.setCurrentTool("draw.drawline", {
        color: Math.random() * 0xffffff,
        lineWidth: +((Math.random() * 6).toFixed(0)) + 1
    })
})
gui.addButton("绘制多边形", () => {
    app.setCurrentTool("draw.drawpolygon", {
        color: Math.random() * 0xffffff
    })
})
gui.addButton("编辑形状", () => {
    app.setCurrentTool("draw.drawedit")
})
gui.addButton("控制物体变换", () => {
    app.setCurrentTool("transform")
})
gui.addButton("拾取对象", () => {
    let opts = {
        pickCallBack: (param) => {
            console.log(param.oldPick) // ⚠️ 运行时恒为 null
            console.log(param.curPick)
            if (param.curPick) app.logInfo("拾取到了", "success")
        }
    }
    app.setCurrentTool("pick", opts)
})
gui.addButton("取消操作", () => {
    app.setCurrentTool("default")
})
gui.toJson().forEach(c => ui.appendChild(c));
</pre>
</mapFrame>

## 封装的工具函数

为了异步编程，简单代码逻辑，针对上面的工具，封装了如下常用工具函数：

|方法名|描述|
|:---|:---|
|[isFreeStatus](https://vjmap.com/map3d/api/classes/PickModule.html#isFreeStatus)|当前应该是否处于自由状态|
|[isFreeStatusRef](https://vjmap.com/map3d/api/classes/PickModule.html#isFreeStatusRef)|自由状态响应式对象|
|[pickPoint](https://vjmap.com/map3d/api/classes/PickModule.html#pickPoint)|拾取一个点|
|[pickEntity](https://vjmap.com/map3d/api/classes/PickModule.html#pickEntity)|拾取实体|
|[drawSelectBox](https://vjmap.com/map3d/api/classes/PickModule.html#drawSelectBox)|绘制一个选择框|
|[actionDrawPoint](https://vjmap.com/map3d/api/classes/PickModule.html#actionDrawPoint)|绘制一个点，获取点坐标|
|[actionDrawLineSting](https://vjmap.com/map3d/api/classes/PickModule.html#actionDrawLineSting)|绘制一条线，获取线坐标|
|[actionDrawPolygon](https://vjmap.com/map3d/api/classes/PickModule.html#actionDrawPolygon)|绘制一个多边形，获取多边形坐标|
|[actionDrawEdit](https://vjmap.com/map3d/api/classes/PickModule.html#actionDrawEdit)|编辑绘制的对象|
|[actionPickSelect](https://vjmap.com/map3d/api/classes/PickModule.html#actionPickSelect)|选中一个实体对象，返回 `{isCancel, info, select}`。⚠️ 右键和 ESC 都结束选择且 `isCancel=false`（之前点选过对象时 `select` 仍保留）；按 Enter 反而 `isCancel=true`|
|[getDrawLayer](https://vjmap.com/map3d/api/classes/PickModule.html#getDrawLayer)|通过图层名称获取当前绘图层的实体|
|[transformObject](https://vjmap.com/map3d/api/classes/ToolStateModule.html#transformObject)|对一个物体进行变换控制器操作。⚠️ ESC 与 Enter 都只是结束操作，**不会还原**已做的变换（ESC 仅在拖拽进行中时撤销当前这一次拖拽）|

**返回值形状**：

|方法|resolve 的结果|
|:---|:---|
|`pickPoint(opts)`|`{cancel: false, point, intersectObject, event}`；ESC 为 `{cancel: true, reason: "escCancel"}`，`rightClickCancel: true` 时右键为 `{cancel: true, reason: "rightClickCancel"}`。`beforeConfirm` 不返回 `true` 时本次点击不确认、继续拾取|
|`pickEntity(opts)`|`{cancel, entities, clearHighlight}`（ESC 时另有 `reason: "escCancel"`）。⚠️ 传了 `beforeConfirm`/`onResult` 时，它们**必须返回 `true`** 才会结束——否则包括 ESC 在内的所有确认/取消都被拦下，Promise 永不 resolve|
|`drawSelectBox(style?, shiftSelectBox?)`|框选完成为屏幕坐标 `[左上 Vector2, 右下 Vector2]`；未框选就结束（如 ESC）为 `null`|
|`actionDrawPoint/LineSting/Polygon(opts)`|`{isCancel, data}`。⚠️ 默认 `isAddToApp: false`——只返回数据、不加入绘图层（而 `setCurrentTool('draw.*')` 默认会加入），需要保留请传 `isAddToApp: true`|
|`actionDrawEdit(opts)`|`{}`（空对象，编辑结果直接写回 `editEntity` 的数据）|
|`actionPickSelect(opts)`|`{isCancel, info, select}`（取消语义见上表）|

### 示例
<mapFrame>
<pre>
let svc = new vjmap3d.Service(env.serviceUrl, env.accessToken);
let app = new vjmap3d.App(svc, {
    container: "map", // 容器id
    stat: { show: true, left: "0" },
    scene: {
        gridHelper: { visible: true } // 是否显示坐标网格
    },
    camera: {  // 相机设置
        viewHelper: { enable: true, position: "leftBottom" } // 是否显示视角指示器
    }
})
let firstEntity;
for(let i = 0; i < 3; i++) {
    let box = new THREE.BoxGeometry(Math.random() * 3 + 0.5, Math.random() * 3 + 0.5, Math.random() * 3 + 0.5)
    const material = new THREE.MeshStandardMaterial({
        color: vjmap3d.randColor()
    });
    let mesh = new THREE.Mesh(box, material);
    mesh.position.copy(vjmap3d.toVector3(vjmap3d.randPoint3D({maxX: 5, maxY: 2, maxZ: 5, minX: -5, minY: -2, minZ: -5})))
    let entity = vjmap3d.Entity.fromObject3d(mesh);
    entity.addTo(app);
    if (i == 0) firstEntity = entity;
}
const ui = await app.getConfigPane({ title: "工具", style: { width: "250px"}});
let gui = new vjmap3d.UiPanelJsonConfig();
gui.addButton("第一个实体变换", async () => {
    app.logInfo("请按Enter或ESC结束操作（ESC不会撤销已做的变换）")
    let options = {
        target: firstEntity.node,
        clickNoSelfEntityExit: false,
        clickNoEntityExit: false,
        transformSize: 1,
    }
    let res = await app.transformObject(options)
    console.log(res)
    app.logInfo("实体变换操作完成", "success")
})
gui.addButton("选择多个进行变换", async () => {
    app.logInfo("请左键选择要变换的对象，右键结束确定选择")
    app.setCurrentTool(); // 取消之前的工具操作
    let res = await app.pickEntity({
        rightClickConfirm: true,
        highlightUseBoxHelper: true
    });
    if (res.entities?.length == 0) return;
    let entities = res.entities || [];
    let positions = entities.map(p => p.position.clone())
    let options = {
        target: entities[entities.length - 1].position,
        pressEKeyModeRotate: false,
        pressRKeyModeScale: false,
        clickEntityAttach: false,
        clickNoSelfEntityExit: false,
        clickNoEntityExit: false,
        rightClickConfirmExit: true,
        editAsCommand: true,
        dragCallBack: (param) => {
            if (param.isReset) {
                // 重新开始了
                positions = entities.map(p => p.position.clone())
            } else if (param.isOnceFinish && param.command) {
                // 一次完成，加入命令
                for(let n = 0; n < entities.length; n++) {
                    param.command.addCommand(new vjmap3d.SetPositionCommand(entities[n].node, entities[n].position, positions[n]));
                }
            }
            res.entities?.forEach(ent => {
              ent.position.add(param.deltaPosition)
            })
            for(let n = 0; n < entities.length; n++) {
                entities[n].position.copy(positions[n].clone().add(param.deltaPosition));
            }
        },
        // onExitCallBack(node, context) {
        //     if (node.id != "edit") return;
        //     app.setCurrentTool()
        // },
    }
    await app.transformObject(options)
    res.clearHighlight();
})
gui.addButton("自定义绘制多边形", async () => {
    app.logInfo("绘制过程中按 Ctrl+I 输入坐标，右键弹出菜单", 5000)
    let lastPoint;
    let drawRes = await app.actionDrawPolygon({
        isAddToApp: true,
        //pointMaxCount: 3, // 三个点结束
        updateCoordinate(point, isMoveing, data) {
            let text;
            text = `当前坐标：${point.x.toFixed(2)}, ${point.y.toFixed(2)}, ${point.z.toFixed(2)}`
            if (!isMoveing) {
                lastPoint = point;
            }
            if (lastPoint) {
                text += `<br/>距离上一个点: ${point.distanceTo(lastPoint).toFixed(2)}`
            }
            app.popup2d.show()
            app.popup2d.setHTML(text)
            app.popup2d.setPosition(point)
        },
        onKeyUp(info, context) {
            let isCtrl = vjmap3d.isCtrlKeyDown(info);
            let keyCode = vjmap3d.eventKeyCode(info) ;
            if (isCtrl && keyCode == 73) {
                // Ctrl + I
                let info = prompt("请输入坐标，格式x,y,z如 10,0,20");
                let pts = info?.split(",").map(p => +p);
                // @ts-ignore
                context.addPoint(new THREE.Vector3().fromArray(pts))
            }
        },
        onContextMenu(info, context) {
            new vjmap3d.ContextMenu({
                // @ts-ignore
                event: info.event.originalEvent,
                theme: "dark", //light
                width: "250px",
                items: [
                    {
                        label: '取消',
                        onClick: () => {
                                context.cancel()
                        }
                    }
                ]
            });
        },
        onComplete() {
            app.popup2d.hide()
        }
    })
    console.log(drawRes)
    if (drawRes.isCancel) {
        app.logInfo("取消绘制", "warn")
    } else {
        app.logInfo("绘制完成", "success")
    }
});
gui.addButton("编辑绘制的第一个多边形", async () => {
    let res = await app.actionDrawEdit({
        editEntity: app.getDrawLayer()?.polygons(),
        editIndex: 0,
        //editShowVertex: false,
        onUnEditEntity(ent, ctx) {
            ctx.complete()
        }
    })
    console.log(res)
    app.logInfo("编辑完成", "success")
});
gui.addButton("第一个多边形显示或隐藏", async () => {
    let  polygons = app.getDrawLayer()?.polygons()
    let data = polygons.getData()
    data[0].hidden = !data[0].hidden;
    polygons.setData(data)
});
gui.addButton("删除第一个多边形", async () => {
    let  layer = app.getDrawLayer()
    // id 是实体内自增的字符串、删除后不复用，不要写死 "0"，从当前数据里取
    let first = layer.polygons()?.getData()[0]
    if (first) layer.deletePolygonData({
        id: first.id,
        coordinates: []
    })
});
gui.addButton("更新第一个多边形坐标", async () => {
    let  layer = app.getDrawLayer()
    let first = layer.polygons()?.getData()[0]
    // update*Data 按 id 整项替换，未带的字段（颜色等）会被重置，先展开原数据
    if (first) layer.updatePolygonData({
        ...first,
        coordinates: [vjmap3d.randPoint3D(),vjmap3d.randPoint3D(), vjmap3d.randPoint3D()]
    })
});
gui.addButton("给定坐标进行编辑坐标", async () => {
    let data = [];
    data.push({
        coordinates: [vjmap3d.randPoint3D(), vjmap3d.randPoint3D(), vjmap3d.randPoint3D()],
        color: "#00ff00",
        lineWidth: 3,
        dashScale: 2,
        dashOffset: 0,
        dashSize: 3,
        gapSize: 3,
        dashed: true
    })
    let polylines = new vjmap3d.PolylinesEntity({
        data: data
    })
    polylines.addTo(app)
    await app.actionDrawEdit({
        editEntity: polylines,
        queryEntities: [polylines],
        editIndex: 0,
        onUnEditEntity(ent, ctx) {
           ctx.complete()
        }
    })
    console.log(polylines.getData())
    polylines.remove();
    app.logInfo("完成", "success")
});
gui.addButton("选择对象进行删除", async () => {
    app.logInfo("选择要删除的对象，按右键进行删除，ESC取消")
    let escPressed = false;
    let res = await app.actionPickSelect({
        // 默认 ESC 与右键都返回 isCancel=false（会照样删除），这里把 ESC/Enter 交给 onKeyUp 自行区分
        enterEscConfirmExit: false,
        onKeyUp(info, ctx) {
            if (vjmap3d.isEscKey(info)) { escPressed = true; ctx.complete(); }
        },
        onPointerMove(info, context, oldHover, newHover) {
            if (oldHover?.target == newHover?.target) return;
            if (oldHover?.target) oldHover.target.selected = false;
            if (newHover?.target) newHover.target.selected = true;
        },
    });
    if (escPressed || res.isCancel || !res.select) return;
    res.select.target.remove()
});
gui.addButton("选择物体移动", async () => {
    app.logInfo("请选择物体，按右键结果选择，移动位置按 Ctrl+I 可输入坐标", 5000)
    let res = await app.actionPickSelect({
        onPointerMove(info, context, oldHover, newHover) {
            if (oldHover?.target == newHover?.target) return;
            if (oldHover?.target) oldHover.target.selected = false;
            if (newHover?.target) newHover.target.selected = true;
        }
    });
    if (res.isCancel || !res.select) return;
    let ent = res.select.target;
    let pos = ent.position.clone()
    let ptRes = await app.actionDrawPoint({
        updateCoordinate(point, isMoveing, data, symbol) {
            ent.position.copy(point)
        },onKeyUp(info, context) {
            let isCtrl = vjmap3d.isCtrlKeyDown(info);
            let keyCode = vjmap3d.eventKeyCode(info) ;
            if (isCtrl && keyCode == 73) {
                // Ctrl + I
                let info = prompt("请输入坐标，格式x,y,z如 10,0,20");
                let pts = info?.split(",").map(p => +p);
                // @ts-ignore
                context.addPoint(new THREE.Vector3().fromArray(pts))
            }
        }
    })
    ent.selected = false;
    if(ptRes.isCancel) {
        ent.position.copy(pos)
        app.logInfo("取消", "warn")
    } else {
        app.logInfo("完成", "success")
    }
});
gui.addButton("绘制矩形", async () => {
    let rect = new vjmap3d.PolylinesEntity({
        data: []
    })
    let ptRes1 = await app.actionDrawPoint();
    console.log(ptRes1)
    if (ptRes1.isCancel) return
    let pt = ptRes1.data.position
    rect.setData([{
        coordinates: [pt, pt, pt, pt, pt]
    }])
    rect.addTo(app)
    let ptRes2 = await app.actionDrawPoint({
        updateCoordinate(point, isMoveing, data, symbol) {
            let pt2 = [pt[0], pt[1], point.z]
            let pt3 = point.toArray();
            let pt4 = [point.x, point.y, pt[2]]
            rect.setData([{
                coordinates: [pt, pt2, pt3, pt4, pt]
            }])
        },
    })
    console.log(ptRes2)
    let data = rect.getData()[0];
    rect.remove()
    if (ptRes2.isCancel) {
        return
    }
    let drawLayer = app.getDrawLayer("", {});
    drawLayer.addPolylineData(data)
});
gui.toJson().forEach(c => ui.appendChild(c));
</pre>
</mapFrame>

#### 第一个实体变换
```js
 app.logInfo("请按Enter或ESC结束操作（ESC不会撤销已做的变换）")
let options = {
    target: firstEntity.node,
    clickNoSelfEntityExit: false,
    clickNoEntityExit: false,
    transformSize: 1,
}
let res = await app.transformObject(options)
console.log(res)
app.logInfo("实体变换操作完成", "success")
```

#### 选择多个进行变换
```js
app.logInfo("请左键选择要变换的对象，右键结束确定选择")
app.setCurrentTool(); // 取消之前的工具操作
let res = await app.pickEntity({
    rightClickConfirm: true,
    highlightUseBoxHelper: true
});
if (res.entities?.length == 0) return;
let entities = res.entities || [];
let positions = entities.map(p => p.position.clone())
let options = {
    target: entities[entities.length - 1].position,
    pressEKeyModeRotate: false,
    pressRKeyModeScale: false,
    clickEntityAttach: false,
    clickNoSelfEntityExit: false,
    clickNoEntityExit: false,
    rightClickConfirmExit: true,
    editAsCommand: true,
    dragCallBack: (param) => {
        if (param.isReset) {
            // 重新开始了
            positions = entities.map(p => p.position.clone())
        } else if (param.isOnceFinish && param.command) {
            // 一次完成，加入命令
            for(let n = 0; n < entities.length; n++) {
                param.command.addCommand(new vjmap3d.SetPositionCommand(entities[n].node, entities[n].position, positions[n]));
            }
        }
        res.entities?.forEach(ent => {
            ent.position.add(param.deltaPosition)
        })
        for(let n = 0; n < entities.length; n++) {
            entities[n].position.copy(positions[n].clone().add(param.deltaPosition));
        }
    },
    // onExitCallBack(node, context) {
    //     if (node.id != "edit") return;
    //     app.setCurrentTool()
    // },
}
await app.transformObject(options)
res.clearHighlight();
```

#### 自定义绘制多边形
```js
app.logInfo("绘制过程中按 Ctrl+I 输入坐标，右键弹出菜单", 5000)
let lastPoint;
let drawRes = await app.actionDrawPolygon({
    isAddToApp: true,
    //pointMaxCount: 3, // 三个点结束
    updateCoordinate(point, isMoveing, data) {
        let text;
        text = `当前坐标：${point.x.toFixed(2)}, ${point.y.toFixed(2)}, ${point.z.toFixed(2)}`
        if (!isMoveing) {
            lastPoint = point;
        }
        if (lastPoint) {
            text += `<br/>距离上一个点: ${point.distanceTo(lastPoint).toFixed(2)}`
        }
        app.popup2d.show()
        app.popup2d.setHTML(text)
        app.popup2d.setPosition(point)
    },
    onKeyUp(info, context) {
        let isCtrl = vjmap3d.isCtrlKeyDown(info);
        let keyCode = vjmap3d.eventKeyCode(info) ;
        if (isCtrl && keyCode == 73) {
            // Ctrl + I
            let info = prompt("请输入坐标，格式x,y,z如 10,0,20");
            let pts = info?.split(",").map(p => +p);
            // @ts-ignore
            context.addPoint(new THREE.Vector3().fromArray(pts))
        }
    },
    onContextMenu(info, context) {
        new vjmap3d.ContextMenu({
            // @ts-ignore
            event: info.event.originalEvent,
            theme: "dark", //light
            width: "250px",
            items: [
                {
                    label: '取消',
                    onClick: () => {
                            context.cancel()
                    }
                }
            ]
        });
    },
    onComplete() {
        app.popup2d.hide()
    }
})
console.log(drawRes)
if (drawRes.isCancel) {
    app.logInfo("取消绘制", "warn")
} else {
    app.logInfo("绘制完成", "success")
}
```

#### 编辑绘制的第一个多边形
```js
let res = await app.actionDrawEdit({
    editEntity: app.getDrawLayer()?.polygons(),
    editIndex: 0,
    //editShowVertex: false,
    onUnEditEntity(ent, ctx) {
        ctx.complete()
    }
})
console.log(res)
app.logInfo("编辑完成", "success")
```

#### 第一个多边形显示或隐藏
```js
let  polygons = app.getDrawLayer()?.polygons()
let data = polygons.getData()
data[0].hidden = !data[0].hidden;
polygons.setData(data)
```

#### 删除第一个多边形
```js
let  layer = app.getDrawLayer()
// id 是实体内自增的字符串、删除后不复用，不要写死 "0"，从当前数据里取
let first = layer.polygons()?.getData()[0]
if (first) layer.deletePolygonData({
    id: first.id,
    coordinates: []
})
```

#### 更新第一个多边形坐标
```js
let  layer = app.getDrawLayer()
let first = layer.polygons()?.getData()[0]
// update*Data 按 id 整项替换，未带的字段（颜色等）会被重置，先展开原数据
if (first) layer.updatePolygonData({
    ...first,
    coordinates: [vjmap3d.randPoint3D(),vjmap3d.randPoint3D(), vjmap3d.randPoint3D()]
})
```

#### 给定坐标进行编辑坐标
```js
let data = [];
data.push({
    coordinates: [vjmap3d.randPoint3D(), vjmap3d.randPoint3D(), vjmap3d.randPoint3D()],
    color: "#00ff00",
    lineWidth: 3,
    dashScale: 2,
    dashOffset: 0,
    dashSize: 3,
    gapSize: 3,
    dashed: true
})
let polylines = new vjmap3d.PolylinesEntity({
    data: data
})
polylines.addTo(app)
await app.actionDrawEdit({
    editEntity: polylines,
    queryEntities: [polylines],
    editIndex: 0,
    onUnEditEntity(ent, ctx) {
        ctx.complete()
    }
})
console.log(polylines.getData())
polylines.remove();
app.logInfo("完成", "success")
```

#### 选择对象进行删除
```js
app.logInfo("选择要删除的对象，按右键进行删除，ESC取消")
let escPressed = false;
let res = await app.actionPickSelect({
    // 默认 ESC 与右键都返回 isCancel=false（会照样删除），这里把 ESC/Enter 交给 onKeyUp 自行区分
    enterEscConfirmExit: false,
    onKeyUp(info, ctx) {
        if (vjmap3d.isEscKey(info)) { escPressed = true; ctx.complete(); }
    },
    onPointerMove(info, context, oldHover, newHover) {
        if (oldHover?.target == newHover?.target) return;
        if (oldHover?.target) oldHover.target.selected = false;
        if (newHover?.target) newHover.target.selected = true;
    },
});
if (escPressed || res.isCancel || !res.select) return;
res.select.target.remove()
```

#### 选择物体移动
```js
app.logInfo("请选择物体，按右键结果选择，移动位置按 Ctrl+I 可输入坐标", 5000)
let res = await app.actionPickSelect({
    onPointerMove(info, context, oldHover, newHover) {
        if (oldHover?.target == newHover?.target) return;
        if (oldHover?.target) oldHover.target.selected = false;
        if (newHover?.target) newHover.target.selected = true;
    }
});
if (res.isCancel || !res.select) return;
let ent = res.select.target;
let pos = ent.position.clone()
let ptRes = await app.actionDrawPoint({
    updateCoordinate(point, isMoveing, data, symbol) {
        ent.position.copy(point)
    },onKeyUp(info, context) {
        let isCtrl = vjmap3d.isCtrlKeyDown(info);
        let keyCode = vjmap3d.eventKeyCode(info) ;
        if (isCtrl && keyCode == 73) {
            // Ctrl + I
            let info = prompt("请输入坐标，格式x,y,z如 10,0,20");
            let pts = info?.split(",").map(p => +p);
            // @ts-ignore
            context.addPoint(new THREE.Vector3().fromArray(pts))
        }
    }
})
ent.selected = false;
if(ptRes.isCancel) {
    ent.position.copy(pos)
    app.logInfo("取消", "warn")
} else {
    app.logInfo("完成", "success")
}
```

#### 绘制矩形
```js
let rect = new vjmap3d.PolylinesEntity({
    data: []
})
let ptRes1 = await app.actionDrawPoint();
console.log(ptRes1)
if (ptRes1.isCancel) return
let pt = ptRes1.data.position
rect.setData([{
    coordinates: [pt, pt, pt, pt, pt]
}])
rect.addTo(app)
let ptRes2 = await app.actionDrawPoint({
    updateCoordinate(point, isMoveing, data, symbol) {
        let pt2 = [pt[0], pt[1], point.z]
        let pt3 = point.toArray();
        let pt4 = [point.x, point.y, pt[2]]
        rect.setData([{
            coordinates: [pt, pt2, pt3, pt4, pt]
        }])
    },
})
console.log(ptRes2)
let data = rect.getData()[0];
rect.remove()
if (ptRes2.isCancel) {
    return
}
let drawLayer = app.getDrawLayer("", {});
drawLayer.addPolylineData(data)
```