Rebuilding a map editor for autonomous driving
At Seoul Robotics, I worked on an editor for maps of spaces where vehicles would drive autonomously. I redesigned its data core to separate map state from Babylon.js rendering. I could then work on editing data, rendering, and tests separately while checking the new behavior against the legacy editor.
When the scene graph owns map data
I ported the editor from a roughly four-year-old TypeScript codebase into a new project. Its rendering and data logic were deeply coupled, but it had working features and we needed to keep adding functionality on top of that design.
As the data logic grew more complex, adding an editing tool required changes across more files than seemed necessary. The coupling also reached pages that only needed data processing: they had to create a hidden canvas and run the rendering logic just to process map data. I found this cumbersome and saw poor performance, but the development schedule required us to keep moving.
Separating data while retaining the rendering behavior
My first priority was limiting side effects. I considered ways to separate the data model while keeping the Babylon.js rendering logic in place. I planned a separate entity-component system (ECS) for the data, using the patterns in the existing Babylon.js implementation as a starting point.
I redesigned the data model, operation boundary, and undo history, with active feedback from two more experienced colleagues. A senior colleague continued reviewing the code and gave feedback throughout implementation.
Keeping data changes behind an operation boundary
The World class managed ECS data independently of the rendering layer. It also handled optimizations for data changes and transformations.
I treated edits initiated outside World as operations and kept their implementations in one file. A custom ESLint rule flagged ECS edits outside that boundary in application code.
Publishing changes to rendering and React
When ECS data changed, the data layer published events. Babylon.js used a notify/subscribe pattern to update rendering from those changes.
My colleagues wanted data propagation to React to follow the Zustand pattern used in another editor codebase. I used a Zustand store to read the ECS changes and propagate values to React components, keeping that integration consistent with the other editor.
This gave the rendering layer and React access to the same data while keeping data management in World.
Recording data deltas for undo
Operation-driven changes also needed to be reversible. I stored the before-and-after values of changed data as a Data Delta. When several pieces of data changed together, I merged their changes into one delta to make undo easier to manage.
Keeping those values had a memory cost. I accepted that cost because resolving side effects in large editing operations and making debugging easier mattered more to me than minimizing history storage.
Checking behavior during the transition
In the legacy structure, data and rendering were too coupled for me to build practical unit or integration tests for the behavior I needed to verify. End-to-end tooling also made it difficult to reproduce the canvas interactions reliably.
I recorded the main user flows and used them to write unit tests against the new design. I combined those automated checks with manual testing. During manual testing, I toggled the feature flag to compare the old and new implementations while keeping the visible interface and functionality largely the same.
I also added a shadow execution path. A single click could trigger calculations in both implementations; a parity check compared the computed results and raised an error notification if they differed. This let me look for behavior differences while changing the underlying architecture.
Restoring an ambiguous parent relationship
Many of the mismatches involved restoring parentId during undo and redo of group and ungroup operations. The editor used this single field both for nesting based on geospatial information and for grouping. Both behaviors could add or remove parent relationships, but their meanings and business rules were not clearly defined in the legacy logic. Restoring the field through undo and redo involved many edge cases.
The data was also represented in Protobuf and managed by the service backend, which made changing the field difficult. I kept the shared parentId, documented its rules more clearly, and added tests as I revised the behavior.
Adding tools and a multi-floor extension
In the legacy design, even adding a tool variant with the same behavior under a different name forced me to duplicate logic across more than twenty files. I then needed separate manual tests to check the side effects of those changes.
With data management and UI logic separated, I could configure a new tool’s data in one file, then add its rendering logic and test cases separately. I was much less concerned about unintended effects when extending the editor.
For a multi-floor extension, I could design and implement the data first, then work on its rendering separately.
Measuring the result
A September 2026 benchmark compared the legacy r16 release with r24, which contains the new core. Both used a 600-object map, with 15 runs per scenario on one machine.
| Measurement | r16 | r24 |
|---|---|---|
| Worst frame interval during 600 keyboard nudges of 100 parking spaces | 667 ms | 117 ms |
| Page readiness with pre-seeded map data and no backend | 5.53 s | 2.22 s |
| Settle time for a burst of 50 undos | 374 ms | 256 ms |
Values are medians; the worst-frame row is the median of each run’s maximum frame interval. These measure the benchmark workload, rather than an operator’s complete editing session. The comparison includes changes beyond the ECS, including a framework upgrade.
Testing history without rendering
The new domain library contains 587 test declarations without importing the 3D engine. The broader core suite, including rendering integration, executes 932 test cases with a reported Vitest duration of roughly seven seconds.
Costs that remain
A single undo of a large move became slower, from 104 to 119 ms. Heap growth during the editing scenario increased from about 8 to 21 MB. First contentful paint took longer even though the page reached readiness sooner.
These are release-level measurements, rather than isolated measurements of the history implementation.