u-space 0.0.1 → 0.0.2
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.
- package/dist/index.cjs +1 -1
- package/dist/index.js +104 -76
- package/dist/plugins/object-controls/ObjectControls.d.ts +69 -0
- package/dist/plugins/object-controls/index.cjs +1 -0
- package/dist/plugins/object-controls/index.d.ts +1 -0
- package/dist/plugins/object-controls/index.js +44 -0
- package/dist/plugins/topology-drawer/TopologyDrawer.d.ts +97 -0
- package/dist/plugins/topology-drawer/index.cjs +1 -0
- package/dist/plugins/topology-drawer/index.d.ts +1 -0
- package/dist/plugins/topology-drawer/index.js +182 -0
- package/dist/src/interactions/InteractionEvent.d.ts +1 -1
- package/dist/src/interactions/InteractionManager.d.ts +2 -3
- package/dist/src/objects/Topology.d.ts +20 -1
- package/docs/api-interactions.md +7 -7
- package/docs/api-plugins.md +143 -0
- package/docs/getting-started.md +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { type ColorRepresentation } from 'three/webgpu';
|
|
2
|
+
import { type Viewer, Topology, type TopologyParameters, type TopologyData } from 'u-space';
|
|
3
|
+
export interface TopologyDrawerOptions extends TopologyParameters {
|
|
4
|
+
/** Y coordinate of the fallback ground plane (used when no scene object is hit). Default: 0 */
|
|
5
|
+
groundY?: number;
|
|
6
|
+
/** Color of the preview line shown while drawing. Default: 0xffff00 */
|
|
7
|
+
previewColor?: ColorRepresentation;
|
|
8
|
+
/** Snap to an existing node when clicking within this world-unit radius. Default: 1.0 */
|
|
9
|
+
snapRadius?: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Interactive plugin for drawing topology graphs on any surface in the scene.
|
|
13
|
+
*
|
|
14
|
+
* Detection uses `viewer.interactionManager` — events fired on hit objects bubble
|
|
15
|
+
* up the parent chain and are caught by `scene.addEventListener`.
|
|
16
|
+
* An invisible ground plane is added to the scene as a fallback so that clicks
|
|
17
|
+
* on empty space (no other object hit) still produce a valid world point.
|
|
18
|
+
*
|
|
19
|
+
* Controls (while enabled):
|
|
20
|
+
* - Left-click : Place a node on the surface under the cursor
|
|
21
|
+
* (snaps to nearest existing node within snapRadius)
|
|
22
|
+
* - Right-click / Escape : Finish the current path chain
|
|
23
|
+
* - Ctrl/Cmd + Z : Undo the last action
|
|
24
|
+
*/
|
|
25
|
+
declare class TopologyDrawer {
|
|
26
|
+
viewer: Viewer;
|
|
27
|
+
topology: Topology;
|
|
28
|
+
private _snapRadius;
|
|
29
|
+
private _scene;
|
|
30
|
+
private _groundMesh;
|
|
31
|
+
private _previewLine;
|
|
32
|
+
private _previewGeometry;
|
|
33
|
+
private _lastNodeId;
|
|
34
|
+
private _nodeCounter;
|
|
35
|
+
private _history;
|
|
36
|
+
private _enabled;
|
|
37
|
+
private _prevFrameloop;
|
|
38
|
+
private _prevPointerMoveEnabled;
|
|
39
|
+
constructor(viewer: Viewer, options?: TopologyDrawerOptions);
|
|
40
|
+
private _findNearestNode;
|
|
41
|
+
private _setPreviewLine;
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the world-space placement point from an InteractionEvent.
|
|
44
|
+
*
|
|
45
|
+
* - If the original hit was a topology node, return its registered position
|
|
46
|
+
* (more accurate than the sphere surface point).
|
|
47
|
+
* - If the hit was a topology edge, return the surface hit point so a new
|
|
48
|
+
* node can be inserted at that location and connected.
|
|
49
|
+
* - Otherwise use intersect.point directly.
|
|
50
|
+
*/
|
|
51
|
+
private _resolvePoint;
|
|
52
|
+
private _placePoint;
|
|
53
|
+
/**
|
|
54
|
+
* Insert a new node into an existing edge (split it).
|
|
55
|
+
*
|
|
56
|
+
* The original edge (cutFrom ↔ cutTo) is removed and replaced with two
|
|
57
|
+
* edges: cutFrom → newNode → cutTo.
|
|
58
|
+
* If a path chain is active (_lastNodeId set, and different from the edge
|
|
59
|
+
* endpoints), an additional edge _lastNodeId → newNode is added.
|
|
60
|
+
*/
|
|
61
|
+
private _splitEdge;
|
|
62
|
+
private _onSceneClick;
|
|
63
|
+
private _onScenePointerMove;
|
|
64
|
+
private _onSceneContextMenu;
|
|
65
|
+
private _onKeyDown;
|
|
66
|
+
/** Finish the current path chain. Next click starts an unconnected new path. */
|
|
67
|
+
finishPath(): void;
|
|
68
|
+
/** Undo the last action (node, edge, or finishPath). */
|
|
69
|
+
undo(): void;
|
|
70
|
+
/** Remove all drawn nodes, edges, and path meshes. */
|
|
71
|
+
clear(): void;
|
|
72
|
+
/** Export the current topology as a JSON-serializable object. Delegates to `topology.exportData()`. */
|
|
73
|
+
exportData(): TopologyData;
|
|
74
|
+
/**
|
|
75
|
+
* Load a previously exported topology, replacing any current drawing.
|
|
76
|
+
* Delegates to `topology.importData()` and resets the drawer's internal node counter.
|
|
77
|
+
*/
|
|
78
|
+
importData(data: TopologyData): void;
|
|
79
|
+
/**
|
|
80
|
+
* Enable drawing mode.
|
|
81
|
+
*
|
|
82
|
+
* - Adds the topology, preview line, and fallback ground plane to the scene.
|
|
83
|
+
* - Registers `click`, `pointermove`, `contextmenu` listeners on `viewer.scene`
|
|
84
|
+
* (events are bubbled up by InteractionManager from whichever object is hit).
|
|
85
|
+
* - Enables `pointerMoveEventsEnabled` on the InteractionManager for the
|
|
86
|
+
* preview line to track the cursor.
|
|
87
|
+
*/
|
|
88
|
+
enable(): this;
|
|
89
|
+
/**
|
|
90
|
+
* Disable drawing mode.
|
|
91
|
+
* The drawn topology remains visible in the scene.
|
|
92
|
+
*/
|
|
93
|
+
disable(): this;
|
|
94
|
+
/** Fully dispose all resources and remove the topology from the scene. */
|
|
95
|
+
dispose(): void;
|
|
96
|
+
}
|
|
97
|
+
export { TopologyDrawer };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const d=require("three/webgpu"),r=require("u-space");class h{viewer;topology;_snapRadius;_scene;_groundMesh;_previewLine;_previewGeometry;_lastNodeId=null;_nodeCounter=0;_history=[];_enabled=!1;_prevFrameloop="demand";_prevPointerMoveEnabled=!1;constructor(e,t){this.viewer=e,this._snapRadius=t?.snapRadius??1,this._scene=e.scene,this.topology=new r.Topology({nodeColor:t?.nodeColor,nodeRadius:t?.nodeRadius,edgeColor:t?.edgeColor,edgeRadius:t?.edgeRadius,pathColor:t?.pathColor,pathRadius:t?.pathRadius});const o=new d.PlaneGeometry(1e5,1e5);o.rotateX(-Math.PI/2),this._groundMesh=new d.Mesh(o,new d.MeshBasicNodeMaterial({visible:!1})),this._groundMesh.position.y=t?.groundY??0,this._previewGeometry=new d.BufferGeometry,this._previewGeometry.setAttribute("position",new d.Float32BufferAttribute(new Float32Array(6),3)),this._previewLine=new d.Line(this._previewGeometry,new d.LineBasicNodeMaterial({color:t?.previewColor??16776960,depthTest:!1})),this._previewLine.renderOrder=10,this._previewLine.visible=!1}_findNearestNode(e){let t=this._snapRadius,o=null;return this.topology.nodes.forEach((i,s)=>{const n=e.distanceTo(i);n<t&&(t=n,o=s)}),o}_setPreviewLine(e,t){const o=this._previewGeometry.attributes.position;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}_resolvePoint(e){const t=e.intersect?.point;if(!t)return null;const{type:o,id:i}=e.target.userData;return o==="node"&&i?this.topology.nodes.get(i)?.clone()??t.clone():t.clone()}_placePoint(e){const t=this._findNearestNode(e);if(t){this._lastNodeId&&this._lastNodeId!==t&&(this.topology.addEdge(this._lastNodeId,t),this._history.push({type:"addEdge",from:this._lastNodeId,to:t,prevLastNodeId:this._lastNodeId}),this._lastNodeId=t,this.topology.renderGraph());return}const o=`node_${this._nodeCounter++}`;this.topology.addNode(o,e);const i={type:"addNode",nodeId:o,prevLastNodeId:this._lastNodeId};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()}_splitEdge(e,t,o,i){const s=`node_${this._nodeCounter++}`;this.topology.addNode(s,e),this.topology.removeEdge(t,o),this.topology.addEdge(t,s),this.topology.addEdge(s,o);const n={type:"splitEdge",nodeId:s,cutFrom:t,cutTo:o,cutWeight:i,prevLastNodeId:this._lastNodeId};this._lastNodeId&&this._lastNodeId!==t&&this._lastNodeId!==o&&(this.topology.addEdge(this._lastNodeId,s),n.chainEdge={from:this._lastNodeId,to:s}),this._history.push(n),this._lastNodeId=s,this.topology.renderGraph()}_onSceneClick=({event:e})=>{const t=e.target.userData;if(t.type==="edge"&&t.from&&t.to){const i=e.intersect?.point;if(!i)return;this._splitEdge(i.clone(),t.from,t.to,t.weight??0);return}const o=this._resolvePoint(e);o&&this._placePoint(o)};_onScenePointerMove=({event:e})=>{if(!this._lastNodeId)return;const t=e.intersect?.point;if(!t)return;const o=this.topology.nodes.get(this._lastNodeId);o&&this._setPreviewLine(o,t)};_onSceneContextMenu=({event:e})=>{e.originalEvent.preventDefault(),this.finishPath()};_onKeyDown=e=>{e.key==="Escape"?this.finishPath():(e.ctrlKey||e.metaKey)&&e.key==="z"&&(e.preventDefault(),this.undo())};finishPath(){this._history.push({type:"finishPath",prevLastNodeId:this._lastNodeId}),this._lastNodeId=null,this._previewLine.visible=!1}undo(){const e=this._history.pop();if(e)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){const t=this.topology.nodes.get(this._lastNodeId);t&&this._setPreviewLine(t,t)}else this._previewLine.visible=!1}clear(){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}exportData(){return this.topology.exportData()}importData(e){this._lastNodeId=null,this._history=[],this._previewLine.visible=!1,this.topology.importData(e),Object.keys(e.nodes).forEach(t=>{const o=parseInt(t.replace("node_",""),10);!isNaN(o)&&o>=this._nodeCounter&&(this._nodeCounter=o+1)})}enable(){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)}disable(){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}dispose(){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()}}exports.TopologyDrawer=h;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './TopologyDrawer';
|
|
@@ -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' | '
|
|
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>[]
|
|
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 {
|
|
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
|
}
|
package/docs/api-interactions.md
CHANGED
|
@@ -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
|
-
|
|
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[]
|
|
94
|
+
| `targetObjects` | `Object3D[]` | `scene.children` | 射线检测的目标对象列表,默认为场景的直接子级。 |
|
|
95
95
|
| `pointerMoveEventsEnabled` | `boolean` | `false` | 启用 `pointermove`、`pointerenter`、`pointerleave` 事件。 |
|
|
96
96
|
|
|
97
97
|
### 方法
|
package/docs/api-plugins.md
CHANGED
|
@@ -306,3 +306,146 @@ import { Atmosphere } from 'u-space/plugins/atmosphere';
|
|
|
306
306
|
const atmosphere = new Atmosphere(viewer);
|
|
307
307
|
atmosphere.enable();
|
|
308
308
|
```
|
|
309
|
+
|
|
310
|
+
### `object-controls`
|
|
311
|
+
|
|
312
|
+
对场景中的 3D 对象进行交互式移动、旋转、缩放操作。继承自 Three.js `TransformControls`,集成了 u-space `Viewer` 生命周期管理。
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
import { ObjectControls } from 'u-space/plugins/object-controls';
|
|
316
|
+
|
|
317
|
+
const controls = new ObjectControls(viewer, {
|
|
318
|
+
mode: 'translate', // 'translate' | 'rotate' | 'scale',默认 'translate'
|
|
319
|
+
space: 'world', // 'world' | 'local',默认 'world'
|
|
320
|
+
size: 1, // 控件显示大小,默认 1
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
controls.enable();
|
|
324
|
+
controls.attach(myMesh); // 将控件附加到目标对象
|
|
325
|
+
|
|
326
|
+
// 切换模式(直接赋值,继承自 TransformControls)
|
|
327
|
+
controls.mode = 'rotate';
|
|
328
|
+
controls.space = 'local';
|
|
329
|
+
|
|
330
|
+
// 监听变换事件
|
|
331
|
+
controls.addEventListener('objectChange', () => {
|
|
332
|
+
console.log(myMesh.position);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
controls.addEventListener('dragging-changed', ({ value }) => {
|
|
336
|
+
console.log('拖拽中:', value); // true = 开始,false = 结束
|
|
337
|
+
});
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
**说明:**
|
|
341
|
+
|
|
342
|
+
- `enable()` 时将 gizmo 加入场景并切换 `frameloop` 为 `'always'`,确保拖拽时连续渲染。
|
|
343
|
+
- 拖拽期间自动挂起 `viewer.controls`(CameraControls)以避免冲突;`disable()` 时无论如何都强制还原相机控制,防止 mid-drag 调用 disable 导致相机卡死。
|
|
344
|
+
- 监听 `viewer` 的 `cameraChange` 事件,`viewer.setCamera()` 切换相机后 gizmo 自动同步,无需手动更新。
|
|
345
|
+
|
|
346
|
+
**构造选项(`ObjectControlsOptions`):**
|
|
347
|
+
|
|
348
|
+
| 选项 | 类型 | 默认值 | 说明 |
|
|
349
|
+
| :------- | :--------------------------------- | :------------ | :--------------- |
|
|
350
|
+
| `mode` | `'translate' \| 'rotate' \| 'scale'` | `'translate'` | 初始变换模式。 |
|
|
351
|
+
| `space` | `'world' \| 'local'` | `'world'` | 初始变换空间。 |
|
|
352
|
+
| `size` | `number` | `1` | gizmo 大小。 |
|
|
353
|
+
| `showX` | `boolean` | `true` | 显示 X 轴手柄。 |
|
|
354
|
+
| `showY` | `boolean` | `true` | 显示 Y 轴手柄。 |
|
|
355
|
+
| `showZ` | `boolean` | `true` | 显示 Z 轴手柄。 |
|
|
356
|
+
|
|
357
|
+
**方法:**
|
|
358
|
+
|
|
359
|
+
| 方法 | 说明 |
|
|
360
|
+
| :---------- | :----------------------------------------------------------- |
|
|
361
|
+
| `enable()` | 将 gizmo 加入场景,切换 frameloop 为 `'always'`。 |
|
|
362
|
+
| `disable()` | 移除 gizmo,还原 frameloop 和 CameraControls。 |
|
|
363
|
+
| `attach(object)` | 将控件附加到指定 Object3D。 |
|
|
364
|
+
| `detach()` | 解除当前附加对象。 |
|
|
365
|
+
| `dispose()` | 完全释放所有资源。 |
|
|
366
|
+
|
|
367
|
+
**属性:**
|
|
368
|
+
|
|
369
|
+
| 属性 | 类型 | 说明 |
|
|
370
|
+
| :--------- | :-------- | :------------------------------------ |
|
|
371
|
+
| `isActive` | `boolean` | 当前是否已调用 `enable()`(只读)。 |
|
|
372
|
+
| `object` | `Object3D \| undefined` | 当前附加的对象(继承自 TransformControls)。 |
|
|
373
|
+
| `mode` | `string` | 当前变换模式,可直接赋值切换。 |
|
|
374
|
+
| `space` | `string` | 当前变换空间,可直接赋值切换。 |
|
|
375
|
+
|
|
376
|
+
> 由于 `ObjectControls` 直接继承 `TransformControls`,所有原生属性(`translationSnap`、`rotationSnap`、`scaleSnap`、`showX/Y/Z` 等)和事件均可直接使用,参考 [Three.js TransformControls 文档](https://threejs.org/docs/#examples/en/controls/TransformControls)。
|
|
377
|
+
|
|
378
|
+
### `topology-drawer`
|
|
379
|
+
|
|
380
|
+
在 3D 场景中交互式绘制拓扑路径图。点击场景中的任意对象表面放置节点,自动连接成路径;点击空白处则落回不可见的地面平面。事件检测通过 `viewer.interactionManager` 实现(`scene.addEventListener` 冒泡)。
|
|
381
|
+
|
|
382
|
+
```typescript
|
|
383
|
+
import { TopologyDrawer } from 'u-space/plugins/topology-drawer';
|
|
384
|
+
|
|
385
|
+
const drawer = new TopologyDrawer(viewer, {
|
|
386
|
+
groundY: 0, // 空白区域兜底地面的 Y 坐标,默认 0
|
|
387
|
+
snapRadius: 1.0, // 自动吸附已有节点的世界单位半径,默认 1.0
|
|
388
|
+
nodeRadius: 0.3,
|
|
389
|
+
nodeColor: 0x0088ff,
|
|
390
|
+
edgeRadius: 0.1,
|
|
391
|
+
edgeColor: 0x00ddff,
|
|
392
|
+
previewColor: 0xffff00, // 预览线颜色
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
drawer.enable();
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
**操作说明:**
|
|
399
|
+
|
|
400
|
+
| 操作 | 效果 |
|
|
401
|
+
| :--- | :--- |
|
|
402
|
+
| 左键点击空白 / 模型表面 | 放置新节点,与上一节点自动连边 |
|
|
403
|
+
| 左键点击已有节点(`snapRadius` 范围内) | 吸附并连接到该节点,不新增节点 |
|
|
404
|
+
| 左键点击已有边 | 切断该边,在点击处插入新节点并重连两端 |
|
|
405
|
+
| 右键 / `Escape` | 结束当前路径链,下次点击开始新路径 |
|
|
406
|
+
| `Ctrl / Cmd + Z` | 撤销最后一步(节点、边或结束路径) |
|
|
407
|
+
|
|
408
|
+
**方法:**
|
|
409
|
+
|
|
410
|
+
| 方法 | 说明 |
|
|
411
|
+
| :--- | :--- |
|
|
412
|
+
| `enable()` | 进入绘制模式,将拓扑与地面平面加入场景。同时自动将 `viewer.interactionManager.pointerMoveEventsEnabled` 置为 `true`(用于预览线跟随光标),并在 `disable()` 时恢复为原值。 |
|
|
413
|
+
| `disable()` | 退出绘制模式,已绘内容仍保留在场景中。恢复 `pointerMoveEventsEnabled` 为调用 `enable()` 前的值。 |
|
|
414
|
+
| `finishPath()` | 结束当前路径链(等同于右键 / Escape)。 |
|
|
415
|
+
| `undo()` | 撤销最后一步操作。 |
|
|
416
|
+
| `clear()` | 清空所有已绘节点、边和路径。 |
|
|
417
|
+
| `exportData()` | 以 JSON 可序列化格式导出当前拓扑数据。 |
|
|
418
|
+
| `importData(data)` | 加载 `exportData()` 返回的数据,替换当前内容。 |
|
|
419
|
+
| `dispose()` | 完全释放所有资源并从场景移除拓扑。 |
|
|
420
|
+
|
|
421
|
+
**属性:**
|
|
422
|
+
|
|
423
|
+
| 属性 | 类型 | 说明 |
|
|
424
|
+
| :--- | :--- | :--- |
|
|
425
|
+
| `topology` | `Topology` | 底层 `Topology` 对象,可进一步访问节点、边和路径方法。 |
|
|
426
|
+
|
|
427
|
+
**导出 / 导入:**
|
|
428
|
+
|
|
429
|
+
```typescript
|
|
430
|
+
// 导出
|
|
431
|
+
const data = drawer.exportData();
|
|
432
|
+
// data: { nodes: Record<string, {x,y,z}>, edges: Array<{from,to,weight}> }
|
|
433
|
+
|
|
434
|
+
// 导入
|
|
435
|
+
drawer.importData(data);
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
**结合 `SceneLoader` 在模型表面绘制:**
|
|
439
|
+
|
|
440
|
+
```typescript
|
|
441
|
+
import { SceneLoader } from 'u-space/plugins/u-manager';
|
|
442
|
+
import { TopologyDrawer } from 'u-space/plugins/topology-drawer';
|
|
443
|
+
|
|
444
|
+
const sceneLoader = new SceneLoader(viewer);
|
|
445
|
+
sceneLoader.setPath('./scenes/my-scene');
|
|
446
|
+
const group = await sceneLoader.loadAsync();
|
|
447
|
+
viewer.scene.add(group);
|
|
448
|
+
|
|
449
|
+
const drawer = new TopologyDrawer(viewer, { snapRadius: 1.0 });
|
|
450
|
+
drawer.enable(); // 可直接在加载的模型表面点击绘制
|
|
451
|
+
```
|
package/docs/getting-started.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "u-space",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
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
|
|
32
|
+
"release": "npm publish --access=public"
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"dist",
|