mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-20 11:36:25 +08:00
refactor(editor): unify directories naming (#11516)
**Directory Structure Changes** - Renamed multiple block-related directories by removing the "block-" prefix: - `block-attachment` → `attachment` - `block-bookmark` → `bookmark` - `block-callout` → `callout` - `block-code` → `code` - `block-data-view` → `data-view` - `block-database` → `database` - `block-divider` → `divider` - `block-edgeless-text` → `edgeless-text` - `block-embed` → `embed`
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
import { almostEqual, type Bound, type IVec3 } from '@blocksuite/global/gfx';
|
||||
|
||||
import { Graph } from './graph.js';
|
||||
import { PriorityQueue } from './priority-queue.js';
|
||||
|
||||
function isNullish<T>(val: T | null | undefined): val is null | undefined {
|
||||
return val === null || val === undefined;
|
||||
}
|
||||
|
||||
function cost(point: IVec3, point2: IVec3) {
|
||||
return Math.abs(point[0] - point2[0]) + Math.abs(point[1] - point2[1]);
|
||||
}
|
||||
|
||||
function compare(a: [number, number, number], b: [number, number, number]) {
|
||||
if (a[2] + 0.01 < b[2]) return -1;
|
||||
else if (a[2] - 0.01 > b[2]) return 1;
|
||||
else if (a[0] < b[0]) return -1;
|
||||
else if (a[0] > b[0]) return 1;
|
||||
else if (a[1] > b[1]) return -1;
|
||||
else if (a[1] < b[1]) return 1;
|
||||
else return 0;
|
||||
}
|
||||
|
||||
function heuristic(a: IVec3, b: IVec3): number {
|
||||
return Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]);
|
||||
}
|
||||
|
||||
function getDiagonalCount(a: IVec3, last: IVec3, last2: IVec3): number {
|
||||
if (almostEqual(a[0], last[0]) && almostEqual(a[0], last2[0])) return 0;
|
||||
if (almostEqual(a[1], last[1]) && almostEqual(a[1], last2[1])) return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function pointAlmostEqual(a: IVec3, b: IVec3): boolean {
|
||||
return almostEqual(a[0], b[0], 0.02) && almostEqual(a[1], b[1], 0.02);
|
||||
}
|
||||
|
||||
export class AStarRunner {
|
||||
private readonly _cameFrom = new Map<
|
||||
IVec3,
|
||||
{ from: IVec3[]; indexs: number[] }
|
||||
>();
|
||||
|
||||
private _complete = false;
|
||||
|
||||
private readonly _costSoFar = new Map<IVec3, number[]>();
|
||||
|
||||
private _current: IVec3 | null = null;
|
||||
|
||||
private readonly _diagonalCount = new Map<IVec3, number[]>();
|
||||
|
||||
private _frontier!: PriorityQueue<
|
||||
IVec3,
|
||||
[diagonalCount: number, pointPriority: number, distCost: number]
|
||||
>;
|
||||
|
||||
private readonly _graph: Graph<IVec3>;
|
||||
|
||||
private readonly _pointPriority = new Map<IVec3, number[]>();
|
||||
|
||||
get path() {
|
||||
const result: IVec3[] = [];
|
||||
let current: null | IVec3 = this._complete
|
||||
? this._originalEp
|
||||
: this._current;
|
||||
const nextIndexs = [0];
|
||||
while (current) {
|
||||
result.unshift(current);
|
||||
const froms = this._cameFrom.get(current);
|
||||
if (!froms) return result;
|
||||
const index = nextIndexs.shift();
|
||||
if (isNullish(index)) {
|
||||
break;
|
||||
}
|
||||
nextIndexs.push(froms.indexs[index]);
|
||||
current = froms.from[index];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
constructor(
|
||||
points: IVec3[],
|
||||
private readonly _sp: IVec3,
|
||||
private readonly _ep: IVec3,
|
||||
private readonly _originalSp: IVec3,
|
||||
private _originalEp: IVec3,
|
||||
blocks: Bound[] = [],
|
||||
expandBlocks: Bound[] = []
|
||||
) {
|
||||
this._sp[2] = 0;
|
||||
this._ep[2] = 0;
|
||||
this._originalEp[2] = 0;
|
||||
this._graph = new Graph([...points], blocks, expandBlocks);
|
||||
this._init();
|
||||
}
|
||||
|
||||
private _init() {
|
||||
this._cameFrom.set(this._sp, { from: [this._originalSp], indexs: [-1] });
|
||||
this._cameFrom.set(this._originalSp, { from: [], indexs: [] });
|
||||
|
||||
this._costSoFar.set(this._sp, [0]);
|
||||
this._diagonalCount.set(this._sp, [0]);
|
||||
this._pointPriority.set(this._sp, [0]);
|
||||
this._frontier = new PriorityQueue<
|
||||
IVec3,
|
||||
[diagonalCount: number, pointPriority: number, distCost: number]
|
||||
>(compare);
|
||||
this._frontier.enqueue(this._sp, [0, 0, 0]);
|
||||
}
|
||||
|
||||
private _neighbors(cur: IVec3) {
|
||||
const neighbors = this._graph.neighbors(cur);
|
||||
const cameFroms = this._cameFrom.get(cur);
|
||||
|
||||
if (cameFroms) {
|
||||
cameFroms.from.forEach(from => {
|
||||
const index = neighbors.findIndex(n => pointAlmostEqual(n, from));
|
||||
if (index >= 0) {
|
||||
neighbors.splice(index, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (cur === this._ep) neighbors.push(this._originalEp);
|
||||
return neighbors;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this._cameFrom.clear();
|
||||
this._costSoFar.clear();
|
||||
this._diagonalCount.clear();
|
||||
this._pointPriority.clear();
|
||||
this._complete = false;
|
||||
this._init();
|
||||
}
|
||||
|
||||
run() {
|
||||
while (!this._complete) {
|
||||
this.step();
|
||||
}
|
||||
}
|
||||
|
||||
step() {
|
||||
if (this._complete) return;
|
||||
this._current = this._frontier.dequeue();
|
||||
const current = this._current;
|
||||
if (!current) {
|
||||
this._complete = true;
|
||||
return;
|
||||
}
|
||||
if (current === this._ep && pointAlmostEqual(this._ep, this._originalEp)) {
|
||||
this._originalEp = this._ep;
|
||||
}
|
||||
const neighbors = this._neighbors(current);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
||||
for (let i = 0; i < neighbors.length; i++) {
|
||||
const next = neighbors[i];
|
||||
const curCosts = this._costSoFar.get(current);
|
||||
const curDiagoalCounts = this._diagonalCount.get(current);
|
||||
const curPointPrioritys = this._pointPriority.get(current);
|
||||
const cameFroms = this._cameFrom.get(current);
|
||||
if (!curCosts || !curDiagoalCounts || !curPointPrioritys || !cameFroms) {
|
||||
continue;
|
||||
}
|
||||
const newCosts = curCosts.map(co => co + cost(current, next));
|
||||
|
||||
const newDiagonalCounts = curDiagoalCounts.map(
|
||||
(count, index) =>
|
||||
count + getDiagonalCount(next, current, cameFroms.from[index])
|
||||
);
|
||||
if (isNullish(next[2])) {
|
||||
continue;
|
||||
}
|
||||
const newPointPrioritys = curPointPrioritys.map(
|
||||
pointPriority => pointPriority + next[2]
|
||||
);
|
||||
let index = -1;
|
||||
if (newCosts.length === 1) {
|
||||
index = 0;
|
||||
} else {
|
||||
const costsIndexs = findAllMinimalIndexs(
|
||||
newCosts,
|
||||
(a, b) => a + 0.01 < b,
|
||||
(a, b) => almostEqual(a, b, 0.02)
|
||||
);
|
||||
if (costsIndexs.length === 1) {
|
||||
index = costsIndexs[0];
|
||||
} else {
|
||||
const diagonalCounts = costsIndexs.map(i => newDiagonalCounts[i]);
|
||||
const diagonalCountsIndexs = findAllMinimalIndexs(
|
||||
diagonalCounts,
|
||||
(a, b) => a < b,
|
||||
(a, b) => a === b
|
||||
);
|
||||
if (diagonalCountsIndexs.length === 1) {
|
||||
index = costsIndexs[diagonalCountsIndexs[0]];
|
||||
} else {
|
||||
const pointPriorities = diagonalCountsIndexs.map(
|
||||
i => newPointPrioritys[costsIndexs[i]]
|
||||
);
|
||||
const pointPrioritiesIndexs = findAllMaximalIndexs(
|
||||
pointPriorities,
|
||||
(a, b) => a > b,
|
||||
(a, b) => a === b
|
||||
);
|
||||
index = pointPrioritiesIndexs[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
const shouldEnqueue = !this._costSoFar.has(next);
|
||||
const nextCosts = this._costSoFar.get(next) ?? [];
|
||||
const nextDiagonalCounts = this._diagonalCount.get(next) ?? [];
|
||||
const nextPointPriorities = this._pointPriority.get(next) ?? [];
|
||||
const nextCameFrom = this._cameFrom.get(next) ?? { from: [], indexs: [] };
|
||||
nextCosts.push(newCosts[index]);
|
||||
nextDiagonalCounts.push(newDiagonalCounts[index]);
|
||||
nextPointPriorities.push(newPointPrioritys[index]);
|
||||
nextCameFrom.from.push(current);
|
||||
nextCameFrom.indexs.push(index);
|
||||
|
||||
const newDiagonalCount = newDiagonalCounts[index];
|
||||
const newPointPriority = newPointPrioritys[index];
|
||||
const newCost = newCosts[index];
|
||||
|
||||
this._costSoFar.set(next, nextCosts);
|
||||
this._diagonalCount.set(next, nextDiagonalCounts);
|
||||
this._pointPriority.set(next, nextPointPriorities);
|
||||
this._cameFrom.set(next, nextCameFrom);
|
||||
const newPriority: [number, number, number] = [
|
||||
newDiagonalCount,
|
||||
newPointPriority,
|
||||
newCost + heuristic(next, this._ep),
|
||||
];
|
||||
if (shouldEnqueue) {
|
||||
this._frontier.enqueue(next, newPriority);
|
||||
} else {
|
||||
const index = this._frontier.heap.findIndex(
|
||||
item => item.value === next
|
||||
);
|
||||
const old = this._frontier.heap[index];
|
||||
if (old) {
|
||||
if (compare(newPriority, old.priority) < 0) {
|
||||
old.priority = newPriority;
|
||||
this._frontier.bubbleUp(index);
|
||||
}
|
||||
} else {
|
||||
this._frontier.enqueue(next, newPriority);
|
||||
}
|
||||
}
|
||||
if (
|
||||
pointAlmostEqual(current, this._ep) &&
|
||||
pointAlmostEqual(next, this._originalEp)
|
||||
) {
|
||||
this._originalEp = next;
|
||||
this._complete = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findAllMinimalIndexs(
|
||||
data: number[],
|
||||
isLess: (a: number, b: number) => boolean,
|
||||
isEqual: (a: number, b: number) => boolean
|
||||
) {
|
||||
let min = Infinity;
|
||||
let indexs: number[] = [];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const cur = data[i];
|
||||
if (isLess(cur, min)) {
|
||||
min = cur;
|
||||
indexs = [i];
|
||||
} else if (isEqual(cur, min)) {
|
||||
indexs.push(i);
|
||||
}
|
||||
}
|
||||
return indexs;
|
||||
}
|
||||
|
||||
function findAllMaximalIndexs(
|
||||
data: number[],
|
||||
isGreat: (a: number, b: number) => boolean,
|
||||
isEqual: (a: number, b: number) => boolean
|
||||
) {
|
||||
let max = -Infinity;
|
||||
let indexs: number[] = [];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const cur = data[i];
|
||||
if (isGreat(cur, max)) {
|
||||
max = cur;
|
||||
indexs = [i];
|
||||
} else if (isEqual(cur, max)) {
|
||||
indexs.push(i);
|
||||
}
|
||||
}
|
||||
return indexs;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
DEFAULT_NOTE_HEIGHT,
|
||||
DEFAULT_NOTE_WIDTH,
|
||||
NOTE_MIN_HEIGHT,
|
||||
type NoteBlockModel,
|
||||
NoteDisplayMode,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { focusTextModel } from '@blocksuite/affine-rich-text';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { NoteChildrenFlavour } from '@blocksuite/affine-shared/types';
|
||||
import { handleNativeRangeAtPoint } from '@blocksuite/affine-shared/utils';
|
||||
import { type IPoint, type Point, serializeXYWH } from '@blocksuite/global/gfx';
|
||||
import type { BlockStdScope } from '@blocksuite/std';
|
||||
import {
|
||||
type GfxBlockElementModel,
|
||||
GfxControllerIdentifier,
|
||||
} from '@blocksuite/std/gfx';
|
||||
|
||||
import { DEFAULT_NOTE_OFFSET_X, DEFAULT_NOTE_OFFSET_Y } from '../consts';
|
||||
import { EdgelessCRUDIdentifier } from '../extensions/crud-extension';
|
||||
|
||||
export function addNoteAtPoint(
|
||||
std: BlockStdScope,
|
||||
/**
|
||||
* The point is in browser coordinate
|
||||
*/
|
||||
point: IPoint,
|
||||
options: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
parentId?: string;
|
||||
noteIndex?: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
scale?: number;
|
||||
} = {}
|
||||
) {
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
const crud = std.get(EdgelessCRUDIdentifier);
|
||||
const {
|
||||
width = DEFAULT_NOTE_WIDTH,
|
||||
height = DEFAULT_NOTE_HEIGHT,
|
||||
offsetX = DEFAULT_NOTE_OFFSET_X,
|
||||
offsetY = DEFAULT_NOTE_OFFSET_Y,
|
||||
parentId = gfx.doc.root?.id,
|
||||
noteIndex,
|
||||
scale = 1,
|
||||
} = options;
|
||||
const [x, y] = gfx.viewport.toModelCoord(point.x, point.y);
|
||||
const blockId = crud.addBlock(
|
||||
'affine:note',
|
||||
{
|
||||
xywh: serializeXYWH(
|
||||
x - offsetX * scale,
|
||||
y - offsetY * scale,
|
||||
width,
|
||||
height
|
||||
),
|
||||
displayMode: NoteDisplayMode.EdgelessOnly,
|
||||
},
|
||||
parentId,
|
||||
noteIndex
|
||||
);
|
||||
|
||||
std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
|
||||
control: 'canvas:draw',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: 'note',
|
||||
});
|
||||
|
||||
return blockId;
|
||||
}
|
||||
|
||||
type NoteOptions = {
|
||||
childFlavour: NoteChildrenFlavour;
|
||||
childType: string | null;
|
||||
collapse: boolean;
|
||||
};
|
||||
|
||||
export function addNote(
|
||||
std: BlockStdScope,
|
||||
point: Point,
|
||||
options: NoteOptions,
|
||||
width = DEFAULT_NOTE_WIDTH,
|
||||
height = DEFAULT_NOTE_HEIGHT
|
||||
) {
|
||||
const noteId = addNoteAtPoint(std, point, {
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
const doc = std.store;
|
||||
|
||||
const blockId = doc.addBlock(
|
||||
options.childFlavour,
|
||||
{ type: options.childType },
|
||||
noteId
|
||||
);
|
||||
if (options.collapse && height > NOTE_MIN_HEIGHT) {
|
||||
const note = doc.getModelById(noteId) as NoteBlockModel;
|
||||
doc.updateBlock(note, () => {
|
||||
note.props.edgeless.collapse = true;
|
||||
note.props.edgeless.collapsedHeight = height;
|
||||
});
|
||||
}
|
||||
gfx.tool.setTool(
|
||||
// @ts-expect-error FIXME: resolve after gfx tool refactor
|
||||
'default'
|
||||
);
|
||||
|
||||
// Wait for edgelessTool updated
|
||||
requestAnimationFrame(() => {
|
||||
const blocks =
|
||||
(doc.root?.children.filter(
|
||||
child => child.flavour === 'affine:note'
|
||||
) as GfxBlockElementModel[]) ?? [];
|
||||
const element = blocks.find(b => b.id === noteId);
|
||||
if (element) {
|
||||
gfx.selection.set({
|
||||
elements: [element.id],
|
||||
editing: true,
|
||||
});
|
||||
|
||||
// Waiting dom updated, `note mask` is removed
|
||||
if (blockId) {
|
||||
focusTextModel(gfx.std, blockId);
|
||||
} else {
|
||||
// Cannot reuse `handleNativeRangeClick` directly here,
|
||||
// since `retargetClick` will re-target to pervious editor
|
||||
handleNativeRangeAtPoint(point.x, point.y);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { FontFamily } from '@blocksuite/affine-model';
|
||||
import { IS_FIREFOX } from '@blocksuite/global/env';
|
||||
|
||||
export function wrapFontFamily(fontFamily: FontFamily | string): string {
|
||||
return `"${fontFamily}"`;
|
||||
}
|
||||
|
||||
export const getFontFaces = IS_FIREFOX
|
||||
? () => {
|
||||
const keys = document.fonts.keys();
|
||||
const fonts = [];
|
||||
let done = false;
|
||||
while (!done) {
|
||||
const item = keys.next();
|
||||
done = !!item.done;
|
||||
if (item.value) {
|
||||
fonts.push(item.value);
|
||||
}
|
||||
}
|
||||
return fonts;
|
||||
}
|
||||
: () => [...document.fonts.keys()];
|
||||
|
||||
export const isSameFontFamily = IS_FIREFOX
|
||||
? (fontFamily: FontFamily | string) => (fontFace: FontFace) =>
|
||||
fontFace.family === `"${fontFamily}"`
|
||||
: (fontFamily: FontFamily | string) => (fontFace: FontFace) =>
|
||||
fontFace.family === fontFamily;
|
||||
|
||||
export function getFontFacesByFontFamily(
|
||||
fontFamily: FontFamily | string
|
||||
): FontFace[] {
|
||||
return (
|
||||
getFontFaces()
|
||||
.filter(isSameFontFamily(fontFamily))
|
||||
// remove duplicate font faces
|
||||
.filter(
|
||||
(item, index, arr) =>
|
||||
arr.findIndex(
|
||||
fontFace =>
|
||||
fontFace.family === item.family &&
|
||||
fontFace.weight === item.weight &&
|
||||
fontFace.style === item.style
|
||||
) === index
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { clamp } from '@blocksuite/global/gfx';
|
||||
|
||||
import { GRID_GAP_MAX, GRID_GAP_MIN } from '../consts';
|
||||
|
||||
export function getBgGridGap(zoom: number) {
|
||||
const step = zoom < 0.5 ? 2 : 1 / (Math.floor(zoom) || 1);
|
||||
const gap = clamp(20 * step * zoom, GRID_GAP_MIN, GRID_GAP_MAX);
|
||||
|
||||
return Math.round(gap);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getShapeName, type ShapeProps } from '@blocksuite/affine-model';
|
||||
import type {
|
||||
LastProps,
|
||||
LastPropsKey,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { NodePropsSchema } from '@blocksuite/affine-shared/utils';
|
||||
|
||||
const LastPropsSchema = NodePropsSchema;
|
||||
|
||||
export function getLastPropsKey(
|
||||
modelType: string,
|
||||
modelProps: Partial<LastProps[LastPropsKey]>
|
||||
): LastPropsKey | null {
|
||||
if (modelType === 'shape') {
|
||||
const { shapeType, radius } = modelProps as ShapeProps;
|
||||
const shapeName = getShapeName(shapeType, radius);
|
||||
return `${modelType}:${shapeName}`;
|
||||
}
|
||||
|
||||
if (isLastPropsKey(modelType)) {
|
||||
return modelType;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isLastPropsKey(key: string): key is LastPropsKey {
|
||||
return Object.keys(LastPropsSchema.shape).includes(key);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { BlockStdScope } from '@blocksuite/std';
|
||||
import type { Store } from '@blocksuite/store';
|
||||
|
||||
import type { SurfaceBlockComponent } from '../surface-block';
|
||||
import type { SurfaceBlockModel } from '../surface-model';
|
||||
|
||||
export function getSurfaceBlock(doc: Store) {
|
||||
const blocks = doc.getBlocksByFlavour('affine:surface');
|
||||
return blocks.length !== 0 ? (blocks[0].model as SurfaceBlockModel) : null;
|
||||
}
|
||||
|
||||
export function getSurfaceComponent(std: BlockStdScope) {
|
||||
const surface = getSurfaceBlock(std.store);
|
||||
if (!surface) return null;
|
||||
return std.view.getBlock(surface.id) as SurfaceBlockComponent | null;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { Bound, IVec, IVec3 } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
almostEqual,
|
||||
isOverlap as _isOverlap,
|
||||
linePolygonIntersects,
|
||||
} from '@blocksuite/global/gfx';
|
||||
|
||||
function isOverlap(line: IVec[], line2: IVec[]) {
|
||||
if (
|
||||
[line[0][1], line[1][1], line2[0][1], line2[1][1]].every(y =>
|
||||
almostEqual(y, line[0][1], 0.02)
|
||||
)
|
||||
) {
|
||||
return _isOverlap(line, line2, 0);
|
||||
} else if (
|
||||
[line[0][0], line[1][0], line2[0][0], line2[1][0]].every(x =>
|
||||
almostEqual(x, line[0][0], 0.02)
|
||||
)
|
||||
) {
|
||||
return _isOverlap(line, line2, 1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function arrayAlmostEqual(point: IVec | IVec3, point2: IVec | IVec3) {
|
||||
return almostEqual(point[0], point2[0]) && almostEqual(point[1], point2[1]);
|
||||
}
|
||||
|
||||
export class Graph<V extends IVec | IVec3 = IVec> {
|
||||
private readonly _xMap = new Map<number, V[]>();
|
||||
|
||||
private readonly _yMap = new Map<number, V[]>();
|
||||
|
||||
constructor(
|
||||
private readonly points: V[],
|
||||
private readonly blocks: Bound[] = [],
|
||||
private readonly expandedBlocks: Bound[] = [],
|
||||
private readonly excludedPoints: V[] = []
|
||||
) {
|
||||
const xMap = this._xMap;
|
||||
const yMap = this._yMap;
|
||||
this.points.forEach(point => {
|
||||
const [x, y] = point;
|
||||
if (!xMap.has(x)) xMap.set(x, []);
|
||||
if (!yMap.has(y)) yMap.set(y, []);
|
||||
xMap.get(x)?.push(point);
|
||||
yMap.get(y)?.push(point);
|
||||
});
|
||||
}
|
||||
|
||||
private _canSkipBlock(point: IVec | IVec3) {
|
||||
return this.excludedPoints.some(excludedPoint => {
|
||||
return arrayAlmostEqual(point, excludedPoint);
|
||||
});
|
||||
}
|
||||
|
||||
private _isBlock(sp: IVec | IVec3, ep: IVec | IVec3) {
|
||||
return (
|
||||
this.blocks.some(block => {
|
||||
const rst = linePolygonIntersects(sp as IVec, ep as IVec, block.points);
|
||||
return (
|
||||
rst?.length === 2 ||
|
||||
block.isPointInBound(sp as IVec, 0) ||
|
||||
block.isPointInBound(ep as IVec, 0) ||
|
||||
[
|
||||
block.leftLine,
|
||||
block.upperLine,
|
||||
block.rightLine,
|
||||
block.lowerLine,
|
||||
].some(line => {
|
||||
return isOverlap(line, [sp, ep] as IVec[]);
|
||||
})
|
||||
);
|
||||
}) ||
|
||||
this.expandedBlocks.some(block => {
|
||||
const result = linePolygonIntersects(
|
||||
sp as IVec,
|
||||
ep as IVec,
|
||||
block.expand(-0.5).points
|
||||
);
|
||||
return result?.length === 2;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
neighbors(curPoint: V): V[] {
|
||||
const [x, y] = curPoint;
|
||||
const neighbors = new Set<V>();
|
||||
const xPoints = this._xMap.get(x);
|
||||
const yPoints = this._yMap.get(y);
|
||||
if (xPoints) {
|
||||
let plusMin = Infinity;
|
||||
let minusMin = Infinity;
|
||||
let plusPoint: V | undefined;
|
||||
let minusPoint: V | undefined;
|
||||
xPoints.forEach(point => {
|
||||
if (arrayAlmostEqual(point, curPoint as unknown as IVec)) return;
|
||||
const dif = point[1] - curPoint[1];
|
||||
if (dif > 0 && dif < plusMin) {
|
||||
plusMin = dif;
|
||||
plusPoint = point;
|
||||
}
|
||||
if (dif < 0 && Math.abs(dif) < minusMin) {
|
||||
minusMin = Math.abs(dif);
|
||||
minusPoint = point;
|
||||
}
|
||||
});
|
||||
if (
|
||||
plusPoint &&
|
||||
(this._canSkipBlock(plusPoint) || !this._isBlock(curPoint, plusPoint))
|
||||
) {
|
||||
neighbors.add(plusPoint);
|
||||
}
|
||||
if (
|
||||
minusPoint &&
|
||||
(this._canSkipBlock(minusPoint) || !this._isBlock(curPoint, minusPoint))
|
||||
)
|
||||
neighbors.add(minusPoint);
|
||||
}
|
||||
if (yPoints) {
|
||||
let plusMin = Infinity;
|
||||
let minusMin = Infinity;
|
||||
let plusPoint: V | undefined;
|
||||
let minusPoint: V | undefined;
|
||||
yPoints.forEach(point => {
|
||||
if (arrayAlmostEqual(point, curPoint)) return;
|
||||
const dif = point[0] - curPoint[0];
|
||||
if (dif > 0 && dif < plusMin) {
|
||||
plusMin = dif;
|
||||
plusPoint = point;
|
||||
}
|
||||
if (dif < 0 && Math.abs(dif) < minusMin) {
|
||||
minusMin = Math.abs(dif);
|
||||
minusPoint = point;
|
||||
}
|
||||
});
|
||||
if (
|
||||
plusPoint &&
|
||||
(this._canSkipBlock(plusPoint) || !this._isBlock(curPoint, plusPoint))
|
||||
)
|
||||
neighbors.add(plusPoint);
|
||||
if (
|
||||
minusPoint &&
|
||||
(this._canSkipBlock(minusPoint) || !this._isBlock(curPoint, minusPoint))
|
||||
)
|
||||
neighbors.add(minusPoint);
|
||||
}
|
||||
|
||||
return Array.from(neighbors);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { ZOOM_WHEEL_STEP } from '../consts.js';
|
||||
|
||||
export { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing';
|
||||
|
||||
export function generateElementId() {
|
||||
return nanoid(10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes wheel delta.
|
||||
*
|
||||
* See https://stackoverflow.com/a/13650579
|
||||
*
|
||||
* From https://github.com/excalidraw/excalidraw/blob/master/src/components/App.tsx
|
||||
* MIT License
|
||||
*/
|
||||
export function normalizeWheelDeltaY(delta: number, zoom = 1) {
|
||||
const sign = Math.sign(delta);
|
||||
const abs = Math.abs(delta);
|
||||
const maxStep = ZOOM_WHEEL_STEP * 100;
|
||||
if (abs > maxStep) {
|
||||
delta = maxStep * sign;
|
||||
}
|
||||
let newZoom = zoom - delta / 100;
|
||||
// increase zoom steps the more zoomed-in we are (applies to >100% only)
|
||||
newZoom +=
|
||||
Math.log10(Math.max(1, zoom)) *
|
||||
-sign *
|
||||
// reduced amplification for small deltas (small movements on a trackpad)
|
||||
Math.min(1, abs / 20);
|
||||
return newZoom;
|
||||
}
|
||||
|
||||
export { addNote, addNoteAtPoint } from './add-note';
|
||||
export { getBgGridGap } from './get-bg-grip-gap';
|
||||
export { getLastPropsKey } from './get-last-props-key';
|
||||
export * from './get-surface-block';
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Preet Shihn
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Segment } from './parser.js';
|
||||
|
||||
// Translate relative commands to absolute commands
|
||||
export function absolutize(segments: Segment[]): Segment[] {
|
||||
let cx = 0,
|
||||
cy = 0;
|
||||
let subx = 0,
|
||||
suby = 0;
|
||||
const out: Segment[] = [];
|
||||
for (const { key, data } of segments) {
|
||||
switch (key) {
|
||||
case 'M':
|
||||
out.push({ key: 'M', data: [...data] });
|
||||
[cx, cy] = data;
|
||||
[subx, suby] = data;
|
||||
break;
|
||||
case 'm':
|
||||
cx += data[0];
|
||||
cy += data[1];
|
||||
out.push({ key: 'M', data: [cx, cy] });
|
||||
subx = cx;
|
||||
suby = cy;
|
||||
break;
|
||||
case 'L':
|
||||
out.push({ key: 'L', data: [...data] });
|
||||
[cx, cy] = data;
|
||||
break;
|
||||
case 'l':
|
||||
cx += data[0];
|
||||
cy += data[1];
|
||||
out.push({ key: 'L', data: [cx, cy] });
|
||||
break;
|
||||
case 'C':
|
||||
out.push({ key: 'C', data: [...data] });
|
||||
cx = data[4];
|
||||
cy = data[5];
|
||||
break;
|
||||
case 'c': {
|
||||
const newdata = data.map((d, i) => (i % 2 ? d + cy : d + cx));
|
||||
out.push({ key: 'C', data: newdata });
|
||||
cx = newdata[4];
|
||||
cy = newdata[5];
|
||||
break;
|
||||
}
|
||||
case 'Q':
|
||||
out.push({ key: 'Q', data: [...data] });
|
||||
cx = data[2];
|
||||
cy = data[3];
|
||||
break;
|
||||
case 'q': {
|
||||
const newdata = data.map((d, i) => (i % 2 ? d + cy : d + cx));
|
||||
out.push({ key: 'Q', data: newdata });
|
||||
cx = newdata[2];
|
||||
cy = newdata[3];
|
||||
break;
|
||||
}
|
||||
case 'A':
|
||||
out.push({ key: 'A', data: [...data] });
|
||||
cx = data[5];
|
||||
cy = data[6];
|
||||
break;
|
||||
case 'a':
|
||||
cx += data[5];
|
||||
cy += data[6];
|
||||
out.push({
|
||||
key: 'A',
|
||||
data: [data[0], data[1], data[2], data[3], data[4], cx, cy],
|
||||
});
|
||||
break;
|
||||
case 'H':
|
||||
out.push({ key: 'H', data: [...data] });
|
||||
cx = data[0];
|
||||
break;
|
||||
case 'h':
|
||||
cx += data[0];
|
||||
out.push({ key: 'H', data: [cx] });
|
||||
break;
|
||||
case 'V':
|
||||
out.push({ key: 'V', data: [...data] });
|
||||
cy = data[0];
|
||||
break;
|
||||
case 'v':
|
||||
cy += data[0];
|
||||
out.push({ key: 'V', data: [cy] });
|
||||
break;
|
||||
case 'S':
|
||||
out.push({ key: 'S', data: [...data] });
|
||||
cx = data[2];
|
||||
cy = data[3];
|
||||
break;
|
||||
case 's': {
|
||||
const newdata = data.map((d, i) => (i % 2 ? d + cy : d + cx));
|
||||
out.push({ key: 'S', data: newdata });
|
||||
cx = newdata[2];
|
||||
cy = newdata[3];
|
||||
break;
|
||||
}
|
||||
case 'T':
|
||||
out.push({ key: 'T', data: [...data] });
|
||||
cx = data[0];
|
||||
cy = data[1];
|
||||
break;
|
||||
case 't':
|
||||
cx += data[0];
|
||||
cy += data[1];
|
||||
out.push({ key: 'T', data: [cx, cy] });
|
||||
break;
|
||||
case 'Z':
|
||||
case 'z':
|
||||
out.push({ key: 'Z', data: [] });
|
||||
cx = subx;
|
||||
cy = suby;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import type { Segment } from './parser.js';
|
||||
|
||||
// Normalize path to include only M, L, C, and Z commands
|
||||
export function normalize(segments: Segment[]): Segment[] {
|
||||
const out: Segment[] = [];
|
||||
|
||||
let lastType = '';
|
||||
let cx = 0,
|
||||
cy = 0;
|
||||
let subx = 0,
|
||||
suby = 0;
|
||||
let lcx = 0,
|
||||
lcy = 0;
|
||||
|
||||
for (const { key, data } of segments) {
|
||||
switch (key) {
|
||||
case 'M':
|
||||
out.push({ key: 'M', data: [...data] });
|
||||
[cx, cy] = data;
|
||||
[subx, suby] = data;
|
||||
break;
|
||||
case 'C':
|
||||
out.push({ key: 'C', data: [...data] });
|
||||
cx = data[4];
|
||||
cy = data[5];
|
||||
lcx = data[2];
|
||||
lcy = data[3];
|
||||
break;
|
||||
case 'L':
|
||||
out.push({ key: 'L', data: [...data] });
|
||||
[cx, cy] = data;
|
||||
break;
|
||||
case 'H':
|
||||
cx = data[0];
|
||||
out.push({ key: 'L', data: [cx, cy] });
|
||||
break;
|
||||
case 'V':
|
||||
cy = data[0];
|
||||
out.push({ key: 'L', data: [cx, cy] });
|
||||
break;
|
||||
case 'S': {
|
||||
let cx1 = 0,
|
||||
cy1 = 0;
|
||||
if (lastType === 'C' || lastType === 'S') {
|
||||
cx1 = cx + (cx - lcx);
|
||||
cy1 = cy + (cy - lcy);
|
||||
} else {
|
||||
cx1 = cx;
|
||||
cy1 = cy;
|
||||
}
|
||||
out.push({ key: 'C', data: [cx1, cy1, ...data] });
|
||||
lcx = data[0];
|
||||
lcy = data[1];
|
||||
cx = data[2];
|
||||
cy = data[3];
|
||||
break;
|
||||
}
|
||||
case 'T': {
|
||||
const [x, y] = data;
|
||||
let x1 = 0,
|
||||
y1 = 0;
|
||||
if (lastType === 'Q' || lastType === 'T') {
|
||||
x1 = cx + (cx - lcx);
|
||||
y1 = cy + (cy - lcy);
|
||||
} else {
|
||||
x1 = cx;
|
||||
y1 = cy;
|
||||
}
|
||||
const cx1 = cx + (2 * (x1 - cx)) / 3;
|
||||
const cy1 = cy + (2 * (y1 - cy)) / 3;
|
||||
const cx2 = x + (2 * (x1 - x)) / 3;
|
||||
const cy2 = y + (2 * (y1 - y)) / 3;
|
||||
out.push({ key: 'C', data: [cx1, cy1, cx2, cy2, x, y] });
|
||||
lcx = x1;
|
||||
lcy = y1;
|
||||
cx = x;
|
||||
cy = y;
|
||||
break;
|
||||
}
|
||||
case 'Q': {
|
||||
const [x1, y1, x, y] = data;
|
||||
const cx1 = cx + (2 * (x1 - cx)) / 3;
|
||||
const cy1 = cy + (2 * (y1 - cy)) / 3;
|
||||
const cx2 = x + (2 * (x1 - x)) / 3;
|
||||
const cy2 = y + (2 * (y1 - y)) / 3;
|
||||
out.push({ key: 'C', data: [cx1, cy1, cx2, cy2, x, y] });
|
||||
lcx = x1;
|
||||
lcy = y1;
|
||||
cx = x;
|
||||
cy = y;
|
||||
break;
|
||||
}
|
||||
case 'A': {
|
||||
const r1 = Math.abs(data[0]);
|
||||
const r2 = Math.abs(data[1]);
|
||||
const angle = data[2];
|
||||
const largeArcFlag = data[3];
|
||||
const sweepFlag = data[4];
|
||||
const x = data[5];
|
||||
const y = data[6];
|
||||
if (r1 === 0 || r2 === 0) {
|
||||
out.push({ key: 'C', data: [cx, cy, x, y, x, y] });
|
||||
cx = x;
|
||||
cy = y;
|
||||
} else {
|
||||
if (cx !== x || cy !== y) {
|
||||
const curves: number[][] = arcToCubicCurves(
|
||||
cx,
|
||||
cy,
|
||||
x,
|
||||
y,
|
||||
r1,
|
||||
r2,
|
||||
angle,
|
||||
largeArcFlag,
|
||||
sweepFlag
|
||||
);
|
||||
curves.forEach(function (curve) {
|
||||
out.push({ key: 'C', data: curve });
|
||||
});
|
||||
cx = x;
|
||||
cy = y;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'Z':
|
||||
out.push({ key: 'Z', data: [] });
|
||||
cx = subx;
|
||||
cy = suby;
|
||||
break;
|
||||
}
|
||||
lastType = key;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function degToRad(degrees: number): number {
|
||||
return (Math.PI * degrees) / 180;
|
||||
}
|
||||
|
||||
function rotate(x: number, y: number, angleRad: number): [number, number] {
|
||||
const X = x * Math.cos(angleRad) - y * Math.sin(angleRad);
|
||||
const Y = x * Math.sin(angleRad) + y * Math.cos(angleRad);
|
||||
return [X, Y];
|
||||
}
|
||||
|
||||
function arcToCubicCurves(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
r1: number,
|
||||
r2: number,
|
||||
angle: number,
|
||||
largeArcFlag: number,
|
||||
sweepFlag: number,
|
||||
recursive?: number[]
|
||||
): number[][] {
|
||||
const angleRad = degToRad(angle);
|
||||
let params: number[][] = [];
|
||||
|
||||
let f1 = 0,
|
||||
f2 = 0,
|
||||
cx = 0,
|
||||
cy = 0;
|
||||
if (recursive) {
|
||||
[f1, f2, cx, cy] = recursive;
|
||||
} else {
|
||||
[x1, y1] = rotate(x1, y1, -angleRad);
|
||||
[x2, y2] = rotate(x2, y2, -angleRad);
|
||||
|
||||
const x = (x1 - x2) / 2;
|
||||
const y = (y1 - y2) / 2;
|
||||
let h = (x * x) / (r1 * r1) + (y * y) / (r2 * r2);
|
||||
if (h > 1) {
|
||||
h = Math.sqrt(h);
|
||||
r1 = h * r1;
|
||||
r2 = h * r2;
|
||||
}
|
||||
|
||||
const sign = largeArcFlag === sweepFlag ? -1 : 1;
|
||||
|
||||
const r1Pow = r1 * r1;
|
||||
const r2Pow = r2 * r2;
|
||||
|
||||
const left = r1Pow * r2Pow - r1Pow * y * y - r2Pow * x * x;
|
||||
const right = r1Pow * y * y + r2Pow * x * x;
|
||||
|
||||
const k = sign * Math.sqrt(Math.abs(left / right));
|
||||
|
||||
cx = (k * r1 * y) / r2 + (x1 + x2) / 2;
|
||||
cy = (k * -r2 * x) / r1 + (y1 + y2) / 2;
|
||||
|
||||
f1 = Math.asin(parseFloat(((y1 - cy) / r2).toFixed(9)));
|
||||
f2 = Math.asin(parseFloat(((y2 - cy) / r2).toFixed(9)));
|
||||
|
||||
if (x1 < cx) {
|
||||
f1 = Math.PI - f1;
|
||||
}
|
||||
if (x2 < cx) {
|
||||
f2 = Math.PI - f2;
|
||||
}
|
||||
|
||||
if (f1 < 0) {
|
||||
f1 = Math.PI * 2 + f1;
|
||||
}
|
||||
if (f2 < 0) {
|
||||
f2 = Math.PI * 2 + f2;
|
||||
}
|
||||
|
||||
if (sweepFlag && f1 > f2) {
|
||||
f1 = f1 - Math.PI * 2;
|
||||
}
|
||||
if (!sweepFlag && f2 > f1) {
|
||||
f2 = f2 - Math.PI * 2;
|
||||
}
|
||||
}
|
||||
|
||||
let df = f2 - f1;
|
||||
|
||||
if (Math.abs(df) > (Math.PI * 120) / 180) {
|
||||
const f2old = f2;
|
||||
const x2old = x2;
|
||||
const y2old = y2;
|
||||
|
||||
if (sweepFlag && f2 > f1) {
|
||||
f2 = f1 + ((Math.PI * 120) / 180) * 1;
|
||||
} else {
|
||||
f2 = f1 + ((Math.PI * 120) / 180) * -1;
|
||||
}
|
||||
|
||||
x2 = cx + r1 * Math.cos(f2);
|
||||
y2 = cy + r2 * Math.sin(f2);
|
||||
params = arcToCubicCurves(
|
||||
x2,
|
||||
y2,
|
||||
x2old,
|
||||
y2old,
|
||||
r1,
|
||||
r2,
|
||||
angle,
|
||||
0,
|
||||
sweepFlag,
|
||||
[f2, f2old, cx, cy]
|
||||
);
|
||||
}
|
||||
|
||||
df = f2 - f1;
|
||||
|
||||
const c1 = Math.cos(f1);
|
||||
const s1 = Math.sin(f1);
|
||||
const c2 = Math.cos(f2);
|
||||
const s2 = Math.sin(f2);
|
||||
const t = Math.tan(df / 4);
|
||||
const hx = (4 / 3) * r1 * t;
|
||||
const hy = (4 / 3) * r2 * t;
|
||||
|
||||
const m1 = [x1, y1];
|
||||
const m2 = [x1 + hx * s1, y1 - hy * c1];
|
||||
const m3 = [x2 + hx * s2, y2 - hy * c2];
|
||||
const m4 = [x2, y2];
|
||||
|
||||
m2[0] = 2 * m1[0] - m2[0];
|
||||
m2[1] = 2 * m1[1] - m2[1];
|
||||
|
||||
if (recursive) {
|
||||
return [m2, m3, m4].concat(params);
|
||||
} else {
|
||||
params = [m2, m3, m4].concat(params);
|
||||
const curves = [];
|
||||
for (let i = 0; i < params.length; i += 3) {
|
||||
const r1 = rotate(params[i][0], params[i][1], angleRad);
|
||||
const r2 = rotate(params[i + 1][0], params[i + 1][1], angleRad);
|
||||
const r3 = rotate(params[i + 2][0], params[i + 2][1], angleRad);
|
||||
curves.push([r1[0], r1[1], r2[0], r2[1], r3[0], r3[1]]);
|
||||
}
|
||||
return curves;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
const COMMAND = 0;
|
||||
const NUMBER = 1;
|
||||
const EOD = 2;
|
||||
type TokenType = 0 | 1 | 2;
|
||||
|
||||
interface PathToken {
|
||||
type: TokenType;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface Segment {
|
||||
key: string;
|
||||
data: number[];
|
||||
}
|
||||
|
||||
const PARAMS: Record<string, number> = {
|
||||
A: 7,
|
||||
a: 7,
|
||||
C: 6,
|
||||
c: 6,
|
||||
H: 1,
|
||||
h: 1,
|
||||
L: 2,
|
||||
l: 2,
|
||||
M: 2,
|
||||
m: 2,
|
||||
Q: 4,
|
||||
q: 4,
|
||||
S: 4,
|
||||
s: 4,
|
||||
T: 2,
|
||||
t: 2,
|
||||
V: 1,
|
||||
v: 1,
|
||||
Z: 0,
|
||||
z: 0,
|
||||
};
|
||||
|
||||
function tokenize(d: string): PathToken[] {
|
||||
const tokens: PathToken[] = [];
|
||||
let match: RegExpMatchArray | null;
|
||||
|
||||
while (d !== '') {
|
||||
match = d.match(/^([ \t\r\n,]+)/);
|
||||
if (match) {
|
||||
d = d.slice(match[0].length);
|
||||
continue;
|
||||
}
|
||||
match = d.match(/^([aAcChHlLmMqQsStTvVzZ])/);
|
||||
if (match) {
|
||||
tokens.push({ type: COMMAND, text: match[0] });
|
||||
d = d.slice(match[0].length);
|
||||
continue;
|
||||
}
|
||||
match = d.match(
|
||||
/^(([-+]?[0-9]+(\.[0-9]*)?|[-+]?\.[0-9]+)([eE][-+]?[0-9]+)?)/
|
||||
);
|
||||
if (match) {
|
||||
tokens.push({ type: NUMBER, text: `${parseFloat(match[0])}` });
|
||||
d = d.slice(match[0].length);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
tokens.push({ type: EOD, text: '' });
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function isType(token: PathToken, type: number) {
|
||||
return token.type === type;
|
||||
}
|
||||
|
||||
export function parsePath(d: string): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
const tokens = tokenize(d);
|
||||
let mode = 'BOD';
|
||||
let index = 0;
|
||||
let token = tokens[index];
|
||||
while (!isType(token, EOD)) {
|
||||
let paramsCount = 0;
|
||||
const params: number[] = [];
|
||||
if (mode === 'BOD') {
|
||||
if (token.text === 'M' || token.text === 'm') {
|
||||
index++;
|
||||
paramsCount = PARAMS[token.text];
|
||||
mode = token.text;
|
||||
} else {
|
||||
return parsePath('M0,0' + d);
|
||||
}
|
||||
} else if (isType(token, NUMBER)) {
|
||||
paramsCount = PARAMS[mode];
|
||||
} else {
|
||||
index++;
|
||||
paramsCount = PARAMS[token.text];
|
||||
mode = token.text;
|
||||
}
|
||||
if (index + paramsCount < tokens.length) {
|
||||
for (let i = index; i < index + paramsCount; i++) {
|
||||
const numbeToken = tokens[i];
|
||||
if (isType(numbeToken, NUMBER)) {
|
||||
params[params.length] = +numbeToken.text;
|
||||
} else {
|
||||
throw new Error(
|
||||
'Param not a number: ' + mode + ',' + numbeToken.text
|
||||
);
|
||||
}
|
||||
}
|
||||
if (typeof PARAMS[mode] === 'number') {
|
||||
const segment: Segment = { key: mode, data: params };
|
||||
segments.push(segment);
|
||||
index += paramsCount;
|
||||
token = tokens[index];
|
||||
if (mode === 'M') mode = 'L';
|
||||
if (mode === 'm') mode = 'l';
|
||||
} else {
|
||||
throw new Error('Bad segment: ' + mode);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Path data ended short');
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function serialize(segments: Segment[]): string {
|
||||
const tokens: (string | number)[] = [];
|
||||
for (const { key, data } of segments) {
|
||||
tokens.push(key);
|
||||
switch (key) {
|
||||
case 'C':
|
||||
case 'c':
|
||||
tokens.push(
|
||||
data[0],
|
||||
`${data[1]},`,
|
||||
data[2],
|
||||
`${data[3]},`,
|
||||
data[4],
|
||||
data[5]
|
||||
);
|
||||
break;
|
||||
case 'S':
|
||||
case 's':
|
||||
case 'Q':
|
||||
case 'q':
|
||||
tokens.push(data[0], `${data[1]},`, data[2], data[3]);
|
||||
break;
|
||||
default:
|
||||
tokens.push(...data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return tokens.join(' ');
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Preet Shihn
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Point } from '../rough/geometry.js';
|
||||
|
||||
function clone(p: Point): Point {
|
||||
return [...p] as Point;
|
||||
}
|
||||
|
||||
export function curveToBezier(
|
||||
pointsIn: readonly Point[],
|
||||
curveTightness = 0
|
||||
): Point[] {
|
||||
const len = pointsIn.length;
|
||||
if (len < 3) {
|
||||
throw new Error('A curve must have at least three points.');
|
||||
}
|
||||
const out: Point[] = [];
|
||||
if (len === 3) {
|
||||
out.push(
|
||||
clone(pointsIn[0]),
|
||||
clone(pointsIn[1]),
|
||||
clone(pointsIn[2]),
|
||||
clone(pointsIn[2])
|
||||
);
|
||||
} else {
|
||||
const points: Point[] = [];
|
||||
points.push(pointsIn[0], pointsIn[0]);
|
||||
for (let i = 1; i < pointsIn.length; i++) {
|
||||
points.push(pointsIn[i]);
|
||||
if (i === pointsIn.length - 1) {
|
||||
points.push(pointsIn[i]);
|
||||
}
|
||||
}
|
||||
const b: Point[] = [];
|
||||
const s = 1 - curveTightness;
|
||||
out.push(clone(points[0]));
|
||||
for (let i = 1; i + 2 < points.length; i++) {
|
||||
const cachedVertArray = points[i];
|
||||
b[0] = [cachedVertArray[0], cachedVertArray[1]];
|
||||
b[1] = [
|
||||
cachedVertArray[0] + (s * points[i + 1][0] - s * points[i - 1][0]) / 6,
|
||||
cachedVertArray[1] + (s * points[i + 1][1] - s * points[i - 1][1]) / 6,
|
||||
];
|
||||
b[2] = [
|
||||
points[i + 1][0] + (s * points[i][0] - s * points[i + 2][0]) / 6,
|
||||
points[i + 1][1] + (s * points[i][1] - s * points[i + 2][1]) / 6,
|
||||
];
|
||||
b[3] = [points[i + 1][0], points[i + 1][1]];
|
||||
out.push(b[1], b[2], b[3]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { Point } from '../rough/geometry.js';
|
||||
|
||||
// distance between 2 points
|
||||
function distance(p1: Point, p2: Point): number {
|
||||
return Math.sqrt(distanceSq(p1, p2));
|
||||
}
|
||||
|
||||
// distance between 2 points squared
|
||||
function distanceSq(p1: Point, p2: Point): number {
|
||||
return Math.pow(p1[0] - p2[0], 2) + Math.pow(p1[1] - p2[1], 2);
|
||||
}
|
||||
|
||||
// Sistance squared from a point p to the line segment vw
|
||||
function distanceToSegmentSq(p: Point, v: Point, w: Point): number {
|
||||
const l2 = distanceSq(v, w);
|
||||
if (l2 === 0) {
|
||||
return distanceSq(p, v);
|
||||
}
|
||||
let t = ((p[0] - v[0]) * (w[0] - v[0]) + (p[1] - v[1]) * (w[1] - v[1])) / l2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
return distanceSq(p, lerp(v, w, t));
|
||||
}
|
||||
|
||||
function lerp(a: Point, b: Point, t: number): Point {
|
||||
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||||
}
|
||||
|
||||
// Adapted from https://seant23.wordpress.com/2010/11/12/offset-bezier-curves/
|
||||
function flatness(points: readonly Point[], offset: number): number {
|
||||
const p1 = points[offset + 0];
|
||||
const p2 = points[offset + 1];
|
||||
const p3 = points[offset + 2];
|
||||
const p4 = points[offset + 3];
|
||||
|
||||
let ux = 3 * p2[0] - 2 * p1[0] - p4[0];
|
||||
ux *= ux;
|
||||
let uy = 3 * p2[1] - 2 * p1[1] - p4[1];
|
||||
uy *= uy;
|
||||
let vx = 3 * p3[0] - 2 * p4[0] - p1[0];
|
||||
vx *= vx;
|
||||
let vy = 3 * p3[1] - 2 * p4[1] - p1[1];
|
||||
vy *= vy;
|
||||
|
||||
if (ux < vx) {
|
||||
ux = vx;
|
||||
}
|
||||
|
||||
if (uy < vy) {
|
||||
uy = vy;
|
||||
}
|
||||
|
||||
return ux + uy;
|
||||
}
|
||||
|
||||
function getPointsOnBezierCurveWithSplitting(
|
||||
points: readonly Point[],
|
||||
offset: number,
|
||||
tolerance: number,
|
||||
newPoints?: Point[]
|
||||
): Point[] {
|
||||
const outPoints = newPoints || [];
|
||||
if (flatness(points, offset) < tolerance) {
|
||||
const p0 = points[offset + 0];
|
||||
if (outPoints.length) {
|
||||
const d = distance(outPoints[outPoints.length - 1], p0);
|
||||
if (d > 1) {
|
||||
outPoints.push(p0);
|
||||
}
|
||||
} else {
|
||||
outPoints.push(p0);
|
||||
}
|
||||
outPoints.push(points[offset + 3]);
|
||||
} else {
|
||||
// subdivide
|
||||
const t = 0.5;
|
||||
const p1 = points[offset + 0];
|
||||
const p2 = points[offset + 1];
|
||||
const p3 = points[offset + 2];
|
||||
const p4 = points[offset + 3];
|
||||
|
||||
const q1 = lerp(p1, p2, t);
|
||||
const q2 = lerp(p2, p3, t);
|
||||
const q3 = lerp(p3, p4, t);
|
||||
|
||||
const r1 = lerp(q1, q2, t);
|
||||
const r2 = lerp(q2, q3, t);
|
||||
|
||||
const red = lerp(r1, r2, t);
|
||||
|
||||
getPointsOnBezierCurveWithSplitting(
|
||||
[p1, q1, r1, red],
|
||||
0,
|
||||
tolerance,
|
||||
outPoints
|
||||
);
|
||||
getPointsOnBezierCurveWithSplitting(
|
||||
[red, r2, q3, p4],
|
||||
0,
|
||||
tolerance,
|
||||
outPoints
|
||||
);
|
||||
}
|
||||
return outPoints;
|
||||
}
|
||||
|
||||
export function simplify(points: readonly Point[], distance: number): Point[] {
|
||||
return simplifyPoints(points, 0, points.length, distance);
|
||||
}
|
||||
|
||||
// Ramer–Douglas–Peucker algorithm
|
||||
// https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm
|
||||
function simplifyPoints(
|
||||
points: readonly Point[],
|
||||
start: number,
|
||||
end: number,
|
||||
epsilon: number,
|
||||
newPoints?: Point[]
|
||||
): Point[] {
|
||||
const outPoints = newPoints || [];
|
||||
|
||||
// find the most distance point from the endpoints
|
||||
const s = points[start];
|
||||
const e = points[end - 1];
|
||||
let maxDistSq = 0;
|
||||
let maxNdx = 1;
|
||||
for (let i = start + 1; i < end - 1; ++i) {
|
||||
const distSq = distanceToSegmentSq(points[i], s, e);
|
||||
if (distSq > maxDistSq) {
|
||||
maxDistSq = distSq;
|
||||
maxNdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
// if that point is too far, split
|
||||
if (Math.sqrt(maxDistSq) > epsilon) {
|
||||
simplifyPoints(points, start, maxNdx + 1, epsilon, outPoints);
|
||||
simplifyPoints(points, maxNdx, end, epsilon, outPoints);
|
||||
} else {
|
||||
if (!outPoints.length) {
|
||||
outPoints.push(s);
|
||||
}
|
||||
outPoints.push(e);
|
||||
}
|
||||
|
||||
return outPoints;
|
||||
}
|
||||
|
||||
export function pointsOnBezierCurves(
|
||||
points: Point[],
|
||||
tolerance = 0.15,
|
||||
distance?: number
|
||||
): Point[] {
|
||||
const newPoints: Point[] = [];
|
||||
const numSegments = (points.length - 1) / 3;
|
||||
for (let i = 0; i < numSegments; i++) {
|
||||
const offset = i * 3;
|
||||
getPointsOnBezierCurveWithSplitting(points, offset, tolerance, newPoints);
|
||||
}
|
||||
if (distance && distance > 0) {
|
||||
return simplifyPoints(newPoints, 0, newPoints.length, distance);
|
||||
}
|
||||
return newPoints;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Preet
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,77 @@
|
||||
import { absolutize } from '../path-data-parser/absolutize.js';
|
||||
import { normalize } from '../path-data-parser/normalize.js';
|
||||
import { parsePath } from '../path-data-parser/parser.js';
|
||||
import { pointsOnBezierCurves, simplify } from '../points-on-curve/index.js';
|
||||
import type { Point } from '../rough/geometry.js';
|
||||
|
||||
export function pointsOnPath(
|
||||
path: string,
|
||||
tolerance?: number,
|
||||
distance?: number
|
||||
): Point[][] {
|
||||
const segments = parsePath(path);
|
||||
const normalized = normalize(absolutize(segments));
|
||||
|
||||
const sets: Point[][] = [];
|
||||
let currentPoints: Point[] = [];
|
||||
let start: Point = [0, 0];
|
||||
let pendingCurve: Point[] = [];
|
||||
|
||||
const appendPendingCurve = () => {
|
||||
if (pendingCurve.length >= 4) {
|
||||
currentPoints.push(...pointsOnBezierCurves(pendingCurve, tolerance));
|
||||
}
|
||||
pendingCurve = [];
|
||||
};
|
||||
|
||||
const appendPendingPoints = () => {
|
||||
appendPendingCurve();
|
||||
if (currentPoints.length) {
|
||||
sets.push(currentPoints);
|
||||
currentPoints = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const { key, data } of normalized) {
|
||||
switch (key) {
|
||||
case 'M':
|
||||
appendPendingPoints();
|
||||
start = [data[0], data[1]];
|
||||
currentPoints.push(start);
|
||||
break;
|
||||
case 'L':
|
||||
appendPendingCurve();
|
||||
currentPoints.push([data[0], data[1]]);
|
||||
break;
|
||||
case 'C':
|
||||
if (!pendingCurve.length) {
|
||||
const lastPoint = currentPoints.length
|
||||
? currentPoints[currentPoints.length - 1]
|
||||
: start;
|
||||
pendingCurve.push([lastPoint[0], lastPoint[1]]);
|
||||
}
|
||||
pendingCurve.push([data[0], data[1]]);
|
||||
pendingCurve.push([data[2], data[3]]);
|
||||
pendingCurve.push([data[4], data[5]]);
|
||||
break;
|
||||
case 'Z':
|
||||
appendPendingCurve();
|
||||
currentPoints.push([start[0], start[1]]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
appendPendingPoints();
|
||||
|
||||
if (!distance) {
|
||||
return sets;
|
||||
}
|
||||
|
||||
const out: Point[][] = [];
|
||||
for (const set of sets) {
|
||||
const simplifiedSet = simplify(set, distance);
|
||||
if (simplifiedSet.length) {
|
||||
out.push(simplifiedSet);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
type PriorityQueueNode<T, K> = {
|
||||
value: T;
|
||||
priority: K;
|
||||
};
|
||||
|
||||
export class PriorityQueue<T, K> {
|
||||
heap: PriorityQueueNode<T, K>[] = [];
|
||||
|
||||
constructor(private readonly _compare: (a: K, b: K) => number) {}
|
||||
|
||||
bubbleDown(): void {
|
||||
let index = 0;
|
||||
const length = this.heap.length;
|
||||
const element = this.heap[0];
|
||||
|
||||
for (;;) {
|
||||
const leftChildIndex = 2 * index + 1;
|
||||
const rightChildIndex = 2 * index + 2;
|
||||
let swap = -1;
|
||||
let leftChild: PriorityQueueNode<T, K>,
|
||||
rightChild: PriorityQueueNode<T, K>;
|
||||
|
||||
if (leftChildIndex < length) {
|
||||
leftChild = this.heap[leftChildIndex];
|
||||
if (this._compare(leftChild.priority, element.priority) < 0) {
|
||||
swap = leftChildIndex;
|
||||
}
|
||||
}
|
||||
|
||||
if (rightChildIndex < length) {
|
||||
leftChild = this.heap[leftChildIndex];
|
||||
rightChild = this.heap[rightChildIndex];
|
||||
if (
|
||||
(swap === null &&
|
||||
this._compare(rightChild.priority, element.priority) < 0) ||
|
||||
(swap !== null &&
|
||||
this._compare(rightChild.priority, leftChild.priority) < 0)
|
||||
) {
|
||||
swap = rightChildIndex;
|
||||
}
|
||||
}
|
||||
|
||||
if (swap === -1) break;
|
||||
|
||||
this.heap[index] = this.heap[swap];
|
||||
this.heap[swap] = element;
|
||||
index = swap;
|
||||
}
|
||||
}
|
||||
|
||||
bubbleUp(index: number = this.heap.length - 1): void {
|
||||
const element = this.heap[index];
|
||||
|
||||
while (index > 0) {
|
||||
const parentIndex = Math.floor((index - 1) / 2);
|
||||
const parent = this.heap[parentIndex];
|
||||
|
||||
if (this._compare(parent.priority, element.priority) <= 0) break;
|
||||
|
||||
this.heap[parentIndex] = element;
|
||||
this.heap[index] = parent;
|
||||
index = parentIndex;
|
||||
}
|
||||
}
|
||||
|
||||
dequeue(): T | null {
|
||||
const min = this.heap[0];
|
||||
const end = this.heap.pop();
|
||||
if (this.heap.length > 0 && end) {
|
||||
this.heap[0] = end;
|
||||
this.bubbleDown();
|
||||
}
|
||||
return min?.value ?? null;
|
||||
}
|
||||
|
||||
empty(): boolean {
|
||||
return this.heap.length === 0;
|
||||
}
|
||||
|
||||
enqueue(value: T, priority: K): void {
|
||||
const node = { value, priority };
|
||||
this.heap.push(node);
|
||||
this.bubbleUp();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Preet Shihn
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,214 @@
|
||||
import type {
|
||||
Config,
|
||||
Drawable,
|
||||
OpSet,
|
||||
Options,
|
||||
ResolvedOptions,
|
||||
} from './core.js';
|
||||
import { RoughGenerator } from './generator.js';
|
||||
import type { Point } from './geometry.js';
|
||||
|
||||
export class RoughCanvas {
|
||||
private readonly canvas: HTMLCanvasElement;
|
||||
|
||||
private readonly ctx: CanvasRenderingContext2D;
|
||||
|
||||
private readonly gen: RoughGenerator;
|
||||
|
||||
get generator(): RoughGenerator {
|
||||
return this.gen;
|
||||
}
|
||||
|
||||
constructor(canvas: HTMLCanvasElement, config?: Config) {
|
||||
this.canvas = canvas;
|
||||
|
||||
this.ctx = this.canvas.getContext('2d')!;
|
||||
this.gen = new RoughGenerator(config);
|
||||
}
|
||||
|
||||
private _drawToContext(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
drawing: OpSet,
|
||||
fixedDecimals?: number,
|
||||
rule: CanvasFillRule = 'nonzero'
|
||||
) {
|
||||
ctx.beginPath();
|
||||
for (const item of drawing.ops) {
|
||||
const data =
|
||||
typeof fixedDecimals === 'number' && fixedDecimals >= 0
|
||||
? item.data.map(d => +d.toFixed(fixedDecimals))
|
||||
: item.data;
|
||||
switch (item.op) {
|
||||
case 'move':
|
||||
ctx.moveTo(data[0], data[1]);
|
||||
break;
|
||||
case 'bcurveTo':
|
||||
ctx.bezierCurveTo(
|
||||
data[0],
|
||||
data[1],
|
||||
data[2],
|
||||
data[3],
|
||||
data[4],
|
||||
data[5]
|
||||
);
|
||||
break;
|
||||
case 'lineTo':
|
||||
ctx.lineTo(data[0], data[1]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (drawing.type === 'fillPath') {
|
||||
ctx.fill(rule);
|
||||
} else {
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
private fillSketch(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
drawing: OpSet,
|
||||
o: ResolvedOptions
|
||||
) {
|
||||
let fweight = o.fillWeight;
|
||||
if (fweight < 0) {
|
||||
fweight = o.strokeWidth / 2;
|
||||
}
|
||||
ctx.save();
|
||||
if (o.fillLineDash) {
|
||||
ctx.setLineDash(o.fillLineDash);
|
||||
}
|
||||
if (o.fillLineDashOffset) {
|
||||
ctx.lineDashOffset = o.fillLineDashOffset;
|
||||
}
|
||||
ctx.strokeStyle = o.fill || '';
|
||||
ctx.lineWidth = fweight;
|
||||
this._drawToContext(ctx, drawing, o.fixedDecimalPlaceDigits);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
arc(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
start: number,
|
||||
stop: number,
|
||||
closed = false,
|
||||
options?: Options
|
||||
): Drawable {
|
||||
const d = this.gen.arc(x, y, width, height, start, stop, closed, options);
|
||||
this.draw(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
circle(x: number, y: number, diameter: number, options?: Options): Drawable {
|
||||
const d = this.gen.circle(x, y, diameter, options);
|
||||
this.draw(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
curve(points: Point[], options?: Options): Drawable {
|
||||
const d = this.gen.curve(points, options);
|
||||
this.draw(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
draw(drawable: Drawable): void {
|
||||
const sets = drawable.sets || [];
|
||||
const o = drawable.options || this.getDefaultOptions();
|
||||
const ctx = this.ctx;
|
||||
const precision = drawable.options.fixedDecimalPlaceDigits;
|
||||
|
||||
for (const drawing of sets) {
|
||||
switch (drawing.type) {
|
||||
case 'path':
|
||||
ctx.save();
|
||||
ctx.strokeStyle = o.stroke === 'none' ? 'transparent' : o.stroke;
|
||||
ctx.lineWidth = o.strokeWidth;
|
||||
if (o.strokeLineDash) {
|
||||
ctx.setLineDash(o.strokeLineDash);
|
||||
}
|
||||
if (o.strokeLineDashOffset) {
|
||||
ctx.lineDashOffset = o.strokeLineDashOffset;
|
||||
}
|
||||
this._drawToContext(ctx, drawing, precision);
|
||||
ctx.restore();
|
||||
break;
|
||||
case 'fillPath': {
|
||||
ctx.save();
|
||||
ctx.fillStyle = o.fill || '';
|
||||
const fillRule: CanvasFillRule =
|
||||
drawable.shape === 'curve' ||
|
||||
drawable.shape === 'polygon' ||
|
||||
drawable.shape === 'path'
|
||||
? 'evenodd'
|
||||
: 'nonzero';
|
||||
this._drawToContext(ctx, drawing, precision, fillRule);
|
||||
ctx.restore();
|
||||
break;
|
||||
}
|
||||
case 'fillSketch':
|
||||
this.fillSketch(ctx, drawing, o);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ellipse(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
options?: Options
|
||||
): Drawable {
|
||||
const d = this.gen.ellipse(x, y, width, height, options);
|
||||
this.draw(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
getDefaultOptions(): ResolvedOptions {
|
||||
return this.gen.defaultOptions;
|
||||
}
|
||||
|
||||
line(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
options?: Options
|
||||
): Drawable {
|
||||
const d = this.gen.line(x1, y1, x2, y2, options);
|
||||
this.draw(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
linearPath(points: Point[], options?: Options): Drawable {
|
||||
const d = this.gen.linearPath(points, options);
|
||||
this.draw(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
path(d: string, options?: Options): Drawable {
|
||||
const drawing = this.gen.path(d, options);
|
||||
this.draw(drawing);
|
||||
return drawing;
|
||||
}
|
||||
|
||||
polygon(points: Point[], options?: Options): Drawable {
|
||||
const d = this.gen.polygon(points, options);
|
||||
this.draw(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
rectangle(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
options?: Options
|
||||
): Drawable {
|
||||
const d = this.gen.rectangle(x, y, width, height, options);
|
||||
this.draw(d);
|
||||
return d;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Point } from './geometry.js';
|
||||
import type { Random } from './math.js';
|
||||
|
||||
export const SVGNS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
export interface Config {
|
||||
options?: Options;
|
||||
}
|
||||
|
||||
export interface DrawingSurface {
|
||||
width: number | SVGAnimatedLength;
|
||||
height: number | SVGAnimatedLength;
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
maxRandomnessOffset?: number;
|
||||
roughness?: number;
|
||||
bowing?: number;
|
||||
stroke?: string;
|
||||
strokeWidth?: number;
|
||||
curveFitting?: number;
|
||||
curveTightness?: number;
|
||||
curveStepCount?: number;
|
||||
fill?: string;
|
||||
fillStyle?: string;
|
||||
fillWeight?: number;
|
||||
hachureAngle?: number;
|
||||
hachureGap?: number;
|
||||
simplification?: number;
|
||||
dashOffset?: number;
|
||||
dashGap?: number;
|
||||
zigzagOffset?: number;
|
||||
seed?: number;
|
||||
strokeLineDash?: number[];
|
||||
strokeLineDashOffset?: number;
|
||||
fillLineDash?: number[];
|
||||
fillLineDashOffset?: number;
|
||||
disableMultiStroke?: boolean;
|
||||
disableMultiStrokeFill?: boolean;
|
||||
preserveVertices?: boolean;
|
||||
fixedDecimalPlaceDigits?: number;
|
||||
}
|
||||
|
||||
export interface ResolvedOptions extends Options {
|
||||
maxRandomnessOffset: number;
|
||||
roughness: number;
|
||||
bowing: number;
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
curveFitting: number;
|
||||
curveTightness: number;
|
||||
curveStepCount: number;
|
||||
fillStyle: string;
|
||||
fillWeight: number;
|
||||
hachureAngle: number;
|
||||
hachureGap: number;
|
||||
dashOffset: number;
|
||||
dashGap: number;
|
||||
zigzagOffset: number;
|
||||
seed: number;
|
||||
randomizer?: Random;
|
||||
disableMultiStroke: boolean;
|
||||
disableMultiStrokeFill: boolean;
|
||||
preserveVertices: boolean;
|
||||
}
|
||||
|
||||
export type OpType = 'move' | 'bcurveTo' | 'lineTo';
|
||||
export type OpSetType = 'path' | 'fillPath' | 'fillSketch';
|
||||
|
||||
export interface Op {
|
||||
op: OpType;
|
||||
data: number[];
|
||||
}
|
||||
|
||||
export interface OpSet {
|
||||
type: OpSetType;
|
||||
ops: Op[];
|
||||
size?: Point;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface Drawable {
|
||||
shape: string;
|
||||
options: ResolvedOptions;
|
||||
sets: OpSet[];
|
||||
}
|
||||
|
||||
export interface PathInfo {
|
||||
d: string;
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
fill?: string;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Op, OpSet, ResolvedOptions } from '../core.js';
|
||||
import type { Line, Point } from '../geometry.js';
|
||||
import { lineLength } from '../geometry.js';
|
||||
import type { PatternFiller, RenderHelper } from './filler-interface.js';
|
||||
import { polygonHachureLines } from './scan-line-hachure.js';
|
||||
|
||||
export class DashedFiller implements PatternFiller {
|
||||
private readonly helper: RenderHelper;
|
||||
|
||||
constructor(helper: RenderHelper) {
|
||||
this.helper = helper;
|
||||
}
|
||||
|
||||
private dashedLine(lines: Line[], o: ResolvedOptions): Op[] {
|
||||
const offset =
|
||||
o.dashOffset < 0
|
||||
? o.hachureGap < 0
|
||||
? o.strokeWidth * 4
|
||||
: o.hachureGap
|
||||
: o.dashOffset;
|
||||
const gap =
|
||||
o.dashGap < 0
|
||||
? o.hachureGap < 0
|
||||
? o.strokeWidth * 4
|
||||
: o.hachureGap
|
||||
: o.dashGap;
|
||||
const ops: Op[] = [];
|
||||
lines.forEach(line => {
|
||||
const length = lineLength(line);
|
||||
const count = Math.floor(length / (offset + gap));
|
||||
const startOffset = (length + gap - count * (offset + gap)) / 2;
|
||||
let p1 = line[0];
|
||||
let p2 = line[1];
|
||||
if (p1[0] > p2[0]) {
|
||||
p1 = line[1];
|
||||
p2 = line[0];
|
||||
}
|
||||
const alpha = Math.atan((p2[1] - p1[1]) / (p2[0] - p1[0]));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const lstart = i * (offset + gap);
|
||||
const lend = lstart + offset;
|
||||
const start: Point = [
|
||||
p1[0] + lstart * Math.cos(alpha) + startOffset * Math.cos(alpha),
|
||||
p1[1] + lstart * Math.sin(alpha) + startOffset * Math.sin(alpha),
|
||||
];
|
||||
const end: Point = [
|
||||
p1[0] + lend * Math.cos(alpha) + startOffset * Math.cos(alpha),
|
||||
p1[1] + lend * Math.sin(alpha) + startOffset * Math.sin(alpha),
|
||||
];
|
||||
ops.push(
|
||||
...this.helper.doubleLineOps(start[0], start[1], end[0], end[1], o)
|
||||
);
|
||||
}
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet {
|
||||
const lines = polygonHachureLines(polygonList, o);
|
||||
return { type: 'fillSketch', ops: this.dashedLine(lines, o) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Op, OpSet, ResolvedOptions } from '../core.js';
|
||||
import type { Line, Point } from '../geometry.js';
|
||||
import { lineLength } from '../geometry.js';
|
||||
import type { PatternFiller, RenderHelper } from './filler-interface.js';
|
||||
import { polygonHachureLines } from './scan-line-hachure.js';
|
||||
|
||||
export class DotFiller implements PatternFiller {
|
||||
private readonly helper: RenderHelper;
|
||||
|
||||
constructor(helper: RenderHelper) {
|
||||
this.helper = helper;
|
||||
}
|
||||
|
||||
private dotsOnLines(lines: Line[], o: ResolvedOptions): OpSet {
|
||||
const ops: Op[] = [];
|
||||
let gap = o.hachureGap;
|
||||
if (gap < 0) {
|
||||
gap = o.strokeWidth * 4;
|
||||
}
|
||||
gap = Math.max(gap, 0.1);
|
||||
let fweight = o.fillWeight;
|
||||
if (fweight < 0) {
|
||||
fweight = o.strokeWidth / 2;
|
||||
}
|
||||
const ro = gap / 4;
|
||||
for (const line of lines) {
|
||||
const length = lineLength(line);
|
||||
const dl = length / gap;
|
||||
const count = Math.ceil(dl) - 1;
|
||||
const offset = length - count * gap;
|
||||
const x = (line[0][0] + line[1][0]) / 2 - gap / 4;
|
||||
const minY = Math.min(line[0][1], line[1][1]);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const y = minY + offset + i * gap;
|
||||
const cx = x - ro + Math.random() * 2 * ro;
|
||||
const cy = y - ro + Math.random() * 2 * ro;
|
||||
const el = this.helper.ellipse(cx, cy, fweight, fweight, o);
|
||||
ops.push(...el.ops);
|
||||
}
|
||||
}
|
||||
return { type: 'fillSketch', ops };
|
||||
}
|
||||
|
||||
fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet {
|
||||
o = Object.assign({}, o, { hachureAngle: 0 });
|
||||
const lines = polygonHachureLines(polygonList, o);
|
||||
return this.dotsOnLines(lines, o);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Op, OpSet, ResolvedOptions } from '../core.js';
|
||||
import type { Point } from '../geometry.js';
|
||||
|
||||
export interface PatternFiller {
|
||||
fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet;
|
||||
}
|
||||
|
||||
export interface RenderHelper {
|
||||
randOffset(x: number, o: ResolvedOptions): number;
|
||||
randOffsetWithRange(min: number, max: number, o: ResolvedOptions): number;
|
||||
ellipse(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
o: ResolvedOptions
|
||||
): OpSet;
|
||||
doubleLineOps(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
o: ResolvedOptions
|
||||
): Op[];
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ResolvedOptions } from '../core.js';
|
||||
import { DashedFiller } from './dashed-filler.js';
|
||||
import { DotFiller } from './dot-filler.js';
|
||||
import type { PatternFiller, RenderHelper } from './filler-interface.js';
|
||||
import { HachureFiller } from './hachure-filler.js';
|
||||
import { HatchFiller } from './hatch-filler.js';
|
||||
import { ZigZagFiller } from './zigzag-filler.js';
|
||||
import { ZigZagLineFiller } from './zigzag-line-filler.js';
|
||||
|
||||
const fillers: Record<string, PatternFiller> = {};
|
||||
|
||||
export function getFiller(
|
||||
o: ResolvedOptions,
|
||||
helper: RenderHelper
|
||||
): PatternFiller {
|
||||
let fillerName = o.fillStyle || 'hachure';
|
||||
if (!fillers[fillerName]) {
|
||||
switch (fillerName) {
|
||||
case 'zigzag':
|
||||
if (!fillers[fillerName]) {
|
||||
fillers[fillerName] = new ZigZagFiller(helper);
|
||||
}
|
||||
break;
|
||||
case 'cross-hatch':
|
||||
if (!fillers[fillerName]) {
|
||||
fillers[fillerName] = new HatchFiller(helper);
|
||||
}
|
||||
break;
|
||||
case 'dots':
|
||||
if (!fillers[fillerName]) {
|
||||
fillers[fillerName] = new DotFiller(helper);
|
||||
}
|
||||
break;
|
||||
case 'dashed':
|
||||
if (!fillers[fillerName]) {
|
||||
fillers[fillerName] = new DashedFiller(helper);
|
||||
}
|
||||
break;
|
||||
case 'zigzag-line':
|
||||
if (!fillers[fillerName]) {
|
||||
fillers[fillerName] = new ZigZagLineFiller(helper);
|
||||
}
|
||||
break;
|
||||
case 'hachure':
|
||||
default:
|
||||
fillerName = 'hachure';
|
||||
if (!fillers[fillerName]) {
|
||||
fillers[fillerName] = new HachureFiller(helper);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return fillers[fillerName];
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Op, OpSet, ResolvedOptions } from '../core.js';
|
||||
import type { Line, Point } from '../geometry.js';
|
||||
import type { PatternFiller, RenderHelper } from './filler-interface.js';
|
||||
import { polygonHachureLines } from './scan-line-hachure.js';
|
||||
|
||||
export class HachureFiller implements PatternFiller {
|
||||
private readonly helper: RenderHelper;
|
||||
|
||||
constructor(helper: RenderHelper) {
|
||||
this.helper = helper;
|
||||
}
|
||||
|
||||
protected _fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet {
|
||||
const lines = polygonHachureLines(polygonList, o);
|
||||
const ops = this.renderLines(lines, o);
|
||||
return { type: 'fillSketch', ops };
|
||||
}
|
||||
|
||||
fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet {
|
||||
return this._fillPolygons(polygonList, o);
|
||||
}
|
||||
|
||||
protected renderLines(lines: Line[], o: ResolvedOptions): Op[] {
|
||||
const ops: Op[] = [];
|
||||
for (const line of lines) {
|
||||
ops.push(
|
||||
...this.helper.doubleLineOps(
|
||||
line[0][0],
|
||||
line[0][1],
|
||||
line[1][0],
|
||||
line[1][1],
|
||||
o
|
||||
)
|
||||
);
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { OpSet, ResolvedOptions } from '../core.js';
|
||||
import type { Point } from '../geometry.js';
|
||||
import { HachureFiller } from './hachure-filler.js';
|
||||
|
||||
export class HatchFiller extends HachureFiller {
|
||||
override fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet {
|
||||
const set = this._fillPolygons(polygonList, o);
|
||||
const o2 = Object.assign({}, o, { hachureAngle: o.hachureAngle + 90 });
|
||||
const set2 = this._fillPolygons(polygonList, o2);
|
||||
set.ops = set.ops.concat(set2.ops);
|
||||
return set;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { ResolvedOptions } from '../core.js';
|
||||
import type { Line, Point } from '../geometry.js';
|
||||
import { rotateLines, rotatePoints } from '../geometry.js';
|
||||
|
||||
interface EdgeEntry {
|
||||
ymin: number;
|
||||
ymax: number;
|
||||
x: number;
|
||||
islope: number;
|
||||
}
|
||||
|
||||
interface ActiveEdgeEntry {
|
||||
s: number;
|
||||
edge: EdgeEntry;
|
||||
}
|
||||
|
||||
export function polygonHachureLines(
|
||||
polygonList: Point[][],
|
||||
o: ResolvedOptions
|
||||
): Line[] {
|
||||
const angle = o.hachureAngle + 90;
|
||||
let gap = o.hachureGap;
|
||||
if (gap < 0) {
|
||||
gap = o.strokeWidth * 4;
|
||||
}
|
||||
gap = Math.max(gap, 0.1);
|
||||
|
||||
const rotationCenter: Point = [0, 0];
|
||||
if (angle) {
|
||||
for (const polygon of polygonList) {
|
||||
rotatePoints(polygon, rotationCenter, angle);
|
||||
}
|
||||
}
|
||||
const lines = straightHachureLines(polygonList, gap);
|
||||
if (angle) {
|
||||
for (const polygon of polygonList) {
|
||||
rotatePoints(polygon, rotationCenter, -angle);
|
||||
}
|
||||
rotateLines(lines, rotationCenter, -angle);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function straightHachureLines(polygonList: Point[][], gap: number): Line[] {
|
||||
const vertexArray: Point[][] = [];
|
||||
for (const polygon of polygonList) {
|
||||
const vertices = [...polygon];
|
||||
if (vertices[0].join(',') !== vertices[vertices.length - 1].join(',')) {
|
||||
vertices.push([vertices[0][0], vertices[0][1]]);
|
||||
}
|
||||
if (vertices.length > 2) {
|
||||
vertexArray.push(vertices);
|
||||
}
|
||||
}
|
||||
|
||||
const lines: Line[] = [];
|
||||
gap = Math.max(gap, 0.1);
|
||||
|
||||
// Create sorted edges table
|
||||
const edges: EdgeEntry[] = [];
|
||||
|
||||
for (const vertices of vertexArray) {
|
||||
for (let i = 0; i < vertices.length - 1; i++) {
|
||||
const p1 = vertices[i];
|
||||
const p2 = vertices[i + 1];
|
||||
if (p1[1] !== p2[1]) {
|
||||
const ymin = Math.min(p1[1], p2[1]);
|
||||
edges.push({
|
||||
ymin,
|
||||
ymax: Math.max(p1[1], p2[1]),
|
||||
x: ymin === p1[1] ? p1[0] : p2[0],
|
||||
islope: (p2[0] - p1[0]) / (p2[1] - p1[1]),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
edges.sort((e1, e2) => {
|
||||
if (e1.ymin < e2.ymin) {
|
||||
return -1;
|
||||
}
|
||||
if (e1.ymin > e2.ymin) {
|
||||
return 1;
|
||||
}
|
||||
if (e1.x < e2.x) {
|
||||
return -1;
|
||||
}
|
||||
if (e1.x > e2.x) {
|
||||
return 1;
|
||||
}
|
||||
if (e1.ymax === e2.ymax) {
|
||||
return 0;
|
||||
}
|
||||
return (e1.ymax - e2.ymax) / Math.abs(e1.ymax - e2.ymax);
|
||||
});
|
||||
if (!edges.length) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Start scanning
|
||||
let activeEdges: ActiveEdgeEntry[] = [];
|
||||
let y = edges[0].ymin;
|
||||
while (activeEdges.length || edges.length) {
|
||||
if (edges.length) {
|
||||
let ix = -1;
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
if (edges[i].ymin > y) {
|
||||
break;
|
||||
}
|
||||
ix = i;
|
||||
}
|
||||
const removed = edges.splice(0, ix + 1);
|
||||
removed.forEach(edge => {
|
||||
activeEdges.push({ s: y, edge });
|
||||
});
|
||||
}
|
||||
activeEdges = activeEdges.filter(ae => {
|
||||
if (ae.edge.ymax <= y) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
activeEdges.sort((ae1, ae2) => {
|
||||
if (ae1.edge.x === ae2.edge.x) {
|
||||
return 0;
|
||||
}
|
||||
return (ae1.edge.x - ae2.edge.x) / Math.abs(ae1.edge.x - ae2.edge.x);
|
||||
});
|
||||
|
||||
// fill between the edges
|
||||
if (activeEdges.length > 1) {
|
||||
for (let i = 0; i < activeEdges.length; i = i + 2) {
|
||||
const nexti = i + 1;
|
||||
if (nexti >= activeEdges.length) {
|
||||
break;
|
||||
}
|
||||
const ce = activeEdges[i].edge;
|
||||
const ne = activeEdges[nexti].edge;
|
||||
lines.push([
|
||||
[Math.round(ce.x), y],
|
||||
[Math.round(ne.x), y],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
y += gap;
|
||||
activeEdges.forEach(ae => {
|
||||
ae.edge.x = ae.edge.x + gap * ae.edge.islope;
|
||||
});
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { OpSet, ResolvedOptions } from '../core.js';
|
||||
import type { Line, Point } from '../geometry.js';
|
||||
import { lineLength } from '../geometry.js';
|
||||
import { HachureFiller } from './hachure-filler.js';
|
||||
import { polygonHachureLines } from './scan-line-hachure.js';
|
||||
|
||||
export class ZigZagFiller extends HachureFiller {
|
||||
override fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet {
|
||||
let gap = o.hachureGap;
|
||||
if (gap < 0) {
|
||||
gap = o.strokeWidth * 4;
|
||||
}
|
||||
gap = Math.max(gap, 0.1);
|
||||
const o2 = Object.assign({}, o, { hachureGap: gap });
|
||||
const lines = polygonHachureLines(polygonList, o2);
|
||||
const zigZagAngle = (Math.PI / 180) * o.hachureAngle;
|
||||
const zigzagLines: Line[] = [];
|
||||
const dgx = gap * 0.5 * Math.cos(zigZagAngle);
|
||||
const dgy = gap * 0.5 * Math.sin(zigZagAngle);
|
||||
for (const [p1, p2] of lines) {
|
||||
if (lineLength([p1, p2])) {
|
||||
zigzagLines.push(
|
||||
[[p1[0] - dgx, p1[1] + dgy], [...p2]],
|
||||
[[p1[0] + dgx, p1[1] - dgy], [...p2]]
|
||||
);
|
||||
}
|
||||
}
|
||||
const ops = this.renderLines(zigzagLines, o);
|
||||
return { type: 'fillSketch', ops };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Op, OpSet, ResolvedOptions } from '../core.js';
|
||||
import type { Line, Point } from '../geometry.js';
|
||||
import { lineLength } from '../geometry.js';
|
||||
import type { PatternFiller, RenderHelper } from './filler-interface.js';
|
||||
import { polygonHachureLines } from './scan-line-hachure.js';
|
||||
|
||||
export class ZigZagLineFiller implements PatternFiller {
|
||||
private readonly helper: RenderHelper;
|
||||
|
||||
constructor(helper: RenderHelper) {
|
||||
this.helper = helper;
|
||||
}
|
||||
|
||||
private zigzagLines(lines: Line[], zo: number, o: ResolvedOptions): Op[] {
|
||||
const ops: Op[] = [];
|
||||
lines.forEach(line => {
|
||||
const length = lineLength(line);
|
||||
const count = Math.round(length / (2 * zo));
|
||||
let p1 = line[0];
|
||||
let p2 = line[1];
|
||||
if (p1[0] > p2[0]) {
|
||||
p1 = line[1];
|
||||
p2 = line[0];
|
||||
}
|
||||
const alpha = Math.atan((p2[1] - p1[1]) / (p2[0] - p1[0]));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const lstart = i * 2 * zo;
|
||||
const lend = (i + 1) * 2 * zo;
|
||||
const dz = Math.sqrt(2 * Math.pow(zo, 2));
|
||||
const start: Point = [
|
||||
p1[0] + lstart * Math.cos(alpha),
|
||||
p1[1] + lstart * Math.sin(alpha),
|
||||
];
|
||||
const end: Point = [
|
||||
p1[0] + lend * Math.cos(alpha),
|
||||
p1[1] + lend * Math.sin(alpha),
|
||||
];
|
||||
const middle: Point = [
|
||||
start[0] + dz * Math.cos(alpha + Math.PI / 4),
|
||||
start[1] + dz * Math.sin(alpha + Math.PI / 4),
|
||||
];
|
||||
ops.push(
|
||||
...this.helper.doubleLineOps(
|
||||
start[0],
|
||||
start[1],
|
||||
middle[0],
|
||||
middle[1],
|
||||
o
|
||||
),
|
||||
...this.helper.doubleLineOps(middle[0], middle[1], end[0], end[1], o)
|
||||
);
|
||||
}
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet {
|
||||
const gap = o.hachureGap < 0 ? o.strokeWidth * 4 : o.hachureGap;
|
||||
const zo = o.zigzagOffset < 0 ? gap : o.zigzagOffset;
|
||||
o = Object.assign({}, o, { hachureGap: gap + zo });
|
||||
const lines = polygonHachureLines(polygonList, o);
|
||||
return { type: 'fillSketch', ops: this.zigzagLines(lines, zo, o) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { curveToBezier } from '../points-on-curve/curve-to-bezier.js';
|
||||
import { pointsOnBezierCurves } from '../points-on-curve/index.js';
|
||||
import { pointsOnPath } from '../points-on-path/index.js';
|
||||
import type {
|
||||
Config,
|
||||
Drawable,
|
||||
OpSet,
|
||||
Options,
|
||||
PathInfo,
|
||||
ResolvedOptions,
|
||||
} from './core.js';
|
||||
import type { Point } from './geometry.js';
|
||||
import { randomSeed } from './math.js';
|
||||
import {
|
||||
arc,
|
||||
curve,
|
||||
ellipseWithParams,
|
||||
generateEllipseParams,
|
||||
line,
|
||||
linearPath,
|
||||
patternFillArc,
|
||||
patternFillPolygons,
|
||||
rectangle,
|
||||
solidFillPolygon,
|
||||
svgPath,
|
||||
} from './renderer.js';
|
||||
|
||||
const NOS = 'none';
|
||||
|
||||
export class RoughGenerator {
|
||||
private readonly config: Config;
|
||||
|
||||
defaultOptions: ResolvedOptions = {
|
||||
maxRandomnessOffset: 2,
|
||||
roughness: 1,
|
||||
bowing: 1,
|
||||
stroke: '#000',
|
||||
strokeWidth: 1,
|
||||
curveTightness: 0,
|
||||
curveFitting: 0.95,
|
||||
curveStepCount: 9,
|
||||
fillStyle: 'hachure',
|
||||
fillWeight: -1,
|
||||
hachureAngle: -41,
|
||||
hachureGap: -1,
|
||||
dashOffset: -1,
|
||||
dashGap: -1,
|
||||
zigzagOffset: -1,
|
||||
seed: 0,
|
||||
disableMultiStroke: false,
|
||||
disableMultiStrokeFill: false,
|
||||
preserveVertices: false,
|
||||
};
|
||||
|
||||
constructor(config?: Config) {
|
||||
this.config = config || {};
|
||||
if (this.config.options) {
|
||||
this.defaultOptions = this._o(this.config.options);
|
||||
}
|
||||
}
|
||||
|
||||
static newSeed(): number {
|
||||
return randomSeed();
|
||||
}
|
||||
|
||||
private _d(shape: string, sets: OpSet[], options: ResolvedOptions): Drawable {
|
||||
return { shape, sets: sets || [], options: options || this.defaultOptions };
|
||||
}
|
||||
|
||||
private _o(options?: Options): ResolvedOptions {
|
||||
return options
|
||||
? Object.assign({}, this.defaultOptions, options)
|
||||
: this.defaultOptions;
|
||||
}
|
||||
|
||||
private fillSketch(drawing: OpSet, o: ResolvedOptions): PathInfo {
|
||||
let fweight = o.fillWeight;
|
||||
if (fweight < 0) {
|
||||
fweight = o.strokeWidth / 2;
|
||||
}
|
||||
return {
|
||||
d: this.opsToPath(drawing),
|
||||
stroke: o.fill || NOS,
|
||||
strokeWidth: fweight,
|
||||
fill: NOS,
|
||||
};
|
||||
}
|
||||
|
||||
arc(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
start: number,
|
||||
stop: number,
|
||||
closed = false,
|
||||
options?: Options
|
||||
): Drawable {
|
||||
const o = this._o(options);
|
||||
const paths = [];
|
||||
const outline = arc(x, y, width, height, start, stop, closed, true, o);
|
||||
if (closed && o.fill) {
|
||||
if (o.fillStyle === 'solid') {
|
||||
const fillOptions: ResolvedOptions = { ...o };
|
||||
fillOptions.disableMultiStroke = true;
|
||||
const shape = arc(
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
start,
|
||||
stop,
|
||||
true,
|
||||
false,
|
||||
fillOptions
|
||||
);
|
||||
shape.type = 'fillPath';
|
||||
paths.push(shape);
|
||||
} else {
|
||||
paths.push(patternFillArc(x, y, width, height, start, stop, o));
|
||||
}
|
||||
}
|
||||
if (o.stroke !== NOS) {
|
||||
paths.push(outline);
|
||||
}
|
||||
return this._d('arc', paths, o);
|
||||
}
|
||||
|
||||
circle(x: number, y: number, diameter: number, options?: Options): Drawable {
|
||||
const ret = this.ellipse(x, y, diameter, diameter, options);
|
||||
ret.shape = 'circle';
|
||||
return ret;
|
||||
}
|
||||
|
||||
curve(points: Point[], options?: Options): Drawable {
|
||||
const o = this._o(options);
|
||||
const paths: OpSet[] = [];
|
||||
const outline = curve(points, o);
|
||||
if (o.fill && o.fill !== NOS && points.length >= 3) {
|
||||
const bcurve = curveToBezier(points);
|
||||
const polyPoints = pointsOnBezierCurves(
|
||||
bcurve,
|
||||
10,
|
||||
(1 + o.roughness) / 2
|
||||
);
|
||||
if (o.fillStyle === 'solid') {
|
||||
paths.push(solidFillPolygon([polyPoints], o));
|
||||
} else {
|
||||
paths.push(patternFillPolygons([polyPoints], o));
|
||||
}
|
||||
}
|
||||
if (o.stroke !== NOS) {
|
||||
paths.push(outline);
|
||||
}
|
||||
return this._d('curve', paths, o);
|
||||
}
|
||||
|
||||
ellipse(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
options?: Options
|
||||
): Drawable {
|
||||
const o = this._o(options);
|
||||
const paths: OpSet[] = [];
|
||||
const ellipseParams = generateEllipseParams(width, height, o);
|
||||
const ellipseResponse = ellipseWithParams(x, y, o, ellipseParams);
|
||||
if (o.fill) {
|
||||
if (o.fillStyle === 'solid') {
|
||||
const shape = ellipseWithParams(x, y, o, ellipseParams).opset;
|
||||
shape.type = 'fillPath';
|
||||
paths.push(shape);
|
||||
} else {
|
||||
paths.push(patternFillPolygons([ellipseResponse.estimatedPoints], o));
|
||||
}
|
||||
}
|
||||
if (o.stroke !== NOS) {
|
||||
paths.push(ellipseResponse.opset);
|
||||
}
|
||||
return this._d('ellipse', paths, o);
|
||||
}
|
||||
|
||||
line(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
options?: Options
|
||||
): Drawable {
|
||||
const o = this._o(options);
|
||||
return this._d('line', [line(x1, y1, x2, y2, o)], o);
|
||||
}
|
||||
|
||||
linearPath(points: Point[], options?: Options): Drawable {
|
||||
const o = this._o(options);
|
||||
return this._d('linearPath', [linearPath(points, false, o)], o);
|
||||
}
|
||||
|
||||
opsToPath(drawing: OpSet, fixedDecimals?: number): string {
|
||||
let path = '';
|
||||
for (const item of drawing.ops) {
|
||||
const data =
|
||||
typeof fixedDecimals === 'number' && fixedDecimals >= 0
|
||||
? item.data.map(d => +d.toFixed(fixedDecimals))
|
||||
: item.data;
|
||||
switch (item.op) {
|
||||
case 'move':
|
||||
path += `M${data[0]} ${data[1]} `;
|
||||
break;
|
||||
case 'bcurveTo':
|
||||
path += `C${data[0]} ${data[1]}, ${data[2]} ${data[3]}, ${data[4]} ${data[5]} `;
|
||||
break;
|
||||
case 'lineTo':
|
||||
path += `L${data[0]} ${data[1]} `;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return path.trim();
|
||||
}
|
||||
|
||||
path(d: string, options?: Options): Drawable {
|
||||
const o = this._o(options);
|
||||
const paths: OpSet[] = [];
|
||||
if (!d) {
|
||||
return this._d('path', paths, o);
|
||||
}
|
||||
d = (d || '')
|
||||
.replace(/\n/g, ' ')
|
||||
.replace(/(-\s)/g, '-')
|
||||
.replace('/(ss)/g', ' ');
|
||||
|
||||
const hasFill = o.fill && o.fill !== 'transparent' && o.fill !== NOS;
|
||||
const hasStroke = o.stroke !== NOS;
|
||||
const simplified = !!(o.simplification && o.simplification < 1);
|
||||
const distance = simplified
|
||||
? 4 - 4 * o.simplification!
|
||||
: (1 + o.roughness) / 2;
|
||||
const sets = pointsOnPath(d, 1, distance);
|
||||
|
||||
if (hasFill) {
|
||||
if (o.fillStyle === 'solid') {
|
||||
paths.push(solidFillPolygon(sets, o));
|
||||
} else {
|
||||
paths.push(patternFillPolygons(sets, o));
|
||||
}
|
||||
}
|
||||
if (hasStroke) {
|
||||
if (simplified) {
|
||||
sets.forEach(set => {
|
||||
paths.push(linearPath(set, false, o));
|
||||
});
|
||||
} else {
|
||||
paths.push(svgPath(d, o));
|
||||
}
|
||||
}
|
||||
|
||||
return this._d('path', paths, o);
|
||||
}
|
||||
|
||||
polygon(points: Point[], options?: Options): Drawable {
|
||||
const o = this._o(options);
|
||||
const paths: OpSet[] = [];
|
||||
const outline = linearPath(points, true, o);
|
||||
if (o.fill) {
|
||||
if (o.fillStyle === 'solid') {
|
||||
paths.push(solidFillPolygon([points], o));
|
||||
} else {
|
||||
paths.push(patternFillPolygons([points], o));
|
||||
}
|
||||
}
|
||||
if (o.stroke !== NOS) {
|
||||
paths.push(outline);
|
||||
}
|
||||
return this._d('polygon', paths, o);
|
||||
}
|
||||
|
||||
rectangle(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
options?: Options
|
||||
): Drawable {
|
||||
const o = this._o(options);
|
||||
const paths = [];
|
||||
const outline = rectangle(x, y, width, height, o);
|
||||
if (o.fill) {
|
||||
const points: Point[] = [
|
||||
[x, y],
|
||||
[x + width, y],
|
||||
[x + width, y + height],
|
||||
[x, y + height],
|
||||
];
|
||||
if (o.fillStyle === 'solid') {
|
||||
paths.push(solidFillPolygon([points], o));
|
||||
} else {
|
||||
paths.push(patternFillPolygons([points], o));
|
||||
}
|
||||
}
|
||||
if (o.stroke !== NOS) {
|
||||
paths.push(outline);
|
||||
}
|
||||
return this._d('rectangle', paths, o);
|
||||
}
|
||||
|
||||
toPaths(drawable: Drawable): PathInfo[] {
|
||||
const sets = drawable.sets || [];
|
||||
const o = drawable.options || this.defaultOptions;
|
||||
const paths: PathInfo[] = [];
|
||||
for (const drawing of sets) {
|
||||
let path: PathInfo | null = null;
|
||||
switch (drawing.type) {
|
||||
case 'path':
|
||||
path = {
|
||||
d: this.opsToPath(drawing),
|
||||
stroke: o.stroke,
|
||||
strokeWidth: o.strokeWidth,
|
||||
fill: NOS,
|
||||
};
|
||||
break;
|
||||
case 'fillPath':
|
||||
path = {
|
||||
d: this.opsToPath(drawing),
|
||||
stroke: NOS,
|
||||
strokeWidth: 0,
|
||||
fill: o.fill || NOS,
|
||||
};
|
||||
break;
|
||||
case 'fillSketch':
|
||||
path = this.fillSketch(drawing, o);
|
||||
break;
|
||||
}
|
||||
if (path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export type Point = [number, number];
|
||||
export type Line = [Point, Point];
|
||||
|
||||
export interface Rectangle {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function rotatePoints(
|
||||
points: Point[],
|
||||
center: Point,
|
||||
degrees: number
|
||||
): void {
|
||||
if (points && points.length) {
|
||||
const [cx, cy] = center;
|
||||
const angle = (Math.PI / 180) * degrees;
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
points.forEach(p => {
|
||||
const [x, y] = p;
|
||||
p[0] = (x - cx) * cos - (y - cy) * sin + cx;
|
||||
p[1] = (x - cx) * sin + (y - cy) * cos + cy;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function rotateLines(
|
||||
lines: Line[],
|
||||
center: Point,
|
||||
degrees: number
|
||||
): void {
|
||||
const points: Point[] = [];
|
||||
lines.forEach(line => points.push(...line));
|
||||
rotatePoints(points, center, degrees);
|
||||
}
|
||||
|
||||
export function lineLength(line: Line): number {
|
||||
const p1 = line[0];
|
||||
const p2 = line[1];
|
||||
return Math.sqrt(Math.pow(p1[0] - p2[0], 2) + Math.pow(p1[1] - p2[1], 2));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export function randomSeed(): number {
|
||||
return Math.floor(Math.random() * 2 ** 31);
|
||||
}
|
||||
|
||||
export class Random {
|
||||
private seed: number;
|
||||
|
||||
constructor(seed: number) {
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
next(): number {
|
||||
if (this.seed) {
|
||||
return (
|
||||
((2 ** 31 - 1) & (this.seed = Math.imul(48271, this.seed))) / 2 ** 31
|
||||
);
|
||||
} else {
|
||||
return Math.random();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
import { absolutize } from '../path-data-parser/absolutize.js';
|
||||
import { normalize } from '../path-data-parser/normalize.js';
|
||||
import { parsePath } from '../path-data-parser/parser.js';
|
||||
import type { Op, OpSet, ResolvedOptions } from './core.js';
|
||||
import { getFiller } from './fillers/filler.js';
|
||||
import type { RenderHelper } from './fillers/filler-interface.js';
|
||||
import type { Point } from './geometry.js';
|
||||
import { Random } from './math.js';
|
||||
|
||||
interface EllipseParams {
|
||||
rx: number;
|
||||
ry: number;
|
||||
increment: number;
|
||||
}
|
||||
|
||||
const helper: RenderHelper = {
|
||||
randOffset,
|
||||
randOffsetWithRange,
|
||||
ellipse,
|
||||
doubleLineOps: doubleLineFillOps,
|
||||
};
|
||||
|
||||
export function line(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
o: ResolvedOptions
|
||||
): OpSet {
|
||||
return { type: 'path', ops: _doubleLine(x1, y1, x2, y2, o) };
|
||||
}
|
||||
|
||||
export function linearPath(
|
||||
points: Point[],
|
||||
close: boolean,
|
||||
o: ResolvedOptions
|
||||
): OpSet {
|
||||
const len = (points || []).length;
|
||||
if (len > 2) {
|
||||
const ops: Op[] = [];
|
||||
for (let i = 0; i < len - 1; i++) {
|
||||
ops.push(
|
||||
..._doubleLine(
|
||||
points[i][0],
|
||||
points[i][1],
|
||||
points[i + 1][0],
|
||||
points[i + 1][1],
|
||||
o
|
||||
)
|
||||
);
|
||||
}
|
||||
if (close) {
|
||||
ops.push(
|
||||
..._doubleLine(
|
||||
points[len - 1][0],
|
||||
points[len - 1][1],
|
||||
points[0][0],
|
||||
points[0][1],
|
||||
o
|
||||
)
|
||||
);
|
||||
}
|
||||
return { type: 'path', ops };
|
||||
} else if (len === 2) {
|
||||
return line(points[0][0], points[0][1], points[1][0], points[1][1], o);
|
||||
}
|
||||
return { type: 'path', ops: [] };
|
||||
}
|
||||
|
||||
export function polygon(points: Point[], o: ResolvedOptions): OpSet {
|
||||
return linearPath(points, true, o);
|
||||
}
|
||||
|
||||
export function rectangle(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
o: ResolvedOptions
|
||||
): OpSet {
|
||||
const points: Point[] = [
|
||||
[x, y],
|
||||
[x + width, y],
|
||||
[x + width, y + height],
|
||||
[x, y + height],
|
||||
];
|
||||
return polygon(points, o);
|
||||
}
|
||||
|
||||
export function curve(points: Point[], o: ResolvedOptions): OpSet {
|
||||
let o1 = _curveWithOffset(points, 1 * (1 + o.roughness * 0.2), o);
|
||||
if (!o.disableMultiStroke) {
|
||||
const o2 = _curveWithOffset(
|
||||
points,
|
||||
1.5 * (1 + o.roughness * 0.22),
|
||||
cloneOptionsAlterSeed(o)
|
||||
);
|
||||
o1 = o1.concat(o2);
|
||||
}
|
||||
return { type: 'path', ops: o1 };
|
||||
}
|
||||
|
||||
export interface EllipseResult {
|
||||
opset: OpSet;
|
||||
estimatedPoints: Point[];
|
||||
}
|
||||
|
||||
export function ellipse(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
o: ResolvedOptions
|
||||
): OpSet {
|
||||
const params = generateEllipseParams(width, height, o);
|
||||
return ellipseWithParams(x, y, o, params).opset;
|
||||
}
|
||||
|
||||
export function generateEllipseParams(
|
||||
width: number,
|
||||
height: number,
|
||||
o: ResolvedOptions
|
||||
): EllipseParams {
|
||||
const psq = Math.sqrt(
|
||||
Math.PI *
|
||||
2 *
|
||||
Math.sqrt((Math.pow(width / 2, 2) + Math.pow(height / 2, 2)) / 2)
|
||||
);
|
||||
const stepCount = Math.ceil(
|
||||
Math.max(o.curveStepCount, (o.curveStepCount / Math.sqrt(200)) * psq)
|
||||
);
|
||||
const increment = (Math.PI * 2) / stepCount;
|
||||
let rx = Math.abs(width / 2);
|
||||
let ry = Math.abs(height / 2);
|
||||
const curveFitRandomness = 1 - o.curveFitting;
|
||||
rx += _offsetOpt(rx * curveFitRandomness, o);
|
||||
ry += _offsetOpt(ry * curveFitRandomness, o);
|
||||
return { increment, rx, ry };
|
||||
}
|
||||
|
||||
export function ellipseWithParams(
|
||||
x: number,
|
||||
y: number,
|
||||
o: ResolvedOptions,
|
||||
ellipseParams: EllipseParams
|
||||
): EllipseResult {
|
||||
const [ap1, cp1] = _computeEllipsePoints(
|
||||
ellipseParams.increment,
|
||||
x,
|
||||
y,
|
||||
ellipseParams.rx,
|
||||
ellipseParams.ry,
|
||||
1,
|
||||
ellipseParams.increment * _offset(0.1, _offset(0.4, 1, o), o),
|
||||
o
|
||||
);
|
||||
let o1 = _curve(ap1, null, o);
|
||||
if (!o.disableMultiStroke && o.roughness !== 0) {
|
||||
const [ap2] = _computeEllipsePoints(
|
||||
ellipseParams.increment,
|
||||
x,
|
||||
y,
|
||||
ellipseParams.rx,
|
||||
ellipseParams.ry,
|
||||
1.5,
|
||||
0,
|
||||
o
|
||||
);
|
||||
const o2 = _curve(ap2, null, o);
|
||||
o1 = o1.concat(o2);
|
||||
}
|
||||
return {
|
||||
estimatedPoints: cp1,
|
||||
opset: { type: 'path', ops: o1 },
|
||||
};
|
||||
}
|
||||
|
||||
export function arc(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
start: number,
|
||||
stop: number,
|
||||
closed: boolean,
|
||||
roughClosure: boolean,
|
||||
o: ResolvedOptions
|
||||
): OpSet {
|
||||
const cx = x;
|
||||
const cy = y;
|
||||
let rx = Math.abs(width / 2);
|
||||
let ry = Math.abs(height / 2);
|
||||
rx += _offsetOpt(rx * 0.01, o);
|
||||
ry += _offsetOpt(ry * 0.01, o);
|
||||
let strt = start;
|
||||
let stp = stop;
|
||||
while (strt < 0) {
|
||||
strt += Math.PI * 2;
|
||||
stp += Math.PI * 2;
|
||||
}
|
||||
if (stp - strt > Math.PI * 2) {
|
||||
strt = 0;
|
||||
stp = Math.PI * 2;
|
||||
}
|
||||
const ellipseInc = (Math.PI * 2) / o.curveStepCount;
|
||||
const arcInc = Math.min(ellipseInc / 2, (stp - strt) / 2);
|
||||
const ops = _arc(arcInc, cx, cy, rx, ry, strt, stp, 1, o);
|
||||
if (!o.disableMultiStroke) {
|
||||
const o2 = _arc(arcInc, cx, cy, rx, ry, strt, stp, 1.5, o);
|
||||
ops.push(...o2);
|
||||
}
|
||||
if (closed) {
|
||||
if (roughClosure) {
|
||||
ops.push(
|
||||
..._doubleLine(
|
||||
cx,
|
||||
cy,
|
||||
cx + rx * Math.cos(strt),
|
||||
cy + ry * Math.sin(strt),
|
||||
o
|
||||
),
|
||||
..._doubleLine(
|
||||
cx,
|
||||
cy,
|
||||
cx + rx * Math.cos(stp),
|
||||
cy + ry * Math.sin(stp),
|
||||
o
|
||||
)
|
||||
);
|
||||
} else {
|
||||
ops.push(
|
||||
{ op: 'lineTo', data: [cx, cy] },
|
||||
{
|
||||
op: 'lineTo',
|
||||
data: [cx + rx * Math.cos(strt), cy + ry * Math.sin(strt)],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
return { type: 'path', ops };
|
||||
}
|
||||
|
||||
export function svgPath(path: string, o: ResolvedOptions): OpSet {
|
||||
const segments = normalize(absolutize(parsePath(path)));
|
||||
const ops: Op[] = [];
|
||||
let first: Point = [0, 0];
|
||||
let current: Point = [0, 0];
|
||||
for (const { key, data } of segments) {
|
||||
switch (key) {
|
||||
case 'M': {
|
||||
const ro = 1 * (o.maxRandomnessOffset || 0);
|
||||
const pv = o.preserveVertices;
|
||||
ops.push({
|
||||
op: 'move',
|
||||
data: data.map(d => d + (pv ? 0 : _offsetOpt(ro, o))),
|
||||
});
|
||||
current = [data[0], data[1]];
|
||||
first = [data[0], data[1]];
|
||||
break;
|
||||
}
|
||||
case 'L':
|
||||
ops.push(..._doubleLine(current[0], current[1], data[0], data[1], o));
|
||||
current = [data[0], data[1]];
|
||||
break;
|
||||
case 'C': {
|
||||
const [x1, y1, x2, y2, x, y] = data;
|
||||
ops.push(..._bezierTo(x1, y1, x2, y2, x, y, current, o));
|
||||
current = [x, y];
|
||||
break;
|
||||
}
|
||||
case 'Z':
|
||||
ops.push(..._doubleLine(current[0], current[1], first[0], first[1], o));
|
||||
current = [first[0], first[1]];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { type: 'path', ops };
|
||||
}
|
||||
|
||||
// Fills
|
||||
|
||||
export function solidFillPolygon(
|
||||
polygonList: Point[][],
|
||||
o: ResolvedOptions
|
||||
): OpSet {
|
||||
const ops: Op[] = [];
|
||||
for (const points of polygonList) {
|
||||
if (points.length) {
|
||||
const offset = o.maxRandomnessOffset || 0;
|
||||
const len = points.length;
|
||||
if (len > 2) {
|
||||
ops.push({
|
||||
op: 'move',
|
||||
data: [
|
||||
points[0][0] + _offsetOpt(offset, o),
|
||||
points[0][1] + _offsetOpt(offset, o),
|
||||
],
|
||||
});
|
||||
for (let i = 1; i < len; i++) {
|
||||
ops.push({
|
||||
op: 'lineTo',
|
||||
data: [
|
||||
points[i][0] + _offsetOpt(offset, o),
|
||||
points[i][1] + _offsetOpt(offset, o),
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { type: 'fillPath', ops };
|
||||
}
|
||||
|
||||
export function patternFillPolygons(
|
||||
polygonList: Point[][],
|
||||
o: ResolvedOptions
|
||||
): OpSet {
|
||||
return getFiller(o, helper).fillPolygons(polygonList, o);
|
||||
}
|
||||
|
||||
export function patternFillArc(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
start: number,
|
||||
stop: number,
|
||||
o: ResolvedOptions
|
||||
): OpSet {
|
||||
const cx = x;
|
||||
const cy = y;
|
||||
let rx = Math.abs(width / 2);
|
||||
let ry = Math.abs(height / 2);
|
||||
rx += _offsetOpt(rx * 0.01, o);
|
||||
ry += _offsetOpt(ry * 0.01, o);
|
||||
let strt = start;
|
||||
let stp = stop;
|
||||
while (strt < 0) {
|
||||
strt += Math.PI * 2;
|
||||
stp += Math.PI * 2;
|
||||
}
|
||||
if (stp - strt > Math.PI * 2) {
|
||||
strt = 0;
|
||||
stp = Math.PI * 2;
|
||||
}
|
||||
const increment = (stp - strt) / o.curveStepCount;
|
||||
const points: Point[] = [];
|
||||
for (let angle = strt; angle <= stp; angle = angle + increment) {
|
||||
points.push([cx + rx * Math.cos(angle), cy + ry * Math.sin(angle)]);
|
||||
}
|
||||
points.push([cx + rx * Math.cos(stp), cy + ry * Math.sin(stp)]);
|
||||
points.push([cx, cy]);
|
||||
return patternFillPolygons([points], o);
|
||||
}
|
||||
|
||||
export function randOffset(x: number, o: ResolvedOptions): number {
|
||||
return _offsetOpt(x, o);
|
||||
}
|
||||
|
||||
export function randOffsetWithRange(
|
||||
min: number,
|
||||
max: number,
|
||||
o: ResolvedOptions
|
||||
): number {
|
||||
return _offset(min, max, o);
|
||||
}
|
||||
|
||||
export function doubleLineFillOps(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
o: ResolvedOptions
|
||||
): Op[] {
|
||||
return _doubleLine(x1, y1, x2, y2, o, true);
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
|
||||
function cloneOptionsAlterSeed(ops: ResolvedOptions): ResolvedOptions {
|
||||
const result: ResolvedOptions = { ...ops };
|
||||
result.randomizer = undefined;
|
||||
if (ops.seed) {
|
||||
result.seed = ops.seed + 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function random(ops: ResolvedOptions): number {
|
||||
if (!ops.randomizer) {
|
||||
ops.randomizer = new Random(ops.seed || 0);
|
||||
}
|
||||
return ops.randomizer.next();
|
||||
}
|
||||
|
||||
function _offset(
|
||||
min: number,
|
||||
max: number,
|
||||
ops: ResolvedOptions,
|
||||
roughnessGain = 1
|
||||
): number {
|
||||
return ops.roughness * roughnessGain * (random(ops) * (max - min) + min);
|
||||
}
|
||||
|
||||
function _offsetOpt(
|
||||
x: number,
|
||||
ops: ResolvedOptions,
|
||||
roughnessGain = 1
|
||||
): number {
|
||||
return _offset(-x, x, ops, roughnessGain);
|
||||
}
|
||||
|
||||
function _doubleLine(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
o: ResolvedOptions,
|
||||
filling = false
|
||||
): Op[] {
|
||||
const singleStroke = filling
|
||||
? o.disableMultiStrokeFill
|
||||
: o.disableMultiStroke;
|
||||
const o1 = _line(x1, y1, x2, y2, o, true, false);
|
||||
if (singleStroke) {
|
||||
return o1;
|
||||
}
|
||||
const o2 = _line(x1, y1, x2, y2, o, true, true);
|
||||
return o1.concat(o2);
|
||||
}
|
||||
|
||||
function _line(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
o: ResolvedOptions,
|
||||
move: boolean,
|
||||
overlay: boolean
|
||||
): Op[] {
|
||||
const lengthSq = Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2);
|
||||
const length = Math.sqrt(lengthSq);
|
||||
let roughnessGain = 1;
|
||||
if (length < 200) {
|
||||
roughnessGain = 1;
|
||||
} else if (length > 500) {
|
||||
roughnessGain = 0.4;
|
||||
} else {
|
||||
roughnessGain = -0.0016668 * length + 1.233334;
|
||||
}
|
||||
|
||||
let offset = o.maxRandomnessOffset || 0;
|
||||
if (offset * offset * 100 > lengthSq) {
|
||||
offset = length / 10;
|
||||
}
|
||||
const halfOffset = offset / 2;
|
||||
const divergePoint = 0.2 + random(o) * 0.2;
|
||||
let midDispX = (o.bowing * o.maxRandomnessOffset * (y2 - y1)) / 200;
|
||||
let midDispY = (o.bowing * o.maxRandomnessOffset * (x1 - x2)) / 200;
|
||||
midDispX = _offsetOpt(midDispX, o, roughnessGain);
|
||||
midDispY = _offsetOpt(midDispY, o, roughnessGain);
|
||||
const ops: Op[] = [];
|
||||
const randomHalf = () => _offsetOpt(halfOffset, o, roughnessGain);
|
||||
const randomFull = () => _offsetOpt(offset, o, roughnessGain);
|
||||
const preserveVertices = o.preserveVertices;
|
||||
if (move) {
|
||||
if (overlay) {
|
||||
ops.push({
|
||||
op: 'move',
|
||||
data: [
|
||||
x1 + (preserveVertices ? 0 : randomHalf()),
|
||||
y1 + (preserveVertices ? 0 : randomHalf()),
|
||||
],
|
||||
});
|
||||
} else {
|
||||
ops.push({
|
||||
op: 'move',
|
||||
data: [
|
||||
x1 + (preserveVertices ? 0 : _offsetOpt(offset, o, roughnessGain)),
|
||||
y1 + (preserveVertices ? 0 : _offsetOpt(offset, o, roughnessGain)),
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (overlay) {
|
||||
ops.push({
|
||||
op: 'bcurveTo',
|
||||
data: [
|
||||
midDispX + x1 + (x2 - x1) * divergePoint + randomHalf(),
|
||||
midDispY + y1 + (y2 - y1) * divergePoint + randomHalf(),
|
||||
midDispX + x1 + 2 * (x2 - x1) * divergePoint + randomHalf(),
|
||||
midDispY + y1 + 2 * (y2 - y1) * divergePoint + randomHalf(),
|
||||
x2 + (preserveVertices ? 0 : randomHalf()),
|
||||
y2 + (preserveVertices ? 0 : randomHalf()),
|
||||
],
|
||||
});
|
||||
} else {
|
||||
ops.push({
|
||||
op: 'bcurveTo',
|
||||
data: [
|
||||
midDispX + x1 + (x2 - x1) * divergePoint + randomFull(),
|
||||
midDispY + y1 + (y2 - y1) * divergePoint + randomFull(),
|
||||
midDispX + x1 + 2 * (x2 - x1) * divergePoint + randomFull(),
|
||||
midDispY + y1 + 2 * (y2 - y1) * divergePoint + randomFull(),
|
||||
x2 + (preserveVertices ? 0 : randomFull()),
|
||||
y2 + (preserveVertices ? 0 : randomFull()),
|
||||
],
|
||||
});
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
function _curveWithOffset(
|
||||
points: Point[],
|
||||
offset: number,
|
||||
o: ResolvedOptions
|
||||
): Op[] {
|
||||
const ps: Point[] = [];
|
||||
ps.push([
|
||||
points[0][0] + _offsetOpt(offset, o),
|
||||
points[0][1] + _offsetOpt(offset, o),
|
||||
]);
|
||||
ps.push([
|
||||
points[0][0] + _offsetOpt(offset, o),
|
||||
points[0][1] + _offsetOpt(offset, o),
|
||||
]);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
ps.push([
|
||||
points[i][0] + _offsetOpt(offset, o),
|
||||
points[i][1] + _offsetOpt(offset, o),
|
||||
]);
|
||||
if (i === points.length - 1) {
|
||||
ps.push([
|
||||
points[i][0] + _offsetOpt(offset, o),
|
||||
points[i][1] + _offsetOpt(offset, o),
|
||||
]);
|
||||
}
|
||||
}
|
||||
return _curve(ps, null, o);
|
||||
}
|
||||
|
||||
function _curve(
|
||||
points: Point[],
|
||||
closePoint: Point | null,
|
||||
o: ResolvedOptions
|
||||
): Op[] {
|
||||
const len = points.length;
|
||||
const ops: Op[] = [];
|
||||
if (len > 3) {
|
||||
const b = [];
|
||||
const s = 1 - o.curveTightness;
|
||||
ops.push({ op: 'move', data: [points[1][0], points[1][1]] });
|
||||
for (let i = 1; i + 2 < len; i++) {
|
||||
const cachedVertArray = points[i];
|
||||
b[0] = [cachedVertArray[0], cachedVertArray[1]];
|
||||
b[1] = [
|
||||
cachedVertArray[0] + (s * points[i + 1][0] - s * points[i - 1][0]) / 6,
|
||||
cachedVertArray[1] + (s * points[i + 1][1] - s * points[i - 1][1]) / 6,
|
||||
];
|
||||
b[2] = [
|
||||
points[i + 1][0] + (s * points[i][0] - s * points[i + 2][0]) / 6,
|
||||
points[i + 1][1] + (s * points[i][1] - s * points[i + 2][1]) / 6,
|
||||
];
|
||||
b[3] = [points[i + 1][0], points[i + 1][1]];
|
||||
ops.push({
|
||||
op: 'bcurveTo',
|
||||
data: [b[1][0], b[1][1], b[2][0], b[2][1], b[3][0], b[3][1]],
|
||||
});
|
||||
}
|
||||
if (closePoint && closePoint.length === 2) {
|
||||
const ro = o.maxRandomnessOffset;
|
||||
ops.push({
|
||||
op: 'lineTo',
|
||||
data: [
|
||||
closePoint[0] + _offsetOpt(ro, o),
|
||||
closePoint[1] + _offsetOpt(ro, o),
|
||||
],
|
||||
});
|
||||
}
|
||||
} else if (len === 3) {
|
||||
ops.push({ op: 'move', data: [points[1][0], points[1][1]] });
|
||||
ops.push({
|
||||
op: 'bcurveTo',
|
||||
data: [
|
||||
points[1][0],
|
||||
points[1][1],
|
||||
points[2][0],
|
||||
points[2][1],
|
||||
points[2][0],
|
||||
points[2][1],
|
||||
],
|
||||
});
|
||||
} else if (len === 2) {
|
||||
ops.push(
|
||||
..._doubleLine(points[0][0], points[0][1], points[1][0], points[1][1], o)
|
||||
);
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
function _computeEllipsePoints(
|
||||
increment: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
rx: number,
|
||||
ry: number,
|
||||
offset: number,
|
||||
overlap: number,
|
||||
o: ResolvedOptions
|
||||
): Point[][] {
|
||||
const coreOnly = o.roughness === 0;
|
||||
const corePoints: Point[] = [];
|
||||
const allPoints: Point[] = [];
|
||||
|
||||
if (coreOnly) {
|
||||
increment = increment / 4;
|
||||
allPoints.push([
|
||||
cx + rx * Math.cos(-increment),
|
||||
cy + ry * Math.sin(-increment),
|
||||
]);
|
||||
for (let angle = 0; angle <= Math.PI * 2; angle = angle + increment) {
|
||||
const p: Point = [cx + rx * Math.cos(angle), cy + ry * Math.sin(angle)];
|
||||
corePoints.push(p);
|
||||
allPoints.push(p);
|
||||
}
|
||||
allPoints.push([cx + rx * Math.cos(0), cy + ry * Math.sin(0)]);
|
||||
allPoints.push([
|
||||
cx + rx * Math.cos(increment),
|
||||
cy + ry * Math.sin(increment),
|
||||
]);
|
||||
} else {
|
||||
const radOffset = _offsetOpt(0.5, o) - Math.PI / 2;
|
||||
allPoints.push([
|
||||
_offsetOpt(offset, o) + cx + 0.9 * rx * Math.cos(radOffset - increment),
|
||||
_offsetOpt(offset, o) + cy + 0.9 * ry * Math.sin(radOffset - increment),
|
||||
]);
|
||||
const endAngle = Math.PI * 2 + radOffset - 0.01;
|
||||
for (let angle = radOffset; angle < endAngle; angle = angle + increment) {
|
||||
const p: Point = [
|
||||
_offsetOpt(offset, o) + cx + rx * Math.cos(angle),
|
||||
_offsetOpt(offset, o) + cy + ry * Math.sin(angle),
|
||||
];
|
||||
corePoints.push(p);
|
||||
allPoints.push(p);
|
||||
}
|
||||
allPoints.push([
|
||||
_offsetOpt(offset, o) +
|
||||
cx +
|
||||
rx * Math.cos(radOffset + Math.PI * 2 + overlap * 0.5),
|
||||
_offsetOpt(offset, o) +
|
||||
cy +
|
||||
ry * Math.sin(radOffset + Math.PI * 2 + overlap * 0.5),
|
||||
]);
|
||||
allPoints.push([
|
||||
_offsetOpt(offset, o) + cx + 0.98 * rx * Math.cos(radOffset + overlap),
|
||||
_offsetOpt(offset, o) + cy + 0.98 * ry * Math.sin(radOffset + overlap),
|
||||
]);
|
||||
allPoints.push([
|
||||
_offsetOpt(offset, o) +
|
||||
cx +
|
||||
0.9 * rx * Math.cos(radOffset + overlap * 0.5),
|
||||
_offsetOpt(offset, o) +
|
||||
cy +
|
||||
0.9 * ry * Math.sin(radOffset + overlap * 0.5),
|
||||
]);
|
||||
}
|
||||
|
||||
return [allPoints, corePoints];
|
||||
}
|
||||
|
||||
function _arc(
|
||||
increment: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
rx: number,
|
||||
ry: number,
|
||||
strt: number,
|
||||
stp: number,
|
||||
offset: number,
|
||||
o: ResolvedOptions
|
||||
) {
|
||||
const radOffset = strt + _offsetOpt(0.1, o);
|
||||
const points: Point[] = [];
|
||||
points.push([
|
||||
_offsetOpt(offset, o) + cx + 0.9 * rx * Math.cos(radOffset - increment),
|
||||
_offsetOpt(offset, o) + cy + 0.9 * ry * Math.sin(radOffset - increment),
|
||||
]);
|
||||
for (let angle = radOffset; angle <= stp; angle = angle + increment) {
|
||||
points.push([
|
||||
_offsetOpt(offset, o) + cx + rx * Math.cos(angle),
|
||||
_offsetOpt(offset, o) + cy + ry * Math.sin(angle),
|
||||
]);
|
||||
}
|
||||
points.push([cx + rx * Math.cos(stp), cy + ry * Math.sin(stp)]);
|
||||
points.push([cx + rx * Math.cos(stp), cy + ry * Math.sin(stp)]);
|
||||
return _curve(points, null, o);
|
||||
}
|
||||
|
||||
function _bezierTo(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
x: number,
|
||||
y: number,
|
||||
current: Point,
|
||||
o: ResolvedOptions
|
||||
): Op[] {
|
||||
const ops: Op[] = [];
|
||||
const ros = [o.maxRandomnessOffset || 1, (o.maxRandomnessOffset || 1) + 0.3];
|
||||
let f: Point = [0, 0];
|
||||
const iterations = o.disableMultiStroke ? 1 : 2;
|
||||
const preserveVertices = o.preserveVertices;
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
if (i === 0) {
|
||||
ops.push({ op: 'move', data: [current[0], current[1]] });
|
||||
} else {
|
||||
ops.push({
|
||||
op: 'move',
|
||||
data: [
|
||||
current[0] + (preserveVertices ? 0 : _offsetOpt(ros[0], o)),
|
||||
current[1] + (preserveVertices ? 0 : _offsetOpt(ros[0], o)),
|
||||
],
|
||||
});
|
||||
}
|
||||
f = preserveVertices
|
||||
? [x, y]
|
||||
: [x + _offsetOpt(ros[i], o), y + _offsetOpt(ros[i], o)];
|
||||
ops.push({
|
||||
op: 'bcurveTo',
|
||||
data: [
|
||||
x1 + _offsetOpt(ros[i], o),
|
||||
y1 + _offsetOpt(ros[i], o),
|
||||
x2 + _offsetOpt(ros[i], o),
|
||||
y2 + _offsetOpt(ros[i], o),
|
||||
f[0],
|
||||
f[1],
|
||||
],
|
||||
});
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { RoughCanvas } from './canvas.js';
|
||||
import type { Config } from './core.js';
|
||||
import { RoughGenerator } from './generator.js';
|
||||
import { RoughSVG } from './svg.js';
|
||||
|
||||
export default {
|
||||
canvas(canvas: HTMLCanvasElement, config?: Config): RoughCanvas {
|
||||
return new RoughCanvas(canvas, config);
|
||||
},
|
||||
|
||||
svg(svg: SVGSVGElement, config?: Config): RoughSVG {
|
||||
return new RoughSVG(svg, config);
|
||||
},
|
||||
|
||||
generator(config?: Config): RoughGenerator {
|
||||
return new RoughGenerator(config);
|
||||
},
|
||||
|
||||
newSeed(): number {
|
||||
return RoughGenerator.newSeed();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
import type {
|
||||
Config,
|
||||
Drawable,
|
||||
OpSet,
|
||||
Options,
|
||||
ResolvedOptions,
|
||||
} from './core.js';
|
||||
import { SVGNS } from './core.js';
|
||||
import { RoughGenerator } from './generator.js';
|
||||
import type { Point } from './geometry.js';
|
||||
|
||||
export class RoughSVG {
|
||||
private readonly gen: RoughGenerator;
|
||||
|
||||
private readonly svg: SVGSVGElement;
|
||||
|
||||
get generator(): RoughGenerator {
|
||||
return this.gen;
|
||||
}
|
||||
|
||||
constructor(svg: SVGSVGElement, config?: Config) {
|
||||
this.svg = svg;
|
||||
this.gen = new RoughGenerator(config);
|
||||
}
|
||||
|
||||
private fillSketch(
|
||||
doc: Document,
|
||||
drawing: OpSet,
|
||||
o: ResolvedOptions
|
||||
): SVGPathElement {
|
||||
let fweight = o.fillWeight;
|
||||
if (fweight < 0) {
|
||||
fweight = o.strokeWidth / 2;
|
||||
}
|
||||
const path = doc.createElementNS(SVGNS, 'path');
|
||||
path.setAttribute('d', this.opsToPath(drawing, o.fixedDecimalPlaceDigits));
|
||||
path.setAttribute('stroke', o.fill || '');
|
||||
path.setAttribute('stroke-width', fweight + '');
|
||||
path.setAttribute('fill', 'none');
|
||||
if (o.fillLineDash) {
|
||||
path.setAttribute('stroke-dasharray', o.fillLineDash.join(' ').trim());
|
||||
}
|
||||
if (o.fillLineDashOffset) {
|
||||
path.setAttribute('stroke-dashoffset', `${o.fillLineDashOffset}`);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
arc(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
start: number,
|
||||
stop: number,
|
||||
closed = false,
|
||||
options?: Options
|
||||
): SVGGElement {
|
||||
const d = this.gen.arc(x, y, width, height, start, stop, closed, options);
|
||||
return this.draw(d);
|
||||
}
|
||||
|
||||
circle(
|
||||
x: number,
|
||||
y: number,
|
||||
diameter: number,
|
||||
options?: Options
|
||||
): SVGGElement {
|
||||
const d = this.gen.circle(x, y, diameter, options);
|
||||
return this.draw(d);
|
||||
}
|
||||
|
||||
curve(points: Point[], options?: Options): SVGGElement {
|
||||
const d = this.gen.curve(points, options);
|
||||
return this.draw(d);
|
||||
}
|
||||
|
||||
draw(drawable: Drawable): SVGGElement {
|
||||
const sets = drawable.sets || [];
|
||||
const o = drawable.options || this.getDefaultOptions();
|
||||
const doc = this.svg.ownerDocument || window.document;
|
||||
const g = doc.createElementNS(SVGNS, 'g');
|
||||
const precision = drawable.options.fixedDecimalPlaceDigits;
|
||||
for (const drawing of sets) {
|
||||
let path = null;
|
||||
switch (drawing.type) {
|
||||
case 'path': {
|
||||
path = doc.createElementNS(SVGNS, 'path');
|
||||
path.setAttribute('d', this.opsToPath(drawing, precision));
|
||||
path.setAttribute('stroke', o.stroke);
|
||||
path.setAttribute('stroke-width', o.strokeWidth + '');
|
||||
path.setAttribute('fill', 'none');
|
||||
if (o.strokeLineDash) {
|
||||
path.setAttribute(
|
||||
'stroke-dasharray',
|
||||
o.strokeLineDash.join(' ').trim()
|
||||
);
|
||||
}
|
||||
if (o.strokeLineDashOffset) {
|
||||
path.setAttribute('stroke-dashoffset', `${o.strokeLineDashOffset}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'fillPath': {
|
||||
path = doc.createElementNS(SVGNS, 'path');
|
||||
path.setAttribute('d', this.opsToPath(drawing, precision));
|
||||
path.setAttribute('stroke', 'none');
|
||||
path.setAttribute('stroke-width', '0');
|
||||
path.setAttribute('fill', o.fill || '');
|
||||
if (drawable.shape === 'curve' || drawable.shape === 'polygon') {
|
||||
path.setAttribute('fill-rule', 'evenodd');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'fillSketch': {
|
||||
path = this.fillSketch(doc, drawing, o);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (path) {
|
||||
g.append(path);
|
||||
}
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
ellipse(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
options?: Options
|
||||
): SVGGElement {
|
||||
const d = this.gen.ellipse(x, y, width, height, options);
|
||||
return this.draw(d);
|
||||
}
|
||||
|
||||
getDefaultOptions(): ResolvedOptions {
|
||||
return this.gen.defaultOptions;
|
||||
}
|
||||
|
||||
line(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
options?: Options
|
||||
): SVGGElement {
|
||||
const d = this.gen.line(x1, y1, x2, y2, options);
|
||||
return this.draw(d);
|
||||
}
|
||||
|
||||
linearPath(points: Point[], options?: Options): SVGGElement {
|
||||
const d = this.gen.linearPath(points, options);
|
||||
return this.draw(d);
|
||||
}
|
||||
|
||||
opsToPath(drawing: OpSet, fixedDecimalPlaceDigits?: number): string {
|
||||
return this.gen.opsToPath(drawing, fixedDecimalPlaceDigits);
|
||||
}
|
||||
|
||||
path(d: string, options?: Options): SVGGElement {
|
||||
const drawing = this.gen.path(d, options);
|
||||
return this.draw(drawing);
|
||||
}
|
||||
|
||||
polygon(points: Point[], options?: Options): SVGGElement {
|
||||
const d = this.gen.polygon(points, options);
|
||||
return this.draw(d);
|
||||
}
|
||||
|
||||
rectangle(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
options?: Options
|
||||
): SVGGElement {
|
||||
const d = this.gen.rectangle(x, y, width, height, options);
|
||||
return this.draw(d);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
export function loadingSort<T extends { id: string; deps: string[] }>(
|
||||
elements: T[]
|
||||
) {
|
||||
const graph = new Map<string, string[]>();
|
||||
const outDegree = new Map<string, number>();
|
||||
const sortedOrder = [];
|
||||
const map = new Map<string, T>();
|
||||
|
||||
elements.forEach(element => {
|
||||
outDegree.set(element.id, 0);
|
||||
map.set(element.id, element);
|
||||
});
|
||||
|
||||
elements.forEach(element => {
|
||||
element.deps.forEach(depId => {
|
||||
if (outDegree.has(depId)) {
|
||||
graph.has(depId)
|
||||
? graph.get(depId)!.push(element.id)
|
||||
: graph.set(depId, [element.id]);
|
||||
outDegree.set(element.id, outDegree.get(element.id)! + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const queue: string[] = [];
|
||||
|
||||
for (const [id, degree] of outDegree) {
|
||||
if (degree === 0) {
|
||||
queue.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const node = queue.shift()!;
|
||||
sortedOrder.push(node);
|
||||
|
||||
const deps = graph.get(node) || [];
|
||||
deps.forEach(depId => {
|
||||
if (outDegree.has(depId)) {
|
||||
outDegree.set(depId, outDegree.get(depId)! - 1);
|
||||
|
||||
if (outDegree.get(depId) === 0) {
|
||||
queue.push(depId);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return sortedOrder.map(id => map.get(id)!);
|
||||
}
|
||||
|
||||
export function sortIndex(
|
||||
a: { id: string; index: string },
|
||||
b: { id: string; index: string },
|
||||
groupIndexMap: Map<string, { id: string; index: string }>
|
||||
) {
|
||||
const aGroupIndex = groupIndexMap.get(a.id);
|
||||
const bGroupIndex = groupIndexMap.get(b.id);
|
||||
|
||||
if (aGroupIndex && bGroupIndex) {
|
||||
return aGroupIndex.id === bGroupIndex.id
|
||||
? a.index === b.index
|
||||
? 0
|
||||
: a.index > b.index
|
||||
? 1
|
||||
: -1
|
||||
: aGroupIndex.index > bGroupIndex.index
|
||||
? 1
|
||||
: -1;
|
||||
}
|
||||
|
||||
if (aGroupIndex) {
|
||||
return aGroupIndex.id === b.id
|
||||
? 1
|
||||
: aGroupIndex.index === b.index
|
||||
? 0
|
||||
: aGroupIndex.index > b.index
|
||||
? 1
|
||||
: -1;
|
||||
}
|
||||
|
||||
if (bGroupIndex) {
|
||||
return a.id === bGroupIndex.id
|
||||
? -1
|
||||
: a.index === bGroupIndex.index
|
||||
? 0
|
||||
: a.index > bGroupIndex.index
|
||||
? 1
|
||||
: -1;
|
||||
}
|
||||
|
||||
return a.index === b.index ? 0 : a.index > b.index ? 1 : -1;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
ConnectorElementModel,
|
||||
MindmapElementModel,
|
||||
NOTE_MIN_HEIGHT,
|
||||
NOTE_MIN_WIDTH,
|
||||
NoteBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { Bound, clamp } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
type GfxGroupCompatibleInterface,
|
||||
type GfxModel,
|
||||
isGfxGroupCompatibleModel,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import type { BlockModel, BlockProps } from '@blocksuite/store';
|
||||
|
||||
function updatChildElementsXYWH(
|
||||
container: GfxGroupCompatibleInterface,
|
||||
targetBound: Bound,
|
||||
updateElement: (id: string, props: Record<string, unknown>) => void,
|
||||
updateBlock: (
|
||||
model: BlockModel,
|
||||
callBackOrProps: (() => void) | Partial<BlockProps>
|
||||
) => void
|
||||
) {
|
||||
const containerBound = Bound.deserialize(container.xywh);
|
||||
const scaleX = targetBound.w / containerBound.w;
|
||||
const scaleY = targetBound.h / containerBound.h;
|
||||
container.childElements.forEach(child => {
|
||||
const childBound = Bound.deserialize(child.xywh);
|
||||
childBound.x = targetBound.x + scaleX * (childBound.x - containerBound.x);
|
||||
childBound.y = targetBound.y + scaleY * (childBound.y - containerBound.y);
|
||||
childBound.w = scaleX * childBound.w;
|
||||
childBound.h = scaleY * childBound.h;
|
||||
updateXYWH(child, childBound, updateElement, updateBlock);
|
||||
});
|
||||
}
|
||||
|
||||
export function updateXYWH(
|
||||
ele: GfxModel,
|
||||
bound: Bound,
|
||||
updateElement: (id: string, props: Record<string, unknown>) => void,
|
||||
updateBlock: (
|
||||
model: BlockModel,
|
||||
callBackOrProps: (() => void) | Partial<BlockProps>
|
||||
) => void
|
||||
) {
|
||||
if (ele instanceof ConnectorElementModel) {
|
||||
ele.moveTo(bound);
|
||||
} else if (ele instanceof NoteBlockModel) {
|
||||
const scale = ele.props.edgeless.scale ?? 1;
|
||||
bound.w = clamp(bound.w, NOTE_MIN_WIDTH * scale, Infinity);
|
||||
bound.h = clamp(bound.h, NOTE_MIN_HEIGHT * scale, Infinity);
|
||||
if (bound.h >= NOTE_MIN_HEIGHT * scale) {
|
||||
updateBlock.call(ele.doc, ele, () => {
|
||||
ele.props.edgeless.collapse = true;
|
||||
ele.props.edgeless.collapsedHeight = bound.h / scale;
|
||||
});
|
||||
}
|
||||
updateElement(ele.id, {
|
||||
xywh: bound.serialize(),
|
||||
});
|
||||
} else if (ele instanceof MindmapElementModel) {
|
||||
const rootId = ele.tree.id;
|
||||
const rootEle = ele.childElements.find(child => child.id === rootId);
|
||||
if (rootEle) {
|
||||
const rootBound = Bound.deserialize(rootEle.xywh);
|
||||
rootBound.x += bound.x - ele.x;
|
||||
rootBound.y += bound.y - ele.y;
|
||||
updateXYWH(rootEle, rootBound, updateElement, updateBlock);
|
||||
}
|
||||
ele.layout();
|
||||
} else if (isGfxGroupCompatibleModel(ele)) {
|
||||
updatChildElementsXYWH(ele, bound, updateElement, updateBlock);
|
||||
updateElement(ele.id, {
|
||||
xywh: bound.serialize(),
|
||||
});
|
||||
} else {
|
||||
updateElement(ele.id, {
|
||||
xywh: bound.serialize(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user