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 { StageOverlay } from "./components/StageOverlay";
|
||||
import { DesignsDrawer } from "./components/DesignsDrawer";
|
||||
import { ColorPicker } from "./components/ColorPicker";
|
||||
import { runJscad, parseParams, type Param } from "./engine";
|
||||
|
||||
interface Msg {
|
||||
|
|
@ -45,6 +46,13 @@ export default function App() {
|
|||
const [designs, setDesigns] = useState<DesignSummary[]>([]);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
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 captureRef = useRef<(() => string | null) | null>(null);
|
||||
|
|
@ -294,6 +302,15 @@ export default function App() {
|
|||
}
|
||||
}, [steppy, messages, title]);
|
||||
|
||||
const setModelColor = useCallback((c: string) => {
|
||||
setColor(c);
|
||||
try {
|
||||
localStorage.setItem("chisel_color", c);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fitKey = String(designVersion);
|
||||
|
||||
return (
|
||||
|
|
@ -393,9 +410,10 @@ export default function App() {
|
|||
</aside>
|
||||
|
||||
<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]} />
|
||||
{title && <div className="title-chip">{title}</div>}
|
||||
{stl && !stage && <ColorPicker color={color} onChange={setModelColor} />}
|
||||
{stl && !stage && (
|
||||
<div className="toolbar">
|
||||
<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 type { BufferGeometry } from "three";
|
||||
|
||||
function Model({ stl }: { stl: ArrayBuffer }) {
|
||||
function Model({ stl, color }: { stl: ArrayBuffer; color: string }) {
|
||||
const geometry = useMemo<BufferGeometry>(() => {
|
||||
const g = new STLLoader().parse(stl.slice(0));
|
||||
g.computeVertexNormals();
|
||||
|
|
@ -16,7 +16,7 @@ function Model({ stl }: { stl: ArrayBuffer }) {
|
|||
return (
|
||||
<Center>
|
||||
<mesh geometry={geometry} castShadow receiveShadow>
|
||||
<meshStandardMaterial color="#FF2D55" metalness={0.15} roughness={0.55} />
|
||||
<meshStandardMaterial color={color} metalness={0.15} roughness={0.55} />
|
||||
</mesh>
|
||||
</Center>
|
||||
);
|
||||
|
|
@ -52,10 +52,11 @@ interface ViewerProps {
|
|||
// 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;
|
||||
captureRef: MutableRefObject<(() => string | null) | null>;
|
||||
}
|
||||
|
||||
export function Viewer({ stl, fitKey, captureRef }: ViewerProps) {
|
||||
export function Viewer({ stl, fitKey, color, captureRef }: ViewerProps) {
|
||||
const controls = useRef(null);
|
||||
return (
|
||||
<Canvas
|
||||
|
|
@ -79,7 +80,7 @@ export function Viewer({ stl, fitKey, captureRef }: ViewerProps) {
|
|||
{/* key={fitKey} → Bounds refits exactly once per new design, not per param. */}
|
||||
{stl && (
|
||||
<Bounds key={fitKey} fit clip margin={1.35}>
|
||||
<Model stl={stl} />
|
||||
<Model stl={stl} color={color} />
|
||||
</Bounds>
|
||||
)}
|
||||
<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; }
|
||||
|
||||
/* --- 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 --- */
|
||||
.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; } }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue