angular-three-theatre
Version:
TheatreJS for Angular Three
1,169 lines (1,150 loc) • 58.8 kB
JavaScript
import * as i0 from '@angular/core';
import { input, computed, effect, ChangeDetectionStrategy, Component, inject, DestroyRef, Directive, InjectionToken, booleanAttribute, model, TemplateRef, ViewContainerRef, untracked, linkedSignal, viewChild, afterNextRender, CUSTOM_ELEMENTS_SCHEMA, signal } from '@angular/core';
import { getProject, types, val, onChange } from '@theatre/core';
import { injectStore, resolveRef, resolveInstanceKey, extend, omit, pick } from 'angular-three';
import * as THREE from 'three';
import { Group } from 'three';
import { NgtsTransformControls } from 'angular-three-soba/gizmos';
import { mergeInputs } from 'ngxtension/inject-inputs';
/**
* Component that creates and manages a Theatre.js project.
*
* A Theatre.js project is the top-level container for all animation data.
* It contains sheets, which in turn contain sheet objects that hold animatable properties.
*
* @example
* ```html
* <theatre-project name="my-animation" [config]="{ state: savedState }">
* <ng-container sheet="scene1">
* <!-- sheet objects here -->
* </ng-container>
* </theatre-project>
* ```
*/
class TheatreProject {
constructor() {
/**
* The name of the Theatre.js project.
* This name is used to identify the project and must be unique.
*
* @default 'default-theatre-project'
*/
this.name = input('default-theatre-project', ...(ngDevMode ? [{ debugName: "name" }] : /* istanbul ignore next */ []));
/**
* Configuration options for the Theatre.js project.
* Can include saved state data for restoring animations.
*
* @default {}
*/
this.config = input({}, ...(ngDevMode ? [{ debugName: "config" }] : /* istanbul ignore next */ []));
/**
* Computed signal containing the Theatre.js project instance.
*/
this.project = computed(() => getProject(this.name(), this.config()), ...(ngDevMode ? [{ debugName: "project" }] : /* istanbul ignore next */ []));
/**
* Internal registry of sheets created within this project.
* Tracks sheet instances and their reference counts for cleanup.
*/
this.sheets = {};
effect(() => {
const project = this.project();
project.ready.then();
});
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: TheatreProject, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.9", type: TheatreProject, isStandalone: true, selector: "theatre-project", inputs: { name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: false, transformFunction: null }, config: { classPropertyName: "config", publicName: "config", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
<ng-content />
`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: TheatreProject, decorators: [{
type: Component,
args: [{
selector: 'theatre-project',
template: `
<ng-content />
`,
changeDetection: ChangeDetectionStrategy.OnPush,
}]
}], ctorParameters: () => [], propDecorators: { name: [{ type: i0.Input, args: [{ isSignal: true, alias: "name", required: false }] }], config: [{ type: i0.Input, args: [{ isSignal: true, alias: "config", required: false }] }] } });
/**
* Directive that creates and manages a Theatre.js sheet within a project.
*
* A sheet is a container for sheet objects and their animations. Multiple sheets
* can exist within a project, allowing you to organize animations into logical groups.
*
* The directive automatically handles reference counting and cleanup when the
* directive is destroyed.
*
* @example
* ```html
* <theatre-project>
* <ng-container sheet="mainScene">
* <!-- sheet objects here -->
* </ng-container>
* </theatre-project>
* ```
*
* @example
* ```html
* <!-- Using with template reference -->
* <ng-container sheet="mySheet" #sheetRef="sheet">
* {{ sheetRef.sheet().sequence.position }}
* </ng-container>
* ```
*/
let TheatreSheet$1 = class TheatreSheet {
constructor() {
/**
* The name of the sheet within the project.
* This name must be unique within the parent project.
*
* @default 'default-theatre-sheet'
*/
this.name = input('default-theatre-sheet', { ...(ngDevMode ? { debugName: "name" } : /* istanbul ignore next */ {}), transform: (value) => {
if (value === '')
return 'default-theatre-sheet';
return value;
},
alias: 'sheet' });
this.project = inject(TheatreProject);
/**
* Computed signal containing the Theatre.js sheet instance.
* Returns an existing sheet if one with the same name already exists,
* otherwise creates a new sheet.
*/
this.sheet = computed(() => {
const name = this.name();
const existing = this.project.sheets[name] || [];
if (existing[0]) {
existing[1]++;
return existing[0];
}
const sheet = this.project.project().sheet(name);
this.project.sheets[name] = [sheet, 1];
return sheet;
}, ...(ngDevMode ? [{ debugName: "sheet" }] : /* istanbul ignore next */ []));
inject(DestroyRef).onDestroy(() => {
const existing = this.project.sheets[this.name()];
if (!existing)
return;
if (existing[1] >= 1) {
existing[1]--;
}
if (existing[1] === 0) {
delete this.project.sheets[this.name()];
}
});
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: TheatreSheet, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: TheatreSheet, isStandalone: true, selector: "[sheet]", inputs: { name: { classPropertyName: "name", publicName: "sheet", isSignal: true, isRequired: false, transformFunction: null } }, exportAs: ["sheet"], ngImport: i0 }); }
};
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: TheatreSheet$1, decorators: [{
type: Directive,
args: [{ selector: '[sheet]', exportAs: 'sheet' }]
}], ctorParameters: () => [], propDecorators: { name: [{ type: i0.Input, args: [{ isSignal: true, alias: "sheet", required: false }] }] } });
/**
* Injection token for accessing the Theatre.js Studio instance.
*
* The studio provides a visual editor for creating and editing animations.
* This token is provided by the TheatreStudio directive and can be injected
* into child components.
*
* @example
* ```typescript
* import { THEATRE_STUDIO } from 'angular-three-theatre';
*
* @Component({...})
* export class MyComponent {
* private studio = inject(THEATRE_STUDIO, { optional: true });
*
* selectObject() {
* this.studio()?.setSelection([mySheetObject]);
* }
* }
* ```
*/
const THEATRE_STUDIO = new InjectionToken('Theatre Studio');
/**
* Directive that creates a Theatre.js sheet object for animating properties.
*
* A sheet object is a container for animatable properties within a sheet.
* This directive must be applied to an `ng-template` element and provides
* a structural context with access to the sheet object and its values.
*
* The template context includes:
* - `sheetObject`: The Theatre.js sheet object instance (read-only signal)
* - `values`: Current values of all animated properties (read-only signal)
* - `select()`: Method to select this object in Theatre.js Studio
* - `deselect()`: Method to deselect this object in Theatre.js Studio
*
* @example
* ```html
* <ng-template sheetObject="cube" [sheetObjectProps]="{ opacity: 1 }" let-values="values">
* <ngt-mesh>
* <ngt-mesh-standard-material [opacity]="values().opacity" />
* </ngt-mesh>
* </ng-template>
* ```
*
* @example
* ```html
* <!-- With selection support -->
* <ng-template
* sheetObject="cube"
* [(sheetObjectSelected)]="isSelected"
* let-select="select"
* let-deselect="deselect"
* >
* <ngt-mesh (click)="select()" />
* </ng-template>
* ```
*/
let TheatreSheetObject$1 = class TheatreSheetObject {
constructor() {
/**
* Unique key identifying this sheet object within its parent sheet.
* This key is used by Theatre.js to track and persist animation data.
*/
this.key = input.required({ ...(ngDevMode ? { debugName: "key" } : /* istanbul ignore next */ {}), alias: 'sheetObject' });
/**
* Initial properties and their default values for this sheet object.
* These properties will be animatable in Theatre.js Studio.
*
* @default {}
*/
this.props = input({}, { ...(ngDevMode ? { debugName: "props" } : /* istanbul ignore next */ {}), alias: 'sheetObjectProps' });
/**
* Whether to detach (remove) the sheet object when this directive is destroyed.
* When true, the animation data for this object will be removed from the sheet.
*
* @default false
*/
this.detach = input(false, { ...(ngDevMode ? { debugName: "detach" } : /* istanbul ignore next */ {}), transform: booleanAttribute, alias: 'sheetObjectDetach' });
/**
* Two-way bindable signal indicating whether this object is selected in Theatre.js Studio.
*
* @default false
*/
this.selected = model(false, { ...(ngDevMode ? { debugName: "selected" } : /* istanbul ignore next */ {}), alias: 'sheetObjectSelected' });
this.templateRef = inject(TemplateRef);
this.vcr = inject(ViewContainerRef);
this.sheet = inject(TheatreSheet$1);
this.studio = inject(THEATRE_STUDIO, { optional: true });
this.store = injectStore();
this.originalSheetObject = computed(() => {
const sheet = this.sheet.sheet();
return sheet.object(this.key(), untracked(this.props), { reconfigure: true });
}, ...(ngDevMode ? [{ debugName: "originalSheetObject" }] : /* istanbul ignore next */ []));
/**
* Signal containing the Theatre.js sheet object instance.
* This is a linked signal that updates when the sheet or key changes.
*/
this.sheetObject = linkedSignal(this.originalSheetObject, ...(ngDevMode ? [{ debugName: "sheetObject" }] : /* istanbul ignore next */ []));
/**
* Signal containing the current values of all animated properties.
* Updates automatically when Theatre.js values change.
*/
this.values = linkedSignal(() => this.sheetObject().value, ...(ngDevMode ? [{ debugName: "values" }] : /* istanbul ignore next */ []));
this.detached = false;
this.aggregatedProps = {};
effect(() => {
this.aggregatedProps = { ...this.aggregatedProps, ...this.props() };
});
effect((onCleanup) => {
const sheetObject = this.sheetObject();
const cleanup = sheetObject.onValuesChange((newValues) => {
this.values.set(newValues);
this.store.snapshot.invalidate();
});
onCleanup(cleanup);
});
effect((onCleanup) => {
const studio = this.studio?.();
if (!studio)
return;
const sheetObject = this.sheetObject();
const cleanup = studio.onSelectionChange((selection) => {
this.selected.set(selection.includes(sheetObject));
});
onCleanup(cleanup);
});
effect((onCleanup) => {
const view = this.vcr.createEmbeddedView(this.templateRef, {
select: this.select.bind(this),
deselect: this.deselect.bind(this),
sheetObject: this.sheetObject.asReadonly(),
values: this.values.asReadonly(),
});
view.detectChanges();
onCleanup(() => {
view.destroy();
});
});
inject(DestroyRef).onDestroy(() => {
if (this.detach()) {
this.detached = true;
this.sheet.sheet().detachObject(this.key());
}
});
}
/**
* Updates the sheet object with the current aggregated props.
* Detaches the existing object and creates a new one with reconfigured properties.
*/
update() {
if (this.detached)
return;
const [sheet, key] = [untracked(this.sheet.sheet), untracked(this.key)];
sheet.detachObject(key);
this.sheetObject.set(sheet.object(key, this.aggregatedProps, { reconfigure: true }));
}
/**
* Adds new properties to the sheet object.
* The properties are merged with existing properties and the object is reconfigured.
*
* @param props - Properties to add to the sheet object
*/
addProps(props) {
this.aggregatedProps = { ...this.aggregatedProps, ...props };
this.update();
}
/**
* Removes properties from the sheet object.
* If all properties are removed and `detach` is true, the object is detached from the sheet.
*
* @param props - Array of property names to remove
*/
removeProps(props) {
const [detach, sheet, key] = [untracked(this.detach), untracked(this.sheet.sheet), untracked(this.key)];
// remove props from sheet object
props.forEach((prop) => {
delete this.aggregatedProps[prop];
});
// if there are no more props, detach sheet object
if (Object.keys(this.aggregatedProps).length === 0) {
// detach sheet object
if (detach) {
sheet.detachObject(key);
}
}
else {
// update sheet object (reconfigure)
this.update();
}
}
/**
* Selects this sheet object in Theatre.js Studio.
* Only works when the studio is available.
*/
select() {
const studio = this.studio?.();
if (!studio)
return;
studio.setSelection([this.sheetObject()]);
}
/**
* Deselects this sheet object in Theatre.js Studio.
* Only deselects if this object is currently selected.
*/
deselect() {
const studio = this.studio?.();
if (!studio)
return;
if (studio.selection.includes(this.sheetObject())) {
studio.setSelection([]);
}
}
/**
* Type guard for the template context.
* Provides type safety for the template variables exposed by this directive.
*
* @param _ - The directive instance
* @param ctx - The template context
* @returns Type predicate for the template context
*/
static ngTemplateContextGuard(_, ctx) {
return true;
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: TheatreSheetObject, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: TheatreSheetObject, isStandalone: true, selector: "ng-template[sheetObject]", inputs: { key: { classPropertyName: "key", publicName: "sheetObject", isSignal: true, isRequired: true, transformFunction: null }, props: { classPropertyName: "props", publicName: "sheetObjectProps", isSignal: true, isRequired: false, transformFunction: null }, detach: { classPropertyName: "detach", publicName: "sheetObjectDetach", isSignal: true, isRequired: false, transformFunction: null }, selected: { classPropertyName: "selected", publicName: "sheetObjectSelected", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selected: "sheetObjectSelectedChange" }, exportAs: ["sheetObject"], ngImport: i0 }); }
};
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: TheatreSheetObject$1, decorators: [{
type: Directive,
args: [{ selector: 'ng-template[sheetObject]', exportAs: 'sheetObject' }]
}], ctorParameters: () => [], propDecorators: { key: [{ type: i0.Input, args: [{ isSignal: true, alias: "sheetObject", required: true }] }], props: [{ type: i0.Input, args: [{ isSignal: true, alias: "sheetObjectProps", required: false }] }], detach: [{ type: i0.Input, args: [{ isSignal: true, alias: "sheetObjectDetach", required: false }] }], selected: [{ type: i0.Input, args: [{ isSignal: true, alias: "sheetObjectSelected", required: false }] }, { type: i0.Output, args: ["sheetObjectSelectedChange"] }] } });
/**
* Factory function for creating a Theatre.js transformer.
*
* This is a convenience function that provides type inference for transformer creation.
*
* @param transformer - The transformer configuration object
* @returns The same transformer object (identity function with type inference)
*
* @example
* ```typescript
* import { createTransformer } from 'angular-three-theatre';
* import { types } from '@theatre/core';
*
* export const percentage = createTransformer({
* transform: (value) => types.number(value * 100, { range: [0, 100] }),
* apply: (target, property, value) => { target[property] = value / 100; }
* });
* ```
*/
function createTransformer(transformer) {
return transformer;
}
const _color = new THREE.Color();
/**
* Transformer for Three.js Color objects.
*
* Converts Three.js Color to Theatre.js RGBA format for the color picker UI.
* Uses sRGB color space for accurate color representation.
*
* @example
* ```typescript
* import { color } from 'angular-three-theatre';
*
* // Used automatically for Color properties, or manually:
* [sync]="material"
* [syncProps]="[['emissive', { transformer: color }]]"
* ```
*/
const color = createTransformer({
transform(value) {
value.getRGB(_color, THREE.SRGBColorSpace);
return types.rgba({ r: _color.r, g: _color.g, b: _color.b, a: 1 });
},
apply(target, path, value) {
target[path].setRGB(value.r, value.g, value.b, THREE.SRGBColorSpace);
},
});
/**
* Transformer for radian values that displays as degrees in the UI.
*
* Converts between radians (used by Three.js) and degrees (more intuitive for users).
* Used automatically for rotation.x, rotation.y, and rotation.z properties.
*
* @example
* ```typescript
* import { degrees } from 'angular-three-theatre';
*
* // Used automatically for rotation components, or manually:
* [sync]="camera"
* [syncProps]="[['fov', { transformer: degrees }]]"
* ```
*/
const degrees = createTransformer({
transform(target) {
return types.number(target * THREE.MathUtils.RAD2DEG);
},
apply(target, path, value) {
target[path] = value * THREE.MathUtils.DEG2RAD;
},
});
/**
* Transformer for Three.js Euler rotation objects.
*
* Converts Euler angles from radians to degrees for display in Theatre.js Studio.
* Creates a compound property with x, y, z components shown in degrees.
*
* Used automatically for properties where `isEuler` is true (e.g., Object3D.rotation).
*
* @example
* ```typescript
* import { euler } from 'angular-three-theatre';
*
* // Used automatically for Euler properties, or manually:
* [sync]="mesh"
* [syncProps]="[['rotation', { transformer: euler }]]"
* ```
*/
const euler = createTransformer({
transform(value) {
return types.compound({
x: value.x * THREE.MathUtils.RAD2DEG,
y: value.y * THREE.MathUtils.RAD2DEG,
z: value.z * THREE.MathUtils.RAD2DEG,
});
},
apply(target, path, value) {
target[path].x = value.x * THREE.MathUtils.DEG2RAD;
target[path].y = value.y * THREE.MathUtils.DEG2RAD;
target[path].z = value.z * THREE.MathUtils.DEG2RAD;
},
});
/**
* Generic fallback transformer that handles common JavaScript types.
*
* Automatically detects the value type and applies the appropriate Theatre.js type:
* - Numbers → `types.number` (Infinity converted to MAX_VALUE)
* - Strings → `types.string`
* - Booleans → `types.boolean`
* - Objects → `types.compound` (spreads properties)
*
* Used as the default transformer when no specific transformer matches.
*
* @example
* ```typescript
* import { generic } from 'angular-three-theatre';
*
* // Explicitly use generic transformer:
* [sync]="mesh"
* [syncProps]="[['customProperty', { transformer: generic }]]"
* ```
*/
const generic = createTransformer({
transform(value) {
if (typeof value === 'number') {
return types.number(value === Infinity ? Number.MAX_VALUE : value);
}
else if (typeof value === 'string') {
return types.string(value);
}
else if (typeof value === 'boolean') {
return types.boolean(value);
}
return types.compound({ ...value });
},
apply(target, path, value) {
if (target[path] !== null && typeof target[path] === 'object') {
Object.assign(target[path], value);
}
else {
target[path] = value;
}
},
});
/**
* Transformer for normalized values in the 0-1 range.
*
* Creates a number input with a slider constrained to the 0-1 range.
* Used automatically for material properties like opacity, roughness,
* metalness, transmission, and color components (r, g, b).
*
* @example
* ```typescript
* import { normalized } from 'angular-three-theatre';
*
* // Used automatically for common properties, or manually:
* [sync]="material"
* [syncProps]="[['customNormalizedValue', { transformer: normalized }]]"
* ```
*/
const normalized = createTransformer({
transform(value) {
return types.number(value, { range: [0, 1] });
},
apply(target, path, value) {
target[path] = value;
},
});
/**
* Transformer for Three.js material side property.
*
* Converts between Three.js side constants (FrontSide, BackSide, DoubleSide)
* and a switch UI in Theatre.js Studio with human-readable labels.
*
* Used automatically for the `side` property on materials.
*
* @example
* ```typescript
* import { side } from 'angular-three-theatre';
*
* // Used automatically for material.side, or manually:
* [sync]="material"
* [syncProps]="[['side', { transformer: side }]]"
* ```
*/
const side = createTransformer({
transform(value) {
// TODO: fix this type
return types.stringLiteral(value === THREE.FrontSide ? 'f' : value === THREE.BackSide ? 'b' : 'd', { f: 'Front', b: 'Back', d: 'Double' }, { as: 'switch' });
},
apply(target, path, value) {
target[path] = value === 'f' ? THREE.FrontSide : value === 'b' ? THREE.BackSide : THREE.DoubleSide;
},
});
/**
* Checks if a property path matches a pattern exactly or ends with the pattern.
*
* @param fullPropertyPath - The full property path (e.g., 'material.opacity')
* @param pattern - The pattern to match (e.g., 'opacity')
* @returns True if the path matches or ends with the pattern
*/
function isFullOrEndingPattern(fullPropertyPath, pattern) {
return fullPropertyPath.endsWith(`.${pattern}`) || fullPropertyPath === pattern;
}
/**
* Determines the appropriate transformer for a Three.js property based on its type and path.
*
* This function automatically selects the best transformer for common Three.js properties:
* - Euler rotations → `euler` transformer (degrees display)
* - Color values → `color` transformer (RGBA picker)
* - Rotation components (x, y, z) → `degrees` transformer
* - Color components (r, g, b) → `normalized` transformer (0-1 range)
* - Material properties (opacity, roughness, metalness, transmission) → `normalized` transformer
* - Side property → `side` transformer (Front/Back/Double switch)
* - All others → `generic` transformer
*
* @param target - The parent object containing the property
* @param path - The property name on the target
* @param fullPropertyPath - The full dot-notation path to the property
* @returns The appropriate transformer for the property
*
* @example
* ```typescript
* import { getDefaultTransformer } from 'angular-three-theatre';
*
* const mesh = new THREE.Mesh();
* const transformer = getDefaultTransformer(mesh, 'rotation', 'rotation');
* // Returns the euler transformer
* ```
*/
function getDefaultTransformer(target, path, fullPropertyPath) {
const property = target[path];
if (property.isEuler)
return euler;
if (property.isColor)
return color;
if (isFullOrEndingPattern(fullPropertyPath, 'rotation.x') ||
isFullOrEndingPattern(fullPropertyPath, 'rotation.y') ||
isFullOrEndingPattern(fullPropertyPath, 'rotation.z') ||
(target.isEuler && (fullPropertyPath === 'x' || fullPropertyPath === 'y' || fullPropertyPath === 'z'))) {
return degrees;
}
if (isFullOrEndingPattern(fullPropertyPath, 'r'))
return normalized;
if (isFullOrEndingPattern(fullPropertyPath, 'g'))
return normalized;
if (isFullOrEndingPattern(fullPropertyPath, 'b'))
return normalized;
if (isFullOrEndingPattern(fullPropertyPath, 'opacity'))
return normalized;
if (isFullOrEndingPattern(fullPropertyPath, 'roughness'))
return normalized;
if (isFullOrEndingPattern(fullPropertyPath, 'metalness'))
return normalized;
if (isFullOrEndingPattern(fullPropertyPath, 'transmission'))
return normalized;
if (isFullOrEndingPattern(fullPropertyPath, 'side'))
return side;
return generic;
}
const updateProjectionMatrixKeys = ['fov', 'near', 'far', 'zoom', 'left', 'right', 'top', 'bottom', 'aspect'];
/**
* Directive that synchronizes Three.js object properties with Theatre.js animations.
*
* This directive allows you to expose specific properties of a Three.js object
* to Theatre.js for animation. It automatically handles property transformation
* (e.g., converting Euler angles to degrees for the UI).
*
* Must be used within a `TheatreSheetObject` context.
*
* @example
* ```html
* <ng-template sheetObject="myMaterial">
* <ngt-mesh-standard-material
* [sync]="material"
* [syncProps]="['opacity', 'roughness', 'metalness']"
* #material
* />
* </ng-template>
* ```
*
* @example
* ```html
* <!-- With custom property mapping -->
* <ng-template sheetObject="myLight">
* <ngt-point-light
* [sync]="light"
* [syncProps]="[
* ['intensity', { label: 'Light Intensity', key: 'lightIntensity' }],
* 'color'
* ]"
* #light
* />
* </ng-template>
* ```
*
* @typeParam TObject - The type of the Three.js object being synchronized
*/
class TheatreSheetObjectSync {
constructor() {
/**
* The Three.js object to synchronize with Theatre.js.
* Can be an object reference, ElementRef, or a Signal of either.
*/
this.parent = input.required({ ...(ngDevMode ? { debugName: "parent" } : /* istanbul ignore next */ {}), alias: 'sync' });
/**
* Array of property paths to synchronize with Theatre.js.
*
* Each item can be:
* - A string property path (e.g., 'opacity', 'position.x')
* - A tuple of [propertyPath, keyOrOptions] where options can include:
* - `label`: Display label in Theatre.js Studio
* - `key`: Unique key for the property in Theatre.js
* - `transformer`: Custom transformer for the property value
*
* @default []
*/
this.props = input([], { ...(ngDevMode ? { debugName: "props" } : /* istanbul ignore next */ {}), alias: 'syncProps' });
this.theatreSheetObject = inject(TheatreSheetObject$1);
/**
* Computed signal containing the Theatre.js sheet object instance.
*/
this.sheetObject = computed(() => this.theatreSheetObject.sheetObject(), ...(ngDevMode ? [{ debugName: "sheetObject" }] : /* istanbul ignore next */ []));
this.studio = inject(THEATRE_STUDIO, { optional: true });
this.parentRef = computed(() => {
const parent = this.parent();
if (typeof parent === 'function')
return resolveRef(parent());
return resolveRef(parent);
}, ...(ngDevMode ? [{ debugName: "parentRef" }] : /* istanbul ignore next */ []));
this.resolvedProps = computed(() => {
const props = this.props();
return props.reduce((resolved, prop) => {
if (typeof prop === 'string') {
resolved.push([prop, { key: this.resolvePropertyPath(prop) }]);
}
else {
if (typeof prop[1] === 'string') {
resolved.push([prop[0], { key: prop[1] }]);
}
else {
resolved.push(prop);
}
}
return resolved;
}, []);
}, ...(ngDevMode ? [{ debugName: "resolvedProps" }] : /* istanbul ignore next */ []));
this.propsToAdd = computed(() => {
const parent = this.parentRef();
if (!parent)
return null;
const propsToAdd = {};
const resolvedProps = this.resolvedProps();
resolvedProps.forEach(([propName, { key, label, transformer }]) => {
const { root, targetKey } = resolveInstanceKey(parent, propName);
const rawValue = root[targetKey];
const valueTransformer = transformer ?? getDefaultTransformer(root, targetKey, propName);
const value = valueTransformer.transform(rawValue);
value.label = label ?? key;
this.propsMapping[key] = { path: propName, transformer: valueTransformer };
propsToAdd[key] = value;
});
return propsToAdd;
}, ...(ngDevMode ? [{ debugName: "propsToAdd" }] : /* istanbul ignore next */ []));
this.propsMapping = {};
effect(() => {
const propsToAdd = this.propsToAdd();
if (!propsToAdd)
return;
this.theatreSheetObject.addProps(propsToAdd);
});
effect((onCleanup) => {
const parent = this.parentRef();
if (!parent)
return;
const propsToAdd = this.propsToAdd();
if (!propsToAdd)
return;
const sheetObject = this.sheetObject();
const cleanup = sheetObject.onValuesChange((newValues) => {
Object.keys(newValues).forEach((key) => {
// first, check if the prop is mapped in this component
const propMapping = this.propsMapping[key];
if (!propMapping)
return;
// we're using the addedProps map to infer the target property name from the property name on values
const { root, targetKey } = resolveInstanceKey(parent, propMapping.path);
// use a transformer to apply value
const transformer = propMapping.transformer;
transformer.apply(root, targetKey, newValues[key]);
if (updateProjectionMatrixKeys.includes(targetKey)) {
root.updateProjectionMatrix?.();
}
});
});
onCleanup(cleanup);
});
inject(DestroyRef).onDestroy(() => {
this.theatreSheetObject.removeProps(Object.keys(this.propsMapping));
});
}
/**
* Captures the current values of all synchronized properties from the Three.js object
* and commits them to Theatre.js.
*
* This is useful for "baking" the current state of the Three.js object into the
* Theatre.js animation. Requires Theatre.js Studio to be available.
*/
capture() {
const studio = this.studio?.();
if (!studio)
return;
const parent = this.parentRef();
if (!parent)
return;
const sheetObject = this.sheetObject();
if (!sheetObject)
return;
const scrub = studio.scrub();
Object.keys(sheetObject.value).forEach((key) => {
// first, check if the prop is mapped in this component
const propMapping = this.propsMapping[key];
if (!propMapping)
return;
// we're using the addedProps map to infer the target property name from the property name on values
const { targetProp } = resolveInstanceKey(parent, propMapping.path);
const value = propMapping.transformer.transform(targetProp).default;
scrub.capture(({ set }) => {
set(sheetObject.props[key], value);
});
});
scrub.commit();
}
/**
* Converts a property path (e.g., 'position.x') to a safe alphanumeric key.
*
* @param propPath - The property path to convert
* @returns A safe alphanumeric key string
*/
resolvePropertyPath(propPath) {
return (propPath
// make the label alphanumeric by first removing dots (fundamental feature for pierced props)
.replace(/\./g, '-')
// make the following characters uppercase
.replace(/-([a-z])/g, (g) => g[1].toUpperCase())
// convert to safe alphanumeric characters without dashes
.replace(/[^a-zA-Z0-9]/g, ''));
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: TheatreSheetObjectSync, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: TheatreSheetObjectSync, isStandalone: true, selector: "[sync]", inputs: { parent: { classPropertyName: "parent", publicName: "sync", isSignal: true, isRequired: true, transformFunction: null }, props: { classPropertyName: "props", publicName: "syncProps", isSignal: true, isRequired: false, transformFunction: null } }, exportAs: ["sync"], ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: TheatreSheetObjectSync, decorators: [{
type: Directive,
args: [{ selector: '[sync]', exportAs: 'sync' }]
}], ctorParameters: () => [], propDecorators: { parent: [{ type: i0.Input, args: [{ isSignal: true, alias: "sync", required: true }] }], props: [{ type: i0.Input, args: [{ isSignal: true, alias: "syncProps", required: false }] }] } });
/**
* Component that provides transform controls for animating position, rotation, and scale
* of child Three.js objects via Theatre.js.
*
* When the sheet object is selected in Theatre.js Studio, transform controls appear
* allowing direct manipulation of the object's transform. Changes are captured and
* committed to Theatre.js.
*
* Must be used within a `TheatreSheetObject` context.
*
* @example
* ```html
* <ng-template sheetObject="myCube">
* <theatre-transform>
* <ngt-mesh>
* <ngt-box-geometry />
* <ngt-mesh-standard-material />
* </ngt-mesh>
* </theatre-transform>
* </ng-template>
* ```
*
* @example
* ```html
* <!-- With custom key and options -->
* <ng-template sheetObject="scene">
* <theatre-transform key="cubeTransform" label="Cube" [options]="{ mode: 'rotate' }">
* <ngt-mesh />
* </theatre-transform>
* </ng-template>
* ```
*
* @typeParam TLabel - The type of the label string
*/
class TheatreSheetObjectTransform {
onMouseDown() {
if (!this.studio)
return;
if (this.scrub)
return;
this.scrub = this.studio().scrub();
}
onMouseUp() {
if (!this.scrub)
return;
this.scrub.commit();
this.scrub = undefined;
}
onChange() {
if (!this.scrub)
return;
this.scrub.capture((api) => {
const sheetObject = this.sheetObject();
if (!sheetObject)
return;
const group = this.groupRef().nativeElement;
const key = this.key();
const baseTarget = key ? sheetObject.props[key] : sheetObject.props;
api.set(baseTarget['position'], { ...group.position });
api.set(baseTarget['rotation'], {
x: group.rotation.x * THREE.MathUtils.RAD2DEG,
y: group.rotation.y * THREE.MathUtils.RAD2DEG,
z: group.rotation.z * THREE.MathUtils.RAD2DEG,
});
api.set(baseTarget['scale'], { ...group.scale });
});
}
constructor() {
/**
* Display label for the transform properties in Theatre.js Studio.
*/
this.label = input(...(ngDevMode ? [undefined, { debugName: "label" }] : /* istanbul ignore next */ []));
/**
* Unique key for grouping the transform properties in Theatre.js.
* If provided, position/rotation/scale will be nested under this key.
*/
this.key = input(...(ngDevMode ? [undefined, { debugName: "key" }] : /* istanbul ignore next */ []));
/**
* Options for the transform controls gizmo.
* Allows configuring the transform mode, snap values, and coordinate space.
*
* @default {}
*/
this.options = input({}, ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
/**
* Reference to the Three.js Group element that wraps the transformed content.
*/
this.groupRef = viewChild.required('group');
this.controlsRef = viewChild(NgtsTransformControls, ...(ngDevMode ? [{ debugName: "controlsRef" }] : /* istanbul ignore next */ []));
this.theatreSheetObject = inject(TheatreSheetObject$1);
/**
* Computed signal containing the Theatre.js sheet object instance.
*/
this.sheetObject = computed(() => this.theatreSheetObject.sheetObject(), ...(ngDevMode ? [{ debugName: "sheetObject" }] : /* istanbul ignore next */ []));
this.studio = inject(THEATRE_STUDIO, { optional: true });
this.selected = this.theatreSheetObject.selected.asReadonly();
this.positionTransformer = computed(() => getDefaultTransformer(this.groupRef().nativeElement, 'position', 'position'), ...(ngDevMode ? [{ debugName: "positionTransformer" }] : /* istanbul ignore next */ []));
this.rotationTransformer = computed(() => getDefaultTransformer(this.groupRef().nativeElement, 'rotation', 'rotation'), ...(ngDevMode ? [{ debugName: "rotationTransformer" }] : /* istanbul ignore next */ []));
this.scaleTransformer = computed(() => getDefaultTransformer(this.groupRef().nativeElement, 'scale', 'scale'), ...(ngDevMode ? [{ debugName: "scaleTransformer" }] : /* istanbul ignore next */ []));
extend({ Group });
afterNextRender(() => {
this.init();
});
effect((onCleanup) => {
const [sheetObject, key, positionTransformer, rotationTransformer, scaleTransformer, group] = [
this.sheetObject(),
untracked(this.key),
untracked(this.positionTransformer),
untracked(this.rotationTransformer),
untracked(this.scaleTransformer),
untracked(this.groupRef).nativeElement,
];
const cleanup = sheetObject.onValuesChange((newValues) => {
let object = newValues;
if (key) {
if (!newValues[key])
return;
object = newValues[key];
}
else {
if (!newValues['position'] || !newValues['rotation'] || !newValues['scale'])
return;
}
// sanity check
if (!object)
return;
positionTransformer.apply(group, 'position', object['position']);
rotationTransformer.apply(group, 'rotation', object['rotation']);
scaleTransformer.apply(group, 'scale', object['scale']);
});
onCleanup(cleanup);
});
// TODO: (chau) use event binding when they no longer trigger change detection
effect((onCleanup) => {
const controls = this.controlsRef();
if (!controls)
return;
const subs = [
controls.change.subscribe(this.onChange.bind(this)),
controls.mouseDown.subscribe(this.onMouseDown.bind(this)),
controls.mouseUp.subscribe(this.onMouseUp.bind(this)),
];
onCleanup(() => {
subs.forEach((sub) => sub.unsubscribe());
});
});
inject(DestroyRef).onDestroy(() => {
const key = this.key();
this.theatreSheetObject.removeProps(key ? [key] : ['position', 'rotation', 'scale']);
});
}
init() {
const [group, key, label, positionTransformer, rotationTransformer, scaleTransformer] = [
this.groupRef().nativeElement,
this.key(),
this.label(),
this.positionTransformer(),
this.rotationTransformer(),
this.scaleTransformer(),
];
const position = positionTransformer.transform(group.position);
const rotation = rotationTransformer.transform(group.rotation);
const scale = scaleTransformer.transform(group.scale);
if (key) {
this.theatreSheetObject.addProps({
[key]: types.compound({ position, rotation, scale }, { label: label ?? key }),
});
}
else {
this.theatreSheetObject.addProps({ position, rotation, scale });
}
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: TheatreSheetObjectTransform, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.9", type: TheatreSheetObjectTransform, isStandalone: true, selector: "theatre-transform", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, key: { classPropertyName: "key", publicName: "key", isSignal: true, isRequired: false, transformFunction: null }, options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null } }, viewQueries: [{ propertyName: "groupRef", first: true, predicate: ["group"], descendants: true, isSignal: true }, { propertyName: "controlsRef", first: true, predicate: NgtsTransformControls, descendants: true, isSignal: true }], ngImport: i0, template: `
@if (selected()) {
<ngts-transform-controls [object]="$any(group)" [options]="options()" />
}
<ngt-group #group>
<ng-content />
</ngt-group>
`, isInline: true, dependencies: [{ kind: "component", type: NgtsTransformControls, selector: "ngts-transform-controls", inputs: ["object", "options"], outputs: ["change", "mouseDown", "mouseUp", "objectChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: TheatreSheetObjectTransform, decorators: [{
type: Component,
args: [{
selector: 'theatre-transform',
template: `
@if (selected()) {
<ngts-transform-controls [object]="$any(group)" [options]="options()" />
}
<ngt-group #group>
<ng-content />
</ngt-group>
`,
imports: [NgtsTransformControls],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
changeDetection: ChangeDetectionStrategy.OnPush,
}]
}], ctorParameters: () => [], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], key: [{ type: i0.Input, args: [{ isSignal: true, alias: "key", required: false }] }], options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], groupRef: [{ type: i0.ViewChild, args: ['group', { isSignal: true }] }], controlsRef: [{ type: i0.ViewChild, args: [i0.forwardRef(() => NgtsTransformControls), { isSignal: true }] }] } });
/**
* Combined array of sheet object directives for convenient importing.
*
* Includes:
* - `TheatreSheetObject` - Base directive for creating sheet objects
* - `TheatreSheetObjectTransform` - Component for animating transform properties
* - `TheatreSheetObjectSync` - Directive for syncing arbitrary object properties
*
* @example
* ```typescript
* import { TheatreSheetObject } from 'angular-three-theatre';
*
* @Component({
* imports: [TheatreSheetObject],
* template: `
* <ng-template sheetObject="myObject">
* <theatre-transform>
* <ngt-mesh />
* </theatre-transform>
* </ng-template>
* `
* })
* export class MyComponent {}
* ```
*/
const TheatreSheetObject = [TheatreSheetObject$1, TheatreSheetObjectTransform, TheatreSheetObjectSync];
/**
* Directive that initializes and manages the Theatre.js Studio.
*
* Theatre.js Studio is a visual editor that allows you to create and edit
* animations directly in the browser. The studio UI is dynamically imported
* to avoid including it in production builds.
*
* This directive must be applied to a `theatre-project` element and provides
* the studio instance via the `THEATRE_STUDIO` injection token.
*
* @example
* ```html
* <!-- Enable studio (default) -->
* <theatre-project studio>
* <ng-container sheet="scene">...</ng-container>
* </theatre-project>
*
* <!-- Conditionally enable/disable studio -->
* <theatre-project [studio]="isDevelopment">
* <ng-container sheet="scene">...</ng-container>
* </theatre-project>
*
* <!-- Disable studio -->
* <theatre-project [studio]="false">
* <ng-container sheet="scene">...</ng-container>
* </theatre-project>
* ```
*/
class TheatreStudio {
constructor() {
/**
* Whether the studio UI should be visible.
* When false, the studio UI is hidden but the studio instance remains active.
*
* @default true
*/
this.enabled = input(true, { ...(ngDevMode ? { debugName: "enabled" } : /* istanbul ignore next */ {}), alias: 'studio', transform: booleanAttribute });
this.Studio = signal(null, ...(ngDevMode ? [{ debugName: "Studio" }] : /* istanbul ignore next */ []));
/**
* Read-only signal containing the Theatre.js Studio instance.
* May be null while the studio is being loaded.
*/
this.studio = this.Studio.asReadonly();
import('@theatre/studio').then((m) => {
const Studio = 'default' in m.default ? m.default.default : m.default;
Studio.initialize();
this.Studio.set(Studio);
});
effect((onCleanup) => {
const studio = this.Studio();
if (!studio)
return;
const enabled = this.enabled();
if (enabled) {
studio.ui.restore();
}
else {
studio.ui.hide();
}
onCleanup(() => {
studio.ui.hide();
});
});
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: TheatreStudio, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: TheatreStudio, isStandalone: true, selector: "theatre-project[studio]", inputs: { enabled: { classPropertyName: "enabled", publicName: "studio", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
{ provide: THEATRE_STUDIO, useFactory: (studio) => studio.studio, deps: [TheatreStudio] },
], exportAs: ["studio"], ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: TheatreStudio, decorators: [{
type: Directive,
args: [{
selector: 'theatre-project[studio]',
exportAs: 'studio',
providers: [
{ provide: THEATRE_STUDIO, useFactory: (studio) => studio.studio, deps: [TheatreStudio] },
],
}]
}], ctorParameters: () => [], propDecorators: { enabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "studio", required: false }] }] } });
const defaultOptions = {
rate: 1,
autoplay: false,
autopause: false,
delay: 0,
};
/**
* Directive that provides control over a Theatre.js sequence.
*
* A sequence controls the playback of animations within a sheet. This directive
* provides methods to play, pause, and reset the sequence, as well as reactive
* signals for the current position, playing state, and length.
*
* Must be used on an element that also has the `sheet` directive.
*
* @example
* ```html
* <ng-co