From c1407c3970bca6a61e6d7385c18760c3e630be9b Mon Sep 17 00:00:00 2001 From: Patrick Walton Date: Fri, 30 Nov 2018 15:42:19 -0800 Subject: [PATCH] WIP --- demo2/geometry.ts | 57 ++++++- demo2/pathfinder.ts | 42 +++-- demo2/tiling.ts | 396 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 470 insertions(+), 25 deletions(-) diff --git a/demo2/geometry.ts b/demo2/geometry.ts index 4986b6b8..aebe0fa9 100644 --- a/demo2/geometry.ts +++ b/demo2/geometry.ts @@ -17,6 +17,7 @@ export class Point2D { constructor(x: number, y: number) { this.x = x; this.y = y; + Object.freeze(this); } approxEq(other: Point2D): boolean { @@ -33,9 +34,55 @@ export interface Size2D { height: number; } -export interface Rect { +export class Rect { origin: Point2D; size: Size2D; + + constructor(origin: Point2D, size: Size2D) { + this.origin = origin; + this.size = size; + Object.freeze(this); + } + + unionWithPoint(point: Point2D): Rect { + let newOrigin = this.origin, newSize = this.size; + + if (point.x < this.origin.x) { + newSize = { + width: newSize.width + newOrigin.x - point.x, + height: newSize.height, + }; + newOrigin = new Point2D(point.x, newOrigin.y); + } else if (point.x > this.maxX()) { + newSize = { + width: newSize.width + point.x - this.maxX(), + height: newSize.height, + }; + } + + if (point.y < this.origin.y) { + newSize = { + width: newSize.width, + height: newSize.height + newOrigin.y - point.y, + }; + newOrigin = new Point2D(newOrigin.x, point.y); + } else if (point.y > this.maxY()) { + newSize = { + width: newSize.width, + height: newSize.height + point.y - this.maxY(), + }; + } + + return new Rect(newOrigin, newSize); + } + + maxX(): number { + return this.origin.x + this.size.width; + } + + maxY(): number { + return this.origin.y + this.size.height; + } } export interface Vector3D { @@ -63,3 +110,11 @@ export function approxEq(a: number, b: number): boolean { export function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; } + +export function cross(a: Vector3D, b: Vector3D): Vector3D { + return { + x: a.y*b.z - a.z*b.y, + y: a.z*b.x - a.x*b.z, + z: a.x*b.y - a.y*b.x, + }; +} diff --git a/demo2/pathfinder.ts b/demo2/pathfinder.ts index e9eb65aa..9abd0cba 100644 --- a/demo2/pathfinder.ts +++ b/demo2/pathfinder.ts @@ -14,8 +14,8 @@ import STENCIL_VERTEX_SHADER_SOURCE from "./stencil.vs.glsl"; import STENCIL_FRAGMENT_SHADER_SOURCE from "./stencil.fs.glsl"; import SVG from "../resources/svg/Ghostscript_Tiger.svg"; import AREA_LUT from "../resources/textures/area-lut.png"; -import {Matrix2D, Point2D, Rect, Size2D, Vector3D, approxEq, lerp} from "./geometry"; -import {SVGPath, Tiler} from "./tiling"; +import {Matrix2D, Point2D, Rect, Size2D, Vector3D, approxEq, cross, lerp} from "./geometry"; +import {SVGPath, TILE_SIZE, TileDebugger, Tiler, testIntervals} from "./tiling"; import {staticCast, unwrapNull} from "./util"; const SVGPath: (path: string) => SVGPath = require('svgpath'); @@ -23,7 +23,6 @@ const parseColor: (color: string) => any = require('parse-color'); const SVG_NS: string = "http://www.w3.org/2000/svg"; -const TILE_SIZE: Size2D = {width: 32.0, height: 32.0}; const STENCIL_FRAMEBUFFER_SIZE: Size2D = { width: TILE_SIZE.width * 256, height: TILE_SIZE.height * 256, @@ -417,7 +416,9 @@ class Scene { const pathElements = Array.from(document.getElementsByTagName('path')); const tiles: Tile[] = [], pathColors = []; - for (let pathElementIndex = 0; + const tileDebugger = new TileDebugger(document); + + for (let pathElementIndex = 0, realPathIndex = 0; pathElementIndex < pathElements.length; pathElementIndex++) { const pathElement = pathElements[pathElementIndex]; @@ -450,7 +451,15 @@ class Scene { path = flattenPath(path); path = canonicalizePath(path); - const tiler = new Tiler(path); + realPathIndex++; + + //if (realPathIndex === 73) { + //console.log("path", pathElementIndex, "svg path", path); + const tiler = new Tiler(path); + tiler.tile(); + tileDebugger.addTiler(tiler, paint); + console.log("path", pathElementIndex, "tiles", tiler.getTileStrips()); + //} const boundingRect = this.boundingRectOfPath(path); @@ -464,7 +473,7 @@ class Scene { while (true) { let x = boundingRect.origin.x - boundingRect.origin.x % TILE_SIZE.width; while (true) { - const tileBounds = {origin: new Point2D(x, y), size: TILE_SIZE}; + const tileBounds = new Rect(new Point2D(x, y), TILE_SIZE); const tilePath = this.clipPathToRect(path, tileBounds); if (tilePath.toString().length > 0) { @@ -518,6 +527,13 @@ class Scene { document.body.removeChild(svgElement); + const svgContainer = document.createElement('div'); + svgContainer.style.position = 'relative'; + svgContainer.style.width = "2000px"; + svgContainer.style.height = "2000px"; + svgContainer.appendChild(tileDebugger.svg); + document.body.appendChild(svgContainer); + console.log(tiles); this.tiles = tiles; this.pathColors = pathColors; @@ -537,8 +553,8 @@ class Scene { } }); if (minX == null || minY == null || maxX == null || maxY == null) - return {origin: new Point2D(0, 0), size: {width: 0, height: 0}}; - return {origin: new Point2D(minX, minY), size: {width: maxX - minX, height: maxY - minY}}; + return new Rect(new Point2D(0, 0), {width: 0, height: 0}); + return new Rect(new Point2D(minX, minY), {width: maxX - minX, height: maxY - minY}); } private clipPathToRect(path: SVGPath, tileBounds: Rect): SVGPath { @@ -772,14 +788,6 @@ function canonicalizePath(path: SVGPath): SVGPath { }); } -function cross(a: Vector3D, b: Vector3D): Vector3D { - return { - x: a.y*b.z - a.z*b.y, - y: a.z*b.x - a.x*b.z, - z: a.x*b.y - a.y*b.x, - }; -} - function waitForQuery(gl: WebGL2RenderingContext, disjointTimerQueryExt: any, query: WebGLQuery): void { const queryResultAvailable = disjointTimerQueryExt.QUERY_RESULT_AVAILABLE_EXT; @@ -812,6 +820,8 @@ function sampleBezier(from: Point2D, ctrl: Point2D, to: Point2D, t: number): Poi function main(): void { window.fetch(SVG).then(svg => { svg.text().then(svgText => { + testIntervals(); + const svg = staticCast((new DOMParser).parseFromString(svgText, 'image/svg+xml'), XMLDocument); const image = new Image; diff --git a/demo2/tiling.ts b/demo2/tiling.ts index 3ef5dc32..622e6207 100644 --- a/demo2/tiling.ts +++ b/demo2/tiling.ts @@ -8,8 +8,10 @@ // option. This file may not be copied, modified, or distributed // except according to those terms. -import {Point2D} from "./geometry"; -import {panic, unwrapNull} from "./util"; +import {Point2D, Rect, Size2D, cross} from "./geometry"; +import {panic, staticCast, unwrapNull} from "./util"; + +export const TILE_SIZE: Size2D = {width: 32.0, height: 32.0}; export interface SVGPath { abs(): SVGPath; @@ -29,16 +31,20 @@ interface EndpointIndex { export class Tiler { private path: SVGPath; private endpoints: SubpathEndpoints[]; - private sortedEndpointIndices: EndpointIndex[]; + private sortedEdges: Edge[]; + private boundingRect: Rect | null; + private tileStrips: TileStrip[]; constructor(path: SVGPath) { this.path = path; + //console.log("tiler: path=", path); // Accumulate endpoints. this.endpoints = []; let currentSubpathEndpoints = new SubpathEndpoints; path.iterate(segString => { const segment = new PathSegment(segString); + //console.log("segment", segment); switch (segment.command) { case 'M': if (!currentSubpathEndpoints.isEmpty()) { @@ -64,24 +70,158 @@ export class Tiler { if (!currentSubpathEndpoints.isEmpty()) this.endpoints.push(currentSubpathEndpoints); - // Sort endpoints. - this.sortedEndpointIndices = []; + // Sort edges, and accumulate bounding rect. + this.sortedEdges = []; + this.boundingRect = null; for (let subpathIndex = 0; subpathIndex < this.endpoints.length; subpathIndex++) { const subpathEndpoints = this.endpoints[subpathIndex]; for (let endpointIndex = 0; endpointIndex < subpathEndpoints.endpoints.length; endpointIndex++) { - this.sortedEndpointIndices.push({subpathIndex, endpointIndex}); + this.sortedEdges.push(this.nextEdgeFromEndpoint({subpathIndex, endpointIndex})); + + const endpoint = subpathEndpoints.endpoints[endpointIndex]; + if (this.boundingRect == null) + this.boundingRect = new Rect(endpoint, {width: 0, height: 0}); + else + this.boundingRect.unionWithPoint(endpoint); } } + this.sortedEdges.sort((edgeA, edgeB) => { + return Math.min(edgeA.from.y, edgeA.to.y) - Math.min(edgeB.from.y, edgeB.to.y); + }); + + /* + // Dump endpoints. + const allEndpoints = this.sortedEndpointIndices.map(index => { + return { + index: index, + endpoint: this.endpoints[index.subpathIndex].endpoints[index.endpointIndex], + }; + }); + //console.log("allEndpoints", allEndpoints); + */ + + this.tileStrips = []; } tile(): void { - const activeEdges = []; - for (const endpointIndex of this.sortedEndpointIndices) { + if (this.boundingRect == null) + return; + const activeIntervals = new Intervals(this.boundingRect.maxY());; + let activeEdges: Edge[] = []; + let nextEdgeIndex = 0; + this.tileStrips = []; + + let tileTop = this.boundingRect.origin.y - this.boundingRect.origin.y % TILE_SIZE.height; + while (tileTop < this.boundingRect.maxY()) { + const tileBottom = tileTop + TILE_SIZE.height; + + // Populate tile strip with active intervals. + const tileStrip = new TileStrip(tileTop); + for (const interval of activeIntervals.intervalRanges()) { + if (interval.winding === 0) + continue; + const startPoint = new Point2D(interval.start, tileTop); + const endPoint = new Point2D(interval.end, tileTop); + if (interval.winding < 0) + tileStrip.pushEdge(new Edge(startPoint, endPoint)); + else + tileStrip.pushEdge(new Edge(endPoint, startPoint)); + } + + // Populate tile strip with active edges. + const oldEdges = activeEdges; + activeEdges = []; + for (const activeEdge of oldEdges) + this.processEdge(activeEdge, tileStrip, activeEdges, activeIntervals, tileTop); + + while (nextEdgeIndex < this.sortedEdges.length) { + const edge = this.sortedEdges[nextEdgeIndex]; + if (edge.from.y > tileBottom && edge.to.y > tileBottom) + break; + + this.processEdge(edge, tileStrip, activeEdges, activeIntervals, tileTop); + nextEdgeIndex++; + } + + this.tileStrips.push(tileStrip); + tileTop = tileBottom; } } + + getTileStrips(): TileStrip[] { + return this.tileStrips; + } + + getBoundingRect(): Rect { + if (this.boundingRect == null) + return new Rect(new Point2D(0, 0), {width: 0, height: 0}); + + const tileLeft = this.boundingRect.origin.x - this.boundingRect.origin.x % TILE_SIZE.width; + const tileRight = Math.ceil(this.boundingRect.maxX() / TILE_SIZE.width) * TILE_SIZE.width; + const tileTop = this.boundingRect.origin.y - this.boundingRect.origin.y % TILE_SIZE.height; + const tileBottom = Math.ceil(this.boundingRect.maxY() / TILE_SIZE.height) * + TILE_SIZE.height; + return new Rect(new Point2D(tileLeft, tileTop), + {width: tileRight - tileLeft, height: tileBottom - tileTop}); + } + + private processEdge(edge: Edge, + tileStrip: TileStrip, + activeEdges: Edge[], + intervals: Intervals, + tileTop: number): + void { + const tileBottom = tileTop + TILE_SIZE.height; + const clipped = this.clipEdgeY(edge, tileBottom); + + if (clipped.upper != null) { + tileStrip.pushEdge(clipped.upper); + + if (edge.from.x <= edge.to.x) + intervals.add(new IntervalRange(edge.from.x, edge.to.x, 1)); + else + intervals.add(new IntervalRange(edge.to.x, edge.from.x, -1)); + } + + if (clipped.lower != null) + activeEdges.push(clipped.lower); + } + + private clipEdgeY(edge: Edge, y: number): ClippedEdgesY { + if (edge.from.y < y && edge.to.y < y) + return {upper: edge, lower: null}; + if (edge.from.y > y && edge.to.y > y) + return {upper: null, lower: edge}; + + const from = {x: edge.from.x, y: edge.from.y, z: 1.0}; + const to = {x: edge.to.x, y: edge.to.y, z: 1.0}; + const clipLine = {x: 0.0, y: 1.0, z: -y }; + + const intersectionHC = cross(cross(from, to), clipLine); + const intersection = new Point2D(intersectionHC.x / intersectionHC.z, + intersectionHC.y / intersectionHC.z); + const fromEdge = new Edge(edge.from, intersection); + const toEdge = new Edge(intersection, edge.to); + + if (edge.from.y < y) + return {upper: fromEdge, lower: toEdge}; + return {upper: toEdge, lower: fromEdge}; + } + + private prevEdgeFromEndpoint(endpointIndex: EndpointIndex): Edge { + const subpathEndpoints = this.endpoints[endpointIndex.subpathIndex]; + return new Edge(subpathEndpoints.prevEndpointOf(endpointIndex.endpointIndex), + subpathEndpoints.endpoints[endpointIndex.endpointIndex]); + } + + private nextEdgeFromEndpoint(endpointIndex: EndpointIndex): Edge { + const subpathEndpoints = this.endpoints[endpointIndex.subpathIndex]; + return new Edge(subpathEndpoints.endpoints[endpointIndex.endpointIndex], + subpathEndpoints.nextEndpointOf(endpointIndex.endpointIndex)); + } } class SubpathEndpoints { @@ -123,6 +263,7 @@ export class PathSegment { for (let i = 1; i < segment.length; i += 2) points.push(new Point2D(parseFloat(segment[i]), parseFloat(segment[i + 1]))); this.points = points; + //console.log("PathSegment, segment=", segment, "points=", points); this.command = segment[0]; } @@ -130,3 +271,242 @@ export class PathSegment { return this.points[this.points.length - 1]; } } + +class Edge { + from: Point2D; + to: Point2D; + + constructor(from: Point2D, to: Point2D) { + this.from = from; + this.to = to; + Object.freeze(this); + } +} + +class TileStrip { + edges: Edge[]; + tileTop: number; + + constructor(tileTop: number) { + this.edges = []; + this.tileTop = tileTop; + } + + pushEdge(edge: Edge): void { + this.edges.push(edge); + } + + tileBottom(): number { + return this.tileTop + TILE_SIZE.height; + } +} + +interface ClippedEdgesY { + upper: Edge | null; + lower: Edge | null; +} + +class Intervals { + private ranges: IntervalRange[]; + + constructor(width: number) { + this.ranges = [new IntervalRange(0, width, 0)]; + } + + intervalRanges(): IntervalRange[] { + return this.ranges; + } + + add(range: IntervalRange): void { + this.splitAt(range.start); + this.splitAt(range.end); + + let startIndex = this.ranges.length, endIndex = this.ranges.length; + for (let i = 0; i < this.ranges.length; i++) { + if (range.start === this.ranges[i].start) + startIndex = i; + if (range.end === this.ranges[i].end) + endIndex = i + 1; + } + + // Adjust winding numbers. + for (let i = startIndex; i < endIndex; i++) + this.ranges[i].winding += range.winding; + + this.mergeAdjacent(); + } + + clear(): void { + this.ranges = [new IntervalRange(0, this.ranges[this.ranges.length - 1].end, 0)]; + } + + private splitAt(value: number): void { + for (let i = 0; i < this.ranges.length; i++) { + if (this.ranges[i].start < value && value < this.ranges[i].end) { + const firstRange = this.ranges[i]; + const secondRange = new IntervalRange(value, firstRange.end, firstRange.winding); + this.ranges.splice(i + 1, 0, secondRange); + firstRange.end = value; + break; + } + } + } + + private mergeAdjacent(): void { + let i = 0; + while (i + 1 < this.ranges.length) { + if (this.ranges[i].end === this.ranges[i + 1].start && + this.ranges[i].winding === this.ranges[i + 1].winding) { + this.ranges[i].end = this.ranges[i + 1].end; + this.ranges.splice(i + 1, 1); + continue; + } + i++; + } + } +} + +class IntervalRange { + start: number; + end: number; + winding: number; + + constructor(start: number, end: number, winding: number) { + this.start = start; + this.end = end; + this.winding = winding; + } + + contains(value: number): boolean { + return value >= this.start && value < this.end; + } +} + +// Debugging + +const SVG_NS: string = "http://www.w3.org/2000/svg"; + +export class TileDebugger { + svg: SVGElement; + size: Size2D; + + constructor(document: HTMLDocument) { + this.svg = staticCast(document.createElementNS(SVG_NS, 'svg'), SVGElement); + + this.size = {width: 0, height: 0}; + + this.svg.style.position = 'absolute'; + this.svg.style.left = "0"; + this.svg.style.top = "0"; + this.updateSVGSize(); + } + + addTiler(tiler: Tiler, fillColor: string): void { + const boundingRect = tiler.getBoundingRect(); + this.size.width = Math.max(this.size.width, boundingRect.maxX()); + this.size.height = Math.max(this.size.height, boundingRect.maxY()); + + for (const tileStrip of tiler.getTileStrips()) { + const tileBottom = tileStrip.tileBottom(); + let path = ""; + for (const edge of tileStrip.edges) { + path += "M " + edge.from.x + " " + edge.from.y + " "; + path += "L " + edge.to.x + " " + edge.to.y + " "; + path += "L " + edge.to.x + " " + tileBottom + " "; + path += "L " + edge.from.x + " " + tileBottom + " "; + path += "Z "; + } + + const pathElement = staticCast(document.createElementNS(SVG_NS, 'path'), + SVGPathElement); + pathElement.setAttribute('d', path); + pathElement.setAttribute('fill', fillColor); + pathElement.setAttribute('stroke', "rgb(0, 128.0, 0)"); + this.svg.appendChild(pathElement); + } + + this.updateSVGSize(); + } + + private updateSVGSize(): void { + this.svg.style.width = this.size.width + "px"; + this.svg.style.height = this.size.height + "px"; + } +} + +function assertEq(actual: T, expected: T): void { + if (JSON.stringify(expected) !== JSON.stringify(actual)) { + console.error("expected", expected, "but found", actual); + throw new Error("Assertion failed!"); + } +} + +export function testIntervals(): void { + const intervals = new Intervals(7); + intervals.add(new IntervalRange(1, 2, 1)); + intervals.add(new IntervalRange(3, 4, 1)); + intervals.add(new IntervalRange(5, 6, 1)); + assertEq(intervals.intervalRanges(), [ + new IntervalRange(0, 1, 0), + new IntervalRange(1, 2, 1), + new IntervalRange(2, 3, 0), + new IntervalRange(3, 4, 1), + new IntervalRange(4, 5, 0), + new IntervalRange(5, 6, 1), + new IntervalRange(6, 7, 0), + ]); + + intervals.clear(); + intervals.add(new IntervalRange(1, 2, 1)); + intervals.add(new IntervalRange(2, 3, 1)); + assertEq(intervals.intervalRanges(), [ + new IntervalRange(0, 1, 0), + new IntervalRange(1, 3, 1), + new IntervalRange(3, 7, 0), + ]); + + intervals.clear(); + intervals.add(new IntervalRange(1, 4, 1)); + intervals.add(new IntervalRange(3, 5, 1)); + assertEq(intervals.intervalRanges(), [ + new IntervalRange(0, 1, 0), + new IntervalRange(1, 3, 1), + new IntervalRange(3, 4, 2), + new IntervalRange(4, 5, 1), + new IntervalRange(5, 7, 0), + ]); + + intervals.clear(); + intervals.add(new IntervalRange(2, 3.5, 1)); + intervals.add(new IntervalRange(3, 5, 1)); + intervals.add(new IntervalRange(6, 7, 1)); + assertEq(intervals.intervalRanges(), [ + new IntervalRange(0, 2, 0), + new IntervalRange(2, 3, 1), + new IntervalRange(3, 3.5, 2), + new IntervalRange(3.5, 5, 1), + new IntervalRange(5, 6, 0), + new IntervalRange(6, 7, 1), + ]); + + intervals.clear(); + intervals.add(new IntervalRange(2, 5, 1)); + intervals.add(new IntervalRange(3, 3.5, -1)); + assertEq(intervals.intervalRanges(), [ + new IntervalRange(0, 2, 0), + new IntervalRange(2, 3, 1), + new IntervalRange(3, 3.5, 0), + new IntervalRange(3.5, 5, 1), + new IntervalRange(5, 7, 0), + ]); + + intervals.clear(); + intervals.add(new IntervalRange(2, 5, 1)); + intervals.add(new IntervalRange(3, 3.5, -1)); + intervals.add(new IntervalRange(3, 3.5, 1)); + assertEq(intervals.intervalRanges(), [ + new IntervalRange(0, 2, 0), + new IntervalRange(2, 5, 1), + new IntervalRange(5, 7, 0), + ]); +}