chatcad / app.py
Samarjithbiswas's picture
uniform body color (drop invented sub-panels); panel shows only real parts
595902c verified
Raw
History Blame Contribute Delete
68.2 kB
"""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
@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": "<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()})
@app.route("/part/<name>.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/<name>/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/<name>.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/<name>.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/<note_id>", 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/<name>.obj")
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)
@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/<name>.obj")
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)
@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/<fmt>")
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)