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:
parent
38b419d505
commit
9bf79ccdc3
4 changed files with 67 additions and 5 deletions
|
|
@ -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">
|
||||||
|
|
|
||||||
34
src/client/components/ColorPicker.tsx
Normal file
34
src/client/components/ColorPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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; } }
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue