GcodeToolpathViewer.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  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. container.appendChild(renderer.domElement);
  235. const controls = new OrbitControls(camera, renderer.domElement);
  236. controls.enableDamping = true;
  237. controls.dampingFactor = 0.05;
  238. controlsRef.current = controls;
  239. // The bed. The toolpath shader lights itself (libvgcode carries its own
  240. // light directions), so the scene needs no lights at all.
  241. const grid = new THREE.GridHelper(
  242. Math.max(volume.x, volume.y),
  243. Math.ceil(Math.max(volume.x, volume.y) / 16),
  244. 0x444444,
  245. 0x333333,
  246. );
  247. // The toolpath group is rotated -90 degrees about X to take the slicer's
  248. // Z-up space into three's Y-up, which maps (x, y, z) to (x, z, -y) -- so
  249. // the bed's +Y runs along world -Z. Placing the grid at +Z left the print
  250. // sitting beside its own plate rather than on it.
  251. grid.position.set(volume.x / 2, 0, -volume.y / 2);
  252. scene.add(grid);
  253. let frame = 0;
  254. const animate = () => {
  255. frame = requestAnimationFrame(animate);
  256. controls.update();
  257. renderer.render(scene, camera);
  258. };
  259. animate();
  260. const handleResize = () => {
  261. const w = container.clientWidth || 1;
  262. const h = container.clientHeight || 1;
  263. camera.aspect = w / h;
  264. camera.updateProjectionMatrix();
  265. renderer.setSize(w, h);
  266. };
  267. const observer = new ResizeObserver(handleResize);
  268. observer.observe(container);
  269. window.addEventListener('resize', handleResize);
  270. return () => {
  271. window.removeEventListener('resize', handleResize);
  272. observer.disconnect();
  273. cancelAnimationFrame(frame);
  274. controls.dispose();
  275. grid.geometry.dispose();
  276. (grid.material as THREE.Material).dispose();
  277. renderer.dispose();
  278. container.removeChild(renderer.domElement);
  279. sceneRef.current = null;
  280. cameraRef.current = null;
  281. controlsRef.current = null;
  282. };
  283. }, [volume]);
  284. // --- Toolpath: rebuilt when the colouring changes its vertex layout ------
  285. useEffect(() => {
  286. const scene = sceneRef.current;
  287. const camera = cameraRef.current;
  288. const controls = controlsRef.current;
  289. if (!scene || !camera || !controls || !parsed || parsed.layers.length === 0) return;
  290. // Filament and feature colouring merge adjacent vertices differently, so
  291. // they genuinely produce different vertex streams and cannot share a mesh.
  292. const keyed = filamentView ? layersByFilament(parsed.layers) : parsed.layers;
  293. const sourceLayers = filterLayersByType(keyed, hidden);
  294. const data = buildSegmentData(sourceLayers, parsed.defaultWidth);
  295. const handle = makeToolpath(THREE, data);
  296. segmentDataRef.current = data;
  297. handleRef.current = handle;
  298. const group = new THREE.Group();
  299. group.rotation.x = -Math.PI / 2;
  300. group.add(handle.mesh);
  301. group.add(handle.travLines);
  302. scene.add(group);
  303. handle.setTravelVisible(showTravelRef.current);
  304. handle.setLayerRange(layerRangeRef.current[0], layerRangeRef.current[1]);
  305. setToolpathGeneration((n) => n + 1);
  306. // Frame only on first build, so switching colour mode does not yank the
  307. // camera back from wherever the user put it.
  308. if (!framedRef.current) {
  309. framedRef.current = true;
  310. // Not Box3.setFromObject: this renderer keeps segment positions in a
  311. // data texture, and the geometry attribute is only the 8-vertex diamond
  312. // template -- measuring the object reports a few millimetres, so the
  313. // camera parked itself far away and the print came out tiny.
  314. const b = parsed.bounds;
  315. const box = b
  316. ? new THREE.Box3(
  317. new THREE.Vector3(b.min[0], b.min[2], -b.max[1]),
  318. new THREE.Vector3(b.max[0], b.max[2], -b.min[1]),
  319. )
  320. : new THREE.Box3(new THREE.Vector3(0, 0, 0), new THREE.Vector3(volume.x, 1, -volume.y));
  321. const center = box.getCenter(new THREE.Vector3());
  322. const radius = Math.max(box.getSize(new THREE.Vector3()).length() / 2, 0.001);
  323. const vFov = THREE.MathUtils.degToRad(camera.fov);
  324. const hFov = 2 * Math.atan(Math.tan(vFov / 2) * camera.aspect);
  325. const distance = 1.15 * Math.max(radius / Math.sin(vFov / 2), radius / Math.sin(hFov / 2));
  326. camera.position.copy(center).addScaledVector(new THREE.Vector3(0.7, 0.5, 0.7).normalize(), distance);
  327. camera.near = Math.max(distance / 1000, 0.01);
  328. camera.far = distance + radius * 4;
  329. camera.updateProjectionMatrix();
  330. controls.target.copy(center);
  331. controls.update();
  332. }
  333. return () => {
  334. scene.remove(group);
  335. // The handle owns instanced buffers and a data texture per segment; on a
  336. // large print that is a lot of GPU memory to leave behind.
  337. handle.dispose();
  338. handleRef.current = null;
  339. segmentDataRef.current = null;
  340. };
  341. // hiddenKey rather than the Set, whose identity changes on every toggle.
  342. // eslint-disable-next-line react-hooks/exhaustive-deps
  343. }, [parsed, filamentView, volume, hiddenKey]);
  344. // --- Controls drive the existing handle rather than rebuilding it ---------
  345. useEffect(() => {
  346. handleRef.current?.setLayerRange(layerRange[0], layerRange[1]);
  347. }, [layerRange]);
  348. useEffect(() => {
  349. handleRef.current?.setTravelVisible(showTravel);
  350. }, [showTravel]);
  351. const colorResult = useMemo(() => {
  352. const data = segmentDataRef.current;
  353. if (!data || !parsed) return null;
  354. if (filamentView) {
  355. // In this mode each vertex's "type" is its filament index + 1, so the
  356. // AMS colours can be applied straight from the per-vertex metadata.
  357. const colors = new Float32Array(data.nV * 4);
  358. for (let v = 0; v < data.nV; v += 1) {
  359. const slot = Math.max(0, data.meta.vType[v] - 1);
  360. const hex = filamentColors?.[slot] ?? filamentColors?.[0] ?? '#00ae42';
  361. colors[v * 4] = packColor(hex);
  362. }
  363. return { color: colors, min: 0, max: 0, unit: '', cont: false };
  364. }
  365. // feature / height / width never consult the settings context, so an empty
  366. // one is honest here; speed / fan / temp would not be, which is why they
  367. // are not offered.
  368. return computeColors(data, viewMode, {});
  369. // toolpathGeneration is a dependency so a rebuilt mesh gets recoloured.
  370. // eslint-disable-next-line react-hooks/exhaustive-deps
  371. }, [viewMode, parsed, filamentView, filamentColors, toolpathGeneration]);
  372. useEffect(() => {
  373. if (colorResult) handleRef.current?.setColors(colorResult.color);
  374. }, [colorResult]);
  375. const layerCount = parsed?.layers.length ?? 0;
  376. if (notSliced) {
  377. return (
  378. <div className={`flex flex-col items-center justify-center gap-2 text-bambu-gray ${className}`}>
  379. <FileWarning className="w-8 h-8" />
  380. {t('gcodeViewer.notSliced', 'This file has not been sliced yet.')}
  381. </div>
  382. );
  383. }
  384. if (error) {
  385. return (
  386. <div className={`flex flex-col items-center justify-center gap-2 text-bambu-gray ${className}`}>
  387. <FileWarning className="w-8 h-8" />
  388. {t('gcodeViewer.loadFailed', 'Could not load the G-code for this file.')}
  389. </div>
  390. );
  391. }
  392. return (
  393. <div className={`relative ${className}`}>
  394. <div ref={containerRef} className="w-full h-full" />
  395. {loading && (
  396. <div className="absolute inset-0 flex items-center justify-center gap-2 bg-bambu-dark/60 text-sm text-bambu-gray">
  397. <Loader2 className="w-4 h-4 animate-spin" />
  398. {t('gcodeViewer.loading', 'Reading toolpath...')}
  399. </div>
  400. )}
  401. {!loading && layerCount > 0 && (
  402. <>
  403. {/* Colour mode + travel toggle */}
  404. <div className="absolute left-3 top-3 flex flex-col gap-2 rounded border border-bambu-dark-tertiary bg-bambu-dark/85 p-2">
  405. <div className="flex overflow-hidden rounded border border-bambu-dark-tertiary">
  406. {VIEW_MODES.filter((mode) => mode !== 'filament' || hasFilamentColors).map((mode) => (
  407. <button
  408. key={mode}
  409. type="button"
  410. onClick={() => {
  411. modeChosenRef.current = true;
  412. setViewMode(mode);
  413. }}
  414. className={`px-2 py-1 text-xs transition-colors ${
  415. viewMode === mode ? 'bg-bambu-green text-white' : 'text-bambu-gray hover:text-white'
  416. }`}
  417. >
  418. {t(`gcodeViewer.view.${mode}`, mode)}
  419. </button>
  420. ))}
  421. </div>
  422. <label className="flex cursor-pointer items-center gap-2 text-xs text-bambu-gray">
  423. <input
  424. type="checkbox"
  425. checked={showTravel}
  426. onChange={(e) => setShowTravel(e.target.checked)}
  427. className="cursor-pointer"
  428. />
  429. {t('gcodeViewer.showTravel', 'Travel moves')}
  430. </label>
  431. {filamentView ? (
  432. <ul className="flex flex-col gap-0.5">
  433. {(filamentColors ?? []).map((color, slot) => {
  434. // Filament view keys types as slot + 1; see layersByFilament.
  435. const type = slot + 1;
  436. const isHidden = hidden.has(type);
  437. return (
  438. <li key={slot}>
  439. <button
  440. type="button"
  441. onClick={() => toggleHidden(type)}
  442. aria-pressed={!isHidden}
  443. className={`flex w-full items-center gap-1.5 text-left text-[0.7rem] transition-opacity hover:text-white ${
  444. isHidden ? 'text-bambu-gray/40' : 'text-bambu-gray'
  445. }`}
  446. >
  447. <span
  448. className={`inline-block h-2.5 w-2.5 shrink-0 rounded-sm border border-white/20 ${isHidden ? 'opacity-25' : ''}`}
  449. style={{ backgroundColor: color }}
  450. aria-hidden
  451. />
  452. <span className={isHidden ? 'line-through' : ''}>
  453. {t('gcodeViewer.filamentSlot', 'Filament {{n}}', { n: slot + 1 })}
  454. </span>
  455. </button>
  456. </li>
  457. );
  458. })}
  459. </ul>
  460. ) : viewMode === 'feature' ? (
  461. <ul className="flex flex-col gap-0.5">
  462. {LEGEND_ENTRIES.map((entry) => {
  463. const isHidden = hidden.has(entry.type);
  464. return (
  465. <li key={entry.type}>
  466. <button
  467. type="button"
  468. onClick={() => toggleHidden(entry.type)}
  469. aria-pressed={!isHidden}
  470. className={`flex w-full items-center gap-1.5 text-left text-[0.7rem] transition-opacity hover:text-white ${
  471. isHidden ? 'text-bambu-gray/40' : 'text-bambu-gray'
  472. }`}
  473. >
  474. <span
  475. className={`inline-block h-2.5 w-2.5 shrink-0 rounded-sm ${isHidden ? 'opacity-25' : ''}`}
  476. style={{ backgroundColor: cssColor(TYPE_COLOR[entry.type]) }}
  477. aria-hidden
  478. />
  479. <span className={isHidden ? 'line-through' : ''}>{t(entry.key, entry.fallback)}</span>
  480. </button>
  481. </li>
  482. );
  483. })}
  484. </ul>
  485. ) : (
  486. colorResult && (
  487. // Continuous scale. The ramp is drawn from the renderer's own
  488. // stops rather than an approximation, and the ends are labelled
  489. // -- a bare gradient says nothing about what the colours mean.
  490. <div className="flex flex-col gap-1">
  491. <div
  492. className="h-2.5 w-full rounded-sm border border-white/10"
  493. style={{ background: `linear-gradient(to right, ${rampStops()})` }}
  494. aria-hidden
  495. />
  496. <div className="flex items-center justify-between text-[0.7rem] tabular-nums text-bambu-gray">
  497. <span>{formatScale(colorResult.min)}</span>
  498. <span className="text-bambu-gray/70">{colorResult.unit}</span>
  499. <span>{formatScale(colorResult.max)}</span>
  500. </div>
  501. </div>
  502. )
  503. )}
  504. </div>
  505. {/* Layer range. Two ends, because inspecting a print means isolating
  506. a band of layers, not just capping the top. */}
  507. <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">
  508. <span className="text-[0.65rem] tabular-nums text-bambu-gray">{layerRange[1] + 1}</span>
  509. <input
  510. type="range"
  511. min={0}
  512. max={Math.max(0, layerCount - 1)}
  513. value={layerRange[1]}
  514. onChange={(e) => {
  515. const top = Number(e.target.value);
  516. setLayerRange(([bottom]) => [Math.min(bottom, top), top]);
  517. }}
  518. aria-label={t('gcodeViewer.topLayer', 'Top layer')}
  519. className="h-40 w-4 cursor-pointer"
  520. style={{ writingMode: 'vertical-lr', direction: 'rtl' }}
  521. />
  522. <input
  523. type="range"
  524. min={0}
  525. max={Math.max(0, layerCount - 1)}
  526. value={layerRange[0]}
  527. onChange={(e) => {
  528. const bottom = Number(e.target.value);
  529. setLayerRange(([, top]) => [bottom, Math.max(bottom, top)]);
  530. }}
  531. aria-label={t('gcodeViewer.bottomLayer', 'Bottom layer')}
  532. className="h-40 w-4 cursor-pointer"
  533. style={{ writingMode: 'vertical-lr', direction: 'rtl' }}
  534. />
  535. <span className="text-[0.65rem] tabular-nums text-bambu-gray">{layerRange[0] + 1}</span>
  536. </div>
  537. </>
  538. )}
  539. </div>
  540. );
  541. }