u-space 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,182 @@
1
+ import { PlaneGeometry as n, Mesh as r, MeshBasicNodeMaterial as h, BufferGeometry as a, Float32BufferAttribute as l, Line as p, LineBasicNodeMaterial as _ } from "three/webgpu";
2
+ import { Topology as v } from "u-space";
3
+ class u {
4
+ viewer;
5
+ topology;
6
+ _snapRadius;
7
+ // Typed reference to viewer.scene for InteractionEventMap listeners
8
+ _scene;
9
+ // Invisible ground plane added to scene as raycast fallback for empty space
10
+ _groundMesh;
11
+ _previewLine;
12
+ _previewGeometry;
13
+ _lastNodeId = null;
14
+ _nodeCounter = 0;
15
+ _history = [];
16
+ _enabled = !1;
17
+ _prevFrameloop = "demand";
18
+ _prevPointerMoveEnabled = !1;
19
+ constructor(e, t) {
20
+ this.viewer = e, this._snapRadius = t?.snapRadius ?? 1, this._scene = e.scene, this.topology = new v({
21
+ nodeColor: t?.nodeColor,
22
+ nodeRadius: t?.nodeRadius,
23
+ edgeColor: t?.edgeColor,
24
+ edgeRadius: t?.edgeRadius,
25
+ pathColor: t?.pathColor,
26
+ pathRadius: t?.pathRadius
27
+ });
28
+ const o = new n(1e5, 1e5);
29
+ o.rotateX(-Math.PI / 2), this._groundMesh = new r(o, new h({ visible: !1 })), this._groundMesh.position.y = t?.groundY ?? 0, this._previewGeometry = new a(), this._previewGeometry.setAttribute("position", new l(new Float32Array(6), 3)), this._previewLine = new p(
30
+ this._previewGeometry,
31
+ new _({ color: t?.previewColor ?? 16776960, depthTest: !1 })
32
+ ), this._previewLine.renderOrder = 10, this._previewLine.visible = !1;
33
+ }
34
+ // ─── Private helpers ──────────────────────────────────────────────────────
35
+ _findNearestNode(e) {
36
+ let t = this._snapRadius, o = null;
37
+ return this.topology.nodes.forEach((i, s) => {
38
+ const d = e.distanceTo(i);
39
+ d < t && (t = d, o = s);
40
+ }), o;
41
+ }
42
+ _setPreviewLine(e, t) {
43
+ const o = this._previewGeometry.attributes.position;
44
+ o.setXYZ(0, e.x, e.y, e.z), o.setXYZ(1, t.x, t.y, t.z), o.needsUpdate = !0, this._previewLine.visible = !0;
45
+ }
46
+ /**
47
+ * Resolve the world-space placement point from an InteractionEvent.
48
+ *
49
+ * - If the original hit was a topology node, return its registered position
50
+ * (more accurate than the sphere surface point).
51
+ * - If the hit was a topology edge, return the surface hit point so a new
52
+ * node can be inserted at that location and connected.
53
+ * - Otherwise use intersect.point directly.
54
+ */
55
+ _resolvePoint(e) {
56
+ const t = e.intersect?.point;
57
+ if (!t) return null;
58
+ const { type: o, id: i } = e.target.userData;
59
+ return o === "node" && i ? this.topology.nodes.get(i)?.clone() ?? t.clone() : t.clone();
60
+ }
61
+ _placePoint(e) {
62
+ const t = this._findNearestNode(e);
63
+ if (t) {
64
+ this._lastNodeId && this._lastNodeId !== t && (this.topology.addEdge(this._lastNodeId, t), this._history.push({
65
+ type: "addEdge",
66
+ from: this._lastNodeId,
67
+ to: t,
68
+ prevLastNodeId: this._lastNodeId
69
+ }), this._lastNodeId = t, this.topology.renderGraph());
70
+ return;
71
+ }
72
+ const o = `node_${this._nodeCounter++}`;
73
+ this.topology.addNode(o, e);
74
+ const i = { type: "addNode", nodeId: o, prevLastNodeId: this._lastNodeId };
75
+ this._lastNodeId && (this.topology.addEdge(this._lastNodeId, o), i.addedEdge = { from: this._lastNodeId, to: o }), this._history.push(i), this._lastNodeId = o, this.topology.renderGraph();
76
+ }
77
+ /**
78
+ * Insert a new node into an existing edge (split it).
79
+ *
80
+ * The original edge (cutFrom ↔ cutTo) is removed and replaced with two
81
+ * edges: cutFrom → newNode → cutTo.
82
+ * If a path chain is active (_lastNodeId set, and different from the edge
83
+ * endpoints), an additional edge _lastNodeId → newNode is added.
84
+ */
85
+ _splitEdge(e, t, o, i) {
86
+ const s = `node_${this._nodeCounter++}`;
87
+ this.topology.addNode(s, e), this.topology.removeEdge(t, o), this.topology.addEdge(t, s), this.topology.addEdge(s, o);
88
+ const d = {
89
+ type: "splitEdge",
90
+ nodeId: s,
91
+ cutFrom: t,
92
+ cutTo: o,
93
+ cutWeight: i,
94
+ prevLastNodeId: this._lastNodeId
95
+ };
96
+ this._lastNodeId && this._lastNodeId !== t && this._lastNodeId !== o && (this.topology.addEdge(this._lastNodeId, s), d.chainEdge = { from: this._lastNodeId, to: s }), this._history.push(d), this._lastNodeId = s, this.topology.renderGraph();
97
+ }
98
+ // ─── Scene-level InteractionManager event handlers ────────────────────────
99
+ _onSceneClick = ({ event: e }) => {
100
+ const t = e.target.userData;
101
+ if (t.type === "edge" && t.from && t.to) {
102
+ const i = e.intersect?.point;
103
+ if (!i) return;
104
+ this._splitEdge(i.clone(), t.from, t.to, t.weight ?? 0);
105
+ return;
106
+ }
107
+ const o = this._resolvePoint(e);
108
+ o && this._placePoint(o);
109
+ };
110
+ _onScenePointerMove = ({ event: e }) => {
111
+ if (!this._lastNodeId) return;
112
+ const t = e.intersect?.point;
113
+ if (!t) return;
114
+ const o = this.topology.nodes.get(this._lastNodeId);
115
+ o && this._setPreviewLine(o, t);
116
+ };
117
+ _onSceneContextMenu = ({ event: e }) => {
118
+ e.originalEvent.preventDefault(), this.finishPath();
119
+ };
120
+ _onKeyDown = (e) => {
121
+ e.key === "Escape" ? this.finishPath() : (e.ctrlKey || e.metaKey) && e.key === "z" && (e.preventDefault(), this.undo());
122
+ };
123
+ // ─── Public API ───────────────────────────────────────────────────────────
124
+ /** Finish the current path chain. Next click starts an unconnected new path. */
125
+ finishPath() {
126
+ this._history.push({ type: "finishPath", prevLastNodeId: this._lastNodeId }), this._lastNodeId = null, this._previewLine.visible = !1;
127
+ }
128
+ /** Undo the last action (node, edge, or finishPath). */
129
+ undo() {
130
+ const e = this._history.pop();
131
+ if (e)
132
+ if (e.type === "addNode" ? (e.addedEdge && this.topology.removeEdge(e.addedEdge.from, e.addedEdge.to), this.topology.removeNode(e.nodeId), this._lastNodeId = e.prevLastNodeId, this.topology.renderGraph()) : e.type === "addEdge" ? (this.topology.removeEdge(e.from, e.to), this._lastNodeId = e.prevLastNodeId, this.topology.renderGraph()) : e.type === "splitEdge" ? (this.topology.removeEdge(e.cutFrom, e.nodeId), this.topology.removeEdge(e.nodeId, e.cutTo), e.chainEdge && this.topology.removeEdge(e.chainEdge.from, e.chainEdge.to), this.topology.removeNode(e.nodeId), this.topology.addEdge(e.cutFrom, e.cutTo, e.cutWeight), this._lastNodeId = e.prevLastNodeId, this.topology.renderGraph()) : e.type === "finishPath" && (this._lastNodeId = e.prevLastNodeId), this._lastNodeId) {
133
+ const t = this.topology.nodes.get(this._lastNodeId);
134
+ t && this._setPreviewLine(t, t);
135
+ } else
136
+ this._previewLine.visible = !1;
137
+ }
138
+ /** Remove all drawn nodes, edges, and path meshes. */
139
+ clear() {
140
+ this.topology.clearGraph(), this.topology.clearPaths(), this.topology.nodes.clear(), this.topology.adjacencyMap.clear(), this._lastNodeId = null, this._nodeCounter = 0, this._history = [], this._previewLine.visible = !1;
141
+ }
142
+ /** Export the current topology as a JSON-serializable object. Delegates to `topology.exportData()`. */
143
+ exportData() {
144
+ return this.topology.exportData();
145
+ }
146
+ /**
147
+ * Load a previously exported topology, replacing any current drawing.
148
+ * Delegates to `topology.importData()` and resets the drawer's internal node counter.
149
+ */
150
+ importData(e) {
151
+ this._lastNodeId = null, this._history = [], this._previewLine.visible = !1, this.topology.importData(e), Object.keys(e.nodes).forEach((t) => {
152
+ const o = parseInt(t.replace("node_", ""), 10);
153
+ !isNaN(o) && o >= this._nodeCounter && (this._nodeCounter = o + 1);
154
+ });
155
+ }
156
+ /**
157
+ * Enable drawing mode.
158
+ *
159
+ * - Adds the topology, preview line, and fallback ground plane to the scene.
160
+ * - Registers `click`, `pointermove`, `contextmenu` listeners on `viewer.scene`
161
+ * (events are bubbled up by InteractionManager from whichever object is hit).
162
+ * - Enables `pointerMoveEventsEnabled` on the InteractionManager for the
163
+ * preview line to track the cursor.
164
+ */
165
+ enable() {
166
+ return this._enabled ? this : (this._enabled = !0, this._prevFrameloop = this.viewer.frameloop, this._prevPointerMoveEnabled = this.viewer.interactionManager.pointerMoveEventsEnabled, this.viewer.frameloop = "always", this.viewer.interactionManager.pointerMoveEventsEnabled = !0, this.viewer.scene.add(this._groundMesh), this.viewer.scene.add(this.topology), this.viewer.scene.add(this._previewLine), this._scene.addEventListener("click", this._onSceneClick), this._scene.addEventListener("pointermove", this._onScenePointerMove), this._scene.addEventListener("rightclick", this._onSceneContextMenu), window.addEventListener("keydown", this._onKeyDown), this);
167
+ }
168
+ /**
169
+ * Disable drawing mode.
170
+ * The drawn topology remains visible in the scene.
171
+ */
172
+ disable() {
173
+ return this._enabled ? (this._enabled = !1, this.viewer.frameloop = this._prevFrameloop, this.viewer.interactionManager.pointerMoveEventsEnabled = this._prevPointerMoveEnabled, this.viewer.scene.remove(this._groundMesh), this.viewer.scene.remove(this._previewLine), this._previewLine.visible = !1, this._scene.removeEventListener("click", this._onSceneClick), this._scene.removeEventListener("pointermove", this._onScenePointerMove), this._scene.removeEventListener("rightclick", this._onSceneContextMenu), window.removeEventListener("keydown", this._onKeyDown), this) : this;
174
+ }
175
+ /** Fully dispose all resources and remove the topology from the scene. */
176
+ dispose() {
177
+ this.disable(), this.viewer.scene.remove(this.topology), this._groundMesh.geometry.dispose(), this._groundMesh.material.dispose(), this._previewGeometry.dispose(), this._previewLine.material.dispose(), this.topology.dispose();
178
+ }
179
+ }
180
+ export {
181
+ u as TopologyDrawer
182
+ };
@@ -1,5 +1,5 @@
1
1
  import type { Object3D, Intersection, Object3DEventMap } from 'three/webgpu';
