From 9bf79ccdc398781546556748a9c2cf4aac45f8f5 Mon Sep 17 00:00:00 2001 From: maverick Date: Sat, 25 Jul 2026 14:58:44 +1000 Subject: [PATCH] feat: model colour selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/client/App.tsx | 20 +++++++++++++++- src/client/components/ColorPicker.tsx | 34 +++++++++++++++++++++++++++ src/client/components/Viewer.tsx | 9 +++---- src/client/styles.css | 9 +++++++ 4 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 src/client/components/ColorPicker.tsx diff --git a/src/client/App.tsx b/src/client/App.tsx index cc968f8..562b0b3 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -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([]); const [drawerOpen, setDrawerOpen] = useState(false); const [steppy, setSteppy] = useState(false); // STEP export in flight + const [color, setColor] = useState(() => { + try { + return localStorage.getItem("chisel_color") || "#FF2D55"; + } catch { + return "#FF2D55"; + } + }); const scrollRef = useRef(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() {
- + {title &&
{title}
} + {stl && !stage && } {stl && !stage && (
+ ); +} diff --git a/src/client/components/Viewer.tsx b/src/client/components/Viewer.tsx index 9d8338f..1f2e901 100644 --- a/src/client/components/Viewer.tsx +++ b/src/client/components/Viewer.tsx @@ -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(() => { const g = new STLLoader().parse(stl.slice(0)); g.computeVertexNormals(); @@ -16,7 +16,7 @@ function Model({ stl }: { stl: ArrayBuffer }) { return (
- +
); @@ -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 ( - + )} diff --git a/src/client/styles.css b/src/client/styles.css index 303763e..2144be0 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -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; } }