This commit is contained in:
2025-01-04 00:34:03 +01:00
parent 41829408dc
commit 0ca14bbc19
18111 changed files with 1871397 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
export {default as RegionGeometry} from "./geometry.mjs";
export {default as RegionMesh} from "./mesh.mjs";
export {default as RegionPolygonTree} from "./polygon-tree.mjs";
export {default as RegionShape} from "./shape.mjs";

View File

@@ -0,0 +1,65 @@
/**
* The geometry of a {@link Region}.
* - Vertex Attribute: `aVertexPosition` (`vec2`)
* - Draw Mode: `PIXI.DRAW_MODES.TRIANGLES`
*/
export default class RegionGeometry extends PIXI.Geometry {
/**
* Create a RegionGeometry.
* @param {Region} region The Region to create the RegionGeometry from.
* @internal
*/
constructor(region) {
super();
this.#region = region;
this.addAttribute("aVertexPosition", new PIXI.Buffer(new Float32Array(), true, false), 2);
this.addIndex(new PIXI.Buffer(new Uint16Array(), true, true));
}
/* -------------------------------------------- */
/**
* The Region this geometry belongs to.
* @type {Region}
*/
get region() {
return this.#region;
}
#region;
/* -------------------------------------------- */
/**
* Do the buffers need to be updated?
* @type {boolean}
*/
#invalidBuffers = true;
/* -------------------------------------------- */
/**
* Update the buffers.
* @internal
*/
_clearBuffers() {
this.buffers[0].update(new Float32Array());
this.indexBuffer.update(new Uint16Array());
this.#invalidBuffers = true;
}
/* -------------------------------------------- */
/**
* Update the buffers.
* @internal
*/
_updateBuffers() {
if ( !this.#invalidBuffers ) return;
const triangulation = this.region.triangulation;
this.buffers[0].update(triangulation.vertices);
this.indexBuffer.update(triangulation.indices);
this.#invalidBuffers = false;
}
}

View File

@@ -0,0 +1,213 @@
/**
* A mesh of a {@link Region}.
* @extends {PIXI.Container}
*/
export default class RegionMesh extends PIXI.Container {
/**
* Create a RegionMesh.
* @param {Region} region The Region to create the RegionMesh from.
* @param {AbstractBaseShader} [shaderClass] The shader class to use.
*/
constructor(region, shaderClass=RegionShader) {
super();
this.#region = region;
this.region.geometry.refCount++;
if ( !AbstractBaseShader.isPrototypeOf(shaderClass) ) {
throw new Error("RegionMesh shader class must inherit from AbstractBaseShader.");
}
this.#shader = shaderClass.create();
}
/* ---------------------------------------- */
/**
* Shared point instance.
* @type {PIXI.Point}
*/
static #SHARED_POINT = new PIXI.Point();
/* ---------------------------------------- */
/**
* The Region of this RegionMesh.
* @type {RegionMesh}
*/
get region() {
return this.#region;
}
#region;
/* ---------------------------------------- */
/**
* The shader bound to this RegionMesh.
* @type {AbstractBaseShader}
*/
get shader() {
return this.#shader;
}
#shader;
/* ---------------------------------------- */
/**
* The blend mode assigned to this RegionMesh.
* @type {PIXI.BLEND_MODES}
*/
get blendMode() {
return this.#state.blendMode;
}
set blendMode(value) {
if ( this.#state.blendMode === value ) return;
this.#state.blendMode = value;
this._tintAlphaDirty = true;
}
#state = PIXI.State.for2d();
/* ---------------------------------------- */
/**
* The tint applied to the mesh. This is a hex value.
*
* A value of 0xFFFFFF will remove any tint effect.
* @type {number}
* @defaultValue 0xFFFFFF
*/
get tint() {
return this._tintColor.value;
}
set tint(tint) {
const currentTint = this._tintColor.value;
this._tintColor.setValue(tint);
if ( currentTint === this._tintColor.value ) return;
this._tintAlphaDirty = true;
}
/* ---------------------------------------- */
/**
* The tint applied to the mesh. This is a hex value. A value of 0xFFFFFF will remove any tint effect.
* @type {PIXI.Color}
* @protected
*/
_tintColor = new PIXI.Color(0xFFFFFF);
/* ---------------------------------------- */
/**
* Cached tint value for the shader uniforms.
* @type {[red: number, green: number, blue: number, alpha: number]}
* @protected
* @internal
*/
_cachedTint = [1, 1, 1, 1];
/* ---------------------------------------- */
/**
* Used to track a tint or alpha change to execute a recomputation of _cachedTint.
* @type {boolean}
* @protected
*/
_tintAlphaDirty = true;
/* ---------------------------------------- */
/**
* Initialize shader based on the shader class type.
* @param {type AbstractBaseShader} shaderClass The shader class, which must inherit from {@link AbstractBaseShader}.
*/
setShaderClass(shaderClass) {
if ( !AbstractBaseShader.isPrototypeOf(shaderClass) ) {
throw new Error("RegionMesh shader class must inherit from AbstractBaseShader.");
}
if ( this.#shader.constructor === shaderClass ) return;
// Create shader program
this.#shader = shaderClass.create();
}
/* ---------------------------------------- */
/** @override */
updateTransform() {
super.updateTransform();
// We set tintAlphaDirty to true if the worldAlpha has changed
// It is needed to recompute the _cachedTint vec4 which is a combination of tint and alpha
if ( this.#worldAlpha !== this.worldAlpha ) {
this.#worldAlpha = this.worldAlpha;
this._tintAlphaDirty = true;
}
}
#worldAlpha;
/* ---------------------------------------- */
/** @override */
_render(renderer) {
if ( this._tintAlphaDirty ) {
const premultiply = PIXI.utils.premultiplyBlendMode[1][this.blendMode] === this.blendMode;
PIXI.Color.shared.setValue(this._tintColor)
.premultiply(this.worldAlpha, premultiply)
.toArray(this._cachedTint);
this._tintAlphaDirty = false;
}
this.#shader._preRender(this, renderer);
this.#shader.uniforms.translationMatrix = this.transform.worldTransform.toArray(true);
// Flush batch renderer
renderer.batch.flush();
// Set state
renderer.state.set(this.#state);
// Bind shader and geometry
renderer.shader.bind(this.#shader);
const geometry = this.region.geometry;
geometry._updateBuffers();
renderer.geometry.bind(geometry, this.#shader);
// Draw the geometry
renderer.geometry.draw(PIXI.DRAW_MODES.TRIANGLES);
}
/* ---------------------------------------- */
/** @override */
_calculateBounds() {
const {left, top, right, bottom} = this.region.bounds;
this._bounds.addFrame(this.transform, left, top, right, bottom);
}
/* ---------------------------------------- */
/**
* Tests if a point is inside this RegionMesh.
* @param {PIXI.IPointData} point
* @returns {boolean}
*/
containsPoint(point) {
return this.region.polygonTree.testPoint(this.worldTransform.applyInverse(point, RegionMesh.#SHARED_POINT));
}
/* ---------------------------------------- */
/** @override */
destroy(options) {
super.destroy(options);
const geometry = this.region.geometry;
geometry.refCount--;
if ( geometry.refCount === 0 ) geometry.dispose();
this.#shader = null;
this.#state = null;
}
}

View File

@@ -0,0 +1,298 @@
/**
* The node of a {@link RegionPolygonTree}.
*/
class RegionPolygonTreeNode {
/**
* Create a RegionPolygonTreeNode.
* @param {RegionPolygonTreeNode|null} parent The parent node.
* @internal
*/
constructor(parent) {
this.#parent = parent;
this.#children = [];
this.#depth = parent ? parent.depth + 1 : 0;
this.#isHole = this.#depth % 2 === 0;
if ( parent ) parent.#children.push(this);
else {
this.#polygon = null;
this.#clipperPath = null;
}
}
/* -------------------------------------------- */
/**
* Create a node from the Clipper path and add it to the children of the parent.
* @param {ClipperLib.IntPoint[]} clipperPath The clipper path of this node.
* @param {RegionPolygonTreeNode|null} parent The parent node or `null` if root.
* @internal
*/
static _fromClipperPath(clipperPath, parent) {
const node = new RegionPolygonTreeNode(parent);
if ( parent ) node.#clipperPath = clipperPath;
return node;
}
/* -------------------------------------------- */
/**
* The parent of this node or `null` if this is the root node.
* @type {RegionPolygonTreeNode|null}
*/
get parent() {
return this.#parent;
}
#parent;
/* -------------------------------------------- */
/**
* The children of this node.
* @type {ReadonlyArray<RegionPolygonTreeNode>}
*/
get children() {
return this.#children;
}
#children;
/* -------------------------------------------- */
/**
* The depth of this node.
* The depth of the root node is 0.
* @type {number}
*/
get depth() {
return this.#depth;
}
#depth;
/* -------------------------------------------- */
/**
* Is this a hole?
* The root node is a hole.
* @type {boolean}
*/
get isHole() {
return this.#isHole;
}
#isHole;
/* -------------------------------------------- */
/**
* The Clipper path of this node.
* It is empty in case of the root node.
* @type {ReadonlyArray<ClipperLib.IntPoint>|null}
*/
get clipperPath() {
return this.#clipperPath;
}
#clipperPath;
/* -------------------------------------------- */
/**
* The polygon of this node.
* It is `null` in case of the root node.
* @type {PIXI.Polygon|null}
*/
get polygon() {
let polygon = this.#polygon;
if ( polygon === undefined ) polygon = this.#polygon = this.#createPolygon();
return polygon;
}
#polygon;
/* -------------------------------------------- */
/**
* The points of the polygon ([x0, y0, x1, y1, ...]).
* They are `null` in case of the root node.
* @type {ReadonlyArray<number>|null}
*/
get points() {
const polygon = this.polygon;
if ( !polygon ) return null;
return polygon.points;
}
/* -------------------------------------------- */
/**
* The bounds of the polygon.
* They are `null` in case of the root node.
* @type {PIXI.Rectangle|null}
*/
get bounds() {
let bounds = this.#bounds;
if ( bounds === undefined ) bounds = this.#bounds = this.polygon?.getBounds() ?? null;
return bounds;
}
#bounds;
/* -------------------------------------------- */
/**
* Iterate over recursively over the children in depth-first order.
* @yields {RegionPolygonTreeNode}
*/
*[Symbol.iterator]() {
for ( const child of this.children ) {
yield child;
yield *child;
}
}
/* -------------------------------------------- */
/**
* Test whether given point is contained within this node.
* @param {Point} point The point.
* @returns {boolean}
*/
testPoint(point) {
return this.#testPoint(point) === 2;
}
/* -------------------------------------------- */
/**
* Test point containment.
* @param {Point} point The point.
* @returns {0|1|2} - 0: not contained within the polygon of this node.
* - 1: contained within the polygon of this node but also contained
* inside the polygon of a sub-node that is a hole.
* - 2: contained within the polygon of this node and not contained
* inside any polygon of a sub-node that is a hole.
*/
#testPoint(point) {
const {x, y} = point;
if ( this.parent ) {
if ( !this.bounds.contains(x, y) || !this.polygon.contains(x, y) ) return 0;
}
const children = this.children;
for ( let i = 0, n = children.length; i < n; i++ ) {
const result = children[i].#testPoint(point);
if ( result !== 0 ) return result;
}
return this.isHole ? 1 : 2;
}
/* -------------------------------------------- */
/**
* Test circle containment/intersection with this node.
* @param {Point} center The center point of the circle.
* @param {number} radius The radius of the circle.
* @returns {-1|0|1} - -1: the circle is in the exterior and does not intersect the boundary.
* - 0: the circle is intersects the boundary.
* - 1: the circle is in the interior and does not intersect the boundary.
*/
testCircle(center, radius) {
switch ( this.#testCircle(center, radius) ) {
case 2: return 1;
case 3: return 0;
default: return -1;
}
}
/* -------------------------------------------- */
/**
* Test circle containment/intersection with this node.
* @param {Point} center The center point of the circle.
* @param {number} radius The radius of the circle.
* @returns {0|1|2|3} - 0: does not intersect the boundary or interior of this node.
* - 1: contained within the polygon of this node but also contained
* inside the polygon of a sub-node that is a hole.
* - 2: contained within the polygon of this node and not contained
* inside any polygon of a sub-node that is a hole.
* - 3: intersects the boundary of this node or any sub-node.
*/
#testCircle(center, radius) {
if ( this.parent ) {
const {x, y} = center;
// Test whether the circle intersects the bounds of this node
const {left, right, top, bottom} = this.bounds;
if ( (x < left - radius) || (x > right + radius) || (y < top - radius) || (y > bottom + radius) ) return 0;
// Test whether the circle intersects any edge of the polygon of this node
const intersects = foundry.utils.pathCircleIntersects(this.points, true, center, radius);
if ( intersects ) return 3;
// Test whether the circle is completely outside of the polygon
const inside = this.polygon.contains(x, y);
if ( !inside ) return 0;
}
// Test the children of this node now that we know that the circle is
// completely inside of the polygon of this node
const children = this.children;
for ( let i = 0, n = children.length; i < n; i++ ) {
const result = children[i].#testCircle(center, radius);
if ( result !== 0 ) return result;
}
return this.isHole ? 1 : 2;
}
/* -------------------------------------------- */
/**
* Create the polygon of this node.
* @returns {PIXI.Polygon|null}
*/
#createPolygon() {
if ( !this.parent ) return null;
const scalingFactor = Region.CLIPPER_SCALING_FACTOR;
const polygon = PIXI.Polygon.fromClipperPoints(this.clipperPath, {scalingFactor});
polygon._isPositive = !this.isHole;
return polygon;
}
}
/* -------------------------------------------- */
/**
* The polygon tree of a {@link Region}.
*/
export default class RegionPolygonTree extends RegionPolygonTreeNode {
/**
* Create a RegionPolygonTree.
* @internal
*/
constructor() {
super(null);
}
/* -------------------------------------------- */
/**
* Create the tree from a Clipper polygon tree.
* @param {ClipperLib.PolyTree} clipperPolyTree
* @internal
*/
static _fromClipperPolyTree(clipperPolyTree) {
const visit = (clipperPolyNode, parent) => {
const clipperPath = clipperPolyNode.Contour();
const node = RegionPolygonTreeNode._fromClipperPath(clipperPath, parent);
clipperPolyNode.Childs().forEach(child => visit(child, node));
return node;
};
const tree = new RegionPolygonTree();
clipperPolyTree.Childs().forEach(child => visit(child, tree));
return tree;
}
}

View File

@@ -0,0 +1,371 @@
import {CircleShapeData, EllipseShapeData, PolygonShapeData, RectangleShapeData} from "../../data/_module.mjs";
/**
* A shape of a {@link Region}.
* @template {data.BaseShapeData} T
* @abstract
*/
export default class RegionShape {
/**
* Create a RegionShape.
* @param {T} data The shape data.
* @internal
*/
constructor(data) {
this.#data = data;
}
/* -------------------------------------------- */
/**
* Create the RegionShape from the shape data.
* @template {data.BaseShapeData} T
* @param {T} data The shape data.
* @returns {RegionShape<T>}
*/
static create(data) {
switch ( data.type ) {
case "circle": return new RegionCircle(data);
case "ellipse": return new RegionEllipse(data);
case "polygon": return new RegionPolygon(data);
case "rectangle": return new RegionRectangle(data);
default: throw new Error("Invalid shape type");
}
}
/* -------------------------------------------- */
/**
* The data of this shape.
* It is owned by the shape and must not be modified.
* @type {T}
*/
get data() {
return this.#data;
}
#data;
/* -------------------------------------------- */
/**
* Is this a hole?
* @type {boolean}
*/
get isHole() {
return this.data.hole;
}
/* -------------------------------------------- */
/**
* The Clipper paths of this shape.
* The winding numbers are 1 or 0.
* @type {ReadonlyArray<ReadonlyArray<ClipperLib.IntPoint>>}
*/
get clipperPaths() {
return this.#clipperPaths ??= ClipperLib.Clipper.PolyTreeToPaths(this.clipperPolyTree);
}
#clipperPaths;
/* -------------------------------------------- */
/**
* The Clipper polygon tree of this shape.
* @type {ClipperLib.PolyTree}
*/
get clipperPolyTree() {
let clipperPolyTree = this.#clipperPolyTree;
if ( !clipperPolyTree ) {
clipperPolyTree = this._createClipperPolyTree();
if ( Array.isArray(clipperPolyTree) ) {
const clipperPolyNode = new ClipperLib.PolyNode();
clipperPolyNode.m_polygon = clipperPolyTree;
clipperPolyTree = new ClipperLib.PolyTree();
clipperPolyTree.AddChild(clipperPolyNode);
clipperPolyTree.m_AllPolys.push(clipperPolyNode);
}
this.#clipperPolyTree = clipperPolyTree;
}
return clipperPolyTree;
}
#clipperPolyTree;
/* -------------------------------------------- */
/**
* Create the Clipper polygon tree of this shape.
* This function may return a single positively-orientated and non-selfintersecting Clipper path instead of a tree,
* which is automatically converted to a Clipper polygon tree.
* This function is called only once. It is not called if the shape is empty.
* @returns {ClipperLib.PolyTree|ClipperLib.IntPoint[]}
* @protected
* @abstract
*/
_createClipperPolyTree() {
throw new Error("A subclass of the RegionShape must implement the _createClipperPolyTree method.");
}
/* -------------------------------------------- */
/**
* Draw shape into the graphics.
* @param {PIXI.Graphics} graphics The graphics to draw the shape into.
* @protected
* @internal
*/
_drawShape(graphics) {
throw new Error("A subclass of the RegionShape must implement the _drawShape method.");
}
}
/* -------------------------------------------- */
/**
* A circle of a {@link Region}.
* @extends {RegionShape<data.CircleShapeData>}
*
* @param {data.CircleShapeData} data The shape data.
*/
class RegionCircle extends RegionShape {
constructor(data) {
if ( !(data instanceof CircleShapeData) ) throw new Error("Invalid shape data");
super(data);
}
/* -------------------------------------------- */
/**
* The vertex density epsilon used to create a polygon approximation of the circle.
* @type {number}
*/
static #VERTEX_DENSITY_EPSILON = 1;
/* -------------------------------------------- */
/** @override */
_createClipperPolyTree() {
const scalingFactor = Region.CLIPPER_SCALING_FACTOR;
const data = this.data;
const x = data.x * scalingFactor;
const y = data.y * scalingFactor;
const radius = data.radius * scalingFactor;
const epsilon = RegionCircle.#VERTEX_DENSITY_EPSILON * scalingFactor;
const density = PIXI.Circle.approximateVertexDensity(radius, epsilon);
const path = new Array(density);
for ( let i = 0; i < density; i++ ) {
const angle = 2 * Math.PI * (i / density);
path[i] = new ClipperLib.IntPoint(
Math.round(x + (Math.cos(angle) * radius)),
Math.round(y + (Math.sin(angle) * radius))
);
}
return path;
}
/* -------------------------------------------- */
/** @override */
_drawShape(graphics) {
const {x, y, radius} = this.data;
graphics.drawCircle(x, y, radius);
}
}
/* -------------------------------------------- */
/**
* An ellipse of a {@link Region}.
* @extends {RegionShape<data.EllipseShapeData>}
*
* @param {data.EllipseShapeData} data The shape data.
*/
class RegionEllipse extends RegionShape {
constructor(data) {
if ( !(data instanceof EllipseShapeData) ) throw new Error("Invalid shape data");
super(data);
}
/* -------------------------------------------- */
/**
* The vertex density epsilon used to create a polygon approximation of the circle.
* @type {number}
*/
static #VERTEX_DENSITY_EPSILON = 1;
/* -------------------------------------------- */
/** @override */
_createClipperPolyTree() {
const scalingFactor = Region.CLIPPER_SCALING_FACTOR;
const data = this.data;
const x = data.x * scalingFactor;
const y = data.y * scalingFactor;
const radiusX = data.radiusX * scalingFactor;
const radiusY = data.radiusY * scalingFactor;
const epsilon = RegionEllipse.#VERTEX_DENSITY_EPSILON * scalingFactor;
const density = PIXI.Circle.approximateVertexDensity((radiusX + radiusY) / 2, epsilon);
const rotation = Math.toRadians(data.rotation);
const cos = Math.cos(rotation);
const sin = Math.sin(rotation);
const path = new Array(density);
for ( let i = 0; i < density; i++ ) {
const angle = 2 * Math.PI * (i / density);
const dx = Math.cos(angle) * radiusX;
const dy = Math.sin(angle) * radiusY;
path[i] = new ClipperLib.IntPoint(
Math.round(x + ((cos * dx) - (sin * dy))),
Math.round(y + ((sin * dx) + (cos * dy)))
);
}
return path;
}
/* -------------------------------------------- */
/** @override */
_drawShape(graphics) {
const {x, y, radiusX, radiusY, rotation} = this.data;
if ( rotation === 0 ) {
graphics.drawEllipse(x, y, radiusX, radiusY);
} else {
graphics.setMatrix(new PIXI.Matrix()
.translate(-x, -x)
.rotate(Math.toRadians(rotation))
.translate(x, y));
graphics.drawEllipse(x, y, radiusX, radiusY);
graphics.setMatrix(null);
}
}
}
/* -------------------------------------------- */
/**
* A polygon of a {@link Region}.
* @extends {RegionShape<data.PolygonShapeData>}
*
* @param {data.PolygonShapeData} data The shape data.
*/
class RegionPolygon extends RegionShape {
constructor(data) {
if ( !(data instanceof PolygonShapeData) ) throw new Error("Invalid shape data");
super(data);
}
/* -------------------------------------------- */
/** @override */
_createClipperPolyTree() {
const scalingFactor = Region.CLIPPER_SCALING_FACTOR;
const points = this.data.points;
const path = new Array(points.length / 2);
for ( let i = 0, j = 0; i < path.length; i++ ) {
path[i] = new ClipperLib.IntPoint(
Math.round(points[j++] * scalingFactor),
Math.round(points[j++] * scalingFactor)
);
}
if ( !ClipperLib.Clipper.Orientation(path) ) path.reverse();
return path;
}
/* -------------------------------------------- */
/** @override */
_drawShape(graphics) {
graphics.drawPolygon(this.data.points);
}
}
/* -------------------------------------------- */
/**
* A rectangle of a {@link Region}.
* @extends {RegionShape<data.RectangleShapeData>}
*
* @param {data.RectangleShapeData} data The shape data.
*/
class RegionRectangle extends RegionShape {
constructor(data) {
if ( !(data instanceof RectangleShapeData) ) throw new Error("Invalid shape data");
super(data);
}
/* -------------------------------------------- */
/** @override */
_createClipperPolyTree() {
let p0;
let p1;
let p2;
let p3;
const scalingFactor = Region.CLIPPER_SCALING_FACTOR;
const {x, y, width, height, rotation} = this.data;
let x0 = x * scalingFactor;
let y0 = y * scalingFactor;
let x1 = (x + width) * scalingFactor;
let y1 = (y + height) * scalingFactor;
// The basic non-rotated case
if ( rotation === 0 ) {
x0 = Math.round(x0);
y0 = Math.round(y0);
x1 = Math.round(x1);
y1 = Math.round(y1);
p0 = new ClipperLib.IntPoint(x0, y0);
p1 = new ClipperLib.IntPoint(x1, y0);
p2 = new ClipperLib.IntPoint(x1, y1);
p3 = new ClipperLib.IntPoint(x0, y1);
}
// The more complex rotated case
else {
const tx = (x0 + x1) / 2;
const ty = (y0 + y1) / 2;
x0 -= tx;
y0 -= ty;
x1 -= tx;
y1 -= ty;
const angle = Math.toRadians(rotation);
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const x00 = Math.round((cos * x0) - (sin * y0) + tx);
const y00 = Math.round((sin * x0) + (cos * y0) + ty);
const x10 = Math.round((cos * x1) - (sin * y0) + tx);
const y10 = Math.round((sin * x1) + (cos * y0) + ty);
const x11 = Math.round((cos * x1) - (sin * y1) + tx);
const y11 = Math.round((sin * x1) + (cos * y1) + ty);
const x01 = Math.round((cos * x0) - (sin * y1) + tx);
const y01 = Math.round((sin * x0) + (cos * y1) + ty);
p0 = new ClipperLib.IntPoint(x00, y00);
p1 = new ClipperLib.IntPoint(x10, y10);
p2 = new ClipperLib.IntPoint(x11, y11);
p3 = new ClipperLib.IntPoint(x01, y01);
}
return [p0, p1, p2, p3];
}
/* -------------------------------------------- */
/** @override */
_drawShape(graphics) {
const {x, y, width, height, rotation} = this.data;
if ( rotation === 0 ) {
graphics.drawRect(x, y, width, height);
} else {
const centerX = x + (width / 2);
const centerY = y + (height / 2);
graphics.setMatrix(new PIXI.Matrix()
.translate(-centerX, -centerY)
.rotate(Math.toRadians(rotation))
.translate(centerX, centerY));
graphics.drawRect(x, y, width, height);
graphics.setMatrix(null);
}
}
}