This commit is contained in:
2026-08-21 11:13:18 +08:00
parent beba8744b4
commit 41ed846c23
2028 changed files with 1327194 additions and 293 deletions
+1
View File
@@ -46,6 +46,7 @@ export async function POST(request: NextRequest) {
selected_task_id: body.selectedTaskId || null,
provider_id: body.providerId || null,
model_id: body.modelId || null,
viewer_context: Array.isArray(body.viewerContext) ? body.viewerContext : [],
messages: messagesForBackend((Array.isArray(body.messages) ? body.messages : []) as CadUIMessage[]),
}),
signal: request.signal,
+1 -206
View File
@@ -374,215 +374,10 @@ button:disabled {
@keyframes generation-edge-soft-out { to { opacity: 0; } }
.generation-success {
position: fixed;
inset: 0;
z-index: 101;
display: grid;
place-items: center;
pointer-events: none;
}
.generation-success__glow {
position: absolute;
left: 50%;
top: 50%;
width: 360px;
height: 220px;
margin: -110px 0 0 -180px;
border-radius: 50%;
background: radial-gradient(ellipse at center, rgba(120, 220, 255, 0.24), transparent 70%);
opacity: 0;
animation: generation-success-glow 2100ms ease-out 1300ms both;
}
@keyframes generation-success-glow {
0% { opacity: 0; transform: scale(0.6); }
28% { opacity: 1; }
72% { opacity: 1; }
100% { opacity: 0; transform: scale(1.05); }
}
.generation-success__card {
position: relative;
display: flex;
align-items: center;
gap: 13px;
max-width: min(420px, calc(100vw - 32px));
padding: 14px 22px 14px 15px;
border: 0.5px solid rgba(255, 255, 255, 0.92);
border-radius: 20px;
background: rgba(255, 255, 255, 0.74);
box-shadow:
0 18px 50px rgba(28, 40, 60, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.75);
-webkit-backdrop-filter: blur(30px) saturate(1.7);
backdrop-filter: blur(30px) saturate(1.7);
opacity: 0;
will-change: transform, opacity;
animation:
generation-success-card-in 640ms cubic-bezier(0.34, 1.56, 0.64, 1) 1520ms both,
generation-success-card-out 460ms ease-in 3.5s forwards;
}
@keyframes generation-success-card-in {
0% { opacity: 0; transform: scale(0.8) translateY(6px); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes generation-success-card-out {
to { opacity: 0; transform: scale(0.97); }
}
.generation-success__badge {
position: relative;
flex: 0 0 auto;
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 50%;
background: radial-gradient(circle at 50% 38%, rgba(70, 230, 175, 0.26), transparent 70%);
}
.generation-success__badge::before {
content: "";
position: absolute;
inset: -3px;
border-radius: 50%;
background: conic-gradient(from 120deg, #34c759, #2fd6c0, #4ab8ff, #34c759);
opacity: 0;
filter: blur(5px);
animation: generation-success-badge-glow 760ms ease-out 1700ms both;
}
@keyframes generation-success-badge-glow {
0% { opacity: 0; transform: scale(0.7); }
60% { opacity: 0.6; }
100% { opacity: 0.34; transform: scale(1); }
}
.generation-success__check {
position: relative;
z-index: 1;
width: 32px;
height: 32px;
}
.generation-success__check circle {
fill: none;
stroke: #34c759;
stroke-width: 3;
stroke-dasharray: 145;
stroke-dashoffset: 145;
animation: generation-success-check-circle 500ms cubic-bezier(0.65, 0, 0.35, 1) 1760ms forwards;
}
.generation-success__check path {
fill: none;
stroke: #34c759;
stroke-width: 4.2;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 36;
stroke-dashoffset: 36;
animation: generation-success-check-path 320ms cubic-bezier(0.65, 0, 0.35, 1) 2080ms forwards;
}
@keyframes generation-success-check-circle { to { stroke-dashoffset: 0; } }
@keyframes generation-success-check-path { to { stroke-dashoffset: 0; } }
.generation-success__copy {
display: grid;
gap: 1px;
min-width: 0;
overflow: hidden;
}
.generation-success__kicker {
color: rgba(118, 138, 165, 0.9);
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
opacity: 0;
animation: generation-success-copy-in 520ms ease-out 1640ms both;
}
.generation-success__copy strong {
position: relative;
color: #18212f;
font-size: 18px;
font-weight: 800;
line-height: 1.18;
letter-spacing: 0;
opacity: 0;
animation: generation-success-copy-in 560ms cubic-bezier(0.2, 1, 0.3, 1) 1740ms both;
}
.generation-success__copy strong::after {
content: attr(data-text);
position: absolute;
left: 0;
top: 0;
color: transparent;
-webkit-text-fill-color: transparent;
background: linear-gradient(100deg, transparent 36%, rgba(74, 168, 255, 0.95) 50%, transparent 64%);
background-size: 260% 100%;
background-position: 130% 0;
-webkit-background-clip: text;
background-clip: text;
animation: generation-success-title-shine 1100ms ease-out 2150ms both;
}
@keyframes generation-success-title-shine { to { background-position: -50% 0; } }
.generation-success__detail {
overflow: hidden;
color: #7b8a9c;
font-size: 12.5px;
font-weight: 600;
line-height: 1.3;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0;
animation: generation-success-copy-in 560ms ease-out 1900ms both;
}
@keyframes generation-success-copy-in {
0% { opacity: 0; transform: translateY(5px); filter: blur(3px); }
100% { opacity: 1; transform: translateY(0); filter: blur(0); }
}
.generation-success__spark {
position: absolute;
width: 5px;
height: 5px;
border-radius: 50%;
background: radial-gradient(circle, #fff 0%, rgba(120, 220, 255, 0.7) 55%, transparent 75%);
opacity: 0;
animation: generation-success-spark 1300ms ease-out calc(2000ms + var(--spark-index) * 230ms) both;
}
.generation-success__spark:nth-of-type(1) { top: -7px; right: 30px; }
.generation-success__spark:nth-of-type(2) { bottom: -5px; left: 46px; }
.generation-success__spark:nth-of-type(3) { top: 12px; right: -7px; }
@keyframes generation-success-spark {
0% { opacity: 0; transform: scale(0); }
42% { opacity: 1; transform: scale(1.25); }
100% { opacity: 0; transform: scale(0.35); }
}
@media (prefers-reduced-motion: reduce) {
.generation-edge-glow,
.generation-edge-glow *,
.generation-edge-glow *::before,
.generation-success,
.generation-success *,
.generation-success *::before,
.generation-success *::after {
.generation-edge-glow *::before {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
}
+16 -1
View File
@@ -8,6 +8,7 @@ import { AlertCircle, Box, Loader2, Moon, Sun } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { latestSuccessfulResult } from "@/lib/cad-artifacts";
import { normalizeCadMessages } from "@/lib/cad-messages";
import type { ViewerSelectionContext } from "@/lib/viewer-selection";
import type {
BackendConfig,
CadError,
@@ -36,6 +37,7 @@ export function AgentStudio() {
const [providerId, setProviderId] = useState("");
const [modelId, setModelId] = useState("");
const [theme, setTheme] = useState<"light" | "dark">("light");
const [viewerSelection, setViewerSelection] = useState<ViewerSelectionContext | null>(null);
const syncUrl = useCallback((conversation: string, task: string) => {
const params = new URLSearchParams(window.location.search);
@@ -121,6 +123,7 @@ export function AgentStudio() {
const handleResult = useCallback((result: CadResult) => {
setCadResult(result);
setViewerSelection(null);
setSelectedTaskId(result.taskId);
setLastError("");
if (conversationId) {
@@ -190,6 +193,7 @@ export function AgentStudio() {
selectedTaskId={selectedTaskId}
providerId={providerId}
modelId={modelId}
viewerSelection={viewerSelection}
initialMessages={initialMessages}
onCadResult={handleResult}
onCadError={handleError}
@@ -209,6 +213,7 @@ export function AgentStudio() {
onModelChange={setModelId}
onCadResult={handleResult}
onCadError={handleError}
onSelectionChange={setViewerSelection}
/>
</AgentRuntime>
);
@@ -219,6 +224,7 @@ function AgentRuntime({
selectedTaskId,
providerId,
modelId,
viewerSelection,
initialMessages,
onCadResult,
onCadError,
@@ -228,6 +234,7 @@ function AgentRuntime({
selectedTaskId: string;
providerId: string;
modelId: string;
viewerSelection: ViewerSelectionContext | null;
initialMessages: CadUIMessage[];
onCadResult: (result: CadResult) => void;
onCadError: (error: CadError) => void;
@@ -237,12 +244,14 @@ function AgentRuntime({
const taskRef = useRef(selectedTaskId);
const providerRef = useRef(providerId);
const modelRef = useRef(modelId);
const viewerSelectionRef = useRef(viewerSelection);
const onCadResultRef = useRef(onCadResult);
const onCadErrorRef = useRef(onCadError);
conversationRef.current = conversationId;
taskRef.current = selectedTaskId;
providerRef.current = providerId;
modelRef.current = modelId;
viewerSelectionRef.current = viewerSelection;
onCadResultRef.current = onCadResult;
onCadErrorRef.current = onCadError;
@@ -257,6 +266,7 @@ function AgentRuntime({
selectedTaskId: taskRef.current || null,
providerId: providerRef.current || null,
modelId: modelRef.current || null,
viewerContext: viewerSelectionRef.current ? [viewerSelectionRef.current] : [],
trigger: options.trigger,
messageId: options.messageId,
},
@@ -424,6 +434,7 @@ function StudioShell({
onModelChange,
onCadResult,
onCadError,
onSelectionChange,
}: {
config: BackendConfig | null;
cadResult: CadResult | null;
@@ -439,9 +450,13 @@ function StudioShell({
onModelChange: (id: string) => void;
onCadResult: (result: CadResult) => void;
onCadError: (error: CadError) => void;
onSelectionChange: (selection: ViewerSelectionContext | null) => void;
}) {
const running = useAuiState((state) => state.thread.isRunning);
const provider = config?.providers.find((item) => item.id === providerId);
const handleViewerError = useCallback((message: string) => {
onCadError({ stage: "viewer", message });
}, [onCadError]);
return (
<main className="studio-app">
<header className="app-header">
@@ -459,7 +474,7 @@ function StudioShell({
{!config?.configured ? <div className="config-warning"><AlertCircle size={16} /><span></span></div> : null}
<div className="studio-main">
<aside className="agent-pane"><AgentThread attachments={attachments} uploading={uploading} onUpload={onUpload} /></aside>
<section className="preview-pane"><CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onResult={onCadResult} onError={(message) => onCadError({ stage: "viewer", message })} /></section>
<section className="preview-pane"><CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onResult={onCadResult} onError={handleViewerError} onSelectionChange={onSelectionChange} /></section>
</div>
</main>
);
+37 -7
View File
@@ -10,9 +10,9 @@ 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 { GenerationSuccessReveal } from "./generation-success-reveal";
import { ParameterPanel } from "./parameter-panel";
type Props = {
@@ -22,6 +22,7 @@ type Props = {
theme: "light" | "dark";
onResult: (result: CadResult) => void;
onError: (message: string) => void;
onSelectionChange: (selection: ViewerSelectionContext | null) => void;
};
type SelectorRuntime = ReturnType<typeof buildCdslSelectorRuntime>;
@@ -186,7 +187,7 @@ function EditToolPickOverlay({
);
}
export function CadViewerPreview({ result, isGenerating, lastError, theme, onResult, onError }: Props) {
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.
@@ -233,6 +234,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
setEditHoverPick(null);
setEditSelectionReady(false);
setAiSelectionDraft(null);
onSelectionChange(null);
if (suppressInitialReveal.current) {
suppressInitialReveal.current = false;
} else {
@@ -245,7 +247,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
onError(error instanceof Error ? error.message : "CAD Viewer asset loading failed");
});
return () => controller.abort();
}, [onError, result?.glbPath, result?.revisionId, result?.selectorPath, result?.taskId]);
}, [onError, onSelectionChange, result?.glbPath, result?.revisionId, result?.selectorPath, result?.taskId]);
useEffect(() => {
if (!reveal) return;
@@ -334,12 +336,41 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
setSelectionMode("point");
}, []);
const onAiSelectionComplete = useCallback((selection: { referenceIds?: unknown } | null) => {
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;
@@ -393,7 +424,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
pickableFaces={pickableFaces}
pickableEdges={[]}
onHoverReferenceChange={setHoveredReferenceId}
onActivateReference={(referenceId: string) => setSelectedReferenceIds(referenceId ? [referenceId] : [])}
onActivateReference={handleActivateReference}
editPointPickEnabled={Boolean(activeTool)}
activeEditToolId={activeTool}
editToolPickKind={cadEditToolNextPickKind(activeTool, editPicks.length)}
@@ -496,7 +527,6 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
</div>
) : null}
{reveal > 0 ? <GenerationEdgeGlow /> : null}
{reveal > 0 ? <GenerationSuccessReveal title="生成成功" detail={result?.summary || "CDSL 模型已完成"} /> : null}
{showParameters && result ? (
<div className="absolute inset-y-0 right-0 z-40 w-full max-w-[360px] shadow-[var(--ui-shadow-panel)]">
<ParameterPanel
@@ -1,44 +0,0 @@
"use client";
import type { CSSProperties } from "react";
import { createPortal } from "react-dom";
type SparkStyle = CSSProperties & { "--spark-index": number };
export function GenerationSuccessReveal({
title,
detail,
}: {
title: string;
detail: string;
}) {
if (typeof document === "undefined") return null;
return createPortal(
<div className="generation-success" role="status">
<span className="generation-success__glow" aria-hidden="true" />
<div className="generation-success__card">
<span className="generation-success__badge" aria-hidden="true">
<svg className="generation-success__check" viewBox="0 0 52 52">
<circle cx="26" cy="26" r="23" />
<path d="M15 27 l7.5 7.5 L37 18" />
</svg>
</span>
<span className="generation-success__copy">
<span className="generation-success__kicker">LINGXIN · </span>
<strong data-text={title}>{title}</strong>
<span className="generation-success__detail">{detail}</span>
</span>
{[0, 1, 2].map((sparkIndex) => (
<i
className="generation-success__spark"
style={{ "--spark-index": sparkIndex } as SparkStyle}
aria-hidden="true"
key={sparkIndex}
/>
))}
</div>
</div>,
document.body,
);
}
+1 -1
View File
@@ -82,7 +82,7 @@ export type BackendConfig = {
providers: Array<{
id: string;
label: string;
models: Array<{ id: string; vision: boolean }>;
models: Array<{ id: string; vision: boolean; strict_tool_schema?: boolean }>;
}>;
model: string;
configured: boolean;
@@ -130,6 +130,7 @@ export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) {
},
cdslFrame: frame,
cdslCoordinateSystem: "build123d_y_up_glb",
sourceBoundsMm: reference.bbox || null,
// Keep native CDSL millimetres for the deterministic edit compiler.
frame,
triangleStart: Math.max(0, Math.floor(Number(reference.triangle_start) || 0)),
+40
View File
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildViewerSelectionContext } from "./viewer-selection";
test("adds native bottom-face geometry to viewer selection context", () => {
const context = buildViewerSelectionContext({
taskId: "cad_cup",
revisionId: "rev_1",
selection: {
kind: "point_pick",
selectionMode: "point",
referenceIds: ["face_bottom"],
entities: [{ referenceId: "face_bottom", screenPoint: { x: 100, y: 200 } }],
},
references: [{
id: "face_bottom",
label: "plane",
selectorType: "face",
displaySelector: "face_bottom",
pickData: {
surfaceType: "plane",
cdslFrame: { origin_mm: [0, 0, 0], normal: [0, 0, -1] },
sourceBoundsMm: { min: [-20, -20, 0], max: [20, 20, 80] },
},
}],
});
assert.equal(context?.selection.scope, "selected_reference_only");
assert.deepEqual(context?.selection.entities[0]?.centerMm, [0, 0, 0]);
assert.match(String(context?.selection.entities[0]?.verticalPositionHint), /bottom face/);
});
test("does not create context for an empty viewer selection", () => {
assert.equal(buildViewerSelectionContext({
taskId: "cad_cup",
revisionId: "rev_1",
selection: { referenceIds: [] },
references: [],
}), null);
});
+176
View File
@@ -0,0 +1,176 @@
type Vector3 = [number, number, number];
type BoundingBox = {
min: Vector3;
max: Vector3;
};
type ViewerReference = Record<string, unknown> & {
id?: unknown;
label?: unknown;
selectorType?: unknown;
displaySelector?: unknown;
normalizedSelector?: unknown;
pickData?: unknown;
};
export type ViewerSelectionContext = {
schema: "cdsl-cad-viewer-selection.v1";
source: {
taskId: string;
revisionId: string;
units: "mm";
coordinateSystem: "z-up";
};
selection: {
kind: string;
selectionMode: string;
scope: "selected_reference_only" | "selected_references";
referenceIds: string[];
entities: Array<Record<string, unknown>>;
view?: unknown;
};
};
function vector3(value: unknown): Vector3 | null {
if (!Array.isArray(value) || value.length < 3) return null;
const vector = value.slice(0, 3).map(Number) as Vector3;
return vector.every(Number.isFinite) ? vector : null;
}
function boundingBox(value: unknown): BoundingBox | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const min = vector3(record.min);
const max = vector3(record.max);
return min && max ? { min, max } : null;
}
function referenceId(value: unknown) {
if (!value || typeof value !== "object") return "";
const record = value as Record<string, unknown>;
return String(record.referenceId || record.id || "").trim();
}
function uniqueStrings(values: unknown[]) {
const seen = new Set<string>();
return values.flatMap((value) => {
const normalized = String(value || "").trim();
if (!normalized || seen.has(normalized)) return [];
seen.add(normalized);
return [normalized];
});
}
function sourceFrame(reference: ViewerReference) {
const pickData = reference.pickData && typeof reference.pickData === "object"
? reference.pickData as Record<string, unknown>
: {};
const frame = pickData.cdslFrame && typeof pickData.cdslFrame === "object"
? pickData.cdslFrame as Record<string, unknown>
: {};
return {
centerMm: vector3(frame.origin_mm),
normal: vector3(frame.normal),
bboxMm: boundingBox(pickData.sourceBoundsMm),
surfaceType: String(pickData.surfaceType || "").trim(),
};
}
function combinedBounds(references: ViewerReference[]) {
const bounds = references
.map(sourceFrame)
.map((reference) => reference.bboxMm)
.filter((value): value is BoundingBox => Boolean(value));
if (!bounds.length) return null;
return {
min: [
Math.min(...bounds.map((value) => value.min[0])),
Math.min(...bounds.map((value) => value.min[1])),
Math.min(...bounds.map((value) => value.min[2])),
] as Vector3,
max: [
Math.max(...bounds.map((value) => value.max[0])),
Math.max(...bounds.map((value) => value.max[1])),
Math.max(...bounds.map((value) => value.max[2])),
] as Vector3,
};
}
function verticalPositionHint(centerMm: Vector3 | null, normal: Vector3 | null, bounds: BoundingBox | null) {
if (!centerMm || !normal || !bounds) return "position cannot be classified from topology data";
const height = Math.max(bounds.max[2] - bounds.min[2], 0);
const tolerance = Math.max(height * 0.03, 0.25);
if (centerMm[2] <= bounds.min[2] + tolerance && normal[2] <= -0.7) {
return "near the model's lowest Z extent with a downward normal; likely an underside or bottom face";
}
if (centerMm[2] >= bounds.max[2] - tolerance && normal[2] >= 0.7) {
return "near the model's highest Z extent with an upward normal; likely a top face";
}
return "not a horizontal outer top or bottom face at the model's Z extent";
}
function compactEntity(
reference: ViewerReference | undefined,
selectedEntity: Record<string, unknown> | undefined,
bounds: BoundingBox | null,
) {
const referenceData = reference ? sourceFrame(reference) : {
centerMm: null,
normal: null,
bboxMm: null,
surfaceType: "",
};
const id = referenceId(selectedEntity) || referenceId(reference);
const selectedScreenPoint = selectedEntity?.screenPoint;
return {
referenceId: id,
selector: String(selectedEntity?.selector || reference?.displaySelector || reference?.normalizedSelector || id).trim(),
label: String(reference?.label || selectedEntity?.surfaceType || referenceData.surfaceType || "face").trim(),
selectorType: String(selectedEntity?.selectorType || reference?.selectorType || "face").trim(),
surfaceType: String(selectedEntity?.surfaceType || referenceData.surfaceType || "unknown").trim(),
centerMm: referenceData.centerMm,
normal: referenceData.normal,
bboxMm: referenceData.bboxMm,
screenPoint: selectedScreenPoint && typeof selectedScreenPoint === "object" ? selectedScreenPoint : null,
verticalPositionHint: verticalPositionHint(referenceData.centerMm, referenceData.normal, bounds),
};
}
export function buildViewerSelectionContext({
selection,
references,
taskId,
revisionId,
}: {
selection: Record<string, unknown> | null;
references: ViewerReference[];
taskId: string;
revisionId: string;
}): ViewerSelectionContext | null {
if (!selection || !taskId || !revisionId) return null;
const selectedEntities = Array.isArray(selection.entities)
? selection.entities.filter((value): value is Record<string, unknown> => Boolean(value && typeof value === "object"))
: [];
const referenceIds = uniqueStrings([
...(Array.isArray(selection.referenceIds) ? selection.referenceIds : []),
...selectedEntities.map(referenceId),
]);
if (!referenceIds.length) return null;
const referencesById = new Map(references.map((reference) => [referenceId(reference), reference]));
const entitiesById = new Map(selectedEntities.map((entity) => [referenceId(entity), entity]));
const bounds = combinedBounds(references);
return {
schema: "cdsl-cad-viewer-selection.v1",
source: { taskId, revisionId, units: "mm", coordinateSystem: "z-up" },
selection: {
kind: String(selection.kind || "topology_selection"),
selectionMode: String(selection.selectionMode || "point"),
scope: referenceIds.length === 1 ? "selected_reference_only" : "selected_references",
referenceIds,
entities: referenceIds.map((id) => compactEntity(referencesById.get(id), entitiesById.get(id), bounds)),
view: selection.view && typeof selection.view === "object" ? selection.view : undefined,
},
};
}