
Thatopen Syntax Components
- 5 installs
- 17 repo stars
- Updated July 8, 2026
- openaec-foundation/thatopen-claude-skill-package
Helps with ai & agent building tasks.
About
thatopen-syntax-components is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- thatopen-syntax-components
- AI & Agent Building
- AI-coding skill
Thatopen Syntax Components by the numbers
- 5 all-time installs (skills.sh)
- Ranked #13,046 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 21, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/thatopen-claude-skill-package --skill thatopen-syntax-componentsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 5 |
|---|---|
| repo stars | ★ 17 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/thatopen-claude-skill-package ↗ |
What it does
Helps with ai & agent building tasks.
Files
ThatOpen Component Syntax
Purpose
This skill covers the syntax and usage patterns for ThatOpen's component system. It provides exact method signatures, lifecycle interface contracts, event system API, and reactive collection patterns. For architectural overview and design rationale, see thatopen-core-architecture.
Version: @thatopen/components 3.3.x
The components.get() Pattern
ALWAYS obtain component instances through the singleton registry:
import * as OBC from "@thatopen/components";
const components = new OBC.Components();
// ALWAYS use get() — lazy singleton creation
const worlds = components.get(OBC.Worlds);
const ifcLoader = components.get(OBC.IfcLoader);
const fragments = components.get(OBC.FragmentsManager);How it works: 1. First call: get() invokes new ComponentClass(components) internally. 2. The constructor calls components.add(ComponentClass.uuid, this) to register the instance. 3. Subsequent calls: get() returns the cached instance from components.list.
NEVER call new OBC.Worlds(components) or any component constructor directly. This bypasses the registry and creates untracked duplicate instances.
Components Container API
class Components implements Disposable {
readonly list: DataMap<string, Component>;
enabled: boolean;
onDisposed: Event<void>;
onInit: Event<undefined>;
static release: string;
get<U extends Component>(Ctor: new (c: Components) => U): U;
add(uuid: string, instance: Component): void;
init(): void;
dispose(): void;
}init()
ALWAYS call components.init() after setting up your world. This starts the requestAnimationFrame loop using THREE.Clock for delta time. Without it, nothing renders and no Updateable components receive updates.
dispose()
ALWAYS call components.dispose() on cleanup. This iterates all registered components and disposes each one. FragmentsManager is ALWAYS disposed last to prevent dangling references. After dispose(), NEVER use any component references — they are invalidated.
Component Base Class
Every ThatOpen component extends this hierarchy:
abstract class Base {
constructor(public components: Components) {}
// Runtime interface detection (duck-typing, NOT instanceof)
isDisposeable(): this is Disposable;
isUpdateable(): this is Updateable;
isConfigurable(): this is Configurable<any, any>;
isResizeable(): this is Resizeable;
isHideable(): this is Hideable;
isSerializable(): this is Serializable<any>;
}
abstract class Component extends Base {
static readonly uuid: string; // MUST be unique across all components
abstract enabled: boolean; // MUST be implemented by every component
}Creating Custom Components
Every custom component MUST follow this exact pattern:
import * as OBC from "@thatopen/components";
class MyTool extends OBC.Component implements OBC.Disposable {
// 1. Static UUID — MUST be unique, use a real UUID generator
static readonly uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" as const;
// 2. Enabled flag — required by Component
enabled = true;
// 3. Disposable event — required by Disposable interface
onDisposed = new OBC.Event<void>();
constructor(components: OBC.Components) {
super(components);
// 4. Self-register — ALWAYS call add() in the constructor
components.add(MyTool.uuid, this);
}
dispose(): void {
this.enabled = false;
// 5. Trigger disposal event, then reset to clear handlers
this.onDisposed.trigger();
this.onDisposed.reset();
}
}
// 6. Access via get() — NEVER via new
const myTool = components.get(MyTool);Checklist for custom components:
- Static
uuidwithas constassertion enabledproperty initialized- Constructor calls
super(components)thencomponents.add() - Implements
Disposableif it holds any resources dispose()setsenabled = false, triggersonDisposed, resets events
Lifecycle Interfaces
Components opt into behaviors by implementing interfaces. This is a mixin pattern — no deep inheritance hierarchies.
Disposable
ALWAYS implement if the component holds resources (event handlers, GPU buffers, DOM references, subscriptions).
interface Disposable {
dispose(): void;
onDisposed: Event<void>;
}Implementation pattern:
dispose(): void {
this.enabled = false;
// Clean up resources: remove DOM elements, clear maps, etc.
this.someMap.clear();
this.someDomElement?.remove();
// Trigger event, then reset ALL events owned by this component
this.onDisposed.trigger();
this.onDisposed.reset();
this.onSomeEvent.reset();
}Updateable
Implement when the component needs per-frame updates. The Components animation loop calls update(delta) on EVERY enabled Updateable component each frame.
interface Updateable {
update(delta?: number): void;
onBeforeUpdate: Event<any>;
onAfterUpdate: Event<any>;
}Implementation pattern:
class AnimationController extends OBC.Component
implements OBC.Updateable, OBC.Disposable {
static readonly uuid = "..." as const;
enabled = true;
onBeforeUpdate = new OBC.Event<void>();
onAfterUpdate = new OBC.Event<void>();
onDisposed = new OBC.Event<void>();
constructor(components: OBC.Components) {
super(components);
components.add(AnimationController.uuid, this);
}
// Called automatically every frame when enabled=true
update(delta?: number): void {
this.onBeforeUpdate.trigger();
// Per-frame logic using delta (seconds since last frame)
this.onAfterUpdate.trigger();
}
dispose(): void {
this.enabled = false;
this.onDisposed.trigger();
this.onDisposed.reset();
this.onBeforeUpdate.reset();
this.onAfterUpdate.reset();
}
}Configurable
Implement when the component requires deferred or async initialization. ALWAYS call setup() before using a Configurable component.
interface Configurable<TConfig, TPartialConfig> {
setup(config?: TPartialConfig): void;
config: TConfig;
isSetup: boolean;
onSetup: Event<any>;
}Implementation pattern:
class MyConfigurable extends OBC.Component
implements OBC.Configurable<MyConfig, Partial<MyConfig>>, OBC.Disposable {
static readonly uuid = "..." as const;
enabled = true;
isSetup = false;
config: MyConfig = { /* defaults */ };
onSetup = new OBC.Event<MyConfigurable>();
onDisposed = new OBC.Event<void>();
constructor(components: OBC.Components) {
super(components);
components.add(MyConfigurable.uuid, this);
}
setup(config?: Partial<MyConfig>): void {
if (config) {
this.config = { ...this.config, ...config };
}
// Perform initialization...
this.isSetup = true;
this.onSetup.trigger(this);
}
dispose(): void {
this.enabled = false;
this.isSetup = false;
this.onDisposed.trigger();
this.onDisposed.reset();
this.onSetup.reset();
}
}ALWAYS check isSetup before calling methods that depend on configuration:
const myComp = components.get(MyConfigurable);
if (!myComp.isSetup) {
await myComp.setup({ /* config */ });
}Other Interfaces
| Interface | Contract |
|---|---|
Resizeable | resize(size?), getSize(), onResize: Event |
Hideable | visible: boolean |
Createable | create(), delete(), endCreation(), cancelCreation() |
Serializable | import(data), export(): data |
Event\<T\> System
ThatOpen uses a custom pub/sub event class throughout the entire API.
class Event<T> {
enabled: boolean; // default: true
add(handler: (data: T) => void): void; // subscribe
remove(handler: (data: T) => void): void; // unsubscribe
trigger(data?: T): void; // fire event
reset(): void; // remove ALL handlers
}Usage Rules
1. ALWAYS store handler references when you need to remove them later. Anonymous arrow functions cannot be removed.
2. ALWAYS call `reset()` on all owned events in your dispose() method to prevent memory leaks.
3. Use `enabled = false` to temporarily suppress an event without removing handlers. Set back to true to resume.
4. NEVER assume event ordering — handlers fire in registration order, but do not depend on this for correctness.
Quick Reference
// Store reference — NEVER use anonymous functions if you need to remove
const onLoaded = (model: OBC.FragmentsModel) => { /* ... */ };
fragments.onFragmentsLoaded.add(onLoaded); // subscribe
fragments.onFragmentsLoaded.remove(onLoaded); // unsubscribe
fragments.onFragmentsLoaded.enabled = false; // suppress temporarily
fragments.onFragmentsLoaded.enabled = true; // re-enable
fragments.onFragmentsLoaded.reset(); // remove ALL handlersSee references/examples.md for detailed patterns.
DataMap\<K, V\>: Reactive Map
Extends the standard Map with event hooks. Used throughout ThatOpen for observable collections (e.g., Components.list, Worlds.list, Classifier.list).
class DataMap<K, V> extends Map<K, V> {
onItemSet: Event<{ key: K; value: V }>;
onItemUpdated: Event<{ key: K; value: V }>;
onItemDeleted: Event<{ key: K }>;
onCleared: Event<void>;
}All standard Map methods work (get, set, delete, has, forEach, entries, keys, values, size). The events fire automatically when the corresponding operations occur.
Reacting to DataMap Changes
const worlds = components.get(OBC.Worlds);
// React when a new world is added
worlds.list.onItemSet.add(({ key, value }) => {
console.log(`World added: ${key}`);
});
// React when a world is updated
worlds.list.onItemUpdated.add(({ key, value }) => {
console.log(`World updated: ${key}`);
});
// React when a world is removed
worlds.list.onItemDeleted.add(({ key }) => {
console.log(`World removed: ${key}`);
});
// React when all worlds are cleared
worlds.list.onCleared.add(() => {
console.log("All worlds cleared");
});DataSet\<T\>: Reactive Set
Extends the standard Set with event hooks. Used for collections like world.meshes, measurement lists, and style sets.
class DataSet<T> extends Set<T> {
onItemAdded: Event<T>;
onItemDeleted: Event<T>;
onCleared: Event<void>;
}All standard Set methods work (add, delete, has, forEach, entries, values, size). Events fire automatically.
Reacting to DataSet Changes
// Track when meshes are added to a world
world.meshes.onItemAdded.add((mesh) => {
console.log("Mesh added:", mesh.name);
});
world.meshes.onItemDeleted.add((mesh) => {
console.log("Mesh removed:", mesh.name);
});Components Lifecycle Flow
new Components()
|
v
components.get(X) ──> new X(components) ──> components.add(uuid, instance)
| |
v v
[setup() if Configurable] registered in components.list
|
v
components.init() ──> starts requestAnimationFrame loop
| |
v v
update(delta) called on ALL enabled Updateable components each frame
|
v
components.dispose() ──> dispose() on ALL components
FragmentsManager disposed LASTRuntime Interface Detection
Use the is*() methods on Base for runtime type checking:
const component = components.list.get(someUuid);
if (component?.isDisposeable()) {
component.dispose(); // TypeScript narrows type to Disposable
}
if (component?.isUpdateable()) {
component.update(0.016); // TypeScript narrows type to Updateable
}
if (component?.isConfigurable()) {
if (!component.isSetup) {
component.setup();
}
}These use duck-typing (checking for method existence), NOT instanceof.
Critical Rules
1. ALWAYS use components.get(ComponentClass) to obtain instances. NEVER use new ComponentClass(components) directly. 2. ALWAYS implement Disposable if your component holds any resources. 3. ALWAYS call components.add(uuid, this) in your component constructor. 4. ALWAYS define a static uuid with as const on custom components. 5. ALWAYS call reset() on all owned events in dispose(). 6. ALWAYS store event handler references for later removal. 7. ALWAYS call setup() on Configurable components before using them. 8. ALWAYS call components.init() after world setup. 9. NEVER use components after components.dispose() has been called. 10. NEVER use anonymous functions as event handlers if you need to remove them later.
Reference Files
- references/methods.md — Complete API signatures
for Event, DataMap, DataSet, lifecycle interfaces, and Base/Component
- references/examples.md — Custom component,
lifecycle implementation, and event patterns
- references/anti-patterns.md — Direct
instantiation, missing disposal, event leaks, and other common mistakes
Source Verification
All API signatures verified against:
- GitHub:
ThatOpen/engine_componentsmain branch (packages/core/src/) - npm:
@thatopen/components@3.3.3 - Research:
docs/research/vooronderzoek-thatopen.md
Anti-Patterns — Component Syntax
Version: @thatopen/components 3.3.x
Common mistakes when using ThatOpen components, events, and lifecycle interfaces — and what to do instead.
---
AP-001: Direct Component Instantiation
WRONG:
// NEVER do this — bypasses the singleton registry
const worlds = new OBC.Worlds(components);
const ifcLoader = new OBC.IfcLoader(components);
const myTool = new MyTool(components);CORRECT:
// ALWAYS use components.get()
const worlds = components.get(OBC.Worlds);
const ifcLoader = components.get(OBC.IfcLoader);
const myTool = components.get(MyTool);Why: components.get() implements lazy singleton creation. Direct instantiation creates duplicate instances not tracked by the container, leading to lifecycle bugs, duplicate state, and memory leaks.
---
AP-002: Missing Disposal in Custom Components
WRONG:
class BadComponent extends OBC.Component implements OBC.Disposable {
static readonly uuid = "..." as const;
enabled = true;
onDisposed = new OBC.Event<void>();
onCustomEvent = new OBC.Event<string>();
private _data = new Map<string, object>();
constructor(components: OBC.Components) {
super(components);
components.add(BadComponent.uuid, this);
}
dispose(): void {
// Missing: does not set enabled=false
// Missing: does not clear _data
// Missing: does not reset onCustomEvent
this.onDisposed.trigger();
this.onDisposed.reset();
}
}CORRECT:
class GoodComponent extends OBC.Component implements OBC.Disposable {
static readonly uuid = "..." as const;
enabled = true;
readonly onDisposed = new OBC.Event<void>();
readonly onCustomEvent = new OBC.Event<string>();
private _data = new Map<string, object>();
constructor(components: OBC.Components) {
super(components);
components.add(GoodComponent.uuid, this);
}
dispose(): void {
this.enabled = false; // Prevent further use
this._data.clear(); // Release data references
this.onDisposed.trigger(); // Notify listeners
this.onDisposed.reset(); // Clear all handlers
this.onCustomEvent.reset(); // Clear ALL owned events
}
}Why: Failing to reset all events causes handler accumulation. Failing to clear internal data structures causes memory leaks. Failing to set enabled = false allows the component to continue processing after disposal.
---
AP-003: Anonymous Event Handlers (Cannot Be Removed)
WRONG:
function setupHandler() {
const fragments = components.get(OBC.FragmentsManager);
// Anonymous arrow function — cannot be removed later
fragments.onFragmentsLoaded.add((model) => {
processModel(model);
});
}
// Called multiple times → duplicate handlers accumulate
setupHandler();
setupHandler();
setupHandler();
// processModel now fires 3 times per event!CORRECT:
// Store handler reference at a stable scope
const onModelLoaded = (model: OBC.FragmentsModel) => {
processModel(model);
};
function setupHandler() {
const fragments = components.get(OBC.FragmentsManager);
fragments.onFragmentsLoaded.add(onModelLoaded);
}
function teardownHandler() {
const fragments = components.get(OBC.FragmentsManager);
fragments.onFragmentsLoaded.remove(onModelLoaded);
}Why: Event.remove() compares function references. Anonymous functions create a new reference each time, so they can never be matched for removal. This causes handler accumulation, performance degradation, and duplicate side effects.
---
AP-004: Forgetting to Reset Events on Dispose
WRONG:
dispose(): void {
this.enabled = false;
this.onDisposed.trigger();
this.onDisposed.reset();
// Forgot to reset onDataChanged, onError, onProgress...
}CORRECT:
dispose(): void {
this.enabled = false;
this.onDisposed.trigger();
// Reset EVERY event this component owns
this.onDisposed.reset();
this.onDataChanged.reset();
this.onError.reset();
this.onProgress.reset();
}Why: Unreset events retain references to handler functions, which in turn retain references to their closures. This prevents garbage collection of potentially large object graphs. ALWAYS reset every event in dispose().
---
AP-005: Missing components.add() in Constructor
WRONG:
class BrokenComponent extends OBC.Component {
static readonly uuid = "..." as const;
enabled = true;
constructor(components: OBC.Components) {
super(components);
// Missing: components.add(BrokenComponent.uuid, this)
}
}
// components.get(BrokenComponent) creates a new instance every time
// because it is never registered in the listCORRECT:
class WorkingComponent extends OBC.Component {
static readonly uuid = "..." as const;
enabled = true;
constructor(components: OBC.Components) {
super(components);
components.add(WorkingComponent.uuid, this);
}
}Why: Without components.add(), the component is never registered in components.list. The get() method will create a new instance on every call, breaking the singleton pattern and causing duplicate instances.
---
AP-006: Missing Static UUID
WRONG:
class NoUuidComponent extends OBC.Component {
// Missing static uuid!
enabled = true;
constructor(components: OBC.Components) {
super(components);
// components.add(???, this) — no uuid to register with
}
}CORRECT:
class ProperComponent extends OBC.Component {
static readonly uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" as const;
enabled = true;
constructor(components: OBC.Components) {
super(components);
components.add(ProperComponent.uuid, this);
}
}Why: The static uuid is the registry key. Without it, the component cannot be registered or retrieved. ALWAYS use a real UUID (not a random string) and add as const for type narrowing.
---
AP-007: Using Configurable Component Before setup()
WRONG:
const ifcLoader = components.get(OBC.IfcLoader);
// Forgot to call setup() — WASM not initialized
const model = await ifcLoader.load(data, true, "test"); // FailsCORRECT:
const ifcLoader = components.get(OBC.IfcLoader);
await ifcLoader.setup(); // ALWAYS call setup() first
const model = await ifcLoader.load(data, true, "test");ALWAYS check `isSetup` if unsure:
const ifcLoader = components.get(OBC.IfcLoader);
if (!ifcLoader.isSetup) {
await ifcLoader.setup();
}Why: Configurable components defer their initialization to setup(). Internal state (WASM engine, shaders, workers) is not ready until setup completes. Calling methods before setup leads to silent failures or errors.
---
AP-008: Not Implementing Disposable for Resource-Holding Components
WRONG:
class LeakyComponent extends OBC.Component {
static readonly uuid = "..." as const;
enabled = true;
private _canvas: HTMLCanvasElement;
private _worker: Worker;
private _handlers = new Map<string, Function>();
constructor(components: OBC.Components) {
super(components);
components.add(LeakyComponent.uuid, this);
this._canvas = document.createElement("canvas");
this._worker = new Worker("/worker.js");
}
// No dispose() — canvas, worker, and handlers are NEVER cleaned up
}CORRECT:
class CleanComponent extends OBC.Component implements OBC.Disposable {
static readonly uuid = "..." as const;
enabled = true;
readonly onDisposed = new OBC.Event<void>();
private _canvas: HTMLCanvasElement;
private _worker: Worker;
constructor(components: OBC.Components) {
super(components);
components.add(CleanComponent.uuid, this);
this._canvas = document.createElement("canvas");
this._worker = new Worker("/worker.js");
}
dispose(): void {
this.enabled = false;
this._canvas.remove();
this._worker.terminate();
this.onDisposed.trigger();
this.onDisposed.reset();
}
}Why: ALWAYS implement Disposable if your component creates DOM elements, Web Workers, WebGL resources, event subscriptions, or any other resources that need explicit cleanup. Without disposal, components.dispose() cannot clean up your component, causing memory leaks.
---
AP-009: Subscribing to DataMap/DataSet Without Cleanup
WRONG:
function watchWorlds() {
const worlds = components.get(OBC.Worlds);
// Anonymous handlers on reactive collections — never cleaned up
worlds.list.onItemSet.add(({ key }) => {
console.log("World added:", key);
});
}CORRECT:
const onWorldAdded = ({ key, value }: { key: string; value: any }) => {
console.log("World added:", key);
};
function watchWorlds() {
const worlds = components.get(OBC.Worlds);
worlds.list.onItemSet.add(onWorldAdded);
}
function unwatchWorlds() {
const worlds = components.get(OBC.Worlds);
worlds.list.onItemSet.remove(onWorldAdded);
}Why: DataMap and DataSet events follow the same rules as regular events. Anonymous handlers cannot be removed. ALWAYS store references and clean up when no longer needed.
---
AP-010: Using Components After dispose()
WRONG:
components.dispose();
// NEVER use components or managed instances after this
const worlds = components.get(OBC.Worlds); // Undefined behaviorCORRECT:
components.dispose();
// Null out the reference — create new Components if needed
let componentsRef: OBC.Components | null = components;
componentsRef.dispose();
componentsRef = null;Why: After dispose(), all internal state is cleaned up. Accessing disposed components leads to undefined behavior, null reference errors, and potential use-after-free bugs in WASM memory.
Examples — Component Syntax
Version: @thatopen/components 3.3.x
---
1. Custom Component with Disposable
The minimal custom component pattern:
import * as OBC from "@thatopen/components";
class SelectionTracker extends OBC.Component implements OBC.Disposable {
static readonly uuid = "c3d4e5f6-a7b8-9012-cdef-123456789012" as const;
enabled = true;
readonly onDisposed = new OBC.Event<void>();
readonly onSelectionChanged = new OBC.Event<Set<number>>();
private _selected = new Set<number>();
constructor(components: OBC.Components) {
super(components);
components.add(SelectionTracker.uuid, this);
}
select(ids: number[]): void {
ids.forEach((id) => this._selected.add(id));
this.onSelectionChanged.trigger(this._selected);
}
clearSelection(): void {
this._selected.clear();
this.onSelectionChanged.trigger(this._selected);
}
dispose(): void {
this.enabled = false;
this._selected.clear();
this.onDisposed.trigger();
this.onDisposed.reset();
this.onSelectionChanged.reset();
}
}
// Usage — ALWAYS via get()
const tracker = components.get(SelectionTracker);
tracker.select([101, 102, 103]);2. Custom Component with Updateable
Per-frame animation component:
import * as OBC from "@thatopen/components";
import * as THREE from "three";
class ModelRotator extends OBC.Component
implements OBC.Updateable, OBC.Disposable {
static readonly uuid = "d4e5f6a7-b8c9-0123-defa-234567890123" as const;
enabled = true;
speed = 0.5; // radians per second
readonly onBeforeUpdate = new OBC.Event<void>();
readonly onAfterUpdate = new OBC.Event<void>();
readonly onDisposed = new OBC.Event<void>();
private _target: THREE.Object3D | null = null;
constructor(components: OBC.Components) {
super(components);
components.add(ModelRotator.uuid, this);
}
setTarget(object: THREE.Object3D): void {
this._target = object;
}
// Called automatically by Components every frame when enabled=true
update(delta?: number): void {
if (!this._target || !delta) return;
this.onBeforeUpdate.trigger();
this._target.rotation.y += this.speed * delta;
this.onAfterUpdate.trigger();
}
dispose(): void {
this.enabled = false;
this._target = null;
this.onDisposed.trigger();
this.onDisposed.reset();
this.onBeforeUpdate.reset();
this.onAfterUpdate.reset();
}
}
// Usage
const rotator = components.get(ModelRotator);
rotator.setTarget(someObject);
rotator.speed = 1.0;
// update() is called automatically each frame — no manual loop needed3. Custom Component with Configurable
Component with deferred initialization:
import * as OBC from "@thatopen/components";
interface AnalyzerConfig {
maxElements: number;
includeGeometry: boolean;
workerUrl: string;
}
class ModelAnalyzer extends OBC.Component
implements OBC.Configurable<AnalyzerConfig, Partial<AnalyzerConfig>>,
OBC.Disposable {
static readonly uuid = "e5f6a7b8-c9d0-1234-efab-345678901234" as const;
enabled = true;
isSetup = false;
config: AnalyzerConfig = {
maxElements: 10000,
includeGeometry: false,
workerUrl: "/workers/analyzer.js",
};
readonly onSetup = new OBC.Event<ModelAnalyzer>();
readonly onDisposed = new OBC.Event<void>();
private _worker: Worker | null = null;
constructor(components: OBC.Components) {
super(components);
components.add(ModelAnalyzer.uuid, this);
}
setup(config?: Partial<AnalyzerConfig>): void {
if (config) {
this.config = { ...this.config, ...config };
}
this._worker = new Worker(this.config.workerUrl);
this.isSetup = true;
this.onSetup.trigger(this);
}
analyze(): void {
if (!this.isSetup) {
throw new Error("Call setup() before analyze()");
}
// Use this._worker...
}
dispose(): void {
this.enabled = false;
this.isSetup = false;
this._worker?.terminate();
this._worker = null;
this.onDisposed.trigger();
this.onDisposed.reset();
this.onSetup.reset();
}
}
// Usage — ALWAYS call setup() before using
const analyzer = components.get(ModelAnalyzer);
analyzer.setup({ maxElements: 50000 });
console.log(analyzer.isSetup); // true
analyzer.analyze();4. Event System Patterns
Basic Subscribe / Unsubscribe
import * as OBC from "@thatopen/components";
const fragments = components.get(OBC.FragmentsManager);
// ALWAYS store handler reference for later removal
const onModelLoaded = (model: OBC.FragmentsModel) => {
console.log("Loaded:", model.modelId);
};
// Subscribe
fragments.onFragmentsLoaded.add(onModelLoaded);
// Later: unsubscribe (MUST use same reference)
fragments.onFragmentsLoaded.remove(onModelLoaded);Custom Event with Typed Data
import * as OBC from "@thatopen/components";
interface ClashResult {
modelA: string;
modelB: string;
intersections: number;
}
// Create a typed event
const onClashDetected = new OBC.Event<ClashResult>();
// Subscribe with full type safety
onClashDetected.add((result) => {
// result is typed as ClashResult
console.log(`${result.intersections} clashes found`);
});
// Trigger with typed data
onClashDetected.trigger({
modelA: "model-1",
modelB: "model-2",
intersections: 42,
});
// Cleanup
onClashDetected.reset();Temporarily Suppressing Events
const fragments = components.get(OBC.FragmentsManager);
// Disable — handlers stay registered but do not fire
fragments.onFragmentsLoaded.enabled = false;
// Load models silently (no event fires)
await ifcLoader.load(data, true, "silent-load");
// Re-enable
fragments.onFragmentsLoaded.enabled = true;One-Shot Event Pattern
const fragments = components.get(OBC.FragmentsManager);
// Self-removing handler for a one-time reaction
const onFirstLoad = (model: OBC.FragmentsModel) => {
console.log("First model loaded:", model.modelId);
fragments.onFragmentsLoaded.remove(onFirstLoad);
};
fragments.onFragmentsLoaded.add(onFirstLoad);5. DataMap Reactive Patterns
Watching Component Registry
import * as OBC from "@thatopen/components";
// components.list is a DataMap<string, Component>
components.list.onItemSet.add(({ key, value }) => {
console.log(`Component registered: ${key}`);
});Watching World Lifecycle
const worlds = components.get(OBC.Worlds);
// React to new worlds
worlds.list.onItemSet.add(({ key, value }) => {
console.log(`World created: ${key}`);
// Perform setup for the new world
});
// React to world removal
worlds.list.onItemDeleted.add(({ key }) => {
console.log(`World disposed: ${key}`);
// Clean up world-specific resources
});
// React to all worlds being cleared
worlds.list.onCleared.add(() => {
console.log("All worlds cleared");
});Iterating a DataMap
const worlds = components.get(OBC.Worlds);
// Standard Map iteration — works exactly like Map
for (const [id, world] of worlds.list) {
console.log(`World ${id}:`, world);
}
// Check size
console.log(`Total worlds: ${worlds.list.size}`);
// Check existence
if (worlds.list.has("some-uuid")) {
const world = worlds.list.get("some-uuid");
}6. DataSet Reactive Patterns
Tracking Meshes in a World
// world.meshes is a DataSet<THREE.Mesh> (or Set — depends on version)
// Use DataSet events when available
const meshSet = new OBC.DataSet<THREE.Mesh>();
meshSet.onItemAdded.add((mesh) => {
console.log("Mesh added:", mesh.name);
});
meshSet.onItemDeleted.add((mesh) => {
console.log("Mesh removed:", mesh.name);
});
// Standard Set operations trigger events
meshSet.add(someMesh); // fires onItemAdded
meshSet.delete(someMesh); // fires onItemDeleted
meshSet.clear(); // fires onCleared7. Runtime Interface Detection
import * as OBC from "@thatopen/components";
function inspectComponent(component: OBC.Component): void {
console.log("Disposable:", component.isDisposeable());
console.log("Updateable:", component.isUpdateable());
console.log("Configurable:", component.isConfigurable());
console.log("Resizeable:", component.isResizeable());
console.log("Hideable:", component.isHideable());
console.log("Serializable:", component.isSerializable());
}
// Conditional disposal with type narrowing
function safeDispose(component: OBC.Component): void {
if (component.isDisposeable()) {
component.dispose(); // TypeScript knows this is Disposable
}
}
// Conditional setup with type narrowing
function ensureSetup(component: OBC.Component): void {
if (component.isConfigurable()) {
if (!component.isSetup) {
component.setup();
}
}
}8. Full Custom Component with Multiple Interfaces
import * as OBC from "@thatopen/components";
interface ToolConfig {
sensitivity: number;
snapToGrid: boolean;
}
class MeasurementTool extends OBC.Component
implements OBC.Disposable,
OBC.Updateable,
OBC.Configurable<ToolConfig, Partial<ToolConfig>> {
static readonly uuid = "f6a7b8c9-d0e1-2345-fabc-456789012345" as const;
enabled = true;
isSetup = false;
config: ToolConfig = {
sensitivity: 1.0,
snapToGrid: true,
};
readonly onDisposed = new OBC.Event<void>();
readonly onBeforeUpdate = new OBC.Event<void>();
readonly onAfterUpdate = new OBC.Event<void>();
readonly onSetup = new OBC.Event<MeasurementTool>();
readonly onMeasured = new OBC.Event<number>();
constructor(components: OBC.Components) {
super(components);
components.add(MeasurementTool.uuid, this);
}
setup(config?: Partial<ToolConfig>): void {
if (config) {
this.config = { ...this.config, ...config };
}
this.isSetup = true;
this.onSetup.trigger(this);
}
update(delta?: number): void {
if (!this.isSetup) return;
this.onBeforeUpdate.trigger();
// Per-frame measurement update logic
this.onAfterUpdate.trigger();
}
dispose(): void {
this.enabled = false;
this.isSetup = false;
this.onDisposed.trigger();
this.onDisposed.reset();
this.onBeforeUpdate.reset();
this.onAfterUpdate.reset();
this.onSetup.reset();
this.onMeasured.reset();
}
}API Signatures — Component Syntax
Version: @thatopen/components 3.3.x
---
Components Container
class Components implements Disposable {
readonly list: DataMap<string, Component>;
enabled: boolean;
onDisposed: Event<void>;
onInit: Event<undefined>;
static release: string;
/**
* Lazy singleton getter. Creates the component on first call,
* returns the cached instance on subsequent calls.
* The component's constructor is invoked with this Components instance.
*/
get<U extends Component>(Ctor: new (c: Components) => U): U;
/**
* Registers a component instance by UUID.
* Called internally by component constructors — NEVER call manually
* outside of a component constructor.
*/
add(uuid: string, instance: Component): void;
/**
* Starts the requestAnimationFrame loop.
* Uses THREE.Clock for delta time calculation.
* Calls update(delta) on all enabled Updateable components each frame.
* Triggers onInit event.
*/
init(): void;
/**
* Disposes all registered components.
* FragmentsManager is ALWAYS disposed last.
* Triggers onDisposed event.
* After this call, all references are invalidated.
*/
dispose(): void;
}Base Class
abstract class Base {
constructor(public components: Components) {}
/** Runtime check: does this implement Disposable? (duck-typing) */
isDisposeable(): this is Disposable;
/** Runtime check: does this implement Updateable? (duck-typing) */
isUpdateable(): this is Updateable;
/** Runtime check: does this implement Configurable? (duck-typing) */
isConfigurable(): this is Configurable<any, any>;
/** Runtime check: does this implement Resizeable? (duck-typing) */
isResizeable(): this is Resizeable;
/** Runtime check: does this implement Hideable? (duck-typing) */
isHideable(): this is Hideable;
/** Runtime check: does this implement Serializable? (duck-typing) */
isSerializable(): this is Serializable<any>;
}Component Class
abstract class Component extends Base {
/** MUST be unique across all components. Used as registry key. */
static readonly uuid: string;
/** Whether this component is active. MUST be implemented. */
abstract enabled: boolean;
}Event\<T\>
class Event<T> {
/**
* Whether the event is active. When false, trigger() is a no-op.
* Handlers remain registered but do not fire.
* Default: true
*/
enabled: boolean;
/**
* Subscribe a handler function.
* The handler will be called with the event data when triggered.
* Duplicate handlers are allowed — the same function can be added
* multiple times and will fire multiple times.
*/
add(handler: (data: T) => void): void;
/**
* Unsubscribe a handler function.
* Requires the SAME function reference that was passed to add().
* If the handler was added multiple times, only the first match
* is removed.
*/
remove(handler: (data: T) => void): void;
/**
* Fire the event, calling all registered handlers with the provided data.
* If enabled is false, this is a no-op.
* Handlers are called synchronously in registration order.
*/
trigger(data?: T): void;
/**
* Remove ALL registered handlers.
* ALWAYS call this in dispose() to prevent memory leaks.
*/
reset(): void;
}Disposable Interface
interface Disposable {
/** Clean up all resources. Set enabled=false, trigger onDisposed. */
dispose(): void;
/** Fired when dispose() is called. Reset this in dispose(). */
onDisposed: Event<void>;
}Updateable Interface
interface Updateable {
/**
* Called by the Components animation loop every frame.
* Only called when enabled=true.
* @param delta — seconds since the last frame (from THREE.Clock)
*/
update(delta?: number): void;
/** Fired at the start of update(), before logic runs. */
onBeforeUpdate: Event<any>;
/** Fired at the end of update(), after logic completes. */
onAfterUpdate: Event<any>;
}Configurable Interface
interface Configurable<TConfig, TPartialConfig> {
/**
* Initialize or reconfigure the component.
* ALWAYS call before using the component's main functionality.
* @param config — partial config merged with defaults
*/
setup(config?: TPartialConfig): void;
/** Current configuration. Read-only after setup. */
config: TConfig;
/** Whether setup() has been called. Check before using the component. */
isSetup: boolean;
/** Fired when setup() completes successfully. */
onSetup: Event<any>;
}Resizeable Interface
interface Resizeable {
/** Resize the component (e.g., renderer viewport). */
resize(size?: THREE.Vector2): void;
/** Get the current size. */
getSize(): THREE.Vector2;
/** Fired when resize occurs. */
onResize: Event<THREE.Vector2>;
}Hideable Interface
interface Hideable {
/** Whether the component is visible. */
visible: boolean;
}Createable Interface
interface Createable {
/** Create a new item (e.g., measurement, clipping plane). */
create(data?: any): void;
/** Delete an item. */
delete(data?: any): void;
/** Finish an interactive creation session. */
endCreation(data?: any): void;
/** Cancel an interactive creation session. */
cancelCreation(data?: any): void;
}Serializable Interface
interface Serializable<T> {
/** Import data into the component. */
import(data: T): void;
/** Export the component's state as data. */
export(): T;
}DataMap\<K, V\>
class DataMap<K, V> extends Map<K, V> {
/**
* Fired when a NEW key-value pair is added via set().
* Payload: the key and value that were added.
*/
onItemSet: Event<{ key: K; value: V }>;
/**
* Fired when an EXISTING key's value is updated via set().
* Payload: the key and new value.
*/
onItemUpdated: Event<{ key: K; value: V }>;
/**
* Fired when a key-value pair is removed via delete().
* Payload: the key that was removed.
*/
onItemDeleted: Event<{ key: K }>;
/**
* Fired when clear() is called, removing all entries.
*/
onCleared: Event<void>;
}
// Inherits all Map methods:
// get(key), set(key, value), delete(key), has(key), clear()
// forEach(), entries(), keys(), values(), sizeDataSet\<T\>
class DataSet<T> extends Set<T> {
/**
* Fired when a new item is added via add().
* Payload: the item that was added.
*/
onItemAdded: Event<T>;
/**
* Fired when an item is removed via delete().
* Payload: the item that was removed.
*/
onItemDeleted: Event<T>;
/**
* Fired when clear() is called, removing all items.
*/
onCleared: Event<void>;
}
// Inherits all Set methods:
// add(value), delete(value), has(value), clear()
// forEach(), entries(), keys(), values(), size