|
|
@@ -1,7 +1,10 @@
|
|
|
import { Color } from '../../math/Color.js';
|
|
|
+import { Box2 } from '../../math/Box2.js';
|
|
|
+import { Vector2 } from '../../math/Vector2.js';
|
|
|
import { Path } from './Path.js';
|
|
|
import { Shape } from './Shape.js';
|
|
|
import { ShapeUtils } from '../ShapeUtils.js';
|
|
|
+import { warn } from '../../utils.js';
|
|
|
|
|
|
/**
|
|
|
* This class is used to convert a series of paths to an array of
|
|
|
@@ -39,6 +42,14 @@ class ShapePath {
|
|
|
*/
|
|
|
this.currentPath = null;
|
|
|
|
|
|
+ /**
|
|
|
+ * An object that can be used to store custom data about the shape path.
|
|
|
+ * Mainly used by SVGLoader to store style information.
|
|
|
+ *
|
|
|
+ * @type {Object}
|
|
|
+ */
|
|
|
+ this.userData = {};
|
|
|
+
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -130,235 +141,212 @@ class ShapePath {
|
|
|
/**
|
|
|
* Converts the paths into an array of shapes.
|
|
|
*
|
|
|
- * @param {boolean} isCCW - By default solid shapes are defined clockwise (CW) and holes are defined counterclockwise (CCW).
|
|
|
- * If this flag is set to `true`, then those are flipped.
|
|
|
* @return {Array<Shape>} An array of shapes.
|
|
|
*/
|
|
|
- toShapes( isCCW ) {
|
|
|
-
|
|
|
- function toShapesNoHoles( inSubpaths ) {
|
|
|
-
|
|
|
- const shapes = [];
|
|
|
-
|
|
|
- for ( let i = 0, l = inSubpaths.length; i < l; i ++ ) {
|
|
|
-
|
|
|
- const tmpPath = inSubpaths[ i ];
|
|
|
-
|
|
|
- const tmpShape = new Shape();
|
|
|
- tmpShape.curves = tmpPath.curves;
|
|
|
-
|
|
|
- shapes.push( tmpShape );
|
|
|
-
|
|
|
- }
|
|
|
-
|
|
|
- return shapes;
|
|
|
-
|
|
|
- }
|
|
|
+ toShapes() {
|
|
|
|
|
|
- function isPointInsidePolygon( inPt, inPolygon ) {
|
|
|
+ // Point-in-polygon test using the even-odd ray-casting rule. Valid for
|
|
|
+ // simple (non self-intersecting) polygons.
|
|
|
+ function pointInPolygon( p, polygon ) {
|
|
|
|
|
|
- const polyLen = inPolygon.length;
|
|
|
-
|
|
|
- // inPt on polygon contour => immediate success or
|
|
|
- // toggling of inside/outside at every single! intersection point of an edge
|
|
|
- // with the horizontal line through inPt, left of inPt
|
|
|
- // not counting lowerY endpoints of edges and whole edges on that line
|
|
|
let inside = false;
|
|
|
- for ( let p = polyLen - 1, q = 0; q < polyLen; p = q ++ ) {
|
|
|
-
|
|
|
- let edgeLowPt = inPolygon[ p ];
|
|
|
- let edgeHighPt = inPolygon[ q ];
|
|
|
-
|
|
|
- let edgeDx = edgeHighPt.x - edgeLowPt.x;
|
|
|
- let edgeDy = edgeHighPt.y - edgeLowPt.y;
|
|
|
-
|
|
|
- if ( Math.abs( edgeDy ) > Number.EPSILON ) {
|
|
|
-
|
|
|
- // not parallel
|
|
|
- if ( edgeDy < 0 ) {
|
|
|
-
|
|
|
- edgeLowPt = inPolygon[ q ]; edgeDx = - edgeDx;
|
|
|
- edgeHighPt = inPolygon[ p ]; edgeDy = - edgeDy;
|
|
|
-
|
|
|
- }
|
|
|
+ const n = polygon.length;
|
|
|
|
|
|
- if ( ( inPt.y < edgeLowPt.y ) || ( inPt.y > edgeHighPt.y ) ) continue;
|
|
|
+ for ( let i = 0, j = n - 1; i < n; j = i ++ ) {
|
|
|
|
|
|
- if ( inPt.y === edgeLowPt.y ) {
|
|
|
+ const a = polygon[ i ];
|
|
|
+ const b = polygon[ j ];
|
|
|
|
|
|
- if ( inPt.x === edgeLowPt.x ) return true; // inPt is on contour ?
|
|
|
- // continue; // no intersection or edgeLowPt => doesn't count !!!
|
|
|
+ if ( ( a.y > p.y ) !== ( b.y > p.y ) &&
|
|
|
+ p.x < ( b.x - a.x ) * ( p.y - a.y ) / ( b.y - a.y ) + a.x ) {
|
|
|
|
|
|
- } else {
|
|
|
-
|
|
|
- const perpEdge = edgeDy * ( inPt.x - edgeLowPt.x ) - edgeDx * ( inPt.y - edgeLowPt.y );
|
|
|
- if ( perpEdge === 0 ) return true; // inPt is on contour ?
|
|
|
- if ( perpEdge < 0 ) continue;
|
|
|
- inside = ! inside; // true intersection left of inPt
|
|
|
-
|
|
|
- }
|
|
|
-
|
|
|
- } else {
|
|
|
-
|
|
|
- // parallel or collinear
|
|
|
- if ( inPt.y !== edgeLowPt.y ) continue; // parallel
|
|
|
- // edge lies on the same horizontal line as inPt
|
|
|
- if ( ( ( edgeHighPt.x <= inPt.x ) && ( inPt.x <= edgeLowPt.x ) ) ||
|
|
|
- ( ( edgeLowPt.x <= inPt.x ) && ( inPt.x <= edgeHighPt.x ) ) ) return true; // inPt: Point on contour !
|
|
|
- // continue;
|
|
|
+ inside = ! inside;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
- return inside;
|
|
|
+ return inside;
|
|
|
|
|
|
}
|
|
|
|
|
|
- const isClockWise = ShapeUtils.isClockWise;
|
|
|
+ // Returns a point guaranteed to be strictly inside the given simple
|
|
|
+ // polygon. First tries the bounding-box center; if that falls outside
|
|
|
+ // the polygon, casts a horizontal ray at the center's y and picks the
|
|
|
+ // midpoint between the first two sorted intercepts.
|
|
|
+ //
|
|
|
+ // Port of paper.js' Path#getInteriorPoint()
|
|
|
+ // https://github.com/paperjs/paper.js/blob/develop/src/path/PathItem.Boolean.js
|
|
|
+ function getInteriorPoint( polygon, boundingBox ) {
|
|
|
|
|
|
- const subPaths = this.subPaths;
|
|
|
- if ( subPaths.length === 0 ) return [];
|
|
|
+ const point = boundingBox.getCenter( new Vector2() );
|
|
|
|
|
|
- let solid, tmpPath, tmpShape;
|
|
|
- const shapes = [];
|
|
|
-
|
|
|
- if ( subPaths.length === 1 ) {
|
|
|
+ if ( pointInPolygon( point, polygon ) ) return point;
|
|
|
|
|
|
- tmpPath = subPaths[ 0 ];
|
|
|
- tmpShape = new Shape();
|
|
|
- tmpShape.curves = tmpPath.curves;
|
|
|
- shapes.push( tmpShape );
|
|
|
- return shapes;
|
|
|
+ const y = point.y;
|
|
|
+ const intercepts = [];
|
|
|
+ const n = polygon.length;
|
|
|
|
|
|
- }
|
|
|
+ for ( let i = 0; i < n; i ++ ) {
|
|
|
|
|
|
- let holesFirst = ! isClockWise( subPaths[ 0 ].getPoints() );
|
|
|
- holesFirst = isCCW ? ! holesFirst : holesFirst;
|
|
|
+ const a = polygon[ i ];
|
|
|
+ const b = polygon[ ( i + 1 ) % n ];
|
|
|
|
|
|
- // log("Holes first", holesFirst);
|
|
|
+ // Half-open crossing rule — counts each vertex exactly once and
|
|
|
+ // skips horizontal edges.
|
|
|
+ if ( ( a.y > y ) !== ( b.y > y ) ) {
|
|
|
|
|
|
- const betterShapeHoles = [];
|
|
|
- const newShapes = [];
|
|
|
- let newShapeHoles = [];
|
|
|
- let mainIdx = 0;
|
|
|
- let tmpPoints;
|
|
|
+ const x = a.x + ( y - a.y ) * ( b.x - a.x ) / ( b.y - a.y );
|
|
|
+ intercepts.push( x );
|
|
|
|
|
|
- newShapes[ mainIdx ] = undefined;
|
|
|
- newShapeHoles[ mainIdx ] = [];
|
|
|
+ }
|
|
|
|
|
|
- for ( let i = 0, l = subPaths.length; i < l; i ++ ) {
|
|
|
+ }
|
|
|
|
|
|
- tmpPath = subPaths[ i ];
|
|
|
- tmpPoints = tmpPath.getPoints();
|
|
|
- solid = isClockWise( tmpPoints );
|
|
|
- solid = isCCW ? ! solid : solid;
|
|
|
+ if ( intercepts.length > 1 ) {
|
|
|
|
|
|
- if ( solid ) {
|
|
|
+ intercepts.sort( ( a, b ) => a - b );
|
|
|
+ point.x = ( intercepts[ 0 ] + intercepts[ 1 ] ) / 2;
|
|
|
|
|
|
- if ( ( ! holesFirst ) && ( newShapes[ mainIdx ] ) ) mainIdx ++;
|
|
|
+ }
|
|
|
|
|
|
- newShapes[ mainIdx ] = { s: new Shape(), p: tmpPoints };
|
|
|
- newShapes[ mainIdx ].s.curves = tmpPath.curves;
|
|
|
+ return point;
|
|
|
|
|
|
- if ( holesFirst ) mainIdx ++;
|
|
|
- newShapeHoles[ mainIdx ] = [];
|
|
|
+ }
|
|
|
|
|
|
- //log('cw', i);
|
|
|
+ // Resolve fill-rule. Defaults to 'nonzero'.
|
|
|
+ let fillRule = ( this.userData.style && this.userData.style.fillRule ) || 'nonzero';
|
|
|
|
|
|
- } else {
|
|
|
+ if ( fillRule !== 'nonzero' && fillRule !== 'evenodd' ) {
|
|
|
|
|
|
- newShapeHoles[ mainIdx ].push( { h: tmpPath, p: tmpPoints[ 0 ] } );
|
|
|
+ warn( 'Fill-rule "' + fillRule + '" is not supported, falling back to "nonzero".' );
|
|
|
+ fillRule = 'nonzero';
|
|
|
|
|
|
- //log('ccw', i);
|
|
|
+ }
|
|
|
|
|
|
- }
|
|
|
+ // Predicate that decides whether a winding number falls inside the fill
|
|
|
+ // region, per the SVG fill-rule spec. Works for negative windings too,
|
|
|
+ // because JavaScript's bitwise AND preserves odd/even under two's
|
|
|
+ // complement.
|
|
|
+ const isInside = fillRule === 'nonzero'
|
|
|
+ ? ( w => w !== 0 )
|
|
|
+ : ( w => ( w & 1 ) !== 0 );
|
|
|
+
|
|
|
+ // Build an entry per usable subpath. Self-winding follows the standard
|
|
|
+ // convention used by ShapeUtils: counter-clockwise (signed area > 0)
|
|
|
+ // contributes +1 to the winding number at an interior point,
|
|
|
+ // clockwise contributes -1.
|
|
|
+ const entries = [];
|
|
|
+
|
|
|
+ for ( const subPath of this.subPaths ) {
|
|
|
+
|
|
|
+ const points = subPath.getPoints();
|
|
|
+ if ( points.length < 3 ) continue;
|
|
|
+
|
|
|
+ const area = ShapeUtils.area( points );
|
|
|
+ if ( area === 0 ) continue;
|
|
|
+
|
|
|
+ const boundingBox = new Box2();
|
|
|
+ for ( let i = 0; i < points.length; i ++ ) boundingBox.expandByPoint( points[ i ] );
|
|
|
+
|
|
|
+ entries.push( {
|
|
|
+ subPath: subPath,
|
|
|
+ points: points,
|
|
|
+ boundingBox: boundingBox,
|
|
|
+ interiorPoint: getInteriorPoint( points, boundingBox ),
|
|
|
+ absArea: Math.abs( area ),
|
|
|
+ winding: area < 0 ? - 1 : 1,
|
|
|
+ container: null,
|
|
|
+ exclude: false,
|
|
|
+ role: null
|
|
|
+ } );
|
|
|
|
|
|
}
|
|
|
|
|
|
- // only Holes? -> probably all Shapes with wrong orientation
|
|
|
- if ( ! newShapes[ 0 ] ) return toShapesNoHoles( subPaths );
|
|
|
+ // Sort by area descending. This guarantees that any subpath that could
|
|
|
+ // contain `entries[i]` is located at a smaller index and has already
|
|
|
+ // been processed when it's entries[i]'s turn. Port of paper.js'
|
|
|
+ // reorientPaths() algorithm.
|
|
|
+ entries.sort( ( a, b ) => b.absArea - a.absArea );
|
|
|
|
|
|
+ // Walk already-processed entries from closest-in-size to largest,
|
|
|
+ // stopping at the innermost container. Accumulate the container's
|
|
|
+ // cumulative winding into this entry's winding so that the final value
|
|
|
+ // equals the winding number at this entry's interior point.
|
|
|
+ //
|
|
|
+ // A subpath only contributes to the fill boundary when crossing it
|
|
|
+ // actually flips the "insideness" per the fill rule; otherwise it's a
|
|
|
+ // redundant overlap and gets excluded to avoid double-counting.
|
|
|
+ for ( let i = 0; i < entries.length; i ++ ) {
|
|
|
|
|
|
- if ( newShapes.length > 1 ) {
|
|
|
+ const entry = entries[ i ];
|
|
|
+ let containerWinding = 0;
|
|
|
|
|
|
- let ambiguous = false;
|
|
|
- let toChange = 0;
|
|
|
+ for ( let j = i - 1; j >= 0; j -- ) {
|
|
|
|
|
|
- for ( let sIdx = 0, sLen = newShapes.length; sIdx < sLen; sIdx ++ ) {
|
|
|
+ const candidate = entries[ j ];
|
|
|
+ if ( ! candidate.boundingBox.containsPoint( entry.interiorPoint ) ) continue;
|
|
|
+ if ( ! pointInPolygon( entry.interiorPoint, candidate.points ) ) continue;
|
|
|
|
|
|
- betterShapeHoles[ sIdx ] = [];
|
|
|
+ entry.container = candidate.exclude ? candidate.container : candidate;
|
|
|
+ containerWinding = candidate.winding;
|
|
|
+ entry.winding += containerWinding;
|
|
|
+ break;
|
|
|
|
|
|
}
|
|
|
|
|
|
- for ( let sIdx = 0, sLen = newShapes.length; sIdx < sLen; sIdx ++ ) {
|
|
|
-
|
|
|
- const sho = newShapeHoles[ sIdx ];
|
|
|
-
|
|
|
- for ( let hIdx = 0; hIdx < sho.length; hIdx ++ ) {
|
|
|
-
|
|
|
- const ho = sho[ hIdx ];
|
|
|
- let hole_unassigned = true;
|
|
|
-
|
|
|
- for ( let s2Idx = 0; s2Idx < newShapes.length; s2Idx ++ ) {
|
|
|
-
|
|
|
- if ( isPointInsidePolygon( ho.p, newShapes[ s2Idx ].p ) ) {
|
|
|
-
|
|
|
- if ( sIdx !== s2Idx ) toChange ++;
|
|
|
-
|
|
|
- if ( hole_unassigned ) {
|
|
|
-
|
|
|
- hole_unassigned = false;
|
|
|
- betterShapeHoles[ s2Idx ].push( ho );
|
|
|
-
|
|
|
- } else {
|
|
|
+ if ( isInside( entry.winding ) === isInside( containerWinding ) ) {
|
|
|
|
|
|
- ambiguous = true;
|
|
|
+ entry.exclude = true;
|
|
|
|
|
|
- }
|
|
|
-
|
|
|
- }
|
|
|
-
|
|
|
- }
|
|
|
+ }
|
|
|
|
|
|
- if ( hole_unassigned ) {
|
|
|
+ }
|
|
|
|
|
|
- betterShapeHoles[ sIdx ].push( ho );
|
|
|
+ // Classify retained entries. An entry is an outer shape if it has no
|
|
|
+ // container or if its container is itself a hole (a solid nested inside
|
|
|
+ // a hole becomes a new top-level shape); otherwise it's a hole in its
|
|
|
+ // container. Entries were already sorted outermost-first, so each
|
|
|
+ // container's role is known by the time we look at it.
|
|
|
+ for ( const entry of entries ) {
|
|
|
|
|
|
- }
|
|
|
+ if ( entry.exclude ) continue;
|
|
|
+ entry.role = ( entry.container === null || entry.container.role === 'hole' ) ? 'outer' : 'hole';
|
|
|
|
|
|
- }
|
|
|
+ }
|
|
|
|
|
|
- }
|
|
|
+ // Build Shapes for outers first, then attach holes to their container's
|
|
|
+ // Shape.
|
|
|
+ const shapes = [];
|
|
|
+ const shapeByEntry = new Map();
|
|
|
|
|
|
- if ( toChange > 0 && ambiguous === false ) {
|
|
|
+ for ( const entry of entries ) {
|
|
|
|
|
|
- newShapeHoles = betterShapeHoles;
|
|
|
+ if ( entry.exclude || entry.role !== 'outer' ) continue;
|
|
|
|
|
|
- }
|
|
|
+ const shape = new Shape();
|
|
|
+ shape.curves = entry.subPath.curves;
|
|
|
+ shapes.push( shape );
|
|
|
+ shapeByEntry.set( entry, shape );
|
|
|
|
|
|
}
|
|
|
|
|
|
- let tmpHoles;
|
|
|
+ for ( const entry of entries ) {
|
|
|
|
|
|
- for ( let i = 0, il = newShapes.length; i < il; i ++ ) {
|
|
|
+ if ( entry.exclude || entry.role !== 'hole' ) continue;
|
|
|
|
|
|
- tmpShape = newShapes[ i ].s;
|
|
|
- shapes.push( tmpShape );
|
|
|
- tmpHoles = newShapeHoles[ i ];
|
|
|
+ const shape = shapeByEntry.get( entry.container );
|
|
|
+ if ( ! shape ) continue;
|
|
|
|
|
|
- for ( let j = 0, jl = tmpHoles.length; j < jl; j ++ ) {
|
|
|
-
|
|
|
- tmpShape.holes.push( tmpHoles[ j ].h );
|
|
|
-
|
|
|
- }
|
|
|
+ const hole = new Path();
|
|
|
+ hole.curves = entry.subPath.curves;
|
|
|
+ shape.holes.push( hole );
|
|
|
|
|
|
}
|
|
|
|
|
|
- //log("shape", shapes);
|
|
|
-
|
|
|
return shapes;
|
|
|
|
|
|
+
|
|
|
}
|
|
|
|
|
|
}
|