folk-canvas/lib/folk-rope.ts

334 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// This is a rewrite of https://github.com/guerrillacontra/html5-es6-physics-rope
import { Vector } from './common/Vector.ts';
import type { Point } from './common/types.ts';
import { DOMRectTransform } from './common/DOMRectTransform.ts';
import { FolkBaseConnection } from './folk-base-connection.ts';
import { css, PropertyValues } from '@lit/reactive-element';
import { AnimationFrameController, AnimationFrameControllerHost } from './common/animation-frame-controller.ts';
import { property } from '@lit/reactive-element/decorators.js';
// Each rope part is one of these uses a high precision variant of StörmerVerlet integration to keep the simulation consistent otherwise it would "explode"!
interface RopePoint {
pos: Point;
distanceToNextPoint: number;
isFixed: boolean;
oldPos: Point;
velocity: Point;
mass: number;
damping: number;
prev: RopePoint | null;
next: RopePoint | null;
}
declare global {
interface HTMLElementTagNameMap {
'folk-rope': FolkRope;
}
}
export class FolkRope extends FolkBaseConnection implements AnimationFrameControllerHost {
static override tagName = 'folk-rope';
static #resolution = 5;
static styles = [
FolkBaseConnection.styles,
css`
svg {
height: 100%;
pointer-events: none;
width: 100%;
}
path {
fill: none;
pointer-events: none;
stroke: var(--folk-rope-color, black);
stroke-width: var(--folk-rope-width, 3);
stroke-linecap: var(--folk-rope-linecap, round);
}
`,
];
#rAF = new AnimationFrameController(this);
#svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
#path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
#path2 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
#points: RopePoint[] = [];
get points() {
return this.#points;
}
@property({ type: Object }) gravity = { x: 0, y: 3000 };
override createRenderRoot() {
const root = super.createRenderRoot();
this.#svg.append(this.#path, this.#path2);
root.appendChild(this.#svg);
return root;
}
tick() {
for (const point of this.#points) {
this.#integratePoint(point, this.gravity);
}
// 3 constraint iterations is enough for fixed timestep
for (let iteration = 0; iteration < 3; iteration++) {
for (const point of this.#points) {
this.#constrainPoint(point);
}
}
}
override update(changedProperties: PropertyValues<this>) {
super.update(changedProperties);
const { sourceRect, targetRect } = this;
if (sourceRect === null || targetRect === null) {
this.#rAF.stop();
this.#points = [];
this.#path.removeAttribute('d');
this.#path2.removeAttribute('d');
return;
}
let source: Point;
let target: Point;
if (sourceRect instanceof DOMRectTransform) {
source = sourceRect.center;
} else {
source = {
x: sourceRect.x + sourceRect.width / 2,
y: sourceRect.y + sourceRect.height / 2,
};
}
if (targetRect instanceof DOMRectTransform) {
target = targetRect.center;
} else {
target = {
x: targetRect.x + targetRect.width / 2,
y: targetRect.y + targetRect.height / 2,
};
}
if (this.#points.length === 0) {
this.#points = this.#generatePoints(source, target);
this.#rAF.start();
}
const startingPoint = this.#points.at(0);
const endingPoint = this.#points.at(-1);
if (startingPoint === undefined || endingPoint === undefined) return;
startingPoint.pos = source;
endingPoint.pos = target;
}
/** add/remove points based on distance between source and target rects */
stretch() {
if (this.sourceRect === null || this.targetRect === null || this.#points.length < 2) return;
// Calculate desired length based on source and target positions
const distance = Vector.distance(this.sourceRect, this.targetRect);
// Apply a nonlinear scaling to gradually increase point spacing with distance
const scaleFactor = Math.log10(distance + 10) / 2; // adjust the '+10' and '/2' to tune the curve
const effectiveResolution = FolkRope.#resolution * scaleFactor;
const desiredPoints = Math.floor(distance / effectiveResolution);
while (this.#points.length < desiredPoints) {
const lastPoint = this.#points.at(-1)!;
lastPoint.isFixed = false;
const newPoint = {
pos: { ...lastPoint.pos },
oldPos: { ...lastPoint.pos },
distanceToNextPoint: FolkRope.#resolution,
mass: 1,
damping: 0.99,
velocity: Vector.zero(),
isFixed: true,
prev: lastPoint,
next: null,
};
lastPoint.next = newPoint;
this.#points.push(newPoint);
}
while (this.#points.length > desiredPoints) {
this.#points.pop();
this.#points.at(-1)!.isFixed = true;
}
}
render() {
if (this.#points.length < 2) return;
let pathData = `M ${this.#points[0].pos.x} ${this.#points[0].pos.y}`;
let path2Data = '';
let isBroken = false;
for (let i = 1; i < this.#points.length; i++) {
const point = this.#points[i];
if (point.prev === null) {
isBroken = true;
path2Data = `M ${point.pos.x} ${point.pos.y}`;
} else if (isBroken) {
path2Data += ` L ${point.pos.x} ${point.pos.y}`;
} else {
pathData += ` L ${point.pos.x} ${point.pos.y}`;
}
}
this.#path.setAttribute('d', pathData);
if (path2Data) {
this.#path2.setAttribute('d', path2Data);
} else {
this.#path2.removeAttribute('d');
}
}
#generatePoints(start: Point, end: Point) {
const delta = Vector.sub(end, start);
const len = Vector.mag(delta);
const points: RopePoint[] = [];
const pointsLen = Math.floor(len / FolkRope.#resolution);
for (let i = 0; i < pointsLen; i++) {
const percentage = i / (pointsLen - 1);
const pos = Vector.lerp(start, end, percentage);
points.push({
pos,
oldPos: { ...pos },
distanceToNextPoint: FolkRope.#resolution,
mass: 1,
damping: 0.99,
velocity: Vector.zero(),
isFixed: i === 0 || i === pointsLen - 1,
prev: null,
next: null,
});
}
// Link nodes into a doubly linked list
for (let i = 0; i < pointsLen; i++) {
const prev = i != 0 ? points[i - 1] : null;
const curr = points[i];
const next = i != pointsLen - 1 ? points[i + 1] : null;
curr.prev = prev;
curr.next = next;
}
return points;
}
#integratePoint(point: RopePoint, gravity: Point) {
if (!point.isFixed) {
point.velocity = Vector.sub(point.pos, point.oldPos);
point.oldPos = { ...point.pos };
const accel = Vector.add(gravity, { x: 0, y: point.mass });
const tsSq = this.#rAF.fixedTimestep ** 2;
point.pos.x += point.velocity.x * point.damping + accel.x * tsSq;
point.pos.y += point.velocity.y * point.damping + accel.y * tsSq;
} else {
point.velocity = Vector.zero();
point.oldPos = { ...point.pos };
}
}
// Apply constraints related to other nodes next to it (keeps each node within distance)
#constrainPoint(point: RopePoint) {
if (point.next) applyConstraint(point, point.next);
if (point.prev) applyConstraint(point, point.prev);
}
#cutIndex = -1;
get isCut() {
return this.#cutIndex !== -1;
}
cut(atPercentage = 0.5) {
if (this.isCut) return;
this.#cutIndex = this.#getPointIndexAt(atPercentage);
this.#points[this.#cutIndex].next = null;
this.#points[this.#cutIndex + 1].prev = null;
this.#rAF.reset();
}
mend() {
if (!this.isCut) return;
this.#points[this.#cutIndex].next = this.#points[this.#cutIndex + 1];
this.#points[this.#cutIndex + 1].prev = this.#points[this.#cutIndex];
this.#cutIndex = -1;
this.#rAF.reset();
}
getPointAt(percentage: number) {
return this.#points[this.#getPointIndexAt(percentage)];
}
#getPointIndexAt(percentage: number) {
const clamped = Math.min(Math.max(percentage, 0), 1);
return Math.floor(this.#points.length * clamped);
}
getPercentageFromPoint(hitPoint: Point): number | null {
for (let i = 0; i < this.#points.length - 1; i++) {
const point = this.#points[i];
const nextPoint = point.next;
if (nextPoint === null) return null;
if (
Vector.distance(point.pos, hitPoint) +
Vector.distance(hitPoint, nextPoint.pos) -
Vector.distance(point.pos, nextPoint.pos) <
1
) {
return i / this.#points.length;
}
}
return null;
}
}
function applyConstraint(p1: RopePoint, p2: RopePoint) {
const delta = Vector.sub(p2.pos, p1.pos);
const len = Vector.mag(delta);
// Prevent division by zero
if (len < 0.0001) return;
const diff = len - p1.distanceToNextPoint;
const normal = Vector.normalized(delta);
const adjustment = Vector.scale(normal, diff * 0.75);
if (!p1.isFixed) {
p1.pos = Vector.add(p1.pos, adjustment);
}
if (!p2.isFixed) {
p2.pos = Vector.sub(p2.pos, adjustment);
}
}