From f3beb8cdd94d5c616144c283fb524afde02bb8a8 Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Sun, 14 Feb 2021 12:07:34 -0500 Subject: [PATCH] Auto-detect direction of normals. (#2) This auto-detects the direction of normals on which to attach attachments. Right now it's getting very confused by corners, but otherwise it seems to mostly work. --- lib/browser-main.tsx | 45 +++++--- lib/specs.ts | 217 ++++++++++++++++++++++++++++++++++++--- svg/antler specs.svg | 18 ++++ svg/arm specs.svg | 21 ++++ svg/circle specs.svg | 19 ++++ svg/cloud specs.svg | 19 ++++ svg/crown specs.svg | 21 ++++ svg/cup specs.svg | 19 ++++ svg/eye specs.svg | 19 ++++ svg/goat horn specs.svg | 19 ++++ svg/hand specs.svg | 19 ++++ svg/heart specs.svg | 18 ++++ svg/leg hoof specs.svg | 20 ++++ svg/leg specs.svg | 18 ++++ svg/muscle arm specs.svg | 20 ++++ svg/tail specs.svg | 20 ++++ svg/wing specs.svg | 19 ++++ 17 files changed, 522 insertions(+), 29 deletions(-) create mode 100644 svg/antler specs.svg create mode 100644 svg/arm specs.svg create mode 100644 svg/circle specs.svg create mode 100644 svg/cloud specs.svg create mode 100644 svg/crown specs.svg create mode 100644 svg/cup specs.svg create mode 100644 svg/eye specs.svg create mode 100644 svg/goat horn specs.svg create mode 100644 svg/hand specs.svg create mode 100644 svg/heart specs.svg create mode 100644 svg/leg hoof specs.svg create mode 100644 svg/leg specs.svg create mode 100644 svg/muscle arm specs.svg create mode 100644 svg/tail specs.svg create mode 100644 svg/wing specs.svg diff --git a/lib/browser-main.tsx b/lib/browser-main.tsx index 855dadd..a1cb536 100644 --- a/lib/browser-main.tsx +++ b/lib/browser-main.tsx @@ -4,7 +4,7 @@ import { Point, BBox } from "../vendor/bezier-js"; import { dilateBoundingBox, getBoundingBoxSize } from "./bounding-box"; import { FILL_REPLACEMENT_COLOR, STROKE_REPLACEMENT_COLOR } from "./colors"; import * as colors from "./colors"; -import { Specs } from "./specs"; +import { PointWithNormal, Specs } from "./specs"; import _SvgVocabulary from "./svg-vocabulary.json"; import type { SvgSymbolData, SvgSymbolElement } from "./vocabulary"; @@ -68,19 +68,38 @@ function reactifySvgSymbolElement( const ATTACHMENT_POINT_RADIUS = 20; -const AttachmentPoints: React.FC<{ color: string; points: Point[] }> = ( - props -) => ( +const ATTACHMENT_POINT_NORMAL_LENGTH = 50; + +const ATTACHMENT_POINT_NORMAL_STROKE = 4; + +const AttachmentPoints: React.FC<{ + color: string; + points: PointWithNormal[]; +}> = (props) => ( <> - {props.points.map((p, i) => ( - - ))} + {props.points.map((pwn, i) => { + const { x, y } = pwn.point; + const x2 = x + pwn.normal.x * ATTACHMENT_POINT_NORMAL_LENGTH; + const y2 = y + pwn.normal.y * ATTACHMENT_POINT_NORMAL_LENGTH; + return ( + + + + + ); + })} ); diff --git a/lib/specs.ts b/lib/specs.ts index 49810d4..10c2160 100644 --- a/lib/specs.ts +++ b/lib/specs.ts @@ -1,27 +1,37 @@ -import { Point, BBox } from "../vendor/bezier-js"; +import { Point, BBox, Bezier, Line } from "../vendor/bezier-js"; import { getBoundingBoxCenter, getBoundingBoxForBeziers } from "./bounding-box"; import * as colors from "./colors"; import { pathToShapes } from "./path"; +import { flatten } from "./util"; import type { SvgSymbolElement } from "./vocabulary"; const SPEC_LAYER_ID_RE = /^specs.*/i; +export type PointWithNormal = { + point: Point; + normal: Point; +}; + export type Specs = { - tail?: Point[]; - leg?: Point[]; - arm?: Point[]; - horn?: Point[]; - crown?: Point[]; + tail?: PointWithNormal[]; + leg?: PointWithNormal[]; + arm?: PointWithNormal[]; + horn?: PointWithNormal[]; + crown?: PointWithNormal[]; nesting?: BBox[]; }; -function getPoints(path: string): Point[] { +function getPointsWithEmptyNormals(path: string): PointWithNormal[] { const shapes = pathToShapes(path); - const points: Point[] = []; + const points: PointWithNormal[] = []; for (let shape of shapes) { const bbox = getBoundingBoxForBeziers(shape); - points.push(getBoundingBoxCenter(bbox)); + const point = getBoundingBoxCenter(bbox); + points.push({ + point, + normal: ORIGIN, + }); } return points; @@ -45,15 +55,30 @@ function concat(first: T[] | undefined, second: T[]): T[] { function updateSpecs(fill: string, path: string, specs: Specs): Specs { switch (fill) { case colors.TAIL_ATTACHMENT_COLOR: - return { ...specs, tail: concat(specs.tail, getPoints(path)) }; + return { + ...specs, + tail: concat(specs.tail, getPointsWithEmptyNormals(path)), + }; case colors.LEG_ATTACHMENT_COLOR: - return { ...specs, leg: concat(specs.leg, getPoints(path)) }; + return { + ...specs, + leg: concat(specs.leg, getPointsWithEmptyNormals(path)), + }; case colors.ARM_ATTACHMENT_COLOR: - return { ...specs, arm: concat(specs.arm, getPoints(path)) }; + return { + ...specs, + arm: concat(specs.arm, getPointsWithEmptyNormals(path)), + }; case colors.HORN_ATTACHMENT_COLOR: - return { ...specs, horn: concat(specs.horn, getPoints(path)) }; + return { + ...specs, + horn: concat(specs.horn, getPointsWithEmptyNormals(path)), + }; case colors.CROWN_ATTACHMENT_COLOR: - return { ...specs, crown: concat(specs.crown, getPoints(path)) }; + return { + ...specs, + crown: concat(specs.crown, getPointsWithEmptyNormals(path)), + }; case colors.NESTING_BOUNDING_BOX_COLOR: return { ...specs, @@ -85,8 +110,163 @@ function getSpecs(layers: SvgSymbolElement[]): Specs { return specs; } +function filterElements( + elements: SvgSymbolElement[], + filter: (el: SvgSymbolElement) => boolean +): SvgSymbolElement[] { + const result: SvgSymbolElement[] = []; + + for (let el of elements) { + if (filter(el)) { + switch (el.tagName) { + case "g": + result.push({ + ...el, + children: filterElements(el.children, filter), + }); + break; + case "path": + result.push(el); + break; + } + } + } + + return result; +} + +function getAllShapes(layers: SvgSymbolElement[]): Bezier[][] { + const beziers: Bezier[][] = []; + + for (let layer of layers) { + switch (layer.tagName) { + case "g": + beziers.push(...getAllShapes(layer.children)); + break; + case "path": + if (!layer.props.d) { + throw new Error(` does not have a "d" attribute!`); + } + beziers.push(...pathToShapes(layer.props.d)); + break; + } + } + + return beziers; +} + +const ORIGIN: Point = { x: 0, y: 0 }; + +function invertVector(v: Point) { + v.x = -v.x; + v.y = -v.y; +} + +const TO_INFINITY_AMOUNT = 2000; + +/** + * Return whether the given point is inside the given shape, assuming + * the SVG "evenodd" fill rule: + * + * https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/fill-rule#evenodd + */ +function isPointInsideShape(point: Point, beziers: Bezier[]): boolean { + let intersections = 0; + const line: Line = { + p1: point, + p2: { + x: point.x + TO_INFINITY_AMOUNT, + y: point.y + TO_INFINITY_AMOUNT, + }, + }; + + for (let bezier of beziers) { + const points = bezier.lineIntersects(line); + intersections += points.length; + } + + const isOdd = intersections % 2 === 1; + return isOdd; +} + +function addPoints(p1: Point, p2: Point): Point { + return { + x: p1.x + p2.x, + y: p1.y + p2.y, + }; +} + +function populateNormals(pwns: PointWithNormal[], shapes: Bezier[][]) { + if (shapes.length === 0) { + throw new Error(`Expected beizers to be non-empty!`); + } + + for (let pwn of pwns) { + let minDistance = Infinity; + let minDistanceNormal = ORIGIN; + let minDistancePoint = ORIGIN; + for (let shape of shapes) { + let minDistanceChanged = false; + for (let bezier of shape) { + const { t, d } = bezier.project(pwn.point); + if (d === undefined || t === undefined) { + throw new Error(`Expected bezier.project() to return t and d!`); + } + if (d < minDistance) { + minDistanceChanged = true; + minDistance = d; + minDistanceNormal = bezier.normal(t); + minDistancePoint = bezier.get(t); + } + } + if (minDistanceChanged) { + const pointToNormal = addPoints(minDistancePoint, minDistanceNormal); + if (isPointInsideShape(pointToNormal, shape)) { + invertVector(minDistanceNormal); + } + } + } + pwn.normal = minDistanceNormal; + } +} + +function filterFilledShapes(elements: SvgSymbolElement[]): SvgSymbolElement[] { + return filterElements(elements, (el) => { + if (el.tagName === "path") { + if (el.props.fill === "none") return false; + if (el.props.fillRule !== "evenodd") { + throw new Error( + `Expected to have fill-rule="evenodd" but it is "${el.props.fillRule}"!` + ); + } + } + return true; + }); +} + +function populateSpecNormals(specs: Specs, layers: SvgSymbolElement[]): void { + const shapes = getAllShapes(filterFilledShapes(layers)); + + if (specs.tail) { + populateNormals(specs.tail, shapes); + } + if (specs.leg) { + populateNormals(specs.leg, shapes); + } + if (specs.arm) { + populateNormals(specs.arm, shapes); + } + if (specs.horn) { + populateNormals(specs.horn, shapes); + } + if (specs.crown) { + populateNormals(specs.crown, shapes); + } +} + export function extractSpecs( - layers: SvgSymbolElement[] + layers: SvgSymbolElement[], + populateNormals: boolean = true ): [Specs | undefined, SvgSymbolElement[]] { const layersWithoutSpecs: SvgSymbolElement[] = []; let specs: Specs | undefined = undefined; @@ -107,7 +287,7 @@ export function extractSpecs( if (id && SPEC_LAYER_ID_RE.test(id)) { setSpecs(getSpecs(layer.children)); } else { - let [s, children] = extractSpecs(layer.children); + let [s, children] = extractSpecs(layer.children, false); setSpecs(s); layersWithoutSpecs.push({ ...layer, @@ -117,8 +297,13 @@ export function extractSpecs( break; case "path": layersWithoutSpecs.push(layer); + break; } } + if (populateNormals && specs) { + populateSpecNormals(specs, layersWithoutSpecs); + } + return [specs, layersWithoutSpecs]; } diff --git a/svg/antler specs.svg b/svg/antler specs.svg new file mode 100644 index 0000000..b86a32c --- /dev/null +++ b/svg/antler specs.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/svg/arm specs.svg b/svg/arm specs.svg new file mode 100644 index 0000000..e48cfcd --- /dev/null +++ b/svg/arm specs.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/svg/circle specs.svg b/svg/circle specs.svg new file mode 100644 index 0000000..4e68941 --- /dev/null +++ b/svg/circle specs.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/svg/cloud specs.svg b/svg/cloud specs.svg new file mode 100644 index 0000000..f312642 --- /dev/null +++ b/svg/cloud specs.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/svg/crown specs.svg b/svg/crown specs.svg new file mode 100644 index 0000000..28eddf0 --- /dev/null +++ b/svg/crown specs.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/svg/cup specs.svg b/svg/cup specs.svg new file mode 100644 index 0000000..656c202 --- /dev/null +++ b/svg/cup specs.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/svg/eye specs.svg b/svg/eye specs.svg new file mode 100644 index 0000000..1726b23 --- /dev/null +++ b/svg/eye specs.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/svg/goat horn specs.svg b/svg/goat horn specs.svg new file mode 100644 index 0000000..505403b --- /dev/null +++ b/svg/goat horn specs.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/svg/hand specs.svg b/svg/hand specs.svg new file mode 100644 index 0000000..be9a9a1 --- /dev/null +++ b/svg/hand specs.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/svg/heart specs.svg b/svg/heart specs.svg new file mode 100644 index 0000000..d812f20 --- /dev/null +++ b/svg/heart specs.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/svg/leg hoof specs.svg b/svg/leg hoof specs.svg new file mode 100644 index 0000000..4edd9c6 --- /dev/null +++ b/svg/leg hoof specs.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/svg/leg specs.svg b/svg/leg specs.svg new file mode 100644 index 0000000..71cde40 --- /dev/null +++ b/svg/leg specs.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/svg/muscle arm specs.svg b/svg/muscle arm specs.svg new file mode 100644 index 0000000..f7b2a7c --- /dev/null +++ b/svg/muscle arm specs.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/svg/tail specs.svg b/svg/tail specs.svg new file mode 100644 index 0000000..d43d641 --- /dev/null +++ b/svg/tail specs.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/svg/wing specs.svg b/svg/wing specs.svg new file mode 100644 index 0000000..2341366 --- /dev/null +++ b/svg/wing specs.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + +