Working 2d knot simulation with autointersections
This commit is contained in:
@@ -2,6 +2,51 @@ function distance([x1, y1], [x2, y2]) {
|
||||
return Math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2)
|
||||
}
|
||||
|
||||
function midpoints([x1, y1], [x2, y2], count) {
|
||||
const dx = x2 - x1
|
||||
const dy = y2 - y1
|
||||
|
||||
const polyline = []
|
||||
|
||||
for (let i = 1; i < count; i++) {
|
||||
const ratio = i / count
|
||||
polyline.push([x1 + dx * ratio, y1 + dy * ratio])
|
||||
}
|
||||
|
||||
return polyline
|
||||
}
|
||||
|
||||
export function resampleCurve(curve, minDist, maxDist) {
|
||||
if (curve.length === 0) return []
|
||||
|
||||
const newCurve = [curve[0]]
|
||||
|
||||
let lastPt = curve[0]
|
||||
for (let i = 1; i < curve.length; i++) {
|
||||
const pt = curve[i]
|
||||
|
||||
const segmentLength = distance(lastPt, pt)
|
||||
|
||||
if (segmentLength < minDist) {
|
||||
// console.log('skipping point, segmentLength:', segmentLength)
|
||||
continue
|
||||
}
|
||||
if (segmentLength > maxDist) {
|
||||
// console.log('adding points, segmentLength:', segmentLength)
|
||||
newCurve.push(...midpoints(lastPt, pt, Math.floor(segmentLength / maxDist)))
|
||||
}
|
||||
|
||||
newCurve.push(pt)
|
||||
lastPt = pt
|
||||
}
|
||||
|
||||
if (lastPt !== curve.at(-1)) {
|
||||
newCurve.push(curve.at(-1))
|
||||
}
|
||||
|
||||
return newCurve
|
||||
}
|
||||
|
||||
export function simplifyCurve(curve, minDistance) {
|
||||
if (curve.length === 0) return []
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
export const Vec2 = {
|
||||
Mutate: {
|
||||
add(target, [x, y]) {
|
||||
target[0] += x
|
||||
target[1] += y
|
||||
|
||||
return target
|
||||
},
|
||||
scale(target, factor) {
|
||||
target[0] *= factor
|
||||
target[1] *= factor
|
||||
|
||||
return target
|
||||
},
|
||||
normalize(target) {
|
||||
const norm = Vec2.norm2([x, y])
|
||||
target[0] /= norm
|
||||
target[1] /= norm
|
||||
|
||||
return target
|
||||
},
|
||||
set(target, [x, y]) {
|
||||
target[0] = x
|
||||
target[1] = y
|
||||
return target
|
||||
},
|
||||
},
|
||||
perpendicular([x, y]) {
|
||||
return [-y, x]
|
||||
},
|
||||
distance2(v1, v2) {
|
||||
return Vec2.norm2(Vec2.sub(v1, v2))
|
||||
},
|
||||
add([x1, y1], [x2, y2]) {
|
||||
return [x1 + x2, y1 + y2]
|
||||
},
|
||||
sub([x1, y1], [x2, y2]) {
|
||||
return [x1 - x2, y1 - y2]
|
||||
},
|
||||
dot([x1, y1], [x2, y2]) {
|
||||
return x1 * x2 + y1 * y2
|
||||
},
|
||||
scale([x, y], factor) {
|
||||
return [x * factor, y * factor]
|
||||
},
|
||||
norm2([x, y]) {
|
||||
return Math.sqrt(x ** 2 + y ** 2)
|
||||
},
|
||||
normalize([x, y]) {
|
||||
const norm = Vec2.norm2([x, y])
|
||||
return [x / norm, y / norm]
|
||||
},
|
||||
}
|
||||
|
||||
export function clamp(min, value, max) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
@@ -1,34 +1,157 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { simplifyCurve } from '../../lib/math/curves.js'
|
||||
import { resampleCurve, simplifyCurve } from '../../lib/math/curves.js'
|
||||
import { clamp, Vec2 } from '../../lib/math/math.js'
|
||||
|
||||
function createSimulation(positions, velocities, accelerations) {
|
||||
return {
|
||||
positions,
|
||||
velocities,
|
||||
accelerations,
|
||||
|
||||
update(newAccelerations) {
|
||||
const n = this.positions.length
|
||||
const newPositions = Array.from({ length: n }, () => [])
|
||||
const newVelocities = Array.from({ length: n }, () => [])
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
newPositions[i][0] = this.positions[i][0] + this.velocities[i][0] + this.accelerations[i][0] * 0.5
|
||||
newPositions[i][1] = this.positions[i][1] + this.velocities[i][1] + this.accelerations[i][1] * 0.5
|
||||
newVelocities[i][0] = this.velocities[i][0] + (this.accelerations[i][0] + newAccelerations[i][0]) * 0.5
|
||||
newVelocities[i][1] = this.velocities[i][1] + (this.accelerations[i][1] + newAccelerations[i][1]) * 0.5
|
||||
}
|
||||
|
||||
this.positions = newPositions
|
||||
this.velocities = newVelocities
|
||||
this.accelerations = newAccelerations
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
class KnotSimulation {
|
||||
constructor(knotRef) {
|
||||
/** ref allo stato esterno che maneggia anche Preact */
|
||||
this.knotRef = knotRef
|
||||
|
||||
/** percorso temporaneo utilizzato mentre si disegna sul layer */
|
||||
this.ghostPath = null
|
||||
|
||||
/** position where to place the black hole while right-clicking the canvas */
|
||||
this.blackHolePosition = null
|
||||
|
||||
/** particle simulation that holds positions, velocities and accelerations */
|
||||
this.particleSimulation = null
|
||||
}
|
||||
|
||||
onMouseDown(x, y, buttons) {
|
||||
if (buttons === 1) {
|
||||
this.ghostPath = []
|
||||
}
|
||||
if (buttons === 2) {
|
||||
this.blackHolePosition = [x, y]
|
||||
}
|
||||
}
|
||||
|
||||
onMouseDrag(x, y) {
|
||||
if (!this.ghostPath) {
|
||||
this.ghostPath = []
|
||||
if (this.ghostPath) {
|
||||
this.ghostPath.push([x, y])
|
||||
}
|
||||
if (this.blackHolePosition) {
|
||||
this.blackHolePosition = [x, y]
|
||||
}
|
||||
|
||||
this.ghostPath.push([x, y])
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
this.knotRef.current.points = simplifyCurve(this.ghostPath, 15)
|
||||
this.ghostPath = null
|
||||
if (this.ghostPath) {
|
||||
this.ghostPath.push(this.ghostPath[0])
|
||||
|
||||
const curve = resampleCurve(this.ghostPath, 10, 10)
|
||||
curve.pop()
|
||||
|
||||
this.setPositions(curve)
|
||||
|
||||
this.ghostPath = null
|
||||
}
|
||||
|
||||
this.blackHolePosition = null
|
||||
}
|
||||
|
||||
update() {}
|
||||
setPositions(positions) {
|
||||
this.particleSimulation = createSimulation(
|
||||
positions,
|
||||
Array.from({ length: positions.length }, () => [0, 0]),
|
||||
Array.from({ length: positions.length }, () => [0, 0])
|
||||
)
|
||||
}
|
||||
|
||||
update() {
|
||||
if (!this.particleSimulation) {
|
||||
return
|
||||
}
|
||||
|
||||
const { positions, velocities } = this.particleSimulation
|
||||
const N = positions.length
|
||||
|
||||
const newAccelerations = Array.from({ length: N }, () => [0, 0])
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
const prev = positions.at((i - 1) % N)
|
||||
const curr = positions.at(i)
|
||||
const next = positions.at((i + 1) % N)
|
||||
|
||||
if (this.blackHolePosition) {
|
||||
const v = Vec2.sub(this.blackHolePosition, curr)
|
||||
const dir = Vec2.normalize(v)
|
||||
const dist = Vec2.norm2(v)
|
||||
|
||||
Vec2.Mutate.add(newAccelerations[i], Vec2.scale(dir, 1e6 / Math.max(dist, 75) ** 3))
|
||||
}
|
||||
}
|
||||
|
||||
// barrette rigide
|
||||
for (let i = 0; i < N; i++) {
|
||||
const prev = positions.at((i - 1) % N)
|
||||
const curr = positions.at(i)
|
||||
const next = positions.at((i + 1) % N)
|
||||
|
||||
const v = Vec2.sub(next, curr)
|
||||
const d = Vec2.norm2(v)
|
||||
|
||||
const factor = (d - 10) / d
|
||||
|
||||
positions[i] = Vec2.add(curr, Vec2.scale(v, 0.5 * factor))
|
||||
|
||||
const nextPos = Vec2.add(next, Vec2.scale(v, -0.5 * factor))
|
||||
next[0] = nextPos[0]
|
||||
next[1] = nextPos[1]
|
||||
|
||||
const baseForce = Vec2.scale(Vec2.add(Vec2.sub(prev, curr), Vec2.sub(next, curr)), 0.5)
|
||||
const jointFactor = 1
|
||||
|
||||
Vec2.Mutate.add(positions.at((i - 1) % N), Vec2.scale(baseForce, -jointFactor / 2))
|
||||
Vec2.Mutate.add(positions[i], Vec2.scale(baseForce, jointFactor))
|
||||
Vec2.Mutate.add(positions.at((i + 1) % N), Vec2.scale(baseForce, -jointFactor / 2))
|
||||
}
|
||||
|
||||
this.particleSimulation.update(newAccelerations)
|
||||
|
||||
// attrito viscoso
|
||||
for (let i = 0; i < N; i++) {
|
||||
Vec2.Mutate.scale(this.particleSimulation.velocities[i], 0.1)
|
||||
}
|
||||
|
||||
this.knotRef.current.points = this.particleSimulation.positions
|
||||
}
|
||||
|
||||
/** @param {CanvasRenderingContext2D} g */
|
||||
render(g) {
|
||||
const w = g.canvas.width
|
||||
const h = g.canvas.height
|
||||
const knot = this.knotRef.current
|
||||
|
||||
g.clearRect(0, 0, w, h)
|
||||
|
||||
g.fillStyle = '#fff'
|
||||
g.fillRect(0, 0, w, h)
|
||||
|
||||
g.lineWidth = 3
|
||||
g.lineCap = 'round'
|
||||
g.lineJoin = 'round'
|
||||
@@ -45,22 +168,80 @@ class KnotSimulation {
|
||||
}
|
||||
}
|
||||
g.stroke()
|
||||
} else if (knot.points.length > 0) {
|
||||
|
||||
g.save()
|
||||
{
|
||||
g.strokeStyle = '#080'
|
||||
g.lineWidth = 3
|
||||
g.setLineDash([6, 6])
|
||||
g.beginPath()
|
||||
g.moveTo(...this.ghostPath.at(-1))
|
||||
g.lineTo(...this.ghostPath.at(0))
|
||||
g.stroke()
|
||||
}
|
||||
g.restore()
|
||||
}
|
||||
|
||||
if (!this.particleSimulation) {
|
||||
return
|
||||
}
|
||||
|
||||
const positions = this.particleSimulation.positions
|
||||
const particleCount = positions.length
|
||||
|
||||
if (particleCount > 0) {
|
||||
g.beginPath()
|
||||
const [x0, y0] = knot.points[0]
|
||||
const [x0, y0] = positions[0]
|
||||
g.moveTo(x0, y0)
|
||||
|
||||
for (const [x, y] of knot.points) {
|
||||
g.lineTo(x, y)
|
||||
}
|
||||
g.stroke()
|
||||
g.strokeStyle = '#333'
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const prev2 = positions.at((i - 2) % particleCount)
|
||||
const prev = positions.at((i - 1) % particleCount)
|
||||
const curr = positions.at(i % particleCount)
|
||||
const next = positions.at((i + 1) % particleCount)
|
||||
const next2 = positions.at((i + 2) % particleCount)
|
||||
|
||||
g.fillStyle = '#080'
|
||||
for (const [x, y] of knot.points) {
|
||||
g.beginPath()
|
||||
g.ellipse(x, y, 3, 3, 0, 0, Math.PI * 2)
|
||||
g.fill()
|
||||
g.save()
|
||||
{
|
||||
g.strokeStyle = '#fff'
|
||||
g.lineWidth = 12
|
||||
g.lineJoin = 'round'
|
||||
g.lineCap = 'butt'
|
||||
|
||||
g.beginPath()
|
||||
g.moveTo(...prev)
|
||||
g.lineTo(...curr)
|
||||
g.lineTo(...next)
|
||||
g.stroke()
|
||||
}
|
||||
g.restore()
|
||||
|
||||
g.save()
|
||||
{
|
||||
g.strokeStyle = '#333'
|
||||
g.lineWidth = 3
|
||||
g.lineCap = 'round'
|
||||
g.lineJoin = 'round'
|
||||
|
||||
g.beginPath()
|
||||
g.moveTo(...prev2)
|
||||
g.moveTo(...prev)
|
||||
g.lineTo(...curr)
|
||||
g.lineTo(...next)
|
||||
g.lineTo(...next2)
|
||||
g.stroke()
|
||||
}
|
||||
g.restore()
|
||||
}
|
||||
|
||||
// for (let i = 0; i < particleCount; i++) {
|
||||
// g.fillStyle = '#f00'
|
||||
|
||||
// g.beginPath()
|
||||
// g.ellipse(...positions[i], 2, 2, 0, 0, 2 * Math.PI)
|
||||
// g.fill()
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,16 +260,14 @@ export const KnotLayer = ({ knotRef }) => {
|
||||
let simTimerHandle
|
||||
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.width = canvasRef.current.offsetWidth
|
||||
canvasRef.current.height = canvasRef.current.offsetHeight
|
||||
|
||||
const g = canvasRef.current.getContext('2d')
|
||||
const $canvas = canvasRef.current
|
||||
$canvas.width = $canvas.offsetWidth
|
||||
$canvas.height = $canvas.offsetHeight
|
||||
$canvas.graphicsContext = $canvas.getContext('2d')
|
||||
|
||||
simTimerHandle = setInterval(() => {
|
||||
knotSim.update()
|
||||
requestAnimationFrame(() => knotSim.render(g))
|
||||
|
||||
console.log('Prova')
|
||||
requestAnimationFrame(() => knotSim.render($canvas.graphicsContext))
|
||||
}, 1000 / 30)
|
||||
}
|
||||
|
||||
@@ -103,17 +282,20 @@ export const KnotLayer = ({ knotRef }) => {
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onMouseDown={e => {
|
||||
if (e.buttons === 1) {
|
||||
knotSim.onMouseDrag(e.offsetX, e.offsetY)
|
||||
}
|
||||
knotSim.onMouseDown(e.offsetX, e.offsetY, e.buttons)
|
||||
}}
|
||||
onMouseMove={e => {
|
||||
if (e.buttons === 1) {
|
||||
knotSim.onMouseDrag(e.offsetX, e.offsetY)
|
||||
if (canvasRef.current && e.buttons > 0) {
|
||||
knotSim.onMouseDrag(e.offsetX, e.offsetY, e.buttons)
|
||||
requestAnimationFrame(() => knotSim.render(canvasRef.current.graphicsContext))
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
onMouseUp={e => {
|
||||
knotSim.onMouseUp()
|
||||
if (canvasRef.current) {
|
||||
knotSim.onMouseUp()
|
||||
requestAnimationFrame(() => knotSim.render(canvasRef.current.graphicsContext))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user