feat: model colour selector

Floating swatch palette (bottom-left) + native custom picker. Colour is a
viewer preference persisted in localStorage; threads App → Viewer → Model
material. Defaults to brand red.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
maverick 2026-07-25 14:58:44 +10:00
parent 38b419d505
commit 9bf79ccdc3
4 changed files with 67 additions and 5 deletions

View file

@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { Viewer } from "./components/Viewer"; 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 { runJscad, parseParams, type Param } from "./engine"; import { runJscad, parseParams, type Param } from "./engine";
interface Msg { interface Msg {
@ -45,6 +46,13 @@ export default function App() {
const [designs, setDesigns] = useState<DesignSummary[]>([]); const [designs, setDesigns] = useState<DesignSummary[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [steppy, setSteppy] = useState(false); // STEP export in flight const [steppy, setSteppy] = useState(false); // STEP export in flight
const [color, setColor] = useState<string>(() => {
try {
return localStorage.getItem("chisel_color") || "#FF2D55";
} catch {
return "#FF2D55";
}
});
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const captureRef = useRef<(() => string | null) | null>(null); const captureRef = useRef<(() => string | null) | null>(null);
@ -294,6 +302,15 @@ export default function App() {
} }
}, [steppy, messages, title]); }, [steppy, messages, title]);
const setModelColor = useCallback((c: string) => {
setColor(c);
try {
localStorage.setItem("chisel_color", c);
} catch {
/* ignore */
}
}, []);
const fitKey = String(designVersion); const fitKey = String(designVersion);
return ( return (
@ -393,9 +410,10 @@ export default function App() {
</aside> </aside>
<main className="stage"> <main className="stage">
<Viewer stl={stl} fitKey={fitKey} captureRef={captureRef} /> <Viewer stl={stl} fitKey={fitKey} color={color} 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 && ( {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">

View file

@ -0,0 +1,34 @@
// Model colour selector: a row of preset swatches + a native custom picker.
// Colour is a viewer preference (persisted in localStorage by App), not part of
// the geometry — it only sets the preview material.
const PRESETS = [
"#FF2D55", // radical red
"#FF7A00", // orange
"#FFD400", // yellow
"#3ddc84", // green
"#4a9eff", // blue
"#B47CFF", // purple
"#C8CCD4", // steel
"#F2F2F0", // white
"#1A1A1A", // graphite
];
export function ColorPicker({ color, onChange }: { color: string; onChange: (c: string) => void }) {
return (
<div className="colorpicker">
{PRESETS.map((c) => (
<button
key={c}
className={`swatch ${c.toLowerCase() === color.toLowerCase() ? "active" : ""}`}
style={{ background: c }}
title={c}
onClick={() => onChange(c)}
/>
))}
<label className="swatch custom" title="Custom colour">
<input type="color" value={color} onChange={(e) => onChange(e.target.value)} />
<span>+</span>
</label>
</div>
);
}

View file

@ -4,7 +4,7 @@ import { OrbitControls, GizmoHelper, GizmoViewport, Grid, Center, Bounds } from
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 }: { stl: ArrayBuffer }) { function Model({ stl, color }: { stl: ArrayBuffer; color: string }) {
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();
@ -16,7 +16,7 @@ function Model({ stl }: { stl: ArrayBuffer }) {
return ( return (
<Center> <Center>
<mesh geometry={geometry} castShadow receiveShadow> <mesh geometry={geometry} castShadow receiveShadow>
<meshStandardMaterial color="#FF2D55" metalness={0.15} roughness={0.55} /> <meshStandardMaterial color={color} metalness={0.15} roughness={0.55} />
</mesh> </mesh>
</Center> </Center>
); );
@ -52,10 +52,11 @@ interface ViewerProps {
// Changes ONLY when a new design loads (or the user hits Fit). Param tweaks keep // 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. // the same fitKey so the camera holds still and size changes stay visible.
fitKey: string; fitKey: string;
color: string;
captureRef: MutableRefObject<(() => string | null) | null>; captureRef: MutableRefObject<(() => string | null) | null>;
} }
export function Viewer({ stl, fitKey, captureRef }: ViewerProps) { export function Viewer({ stl, fitKey, color, captureRef }: ViewerProps) {
const controls = useRef(null); const controls = useRef(null);
return ( return (
<Canvas <Canvas
@ -79,7 +80,7 @@ export function Viewer({ stl, fitKey, captureRef }: ViewerProps) {
{/* 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.35}>
<Model stl={stl} /> <Model stl={stl} color={color} />
</Bounds> </Bounds>
)} )}
<OrbitControls ref={controls} makeDefault enableDamping /> <OrbitControls ref={controls} makeDefault enableDamping />

View file

@ -82,6 +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; }
/* --- 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); }
.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; }
.swatch.custom { position: relative; display: inline-flex; align-items: center; justify-content: center; background: conic-gradient(from 0deg, #ff2d55, #ffd400, #3ddc84, #4a9eff, #b47cff, #ff2d55); color: #fff; font-weight: 700; font-size: 13px; overflow: hidden; }
.swatch.custom span { text-shadow: 0 0 3px rgba(0,0,0,.7); pointer-events: none; }
.swatch.custom input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
/* --- Progress overlay --- */ /* --- 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; } .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; } } @keyframes fade { from { opacity: 0; } to { opacity: 1; } }