import type {
PossibleVector2,
SerializedVector2,
SignalValue,
SimpleSignal,
} from '@revideo/core';
import {
BBox,
Vector2,
transformVectorAsPoint,
unwrap,
useLogger,
} from '@revideo/core';
import type {CurveProfile, KnotInfo} from '../curves';
import {CubicBezierSegment, getBezierSplineProfile} from '../curves';
import type {PolynomialSegment} from '../curves/PolynomialSegment';
import {computed, initial, signal} from '../decorators';
import type {DesiredLength} from '../partials';
import {
arc,
bezierCurveTo,
drawLine,
drawPivot,
lineTo,
moveTo,
quadraticCurveTo,
} from '../utils';
import type {CurveProps} from './Curve';
import {Curve} from './Curve';
import {Knot} from './Knot';
import type {Node} from './Node';
const splineWithInsufficientKnots = `
The spline won't be visible unless you specify at least two knots:
\`\`\`tsx
\`\`\`
For more control over the knot handles, you can alternatively provide the knots
as children to the spline using the \`Knot\` component:
\`\`\`tsx
\`\`\`
`;
export interface SplineProps extends CurveProps {
/**
* {@inheritDoc Spline.smoothness}
*/
smoothness?: SignalValue;
/**
* {@inheritDoc Spline.points}
*/
points?: SignalValue>;
}
/**
* A node for drawing a smooth line through a number of points.
*
* @remarks
* This node uses Bézier curves for drawing each segment of the spline.
*
* @example
* Defining knots using the `points` property. This will automatically
* calculate the handle positions for each knot do draw a smooth curve. You
* can control the smoothness of the resulting curve via the
* {@link Spline.smoothness} property:
*
* ```tsx
*
* ```
*
* Defining knots with {@link Knot} nodes:
*
* ```tsx
*
*
*
*
*
*
*
* ```
*/
export class Spline extends Curve {
/**
* The smoothness of the spline when using auto-calculated handles.
*
* @remarks
* This property is only applied to knots that don't use explicit handles.
*
* @defaultValue 0.4
*/
@initial(0.4)
@signal()
public declare readonly smoothness: SimpleSignal;
/**
* The knots of the spline as an array of knots with auto-calculated handles.
*
* @remarks
* You can control the smoothness of the resulting curve
* via the {@link smoothness} property.
*/
@initial(null)
@signal()
public declare readonly points: SimpleSignal<
SignalValue[] | null,
this
>;
public constructor(props: SplineProps) {
super(props);
if (
(props.children === undefined ||
!Array.isArray(props.children) ||
props.children.length < 2) &&
(props.points === undefined || props.points.length < 2) &&
props.spawner === undefined
) {
useLogger().warn({
message:
'Insufficient number of knots specified for spline. A spline needs at least two knots.',
remarks: splineWithInsufficientKnots,
inspect: this.key,
});
}
}
@computed()
public override profile(): CurveProfile {
return getBezierSplineProfile(
this.knots(),
this.closed(),
this.smoothness(),
);
}
@computed()
public knots(): KnotInfo[] {
const points = this.points();
if (points) {
return points.map(signal => {
const point = new Vector2(unwrap(signal));
return {
position: point,
startHandle: point,
endHandle: point,
auto: {start: 1, end: 1},
};
});
}
return this.children()
.filter(this.isKnot)
.map(knot => knot.points());
}
@computed()
protected childrenBBox() {
const points = (this.profile().segments as PolynomialSegment[]).flatMap(
segment => segment.points,
);
return BBox.fromPoints(...points);
}
protected override lineWidthCoefficient(): number {
const join = this.lineJoin();
let coefficient = super.lineWidthCoefficient();
if (join !== 'miter') {
return coefficient;
}
const {minSin} = this.profile();
if (minSin > 0) {
coefficient = Math.max(coefficient, 0.5 / minSin);
}
return coefficient;
}
protected override desiredSize(): SerializedVector2 {
return this.getTightBBox().size;
}
protected override offsetComputedLayout(box: BBox): BBox {
box.position = box.position.sub(this.getTightBBox().center);
return box;
}
@computed()
private getTightBBox(): BBox {
const bounds = (this.profile().segments as PolynomialSegment[]).map(
segment => segment.getBBox(),
);
return BBox.fromBBoxes(...bounds);
}
public override drawOverlay(
context: CanvasRenderingContext2D,
matrix: DOMMatrix,
) {
const size = this.computedSize();
const box = this.childrenBBox().transformCorners(matrix);
const offsetVector = size.mul(this.offset()).scale(0.5);
const offset = transformVectorAsPoint(offsetVector, matrix);
const segments = this.profile().segments as PolynomialSegment[];
context.lineWidth = 1;
context.strokeStyle = 'white';
context.fillStyle = 'white';
const splinePath = new Path2D();
// Draw the actual spline first so that all control points get drawn on top of it.
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
const [from, startHandle, endHandle, to] =
segment.transformPoints(matrix);
moveTo(splinePath, from);
if (segment instanceof CubicBezierSegment) {
bezierCurveTo(splinePath, startHandle, endHandle, to as Vector2);
} else {
quadraticCurveTo(splinePath, startHandle, endHandle);
}
}
context.stroke(splinePath);
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
context.fillStyle = 'white';
const [from, startHandle, endHandle, to] =
segment.transformPoints(matrix);
const handlePath = new Path2D();
context.globalAlpha = 0.5;
// Line from p0 to p1
moveTo(handlePath, from);
lineTo(handlePath, startHandle);
if (segment instanceof CubicBezierSegment) {
// Line from p2 to p3
moveTo(handlePath, endHandle);
lineTo(handlePath, to as Vector2);
context.beginPath();
context.stroke(handlePath);
} else {
// Line from p1 to p2
lineTo(handlePath, endHandle);
context.beginPath();
context.stroke(handlePath);
}
context.globalAlpha = 1;
context.lineWidth = 2;
// Draw first point of segment
moveTo(context, from);
context.beginPath();
arc(context, from, 4);
context.closePath();
context.stroke();
context.fill();
// Draw final point of segment only if we're on the last segment.
// Otherwise, it will get drawn as the start point of the next segment.
if (i === segments.length - 1) {
if (to !== undefined) {
moveTo(context, to);
context.beginPath();
arc(context, to, 4);
context.closePath();
context.stroke();
context.fill();
}
}
// Draw the control points
context.fillStyle = 'black';
for (const point of [startHandle, endHandle]) {
if (point.magnitude > 0) {
moveTo(context, point);
context.beginPath();
arc(context, point, 4);
context.closePath();
context.fill();
context.stroke();
}
}
}
context.lineWidth = 1;
context.beginPath();
drawPivot(context, offset);
context.stroke();
context.beginPath();
drawLine(context, box);
context.closePath();
context.stroke();
}
private isKnot(node: Node): node is Knot {
return node instanceof Knot;
}
}