UNPKG

three

Version:

JavaScript 3D library

356 lines (259 loc) 9.47 kB
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 * shapes. It is specifically used in context of fonts and SVG. */ class ShapePath { /** * Constructs a new shape path. */ constructor() { this.type = 'ShapePath'; /** * The color of the shape. * * @type {Color} */ this.color = new Color(); /** * The paths that have been generated for this shape. * * @type {Array<Path>} * @default null */ this.subPaths = []; /** * The current path that is being generated. * * @type {?Path} * @default null */ 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 = {}; } /** * Creates a new path and moves it current point to the given one. * * @param {number} x - The x coordinate. * @param {number} y - The y coordinate. * @return {ShapePath} A reference to this shape path. */ moveTo( x, y ) { this.currentPath = new Path(); this.subPaths.push( this.currentPath ); this.currentPath.moveTo( x, y ); return this; } /** * Adds an instance of {@link LineCurve} to the path by connecting * the current point with the given one. * * @param {number} x - The x coordinate of the end point. * @param {number} y - The y coordinate of the end point. * @return {ShapePath} A reference to this shape path. */ lineTo( x, y ) { this.currentPath.lineTo( x, y ); return this; } /** * Adds an instance of {@link QuadraticBezierCurve} to the path by connecting * the current point with the given one. * * @param {number} aCPx - The x coordinate of the control point. * @param {number} aCPy - The y coordinate of the control point. * @param {number} aX - The x coordinate of the end point. * @param {number} aY - The y coordinate of the end point. * @return {ShapePath} A reference to this shape path. */ quadraticCurveTo( aCPx, aCPy, aX, aY ) { this.currentPath.quadraticCurveTo( aCPx, aCPy, aX, aY ); return this; } /** * Adds an instance of {@link CubicBezierCurve} to the path by connecting * the current point with the given one. * * @param {number} aCP1x - The x coordinate of the first control point. * @param {number} aCP1y - The y coordinate of the first control point. * @param {number} aCP2x - The x coordinate of the second control point. * @param {number} aCP2y - The y coordinate of the second control point. * @param {number} aX - The x coordinate of the end point. * @param {number} aY - The y coordinate of the end point. * @return {ShapePath} A reference to this shape path. */ bezierCurveTo( aCP1x, aCP1y, aCP2x, aCP2y, aX, aY ) { this.currentPath.bezierCurveTo( aCP1x, aCP1y, aCP2x, aCP2y, aX, aY ); return this; } /** * Adds an instance of {@link SplineCurve} to the path by connecting * the current point with the given list of points. * * @param {Array<Vector2>} pts - An array of points in 2D space. * @return {ShapePath} A reference to this shape path. */ splineThru( pts ) { this.currentPath.splineThru( pts ); return this; } /** * Converts the paths into an array of shapes. * * @return {Array<Shape>} An array of shapes. */ toShapes() { // Point-in-polygon test using the even-odd ray-casting rule. Valid for // simple (non self-intersecting) polygons. function pointInPolygon( p, polygon ) { let inside = false; const n = polygon.length; for ( let i = 0, j = n - 1; i < n; j = i ++ ) { const a = polygon[ i ]; const b = polygon[ j ]; 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 ) { inside = ! inside; } } return inside; } // 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 point = boundingBox.getCenter( new Vector2() ); if ( pointInPolygon( point, polygon ) ) return point; const y = point.y; const intercepts = []; const n = polygon.length; for ( let i = 0; i < n; i ++ ) { const a = polygon[ i ]; const b = polygon[ ( i + 1 ) % n ]; // Half-open crossing rule — counts each vertex exactly once and // skips horizontal edges. if ( ( a.y > y ) !== ( b.y > y ) ) { const x = a.x + ( y - a.y ) * ( b.x - a.x ) / ( b.y - a.y ); intercepts.push( x ); } } if ( intercepts.length > 1 ) { intercepts.sort( ( a, b ) => a - b ); point.x = ( intercepts[ 0 ] + intercepts[ 1 ] ) / 2; } return point; } // Resolve fill-rule. Defaults to 'nonzero'. let fillRule = ( this.userData.style && this.userData.style.fillRule ) || 'nonzero'; if ( fillRule !== 'nonzero' && fillRule !== 'evenodd' ) { warn( 'Fill-rule "' + fillRule + '" is not supported, falling back to "nonzero".' ); fillRule = 'nonzero'; } // 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 } ); } // 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 ++ ) { const entry = entries[ i ]; let containerWinding = 0; for ( let j = i - 1; j >= 0; j -- ) { const candidate = entries[ j ]; if ( ! candidate.boundingBox.containsPoint( entry.interiorPoint ) ) continue; if ( ! pointInPolygon( entry.interiorPoint, candidate.points ) ) continue; entry.container = candidate.exclude ? candidate.container : candidate; containerWinding = candidate.winding; entry.winding += containerWinding; break; } if ( isInside( entry.winding ) === isInside( containerWinding ) ) { entry.exclude = true; } } // 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(); for ( const entry of entries ) { if ( entry.exclude || entry.role !== 'outer' ) continue; const shape = new Shape(); shape.curves = entry.subPath.curves; shapes.push( shape ); shapeByEntry.set( entry, shape ); } for ( const entry of entries ) { if ( entry.exclude || entry.role !== 'hole' ) continue; const shape = shapeByEntry.get( entry.container ); if ( ! shape ) continue; const hole = new Path(); hole.curves = entry.subPath.curves; shape.holes.push( hole ); } return shapes; } } export { ShapePath };