Skip to content

Instantly share code, notes, and snippets.

@mlshv
Created December 4, 2025 09:34
Show Gist options
  • Select an option

  • Save mlshv/6cc30dd91e02dd34296b13cc7d39431c to your computer and use it in GitHub Desktop.

Select an option

Save mlshv/6cc30dd91e02dd34296b13cc7d39431c to your computer and use it in GitHub Desktop.
collision detection with belt/pulley problem math in p5js
<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/p5.min.js"></script>
<style>
body {
margin: 0;
overflow: hidden;
background: #000;
}
</style>
</head>
<body>
<script>
// Pulley system: two circles with belt (external bitangents)
let c1, c2; // circle centers
let circles = [];
let nextTopoId = 3; // Counter for unique topology entry IDs
// Debounced logging helper for render loop (only logs in debug modes)
let lastLogTime = {};
function logThrottled(key, interval, ...args) {
if (debugMode === 0) return; // no logs in mode 0
let now = Date.now();
if (!lastLogTime[key] || now - lastLogTime[key] >= interval) {
lastLogTime[key] = now;
console.log(...args);
}
}
// Get the effective collision radius for a circle
// This is the base radius + offset for all existing layers on this circle
function getCollisionRadius(circleIdx) {
let layerCount = 0;
for (let t of topology) {
if (t.circle === circleIdx) {
layerCount++;
}
}
const magicNumberForSmoothness = 4; // lol
return circles[circleIdx].radius + magicNumberForSmoothness + BELT_OFFSET * layerCount;
}
// Debug info for console: copy(getDebugInfo())
window.getDebugInfo = function() {
return JSON.stringify({
circles: circles.map((c, i) => ({
index: i,
x: Math.round(c.pos.x),
y: Math.round(c.pos.y),
radius: c.radius
})),
topology: topology
}, null, 2);
};
let topology = [
{
id: 0,
circle: 0,
type: "inner",
layer: 0,
},
{
id: 1,
circle: 1,
type: "inner",
layer: 0,
},
{
id: 2,
circle: 2,
type: "outer",
layer: 0,
},
];
let belt = null; // stores the belt path
let beltPhase = 0; // animation phase for dots
const BELT_OFFSET = 5; // base offset from circle edge, also layer thickness
// Debug modes: 0 = none (white, no dots, no logs), 1 = minimal (colors, dots), 2 = full (rays, effective radii)
let debugMode = 0; // cycle with 'd' key
function setup() {
createCanvas(windowWidth, windowHeight);
circles.push({ pos: createVector(width * 0.35, height * 0.5), radius: 80 });
circles.push({ pos: createVector(width * 0.65, height * 0.5), radius: 50 });
circles.push({ pos: createVector(width * 0.5, height * 0.6), radius: 40 });
}
function draw() {
background(0, 0, 0);
textFont('Courier New');
// Draw dot grid background (engineering sketchbook style)
let gridSpacing = 25;
fill(40);
noStroke();
for (let x = gridSpacing; x < width; x += gridSpacing) {
for (let y = gridSpacing; y < height; y += gridSpacing) {
circle(x, y, 2);
}
}
// Calculate and store belt path from topology
belt = computeBelt(topology, circles);
// Remove circles from topology if their arc angle is <= 0
if (belt) {
removeCollapsedCircles(belt);
}
// Recompute belt after potential removals
belt = computeBelt(topology, circles);
if (belt) {
// Draw belt segments, highlighting collisions in red
strokeWeight(2);
noFill();
for (let seg of belt.segments) {
// Check if any circle collides with this segment
// Skip circles that are endpoints of this segment
let collides = false;
let collidingCircle = -1;
for (let i = 0; i < circles.length; i++) {
// For line segments, skip the two circles it connects
if (seg.type === "line" && (i === seg.circleFrom || i === seg.circleTo)) continue;
// For arc segments, skip the circle the arc is on
if (seg.type === "arc" && i === seg.circleIdx) continue;
// Use effective collision radius (base + offset for existing layers)
let collisionRadius = getCollisionRadius(i);
if (segmentCircleCollision(seg, { pos: circles[i].pos, radius: collisionRadius })) {
collides = true;
collidingCircle = i;
break;
}
}
// If a line segment collides with a circle, insert it into topology
if (collides && seg.type === "line" && collidingCircle >= 0) {
insertCircleIntoTopology(collidingCircle, seg);
}
stroke(255, 160, 70); // orange belt
if (seg.type === "line") {
line(seg.from.x, seg.from.y, seg.to.x, seg.to.y);
} else {
// Arc - p5.js draws CCW from start to stop
// For CW traversal arc, draw from fromAngle to toAngle
// For CCW traversal arc, draw from toAngle to fromAngle
if (seg.cw) {
arc(seg.center.x, seg.center.y, seg.radius * 2, seg.radius * 2, seg.fromAngle, seg.toAngle);
} else {
arc(seg.center.x, seg.center.y, seg.radius * 2, seg.radius * 2, seg.toAngle, seg.fromAngle);
}
}
}
// Draw tangent points (not in mode 0)
if (debugMode > 0) {
fill(255, 160, 70);
noStroke();
for (let seg of belt.segments) {
if (seg.type === "line") {
circle(seg.from.x, seg.from.y, 8);
circle(seg.to.x, seg.to.y, 8);
}
}
}
// Animate dots along belt (not in mode 0)
if (debugMode > 0) {
drawBeltDots(belt);
beltPhase += 0.003;
}
// Debug: draw angle vectors at each arc (full mode only)
if (debugMode === 2) {
drawAngleDebug(belt);
}
}
// Draw all circles
for (let i = 0; i < circles.length; i++) {
let c = circles[i];
noFill();
stroke(255);
strokeWeight(1.5);
circle(c.pos.x, c.pos.y, c.radius * 2);
// Debug: draw effective radius for each topology entry of this circle (full mode only)
if (debugMode === 2 && belt) {
stroke(255, 80);
strokeWeight(1);
for (let seg of belt.segments) {
if (seg.type === "arc" && seg.circleIdx === i) {
circle(c.pos.x, c.pos.y, seg.radius * 2);
}
}
}
// Draw number in center
fill(255);
noStroke();
textAlign(CENTER, CENTER);
textSize(16);
text(i + 1, c.pos.x, c.pos.y);
}
textAlign(LEFT, BASELINE);
// Instructions (bottom left)
fill(100);
noStroke();
textSize(12);
let modeNames = ["none", "minimal", "full"];
text(`Drag circles | Scroll to resize | Double-click to delete | D = debug (${modeNames[debugMode]})`, 20, height - 20);
// Show belt length (label white, value orange)
if (belt) {
textSize(14);
fill(255);
text("Belt length: ", 20, 30);
fill(255, 160, 70);
text(belt.length.toFixed(1), 20 + textWidth("Belt length: "), 30);
}
// Draw topology visualization in top right
drawTopologyDiagram();
}
// Draw topology as a diagram in the top right corner
function drawTopologyDiagram() {
let miniRadius = 12;
let spacing = 40;
let startX = width - (circles.length * spacing);
let startY = 60;
// Title
fill(255, 255, 255);
noStroke();
textAlign(LEFT, BASELINE);
textSize(14);
text("Topology", startX - 15, startY - 30);
// Draw mini circles for each circle
for (let i = 0; i < circles.length; i++) {
let x = startX + i * spacing;
let y = startY;
let c = circles[i];
// Circle outline
noFill();
stroke(255);
strokeWeight(1);
circle(x, y, miniRadius * 2);
// Number in center (1-indexed)
fill(255);
noStroke();
textAlign(CENTER, CENTER);
textSize(12);
text(i + 1, x, y);
}
// Draw curved arrows for topology connections (not wrapping back to first)
if (topology.length < 2) return;
for (let i = 0; i < topology.length - 1; i++) {
let fromCircle = topology[i].circle;
let toCircle = topology[i + 1].circle;
let fromType = topology[i].type;
let fromX = startX + fromCircle * spacing;
let toX = startX + toCircle * spacing;
let y = startY;
// Curve direction: inner goes up (negative), outer goes down (positive)
// Curve height based on layer - higher layer = more curved
let dist = abs(toX - fromX);
let layer = topology[i].layer || 0;
let baseHeight = max(20, dist * 0.3);
let layerBonus = layer * 12; // extra height per layer
let curveHeight = baseHeight + layerBonus;
let curveDir = fromType === "inner" ? -1 : 1; // -1 = up, 1 = down
// Arrow color - orange
let arrowColor = color(255, 160, 70);
stroke(arrowColor);
strokeWeight(1);
noFill();
// Draw bezier curve
let cp1x = fromX + (toX - fromX) * 0.3;
let cp2x = fromX + (toX - fromX) * 0.7;
let cpy = y + curveDir * curveHeight;
let startYOffset = curveDir * miniRadius;
let endYOffset = curveDir * miniRadius;
bezier(fromX, y + startYOffset, cp1x, cpy, cp2x, cpy, toX, y + endYOffset);
// Draw arrowhead at destination
let angle = atan2((y + endYOffset) - cpy, toX - cp2x);
let arrowSize = 6;
fill(arrowColor);
noStroke();
push();
translate(toX, y + endYOffset);
rotate(angle);
triangle(0, 0, -arrowSize, -arrowSize/2, -arrowSize, arrowSize/2);
pop();
}
// Reset text alignment
textAlign(LEFT, BASELINE);
}
// Draw animated dots along the belt to show direction
function drawBeltDots(belt) {
let numDots = 8;
fill(255, 200, 120); // lighter orange for dots
noStroke();
// Calculate length of each segment
let segmentLengths = belt.segments.map((seg) => {
if (seg.type === "line") {
return p5.Vector.dist(seg.from, seg.to);
} else {
return seg.radius * getArcLength(seg.fromAngle, seg.toAngle, seg.cw);
}
});
let totalLen = segmentLengths.reduce((a, b) => a + b, 0);
// Build cumulative boundaries
let boundaries = [0];
for (let len of segmentLengths) {
boundaries.push(boundaries[boundaries.length - 1] + len / totalLen);
}
for (let i = 0; i < numDots; i++) {
let t = (beltPhase + i / numDots) % 1;
// Find which segment this t falls into
let segIdx = 0;
for (let j = 0; j < belt.segments.length; j++) {
if (t < boundaries[j + 1]) {
segIdx = j;
break;
}
}
let seg = belt.segments[segIdx];
let localT = (t - boundaries[segIdx]) / (boundaries[segIdx + 1] - boundaries[segIdx]);
let pos;
if (seg.type === "line") {
pos = p5.Vector.lerp(seg.from, seg.to, localT);
} else {
// Arc - interpolate angle based on direction
let angle;
if (seg.cw) {
// CW: angle decreases from fromAngle to toAngle
let arcLen = getArcLength(seg.fromAngle, seg.toAngle, false);
angle = seg.fromAngle + localT * arcLen;
} else {
// CCW: angle increases from fromAngle to toAngle
let arcLen = getArcLength(seg.fromAngle, seg.toAngle, true);
angle = seg.fromAngle - localT * arcLen;
}
pos = createVector(seg.center.x + seg.radius * cos(angle), seg.center.y + seg.radius * sin(angle));
}
circle(pos.x, pos.y, 10);
}
}
// Debug visualization of angle directions at each arc
function drawAngleDebug(belt) {
for (let i = 0; i < belt.segments.length; i++) {
let seg = belt.segments[i];
if (seg.type !== "arc") continue;
let prevSegment = belt.segments.at(i - 1);
let nextSegment = belt.segments.at((i + 1) % belt.segments.length);
// Two rays that may form an angle:
// Ray 1: prev.from → prev.to (extending beyond)
// Ray 2: next.to → next.from (extending beyond)
let ray1Dir = p5.Vector.sub(prevSegment.to, prevSegment.from);
let ray2Dir = p5.Vector.sub(nextSegment.from, nextSegment.to);
let intersection = intersectRays(prevSegment.from, ray1Dir, nextSegment.to, ray2Dir);
// Draw the ray extensions (beyond the segment endpoints)
let rayLen = 300;
let ray1Ext = p5.Vector.add(prevSegment.to, p5.Vector.mult(ray1Dir.copy().normalize(), rayLen));
let ray2Ext = p5.Vector.add(nextSegment.from, p5.Vector.mult(ray2Dir.copy().normalize(), rayLen));
stroke(0, 255, 255, 150); // cyan for ray 1 extension (beyond prev.to)
strokeWeight(1);
line(prevSegment.to.x, prevSegment.to.y, ray1Ext.x, ray1Ext.y);
stroke(255, 0, 255, 150); // magenta for ray 2 extension (beyond next.from)
line(nextSegment.from.x, nextSegment.from.y, ray2Ext.x, ray2Ext.y);
if (!intersection) {
// No intersection, no angle - just show label
fill(100);
textSize(12);
text("no angle", prevSegment.to.x + 10, prevSegment.to.y - 10);
text(seg.cw ? "inner" : "outer", prevSegment.to.x + 10, prevSegment.to.y + 5);
continue;
}
// Direction for left/right: path prev.from → prev.to → next.from → next.to
let dir1 = p5.Vector.sub(prevSegment.to, prevSegment.from);
let dir2 = p5.Vector.sub(nextSegment.to, nextSegment.from);
let cross = dir1.x * dir2.y - dir1.y * dir2.x;
let pointsLeft = cross > 0;
// Draw intersection point (if on screen) or label near arc
let labelPos = intersection.copy();
let meetPoint = prevSegment.to;
let distToMeet = p5.Vector.dist(intersection, meetPoint);
// If intersection is too far, place label near the meeting point instead
if (
distToMeet > 200 ||
intersection.x < 0 ||
intersection.x > width ||
intersection.y < 0 ||
intersection.y > height
) {
labelPos = meetPoint.copy();
labelPos.x += 15;
labelPos.y -= 15;
} else {
// Draw intersection point
fill(255, 255, 0);
noStroke();
circle(intersection.x, intersection.y, 10);
}
// Label with L/R
fill(255);
textSize(12);
text(pointsLeft ? "L" : "R", labelPos.x + 8, labelPos.y - 8);
text(seg.cw ? "inner" : "outer", labelPos.x + 8, labelPos.y + 8);
}
}
// Remove circles from topology if their edges form a wrong-pointing angle
// Two rays: prev.from→prev.to and next.to→next.from
// If they intersect, check if angle points left or right
// Belt is CW: left = outside, right = inside
// - Inner circles: angle should point left; remove if pointing right
// - Outer circles: angle should point right; remove if pointing left
function removeCollapsedCircles(belt) {
let toRemove = [];
for (let i = 0; i < belt.segments.length; i++) {
let seg = belt.segments[i];
if (seg.type !== "arc") continue;
let topoIdx = findTopologyIndex(seg.topoId);
if (topoIdx === -1) continue;
let arcLayer = topology[topoIdx].layer || 0;
let prevSegment = belt.segments.at(i - 1);
let nextSegment = belt.segments.at((i + 1) % belt.segments.length);
// Two rays that may form an angle:
// Ray 1: prev.from → prev.to (extending beyond)
// Ray 2: next.to → next.from (extending beyond)
let ray1Dir = p5.Vector.sub(prevSegment.to, prevSegment.from);
let ray2Dir = p5.Vector.sub(nextSegment.from, nextSegment.to);
let intersection = intersectRays(prevSegment.from, ray1Dir, nextSegment.to, ray2Dir);
// No intersection means no angle - don't remove
if (!intersection) continue;
// Direction for left/right: path prev.from → prev.to → next.from → next.to
let dir1 = p5.Vector.sub(prevSegment.to, prevSegment.from);
let dir2 = p5.Vector.sub(nextSegment.to, nextSegment.from);
let cross = dir1.x * dir2.y - dir1.y * dir2.x;
let pointsLeft = cross > 0;
let pointsRight = cross < 0;
// Inner (CW arc): should point left; remove if pointing right
// Outer (CCW arc): should point right; remove if pointing left
let shouldRemove = seg.cw ? pointsRight : pointsLeft;
if (shouldRemove) {
logThrottled(
"remove-" + seg.topoId,
500,
`removing ${seg.circleColor} (topoId=${seg.topoId}, layer=${arcLayer}) at index ${topoIdx} because it's ${
seg.cw ? "inner" : "outer"
} and angle points ${pointsLeft ? "left" : "right"}`
);
toRemove.push(topoIdx);
}
}
let removed = toRemove.length > 0;
if (removed) {
logThrottled("before-remove", 500, "before removing", JSON.parse(JSON.stringify(topology)));
}
// Remove in reverse order to maintain indices
toRemove.sort((a, b) => b - a);
for (let idx of toRemove) {
// Don't remove if it would leave less than 2 circles
if (topology.length > 2) {
topology.splice(idx, 1);
}
}
if (removed) {
logThrottled("after-remove", 500, "after removing", JSON.parse(JSON.stringify(topology)));
}
}
// Find the topology index for an arc segment using its unique ID
function findTopologyIndex(topoId) {
for (let i = 0; i < topology.length; i++) {
if (topology[i].id === topoId) {
return i;
}
}
return -1;
}
// Insert a circle into the topology when it collides with an edge
function insertCircleIntoTopology(circleIdx, seg) {
// Find the position in topology where this edge is
// The edge goes from seg.circleFrom to seg.circleTo
let insertPos = -1;
for (let i = 0; i < topology.length; i++) {
let curr = topology[i].circle;
let next = topology[(i + 1) % topology.length].circle;
if (curr === seg.circleFrom && next === seg.circleTo) {
// Check if this circle is already inserted at this exact position
// (i.e., is the next entry already this circle?)
if (next === circleIdx) return;
// Also check if current is this circle (already inserted before this edge)
if (curr === circleIdx) return;
insertPos = i + 1;
break;
}
}
if (insertPos === -1) return;
// Determine if circle is inside or outside the belt
// Use cross product: (edge direction) × (vector to circle center)
let edgeDir = p5.Vector.sub(seg.to, seg.from);
let toCircle = p5.Vector.sub(circles[circleIdx].pos, seg.from);
let cross = edgeDir.x * toCircle.y - edgeDir.y * toCircle.x;
// For CW belt: positive cross = right side = inside = "inner"
// negative cross = left side = outside = "outer"
let type = cross > 0 ? "inner" : "outer";
// Calculate layer: one higher than the max existing layer
let maxLayer = Math.max(...topology.map((t) => t.layer || 0));
let newLayer = maxLayer + 1;
// Insert into topology with unique ID
topology.splice(insertPos, 0, { id: nextTopoId++, circle: circleIdx, type: type, layer: newLayer });
}
// Compute belt as array of directed segments from topology
// topology: array of {circle: index, type: "inner"|"outer"}
// circles: array of {pos, radius} objects
function computeBelt(topology, circles) {
if (topology.length < 2) return null;
let segments = [];
let n = topology.length;
// Calculate effective radii based on topology entry's layer
// For each circle, count how many entries have lower layer numbers
// This ensures consecutive stacking even if global layer numbers have gaps
let effectiveRadii = []; // effective radius for each topology entry
for (let i = 0; i < n; i++) {
let circleIdx = topology[i].circle;
let myLayer = topology[i].layer || 0;
// Count how many entries for this same circle have a lower layer
let lowerLayers = 0;
for (let j = 0; j < n; j++) {
if (topology[j].circle === circleIdx && (topology[j].layer || 0) < myLayer) {
lowerLayers++;
}
}
let baseRadius = circles[circleIdx].radius;
effectiveRadii[i] = baseRadius + BELT_OFFSET * (1 + lowerLayers);
// Debug: log effective radius info (uncomment to enable)
// logThrottled('eff-' + i, 1000, `Topo[${i}]: circle=${circleIdx} (${circles[circleIdx].color}), layer=${myLayer}, lowerLayers=${lowerLayers}, base=${baseRadius}, effective=${effectiveRadii[i]}`);
}
// Compute all tangents between consecutive circles in topology
let tangents = [];
for (let i = 0; i < n; i++) {
let currTopo = topology[i];
let nextTopo = topology[(i + 1) % n];
let curr = circles[currTopo.circle];
let next = circles[nextTopo.circle];
// Compute tangent based on both source and destination types
// Use effective radii instead of raw circle radii
let tangent = computeTangent(
curr.pos,
effectiveRadii[i],
next.pos,
effectiveRadii[(i + 1) % n],
currTopo.type,
nextTopo.type
);
if (!tangent) return null;
tangents.push(tangent);
}
// Build segments: arc, line, arc, line, ...
for (let i = 0; i < n; i++) {
let currTopo = topology[i];
let nextTopo = topology[(i + 1) % n];
let curr = circles[currTopo.circle];
let tangentOut = tangents[i]; // tangent leaving curr
let tangentIn = tangents[(i - 1 + n) % n]; // tangent arriving at curr
// Arc direction: CW for inner, CCW for outer
let arcCW = currTopo.type === "inner";
// Arc on current circle: from incoming tangent to outgoing tangent
// Use effective radius for this topology entry
segments.push({
type: "arc",
center: curr.pos,
radius: effectiveRadii[i],
fromAngle: tangentIn.angleEnd, // where we arrived (end of previous tangent)
toAngle: tangentOut.angleStart, // where we leave (start of next tangent)
cw: arcCW,
circleIdx: currTopo.circle,
circleColor: curr.color,
topoId: currTopo.id, // unique ID for bulletproof lookup
});
// Line from curr to next
segments.push({
type: "line",
from: tangentOut.p1.copy(),
to: tangentOut.p2.copy(),
circleFrom: currTopo.circle,
circleTo: nextTopo.circle,
});
}
// Calculate total length
let totalLength = 0;
for (let seg of segments) {
if (seg.type === "line") {
totalLength += p5.Vector.dist(seg.from, seg.to);
} else {
totalLength += seg.radius * getArcLength(seg.fromAngle, seg.toAngle, seg.cw);
}
}
return {
segments: segments,
length: totalLength,
};
}
// Compute tangent from circle 1 to circle 2
// type1, type2: "inner" or "outer" for each circle
function computeTangent(c1, r1, c2, r2, type1, type2) {
let dx = c2.x - c1.x;
let dy = c2.y - c1.y;
let d = sqrt(dx * dx + dy * dy);
let theta = atan2(dy, dx);
let angle1, angle2;
if (type1 === type2) {
// Same type: external tangent (both points on same side)
if (d <= abs(r1 - r2) + 0.001) return null;
let sinAlpha = (r1 - r2) / d;
if (abs(sinAlpha) > 1) return null;
let alpha = asin(sinAlpha);
if (type1 === "inner") {
// Right-hand tangent
angle1 = theta - HALF_PI + alpha;
angle2 = theta - HALF_PI + alpha;
} else {
// Left-hand tangent
angle1 = theta + HALF_PI - alpha;
angle2 = theta + HALF_PI - alpha;
}
} else {
// Different types: internal tangent (points on opposite sides)
if (d <= r1 + r2 + 0.001) return null;
let sinAlpha = (r1 + r2) / d;
if (abs(sinAlpha) > 1) return null;
let alpha = asin(sinAlpha);
if (type1 === "inner") {
// inner -> outer: leave right, arrive left
angle1 = theta - HALF_PI + alpha;
angle2 = theta + HALF_PI + alpha;
} else {
// outer -> inner: leave left, arrive right
angle1 = theta + HALF_PI - alpha;
angle2 = theta - HALF_PI - alpha;
}
}
let p1 = createVector(c1.x + r1 * cos(angle1), c1.y + r1 * sin(angle1));
let p2 = createVector(c2.x + r2 * cos(angle2), c2.y + r2 * sin(angle2));
return { p1, p2, angleStart: angle1, angleEnd: angle2 };
}
// Check if a segment collides with a circle
function segmentCircleCollision(seg, circ) {
if (seg.type === "line") {
return lineCircleCollision(seg.from, seg.to, circ.pos, circ.radius);
} else {
return arcCircleCollision(seg, circ.pos, circ.radius);
}
}
// Check if line segment collides with circle
function lineCircleCollision(a, b, center, radius) {
let ab = p5.Vector.sub(b, a);
let ac = p5.Vector.sub(center, a);
let abLenSq = ab.magSq();
if (abLenSq === 0) {
return p5.Vector.dist(a, center) < radius;
}
let t = constrain(ab.dot(ac) / abLenSq, 0, 1);
let closest = p5.Vector.add(a, p5.Vector.mult(ab, t));
return p5.Vector.dist(closest, center) < radius;
}
// Check if arc segment collides with circle
function arcCircleCollision(arcSeg, center, radius) {
// Distance from arc center to obstacle center
let d = p5.Vector.dist(arcSeg.center, center);
// Check if obstacle is within the "donut" ring of the arc
let innerR = arcSeg.radius - radius;
let outerR = arcSeg.radius + radius;
if (d > outerR || d < innerR) {
return false; // Too far or too close
}
// Check if obstacle is within the arc's angular span
let angleToCenter = atan2(center.y - arcSeg.center.y, center.x - arcSeg.center.x);
// The rendered arc goes CCW from fromAngle to toAngle (for cw=true)
// or CCW from toAngle to fromAngle (for cw=false)
if (arcSeg.cw) {
return isAngleInCCWArc(angleToCenter, arcSeg.fromAngle, arcSeg.toAngle);
} else {
return isAngleInCCWArc(angleToCenter, arcSeg.toAngle, arcSeg.fromAngle);
}
}
// Check if angle is within CCW arc span from 'from' to 'to'
function isAngleInCCWArc(angle, fromAngle, toAngle) {
// Normalize all angles to [0, TWO_PI)
angle = ((angle % TWO_PI) + TWO_PI) % TWO_PI;
let from = ((fromAngle % TWO_PI) + TWO_PI) % TWO_PI;
let to = ((toAngle % TWO_PI) + TWO_PI) % TWO_PI;
// CCW: going from 'from' increasing to 'to'
if (to >= from) {
return angle >= from && angle <= to;
} else {
// Wraps around 0
return angle >= from || angle <= to;
}
}
// Get arc length for CW or CCW traversal
function getArcLength(fromAngle, toAngle, cw) {
let diff;
if (cw) {
// CW: going from fromAngle decreasing to toAngle
diff = fromAngle - toAngle;
while (diff < 0) diff += TWO_PI;
} else {
// CCW: going from fromAngle increasing to toAngle
diff = toAngle - fromAngle;
while (diff < 0) diff += TWO_PI;
}
return diff;
}
/**
* Checks if two rays intersect in 2D space.
* @param {p5.Vector} origin1 - Start point of the first ray.
* @param {p5.Vector} direction1 - Direction vector of the first ray.
* @param {p5.Vector} origin2 - Start point of the second ray.
* @param {p5.Vector} direction2 - Direction vector of the second ray.
* @returns {p5.Vector | null} The intersection point if they intersect, otherwise null.
*/
function intersectRays(origin1, direction1, origin2, direction2) {
// Line intercept math by Paul Bourke (adapted)
// http://paulbourke.net/geometry/pointlineplane/
let x1 = origin1.x,
y1 = origin1.y;
let x2 = origin1.x + direction1.x,
y2 = origin1.y + direction1.y; // A point along the first ray
let x3 = origin2.x,
y3 = origin2.y;
let x4 = origin2.x + direction2.x,
y4 = origin2.y + direction2.y; // A point along the second ray
// Calculate the denominator for the intersection point formula
let denominator = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1);
// If the denominator is 0, lines are parallel or collinear
if (denominator === 0) {
return null;
}
// Calculate the parameters ua and ub for the intersection point
let ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denominator;
let ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denominator;
// Check if the intersection point lies on both rays
// For rays, we only care if the parameters are positive (ua >= 0 and ub >= 0)
if (ua >= 0 && ub >= 0) {
// Return the intersection point as a p5.Vector
let intersectionX = x1 + ua * (x2 - x1);
let intersectionY = y1 + ua * (y2 - y1);
return createVector(intersectionX, intersectionY);
} else {
// Intersection point is outside the bounds of one or both rays
return null;
}
}
// Interaction
let dragging = null;
let dragOffset = null;
function mousePressed() {
// Check if clicking on existing circle
for (let circle of circles) {
if (dist(mouseX, mouseY, circle.pos.x, circle.pos.y) < circle.radius) {
dragging = circle;
dragOffset = createVector(circle.pos.x - mouseX, circle.pos.y - mouseY);
return;
}
}
// Click on empty area - spawn new circle
circles.push({
pos: createVector(mouseX, mouseY),
radius: 35,
});
}
function mouseDragged() {
if (dragging) {
dragging.pos.x = mouseX + dragOffset.x;
dragging.pos.y = mouseY + dragOffset.y;
}
}
function mouseReleased() {
dragging = null;
}
function doubleClicked() {
// Find clicked circle
for (let i = circles.length - 1; i >= 0; i--) {
if (dist(mouseX, mouseY, circles[i].pos.x, circles[i].pos.y) < circles[i].radius) {
// Remove topology entries referencing this circle
topology = topology.filter(t => t.circle !== i);
// Update topology entries with higher circle indices
for (let t of topology) {
if (t.circle > i) t.circle--;
}
// Remove the circle
circles.splice(i, 1);
return;
}
}
}
function mouseWheel(event) {
for (let circle of circles) {
if (dist(mouseX, mouseY, circle.pos.x, circle.pos.y) < circle.radius + 20) {
circle.radius = constrain(circle.radius - event.delta * 0.1, 20, 200);
return false;
}
}
}
function keyPressed() {
if (key === "d" || key === "D") {
debugMode = (debugMode + 1) % 3; // cycle: 0 → 1 → 2 → 0
}
}
function windowResized() {
resizeCanvas(windowWidth, windowHeight);
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment