diff --git a/examples/tests/clipping-margin.ts b/examples/tests/clipping-margin.ts new file mode 100644 index 0000000..10bd496 --- /dev/null +++ b/examples/tests/clipping-margin.ts @@ -0,0 +1,72 @@ +import type { ExampleSettings } from '../common/ExampleSettings.js'; +import robotImg from '../assets/robot/robot.png'; + +export async function automation(settings: ExampleSettings) { + await test(settings); + await settings.snapshot(); +} + +const SQUARE_SIZE = 200; +const PADDING = 40; + +/** + * Visual regression coverage for the `clipping: [top, right, bottom, left]` + * tuple form. Each green square is a clipping container at the same x/y/w/h; + * what differs is how far we let children spill beyond each side before the + * scissor clips them. + */ +export default async function test({ renderer, testRoot }: ExampleSettings) { + const cases: Array<{ + label: string; + margin: [number, number, number, number] | true; + }> = [ + { label: 'clipping: true', margin: true }, + { label: '[40, 0, 0, 0] (top only)', margin: [40, 0, 0, 0] }, + { label: '[0, 40, 0, 0] (right only)', margin: [0, 40, 0, 0] }, + { label: '[0, 0, 40, 0] (bottom only)', margin: [0, 0, 40, 0] }, + { label: '[0, 0, 0, 40] (left only)', margin: [0, 0, 0, 40] }, + { label: '[40, 40, 40, 40] (all sides)', margin: [40, 40, 40, 40] }, + { label: '[-20, -20, -20, -20] (inset)', margin: [-20, -20, -20, -20] }, + ]; + + let curX = 20; + const curY = 60; + + for (let i = 0; i < cases.length; i++) { + const c = cases[i]!; + + renderer.createTextNode({ + x: curX, + y: 20, + w: SQUARE_SIZE, + fontFamily: 'Ubuntu', + fontSize: 18, + color: 0xffffffff, + text: c.label, + parent: testRoot, + }); + + const clipContainer = renderer.createNode({ + x: curX, + y: curY, + w: SQUARE_SIZE, + h: SQUARE_SIZE, + color: 0x00ff00ff, + parent: testRoot, + clipping: c.margin, + }); + + // Child overflows the container on ALL sides so we can see which edges + // the margin opens up. + renderer.createNode({ + x: -60, + y: -60, + w: SQUARE_SIZE + 120, + h: SQUARE_SIZE + 120, + src: robotImg, + parent: clipContainer, + }); + + curX += SQUARE_SIZE + PADDING; + } +} diff --git a/src/core/CoreNode.test.ts b/src/core/CoreNode.test.ts index 6478aa3..dfce10a 100644 --- a/src/core/CoreNode.test.ts +++ b/src/core/CoreNode.test.ts @@ -971,4 +971,147 @@ describe('set color()', () => { expect(spyB).toHaveBeenCalledTimes(1); }); }); + + describe('clipping property', () => { + it('defaults to false', () => { + const node = new CoreNode(stage, defaultProps()); + expect(node.clipping).toBe(false); + }); + + it('accepts boolean true via setter', () => { + const node = new CoreNode(stage, defaultProps()); + node.clipping = true; + expect(node.clipping).toBe(true); + expect(node.props.clipping).toBe(true); + }); + + it('stores a [top, right, bottom, left] tuple as-is', () => { + const node = new CoreNode(stage, defaultProps()); + const tuple: [number, number, number, number] = [10, 20, 30, 40]; + node.clipping = tuple; + expect(node.props.clipping).toBe(tuple); + expect(node.clipping).toBe(tuple); + }); + + it('accepts negative margins (insets the clip rect)', () => { + const node = new CoreNode(stage, defaultProps()); + node.clipping = [-5, -5, -5, -5]; + expect(node.clipping).toEqual([-5, -5, -5, -5]); + }); + + it('clears margins when reassigned to a plain boolean', () => { + const node = new CoreNode(stage, defaultProps()); + node.clipping = [10, 20, 30, 40]; + node.clipping = true; + expect(node.clipping).toBe(true); + node.clipping = false; + expect(node.clipping).toBe(false); + }); + + it('short-circuits redundant writes of the same reference', () => { + const node = new CoreNode(stage, defaultProps()); + const tuple: [number, number, number, number] = [10, 20, 30, 40]; + node.clipping = tuple; + node.updateType = 0; + node.clipping = tuple; + expect(node.updateType).toBe(0); + }); + + it('schedules clipping + render-bounds updates when value changes', () => { + const node = new CoreNode(stage, defaultProps()); + node.updateType = 0; + node.clipping = [10, 20, 30, 40]; + expect(node.updateType & UpdateType.Clipping).toBeTruthy(); + expect(node.updateType & UpdateType.RenderBounds).toBeTruthy(); + }); + + it('expands the clipping rect outward by the configured margins', () => { + const parent = new CoreNode(stage, defaultProps()); + parent.globalTransform = Matrix3d.identity(); + parent.worldAlpha = 1; + + const node = new CoreNode(stage, defaultProps({ parent })); + node.alpha = 1; + node.x = 100; + node.y = 100; + node.w = 50; + node.h = 50; + node.clipping = [10, 20, 30, 40]; + + node.update(0, { x: 0, y: 0, w: 1000, h: 1000, valid: true }); + + // Expected: x = 100 - 40 = 60, y = 100 - 10 = 90, + // w = 50 + 40 + 20 = 110, h = 50 + 10 + 30 = 90 + expect(node.clippingRect.valid).toBe(true); + expect(node.clippingRect.x).toBe(60); + expect(node.clippingRect.y).toBe(90); + expect(node.clippingRect.w).toBe(110); + expect(node.clippingRect.h).toBe(90); + }); + + it('produces the unmodified node rect when clipping = true with no margins', () => { + const parent = new CoreNode(stage, defaultProps()); + parent.globalTransform = Matrix3d.identity(); + parent.worldAlpha = 1; + + const node = new CoreNode(stage, defaultProps({ parent })); + node.alpha = 1; + node.x = 25; + node.y = 35; + node.w = 50; + node.h = 60; + node.clipping = true; + + node.update(0, { x: 0, y: 0, w: 1000, h: 1000, valid: true }); + + expect(node.clippingRect.x).toBe(25); + expect(node.clippingRect.y).toBe(35); + expect(node.clippingRect.w).toBe(50); + expect(node.clippingRect.h).toBe(60); + }); + + it('still intersects with parent clipping rect when margins push beyond it', () => { + const parent = new CoreNode(stage, defaultProps()); + parent.globalTransform = Matrix3d.identity(); + parent.worldAlpha = 1; + + const node = new CoreNode(stage, defaultProps({ parent })); + node.alpha = 1; + node.x = 100; + node.y = 100; + node.w = 50; + node.h = 50; + // Margins try to extend the clip past the parent bounds: + node.clipping = [100, 100, 100, 100]; + + // Parent clip limits us to (0,0,200,200). + node.update(0, { x: 0, y: 0, w: 200, h: 200, valid: true }); + + expect(node.clippingRect.valid).toBe(true); + expect(node.clippingRect.x).toBe(0); + expect(node.clippingRect.y).toBe(0); + expect(node.clippingRect.w).toBe(200); + expect(node.clippingRect.h).toBe(200); + }); + + it('does not produce its own clip rect when the node is rotated, even with margins', () => { + const parent = new CoreNode(stage, defaultProps()); + parent.globalTransform = Matrix3d.identity(); + parent.worldAlpha = 1; + + const node = new CoreNode(stage, defaultProps({ parent })); + node.alpha = 1; + node.x = 100; + node.y = 100; + node.w = 50; + node.h = 50; + node.clipping = [10, 10, 10, 10]; + node.rotation = Math.PI / 4; + + // No parent clip rect to inherit — rotated nodes must skip their own clip. + node.update(0, { x: 0, y: 0, w: 0, h: 0, valid: false }); + + expect(node.clippingRect.valid).toBe(false); + }); + }); }); diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts index 213a5df..2ddc40b 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -295,6 +295,10 @@ export interface CoreNodeProps { * its descendants from overflowing outside of the Node's x/y/width/height * bounds. * + * Pass `true` to clip exactly to the Node's bounds, or pass a + * `[top, right, bottom, left]` tuple to expand the clip rectangle outward + * by the given pixel amounts on each side (negative values inset it). + * * For WebGL, clipping is implemented using the high-performance WebGL * operation scissor. As a consequence, clipping does not work for * non-rectangular areas. So, if the element is rotated @@ -305,7 +309,7 @@ export interface CoreNodeProps { * * @default `false` */ - clipping: boolean; + clipping: boolean | [number, number, number, number]; /** * The color of the Node. * @@ -1366,7 +1370,7 @@ export class CoreNode extends EventEmitter { childUpdateType |= UpdateType.Global; } - if (this.clipping === true) { + if (this.props.clipping !== false) { updateType |= UpdateType.Clipping | UpdateType.RenderBounds; childUpdateType |= UpdateType.RenderBounds; } @@ -1669,14 +1673,31 @@ export class CoreNode extends EventEmitter { } // clipping is enabled and we are in bounds create our own bounds - const { x, y, w, h } = this.props; + const { x, y, w, h, clipping } = this.props; // Pick the global transform if available, otherwise use the local transform // global transform is only available if the node in an RTT chain const { tx, ty } = this.sceneGlobalTransform || this.globalTransform || {}; const _x = tx ?? x; const _y = ty ?? y; - this.strictBound = createBound(_x, _y, _x + w, _y + h, this.strictBound); + + let mT = 0; + let mR = 0; + let mB = 0; + let mL = 0; + if (Array.isArray(clipping) === true) { + mT = clipping[0]; + mR = clipping[1]; + mB = clipping[2]; + mL = clipping[3]; + } + this.strictBound = createBound( + _x - mL, + _y - mT, + _x + w + mR, + _y + h + mB, + this.strictBound, + ); this.preloadBound = createPreloadBounds( this.strictBound, @@ -1916,11 +1937,21 @@ export class CoreNode extends EventEmitter { const { clipping } = props; const isRotated = gt!.tb !== 0 || gt!.tc !== 0; - if (clipping === true && isRotated === false) { - clippingRect.x = gt!.tx; - clippingRect.y = gt!.ty; - clippingRect.w = this.props.w * gt!.ta; - clippingRect.h = this.props.h * gt!.td; + if (clipping !== false && isRotated === false) { + let mT = 0; + let mR = 0; + let mB = 0; + let mL = 0; + if (Array.isArray(clipping) === true) { + mT = clipping[0]; + mR = clipping[1]; + mB = clipping[2]; + mL = clipping[3]; + } + clippingRect.x = gt!.tx - mL; + clippingRect.y = gt!.ty - mT; + clippingRect.w = this.props.w * gt!.ta + mL + mR; + clippingRect.h = this.props.h * gt!.td + mT + mB; clippingRect.valid = true; } else { clippingRect.valid = false; @@ -2421,11 +2452,14 @@ export class CoreNode extends EventEmitter { this.setUpdateType(UpdateType.RenderBounds); } - get clipping(): boolean { + get clipping(): boolean | [number, number, number, number] { return this.props.clipping; } - set clipping(value: boolean) { + set clipping(value: boolean | [number, number, number, number]) { + if (this.props.clipping === value) { + return; + } this.props.clipping = value; this.setUpdateType( UpdateType.Clipping | UpdateType.RenderBounds | UpdateType.Children, diff --git a/visual-regression/certified-snapshots/chromium-ci/clipping-margin-1.png b/visual-regression/certified-snapshots/chromium-ci/clipping-margin-1.png new file mode 100644 index 0000000..9553357 Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/clipping-margin-1.png differ