GcodeToolpathViewer.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616
  1. /**
  2. * G-code preview drawn the way the desktop slicer draws it.
  3. *
  4. * The renderer under this is OrcaSlicer's own `libvgcode`, vendored via
  5. * `three-slicer` (see `src/lib/vendor/toolpathRenderer.js`): each extrusion is a
  6. * diamond-section prism instanced once per segment, so a whole print is a
  7. * single indexed draw call and the toolpath occludes itself. The previous
  8. * viewer drew screen-space lines, which have no thickness in the scene and
  9. * therefore cannot hide the layer behind them -- the reason a sliced model
  10. * came out stringy and shimmering.
  11. *
  12. * The other half of the difference is colour. This colours by *feature* --
  13. * wall, infill, support, bridge -- from the `;TYPE:` annotations the slicer
  14. * writes, which is what makes a preview readable. Colouring by filament, as
  15. * the old viewer did, paints AMS slot colours across the whole print and tells
  16. * you nothing about what the printer is doing.
  17. */
  18. import { useEffect, useMemo, useRef, useState } from 'react';
  19. import { useTranslation } from 'react-i18next';
  20. import * as THREE from 'three';
  21. import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
  22. import { Loader2, FileWarning } from 'lucide-react';
  23. import { getAuthToken } from '../api/client';
  24. import {
  25. parseGcodeToolpath,
  26. layersByFilament,
  27. filterLayersByType,
  28. ToolpathType,
  29. type ParsedToolpath,
  30. } from '../lib/gcodeToolpath';
  31. // Typed by the sibling toolpathRenderer.d.ts.
  32. import {
  33. buildSegmentData,
  34. makeToolpath,
  35. computeColors,
  36. TYPE_COLOR,
  37. DEFAULT_RANGES_COLORS,
  38. } from '../lib/vendor/toolpathRenderer.js';
  39. interface GcodeToolpathViewerProps {
  40. gcodeUrl: string;
  41. buildVolume?: { x: number; y: number; z: number };
  42. /**
  43. * AMS slot colours, in tool order. When supplied the viewer opens on a
  44. * filament-coloured view, which is what a multi-material print is usually
  45. * being looked at for -- feature colouring answers a different question.
  46. */
  47. filamentColors?: string[];
  48. className?: string;
  49. }
  50. /**
  51. * The colour modes worth offering.
  52. *
  53. * Upstream also exposes speed, fan and temperature, but its own implementation
  54. * derives those from *settings* rather than the toolpath, because its slicing
  55. * kernel doesn't expose them per segment. Reading them out of the G-code would
  56. * give real values -- `F`, `M106` and `M104` are right there in the file -- so
  57. * they are left out until the parser carries them, rather than shipped as
  58. * plausible-looking guesses.
  59. */
  60. const VIEW_MODES = ['filament', 'feature', 'height', 'width'] as const;
  61. type ViewMode = (typeof VIEW_MODES)[number];
  62. /** Feature rows for the legend, in the order the slicer lists them. */
  63. const LEGEND_ENTRIES: Array<{ type: number; key: string; fallback: string }> = [
  64. { type: ToolpathType.wall, key: 'gcodeViewer.feature.wall', fallback: 'Walls' },
  65. { type: ToolpathType.sparseInfill, key: 'gcodeViewer.feature.sparseInfill', fallback: 'Sparse infill' },
  66. { type: ToolpathType.solidInfill, key: 'gcodeViewer.feature.solidInfill', fallback: 'Solid infill' },
  67. { type: ToolpathType.bridge, key: 'gcodeViewer.feature.bridge', fallback: 'Bridge / overhang' },
  68. { type: ToolpathType.support, key: 'gcodeViewer.feature.support', fallback: 'Support' },
  69. { type: ToolpathType.skirt, key: 'gcodeViewer.feature.skirt', fallback: 'Skirt / brim' },
  70. { type: ToolpathType.gapFill, key: 'gcodeViewer.feature.gapFill', fallback: 'Gap fill' },
  71. { type: ToolpathType.ironing, key: 'gcodeViewer.feature.ironing', fallback: 'Ironing' },
  72. { type: ToolpathType.primeTower, key: 'gcodeViewer.feature.primeTower', fallback: 'Prime tower' },
  73. ];
  74. /**
  75. * Pack a CSS hex colour the way the renderer expects.
  76. *
  77. * It stores colour as a single float holding `r << 16 | g << 8 | b`, which its
  78. * shader unpacks. Matching that exactly is what lets filament colours be
  79. * applied through the same `setColors` path the built-in views use.
  80. */
  81. function packColor(hex: string): number {
  82. const value = hex.replace('#', '');
  83. const full = value.length === 3 ? value.split('').map((c) => c + c).join('') : value;
  84. const n = Number.parseInt(full.slice(0, 6), 16);
  85. return Number.isFinite(n) ? n : 0x00ae42;
  86. }
  87. const cssColor = (rgb: number[] | undefined): string =>
  88. rgb ? `rgb(${rgb.map((c) => Math.round(c * 255)).join(',')})` : 'transparent';
  89. /** The renderer's own blue-to-red ramp, as CSS gradient stops. */
  90. function rampStops(): string {
  91. const colors = DEFAULT_RANGES_COLORS as number[][];
  92. return colors
  93. .map((rgb, i) => `${cssColor(rgb)} ${((i / (colors.length - 1)) * 100).toFixed(0)}%`)
  94. .join(', ');
  95. }
  96. /** Two decimals for a layer height, none for a large speed-like value. */
  97. function formatScale(value: number): string {
  98. if (!Number.isFinite(value)) return '-';
  99. return Math.abs(value) < 10 ? value.toFixed(2) : value.toFixed(0);
  100. }
  101. export function GcodeToolpathViewer({
  102. gcodeUrl,
  103. buildVolume = { x: 256, y: 256, z: 256 },
  104. filamentColors,
  105. className = '',
  106. }: GcodeToolpathViewerProps) {
  107. const { t } = useTranslation();
  108. const containerRef = useRef<HTMLDivElement>(null);
  109. const [loading, setLoading] = useState(true);
  110. const [error, setError] = useState<string | null>(null);
  111. const [notSliced, setNotSliced] = useState(false);
  112. const [parsed, setParsed] = useState<ParsedToolpath | null>(null);
  113. const hasFilamentColors = (filamentColors?.length ?? 0) > 0;
  114. const [viewMode, setViewMode] = useState<ViewMode>(hasFilamentColors ? 'filament' : 'feature');
  115. // Colours are fetched, so they usually arrive after the first render and the
  116. // initial state above lands on 'feature'. Adopt filament when they turn up,
  117. // unless the user has already picked a mode for themselves.
  118. const modeChosenRef = useRef(false);
  119. useEffect(() => {
  120. if (hasFilamentColors && !modeChosenRef.current) setViewMode('filament');
  121. }, [hasFilamentColors]);
  122. // Filament and feature colouring merge vertices differently, so they cannot
  123. // share one built mesh; the toolpath is rebuilt when crossing between them.
  124. const [layerRange, setLayerRange] = useState<[number, number]>([0, 0]);
  125. // Hidden types, tracked separately per colour space: in filament view a
  126. // "type" is a filament slot, in every other view it is a feature.
  127. const [hiddenFeatures, setHiddenFeatures] = useState<ReadonlySet<number>>(new Set());
  128. const [hiddenFilaments, setHiddenFilaments] = useState<ReadonlySet<number>>(new Set());
  129. const filamentView = viewMode === 'filament';
  130. const hidden = filamentView ? hiddenFilaments : hiddenFeatures;
  131. // A stable key so the toolpath effect re-runs on a change of contents rather
  132. // than on every new Set identity.
  133. const hiddenKey = [...hidden].sort((a, b) => a - b).join(',');
  134. const toggleHidden = (type: number) => {
  135. const update = (prev: ReadonlySet<number>) => {
  136. const next = new Set(prev);
  137. if (next.has(type)) next.delete(type);
  138. else next.add(type);
  139. return next;
  140. };
  141. if (filamentView) setHiddenFilaments(update);
  142. else setHiddenFeatures(update);
  143. };
  144. const [showTravel, setShowTravel] = useState(false);
  145. // Kept out of state: these are three.js objects, and re-rendering React on
  146. // every camera nudge would be pointless work.
  147. const sceneRef = useRef<THREE.Scene | null>(null);
  148. const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
  149. const controlsRef = useRef<OrbitControls | null>(null);
  150. const handleRef = useRef<ReturnType<typeof makeToolpath> | null>(null);
  151. const segmentDataRef = useRef<ReturnType<typeof buildSegmentData> | null>(null);
  152. // Bumped when a new toolpath is built, so the colour effect re-runs against
  153. // it -- the data it colours lives in a ref rather than in state.
  154. const [toolpathGeneration, setToolpathGeneration] = useState(0);
  155. // Read inside the toolpath effect without making it a dependency: a rebuild
  156. // should honour the current controls, not reset them, and re-running on
  157. // every slider nudge would rebuild the whole mesh.
  158. const showTravelRef = useRef(showTravel);
  159. const layerRangeRef = useRef(layerRange);
  160. showTravelRef.current = showTravel;
  161. layerRangeRef.current = layerRange;
  162. // The camera is framed once. Re-framing on a colour-mode switch would yank
  163. // the view back from wherever the user had put it.
  164. const framedRef = useRef(false);
  165. // `buildVolume` defaults to an object literal, so without this every render
  166. // produced a new identity. That identity was a dependency of the scene
  167. // effect, which therefore tore down and rebuilt the WebGL renderer on every
  168. // render -- and browsers cap live WebGL contexts at around sixteen, dropping
  169. // the oldest, which is why the canvas went blank after a few interactions.
  170. const volumeKey = `${buildVolume.x}x${buildVolume.y}x${buildVolume.z}`;
  171. const volume = useMemo(
  172. () => ({ x: buildVolume.x, y: buildVolume.y, z: buildVolume.z }),
  173. // eslint-disable-next-line react-hooks/exhaustive-deps
  174. [volumeKey],
  175. );
  176. // --- Fetch and parse -----------------------------------------------------
  177. useEffect(() => {
  178. let cancelled = false;
  179. setLoading(true);
  180. setError(null);
  181. setNotSliced(false);
  182. setParsed(null);
  183. const headers: HeadersInit = {};
  184. const token = getAuthToken();
  185. if (token) headers['Authorization'] = `Bearer ${token}`;
  186. fetch(gcodeUrl, { headers })
  187. .then(async (response) => {
  188. if (!response.ok) {
  189. if (response.status === 404) {
  190. const data = await response.json().catch(() => ({}));
  191. if (typeof data.detail === 'string' && data.detail.includes('sliced')) {
  192. setNotSliced(true);
  193. throw new Error('not_sliced');
  194. }
  195. }
  196. throw new Error('Failed to load G-code');
  197. }
  198. return response.text();
  199. })
  200. .then((gcode) => {
  201. if (cancelled) return;
  202. framedRef.current = false;
  203. const result = parseGcodeToolpath(gcode);
  204. setParsed(result);
  205. setLayerRange([0, Math.max(0, result.layers.length - 1)]);
  206. setLoading(false);
  207. })
  208. .catch((err: Error) => {
  209. if (cancelled) return;
  210. if (err.message !== 'not_sliced') setError(err.message);
  211. setLoading(false);
  212. });
  213. return () => {
  214. cancelled = true;
  215. };
  216. }, [gcodeUrl]);
  217. // --- Scene: created once, never rebuilt ----------------------------------
  218. // Deliberately independent of the toolpath. Tearing the renderer down to
  219. // recolour would leak WebGL contexts and throw away the camera the user had
  220. // positioned.
  221. useEffect(() => {
  222. const container = containerRef.current;
  223. if (!container) return;
  224. const width = container.clientWidth || 1;
  225. const height = container.clientHeight || 1;
  226. const scene = new THREE.Scene();
  227. scene.background = new THREE.Color(0x1a1a1a);
  228. sceneRef.current = scene;
  229. const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
  230. cameraRef.current = camera;
  231. const renderer = new THREE.WebGLRenderer({ antialias: true });
  232. renderer.setSize(width, height);
  233. renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  234. // Take the canvas out of flow before it is ever in the document (#2887).
  235. // `setSize` writes the size onto the canvas as inline width/height, and the
  236. // canvas lives inside the very element we measure and observe — so on a page
  237. // where that element's height comes from its content, each resize grew the
  238. // container, which fired the observer, which resized again. three.js leaves
  239. // the canvas `display: inline`, so the line box added its descender space
  240. // (~33px) every round and the page climbed without limit. Out of flow it
  241. // cannot contribute to the container's height at all; `display: block` is
  242. // belt and braces for the same descender, and matters if this is ever
  243. // rendered somewhere the absolute positioning is overridden.
  244. renderer.domElement.style.display = 'block';
  245. renderer.domElement.style.position = 'absolute';
  246. renderer.domElement.style.inset = '0';
  247. container.appendChild(renderer.domElement);
  248. const controls = new OrbitControls(camera, renderer.domElement);
  249. controls.enableDamping = true;
  250. controls.dampingFactor = 0.05;
  251. controlsRef.current = controls;
  252. // The bed. The toolpath shader lights itself (libvgcode carries its own
  253. // light directions), so the scene needs no lights at all.
  254. const grid = new THREE.GridHelper(
  255. Math.max(volume.x, volume.y),
  256. Math.ceil(Math.max(volume.x, volume.y) / 16),
  257. 0x444444,
  258. 0x333333,
  259. );
  260. // The toolpath group is rotated -90 degrees about X to take the slicer's
  261. // Z-up space into three's Y-up, which maps (x, y, z) to (x, z, -y) -- so
  262. // the bed's +Y runs along world -Z. Placing the grid at +Z left the print
  263. // sitting beside its own plate rather than on it.
  264. grid.position.set(volume.x / 2, 0, -volume.y / 2);
  265. scene.add(grid);
  266. let frame = 0;
  267. const animate = () => {
  268. frame = requestAnimationFrame(animate);
  269. controls.update();
  270. renderer.render(scene, camera);
  271. };
  272. animate();
  273. const handleResize = () => {
  274. const w = container.clientWidth || 1;
  275. const h = container.clientHeight || 1;
  276. camera.aspect = w / h;
  277. camera.updateProjectionMatrix();
  278. renderer.setSize(w, h);
  279. };
  280. const observer = new ResizeObserver(handleResize);
  281. observer.observe(container);
  282. window.addEventListener('resize', handleResize);
  283. return () => {
  284. window.removeEventListener('resize', handleResize);
  285. observer.disconnect();
  286. cancelAnimationFrame(frame);
  287. controls.dispose();
  288. grid.geometry.dispose();
  289. (grid.material as THREE.Material).dispose();
  290. renderer.dispose();
  291. container.removeChild(renderer.domElement);
  292. sceneRef.current = null;
  293. cameraRef.current = null;
  294. controlsRef.current = null;
  295. };
  296. }, [volume]);
  297. // --- Toolpath: rebuilt when the colouring changes its vertex layout ------
  298. useEffect(() => {
  299. const scene = sceneRef.current;
  300. const camera = cameraRef.current;
  301. const controls = controlsRef.current;
  302. if (!scene || !camera || !controls || !parsed || parsed.layers.length === 0) return;
  303. // Filament and feature colouring merge adjacent vertices differently, so
  304. // they genuinely produce different vertex streams and cannot share a mesh.
  305. const keyed = filamentView ? layersByFilament(parsed.layers) : parsed.layers;
  306. const sourceLayers = filterLayersByType(keyed, hidden);
  307. const data = buildSegmentData(sourceLayers, parsed.defaultWidth);
  308. const handle = makeToolpath(THREE, data);
  309. segmentDataRef.current = data;
  310. handleRef.current = handle;
  311. const group = new THREE.Group();
  312. group.rotation.x = -Math.PI / 2;
  313. group.add(handle.mesh);
  314. group.add(handle.travLines);
  315. scene.add(group);
  316. handle.setTravelVisible(showTravelRef.current);
  317. handle.setLayerRange(layerRangeRef.current[0], layerRangeRef.current[1]);
  318. setToolpathGeneration((n) => n + 1);
  319. // Frame only on first build, so switching colour mode does not yank the
  320. // camera back from wherever the user put it.
  321. if (!framedRef.current) {
  322. framedRef.current = true;
  323. // Not Box3.setFromObject: this renderer keeps segment positions in a
  324. // data texture, and the geometry attribute is only the 8-vertex diamond
  325. // template -- measuring the object reports a few millimetres, so the
  326. // camera parked itself far away and the print came out tiny.
  327. const b = parsed.bounds;
  328. const box = b
  329. ? new THREE.Box3(
  330. new THREE.Vector3(b.min[0], b.min[2], -b.max[1]),
  331. new THREE.Vector3(b.max[0], b.max[2], -b.min[1]),
  332. )
  333. : new THREE.Box3(new THREE.Vector3(0, 0, 0), new THREE.Vector3(volume.x, 1, -volume.y));
  334. const center = box.getCenter(new THREE.Vector3());
  335. const radius = Math.max(box.getSize(new THREE.Vector3()).length() / 2, 0.001);
  336. const vFov = THREE.MathUtils.degToRad(camera.fov);
  337. const hFov = 2 * Math.atan(Math.tan(vFov / 2) * camera.aspect);
  338. const distance = 1.15 * Math.max(radius / Math.sin(vFov / 2), radius / Math.sin(hFov / 2));
  339. camera.position.copy(center).addScaledVector(new THREE.Vector3(0.7, 0.5, 0.7).normalize(), distance);
  340. camera.near = Math.max(distance / 1000, 0.01);
  341. camera.far = distance + radius * 4;
  342. camera.updateProjectionMatrix();
  343. controls.target.copy(center);
  344. controls.update();
  345. }
  346. return () => {
  347. scene.remove(group);
  348. // The handle owns instanced buffers and a data texture per segment; on a
  349. // large print that is a lot of GPU memory to leave behind.
  350. handle.dispose();
  351. handleRef.current = null;
  352. segmentDataRef.current = null;
  353. };
  354. // hiddenKey rather than the Set, whose identity changes on every toggle.
  355. // eslint-disable-next-line react-hooks/exhaustive-deps
  356. }, [parsed, filamentView, volume, hiddenKey]);
  357. // --- Controls drive the existing handle rather than rebuilding it ---------
  358. useEffect(() => {
  359. handleRef.current?.setLayerRange(layerRange[0], layerRange[1]);
  360. }, [layerRange]);
  361. useEffect(() => {
  362. handleRef.current?.setTravelVisible(showTravel);
  363. }, [showTravel]);
  364. const colorResult = useMemo(() => {
  365. const data = segmentDataRef.current;
  366. if (!data || !parsed) return null;
  367. if (filamentView) {
  368. // In this mode each vertex's "type" is its filament index + 1, so the
  369. // AMS colours can be applied straight from the per-vertex metadata.
  370. const colors = new Float32Array(data.nV * 4);
  371. for (let v = 0; v < data.nV; v += 1) {
  372. const slot = Math.max(0, data.meta.vType[v] - 1);
  373. const hex = filamentColors?.[slot] ?? filamentColors?.[0] ?? '#00ae42';
  374. colors[v * 4] = packColor(hex);
  375. }
  376. return { color: colors, min: 0, max: 0, unit: '', cont: false };
  377. }
  378. // feature / height / width never consult the settings context, so an empty
  379. // one is honest here; speed / fan / temp would not be, which is why they
  380. // are not offered.
  381. return computeColors(data, viewMode, {});
  382. // toolpathGeneration is a dependency so a rebuilt mesh gets recoloured.
  383. // eslint-disable-next-line react-hooks/exhaustive-deps
  384. }, [viewMode, parsed, filamentView, filamentColors, toolpathGeneration]);
  385. useEffect(() => {
  386. if (colorResult) handleRef.current?.setColors(colorResult.color);
  387. }, [colorResult]);
  388. const layerCount = parsed?.layers.length ?? 0;
  389. if (notSliced) {
  390. return (
  391. <div className={`flex flex-col items-center justify-center gap-2 text-bambu-gray ${className}`}>
  392. <FileWarning className="w-8 h-8" />
  393. {t('gcodeViewer.notSliced', 'This file has not been sliced yet.')}
  394. </div>
  395. );
  396. }
  397. if (error) {
  398. return (
  399. <div className={`flex flex-col items-center justify-center gap-2 text-bambu-gray ${className}`}>
  400. <FileWarning className="w-8 h-8" />
  401. {t('gcodeViewer.loadFailed', 'Could not load the G-code for this file.')}
  402. </div>
  403. );
  404. }
  405. return (
  406. <div className={`relative ${className}`}>
  407. {/*
  408. Absolute, not `w-full h-full` (#2887). The canvas is appended here and
  409. this element is what the ResizeObserver watches, so its height must come
  410. from the pane above it and never from what it contains. `h-full` is a
  411. percentage, which resolves to `auto` unless every ancestor has a definite
  412. height — on the full-page route none does, so the height fell through to
  413. the content and the canvas ended up sizing the box that sizes the canvas.
  414. `inset-0` against the `relative` parent is a definite height whatever the
  415. page does, which also keeps this working if a future caller forgets to
  416. give the pane a height of its own.
  417. */}
  418. <div ref={containerRef} className="absolute inset-0" />
  419. {loading && (
  420. <div className="absolute inset-0 flex items-center justify-center gap-2 bg-bambu-dark/60 text-sm text-bambu-gray">
  421. <Loader2 className="w-4 h-4 animate-spin" />
  422. {t('gcodeViewer.loading', 'Reading toolpath...')}
  423. </div>
  424. )}
  425. {!loading && layerCount > 0 && (
  426. <>
  427. {/* Colour mode + travel toggle */}
  428. <div className="absolute left-3 top-3 flex flex-col gap-2 rounded border border-bambu-dark-tertiary bg-bambu-dark/85 p-2">
  429. <div className="flex overflow-hidden rounded border border-bambu-dark-tertiary">
  430. {VIEW_MODES.filter((mode) => mode !== 'filament' || hasFilamentColors).map((mode) => (
  431. <button
  432. key={mode}
  433. type="button"
  434. onClick={() => {
  435. modeChosenRef.current = true;
  436. setViewMode(mode);
  437. }}
  438. className={`px-2 py-1 text-xs transition-colors ${
  439. viewMode === mode ? 'bg-bambu-green text-white' : 'text-bambu-gray hover:text-white'
  440. }`}
  441. >
  442. {t(`gcodeViewer.view.${mode}`, mode)}
  443. </button>
  444. ))}
  445. </div>
  446. <label className="flex cursor-pointer items-center gap-2 text-xs text-bambu-gray">
  447. <input
  448. type="checkbox"
  449. checked={showTravel}
  450. onChange={(e) => setShowTravel(e.target.checked)}
  451. className="cursor-pointer"
  452. />
  453. {t('gcodeViewer.showTravel', 'Travel moves')}
  454. </label>
  455. {filamentView ? (
  456. <ul className="flex flex-col gap-0.5">
  457. {(filamentColors ?? []).map((color, slot) => {
  458. // Filament view keys types as slot + 1; see layersByFilament.
  459. const type = slot + 1;
  460. const isHidden = hidden.has(type);
  461. return (
  462. <li key={slot}>
  463. <button
  464. type="button"
  465. onClick={() => toggleHidden(type)}
  466. aria-pressed={!isHidden}
  467. className={`flex w-full items-center gap-1.5 text-left text-[0.7rem] transition-opacity hover:text-white ${
  468. isHidden ? 'text-bambu-gray/40' : 'text-bambu-gray'
  469. }`}
  470. >
  471. <span
  472. className={`inline-block h-2.5 w-2.5 shrink-0 rounded-sm border border-white/20 ${isHidden ? 'opacity-25' : ''}`}
  473. style={{ backgroundColor: color }}
  474. aria-hidden
  475. />
  476. <span className={isHidden ? 'line-through' : ''}>
  477. {t('gcodeViewer.filamentSlot', 'Filament {{n}}', { n: slot + 1 })}
  478. </span>
  479. </button>
  480. </li>
  481. );
  482. })}
  483. </ul>
  484. ) : viewMode === 'feature' ? (
  485. <ul className="flex flex-col gap-0.5">
  486. {LEGEND_ENTRIES.map((entry) => {
  487. const isHidden = hidden.has(entry.type);
  488. return (
  489. <li key={entry.type}>
  490. <button
  491. type="button"
  492. onClick={() => toggleHidden(entry.type)}
  493. aria-pressed={!isHidden}
  494. className={`flex w-full items-center gap-1.5 text-left text-[0.7rem] transition-opacity hover:text-white ${
  495. isHidden ? 'text-bambu-gray/40' : 'text-bambu-gray'
  496. }`}
  497. >
  498. <span
  499. className={`inline-block h-2.5 w-2.5 shrink-0 rounded-sm ${isHidden ? 'opacity-25' : ''}`}
  500. style={{ backgroundColor: cssColor(TYPE_COLOR[entry.type]) }}
  501. aria-hidden
  502. />
  503. <span className={isHidden ? 'line-through' : ''}>{t(entry.key, entry.fallback)}</span>
  504. </button>
  505. </li>
  506. );
  507. })}
  508. </ul>
  509. ) : (
  510. colorResult && (
  511. // Continuous scale. The ramp is drawn from the renderer's own
  512. // stops rather than an approximation, and the ends are labelled
  513. // -- a bare gradient says nothing about what the colours mean.
  514. <div className="flex flex-col gap-1">
  515. <div
  516. className="h-2.5 w-full rounded-sm border border-white/10"
  517. style={{ background: `linear-gradient(to right, ${rampStops()})` }}
  518. aria-hidden
  519. />
  520. <div className="flex items-center justify-between text-[0.7rem] tabular-nums text-bambu-gray">
  521. <span>{formatScale(colorResult.min)}</span>
  522. <span className="text-bambu-gray/70">{colorResult.unit}</span>
  523. <span>{formatScale(colorResult.max)}</span>
  524. </div>
  525. </div>
  526. )
  527. )}
  528. </div>
  529. {/* Layer range. Two ends, because inspecting a print means isolating
  530. a band of layers, not just capping the top. */}
  531. <div className="absolute right-3 top-3 flex flex-col items-center gap-1 rounded border border-bambu-dark-tertiary bg-bambu-dark/85 p-2">
  532. <span className="text-[0.65rem] tabular-nums text-bambu-gray">{layerRange[1] + 1}</span>
  533. <input
  534. type="range"
  535. min={0}
  536. max={Math.max(0, layerCount - 1)}
  537. value={layerRange[1]}
  538. onChange={(e) => {
  539. const top = Number(e.target.value);
  540. setLayerRange(([bottom]) => [Math.min(bottom, top), top]);
  541. }}
  542. aria-label={t('gcodeViewer.topLayer', 'Top layer')}
  543. className="h-40 w-4 cursor-pointer"
  544. style={{ writingMode: 'vertical-lr', direction: 'rtl' }}
  545. />
  546. <input
  547. type="range"
  548. min={0}
  549. max={Math.max(0, layerCount - 1)}
  550. value={layerRange[0]}
  551. onChange={(e) => {
  552. const bottom = Number(e.target.value);
  553. setLayerRange(([, top]) => [bottom, Math.max(bottom, top)]);
  554. }}
  555. aria-label={t('gcodeViewer.bottomLayer', 'Bottom layer')}
  556. className="h-40 w-4 cursor-pointer"
  557. style={{ writingMode: 'vertical-lr', direction: 'rtl' }}
  558. />
  559. <span className="text-[0.65rem] tabular-nums text-bambu-gray">{layerRange[0] + 1}</span>
  560. </div>
  561. </>
  562. )}
  563. </div>
  564. );
  565. }