Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions examples/tests/clipping-margin.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
143 changes: 143 additions & 0 deletions src/core/CoreNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
56 changes: 45 additions & 11 deletions src/core/CoreNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -305,7 +309,7 @@ export interface CoreNodeProps {
*
* @default `false`
*/
clipping: boolean;
clipping: boolean | [number, number, number, number];
/**
* The color of the Node.
*
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading