"""Local Flask server that hosts the chat-CAD app. Run: python app.py Then visit http://127.0.0.1:5000 The Anthropic API key can be supplied via the ANTHROPIC_API_KEY env var or typed into the UI's settings panel. Without a key the regex parser is used. """ from __future__ import annotations import os import threading import webbrowser from flask import Flask, jsonify, request, send_file, send_from_directory from cad_engine import CadEngine from llm import run_claude, run_parser HERE = os.path.dirname(os.path.abspath(__file__)) def _is_writable(d: str) -> bool: # os.access(W_OK) is unreliable on Windows (ignores ACLs). Do a real probe. try: os.makedirs(d, exist_ok=True) probe = os.path.join(d, ".chatcad_write_probe.tmp") with open(probe, "w") as f: f.write("") os.remove(probe) return True except OSError: return False _default_output = os.path.join(HERE, "output") if not _is_writable(_default_output): # Installed location (e.g. C:\Program Files\ChatCAD) — route outputs to user appdata. _user_base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~") _default_output = os.path.join(_user_base, "ChatCAD", "output") OUTPUT = os.environ.get("CHATCAD_OUTPUT", _default_output) app = Flask(__name__, template_folder="templates", static_folder="static") engine = CadEngine(OUTPUT) chat_history: list[dict] = [] # Claude conversation history gemini_history: list[dict] = [] # Gemini conversation history (separate format) ollama_history: list[dict] = [] # Ollama conversation history _lock = threading.Lock() DEFAULT_MODEL = "claude-opus-4-7" DEFAULT_GEMINI_MODEL = "gemini-2.0-flash" def _detect_backend(api_key: str, model: str = "") -> str: """Return 'anthropic', 'gemini', 'ollama', or 'none' based on the model name (ollama models are prefixed 'ollama:') and the API key prefix. """ if model and model.startswith("ollama:"): return "ollama" if not api_key: return "none" if api_key.startswith("sk-ant-"): return "anthropic" if api_key.startswith("AIza"): return "gemini" return "anthropic" # default fallback for unknown formats def _refresh_stl() -> None: """Mark the combined scene.stl as stale; do NOT regenerate it now. Multi-stage engine builds create 30+ sub-parts; rebuilding a combined STL after every command was costing 3-5 minutes and timing out the browser. The frontend fetches per-part STLs lazily via /part/.stl, so we only need scene.stl when someone actually downloads /scene.stl — generate then. """ path = os.path.join(OUTPUT, "scene.stl") try: if os.path.exists(path): os.remove(path) except Exception: pass @app.route("/") def index(): return send_from_directory(app.template_folder, "index.html") @app.route("/scene.stl") def scene_stl(): path = os.path.join(OUTPUT, "scene.stl") if not os.path.exists(path): _refresh_stl() return send_file(path, mimetype="model/stl") @app.route("/scene/manifest") def scene_manifest(): with _lock: return jsonify({"parts": engine.manifest()}) @app.route("/features") def features_list(): """Return per-part creation command (feature tree).""" with _lock: feats = getattr(engine, "features", {}) or {} return jsonify({ "features": [ {"name": n, "cmd": feats.get(n, "")} for n in engine.parts.keys() ] }) @app.route("/edit_feature", methods=["POST"]) def edit_feature(): """Replace a part by re-running an edited creation command. Body: { "name": "", "cmd": "" } The new cmd must reference the same name as arg-0 (otherwise we leave a stale duplicate). Returns the parser reply. """ data = request.get_json(force=True) name = (data.get("name") or "").strip() new_cmd = (data.get("cmd") or "").strip() if not name or not new_cmd: return jsonify({"ok": False, "error": "name and cmd required"}), 400 with _lock: if name not in engine.parts: return jsonify({"ok": False, "error": f"no part '{name}'"}), 404 # delete old part so a clean rebuild happens try: engine.delete(name) except Exception: pass reply = run_parser(engine, new_cmd) ok = name in engine.parts _refresh_stl() return jsonify({"ok": ok, "reply": reply, "parts": engine.list_parts()}) @app.route("/part/.stl") def part_stl(name: str): with _lock: try: path = engine.export_part_stl(name) except KeyError: return ("no such part", 404) return send_file(path, mimetype="model/stl") @app.route("/part//volume") def part_volume(name: str): with _lock: if name not in engine.parts: return jsonify({"error": f"no part named '{name}'"}), 404 try: shape = engine.parts[name].val() vol = float(shape.Volume()) bb = shape.BoundingBox() bbox = [bb.xmin, bb.ymin, bb.zmin, bb.xmax, bb.ymax, bb.zmax] except Exception as e: return jsonify({"error": str(e)}), 500 return jsonify({"name": name, "volume_mm3": vol, "bbox": bbox}) @app.route("/chat", methods=["POST"]) def chat(): data = request.get_json(force=True) message = (data.get("message") or "").strip() api_key = (data.get("api_key") or os.environ.get("ANTHROPIC_API_KEY") or "").strip() model = (data.get("model") or DEFAULT_MODEL).strip() if not message: return jsonify({"reply": "(empty message)", "ops": [], "parts": engine.list_parts()}) force_parser = bool(data.get("force_parser")) backend = "parser" if force_parser else _detect_backend(api_key, model) # ── Direct command passthrough ───────────────────────────────────────── # If the message's FIRST token is a known parser command (e.g. # "car my_car style=sedan", "box b1 30 20 10"), execute it verbatim # through the parser regardless of which model is selected. This guarantees # explicit commands build deterministically instead of being re-interpreted # (or just chatted about) by an LLM backend. try: from llm_ollama import _PARSER_FIRST_TOKENS as _CMD_TOKENS except Exception: _CMD_TOKENS = set() _first_tok = message.split(None, 1)[0].lower() if message else "" if backend != "parser" and _first_tok in _CMD_TOKENS: with _lock: reply = run_parser(engine, message) _refresh_stl() return jsonify({"reply": reply, "ops": [], "parts": engine.list_parts(), "backend": "parser (command passthrough)"}) with _lock: if backend == "parser": reply = run_parser(engine, message) ops = [] _refresh_stl() return jsonify({"reply": reply, "ops": ops, "parts": engine.list_parts(), "backend": "parser"}) if backend == "ollama": try: from llm_ollama import run_ollama ollama_model = model[len("ollama:"):] reply, ops = run_ollama(ollama_model, ollama_history, engine, message) except Exception as e: reply = f"Ollama call failed: {e}\nFalling back to parser.\n\n" + run_parser(engine, message) ops = [] elif backend == "anthropic": try: from anthropic import Anthropic client = Anthropic(api_key=api_key) reply, ops = run_claude(client, model, chat_history, engine, message) except Exception as e: reply = f"Claude call failed: {e}\nFalling back to parser.\n\n" + run_parser(engine, message) ops = [] elif backend == "gemini": try: from llm_gemini import run_gemini gmodel = model if model.startswith("gemini") else DEFAULT_GEMINI_MODEL reply, ops = run_gemini(api_key, gmodel, gemini_history, engine, message) except Exception as e: reply = f"Gemini call failed: {e}\nFalling back to parser.\n\n" + run_parser(engine, message) ops = [] else: reply = run_parser(engine, message) ops = [] _refresh_stl() return jsonify({"reply": reply, "ops": ops, "parts": engine.list_parts(), "backend": backend}) @app.route("/sketches") def list_sketches(): with _lock: names = list(engine.sketches.sketches.keys()) info = {n: engine.sketches.info(n) for n in names} return jsonify({"names": names, "info": info}) @app.route("/sketch/.svg") def sketch_svg(name: str): with _lock: if name not in engine.sketches.sketches: return ("sketch not found", 404) svg = engine.sketches.svg(name) return (svg, 200, {"Content-Type": "image/svg+xml"}) @app.route("/assemblies") def list_assemblies(): with _lock: names = list(engine.assemblies.assemblies.keys()) info = {n: engine.assemblies.info(n) for n in names} return jsonify({"names": names, "info": info}) @app.route("/parts") def list_parts(): with _lock: return jsonify({"text": engine.list_parts()}) @app.route("/import/step", methods=["POST"]) def import_step_endpoint(): """Upload a STEP file and add it to the scene as a named part.""" name = (request.form.get("name") or "").strip() if not name: return jsonify({"error": "name is required"}), 400 f = request.files.get("file") if f is None or not f.filename: return jsonify({"error": "no file uploaded"}), 400 safe = "".join(c if c.isalnum() or c in "._-" else "_" for c in f.filename) tmp_path = os.path.join(OUTPUT, "uploads") os.makedirs(tmp_path, exist_ok=True) saved = os.path.join(tmp_path, safe) f.save(saved) with _lock: try: msg = engine.step_io.step_import(name, saved) _refresh_stl() except Exception as e: return jsonify({"error": str(e)}), 400 return jsonify({"ok": True, "reply": msg, "parts": engine.list_parts()}) @app.route("/drawing/.pdf") def drawing_pdf(name: str): """Download an A4 4-view engineering drawing PDF for one part.""" from drawings import export_drawing with _lock: if name not in engine.parts: return jsonify({"error": f"no part '{name}'"}), 404 try: path = os.path.join(OUTPUT, f"drawing_{name}.pdf") export_drawing(engine, name, path) except Exception as e: return jsonify({"error": str(e)}), 500 return send_file(path, as_attachment=True, download_name=f"{name}.pdf") @app.route("/knowledge/list") def knowledge_list(): with _lock: return jsonify({"notes": engine.knowledge.list_notes()}) @app.route("/knowledge/add", methods=["POST"]) def knowledge_add(): data = request.get_json(force=True) text = (data.get("text") or "").strip() tags = data.get("tags") or [] if not text: return jsonify({"error": "text is required"}), 400 with _lock: nid = engine.knowledge.add(text, tags=tags, source="manual") return jsonify({"ok": True, "id": nid}) @app.route("/knowledge/remove/", methods=["POST"]) def knowledge_remove(note_id: str): with _lock: ok = engine.knowledge.remove(note_id) return jsonify({"ok": ok}) @app.route("/knowledge/search") def knowledge_search(): q = (request.args.get("q") or "").strip() with _lock: hits = engine.knowledge.search(q, k=10) return jsonify({"hits": hits}) @app.route("/drawings.pdf") def drawings_all_pdf(): """Multi-page drawing PDF: one page per part in the scene.""" from drawings import export_drawings_all with _lock: if not engine.parts: return jsonify({"error": "scene is empty"}), 400 path = os.path.join(OUTPUT, "drawings.pdf") try: export_drawings_all(engine, path) except Exception as e: return jsonify({"error": str(e)}), 500 return send_file(path, as_attachment=True, download_name="drawings.pdf") @app.route("/agent/design", methods=["POST"]) def agent_design(): """Run the multi-agent design loop (planner -> modeler -> visual critic). Requires an Anthropic API key (visual critic needs Claude vision). """ data = request.get_json(force=True) brief = (data.get("brief") or "").strip() api_key = (data.get("api_key") or os.environ.get("ANTHROPIC_API_KEY") or "").strip() model = (data.get("model") or DEFAULT_MODEL).strip() max_revises = int(data.get("max_revises", 2)) if not brief: return jsonify({"error": "brief is required"}), 400 if not api_key: return jsonify({"error": "API key required for the design agent " "(visual critic needs Claude vision)"}), 400 with _lock: try: from anthropic import Anthropic from agents import design_loop client = Anthropic(api_key=api_key) events = design_loop(client, model, engine, brief, max_revises_per_milestone=max_revises) _refresh_stl() return jsonify({"events": events, "parts": engine.list_parts()}) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/reset", methods=["POST"]) def reset(): with _lock: engine.clear() chat_history.clear() gemini_history.clear() ollama_history.clear() _refresh_stl() return jsonify({"ok": True}) @app.route("/tool/run", methods=["POST"]) def tool_run(): """Run a single named operation. Used by the in-browser WebLLM backend so the browser-side LLM can call our CadQuery tools without going through the full /chat loop (which lives on the server). """ data = request.get_json(force=True) op = (data.get("op") or "").strip() args = data.get("args") or {} if not op: return jsonify({"error": "op is required"}), 400 with _lock: try: from cad_engine import dispatch result = dispatch(engine, op, dict(args)) _refresh_stl() return jsonify({"ok": True, "result": str(result), "parts": engine.list_parts()}) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 400 @app.route("/tools/list") def tools_list(): """Return the full Anthropic-style TOOLS array so the in-browser LLM can register them as function declarations. """ from llm import TOOLS return jsonify({"tools": TOOLS}) @app.route("/fea/stress_plot/.obj") def fea_stress_plot(name: str): """Serve the colored OBJ generated by the last stress analysis for .""" candidates = [ os.path.join(OUTPUT, f"part_{name}_stress.obj"), os.path.join(OUTPUT, f"{name}_stress.obj"), ] for path in candidates: if os.path.exists(path): return send_file(path, mimetype="text/plain") return ("no stress plot yet — run 'stress' first", 404) @app.route("/drivaer/runs") def drivaer_runs(): """List the real DrivAer cars available locally.""" try: import drivaer_realviz as RV return jsonify({"runs": RV.list_runs(".")}) except Exception as e: return jsonify({"runs": [], "error": str(e)}) @app.route("/car/realistic_solid.obj") def car_realistic_solid(): """Generate a realistic car as a SOLID watertight mesh (reconstructed from a morphed real DrivAer baseline) and return it as a vertex-coloured OBJ the viewer renders as a shaded painted body. Same query params as /car/realistic, plus res (voxel resolution, default 90).""" def _q(name, default): try: v = request.args.get(name) return float(v) if v is not None and v != "" else default except ValueError: return default color = request.args.get("color") obj_path = os.path.join(OUTPUT, "realistic_car.obj") d = None # Preferred: morph the REAL DrivAer CAD mesh (clean, sharp - the article's # method). Falls back to voxel reconstruction only if the CAD mesh isn't # available locally. try: import drivaer_meshgen as MG if MG.available(): d = MG.generate(length_mm=_q("length", 4700.0), width_mm=_q("width", 1900.0), height_mm=_q("height", 1450.0), roof=_q("roof", 0.0), nose=_q("nose", 0.0), rake=_q("rake", 0.0), target_cd=_q("cd", None)) if d is not None: MG.colored_obj(d, obj_path, color=color) except Exception: d = None try: import vehicle_realgen as G if d is None: # fallback: voxel reconstruction d = G.generate_solid( ".", length_mm=_q("length", 4700.0), width_mm=_q("width", 1900.0), height_mm=_q("height", 1450.0), roof=_q("roof", 0.0), nose=_q("nose", 0.0), rake=_q("rake", 0.0), target_cd=_q("cd", None), res=int(_q("res", 150))) if d is None: return ("no real DrivAer baselines; run fetch_drivaernet_pointclouds.py", 404) G.solid_obj(d, obj_path, color=color) from flask import Response with open(obj_path) as fh: txt = fh.read() resp = Response(txt, mimetype="text/plain") resp.headers["X-Car-Baseline"] = str(d.get("baseline")) pred = G.predict_cd_of_cloud(d["points_mm"]) if pred is not None: resp.headers["X-Car-Pred-Cd"] = f"{pred:.4f}" if d.get("baseline_cd") is not None: resp.headers["X-Car-Baseline-Cd"] = f"{d['baseline_cd']:.4f}" return resp except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/car/realistic") def car_realistic(): """Generate a REALISTIC car by morphing the closest real DrivAer baseline to the requested dimensions/shape, return it as a binary point cloud (mm, Z-up) coloured by real Cp. Query: length,width,height (mm), roof,nose,rake [-1..1], cd (target Cd to pick the baseline).""" from flask import Response def _q(name, default): try: v = request.args.get(name) return float(v) if v is not None and v != "" else default except ValueError: return default try: import vehicle_realgen as G d = G.generate( ".", length_mm=_q("length", 4700.0), width_mm=_q("width", 1900.0), height_mm=_q("height", 1450.0), roof=_q("roof", 0.0), nose=_q("nose", 0.0), rake=_q("rake", 0.0), target_cd=_q("cd", None), color=request.args.get("color")) if d is None: return jsonify({"error": "no real DrivAer baselines; run " "fetch_drivaernet_pointclouds.py"}), 404 pred = G.predict_cd_of_cloud(d["points_mm"]) resp = Response(d["buffer"], mimetype="application/octet-stream") resp.headers["X-Car-Baseline"] = str(d.get("baseline")) if d.get("baseline_cd") is not None: resp.headers["X-Car-Baseline-Cd"] = f"{d['baseline_cd']:.4f}" if pred is not None: resp.headers["X-Car-Pred-Cd"] = f"{pred:.4f}" resp.headers["X-Car-N"] = str(d.get("n")) return resp except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/drivaer/cloud") def drivaer_cloud(): """Return a real DrivAer car as a binary point-cloud buffer (mm, Z-up), coloured by real CFD surface pressure (Cp). Query: run=run_15, color=cp|flat, max=60000. This is the actual DrivAerNet geometry, not the CVAE body.""" from flask import Response run = request.args.get("run") or None color = request.args.get("color", "cp") try: maxp = int(request.args.get("max", "200000")) except ValueError: maxp = 200000 try: import drivaer_realviz as RV data = RV.build_cloud_buffer(".", run=run, max_points=maxp, color_by=color) if data is None: return jsonify({"error": "no DrivAer point clouds found; run " "fetch_drivaernet_pointclouds.py"}), 404 resp = Response(data["buffer"], mimetype="application/octet-stream") resp.headers["X-Drivaer-Run"] = str(data.get("run")) resp.headers["X-Drivaer-N"] = str(data.get("n")) if data.get("cd") is not None: resp.headers["X-Drivaer-Cd"] = f"{data['cd']:.4f}" return resp except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/aero/pressure_plot/.obj") def aero_pressure_plot(name: str): """Serve the colored OBJ generated by the last surface-pressure prediction for (vertex colors encode Cp).""" path = os.path.join(OUTPUT, f"part_{name}_pressure.obj") if os.path.exists(path): return send_file(path, mimetype="text/plain") return ("no pressure plot yet — run 'car_pressure' first", 404) @app.route("/aero/pressure", methods=["POST"]) def aero_pressure(): """Predict the per-point surface pressure field (Cp) on a part using the RegDGCNN surface-field model, write a vertex-colored OBJ, and report the Cp range. This is the DrivAerNet surface-field-prediction task.""" data = request.get_json(force=True) part = (data.get("part") or "").strip() if not part: return jsonify({"error": "part is required"}), 400 with _lock: if part not in engine.parts: return jsonify({"error": f"no part '{part}'"}), 404 try: import vehicle_surface_field as SF if not SF.available(): return jsonify({"error": "surface-field model not trained; " "run `python -m vehicle_surface_field train`"}), 400 obj_path = os.path.join(OUTPUT, f"part_{part}_pressure.obj") info = SF.colored_pressure_obj(engine.parts[part], obj_path) if info is None: return jsonify({"error": "prediction failed (torch/cadquery/stl?)"}), 500 info.update({"ok": True, "part": part, "obj_url": f"/aero/pressure_plot/{part}.obj", "field": "CpMeanTrim"}) return jsonify(info) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/fea/run", methods=["POST"]) def fea_run(): """Run a basic linear-elastic cantilever FEA on the named part using gmsh + scikit-fem. Returns max stress and displacement. """ data = request.get_json(force=True) part = (data.get("part") or "").strip() load_N = float(data.get("load_N", 100.0)) axis = (data.get("axis") or "Z").strip().upper() if not part: return jsonify({"error": "part is required"}), 400 with _lock: if part not in engine.parts: return jsonify({"error": f"no part '{part}'"}), 404 material = engine.materials.material_of(part) if hasattr(engine, "materials") else "default" try: stl_path = engine.export_part_stl(part) except Exception as e: return jsonify({"error": f"could not export STL for FEA: {e}"}), 500 # Run FEA outside the lock — solver can take a few seconds try: from fea import run_fea result = run_fea(stl_path, load_N=load_N, axis=axis, material=material) return jsonify(result) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/fea/modal", methods=["POST"]) def fea_modal(): """Modal analysis: returns the first N natural frequencies of the part (rigid-body modes filtered out). Free-free boundary conditions. """ data = request.get_json(force=True) part = (data.get("part") or "").strip() n_modes = int(data.get("n_modes", 6)) if not part: return jsonify({"error": "part is required"}), 400 with _lock: if part not in engine.parts: return jsonify({"error": f"no part '{part}'"}), 404 material = engine.materials.material_of(part) if hasattr(engine, "materials") else "default" try: stl_path = engine.export_part_stl(part) except Exception as e: return jsonify({"error": f"could not export STL: {e}"}), 500 try: from fea import run_modal return jsonify(run_modal(stl_path, material, n_modes)) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/acoustic_design", methods=["POST"]) def acoustic_design(): """Inverse-design endpoint: given a target frequency window, search across phononic unit-cell families for the best parametric match and build the result in the scene. """ data = request.get_json(force=True) try: target_lo = float(data.get("target_lo_Hz") or 0) target_hi = float(data.get("target_hi_Hz") or 0) except Exception: return jsonify({"error": "target_lo_Hz and target_hi_Hz required"}), 400 if target_hi <= target_lo or target_lo <= 0: return jsonify({"error": "invalid target band"}), 400 name = (data.get("name") or "design").strip() or "design" nx = int(data.get("nx", 6)) ny = int(data.get("ny", 6)) with _lock: from inverse_design import ( design_acoustic_metamaterial, build_geometry_from_candidate, ) result = design_acoustic_metamaterial(target_lo, target_hi) if not result.get("ok"): return jsonify(result), 400 try: build_geometry_from_candidate(engine, name, result["best"], nx, ny) _refresh_stl() except Exception as e: return jsonify({"ok": False, "error": str(e), "result": result}), 500 result["built_part"] = name result["lattice"] = [nx, ny] return jsonify(result) @app.route("/sketch_upload", methods=["POST"]) def sketch_upload(): """Upload a 2D image (hand sketch / technical drawing / silhouette photo) and convert it to a 3D part. Modes: - trace: contour-trace the silhouette via opencv, then extrude - interpret: send to Claude vision, get parser commands, run them Form fields: image: binary file mode: 'trace' or 'interpret' name: part name to store under target_mm: trace only — longest bbox side after scaling (default 50) extrude_mm: trace only — extrusion depth (default 5) api_key: interpret only model: interpret only (default claude-opus-4-7) """ file = request.files.get("image") if not file: return jsonify({"error": "no 'image' file uploaded"}), 400 mode = (request.form.get("mode") or "trace").strip().lower() name = (request.form.get("name") or "sketch_part").strip() or "sketch_part" img_bytes = file.read() if mode == "trace" or mode == "auto": target_mm = float(request.form.get("target_mm") or 50.0) extrude_mm = float(request.form.get("extrude_mm") or 5.0) with _lock: try: from image_to_3d import trace_silhouette, classify_silhouette # Try the local shape classifier FIRST — if it recognises a # nut / bolt / circle / gear / rectangle confidently, run the # parametric parser command and skip the flat extrude. cls = classify_silhouette(img_bytes, target_mm) # Accept any best-guess from the classifier. The classifier # itself only returns 'unknown' for genuinely uninterpretable # shapes (tiny / empty); everything else is mapped to the # nearest parametric primitive. Literal trace is now a # last-resort. if cls.get("parser_cmd") and cls.get("confidence", 0) >= 0.4: # Use the user's chosen part name in the generated commands cmd_toks = cls["parser_cmd"].split() if len(cmd_toks) >= 2: old_name = cmd_toks[1] cmd_toks[1] = name base_cmd = " ".join(cmd_toks) engine._snapshot() base_reply = run_parser(engine, base_cmd) # Apply follow-up commands (holes etc.); rewrite the # placeholder name to the user's chosen name. follow_results = [] for fc in cls.get("follow_cmds") or []: ft = fc.split() if len(ft) >= 2: ft[1] = name fcmd = " ".join(ft) try: fr = run_parser(engine, fcmd) follow_results.append(f"{fcmd} -> {fr}") except Exception as e: follow_results.append(f"{fcmd} FAILED {e}") _refresh_stl() return jsonify({"ok": True, "name": name, "mode": "classified", "kind": cls["kind"], "confidence": cls["confidence"], "reason": cls["reason"], "command": base_cmd, "follow_commands": follow_results, "reply": base_reply}) # Otherwise fall back to literal contour extrusion. wp, info = trace_silhouette(img_bytes, target_mm, extrude_mm) engine._snapshot() engine.parts[name] = wp _refresh_stl() info["classifier"] = {"kind": cls.get("kind"), "confidence": cls.get("confidence"), "reason": cls.get("reason")} return jsonify({"ok": True, "name": name, "mode": "trace", "info": info}) except Exception as e: return jsonify({"error": str(e)}), 400 elif mode == "interpret": api_key = (request.form.get("api_key") or os.environ.get("ANTHROPIC_API_KEY") or "").strip() if not api_key or not api_key.startswith("sk-ant-"): return jsonify({"error": "interpret mode needs an Anthropic key " "(vision model). Paste sk-ant-... in settings."}), 400 model = (request.form.get("model") or DEFAULT_MODEL).strip() with _lock: try: from image_to_3d import interpret_with_vision cmds, summary = interpret_with_vision(img_bytes, api_key, model) if not cmds: return jsonify({"error": "vision returned no commands"}), 400 # execute the commands in order results = [] cmd_lines = [] for c in cmds: try: r = run_parser(engine, c) results.append({"cmd": c, "result": r}) cmd_lines.append(f"{c} -> {r}") except Exception as e: results.append({"cmd": c, "error": str(e)}) cmd_lines.append(f"{c} FAILED {e}") _refresh_stl() return jsonify({"ok": True, "mode": "interpret", "interpretation": summary or "(no summary)", "commands": cmd_lines, "commands_run": results, "name": cmds[0].split()[1] if cmds and len(cmds[0].split()) > 1 else name}) except Exception as e: return jsonify({"error": str(e)}), 500 else: return jsonify({"error": f"unknown mode '{mode}'"}), 400 _LAST_SKETCH = {"bytes": None} @app.route("/aero/flowfield", methods=["POST"]) def aero_flowfield(): """Live symmetry-plane flow field (velocity magnitude + streamlines) around the current car. Instant inviscid potential-flow estimate.""" from flask import Response try: import drivaer_meshgen as MG import vehicle_flowfield as FF from llm import _realcar_state st = _realcar_state(engine) d = MG.generate(length_mm=st["length"], width_mm=st["width"], height_mm=st["height"], roof=st["roof"], nose=st["nose"], rake=st["rake"], target_cd=st["cd"]) if d is None: return jsonify({"error": "no car geometry"}), 400 spd = 30.0 png = FF.render_symmetry_plane(d["verts"], speed_ms=spd) if not png: return jsonify({"error": "flow field could not be computed"}), 500 return Response(png, mimetype="image/png") except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/aero/retrieve", methods=["POST"]) def aero_retrieve(): """Design retrieval (Figure 11): top-K real DrivAer matches to the current car by aero (Cd) or shape, with delta-Cd. Returns the ranking GRAPHIC.""" from flask import Response try: import numpy as np import vehicle_retrieve as VR import drivaer_meshgen as MG from llm import _realcar_state st = _realcar_state(engine) data = request.get_json(silent=True) or {} by = data.get("by", "shape") k = int(data.get("k", 6)) # build the QUERY descriptor + baseline Cd from the current car qdesc = None; qcd = st.get("cd") or 0.27 try: d = MG.generate(length_mm=st["length"], width_mm=st["width"], height_mm=st["height"], roof=st["roof"], nose=st["nose"], rake=st["rake"], target_cd=st["cd"]) if d is not None: # descriptor wants metres (same as the cached real clouds) qdesc = VR.shape_descriptor(np.asarray(d["verts"]) / 1000.0) try: import vehicle_realgen as G pc = G.predict_cd_of_cloud(d["verts"]) if pc: qcd = float(pc) except Exception: pass except Exception: pass res = VR.retrieve(query_cd=qcd, query_desc=qdesc, by=by, k=k, baseline_cd=qcd) if not res.get("ok"): return jsonify(res), 500 style = (data.get("style") or "thumbs") png = (VR.retrieve_thumbnails(res) if style == "thumbs" else VR.retrieve_plot(res)) if not png: # fall back to the bar chart png = VR.retrieve_plot(res) if not png: return jsonify({"error": "no plot"}), 500 resp = Response(png, mimetype="image/png") resp.headers["X-Retrieve-By"] = by return resp except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/aero/deepsdf", methods=["POST"]) def aero_deepsdf(): """DeepSDF latent-space interpolation grid (learned neural shape space): smooth GENERATED geometries between two real cars.""" from flask import Response try: import vehicle_deepsdf as DS if not DS.available(): return jsonify({"error": "DeepSDF not trained yet " "(weights/deepsdf.pt missing - training in progress)"}), 503 data = request.get_json(silent=True) or {} n = int(data.get("n", 5)) lat = DS.latents(); N = len(lat) i = int(data.get("i", 0)); j = int(data.get("j", N - 1)) png = DS.interp_grid_png(i=i % N, j=j % N, n=n) return Response(png, mimetype="image/png") except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/aero/designmap", methods=["POST"]) def aero_designmap(): """t-SNE/PCA design-space map of the real DrivAer designs coloured by drag, with the current car marked (the paper's design-space exploration figure).""" from flask import Response import numpy as np try: import vehicle_retrieve as VR import drivaer_meshgen as MG from llm import _realcar_state st = _realcar_state(engine) qdesc = None try: d = MG.generate(length_mm=st["length"], width_mm=st["width"], height_mm=st["height"], roof=st["roof"], nose=st["nose"], rake=st["rake"], target_cd=st["cd"]) if d is not None: qdesc = VR.shape_descriptor(np.asarray(d["verts"]) / 1000.0) except Exception: pass png = VR.design_map_plot(query_desc=qdesc, query_cd=st.get("cd")) if not png: return jsonify({"error": "design map needs the feature cache; " "press Retrieve similar once to build it"}), 503 return Response(png, mimetype="image/png") except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/aero/interp", methods=["POST"]) def aero_interp(): """Design-space interpolation grid (Figure 9): smooth morph between two configurations, each with predicted Cd. Returns the grid GRAPHIC.""" from flask import Response try: import vehicle_interp as VI data = request.get_json(silent=True) or {} a = str(data.get("a", "notchback")).lower() b = str(data.get("b", "estateback")).lower() n = int(data.get("n", 5)) sa = VI.PRESETS.get(a, VI.PRESETS["notchback"]) sb = VI.PRESETS.get(b, VI.PRESETS["estateback"]) png = VI.interp_grid(sa, sb, n=n) if not png: return jsonify({"error": "interp failed"}), 500 return Response(png, mimetype="image/png") except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/car/cp_solid.obj") def car_cp_solid(): """The solid car painted with the predicted SURFACE PRESSURE field (Cp): blue = suction, red = stagnation (high pressure) - the paper's surface-Cp view. Predicts Cp on a subsample (the graph-CNN is O(N^2)) and propagates to all vertices. Returns an OBJ with per-vertex Cp colours.""" from flask import Response import numpy as np try: import drivaer_meshgen as MG import vehicle_surface_field as SF import matplotlib.cm as cm from llm import _realcar_state st = _realcar_state(engine) d = MG.generate(length_mm=st["length"], width_mm=st["width"], height_mm=st["height"], roof=st["roof"], nose=st["nose"], rake=st["rake"], target_cd=st["cd"]) if d is None: return jsonify({"error": "no car geometry"}), 400 if not SF.available(): return jsonify({"error": "surface-pressure model not trained " "(weights/regdgcnn_cp.pt missing)"}), 503 V = np.asarray(d["verts"], np.float32); F = np.asarray(d["faces"], np.int64) n = len(V); sub_n = min(SF.NUM_POINTS, n) idx = np.random.default_rng(0).choice(n, sub_n, replace=False) cp_sub = SF.predict_field_on_points(V[idx]) if cp_sub is None: return jsonify({"error": "Cp prediction failed"}), 500 # propagate to all vertices (nearest sampled point) try: from scipy.spatial import cKDTree _, nn = cKDTree(V[idx]).query(V) cp = np.asarray(cp_sub)[nn] except Exception: cp = np.zeros(n); cp[idx] = cp_sub lo, hi = float(np.percentile(cp, 2)), float(np.percentile(cp, 98)) t = np.clip((cp - lo) / max(hi - lo, 1e-6), 0, 1) cols = cm.turbo(t)[:, :3] out = ["# chat_cad surface pressure Cp (blue=suction, red=stagnation)"] ap = out.append for p, c in zip(V, cols): ap("v %.2f %.2f %.2f %.3f %.3f %.3f" % (p[0], p[1], p[2], c[0], c[1], c[2])) for f in F: ap("f %d %d %d" % (f[0]+1, f[1]+1, f[2]+1)) resp = Response("\n".join(out), mimetype="text/plain") resp.headers["X-Cp-Min"] = "%.3f" % float(cp.min()) resp.headers["X-Cp-Max"] = "%.3f" % float(cp.max()) return resp except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/car/wss_solid.obj") def car_wss_solid(): """The solid car painted with the predicted WALL-SHEAR-STRESS field (|tau|): low (blue) = separated/low-friction, high (red) = attached high-shear flow (A-pillars, leading edges, mirror fairings). Trained on the real DrivAerNet++ WSS deposit. Also reports a skin-friction proxy = mean |tau|.""" from flask import Response import numpy as np try: import drivaer_meshgen as MG import vehicle_wss_field as WSS import matplotlib.cm as cm from llm import _realcar_state st = _realcar_state(engine) d = MG.generate(length_mm=st["length"], width_mm=st["width"], height_mm=st["height"], roof=st["roof"], nose=st["nose"], rake=st["rake"], target_cd=st["cd"]) if d is None: return jsonify({"error": "no car geometry"}), 400 if not WSS.available(): return jsonify({"error": "WSS model not trained " "(weights/regdgcnn_wss.pt missing)"}), 503 V = np.asarray(d["verts"], np.float32); F = np.asarray(d["faces"], np.int64) n = len(V); sub_n = min(WSS.NUM_POINTS, n) idx = np.random.default_rng(0).choice(n, sub_n, replace=False) w_sub = WSS.predict_wss_on_points(V[idx]) if w_sub is None: return jsonify({"error": "WSS prediction failed"}), 500 try: from scipy.spatial import cKDTree _, nn = cKDTree(V[idx]).query(V) w = np.asarray(w_sub)[nn] except Exception: w = np.zeros(n); w[idx] = w_sub lo, hi = float(np.percentile(w, 2)), float(np.percentile(w, 98)) t = np.clip((w - lo) / max(hi - lo, 1e-6), 0, 1) cols = cm.turbo(t)[:, :3] out = ["# chat_cad wall shear stress |tau| (blue=low, red=high friction)"] ap = out.append for p, c in zip(V, cols): ap("v %.2f %.2f %.2f %.3f %.3f %.3f" % (p[0], p[1], p[2], c[0], c[1], c[2])) for f in F: ap("f %d %d %d" % (f[0]+1, f[1]+1, f[2]+1)) resp = Response("\n".join(out), mimetype="text/plain") resp.headers["X-Wss-Min"] = "%.4f" % float(w.min()) resp.headers["X-Wss-Max"] = "%.4f" % float(w.max()) resp.headers["X-Wss-Mean"] = "%.4f" % float(w.mean()) # skin-friction proxy return resp except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/car/crash_solid.obj") def car_crash_solid(): """Return the CRUSHED car mesh (frontal pole impact deformation applied) as an OBJ, per-vertex coloured by displacement magnitude (blue = intact, red = most deformed). Loaded into the 3D viewer for interactive rotation, like an FE crash post-processor. Query: vel,pole,offset,boxt to vary.""" from flask import Response import numpy as np try: import drivaer_meshgen as MG import vehicle_crash as VC import matplotlib.cm as cm from llm import _realcar_state st = _realcar_state(engine) d = MG.generate(length_mm=st["length"], width_mm=st["width"], height_mm=st["height"], roof=st["roof"], nose=st["nose"], rake=st["rake"], target_cd=st["cd"]) if d is None: return jsonify({"error": "no car geometry"}), 400 def _q(n, dv): try: v = request.args.get(n) return float(v) if v not in (None, "") else dv except ValueError: return dv params = {"impact_velocity_kmh": _q("vel", 48.0), "pole_diameter_mm": _q("pole", 254.0), "lateral_offset_mm": _q("offset", 0.0), "crash_box_thickness_mm": _q("boxt", 1.6)} vol = (st["length"]/1000.0)*(st["width"]/1000.0)*(st["height"]/1000.0) mass = float(max(900.0, min(2600.0, 230.0*vol+700.0+300.0))) verts = np.asarray(d["verts"], np.float64) faces = np.asarray(d["faces"], np.int64) vd, dmag = VC.deform_mesh(verts, faces, d.get("region"), params, mass) r = VC.crash_estimate(params, mass_kg=mass) tmax = float(dmag.max()) or 1.0 cols = cm.turbo(np.clip(dmag / tmax, 0, 1))[:, :3] out = ["# chat_cad crash-deformed car (colour = displacement magnitude)"] ap = out.append for p, c in zip(vd, cols): ap("v %.2f %.2f %.2f %.3f %.3f %.3f" % (p[0], p[1], p[2], c[0], c[1], c[2])) for f in faces: ap("f %d %d %d" % (f[0]+1, f[1]+1, f[2]+1)) resp = Response("\n".join(out), mimetype="text/plain") resp.headers["X-Crash-Verdict"] = r["verdict"] resp.headers["X-Crash-Decel-g"] = str(r["peak_deceleration_g"]) resp.headers["X-Crash-Intrusion-mm"] = str(r["intrusion_mm"]) resp.headers["X-Crash-MaxDisp-mm"] = "%.0f" % tmax return resp except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/car/crash_frames.bin") def car_crash_frames(): """Binary payload for the PROGRESSIVE crash animation (Option 3): base mesh + faces + per-node final displacement, fold-front arrival, and peak von-Mises proxy, plus the transient deceleration pulse (in headers). The viewer plays a crush where the fold front and the high-stress band sweep rearward in time.""" from flask import Response import json as _json import numpy as np try: import drivaer_meshgen as MG import vehicle_crash as VC from llm import _realcar_state st = _realcar_state(engine) d = MG.generate(length_mm=st["length"], width_mm=st["width"], height_mm=st["height"], roof=st["roof"], nose=st["nose"], rake=st["rake"], target_cd=st["cd"]) if d is None: return jsonify({"error": "no car geometry"}), 400 def _q(n, dv): try: vv = request.args.get(n) return float(vv) if vv not in (None, "") else dv except ValueError: return dv params = {"impact_velocity_kmh": _q("vel", 48.0), "pole_diameter_mm": _q("pole", 254.0), "lateral_offset_mm": _q("offset", 0.0), "crash_box_thickness_mm": _q("boxt", 1.6)} vol = (st["length"]/1000.0)*(st["width"]/1000.0)*(st["height"]/1000.0) mass = float(max(900.0, min(2600.0, 230.0*vol+700.0+300.0))) V = np.asarray(d["verts"], np.float32) F = np.asarray(d["faces"], np.int32) fields = VC.crash_fields(V, F, d.get("region"), params, mass) disp = fields["final_disp"].astype(np.float32) arrival = fields["arrival"].astype(np.float32) stress = fields["peak_stress"].astype(np.float32) nverts = V.shape[0]; nfaces = F.shape[0] blob = b"CRSH" + np.array([nverts, nfaces], np.uint32).tobytes() blob += np.ascontiguousarray(V).tobytes() blob += np.ascontiguousarray(F.astype(np.uint32)).tobytes() blob += np.ascontiguousarray(disp).tobytes() blob += np.ascontiguousarray(arrival).tobytes() blob += np.ascontiguousarray(stress).tobytes() r = fields["estimate"] resp = Response(blob, mimetype="application/octet-stream") resp.headers["X-Crash-Pulse"] = _json.dumps(fields["pulse"]) resp.headers["X-Crash-StressMax"] = "%.1f" % fields["stress_max"] resp.headers["X-Crash-Verdict"] = r["verdict"] resp.headers["X-Crash-Decel-g"] = str(r["peak_deceleration_g"]) resp.headers["X-Crash-Intrusion-mm"] = str(r["intrusion_mm"]) return resp except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/car/parts.bin") def car_parts_bin(): """Binary payload for PER-PART editing: base verts + faces + per-vertex part id (uint8) + the part palette (header JSON). The viewer renders glass as a separate transparent mesh (fixing glass/body overlap) and lets the user recolour any individual part (hood, doors, bumpers, roof, wheels, ...).""" from flask import Response import json as _json import numpy as np try: import drivaer_meshgen as MG import vehicle_parts as VP from llm import _realcar_state st = _realcar_state(engine) d = MG.generate(length_mm=st["length"], width_mm=st["width"], height_mm=st["height"], roof=st["roof"], nose=st["nose"], rake=st["rake"], target_cd=st["cd"]) if d is None: return jsonify({"error": "no car geometry"}), 400 V = np.asarray(d["verts"], np.float32) F = np.asarray(d["faces"], np.int32) pid = VP.part_labels(V, d["region"], faces=F).astype(np.uint8) nverts, nfaces = V.shape[0], F.shape[0] blob = b"PART" + np.array([nverts, nfaces], np.uint32).tobytes() blob += np.ascontiguousarray(V).tobytes() blob += np.ascontiguousarray(F.astype(np.uint32)).tobytes() blob += np.ascontiguousarray(pid).tobytes() resp = Response(blob, mimetype="application/octet-stream") present = set(int(x) for x in np.unique(pid)) pal = [p for p in VP.palette() if p["id"] in present] resp.headers["X-Parts-Palette"] = _json.dumps(pal) resp.headers["X-Parts-Glass-Id"] = str(VP.NAME_TO_ID["glass"]) return resp except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/aero/image_cd", methods=["POST"]) def aero_image_cd(): """Multimodal IMAGE -> drag: render the current car and predict Cd with the fine-tuned ResNet (val R2 0.70 on 7,967 real DrivAerNet++ renderings).""" try: import vehicle_image_cd as IC if not IC.available(): return jsonify({"error": "image-Cd model not trained"}), 503 import blender_render as BR from llm import _realcar_state st = _realcar_state(engine) out = os.path.join(OUTPUT, "imgcd_render.png") png = None if BR.available(): png = BR.render(length_mm=st["length"], width_mm=st["width"], height_mm=st["height"], roof=st["roof"], nose=st["nose"], rake=st["rake"], target_cd=st["cd"], color=st.get("color") or "silver", out_path=out, timeout=600) if not png or not os.path.exists(png): return jsonify({"error": "could not render the car for image prediction"}), 500 cd = IC.predict_cd_from_image(png) if cd is None: return jsonify({"error": "image prediction failed"}), 500 return jsonify({"cd": round(float(cd), 4), "model": "ResNet18 fine-tuned on 7,967 real DrivAerNet++ renderings (val R2 0.70)"}) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/aero/tsne", methods=["POST"]) def aero_tsne(): """t-SNE design-space map (Fig: design exploration): 4,165 real DrivAer designs embedded in 2D, coloured by real CFD Cd; low-drag clusters marked.""" from flask import Response try: import vehicle_tsne as TS from llm import _realcar_state st = _realcar_state(engine) png = TS.render_tsne(query_cd=st.get("cd")) if not png: return jsonify({"error": "parametric data missing"}), 503 return Response(png, mimetype="image/png") except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/aero/crash_plot", methods=["POST"]) def aero_crash_plot(): """Crash-report GRAPHIC for the current car: deceleration pulse, force-crush curve, energy budget, verdict.""" from flask import Response try: import vehicle_crash as VC from llm import _realcar_state st = _realcar_state(engine) data = request.get_json(silent=True) or {} ov = {} _map = {"vel": "impact_velocity_kmh", "boxt": "crash_box_thickness_mm", "beamt": "bumper_beam_thickness_mm", "pole": "pole_diameter_mm", "offset": "lateral_offset_mm"} for k, kk in _map.items(): if data.get(k) is not None: try: ov[kk] = float(data[k]) except (TypeError, ValueError): pass vol = (st["length"]/1000.0)*(st["width"]/1000.0)*(st["height"]/1000.0) mass = float(max(900.0, min(2600.0, 230.0*vol+700.0+300.0))) r = VC.crash_estimate(ov, mass_kg=mass) png = VC.crash_plot(r) return Response(png, mimetype="image/png") except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/car/photoreal", methods=["POST"]) def car_photoreal(): """Photorealistic Blender (Cycles, GPU) render of the CURRENT car geometry - metallic paint, glass, chrome rims, rubber tyres, emissive lamps. Returns a PNG of the real 3D model (not a diffusion image).""" from flask import Response try: import blender_render as BR if not BR.available(): return jsonify({"error": "Blender not found on this machine"}), 503 from llm import _realcar_state st = _realcar_state(engine) out = os.path.join(OUTPUT, "blender_car.png") png = BR.render(length_mm=st["length"], width_mm=st["width"], height_mm=st["height"], roof=st["roof"], nose=st["nose"], rake=st["rake"], target_cd=st["cd"], color=st.get("color") or "silver", out_path=out, timeout=600) if not png or not os.path.exists(png): return jsonify({"error": "Blender render failed"}), 500 with open(png, "rb") as f: data = f.read() return Response(data, mimetype="image/png") except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/car/style_render", methods=["POST"]) def car_style_render(): """Styling Agent: text prompt (+ optional sketch) -> photorealistic car rendering via Stable Diffusion + ControlNet. Returns a PNG image.""" from flask import Response prompt = "" sketch = None seed = None from_model = False if request.files.get("image"): sketch = request.files["image"].read() prompt = (request.form.get("prompt") or "").strip() if request.form.get("seed"): try: seed = int(request.form.get("seed")) except ValueError: seed = None else: data = request.get_json(silent=True) or {} prompt = (data.get("prompt") or "").strip() seed = data.get("seed") from_model = bool(data.get("from_model")) fast = bool(data.get("fast")) if data.get("use_sketch"): sketch = _LAST_SKETCH.get("bytes") try: import vehicle_styling as ST if not ST.available(): return jsonify({"error": "styling model not installed yet " "(diffusers/torch still setting up)"}), 503 # geometry-guided: render the CURRENT 3D model (clean clay control # pass) in Blender, then force the diffusion to follow that silhouette. model_bytes = None if from_model: try: import blender_render as BR if not BR.available(): return jsonify({"error": "Blender not found - needed to " "render your model's silhouette"}), 503 from llm import _realcar_state st = _realcar_state(engine) ctl = os.path.join(OUTPUT, "blender_control.png") p = BR.render(length_mm=st["length"], width_mm=st["width"], height_mm=st["height"], roof=st["roof"], nose=st["nose"], rake=st["rake"], target_cd=st["cd"], color=st.get("color") or "silver", out_path=ctl, timeout=600, mode="control") if not p or not os.path.exists(p): return jsonify({"error": "could not render model " "silhouette for guidance"}), 500 with open(p, "rb") as f: model_bytes = f.read() except Exception as e: return jsonify({"error": "model-guided setup failed: " + str(e)}), 500 steps = (18 if fast else 28) if ST.gpu_ready() else 12 png = ST.style_render(prompt, sketch_bytes=sketch, seed=seed, steps=steps, model_bytes=model_bytes, fast=fast) resp = Response(png, mimetype="image/png") resp.headers["X-Style-Device"] = "gpu" if ST.gpu_ready() else "cpu" resp.headers["X-Style-Guided"] = "model" if from_model else ( "sketch" if sketch else "text") return resp except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/car/sketch", methods=["POST"]) def car_sketch_upload(): """Upload a car SIDE-PROFILE sketch/image and generate a full 3D car. Reads the silhouette (PIL/numpy, no OpenCV), infers body style + proportions, builds the car via the generative body pipeline, and reports aero + real-CFD calibration. This is chat_cad's analogue of the DrivAerNet++ Styling Agent (sketch -> design). Form fields: image: binary file (car side profile, light background best) name: prefix for the generated car parts (default 'sketchcar') generate: '1' to use the CVAE body generator, '0' for the style preset (default '1') style: optional override of the inferred style """ file = request.files.get("image") if not file: return jsonify({"error": "no 'image' file uploaded"}), 400 style_override = (request.form.get("style") or "").strip().lower() or None img_bytes = file.read() _LAST_SKETCH["bytes"] = img_bytes # reused by the Styling Agent with _lock: try: import urllib.parse as _up from car_sketch import read_car_sketch from llm import _realcar_state, _REALCAR_STYLES info = read_car_sketch(img_bytes) if not info.get("ok"): return jsonify({"error": info.get("reason", "could not read sketch")}), 400 style = style_override or info["style"] wb = float(info["wheelbase"]); tr = float(info["track"]) h = float(info["height"]) # sketch -> editable realistic-car state (the generative DrivAer # mesh-morph path, NOT the old parametric assembly). st = _realcar_state(engine) st["solid"] = True if style in _REALCAR_STYLES: st["style"] = style st.update(_REALCAR_STYLES[style]) # proportions inferred from the silhouette st["length"] = round(wb * 1.74) # overall length ~ 1.74 x wheelbase st["width"] = round(tr + 350) # body width ~ track + 350 mm if h > 800: st["height"] = round(h) q = {"length": st["length"], "width": st["width"], "height": st["height"], "roof": st["roof"], "nose": st["nose"], "rake": st["rake"], "cd": "" if st["cd"] is None else st["cd"]} if st.get("color"): q["color"] = st["color"] qs = _up.urlencode(q) # predicted drag of the morphed mesh cd = None try: import drivaer_meshgen as MG if MG.available(): d = MG.generate(length_mm=st["length"], width_mm=st["width"], height_mm=st["height"], roof=st["roof"], nose=st["nose"], rake=st["rake"], target_cd=st["cd"]) import vehicle_realgen as G cd = G.predict_cd_of_cloud(d["points_mm"]) if d else None except Exception: cd = None return jsonify({ "ok": True, "style": style, "sketch": info, "realcar_query": qs, "cd": round(cd, 3) if cd is not None else None, "reply": f"sketch -> realistic {style} (generative DrivAer " f"mesh morph)", }) except Exception as e: return jsonify({"error": str(e)}), 400 @app.route("/verify", methods=["POST"]) def verify(): """Verification agent: render the current scene + ask Claude vision whether it matches the user's intent. Works in any mode (Chat, Design Agent, parser-only). Requires an Anthropic key (vision model). """ data = request.get_json(force=True) intent = (data.get("intent") or "").strip() api_key = (data.get("api_key") or os.environ.get("ANTHROPIC_API_KEY") or "").strip() model = (data.get("model") or DEFAULT_MODEL).strip() if not intent: return jsonify({"error": "intent (what you asked for) is required"}), 400 if not api_key or not api_key.startswith("sk-ant-"): return jsonify({"error": "verification agent needs an Anthropic key " "(vision model). Paste sk-ant-... in settings."}), 400 with _lock: if not engine.parts: return jsonify({"error": "scene is empty — nothing to verify"}), 400 parts_summary = engine.list_parts() try: from agents import render_scene_png, verify_intent from anthropic import Anthropic img_path = os.path.join(OUTPUT, "_verify.png") render_scene_png(engine, img_path, width=640, height=480) client = Anthropic(api_key=api_key) result = verify_intent(client, model, intent, img_path, parts_summary) return jsonify(result) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/cfd/run", methods=["POST"]) def cfd_run(): """2D steady Stokes flow around the part's XY silhouette. Real PDE solve via Taylor-Hood elements (P2-velocity / P1-pressure). Returns max velocity + pressure drop. Stokes regime only (Re << 1). """ data = request.get_json(force=True) part = (data.get("part") or "").strip() U = float(data.get("inlet_velocity", 1.0)) mu = float(data.get("viscosity", 1.0e-3)) axis = (data.get("axis") or "Z").strip().upper() if not part: return jsonify({"error": "part is required"}), 400 with _lock: if part not in engine.parts: return jsonify({"error": f"no part '{part}'"}), 404 try: stl_path = engine.export_part_stl(part) except Exception as e: return jsonify({"error": f"could not export STL: {e}"}), 500 try: from fea import run_cfd_2d return jsonify(run_cfd_2d(stl_path, U, mu, axis)) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/fea/thermal", methods=["POST"]) def fea_thermal(): """Steady-state heat conduction on the named part. Hot face fixed at t_hot°C on the +axis side, cold face at t_cold°C on the -axis side. """ data = request.get_json(force=True) part = (data.get("part") or "").strip() t_hot = float(data.get("t_hot", 100.0)) t_cold = float(data.get("t_cold", 20.0)) axis = (data.get("axis") or "Z").strip().upper() if not part: return jsonify({"error": "part is required"}), 400 with _lock: if part not in engine.parts: return jsonify({"error": f"no part '{part}'"}), 404 try: stl_path = engine.export_part_stl(part) except Exception as e: return jsonify({"error": f"could not export STL: {e}"}), 500 try: from fea import run_thermal return jsonify(run_thermal(stl_path, t_hot=t_hot, t_cold=t_cold, axis=axis)) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/ollama/status") def ollama_status(): """Quick health check used by the UI to show Ollama availability.""" from llm_ollama import check_ollama ok, msg = check_ollama() return jsonify({"ok": ok, "message": msg}) @app.route("/export/") def export(fmt: str): fmt = fmt.lower() if fmt not in ("step", "stl"): return jsonify({"error": f"unknown format {fmt}"}), 400 with _lock: try: path = engine.export_step("scene.step") if fmt == "step" else engine.export_stl("scene.stl") except Exception as e: return jsonify({"error": str(e)}), 400 return send_file(path, as_attachment=True, download_name=os.path.basename(path)) def _open_browser(): webbrowser.open(f"http://127.0.0.1:{PORT}/") if __name__ == "__main__": HOST = os.environ.get("HOST", "127.0.0.1") PORT = int(os.environ.get("PORT", "5000")) # only launch a browser tab when running locally if HOST in ("127.0.0.1", "localhost"): threading.Timer(1.0, _open_browser).start() app.run(host=HOST, port=PORT, debug=False)