Files
cdsl-cad/frontend/src/components/parameter-panel.tsx
T
2026-08-19 19:34:30 +08:00

399 lines
17 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import * as Collapsible from "@radix-ui/react-collapsible";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { ChevronDown, ChevronUp, Download, Loader2, RefreshCcw, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CadamSlider } from "@/components/ui/cadam-slider";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
type EditableParameter = Record<string, unknown>;
type NormalizedParameter = {
id: string;
name: string;
displayName: string;
group: string;
groupDisplayName: string;
value: number;
defaultValue: number;
min: number | null;
max: number | null;
step: number;
precision: number;
unit: string;
editable: boolean;
editState: string;
};
type DownloadFormat = {
label: string;
description: string;
url: string;
};
function numberValue(value: unknown) {
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function parameterToView(parameter: EditableParameter): NormalizedParameter | null {
const id = String(parameter.id || parameter.name || "").trim();
const name = String(parameter.name || id).trim();
const value = numberValue(parameter.value);
const declaredRange = Array.isArray(parameter.range) ? parameter.range : [];
const min = numberValue(parameter.min ?? parameter.minimum ?? declaredRange[0]);
const max = numberValue(parameter.max ?? parameter.maximum ?? declaredRange[1]);
if (!name || value === null) return null;
return {
id,
name,
displayName: String(parameter.display_name || parameter.displayName || parameter.label || name),
group: String(parameter.group || "dimensions"),
groupDisplayName: String(parameter.group_display_name || parameter.groupDisplayName || "尺寸"),
value,
defaultValue: numberValue(parameter.default_value ?? parameter.defaultValue) ?? value,
min,
max,
step: numberValue(parameter.step) ?? 1,
precision: Math.max(0, numberValue(parameter.precision) ?? 2),
unit: String(parameter.unit || ""),
editable: parameter.editable === true || parameter.edit_state === "declared_unvalidated",
editState: String(parameter.edit_state || "unvalidated"),
};
}
function formatValue(value: number, precision: number) {
if (!Number.isFinite(value)) return "";
if (Number.isInteger(value) && precision === 0) return String(value);
return Number(value.toFixed(precision)).toString();
}
function clampValue(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function visualRange(parameter: NormalizedParameter) {
if (parameter.min !== null && parameter.max !== null && parameter.max > parameter.min) {
return { min: parameter.min, max: parameter.max };
}
const span = Math.max(Math.abs(parameter.value), 1);
return {
min: Math.max(0, parameter.value - span),
max: parameter.value + span,
};
}
function sectionDisplayName(group: { id: string; displayName: string }) {
return group.id === "dimensions" || group.displayName === "尺寸"
? "Dimensions"
: group.displayName;
}
export function ParameterPanel({
parameters,
downloads = [],
pendingParameter,
error,
onClose,
onCommit,
onReset,
}: {
parameters: EditableParameter[];
downloads?: DownloadFormat[];
pendingParameter?: string;
error?: string;
onClose: () => void;
onCommit: (parameter: string, value: number) => void;
onReset: (values: Record<string, number>) => void;
}) {
const normalizedParameters = useMemo(
() => parameters.map(parameterToView).filter((value): value is NormalizedParameter => Boolean(value)),
[parameters],
);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
const [selectedDownload, setSelectedDownload] = useState(downloads[0]?.label || "");
useEffect(() => {
setDrafts(Object.fromEntries(
normalizedParameters.map((parameter) => [
parameter.name,
formatValue(parameter.value, parameter.precision),
]),
));
setOpenGroups((current) => {
const next = { ...current };
for (const parameter of normalizedParameters) {
if (next[parameter.group] === undefined) next[parameter.group] = true;
}
return next;
});
}, [normalizedParameters]);
useEffect(() => {
if (!downloads.some((download) => download.label === selectedDownload)) {
setSelectedDownload(downloads[0]?.label || "");
}
}, [downloads, selectedDownload]);
const grouped = useMemo(() => {
const groups = new Map<string, { id: string; displayName: string; parameters: NormalizedParameter[] }>();
for (const parameter of normalizedParameters) {
const group = groups.get(parameter.group) || {
id: parameter.group,
displayName: parameter.groupDisplayName,
parameters: [],
};
group.parameters.push(parameter);
groups.set(parameter.group, group);
}
return Array.from(groups.values());
}, [normalizedParameters]);
const resetParameters = () => {
const values = Object.fromEntries(
normalizedParameters
.filter((parameter) => parameter.editable && parameter.value !== parameter.defaultValue)
.map((parameter) => [parameter.id, parameter.defaultValue]),
);
if (Object.keys(values).length) {
onReset(values);
return;
}
setDrafts(Object.fromEntries(
normalizedParameters.map((parameter) => [
parameter.name,
formatValue(parameter.defaultValue, parameter.precision),
]),
));
};
const selectedDownloadItem = downloads.find((download) => download.label === selectedDownload) || downloads[0] || null;
const commitValue = (parameter: NormalizedParameter, rawValue: string | number) => {
const numeric = numberValue(rawValue);
if (!parameter.editable) {
setDrafts((current) => ({
...current,
[parameter.name]: formatValue(parameter.value, parameter.precision),
}));
return;
}
if (numeric === null) {
setDrafts((current) => ({
...current,
[parameter.name]: formatValue(parameter.value, parameter.precision),
}));
return;
}
const clamped = parameter.min !== null && parameter.max !== null
? clampValue(numeric, parameter.min, parameter.max)
: numeric;
const formatted = formatValue(clamped, parameter.precision);
setDrafts((current) => ({ ...current, [parameter.name]: formatted }));
if (Number(formatted) !== parameter.value) {
onCommit(parameter.id, Number(formatted));
}
};
return (
<aside
className="flex h-full min-h-0 w-full flex-col overflow-hidden border-l border-[var(--ui-border)] bg-[var(--ui-panel)] text-[var(--ui-text-strong)] shadow-[var(--ui-shadow-panel)]"
data-viewer-interaction-overlay="true"
role="dialog"
aria-label="可编辑参数"
onPointerDown={(event) => event.stopPropagation()}
>
<div className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--ui-border-strong)] bg-[var(--ui-panel)] px-6">
<div className="flex items-center gap-2">
<div className="text-lg font-semibold tracking-tight text-[var(--ui-text-strong)]">Parameters</div>
</div>
<div className="flex items-center gap-1">
<Button
aria-label="恢复全部默认参数"
size="icon-sm"
variant="ghost"
className="size-8 rounded-full text-[var(--ui-text-strong)] hover:bg-[var(--ui-control-hover)]"
disabled={Boolean(pendingParameter) || !normalizedParameters.length}
onClick={resetParameters}
>
<RefreshCcw className="size-4" />
</Button>
<Button
aria-label="隐藏参数面板"
size="icon-sm"
variant="ghost"
className="size-8 rounded-full text-[var(--ui-text-strong)] hover:bg-[var(--ui-control-hover)]"
onClick={onClose}
>
<X className="size-4" />
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto px-6 py-6 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{error ? (
<div className="mb-3 rounded border border-[var(--ui-error-border)] bg-[var(--ui-error-bg)] px-3 py-2 text-[11px] leading-4 text-[var(--ui-error-text)]">
{error}
</div>
) : null}
{normalizedParameters.length ? (
<div className="flex flex-col gap-3">
{grouped.map((group) => (
<Collapsible.Root
key={group.id}
open={openGroups[group.id] ?? true}
onOpenChange={(open) => setOpenGroups((current) => ({ ...current, [group.id]: open }))}
>
<Collapsible.Trigger className="group flex w-full items-center justify-between gap-2 rounded-md py-1 text-left text-xs font-semibold text-[var(--ui-text-strong)] transition-colors focus:outline-none">
<span className="flex items-center gap-2">
{sectionDisplayName(group)}
<span className="text-[10px] text-[var(--ui-text-subtle)]">{group.parameters.length}</span>
</span>
<ChevronDown
className={cn(
"size-3.5 text-[var(--ui-text-subtle)] transition-all duration-200 group-hover:text-[var(--ui-text-strong)]",
openGroups[group.id] !== false && "rotate-180",
)}
/>
</Collapsible.Trigger>
<Collapsible.Content className="mt-3 flex flex-col gap-3">
{group.parameters.map((parameter) => {
const draft = drafts[parameter.name] ?? formatValue(parameter.value, parameter.precision);
const numericDraft = numberValue(draft) ?? parameter.value;
const disabled = Boolean(pendingParameter) || !parameter.editable;
const range = visualRange(parameter);
const pending = pendingParameter === parameter.id;
return (
<div
className="grid w-full grid-cols-[80px_minmax(0,1fr)] items-center gap-3"
key={parameter.name}
>
<label
className="min-w-0 overflow-hidden text-ellipsis text-xs font-normal leading-4 text-[var(--ui-text-muted)]"
htmlFor={`parameter-${parameter.name}`}
title={parameter.displayName}
>
<span className="block truncate">{parameter.displayName}</span>
</label>
<div className="flex w-full min-w-0 items-center gap-3">
<CadamSlider
id={`${parameter.name}-slider`}
name={parameter.name}
min={range.min}
max={range.max}
step={parameter.step}
value={[clampValue(numericDraft, range.min, range.max)]}
defaultValue={[clampValue(parameter.defaultValue, range.min, range.max)]}
disabled={disabled}
visualOnly={false}
defaultMarkerStyle="line"
onValueChange={([nextValue]) => {
setDrafts((current) => ({
...current,
[parameter.name]: formatValue(nextValue, parameter.precision),
}));
}}
onValueCommit={([nextValue]) => commitValue(parameter, nextValue)}
/>
<div className="flex shrink-0 items-center gap-2">
<div className="relative">
<Input
id={`parameter-${parameter.name}`}
autoComplete="off"
className="h-6 w-14 rounded-lg border-0 bg-[var(--ui-control-bg)] px-2 pr-2 text-left text-xs text-[var(--ui-text-strong)] transition-colors selection:bg-[var(--ui-selection-bg)] selection:text-[var(--ui-text-inverse)] focus-visible:ring-0 hover:bg-[var(--ui-control-hover)]"
disabled={disabled}
inputMode="decimal"
max={range.max}
min={range.min}
step={parameter.step}
type="number"
value={draft}
onBlur={() => commitValue(parameter, draft)}
onChange={(event) => setDrafts((current) => ({
...current,
[parameter.name]: event.target.value,
}))}
onFocus={(event) => event.target.select()}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
if (event.key === "Escape") {
setDrafts((current) => ({
...current,
[parameter.name]: formatValue(parameter.value, parameter.precision),
}));
}
}}
/>
{pending ? (
<Loader2 className="absolute right-1.5 top-1.5 size-3 animate-spin text-[var(--ui-accent)]" />
) : null}
</div>
<span className="ml-1 w-6 text-left text-xs text-[var(--ui-text-muted)]">
{parameter.unit}
</span>
</div>
</div>
</div>
);
})}
</Collapsible.Content>
</Collapsible.Root>
))}
</div>
) : (
<div className="rounded border border-[var(--ui-border-strong)] bg-[var(--ui-panel-raised)] px-3 py-3 text-xs leading-5 text-[var(--ui-text-muted)]">
当前模型没有可直接编辑的参数。
</div>
)}
</div>
<div className="flex shrink-0 flex-col gap-4 border-t border-[var(--ui-border-strong)] px-6 py-6">
<div className="flex">
<a
aria-disabled={!selectedDownloadItem}
className={cn(
"inline-flex h-12 flex-1 items-center justify-center rounded-l-lg rounded-r-none bg-[var(--ui-accent)] text-sm font-semibold text-[var(--ui-accent-contrast)] transition-colors hover:bg-[var(--ui-accent-hover)]",
!selectedDownloadItem && "pointer-events-none opacity-50",
)}
href={selectedDownloadItem?.url || "#"}
download
>
<Download className="mr-2 size-4" />
{selectedDownloadItem?.label || "STEP"}
</a>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Button
aria-label="选择下载格式"
className="h-12 w-12 rounded-l-none rounded-r-lg border-l border-[var(--ui-border-muted)] bg-[var(--ui-accent)] p-0 text-[var(--ui-accent-contrast)] hover:bg-[var(--ui-accent-hover)]"
disabled={!downloads.length}
>
<ChevronUp className="size-4" />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content
align="end"
className="z-50 w-64 rounded-md border border-[var(--ui-border)] bg-[var(--ui-popover)] p-1 shadow-[var(--ui-shadow-popover)]"
>
{downloads.map((download) => (
<DropdownMenu.Item
key={download.label}
className="flex cursor-pointer items-center rounded px-3 py-2 text-[var(--ui-text-strong)] outline-none hover:bg-[var(--ui-control-hover)]"
onSelect={() => setSelectedDownload(download.label)}
>
<span className="text-sm">.{download.label}</span>
<span className="ml-3 text-xs text-[var(--ui-text-muted)]">{download.description}</span>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</div>
</aside>
);
}