"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; 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; return { ...base, materials: { ...(base.materials as Record), defaultColor: "#d7dce2", roughness: 0.36, metalness: 0.18 }, edges: { ...(base.edges as Record), enabled: true, color: "#768493", opacity: 0.22, thickness: 1 }, floor: { ...(base.floor as Record), enabled: false, mode: "none" }, environment: { ...(base.environment as Record), enabled: false }, }; })(); function resultFromBackend(payload: Record): 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"), }; } function finiteClientPoint(pick: Record | 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, 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 ( ); } function EditToolPickOverlay({ activeToolId, picks, hoverPick, parameters, }: { activeToolId: string; picks: Record[]; hoverPick: Record | null; parameters: Record; }) { 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 ( ); } export function CadViewerPreview({ result, isGenerating, lastError, theme, onResult, onError, onSelectionChange }: Props) { const viewerRef = useRef<{ captureScreenshot?: (options?: unknown) => Promise; 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({ kind: "empty" }); const [activeTool, setActiveTool] = useState(""); const [editPicks, setEditPicks] = useState[]>([]); const [editParameters, setEditParameters] = useState>({}); const [editHoverPick, setEditHoverPick] = useState | null>(null); const [editSelectionReady, setEditSelectionReady] = useState(false); const [aiSelectionDraft, setAiSelectionDraft] = useState(null); const [hoveredReferenceId, setHoveredReferenceId] = useState(""); const [selectedReferenceIds, setSelectedReferenceIds] = useState([]); const [selectionMode, setSelectionMode] = useState("point"); const [editPending, setEditPending] = useState(false); const [reveal, setReveal] = useState(0); const [showParameters, setShowParameters] = useState(false); const [parameters, setParameters] = useState[]>([]); 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[] }; 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[]) => { 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 & { 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 | 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 | 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) => { 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 & { 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 } text="3D 预览等待模型" />; if (loadState.kind === "loading") return } text="加载 CAD Viewer 资产..." />; if (loadState.kind === "error") return } text={lastError || "CAD Viewer 资产加载失败"} error />; if (loadState.kind !== "ready") return null; return (
{ 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); }} /> viewerRef.current?.zoomToFit?.()} onScreenshot={() => void viewerRef.current?.captureScreenshot?.({ filename: "cdsl-cad.png" })} onParameters={() => setShowParameters(true)} /> {editPending || isGenerating ?
{editPending ? "正在应用 CDSL 编辑..." : "正在生成 CDSL 模型..."}
: null} {activeToolDefinition ? (
{activeToolDefinition.label}
{activeToolDefinition.parameterFields.map((field) => ( ))}
{editPicks.length}/{activeToolDefinition.pickKinds.length} 个几何点已选择
) : null} {reveal > 0 ? : null} {showParameters && result ? (
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) }, ]} />
) : null}
); } function ViewerState({ icon, text, error = false }: { icon: React.ReactNode; text: string; error?: boolean }) { return
{icon}{text}
; }