/* global React, ReactDOM */ /* =========================================================== VOLTLAB · CAD — Desenho técnico 2D (engine refatorado v2) ─────────────────────────────────────────────────────────── • Pan/Zoom desacoplados do React (requestAnimationFrame + transform imperativo via ref; estado só atualiza no fim) • Snap inteligente com CACHE (WeakMap) + pré-filtro por bbox • Atualizações de mousemove coalescidas em 1 frame (rAF) • Histórico em ref (zero re-render) com limite (cap) • Autosave em localStorage (debounced, à prova de falha) • Exportação SVG limpa: viewBox do desenho inteiro, sem estilos de seleção e independente do pan/zoom atual • Listener de wheel nativo non-passive (preventDefault confiável em todos os browsers) • Restrição ortogonal (Shift → 0/45/90°) · Espaço = mão • Entidades memoizadas (React.memo) + non-scaling-stroke =========================================================== */ const { useState, useRef, useEffect, useMemo, useCallback } = React; /* ---------- ICONS ---------- */ const CIcon = ({ name, size = 16 }) => { const p = { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" }; const M = { cursor: , pan: <>, line: <>, rect: , circle: , poly: <>, text: <>, measure: <>, grid: <>, snap: <>, ortho: <>, zoomIn: <>, zoomOut: <>, fit: <>, undo: <>, redo: <>, trash: <>, download:<>, plus: <>, eye: <>, eyeOff: <>, lock: <>, unlock: <>, }; return {M[name]}; }; /* ---------- ELECTRICAL SYMBOL LIBRARY ---------- */ const SYMBOLS = [ { id: "motor", group: "Máquinas", name: "Motor 3~", w: 60, h: 60, draw: <>M3~ }, { id: "trafo", group: "Máquinas", name: "Transformador", w: 80, h: 60, draw: <> }, { id: "gen", group: "Máquinas", name: "Gerador", w: 60, h: 60, draw: <>G }, { id: "breaker", group: "Proteção", name: "Disjuntor", w: 40, h: 60, draw: <> }, { id: "fuse", group: "Proteção", name: "Fusível", w: 30, h: 50, draw: <> }, { id: "ground", group: "Proteção", name: "Terra", w: 40, h: 40, draw: <> }, { id: "contact", group: "Manobra", name: "Contator", w: 40, h: 50, draw: <> }, { id: "relay", group: "Manobra", name: "Relé Térmico", w: 40, h: 50, draw: <> }, { id: "switch", group: "Manobra", name: "Chave", w: 30, h: 50, draw: <> }, { id: "lamp", group: "Cargas", name: "Lâmpada", w: 50, h: 50, draw: <> }, { id: "outlet", group: "Cargas", name: "Tomada 2P+T", w: 40, h: 40, draw: <> }, { id: "cap", group: "Componentes", name: "Capacitor", w: 30, h: 50, draw: <> }, { id: "resist", group: "Componentes", name: "Resistor", w: 60, h: 30, draw: <> }, { id: "ind", group: "Componentes", name: "Indutor", w: 60, h: 24, draw: <> }, { id: "battery", group: "Cargas", name: "Bateria", w: 40, h: 40, draw: <> }, { id: "ammeter", group: "Medição", name: "Amperímetro", w: 50, h: 50, draw: <>A }, { id: "voltmeter", group: "Medição", name: "Voltímetro", w: 50, h: 50, draw: <>V }, ]; const SYM_BY_ID = Object.fromEntries(SYMBOLS.map(s => [s.id, s])); const SYM_GROUPS = [...new Set(SYMBOLS.map(s => s.group))]; /* ---------- TOOLS ---------- */ const TOOLS = [ { id: "select", label: "Selecionar (V)", icon: "cursor", shortcut: "v" }, { id: "pan", label: "Mão (H)", icon: "pan", shortcut: "h" }, { id: "line", label: "Linha (L)", icon: "line", shortcut: "l" }, { id: "rect", label: "Retângulo (R)", icon: "rect", shortcut: "r" }, { id: "circle", label: "Círculo (C)", icon: "circle", shortcut: "c" }, { id: "poly", label: "Polilinha (P)", icon: "poly", shortcut: "p" }, { id: "text", label: "Texto (T)", icon: "text", shortcut: "t" }, { id: "measure", label: "Cota (M)", icon: "measure", shortcut: "m" }, ]; const SNAP_LABEL = { end: "fim", mid: "médio", center: "centro", vertex: "vértice", quad: "quadrante", grid: "grade" }; /* ---------- CONSTANTS ---------- */ const MAX_HISTORY = 100; // cap do histórico (memória O(1) amortizada) const PERSIST_KEY = "vl_cad_drawing_v1"; // autosave const TEXT_CHAR_W = 0.5; // largura ≈ size·0.5 por caractere (mono) /* ---------- HELPERS ---------- */ let __uidSeq = 0; const uid = () => Date.now().toString(36) + "-" + (__uidSeq++).toString(36) + Math.random().toString(36).slice(2, 5); const snapGrid = (v, step) => Math.round(v / step) * step; const fmt = (v) => Math.abs(v) < 0.01 ? "0" : v.toFixed(2).replace(/\.?0+$/, ""); const clampZoom = (z) => Math.max(0.1, Math.min(16, z)); function distPtToSeg(p, a, b) { const dx = b.x - a.x, dy = b.y - a.y; if (dx === 0 && dy === 0) return Math.hypot(p.x - a.x, p.y - a.y); const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / (dx * dx + dy * dy))); return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)); } function textBox(e) { const size = e.size || 14; return { x1: e.x, y1: e.y - size, x2: e.x + e.text.length * size * TEXT_CHAR_W, y2: e.y + size * 0.3 }; } function computeBBox(e) { if (e.kind === "line" || e.kind === "measure") return { x1: Math.min(e.x1, e.x2), y1: Math.min(e.y1, e.y2), x2: Math.max(e.x1, e.x2), y2: Math.max(e.y1, e.y2) }; if (e.kind === "rect") return { x1: e.x, y1: e.y, x2: e.x + e.w, y2: e.y + e.h }; if (e.kind === "circle") return { x1: e.cx - e.r, y1: e.cy - e.r, x2: e.cx + e.r, y2: e.cy + e.r }; if (e.kind === "poly") { // loop simples (sem spread): não estoura a pilha em polilinhas longas let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity; for (const p of e.points) { if (p.x < x1) x1 = p.x; if (p.x > x2) x2 = p.x; if (p.y < y1) y1 = p.y; if (p.y > y2) y2 = p.y; } return { x1, y1, x2, y2 }; } if (e.kind === "text") return textBox(e); if (e.kind === "symbol") return { x1: e.x - e.w / 2, y1: e.y - e.h / 2, x2: e.x + e.w / 2, y2: e.y + e.h / 2 }; return { x1: 0, y1: 0, x2: 0, y2: 0 }; } /* cache de bbox e snap points — entidades são imutáveis, então o WeakMap nunca serve dado obsoleto e é coletado pelo GC sozinho */ const __bboxCache = new WeakMap(); const bbox = (e) => { let b = __bboxCache.get(e); if (!b) { b = computeBBox(e); __bboxCache.set(e, b); } return b; }; function translateEnt(e, dx, dy) { if (e.kind === "line" || e.kind === "measure") return { ...e, x1: e.x1 + dx, y1: e.y1 + dy, x2: e.x2 + dx, y2: e.y2 + dy }; if (e.kind === "rect") return { ...e, x: e.x + dx, y: e.y + dy }; if (e.kind === "circle") return { ...e, cx: e.cx + dx, cy: e.cy + dy }; if (e.kind === "poly") return { ...e, points: e.points.map(p => ({ x: p.x + dx, y: p.y + dy })) }; if (e.kind === "text" || e.kind === "symbol") return { ...e, x: e.x + dx, y: e.y + dy }; return e; } /* notable snap points for a single entity (cacheado) */ function computeNotablePoints(e) { const pts = []; const add = (x, y, type) => pts.push({ x, y, type }); if (e.kind === "line" || e.kind === "measure") { add(e.x1, e.y1, "end"); add(e.x2, e.y2, "end"); add((e.x1 + e.x2) / 2, (e.y1 + e.y2) / 2, "mid"); } else if (e.kind === "rect") { const { x, y, w, h } = e; add(x, y, "vertex"); add(x + w, y, "vertex"); add(x, y + h, "vertex"); add(x + w, y + h, "vertex"); add(x + w / 2, y, "mid"); add(x + w / 2, y + h, "mid"); add(x, y + h / 2, "mid"); add(x + w, y + h / 2, "mid"); add(x + w / 2, y + h / 2, "center"); } else if (e.kind === "circle") { add(e.cx, e.cy, "center"); add(e.cx + e.r, e.cy, "quad"); add(e.cx - e.r, e.cy, "quad"); add(e.cx, e.cy + e.r, "quad"); add(e.cx, e.cy - e.r, "quad"); } else if (e.kind === "poly") { e.points.forEach(p => add(p.x, p.y, "vertex")); for (let i = 0; i < e.points.length - 1; i++) add((e.points[i].x + e.points[i + 1].x) / 2, (e.points[i].y + e.points[i + 1].y) / 2, "mid"); } else if (e.kind === "symbol") { add(e.x, e.y, "center"); add(e.x - e.w / 2, e.y - e.h / 2, "vertex"); add(e.x + e.w / 2, e.y - e.h / 2, "vertex"); add(e.x - e.w / 2, e.y + e.h / 2, "vertex"); add(e.x + e.w / 2, e.y + e.h / 2, "vertex"); } return pts; } const __snapCache = new WeakMap(); const notablePoints = (e) => { let p = __snapCache.get(e); if (!p) { p = computeNotablePoints(e); __snapCache.set(e, p); } return p; }; /* resize handles (world coords) */ function getHandles(e) { if (e.kind === "rect") { const { x, y, w, h } = e; return [ { id: "nw", x, y }, { id: "n", x: x + w / 2, y }, { id: "ne", x: x + w, y }, { id: "e", x: x + w, y: y + h / 2 }, { id: "se", x: x + w, y: y + h }, { id: "s", x: x + w / 2, y: y + h }, { id: "sw", x, y: y + h }, { id: "w", x, y: y + h / 2 }, ]; } if (e.kind === "circle") { return [ { id: "e", x: e.cx + e.r, y: e.cy }, { id: "w", x: e.cx - e.r, y: e.cy }, { id: "s", x: e.cx, y: e.cy + e.r }, { id: "n", x: e.cx, y: e.cy - e.r }, ]; } if (e.kind === "line" || e.kind === "measure") { return [{ id: "p1", x: e.x1, y: e.y1 }, { id: "p2", x: e.x2, y: e.y2 }]; } return []; } /* apply ortho/45° constraint from anchor a to point p */ function applyOrtho(a, p) { const dx = p.x - a.x, dy = p.y - a.y; const len = Math.hypot(dx, dy); if (len < 1e-6) return p; const step = Math.PI / 4; const ang = Math.round(Math.atan2(dy, dx) / step) * step; return { x: a.x + Math.cos(ang) * len, y: a.y + Math.sin(ang) * len }; } /* ---------- persistence (à prova de falha: nunca quebra o app) ---------- */ function loadPersisted() { try { const raw = localStorage.getItem(PERSIST_KEY); if (!raw) return null; const d = JSON.parse(raw); return d && typeof d === "object" ? d : null; } catch { return null; } } /* =========================================================== ENTITY RENDERING (memoized) =========================================================== */ function entityEl(e, strokeC, strokeW, dash) { const common = { stroke: strokeC, strokeWidth: strokeW, fill: "none", vectorEffect: "non-scaling-stroke", strokeDasharray: dash || undefined }; switch (e.kind) { case "line": return ; case "rect": return ; case "circle": return ; case "poly": return `${p.x},${p.y}`).join(" ")}/>; case "text": return {e.text}; case "measure": { const dx = e.x2 - e.x1, dy = e.y2 - e.y1; const len = Math.hypot(dx, dy), ang = Math.atan2(dy, dx), off = 14; const ox = -Math.sin(ang) * off, oy = Math.cos(ang) * off; return ( {fmt(len / 10)} m ); } case "symbol": { const sym = SYM_BY_ID[e.symId]; if (!sym) return null; // style.color → letras "M/G/A/V" (currentColor) acompanham a cor da entidade return ( {sym.draw} ); } default: return null; } } const EntShape = React.memo(function EntShape({ e, sel, hover, color }) { const strokeC = sel ? "var(--accent)" : hover ? "var(--accent-2)" : color; const strokeW = e.stroke || 1.5; return {entityEl(e, strokeC, strokeW, sel ? "5 4" : "")}; }); const EntitiesLayer = React.memo(function EntitiesLayer({ ents, selKey, hoverId, layers }) { const selSet = useMemo(() => new Set(selKey ? selKey.split("|") : []), [selKey]); const lmap = useMemo(() => Object.fromEntries(layers.map(l => [l.id, l])), [layers]); return ( <> {ents.map(e => { const L = lmap[e.layer]; if (L && L.visible === false) return null; return ; })} ); }); /* =========================================================== ROOT =========================================================== */ const CADModule = () => { const containerRef = useRef(null); const svgRef = useRef(null); const worldRef = useRef(null); // transformed const minorRef = useRef(null); const majorRef = useRef(null); const minorPathRef = useRef(null); const majorPathRef = useRef(null); const axisXRef = useRef(null); const axisYRef = useRef(null); const snapMarkerRef = useRef(null); const crosshairRef = useRef(null); const crossHRef = useRef(null); const crossVRef = useRef(null); const crossBoxRef = useRef(null); const coordXRef = useRef(null); const coordYRef = useRef(null); const zoomLabelRef = useRef(null); const exportRef = useRef(null); /* estado inicial restaurado do autosave (uma única leitura) */ const initRef = useRef(null); if (initRef.current === null) initRef.current = loadPersisted() || {}; const [size, setSize] = useState({ w: 1000, h: 700 }); const [zoom, setZoom] = useState(1); const [offset, setOffset] = useState({ x: 200, y: 200 }); // live view (ahead of React state during gestures) const viewRef = useRef({ zoom: 1, offset: { x: 200, y: 200 } }); const [tool, setTool] = useState("select"); const [color, setColor] = useState("#3D8BFF"); const [stroke, setStroke] = useState(1.5); const [snapOn, setSnapOn] = useState(true); const [gridOn, setGridOn] = useState(true); const [gridStep, setGridStep] = useState(20); const [ents, setEnts] = useState(() => Array.isArray(initRef.current.ents) ? initRef.current.ents : []); const [draft, setDraft] = useState(null); const [selected, setSelected] = useState(new Set()); const [hover, setHover] = useState(null); const [exporting, setExporting] = useState(null); // {vb,w,h} durante a exportação const [layers, setLayers] = useState(() => Array.isArray(initRef.current.layers) && initRef.current.layers.length ? initRef.current.layers : [ { id: "L1", name: "Geral", color: "#3D8BFF", visible: true, locked: false }, { id: "L2", name: "Símbolos", color: "#5BE38C", visible: true, locked: false }, { id: "L3", name: "Cotas", color: "#F2B043", visible: true, locked: false }, ]); const [activeLayer, setActiveLayer] = useState("L1"); const [symGroup, setSymGroup] = useState(SYM_GROUPS[0]); // mirrors for use inside imperative handlers (avoid stale closures during gestures) const entsRef = useRef(ents); useEffect(() => { entsRef.current = ents; }, [ents]); const layersRef = useRef(layers); const layerMapRef = useRef({}); useEffect(() => { layersRef.current = layers; layerMapRef.current = Object.fromEntries(layers.map(l => [l.id, l])); // O(1) lookup nos hot paths }, [layers]); const toolRef = useRef(tool); useEffect(() => { toolRef.current = tool; }, [tool]); const snapOnRef = useRef(snapOn); useEffect(() => { snapOnRef.current = snapOn; }, [snapOn]); const gridStepRef = useRef(gridStep); useEffect(() => { gridStepRef.current = gridStep; }, [gridStep]); const selectedRef = useRef(selected); useEffect(() => { selectedRef.current = selected; }, [selected]); const draftRef = useRef(draft); useEffect(() => { draftRef.current = draft; }, [draft]); const shiftRef = useRef(false); const spaceRef = useRef(false); useEffect(() => { viewRef.current = { zoom, offset }; }, [zoom, offset]); /* ---------- coalescência de setState em mousemove (1 update/frame) ---------- */ const pendingRef = useRef({}); const flushRafRef = useRef(0); const scheduleState = useCallback((patch) => { // refs atualizam imediatamente → lógica subsequente nunca lê estado velho if ("ents" in patch) entsRef.current = patch.ents; if ("draft" in patch) draftRef.current = patch.draft; Object.assign(pendingRef.current, patch); if (flushRafRef.current) return; flushRafRef.current = requestAnimationFrame(() => { flushRafRef.current = 0; const p = pendingRef.current; pendingRef.current = {}; if ("ents" in p) setEnts(p.ents); if ("draft" in p) setDraft(p.draft); }); }, []); /* updates discretos: cancelam pendências do rAF para não "ressuscitar" estado antigo */ const setEntsNow = useCallback((arr) => { delete pendingRef.current.ents; entsRef.current = arr; setEnts(arr); }, []); const setDraftNow = useCallback((d) => { delete pendingRef.current.draft; draftRef.current = d; setDraft(d); }, []); /* ---------- resize observer ---------- */ useEffect(() => { if (!containerRef.current) return; const ro = new ResizeObserver(([e]) => setSize({ w: e.contentRect.width, h: e.contentRect.height })); ro.observe(containerRef.current); return () => ro.disconnect(); }, []); /* ---------- imperative view application (rAF) ---------- */ const rafRef = useRef(0); const applyView = useCallback((v) => { const step = gridStepRef.current; if (worldRef.current) worldRef.current.setAttribute("transform", `translate(${v.offset.x} ${v.offset.y}) scale(${v.zoom})`); const setPat = (ref, pathRef, mult) => { if (!ref.current) return; const tile = step * mult * v.zoom; ref.current.setAttribute("x", v.offset.x); ref.current.setAttribute("y", v.offset.y); ref.current.setAttribute("width", tile); ref.current.setAttribute("height", tile); // keep the tile's grid path in sync with the tile size, otherwise the // lines stay at the previous scale and the grid "collapses" mid-gesture if (pathRef.current) pathRef.current.setAttribute("d", `M ${tile} 0 L 0 0 0 ${tile}`); }; setPat(minorRef, minorPathRef, 1); setPat(majorRef, majorPathRef, 5); if (axisXRef.current) { axisXRef.current.setAttribute("y1", v.offset.y); axisXRef.current.setAttribute("y2", v.offset.y); } if (axisYRef.current) { axisYRef.current.setAttribute("x1", v.offset.x); axisYRef.current.setAttribute("x2", v.offset.x); } if (zoomLabelRef.current) zoomLabelRef.current.textContent = (v.zoom * 100).toFixed(0) + "%"; }, []); const scheduleApply = useCallback(() => { if (rafRef.current) return; rafRef.current = requestAnimationFrame(() => { rafRef.current = 0; applyView(viewRef.current); }); }, [applyView]); /* commit live view → React state (debounced for wheel) */ const commitTimer = useRef(0); const commitViewSoon = useCallback(() => { clearTimeout(commitTimer.current); commitTimer.current = setTimeout(() => { setZoom(viewRef.current.zoom); setOffset({ ...viewRef.current.offset }); }, 140); }, []); const commitViewNow = useCallback(() => { clearTimeout(commitTimer.current); setZoom(viewRef.current.zoom); setOffset({ ...viewRef.current.offset }); }, []); /* ---------- limpeza total no unmount (sem vazamentos) ---------- */ useEffect(() => () => { cancelAnimationFrame(rafRef.current); cancelAnimationFrame(flushRafRef.current); clearTimeout(commitTimer.current); }, []); /* ---------- autosave (debounced, nunca lança) ---------- */ const saveTimer = useRef(0); useEffect(() => { clearTimeout(saveTimer.current); saveTimer.current = setTimeout(() => { try { localStorage.setItem(PERSIST_KEY, JSON.stringify({ ents, layers })); } catch { /* quota/priv. mode */ } }, 600); return () => clearTimeout(saveTimer.current); }, [ents, layers]); /* ---------- coordinate / screen helpers (live view) ---------- */ const toWorld = (sx, sy) => { const v = viewRef.current; return { x: (sx - v.offset.x) / v.zoom, y: (sy - v.offset.y) / v.zoom }; }; const toScreen = (x, y) => { const v = viewRef.current; return { x: x * v.zoom + v.offset.x, y: y * v.zoom + v.offset.y }; }; const localXY = (e) => { const r = svgRef.current.getBoundingClientRect(); return { sx: e.clientX - r.left, sy: e.clientY - r.top }; }; /* ---------- CROSSHAIR (follows cursor, native pointer hidden) ---------- */ const positionCross = (sx, sy) => { if (crossHRef.current) { crossHRef.current.setAttribute("y1", sy); crossHRef.current.setAttribute("y2", sy); } if (crossVRef.current) { crossVRef.current.setAttribute("x1", sx); crossVRef.current.setAttribute("x2", sx); } if (crossBoxRef.current) { crossBoxRef.current.setAttribute("x", sx - 6.5); crossBoxRef.current.setAttribute("y", sy - 6.5); } }; const showCross = (e) => { const { sx, sy } = localXY(e); positionCross(sx, sy); if (crosshairRef.current) crosshairRef.current.style.display = ""; }; const hideCross = () => { if (crosshairRef.current) crosshairRef.current.style.display = "none"; }; /* ---------- SNAP ENGINE ---------- O(N) com pré-filtro por bbox em cache; pontos notáveis em cache por entidade (WeakMap). O toggle SNAP agora desliga TAMBÉM o snap a pontos notáveis (consistente com o título do botão: "Snap a pontos notáveis e grade"). */ const findSnap = (sx, sy, excludeIds) => { const v = viewRef.current; const world = toWorld(sx, sy); if (!snapOnRef.current) return { x: world.x, y: world.y, type: "free" }; const tolScreen = 11; const tol = tolScreen / v.zoom; let best = null, bestD = tol; const arr = entsRef.current; const lmap = layerMapRef.current; const exclude = excludeIds && excludeIds.length ? new Set(excludeIds) : null; for (let i = arr.length - 1; i >= 0; i--) { const e = arr[i]; if (exclude && exclude.has(e.id)) continue; const L = lmap[e.layer]; if (L && (L.visible === false || L.locked)) continue; // pré-filtro: descarta entidades cujo bbox expandido não contém o cursor const b = bbox(e); if (world.x < b.x1 - tol || world.x > b.x2 + tol || world.y < b.y1 - tol || world.y > b.y2 + tol) continue; const pts = notablePoints(e); for (const pt of pts) { const d = Math.hypot(pt.x - world.x, pt.y - world.y); if (d < bestD) { bestD = d; best = pt; } } } if (best) return best; const step = gridStepRef.current; return { x: snapGrid(world.x, step), y: snapGrid(world.y, step), type: "grid" }; }; const showSnapMarker = (pt) => { const g = snapMarkerRef.current; if (!g) return; if (!pt || pt.type === "free") { g.style.display = "none"; return; } const s = toScreen(pt.x, pt.y); g.style.display = ""; g.setAttribute("transform", `translate(${s.x} ${s.y})`); const col = pt.type === "grid" ? "var(--fg-mute)" : "var(--accent-2)"; const box = g.querySelector(".snap-box"); const lab = g.querySelector(".snap-lab"); if (box) box.setAttribute("stroke", col); if (lab) { lab.textContent = SNAP_LABEL[pt.type] || ""; lab.setAttribute("fill", col); } }; /* ---------- HIT TEST (com pré-filtro por bbox + lookup O(1) de camada) ---------- */ const hitTest = (world) => { const v = viewRef.current; const tol = 6 / v.zoom; const arr = entsRef.current; const lmap = layerMapRef.current; for (let i = arr.length - 1; i >= 0; i--) { const e = arr[i]; const L = lmap[e.layer]; if (L && (!L.visible || L.locked)) continue; const b = bbox(e); if (world.x < b.x1 - tol || world.x > b.x2 + tol || world.y < b.y1 - tol || world.y > b.y2 + tol) continue; if (e.kind === "line" || e.kind === "measure") { if (distPtToSeg(world, { x: e.x1, y: e.y1 }, { x: e.x2, y: e.y2 }) < tol) return e.id; } else if (e.kind === "rect") { const onX = Math.abs(world.x - e.x) < tol || Math.abs(world.x - (e.x + e.w)) < tol; const onY = Math.abs(world.y - e.y) < tol || Math.abs(world.y - (e.y + e.h)) < tol; const inX = world.x >= e.x - tol && world.x <= e.x + e.w + tol; const inY = world.y >= e.y - tol && world.y <= e.y + e.h + tol; if ((onX && inY) || (onY && inX)) return e.id; } else if (e.kind === "circle") { if (Math.abs(Math.hypot(world.x - e.cx, world.y - e.cy) - e.r) < tol) return e.id; } else if (e.kind === "poly") { for (let k = 0; k < e.points.length - 1; k++) if (distPtToSeg(world, e.points[k], e.points[k + 1]) < tol) return e.id; } else if (e.kind === "text") { const tb = textBox(e); if (world.x >= tb.x1 && world.x <= tb.x2 && world.y >= tb.y1 && world.y <= tb.y2) return e.id; } else if (e.kind === "symbol") { if (world.x >= e.x - e.w / 2 && world.x <= e.x + e.w / 2 && world.y >= e.y - e.h / 2 && world.y <= e.y + e.h / 2) return e.id; } } return null; }; const handleHitTest = (sx, sy) => { const sel = selectedRef.current; if (sel.size !== 1) return null; const id = [...sel][0]; const e = entsRef.current.find(x => x.id === id); if (!e) return null; for (const h of getHandles(e)) { const s = toScreen(h.x, h.y); if (Math.hypot(s.x - sx, s.y - sy) < 9) return { handle: h.id, entId: id, orig: e }; } return null; }; /* ---------- history ---------- 100% em ref: zero re-render por commit, sem closures velhas, com cap (MAX_HISTORY). Snapshots compartilham referências de entidades não alteradas (structural sharing) → memória baixa. */ const histRef = useRef(null); if (histRef.current === null) histRef.current = { stack: [entsRef.current], idx: 0 }; const commit = useCallback((next) => { setEntsNow(next); const h = histRef.current; h.stack = h.stack.slice(0, h.idx + 1); h.stack.push(next); if (h.stack.length > MAX_HISTORY) h.stack.splice(0, h.stack.length - MAX_HISTORY); h.idx = h.stack.length - 1; }, [setEntsNow]); const doUndo = useCallback(() => { const h = histRef.current; if (h.idx <= 0) return; h.idx -= 1; setEntsNow(h.stack[h.idx]); setSelected(new Set()); }, [setEntsNow]); const doRedo = useCallback(() => { const h = histRef.current; if (h.idx >= h.stack.length - 1) return; h.idx += 1; setEntsNow(h.stack[h.idx]); }, [setEntsNow]); /* ---------- keyboard (registrado UMA única vez) ---------- */ useEffect(() => { const onKey = (e) => { if (e.key === "Shift") shiftRef.current = true; const tag = e.target.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || e.target.isContentEditable) return; if (e.code === "Space") { spaceRef.current = true; e.preventDefault(); return; } // espaço = mão (já anunciado na barra de ajuda) if (e.metaKey || e.ctrlKey) { if (e.key === "z") { e.preventDefault(); e.shiftKey ? doRedo() : doUndo(); return; } if (e.key === "y") { e.preventDefault(); doRedo(); return; } } const t = TOOLS.find(t => t.shortcut === e.key.toLowerCase()); if (t) { setTool(t.id); return; } if (e.key === "Escape") { setDraftNow(null); setSelected(new Set()); } if (e.key === "Delete" || e.key === "Backspace") { const sel = selectedRef.current; if (sel.size) { commit(entsRef.current.filter(en => !sel.has(en.id))); setSelected(new Set()); } } }; const onKeyUp = (e) => { if (e.key === "Shift") shiftRef.current = false; if (e.code === "Space") spaceRef.current = false; }; window.addEventListener("keydown", onKey); window.addEventListener("keyup", onKeyUp); return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("keyup", onKeyUp); }; }, [commit, doUndo, doRedo, setDraftNow]); /* ---------- pointer interactions ---------- */ const dragRef = useRef(null); const onMouseDown = (e) => { const { sx, sy } = localXY(e); // pan: middle/right button, pan tool, ou espaço pressionado if (e.button === 1 || e.button === 2 || (e.button === 0 && (toolRef.current === "pan" || spaceRef.current))) { dragRef.current = { type: "pan", sx, sy, off: { ...viewRef.current.offset } }; e.preventDefault(); return; } if (e.button !== 0) return; if (tool === "select") { // handle drag first const hh = handleHitTest(sx, sy); if (hh) { dragRef.current = { type: "resize", ...hh, changed: false }; return; } const world = toWorld(sx, sy); const hit = hitTest(world); if (hit) { let nextSel; if (e.shiftKey) { nextSel = new Set(selected); nextSel.has(hit) ? nextSel.delete(hit) : nextSel.add(hit); } else nextSel = new Set([hit]); setSelected(nextSel); dragRef.current = { type: "move", startWorld: world, moved: false, originals: entsRef.current.filter(en => nextSel.has(en.id)) }; } else { setSelected(new Set()); dragRef.current = { type: "marquee", x1: world.x, y1: world.y }; setDraftNow({ type: "marquee", x1: world.x, y1: world.y, x2: world.x, y2: world.y }); } return; } // drawing tools — use snap point const snap = findSnap(sx, sy); const p = { x: snap.x, y: snap.y }; if (tool === "line") { setDraftNow({ kind: "line", x1: p.x, y1: p.y, x2: p.x, y2: p.y, color, stroke, layer: activeLayer }); dragRef.current = { type: "draw" }; } else if (tool === "rect") { setDraftNow({ kind: "rect", x: p.x, y: p.y, w: 0, h: 0, color, stroke, layer: activeLayer }); dragRef.current = { type: "draw", start: p }; } else if (tool === "circle") { setDraftNow({ kind: "circle", cx: p.x, cy: p.y, r: 0, color, stroke, layer: activeLayer }); dragRef.current = { type: "draw", start: p }; } else if (tool === "poly") { const d = draftRef.current; if (!d || d.kind !== "poly") setDraftNow({ kind: "poly", points: [p, { ...p }], color, stroke, layer: activeLayer }); else { const pts = [...d.points]; pts[pts.length - 1] = p; pts.push({ ...p }); setDraftNow({ ...d, points: pts }); } } else if (tool === "text") { const txt = prompt("Texto:", "Texto"); if (txt) commit([...entsRef.current, { id: uid(), kind: "text", x: p.x, y: p.y, text: txt, color, layer: activeLayer, size: 14 }]); } else if (tool === "measure") { setDraftNow({ kind: "measure", x1: p.x, y1: p.y, x2: p.x, y2: p.y, color: "#F2B043", stroke: 1, layer: "L3" }); dragRef.current = { type: "draw" }; } }; const onMouseMove = (e) => { const { sx, sy } = localXY(e); positionCross(sx, sy); if (crosshairRef.current && crosshairRef.current.style.display === "none") crosshairRef.current.style.display = ""; const d = dragRef.current; /* PAN — fully imperative */ if (d && d.type === "pan") { viewRef.current = { ...viewRef.current, offset: { x: d.off.x + (sx - d.sx), y: d.off.y + (sy - d.sy) } }; scheduleApply(); return; } // live coordinate readout (imperative — no re-render) const rawWorld = toWorld(sx, sy); if (coordXRef.current) coordXRef.current.textContent = rawWorld.x.toFixed(0); if (coordYRef.current) coordYRef.current.textContent = rawWorld.y.toFixed(0); /* RESIZE — coalescido em 1 update/frame */ if (d && d.type === "resize") { const snap = findSnap(sx, sy, [d.orig.id]); const p = { x: snap.x, y: snap.y }; showSnapMarker(snap); const o = d.orig; let updated = o; if (o.kind === "rect") { let x1 = o.x, y1 = o.y, x2 = o.x + o.w, y2 = o.y + o.h; if (d.handle.includes("w")) x1 = p.x; if (d.handle.includes("e")) x2 = p.x; if (d.handle.includes("n")) y1 = p.y; if (d.handle.includes("s")) y2 = p.y; updated = { ...o, x: Math.min(x1, x2), y: Math.min(y1, y2), w: Math.abs(x2 - x1), h: Math.abs(y2 - y1) }; } else if (o.kind === "circle") { updated = { ...o, r: Math.max(0.5, Math.hypot(p.x - o.cx, p.y - o.cy)) }; } else if (o.kind === "line" || o.kind === "measure") { const anchor = d.handle === "p1" ? { x: o.x2, y: o.y2 } : { x: o.x1, y: o.y1 }; const pp = shiftRef.current ? applyOrtho(anchor, p) : p; updated = d.handle === "p1" ? { ...o, x1: pp.x, y1: pp.y } : { ...o, x2: pp.x, y2: pp.y }; } d.changed = true; scheduleState({ ents: entsRef.current.map(en => en.id === o.id ? updated : en) }); return; } /* MOVE selected — coalescido */ if (d && d.type === "move") { const snap = findSnap(sx, sy, d.originals.map(o => o.id)); showSnapMarker(snap); const dx = snap.x - d.startWorld.x, dy = snap.y - d.startWorld.y; d.moved = true; const origById = d.origById || (d.origById = Object.fromEntries(d.originals.map(o => [o.id, o]))); scheduleState({ ents: entsRef.current.map(en => { const orig = origById[en.id]; return orig ? translateEnt(orig, dx, dy) : en; }) }); return; } /* MARQUEE */ if (d && d.type === "marquee") { const w = toWorld(sx, sy); const dr = draftRef.current; if (dr) scheduleState({ draft: { ...dr, x2: w.x, y2: w.y } }); return; } /* DRAWING — coalescido */ if (d && d.type === "draw") { const snap = findSnap(sx, sy); let p = { x: snap.x, y: snap.y }; showSnapMarker(snap); const dr = draftRef.current; if (!dr) return; if (dr.kind === "line" || dr.kind === "measure") { if (shiftRef.current) p = applyOrtho({ x: dr.x1, y: dr.y1 }, p); scheduleState({ draft: { ...dr, x2: p.x, y2: p.y } }); } else if (dr.kind === "rect") { const s = d.start; let w = p.x - s.x, h = p.y - s.y; if (shiftRef.current) { const m = Math.max(Math.abs(w), Math.abs(h)); w = Math.sign(w || 1) * m; h = Math.sign(h || 1) * m; } scheduleState({ draft: { ...dr, x: Math.min(s.x, s.x + w), y: Math.min(s.y, s.y + h), w: Math.abs(w), h: Math.abs(h) } }); } else if (dr.kind === "circle") { scheduleState({ draft: { ...dr, r: Math.hypot(p.x - d.start.x, p.y - d.start.y) } }); } return; } /* IDLE — hover highlight + snap preview */ const snap = findSnap(sx, sy); showSnapMarker(toolRef.current === "select" ? (snap.type === "grid" ? null : snap) : snap); if (toolRef.current === "select") setHover(hitTest(rawWorld)); else if (draftRef.current && draftRef.current.kind === "poly") { // rubber-band the last poly point while moving (click-to-add mode) const dd = draftRef.current; const pts = [...dd.points]; let p = { x: snap.x, y: snap.y }; if (shiftRef.current && pts.length >= 2) p = applyOrtho(pts[pts.length - 2], p); pts[pts.length - 1] = p; scheduleState({ draft: { ...dd, points: pts } }); } }; const onMouseUp = () => { const d = dragRef.current; if (!d) return; if (d.type === "pan") { commitViewNow(); dragRef.current = null; return; } if (d.type === "resize") { // só grava no histórico se houve alteração real (evita snapshot duplicado) if (d.changed) commit(entsRef.current); dragRef.current = null; showSnapMarker(null); return; } if (d.type === "move") { if (d.moved) commit(entsRef.current); dragRef.current = null; showSnapMarker(null); return; } if (d.type === "marquee") { const m = draftRef.current; const x1 = Math.min(m.x1, m.x2), x2 = Math.max(m.x1, m.x2); const y1 = Math.min(m.y1, m.y2), y2 = Math.max(m.y1, m.y2); const inside = new Set(); const lmap = layerMapRef.current; entsRef.current.forEach(en => { const L = lmap[en.layer]; if (L && (!L.visible || L.locked)) return; // marquee respeita camadas ocultas/travadas const b = bbox(en); if (b.x1 >= x1 && b.x2 <= x2 && b.y1 >= y1 && b.y2 <= y2) inside.add(en.id); }); setSelected(inside); setDraftNow(null); dragRef.current = null; return; } if (d.type === "draw") { const dr = draftRef.current; if (dr && (dr.kind === "line" || dr.kind === "measure")) { if (Math.hypot(dr.x2 - dr.x1, dr.y2 - dr.y1) > 0.5) commit([...entsRef.current, { ...dr, id: uid() }]); setDraftNow(null); } else if (dr && dr.kind === "rect") { if (dr.w > 0.5 && dr.h > 0.5) commit([...entsRef.current, { ...dr, id: uid() }]); setDraftNow(null); } else if (dr && dr.kind === "circle") { if (dr.r > 0.5) commit([...entsRef.current, { ...dr, id: uid() }]); setDraftNow(null); } // poly stays open (finalized on dblclick) showSnapMarker(null); dragRef.current = null; } }; const onDoubleClick = () => { const dr = draftRef.current; if (dr && dr.kind === "poly") { const pts = dr.points.slice(0, -1); if (pts.length >= 2) commit([...entsRef.current, { ...dr, points: pts, id: uid() }]); setDraftNow(null); } }; /* ---------- WHEEL — listener NATIVO non-passive ---------- (React/Chrome registram wheel como passive em alguns níveis e preventDefault() falha silenciosamente → a página rola junto com o zoom; o listener nativo elimina esse comportamento) */ useEffect(() => { const el = svgRef.current; if (!el) return; const onWheelNative = (e) => { e.preventDefault(); const r = el.getBoundingClientRect(); const sx = e.clientX - r.left, sy = e.clientY - r.top; const v = viewRef.current; const factor = e.deltaY > 0 ? 0.9 : 1.1; const nz = clampZoom(v.zoom * factor); const wx = (sx - v.offset.x) / v.zoom, wy = (sy - v.offset.y) / v.zoom; viewRef.current = { zoom: nz, offset: { x: sx - wx * nz, y: sy - wy * nz } }; scheduleApply(); commitViewSoon(); }; el.addEventListener("wheel", onWheelNative, { passive: false }); return () => el.removeEventListener("wheel", onWheelNative); }, [scheduleApply, commitViewSoon]); /* ---------- viewport buttons ---------- */ const zoomBtn = (factor) => { const v = viewRef.current; const cx = size.w / 2, cy = size.h / 2; const nz = clampZoom(v.zoom * factor); const wx = (cx - v.offset.x) / v.zoom, wy = (cy - v.offset.y) / v.zoom; viewRef.current = { zoom: nz, offset: { x: cx - wx * nz, y: cy - wy * nz } }; applyView(viewRef.current); commitViewNow(); }; const drawingBBox = (arr) => { let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity; arr.forEach(e => { const b = bbox(e); if (b.x1 < x1) x1 = b.x1; if (b.y1 < y1) y1 = b.y1; if (b.x2 > x2) x2 = b.x2; if (b.y2 > y2) y2 = b.y2; }); return { x1, y1, x2, y2 }; }; const zoomFit = () => { if (ents.length === 0) { viewRef.current = { zoom: 1, offset: { x: size.w / 2, y: size.h / 2 } }; } else { const { x1, y1, x2, y2 } = drawingBBox(ents); const pad = 70; const z = clampZoom(Math.min((size.w - pad * 2) / (x2 - x1 + 1), (size.h - pad * 2) / (y2 - y1 + 1))); viewRef.current = { zoom: z, offset: { x: size.w / 2 - (x1 + x2) / 2 * z, y: size.h / 2 - (y1 + y2) / 2 * z } }; } applyView(viewRef.current); commitViewNow(); }; const dropSymbol = (sym) => { const v = viewRef.current; const cx = (size.w / 2 - v.offset.x) / v.zoom; const cy = (size.h / 2 - v.offset.y) / v.zoom; const p = snapOn ? { x: snapGrid(cx, gridStep), y: snapGrid(cy, gridStep) } : { x: cx, y: cy }; commit([...entsRef.current, { id: uid(), kind: "symbol", symId: sym.id, x: p.x, y: p.y, w: sym.w, h: sym.h, rot: 0, color: (layerMapRef.current["L2"] || {}).color || "#5BE38C", stroke: 1.5, layer: "L2" }]); }; const clearAll = () => { if (confirm("Limpar todo o desenho?")) { commit([]); setSelected(new Set()); } }; /* ---------- EXPORT SVG limpo ---------- Renderiza um SVG oculto SOMENTE com as entidades (sem grid, sem seleção/hover, sem crosshair), com viewBox calculado do desenho INTEIRO → independente do pan/zoom e sem recorte. */ const exportSVG = () => { const arr = entsRef.current; let vb; if (arr.length === 0) vb = { x: 0, y: 0, w: size.w, h: size.h }; else { const { x1, y1, x2, y2 } = drawingBBox(arr); const pad = 40; vb = { x: x1 - pad, y: y1 - pad, w: (x2 - x1) + pad * 2, h: (y2 - y1) + pad * 2 }; } ReactDOM.flushSync(() => setExporting(vb)); const node = exportRef.current; if (node) { const clone = node.cloneNode(true); clone.removeAttribute("style"); const xml = new XMLSerializer().serializeToString(clone); const blob = new Blob([xml], { type: "image/svg+xml" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "voltlab-desenho.svg"; a.click(); URL.revokeObjectURL(url); } setExporting(null); }; /* selection signature (stable string) so EntitiesLayer memo holds across draft updates */ const selKey = useMemo(() => [...selected].sort().join("|"), [selected]); const singleSel = selected.size === 1 ? ents.find(e => e.id === [...selected][0]) : null; const handles = singleSel ? getHandles(singleSel) : []; /* contagem de entidades por camada calculada UMA vez por render (O(N)) */ const layerCounts = useMemo(() => { const c = {}; for (const e of ents) c[e.layer] = (c[e.layer] || 0) + 1; return c; }, [ents]); /* draft entity preview color resolution */ const draftColor = (draft && draft.color) || color; return (
{/* LEFT TOOLBAR */} {/* CANVAS */}
{(TOOLS.find(t => t.id === tool) || {}).label}
STEP
X 0 · Y 0 ZOOM {(zoom * 100).toFixed(0)}%
{ onMouseUp(); hideCross(); }} onDoubleClick={onDoubleClick} onContextMenu={e => e.preventDefault()} style={{ cursor: "none" }} > {gridOn && ( )} {/* World group — transform driven by React state, overridden imperatively during gestures */} {draft && draft.kind && ( {entityEl(draft, draftColor, draft.stroke || stroke, "4 4")} )} {/* Marquee (screen space) */} {draft && draft.type === "marquee" && (() => { const a = toScreen(draft.x1, draft.y1), b = toScreen(draft.x2, draft.y2); return ; })()} {/* Resize handles (screen space, constant size) */} {handles.length > 0 && tool === "select" && ( {handles.map(h => { const s = toScreen(h.x, h.y); return ; })} )} {/* Cursor crosshair (screen space, follows pointer) */} {/* Snap marker (imperative) */} {/* SVG oculto usado apenas durante a exportação (fora da tela, sem UI) */} {exporting && ( )}
OBJETOS · {ents.length} SELECIONADOS · {selected.size} CAMADA ATIVA · {(layers.find(l => l.id === activeLayer) || {}).name} L·R·C·P·T = ferramentas · Shift = orto/45° · arraste alças p/ redimensionar · scroll = zoom · espaço/H = mão
{/* RIGHT PANEL */}
); }; window.VL_CAD = CADModule;