Skip to content

Refactor Annotation System with AnnotationManager#8323

Merged
willeastcott merged 11 commits intomainfrom
better-annotations
Dec 29, 2025
Merged

Refactor Annotation System with AnnotationManager#8323
willeastcott merged 11 commits intomainfrom
better-annotations

Conversation

@willeastcott
Copy link
Contributor

@willeastcott willeastcott commented Dec 28, 2025

This PR refactors the annotation system to introduce an AnnotationManager that handles all global configuration and rendering resources, while Annotation becomes 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:

  • Global configuration with Editor-configurable attributes:
    • hotspotSize - Size of hotspots in screen pixels
    • hotspotColor - Default color of hotspots
    • hoverColor - Color when hovered
    • opacity - Opacity when visible
    • behindOpacity - Opacity when behind geometry
  • Shared resources: CSS stylesheet, rendering layers, shared mesh, tooltip DOM
  • Per-annotation resources: Entities, materials, textures, DOM elements
  • Interactions: Hover states, click handling, tooltip display

The manager listens for app-level events:

  • annotation:add - Automatically registers annotations and creates their rendering resources
  • annotation:remove - Cleans up resources when annotations are destroyed

Annotation

Simplified to a pure data class with three attributes:

  • label - Short text on the hotspot (e.g., "1", "A")
  • title - Tooltip title
  • text - Tooltip description

Fires app-level events in postInitialize() to ensure the manager is ready:

  • annotation:add - When created
  • annotation:remove - When destroyed

Also fires script-level events for the manager to handle attribute changes:

  • label:set, title:set, text:set

Key Changes

Decoupled Architecture

  • Annotation has zero knowledge of AnnotationManager
  • Communication happens entirely through app-level events
  • Manager subscribes to individual annotation events after registration

Event Flow

AnnotationManager.initialize()
    → Listens for 'annotation:add' on app

Annotation.postInitialize()
    → Fires 'annotation:add' on app

AnnotationManager._registerAnnotation()
    → Creates texture, materials, entities, DOM
    → Subscribes to annotation's label:set, title:set, text:set events

Resource Management

  • All rendering resources are created and owned by the manager
  • Clean separation: Annotation is just data, Manager handles visuals
  • Proper cleanup on destroy for both manager and individual annotations

Updated Example

The annotations example has been updated to demonstrate the new architecture:

  • Creates AnnotationManager on the bicycle entity
  • Annotations are children of the bicycle, so they move with it
  • Includes control panel for adjusting all manager properties:
    • Hotspot size slider
    • Hotspot color picker
    • Hover color picker
    • Opacity sliders

Public API

AnnotationManager

// Create manager
const manager = entity.script.create(AnnotationManager, {
    properties: {
        hotspotSize: 30,
        hotspotColor: new Color(1, 1, 1),
        hoverColor: new Color(1, 0.5, 0),
        opacity: 1,
        behindOpacity: 0.25
    }
});

// Update at runtime
manager.hotspotSize = 40;
manager.hotspotColor = new Color(0.9, 0.9, 0.9);

Annotation

// Create annotation - automatically registered with manager via events
const annotation = entity.script.create(Annotation, {
    properties: {
        label: '1',
        title: 'Point of Interest',
        text: 'Description text here'
    }
});

// Update at runtime - manager handles visual updates
annotation.label = 'A';
annotation.title = 'New Title';

Checklist

  • I have read the contributing guidelines
  • My code follows the project's coding standards
  • This PR focuses on a single change

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 AnnotationManager class with configurable attributes (hotspot size, colors, opacity) and centralized resource management
  • Simplified Annotation to 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) {
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
set behindOpacity(value) {
set behindOpacity(value) {
if (this._behindOpacity === value) {
return;
}

Copilot uses AI. Check for mistakes.
Comment on lines 846 to 851
if (this._camera === null) {
const cameraComponent = this.app.root.findComponent('camera');
if (cameraComponent) {
this._camera = cameraComponent.entity;
}
}
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
// Clean up on destroy
this.once('destroy', () => {
// Unregister all annotations
for (const annotation of this._annotationResources.keys()) {
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
for (const annotation of this._annotationResources.keys()) {
for (const annotation of Array.from(this._annotationResources.keys())) {

Copilot uses AI. Check for mistakes.
}
// Remove layers from scene
const { layers } = this.app.scene;
this._layers.forEach(layer => layers.remove(layer));
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
this._layers.forEach(layer => layers.remove(layer));
this._layers.forEach(layer => {
if (layers.has(layer)) {
layers.remove(layer);
}
});

Copilot uses AI. Check for mistakes.
Comment on lines +1042 to +1044
postInitialize() {
// Notify any listeners that this annotation has been created
this.app.fire('annotation:add', this);
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +840 to +842
this._layers = [
createLayer('HotspotBase', false),
createLayer('HotspotOverlay', true)
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 496 to 503
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');
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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');
}

Copilot uses AI. Check for mistakes.
willeastcott and others added 2 commits December 28, 2025 13:36
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@willeastcott willeastcott merged commit afcb730 into main Dec 29, 2025
7 checks passed
@willeastcott willeastcott deleted the better-annotations branch December 29, 2025 13:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants