diff --git a/examples/tests/autosize.ts b/examples/tests/autosize.ts new file mode 100644 index 00000000..34af8916 --- /dev/null +++ b/examples/tests/autosize.ts @@ -0,0 +1,269 @@ +/* + * Visual Regression Test: Autosize System + * + * This test demonstrates various autosize scenarios for visual regression testing. + * All tests are 200x200px and arranged in a grid across a 1080p screen. + */ + +import type { ExampleSettings } from '../common/ExampleSettings.js'; +import rockoImg from '../assets/rocko.png'; + +export async function automation(settings: ExampleSettings) { + // Snapshot single page + await autosizeExample(settings); +} + +export default async function autosizeExample({ + renderer, + testRoot, + snapshot, +}: ExampleSettings) { + const rootNode = testRoot; + + // Helper function to create text label + const createLabel = (text: string, x: number, y: number) => { + return renderer.createTextNode({ + x, + y, + text: text, + fontSize: 20, + fontFamily: 'sans-serif', + color: 0xffffffff, + parent: rootNode, + }); + }; + + // Test 1: Autosize parent with 2 children + createLabel('1. Parent w/ 2 children', 50, 50); + const test1Parent = renderer.createNode({ + x: 50, + y: 80, + color: 0x00ff0088, + autosize: true, + parent: rootNode, + }); + renderer.createNode({ + x: 10, + y: 10, + w: 80, + h: 60, + color: 0xff0000ff, + parent: test1Parent, + }); + renderer.createNode({ + x: 100, + y: 80, + w: 70, + h: 50, + color: 0x0000ffff, + parent: test1Parent, + }); + + // Test 2: Autosize parent with 1 child, later added child + createLabel('2: add 2nd', 300, 50); + const test2Parent = renderer.createNode({ + x: 300, + y: 80, + color: 0x00ff0088, + autosize: true, + parent: rootNode, + }); + renderer.createNode({ + x: 20, + y: 20, + w: 60, + h: 50, + color: 0xff0000ff, + parent: test2Parent, + }); + + // Test 3: Autosize parent with 2 children, child 1 position updated + createLabel('3: Update position', 550, 50); + const test3Parent = renderer.createNode({ + x: 550, + y: 80, + color: 0x00ff0088, + autosize: true, + parent: rootNode, + }); + const test3Child1 = renderer.createNode({ + x: 20, + y: 20, + w: 60, + h: 50, + color: 0xff0000ff, + parent: test3Parent, + }); + renderer.createNode({ + x: 50, + y: 80, + w: 70, + h: 40, + color: 0x0000ffff, + parent: test3Parent, + }); + + // Test 4: Autosize parent with 2 children, 1 child alpha 0 + createLabel('4: child alpha=0', 800, 50); + const test4Parent = renderer.createNode({ + x: 800, + y: 80, + color: 0x00ff0088, + autosize: true, + parent: rootNode, + }); + renderer.createNode({ + x: 20, + y: 20, + w: 60, + h: 50, + color: 0xff0000ff, + alpha: 1, + parent: test4Parent, + }); + renderer.createNode({ + x: 90, + y: 70, + w: 80, + h: 60, + alpha: 0, + color: 0x0000ffff, + parent: test4Parent, + }); + + // Test 5: Autosize parent with 2 children, off screen then moved into screen + createLabel('5: Off-screen then on', 1050, 50); + const test5Parent = renderer.createNode({ + x: -500, + y: -500, + color: 0x00ff0088, + autosize: true, + parent: rootNode, + }); + renderer.createNode({ + x: 20, + y: 20, + w: 60, + h: 50, + color: 0xff0000ff, + parent: test5Parent, + }); + renderer.createNode({ + x: 90, + y: 70, + w: 80, + h: 60, + color: 0x0000ffff, + parent: test5Parent, + }); + + // Test 6: Autosize parent with 2 children, later removed child + createLabel('6: Remove child later', 1300, 50); + const test6Parent = renderer.createNode({ + x: 1300, + y: 80, + color: 0x00ff0088, + autosize: true, + parent: rootNode, + }); + renderer.createNode({ + x: 20, + y: 20, + w: 60, + h: 50, + color: 0xff0000ff, + parent: test6Parent, + }); + const test6Child2 = renderer.createNode({ + x: 90, + y: 70, + w: 80, + h: 60, + color: 0x0000ffff, + parent: test6Parent, + }); + + // Test 7: Autosize OFF, parent with 2 children + createLabel('7: Autosize=false', 50, 350); + const test7Parent = renderer.createNode({ + x: 50, + y: 380, + w: 100, + h: 100, + color: 0x00ff0088, + autosize: false, + parent: rootNode, + }); + renderer.createNode({ + x: 20, + y: 20, + w: 60, + h: 50, + color: 0xff0000ff, + parent: test7Parent, + }); + renderer.createNode({ + x: 90, + y: 70, + w: 80, + h: 60, + color: 0x0000ffff, + parent: test7Parent, + }); + + // Test 8a: Texture with autosize true + createLabel('8a: Texture autosize=true', 300, 350); + renderer.createNode({ + x: 300, + y: 380, + color: 0xff00ff88, + autosize: true, + src: rockoImg, + parent: rootNode, + }); + + // Test 8b: Texture with autosize false + createLabel('8b: Texture autosize=false', 550, 350); + renderer.createNode({ + x: 550, + y: 380, + w: 100, + h: 100, + color: 0xff00ff88, + autosize: false, + src: rockoImg, + parent: rootNode, + }); + console.log('All autosize nodes created.'); + + await snapshot(); + + await new Promise((resolve) => + setTimeout(() => { + console.log('Making updates to autosize nodes...'); + // add node to test 2 + renderer.createNode({ + x: 90, + y: 70, + w: 80, + h: 60, + color: 0x0000ffff, + parent: test2Parent, + }); + + // move child in test 3 + test3Child1.x = 100; + test3Child1.y = 100; + + // Move into screen test 5 + test5Parent.x = 1050; + test5Parent.y = 80; + + // remove child from test 6 + test6Child2.parent = null; + resolve(true); + }, 200), + ); + + await snapshot(); +} diff --git a/src/core/Autosizer.ts b/src/core/Autosizer.ts new file mode 100644 index 00000000..47978d14 --- /dev/null +++ b/src/core/Autosizer.ts @@ -0,0 +1,224 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2023 Comcast Cable Communications Management, LLC. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { UpdateType, type CoreNode } from './CoreNode.js'; +import type { Coord } from './lib/utils.js'; + +export enum AutosizeMode { + Children = 0, + Texture = 1, +} + +export enum AutosizeUpdateType { + None = 0, + Filtered = 1, + All = 2, +} + +const applyDimensions = (node: CoreNode, w: number, h: number) => { + node.props.w = w; + node.props.h = h; + node.setUpdateType(UpdateType.Local); +}; + +const getFilteredChildren = ( + children: number[], + childMap: Map, +) => { + const filtered: CoreNode[] = []; + while (children.length > 0) { + const id = children.pop()!; + const child = childMap.get(id)!; + filtered.push(child); + } + return filtered; +}; + +let autosizerId = 0; + +export class Autosizer { + public id = autosizerId++; + mode: AutosizeMode = AutosizeMode.Children; + updateType: AutosizeUpdateType = AutosizeUpdateType.All; + lastWidth: number = 0; + lastHeight: number = 0; + lastHasChanged: boolean = false; + + flaggedChildren: number[] = []; + childMap: Map = new Map(); + + minX = Infinity; + minY = Infinity; + maxX = -Infinity; + maxY = -Infinity; + + corners: [Coord, Coord, Coord, Coord] = [ + { x: 0, y: 0 }, + { x: 0, y: 0 }, + { x: 0, y: 0 }, + { x: 0, y: 0 }, + ]; + + constructor(public node: CoreNode) { + if (node.texture !== null) { + this.mode = AutosizeMode.Texture; + } + } + + attach(node: CoreNode) { + this.childMap.set(node.id, node); + node.parentAutosizer = this; + + //bubble down to attach to grandchildren + if (node.children.length > 0 && node.autosizer === null) { + const children = node.children; + for (let i = 0; i < children.length; i++) { + this.attach(children[i]!); + } + } + } + + detach(node: CoreNode) { + if (this.childMap.delete(node.id) === true) { + node.parentAutosizer = null; + if (node.children.length > 0 && node.autosizer === null) { + const children = node.children; + for (let i = 0; i < children.length; i++) { + this.detach(children[i]!); + } + } + //detached a child, need full update + this.setUpdateType(AutosizeUpdateType.All); + } + } + + patch(id: number) { + const entry = this.childMap.get(id); + if (entry === undefined) { + return; + } + this.flaggedChildren.push(id); + this.setUpdateType(AutosizeUpdateType.Filtered); + } + + setUpdateType(updateType: AutosizeUpdateType) { + this.updateType |= updateType; + this.node.setUpdateType(UpdateType.Autosize); + } + + setMode(mode: AutosizeMode) { + this.mode = mode; + this.setUpdateType(AutosizeUpdateType.All); + } + + update() { + const node = this.node; + + if ( + this.mode === AutosizeMode.Texture && + node.texture !== null && + node.texture.dimensions !== null + ) { + const { w, h } = node.texture.dimensions; + if (w !== node.w || h !== node.h) { + applyDimensions(node, w, h); + } + this.lastWidth = w; + this.lastHeight = h; + this.updateType = AutosizeUpdateType.None; + return; + } + + let filtered: CoreNode[] = + this.updateType === AutosizeUpdateType.Filtered + ? getFilteredChildren(this.flaggedChildren, this.childMap) + : Array.from(this.childMap.values()); + + if (filtered.length === 0) { + return; + } + + const corners = this.corners; + let minX = this.minX; + let minY = this.minY; + let maxX = this.maxX; + let maxY = this.maxY; + + for (let i = 0; i < filtered.length; i++) { + const child = filtered[i]!; + if (child.isRenderable === false || child.localTransform === undefined) { + continue; + } + + const { tx, ty, ta, tb, tc, td } = child.localTransform; + const w = child.props.w; + const h = child.props.h; + + const childMinX = tx; + const childMaxX = tx + w * ta; + const childMinY = ty; + const childMaxY = ty + h * td; + + corners[0].x = childMinX; + corners[0].y = childMinY; + corners[1].x = childMaxX; + + //no rotation/scale + if (tb === 0 && tc === 0) { + corners[1].y = childMinY; + corners[2].x = childMaxX; + corners[2].y = childMaxY; + corners[3].x = childMinX; + corners[3].y = childMaxY; + } else { + corners[1].y = tx + w * tc; + corners[2].x = tx + w * ta + h * tb; + corners[2].y = ty + w * tc + h * td; + corners[3].x = tx + h * tb; + corners[3].y = ty + h * td; + } + + for (let j = 0; j < 4; j++) { + const corner = corners[j]!; + if (corner.x < minX) minX = corner.x; + if (corner.y < minY) minY = corner.y; + if (corner.x > maxX) maxX = corner.x; + if (corner.y > maxY) maxY = corner.y; + } + } + this.updateType = AutosizeUpdateType.None; + + const newWidth = maxX > 0 ? maxX : 0; + const newHeight = maxY > 0 ? maxY : 0; + + applyDimensions(node, newWidth, newHeight); + this.lastWidth = newWidth; + this.lastHeight = newHeight; + } + + destroy() { + if (this.childMap.size > 0) { + for (const child of this.childMap.values()) { + child.parentAutosizer = null; + } + } + this.childMap.clear(); + this.flaggedChildren.length = 0; + } +} diff --git a/src/core/CoreNode.test.ts b/src/core/CoreNode.test.ts index b63825bb..a59811c7 100644 --- a/src/core/CoreNode.test.ts +++ b/src/core/CoreNode.test.ts @@ -248,4 +248,118 @@ describe('set color()', () => { expect(eventCallback).not.toHaveBeenCalled(); }); }); + + describe('autosize system', () => { + it('should initialize with autosize disabled', () => { + const node = new CoreNode(stage, defaultProps); + expect(node.autosize).toBe(false); + }); + + it('should enable texture autosize when texture is present', () => { + const node = new CoreNode(stage, defaultProps); + const mockTexture = mock(); + mockTexture.state = 'loading'; + + node.texture = mockTexture; + node.autosize = true; + + // Should not create autosize manager for texture mode + expect((node as any).autosizer).toBeTruthy(); + }); + + it('should enable children autosize when no texture but has children', () => { + const parent = new CoreNode(stage, defaultProps); + const child = new CoreNode(stage, defaultProps); + + parent.autosize = true; + child.parent = parent; + + // Should create autosize manager for children mode + expect((parent as any).autosizer).toBeTruthy(); + }); + + it('should prioritize texture autosize over children autosize', () => { + const parent = new CoreNode(stage, defaultProps); + const child = new CoreNode(stage, defaultProps); + const mockTexture = mock(); + mockTexture.state = 'loading'; + + child.parent = parent; + parent.texture = mockTexture; + parent.autosize = true; + + expect(parent.autosize).toBe(true); + // Should NOT create autosize manager when texture is present + expect((parent as any).autosizer).toBeTruthy(); + }); + + it('should switch from children to texture autosize when texture is added', () => { + const parent = new CoreNode(stage, defaultProps); + const child = new CoreNode(stage, defaultProps); + + child.parent = parent; + parent.autosize = true; + expect((parent as any).autosizer).toBeTruthy(); + + // Add texture - should switch to texture autosize + const mockTexture = mock(); + mockTexture.state = 'loading'; + parent.texture = mockTexture; + + expect((parent as any).autosizer).toBeTruthy(); + }); + + it('should switch from texture to children autosize when texture is removed', () => { + const parent = new CoreNode(stage, defaultProps); + const child = new CoreNode(stage, defaultProps); + const mockTexture = mock(); + mockTexture.state = 'loading'; + + child.parent = parent; + parent.texture = mockTexture; + parent.autosize = true; + expect((parent as any).autosizer).toBeTruthy(); + + // Remove texture - should switch to children autosize + parent.texture = null; + expect((parent as any).autosizer).toBeTruthy(); + }); + + it('should cleanup autosize manager when disabled', () => { + const parent = new CoreNode(stage, defaultProps); + const child = new CoreNode(stage, defaultProps); + + child.parent = parent; + parent.autosize = true; + expect((parent as any).autosizer).toBeTruthy(); + + parent.autosize = false; + expect((parent as any).autosizer).toBeFalsy(); + }); + + it('should establish autosize chain when child is added to autosize parent', () => { + const parent = new CoreNode(stage, defaultProps); + const child = new CoreNode(stage, defaultProps); + + // Enable autosize BEFORE adding child + parent.autosize = true; + child.parent = parent; + + expect((child as any).parentAutosizer).toBe(parent.autosizer); + expect((parent as any).autosizer.childMap.size).toBe(1); + }); + + it('should remove from autosize chain when child is removed', () => { + const parent = new CoreNode(stage, defaultProps); + const child = new CoreNode(stage, defaultProps); + + // Enable autosize BEFORE adding child + parent.autosize = true; + child.parent = parent; + expect((parent as any).autosizer.childMap.size).toBe(1); + + child.parent = null; + expect((child as any).parentAutosizer).toBeNull(); + }); + }); }); diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts index b4ec494a..448e6fc6 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -57,6 +57,7 @@ import type { IAnimationController } from '../common/IAnimationController.js'; import { CoreAnimation } from './animations/CoreAnimation.js'; import { CoreAnimationController } from './animations/CoreAnimationController.js'; import type { CoreShaderNode } from './renderers/CoreShaderNode.js'; +import { AutosizeMode, Autosizer } from './Autosizer.js'; import { bucketSortByZIndex, incrementalRepositionByZIndex, @@ -187,6 +188,10 @@ export enum UpdateType { */ RecalcUniforms = 4096, + /** + * Autosize update + */ + Autosize = 8192, /** * None */ @@ -195,7 +200,7 @@ export enum UpdateType { /** * All */ - All = 7167, + All = 16383, } /** @@ -259,14 +264,35 @@ export interface CoreNodeProps { */ alpha: number; /** - * Autosize mode + * Autosize * * @remarks - * When enabled, when a texture is loaded into the Node, the Node will - * automatically resize to the dimensions of the texture. + * When enabled, the Node automatically resizes based on its content + * + * **Texture Autosize Mode:** + * - When the Node has a texture, it automatically resizes to match the + * texture's dimensions when the texture loads + * - This ensures images display at their natural size without manual sizing + * - Text Nodes always use this mode regardless of this setting + * + * **Children Autosize Mode:** + * - When the Node has no texture but contains children, it automatically + * resizes to encompass all children's bounds + * - Calculates the bounding box that contains all child positions, dimensions, + * and transforms (scale, rotation, mount/pivot points) + * - Creates container behavior where the parent grows to fit its content + * - Updates dynamically as children are added, removed, or transformed + * + * **Mode Selection Logic:** + * - Texture mode takes precedence over children mode + * - Mode switches automatically when texture is added/removed + * - If no texture and no children, autosize has no effect + * + * **Performance:** + * - Children mode uses efficient transform caching and differential updates + * - Only recalculates when child transforms actually change + * - Minimal memory allocation with factory function patterns * - * Text Nodes are always autosized based on their text content regardless - * of this mode setting. * * @default `false` */ @@ -759,6 +785,10 @@ export class CoreNode extends EventEmitter { */ public framebufferDimensions: Dimensions | null = null; + /**Autosize properties */ + autosizer: Autosizer | null = null; + parentAutosizer: Autosizer | null = null; + public destroyed = false; constructor(readonly stage: Stage, props: CoreNodeProps) { @@ -822,6 +852,7 @@ export class CoreNode extends EventEmitter { p.srcY = props.srcY; p.srcWidth = props.srcWidth; p.srcHeight = props.srcHeight; + p.autosize = props.autosize; p.parent = props.parent; p.texture = null; @@ -847,6 +878,11 @@ export class CoreNode extends EventEmitter { this.boundsMargin = props.boundsMargin; this.interactive = props.interactive; + // Initialize autosize if enabled + if (p.autosize === true) { + this.autosizer = new Autosizer(this); + } + this.setUpdateType(initialUpdateType); // if the default texture isn't loaded yet, wait for it to load @@ -877,7 +913,11 @@ export class CoreNode extends EventEmitter { * This method is called in a microtask to release the texture. */ private loadTextureTask = (): void => { - const texture = this.texture as Texture; + const texture = this.props.texture as Texture; + //it is possible that texture is null here if user sets the texture to null right after loadTexture call + if (texture === null) { + return; + } if (this.textureOptions.preload === true) { this.stage.txManager.loadTexture(texture); } @@ -919,9 +959,8 @@ export class CoreNode extends EventEmitter { } protected onTextureLoaded: TextureLoadedEventHandler = (_, dimensions) => { - if (this.autosize === true) { - this.w = dimensions.w; - this.h = dimensions.h; + if (this.autosizer !== null) { + this.autosizer.update(); } this.setUpdateType(UpdateType.IsRenderable); @@ -1094,6 +1133,12 @@ export class CoreNode extends EventEmitter { let updateType = this.updateType; let childUpdateType = this.childUpdateType; let updateParent = false; + + //this needs to be handled before setting updateTypes are reset + if (updateType & UpdateType.Autosize && this.autosizer !== null) { + this.autosizer.update(); + } + // reset update type this.updateType = 0; this.childUpdateType = 0; @@ -1148,12 +1193,14 @@ export class CoreNode extends EventEmitter { this.calculateRenderCoords(); this.updateBoundingRect(); - updateType |= - UpdateType.RenderState | - UpdateType.Children | - UpdateType.RecalcUniforms; + updateType |= UpdateType.RenderState | UpdateType.RecalcUniforms; updateParent = hasParent; - childUpdateType |= UpdateType.Global; + + //only propagate children updates if not autosizing + if ((updateType & UpdateType.Autosize) === 0) { + updateType |= UpdateType.Children; + childUpdateType |= UpdateType.Global; + } if (this.clipping === true) { updateType |= UpdateType.Clipping | UpdateType.RenderBounds; @@ -1198,6 +1245,15 @@ export class CoreNode extends EventEmitter { this.updateIsRenderable(); } + // Handle autosize updates when children transforms change + if ( + updateType & UpdateType.Global && + this.isRenderable === true && + this.parentAutosizer !== null + ) { + this.parentAutosizer.patch(this.id); + } + if (updateType & UpdateType.Clipping) { this.calculateClippingRect(parentClippingRect); updateType |= UpdateType.Children; @@ -1838,12 +1894,14 @@ export class CoreNode extends EventEmitter { } removeChild(node: CoreNode, targetParent: CoreNode | null = null) { - if ( - targetParent === null && - this.props.rtt === true && - this.parentHasRenderTexture === true - ) { - node.clearRTTInheritance(); + if (targetParent === null) { + if (this.props.rtt === true && this.parentHasRenderTexture === true) { + node.clearRTTInheritance(); + } + const autosizeTarget = this.autosizer || this.parentAutosizer; + if (autosizeTarget !== null) { + autosizeTarget.detach(node); + } } removeChild(node, this.children); } @@ -1855,6 +1913,8 @@ export class CoreNode extends EventEmitter { const min = this.zIndexMin; const max = this.zIndexMax; const zIndex = node.zIndex; + const autosizeTarget = this.autosizer || this.parentAutosizer; + let attachToAutosizer = autosizeTarget !== null; node.parentHasRenderTexture = inRttCluster; if (previousParent !== null) { @@ -1865,6 +1925,22 @@ export class CoreNode extends EventEmitter { // update child RTT status node.clearRTTInheritance(); } + const previousAutosizer = node.autosizer || node.parentAutosizer; + + if (previousAutosizer !== null) { + if ( + autosizeTarget === null || + previousAutosizer.id !== autosizeTarget.id + ) { + previousAutosizer.detach(node); + } + attachToAutosizer = false; + } + } + + if (attachToAutosizer === true) { + //if this is true, then the autosizer really exists + autosizeTarget!.attach(node); } if (inRttCluster === true) { @@ -2109,7 +2185,17 @@ export class CoreNode extends EventEmitter { } set autosize(value: boolean) { + if (this.props.autosize === value) { + return; + } + this.props.autosize = value; + + if (value === true && this.autosizer === null) { + this.autosizer = new Autosizer(this); + } else { + this.autosizer = null; + } } get boundsMargin(): number | [number, number, number, number] | null { @@ -2551,11 +2637,17 @@ export class CoreNode extends EventEmitter { const oldTexture = this.props.texture; if (oldTexture) { this.unloadTexture(); + if (this.autosizer !== null && value === null) { + this.autosizer.setMode(AutosizeMode.Children); // Set to children size mode + } } this.textureCoords = undefined; this.props.texture = value; if (value !== null) { + if (this.autosizer !== null) { + this.autosizer.setMode(AutosizeMode.Texture); // Set to texture size mode + } value.setRenderableOwner(this._id, this.isRenderable); this.loadTexture(); } diff --git a/src/core/lib/utils.ts b/src/core/lib/utils.ts index fdbc8272..02541dc9 100644 --- a/src/core/lib/utils.ts +++ b/src/core/lib/utils.ts @@ -97,6 +97,11 @@ export interface Bound { y2: number; } +export interface Coord { + x: number; + y: number; +} + export interface BoundWithValid extends Bound { valid: boolean; } diff --git a/visual-regression/certified-snapshots/chromium-ci/autosize-1.png b/visual-regression/certified-snapshots/chromium-ci/autosize-1.png new file mode 100644 index 00000000..7cf9b5d1 Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/autosize-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/autosize-2.png b/visual-regression/certified-snapshots/chromium-ci/autosize-2.png new file mode 100644 index 00000000..2eb17086 Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/autosize-2.png differ