Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Stroke caps #32

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
*.swp
.meteor
.DS_Store
node_modules
18,668 changes: 9,334 additions & 9,334 deletions graphics.txt

Large diffs are not rendered by default.

169 changes: 169 additions & 0 deletions stroke_caps/fixStrokes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
const {
distToPath,
getCosSimAroundPoint,
getLinesIntersectPoint,
getOutlinePoints,
extendPointOnLine,
estimateTanPoints,
roundPathPoints,
ptEq,
dist,
} = require('./utils');


CLIP_THRESH = 2;
LOWER_COS_SIM_THRESH = 0.89;
UPPER_COS_SIM_THRESH = 0.97;

// A bridge is a place in the pathstring where 2 strokes intersect. It can either be 1 stroke clipping
// another, or it can be strokes passing through each other. In the pathstring from makemeahanzi, any
// L # # in the pathstring is a
class Bridge {
constructor(points, pointString, stroke) {
this.points = points;
this.pointString = pointString;
this.stroke = stroke;
this.estTanPoints = estimateTanPoints(stroke.outline, points);
}

getClips() {
// this clip point is super tiny, it's probably just a glitch, skip it
if (dist(this.points[0], this.points[1]) < 3.1) return [];
const cosSim0 = getCosSimAroundPoint(this.points[0], this.stroke.outline);
const cosSim1 = getCosSimAroundPoint(this.points[1], this.stroke.outline);
// If the angle around the bridge points looks flat, it's probably an intersection.
if (Math.min(cosSim0, cosSim1) > LOWER_COS_SIM_THRESH && Math.max(cosSim0, cosSim1) > UPPER_COS_SIM_THRESH) {
return [];
}
return this.stroke.character.strokes.filter(stroke => {
if (stroke === this.stroke) return false;
const dist0 = distToPath(this.points[0], stroke.outline);
const dist1 = distToPath(this.points[1], stroke.outline);
return dist0 <= CLIP_THRESH && dist1 <= CLIP_THRESH;
}).map(clippingStroke => new Clip(this, clippingStroke));
}
}

class Clip {
constructor(bridge, clippingStroke) {
this.points = bridge.points;
this.estTanPoints = bridge.estTanPoints;
this.pointString = bridge.pointString;
this.clippedBy = [clippingStroke];
this.isDouble = false;
}

canMerge(otherClip) {
return ptEq(this.points[1], otherClip.points[0]);
}

mergeIntoDouble(otherClip) {
this.isDouble = true;
this.clippedBy = this.clippedBy.concat(otherClip.clippedBy);
this.middlePoint = otherClip.points[0];
this.points[1] = otherClip.points[1];
this.estTanPoints[1] = otherClip.estTanPoints[1];
this.pointString += otherClip.pointString.replace(/.*L/, ' L');
}

getNewStrokeTip() {
const maxControlPoint = getLinesIntersectPoint(
this.estTanPoints[0],
this.points[0],
this.estTanPoints[1],
this.points[1],
);

const maxDistControl0 = dist(maxControlPoint, this.points[0]);
const maxDistControl1 = dist(maxControlPoint, this.points[1]);
let distControl0 = Math.min(maxDistControl0, 30);
let distControl1 = Math.min(maxDistControl1, 30);

// if the 2 lines are parallel, there will be no intersection point. Just use 30 in that case.
if (isNaN(distControl0)) distControl0 = 30;
if (isNaN(distControl1)) distControl1 = 30;

if (this.isDouble) {
const midDist0 = dist(this.middlePoint, this.points[0]);
const midDist1 = dist(this.middlePoint, this.points[1]);
distControl0 = Math.max(midDist0 * 1.4, distControl0);
distControl1 = Math.max(midDist1 * 1.4, distControl1);
}

const controlPoint0 = extendPointOnLine(this.estTanPoints[0], this.points[0], distControl0);
const controlPoint1 = extendPointOnLine(this.estTanPoints[1], this.points[1], distControl1);

const pString = point => `${Math.round(point.x)} ${Math.round(point.y)}`;

return `${pString(this.points[0])} C ${pString(controlPoint0)} ${pString(controlPoint1)} ${pString(this.points[1])}`;
}
}

