scaffold rope
This commit is contained in:
parent
e4449bef21
commit
dec2ab8c4d
|
|
@ -0,0 +1,44 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Wiggly</title>
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100%;
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fc-geometry {
|
||||||
|
border: 1px solid black;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
fc-rope {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 0 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<fc-geometry id="box1" x="100" y="100" width="50" height="50"></fc-geometry>
|
||||||
|
<fc-geometry id="box2" x="300" y="105">Hello World</fc-geometry>
|
||||||
|
<fc-rope source="#box1" target="#box2"></fc-rope>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import { FolkGeometry } from '../src/canvas/fc-geometry.ts';
|
||||||
|
import { FolkRope } from '../src/arrows/fc-rope.ts';
|
||||||
|
|
||||||
|
FolkGeometry.register();
|
||||||
|
FolkRope.register();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -1,12 +1,8 @@
|
||||||
|
import { Vertex } from './utils';
|
||||||
import { VisualObserverEntry, VisualObserverManager } from './visual-observer';
|
import { VisualObserverEntry, VisualObserverManager } from './visual-observer';
|
||||||
|
|
||||||
const visualObserver = new VisualObserverManager();
|
const visualObserver = new VisualObserverManager();
|
||||||
|
|
||||||
interface Vertex {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const vertexRegex = /(?<x>-?([0-9]*[.])?[0-9]+),\s*(?<y>-?([0-9]*[.])?[0-9]+)/;
|
const vertexRegex = /(?<x>-?([0-9]*[.])?[0-9]+),\s*(?<y>-?([0-9]*[.])?[0-9]+)/;
|
||||||
|
|
||||||
function parseVertex(str: string): Vertex | null {
|
function parseVertex(str: string): Vertex | null {
|
||||||
|
|
@ -38,7 +34,7 @@ export class AbstractArrow extends HTMLElement {
|
||||||
this.observeSource();
|
this.observeSource();
|
||||||
}
|
}
|
||||||
|
|
||||||
#sourceRect!: DOMRectReadOnly;
|
#sourceRect: DOMRectReadOnly | undefined;
|
||||||
get sourceRect() {
|
get sourceRect() {
|
||||||
return this.#sourceRect;
|
return this.#sourceRect;
|
||||||
}
|
}
|
||||||
|
|
@ -64,7 +60,7 @@ export class AbstractArrow extends HTMLElement {
|
||||||
this.observeTarget();
|
this.observeTarget();
|
||||||
}
|
}
|
||||||
|
|
||||||
#targetRect!: DOMRectReadOnly;
|
#targetRect: DOMRectReadOnly | undefined;
|
||||||
get targetRect() {
|
get targetRect() {
|
||||||
return this.#targetRect;
|
return this.#targetRect;
|
||||||
}
|
}
|
||||||
|
|
@ -80,8 +76,8 @@ export class AbstractArrow extends HTMLElement {
|
||||||
};
|
};
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.source = this.getAttribute('source') || '';
|
this.source = this.getAttribute('source') || this.#source;
|
||||||
this.target = this.getAttribute('target') || '';
|
this.target = this.getAttribute('target') || this.#target;
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
|
|
@ -103,14 +99,14 @@ export class AbstractArrow extends HTMLElement {
|
||||||
this.#sourceRect = DOMRectReadOnly.fromRect(vertex);
|
this.#sourceRect = DOMRectReadOnly.fromRect(vertex);
|
||||||
this.#update();
|
this.#update();
|
||||||
} else {
|
} else {
|
||||||
const el = document.querySelector(this.source);
|
this.#sourceElement = document.querySelector(this.source);
|
||||||
|
|
||||||
if (el === null) {
|
if (this.#sourceElement === null) {
|
||||||
throw new Error('source is not a valid element');
|
throw new Error('source is not a valid element');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#sourceElement = el;
|
|
||||||
visualObserver.observe(this.#sourceElement, this.#sourceCallback);
|
visualObserver.observe(this.#sourceElement, this.#sourceCallback);
|
||||||
|
this.#sourceRect = this.#sourceElement.getBoundingClientRect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,6 +132,7 @@ export class AbstractArrow extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
visualObserver.observe(this.#targetElement, this.#targetCallback);
|
visualObserver.observe(this.#targetElement, this.#targetCallback);
|
||||||
|
this.#targetRect = this.#targetElement.getBoundingClientRect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,8 +145,9 @@ export class AbstractArrow extends HTMLElement {
|
||||||
#update() {
|
#update() {
|
||||||
if (this.#sourceRect === undefined || this.#targetRect === undefined) return;
|
if (this.#sourceRect === undefined || this.#targetRect === undefined) return;
|
||||||
|
|
||||||
this.render();
|
this.render(this.#sourceRect, this.#targetRect);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {}
|
// @ts-ignore
|
||||||
|
render(sourceRect: DOMRectReadOnly, targetRect: DOMRectReadOnly) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,11 @@ export class FolkConnection extends AbstractArrow {
|
||||||
override render() {
|
override render() {
|
||||||
const { sourceRect, targetRect } = this;
|
const { sourceRect, targetRect } = this;
|
||||||
|
|
||||||
|
if (sourceRect === undefined || targetRect === undefined) {
|
||||||
|
this.style.clipPath = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const [sx, sy, cx, cy, ex, ey] = getBoxToBoxArrow(
|
const [sx, sy, cx, cy, ex, ey] = getBoxToBoxArrow(
|
||||||
sourceRect.x,
|
sourceRect.x,
|
||||||
sourceRect.y,
|
sourceRect.y,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,357 @@
|
||||||
|
import { AbstractArrow } from './abstract-arrow.ts';
|
||||||
|
import { Vertex } from './utils.ts';
|
||||||
|
//A small scaffold specifically to help me design code pen interactions
|
||||||
|
|
||||||
|
//Math extensions
|
||||||
|
const lerp = (first, second, percentage) => {
|
||||||
|
return first + (second - first) * percentage;
|
||||||
|
};
|
||||||
|
|
||||||
|
class Vector2 {
|
||||||
|
static zero() {
|
||||||
|
return { x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
static sub(a, b) {
|
||||||
|
return { x: a.x - b.x, y: a.y - b.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
static add(a, b) {
|
||||||
|
return { x: a.x + b.x, y: a.y + b.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
static mult(a, b) {
|
||||||
|
return { x: a.x * b.x, y: a.y * b.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
static scale(v, scaleFactor) {
|
||||||
|
return { x: v.x * scaleFactor, y: v.y * scaleFactor };
|
||||||
|
}
|
||||||
|
|
||||||
|
static mag(v) {
|
||||||
|
return Math.sqrt(v.x * v.x + v.y * v.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
static normalized(v) {
|
||||||
|
const mag = Vector2.mag(v);
|
||||||
|
|
||||||
|
if (mag === 0) {
|
||||||
|
return Vector2.zero();
|
||||||
|
}
|
||||||
|
return { x: v.x / mag, y: v.y / mag };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class App {
|
||||||
|
constructor(window, canvas, context, updateHandler, drawHandler, frameRate = 60) {
|
||||||
|
this._window = window;
|
||||||
|
this._canvas = canvas;
|
||||||
|
this._context = context;
|
||||||
|
this._updateHandler = updateHandler;
|
||||||
|
this._drawHandler = drawHandler;
|
||||||
|
this._frameRate = frameRate;
|
||||||
|
this._lastTime = 0;
|
||||||
|
this._currentTime = 0;
|
||||||
|
this._deltaTime = 0;
|
||||||
|
this._interval = 0;
|
||||||
|
this.onMouseMoveHandler = (x, y) => {};
|
||||||
|
this.onMouseDownHandler = (x, y) => {};
|
||||||
|
this.start = this.start.bind(this);
|
||||||
|
this._onMouseEventHandlerWrapper = this._onMouseEventHandlerWrapper.bind(this);
|
||||||
|
this._onRequestAnimationFrame = this._onRequestAnimationFrame.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this._lastTime = new Date().getTime();
|
||||||
|
this._currentTime = 0;
|
||||||
|
this._deltaTime = 0;
|
||||||
|
this._interval = 1000 / this._frameRate;
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'mousemove',
|
||||||
|
(e) => {
|
||||||
|
this._onMouseEventHandlerWrapper(e, this.onMouseMoveHandler);
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'mousedown',
|
||||||
|
(e) => {
|
||||||
|
this._onMouseEventHandlerWrapper(e, this.onMouseDownHandler);
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
this._onRequestAnimationFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
_onMouseEventHandlerWrapper(e, callback) {
|
||||||
|
let element = this._canvas;
|
||||||
|
let offsetX = 0;
|
||||||
|
let offsetY = 0;
|
||||||
|
|
||||||
|
if (element.offsetParent) {
|
||||||
|
do {
|
||||||
|
offsetX += element.offsetLeft;
|
||||||
|
offsetY += element.offsetTop;
|
||||||
|
} while ((element = element.offsetParent));
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = e.pageX - offsetX;
|
||||||
|
const y = e.pageY - offsetY;
|
||||||
|
|
||||||
|
callback(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
_onRequestAnimationFrame() {
|
||||||
|
this._window.requestAnimationFrame(this._onRequestAnimationFrame);
|
||||||
|
|
||||||
|
this._currentTime = new Date().getTime();
|
||||||
|
this._deltaTime = this._currentTime - this._lastTime;
|
||||||
|
|
||||||
|
if (this._deltaTime > this._interval) {
|
||||||
|
//delta time in seconds
|
||||||
|
const dts = this._deltaTime * 0.001;
|
||||||
|
|
||||||
|
this._updateHandler(dts);
|
||||||
|
|
||||||
|
this._context.clearRect(0, 0, this._canvas.width, this._canvas.height);
|
||||||
|
this._drawHandler(this._canvas, this._context, dts);
|
||||||
|
|
||||||
|
this._lastTime = this._currentTime - (this._deltaTime % this._interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//each rope part is one of these
|
||||||
|
//uses a high precison varient of Störmer–Verlet integration
|
||||||
|
//to keep the simulation consistant otherwise it would "explode"!
|
||||||
|
class RopePoint {
|
||||||
|
//integrates motion equations per node without taking into account relationship
|
||||||
|
//with other nodes...
|
||||||
|
static integrate(point, gravity, dt, previousFrameDt) {
|
||||||
|
if (!point.isFixed) {
|
||||||
|
point.velocity = Vector2.sub(point.pos, point.oldPos);
|
||||||
|
point.oldPos = { ...point.pos };
|
||||||
|
|
||||||
|
//drastically improves stability
|
||||||
|
let timeCorrection = previousFrameDt != 0.0 ? dt / previousFrameDt : 0.0;
|
||||||
|
|
||||||
|
let accel = Vector2.add(gravity, { x: 0, y: point.mass });
|
||||||
|
|
||||||
|
const velCoef = timeCorrection * point.damping;
|
||||||
|
const accelCoef = Math.pow(dt, 2);
|
||||||
|
|
||||||
|
point.pos.x += point.velocity.x * velCoef + accel.x * accelCoef;
|
||||||
|
point.pos.y += point.velocity.y * velCoef + accel.y * accelCoef;
|
||||||
|
} else {
|
||||||
|
point.velocity = Vector2.zero();
|
||||||
|
point.oldPos = { ...point.pos };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//apply constraints related to other nodes next to it
|
||||||
|
//(keeps each node within distance)
|
||||||
|
static constrain(point) {
|
||||||
|
if (point.next) {
|
||||||
|
const delta = Vector2.sub(point.next.pos, point.pos);
|
||||||
|
const len = Vector2.mag(delta);
|
||||||
|
const diff = len - point.distanceToNextPoint;
|
||||||
|
const normal = Vector2.normalized(delta);
|
||||||
|
|
||||||
|
if (!point.isFixed) {
|
||||||
|
point.pos.x += normal.x * diff * 0.25;
|
||||||
|
point.pos.y += normal.y * diff * 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!point.next.isFixed) {
|
||||||
|
point.next.pos.x -= normal.x * diff * 0.25;
|
||||||
|
point.next.pos.y -= normal.y * diff * 0.25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (point.prev) {
|
||||||
|
const delta = Vector2.sub(point.prev.pos, point.pos);
|
||||||
|
const len = Vector2.mag(delta);
|
||||||
|
const diff = len - point.distanceToNextPoint;
|
||||||
|
const normal = Vector2.normalized(delta);
|
||||||
|
|
||||||
|
if (!point.isFixed) {
|
||||||
|
point.pos.x += normal.x * diff * 0.25;
|
||||||
|
point.pos.y += normal.y * diff * 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!point.prev.isFixed) {
|
||||||
|
point.prev.pos.x -= normal.x * diff * 0.25;
|
||||||
|
point.prev.pos.y -= normal.y * diff * 0.25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(initialPos, distanceToNextPoint) {
|
||||||
|
this.pos = initialPos;
|
||||||
|
this.distanceToNextPoint = distanceToNextPoint;
|
||||||
|
this.isFixed = false;
|
||||||
|
this.oldPos = { ...initialPos };
|
||||||
|
this.velocity = Vector2.zero();
|
||||||
|
this.mass = 1.0;
|
||||||
|
this.damping = 1.0;
|
||||||
|
this.prev = null;
|
||||||
|
this.next = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//manages a collection of rope points and executes
|
||||||
|
//the integration
|
||||||
|
class Rope {
|
||||||
|
//generate an array of points suitable for a dynamic
|
||||||
|
//rope contour
|
||||||
|
static generate(start, end, resolution, mass, damping) {
|
||||||
|
const delta = Vector2.sub(end, start);
|
||||||
|
const len = Vector2.mag(delta);
|
||||||
|
|
||||||
|
let points = [];
|
||||||
|
const pointsLen = len / resolution;
|
||||||
|
|
||||||
|
for (let i = 0; i < pointsLen; i++) {
|
||||||
|
const percentage = i / (pointsLen - 1);
|
||||||
|
|
||||||
|
const lerpX = lerp(start.x, end.x, percentage);
|
||||||
|
const lerpY = lerp(start.y, end.y, percentage);
|
||||||
|
|
||||||
|
points[i] = new RopePoint({ x: lerpX, y: lerpY }, resolution);
|
||||||
|
points[i].mass = mass;
|
||||||
|
points[i].damping = damping;
|
||||||
|
}
|
||||||
|
|
||||||
|
//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;
|
||||||
|
}
|
||||||
|
|
||||||
|
points[0].isFixed = points[points.length - 1].isFixed = true;
|
||||||
|
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(points, solverIterations) {
|
||||||
|
this._points = points;
|
||||||
|
this.update = this.update.bind(this);
|
||||||
|
this._prevDelta = 0;
|
||||||
|
this._solverIterations = solverIterations;
|
||||||
|
|
||||||
|
this.getPoint = this.getPoint.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPoint(index) {
|
||||||
|
return this._points[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
update(gravity, dt) {
|
||||||
|
for (let i = 0; i < this._points.length; i++) {
|
||||||
|
let point = this._points[i];
|
||||||
|
|
||||||
|
let accel = { ...gravity };
|
||||||
|
|
||||||
|
RopePoint.integrate(point, accel, dt, this._prevDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let iteration = 0; iteration < this._solverIterations; iteration++)
|
||||||
|
for (let i = 0; i < this._points.length; i++) {
|
||||||
|
let point = this._points[i];
|
||||||
|
RopePoint.constrain(point);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._prevDelta = dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'fc-rope': FolkRope;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FolkRope extends AbstractArrow {
|
||||||
|
static override tagName = 'fc-rope';
|
||||||
|
|
||||||
|
#canvas = document.createElement('canvas');
|
||||||
|
#context = this.#canvas.getContext('2d')!;
|
||||||
|
#shadow = this.attachShadow({ mode: 'open' });
|
||||||
|
#args;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const context = this.#context;
|
||||||
|
this.#canvas.width = this.clientWidth;
|
||||||
|
this.#canvas.height = this.clientHeight;
|
||||||
|
|
||||||
|
const gradient = this.#context.createLinearGradient(0, 0, 500, 0);
|
||||||
|
gradient.addColorStop(0, 'white');
|
||||||
|
gradient.addColorStop(0.25, 'yellow');
|
||||||
|
gradient.addColorStop(0.5, 'blue');
|
||||||
|
gradient.addColorStop(0.75, 'red');
|
||||||
|
gradient.addColorStop(1.0, 'white');
|
||||||
|
|
||||||
|
const args = (this.#args = {
|
||||||
|
start: { x: 100, y: this.#canvas.height / 2 },
|
||||||
|
end: { x: this.#canvas.width - 100, y: this.#canvas.height / 2 },
|
||||||
|
resolution: 10,
|
||||||
|
mass: 1,
|
||||||
|
damping: 0.99,
|
||||||
|
gravity: { x: 0, y: 3000 },
|
||||||
|
solverIterations: 600,
|
||||||
|
ropeColour: gradient,
|
||||||
|
ropeSize: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const points = Rope.generate(args.start, args.end, args.resolution, args.mass, args.damping);
|
||||||
|
|
||||||
|
let rope = new Rope(points, args.solverIterations);
|
||||||
|
|
||||||
|
const tick = (dt) => {
|
||||||
|
rope.update(args.gravity, dt);
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawRopePoints = (points, colour, width) => {
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
let p = points[i];
|
||||||
|
|
||||||
|
const prev = i > 0 ? points[i - 1] : null;
|
||||||
|
|
||||||
|
if (prev) {
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(prev.pos.x, prev.pos.y);
|
||||||
|
context.lineTo(p.pos.x, p.pos.y);
|
||||||
|
context.lineWidth = width;
|
||||||
|
context.strokeStyle = colour;
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
//render a rope using the verlet points
|
||||||
|
const draw = (dt) => {
|
||||||
|
drawRopePoints(points, args.ropeColour, args.ropeSize);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseMove = (x, y) => {
|
||||||
|
let point = rope.getPoint(0);
|
||||||
|
point.pos.x = x;
|
||||||
|
point.pos.y = y;
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = new App(window, this.#canvas, context, tick, draw);
|
||||||
|
|
||||||
|
app.onMouseMoveHandler = onMouseMove;
|
||||||
|
app.start();
|
||||||
|
|
||||||
|
this.#shadow.appendChild(this.#canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,11 @@
|
||||||
|
|
||||||
export type Point = [number, number];
|
export type Point = [number, number];
|
||||||
|
|
||||||
|
export interface Vertex {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
// distance between 2 points
|
// distance between 2 points
|
||||||
function distance(p1: Point, p2: Point): number {
|
function distance(p1: Point, p2: Point): number {
|
||||||
return Math.sqrt(distanceSq(p1, p2));
|
return Math.sqrt(distanceSq(p1, p2));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue