diff --git a/frontend/lib/math/curves.js b/frontend/lib/math/curves.js index f92d809..c3476be 100644 --- a/frontend/lib/math/curves.js +++ b/frontend/lib/math/curves.js @@ -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 [] diff --git a/frontend/lib/math/math.js b/frontend/lib/math/math.js new file mode 100644 index 0000000..9565469 --- /dev/null +++ b/frontend/lib/math/math.js @@ -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)) +} diff --git a/frontend/src/components/KnotLayer.jsx b/frontend/src/components/KnotLayer.jsx index edb46d4..79cf1be 100644 --- a/frontend/src/components/KnotLayer.jsx +++ b/frontend/src/components/KnotLayer.jsx @@ -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 }) => { { - 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)) + } }} /> )