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:
parent
9bf79ccdc3
commit
6f2a43f4d7
4 changed files with 162 additions and 37 deletions
|
|
@ -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<Finish>(() => {
|
||||
try {
|
||||
return (localStorage.getItem("chisel_finish") as Finish) || "plastic";
|
||||
} catch {
|
||||
return "plastic";
|
||||
}
|
||||
});
|
||||
const [hideOverlays, setHideOverlays] = useState(false);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(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() {
|
|||
</aside>
|
||||
|
||||
<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]} />
|
||||
{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 && (
|
||||
<div className="toolbar">
|
||||
<button onClick={() => setDesignVersion((v) => v + 1)} className="ghost" title="Fit to view">
|
||||
⤢ Fit
|
||||
</button>
|
||||
<button onClick={snapshot} className="ghost" title="Save a clean PNG render">
|
||||
▣ Snapshot
|
||||
</button>
|
||||
<button onClick={download}>Download STL</button>
|
||||
<button onClick={downloadStep} disabled={steppy} title="True B-rep for Fusion/SolidWorks/FreeCAD">
|
||||
{steppy ? "Building STEP…" : "Download STEP"}
|
||||
|
|
|
|||
18
src/client/components/FinishPicker.tsx
Normal file
18
src/client/components/FinishPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<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 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 (
|
||||
<Center>
|
||||
<mesh geometry={geometry} castShadow receiveShadow>
|
||||
<meshStandardMaterial color={color} metalness={0.15} roughness={0.55} />
|
||||
<mesh geometry={geometry} position={[-cx, -bb.min.y, -cz]} castShadow receiveShadow>
|
||||
<meshPhysicalMaterial
|
||||
color={color}
|
||||
metalness={f.metalness}
|
||||
roughness={f.roughness}
|
||||
clearcoat={f.clearcoat}
|
||||
clearcoatRoughness={f.clearcoatRoughness}
|
||||
envMapIntensity={1}
|
||||
/>
|
||||
</mesh>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
// Exposes a screenshot function to the parent (used by the vision-verify loop).
|
||||
// Needs preserveDrawingBuffer on the GL context (set on <Canvas> 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,25 +85,34 @@ 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 (
|
||||
<Canvas
|
||||
shadows
|
||||
gl={{ preserveDrawingBuffer: true, antialias: true }}
|
||||
camera={{ position: [90, 70, 90], fov: 45, near: 0.1, far: 8000 }}
|
||||
style={{ background: "#0B0B09" }}
|
||||
>
|
||||
<ambientLight intensity={0.6} />
|
||||
<directionalLight position={[50, 80, 40]} intensity={1.4} castShadow />
|
||||
<directionalLight position={[-40, -20, -50]} intensity={0.4} />
|
||||
{/* Scene background so captures render on a solid colour, not transparent. */}
|
||||
<color attach="background" args={["#0B0B09"]} />
|
||||
<ambientLight intensity={0.35} />
|
||||
<directionalLight position={[50, 80, 40]} intensity={1.1} castShadow shadow-mapSize={[1024, 1024]} />
|
||||
|
||||
{/* Self-contained studio environment (no external HDRI fetch) for reflections. */}
|
||||
<Environment resolution={256} frames={1}>
|
||||
<Lightformer form="rect" intensity={3} position={[0, 8, 1]} scale={[12, 8, 1]} rotation={[Math.PI / 2, 0, 0]} />
|
||||
<Lightformer form="rect" intensity={1.6} position={[-7, 3, -5]} scale={[6, 6, 1]} />
|
||||
<Lightformer form="rect" intensity={1.6} position={[7, 3, 5]} scale={[6, 6, 1]} color="#ffe9e0" />
|
||||
</Environment>
|
||||
|
||||
{!hideOverlays && (
|
||||
<Grid
|
||||
infiniteGrid
|
||||
cellSize={5}
|
||||
|
|
@ -75,18 +120,26 @@ export function Viewer({ stl, fitKey, color, captureRef }: ViewerProps) {
|
|||
fadeDistance={800}
|
||||
cellColor="#2a2a26"
|
||||
sectionColor="#3a2a2e"
|
||||
position={[0, -0.01, 0]}
|
||||
position={[0, 0, 0]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* key={fitKey} → Bounds refits exactly once per new design, not per param. */}
|
||||
{stl && (
|
||||
<Bounds key={fitKey} fit clip margin={1.35}>
|
||||
<Model stl={stl} color={color} />
|
||||
<Bounds key={fitKey} fit clip margin={1.3}>
|
||||
<Model stl={stl} color={color} finish={finish} />
|
||||
</Bounds>
|
||||
)}
|
||||
{stl && (
|
||||
<ContactShadows position={[0, 0, 0]} opacity={0.5} scale={320} blur={2.4} far={140} color="#000000" />
|
||||
)}
|
||||
|
||||
<OrbitControls ref={controls} makeDefault enableDamping />
|
||||
{!hideOverlays && (
|
||||
<GizmoHelper alignment="bottom-right" margin={[70, 70]}>
|
||||
<GizmoViewport axisColors={["#FF2D55", "#3ddc84", "#4a9eff"]} labelColor="#eee" />
|
||||
</GizmoHelper>
|
||||
)}
|
||||
<CaptureBridge captureRef={captureRef} />
|
||||
</Canvas>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue