Spaces:
Running
Running
| """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/<name>.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 | |
| def index(): | |
| return send_from_directory(app.template_folder, "index.html") | |
| 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") | |
| def scene_manifest(): | |
| with _lock: | |
| return jsonify({"parts": engine.manifest()}) | |
| 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() | |
| ] | |
| }) | |
| def edit_feature(): | |
| """Replace a part by re-running an edited creation command. | |
| Body: { "name": "<existing part>", "cmd": "<new parser line>" } | |
| 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()}) | |
| 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") | |
| 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}) | |
| 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}) | |
| 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}) | |
| 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"}) | |
| 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}) | |
| def list_parts(): | |
| with _lock: | |
| return jsonify({"text": engine.list_parts()}) | |
| 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()}) | |
| 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") | |
| def knowledge_list(): | |
| with _lock: | |
| return jsonify({"notes": engine.knowledge.list_notes()}) | |
| 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}) | |
| def knowledge_remove(note_id: str): | |
| with _lock: | |
| ok = engine.knowledge.remove(note_id) | |
| return jsonify({"ok": ok}) | |
| def knowledge_search(): | |
| q = (request.args.get("q") or "").strip() | |
| with _lock: | |
| hits = engine.knowledge.search(q, k=10) | |
| return jsonify({"hits": hits}) | |
| 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") | |
| 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 | |
| def reset(): | |
| with _lock: | |
| engine.clear() | |
| chat_history.clear() | |
| gemini_history.clear() | |
| ollama_history.clear() | |
| _refresh_stl() | |
| return jsonify({"ok": True}) | |
| 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 | |
| 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}) | |
| def fea_stress_plot(name: str): | |
| """Serve the colored OBJ generated by the last stress analysis for <name>.""" | |
| 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) | |
| 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)}) | |
| 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 | |
| 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 | |
| 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 | |
| def aero_pressure_plot(name: str): | |
| """Serve the colored OBJ generated by the last surface-pressure | |
| prediction for <name> (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) | |
| 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 | |
| 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 | |
| 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 | |
| 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) | |
| 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} | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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}) | |
| 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) | |