UNPKG

angular-three-theatre

Version:
1,169 lines (1,150 loc) 58.8 kB
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