Created
December 4, 2025 09:34
-
-
Save mlshv/6cc30dd91e02dd34296b13cc7d39431c to your computer and use it in GitHub Desktop.
collision detection with belt/pulley problem math in p5js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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