diff --git a/src/studio/__tests__/adapters/validitySuggestions.test.ts b/src/studio/__tests__/adapters/validitySuggestions.test.ts index 75c7a0f45..23fa8c4cd 100644 --- a/src/studio/__tests__/adapters/validitySuggestions.test.ts +++ b/src/studio/__tests__/adapters/validitySuggestions.test.ts @@ -218,6 +218,7 @@ describe('buildValiditySuggestions', () => { id: 'diagnostic:assembly.interference.overlap:bracket:0', targetLabel: 'bracket ↔ cover', targetId: 'bracket', + targetIds: ['bracket', 'cover'], }); }); diff --git a/src/studio/__tests__/diagnosticRouter.test.ts b/src/studio/__tests__/diagnosticRouter.test.ts index 4f868a5c7..64b4ac770 100644 --- a/src/studio/__tests__/diagnosticRouter.test.ts +++ b/src/studio/__tests__/diagnosticRouter.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors import { describe, expect, it } from 'vitest'; -import { routeDiagnosticToSelection } from '../logic/diagnosticRouter'; +import { routeDiagnosticToFocusTarget, routeDiagnosticToSelection } from '../logic/diagnosticRouter'; import type { ValidatorDiagnostic } from '../../modeling/mates/validator'; function diag(overrides: Partial): ValidatorDiagnostic { @@ -46,3 +46,45 @@ describe('routeDiagnosticToSelection', () => { expect(routeDiagnosticToSelection(diag({ partB: 'base-plate' }))).toBeNull(); }); }); + +describe('routeDiagnosticToFocusTarget', () => { + it('focuses both parts for pair diagnostics while keeping partA primary', () => { + expect(routeDiagnosticToFocusTarget(diag({ + code: 'assembly.interference.overlap', + partA: 'bracket', + partB: 'cover', + }))).toEqual({ + ids: ['bracket', 'cover'], + primaryId: 'bracket', + }); + }); + + it('deduplicates repeated pair endpoints', () => { + expect(routeDiagnosticToFocusTarget(diag({ + partA: 'bracket', + partB: 'bracket', + }))).toEqual({ + ids: ['bracket'], + primaryId: 'bracket', + }); + }); + + it('focuses a single named part diagnostic', () => { + expect(routeDiagnosticToFocusTarget(diag({ + partName: 'output-horn', + }))).toEqual({ + ids: ['output-horn'], + primaryId: 'output-horn', + }); + }); + + it('does not treat mateName-only diagnostics as geometry focus targets', () => { + expect(routeDiagnosticToFocusTarget(diag({ + mateName: 'jaw-coupling', + }))).toBeNull(); + }); + + it('returns null for diagnostics without geometry target fields', () => { + expect(routeDiagnosticToFocusTarget(diag({}))).toBeNull(); + }); +}); diff --git a/src/studio/__tests__/shellStore.test.ts b/src/studio/__tests__/shellStore.test.ts index a04e14521..33e56d034 100644 --- a/src/studio/__tests__/shellStore.test.ts +++ b/src/studio/__tests__/shellStore.test.ts @@ -23,6 +23,8 @@ describe('ShellStore', () => { expect(s.agentDraftPrompt).toBeNull(); expect(s.agentDraftPromptVersion).toBe(0); expect(s.agentRepairWorkflow).toBeNull(); + expect(s.viewportFocusTarget).toBeNull(); + expect(s.viewportFocusTargetVersion).toBe(0); expect(s.previousValidity).toBeNull(); expect(s.currentValidity).toBeNull(); }); @@ -122,6 +124,21 @@ describe('ShellStore', () => { expect(store.getSnapshot().agentRepairWorkflow).toBeNull(); }); + it('setViewportFocusTarget redelivers repeated non-null targets and clears idempotently', () => { + store = new ShellStore(); + const listener = vi.fn(); + store.subscribe(listener); + + store.setViewportFocusTarget({ ids: ['bracket', 'cover'], source: 'validity-diagnostic' }); + store.setViewportFocusTarget({ ids: ['bracket', 'cover'], source: 'validity-diagnostic' }); + store.setViewportFocusTarget(null); + store.setViewportFocusTarget(null); + + expect(listener).toHaveBeenCalledTimes(3); + expect(store.getSnapshot().viewportFocusTarget).toBeNull(); + expect(store.getSnapshot().viewportFocusTargetVersion).toBe(2); + }); + it('publishValidity rotates current → previous', () => { store = new ShellStore(); const v1 = makeValidity('solved'); diff --git a/src/studio/__tests__/tabs/ValidityTab.test.tsx b/src/studio/__tests__/tabs/ValidityTab.test.tsx index 0180b3f65..1b8c5d33c 100644 --- a/src/studio/__tests__/tabs/ValidityTab.test.tsx +++ b/src/studio/__tests__/tabs/ValidityTab.test.tsx @@ -153,6 +153,32 @@ describe('ValidityTab', () => { expect(mockSelectFeature).toHaveBeenCalledWith('output-horn'); }); + it('clicking a pair diagnostic row selects the primary part and focuses both parts', () => { + const diag: ValidatorDiagnostic = { + code: 'assembly.interference.overlap', + severity: 'error', + message: 'bracket collides with cover', + hint: 'move the cover clear of the bracket', + partA: 'bracket', + partB: 'cover', + }; + + mockUseRecomputeResult.mockReturnValue( + withValidity(makeValidity('error', [diag], 2, 0)), + ); + + render(); + + fireEvent.click(screen.getByTestId('diagnostic-row')); + + expect(mockSelectFeature).toHaveBeenCalledTimes(1); + expect(mockSelectFeature).toHaveBeenCalledWith('bracket'); + expect(shellStore.getSnapshot().viewportFocusTarget).toEqual({ + ids: ['bracket', 'cover'], + source: 'validity-diagnostic', + }); + }); + it('renders a suggestion card above diagnostic rows for an error diagnostic', () => { const diag: ValidatorDiagnostic = { code: 'assembly.part.floating', @@ -198,6 +224,32 @@ describe('ValidityTab', () => { expect(mockSelectFeature).toHaveBeenCalledWith('output-horn'); }); + it('clicking a pair suggestion card Jump button focuses both pair parts', () => { + const diag: ValidatorDiagnostic = { + code: 'assembly.interference.overlap', + severity: 'error', + message: 'bracket collides with cover', + hint: 'move the cover clear of the bracket', + partA: 'bracket', + partB: 'cover', + }; + + mockUseRecomputeResult.mockReturnValue( + withValidity(makeValidity('error', [diag], 2, 0)), + ); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Jump' })); + + expect(mockSelectFeature).toHaveBeenCalledTimes(1); + expect(mockSelectFeature).toHaveBeenCalledWith('bracket'); + expect(shellStore.getSnapshot().viewportFocusTarget).toEqual({ + ids: ['bracket', 'cover'], + source: 'validity-suggestion', + }); + }); + it('clicking Use prompt drafts a repair prompt, selects the target, and opens the agent rail', () => { const diag: ValidatorDiagnostic = { code: 'assembly.part.floating', @@ -230,6 +282,37 @@ describe('ValidityTab', () => { expect(mockSelectFeature).toHaveBeenCalledWith('output-horn'); }); + it('clicking Use prompt on a pair diagnostic preserves primary workflow target and focuses both parts', () => { + const diag: ValidatorDiagnostic = { + code: 'assembly.interference.overlap', + severity: 'error', + message: 'bracket collides with cover', + hint: 'move the cover clear of the bracket', + partA: 'bracket', + partB: 'cover', + }; + + mockUseRecomputeResult.mockReturnValue( + withValidity(makeValidity('error', [diag], 2, 0)), + ); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Use prompt' })); + + expect(shellStore.getSnapshot().agentRepairWorkflow).toMatchObject({ + cardId: 'diagnostic:assembly.interference.overlap:bracket:0', + targetId: 'bracket', + state: 'drafted', + }); + expect(mockSelectFeature).toHaveBeenCalledTimes(1); + expect(mockSelectFeature).toHaveBeenCalledWith('bracket'); + expect(shellStore.getSnapshot().viewportFocusTarget).toEqual({ + ids: ['bracket', 'cover'], + source: 'validity-suggestion', + }); + }); + it('labels the active suggestion card as drafted after Use prompt', () => { const diag: ValidatorDiagnostic = { code: 'assembly.part.floating', diff --git a/src/studio/__tests__/viewportFocusTarget.test.ts b/src/studio/__tests__/viewportFocusTarget.test.ts new file mode 100644 index 000000000..cc4bc9b26 --- /dev/null +++ b/src/studio/__tests__/viewportFocusTarget.test.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors +import { describe, expect, it } from 'vitest'; +import type { GeometryResult } from '../../shared/worker/geometryEngine'; +import { filterGeometriesForFocusTarget } from '../components/viewer/controllers/focusTarget'; + +function geometry(name: string | undefined): GeometryResult { + return { + faces: [ + { + vertices: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]), + normal: [0, 0, 1], + }, + ], + assemblyPartName: name, + }; +} + +describe('filterGeometriesForFocusTarget', () => { + it('returns geometries whose assemblyPartName matches any focus id', () => { + const bracket = geometry('bracket'); + const shaft = geometry('shaft'); + const cover = geometry('cover'); + + expect(filterGeometriesForFocusTarget( + [bracket, shaft, cover], + { ids: ['bracket', 'cover'], source: 'validity-diagnostic' }, + )).toEqual([bracket, cover]); + }); + + it('returns an empty list when no geometry names match', () => { + expect(filterGeometriesForFocusTarget( + [geometry('shaft')], + { ids: ['bracket', 'cover'], source: 'validity-suggestion' }, + )).toEqual([]); + }); + + it('ignores unnamed geometries', () => { + expect(filterGeometriesForFocusTarget( + [geometry(undefined), geometry('cover')], + { ids: ['cover'], source: 'validity-diagnostic' }, + )).toEqual([geometry('cover')]); + }); +}); diff --git a/src/studio/adapters/validitySuggestions.ts b/src/studio/adapters/validitySuggestions.ts index b0d086f6e..184667125 100644 --- a/src/studio/adapters/validitySuggestions.ts +++ b/src/studio/adapters/validitySuggestions.ts @@ -12,6 +12,7 @@ export interface ValiditySuggestionCard { readonly action: string; readonly targetLabel: string | null; readonly targetId: string | null; + readonly targetIds: readonly string[]; readonly code: string; readonly promptText: string; readonly promptSource: 'review' | 'fallback'; @@ -48,6 +49,7 @@ export function buildValiditySuggestions(input: { action, targetLabel: null, targetId: null, + targetIds: [], code: entry.code, promptText: backendPrompt ?? fallbackPrompt(entry.code, evidence, action), promptSource, @@ -58,6 +60,7 @@ export function buildValiditySuggestions(input: { input.validity?.diagnostics.map((diag, index): ValiditySuggestionCard => { const targetLabel = diagnosticTargetLabel(diag); const targetId = diagnosticTargetId(diag); + const targetIds = diagnosticTargetIds(diag); const evidence = textOrFallback(diag.message, diag.code); const action = textOrFallback(diag.hint, 'Review deterministic validation evidence.'); return { @@ -69,6 +72,7 @@ export function buildValiditySuggestions(input: { action, targetLabel, targetId, + targetIds, code: diag.code, promptText: backendPrompt ?? fallbackPrompt(diag.code, evidence, action), promptSource, @@ -94,6 +98,21 @@ function diagnosticTargetId(d: ValidatorDiagnostic): string | null { return null; } +function diagnosticTargetIds(d: ValidatorDiagnostic): string[] { + return uniqueNonEmpty([d.partName, d.partA, d.partB]); +} + +function uniqueNonEmpty(values: ReadonlyArray): string[] { + const seen = new Set(); + const ids: string[] = []; + for (const value of values) { + if (!value || seen.has(value)) continue; + seen.add(value); + ids.push(value); + } + return ids; +} + function textOrFallback(value: string, fallback: string): string { return value.trim() === '' ? fallback : value; } diff --git a/src/studio/components/Viewer.tsx b/src/studio/components/Viewer.tsx index 0ccb99925..bb168b96b 100644 --- a/src/studio/components/Viewer.tsx +++ b/src/studio/components/Viewer.tsx @@ -84,7 +84,13 @@ export default function Viewer({ geometries, previewGeometries, sketchesGeometri }, [geometries]); const { - sectionMode, sectionAxesEnabled, sectionSides, sectionOffsets, sectionKeepWhole, + sectionMode, + sectionAxesEnabled, + sectionSides, + sectionOffsets, + sectionKeepWhole, + viewportFocusTarget, + viewportFocusTargetVersion, } = useShellStore(); // Three stable plane instances, mutated in place so slider/side changes // never rebuild materials (only mode/axis-count switches do — see ShapeGeometry). @@ -113,6 +119,14 @@ export default function Viewer({ geometries, previewGeometries, sketchesGeometri const [hoveredItem, setHoveredItem] = useState(null); const [snapPoint, setSnapPoint] = useState(null); const [navigationRequest, setNavigationRequest] = useState<{ target: ViewTarget; id: number } | null>(null); + const focusRequest = useMemo( + () => ( + viewportFocusTarget == null + ? null + : { target: viewportFocusTarget, id: viewportFocusTargetVersion } + ), + [viewportFocusTarget, viewportFocusTargetVersion], + ); useEffect(() => { if (hoveredItem?.object?.userData?.ownerId) { @@ -282,7 +296,11 @@ export default function Viewer({ geometries, previewGeometries, sketchesGeometri enableDamping dampingFactor={0.12} /> - + setNavigationRequest((prev) => ({ diff --git a/src/studio/components/viewer/controllers/CameraHandler.tsx b/src/studio/components/viewer/controllers/CameraHandler.tsx index c02d653b2..f7ff18e57 100644 --- a/src/studio/components/viewer/controllers/CameraHandler.tsx +++ b/src/studio/components/viewer/controllers/CameraHandler.tsx @@ -5,8 +5,10 @@ import * as THREE from "three"; import { useEffect, useRef } from "react"; import { useWorkbench } from "../../../context/WorkbenchContext"; import type { GeometryResult } from "../../../../shared/worker/geometryEngine"; +import type { ViewportFocusTarget } from "../../../store/shellStore"; import { computeGeometryBounds } from "./cameraBounds"; import { buildCameraPose, buildFitCameraPose, type ViewTarget } from "./cameraPose"; +import { filterGeometriesForFocusTarget } from "./focusTarget"; // Using the exported constants if needed, but they are defined in Viewer.tsx conventionally. // For now I will hardcode or use the same values. @@ -15,9 +17,11 @@ const SKETCH_DISTANCE = 20; export function CameraHandler({ geometries, navigationRequest, + focusRequest, }: { geometries: GeometryResult[]; navigationRequest?: { target: ViewTarget; id: number } | null; + focusRequest?: { target: ViewportFocusTarget; id: number } | null; }) { const { selectedFace, sketchMode } = useWorkbench(); const { camera, controls } = useThree(); @@ -80,6 +84,24 @@ export function CameraHandler({ targetState.current = { position: pose.position, lookAt: pose.lookAt }; }, [navigationRequest, geometries, sketchMode.active]); + useEffect(() => { + if (!focusRequest || geometries.length === 0 || sketchMode.active) return; + + const focusedGeometries = filterGeometriesForFocusTarget(geometries, focusRequest.target); + if (focusedGeometries.length === 0) return; + + const bounds = computeGeometryBounds(focusedGeometries); + if (!bounds) return; + + const distance = Math.max(bounds.radius * 2.8, SKETCH_DISTANCE); + const pose = buildFitCameraPose(bounds.center, distance); + cameraRef.current.up.copy(pose.up); + cameraRef.current.near = Math.max(distance / 500, 0.01); + cameraRef.current.far = Math.max(distance * 20, 1000); + cameraRef.current.updateProjectionMatrix(); + targetState.current = { position: pose.position, lookAt: pose.lookAt }; + }, [focusRequest, geometries, sketchMode.active]); + useEffect(() => { const isSketching = sketchMode.active; const wasSketching = prevSketchActive.current; diff --git a/src/studio/components/viewer/controllers/focusTarget.ts b/src/studio/components/viewer/controllers/focusTarget.ts new file mode 100644 index 000000000..6db50cdbf --- /dev/null +++ b/src/studio/components/viewer/controllers/focusTarget.ts @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors +import type { GeometryResult } from '../../../../shared/worker/geometryEngine'; +import type { ViewportFocusTarget } from '../../../store/shellStore'; + +export function filterGeometriesForFocusTarget( + geometries: readonly GeometryResult[], + focusTarget: ViewportFocusTarget, +): GeometryResult[] { + const ids = new Set(focusTarget.ids); + return geometries.filter((geometry) => { + const name = geometry.assemblyPartName; + return name != null && ids.has(name); + }); +} diff --git a/src/studio/logic/diagnosticRouter.ts b/src/studio/logic/diagnosticRouter.ts index 0ef5e29b4..35d9bcf43 100644 --- a/src/studio/logic/diagnosticRouter.ts +++ b/src/studio/logic/diagnosticRouter.ts @@ -3,6 +3,11 @@ import type { ValidatorDiagnostic } from '../../modeling/mates/validator'; import type { SelectedFeatureId } from '../types'; +export interface DiagnosticFocusTarget { + readonly ids: readonly string[]; + readonly primaryId: SelectedFeatureId; +} + /** * Map a validator diagnostic to the selection target the tri-pane sync * should jump to. @@ -20,3 +25,25 @@ export function routeDiagnosticToSelection(diagnostic: ValidatorDiagnostic): Sel if (diagnostic.partA) return diagnostic.partA; return null; } + +/** + * Map a diagnostic to viewport-focus ids. Unlike selection, focus can include + * both ends of a pair diagnostic so the camera frames the actual relationship. + */ +export function routeDiagnosticToFocusTarget(diagnostic: ValidatorDiagnostic): DiagnosticFocusTarget | null { + const primaryId = routeDiagnosticToSelection(diagnostic); + const ids = uniqueNonEmpty([diagnostic.partName, diagnostic.partA, diagnostic.partB]); + if (ids.length === 0) return null; + return { ids, primaryId }; +} + +function uniqueNonEmpty(values: ReadonlyArray): string[] { + const seen = new Set(); + const ids: string[] = []; + for (const value of values) { + if (!value || seen.has(value)) continue; + seen.add(value); + ids.push(value); + } + return ids; +} diff --git a/src/studio/store/shellStore.ts b/src/studio/store/shellStore.ts index 0db4aa9da..2257ded0c 100644 --- a/src/studio/store/shellStore.ts +++ b/src/studio/store/shellStore.ts @@ -40,12 +40,19 @@ export interface AgentRepairWorkflow { readonly state: AgentRepairWorkflowState; } +export interface ViewportFocusTarget { + readonly ids: readonly string[]; + readonly source: 'validity-diagnostic' | 'validity-suggestion'; +} + export interface ShellState { readonly selectedFeatureId: SelectedFeatureId; readonly agentRailOpen: boolean; readonly agentDraftPrompt: string | null; readonly agentDraftPromptVersion: number; readonly agentRepairWorkflow: AgentRepairWorkflow | null; + readonly viewportFocusTarget: ViewportFocusTarget | null; + readonly viewportFocusTargetVersion: number; readonly inspectorOpen: boolean; readonly previousValidity: ValidatorResult | null; readonly currentValidity: ValidatorResult | null; @@ -96,6 +103,8 @@ const INITIAL_STATE: ShellState = { agentDraftPrompt: null, agentDraftPromptVersion: 0, agentRepairWorkflow: null, + viewportFocusTarget: null, + viewportFocusTargetVersion: 0, inspectorOpen: true, previousValidity: null, currentValidity: null, @@ -160,6 +169,19 @@ export class ShellStore { this.emit(); }; + setViewportFocusTarget = (target: ViewportFocusTarget | null): void => { + if (target === null && this.state.viewportFocusTarget === null) return; + this.state = { + ...this.state, + viewportFocusTarget: target, + viewportFocusTargetVersion: + target === null + ? this.state.viewportFocusTargetVersion + : this.state.viewportFocusTargetVersion + 1, + }; + this.emit(); + }; + setInspectorOpen = (open: boolean): void => { if (this.state.inspectorOpen === open) return; this.state = { ...this.state, inspectorOpen: open }; diff --git a/src/studio/tabs/ValidityTab.tsx b/src/studio/tabs/ValidityTab.tsx index baa16a035..b5c8c0d19 100644 --- a/src/studio/tabs/ValidityTab.tsx +++ b/src/studio/tabs/ValidityTab.tsx @@ -3,7 +3,7 @@ import type { JSX, MouseEvent } from 'react'; import { useRecomputeResult } from '../hooks/useRecomputeResult'; import { useFeatureSelection } from '../hooks/useFeatureSelection'; -import { routeDiagnosticToSelection } from '../logic/diagnosticRouter'; +import { routeDiagnosticToFocusTarget, routeDiagnosticToSelection } from '../logic/diagnosticRouter'; import type { ValidatorDiagnostic, ValidatorStatus } from '../../modeling/mates/validator'; import { buildValiditySuggestions, @@ -82,11 +82,7 @@ export function ValidityTab(): JSX.Element { card={card} workflowState={workflowView.cardStates.get(card.id) ?? null} validityFingerprint={validityFingerprint} - onSelect={ - card.targetId == null - ? undefined - : () => selectFeature(card.targetId) - } + onSelect={makeSuggestionSelectHandler(card, selectFeature)} /> ))} @@ -103,7 +99,7 @@ export function ValidityTab(): JSX.Element { selectFeature(routeDiagnosticToSelection(diag))} + onSelect={() => selectDiagnostic(diag, selectFeature)} /> ))} @@ -112,6 +108,34 @@ export function ValidityTab(): JSX.Element { ); } +function selectDiagnostic( + diagnostic: ValidatorDiagnostic, + selectFeature: (id: string | null) => void, +): void { + selectFeature(routeDiagnosticToSelection(diagnostic)); + const focusTarget = routeDiagnosticToFocusTarget(diagnostic); + if (focusTarget == null) return; + shellStore.setViewportFocusTarget({ + ids: focusTarget.ids, + source: 'validity-diagnostic', + }); +} + +function makeSuggestionSelectHandler( + card: ValiditySuggestionCard, + selectFeature: (id: string | null) => void, +): (() => void) | undefined { + if (card.targetId == null && card.targetIds.length === 0) return undefined; + return () => { + selectFeature(card.targetId); + if (card.targetIds.length === 0) return; + shellStore.setViewportFocusTarget({ + ids: card.targetIds, + source: 'validity-suggestion', + }); + }; +} + function SuggestionCard({ card, workflowState,