feat: material finishes, HDRI lighting, contact shadows, snapshot

- Finishes (Plastic/Matte/Glossy/Metal/Chrome) via meshPhysicalMaterial pills
- Self-contained studio Environment (Lightformers, no external HDRI fetch) so
  metal/glossy actually reflect; ContactShadows ground the model on y=0
- Models now rest on the floor plane (centered X/Z) instead of floating centered
- Snapshot button → clean PNG (hides grid+gizmo, forces render, downloads)
- color + finish persisted to localStorage

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
maverick 2026-07-25 15:19:50 +10:00
parent 9bf79ccdc3
commit 6f2a43f4d7
4 changed files with 162 additions and 37 deletions

View file

@ -3,6 +3,8 @@ import { Viewer } from "./components/Viewer";
import { StageOverlay } from "./components/StageOverlay"; import { StageOverlay } from "./components/StageOverlay";
import { DesignsDrawer } from "./components/DesignsDrawer"; import { DesignsDrawer } from "./components/DesignsDrawer";
import { ColorPicker } from "./components/ColorPicker"; import { ColorPicker } from "./components/ColorPicker";
import { FinishPicker } from "./components/FinishPicker";
import type { Finish } from "./components/Viewer";
import { runJscad, parseParams, type Param } from "./engine"; import { runJscad, parseParams, type Param } from "./engine";
interface Msg { interface Msg {
@ -53,6 +55,14 @@ export default function App() {
return "#FF2D55"; return "#FF2D55";
} }
}); });
const [finish, setFinish] = useState<Finish>(() => {
try {
return (localStorage.getItem("chisel_finish") as Finish) || "plastic";
} catch {
return "plastic";
}
});
const [hideOverlays, setHideOverlays] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const captureRef = useRef<(() => string | null) | null>(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); const fitKey = String(designVersion);
return ( return (
@ -410,15 +442,30 @@ export default function App() {
</aside> </aside>
<main className="stage"> <main className="stage">
<Viewer stl={stl} fitKey={fitKey} color={color} captureRef={captureRef} /> <Viewer
stl={stl}
fitKey={fitKey}
color={color}
finish={finish}
hideOverlays={hideOverlays}
captureRef={captureRef}
/>
<StageOverlay stage={stage} label={STAGE_LABEL[stage]} /> <StageOverlay stage={stage} label={STAGE_LABEL[stage]} />
{title && <div className="title-chip">{title}</div>} {title && <div className="title-chip">{title}</div>}
{stl && !stage && <ColorPicker color={color} onChange={setModelColor} />} {stl && !stage && (
<div className="appearance">
<ColorPicker color={color} onChange={setModelColor} />
<FinishPicker finish={finish} onChange={setModelFinish} />
</div>
)}
{stl && !stage && ( {stl && !stage && (
<div className="toolbar"> <div className="toolbar">
<button onClick={() => setDesignVersion((v) => v + 1)} className="ghost" title="Fit to view"> <button onClick={() => setDesignVersion((v) => v + 1)} className="ghost" title="Fit to view">
Fit Fit
</button> </button>
<button onClick={snapshot} className="ghost" title="Save a clean PNG render">
Snapshot
</button>
<button onClick={download}>Download STL</button> <button onClick={download}>Download STL</button>
<button onClick={downloadStep} disabled={steppy} title="True B-rep for Fusion/SolidWorks/FreeCAD"> <button onClick={downloadStep} disabled={steppy} title="True B-rep for Fusion/SolidWorks/FreeCAD">
{steppy ? "Building STEP…" : "Download STEP"} {steppy ? "Building STEP…" : "Download STEP"}

View file

@ -0,0 +1,18 @@
import { FINISH_OPTIONS, type Finish } from "./Viewer";
// Material-finish pills. Sits with the colour picker in the appearance cluster.
export function FinishPicker({ finish, onChange }: { finish: Finish; onChange: (f: Finish) => void }) {
return (
<div className="finishpicker">
{FINISH_OPTIONS.map((o) => (
<button
key={o.key}
className={`finish ${o.key === finish ? "active" : ""}`}
onClick={() => onChange(o.key)}
>
{o.label}
</button>
))}
</div>
);
}

View file

@ -1,29 +1,68 @@
import { useEffect, useMemo, useRef, type MutableRefObject } from "react"; import { useEffect, useMemo, useRef, type MutableRefObject } from "react";
import { Canvas, useThree } from "@react-three/fiber"; 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 { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
import type { BufferGeometry } from "three"; 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<Finish, { metalness: number; roughness: number; clearcoat: number; clearcoatRoughness: number }> = {
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<BufferGeometry>(() => { const geometry = useMemo<BufferGeometry>(() => {
const g = new STLLoader().parse(stl.slice(0)); const g = new STLLoader().parse(stl.slice(0));
g.computeVertexNormals(); g.computeVertexNormals();
g.computeBoundingBox();
return g; return g;
}, [stl]); }, [stl]);
useEffect(() => () => geometry.dispose(), [geometry]); 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 ( return (
<Center> <mesh geometry={geometry} position={[-cx, -bb.min.y, -cz]} castShadow receiveShadow>
<mesh geometry={geometry} castShadow receiveShadow> <meshPhysicalMaterial
<meshStandardMaterial color={color} metalness={0.15} roughness={0.55} /> color={color}
</mesh> metalness={f.metalness}
</Center> roughness={f.roughness}
clearcoat={f.clearcoat}
clearcoatRoughness={f.clearcoatRoughness}
envMapIntensity={1}
/>
</mesh>
); );
} }
// Exposes a screenshot function to the parent (used by the vision-verify loop). // Exposes a screenshot function to the parent (vision-verify + snapshot).
// Needs preserveDrawingBuffer on the GL context (set on <Canvas> below).
function CaptureBridge({ captureRef }: { captureRef: MutableRefObject<(() => string | null) | null> }) { function CaptureBridge({ captureRef }: { captureRef: MutableRefObject<(() => string | null) | null> }) {
const gl = useThree((s) => s.gl); const gl = useThree((s) => s.gl);
const scene = useThree((s) => s.scene); const scene = useThree((s) => s.scene);
@ -31,10 +70,7 @@ function CaptureBridge({ captureRef }: { captureRef: MutableRefObject<(() => str
useEffect(() => { useEffect(() => {
captureRef.current = () => { captureRef.current = () => {
try { try {
// Force a synchronous render into the (preserved) drawing buffer right gl.render(scene, camera); // force a fresh frame into the preserved buffer
// 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);
return gl.domElement.toDataURL("image/png"); return gl.domElement.toDataURL("image/png");
} catch { } catch {
return null; return null;
@ -49,44 +85,61 @@ function CaptureBridge({ captureRef }: { captureRef: MutableRefObject<(() => str
interface ViewerProps { interface ViewerProps {
stl: ArrayBuffer | null; 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; fitKey: string;
color: string; color: string;
finish: Finish;
hideOverlays?: boolean; // hide grid + gizmo for clean snapshots
captureRef: MutableRefObject<(() => string | null) | null>; 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); const controls = useRef(null);
return ( return (
<Canvas <Canvas
shadows shadows
gl={{ preserveDrawingBuffer: true, antialias: true }} gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [90, 70, 90], fov: 45, near: 0.1, far: 8000 }} camera={{ position: [90, 70, 90], fov: 45, near: 0.1, far: 8000 }}
style={{ background: "#0B0B09" }}
> >
<ambientLight intensity={0.6} /> {/* Scene background so captures render on a solid colour, not transparent. */}
<directionalLight position={[50, 80, 40]} intensity={1.4} castShadow /> <color attach="background" args={["#0B0B09"]} />
<directionalLight position={[-40, -20, -50]} intensity={0.4} /> <ambientLight intensity={0.35} />
<Grid <directionalLight position={[50, 80, 40]} intensity={1.1} castShadow shadow-mapSize={[1024, 1024]} />
infiniteGrid
cellSize={5} {/* Self-contained studio environment (no external HDRI fetch) for reflections. */}
sectionSize={50} <Environment resolution={256} frames={1}>
fadeDistance={800} <Lightformer form="rect" intensity={3} position={[0, 8, 1]} scale={[12, 8, 1]} rotation={[Math.PI / 2, 0, 0]} />
cellColor="#2a2a26" <Lightformer form="rect" intensity={1.6} position={[-7, 3, -5]} scale={[6, 6, 1]} />
sectionColor="#3a2a2e" <Lightformer form="rect" intensity={1.6} position={[7, 3, 5]} scale={[6, 6, 1]} color="#ffe9e0" />
position={[0, -0.01, 0]} </Environment>
/>
{!hideOverlays && (
<Grid
infiniteGrid
cellSize={5}
sectionSize={50}
fadeDistance={800}
cellColor="#2a2a26"
sectionColor="#3a2a2e"
position={[0, 0, 0]}
/>
)}
{/* key={fitKey} → Bounds refits exactly once per new design, not per param. */} {/* key={fitKey} → Bounds refits exactly once per new design, not per param. */}
{stl && ( {stl && (
<Bounds key={fitKey} fit clip margin={1.35}> <Bounds key={fitKey} fit clip margin={1.3}>
<Model stl={stl} color={color} /> <Model stl={stl} color={color} finish={finish} />
</Bounds> </Bounds>
)} )}
{stl && (
<ContactShadows position={[0, 0, 0]} opacity={0.5} scale={320} blur={2.4} far={140} color="#000000" />
)}
<OrbitControls ref={controls} makeDefault enableDamping /> <OrbitControls ref={controls} makeDefault enableDamping />
<GizmoHelper alignment="bottom-right" margin={[70, 70]}> {!hideOverlays && (
<GizmoViewport axisColors={["#FF2D55", "#3ddc84", "#4a9eff"]} labelColor="#eee" /> <GizmoHelper alignment="bottom-right" margin={[70, 70]}>
</GizmoHelper> <GizmoViewport axisColors={["#FF2D55", "#3ddc84", "#4a9eff"]} labelColor="#eee" />
</GizmoHelper>
)}
<CaptureBridge captureRef={captureRef} /> <CaptureBridge captureRef={captureRef} />
</Canvas> </Canvas>
); );

View file

@ -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; } .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 --- */ /* --- 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 { 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:hover { transform: scale(1.15); }
.swatch.active { border-color: #fff; } .swatch.active { border-color: #fff; }