Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/studio/__tests__/adapters/validitySuggestions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ describe('buildValiditySuggestions', () => {
id: 'diagnostic:assembly.interference.overlap:bracket:0',
targetLabel: 'bracket ↔ cover',
targetId: 'bracket',
targetIds: ['bracket', 'cover'],
});
});

Expand Down
44 changes: 43 additions & 1 deletion src/studio/__tests__/diagnosticRouter.test.ts
Original file line number Diff line number Diff line change
@@ -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>): ValidatorDiagnostic {
Expand Down Expand Up @@ -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();
});
});
17 changes: 17 additions & 0 deletions src/studio/__tests__/shellStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down Expand Up @@ -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');
Expand Down
83 changes: 83 additions & 0 deletions src/studio/__tests__/tabs/ValidityTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ValidityTab />);

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',
Expand Down Expand Up @@ -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(<ValidityTab />);

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',
Expand Down Expand Up @@ -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(<ValidityTab />);

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',
Expand Down
44 changes: 44 additions & 0 deletions src/studio/__tests__/viewportFocusTarget.test.ts
Original file line number Diff line number Diff line change
@@ -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')]);
});
});
19 changes: 19 additions & 0 deletions src/studio/adapters/validitySuggestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -69,6 +72,7 @@ export function buildValiditySuggestions(input: {
action,
targetLabel,
targetId,
targetIds,
code: diag.code,
promptText: backendPrompt ?? fallbackPrompt(diag.code, evidence, action),
promptSource,
Expand All @@ -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 | undefined>): string[] {
const seen = new Set<string>();
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;
}
Expand Down
22 changes: 20 additions & 2 deletions src/studio/components/Viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -113,6 +119,14 @@ export default function Viewer({ geometries, previewGeometries, sketchesGeometri
const [hoveredItem, setHoveredItem] = useState<HoverResult | null>(null);
const [snapPoint, setSnapPoint] = useState<SnapResult | null>(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) {
Expand Down Expand Up @@ -282,7 +296,11 @@ export default function Viewer({ geometries, previewGeometries, sketchesGeometri
enableDamping
dampingFactor={0.12}
/>
<CameraHandler geometries={geometries} navigationRequest={navigationRequest} />
<CameraHandler
geometries={geometries}
navigationRequest={navigationRequest}
focusRequest={focusRequest}
/>
</Canvas>
<ViewGizmo
onNavigate={(target) => setNavigationRequest((prev) => ({
Expand Down
22 changes: 22 additions & 0 deletions src/studio/components/viewer/controllers/CameraHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading