dxf-agent/main.py

705 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Agent DXF - Dorafort
DXF upload, SVG preview, RAG chat cu qwen2.5vl:3b
"""
import json, uuid, math
from pathlib import Path
from typing import Optional
import ezdxf
import httpx
from fastapi import FastAPI, File, UploadFile, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse, Response
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
app = FastAPI(title="Agent DXF - Dorafort")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
LITELLM_URL = "http://10.0.12.4:4000"
LITELLM_KEY = "DHoa2l5FBCrhFd8DZIV1LIpn7AVyNHss"
MODEL = "llama-70b"
UPLOAD_DIR = Path("/tmp/dxf_uploads")
UPLOAD_DIR.mkdir(exist_ok=True)
sessions: dict[str, dict] = {} # session_id -> {context, svg, analysis}
# ── LAYER STYLE MAP ───────────────────────────────────────────────────────────
LAYER_STYLE = {
"CADRU": {"color": "#e2e8f0", "width": 2.5, "dash": ""},
"STICLA": {"color": "#38bdf8", "width": 1.0, "dash": "6,3"},
"PROFIL": {"color": "#4ade80", "width": 2.0, "dash": ""},
"FERONERIE": {"color": "#fb923c", "width": 1.5, "dash": ""},
"COTE": {"color": "#fbbf24", "width": 0.8, "dash": "2,2"},
"TEXT": {"color": "#94a3b8", "width": 0.5, "dash": ""},
"0": {"color": "#64748b", "width": 0.8, "dash": ""},
}
def layer_style(name: str) -> dict:
for key, s in LAYER_STYLE.items():
if key.upper() in name.upper():
return s
return {"color": "#94a3b8", "width": 1.0, "dash": ""}
# ── SVG RENDERER ──────────────────────────────────────────────────────────────
def dxf_to_svg(filepath: str) -> str:
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
all_x, all_y = [], []
by_layer: dict[str, list[str]] = {}
def reg(layer, el):
by_layer.setdefault(layer, []).append(el)
def fy(y): return -y # flip Y axis (DXF up → SVG down)
def arc_path(cx, cy, r, start_deg, end_deg):
s = math.radians(start_deg)
e = math.radians(end_deg)
if end_deg <= start_deg:
e += 2 * math.pi
large = 1 if (e - s) > math.pi else 0
x1 = cx + r * math.cos(s); y1 = fy(cy + r * math.sin(s))
x2 = cx + r * math.cos(e); y2 = fy(cy + r * math.sin(e))
return f"M {x1} {y1} A {r} {r} 0 {large} 0 {x2} {y2}"
for entity in msp:
etype = entity.dxftype()
layer = entity.dxf.layer
st = layer_style(layer)
col = st["color"]
sw = st["width"]
dash = f'stroke-dasharray="{st["dash"]}"' if st["dash"] else ""
attrs = f'stroke="{col}" stroke-width="{sw}" fill="none" {dash}'
try:
if etype == "LWPOLYLINE":
pts = [(p[0], p[1]) for p in entity.get_points()]
if len(pts) < 2:
continue
all_x += [p[0] for p in pts]; all_y += [p[1] for p in pts]
pts_s = " ".join(f"{p[0]:.3f},{fy(p[1]):.3f}" for p in pts)
tag = "polygon" if entity.closed else "polyline"
reg(layer, f'<{tag} points="{pts_s}" {attrs}/>')
elif etype == "LINE":
s, e = entity.dxf.start, entity.dxf.end
all_x += [s.x, e.x]; all_y += [s.y, e.y]
reg(layer, f'<line x1="{s.x:.3f}" y1="{fy(s.y):.3f}" x2="{e.x:.3f}" y2="{fy(e.y):.3f}" {attrs}/>')
elif etype == "CIRCLE":
c, r = entity.dxf.center, entity.dxf.radius
all_x += [c.x-r, c.x+r]; all_y += [c.y-r, c.y+r]
reg(layer, f'<circle cx="{c.x:.3f}" cy="{fy(c.y):.3f}" r="{r:.3f}" {attrs}/>')
elif etype == "ARC":
c, r = entity.dxf.center, entity.dxf.radius
sa, ea = entity.dxf.start_angle, entity.dxf.end_angle
all_x += [c.x-r, c.x+r]; all_y += [c.y-r, c.y+r]
d = arc_path(c.x, c.y, r, sa, ea)
reg(layer, f'<path d="{d}" {attrs}/>')
elif etype in ("TEXT", "MTEXT"):
txt = (entity.dxf.text if etype == "TEXT" else entity.text).strip()
if not txt:
continue
ins = entity.dxf.insert
h = max(getattr(entity.dxf, "height", 10), 5)
all_x.append(ins.x); all_y.append(ins.y)
esc = txt.replace("&","&amp;").replace("<","&lt;").replace(">","&gt;")
ty_svg = fy(ins.y)
# matrix(1 0 0 -1 0 2*ty_svg) flips Y around the text baseline
# without CSS transform-origin which causes mirroring artifacts
reg(layer, f'<text x="{ins.x:.3f}" y="{ty_svg:.3f}" '
f'font-size="{h:.2f}" fill="{col}" font-family="monospace" '
f'transform="matrix(1 0 0 -1 0 {2*ty_svg:.3f})">'
f'{esc}</text>')
except Exception:
continue
if not all_x:
return '<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><text fill="red">No geometry</text></svg>'
mn_x, mx_x = min(all_x), max(all_x)
mn_y, mx_y = min(all_y), max(all_y)
pad = max(mx_x - mn_x, mx_y - mn_y) * 0.07 + 10
vbx = mn_x - pad
vby = fy(mx_y) - pad
vbw = (mx_x - mn_x) + 2*pad
vbh = (mx_y - mn_y) + 2*pad
parts = [
f'<svg xmlns="http://www.w3.org/2000/svg" '
f'viewBox="{vbx:.2f} {vby:.2f} {vbw:.2f} {vbh:.2f}" '
f'id="dxf-svg" style="width:100%;height:100%;background:#080f1e">'
]
# grid
grid_step = max(100, round((vbw+vbh)/2 / 10 / 100)*100)
parts.append(f'<defs><pattern id="grid" width="{grid_step}" height="{grid_step}" '
f'patternUnits="userSpaceOnUse" patternTransform="translate(0,0)">'
f'<path d="M {grid_step} 0 L 0 0 0 {grid_step}" fill="none" '
f'stroke="#1e293b" stroke-width="0.5"/></pattern></defs>')
parts.append(f'<rect x="{vbx:.2f}" y="{vby:.2f}" width="{vbw:.2f}" height="{vbh:.2f}" fill="url(#grid)"/>')
skip = {"Defpoints", "defpoints"}
for layer, els in by_layer.items():
if layer in skip:
continue
safe = layer.replace(" ","_").replace("/","_").replace("(","").replace(")","")
col = layer_style(layer)["color"]
parts.append(f'<g id="L-{safe}" data-layer="{layer}" data-color="{col}">')
parts += els
parts.append('</g>')
parts.append('</svg>')
return "\n".join(parts)
# ── DXF ANALYSIS ─────────────────────────────────────────────────────────────
def analyze_dxf(filepath: str) -> dict:
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
res = {"file": Path(filepath).name, "layers": [], "entities_count": {},
"texts": [], "geometry": {"polylines": [], "circles": []}, "summary": {}}
for layer in doc.layers:
res["layers"].append(layer.dxf.name)
for entity in msp:
et = entity.dxftype()
res["entities_count"][et] = res["entities_count"].get(et, 0) + 1
try:
if et == "TEXT":
v = entity.dxf.text.strip()
if v: res["texts"].append({"text": v, "layer": entity.dxf.layer})
elif et == "MTEXT":
v = entity.text.strip()
if v: res["texts"].append({"text": v, "layer": entity.dxf.layer})
elif et == "LWPOLYLINE":
pts = list(entity.get_points())
if pts:
xs = [p[0] for p in pts]; ys = [p[1] for p in pts]
w, h = max(xs)-min(xs), max(ys)-min(ys)
res["geometry"]["polylines"].append({
"layer": entity.dxf.layer,
"bbox_width_mm": round(w,2), "bbox_height_mm": round(h,2),
"area_m2": round(w*h/1e6, 4),
})
elif et == "CIRCLE":
res["geometry"]["circles"].append({
"layer": entity.dxf.layer, "radius_mm": round(entity.dxf.radius,2)
})
except Exception:
continue
cadru = [p for p in res["geometry"]["polylines"] if "CADRU" in p["layer"].upper()]
sticla = [p for p in res["geometry"]["polylines"] if "STICLA" in p["layer"].upper()]
if not cadru:
cadru = sorted(res["geometry"]["polylines"], key=lambda p: p["area_m2"], reverse=True)[:1]
if cadru:
f = max(cadru, key=lambda p: p["area_m2"])
res["summary"]["latime_mm"] = f["bbox_width_mm"]
res["summary"]["inaltime_mm"] = f["bbox_height_mm"]
res["summary"]["suprafata_bruta_m2"] = f["area_m2"]
res["summary"]["perimetru_m"] = round(2*(f["bbox_width_mm"]+f["bbox_height_mm"])/1000, 3)
if sticla:
g = max(sticla, key=lambda p: p["area_m2"])
res["summary"]["sticla_w_mm"] = g["bbox_width_mm"]
res["summary"]["sticla_h_mm"] = g["bbox_height_mm"]
res["summary"]["sticla_m2"] = g["area_m2"]
for t in res["texts"]:
tx = t["text"]
if "PROFIL:" in tx.upper(): res["summary"]["profil"] = tx
if "STICLA:" in tx.upper(): res["summary"]["tip_sticla"] = tx
if any(k in tx.upper() for k in ["TIP ", "FEREASTRA", "USA"]):
res["summary"]["tip"] = tx
return res
def fmt_context(a: dict) -> str:
s = a.get("summary", {})
return "\n".join([
f"=== DXF: {a['file']} ===",
f"Tip: {s.get('tip','?')}",
f"Dimensiuni: {s.get('latime_mm','?')} x {s.get('inaltime_mm','?')} mm",
f"Suprafata bruta: {s.get('suprafata_bruta_m2','?')} m2",
f"Perimetru: {s.get('perimetru_m','?')} m",
f"Sticla: {s.get('sticla_w_mm','?')} x {s.get('sticla_h_mm','?')} mm = {s.get('sticla_m2','?')} m2",
f"Profil: {s.get('profil','?')}",
f"Tip sticla: {s.get('tip_sticla','?')}",
"",
"Texte din desen: " + " | ".join(t["text"] for t in a.get("texts",[])),
f"Layere: {', '.join(a.get('layers',[]))}",
])
# ── LLM (Nvidia via LiteLLM) ──────────────────────────────────────────────────
async def ask_ollama(context: str, message: str) -> str:
system = f"""Esti Agent DXF pentru Dorafort, producator de ferestre si usi din lemn stratificat.
Ai urmatoarea analiza a unui desen DXF:
{context}
Raspunde in romana, concis si precis. Poti calcula cantitati de profil, suprafete sticla, liste materiale."""
async with httpx.AsyncClient(timeout=60) as c:
r = await c.post(
f"{LITELLM_URL}/v1/chat/completions",
headers={"Authorization": f"Bearer {LITELLM_KEY}"},
json={
"model": MODEL,
"messages": [{"role":"system","content":system},{"role":"user","content":message}],
"temperature": 0.1,
"max_tokens": 800,
}
)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
# ── ENDPOINTS ─────────────────────────────────────────────────────────────────
@app.post("/upload")
async def upload(file: UploadFile = File(...)):
if not file.filename.lower().endswith(".dxf"):
raise HTTPException(400, "Fisierul trebuie sa fie .dxf")
sid = str(uuid.uuid4())
path = UPLOAD_DIR / f"{sid}.dxf"
path.write_bytes(await file.read())
try:
analysis = analyze_dxf(str(path))
svg = dxf_to_svg(str(path))
except Exception as e:
path.unlink(missing_ok=True)
raise HTTPException(400, f"Eroare DXF: {e}")
sessions[sid] = {"context": fmt_context(analysis), "svg": svg,
"analysis": analysis, "filename": file.filename}
return {"session_id": sid, "analysis": analysis["summary"],
"layers": [l for l in analysis["layers"] if l not in ("Defpoints","0")],
"filename": file.filename}
@app.get("/preview/{sid}")
async def preview(sid: str):
s = sessions.get(sid)
if not s:
raise HTTPException(404, "Sesiune invalida")
return Response(content=s["svg"], media_type="image/svg+xml")
class ChatReq(BaseModel):
session_id: str
message: str
@app.post("/chat")
async def chat(req: ChatReq):
s = sessions.get(req.session_id)
if not s:
raise HTTPException(404, "Sesiune invalida — re-uploadati DXF-ul")
answer = await ask_ollama(s["context"], req.message)
return {"answer": answer}
@app.get("/health")
async def health():
return {"status":"ok","model":MODEL,"provider":"nvidia-nim","sessions":len(sessions)}
# ── UI ────────────────────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def ui():
return r"""<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Agent DXF — Dorafort</title>
<style>
:root{
--bg:#080f1e;--panel:#0d1b2e;--border:#1e3a5f;
--accent:#38bdf8;--accent2:#818cf8;--green:#4ade80;
--text:#e2e8f0;--muted:#64748b;--warn:#fb923c;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:var(--bg);color:var(--text);
height:100vh;display:flex;flex-direction:column;overflow:hidden}
/* ── TOP BAR ── */
header{display:flex;align-items:center;gap:1rem;padding:.6rem 1.25rem;
background:var(--panel);border-bottom:1px solid var(--border);
flex-shrink:0;z-index:10}
header h1{font-size:.95rem;font-weight:700;color:var(--accent);letter-spacing:.04em}
header span{font-size:.75rem;color:var(--muted)}
.dot{width:7px;height:7px;border-radius:50%;background:var(--green);
box-shadow:0 0 6px var(--green);margin-left:auto}
/* ── LAYOUT ── */
main{display:flex;flex:1;overflow:hidden}
/* ── LEFT PANEL ── */
aside{width:360px;flex-shrink:0;display:flex;flex-direction:column;
border-right:1px solid var(--border);background:var(--panel)}
.upload-area{padding:1rem;border-bottom:1px solid var(--border)}
.drop-zone{border:2px dashed var(--border);border-radius:10px;padding:1.5rem;
text-align:center;cursor:pointer;transition:.2s;position:relative}
.drop-zone:hover,.drop-zone.over{border-color:var(--accent);background:#0a1929}
.drop-zone input{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%}
.drop-icon{font-size:2rem;margin-bottom:.4rem}
.drop-zone label{color:var(--accent);font-weight:600;font-size:.875rem;cursor:pointer}
.drop-zone p{color:var(--muted);font-size:.75rem;margin-top:.25rem}
#fname{font-size:.75rem;color:var(--accent2);margin-top:.5rem;text-align:center;min-height:1em}
/* metrics */
.metrics{padding:.75rem 1rem;border-bottom:1px solid var(--border)}
.metrics h2{font-size:.7rem;text-transform:uppercase;letter-spacing:.06em;
color:var(--muted);margin-bottom:.6rem}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:.5rem}
.metric{background:#0a1929;border:1px solid var(--border);border-radius:8px;
padding:.6rem .8rem;transition:.3s}
.metric .lbl{font-size:.68rem;color:var(--muted);margin-bottom:.2rem}
.metric .val{font-size:1.05rem;font-weight:700;color:var(--accent)}
.metric.green .val{color:var(--green)}
.metric.warn .val{color:var(--warn)}
.metric.purple .val{color:var(--accent2)}
/* chat */
.chat-wrap{flex:1;display:flex;flex-direction:column;overflow:hidden;padding:.75rem 1rem}
.chat-wrap h2{font-size:.7rem;text-transform:uppercase;letter-spacing:.06em;
color:var(--muted);margin-bottom:.6rem;flex-shrink:0}
.messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:.6rem;
padding-right:.25rem;scrollbar-width:thin;scrollbar-color:var(--border) transparent}
.msg{padding:.6rem .9rem;border-radius:10px;font-size:.82rem;line-height:1.5;max-width:100%;animation:fadeIn .2s}
.msg.user{background:#1d4ed8;align-self:flex-end;border-radius:10px 10px 2px 10px}
.msg.agent{background:#0a1929;border:1px solid var(--border);white-space:pre-wrap}
.msg.thinking{color:var(--muted);font-style:italic}
@keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.chat-input{display:flex;gap:.4rem;margin-top:.6rem;flex-shrink:0}
.chat-input input{flex:1;background:#0a1929;border:1px solid var(--border);
border-radius:7px;padding:.5rem .75rem;color:var(--text);
font-size:.82rem;outline:none;transition:.2s}
.chat-input input:focus{border-color:var(--accent)}
.chat-input input:disabled{opacity:.5}
.btn{background:var(--accent);color:#000;border:none;padding:.5rem .9rem;
border-radius:7px;font-weight:700;cursor:pointer;font-size:.8rem;
transition:.15s;white-space:nowrap}
.btn:hover{background:#7dd3fc}
.btn:disabled{background:var(--border);color:var(--muted);cursor:not-allowed}
/* ── RIGHT — PREVIEW ── */
.preview-wrap{flex:1;display:flex;flex-direction:column;overflow:hidden}
.preview-toolbar{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;
background:var(--panel);border-bottom:1px solid var(--border);
flex-wrap:wrap;flex-shrink:0}
.tool-btn{background:transparent;border:1px solid var(--border);color:var(--text);
border-radius:6px;padding:.3rem .6rem;font-size:.72rem;cursor:pointer;transition:.15s}
.tool-btn:hover{background:var(--border)}
.tool-btn.active{background:var(--accent);border-color:var(--accent);color:#000;font-weight:600}
.sep{width:1px;height:18px;background:var(--border);margin:0 .25rem}
.layer-btn{border-radius:20px;padding:.25rem .7rem;font-size:.7rem}
.layer-btn.off{opacity:.35;text-decoration:line-through}
#zoom-level{font-size:.72rem;color:var(--muted);margin-left:auto}
.canvas{flex:1;position:relative;overflow:hidden;cursor:grab;background:var(--bg)}
.canvas:active{cursor:grabbing}
#svg-container{position:absolute;inset:0;transition:none;transform-origin:0 0}
#svg-container svg{display:block;width:100%;height:100%}
/* empty state */
.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;
height:100%;gap:.75rem;color:var(--muted);user-select:none}
.empty .icon{font-size:4rem;opacity:.2}
.empty p{font-size:.85rem}
</style>
</head>
<body>
<header>
<h1>Agent DXF</h1>
<span>Dorafort — Ferestre &amp; Uși</span>
<div class="dot" id="status-dot"></div>
</header>
<main>
<!-- LEFT PANEL -->
<aside>
<div class="upload-area">
<div class="drop-zone" id="dz">
<input type="file" id="file-in" accept=".dxf">
<div class="drop-icon">📐</div>
<label for="file-in">Alege fișier DXF</label>
<p>sau trage aici</p>
</div>
<div id="fname"></div>
</div>
<div class="metrics" id="metrics-wrap" style="display:none">
<h2>Analiza</h2>
<div class="grid" id="metrics-grid"></div>
</div>
<div class="chat-wrap">
<h2>Chat</h2>
<div class="messages" id="msgs">
<div class="msg agent">Incarca un fisier DXF pentru a incepe analiza.</div>
</div>
<div class="chat-input">
<input id="user-in" placeholder="Intreaba despre desen..." disabled>
<button class="btn" id="send-btn" onclick="send()" disabled>▶</button>
</div>
</div>
</aside>
<!-- RIGHT PANEL — PREVIEW -->
<div class="preview-wrap">
<div class="preview-toolbar" id="toolbar" style="display:none">
<button class="tool-btn" onclick="zoom(1.25)">+</button>
<button class="tool-btn" onclick="zoom(0.8)"></button>
<button class="tool-btn" onclick="fitView()">⊡ Fit</button>
<div class="sep"></div>
<span style="font-size:.7rem;color:var(--muted)">Layere:</span>
<div id="layer-btns"></div>
<span id="zoom-level">100%</span>
</div>
<div class="canvas" id="canvas">
<div class="empty" id="empty-state">
<div class="icon">🗂️</div>
<p>Incarca un fisier DXF<br>pentru previzualizare</p>
</div>
<div id="svg-container" style="display:none"></div>
</div>
</div>
</main>
<script>
// ── STATE ──
let sid = null, svgEl = null;
let tx = 0, ty = 0, scale = 1;
let dragging = false, lastX = 0, lastY = 0;
let naturalW = 0, naturalH = 0;
// ── UPLOAD ──
const dz = document.getElementById('dz');
const fi = document.getElementById('file-in');
dz.addEventListener('dragover', e=>{e.preventDefault();dz.classList.add('over')});
dz.addEventListener('dragleave', ()=>dz.classList.remove('over'));
dz.addEventListener('drop', e=>{e.preventDefault();dz.classList.remove('over');
if(e.dataTransfer.files[0]) doUpload(e.dataTransfer.files[0])});
fi.addEventListener('change', ()=>{ if(fi.files[0]) doUpload(fi.files[0]) });
async function doUpload(file){
document.getElementById('fname').textContent = '' + file.name;
const form = new FormData();
form.append('file', file);
try{
const r = await fetch('/upload',{method:'POST',body:form});
if(!r.ok){ const e=await r.json(); throw new Error(e.detail); }
const d = await r.json();
sid = d.session_id;
document.getElementById('fname').textContent = '' + d.filename;
showMetrics(d.analysis);
showLayers(d.layers);
await loadPreview(sid);
enableChat();
addMsg('agent', `Desenul <strong>${d.filename}</strong> a fost analizat ✅\nPune întrebări despre dimensiuni, cantități de profil, suprafețe de sticlă sau orice detaliu tehnic.`);
} catch(e){
document.getElementById('fname').textContent = '✗ Eroare: ' + e.message;
}
}
// ── METRICS ──
function showMetrics(s){
const wrap = document.getElementById('metrics-wrap');
const grid = document.getElementById('metrics-grid');
wrap.style.display = '';
const items = [
['Lățime', s.latime_mm ? s.latime_mm+' mm' : '', ''],
['Înălțime', s.inaltime_mm ? s.inaltime_mm+' mm' : '', ''],
['Sup. brută', s.suprafata_bruta_m2 ? s.suprafata_bruta_m2+'' : '', 'purple'],
['Sup. sticlă', s.sticla_m2 ? s.sticla_m2+'' : '', 'green'],
['Perimetru', s.perimetru_m ? s.perimetru_m+' m' : '', 'warn'],
['Tip', s.tip || '', ''],
];
grid.innerHTML = items.map(([l,v,c])=>
`<div class="metric ${c}"><div class="lbl">${l}</div><div class="val">${v}</div></div>`
).join('');
}
// ── PREVIEW ──
async function loadPreview(sid){
const container = document.getElementById('svg-container');
const empty = document.getElementById('empty-state');
const toolbar = document.getElementById('toolbar');
const r = await fetch(`/preview/${sid}`);
const svgText = await r.text();
container.innerHTML = svgText;
svgEl = container.querySelector('svg');
// measure natural size from viewBox
const vb = svgEl.getAttribute('viewBox')?.split(' ').map(Number);
if(vb){ naturalW = vb[2]; naturalH = vb[3]; }
empty.style.display = 'none';
container.style.display = 'block';
toolbar.style.display = 'flex';
fitView();
setupPanZoom();
}
// ── LAYER TOGGLES ──
function showLayers(layers){
const COLORS = {CADRU:'#e2e8f0',STICLA:'#38bdf8',PROFIL:'#4ade80',
FERONERIE:'#fb923c',COTE:'#fbbf24',TEXT:'#94a3b8'};
const btns = document.getElementById('layer-btns');
btns.innerHTML = '';
layers.filter(l=>l!='0'&&l!='Defpoints').forEach(layer=>{
const col = Object.entries(COLORS).find(([k])=>k.toUpperCase()===layer.toUpperCase())?.[1] || '#94a3b8';
const b = document.createElement('button');
b.className = 'tool-btn layer-btn active';
b.style.borderColor = col; b.style.color = col;
b.textContent = layer;
b.dataset.layer = layer;
b.onclick = ()=>toggleLayer(b, layer, col);
btns.appendChild(b);
});
}
function toggleLayer(btn, layer, col){
const svg = document.getElementById('svg-container').querySelector('svg');
if(!svg) return;
const safe = layer.replace(/[\s\/()]/g,'_');
const g = svg.getElementById('L-'+safe);
if(!g) return;
const isOn = btn.classList.contains('active');
btn.classList.toggle('active', !isOn);
btn.classList.toggle('off', isOn);
g.style.display = isOn ? 'none' : '';
}
// ── PAN / ZOOM ──
function setupPanZoom(){
const canvas = document.getElementById('canvas');
canvas.addEventListener('wheel', e=>{
e.preventDefault();
const f = e.deltaY < 0 ? 1.12 : 0.89;
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
tx = mx - (mx - tx)*f;
ty = my - (my - ty)*f;
scale *= f;
applyTransform();
}, {passive:false});
canvas.addEventListener('mousedown', e=>{dragging=true;lastX=e.clientX;lastY=e.clientY});
canvas.addEventListener('mousemove', e=>{
if(!dragging) return;
tx += e.clientX - lastX; ty += e.clientY - lastY;
lastX = e.clientX; lastY = e.clientY;
applyTransform();
});
canvas.addEventListener('mouseup', ()=>dragging=false);
canvas.addEventListener('mouseleave',()=>dragging=false);
// touch
let lastDist = 0, touches = [];
canvas.addEventListener('touchstart', e=>{
touches = Array.from(e.touches);
if(touches.length===2) lastDist = dist(touches[0],touches[1]);
});
canvas.addEventListener('touchmove', e=>{
e.preventDefault();
const t = Array.from(e.touches);
if(t.length===1&&touches.length===1){
tx += t[0].clientX-touches[0].clientX;
ty += t[0].clientY-touches[0].clientY;
} else if(t.length===2){
const d = dist(t[0],t[1]);
if(lastDist) scale *= d/lastDist;
lastDist = d;
}
touches = t; applyTransform();
},{passive:false});
}
function dist(a,b){ return Math.hypot(a.clientX-b.clientX, a.clientY-b.clientY) }
function applyTransform(){
const c = document.getElementById('svg-container');
c.style.transform = `translate(${tx}px,${ty}px) scale(${scale})`;
document.getElementById('zoom-level').textContent = Math.round(scale*100)+'%';
}
function zoom(f){
const canvas = document.getElementById('canvas');
const cx = canvas.clientWidth/2, cy = canvas.clientHeight/2;
tx = cx - (cx-tx)*f; ty = cy - (cy-ty)*f;
scale *= f; applyTransform();
}
function fitView(){
const canvas = document.getElementById('canvas');
const cw = canvas.clientWidth, ch = canvas.clientHeight;
if(!naturalW || !naturalH) return;
scale = Math.min(cw/naturalW, ch/naturalH) * 0.92;
tx = (cw - naturalW*scale)/2;
ty = (ch - naturalH*scale)/2;
applyTransform();
}
// ── CHAT ──
function enableChat(){
document.getElementById('user-in').disabled = false;
document.getElementById('send-btn').disabled = false;
document.getElementById('user-in').focus();
}
function addMsg(role, html){
const div = document.createElement('div');
div.className = 'msg '+role;
div.innerHTML = html;
const msgs = document.getElementById('msgs');
msgs.appendChild(div);
msgs.scrollTop = msgs.scrollHeight;
return div;
}
async function send(){
const inp = document.getElementById('user-in');
const msg = inp.value.trim();
if(!msg||!sid) return;
inp.value = '';
addMsg('user', msg);
const thinking = addMsg('agent thinking', '⏳ Se gândește...');
document.getElementById('send-btn').disabled = true;
inp.disabled = true;
try{
const r = await fetch('/chat',{method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({session_id:sid, message:msg})
});
const d = await r.json();
thinking.className='msg agent';
thinking.innerHTML = (d.answer||d.detail||'Eroare').replace(/\n/g,'<br>');
} catch(e){
thinking.innerHTML = ''+e.message;
}
document.getElementById('send-btn').disabled = false;
inp.disabled = false;
inp.focus();
}
document.getElementById('user-in').addEventListener('keydown', e=>{
if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send()}
});
// resize fit
window.addEventListener('resize', ()=>{ if(svgEl) fitView() });
</script>
</body>
</html>"""