562 lines
28 KiB
TypeScript
562 lines
28 KiB
TypeScript
"use client";
|
|
|
|
import { AlertTriangle, Box, Loader2 } from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import CadViewer from "@/viewer-port/components/CadViewer";
|
|
import { RENDER_FORMAT } from "@/viewer-port/workbench/constants";
|
|
import { loadRenderGlb, loadRenderJson } from "@/viewer-runtime/lib/renderAssetClient";
|
|
import { cloneThemePresetSettings } from "@/viewer-runtime/lib/themeSettings";
|
|
import { encodeArtifactUrl } from "@/lib/cad-artifacts";
|
|
import { buildCdslSelectorRuntime } from "@/lib/cdsl-selector-runtime";
|
|
import { cadEditToolForOperation, cadEditToolNextPickKind, cadEditToolPickComplete, defaultCadEditParameters, type AiSelectionMode } from "@/lib/cad-edit-tools";
|
|
import type { CadResult } from "@/lib/cad-types";
|
|
import { buildViewerSelectionContext, type ViewerSelectionContext } from "@/lib/viewer-selection";
|
|
import { EmbeddedCadEditToolbar, EmbeddedCadViewToolbar } from "./embedded-cad-toolbar";
|
|
import { GenerationEdgeGlow } from "./generation-edge-glow";
|
|
import { ParameterPanel } from "./parameter-panel";
|
|
|
|
type Props = {
|
|
result: CadResult | null;
|
|
isGenerating: boolean;
|
|
lastError?: string;
|
|
theme: "light" | "dark";
|
|
onResult: (result: CadResult) => void;
|
|
onError: (message: string) => void;
|
|
onSelectionChange: (selection: ViewerSelectionContext | null) => void;
|
|
};
|
|
|
|
type SelectorRuntime = ReturnType<typeof buildCdslSelectorRuntime>;
|
|
type LoadState = { kind: "empty" | "loading" | "error" } | { kind: "ready"; meshData: unknown; selectorRuntime: SelectorRuntime | null };
|
|
type AiSelectionDraft = {
|
|
active?: boolean;
|
|
points?: Array<{ x: number; y: number }>;
|
|
} | null;
|
|
|
|
const VIEWER_THEME = (() => {
|
|
const base = cloneThemePresetSettings("workbench") as Record<string, unknown>;
|
|
return {
|
|
...base,
|
|
materials: { ...(base.materials as Record<string, unknown>), defaultColor: "#d7dce2", roughness: 0.36, metalness: 0.18 },
|
|
edges: { ...(base.edges as Record<string, unknown>), enabled: true, color: "#768493", opacity: 0.22, thickness: 1 },
|
|
floor: { ...(base.floor as Record<string, unknown>), enabled: false, mode: "none" },
|
|
environment: { ...(base.environment as Record<string, unknown>), enabled: false },
|
|
};
|
|
})();
|
|
|
|
function resultFromBackend(payload: Record<string, unknown>): CadResult {
|
|
return {
|
|
taskId: String(payload.task_id), revisionId: String(payload.revision_id),
|
|
cdslPath: String(payload.cdsl_path), stepPath: String(payload.step_path),
|
|
glbPath: String(payload.glb_path), reportPath: String(payload.report_path),
|
|
parametersPath: typeof payload.parameters_path === "string" ? payload.parameters_path : undefined,
|
|
selectorPath: typeof payload.selector_path === "string" ? payload.selector_path : undefined,
|
|
edgesPath: typeof payload.edges_path === "string" ? payload.edges_path : undefined,
|
|
summary: String(payload.summary || "Updated CDSL model"),
|
|
referenceIds: Array.isArray(payload.reference_ids) ? payload.reference_ids.map(String) : [],
|
|
engine: String(payload.engine || "cdsl_only"),
|
|
qualityStatus: String(payload.quality_status || ""),
|
|
qualityPath: typeof payload.quality_path === "string" ? payload.quality_path : undefined,
|
|
assumptions: Array.isArray(payload.generation_assumptions) ? payload.generation_assumptions.map(String) : [],
|
|
warnings: Array.isArray(payload.warnings) ? payload.warnings.map(String) : [],
|
|
repairAttempts: Number(payload.repair_attempts || 0),
|
|
snapshotPaths: Array.isArray(payload.snapshot_paths) ? payload.snapshot_paths.map(String) : [],
|
|
snapshotStatus: String(payload.snapshot_status || "unavailable"),
|
|
};
|
|
}
|
|
|
|
function finiteClientPoint(pick: Record<string, unknown> | null) {
|
|
const x = Number(pick?.clientX);
|
|
const y = Number(pick?.clientY);
|
|
return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null;
|
|
}
|
|
|
|
function editToolPickMarkerLabel(activeToolId: string, index: number) {
|
|
if (activeToolId === "add_slot") return index === 0 ? "起点" : "终点";
|
|
if (activeToolId === "add_hole_pattern") return "中心";
|
|
return "位置";
|
|
}
|
|
|
|
function editParameter(parameters: Record<string, string | number>, name: string, fallback: number) {
|
|
const value = Number(parameters[name]);
|
|
return Number.isFinite(value) ? value : fallback;
|
|
}
|
|
|
|
function previewPixels(valueMm: number, scale = 5, min = 12, max = 180) {
|
|
const value = Math.abs(valueMm);
|
|
return Number.isFinite(value) && value > 0 ? Math.min(Math.max(value * scale, min), max) : min;
|
|
}
|
|
|
|
function AiSelectionOverlay({ draft }: { draft: AiSelectionDraft }) {
|
|
const points = draft?.active && Array.isArray(draft.points) ? draft.points : [];
|
|
const polylinePoints = points.map((point) => `${Number(point.x)},${Number(point.y)}`).join(" ");
|
|
if (!polylinePoints) return null;
|
|
return (
|
|
<div className="pointer-events-none fixed inset-0 z-[26]" aria-hidden="true">
|
|
<svg className="fixed inset-0 size-full overflow-visible">
|
|
<polyline
|
|
points={polylinePoints}
|
|
fill="none"
|
|
className="stroke-[var(--ui-accent)] opacity-[0.85]"
|
|
strokeWidth="2.25"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EditToolPickOverlay({
|
|
activeToolId,
|
|
picks,
|
|
hoverPick,
|
|
parameters,
|
|
}: {
|
|
activeToolId: string;
|
|
picks: Record<string, unknown>[];
|
|
hoverPick: Record<string, unknown> | null;
|
|
parameters: Record<string, string | number>;
|
|
}) {
|
|
if (!activeToolId) return null;
|
|
const selected = picks.flatMap((pick, index) => {
|
|
const point = finiteClientPoint(pick);
|
|
return point ? [{ pick, index, point, hover: false }] : [];
|
|
});
|
|
const hoverPoint = finiteClientPoint(hoverPick);
|
|
const hover = hoverPoint && !selected.some((entry) => Math.hypot(entry.point.x - hoverPoint.x, entry.point.y - hoverPoint.y) < 1)
|
|
? { pick: hoverPick || {}, index: selected.length, point: hoverPoint, hover: true }
|
|
: null;
|
|
const points = hover ? [...selected, hover] : selected;
|
|
if (!points.length) return null;
|
|
|
|
const slot = activeToolId === "add_slot" && points.length >= 2
|
|
? { start: points[0].point, end: points[1].point, preview: points[1].hover }
|
|
: null;
|
|
const anchor = activeToolId === "add_slot" ? null : (selected.at(-1)?.point || hover?.point || null);
|
|
const tool = activeToolId === "add_counterbore" ? "double" : activeToolId;
|
|
const circle = anchor && ["add_hole", "double", "add_countersink", "add_circular_pocket"].includes(tool)
|
|
? {
|
|
point: anchor,
|
|
inner: previewPixels(editParameter(parameters, tool === "add_circular_pocket" ? "diameter" : "holeDiameter", tool === "add_circular_pocket" ? 8 : 3)),
|
|
outer: tool === "double"
|
|
? previewPixels(editParameter(parameters, "counterboreDiameter", 6))
|
|
: tool === "add_countersink"
|
|
? previewPixels(editParameter(parameters, "countersinkDiameter", 6))
|
|
: 0,
|
|
dashed: tool === "add_countersink",
|
|
}
|
|
: null;
|
|
const pocket = anchor && activeToolId === "add_pocket"
|
|
? { point: anchor, width: previewPixels(editParameter(parameters, "width", 10), 4, 24, 240), height: previewPixels(editParameter(parameters, "height", 6), 4, 18, 180) }
|
|
: null;
|
|
const pattern = anchor && activeToolId === "add_hole_pattern"
|
|
? {
|
|
point: anchor,
|
|
rows: Math.min(Math.max(Math.round(editParameter(parameters, "rows", 2)), 1), 8),
|
|
columns: Math.min(Math.max(Math.round(editParameter(parameters, "columns", 2)), 1), 8),
|
|
pitchX: previewPixels(editParameter(parameters, "pitchX", 8), 3, 18, 90),
|
|
pitchY: previewPixels(editParameter(parameters, "pitchY", 8), 3, 18, 90),
|
|
diameter: previewPixels(editParameter(parameters, "holeDiameter", 2), 4, 8, 48),
|
|
}
|
|
: null;
|
|
const slotWidth = slot ? previewPixels(editParameter(parameters, "slotWidth", 2), 5, 12, 80) : 0;
|
|
|
|
return (
|
|
<div className="pointer-events-none fixed inset-0 z-[25]" aria-hidden="true">
|
|
{(slot || circle || pocket || pattern) ? (
|
|
<svg className="fixed inset-0 size-full overflow-visible">
|
|
{slot ? <>
|
|
<line x1={slot.start.x} y1={slot.start.y} x2={slot.end.x} y2={slot.end.y} className="stroke-[var(--ui-accent-soft)]" strokeWidth={slotWidth} strokeLinecap="round" />
|
|
<line x1={slot.start.x} y1={slot.start.y} x2={slot.end.x} y2={slot.end.y} className={slot.preview ? "stroke-[var(--ui-accent)] opacity-65" : "stroke-[var(--ui-accent)] opacity-90"} strokeWidth="2.5" strokeLinecap="round" strokeDasharray={slot.preview ? "7 5" : "none"} />
|
|
</> : null}
|
|
{circle ? <>
|
|
{circle.outer ? <circle cx={circle.point.x} cy={circle.point.y} r={circle.outer / 2} className="fill-[var(--ui-accent-soft)] stroke-[var(--ui-accent)]" strokeWidth="2" strokeDasharray={circle.dashed ? "7 4" : "none"} /> : null}
|
|
<circle cx={circle.point.x} cy={circle.point.y} r={circle.inner / 2} className="fill-[var(--ui-accent-soft)] stroke-[var(--ui-accent)]" strokeWidth="2" strokeDasharray={circle.outer ? "4 4" : "5 4"} />
|
|
</> : null}
|
|
{pocket ? <rect x={pocket.point.x - pocket.width / 2} y={pocket.point.y - pocket.height / 2} width={pocket.width} height={pocket.height} rx="6" className="fill-[var(--ui-accent-soft)] stroke-[var(--ui-accent)]" strokeWidth="2" strokeDasharray="6 4" /> : null}
|
|
{pattern ? Array.from({ length: pattern.rows * pattern.columns }, (_, index) => {
|
|
const row = Math.floor(index / pattern.columns);
|
|
const column = index % pattern.columns;
|
|
return <circle key={index} cx={pattern.point.x + (column - (pattern.columns - 1) / 2) * pattern.pitchX} cy={pattern.point.y + (row - (pattern.rows - 1) / 2) * pattern.pitchY} r={pattern.diameter / 2} className="fill-[var(--ui-accent-soft)] stroke-[var(--ui-accent)]" strokeWidth="1.5" strokeDasharray="4 3" />;
|
|
}) : null}
|
|
</svg>
|
|
) : null}
|
|
{points.map(({ index, point, hover: isHover }) => (
|
|
<div key={`${index}:${point.x}:${point.y}`} className="fixed" style={{ left: `${point.x}px`, top: `${point.y}px`, transform: "translate(-50%, -50%)" }}>
|
|
<span className={`absolute left-1/2 top-1/2 h-12 w-px -translate-x-1/2 -translate-y-1/2 bg-[var(--ui-accent-muted)] ${isHover ? "opacity-60" : "opacity-100"}`} />
|
|
<span className={`absolute left-1/2 top-1/2 h-px w-12 -translate-x-1/2 -translate-y-1/2 bg-[var(--ui-accent-muted)] ${isHover ? "opacity-60" : "opacity-100"}`} />
|
|
<span className={`absolute left-1/2 top-1/2 size-7 -translate-x-1/2 -translate-y-1/2 rounded-full border bg-[var(--ui-accent-soft)] shadow-[0_0_22px_var(--ui-accent-muted)] ${isHover ? "border-[var(--ui-accent-border)] border-dashed opacity-75" : "border-[var(--ui-accent-border)]"}`} />
|
|
<span className={`relative block rounded-full border border-[var(--ui-text-inverse)] bg-[var(--ui-accent)] shadow-[var(--ui-shadow-soft)] ${isHover ? "size-2 opacity-70" : "size-3"}`} />
|
|
<span className="absolute left-4 top-3 whitespace-nowrap rounded-full border border-[var(--ui-accent-border)] bg-[var(--ui-glass-popover)] px-2 py-0.5 text-[10px] font-semibold text-[var(--ui-text-strong)] shadow-[var(--ui-shadow-soft)]">{isHover ? "预选" : editToolPickMarkerLabel(activeToolId, index)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function CadViewerPreview({ result, isGenerating, lastError, theme, onResult, onError, onSelectionChange }: Props) {
|
|
const viewerRef = useRef<{ captureScreenshot?: (options?: unknown) => Promise<void>; zoomToFit?: () => void } | null>(null);
|
|
// A task restored from the URL should appear quietly after a page refresh.
|
|
// Subsequent revisions in this mounted workspace still receive completion feedback.
|
|
const suppressInitialReveal = useRef(Boolean(result));
|
|
const [loadState, setLoadState] = useState<LoadState>({ kind: "empty" });
|
|
const [activeTool, setActiveTool] = useState("");
|
|
const [editPicks, setEditPicks] = useState<Record<string, unknown>[]>([]);
|
|
const [editParameters, setEditParameters] = useState<Record<string, string | number>>({});
|
|
const [editHoverPick, setEditHoverPick] = useState<Record<string, unknown> | null>(null);
|
|
const [editSelectionReady, setEditSelectionReady] = useState(false);
|
|
const [aiSelectionDraft, setAiSelectionDraft] = useState<AiSelectionDraft>(null);
|
|
const [hoveredReferenceId, setHoveredReferenceId] = useState("");
|
|
const [selectedReferenceIds, setSelectedReferenceIds] = useState<string[]>([]);
|
|
const [selectionMode, setSelectionMode] = useState<AiSelectionMode>("point");
|
|
const [editPending, setEditPending] = useState(false);
|
|
const [reveal, setReveal] = useState(0);
|
|
const [showParameters, setShowParameters] = useState(false);
|
|
const [parameters, setParameters] = useState<Record<string, unknown>[]>([]);
|
|
const [parameterPending, setParameterPending] = useState("");
|
|
const [parameterError, setParameterError] = useState("");
|
|
|
|
useEffect(() => {
|
|
if (!result) {
|
|
setLoadState((current) => current.kind === "ready" ? current : { kind: "empty" });
|
|
return;
|
|
}
|
|
const controller = new AbortController();
|
|
setLoadState((current) => current.kind === "ready" ? current : { kind: "loading" });
|
|
const glbUrl = encodeArtifactUrl(result.taskId, result.glbPath);
|
|
const selectorUrl = result.selectorPath ? encodeArtifactUrl(result.taskId, result.selectorPath) : "";
|
|
void Promise.all([
|
|
loadRenderGlb(glbUrl),
|
|
selectorUrl ? loadRenderJson(selectorUrl).catch(() => null) : Promise.resolve(null),
|
|
])
|
|
.then(([meshData, selectorSidecar]) => {
|
|
if (controller.signal.aborted) return;
|
|
const selectorRuntime = selectorSidecar && typeof selectorSidecar === "object"
|
|
? buildCdslSelectorRuntime(selectorSidecar, meshData)
|
|
: null;
|
|
setLoadState({ kind: "ready", meshData, selectorRuntime });
|
|
setHoveredReferenceId("");
|
|
setSelectedReferenceIds([]);
|
|
setEditPicks([]);
|
|
setEditHoverPick(null);
|
|
setEditSelectionReady(false);
|
|
setAiSelectionDraft(null);
|
|
onSelectionChange(null);
|
|
if (suppressInitialReveal.current) {
|
|
suppressInitialReveal.current = false;
|
|
} else {
|
|
setReveal((value) => value + 1);
|
|
}
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (controller.signal.aborted) return;
|
|
setLoadState((current) => current.kind === "ready" ? current : { kind: "error" });
|
|
onError(error instanceof Error ? error.message : "CAD Viewer asset loading failed");
|
|
});
|
|
return () => controller.abort();
|
|
}, [onError, onSelectionChange, result?.glbPath, result?.revisionId, result?.selectorPath, result?.taskId]);
|
|
|
|
useEffect(() => {
|
|
if (!reveal) return;
|
|
const timer = window.setTimeout(() => setReveal(0), 3600);
|
|
return () => window.clearTimeout(timer);
|
|
}, [reveal]);
|
|
|
|
useEffect(() => {
|
|
if (!result) {
|
|
setParameters([]);
|
|
setShowParameters(false);
|
|
return;
|
|
}
|
|
const controller = new AbortController();
|
|
setParameterError("");
|
|
void fetch(`/api/tasks/${encodeURIComponent(result.taskId)}/parameters`, { signal: controller.signal })
|
|
.then(async (response) => {
|
|
if (response.status === 404) return [];
|
|
if (!response.ok) throw new Error(await response.text());
|
|
const payload = await response.json() as { parameters?: Record<string, unknown>[] };
|
|
return Array.isArray(payload.parameters) ? payload.parameters : [];
|
|
})
|
|
.then((next) => { if (!controller.signal.aborted) setParameters(next); })
|
|
.catch((error: unknown) => {
|
|
if (!controller.signal.aborted) setParameterError(error instanceof Error ? error.message : "无法读取参数");
|
|
});
|
|
return () => controller.abort();
|
|
}, [result?.revisionId, result?.taskId]);
|
|
|
|
const submitEdit = useCallback(async (operation: string, picks: Record<string, unknown>[]) => {
|
|
if (!result || !operation || !picks.length) return;
|
|
setEditPending(true);
|
|
try {
|
|
const response = await fetch(`/api/tasks/${encodeURIComponent(result.taskId)}/modify`, {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ operation, selection: { pick: picks[0], picks }, parameters: editParameters }),
|
|
});
|
|
const payload = await response.json() as Record<string, unknown> & { error?: string };
|
|
if (!response.ok) throw new Error(payload.error || "Direct CAD edit failed");
|
|
onResult(resultFromBackend(payload));
|
|
setActiveTool("");
|
|
setEditPicks([]);
|
|
setEditHoverPick(null);
|
|
setEditSelectionReady(false);
|
|
} catch (error) {
|
|
onError(error instanceof Error ? error.message : "Direct CAD edit failed");
|
|
} finally {
|
|
setEditPending(false);
|
|
}
|
|
}, [editParameters, onError, onResult, result]);
|
|
|
|
const onEditPick = useCallback((pick: Record<string, unknown> | null) => {
|
|
if (!activeTool || editPending) return;
|
|
if (!pick) {
|
|
onError("未命中可编辑平面,请在模型实体表面点击。");
|
|
return;
|
|
}
|
|
setEditPicks((current) => {
|
|
if (cadEditToolPickComplete(activeTool, current)) return current;
|
|
const next = [...current, pick];
|
|
const referenceId = typeof pick.referenceId === "string" ? pick.referenceId : "";
|
|
if (referenceId) setSelectedReferenceIds([referenceId]);
|
|
setEditHoverPick(null);
|
|
setEditSelectionReady(cadEditToolPickComplete(activeTool, next));
|
|
return next;
|
|
});
|
|
}, [activeTool, editPending, onError]);
|
|
|
|
const handleAiSelectionDraftChange = useCallback((draft: AiSelectionDraft) => {
|
|
setAiSelectionDraft((current) => {
|
|
const currentPoints = current?.points || [];
|
|
const nextPoints = draft?.points || [];
|
|
if (current?.active === draft?.active && currentPoints.length === nextPoints.length && currentPoints.every((point, index) => point.x === nextPoints[index]?.x && point.y === nextPoints[index]?.y)) {
|
|
return current;
|
|
}
|
|
return draft;
|
|
});
|
|
}, []);
|
|
|
|
const cancelEdit = useCallback(() => {
|
|
setActiveTool("");
|
|
setEditPicks([]);
|
|
setEditHoverPick(null);
|
|
setEditSelectionReady(false);
|
|
setSelectedReferenceIds([]);
|
|
setSelectionMode("point");
|
|
}, []);
|
|
|
|
const onAiSelectionComplete = useCallback((selection: Record<string, unknown> | null) => {
|
|
const referenceIds = Array.isArray(selection?.referenceIds)
|
|
? selection.referenceIds.filter((value): value is string => typeof value === "string" && value.length > 0)
|
|
: [];
|
|
setSelectedReferenceIds(referenceIds);
|
|
if (!selection || !referenceIds.length || loadState.kind !== "ready" || !result) {
|
|
onSelectionChange(null);
|
|
return;
|
|
}
|
|
onSelectionChange(buildViewerSelectionContext({
|
|
selection,
|
|
references: loadState.selectorRuntime?.references || [],
|
|
taskId: result.taskId,
|
|
revisionId: result.revisionId,
|
|
}));
|
|
}, [loadState, onSelectionChange, result]);
|
|
|
|
const handleActivateReference = useCallback((referenceId: string) => {
|
|
const normalized = String(referenceId || "").trim();
|
|
setSelectedReferenceIds(normalized ? [normalized] : []);
|
|
if (!normalized || loadState.kind !== "ready" || !result) {
|
|
onSelectionChange(null);
|
|
return;
|
|
}
|
|
onSelectionChange(buildViewerSelectionContext({
|
|
selection: {
|
|
kind: "single_reference_activation",
|
|
selectionMode: "point",
|
|
referenceIds: [normalized],
|
|
},
|
|
references: loadState.selectorRuntime?.references || [],
|
|
taskId: result.taskId,
|
|
revisionId: result.revisionId,
|
|
}));
|
|
}, [loadState, onSelectionChange, result]);
|
|
|
|
const commitParameters = useCallback(async (values: Record<string, number>) => {
|
|
if (!result || !Object.keys(values).length) return;
|
|
const parameterId = Object.keys(values)[0];
|
|
setParameterPending(parameterId);
|
|
setParameterError("");
|
|
try {
|
|
const response = await fetch(`/api/tasks/${encodeURIComponent(result.taskId)}/parameters`, {
|
|
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ values }),
|
|
});
|
|
const payload = await response.json() as Record<string, unknown> & { error?: string };
|
|
if (!response.ok) throw new Error(payload.error || "参数更新失败");
|
|
onResult(resultFromBackend(payload));
|
|
} catch (error) {
|
|
setParameterError(error instanceof Error ? error.message : "参数更新失败");
|
|
} finally {
|
|
setParameterPending("");
|
|
}
|
|
}, [onResult, result]);
|
|
|
|
const viewerTheme = useMemo(() => ({ ...VIEWER_THEME, colorMode: theme }), [theme]);
|
|
const activeToolDefinition = activeTool ? cadEditToolForOperation(activeTool) : null;
|
|
const pickableFaces = useMemo(
|
|
() => loadState.kind === "ready" ? loadState.selectorRuntime?.references.filter((reference) => reference.selectorType === "face") || [] : [],
|
|
[loadState]
|
|
);
|
|
if (loadState.kind === "empty") return <ViewerState icon={<Box size={20} />} text="3D 预览等待模型" />;
|
|
if (loadState.kind === "loading") return <ViewerState icon={<Loader2 className="spin" size={20} />} text="加载 CAD Viewer 资产..." />;
|
|
if (loadState.kind === "error") return <ViewerState icon={<AlertTriangle size={20} />} text={lastError || "CAD Viewer 资产加载失败"} error />;
|
|
if (loadState.kind !== "ready") return null;
|
|
|
|
return (
|
|
<div className={`cad-deepbuild-viewer-bg relative h-full min-h-0 overflow-hidden ${theme === "dark" ? "cad-viewer-dark" : ""}`}>
|
|
<CadViewer
|
|
ref={viewerRef}
|
|
meshData={loadState.meshData}
|
|
modelKey={`${result?.taskId}:${result?.revisionId}`}
|
|
renderFormat={RENDER_FORMAT.STEP}
|
|
showEdges
|
|
recomputeNormals={false}
|
|
themeSettings={viewerTheme}
|
|
theme={theme === "dark" ? { sceneBackground: "#0d0f11" } : { sceneBackground: "#fbfbfc" }}
|
|
showViewPlane
|
|
generationIntroKey={`${result?.taskId}:${reveal}`}
|
|
// The enclosing studio owns completion feedback. Enabling CadViewer's
|
|
// shader reveal here would play a second, separate animation.
|
|
generationIntroEnabled={false}
|
|
hoveredReferenceId={hoveredReferenceId}
|
|
selectedReferenceIds={selectedReferenceIds}
|
|
selectorRuntime={loadState.selectorRuntime}
|
|
pickableFaces={pickableFaces}
|
|
pickableEdges={[]}
|
|
onHoverReferenceChange={setHoveredReferenceId}
|
|
onActivateReference={handleActivateReference}
|
|
editPointPickEnabled={Boolean(activeTool)}
|
|
activeEditToolId={activeTool}
|
|
editToolPickKind={cadEditToolNextPickKind(activeTool, editPicks.length)}
|
|
editToolPicks={editPicks}
|
|
editToolHoverPick={editHoverPick}
|
|
editToolParameters={editParameters}
|
|
onEditToolPick={onEditPick}
|
|
onEditToolHover={setEditHoverPick}
|
|
aiSelectionMode={selectionMode === "none" ? "" : selectionMode}
|
|
onAiSelectionDraftChange={handleAiSelectionDraftChange}
|
|
onAiSelectionComplete={onAiSelectionComplete}
|
|
/>
|
|
<AiSelectionOverlay draft={aiSelectionDraft} />
|
|
<EditToolPickOverlay
|
|
activeToolId={activeTool}
|
|
picks={editPicks}
|
|
hoverPick={editHoverPick}
|
|
parameters={editParameters}
|
|
/>
|
|
<EmbeddedCadEditToolbar
|
|
activeToolId={activeTool}
|
|
aiSelectionMode={selectionMode}
|
|
disabled={!result || editPending}
|
|
unavailableToolIds={["add_chamfer", "add_fillet"]}
|
|
onSelectTool={(tool) => {
|
|
setActiveTool(tool);
|
|
setEditPicks([]);
|
|
setEditHoverPick(null);
|
|
setEditSelectionReady(false);
|
|
setAiSelectionDraft(null);
|
|
setHoveredReferenceId("");
|
|
setSelectedReferenceIds([]);
|
|
setEditParameters(tool ? defaultCadEditParameters(tool) : {});
|
|
setSelectionMode(tool ? "none" : "point");
|
|
}}
|
|
onSelectionModeChange={(mode) => {
|
|
setSelectionMode(mode);
|
|
if (mode !== "lasso") setAiSelectionDraft(null);
|
|
}}
|
|
/>
|
|
<EmbeddedCadViewToolbar
|
|
disabled={!result}
|
|
onResetView={() => viewerRef.current?.zoomToFit?.()}
|
|
onScreenshot={() => void viewerRef.current?.captureScreenshot?.({ filename: "cdsl-cad.png" })}
|
|
onParameters={() => setShowParameters(true)}
|
|
/>
|
|
{editPending || isGenerating ? <div className="viewer-loading"><Loader2 className="spin" size={16} /><span>{editPending ? "正在应用 CDSL 编辑..." : "正在生成 CDSL 模型..."}</span></div> : null}
|
|
{activeToolDefinition ? (
|
|
<div className="absolute bottom-3 left-3 z-30 w-[236px] border border-[var(--ui-border)] bg-[var(--ui-glass-popover)] p-3 text-[var(--ui-text-strong)] shadow-[var(--ui-shadow-soft)] backdrop-blur" data-viewer-interaction-overlay="true">
|
|
<div className="mb-2 text-xs font-semibold">{activeToolDefinition.label}</div>
|
|
<div className="grid gap-2">
|
|
{activeToolDefinition.parameterFields.map((field) => (
|
|
<label className="grid grid-cols-[72px_minmax(0,1fr)] items-center gap-2 text-[11px] text-[var(--ui-text-muted)]" key={field.name}>
|
|
<span>{field.label}</span>
|
|
{field.type === "select" ? (
|
|
<select
|
|
className="h-7 min-w-0 border border-[var(--ui-border)] bg-[var(--ui-control-bg)] px-2 text-[11px] text-[var(--ui-text-strong)]"
|
|
value={String(editParameters[field.name] ?? "")}
|
|
onChange={(event) => setEditParameters((current) => ({ ...current, [field.name]: event.target.value }))}
|
|
>
|
|
{(field.options || []).map((option) => <option key={option} value={option}>{option}</option>)}
|
|
</select>
|
|
) : (
|
|
<div className="flex min-w-0 items-center gap-1">
|
|
<input
|
|
className="h-7 min-w-0 flex-1 border border-[var(--ui-border)] bg-[var(--ui-control-bg)] px-2 text-[11px] text-[var(--ui-text-strong)]"
|
|
type={field.type === "number" ? "number" : "text"}
|
|
min={field.min}
|
|
step={field.step}
|
|
value={String(editParameters[field.name] ?? "")}
|
|
onChange={(event) => setEditParameters((current) => ({
|
|
...current,
|
|
[field.name]: field.type === "number" ? Number(event.target.value) : event.target.value,
|
|
}))}
|
|
/>
|
|
{field.unit ? <span className="w-5 text-[10px]">{field.unit}</span> : null}
|
|
</div>
|
|
)}
|
|
</label>
|
|
))}
|
|
</div>
|
|
<div className="mt-2 text-[10px] text-[var(--ui-text-subtle)]">{editPicks.length}/{activeToolDefinition.pickKinds.length} 个几何点已选择</div>
|
|
<div className="mt-3 flex justify-end gap-2 border-t border-[var(--ui-border)] pt-2">
|
|
<button
|
|
type="button"
|
|
className="h-7 border border-[var(--ui-border-strong)] px-2 text-[11px] text-[var(--ui-text-muted)] hover:bg-[var(--ui-control-hover)]"
|
|
onClick={cancelEdit}
|
|
>
|
|
取消
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={!editSelectionReady || editPending}
|
|
className="h-7 border border-[var(--ui-accent-border)] bg-[var(--ui-accent-soft)] px-2 text-[11px] text-[var(--ui-accent-text)] transition hover:bg-[var(--ui-accent-muted)] disabled:cursor-not-allowed disabled:opacity-45"
|
|
onClick={() => void submitEdit(activeTool, editPicks)}
|
|
>
|
|
应用
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{reveal > 0 ? <GenerationEdgeGlow /> : null}
|
|
{showParameters && result ? (
|
|
<div className="absolute inset-y-0 right-0 z-40 w-full max-w-[360px] shadow-[var(--ui-shadow-panel)]">
|
|
<ParameterPanel
|
|
parameters={parameters}
|
|
pendingParameter={parameterPending}
|
|
error={parameterError}
|
|
onClose={() => setShowParameters(false)}
|
|
onCommit={(id, value) => void commitParameters({ [id]: value })}
|
|
onReset={(values) => void commitParameters(values)}
|
|
downloads={[
|
|
{ label: "STEP", description: "CAD exchange", url: encodeArtifactUrl(result.taskId, result.stepPath) },
|
|
{ label: "CDSL", description: "Editable model", url: encodeArtifactUrl(result.taskId, result.cdslPath) },
|
|
{ label: "GLB", description: "Preview mesh", url: encodeArtifactUrl(result.taskId, result.glbPath) },
|
|
{ label: "REPORT", description: "Rebuild validation", url: encodeArtifactUrl(result.taskId, result.reportPath) },
|
|
]}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ViewerState({ icon, text, error = false }: { icon: React.ReactNode; text: string; error?: boolean }) {
|
|
return <div className={`viewer-state ${error ? "viewer-state-error" : ""}`}>{icon}<span>{text}</span></div>;
|
|
}
|