change
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user