Refactor Annotation System with AnnotationManager#8323
Conversation
There was a problem hiding this comment.
Pull request overview
This PR refactors the annotation system by introducing an event-driven architecture that decouples annotation data from rendering. An AnnotationManager class now handles all global configuration, shared resources, and rendering logic, while the Annotation class becomes a lightweight data-only script. The two communicate exclusively through app-level events (annotation:add and annotation:remove), eliminating direct dependencies between them.
Key Changes:
- Introduced
AnnotationManagerclass with configurable attributes (hotspot size, colors, opacity) and centralized resource management - Simplified
Annotationto a pure data class that fires events when properties change - Updated the gaussian-splatting annotations example to demonstrate the new architecture with a bicycle model
Reviewed changes
Copilot reviewed 4 out of 7 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/esm/annotation.mjs | Complete refactor: split monolithic Annotation class into AnnotationManager (handles rendering/interaction) and Annotation (data only), introducing event-based communication |
| examples/src/examples/gaussian-splatting/annotations.example.mjs | Updated example to use new architecture with bicycle model, demonstrates AnnotationManager configuration and runtime property updates |
| examples/src/examples/gaussian-splatting/annotations.controls.mjs | Added controls for new manager properties: hotspot size, hotspot color, hover color, and both opacity settings |
| examples/src/static/styles.css | Simplified CSS selectors by removing -controls suffix from control panel selectors |
| examples/thumbnails/gaussian-splatting_annotations_small.webp | New thumbnail image for updated example |
| examples/thumbnails/gaussian-splatting_annotations_large.webp | New thumbnail image for updated example |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * @range [0, 1] | ||
| * @default 0.25 | ||
| */ | ||
| set behindOpacity(value) { |
There was a problem hiding this comment.
Same issue as with the opacity setter - no early-out check to avoid unnecessary reassignments and no update logic. Consider adding if (this._behindOpacity === value) return; at the start.
| set behindOpacity(value) { | |
| set behindOpacity(value) { | |
| if (this._behindOpacity === value) { | |
| return; | |
| } |
| if (this._camera === null) { | ||
| const cameraComponent = this.app.root.findComponent('camera'); | ||
| if (cameraComponent) { | ||
| this._camera = cameraComponent.entity; | ||
| } | ||
| } |
There was a problem hiding this comment.
The camera is found using findComponent('camera') which returns the first camera component found in the hierarchy. If there are multiple cameras in the scene, this might not be the correct one. Additionally, if the camera is added to the scene after the AnnotationManager initializes, the annotations won't be visible from that camera. Consider: 1) adding a camera attribute to let users explicitly set which camera to use, 2) documenting this limitation, or 3) adding logic to detect when new cameras are added.
| // Clean up on destroy | ||
| this.once('destroy', () => { | ||
| // Unregister all annotations | ||
| for (const annotation of this._annotationResources.keys()) { |
There was a problem hiding this comment.
When iterating over this._annotationResources.keys() during cleanup (line 920), the iteration happens over a live Map that is being modified by _unregisterAnnotation (which calls delete at line 817). While JavaScript Map iteration is safe when modifying the map during iteration (it uses a snapshot), it's clearer to create an array copy first: Array.from(this._annotationResources.keys()) or [...this._annotationResources.keys()] to make the intent explicit and avoid potential confusion.
| for (const annotation of this._annotationResources.keys()) { | |
| for (const annotation of Array.from(this._annotationResources.keys())) { |
| } | ||
| // Remove layers from scene | ||
| const { layers } = this.app.scene; | ||
| this._layers.forEach(layer => layers.remove(layer)); |
There was a problem hiding this comment.
The layers are removed from the scene at line 949, but there's no check to ensure these layers exist or haven't already been removed. If the layers were already removed from the scene (e.g., by another script or manual cleanup), calling layers.remove(layer) might log warnings or cause issues. Consider adding a check: if (layers.has(layer)) before removing, or wrapping in a try-catch.
| this._layers.forEach(layer => layers.remove(layer)); | |
| this._layers.forEach(layer => { | |
| if (layers.has(layer)) { | |
| layers.remove(layer); | |
| } | |
| }); |
| postInitialize() { | ||
| // Notify any listeners that this annotation has been created | ||
| this.app.fire('annotation:add', this); |
There was a problem hiding this comment.
There's a potential issue if an Annotation is created before any AnnotationManager exists. The annotation:add event will be fired on the app (line 1044), but if no manager is listening yet, the annotation won't be registered. If a manager is created later, it won't know about annotations that were created before it. Consider documenting this requirement or adding logic to handle late-arriving managers (e.g., the manager could scan for existing Annotation scripts on initialization).
| this._layers = [ | ||
| createLayer('HotspotBase', false), | ||
| createLayer('HotspotOverlay', true) |
There was a problem hiding this comment.
If multiple AnnotationManager instances are created (which the architecture doesn't prevent), each will create layers with duplicate names 'HotspotBase' and 'HotspotOverlay' (lines 841-842). This could lead to unexpected behavior. Consider either: 1) documenting that only one AnnotationManager should exist per scene, 2) using unique layer names per manager instance, or 3) checking if layers with these names already exist before creating them.
| this._tooltipDom.style.opacity = '0'; | ||
|
|
||
| // Wait for fade out before hiding | ||
| setTimeout(() => { | ||
| if (this._tooltipDom && this._tooltipDom.style.opacity === '0') { | ||
| this._tooltipDom.style.visibility = 'hidden'; | ||
| } | ||
| annotation.fire('hide'); |
There was a problem hiding this comment.
The _hideTooltip method uses a setTimeout to delay hiding the tooltip for fade-out animation (line 499-504). However, if the AnnotationManager is destroyed before this timeout completes, this._tooltipDom could be null and annotation might be destroyed, which could cause errors when trying to fire the 'hide' event. The check on line 500 only guards against the tooltip being shown again, not against destruction. Consider storing the timeout ID and clearing it in the destroy handler, or adding null checks before firing events.
| this._tooltipDom.style.opacity = '0'; | |
| // Wait for fade out before hiding | |
| setTimeout(() => { | |
| if (this._tooltipDom && this._tooltipDom.style.opacity === '0') { | |
| this._tooltipDom.style.visibility = 'hidden'; | |
| } | |
| annotation.fire('hide'); | |
| // If the tooltip DOM has already been torn down, just try to fire the hide event safely. | |
| if (!this._tooltipDom) { | |
| if (annotation && typeof annotation.fire === 'function') { | |
| annotation.fire('hide'); | |
| } | |
| return; | |
| } | |
| this._tooltipDom.style.opacity = '0'; | |
| // Wait for fade out before hiding | |
| setTimeout(() => { | |
| const tooltipDom = this._tooltipDom; | |
| if (tooltipDom && tooltipDom.style.opacity === '0') { | |
| tooltipDom.style.visibility = 'hidden'; | |
| } | |
| if (annotation && typeof annotation.fire === 'function') { | |
| annotation.fire('hide'); | |
| } |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This PR refactors the annotation system to introduce an
AnnotationManagerthat handles all global configuration and rendering resources, whileAnnotationbecomes a lightweight data-only script. The two classes are completely decoupled and communicate via app-level events.new-annotations.mp4
Architecture
AnnotationManager
A new manager script that handles:
hotspotSize- Size of hotspots in screen pixelshotspotColor- Default color of hotspotshoverColor- Color when hoveredopacity- Opacity when visiblebehindOpacity- Opacity when behind geometryThe manager listens for app-level events:
annotation:add- Automatically registers annotations and creates their rendering resourcesannotation:remove- Cleans up resources when annotations are destroyedAnnotation
Simplified to a pure data class with three attributes:
label- Short text on the hotspot (e.g., "1", "A")title- Tooltip titletext- Tooltip descriptionFires app-level events in
postInitialize()to ensure the manager is ready:annotation:add- When createdannotation:remove- When destroyedAlso fires script-level events for the manager to handle attribute changes:
label:set,title:set,text:setKey Changes
Decoupled Architecture
Annotationhas zero knowledge ofAnnotationManagerEvent Flow
Resource Management
Updated Example
The annotations example has been updated to demonstrate the new architecture:
AnnotationManageron the bicycle entityPublic API
AnnotationManager
Annotation
Checklist