Ω
OHMIC FOUNDRYProtected CAD & planning studio
Ready · Select tool
`; win.document.write(doc); win.document.close(); } $$("[data-export]").forEach(b=>b.onclick=()=>({computer:saveToComputer,json:exportJSON,print:printDrawing,svg:exportSVG,png:exportPNG,dxf:exportDXF,csv:exportPartSchedule}[b.dataset.export])()); $("#importInput").onchange=async e=>{const f=e.target.files[0];if(!f)return;if(f.size>15*1024*1024){toast("Project JSON is too large.");e.target.value="";return}try{const raw=JSON.parse(await f.text()),payload=raw.schema==="ohmic-cad"&&raw.data?raw.data:raw;const result=sanitizeProject(payload);saveRecovery("Before project import");replaceState(result.project,{fitView:true,mark:true});closeModal("exportModal");toast(result.warnings.length?`Imported with ${result.warnings.length} repair warning(s).`:"Project imported safely.",4000)}catch(err){toast("That file is not a valid safe OHMIC CAD project.",4500)}finally{e.target.value=""}}; function renderRecovery(){const host=$("#recoveryList");host.replaceChildren();const items=recoveryItems();if(!items.length){const e=document.createElement("div");e.className="empty";e.textContent="No recovery snapshots.";host.append(e);return}for(const item of items){const row=document.createElement("div");row.className="project-row";const info=document.createElement("button");info.className="project-btn";const a=document.createElement("span");a.textContent=item.reason;const d=document.createElement("small");d.textContent=new Date(item.ts).toLocaleString();info.append(a,d);info.onclick=()=>{if(confirm("Restore this snapshot? The current project will be snapshotted first.")){saveRecovery("Before restoring snapshot");const result=sanitizeProject(item.data);replaceState(result.project,{fitView:true,mark:true});closeModal("recoveryModal");toast("Recovery snapshot restored.")}};const dl=button("JSON");dl.onclick=()=>download(`ohmic_recovery_${item.ts}.json`,new Blob([JSON.stringify({schema:"ohmic-cad",schemaVersion:2,data:item.data},null,2)],{type:"application/json"}));row.append(info,dl);host.append(row)}} $("#recoveryBtn").onclick=()=>{renderRecovery();openModal("recoveryModal")}; async function renderVersions(){ const host=$("#versionList"); host.replaceChildren(); let versions=[]; try{ versions=(await dbGetAll(VERSION_STORE)).filter(item=>item.project===state.name).sort((a,b)=>String(b.savedAt).localeCompare(String(a.savedAt))); }catch{} if(!versions.length){ const empty=document.createElement("div"); empty.className="empty"; empty.textContent=`No explicit saved versions are available for “${state.name}”. Use Save Local to create one.`; host.append(empty); return; } for(const version of versions){ const row=document.createElement("div"); row.className="project-row"; const restoreBtn=document.createElement("button"); restoreBtn.className="project-btn"; const label=document.createElement("span"); label.textContent=version.project; const date=document.createElement("small"); date.textContent=new Date(version.savedAt).toLocaleString(); restoreBtn.append(label,date); restoreBtn.onclick=()=>{ if(!confirm("Restore this saved version? The current drawing will be added to Recovery first."))return; saveRecovery("Before restoring saved version"); const result=sanitizeProject(version.data); replaceState(result.project,{fitView:true,mark:true}); closeModal("versionModal"); toast("Saved version restored."); }; const dl=button("JSON"); dl.onclick=()=>download(`${safeName()}_version_${String(version.savedAt).replace(/[:.]/g,"-")}.json`,new Blob([JSON.stringify({schema:"ohmic-cad",schemaVersion:OHMIC_SCHEMA_VERSION,exportedAt:new Date().toISOString(),data:version.data},null,2)],{type:"application/json"})); row.append(restoreBtn,dl); host.append(row); } } async function openVersionHistory(){ await renderVersions(); openModal("versionModal"); } $("#versionsBtn").onclick=openVersionHistory; $("#versionHistoryBtn").onclick=openVersionHistory; function generateConstraintLayout(){ const width=clamp(Math.abs(finite($("#genWidth").value,10000)),100,MAX_SHEET_MM); const height=clamp(Math.abs(finite($("#genHeight").value,7000)),100,MAX_SHEET_MM); const margin=clamp(Math.abs(finite($("#genMargin").value,500)),0,MAX_SHEET_MM/4); const rows=clamp(Math.round(finite($("#genRows").value,2)),1,20); const cols=clamp(Math.round(finite($("#genCols").value,3)),1,20); const gap=clamp(Math.abs(finite($("#genGap").value,150)),0,MAX_SHEET_MM/4); const wall=clamp(Math.abs(finite($("#genWall").value,120)),1,2000); const door=clamp(Math.abs(finite($("#genDoor").value,900)),100,5000); const prefix=safeText($("#genPrefix").value||"Room",40); const usableW=width-gap*(cols-1),usableH=height-gap*(rows-1); if(usableW<=0||usableH<=0)return toast("The gaps are larger than the available layout dimensions.",4200); const cellW=usableW/cols,cellH=usableH/rows; if(cellW<200||cellH<200)return toast("Each generated room must be at least 200 mm in both directions.",4200); const objectEstimate=rows*cols*($("#genDoors").checked?3:2); if((!$("#genReplace").checked?state.objects.length:0)+objectEstimate>MAX_OBJECTS)return toast("This generated layout would exceed the protected object limit.",4200); if(state.objects.length&&!$("#genReplace").checked&&!confirm("Append the generated layout to the current drawing?"))return; saveRecovery("Before generative layout"); if($("#genReplace").checked){ state.objects=[]; state.selected=null; } state.sheet={w:Math.min(MAX_SHEET_MM,width+margin*2),h:Math.min(MAX_SHEET_MM,height+margin*2)}; let count=1; for(let row=0;rowopenModal("generativeModal"); $("#generateLayoutBtn").onclick=generateConstraintLayout; $("#downloadBtn").onclick=saveToComputer; $("#sidebarDownloadBtn").onclick=saveToComputer; $("#printBtn").onclick=printDrawing; $("#sidebarPrintBtn").onclick=printDrawing; function setLayout(mode){if(mode==="wide")app.classList.toggle("wide");if(mode==="focus")app.classList.toggle("focus");if(app.classList.contains("focus"))app.classList.remove("wide");try{localStorage.setItem(LAYOUT_KEY,JSON.stringify({wide:app.classList.contains("wide"),focus:app.classList.contains("focus"),side:getComputedStyle(document.documentElement).getPropertyValue("--side-width")}))}catch{}updateUI();setTimeout(resize,40)} $("#wideBtn").onclick=()=>setLayout("wide");$("#focusBtn").onclick=()=>setLayout("focus");$("#sidebarWideBtn").onclick=()=>setLayout("wide");$("#sidebarFocusBtn").onclick=()=>setLayout("focus");$("#sideBtn").onclick=()=>$("#sidebar").classList.toggle("open"); $("#fullBtn").onclick=async()=>{try{if(!document.fullscreenElement)await document.documentElement.requestFullscreen();else await document.exitFullscreen()}catch{toast("Fullscreen is unavailable in this browser.")}}; $("#sideResizer").addEventListener("pointerdown",e=>{sidebarDrag={start:e.clientX,width:$("#sidebar").getBoundingClientRect().width};e.currentTarget.setPointerCapture(e.pointerId);e.currentTarget.classList.add("dragging")});$("#sideResizer").addEventListener("pointermove",e=>{if(!sidebarDrag)return;const width=clamp(sidebarDrag.width+(sidebarDrag.start-e.clientX),250,520);document.documentElement.style.setProperty("--side-width",width+"px");resize()});$("#sideResizer").addEventListener("pointerup",e=>{sidebarDrag=null;e.currentTarget.classList.remove("dragging");setLayout("save")}); $("#exportBtn").onclick=()=>openModal("exportModal");$("#helpBtn").onclick=()=>openModal("helpModal"); function moveObject(object,dx,dy){ object.x+=dx;object.y+=dy; if("x2" in object){object.x2+=dx;object.y2+=dy} } function nudgeSelected(dx,dy){ const object=selected(); if(!object)return false; if(layerLocked(object.layer)){toast("Unlock this layer before moving its objects.");return true} moveObject(object,dx,dy); pushHistory(); render(); refreshPanels(); return true; } document.addEventListener("keydown",e=>{ const tag=document.activeElement?.tagName; const typing=["INPUT","TEXTAREA","SELECT"].includes(tag); const openModalElement=$(".modal.open"); if(e.key==="Tab"&&openModalElement){ const focusable=$$("button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex='-1'])").filter(node=>openModalElement.contains(node)&&!node.hidden&&node.offsetParent!==null); if(focusable.length){ const first=focusable[0],last=focusable.at(-1); if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus()} else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus()} } } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==="s"){e.preventDefault();saveProject()} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==="z"){e.preventDefault();e.shiftKey?redo():undo()} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==="y"){e.preventDefault();redo()} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==="d"&&!typing){e.preventDefault();$("#duplicateBtn").click()} if(e.shiftKey&&e.key.toLowerCase()==="f"&&!e.ctrlKey&&!e.metaKey&&!typing){e.preventDefault();setLayout("focus")} if(["ArrowLeft","ArrowRight","ArrowUp","ArrowDown"].includes(e.key)&&!typing&&!openModalElement){ const step=(state.snap?state.grid:10)*(e.shiftKey?10:1); const vector={ArrowLeft:[-step,0],ArrowRight:[step,0],ArrowUp:[0,-step],ArrowDown:[0,step]}[e.key]; if(nudgeSelected(...vector))e.preventDefault(); } if(e.key==="Delete"||e.key==="Backspace"){if(!typing){e.preventDefault();deleteSelected()}} if(e.key==="Escape"){ cancelPointerInteraction(); if(app.classList.contains("focus"))setLayout("focus"); $$('.modal.open').forEach(m=>closeModal(m.id)); } }); window.addEventListener("beforeunload",e=>{if(dirty){e.preventDefault();e.returnValue=""}});window.addEventListener("pagehide",()=>{if(dirty)saveDraft()});document.addEventListener("visibilitychange",()=>{if(document.hidden)saveDraft()}); function restoreLayout(){try{const l=JSON.parse(localStorage.getItem(LAYOUT_KEY)||"{}");if(l.wide)app.classList.add("wide");if(l.focus)app.classList.add("focus");if(/^\d+px$/.test(String(l.side).trim()))document.documentElement.style.setProperty("--side-width",String(l.side).trim())}catch{}} async function init(){ restoreLayout(); const params=new URLSearchParams(location.search),draft=loadDraft(); if(params.get("new")==="1"){ state=defaultState(); }else if(draft&&(params.get("resume")==="1"||(!params.has("projects")&&confirm("A protected local draft is available. Resume it?")))){ state=draft; } history=[JSON.stringify(state)]; syncControls(); renderTemplates(); setTool(state.tool||"select"); setTimeout(fit,70); setDirty(false); try{await openDB()}catch{} updateStorageMeter(); if(params.get("projects")==="1"){ await renderProjects(); openModal("projectModal"); } } init();