From bd42138f709d819cd7874988b5934191cef8a7d2 Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Tue, 22 Apr 2025 13:58:13 +0200 Subject: [PATCH 01/18] Now opens editor and viewer --- src/index.ts | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/index.ts b/src/index.ts index 013accf..05e946a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -98,6 +98,13 @@ const extension: JupyterFrontEndPlugin = { tracker.save(widget); }); tracker.add(widget); + + // Open editor alongside viewer + commands.execute('docmanager:open', { + path: widget.context.path, + factory: 'Editor' + }); + }); // Register widget and model factories @@ -121,20 +128,23 @@ const extension: JupyterFrontEndPlugin = { icon: urdf_icon, iconClass: 'jp-URDFIcon', caption: 'Create a new URDF', - execute: () => { + execute: async () => { const cwd = browserFactory.model.path; - commands - .execute('docmanager:new-untitled', { - path: cwd, - type: 'file', - ext: '.urdf' - }) - .then(model => - commands.execute('docmanager:open', { - path: model.path, - factory: FACTORY - }) - ); + const model = await commands.execute('docmanager:new-untitled', { + path: cwd, + type: 'file', + ext: '.urdf' + }); + + await commands.execute('docmanager:open', { + path: model.path, + factory: FACTORY + }); + + await commands.execute('docmanager:open', { + path: model.path, + factory: 'Editor' + }); } }); From 8dab7ad13de7e8c94786e149dac6d95a5cdd0030 Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Wed, 23 Apr 2025 17:17:42 +0200 Subject: [PATCH 02/18] Fix lint check --- src/index.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index 05e946a..5b4e1bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -101,10 +101,9 @@ const extension: JupyterFrontEndPlugin = { // Open editor alongside viewer commands.execute('docmanager:open', { - path: widget.context.path, - factory: 'Editor' + path: widget.context.path, + factory: 'Editor' }); - }); // Register widget and model factories @@ -135,12 +134,12 @@ const extension: JupyterFrontEndPlugin = { type: 'file', ext: '.urdf' }); - + await commands.execute('docmanager:open', { path: model.path, factory: FACTORY }); - + await commands.execute('docmanager:open', { path: model.path, factory: 'Editor' From bd78cb9c648c34fe5283c1e1eb034e27074bfb34 Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Mon, 28 Apr 2025 12:18:11 +0200 Subject: [PATCH 03/18] Add split screen --- src/index.ts | 92 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 56 insertions(+), 36 deletions(-) diff --git a/src/index.ts b/src/index.ts index 5b4e1bd..aa7fcc6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,54 +62,81 @@ const extension: JupyterFrontEndPlugin = { launcher: ILauncher, languageRegistry: IEditorLanguageRegistry ) => { - console.log('JupyterLab extension URDF is activated!'); - const { commands } = app; + const { commands, shell } = app; - // Tracker - const namespace = 'jupyterlab-urdf'; - const tracker = new WidgetTracker({ namespace }); + // --- track whether we've already done the split, and the two anchor IDs --- + let splitDone = false; + let leftEditorRefId: string | null = null; + let rightViewerRefId: string | null = null; - // State restoration: reopen document if it was open previously + const tracker = new WidgetTracker({ + namespace: 'jupyterlab-urdf' + }); if (restorer) { restorer.restore(tracker, { command: 'docmanager:open', - args: widget => ({ path: widget.context.path, factory: FACTORY }), - name: widget => { - console.debug('[Restorer]: Re-opening', widget.context.path); - return widget.context.path; - } + args: w => ({ path: w.context.path, factory: FACTORY }), + name: w => w.context.path }); } - // Create widget factory so that manager knows about widget + // Function to check if any URDF widgets are currently open + const checkAndResetSplitState = () => { + if (tracker.size === 0) { + // No URDF widgets left, reset split state + splitDone = false; + leftEditorRefId = null; + rightViewerRefId = null; + } + }; + const widgetFactory = new URDFWidgetFactory({ name: FACTORY, fileTypes: ['urdf'], defaultFor: ['urdf'] }); - // Add widget to tracker when created - widgetFactory.widgetCreated.connect((sender, widget) => { + widgetFactory.widgetCreated.connect(async (sender, widget) => { widget.title.icon = urdf_icon; widget.title.iconClass = 'jp-URDFIcon'; - - // Notify instance tracker if restore data needs to be updated - widget.context.pathChanged.connect(() => { - tracker.save(widget); - }); + widget.context.pathChanged.connect(() => tracker.save(widget)); tracker.add(widget); - // Open editor alongside viewer - commands.execute('docmanager:open', { - path: widget.context.path, - factory: 'Editor' + // Add dispose listener to reset split state when all widgets are closed + widget.disposed.connect(() => { + checkAndResetSplitState(); }); + + if (!splitDone) { + // First file: split out the editor to the left of this viewer + const editor = await commands.execute('docmanager:open', { + path: widget.context.path, + factory: 'Editor', + options: { mode: 'split-left', ref: widget.id } + }); + splitDone = true; + leftEditorRefId = editor.id; + rightViewerRefId = widget.id; + } else { + // Subsequent viewers → tab them into the _right_ panel + if (rightViewerRefId) { + shell.add(widget, 'main', { + mode: 'tab-after', + ref: rightViewerRefId + }); + } + // And open each new editor as a tab in the _left_ panel + if (leftEditorRefId) { + await commands.execute('docmanager:open', { + path: widget.context.path, + factory: 'Editor', + options: { mode: 'tab-after', ref: leftEditorRefId } + }); + } + } }); - // Register widget and model factories app.docRegistry.addWidgetFactory(widgetFactory); - - // Register file type app.docRegistry.addFileType({ name: 'urdf', displayName: 'URDF', @@ -121,29 +148,22 @@ const extension: JupyterFrontEndPlugin = { icon: urdf_icon }); - // Add command for creating new urdf (file) + // new‐file command now just fires the viewer; widgetCreated handles the rest commands.addCommand('urdf:create-new', { label: 'Create new URDF', icon: urdf_icon, - iconClass: 'jp-URDFIcon', caption: 'Create a new URDF', execute: async () => { const cwd = browserFactory.model.path; - const model = await commands.execute('docmanager:new-untitled', { + const { path } = await commands.execute('docmanager:new-untitled', { path: cwd, type: 'file', ext: '.urdf' }); - await commands.execute('docmanager:open', { - path: model.path, + path, factory: FACTORY }); - - await commands.execute('docmanager:open', { - path: model.path, - factory: 'Editor' - }); } }); From 85bf28b19cbc821d23e919ef1545ea2ceb6b3214 Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Tue, 29 Apr 2025 10:21:52 +0200 Subject: [PATCH 04/18] Refactor code --- src/index.ts | 77 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/src/index.ts b/src/index.ts index aa7fcc6..34a906a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,53 +62,59 @@ const extension: JupyterFrontEndPlugin = { launcher: ILauncher, languageRegistry: IEditorLanguageRegistry ) => { + console.log('JupyterLab extension URDF is activated!'); const { commands, shell } = app; - // --- track whether we've already done the split, and the two anchor IDs --- + // Tracker + const namespace = 'jupyterlab-urdf'; + const tracker = new WidgetTracker({ namespace }); + + // Track split state let splitDone = false; let leftEditorRefId: string | null = null; let rightViewerRefId: string | null = null; - const tracker = new WidgetTracker({ - namespace: 'jupyterlab-urdf' - }); + // State restoration: reopen document if it was open previously if (restorer) { restorer.restore(tracker, { command: 'docmanager:open', - args: w => ({ path: w.context.path, factory: FACTORY }), - name: w => w.context.path + args: widget => ({ path: widget.context.path, factory: FACTORY }), + name: widget => { + console.debug('[Restorer]: Re-opening', widget.context.path); + return widget.context.path; + } }); } - // Function to check if any URDF widgets are currently open - const checkAndResetSplitState = () => { - if (tracker.size === 0) { - // No URDF widgets left, reset split state - splitDone = false; - leftEditorRefId = null; - rightViewerRefId = null; - } - }; - + // Create widget factory so that manager knows about widget const widgetFactory = new URDFWidgetFactory({ name: FACTORY, fileTypes: ['urdf'], defaultFor: ['urdf'] }); + // Add widget to tracker when created widgetFactory.widgetCreated.connect(async (sender, widget) => { widget.title.icon = urdf_icon; widget.title.iconClass = 'jp-URDFIcon'; - widget.context.pathChanged.connect(() => tracker.save(widget)); + + // Notify instance tracker if restore data needs to be updated + widget.context.pathChanged.connect(() => { + tracker.save(widget); + }); tracker.add(widget); - // Add dispose listener to reset split state when all widgets are closed + // Reset split state when all widgets are closed widget.disposed.connect(() => { - checkAndResetSplitState(); + if (tracker.size === 0) { + splitDone = false; + leftEditorRefId = null; + rightViewerRefId = null; + } }); + // Split layout on first open, then tab into panels if (!splitDone) { - // First file: split out the editor to the left of this viewer const editor = await commands.execute('docmanager:open', { path: widget.context.path, factory: 'Editor', @@ -118,14 +124,12 @@ const extension: JupyterFrontEndPlugin = { leftEditorRefId = editor.id; rightViewerRefId = widget.id; } else { - // Subsequent viewers → tab them into the _right_ panel if (rightViewerRefId) { shell.add(widget, 'main', { mode: 'tab-after', ref: rightViewerRefId }); } - // And open each new editor as a tab in the _left_ panel if (leftEditorRefId) { await commands.execute('docmanager:open', { path: widget.context.path, @@ -136,7 +140,10 @@ const extension: JupyterFrontEndPlugin = { } }); + // Register widget and model factories app.docRegistry.addWidgetFactory(widgetFactory); + + // Register file type app.docRegistry.addFileType({ name: 'urdf', displayName: 'URDF', @@ -148,22 +155,26 @@ const extension: JupyterFrontEndPlugin = { icon: urdf_icon }); - // new‐file command now just fires the viewer; widgetCreated handles the rest + // Add command for creating new urdf (file) commands.addCommand('urdf:create-new', { label: 'Create new URDF', icon: urdf_icon, + iconClass: 'jp-URDFIcon', caption: 'Create a new URDF', - execute: async () => { + execute: () => { const cwd = browserFactory.model.path; - const { path } = await commands.execute('docmanager:new-untitled', { - path: cwd, - type: 'file', - ext: '.urdf' - }); - await commands.execute('docmanager:open', { - path, - factory: FACTORY - }); + commands + .execute('docmanager:new-untitled', { + path: cwd, + type: 'file', + ext: '.urdf' + }) + .then(model => + commands.execute('docmanager:open', { + path: model.path, + factory: FACTORY + }) + ); } }); From 8f4a99d02b178cad70be74db510cc3c162f61964 Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Tue, 15 Jul 2025 14:01:15 +0200 Subject: [PATCH 05/18] Add highlight on hover --- src/controls.ts | 70 ++++++++++- src/layout.ts | 95 +++++++++++++++ src/renderer.ts | 285 ++++++++++++++++++++++++++++++++++++++++++--- src/urdf-editor.ts | 56 +++++++++ style/base.css | 9 ++ 5 files changed, 494 insertions(+), 21 deletions(-) create mode 100644 src/urdf-editor.ts diff --git a/src/controls.ts b/src/controls.ts index 7a4668d..4f0445f 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -16,6 +16,7 @@ export class URDFControls extends GUI { private _workspaceFolder: any; private _sceneFolder: any; private _jointsFolder: any; + private _editorFolder: any; private _workingPath = ''; controls: any = { @@ -26,7 +27,8 @@ export class URDFControls extends GUI { height: {} }, joints: {}, - lights: {} + lights: {}, + editor: {} }; /** @@ -59,6 +61,9 @@ export class URDFControls extends GUI { this._sceneFolder = this.addFolder('Scene'); this._sceneFolder.domElement.setAttribute('class', 'dg scene-folder'); + + this._editorFolder = this.addFolder('Editor'); + this._editorFolder.domElement.setAttribute('class', 'dg editor-folder'); } /** @@ -82,6 +87,13 @@ export class URDFControls extends GUI { return this._jointsFolder; } + /** + * Retrieves the folder with editor settings + */ + get editorFolder() { + return this._editorFolder; + } + /** * Checks if a given object is empty {} * @@ -144,9 +156,9 @@ export class URDFControls extends GUI { 'Grid' ); - const minHeight = -2.0; - const maxHeight = 5.0; - const stepSize = 0.001; + const minHeight = -2; + const maxHeight = 5; + const stepSize = 0.1; this.controls.scene.height = this._sceneFolder.add( sceneSettings, 'Height', @@ -196,11 +208,11 @@ export class URDFControls extends GUI { const limitMax = Number(joints[name].limit.upper); // Skip joint if the limits are not defined - if (limitMin === 0.0 && limitMax === 0.0) { + if (limitMin === 0 && limitMax === 0) { return; } - const stepSize = (limitMax - limitMin) / 100.0; + const stepSize = (limitMax - limitMin) / 20; const initValue = joints[name].jointValue[0]; this.controls.joints[name] = this._jointsFolder.add( @@ -392,4 +404,50 @@ export class URDFControls extends GUI { return this.controls.lights; } + + /** + * Creates controls for the editor mode + * + * @returns - The controls to trigger callbacks when editor settings change + */ + createEditorControls(addJointCallback: () => void) { + if (this._isEmpty(this.controls.editor)) { + const editorSettings = { + 'Editor Mode': false, + 'Parent Link': 'none', + 'Child Link': 'none', + 'Joint Name': 'new_joint', + 'Joint Type': 'revolute', + 'Add Joint': addJointCallback + }; + + this.controls.editor.mode = this._editorFolder.add( + editorSettings, + 'Editor Mode' + ); + this.controls.editor.parent = this._editorFolder + .add(editorSettings, 'Parent Link') + .listen(); + this.controls.editor.child = this._editorFolder + .add(editorSettings, 'Child Link') + .listen(); + this.controls.editor.name = this._editorFolder.add( + editorSettings, + 'Joint Name' + ); + this.controls.editor.type = this._editorFolder.add( + editorSettings, + 'Joint Type', + ['revolute', 'continuous', 'prismatic', 'fixed', 'floating', 'planar'] + ); + this.controls.editor.add = this._editorFolder.add( + editorSettings, + 'Add Joint' + ); + + this._editorFolder.open(); + } + + return this.controls.editor; + } } diff --git a/src/layout.ts b/src/layout.ts index 5c19e47..a703ef0 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -12,6 +12,8 @@ import { URDFRenderer } from './renderer'; import { URDFLoadingManager } from './robot'; +import { UrdfEditor } from './urdf-editor'; + interface IURDFColors { sky: Color; ground: Color; @@ -26,6 +28,15 @@ export class URDFLayout extends PanelLayout { private _renderer: URDFRenderer; private _colors: IURDFColors; private _loader: URDFLoadingManager; + private _editor: UrdfEditor; + private _context: DocumentRegistry.IContext | null = null; + private _selectedLinks: { + parent: { name: string | null; obj: any | null }; + child: { name: string | null; obj: any | null }; + } = { + parent: { name: null, obj: null }, + child: { name: null, obj: null } + }; /** * Construct a `URDFLayout` @@ -45,6 +56,7 @@ export class URDFLayout extends PanelLayout { this._renderer = new URDFRenderer(this._colors.sky, this._colors.ground); this._controlsPanel = new URDFControls(); this._loader = new URDFLoadingManager(); + this._editor = new UrdfEditor(); } /** @@ -98,6 +110,7 @@ export class URDFLayout extends PanelLayout { * @param context - Contains the URDF file and its parameters */ setURDF(context: DocumentRegistry.IContext): void { + this._context = context; // Default to parent directory of URDF file const filePath = context.path; const parentDir = filePath.substring(0, filePath.lastIndexOf('/')); @@ -166,6 +179,7 @@ export class URDFLayout extends PanelLayout { this._setSceneControls(); this._setJointControls(); this._setLightControls(); + this._setEditorControls(); } /** @@ -291,6 +305,82 @@ export class URDFLayout extends PanelLayout { this._renderer.setRobot(this._loader.robotModel); } + /** + * Set callbacks for the editor controls + */ + private _setEditorControls(): void { + const addJointCallback = () => { + if ( + this._context && + this._selectedLinks.parent.name && + this._selectedLinks.child.name + ) { + const urdfString = this._context.model.toString(); + const editorControls = this._controlsPanel.controls.editor; + const newUrdfString = this._editor.addJoint(urdfString, { + name: editorControls.name.getValue(), + type: editorControls.type.getValue(), + parent: this._selectedLinks.parent.name, + child: this._selectedLinks.child.name + }); + this._context.model.fromString(newUrdfString); + this._selectedLinks.parent = { name: null, obj: null }; + this._selectedLinks.child = { name: null, obj: null }; + editorControls.parent.setValue('none'); + editorControls.child.setValue('none'); + this._renderer.clearHighlights(); + } + }; + + const editorControls = + this._controlsPanel.createEditorControls(addJointCallback); + + editorControls.mode.onChange((enabled: boolean) => { + this._renderer.setEditorMode(enabled); + if (!enabled) { + this._selectedLinks.parent = { name: null, obj: null }; + this._selectedLinks.child = { name: null, obj: null }; + editorControls.parent.setValue('none'); + editorControls.child.setValue('none'); + } + }); + + this._renderer.linkSelected.connect((sender, selectedObject) => { + let visual: any = selectedObject; + while (visual && !visual.isURDFVisual) { + visual = visual.parent; + } + + if (!visual?.urdfNode?.parentElement) { + console.error( + 'Could not find urdfNode for selected object', + selectedObject + ); + return; + } + + const linkName = visual.urdfNode.parentElement.getAttribute('name'); + const linkObject = selectedObject; + + if (!this._selectedLinks.parent.name) { + this._selectedLinks.parent = { name: linkName, obj: linkObject }; + editorControls.parent.setValue(linkName); + this._renderer.highlightLink(linkObject, 'parent'); + } else if (!this._selectedLinks.child.name) { + this._selectedLinks.child = { name: linkName, obj: linkObject }; + editorControls.child.setValue(linkName); + this._renderer.highlightLink(linkObject, 'child'); + } else { + this._renderer.clearHighlights(); + this._selectedLinks.parent = { name: linkName, obj: linkObject }; + this._selectedLinks.child = { name: null, obj: null }; + editorControls.parent.setValue(linkName); + editorControls.child.setValue('none'); + this._renderer.highlightLink(linkObject, 'parent'); + } + }); + } + /** * Handle `update-request` messages sent to the widget */ @@ -318,6 +408,7 @@ export class URDFLayout extends PanelLayout { protected onAfterAttach(msg: Message): void { this._renderer.redraw(); this._host.appendChild(this._renderer.domElement); + this._host.appendChild(this._renderer.css2dDomElement); this._renderer.setSize( this._renderer.domElement.clientWidth, this._renderer.domElement.clientHeight @@ -337,6 +428,10 @@ export class URDFLayout extends PanelLayout { rect?.width || currentSize.width, rect?.height || currentSize.height ); + this._renderer.setCss2dSize( + rect?.width || currentSize.width, + rect?.height || currentSize.height + ); this._renderer.redraw(); } diff --git a/src/renderer.ts b/src/renderer.ts index 93a7320..3233691 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1,9 +1,15 @@ import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'; +import { + CSS2DRenderer, + CSS2DObject +} from 'three/examples/jsm/renderers/CSS2DRenderer.js'; import { URDFRobot } from 'urdf-loader'; +import { Signal } from '@lumino/signaling'; + /** * THREE.js ROS URDF * Y Z @@ -21,12 +27,37 @@ export class URDFRenderer extends THREE.WebGLRenderer { private _scene: THREE.Scene; private _camera: THREE.PerspectiveCamera; private _controls: OrbitControls; + private _css2dRenderer: CSS2DRenderer; private _colorSky = new THREE.Color(); private _colorGround = new THREE.Color(); private _gridHeight = 0; private _robotIndex = -1; private _directionalLightHelper: THREE.DirectionalLightHelper | null = null; private _hemisphereLightHelper: THREE.HemisphereLightHelper | null = null; + private _raycaster: THREE.Raycaster; + private _mouse: THREE.Vector2; + // private _selectedLink: THREE.Object3D | null = null; + private _editorMode = false; + private _hoveredObj: { + link: any | null; + originalMaterial: THREE.Material | THREE.Material[] | null; + tag: CSS2DObject | null; + } = { link: null, originalMaterial: null, tag: null }; + private _selectedParentObj: { + link: any | null; + originalMaterial: THREE.Material | THREE.Material[] | null; + tag: CSS2DObject | null; + } = { link: null, originalMaterial: null, tag: null }; + private _selectedChildObj: { + link: any | null; + originalMaterial: THREE.Material | THREE.Material[] | null; + tag: CSS2DObject | null; + } = { link: null, originalMaterial: null, tag: null }; + private _highlightMaterial: THREE.Material; + private _parentSelectMaterial: THREE.Material; + private _childSelectMaterial: THREE.Material; + + public linkSelected = new Signal(this); /** * Creates a renderer to manage the scene elements @@ -58,15 +89,36 @@ export class URDFRenderer extends THREE.WebGLRenderer { this._controls = new OrbitControls(this._camera, this.domElement); this._initControls(); - this.printSceneLights(); - } - printSceneLights() { - console.log('Scene Lights:'); - this._scene.children.forEach(obj => { - if (obj.type.includes('Light')) { - console.log(obj); - } + this._raycaster = new THREE.Raycaster(); + this._mouse = new THREE.Vector2(); + this._setupPicking(); + this._setupHovering(); + + // Initialize materials for highlighting + this._highlightMaterial = new THREE.MeshPhongMaterial({ + emissive: '#ffff00', + emissiveIntensity: 0.5, + transparent: true, + opacity: 0.5 + }); + this._parentSelectMaterial = new THREE.MeshPhongMaterial({ + emissive: '#00ff00', + emissiveIntensity: 0.5, + transparent: true, + opacity: 0.5 }); + this._childSelectMaterial = new THREE.MeshPhongMaterial({ + emissive: '#0000ff', + emissiveIntensity: 0.5, + transparent: true, + opacity: 0.5 + }); + + // Initialize the 2D renderer for labels + this._css2dRenderer = new CSS2DRenderer(); + this._css2dRenderer.domElement.style.position = 'absolute'; + this._css2dRenderer.domElement.style.top = '0px'; + this._css2dRenderer.domElement.style.pointerEvents = 'none'; } /** * Initializes the camera @@ -90,6 +142,186 @@ export class URDFRenderer extends THREE.WebGLRenderer { this._controls.addEventListener('change', () => this.redraw()); } + /** + * Sets up picking functionality + */ + private _setupPicking(): void { + this.domElement.addEventListener('click', (event: MouseEvent) => { + if (!this._editorMode) { + return; + } + + const rect = this.domElement.getBoundingClientRect(); + this._mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + this._mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + + this._raycaster.setFromCamera(this._mouse, this._camera); + + const robot = this._scene.children[this._robotIndex]; + if (robot) { + const intersects = this._raycaster.intersectObject(robot, true); + + if (intersects.length > 0) { + const selectedObject = intersects[0].object; + this.linkSelected.emit(selectedObject); + } + } + }); + } + + /** + * Sets up hovering functionality + */ + private _setupHovering(): void { + this.domElement.addEventListener('mousemove', (event: MouseEvent) => { + if (!this._editorMode) { + return; + } + + const rect = this.domElement.getBoundingClientRect(); + this._mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + this._mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + + this._raycaster.setFromCamera(this._mouse, this._camera); + + const robot = this._scene.children[this._robotIndex]; + if (robot) { + const intersects = this._raycaster.intersectObject(robot, true); + + // Un-highlight the previously hovered object + if (this._hoveredObj.link) { + this._hoveredObj.link.material = this._hoveredObj.originalMaterial; + if (this._hoveredObj.tag) { + this._hoveredObj.link.remove(this._hoveredObj.tag); + } + this._hoveredObj = { link: null, originalMaterial: null, tag: null }; + this.redraw(); + } + + if (intersects.length > 0) { + const hoveredObject = intersects[0].object as any; + + // Don't highlight already selected links + if ( + hoveredObject === this._selectedParentObj.link || + hoveredObject === this._selectedChildObj.link + ) { + return; + } + + this._hoveredObj.link = hoveredObject; + this._hoveredObj.originalMaterial = hoveredObject.material; + hoveredObject.material = this._highlightMaterial; + + // Find the link name by traversing up the hierarchy + let visual: any = hoveredObject; + while (visual && !visual.isURDFVisual) { + visual = visual.parent; + } + + // Add a tag with the link's name + if (visual && visual.urdfNode?.parentElement) { + const linkName = visual.urdfNode.parentElement.getAttribute('name'); + const tagDiv = document.createElement('div'); + tagDiv.className = 'jp-urdf-label'; + tagDiv.textContent = linkName; + tagDiv.style.backgroundColor = 'yellow'; + tagDiv.style.color = 'black'; + + const tag = new CSS2DObject(tagDiv); + this._hoveredObj.tag = tag; + hoveredObject.add(tag); + } + + this.redraw(); + } + } + }); + } + + /** + * Enables or disables editor mode + * + * @param enabled - Whether editor mode is enabled + */ + setEditorMode(enabled: boolean): void { + this._editorMode = enabled; + + if (!enabled) { + this.clearHighlights(); + } + } + + /** + * Highlights a selected link and adds a tag. + * @param link - The link object to highlight. + * @param type - The type of selection ('parent' or 'child'). + */ + highlightLink(link: any, type: 'parent' | 'child'): void { + const material = + type === 'parent' + ? this._parentSelectMaterial + : this._childSelectMaterial; + const selectedObj = + type === 'parent' ? this._selectedParentObj : this._selectedChildObj; + + // Clear previous selection of the same type + if (selectedObj.link) { + selectedObj.link.material = selectedObj.originalMaterial; + if (selectedObj.tag) { + selectedObj.link.remove(selectedObj.tag); + } + } + + // Apply new highlight + selectedObj.originalMaterial = link.material; + link.material = material; + selectedObj.link = link; + + // Add tag + const tagDiv = document.createElement('div'); + tagDiv.className = 'jp-urdf-label'; + tagDiv.textContent = type; + tagDiv.style.backgroundColor = type === 'parent' ? 'green' : 'blue'; + + const tag = new CSS2DObject(tagDiv); + link.add(tag); + selectedObj.tag = tag; + + this.redraw(); + } + + /** + * Clears all highlights and tags from selected links. + */ + clearHighlights(): void { + if (this._selectedParentObj.link) { + this._selectedParentObj.link.material = + this._selectedParentObj.originalMaterial; + if (this._selectedParentObj.tag) { + this._selectedParentObj.link.remove(this._selectedParentObj.tag); + } + this._selectedParentObj = { + link: null, + originalMaterial: null, + tag: null + }; + } + if (this._selectedChildObj.link) { + this._selectedChildObj.link.material = + this._selectedChildObj.originalMaterial; + if (this._selectedChildObj.tag) { + this._selectedChildObj.link.remove(this._selectedChildObj.tag); + } + this._selectedChildObj = { + link: null, + originalMaterial: null, + tag: null + }; + } + this.redraw(); + } + /** * Initializes the scene */ @@ -299,14 +531,18 @@ export class URDFRenderer extends THREE.WebGLRenderer { * @param robot */ setRobot(robot: URDFRobot): void { - if (this._robotIndex < 0) { - this._scene.add(robot); - this._robotIndex = this._scene.children - .map(i => i.name) - .indexOf(robot.name); - } else { - this._scene.children[this._robotIndex] = robot; + if (this._robotIndex !== -1) { + this._scene.children[this._robotIndex].traverse(child => { + if (child instanceof THREE.Mesh) { + child.geometry.dispose(); + child.material.dispose(); + } + }); + this._scene.children.splice(this._robotIndex, 1); } + + this._robotIndex = this._scene.children.length; + this._scene.add(robot); this.redraw(); } @@ -453,5 +689,24 @@ export class URDFRenderer extends THREE.WebGLRenderer { this._camera.aspect = renderSize.width / renderSize.height; this._camera.updateProjectionMatrix(); this.render(this._scene, this._camera); + if (this._css2dRenderer) { + this._css2dRenderer.render(this._scene, this._camera); + } + } + + /** + * Returns the CSS2D renderer's DOM element. + */ + get css2dDomElement(): HTMLElement { + return this._css2dRenderer.domElement; + } + + /** + * Sets the size of the CSS2D renderer. + * @param width - The width of the renderer. + * @param height - The height of the renderer. + */ + setCss2dSize(width: number, height: number): void { + this._css2dRenderer.setSize(width, height); } } diff --git a/src/urdf-editor.ts b/src/urdf-editor.ts new file mode 100644 index 0000000..47a0262 --- /dev/null +++ b/src/urdf-editor.ts @@ -0,0 +1,56 @@ +/** + * A class for manipulating URDF XML documents. + */ +export class UrdfEditor { + private _parser = new DOMParser(); + private _serializer = new XMLSerializer(); + + /** + * Adds a new joint to a URDF string. + * + * @param urdfString - The URDF string to modify. + * @param joint - The joint to add. + * @returns The modified URDF string. + */ + addJoint( + urdfString: string, + joint: { + name: string; + type: string; + parent: string; + child: string; + } + ): string { + const urdf = this._parser.parseFromString(urdfString, 'application/xml'); + const robot = urdf.getElementsByTagName('robot')[0]; + + if (!robot) { + throw new Error('No robot tag found in URDF'); + } + + const jointElement = urdf.createElement('joint'); + jointElement.setAttribute('name', joint.name); + jointElement.setAttribute('type', joint.type); + + const parentElement = urdf.createElement('parent'); + parentElement.setAttribute('link', joint.parent); + jointElement.appendChild(parentElement); + + const childElement = urdf.createElement('child'); + childElement.setAttribute('link', joint.child); + jointElement.appendChild(childElement); + + const originElement = urdf.createElement('origin'); + originElement.setAttribute('xyz', '0 0 0'); + originElement.setAttribute('rpy', '0 0 0'); + jointElement.appendChild(originElement); + + const axisElement = urdf.createElement('axis'); + axisElement.setAttribute('xyz', '0 0 1'); + jointElement.appendChild(axisElement); + + robot.appendChild(jointElement); + + return this._serializer.serializeToString(urdf); + } +} diff --git a/style/base.css b/style/base.css index d78c8fc..2b17074 100644 --- a/style/base.css +++ b/style/base.css @@ -192,3 +192,12 @@ li.cr.number.has-slider > div { .property-name:hover::-webkit-scrollbar-thumb { background: #888; } + +/* Styling for 3D labels in editor mode */ +.jp-urdf-label { + color: #fff; + padding: 2px 5px; + border-radius: 3px; + font-family: sans-serif; + font-size: 12px; +} From 09248e7570b4abf7c861397cf3b0367a336df2d4 Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Tue, 15 Jul 2025 14:23:38 +0200 Subject: [PATCH 06/18] Add option to unselect --- src/layout.ts | 39 +++++++++++++++++++++++++++++++-------- src/renderer.ts | 35 ++++++++++++++++++++++++++++++++--- 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/layout.ts b/src/layout.ts index a703ef0..3872664 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -362,22 +362,45 @@ export class URDFLayout extends PanelLayout { const linkName = visual.urdfNode.parentElement.getAttribute('name'); const linkObject = selectedObject; + // Case 1: Clicked on the currently selected parent link to unselect it. + if (this._selectedLinks.parent.name === linkName) { + this._renderer.unHighlightLink('parent'); + this._selectedLinks.parent = { name: null, obj: null }; + editorControls.parent.setValue('none'); + return; + } + + // Case 2: Clicked on the currently selected child link to unselect it. + if (this._selectedLinks.child.name === linkName) { + this._renderer.unHighlightLink('child'); + this._selectedLinks.child = { name: null, obj: null }; + editorControls.child.setValue('none'); + return; + } + + // Case 3: No parent is selected yet. if (!this._selectedLinks.parent.name) { this._selectedLinks.parent = { name: linkName, obj: linkObject }; editorControls.parent.setValue(linkName); this._renderer.highlightLink(linkObject, 'parent'); - } else if (!this._selectedLinks.child.name) { + return; + } + + // Case 4: Parent is selected, but child is not. + if (!this._selectedLinks.child.name) { this._selectedLinks.child = { name: linkName, obj: linkObject }; editorControls.child.setValue(linkName); this._renderer.highlightLink(linkObject, 'child'); - } else { - this._renderer.clearHighlights(); - this._selectedLinks.parent = { name: linkName, obj: linkObject }; - this._selectedLinks.child = { name: null, obj: null }; - editorControls.parent.setValue(linkName); - editorControls.child.setValue('none'); - this._renderer.highlightLink(linkObject, 'parent'); + return; } + + // Case 5: Both parent and child are selected, so reset and set new parent. + this._renderer.clearHighlights(); + this._selectedLinks.parent = { name: linkName, obj: linkObject }; + this._selectedLinks.child = { name: null, obj: null }; + editorControls.parent.setValue(linkName); + editorControls.child.setValue('none'); + this._renderer.highlightLink(linkObject, 'parent'); }); } diff --git a/src/renderer.ts b/src/renderer.ts index 3233691..ce13f9e 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -47,12 +47,20 @@ export class URDFRenderer extends THREE.WebGLRenderer { link: any | null; originalMaterial: THREE.Material | THREE.Material[] | null; tag: CSS2DObject | null; - } = { link: null, originalMaterial: null, tag: null }; + } = { + link: null, + originalMaterial: null, + tag: null + }; private _selectedChildObj: { link: any | null; originalMaterial: THREE.Material | THREE.Material[] | null; tag: CSS2DObject | null; - } = { link: null, originalMaterial: null, tag: null }; + } = { + link: null, + originalMaterial: null, + tag: null + }; private _highlightMaterial: THREE.Material; private _parentSelectMaterial: THREE.Material; private _childSelectMaterial: THREE.Material; @@ -274,7 +282,8 @@ export class URDFRenderer extends THREE.WebGLRenderer { } // Apply new highlight - selectedObj.originalMaterial = link.material; + // The true original material is the one stored on hover. + selectedObj.originalMaterial = this._hoveredObj.originalMaterial; link.material = material; selectedObj.link = link; @@ -681,6 +690,26 @@ export class URDFRenderer extends THREE.WebGLRenderer { } } + /** + * Un-highlights a single selected link. + * @param type - The type of selection to clear ('parent' or 'child'). + */ + unHighlightLink(type: 'parent' | 'child'): void { + const selectedObj = + type === 'parent' ? this._selectedParentObj : this._selectedChildObj; + + if (selectedObj.link) { + selectedObj.link.material = selectedObj.originalMaterial; + if (selectedObj.tag) { + selectedObj.link.remove(selectedObj.tag); + } + selectedObj.link = null; + selectedObj.originalMaterial = null; + selectedObj.tag = null; + } + this.redraw(); + } + /** * Refreshes the viewer by re-rendering the scene and its elements */ From 383c5851cb7cc4992b72cc3d1f76ab380e59fca9 Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Thu, 17 Jul 2025 11:11:01 +0200 Subject: [PATCH 07/18] Make editor link list dropdown --- src/controls.ts | 23 ++++++++++++++++--- src/layout.ts | 55 +++++++++++++++++++++++++++++++++++++++++++--- src/urdf-editor.ts | 36 +++++++++++++++++++++++++----- 3 files changed, 103 insertions(+), 11 deletions(-) diff --git a/src/controls.ts b/src/controls.ts index 4f0445f..7bf6f28 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -410,7 +410,7 @@ export class URDFControls extends GUI { * * @returns - The controls to trigger callbacks when editor settings change */ - createEditorControls(addJointCallback: () => void) { + createEditorControls(addJointCallback: () => void, linkNames: string[] = []) { if (this._isEmpty(this.controls.editor)) { const editorSettings = { 'Editor Mode': false, @@ -418,18 +418,23 @@ export class URDFControls extends GUI { 'Child Link': 'none', 'Joint Name': 'new_joint', 'Joint Type': 'revolute', + 'Origin XYZ': '0 0 0', + 'Origin RPY': '0 0 0', + 'Axis XYZ': '0 0 1', 'Add Joint': addJointCallback }; + const dropdownOptions = ['none', ...linkNames]; + this.controls.editor.mode = this._editorFolder.add( editorSettings, 'Editor Mode' ); this.controls.editor.parent = this._editorFolder - .add(editorSettings, 'Parent Link') + .add(editorSettings, 'Parent Link', dropdownOptions) .listen(); this.controls.editor.child = this._editorFolder - .add(editorSettings, 'Child Link') + .add(editorSettings, 'Child Link', dropdownOptions) .listen(); this.controls.editor.name = this._editorFolder.add( editorSettings, @@ -440,6 +445,18 @@ export class URDFControls extends GUI { 'Joint Type', ['revolute', 'continuous', 'prismatic', 'fixed', 'floating', 'planar'] ); + + // Add origin and axis controls + this.controls.editor.origin_xyz = this._editorFolder + .add(editorSettings, 'Origin XYZ') + .name('Origin XYZ'); + this.controls.editor.origin_rpy = this._editorFolder + .add(editorSettings, 'Origin RPY') + .name('Origin RPY'); + this.controls.editor.axis_xyz = this._editorFolder + .add(editorSettings, 'Axis XYZ') + .name('Axis XYZ'); + this.controls.editor.add = this._editorFolder.add( editorSettings, 'Add Joint' diff --git a/src/layout.ts b/src/layout.ts index 3872664..712d575 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -321,7 +321,10 @@ export class URDFLayout extends PanelLayout { name: editorControls.name.getValue(), type: editorControls.type.getValue(), parent: this._selectedLinks.parent.name, - child: this._selectedLinks.child.name + child: this._selectedLinks.child.name, + origin_xyz: editorControls.origin_xyz.getValue(), + origin_rpy: editorControls.origin_rpy.getValue(), + axis_xyz: editorControls.axis_xyz.getValue() }); this._context.model.fromString(newUrdfString); this._selectedLinks.parent = { name: null, obj: null }; @@ -332,8 +335,11 @@ export class URDFLayout extends PanelLayout { } }; - const editorControls = - this._controlsPanel.createEditorControls(addJointCallback); + const linkNames = Object.keys(this._loader.robotModel.links); + const editorControls = this._controlsPanel.createEditorControls( + addJointCallback, + linkNames + ); editorControls.mode.onChange((enabled: boolean) => { this._renderer.setEditorMode(enabled); @@ -345,6 +351,20 @@ export class URDFLayout extends PanelLayout { } }); + const updateJointName = () => { + const p = this._selectedLinks.parent.name; + const c = this._selectedLinks.child.name; + let newName = 'new_joint'; + if (p && c) { + newName = `${p}_to_${c}_joint`; + } else if (p) { + newName = `${p}_to_..._joint`; + } else if (c) { + newName = `..._to_${c}_joint`; + } + editorControls.name.setValue(newName); + }; + this._renderer.linkSelected.connect((sender, selectedObject) => { let visual: any = selectedObject; while (visual && !visual.isURDFVisual) { @@ -367,6 +387,7 @@ export class URDFLayout extends PanelLayout { this._renderer.unHighlightLink('parent'); this._selectedLinks.parent = { name: null, obj: null }; editorControls.parent.setValue('none'); + updateJointName(); return; } @@ -375,6 +396,15 @@ export class URDFLayout extends PanelLayout { this._renderer.unHighlightLink('child'); this._selectedLinks.child = { name: null, obj: null }; editorControls.child.setValue('none'); + updateJointName(); + return; + } + + // Prevent selecting the same link as both parent and child + if ( + this._selectedLinks.parent.name === linkName || + this._selectedLinks.child.name === linkName + ) { return; } @@ -383,6 +413,7 @@ export class URDFLayout extends PanelLayout { this._selectedLinks.parent = { name: linkName, obj: linkObject }; editorControls.parent.setValue(linkName); this._renderer.highlightLink(linkObject, 'parent'); + updateJointName(); return; } @@ -391,6 +422,7 @@ export class URDFLayout extends PanelLayout { this._selectedLinks.child = { name: linkName, obj: linkObject }; editorControls.child.setValue(linkName); this._renderer.highlightLink(linkObject, 'child'); + updateJointName(); return; } @@ -401,6 +433,23 @@ export class URDFLayout extends PanelLayout { editorControls.parent.setValue(linkName); editorControls.child.setValue('none'); this._renderer.highlightLink(linkObject, 'parent'); + updateJointName(); + }); + + editorControls.parent.onChange((linkName: string) => { + if (linkName === 'none') { + this._renderer.unHighlightLink('parent'); + this._selectedLinks.parent = { name: null, obj: null }; + } + updateJointName(); + }); + + editorControls.child.onChange((linkName: string) => { + if (linkName === 'none') { + this._renderer.unHighlightLink('child'); + this._selectedLinks.child = { name: null, obj: null }; + } + updateJointName(); }); } diff --git a/src/urdf-editor.ts b/src/urdf-editor.ts index 47a0262..bdfed98 100644 --- a/src/urdf-editor.ts +++ b/src/urdf-editor.ts @@ -19,6 +19,9 @@ export class UrdfEditor { type: string; parent: string; child: string; + origin_xyz: string; + origin_rpy: string; + axis_xyz: string; } ): string { const urdf = this._parser.parseFromString(urdfString, 'application/xml'); @@ -28,28 +31,51 @@ export class UrdfEditor { throw new Error('No robot tag found in URDF'); } + // Helper to create an indented text node + const createIndent = (level: number) => + urdf.createTextNode(`\n${' '.repeat(level)}`); + const jointElement = urdf.createElement('joint'); jointElement.setAttribute('name', joint.name); jointElement.setAttribute('type', joint.type); + // Add child elements with indentation + jointElement.appendChild(createIndent(2)); const parentElement = urdf.createElement('parent'); parentElement.setAttribute('link', joint.parent); jointElement.appendChild(parentElement); + jointElement.appendChild(createIndent(2)); const childElement = urdf.createElement('child'); childElement.setAttribute('link', joint.child); jointElement.appendChild(childElement); + jointElement.appendChild(createIndent(2)); const originElement = urdf.createElement('origin'); - originElement.setAttribute('xyz', '0 0 0'); - originElement.setAttribute('rpy', '0 0 0'); + originElement.setAttribute('xyz', joint.origin_xyz); + originElement.setAttribute('rpy', joint.origin_rpy); jointElement.appendChild(originElement); - const axisElement = urdf.createElement('axis'); - axisElement.setAttribute('xyz', '0 0 1'); - jointElement.appendChild(axisElement); + // Add axis only for relevant joint types + if ( + joint.type === 'revolute' || + joint.type === 'continuous' || + joint.type === 'prismatic' || + joint.type === 'planar' + ) { + jointElement.appendChild(createIndent(2)); + const axisElement = urdf.createElement('axis'); + axisElement.setAttribute('xyz', joint.axis_xyz); + jointElement.appendChild(axisElement); + } + + // Add final indent before closing tag + jointElement.appendChild(createIndent(1)); + // Append the new joint with proper indentation + robot.appendChild(createIndent(1)); robot.appendChild(jointElement); + robot.appendChild(createIndent(0)); return this._serializer.serializeToString(urdf); } From 84787043bde09321f996ae103f9559023b7450af Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Thu, 17 Jul 2025 11:49:35 +0200 Subject: [PATCH 08/18] Replace tags with highlights --- src/layout.ts | 8 ++++++++ src/renderer.ts | 23 +++++++++++------------ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/layout.ts b/src/layout.ts index 712d575..b453e3b 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -440,6 +440,10 @@ export class URDFLayout extends PanelLayout { if (linkName === 'none') { this._renderer.unHighlightLink('parent'); this._selectedLinks.parent = { name: null, obj: null }; + } else { + const linkObject = this._loader.robotModel.links[linkName]; + this._selectedLinks.parent = { name: linkName, obj: linkObject }; + this._renderer.highlightLink(linkObject, 'parent'); } updateJointName(); }); @@ -448,6 +452,10 @@ export class URDFLayout extends PanelLayout { if (linkName === 'none') { this._renderer.unHighlightLink('child'); this._selectedLinks.child = { name: null, obj: null }; + } else { + const linkObject = this._loader.robotModel.links[linkName]; + this._selectedLinks.child = { name: linkName, obj: linkObject }; + this._renderer.highlightLink(linkObject, 'child'); } updateJointName(); }); diff --git a/src/renderer.ts b/src/renderer.ts index ce13f9e..adb970a 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -198,7 +198,13 @@ export class URDFRenderer extends THREE.WebGLRenderer { // Un-highlight the previously hovered object if (this._hoveredObj.link) { - this._hoveredObj.link.material = this._hoveredObj.originalMaterial; + // Only restore material if the object is not currently selected + if ( + this._hoveredObj.link !== this._selectedParentObj.link && + this._hoveredObj.link !== this._selectedChildObj.link + ) { + this._hoveredObj.link.material = this._hoveredObj.originalMaterial; + } if (this._hoveredObj.tag) { this._hoveredObj.link.remove(this._hoveredObj.tag); } @@ -283,20 +289,13 @@ export class URDFRenderer extends THREE.WebGLRenderer { // Apply new highlight // The true original material is the one stored on hover. - selectedObj.originalMaterial = this._hoveredObj.originalMaterial; + selectedObj.originalMaterial = + link === this._hoveredObj.link + ? this._hoveredObj.originalMaterial + : link.material; link.material = material; selectedObj.link = link; - // Add tag - const tagDiv = document.createElement('div'); - tagDiv.className = 'jp-urdf-label'; - tagDiv.textContent = type; - tagDiv.style.backgroundColor = type === 'parent' ? 'green' : 'blue'; - - const tag = new CSS2DObject(tagDiv); - link.add(tag); - selectedObj.tag = tag; - this.redraw(); } From 3d572e4b894a5c2214945a413cb4b17c132f00ca Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Thu, 17 Jul 2025 14:39:40 +0200 Subject: [PATCH 09/18] Handle same parent and child edge case --- src/layout.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/layout.ts b/src/layout.ts index b453e3b..696ed28 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -437,11 +437,21 @@ export class URDFLayout extends PanelLayout { }); editorControls.parent.onChange((linkName: string) => { + // Prevent selecting the same link as the child + if (linkName !== 'none' && linkName === this._selectedLinks.child.name) { + editorControls.parent.setValue( + this._selectedLinks.parent.name || 'none' + ); // Revert + return; + } + if (linkName === 'none') { this._renderer.unHighlightLink('parent'); this._selectedLinks.parent = { name: null, obj: null }; } else { - const linkObject = this._loader.robotModel.links[linkName]; + const link = this._loader.robotModel.links[linkName]; + const linkObject = link.children.find((c: any) => c.isURDFVisual) + ?.children[0]; this._selectedLinks.parent = { name: linkName, obj: linkObject }; this._renderer.highlightLink(linkObject, 'parent'); } @@ -449,11 +459,19 @@ export class URDFLayout extends PanelLayout { }); editorControls.child.onChange((linkName: string) => { + // Prevent selecting the same link as the parent + if (linkName !== 'none' && linkName === this._selectedLinks.parent.name) { + editorControls.child.setValue(this._selectedLinks.child.name || 'none'); // Revert + return; + } + if (linkName === 'none') { this._renderer.unHighlightLink('child'); this._selectedLinks.child = { name: null, obj: null }; } else { - const linkObject = this._loader.robotModel.links[linkName]; + const link = this._loader.robotModel.links[linkName]; + const linkObject = link.children.find((c: any) => c.isURDFVisual) + ?.children[0]; this._selectedLinks.child = { name: linkName, obj: linkObject }; this._renderer.highlightLink(linkObject, 'child'); } From f2b553354b302eb9b0f8e548530ada6b7c80aaa9 Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Thu, 17 Jul 2025 16:18:38 +0200 Subject: [PATCH 10/18] Add limits to joints --- src/controls.ts | 20 ++++++++++++++++++++ src/layout.ts | 35 ++++++++++++++++++++++++++++++++++- src/urdf-editor.ts | 15 +++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/controls.ts b/src/controls.ts index 7bf6f28..7d72f29 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -421,6 +421,12 @@ export class URDFControls extends GUI { 'Origin XYZ': '0 0 0', 'Origin RPY': '0 0 0', 'Axis XYZ': '0 0 1', + 'Lower Limit': '0.0', + 'Upper Limit': '0.0', + // eslint-disable-next-line + Effort: '0.0', + // eslint-disable-next-line + Velocity: '0.0', 'Add Joint': addJointCallback }; @@ -457,6 +463,20 @@ export class URDFControls extends GUI { .add(editorSettings, 'Axis XYZ') .name('Axis XYZ'); + // Add limit controls + this.controls.editor.lower = this._editorFolder + .add(editorSettings, 'Lower Limit') + .name('Lower Limit'); + this.controls.editor.upper = this._editorFolder + .add(editorSettings, 'Upper Limit') + .name('Upper Limit'); + this.controls.editor.effort = this._editorFolder + .add(editorSettings, 'Effort') + .name('Effort'); + this.controls.editor.velocity = this._editorFolder + .add(editorSettings, 'Velocity') + .name('Velocity'); + this.controls.editor.add = this._editorFolder.add( editorSettings, 'Add Joint' diff --git a/src/layout.ts b/src/layout.ts index 696ed28..ada6230 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -324,7 +324,11 @@ export class URDFLayout extends PanelLayout { child: this._selectedLinks.child.name, origin_xyz: editorControls.origin_xyz.getValue(), origin_rpy: editorControls.origin_rpy.getValue(), - axis_xyz: editorControls.axis_xyz.getValue() + axis_xyz: editorControls.axis_xyz.getValue(), + lower: editorControls.lower.getValue(), + upper: editorControls.upper.getValue(), + effort: editorControls.effort.getValue(), + velocity: editorControls.velocity.getValue() }); this._context.model.fromString(newUrdfString); this._selectedLinks.parent = { name: null, obj: null }; @@ -436,6 +440,35 @@ export class URDFLayout extends PanelLayout { updateJointName(); }); + // Add a handler for joint type changes to show/hide relevant fields + editorControls.type.onChange((type: string) => { + const axisControl = editorControls.axis_xyz; + const limitControls = [ + editorControls.lower, + editorControls.upper, + editorControls.effort, + editorControls.velocity + ]; + + // Show/hide axis based on type + const needsAxis = [ + 'revolute', + 'continuous', + 'prismatic', + 'planar' + ].includes(type); + axisControl.__li.style.display = needsAxis ? '' : 'none'; + + // Show/hide limits based on type + const needsLimits = ['revolute', 'prismatic'].includes(type); + limitControls.forEach(c => { + c.__li.style.display = needsLimits ? '' : 'none'; + }); + }); + + // Trigger the change handler once to set the initial visibility + editorControls.type.domElement.dispatchEvent(new Event('change')); + editorControls.parent.onChange((linkName: string) => { // Prevent selecting the same link as the child if (linkName !== 'none' && linkName === this._selectedLinks.child.name) { diff --git a/src/urdf-editor.ts b/src/urdf-editor.ts index bdfed98..3da7607 100644 --- a/src/urdf-editor.ts +++ b/src/urdf-editor.ts @@ -22,6 +22,10 @@ export class UrdfEditor { origin_xyz: string; origin_rpy: string; axis_xyz: string; + lower: string; + upper: string; + effort: string; + velocity: string; } ): string { const urdf = this._parser.parseFromString(urdfString, 'application/xml'); @@ -69,6 +73,17 @@ export class UrdfEditor { jointElement.appendChild(axisElement); } + // Add limit only for relevant joint types + if (joint.type === 'revolute' || joint.type === 'prismatic') { + jointElement.appendChild(createIndent(2)); + const limitElement = urdf.createElement('limit'); + limitElement.setAttribute('lower', joint.lower); + limitElement.setAttribute('upper', joint.upper); + limitElement.setAttribute('effort', joint.effort); + limitElement.setAttribute('velocity', joint.velocity); + jointElement.appendChild(limitElement); + } + // Add final indent before closing tag jointElement.appendChild(createIndent(1)); From 7f6f42f366577170f53af861ff26b8841e1e1467 Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Fri, 18 Jul 2025 13:52:53 +0200 Subject: [PATCH 11/18] Enforce numeric input --- src/controls.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/controls.ts b/src/controls.ts index 7d72f29..2014bf3 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -104,6 +104,29 @@ export class URDFControls extends GUI { return Object.keys(obj).length === 0; } + /** + * Restricts input on a control to numeric and special characters. + * + * @param control - The dat.gui controller to modify. + */ + private _enforceNumericInput(control: any): void { + const inputElement = control.domElement as HTMLInputElement; + + inputElement.addEventListener('input', (event: Event) => { + const target = event.target as HTMLInputElement; + const originalValue = target.value; + + // Remove any characters that aren't digits, spaces, periods, or minus signs + const filteredValue = originalValue.replace(/[^\d.\s-]/g, ''); + + if (originalValue !== filteredValue) { + target.value = filteredValue; + // Trigger dat.gui's internal change detection + control.updateDisplay(); + } + }); + } + /** * Creates an input box and a button to modify the working path * @@ -477,6 +500,15 @@ export class URDFControls extends GUI { .add(editorSettings, 'Velocity') .name('Velocity'); + // Enforce numeric input for relevant fields + this._enforceNumericInput(this.controls.editor.origin_xyz); + this._enforceNumericInput(this.controls.editor.origin_rpy); + this._enforceNumericInput(this.controls.editor.axis_xyz); + this._enforceNumericInput(this.controls.editor.lower); + this._enforceNumericInput(this.controls.editor.upper); + this._enforceNumericInput(this.controls.editor.effort); + this._enforceNumericInput(this.controls.editor.velocity); + this.controls.editor.add = this._editorFolder.add( editorSettings, 'Add Joint' From 8e777917dbc208d1278a8a88a972f555eef7486b Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Wed, 23 Jul 2025 11:59:02 +0200 Subject: [PATCH 12/18] Fix some values --- src/controls.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/controls.ts b/src/controls.ts index 2014bf3..f091259 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -179,9 +179,9 @@ export class URDFControls extends GUI { 'Grid' ); - const minHeight = -2; - const maxHeight = 5; - const stepSize = 0.1; + const minHeight = -2.0; + const maxHeight = 5.0; + const stepSize = 0.001; this.controls.scene.height = this._sceneFolder.add( sceneSettings, 'Height', @@ -231,11 +231,11 @@ export class URDFControls extends GUI { const limitMax = Number(joints[name].limit.upper); // Skip joint if the limits are not defined - if (limitMin === 0 && limitMax === 0) { + if (limitMin === 0.0 && limitMax === 0.0) { return; } - const stepSize = (limitMax - limitMin) / 20; + const stepSize = (limitMax - limitMin) / 100; const initValue = joints[name].jointValue[0]; this.controls.joints[name] = this._jointsFolder.add( From 1bd8fb5a968b1b9e4d2d1992c33a2a8aa44fe28a Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Wed, 23 Jul 2025 12:59:22 +0200 Subject: [PATCH 13/18] Refactor editor rendering --- src/editor/editor.ts | 281 ++++++++++++++++++++++++++++++++ src/{ => editor}/urdf-editor.ts | 0 src/layout.ts | 32 ++-- src/renderer.ts | 275 ++----------------------------- 4 files changed, 311 insertions(+), 277 deletions(-) create mode 100644 src/editor/editor.ts rename src/{ => editor}/urdf-editor.ts (100%) diff --git a/src/editor/editor.ts b/src/editor/editor.ts new file mode 100644 index 0000000..59af37e --- /dev/null +++ b/src/editor/editor.ts @@ -0,0 +1,281 @@ +import * as THREE from 'three'; +import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'; +import { Signal } from '@lumino/signaling'; +import { URDFRenderer } from '../renderer'; + +/** + * Editor: Handles all user interaction logic for the URDF editor mode + */ +export class Editor { + private _renderer: URDFRenderer; + private _raycaster: THREE.Raycaster; + private _mouse: THREE.Vector2; + private _editorMode = false; + + private _hoveredObj: { + link: any | null; + originalMaterial: THREE.Material | THREE.Material[] | null; + tag: CSS2DObject | null; + } = { link: null, originalMaterial: null, tag: null }; + + private _selectedParentObj: { + link: any | null; + originalMaterial: THREE.Material | THREE.Material[] | null; + tag: CSS2DObject | null; + } = { + link: null, + originalMaterial: null, + tag: null + }; + + private _selectedChildObj: { + link: any | null; + originalMaterial: THREE.Material | THREE.Material[] | null; + tag: CSS2DObject | null; + } = { + link: null, + originalMaterial: null, + tag: null + }; + + private _highlightMaterial: THREE.Material; + private _parentSelectMaterial: THREE.Material; + private _childSelectMaterial: THREE.Material; + + // Signals for communication with layout + readonly linkSelected = new Signal(this); + + constructor(renderer: URDFRenderer) { + this._renderer = renderer; + this._raycaster = new THREE.Raycaster(); + this._mouse = new THREE.Vector2(); + + // Create materials + this._highlightMaterial = new THREE.MeshBasicMaterial({ + color: 0xffff00, + transparent: true, + opacity: 0.6 + }); + this._parentSelectMaterial = new THREE.MeshBasicMaterial({ + color: 0x00ff00, + transparent: true, + opacity: 0.6 + }); + this._childSelectMaterial = new THREE.MeshBasicMaterial({ + color: 0x0000ff, + transparent: true, + opacity: 0.6 + }); + + this._setupInteractions(); + } + + /** + * Sets the editor mode on/off + */ + setEditorMode(enabled: boolean): void { + this._editorMode = enabled; + + if (!enabled) { + this.clearHighlights(); + } + } + + /** + * Sets up mouse event listeners for picking and hovering + */ + private _setupInteractions(): void { + this._setupPicking(); + this._setupHovering(); + } + + /** + * Sets up mouse click event for selecting links + */ + private _setupPicking(): void { + this._renderer.domElement.addEventListener('click', (event: MouseEvent) => { + if (!this._editorMode) { + return; + } + + const rect = this._renderer.domElement.getBoundingClientRect(); + this._mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + this._mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + + this._raycaster.setFromCamera(this._mouse, this._renderer.camera); + + const robot = this._renderer.getRobot(); + if (robot) { + const intersects = this._raycaster.intersectObject(robot, true); + if (intersects.length > 0) { + this.linkSelected.emit(intersects[0].object); + } + } + }); + } + + /** + * Sets up mouse hover event for highlighting links + */ + private _setupHovering(): void { + this._renderer.domElement.addEventListener( + 'mousemove', + (event: MouseEvent) => { + if (!this._editorMode) { + return; + } + + const rect = this._renderer.domElement.getBoundingClientRect(); + this._mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + this._mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + + this._raycaster.setFromCamera(this._mouse, this._renderer.camera); + + const robot = this._renderer.getRobot(); + if (robot) { + // Un-highlight the previously hovered object + if (this._hoveredObj.link) { + // Only restore material if the object is not currently selected + if ( + this._hoveredObj.link !== this._selectedParentObj.link && + this._hoveredObj.link !== this._selectedChildObj.link + ) { + this._hoveredObj.link.material = + this._hoveredObj.originalMaterial; + } + if (this._hoveredObj.tag) { + this._hoveredObj.link.remove(this._hoveredObj.tag); + } + this._hoveredObj = { + link: null, + originalMaterial: null, + tag: null + }; + this._renderer.redraw(); + } + + const intersects = this._raycaster.intersectObject(robot, true); + if (intersects.length > 0) { + const hoveredObject = intersects[0].object as any; + + // Don't highlight already selected links + if ( + hoveredObject === this._selectedParentObj.link || + hoveredObject === this._selectedChildObj.link + ) { + return; + } + + this._hoveredObj.link = hoveredObject; + this._hoveredObj.originalMaterial = hoveredObject.material; + hoveredObject.material = this._highlightMaterial; + + // Find the link name by traversing up the hierarchy + let visual: any = hoveredObject; + while (visual && !visual.isURDFVisual) { + visual = visual.parent; + } + + // Add a tag with the link's name + if (visual && visual.urdfNode?.parentElement) { + const linkName = + visual.urdfNode.parentElement.getAttribute('name'); + const tagDiv = document.createElement('div'); + tagDiv.className = 'jp-urdf-label'; + tagDiv.textContent = linkName; + tagDiv.style.backgroundColor = 'yellow'; + tagDiv.style.color = 'black'; + + const tag = new CSS2DObject(tagDiv); + this._hoveredObj.tag = tag; + hoveredObject.add(tag); + } + + this._renderer.redraw(); + } + } + } + ); + } + + /** + * Highlights a link with the specified material + */ + highlightLink(link: any, type: 'parent' | 'child'): void { + const material = + type === 'parent' + ? this._parentSelectMaterial + : this._childSelectMaterial; + const selectedObj = + type === 'parent' ? this._selectedParentObj : this._selectedChildObj; + + // Clear previous selection of the same type + if (selectedObj.link) { + selectedObj.link.material = selectedObj.originalMaterial; + if (selectedObj.tag) { + selectedObj.link.remove(selectedObj.tag); + } + } + + // Apply new highlight + // The true original material is the one stored on hover. + selectedObj.originalMaterial = + link === this._hoveredObj.link + ? this._hoveredObj.originalMaterial + : link.material; + link.material = material; + selectedObj.link = link; + + this._renderer.redraw(); + } + + /** + * Un-highlights a single selected link. + */ + unHighlightLink(type: 'parent' | 'child'): void { + const selectedObj = + type === 'parent' ? this._selectedParentObj : this._selectedChildObj; + + if (selectedObj.link) { + selectedObj.link.material = selectedObj.originalMaterial; + if (selectedObj.tag) { + selectedObj.link.remove(selectedObj.tag); + } + selectedObj.link = null; + selectedObj.originalMaterial = null; + selectedObj.tag = null; + } + this._renderer.redraw(); + } + + /** + * Clears all highlights and tags from selected links. + */ + clearHighlights(): void { + if (this._selectedParentObj.link) { + this._selectedParentObj.link.material = + this._selectedParentObj.originalMaterial; + if (this._selectedParentObj.tag) { + this._selectedParentObj.link.remove(this._selectedParentObj.tag); + } + this._selectedParentObj = { + link: null, + originalMaterial: null, + tag: null + }; + } + if (this._selectedChildObj.link) { + this._selectedChildObj.link.material = + this._selectedChildObj.originalMaterial; + if (this._selectedChildObj.tag) { + this._selectedChildObj.link.remove(this._selectedChildObj.tag); + } + this._selectedChildObj = { + link: null, + originalMaterial: null, + tag: null + }; + } + this._renderer.redraw(); + } +} diff --git a/src/urdf-editor.ts b/src/editor/urdf-editor.ts similarity index 100% rename from src/urdf-editor.ts rename to src/editor/urdf-editor.ts diff --git a/src/layout.ts b/src/layout.ts index ada6230..201fb15 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -12,7 +12,9 @@ import { URDFRenderer } from './renderer'; import { URDFLoadingManager } from './robot'; -import { UrdfEditor } from './urdf-editor'; +import { UrdfEditor } from './editor/urdf-editor'; + +import { Editor } from './editor/editor'; interface IURDFColors { sky: Color; @@ -29,6 +31,7 @@ export class URDFLayout extends PanelLayout { private _colors: IURDFColors; private _loader: URDFLoadingManager; private _editor: UrdfEditor; + private _interactionEditor: Editor; private _context: DocumentRegistry.IContext | null = null; private _selectedLinks: { parent: { name: string | null; obj: any | null }; @@ -57,6 +60,7 @@ export class URDFLayout extends PanelLayout { this._controlsPanel = new URDFControls(); this._loader = new URDFLoadingManager(); this._editor = new UrdfEditor(); + this._interactionEditor = new Editor(this._renderer); } /** @@ -335,7 +339,7 @@ export class URDFLayout extends PanelLayout { this._selectedLinks.child = { name: null, obj: null }; editorControls.parent.setValue('none'); editorControls.child.setValue('none'); - this._renderer.clearHighlights(); + this._interactionEditor.clearHighlights(); } }; @@ -346,7 +350,7 @@ export class URDFLayout extends PanelLayout { ); editorControls.mode.onChange((enabled: boolean) => { - this._renderer.setEditorMode(enabled); + this._interactionEditor.setEditorMode(enabled); if (!enabled) { this._selectedLinks.parent = { name: null, obj: null }; this._selectedLinks.child = { name: null, obj: null }; @@ -369,7 +373,7 @@ export class URDFLayout extends PanelLayout { editorControls.name.setValue(newName); }; - this._renderer.linkSelected.connect((sender, selectedObject) => { + this._interactionEditor.linkSelected.connect((sender, selectedObject) => { let visual: any = selectedObject; while (visual && !visual.isURDFVisual) { visual = visual.parent; @@ -388,7 +392,7 @@ export class URDFLayout extends PanelLayout { // Case 1: Clicked on the currently selected parent link to unselect it. if (this._selectedLinks.parent.name === linkName) { - this._renderer.unHighlightLink('parent'); + this._interactionEditor.unHighlightLink('parent'); this._selectedLinks.parent = { name: null, obj: null }; editorControls.parent.setValue('none'); updateJointName(); @@ -397,7 +401,7 @@ export class URDFLayout extends PanelLayout { // Case 2: Clicked on the currently selected child link to unselect it. if (this._selectedLinks.child.name === linkName) { - this._renderer.unHighlightLink('child'); + this._interactionEditor.unHighlightLink('child'); this._selectedLinks.child = { name: null, obj: null }; editorControls.child.setValue('none'); updateJointName(); @@ -416,7 +420,7 @@ export class URDFLayout extends PanelLayout { if (!this._selectedLinks.parent.name) { this._selectedLinks.parent = { name: linkName, obj: linkObject }; editorControls.parent.setValue(linkName); - this._renderer.highlightLink(linkObject, 'parent'); + this._interactionEditor.highlightLink(linkObject, 'parent'); updateJointName(); return; } @@ -425,18 +429,18 @@ export class URDFLayout extends PanelLayout { if (!this._selectedLinks.child.name) { this._selectedLinks.child = { name: linkName, obj: linkObject }; editorControls.child.setValue(linkName); - this._renderer.highlightLink(linkObject, 'child'); + this._interactionEditor.highlightLink(linkObject, 'child'); updateJointName(); return; } // Case 5: Both parent and child are selected, so reset and set new parent. - this._renderer.clearHighlights(); + this._interactionEditor.clearHighlights(); this._selectedLinks.parent = { name: linkName, obj: linkObject }; this._selectedLinks.child = { name: null, obj: null }; editorControls.parent.setValue(linkName); editorControls.child.setValue('none'); - this._renderer.highlightLink(linkObject, 'parent'); + this._interactionEditor.highlightLink(linkObject, 'parent'); updateJointName(); }); @@ -479,14 +483,14 @@ export class URDFLayout extends PanelLayout { } if (linkName === 'none') { - this._renderer.unHighlightLink('parent'); + this._interactionEditor.unHighlightLink('parent'); this._selectedLinks.parent = { name: null, obj: null }; } else { const link = this._loader.robotModel.links[linkName]; const linkObject = link.children.find((c: any) => c.isURDFVisual) ?.children[0]; this._selectedLinks.parent = { name: linkName, obj: linkObject }; - this._renderer.highlightLink(linkObject, 'parent'); + this._interactionEditor.highlightLink(linkObject, 'parent'); } updateJointName(); }); @@ -499,14 +503,14 @@ export class URDFLayout extends PanelLayout { } if (linkName === 'none') { - this._renderer.unHighlightLink('child'); + this._interactionEditor.unHighlightLink('child'); this._selectedLinks.child = { name: null, obj: null }; } else { const link = this._loader.robotModel.links[linkName]; const linkObject = link.children.find((c: any) => c.isURDFVisual) ?.children[0]; this._selectedLinks.child = { name: linkName, obj: linkObject }; - this._renderer.highlightLink(linkObject, 'child'); + this._interactionEditor.highlightLink(linkObject, 'child'); } updateJointName(); }); diff --git a/src/renderer.ts b/src/renderer.ts index adb970a..2b4eb43 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1,15 +1,10 @@ import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'; -import { - CSS2DRenderer, - CSS2DObject -} from 'three/examples/jsm/renderers/CSS2DRenderer.js'; +import { CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer.js'; import { URDFRobot } from 'urdf-loader'; -import { Signal } from '@lumino/signaling'; - /** * THREE.js ROS URDF * Y Z @@ -34,38 +29,6 @@ export class URDFRenderer extends THREE.WebGLRenderer { private _robotIndex = -1; private _directionalLightHelper: THREE.DirectionalLightHelper | null = null; private _hemisphereLightHelper: THREE.HemisphereLightHelper | null = null; - private _raycaster: THREE.Raycaster; - private _mouse: THREE.Vector2; - // private _selectedLink: THREE.Object3D | null = null; - private _editorMode = false; - private _hoveredObj: { - link: any | null; - originalMaterial: THREE.Material | THREE.Material[] | null; - tag: CSS2DObject | null; - } = { link: null, originalMaterial: null, tag: null }; - private _selectedParentObj: { - link: any | null; - originalMaterial: THREE.Material | THREE.Material[] | null; - tag: CSS2DObject | null; - } = { - link: null, - originalMaterial: null, - tag: null - }; - private _selectedChildObj: { - link: any | null; - originalMaterial: THREE.Material | THREE.Material[] | null; - tag: CSS2DObject | null; - } = { - link: null, - originalMaterial: null, - tag: null - }; - private _highlightMaterial: THREE.Material; - private _parentSelectMaterial: THREE.Material; - private _childSelectMaterial: THREE.Material; - - public linkSelected = new Signal(this); /** * Creates a renderer to manage the scene elements @@ -97,37 +60,13 @@ export class URDFRenderer extends THREE.WebGLRenderer { this._controls = new OrbitControls(this._camera, this.domElement); this._initControls(); - this._raycaster = new THREE.Raycaster(); - this._mouse = new THREE.Vector2(); - this._setupPicking(); - this._setupHovering(); - - // Initialize materials for highlighting - this._highlightMaterial = new THREE.MeshPhongMaterial({ - emissive: '#ffff00', - emissiveIntensity: 0.5, - transparent: true, - opacity: 0.5 - }); - this._parentSelectMaterial = new THREE.MeshPhongMaterial({ - emissive: '#00ff00', - emissiveIntensity: 0.5, - transparent: true, - opacity: 0.5 - }); - this._childSelectMaterial = new THREE.MeshPhongMaterial({ - emissive: '#0000ff', - emissiveIntensity: 0.5, - transparent: true, - opacity: 0.5 - }); - // Initialize the 2D renderer for labels this._css2dRenderer = new CSS2DRenderer(); this._css2dRenderer.domElement.style.position = 'absolute'; this._css2dRenderer.domElement.style.top = '0px'; this._css2dRenderer.domElement.style.pointerEvents = 'none'; } + /** * Initializes the camera */ @@ -150,186 +89,6 @@ export class URDFRenderer extends THREE.WebGLRenderer { this._controls.addEventListener('change', () => this.redraw()); } - /** - * Sets up picking functionality - */ - private _setupPicking(): void { - this.domElement.addEventListener('click', (event: MouseEvent) => { - if (!this._editorMode) { - return; - } - - const rect = this.domElement.getBoundingClientRect(); - this._mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; - this._mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; - - this._raycaster.setFromCamera(this._mouse, this._camera); - - const robot = this._scene.children[this._robotIndex]; - if (robot) { - const intersects = this._raycaster.intersectObject(robot, true); - - if (intersects.length > 0) { - const selectedObject = intersects[0].object; - this.linkSelected.emit(selectedObject); - } - } - }); - } - - /** - * Sets up hovering functionality - */ - private _setupHovering(): void { - this.domElement.addEventListener('mousemove', (event: MouseEvent) => { - if (!this._editorMode) { - return; - } - - const rect = this.domElement.getBoundingClientRect(); - this._mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; - this._mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; - - this._raycaster.setFromCamera(this._mouse, this._camera); - - const robot = this._scene.children[this._robotIndex]; - if (robot) { - const intersects = this._raycaster.intersectObject(robot, true); - - // Un-highlight the previously hovered object - if (this._hoveredObj.link) { - // Only restore material if the object is not currently selected - if ( - this._hoveredObj.link !== this._selectedParentObj.link && - this._hoveredObj.link !== this._selectedChildObj.link - ) { - this._hoveredObj.link.material = this._hoveredObj.originalMaterial; - } - if (this._hoveredObj.tag) { - this._hoveredObj.link.remove(this._hoveredObj.tag); - } - this._hoveredObj = { link: null, originalMaterial: null, tag: null }; - this.redraw(); - } - - if (intersects.length > 0) { - const hoveredObject = intersects[0].object as any; - - // Don't highlight already selected links - if ( - hoveredObject === this._selectedParentObj.link || - hoveredObject === this._selectedChildObj.link - ) { - return; - } - - this._hoveredObj.link = hoveredObject; - this._hoveredObj.originalMaterial = hoveredObject.material; - hoveredObject.material = this._highlightMaterial; - - // Find the link name by traversing up the hierarchy - let visual: any = hoveredObject; - while (visual && !visual.isURDFVisual) { - visual = visual.parent; - } - - // Add a tag with the link's name - if (visual && visual.urdfNode?.parentElement) { - const linkName = visual.urdfNode.parentElement.getAttribute('name'); - const tagDiv = document.createElement('div'); - tagDiv.className = 'jp-urdf-label'; - tagDiv.textContent = linkName; - tagDiv.style.backgroundColor = 'yellow'; - tagDiv.style.color = 'black'; - - const tag = new CSS2DObject(tagDiv); - this._hoveredObj.tag = tag; - hoveredObject.add(tag); - } - - this.redraw(); - } - } - }); - } - - /** - * Enables or disables editor mode - * - * @param enabled - Whether editor mode is enabled - */ - setEditorMode(enabled: boolean): void { - this._editorMode = enabled; - - if (!enabled) { - this.clearHighlights(); - } - } - - /** - * Highlights a selected link and adds a tag. - * @param link - The link object to highlight. - * @param type - The type of selection ('parent' or 'child'). - */ - highlightLink(link: any, type: 'parent' | 'child'): void { - const material = - type === 'parent' - ? this._parentSelectMaterial - : this._childSelectMaterial; - const selectedObj = - type === 'parent' ? this._selectedParentObj : this._selectedChildObj; - - // Clear previous selection of the same type - if (selectedObj.link) { - selectedObj.link.material = selectedObj.originalMaterial; - if (selectedObj.tag) { - selectedObj.link.remove(selectedObj.tag); - } - } - - // Apply new highlight - // The true original material is the one stored on hover. - selectedObj.originalMaterial = - link === this._hoveredObj.link - ? this._hoveredObj.originalMaterial - : link.material; - link.material = material; - selectedObj.link = link; - - this.redraw(); - } - - /** - * Clears all highlights and tags from selected links. - */ - clearHighlights(): void { - if (this._selectedParentObj.link) { - this._selectedParentObj.link.material = - this._selectedParentObj.originalMaterial; - if (this._selectedParentObj.tag) { - this._selectedParentObj.link.remove(this._selectedParentObj.tag); - } - this._selectedParentObj = { - link: null, - originalMaterial: null, - tag: null - }; - } - if (this._selectedChildObj.link) { - this._selectedChildObj.link.material = - this._selectedChildObj.originalMaterial; - if (this._selectedChildObj.tag) { - this._selectedChildObj.link.remove(this._selectedChildObj.tag); - } - this._selectedChildObj = { - link: null, - originalMaterial: null, - tag: null - }; - } - this.redraw(); - } - /** * Initializes the scene */ @@ -689,26 +448,6 @@ export class URDFRenderer extends THREE.WebGLRenderer { } } - /** - * Un-highlights a single selected link. - * @param type - The type of selection to clear ('parent' or 'child'). - */ - unHighlightLink(type: 'parent' | 'child'): void { - const selectedObj = - type === 'parent' ? this._selectedParentObj : this._selectedChildObj; - - if (selectedObj.link) { - selectedObj.link.material = selectedObj.originalMaterial; - if (selectedObj.tag) { - selectedObj.link.remove(selectedObj.tag); - } - selectedObj.link = null; - selectedObj.originalMaterial = null; - selectedObj.tag = null; - } - this.redraw(); - } - /** * Refreshes the viewer by re-rendering the scene and its elements */ @@ -737,4 +476,14 @@ export class URDFRenderer extends THREE.WebGLRenderer { setCss2dSize(width: number, height: number): void { this._css2dRenderer.setSize(width, height); } + + get camera(): THREE.PerspectiveCamera { + return this._camera; + } + + getRobot(): URDFRobot | null { + return this._robotIndex !== -1 + ? (this._scene.children[this._robotIndex] as URDFRobot) + : null; + } } From 57a6499fac135a358f7fdcc73476264b4992863d Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Fri, 25 Jul 2025 00:22:38 +0200 Subject: [PATCH 14/18] Fix input validation --- src/controls.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/controls.ts b/src/controls.ts index f091259..ccd8bbf 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -190,6 +190,9 @@ export class URDFControls extends GUI { stepSize ); + // Enforce input validation + this._enforceNumericInput(this.controls.scene.height); + this._sceneFolder.open(); } return this.controls.scene; @@ -245,6 +248,9 @@ export class URDFControls extends GUI { limitMax, stepSize ); + + // Enforce numeric input for joint controls + this._enforceNumericInput(this.controls.joints[name]); }); this._jointsFolder.open(); } @@ -420,6 +426,17 @@ export class URDFControls extends GUI { .name('Show Helper') }; + // Enforce input validation for light controls + this._enforceNumericInput( + this.controls.lights.directional.position.altitude + ); + this._enforceNumericInput( + this.controls.lights.directional.position.azimuth + ); + this._enforceNumericInput(this.controls.lights.directional.intensity); + this._enforceNumericInput(this.controls.lights.ambient.intensity); + this._enforceNumericInput(this.controls.lights.hemisphere.intensity); + // Open Scene (lights) and directional subfolder this._sceneFolder.open(); directionalFolder.open(); From ca6a86e72bb7b46c28e1f5a8823ab83fffd04e45 Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Fri, 25 Jul 2025 00:37:17 +0200 Subject: [PATCH 15/18] Make joints folder responsive --- src/layout.ts | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/layout.ts b/src/layout.ts index 201fb15..7f3e269 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -335,6 +335,12 @@ export class URDFLayout extends PanelLayout { velocity: editorControls.velocity.getValue() }); this._context.model.fromString(newUrdfString); + + // Update the robot model and refresh joint controls + this._loader.setRobot(newUrdfString); + this._renderer.setRobot(this._loader.robotModel); + this._refreshJointControls(); + this._selectedLinks.parent = { name: null, obj: null }; this._selectedLinks.child = { name: null, obj: null }; editorControls.parent.setValue('none'); @@ -470,7 +476,6 @@ export class URDFLayout extends PanelLayout { }); }); - // Trigger the change handler once to set the initial visibility editorControls.type.domElement.dispatchEvent(new Event('change')); editorControls.parent.onChange((linkName: string) => { @@ -478,10 +483,9 @@ export class URDFLayout extends PanelLayout { if (linkName !== 'none' && linkName === this._selectedLinks.child.name) { editorControls.parent.setValue( this._selectedLinks.parent.name || 'none' - ); // Revert + ); return; } - if (linkName === 'none') { this._interactionEditor.unHighlightLink('parent'); this._selectedLinks.parent = { name: null, obj: null }; @@ -501,7 +505,6 @@ export class URDFLayout extends PanelLayout { editorControls.child.setValue(this._selectedLinks.child.name || 'none'); // Revert return; } - if (linkName === 'none') { this._interactionEditor.unHighlightLink('child'); this._selectedLinks.child = { name: null, obj: null }; @@ -516,6 +519,22 @@ export class URDFLayout extends PanelLayout { }); } + /** + * Refreshes the joint controls by clearing and recreating them + */ + private _refreshJointControls(): void { + // Clear existing joint controls + Object.keys(this._controlsPanel.controls.joints).forEach(jointName => { + this._controlsPanel.jointsFolder.remove( + this._controlsPanel.controls.joints[jointName] + ); + }); + this._controlsPanel.controls.joints = {}; + + // Recreate joint controls with updated robot model + this._setJointControls(); + } + /** * Handle `update-request` messages sent to the widget */ From 000447a7f03d2c4b5c0b5807a959836b86f110d6 Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Fri, 25 Jul 2025 10:47:35 +0200 Subject: [PATCH 16/18] Clean up code --- src/controls.ts | 8 -------- src/editor/editor.ts | 6 +----- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/src/controls.ts b/src/controls.ts index ccd8bbf..640bbcc 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -121,7 +121,6 @@ export class URDFControls extends GUI { if (originalValue !== filteredValue) { target.value = filteredValue; - // Trigger dat.gui's internal change detection control.updateDisplay(); } }); @@ -248,8 +247,6 @@ export class URDFControls extends GUI { limitMax, stepSize ); - - // Enforce numeric input for joint controls this._enforceNumericInput(this.controls.joints[name]); }); this._jointsFolder.open(); @@ -426,7 +423,6 @@ export class URDFControls extends GUI { .name('Show Helper') }; - // Enforce input validation for light controls this._enforceNumericInput( this.controls.lights.directional.position.altitude ); @@ -441,7 +437,6 @@ export class URDFControls extends GUI { this._sceneFolder.open(); directionalFolder.open(); } - return this.controls.lights; } @@ -463,9 +458,7 @@ export class URDFControls extends GUI { 'Axis XYZ': '0 0 1', 'Lower Limit': '0.0', 'Upper Limit': '0.0', - // eslint-disable-next-line Effort: '0.0', - // eslint-disable-next-line Velocity: '0.0', 'Add Joint': addJointCallback }; @@ -517,7 +510,6 @@ export class URDFControls extends GUI { .add(editorSettings, 'Velocity') .name('Velocity'); - // Enforce numeric input for relevant fields this._enforceNumericInput(this.controls.editor.origin_xyz); this._enforceNumericInput(this.controls.editor.origin_rpy); this._enforceNumericInput(this.controls.editor.axis_xyz); diff --git a/src/editor/editor.ts b/src/editor/editor.ts index 59af37e..987b08b 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -50,7 +50,7 @@ export class Editor { this._raycaster = new THREE.Raycaster(); this._mouse = new THREE.Vector2(); - // Create materials + // Create highlight and selection materials this._highlightMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00, transparent: true, @@ -133,9 +133,7 @@ export class Editor { const robot = this._renderer.getRobot(); if (robot) { - // Un-highlight the previously hovered object if (this._hoveredObj.link) { - // Only restore material if the object is not currently selected if ( this._hoveredObj.link !== this._selectedParentObj.link && this._hoveredObj.link !== this._selectedChildObj.link @@ -183,8 +181,6 @@ export class Editor { const tagDiv = document.createElement('div'); tagDiv.className = 'jp-urdf-label'; tagDiv.textContent = linkName; - tagDiv.style.backgroundColor = 'yellow'; - tagDiv.style.color = 'black'; const tag = new CSS2DObject(tagDiv); this._hoveredObj.tag = tag; From 3d5d6e21ea574b80e5dcc66077a4c0faab090d83 Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Fri, 25 Jul 2025 10:50:08 +0200 Subject: [PATCH 17/18] Improve styling --- style/base.css | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/style/base.css b/style/base.css index 2b17074..a9cbe39 100644 --- a/style/base.css +++ b/style/base.css @@ -195,9 +195,10 @@ li.cr.number.has-slider > div { /* Styling for 3D labels in editor mode */ .jp-urdf-label { - color: #fff; + background: yellow; + color: black; padding: 2px 5px; border-radius: 3px; - font-family: sans-serif; - font-size: 12px; + font-family: var(--jp-ui-font-family, sans-serif); + font-size: var(--jp-ui-font-size1, 12px); } From db6ab2926010c9537d8c100c07b606687bda6bfc Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Tue, 5 Aug 2025 17:29:25 +0200 Subject: [PATCH 18/18] Refactor code --- src/controls.ts | 51 ++++++++++++----------- src/editor/{editor.ts => linkSelector.ts} | 16 +++---- src/editor/urdf-editor.ts | 2 +- src/layout.ts | 19 ++++----- 4 files changed, 45 insertions(+), 43 deletions(-) rename src/editor/{editor.ts => linkSelector.ts} (95%) diff --git a/src/controls.ts b/src/controls.ts index 640bbcc..fc934a0 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -16,7 +16,7 @@ export class URDFControls extends GUI { private _workspaceFolder: any; private _sceneFolder: any; private _jointsFolder: any; - private _editorFolder: any; + private _jointsEditorFolder: any; private _workingPath = ''; controls: any = { @@ -53,6 +53,12 @@ export class URDFControls extends GUI { this._jointsFolder = this.addFolder('Joints'); this._jointsFolder.domElement.setAttribute('class', 'dg joints-folder'); + this._jointsEditorFolder = this.addFolder('Joints Editor'); + this._jointsEditorFolder.domElement.setAttribute( + 'class', + 'dg editor-folder' + ); + this._workspaceFolder = this.addFolder('Workspace'); this._workspaceFolder.domElement.setAttribute( 'class', @@ -61,9 +67,6 @@ export class URDFControls extends GUI { this._sceneFolder = this.addFolder('Scene'); this._sceneFolder.domElement.setAttribute('class', 'dg scene-folder'); - - this._editorFolder = this.addFolder('Editor'); - this._editorFolder.domElement.setAttribute('class', 'dg editor-folder'); } /** @@ -90,8 +93,8 @@ export class URDFControls extends GUI { /** * Retrieves the folder with editor settings */ - get editorFolder() { - return this._editorFolder; + get jointsEditorFolder() { + return this._jointsEditorFolder; } /** @@ -448,7 +451,7 @@ export class URDFControls extends GUI { createEditorControls(addJointCallback: () => void, linkNames: string[] = []) { if (this._isEmpty(this.controls.editor)) { const editorSettings = { - 'Editor Mode': false, + 'Cursor Link Selection': false, 'Parent Link': 'none', 'Child Link': 'none', 'Joint Name': 'new_joint', @@ -456,8 +459,8 @@ export class URDFControls extends GUI { 'Origin XYZ': '0 0 0', 'Origin RPY': '0 0 0', 'Axis XYZ': '0 0 1', - 'Lower Limit': '0.0', - 'Upper Limit': '0.0', + 'Lower Limit': '-1.0', + 'Upper Limit': '1.0', Effort: '0.0', Velocity: '0.0', 'Add Joint': addJointCallback @@ -465,48 +468,48 @@ export class URDFControls extends GUI { const dropdownOptions = ['none', ...linkNames]; - this.controls.editor.mode = this._editorFolder.add( + this.controls.editor.mode = this._jointsEditorFolder.add( editorSettings, - 'Editor Mode' + 'Cursor Link Selection' ); - this.controls.editor.parent = this._editorFolder + this.controls.editor.parent = this._jointsEditorFolder .add(editorSettings, 'Parent Link', dropdownOptions) .listen(); - this.controls.editor.child = this._editorFolder + this.controls.editor.child = this._jointsEditorFolder .add(editorSettings, 'Child Link', dropdownOptions) .listen(); - this.controls.editor.name = this._editorFolder.add( + this.controls.editor.name = this._jointsEditorFolder.add( editorSettings, 'Joint Name' ); - this.controls.editor.type = this._editorFolder.add( + this.controls.editor.type = this._jointsEditorFolder.add( editorSettings, 'Joint Type', ['revolute', 'continuous', 'prismatic', 'fixed', 'floating', 'planar'] ); // Add origin and axis controls - this.controls.editor.origin_xyz = this._editorFolder + this.controls.editor.origin_xyz = this._jointsEditorFolder .add(editorSettings, 'Origin XYZ') .name('Origin XYZ'); - this.controls.editor.origin_rpy = this._editorFolder + this.controls.editor.origin_rpy = this._jointsEditorFolder .add(editorSettings, 'Origin RPY') .name('Origin RPY'); - this.controls.editor.axis_xyz = this._editorFolder + this.controls.editor.axis_xyz = this._jointsEditorFolder .add(editorSettings, 'Axis XYZ') .name('Axis XYZ'); // Add limit controls - this.controls.editor.lower = this._editorFolder + this.controls.editor.lower = this._jointsEditorFolder .add(editorSettings, 'Lower Limit') .name('Lower Limit'); - this.controls.editor.upper = this._editorFolder + this.controls.editor.upper = this._jointsEditorFolder .add(editorSettings, 'Upper Limit') .name('Upper Limit'); - this.controls.editor.effort = this._editorFolder + this.controls.editor.effort = this._jointsEditorFolder .add(editorSettings, 'Effort') .name('Effort'); - this.controls.editor.velocity = this._editorFolder + this.controls.editor.velocity = this._jointsEditorFolder .add(editorSettings, 'Velocity') .name('Velocity'); @@ -518,12 +521,12 @@ export class URDFControls extends GUI { this._enforceNumericInput(this.controls.editor.effort); this._enforceNumericInput(this.controls.editor.velocity); - this.controls.editor.add = this._editorFolder.add( + this.controls.editor.add = this._jointsEditorFolder.add( editorSettings, 'Add Joint' ); - this._editorFolder.open(); + this._jointsEditorFolder.open(); } return this.controls.editor; diff --git a/src/editor/editor.ts b/src/editor/linkSelector.ts similarity index 95% rename from src/editor/editor.ts rename to src/editor/linkSelector.ts index 987b08b..038393f 100644 --- a/src/editor/editor.ts +++ b/src/editor/linkSelector.ts @@ -4,13 +4,13 @@ import { Signal } from '@lumino/signaling'; import { URDFRenderer } from '../renderer'; /** - * Editor: Handles all user interaction logic for the URDF editor mode + * LinkSelector: Handles all user interaction logic for the URDF link selection mode */ -export class Editor { +export class LinkSelector { private _renderer: URDFRenderer; private _raycaster: THREE.Raycaster; private _mouse: THREE.Vector2; - private _editorMode = false; + private _linkSelectorMode = false; private _hoveredObj: { link: any | null; @@ -71,10 +71,10 @@ export class Editor { } /** - * Sets the editor mode on/off + * Sets the link selector mode on/off */ - setEditorMode(enabled: boolean): void { - this._editorMode = enabled; + setLinkSelectorMode(enabled: boolean): void { + this._linkSelectorMode = enabled; if (!enabled) { this.clearHighlights(); @@ -94,7 +94,7 @@ export class Editor { */ private _setupPicking(): void { this._renderer.domElement.addEventListener('click', (event: MouseEvent) => { - if (!this._editorMode) { + if (!this._linkSelectorMode) { return; } @@ -121,7 +121,7 @@ export class Editor { this._renderer.domElement.addEventListener( 'mousemove', (event: MouseEvent) => { - if (!this._editorMode) { + if (!this._linkSelectorMode) { return; } diff --git a/src/editor/urdf-editor.ts b/src/editor/urdf-editor.ts index 3da7607..cefa25f 100644 --- a/src/editor/urdf-editor.ts +++ b/src/editor/urdf-editor.ts @@ -1,7 +1,7 @@ /** * A class for manipulating URDF XML documents. */ -export class UrdfEditor { +export class URDFEditor { private _parser = new DOMParser(); private _serializer = new XMLSerializer(); diff --git a/src/layout.ts b/src/layout.ts index 7f3e269..dbe18a0 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -12,9 +12,9 @@ import { URDFRenderer } from './renderer'; import { URDFLoadingManager } from './robot'; -import { UrdfEditor } from './editor/urdf-editor'; +import { URDFEditor } from './editor/urdf-editor'; -import { Editor } from './editor/editor'; +import { LinkSelector } from './editor/linkSelector'; interface IURDFColors { sky: Color; @@ -30,8 +30,8 @@ export class URDFLayout extends PanelLayout { private _renderer: URDFRenderer; private _colors: IURDFColors; private _loader: URDFLoadingManager; - private _editor: UrdfEditor; - private _interactionEditor: Editor; + private _editor: URDFEditor; + private _interactionEditor: LinkSelector; private _context: DocumentRegistry.IContext | null = null; private _selectedLinks: { parent: { name: string | null; obj: any | null }; @@ -59,8 +59,8 @@ export class URDFLayout extends PanelLayout { this._renderer = new URDFRenderer(this._colors.sky, this._colors.ground); this._controlsPanel = new URDFControls(); this._loader = new URDFLoadingManager(); - this._editor = new UrdfEditor(); - this._interactionEditor = new Editor(this._renderer); + this._editor = new URDFEditor(); + this._interactionEditor = new LinkSelector(this._renderer); } /** @@ -106,6 +106,7 @@ export class URDFLayout extends PanelLayout { updateURDF(urdfString: string): void { this._loader.setRobot(urdfString); this._renderer.setRobot(this._loader.robotModel); + this._refreshJointControls(); } /** @@ -337,9 +338,7 @@ export class URDFLayout extends PanelLayout { this._context.model.fromString(newUrdfString); // Update the robot model and refresh joint controls - this._loader.setRobot(newUrdfString); - this._renderer.setRobot(this._loader.robotModel); - this._refreshJointControls(); + this.updateURDF(newUrdfString); this._selectedLinks.parent = { name: null, obj: null }; this._selectedLinks.child = { name: null, obj: null }; @@ -356,7 +355,7 @@ export class URDFLayout extends PanelLayout { ); editorControls.mode.onChange((enabled: boolean) => { - this._interactionEditor.setEditorMode(enabled); + this._interactionEditor.setLinkSelectorMode(enabled); if (!enabled) { this._selectedLinks.parent = { name: null, obj: null }; this._selectedLinks.child = { name: null, obj: null };