Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Paper Physics Simulator</title> | |
| <!-- Import FontAwesome for Icons --> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <!-- Import Matter.js for Physics --> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/matter-js/0.19.0/matter.min.js"></script> | |
| <style> | |
| :root { | |
| --bg-color: #121214; | |
| --panel-bg: rgba(32, 33, 36, 0.7); | |
| --accent-color: #8257e6; | |
| --accent-hover: #9466ff; | |
| --text-main: #e1e1e6; | |
| --text-muted: #a8a8b3; | |
| --border-color: rgba(255, 255, 255, 0.1); | |
| --font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| body { | |
| font-family: var(--font-family); | |
| background-color: var(--bg-color); | |
| color: var(--text-main); | |
| overflow: hidden; /* Prevent scroll on the main page */ | |
| height: 100vh; | |
| width: 100vw; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| /* --- Header --- */ | |
| header { | |
| height: 60px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 0 24px; | |
| background: rgba(18, 18, 20, 0.9); | |
| border-bottom: 1px solid var(--border-color); | |
| z-index: 10; | |
| backdrop-filter: blur(10px); | |
| } | |
| .logo { | |
| font-weight: 700; | |
| font-size: 1.2rem; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .logo i { | |
| color: var(--accent-color); | |
| } | |
| .built-with { | |
| font-size: 0.85rem; | |
| color: var(--text-muted); | |
| text-decoration: none; | |
| transition: color 0.3s; | |
| } | |
| .built-with:hover { | |
| color: var(--accent-color); | |
| } | |
| /* --- Main Layout --- */ | |
| main { | |
| flex: 1; | |
| position: relative; | |
| display: flex; | |
| height: calc(100vh - 60px); | |
| } | |
| /* --- Canvas Container --- */ | |
| #canvas-container { | |
| flex: 1; | |
| position: relative; | |
| background: radial-gradient(circle at center, #1e1e24 0%, #121214 100%); | |
| cursor: crosshair; | |
| overflow: hidden; | |
| } | |
| canvas { | |
| display: block; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| /* --- Controls Sidebar --- */ | |
| aside { | |
| width: 320px; | |
| background: var(--panel-bg); | |
| border-left: 1px solid var(--border-color); | |
| backdrop-filter: blur(20px); | |
| display: flex; | |
| flex-direction: column; | |
| overflow-y: auto; | |
| padding: 20px; | |
| gap: 24px; | |
| transition: transform 0.3s ease; | |
| } | |
| .control-group { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 12px; | |
| } | |
| .group-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| font-size: 0.9rem; | |
| font-weight: 600; | |
| color: var(--text-muted); | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| border-bottom: 1px solid var(--border-color); | |
| padding-bottom: 8px; | |
| } | |
| /* --- UI Elements --- */ | |
| label { | |
| font-size: 0.85rem; | |
| display: flex; | |
| justify-content: space-between; | |
| } | |
| .value-display { | |
| color: var(--accent-color); | |
| font-family: monospace; | |
| } | |
| input[type="range"] { | |
| -webkit-appearance: none; | |
| width: 100%; | |
| height: 6px; | |
| background: rgba(255,255,255,0.1); | |
| border-radius: 3px; | |
| outline: none; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 50%; | |
| background: var(--accent-color); | |
| cursor: pointer; | |
| transition: background 0.2s; | |
| } | |
| input[type="range"]::-webkit-slider-thumb:hover { | |
| background: var(--accent-hover); | |
| } | |
| .btn { | |
| background: var(--accent-color); | |
| color: white; | |
| border: none; | |
| padding: 12px; | |
| border-radius: 8px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| } | |
| .btn:hover { | |
| background: var(--accent-hover); | |
| transform: translateY(-1px); | |
| } | |
| .btn:active { | |
| transform: translateY(1px); | |
| } | |
| .btn-outline { | |
| background: transparent; | |
| border: 1px solid var(--border-color); | |
| color: var(--text-main); | |
| } | |
| .btn-outline:hover { | |
| border-color: var(--accent-color); | |
| color: var(--accent-color); | |
| } | |
| .btn-danger { | |
| background: rgba(220, 38, 38, 0.2); | |
| color: #ff6b6b; | |
| border: 1px solid rgba(220, 38, 38, 0.3); | |
| } | |
| .btn-danger:hover { | |
| background: rgba(220, 38, 38, 0.4); | |
| color: #fff; | |
| } | |
| /* --- Toggle Switch --- */ | |
| .toggle-row { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| font-size: 0.9rem; | |
| } | |
| .switch { | |
| position: relative; | |
| display: inline-block; | |
| width: 40px; | |
| height: 20px; | |
| } | |
| .switch input { | |
| opacity: 0; | |
| width: 0; | |
| height: 0; | |
| } | |
| .slider { | |
| position: absolute; | |
| cursor: pointer; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background-color: rgba(255,255,255,0.1); | |
| transition: .4s; | |
| border-radius: 20px; | |
| } | |
| .slider:before { | |
| position: absolute; | |
| content: ""; | |
| height: 14px; | |
| width: 14px; | |
| left: 3px; | |
| bottom: 3px; | |
| background-color: white; | |
| transition: .4s; | |
| border-radius: 50%; | |
| } | |
| input:checked + .slider { | |
| background-color: var(--accent-color); | |
| } | |
| input:checked + .slider:before { | |
| transform: translateX(20px); | |
| } | |
| /* --- Instructions Overlay --- */ | |
| .instructions { | |
| position: absolute; | |
| bottom: 20px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: rgba(0, 0, 0, 0.6); | |
| padding: 8px 16px; | |
| border-radius: 20px; | |
| font-size: 0.85rem; | |
| color: var(--text-muted); | |
| pointer-events: none; | |
| user-select: none; | |
| backdrop-filter: blur(4px); | |
| border: 1px solid var(--border-color); | |
| } | |
| /* --- Mobile Responsive --- */ | |
| @media (max-width: 768px) { | |
| main { | |
| flex-direction: column; | |
| } | |
| aside { | |
| width: 100%; | |
| height: 40%; | |
| border-left: none; | |
| border-top: 1px solid var(--border-color); | |
| } | |
| #canvas-container { | |
| height: 60%; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="logo"> | |
| <i class="fa-solid fa-layer-group"></i> | |
| <span>PaperLab</span> | |
| </div> | |
| <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" class="built-with">Built with anycoder</a> | |
| </header> | |
| <main> | |
| <div id="canvas-container"> | |
| <canvas id="world"></canvas> | |
| <div class="instructions"> | |
| <i class="fa-regular fa-hand-pointer"></i> Tap or Drag anywhere to tear paper | |
| </div> | |
| </div> | |
| <aside> | |
| <!-- Physics Controls --> | |
| <div class="control-group"> | |
| <div class="group-header"> | |
| <span>Physics Engine</span> | |
| <i class="fa-solid fa-atom"></i> | |
| </div> | |
| <div class="toggle-row"> | |
| <span>Gravity</span> | |
| <label class="switch"> | |
| <input type="checkbox" id="gravity-toggle" checked> | |
| <span class="slider"></span> | |
| </label> | |
| </div> | |
| <div class="toggle-row"> | |
| <span>Wind Force</span> | |
| <label class="switch"> | |
| <input type="checkbox" id="wind-toggle"> | |
| <span class="slider"></span> | |
| </label> | |
| </div> | |
| </div> | |
| <!-- Tear Settings --> | |
| <div class="control-group"> | |
| <div class="group-header"> | |
| <span>Tear Settings</span> | |
| <i class="fa-solid fa-scissors"></i> | |
| </div> | |
| <label> | |
| Rip Amount (Complexity) | |
| <span id="val-rip" class="value-display">3</span> | |
| </label> | |
| <input type="range" id="rip-amount" min="1" max="10" value="3" step="1"> | |
| <label> | |
| Extra Pieces (Chaos) | |
| <span id="val-pieces" class="value-display">0</span> | |
| </label> | |
| <input type="range" id="extra-pieces" min="0" max="5" value="0" step="1"> | |
| </div> | |
| <!-- Material Properties --> | |
| <div class="control-group"> | |
| <div class="group-header"> | |
| <span>Material</span> | |
| <i class="fa-solid fa-cube"></i> | |
| </div> | |
| <label> | |
| Roughness (Friction) | |
| <span id="val-friction" class="value-display">0.5</span> | |
| </label> | |
| <input type="range" id="friction" min="0" max="1" value="0.5" step="0.05"> | |
| <label> | |
| Restitution (Bounciness) | |
| <span id="val-restitution" class="value-display">0.2</span> | |
| </label> | |
| <input type="range" id="restitution" min="0" max="1" value="0.2" step="0.05"> | |
| </div> | |
| <!-- Speed Control --> | |
| <div class="control-group"> | |
| <div class="group-header"> | |
| <span>Simulation Speed</span> | |
| <i class="fa-solid fa-gauge-high"></i> | |
| </div> | |
| <label> | |
| Time Scale | |
| <span id="val-speed" class="value-display">1.0x</span> | |
| </label> | |
| <input type="range" id="time-scale" min="0.1" max="2.0" value="1.0" step="0.1"> | |
| </div> | |
| <!-- Actions --> | |
| <div class="control-group" style="margin-top: auto;"> | |
| <button id="btn-reset" class="btn btn-danger"> | |
| <i class="fa-solid fa-trash-can"></i> Reset Scene | |
| </button> | |
| <button id="btn-explode" class="btn btn-outline"> | |
| <i class="fa-solid fa-wind"></i> Explode | |
| </button> | |
| </div> | |
| </aside> | |
| </main> | |
| <script> | |
| // --- 1. Initialization --- | |
| const canvas = document.getElementById('world'); | |
| const container = document.getElementById('canvas-container'); | |
| // Module Aliases | |
| const Engine = Matter.Engine, | |
| Render = Matter.Render, | |
| Runner = Matter.Runner, | |
| Bodies = Matter.Bodies, | |
| Composite = Matter.Composite, | |
| Events = Matter.Events, | |
| Mouse = Matter.Mouse, | |
| MouseConstraint = Matter.MouseConstraint, | |
| Vector = Matter.Vector, | |
| Body = Matter.Body; | |
| // Create Engine | |
| const engine = Engine.create(); | |
| const world = engine.world; | |
| // Handle High DPI Screens | |
| const dpr = window.devicePixelRatio || 1; | |
| function resizeCanvas() { | |
| const width = container.clientWidth; | |
| const height = container.clientHeight; | |
| canvas.width = width * dpr; | |
| canvas.height = height * dpr; | |
| canvas.style.width = `${width}px`; | |
| canvas.style.height = `${height}px`; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.scale(dpr, dpr); | |
| // Keep walls on resize | |
| updateWalls(width, height); | |
| } | |
| // --- 2. Scene Setup --- | |
| let paperBody = null; | |
| let walls = []; | |
| let particles = []; // For explosion effects | |
| function createPaper(width, height) { | |
| // Remove existing paper | |
| if (paperBody) Composite.remove(world, paperBody); | |
| const thickness = 10; | |
| const halfW = width / 2; | |
| const halfH = height / 2; | |
| // Create a static rectangle for the main sheet | |
| paperBody = Bodies.rectangle(halfW, halfH, width, thickness, { | |
| isStatic: true, | |
| chamfer: { radius: 5 }, // Rounded corners | |
| render: { fillStyle: '#ffffff' }, | |
| friction: parseFloat(document.getElementById('friction').value), | |
| restitution: parseFloat(document.getElementById('restitution').value), | |
| label: 'paper' | |
| }); | |
| Composite.add(world, paperBody); | |
| } | |
| function updateWalls(width, height) { | |
| // Remove old walls | |
| if (walls.length > 0) Composite.remove(world, walls); | |
| const wallOptions = { | |
| isStatic: true, | |
| render: { visible: false } // Invisible walls | |
| }; | |
| walls = [ | |
| Bodies.rectangle(width/2, -50, width, 100, wallOptions), // Top | |
| Bodies.rectangle(width/2, height + 50, width, 100, wallOptions), // Bottom | |
| Bodies.rectangle(width + 50, height/2, 100, height, wallOptions), // Right | |
| Bodies.rectangle(-50, height/2, 100, height, wallOptions) // Left | |
| ]; | |
| Composite.add(world, walls); | |
| } | |
| // --- 3. Interaction (Tearing) --- | |
| Events.on(engine, 'collisionStart', function(event) { | |
| const pairs = event.pairs; | |
| const ripAmount = parseInt(document.getElementById('rip-amount').value); | |
| const extraPieces = parseInt(document.getElementById('extra-pieces').value); | |
| // Check if any collision involves the paper | |
| const paperCollisions = pairs.filter(pair => | |
| pair.bodyA.label === 'paper' || pair.bodyB.label === 'paper' | |
| ); | |
| if (paperCollisions.length > 0) { | |
| tearPaper(ripAmount, extraPieces); | |
| } | |
| }); | |
| function tearPaper(ripAmount, extraPieces) { | |
| if (!paperBody) return; | |
| // Get vertices | |
| const vertices = paperBody.vertices; | |
| const center = paperBody.position; | |
| // Calculate area of the original paper | |
| const area = Matter.Vertices.area(vertices); | |
| // We want to cut out a chunk based on ripAmount | |
| // Higher ripAmount = smaller chunk cut out | |
| const chunkSize = area / (ripAmount + 1); | |
| // Pick a random starting vertex | |
| const startIndex = Math.floor(Math.random() * vertices.length); | |
| const startVertex = vertices[startIndex]; | |
| // Find the next vertex | |
| let nextIndex = (startIndex + 1) % vertices.length; | |
| let nextVertex = vertices[nextIndex]; | |
| // Create a new polygon representing the "chunk" to remove | |
| // We construct a polygon starting from startVertex, going around to nextVertex | |
| // and adding a point slightly offset from the center to make it a chunk | |
| const chunkVertices = [startVertex, nextVertex]; | |
| // Add a point in the middle of the paper | |
| chunkVertices.push({ x: center.x, y: center.y }); | |
| // Create a new body from these vertices | |
| const chunkBody = Bodies.fromVertices(center.x, center.y, [chunkVertices], { | |
| isStatic: false, | |
| density: 0.002, | |
| friction: parseFloat(document.getElementById('friction').value), | |
| restitution: parseFloat(document.getElementById('restitution').value), | |
| chamfer: { radius: 2 } | |
| }); | |
| if (chunkBody) { | |
| // Add some random velocity to the chunk so it falls | |
| Body.setVelocity(chunkBody, { | |
| x: (Math.random() - 0.5) * 10, | |
| y: (Math.random() - 0.5) * 10 | |
| }); | |
| // Add some extra random pieces if requested | |
| for(let i=0; i<extraPieces; i++) { | |
| const smallSize = 20 + Math.random() * 30; | |
| const smallBody = Bodies.circle( | |
| center.x + (Math.random()-0.5)*100, | |
| center.y + (Math.random()-0.5)*100, | |
| smallSize/2, | |
| { | |
| isStatic: false, | |
| density: 0.005, | |
| friction: parseFloat(document.getElementById('friction').value), | |
| restitution: parseFloat(document.getElementById('restitution').value) | |
| } | |
| ); | |
| Body.setVelocity(smallBody, { | |
| x: (Math.random() - 0.5) * 20, | |
| y: (Math.random() - 0.5) * 20 | |
| }); | |
| Composite.add(world, smallBody); | |
| } | |
| Composite.add(world, chunkBody); | |
| // Remove the original paper | |
| Composite.remove(world, paperBody); | |
| paperBody = null; | |
| } | |
| } | |
| // --- 4. Rendering --- | |
| // Custom renderer loop | |
| function renderLoop() { | |
| const width = container.clientWidth; | |
| const height = container.clientHeight; | |
| // Clear | |
| const ctx = canvas.getContext('2d'); | |
| ctx.clearRect(0, 0, width, height); | |
| // Draw Particles (Confetti) | |
| particles.forEach((p, index) => { | |
| p.x += p.vx; | |
| p.y += p.vy; | |
| p.life -= 0.02; | |
| if (p.life <= 0) { | |
| particles.splice(index, 1); | |
| } else { | |
| ctx.globalAlpha = p.life; | |
| ctx.fillStyle = p.color; | |
| ctx.beginPath(); | |
| ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| }); | |
| ctx.globalAlpha = 1; | |
| // Draw Bodies | |
| const bodies = Composite.allBodies(world); | |
| ctx.lineWidth = 1; | |
| ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; | |
| ctx.fillStyle = '#ffffff'; | |
| bodies.forEach(body => { | |
| if (body.render.visible === false) return; | |
| ctx.beginPath(); | |
| const vertices = body.vertices; | |
| ctx.moveTo(vertices[0].x, vertices[0].y); | |
| for (let j = 1; j < vertices.length; j += 1) { | |
| ctx.lineTo(vertices[j].x, vertices[j].y); | |
| } | |
| ctx.lineTo(vertices[0].x, vertices[0].y); | |
| ctx.closePath(); | |
| // Fill | |
| ctx.fill(); | |
| // Stroke (for the paper look) | |
| ctx.stroke(); | |
| }); | |
| requestAnimationFrame(renderLoop); | |
| } | |
| // --- 5. Event Listeners & Controls --- | |
| // Resize | |
| window.addEventListener('resize', () => { | |
| resizeCanvas(); | |
| createPaper(container.clientWidth, container.clientHeight); | |
| }); | |
| // Initialize | |
| resizeCanvas(); | |
| createPaper(container.clientWidth, container.clientHeight); | |
| renderLoop(); | |
| // Controls Logic | |
| document.getElementById('gravity-toggle').addEventListener('change', (e) => { | |
| world.gravity.y = e.target.checked ? 1 : 0; | |
| }); | |
| document.getElementById('wind-toggle').addEventListener('change', (e) => { | |
| // Simple wind effect: apply force to all dynamic bodies | |
| const windStrength = 0.05; | |
| const bodies = Composite.allBodies(world).filter(b => !b.isStatic); | |
| if (e.target.checked) { | |
| const windInterval = setInterval(() => { | |
| if (!e.target.checked) { | |
| clearInterval(windInterval); | |
| return; | |
| } | |
| bodies.forEach(b => { | |
| Body.applyForce(b, b.position, { x: windStrength, y: 0 }); | |
| }); | |
| }, 1000); | |
| } | |
| }); | |
| document.getElementById('friction').addEventListener('input', (e) => { | |
| document.getElementById('val-friction').textContent = e.target.value; | |
| if(paperBody) paperBody.friction = parseFloat(e.target.value); | |
| }); | |
| document.getElementById('restitution').addEventListener('input', (e) => { | |
| document.getElementById('val-restitution').textContent = e.target.value; | |
| if(paperBody) paperBody.restitution = parseFloat(e.target.value); | |
| }); | |
| document.getElementById('time-scale').addEventListener('input', (e) => { | |
| const val = parseFloat(e.target.value); | |
| engine.timing.timeScale = val; | |
| document.getElementById('val-speed').textContent = val.toFixed(1) + 'x'; | |
| }); | |
| document.getElementById('rip-amount').addEventListener('input', (e) => { | |
| document.getElementById('val-rip').textContent = e.target.value; | |
| }); | |
| document.getElementById('extra-pieces').addEventListener('input', (e) => { | |
| document.getElementById('val-pieces').textContent = e.target.value; | |
| }); | |
| // Buttons | |
| document.getElementById('btn-reset').addEventListener('click', () => { | |
| createPaper(container.clientWidth, container.clientHeight); | |
| }); | |
| document.getElementById('btn-explode').addEventListener('click', () => { | |
| const bodies = Composite.allBodies(world).filter(b => !b.isStatic); | |
| bodies.forEach(b => { | |
| const forceMagnitude = 0.05 * b.mass; | |
| Body.applyForce(b, b.position, { | |
| x: (b.position.x - container.clientWidth/2) * 0.001 * forceMagnitude, | |
| y: (b.position.y - container.clientHeight/2) * 0.001 * forceMagnitude | |
| }); | |
| // Add confetti | |
| for(let i=0; i<5; i++) { | |
| particles.push({ | |
| x: b.position.x, | |
| y: b.position.y, | |
| vx: (Math.random() - 0.5) * 10, | |
| vy: (Math.random() - 0.5) * 10, | |
| life: 1.0, | |
| color: `hsl(${Math.random()*360}, 70%, 50%)`, | |
| size: Math.random() * 3 + 1 | |
| }); | |
| } | |
| }); | |
| }); | |
| // Mouse Control (Tear on drag) | |
| const mouse = Mouse.create(canvas); | |
| const mouseConstraint = MouseConstraint.create(engine, { | |
| mouse: mouse, | |
| constraint: { | |
| stiffness: 0.2, | |
| render: { visible: false } | |
| } | |
| }); | |
| // Allow tearing on drag by checking velocity | |
| Events.on(mouseConstraint, 'mousedown', function(event) { | |
| // Check if we are clicking on paper | |
| const mousePosition = event.mouse.position; | |
| const bodies = Composite.allBodies(world); | |
| // Simple raycast check or point query | |
| const found = Matter.Query.point(bodies, mousePosition); | |
| if (found.length > 0 && found[0].label === 'paper') { | |
| tearPaper(10, 2); // High rip on click | |
| } | |
| }); | |
| Composite.add(world, mouseConstraint); | |
| </script> | |
| </body> | |
| </html> |