-
+
{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',
+ },
+};