Working 2d knot simulation with autointersections

main
Antonio De Lucreziis 2 years ago
parent 830bbf7e1f
commit a946fb3b01

@ -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
}
onMouseDrag(x, y) {
if (!this.ghostPath) {
onMouseDown(x, y, buttons) {
if (buttons === 1) {
this.ghostPath = []
}
if (buttons === 2) {
this.blackHolePosition = [x, y]
}
}
this.ghostPath.push([x, y])
onMouseDrag(x, y) {
if (this.ghostPath) {
this.ghostPath.push([x, y])
}
if (this.blackHolePosition) {
this.blackHolePosition = [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))
}
}}
/>
)

Loading…
Cancel
Save