705 lines
28 KiB
Python
705 lines
28 KiB
Python
#!/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("&","&").replace("<","<").replace(">",">")
|
||
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 & 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+' m²' : '—', 'purple'],
|
||
['Sup. sticlă', s.sticla_m2 ? s.sticla_m2+' m²' : '—', '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>"""
|