diff --git a/src/client/App.tsx b/src/client/App.tsx index 562b0b3..1681c13 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -3,6 +3,8 @@ import { Viewer } from "./components/Viewer"; import { StageOverlay } from "./components/StageOverlay"; import { DesignsDrawer } from "./components/DesignsDrawer"; import { ColorPicker } from "./components/ColorPicker"; +import { FinishPicker } from "./components/FinishPicker"; +import type { Finish } from "./components/Viewer"; import { runJscad, parseParams, type Param } from "./engine"; interface Msg { @@ -53,6 +55,14 @@ export default function App() { return "#FF2D55"; } }); + const [finish, setFinish] = useState(() => { + try { + return (localStorage.getItem("chisel_finish") as Finish) || "plastic"; + } catch { + return "plastic"; + } + }); + const [hideOverlays, setHideOverlays] = useState(false); const scrollRef = useRef(null); const captureRef = useRef<(() => string | null) | null>(null); @@ -311,6 +321,28 @@ export default function App() { } }, []); + const setModelFinish = useCallback((f: Finish) => { + setFinish(f); + try { + localStorage.setItem("chisel_finish", f); + } catch { + /* ignore */ + } + }, []); + + // Clean product-shot PNG: hide grid + gizmo, render, capture, restore. + const snapshot = useCallback(async () => { + setHideOverlays(true); + await new Promise((r) => setTimeout(r, 150)); // let React drop the overlays + const url = captureRef.current?.(); + setHideOverlays(false); + if (!url) return; + const a = document.createElement("a"); + a.href = url; + a.download = `${(title || "chisel-model").replace(/[^a-z0-9]+/gi, "-").toLowerCase()}.png`; + a.click(); + }, [title]); + const fitKey = String(designVersion); return ( @@ -410,15 +442,30 @@ export default function App() {
- + {title &&
{title}
} - {stl && !stage && } + {stl && !stage && ( +
+ + +
+ )} {stl && !stage && (
+ + ))} +
+ ); +} diff --git a/src/client/components/Viewer.tsx b/src/client/components/Viewer.tsx index 1f2e901..d14db0f 100644 --- a/src/client/components/Viewer.tsx +++ b/src/client/components/Viewer.tsx @@ -1,29 +1,68 @@ 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 { + OrbitControls, + GizmoHelper, + GizmoViewport, + Grid, + Bounds, + Environment, + Lightformer, + ContactShadows, +} from "@react-three/drei"; import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js"; import type { BufferGeometry } from "three"; -function Model({ stl, color }: { stl: ArrayBuffer; color: string }) { +// Material finishes — each maps to physical-material params. Keys are the source +// of truth shared with the FinishPicker UI. +export type Finish = "plastic" | "matte" | "glossy" | "metal" | "chrome"; +export const FINISHES: Record = { + plastic: { metalness: 0.15, roughness: 0.55, clearcoat: 0, clearcoatRoughness: 0 }, + matte: { metalness: 0.0, roughness: 0.95, clearcoat: 0, clearcoatRoughness: 0 }, + glossy: { metalness: 0.1, roughness: 0.18, clearcoat: 1, clearcoatRoughness: 0.12 }, + metal: { metalness: 1.0, roughness: 0.35, clearcoat: 0, clearcoatRoughness: 0 }, + chrome: { metalness: 1.0, roughness: 0.05, clearcoat: 0, clearcoatRoughness: 0 }, +}; +export const FINISH_OPTIONS: { key: Finish; label: string }[] = [ + { key: "plastic", label: "Plastic" }, + { key: "matte", label: "Matte" }, + { key: "glossy", label: "Glossy" }, + { key: "metal", label: "Metal" }, + { key: "chrome", label: "Chrome" }, +]; + +function Model({ stl, color, finish }: { stl: ArrayBuffer; color: string; finish: Finish }) { const geometry = useMemo(() => { const g = new STLLoader().parse(stl.slice(0)); g.computeVertexNormals(); + g.computeBoundingBox(); return g; }, [stl]); useEffect(() => () => geometry.dispose(), [geometry]); + // Center on X/Z and rest the model ON the ground plane (y=0) so ContactShadows + // sit under it instead of through its middle. + const bb = geometry.boundingBox!; + const cx = (bb.min.x + bb.max.x) / 2; + const cz = (bb.min.z + bb.max.z) / 2; + const f = FINISHES[finish] ?? FINISHES.plastic; + return ( -
- - - -
+ + + ); } -// Exposes a screenshot function to the parent (used by the vision-verify loop). -// Needs preserveDrawingBuffer on the GL context (set on below). +// Exposes a screenshot function to the parent (vision-verify + snapshot). function CaptureBridge({ captureRef }: { captureRef: MutableRefObject<(() => string | null) | null> }) { const gl = useThree((s) => s.gl); const scene = useThree((s) => s.scene); @@ -31,10 +70,7 @@ function CaptureBridge({ captureRef }: { captureRef: MutableRefObject<(() => str useEffect(() => { captureRef.current = () => { try { - // Force a synchronous render into the (preserved) drawing buffer right - // before reading it — otherwise toDataURL can grab a pre-render black - // frame on the first/heavy model and the verifier thinks it's empty. - gl.render(scene, camera); + gl.render(scene, camera); // force a fresh frame into the preserved buffer return gl.domElement.toDataURL("image/png"); } catch { return null; @@ -49,44 +85,61 @@ function CaptureBridge({ captureRef }: { captureRef: MutableRefObject<(() => str 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; color: string; + finish: Finish; + hideOverlays?: boolean; // hide grid + gizmo for clean snapshots captureRef: MutableRefObject<(() => string | null) | null>; } -export function Viewer({ stl, fitKey, color, captureRef }: ViewerProps) { +export function Viewer({ stl, fitKey, color, finish, hideOverlays, captureRef }: ViewerProps) { const controls = useRef(null); return ( - - - - + {/* Scene background so captures render on a solid colour, not transparent. */} + + + + + {/* Self-contained studio environment (no external HDRI fetch) for reflections. */} + + + + + + + {!hideOverlays && ( + + )} + {/* key={fitKey} → Bounds refits exactly once per new design, not per param. */} {stl && ( - - + + )} + {stl && ( + + )} + - - - + {!hideOverlays && ( + + + + )} ); diff --git a/src/client/styles.css b/src/client/styles.css index 2144be0..e246b12 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -82,8 +82,15 @@ body { background: var(--bg); color: var(--text); font-family: var(--mono); } .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; } +/* --- Appearance cluster (colour + finish) --- */ +.appearance { position: absolute; bottom: 18px; left: 18px; display: flex; flex-direction: column; gap: 8px; align-items: flex-start; } +.finishpicker { display: flex; gap: 4px; background: rgba(11,11,9,.7); border: 1px solid var(--line); border-radius: 20px; padding: 4px; backdrop-filter: blur(8px); } +.finish { font-family: var(--mono); font-size: 11px; padding: 5px 11px; border-radius: 16px; border: none; background: transparent; color: var(--muted); cursor: pointer; transition: color .12s, background .12s; } +.finish:hover { color: var(--text); } +.finish.active { background: var(--red); color: #fff; } + /* --- Colour selector --- */ -.colorpicker { position: absolute; bottom: 18px; left: 18px; display: flex; gap: 6px; align-items: center; background: rgba(11,11,9,.7); border: 1px solid var(--line); border-radius: 24px; padding: 7px 10px; backdrop-filter: blur(8px); } +.colorpicker { display: flex; gap: 6px; align-items: center; background: rgba(11,11,9,.7); border: 1px solid var(--line); border-radius: 24px; padding: 7px 10px; backdrop-filter: blur(8px); } .swatch { width: 20px; height: 20px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; padding: 0; box-shadow: inset 0 0 0 1px rgba(255,255,255,.12); transition: transform .1s; } .swatch:hover { transform: scale(1.15); } .swatch.active { border-color: #fff; }