LTM / index.html
mlmihjaz
3D convert: hybrid pipeline — vector outline for filled shapes, raster for icons/strokes
176ac4c
Raw
History Blame Contribute Delete
362 kB
<!DOCTYPE html>
<!--
DesignForge Pro / LTM — proprietary build by mlmihjaz.
© 2026 mlmihjaz. All rights reserved.
Unauthorized copying, modification, or redistribution of this file is prohibited.
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Sri Axis — 2D + 3D design studio. Vector design, 3D extrude & revolve, lighting and materials.">
<meta name="author" content="mlmihjaz">
<meta name="copyright" content="© 2026 mlmihjaz. All rights reserved.">
<meta name="robots" content="noai, noimageai">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Sri Axis</title>
<script src="https://cdn.jsdelivr.net/npm/fabric@5.3.0/dist/fabric.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/jankovicsandras/imagetracerjs@master/imagetracer_v1.2.6.js"></script>
<script src="https://cdn.jsdelivr.net/npm/paper@0.12.17/dist/paper-full.min.js"></script>
<!-- 3D engine: Babylon.js + earcut (polygon triangulation for extrusion) -->
<script src="https://cdn.jsdelivr.net/npm/earcut@2.2.4/dist/earcut.min.js"></script>
<script src="https://cdn.babylonjs.com/babylon.js"></script>
<!-- 3D Studio Pro: model loaders (glTF/GLB/OBJ/STL) + serializers (GLB export) -->
<script src="https://cdn.babylonjs.com/loaders/babylonjs.loaders.min.js"></script>
<script src="https://cdn.babylonjs.com/serializers/babylonjs.serializers.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#f5f5f5;--surf:#ffffff;--surf2:#f8f8f8;--surf3:#efefef;
--brd:#e5e5e5;--brd2:#d0d0d0;
--acc:#7c3aed;--acc-lt:#8b5cf6;--acc-dark:#6d28d9;--acc-bg:#ede9fe;
--txt:#111111;--txt2:#444444;--txt3:#888888;--txt4:#bbbbbb;
--grn:#10b981;--red:#ef4444;--red-bg:#fee2e2;--amb:#f59e0b;
--blue:#3b82f6;--blue-bg:#dbeafe;
--r:6px;--rm:10px;--rl:14px;
--sans:'DM Sans',sans-serif;--mono:'DM Mono',monospace;
--sh-sm:0 1px 3px rgba(0,0,0,.08);--sh:0 4px 12px rgba(0,0,0,.1);--sh-lg:0 16px 40px rgba(0,0,0,.14)
}
body{font-family:var(--sans);background:var(--bg);color:var(--txt);height:100vh;display:flex;flex-direction:column;overflow:hidden;font-size:13px;line-height:1.4}
/* TOPBAR */
#topbar{display:flex;align-items:center;height:52px;background:var(--surf);border-bottom:1px solid var(--brd);flex-shrink:0;padding:0 12px;box-shadow:var(--sh-sm);z-index:100;gap:4px}
#logo{display:flex;align-items:center;gap:8px;font-weight:700;font-size:17px;color:var(--txt);text-decoration:none;padding:0 14px 0 4px;white-space:nowrap;border-right:1px solid var(--brd);margin-right:12px;height:100%}
#logo .brand-dot{color:var(--acc)}
.top-btn{display:inline-flex;align-items:center;gap:5px;padding:6px 12px;border-radius:var(--rm);font-size:13px;font-weight:500;cursor:pointer;border:none;background:transparent;color:var(--txt2);transition:all .12s;white-space:nowrap;font-family:var(--sans)}
.top-btn:hover{background:var(--surf3);color:var(--txt)}
.top-btn.ic{padding:6px 9px;font-size:16px}
.top-btn.pri{background:var(--acc);color:#fff;padding:7px 18px}
.top-btn.pri:hover{background:var(--acc-dark)}
.top-btn.outline{border:1.5px solid var(--brd2);color:var(--txt)}
.top-btn.outline:hover{border-color:var(--acc);color:var(--acc);background:var(--acc-bg)}
.top-sep{width:1px;height:24px;background:var(--brd);margin:0 6px;flex-shrink:0}
#doc-name{font-size:14px;font-weight:600;color:var(--txt);border:1.5px solid transparent;border-radius:var(--r);padding:4px 8px;outline:none;background:transparent;width:180px;transition:border-color .12s}
#doc-name:hover{border-color:var(--brd2)}
#doc-name:focus{border-color:var(--acc);background:var(--surf)}
.top-right{margin-left:auto;display:flex;align-items:center;gap:6px}
/* Common style toolbar (top bar, contextual) */
#style-bar{display:none;align-items:center;gap:5px}
#style-bar.show{display:flex}
.sb-sw{width:24px;height:24px;border:1px solid var(--brd);border-radius:6px;background:var(--surf);cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;font-size:13px;color:var(--txt2)}
.sb-sw:hover{border-color:var(--acc)}
.sb-dot{width:15px;height:15px;border-radius:4px;background:#7c3aed;box-shadow:inset 0 0 0 1px rgba(0,0,0,.12)}
.sb-dot.ring{background:transparent;border:3px solid #111827;box-shadow:none}
.sb-hidden{position:absolute;width:0;height:0;opacity:0;pointer-events:none}
.sb-sel{font-size:11px;padding:3px 5px;border:1px solid var(--brd);border-radius:6px;background:var(--surf);color:var(--txt2);cursor:pointer}
.sb-mini{display:flex;align-items:center;gap:3px;font-size:11px;color:var(--txt3)}
.sb-mini .sb-ic{font-size:12px}
.sb-mini input[type=range]{width:54px}
.sb-mini input[type=number]{width:40px;font-size:11px;border:1px solid var(--brd);border-radius:5px;background:var(--surf);color:var(--txt);padding:2px 4px;text-align:right}
.pf-btn{display:flex;align-items:center;justify-content:center;gap:5px;font-size:11px;padding:7px 4px}
.pf-btn svg{width:16px;height:16px;flex-shrink:0}
/* Online icon (Iconify) search results in the Shapes panel */
#icon-results{display:none;flex-wrap:wrap;gap:6px;margin-bottom:12px}
.ico-item{width:42px;height:42px;border:1px solid var(--brd);border-radius:8px;background:var(--surf);cursor:pointer;display:flex;align-items:center;justify-content:center;padding:6px;transition:all .1s}
.ico-item:hover{border-color:var(--acc);background:var(--acc-bg);transform:scale(1.05)}
.ico-item img{width:100%;height:100%;object-fit:contain}
.ico-hint{font-size:11px;color:var(--txt3);padding:4px 0}
/* Top-bar toolbar */
#tool-bar{flex:1;display:flex;justify-content:center;align-items:center;gap:3px;min-width:0}
#tool-bar .top-btn.ic svg{width:18px;height:18px;display:block}
.top-btn.tool-active{background:var(--acc)!important;color:#fff!important;border-color:var(--acc)!important}
/* drawing tools live in the top bar — neutralise the big panel .dtool sizing */
#tool-bar .dtool{height:auto;width:auto;font-size:inherit;border-width:1px}
#tool-bar .dtool.active{background:var(--acc);border-color:var(--acc);color:#fff;box-shadow:none}
.tb-select{font-size:11px;padding:3px 5px;border:1px solid var(--brd);border-radius:6px;background:var(--surf);color:var(--txt2);cursor:pointer}
.tb-opt{display:flex;align-items:center;gap:3px;font-size:10px;color:var(--txt3)}
.tb-opt input[type=range]{width:50px}
.tb-color{width:24px;height:24px;border:1px solid var(--brd);border-radius:6px;padding:0;cursor:pointer;background:none}
.zoom-ctrl{display:flex;align-items:center;gap:1px;background:var(--surf3);border:1px solid var(--brd);border-radius:var(--rm);padding:1px}
.zoom-ctrl button{width:20px;height:20px;border:none;background:transparent;border-radius:5px;cursor:pointer;font-size:12px;color:var(--txt2);display:flex;align-items:center;justify-content:center;transition:background .1s}
.zoom-ctrl button:hover{background:var(--surf)}
#zoom-pct{font-size:10.5px;font-weight:600;color:var(--txt);width:38px;min-width:38px;text-align:center;cursor:pointer;border:none;background:transparent;outline:none;font-family:var(--sans);padding:0}
/* SOCIAL BUTTONS */
.social-btn{width:30px;height:30px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;text-decoration:none;transition:transform .1s,filter .1s;flex-shrink:0}
.social-btn:hover{transform:scale(1.1);filter:brightness(1.08)}
.social-btn.fb{background:#1877f2}
.social-btn.wa{background:#25d366}
.social-btn svg{width:17px;height:17px;fill:#fff}
/* WELCOME MODAL */
#welcome-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);backdrop-filter:blur(4px);z-index:600;display:none;align-items:center;justify-content:center}
#welcome-overlay.open{display:flex}
#welcome-modal{background:var(--surf);border-radius:var(--rl);width:440px;max-width:92vw;box-shadow:var(--sh-lg);overflow:hidden;animation:modalIn .25s ease}
#welcome-modal .wm-head{padding:24px 26px 18px;background:linear-gradient(135deg,var(--acc),#ec4899);color:#fff}
#welcome-modal .wm-head h2{font-size:20px;font-weight:700;margin-bottom:5px}
#welcome-modal .wm-head p{font-size:12px;opacity:.92}
#welcome-modal .wm-body{padding:20px 26px}
#welcome-modal .wm-body .wm-intro{font-size:13px;color:var(--txt2);line-height:1.65;margin-bottom:16px}
.wm-contact{display:flex;flex-direction:column;gap:8px}
.wm-link{display:flex;align-items:center;gap:11px;padding:10px 14px;border:1.5px solid var(--brd);border-radius:var(--rm);text-decoration:none;color:var(--txt);font-size:13px;font-weight:600;transition:all .12s}
.wm-link:hover{border-color:var(--acc);background:var(--acc-bg)}
.wm-link .wm-ic{width:30px;height:30px;border-radius:8px;display:flex;align-items:center;justify-content:center;flex-shrink:0;color:#fff;font-size:15px}
.wm-link .wm-ic svg{width:17px;height:17px;fill:#fff}
.wm-foot{padding:4px 26px 24px}
/* TOAST */
#toast{position:fixed;bottom:28px;left:50%;transform:translateX(-50%) translateY(12px);background:#18181b;color:#fff;border-radius:var(--rm);padding:9px 18px;font-size:13px;font-weight:500;opacity:0;transition:opacity .2s,transform .2s;pointer-events:none;z-index:9999;box-shadow:var(--sh-lg);white-space:nowrap}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
#toast.err{background:var(--red)}
/* WORKSPACE */
#workspace{display:flex;flex:1;overflow:hidden;position:relative}
/* LEFT TABS */
#left-tabs{width:56px;background:var(--surf);border-right:1px solid var(--brd);display:flex;flex-direction:column;align-items:stretch;padding:8px 6px;gap:3px;flex-shrink:0;position:relative;z-index:11}
.tab-icon{width:100%;height:44px;border-radius:var(--rm);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;cursor:pointer;color:var(--txt3);transition:background .12s,color .12s;position:relative;overflow:hidden}
.tab-icon:hover{background:var(--surf3);color:var(--txt)}
.tab-icon.active{background:var(--acc-bg);color:var(--acc)}
.tab-icon .tab-label{font-size:8.5px;font-weight:600;line-height:1;text-transform:uppercase;letter-spacing:.01em;max-width:100%;white-space:nowrap;overflow:hidden}
.tab-icon svg{width:18px;height:18px;flex-shrink:0}
/* LEFT PANEL — overlays canvas-wrap so opening/closing it doesn't shift the artboard */
#left-panel{position:absolute;top:0;bottom:0;left:56px;width:0;overflow:hidden;background:var(--surf);border-right:1px solid var(--brd);transition:width .2s ease;display:flex;flex-direction:column;z-index:10;box-shadow:0 0 12px rgba(0,0,0,.04)}
#left-panel.open{width:268px}
.panel-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--brd);flex-shrink:0}
.panel-title{font-size:14px;font-weight:700;color:var(--txt)}
.panel-close{width:26px;height:26px;border:none;background:transparent;cursor:pointer;color:var(--txt3);border-radius:var(--r);display:flex;align-items:center;justify-content:center;font-size:16px;transition:all .1s}
.panel-close:hover{background:var(--surf3);color:var(--txt)}
.panel-body{flex:1;overflow-y:auto;padding:12px}
.panel-body::-webkit-scrollbar{width:4px}
.panel-body::-webkit-scrollbar-thumb{background:var(--brd2);border-radius:2px}
.panel-section{display:none}
.panel-section.active{display:block}
/* Upload */
.upload-zone{border:2px dashed var(--brd2);border-radius:var(--rl);padding:24px 16px;text-align:center;cursor:pointer;transition:all .15s;background:var(--surf2);margin-bottom:14px}
.upload-zone:hover,.upload-zone.drag-over{border-color:var(--acc);background:var(--acc-bg)}
.upload-zone .uz-icon{font-size:28px;margin-bottom:6px;opacity:.7}
.upload-zone .uz-title{font-size:13px;font-weight:600;color:var(--txt);margin-bottom:3px}
.upload-zone .uz-sub{font-size:11px;color:var(--txt3)}
.upload-zone .uz-btn{display:inline-block;margin-top:8px;padding:5px 14px;background:var(--acc);color:#fff;border-radius:var(--rm);font-size:12px;font-weight:600}
.url-input-row{display:flex;gap:6px;margin-bottom:14px}
.url-field{flex:1;padding:7px 10px;border:1.5px solid var(--brd);border-radius:var(--r);font-size:12px;font-family:var(--sans);outline:none;color:var(--txt);background:var(--surf);transition:border-color .12s}
.url-field:focus{border-color:var(--acc)}
.url-field::placeholder{color:var(--txt4)}
.p-btn{display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:7px 12px;border-radius:var(--r);font-size:12px;font-weight:600;cursor:pointer;border:1.5px solid var(--brd);background:var(--surf);color:var(--txt);transition:all .1s;white-space:nowrap;font-family:var(--sans)}
.p-btn:hover{border-color:var(--brd2);background:var(--surf2)}
.p-btn:active{transform:scale(0.97)}
.p-btn.acc{background:var(--acc);border-color:var(--acc);color:#fff}
.p-btn.acc:hover{background:var(--acc-dark);border-color:var(--acc-dark)}
.p-btn.full{width:100%}
.p-btn.danger{border-color:var(--red);color:var(--red)}
.p-btn.danger:hover{background:var(--red-bg)}
.p-btn.active{border-color:var(--acc);color:var(--acc);background:var(--acc-bg)}
.sec-label{font-size:11px;font-weight:700;color:var(--txt3);text-transform:uppercase;letter-spacing:.08em;margin-bottom:8px;margin-top:4px;display:flex;align-items:center;gap:6px}
.thumb-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:12px}
.thumb-item{aspect-ratio:4/3;border-radius:var(--r);overflow:hidden;cursor:pointer;position:relative;background:var(--surf3);border:1.5px solid transparent;transition:all .12s}
.thumb-item:hover{border-color:var(--acc);transform:scale(1.02);box-shadow:var(--sh)}
.thumb-item img{width:100%;height:100%;object-fit:cover;display:block}
.p-btn.tr-mode.sel,.p-btn.tr-edgebtn.sel{background:var(--acc);color:#fff;border-color:transparent}
.lib-item img{object-fit:contain;background:repeating-conic-gradient(#e9e9ef 0% 25%,#fff 0% 50%) 50%/14px 14px}
.lib-del{position:absolute;top:3px;right:3px;width:18px;height:18px;border:none;border-radius:50%;background:rgba(0,0,0,.55);color:#fff;font-size:12px;line-height:1;cursor:pointer;display:none;align-items:center;justify-content:center;padding:0}
.thumb-item:hover .lib-del{display:flex}
.lib-name{position:absolute;left:0;right:0;bottom:0;font-size:9px;padding:2px 4px;background:rgba(0,0,0,.5);color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.thumb-item .thumb-label{position:absolute;bottom:0;left:0;right:0;padding:4px 6px;background:linear-gradient(transparent,rgba(0,0,0,.55));color:#fff;font-size:9px;font-weight:600}
/* SHAPES — category grid */
.shapes-search{width:100%;padding:7px 10px;border:1.5px solid var(--brd);border-radius:var(--rm);font-size:12px;outline:none;color:var(--txt);background:var(--surf2);margin-bottom:10px;transition:all .12s;font-family:var(--sans)}
.shapes-search:focus{border-color:var(--acc);background:var(--surf)}
.shapes-search::placeholder{color:var(--txt4)}
.shape-category{margin-bottom:10px}
.shape-cat-label{font-size:10px;font-weight:700;color:var(--txt3);text-transform:uppercase;letter-spacing:.08em;margin-bottom:5px;padding:2px 0}
.shape-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:4px}
.shape-btn{aspect-ratio:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;background:var(--surf2);border:1.5px solid var(--brd);border-radius:var(--r);cursor:pointer;transition:all .1s;padding:4px;position:relative;overflow:hidden}
.shape-btn:hover{border-color:var(--acc);background:var(--acc-bg);transform:scale(1.06)}
.shape-btn svg{width:24px;height:24px;display:block}
.shape-btn .s-label{font-size:8px;color:var(--txt3);text-align:center;line-height:1;white-space:nowrap;overflow:hidden;max-width:100%}
.shape-btn:hover .s-label{color:var(--acc)}
/* Shape style controls */
.shape-style-row{display:flex;align-items:center;gap:6px;margin-bottom:7px}
.shape-style-row .ss-label{font-size:11px;color:var(--txt3);min-width:50px;flex-shrink:0}
.shape-no-fill-row{display:flex;align-items:center;gap:6px;margin-bottom:7px;font-size:11px;color:var(--txt2)}
input[type="color"]{width:28px;height:28px;border:1.5px solid var(--brd);border-radius:6px;cursor:pointer;padding:2px;background:var(--surf)}
.num-field{width:58px;padding:5px 7px;border:1.5px solid var(--brd);border-radius:var(--r);font-size:12px;font-family:var(--mono);outline:none;color:var(--txt);background:var(--surf);text-align:center;transition:border-color .12s}
.num-field:focus{border-color:var(--acc)}
/* Text presets */
.text-preset{display:block;width:100%;padding:10px 12px;background:var(--surf2);border:1.5px solid var(--brd);border-radius:var(--r);cursor:pointer;margin-bottom:7px;transition:all .1s;text-align:left;font-family:var(--sans);color:var(--txt)}
.text-preset:hover{border-color:var(--acc);background:var(--acc-bg)}
.text-preset.heading{font-size:18px;font-weight:700}
.text-preset.subheading{font-size:14px;font-weight:600}
.text-preset.body-text{font-size:12px}
.text-preset.caption{font-size:10px;color:var(--txt3)}
.icon-row{display:flex;gap:4px;margin-bottom:8px}
.ir-btn{flex:1;height:30px;border:1.5px solid var(--brd);border-radius:var(--r);background:var(--surf);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:13px;color:var(--txt2);transition:all .1s;font-family:var(--sans)}
.ir-btn:hover{border-color:var(--acc);color:var(--acc);background:var(--acc-bg)}
.ir-btn.active{border-color:var(--acc);color:var(--acc);background:var(--acc-bg)}
.chk-row{display:flex;align-items:center;gap:8px;margin-bottom:7px;cursor:pointer;font-size:12px;color:var(--txt2)}
.chk-row input[type="checkbox"]{accent-color:var(--acc);width:14px;height:14px;cursor:pointer}
.input-field{width:100%;padding:7px 10px;border:1.5px solid var(--brd);border-radius:var(--r);font-size:12px;font-family:var(--sans);outline:none;color:var(--txt);background:var(--surf);transition:border-color .12s}
.input-field:focus{border-color:var(--acc)}
.input-field::placeholder{color:var(--txt4)}
.fc-row{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.fc-label{font-size:11px;color:var(--txt3);min-width:56px;flex-shrink:0}
.fc-val{font-size:11px;font-weight:600;color:var(--txt);font-family:var(--mono);min-width:36px;text-align:right}
.fc-num{width:52px;flex-shrink:0;font-size:10.5px;font-weight:600;color:var(--txt);font-family:var(--mono);text-align:right;border:1px solid var(--brd);border-radius:5px;background:var(--surf);padding:2px 4px;outline:none}
.fc-num:focus{border-color:var(--acc)}
.fc-num::-webkit-outer-spin-button,.fc-num::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
input[type="range"]{flex:1;height:4px;border-radius:2px;background:var(--surf3);outline:none;border:none;cursor:pointer;-webkit-appearance:none;accent-color:var(--acc)}
input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--acc);cursor:pointer;box-shadow:0 1px 4px rgba(124,58,237,.4)}
select{width:100%;padding:7px 10px;border:1.5px solid var(--brd);border-radius:var(--r);font-size:12px;font-family:var(--sans);outline:none;color:var(--txt);background:var(--surf);cursor:pointer;transition:border-color .12s;-webkit-appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23888'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;padding-right:28px}
select:focus{border-color:var(--acc)}
.bg-colors{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:8px}
.bg-chip{width:22px;height:22px;border-radius:5px;border:1.5px solid rgba(0,0,0,.08);cursor:pointer;transition:transform .1s}
.bg-chip:hover{transform:scale(1.18)}
.sub-section{margin-bottom:14px}
/* CANVAS */
#canvas-wrap{flex:1;display:flex;align-items:center;justify-content:center;background:#e8e8e8;background-image:linear-gradient(45deg,#d8d8d8 25%,transparent 25%),linear-gradient(-45deg,#d8d8d8 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#d8d8d8 75%),linear-gradient(-45deg,transparent 75%,#d8d8d8 75%);background-size:20px 20px;background-position:0 0,0 10px,10px -10px,-10px 0;overflow:hidden;position:relative}
#canvas-wrap.drag-over{outline:3px solid var(--acc);outline-offset:-3px}
#canvas-container{position:relative;box-shadow:0 8px 40px rgba(0,0,0,.25),0 2px 8px rgba(0,0,0,.1);border-radius:2px;transform-origin:center center}
/* 3D overlay canvas — floats over the 2D artboard, transparent so 2D shows through.
pointer-events off unless orbiting (the .orbit class), so 2D editing is unaffected. */
#babylon-layer{position:absolute;inset:0;width:100%;height:100%;z-index:20;pointer-events:none;outline:none;touch-action:none}
#babylon-layer.orbit{pointer-events:auto;cursor:grab}
#babylon-layer.orbit:active{cursor:grabbing}
/* ===== 3D STUDIO PRO ===== */
.m3-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px}
.m3-add{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;padding:9px 4px;font-size:18px;line-height:1;border:1.5px solid var(--brd);border-radius:var(--r);background:var(--surf);color:var(--txt);cursor:pointer;transition:all .1s}
.m3-add span{font-size:10px;font-weight:600;color:var(--txt3)}
.m3-add:hover{border-color:var(--acc);background:var(--acc-bg);transform:translateY(-1px)}
.m3-add:hover span{color:var(--acc)}
#m3d-stage{position:absolute;inset:0;z-index:60;background:#1e1e26;display:none}
#m3d-stage.open{display:block}
#m3d-canvas{position:absolute;inset:0;width:100%;height:100%;outline:none;touch-action:none;display:block}
.m3-float{position:absolute;background:rgba(30,30,40,.92);backdrop-filter:blur(8px);border:1px solid rgba(255,255,255,.1);border-radius:10px;color:#e8e8ef;box-shadow:0 8px 28px rgba(0,0,0,.45);font-size:12px;z-index:2}
#m3d-topbar{top:10px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:3px;padding:5px 7px}
.m3-tbtn{display:inline-flex;align-items:center;justify-content:center;min-width:30px;height:30px;padding:0 8px;border-radius:7px;border:1px solid transparent;background:transparent;color:#cfcfe0;cursor:pointer;font-size:13px;font-weight:600;transition:all .1s}
.m3-tbtn:hover{background:rgba(255,255,255,.1);color:#fff}
.m3-tbtn.on{background:var(--acc);color:#fff;border-color:var(--acc)}
.m3-tsep{width:1px;height:20px;background:rgba(255,255,255,.14);margin:0 3px}
#m3d-editbar{top:54px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:3px;padding:4px 7px}
#m3d-add{top:54px;left:10px;width:auto;padding:4px 6px 8px;max-height:78%;overflow-y:auto}
.m3-apal{display:grid;grid-template-columns:repeat(4,1fr);gap:4px;padding:0 4px}
.m3-aadd,.m3-alight{width:32px;height:30px;display:flex;align-items:center;justify-content:center;font-size:15px;border-radius:7px;border:1px solid rgba(255,255,255,.1);background:rgba(255,255,255,.05);color:#e8e8ef;cursor:pointer;transition:all .1s}
.m3-aadd:hover,.m3-alight:hover{background:var(--acc);border-color:var(--acc);transform:translateY(-1px)}
#m3d-outliner{top:10px;right:10px;width:212px;max-height:38%;display:flex;flex-direction:column}
#m3d-props{top:calc(38% + 22px);right:10px;width:212px;max-height:54%;overflow-y:auto}
.m3-fhead{padding:8px 11px;font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#9a9ab0;border-bottom:1px solid rgba(255,255,255,.08);display:flex;justify-content:space-between;align-items:center}
#m3d-olist{overflow-y:auto;padding:5px}
.m3-orow{display:flex;align-items:center;gap:6px;padding:5px 7px;border-radius:6px;cursor:pointer;font-size:12px}
.m3-orow:hover{background:rgba(255,255,255,.07)}
.m3-orow.sel{background:var(--acc);color:#fff}
.m3-orow .m3-oname{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.m3-orow .m3-oeye,.m3-orow .m3-odel{opacity:.55;cursor:pointer;font-size:11px}
.m3-orow .m3-oeye:hover,.m3-orow .m3-odel:hover{opacity:1}
#m3d-props .m3-pbody{padding:10px 11px}
.m3-plabel{font-size:10px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:#8a8aa0;margin:12px 0 6px}
.m3-plabel:first-child{margin-top:0}
.m3-vec3{display:grid;grid-template-columns:repeat(3,1fr);gap:5px}
.m3-vec3 input{width:100%;padding:5px 4px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);border-radius:5px;color:#fff;font-size:11px;font-family:var(--mono);text-align:center}
.m3-vec3 input:focus{border-color:var(--acc);outline:none}
.m3-prow{display:flex;align-items:center;gap:8px;margin:6px 0;font-size:11.5px}
.m3-prow label{flex:1;color:#bcbccf}
.m3-prow input[type=range]{flex:1.4}
.m3-prow input[type=color]{width:30px;height:22px;padding:0;border:1px solid rgba(255,255,255,.18);border-radius:5px;background:none;cursor:pointer}
.m3-pbtns{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-top:6px}
.m3-pbtn{padding:6px;font-size:11px;font-weight:600;border-radius:6px;border:1px solid rgba(255,255,255,.14);background:rgba(255,255,255,.05);color:#dcdce8;cursor:pointer}
.m3-pbtn:hover{background:rgba(255,255,255,.13)}
.m3-pbtn.danger{color:#ff8a8a;border-color:rgba(255,90,90,.4)}
.m3-pbtn.acc{background:var(--acc);border-color:var(--acc);color:#fff}
#m3d-status{bottom:10px;left:50%;transform:translateX(-50%);padding:6px 14px;font-size:11px;color:#bcbccf;border-radius:20px}
#m3d-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none;color:rgba(255,255,255,.25);font-size:14px;font-weight:600;letter-spacing:.04em}
#canvas-container.orbiting{outline:2px solid var(--acc);outline-offset:3px}
#canvas-toolbar{position:absolute;top:12px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:4px;background:var(--surf);border:1px solid var(--brd);border-radius:30px;padding:5px 8px;box-shadow:var(--sh);z-index:35;opacity:0;pointer-events:none;transition:opacity .15s}
.ct-grip{cursor:grab;color:var(--txt3);font-size:13px;letter-spacing:-2px;padding:0 3px 0 1px;user-select:none;line-height:1;touch-action:none}
.ct-grip:active{cursor:grabbing}
.ct-color-wrap{position:relative;display:inline-flex}
.ct-color-ovl{position:absolute;left:0;top:0;width:100%;height:100%;opacity:0;pointer-events:none;border:0;padding:0;margin:0}
#canvas-toolbar.visible{opacity:1;pointer-events:all}
#ct-recent{display:none;align-items:center;gap:3px}
.ct-sw{width:18px;height:18px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px var(--brd2);cursor:pointer;padding:0;flex-shrink:0;transition:transform .1s}
.ct-sw:hover{transform:scale(1.18)}
.ct-color-dot{display:block;width:18px;height:18px;border-radius:50%;background:#7c3aed;box-shadow:0 0 0 1px var(--brd2) inset,0 0 0 2px #fff inset}
#ct-adjust{font-size:15px}
.ct-pop{position:absolute;top:54px;left:50%;transform:translateX(-50%) translateY(-6px);width:300px;max-width:88vw;background:var(--surf);border:1px solid var(--brd);border-radius:var(--rl);box-shadow:var(--sh-lg);padding:14px;z-index:36;opacity:0;pointer-events:none;transition:opacity .15s,transform .15s}
.ct-pop.open{opacity:1;pointer-events:auto;transform:translateX(-50%) translateY(0)}
/* AutoDraw-style suggestion bar */
#ai-suggest{position:absolute;top:10px;left:50%;transform:translateX(-50%);display:none;align-items:center;gap:8px;max-width:90%;background:var(--surf);border:1px solid var(--brd);border-radius:30px;padding:6px 10px 6px 14px;box-shadow:var(--sh-lg);z-index:36}
#ai-suggest.show{display:flex}
#ai-suggest .sg-title{font-size:12px;font-weight:600;color:var(--txt2);white-space:nowrap;flex-shrink:0}
#ai-suggest .sg-row{display:flex;gap:4px;overflow-x:auto;padding:2px;scrollbar-width:thin}
.sg-icon{width:42px;height:42px;flex-shrink:0;border:1px solid var(--brd);border-radius:10px;background:var(--surf2);cursor:pointer;display:flex;align-items:center;justify-content:center;padding:5px;transition:all .1s}
.sg-icon:hover{border-color:var(--acc);background:var(--acc-bg);transform:translateY(-1px)}
.sg-icon img{width:100%;height:100%;object-fit:contain;pointer-events:none}
.sg-close{flex-shrink:0;width:26px;height:26px;border:none;background:transparent;color:var(--txt3);font-size:18px;cursor:pointer;border-radius:50%}
.sg-close:hover{background:var(--surf3);color:var(--txt)}
/* Drawing tools palette */
.draw-tools{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:8px}
.dtool{height:44px;border:1.5px solid var(--brd2);border-radius:var(--rm);background:var(--surf);cursor:pointer;font-size:19px;display:flex;align-items:center;justify-content:center;transition:all .1s}
.dtool:hover{border-color:var(--acc);background:var(--acc-bg)}
.dtool.active{border-color:var(--acc);background:var(--acc);box-shadow:0 2px 8px rgba(124,58,237,.35)}
.draw-opts{display:flex;flex-direction:column;gap:9px;background:var(--surf2);border:1px solid var(--brd);border-radius:var(--rm);padding:10px;margin-bottom:10px}
.draw-opts .do-row{display:flex;align-items:center;gap:8px}
.draw-opts .do-lbl{font-size:12px;color:var(--txt2)}
.draw-opts input[type=range]{flex:1;max-width:90px;accent-color:var(--acc)}
#draw-size-val{font-size:11px;font-family:var(--mono);color:var(--txt2);min-width:18px;text-align:right}
.ct-pop .rp-section{padding:0;border:none;margin:0}
.ct-opacity{width:66px;accent-color:var(--acc);cursor:pointer;vertical-align:middle}
.ct-op-ic{font-size:13px;color:var(--txt2);margin-right:-2px}
/* (removed: old right-panel AI chat CSS — real AI Designer now in left "AI" tab) */
#ruler-h{position:absolute;top:0;left:22px;right:0;height:22px;z-index:15;pointer-events:none}
#ruler-v{position:absolute;top:22px;left:0;bottom:0;width:22px;z-index:15;pointer-events:none}
#ruler-corner{position:absolute;top:0;left:0;width:22px;height:22px;z-index:16;background:var(--surf);border-right:1px solid var(--brd);border-bottom:1px solid var(--brd);display:flex;align-items:center;justify-content:center}
#ruler-unit{font-size:9px;border:none;background:transparent;color:var(--txt2);cursor:pointer;width:100%;text-align:center;font-family:var(--sans);appearance:none;-webkit-appearance:none;padding:0}
.ct-btn{width:30px;height:30px;border-radius:50%;border:none;background:transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px;color:var(--txt2);transition:all .1s}
.ct-btn:hover{background:var(--surf3);color:var(--txt)}
.ct-sep{width:1px;height:20px;background:var(--brd);margin:0 2px}
#onboard{position:absolute;inset:0;display:none;flex-direction:column;align-items:center;justify-content:center;gap:14px;pointer-events:none;z-index:4}
#onboard .ob-icon{font-size:64px;opacity:.12}
#onboard .ob-title{font-size:18px;font-weight:700;color:var(--txt3);opacity:.6}
#onboard .ob-actions{display:flex;gap:10px;pointer-events:all;opacity:.7}
/* RIGHT PANEL */
#right-panel{width:272px;min-width:272px;background:var(--surf);border-left:1px solid var(--brd);display:flex;flex-direction:column;overflow-y:auto;overflow-x:hidden;flex-shrink:0}
#right-panel::-webkit-scrollbar{width:4px}
#right-panel::-webkit-scrollbar-thumb{background:var(--brd2);border-radius:2px}
.rp-section{padding:10px 12px;border-bottom:1px solid var(--brd)}
.rp-title{font-size:11px;font-weight:700;color:var(--txt3);text-transform:uppercase;letter-spacing:.08em;margin-bottom:7px}
.dim-group{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:8px}
.dim-label{font-size:10px;color:var(--txt3);margin-bottom:3px}
.dim-input{width:100%;padding:6px 8px;border:1.5px solid var(--brd);border-radius:var(--r);font-size:12px;font-family:var(--mono);outline:none;color:var(--txt);background:var(--surf);text-align:center;transition:border-color .12s}
.dim-input:focus{border-color:var(--acc)}
.pos-grid{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-bottom:6px}
.fc-row{margin-bottom:6px}
.xform-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:4px}
.xf-btn{height:28px;border:1.5px solid var(--brd);border-radius:var(--r);background:var(--surf);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:13px;color:var(--txt2);transition:all .1s}
.xf-btn:hover{border-color:var(--acc);color:var(--acc);background:var(--acc-bg)}
.fmt-cards{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:16px}
.fmt-card{padding:10px 6px;border:2px solid var(--brd);border-radius:var(--rm);text-align:center;cursor:pointer;transition:all .1s}
.fmt-card:hover{border-color:var(--brd2)}
.fmt-card.sel{border-color:var(--acc);background:var(--acc-bg)}
.fmt-card .fmt-name{font-size:13px;font-weight:700;color:var(--txt)}
.fmt-card .fmt-sub{font-size:10px;color:var(--txt3);margin-top:2px}
.effect-list{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:8px}
.ef-chip{padding:4px 10px;border:1.5px solid var(--brd);border-radius:20px;font-size:11px;font-weight:500;cursor:pointer;color:var(--txt2);transition:all .1s}
.ef-chip:hover{border-color:var(--acc);color:var(--acc);background:var(--acc-bg)}
.ef-chip.active{border-color:var(--acc);color:var(--acc);background:var(--acc-bg)}
.layer-item{display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:var(--r);cursor:pointer;transition:all .1s;margin-bottom:2px;user-select:none}
.layer-item:hover{background:var(--surf2)}
.layer-item.active{background:var(--acc-bg)}
.layer-icon{width:22px;height:22px;background:var(--surf3);border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:11px;flex-shrink:0}
.layer-item.active .layer-icon{background:var(--acc);color:#fff}
.layer-name{flex:1;font-size:12px;font-weight:500;color:var(--txt);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:110px}
.layer-vis{width:20px;height:20px;border:none;background:transparent;cursor:pointer;color:var(--txt3);font-size:12px;border-radius:4px;display:flex;align-items:center;justify-content:center;flex-shrink:0;opacity:0;transition:opacity .1s}
.layer-item:hover .layer-vis,.layer-item.active .layer-vis{opacity:1}
/* CROP */
#crop-overlay{position:absolute;inset:0;z-index:30;display:none}
#crop-overlay.active{display:block}
#crop-box{position:absolute;border:2px solid #fff;cursor:move}
.crop-handle{position:absolute;width:10px;height:10px;background:#fff;border:1.5px solid var(--acc);border-radius:2px}
.ch-tl{top:-5px;left:-5px;cursor:nw-resize}.ch-tr{top:-5px;right:-5px;cursor:ne-resize}
.ch-bl{bottom:-5px;left:-5px;cursor:sw-resize}.ch-br{bottom:-5px;right:-5px;cursor:se-resize}
.ch-tm{top:-5px;left:50%;transform:translateX(-50%);cursor:n-resize}
.ch-bm{bottom:-5px;left:50%;transform:translateX(-50%);cursor:s-resize}
.ch-ml{left:-5px;top:50%;transform:translateY(-50%);cursor:w-resize}
.ch-mr{right:-5px;top:50%;transform:translateY(-50%);cursor:e-resize}
#crop-toolbar{position:absolute;bottom:16px;left:50%;transform:translateX(-50%);display:flex;gap:8px;background:var(--surf);border:1px solid var(--brd);border-radius:30px;padding:6px 10px;box-shadow:var(--sh-lg)}
/* MODALS */
#modal-overlay,#size-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);backdrop-filter:blur(4px);z-index:500;display:none;align-items:center;justify-content:center}
#modal-overlay.open,#size-modal-overlay.open{display:flex}
#export-modal,#size-modal{background:var(--surf);border-radius:var(--rl);width:460px;box-shadow:var(--sh-lg);overflow:hidden;animation:modalIn .2s ease}
#size-modal{width:500px}
@keyframes modalIn{from{opacity:0;transform:scale(.95) translateY(8px)}to{opacity:1;transform:none}}
.modal-header{display:flex;align-items:center;justify-content:space-between;padding:18px 20px 14px;border-bottom:1px solid var(--brd)}
.modal-title{font-size:16px;font-weight:700;color:var(--txt)}
.modal-close{width:28px;height:28px;border:none;background:transparent;border-radius:var(--r);cursor:pointer;font-size:18px;color:var(--txt3);display:flex;align-items:center;justify-content:center}
.modal-close:hover{background:var(--surf3);color:var(--txt)}
.modal-body{padding:18px 20px}
.modal-footer{padding:14px 20px 18px;border-top:1px solid var(--brd);display:flex;align-items:center;justify-content:flex-end;gap:8px}
.size-presets{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:16px}
.size-preset{padding:10px 8px;border:1.5px solid var(--brd);border-radius:var(--rm);cursor:pointer;transition:all .1s;text-align:center}
.size-preset:hover{border-color:var(--acc);background:var(--acc-bg)}
.size-preset.sel{border-color:var(--acc);background:var(--acc-bg)}
.size-preset .sp-name{font-size:12px;font-weight:600;color:var(--txt)}
.size-preset .sp-dim{font-size:10px;color:var(--txt3);margin-top:2px;font-family:var(--mono)}
.tpl-size-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:14px}
.tpl-size{padding:7px 8px;border:1.5px solid var(--brd);border-radius:var(--r);background:var(--surf);color:var(--txt2);font-size:11px;font-weight:600;cursor:pointer;text-align:left;transition:all .1s;line-height:1.3}
.tpl-size:hover{border-color:var(--acc);color:var(--acc);background:var(--acc-bg)}
.tpl-size small{display:block;font-size:9.5px;color:var(--txt3);font-family:var(--mono);font-weight:500}
/* CTX MENU */
#ctx-menu{position:fixed;background:var(--surf);border:1.5px solid var(--brd);border-radius:var(--rm);box-shadow:var(--sh-lg);padding:6px;z-index:1000;min-width:160px;display:none;animation:fadeIn .1s ease}
@keyframes fadeIn{from{opacity:0;transform:scale(.97)}to{opacity:1;transform:none}}
#arrange-menu{position:fixed;background:var(--surf);border:1.5px solid var(--brd);border-radius:var(--rm);box-shadow:var(--sh-lg);padding:10px;z-index:1000;width:188px;display:none;animation:fadeIn .1s ease}
#arrange-menu.open{display:block}
#arrange-menu .arr-label{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--txt2);margin:2px 2px 6px}
#arrange-menu .arr-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;margin-bottom:9px}
#arrange-menu .arr-grid.two{grid-template-columns:1fr 1fr}
#arrange-menu .arr-grid button,#arrange-menu .arr-wide{background:var(--bg);border:1px solid var(--brd);border-radius:var(--r);cursor:pointer;color:var(--txt);font-size:13px;padding:8px 0;transition:all .1s}
#arrange-menu .arr-grid.two button{font-size:11px;padding:7px 4px}
#arrange-menu .arr-wide{display:flex;align-items:center;gap:7px;width:100%;padding:8px 10px;font-size:12px;margin-top:4px;justify-content:flex-start}
#arrange-menu button:hover{background:var(--acc,#7c3aed);color:#fff;border-color:transparent}
#arrange-menu .arr-sep{height:1px;background:var(--brd);margin:8px 0 4px}
#arrange-menu .arr-kbd{margin-left:auto;font-size:10px;color:var(--txt2);opacity:.8}
#arrange-menu .arr-wide:hover .arr-kbd{color:#fff}
.ctx-item{display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:var(--r);cursor:pointer;font-size:12px;color:var(--txt2);transition:all .08s}
.ctx-item:hover{background:var(--acc-bg);color:var(--acc)}
.ctx-item.danger:hover{background:var(--red-bg);color:var(--red)}
.ctx-sep{height:1px;background:var(--brd);margin:4px 0}
.ctx-kbd{margin-left:auto;font-size:10px;opacity:.6;padding-left:14px}
.ctx-icon{width:16px;text-align:center;flex-shrink:0}
/* STATUS BAR */
#statusbar{display:flex;align-items:center;gap:14px;padding:5px 14px;background:var(--surf);border-top:1px solid var(--brd);font-size:11px;font-family:var(--mono);color:var(--txt3);flex-shrink:0}
.sb-dot{width:6px;height:6px;border-radius:50%;background:var(--grn);flex-shrink:0}
.sb-dot.warn{background:var(--amb)}
.sb-item{display:flex;align-items:center;gap:5px}
.sb-sep{width:1px;height:14px;background:var(--brd2)}
input[type="file"]{display:none}
::-webkit-scrollbar{width:6px;height:6px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--brd2);border-radius:3px}
::-webkit-scrollbar-thumb:hover{background:var(--txt4)}
.badge{display:inline-flex;align-items:center;padding:2px 7px;border-radius:20px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em}
.badge.purple{background:var(--acc-bg);color:var(--acc)}
.badge.green{background:#d1fae5;color:var(--grn)}
.toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px}
.toggle-label{font-size:12px;color:var(--txt2)}
.toggle{width:34px;height:18px;background:var(--brd2);border-radius:9px;cursor:pointer;position:relative;transition:background .15s;border:none;flex-shrink:0}
.toggle.on{background:var(--acc)}
.toggle::after{content:'';position:absolute;top:2px;left:2px;width:14px;height:14px;background:#fff;border-radius:50%;transition:left .15s;box-shadow:0 1px 3px rgba(0,0,0,.2)}
.toggle.on::after{left:18px}
.grad-presets-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:12px}
.grad-item{height:36px;border-radius:var(--r);cursor:pointer;border:1.5px solid transparent;transition:all .12s}
.grad-item:hover{border-color:var(--acc);transform:scale(1.02)}
/* Hint to OS-level drag/select while still allowing real inputs/contenteditable */
img,svg,#main-canvas,canvas{-webkit-user-drag:none;-khtml-user-drag:none;-moz-user-drag:none;-o-user-drag:none;user-drag:none}
</style>
</head>
<body>
<script>
/* =========================================================================
Source-protection deterrents (mlmihjaz / DesignForge LTM)
These are common-sense client-side guards. They DO NOT make the source
secret — anyone with developer skills can still inspect via view-source:
or DevTools — but they stop casual right-click "Save as" / "View source".
========================================================================= */
(function () {
'use strict';
var inEditable = function (t) {
if (!t) return false;
var tag = (t.tagName || '').toLowerCase();
return tag === 'input' || tag === 'textarea' || tag === 'select' || t.isContentEditable === true;
};
// 1. Disable right-click context menu (preserves inputs)
document.addEventListener('contextmenu', function (e) {
if (inEditable(e.target)) return;
e.preventDefault();
return false;
}, false);
// 2. Block common source-grab and devtools shortcuts.
// NOTE: anyone determined can still open DevTools from the browser menu.
document.addEventListener('keydown', function (e) {
var t = e.target, editable = inEditable(t);
var k = (e.key || '').toLowerCase();
if (e.key === 'F12') { e.preventDefault(); return false; }
if ((e.ctrlKey || e.metaKey) && !editable) {
if (k === 'u' || k === 's' || k === 'p') { e.preventDefault(); return false; }
}
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
if (k === 'i' || k === 'j' || k === 'c') { e.preventDefault(); return false; }
}
}, false);
// 3. Prevent dragging images / canvas content out of the page
document.addEventListener('dragstart', function (e) {
var tag = (e.target.tagName || '').toLowerCase();
if (tag === 'img' || tag === 'canvas' || tag === 'svg') e.preventDefault();
}, false);
// 4. Friendly console warning (every modern browser)
try {
var brand = 'background:#7c3aed;color:#fff;font-size:18px;font-weight:bold;padding:8px 18px;border-radius:6px';
var warn = 'color:#ef4444;font-size:13px;font-weight:bold';
var muted = 'color:#666;font-size:12px';
console.log('%c⚠ STOP', brand);
console.log('%cSri Axis — proprietary build.', warn);
console.log('%cThis console is for developers. Copying, modifying or redistributing this code is not authorized.', muted);
console.log('%c© 2026 mlmihjaz. All rights reserved.', muted);
} catch (_) {}
})();
</script>
<div id="topbar">
<a id="logo" href="#">
<svg width="26" height="26" viewBox="0 0 26 26" fill="none"><rect width="26" height="26" rx="7" fill="#7c3aed"/><path d="M7 19L13 7l6 12" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9 15h8" stroke="white" stroke-width="2.5" stroke-linecap="round"/></svg>
Sri <span class="brand-dot">Axis</span>
</a>
<input type="text" id="doc-name" value="Untitled Design">
<div class="top-sep"></div>
<!-- File + history — icon only -->
<button class="top-btn ic" id="btn-new-proj" title="New blank design"></button>
<button class="top-btn ic" id="btn-open-proj" title="Open a saved project">📂</button>
<button class="top-btn ic" id="btn-save-proj" title="Save project to a file">💾</button>
<input type="file" id="proj-file-input" accept=".json,application/json" hidden>
<button class="top-btn ic" id="btn-undo" title="Undo (Ctrl+Z)"></button>
<button class="top-btn ic" id="btn-redo" title="Redo (Ctrl+Shift+Z)"></button>
<div class="top-sep"></div>
<!-- Object tools -->
<button class="top-btn" id="btn-canvas-size" title="Resize the canvas">⊡ Resize</button>
<button class="top-btn ic" id="btn-group" title="Group selected (Ctrl+G)"></button>
<button class="top-btn ic" id="btn-ungroup" title="Ungroup (Ctrl+Shift+G)"></button>
<button class="top-btn" id="btn-arrange">⊞ Arrange ▾</button>
<!-- ARRANGE / ALIGN MENU -->
<div id="arrange-menu">
<div class="arr-label">Align</div>
<div class="arr-grid">
<button data-align="left" title="Align left"></button>
<button data-align="centerH" title="Center horizontally"></button>
<button data-align="right" title="Align right"></button>
<button data-align="top" title="Align top"></button>
<button data-align="middleV" title="Center vertically"></button>
<button data-align="bottom" title="Align bottom"></button>
</div>
<div class="arr-label">Distribute</div>
<div class="arr-grid two">
<button data-align="distH" title="Distribute horizontally">↔ Horizontal</button>
<button data-align="distV" title="Distribute vertically">↕ Vertical</button>
</div>
<div class="arr-sep"></div>
<button class="arr-wide" data-arr="group">⛶ Group <span class="arr-kbd">Ctrl+G</span></button>
<button class="arr-wide" data-arr="ungroup">⛗ Ungroup <span class="arr-kbd">Ctrl+Shift+G</span></button>
<div class="arr-label" style="margin-top:9px">Combine · Pathfinder</div>
<div class="arr-grid two">
<button data-pf="unite" title="Merge shapes into one outline">⬣ Unite</button>
<button data-pf="subtract" title="Bottom shape minus the ones on top">◑ Subtract</button>
<button data-pf="intersect" title="Keep only the overlapping area">◎ Intersect</button>
<button data-pf="exclude" title="Keep everything except the overlap">⊗ Exclude</button>
</div>
</div>
<!-- TOOLBAR (center) -->
<div id="tool-bar">
<button class="top-btn ic tool tool-active" id="qa-select" title="Select / Move (V)">
<svg viewBox="0 0 24 24" fill="none"><path d="M5 3l5.5 15 2.3-6.2 6.2-2.3z" fill="currentColor" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>
</button>
<span class="top-sep"></span>
<button class="top-btn ic tool" id="qa-text" title="Text">
<svg viewBox="0 0 24 24" fill="none"><path d="M5 6.5V5h14v1.5M12 5v14M9 19h6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<span class="top-sep"></span>
<!-- Drawing toolset (moved from the Shapes panel) -->
<button class="top-btn ic dtool" id="t-magic" title="Magic Pencil — sketch, then pick artwork">
<svg viewBox="0 0 24 24" fill="none"><path d="M10 4l1.3 3.5L15 9l-3.7 1.3L10 14l-1.3-3.5L5 9l3.7-1.5z" fill="currentColor"/><path d="M17 13l.7 1.9L19.6 16l-1.9.7L17 18.6l-.7-1.9L14.4 16l1.9-.6z" fill="currentColor"/></svg>
</button>
<button class="top-btn ic dtool" id="t-brush" title="Brush — freehand drawing">
<svg viewBox="0 0 24 24" fill="none"><path d="M9 14L18 5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><path d="M5 19c0-3 1.6-4.6 3.6-4.6S12 16 12 19c-2 1-5 1-7 0z" fill="currentColor"/></svg>
</button>
<button class="top-btn ic dtool" id="t-pen" title="Pen — Bézier (click = corner, drag = curve)">
<svg viewBox="0 0 24 24" fill="none"><path d="M12 3l4.5 9L12 21 7.5 12z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M12 12v9" stroke="currentColor" stroke-width="1.3"/><circle cx="12" cy="10" r="1.4" fill="currentColor"/></svg>
</button>
<button class="top-btn ic dtool" id="t-line" title="Line — click points, double-click to finish">
<svg viewBox="0 0 24 24" fill="none"><path d="M5 19L19 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="5" cy="19" r="1.6" fill="currentColor"/><circle cx="19" cy="5" r="1.6" fill="currentColor"/></svg>
</button>
<select id="brush-type" class="tb-select" title="Brush type"><option value="pencil">Pencil</option><option value="marker">Marker</option><option value="spray">Spray</option><option value="circle">Dots</option></select>
<span class="tb-opt" title="Brush size"><input type="range" id="draw-size" min="1" max="60" value="4"><span id="draw-size-val">4</span></span>
<input type="color" id="draw-color" value="#111111" class="tb-color" title="Brush colour">
<span class="top-sep"></span>
<button class="top-btn ic" id="qa-dup" title="Duplicate (Ctrl+D)">
<svg viewBox="0 0 24 24" fill="none"><rect x="8" y="8" width="11" height="11" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M5 15V6a2 2 0 0 1 2-2h9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
</button>
<button class="top-btn ic" id="qa-del" title="Delete (Del)" style="color:var(--red)">
<svg viewBox="0 0 24 24" fill="none"><path d="M5 7h14M10 7V5h4v2M6 7l1 12h10l1-12" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
<div class="top-right">
<div class="zoom-ctrl">
<button id="z-out"></button>
<input type="text" id="zoom-pct" value="100%" readonly>
<button id="z-in">+</button>
<button id="z-fit" style="font-size:11px"></button>
</div>
<div class="top-sep"></div>
<button class="top-btn outline" id="btn-export-open">⬇ Download</button>
<button class="top-btn pri" id="btn-share">Share</button>
</div>
</div>
<div id="toast"></div>
<div id="workspace">
<!-- ICON TABS -->
<div id="left-tabs">
<div class="tab-icon active" data-panel="upload">
<svg viewBox="0 0 20 20" fill="none"><path d="M10 13V3M6 7l4-4 4 4M4 16h12" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span class="tab-label">Upload</span>
</div>
<div class="tab-icon" data-panel="templates">
<svg viewBox="0 0 20 20" fill="none"><rect x="2" y="2" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5"/><rect x="11" y="2" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5"/><rect x="2" y="11" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5"/><rect x="11" y="11" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5"/></svg>
<span class="tab-label">Templ</span>
</div>
<div class="tab-icon" data-panel="photos">
<svg viewBox="0 0 20 20" fill="none"><circle cx="8.5" cy="8.5" r="5.5" stroke="currentColor" stroke-width="1.6"/><path d="M13 13l5 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
<span class="tab-label">Photos</span>
</div>
<div class="tab-icon" data-panel="elements">
<svg viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="7" stroke="currentColor" stroke-width="1.5"/><path d="M7 7h6v6H7z" stroke="currentColor" stroke-width="1.5"/></svg>
<span class="tab-label">Shapes</span>
</div>
<div class="tab-icon" data-panel="text">
<svg viewBox="0 0 20 20" fill="none"><path d="M4 5h12M10 5v10M7 15h6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
<span class="tab-label">Text</span>
</div>
<div class="tab-icon" data-panel="background">
<svg viewBox="0 0 20 20" fill="none"><path d="M3 14l4-4 4 4 4-6 3 6H3z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><circle cx="6" cy="7" r="2" stroke="currentColor" stroke-width="1.5"/></svg>
<span class="tab-label">BG</span>
</div>
<div class="tab-icon" data-panel="trace">
<svg viewBox="0 0 20 20" fill="none"><path d="M3 16c4-1 4-11 8-11 3 0 2 6 5 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="3" cy="16" r="1.6" fill="currentColor"/><circle cx="16" cy="11" r="1.6" fill="currentColor"/></svg>
<span class="tab-label">Vector</span>
</div>
<div class="tab-icon" data-panel="model3d">
<svg viewBox="0 0 20 20" fill="none"><path d="M10 2.2 17 6v8l-7 3.8L3 14V6l7-3.8Z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M3 6l7 3.9 7-3.9M10 9.9V17.8" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>
<span class="tab-label">3D</span>
</div>
<div class="tab-icon" data-panel="library">
<svg viewBox="0 0 20 20" fill="none"><path d="M4 3v14M7 3v14M11 4l4 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><rect x="2.5" y="3" width="3" height="14" rx="1" stroke="currentColor" stroke-width="1.3"/></svg>
<span class="tab-label">Library</span>
</div>
<div style="flex:1"></div>
<div class="tab-icon" data-panel="layers">
<svg viewBox="0 0 20 20" fill="none"><path d="M3 7l7-4 7 4-7 4-7-4zM3 13l7 4 7-4M3 10l7 4 7-4" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>
<span class="tab-label">Layers</span>
</div>
</div>
<!-- LEFT PANEL -->
<div id="left-panel" class="open">
<div class="panel-header">
<span class="panel-title" id="panel-title">Upload</span>
<button class="panel-close" id="panel-close-btn">×</button>
</div>
<div class="panel-body" id="panel-body">
<!-- UPLOAD -->
<div class="panel-section active" id="sec-upload">
<div class="upload-zone" id="upload-zone">
<div class="uz-icon"></div>
<div class="uz-title">Drag files here</div>
<div class="uz-sub">or click to browse</div>
<span class="uz-btn">Browse files</span>
</div>
<input type="file" id="file-input" accept="image/*,image/svg+xml">
<div class="sec-label">Load from URL</div>
<div class="url-input-row">
<input type="text" class="url-field" id="url-input" placeholder="https://…/image.png">
<button class="p-btn acc" id="btn-load-url">Go</button>
</div>
<div class="sec-label">Samples</div>
<div class="thumb-grid" id="sample-grid">
<div class="thumb-item" data-url="https://picsum.photos/seed/abc/800/600"><img src="https://picsum.photos/seed/abc/120/90" alt="" loading="lazy"><span class="thumb-label">Nature</span></div>
<div class="thumb-item" data-url="https://picsum.photos/seed/xyz/800/600"><img src="https://picsum.photos/seed/xyz/120/90" alt="" loading="lazy"><span class="thumb-label">Landscape</span></div>
<div class="thumb-item" data-url="https://picsum.photos/seed/city/800/600"><img src="https://picsum.photos/seed/city/120/90" alt="" loading="lazy"><span class="thumb-label">City</span></div>
<div class="thumb-item" data-url="https://picsum.photos/seed/tech/800/600"><img src="https://picsum.photos/seed/tech/120/90" alt="" loading="lazy"><span class="thumb-label">Abstract</span></div>
<div class="thumb-item" data-url="https://picsum.photos/seed/food/800/500"><img src="https://picsum.photos/seed/food/120/90" alt="" loading="lazy"><span class="thumb-label">Portrait</span></div>
<div class="thumb-item" data-url="https://picsum.photos/seed/ocean/800/600"><img src="https://picsum.photos/seed/ocean/120/90" alt="" loading="lazy"><span class="thumb-label">Ocean</span></div>
</div>
<div class="sec-label">Clipboard</div>
<button class="p-btn full" id="btn-paste">⎘ Paste from clipboard</button>
</div>
<!-- TEMPLATES -->
<div class="panel-section" id="sec-templates">
<div class="sec-label">Quick canvas sizes</div>
<div class="tpl-size-grid">
<button class="tpl-size template-item" data-w="1080" data-h="1080">Square <small>1080 × 1080</small></button>
<button class="tpl-size template-item" data-w="1920" data-h="1080">HD 16:9 <small>1920 × 1080</small></button>
<button class="tpl-size template-item" data-w="1080" data-h="1920">Story 9:16 <small>1080 × 1920</small></button>
<button class="tpl-size template-item" data-w="1200" data-h="630">OG image <small>1200 × 630</small></button>
<button class="tpl-size template-item" data-w="2480" data-h="3508">A4 portrait <small>2480 × 3508</small></button>
<button class="tpl-size template-item" data-w="3508" data-h="2480">A4 landscape <small>3508 × 2480</small></button>
<button class="tpl-size template-item" data-w="800" data-h="600">Standard <small>800 × 600</small></button>
<button class="tpl-size template-item" data-w="1500" data-h="500">Banner <small>1500 × 500</small></button>
</div>
<div class="sec-label">Social Media <span class="badge purple">Popular</span></div>
<div class="thumb-grid">
<div class="thumb-item template-item" data-w="1080" data-h="1080" style="background:linear-gradient(135deg,#667eea,#764ba2);aspect-ratio:1"><span class="thumb-label">Instagram Post</span></div>
<div class="thumb-item template-item" data-w="1920" data-h="1080" style="background:linear-gradient(135deg,#f093fb,#f5576c)"><span class="thumb-label">YouTube Cover</span></div>
<div class="thumb-item template-item" data-w="1500" data-h="500" style="background:linear-gradient(135deg,#4facfe,#00f2fe);aspect-ratio:3/1"><span class="thumb-label">Twitter Header</span></div>
<div class="thumb-item template-item" data-w="1200" data-h="628" style="background:linear-gradient(135deg,#43e97b,#38f9d7)"><span class="thumb-label">Facebook Post</span></div>
</div>
<div class="sec-label">Marketing</div>
<div class="thumb-grid">
<div class="thumb-item template-item" data-w="1200" data-h="630" style="background:linear-gradient(135deg,#fa709a,#fee140)"><span class="thumb-label">OG Image</span></div>
<div class="thumb-item template-item" data-w="800" data-h="450" style="background:linear-gradient(135deg,#a18cd1,#fbc2eb)"><span class="thumb-label">Blog Header</span></div>
<div class="thumb-item template-item" data-w="300" data-h="250" style="background:linear-gradient(135deg,#0fd850,#f9f047)"><span class="thumb-label">Banner Ad</span></div>
<div class="thumb-item template-item" data-w="728" data-h="90" style="background:linear-gradient(90deg,#89f7fe,#66a6ff);aspect-ratio:8/1"><span class="thumb-label">Leaderboard</span></div>
</div>
<div class="sec-label">Print</div>
<div class="thumb-grid">
<div class="thumb-item template-item" data-w="2480" data-h="3508" style="background:linear-gradient(135deg,#ffecd2,#fcb69f);aspect-ratio:2/3"><span class="thumb-label">A4 Portrait</span></div>
<div class="thumb-item template-item" data-w="3508" data-h="2480" style="background:linear-gradient(135deg,#a1c4fd,#c2e9fb)"><span class="thumb-label">A4 Landscape</span></div>
</div>
</div>
<!-- PHOTOS (Pixabay search) -->
<div class="panel-section" id="sec-photos">
<div class="sec-label">Search free photos <span class="badge green">Pixabay</span></div>
<div class="url-input-row">
<input type="text" class="url-field" id="pix-input" placeholder="mountains, coffee, city…">
<button class="p-btn acc" id="pix-search-btn">Go</button>
</div>
<div class="sec-label">Quick</div>
<div style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:10px" id="pix-quick">
<span class="ef-chip" data-q="nature">Nature</span>
<span class="ef-chip" data-q="business">Business</span>
<span class="ef-chip" data-q="food">Food</span>
<span class="ef-chip" data-q="technology">Tech</span>
<span class="ef-chip" data-q="abstract">Abstract</span>
<span class="ef-chip" data-q="people">People</span>
</div>
<div id="pix-status" style="font-size:11px;color:var(--txt3);margin-bottom:8px;display:none"></div>
<div class="thumb-grid" id="pix-results"></div>
</div>
<!-- ELEMENTS / SHAPES -->
<div class="panel-section" id="sec-elements">
<!-- Style controls kept as hidden default-stores; styling is done via the top bar + Inspector -->
<div style="display:none">
<div class="shape-style-row" style="display:none">
<input type="color" id="sh-fill" value="#7c3aed">
<input type="checkbox" id="sh-no-fill">
<input type="color" id="sh-stroke" value="#111111">
</div>
<div style="font-size:10px;color:var(--txt3);margin-bottom:6px">Fill &amp; stroke colour → top bar / popup</div>
<div class="shape-style-row">
<span class="ss-label">Stroke W</span>
<input type="number" class="num-field" id="sh-stroke-w" value="0" min="0" max="30" style="width:52px;margin-left:4px">
</div>
<div class="shape-style-row">
<span class="ss-label">Opacity</span>
<input type="range" id="sh-opacity" min="0" max="1" step="0.01" value="1" style="flex:1">
<span id="sh-op-val" style="font-size:11px;font-family:var(--mono);min-width:32px;text-align:right">100%</span>
</div>
<div class="shape-style-row">
<span class="ss-label">Corners</span>
<input type="range" id="sh-radius" min="0" max="120" step="1" value="0" style="flex:1">
<span id="sh-radius-val" style="font-size:11px;font-family:var(--mono);min-width:32px;text-align:right">0</span>
</div>
<div class="shape-style-row">
<span class="ss-label">Border</span>
<select id="sh-dash" style="flex:1">
<option value="solid">━ Solid</option>
<option value="dashed">┅ Dashed</option>
<option value="dotted">┈ Dotted</option>
</select>
</div>
</div>
<input type="text" class="shapes-search" id="shape-search" placeholder="Search shapes &amp; online icons…">
<div class="sec-label" id="icon-results-label" style="display:none;margin-top:10px">Online icons <span class="badge purple">Iconify · 200k+</span></div>
<div id="icon-results"></div>
<div id="shapes-container">
<!-- filled by JS -->
</div>
</div>
<!-- TEXT -->
<div class="panel-section" id="sec-text">
<div class="sec-label">Add Text</div>
<button class="text-preset heading" id="tp-heading">Add a heading</button>
<button class="text-preset subheading" id="tp-sub">Add a subheading</button>
<button class="text-preset body-text" id="tp-body">Add body text</button>
<button class="text-preset caption" id="tp-caption">Add caption</button>
<div id="txt-style-controls">
<div class="sec-label" style="margin-top:10px">Font</div>
<select id="txt-font" style="margin-bottom:8px">
<optgroup label="System"><option value="Arial">Arial</option><option value="Georgia">Georgia</option><option value="Times New Roman">Times New Roman</option><option value="Verdana">Verdana</option><option value="Impact">Impact</option></optgroup>
<optgroup label="Sans-Serif"><option value="'Poppins',sans-serif">Poppins</option><option value="'Montserrat',sans-serif">Montserrat</option><option value="'Raleway',sans-serif">Raleway</option><option value="'Nunito',sans-serif">Nunito</option><option value="'Inter',sans-serif">Inter</option><option value="'Space Grotesk',sans-serif">Space Grotesk</option><option value="'Plus Jakarta Sans',sans-serif">Plus Jakarta Sans</option><option value="'Manrope',sans-serif">Manrope</option><option value="'Sora',sans-serif">Sora</option></optgroup>
<optgroup label="Serif"><option value="'Playfair Display',serif">Playfair Display</option><option value="'Lora',serif">Lora</option><option value="'Merriweather',serif">Merriweather</option><option value="'Cormorant',serif">Cormorant</option><option value="'Abril Fatface',serif">Abril Fatface</option></optgroup>
<optgroup label="Display"><option value="'Bebas Neue',sans-serif">Bebas Neue</option><option value="'Pacifico',cursive">Pacifico</option><option value="'Dancing Script',cursive">Dancing Script</option><option value="'Caveat',cursive">Caveat</option><option value="'Russo One',sans-serif">Russo One</option></optgroup>
<optgroup label="Mono"><option value="'JetBrains Mono',monospace">JetBrains Mono</option><option value="'Fira Code',monospace">Fira Code</option><option value="'Space Mono',monospace">Space Mono</option></optgroup>
</select>
<div class="fc-row">
<span class="fc-label">Size</span>
<input type="number" id="txt-size" value="36" min="6" max="400" class="num-field" style="width:62px">
<span style="flex:1;font-size:10px;color:var(--txt3)">Color → top bar / popup</span>
<input type="color" id="txt-color" value="#111111" style="display:none">
</div>
<div class="icon-row" style="margin-bottom:8px">
<button class="ir-btn" id="txt-bold" style="font-weight:700">B</button>
<button class="ir-btn" id="txt-italic" style="font-style:italic">I</button>
<button class="ir-btn" id="txt-underline" style="text-decoration:underline">U</button>
<button class="ir-btn" id="txt-align-l" title="Align left"></button>
<button class="ir-btn" id="txt-align-c" title="Center"></button>
<button class="ir-btn" id="txt-align-r" title="Align right"></button>
<button class="ir-btn" id="txt-align-j" title="Justify"></button>
</div>
<div class="fc-row">
<span class="fc-label">Letter</span>
<input type="range" id="txt-charspace" min="-100" max="1000" step="10" value="0" title="Letter spacing">
<span class="fc-val" id="txt-cs-val">0</span>
</div>
<div class="fc-row">
<span class="fc-label">Line</span>
<input type="range" id="txt-lineheight" min="0.7" max="3" step="0.05" value="1.16" title="Line / sentence spacing">
<span class="fc-val" id="txt-lh-val">1.16</span>
</div>
<div class="fc-row">
<span class="fc-label">Bend</span>
<input type="range" id="txt-bend" min="-100" max="100" step="1" value="0" title="Curve / bend text (type on an arc)">
<span class="fc-val" id="txt-bend-v">0</span>
</div>
<div class="fc-row">
<span class="fc-label">Opacity</span>
<input type="range" id="txt-opacity" min="0" max="1" step="0.01" value="1">
<span class="fc-val" id="txt-op-val">100%</span>
</div>
<div class="fc-row">
<span class="fc-label">Stroke</span>
<input type="color" id="txt-stroke-color" value="#000000">
<input type="number" id="txt-stroke-w" value="0" min="0" max="20" class="num-field" style="width:52px">
</div>
<label class="chk-row"><input type="checkbox" id="txt-shadow"> Drop shadow</label>
</div><!-- /txt-style-controls (moved into the Inspector at startup) -->
<div style="font-size:10.5px;color:var(--txt3);margin:6px 0 8px;line-height:1.5">Select a text object to edit its font &amp; style in the <b>Inspector →</b></div>
<input type="text" class="input-field" id="txt-content" placeholder="Type your text…" style="margin-bottom:8px">
<button class="p-btn acc full" id="btn-add-text">+ Add Text</button>
</div>
<!-- BACKGROUND -->
<div class="panel-section" id="sec-background">
<div class="sec-label">Solid Color</div>
<div class="bg-colors" id="bg-palette"></div>
<div class="fc-row" style="margin-bottom:14px">
<span class="fc-label">Custom</span>
<input type="color" id="bg-color-pick" value="#ffffff">
<button class="p-btn" id="btn-apply-bg" style="flex:1">Apply</button>
</div>
<div class="sec-label">Gradient</div>
<div class="sub-section">
<div class="fc-row">
<span class="fc-label">From</span>
<input type="color" id="grad-from" value="#667eea">
<span class="fc-label">To</span>
<input type="color" id="grad-to" value="#764ba2">
</div>
<select id="grad-dir" style="margin-bottom:8px">
<option value="h">→ Horizontal</option>
<option value="v">↓ Vertical</option>
<option value="dr">↘ Diagonal ↘</option>
<option value="ur">↗ Diagonal ↗</option>
</select>
<button class="p-btn full" id="btn-apply-grad">Apply Gradient</button>
</div>
<div class="sec-label">Presets</div>
<div class="grad-presets-grid" id="grad-presets"></div>
<div class="sec-label">Transparency</div>
<button class="p-btn full" id="btn-transparent-bg">Set Transparent</button>
</div>
<!-- VECTORIZE / TRACE -->
<div class="panel-section" id="sec-trace">
<div class="sec-label">Image → Vector <span class="badge green">Trace</span></div>
<button class="p-btn full" id="tr-use-sel" style="margin-bottom:5px">🎯 Use selected image</button>
<div style="display:flex;gap:6px">
<button class="p-btn" id="tr-upload" style="flex:1">⬆ Upload</button>
<button class="p-btn" id="tr-paste" style="flex:1">⎘ Paste</button>
</div>
<input type="file" id="tr-file" accept="image/*" hidden>
<div class="sec-label" style="margin-top:14px">Colors</div>
<div id="tr-modes" style="display:grid;grid-template-columns:repeat(4,1fr);gap:6px">
<button class="p-btn tr-mode" data-mode="bw">B&amp;W</button>
<button class="p-btn tr-mode" data-mode="2">2</button>
<button class="p-btn tr-mode" data-mode="3">3</button>
<button class="p-btn tr-mode" data-mode="4">4</button>
<button class="p-btn tr-mode" data-mode="6">6</button>
<button class="p-btn tr-mode" data-mode="8">8</button>
<button class="p-btn tr-mode" data-mode="12">12</button>
<button class="p-btn tr-mode acc" data-mode="full">Full</button>
</div>
<div class="sec-label" style="margin-top:14px">Edge style</div>
<div id="tr-edge" style="display:grid;grid-template-columns:1fr 1fr;gap:6px">
<button class="p-btn tr-edgebtn sel" data-edge="smooth">⌒ Curvy</button>
<button class="p-btn tr-edgebtn" data-edge="sharp">⊾ Sharp</button>
</div>
<div class="sec-label" style="margin-top:14px">Detail</div>
<input type="range" id="tr-detail" min="0" max="3" step="1" value="2" style="width:100%">
<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--txt3)"><span>Smooth</span><span>Detailed</span></div>
<div id="tr-status" style="font-size:11px;color:var(--acc);margin-top:10px;display:none"></div>
</div>
<!-- 3D STUDIO PRO -->
<div class="panel-section" id="sec-model3d">
<div class="sec-label">3D Studio <span class="badge green">Pro</span></div>
<button class="p-btn acc full" id="m3-enter" style="margin-bottom:8px">🧊 Open 3D Workspace</button>
<div style="font-size:11px;color:var(--txt3);margin-bottom:10px">A full 3D modeller — primitives, gizmos, PBR materials, lights, booleans, import & export. Like a mini-Blender, in the browser.</div>
<div class="sec-label">Add Mesh</div>
<div class="m3-grid">
<button class="m3-add" data-prim="box"><span>Cube</span></button>
<button class="m3-add" data-prim="sphere"><span>Sphere</span></button>
<button class="m3-add" data-prim="cylinder">🥫<span>Cylinder</span></button>
<button class="m3-add" data-prim="cone">🔺<span>Cone</span></button>
<button class="m3-add" data-prim="torus">🍩<span>Torus</span></button>
<button class="m3-add" data-prim="plane"><span>Plane</span></button>
<button class="m3-add" data-prim="ground">🟫<span>Ground</span></button>
<button class="m3-add" data-prim="capsule">💊<span>Capsule</span></button>
<button class="m3-add" data-prim="disc"><span>Disc</span></button>
<button class="m3-add" data-prim="icosphere"><span>Icosph.</span></button>
<button class="m3-add" data-prim="torusknot"><span>Knot</span></button>
<button class="m3-add" data-prim="polyhedron">🔷<span>Polyhed.</span></button>
</div>
<div class="sec-label" style="margin-top:14px">Add Light</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px">
<button class="p-btn m3-light" data-light="point">💡 Point</button>
<button class="p-btn m3-light" data-light="spot">🔦 Spot</button>
<button class="p-btn m3-light" data-light="directional">☀ Sun</button>
<button class="p-btn m3-light" data-light="hemispheric">🌥 Hemi</button>
</div>
<div class="sec-label" style="margin-top:14px">Scene</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px">
<button class="p-btn" id="m3-toggle-grid">▦ Grid</button>
<button class="p-btn" id="m3-toggle-sky">🌌 Sky</button>
<button class="p-btn" id="m3-toggle-ground">🟫 Floor</button>
<button class="p-btn" id="m3-toggle-shadow">🌑 Shadows</button>
</div>
<div class="chk-row" style="margin-top:8px;display:flex;align-items:center;gap:8px;font-size:12px">
<span>Background</span>
<input type="color" id="m3-bg-color" value="#1e1e26" style="width:34px;height:24px;padding:0;border:1px solid var(--brd);border-radius:5px;cursor:pointer">
</div>
<div class="sec-label" style="margin-top:14px">Import Model</div>
<button class="p-btn full" id="m3-import-btn" style="margin-bottom:6px">⬆ Upload .glb / .obj / .stl</button>
<input type="file" id="m3-import-file" accept=".glb,.gltf,.obj,.stl,.babylon" hidden>
<div class="url-input-row">
<input type="text" class="url-field" id="m3-import-url" placeholder="https://…/model.glb">
<button class="p-btn acc" id="m3-import-url-btn">Go</button>
</div>
<div class="sec-label" style="margin-top:14px">Export</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px">
<button class="p-btn" id="m3-export-glb">⬇ GLB</button>
<button class="p-btn" id="m3-export-png">🖼 PNG</button>
<button class="p-btn acc" id="m3-render-2d" style="grid-column:1/3">+ Add render to 2D canvas</button>
</div>
<div class="sec-label" style="margin-top:14px">Shortcuts</div>
<div style="font-size:10.5px;color:var(--txt3);line-height:1.7">
<b>G</b> move · <b>R</b> rotate · <b>S</b> scale · <b>X</b> delete<br>
<b>D</b> duplicate · <b>F</b> focus · <b>Ctrl-click</b> multi-select<br>
drag = orbit · wheel = zoom · right-drag = pan
</div>
</div>
<!-- LIBRARY -->
<div class="panel-section" id="sec-library">
<div class="sec-label">My Library <span class="badge green">Saved</span></div>
<button class="p-btn acc full" id="lib-save" style="margin-bottom:8px">+ Save selection to Library</button>
<div style="display:flex;gap:6px;margin-bottom:10px">
<button class="p-btn" id="lib-export" style="flex:1">⬇ Export</button>
<button class="p-btn" id="lib-import-btn" style="flex:1">⬆ Import</button>
<input type="file" id="lib-file" accept=".json,application/json" hidden>
</div>
<div id="lib-empty" style="font-size:11px;color:var(--txt3);margin-bottom:8px">Nothing saved yet. Select any object (or a traced vector) and tap “Save to Library”.</div>
<div class="thumb-grid" id="lib-grid"></div>
</div>
<div class="panel-section" id="sec-layers">
<div id="layers-list"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-top:8px">
<button class="p-btn" id="lay-up">↑ Forward</button>
<button class="p-btn" id="lay-dn">↓ Backward</button>
<button class="p-btn" id="lay-dup">⎘ Duplicate</button>
<button class="p-btn danger" id="lay-del">✕ Delete</button>
</div>
</div>
</div>
</div>
<!-- CANVAS -->
<div id="canvas-wrap">
<canvas id="ruler-h"></canvas>
<canvas id="ruler-v"></canvas>
<div id="ruler-corner">
<select id="ruler-unit" title="Ruler units">
<option value="px">px</option>
<option value="mm">mm</option>
<option value="cm">cm</option>
<option value="inch">in</option>
<option value="m">m</option>
</select>
</div>
<!-- AutoDraw-style suggestion bar (Smart Draw) -->
<div id="ai-suggest">
<span class="sg-title" id="sg-title">Did you mean</span>
<div class="sg-row" id="sg-row"></div>
<button class="sg-close" id="sg-close" title="Dismiss">×</button>
</div>
<div id="onboard">
<div class="ob-icon">🎨</div>
<div class="ob-title">Start your design</div>
<div class="ob-actions">
<button class="p-btn acc" onclick="document.getElementById('file-input').click()">Upload image</button>
<button class="p-btn" id="btn-sample-quick">Use sample</button>
</div>
</div>
<div id="canvas-toolbar">
<span class="ct-grip" id="ct-grip" title="Drag to move this toolbar"></span>
<!-- Contextual: shapes & text -->
<span class="ct-color-wrap">
<button class="ct-btn ctx-color" id="ct-color-btn" title="Fill color"><span class="ct-color-dot" id="ct-color-dot"></span></button>
<input type="color" id="ct-color-input" class="ct-color-ovl">
</span>
<!-- Contextual: images -->
<button class="ct-btn ctx-image" id="ct-adjust" title="Adjust & filters"></button>
<button class="ct-btn ctx-image" id="ct-crop" title="Crop"></button>
<div class="ct-sep ctx-sep"></div>
<!-- Common -->
<button class="ct-btn" id="ct-flip-h" title="Flip horizontal"></button>
<button class="ct-btn" id="ct-flip-v" title="Flip vertical"></button>
<button class="ct-btn" id="ct-rot-l" title="Rotate 90° left"></button>
<button class="ct-btn" id="ct-rot-r" title="Rotate 90° right"></button>
<div class="ct-sep"></div>
<button class="ct-btn" id="ct-bring-f" title="Bring forward"></button>
<button class="ct-btn" id="ct-send-b" title="Send backward"></button>
<button class="ct-btn" id="ct-dup" title="Duplicate"></button>
<div class="ct-sep"></div>
<span class="ct-op-ic" title="Opacity"></span>
<input type="range" id="obj-opacity" class="ct-opacity" min="0" max="1" step="0.01" value="1" title="Opacity">
<div class="ct-sep"></div>
<div id="ct-recent" title="Recently used colors — click to apply"></div>
<button class="ct-btn" id="ct-del" title="Delete" style="color:var(--red);margin-left:4px">🗑</button>
</div>
<!-- Image adjustments/filters popover (the #rp-adj panel is moved in here at startup) -->
<div id="ct-pop-adjust" class="ct-pop"></div>
<div id="canvas-container">
<canvas id="main-canvas"></canvas>
<!-- 3D overlay: transparent Babylon canvas sitting exactly over the 2D artboard.
pointer-events off by default so 2D editing is untouched; turned on while orbiting. -->
<canvas id="babylon-layer"></canvas>
<div id="crop-overlay">
<div id="crop-box">
<div class="crop-handle ch-tl"></div><div class="crop-handle ch-tr"></div>
<div class="crop-handle ch-bl"></div><div class="crop-handle ch-br"></div>
<div class="crop-handle ch-tm"></div><div class="crop-handle ch-bm"></div>
<div class="crop-handle ch-ml"></div><div class="crop-handle ch-mr"></div>
</div>
<div id="crop-toolbar">
<button class="p-btn acc" id="btn-crop-apply">✓ Apply</button>
<button class="p-btn" id="btn-crop-cancel">Cancel</button>
</div>
</div>
</div>
<!-- ===== 3D STUDIO PRO STAGE ===== -->
<div id="m3d-stage">
<canvas id="m3d-canvas"></canvas>
<div id="m3d-empty">Add a mesh from the 3D panel to begin →</div>
<div id="m3d-topbar" class="m3-float">
<button class="m3-tbtn on" id="m3t-select" title="Select (Q)"></button>
<button class="m3-tbtn" id="m3t-move" title="Move (G)"></button>
<button class="m3-tbtn" id="m3t-rotate" title="Rotate (R)"></button>
<button class="m3-tbtn" id="m3t-scale" title="Scale (S)"></button>
<div class="m3-tsep"></div>
<button class="m3-tbtn" id="m3t-snap" title="Toggle snapping"></button>
<button class="m3-tbtn" id="m3t-space" title="Local / World space">🌐</button>
<div class="m3-tsep"></div>
<button class="m3-tbtn" id="m3t-front" title="Front view">F</button>
<button class="m3-tbtn" id="m3t-top" title="Top view">T</button>
<button class="m3-tbtn" id="m3t-side" title="Side view">S</button>
<button class="m3-tbtn" id="m3t-persp" title="Perspective">¾</button>
<button class="m3-tbtn" id="m3t-focus" title="Focus selected (F)"></button>
<div class="m3-tsep"></div>
<button class="m3-tbtn" id="m3t-edit" title="Edit mode (Tab) — select 1 mesh first">✎ Edit</button>
<button class="m3-tbtn" id="m3t-wire" title="Wireframe"></button>
<button class="m3-tbtn" id="m3t-exit" title="Exit 3D workspace" style="color:#ff9a9a"></button>
</div>
<!-- Add-objects palette (always reachable inside the workspace) -->
<div id="m3d-add" class="m3-float">
<div class="m3-fhead" style="border:none;padding:6px 8px 4px">Add Object</div>
<div class="m3-apal">
<button class="m3-aadd" data-prim="box" title="Cube"></button>
<button class="m3-aadd" data-prim="sphere" title="UV Sphere"></button>
<button class="m3-aadd" data-prim="icosphere" title="Ico Sphere"></button>
<button class="m3-aadd" data-prim="cylinder" title="Cylinder">🥫</button>
<button class="m3-aadd" data-prim="cone" title="Cone">🔺</button>
<button class="m3-aadd" data-prim="pyramid" title="Pyramid">🔻</button>
<button class="m3-aadd" data-prim="prism" title="Prism">📐</button>
<button class="m3-aadd" data-prim="torus" title="Torus">🍩</button>
<button class="m3-aadd" data-prim="torusknot" title="Torus Knot"></button>
<button class="m3-aadd" data-prim="capsule" title="Capsule">💊</button>
<button class="m3-aadd" data-prim="tube" title="Tube">🧪</button>
<button class="m3-aadd" data-prim="polyhedron" title="Polyhedron">🔷</button>
<button class="m3-aadd" data-prim="plane" title="Plane"></button>
<button class="m3-aadd" data-prim="disc" title="Circle"></button>
<button class="m3-aadd" data-prim="grid" title="Grid"></button>
<button class="m3-aadd" data-prim="ground" title="Ground">🟫</button>
</div>
<div class="m3-fhead" style="border:none;padding:6px 8px 4px">Add Light</div>
<div class="m3-apal">
<button class="m3-alight" data-light="point" title="Point">💡</button>
<button class="m3-alight" data-light="spot" title="Spot">🔦</button>
<button class="m3-alight" data-light="directional" title="Sun"></button>
<button class="m3-alight" data-light="hemispheric" title="Hemi">🌥</button>
</div>
</div>
<!-- Edit-mode sub-toolbar (shown only in edit mode) -->
<div id="m3d-editbar" class="m3-float" style="display:none">
<span style="font-size:10px;color:#9a9ab0;padding:0 6px 0 4px">EDIT</span>
<button class="m3-tbtn on" id="m3e-vertex" title="Vertex select (1)">• Vertex</button>
<button class="m3-tbtn" id="m3e-edge" title="Edge select (2)">/ Edge</button>
<button class="m3-tbtn" id="m3e-face" title="Face select (3)">▰ Face</button>
<div class="m3-tsep"></div>
<button class="m3-tbtn" id="m3e-all" title="Select all (A)">All</button>
<button class="m3-tbtn" id="m3e-none" title="Deselect">None</button>
<button class="m3-tbtn" id="m3e-done" title="Exit edit mode (Tab)" style="color:#9ad7ff">✓ Done</button>
</div>
<div id="m3d-outliner" class="m3-float">
<div class="m3-fhead">Outliner <span id="m3-ocount" style="opacity:.6">0</span></div>
<div id="m3d-olist"></div>
</div>
<div id="m3d-props" class="m3-float">
<div class="m3-fhead">Properties</div>
<div class="m3-pbody" id="m3d-pbody">
<div style="color:#7a7a90;font-size:11.5px;padding:6px 0">Nothing selected.</div>
</div>
</div>
<div id="m3d-status" class="m3-float">Ready</div>
</div>
</div>
<!-- RIGHT PANEL -->
<div id="right-panel">
<div style="font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--txt3);padding:2px 2px 8px;border-bottom:1px solid var(--brd);margin-bottom:10px">Inspector</div>
<div class="rp-section" id="rp-pos">
<div class="rp-title" style="display:flex;align-items:center;justify-content:space-between">
<span>Position &amp; Size</span>
<select id="dim-unit" title="Measurement unit" style="font-size:10px;padding:2px 4px;border:1px solid var(--brd);border-radius:5px;background:var(--surf);color:var(--txt2)">
<option value="px">px</option>
<option value="mm">mm</option>
<option value="cm">cm</option>
<option value="m">m</option>
<option value="inch">in</option>
<option value="ft">ft</option>
</select>
</div>
<div class="pos-grid">
<div><div class="dim-label">X</div><input type="number" class="dim-input" id="pos-x"></div>
<div><div class="dim-label">Y</div><input type="number" class="dim-input" id="pos-y"></div>
<div><div class="dim-label">W</div><input type="number" class="dim-input" id="size-w"></div>
<div><div class="dim-label">H</div><input type="number" class="dim-input" id="size-h"></div>
</div>
<label class="chk-row"><input type="checkbox" id="lock-ratio" checked> Lock aspect ratio</label>
</div>
<!-- Appearance (fill, line colour, line type, opacity, corner) — contextual -->
<div class="rp-section" id="rp-style" style="display:none">
<div class="rp-title">Appearance</div>
<div class="fc-row"><span class="fc-label">Fill</span>
<button class="sb-sw" id="sb-fill-btn" title="Fill colour"><span class="sb-dot" id="sb-fill-dot"></span></button>
<input type="color" id="sb-fill" class="sb-hidden">
<button class="sb-sw" id="sb-grad-btn" title="Gradient fill" style="margin-left:auto"></button>
</div>
<div class="fc-row"><span class="fc-label">Line</span>
<button class="sb-sw" id="sb-stroke-btn" title="Line / stroke colour"><span class="sb-dot ring" id="sb-stroke-dot"></span></button>
<input type="color" id="sb-stroke" class="sb-hidden">
</div>
<div class="fc-row"><span class="fc-label">Line type</span>
<select id="sb-linestyle" class="sb-sel" style="flex:1"><option value="solid">─ Solid</option><option value="dashed">┄ Dashed</option><option value="dotted">┈ Dotted</option></select>
</div>
<div class="fc-row"><span class="fc-label">Opacity</span><input type="range" id="sb-opacity" min="0" max="1" step="0.01" value="1"></div>
<div class="fc-row" id="sb-corner-wrap"><span class="fc-label">Corner %</span><input type="number" id="sb-corner" min="0" max="100" value="0" class="fc-num"></div>
</div>
<!-- Text styling — the #txt-style-controls block is moved in here at startup -->
<div class="rp-section" id="rp-text" style="display:none">
<div class="rp-title">Text</div>
<div id="rp-text-body"></div>
</div>
<!-- Pathfinder (boolean ops) — visible when 2+ shapes are selected -->
<div class="rp-section" id="rp-pf" style="display:none">
<div class="rp-title">Pathfinder</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px">
<button class="p-btn pf-btn" id="pf-unite" title="Merge into one outline"><svg viewBox="0 0 24 24"><circle cx="9.5" cy="12" r="6" fill="currentColor"/><circle cx="14.5" cy="12" r="6" fill="currentColor"/></svg>Unite</button>
<button class="p-btn pf-btn" id="pf-subtract" title="Front minus back"><svg viewBox="0 0 24 24"><defs><mask id="pf-msub"><rect width="24" height="24" fill="#fff"/><circle cx="14.5" cy="12" r="6" fill="#000"/></mask></defs><circle cx="9.5" cy="12" r="6" fill="currentColor" mask="url(#pf-msub)"/><circle cx="14.5" cy="12" r="6" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>Subtract</button>
<button class="p-btn pf-btn" id="pf-intersect" title="Keep only the overlap"><svg viewBox="0 0 24 24"><defs><clipPath id="pf-cix"><circle cx="9.5" cy="12" r="6"/></clipPath></defs><circle cx="9.5" cy="12" r="6" fill="none" stroke="currentColor" stroke-width="1.4"/><circle cx="14.5" cy="12" r="6" fill="currentColor" clip-path="url(#pf-cix)"/><circle cx="14.5" cy="12" r="6" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>Intersect</button>
<button class="p-btn pf-btn" id="pf-exclude" title="Everything except the overlap"><svg viewBox="0 0 24 24"><defs><mask id="pf-mex1"><rect width="24" height="24" fill="#fff"/><circle cx="14.5" cy="12" r="6" fill="#000"/></mask><mask id="pf-mex2"><rect width="24" height="24" fill="#fff"/><circle cx="9.5" cy="12" r="6" fill="#000"/></mask></defs><circle cx="9.5" cy="12" r="6" fill="currentColor" mask="url(#pf-mex1)"/><circle cx="14.5" cy="12" r="6" fill="currentColor" mask="url(#pf-mex2)"/></svg>Exclude</button>
</div>
</div>
<!-- Fill & Effects (gradient, shadow, skew) — contextual, for shapes/text/vectors -->
<div class="rp-section" id="rp-fx" style="display:none">
<div class="rp-title">Fill &amp; Effects</div>
<div class="fc-row"><span class="fc-label">Gradient</span>
<input type="color" id="sh-grad-from" value="#7c3aed">
<input type="color" id="sh-grad-to" value="#ec4899">
<select id="sh-grad-dir" style="flex:1">
<option value="h"></option><option value="v"></option><option value="dr"></option><option value="ur"></option>
</select>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-bottom:9px">
<button class="p-btn acc" id="sh-grad-apply">Apply gradient</button>
<button class="p-btn" id="sh-grad-clear">Solid</button>
</div>
<label class="chk-row"><input type="checkbox" id="fx-shadow"> Drop shadow</label>
<div class="fc-row"><span class="fc-label">Shadow</span><input type="color" id="fx-shadow-color" value="#000000"><span class="fc-label">Blur</span><input type="range" id="fx-shadow-blur" min="0" max="40" value="8"></div>
<div class="fc-row"><span class="fc-label">Skew X</span><input type="range" id="fx-skewx" min="-60" max="60" step="1" value="0"><span class="fc-val" id="fx-skewx-v">0</span></div>
<div class="fc-row"><span class="fc-label">Skew Y</span><input type="range" id="fx-skewy" min="-60" max="60" step="1" value="0"><span class="fc-val" id="fx-skewy-v">0</span></div>
</div>
<div class="rp-section" id="rp-adj">
<div class="rp-title">Adjustments</div>
<div class="fc-row"><span class="fc-label">Brightness</span><input type="range" id="adj-brightness" min="-1" max="1" step="0.01" value="0"><span class="fc-val" id="bri-val">0</span></div>
<div class="fc-row"><span class="fc-label">Contrast</span><input type="range" id="adj-contrast" min="-1" max="1" step="0.01" value="0"><span class="fc-val" id="con-val">0</span></div>
<div class="fc-row"><span class="fc-label">Saturation</span><input type="range" id="adj-saturation" min="-1" max="1" step="0.01" value="0"><span class="fc-val" id="sat-val">0</span></div>
<div class="fc-row"><span class="fc-label">Blur</span><input type="range" id="adj-blur" min="0" max="20" step="0.5" value="0"><span class="fc-val" id="blur-val">0</span></div>
<div class="sec-label" style="margin-top:6px;margin-bottom:6px">Quick Filters</div>
<div class="effect-list" id="filter-chips">
<span class="ef-chip active" data-filter="none">None</span>
<span class="ef-chip" data-filter="grayscale">B&amp;W</span>
<span class="ef-chip" data-filter="sepia">Sepia</span>
<span class="ef-chip" data-filter="invert">Invert</span>
<span class="ef-chip" data-filter="vintage">Vintage</span>
<span class="ef-chip" data-filter="polaroid">Polaroid</span>
<span class="ef-chip" data-filter="kodachrome">Kodachrome</span>
<span class="ef-chip" data-filter="brownie">Brownie</span>
<span class="ef-chip" data-filter="sharpen">Sharpen</span>
</div>
</div>
<!-- 3D: extrude / revolve the selected vector into a real mesh (Illustrator-style) -->
<div class="rp-section" id="rp-3d" style="display:none">
<div class="rp-title">3D · Extrude · Revolve · Inflate</div>
<div class="effect-list" id="d3-mode" style="margin-bottom:8px">
<span class="ef-chip active" data-mode="extrude">⬚ Extrude</span>
<span class="ef-chip" data-mode="revolve">⟳ Revolve</span>
<span class="ef-chip" data-mode="inflate">◓ Inflate</span>
</div>
<div class="fc-row" id="d3-row-depth"><span class="fc-label">Depth</span><input type="range" id="d3-depth" min="2" max="300" step="1" value="60"><span class="fc-val" id="d3-depth-v">60</span></div>
<div class="fc-row" id="d3-row-bevel"><span class="fc-label">Bevel</span><input type="range" id="d3-bevel" min="0" max="40" step="0.5" value="6"><span class="fc-val" id="d3-bevel-v">6</span></div>
<div class="fc-row" id="d3-row-arc" style="display:none"><span class="fc-label">Angle°</span><input type="range" id="d3-arc" min="30" max="360" step="5" value="360"><span class="fc-val" id="d3-arc-v">360</span></div>
<div class="fc-row" id="d3-row-seg" style="display:none"><span class="fc-label">Segments</span><input type="range" id="d3-seg" min="6" max="96" step="2" value="48"><span class="fc-val" id="d3-seg-v">48</span></div>
<div class="fc-row"><span class="fc-label">Line style</span>
<select id="d3-line" style="flex:1;padding:5px 7px;border:1.5px solid var(--brd);border-radius:var(--r);background:var(--surf);color:var(--txt);font-size:11px">
<option value="none">None</option>
<option value="thin">Thin outline</option>
<option value="bold">Bold outline</option>
<option value="wire">Wireframe</option>
</select>
</div>
<div class="fc-row"><span class="fc-label">Color</span><input type="color" id="d3-color" value="#7c3aed"><label class="chk-row" style="margin:0 0 0 8px;font-size:10.5px"><input type="checkbox" id="d3-auto-color" checked> auto</label></div>
<div class="fc-row"><span class="fc-label">Metal</span><input type="range" id="d3-metal" min="0" max="1" step="0.02" value="0.1"><span class="fc-val" id="d3-metal-v">0.10</span></div>
<div class="fc-row"><span class="fc-label">Rough</span><input type="range" id="d3-rough" min="0" max="1" step="0.02" value="0.45"><span class="fc-val" id="d3-rough-v">0.45</span></div>
<button class="p-btn acc full" id="d3-create" style="margin-top:6px">✦ Make 3D from selection</button>
<div style="font-size:10px;color:var(--txt3);margin-top:6px;line-height:1.5">Tip: with a 3D object selected, these sliders update it live.</div>
</div>
<!-- 3D scene controls — visible once at least one mesh exists -->
<div class="rp-section" id="rp-3d-scene" style="display:none">
<div class="rp-title">3D Objects</div>
<div id="d3-objlist" style="display:flex;flex-direction:column;gap:4px;margin-bottom:6px"></div>
<div id="d3-sel-info" style="font-size:10.5px;color:var(--txt3);margin-bottom:8px">No 3D objects yet</div>
<div class="sec-label" style="margin-bottom:5px">Rotate &amp; resize (selected only)</div>
<div class="fc-row"><span class="fc-label">Spin Y</span><input type="range" id="d3-ry" min="-180" max="180" step="1" value="0"><input type="number" class="fc-num" id="d3-ry-v" step="1" value="0"></div>
<div class="fc-row"><span class="fc-label">Tilt X</span><input type="range" id="d3-rx" min="-90" max="90" step="1" value="0"><input type="number" class="fc-num" id="d3-rx-v" step="1" value="0"></div>
<div class="fc-row"><span class="fc-label">Roll Z</span><input type="range" id="d3-rz" min="-180" max="180" step="1" value="0"><input type="number" class="fc-num" id="d3-rz-v" step="1" value="0"></div>
<div class="fc-row"><span class="fc-label">Size</span><input type="range" id="d3-scale" min="0.2" max="3" step="0.01" value="1"><input type="number" class="fc-num" id="d3-scale-v" step="0.05" value="1"></div>
<div style="font-size:10px;color:var(--txt3);margin:4px 0 8px;line-height:1.5">Click a 3D object on the canvas to select &amp; <b>drag</b> it. Click a 2D object to edit 2D. Click empty space to deselect.</div>
<label class="chk-row"><input type="checkbox" id="d3-orbit"> 🔄 Orbit view — drag to look around</label>
<div class="sec-label" style="margin-bottom:5px">Lighting</div>
<div class="fc-row"><span class="fc-label">Key color</span><input type="color" id="d3-lcolor" value="#ffffff"><span style="flex:1"></span></div>
<div class="fc-row"><span class="fc-label">Key power</span><input type="range" id="d3-lpow" min="0" max="3" step="0.05" value="0.95"><span class="fc-val" id="d3-lpow-v">0.95</span></div>
<div class="fc-row"><span class="fc-label">Direction</span><input type="range" id="d3-lazi" min="0" max="360" step="1" value="210"><span class="fc-val" id="d3-lazi-v">210°</span></div>
<div class="fc-row"><span class="fc-label">Height</span><input type="range" id="d3-lele" min="5" max="90" step="1" value="55"><span class="fc-val" id="d3-lele-v">55°</span></div>
<label class="chk-row"><input type="checkbox" id="d3-fill" checked> Fill light</label>
<div class="fc-row"><span class="fc-label">Fill power</span><input type="range" id="d3-fpow" min="0" max="2" step="0.05" value="0.45"><span class="fc-val" id="d3-fpow-v">0.45</span></div>
<div class="fc-row"><span class="fc-label">Ambient</span><input type="range" id="d3-amb" min="0" max="2" step="0.05" value="0.9"><span class="fc-val" id="d3-amb-v">0.90</span></div>
<div class="fc-row"><span class="fc-label">Amb color</span><input type="color" id="d3-acolor" value="#ffffff"><span style="flex:1"></span></div>
<div class="sec-label" style="margin:8px 0 5px">Camera</div>
<div class="effect-list" id="d3-proj" style="margin-bottom:6px">
<span class="ef-chip" data-proj="persp">Perspective</span>
<span class="ef-chip active" data-proj="ortho">Orthographic</span>
</div>
<div class="sec-label" style="margin:8px 0 5px">Scene</div>
<label class="chk-row"><input type="checkbox" id="d3-autorot"> Auto-spin view</label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-top:6px">
<button class="p-btn" id="d3-bake">📸 Bake to 2D</button>
<button class="p-btn" id="d3-save">💾 Save PNG</button>
<button class="p-btn" id="d3-frame">⊡ Frame all</button>
<button class="p-btn danger" id="d3-del">✕ Delete</button>
</div>
<button class="p-btn full danger" id="d3-clear" style="margin-top:5px">⊗ Clear all 3D</button>
</div>
</div>
</div>
<div id="statusbar">
<div class="sb-item"><div class="sb-dot" id="sb-dot"></div><span id="sb-status">Ready</span></div>
<div class="sb-sep"></div>
<div class="sb-item">Canvas: <strong id="sb-canvas">800×600</strong></div>
<div class="sb-sep"></div>
<div class="sb-item">Objects: <strong id="sb-objs">0</strong></div>
<div class="sb-sep"></div>
<div class="sb-item" id="sb-sel" title="Selected object"></div>
<div class="sb-sep"></div>
<div class="sb-item" id="sb-pos">Cursor: —</div>
<div class="sb-sep"></div>
<div class="sb-item" id="sb-visits" title="Total visits to this Space" style="font-weight:600;color:var(--acc)">👁 …</div>
<div style="margin-left:auto;display:flex;align-items:center;gap:10px">
<span style="opacity:.45">Sri Axis · by Mihjaz</span>
<a href="https://www.facebook.com/mlmihjaz" target="_blank" rel="noopener" style="color:#1877f2;text-decoration:none;font-weight:700">Follow on Facebook</a>
<a href="https://wa.me/94777869736" target="_blank" rel="noopener" style="color:#25d366;text-decoration:none;font-weight:700">WhatsApp</a>
</div>
</div>
<!-- EXPORT MODAL -->
<div id="modal-overlay">
<div id="export-modal">
<div class="modal-header"><div class="modal-title">Export / Download</div><button class="modal-close" id="modal-close">×</button></div>
<div class="modal-body">
<div class="rp-title" style="margin-bottom:10px">Format</div>
<div class="fmt-cards">
<div class="fmt-card sel" data-fmt="webp"><div class="fmt-name">WebP</div><div class="fmt-sub">Best quality/size</div></div>
<div class="fmt-card" data-fmt="png"><div class="fmt-name">PNG</div><div class="fmt-sub">Lossless + alpha</div></div>
<div class="fmt-card" data-fmt="jpeg"><div class="fmt-name">JPG</div><div class="fmt-sub">Smallest</div></div>
<div class="fmt-card" data-fmt="svg"><div class="fmt-name">SVG</div><div class="fmt-sub">Vector</div></div>
</div>
<div class="fc-row" style="margin-bottom:12px">
<span class="fc-label">Quality</span>
<input type="range" id="modal-quality" min="0.1" max="1" step="0.01" value="0.9">
<span class="fc-val" id="modal-q-val">90%</span>
</div>
<div class="fc-row" style="margin-bottom:12px">
<span class="fc-label">Scale</span>
<input type="range" id="modal-scale" min="0.5" max="4" step="0.5" value="1">
<span class="fc-val" id="modal-scale-val"></span>
</div>
<div class="fc-row" style="margin-top:8px">
<span class="fc-label">Filename</span>
<input type="text" id="modal-fname" value="design" class="input-field" style="flex:1;margin:0">
</div>
</div>
<div class="modal-footer">
<button class="p-btn" id="modal-cancel">Cancel</button>
<button class="p-btn" id="btn-copy-img">⎘ Copy to clipboard</button>
<button class="p-btn acc" id="btn-modal-export">⬇ Download</button>
</div>
</div>
</div>
<!-- SIZE MODAL -->
<div id="size-modal-overlay">
<div id="size-modal">
<div class="modal-header"><div class="modal-title">Resize Canvas</div><button class="modal-close" id="size-modal-close">×</button></div>
<div class="modal-body">
<div class="rp-title" style="margin-bottom:10px">Common Sizes</div>
<div class="size-presets" id="size-presets"></div>
<div class="rp-title" style="margin-bottom:8px;display:flex;align-items:center;justify-content:space-between">
<span>Custom size</span>
<select id="size-unit" title="Unit" style="font-size:11px;padding:3px 6px;border:1px solid var(--brd);border-radius:6px;background:var(--surf);color:var(--txt2)">
<option value="px">px</option><option value="mm">mm</option><option value="cm">cm</option>
<option value="m">m</option><option value="inch">in</option><option value="ft">ft</option>
</select>
</div>
<div class="dim-group">
<div><div class="dim-label">Width</div><input type="number" class="dim-input" id="custom-w" value="800"></div>
<div><div class="dim-label">Height</div><input type="number" class="dim-input" id="custom-h" value="600"></div>
</div>
</div>
<div class="modal-footer">
<button class="p-btn" id="size-cancel">Cancel</button>
<button class="p-btn acc" id="btn-apply-size">Apply Size</button>
</div>
</div>
</div>
<!-- WELCOME MODAL -->
<div id="welcome-overlay">
<div id="welcome-modal">
<div class="wm-head">
<h2>👋 Welcome to Sri Axis</h2>
<p>A free &amp; open-source design studio</p>
</div>
<div class="wm-body">
<p class="wm-intro">We are <b>SriAI</b> 👋 we build many <b>free &amp; open-source AI-related projects</b> to give our community a free hand. Your feedback &amp; requirements shape what I build next — please reach out and support me!</p>
<div class="wm-contact">
<a class="wm-link" href="https://wa.me/+41779412181" target="_blank" rel="noopener">
<span class="wm-ic" style="background:#25d366"><svg viewBox="0 0 24 24"><path d="M17.47 14.38c-.3-.15-1.74-.86-2-.96-.27-.1-.46-.15-.65.15-.2.3-.75.96-.92 1.16-.17.2-.34.22-.63.07-.3-.15-1.25-.46-2.38-1.47-.88-.78-1.47-1.75-1.64-2.05-.17-.3-.02-.46.13-.6.13-.13.3-.34.45-.51.15-.17.2-.3.3-.5.1-.2.05-.37-.02-.52-.08-.15-.65-1.58-.9-2.16-.24-.57-.48-.49-.65-.5l-.56-.01c-.2 0-.5.07-.77.37-.26.3-1 .98-1 2.4 0 1.4 1.03 2.76 1.17 2.96.15.2 2.02 3.08 4.9 4.32.68.3 1.22.47 1.63.6.69.22 1.31.19 1.8.12.55-.08 1.74-.71 1.98-1.4.25-.68.25-1.27.17-1.39-.07-.12-.27-.2-.56-.34zM12 2a10 10 0 0 0-8.6 15.08L2 22l5.05-1.32A10 10 0 1 0 12 2z"/></svg></span>
WhatsApp · +41779412181
</a>
<a class="wm-link" href="mailto:info@sriai.edu.lk">
<span class="wm-ic" style="background:var(--acc)"></span>
mlmihjaz@gmail.com
</a>
<a class="wm-link" href="https://www.facebook.com/mlmihjaz" target="_blank" rel="noopener">
<span class="wm-ic" style="background:#1877f2"><svg viewBox="0 0 24 24"><path d="M22 12a10 10 0 1 0-11.56 9.88v-6.99H7.9V12h2.54V9.8c0-2.5 1.49-3.89 3.78-3.89 1.09 0 2.24.2 2.24.2v2.46h-1.26c-1.24 0-1.63.77-1.63 1.56V12h2.78l-.44 2.89h-2.34v6.99A10 10 0 0 0 22 12z"/></svg></span>
Follow &amp; support me on Facebook
</a>
</div>
</div>
<div class="wm-foot">
<button class="p-btn acc full" id="welcome-close">Start designing →</button>
</div>
</div>
</div>
<!-- CTX MENU -->
<div id="ctx-menu">
<div class="ctx-item" id="ctx-bring-f"><span class="ctx-icon"></span> Bring Forward</div>
<div class="ctx-item" id="ctx-send-b"><span class="ctx-icon"></span> Send Backward</div>
<div class="ctx-item" id="ctx-bring-front"><span class="ctx-icon"></span> Bring to Front</div>
<div class="ctx-item" id="ctx-send-back"><span class="ctx-icon"></span> Send to Back</div>
<div class="ctx-sep"></div>
<div class="ctx-item" id="ctx-copy"><span class="ctx-icon"></span> Copy <span class="ctx-kbd">Ctrl+C</span></div>
<div class="ctx-item" id="ctx-cut"><span class="ctx-icon"></span> Cut <span class="ctx-kbd">Ctrl+X</span></div>
<div class="ctx-item" id="ctx-paste"><span class="ctx-icon">📋</span> Paste <span class="ctx-kbd">Ctrl+V</span></div>
<div class="ctx-item" id="ctx-dup"><span class="ctx-icon"></span> Duplicate <span class="ctx-kbd">Ctrl+D</span></div>
<div class="ctx-sep"></div>
<div class="ctx-item" id="ctx-group"><span class="ctx-icon"></span> Group <span class="ctx-kbd">Ctrl+G</span></div>
<div class="ctx-item" id="ctx-ungroup"><span class="ctx-icon"></span> Ungroup</div>
<div class="ctx-item" id="ctx-flip-h"><span class="ctx-icon"></span> Flip Horizontal</div>
<div class="ctx-item" id="ctx-flip-v"><span class="ctx-icon"></span> Flip Vertical</div>
<div class="ctx-sep"></div>
<div class="ctx-item danger" id="ctx-del"><span class="ctx-icon">🗑</span> Delete <span class="ctx-kbd">Del</span></div>
</div>
<script>
'use strict';
// ============================================================
// SHAPE LIBRARY — fully inline SVG paths, no external fetch
// All paths are 24×24 viewBox (standard icon grid)
// ============================================================
const SHAPE_LIBRARY = [
{
cat: 'Basic Shapes',
shapes: [
{ id: 'rect', name: 'Rectangle', fabricType: 'rect', w:120, h:80 },
{ id: 'square', name: 'Square', fabricType: 'rect', w:90, h:90 },
{ id: 'roundrect', name: 'RoundRect', fabricType: 'rect', w:120, h:80, rx:14 },
{ id: 'circle', name: 'Circle', fabricType: 'ellipse', rx:55, ry:55 },
{ id: 'ellipse', name: 'Ellipse', fabricType: 'ellipse', rx:70, ry:45 },
{ id: 'line', name: 'Line', fabricType: 'line' },
{ id: 'hline', name: 'H-Line', fabricType: 'line-h' },
{ id: 'vline', name: 'V-Line', fabricType: 'line-v' },
{
id: 'triangle', name: 'Triangle', fabricType: 'path',
d: 'M12 3 L22 21 L2 21 Z',
preview: '<polygon points="12,3 22,21 2,21" fill="currentColor"/>'
},
{
id: 'tri-right', name: 'Rt Triangle', fabricType: 'path',
d: 'M3 3 L21 21 L3 21 Z',
preview: '<polygon points="3,3 21,21 3,21" fill="currentColor"/>'
},
{
id: 'tri-down', name: 'Tri Down', fabricType: 'path',
d: 'M2 3 L22 3 L12 21 Z',
preview: '<polygon points="2,3 22,3 12,21" fill="currentColor"/>'
},
]
},
{
cat: 'Polygons',
shapes: [
{
id: 'pentagon', name: 'Pentagon', fabricType: 'path',
d: 'M12 2 L21.8 9.1 L18 20.9 L6 20.9 L2.2 9.1 Z',
preview: '<polygon points="12,2 21.8,9.1 18,20.9 6,20.9 2.2,9.1" fill="currentColor"/>'
},
{
id: 'hexagon', name: 'Hexagon', fabricType: 'path',
d: 'M12 2 L20.66 7 L20.66 17 L12 22 L3.34 17 L3.34 7 Z',
preview: '<polygon points="12,2 20.66,7 20.66,17 12,22 3.34,17 3.34,7" fill="currentColor"/>'
},
{
id: 'octagon', name: 'Octagon', fabricType: 'path',
d: 'M8.1 2 L15.9 2 L22 8.1 L22 15.9 L15.9 22 L8.1 22 L2 15.9 L2 8.1 Z',
preview: '<polygon points="8.1,2 15.9,2 22,8.1 22,15.9 15.9,22 8.1,22 2,15.9 2,8.1" fill="currentColor"/>'
},
{
id: 'diamond', name: 'Diamond', fabricType: 'path',
d: 'M12 2 L22 12 L12 22 L2 12 Z',
preview: '<polygon points="12,2 22,12 12,22 2,12" fill="currentColor"/>'
},
{
id: 'parallelogram', name: 'Parallelogram', fabricType: 'path',
d: 'M6 4 L22 4 L18 20 L2 20 Z',
preview: '<polygon points="6,4 22,4 18,20 2,20" fill="currentColor"/>'
},
{
id: 'trapezoid', name: 'Trapezoid', fabricType: 'path',
d: 'M5 4 L19 4 L22 20 L2 20 Z',
preview: '<polygon points="5,4 19,4 22,20 2,20" fill="currentColor"/>'
},
]
},
{
cat: 'Stars & Bursts',
shapes: [
{
id: 'star5', name: 'Star 5pt', fabricType: 'path',
d: 'M12 2 L14.39 9.26 L22 9.27 L16.19 13.97 L18.36 21.27 L12 16.77 L5.64 21.27 L7.81 13.97 L2 9.27 L9.61 9.26 Z',
preview: '<polygon points="12,2 14.39,9.26 22,9.27 16.19,13.97 18.36,21.27 12,16.77 5.64,21.27 7.81,13.97 2,9.27 9.61,9.26" fill="currentColor"/>'
},
{
id: 'star6', name: 'Star 6pt', fabricType: 'path',
d: 'M12 2 L14 8 L20 8 L15 12 L17 18 L12 14.5 L7 18 L9 12 L4 8 L10 8 Z',
preview: '<polygon points="12,2 14,8 20,8 15,12 17,18 12,14.5 7,18 9,12 4,8 10,8" fill="currentColor"/>'
},
{
id: 'star4', name: 'Star 4pt', fabricType: 'path',
d: 'M12 2 L13.5 10.5 L22 12 L13.5 13.5 L12 22 L10.5 13.5 L2 12 L10.5 10.5 Z',
preview: '<polygon points="12,2 13.5,10.5 22,12 13.5,13.5 12,22 10.5,13.5 2,12 10.5,10.5" fill="currentColor"/>'
},
{
id: 'starburst', name: 'Starburst', fabricType: 'path',
d: 'M12 2L13.1 6.2L17 4L15.3 8L20 8L16.5 11L20 14L15.3 14L17 18L13.1 15.8L12 20L10.9 15.8L7 18L8.7 14L4 14L7.5 11L4 8L8.7 8L7 4L10.9 6.2Z',
preview: '<path d="M12 2L13.1 6.2L17 4L15.3 8L20 8L16.5 11L20 14L15.3 14L17 18L13.1 15.8L12 20L10.9 15.8L7 18L8.7 14L4 14L7.5 11L4 8L8.7 8L7 4L10.9 6.2Z" fill="currentColor"/>'
},
]
},
{
cat: 'Arrows',
shapes: [
{
id: 'arrow-r', name: 'Arrow →', fabricType: 'path',
d: 'M4 11 L16 11 L16 8 L22 12 L16 16 L16 13 L4 13 Z',
preview: '<path d="M4 11 L16 11 L16 8 L22 12 L16 16 L16 13 L4 13 Z" fill="currentColor"/>'
},
{
id: 'arrow-l', name: 'Arrow ←', fabricType: 'path',
d: 'M20 11 L8 11 L8 8 L2 12 L8 16 L8 13 L20 13 Z',
preview: '<path d="M20 11 L8 11 L8 8 L2 12 L8 16 L8 13 L20 13 Z" fill="currentColor"/>'
},
{
id: 'arrow-u', name: 'Arrow ↑', fabricType: 'path',
d: 'M13 20 L13 8 L16 8 L12 2 L8 8 L11 8 L11 20 Z',
preview: '<path d="M13 20 L13 8 L16 8 L12 2 L8 8 L11 8 L11 20 Z" fill="currentColor"/>'
},
{
id: 'arrow-d', name: 'Arrow ↓', fabricType: 'path',
d: 'M11 4 L11 16 L8 16 L12 22 L16 16 L13 16 L13 4 Z',
preview: '<path d="M11 4 L11 16 L8 16 L12 22 L16 16 L13 16 L13 4 Z" fill="currentColor"/>'
},
{
id: 'dbarrow', name: 'Double →←', fabricType: 'path',
d: 'M2 12 L7 8 L7 11 L17 11 L17 8 L22 12 L17 16 L17 13 L7 13 L7 16 Z',
preview: '<path d="M2 12 L7 8 L7 11 L17 11 L17 8 L22 12 L17 16 L17 13 L7 13 L7 16 Z" fill="currentColor"/>'
},
{
id: 'arrow-ne', name: 'Arrow ↗', fabricType: 'path',
d: 'M5 19 L16 8 L13 8 L16 5 L21 5 L21 10 L18 7 L7 18 Z',
preview: '<path d="M5 19 L16 8 L13 8 L16 5 L21 5 L21 10 L18 7 L7 18 Z" fill="currentColor"/>'
},
{
id: 'arrow-curved', name: 'Curved ↩', fabricType: 'path',
d: 'M12 5 C7 5 3 9 3 14 L3 17 L6 14 L3 11 L3 14 C3 10.7 7.1 8 12 8 C16.9 8 21 10.7 21 14 L21 15 L21 17 C21 13 17 9 12 5Z',
preview: '<path d="M12 5 C7 5 3 9 3 14 C3 10.7 7.1 8 12 8 C16.9 8 21 10.7 21 14" stroke="currentColor" stroke-width="2.5" fill="none"/>'
},
]
},
{
cat: 'Symbols & Icons',
shapes: [
{
id: 'heart', name: 'Heart', fabricType: 'path',
d: 'M12 21.593C10.76 20.525 2 14.252 2 8.535 2 5.48 4.315 3 7.163 3c1.658 0 3.084.89 3.837 2.186h2C13.753 3.89 15.179 3 16.837 3 19.685 3 22 5.48 22 8.535c0 5.717-8.76 11.99-10 13.058z',
preview: '<path d="M12 21.593C10.76 20.525 2 14.252 2 8.535 2 5.48 4.315 3 7.163 3c1.658 0 3.084.89 3.837 2.186h2C13.753 3.89 15.179 3 16.837 3 19.685 3 22 5.48 22 8.535c0 5.717-8.76 11.99-10 13.058z" fill="currentColor"/>'
},
{
id: 'star-filled', name: 'Star ★', fabricType: 'path',
d: 'M12 2L14.39 9.26L22 9.27L16.19 13.97L18.36 21.27L12 16.77L5.64 21.27L7.81 13.97L2 9.27L9.61 9.26Z',
preview: '<polygon points="12,2 14.39,9.26 22,9.27 16.19,13.97 18.36,21.27 12,16.77 5.64,21.27 7.81,13.97 2,9.27 9.61,9.26" fill="currentColor"/>'
},
{
id: 'checkmark', name: 'Checkmark', fabricType: 'path',
d: 'M4 12 L9 17 L20 6',
stroke: true,
preview: '<path d="M4 12 L9 17 L20 6" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>'
},
{
id: 'cross', name: 'Cross ×', fabricType: 'path',
d: 'M6 6 L18 18 M18 6 L6 18',
stroke: true,
preview: '<path d="M6 6 L18 18 M18 6 L6 18" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round"/>'
},
{
id: 'plus', name: 'Plus +', fabricType: 'path',
d: 'M12 4 L12 20 M4 12 L20 12',
stroke: true,
preview: '<path d="M12 4 L12 20 M4 12 L20 12" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round"/>'
},
{
id: 'speech', name: 'Speech', fabricType: 'path',
d: 'M4 4 L20 4 L20 15 L14 15 L12 20 L10 15 L4 15 Z',
preview: '<path d="M4 4 L20 4 L20 15 L14 15 L12 20 L10 15 L4 15 Z" fill="currentColor"/>'
},
{
id: 'cloud', name: 'Cloud', fabricType: 'path',
d: 'M6.5 20C4.57 20 3 18.43 3 16.5S4.57 13 6.5 13l.5.05A5.5 5.5 0 0 1 12 7a5.5 5.5 0 0 1 5 8.94.5.5 0 0 1-.03.06A3.5 3.5 0 0 1 19.5 20Z',
preview: '<path d="M6.5 20C4.57 20 3 18.43 3 16.5S4.57 13 6.5 13l.5.05A5.5 5.5 0 0 1 12 7a5.5 5.5 0 0 1 5 8.94A3.5 3.5 0 0 1 19.5 20Z" fill="currentColor"/>'
},
{
id: 'lightning', name: 'Lightning', fabricType: 'path',
d: 'M13 2 L5 14 L11 14 L11 22 L19 10 L13 10 Z',
preview: '<path d="M13 2 L5 14 L11 14 L11 22 L19 10 L13 10 Z" fill="currentColor"/>'
},
{
id: 'ban', name: 'Ban / No', fabricType: 'path',
d: 'M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2zM4.93 4.93l14.14 14.14',
stroke: true, fill: false,
preview: '<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07" stroke="currentColor" stroke-width="2"/>'
},
{
id: 'bookmark', name: 'Bookmark', fabricType: 'path',
d: 'M5 3 L19 3 L19 22 L12 17 L5 22 Z',
preview: '<path d="M5 3 L19 3 L19 22 L12 17 L5 22 Z" fill="currentColor"/>'
},
{
id: 'shield', name: 'Shield', fabricType: 'path',
d: 'M12 2 L20 6 L20 13 C20 17.4 16.4 21.3 12 22 C7.6 21.3 4 17.4 4 13 L4 6 Z',
preview: '<path d="M12 2 L20 6 L20 13 C20 17.4 16.4 21.3 12 22 C7.6 21.3 4 17.4 4 13 L4 6 Z" fill="currentColor"/>'
},
{
id: 'location', name: 'Location', fabricType: 'path',
d: 'M12 2 C8.13 2 5 5.13 5 9 C5 14.25 12 22 12 22 C12 22 19 14.25 19 9 C19 5.13 15.87 2 12 2 Z M12 11.5 C10.62 11.5 9.5 10.38 9.5 9 C9.5 7.62 10.62 6.5 12 6.5 C13.38 6.5 14.5 7.62 14.5 9 C14.5 10.38 13.38 11.5 12 11.5 Z',
preview: '<path d="M12 2 C8.13 2 5 5.13 5 9 C5 14.25 12 22 12 22 C12 22 19 14.25 19 9 C19 5.13 15.87 2 12 2 Z" fill="currentColor"/>'
},
]
},
{
cat: 'Organic & Decorative',
shapes: [
{
id: 'crescent', name: 'Crescent', fabricType: 'path',
d: 'M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z',
preview: '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" fill="currentColor"/>'
},
{
id: 'sun', name: 'Sun', fabricType: 'path',
d: 'M12 17A5 5 0 1 0 12 7a5 5 0 0 0 0 10zm0-15v2m0 14v2M4.93 4.93l1.41 1.41m9.9 9.9 1.41 1.41M2 12h2m14 0h2M4.93 19.07l1.41-1.41m9.9-9.9 1.41-1.41',
stroke: true, fill: false,
preview: '<circle cx="12" cy="12" r="5" fill="currentColor"/><g stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="2" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="22"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="2" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></g>'
},
{
id: 'droplet', name: 'Droplet', fabricType: 'path',
d: 'M12 2 C12 2 5 10 5 15 A7 7 0 0 0 19 15 C19 10 12 2 12 2 Z',
preview: '<path d="M12 2 C12 2 5 10 5 15 A7 7 0 0 0 19 15 C19 10 12 2 12 2 Z" fill="currentColor"/>'
},
{
id: 'leaf', name: 'Leaf', fabricType: 'path',
d: 'M17 8 C17 8 20 12 18 17 C16 22 11 22 8 19 C5 16 4 11 7 8 C10 5 15 4 17 8 Z M12 12 L7 19',
preview: '<path d="M17 8 C17 8 20 12 18 17 C16 22 11 22 8 19 C5 16 4 11 7 8 C10 5 15 4 17 8 Z" fill="currentColor"/>'
},
{
id: 'blob1', name: 'Blob 1', fabricType: 'path',
d: 'M16.5 4.5 C20 7 22 11 20.5 15.5 C19 20 14.5 22 10 21 C5.5 20 2 16 2.5 11.5 C3 7 7 3 11.5 2.5 C14 2.2 15.5 3.5 16.5 4.5 Z',
preview: '<path d="M16.5 4.5 C20 7 22 11 20.5 15.5 C19 20 14.5 22 10 21 C5.5 20 2 16 2.5 11.5 C3 7 7 3 11.5 2.5 C14 2.2 15.5 3.5 16.5 4.5 Z" fill="currentColor"/>'
},
{
id: 'blob2', name: 'Blob 2', fabricType: 'path',
d: 'M15 3 C19 4 22 8.5 21.5 13 C21 17.5 17.5 21 13 21.5 C8.5 22 4 19 3 15 C2 11 4.5 6 8 4 C10.5 2.5 13 2.5 15 3 Z',
preview: '<path d="M15 3 C19 4 22 8.5 21.5 13 C21 17.5 17.5 21 13 21.5 C8.5 22 4 19 3 15 C2 11 4.5 6 8 4 C10.5 2.5 13 2.5 15 3 Z" fill="currentColor"/>'
},
{
id: 'flower', name: 'Flower', fabricType: 'path',
d: 'M12 7 C12 7 10 4 7 4 C4 4 4 7 6 9 C8 11 7 12 7 12 C7 12 4 12 3 14 C2 16 4 18 7 17 C10 16 12 17 12 17 C12 17 11 20 13 21 C15 22 17 20 16 17 C15 14 17 13 17 13 C17 13 20 14 21 12 C22 10 20 8 17 9 C14 10 12 7 12 7 Z',
preview: '<circle cx="12" cy="12" r="4" fill="currentColor"/><circle cx="12" cy="5" r="3" fill="currentColor" opacity=".7"/><circle cx="12" cy="19" r="3" fill="currentColor" opacity=".7"/><circle cx="5" cy="12" r="3" fill="currentColor" opacity=".7"/><circle cx="19" cy="12" r="3" fill="currentColor" opacity=".7"/>'
},
]
},
{
cat: 'Lines & Dividers',
shapes: [
{ id: 'line-s', name: 'Solid Line', fabricType: 'line-h', dash: null },
{ id: 'line-d', name: 'Dashed Line', fabricType: 'line-h', dash: [8,4] },
{ id: 'line-dt', name: 'Dotted Line', fabricType: 'line-h', dash: [2,4] },
{ id: 'line-v2', name: 'Vert. Line', fabricType: 'line-v' },
{ id: 'line-diag',name:'Diagonal', fabricType: 'line-diag' },
{
id: 'wave', name: 'Wave', fabricType: 'path',
d: 'M2 12 C4 8 6 8 8 12 C10 16 12 16 14 12 C16 8 18 8 20 12 C22 16 24 16 24 12',
stroke: true, fill: false,
preview: '<path d="M2 12 C4 8 6 8 8 12 C10 16 12 16 14 12 C16 8 18 8 20 12" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round"/>'
},
]
},
{
cat: 'More Shapes',
shapes: [
{ id:'semicircle', name:'Semicircle', fabricType:'path',
d:'M2 18 A10 10 0 0 1 22 18 Z',
preview:'<path d="M2 18 A10 10 0 0 1 22 18 Z" fill="currentColor"/>' },
{ id:'quarter', name:'Quarter', fabricType:'path',
d:'M4 20 L4 4 A16 16 0 0 1 20 20 Z',
preview:'<path d="M4 20 L4 4 A16 16 0 0 1 20 20 Z" fill="currentColor"/>' },
{ id:'chevron', name:'Chevron', fabricType:'path',
d:'M4 5 L12 13 L20 5 L20 9 L12 17 L4 9 Z',
preview:'<path d="M4 5 L12 13 L20 5 L20 9 L12 17 L4 9 Z" fill="currentColor"/>' },
{ id:'plus-f', name:'Plus', fabricType:'path',
d:'M9 3 L15 3 L15 9 L21 9 L21 15 L15 15 L15 21 L9 21 L9 15 L3 15 L3 9 L9 9 Z',
preview:'<path d="M9 3 L15 3 L15 9 L21 9 L21 15 L15 15 L15 21 L9 21 L9 15 L3 15 L3 9 L9 9 Z" fill="currentColor"/>' },
{ id:'chatbubble', name:'Chat', fabricType:'path',
d:'M5 4 Q3 4 3 6 L3 14 Q3 16 5 16 L8 16 L11 20 L11 16 L17 16 Q19 16 19 14 L19 6 Q19 4 17 4 Z',
preview:'<path d="M5 4 Q3 4 3 6 L3 14 Q3 16 5 16 L8 16 L11 20 L11 16 L17 16 Q19 16 19 14 L19 6 Q19 4 17 4 Z" fill="currentColor"/>' },
{ id:'ribbon', name:'Ribbon', fabricType:'path',
d:'M4 4 L20 4 L20 20 L16 16 L12 20 L8 16 L4 20 Z',
preview:'<path d="M4 4 L20 4 L20 20 L16 16 L12 20 L8 16 L4 20 Z" fill="currentColor"/>' },
{ id:'flag', name:'Flag', fabricType:'path',
d:'M4 4 L18 4 L14 9 L18 14 L4 14 Z',
preview:'<path d="M4 4 L18 4 L14 9 L18 14 L4 14 Z" fill="currentColor"/>' },
{ id:'pacman', name:'Pac', fabricType:'path',
d:'M21 7 A10 10 0 1 0 21 17 L12 12 Z',
preview:'<path d="M21 7 A10 10 0 1 0 21 17 L12 12 Z" fill="currentColor"/>' },
]
},
];
// Flat path-data lookup for AI module: { 'heart': 'M...Z', 'star5': 'M...', ... }
window.LTM_SHAPES_LIB = (function () {
const out = {};
SHAPE_LIBRARY.forEach(cat => cat.shapes.forEach(s => { if (s.d) out[s.id] = s.d; }));
return out;
})();
// ============================================================
// HELPERS
// ============================================================
const $i = id => document.getElementById(id);
const qs = (s, ctx) => (ctx || document).querySelector(s);
const qsa = (s, ctx) => [...(ctx || document).querySelectorAll(s)];
function toast(msg, type) {
const t = $i('toast');
t.textContent = msg;
t.className = 'show' + (type === 'err' ? ' err' : '');
clearTimeout(t._t);
t._t = setTimeout(() => t.className = '', 2800);
}
window.toast = toast;
function setStatus(msg, warn) {
$i('sb-status').textContent = msg;
$i('sb-dot').className = 'sb-dot' + (warn ? ' warn' : '');
}
// ============================================================
// STATE
// ============================================================
let canvas, zoom = 1, fmt = 'webp', exportQuality = 0.9, exportScale = 1;
let hist = [], hidx = -1, undoing = false;
let boldOn = false, italicOn = false, underlineOn = false, textAlign = 'left';
let cropActive = false, copiedObject = null, activeFilter = 'none';
let cropState = {};
let penMode = false, penPoints = [], penTemp = [];
let drawMode = false, drawing = false, drawPts = [];
let activeTool = null, brushActive = false;
let lineOn = false, linePts = [], lineCur = null;
let penBezOn = false, bezAnchors = [], bezDrag = false, bezCur = null;
let nodeEditOn = false, editNodes = [], editClosed = false, editStyle = null, editDrag = null;
let snapLines = [], autosaveTimer = null;
let panning = false, altDragPending = null;
let rulerUnit = 'px', rulerCursor = null;
// ============================================================
// CANVAS INIT
// ============================================================
function initCanvas(w, h) {
w = w || 800; h = h || 600;
if (canvas) canvas.dispose();
canvas = new fabric.Canvas('main-canvas', {
width: w, height: h, backgroundColor: '#ffffff', preserveObjectStacking: true, selection: true
});
window.canvas = canvas;
// Clean, Canva-style selection handles (look set once; size is kept constant
// on-screen across zoom levels by syncControlScale()).
Object.assign(fabric.Object.prototype, {
transparentCorners: false, cornerStyle: 'circle', cornerColor: '#ffffff',
cornerStrokeColor: '#7c3aed', borderColor: '#7c3aed', borderOpacityWhenMoving: 1
});
syncControlScale();
canvas.on('object:added', onCanvasChange);
canvas.on('object:removed', onCanvasChange);
canvas.on('object:modified', onCanvasChange);
// Keep the right-panel X / Y / W / H live while dragging, scaling or rotating.
['object:moving', 'object:scaling', 'object:rotating'].forEach(ev =>
canvas.on(ev, e => { if (e.target) updateRightPanel(e.target); }));
canvas.on('selection:created', onSelectionChange);
canvas.on('selection:updated', onSelectionChange);
canvas.on('selection:cleared', () => {
$i('canvas-toolbar').classList.remove('visible');
if ($i('ct-pop-adjust')) $i('ct-pop-adjust').classList.remove('open');
$i('sb-sel').textContent = '—';
syncStyleBar(null);
});
canvas.on('mouse:move', e => {
if (e.pointer) $i('sb-pos').textContent = `Cursor: ${Math.round(e.pointer.x)}, ${Math.round(e.pointer.y)}`;
drawRulerCursor(e.pointer);
});
canvas.on('mouse:down', e => {
if (nodeEditOn && e.e.button === 0) { nodeDown(e.absolutePointer || canvas.getPointer(e.e), e.e.altKey); return; }
if (drawMode && e.e.button === 0) { drawStart(e.absolutePointer || canvas.getPointer(e.e)); return; }
if (lineOn && e.e.button === 0) { lineClick(e.absolutePointer || canvas.getPointer(e.e), e.e.shiftKey); return; }
if (penBezOn && e.e.button === 0) { penDown(e.absolutePointer || canvas.getPointer(e.e), e.e.altKey); return; }
if (penMode && e.e.button !== 2) {
penAddPoint(e.absolutePointer || canvas.getPointer(e.e));
return;
}
// Alt + drag = leave a copy behind (Illustrator-style). Arm it here; the clone
// is made on the first move so a plain alt-click doesn't duplicate.
if (e.e.button === 0 && e.e.altKey && e.target && !penMode) {
altDragPending = { obj: e.target, left: e.target.left, top: e.target.top, cloned: false };
}
if (e.e.button === 2) showCtxMenu(e.e, canvas.getActiveObject());
else hideCtxMenu();
});
canvas.on('mouse:dblclick', e => {
if (penMode) { endPen(true); return; }
if (lineOn) { finishLine(); return; }
if (penBezOn) { finishBez(false); return; }
if (nodeEditOn) { nodeDblClick(e.absolutePointer || canvas.getPointer(e.e)); return; }
if (!activeTool && e.target && ['path', 'polyline', 'polygon'].includes(e.target.type)) enterNodeEdit(e.target);
});
// Drawing-tool pointer routing (Magic Pencil / Line / Pen / node editing).
canvas.on('mouse:move', e => {
const p = e.absolutePointer || canvas.getPointer(e.e);
if (drawMode && drawing) drawMove(p);
else if (lineOn) { lineCur = p; canvas.requestRenderAll(); }
else if (penBezOn) penMove(p, e.e.altKey);
else if (nodeEditOn) nodeMove(p, e.e.altKey);
});
canvas.on('mouse:up', () => { if (drawMode && drawing) drawEnd(); else if (penBezOn) penUp(); else if (nodeEditOn) nodeUp(); });
canvas.on('path:created', e => { if (e.path) { e.path._type = 'drawing'; e.path._name = 'Brush'; } });
// SMART SNAP GUIDES — snap edges/centers to the canvas and to other objects.
canvas.on('object:moving', e => {
const obj = e.target; if (!obj) return;
// Alt-drag: on the first move, drop a copy at the original spot (then drag on).
if (altDragPending && !altDragPending.cloned && obj === altDragPending.obj) {
altDragPending.cloned = true; const src = obj, L = altDragPending.left, T = altDragPending.top;
src.clone(c => { c.set({ left: L, top: T }); c._name = (src._name || 'obj') + ' copy'; c._type = src._type; canvas.add(c); canvas.sendBackwards(c); canvas.requestRenderAll(); }, ['_name', '_type']);
}
snapLines = [];
obj.setCoords();
const b = obj.getBoundingRect(true, true);
const t = 7 / zoom; // screen-constant threshold
const inSel = obj.type === 'activeSelection' ? obj.getObjects() : [obj];
const vT = [0, canvas.width / 2, canvas.width]; // vertical guide x-targets
const hT = [0, canvas.height / 2, canvas.height]; // horizontal guide y-targets
const all = canvas.getObjects();
// Skip object-to-object snapping on very busy canvases (e.g. a freshly
// ungrouped vector) — looping every object per mouse-move would lag.
if (all.length <= 60) all.forEach(o => {
if (o === obj || inSel.indexOf(o) !== -1) return;
o.setCoords(); const ob = o.getBoundingRect(true, true);
vT.push(ob.left, ob.left + ob.width / 2, ob.left + ob.width);
hT.push(ob.top, ob.top + ob.height / 2, ob.top + ob.height);
});
const objX = [b.left, b.left + b.width / 2, b.left + b.width];
const objY = [b.top, b.top + b.height / 2, b.top + b.height];
let bv = t, vAdj = null, vPos = null;
objX.forEach(px => vT.forEach(tx => { const d = Math.abs(px - tx); if (d < bv) { bv = d; vAdj = tx - px; vPos = tx; } }));
let bh = t, hAdj = null, hPos = null;
objY.forEach(py => hT.forEach(ty => { const d = Math.abs(py - ty); if (d < bh) { bh = d; hAdj = ty - py; hPos = ty; } }));
if (vAdj !== null) { obj.left += vAdj; snapLines.push({ type: 'v', pos: vPos }); }
if (hAdj !== null) { obj.top += hAdj; snapLines.push({ type: 'h', pos: hPos }); }
if (vAdj !== null || hAdj !== null) obj.setCoords();
});
canvas.on('mouse:up', () => {
altDragPending = null;
if (snapLines.length) { snapLines = []; canvas.requestRenderAll(); }
});
canvas.on('after:render', () => {
if (!snapLines.length) return;
const ctx = canvas.contextContainer, r = canvas.getRetinaScaling();
ctx.save();
ctx.setTransform(r, 0, 0, r, 0, 0);
ctx.strokeStyle = '#e3175a'; ctx.lineWidth = 1 / zoom; ctx.setLineDash([5 / zoom, 4 / zoom]);
snapLines.forEach(l => {
ctx.beginPath();
if (l.type === 'v') { ctx.moveTo(l.pos, 0); ctx.lineTo(l.pos, canvas.height); }
else { ctx.moveTo(0, l.pos); ctx.lineTo(canvas.width, l.pos); }
ctx.stroke();
});
ctx.restore();
});
// Smart Draw — paint the in-progress freehand stroke (not a canvas object, so
// it never pollutes the layers/history).
canvas.on('after:render', () => {
if (!drawing || drawPts.length < 2) return;
const ctx = canvas.contextContainer, r = canvas.getRetinaScaling();
ctx.save();
ctx.setTransform(r, 0, 0, r, 0, 0);
ctx.strokeStyle = '#7c3aed'; ctx.lineWidth = 2 / zoom; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.beginPath(); ctx.moveTo(drawPts[0].x, drawPts[0].y);
for (let i = 1; i < drawPts.length; i++) ctx.lineTo(drawPts[i].x, drawPts[i].y);
ctx.stroke(); ctx.restore();
});
// Line / Pen tool live previews (drawn on the canvas surface, never as objects).
canvas.on('after:render', () => {
if (!lineOn && !penBezOn) return;
const ctx = canvas.contextContainer, r = canvas.getRetinaScaling();
ctx.save(); ctx.setTransform(r, 0, 0, r, 0, 0);
const dot = (p, rad, col) => { ctx.fillStyle = col; ctx.beginPath(); ctx.arc(p.x, p.y, rad, 0, 7); ctx.fill(); };
if (lineOn && linePts.length) {
ctx.strokeStyle = '#7c3aed'; ctx.lineWidth = 1.5 / zoom; ctx.beginPath();
ctx.moveTo(linePts[0].x, linePts[0].y);
for (let i = 1; i < linePts.length; i++) ctx.lineTo(linePts[i].x, linePts[i].y);
if (lineCur) ctx.lineTo(lineCur.x, lineCur.y);
ctx.stroke();
linePts.forEach((p, i) => dot(p, 4 / zoom, i === 0 ? '#10b981' : '#7c3aed'));
}
if (penBezOn && bezAnchors.length) {
ctx.strokeStyle = '#7c3aed'; ctx.lineWidth = 1.5 / zoom; ctx.beginPath();
ctx.moveTo(bezAnchors[0].x, bezAnchors[0].y);
for (let i = 1; i < bezAnchors.length; i++) {
const a = bezAnchors[i - 1], b = bezAnchors[i];
if (a.hOut || b.hIn) ctx.bezierCurveTo((a.hOut || a).x, (a.hOut || a).y, (b.hIn || b).x, (b.hIn || b).y, b.x, b.y);
else ctx.lineTo(b.x, b.y);
}
if (bezCur) { const a = bezAnchors[bezAnchors.length - 1]; a.hOut ? ctx.bezierCurveTo(a.hOut.x, a.hOut.y, bezCur.x, bezCur.y, bezCur.x, bezCur.y) : ctx.lineTo(bezCur.x, bezCur.y); }
ctx.stroke();
bezAnchors.forEach((a, i) => {
if (a.hIn) { ctx.strokeStyle = '#10b981'; ctx.lineWidth = 1 / zoom; ctx.beginPath(); ctx.moveTo(a.hIn.x, a.hIn.y); ctx.lineTo(a.hOut.x, a.hOut.y); ctx.stroke(); dot(a.hIn, 3 / zoom, '#10b981'); dot(a.hOut, 3 / zoom, '#10b981'); }
dot(a, 4.5 / zoom, i === 0 ? '#10b981' : '#7c3aed');
});
}
ctx.restore();
});
// Node-editing overlay: the live path + draggable anchors and handles.
canvas.on('after:render', () => {
if (!nodeEditOn || !editNodes.length) return;
const ctx = canvas.contextContainer, r = canvas.getRetinaScaling();
ctx.save(); ctx.setTransform(r, 0, 0, r, 0, 0);
const seg = (a, b) => { if (a.hOut || b.hIn) { const c1 = a.hOut || a, c2 = b.hIn || b; ctx.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, b.x, b.y); } else ctx.lineTo(b.x, b.y); };
ctx.beginPath(); ctx.moveTo(editNodes[0].x, editNodes[0].y);
for (let i = 1; i < editNodes.length; i++) seg(editNodes[i - 1], editNodes[i]);
if (editClosed) { seg(editNodes[editNodes.length - 1], editNodes[0]); ctx.closePath(); if (editStyle.fill) { ctx.fillStyle = editStyle.fill; ctx.fill(); } }
ctx.strokeStyle = editStyle.stroke || '#7c3aed'; ctx.lineWidth = editStyle.strokeWidth || 2; ctx.stroke();
editNodes.forEach((n, i) => {
['hIn', 'hOut'].forEach(h => {
if (n[h]) { ctx.strokeStyle = '#10b981'; ctx.lineWidth = 1 / zoom; ctx.beginPath(); ctx.moveTo(n.x, n.y); ctx.lineTo(n[h].x, n[h].y); ctx.stroke(); ctx.fillStyle = '#10b981'; ctx.beginPath(); ctx.arc(n[h].x, n[h].y, 3.5 / zoom, 0, 7); ctx.fill(); }
});
ctx.fillStyle = i === 0 ? '#10b981' : '#fff'; ctx.strokeStyle = '#7c3aed'; ctx.lineWidth = 1.5 / zoom;
const s = 5 / zoom; ctx.beginPath(); ctx.rect(n.x - s, n.y - s, 2 * s, 2 * s); ctx.fill(); ctx.stroke();
});
ctx.restore();
});
$i('custom-w').value = w;
$i('custom-h').value = h;
updateSB();
fitZoom(); // fit on first paint so the canvas doesn't flash at 100% then snap
}
function onCanvasChange() { updateLayers(); updateSB(); saveHist(); scheduleAutosave(); }
const SHAPE_TYPES = ['rect','circle','ellipse','triangle','polygon','polyline','path','line'];
function onSelectionChange() {
const obj = canvas.getActiveObject();
updateRightPanel(obj);
$i('canvas-toolbar').classList.add('visible');
updateToolbarForType(obj);
updateLayers();
// Auto-switch the left panel to match what's selected
if (obj && !penMode && obj.type !== 'activeSelection') {
if (obj.type === 'i-text' || obj.type === 'text') openPanel('text');
else if (SHAPE_TYPES.includes(obj.type) || obj.type === 'group') openPanel('elements');
}
}
// Delete whatever is selected — handles multi-selection too
function deleteActive() {
const o = canvas.getActiveObject();
if (!o) return;
if (o.type === 'activeSelection') o.forEachObject(obj => canvas.remove(obj));
else canvas.remove(o);
canvas.discardActiveObject();
canvas.requestRenderAll();
updateLayers(); updateSB(); saveHist();
}
// Select every object on the canvas
function selectAll() {
const objs = canvas.getObjects().filter(o => o.selectable !== false && o.visible !== false);
if (!objs.length) return;
canvas.discardActiveObject();
if (objs.length === 1) canvas.setActiveObject(objs[0]);
else canvas.setActiveObject(new fabric.ActiveSelection(objs, { canvas }));
canvas.requestRenderAll();
}
// ============================================================
// HISTORY
// ============================================================
function saveHist() {
if (undoing) return;
let s;
try { s = JSON.stringify(canvas.toJSON(['_name', '_type'])); } catch (e) { return; }
if (hist[hidx] === s) return; // no actual change — skip duplicate
hidx++;
hist = hist.slice(0, hidx);
hist.push(s);
if (hist.length > 60) { hist.shift(); hidx--; }
}
function undo() {
if (hidx <= 0) { toast('Nothing to undo'); return; }
undoing = true; hidx--;
canvas.loadFromJSON(hist[hidx], () => { canvas.renderAll(); updateLayers(); updateSB(); undoing = false; toast('Undo'); });
}
function redo() {
if (hidx >= hist.length - 1) { toast('Nothing to redo'); return; }
undoing = true; hidx++;
canvas.loadFromJSON(hist[hidx], () => { canvas.renderAll(); updateLayers(); updateSB(); undoing = false; toast('Redo'); });
}
// ============================================================
// STATUS BAR
// ============================================================
function updateSB() {
$i('sb-canvas').textContent = canvas.width + '×' + canvas.height;
$i('sb-objs').textContent = canvas.getObjects().length;
$i('custom-w').value = canvas.width;
$i('custom-h').value = canvas.height;
}
// ============================================================
// ZOOM
// ============================================================
let panX = 0, panY = 0;
function applyTransform() {
const cc = $i('canvas-container');
cc.style.transformOrigin = 'center center';
cc.style.transform = `translate(${panX}px, ${panY}px) scale(${zoom})`;
drawRulers();
}
function applyZoom(z, keepPan) {
zoom = Math.max(0.002, Math.min(500, z)); // effectively unlimited zoom range
if (!keepPan) { panX = 0; panY = 0; } // recenter on explicit zoom changes
applyTransform();
syncControlScale();
$i('zoom-pct').value = Math.round(zoom * 100) + '%';
}
// Zoom is a CSS transform on the whole canvas container, so selection borders
// and handles get scaled with it — huge & blurry when zoomed in, invisible when
// zoomed out. Counter-scale them by 1/zoom so they stay a constant on-screen size.
function syncControlScale() {
const s = 1 / zoom;
fabric.Object.prototype.cornerSize = 9 * s;
fabric.Object.prototype.touchCornerSize = 22 * s;
fabric.Object.prototype.borderScaleFactor = s;
if (canvas) canvas.requestRenderAll();
}
function fitZoom() {
const wrap = $i('canvas-wrap');
const z = Math.min((wrap.clientWidth - 80) / canvas.width, (wrap.clientHeight - 80) / canvas.height, 1);
panX = 0; panY = 0;
applyZoom(z);
}
// Middle-mouse drag pans the artboard. Uses Pointer Events + pointer capture so
// the drag keeps working even off-canvas, and preventDefault in the CAPTURE phase
// kills Chrome's middle-click autoscroll (which otherwise eats the mousemoves).
function setupPan() {
const wrap = $i('canvas-wrap');
let sx = 0, sy = 0, ox = 0, oy = 0;
// capture phase, before fabric/anything, so autoscroll never starts
wrap.addEventListener('pointerdown', e => {
if (e.button !== 1) return;
e.preventDefault();
panning = true; sx = e.clientX; sy = e.clientY; ox = panX; oy = panY;
wrap.style.cursor = 'grabbing';
try { wrap.setPointerCapture(e.pointerId); } catch (err) {}
}, true);
wrap.addEventListener('pointermove', e => {
if (!panning) return;
panX = ox + (e.clientX - sx); panY = oy + (e.clientY - sy); applyTransform();
});
const stop = () => { if (panning) { panning = false; wrap.style.cursor = ''; } };
wrap.addEventListener('pointerup', stop);
wrap.addEventListener('pointercancel', stop);
wrap.addEventListener('lostpointercapture', stop);
// belt-and-suspenders: also block the legacy autoscroll/middle-paste defaults
wrap.addEventListener('mousedown', e => { if (e.button === 1) e.preventDefault(); }, true);
wrap.addEventListener('auxclick', e => { if (e.button === 1) e.preventDefault(); });
// Keep the rulers aligned whenever the canvas area changes size — opening or
// collapsing side panels resizes the wrap without firing a window 'resize'.
if (window.ResizeObserver) new ResizeObserver(() => drawRulers()).observe(wrap);
}
// ============================================================
// RULERS (px / mm / cm / inch / m) — drawn around the artboard, zoom/pan aware
// ============================================================
const RULER = 22;
const UNIT_PX = { px: 1, mm: 96 / 25.4, cm: 96 / 2.54, m: 96 / 0.0254, inch: 96, ft: 96 * 12 }; // CSS px per unit
// Position/Size unit + helpers. fromU: input(unit)→px ; toU: px→input(unit, rounded for the field).
let dimUnit = 'px';
let sizeUnit = 'px'; // unit used by the Resize-Canvas modal
const uRound = (v, u) => u === 'px' ? Math.round(v) : Math.round(v * 100) / 100;
const fromU = v => (parseFloat(v) || 0) * (UNIT_PX[dimUnit] || 1);
const toU = px => { const v = px / (UNIT_PX[dimUnit] || 1); return dimUnit === 'px' ? Math.round(v) : Math.round(v * 100) / 100; };
function niceStep(raw) { if (raw <= 0) return 1; const p = Math.pow(10, Math.floor(Math.log10(raw))); const f = raw / p; return (f < 1.5 ? 1 : f < 3 ? 2 : f < 7 ? 5 : 10) * p; }
function fmtUnit(v) { const r = Math.round(v * 100) / 100; return String(r); }
function prepRuler(cv) {
const r = cv.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1;
cv.width = Math.max(1, Math.round(r.width * dpr)); cv.height = Math.max(1, Math.round(r.height * dpr));
const ctx = cv.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { ctx, w: r.width, h: r.height };
}
function drawRulers() {
if (!canvas) return;
const wrap = $i('canvas-wrap'), cont = $i('canvas-container'), rh = $i('ruler-h'), rv = $i('ruler-v');
if (!wrap || !cont || !rh || !rv) return;
const wrapR = wrap.getBoundingClientRect(), contR = cont.getBoundingClientRect();
const originX = contR.left - wrapR.left, originY = contR.top - wrapR.top; // screen pos of canvas px (0,0)
const sc = zoom, ppu = UNIT_PX[rulerUnit] || 1;
const stepU = niceStep(70 / (ppu * sc)); // units between major ticks
const stepPx = stepU * ppu; // canvas px between major ticks
const css = getComputedStyle(document.body);
const bg = (css.getPropertyValue('--surf') || '#fff').trim(), line = (css.getPropertyValue('--txt3') || '#999').trim(), txt = (css.getPropertyValue('--txt2') || '#666').trim();
// Horizontal
let p = prepRuler(rh); let ctx = p.ctx;
ctx.clearRect(0, 0, p.w, p.h); ctx.fillStyle = bg; ctx.fillRect(0, 0, p.w, p.h);
ctx.strokeStyle = line; ctx.fillStyle = txt; ctx.font = '9px sans-serif'; ctx.textBaseline = 'alphabetic'; ctx.lineWidth = 1;
for (let n = 0; n * stepPx <= canvas.width + stepPx; n++) {
const cpx = n * stepPx, x = originX + cpx * sc - RULER;
if (x < -40 || x > p.w + 40) continue;
ctx.beginPath(); ctx.moveTo(x, p.h); ctx.lineTo(x, p.h - 9); ctx.stroke();
ctx.fillText(fmtUnit(n * stepU), x + 2, 9);
const hx = x + (stepPx * sc) / 2; ctx.beginPath(); ctx.moveTo(hx, p.h); ctx.lineTo(hx, p.h - 5); ctx.stroke();
}
if (rulerCursor) { const x = originX + rulerCursor.x * sc - RULER; ctx.strokeStyle = '#e3175a'; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, p.h); ctx.stroke(); }
// Vertical
p = prepRuler(rv); ctx = p.ctx;
ctx.clearRect(0, 0, p.w, p.h); ctx.fillStyle = bg; ctx.fillRect(0, 0, p.w, p.h);
ctx.strokeStyle = line; ctx.fillStyle = txt; ctx.font = '9px sans-serif'; ctx.lineWidth = 1;
for (let n = 0; n * stepPx <= canvas.height + stepPx; n++) {
const cpy = n * stepPx, y = originY + cpy * sc - RULER;
if (y < -40 || y > p.h + 40) continue;
ctx.beginPath(); ctx.moveTo(p.w, y); ctx.lineTo(p.w - 9, y); ctx.stroke();
ctx.save(); ctx.translate(9, y + 2); ctx.rotate(-Math.PI / 2); ctx.textBaseline = 'alphabetic'; ctx.fillText(fmtUnit(n * stepU), 0, 0); ctx.restore();
const hy = y + (stepPx * sc) / 2; ctx.beginPath(); ctx.moveTo(p.w, hy); ctx.lineTo(p.w - 5, hy); ctx.stroke();
}
if (rulerCursor) { const y = originY + rulerCursor.y * sc - RULER; ctx.strokeStyle = '#e3175a'; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(p.w, y); ctx.stroke(); }
}
let _rulerRAF = null;
function drawRulerCursor(pointer) {
rulerCursor = pointer || null;
if (_rulerRAF) return; // coalesce a burst of mousemoves into one paint
_rulerRAF = requestAnimationFrame(() => { _rulerRAF = null; drawRulers(); });
}
// ============================================================
// ADD SHAPE — fully Fabric-based, no external SVG fetching
// ============================================================
const DASH_MAP = { solid: null, dashed: [10, 6], dotted: [2, 5] };
function getShapeStyle() {
const noFill = $i('sh-no-fill').checked;
return {
fill: noFill ? 'transparent' : $i('sh-fill').value,
stroke: $i('sh-stroke').value,
strokeWidth: parseInt($i('sh-stroke-w').value) || 0,
opacity: parseFloat($i('sh-opacity').value),
dash: $i('sh-dash') ? (DASH_MAP[$i('sh-dash').value] || null) : null,
radius: $i('sh-radius') ? (parseInt($i('sh-radius').value) || 0) : 0
};
}
// Quick-insert a basic shape at the canvas centre (used by the top-bar common actions).
function quickAdd(type) {
const cx = canvas.getWidth() / 2, cy = canvas.getHeight() / 2;
const k = Math.max(0.5, Math.min(canvas.getWidth(), canvas.getHeight()) / 600);
let o;
if (type === 'rect') o = new fabric.Rect({ left: cx, top: cy, width: 160 * k, height: 110 * k, rx: 4, ry: 4, fill: '#7c3aed', originX: 'center', originY: 'center' });
else if (type === 'ellipse') o = new fabric.Ellipse({ left: cx, top: cy, rx: 80 * k, ry: 80 * k, fill: '#10b981', originX: 'center', originY: 'center' });
else if (type === 'triangle') o = new fabric.Triangle({ left: cx, top: cy, width: 150 * k, height: 130 * k, fill: '#f59e0b', originX: 'center', originY: 'center' });
else if (type === 'line') o = new fabric.Line([cx - 90 * k, cy, cx + 90 * k, cy], { stroke: '#111827', strokeWidth: 4, strokeLineCap: 'round', originX: 'center', originY: 'center' });
if (!o) return;
o._name = type.charAt(0).toUpperCase() + type.slice(1); o._type = type === 'line' ? 'line' : 'shape';
if ($i('onboard')) $i('onboard').style.display = 'none';
canvas.add(o); canvas.setActiveObject(o); canvas.requestRenderAll(); saveHist();
}
function addShape(shapeObj) {
const style = getShapeStyle();
const cx = canvas.width / 2, cy = canvas.height / 2;
// Scale default shape sizes to the artboard so a shape isn't a tiny speck on a
// large page (e.g. 1080p) — relative to an 800×600 baseline, clamped sane.
const k = Math.min(4, Math.max(0.6, Math.min(canvas.width, canvas.height) / 600));
let obj;
const base = {
left: cx, top: cy,
originX: 'center', originY: 'center',
fill: style.fill,
stroke: style.strokeWidth > 0 ? style.stroke : null,
strokeWidth: style.strokeWidth,
strokeDashArray: style.dash,
opacity: style.opacity,
objectCaching: false
};
const ft = shapeObj.fabricType;
if (ft === 'rect') {
obj = new fabric.Rect({
...base,
width: (shapeObj.w || 120) * k,
height: (shapeObj.h || 80) * k,
rx: shapeObj.rx || style.radius || 0,
ry: shapeObj.rx || style.radius || 0
});
} else if (ft === 'ellipse') {
obj = new fabric.Ellipse({
...base,
rx: (shapeObj.rx || 55) * k,
ry: (shapeObj.ry || 55) * k
});
} else if (ft === 'line' || ft === 'line-h') {
const dash = shapeObj.dash !== undefined ? shapeObj.dash : style.dash;
obj = new fabric.Line([cx - 80 * k, cy, cx + 80 * k, cy], {
stroke: style.stroke || '#111',
strokeWidth: Math.max(style.strokeWidth || 2, 1),
strokeDashArray: dash,
fill: null, opacity: style.opacity,
selectable: true, evented: true
});
} else if (ft === 'line-v') {
obj = new fabric.Line([cx, cy - 80 * k, cx, cy + 80 * k], {
stroke: style.stroke || '#111',
strokeWidth: Math.max(style.strokeWidth || 2, 1),
strokeDashArray: shapeObj.dash !== undefined ? shapeObj.dash : style.dash,
fill: null, opacity: style.opacity
});
} else if (ft === 'line-diag') {
obj = new fabric.Line([cx - 60 * k, cy - 60 * k, cx + 60 * k, cy + 60 * k], {
stroke: style.stroke || '#111',
strokeWidth: Math.max(style.strokeWidth || 2, 1),
strokeDashArray: shapeObj.dash !== undefined ? shapeObj.dash : style.dash,
fill: null, opacity: style.opacity
});
} else if (ft === 'path' && shapeObj.d) {
// Determine effective fill/stroke for path
let pathFill = style.fill;
let pathStroke = style.strokeWidth > 0 ? style.stroke : null;
let pathStrokeW = style.strokeWidth;
// For stroke-only shapes (like checkmark, wave)
if (shapeObj.stroke) {
pathFill = 'transparent';
pathStroke = style.strokeWidth > 0 ? style.stroke : style.fill; // use fill color as stroke if no stroke set
pathStrokeW = Math.max(style.strokeWidth || 2, 1.5);
}
obj = new fabric.Path(shapeObj.d, {
...base,
fill: pathFill,
stroke: pathStroke,
strokeWidth: pathStrokeW,
strokeLineCap: 'round',
strokeLineJoin: 'round',
scaleX: 3.5 * k, scaleY: 3.5 * k // Lucide viewBox is 24×24 — scale up for canvas
});
} else {
toast('Unknown shape type', 'err');
return;
}
if (!obj) return;
obj._name = shapeObj.name;
obj._type = ft;
$i('onboard').style.display = 'none';
canvas.add(obj);
canvas.setActiveObject(obj);
canvas.renderAll();
toast(`${shapeObj.name} added`, 'ok');
saveHist();
}
// ============================================================
// BUILD SHAPE PANEL
// ============================================================
function buildShapePanel() {
const container = $i('shapes-container');
container.innerHTML = '';
SHAPE_LIBRARY.forEach(cat => {
const section = document.createElement('div');
section.className = 'shape-category';
section.dataset.cat = cat.cat.toLowerCase();
section.innerHTML = `<div class="shape-cat-label">${cat.cat}</div>`;
const grid = document.createElement('div');
grid.className = 'shape-grid';
cat.shapes.forEach(shapeObj => {
const btn = document.createElement('button');
btn.className = 'shape-btn';
btn.title = shapeObj.name;
btn.dataset.name = shapeObj.name.toLowerCase();
// Build SVG preview
let svgContent = '';
if (shapeObj.preview) {
svgContent = shapeObj.preview;
} else if (shapeObj.fabricType === 'rect') {
if (shapeObj.rx) svgContent = `<rect x="2" y="5" width="20" height="14" rx="${Math.round(shapeObj.rx/7)}" fill="currentColor"/>`;
else svgContent = `<rect x="2" y="5" width="20" height="14" fill="currentColor"/>`;
} else if (shapeObj.fabricType === 'ellipse') {
const scaleX = shapeObj.ry && shapeObj.rx ? (shapeObj.ry/shapeObj.rx) : 1;
if (Math.abs(scaleX - 1) < 0.15) svgContent = `<circle cx="12" cy="12" r="9" fill="currentColor"/>`;
else svgContent = `<ellipse cx="12" cy="12" rx="10" ry="7" fill="currentColor"/>`;
} else if (shapeObj.fabricType === 'line-h' || shapeObj.fabricType === 'line') {
const dash = shapeObj.dash;
let da = '';
if (dash) da = `stroke-dasharray="${dash.join(' ')}"`;
svgContent = `<line x1="2" y1="12" x2="22" y2="12" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" ${da}/>`;
} else if (shapeObj.fabricType === 'line-v') {
svgContent = `<line x1="12" y1="2" x2="12" y2="22" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>`;
} else if (shapeObj.fabricType === 'line-diag') {
svgContent = `<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>`;
}
btn.innerHTML = `
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" style="color:var(--acc)">${svgContent}</svg>
<span class="s-label">${shapeObj.name}</span>
`;
btn.addEventListener('click', () => addShape(shapeObj));
grid.appendChild(btn);
});
section.appendChild(grid);
container.appendChild(section);
});
}
// Shape search
function setupShapeSearch() {
const input = $i('shape-search');
input.addEventListener('input', function() {
const q = this.value.trim().toLowerCase();
qsa('.shape-category').forEach(sec => {
const btns = qsa('.shape-btn', sec);
let visible = 0;
btns.forEach(btn => {
const match = !q || btn.dataset.name.includes(q) || sec.dataset.cat.includes(q);
btn.style.display = match ? '' : 'none';
if (match) visible++;
});
sec.style.display = visible ? '' : 'none';
});
searchOnlineIcons(q); // also pull matching online icons (Iconify)
});
}
// ============================================================
// TEXT
// ============================================================
const FONT_PRESETS = {
heading: { fontSize: 48, fontWeight: 'bold', fontFamily: 'Georgia', text: 'Heading' },
subheading: { fontSize: 28, fontWeight: '600', fontFamily: 'Georgia', text: 'Subheading' },
body: { fontSize: 16, fontWeight: 'normal', fontFamily: 'Arial', text: 'Body text' },
caption: { fontSize: 12, fontWeight: 'normal', fontFamily: 'Arial', text: 'Caption text', fill: '#888888' }
};
function addText(preset) {
const p = FONT_PRESETS[preset] || {};
const customText = $i('txt-content').value;
const text = preset === 'custom'
? (customText || 'Text')
: (p.text || 'Text');
const obj = new fabric.IText(text, {
left: canvas.width / 2, top: canvas.height / 2,
originX: 'center', originY: 'center',
fontFamily: preset === 'custom' ? ($i('txt-font').value || 'Arial') : p.fontFamily,
fontSize: preset === 'custom' ? (parseInt($i('txt-size').value) || 36) : p.fontSize,
fontWeight: preset === 'custom' ? (boldOn ? 'bold' : 'normal') : p.fontWeight,
fill: preset === 'custom' ? $i('txt-color').value : (p.fill || '#111111'),
textAlign: textAlign,
charSpacing: preset === 'custom' ? (parseInt($i('txt-charspace').value) || 0) : 0,
lineHeight: preset === 'custom' ? (parseFloat($i('txt-lineheight').value) || 1.16) : 1.16,
objectCaching: false
});
obj._name = text.slice(0, 12) || 'Text';
$i('onboard').style.display = 'none';
canvas.add(obj);
canvas.setActiveObject(obj);
canvas.renderAll();
toast('Text added', 'ok');
saveHist();
}
function updateActiveText(prop, val) {
const obj = canvas.getActiveObject();
if (obj && (obj.type === 'i-text' || obj.type === 'text')) {
obj.set(prop, val);
canvas.renderAll();
saveHist();
}
}
// Curve / bend text onto an arc (Fabric text-on-path). bend: -100..100, 0 = straight.
function applyTextBend(val) {
const obj = canvas.getActiveObject();
if (!obj || !(obj.type === 'i-text' || obj.type === 'text')) return;
const bend = (parseFloat(val) || 0) / 100;
obj._bend = parseFloat(val) || 0;
if (Math.abs(bend) < 0.02) { obj.set('path', null); obj.setCoords(); canvas.renderAll(); return; }
const w = (obj.calcTextWidth ? obj.calcTextWidth() : (obj.width || 200)) || 200;
const s = bend * w * 0.5, sa = Math.abs(s);
const R = (sa / 2) + (w * w) / (8 * sa);
const sweep = bend > 0 ? 0 : 1; // positive = arch up (rainbow), negative = arch down
const path = new fabric.Path(`M 0 0 A ${R} ${R} 0 0 ${sweep} ${w} 0`, { fill: '', stroke: '' });
obj.set('path', path); obj.setCoords(); canvas.renderAll();
}
// ============================================================
// RIGHT PANEL
// ============================================================
function updateRightPanel(obj) {
if (!obj) { $i('sb-sel').textContent = '—'; $i('rp-3d').style.display = 'none'; $i('rp-fx').style.display = 'none'; $i('rp-pf').style.display = 'none'; $i('rp-text').style.display = 'none'; syncStyleBar(null); return; }
syncStyleBar(obj);
// Pathfinder shows only when 2+ shapes are selected together
$i('rp-pf').style.display = (obj.type === 'activeSelection' && obj.getObjects().length >= 2) ? '' : 'none';
// Text styling section shows only for text objects
$i('rp-text').style.display = (obj.type === 'i-text' || obj.type === 'text') ? '' : 'none';
// Fill & Effects (gradient / shadow / skew)
$i('rp-fx').style.display = '';
$i('fx-skewx').value = obj.skewX || 0; $i('fx-skewx-v').textContent = Math.round(obj.skewX || 0);
$i('fx-skewy').value = obj.skewY || 0; $i('fx-skewy-v').textContent = Math.round(obj.skewY || 0);
$i('fx-shadow').checked = !!obj.shadow;
if (obj.shadow) { if (obj.shadow.color && /^#[0-9a-f]{6}$/i.test(obj.shadow.color)) $i('fx-shadow-color').value = obj.shadow.color; $i('fx-shadow-blur').value = obj.shadow.blur || 8; }
// 3D panel: show for any fillable vector/shape the user can extrude or revolve.
$i('rp-3d').style.display = ['rect','circle','ellipse','triangle','polygon','polyline','path','group','i-text','text'].includes(obj.type) ? '' : 'none';
$i('pos-x').value = toU(obj.left);
$i('pos-y').value = toU(obj.top);
$i('size-w').value = toU(obj.getScaledWidth());
$i('size-h').value = toU(obj.getScaledHeight());
$i('obj-opacity').value = obj.opacity !== undefined ? obj.opacity : 1;
$i('rp-adj').style.display = obj.type === 'image' ? '' : 'none';
if (obj.type === 'i-text' || obj.type === 'text') {
$i('txt-font').value = obj.fontFamily || 'Arial';
$i('txt-size').value = obj.fontSize || 36;
$i('txt-color').value = obj.fill || '#111111';
$i('txt-charspace').value = obj.charSpacing || 0; $i('txt-cs-val').textContent = obj.charSpacing || 0;
$i('txt-lineheight').value = obj.lineHeight || 1.16; $i('txt-lh-val').textContent = (obj.lineHeight || 1.16).toFixed(2);
$i('txt-bend').value = obj._bend || 0; $i('txt-bend-v').textContent = obj._bend || 0;
boldOn = obj.fontWeight === 'bold';
italicOn = obj.fontStyle === 'italic';
underlineOn = !!obj.underline;
$i('txt-bold').classList.toggle('active', boldOn);
$i('txt-italic').classList.toggle('active', italicOn);
$i('txt-underline').classList.toggle('active', underlineOn);
}
// Shape style sync (groups/vectors included so their colors are editable)
const isGroupish = obj.type === 'group' || obj.type === 'activeSelection';
if (['rect','circle','ellipse','triangle','polygon','path','line'].includes(obj.type) || isGroupish) {
let curFill = obj.fill;
if (isGroupish) { const fc = obj.getObjects().find(c => c.fill && c.fill !== 'transparent'); curFill = fc ? fc.fill : null; }
if ($i('sh-fill')) $i('sh-fill').value = (curFill && typeof curFill === 'string' && curFill !== 'transparent') ? curFill : '#7c3aed';
if ($i('sh-no-fill')) $i('sh-no-fill').checked = !obj.fill || obj.fill === 'transparent';
if ($i('sh-stroke') && obj.stroke) $i('sh-stroke').value = obj.stroke;
if ($i('sh-stroke-w')) $i('sh-stroke-w').value = obj.strokeWidth || 0;
if ($i('sh-opacity')) {
$i('sh-opacity').value = obj.opacity || 1;
$i('sh-op-val').textContent = Math.round((obj.opacity || 1) * 100) + '%';
}
if ($i('sh-radius')) {
const r = obj.type === 'rect' ? (obj.rx || 0) : 0;
$i('sh-radius').value = r;
$i('sh-radius-val').textContent = Math.round(r);
}
if ($i('sh-dash')) {
const d = obj.strokeDashArray;
$i('sh-dash').value = !d || !d.length ? 'solid' : (d[0] <= 3 ? 'dotted' : 'dashed');
}
}
const u = dimUnit === 'inch' ? 'in' : dimUnit;
$i('sb-sel').textContent = `${obj.type} · ${toU(obj.getScaledWidth())}×${toU(obj.getScaledHeight())} ${u} · (${toU(obj.left)}, ${toU(obj.top)})`;
}
const BARE_SHAPES = ['rect','circle','ellipse','triangle','polygon','polyline','path','line'];
// Apply a style prop deep into groups/multi-selections so every child (e.g. each
// path of a traced vector) gets recolored — even while grouped.
function setStyleDeep(obj, prop, val) {
if (obj.type === 'group' || obj.type === 'activeSelection') {
obj.getObjects().forEach(c => setStyleDeep(c, prop, val));
obj.dirty = true;
} else {
obj.set(prop, val);
obj.dirty = true;
}
}
function updateActiveShape(prop, val) {
const obj = canvas.getActiveObject();
if (!obj) return;
if (BARE_SHAPES.includes(obj.type)) obj.set(prop, val);
else if (obj.type === 'group' || obj.type === 'activeSelection') setStyleDeep(obj, prop, val);
else return;
canvas.renderAll();
saveHist();
}
// ============================================================
// RECENT COLORS — swatches shown in the floating selection popup
// ============================================================
let recentColors = [];
try { recentColors = JSON.parse(localStorage.getItem('df_recent_colors') || '[]'); } catch (e) {}
function pushRecentColor(c) {
if (!c || typeof c !== 'string' || c[0] !== '#') return; // skip transparent / gradients
c = c.toLowerCase();
recentColors = [c, ...recentColors.filter(x => x !== c)].slice(0, 8);
try { localStorage.setItem('df_recent_colors', JSON.stringify(recentColors)); } catch (e) {}
renderRecentColors();
}
// Apply a color to whatever's selected — text fill or shape fill. Used by the
// recent swatches, the popup color button, and the side-panel color inputs, so
// colors are common across every object type.
function applyColorToActive(c) {
const obj = canvas.getActiveObject();
if (!obj) { toast('Select an object first', 'warn'); return false; }
if (obj.type === 'i-text' || obj.type === 'text') {
updateActiveText('fill', c); if ($i('txt-color')) $i('txt-color').value = c;
} else {
if ($i('sh-no-fill')) $i('sh-no-fill').checked = false;
if ($i('sh-fill')) $i('sh-fill').value = c;
updateActiveShape('fill', c);
}
setToolbarColorDot();
return true;
}
function applyRecentColor(c) {
if (applyColorToActive(c)) pushRecentColor(c); // bump it to the front
}
function renderRecentColors() {
const el = $i('ct-recent'); if (!el) return;
el.innerHTML = '';
recentColors.forEach(c => {
const b = document.createElement('button');
b.className = 'ct-sw'; b.style.background = c; b.title = c;
b.onclick = () => applyRecentColor(c);
el.appendChild(b);
});
el.style.display = recentColors.length ? 'flex' : 'none';
}
// Reflect the selected object's current color on the popup's color button.
function setToolbarColorDot() {
const dot = $i('ct-color-dot'); if (!dot) return;
const o = canvas && canvas.getActiveObject();
let c = '#7c3aed';
if (o && (o.type === 'i-text' || o.type === 'text')) c = (typeof o.fill === 'string' && o.fill) || '#111111';
else if (o && typeof o.fill === 'string' && o.fill && o.fill !== 'transparent') c = o.fill;
dot.style.background = c;
if ($i('ct-color-input') && /^#[0-9a-f]{6}$/i.test(c)) $i('ct-color-input').value = c;
}
// ============================================================
// COMMON STYLE TOOLBAR (top bar) — contextual, acts on the current selection.
// Side-panel pickers stay and are kept in sync.
// ============================================================
function styleBarApply(action, val) {
const obj = canvas.getActiveObject(); if (!obj) return;
const isText = obj.type === 'i-text' || obj.type === 'text';
if (action === 'fill') {
if (isText) { updateActiveText('fill', val); if ($i('txt-color')) $i('txt-color').value = val; }
else { if ($i('sh-no-fill')) $i('sh-no-fill').checked = false; if ($i('sh-fill')) $i('sh-fill').value = val; updateActiveShape('fill', val); }
pushRecentColor(val);
} else if (action === 'stroke') {
if (isText) updateActiveText('stroke', val); else updateActiveShape('stroke', val);
if ($i('sh-stroke')) $i('sh-stroke').value = val;
pushRecentColor(val);
} else if (action === 'opacity') {
obj.set('opacity', val); canvas.renderAll(); saveHist();
if ($i('txt-opacity')) $i('txt-opacity').value = val;
if ($i('sh-opacity')) $i('sh-opacity').value = val;
if ($i('obj-opacity')) $i('obj-opacity').value = val;
} else if (action === 'dash') {
const d = { solid: null, dashed: [12, 8], dotted: [2, 6] }[val] || null;
if (isText) updateActiveText('strokeDashArray', d); else updateActiveShape('strokeDashArray', d);
} else if (action === 'corner') {
if (obj.type === 'rect') { const r = (Math.min(obj.width, obj.height) / 2) * (Math.max(0, Math.min(100, val)) / 100); obj.set({ rx: r, ry: r }); canvas.renderAll(); saveHist(); if ($i('sh-radius')) $i('sh-radius').value = Math.round(r); }
} else if (action === 'gradient') {
const base = (typeof obj.fill === 'string' && obj.fill[0] === '#') ? obj.fill : ($i('sb-fill') ? $i('sb-fill').value : '#7c3aed');
const g = new fabric.Gradient({ type: 'linear', gradientUnits: 'percentage', coords: { x1: 0, y1: 0, x2: 1, y2: 1 }, colorStops: [{ offset: 0, color: base }, { offset: 1, color: '#ffffff' }] });
obj.set('fill', g); canvas.renderAll(); saveHist();
}
syncStyleBar(obj);
}
function syncStyleBar(obj) {
const sec = $i('rp-style'); if (!sec) return;
if (!obj) { sec.style.display = 'none'; return; }
sec.style.display = '';
const fillStr = (typeof obj.fill === 'string') ? obj.fill : null;
$i('sb-fill-dot').style.background = fillStr || 'linear-gradient(135deg,#7c3aed,#ec4899)';
if (fillStr && /^#[0-9a-f]{6}$/i.test(fillStr)) $i('sb-fill').value = fillStr;
$i('sb-stroke-dot').style.borderColor = (obj.stroke && /^#/.test(obj.stroke)) ? obj.stroke : '#cbd5e1';
if (obj.stroke && /^#[0-9a-f]{6}$/i.test(obj.stroke)) $i('sb-stroke').value = obj.stroke;
$i('sb-opacity').value = obj.opacity !== undefined ? obj.opacity : 1;
const isRect = obj.type === 'rect';
$i('sb-corner-wrap').style.display = isRect ? '' : 'none';
if (isRect) $i('sb-corner').value = Math.round(((obj.rx || 0) / (Math.min(obj.width, obj.height) / 2)) * 100) || 0;
const d = obj.strokeDashArray;
$i('sb-linestyle').value = (!d || !d.length) ? 'solid' : (d[0] <= 3 ? 'dotted' : 'dashed');
}
function wireStyleBar() {
const fill = $i('sb-fill'), stroke = $i('sb-stroke');
$i('sb-fill-btn').addEventListener('click', () => fill.click());
$i('sb-stroke-btn').addEventListener('click', () => stroke.click());
fill.addEventListener('input', () => styleBarApply('fill', fill.value));
stroke.addEventListener('input', () => styleBarApply('stroke', stroke.value));
$i('sb-grad-btn').addEventListener('click', () => styleBarApply('gradient'));
$i('sb-linestyle').addEventListener('change', e => styleBarApply('dash', e.target.value));
$i('sb-opacity').addEventListener('input', e => styleBarApply('opacity', parseFloat(e.target.value)));
$i('sb-corner').addEventListener('input', e => styleBarApply('corner', parseInt(e.target.value) || 0));
}
// Show only the popup controls relevant to the selected object type (Canva-style).
function updateToolbarForType(obj) {
const isImg = obj && obj.type === 'image';
const isText = obj && (obj.type === 'i-text' || obj.type === 'text');
const isShape = obj && !isImg && !isText; // shapes, paths, lines, groups, selections
qsa('#canvas-toolbar .ctx-image').forEach(e => e.style.display = isImg ? '' : 'none');
qsa('#canvas-toolbar .ctx-color').forEach(e => e.style.display = (isText || isShape) ? '' : 'none');
const sep = qs('#canvas-toolbar .ctx-sep'); if (sep) sep.style.display = (isImg || isText || isShape) ? '' : 'none';
if (!isImg && $i('ct-pop-adjust')) $i('ct-pop-adjust').classList.remove('open');
setToolbarColorDot();
}
// ============================================================
// LAYERS
// ============================================================
function updateLayers() {
const el = $i('layers-list');
const objs = canvas.getObjects();
const active = canvas.getActiveObject();
el.innerHTML = '';
const icons = { image:'🖼', 'i-text':'T', text:'T', rect:'▭', circle:'◯', ellipse:'◯', triangle:'△', line:'—', polygon:'⬠', path:'✏' };
// Cap rows: an ungrouped traced vector can have thousands of paths — building a
// DOM row for each one freezes the page. Show the top N and summarize the rest.
const MAX_ROWS = 80, total = objs.length;
const frag = document.createDocumentFragment();
let shown = 0;
for (let i = objs.length - 1; i >= 0 && shown < MAX_ROWS; i--, shown++) {
const o = objs[i];
const ic = icons[o.type] || '◈';
const nm = o._name || (o.type === 'i-text' ? (o.text || '').slice(0, 14) : o.type);
const d = document.createElement('div');
d.className = 'layer-item' + (o === active ? ' active' : '');
d.innerHTML = `<span class="layer-icon">${ic}</span><span class="layer-name">${nm}</span><button class="layer-vis">👁</button>`;
d.onclick = function(e) {
if (e.target.classList.contains('layer-vis')) { o.visible = !o.visible; canvas.renderAll(); return; }
canvas.setActiveObject(o); canvas.renderAll(); updateLayers(); updateRightPanel(o);
};
frag.appendChild(d);
}
if (total > MAX_ROWS) {
const more = document.createElement('div');
more.className = 'layer-item'; more.style.cssText = 'opacity:.6;justify-content:center;cursor:default';
more.textContent = `… and ${total - MAX_ROWS} more objects`;
frag.appendChild(more);
}
el.appendChild(frag);
}
// ============================================================
// PIXABAY PHOTO SEARCH
// ============================================================
const PIXABAY_KEY = '19704134-a2c0c55df148036d03e48ae33';
let pixLoading = false;
async function searchPixabay(q) {
q = (q || '').trim();
if (!q) { toast('Enter a search term', 'err'); return; }
if (pixLoading) return;
pixLoading = true;
const status = $i('pix-status'), grid = $i('pix-results');
status.style.display = 'block'; status.textContent = `Searching “${q}”…`;
grid.innerHTML = '';
try {
const url = `https://pixabay.com/api/?key=${PIXABAY_KEY}&q=${encodeURIComponent(q)}` +
`&image_type=photo&safesearch=true&per_page=30&order=popular`;
const res = await fetch(url);
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
if (!data.hits || !data.hits.length) { status.textContent = `No results for “${q}”`; pixLoading = false; return; }
status.style.display = 'none';
data.hits.forEach(hit => {
const item = document.createElement('div');
item.className = 'thumb-item';
item.title = hit.tags || '';
item.innerHTML = `<img src="${hit.previewURL}" alt="" loading="lazy"><span class="thumb-label">${(hit.tags || '').split(',')[0]}</span>`;
item.addEventListener('click', () => loadURL(hit.largeImageURL || hit.webformatURL));
grid.appendChild(item);
});
} catch (e) {
status.style.display = 'block';
status.textContent = 'Search failed — check your connection';
}
pixLoading = false;
}
// ============================================================
// IMAGE LOAD
// ============================================================
function loadURL(url) {
url = (url || '').trim();
if (!url.startsWith('http')) { toast('Enter a valid URL', 'err'); return; }
toast('Loading…');
setStatus('Loading…', true);
fabric.Image.fromURL(url, img => {
if (!img || !img.width) {
const proxy = 'https://api.allorigins.win/raw?url=' + encodeURIComponent(url);
fabric.Image.fromURL(proxy, img2 => {
if (!img2 || !img2.width) { toast('Could not load image', 'err'); setStatus('Load failed', true); return; }
placeImage(img2);
}, { crossOrigin: 'anonymous' });
return;
}
placeImage(img);
}, { crossOrigin: 'anonymous' });
}
function loadFile(file) {
const r = new FileReader();
r.onload = e => {
fabric.Image.fromURL(e.target.result, img => placeImage(img));
};
r.readAsDataURL(file);
}
function placeImage(img) {
$i('onboard').style.display = 'none';
const sc = Math.min(canvas.width * 0.9 / img.width, canvas.height * 0.9 / img.height, 1);
img.set({ left: canvas.width / 2, top: canvas.height / 2, originX: 'center', originY: 'center', scaleX: sc, scaleY: sc });
img._name = 'Image'; img._type = 'image';
canvas.add(img); canvas.setActiveObject(img); canvas.renderAll();
['adj-brightness','adj-contrast','adj-saturation','adj-blur'].forEach(id => { if ($i(id)) $i(id).value = 0; });
['bri-val','con-val','sat-val','blur-val'].forEach(id => { if ($i(id)) $i(id).textContent = '0'; });
qsa('#filter-chips .ef-chip').forEach(c => c.classList.remove('active'));
qs('#filter-chips [data-filter="none"]').classList.add('active');
activeFilter = 'none';
toast('Image loaded!', 'ok');
setStatus('Ready');
saveHist();
}
// ============================================================
// FILTERS
// ============================================================
function applyFilters() {
let img = canvas.getActiveObject();
if (!img || img.type !== 'image') { const imgs = canvas.getObjects('image'); img = imgs[imgs.length - 1]; }
if (!img) return;
img.filters = [];
const b = parseFloat($i('adj-brightness').value);
const c = parseFloat($i('adj-contrast').value);
const s = parseFloat($i('adj-saturation').value);
const bl = parseFloat($i('adj-blur').value);
if (b !== 0) img.filters.push(new fabric.Image.filters.Brightness({ brightness: b }));
if (c !== 0) img.filters.push(new fabric.Image.filters.Contrast({ contrast: c }));
if (s !== 0) img.filters.push(new fabric.Image.filters.Saturation({ saturation: s }));
if (bl > 0) img.filters.push(new fabric.Image.filters.Blur({ blur: bl / 20 }));
const F = fabric.Image.filters;
const fmap = { grayscale: () => new F.Grayscale(), sepia: () => new F.Sepia(), invert: () => new F.Invert(), vintage: () => new F.Vintage(), polaroid: () => new F.Polaroid(), kodachrome: () => new F.Kodachrome(), brownie: () => new F.Brownie(), sharpen: () => new F.Convolute({ matrix: [0,-1,0,-1,5,-1,0,-1,0] }) };
if (fmap[activeFilter]) img.filters.push(fmap[activeFilter]());
img.applyFilters();
canvas.renderAll();
}
// ============================================================
// BACKGROUND
// ============================================================
function setupBgPalette() {
const colors = ['#ffffff','#f8f8f8','#111111','#222222','#ef4444','#f97316','#eab308','#22c55e','#3b82f6','#8b5cf6','#ec4899','#14b8a6','#fef3c7','#dbeafe','#fce7f3','#d1fae5'];
const el = $i('bg-palette');
colors.forEach(c => {
const d = document.createElement('div');
d.className = 'bg-chip'; d.style.background = c; d.title = c;
d.onclick = () => { canvas.setBackgroundColor(c, canvas.renderAll.bind(canvas)); saveHist(); };
el.appendChild(d);
});
const tr = document.createElement('div');
tr.className = 'bg-chip';
tr.style.background = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Crect width='4' height='4' fill='%23ccc'/%3E%3Crect x='4' y='4' width='4' height='4' fill='%23ccc'/%3E%3C/svg%3E\")";
tr.title = 'Transparent';
tr.onclick = () => { canvas.setBackgroundColor(null, canvas.renderAll.bind(canvas)); saveHist(); };
el.appendChild(tr);
}
function setupGradPresets() {
const grads = [
{ label:'Sunset', a:'#f093fb', b:'#f5576c' },
{ label:'Ocean', a:'#4facfe', b:'#00f2fe' },
{ label:'Forest', a:'#43e97b', b:'#38f9d7' },
{ label:'Purple', a:'#667eea', b:'#764ba2' },
{ label:'Peach', a:'#ffecd2', b:'#fcb69f' },
{ label:'Night', a:'#0c0c0c', b:'#1a237e' },
{ label:'Fire', a:'#f7971e', b:'#ffd200' },
{ label:'Rose', a:'#f953c6', b:'#b91d73' },
];
const el = $i('grad-presets');
grads.forEach(g => {
const d = document.createElement('div');
d.className = 'grad-item';
d.style.background = `linear-gradient(135deg,${g.a},${g.b})`;
d.title = g.label;
d.onclick = () => applyFabricGradient(g.a, g.b, 'dr');
el.appendChild(d);
});
}
function applyFabricGradient(from, to, dir) {
const w = canvas.width, h = canvas.height;
const coords = {
h: { x1:0, y1:0, x2:w, y2:0 },
v: { x1:0, y1:0, x2:0, y2:h },
dr: { x1:0, y1:0, x2:w, y2:h },
ur: { x1:0, y1:h, x2:w, y2:0 }
}[dir] || { x1:0, y1:0, x2:w, y2:h };
const gradient = new fabric.Gradient({ type:'linear', coords, colorStops:[{ offset:0, color:from },{ offset:1, color:to }] });
canvas.setBackgroundColor(gradient, canvas.renderAll.bind(canvas));
saveHist();
toast('Gradient applied', 'ok');
}
// Gradient fill for the selected shape / line (Mix two colors)
function applyShapeGradient(from, to, dir) {
const o = canvas.getActiveObject();
const isText = o && (o.type === 'i-text' || o.type === 'text');
if (!o || o.type === 'activeSelection' || (!SHAPE_TYPES.includes(o.type) && !isText)) {
toast('Select a shape, text or line first', 'err'); return;
}
const coords = {
h: { x1:0, y1:0, x2:1, y2:0 },
v: { x1:0, y1:0, x2:0, y2:1 },
dr: { x1:0, y1:0, x2:1, y2:1 },
ur: { x1:0, y1:1, x2:1, y2:0 }
}[dir] || { x1:0, y1:0, x2:1, y2:1 };
const g = new fabric.Gradient({
type:'linear', gradientUnits:'percentage', coords,
colorStops:[{ offset:0, color:from }, { offset:1, color:to }]
});
if (o.type === 'line') {
if ((o.strokeWidth || 0) < 1) o.set('strokeWidth', 4);
o.set('stroke', g);
} else {
o.set('fill', g);
}
canvas.renderAll(); saveHist();
toast('Gradient applied', 'ok');
}
// ============================================================
// CROP
// ============================================================
function startCrop() {
const obj = canvas.getActiveObject();
if (!obj || obj.type !== 'image') { toast('Select an image to crop', 'err'); return; }
cropActive = true; canvas.selection = false; canvas.discardActiveObject(); canvas.renderAll();
const el = $i('crop-overlay');
el.classList.add('active');
el.style.width = canvas.width + 'px';
el.style.height = canvas.height + 'px';
const il = obj.left - obj.getScaledWidth() / 2;
const it = obj.top - obj.getScaledHeight() / 2;
cropState = { x: Math.max(0, il), y: Math.max(0, it), w: Math.min(obj.getScaledWidth(), canvas.width), h: Math.min(obj.getScaledHeight(), canvas.height), obj };
updateCropBox();
}
function updateCropBox() {
const box = $i('crop-box');
box.style.cssText = `left:${cropState.x}px;top:${cropState.y}px;width:${cropState.w}px;height:${cropState.h}px`;
}
function applyCrop() {
const obj = cropState.obj;
if (!obj) { cancelCrop(); return; }
const imgLeft = obj.left - obj.getScaledWidth() / 2;
const imgTop = obj.top - obj.getScaledHeight() / 2;
const cropX = (cropState.x - imgLeft) / obj.scaleX;
const cropY = (cropState.y - imgTop) / obj.scaleY;
const cropW = Math.max(1, cropState.w / obj.scaleX);
const cropH = Math.max(1, cropState.h / obj.scaleY);
const tmp = document.createElement('canvas');
tmp.width = Math.round(cropW); tmp.height = Math.round(cropH);
tmp.getContext('2d').drawImage(obj.getElement(), Math.max(0, cropX), Math.max(0, cropY), cropW, cropH, 0, 0, cropW, cropH);
const idx = canvas.getObjects().indexOf(obj);
canvas.remove(obj);
fabric.Image.fromURL(tmp.toDataURL(), newImg => {
newImg.set({ left: cropState.x + cropState.w/2, top: cropState.y + cropState.h/2, originX:'center', originY:'center' });
newImg._name = 'Cropped';
canvas.insertAt(newImg, Math.max(0, idx));
canvas.setActiveObject(newImg); canvas.renderAll();
cancelCrop(); toast('Cropped!', 'ok'); saveHist();
});
}
function cancelCrop() {
cropActive = false; canvas.selection = true;
$i('crop-overlay').classList.remove('active');
}
// Crop drag
(function() {
const box = $i('crop-box');
let startX, startY, startState, handle;
box.addEventListener('mousedown', e => {
e.stopPropagation();
handle = e.target.classList.contains('crop-handle') ? [...e.target.classList].find(c => c.startsWith('ch-')) : 'move';
startX = e.clientX; startY = e.clientY;
startState = { ...cropState };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
function onMove(e) {
const dx = (e.clientX - startX) / zoom, dy = (e.clientY - startY) / zoom;
const s = startState;
if (handle === 'move') { cropState.x = Math.max(0, s.x + dx); cropState.y = Math.max(0, s.y + dy); }
else if (handle === 'ch-br') { cropState.w = Math.max(20, s.w + dx); cropState.h = Math.max(20, s.h + dy); }
else if (handle === 'ch-bl') { cropState.x = s.x + dx; cropState.w = Math.max(20, s.w - dx); cropState.h = Math.max(20, s.h + dy); }
else if (handle === 'ch-tr') { cropState.y = s.y + dy; cropState.w = Math.max(20, s.w + dx); cropState.h = Math.max(20, s.h - dy); }
else if (handle === 'ch-tl') { cropState.x = s.x + dx; cropState.y = s.y + dy; cropState.w = Math.max(20, s.w - dx); cropState.h = Math.max(20, s.h - dy); }
else if (handle === 'ch-mr') { cropState.w = Math.max(20, s.w + dx); }
else if (handle === 'ch-ml') { cropState.x = s.x + dx; cropState.w = Math.max(20, s.w - dx); }
else if (handle === 'ch-bm') { cropState.h = Math.max(20, s.h + dy); }
else if (handle === 'ch-tm') { cropState.y = s.y + dy; cropState.h = Math.max(20, s.h - dy); }
updateCropBox();
}
function onUp() { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
})();
// ============================================================
// PEN TOOL — draw a custom polygon by clicking points
// ============================================================
function togglePen() { penMode ? endPen(false) : startPen(); }
function startPen() {
penMode = true; penPoints = []; penTemp = [];
canvas.selection = false;
canvas.discardActiveObject();
canvas.defaultCursor = 'crosshair';
canvas.renderAll();
const btn = $i('btn-pen'); if (btn) { btn.classList.add('active'); btn.textContent = '✓ Finish shape (Enter)'; }
$i('onboard').style.display = 'none';
toast('Pen: click to add points · double-click / Enter to finish · Esc to cancel');
}
function penAddPoint(p) {
penPoints.push({ x: p.x, y: p.y });
redrawPen();
}
function redrawPen() {
penTemp.forEach(o => canvas.remove(o));
penTemp = [];
if (penPoints.length > 1) {
const pl = new fabric.Polyline(penPoints, {
fill: '', stroke: '#7c3aed', strokeWidth: 2, strokeDashArray: [5, 4],
selectable: false, evented: false, objectCaching: false
});
penTemp.push(pl); canvas.add(pl);
}
penPoints.forEach((pt, i) => {
const c = new fabric.Circle({
left: pt.x, top: pt.y, radius: 4, fill: i === 0 ? '#10b981' : '#7c3aed',
originX: 'center', originY: 'center', selectable: false, evented: false, objectCaching: false
});
penTemp.push(c); canvas.add(c);
});
canvas.renderAll();
}
function endPen(commit) {
penTemp.forEach(o => canvas.remove(o));
penTemp = [];
if (commit && penPoints.length >= 3) {
const style = getShapeStyle();
const poly = new fabric.Polygon(penPoints, {
fill: style.fill,
stroke: style.strokeWidth > 0 ? style.stroke : null,
strokeWidth: style.strokeWidth,
strokeDashArray: style.dash,
opacity: style.opacity,
objectCaching: false
});
poly._name = 'Custom shape'; poly._type = 'polygon';
canvas.add(poly); canvas.setActiveObject(poly);
toast('Custom shape created', 'ok');
saveHist();
} else if (commit) {
toast('Need at least 3 points', 'err');
}
penPoints = []; penMode = false;
canvas.selection = true;
canvas.defaultCursor = 'default';
const btn = $i('btn-pen'); if (btn) { btn.classList.remove('active'); btn.textContent = '✏ Pen tool — draw custom shape'; }
canvas.renderAll();
}
// ============================================================
// SMART DRAW — freehand sketch → clean, editable shape (AutoDraw-style)
// ============================================================
function toggleDraw() { drawMode ? stopDraw() : startDraw(); }
function startDraw() {
if (penMode) endPen(false);
drawMode = true; drawing = false; drawPts = [];
canvas.selection = false; canvas.discardActiveObject(); canvas.defaultCursor = 'crosshair'; canvas.renderAll();
const b = $i('btn-autodraw'); if (b) { b.classList.add('active'); b.textContent = '✓ Smart Draw is ON — sketch a shape'; }
$i('onboard').style.display = 'none';
toast('Magic Pencil: sketch something, then pick a matching design from the suggestions · Esc to exit');
}
// Turn the tool off (but leave any suggestion bar up so the user can still pick).
function deactivateDraw() {
drawMode = false; drawing = false; drawPts = [];
canvas.selection = true; canvas.defaultCursor = 'default';
const b = $i('btn-autodraw'); if (b) { b.classList.remove('active'); b.textContent = '✦ Smart Draw — sketch to art'; }
canvas.requestRenderAll();
}
function stopDraw() { deactivateDraw(); hideSuggestions(); }
function drawStart(p) { drawing = true; drawPts = [{ x: p.x, y: p.y }]; canvas.discardActiveObject(); hideSuggestions(); }
function drawMove(p) {
const last = drawPts[drawPts.length - 1];
if (last && Math.hypot(p.x - last.x, p.y - last.y) < 2) return; // skip jitter
drawPts.push({ x: p.x, y: p.y });
canvas.requestRenderAll(); // the stroke preview is painted in the after:render hook
}
async function drawEnd() {
drawing = false;
const pts = drawPts.slice();
drawPts = [];
deactivateDraw(); // one-shot: pen turns off on release — click Magic Pencil again for the next sketch
activeTool = null; refreshToolBtns();
canvas.requestRenderAll();
if (pts.length < 3) return; // ignore taps
// Keep the user's rough sketch on the canvas (editable) while we suggest art.
const s = getShapeStyle();
const sketch = new fabric.Polyline(pts, {
stroke: s.stroke || '#333', strokeWidth: Math.max(s.strokeWidth || 2, 2), fill: '',
strokeLineCap: 'round', strokeLineJoin: 'round', objectCaching: false
});
sketch._name = 'Sketch'; sketch._type = 'sketch';
canvas.add(sketch); canvas.requestRenderAll();
$i('onboard').style.display = 'none';
toast(_dnModel ? 'Recognizing…' : 'Loading AI recognizer… (first time only)');
try {
await ensureDoodleNet();
const preds = (await topPredictions(pts, 5)).filter(p => p.conf > 0.02);
const ids = await gatherIcons(preds.map(p => p.label), 20);
if (ids.length) showSuggestions(ids, _bbox(pts), sketch, preds[0] && preds[0].label);
else toast('No matches found — your sketch is kept (editable)', 'warn');
} catch (e) { console.warn('Smart Draw AI unavailable:', e); toast('AI offline — sketch kept (editable)', 'warn'); }
}
// --- geometry helpers ---
function _dist(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); }
function _perpDist(p, a, b) {
const dx = b.x - a.x, dy = b.y - a.y, len = Math.hypot(dx, dy) || 1e-6;
return Math.abs((p.x - a.x) * dy - (p.y - a.y) * dx) / len;
}
function _rdp(pts, eps) { // Ramer–Douglas–Peucker simplify
if (pts.length < 3) return pts.slice();
const a = pts[0], b = pts[pts.length - 1];
let idx = -1, max = 0;
for (let i = 1; i < pts.length - 1; i++) { const d = _perpDist(pts[i], a, b); if (d > max) { max = d; idx = i; } }
if (max > eps) {
const left = _rdp(pts.slice(0, idx + 1), eps), right = _rdp(pts.slice(idx), eps);
return left.slice(0, -1).concat(right);
}
return [a, b];
}
// Distance from point p to segment a–b.
function _segDist(p, a, b) {
const dx = b.x - a.x, dy = b.y - a.y, l2 = dx * dx + dy * dy;
if (l2 < 1e-9) return _dist(p, a);
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2; t = Math.max(0, Math.min(1, t));
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
}
// Arc-length resample to n evenly-spaced points (standard $1-recognizer preprocessing).
function _resample(pts, n) {
if (pts.length < 2) return pts.slice();
let total = 0; for (let i = 1; i < pts.length; i++) total += _dist(pts[i - 1], pts[i]);
const I = total / (n - 1), out = [pts[0]]; const src = pts.slice(); let D = 0;
for (let i = 1; i < src.length; i++) {
const d = _dist(src[i - 1], src[i]);
if (D + d >= I && d > 1e-6) {
const t = (I - D) / d;
const np = { x: src[i - 1].x + t * (src[i].x - src[i - 1].x), y: src[i - 1].y + t * (src[i].y - src[i - 1].y) };
out.push(np); src.splice(i, 0, np); D = 0;
} else D += d;
}
while (out.length < n) out.push(src[src.length - 1]);
return out;
}
// Algebraic (Kåsa) least-squares circle fit → {cx,cy,r,rms} (rms in px).
function _fitCircle(pts) {
const n = pts.length; let mx = 0, my = 0; pts.forEach(p => { mx += p.x; my += p.y; }); mx /= n; my /= n;
let Suu = 0, Svv = 0, Suv = 0, Suuu = 0, Svvv = 0, Suvv = 0, Svuu = 0;
pts.forEach(p => { const u = p.x - mx, v = p.y - my; Suu += u * u; Svv += v * v; Suv += u * v; Suuu += u * u * u; Svvv += v * v * v; Suvv += u * v * v; Svuu += v * u * u; });
const det = Suu * Svv - Suv * Suv; if (Math.abs(det) < 1e-9) return null;
const E = 0.5 * (Suuu + Suvv), F = 0.5 * (Svvv + Svuu);
const uc = (E * Svv - Suv * F) / det, vc = (Suu * F - Suv * E) / det;
const cx = uc + mx, cy = vc + my, r = Math.sqrt(uc * uc + vc * vc + (Suu + Svv) / n);
let err = 0; pts.forEach(p => { const d = Math.hypot(p.x - cx, p.y - cy) - r; err += d * d; });
return { cx, cy, r, rms: Math.sqrt(err / n) };
}
// PCA-based rotation-aware ellipse fit → {cx,cy,rx,ry,angDeg,rms} (rms in px).
function _fitEllipse(pts) {
const n = pts.length; let mx = 0, my = 0; pts.forEach(p => { mx += p.x; my += p.y; }); mx /= n; my /= n;
let xx = 0, yy = 0, xy = 0; pts.forEach(p => { const dx = p.x - mx, dy = p.y - my; xx += dx * dx; yy += dy * dy; xy += dx * dy; });
xx /= n; yy /= n; xy /= n;
const ang = 0.5 * Math.atan2(2 * xy, xx - yy), c = Math.cos(ang), s = Math.sin(ang);
let rx = 0, ry = 0; pts.forEach(p => { const dx = p.x - mx, dy = p.y - my; rx = Math.max(rx, Math.abs(dx * c + dy * s)); ry = Math.max(ry, Math.abs(-dx * s + dy * c)); });
rx = rx || 1; ry = ry || 1;
let err = 0; pts.forEach(p => { const dx = p.x - mx, dy = p.y - my, u = (dx * c + dy * s) / rx, v = (-dx * s + dy * c) / ry; err += Math.abs(Math.hypot(u, v) - 1); });
return { cx: mx, cy: my, rx, ry, angDeg: ang * 180 / Math.PI, rms: (err / n) * ((rx + ry) / 2) };
}
// RMS distance of points to a closed polygon through vertices V (px).
function _polyResidual(pts, V) {
const m = V.length; if (m < 2) return Infinity;
let s = 0; pts.forEach(p => { let md = Infinity; for (let i = 0; i < m; i++) md = Math.min(md, _segDist(p, V[i], V[(i + 1) % m])); s += md * md; });
return Math.sqrt(s / pts.length);
}
function _axisAlignedRect(V) {
if (V.length !== 4) return false;
for (let i = 0; i < 4; i++) {
const a = V[i], b = V[(i + 1) % 4];
let ang = Math.abs(Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI) % 180;
if (!(ang < 16 || ang > 164 || Math.abs(ang - 90) < 16)) return false;
}
return true;
}
// Professional shape recognition: least-squares fitting (circle / ellipse / line)
// with a polygon residual, deciding by lowest fit error and biased toward round
// shapes — so a smooth loop reads as a circle, not a many-sided polygon.
function recognizeStroke(raw) {
if (!raw || raw.length < 3) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
raw.forEach(p => { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); });
const w = maxX - minX, h = maxY - minY, diag = Math.hypot(w, h);
if (diag < 14) return null;
const R = _resample(raw, 64);
let perim = 0; for (let i = 1; i < R.length; i++) perim += _dist(R[i - 1], R[i]);
const startEnd = _dist(R[0], R[R.length - 1]);
const closed = startEnd < Math.max(0.22 * perim, 0.2 * diag);
// open stroke → straight line, else editable freehand polyline
if (!closed) {
const dev = Math.max(...R.map(p => _perpDist(p, R[0], R[R.length - 1])));
if (dev < Math.max(6, 0.05 * startEnd)) return { type: 'line', a: R[0], b: R[R.length - 1] };
return { type: 'polygon', pts: _rdp(raw, Math.max(4, 0.03 * diag)), open: true };
}
// closed → fit round shapes and a polygon, then compare residuals
const cf = _fitCircle(R), ef = _fitEllipse(R);
const eps = Math.max(4, 0.05 * diag);
let V = _rdp(R, eps);
if (V.length > 1 && _dist(V[0], V[V.length - 1]) < eps) V = V.slice(0, -1); // drop closing dup
const corners = V.length;
const pr = _polyResidual(R, V);
const roundRms = Math.min(cf ? cf.rms : Infinity, ef.rms);
// bias ×1.4 toward round: a circle's points bulge off the polygon chords
// (high pr, low roundRms); a true polygon's points sit on the edges (low pr).
if (corners < 3 || roundRms <= pr * 1.4) {
const ratio = Math.min(ef.rx, ef.ry) / Math.max(ef.rx, ef.ry);
if (cf && ratio > 0.82) return { type: 'circle', cx: cf.cx, cy: cf.cy, r: cf.r };
return { type: 'ellipse', cx: ef.cx, cy: ef.cy, rx: ef.rx, ry: ef.ry, angDeg: ef.angDeg };
}
if (corners === 4 && _axisAlignedRect(V)) return { type: 'rect', minX, minY, w, h };
return { type: 'polygon', pts: V, n: corners };
}
const _POLY_NAMES = { 3: 'Triangle', 4: 'Quad', 5: 'Pentagon', 6: 'Hexagon', 8: 'Octagon' };
function createRecognized(rec) {
const s = getShapeStyle();
const base = { fill: s.fill, stroke: s.strokeWidth > 0 ? s.stroke : null, strokeWidth: s.strokeWidth,
strokeDashArray: s.dash, opacity: s.opacity, objectCaching: false };
let obj, name;
if (rec.type === 'line') {
obj = new fabric.Line([rec.a.x, rec.a.y, rec.b.x, rec.b.y], { stroke: s.stroke || '#111', strokeWidth: Math.max(s.strokeWidth || 2, 1), strokeDashArray: s.dash, opacity: s.opacity }); name = 'Line';
} else if (rec.type === 'circle') {
obj = new fabric.Circle({ ...base, left: rec.cx, top: rec.cy, radius: rec.r, originX: 'center', originY: 'center' }); name = 'Circle';
} else if (rec.type === 'ellipse') {
obj = new fabric.Ellipse({ ...base, left: rec.cx, top: rec.cy, rx: rec.rx, ry: rec.ry, angle: rec.angDeg, originX: 'center', originY: 'center' }); name = 'Ellipse';
} else if (rec.type === 'rect') {
obj = new fabric.Rect({ ...base, left: rec.minX, top: rec.minY, width: rec.w, height: rec.h, rx: s.radius || 0, ry: s.radius || 0 }); name = 'Rectangle';
} else if (rec.type === 'polygon') {
if (rec.open) obj = new fabric.Polyline(rec.pts, { ...base, fill: s.fill });
else obj = new fabric.Polygon(rec.pts, { ...base });
name = rec.open ? 'Shape' : (_POLY_NAMES[rec.n] || 'Polygon');
}
if (!obj) return;
obj._name = name; obj._type = obj.type;
canvas.add(obj); canvas.setActiveObject(obj); canvas.requestRenderAll();
const extra = (rec._guess && !GEOM_LABELS.has(rec._guess)) ? ` · looks like “${rec._guess.replace(/_/g, ' ')}”` : '';
toast(name + ' ✦' + extra, 'ok'); saveHist();
}
// ============================================================
// SMART DRAW — AI recognition (TensorFlow.js + DoodleNet, 345 QuickDraw classes)
// Runs 100% in-browser, no server. The classifier names the sketch; for the
// geometric categories we snap to a clean editable primitive (the model decides
// circle vs hexagon — fixing the ambiguity heuristics can't). Everything loads
// lazily on first use and gracefully falls back to the geometric fitter.
// ============================================================
const GEOM_LABELS = new Set(['circle', 'square', 'triangle', 'line', 'hexagon', 'octagon']);
const _DN_BASE = 'https://cdn.jsdelivr.net/gh/yining1023/doodleNet@master/demo/DoodleClassifier_345/model/';
let _dnModel = null, _dnLabels = null, _dnLoading = null;
function _loadScript(src) {
return new Promise((res, rej) => { const s = document.createElement('script'); s.src = src; s.onload = res; s.onerror = () => rej(new Error('script ' + src)); document.head.appendChild(s); });
}
async function ensureDoodleNet() {
if (_dnModel) return;
if (!_dnLoading) _dnLoading = (async () => {
if (typeof tf === 'undefined') await _loadScript('https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.22.0/dist/tf.min.js');
const [m, txt] = await Promise.all([
tf.loadLayersModel(_DN_BASE + 'model.json'),
fetch(_DN_BASE + 'class_names.txt').then(r => r.text())
]);
_dnModel = m; _dnLabels = txt.trim().split('\n').map(s => s.trim());
})();
await _dnLoading;
}
// Rasterize the stroke to the model's input: 28×28, centred, strokes=1 on bg=0.
function _strokeToTensor(pts) {
const S = 28, pad = 2;
const bb = _bbox(pts), scale = (S - 2 * pad) / (Math.max(bb.w, bb.h) || 1);
const off = document.createElement('canvas'); off.width = S; off.height = S;
const c = off.getContext('2d');
c.fillStyle = '#fff'; c.fillRect(0, 0, S, S);
c.strokeStyle = '#000'; c.lineWidth = 2; c.lineJoin = 'round'; c.lineCap = 'round';
const ox = (S - bb.w * scale) / 2 - bb.minX * scale, oy = (S - bb.h * scale) / 2 - bb.minY * scale;
c.beginPath();
pts.forEach((p, i) => { const x = p.x * scale + ox, y = p.y * scale + oy; i ? c.lineTo(x, y) : c.moveTo(x, y); });
c.stroke();
const d = c.getImageData(0, 0, S, S).data, arr = new Float32Array(S * S);
for (let i = 0; i < S * S; i++) arr[i] = (255 - d[i * 4]) / 255;
return tf.tensor(arr, [1, S, S, 1]);
}
async function classifyStroke(pts) {
const t = _strokeToTensor(pts), out = _dnModel.predict(t), probs = await out.data();
t.dispose(); out.dispose();
let bi = 0; for (let i = 1; i < probs.length; i++) if (probs[i] > probs[bi]) bi = i;
return { label: _dnLabels[bi], conf: probs[bi] };
}
// Top-k predictions (sorted) for the AutoDraw suggestion bar.
async function topPredictions(pts, k) {
const t = _strokeToTensor(pts), out = _dnModel.predict(t), probs = await out.data();
t.dispose(); out.dispose();
return [...probs.keys()].sort((a, b) => probs[b] - probs[a]).slice(0, k).map(i => ({ label: _dnLabels[i], conf: probs[i] }));
}
// --- Clip-art suggestions via the Iconify API (free, CORS, 200k+ icons) ---
async function _iconifySearch(kw, limit) {
try {
const r = await fetch(`https://api.iconify.design/search?query=${encodeURIComponent(kw)}&limit=${limit}`);
const j = await r.json(); return j.icons || [];
} catch (e) { return []; }
}
async function gatherIcons(labels, cap) {
const lists = await Promise.all(labels.slice(0, 3).map(l => _iconifySearch(l.replace(/_/g, ' '), 10)));
const seen = new Set(), out = [];
lists.forEach(list => list.forEach(id => { if (!seen.has(id)) { seen.add(id); out.push(id); } }));
return out.slice(0, cap);
}
// --- Shape search → live online-icon results (Iconify), added as editable vectors ---
let _iconSearchTimer = null, _iconSearchSeq = 0;
function searchOnlineIcons(q) {
const box = $i('icon-results'), label = $i('icon-results-label');
if (!box) return;
clearTimeout(_iconSearchTimer);
if (!q || q.length < 2) { box.style.display = 'none'; label.style.display = 'none'; box.innerHTML = ''; return; }
label.style.display = ''; box.style.display = 'flex'; box.innerHTML = '<div class="ico-hint">Searching icons…</div>';
const seq = ++_iconSearchSeq;
_iconSearchTimer = setTimeout(async () => {
const ids = await _iconifySearch(q, 42);
if (seq !== _iconSearchSeq) return; // a newer search superseded this one
if (!ids.length) { box.innerHTML = '<div class="ico-hint">No online icons for “' + q + '”.</div>'; return; }
box.innerHTML = '';
ids.forEach(id => {
const [p, n] = id.split(':');
const b = document.createElement('button'); b.className = 'ico-item'; b.title = (n || '').replace(/-/g, ' ') + ' · ' + p;
const img = document.createElement('img');
img.src = `https://api.iconify.design/${p}/${n}.svg?height=30&color=%23333`; img.loading = 'lazy'; img.alt = n;
b.appendChild(img); b.onclick = () => addIconToCanvas(id);
box.appendChild(b);
});
}, 350);
}
// Add a chosen online icon to the canvas centre as an editable vector group.
function addIconToCanvas(id) {
const [prefix, name] = id.split(':');
fetch(`https://api.iconify.design/${prefix}/${name}.svg`).then(r => r.text()).then(svg => {
svg = svg.replace(/currentColor/g, '#333');
fabric.loadSVGFromString(svg, (objs, opts) => {
const grp = fabric.util.groupSVGElements(objs, opts);
const target = 150 * Math.max(0.5, Math.min(canvas.getWidth(), canvas.getHeight()) / 600);
const scale = (Math.min(target / (grp.width || 1), target / (grp.height || 1)) || 1);
grp.set({ left: canvas.getWidth() / 2, top: canvas.getHeight() / 2, originX: 'center', originY: 'center', scaleX: scale, scaleY: scale });
grp._name = (name || 'icon').replace(/-/g, ' '); grp._type = 'vector';
if ($i('onboard')) $i('onboard').style.display = 'none';
canvas.add(grp); canvas.setActiveObject(grp); canvas.requestRenderAll();
if (typeof updateLayers === 'function') updateLayers();
saveHist(); toast('Icon added — fully editable', 'ok');
});
}).catch(() => toast('Could not load that icon', 'err'));
}
function hideSuggestions() { const b = $i('ai-suggest'); if (b) { b.classList.remove('show'); $i('sg-row').innerHTML = ''; } }
function showSuggestions(ids, bbox, sketch, guess) {
const bar = $i('ai-suggest'), row = $i('sg-row'); if (!bar) return;
row.innerHTML = '';
$i('sg-title').textContent = guess ? `Did you mean “${guess.replace(/_/g, ' ')}”?` : 'Suggestions';
ids.forEach(id => {
const [p, n] = id.split(':');
const b = document.createElement('button'); b.className = 'sg-icon'; b.title = n.replace(/-/g, ' ');
const img = document.createElement('img');
img.src = `https://api.iconify.design/${p}/${n}.svg?height=36&color=%23333`; img.alt = n; img.loading = 'lazy';
b.appendChild(img); b.onclick = () => placeIcon(id, bbox, sketch);
row.appendChild(b);
});
bar.classList.add('show');
}
// Replace the rough sketch with the chosen clip-art (editable vector group).
function placeIcon(id, bbox, sketch) {
const [prefix, name] = id.split(':');
fetch(`https://api.iconify.design/${prefix}/${name}.svg`).then(r => r.text()).then(svg => {
svg = svg.replace(/currentColor/g, '#333'); // Iconify icons default to currentColor
fabric.loadSVGFromString(svg, (objs, opts) => {
const grp = fabric.util.groupSVGElements(objs, opts);
const gw = grp.width || 1, gh = grp.height || 1;
const scale = (Math.min(bbox.w / gw, bbox.h / gh) || 1) * 0.95;
grp.set({ left: bbox.cx, top: bbox.cy, originX: 'center', originY: 'center', scaleX: scale, scaleY: scale });
grp._name = name.replace(/-/g, ' '); grp._type = 'vector';
if (sketch) canvas.remove(sketch);
canvas.add(grp); canvas.setActiveObject(grp); canvas.requestRenderAll();
updateLayers(); saveHist();
toast('Added ' + grp._name + ' ✦', 'ok');
});
}).catch(() => toast('Could not load that icon', 'err'));
hideSuggestions();
}
function _bbox(pts) {
let a = Infinity, b = Infinity, c = -Infinity, d = -Infinity;
pts.forEach(p => { a = Math.min(a, p.x); b = Math.min(b, p.y); c = Math.max(c, p.x); d = Math.max(d, p.y); });
return { minX: a, minY: b, maxX: c, maxY: d, w: c - a, h: d - b, cx: (a + c) / 2, cy: (b + d) / 2 };
}
function _regPoly(cx, cy, r, n, rot) {
const p = []; for (let i = 0; i < n; i++) { const a = (rot == null ? -Math.PI / 2 : rot) + i * 2 * Math.PI / n; p.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) }); }
return p;
}
// Build a clean editable primitive for a geometric class, using least-squares fits.
function geomFromLabel(label, pts) {
const R = _resample(pts, 64), bb = _bbox(pts);
if (label === 'line') return { type: 'line', a: R[0], b: R[R.length - 1] };
if (label === 'circle') {
const cf = _fitCircle(R), ef = _fitEllipse(R), ratio = Math.min(ef.rx, ef.ry) / Math.max(ef.rx, ef.ry);
return (cf && ratio > 0.82) ? { type: 'circle', cx: cf.cx, cy: cf.cy, r: cf.r }
: { type: 'ellipse', cx: ef.cx, cy: ef.cy, rx: ef.rx, ry: ef.ry, angDeg: ef.angDeg };
}
if (label === 'square') return { type: 'rect', minX: bb.minX, minY: bb.minY, w: bb.w, h: bb.h };
if (label === 'triangle') {
let V = _rdp(R, Math.max(4, 0.06 * Math.hypot(bb.w, bb.h)));
if (V.length > 1 && _dist(V[0], V[V.length - 1]) < 10) V = V.slice(0, -1);
if (V.length !== 3) V = [{ x: bb.cx, y: bb.minY }, { x: bb.maxX, y: bb.maxY }, { x: bb.minX, y: bb.maxY }];
return { type: 'polygon', pts: V, n: 3 };
}
if (label === 'hexagon' || label === 'octagon') {
const cf = _fitCircle(R) || { cx: bb.cx, cy: bb.cy, r: Math.max(bb.w, bb.h) / 2 }, n = label === 'hexagon' ? 6 : 8;
return { type: 'polygon', pts: _regPoly(cf.cx, cf.cy, cf.r, n), n };
}
return null;
}
// Hybrid: DoodleNet names the sketch → snap geometric classes to clean shapes;
// otherwise keep the stroke as an editable vector and surface the guess.
async function smartRecognize(pts) {
let label = null, conf = 0;
try { await ensureDoodleNet(); const r = await classifyStroke(pts); label = r.label; conf = r.conf; }
catch (e) { console.warn('DoodleNet unavailable, using geometric fit:', e); }
if (label && GEOM_LABELS.has(label) && conf > 0.12) {
const g = geomFromLabel(label, pts);
if (g) { g._guess = label; return g; }
}
const rec = recognizeStroke(pts); // geometric fallback
if (rec && label && conf > 0.30) rec._guess = label; // surface the AI guess
return rec;
}
// ============================================================
// DRAWING TOOLSET — Magic Pencil / Brush / Line / Pen(Bézier)
// One tool active at a time; foundation for more brush options later.
// ============================================================
function _drawColor() { return $i('draw-color') ? $i('draw-color').value : '#111111'; }
function _drawSize() { return $i('draw-size') ? (parseInt($i('draw-size').value) || 4) : 4; }
function _hexA(hex, a) { const n = parseInt(hex.slice(1), 16); return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; }
function refreshToolBtns() {
qsa('.dtool').forEach(b => b.classList.remove('active'));
if (activeTool) { const b = $i('t-' + activeTool); if (b) b.classList.add('active'); }
// The top-bar Select (arrow) is highlighted only when no drawing tool is active.
qsa('#tool-bar .tool').forEach(b => b.classList.remove('tool-active'));
if (!activeTool) { const s = $i('qa-select'); if (s) s.classList.add('tool-active'); }
}
function deactivateAllTools(except) {
if (except !== 'magic' && drawMode) deactivateDraw();
if (except !== 'brush') disableBrush();
if (except !== 'line') { lineOn = false; linePts = []; lineCur = null; }
if (except !== 'pen') { penBezOn = false; bezAnchors = []; bezDrag = false; bezCur = null; }
if (penMode) endPen(false);
_setCursor(''); // clear any custom (pen convert) cursor
if (!except) { canvas.selection = true; canvas.defaultCursor = 'default'; }
if (canvas) canvas.requestRenderAll();
}
function selectTool(name) {
if (nodeEditOn) exitNodeEdit(true); // commit any in-progress node editing
if (activeTool === name) { activeTool = null; deactivateAllTools(); refreshToolBtns(); return; } // toggle off
deactivateAllTools(name);
activeTool = name; refreshToolBtns();
canvas.discardActiveObject(); hideSuggestions(); $i('onboard').style.display = 'none';
if (name === 'magic') { startDraw(); }
else if (name === 'brush') { enableBrush(); }
else if (name === 'line') { lineOn = true; linePts = []; canvas.selection = false; canvas.isDrawingMode = false; canvas.defaultCursor = 'crosshair'; toast('Line: click to add points · double-click / Enter to finish · Esc to cancel'); }
else if (name === 'pen') { penBezOn = true; bezAnchors = []; bezDrag = false; canvas.selection = false; canvas.isDrawingMode = false; canvas.defaultCursor = 'crosshair'; _setCursor('crosshair'); toast('Pen: click = corner · click-drag = curve · Alt-click an anchor to cut its handle · click the first point or Enter to finish'); }
canvas.requestRenderAll();
}
// --- Brush (freehand) — fabric free-drawing -------------------------------
function enableBrush() {
brushActive = true;
const type = $i('brush-type') ? $i('brush-type').value : 'pencil';
let b;
if (type === 'spray') b = new fabric.SprayBrush(canvas);
else if (type === 'circle') b = new fabric.CircleBrush(canvas);
else b = new fabric.PencilBrush(canvas);
b.width = (type === 'marker' ? _drawSize() * 2.2 : _drawSize());
b.color = (type === 'marker' ? _hexA(_drawColor(), 0.55) : _drawColor());
canvas.freeDrawingBrush = b;
canvas.isDrawingMode = true; canvas.defaultCursor = 'crosshair';
toast('Brush: draw freely · switch tool or press Esc when done');
}
function disableBrush() { brushActive = false; if (canvas) canvas.isDrawingMode = false; }
function refreshBrush() { if (brushActive) enableBrush(); } // re-apply on size/type/color change
// --- Line (click points → straight open polyline) -------------------------
function _constrain(prev, p, shift) {
if (!shift || !prev) return p;
const dx = p.x - prev.x, dy = p.y - prev.y;
if (Math.abs(dx) > Math.abs(dy) * 2) return { x: p.x, y: prev.y };
if (Math.abs(dy) > Math.abs(dx) * 2) return { x: prev.x, y: p.y };
const m = (Math.abs(dx) + Math.abs(dy)) / 2, sx = Math.sign(dx) || 1, sy = Math.sign(dy) || 1;
return { x: prev.x + m * sx, y: prev.y + m * sy };
}
function lineClick(p, shift) { linePts.push(_constrain(linePts[linePts.length - 1], p, shift)); canvas.requestRenderAll(); }
function finishLine() {
// a double-click adds two near-coincident points — drop trailing duplicates for a clean line
while (linePts.length >= 2 && _dist(linePts[linePts.length - 1], linePts[linePts.length - 2]) < 3) linePts.pop();
if (linePts.length >= 2) {
const o = new fabric.Polyline(linePts, { stroke: _drawColor(), strokeWidth: _drawSize(), fill: '', strokeLineCap: 'round', strokeLineJoin: 'round', objectCaching: false });
o._name = 'Line'; o._type = 'line';
canvas.add(o); canvas.setActiveObject(o); saveHist();
}
linePts = []; lineCur = null; canvas.requestRenderAll(); // stay in Line tool for the next one
}
// --- Pen (Illustrator-style Bézier) ---------------------------------------
// Convert/cut cursor (caret ⌃), shown when Alt hovers an anchor — like Illustrator.
const PEN_CONVERT_CURSOR = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Cpath d='M4 13 L10 5 L16 13' fill='none' stroke='%23000' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\") 10 9, crosshair";
function _setCursor(c) { if (canvas && canvas.upperCanvasEl) canvas.upperCanvasEl.style.cursor = c; }
function penDown(p, alt) {
const last = bezAnchors[bezAnchors.length - 1];
// Alt-click ON the last anchor = CUT its forward handle (make a corner). The
// path continues from this point: a plain next click goes straight, an
// Alt-drag here pulls a fresh independent handle (a new direction).
if (alt && last && _dist(p, last) < 9) { last.hOut = null; bezDrag = true; canvas.requestRenderAll(); return; }
if (!alt && bezAnchors.length > 1 && _dist(p, bezAnchors[0]) < 9) { finishBez(true); return; } // click first point → close
bezAnchors.push({ x: p.x, y: p.y, hIn: null, hOut: null });
bezDrag = true;
}
function penMove(p, alt) {
bezCur = p;
const last = bezAnchors[bezAnchors.length - 1];
const overAnchor = last && (_dist(p, last) < 9 || (bezAnchors.length > 1 && _dist(p, bezAnchors[0]) < 9));
_setCursor(alt && overAnchor ? PEN_CONVERT_CURSOR : 'crosshair');
if (bezDrag && bezAnchors.length) {
const a = bezAnchors[bezAnchors.length - 1];
a.hOut = { x: p.x, y: p.y };
// Alt = break the handle (corner/cusp): drag the out-handle independently and
// freeze the in-handle. Without Alt the handles stay mirrored (smooth).
if (!alt) a.hIn = { x: 2 * a.x - p.x, y: 2 * a.y - p.y };
}
canvas.requestRenderAll();
}
function penUp() { bezDrag = false; canvas.requestRenderAll(); }
function _bezD(anchors, closed) {
if (anchors.length < 2) return '';
let d = `M ${anchors[0].x} ${anchors[0].y}`;
const seg = (a, b) => (a.hOut || b.hIn)
? ` C ${(a.hOut || a).x} ${(a.hOut || a).y} ${(b.hIn || b).x} ${(b.hIn || b).y} ${b.x} ${b.y}`
: ` L ${b.x} ${b.y}`;
for (let i = 1; i < anchors.length; i++) d += seg(anchors[i - 1], anchors[i]);
if (closed) { d += seg(anchors[anchors.length - 1], anchors[0]) + ' Z'; }
return d;
}
function finishBez(closed) {
const d = _bezD(bezAnchors, closed);
if (d) {
const o = new fabric.Path(d, { stroke: _drawColor(), strokeWidth: _drawSize(), fill: closed ? _drawColor() : '', strokeLineCap: 'round', strokeLineJoin: 'round', objectCaching: false });
o._name = closed ? 'Shape' : 'Path'; o._type = 'path';
canvas.add(o); canvas.setActiveObject(o); saveHist();
}
bezAnchors = []; bezDrag = false; bezCur = null; canvas.requestRenderAll(); // stay in Pen tool
}
// --- Post-placement anchor editing (Illustrator Direct-Selection style) ----
// Double-click a path/line/shape to edit; drag anchors & handles; Alt-drag a
// handle to break it; double-click an anchor to delete; Enter/Esc to finish.
function enterNodeEdit(obj) {
const style = { stroke: obj.stroke || '#333', strokeWidth: obj.strokeWidth || 2, fill: (typeof obj.fill === 'string') ? obj.fill : '' };
const M = obj.calcTransformMatrix(), po = obj.pathOffset || { x: 0, y: 0 };
const toC = (x, y) => fabric.util.transformPoint({ x: x - po.x, y: y - po.y }, M);
const nodes = []; let closed = false, cur = null;
if (obj.type === 'path' && obj.path) {
obj.path.forEach(seg => {
const c = seg[0];
if (c === 'M' || c === 'L') { const p = toC(seg[1], seg[2]); nodes.push({ x: p.x, y: p.y, hIn: null, hOut: null }); cur = nodes[nodes.length - 1]; }
else if (c === 'C') { if (cur) { const o = toC(seg[1], seg[2]); cur.hOut = { x: o.x, y: o.y }; } const i2 = toC(seg[3], seg[4]), p = toC(seg[5], seg[6]); nodes.push({ x: p.x, y: p.y, hIn: { x: i2.x, y: i2.y }, hOut: null }); cur = nodes[nodes.length - 1]; }
else if (c === 'Q') { const p = toC(seg[3], seg[4]); nodes.push({ x: p.x, y: p.y, hIn: null, hOut: null }); cur = nodes[nodes.length - 1]; }
else if (c === 'Z') closed = true;
});
if (closed && nodes.length > 1 && _dist(nodes[0], nodes[nodes.length - 1]) < 1.5) { nodes[0].hIn = nodes[nodes.length - 1].hIn; nodes.pop(); }
} else if ((obj.type === 'polyline' || obj.type === 'polygon') && obj.points) {
obj.points.forEach(pt => { const p = toC(pt.x, pt.y); nodes.push({ x: p.x, y: p.y, hIn: null, hOut: null }); });
closed = obj.type === 'polygon';
} else { toast('Double-click a pen / line / shape path to edit its points', 'warn'); return; }
if (nodes.length < 2) return;
if (nodeEditOn) exitNodeEdit(true);
deactivateAllTools(); activeTool = null; refreshToolBtns();
canvas.remove(obj); canvas.discardActiveObject();
nodeEditOn = true; editNodes = nodes; editClosed = closed; editStyle = style; editDrag = null;
canvas.selection = false; canvas.defaultCursor = 'default';
toast('Edit points: drag anchors & handles · click a segment to add a point · Alt-click an anchor = corner↔smooth · Alt-drag a handle to break it · double-click an anchor to delete · Enter / Esc to finish');
canvas.requestRenderAll();
}
function exitNodeEdit(commit) {
if (!nodeEditOn) return;
nodeEditOn = false; canvas.selection = true; canvas.defaultCursor = 'default';
if (commit && editNodes.length >= 2) {
const d = _bezD(editNodes, editClosed);
if (d) {
const o = new fabric.Path(d, { stroke: editStyle.stroke, strokeWidth: editStyle.strokeWidth, fill: editClosed ? editStyle.fill : '', strokeLineCap: 'round', strokeLineJoin: 'round', objectCaching: false });
o._name = 'Path'; o._type = 'path';
canvas.add(o); canvas.setActiveObject(o); saveHist();
}
}
editNodes = []; editDrag = null; editStyle = null; canvas.requestRenderAll();
}
function nodeDown(p, alt) {
const th = 9 / zoom;
for (let i = 0; i < editNodes.length; i++) {
const n = editNodes[i];
if (n.hOut && _dist(p, n.hOut) < th) { editDrag = { i, kind: 'hOut' }; return; }
if (n.hIn && _dist(p, n.hIn) < th) { editDrag = { i, kind: 'hIn' }; return; }
}
for (let i = 0; i < editNodes.length; i++) if (_dist(p, editNodes[i]) < th) {
if (alt) { toggleNode(i); editDrag = null; canvas.requestRenderAll(); } // Alt-click → corner ↔ smooth
else editDrag = { i, kind: 'anchor' };
return;
}
const ins = nodeInsert(p); // clicked a segment → add an anchor there
if (ins >= 0) { editDrag = { i: ins, kind: 'anchor' }; canvas.requestRenderAll(); return; }
editDrag = null;
}
function _lerp(a, b, t) { return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; }
function _cubicPt(P0, P1, P2, P3, t) { const u = 1 - t, a = u * u * u, b = 3 * u * u * t, c = 3 * u * t * t, d = t * t * t; return { x: a * P0.x + b * P1.x + c * P2.x + d * P3.x, y: a * P0.y + b * P1.y + c * P2.y + d * P3.y }; }
// Insert an anchor on the segment nearest the click (within threshold). Bézier
// segments are split with De Casteljau so the curve shape is preserved.
function nodeInsert(p) {
const n = editNodes.length, segs = editClosed ? n : n - 1, th = 8 / zoom;
let best = { d: Infinity, s: -1, t: 0 };
for (let s = 0; s < segs; s++) {
const a = editNodes[s], b = editNodes[(s + 1) % n];
if (a.hOut || b.hIn) {
const P0 = a, P1 = a.hOut || a, P2 = b.hIn || b, P3 = b;
for (let k = 1; k < 25; k++) { const t = k / 25, pt = _cubicPt(P0, P1, P2, P3, t), d = _dist(p, pt); if (d < best.d) best = { d, s, t }; }
} else {
const dx = b.x - a.x, dy = b.y - a.y, L2 = dx * dx + dy * dy || 1;
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / L2; t = Math.max(0, Math.min(1, t));
const d = _dist(p, { x: a.x + dx * t, y: a.y + dy * t }); if (d < best.d) best = { d, s, t };
}
}
if (best.s < 0 || best.d > th) return -1;
const s = best.s, t = best.t, a = editNodes[s], b = editNodes[(s + 1) % n];
let N;
if (a.hOut || b.hIn) {
const P0 = { x: a.x, y: a.y }, P1 = a.hOut || { x: a.x, y: a.y }, P2 = b.hIn || { x: b.x, y: b.y }, P3 = { x: b.x, y: b.y };
const P01 = _lerp(P0, P1, t), P12 = _lerp(P1, P2, t), P23 = _lerp(P2, P3, t);
const P012 = _lerp(P01, P12, t), P123 = _lerp(P12, P23, t), mid = _lerp(P012, P123, t);
a.hOut = { x: P01.x, y: P01.y }; b.hIn = { x: P23.x, y: P23.y };
N = { x: mid.x, y: mid.y, hIn: { x: P012.x, y: P012.y }, hOut: { x: P123.x, y: P123.y } };
} else {
N = { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, hIn: null, hOut: null };
}
editNodes.splice(s + 1, 0, N);
return s + 1;
}
function nodeMove(p, alt) {
if (!editDrag) return;
const n = editNodes[editDrag.i];
if (editDrag.kind === 'anchor') {
const dx = p.x - n.x, dy = p.y - n.y; n.x = p.x; n.y = p.y;
if (n.hIn) { n.hIn.x += dx; n.hIn.y += dy; } if (n.hOut) { n.hOut.x += dx; n.hOut.y += dy; }
} else {
n[editDrag.kind] = { x: p.x, y: p.y };
const opp = editDrag.kind === 'hOut' ? 'hIn' : 'hOut';
if (!alt && n[opp]) n[opp] = { x: 2 * n.x - p.x, y: 2 * n.y - p.y }; // keep smooth unless Alt
}
canvas.requestRenderAll();
}
function nodeUp() { editDrag = null; }
// Convert an anchor: smooth (has handles) → corner (drop them); corner → smooth
// (symmetric handles along the tangent through its neighbours).
function toggleNode(i) {
const n = editNodes[i];
if (n.hIn || n.hOut) { n.hIn = null; n.hOut = null; return; }
const cnt = editNodes.length;
const prev = i > 0 ? editNodes[i - 1] : (editClosed ? editNodes[cnt - 1] : null);
const next = i < cnt - 1 ? editNodes[i + 1] : (editClosed ? editNodes[0] : null);
const r0 = prev || n, r1 = next || n;
let tx = r1.x - r0.x, ty = r1.y - r0.y; const len = Math.hypot(tx, ty) || 1; tx /= len; ty /= len;
if (prev) { const d = _dist(n, prev) / 3; n.hIn = { x: n.x - tx * d, y: n.y - ty * d }; }
if (next) { const d = _dist(n, next) / 3; n.hOut = { x: n.x + tx * d, y: n.y + ty * d }; }
}
function nodeDblClick(p) {
const th = 10 / zoom;
for (let i = 0; i < editNodes.length; i++) {
if (_dist(p, editNodes[i]) < th) { if (editNodes.length > 2) { editNodes.splice(i, 1); canvas.requestRenderAll(); } return; }
}
exitNodeEdit(true); // double-click away from any node → finish & exit node editing
}
// ============================================================
// CONTEXT MENU
// ============================================================
function showCtxMenu(e, obj) {
if (!obj) return;
const menu = $i('ctx-menu');
menu.style.display = 'block';
menu.style.left = Math.min(e.clientX, window.innerWidth - 170) + 'px';
menu.style.top = Math.min(e.clientY, window.innerHeight - 220) + 'px';
}
function hideCtxMenu() { $i('ctx-menu').style.display = 'none'; }
// ============================================================
// EXPORT
// ============================================================
async function doExport(options) {
const { fmt: f = fmt, quality: q = exportQuality, scale = exportScale, filename: fn = 'design' } = options || {};
const ext = f === 'jpeg' ? 'jpg' : f;
try {
// If a 3D layer exists, export the composited artboard (2D + 3D) for raster formats.
const dataURL = (f !== 'svg' && window.LTM_has3D && window.LTM_has3D())
? await window.LTM_compositeArtboard(f, q, scale)
: canvas.toDataURL({ format: f, quality: q, multiplier: scale });
const a = document.createElement('a'); a.href = dataURL; a.download = fn + '.' + ext; a.click();
toast('Downloaded!', 'ok');
} catch (e) { toast('Export failed: ' + e.message, 'err'); }
}
async function doCopy() {
try {
const dataURL = canvas.toDataURL({ format: 'png', multiplier: 1 });
const blob = await (await fetch(dataURL)).blob();
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
toast('Copied!', 'ok');
} catch (e) { toast('Clipboard requires HTTPS', 'err'); }
}
// ============================================================
// CANVAS RESIZE
// ============================================================
const SIZE_PRESETS = [
{ name:'Custom', w:800, h:600 }, { name:'Square', w:500, h:500 }, { name:'HD 1080p', w:1920, h:1080 },
{ name:'Instagram', w:1080, h:1080 }, { name:'Facebook', w:1200, h:628 }, { name:'Twitter', w:1500, h:500 },
{ name:'YouTube', w:2560, h:1440 }, { name:'OG Image', w:1200, h:630 }, { name:'A4', w:2480, h:3508 },
{ name:'Banner', w:300, h:250 }, { name:'Leaderboard', w:728, h:90 }, { name:'Blog', w:800, h:450 }
];
function setupSizePresets() {
const el = $i('size-presets');
SIZE_PRESETS.forEach(p => {
const d = document.createElement('div');
d.className = 'size-preset';
d.innerHTML = `<div class="sp-name">${p.name}</div><div class="sp-dim">${p.w}×${p.h}</div>`;
d.onclick = () => {
qsa('.size-preset').forEach(x => x.classList.remove('sel'));
d.classList.add('sel');
const f = 1 / (UNIT_PX[sizeUnit] || 1);
$i('custom-w').value = uRound(p.w * f, sizeUnit); $i('custom-h').value = uRound(p.h * f, sizeUnit);
};
el.appendChild(d);
});
}
function applyCanvasSize(w, h) {
w = parseInt(w) || 800; h = parseInt(h) || 600;
canvas.setWidth(w); canvas.setHeight(h); canvas.renderAll();
updateSB(); fitZoom();
toast(`Canvas → ${w}×${h}`, 'ok'); saveHist();
}
function applyPosSize(e) {
const obj = canvas.getActiveObject();
if (!obj) return;
const which = e && e.target ? e.target.id : '';
const x = fromU($i('pos-x').value), y = fromU($i('pos-y').value);
const w = fromU($i('size-w').value), h = fromU($i('size-h').value);
if (which === 'pos-x') obj.set('left', x);
else if (which === 'pos-y') obj.set('top', y);
else if (which === 'size-w' && w > 0) {
const sx = w / obj.width;
obj.set('scaleX', sx);
if ($i('lock-ratio').checked) obj.set('scaleY', sx); // keep ratio → height follows
} else if (which === 'size-h' && h > 0) {
const sy = h / obj.height;
obj.set('scaleY', sy);
if ($i('lock-ratio').checked) obj.set('scaleX', sy); // keep ratio → width follows
} else {
// applied programmatically (no specific field) → set everything we can
if (!isNaN(x)) obj.set('left', x); if (!isNaN(y)) obj.set('top', y);
if (w > 0) obj.set('scaleX', w / obj.width);
if (h > 0) obj.set('scaleY', $i('lock-ratio').checked ? w / obj.width : h / obj.height);
}
obj.setCoords(); canvas.renderAll(); updateRightPanel(obj); saveHist();
}
// ============================================================
// ALIGN & DISTRIBUTE
// Single object → align to the canvas. Multi-selection → align within
// the selection's bounding box. (Objects return to absolute coords once
// the active selection is discarded, which keeps the math simple.)
// ============================================================
function bboxOf(objs) {
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
objs.forEach(o => { o.setCoords(); const b = o.getBoundingRect(true, true); x1 = Math.min(x1, b.left); y1 = Math.min(y1, b.top); x2 = Math.max(x2, b.left + b.width); y2 = Math.max(y2, b.top + b.height); });
return { left: x1, top: y1, width: x2 - x1, height: y2 - y1 };
}
function alignObjects(mode) {
const ao = canvas.getActiveObject();
if (!ao) { toast('Select an object first'); return; }
const multi = ao.type === 'activeSelection';
let objs, box;
if (multi) { objs = ao.getObjects().slice(); canvas.discardActiveObject(); box = bboxOf(objs); }
else { objs = [ao]; box = { left: 0, top: 0, width: canvas.width, height: canvas.height }; }
if (mode === 'distH' || mode === 'distV') {
if (objs.length < 3) { toast('Select 3+ objects to distribute'); if (multi) { canvas.setActiveObject(new fabric.ActiveSelection(objs, { canvas })); } return; }
const horiz = mode === 'distH';
const items = objs.map(o => { o.setCoords(); const b = o.getBoundingRect(true, true); return { o, c: horiz ? b.left + b.width / 2 : b.top + b.height / 2 }; });
items.sort((a, b) => a.c - b.c);
const first = items[0].c, last = items[items.length - 1].c, step = (last - first) / (items.length - 1);
items.forEach((it, i) => { const d = (first + step * i) - it.c; if (horiz) it.o.left += d; else it.o.top += d; it.o.setCoords(); });
} else {
objs.forEach(o => {
o.setCoords(); const b = o.getBoundingRect(true, true);
if (mode === 'left') o.left += box.left - b.left;
if (mode === 'centerH') o.left += (box.left + box.width / 2) - (b.left + b.width / 2);
if (mode === 'right') o.left += (box.left + box.width) - (b.left + b.width);
if (mode === 'top') o.top += box.top - b.top;
if (mode === 'middleV') o.top += (box.top + box.height / 2) - (b.top + b.height / 2);
if (mode === 'bottom') o.top += (box.top + box.height) - (b.top + b.height);
o.setCoords();
});
}
if (multi) canvas.setActiveObject(new fabric.ActiveSelection(objs, { canvas }));
canvas.requestRenderAll(); saveHist();
}
// ============================================================
// GROUP / UNGROUP
// ============================================================
function groupSelection() {
const ao = canvas.getActiveObject();
if (!ao || ao.type !== 'activeSelection') { toast('Select 2+ objects to group'); return; }
const g = ao.toGroup(); g._name = 'Group'; g._type = 'group';
canvas.requestRenderAll(); updateLayers(); saveHist(); toast('Grouped', 'ok');
}
function ungroupSelection() {
const ao = canvas.getActiveObject();
if (!ao || ao.type !== 'group') { toast('Select a group to ungroup'); return; }
const n = ao.size ? ao.size() : ao.getObjects().length;
if (n > 300 && !confirm(`This vector has ${n} separate parts. Ungrouping them all can be slow and hard to edit individually.\n\nUngroup anyway?`)) return;
toast('Ungrouping…'); setStatus('Ungrouping ' + n + ' parts…');
// Defer so the toast/status paint before the heavy synchronous split.
setTimeout(() => {
ao.toActiveSelection();
canvas.requestRenderAll(); updateLayers(); saveHist();
setStatus('Ready'); toast('Ungrouped', 'ok');
}, 30);
}
// ---- object clipboard (copy / cut / paste) -------------------------------
function copyActive() { const o = canvas.getActiveObject(); if (!o) { toast('Nothing selected'); return; } o.clone(c => { copiedObject = c; }, ['_name', '_type']); toast('Copied'); }
function cutActive() { const o = canvas.getActiveObject(); if (!o) { toast('Nothing selected'); return; } o.clone(c => { copiedObject = c; deleteActive(); toast('Cut'); }, ['_name', '_type']); }
// Place a freshly-cloned object (or multi-selection) onto the canvas, offset by dx/dy.
// Handles fabric activeSelection correctly (its children must be added individually).
function _placeClone(cloned, dx, dy) {
canvas.discardActiveObject();
cloned.set({ left: (cloned.left || 0) + dx, top: (cloned.top || 0) + dy, evented: true });
if (cloned.type === 'activeSelection') {
cloned.canvas = canvas;
cloned.forEachObject(o => { o._name = (o._name || 'obj') + ' copy'; canvas.add(o); });
cloned.setCoords();
} else {
cloned._name = (cloned._name || 'obj') + ' copy';
canvas.add(cloned);
}
canvas.setActiveObject(cloned);
canvas.requestRenderAll(); saveHist();
}
function pasteCopied() {
if (!copiedObject) { toast('Nothing to paste'); return; }
copiedObject.clone(c => _placeClone(c, 20, 20), ['_name', '_type']);
}
// ============================================================
// PATHFINDER — boolean ops (unite / subtract / intersect / exclude) via paper.js
// ============================================================
let paperReady = false;
function ensurePaper() {
if (paperReady) return true;
if (typeof paper === 'undefined') { toast('Pathfinder still loading — try again in a second', 'warn'); return false; }
paper.setup(document.createElement('canvas'));
paper.settings.applyMatrix = true; // bake transforms into geometry as we go
paperReady = true; return true;
}
// Turn a Fabric object into ONE paper path, in absolute canvas coords.
// We read the shape's LOCAL (transform-free, centre-origin) geometry and then
// apply its absolute matrix `m` — this mirrors exactly how Fabric renders the
// object, so rotation / scale / position are always preserved. (We can't rely on
// obj.toSVG() after discarding the selection, because Fabric doesn't bake the
// active-selection's rotation back into the child objects.)
function objToPaperPath(obj, m) {
try {
// Temporarily neutralise the object's own transform to read pure local geometry.
const saved = { left: obj.left, top: obj.top, angle: obj.angle, scaleX: obj.scaleX, scaleY: obj.scaleY,
skewX: obj.skewX, skewY: obj.skewY, flipX: obj.flipX, flipY: obj.flipY,
originX: obj.originX, originY: obj.originY };
obj.set({ left: 0, top: 0, angle: 0, scaleX: 1, scaleY: 1, skewX: 0, skewY: 0,
flipX: false, flipY: false, originX: 'center', originY: 'center' });
obj.setCoords();
const svg = '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">' + obj.toSVG() + '</svg>';
obj.set(saved); obj.setCoords(); // restore immediately (no render happened in between)
const item = paper.project.importSVG(svg, { expandShapes: true, insert: true });
const arr = [];
(function gather(it) {
if (!it) return;
const cn = it.className;
if (cn === 'Path' || cn === 'CompoundPath') arr.push(it);
if (it.children) it.children.slice().forEach(gather);
})(item);
if (!arr.length) { item.remove(); return null; }
let local = arr[0].clone({ insert: true }); // clone so it survives item.remove()
for (let i = 1; i < arr.length; i++) { try { local = local.unite(arr[i]); } catch (e) {} }
item.remove();
if (!local) return null;
// CRITICAL: bake the absolute transform into the actual point coordinates and
// rebuild a fresh, identity-matrix path from that string. paper's boolean ops
// ignore pending/unbaked matrices, so the operands MUST already be absolute —
// getPathData(matrix) is the only thing that provably transforms the points.
const pm = m ? new paper.Matrix(m[0], m[1], m[2], m[3], m[4], m[5]) : null;
const absData = pm ? local.getPathData(pm, 4) : local.pathData;
local.remove();
if (!absData) return null;
return new paper.CompoundPath({ pathData: absData, insert: true });
} catch (e) { console.error('objToPaperPath failed:', e); return null; }
}
function pathfinder(op) {
const ao = canvas.getActiveObject();
if (!ao || ao.type !== 'activeSelection' || ao.getObjects().length < 2) { toast('Select 2 or more shapes first', 'warn'); return; }
if (!ensurePaper()) return;
const objs = ao.getObjects().slice(); // bottom-to-top stacking order
// Capture each object's ABSOLUTE transform (incl. the active-selection's own
// rotation/scale) BEFORE discarding — afterwards Fabric loses the selection's
// rotation, so this is the only reliable source of the real on-canvas placement.
const mats = objs.map(o => o.calcTransformMatrix());
const style = { fill: (objs[0].fill && objs[0].fill !== 'transparent') ? objs[0].fill : '#7c3aed', stroke: objs[0].stroke || null, strokeWidth: objs[0].strokeWidth || 0 };
canvas.discardActiveObject(); canvas.requestRenderAll(); // restore absolute coords
paper.project.clear();
const paths = objs.map((o, i) => objToPaperPath(o, mats[i])).filter(Boolean);
if (paths.length < 2) { toast('Could not read those shapes', 'err'); return; }
let result;
try {
if (op === 'unite') result = paths.reduce((a, b) => a.unite(b));
else if (op === 'intersect') result = paths.reduce((a, b) => a.intersect(b));
else if (op === 'exclude') result = paths.reduce((a, b) => a.exclude(b));
else { result = paths[0]; for (let i = 1; i < paths.length; i++) result = result.subtract(paths[i]); } // subtract = bottom minus the ones above
} catch (e) { console.error(e); toast('Operation failed on these shapes', 'err'); return; }
// Read the result in absolute coords (globalMatrix handles any residual transform).
const d = result ? result.getPathData(result.globalMatrix, 4) : null;
if (!d) { toast('Result was empty', 'warn'); return; }
objs.forEach(o => canvas.remove(o));
const path = new fabric.Path(d, { fill: style.fill, stroke: style.stroke, strokeWidth: style.strokeWidth, fillRule: op === 'unite' ? 'nonzero' : 'evenodd' });
path._name = 'Combined'; path._type = 'shape';
canvas.add(path); canvas.setActiveObject(path); canvas.requestRenderAll();
updateLayers(); saveHist();
toast(({ unite: 'United', subtract: 'Subtracted', intersect: 'Intersected', exclude: 'Excluded' })[op] + ' into one shape', 'ok');
}
// ============================================================
// SAVE / LOAD PROJECTS (autosave to localStorage + .json export/import)
// ============================================================
function projectData() {
return { app: 'designforge', v: 1, name: $i('doc-name').value || 'Untitled Design', w: canvas.width, h: canvas.height, json: canvas.toJSON(['_name', '_type']) };
}
function scheduleAutosave() { clearTimeout(autosaveTimer); autosaveTimer = setTimeout(() => { try { localStorage.setItem('df_autosave', JSON.stringify(projectData())); } catch (e) {} }, 800); }
function loadProject(data, announce) {
if (!data || !data.json) { toast('Not a valid project file', 'warn'); return; }
canvas.setWidth(parseInt(data.w) || 800); canvas.setHeight(parseInt(data.h) || 600);
$i('doc-name').value = data.name || 'Untitled Design';
$i('custom-w').value = canvas.width; $i('custom-h').value = canvas.height;
undoing = true;
canvas.loadFromJSON(data.json, () => {
canvas.renderAll(); undoing = false;
hist = []; hidx = -1; saveHist();
updateLayers(); updateSB(); fitZoom();
if (announce) toast(announce, 'ok');
});
}
function restoreAutosave() {
let data; try { data = JSON.parse(localStorage.getItem('df_autosave') || 'null'); } catch (e) {}
if (!data || !data.json || !data.json.objects || !data.json.objects.length) return false;
loadProject(data, 'Restored your previous design — use ✲ New to start fresh');
return true;
}
function saveProjectFile() {
const blob = new Blob([JSON.stringify(projectData())], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = ($i('doc-name').value || 'design').replace(/[^\w\-]+/g, '_') + '.dfp.json'; a.click();
URL.revokeObjectURL(url); toast('Project saved', 'ok');
}
function openProjectFile(file) {
const r = new FileReader();
r.onload = () => { try { loadProject(JSON.parse(r.result), 'Project opened'); } catch (e) { toast('Could not read project file', 'warn'); } };
r.readAsText(file);
}
function newDesign() {
if (canvas.getObjects().length && !confirm('Start a new blank design? Unsaved changes will be cleared.')) return;
canvas.clear(); canvas.setBackgroundColor('#ffffff', canvas.renderAll.bind(canvas));
$i('doc-name').value = 'Untitled Design';
try { localStorage.removeItem('df_autosave'); } catch (e) {}
hist = []; hidx = -1; saveHist(); updateLayers(); updateSB(); fitZoom();
toast('New design', 'ok');
}
// ============================================================
// VECTOR TRACING (raster -> SVG via ImageTracer.js) + MY LIBRARY
// ============================================================
let traceSourceEl = null; // HTMLImageElement queued for tracing
let traceSourceObj = null; // the on-canvas fabric image it came from (removed after tracing)
function getSelectedImageEl() {
const o = canvas.getActiveObject();
return (o && o.type === 'image') ? (o._element || o.getElement()) : null;
}
function setTraceSourceFromFile(file) {
const r = new FileReader();
r.onload = () => { const im = new Image(); im.onload = () => { traceSourceEl = im; traceSourceObj = null; toast('Source ready — pick a color count', 'ok'); }; im.src = r.result; };
r.readAsDataURL(file);
}
function imageToImageData(imgEl, maxDim) {
let w = imgEl.naturalWidth || imgEl.width, h = imgEl.naturalHeight || imgEl.height;
const s = Math.min(1, maxDim / Math.max(w, h));
w = Math.max(1, Math.round(w * s)); h = Math.max(1, Math.round(h * s));
const c = document.createElement('canvas'); c.width = w; c.height = h;
const ctx = c.getContext('2d'); ctx.drawImage(imgEl, 0, 0, w, h);
return ctx.getImageData(0, 0, w, h);
}
function thresholdImageData(imgd) {
const d = imgd.data;
for (let i = 0; i < d.length; i += 4) {
const lum = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
const v = lum < 128 ? 0 : 255; d[i] = d[i + 1] = d[i + 2] = v; d[i + 3] = 255;
}
return imgd;
}
function traceOptionsFor(mode, detail, edge) {
// detail 0..3: lower thresholds + less path-omission = more faithful, more points.
const lq = [2, 1, 0.5, 0.3][detail] != null ? [2, 1, 0.5, 0.3][detail] : 1;
const pathomit = [12, 8, 4, 1][detail] != null ? [12, 8, 4, 1][detail] : 8;
const sharp = edge === 'sharp';
const base = {
ltres: lq, qtres: lq, pathomit,
rightangleenhance: sharp, // crisp 90° corners for logos/text
linefilter: !sharp, // smooth jagged pixel steps into clean curves
strokewidth: 0, scale: 1, roundcoords: 2, viewbox: false, desc: false,
colorsampling: 2, colorquantcycles: 4, mincolorratio: 0, blurradius: 0
};
const blackWhitePal = [{ r: 0, g: 0, b: 0, a: 255 }, { r: 255, g: 255, b: 255, a: 255 }];
if (mode === 'bw') return Object.assign(base, { numberofcolors: 2, pal: blackWhitePal, _bw: true });
if (mode === '1') return Object.assign(base, { numberofcolors: 2, pal: blackWhitePal, _one: true });
const colors = mode === 'full' ? 16 : parseInt(mode);
return Object.assign(base, { numberofcolors: colors, blurradius: colors >= 8 ? 1 : 0 });
}
function runTrace(mode) {
if (typeof ImageTracer === 'undefined') { toast('Tracer still loading — try again in a second', 'warn'); return; }
const el = traceSourceEl || getSelectedImageEl();
if (!el) { toast('Choose a source image first', 'warn'); return; }
// If they just selected an image and tapped a color (without "Use selected"),
// still treat that canvas image as the source so it gets replaced, not duplicated.
if (!traceSourceObj) { const ao = canvas.getActiveObject(); if (ao && ao.type === 'image') traceSourceObj = ao; }
const detail = parseInt($i('tr-detail').value) || 1;
const edgeBtn = qs('#tr-edge .sel'); const edge = edgeBtn ? edgeBtn.dataset.edge : 'smooth';
const opts = traceOptionsFor(mode, detail, edge);
const st = $i('tr-status'); st.style.display = 'block'; st.textContent = '⏳ Tracing… this can take a moment';
setStatus('Tracing image…');
setTimeout(() => {
try {
let imgd = imageToImageData(el, 1300);
if (opts._bw || opts._one) imgd = thresholdImageData(imgd);
const svgstr = ImageTracer.imagedataToSVG(imgd, opts);
fabric.loadSVGFromString(svgstr, (objects, options) => {
let objs = (objects || []).filter(Boolean);
if (opts._one) objs = objs.filter(o => { const f = new fabric.Color(o.fill || '#000').getSource(); return (f[0] + f[1] + f[2]) < 384; });
if (!objs.length) { st.style.display = 'none'; setStatus('Ready'); toast('Nothing traceable in this image', 'warn'); return; }
const g = fabric.util.groupSVGElements(objs, options);
const sc = Math.min(canvas.width * 0.6 / (g.width || 1), canvas.height * 0.6 / (g.height || 1), 4);
g.set({ left: canvas.width / 2, top: canvas.height / 2, originX: 'center', originY: 'center', scaleX: sc, scaleY: sc });
g._name = 'Vector (' + (mode === 'bw' ? 'B&W' : mode === 'full' ? 'full color' : mode + ' color') + ')'; g._type = 'vector';
// Replace the original raster: drop the source image so it isn't left
// behind (and won't get copied along with the vector).
if (traceSourceObj && canvas.getObjects().indexOf(traceSourceObj) !== -1) { canvas.remove(traceSourceObj); }
traceSourceObj = null;
canvas.add(g); canvas.setActiveObject(g); canvas.requestRenderAll();
updateLayers(); saveHist();
st.style.display = 'none'; setStatus('Ready'); toast('Vectorized! 🎉', 'ok');
});
} catch (e) {
console.error(e); st.style.display = 'none'; setStatus('Ready');
toast('Trace failed — the image may be cross-origin protected. Try Upload/Paste.', 'err');
}
}, 40);
}
// ---- My Library (persistent, localStorage) -------------------------------
function loadLib() { try { return JSON.parse(localStorage.getItem('df_library') || '[]'); } catch (e) { return []; } }
function saveLib(arr) { try { localStorage.setItem('df_library', JSON.stringify(arr)); return true; } catch (e) { toast('Library storage full — export & remove some items', 'warn'); return false; } }
function addToLibrary() {
let o = canvas.getActiveObject();
if (!o) { toast('Select an object to save first', 'warn'); return; }
if (o.type === 'activeSelection') { o.toGroup(); canvas.requestRenderAll(); o = canvas.getActiveObject(); }
const json = o.toObject(['_name', '_type']);
let thumb = '';
try { thumb = o.toDataURL({ format: 'png', multiplier: Math.min(1, 90 / Math.max(o.getScaledWidth(), o.getScaledHeight())) }); } catch (e) {}
const arr = loadLib();
arr.unshift({ id: 'L' + Date.now(), name: o._name || o.type || 'Item', json, thumb, ts: Date.now() });
if (saveLib(arr)) { renderLibrary(); toast('Saved to Library', 'ok'); }
}
function addLibraryItemToCanvas(item) {
fabric.util.enlivenObjects([item.json], objs => {
const o = objs[0]; if (!o) return;
o.set({ left: canvas.width / 2, top: canvas.height / 2, originX: 'center', originY: 'center' });
o._name = item.name; canvas.add(o); canvas.setActiveObject(o); canvas.requestRenderAll();
updateLayers(); saveHist(); toast('Added from Library', 'ok');
});
}
function renderLibrary() {
const arr = loadLib(), grid = $i('lib-grid'), empty = $i('lib-empty');
if (!grid) return;
grid.innerHTML = ''; empty.style.display = arr.length ? 'none' : 'block';
arr.forEach(item => {
const d = document.createElement('div'); d.className = 'thumb-item lib-item'; d.title = item.name;
const safe = (item.name || '').replace(/[<>&"]/g, '');
d.innerHTML = `<img src="${item.thumb || ''}" alt=""><span class="lib-name">${safe}</span><button class="lib-del" title="Delete">×</button>`;
d.querySelector('img').onclick = () => addLibraryItemToCanvas(item);
d.querySelector('.lib-del').onclick = e => { e.stopPropagation(); saveLib(loadLib().filter(x => x.id !== item.id)); renderLibrary(); };
grid.appendChild(d);
});
}
function exportLibrary() {
const blob = new Blob([JSON.stringify(loadLib())], { type: 'application/json' });
const u = URL.createObjectURL(blob); const a = document.createElement('a');
a.href = u; a.download = 'designforge-library.json'; a.click(); URL.revokeObjectURL(u);
toast('Library exported', 'ok');
}
function importLibrary(file) {
const r = new FileReader();
r.onload = () => { try { const inc = JSON.parse(r.result); if (!Array.isArray(inc)) throw 0; const cur = loadLib(); const ids = new Set(cur.map(x => x.id)); inc.forEach(x => { if (!ids.has(x.id)) cur.push(x); }); saveLib(cur); renderLibrary(); toast('Library imported', 'ok'); } catch (e) { toast('Invalid library file', 'err'); } };
r.readAsText(file);
}
// ============================================================
// PANEL SWITCHING
// ============================================================
let activePanelId = 'upload';
const PANEL_TITLES = { upload:'Upload', templates:'Templates', photos:'Photos', elements:'Shapes', text:'Text', background:'Background', trace:'Vectorize', model3d:'3D Studio', threed:'3D Studio', ai:'AI Designer', library:'My Library', layers:'Layers' };
// Always open the given panel (used by auto-switch on selection)
function openPanel(id) {
qsa('.tab-icon').forEach(t => t.classList.remove('active'));
const tab = qs(`[data-panel="${id}"]`);
if (tab) tab.classList.add('active');
qsa('.panel-section').forEach(s => s.classList.remove('active'));
const sec = $i('sec-' + id);
if (sec) sec.classList.add('active');
$i('panel-title').textContent = PANEL_TITLES[id] || id;
$i('left-panel').classList.add('open');
activePanelId = id;
// 3D Studio stage follows the panel: open it on the 3D tab, close it elsewhere
if (window.M3D) { if (id === 'model3d') M3D.enter(); else M3D.exit(); }
// Populate photo search on first open
if (id === 'photos' && !$i('pix-results').children.length && !pixLoading) searchPixabay('nature');
}
// Tab click: toggle closed if already on this panel, else open
function switchPanel(id) {
const panel = $i('left-panel');
if (activePanelId === id && panel.classList.contains('open')) { panel.classList.remove('open'); return; }
openPanel(id);
}
// ============================================================
// MAIN INIT
// ============================================================
document.addEventListener('DOMContentLoaded', () => {
// TOP-BAR TOOLBAR — Select (default), shape inserts, draw, duplicate, delete
const qa = (id, fn) => { const b = $i(id); if (b) b.addEventListener('click', fn); };
const setTopTool = id => qsa('#tool-bar .tool').forEach(b => b.classList.toggle('tool-active', b.id === id));
function topSelectMode() { if (nodeEditOn) exitNodeEdit(true); deactivateAllTools(); activeTool = null; refreshToolBtns(); canvas.selection = true; setTopTool('qa-select'); }
qa('qa-select', topSelectMode);
qa('qa-text', () => { addText('custom'); setTopTool('qa-select'); });
qa('pf-unite', () => pathfinder('unite'));
qa('pf-subtract', () => pathfinder('subtract'));
qa('pf-intersect', () => pathfinder('intersect'));
qa('pf-exclude', () => pathfinder('exclude'));
setTopTool('qa-select'); // arrow/select is the default active tool
qa('qa-dup', () => { const o = canvas.getActiveObject(); if (o) o.clone(c => _placeClone(c, 20, 20), ['_name', '_type']); else if (B.selected) duplicate3D(); });
qa('qa-del', () => { const o = canvas.getActiveObject(); if (o) deleteActive(); else if (B.selected) deleteSelected(); });
wireStyleBar(); // common style toolbar (top bar)
// Inspector "Fill & Effects" — shadow + skew (gradient buttons are wired separately)
const applyShadow = () => {
const o = canvas.getActiveObject(); if (!o) return;
o.set('shadow', $i('fx-shadow').checked
? new fabric.Shadow({ color: $i('fx-shadow-color').value, blur: parseInt($i('fx-shadow-blur').value) || 8, offsetX: 4, offsetY: 4 })
: null);
canvas.renderAll(); saveHist();
};
$i('fx-shadow').addEventListener('change', applyShadow);
$i('fx-shadow-color').addEventListener('input', () => { if ($i('fx-shadow').checked) applyShadow(); });
$i('fx-shadow-blur').addEventListener('input', () => { if ($i('fx-shadow').checked) applyShadow(); });
const applySkew = (axis, v, lbl) => { const o = canvas.getActiveObject(); if (!o) return; $i(lbl).textContent = v; o.set(axis, parseFloat(v)); o.setCoords(); canvas.renderAll(); };
$i('fx-skewx').addEventListener('input', function() { applySkew('skewX', this.value, 'fx-skewx-v'); });
$i('fx-skewy').addEventListener('input', function() { applySkew('skewY', this.value, 'fx-skewy-v'); });
$i('fx-skewx').addEventListener('change', saveHist);
$i('fx-skewy').addEventListener('change', saveHist);
// WELCOME POPUP — shown once per browser
const wo = $i('welcome-overlay');
if (!localStorage.getItem('df_welcomed')) wo.classList.add('open');
const closeWelcome = () => { wo.classList.remove('open'); try { localStorage.setItem('df_welcomed', '1'); } catch (e) {} };
$i('welcome-close').onclick = closeWelcome;
wo.addEventListener('click', e => { if (e.target === wo) closeWelcome(); });
initCanvas(800, 600);
saveHist();
restoreAutosave(); // bring back the user's last design if there is one
setupPan(); // middle-mouse drag to pan the artboard
setupBgPalette();
setupGradPresets();
setupSizePresets();
buildShapePanel();
setupShapeSearch();
// Load Google Fonts
const fonts = ['Poppins','Montserrat','Raleway','Nunito','Inter','Space+Grotesk','Plus+Jakarta+Sans','Manrope','Sora','Playfair+Display','Lora','Merriweather','Cormorant','Abril+Fatface','Bebas+Neue','Pacifico','Dancing+Script','Caveat','Russo+One','JetBrains+Mono','Fira+Code','Space+Mono'];
const link = document.createElement('link');
link.href = `https://fonts.googleapis.com/css2?family=${fonts.join('&family=')}&display=swap`;
link.rel = 'stylesheet';
document.head.appendChild(link);
// TAB ICONS
qsa('.tab-icon[data-panel]').forEach(t => t.addEventListener('click', () => switchPanel(t.dataset.panel)));
$i('panel-close-btn').addEventListener('click', () => $i('left-panel').classList.remove('open'));
// UPLOAD
const uz = $i('upload-zone');
uz.addEventListener('click', () => $i('file-input').click());
uz.addEventListener('dragover', e => { e.preventDefault(); uz.classList.add('drag-over'); });
uz.addEventListener('dragleave', () => uz.classList.remove('drag-over'));
uz.addEventListener('drop', e => { e.preventDefault(); uz.classList.remove('drag-over'); if (e.dataTransfer.files[0]?.type.startsWith('image/')) loadFile(e.dataTransfer.files[0]); });
$i('file-input').addEventListener('change', e => { if (e.target.files[0]) loadFile(e.target.files[0]); });
// URL
$i('btn-load-url').addEventListener('click', () => loadURL($i('url-input').value));
$i('url-input').addEventListener('keydown', e => { if (e.key === 'Enter') loadURL(e.target.value); });
// PIXABAY PHOTO SEARCH
$i('pix-search-btn').addEventListener('click', () => searchPixabay($i('pix-input').value));
$i('pix-input').addEventListener('keydown', e => { if (e.key === 'Enter') searchPixabay(e.target.value); });
qsa('#pix-quick .ef-chip').forEach(chip => chip.addEventListener('click', function() {
$i('pix-input').value = this.dataset.q; searchPixabay(this.dataset.q);
}));
// SAMPLES
qsa('#sample-grid .thumb-item').forEach(t => t.addEventListener('click', () => loadURL(t.dataset.url)));
$i('btn-sample-quick').addEventListener('click', () => {
const urls = ['abc','xyz','city','tech','food','ocean'].map(s => `https://picsum.photos/seed/${s}/800/600`);
loadURL(urls[Math.floor(Math.random() * urls.length)]);
});
// PASTE
$i('btn-paste').addEventListener('click', async () => {
try {
const items = await navigator.clipboard.read();
for (const item of items) for (const type of item.types) if (type.startsWith('image/')) { loadFile(await item.getType(type)); return; }
toast('No image in clipboard', 'err');
} catch { toast('Paste URL above instead', 'err'); }
});
document.addEventListener('paste', e => {
if (['INPUT','TEXTAREA'].includes(document.activeElement.tagName)) return;
for (const item of e.clipboardData.items) if (item.type.startsWith('image/')) { loadFile(item.getAsFile()); return; }
});
// CANVAS DRAG-DROP
const cw = $i('canvas-wrap');
cw.addEventListener('dragover', e => { e.preventDefault(); cw.classList.add('drag-over'); });
cw.addEventListener('dragleave', () => cw.classList.remove('drag-over'));
cw.addEventListener('drop', e => {
e.preventDefault(); cw.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file?.type.startsWith('image/')) { loadFile(file); return; }
const url = e.dataTransfer.getData('text/uri-list') || e.dataTransfer.getData('text/plain');
if (url?.startsWith('http')) loadURL(url);
});
// TEMPLATES
qsa('.template-item').forEach(t => t.addEventListener('click', () => applyCanvasSize(t.dataset.w, t.dataset.h)));
// SHAPE STYLE LIVE UPDATE
$i('sh-fill').addEventListener('input', function() { if (!$i('sh-no-fill').checked) updateActiveShape('fill', this.value); });
$i('sh-fill').addEventListener('change', function() { if (!$i('sh-no-fill').checked) pushRecentColor(this.value); });
$i('sh-no-fill').addEventListener('change', function() { updateActiveShape('fill', this.checked ? 'transparent' : $i('sh-fill').value); });
$i('sh-stroke').addEventListener('input', function() { updateActiveShape('stroke', this.value); });
$i('sh-stroke').addEventListener('change', function() { pushRecentColor(this.value); });
$i('sh-stroke-w').addEventListener('change', function() { updateActiveShape('strokeWidth', parseInt(this.value) || 0); });
$i('sh-opacity').addEventListener('input', function() {
$i('sh-op-val').textContent = Math.round(this.value * 100) + '%';
updateActiveShape('opacity', parseFloat(this.value));
});
$i('sh-radius').addEventListener('input', function() {
$i('sh-radius-val').textContent = this.value;
const o = canvas.getActiveObject();
if (o && o.type === 'rect') { o.set({ rx: +this.value, ry: +this.value }); canvas.renderAll(); }
});
$i('sh-radius').addEventListener('change', saveHist);
$i('sh-dash').addEventListener('change', function() {
updateActiveShape('strokeDashArray', DASH_MAP[this.value] || null);
});
$i('sh-grad-apply').addEventListener('click', () => applyShapeGradient($i('sh-grad-from').value, $i('sh-grad-to').value, $i('sh-grad-dir').value));
$i('sh-grad-clear').addEventListener('click', () => updateActiveShape('fill', $i('sh-no-fill').checked ? 'transparent' : $i('sh-fill').value));
// DRAWING TOOLSET
$i('t-magic').addEventListener('click', () => selectTool('magic'));
$i('t-brush').addEventListener('click', () => selectTool('brush'));
$i('t-line').addEventListener('click', () => selectTool('line'));
$i('t-pen').addEventListener('click', () => selectTool('pen'));
$i('draw-size').addEventListener('input', function () { $i('draw-size-val').textContent = this.value; refreshBrush(); });
$i('draw-color').addEventListener('input', refreshBrush);
$i('brush-type').addEventListener('change', refreshBrush);
$i('sg-close').addEventListener('click', hideSuggestions);
// TEXT PRESETS
$i('tp-heading').addEventListener('click', () => addText('heading'));
$i('tp-sub').addEventListener('click', () => addText('subheading'));
$i('tp-body').addEventListener('click', () => addText('body'));
$i('tp-caption').addEventListener('click', () => addText('caption'));
$i('btn-add-text').addEventListener('click', () => addText('custom'));
// TEXT LIVE UPDATE
$i('txt-font').addEventListener('change', function() { updateActiveText('fontFamily', this.value); });
$i('txt-size').addEventListener('change', function() { updateActiveText('fontSize', parseInt(this.value)); });
$i('txt-color').addEventListener('input', function() { updateActiveText('fill', this.value); });
$i('txt-color').addEventListener('change', function() { pushRecentColor(this.value); });
$i('txt-stroke-color').addEventListener('input', function() { updateActiveText('stroke', this.value); });
$i('txt-stroke-color').addEventListener('change', function() { pushRecentColor(this.value); });
$i('txt-stroke-w').addEventListener('change', function() { updateActiveText('strokeWidth', parseInt(this.value) || 0); });
$i('txt-shadow').addEventListener('change', function() {
updateActiveText('shadow', this.checked ? new fabric.Shadow({ color:'rgba(0,0,0,0.4)', blur:10, offsetX:3, offsetY:3 }) : null);
});
$i('txt-bold').addEventListener('click', function() { boldOn = !boldOn; this.classList.toggle('active', boldOn); updateActiveText('fontWeight', boldOn ? 'bold' : 'normal'); });
$i('txt-italic').addEventListener('click', function() { italicOn = !italicOn; this.classList.toggle('active', italicOn); updateActiveText('fontStyle', italicOn ? 'italic' : 'normal'); });
$i('txt-underline').addEventListener('click', function() { underlineOn = !underlineOn; this.classList.toggle('active', underlineOn); updateActiveText('underline', underlineOn); });
['txt-align-l','txt-align-c','txt-align-r','txt-align-j'].forEach(id => {
$i(id).addEventListener('click', function() {
qsa('#sec-text .ir-btn[id^="txt-align"]').forEach(b => b.classList.remove('active'));
this.classList.add('active');
textAlign = { 'txt-align-l':'left','txt-align-c':'center','txt-align-r':'right','txt-align-j':'justify' }[id];
updateActiveText('textAlign', textAlign);
});
});
$i('txt-charspace').addEventListener('input', function() {
$i('txt-cs-val').textContent = this.value;
updateActiveText('charSpacing', parseInt(this.value) || 0);
});
$i('txt-lineheight').addEventListener('input', function() {
$i('txt-lh-val').textContent = (+this.value).toFixed(2);
updateActiveText('lineHeight', parseFloat(this.value) || 1.16);
});
$i('txt-bend').addEventListener('input', function() { $i('txt-bend-v').textContent = this.value; applyTextBend(this.value); });
$i('txt-bend').addEventListener('change', saveHist);
$i('txt-opacity').addEventListener('input', function() {
$i('txt-op-val').textContent = Math.round(this.value * 100) + '%';
updateActiveText('opacity', parseFloat(this.value));
});
// BACKGROUND
$i('btn-apply-bg').addEventListener('click', () => { canvas.setBackgroundColor($i('bg-color-pick').value, canvas.renderAll.bind(canvas)); saveHist(); toast('Background updated', 'ok'); });
$i('btn-apply-grad').addEventListener('click', () => applyFabricGradient($i('grad-from').value, $i('grad-to').value, $i('grad-dir').value));
$i('btn-transparent-bg').addEventListener('click', () => { canvas.setBackgroundColor(null, canvas.renderAll.bind(canvas)); saveHist(); toast('Transparent', 'ok'); });
// ADJUSTMENTS
[['adj-brightness','bri-val'],['adj-contrast','con-val'],['adj-saturation','sat-val'],['adj-blur','blur-val']].forEach(([id, vid]) => {
$i(id).addEventListener('input', function() { $i(vid).textContent = parseFloat(this.value).toFixed(1); applyFilters(); });
});
qsa('#filter-chips .ef-chip').forEach(chip => chip.addEventListener('click', function() {
qsa('#filter-chips .ef-chip').forEach(c => c.classList.remove('active'));
this.classList.add('active'); activeFilter = this.dataset.filter; applyFilters();
}));
// OBJECT TRANSFORM
['pos-x','pos-y','size-w','size-h'].forEach(id => $i(id).addEventListener('change', applyPosSize));
$i('obj-opacity').addEventListener('input', function() {
const obj = canvas.getActiveObject(); if (obj) { obj.set('opacity', parseFloat(this.value)); canvas.renderAll(); }
});
$i('obj-opacity').addEventListener('change', saveHist);
// TRANSFORM BUTTONS (flip / rotate live in the selection popup)
const doFlipH = () => { const o = canvas.getActiveObject(); if (o) { o.set('flipX', !o.flipX); canvas.renderAll(); saveHist(); } };
const doFlipV = () => { const o = canvas.getActiveObject(); if (o) { o.set('flipY', !o.flipY); canvas.renderAll(); saveHist(); } };
const doRotL = () => { const o = canvas.getActiveObject(); if (o) { o.rotate((o.angle||0) - 90); canvas.renderAll(); saveHist(); } };
const doRotR = () => { const o = canvas.getActiveObject(); if (o) { o.rotate((o.angle||0) + 90); canvas.renderAll(); saveHist(); } };
$i('ct-flip-h').onclick = doFlipH; $i('ct-flip-v').onclick = doFlipV;
$i('ct-rot-l').onclick = doRotL; $i('ct-rot-r').onclick = doRotR;
// CROP
$i('ct-crop').onclick = startCrop;
$i('btn-crop-apply').onclick = applyCrop;
$i('btn-crop-cancel').onclick = cancelCrop;
// CANVAS TOOLBAR
$i('ct-bring-f').onclick = () => { const o = canvas.getActiveObject(); if (o) { canvas.bringForward(o); updateLayers(); saveHist(); } };
$i('ct-send-b').onclick = () => { const o = canvas.getActiveObject(); if (o) { canvas.sendBackwards(o); updateLayers(); saveHist(); } };
$i('ct-del').onclick = deleteActive;
// CONTEXTUAL POPUP — color + image adjustments
const duplicateActive = () => {
const o = canvas.getActiveObject(); if (!o) return;
o.clone(c => _placeClone(c, 20, 20), ['_name', '_type']);
};
$i('ct-dup').onclick = duplicateActive;
$i('ct-color-btn').onclick = () => { setToolbarColorDot(); $i('ct-color-input').click(); };
$i('ct-color-input').addEventListener('input', function() { applyColorToActive(this.value); });
$i('ct-color-input').addEventListener('change', function() { pushRecentColor(this.value); });
// Drag the floating selection toolbar by its grip (zoom-aware, since the canvas
// container is CSS-scaled). The native colour picker now opens next to its button
// because the overlay <input> sits exactly on top of it (no longer display:none).
(function makeToolbarDraggable() {
const bar = $i('canvas-toolbar'), grip = $i('ct-grip'); if (!bar || !grip) return;
let sx = 0, sy = 0, sl = 0, st = 0, dragging = false;
grip.addEventListener('pointerdown', e => {
e.preventDefault();
// convert the centered (translateX(-50%)) position to an explicit left/top once
sl = bar.offsetLeft - bar.offsetWidth / 2; st = bar.offsetTop;
bar.style.transform = 'none'; bar.style.left = sl + 'px'; bar.style.top = st + 'px';
sx = e.clientX; sy = e.clientY; dragging = true;
try { grip.setPointerCapture(e.pointerId); } catch (_) {}
});
grip.addEventListener('pointermove', e => {
if (!dragging) return;
const z = zoom || 1;
bar.style.left = (sl + (e.clientX - sx) / z) + 'px';
bar.style.top = (st + (e.clientY - sy) / z) + 'px';
});
const stop = e => { if (dragging) { dragging = false; try { grip.releasePointerCapture(e.pointerId); } catch (_) {} } };
grip.addEventListener('pointerup', stop);
grip.addEventListener('pointercancel', stop);
})();
// Move the image Adjustments/Filters panel out of the right rail and into a
// popover that drops from the selection toolbar when an image is selected.
if ($i('rp-adj') && $i('ct-pop-adjust')) $i('ct-pop-adjust').appendChild($i('rp-adj'));
// Move the text styling controls out of the left Text panel into the Inspector (keeps all ids/wiring)
if ($i('txt-style-controls') && $i('rp-text-body')) $i('rp-text-body').appendChild($i('txt-style-controls'));
$i('ct-adjust').onclick = () => { if (canvas.getActiveObject()) $i('ct-pop-adjust').classList.toggle('open'); };
document.addEventListener('mousedown', e => {
const pop = $i('ct-pop-adjust');
if (pop && pop.classList.contains('open') && !pop.contains(e.target) && !$i('ct-adjust').contains(e.target))
pop.classList.remove('open');
});
// LAYERS
$i('lay-up').onclick = () => { const o = canvas.getActiveObject(); if (o) { canvas.bringForward(o); updateLayers(); saveHist(); } };
$i('lay-dn').onclick = () => { const o = canvas.getActiveObject(); if (o) { canvas.sendBackwards(o); updateLayers(); saveHist(); } };
$i('lay-del').onclick = deleteActive;
$i('lay-dup').onclick = duplicateActive;
// CANVAS SIZE
$i('btn-canvas-size').onclick = () => {
sizeUnit = 'px'; $i('size-unit').value = 'px';
$i('custom-w').value = canvas.width; $i('custom-h').value = canvas.height;
$i('size-modal-overlay').style.display = 'flex'; $i('size-modal-overlay').classList.add('open');
};
$i('size-modal-close').onclick = $i('size-cancel').onclick = () => { $i('size-modal-overlay').style.display = 'none'; };
// switching unit converts the shown W/H so the physical size stays the same
$i('size-unit').addEventListener('change', e => {
const nu = e.target.value, f = (UNIT_PX[sizeUnit] || 1) / (UNIT_PX[nu] || 1);
$i('custom-w').value = uRound((parseFloat($i('custom-w').value) || 0) * f, nu);
$i('custom-h').value = uRound((parseFloat($i('custom-h').value) || 0) * f, nu);
sizeUnit = nu;
});
$i('btn-apply-size').onclick = () => {
const k = UNIT_PX[sizeUnit] || 1;
applyCanvasSize(Math.round((parseFloat($i('custom-w').value) || 0) * k), Math.round((parseFloat($i('custom-h').value) || 0) * k));
$i('size-modal-overlay').style.display = 'none';
};
$i('size-modal-overlay').addEventListener('click', e => { if (e.target === e.currentTarget) e.currentTarget.style.display = 'none'; });
// EXPORT (single path — the topbar Download button opens the modal)
$i('btn-copy-img').onclick = doCopy;
$i('btn-export-open').onclick = () => $i('modal-overlay').classList.add('open');
$i('btn-share').onclick = () => toast('Use Export to download your design', 'ok');
$i('modal-close').onclick = $i('modal-cancel').onclick = () => $i('modal-overlay').classList.remove('open');
$i('modal-overlay').addEventListener('click', e => { if (e.target === e.currentTarget) e.currentTarget.classList.remove('open'); });
qsa('#export-modal .fmt-card').forEach(card => card.addEventListener('click', function() {
qsa('#export-modal .fmt-card').forEach(c => c.classList.remove('sel')); this.classList.add('sel');
}));
$i('modal-quality').addEventListener('input', function() { $i('modal-q-val').textContent = Math.round(this.value * 100) + '%'; });
$i('modal-scale').addEventListener('input', function() { $i('modal-scale-val').textContent = this.value + '×'; exportScale = parseFloat(this.value); });
$i('btn-modal-export').onclick = () => {
const sel = qs('#export-modal .fmt-card.sel');
const f = sel ? sel.dataset.fmt : 'webp';
if (f === 'svg') {
const blob = new Blob([canvas.toSVG()], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = ($i('modal-fname').value || 'design') + '.svg'; a.click();
URL.revokeObjectURL(url); toast('SVG downloaded!', 'ok');
} else {
doExport({ fmt: f, quality: parseFloat($i('modal-quality').value), scale: exportScale, filename: $i('modal-fname').value });
}
$i('modal-overlay').classList.remove('open');
};
// ZOOM
$i('z-in').onclick = () => applyZoom(zoom * 1.2, true);
$i('z-out').onclick = () => applyZoom(zoom / 1.2, true);
$i('z-fit').onclick = fitZoom;
$i('zoom-pct').addEventListener('click', () => applyZoom(1));
$i('canvas-wrap').addEventListener('wheel', e => {
if (e.ctrlKey || e.metaKey) { // Ctrl/⌘ + wheel = zoom the 2D artboard
e.preventDefault(); applyZoom(zoom * (e.deltaY < 0 ? 1.12 : 1 / 1.12), true);
return;
}
// Plain wheel with a 3D object selected = zoom the 3D camera (no conflict with artboard zoom)
if (window.LTM_zoom3D && window.LTM_zoom3D(e.deltaY)) e.preventDefault();
}, { passive: false });
// CONTEXT MENU
document.addEventListener('contextmenu', e => { if ($i('canvas-container').contains(e.target)) e.preventDefault(); });
document.addEventListener('click', hideCtxMenu);
$i('ctx-bring-f').onclick = () => { const o = canvas.getActiveObject(); if (o) { canvas.bringForward(o); updateLayers(); saveHist(); } hideCtxMenu(); };
$i('ctx-send-b').onclick = () => { const o = canvas.getActiveObject(); if (o) { canvas.sendBackwards(o); updateLayers(); saveHist(); } hideCtxMenu(); };
$i('ctx-bring-front').onclick = () => { const o = canvas.getActiveObject(); if (o) { canvas.bringToFront(o); updateLayers(); saveHist(); } hideCtxMenu(); };
$i('ctx-send-back').onclick = () => { const o = canvas.getActiveObject(); if (o) { canvas.sendToBack(o); updateLayers(); saveHist(); } hideCtxMenu(); };
$i('ctx-dup').onclick = () => {
const o = canvas.getActiveObject(); if (!o) { hideCtxMenu(); return; }
o.clone(c => { c.set({ left: o.left + 20, top: o.top + 20 }); c._name = (o._name||'obj') + ' copy'; canvas.add(c); canvas.setActiveObject(c); canvas.renderAll(); saveHist(); });
hideCtxMenu();
};
$i('ctx-flip-h').onclick = () => { doFlipH(); hideCtxMenu(); };
$i('ctx-flip-v').onclick = () => { doFlipV(); hideCtxMenu(); };
$i('ctx-copy').onclick = () => { copyActive(); hideCtxMenu(); };
$i('ctx-cut').onclick = () => { cutActive(); hideCtxMenu(); };
$i('ctx-paste').onclick = () => { pasteCopied(); hideCtxMenu(); };
$i('ctx-group').onclick = () => { groupSelection(); hideCtxMenu(); };
$i('ctx-ungroup').onclick = () => { ungroupSelection(); hideCtxMenu(); };
$i('ctx-del').onclick = () => { deleteActive(); hideCtxMenu(); };
// KEYBOARD SHORTCUTS
document.addEventListener('keydown', e => {
const editing = ['INPUT','TEXTAREA'].includes(document.activeElement.tagName);
// Pen tool capture
if (penMode && !editing) {
if (e.key === 'Enter') { e.preventDefault(); endPen(true); return; }
if (e.key === 'Escape') { e.preventDefault(); endPen(false); return; }
}
if (drawMode && !editing && e.key === 'Escape') { e.preventDefault(); stopDraw(); activeTool = null; refreshToolBtns(); return; }
// Drawing toolset: Enter finishes Line/Pen, Esc exits the active tool.
if (!editing && (lineOn || penBezOn) && e.key === 'Enter') { e.preventDefault(); lineOn ? finishLine() : finishBez(false); return; }
if (!editing && nodeEditOn && (e.key === 'Enter' || e.key === 'Escape')) { e.preventDefault(); exitNodeEdit(true); return; }
if (!editing && activeTool && e.key === 'Escape') { e.preventDefault(); activeTool = null; deactivateAllTools(); refreshToolBtns(); return; }
if (e.ctrlKey || e.metaKey) {
if (e.key.toLowerCase() === 'z') { e.preventDefault(); undo(); return; }
if (e.key.toLowerCase() === 'y') { e.preventDefault(); redo(); return; }
if (e.key.toLowerCase() === 's') { e.preventDefault(); saveProjectFile(); return; }
if (!editing) {
if (e.key.toLowerCase() === 'a') { e.preventDefault(); selectAll(); return; }
if (e.key.toLowerCase() === 'g') { e.preventDefault(); e.shiftKey ? ungroupSelection() : groupSelection(); return; }
if (e.key.toLowerCase() === 'd') { e.preventDefault(); const o = canvas.getActiveObject(); if (o) o.clone(c => _placeClone(c, 20, 20), ['_name','_type']); else if (B.selected) duplicate3D(); }
if (e.key.toLowerCase() === 'c') { const o = canvas.getActiveObject(); if (o) { copyActive(); B.clip = null; } else if (B.selected) { B.clip = B.selected; copiedObject = null; } }
if (e.key.toLowerCase() === 'x') { e.preventDefault(); cutActive(); }
if (e.key.toLowerCase() === 'v') { if (B.clip) { e.preventDefault(); duplicate3D(B.clip); } else { pasteCopied(); } }
}
}
if (!editing) {
if ((e.key === 'Delete' || e.key === 'Backspace') && !e.ctrlKey) {
const o = canvas.getActiveObject();
if (o && !o.isEditing) deleteActive();
else if (B.selected) deleteSelected(); // delete the selected 3D object
}
if (e.key === 'Escape' && cropActive) cancelCrop();
const nudge = e.shiftKey ? 10 : 1;
const obj = canvas.getActiveObject();
if (obj && ['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key)) {
e.preventDefault();
if (e.key === 'ArrowLeft') obj.set('left', obj.left - nudge);
if (e.key === 'ArrowRight') obj.set('left', obj.left + nudge);
if (e.key === 'ArrowUp') obj.set('top', obj.top - nudge);
if (e.key === 'ArrowDown') obj.set('top', obj.top + nudge);
canvas.renderAll(); saveHist();
}
}
});
// UNDO/REDO BUTTONS
$i('btn-undo').onclick = undo;
$i('btn-redo').onclick = redo;
// ARRANGE / ALIGN / GROUP MENU
const arrMenu = $i('arrange-menu');
$i('btn-arrange').onclick = e => {
e.stopPropagation();
const open = arrMenu.classList.toggle('open');
if (open) { const r = e.currentTarget.getBoundingClientRect(); arrMenu.style.left = r.left + 'px'; arrMenu.style.top = (r.bottom + 6) + 'px'; }
};
document.addEventListener('click', e => { if (!arrMenu.contains(e.target) && e.target.id !== 'btn-arrange') arrMenu.classList.remove('open'); });
qsa('#arrange-menu [data-align]').forEach(b => b.onclick = () => alignObjects(b.dataset.align));
qsa('#arrange-menu [data-arr]').forEach(b => b.onclick = () => { b.dataset.arr === 'group' ? groupSelection() : ungroupSelection(); arrMenu.classList.remove('open'); });
qsa('#arrange-menu [data-pf]').forEach(b => b.onclick = () => { pathfinder(b.dataset.pf); arrMenu.classList.remove('open'); });
// SAVE / OPEN / NEW PROJECT
$i('btn-save-proj').onclick = saveProjectFile;
$i('btn-new-proj').onclick = newDesign;
$i('btn-open-proj').onclick = () => $i('proj-file-input').click();
$i('proj-file-input').addEventListener('change', e => { if (e.target.files[0]) openProjectFile(e.target.files[0]); e.target.value = ''; });
// VECTOR TRACE
$i('tr-use-sel').onclick = () => { const o = canvas.getActiveObject(); const el = getSelectedImageEl(); if (!el) { toast('Select an image on the canvas first', 'warn'); return; } traceSourceEl = el; traceSourceObj = o; toast('Source set — pick a color count', 'ok'); };
$i('tr-upload').onclick = () => $i('tr-file').click();
$i('tr-file').addEventListener('change', e => { if (e.target.files[0]) setTraceSourceFromFile(e.target.files[0]); e.target.value = ''; });
$i('tr-paste').onclick = async () => {
try { const items = await navigator.clipboard.read(); for (const it of items) for (const t of it.types) if (t.startsWith('image/')) { setTraceSourceFromFile(await it.getType(t)); return; } toast('No image in clipboard', 'err'); }
catch { toast('Clipboard blocked — use Upload instead', 'err'); }
};
qsa('#tr-modes .tr-mode').forEach(b => b.onclick = () => { qsa('#tr-modes .tr-mode').forEach(x => x.classList.remove('sel')); b.classList.add('sel'); runTrace(b.dataset.mode); });
qsa('#tr-edge .tr-edgebtn').forEach(b => b.onclick = () => { qsa('#tr-edge .tr-edgebtn').forEach(x => x.classList.remove('sel')); b.classList.add('sel'); });
// GROUP / UNGROUP top-bar buttons
$i('btn-group').onclick = groupSelection;
$i('btn-ungroup').onclick = ungroupSelection;
// RULERS
$i('ruler-unit').addEventListener('change', e => { rulerUnit = e.target.value; drawRulers(); });
$i('dim-unit').addEventListener('change', e => { dimUnit = e.target.value; const o = canvas.getActiveObject(); if (o) updateRightPanel(o); });
window.addEventListener('resize', () => drawRulers());
requestAnimationFrame(drawRulers);
// LIBRARY
$i('lib-save').onclick = addToLibrary;
$i('lib-export').onclick = exportLibrary;
$i('lib-import-btn').onclick = () => $i('lib-file').click();
$i('lib-file').addEventListener('change', e => { if (e.target.files[0]) importLibrary(e.target.files[0]); e.target.value = ''; });
renderLibrary();
renderRecentColors();
// (removed: old AI chat stub — real AI Designer is now in the left "AI" tab)
// VISITOR COUNTER (counterapi.dev — free, no auth; display = base + live count)
(function visitorCounter() {
const BASE = 0, NS = 'designforge-ltm-mlmihjaz', KEY = 'visits'; // server counter seeded at 1990
const el = $i('sb-visits');
const show = n => { el.textContent = '👁 ' + (BASE + n).toLocaleString() + ' visits'; };
const cached = parseInt(localStorage.getItem('df_visits_cache') || '', 10);
if (!isNaN(cached)) show(cached); // instant paint from last known value
const countedThisSession = sessionStorage.getItem('df_visit_counted');
// /up increments (fresh response); read path needs a cache-buster — that endpoint is CDN-cached
const url = countedThisSession
? `https://api.counterapi.dev/v1/${NS}/${KEY}/?cb=${Date.now()}`
: `https://api.counterapi.dev/v1/${NS}/${KEY}/up`;
fetch(url)
.then(r => r.json())
.then(d => {
if (d && typeof d.count === 'number') {
localStorage.setItem('df_visits_cache', String(d.count));
sessionStorage.setItem('df_visit_counted', '1');
show(d.count);
}
})
.catch(() => { if (isNaN(cached)) show(0); });
})();
// ============================================================
// 3D LAYER — Babylon.js woven into the 2D artboard.
// Vectors extrude/revolve into real meshes on a transparent overlay
// that sits over the Fabric canvas. 2D stays the primary surface.
// ============================================================
let B = { engine:null, scene:null, cam:null, hemi:null, dir:null, selected:null, drag:null,
uid:0, ready:false, mode:'none', autorot:false, ortho:false, meshes:[] };
let d3mode = 'extrude'; // 'extrude' | 'revolve' ; B.mode: 'none' (2D + click-to-pick 3D) | 'orbit'
function ensure3D() {
if (B.ready) return true;
if (typeof BABYLON === 'undefined') { toast('3D engine still loading — try again in a second', 'warn'); return false; }
const cv = $i('babylon-layer');
B.engine = new BABYLON.Engine(cv, true, { preserveDrawingBuffer:true, stencil:true, alpha:true, antialias:true });
B.scene = new BABYLON.Scene(B.engine);
B.scene.clearColor = new BABYLON.Color4(0,0,0,0); // transparent → 2D shows through
B.cam = new BABYLON.ArcRotateCamera('d3cam', -Math.PI/2, 0.62, 700, BABYLON.Vector3.Zero(), B.scene);
B.cam.wheelPrecision = 0.4; B.cam.lowerRadiusLimit = 40; B.cam.panningSensibility = 30; B.cam.minZ = 1;
// Frame the whole artboard once. Objects are placed at their 2D positions (world spans
// roughly ±W/2 × ±H/2), so this keeps every object on-screen without ever moving the
// camera when new ones are added.
B.cam.setTarget(BABYLON.Vector3.Zero());
B.cam.radius = Math.max(canvas.getWidth(), canvas.getHeight()) * 1.45;
B.ortho = true; // default the artboard to orthographic
B.cam.mode = BABYLON.Camera.ORTHOGRAPHIC_CAMERA;
B.hemi = new BABYLON.HemisphericLight('d3h', new BABYLON.Vector3(0.3,1,0.2), B.scene); // ambient
B.dir = new BABYLON.DirectionalLight('d3d', new BABYLON.Vector3(-0.6,-1.4,-0.8), B.scene); // key
B.dir2 = new BABYLON.DirectionalLight('d3d2', new BABYLON.Vector3(0.7,-0.5,0.6), B.scene); // fill
updateLights();
sync3DSize();
B.engine.runRenderLoop(() => {
if (!B.scene) return;
if (B.autorot && B.meshes.length) B.cam.alpha += 0.004;
if (B.ortho) updateOrtho(); // keep ortho framing in sync with zoom/aspect
if (B.meshes.length) B.scene.render();
});
window.addEventListener('resize', sync3DSize);
B.engine.setHardwareScalingLevel(1); // render res == canvas px, so Fabric & 3D picks line up
// Auto-switching: the 3D overlay stays pointer-transparent, so Fabric is the single input
// layer (2D always works). We hook Fabric's pointer stream to pick / drag 3D objects:
// • click a 2D object → Fabric selects it (2D activates), 3D deselects
// • click empty space over a 3D object → that 3D object activates and can be dragged
// • click truly empty → everything deselects
canvas.on('mouse:down', opt => {
// hands off while orbiting, using a drawing tool, or in any 2D edit mode (node-edit, pen, line)
if (B.mode === 'orbit' || activeTool || nodeEditOn || penBezOn || penMode || lineOn || drawMode) return;
if (opt.e && opt.e.button) return; // left button only
if (opt.target) { if (B.selected) deselectMesh(); return; } // a 2D object → 2D activates
if (!B.meshes.length) return;
const p = canvas.getPointer(opt.e);
const m = pick3D(p); // precise ray↔triangle test
if (m) {
selectMesh(m); canvas.discardActiveObject();
const g = groundPoint(p);
B.drag = { mesh: m, dx: g ? g.x - m.position.x : 0, dz: g ? g.z - m.position.z : 0, moved: false };
} else if (B.selected) { deselectMesh(); } // empty space → deselect 3D only
});
canvas.on('mouse:move', opt => {
if (!B.drag) return;
if (!B.drag.moved) { B.drag.moved = true; canvas.selection = false; } // disable rubber-band only once we truly drag
canvas._groupSelector = null;
const g = groundPoint(canvas.getPointer(opt.e));
if (g) { B.drag.mesh.position.x = g.x - B.drag.dx; B.drag.mesh.position.z = g.z - B.drag.dz; }
});
// End any 3D drag and ALWAYS restore 2D selection (safety net so shapes never get stuck unselectable).
const end3dDrag = () => { if (B.drag) { B.drag = null; if (!activeTool) canvas.selection = true; } };
canvas.on('mouse:up', end3dDrag);
window.addEventListener('pointerup', end3dDrag);
// Right-click + hold + drag = rotate the selected 3D object.
// Left/right → spin (Y), up/down → tilt (X). A plain right-click still opens the 2D menu.
const cont = $i('canvas-container');
let rrot = null, rrotMoved = false;
cont.addEventListener('pointerdown', e => {
if (e.button !== 2 || !B.selected || B.mode === 'orbit' || activeTool) return;
rrot = { x: e.clientX, y: e.clientY, rx: B.selected.rotation.x, rz: B.selected.rotation.z };
rrotMoved = false;
try { cont.setPointerCapture(e.pointerId); } catch (_) {}
e.preventDefault();
});
cont.addEventListener('pointermove', e => {
if (!rrot || !B.selected) return;
const dx = e.clientX - rrot.x, dy = e.clientY - rrot.y;
if (Math.abs(dx) + Math.abs(dy) > 2) rrotMoved = true;
// Mouse controls X (tilt) and Z (roll). Y (spin) is left to the slider.
// When the object is spun past ~90° (facing away), its local tilt/roll axes flip
// relative to the screen — counter that so the drag direction stays consistent.
const face = Math.cos(B.selected.rotation.y) < 0 ? -1 : 1;
B.selected.rotation.x = rrot.rx - dy * 0.01 * face; // drag up → tilt up, down → tilt down
B.selected.rotation.z = rrot.rz - dx * 0.01 * face; // drag left/right → roll Z
setSlider('d3-rx', deg(B.selected.rotation.x), 'd3-rx-v', '°');
setSlider('d3-rz', deg(B.selected.rotation.z), 'd3-rz-v', '°');
e.preventDefault();
});
const endRrot = e => { if (rrot) { rrot = null; try { cont.releasePointerCapture(e.pointerId); } catch (_) {} } };
cont.addEventListener('pointerup', endRrot);
cont.addEventListener('pointercancel', endRrot);
// swallow the context menu only when we actually right-dragged to rotate
cont.addEventListener('contextmenu', e => { if (rrotMoved) { e.preventDefault(); e.stopPropagation(); rrotMoved = false; } }, true);
B.ready = true; return true;
}
// Pick the topmost 3D mesh under a Fabric-space point, or null.
function pick3D(p) {
if (!B.scene || !B.meshes.length) return null;
const ray = B.scene.createPickingRay(p.x, p.y, BABYLON.Matrix.Identity(), B.cam);
const hit = B.scene.pickWithRay(ray, m => B.meshes.indexOf(m) >= 0);
return (hit && hit.pickedMesh) ? hit.pickedMesh : null;
}
// Orthographic framing derived from the camera distance + canvas aspect, so zooming
// (radius change) still scales the view and the box matches the perspective framing.
function updateOrtho() {
if (!B.cam || !B.engine) return;
const aspect = B.engine.getRenderWidth() / B.engine.getRenderHeight();
const h = B.cam.radius * 0.5;
B.cam.orthoTop = h; B.cam.orthoBottom = -h;
B.cam.orthoLeft = -h * aspect; B.cam.orthoRight = h * aspect;
}
function setProjection(ortho) {
if (!ensure3D()) return;
B.ortho = ortho;
B.cam.mode = ortho ? BABYLON.Camera.ORTHOGRAPHIC_CAMERA : BABYLON.Camera.PERSPECTIVE_CAMERA;
if (ortho) updateOrtho();
}
// Light direction from azimuth (compass) + height (elevation) angles. Returns the
// direction the light travels (downward into the scene).
function dirVec(aziDeg, eleDeg) {
const a = aziDeg * Math.PI / 180, e = eleDeg * Math.PI / 180;
return new BABYLON.Vector3(Math.cos(e) * Math.cos(a), -Math.sin(e), Math.cos(e) * Math.sin(a)).normalize();
}
// Apply all lighting controls (key + fill directional lights + hemispheric ambient).
function updateLights() {
if (!B.scene || !B.dir) return;
const azi = +$i('d3-lazi').value, ele = +$i('d3-lele').value;
const keyCol = BABYLON.Color3.FromHexString($i('d3-lcolor').value);
B.dir.direction = dirVec(azi, ele);
B.dir.intensity = +$i('d3-lpow').value; B.dir.diffuse = keyCol;
B.dir2.setEnabled($i('d3-fill').checked);
B.dir2.direction = dirVec(azi + 165, Math.max(12, ele * 0.5));
B.dir2.intensity = +$i('d3-fpow').value; B.dir2.diffuse = keyCol;
const ambCol = BABYLON.Color3.FromHexString($i('d3-acolor').value);
B.hemi.intensity = +$i('d3-amb').value; B.hemi.diffuse = ambCol; B.hemi.groundColor = ambCol.scale(0.4);
}
// Where a Fabric-space point lands on the ground plane (y=0) in world space.
function groundPoint(p) {
const ray = B.scene.createPickingRay(p.x, p.y, BABYLON.Matrix.Identity(), B.cam);
const dy = ray.direction.y; if (Math.abs(dy) < 1e-6) return null;
return ray.origin.add(ray.direction.scale(-ray.origin.y / dy));
}
// Keep the 3D canvas resolution matched to the 2D canvas (it inherits the
// container's zoom/pan CSS transform automatically, so it stays aligned).
function sync3DSize() {
const cv = $i('babylon-layer'); if (!cv || !canvas) return;
const w = canvas.getWidth(), h = canvas.getHeight();
if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
if (B.engine) B.engine.resize();
}
// Selected vector → array of polygons ({x,y} in canvas px). First = outline, rest = holes.
// Text has no path data. Render the ACTUAL styled Fabric text (font, spacing, bold/italic
// all included) at high resolution, trace its glyphs to clean outlines (ImageTracer), then
// scale the result back so the 3D matches the 2D text's real size.
function textToPolys(obj) {
if (typeof ImageTracer === 'undefined') { toast('Tracer still loading — try again', 'warn'); return null; }
if (!ensurePaper()) return null;
const dim = Math.max(obj.getScaledWidth(), obj.getScaledHeight()) || 40;
const mult = Math.max(2, Math.min(14, 640 / dim)); // render large → crisp trace (cap so wide shapes don't blow up)
const hadPath = obj.path; if (hadPath) obj.set('path', null); // trace straight glyphs, not a bent baseline
let el;
try { el = obj.toCanvasElement({ multiplier: mult }); } catch (e) { if (hadPath) obj.set('path', hadPath); return null; }
if (hadPath) obj.set('path', hadPath);
// Composite onto a PADDED transparent canvas so the object never touches the edge. Otherwise a
// box-filling shape (rect/circle/icon) spans the whole image and gets dropped as the "frame".
const pad = Math.round(Math.max(el.width, el.height) * 0.12) + 6;
const cw = el.width + pad * 2, ch = el.height + pad * 2;
const pcv = document.createElement('canvas'); pcv.width = cw; pcv.height = ch;
const pctx = pcv.getContext('2d'); pctx.drawImage(el, pad, pad);
// Black shape on white, keyed off the ALPHA channel (fill/stroke colour irrelevant).
const sd = pctx.getImageData(0, 0, cw, ch);
const imgd = pctx.createImageData(cw, ch);
for (let i = 0; i < sd.data.length; i += 4) {
const v = sd.data[i + 3] > 100 ? 0 : 255; // opaque = shape → black, transparent bg → white
imgd.data[i] = imgd.data[i + 1] = imgd.data[i + 2] = v; imgd.data[i + 3] = 255;
}
let svgstr; try { svgstr = ImageTracer.imagedataToSVG(imgd, traceOptionsFor('1', 3, 'smooth')); } catch (e) { return null; }
// map traced coords back to the object's real canvas position (content is centred in the padded canvas)
const cen = obj.getCenterPoint();
const offX = cen.x - (pad + el.width / 2) / mult, offY = cen.y - (pad + el.height / 2) / mult;
const polys = [];
try {
paper.project.clear();
const item = paper.project.importSVG(svgstr, { expandShapes: true, insert: true });
(function gather(it) {
if (!it) return;
if (it.className === 'Path' || it.className === 'CompoundPath') {
it.flatten(0.8); // finer flatten → smooth curves
const subs = it.className === 'CompoundPath' ? it.children : [it];
subs.forEach(p => { const pts = p.segments.map(s => ({ x: s.point.x / mult + offX, y: s.point.y / mult + offY })); if (pts.length >= 3) polys.push(pts); });
return; // outline + holes both come through as sub-paths; don't recurse
}
if (it.children) it.children.slice().forEach(gather);
})(item);
paper.project.clear();
} catch (e) { console.error(e); return null; }
if (!polys.length) { toast('Could not read that shape', 'warn'); return null; }
// Drop ONLY the white background frame (spans ~the whole padded image). The real shape is inset
// because of the padding, so it always survives.
const imgW = cw / mult, imgH = ch / mult;
const keep = polys.filter(p => {
let minx = 1e9, miny = 1e9, maxx = -1e9, maxy = -1e9;
p.forEach(q => { if (q.x < minx) minx = q.x; if (q.x > maxx) maxx = q.x; if (q.y < miny) miny = q.y; if (q.y > maxy) maxy = q.y; });
return !((maxx - minx) > 0.92 * imgW && (maxy - miny) > 0.92 * imgH);
});
if (!keep.length) { toast('Could not read that shape', 'warn'); return null; }
keep.text = (obj.type === 'i-text' || obj.type === 'text');
keep.fillColor = (typeof obj.fill === 'string' && obj.fill !== 'transparent') ? obj.fill : '#7c3aed';
return keep;
}
function selectionToPolys() {
const obj = canvas.getActiveObject();
if (!obj) { toast('Select a shape, text or vector first', 'warn'); return null; }
if (obj.type === 'activeSelection') { toast('Group the shapes first (Ctrl+G), then make 3D', 'warn'); return null; }
if (obj.type === 'i-text' || obj.type === 'text') return textToPolys(obj);
// FILLED vector shapes (rect, circle, star, decorative library shapes, filled paths) → exact
// vector outline. Groups/icons and stroke-only shapes → rasterise+trace so strokes become real
// rings (e.g. a bicycle's wheels), not filled discs.
const VEC = ['rect','circle','ellipse','triangle','polygon','polyline','path'];
const filled = VEC.includes(obj.type) && obj.fill && obj.fill !== 'transparent';
if (!filled) return textToPolys(obj);
if (!ensurePaper()) return textToPolys(obj);
try {
paper.project.clear();
const cp = objToPaperPath(obj, obj.calcTransformMatrix());
if (!cp) { paper.project.clear(); return textToPolys(obj); }
cp.flatten(0.9); // curves → segments (fine = keeps decorative detail)
const paths = cp.className === 'CompoundPath' ? cp.children : [cp];
const polys = paths.map(p => p.segments.map(s => ({ x: s.point.x, y: s.point.y }))).filter(a => a.length >= 3);
paper.project.clear();
if (!polys.length) return textToPolys(obj);
polys.fillColor = obj.fill;
return polys; // buildGlyphMesh groups these into fills+holes by even/odd nesting
} catch (e) { console.error(e); return textToPolys(obj); }
}
function mat3d(hex) {
const m = new BABYLON.PBRMetallicRoughnessMaterial('d3m'+B.meshes.length, B.scene);
m.baseColor = BABYLON.Color3.FromHexString(hex);
m.metallic = parseFloat($i('d3-metal').value); m.roughness = parseFloat($i('d3-rough').value);
m.backFaceCulling = false; return m;
}
function pickColor(polys) {
if ($i('d3-auto-color').checked && polys.fillColor) {
try { return '#' + new fabric.Color(polys.fillColor).toHex().slice(0,6); } catch(e){}
}
return $i('d3-color').value;
}
// Centroid + canvas centre, so multiple meshes keep their 2D layout around the origin.
function centreOf(poly) { let x=0,y=0; poly.forEach(p=>{x+=p.x;y+=p.y;}); return { x:x/poly.length, y:y/poly.length }; }
// Outline / wireframe = "line style" on the 3D mesh.
function applyLineStyle(mesh, style) {
if (!mesh) return;
try { mesh.disableEdgesRendering(); } catch(e){}
if (mesh.material) mesh.material.wireframe = false;
if (style === 'wire') { if (mesh.material) mesh.material.wireframe = true; }
else if (style === 'thin' || style === 'bold') {
mesh.enableEdgesRendering();
mesh.edgesWidth = style === 'bold' ? 9 : 3.5;
mesh.edgesColor = new BABYLON.Color4(0.08, 0.08, 0.11, 1);
}
}
// Accurate extrude for ANY contour set (shapes AND text): groups contours into fills + holes
// by even/odd nesting depth, extrudes each, and merges. Handles disjoint parts, nested holes,
// decorative/organic shapes, multi-letter strings, and counters (o, a, e…) — keeps the original
// shape exactly. Named "glyph" historically; used for all extrudes now.
function buildGlyphMesh(src) {
const polys = src.polys, depth = parseFloat($i('d3-depth').value);
let cx = 0, cy = 0, n = 0; polys.forEach(p => p.forEach(pt => { cx += pt.x; cy += pt.y; n++; })); cx /= n; cy /= n;
const area = a => { let s = 0; for (let i = 0; i < a.length; i++) { const p = a[i], q = a[(i + 1) % a.length]; s += p.x * q.y - q.x * p.y; } return Math.abs(s) / 2; };
const ctr = a => { let x = 0, y = 0; a.forEach(p => { x += p.x; y += p.y; }); return { x: x / a.length, y: y / a.length }; };
const inside = (pt, poly) => { let c = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const a = poly[i], b = poly[j]; if (((a.y > pt.y) !== (b.y > pt.y)) && (pt.x < (b.x - a.x) * (pt.y - a.y) / (b.y - a.y) + a.x)) c = !c; } return c; };
let cs = polys.map(p => ({ pts: p, a: area(p), c: ctr(p) }));
// de-dupe near-coincident contours (a glyph's inner edge can be traced twice)
cs = cs.filter((m, i) => !cs.some((o, j) => j < i && Math.abs(o.a - m.a) < m.a * 0.06 && Math.hypot(o.c.x - m.c.x, o.c.y - m.c.y) < 3));
// even/odd nesting depth: even = filled outline, odd = counter (hole)
cs.forEach(m => { m.depth = 0; cs.forEach(o => { if (o !== m && o.a > m.a && inside(m.c, o.pts)) m.depth++; }); });
const fills = cs.filter(m => m.depth % 2 === 0);
const groups = fills.map(f => ({ shape: f.pts, holes: [] }));
cs.filter(m => m.depth % 2 === 1).forEach(hole => { // assign each hole to its smallest containing fill
let bi = -1, ba = Infinity;
fills.forEach((f, i) => { if (f.a > hole.a && inside(hole.c, f.pts) && f.a < ba) { bi = i; ba = f.a; } });
if (bi >= 0) groups[bi].holes.push(hole.pts);
});
const toV = pt => new BABYLON.Vector3(pt.x - cx, 0, cy - pt.y);
const parts = [];
groups.forEach(g => { try {
parts.push(BABYLON.MeshBuilder.ExtrudePolygon('d3g' + (B.uid++), { shape: g.shape.map(toV), holes: g.holes.map(h => h.map(toV)), depth, sideOrientation: BABYLON.Mesh.DOUBLESIDE }, B.scene, window.earcut));
} catch (e) { console.warn('glyph contour skipped', e); } });
if (!parts.length) return null;
const mesh = parts.length === 1 ? parts[0] : BABYLON.Mesh.MergeMeshes(parts, true, true);
if (!mesh) return null;
if (parseFloat($i('d3-bevel').value) < 3) mesh.convertToFlatShadedMesh(); // bevel slider → crisp vs soft edges
mesh.position.set(cx - canvas.getWidth() / 2, depth / 2, canvas.getHeight() / 2 - cy);
mesh.refreshBoundingInfo();
const bc = mesh.getBoundingInfo().boundingBox.center.clone();
if (bc.lengthSquared() > 1e-6) { mesh.bakeTransformIntoVertices(BABYLON.Matrix.Translation(-bc.x, -bc.y, -bc.z)); mesh.position.addInPlace(bc); mesh.refreshBoundingInfo(); }
mesh.bakeTransformIntoVertices(BABYLON.Matrix.RotationX(-B.cam.beta)); // rest pose faces the camera → Tilt X reads 0
mesh.material = mat3d(src.color);
applyLineStyle(mesh, $i('d3-line').value);
mesh.metadata = { src };
return mesh;
}
// Inflate / puff: triangulate the polygon, subdivide for density, then push each vertex out
// along ±Y by its distance to the nearest edge → a rounded pillow (edges flat, centre bulged).
function _subdivide(verts, indices) {
const nv = verts.slice(), mid = new Map(), ni = [];
const getMid = (a, b) => { const k = a < b ? a + '_' + b : b + '_' + a; if (mid.has(k)) return mid.get(k);
const m = { x: (verts[a].x + verts[b].x) / 2, y: (verts[a].y + verts[b].y) / 2 }; const i = nv.length; nv.push(m); mid.set(k, i); return i; };
for (let i = 0; i < indices.length; i += 3) {
const a = indices[i], b = indices[i + 1], c = indices[i + 2];
const ab = getMid(a, b), bc = getMid(b, c), ca = getMid(c, a);
ni.push(a, ab, ca, ab, b, bc, ca, bc, c, ab, bc, ca);
}
return { verts: nv, indices: ni };
}
function _distSeg(p, a, b) { const dx = b.x - a.x, dy = b.y - a.y, l2 = dx * dx + dy * dy;
let t = l2 ? ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2 : 0; t = Math.max(0, Math.min(1, t));
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)); }
// Group contours into fills + their holes by even/odd nesting (shared by extrude & inflate).
function _groupContours(polys) {
const area = a => { let s = 0; for (let i = 0; i < a.length; i++) { const p = a[i], q = a[(i + 1) % a.length]; s += p.x * q.y - q.x * p.y; } return Math.abs(s) / 2; };
const ctr = a => { let x = 0, y = 0; a.forEach(p => { x += p.x; y += p.y; }); return { x: x / a.length, y: y / a.length }; };
const inside = (pt, poly) => { let c = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const a = poly[i], b = poly[j]; if (((a.y > pt.y) !== (b.y > pt.y)) && (pt.x < (b.x - a.x) * (pt.y - a.y) / (b.y - a.y) + a.x)) c = !c; } return c; };
let cs = polys.map(p => ({ pts: p, a: area(p), c: ctr(p) }));
cs = cs.filter((m, i) => !cs.some((o, j) => j < i && Math.abs(o.a - m.a) < m.a * 0.06 && Math.hypot(o.c.x - m.c.x, o.c.y - m.c.y) < 3));
cs.forEach(m => { m.depth = 0; cs.forEach(o => { if (o !== m && o.a > m.a && inside(m.c, o.pts)) m.depth++; }); });
const fills = cs.filter(m => m.depth % 2 === 0);
const groups = fills.map(f => ({ shape: f.pts, holes: [] }));
cs.filter(m => m.depth % 2 === 1).forEach(hole => { let bi = -1, ba = Infinity;
fills.forEach((f, i) => { if (f.a > hole.a && inside(hole.c, f.pts) && f.a < ba) { bi = i; ba = f.a; } });
if (bi >= 0) groups[bi].holes.push(hole.pts); });
return groups;
}
// Inflate ONE fill (+ its holes) into a smooth two-sided pillow.
// Smooth pillow on a UNIFORM GRID (no earcut slivers → no scratchy shading). Each inside grid
// vertex is pushed out by a domed function of its distance to the nearest edge (outer or hole).
function _inflateOne(shape, holes, amp, cx, cy) {
let minx = 1e9, miny = 1e9, maxx = -1e9, maxy = -1e9;
shape.forEach(p => { if (p.x < minx) minx = p.x; if (p.x > maxx) maxx = p.x; if (p.y < miny) miny = p.y; if (p.y > maxy) maxy = p.y; });
const W = maxx - minx, H = maxy - miny; if (W < 1 || H < 1) return null;
const step = Math.max(1.5, Math.min(W, H) / 60); // ~60 cells across the smaller side
const cols = Math.ceil(W / step) + 1, rows = Math.ceil(H / step) + 1;
const inside = (pt, poly) => { let c = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const a = poly[i], b = poly[j]; if (((a.y > pt.y) !== (b.y > pt.y)) && (pt.x < (b.x - a.x) * (pt.y - a.y) / (b.y - a.y) + a.x)) c = !c; } return c; };
const inShape = pt => inside(pt, shape) && !holes.some(h => inside(pt, h));
const segs = []; [shape].concat(holes).forEach(r => { for (let i = 0; i < r.length; i++) segs.push([r[i], r[(i + 1) % r.length]]); });
const idx = new Int32Array(cols * rows).fill(-1);
const gv = [];
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
const x = minx + c * step, y = miny + r * step;
if (!inShape({ x, y })) continue;
let d = 1e9; for (const s of segs) d = Math.min(d, _distSeg({ x, y }, s[0], s[1]));
idx[r * cols + c] = gv.length; gv.push({ x, y, h: amp * Math.sin(Math.min(1, d / amp) * Math.PI / 2) });
}
if (gv.length < 3) return null;
const pos = [], ind = [], V = gv.length;
gv.forEach(v => pos.push(v.x - cx, v.h, cy - v.y)); // top dome
gv.forEach(v => pos.push(v.x - cx, -v.h, cy - v.y)); // bottom dome
for (let r = 0; r < rows - 1; r++) for (let c = 0; c < cols - 1; c++) {
const a = idx[r * cols + c], b = idx[r * cols + c + 1], cc = idx[(r + 1) * cols + c], dd = idx[(r + 1) * cols + c + 1];
if (a < 0 || b < 0 || cc < 0 || dd < 0) continue;
ind.push(a, cc, b, b, cc, dd); // top → +Y
ind.push(V + a, V + b, V + cc, V + b, V + dd, V + cc); // bottom → -Y
}
if (!ind.length) return null;
const vd = new BABYLON.VertexData(); vd.positions = pos; vd.indices = ind;
const norms = []; BABYLON.VertexData.ComputeNormals(pos, ind, norms); vd.normals = norms;
const mesh = new BABYLON.Mesh('d3in' + (B.uid++), B.scene); vd.applyToMesh(mesh, true);
return mesh;
}
function buildInflate(src) {
const polys = src.polys;
let cx = 0, cy = 0, n = 0; polys.forEach(p => p.forEach(pt => { cx += pt.x; cy += pt.y; n++; })); cx /= n; cy /= n;
const amp = Math.max(8, parseFloat($i('d3-depth').value) * 0.5);
const parts = [];
_groupContours(polys).forEach(g => { const m = _inflateOne(g.shape, g.holes, amp, cx, cy); if (m) parts.push(m); });
if (!parts.length) return null;
const mesh = parts.length === 1 ? parts[0] : BABYLON.Mesh.MergeMeshes(parts, true, true);
if (!mesh) return null;
mesh.refreshBoundingInfo();
const bc = mesh.getBoundingInfo().boundingBox.center.clone();
if (bc.lengthSquared() > 1e-6) { mesh.bakeTransformIntoVertices(BABYLON.Matrix.Translation(-bc.x, -bc.y, -bc.z)); mesh.position.addInPlace(bc); mesh.refreshBoundingInfo(); }
mesh.bakeTransformIntoVertices(BABYLON.Matrix.RotationX(-B.cam.beta));
mesh.material = mat3d(src.color);
applyLineStyle(mesh, $i('d3-line').value);
mesh.metadata = { src };
return mesh;
}
// Build (or rebuild) a mesh from its source polygons + the current panel params.
function buildMesh(src) {
const polys = src.polys;
if (src.kind === 'inflate') return buildInflate(src);
// Extrude (shapes AND text) goes through the accurate multi-contour even/odd builder, so
// decorative shapes with disjoint parts + nested holes keep their shape exactly.
if (src.kind !== 'revolve') return buildGlyphMesh(src);
// --- Revolve (lathe of the outline profile) ---
const c = centreOf(polys[0]);
const xs = polys[0].map(p=>p.x), ys = polys[0].map(p=>p.y);
const minX = Math.min(...xs), minY = Math.min(...ys), maxY = Math.max(...ys);
const profile = polys[0].map(p => new BABYLON.Vector3(p.x - minX, maxY - p.y, 0));
const arc = parseFloat($i('d3-arc').value) / 360, seg = parseInt($i('d3-seg').value, 10);
const mesh = BABYLON.MeshBuilder.CreateLathe('d3rv'+(B.uid++),
{ shape: profile, tessellation: seg, arc, sideOrientation: BABYLON.Mesh.DOUBLESIDE, closed: true }, B.scene);
mesh.position.set(c.x - canvas.getWidth()/2, -(maxY - minY)/2, canvas.getHeight()/2 - c.y);
mesh.refreshBoundingInfo();
const ctr = mesh.getBoundingInfo().boundingBox.center.clone();
if (ctr.lengthSquared() > 1e-6) { mesh.bakeTransformIntoVertices(BABYLON.Matrix.Translation(-ctr.x, -ctr.y, -ctr.z)); mesh.position.addInPlace(ctr); mesh.refreshBoundingInfo(); }
mesh.material = mat3d(src.color);
applyLineStyle(mesh, $i('d3-line').value);
mesh.metadata = { src };
return mesh;
}
// Sets both the range slider and its editable number input to the same value.
const setSlider = (id, val, vid) => { const s = $i(id); if (s) s.value = val; const v = $i(vid); if (v) v.value = val; };
const deg = r => Math.round(r * 180 / Math.PI);
// Rebuild the clickable list of 3D objects in the panel.
function refreshObjList() {
const el = $i('d3-objlist'); if (!el) return;
el.innerHTML = '';
B.meshes.forEach((m, i) => {
const row = document.createElement('button');
row.className = 'p-btn' + (m === B.selected ? ' acc' : '');
row.style.cssText = 'text-align:left;font-size:11px;padding:5px 9px';
const kind = (m.metadata && m.metadata.src && m.metadata.src.kind === 'revolve') ? 'Revolve' : 'Extrude';
row.textContent = (i + 1) + '. ' + kind;
row.addEventListener('click', () => selectMesh(m));
el.appendChild(row);
});
if ($i('d3-sel-info')) $i('d3-sel-info').textContent = B.selected
? 'Editing object ' + (B.meshes.indexOf(B.selected) + 1) + ' — sliders below affect only this one'
: (B.meshes.length ? 'Pick an object above, or click one in Edit-3D mode' : 'No 3D objects yet');
}
function selectMesh(m) {
B.selected = m; refreshObjList();
if (m.renderOutline !== undefined) { B.meshes.forEach(x => x.renderOutline = false);
m.renderOutline = true; m.outlineColor = BABYLON.Color3.FromHexString('#7c3aed'); m.outlineWidth = 4; }
// load this object's transform into the per-object sliders
setSlider('d3-ry', deg(m.rotation.y), 'd3-ry-v', '°');
setSlider('d3-rx', deg(m.rotation.x), 'd3-rx-v', '°');
setSlider('d3-rz', deg(m.rotation.z), 'd3-rz-v', '°');
setSlider('d3-scale', m.scaling.x.toFixed(2), 'd3-scale-v', '');
const src = m.metadata && m.metadata.src;
if (src && src.color && $i('d3-color')) { $i('d3-color').value = src.color; $i('d3-auto-color').checked = false; }
}
function deselectMesh() {
if (B.selected && B.selected.renderOutline !== undefined) B.selected.renderOutline = false;
B.selected = null; refreshObjList();
}
// Rebuild the selected mesh in place (used when geometry params change), keeping its
// current move/rotate/scale so the user doesn't lose their placement.
function rebuildSelected() {
if (!B.selected) return;
const old = B.selected, src = old.metadata.src, idx = B.meshes.indexOf(old);
const pos = old.position.clone(), rot = old.rotation.clone(), scl = old.scaling.clone();
const m = buildMesh(src);
m.position.copyFrom(pos); m.rotation.copyFrom(rot); m.scaling.copyFrom(scl);
old.dispose(); if (idx >= 0) B.meshes[idx] = m; B.selected = m; selectMesh(m);
}
function deleteSelected() {
if (!B.selected) { toast('Pick a 3D object first', 'warn'); return; }
const idx = B.meshes.indexOf(B.selected); if (idx >= 0) B.meshes.splice(idx, 1);
B.selected.dispose(); deselectMesh();
if (!B.meshes.length) { setMode('none'); $i('rp-3d-scene').style.display = 'none'; }
if (B.scene) B.scene.render();
toast('Deleted 3D object', 'ok');
}
// Clone a 3D object (geometry + material + transform), offset slightly. Used by copy/paste & duplicate.
function duplicate3D(mesh) {
mesh = mesh || B.selected; if (!mesh || !B.scene) return;
const copy = mesh.clone('d3copy' + (B.uid++)); if (!copy) return;
if (mesh.material) copy.material = mesh.material.clone('d3matC' + B.uid);
const s = mesh.metadata && mesh.metadata.src;
copy.metadata = s ? { src: { kind: s.kind, polys: s.polys, color: s.color } } : mesh.metadata;
copy.renderOutline = false;
copy.position.x += 30; copy.position.z += 30;
B.meshes.push(copy); selectMesh(copy);
toast('Duplicated 3D object', 'ok');
}
window.LTM_has3DSelection = () => !!B.selected; // let global shortcuts know a 3D object is active
function create3D() {
if (!ensure3D()) return;
const srcObj = canvas.getActiveObject(); // the 2D object being converted
if (!srcObj) { toast('Select a shape, text or vector first', 'warn'); return; }
// Text routes through the SAME pipeline as shapes: selectionToPolys (traces glyphs) → buildMesh,
// so depth / bevel / line-style / material / rotation all work identically.
const polys = selectionToPolys(); if (!polys) return;
const src = { kind: polys.text ? 'extrude' : d3mode, polys, color: pickColor(polys) };
let mesh;
try { mesh = buildMesh(src); }
catch (e) { console.error(e); toast('Could not build that 3D shape', 'err'); return; }
if (!mesh) return;
B.meshes.push(mesh); // rest pose already faces the camera (tilt baked into geometry); Tilt X = 0
// Convert = replace: remove the original 2D object now that it's a 3D object.
canvas.discardActiveObject(); canvas.remove(srcObj); canvas.requestRenderAll();
$i('rp-3d-scene').style.display = '';
selectMesh(mesh); // camera is fixed on the artboard; new objects never move it
setStatus('3D created');
toast(polys.text ? 'Text is now 3D ✦' : (d3mode === 'revolve' ? 'Revolved into 3D ✦' : (d3mode === 'inflate' ? 'Inflated into 3D ✦' : 'Extruded into 3D ✦')), 'ok');
}
// Interaction mode.
// 'none' = default. Overlay ignores the pointer so 2D tools always work; clicking a 3D
// object (via Fabric's pointer hook) selects & drags it — auto-switching.
// 'orbit' = overlay captures the pointer so you can drag to look around the 3D scene.
function setMode(mode) {
if (!ensure3D()) return;
B.mode = mode;
const cv = $i('babylon-layer'), cc = $i('canvas-container');
const active = mode === 'orbit';
cv.classList.toggle('orbit', active); cc.classList.toggle('orbiting', active);
if (active) B.cam.attachControl(cv, true); else B.cam.detachControl();
if ($i('d3-orbit')) $i('d3-orbit').checked = active;
}
function frameAll() {
if (!B.meshes.length) return;
let min = new BABYLON.Vector3(1e9,1e9,1e9), max = new BABYLON.Vector3(-1e9,-1e9,-1e9);
B.meshes.forEach(m => { m.computeWorldMatrix(true); const b = m.getBoundingInfo().boundingBox;
min = BABYLON.Vector3.Minimize(min, b.minimumWorld); max = BABYLON.Vector3.Maximize(max, b.maximumWorld); });
const c = min.add(max).scale(0.5), r = max.subtract(min).length()/2 || 100;
B.cam.setTarget(c); B.cam.radius = r * 2.4 + 60;
}
// Composite the 2D canvas + 3D overlay into one artboard-area image.
function compositeDataURL(format, quality, mult) {
format = format || 'png'; quality = quality == null ? 1 : quality; mult = mult || 1;
const w = Math.round(canvas.getWidth() * mult), h = Math.round(canvas.getHeight() * mult);
const out = document.createElement('canvas'); out.width = w; out.height = h;
const ctx = out.getContext('2d');
if (format === 'jpeg') { ctx.fillStyle = '#ffffff'; ctx.fillRect(0,0,w,h); }
const img2d = new Image();
return new Promise(res => {
img2d.onload = () => {
ctx.drawImage(img2d, 0, 0, w, h);
if (B.scene && B.meshes.length) { B.scene.render(); ctx.drawImage($i('babylon-layer'), 0, 0, w, h); }
res(out.toDataURL('image/' + format, quality));
};
img2d.src = canvas.toDataURL({ format:'png', multiplier: mult });
});
}
function has3D() { return !!(B.scene && B.meshes.length); }
window.LTM_compositeArtboard = compositeDataURL; // used by the export/save flow
window.LTM_has3D = has3D;
// Plain mouse wheel zooms the 3D camera when a 3D object is selected (unlimited).
// Returns true if it handled the event so the 2D layer leaves the wheel alone.
window.LTM_zoom3D = deltaY => {
if (!B.ready || !B.selected) return false;
B.cam.radius = Math.max(3, B.cam.radius * (deltaY < 0 ? 1 / 1.12 : 1.12));
if (B.ortho) updateOrtho();
return true;
};
async function saveArtboardPNG() {
const url = await compositeDataURL();
const a = document.createElement('a'); a.href = url; a.download = 'artboard.png'; a.click();
toast('Saved artboard PNG (2D + 3D)', 'ok');
}
async function bakeTo2D() {
if (!B.meshes.length) { toast('Nothing in 3D to bake', 'warn'); return; }
B.scene.render();
fabric.Image.fromURL($i('babylon-layer').toDataURL('image/png'), img => {
img.set({ left: canvas.getWidth()/2, top: canvas.getHeight()/2, originX:'center', originY:'center' });
img._name = '3D bake'; canvas.add(img); canvas.setActiveObject(img); canvas.requestRenderAll();
toast('Baked 3D onto the 2D canvas', 'ok');
});
}
function clear3D() {
deselectMesh();
B.meshes.forEach(m => m.dispose()); B.meshes = [];
setMode('none');
$i('rp-3d-scene').style.display = 'none';
if (B.scene) B.scene.render();
toast('Cleared 3D scene', 'ok');
}
function wire3D() {
// slider → (label update, then an action on the selected mesh)
const link = (id, vid, fmt, act) => { const s=$i(id); if(!s) return; const v=$i(vid);
s.addEventListener('input', () => { if (v) v.textContent = fmt ? fmt(s.value) : s.value; if (act) act(s.value); }); };
const geomRebuild = kind => () => { if (B.selected && B.selected.metadata.src.kind === kind) rebuildSelected(); };
link('d3-depth','d3-depth-v', null, geomRebuild('extrude'));
link('d3-bevel','d3-bevel-v', null, geomRebuild('extrude'));
link('d3-arc','d3-arc-v', null, geomRebuild('revolve'));
link('d3-seg','d3-seg-v', null, geomRebuild('revolve'));
link('d3-metal','d3-metal-v', v=>(+v).toFixed(2), v => { if (B.selected && B.selected.material) B.selected.material.metallic = +v; });
link('d3-rough','d3-rough-v', v=>(+v).toFixed(2), v => { if (B.selected && B.selected.material) B.selected.material.roughness = +v; });
// Lighting (key + fill directional + ambient) — all live
link('d3-lpow','d3-lpow-v', v=>(+v).toFixed(2), updateLights);
link('d3-lazi','d3-lazi-v', v=>v+'°', updateLights);
link('d3-lele','d3-lele-v', v=>v+'°', updateLights);
link('d3-fpow','d3-fpow-v', v=>(+v).toFixed(2), updateLights);
link('d3-amb','d3-amb-v', v=>(+v).toFixed(2), updateLights);
$i('d3-lcolor').addEventListener('input', updateLights);
$i('d3-acolor').addEventListener('input', updateLights);
$i('d3-fill').addEventListener('change', updateLights);
// colour picker → recolour the selected mesh live
$i('d3-color').addEventListener('input', e => {
if (B.selected) { B.selected.metadata.src.color = e.target.value;
if (B.selected.material) B.selected.material.baseColor = BABYLON.Color3.FromHexString(e.target.value); }
});
// line style → outline / wireframe on the selected mesh
$i('d3-line').addEventListener('change', e => { if (B.selected) applyLineStyle(B.selected, e.target.value); });
// extrude / revolve mode
document.querySelectorAll('#d3-mode .ef-chip').forEach(ch => ch.addEventListener('click', () => {
document.querySelectorAll('#d3-mode .ef-chip').forEach(c=>c.classList.remove('active'));
ch.classList.add('active'); d3mode = ch.dataset.mode;
const rev = d3mode === 'revolve', inf = d3mode === 'inflate';
$i('d3-row-depth').style.display = rev ? 'none' : ''; // depth = thickness (extrude) / puff amount (inflate)
$i('d3-row-bevel').style.display = (rev || inf) ? 'none' : '';
$i('d3-row-arc').style.display = rev ? '' : 'none';
$i('d3-row-seg').style.display = rev ? '' : 'none';
$i('d3-create').textContent = inf ? '✦ Inflate selection into 3D' : (rev ? '✦ Revolve selection into 3D' : '✦ Extrude selection into 3D');
}));
// per-object transform — affects ONLY the selected mesh
// Slider ⇄ number input, both drive the selected mesh. Typing in the number box
// applies the exact value (even beyond the slider range); the slider thumb just pins.
const linkNum = (rangeId, axis, isScale) => {
const r = $i(rangeId), n = $i(rangeId + '-v');
const apply = v => { if (!B.selected) return;
if (isScale) { const s = Math.max(0.01, +v || 0); B.selected.scaling.set(s, s, s); }
else B.selected.rotation[axis] = (+v || 0) * Math.PI / 180; };
r.addEventListener('input', () => { n.value = r.value; apply(r.value); });
const fromNum = () => { const v = parseFloat(n.value); if (isNaN(v)) return; r.value = v; apply(v); };
n.addEventListener('input', fromNum); n.addEventListener('change', fromNum);
};
linkNum('d3-ry', 'y'); linkNum('d3-rx', 'x'); linkNum('d3-rz', 'z'); linkNum('d3-scale', null, true);
// camera projection: perspective ⇄ orthographic
document.querySelectorAll('#d3-proj .ef-chip').forEach(ch => ch.addEventListener('click', () => {
document.querySelectorAll('#d3-proj .ef-chip').forEach(c=>c.classList.remove('active'));
ch.classList.add('active'); setProjection(ch.dataset.proj === 'ortho');
}));
$i('d3-create').addEventListener('click', create3D);
$i('d3-orbit').addEventListener('change', e => setMode(e.target.checked ? 'orbit' : 'none'));
$i('d3-autorot').addEventListener('change', e => { B.autorot = e.target.checked; });
$i('d3-bake').addEventListener('click', bakeTo2D);
$i('d3-save').addEventListener('click', saveArtboardPNG);
$i('d3-frame').addEventListener('click', frameAll);
$i('d3-del').addEventListener('click', deleteSelected);
$i('d3-clear').addEventListener('click', clear3D);
}
wire3D();
// INIT — one more fit after layout settles (same value, so no visible jump)
requestAnimationFrame(fitZoom);
setStatus('Ready');
});
// ============================================================
// 3D STUDIO PRO — full Babylon.js modelling workspace
// Primitives · gizmos · PBR materials · lights · booleans · I/O
// ============================================================
window.M3D = (function () {
const V3 = (x, y, z) => new BABYLON.Vector3(x, y, z);
const ACC = () => BABYLON.Color3.FromHexString('#7c3aed');
const $ = id => document.getElementById(id);
const PALETTE = ['#7c3aed','#ef4444','#10b981','#3b82f6','#f59e0b','#ec4899','#14b8a6','#8b5cf6','#f97316','#22c55e'];
let eng, scene, cam, gm, hl, sun;
let booted = false, started = false, uid = 1, palIdx = 0;
let tool = 'select', snap = false, worldSpace = true;
let grid = null, ground = null, sky = null, shadowGen = null;
const objs = []; // { id, mesh, type, name, light? }
let sel = []; // selected meshes (multi-select)
function status(t) { const s = $('m3d-status'); if (s) s.textContent = t; }
function bg(hex) { if (scene) scene.clearColor = BABYLON.Color4.FromHexString((hex || '#1e1e26') + 'ff'); const st = $('m3d-stage'); if (st) st.style.background = hex; }
// ---- engine boot (once) ----
function ensure() {
if (booted) return true;
if (typeof BABYLON === 'undefined') { status('3D engine still loading…'); return false; }
const cv = $('m3d-canvas');
eng = new BABYLON.Engine(cv, true, { preserveDrawingBuffer: true, stencil: true, antialias: true });
scene = new BABYLON.Scene(eng);
bg($('m3-bg-color') ? $('m3-bg-color').value : '#1e1e26');
cam = new BABYLON.ArcRotateCamera('m3cam', -Math.PI / 3, Math.PI / 3, 14, V3(0, 1, 0), scene);
cam.attachControl(cv, true);
cam.wheelDeltaPercentage = 0.02; cam.panningSensibility = 600;
cam.minZ = 0.05; cam.maxZ = 4000; cam.lowerRadiusLimit = 0.4; cam.upperRadiusLimit = 800;
const hemi = new BABYLON.HemisphericLight('m3hemi', V3(0.2, 1, 0.1), scene); hemi.intensity = 0.7; hemi.groundColor = new BABYLON.Color3(0.25, 0.25, 0.3);
sun = new BABYLON.DirectionalLight('m3sun', V3(-0.5, -1, -0.7), scene); sun.intensity = 1.15; sun.position = V3(12, 22, 16);
hl = new BABYLON.HighlightLayer('m3hl', scene); hl.innerGlow = false; hl.outerGlow = true;
gm = new BABYLON.GizmoManager(scene); gm.usePointerToAttachGizmos = false;
buildGrid();
scene.onPointerObservable.add(onPointer);
scene.onBeforeRenderObservable.add(() => {
objs.forEach(o => { if (o.light && o.light.position && o.mesh) o.light.position.copyFrom(o.mesh.position); });
applyEditDrag();
});
eng.runRenderLoop(() => { if (started) scene.render(); });
window.addEventListener('resize', () => { if (started) eng.resize(); });
booted = true;
return true;
}
function buildGrid() {
const N = 20, lines = [];
for (let i = -N; i <= N; i++) { lines.push([V3(-N, 0, i), V3(N, 0, i)]); lines.push([V3(i, 0, -N), V3(i, 0, N)]); }
grid = BABYLON.MeshBuilder.CreateLineSystem('m3grid', { lines }, scene);
grid.color = new BABYLON.Color3(0.32, 0.32, 0.4); grid.alpha = 0.55; grid.isPickable = false;
const ax = BABYLON.MeshBuilder.CreateLineSystem('m3axis', { lines: [[V3(-N, 0.01, 0), V3(N, 0.01, 0)], [V3(0, 0.01, -N), V3(0, 0.01, N)]], colors: [[new BABYLON.Color4(.9,.3,.3,1), new BABYLON.Color4(.9,.3,.3,1)], [new BABYLON.Color4(.35,.55,.95,1), new BABYLON.Color4(.35,.55,.95,1)]] }, scene);
ax.isPickable = false; ax.parent = grid;
}
// ---- material ----
function makeMat(hex) {
const m = new BABYLON.PBRMaterial('m3mat' + uid++, scene);
m.albedoColor = BABYLON.Color3.FromHexString(hex);
m.metallic = 0.1; m.roughness = 0.55; m.emissiveColor = new BABYLON.Color3(0, 0, 0);
return m;
}
// ---- primitives ----
const BUILD = {
box: () => BABYLON.MeshBuilder.CreateBox('Cube', { size: 2 }, scene),
sphere: () => BABYLON.MeshBuilder.CreateSphere('Sphere', { diameter: 2, segments: 24 }, scene),
cylinder: () => BABYLON.MeshBuilder.CreateCylinder('Cylinder', { height: 2.4, diameter: 2, tessellation: 32 }, scene),
cone: () => BABYLON.MeshBuilder.CreateCylinder('Cone', { height: 2.4, diameterTop: 0, diameterBottom: 2, tessellation: 32 }, scene),
torus: () => BABYLON.MeshBuilder.CreateTorus('Torus', { diameter: 2.4, thickness: 0.7, tessellation: 32 }, scene),
plane: () => BABYLON.MeshBuilder.CreatePlane('Plane', { size: 2.5, sideOrientation: BABYLON.Mesh.DOUBLESIDE }, scene),
ground: () => BABYLON.MeshBuilder.CreateGround('GroundMesh', { width: 4, height: 4, subdivisions: 2 }, scene),
capsule: () => BABYLON.MeshBuilder.CreateCapsule('Capsule', { radius: 0.8, height: 2.6 }, scene),
disc: () => BABYLON.MeshBuilder.CreateDisc('Disc', { radius: 1.4, tessellation: 48, sideOrientation: BABYLON.Mesh.DOUBLESIDE }, scene),
icosphere: () => BABYLON.MeshBuilder.CreateIcoSphere('IcoSphere', { radius: 1.2, subdivisions: 3 }, scene),
torusknot: () => BABYLON.MeshBuilder.CreateTorusKnot('TorusKnot', { radius: 1.1, tube: 0.34, radialSegments: 96, tubularSegments: 24 }, scene),
polyhedron: () => BABYLON.MeshBuilder.CreatePolyhedron('Polyhedron', { type: 2, size: 1.2 }, scene),
pyramid: () => BABYLON.MeshBuilder.CreateCylinder('Pyramid', { height: 2.2, diameterTop: 0, diameterBottom: 2.4, tessellation: 4 }, scene),
prism: () => BABYLON.MeshBuilder.CreateCylinder('Prism', { height: 2.2, diameter: 2.2, tessellation: 3 }, scene),
tube: () => BABYLON.MeshBuilder.CreateTube('Tube', { path: [V3(0, -1.2, 0), V3(0, 1.2, 0)], radius: 0.85, tessellation: 28, cap: BABYLON.Mesh.CAP_ALL }, scene),
grid: () => BABYLON.MeshBuilder.CreateGround('Grid', { width: 4, height: 4, subdivisions: 10 }, scene)
};
function addPrimitive(kind) {
if (!ensure()) return; enter(); if (EDIT.on) exitEdit();
const fn = BUILD[kind]; if (!fn) return;
const mesh = fn();
mesh.position.y = 1.2;
mesh.material = makeMat(PALETTE[palIdx++ % PALETTE.length]);
if (shadowGen) shadowGen.addShadowCaster(mesh);
register(mesh, kind);
selectOnly(mesh); setTool('move');
status('Added ' + mesh.name);
}
// ---- lights ----
function addLight(kind) {
if (!ensure()) return; enter();
const marker = BABYLON.MeshBuilder.CreateSphere('m3lm' + uid, { diameter: 0.5 }, scene);
marker.position = V3(3, 4, 2);
const mm = new BABYLON.StandardMaterial('m3lmm' + uid, scene); mm.emissiveColor = new BABYLON.Color3(1, 0.85, 0.3); mm.disableLighting = true; marker.material = mm;
let light, label;
if (kind === 'point') { light = new BABYLON.PointLight('Point' + uid, marker.position.clone(), scene); label = 'Point Light'; }
else if (kind === 'spot') { light = new BABYLON.SpotLight('Spot' + uid, marker.position.clone(), V3(-0.5, -1, -0.4), Math.PI / 3, 8, scene); label = 'Spot Light'; }
else if (kind === 'directional') { light = new BABYLON.DirectionalLight('Sun' + uid, V3(-0.5, -1, -0.6), scene); light.position = marker.position.clone(); label = 'Directional'; }
else { light = new BABYLON.HemisphericLight('Hemi' + uid, V3(0, 1, 0), scene); label = 'Hemi Light'; }
light.intensity = kind === 'hemispheric' ? 0.5 : 1.0;
marker.name = label;
register(marker, 'light', label, light);
selectOnly(marker); setTool('move');
status('Added ' + label);
}
// ---- registry ----
function register(mesh, type, name, light) {
const id = 'o' + uid++;
mesh.metadata = Object.assign(mesh.metadata || {}, { m3id: id, m3type: type });
objs.push({ id, mesh, type, name: name || mesh.name || type, light: light || null });
if (shadowGen && type !== 'light') shadowGen.addShadowCaster(mesh);
refreshOutliner();
}
function findObj(mesh) { return objs.find(o => o.mesh === mesh || (mesh && mesh.metadata && o.id === mesh.metadata.m3id)); }
function removeObj(o) {
const i = objs.indexOf(o); if (i < 0) return;
sel = sel.filter(m => m !== o.mesh);
if (o.light) o.light.dispose();
o.mesh.dispose();
objs.splice(i, 1);
updateSelVisual(); gizmoAttach(); refreshOutliner(); refreshProps();
}
// ---- selection ----
function onPointer(pi) {
if (pi.type !== BABYLON.PointerEventTypes.POINTERTAP) return;
const pr = pi.pickInfo, ev = pi.event || {};
if (EDIT.on) { editPick(ev); return; }
if (pr && pr.hit && pr.pickedMesh && findObj(pr.pickedMesh)) {
if (ev.ctrlKey || ev.metaKey || ev.shiftKey) toggleSelect(pr.pickedMesh);
else selectOnly(pr.pickedMesh);
} else if (!(ev.ctrlKey || ev.metaKey || ev.shiftKey)) {
clearSel();
}
}
function selectOnly(mesh) { if (EDIT.on) exitEdit(); sel = [mesh]; updateSelVisual(); gizmoAttach(); refreshOutliner(); refreshProps(); }
function toggleSelect(mesh) { if (EDIT.on) exitEdit(); const i = sel.indexOf(mesh); if (i >= 0) sel.splice(i, 1); else sel.push(mesh); updateSelVisual(); gizmoAttach(); refreshOutliner(); refreshProps(); }
function clearSel() { sel = []; updateSelVisual(); gizmoAttach(); refreshOutliner(); refreshProps(); }
function updateSelVisual() { if (!hl) return; hl.removeAllMeshes(); sel.forEach(m => { try { hl.addMesh(m, ACC()); } catch (e) {} }); }
function gizmoAttach() { if (gm && !EDIT.on) gm.attachToMesh(sel.length ? sel[sel.length - 1] : null); }
// ---- tools / gizmos ----
function setTool(t) {
if (EDIT.on) return;
tool = t;
gm.positionGizmoEnabled = (t === 'move');
gm.rotationGizmoEnabled = (t === 'rotate');
gm.scaleGizmoEnabled = (t === 'scale');
gm.boundingBoxGizmoEnabled = false;
applyGizmoOpts();
gizmoAttach();
[['m3t-select','select'],['m3t-move','move'],['m3t-rotate','rotate'],['m3t-scale','scale']].forEach(([id, n]) => { const b = $(id); if (b) b.classList.toggle('on', n === t); });
}
function applyGizmoOpts() {
if (!gm.gizmos) return;
const pg = gm.gizmos.positionGizmo, rg = gm.gizmos.rotationGizmo, sg = gm.gizmos.scaleGizmo;
if (pg) { pg.snapDistance = snap ? 0.5 : 0; pg.updateGizmoRotationToMatchAttachedMesh = !worldSpace; bindDrag(pg); }
if (rg) { rg.snapDistance = snap ? Math.PI / 12 : 0; rg.updateGizmoRotationToMatchAttachedMesh = !worldSpace; bindDrag(rg); }
if (sg) { sg.snapDistance = snap ? 0.25 : 0; bindDrag(sg); }
}
function bindDrag(g) { if (g._m3bound) return; g._m3bound = true; if (g.onDragObservable) g.onDragObservable.add(() => refreshTransformFields()); }
// ---- outliner ----
const ICON = { light: '💡', imported: '📦' };
function refreshOutliner() {
const list = $('m3d-olist'); if (!list) return;
$('m3-ocount').textContent = objs.length;
$('m3d-empty').style.display = objs.length ? 'none' : 'flex';
list.innerHTML = '';
objs.forEach(o => {
const row = document.createElement('div');
row.className = 'm3-orow' + (sel.includes(o.mesh) ? ' sel' : '');
const ic = ICON[o.type] || '🔷';
row.innerHTML = '<span style="font-size:12px">' + ic + '</span><span class="m3-oname">' + o.name + '</span><span class="m3-oeye">' + (o.mesh.isEnabled() ? '👁' : '🚫') + '</span><span class="m3-odel">✕</span>';
row.querySelector('.m3-oname').onclick = e => { if (e.ctrlKey || e.metaKey || e.shiftKey) toggleSelect(o.mesh); else selectOnly(o.mesh); };
row.querySelector('.m3-oeye').onclick = e => { e.stopPropagation(); o.mesh.setEnabled(!o.mesh.isEnabled()); if (o.light) o.light.setEnabled(o.mesh.isEnabled()); refreshOutliner(); };
row.querySelector('.m3-odel').onclick = e => { e.stopPropagation(); removeObj(o); };
list.appendChild(row);
});
}
// ---- properties ----
function refreshTransformFields() {
const m = sel.length === 1 ? sel[0] : null; if (!m) return;
const set = (id, v) => { const el = $(id); if (el && document.activeElement !== el) el.value = (Math.round(v * 1000) / 1000); };
set('m3p-px', m.position.x); set('m3p-py', m.position.y); set('m3p-pz', m.position.z);
set('m3p-rx', m.rotation.x * 180 / Math.PI); set('m3p-ry', m.rotation.y * 180 / Math.PI); set('m3p-rz', m.rotation.z * 180 / Math.PI);
set('m3p-sx', m.scaling.x); set('m3p-sy', m.scaling.y); set('m3p-sz', m.scaling.z);
}
function refreshProps() {
const body = $('m3d-pbody'); if (!body) return;
if (sel.length === 0) { body.innerHTML = '<div style="color:#7a7a90;font-size:11.5px;padding:6px 0">Nothing selected.</div>'; return; }
if (sel.length >= 2) {
body.innerHTML = '<div style="color:#bcbccf;font-size:11.5px;margin-bottom:8px">' + sel.length + ' objects selected</div>'
+ '<div class="m3-plabel">Boolean (A ⊙ B)</div>'
+ '<div class="m3-pbtns"><button class="m3-pbtn" data-bool="union">∪ Union</button><button class="m3-pbtn" data-bool="subtract">∖ Subtract</button><button class="m3-pbtn" data-bool="intersect">∩ Intersect</button><button class="m3-pbtn" data-op="dupe">⎘ Duplicate</button></div>'
+ '<div class="m3-pbtns" style="margin-top:5px"><button class="m3-pbtn danger" data-op="del" style="grid-column:1/3">✕ Delete selected</button></div>';
wireProps(); return;
}
const m = sel[0], o = findObj(m), isLight = !!(o && o.light);
const num = (id, v) => '<input id="' + id + '" type="number" step="0.1" value="' + (Math.round(v * 1000) / 1000) + '">';
let html = '<div class="m3-plabel">Position</div><div class="m3-vec3">' + num('m3p-px', m.position.x) + num('m3p-py', m.position.y) + num('m3p-pz', m.position.z) + '</div>';
html += '<div class="m3-plabel">Rotation°</div><div class="m3-vec3">' + num('m3p-rx', m.rotation.x * 180 / Math.PI) + num('m3p-ry', m.rotation.y * 180 / Math.PI) + num('m3p-rz', m.rotation.z * 180 / Math.PI) + '</div>';
html += '<div class="m3-plabel">Scale</div><div class="m3-vec3">' + num('m3p-sx', m.scaling.x) + num('m3p-sy', m.scaling.y) + num('m3p-sz', m.scaling.z) + '</div>';
if (isLight) {
const lt = o.light;
html += '<div class="m3-plabel">Light</div>';
html += '<div class="m3-prow"><label>Color</label><input type="color" id="m3l-col" value="' + (lt.diffuse ? lt.diffuse.toHexString() : '#ffffff') + '"></div>';
html += '<div class="m3-prow"><label>Intensity</label><input type="range" id="m3l-int" min="0" max="4" step="0.05" value="' + lt.intensity + '"></div>';
} else if (m.material) {
const mat = m.material;
const albedo = mat.albedoColor ? mat.albedoColor.toHexString() : '#cccccc';
const emi = mat.emissiveColor ? mat.emissiveColor.toHexString() : '#000000';
html += '<div class="m3-plabel">Material</div>';
html += '<div class="m3-prow"><label>Base color</label><input type="color" id="m3m-col" value="' + albedo + '"></div>';
html += '<div class="m3-prow"><label>Metallic</label><input type="range" id="m3m-met" min="0" max="1" step="0.02" value="' + (mat.metallic != null ? mat.metallic : 0.1) + '"></div>';
html += '<div class="m3-prow"><label>Roughness</label><input type="range" id="m3m-rgh" min="0" max="1" step="0.02" value="' + (mat.roughness != null ? mat.roughness : 0.5) + '"></div>';
html += '<div class="m3-prow"><label>Emissive</label><input type="color" id="m3m-emi" value="' + emi + '"></div>';
html += '<div class="m3-prow"><label>Opacity</label><input type="range" id="m3m-alp" min="0" max="1" step="0.02" value="' + (mat.alpha != null ? mat.alpha : 1) + '"></div>';
html += '<div class="m3-prow"><label>Wireframe</label><input type="checkbox" id="m3m-wire" ' + (mat.wireframe ? 'checked' : '') + '></div>';
}
html += '<div class="m3-plabel">Object</div>';
html += '<div class="m3-pbtns"><button class="m3-pbtn" data-op="dupe">⎘ Duplicate</button><button class="m3-pbtn" data-op="focus">⊙ Focus</button>';
if (!isLight) html += '<button class="m3-pbtn" data-op="subdiv">⊞ Subdivide</button><button class="m3-pbtn" data-op="smooth">◠ Smooth</button><button class="m3-pbtn" data-op="flat">◣ Flat</button><button class="m3-pbtn" data-op="mirror">⇋ Mirror X</button>';
html += '<button class="m3-pbtn danger" data-op="del" style="grid-column:1/3">✕ Delete</button></div>';
body.innerHTML = html;
wireProps();
}
function wireProps() {
const m = sel[0];
const onNum = (id, fn) => { const el = $(id); if (el) el.oninput = () => fn(parseFloat(el.value) || 0); };
if (sel.length === 1 && m) {
onNum('m3p-px', v => m.position.x = v); onNum('m3p-py', v => m.position.y = v); onNum('m3p-pz', v => m.position.z = v);
const D = Math.PI / 180;
onNum('m3p-rx', v => m.rotation.x = v * D); onNum('m3p-ry', v => m.rotation.y = v * D); onNum('m3p-rz', v => m.rotation.z = v * D);
onNum('m3p-sx', v => m.scaling.x = v); onNum('m3p-sy', v => m.scaling.y = v); onNum('m3p-sz', v => m.scaling.z = v);
const o = findObj(m);
if (o && o.light) {
const lc = $('m3l-col'); if (lc) lc.oninput = () => { const c = BABYLON.Color3.FromHexString(lc.value); o.light.diffuse = c; o.light.specular = c; };
const li = $('m3l-int'); if (li) li.oninput = () => o.light.intensity = parseFloat(li.value);
} else if (m.material) {
const mat = m.material;
const c = $('m3m-col'); if (c) c.oninput = () => mat.albedoColor = BABYLON.Color3.FromHexString(c.value);
const me = $('m3m-met'); if (me) me.oninput = () => mat.metallic = parseFloat(me.value);
const rg = $('m3m-rgh'); if (rg) rg.oninput = () => mat.roughness = parseFloat(rg.value);
const em = $('m3m-emi'); if (em) em.oninput = () => mat.emissiveColor = BABYLON.Color3.FromHexString(em.value);
const al = $('m3m-alp'); if (al) al.oninput = () => { mat.alpha = parseFloat(al.value); mat.transparencyMode = mat.alpha < 1 ? BABYLON.PBRMaterial.PBRMATERIAL_ALPHABLEND : BABYLON.PBRMaterial.PBRMATERIAL_OPAQUE; };
const wf = $('m3m-wire'); if (wf) wf.onchange = () => mat.wireframe = wf.checked;
}
}
document.querySelectorAll('#m3d-pbody [data-op]').forEach(b => b.onclick = () => op(b.dataset.op));
document.querySelectorAll('#m3d-pbody [data-bool]').forEach(b => b.onclick = () => boolean(b.dataset.bool));
}
// ---- ops ----
function op(name) {
if (name === 'del') { [...sel].forEach(m => { const o = findObj(m); if (o) removeObj(o); }); return; }
if (name === 'dupe') return duplicate();
if (name === 'focus') return frameSelected();
const m = sel[0]; if (!m) return;
if (name === 'subdiv') { try { m.increaseVertices(1); status('Subdivided'); } catch (e) { status('Cannot subdivide this mesh'); } }
else if (name === 'flat') { try { m.convertToFlatShadedMesh(); status('Flat shading'); } catch (e) {} }
else if (name === 'smooth') { try { m.forceSharedVertices(); const p = m.getVerticesData(BABYLON.VertexBuffer.PositionKind), idx = m.getIndices(), n = []; BABYLON.VertexData.ComputeNormals(p, idx, n); m.updateVerticesData(BABYLON.VertexBuffer.NormalKind, n); status('Smooth shading'); } catch (e) {} }
else if (name === 'mirror') { m.scaling.x *= -1; if (m.flipFaces) m.flipFaces(true); status('Mirrored X'); }
refreshProps();
}
function duplicate() {
const fresh = [];
sel.forEach(m => {
const o = findObj(m); if (!o) return;
const c = m.clone(m.name + ' copy');
if (m.material) c.material = m.material.clone(m.material.name + 'c');
c.position.x += 1.2; c.metadata = {};
let light = null;
if (o.light && o.light.clone) light = o.light.clone(o.light.name + 'c');
register(c, o.type, o.name + ' copy', light);
fresh.push(c);
});
if (fresh.length) { sel = fresh; updateSelVisual(); gizmoAttach(); refreshOutliner(); refreshProps(); status('Duplicated'); }
}
function boolean(opName) {
if (sel.length < 2) return;
const a = sel[0], b = sel[1];
try {
const ca = BABYLON.CSG.FromMesh(a), cb = BABYLON.CSG.FromMesh(b);
const r = opName === 'union' ? ca.union(cb) : opName === 'subtract' ? ca.subtract(cb) : ca.intersect(cb);
const mat = a.material ? a.material.clone('boolm') : makeMat('#9b8cff');
const res = r.toMesh('Boolean', mat, scene, false);
const oa = findObj(a), ob = findObj(b);
if (oa) removeObj(oa); if (ob) removeObj(ob);
register(res, 'mesh', 'Boolean ' + opName);
selectOnly(res); status('Boolean: ' + opName);
} catch (e) { status('Boolean failed: ' + e.message); }
}
// ---- camera / view ----
function frameSelected() { const t = sel.length ? sel[sel.length - 1] : null; if (!t) return frameAll(); const b = t.getBoundingInfo().boundingSphere; cam.setTarget(b.centerWorld); cam.radius = b.radiusWorld * 3.2 + 1; }
function frameAll() {
const ms = objs.map(o => o.mesh); if (!ms.length) { cam.setTarget(V3(0, 1, 0)); cam.radius = 14; return; }
let mn = V3(1e9, 1e9, 1e9), mx = V3(-1e9, -1e9, -1e9);
ms.forEach(m => { const bb = m.getBoundingInfo().boundingBox; mn = BABYLON.Vector3.Minimize(mn, bb.minimumWorld); mx = BABYLON.Vector3.Maximize(mx, bb.maximumWorld); });
const c = mn.add(mx).scale(0.5), r = mx.subtract(mn).length() / 2;
cam.setTarget(c); cam.radius = r * 2.6 + 2;
}
function view(which) {
cam.mode = BABYLON.Camera.PERSPECTIVE_CAMERA;
if (which === 'front') { cam.alpha = -Math.PI / 2; cam.beta = Math.PI / 2; }
else if (which === 'side') { cam.alpha = 0; cam.beta = Math.PI / 2; }
else if (which === 'top') { cam.alpha = -Math.PI / 2; cam.beta = 0.001; }
else { cam.alpha = -Math.PI / 3; cam.beta = Math.PI / 3; }
}
// ---- scene toggles ----
function toggleGrid() { if (grid) grid.setEnabled(!grid.isEnabled()); }
function toggleGround() {
if (!ground) {
ground = BABYLON.MeshBuilder.CreateGround('m3floor', { width: 40, height: 40 }, scene);
const gmat = new BABYLON.PBRMaterial('m3floorm', scene); gmat.albedoColor = new BABYLON.Color3(0.16, 0.16, 0.2); gmat.metallic = 0; gmat.roughness = 0.95;
ground.material = gmat; ground.isPickable = false; ground.receiveShadows = true; ground.position.y = -0.01;
} else ground.setEnabled(!ground.isEnabled());
}
function toggleSky() {
if (!sky) {
sky = BABYLON.MeshBuilder.CreateSphere('m3sky', { diameter: 2000, sideOrientation: BABYLON.Mesh.BACKSIDE }, scene);
const dt = new BABYLON.DynamicTexture('m3skytx', { width: 8, height: 512 }, scene, false);
const ctx = dt.getContext(), g = ctx.createLinearGradient(0, 0, 0, 512);
g.addColorStop(0, '#3a3a55'); g.addColorStop(0.55, '#22222f'); g.addColorStop(1, '#0e0e14');
ctx.fillStyle = g; ctx.fillRect(0, 0, 8, 512); dt.update();
const sm = new BABYLON.StandardMaterial('m3skym', scene); sm.backFaceCulling = false; sm.disableLighting = true; sm.emissiveTexture = dt; sm.diffuseColor = new BABYLON.Color3(0, 0, 0);
sky.material = sm; sky.isPickable = false; sky.infiniteDistance = true;
} else sky.setEnabled(!sky.isEnabled());
}
function toggleShadows() {
if (!shadowGen) {
shadowGen = new BABYLON.ShadowGenerator(1024, sun); shadowGen.useBlurExponentialShadowMap = true; shadowGen.blurScale = 2;
objs.forEach(o => { if (o.type !== 'light') shadowGen.addShadowCaster(o.mesh); });
if (!ground) toggleGround(); else ground.receiveShadows = true;
status('Shadows on');
} else { shadowGen.dispose(); shadowGen = null; status('Shadows off'); }
}
// ---- import / export ----
function importFile(file) {
if (!ensure()) return; enter();
const ext = '.' + file.name.split('.').pop().toLowerCase();
status('Importing ' + file.name + '…');
BABYLON.SceneLoader.ImportMeshAsync('', '', file, scene, null, ext).then(res => {
res.meshes.forEach(m => { if (m.getTotalVertices && m.getTotalVertices() > 0 && !findObj(m)) { register(m, 'imported', m.name || 'Imported'); if (shadowGen) shadowGen.addShadowCaster(m); } });
frameAll(); status('Imported ' + file.name);
}).catch(e => status('Import failed: ' + e.message));
}
function importUrl(url) {
if (!url || !ensure()) return; enter();
status('Loading model…');
BABYLON.SceneLoader.ImportMeshAsync('', '', url, scene).then(res => {
res.meshes.forEach(m => { if (m.getTotalVertices && m.getTotalVertices() > 0 && !findObj(m)) register(m, 'imported', m.name || 'Imported'); });
frameAll(); status('Model loaded');
}).catch(e => status('Load failed (CORS?): ' + e.message));
}
function exportGLB() {
if (!scene) return; status('Exporting GLB…');
const skipNames = ['m3grid', 'm3axis', 'm3sky', 'm3floor', 'm3lm'];
BABYLON.GLTF2Export.GLBAsync(scene, 'sri-axis-model', { shouldExportNode: n => !n.name || !skipNames.some(s => n.name.indexOf(s) === 0) })
.then(glb => { glb.downloadFiles(); status('GLB downloaded'); })
.catch(e => status('Export failed: ' + e.message));
}
function exportPNG() {
if (!scene) return; scene.render();
const a = document.createElement('a'); a.download = 'sri-axis-3d.png'; a.href = $('m3d-canvas').toDataURL('image/png'); a.click();
status('PNG saved');
}
function renderTo2D() {
if (!scene) return; scene.render();
const data = $('m3d-canvas').toDataURL('image/png');
if (typeof fabric !== 'undefined' && typeof canvas !== 'undefined') {
fabric.Image.fromURL(data, img => {
img.scaleToWidth(Math.min(360, canvas.getWidth() * 0.7));
img.set({ left: canvas.getWidth() / 2, top: canvas.getHeight() / 2, originX: 'center', originY: 'center' });
img._name = '3D render'; img._type = 'image';
canvas.add(img); canvas.setActiveObject(img); canvas.renderAll();
});
}
if (typeof openPanel === 'function') openPanel('upload');
if (typeof toast === 'function') toast('3D render added to canvas 🎉', 'ok');
}
// ============================================================
// EDIT MODE — vertex / edge / face selection + transform
// ============================================================
const EDIT = { on: false, mode: 'vertex', mesh: null, positions: null, indices: null,
verts: [], edges: [], buf2log: null, sel: new Set(),
vtx: null, edgeMesh: null, pivot: null, prev: null,
mtx: null, col: null, edgeLines: null };
const EPS = 1e-4;
const keyOf = (x, y, z) => Math.round(x / EPS) + '_' + Math.round(y / EPS) + '_' + Math.round(z / EPS);
function canEdit() { const m = sel[0]; return sel.length === 1 && m && !(findObj(m) && findObj(m).light) && m.getTotalVertices && m.getTotalVertices() > 0 && m.getIndices && m.getIndices(); }
function toggleEdit() { if (EDIT.on) exitEdit(); else enterEdit(); }
function enterEdit() {
if (!canEdit()) { status('Select a single mesh first, then Edit'); return; }
const mesh = sel[0];
const pos = mesh.getVerticesData(BABYLON.VertexBuffer.PositionKind), idx = mesh.getIndices();
if (!pos || !idx) { status('This mesh can’t be edited'); return; }
EDIT.on = true; EDIT.mesh = mesh; EDIT.positions = Array.from(pos); EDIT.indices = Array.from(idx);
// give this mesh its own updatable geometry so editing doesn't touch clones
mesh.makeGeometryUnique();
mesh.setVerticesData(BABYLON.VertexBuffer.PositionKind, EDIT.positions, true);
const n0 = []; BABYLON.VertexData.ComputeNormals(EDIT.positions, EDIT.indices, n0);
mesh.setVerticesData(BABYLON.VertexBuffer.NormalKind, n0, true);
// group buffer verts → logical verts (welded by position)
const groups = new Map(); EDIT.buf2log = new Int32Array(pos.length / 3); EDIT.verts = [];
for (let i = 0; i < pos.length / 3; i++) {
const k = keyOf(pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2]);
let g = groups.get(k);
if (!g) { g = { pos: [pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2]], buf: [], li: EDIT.verts.length }; groups.set(k, g); EDIT.verts.push(g); }
g.buf.push(i); EDIT.buf2log[i] = g.li;
}
// edges from triangles
const eset = new Set(); EDIT.edges = [];
for (let t = 0; t < idx.length; t += 3) {
const a = EDIT.buf2log[idx[t]], b = EDIT.buf2log[idx[t + 1]], c = EDIT.buf2log[idx[t + 2]];
[[a, b], [b, c], [c, a]].forEach(([x, y]) => { if (x === y) return; const kk = x < y ? x + '-' + y : y + '-' + x; if (!eset.has(kk)) { eset.add(kk); EDIT.edges.push([x, y]); } });
}
buildVtxMarkers(); buildEdgeMesh();
EDIT.sel.clear();
hl.removeAllMeshes();
gm.positionGizmoEnabled = true; gm.rotationGizmoEnabled = false; gm.scaleGizmoEnabled = false;
if (gm.attachToNode) gm.attachToNode(null); else gm.attachToMesh(null);
setMode(EDIT.mode);
$('m3d-editbar').style.display = 'flex';
$('m3t-edit').classList.add('on');
refreshEditSel();
status('Edit mode — click to select, drag arrows to move');
}
function exitEdit() {
if (!EDIT.on) return;
EDIT.on = false;
if (EDIT.vtx) { EDIT.vtx.dispose(); EDIT.vtx = null; }
if (EDIT.edgeMesh) { EDIT.edgeMesh.dispose(); EDIT.edgeMesh = null; }
if (EDIT.pivot) { EDIT.pivot.dispose(); EDIT.pivot = null; }
if (EDIT.mesh) { EDIT.mesh.isPickable = true; EDIT.mesh.refreshBoundingInfo(); }
EDIT.sel.clear(); EDIT.mesh = null;
$('m3d-editbar').style.display = 'none';
$('m3t-edit').classList.remove('on');
setTool('move');
updateSelVisual();
status('Object mode');
}
function buildVtxMarkers() {
const base = BABYLON.MeshBuilder.CreateSphere('m3vtx', { diameter: 0.15, segments: 6 }, scene);
const mat = new BABYLON.StandardMaterial('m3vtxm', scene);
mat.diffuseColor = new BABYLON.Color3(1, 1, 1); mat.specularColor = new BABYLON.Color3(0, 0, 0); mat.emissiveColor = new BABYLON.Color3(0.08, 0.08, 0.1);
base.material = mat; base.parent = EDIT.mesh; base.renderingGroupId = 1;
base.isPickable = true; base.thinInstanceEnablePicking = true;
const n = EDIT.verts.length;
EDIT.mtx = new Float32Array(n * 16); EDIT.col = new Float32Array(n * 4);
for (let i = 0; i < n; i++) { const p = EDIT.verts[i].pos; BABYLON.Matrix.Translation(p[0], p[1], p[2]).copyToArray(EDIT.mtx, i * 16); EDIT.col.set([0.45, 0.7, 1, 1], i * 4); }
base.thinInstanceSetBuffer('matrix', EDIT.mtx, 16);
base.thinInstanceSetBuffer('color', EDIT.col, 4);
EDIT.vtx = base;
}
function buildEdgeMesh() {
EDIT.edgeLines = EDIT.edges.map(([a, b]) => [V3(EDIT.verts[a].pos[0], EDIT.verts[a].pos[1], EDIT.verts[a].pos[2]), V3(EDIT.verts[b].pos[0], EDIT.verts[b].pos[1], EDIT.verts[b].pos[2])]);
const lm = BABYLON.MeshBuilder.CreateLineSystem('m3edges', { lines: EDIT.edgeLines, updatable: true }, scene);
lm.color = new BABYLON.Color3(0.45, 0.5, 0.65); lm.parent = EDIT.mesh; lm.isPickable = false; lm.renderingGroupId = 1;
EDIT.edgeMesh = lm;
}
function updateEdgeLines() {
if (!EDIT.edgeMesh) return;
EDIT.edges.forEach(([a, b], ei) => { const la = EDIT.verts[a].pos, lb = EDIT.verts[b].pos; EDIT.edgeLines[ei][0].copyFromFloats(la[0], la[1], la[2]); EDIT.edgeLines[ei][1].copyFromFloats(lb[0], lb[1], lb[2]); });
EDIT.edgeMesh = BABYLON.MeshBuilder.CreateLineSystem('m3edges', { lines: EDIT.edgeLines, instance: EDIT.edgeMesh }, scene);
}
function setMode(mode) {
EDIT.mode = mode;
['vertex', 'edge', 'face'].forEach(m => { const b = $('m3e-' + m); if (b) b.classList.toggle('on', m === mode); });
if (EDIT.vtx) EDIT.vtx.isPickable = (mode === 'vertex');
if (EDIT.mesh) EDIT.mesh.isPickable = (mode === 'edge' || mode === 'face');
status('Edit: ' + mode + ' select');
}
function segDist(p, a, b) {
const ax = b[0] - a[0], ay = b[1] - a[1], az = b[2] - a[2];
const wx = p.x - a[0], wy = p.y - a[1], wz = p.z - a[2];
const len = ax * ax + ay * ay + az * az || 1e-9;
let t = (wx * ax + wy * ay + wz * az) / len; t = Math.max(0, Math.min(1, t));
const dx = a[0] + ax * t - p.x, dy = a[1] + ay * t - p.y, dz = a[2] + az * t - p.z;
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
function editPick(ev) {
const add = ev.ctrlKey || ev.metaKey || ev.shiftKey;
if (!add) EDIT.sel.clear();
const x = scene.pointerX, y = scene.pointerY;
if (EDIT.mode === 'vertex') {
const pk = scene.pick(x, y, m => m === EDIT.vtx);
if (pk && pk.hit && pk.thinInstanceIndex >= 0) { const li = pk.thinInstanceIndex; if (add && EDIT.sel.has(li)) EDIT.sel.delete(li); else EDIT.sel.add(li); }
} else if (EDIT.mode === 'face') {
const pk = scene.pick(x, y, m => m === EDIT.mesh);
if (pk && pk.hit && pk.faceId >= 0) { const t = pk.faceId * 3; [EDIT.indices[t], EDIT.indices[t + 1], EDIT.indices[t + 2]].forEach(bi => EDIT.sel.add(EDIT.buf2log[bi])); }
} else {
const pk = scene.pick(x, y, m => m === EDIT.mesh);
if (pk && pk.hit && pk.pickedPoint) {
const inv = BABYLON.Matrix.Invert(EDIT.mesh.getWorldMatrix());
const lp = BABYLON.Vector3.TransformCoordinates(pk.pickedPoint, inv);
let best = -1, bd = 1e9;
EDIT.edges.forEach((e, ei) => { const d = segDist(lp, EDIT.verts[e[0]].pos, EDIT.verts[e[1]].pos); if (d < bd) { bd = d; best = ei; } });
if (best >= 0) { EDIT.sel.add(EDIT.edges[best][0]); EDIT.sel.add(EDIT.edges[best][1]); }
}
}
refreshEditSel();
}
function selectAllVerts() { EDIT.verts.forEach((_, i) => EDIT.sel.add(i)); refreshEditSel(); }
function refreshEditSel() {
if (EDIT.vtx && EDIT.col) {
for (let i = 0; i < EDIT.verts.length; i++) { const s = EDIT.sel.has(i); EDIT.col.set(s ? [1, 0.5, 0.1, 1] : [0.45, 0.7, 1, 1], i * 4); }
EDIT.vtx.thinInstanceBufferUpdated('color');
}
buildPivot();
status('Edit: ' + EDIT.mode + ' — ' + EDIT.sel.size + ' selected');
}
function buildPivot() {
if (EDIT.pivot) { EDIT.pivot.dispose(); EDIT.pivot = null; }
if (!EDIT.sel.size) { if (gm.attachToNode) gm.attachToNode(null); else gm.attachToMesh(null); return; }
let cx = 0, cy = 0, cz = 0; EDIT.sel.forEach(li => { const p = EDIT.verts[li].pos; cx += p[0]; cy += p[1]; cz += p[2]; });
const n = EDIT.sel.size; const pv = new BABYLON.TransformNode('m3pivot', scene); pv.parent = EDIT.mesh; pv.position = V3(cx / n, cy / n, cz / n);
EDIT.pivot = pv; EDIT.prev = pv.position.clone();
gm.positionGizmoEnabled = true;
if (gm.attachToNode) gm.attachToNode(pv); else gm.attachToMesh(pv);
}
function applyEditDrag() {
if (!EDIT.on || !EDIT.pivot || !EDIT.sel.size) return;
const p = EDIT.pivot.position;
if (Math.abs(p.x - EDIT.prev.x) < 1e-6 && Math.abs(p.y - EDIT.prev.y) < 1e-6 && Math.abs(p.z - EDIT.prev.z) < 1e-6) return;
const dx = p.x - EDIT.prev.x, dy = p.y - EDIT.prev.y, dz = p.z - EDIT.prev.z;
EDIT.sel.forEach(li => {
const v = EDIT.verts[li]; v.pos[0] += dx; v.pos[1] += dy; v.pos[2] += dz;
v.buf.forEach(bi => { EDIT.positions[bi * 3] += dx; EDIT.positions[bi * 3 + 1] += dy; EDIT.positions[bi * 3 + 2] += dz; });
BABYLON.Matrix.Translation(v.pos[0], v.pos[1], v.pos[2]).copyToArray(EDIT.mtx, li * 16);
});
EDIT.prev.copyFrom(p);
EDIT.mesh.updateVerticesData(BABYLON.VertexBuffer.PositionKind, EDIT.positions);
const nn = []; BABYLON.VertexData.ComputeNormals(EDIT.positions, EDIT.indices, nn); EDIT.mesh.updateVerticesData(BABYLON.VertexBuffer.NormalKind, nn);
if (EDIT.vtx) EDIT.vtx.thinInstanceBufferUpdated('matrix');
updateEdgeLines();
}
// ---- enter / exit ----
function enter() { if (!ensure()) return; $('m3d-stage').classList.add('open'); started = true; setTool(tool); eng.resize(); refreshOutliner(); refreshProps(); status(objs.length ? '3D workspace' : 'Add a mesh to begin'); }
function exit() { if (EDIT.on) exitEdit(); const st = $('m3d-stage'); if (st) st.classList.remove('open'); started = false; }
// ---- keyboard ----
function onKey(e) {
if (!started) return;
const t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
const k = e.key.toLowerCase();
if (k === 'tab') { toggleEdit(); e.preventDefault(); return; }
if (EDIT.on) {
if (k === '1') setMode('vertex'); else if (k === '2') setMode('edge'); else if (k === '3') setMode('face');
else if (k === 'a') selectAllVerts(); else if (k === 'escape') exitEdit();
else return;
e.preventDefault(); return;
}
if (k === 'q') setTool('select'); else if (k === 'g') setTool('move'); else if (k === 'r') setTool('rotate'); else if (k === 's') setTool('scale');
else if (k === 'f') frameSelected(); else if (k === 'd') duplicate();
else if (k === 'x' || k === 'delete' || k === 'backspace') { [...sel].forEach(m => { const o = findObj(m); if (o) removeObj(o); }); }
else return;
e.preventDefault();
}
// ---- wiring ----
function wire() {
const on = (id, ev, fn) => { const el = $(id); if (el) el[ev] = fn; };
on('m3-enter', 'onclick', () => enter());
document.querySelectorAll('.m3-add').forEach(b => b.onclick = () => addPrimitive(b.dataset.prim));
document.querySelectorAll('.m3-light').forEach(b => b.onclick = () => addLight(b.dataset.light));
document.querySelectorAll('.m3-aadd').forEach(b => b.onclick = () => addPrimitive(b.dataset.prim));
document.querySelectorAll('.m3-alight').forEach(b => b.onclick = () => addLight(b.dataset.light));
on('m3t-edit', 'onclick', () => toggleEdit());
on('m3e-vertex', 'onclick', () => setMode('vertex'));
on('m3e-edge', 'onclick', () => setMode('edge'));
on('m3e-face', 'onclick', () => setMode('face'));
on('m3e-all', 'onclick', () => selectAllVerts());
on('m3e-none', 'onclick', () => { EDIT.sel.clear(); refreshEditSel(); });
on('m3e-done', 'onclick', () => exitEdit());
on('m3-toggle-grid', 'onclick', () => { ensure(); toggleGrid(); });
on('m3-toggle-sky', 'onclick', () => { ensure(); enter(); toggleSky(); });
on('m3-toggle-ground', 'onclick', () => { ensure(); enter(); toggleGround(); });
on('m3-toggle-shadow', 'onclick', () => { ensure(); enter(); toggleShadows(); });
on('m3-bg-color', 'oninput', e => { ensure(); bg(e.target.value); });
on('m3-import-btn', 'onclick', () => $('m3-import-file').click());
on('m3-import-file', 'onchange', e => { if (e.target.files[0]) importFile(e.target.files[0]); e.target.value = ''; });
on('m3-import-url-btn', 'onclick', () => importUrl($('m3-import-url').value.trim()));
on('m3-export-glb', 'onclick', exportGLB);
on('m3-export-png', 'onclick', exportPNG);
on('m3-render-2d', 'onclick', renderTo2D);
on('m3t-select', 'onclick', () => setTool('select'));
on('m3t-move', 'onclick', () => setTool('move'));
on('m3t-rotate', 'onclick', () => setTool('rotate'));
on('m3t-scale', 'onclick', () => setTool('scale'));
on('m3t-snap', 'onclick', () => { snap = !snap; $('m3t-snap').classList.toggle('on', snap); applyGizmoOpts(); status('Snap ' + (snap ? 'on' : 'off')); });
on('m3t-space', 'onclick', () => { worldSpace = !worldSpace; $('m3t-space').classList.toggle('on', !worldSpace); applyGizmoOpts(); status(worldSpace ? 'World space' : 'Local space'); });
on('m3t-front', 'onclick', () => view('front'));
on('m3t-top', 'onclick', () => view('top'));
on('m3t-side', 'onclick', () => view('side'));
on('m3t-persp', 'onclick', () => view('persp'));
on('m3t-focus', 'onclick', frameSelected);
on('m3t-wire', 'onclick', () => { const w = !$('m3t-wire').classList.contains('on'); $('m3t-wire').classList.toggle('on', w); objs.forEach(o => { if (o.mesh.material) o.mesh.material.wireframe = w; }); });
on('m3t-exit', 'onclick', () => { if (typeof openPanel === 'function') openPanel('upload'); else exit(); });
window.addEventListener('keydown', onKey);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire); else wire();
return { enter, exit, addPrimitive };
})();
</script>
</body>
</html>