Files
cadSet/text-to-cad/viewer/src/client/components/workbench/GcodeFileSheet.js
T
2026-07-22 19:38:36 +08:00

429 lines
17 KiB
JavaScript

import FileSheet, {
FILE_SHEET_FIELD_LABEL_CLASSES,
FILE_SHEET_PRECISION_SLIDER_CLASSES,
FileSheetSection,
FileSheetSliderField,
FileSheetSubsection,
FileSheetToggleRow,
parseFileSheetNumberInput
} from "./FileSheet";
import FileMetadataSection from "./FileMetadataSection";
import {
Accordion
} from "../ui/accordion";
import { Badge } from "../ui/badge";
import { Slider } from "../ui/slider";
import {
DEFAULT_GCODE_PREVIEW_DETAIL_LEVEL,
GCODE_PREVIEW_DETAIL_MAX,
GCODE_PREVIEW_DETAIL_MIN
} from "cadjs/lib/gcode/buildPreviewMesh";
import FileStatusSection from "./FileStatusSection";
const fieldLabelClasses = FILE_SHEET_FIELD_LABEL_CLASSES;
const compactNumberBadgeClasses = "h-4 rounded-sm px-1.5 py-0 text-[10px] font-medium leading-none";
function formatNumber(value, digits = 2) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue.toFixed(digits) : (0).toFixed(digits);
}
function formatCount(value) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? String(Math.round(numericValue)) : "0";
}
function formatOrdinal(value) {
const numericValue = Math.round(Number(value) || 0);
const absoluteValue = Math.abs(numericValue);
const tensRemainder = absoluteValue % 100;
if (tensRemainder >= 11 && tensRemainder <= 13) {
return `${numericValue}th`;
}
switch (absoluteValue % 10) {
case 1:
return `${numericValue}st`;
case 2:
return `${numericValue}nd`;
case 3:
return `${numericValue}rd`;
default:
return `${numericValue}th`;
}
}
function formatDistance(value, digits = 1) {
return `${formatNumber(value, digits)} mm`;
}
function formatCommandCount(value) {
const count = formatCount(value);
return `${count} cmd${count === "1" ? "" : "s"}`;
}
function formatFeatureCategory(value) {
const normalized = String(value || "").trim();
return normalized ? `${normalized[0].toUpperCase()}${normalized.slice(1)}` : "Other";
}
function previewDetailLabel(value) {
switch (Math.round(Number(value) || DEFAULT_GCODE_PREVIEW_DETAIL_LEVEL)) {
case 1:
return "Light";
case 2:
return "Lean";
case 4:
return "Dense";
case 5:
return "High";
case 6:
return "Very high";
case 7:
return "Max adaptive";
case 3:
default:
return "Standard";
}
}
function normalizedBounds(bounds) {
const min = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0];
const max = Array.isArray(bounds?.max) ? bounds.max : [0, 0, 0];
return { min, max };
}
function boundsAxisText(bounds, axis, digits = 1) {
const { min, max } = normalizedBounds(bounds);
return `${formatNumber(min[axis], digits)} to ${formatNumber(max[axis], digits)} mm`;
}
function visibleLayerText(layerCount, maxLayer) {
if (layerCount < 1) {
return "0 / 0";
}
const visibleCount = maxLayer + 1;
return visibleCount > 1
? `1-${visibleCount} / ${layerCount}`
: `1 / ${layerCount}`;
}
function parseVisibleLayerInput(value, fallbackVisibleCount, layerCount) {
if (layerCount < 1) {
return 0;
}
const text = String(value ?? "").trim();
const rangeMatch = text.match(/\b\d+\s*-\s*(\d+)\b/);
const parsedValue = rangeMatch?.[1] ?? value;
return parseFileSheetNumberInput(parsedValue, {
fallback: fallbackVisibleCount,
min: 1,
max: Math.max(layerCount, 0),
integer: true
}) - 1;
}
function GcodeValueField({ label, value, mono = false }) {
const displayValue = String(value ?? "");
return (
<div className="block min-w-0">
<span className={fieldLabelClasses}>{label}</span>
<div
className={`mt-1 min-h-7 truncate rounded-md border border-border/70 bg-muted/25 px-2 py-1.5 text-[11px] font-medium leading-4 text-foreground ${mono ? "font-mono" : ""}`}
title={displayValue}
>
{displayValue}
</div>
</div>
);
}
export default function GcodeFileSheet({
open,
isDesktop,
width,
selectedEntry = null,
onOpenChange,
onStartResize,
gcodeData = null,
previewMetadata = null,
maxLayer = 0,
showTravel = false,
fullDetail = false,
previewDetailLevel = DEFAULT_GCODE_PREVIEW_DETAIL_LEVEL,
onMaxLayerChange,
onShowTravelChange,
onFullDetailChange,
onPreviewDetailLevelChange,
fileDownloadAvailable = false,
viewerServerInfo = null,
localFileOpenAvailable = false,
fileAccessBusyKey = "",
onOpenFileAsset,
suppressDynamicMetadataStatus = false,
statusItems = [],
themeSections = null,
openSectionIds = [],
onOpenSectionIdsChange
}) {
const layers = Array.isArray(gcodeData?.layers) ? gcodeData.layers : [];
const stats = gcodeData?.stats || {};
const features = Array.isArray(gcodeData?.features) ? gcodeData.features : [];
const supportFeatures = features.filter((feature) => feature?.category === "support");
const hasSupports = supportFeatures.some((feature) => (
Number(feature?.extrusionMoves) > 0 ||
Number(feature?.markerCount) > 0
));
const featureMarkerCount = features.reduce((total, feature) => total + (Number(feature?.markerCount) || 0), 0);
const renderedSegments = Number(previewMetadata?.renderedSegments);
const visibleSegments = Number(previewMetadata?.visibleSegments);
const layerStride = Number(previewMetadata?.layerStride);
const renderedLayers = Number(previewMetadata?.renderedLayers);
const visiblePreviewLayers = Number(previewMetadata?.visibleLayers);
const decimatedPreview = Boolean(previewMetadata?.decimated);
const renderedPathText = Number.isFinite(renderedSegments) && Number.isFinite(visibleSegments)
? `${formatCount(renderedSegments)} / ${formatCount(visibleSegments)}`
: "";
const renderedLayerText = Number.isFinite(renderedLayers) && Number.isFinite(visiblePreviewLayers)
? `${formatCount(renderedLayers)} / ${formatCount(visiblePreviewLayers)}`
: "";
const layerCount = layers.length;
const safePreviewDetailLevel = Math.min(
Math.max(Math.round(Number(previewDetailLevel) || DEFAULT_GCODE_PREVIEW_DETAIL_LEVEL), GCODE_PREVIEW_DETAIL_MIN),
GCODE_PREVIEW_DETAIL_MAX
);
const safeMaxLayer = layerCount > 0
? Math.min(Math.max(Math.trunc(Number(maxLayer) || 0), 0), layerCount - 1)
: 0;
const hasGcodeData = Boolean(gcodeData);
const visibleLayers = visibleLayerText(layerCount, safeMaxLayer);
return (
<FileSheet
open={open}
title="G-code"
isDesktop={isDesktop}
width={width}
onOpenChange={onOpenChange}
onStartResize={onStartResize}
>
<Accordion
type="multiple"
value={openSectionIds}
onValueChange={onOpenSectionIdsChange}
className="text-sm"
>
<FileStatusSection items={statusItems} />
<FileSheetSection value="toolpath" title="Toolpath">
<div>
{!hasGcodeData ? (
<p className="px-3 py-2 text-xs text-muted-foreground">Loading G-code...</p>
) : null}
<FileSheetSubsection title="Summary" contentClassName="px-3">
<div className="flex flex-wrap gap-1.5">
<Badge variant="secondary" className="font-normal">
{formatCount(layerCount)} layer{layerCount === 1 ? "" : "s"}
</Badge>
<Badge variant="outline" className="font-normal">
{formatCount(stats.extrusionMoves)} path{formatCount(stats.extrusionMoves) === "1" ? "" : "s"}
</Badge>
<Badge variant={hasSupports ? "secondary" : "outline"} className="font-normal">
Supports {hasSupports ? "yes" : "no"}
</Badge>
{decimatedPreview ? (
<Badge variant="outline" className="font-normal">
Layer preview
</Badge>
) : null}
</div>
</FileSheetSubsection>
<FileSheetSubsection title="Layers">
<FileSheetSliderField
label="Visible layers"
value={visibleLayers}
onValueCommit={(nextValue) => {
onMaxLayerChange?.(parseVisibleLayerInput(nextValue, safeMaxLayer + 1, layerCount));
}}
valueInputProps={{
disabled: layerCount <= 1,
ariaLabel: "Visible G-code layers value",
inputMode: "numeric"
}}
>
<Slider
value={[safeMaxLayer]}
min={0}
max={Math.max(layerCount - 1, 0)}
step={1}
disabled={layerCount <= 1}
onValueChange={(nextValue) => {
onMaxLayerChange?.(Number(nextValue?.[0] ?? 0));
}}
className={FILE_SHEET_PRECISION_SLIDER_CLASSES}
aria-label="Visible G-code layers"
/>
</FileSheetSliderField>
</FileSheetSubsection>
<FileSheetSubsection
title="Preview"
>
<FileSheetSliderField
label="Adaptive detail"
value={`${previewDetailLabel(safePreviewDetailLevel)} (${safePreviewDetailLevel}/${GCODE_PREVIEW_DETAIL_MAX})`}
onValueCommit={(nextValue) => {
onPreviewDetailLevelChange?.(parseFileSheetNumberInput(nextValue, {
fallback: safePreviewDetailLevel,
min: GCODE_PREVIEW_DETAIL_MIN,
max: GCODE_PREVIEW_DETAIL_MAX,
integer: true
}));
}}
valueInputProps={{
disabled: fullDetail,
ariaLabel: "G-code preview detail value",
inputMode: "numeric"
}}
>
<Slider
value={[safePreviewDetailLevel]}
min={GCODE_PREVIEW_DETAIL_MIN}
max={GCODE_PREVIEW_DETAIL_MAX}
step={1}
disabled={fullDetail}
onValueChange={(nextValue) => {
onPreviewDetailLevelChange?.(Number(nextValue?.[0] ?? DEFAULT_GCODE_PREVIEW_DETAIL_LEVEL));
}}
className={FILE_SHEET_PRECISION_SLIDER_CLASSES}
aria-label="G-code preview detail"
/>
</FileSheetSliderField>
</FileSheetSubsection>
<FileSheetSubsection title="Rendering">
<FileSheetToggleRow
label="Travel moves"
description={`${formatCount(stats.travelMoves)} move${formatCount(stats.travelMoves) === "1" ? "" : "s"}`}
checked={showTravel}
onCheckedChange={(checked) => onShowTravelChange?.(checked)}
ariaLabel="Show G-code travel moves"
/>
<FileSheetToggleRow
label="Full detail"
description={fullDetail
? "Exact: render every visible path."
: renderedPathText
? `${renderedPathText} rendered path${renderedSegments === 1 ? "" : "s"}${decimatedPreview && Number.isFinite(layerStride) ? ` across ${renderedLayerText || "sampled"} layer${renderedLayers === 1 ? "" : "s"}, every ${formatOrdinal(layerStride)}` : ""}`
: "Use adaptive layer sampling."}
checked={fullDetail}
onCheckedChange={(checked) => onFullDetailChange?.(checked)}
ariaLabel="Render full G-code detail"
/>
</FileSheetSubsection>
</div>
</FileSheetSection>
<FileSheetSection
value="features"
title={(
<span className="flex min-w-0 items-center gap-2">
<span>Features</span>
{features.length ? (
<Badge variant="outline" className={compactNumberBadgeClasses}>{features.length}</Badge>
) : null}
{hasSupports ? (
<Badge variant="outline" className="font-normal">Supports</Badge>
) : null}
</span>
)}
>
<div className="space-y-2 px-3 py-2.5">
{!features.length ? (
<p className="text-xs leading-5 text-muted-foreground">
No slicer feature annotations found. The preview can still show layers, extrusion paths, and travel moves.
</p>
) : (
<>
<div className="flex flex-wrap gap-1.5 text-[11px]">
<Badge variant={hasSupports ? "secondary" : "outline"} className="font-normal">
Supports {hasSupports ? "included" : "not detected"}
</Badge>
<Badge variant="outline" className={compactNumberBadgeClasses}>
{formatCount(featureMarkerCount)} markers
</Badge>
</div>
<ul className="divide-y divide-border/60 overflow-hidden rounded-md border border-border/60 bg-muted/15">
{features.map((feature) => {
const extrusionMoves = formatCount(feature?.extrusionMoves);
const markerCount = formatCount(feature?.markerCount);
const detailParts = [
`${extrusionMoves} path${extrusionMoves === "1" ? "" : "s"}`,
formatDistance(feature?.pathMm, 1),
feature?.layerRangeText || "",
markerCount !== "0" ? `${markerCount} marker${markerCount === "1" ? "" : "s"}` : ""
].filter(Boolean);
const rawLabels = Array.isArray(feature?.rawLabels) && feature.rawLabels.length
? feature.rawLabels.join(", ")
: "";
return (
<li
key={feature?.id || feature?.label}
className="min-w-0 px-2.5 py-1.5"
title={rawLabels}
>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
{feature?.label || "Other"}
</span>
<span className="shrink-0 text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
{formatFeatureCategory(feature?.category)}
</span>
</div>
<p className="truncate text-[11px] leading-5 text-muted-foreground">
{detailParts.join(" | ")}
</p>
</li>
);
})}
</ul>
</>
)}
</div>
</FileSheetSection>
<FileSheetSection value="stats" title="Stats">
<div className="grid grid-cols-2 gap-2 px-3 py-3">
<GcodeValueField label="Extrusion" value={formatDistance(stats.extrusionMm, 1)} />
<GcodeValueField label="Path length" value={formatDistance(stats.pathMm, 1)} />
<GcodeValueField label="Retracts" value={formatCount(stats.retractMoves)} />
<GcodeValueField label="Temperature" value={formatCommandCount(stats.temperatureCommands)} />
<GcodeValueField label="Move commands" value={formatCommandCount(stats.movementCommands)} />
<GcodeValueField label="Prime moves" value={formatCount(stats.primeMoves)} />
</div>
</FileSheetSection>
<FileSheetSection value="bounds" title="Bounds">
<div className="grid grid-cols-1 gap-2 px-3 py-3">
<GcodeValueField label="X" value={boundsAxisText(gcodeData?.bounds, 0, 1)} mono />
<GcodeValueField label="Y" value={boundsAxisText(gcodeData?.bounds, 1, 1)} mono />
<GcodeValueField label="Z" value={boundsAxisText(gcodeData?.bounds, 2, 2)} mono />
</div>
</FileSheetSection>
{themeSections}
<FileMetadataSection
entry={selectedEntry}
fileDownloadAvailable={fileDownloadAvailable}
viewerServerInfo={viewerServerInfo}
localFileOpenAvailable={localFileOpenAvailable}
fileAccessBusyKey={fileAccessBusyKey}
onOpenFileAsset={onOpenFileAsset}
suppressDynamicStatus={suppressDynamicMetadataStatus}
/>
</Accordion>
</FileSheet>
);
}