A browser-based 3D digital twin for a two-robot workcell (a Universal Robots UR10e and a Kawasaki RS005L mounted on an AGV). It renders both robots from their real CAD/URDF mesh geometry, drives them either from live telemetry stored in MongoDB Atlas or from manual on-screen controls, and reproduces the original Gazebo simulation environment (safety fencing, cabinet, table, conveyor, AGV floor rail) around them.
The project is a small monorepo with two independent Node projects:
| Folder | What it is |
|---|---|
client/ |
React + Three.js single-page app — the 3D viewer and control UI |
api/ |
Express backend that streams the latest robot joint state from MongoDB to the client over Server-Sent Events |
- Two fully-articulated robots, built joint-by-joint from their actual STL meshes (not primitives):
- UR10e — 6 rotational joints + a 7th linear rail axis (
UR10eMount), plus a mounted 2-finger gripper. - Kawasaki RS005L — 6 rotational joints, mounted on an OTA AGV mobile base (
AGVBase) that itself rides a 7th linear floor-rail axis, plus its SCHUNK EGP-50 gripper.
- UR10e — 6 rotational joints + a 7th linear rail axis (
- Live mode — an
EventSourceconnection streams real joint angles from MongoDB Atlas every ~250 ms; a Live / Connecting / Offline indicator in the header shows the connection state at a glance. - Manual mode — every joint (6 per robot), each robot's 7th rail axis, and the gripper opening can be dragged by hand in the control panel; live updates are simply skipped while "Play demo animation" is on, so manual and live control never fight each other outside of that.
- Demo animation — a scripted, collision-aware "scan" motion for both arms plus rail sliding, driven by direct Three.js ref mutation (not React state) so it stays smooth at 60 fps.
- Gazebo-accurate environment — the floor fencing, cabinet, table, conveyor, chassis and AGV rail are the actual meshes from the original Gazebo world (
ifarlab.sdf), positioned using the world's real coordinate frame and the robots' real spawn poses from the ROS 2 launch files, not guesswork. - Manual camera controls — an on-screen rotate/zoom button cluster (top-right of the viewport) that does the same thing as left-click-drag (orbit) and scroll (zoom), for anyone who doesn't want to use the mouse gestures.
- Responsive — the control panel becomes a slide-in drawer on mobile, the navbar collapses to just a logo + Home link, and the layout adapts down to phone widths.
Frontend (client/)
- React 18 (Create React App /
react-scripts) - react-three-fiber — React renderer for Three.js
@react-three/drei—OrbitControls,Htmlhelpers- Plain CSS, no UI framework
Backend (api/)
- Node.js + Express
mongodbofficial driver — reads from MongoDB Atlas- Server-Sent Events (native
res.write, no socket library) for the live stream cors,dotenv
Quick-glance text version of the same flow:
MongoDB Atlas (joint_states collection)
│ polled every request-interval (min 250 ms)
▼
api/server.js ──HTTP GET /api/telemetry/joint-states/stream (SSE)──▶ client (EventSource)
│ │
│ converts raw doc → { ur10e:[6 deg], ur10eRail, kawasaki:[6 deg], agvRail }
│ ▼
│ React state in DigitalTwin.js
│ │
└── api/debug.js: same Mongo read, prints to console (no HTTP) ▼
Three.js scene re-renders
robots at the new joint angles
Each MongoDB document stores ur10e and kawasaki as 7-element arrays, in radians: index 0 is the linear/prismatic "7th axis" (the UR10e's rail carriage position, or the AGV's position along its own floor rail), and indices 1–6 are the six arm joints in URDF order. api/server.js drops nothing — it converts the 6 rotational values to degrees for the arm, and passes the 7th value straight through in metres — and streams the result as a joint_states SSE event. The client's EventSource listener applies it directly to React state, which flows down into the Three.js joint groups.
The PlantUML source behind the diagram above is docs/sequence-diagram.puml — edit it and re-render with any PlantUML renderer (e.g. the PlantUML VS Code extension, or plantuml.com) if the flow changes.
The STL meshes under client/public/meshes/ come directly from the two robots' real ROS description packages (Universal_Robots_ROS2_Description for the UR10e, and the project's own mobile_manipulator_description for the Kawasaki + AGV + facility). Joint offsets and axes were taken from the packages' .xacro files, not eyeballed — see the comments at the top of UR10eRobot.js and KawasakiRobot.js for the exact joint table. The facility/environment layout in EnvironmentElements.js reconstructs the original ifarlab.sdf Gazebo world: every static prop's raw STL coordinates already encode its true position in that world's shared frame, and the robots' spawn poses were taken from whole_ifarlab_gazebo.launch.py, so the whole scene lines up without hand-placed guesswork. A few small furniture pieces (chassis, table, conveyor) have no matching xacro/launch entry in the source repo, so their placement is a best-effort reading of their own raw coordinates rather than a verified one — see the comments in EnvironmentElements.js for exactly which pieces that applies to.
This repo includes paid 3D assets purchased from marketplace platforms (for example, Fab). Those files are used under the applicable product license for each purchased model, and the license terms must be checked before redistribution, public hosting, or reuse outside the project scope.
For the purchased models currently used in this project, the relevant license is the product's Fab Standard License as published on the marketplace listing and linked in the vendor's terms page: https://www.fab.com/eula
Notes:
*.stl,*.glb,*.gltf, and similar mesh files are not automatically covered by an open-source license unless the source marketplace explicitly says so.- Commercial use, redistribution, and publication must remain consistent with the specific asset license attached to the purchased listing.
- If a model is used in a public demo, client delivery, or publication, keep the original purchase record and the listing URL in the project documentation.
- This repo does not redistribute the raw purchased asset pack as a separate public download unless the marketplace license explicitly allows it.
- This is a project-level compliance note and not legal advice; for final legal clearance, confirm the exact license terms for each asset in the store listing and purchase receipt.
Recommended practice for this project:
- keep a record of each marketplace listing URL and purchase receipt;
- store asset provenance in the same folder or documentation as the model files;
- do not re-upload vendor meshes to a public file-sharing site unless the license explicitly permits it;
- ensure any customer-facing deployment uses only the set of assets covered by the purchased license.
monitoring-tool/
├── api/ Express + SSE backend
│ ├── server.js npm start — the HTTP/SSE server the client talks to
│ ├── debug.js npm run debug — one-off/console CLI dump of the latest Mongo doc
│ └── .env MONGODB_URI, DB_NAME, COLLECTION_NAME, PORT (not committed)
│
└── client/ React app
├── public/
│ ├── assets/logo/ MATISSE logo
│ └── meshes/ STL meshes served to the browser
│ ├── ur10e/ UR10e arm + rail carriage + gripper
│ ├── rs005l/ Kawasaki RS005L arm + gripper
│ ├── ota/ AGV (OTA) mobile base + wheels
│ └── environment/ Facility props (fencing, cabinet, ifarlab_ray rail)
└── src/
├── App.js / App.css App shell: Navbar + page + Footer
├── components/
│ ├── Navbar.js / Footer.js
│ ├── Scene.js Lighting + procedural grid-texture floor
│ ├── UR10eRobot.js UR10e kinematic chain + gripper
│ ├── UR10eMount.js UR10e's 7th-axis rail carriage
│ ├── KawasakiRobot.js Kawasaki kinematic chain + gripper
│ ├── AGVBase.js OTA mobile base + wheels
│ ├── RailAnimator.js Drives rail sliding during demo mode (ref-based, no re-render)
│ ├── EnvironmentElements.js Static workcell/facility meshes
│ └── ControlPanel.js / .css Sidebar UI (joints, rail, gripper, animation, environment)
└── pages/
└── DigitalTwin.js / .css Main page: layout, SSE client, camera, robot placement
- Node.js 18+ (developed on Node 24)
- A MongoDB Atlas cluster with a
joint_statescollection (see Data model below)
cd api
npm install
cp .env.example .env # if you don't already have one — see Environment variables below
npm start # starts the SSE server on http://localhost:3001You should see:
API listening: http://localhost:3001
MongoDB Atlas connection successful.
To just sanity-check the Mongo connection without the web server, use the CLI tool instead:
npm run debug # polls and prints the latest document, Ctrl+C to stop
npm run debug:once # prints once and exitscd client
npm install
npm start # dev server, defaults to http://localhost:3000Open the printed URL in a browser. The app talks to the backend at http://localhost:3001/api by default (see REACT_APP_API_URL below to point it elsewhere).
For a production build: npm run build (outputs to client/build/, serve with any static file server).
api/.env
| Variable | Required | Default | Purpose |
|---|---|---|---|
MONGODB_URI |
yes | — | Atlas connection string |
DB_NAME |
no | ifarlabmatisse_db_user |
Database name |
COLLECTION_NAME |
no | joint_states |
Collection name |
PORT |
no | 3001 |
Port the SSE server listens on (Render sets this automatically) |
CORS_ORIGIN |
no | (allow all) | Comma-separated list of allowed frontend origins in production, e.g. https://your-app.vercel.app. Leave unset for local dev. |
POLL_INTERVAL_MS |
no | 1000 |
debug.js only — how often it re-polls Mongo (min 250) |
client/.env (optional)
| Variable | Default | Purpose |
|---|---|---|
REACT_APP_API_URL |
http://localhost:3001/api |
Base URL the client's EventSource connects to |
Each document in the joint_states collection looks like:
If no document has _id: "latest", both server.js and debug.js fall back to the most recent document by ts.
- Navbar — MATISSE logo (left) and Home (right); minimal by design.
- Live badge (top-right of the secondary header) — green/blinking "Live" once real telemetry is flowing, gray "Connecting" while the SSE connection is establishing, red "Offline" if the backend can't be reached.
- Robot tabs (top of the sidebar) — switch the control panel between UR10e and Kawasaki; each robot keeps its own joint/rail state independently.
- Joints — one slider per rotational joint (±360°) plus a "Rail (7th axis)" slider in metres for that robot's linear axis. Sliders are disabled while demo animation is playing, and are overwritten by live data unless animation is on.
- Animation — "Play demo animation" runs a safe, repeatable scan/inspect motion (ignores live/manual joint values while active); "Speed" scales how fast it runs.
- Gripper — one slider (0–100% opening) shared by both robots' grippers.
- Show environment — toggles the facility props (fencing, cabinet, table, conveyor, chassis, AGV rail) on/off, leaving just the two robots and the floor.
- 3D viewport — left-click drag to orbit, right-click drag to pan, scroll to zoom, or use the ▲◀▶▼ / +/− button cluster in the top-right corner for the same gestures without a mouse.
- A handful of environment meshes (
chassis.stl,alumuniumtable.stl,conveyorbelt.stl) have no corresponding entry in any.xacroor launch file in the source repo, so their on-screen position is a best-effort reading of their own raw coordinates rather than a verified placement — see the comment block at the top ofEnvironmentElements.js. - The backend has no authentication — it's meant to run behind a trusted network/VPN or be fronted by your own auth layer before exposing it publicly.
api/.envis git-ignored on purpose; you must supply your own Atlas credentials to run the backend.



{ "_id": "latest", // server.js/debug.js always read this one first "ts": "2026-08-03T11:02:48.652Z", "ur10e": [/* 7 numbers, radians: [rail_axis, shoulder_pan, shoulder_lift, elbow, wrist_1, wrist_2, wrist_3] */], "kawasaki": [/* 7 numbers, radians: [agv_rail_axis, joint1, joint2, joint3, joint4, joint5, joint6] */] }