v1: sliders fix, progress UX, vision verify, persistence, STEP scaffold

- Fix param sliders: Viewer now fits camera once per design (fitKey), not on
  every param change — size changes are visible again. Better slider ranges.
- Progress UX: full-stage overlay with spinning cube + pipeline stepper,
  animated thinking dots in chat, send-button spinner, staged status.
- B (vision verify): capture canvas → /api/verify (Claude vision) → auto-refine
  one pass on mismatch. Fail-open so a flaky judge never blocks.
- C (persistence): D1 designs table + anonymous cookie identity, save/load/list,
  designs drawer, autosave, shareable ids. no-store on all /api responses.
- D (STEP scaffold): /api/step + build123d prompt + service client (step.ts),
  Download STEP button. Needs STEP_SERVICE_URL to go live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
maverick 2026-07-25 00:37:35 +10:00
parent 974e26f592
commit ba565ee1a1
13 changed files with 782 additions and 86 deletions

13
migrations/0001_init.sql Normal file
View file

@ -0,0 +1,13 @@
-- Saved designs. The script is the source of truth; messages preserve the chat
-- so a design reopens mid-conversation. uid scopes ownership (anonymous cookie).
CREATE TABLE IF NOT EXISTS designs (
id TEXT PRIMARY KEY,
uid TEXT NOT NULL,
title TEXT NOT NULL DEFAULT 'Untitled',
script TEXT NOT NULL DEFAULT '',
messages TEXT NOT NULL DEFAULT '[]',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_designs_uid ON designs (uid, updated_at DESC);

View file

@ -1,19 +1,35 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Viewer } from "./components/Viewer";
import { StageOverlay } from "./components/StageOverlay";
import { DesignsDrawer } from "./components/DesignsDrawer";
import { runJscad, parseParams, type Param } from "./engine";
interface Msg {
role: "user" | "assistant";
content: string;
}
const MAX_REPAIRS = 3;
interface GenResponse {
script?: string;
summary?: string;
error?: string;
}
interface DesignSummary {
id: string;
title: string;
updated_at: number;
}
export type Stage = "" | "thinking" | "building" | "fixing" | "checking" | "refining";
const STAGE_LABEL: Record<Stage, string> = {
"": "",
thinking: "Designing your part",
building: "Building geometry",
fixing: "Fixing geometry",
checking: "Checking the result",
refining: "Refining the details",
};
const MAX_REPAIRS = 3;
export default function App() {
const [messages, setMessages] = useState<Msg[]>([]);
@ -21,21 +37,42 @@ export default function App() {
const [script, setScript] = useState("");
const [params, setParams] = useState<Param[]>([]);
const [stl, setStl] = useState<ArrayBuffer | null>(null);
const [status, setStatus] = useState<string>("");
const [stage, setStage] = useState<Stage>("");
const [busy, setBusy] = useState(false);
const [designVersion, setDesignVersion] = useState(0); // bumps only on new geometry / fit / load
const [designId, setDesignId] = useState<string | null>(null);
const [title, setTitle] = useState("");
const [designs, setDesigns] = useState<DesignSummary[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [steppy, setSteppy] = useState(false); // STEP export in flight
const scrollRef = useRef<HTMLDivElement>(null);
const captureRef = useRef<(() => string | null) | null>(null);
const designIdRef = useRef<string | null>(null);
designIdRef.current = designId;
useEffect(() => {
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight);
}, [messages, status]);
}, [messages, stage]);
const refreshDesigns = useCallback(async () => {
try {
const r = await fetch("/api/designs");
const d = (await r.json()) as { designs: DesignSummary[] };
setDesigns(d.designs ?? []);
} catch {
/* ignore */
}
}, []);
useEffect(() => {
refreshDesigns();
}, [refreshDesigns]);
const paramValues = useCallback(
(list: Param[]) => Object.fromEntries(list.map((p) => [p.name, p.value])),
[]
);
// Run a script through the worker; on failure, ask the server to repair and
// retry up to MAX_REPAIRS. Returns the script that actually executed.
const executeWithRepair = useCallback(
async (initialScript: string, values: Record<string, number>): Promise<string> => {
let current = initialScript;
@ -46,7 +83,7 @@ export default function App() {
return current;
} catch (err) {
if (attempt === MAX_REPAIRS) throw err;
setStatus(`Fixing a geometry error (attempt ${attempt + 1}/${MAX_REPAIRS})…`);
setStage("fixing");
const res = await fetch("/api/repair", {
method: "POST",
headers: { "content-type": "application/json" },
@ -62,6 +99,91 @@ export default function App() {
[]
);
const capture = useCallback(async (): Promise<string | null> => {
// Give the renderer a couple of frames to draw + the camera to settle.
await new Promise((r) => setTimeout(r, 450));
return captureRef.current?.() ?? null;
}, []);
const autosave = useCallback(
async (msgs: Msg[], scr: string, ttl: string) => {
try {
const r = await fetch("/api/designs", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ id: designIdRef.current, title: ttl, script: scr, messages: msgs }),
});
const d = (await r.json()) as { id: string };
setDesignId(d.id);
refreshDesigns();
} catch {
/* ignore */
}
},
[refreshDesigns]
);
// One generate → build → (repair) → verify → (refine) cycle.
const runTurn = useCallback(
async (convo: Msg[], priorScript: string, originalRequest: string) => {
setStage("thinking");
const res = await fetch("/api/generate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ messages: convo, currentScript: priorScript }),
});
const data = (await res.json()) as GenResponse;
if (data.error) throw new Error(data.error);
setStage("building");
const params0 = parseParams(data.script!);
let finalScript = await executeWithRepair(data.script!, paramValues(params0));
setScript(finalScript);
setParams(parseParams(finalScript));
setDesignVersion((v) => v + 1); // new geometry → refit camera once
setMessages((m) => [...m, { role: "assistant", content: data.summary! }]);
// --- Vision verify (B) ---
setStage("checking");
const img = await capture();
if (img) {
try {
const vr = await fetch("/api/verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ request: originalRequest, image: img }),
});
const verdict = (await vr.json()) as { matches: boolean; critique: string };
if (!verdict.matches && verdict.critique) {
setStage("refining");
setMessages((m) => [...m, { role: "assistant", content: `Refining: ${verdict.critique}` }]);
const refineRes = await fetch("/api/generate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
messages: [{ role: "user", content: verdict.critique }],
currentScript: finalScript,
}),
});
const rd = (await refineRes.json()) as GenResponse;
if (!rd.error && rd.script) {
const rp = parseParams(rd.script);
finalScript = await executeWithRepair(rd.script, paramValues(rp));
setScript(finalScript);
setParams(parseParams(finalScript));
setDesignVersion((v) => v + 1);
}
}
} catch {
/* verify is best-effort */
}
}
return finalScript;
},
[executeWithRepair, paramValues, capture]
);
const send = useCallback(async () => {
const text = input.trim();
if (!text || busy) return;
@ -69,34 +191,25 @@ export default function App() {
setBusy(true);
const nextMessages = [...messages, { role: "user" as const, content: text }];
setMessages(nextMessages);
setStatus("Designing…");
const ttl = title || text.slice(0, 60);
if (!title) setTitle(ttl);
try {
const res = await fetch("/api/generate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ messages: nextMessages, currentScript: script }),
const finalScript = await runTurn(nextMessages, script, text);
setStage("");
// autosave with the freshest assistant messages
setMessages((m) => {
autosave(m, finalScript, ttl);
return m;
});
const data = (await res.json()) as GenResponse;
if (data.error) throw new Error(data.error);
const newParams = parseParams(data.script!);
setStatus("Building geometry…");
const finalScript = await executeWithRepair(data.script!, paramValues(newParams));
setScript(finalScript);
setParams(parseParams(finalScript));
setMessages((m) => [...m, { role: "assistant", content: data.summary! }]);
setStatus("");
} catch (err) {
setMessages((m) => [...m, { role: "assistant", content: `⚠️ ${(err as Error).message}` }]);
setStatus("");
setStage("");
} finally {
setBusy(false);
}
}, [input, busy, messages, script, executeWithRepair, paramValues]);
}, [input, busy, messages, title, script, runTurn, autosave]);
// Live slider edits: re-run locally, no API call.
const onParam = useCallback(
(name: string, value: number) => {
const updated = params.map((p) => (p.name === name ? { ...p, value } : p));
@ -106,25 +219,104 @@ export default function App() {
[params, script, paramValues]
);
const newDesign = useCallback(() => {
setMessages([]);
setScript("");
setParams([]);
setStl(null);
setDesignId(null);
setTitle("");
setDrawerOpen(false);
}, []);
const loadDesign = useCallback(
async (id: string) => {
setDrawerOpen(false);
setBusy(true);
setStage("building");
try {
const r = await fetch(`/api/designs/${id}`);
const d = (await r.json()) as { title: string; script: string; messages: Msg[] };
setMessages(d.messages ?? []);
setTitle(d.title);
const p = parseParams(d.script);
setParams(p);
setScript(d.script);
setDesignId(id);
await runJscad(d.script, paramValues(p)).then(setStl);
setDesignVersion((v) => v + 1);
} catch (err) {
setMessages((m) => [...m, { role: "assistant", content: `⚠️ ${(err as Error).message}` }]);
} finally {
setStage("");
setBusy(false);
}
},
[paramValues]
);
const download = useCallback(() => {
if (!stl) return;
const blob = new Blob([stl], { type: "model/stl" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "chisel-model.stl";
a.download = `${(title || "chisel-model").replace(/[^a-z0-9]+/gi, "-").toLowerCase()}.stl`;
a.click();
URL.revokeObjectURL(url);
}, [stl]);
}, [stl, title]);
// STEP export (D) — re-authors the design in build123d for a true B-rep file.
const downloadStep = useCallback(async () => {
if (steppy) return;
setSteppy(true);
try {
const brief = messages.filter((m) => m.role === "user").map((m) => m.content).join(". ");
const res = await fetch("/api/step", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ request: brief || title }),
});
if (!res.ok) {
const e = (await res.json().catch(() => ({}))) as { error?: string };
alert(e.error || `STEP export failed (${res.status})`);
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${(title || "chisel-model").replace(/[^a-z0-9]+/gi, "-").toLowerCase()}.step`;
a.click();
URL.revokeObjectURL(url);
} finally {
setSteppy(false);
}
}, [steppy, messages, title]);
const fitKey = String(designVersion);
return (
<div className="app">
<aside className="chat">
<header className="brand">
<button className="menu" onClick={() => setDrawerOpen((o) => !o)} title="Your designs">
</button>
<span className="logo"> CHISEL</span>
<span className="tagline">say it. shape it.</span>
<button className="menu new" onClick={newDesign} title="New design">
</button>
</header>
<DesignsDrawer
open={drawerOpen}
designs={designs}
activeId={designId}
onSelect={loadDesign}
onClose={() => setDrawerOpen(false)}
/>
<div className="messages" ref={scrollRef}>
{messages.length === 0 && (
<div className="empty">
@ -147,26 +339,37 @@ export default function App() {
{m.content}
</div>
))}
{status && <div className="msg status">{status}</div>}
{stage && (
<div className="msg assistant thinking">
<span className="dots"><i /><i /><i /></span>
{STAGE_LABEL[stage]}
</div>
)}
</div>
{params.length > 0 && (
<div className="params">
<div className="params-title">Parameters</div>
{params.map((p) => (
<div className="params-title">Parameters · drag to reshape</div>
{params.map((p) => {
const min = p.value > 0 ? Math.max(p.value * 0.25, 0.5) : p.value * 2;
const max = p.value > 0 ? p.value * 2.5 + 5 : Math.max(p.value * 0.25, 5);
const step = Math.max(Math.abs(p.value) / 100, 0.1);
return (
<label key={p.name} className="param">
<span>{p.label}</span>
<span title={p.label}>{p.label}</span>
<input
type="range"
min={Math.min(p.value * 0.2, 0)}
max={Math.max(p.value * 2.5, p.value + 10)}
step={Math.max(Math.abs(p.value) / 100, 0.1)}
min={min}
max={max}
step={step}
value={p.value}
disabled={busy}
onChange={(e) => onParam(p.name, parseFloat(e.target.value))}
/>
<b>{p.value.toFixed(1)}</b>
</label>
))}
);
})}
</div>
)}
@ -184,21 +387,25 @@ export default function App() {
disabled={busy}
/>
<button onClick={send} disabled={busy || !input.trim()}>
{busy ? "…" : "↑"}
{busy ? <span className="spin" /> : "↑"}
</button>
</div>
</aside>
<main className="stage">
<Viewer stl={stl} />
{stl && (
<Viewer stl={stl} fitKey={fitKey} captureRef={captureRef} />
<StageOverlay stage={stage} label={STAGE_LABEL[stage]} />
{title && <div className="title-chip">{title}</div>}
{stl && !stage && (
<div className="toolbar">
<button onClick={() => setDesignVersion((v) => v + 1)} className="ghost" title="Fit to view">
Fit
</button>
<button onClick={download}>Download STL</button>
<button
className="ghost"
onClick={() => navigator.clipboard.writeText(script)}
title="Copy the JSCAD source"
>
<button onClick={downloadStep} disabled={steppy} title="True B-rep for Fusion/SolidWorks/FreeCAD">
{steppy ? "Building STEP…" : "Download STEP"}
</button>
<button className="ghost" onClick={() => navigator.clipboard.writeText(script)} title="Copy the JSCAD source">
Copy script
</button>
</div>

View file

@ -0,0 +1,50 @@
interface DesignSummary {
id: string;
title: string;
updated_at: number;
}
function ago(ts: number): string {
const s = Math.floor((Date.now() - ts) / 1000);
if (s < 60) return "just now";
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
export function DesignsDrawer({
open,
designs,
activeId,
onSelect,
onClose,
}: {
open: boolean;
designs: DesignSummary[];
activeId: string | null;
onSelect: (id: string) => void;
onClose: () => void;
}) {
if (!open) return null;
return (
<div className="drawer">
<div className="drawer-head">
Your designs
<button onClick={onClose}></button>
</div>
{designs.length === 0 && <div className="drawer-empty">No saved designs yet.</div>}
<div className="drawer-list">
{designs.map((d) => (
<button
key={d.id}
className={`drawer-item ${d.id === activeId ? "active" : ""}`}
onClick={() => onSelect(d.id)}
>
<span className="di-title">{d.title || "Untitled"}</span>
<span className="di-time">{ago(d.updated_at)}</span>
</button>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,45 @@
import type { Stage } from "../App";
// A hard-to-miss overlay on the 3D stage while work is happening. Shows an
// animated wireframe cube, the current step, and a stepper so the user always
// knows something is happening and roughly where in the pipeline we are.
const STEPS: { key: Stage; short: string }[] = [
{ key: "thinking", short: "Design" },
{ key: "building", short: "Build" },
{ key: "checking", short: "Check" },
];
function stepIndex(stage: Stage): number {
if (stage === "thinking") return 0;
if (stage === "building" || stage === "fixing") return 1;
if (stage === "checking" || stage === "refining") return 2;
return -1;
}
export function StageOverlay({ stage, label }: { stage: Stage; label: string }) {
if (!stage) return null;
const active = stepIndex(stage);
return (
<div className="overlay">
<div className="overlay-card">
<div className="cube-loader" aria-hidden>
<div className="cube">
<span /><span /><span /><span /><span /><span />
</div>
</div>
<div className="overlay-label">
{label}
<span className="dots"><i /><i /><i /></span>
</div>
<div className="stepper">
{STEPS.map((s, i) => (
<div key={s.key} className={`step ${i < active ? "done" : ""} ${i === active ? "on" : ""}`}>
<span className="dot" />
{s.short}
</div>
))}
</div>
</div>
</div>
);
}

View file

@ -1,37 +1,61 @@
import { useEffect, useMemo, useRef } from "react";
import { Canvas } from "@react-three/fiber";
import { useEffect, useMemo, useRef, type MutableRefObject } from "react";
import { Canvas, useThree } from "@react-three/fiber";
import { OrbitControls, GizmoHelper, GizmoViewport, Grid, Center, Bounds } from "@react-three/drei";
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
import type { BufferGeometry } from "three";
function Model({ stl }: { stl: ArrayBuffer }) {
// Parse once per STL buffer. STLLoader.parse wants an ArrayBuffer.
const geometry = useMemo<BufferGeometry>(() => {
const g = new STLLoader().parse(stl.slice(0));
g.computeVertexNormals();
return g;
}, [stl]);
const ref = useRef<BufferGeometry>(null);
useEffect(() => () => geometry.dispose(), [geometry]);
return (
<Bounds fit clip observe margin={1.3}>
<Center>
<mesh geometry={geometry} castShadow receiveShadow>
<primitive object={geometry} attach="geometry" ref={ref} />
<meshStandardMaterial color="#FF2D55" metalness={0.15} roughness={0.55} />
</mesh>
</Center>
</Bounds>
);
}
export function Viewer({ stl }: { stl: ArrayBuffer | null }) {
// Exposes a screenshot function to the parent (used by the vision-verify loop).
// Needs preserveDrawingBuffer on the GL context (set on <Canvas> below).
function CaptureBridge({ captureRef }: { captureRef: MutableRefObject<(() => string | null) | null> }) {
const gl = useThree((s) => s.gl);
useEffect(() => {
captureRef.current = () => {
try {
return gl.domElement.toDataURL("image/png");
} catch {
return null;
}
};
return () => {
captureRef.current = null;
};
}, [gl, captureRef]);
return null;
}
interface ViewerProps {
stl: ArrayBuffer | null;
// Changes ONLY when a new design loads (or the user hits Fit). Param tweaks keep
// the same fitKey so the camera holds still and size changes stay visible.
fitKey: string;
captureRef: MutableRefObject<(() => string | null) | null>;
}
export function Viewer({ stl, fitKey, captureRef }: ViewerProps) {
const controls = useRef(null);
return (
<Canvas
shadows
camera={{ position: [80, 60, 80], fov: 45, near: 0.1, far: 5000 }}
gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [90, 70, 90], fov: 45, near: 0.1, far: 8000 }}
style={{ background: "#0B0B09" }}
>
<ambientLight intensity={0.6} />
@ -41,16 +65,22 @@ export function Viewer({ stl }: { stl: ArrayBuffer | null }) {
infiniteGrid
cellSize={5}
sectionSize={50}
fadeDistance={600}
fadeDistance={800}
cellColor="#2a2a26"
sectionColor="#3a2a2e"
position={[0, -0.01, 0]}
/>
{stl && <Model stl={stl} />}
<OrbitControls makeDefault enableDamping />
{/* key={fitKey} → Bounds refits exactly once per new design, not per param. */}
{stl && (
<Bounds key={fitKey} fit clip margin={1.35}>
<Model stl={stl} />
</Bounds>
)}
<OrbitControls ref={controls} makeDefault enableDamping />
<GizmoHelper alignment="bottom-right" margin={[70, 70]}>
<GizmoViewport axisColors={["#FF2D55", "#3ddc84", "#4a9eff"]} labelColor="#eee" />
</GizmoHelper>
<CaptureBridge captureRef={captureRef} />
</Canvas>
);
}

View file

@ -17,9 +17,23 @@ body { background: var(--bg); color: var(--text); font-family: var(--mono); }
/* --- Chat panel --- */
.chat { display: flex; flex-direction: column; background: var(--panel); border-right: 1px solid var(--line); min-height: 0; }
.brand { padding: 18px 20px; border-bottom: 1px solid var(--line); display: flex; align-items: baseline; gap: 10px; }
.logo { font-family: var(--display); font-weight: 800; letter-spacing: 1px; color: var(--red); font-size: 20px; }
.tagline { color: var(--muted); font-size: 12px; }
.brand { padding: 14px 16px; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 10px; position: relative; }
.logo { font-family: var(--display); font-weight: 800; letter-spacing: 1px; color: var(--red); font-size: 20px; flex: 1; }
.menu { width: 32px; height: 32px; border: 1px solid var(--line); background: #0d0d0b; color: var(--text); border-radius: 8px; cursor: pointer; font-size: 15px; line-height: 1; }
.menu:hover { border-color: var(--red); }
.menu.new { color: var(--red); font-weight: 700; }
/* --- Designs drawer --- */
.drawer { position: absolute; top: 58px; left: 8px; right: 8px; z-index: 30; background: #16160f; border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 20px 60px rgba(0,0,0,.6); max-height: 60vh; display: flex; flex-direction: column; overflow: hidden; }
.drawer-head { display: flex; justify-content: space-between; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--line); font-size: 12px; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); }
.drawer-head button { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 13px; }
.drawer-empty { padding: 18px 14px; color: var(--muted); font-size: 13px; }
.drawer-list { overflow-y: auto; }
.drawer-item { width: 100%; text-align: left; display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 11px 14px; background: none; border: none; border-bottom: 1px solid #1c1c17; color: var(--text); cursor: pointer; font-family: var(--mono); }
.drawer-item:hover { background: #1d1d15; }
.drawer-item.active { border-left: 2px solid var(--red); }
.di-title { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.di-time { font-size: 11px; color: var(--muted); flex-shrink: 0; }
.messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 10px; min-height: 0; }
.empty { color: var(--muted); font-size: 13px; }
@ -30,7 +44,18 @@ body { background: var(--bg); color: var(--text); font-family: var(--mono); }
.msg { padding: 10px 13px; border-radius: 10px; font-size: 13px; line-height: 1.5; white-space: pre-wrap; max-width: 92%; }
.msg.user { background: var(--red); color: #fff; align-self: flex-end; border-bottom-right-radius: 3px; }
.msg.assistant { background: #1b1b18; align-self: flex-start; border-bottom-left-radius: 3px; }
.msg.status { color: var(--muted); font-style: italic; background: none; padding: 4px 2px; }
.msg.thinking { display: flex; align-items: center; gap: 8px; color: var(--muted); }
/* animated thinking dots */
.dots { display: inline-flex; gap: 3px; margin-left: 2px; }
.dots i { width: 5px; height: 5px; border-radius: 50%; background: var(--red); display: inline-block; animation: bounce 1.2s infinite ease-in-out both; }
.dots i:nth-child(1) { animation-delay: -0.24s; }
.dots i:nth-child(2) { animation-delay: -0.12s; }
@keyframes bounce { 0%, 80%, 100% { transform: scale(.5); opacity: .4; } 40% { transform: scale(1); opacity: 1; } }
/* send-button spinner */
.spin { width: 15px; height: 15px; border: 2px solid rgba(255,255,255,.35); border-top-color: #fff; border-radius: 50%; display: inline-block; animation: rot .7s linear infinite; }
@keyframes rot { to { transform: rotate(360deg); } }
/* --- Parameters --- */
.params { border-top: 1px solid var(--line); padding: 12px 16px; max-height: 32vh; overflow-y: auto; }
@ -49,7 +74,37 @@ body { background: var(--bg); color: var(--text); font-family: var(--mono); }
/* --- Stage --- */
.stage { position: relative; }
.toolbar { position: absolute; top: 16px; right: 16px; display: flex; gap: 8px; }
.toolbar { position: absolute; top: 16px; right: 16px; display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; max-width: 70%; }
.toolbar button { font-family: var(--mono); font-size: 12px; padding: 9px 14px; border-radius: 8px; border: 1px solid var(--red); background: var(--red); color: #fff; cursor: pointer; }
.toolbar button:disabled { opacity: .6; cursor: default; }
.toolbar button.ghost { background: transparent; border-color: var(--line); color: var(--text); }
.toolbar button.ghost:hover { border-color: var(--red); }
.title-chip { position: absolute; top: 18px; left: 18px; font-family: var(--display); font-weight: 600; font-size: 14px; color: var(--text); background: rgba(11,11,9,.7); border: 1px solid var(--line); border-radius: 20px; padding: 6px 14px; backdrop-filter: blur(6px); max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* --- Progress overlay --- */
.overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: radial-gradient(circle at center, rgba(11,11,9,.55), rgba(11,11,9,.82)); backdrop-filter: blur(2px); z-index: 20; animation: fade .2s ease; }
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
.overlay-card { display: flex; flex-direction: column; align-items: center; gap: 22px; }
.overlay-label { font-family: var(--display); font-weight: 600; font-size: 17px; color: var(--text); display: flex; align-items: center; }
/* spinning wireframe cube */
.cube-loader { width: 64px; height: 64px; perspective: 240px; }
.cube { width: 100%; height: 100%; position: relative; transform-style: preserve-3d; animation: spinCube 2.4s infinite linear; }
.cube span { position: absolute; width: 64px; height: 64px; border: 2px solid var(--red); background: rgba(255,45,85,.06); }
.cube span:nth-child(1) { transform: rotateY(0deg) translateZ(32px); }
.cube span:nth-child(2) { transform: rotateY(90deg) translateZ(32px); }
.cube span:nth-child(3) { transform: rotateY(180deg) translateZ(32px); }
.cube span:nth-child(4) { transform: rotateY(-90deg) translateZ(32px); }
.cube span:nth-child(5) { transform: rotateX(90deg) translateZ(32px); }
.cube span:nth-child(6) { transform: rotateX(-90deg) translateZ(32px); }
@keyframes spinCube { from { transform: rotateX(-24deg) rotateY(0deg); } to { transform: rotateX(-24deg) rotateY(360deg); } }
/* pipeline stepper */
.stepper { display: flex; gap: 26px; }
.step { display: flex; flex-direction: column; align-items: center; gap: 6px; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); }
.step .dot { width: 9px; height: 9px; border-radius: 50%; background: #333; border: 1px solid #444; }
.step.done .dot { background: #3ddc84; border-color: #3ddc84; }
.step.on .dot { background: var(--red); border-color: var(--red); box-shadow: 0 0 0 4px rgba(255,45,85,.25); animation: pulse 1.1s infinite; }
.step.on { color: var(--text); }
@keyframes pulse { 0%,100% { box-shadow: 0 0 0 3px rgba(255,45,85,.25); } 50% { box-shadow: 0 0 0 7px rgba(255,45,85,.05); } }

23
src/worker/auth.ts Normal file
View file

@ -0,0 +1,23 @@
import type { Context, Next } from "hono";
import { getCookie, setCookie } from "hono/cookie";
// Lightweight anonymous identity: a random uid in an httpOnly cookie. No login
// wall — every visitor gets a stable id their designs are scoped to. Real auth
// (magic link / OAuth) can layer on top later without changing the data model.
const COOKIE = "chisel_uid";
export async function identity(c: Context, next: Next) {
let uid = getCookie(c, COOKIE);
if (!uid) {
uid = crypto.randomUUID();
setCookie(c, COOKIE, uid, {
httpOnly: true,
secure: true,
sameSite: "Lax",
path: "/",
maxAge: 60 * 60 * 24 * 400, // 400 days — Hono's max allowed cookie lifetime
});
}
c.set("uid", uid);
await next();
}

View file

@ -1,4 +1,4 @@
import { SYSTEM_PROMPT } from "./prompts";
import { SYSTEM_PROMPT, VERIFY_SYSTEM } from "./prompts";
// Default to Sonnet 4.6 for snappy chat latency. Opus 4.8 gives noticeably better
// geometric reasoning for hard parts — flip MODEL if quality matters more than speed.
@ -53,3 +53,51 @@ export async function callClaude(apiKey: string, messages: ChatMsg[]): Promise<M
if (!parsed.script) throw new Error("Model returned no code block.");
return parsed;
}
export interface Verdict {
matches: boolean;
critique: string;
}
// Vision check: does the rendered model match the request? pngDataUrl is a
// "data:image/png;base64,..." string captured from the WebGL canvas.
export async function verifyModel(
apiKey: string,
request: string,
pngDataUrl: string
): Promise<Verdict> {
const b64 = pngDataUrl.replace(/^data:image\/png;base64,/, "");
const res = await fetch(API, {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: MODEL,
max_tokens: 300,
system: [{ type: "text", text: VERIFY_SYSTEM, cache_control: { type: "ephemeral" } }],
messages: [
{
role: "user",
content: [
{ type: "image", source: { type: "base64", media_type: "image/png", data: b64 } },
{ type: "text", text: `The user asked for: "${request}". Does this model match?` },
],
},
],
}),
});
if (!res.ok) throw new Error(`Verify API ${res.status}`);
const data = (await res.json()) as { content: Array<{ type: string; text?: string }> };
const raw = data.content.filter((b) => b.type === "text").map((b) => b.text).join("");
const json = raw.match(/\{[\s\S]*\}/);
if (!json) return { matches: true, critique: "" }; // fail open — never block on a flaky judge
try {
const v = JSON.parse(json[0]) as Verdict;
return { matches: !!v.matches, critique: v.critique || "" };
} catch {
return { matches: true, critique: "" };
}
}

65
src/worker/db.ts Normal file
View file

@ -0,0 +1,65 @@
export interface DesignRow {
id: string;
uid: string;
title: string;
script: string;
messages: string;
created_at: number;
updated_at: number;
}
export interface DesignSummary {
id: string;
title: string;
updated_at: number;
mine: boolean;
}
export async function listDesigns(db: D1Database, uid: string): Promise<DesignSummary[]> {
const { results } = await db
.prepare("SELECT id, title, updated_at FROM designs WHERE uid = ? ORDER BY updated_at DESC LIMIT 100")
.bind(uid)
.all<{ id: string; title: string; updated_at: number }>();
return (results ?? []).map((r) => ({ ...r, mine: true }));
}
export async function getDesign(db: D1Database, id: string): Promise<DesignRow | null> {
return db.prepare("SELECT * FROM designs WHERE id = ?").bind(id).first<DesignRow>();
}
export async function saveDesign(
db: D1Database,
uid: string,
d: { id?: string; title: string; script: string; messages: unknown },
now: number
): Promise<string> {
const id = d.id ?? crypto.randomUUID();
const messages = JSON.stringify(d.messages ?? []);
// Upsert — but only the owner may overwrite an existing row.
const existing = await getDesign(db, id);
if (existing && existing.uid !== uid) {
// Someone editing a design that isn't theirs → fork it under a new id.
return saveDesign(db, uid, { title: d.title, script: d.script, messages: d.messages }, now);
}
if (existing) {
await db
.prepare("UPDATE designs SET title = ?, script = ?, messages = ?, updated_at = ? WHERE id = ?")
.bind(d.title, d.script, messages, now, id)
.run();
} else {
await db
.prepare(
"INSERT INTO designs (id, uid, title, script, messages, created_at, updated_at) VALUES (?,?,?,?,?,?,?)"
)
.bind(id, uid, d.title, d.script, messages, now, now)
.run();
}
return id;
}
export async function deleteDesign(db: D1Database, uid: string, id: string): Promise<boolean> {
const res = await db.prepare("DELETE FROM designs WHERE id = ? AND uid = ?").bind(id, uid).run();
return (res.meta.changes ?? 0) > 0;
}

View file

@ -1,19 +1,29 @@
import { Hono } from "hono";
import { callClaude, type ChatMsg } from "./claude";
import { callClaude, verifyModel, type ChatMsg } from "./claude";
import { editContext, repairContext } from "./prompts";
import { identity } from "./auth";
import { listDesigns, getDesign, saveDesign, deleteDesign } from "./db";
import { generateStep } from "./step";
interface Env {
ANTHROPIC_API_KEY: string;
STEP_SERVICE_URL?: string;
DB: D1Database;
ASSETS: { fetch: typeof fetch };
}
const app = new Hono<{ Bindings: Env }>();
type Vars = { uid: string };
const app = new Hono<{ Bindings: Env; Variables: Vars }>();
// API responses must never be edge-cached (they're per-user and dynamic).
app.use("/api/*", async (c, next) => {
await next();
c.header("Cache-Control", "no-store");
});
app.use("/api/*", identity);
// --- Generate / edit a design -------------------------------------------------
// The client sends the running chat plus (optionally) the current script. If a
// script exists we prepend an edit-context turn so the model patches it rather
// than starting over. The JSCAD is executed client-side in a sandboxed Web
// Worker; the repair loop lives at /api/repair.
app.post("/api/generate", async (c) => {
const { messages, currentScript } = await c.req.json<{
messages: ChatMsg[];
@ -42,7 +52,6 @@ app.post("/api/generate", async (c) => {
app.post("/api/repair", async (c) => {
const { script, error } = await c.req.json<{ script: string; error: string }>();
if (!c.env.ANTHROPIC_API_KEY) return c.json({ error: "ANTHROPIC_API_KEY not set" }, 500);
try {
const result = await callClaude(c.env.ANTHROPIC_API_KEY, [
{ role: "user", content: repairContext(script, error) },
@ -53,6 +62,67 @@ app.post("/api/repair", async (c) => {
}
});
// --- Vision verification (B) --------------------------------------------------
// Client renders the model, snapshots the canvas, and asks: does this match?
app.post("/api/verify", async (c) => {
const { request, image } = await c.req.json<{ request: string; image: string }>();
if (!c.env.ANTHROPIC_API_KEY) return c.json({ error: "ANTHROPIC_API_KEY not set" }, 500);
if (!image || !request) return c.json({ matches: true, critique: "" });
try {
const verdict = await verifyModel(c.env.ANTHROPIC_API_KEY, request, image);
return c.json(verdict);
} catch {
return c.json({ matches: true, critique: "" }); // never block the user on a flaky judge
}
});
// --- STEP export (D) ----------------------------------------------------------
app.post("/api/step", async (c) => {
const { request } = await c.req.json<{ request: string }>();
if (!c.env.STEP_SERVICE_URL) return c.json({ error: "STEP engine not configured yet." }, 503);
if (!c.env.ANTHROPIC_API_KEY) return c.json({ error: "ANTHROPIC_API_KEY not set" }, 500);
try {
const step = await generateStep(c.env.ANTHROPIC_API_KEY, c.env.STEP_SERVICE_URL, request);
return new Response(step, {
headers: {
"content-type": "application/step",
"content-disposition": 'attachment; filename="chisel-model.step"',
},
});
} catch (e) {
return c.json({ error: (e as Error).message }, 500);
}
});
// --- Persistence (C) ----------------------------------------------------------
app.get("/api/designs", async (c) => {
const designs = await listDesigns(c.env.DB, c.get("uid"));
return c.json({ designs });
});
app.get("/api/designs/:id", async (c) => {
const row = await getDesign(c.env.DB, c.req.param("id"));
if (!row) return c.json({ error: "not found" }, 404);
return c.json({
id: row.id,
title: row.title,
script: row.script,
messages: JSON.parse(row.messages),
mine: row.uid === c.get("uid"),
});
});
app.post("/api/designs", async (c) => {
const body = await c.req.json<{ id?: string; title: string; script: string; messages: unknown }>();
const id = await saveDesign(c.env.DB, c.get("uid"), body, Date.now());
return c.json({ id });
});
app.delete("/api/designs/:id", async (c) => {
const ok = await deleteDesign(c.env.DB, c.get("uid"), c.req.param("id"));
return c.json({ ok });
});
// Everything else → static assets / SPA fallback.
app.all("*", (c) => c.env.ASSETS.fetch(c.req.raw));

View file

@ -59,6 +59,19 @@ You'll be given the previous script. Make the SMALLEST change that satisfies the
## When repairing an error
You'll be given a script and the runtime error it threw. Fix the bug and return the complete corrected script. Common causes: wrong argument shape, radians vs degrees, non-overlapping booleans, undefined variable, using an API that doesn't exist.`;
// Vision judge. Sees a render of the model and the request; decides if it's a
// faithful match. Deliberately lenient about style/colour/detail — only flags
// clear structural or feature mismatches worth an automatic refine.
export const VERIFY_SYSTEM = `You are Chisel's quality checker. You are shown a 3D render of a CAD model and the user's original request. Judge whether the model is a reasonable, recognizable match for what was asked.
Be pragmatic, not pedantic:
- Colour is always red and lighting is neutral IGNORE those.
- Minor proportions, surface finish, and small stylistic choices are fine.
- ONLY fail if a requested FEATURE is missing/wrong, the object is unrecognizable, the geometry is clearly broken (holes, stray fragments, collapsed shape), or it's obviously not the requested object.
Respond with ONLY a JSON object, no prose, no code fence:
{"matches": true|false, "critique": "one sentence — if matches:false, the single most important concrete fix, phrased as an instruction (e.g. 'The handle is missing; add a looped handle on one side.')"}`;
export function editContext(previousScript: string): string {
return `Here is the current design script. Edit it per my next message and return the full updated script.\n\n\`\`\`javascript\n${previousScript}\n\`\`\``;
}

69
src/worker/step.ts Normal file
View file

@ -0,0 +1,69 @@
// STEP export engine (D). Claude authors build123d (Python + OpenCascade) code,
// which a small execution service runs to emit a true B-rep STEP file — the
// format that reopens cleanly in Fusion / SolidWorks / FreeCAD.
//
// The service (see step-service/) exposes POST /run {code} -> STEP bytes.
const API = "https://api.anthropic.com/v1/messages";
const MODEL = "claude-sonnet-4-6";
const BUILD123D_SYSTEM = `You are Chisel's STEP engineer. Turn the request into a build123d (Python) script that builds ONE solid and exports it to STEP.
Output EXACTLY one \`\`\`python code block, nothing else.
Rules:
- Use build123d's builder API. Units are millimetres.
- Assign the final solid to a variable named \`result\` (a Part/Compound/Solid).
- Do NOT write any file yourself and do NOT call export_step the runner assigns \`result\` and exports it.
- Keep it manufacturable: real wall thickness, no self-intersections, clean booleans.
Example:
\`\`\`python
from build123d import *
with BuildPart() as part:
Box(40, 40, 20)
with Locations((0, 0, 0)):
Hole(radius=5)
result = part.part
\`\`\``;
async function claudePython(apiKey: string, request: string): Promise<string> {
const res = await fetch(API, {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: MODEL,
max_tokens: 4000,
system: [{ type: "text", text: BUILD123D_SYSTEM, cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: request }],
}),
});
if (!res.ok) throw new Error(`Claude API ${res.status}`);
const data = (await res.json()) as { content: Array<{ type: string; text?: string }> };
const raw = data.content.filter((b) => b.type === "text").map((b) => b.text).join("");
const fence = raw.match(/```(?:python|py)?\s*\n([\s\S]*?)```/);
if (!fence) throw new Error("STEP engineer returned no code.");
return fence[1].trim();
}
export async function generateStep(
apiKey: string,
serviceUrl: string,
request: string
): Promise<ArrayBuffer> {
const code = await claudePython(apiKey, request);
const res = await fetch(`${serviceUrl.replace(/\/$/, "")}/run`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ code }),
});
if (!res.ok) {
const t = await res.text();
throw new Error(`STEP service ${res.status}: ${t.slice(0, 300)}`);
}
return res.arrayBuffer();
}

View file

@ -16,7 +16,15 @@
"binding": "ASSETS",
"not_found_handling": "single-page-application",
"run_worker_first": ["/api/*"]
},
"d1_databases": [
{
"binding": "DB",
"database_name": "chisel",
"database_id": "3838ec37-45b2-44e3-b31a-a12a138400ff",
"migrations_dir": "migrations"
}
]
// ANTHROPIC_API_KEY is set via: wrangler secret put ANTHROPIC_API_KEY
// v2: add a Container binding here for the build123d / STEP engine.
// STEP_SERVICE_URL (build123d STEP engine) set via: wrangler secret put STEP_SERVICE_URL
}