feat: add polyline (#84)

This commit is contained in:
Jacob
2022-11-28 11:57:51 +01:00
committed by GitHub
parent 9e114c8830
commit 4ceaf84291
16 changed files with 581 additions and 11 deletions

View File

@@ -287,6 +287,7 @@ export class Layout extends Node {
@cloneable(false)
@wrapper(Vector2)
@initial({x: null, y: null})
@compound({x: 'width', y: 'height'})
public declare readonly size: Vector2LengthProperty<this>;
@@ -401,12 +402,12 @@ export class Layout extends Node {
* inheritance).
*/
@computed()
protected layoutEnabled(): boolean {
public layoutEnabled(): boolean {
return this.layout() ?? this.parentTransform()?.layoutEnabled() ?? false;
}
@computed()
protected isLayoutRoot(): boolean {
public isLayoutRoot(): boolean {
return !this.layoutEnabled() || !this.parentTransform()?.layoutEnabled();
}
@@ -422,7 +423,9 @@ export class Layout extends Node {
}
protected getComputedLayout(): Rect {
return new Rect(this.element.getBoundingClientRect());
const rect = new Rect(this.element.getBoundingClientRect());
rect.position = rect.position.add(this.getCustomOffset());
return rect;
}
@computed()
@@ -448,7 +451,7 @@ export class Layout extends Node {
@computed()
protected computedSize(): Vector2 {
this.requestLayoutUpdate();
return new Vector2(this.getComputedLayout());
return this.getComputedLayout().size;
}
/**
@@ -509,8 +512,9 @@ export class Layout extends Node {
}
protected override getCacheRect(): Rect {
const size = this.computedSize();
return new Rect(size.scale(-0.5), size);
const rect = Rect.fromSizeCentered(this.computedSize());
rect.position = rect.position.sub(this.getCustomOffset());
return rect;
}
protected override draw(context: CanvasRenderingContext2D) {
@@ -536,6 +540,7 @@ export class Layout extends Node {
const size = this.computedSize();
const offset = size.mul(this.offset()).scale(0.5).transformAsPoint(matrix);
const rect = Rect.fromSizeCentered(size);
rect.position = rect.position.sub(this.getCustomOffset());
const layout = rect.transformCorners(matrix);
const padding = rect
.addSpacing(this.padding().scale(-1))
@@ -673,7 +678,7 @@ export class Layout extends Node {
public override hit(position: Vector2): Node | null {
const local = position.transformAsPoint(this.localToParent().inverse());
if (this.getCacheRect().includes(local)) {
if (this.cacheRect().includes(local)) {
return super.hit(position) ?? this;
}

View File

@@ -0,0 +1,276 @@
import {Shape, ShapeProps} from './Shape';
import {Node} from './Node';
import {computed, initial, property} from '../decorators';
import {arc, lineTo, moveTo, resolveCanvasStyle} from '../utils';
import {Signal, SignalValue} from '@motion-canvas/core/lib/utils';
import {Rect, Vector2} from '@motion-canvas/core/lib/types';
import {clamp} from '@motion-canvas/core/lib/tweening';
import {Length} from '../partials';
import {Layout} from './Layout';
import {
CurveDrawingInfo,
CurveProfile,
getPolylineProfile,
getPointAtDistance,
CurvePoint,
} from '../curves';
export interface LineProps extends ShapeProps {
children: Node[];
closed?: SignalValue<boolean>;
radius?: SignalValue<number>;
start?: SignalValue<number>;
startOffset?: SignalValue<number>;
startArrow?: SignalValue<boolean>;
end?: SignalValue<number>;
endOffset?: SignalValue<number>;
endArrow?: SignalValue<boolean>;
arrowSize?: SignalValue<number>;
}
export class Line extends Shape {
@initial(0)
@property()
public declare readonly radius: Signal<number, this>;
@initial(false)
@property()
public declare readonly closed: Signal<boolean, this>;
@initial(0)
@property()
public declare readonly start: Signal<number, this>;
@initial(0)
@property()
public declare readonly startOffset: Signal<number, this>;
@initial(false)
@property()
public declare readonly startArrow: Signal<boolean, this>;
@initial(1)
@property()
public declare readonly end: Signal<number, this>;
@initial(1)
@property()
public declare readonly endOffset: Signal<number, this>;
@initial(false)
@property()
public declare readonly endArrow: Signal<boolean, this>;
@initial(24)
@property()
public declare readonly arrowSize: Signal<number, this>;
public getCustomWidth(): Length {
return this.childrenRect().width;
}
public setCustomWidth() {
// do nothing
}
public getCustomHeight(): Length {
return this.childrenRect().height;
}
public setCustomHeight() {
// do nothing
}
public constructor(props: LineProps) {
super(props);
}
@computed()
protected childrenRect() {
return Rect.fromPoints(
...this.children()
.filter(child => !(child instanceof Layout) || child.isLayoutRoot())
.map(child => child.position()),
);
}
@computed()
public profile(): CurveProfile {
const points = this.children().map(child => child.position());
return getPolylineProfile(points, this.radius(), this.closed());
}
public percentageToDistance(value: number): number {
const {arcLength} = this.profile();
const startOffset = this.startOffset();
const endOffset = this.endOffset();
const realLength = arcLength - startOffset - endOffset;
return startOffset + realLength * value;
}
@computed()
protected curveDrawingInfo(): CurveDrawingInfo {
const path = new Path2D();
const profile = this.profile();
let start = this.percentageToDistance(this.start());
let end = this.percentageToDistance(this.end());
if (start > end) {
[start, end] = [end, start];
}
const distance = end - start;
const arrowSize = Math.min(distance / 2, this.arrowSize());
if (this.startArrow()) {
start += arrowSize / 2;
}
if (this.endArrow()) {
end -= arrowSize / 2;
}
let length = 0;
let startPoint = null;
let startTangent = null;
let endPoint = null;
let endTangent = null;
for (const segment of profile.segments) {
const previousLength = length;
length += segment.arcLength;
if (length < start) {
continue;
}
const relativeStart = (start - previousLength) / segment.arcLength;
const relativeEnd = (end - previousLength) / segment.arcLength;
const clampedStart = clamp(0, 1, relativeStart);
const clampedEnd = clamp(0, 1, relativeEnd);
const [
currentStartPoint,
currentStartTangent,
currentEndPoint,
currentEndTangent,
] = segment.draw(path, clampedStart, clampedEnd, startPoint === null);
if (startPoint === null) {
startPoint = currentStartPoint;
startTangent = currentStartTangent;
}
endPoint = currentEndPoint;
endTangent = currentEndTangent;
if (length > end) {
break;
}
}
return {
startPoint: startPoint ?? Vector2.zero,
startTangent: startTangent ?? Vector2.up,
endPoint: endPoint ?? Vector2.zero,
endTangent: endTangent ?? Vector2.up,
arrowSize,
path,
startOffset: start,
};
}
protected getPointAtDistance(value: number): CurvePoint {
return getPointAtDistance(this.profile(), value + this.startOffset());
}
public getPointAtPercentage(value: number): CurvePoint {
return getPointAtDistance(this.profile(), this.percentageToDistance(value));
}
protected override applyStyle(context: CanvasRenderingContext2D) {
super.applyStyle(context);
const {arcLength} = this.profile();
context.lineDashOffset -= arcLength / 2;
}
protected override getCustomOffset(): Vector2 {
if (this.layoutEnabled()) {
return this.childrenRect().center.flipped;
} else {
return Vector2.zero;
}
}
protected override getPath(): Path2D {
return this.curveDrawingInfo().path;
}
protected override getCacheRect(): Rect {
const arrowSize = this.arrowSize();
const lineWidth = this.lineWidth();
return super.getCacheRect().expand(Math.max(0, arrowSize - lineWidth / 2));
}
protected override drawShape(context: CanvasRenderingContext2D) {
super.drawShape(context);
const {startPoint, startTangent, endPoint, endTangent, arrowSize} =
this.curveDrawingInfo();
if (arrowSize < 0.001) {
return;
}
context.save();
context.beginPath();
if (this.endArrow()) {
this.drawArrow(context, endPoint, endTangent, arrowSize);
}
if (this.startArrow()) {
this.drawArrow(context, startPoint, startTangent, arrowSize);
}
context.fillStyle = resolveCanvasStyle(this.stroke(), context);
context.closePath();
context.fill();
context.restore();
}
private drawArrow(
context: CanvasRenderingContext2D | Path2D,
center: Vector2,
tangent: Vector2,
arrowSize: number,
) {
const normal = tangent.perpendicular;
const origin = center.add(normal.scale(-arrowSize / 2));
moveTo(context, origin);
lineTo(context, origin.add(normal.add(tangent).scale(arrowSize)));
lineTo(context, origin.add(normal.sub(tangent).scale(arrowSize)));
lineTo(context, origin);
context.closePath();
}
public override drawOverlay(
context: CanvasRenderingContext2D,
matrix: DOMMatrix,
) {
super.drawOverlay(context, matrix);
context.fillStyle = 'white';
context.strokeStyle = 'black';
context.lineWidth = 1;
const path = new Path2D();
const points = this.children().map(child =>
child.position().transformAsPoint(matrix),
);
moveTo(path, points[0]);
for (const point of points) {
lineTo(path, point);
context.beginPath();
arc(context, point, 4);
context.closePath();
context.fill();
context.stroke();
}
context.strokeStyle = 'white';
context.stroke(path);
}
}

View File

@@ -337,6 +337,8 @@ export class Node implements Promisable<Node> {
matrix.translateSelf(this.position.x(), this.position.y());
matrix.rotateSelf(0, 0, this.rotation());
matrix.scaleSelf(this.scale.x(), this.scale.y());
const offset = this.getCustomOffset();
matrix.translateSelf(offset.x, offset.y);
return matrix;
}
@@ -400,6 +402,13 @@ export class Node implements Promisable<Node> {
return new DOMMatrix();
}
/**
* Get the custom offset for this node's children.
*/
protected getCustomOffset(): Vector2 {
return Vector2.zero;
}
@computed()
public view(): View2D | null {
return this.parent()?.view() ?? null;
@@ -802,12 +811,19 @@ export class Node implements Promisable<Node> {
*/
public drawOverlay(context: CanvasRenderingContext2D, matrix: DOMMatrix) {
const rect = this.cacheRect().transformCorners(matrix);
const cache = this.getCacheRect().transformCorners(matrix);
context.strokeStyle = 'white';
context.lineWidth = 1;
context.beginPath();
drawLine(context, rect);
context.closePath();
context.stroke();
context.strokeStyle = 'blue';
context.beginPath();
drawLine(context, cache);
context.closePath();
context.stroke();
}
protected transformContext(context: CanvasRenderingContext2D) {

View File

@@ -1,6 +1,7 @@
export * from './Circle';
export * from './Image';
export * from './Layout';
export * from './Line';
export * from './Node';
export * from './Rect';
export * from './Text';

View File

@@ -0,0 +1,66 @@
import {Vector2} from '@motion-canvas/core/lib/types';
import {Segment} from './Segment';
export class CircleSegment extends Segment {
private readonly length: number;
private readonly angle: number;
public constructor(
private center: Vector2,
private radius: number,
private from: Vector2,
private to: Vector2,
private counter: boolean,
) {
super();
this.angle = Math.acos(from.dot(to));
this.length = Math.abs(this.angle * radius);
}
public get arcLength(): number {
return this.length;
}
public draw(
context: CanvasRenderingContext2D | Path2D,
from: number,
to: number,
): [Vector2, Vector2, Vector2, Vector2] {
const counterFactor = this.counter ? -1 : 1;
const startAngle = this.from.radians + from * this.angle * counterFactor;
const endAngle = this.to.radians - (1 - to) * this.angle * counterFactor;
if (Math.abs(from - to) > 0.0001) {
context.arc(
this.center.x,
this.center.y,
this.radius,
startAngle,
endAngle,
this.counter,
);
}
const startTangent = Vector2.fromRadians(startAngle);
const endTangent = Vector2.fromRadians(endAngle);
return [
this.center.add(startTangent.scale(this.radius)),
this.counter ? startTangent : startTangent.flipped,
this.center.add(endTangent.scale(this.radius)),
this.counter ? endTangent.flipped : endTangent,
];
}
public getPoint(distance: number): [Vector2, Vector2] {
const counterFactor = this.counter ? -1 : 1;
const angle = this.from.radians + distance * this.angle * counterFactor;
const tangent = Vector2.fromRadians(angle);
return [
this.center.add(tangent.scale(this.radius)),
this.counter ? tangent : tangent.flipped,
];
}
}

View File

@@ -0,0 +1,11 @@
import {Vector2} from '@motion-canvas/core/lib/types';
export interface CurveDrawingInfo {
path: Path2D;
arrowSize: number;
endPoint: Vector2;
endTangent: Vector2;
startPoint: Vector2;
startTangent: Vector2;
startOffset: number;
}

View File

@@ -0,0 +1,6 @@
import type {Vector2} from '@motion-canvas/core/lib/types';
export interface CurvePoint {
position: Vector2;
tangent: Vector2;
}

View File

@@ -0,0 +1,6 @@
import {Segment} from './Segment';
export interface CurveProfile {
arcLength: number;
segments: Segment[];
}

View File

@@ -0,0 +1,40 @@
import {Vector2} from '@motion-canvas/core/lib/types';
import {lineTo, moveTo} from '../utils';
import {Segment} from './Segment';
export class LineSegment extends Segment {
private readonly length: number;
private readonly vector: Vector2;
private readonly tangent: Vector2;
public constructor(private from: Vector2, private to: Vector2) {
super();
this.vector = to.sub(from);
this.length = this.vector.magnitude;
this.tangent = this.vector.perpendicular.normalized.safe;
}
public get arcLength(): number {
return this.length;
}
public draw(
context: CanvasRenderingContext2D | Path2D,
start = 0,
end = 1,
move = false,
): [Vector2, Vector2, Vector2, Vector2] {
const from = this.from.add(this.vector.scale(start));
const to = this.from.add(this.vector.scale(end));
if (move) {
moveTo(context, from);
}
lineTo(context, to);
return [from, this.tangent.flipped, to, this.tangent];
}
public getPoint(distance: number): [Vector2, Vector2] {
const point = this.from.add(this.vector.scale(distance));
return [point, this.tangent.flipped];
}
}

View File

@@ -0,0 +1,14 @@
import {Vector2} from '@motion-canvas/core/lib/types';
export abstract class Segment {
public abstract draw(
context: CanvasRenderingContext2D | Path2D,
start: number,
end: number,
move: boolean,
): [Vector2, Vector2, Vector2, Vector2];
public abstract getPoint(distance: number): [Vector2, Vector2];
public abstract get arcLength(): number;
}

View File

@@ -0,0 +1,23 @@
import {clamp} from '@motion-canvas/core/lib/tweening';
import {Vector2} from '@motion-canvas/core/lib/types';
import {CurveProfile} from './CurveProfile';
import {CurvePoint} from './CurvePoint';
export function getPointAtDistance(
profile: CurveProfile,
distance: number,
): CurvePoint {
const clamped = clamp(0, profile.arcLength, distance);
let length = 0;
for (const segment of profile.segments) {
const previousLength = length;
length += segment.arcLength;
if (length >= clamped) {
const relative = (clamped - previousLength) / segment.arcLength;
const [position, tangent] = segment.getPoint(clamp(0, 1, relative));
return {position, tangent};
}
}
return {position: Vector2.zero, tangent: Vector2.up};
}

View File

@@ -0,0 +1,75 @@
import {Vector2} from '@motion-canvas/core/lib/types';
import {CurveProfile} from './CurveProfile';
import {LineSegment} from './LineSegment';
import {CircleSegment} from './CircleSegment';
export function getPolylineProfile(
points: Vector2[],
radius: number,
closed: boolean,
): CurveProfile {
const profile: CurveProfile = {
arcLength: 0,
segments: [],
};
if (closed) {
const middle = points[0].add(points[points.length - 1]).scale(0.5);
points.unshift(middle);
points.push(middle);
}
let last = points[0];
for (let i = 2; i < points.length; i++) {
const start = points[i - 2];
const center = points[i - 1];
const end = points[i];
const centerToStart = start.sub(center);
const centerToEnd = end.sub(center);
const startVector = centerToStart.normalized;
const endVector = centerToEnd.normalized;
const angleBetween = Math.acos(startVector.dot(endVector));
const angleTan = Math.tan(angleBetween / 2);
const safeRadius = Math.min(
radius,
angleTan * centerToStart.magnitude * (i === 2 ? 1 : 0.5),
angleTan * centerToEnd.magnitude * (i === points.length - 1 ? 1 : 0.5),
);
const circleOffsetDistance = safeRadius / Math.sin(angleBetween / 2);
const pointOffsetDistance = safeRadius / angleTan;
const circleDistance = startVector
.add(endVector)
.scale(1 / 2)
.normalized.scale(circleOffsetDistance)
.add(center);
const counter = startVector.perpendicular.dot(endVector) < 0;
const line = new LineSegment(
last,
center.add(startVector.scale(pointOffsetDistance)),
);
const circle = new CircleSegment(
circleDistance,
safeRadius,
startVector.perpendicular.scale(counter ? 1 : -1),
endVector.perpendicular.scale(counter ? -1 : 1),
counter,
);
profile.segments.push(line);
profile.segments.push(circle);
profile.arcLength += line.arcLength;
profile.arcLength += circle.arcLength;
last = center.add(endVector.scale(pointOffsetDistance));
}
const line = new LineSegment(last, points[points.length - 1]);
profile.segments.push(line);
profile.arcLength += line.arcLength;
return profile;
}

View File

@@ -0,0 +1,8 @@
export * from './CircleSegment';
export * from './CurveDrawingInfo';
export * from './CurvePoint';
export * from './CurveProfile';
export * from './getPointAtDistance';
export * from './getPolylineProfile';
export * from './LineSegment';
export * from './Segment';

View File

@@ -246,9 +246,9 @@ export function property<T>(): PropertyDecorator {
context.defaults[key] ?? meta.default,
meta.interpolationFunction ?? deepLerp,
meta.parser,
target[`get${capitalize(<string>key)}`],
target[`set${capitalize(<string>key)}`],
target[`tween${capitalize(<string>key)}`],
instance[`get${capitalize(<string>key)}`],
instance[`set${capitalize(<string>key)}`],
instance[`tween${capitalize(<string>key)}`],
);
});
};

View File

@@ -134,3 +134,21 @@ export function drawLine(
lineTo(context, point);
}
}
export function arc(
context: CanvasRenderingContext2D | Path2D,
center: Vector2,
radius: number,
startAngle = 0,
endAngle = Math.PI * 2,
counterclockwise = false,
) {
context.arc(
center.x,
center.y,
radius,
startAngle,
endAngle,
counterclockwise,
);
}

View File

@@ -123,6 +123,11 @@ export class Rect implements Type {
return new Vector2(this.x, this.y);
}
public set position(value: Vector2) {
this.x = value.x;
this.y = value.y;
}
public get size() {
return new Vector2(this.width, this.height);
}
@@ -153,7 +158,7 @@ export class Rect implements Type {
}
public set top(value: number) {
this.width += this.y - value;
this.height += this.y - value;
this.y = value;
}