diff --git a/CHANGELOG.md b/CHANGELOG.md index 01b3ad8b..7b4f69d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ user-facing notes live in [`docs/updates/`](docs/updates/) (English and ### Added +- Editor: light mode. The editor follows the system theme and a button in the + header cycles system, light and dark (remembered); the 3D viewport, edges, + grid, axes and the code editor switch with it, in place, without reloading + the model or losing the camera and selection. - `sca edit [WORKSPACE]`: the web editor, now part of the SDK (`simplecadapi.editor`, UI sources in `editor-web/`; needs the `editor` extra). It runs a workspace's notebooks in a marimo kernel with the SDK's diff --git a/editor-web/index.html b/editor-web/index.html index 5f552936..bfce5606 100644 --- a/editor-web/index.html +++ b/editor-web/index.html @@ -4,6 +4,21 @@ SimpleCAD Studio +
diff --git a/editor-web/src/App.tsx b/editor-web/src/App.tsx index 56133986..2d481e48 100644 --- a/editor-web/src/App.tsx +++ b/editor-web/src/App.tsx @@ -13,6 +13,7 @@ import { fetchPreview, getWorkspace, openNotebook, postViewport, type ModuleRow, import { parsePreview } from "@/lib/preview"; import { EnvelopeSocket, type Envelope } from "@/lib/envelope"; import { useRunLog } from "@/lib/runlog"; +import { useTheme } from "@/lib/theme"; import { selectionPayload, variableNames, type SelectionItem } from "@/lib/selection"; import { cn } from "@/lib/utils"; import { Viewport, type PickResult, type SelectionMode } from "@/scene/Viewport"; @@ -63,6 +64,7 @@ export function App() { const [loading, setLoading] = useState("connecting"); const kind = notebooks.find((row) => row.path === currentPath)?.kind ?? null; + const theme = useTheme(); const log = useRunLog(); const running = cells.some((cell) => cell.status === "running" || cell.status === "queued"); const handleStop = useCallback(() => { @@ -318,6 +320,8 @@ export function App() { kernelAlive={kernelAlive} status={status} marimoUrl={marimoUrl} + themePreference={theme.preference} + onTheme={theme.cycle} running={running} onStop={handleStop} onRunAll={() => { @@ -376,7 +380,7 @@ export function App() { className={cn( "border-2 px-2 py-px text-[10px] font-semibold uppercase tracking-[0.1em] transition-colors", selectionMode === mode - ? "border-white bg-white text-black" + ? "border-primary bg-primary text-primary-foreground" : "border-transparent text-muted-foreground hover:border-input hover:text-foreground", )} onClick={() => setSelectionMode(mode)} @@ -400,6 +404,7 @@ export function App() { selectionMode={selectionMode} selection={selection} sceneVersion={sceneVersion} + theme={theme.resolved} onPick={handlePick} onClear={handleClear} /> @@ -411,7 +416,7 @@ export function App() { - + { event.stopPropagation(); onInsert(after); @@ -337,7 +337,7 @@ function CellBlock({ return (
diff --git a/editor-web/src/components/Dock.tsx b/editor-web/src/components/Dock.tsx index cb486324..2e23c21d 100644 --- a/editor-web/src/components/Dock.tsx +++ b/editor-web/src/components/Dock.tsx @@ -61,7 +61,7 @@ export function Dock({ const percent = batch && batch.total > 0 ? Math.round((batch.done / batch.total) * 100) : 0; return ( -
+
{open && (
{entries.length === 0 ? ( diff --git a/editor-web/src/components/Header.tsx b/editor-web/src/components/Header.tsx index b6b29d3b..37a3936f 100644 --- a/editor-web/src/components/Header.tsx +++ b/editor-web/src/components/Header.tsx @@ -1,4 +1,6 @@ -import { ExternalLink, Play, Square } from "lucide-react"; +import { ExternalLink, Monitor, Moon, Play, Square, Sun } from "lucide-react"; + +import type { ThemePreference } from "@/lib/theme"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -14,6 +16,8 @@ export function Header({ running, onStop, marimoUrl, + themePreference, + onTheme, }: { notebook: string | null; kind: string | null; @@ -24,10 +28,12 @@ export function Header({ running: boolean; onStop: () => void; marimoUrl: string | null; + themePreference: ThemePreference; + onTheme: () => void; }) { const failing = status.startsWith("error"); return ( -
+
SimpleCAD {notebook ? ( @@ -64,6 +70,18 @@ export function Header({ )} +
); } + +const NEXT_THEME: Record = { system: "light", light: "dark", dark: "system" }; diff --git a/editor-web/src/components/LoadingMask.tsx b/editor-web/src/components/LoadingMask.tsx index 9cdd1b3c..f6abf553 100644 --- a/editor-web/src/components/LoadingMask.tsx +++ b/editor-web/src/components/LoadingMask.tsx @@ -18,7 +18,7 @@ const WAVE_SPEED = 0.0009; * dot-matrix with a ripple that spreads from the middle, and a small retro * window saying what is going on and for how long. It appears only after * `DELAY_MS`, so quick operations do not flash it. */ -export function LoadingMask({ active, message }: { active: boolean; message: string }) { +export function LoadingMask({ active, message, theme }: { active: boolean; message: string; theme: string }) { const DELAY_MS = 250; const [visible, setVisible] = useState(false); const [elapsed, setElapsed] = useState(0); @@ -45,11 +45,11 @@ export function LoadingMask({ active, message }: { active: boolean; message: str
- -
-
+ +
+
loading {minutes}:{seconds} @@ -58,7 +58,7 @@ export function LoadingMask({ active, message }: { active: boolean; message: str
-

+

{message}

@@ -81,7 +81,7 @@ function Spinner() { return ( = 0 && "sca-chase")} + className={cn("block bg-foreground", order < 0 && "invisible", order >= 0 && "sca-chase")} style={order >= 0 ? { animationDelay: `${order * 0.1}s` } : undefined} /> ); @@ -93,18 +93,32 @@ function Spinner() { /** A strip of blocks filling and emptying: the load has no known length. */ function Progress() { return ( -

+
{Array.from({ length: 20 }, (_, index) => ( - + ))}
); } +/** A CSS color variable of the page (a #rrggbb token) as "r,g,b", for drawing on a canvas. */ +function cssRgb(name: string, fallback: string): string { + const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + const match = /^#([0-9a-f]{6})$/i.exec(value); + if (!match) return fallback; + const n = parseInt(match[1], 16); + return `${(n >> 16) & 255},${(n >> 8) & 255},${n & 255}`; +} + /** The dot-matrix: square dots whose brightness follows a ring-shaped wave * travelling out from the middle, quantised to four levels. */ -function Ripple() { +function Ripple({ theme }: { theme: string }) { const canvasRef = useRef(null); + // The dots take the theme's ink: the crest is the focus color, the rest the foreground. + const inks = useRef({ crest: "255,154,46", base: "255,255,255" }); + useEffect(() => { + inks.current = { crest: cssRgb("--focus", "255,154,46"), base: cssRgb("--foreground", "255,255,255") }; + }, [theme]); useEffect(() => { const canvas = canvasRef.current; @@ -142,7 +156,7 @@ function Ripple() { } } paths.forEach((path, level) => { - context.fillStyle = level === LEVELS.length - 1 ? `rgba(255,154,46,${LEVELS[level]})` : `rgba(255,255,255,${LEVELS[level]})`; + context.fillStyle = `rgba(${level === LEVELS.length - 1 ? inks.current.crest : inks.current.base},${LEVELS[level]})`; context.fill(path); }); }; diff --git a/editor-web/src/components/ParamControl.tsx b/editor-web/src/components/ParamControl.tsx index 822a08e7..74f1dd22 100644 --- a/editor-web/src/components/ParamControl.tsx +++ b/editor-web/src/components/ParamControl.tsx @@ -45,7 +45,7 @@ export function ParamControl({ onClick={() => forwardParam(param.id, !on)} > {label} - + {on ? "on" : "off"} diff --git a/editor-web/src/components/ui/badge.tsx b/editor-web/src/components/ui/badge.tsx index bda43152..7bef5877 100644 --- a/editor-web/src/components/ui/badge.tsx +++ b/editor-web/src/components/ui/badge.tsx @@ -8,14 +8,14 @@ const badgeVariants = cva( { variants: { variant: { - default: "border-white bg-white text-black", + default: "border-primary bg-primary text-primary-foreground", secondary: "border-input text-muted-foreground", outline: "border-input text-foreground", focus: "border-focus text-focus", pass: "border-pass text-pass", fail: "border-fail text-fail", name: "border-name text-name", - destructive: "border-fail bg-fail text-black", + destructive: "border-fail bg-fail text-primary-foreground", }, }, defaultVariants: { variant: "default" }, diff --git a/editor-web/src/components/ui/button.tsx b/editor-web/src/components/ui/button.tsx index a6aa38f3..45fea79b 100644 --- a/editor-web/src/components/ui/button.tsx +++ b/editor-web/src/components/ui/button.tsx @@ -9,9 +9,9 @@ const buttonVariants = cva( { variants: { variant: { - default: "border-white bg-white text-black hover:border-focus hover:bg-focus", - destructive: "border-fail bg-fail text-black hover:bg-transparent hover:text-fail", - outline: "border-input bg-transparent text-foreground hover:border-white", + default: "border-primary bg-primary text-primary-foreground hover:border-focus hover:bg-focus", + destructive: "border-fail bg-fail text-primary-foreground hover:bg-transparent hover:text-fail", + outline: "border-input bg-transparent text-foreground hover:border-primary", secondary: "border-transparent bg-secondary text-secondary-foreground hover:border-input", ghost: "border-transparent bg-transparent text-muted-foreground hover:text-foreground", link: "border-transparent text-name underline-offset-4 hover:underline", diff --git a/editor-web/src/components/ui/scroll-area.tsx b/editor-web/src/components/ui/scroll-area.tsx index cd78a91a..61bb5856 100644 --- a/editor-web/src/components/ui/scroll-area.tsx +++ b/editor-web/src/components/ui/scroll-area.tsx @@ -24,7 +24,7 @@ function ScrollBar({ className, ...props }: React.ComponentProps - + ); } diff --git a/editor-web/src/components/ui/slider.tsx b/editor-web/src/components/ui/slider.tsx index d1d3f2ad..15d8ce3b 100644 --- a/editor-web/src/components/ui/slider.tsx +++ b/editor-web/src/components/ui/slider.tsx @@ -16,12 +16,12 @@ function Slider({ {...props} > - + diff --git a/editor-web/src/components/ui/tabs.tsx b/editor-web/src/components/ui/tabs.tsx index 8ff85c50..c60fc777 100644 --- a/editor-web/src/components/ui/tabs.tsx +++ b/editor-web/src/components/ui/tabs.tsx @@ -20,7 +20,7 @@ function TabsTrigger({ className, ...props }: React.ComponentProps window.matchMedia("(prefers-color-scheme: dark)").matches; + +function readPreference(): ThemePreference { + try { + const stored = window.localStorage.getItem(KEY); + if (stored === "light" || stored === "dark" || stored === "system") return stored; + } catch { + /* private mode: the choice just does not persist */ + } + return "system"; +} + +const resolve = (preference: ThemePreference): ResolvedTheme => + preference === "system" ? (systemDark() ? "dark" : "light") : preference; + +/** Put the theme on the page: the `dark` class selects the dark tokens, `color-scheme` + * makes native controls and scrollbars follow. index.html does the same before first paint. */ +function apply(theme: ResolvedTheme) { + const root = document.documentElement; + root.classList.toggle("dark", theme === "dark"); + root.style.colorScheme = theme; +} + +/** The theme: a remembered preference (system, light, dark) and the theme it resolves to. + * While the preference is `system` a change of the operating system's theme is followed live. */ +export function useTheme() { + const [preference, setPreference] = useState(readPreference); + const [resolved, setResolved] = useState(() => resolve(readPreference())); + + useEffect(() => { + const update = () => { + const next = resolve(preference); + setResolved(next); + apply(next); + }; + update(); + if (preference !== "system") return; + const query = window.matchMedia("(prefers-color-scheme: dark)"); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, [preference]); + + const choose = useCallback((next: ThemePreference) => { + setPreference(next); + try { + window.localStorage.setItem(KEY, next); + } catch { + /* not persisted */ + } + }, []); + + /** system -> light -> dark -> system */ + const cycle = useCallback(() => choose(ORDER[(ORDER.indexOf(preference) + 1) % ORDER.length]), [choose, preference]); + + return { preference, resolved, choose, cycle }; +} diff --git a/editor-web/src/scene/Viewport.tsx b/editor-web/src/scene/Viewport.tsx index 2995d3aa..21ad4999 100644 --- a/editor-web/src/scene/Viewport.tsx +++ b/editor-web/src/scene/Viewport.tsx @@ -2,11 +2,10 @@ import { useEffect, useRef } from "react"; import * as THREE from "three"; import { SceneView, type PickResult, type SelectionMode } from "@/scene/scene-view"; +import { PALETTES, type SceneTheme } from "@/scene/theme"; export type { PickResult, SelectionMode }; -/** Selection highlight: the "focus" ink. */ -const SELECTION_COLOR = "#ff9a2e"; /** What the viewport highlights: the picked entities. */ export type Highlight = { nodeId: string; entityId: string | null }; @@ -24,6 +23,7 @@ export function Viewport({ selectionMode, selection, sceneVersion, + theme, onPick, onClear, }: { @@ -31,6 +31,7 @@ export function Viewport({ selectionMode: SelectionMode; selection: Highlight[]; sceneVersion: number; + theme: SceneTheme; onPick: (pick: PickResult) => void; onClear: () => void; }) { @@ -40,6 +41,9 @@ export function Viewport({ const clearRef = useRef(onClear); clearRef.current = onClear; const overlayRef = useRef([]); + // The theme the view was created with; later changes go through setTheme. + const themeRef = useRef(theme); + themeRef.current = theme; const dropOverlays = () => { for (const object of overlayRef.current) { @@ -62,7 +66,7 @@ export function Viewport({ onPick: (result) => pickRef.current(result), onClear: () => clearRef.current(), selectionMode: "solid", - background: "#000000", + theme: themeRef.current, }); viewRef.current = view; // Debug/agent affordance: reach the imperative scene from the console. @@ -84,12 +88,20 @@ export function Viewport({ if (!view) return; dropOverlays(); for (const item of selection) { - overlayRef.current.push(...view.buildEntityOverlays(item.nodeId, item.entityId, SELECTION_COLOR, 0.8)); + overlayRef.current.push(...view.buildEntityOverlays(item.nodeId, item.entityId, PALETTES[theme].selection, 0.8)); } view.engine.invalidate(); document.documentElement.dataset.overlayCount = String(overlayRef.current.length); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selection, sceneVersion]); + }, [selection, sceneVersion, theme]); + + // Another theme: recolor in place (camera, model and selection stay). + useEffect(() => { + const view = viewRef.current; + if (!view) return; + view.setTheme(theme); + view.engine.invalidate(); + }, [theme, viewRef]); return
; } diff --git a/editor-web/src/scene/renderer/brep-renderer.ts b/editor-web/src/scene/renderer/brep-renderer.ts index 32aa8401..38a90512 100644 --- a/editor-web/src/scene/renderer/brep-renderer.ts +++ b/editor-web/src/scene/renderer/brep-renderer.ts @@ -23,7 +23,8 @@ import { LineMaterial } from 'three/addons/lines/LineMaterial.js'; import { LineSegments2 } from 'three/addons/lines/LineSegments2.js'; import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js'; import { strFromU8 } from 'fflate'; -import { DEFAULT_BASE_COLOR, CAD_EDGE_COLOR, CAD_EDGE_LINE_WIDTH, cadPointToGltf, disposeObject, materialFor, placementMatrix } from '../cad-three'; +import { DEFAULT_BASE_COLOR, CAD_EDGE_LINE_WIDTH, cadPointToGltf, disposeObject, materialFor, placementMatrix } from '../cad-three'; +import { PALETTES, type SceneTheme, type ScenePalette } from '../theme'; import type { PackageFiles } from '../product-package'; import type { PreviewData, PreviewPart } from '../../lib/preview'; import type { Entity, EntitySidecar, FaceGroup, SceneManifest, SceneNode, Vec3 } from '../scene2'; @@ -45,6 +46,9 @@ export type CameraState = { }; export type BRepRendererOptions = { + /** The scene's theme (default dark); `setTheme` changes it later. */ + theme?: SceneTheme; + /** Overrides the theme's background (sRGB). */ background?: string; /** GTAO ambient occlusion pass (default on). */ ssao?: boolean; @@ -85,8 +89,8 @@ function isHiddenEdge(entity: Entity | undefined): boolean { */ const EDGE_DEPTH_BIAS = 0.0004; -function createEdgeMaterial(): LineMaterial { - const material = new LineMaterial({ color: CAD_EDGE_COLOR, linewidth: CAD_EDGE_LINE_WIDTH, worldUnits: false, depthTest: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2 }); +function createEdgeMaterial(color: string): LineMaterial { + const material = new LineMaterial({ color, linewidth: CAD_EDGE_LINE_WIDTH, worldUnits: false, depthTest: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2 }); material.onBeforeCompile = (shader) => { shader.uniforms.edgeDepthBias = { value: EDGE_DEPTH_BIAS }; shader.vertexShader = shader.vertexShader @@ -94,6 +98,7 @@ function createEdgeMaterial(): LineMaterial { .replace('gl_Position = clip;', 'float biasedView = -clip.w + edgeDepthBias;\n\t\t\tclip.z = (projectionMatrix[2][2] * biasedView + projectionMatrix[3][2]) / -biasedView * clip.w;\n\t\t\tgl_Position = clip;'); }; material.customProgramCacheKey = () => 'edge-depth-bias'; + material.userData.role = 'edge'; // setTheme recolors these, and only these return material; } @@ -119,6 +124,9 @@ export class BRepRenderer { private ssaoPass: GTAOPass | null = null; private ssaoEnabled: boolean; private rendererClearColor: THREE.Color; + private palette: ScenePalette; + /** The gizmo's per-axis parts, kept to recolor on a theme change. */ + private gizmoAxes: { materials: THREE.MeshBasicMaterial[]; tail: THREE.MeshBasicMaterial; canvas: HTMLCanvasElement; texture: THREE.CanvasTexture; label: string }[] = []; private blitScene: THREE.Scene | null = null; private blitCamera: THREE.OrthographicCamera | null = null; private copyMaterial: THREE.MeshBasicMaterial | null = null; @@ -151,7 +159,8 @@ export class BRepRenderer { // color is consumed raw as linear working-space values, so convert the // sRGB design color first — otherwise the presented background is ~4x // brighter than intended. - this.rendererClearColor = new THREE.Color(options.background ?? '#0b0e12').convertSRGBToLinear(); + this.palette = PALETTES[options.theme ?? 'dark']; + this.rendererClearColor = this.clearColorFor(options.background); this.renderer.setClearColor(this.rendererClearColor, 1); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.outputColorSpace = THREE.SRGBColorSpace; @@ -386,8 +395,8 @@ export class BRepRenderer { const extent = count * step; const positions: number[] = []; const colors: number[] = []; - const minor = new THREE.Color('#262626'); - const major = new THREE.Color('#444444'); + const minor = new THREE.Color(this.palette.gridMinor); + const major = new THREE.Color(this.palette.gridMajor); for (let index = -count; index <= count; index += 1) { const color = index % 5 === 0 ? major : minor; const at = index * step; @@ -412,28 +421,23 @@ export class BRepRenderer { /** The three axes as blocks and letters; drawn in a corner of the canvas. */ private buildGizmo(): void { - const axes: { dir: THREE.Vector3; color: string; label: string }[] = [ - { dir: new THREE.Vector3(1, 0, 0), color: '#ff4d5e', label: 'X' }, - { dir: new THREE.Vector3(0, 1, 0), color: '#3ddc84', label: 'Y' }, - { dir: new THREE.Vector3(0, 0, 1), color: '#4aa3ff', label: 'Z' }, - ]; + const dirs = [new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 1)]; + const labels = ['X', 'Y', 'Z']; const basic = (color: string) => new THREE.MeshBasicMaterial({ color, toneMapped: false }); - for (const { dir, color, label } of axes) { - const shaft = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.07, 1), basic(color)); + dirs.forEach((dir, index) => { + const color = this.palette.axes[index]; + const shaftMaterial = basic(color); + const tipMaterial = basic(color); + const tailMaterial = basic(this.palette.axisTail); + const shaft = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.07, 1), shaftMaterial); shaft.position.copy(dir).multiplyScalar(0.5); shaft.lookAt(dir); - const tip = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.2, 0.2), basic(color)); + const tip = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.2, 0.2), tipMaterial); tip.position.copy(dir); - const tail = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.12, 0.12), basic('#555555')); + const tail = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.12, 0.12), tailMaterial); tail.position.copy(dir).multiplyScalar(-1); const canvas = document.createElement('canvas'); canvas.width = canvas.height = 64; - const context = canvas.getContext('2d')!; - context.font = '700 44px ui-monospace, SFMono-Regular, Menlo, monospace'; - context.textAlign = 'center'; - context.textBaseline = 'middle'; - context.fillStyle = color; - context.fillText(label, 32, 34); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; const sprite = new THREE.Sprite( @@ -441,8 +445,57 @@ export class BRepRenderer { ); sprite.scale.set(0.42, 0.42, 1); sprite.position.copy(dir).multiplyScalar(1.38); + this.gizmoAxes.push({ materials: [shaftMaterial, tipMaterial], tail: tailMaterial, canvas, texture, label: labels[index] }); + this.paintGizmoLabel(this.gizmoAxes[index], color); this.gizmoScene.add(shaft, tip, tail, sprite); - } + }); + } + + private vertexMaterial(): THREE.PointsMaterial { + const material = new THREE.PointsMaterial({ color: this.palette.vertex, size: 7, sizeAttenuation: false, depthWrite: false, transparent: true, opacity: 0.48 }); + material.userData.role = 'vertex'; + return material; + } + + private paintGizmoLabel(axis: { canvas: HTMLCanvasElement; texture: THREE.CanvasTexture; label: string }, color: string): void { + const context = axis.canvas.getContext('2d')!; + context.clearRect(0, 0, 64, 64); + context.font = '700 44px ui-monospace, SFMono-Regular, Menlo, monospace'; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillStyle = color; + context.fillText(axis.label, 32, 34); + axis.texture.needsUpdate = true; + } + + /** The viewport background as a linear color: sRGB design value, brightened by the + * theme's boost so the tone-mapped result is the page's own background. */ + private clearColorFor(background?: string): THREE.Color { + const color = new THREE.Color(background ?? this.palette.clear).convertSRGBToLinear(); + if (background === undefined) color.multiplyScalar(this.palette.clearBoost); + return color; + } + + /** Switch the scene to another theme in place: background, edges, grid, gizmo, vertex + * points. The camera, the model and any selection overlay stay as they are. */ + setTheme(theme: SceneTheme): void { + this.palette = PALETTES[theme]; + this.rendererClearColor = this.clearColorFor(); + this.renderer.setClearColor(this.rendererClearColor, 1); + this.modelRoot.traverse((object) => { + const material = (object as THREE.Mesh).material as THREE.Material | THREE.Material[] | undefined; + if (!material || Array.isArray(material)) return; + if (material.userData.role === 'edge') (material as LineMaterial).color.set(this.palette.edge); + else if (material.userData.role === 'vertex') (material as THREE.PointsMaterial).color.set(this.palette.vertex); + }); + this.gizmoAxes.forEach((axis, index) => { + const color = this.palette.axes[index]; + axis.materials.forEach((material) => material.color.set(color)); + axis.tail.color.set(this.palette.axisTail); + this.paintGizmoLabel(axis, color); + }); + this.updateGrid(); + this.needsRender = true; } private renderGizmo(): void { @@ -622,7 +675,7 @@ export class BRepRenderer { this.previewGeometries.push(faces, pick, edges, ...(vertices ? [vertices] : [])); return { faces, pick, edges, vertices, sidecar }; }; - const edgeMaterial = createEdgeMaterial(); + const edgeMaterial = createEdgeMaterial(this.palette.edge); const size = this.resolution(); edgeMaterial.resolution.set(Math.max(size.width, 1), Math.max(size.height, 1)); for (const item of preview.nodes) { @@ -662,7 +715,7 @@ export class BRepRenderer { } group.add(pickEdges); if (geometry.vertices) { - const points = new THREE.Points(geometry.vertices, new THREE.PointsMaterial({ color: '#d8ff83', size: 7, sizeAttenuation: false, depthWrite: false, transparent: true, opacity: 0.48 })); + const points = new THREE.Points(geometry.vertices, this.vertexMaterial()); points.visible = false; points.userData.pickRole = 'vertex'; points.userData.pickable = true; @@ -728,7 +781,7 @@ export class BRepRenderer { }); const vertexGeometry = new THREE.BufferGeometry(); vertexGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); - const vertexPoints = new THREE.Points(vertexGeometry, new THREE.PointsMaterial({ color: '#d8ff83', size: 7, sizeAttenuation: false, depthWrite: false, transparent: true, opacity: 0.48 })); + const vertexPoints = new THREE.Points(vertexGeometry, this.vertexMaterial()); vertexPoints.visible = false; vertexPoints.userData.pickRole = 'vertex'; vertexPoints.userData.pickable = true; @@ -783,7 +836,7 @@ export class BRepRenderer { geometry.setPositions(positions); // Negative slope-scaled offset mirrors the faces' push (see materialFor): // keeps silhouette edges continuous where a flat bias loses. - const material = createEdgeMaterial(); + const material = createEdgeMaterial(this.palette.edge); const size = this.resolution(); material.resolution.set(Math.max(size.width, 1), Math.max(size.height, 1)); const visual = new LineSegments2(geometry, material); diff --git a/editor-web/src/scene/scene-view.ts b/editor-web/src/scene/scene-view.ts index 1574cdba..dac278b9 100644 --- a/editor-web/src/scene/scene-view.ts +++ b/editor-web/src/scene/scene-view.ts @@ -4,6 +4,7 @@ // modes and annotation marks. All rendering — materials, edge display, // lighting, ambient occlusion, camera — lives in the renderer component. +import type { SceneTheme } from './theme'; import * as THREE from 'three'; import { LineMaterial } from 'three/addons/lines/LineMaterial.js'; import { LineSegments2 } from 'three/addons/lines/LineSegments2.js'; @@ -36,6 +37,8 @@ export type SceneViewOptions = { onClear?: () => void; selectionMode?: SelectionMode; background?: string; + /** The scene's theme (default dark). */ + theme?: SceneTheme; interactive?: boolean; /** GTAO ambient occlusion (default on). */ ssao?: boolean; @@ -61,7 +64,7 @@ export class SceneView { this.onPick = options.onPick ?? null; this.onClear = options.onClear ?? null; this.selectionMode = options.selectionMode ?? 'face'; - this.engine = new BRepRenderer(host, { background: options.background, ssao: options.ssao, seams: options.seams }); + this.engine = new BRepRenderer(host, { background: options.background, theme: options.theme, ssao: options.ssao, seams: options.seams }); if (options.interactive !== false) { this.unbindClick = bindClickSelection({ element: this.engine.domElement, @@ -339,6 +342,11 @@ export class SceneView { this.engine.setSeamsVisible(visible); } + /** Recolor the scene for another theme without reloading it. */ + setTheme(theme: SceneTheme): void { + this.engine.setTheme(theme); + } + setSSAO(enabled: boolean): void { this.engine.invalidate(); this.engine.setSSAO(enabled); diff --git a/editor-web/src/scene/theme.ts b/editor-web/src/scene/theme.ts new file mode 100644 index 00000000..ff1ad23d --- /dev/null +++ b/editor-web/src/scene/theme.ts @@ -0,0 +1,47 @@ +/** The colors of the 3D scene in each theme. The page's own colors are CSS variables + * (src/index.css); the scene is drawn by three.js and needs its own copy. */ +export type SceneTheme = 'light' | 'dark'; + +export interface ScenePalette { + /** Viewport background, sRGB. */ + clear: string; + /** Multiplier on the linear background: the present pass tone-maps it (ACES), which + * leaves a pure white at about 0.9; a brighter input lands on the page's white. */ + clearBoost: number; + /** Outline color of the model's edges. */ + edge: string; + gridMinor: string; + gridMajor: string; + /** X, Y, Z. */ + axes: [string, string, string]; + /** The negative end of each axis. */ + axisTail: string; + vertex: string; + /** Highlight of picked entities. */ + selection: string; +} + +export const PALETTES: Record = { + dark: { + clear: '#000000', + clearBoost: 1, + edge: '#3a414d', + gridMinor: '#262626', + gridMajor: '#444444', + axes: ['#ff4d5e', '#3ddc84', '#4aa3ff'], + axisTail: '#555555', + vertex: '#d8ff83', + selection: '#ff9a2e', + }, + light: { + clear: '#ffffff', + clearBoost: 8, + edge: '#1b1f27', + gridMinor: '#e0e0e0', + gridMajor: '#bdbdbd', + axes: ['#d12b3c', '#12803f', '#1a62c4'], + axisTail: '#9a9a9a', + vertex: '#2a7a00', + selection: '#d56a00', + }, +};