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 @@
+
+
+
+