Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Interactive Bezier-Bands Tool</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.4.0/p5.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/control-p5/1.6.0/controlP5.min.js"></script> | |
| <style> | |
| .band-fill-0 { fill: rgba(255, 99, 132, 0.3); } | |
| .band-fill-1 { fill: rgba(54, 162, 235, 0.3); } | |
| .band-fill-2 { fill: rgba(255, 206, 86, 0.3); } | |
| .band-fill-3 { fill: rgba(75, 192, 192, 0.3); } | |
| .band-fill-4 { fill: rgba(153, 102, 255, 0.3); } | |
| .band-stroke-0 { stroke: rgba(255, 99, 132, 1); } | |
| .band-stroke-1 { stroke: rgba(54, 162, 235, 1); } | |
| .band-stroke-2 { stroke: rgba(255, 206, 86, 1); } | |
| .band-stroke-3 { stroke: rgba(75, 192, 192, 1); } | |
| .band-stroke-4 { stroke: rgba(153, 102, 255, 1); } | |
| #canvas-container { | |
| position: relative; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| #processing-canvas { | |
| width: 100%; | |
| height: 100%; | |
| } | |
| .tooltip { | |
| position: relative; | |
| display: inline-block; | |
| } | |
| .tooltip .tooltiptext { | |
| visibility: hidden; | |
| width: 200px; | |
| background-color: #555; | |
| color: #fff; | |
| text-align: center; | |
| border-radius: 6px; | |
| padding: 5px; | |
| position: absolute; | |
| z-index: 1; | |
| bottom: 125%; | |
| left: 50%; | |
| margin-left: -100px; | |
| opacity: 0; | |
| transition: opacity 0.3s; | |
| } | |
| .tooltip:hover .tooltiptext { | |
| visibility: visible; | |
| opacity: 1; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-100 h-screen flex flex-col"> | |
| <div class="bg-indigo-700 text-white p-4 shadow-md"> | |
| <h1 class="text-2xl font-bold">Interactive Bezier-Bands Tool</h1> | |
| <p class="text-sm opacity-80">Create smooth curves with offset bands and transformations</p> | |
| </div> | |
| <div class="flex flex-1 overflow-hidden"> | |
| <!-- Control Panel --> | |
| <div class="w-80 bg-white p-4 shadow-md overflow-y-auto"> | |
| <div class="space-y-6"> | |
| <div> | |
| <h2 class="text-lg font-semibold mb-2 border-b pb-2">Curve Parameters</h2> | |
| <div class="space-y-4"> | |
| <div> | |
| <label for="numCurves" class="block text-sm font-medium text-gray-700 tooltip"> | |
| Number of Curves (N) | |
| <span class="tooltiptext">Total number of curves including the base curve (min 2, max 50)</span> | |
| </label> | |
| <input type="range" id="numCurves" min="2" max="50" value="10" class="w-full mt-1"> | |
| <div class="flex justify-between text-xs text-gray-500"> | |
| <span>2</span> | |
| <span id="numCurvesValue">10</span> | |
| <span>50</span> | |
| </div> | |
| </div> | |
| <div> | |
| <label for="offsetSpacing" class="block text-sm font-medium text-gray-700 tooltip"> | |
| Offset Spacing (D) | |
| <span class="tooltiptext">Distance between each offset curve in pixels</span> | |
| </label> | |
| <input type="range" id="offsetSpacing" min="1" max="50" value="20" class="w-full mt-1"> | |
| <div class="flex justify-between text-xs text-gray-500"> | |
| <span>1</span> | |
| <span id="offsetSpacingValue">20</span> | |
| <span>50</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div> | |
| <h2 class="text-lg font-semibold mb-2 border-b pb-2">Base Transform</h2> | |
| <div class="space-y-4"> | |
| <div> | |
| <label for="baseOffsetX" class="block text-sm font-medium text-gray-700 tooltip"> | |
| Base X Offset | |
| <span class="tooltiptext">Initial X offset applied to the first band</span> | |
| </label> | |
| <input type="range" id="baseOffsetX" min="-100" max="100" value="0" class="w-full mt-1"> | |
| <div class="flex justify-between text-xs text-gray-500"> | |
| <span>-100</span> | |
| <span id="baseOffsetXValue">0</span> | |
| <span>100</span> | |
| </div> | |
| </div> | |
| <div> | |
| <label for="baseOffsetY" class="block text-sm font-medium text-gray-700 tooltip"> | |
| Base Y Offset | |
| <span class="tooltiptext">Initial Y offset applied to the first band</span> | |
| </label> | |
| <input type="range" id="baseOffsetY" min="-100" max="100" value="0" class="w-full mt-1"> | |
| <div class="flex justify-between text-xs text-gray-500"> | |
| <span>-100</span> | |
| <span id="baseOffsetYValue">0</span> | |
| <span>100</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div> | |
| <h2 class="text-lg font-semibold mb-2 border-b pb-2">Incremental Transform</h2> | |
| <div class="space-y-4"> | |
| <div> | |
| <label for="deltaOffsetX" class="block text-sm font-medium text-gray-700 tooltip"> | |
| X Offset Increment | |
| <span class="tooltiptext">Additional X offset added to each subsequent band</span> | |
| </label> | |
| <input type="range" id="deltaOffsetX" min="-20" max="20" value="0" class="w-full mt-1"> | |
| <div class="flex justify-between text-xs text-gray-500"> | |
| <span>-20</span> | |
| <span id="deltaOffsetXValue">0</span> | |
| <span>20</span> | |
| </div> | |
| </div> | |
| <div> | |
| <label for="deltaOffsetY" class="block text-sm font-medium text-gray-700 tooltip"> | |
| Y Offset Increment | |
| <span class="tooltiptext">Additional Y offset added to each subsequent band</span> | |
| </label> | |
| <input type="range" id="deltaOffsetY" min="-20" max="20" value="0" class="w-full mt-1"> | |
| <div class="flex justify-between text-xs text-gray-500"> | |
| <span>-20</span> | |
| <span id="deltaOffsetYValue">0</span> | |
| <span>20</span> | |
| </div> | |
| </div> | |
| <div> | |
| <label for="deltaRotationDeg" class="block text-sm font-medium text-gray-700 tooltip"> | |
| Rotation Increment (deg) | |
| <span class="tooltiptext">Additional rotation applied to each subsequent band in degrees</span> | |
| </label> | |
| <input type="range" id="deltaRotationDeg" min="-15" max="15" value="0" class="w-full mt-1"> | |
| <div class="flex justify-between text-xs text-gray-500"> | |
| <span>-15</span> | |
| <span id="deltaRotationDegValue">0</span> | |
| <span>15</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div> | |
| <h2 class="text-lg font-semibold mb-2 border-b pb-2">Anchor Point</h2> | |
| <div class="flex items-center space-x-2"> | |
| <input type="radio" id="anchorCentroid" name="anchorType" value="centroid" checked class="h-4 w-4 text-indigo-600"> | |
| <label for="anchorCentroid" class="text-sm font-medium text-gray-700">Centroid</label> | |
| <input type="radio" id="anchorBounds" name="anchorType" value="bounds" class="h-4 w-4 text-indigo-600"> | |
| <label for="anchorBounds" class="text-sm font-medium text-gray-700">Bounds Center</label> | |
| </div> | |
| </div> | |
| <div class="space-y-2"> | |
| <h2 class="text-lg font-semibold mb-2 border-b pb-2">Actions</h2> | |
| <button id="exportSVG" class="w-full bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700 transition"> | |
| Export SVG | |
| </button> | |
| <button id="savePNG" class="w-full bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700 transition"> | |
| Save PNG | |
| </button> | |
| <button id="clearPoints" class="w-full bg-red-600 text-white py-2 px-4 rounded hover:bg-red-700 transition"> | |
| Clear Points | |
| </button> | |
| <button id="resetView" class="w-full bg-gray-600 text-white py-2 px-4 rounded hover:bg-gray-700 transition"> | |
| Reset View | |
| </button> | |
| </div> | |
| <div class="text-xs text-gray-500"> | |
| <p><strong>Instructions:</strong></p> | |
| <ul class="list-disc pl-5 space-y-1 mt-1"> | |
| <li>Left-click to add points</li> | |
| <li>Drag points to move them</li> | |
| <li>Right-click a point to delete it</li> | |
| <li>Press 'C' to clear all points</li> | |
| </ul> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Canvas Area --> | |
| <div class="flex-1 bg-gray-200 relative"> | |
| <div id="canvas-container"> | |
| <div id="processing-canvas"></div> | |
| </div> | |
| <div id="status-message" class="absolute top-4 left-4 bg-white bg-opacity-90 p-2 rounded shadow-md hidden"> | |
| <p id="status-text" class="text-sm"></p> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // Global variables | |
| let points = []; | |
| let selectedPoint = null; | |
| let baseCurve = []; | |
| let offsetCurves = []; | |
| let bands = []; | |
| let viewOffset = { x: 0, y: 0 }; | |
| let viewScale = 1; | |
| let isDragging = false; | |
| let lastMouseX, lastMouseY; | |
| // DOM elements | |
| const numCurvesSlider = document.getElementById('numCurves'); | |
| const offsetSpacingSlider = document.getElementById('offsetSpacing'); | |
| const baseOffsetXSlider = document.getElementById('baseOffsetX'); | |
| const baseOffsetYSlider = document.getElementById('baseOffsetY'); | |
| const deltaOffsetXSlider = document.getElementById('deltaOffsetX'); | |
| const deltaOffsetYSlider = document.getElementById('deltaOffsetY'); | |
| const deltaRotationDegSlider = document.getElementById('deltaRotationDeg'); | |
| const exportSVGBtn = document.getElementById('exportSVG'); | |
| const savePNGBtn = document.getElementById('savePNG'); | |
| const clearPointsBtn = document.getElementById('clearPoints'); | |
| const resetViewBtn = document.getElementById('resetView'); | |
| const statusMessage = document.getElementById('status-message'); | |
| const statusText = document.getElementById('status-text'); | |
| // Display values | |
| const numCurvesValue = document.getElementById('numCurvesValue'); | |
| const offsetSpacingValue = document.getElementById('offsetSpacingValue'); | |
| const baseOffsetXValue = document.getElementById('baseOffsetXValue'); | |
| const baseOffsetYValue = document.getElementById('baseOffsetYValue'); | |
| const deltaOffsetXValue = document.getElementById('deltaOffsetXValue'); | |
| const deltaOffsetYValue = document.getElementById('deltaOffsetYValue'); | |
| const deltaRotationDegValue = document.getElementById('deltaRotationDegValue'); | |
| // Update display values when sliders change | |
| numCurvesSlider.addEventListener('input', () => { | |
| numCurvesValue.textContent = numCurvesSlider.value; | |
| updateCurves(); | |
| }); | |
| offsetSpacingSlider.addEventListener('input', () => { | |
| offsetSpacingValue.textContent = offsetSpacingSlider.value; | |
| updateCurves(); | |
| }); | |
| baseOffsetXSlider.addEventListener('input', () => { | |
| baseOffsetXValue.textContent = baseOffsetXSlider.value; | |
| updateCurves(); | |
| }); | |
| baseOffsetYSlider.addEventListener('input', () => { | |
| baseOffsetYValue.textContent = baseOffsetYSlider.value; | |
| updateCurves(); | |
| }); | |
| deltaOffsetXSlider.addEventListener('input', () => { | |
| deltaOffsetXValue.textContent = deltaOffsetXSlider.value; | |
| updateCurves(); | |
| }); | |
| deltaOffsetYSlider.addEventListener('input', () => { | |
| deltaOffsetYValue.textContent = deltaOffsetYSlider.value; | |
| updateCurves(); | |
| }); | |
| deltaRotationDegSlider.addEventListener('input', () => { | |
| deltaRotationDegValue.textContent = deltaRotationDegSlider.value; | |
| updateCurves(); | |
| }); | |
| // Button event listeners | |
| clearPointsBtn.addEventListener('click', () => { | |
| points = []; | |
| updateCurves(); | |
| showStatus("All points cleared"); | |
| }); | |
| resetViewBtn.addEventListener('click', () => { | |
| viewOffset = { x: 0, y: 0 }; | |
| viewScale = 1; | |
| updateCurves(); | |
| showStatus("View reset"); | |
| }); | |
| exportSVGBtn.addEventListener('click', () => { | |
| showStatus("SVG export would be implemented in Processing"); | |
| }); | |
| savePNGBtn.addEventListener('click', () => { | |
| showStatus("PNG save would be implemented in Processing"); | |
| }); | |
| // Show status message | |
| function showStatus(message, duration = 3000) { | |
| statusText.textContent = message; | |
| statusMessage.classList.remove('hidden'); | |
| if (duration > 0) { | |
| setTimeout(() => { | |
| statusMessage.classList.add('hidden'); | |
| }, duration); | |
| } | |
| } | |
| // Initialize p5.js sketch | |
| const sketch = (p) => { | |
| p.setup = () => { | |
| const canvas = p.createCanvas(p.windowWidth - 320, p.windowHeight - 60); | |
| canvas.parent('processing-canvas'); | |
| p.background(240); | |
| p.noFill(); | |
| p.stroke(0); | |
| p.strokeWeight(1); | |
| // Handle mouse events | |
| canvas.mousePressed((e) => { | |
| if (e.button === 0) { // Left click | |
| handleLeftClick(p.mouseX, p.mouseY); | |
| } | |
| }); | |
| canvas.mouseReleased(() => { | |
| selectedPoint = null; | |
| }); | |
| canvas.mouseMoved(() => { | |
| if (!isDragging) { | |
| checkPointHover(p.mouseX, p.mouseY); | |
| } | |
| }); | |
| canvas.mouseDragged((e) => { | |
| if (e.button === 0 && selectedPoint) { | |
| // Move the selected point | |
| selectedPoint.x = (p.mouseX - viewOffset.x) / viewScale; | |
| selectedPoint.y = (p.mouseY - viewOffset.y) / viewScale; | |
| updateCurves(); | |
| } else if (e.button === 0) { | |
| // Pan the view | |
| if (!isDragging) { | |
| isDragging = true; | |
| lastMouseX = p.mouseX; | |
| lastMouseY = p.mouseY; | |
| } else { | |
| const dx = p.mouseX - lastMouseX; | |
| const dy = p.mouseY - lastMouseY; | |
| viewOffset.x += dx; | |
| viewOffset.y += dy; | |
| lastMouseX = p.mouseX; | |
| lastMouseY = p.mouseY; | |
| p.redraw(); | |
| } | |
| } | |
| }); | |
| canvas.mouseWheel((e) => { | |
| // Zoom the view | |
| const zoomFactor = e.delta > 0 ? 0.9 : 1.1; | |
| const mouseX = p.mouseX; | |
| const mouseY = p.mouseY; | |
| // Calculate the mouse position in world coordinates | |
| const worldX = (mouseX - viewOffset.x) / viewScale; | |
| const worldY = (mouseY - viewOffset.y) / viewScale; | |
| // Apply zoom | |
| viewScale *= zoomFactor; | |
| // Adjust the offset so the zoom is centered on the mouse | |
| viewOffset.x = mouseX - worldX * viewScale; | |
| viewOffset.y = mouseY - worldY * viewScale; | |
| p.redraw(); | |
| return false; | |
| }); | |
| // Handle right click (context menu) | |
| canvas.elt.addEventListener('contextmenu', (e) => { | |
| e.preventDefault(); | |
| handleRightClick(p.mouseX, p.mouseY); | |
| return false; | |
| }); | |
| // Handle keyboard events | |
| document.addEventListener('keydown', (e) => { | |
| if (e.key === 'c' || e.key === 'C') { | |
| points = []; | |
| updateCurves(); | |
| showStatus("All points cleared"); | |
| } else if ((e.key === 'Backspace' || e.key === 'Delete') && selectedPoint) { | |
| points = points.filter(p => p !== selectedPoint); | |
| selectedPoint = null; | |
| updateCurves(); | |
| showStatus("Point deleted"); | |
| } | |
| }); | |
| }; | |
| p.draw = () => { | |
| p.background(240); | |
| p.push(); | |
| p.translate(viewOffset.x, viewOffset.y); | |
| p.scale(viewScale); | |
| // Draw the bands | |
| drawBands(p); | |
| // Draw the base curve | |
| if (baseCurve.length > 0) { | |
| p.stroke(0, 0, 255); | |
| p.strokeWeight(2 / viewScale); | |
| p.noFill(); | |
| p.beginShape(); | |
| for (const pt of baseCurve) { | |
| p.vertex(pt.x, pt.y); | |
| } | |
| p.endShape(); | |
| } | |
| // Draw the control points | |
| for (const pt of points) { | |
| if (pt === selectedPoint) { | |
| p.fill(255, 0, 0); | |
| p.stroke(255, 0, 0); | |
| } else if (pt.hovered) { | |
| p.fill(255, 165, 0); | |
| p.stroke(255, 165, 0); | |
| } else { | |
| p.fill(0); | |
| p.stroke(0); | |
| } | |
| p.strokeWeight(1 / viewScale); | |
| p.ellipse(pt.x, pt.y, 8 / viewScale, 8 / viewScale); | |
| } | |
| p.pop(); | |
| // Reset dragging state when mouse is released | |
| if (!p.mouseIsPressed) { | |
| isDragging = false; | |
| } | |
| }; | |
| p.windowResized = () => { | |
| p.resizeCanvas(p.windowWidth - 320, p.windowHeight - 60); | |
| }; | |
| }; | |
| new p5(sketch); | |
| // Handle left click to add points | |
| function handleLeftClick(mouseX, mouseY) { | |
| // Convert to world coordinates | |
| const worldX = (mouseX - viewOffset.x) / viewScale; | |
| const worldY = (mouseY - viewOffset.y) / viewScale; | |
| // Check if we're clicking near an existing point | |
| let clickedOnPoint = false; | |
| for (const pt of points) { | |
| const d = Math.sqrt((pt.x - worldX) ** 2 + (pt.y - worldY) ** 2); | |
| if (d < 10 / viewScale) { | |
| selectedPoint = pt; | |
| clickedOnPoint = true; | |
| break; | |
| } | |
| } | |
| if (!clickedOnPoint) { | |
| // Add a new point | |
| points.push({ x: worldX, y: worldY, hovered: false }); | |
| updateCurves(); | |
| showStatus("Point added"); | |
| } | |
| } | |
| // Handle right click to delete points | |
| function handleRightClick(mouseX, mouseY) { | |
| // Convert to world coordinates | |
| const worldX = (mouseX - viewOffset.x) / viewScale; | |
| const worldY = (mouseY - viewOffset.y) / viewScale; | |
| // Find the closest point | |
| let closestPoint = null; | |
| let minDist = Infinity; | |
| for (const pt of points) { | |
| const d = Math.sqrt((pt.x - worldX) ** 2 + (pt.y - worldY) ** 2); | |
| if (d < 10 / viewScale && d < minDist) { | |
| closestPoint = pt; | |
| minDist = d; | |
| } | |
| } | |
| if (closestPoint) { | |
| points = points.filter(p => p !== closestPoint); | |
| if (selectedPoint === closestPoint) { | |
| selectedPoint = null; | |
| } | |
| updateCurves(); | |
| showStatus("Point deleted"); | |
| } | |
| } | |
| // Check if mouse is hovering over a point | |
| function checkPointHover(mouseX, mouseY) { | |
| // Convert to world coordinates | |
| const worldX = (mouseX - viewOffset.x) / viewScale; | |
| const worldY = (mouseY - viewOffset.y) / viewScale; | |
| let anyHovered = false; | |
| for (const pt of points) { | |
| const d = Math.sqrt((pt.x - worldX) ** 2 + (pt.y - worldY) ** 2); | |
| pt.hovered = d < 10 / viewScale; | |
| if (pt.hovered) anyHovered = true; | |
| } | |
| document.body.style.cursor = anyHovered ? 'pointer' : 'default'; | |
| } | |
| // Update the curves and bands based on current points and settings | |
| function updateCurves() { | |
| if (points.length < 2) { | |
| baseCurve = []; | |
| offsetCurves = []; | |
| bands = []; | |
| return; | |
| } | |
| // Compute the base curve as a Catmull-Rom spline | |
| computeBaseCurve(); | |
| // Compute offset curves | |
| computeOffsetCurves(); | |
| // Compute bands | |
| computeBands(); | |
| } | |
| // Compute the base curve using Catmull-Rom spline | |
| function computeBaseCurve() { | |
| baseCurve = []; | |
| if (points.length < 2) return; | |
| // Number of samples per segment | |
| const samplesPerSegment = 50; | |
| for (let i = 0; i < points.length - 1; i++) { | |
| const p0 = i === 0 ? points[0] : points[i - 1]; | |
| const p1 = points[i]; | |
| const p2 = points[i + 1]; | |
| const p3 = i === points.length - 2 ? points[points.length - 1] : points[i + 2]; | |
| for (let j = 0; j < samplesPerSegment; j++) { | |
| const t = j / samplesPerSegment; | |
| const point = catmullRom(t, p0, p1, p2, p3); | |
| baseCurve.push(point); | |
| } | |
| } | |
| // Add the last point | |
| baseCurve.push({ x: points[points.length - 1].x, y: points[points.length - 1].y }); | |
| } | |
| // Catmull-Rom interpolation | |
| function catmullRom(t, p0, p1, p2, p3) { | |
| // tension = 0.5 for Catmull-Rom | |
| const tension = 0.5; | |
| const t2 = t * t; | |
| const t3 = t2 * t; | |
| // The matrix coefficients | |
| const m0 = (-tension * t3) + (2 * tension * t2) + (-tension * t); | |
| const m1 = ((2 - tension) * t3) + (tension - 3) * t2 + 1; | |
| const m2 = (tension - 2) * t3 + (3 - 2 * tension) * t2 + tension * t; | |
| const m3 = tension * t3 - tension * t2; | |
| return { | |
| x: m0 * p0.x + m1 * p1.x + m2 * p2.x + m3 * p3.x, | |
| y: m0 * p0.y + m1 * p1.y + m2 * p2.y + m3 * p3.y | |
| }; | |
| } | |
| // Compute offset curves | |
| function computeOffsetCurves() { | |
| offsetCurves = []; | |
| if (baseCurve.length < 2) return; | |
| const numCurves = parseInt(numCurvesSlider.value); | |
| const offsetSpacing = parseInt(offsetSpacingSlider.value); | |
| // First curve is the base curve | |
| offsetCurves.push([...baseCurve]); | |
| // Compute normals for the base curve | |
| const normals = computeNormals(baseCurve); | |
| // Create offset curves | |
| for (let k = 1; k < numCurves; k++) { | |
| const offsetDistance = k * offsetSpacing; | |
| const offsetCurve = []; | |
| for (let i = 0; i < baseCurve.length; i++) { | |
| const pt = baseCurve[i]; | |
| const normal = normals[i]; | |
| offsetCurve.push({ | |
| x: pt.x + normal.x * offsetDistance, | |
| y: pt.y + normal.y * offsetDistance | |
| }); | |
| } | |
| offsetCurves.push(offsetCurve); | |
| } | |
| } | |
| // Compute normals for a polyline | |
| function computeNormals(polyline) { | |
| const normals = []; | |
| for (let i = 0; i < polyline.length; i++) { | |
| let normal; | |
| if (i === 0) { | |
| // First point - use next segment | |
| const dx = polyline[i + 1].x - polyline[i].x; | |
| const dy = polyline[i + 1].y - polyline[i].y; | |
| normal = normalize({ x: -dy, y: dx }); | |
| } else if (i === polyline.length - 1) { | |
| // Last point - use previous segment | |
| const dx = polyline[i].x - polyline[i - 1].x; | |
| const dy = polyline[i].y - polyline[i - 1].y; | |
| normal = normalize({ x: -dy, y: dx }); | |
| } else { | |
| // Middle point - average adjacent segments | |
| const dx1 = polyline[i].x - polyline[i - 1].x; | |
| const dy1 = polyline[i].y - polyline[i - 1].y; | |
| const n1 = normalize({ x: -dy1, y: dx1 }); | |
| const dx2 = polyline[i + 1].x - polyline[i].x; | |
| const dy2 = polyline[i + 1].y - polyline[i].y; | |
| const n2 = normalize({ x: -dy2, y: dx2 }); | |
| normal = normalize({ | |
| x: (n1.x + n2.x) / 2, | |
| y: (n1.y + n2.y) / 2 | |
| }); | |
| } | |
| normals.push(normal); | |
| } | |
| return normals; | |
| } | |
| // Normalize a vector | |
| function normalize(v) { | |
| const length = Math.sqrt(v.x * v.x + v.y * v.y); | |
| if (length === 0) return { x: 0, y: 0 }; | |
| return { x: v.x / length, y: v.y / length }; | |
| } | |
| // Compute bands between offset curves | |
| function computeBands() { | |
| bands = []; | |
| if (offsetCurves.length < 2) return; | |
| const baseOffsetX = parseInt(baseOffsetXSlider.value); | |
| const baseOffsetY = parseInt(baseOffsetYSlider.value); | |
| const deltaOffsetX = parseInt(deltaOffsetXSlider.value); | |
| const deltaOffsetY = parseInt(deltaOffsetYSlider.value); | |
| const deltaRotationDeg = parseInt(deltaRotationDegSlider.value); | |
| const anchorType = document.querySelector('input[name="anchorType"]:checked').value; | |
| for (let i = 0; i < offsetCurves.length - 1; i++) { | |
| const band = { | |
| polygon: [], | |
| centroid: { x: 0, y: 0 } | |
| }; | |
| // Create polygon by combining curve i (forward) and curve i+1 (reverse) | |
| for (const pt of offsetCurves[i]) { | |
| band.polygon.push({ x: pt.x, y: pt.y }); | |
| } | |
| for (let j = offsetCurves[i + 1].length - 1; j >= 0; j--) { | |
| band.polygon.push({ | |
| x: offsetCurves[i + 1][j].x, | |
| y: offsetCurves[i + 1][j].y | |
| }); | |
| } | |
| // Close the polygon | |
| band.polygon.push({ | |
| x: offsetCurves[i][0].x, | |
| y: offsetCurves[i][0].y | |
| }); | |
| // Compute centroid or bounds center | |
| band.centroid = computeCentroid(band.polygon); | |
| // Apply transforms | |
| const offsetX = baseOffsetX + i * deltaOffsetX; | |
| const offsetY = baseOffsetY + i * deltaOffsetY; | |
| const rotation = i * deltaRotationDeg * Math.PI / 180; | |
| for (const pt of band.polygon) { | |
| // Translate to origin, rotate, then translate back | |
| const translatedX = pt.x - band.centroid.x; | |
| const translatedY = pt.y - band.centroid.y; | |
| const rotatedX = translatedX * Math.cos(rotation) - translatedY * Math.sin(rotation); | |
| const rotatedY = translatedX * Math.sin(rotation) + translatedY * Math.cos(rotation); | |
| pt.x = rotatedX + band.centroid.x + offsetX; | |
| pt.y = rotatedY + band.centroid.y + offsetY; | |
| } | |
| // Update centroid after transform | |
| band.centroid.x += offsetX; | |
| band.centroid.y += offsetY; | |
| bands.push(band); | |
| } | |
| } | |
| // Compute centroid of a polygon | |
| function computeCentroid(polygon) { | |
| let area = 0; | |
| let cx = 0; | |
| let cy = 0; | |
| for (let i = 0; i < polygon.length - 1; i++) { | |
| const x0 = polygon[i].x; | |
| const y0 = polygon[i].y; | |
| const x1 = polygon[i + 1].x; | |
| const y1 = polygon[i + 1].y; | |
| const a = x0 * y1 - x1 * y0; | |
| area += a; | |
| cx += (x0 + x1) * a; | |
| cy += (y0 + y1) * a; | |
| } | |
| area /= 2; | |
| const centroid = { | |
| x: cx / (6 * area), | |
| y: cy / (6 * area) | |
| }; | |
| return centroid; | |
| } | |
| // Draw all bands | |
| function drawBands(p) { | |
| if (bands.length === 0) return; | |
| // Draw from inner to outer | |
| for (let i = bands.length - 1; i >= 0; i--) { | |
| const band = bands[i]; | |
| // Alternate colors | |
| const colorIndex = i % 5; | |
| p.fill(0, 0, 255, 30); | |
| p.stroke(0, 0, 255); | |
| p.strokeWeight(1 / viewScale); | |
| p.beginShape(); | |
| for (const pt of band.polygon) { | |
| p.vertex(pt.x, pt.y); | |
| } | |
| p.endShape(p.CLOSE); | |
| // Draw centroid | |
| p.fill(255, 0, 0); | |
| p.noStroke(); | |
| p.ellipse(band.centroid.x, band.centroid.y, 5 / viewScale, 5 / viewScale); | |
| } | |
| } | |
| </script> | |
| <p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=MarkArtist21/shapefinder" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> | |
| </html> |