2
- export type InteractionEventType = 'click' | 'dblclick' | 'contextmenu' | 'pointerdown' | 'pointerup' | 'pointermove' | 'pointerenter' | 'pointerleave';
2
+ export type InteractionEventType = 'click' | 'dblclick' | 'rightclick' | 'pointerdown' | 'pointerup' | 'pointermove' | 'pointerenter' | 'pointerleave';
3
3
  export interface InteractionEventParameters {
4
4
  type: InteractionEventType;
5
5
  target: Object3D<InteractionEventMap>;
@@ -17,10 +17,9 @@ export declare class InteractionManager {
17
17
  private longPressThreshold;
18
18
  private moveThreshold;
19
19
  /**
20
- * The objects to check for intersections.
21
- * If not set, it defaults to the children of the scene.
20
+ * The objects to check for intersections. default to all children of the scene (including nested).
22
21
  */
23
- targetObjects: Object3D<InteractionEventMap>[] | null;
22
+ targetObjects: Object3D<InteractionEventMap>[];
24
23
  /**
25
24
  * Whether to enable pointer move events (pointermove, pointerenter, pointerleave).
26
25
  * Disabled by default for performance optimization.
@@ -1,7 +1,19 @@
1
- import { type Vector3, Group, type ColorRepresentation } from 'three/webgpu';
1
+ import { Vector3, Group, type ColorRepresentation } from 'three/webgpu';
2
2
  import type { InteractionEventMap } from '../interactions';
3
3
  import { TubeMesh } from './TubeMesh';
4
4
  import { BaseGroup } from './BaseGroup';
5
+ export type TopologyData = {
6
+ nodes: Record<string, {
7
+ x: number;
8
+ y: number;
9
+ z: number;
10
+ }>;
11
+ edges: Array<{
12
+ from: string;
13
+ to: string;
14
+ weight: number;
15
+ }>;
16
+ };
5
17
  export interface TopologyParameters {
6
18
  nodeColor?: ColorRepresentation;
7
19
  nodeRadius?: number;
@@ -59,6 +71,13 @@ export declare class Topology extends BaseGroup<InteractionEventMap> {
59
71
  */
60
72
  renderPath(points: Vector3[], color?: ColorRepresentation): TubeMesh | undefined;
61
73
  clearPaths(): void;
74
+ /** Export nodes and edges as a JSON-serializable object. */
75
+ exportData(): TopologyData;
76
+ /**
77
+ * Load topology from a previously exported data object.
78
+ * Replaces all current nodes, edges, and path meshes, then re-renders the graph.
79
+ */
80
+ importData(data: TopologyData): void;
62
81
  dispose(): void;
63
82
  getNeighbors(id: string): Map<string, number> | undefined;
64
83
  }
@@ -15,14 +15,14 @@ viewer.interactionManager.pointerMoveEventsEnabled = true;
15
15
 
16
16
  ### 目标对象
17
17
 
18
- 可以限制射线检测所针对的对象范围。
18
+ 可以限制射线检测所针对的对象范围。默认值为 `scene.children`(即整个场景的直接子级,含递归嵌套)。
19
19
 
20
20
  ```typescript
21
- // 默认行为:检测整个场景
22
- viewer.interactionManager.targetObjects = [];
23
-
24
21
  // 仅检测指定对象
25
- // viewer.interactionManager.targetObjects = [myBox, myModel];
22
+ viewer.interactionManager.targetObjects = [myBox, myModel];
23
+
24
+ // 恢复默认:检测整个场景
25
+ viewer.interactionManager.targetObjects = viewer.scene.children;
26
26
  ```
27
27
 
28
28
  ## 添加事件监听
@@ -48,7 +48,7 @@ myBox.addEventListener('click', (eventData) => {
48
48
 
49
49
  - `click`:指针点击对象时触发(过滤:长按或大幅移动后忽略)。
50
50
  - `dblclick`:快速双击对象时触发。
51
- - `contextmenu`:右键点击时触发(阻止默认浏览器上下文菜单)。
51
+ - `rightclick`:右键短按松开时触发(长按或拖拽不触发;浏览器原生上下文菜单已被阻止)。与原生 `contextmenu` 的区别在于,此事件在 `pointerup` 时派发,因此可以正确过滤相机拖拽等操作。
52
52
  - `pointerdown`:指针按键在对象上按下时触发。
53
53
  - `pointerup`:指针按键在对象上释放时触发。
54
54
  - `pointermove`:指针在对象上移动时触发,需要 `pointerMoveEventsEnabled = true`。
@@ -91,7 +91,7 @@ myObject.addEventListener('click', (e) => {
91
91
 
92
92
  | 属性 | 类型 | 默认值 | 说明 |
93
93
  | :------------------------- | :---------------------------- | :------ | :--------------------------------------------------------- |
94
- | `targetObjects` | `Object3D[] \| null` | `null` | 射线检测的目标对象。`null` 表示检测所有场景子对象。 |
94
+ | `targetObjects` | `Object3D[]` | `scene.children` | 射线检测的目标对象列表,默认为场景的直接子级。 |
95
95
  | `pointerMoveEventsEnabled` | `boolean` | `false` | 启用 `pointermove`、`pointerenter`、`pointerleave` 事件。 |
96
96
 
97
97
  ### 方法
@@ -186,7 +186,9 @@ const poi = new Poi({
186
186
  backgroundColor: 'rgba(0,0,0,0.6)',
187
187
  textPosition: 'right',
188
188
  });
189
- await poi.updateAsync(); // 渲染 canvas 纹理
189
+ // 构造时会自动调用 updateAsync(),无需手动调用
190
+ // 如需更新参数,可再次调用:
191
+ // await poi.updateAsync({ text: '新文字' });
190
192
 
191
193
  poi.position.set(10, 5, 10);
192
194
  viewer.scene.add(poi);
@@ -203,15 +205,16 @@ viewer.scene.add(poi);
203
205
  | `color` | `string` | `'#ffffff'` | 文字颜色。 |
204
206
  | `iconSize` | `number` | `64` | 图标大小(像素)。 |
205
207
  | `padding` | `number` | `10` | 内容四周的内边距。 |
206
- | `backgroundColor` | `string` | `'rgba(0, 0, 0, 0.5)'` | 背景填充颜色。 |
208
+ | `backgroundColor` | `string` | `'rgba(0, 0, 0, 0)'` | 背景填充颜色。 |
207
209
  | `borderRadius` | `number` | `8` | 背景圆角半径。 |
208
210
  | `textPosition` | `'top' \| 'bottom' \| 'left' \| 'right'` | `'right'` | 文字相对图标的位置。 |
211
+ | `scaleFactor` | `number` | `0.01` | canvas 像素到世界单位的缩放系数。 |
209
212
 
210
213
  ### 方法
211
214
 
212
215
  #### `updateAsync(parameters?)`
213
216
 
214
- 使用可选的参数覆盖重新渲染 canvas 纹理。
217
+ 使用可选的参数覆盖重新渲染 canvas 纹理。构造时会自动调用一次,后续参数未变化时会跳过重绘。
215
218
 
216
219
  ```typescript
217
220
  await poi.updateAsync({ text: '更新后的标签', color: '#ffff00' });
@@ -306,6 +309,25 @@ viewer.render();
306
309
 
307
310
  返回节点的邻接表(邻居 ID → 边权重)。
308
311
 
312
+ #### `exportData(): TopologyData`
313
+
314
+ 将当前拓扑图(节点和边)导出为 JSON 可序列化对象。
315
+
316
+ ```typescript
317
+ const data = topo.exportData();
318
+ // { nodes: Record<string, {x,y,z}>, edges: Array<{from,to,weight}> }
319
+ ```
320
+
321
+ 双向边在导出时会去重,只保留一条记录。
322
+
323
+ #### `importData(data: TopologyData)`
324
+
325
+ 从 `exportData()` 返回的数据中还原拓扑图,替换当前所有节点、边和路径网格,并重新渲染。
326
+
327
+ ```typescript
328
+ topo.importData(data);
329
+ ```
330
+
309
331
  #### `dispose()`
310
332
 
311
333
  清除图和路径网格。
@@ -2,9 +2,7 @@
2
2
 
3
3
  `u-space` 架构包含一套丰富的插件,提供从 GIS 瓦片加载到键盘控制和场景管理等高级功能。所有插件从 `u-space/plugins/*` 命名空间导入。
4
4
 
5
- ## 可用插件
6
-
7
- ### `keyboard-controls`
5
+ ## `keyboard-controls`
8
6
 
9
7
  为激活相机提供 WASD 或方向键的平移和旋转控制。
10
8
 
@@ -45,7 +43,7 @@ import { ACTION } from 'u-space/plugins/keyboard-controls';
45
43
  keyboardControls.keys['Space'] = ACTION.MOVE_UP;
46
44
  ```
47
45
 
48
- ### `minimap`
46
+ ## `minimap`
49
47
 
50
48
  生成一个 2D 小地图叠加层,在 3D 场景中跟踪目标对象。渲染到注入 `viewer.el` 的 `<canvas>` 元素中。
51
49
 
@@ -82,7 +80,7 @@ minimap.dispose();
82
80
  - `disable()` — 移除画布并停止渲染。
83
81
  - `dispose()` — 禁用并清理所有资源。
84
82
 
85
- ### `tiles`
83
+ ## `tiles`
86
84
 
87
85
  提供与 `3d-tiles-renderer` 和地理空间数据的集成。主要导出 `ArcgisTilesRenderer`,可流式加载 ArcGIS Online 3D 瓦片并将地球重新定向到指定地理位置。
88
86
 
@@ -109,7 +107,7 @@ arcgisTilesRenderer.enable();
109
107
  | `disable()` | 从场景中移除瓦片并暂停更新循环。 |
110
108
  | `dispose()` | 禁用并完全释放瓦片渲染器。 |
111
109
 
112
- ### `u-manager`
110
+ ## `u-manager`
113
111
 
114
112
  `u-manager` 是一套全面的加载器和解析器,用于从服务器路径流式传输、解密并显示结构化场景数据。支持场景、拓扑、动画、属性和相机视点。
115
113
 
@@ -233,7 +231,7 @@ const modelProps = properties.filter(p => p.modelId === myModel.userData.id);
233
231
  | `value` | `string \| null` | 属性值。 |
234
232
  | `label` | `string \| null` | 属性的显示标签。 |
235
233
 
236
- ### `curve-movement`
234
+ ## `curve-movement`
237
235
 
238
236
  沿样条路径对相机或对象进行动画。提供两个具体子类:`CurveMovementCamera` 和 `CurveMovementObject`。
239
237
 
@@ -272,7 +270,7 @@ movement.addEventListener('complete', () => console.log('完成'));
272
270
  | `positionOffset`| `Vector3` | `(0,0,0)` | 叠加到每个位置上的世界坐标偏移量。 |
273
271
  | `direction` | `1 \| -1` | `1` | 当前行进方向。 |
274
272
 
275
- ### `tracking-controls`
273
+ ## `tracking-controls`
276
274
 
277
275
  使相机平滑跟随移动的 `Object3D` 目标。
278
276
 
@@ -296,7 +294,7 @@ tracking.enable();
296
294
  | `type` | `'position' \| 'box3'` | `'position'` | 跟踪世界位置还是包围盒中心。 |
297
295
  | `offset` | `Vector3` | `(0,0,0)` | 在移动相机前叠加到跟踪位置上的偏移量。 |
298
296
 
299
- ### `atmosphere`
297
+ ## `atmosphere`
300
298
 
301
299
  天空和大气渲染插件。目前为占位实现,`enable()` / `disable()` / `dispose()` 方法可用,但尚未实现具体功能。
302
300
 
@@ -306,3 +304,146 @@ import { Atmosphere } from 'u-space/plugins/atmosphere';
306
304
  const atmosphere = new Atmosphere(viewer);
307
305
  atmosphere.enable();
308
306
  ```
307
+
308
+ ## `object-controls`
309
+
310
+ 对场景中的 3D 对象进行交互式移动、旋转、缩放操作。继承自 Three.js `TransformControls`,集成了 u-space `Viewer` 生命周期管理。
311
+
312
+ ```typescript
313
+ import { ObjectControls } from 'u-space/plugins/object-controls';
314
+
315
+ const controls = new ObjectControls(viewer, {
316
+ mode: 'translate', // 'translate' | 'rotate' | 'scale',默认 'translate'
317
+ space: 'world', // 'world' | 'local',默认 'world'
318
+ size: 1, // 控件显示大小,默认 1
319
+ });
320
+
321
+ controls.enable();
322
+ controls.attach(myMesh); // 将控件附加到目标对象
323
+
324
+ // 切换模式(直接赋值,继承自 TransformControls)
325
+ controls.mode = 'rotate';
326
+ controls.space = 'local';
327
+
328
+ // 监听变换事件
329
+ controls.addEventListener('objectChange', () => {
330
+ console.log(myMesh.position);
331
+ });
332
+
333
+ controls.addEventListener('dragging-changed', ({ value }) => {
334
+ console.log('拖拽中:', value); // true = 开始,false = 结束
335
+ });
336
+ ```
337
+
338
+ **说明:**
339
+
340
+ - `enable()` 时将 gizmo 加入场景并切换 `frameloop` 为 `'always'`,确保拖拽时连续渲染。
341
+ - 拖拽期间自动挂起 `viewer.controls`(CameraControls)以避免冲突;`disable()` 时无论如何都强制还原相机控制,防止 mid-drag 调用 disable 导致相机卡死。
342
+ - 监听 `viewer` 的 `cameraChange` 事件,`viewer.setCamera()` 切换相机后 gizmo 自动同步,无需手动更新。
343
+
344
+ **构造选项(`ObjectControlsOptions`):**
345
+
346
+ | 选项 | 类型 | 默认值 | 说明 |
347
+ | :------- | :--------------------------------- | :------------ | :--------------- |
348
+ | `mode` | `'translate' \| 'rotate' \| 'scale'` | `'translate'` | 初始变换模式。 |
349
+ | `space` | `'world' \| 'local'` | `'world'` | 初始变换空间。 |
350
+ | `size` | `number` | `1` | gizmo 大小。 |
351
+ | `showX` | `boolean` | `true` | 显示 X 轴手柄。 |
352
+ | `showY` | `boolean` | `true` | 显示 Y 轴手柄。 |
353
+ | `showZ` | `boolean` | `true` | 显示 Z 轴手柄。 |
354
+
355
+ **方法:**
356
+
357
+ | 方法 | 说明 |
358
+ | :---------- | :----------------------------------------------------------- |
359
+ | `enable()` | 将 gizmo 加入场景,切换 frameloop 为 `'always'`。 |
360
+ | `disable()` | 移除 gizmo,还原 frameloop 和 CameraControls。 |
361
+ | `attach(object)` | 将控件附加到指定 Object3D。 |
362
+ | `detach()` | 解除当前附加对象。 |
363
+ | `dispose()` | 完全释放所有资源。 |
364
+
365
+ **属性:**
366
+
367
+ | 属性 | 类型 | 说明 |
368
+ | :--------- | :-------- | :------------------------------------ |
369
+ | `isActive` | `boolean` | 当前是否已调用 `enable()`(只读)。 |
370
+ | `object` | `Object3D \| undefined` | 当前附加的对象(继承自 TransformControls)。 |
371
+ | `mode` | `string` | 当前变换模式,可直接赋值切换。 |
372
+ | `space` | `string` | 当前变换空间,可直接赋值切换。 |
373
+
374
+ > 由于 `ObjectControls` 直接继承 `TransformControls`,所有原生属性(`translationSnap`、`rotationSnap`、`scaleSnap`、`showX/Y/Z` 等)和事件均可直接使用,参考 [Three.js TransformControls 文档](https://threejs.org/docs/#examples/en/controls/TransformControls)。
375
+
376
+ ## `topology-drawer`
377
+
378
+ 在 3D 场景中交互式绘制拓扑路径图。点击场景中的任意对象表面放置节点,自动连接成路径;点击空白处则落回不可见的地面平面。事件检测通过 `viewer.interactionManager` 实现(`scene.addEventListener` 冒泡)。
379
+
380
+ ```typescript
381
+ import { TopologyDrawer } from 'u-space/plugins/topology-drawer';
382
+
383
+ const drawer = new TopologyDrawer(viewer, {
384
+ groundY: 0, // 空白区域兜底地面的 Y 坐标,默认 0
385
+ snapRadius: 1.0, // 自动吸附已有节点的世界单位半径,默认 1.0
386
+ nodeRadius: 0.3,
387
+ nodeColor: 0x0088ff,
388
+ edgeRadius: 0.1,
389
+ edgeColor: 0x00ddff,
390
+ previewColor: 0xffff00, // 预览线颜色
391
+ });
392
+
393
+ drawer.enable();
394
+ ```
395
+
396
+ **操作说明:**
397
+
398
+ | 操作 | 效果 |
399
+ | :--- | :--- |
400
+ | 左键点击空白 / 模型表面 | 放置新节点,与上一节点自动连边 |
401
+ | 左键点击已有节点(`snapRadius` 范围内) | 吸附并连接到该节点,不新增节点 |
402
+ | 左键点击已有边 | 切断该边,在点击处插入新节点并重连两端 |
403
+ | 右键 / `Escape` | 结束当前路径链,下次点击开始新路径 |
404
+ | `Ctrl / Cmd + Z` | 撤销最后一步(节点、边或结束路径) |
405
+
406
+ **方法:**
407
+
408
+ | 方法 | 说明 |
409
+ | :--- | :--- |
410
+ | `enable()` | 进入绘制模式,将拓扑与地面平面加入场景。同时自动将 `viewer.interactionManager.pointerMoveEventsEnabled` 置为 `true`(用于预览线跟随光标),并在 `disable()` 时恢复为原值。 |
411
+ | `disable()` | 退出绘制模式,已绘内容仍保留在场景中。恢复 `pointerMoveEventsEnabled` 为调用 `enable()` 前的值。 |
412
+ | `finishPath()` | 结束当前路径链(等同于右键 / Escape)。 |
413
+ | `undo()` | 撤销最后一步操作。 |
414
+ | `clear()` | 清空所有已绘节点、边和路径。 |
415
+ | `exportData()` | 以 JSON 可序列化格式导出当前拓扑数据。 |
416
+ | `importData(data)` | 加载 `exportData()` 返回的数据,替换当前内容。 |
417
+ | `dispose()` | 完全释放所有资源并从场景移除拓扑。 |
418
+
419
+ **属性:**
420
+
421
+ | 属性 | 类型 | 说明 |
422
+ | :--- | :--- | :--- |
423
+ | `topology` | `Topology` | 底层 `Topology` 对象,可进一步访问节点、边和路径方法。 |
424
+
425
+ **导出 / 导入:**
426
+
427
+ ```typescript
428
+ // 导出
429
+ const data = drawer.exportData();
430
+ // data: { nodes: Record<string, {x,y,z}>, edges: Array<{from,to,weight}> }
431
+
432
+ // 导入
433
+ drawer.importData(data);
434
+ ```
435
+
436
+ **结合 `SceneLoader` 在模型表面绘制:**
437
+
438
+ ```typescript
439
+ import { SceneLoader } from 'u-space/plugins/u-manager';
440
+ import { TopologyDrawer } from 'u-space/plugins/topology-drawer';
441
+
442
+ const sceneLoader = new SceneLoader(viewer);
443
+ sceneLoader.setPath('./scenes/my-scene');
444
+ const group = await sceneLoader.loadAsync();
445
+ viewer.scene.add(group);
446
+
447
+ const drawer = new TopologyDrawer(viewer, { snapRadius: 1.0 });
448
+ drawer.enable(); // 可直接在加载的模型表面点击绘制
449
+ ```
@@ -100,6 +100,52 @@ const camera = viewer.createOrthographicCamera();
100
100
  viewer.setCamera(camera);
101
101
  ```
102
102
 
103
+ ## 调试工具
104
+
105
+ ### Info
106
+
107
+ `viewer.info` 是一个轻量的渲染统计叠加层,显示当前帧的 GPU 诊断数据,适合开发调试阶段使用。
108
+
109
+ ```typescript
110
+ viewer.info.enable(); // 在画面左下角显示统计信息
111
+ viewer.info.disable(); // 隐藏统计信息
112
+ ```
113
+
114
+ 启用后将在 `viewer.el` 左下角叠加以下数据:
115
+
116
+ | 指标 | 说明 |
117
+ | :------------- | :---------------------------- |
118
+ | `draw calls` | 当前帧的绘制调用次数 |
119
+ | `frame calls` | 当前帧的帧调用次数 |
120
+ | `triangles` | 当前帧渲染的三角面数量 |
121
+ | `points` | 当前帧渲染的点数量 |
122
+ | `lines` | 当前帧渲染的线段数量 |
123
+ | `timestamp` | GPU 渲染耗时(ms,WebGPU 专属)|
124
+
125
+ > `timestamp` 指标仅在 WebGPU 后端可用,WebGL 回退模式下显示 `0`。
126
+
127
+ ### ViewerHelper
128
+
129
+ `viewer.viewerHelper` 是一个方向指示器 gizmo(基于 Three.js `ViewHelper`),显示当前相机朝向的 XYZ 轴,渲染为画面角落的叠加层。
130
+
131
+ ```typescript
132
+ viewer.viewerHelper.enable(); // 显示方向 gizmo
133
+ viewer.viewerHelper.disable(); // 隐藏方向 gizmo
134
+ ```
135
+
136
+ **属性:**
137
+
138
+ | 属性 | 类型 | 说明 |
139
+ | :------------- | :---------- | :---------------------------------------- |
140
+ | `location` | `object` | gizmo 在画面中的位置,支持 `top`、`bottom`、`left`、`right` 偏移(像素)。默认右下角。 |
141
+
142
+ ```typescript
143
+ // 调整位置到左下角
144
+ viewer.viewerHelper.location.left = 12;
145
+ viewer.viewerHelper.location.bottom = 12;
146
+ viewer.viewerHelper.location.right = null;
147
+ ```
148
+
103
149
  ### `dispose()`
104
150
 
105
151
  清理查看器,从 DOM 中移除 canvas,移除事件监听,并释放渲染器和环境贴图,以防止内存泄漏。
@@ -67,6 +67,7 @@ pnpm install three camera-controls
67
67
  el: app,
68
68
  rendererOptions: { forceWebGL: false }, // 优先使用 WebGPU
69
69
  });
70
+ await viewer.init();
70
71
 
71
72
  // 设置背景颜色
72
73
  viewer.scene.background = new Color(0x666666);
@@ -92,8 +93,7 @@ pnpm install three camera-controls
92
93
  `u-space` 内置了 `InteractionManager`,可以轻松为 3D 对象添加事件监听。
93
94
 
94
95
  ```javascript
95
- // 启用指针移动事件
96
- viewer.interactionManager.targetObjects = [];
96
+ // 启用指针移动事件(pointerenter / pointerleave 需要此开关)
97
97
  viewer.interactionManager.pointerMoveEventsEnabled = true;
98
98
 
99
99
  // 添加点击事件
package/docs/index.md CHANGED
@@ -48,3 +48,5 @@ features:
48
48
  | [curve-movement](./api-plugins#curve-movement) | 沿样条曲线移动相机或对象 |
49
49
  | [tracking-controls](./api-plugins#tracking-controls) | 相机跟随移动目标 |
50
50
  | [atmosphere](./api-plugins#atmosphere) | 天空/大气渲染(开发中) |
51
+ | [object-controls](./api-plugins#object-controls) | 交互式移动、旋转、缩放 3D 对象 |
52
+ | [topology-drawer](./api-plugins#topology-drawer) | 在场景任意表面交互式绘制拓扑路径图 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-space",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "type": "module",
5
5
  "types": "dist/src/index.d.ts",
6
6
  "module": "dist/index.js",
@@ -29,7 +29,7 @@
29
29
  "docs:build": "vitepress build docs",
30
30
  "docs:preview": "vitepress preview docs",
31
31
  "docs:deploy": "pnpm docs:build && vercel --prod",
32
- "release": "npm version patch && npm publish --access public"
32
+ "release": "npm version patch && npm publish --access=public"
33
33
  },
34
34
  "files": [
35
35
  "dist",