From 0cd2fa671886334d475e99b5ae923e8ae16d090e Mon Sep 17 00:00:00 2001 From: Phil DeOrsey <99980110+PhilDeOrsey@users.noreply.github.com> Date: Thu, 4 Apr 2024 20:24:41 -0400 Subject: [PATCH 01/10] Add files via upload --- boolean-test.ts | 62 +++++ boolean.ts | 645 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 707 insertions(+) create mode 100644 boolean-test.ts create mode 100644 boolean.ts diff --git a/boolean-test.ts b/boolean-test.ts new file mode 100644 index 0000000..26c64b5 --- /dev/null +++ b/boolean-test.ts @@ -0,0 +1,62 @@ +// ============================================================================= +// Euclid.js | Boolean Operations Tests +// (c) Mathigon +// ============================================================================= + + +import tape from 'tape'; +import {difference, intersect, Polygon, union, xor} from '../src'; +import {Point} from '../src'; + + +const poly = (...p: number[][]) => p.map(q => new Point(q[0], q[1])); +const points = (p: Point[][]) => p.map(q => q.map(r => r.array)); + +tape('boolean operations', (test) => { + const p1 = poly([0, 0], [2, 0], [2, 2], [0, 2]); + const p2 = poly([1, 1], [3, 1], [3, 3], [1, 3]); + + const r1 = points(union([p1], [p2])); + test.deepEquals(r1, [[[3, 3], [3, 1], [2, 1], [2, 0], [0, 0], [0, 2], [1, 2], [1, 3]]]); + + const r2 = points(intersect([p1], [p2])); + test.deepEquals(r2, [[[2, 2], [2, 1], [1, 1], [1, 2]]]); + + const r3 = points(difference([p1], [p2])); + const a = [[2, 1], [2, 0], [0, 0], [0, 2], [1, 2], [1, 1]]; + test.deepEquals(r3, [a]); + + const r4 = points(xor([p1], [p2])); + test.deepEquals(r4, [a, [[3, 3], [3, 1], [2, 1], [2, 2], [1, 2], [1, 3]]]); + + test.end(); +}); + +tape('compound polygons', (test) => { + const p1 = poly([50, 50], [150, 150], [190, 50]); + const p2 = poly([130, 50], [290, 150], [290, 50]); + const p3 = poly([110, 20], [110, 110], [20, 20]); + const p4 = poly([130, 170], [130, 20], [260, 20], [260, 170]); + + const r = points(intersect([p1, p2], [p3, p4])); + test.deepEquals(r, [ + [[50, 50], [110, 50], [110, 110]], + [[178, 80], [130, 50], [130, 130], [150, 150]], + [[178, 80], [190, 50], [260, 50], [260, 131.25]] + ]); + + test.end(); +}); + +tape('intersections', (test) => { + const hexagon = Polygon.regular(6, 100); + const result = Polygon.intersection([hexagon, hexagon]); + test.equal(hexagon.area, result[0].area); + + const p1 = new Polygon(new Point(340, 300), new Point(341.95, 210), new Point(360, 250)); + const p2 = new Polygon(new Point(340, 300), new Point(341.953125, 210), new Point(360, 250)); + const r2 = Polygon.intersection([p1, p2]); + test.equal(r2.length, 1); + + test.end(); +}); diff --git a/boolean.ts b/boolean.ts new file mode 100644 index 0000000..fa14e1e --- /dev/null +++ b/boolean.ts @@ -0,0 +1,645 @@ +// ============================================================================= +// Euclid.js | Boolean Operations for Polygons +// (c) Mathigon +// ============================================================================= + + +import {last} from '@mathigon/core'; +import {nearlyEquals} from '@mathigon/fermat'; +import {Point} from './point'; + +// Based on https://github.com/velipso/polybooljs (MIT License) +// – Converted to typescript +// - Use Euclid.js's existing Point/Polygon classes +// – Removed unneeded features (e.g. GeoJSON support and inverted polygons) + + +// ----------------------------------------------------------------------------- +// Utility Functions + +const DEFAULT_PRECISION = 0.000001; +let PRECISION = DEFAULT_PRECISION; + +function pointAboveOrOnLine(pt: Point, left: Point, right: Point) { + const d1 = (right.x - left.x) * (pt.y - left.y); + const d2 = (right.y - left.y) * (pt.x - left.x); + return d1 - d2 >= -PRECISION; +} + +function pointBetween(p: Point, left: Point, right: Point) { + // p must be collinear with left->right + // returns false if p == left, p == right, or left == right + const dpyly = p.y - left.y; + const drxlx = right.x - left.x; + const dpxlx = p.x - left.x; + const dryly = right.y - left.y; + + const dot = dpxlx * drxlx + dpyly * dryly; + // if `dot` is 0, then `p` == `left` or `left` == `right` (reject) + // if `dot` is less than 0, then `p` is to the left of `left` (reject) + if (dot < PRECISION) return false; + + const sqlen = drxlx * drxlx + dryly * dryly; + // if `dot` > `sqlen`, then `p` is to the right of `right` (reject) + // therefore, if `dot - sqlen` is greater than 0, then `p` is to the right of `right` (reject) + return dot - sqlen <= -PRECISION; +} + +function pointsCompare(p1: Point, p2: Point) { + // returns -1 if p1 is smaller, 1 if p2 is smaller, 0 if equal + if (nearlyEquals(p1.x, p2.x, PRECISION)) { + return nearlyEquals(p1.y, p2.y, PRECISION) ? 0 : (p1.y < p2.y ? -1 : 1); + } + return p1.x < p2.x ? -1 : 1; +} + +/** + * Categorize where intersection point is along A and B: + * -2: intersection point is before segment's first point + * -1: intersection point is directly on segment's first point + * 0: intersection point is between segment's first and second points (exclusive) + * 1: intersection point is directly on segment's second point + * 2: intersection point is after segment's second point + */ +function getOffset(A: number, length: number): -2|-1|0|1|2 { + const precision = PRECISION / length; + if (A <= -precision) return -2; + if (A < precision) return -1; + if (A - 1 <= -precision) return 0; + if (A - 1 < precision) return 1; + return 2; +} + +function linesIntersect(a0: Point, a1: Point, b0: Point, b1: Point) { + const adx = a1.x - a0.x; + const ady = a1.y - a0.y; + const bdx = b1.x - b0.x; + const bdy = b1.y - b0.y; + + const axb = adx * bdy - ady * bdx; + if (nearlyEquals(axb, 0, PRECISION)) return false; // lines are coincident + + const dx = a0.x - b0.x; + const dy = a0.y - b0.y; + const A = (bdx * dy - bdy * dx) / axb; + const B = (adx * dy - ady * dx) / axb; + const aLength = Math.hypot(adx, ady); + const bLength = Math.hypot(bdx, bdy); + + const pt = new Point(a0.x + A * adx, a0.y + A * ady); + return {alongA: getOffset(A, aLength), alongB: getOffset(B, bLength), pt}; +} + + +// ----------------------------------------------------------------------------- +// Types + +type Node = {prev?: Node, next?: Node, root?: boolean, remove: () => void} & T; + +interface Segment { + start: Point; + end: Point; + myFill: {above?: boolean, below?: boolean}; // Is there fill above or below us? + otherFill?: {above?: boolean, below?: boolean}; +} + +interface Event { + isStart?: boolean; + pt: Point; + seg: Segment; + primary?: boolean; + other?: Node; + status?: Node; + ev?: Node; +} + +type Status = {ev: Node}; + + +// ----------------------------------------------------------------------------- +// Linked List + +class LinkedList { + // TODO Better types without any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + root: any = {root: true, next: undefined}; + + exists(node: Node) { + return node !== undefined && node !== this.root; + } + + get head() { + return this.root.next; + } + + insertBefore(node: Node, check: (n: Node) => boolean) { + let last = this.root; + let here = this.root.next; + while (here) { + if (check(here)) { + node.prev = here.prev; + node.next = here; + here.prev.next = node; + here.prev = node; + return; + } + last = here; + here = here.next; + } + last.next = node; + node.prev = last; + node.next = undefined; + } + + findTransition(check: (n: Node) => boolean) { + let prev = this.root; + let here = this.root.next; + while (here) { + if (check(here)) break; + prev = here; + here = here.next; + } + return { + before: prev === this.root ? undefined : prev, + after: here, + insert: (node: Node) => { + node.prev = prev; + node.next = here; + prev.next = node; + if (here) here.prev = node; + return node; + } + }; + } + + static node(data: T) { + const d = data as Node; // TODO Fix this typing! + d.remove = () => { + if (d.prev) d.prev.next = d.next; + if (d.next) d.next.prev = d.prev; + d.prev = d.next = undefined; + }; + return d; + } +} + + +// ----------------------------------------------------------------------------- +// Main Algorithm + +function copy(start: Point, end: Point, seg: Segment) { + const myFill = {above: seg.myFill.above, below: seg.myFill.below}; + return {start, end, myFill}; +} + +function eventCompare(p1isStart: boolean, p11: Point, p12: Point, p2isStart: boolean, p21: Point, p22: Point) { + const comp = pointsCompare(p11, p21); + if (comp !== 0) return comp; // the selected points are the same + + // If the non-selected points are the same too then the segments are equal. + if (Point.equals(p12, p22, PRECISION)) return 0; + + // If one is a start and the other isn't favor the one that isn't the start. + if (p1isStart !== p2isStart) return p1isStart ? 1 : -1; + + // Otherwise, we'll have to calculate which one is below the other manually. Order matters! + return pointAboveOrOnLine(p12, p2isStart ? p21 : p22, p2isStart ? p22 : p21,) ? 1 : -1; +} + +function eventAdd(eventRoot: LinkedList, ev: Node, otherPt: Point) { + eventRoot.insertBefore(ev, (here) => + eventCompare(!!ev.isStart, ev.pt, otherPt, !!here.isStart, here.pt, here.other!.pt) < 0); +} + +function addSegmentStart(eventRoot: LinkedList, seg: Segment, primary: boolean) { + const evStart = LinkedList.node({isStart: true, pt: seg.start, seg, primary}); + eventAdd(eventRoot, evStart, seg.end); + return evStart; +} + +function addSegmentEnd(eventRoot: LinkedList, evStart: Node, seg: Segment, primary: boolean) { + const evEnd = LinkedList.node({pt: seg.end, seg, primary, other: evStart}); + evStart.other = evEnd; + eventAdd(eventRoot, evEnd, evStart.pt); +} + +function addSegment(eventRoot: LinkedList, seg: Segment, primary: boolean) { + const evStart = addSegmentStart(eventRoot, seg, primary); + addSegmentEnd(eventRoot, evStart, seg, primary); + return evStart; +} + +function eventUpdateEnd(eventRoot: LinkedList, ev: Node, end: Point) { + // Slides an end backwards + ev.other!.remove(); + ev.seg.end = end; + ev.other!.pt = end; + eventAdd(eventRoot, ev.other!, ev.pt); +} + +function eventDivide(eventRoot: LinkedList, ev: Node, pt: Point) { + const ns = copy(pt, ev.seg.end, ev.seg); + eventUpdateEnd(eventRoot, ev, pt); + return addSegment(eventRoot, ns, !!ev.primary); +} + +function statusCompare(ev1: Node, ev2: Node) { + const a1 = ev1.seg.start; + const a2 = ev1.seg.end; + const b1 = ev2.seg.start; + const b2 = ev2.seg.end; + + if (!Point.colinear(a1, b1, b2, PRECISION)) return pointAboveOrOnLine(a1, b1, b2) ? 1 : -1; + if (!Point.colinear(a2, b1, b2, PRECISION)) return pointAboveOrOnLine(a2, b1, b2) ? 1 : -1; + return 1; +} + +/** Returns the segment equal to ev1, or false if nothing equal. */ +function checkIntersection(eventRoot: LinkedList, ev1: Node, ev2: Node) { + const seg1 = ev1.seg; + const seg2 = ev2.seg; + const a1 = seg1.start; + const a2 = seg1.end; + const b1 = seg2.start; + const b2 = seg2.end; + + const i = linesIntersect(a1, a2, b1, b2); + + if (i === false) { + // Segments are parallel or coincident. If points aren't collinear, then + // the segments are parallel, so no intersections. Otherwise, segments are + // on top of each other somehow (aka coincident) + if (!Point.colinear(a1, a2, b1, PRECISION)) return false; + if (Point.equals(a1, b2, PRECISION) || Point.equals(a2, b1, PRECISION)) return false; + + const a1isb1 = Point.equals(a1, b1, PRECISION); + const a2isb2 = Point.equals(a2, b2, PRECISION); + + if (a1isb1 && a2isb2) return ev2; // Segments are exactly equal + + const a1Between = !a1isb1 && pointBetween(a1, b1, b2); + const a2Between = !a2isb2 && pointBetween(a2, b1, b2); + + if (a1isb1) { + a2Between ? eventDivide(eventRoot, ev2, a2) : eventDivide(eventRoot, ev1, b2); + return ev2; + } else if (a1Between) { + if (!a2isb2) { + a2Between ? eventDivide(eventRoot, ev2, a2) : eventDivide(eventRoot, ev1, b2); + } + eventDivide(eventRoot, ev2, a1); + } + + } else { + // Otherwise, lines intersect at i.pt, which may or may not be between the endpoints + + // Is A divided between its endpoints? (exclusive) + if (i.alongA === 0) { + if (i.alongB === -1) { // yes, at exactly b1 + eventDivide(eventRoot, ev1, b1); + } else if (i.alongB === 0) { // yes, somewhere between B's endpoints + eventDivide(eventRoot, ev1, i.pt); + } else if (i.alongB === 1) { // yes, at exactly b2 + eventDivide(eventRoot, ev1, b2); + } + } + + // Is B divided between its endpoints? (exclusive) + if (i.alongB === 0) { + if (i.alongA === -1) { // yes, at exactly a1 + eventDivide(eventRoot, ev2, a1); + } else if (i.alongA === 0) { // yes, somewhere between A's endpoints (exclusive) + eventDivide(eventRoot, ev2, i.pt); + } else if (i.alongA === 1) { // yes, at exactly a2 + eventDivide(eventRoot, ev2, a2); + } + } + } + return false; +} + +function calculate(eventRoot: LinkedList, selfIntersection: boolean) { + const statusRoot = new LinkedList(); + + const segments = []; + while (eventRoot.head) { + const ev = eventRoot.head; + + if (ev.isStart) { + const surrounding = statusRoot.findTransition((here) => statusCompare(ev, here.ev) > 0); + const above = surrounding.before?.ev; + const below = surrounding.after?.ev; + + // eslint-disable-next-line no-inner-declarations + function checkBothIntersections() { + if (above) { + const eve = checkIntersection(eventRoot, ev, above); + if (eve) return eve; + } + if (below) return checkIntersection(eventRoot, ev, below); + return false; + } + + const eve = checkBothIntersections(); + if (eve) { + // ev and eve are equal: we'll keep eve and throw away ev + + if (selfIntersection) { + // If we are a toggling edge, we merge two segments that belong to the + // same polygon. Think of this as sandwiching two segments together, + // where `eve.seg` is the bottom. This will cause the above fill flag to toggle + const toggle = !ev.seg.myFill.below ? true : ev.seg.myFill.above !== ev.seg.myFill.below; + if (toggle) eve.seg.myFill.above = !eve.seg.myFill.above; + } else { + // Merge two segments that belong to different polygons. Each segment + // has distinct knowledge, so no special logic is needed note that + // this can only happen once per segment in this phase, because we are + // guaranteed that all self-intersections are gone. + eve.seg.otherFill = ev.seg.myFill; + } + + ev.other.remove(); + ev.remove(); + } + + // something was inserted before us in the event queue, so loop back around and + // process it before continuing + if (eventRoot.head !== ev) continue; + + // Calculate fill flags + + if (selfIntersection) { + // We toggle an edge if if we are a new segment, or we are a segment + // that has previous knowledge from a division + const toggle = (!ev.seg.myFill.below) ? true : ev.seg.myFill.above !== ev.seg.myFill.below; + + // Calculate whether we are filled below us. If nothing is below us, we + // are not filled below. Otherwise, the answer is the same if whatever + // is below us is filled above it. + ev.seg.myFill.below = !below ? false : below.seg.myFill.above; + + // since now we know if we're filled below us, we can calculate whether + // we're filled above us by applying toggle to whatever is below us + ev.seg.myFill.above = toggle ? !ev.seg.myFill.below : ev.seg.myFill.below; + + } else if (ev.seg.otherFill === undefined) { + // If we don't have other information, we need to figure out if we're + // inside the other polygon. If nothing is below us, then we're + // outside. Otherwise copy the below segment's other polygon's above. + const inside = !below ? false : (ev.primary === below.primary) ? below.seg.otherFill.above : below.seg.myFill.above; + ev.seg.otherFill = {above: inside, below: inside}; + } + + // Insert the status and remember it for later removal + ev.other.status = surrounding.insert(LinkedList.node({ev})); + + } else { + const st = ev.status; + if (st === undefined) throw new Error('[Euclid.js] Zero-length segment detected!'); + + // Removing the status will create two new adjacent edges, so we'll need + // to check for those. + if (statusRoot.exists(st.prev) && statusRoot.exists(st.next)) { + checkIntersection(eventRoot, st.prev.ev, st.next.ev); + } + + st.remove(); + + // Now we've calculated everything, so save the segment for reporting. + if (!ev.primary) { + const s = ev.seg.myFill; // Make sure `seg.myFill` points to the primary polygon. + ev.seg.myFill = ev.seg.otherFill; + ev.seg.otherFill = s; + } + segments.push(ev.seg); + } + + eventRoot.head.remove(); + } + + return segments; +} + + +// ----------------------------------------------------------------------------- +// Segment Chainer + +function segmentChainer(segments: Segment[]) { + const chains: Point[][] = []; + const regions: Point[][] = []; + + segments.forEach((seg) => { + const pt1 = seg.start; + const pt2 = seg.end; + if (Point.equals(pt1, pt2, PRECISION)) return; // Zero-length segment: maybe PRECISION is too small or too large! + + // Search for two chains that this segment matches. + const firstMatch = {index: 0, matchesHead: false, matchesPt1: false}; + const secondMatch = {index: 0, matchesHead: false, matchesPt1: false}; + + // TODO Better types without any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let nextMatch: any = firstMatch; + + function setMatch(index: number, matchesHead: boolean, matchesPt1: boolean) { + nextMatch.index = index; + nextMatch.matchesHead = matchesHead; + nextMatch.matchesPt1 = matchesPt1; + const match = nextMatch === firstMatch; + nextMatch = match ? secondMatch : undefined; + return !match; + } + + for (let i = 0; i < chains.length; i++) { + const chain = chains[i]; + const head = chain[0]; + const tail = last(chain); + if (Point.equals(head, pt1, PRECISION)) { + if (setMatch(i, true, true)) break; + } else if (Point.equals(head, pt2, PRECISION)) { + if (setMatch(i, true, false)) break; + } else if (Point.equals(tail, pt1, PRECISION)) { + if (setMatch(i, false, true)) break; + } else if (Point.equals(tail, pt2, PRECISION)) { + if (setMatch(i, false, false)) break; + } + } + + if (nextMatch === firstMatch) { + // We didn't match anything, so create a new chain. + chains.push([pt1, pt2]); + return; + } + + if (nextMatch === secondMatch) { + // We matched a single chain. Add the other point to the appropriate end, + // and check to see if we've closed the chain into a loop. + + const index = firstMatch.index; + const pt = firstMatch.matchesPt1 ? pt2 : pt1; + const addToHead = firstMatch.matchesHead; + + const chain = chains[index]; + let grow = addToHead ? chain[0] : chain[chain.length - 1]; + const grow2 = addToHead ? chain[1] : chain[chain.length - 2]; + const oppo = addToHead ? chain[chain.length - 1] : chain[0]; + const oppo2 = addToHead ? chain[chain.length - 2] : chain[1]; + + if (Point.colinear(grow2, grow, pt, PRECISION)) { + // Grow isn't needed because it's directly between grow2 and pt. + addToHead ? chain.shift() : chain.pop(); + grow = grow2; // Old grow is gone... new grow is what grow2 was. + } + + if (Point.equals(oppo, pt, PRECISION)) { + // We're closing the loop, so remove chain from chains. + chains.splice(index, 1); + + if (Point.colinear(oppo2, oppo, grow, PRECISION)) { + // Oppo isn't needed because it's directly between oppo2 and grow. + addToHead ? chain.pop() : chain.shift(); + } + + regions.push(chain); + return; + } + + // Not closing a loop, so just add it to the appropriate side. + addToHead ? chain.unshift(pt) : chain.push(pt); + return; + } + + // Otherwise, we matched two chains, so we need to combine those chains together. + + function reverseChain(index: number) { + chains[index].reverse(); + } + + function appendChain(index1: number, index2: number) { + // index1 gets index2 appended to it, and index2 is removed + const chain1 = chains[index1]; + const chain2 = chains[index2]; + let tail = chain1[chain1.length - 1]; + const tail2 = chain1[chain1.length - 2]; + const head = chain2[0]; + const head2 = chain2[1]; + + if (Point.colinear(tail2, tail, head, PRECISION)) { + // Tail isn't needed because it's directly between tail2 and head + chain1.pop(); + tail = tail2; // old tail is gone... new tail is what tail2 was + } + + if (Point.colinear(tail, head, head2, PRECISION)) { + // Head isn't needed because it's directly between tail and head2 + chain2.shift(); + } + + chains[index1] = chain1.concat(chain2); + chains.splice(index2, 1); + } + + const F = firstMatch.index; + const S = secondMatch.index; + + const reverseF = chains[F].length < chains[S].length; // Reverse the shorter chain + if (firstMatch.matchesHead) { + if (secondMatch.matchesHead) { + if (reverseF) { + reverseChain(F); + appendChain(F, S); + } else { + reverseChain(S); + appendChain(S, F); + } + } else { + appendChain(S, F); + } + } else { + if (secondMatch.matchesHead) { + appendChain(F, S); + } else { + if (reverseF) { + reverseChain(F); + appendChain(S, F); + } else { + reverseChain(S); + appendChain(F, S); + } + } + } + }); + + return regions; +} + + +// ----------------------------------------------------------------------------- +// Workflow + +function select(segments: Segment[], selection: number[]) { + const result: Segment[] = []; + + for (const seg of segments) { + const index = (seg.myFill.above ? 8 : 0) + + (seg.myFill.below ? 4 : 0) + + ((seg.otherFill && seg.otherFill.above) ? 2 : 0) + + ((seg.otherFill && seg.otherFill.below) ? 1 : 0); + if (selection[index] !== 0) { + result.push({ + start: seg.start, + end: seg.end, + myFill: {above: selection[index] === 1, below: selection[index] === 2} + }); + } + } + + return result; +} + +function segments(poly: MultiPolygon) { + const root = new LinkedList(); + + for (const region of poly) { + for (let i = 0; i < region.length; i++) { + const pt1 = i ? region[i - 1] : last(region); + const pt2 = region[i]; + + const forward = pointsCompare(pt1, pt2); + if (forward === 0) continue; // skip zero-length segments + + const start = forward < 0 ? pt1 : pt2; + const end = forward < 0 ? pt2 : pt1; + addSegment(root, {start, end, myFill: {}}, true); + } + } + + return calculate(root, true); +} + +function operate(poly1: MultiPolygon, poly2: MultiPolygon, selection: number[], precision?: number) { + if (precision !== undefined) PRECISION = precision; + const root = new LinkedList(); + for (const s of segments(poly1)) addSegment(root, copy(s.start, s.end, s), true); + for (const s of segments(poly2)) addSegment(root, copy(s.start, s.end, s), false); + + const results = segmentChainer(select(calculate(root, false), selection)); + PRECISION = DEFAULT_PRECISION; + return results.filter(polygon => polygon.length > 2); +} + + +// ----------------------------------------------------------------------------- +// Public Exports + +type MultiPolygon = Point[][]; + +const UNION = [0, 2, 1, 0, 2, 2, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0]; +const INTERSECT = [0, 0, 0, 0, 0, 2, 0, 2, 0, 0, 1, 1, 0, 2, 1, 0]; +const DIFFERENCE = [0, 0, 0, 0, 2, 0, 2, 0, 1, 1, 0, 0, 0, 1, 2, 0]; +const XOR = [0, 2, 1, 0, 2, 0, 0, 1, 1, 0, 0, 2, 0, 1, 2, 0]; + +export const union = (p1: MultiPolygon, p2: MultiPolygon, precision?: number) => operate(p1, p2, UNION, precision); +export const intersect = (p1: MultiPolygon, p2: MultiPolygon, precision?: number) => operate(p1, p2, INTERSECT, precision); +export const difference = (p1: MultiPolygon, p2: MultiPolygon, precision?: number) => operate(p1, p2, DIFFERENCE, precision); +export const xor = (p1: MultiPolygon, p2: MultiPolygon, precision?: number) => operate(p1, p2, XOR, precision); From c83bdece272536619073f29c95cea5625b2fa714 Mon Sep 17 00:00:00 2001 From: Phil DeOrsey Date: Thu, 4 Apr 2024 20:58:59 -0400 Subject: [PATCH 02/10] remove errant files, commit roundPoint to boolean.ts --- src/boolean.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/boolean.ts b/src/boolean.ts index fa14e1e..4ebf12d 100644 --- a/src/boolean.ts +++ b/src/boolean.ts @@ -5,7 +5,7 @@ import {last} from '@mathigon/core'; -import {nearlyEquals} from '@mathigon/fermat'; +import {nearlyEquals, round} from '@mathigon/fermat'; import {Point} from './point'; // Based on https://github.com/velipso/polybooljs (MIT License) @@ -617,11 +617,16 @@ function segments(poly: MultiPolygon) { return calculate(root, true); } -function operate(poly1: MultiPolygon, poly2: MultiPolygon, selection: number[], precision?: number) { +function roundPoint(point: Point, useRound?: boolean, precision = 2) { + if (!useRound) return point; + return new Point(round(point.x, precision), round(point.y, precision)); +} + +function operate(poly1: MultiPolygon, poly2: MultiPolygon, selection: number[], precision?: number, useRound?: boolean) { if (precision !== undefined) PRECISION = precision; const root = new LinkedList(); - for (const s of segments(poly1)) addSegment(root, copy(s.start, s.end, s), true); - for (const s of segments(poly2)) addSegment(root, copy(s.start, s.end, s), false); + for (const s of segments(poly1)) addSegment(root, copy(roundPoint(s.start, useRound), roundPoint(s.end, useRound), s), true); + for (const s of segments(poly2)) addSegment(root, copy(roundPoint(s.start, useRound), roundPoint(s.end, useRound), s), false); const results = segmentChainer(select(calculate(root, false), selection)); PRECISION = DEFAULT_PRECISION; @@ -639,7 +644,7 @@ const INTERSECT = [0, 0, 0, 0, 0, 2, 0, 2, 0, 0, 1, 1, 0, 2, 1, 0]; const DIFFERENCE = [0, 0, 0, 0, 2, 0, 2, 0, 1, 1, 0, 0, 0, 1, 2, 0]; const XOR = [0, 2, 1, 0, 2, 0, 0, 1, 1, 0, 0, 2, 0, 1, 2, 0]; -export const union = (p1: MultiPolygon, p2: MultiPolygon, precision?: number) => operate(p1, p2, UNION, precision); -export const intersect = (p1: MultiPolygon, p2: MultiPolygon, precision?: number) => operate(p1, p2, INTERSECT, precision); -export const difference = (p1: MultiPolygon, p2: MultiPolygon, precision?: number) => operate(p1, p2, DIFFERENCE, precision); -export const xor = (p1: MultiPolygon, p2: MultiPolygon, precision?: number) => operate(p1, p2, XOR, precision); +export const union = (p1: MultiPolygon, p2: MultiPolygon, precision?: number, useRound?: boolean) => operate(p1, p2, UNION, precision, useRound); +export const intersect = (p1: MultiPolygon, p2: MultiPolygon, precision?: number, useRound?: boolean) => operate(p1, p2, INTERSECT, precision, useRound); +export const difference = (p1: MultiPolygon, p2: MultiPolygon, precision?: number, useRound?: boolean) => operate(p1, p2, DIFFERENCE, precision, useRound); +export const xor = (p1: MultiPolygon, p2: MultiPolygon, precision?: number, useRound?: boolean) => operate(p1, p2, XOR, precision, useRound); From bf4327bb150775c5a77f406e5ad87c540ea89ac9 Mon Sep 17 00:00:00 2001 From: Phil DeOrsey Date: Thu, 4 Apr 2024 20:59:53 -0400 Subject: [PATCH 03/10] remove errant files --- boolean-test.ts | 62 ----- boolean.ts | 645 ------------------------------------------------ 2 files changed, 707 deletions(-) delete mode 100644 boolean-test.ts delete mode 100644 boolean.ts diff --git a/boolean-test.ts b/boolean-test.ts deleted file mode 100644 index 26c64b5..0000000 --- a/boolean-test.ts +++ /dev/null @@ -1,62 +0,0 @@ -// ============================================================================= -// Euclid.js | Boolean Operations Tests -// (c) Mathigon -// ============================================================================= - - -import tape from 'tape'; -import {difference, intersect, Polygon, union, xor} from '../src'; -import {Point} from '../src'; - - -const poly = (...p: number[][]) => p.map(q => new Point(q[0], q[1])); -const points = (p: Point[][]) => p.map(q => q.map(r => r.array)); - -tape('boolean operations', (test) => { - const p1 = poly([0, 0], [2, 0], [2, 2], [0, 2]); - const p2 = poly([1, 1], [3, 1], [3, 3], [1, 3]); - - const r1 = points(union([p1], [p2])); - test.deepEquals(r1, [[[3, 3], [3, 1], [2, 1], [2, 0], [0, 0], [0, 2], [1, 2], [1, 3]]]); - - const r2 = points(intersect([p1], [p2])); - test.deepEquals(r2, [[[2, 2], [2, 1], [1, 1], [1, 2]]]); - - const r3 = points(difference([p1], [p2])); - const a = [[2, 1], [2, 0], [0, 0], [0, 2], [1, 2], [1, 1]]; - test.deepEquals(r3, [a]); - - const r4 = points(xor([p1], [p2])); - test.deepEquals(r4, [a, [[3, 3], [3, 1], [2, 1], [2, 2], [1, 2], [1, 3]]]); - - test.end(); -}); - -tape('compound polygons', (test) => { - const p1 = poly([50, 50], [150, 150], [190, 50]); - const p2 = poly([130, 50], [290, 150], [290, 50]); - const p3 = poly([110, 20], [110, 110], [20, 20]); - const p4 = poly([130, 170], [130, 20], [260, 20], [260, 170]); - - const r = points(intersect([p1, p2], [p3, p4])); - test.deepEquals(r, [ - [[50, 50], [110, 50], [110, 110]], - [[178, 80], [130, 50], [130, 130], [150, 150]], - [[178, 80], [190, 50], [260, 50], [260, 131.25]] - ]); - - test.end(); -}); - -tape('intersections', (test) => { - const hexagon = Polygon.regular(6, 100); - const result = Polygon.intersection([hexagon, hexagon]); - test.equal(hexagon.area, result[0].area); - - const p1 = new Polygon(new Point(340, 300), new Point(341.95, 210), new Point(360, 250)); - const p2 = new Polygon(new Point(340, 300), new Point(341.953125, 210), new Point(360, 250)); - const r2 = Polygon.intersection([p1, p2]); - test.equal(r2.length, 1); - - test.end(); -}); diff --git a/boolean.ts b/boolean.ts deleted file mode 100644 index fa14e1e..0000000 --- a/boolean.ts +++ /dev/null @@ -1,645 +0,0 @@ -// ============================================================================= -// Euclid.js | Boolean Operations for Polygons -// (c) Mathigon -// ============================================================================= - - -import {last} from '@mathigon/core'; -import {nearlyEquals} from '@mathigon/fermat'; -import {Point} from './point'; - -// Based on https://github.com/velipso/polybooljs (MIT License) -// – Converted to typescript -// - Use Euclid.js's existing Point/Polygon classes -// – Removed unneeded features (e.g. GeoJSON support and inverted polygons) - - -// ----------------------------------------------------------------------------- -// Utility Functions - -const DEFAULT_PRECISION = 0.000001; -let PRECISION = DEFAULT_PRECISION; - -function pointAboveOrOnLine(pt: Point, left: Point, right: Point) { - const d1 = (right.x - left.x) * (pt.y - left.y); - const d2 = (right.y - left.y) * (pt.x - left.x); - return d1 - d2 >= -PRECISION; -} - -function pointBetween(p: Point, left: Point, right: Point) { - // p must be collinear with left->right - // returns false if p == left, p == right, or left == right - const dpyly = p.y - left.y; - const drxlx = right.x - left.x; - const dpxlx = p.x - left.x; - const dryly = right.y - left.y; - - const dot = dpxlx * drxlx + dpyly * dryly; - // if `dot` is 0, then `p` == `left` or `left` == `right` (reject) - // if `dot` is less than 0, then `p` is to the left of `left` (reject) - if (dot < PRECISION) return false; - - const sqlen = drxlx * drxlx + dryly * dryly; - // if `dot` > `sqlen`, then `p` is to the right of `right` (reject) - // therefore, if `dot - sqlen` is greater than 0, then `p` is to the right of `right` (reject) - return dot - sqlen <= -PRECISION; -} - -function pointsCompare(p1: Point, p2: Point) { - // returns -1 if p1 is smaller, 1 if p2 is smaller, 0 if equal - if (nearlyEquals(p1.x, p2.x, PRECISION)) { - return nearlyEquals(p1.y, p2.y, PRECISION) ? 0 : (p1.y < p2.y ? -1 : 1); - } - return p1.x < p2.x ? -1 : 1; -} - -/** - * Categorize where intersection point is along A and B: - * -2: intersection point is before segment's first point - * -1: intersection point is directly on segment's first point - * 0: intersection point is between segment's first and second points (exclusive) - * 1: intersection point is directly on segment's second point - * 2: intersection point is after segment's second point - */ -function getOffset(A: number, length: number): -2|-1|0|1|2 { - const precision = PRECISION / length; - if (A <= -precision) return -2; - if (A < precision) return -1; - if (A - 1 <= -precision) return 0; - if (A - 1 < precision) return 1; - return 2; -} - -function linesIntersect(a0: Point, a1: Point, b0: Point, b1: Point) { - const adx = a1.x - a0.x; - const ady = a1.y - a0.y; - const bdx = b1.x - b0.x; - const bdy = b1.y - b0.y; - - const axb = adx * bdy - ady * bdx; - if (nearlyEquals(axb, 0, PRECISION)) return false; // lines are coincident - - const dx = a0.x - b0.x; - const dy = a0.y - b0.y; - const A = (bdx * dy - bdy * dx) / axb; - const B = (adx * dy - ady * dx) / axb; - const aLength = Math.hypot(adx, ady); - const bLength = Math.hypot(bdx, bdy); - - const pt = new Point(a0.x + A * adx, a0.y + A * ady); - return {alongA: getOffset(A, aLength), alongB: getOffset(B, bLength), pt}; -} - - -// ----------------------------------------------------------------------------- -// Types - -type Node = {prev?: Node, next?: Node, root?: boolean, remove: () => void} & T; - -interface Segment { - start: Point; - end: Point; - myFill: {above?: boolean, below?: boolean}; // Is there fill above or below us? - otherFill?: {above?: boolean, below?: boolean}; -} - -interface Event { - isStart?: boolean; - pt: Point; - seg: Segment; - primary?: boolean; - other?: Node; - status?: Node; - ev?: Node; -} - -type Status = {ev: Node}; - - -// ----------------------------------------------------------------------------- -// Linked List - -class LinkedList { - // TODO Better types without any - // eslint-disable-next-line @typescript-eslint/no-explicit-any - root: any = {root: true, next: undefined}; - - exists(node: Node) { - return node !== undefined && node !== this.root; - } - - get head() { - return this.root.next; - } - - insertBefore(node: Node, check: (n: Node) => boolean) { - let last = this.root; - let here = this.root.next; - while (here) { - if (check(here)) { - node.prev = here.prev; - node.next = here; - here.prev.next = node; - here.prev = node; - return; - } - last = here; - here = here.next; - } - last.next = node; - node.prev = last; - node.next = undefined; - } - - findTransition(check: (n: Node) => boolean) { - let prev = this.root; - let here = this.root.next; - while (here) { - if (check(here)) break; - prev = here; - here = here.next; - } - return { - before: prev === this.root ? undefined : prev, - after: here, - insert: (node: Node) => { - node.prev = prev; - node.next = here; - prev.next = node; - if (here) here.prev = node; - return node; - } - }; - } - - static node(data: T) { - const d = data as Node; // TODO Fix this typing! - d.remove = () => { - if (d.prev) d.prev.next = d.next; - if (d.next) d.next.prev = d.prev; - d.prev = d.next = undefined; - }; - return d; - } -} - - -// ----------------------------------------------------------------------------- -// Main Algorithm - -function copy(start: Point, end: Point, seg: Segment) { - const myFill = {above: seg.myFill.above, below: seg.myFill.below}; - return {start, end, myFill}; -} - -function eventCompare(p1isStart: boolean, p11: Point, p12: Point, p2isStart: boolean, p21: Point, p22: Point) { - const comp = pointsCompare(p11, p21); - if (comp !== 0) return comp; // the selected points are the same - - // If the non-selected points are the same too then the segments are equal. - if (Point.equals(p12, p22, PRECISION)) return 0; - - // If one is a start and the other isn't favor the one that isn't the start. - if (p1isStart !== p2isStart) return p1isStart ? 1 : -1; - - // Otherwise, we'll have to calculate which one is below the other manually. Order matters! - return pointAboveOrOnLine(p12, p2isStart ? p21 : p22, p2isStart ? p22 : p21,) ? 1 : -1; -} - -function eventAdd(eventRoot: LinkedList, ev: Node, otherPt: Point) { - eventRoot.insertBefore(ev, (here) => - eventCompare(!!ev.isStart, ev.pt, otherPt, !!here.isStart, here.pt, here.other!.pt) < 0); -} - -function addSegmentStart(eventRoot: LinkedList, seg: Segment, primary: boolean) { - const evStart = LinkedList.node({isStart: true, pt: seg.start, seg, primary}); - eventAdd(eventRoot, evStart, seg.end); - return evStart; -} - -function addSegmentEnd(eventRoot: LinkedList, evStart: Node, seg: Segment, primary: boolean) { - const evEnd = LinkedList.node({pt: seg.end, seg, primary, other: evStart}); - evStart.other = evEnd; - eventAdd(eventRoot, evEnd, evStart.pt); -} - -function addSegment(eventRoot: LinkedList, seg: Segment, primary: boolean) { - const evStart = addSegmentStart(eventRoot, seg, primary); - addSegmentEnd(eventRoot, evStart, seg, primary); - return evStart; -} - -function eventUpdateEnd(eventRoot: LinkedList, ev: Node, end: Point) { - // Slides an end backwards - ev.other!.remove(); - ev.seg.end = end; - ev.other!.pt = end; - eventAdd(eventRoot, ev.other!, ev.pt); -} - -function eventDivide(eventRoot: LinkedList, ev: Node, pt: Point) { - const ns = copy(pt, ev.seg.end, ev.seg); - eventUpdateEnd(eventRoot, ev, pt); - return addSegment(eventRoot, ns, !!ev.primary); -} - -function statusCompare(ev1: Node, ev2: Node) { - const a1 = ev1.seg.start; - const a2 = ev1.seg.end; - const b1 = ev2.seg.start; - const b2 = ev2.seg.end; - - if (!Point.colinear(a1, b1, b2, PRECISION)) return pointAboveOrOnLine(a1, b1, b2) ? 1 : -1; - if (!Point.colinear(a2, b1, b2, PRECISION)) return pointAboveOrOnLine(a2, b1, b2) ? 1 : -1; - return 1; -} - -/** Returns the segment equal to ev1, or false if nothing equal. */ -function checkIntersection(eventRoot: LinkedList, ev1: Node, ev2: Node) { - const seg1 = ev1.seg; - const seg2 = ev2.seg; - const a1 = seg1.start; - const a2 = seg1.end; - const b1 = seg2.start; - const b2 = seg2.end; - - const i = linesIntersect(a1, a2, b1, b2); - - if (i === false) { - // Segments are parallel or coincident. If points aren't collinear, then - // the segments are parallel, so no intersections. Otherwise, segments are - // on top of each other somehow (aka coincident) - if (!Point.colinear(a1, a2, b1, PRECISION)) return false; - if (Point.equals(a1, b2, PRECISION) || Point.equals(a2, b1, PRECISION)) return false; - - const a1isb1 = Point.equals(a1, b1, PRECISION); - const a2isb2 = Point.equals(a2, b2, PRECISION); - - if (a1isb1 && a2isb2) return ev2; // Segments are exactly equal - - const a1Between = !a1isb1 && pointBetween(a1, b1, b2); - const a2Between = !a2isb2 && pointBetween(a2, b1, b2); - - if (a1isb1) { - a2Between ? eventDivide(eventRoot, ev2, a2) : eventDivide(eventRoot, ev1, b2); - return ev2; - } else if (a1Between) { - if (!a2isb2) { - a2Between ? eventDivide(eventRoot, ev2, a2) : eventDivide(eventRoot, ev1, b2); - } - eventDivide(eventRoot, ev2, a1); - } - - } else { - // Otherwise, lines intersect at i.pt, which may or may not be between the endpoints - - // Is A divided between its endpoints? (exclusive) - if (i.alongA === 0) { - if (i.alongB === -1) { // yes, at exactly b1 - eventDivide(eventRoot, ev1, b1); - } else if (i.alongB === 0) { // yes, somewhere between B's endpoints - eventDivide(eventRoot, ev1, i.pt); - } else if (i.alongB === 1) { // yes, at exactly b2 - eventDivide(eventRoot, ev1, b2); - } - } - - // Is B divided between its endpoints? (exclusive) - if (i.alongB === 0) { - if (i.alongA === -1) { // yes, at exactly a1 - eventDivide(eventRoot, ev2, a1); - } else if (i.alongA === 0) { // yes, somewhere between A's endpoints (exclusive) - eventDivide(eventRoot, ev2, i.pt); - } else if (i.alongA === 1) { // yes, at exactly a2 - eventDivide(eventRoot, ev2, a2); - } - } - } - return false; -} - -function calculate(eventRoot: LinkedList, selfIntersection: boolean) { - const statusRoot = new LinkedList(); - - const segments = []; - while (eventRoot.head) { - const ev = eventRoot.head; - - if (ev.isStart) { - const surrounding = statusRoot.findTransition((here) => statusCompare(ev, here.ev) > 0); - const above = surrounding.before?.ev; - const below = surrounding.after?.ev; - - // eslint-disable-next-line no-inner-declarations - function checkBothIntersections() { - if (above) { - const eve = checkIntersection(eventRoot, ev, above); - if (eve) return eve; - } - if (below) return checkIntersection(eventRoot, ev, below); - return false; - } - - const eve = checkBothIntersections(); - if (eve) { - // ev and eve are equal: we'll keep eve and throw away ev - - if (selfIntersection) { - // If we are a toggling edge, we merge two segments that belong to the - // same polygon. Think of this as sandwiching two segments together, - // where `eve.seg` is the bottom. This will cause the above fill flag to toggle - const toggle = !ev.seg.myFill.below ? true : ev.seg.myFill.above !== ev.seg.myFill.below; - if (toggle) eve.seg.myFill.above = !eve.seg.myFill.above; - } else { - // Merge two segments that belong to different polygons. Each segment - // has distinct knowledge, so no special logic is needed note that - // this can only happen once per segment in this phase, because we are - // guaranteed that all self-intersections are gone. - eve.seg.otherFill = ev.seg.myFill; - } - - ev.other.remove(); - ev.remove(); - } - - // something was inserted before us in the event queue, so loop back around and - // process it before continuing - if (eventRoot.head !== ev) continue; - - // Calculate fill flags - - if (selfIntersection) { - // We toggle an edge if if we are a new segment, or we are a segment - // that has previous knowledge from a division - const toggle = (!ev.seg.myFill.below) ? true : ev.seg.myFill.above !== ev.seg.myFill.below; - - // Calculate whether we are filled below us. If nothing is below us, we - // are not filled below. Otherwise, the answer is the same if whatever - // is below us is filled above it. - ev.seg.myFill.below = !below ? false : below.seg.myFill.above; - - // since now we know if we're filled below us, we can calculate whether - // we're filled above us by applying toggle to whatever is below us - ev.seg.myFill.above = toggle ? !ev.seg.myFill.below : ev.seg.myFill.below; - - } else if (ev.seg.otherFill === undefined) { - // If we don't have other information, we need to figure out if we're - // inside the other polygon. If nothing is below us, then we're - // outside. Otherwise copy the below segment's other polygon's above. - const inside = !below ? false : (ev.primary === below.primary) ? below.seg.otherFill.above : below.seg.myFill.above; - ev.seg.otherFill = {above: inside, below: inside}; - } - - // Insert the status and remember it for later removal - ev.other.status = surrounding.insert(LinkedList.node({ev})); - - } else { - const st = ev.status; - if (st === undefined) throw new Error('[Euclid.js] Zero-length segment detected!'); - - // Removing the status will create two new adjacent edges, so we'll need - // to check for those. - if (statusRoot.exists(st.prev) && statusRoot.exists(st.next)) { - checkIntersection(eventRoot, st.prev.ev, st.next.ev); - } - - st.remove(); - - // Now we've calculated everything, so save the segment for reporting. - if (!ev.primary) { - const s = ev.seg.myFill; // Make sure `seg.myFill` points to the primary polygon. - ev.seg.myFill = ev.seg.otherFill; - ev.seg.otherFill = s; - } - segments.push(ev.seg); - } - - eventRoot.head.remove(); - } - - return segments; -} - - -// ----------------------------------------------------------------------------- -// Segment Chainer - -function segmentChainer(segments: Segment[]) { - const chains: Point[][] = []; - const regions: Point[][] = []; - - segments.forEach((seg) => { - const pt1 = seg.start; - const pt2 = seg.end; - if (Point.equals(pt1, pt2, PRECISION)) return; // Zero-length segment: maybe PRECISION is too small or too large! - - // Search for two chains that this segment matches. - const firstMatch = {index: 0, matchesHead: false, matchesPt1: false}; - const secondMatch = {index: 0, matchesHead: false, matchesPt1: false}; - - // TODO Better types without any - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let nextMatch: any = firstMatch; - - function setMatch(index: number, matchesHead: boolean, matchesPt1: boolean) { - nextMatch.index = index; - nextMatch.matchesHead = matchesHead; - nextMatch.matchesPt1 = matchesPt1; - const match = nextMatch === firstMatch; - nextMatch = match ? secondMatch : undefined; - return !match; - } - - for (let i = 0; i < chains.length; i++) { - const chain = chains[i]; - const head = chain[0]; - const tail = last(chain); - if (Point.equals(head, pt1, PRECISION)) { - if (setMatch(i, true, true)) break; - } else if (Point.equals(head, pt2, PRECISION)) { - if (setMatch(i, true, false)) break; - } else if (Point.equals(tail, pt1, PRECISION)) { - if (setMatch(i, false, true)) break; - } else if (Point.equals(tail, pt2, PRECISION)) { - if (setMatch(i, false, false)) break; - } - } - - if (nextMatch === firstMatch) { - // We didn't match anything, so create a new chain. - chains.push([pt1, pt2]); - return; - } - - if (nextMatch === secondMatch) { - // We matched a single chain. Add the other point to the appropriate end, - // and check to see if we've closed the chain into a loop. - - const index = firstMatch.index; - const pt = firstMatch.matchesPt1 ? pt2 : pt1; - const addToHead = firstMatch.matchesHead; - - const chain = chains[index]; - let grow = addToHead ? chain[0] : chain[chain.length - 1]; - const grow2 = addToHead ? chain[1] : chain[chain.length - 2]; - const oppo = addToHead ? chain[chain.length - 1] : chain[0]; - const oppo2 = addToHead ? chain[chain.length - 2] : chain[1]; - - if (Point.colinear(grow2, grow, pt, PRECISION)) { - // Grow isn't needed because it's directly between grow2 and pt. - addToHead ? chain.shift() : chain.pop(); - grow = grow2; // Old grow is gone... new grow is what grow2 was. - } - - if (Point.equals(oppo, pt, PRECISION)) { - // We're closing the loop, so remove chain from chains. - chains.splice(index, 1); - - if (Point.colinear(oppo2, oppo, grow, PRECISION)) { - // Oppo isn't needed because it's directly between oppo2 and grow. - addToHead ? chain.pop() : chain.shift(); - } - - regions.push(chain); - return; - } - - // Not closing a loop, so just add it to the appropriate side. - addToHead ? chain.unshift(pt) : chain.push(pt); - return; - } - - // Otherwise, we matched two chains, so we need to combine those chains together. - - function reverseChain(index: number) { - chains[index].reverse(); - } - - function appendChain(index1: number, index2: number) { - // index1 gets index2 appended to it, and index2 is removed - const chain1 = chains[index1]; - const chain2 = chains[index2]; - let tail = chain1[chain1.length - 1]; - const tail2 = chain1[chain1.length - 2]; - const head = chain2[0]; - const head2 = chain2[1]; - - if (Point.colinear(tail2, tail, head, PRECISION)) { - // Tail isn't needed because it's directly between tail2 and head - chain1.pop(); - tail = tail2; // old tail is gone... new tail is what tail2 was - } - - if (Point.colinear(tail, head, head2, PRECISION)) { - // Head isn't needed because it's directly between tail and head2 - chain2.shift(); - } - - chains[index1] = chain1.concat(chain2); - chains.splice(index2, 1); - } - - const F = firstMatch.index; - const S = secondMatch.index; - - const reverseF = chains[F].length < chains[S].length; // Reverse the shorter chain - if (firstMatch.matchesHead) { - if (secondMatch.matchesHead) { - if (reverseF) { - reverseChain(F); - appendChain(F, S); - } else { - reverseChain(S); - appendChain(S, F); - } - } else { - appendChain(S, F); - } - } else { - if (secondMatch.matchesHead) { - appendChain(F, S); - } else { - if (reverseF) { - reverseChain(F); - appendChain(S, F); - } else { - reverseChain(S); - appendChain(F, S); - } - } - } - }); - - return regions; -} - - -// ----------------------------------------------------------------------------- -// Workflow - -function select(segments: Segment[], selection: number[]) { - const result: Segment[] = []; - - for (const seg of segments) { - const index = (seg.myFill.above ? 8 : 0) + - (seg.myFill.below ? 4 : 0) + - ((seg.otherFill && seg.otherFill.above) ? 2 : 0) + - ((seg.otherFill && seg.otherFill.below) ? 1 : 0); - if (selection[index] !== 0) { - result.push({ - start: seg.start, - end: seg.end, - myFill: {above: selection[index] === 1, below: selection[index] === 2} - }); - } - } - - return result; -} - -function segments(poly: MultiPolygon) { - const root = new LinkedList(); - - for (const region of poly) { - for (let i = 0; i < region.length; i++) { - const pt1 = i ? region[i - 1] : last(region); - const pt2 = region[i]; - - const forward = pointsCompare(pt1, pt2); - if (forward === 0) continue; // skip zero-length segments - - const start = forward < 0 ? pt1 : pt2; - const end = forward < 0 ? pt2 : pt1; - addSegment(root, {start, end, myFill: {}}, true); - } - } - - return calculate(root, true); -} - -function operate(poly1: MultiPolygon, poly2: MultiPolygon, selection: number[], precision?: number) { - if (precision !== undefined) PRECISION = precision; - const root = new LinkedList(); - for (const s of segments(poly1)) addSegment(root, copy(s.start, s.end, s), true); - for (const s of segments(poly2)) addSegment(root, copy(s.start, s.end, s), false); - - const results = segmentChainer(select(calculate(root, false), selection)); - PRECISION = DEFAULT_PRECISION; - return results.filter(polygon => polygon.length > 2); -} - - -// ----------------------------------------------------------------------------- -// Public Exports - -type MultiPolygon = Point[][]; - -const UNION = [0, 2, 1, 0, 2, 2, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0]; -const INTERSECT = [0, 0, 0, 0, 0, 2, 0, 2, 0, 0, 1, 1, 0, 2, 1, 0]; -const DIFFERENCE = [0, 0, 0, 0, 2, 0, 2, 0, 1, 1, 0, 0, 0, 1, 2, 0]; -const XOR = [0, 2, 1, 0, 2, 0, 0, 1, 1, 0, 0, 2, 0, 1, 2, 0]; - -export const union = (p1: MultiPolygon, p2: MultiPolygon, precision?: number) => operate(p1, p2, UNION, precision); -export const intersect = (p1: MultiPolygon, p2: MultiPolygon, precision?: number) => operate(p1, p2, INTERSECT, precision); -export const difference = (p1: MultiPolygon, p2: MultiPolygon, precision?: number) => operate(p1, p2, DIFFERENCE, precision); -export const xor = (p1: MultiPolygon, p2: MultiPolygon, precision?: number) => operate(p1, p2, XOR, precision); From 218a3b46de7d2ba934546d11d16b426ab5dfe19a Mon Sep 17 00:00:00 2001 From: Phil DeOrsey Date: Fri, 5 Apr 2024 08:17:16 -0400 Subject: [PATCH 04/10] add optional round to wrappers --- src/polygon.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/polygon.ts b/src/polygon.ts index ff29240..001358e 100755 --- a/src/polygon.ts +++ b/src/polygon.ts @@ -118,16 +118,16 @@ export class Polygon implements GeoShape { return false; } - static union(polygons: Polygon[], precision?: number): Polygon[] { + static union(polygons: Polygon[], precision?: number, useRound?: boolean): Polygon[] { const [first, ...other] = polygons; if (!other.length) return [first]; const p1 = [first.points]; - const p2 = other.length > 1 ? Polygon.union(other, precision).map(p => p.points) : [polygons[1].points]; - return union(p1, p2, precision).map(p => new Polygon(...p)); + const p2 = other.length > 1 ? Polygon.union(other, precision, useRound).map(p => p.points) : [polygons[1].points]; + return union(p1, p2, precision, useRound).map(p => new Polygon(...p)); } - static intersection(polygons: Polygon[], precision?: number): Polygon[] { + static intersection(polygons: Polygon[], precision?: number, useRound?: boolean): Polygon[] { const [first, ...other] = polygons; if (!other.length) return [first]; @@ -136,16 +136,16 @@ export class Polygon implements GeoShape { for (const poly of other) { const p1 = intersection; const p2 = [poly.points]; - intersection = intersect(p1, p2, precision); + intersection = intersect(p1, p2, precision, useRound); if (!intersection.length) return []; } return intersection.map(p => new Polygon(...p)); } - static difference(p1: Polygon, p2: Polygon, precision?: number): Polygon[] { - const poly12 = difference([p1.points], [p2.points], precision); - const poly21 = difference([p2.points], [p1.points], precision); + static difference(p1: Polygon, p2: Polygon, precision?: number, useRound?: boolean): Polygon[] { + const poly12 = difference([p1.points], [p2.points], precision, useRound); + const poly21 = difference([p2.points], [p1.points], precision, useRound); return poly12.concat(poly21).map(p => new Polygon(...p)); } From 4ccdc2494f8778cda83b8d81bf5df49114c1cb6e Mon Sep 17 00:00:00 2001 From: Phil DeOrsey Date: Fri, 5 Apr 2024 10:55:29 -0400 Subject: [PATCH 05/10] add some tests --- test/boolean-test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/boolean-test.ts b/test/boolean-test.ts index 26c64b5..f267d8d 100644 --- a/test/boolean-test.ts +++ b/test/boolean-test.ts @@ -7,6 +7,7 @@ import tape from 'tape'; import {difference, intersect, Polygon, union, xor} from '../src'; import {Point} from '../src'; +import {total} from "@mathigon/core"; const poly = (...p: number[][]) => p.map(q => new Point(q[0], q[1])); @@ -58,5 +59,34 @@ tape('intersections', (test) => { const r2 = Polygon.intersection([p1, p2]); test.equal(r2.length, 1); + // Minimally overlapping triangles + const t1 = new Polygon(new Point(630.64, 783.64), new Point(655.64, 826.941270189222), new Point(680.64, 783.64)); + const t2 = new Polygon(new Point(630.64, 783.6412701892219), new Point(655.64, 740.34), new Point(680.64, 783.6412701892219)); + const i1 = Polygon.intersection([t1, t2], undefined, true); + test.equal(i1.length, 0); + + // Minimally overlapping rhombus + const rhom1 = new Polygon(new Point(364.20573225037634, 762.5778998441511), new Point(393.594994865, 803.0287495628985), new Point(347.91772198287, 782.6919174091084), new Point(318.52845936824633, 742.2410676903611)); + const rhom2 = new Polygon(new Point(350.29712442218573, 828.024401212054), new Point(300.29712442218573, 828.024401212054), new Point(343.5983946114076, 803.024401212054), new Point(393.5983946114076, 803.024401212054)); + const i2 = Polygon.intersection([rhom1, rhom2], undefined, true); + test.equal(i2[0].area < .1, true); + + // Mostly overlapping rhombus + const rhom3 = new Polygon(new Point(391.08895330967306, 854.8726448020227), new Point(393.7057511218203, 804.941168064294), new Point(416.40527610879764, 849.4914942737123), new Point(413.7884782966504, 899.4229710114411)); + const rhom4 = new Polygon(new Point(390.9815967992604, 852.9558779497828), new Point(393.5983946114076, 803.024401212054), new Point(416.29791959838496, 847.5747274214724), new Point(413.6811217862377, 897.5062041592012)); + const i3 = Polygon.intersection([rhom3, rhom4], undefined, true); + test.equal(i3.length, 1); + + test.end(); +}); + +tape('unions', (test) => { + // In some configurations this throws a zero segment error. + const u1 = new Polygon(new Point(1622.9, 534.7), new Point(1522.9, 534.7), new Point(1547.9, 578), new Point(1597.9, 578)); + const u2 = new Polygon(new Point(1398.71, 552.1512701892219), new Point(1448.71, 552.1512701892219), new Point(1423.71, 508.85)); + const u3 = new Polygon(new Point(1448.71, 552.1487298107781), new Point(1398.71, 552.1487298107781), new Point(1423.71, 595.45)); + const union = Polygon.union([u1, u2, u3], undefined, true); + test.equal(Math.abs(total(union.map(u => u.area)) - (u1.area + u2.area + u3.area)) < 1, true); + test.end(); }); From ed14f738cecf52807221a4b74a568a32f1922b84 Mon Sep 17 00:00:00 2001 From: Phil DeOrsey Date: Fri, 5 Apr 2024 12:40:36 -0400 Subject: [PATCH 06/10] lint fix --- test/boolean-test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/boolean-test.ts b/test/boolean-test.ts index f267d8d..35de228 100644 --- a/test/boolean-test.ts +++ b/test/boolean-test.ts @@ -7,7 +7,7 @@ import tape from 'tape'; import {difference, intersect, Polygon, union, xor} from '../src'; import {Point} from '../src'; -import {total} from "@mathigon/core"; +import {total} from '@mathigon/core'; const poly = (...p: number[][]) => p.map(q => new Point(q[0], q[1])); From 8c09de661b95481ee9f96c46ba33ccaf27304010 Mon Sep 17 00:00:00 2001 From: Phil DeOrsey Date: Fri, 5 Apr 2024 14:04:40 -0400 Subject: [PATCH 07/10] adding in a reproducible bad union test --- test/boolean-test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/boolean-test.ts b/test/boolean-test.ts index 35de228..b4e988e 100644 --- a/test/boolean-test.ts +++ b/test/boolean-test.ts @@ -88,5 +88,12 @@ tape('unions', (test) => { const union = Polygon.union([u1, u2, u3], undefined, true); test.equal(Math.abs(total(union.map(u => u.area)) - (u1.area + u2.area + u3.area)) < 1, true); + // Using round on these two will produce a zero segment error. + const polyList = [ + new Polygon(new Point(1167.2641162274222, 3633.834721294776), new Point(1342.2641162274222, 3330.7258299702225), new Point(1167.2641162274222, 3330.7258299702225), new Point(1079.7641162274222, 3482.2802756324995)), + new Polygon(new Point(1692.26, 3936.95), new Point(1342.26, 3936.94), new Point(1254.76, 4088.49), new Point(1079.76, 4088.49), new Point(992.26, 3936.94), new Point(992.27, 3936.94), new Point(1167.26, 3633.83), new Point(1167.2636603221083, 3633.8336603221082), new Point(1342.26, 3330.74), new Point(1517.2542265184259, 3633.84), new Point(1692.26, 3633.84), new Point(1779.76, 3785.39)) + ]; + const badUnion = Polygon.union(polyList, undefined, false); + test.end(); }); From 23b7f8811abf665cdda3fbcd5b3867afb625b436 Mon Sep 17 00:00:00 2001 From: Phil DeOrsey Date: Fri, 5 Apr 2024 15:17:46 -0400 Subject: [PATCH 08/10] add clarifying comment --- test/boolean-test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/boolean-test.ts b/test/boolean-test.ts index b4e988e..b047dbb 100644 --- a/test/boolean-test.ts +++ b/test/boolean-test.ts @@ -88,7 +88,7 @@ tape('unions', (test) => { const union = Polygon.union([u1, u2, u3], undefined, true); test.equal(Math.abs(total(union.map(u => u.area)) - (u1.area + u2.area + u3.area)) < 1, true); - // Using round on these two will produce a zero segment error. + // if you change useRound to true on this union it will produce a zero segment error. const polyList = [ new Polygon(new Point(1167.2641162274222, 3633.834721294776), new Point(1342.2641162274222, 3330.7258299702225), new Point(1167.2641162274222, 3330.7258299702225), new Point(1079.7641162274222, 3482.2802756324995)), new Polygon(new Point(1692.26, 3936.95), new Point(1342.26, 3936.94), new Point(1254.76, 4088.49), new Point(1079.76, 4088.49), new Point(992.26, 3936.94), new Point(992.27, 3936.94), new Point(1167.26, 3633.83), new Point(1167.2636603221083, 3633.8336603221082), new Point(1342.26, 3330.74), new Point(1517.2542265184259, 3633.84), new Point(1692.26, 3633.84), new Point(1779.76, 3785.39)) From a8052cc4f5cc1c75bbaf39de519c48d34c1b6143 Mon Sep 17 00:00:00 2001 From: Phil DeOrsey Date: Fri, 5 Apr 2024 15:21:55 -0400 Subject: [PATCH 09/10] add test --- test/boolean-test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/boolean-test.ts b/test/boolean-test.ts index b047dbb..1626597 100644 --- a/test/boolean-test.ts +++ b/test/boolean-test.ts @@ -94,6 +94,7 @@ tape('unions', (test) => { new Polygon(new Point(1692.26, 3936.95), new Point(1342.26, 3936.94), new Point(1254.76, 4088.49), new Point(1079.76, 4088.49), new Point(992.26, 3936.94), new Point(992.27, 3936.94), new Point(1167.26, 3633.83), new Point(1167.2636603221083, 3633.8336603221082), new Point(1342.26, 3330.74), new Point(1517.2542265184259, 3633.84), new Point(1692.26, 3633.84), new Point(1779.76, 3785.39)) ]; const badUnion = Polygon.union(polyList, undefined, false); + test.equal(Math.abs(total(badUnion.map(u => u.area)) - total(polyList.map(u => u.area))) < 1, true); test.end(); }); From bf001b7f0b70266798837f1f1f4393e8b00406ba Mon Sep 17 00:00:00 2001 From: Phil DeOrsey Date: Fri, 5 Apr 2024 15:31:07 -0400 Subject: [PATCH 10/10] isolate tests that showcase use of round --- test/boolean-test.ts | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/test/boolean-test.ts b/test/boolean-test.ts index 1626597..0efe787 100644 --- a/test/boolean-test.ts +++ b/test/boolean-test.ts @@ -60,34 +60,16 @@ tape('intersections', (test) => { test.equal(r2.length, 1); // Minimally overlapping triangles + // If useRound is false this produces a zero segment error! const t1 = new Polygon(new Point(630.64, 783.64), new Point(655.64, 826.941270189222), new Point(680.64, 783.64)); const t2 = new Polygon(new Point(630.64, 783.6412701892219), new Point(655.64, 740.34), new Point(680.64, 783.6412701892219)); const i1 = Polygon.intersection([t1, t2], undefined, true); test.equal(i1.length, 0); - // Minimally overlapping rhombus - const rhom1 = new Polygon(new Point(364.20573225037634, 762.5778998441511), new Point(393.594994865, 803.0287495628985), new Point(347.91772198287, 782.6919174091084), new Point(318.52845936824633, 742.2410676903611)); - const rhom2 = new Polygon(new Point(350.29712442218573, 828.024401212054), new Point(300.29712442218573, 828.024401212054), new Point(343.5983946114076, 803.024401212054), new Point(393.5983946114076, 803.024401212054)); - const i2 = Polygon.intersection([rhom1, rhom2], undefined, true); - test.equal(i2[0].area < .1, true); - - // Mostly overlapping rhombus - const rhom3 = new Polygon(new Point(391.08895330967306, 854.8726448020227), new Point(393.7057511218203, 804.941168064294), new Point(416.40527610879764, 849.4914942737123), new Point(413.7884782966504, 899.4229710114411)); - const rhom4 = new Polygon(new Point(390.9815967992604, 852.9558779497828), new Point(393.5983946114076, 803.024401212054), new Point(416.29791959838496, 847.5747274214724), new Point(413.6811217862377, 897.5062041592012)); - const i3 = Polygon.intersection([rhom3, rhom4], undefined, true); - test.equal(i3.length, 1); - test.end(); }); tape('unions', (test) => { - // In some configurations this throws a zero segment error. - const u1 = new Polygon(new Point(1622.9, 534.7), new Point(1522.9, 534.7), new Point(1547.9, 578), new Point(1597.9, 578)); - const u2 = new Polygon(new Point(1398.71, 552.1512701892219), new Point(1448.71, 552.1512701892219), new Point(1423.71, 508.85)); - const u3 = new Polygon(new Point(1448.71, 552.1487298107781), new Point(1398.71, 552.1487298107781), new Point(1423.71, 595.45)); - const union = Polygon.union([u1, u2, u3], undefined, true); - test.equal(Math.abs(total(union.map(u => u.area)) - (u1.area + u2.area + u3.area)) < 1, true); - // if you change useRound to true on this union it will produce a zero segment error. const polyList = [ new Polygon(new Point(1167.2641162274222, 3633.834721294776), new Point(1342.2641162274222, 3330.7258299702225), new Point(1167.2641162274222, 3330.7258299702225), new Point(1079.7641162274222, 3482.2802756324995)),