class Stroke {
constructor(pathString, character, strokeNum) {
this.pathString = pathString;
this.outline = getOutlinePoints(pathString);
this.character = character;
this.strokeNum = strokeNum;
}

getBridges() {
const pointStringParts = this.pathString.match(/-?\d+(?:\.\d+)? -?\d+(?:\.\d+)? L/ig);
if (!pointStringParts) return [];
return pointStringParts.map(pointStringPart => {
const fullPointStringRegex = new RegExp(`${pointStringPart} -?\\d+(?:\\.\\d+)? -?\\d+(?:\\.\\d+)?`);
const pointString = this.pathString.match(fullPointStringRegex)[0];
const parts = pointString.split(/\sL?\s?/).map(num => parseFloat(num));
const points = [{x: parts[0], y: parts[1]}, {x: parts[2], y: parts[3]}];
return new Bridge(points, pointString, this);
});
}

fixPathString() {
const bridges = this.getBridges();
let clips = [];
bridges.forEach(bridge => {
bridge.getClips().forEach(clip => {
const lastClip = clips[clips.length - 1];
if (lastClip && lastClip.canMerge(clip)) {
lastClip.mergeIntoDouble(clip);
} else {
clips.push(clip);
}
});
});

let modifiedPathString = this.pathString;
clips.forEach(clip => {
const newTip = clip.getNewStrokeTip();
modifiedPathString = roundPathPoints(modifiedPathString.replace(clip.pointString, newTip));
});

return {
isModified: clips.length > 0,
isDoubleClipped: !!clips.find(clip => clip.isDouble),
pathString: modifiedPathString,
strokeNum: this.strokeNum,
};
}
}

class Character {
constructor(pathStrings) {
this.strokes = pathStrings.map((path, i) => new Stroke(path, this, i));
}
}

const fixStrokes = (strokePathStrings) => {
const character = new Character(strokePathStrings);
const fixedStrokesInfo = character.strokes.map(stroke => stroke.fixPathString());

return {
modified: !!fixedStrokesInfo.find(summary => summary.isModified),
hasDoubleClippedStroke: !!fixedStrokesInfo.find(summary => summary.isDoubleClipped),
modifiedStrokes: fixedStrokesInfo.filter(summary => summary.isModified).map(summary => summary.strokeNum),
strokes: fixedStrokesInfo.map(summary => summary.pathString),
};
};

module.exports = fixStrokes;
8 changes: 8 additions & 0 deletions stroke_caps/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"devDependencies": {
"babel-preset-env": "^1.6.1",
"commander": "^2.14.1",
"point-at-length": "^1.1.0",
"svg-path-properties": "^0.4.1"
}
}
51 changes: 51 additions & 0 deletions stroke_caps/updateGraphicsTxt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
const program = require('commander');
const fs = require('fs');
const fixStrokes = require('./fixStrokes');

program
.option('-i --input <path>', 'path to graphics.txt. Defaults to ./graphics.txt')
.option('-o --output <path>', 'path to write updated graphics.txt. Defaults to ./graphics-capped.txt', 'graphics-capped.txt')
.option('-v --verbose', 'output debugging info while running')
.parse(process.argv);

const inputFile = program.input || 'graphics.txt';
const outputFile = program.output || 'graphics-capped.txt';

const log = (msg, force = false) => program.verbose || force ? console.log(msg) : null;

log(`reading ${inputFile}`);
const input = fs.readFileSync(inputFile, 'utf8');
const inputLines = input.split('\n');
let outputJsonStrings = [];

let count = 0;
let modifiedChars = [];
let doubleClippedChars = [];
const total = inputLines.length;

inputLines.forEach(line => {
if (!line) return;
count += 1;
const data = JSON.parse(line);
log(`${count}/${total}:\t${data.character}`);
const correction = fixStrokes(data.strokes);
if (correction.modified) {
log(`modified ${correction.modifiedStrokes.length} strokes`);
modifiedChars.push(data.character);

if (correction.hasDoubleClippedStroke) {
doubleClippedChars.push(data.character);
}

data.strokes = correction.strokes;
}
outputJsonStrings.push(JSON.stringify(data));
});

log(`writing ${outputFile}`)
fs.writeFileSync(outputFile, outputJsonStrings.join('\n') + '\n');

log('Done!', true);
log(`Read ${count} chars`, true);
log(`Modified ${modifiedChars.length} chars`, true);
log(`Double-clipped stroke characters: ${doubleClippedChars.join(', ')}`);
98 changes: 98 additions & 0 deletions stroke_caps/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
const svgPathUtils = require('point-at-length');

const dist = (p1, p2) => Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2));
const norm = (vect) => dist(vect, {x: 0, y: 0});
const subtract = (p1, p2) => ({x: p1.x - p2.x, y: p1.y - p2.y});
const ptEq = (p1, p2) => p1.x === p2.x && p1.y === p2.y;

const getOutlinePoints = (pathString, count = 1000) => {
const path = svgPathUtils(pathString);
const delta = path.length() / count;
const outline = [];
for (let i = 0; i < count; i += 1) {
const svgPoint = path.at(i * delta);
outline.push({x: svgPoint[0], y: svgPoint[1]});
}
return outline;
};

// get the intersection point of 2 lines defined by 2 points each
// from https://en.wikipedia.org/wiki/Line%E2%80%93line_intersection
const getLinesIntersectPoint = (l1p1, l1p2, l2p1, l2p2) => {
const x1 = l1p1.x;
const x2 = l1p2.x;
const x3 = l2p1.x;
const x4 = l2p2.x;
const y1 = l1p1.y;
const y2 = l1p2.y;
const y3 = l2p1.y;
const y4 = l2p2.y;
const xNumerator = (x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4);
const yNumerator = (x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4);
const denominator = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
return {x: xNumerator / denominator, y: yNumerator / denominator};
};

const getPointIndex = (point, pathOutline) => {
const dists = pathOutline.map(outlinePoint => dist(point, outlinePoint));
const min = Math.min(...dists);
return dists.indexOf(min);
};

const getIndexAtDelta = (index, delta, pathOutline) => {
return (pathOutline.length + index + delta) % pathOutline.length;
};

const getCosSimAroundPoint = (point, pathOutline) => {
// if this is 1, the point is on a flat line.
const pointIndex = getPointIndex(point, pathOutline);
const preIndex = getIndexAtDelta(pointIndex, -3, pathOutline);
const postIndex = getIndexAtDelta(pointIndex, 3, pathOutline);
const vect1 = subtract(pathOutline[pointIndex], pathOutline[preIndex]);
const vect2 = subtract(pathOutline[postIndex], pathOutline[pointIndex]);
return (vect1.x * vect2.x + vect1.y * vect2.y) / (norm(vect1) * norm(vect2));
};

// return a new point, p3, which is on the same line as p1 and p2, but distance away
// from p2. p1, p2, p3 will always lie on the line in that order
const extendPointOnLine = (p1, p2, distance) => {
const vect = subtract(p2, p1);
const mag = distance / norm(vect);
return {x: p2.x + mag * vect.x, y: p2.y + mag * vect.y};
};

const distToPath = (point, pathOutline) => {
const dists = pathOutline.map(outlinePoint => dist(point, outlinePoint));
return Math.min(...dists);
};

const roundPathPoints = (pathString) => {
const floats = pathString.match(/\d+\.\d+/ig);
if (!floats) return pathString;
let fixedPathString = pathString;
floats.forEach(float => {
fixedPathString = fixedPathString.replace(float, Math.round(parseFloat(float)));
});
return fixedPathString;
};

const estimateTanPoints = (pathOutline, clipPoints) => {
const cpIndex0 = getPointIndex(clipPoints[0], pathOutline);
const cpIndex1 = getPointIndex(clipPoints[1], pathOutline);
return [
pathOutline[getIndexAtDelta(cpIndex0, -15, pathOutline)],
pathOutline[getIndexAtDelta(cpIndex1, 15, pathOutline)],
];
};

module.exports = {
distToPath,
getCosSimAroundPoint,
getOutlinePoints,
getLinesIntersectPoint,
extendPointOnLine,
estimateTanPoints,
dist,
ptEq,
roundPathPoints,
};
Loading