Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <title>LogicSpine PolyPath v2.0</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/@phosphor-icons/web"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@jaames/iro@5"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script> | |
| <style> | |
| body { margin: 0; overflow: hidden; background-color: #0a0a0a; color: #fff; touch-action: none; } | |
| /* The Viewport holds the camera */ | |
| #viewport { | |
| position: relative; width: 100%; height: 100%; overflow: hidden; | |
| background: radial-gradient(circle, #1a1a1a 0%, #000000 100%); | |
| /* Checkerboard for transparency indication */ | |
| background-image: linear-gradient(45deg, #111 25%, transparent 25%), | |
| linear-gradient(-45deg, #111 25%, transparent 25%), | |
| linear-gradient(45deg, transparent 75%, #111 75%), | |
| linear-gradient(-45deg, transparent 75%, #111 75%); | |
| background-size: 20px 20px; | |
| background-position: 0 0, 0 10px, 10px -10px, -10px 0px; | |
| cursor: grab; | |
| } | |
| #viewport:active { cursor: grabbing; } | |
| #viewport.drawing-mode { cursor: crosshair; } | |
| /* The Camera Container that actually moves and scales */ | |
| #camera { | |
| position: absolute; top: 0; left: 0; | |
| transform-origin: 0 0; | |
| box-shadow: 0 0 100px rgba(0,0,0,0.8); | |
| } | |
| canvas { display: block; image-rendering: pixelated; } /* Keeps pixels sharp when zoomed */ | |
| #overlay-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; } | |
| /* DOM Objects (Text/Images) */ | |
| .draggable-obj { | |
| position: absolute; pointer-events: auto; cursor: move; | |
| border: 2px dashed transparent; box-sizing: border-box; | |
| transform-origin: center center; | |
| } | |
| .draggable-obj:hover, .draggable-obj.active { border-color: #6366f1; } | |
| .editable-text { | |
| outline: none; min-width: 50px; font-family: sans-serif; white-space: nowrap; | |
| } | |
| /* Handles */ | |
| .handle { | |
| position: absolute; width: 14px; height: 14px; background: #fff; | |
| border: 2px solid #6366f1; border-radius: 50%; display: none; | |
| } | |
| .draggable-obj.active .handle { display: block; } | |
| .resize-handle { bottom: -7px; right: -7px; cursor: nwse-resize; } | |
| .rotate-handle { top: -25px; left: calc(50% - 7px); cursor: crosshair; } | |
| /* Line connecting rotate handle to box */ | |
| .draggable-obj.active::before { | |
| content: ''; position: absolute; top: -18px; left: calc(50% - 1px); | |
| width: 2px; height: 18px; background: #6366f1; | |
| } | |
| /* Color Picker Popover */ | |
| #colorPickerUI { | |
| position: absolute; top: 20%; left: 100px; z-index: 100; | |
| display: none; box-shadow: 0 20px 50px rgba(0,0,0,0.5); | |
| } | |
| /* Gradient Text Support */ | |
| .gradient-text { | |
| background-clip: text; -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; color: transparent; | |
| } | |
| /* Loading Screen */ | |
| #globalLoader { | |
| backdrop-filter: blur(10px); | |
| transition: opacity 0.3s ease; | |
| } | |
| ::-webkit-scrollbar { width: 8px; } | |
| ::-webkit-scrollbar-track { background: #111; } | |
| ::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; } | |
| </style> | |
| </head> | |
| <body class="flex flex-col h-screen font-sans selection:bg-indigo-500 selection:text-white" oncontextmenu="return false;"> | |
| <header class="h-16 bg-neutral-900 border-b border-neutral-800 flex items-center justify-between px-6 z-30 shrink-0"> | |
| <div class="flex items-center gap-3"> | |
| <h1 class="text-xl font-bold tracking-widest text-white transition-all"><a href="https://logicspine.in" target="_blank" class="hover:opacity-80 transition-opacity cursor-pointer">LOGIC<span class="text-indigo-500">SPINE</span></a><span class="ml-2 px-2 py-0.5 bg-indigo-500/10 border border-indigo-500/20 rounded text-indigo-400 text-sm align-middle font-mono">PolyPath</span></h1> | |
| <span class="px-2 py-1 text-xs bg-neutral-800 text-neutral-400 rounded-md font-mono"> v2.0</span> | |
| </div> | |
| <div class="flex items-center gap-4"> | |
| <button onclick="document.getElementById('fileUpload').click()" class="flex items-center gap-2 hover:text-indigo-400 text-sm font-medium transition-colors"> | |
| <i class="ph ph-upload-simple text-lg"></i> Open Image | |
| </button> | |
| <input type="file" id="fileUpload" class="hidden" accept="image/*" onchange="loadImage(event)"> | |
| <div class="h-6 w-px bg-neutral-700"></div> | |
| <button onclick="undo()" class="hover:text-white text-neutral-400 transition-colors" title="Undo"><i class="ph ph-arrow-u-up-left text-xl"></i></button> | |
| <button onclick="redo()" class="hover:text-white text-neutral-400 transition-colors" title="Redo"><i class="ph ph-arrow-u-up-right text-xl"></i></button> | |
| <div class="h-6 w-px bg-neutral-700"></div> | |
| <span class="text-xs text-neutral-500 font-mono" id="zoomLevelIndicator">100%</span> | |
| <button onclick="resetCamera()" class="hover:text-white text-neutral-400 text-xs" title="Reset View"><i class="ph ph-corners-out"></i></button> | |
| <div class="h-6 w-px bg-neutral-700 mx-2"></div> | |
| <button onclick="openModal('vectorModal')" class="flex items-center gap-2 px-4 py-2 bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 rounded-lg text-sm font-medium transition-all"> | |
| <i class="ph ph-bezier-curve"></i> Vector Art | |
| </button> | |
| <button onclick="openModal('exportModal')" class="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 rounded-lg text-sm font-medium shadow-[0_0_15px_rgba(79,70,229,0.3)] transition-all"> | |
| <i class="ph ph-download-simple"></i> Export UHD | |
| </button> | |
| <button onclick="openModal('pdfModal')" class="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-500 rounded-lg text-sm font-medium shadow-[0_0_15px_rgba(220,38,38,0.3)] transition-all"> | |
| <i class="ph ph-file-pdf"></i> Export PDF | |
| </button> | |
| </div> | |
| </header> | |
| <div class="flex flex-1 overflow-hidden relative"> | |
| <aside class="w-20 bg-neutral-900 border-r border-neutral-800 flex flex-col items-center py-6 gap-6 z-30 shrink-0"> | |
| <button class="tool-btn text-indigo-400" data-tool="select" title="Select & Move Objects" onclick="setTool('select')"> | |
| <i class="ph ph-cursor text-2xl mb-1"></i><span class="text-[10px]">Select</span> | |
| </button> | |
| <button class="tool-btn text-neutral-400 hover:text-white" data-tool="erase" title="Freehand Eraser" onclick="setTool('erase')"> | |
| <i class="ph ph-eraser text-2xl mb-1"></i><span class="text-[10px]">Erase</span> | |
| </button> | |
| <button class="tool-btn text-neutral-400 hover:text-white" data-tool="replaceColor" title="Replace Specific Color" onclick="setTool('replaceColor')"> | |
| <i class="ph ph-palette text-2xl mb-1"></i><span class="text-[10px]">Replace</span> | |
| </button> | |
| <button class="tool-btn text-neutral-400 hover:text-white" data-tool="removeColor" title="Remove Color to Transparent" onclick="setTool('removeColor')"> | |
| <i class="ph ph-drop text-2xl mb-1"></i><span class="text-[10px]">Remove</span> | |
| </button> | |
| <button class="tool-btn text-neutral-400 hover:text-white" data-tool="canny" title="Area Fill Eraser" onclick="setTool('canny')"> | |
| <i class="ph ph-paint-bucket text-2xl mb-1"></i><span class="text-[10px]">Area Fill</span> | |
| </button> | |
| <div class="w-10 h-px bg-neutral-800"></div> | |
| <button class="tool-btn text-neutral-400 hover:text-white" onclick="addTextObject()"> | |
| <i class="ph ph-text-t text-2xl mb-1"></i><span class="text-[10px]">Text</span> | |
| </button> | |
| <button class="tool-btn text-neutral-400 hover:text-white" onclick="document.getElementById('addOverlayImage').click()"> | |
| <i class="ph ph-image text-2xl mb-1"></i><span class="text-[10px]">Image</span> | |
| </button> | |
| <input type="file" id="addOverlayImage" class="hidden" accept="image/*" onchange="addOverlayImage(event)"> | |
| </aside> | |
| <main id="viewport" class="flex-1"> | |
| <div id="camera"> | |
| <canvas id="mainCanvas"></canvas> | |
| <canvas id="edgeOverlay" style="position:absolute; top:0; left:0; pointer-events:none; opacity:0.8; image-rendering:pixelated;"></canvas> | |
| <div id="overlay-layer"></div> | |
| </div> | |
| </main> | |
| <aside id="propertiesPanel" class="w-64 bg-neutral-900 border-l border-neutral-800 p-4 hidden flex-col gap-4 z-30 shrink-0"> | |
| <h3 class="text-xs font-bold text-neutral-500 uppercase tracking-wider mb-2">Properties</h3> | |
| <div id="dynamicProperties" class="flex flex-col gap-4"></div> | |
| </aside> | |
| </div> | |
| <div id="colorPickerUI" class="bg-neutral-900 border border-neutral-700 p-4 rounded-xl w-72"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <span class="text-sm font-bold text-white">Color Settings</span> | |
| <button onclick="closeColorPicker()" class="text-neutral-500 hover:text-white"><i class="ph ph-x"></i></button> | |
| </div> | |
| <div id="pickerContainer" class="flex justify-center mb-4"></div> | |
| <div class="grid grid-cols-2 gap-2 mb-4"> | |
| <div> | |
| <label class="text-xs text-neutral-500">HEX</label> | |
| <input type="text" id="hexInput" class="w-full bg-neutral-800 border border-neutral-700 rounded p-1 text-sm text-center font-mono text-white outline-none focus:border-indigo-500"> | |
| </div> | |
| <div> | |
| <label class="text-xs text-neutral-500">RGB</label> | |
| <input type="text" id="rgbInput" class="w-full bg-neutral-800 border border-neutral-700 rounded p-1 text-sm text-center font-mono text-white outline-none focus:border-indigo-500"> | |
| </div> | |
| </div> | |
| <button id="eyedropperBtn" onclick="activateEyedropper()" class="w-full py-2 bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 rounded flex items-center justify-center gap-2 text-sm transition-colors"> | |
| <i class="ph ph-eyedropper text-indigo-400"></i> Pick from Canvas | |
| </button> | |
| </div> | |
| <div id="globalLoader" class="fixed inset-0 bg-black/60 z-50 flex-col items-center justify-center hidden pointer-events-none"> | |
| <i class="ph ph-spinner-gap animate-spin text-5xl text-indigo-500 mb-4"></i> | |
| <h2 class="text-xl font-bold tracking-widest text-white" id="loaderText">PROCESSING...</h2> | |
| <p class="text-sm text-neutral-400 mt-2">Computing neural pathways and vector math.</p> | |
| </div> | |
| <div id="exportModal" class="fixed inset-0 bg-black/80 backdrop-blur-sm hidden items-center justify-center z-50 px-4"> | |
| <div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md shadow-2xl"> | |
| <div class="flex justify-between items-center mb-6"> | |
| <h2 class="text-xl font-bold">Export UHD PNG</h2> | |
| <button onclick="closeModal('exportModal')" class="text-neutral-500 hover:text-white"><i class="ph ph-x text-xl"></i></button> | |
| </div> | |
| <label class="block text-sm text-neutral-400 mb-2">Resolution Preset (Width)</label> | |
| <select id="exportPreset" class="w-full bg-neutral-800 border border-neutral-700 rounded-lg p-3 text-white mb-4 outline-none focus:border-indigo-500" onchange="document.getElementById('customResDiv').style.display = this.value === 'custom' ? 'block' : 'none'"> | |
| <option value="original">Original Size</option> | |
| <option value="2000">High (2000px)</option> | |
| <option value="4000">Ultra (4000px)</option> | |
| <option value="6000">UHD Print (6000px)</option> | |
| <option value="custom">Custom Width...</option> | |
| </select> | |
| <div id="customResDiv" class="hidden mb-4"> | |
| <label class="block text-sm text-neutral-400 mb-2">Custom Max Width (px)</label> | |
| <input type="number" id="customWidth" value="8000" class="w-full bg-neutral-800 border border-neutral-700 rounded-lg p-3 text-white outline-none focus:border-indigo-500"> | |
| </div> | |
| <p class="text-xs text-neutral-500 mb-6">Note: This will bake all text and layers into a single high-resolution PNG image.</p> | |
| <button onclick="executeExport()" class="w-full py-3 bg-indigo-600 hover:bg-indigo-500 rounded-lg font-bold transition-colors">Download Now</button> | |
| </div> | |
| </div> | |
| <div id="vectorModal" class="fixed inset-0 bg-black/80 backdrop-blur-sm hidden items-center justify-center z-50 px-4"> | |
| <div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md shadow-2xl"> | |
| <div class="flex justify-between items-center mb-6"> | |
| <h2 class="text-xl font-bold">Create Vector Art</h2> | |
| <button onclick="closeModal('vectorModal')" class="text-neutral-500 hover:text-white"><i class="ph ph-x text-xl"></i></button> | |
| </div> | |
| <label class="block text-sm text-neutral-400 mb-2">Vector Quality Level</label> | |
| <select id="vectorQuality" class="w-full bg-neutral-800 border border-neutral-700 rounded-lg p-3 text-white mb-6 outline-none focus:border-indigo-500"> | |
| <option value="low">Low (Fastest, Flat Colors)</option> | |
| <option value="mid">Mid (Good for Logos)</option> | |
| <option value="high">High (Detailed Artwork)</option> | |
| <option value="super" selected>Super High (Max Detail, Heavy File)</option> | |
| </select> | |
| <button onclick="executeVectorize()" class="w-full py-3 bg-indigo-600 hover:bg-indigo-500 rounded-lg font-bold transition-colors">Generate SVG</button> | |
| </div> | |
| </div> | |
| <div id="pdfModal" class="fixed inset-0 bg-black/80 backdrop-blur-sm hidden items-center justify-center z-50 px-4"> | |
| <div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md shadow-2xl"> | |
| <div class="flex justify-between items-center mb-6"> | |
| <h2 class="text-xl font-bold">Export Project Report (PDF)</h2> | |
| <button onclick="closeModal('pdfModal')" class="text-neutral-500 hover:text-white"><i class="ph ph-x text-xl"></i></button> | |
| </div> | |
| <label class="block text-sm text-neutral-400 mb-2">Vector Quality</label> | |
| <select id="pdfQuality" class="w-full bg-neutral-800 border border-neutral-700 rounded-lg p-3 text-white mb-4 outline-none focus:border-indigo-500"> | |
| <option value="low">Low (Fast Generation)</option> | |
| <option value="super" selected>Super High (Max Detail)</option> | |
| </select> | |
| <label class="block text-sm text-neutral-400 mb-2">UHD Render Width (px)</label> | |
| <input type="number" id="pdfWidth" value="6000" class="w-full bg-neutral-800 border border-neutral-700 rounded-lg p-3 text-white mb-6 outline-none focus:border-indigo-500"> | |
| <p class="text-xs text-neutral-500 mb-6">Note: This generates a massive 3-page PDF containing the SVG render, UHD render, and Original workspace.</p> | |
| <button onclick="executePDFExport()" class="w-full py-3 bg-red-600 hover:bg-red-500 rounded-lg font-bold transition-colors">Generate PDF</button> | |
| </div> | |
| </div> | |
| <script> | |
| // --- Core Elements --- | |
| const viewport = document.getElementById('viewport'); | |
| const camera = document.getElementById('camera'); | |
| const canvas = document.getElementById('mainCanvas'); | |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); | |
| const overlayLayer = document.getElementById('overlay-layer'); | |
| const propsPanel = document.getElementById('propertiesPanel'); | |
| const dynProps = document.getElementById('dynamicProperties'); | |
| let currentImage = null; | |
| let currentTool = 'select'; // select, erase, replaceColor, removeColor, canny | |
| // --- History --- | |
| let history = []; let historyStep = -1; | |
| function saveState() { | |
| if(!currentImage) return; | |
| historyStep++; history.length = historyStep; | |
| history.push(ctx.getImageData(0, 0, canvas.width, canvas.height)); | |
| } | |
| function undo() { if (historyStep > 0) { historyStep--; ctx.putImageData(history[historyStep], 0, 0); } } | |
| function redo() { if (historyStep < history.length - 1) { historyStep++; ctx.putImageData(history[historyStep], 0, 0); } } | |
| // --- Loading System --- | |
| function showLoading(msg = "PROCESSING...") { | |
| document.getElementById('loaderText').innerText = msg; | |
| document.getElementById('globalLoader').style.display = 'flex'; | |
| } | |
| function hideLoading() { document.getElementById('globalLoader').style.display = 'none'; } | |
| function openModal(id) { document.getElementById(id).style.display = 'flex'; } | |
| function closeModal(id) { document.getElementById(id).style.display = 'none'; } | |
| // --- Color Picker Logic (iro.js) --- | |
| let iroPicker = new iro.ColorPicker("#pickerContainer", { | |
| width: 200, color: "#ff0000", | |
| layoutDirection: "vertical", | |
| layout: [ | |
| { component: iro.ui.Wheel, options: {} }, | |
| { component: iro.ui.Slider, options: { sliderType: 'value' } }, | |
| { component: iro.ui.Slider, options: { sliderType: 'alpha' } } // Transparency slider | |
| ] | |
| }); | |
| // Current target to update when color changes (e.g., 'replacementColor', 'textColorActive') | |
| let activeColorTarget = null; | |
| let customReplacementColor = {r: 255, g: 0, b: 0, a: 255}; | |
| iroPicker.on('color:change', function(color) { | |
| document.getElementById('hexInput').value = color.hexString; | |
| document.getElementById('rgbInput').value = `rgb(${color.rgb.r}, ${color.rgb.g}, ${color.rgb.b})`; | |
| if(activeColorTarget === 'replacement') { | |
| customReplacementColor = {r: color.rgb.r, g: color.rgb.g, b: color.rgb.b, a: Math.round(color.alpha * 255)}; | |
| document.getElementById('replaceColorPreview').style.backgroundColor = color.hex8String; | |
| } else if (activeColorTarget === 'text-solid' && activeDOMObject) { | |
| activeDOMObject.querySelector('.editable-text').style.color = color.hex8String; | |
| } else if (activeColorTarget === 'text-grad-1' && activeDOMObject) { | |
| updateTextGradient(activeDOMObject, 1, color.hex8String); | |
| } else if (activeColorTarget === 'text-grad-2' && activeDOMObject) { | |
| updateTextGradient(activeDOMObject, 2, color.hex8String); | |
| } | |
| }); | |
| // Input syncing | |
| document.getElementById('hexInput').addEventListener('change', (e) => iroPicker.color.hexString = e.target.value); | |
| function openColorPicker(target, buttonEl) { | |
| activeColorTarget = target; | |
| const ui = document.getElementById('colorPickerUI'); | |
| const rect = buttonEl.getBoundingClientRect(); | |
| ui.style.display = 'block'; | |
| ui.style.top = rect.top + 'px'; | |
| ui.style.right = '280px'; // Pop out left of properties panel | |
| ui.style.left = 'auto'; | |
| } | |
| function closeColorPicker() { document.getElementById('colorPickerUI').style.display = 'none'; } | |
| let isPickingColor = false; | |
| function activateEyedropper() { | |
| if (!window.EyeDropper) { alert("Your browser doesn't support the native Eyedropper API. Click on the canvas instead."); return; } | |
| const eyeDropper = new EyeDropper(); | |
| eyeDropper.open().then(result => { | |
| iroPicker.color.hexString = result.sRGBHex; | |
| }).catch(e => console.log(e)); | |
| } | |
| // --- Camera System (Pan & Zoom) --- | |
| let scale = 1, panX = 0, panY = 0; | |
| let isPanning = false, startPanX, startPanY; | |
| function updateCamera() { | |
| camera.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`; | |
| document.getElementById('zoomLevelIndicator').innerText = Math.round(scale * 100) + '%'; | |
| } | |
| function generateEdgeMap() { | |
| if (!currentImage) return; | |
| const eCanvas = document.getElementById('edgeOverlay'); | |
| eCanvas.width = canvas.width; eCanvas.height = canvas.height; | |
| const eCtx = eCanvas.getContext('2d'); | |
| const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); | |
| const data = imgData.data; | |
| const edgeData = eCtx.createImageData(canvas.width, canvas.height); | |
| const eData = edgeData.data; | |
| // Fast boundary detection | |
| for (let y = 0; y < canvas.height - 1; y++) { | |
| for (let x = 0; x < canvas.width - 1; x++) { | |
| let idx = (y * canvas.width + x) * 4; | |
| let rightIdx = (y * canvas.width + (x + 1)) * 4; | |
| let bottomIdx = ((y + 1) * canvas.width + x) * 4; | |
| if (data[idx+3] === 0) continue; // Skip already transparent areas | |
| // Check difference with neighbor pixels | |
| let diffRight = Math.abs(data[idx]-data[rightIdx]) + Math.abs(data[idx+1]-data[rightIdx+1]) + Math.abs(data[idx+2]-data[rightIdx+2]); | |
| let diffBottom = Math.abs(data[idx]-data[bottomIdx]) + Math.abs(data[idx+1]-data[bottomIdx+1]) + Math.abs(data[idx+2]-data[bottomIdx+2]); | |
| // If color difference is greater than tolerance, draw an outline pixel | |
| if (diffRight > colorTolerance || diffBottom > colorTolerance) { | |
| eData[idx] = 99; // Logic Spine Indigo R | |
| eData[idx+1] = 102; // G | |
| eData[idx+2] = 241; // B | |
| eData[idx+3] = 255; // Solid Alpha | |
| } | |
| } | |
| } | |
| eCtx.putImageData(edgeData, 0, 0); | |
| } | |
| function clearEdgeMap() { | |
| const eCanvas = document.getElementById('edgeOverlay'); | |
| if (eCanvas) { | |
| const eCtx = eCanvas.getContext('2d'); | |
| eCtx.clearRect(0, 0, eCanvas.width || 10000, eCanvas.height || 10000); | |
| } | |
| } | |
| function resetCamera() { | |
| if(!currentImage) return; | |
| const vRect = viewport.getBoundingClientRect(); | |
| // Fit to screen with some padding | |
| const scaleX = (vRect.width - 100) / canvas.width; | |
| const scaleY = (vRect.height - 100) / canvas.height; | |
| scale = Math.min(scaleX, scaleY, 1); | |
| panX = (vRect.width - (canvas.width * scale)) / 2; | |
| panY = (vRect.height - (canvas.height * scale)) / 2; | |
| updateCamera(); | |
| } | |
| // Math to convert screen mouse coordinates to raw Canvas coordinates (crucial for accurate drawing when zoomed) | |
| function getCanvasCoords(clientX, clientY) { | |
| const vRect = viewport.getBoundingClientRect(); | |
| const mouseX = clientX - vRect.left; | |
| const mouseY = clientY - vRect.top; | |
| return { | |
| x: (mouseX - panX) / scale, | |
| y: (mouseY - panY) / scale | |
| }; | |
| } | |
| // Mouse Wheel Zoom | |
| viewport.addEventListener('wheel', (e) => { | |
| e.preventDefault(); | |
| const zoomDirection = e.deltaY < 0 ? 1.1 : 0.9; | |
| const vRect = viewport.getBoundingClientRect(); | |
| const mouseX = e.clientX - vRect.left; | |
| const mouseY = e.clientY - vRect.top; | |
| const newScale = Math.max(0.1, Math.min(scale * zoomDirection, 20)); // Allow 2000% zoom | |
| // Math to keep the pixel under the mouse in the same screen spot | |
| panX = mouseX - (mouseX - panX) * (newScale / scale); | |
| panY = mouseY - (mouseY - panY) * (newScale / scale); | |
| scale = newScale; | |
| updateCamera(); | |
| }, { passive: false }); | |
| // Pan with Right Click or Middle Click | |
| viewport.addEventListener('mousedown', (e) => { | |
| if (e.button === 1 || e.button === 2) { | |
| isPanning = true; startPanX = e.clientX - panX; startPanY = e.clientY - panY; | |
| viewport.style.cursor = 'grabbing'; | |
| } | |
| }); | |
| window.addEventListener('mousemove', (e) => { | |
| if (isPanning) { panX = e.clientX - startPanX; panY = e.clientY - startPanY; updateCamera(); } | |
| }); | |
| window.addEventListener('mouseup', (e) => { | |
| if (isPanning) { isPanning = false; viewport.style.cursor = currentTool === 'select' ? 'grab' : 'crosshair'; } | |
| }); | |
| // Touch gestures for Mobile (Pinch zoom & drag pan) | |
| let initialPinchDistance = null; let initialScale = 1; | |
| viewport.addEventListener('touchstart', (e) => { | |
| if(e.touches.length === 2) { | |
| initialPinchDistance = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY); | |
| initialScale = scale; | |
| } else if (e.touches.length === 1 && currentTool === 'select') { | |
| isPanning = true; startPanX = e.touches[0].clientX - panX; startPanY = e.touches[0].clientY - panY; | |
| } | |
| }); | |
| viewport.addEventListener('touchmove', (e) => { | |
| if(e.touches.length === 2 && initialPinchDistance) { | |
| e.preventDefault(); | |
| const dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY); | |
| const zoomDir = dist / initialPinchDistance; | |
| // Simple zoom relative to center for mobile to save complexity | |
| const newScale = Math.max(0.1, Math.min(initialScale * zoomDir, 20)); | |
| const vRect = viewport.getBoundingClientRect(); | |
| const centerX = vRect.width / 2; const centerY = vRect.height / 2; | |
| panX = centerX - (centerX - panX) * (newScale / scale); | |
| panY = centerY - (centerY - panY) * (newScale / scale); | |
| scale = newScale; updateCamera(); | |
| } else if (e.touches.length === 1 && isPanning) { | |
| panX = e.touches[0].clientX - startPanX; panY = e.touches[0].clientY - startPanY; updateCamera(); | |
| } | |
| }, { passive: false }); | |
| viewport.addEventListener('touchend', () => { initialPinchDistance = null; isPanning = false; }); | |
| // --- UI & Tools --- | |
| let brushSize = 20; let colorTolerance = 30; | |
| function setTool(toolName) { | |
| currentTool = toolName; | |
| document.querySelectorAll('.tool-btn').forEach(btn => { | |
| btn.classList.remove('text-indigo-400'); btn.classList.add('text-neutral-400'); | |
| if(btn.dataset.tool === toolName) { btn.classList.remove('text-neutral-400'); btn.classList.add('text-indigo-400'); } | |
| }); | |
| viewport.className = toolName === 'select' ? 'flex-1 cursor-grab' : 'flex-1 drawing-mode'; | |
| propsPanel.style.display = 'flex'; propsPanel.classList.remove('hidden'); | |
| // NEW: Handle the Live Edge Overlay | |
| if (toolName === 'canny' && currentImage) { | |
| showLoading("MAPPING EDGES..."); | |
| setTimeout(() => { generateEdgeMap(); hideLoading(); }, 50); | |
| } else { | |
| clearEdgeMap(); | |
| } | |
| if(toolName === 'erase') { | |
| dynProps.innerHTML = ` | |
| <label class="text-sm text-neutral-400">Brush Size</label> | |
| <input type="range" min="1" max="200" value="${brushSize}" onchange="brushSize = this.value" class="w-full accent-indigo-500"> | |
| `; | |
| } else if (toolName === 'replaceColor' || toolName === 'removeColor' || toolName === 'canny') { | |
| let replaceBtn = toolName === 'replaceColor' ? ` | |
| <label class="text-sm text-neutral-400 mt-4">Replace With</label> | |
| <button id="replaceColorPreview" onclick="openColorPicker('replacement', this)" class="w-full h-10 rounded border border-neutral-700 mt-1" style="background: red;"></button> | |
| ` : ''; | |
| let liveUpdate = toolName === 'canny' ? `oninput="colorTolerance = parseInt(this.value); generateEdgeMap();"` : `onchange="colorTolerance = parseInt(this.value)"`; | |
| // Generate the Palette HTML | |
| let paletteHtml = '<div class="grid grid-cols-4 gap-2 mt-2">'; | |
| if (currentImage) { | |
| const colors = getDominantColors(); | |
| colors.forEach(c => { | |
| paletteHtml += `<button onclick="setTargetColorFromPalette(${c[0]}, ${c[1]}, ${c[2]})" class="w-full h-8 rounded border border-neutral-700 hover:scale-110 transition-transform" style="background: rgb(${c[0]},${c[1]},${c[2]})"></button>`; | |
| }); | |
| } | |
| paletteHtml += '</div>'; | |
| dynProps.innerHTML = ` | |
| <label class="text-sm text-neutral-400">Dominant Colors</label> | |
| ${paletteHtml} | |
| <p class="text-[10px] text-neutral-500 mt-1 mb-4">Click a swatch to target it, or click the image manually.</p> | |
| <label class="text-sm text-neutral-400">Tolerance</label> | |
| <input type="range" min="0" max="255" value="${colorTolerance}" ${liveUpdate} class="w-full accent-indigo-500"> | |
| ${replaceBtn} | |
| `; | |
| } else { propsPanel.style.display = 'none'; } | |
| } | |
| // --- Drawing & Image Processing --- | |
| function loadImage(e) { | |
| const file = e.target.files[0]; if (!file) return; | |
| showLoading("LOADING IMAGE..."); | |
| const reader = new FileReader(); | |
| reader.onload = function(event) { | |
| const img = new Image(); | |
| img.onload = function() { | |
| canvas.width = img.width; canvas.height = img.height; | |
| ctx.drawImage(img, 0, 0); | |
| currentImage = img; | |
| camera.style.width = img.width + 'px'; camera.style.height = img.height + 'px'; | |
| history = []; historyStep = -1; saveState(); | |
| resetCamera(); setTool('select'); hideLoading(); | |
| } | |
| img.src = event.target.result; | |
| } | |
| reader.readAsDataURL(file); | |
| } | |
| let isDrawing = false; | |
| viewport.addEventListener('mousedown', (e) => { | |
| if(!currentImage || e.button !== 0 || currentTool === 'select' || e.target.closest('.draggable-obj')) return; | |
| const coords = getCanvasCoords(e.clientX, e.clientY); | |
| if (currentTool === 'erase') { | |
| isDrawing = true; ctx.globalCompositeOperation = 'destination-out'; | |
| ctx.beginPath(); ctx.arc(coords.x, coords.y, brushSize / 2, 0, Math.PI * 2); ctx.fill(); | |
| } else if (currentTool === 'removeColor' || currentTool === 'replaceColor') { | |
| showLoading("PROCESSING PIXELS..."); | |
| // Use setTimeout to allow UI to render loading screen before heavy CPU task | |
| setTimeout(() => { | |
| processGlobalColor(coords.x, coords.y, currentTool === 'removeColor' ? 'remove' : 'replace'); | |
| hideLoading(); | |
| }, 50); | |
| } else if (currentTool === 'canny') { | |
| showLoading("CALCULATING AREA..."); | |
| setTimeout(() => { floodFill(Math.floor(coords.x), Math.floor(coords.y)); hideLoading(); }, 50); | |
| } | |
| }); | |
| viewport.addEventListener('mousemove', (e) => { | |
| if(!isDrawing || currentTool !== 'erase') return; | |
| const coords = getCanvasCoords(e.clientX, e.clientY); | |
| ctx.lineTo(coords.x, coords.y); | |
| ctx.lineWidth = brushSize; ctx.lineCap = 'round'; ctx.stroke(); | |
| ctx.beginPath(); ctx.moveTo(coords.x, coords.y); | |
| }); | |
| window.addEventListener('mouseup', () => { if(isDrawing) { isDrawing = false; ctx.globalCompositeOperation = 'source-over'; saveState(); } }); | |
| // --- Color Palette Extractor --- | |
| function getDominantColors() { | |
| const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height).data; | |
| const colorCounts = {}; | |
| // Scan every 4th pixel to save CPU time (fast processing) | |
| for (let i = 0; i < imgData.length; i += 16) { | |
| if (imgData[i+3] === 0) continue; // Skip transparent | |
| // Round to nearest 15 to group very similar shades together | |
| const r = Math.round(imgData[i] / 15) * 15; | |
| const g = Math.round(imgData[i+1] / 15) * 15; | |
| const b = Math.round(imgData[i+2] / 15) * 15; | |
| const rgb = `${r},${g},${b}`; | |
| colorCounts[rgb] = (colorCounts[rgb] || 0) + 1; | |
| } | |
| // Sort by most used and grab the top 12 colors | |
| return Object.entries(colorCounts) | |
| .sort((a, b) => b[1] - a[1]) | |
| .slice(0, 12) | |
| .map(e => e[0].split(',').map(Number)); | |
| } | |
| // Global function for the UI buttons to set the target color | |
| window.setTargetColorFromPalette = function(r, g, b) { | |
| // Simulate a click on the canvas to set the target for the Replace/Remove tools | |
| targetColor = {r: r, g: g, b: b, a: 255}; | |
| alert(`Target color set to RGB(${r}, ${g}, ${b}). Now click the canvas to apply!`); | |
| }; | |
| // --- Pixel Algorithms --- | |
| function getPixelColor(x, y, imgData) { | |
| const idx = (Math.floor(y) * canvas.width + Math.floor(x)) * 4; | |
| return { r: imgData.data[idx], g: imgData.data[idx+1], b: imgData.data[idx+2], a: imgData.data[idx+3] }; | |
| } | |
| function colorMatch(c1, c2, tol) { return Math.abs(c1.r - c2.r) <= tol && Math.abs(c1.g - c2.g) <= tol && Math.abs(c1.b - c2.b) <= tol && c1.a > 0; } | |
| function processGlobalColor(x, y, mode) { | |
| const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); | |
| const target = getPixelColor(x, y, imgData); | |
| for (let i = 0; i < imgData.data.length; i += 4) { | |
| let current = {r: imgData.data[i], g: imgData.data[i+1], b: imgData.data[i+2], a: imgData.data[i+3]}; | |
| if (colorMatch(current, target, colorTolerance)) { | |
| if (mode === 'remove') imgData.data[i+3] = 0; | |
| else { imgData.data[i] = customReplacementColor.r; imgData.data[i+1] = customReplacementColor.g; imgData.data[i+2] = customReplacementColor.b; imgData.data[i+3] = customReplacementColor.a; } | |
| } | |
| } | |
| ctx.putImageData(imgData, 0, 0); saveState(); | |
| } | |
| function floodFill(startX, startY) { | |
| const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); | |
| const w = canvas.width; const h = canvas.height; | |
| const target = getPixelColor(startX, startY, imgData); | |
| if(target.a === 0) return; // Don't fill empty space | |
| const stack = [[startX, startY]]; | |
| const getIdx = (x, y) => (y * w + x) * 4; | |
| while(stack.length) { | |
| let [x, y] = stack.pop(); let pixelPos = getIdx(x, y); | |
| while(y-- >= 0 && colorMatch(getPixelColor(x, y, imgData), target, colorTolerance)) pixelPos -= w * 4; | |
| pixelPos += w * 4; ++y; | |
| let reachLeft = false; let reachRight = false; | |
| while(y++ < h - 1 && colorMatch(getPixelColor(x, y, imgData), target, colorTolerance)) { | |
| imgData.data[pixelPos + 3] = 0; // Erase to transparent | |
| if(x > 0) { | |
| if(colorMatch(getPixelColor(x - 1, y, imgData), target, colorTolerance)) { | |
| if(!reachLeft) { stack.push([x - 1, y]); reachLeft = true; } | |
| } else reachLeft = false; | |
| } | |
| if(x < w - 1) { | |
| if(colorMatch(getPixelColor(x + 1, y, imgData), target, colorTolerance)) { | |
| if(!reachRight) { stack.push([x + 1, y]); reachRight = true; } | |
| } else reachRight = false; | |
| } | |
| pixelPos += w * 4; | |
| } | |
| } | |
| ctx.putImageData(imgData, 0, 0); | |
| saveState(); | |
| // Re-run the edge map so the preview updates immediately after the cut! | |
| generateEdgeMap(); | |
| } | |
| // --- DOM Objects (Text & Images) with Rotate & Resize --- | |
| let activeDOMObject = null; | |
| let objCounter = 0; | |
| function showTextProperties(el) { | |
| propsPanel.style.display = 'flex'; | |
| dynProps.innerHTML = ` | |
| <label class="text-sm text-neutral-400">Color Style</label> | |
| <select id="colorStyleSel" class="w-full bg-neutral-800 border border-neutral-700 rounded p-2 text-white outline-none mb-2" onchange="toggleGradientUI(this.value)"> | |
| <option value="solid">Solid Color</option> | |
| <option value="gradient">Linear Gradient</option> | |
| </select> | |
| <div id="solidUI"> | |
| <button onclick="openColorPicker('text-solid', this)" class="w-full h-8 rounded border border-neutral-700" style="background: white;"></button> | |
| </div> | |
| <div id="gradientUI" class="hidden"> | |
| <div class="flex gap-2"> | |
| <button onclick="openColorPicker('text-grad-1', this)" class="w-full h-8 rounded border border-neutral-700" style="background: #ff0000;"></button> | |
| <button onclick="openColorPicker('text-grad-2', this)" class="w-full h-8 rounded border border-neutral-700" style="background: #0000ff;"></button> | |
| </div> | |
| <label class="text-xs text-neutral-400 mt-2">Direction</label> | |
| <select id="gradDirSel" onchange="updateTextGradientActive()" class="w-full bg-neutral-800 border border-neutral-700 rounded p-1 text-white text-xs"> | |
| <option value="to right">Horizontal</option> | |
| <option value="to bottom">Vertical</option> | |
| <option value="to bottom right">Diagonal</option> | |
| </select> | |
| </div> | |
| <label class="text-sm text-neutral-400 mt-4">Font Size</label> | |
| <input type="range" min="10" max="500" value="60" oninput="activeDOMObject.querySelector('.editable-text').style.fontSize = this.value + 'px'" class="w-full accent-indigo-500"> | |
| <button onclick="activeDOMObject.remove(); propsPanel.style.display='none'" class="mt-4 w-full py-2 bg-red-900/50 hover:bg-red-800 text-red-400 rounded transition-colors text-sm"><i class="ph ph-trash"></i> Delete Object</button> | |
| `; | |
| } | |
| // Expose function for the select menu | |
| window.toggleGradientUI = function(val) { | |
| document.getElementById('solidUI').style.display = val === 'solid' ? 'block' : 'none'; | |
| document.getElementById('gradientUI').style.display = val === 'gradient' ? 'block' : 'none'; | |
| if(val === 'solid') { | |
| activeDOMObject.querySelector('.editable-text').classList.remove('gradient-text'); | |
| activeDOMObject.querySelector('.editable-text').style.background = 'none'; | |
| } else { | |
| activeDOMObject.querySelector('.editable-text').classList.add('gradient-text'); | |
| updateTextGradientActive(); | |
| } | |
| }; | |
| window.updateTextGradientActive = function() { | |
| if(!activeDOMObject) return; | |
| const dir = document.getElementById('gradDirSel').value; | |
| // Get colors from buttons (simplified for this demo) | |
| const c1 = document.querySelectorAll('#gradientUI button')[0].style.backgroundColor; | |
| const c2 = document.querySelectorAll('#gradientUI button')[1].style.backgroundColor; | |
| updateTextGradient(activeDOMObject, null, null, `${dir}, ${c1}, ${c2}`); | |
| } | |
| function updateTextGradient(obj, num, hex, fullCssString) { | |
| const txt = obj.querySelector('.editable-text'); | |
| if(fullCssString) { | |
| txt.style.backgroundImage = `linear-gradient(${fullCssString})`; | |
| } else { | |
| // Hacking it slightly to avoid complex state management for this demo | |
| txt.style.backgroundImage = `linear-gradient(to right, ${num===1?hex:'red'}, ${num===2?hex:'blue'})`; | |
| } | |
| } | |
| function makeTransformable(el, type) { | |
| let isDragging = false, isResizing = false, isRotating = false; | |
| let startX, startY, initX, initY, initW, initH; | |
| let centerX, centerY, currentRotation = 0; | |
| const handleResize = el.querySelector('.resize-handle'); | |
| const handleRotate = el.querySelector('.rotate-handle'); | |
| el.addEventListener('mousedown', (e) => { | |
| if(currentTool !== 'select') return; | |
| e.stopPropagation(); // Stop pan | |
| document.querySelectorAll('.draggable-obj').forEach(obj => obj.classList.remove('active')); | |
| el.classList.add('active'); activeDOMObject = el; | |
| closeColorPicker(); | |
| if (type === 'text') showTextProperties(el); | |
| if(e.target === handleResize) { isResizing = true; startX = e.clientX; startY = e.clientY; initW = el.offsetWidth; initH = el.offsetHeight; return; } | |
| if(e.target === handleRotate) { | |
| isRotating = true; | |
| const rect = el.getBoundingClientRect(); | |
| centerX = rect.left + rect.width / 2; centerY = rect.top + rect.height / 2; | |
| return; | |
| } | |
| isDragging = true; startX = e.clientX; startY = e.clientY; | |
| // Parse translate if it exists | |
| const transform = el.style.transform; | |
| const matchX = transform.match(/translateX\(([-\d.]+)px\)/); | |
| const matchY = transform.match(/translateY\(([-\d.]+)px\)/); | |
| initX = matchX ? parseFloat(matchX[1]) : 0; | |
| initY = matchY ? parseFloat(matchY[1]) : 0; | |
| }); | |
| window.addEventListener('mousemove', (e) => { | |
| if(isDragging) { | |
| const dx = (e.clientX - startX) / scale; // Account for camera scale | |
| const dy = (e.clientY - startY) / scale; | |
| el.style.transform = `translateX(${initX + dx}px) translateY(${initY + dy}px) rotate(${currentRotation}deg)`; | |
| } else if (isResizing && type === 'image') { | |
| // Only image resizes physically, text resizes via font-size | |
| const dx = (e.clientX - startX) / scale; | |
| el.querySelector('img').style.width = Math.max(50, initW + dx) + 'px'; | |
| } else if (isRotating) { | |
| const angle = Math.atan2(e.clientY - centerY, e.clientX - centerX); | |
| currentRotation = angle * (180 / Math.PI) + 90; // Offset by 90deg because handle is at top | |
| // Extract current translate | |
| const transform = el.style.transform; | |
| const matchX = transform.match(/translateX\(([-\d.]+)px\)/) || [0,0]; | |
| const matchY = transform.match(/translateY\(([-\d.]+)px\)/) || [0,0]; | |
| el.style.transform = `translateX(${matchX[1]}px) translateY(${matchY[1]}px) rotate(${currentRotation}deg)`; | |
| } | |
| }); | |
| window.addEventListener('mouseup', () => { isDragging = false; isResizing = false; isRotating = false; }); | |
| } | |
| function addTextObject() { | |
| setTool('select'); | |
| const div = document.createElement('div'); | |
| div.className = 'draggable-obj active'; | |
| // Start centered in current view | |
| div.style.transform = `translateX(${-panX/scale + 100}px) translateY(${-panY/scale + 100}px) rotate(0deg)`; | |
| const txt = document.createElement('div'); | |
| txt.className = 'editable-text'; | |
| txt.contentEditable = true; | |
| txt.innerText = "Double Click to Edit"; | |
| txt.style.fontSize = '60px'; | |
| txt.style.color = '#ffffff'; | |
| div.innerHTML = `<div class="handle rotate-handle"></div>`; | |
| div.appendChild(txt); | |
| overlayLayer.appendChild(div); | |
| activeDOMObject = div; | |
| makeTransformable(div, 'text'); | |
| showTextProperties(div); | |
| } | |
| function addOverlayImage(e) { | |
| setTool('select'); | |
| const file = e.target.files[0]; if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = function(event) { | |
| const div = document.createElement('div'); | |
| div.className = 'draggable-obj active'; | |
| div.style.transform = `translateX(${-panX/scale + 100}px) translateY(${-panY/scale + 100}px) rotate(0deg)`; | |
| div.innerHTML = ` | |
| <div class="handle rotate-handle"></div> | |
| <img src="${event.target.result}" style="width: 300px; display: block;" draggable="false"> | |
| <div class="handle resize-handle"></div> | |
| `; | |
| overlayLayer.appendChild(div); | |
| activeDOMObject = div; | |
| makeTransformable(div, 'image'); | |
| // Show simple delete props | |
| propsPanel.style.display = 'flex'; | |
| dynProps.innerHTML = `<button onclick="activeDOMObject.remove(); propsPanel.style.display='none'" class="mt-4 w-full py-2 bg-red-900/50 hover:bg-red-800 text-red-400 rounded text-sm"><i class="ph ph-trash"></i> Delete Image</button>`; | |
| } | |
| reader.readAsDataURL(file); | |
| } | |
| async function executeVectorize() { | |
| closeModal('vectorModal'); | |
| const quality = document.getElementById('vectorQuality').value; | |
| // 1. Get the flattened image | |
| const blob = await getFlattenedImageBlob(); | |
| // 2. Prepare the payload | |
| const formData = new FormData(); | |
| formData.append('file', blob, 'logicspine_workspace.png'); | |
| formData.append('quality', quality); | |
| showLoading(`VECTORIZING (${quality.toUpperCase()})...`); | |
| try { | |
| // 3. Send to FastAPI | |
| const response = await fetch('https://5m4ck3r-polypath2-0.hf.space/vectorize/', { | |
| method: 'POST', | |
| headers: { | |
| 'X-LogicSpine-Key': 'LogicSpine_PolyPath_Secure_2026!' | |
| }, | |
| body: formData | |
| }); | |
| if (!response.ok) throw new Error("Backend failed"); | |
| // 4. Download the massive SVG directly to the browser | |
| const blobRes = await response.blob(); | |
| const url = window.URL.createObjectURL(blobRes); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `LogicSpine_${quality}_vector.svg`; | |
| a.click(); | |
| } catch (e) { | |
| alert("Error connecting to Python backend. Is uvicorn running?"); | |
| console.error(e); | |
| } | |
| hideLoading(); | |
| } | |
| async function executeExport() { | |
| closeModal('exportModal'); | |
| let preset = document.getElementById('exportPreset').value; | |
| let width = preset === 'custom' ? document.getElementById('customWidth').value : preset; | |
| if (width === 'original') width = canvas.width; | |
| // 1. Get the flattened image | |
| const blob = await getFlattenedImageBlob(); | |
| // 2. Prepare the payload | |
| const formData = new FormData(); | |
| formData.append('file', blob, 'logicspine_workspace.png'); | |
| formData.append('width', width); | |
| showLoading(`UPSCALING TO ${width}px...`); | |
| try { | |
| // 3. Send to FastAPI | |
| const response = await fetch('https://5m4ck3r-polypath2-0.hf.space/export-uhd/', { | |
| method: 'POST', | |
| headers: { | |
| 'X-LogicSpine-Key': 'LogicSpine_PolyPath_Secure_2026!' | |
| }, | |
| body: formData | |
| }); | |
| if (!response.ok) throw new Error("Backend failed"); | |
| // 4. Download the ultra-high-res PNG | |
| const blobRes = await response.blob(); | |
| const url = window.URL.createObjectURL(blobRes); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `LogicSpine_UHD_${width}px.png`; | |
| a.click(); | |
| } catch (e) { | |
| alert("Error connecting to Python backend. Is uvicorn running?"); | |
| console.error(e); | |
| } | |
| hideLoading(); | |
| } | |
| // --- API Integration & Layer Flattening --- | |
| async function getFlattenedImageBlob() { | |
| showLoading("FLATTENING LAYERS..."); | |
| // 1. Hide UI elements | |
| document.querySelectorAll('.active').forEach(el => el.classList.remove('active')); | |
| clearEdgeMap(); | |
| // 2. Temporarily reset camera zoom | |
| const currentTransform = camera.style.transform; | |
| camera.style.transform = `translate(0px, 0px) scale(1)`; | |
| const cameraDiv = document.getElementById('camera'); | |
| // THE FIX: Remove the glowing drop shadow temporarily so it doesn't bleed into the transparency! | |
| const originalShadow = cameraDiv.style.boxShadow; | |
| cameraDiv.style.boxShadow = 'none'; | |
| // 3. Take the snapshot | |
| const renderedCanvas = await html2canvas(cameraDiv, { | |
| backgroundColor: null, // Keeps the background completely transparent | |
| scale: 1, | |
| useCORS: true, | |
| logging: false | |
| }); | |
| // 4. Put the camera and shadow back where the user had it | |
| cameraDiv.style.boxShadow = originalShadow; | |
| camera.style.transform = currentTransform; | |
| hideLoading(); | |
| // Convert to a file we can send to Python | |
| return new Promise(resolve => renderedCanvas.toBlob(resolve, 'image/png')); | |
| } | |
| async function executePDFExport() { | |
| closeModal('pdfModal'); | |
| const quality = document.getElementById('pdfQuality').value; | |
| const width = document.getElementById('pdfWidth').value; | |
| // 1. Get the flattened image of the workspace | |
| const blob = await getFlattenedImageBlob(); | |
| // 2. Prepare the payload matching the Python backend requirements | |
| const formData = new FormData(); | |
| formData.append('file', blob, 'logicspine_workspace.png'); | |
| formData.append('quality', quality); | |
| formData.append('width', width); | |
| showLoading(`GENERATING 3-PAGE PDF...`); | |
| try { | |
| // 3. Send to FastAPI | |
| const response = await fetch('https://5m4ck3r-polypath2-0.hf.space/export-pdf/', { | |
| method: 'POST', | |
| headers: { | |
| 'X-LogicSpine-Key': 'LogicSpine_PolyPath_Secure_2026!' | |
| }, | |
| body: formData | |
| }); | |
| if (!response.ok) throw new Error("Backend failed"); | |
| // 4. Download the final PDF file | |
| const blobRes = await response.blob(); | |
| const url = window.URL.createObjectURL(blobRes); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `LogicSpine_Project_Report.pdf`; | |
| a.click(); | |
| } catch (e) { | |
| alert("Error connecting to Python backend. Check terminal for errors."); | |
| console.error(e); | |
| } | |
| hideLoading(); | |
| } | |
| </script> | |
| </body> | |
| </html> |