From 615f0c879d852217cd81817e44b2339e0d101997 Mon Sep 17 00:00:00 2001 From: dsk-dev-ai Date: Mon, 17 Aug 2026 23:34:27 +0530 Subject: [PATCH] feat(visualization): add Phase 6.12 molecular structure viewer --- apps/web/package.json | 10 +- .../app/visualization/VisualizationDemo.tsx | 22 +- .../MolecularStructureDemo.tsx | 19 ++ .../molecular-structure/page.tsx | 36 +++ apps/web/src/app/visualization/page.tsx | 6 + .../MolecularStructureViewer.test.tsx | 183 ++++++++++++++ .../molecular/MolecularStructureViewer.tsx | 182 ++++++++++++++ apps/web/src/lib/molecular/api.test.ts | 152 ++++++++++++ apps/web/src/lib/molecular/api.ts | 205 ++++++++++++++++ apps/web/src/lib/molecular/geometry.test.ts | 147 ++++++++++++ apps/web/src/lib/molecular/geometry.ts | 187 +++++++++++++++ .../lib/molecular/molecular.fixtures.test.ts | 33 +++ .../src/lib/molecular/molecular.fixtures.ts | 152 ++++++++++++ .../render/representationBuilder.test.ts | 107 +++++++++ .../molecular/render/representationBuilder.ts | 135 +++++++++++ .../lib/molecular/render/threeViewer.test.ts | 176 ++++++++++++++ .../src/lib/molecular/render/threeViewer.ts | 159 ++++++++++++ apps/web/src/lib/molecular/render/types.ts | 51 ++++ .../src/lib/molecular/representations.test.ts | 53 ++++ apps/web/src/lib/molecular/representations.ts | 54 +++++ apps/web/src/lib/molecular/types.ts | 95 ++++++++ .../useMolecularStructureViewer.test.tsx | 123 ++++++++++ .../molecular/useMolecularStructureViewer.ts | 132 ++++++++++ apps/web/src/lib/molecular/validate.test.ts | 200 ++++++++++++++++ apps/web/src/lib/molecular/validate.ts | 170 +++++++++++++ .../visualizationModules.test.ts | 3 +- .../lib/visualization/visualizationModules.ts | 10 +- docs/visualization/README.md | 64 ++++- docs/visualization/architecture.md | 7 +- docs/visualization/molecular-structure.md | 226 ++++++++++++++++++ docs/visualization/protein-viewer.md | 6 +- docs/visualization/roadmap.md | 82 ++++++- pnpm-lock.yaml | 53 ++++ 33 files changed, 3199 insertions(+), 41 deletions(-) create mode 100644 apps/web/src/app/visualization/molecular-structure/MolecularStructureDemo.tsx create mode 100644 apps/web/src/app/visualization/molecular-structure/page.tsx create mode 100644 apps/web/src/components/molecular/MolecularStructureViewer.test.tsx create mode 100644 apps/web/src/components/molecular/MolecularStructureViewer.tsx create mode 100644 apps/web/src/lib/molecular/api.test.ts create mode 100644 apps/web/src/lib/molecular/api.ts create mode 100644 apps/web/src/lib/molecular/geometry.test.ts create mode 100644 apps/web/src/lib/molecular/geometry.ts create mode 100644 apps/web/src/lib/molecular/molecular.fixtures.test.ts create mode 100644 apps/web/src/lib/molecular/molecular.fixtures.ts create mode 100644 apps/web/src/lib/molecular/render/representationBuilder.test.ts create mode 100644 apps/web/src/lib/molecular/render/representationBuilder.ts create mode 100644 apps/web/src/lib/molecular/render/threeViewer.test.ts create mode 100644 apps/web/src/lib/molecular/render/threeViewer.ts create mode 100644 apps/web/src/lib/molecular/render/types.ts create mode 100644 apps/web/src/lib/molecular/representations.test.ts create mode 100644 apps/web/src/lib/molecular/representations.ts create mode 100644 apps/web/src/lib/molecular/types.ts create mode 100644 apps/web/src/lib/molecular/useMolecularStructureViewer.test.tsx create mode 100644 apps/web/src/lib/molecular/useMolecularStructureViewer.ts create mode 100644 apps/web/src/lib/molecular/validate.test.ts create mode 100644 apps/web/src/lib/molecular/validate.ts create mode 100644 docs/visualization/molecular-structure.md diff --git a/apps/web/package.json b/apps/web/package.json index 5dff12d..3bde1b4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,7 +14,8 @@ "dependencies": { "next": "^15.0.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "three": "^0.185.1" }, "devDependencies": { "@testing-library/dom": "^10.4.0", @@ -23,12 +24,13 @@ "@types/node": "^20.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@types/three": "^0.185.4", "@vitejs/plugin-react": "^4.4.0", + "autoprefixer": "^10.0.0", "jsdom": "^26.0.0", - "typescript": "^5.7.0", - "tailwindcss": "^3.4.0", "postcss": "^8.0.0", - "autoprefixer": "^10.0.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.7.0", "vitest": "^3.0.0" } } diff --git a/apps/web/src/app/visualization/VisualizationDemo.tsx b/apps/web/src/app/visualization/VisualizationDemo.tsx index b9c3756..696419e 100644 --- a/apps/web/src/app/visualization/VisualizationDemo.tsx +++ b/apps/web/src/app/visualization/VisualizationDemo.tsx @@ -10,10 +10,9 @@ import { /** * Client-side demonstration of the Phase 6.1 visualization foundation. * - * Shows the data lifecycle (loading → success / empty / error) flowing - * through the reusable `VisualizationContainer`. The module catalog maps the - * full Phase 6.2–6.11 platform, each entry resolving to the viewer(s) shown - * above on this page. + * Shows the module catalog flowing through the reusable + * `VisualizationContainer`. The catalog maps the full Phase 6.2–6.12 + * platform, each entry resolving to the viewer(s) shown above on this page. */ export function VisualizationDemo() { const { status, data, error, refetch } = useVisualizationData( @@ -22,7 +21,7 @@ export function VisualizationDemo() { ) return ( -
+
- -
- - -
) } diff --git a/apps/web/src/app/visualization/molecular-structure/MolecularStructureDemo.tsx b/apps/web/src/app/visualization/molecular-structure/MolecularStructureDemo.tsx new file mode 100644 index 0000000..9cbdb85 --- /dev/null +++ b/apps/web/src/app/visualization/molecular-structure/MolecularStructureDemo.tsx @@ -0,0 +1,19 @@ +'use client' + +import { MolecularStructureViewer } from '@/components/molecular/MolecularStructureViewer' +import { P53_HELIX_STRUCTURE_FIXTURE } from '@/lib/molecular/molecular.fixtures' +import { useMolecularStructureViewer } from '@/lib/molecular/useMolecularStructureViewer' + +/** + * Phase 6.12 demo: Molecular Structure Viewer over the development fixture. + * + * The backend does not yet expose a molecular structure endpoint, so this + * demo feeds the synthetic typed fixture through the same normalizer the + * production adapter uses. See `docs/visualization/molecular-structure.md`. + */ +export function MolecularStructureDemo() { + const result = useMolecularStructureViewer({ + loader: async () => P53_HELIX_STRUCTURE_FIXTURE, + }) + return +} diff --git a/apps/web/src/app/visualization/molecular-structure/page.tsx b/apps/web/src/app/visualization/molecular-structure/page.tsx new file mode 100644 index 0000000..e21cff6 --- /dev/null +++ b/apps/web/src/app/visualization/molecular-structure/page.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from 'next' +import Link from 'next/link' + +import { MolecularStructureDemo } from './MolecularStructureDemo' + +export const metadata: Metadata = { + title: 'Molecular Structure Viewer — GenomeAI', + description: + 'Interactive 3D molecular structure viewer (Phase 6.12): cartoon, ball-and-stick, and space-filling representations over a synthetic development fixture.', +} + +export default function MolecularStructurePage() { + return ( +
+
+

Molecular Structure Viewer

+

+ Phase 6.12 interactive 3D molecular structure rendering: orbit, zoom, and pan the camera; + switch between cartoon / ribbon, ball-and-stick, and space-filling representations; and + reset or fit the view. The demo renders a synthetic development fixture through the same + typed normalizer the production structure adapter will use — no structure endpoint exists + yet (see the molecular structure documentation). +

+
+ + +
+ ) +} diff --git a/apps/web/src/app/visualization/page.tsx b/apps/web/src/app/visualization/page.tsx index 9b88dd3..64cf558 100644 --- a/apps/web/src/app/visualization/page.tsx +++ b/apps/web/src/app/visualization/page.tsx @@ -38,6 +38,12 @@ export default function VisualizationPage() { > Open Integrated Research Workspace + + Open Molecular Structure Viewer + diff --git a/apps/web/src/components/molecular/MolecularStructureViewer.test.tsx b/apps/web/src/components/molecular/MolecularStructureViewer.test.tsx new file mode 100644 index 0000000..31f2da0 --- /dev/null +++ b/apps/web/src/components/molecular/MolecularStructureViewer.test.tsx @@ -0,0 +1,183 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { P53_HELIX_STRUCTURE_FIXTURE } from '@/lib/molecular/molecular.fixtures' +import type { MolecularViewer } from '@/lib/molecular/render/types' +import type { MolecularStructureViewerResult } from '@/lib/molecular/useMolecularStructureViewer' + +import { MolecularStructureViewer } from './MolecularStructureViewer' + +function result( + overrides: Partial = {}, +): MolecularStructureViewerResult { + const base: MolecularStructureViewerResult = { + status: 'success', + error: undefined, + refetch: vi.fn(), + structure: P53_HELIX_STRUCTURE_FIXTURE, + summary: { + name: 'p53 N-terminal helix (synthetic fixture)', + chains: 1, + residues: 15, + atoms: 60, + bonds: 75, + }, + representation: 'cartoon', + setRepresentation: vi.fn(), + visible: true, + setVisible: vi.fn(), + focus: { target: { x: 0, y: 0, z: 0 }, radius: 5, version: 0 }, + resetView: vi.fn(), + fitToView: vi.fn(), + } + return { ...base, ...overrides } +} + +function fakeViewer() { + const viewer: MolecularViewer = { + setStructure: vi.fn(), + setRepresentation: vi.fn(), + setVisible: vi.fn(), + focusCamera: vi.fn(), + resize: vi.fn(), + dispose: vi.fn(), + } + const createViewer = vi.fn(() => viewer) + return { viewer, createViewer } +} + +afterEach(() => { + cleanup() +}) + +describe('MolecularStructureViewer', () => { + it('renders the canvas with a labelled image and a live summary', () => { + const { createViewer } = fakeViewer() + render() + const canvas = screen.getByRole('img') + expect(canvas).toBeInTheDocument() + expect(canvas.getAttribute('aria-label')).toContain('15 residues') + expect(screen.getByTestId('structure-status').textContent).toContain('60 atoms') + }) + + it('renders the loading state without creating a viewer', () => { + const { createViewer } = fakeViewer() + render( + , + ) + expect(screen.getByText('Loading molecular structure...')).toBeInTheDocument() + expect(createViewer).not.toHaveBeenCalled() + }) + + it('renders the empty state message', () => { + const { createViewer } = fakeViewer() + render( + , + ) + expect(screen.getByText('No molecular structure to display.')).toBeInTheDocument() + expect(createViewer).not.toHaveBeenCalled() + }) + + it('renders the error state and retries', () => { + const { createViewer } = fakeViewer() + const refetch = vi.fn() + render( + , + ) + expect(screen.getByText('Failed to load molecular structure')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /retry/i })) + expect(refetch).toHaveBeenCalledTimes(1) + expect(createViewer).not.toHaveBeenCalled() + }) + + it('creates the viewer once and feeds it the structure and focus', () => { + const { viewer, createViewer } = fakeViewer() + render() + expect(createViewer).toHaveBeenCalledTimes(1) + expect(viewer.setStructure).toHaveBeenCalledWith(P53_HELIX_STRUCTURE_FIXTURE, 'cartoon') + expect(viewer.focusCamera).toHaveBeenCalledWith({ x: 0, y: 0, z: 0 }, 5) + }) + + it('switches representation through the labelled select', () => { + const { viewer, createViewer } = fakeViewer() + const setRepresentation = vi.fn() + render( + , + ) + const select = screen.getByRole('combobox', { name: 'Structure representation' }) + fireEvent.change(select, { target: { value: 'space-filling' } }) + expect(setRepresentation).toHaveBeenCalledWith('space-filling') + expect(viewer.setStructure).toHaveBeenCalledWith(P53_HELIX_STRUCTURE_FIXTURE, 'cartoon') + }) + + it('toggles visibility with an accessible pressed state', () => { + const { viewer, createViewer } = fakeViewer() + const setVisible = vi.fn() + const { unmount } = render( + , + ) + const toggle = screen.getByRole('button', { name: 'Hide structure' }) + expect(toggle.getAttribute('aria-pressed')).toBe('true') + + fireEvent.click(toggle) + expect(setVisible).toHaveBeenCalledWith(false) + expect(viewer.setVisible).toHaveBeenCalledWith(true) + + unmount() + }) + + it('re-frames the camera when the focus version changes', async () => { + const { viewer, createViewer } = fakeViewer() + const first = result() + const { rerender } = render( + , + ) + expect(viewer.focusCamera).toHaveBeenCalledTimes(1) + + const refocused = result({ focus: { target: { x: 1, y: 1, z: 1 }, radius: 6, version: 1 } }) + rerender() + await waitFor(() => expect(viewer.focusCamera).toHaveBeenCalledTimes(2)) + }) + + it('exposes reset and fit controls', () => { + const { createViewer } = fakeViewer() + const resetView = vi.fn() + const fitToView = vi.fn() + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: 'Reset view' })) + fireEvent.click(screen.getByRole('button', { name: 'Fit to structure' })) + expect(resetView).toHaveBeenCalledTimes(1) + expect(fitToView).toHaveBeenCalledTimes(1) + }) + + it('disposes the viewer on unmount', () => { + const { viewer, createViewer } = fakeViewer() + const { unmount } = render( + , + ) + unmount() + expect(viewer.dispose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/web/src/components/molecular/MolecularStructureViewer.tsx b/apps/web/src/components/molecular/MolecularStructureViewer.tsx new file mode 100644 index 0000000..180b6f9 --- /dev/null +++ b/apps/web/src/components/molecular/MolecularStructureViewer.tsx @@ -0,0 +1,182 @@ +'use client' + +import { type ChangeEvent, useEffect, useRef } from 'react' + +import { VisualizationContainer } from '@/components/visualization/VisualizationContainer' +import { createThreeViewer } from '@/lib/molecular/render/threeViewer' +import type { CreateMolecularViewer, MolecularViewer } from '@/lib/molecular/render/types' +import { REPRESENTATIONS, type RepresentationId } from '@/lib/molecular/representations' +import type { MolecularStructureViewerResult } from '@/lib/molecular/useMolecularStructureViewer' + +/** + * 3D molecular structure viewer (Phase 6.12). + * + * Renders a `MolecularStructure` through a Three.js viewer behind the shared + * `VisualizationContainer` lifecycle. The viewer instance is created once per + * mount and updated in place (structure, representation, visibility, camera + * framing) so the renderer is never recreated unnecessarily, and it is fully + * disposed on unmount. + * + * The WebGL canvas is a supplementary visual: the labelled controls and the + * textual structure summary carry the keyboard/assistive interaction, and the + * canvas itself is exposed as a labelled image (see the accessibility section + * of `docs/visualization/molecular-structure.md`). + */ +export interface MolecularStructureViewerProps { + /** View model produced by `useMolecularStructureViewer`. */ + result: MolecularStructureViewerResult + /** Container heading. */ + title?: string + /** + * Viewer factory (defaults to the Three.js implementation). Inject a fake + * in tests to drive the lifecycle without a GPU. + */ + createViewer?: CreateMolecularViewer +} + +function structureCanvasLabel(result: MolecularStructureViewerResult): string { + const summary = result.summary + if (summary === undefined) return '3D molecular structure' + return [ + `3D structure of ${summary.name}`, + `${summary.chains} chain${summary.chains === 1 ? '' : 's'}`, + `${summary.residues} residues`, + `${summary.atoms} atoms`, + `${summary.bonds} bonds`, + ].join(', ') +} + +export function MolecularStructureViewer({ + result, + title = 'Molecular Structure Viewer', + createViewer = createThreeViewer, +}: MolecularStructureViewerProps) { + const containerRef = useRef(null) + const viewerRef = useRef(null) + const createViewerRef = useRef(createViewer) + createViewerRef.current = createViewer + + // Create the viewer once the success-state container exists; dispose it on + // unmount (or when the component leaves the success state, e.g. refetch). + const viewerCreatedRef = useRef(false) + useEffect(() => { + const container = containerRef.current + if (container === null || viewerCreatedRef.current || result.status !== 'success') return + viewerCreatedRef.current = true + viewerRef.current = createViewerRef.current(container) + return () => { + viewerRef.current?.dispose() + viewerRef.current = null + viewerCreatedRef.current = false + } + }, [result.status]) + + useEffect(() => { + const viewer = viewerRef.current + if (viewer === null || result.structure === undefined || result.status !== 'success') return + viewer.setStructure(result.structure, result.representation) + }, [result.status, result.structure, result.representation]) + + useEffect(() => { + const viewer = viewerRef.current + if (viewer === null || result.focus === undefined) return + viewer.focusCamera(result.focus.target, result.focus.radius) + }, [result.focus]) + + useEffect(() => { + viewerRef.current?.setVisible(result.visible) + }, [result.visible]) + + function handleRepresentationChange(event: ChangeEvent) { + result.setRepresentation(event.target.value as RepresentationId) + } + + return ( + + {result.status === 'success' && result.structure ? ( +
+
+ + {structureCanvasLabel(result)} + + +
+
+ 3D viewport controls + + +
+ + + + + +
+
+ ) : null} + + ) +} diff --git a/apps/web/src/lib/molecular/api.test.ts b/apps/web/src/lib/molecular/api.test.ts new file mode 100644 index 0000000..888c398 --- /dev/null +++ b/apps/web/src/lib/molecular/api.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { GenomeApiError } from '@/lib/genome/api' + +import { fetchMolecularStructure, toStructure } from './api' + +const rawFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = rawFetch + vi.restoreAllMocks() +}) + +function jsonResponse(payload: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(payload), + } as Response +} + +describe('toStructure', () => { + it('normalizes a raw structure record', () => { + const structure = toStructure({ + id: 'fixture-mini-p53-helix', + name: 'p53 N-terminal helix', + kind: 'protein', + organism: 'Homo sapiens', + description: 'Synthetic.', + chains: [{ id: 'A', residues: [{ index: 1, name: 'MET', atom_indices: [1, 2, 3] }] }], + atoms: [ + { + index: 1, + element: 'N', + atom_name: 'N', + residue_name: 'MET', + residue_index: 1, + chain_id: 'A', + x: 0, + y: 0, + z: 0, + }, + { + serial: 2, + element_symbol: 'C', + position_x: 1, + position_y: 1, + position_z: 1, + residue: 1, + chain: 'A', + }, + { index: 3, element: 'O', x: 2, y: 2, z: 2, residue_index: 1, chain_id: 'A' }, + ], + bonds: [ + { atom_a: 1, atom_b: 2, order: 1 }, + { atom1: 2, atom2: 3 }, + ], + metadata: { source: 'fixture', numeric: 3 }, + }) + + expect(structure.id).toBe('fixture-mini-p53-helix') + expect(structure.name).toBe('p53 N-terminal helix') + expect(structure.kind).toBe('protein') + expect(structure.organism).toBe('Homo sapiens') + expect(structure.chains[0].residues[0].name).toBe('MET') + expect(structure.atoms).toHaveLength(3) + expect(structure.atoms[1].element).toBe('C') + expect(structure.atoms[1].x).toBe(1) + expect(structure.atoms[1].residueIndex).toBe(1) + expect(structure.atoms[1].chainId).toBe('A') + expect(structure.bonds).toEqual([ + { atomA: 1, atomB: 2, order: 1 }, + { atomA: 2, atomB: 3 }, + ]) + expect(structure.metadata).toEqual({ source: 'fixture', numeric: 3 }) + }) + + it('drops malformed atoms and bonds while keeping well-formed ones', () => { + const structure = toStructure({ + id: 'partial', + atoms: [ + { index: 1, element: 'C', x: 0, y: 0, z: 0, residue_index: 1 }, + { index: 2, element: 'N' }, + ], + bonds: [{ atom_a: 1, atom_b: 2 }, { atom_b: 1 }], + }) + expect(structure.atoms).toHaveLength(1) + expect(structure.bonds).toHaveLength(1) + }) + + it('falls back to molecule_type for the kind and omits unknown kinds', () => { + expect(toStructure({ id: 'x', molecule_type: 'nucleic-acid' }).kind).toBe('nucleic-acid') + expect(toStructure({ id: 'x', kind: 'virus' }).kind).toBeUndefined() + }) + + it('filters non-scalar metadata values', () => { + const structure = toStructure({ id: 'x', metadata: { source: 'fixture', nested: { a: 1 } } }) + expect(structure.metadata).toEqual({ source: 'fixture' }) + }) + + it('defaults the id to an empty string and optional fields to undefined', () => { + const structure = toStructure({}) + expect(structure.id).toBe('') + expect(structure.name).toBeUndefined() + expect(structure.chains).toEqual([]) + expect(structure.atoms).toEqual([]) + expect(structure.bonds).toEqual([]) + }) +}) + +describe('fetchMolecularStructure', () => { + it('fetches, normalizes, and validates a structure', async () => { + const fetchMock = vi.fn(async () => + jsonResponse({ + id: 'a1b2c3', + name: 'Minimal', + chains: [{ id: 'A', residues: [{ index: 1, atom_indices: [1] }] }], + atoms: [{ index: 1, element: 'C', x: 0, y: 0, z: 0, residue_index: 1, chain_id: 'A' }], + bonds: [], + }), + ) + globalThis.fetch = fetchMock as unknown as typeof fetch + + const structure = await fetchMolecularStructure('a1b2c3', new AbortController().signal) + expect(structure.id).toBe('a1b2c3') + expect(structure.atoms).toHaveLength(1) + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:8000/structures/a1b2c3', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ) + }) + + it('throws a GenomeApiError for a non-2xx response', async () => { + globalThis.fetch = vi.fn(async () => jsonResponse({}, 404)) as unknown as typeof fetch + await expect(fetchMolecularStructure('missing')).rejects.toMatchObject({ + name: 'GenomeApiError', + status: 404, + }) + }) + + it('throws a GenomeApiError for an invalid payload', async () => { + globalThis.fetch = vi.fn(async () => jsonResponse(null)) as unknown as typeof fetch + await expect(fetchMolecularStructure('a1b2c3')).rejects.toThrow(GenomeApiError) + }) + + it('throws a GenomeApiError when the payload does not describe a valid structure', async () => { + globalThis.fetch = vi.fn(async () => + jsonResponse({ id: 'empty', atoms: [] }), + ) as unknown as typeof fetch + await expect(fetchMolecularStructure('a1b2c3')).rejects.toThrow(GenomeApiError) + }) +}) diff --git a/apps/web/src/lib/molecular/api.ts b/apps/web/src/lib/molecular/api.ts new file mode 100644 index 0000000..516f8fb --- /dev/null +++ b/apps/web/src/lib/molecular/api.ts @@ -0,0 +1,205 @@ +/** + * Molecular structure data adapter (Phase 6.12). + * + * Documents the future GenomeAI structure contract and normalizes raw wire + * records into the canonical `MolecularStructure` model: + * + * GET /structures/{structure_id} + * + * The backend does **not yet expose any molecular structure endpoint**. This + * adapter defines the normalized shape a future source is expected to return + * and provides `toStructure` so a real endpoint can be wired in without + * touching the viewer. Until then the demo uses the clearly isolated + * development fixture in `lib/molecular/molecular.fixtures.ts`, routed through + * the same normalizer as production. See `docs/visualization/molecular-structure.md`. + */ + +import { API_BASE_URL, GenomeApiError, asNumber, asString } from '@/lib/genome/api' +import type { + MolecularStructure, + StructureAtom, + StructureBond, + StructureChain, + StructureKind, + StructureResidue, +} from './types' +import { firstStructureError } from './validate' + +/** Raw record shape a future `GET /structures/{id}` endpoint would return. */ +export interface RawStructureRecord { + id?: unknown + name?: unknown + molecule_name?: unknown + kind?: unknown + molecule_type?: unknown + organism?: unknown + description?: unknown + chains?: unknown + atoms?: unknown + bonds?: unknown + metadata?: unknown + [key: string]: unknown +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function asKind(value: unknown): StructureKind | undefined { + return value === 'protein' || value === 'nucleic-acid' || value === 'other' ? value : undefined +} + +function toAtom(value: unknown): StructureAtom | undefined { + if (!isObject(value)) return undefined + const index = asNumber(value.index) ?? asNumber(value.serial) + const x = asNumber(value.x) ?? asNumber(value.position_x) + const y = asNumber(value.y) ?? asNumber(value.position_y) + const z = asNumber(value.z) ?? asNumber(value.position_z) + const residueIndex = + asNumber(value.residue_index) ?? asNumber(value.residue_seq_number) ?? asNumber(value.residue) + const chainId = asString(value.chain_id) ?? asString(value.chain) + const element = (asString(value.element) ?? asString(value.element_symbol) ?? '').toUpperCase() + if (index === undefined || x === undefined || y === undefined || z === undefined) { + return undefined + } + return { + index, + element, + x, + y, + z, + residueIndex: residueIndex ?? 0, + chainId: chainId ?? '', + ...(asString(value.residue_name) !== undefined + ? { residueName: asString(value.residue_name) } + : {}), + ...(asString(value.atom_name) !== undefined ? { atomName: asString(value.atom_name) } : {}), + } +} + +function toBond(value: unknown): StructureBond | undefined { + if (!isObject(value)) return undefined + const atomA = asNumber(value.atom_a) ?? asNumber(value.atom1) + const atomB = asNumber(value.atom_b) ?? asNumber(value.atom2) + const order = asNumber(value.order) + if (atomA === undefined || atomB === undefined) return undefined + return { atomA, atomB, ...(order !== undefined ? { order } : {}) } +} + +function toResidue(value: unknown): StructureResidue | undefined { + if (!isObject(value)) return undefined + const index = asNumber(value.index) ?? asNumber(value.residue_number) + const atomIndices = Array.isArray(value.atom_indices) + ? value.atom_indices + .map((entry: unknown) => (typeof entry === 'number' ? entry : asNumber(entry))) + .filter((entry: number | undefined): entry is number => entry !== undefined) + : [] + if (index === undefined) return undefined + return { + index, + ...(asString(value.name) !== undefined ? { name: asString(value.name) } : {}), + atomIndices, + } +} + +function toChain(value: unknown): StructureChain | undefined { + if (!isObject(value)) return undefined + const id = asString(value.id) ?? asString(value.chain_id) + const residues = Array.isArray(value.residues) + ? value.residues + .map(toResidue) + .filter((residue): residue is StructureResidue => residue !== undefined) + : [] + if (id === undefined) return undefined + return { id, residues } +} + +function toMetadata(value: unknown): Record | undefined { + if (!isObject(value)) return undefined + const entries = Object.entries(value).filter( + (entry): entry is [string, string | number | boolean] => { + const [, field] = entry + return typeof field === 'string' || typeof field === 'number' || typeof field === 'boolean' + }, + ) + return entries.length > 0 ? Object.fromEntries(entries) : undefined +} + +/** + * Normalizes a raw structure record into the canonical `MolecularStructure`. + * Records that are not objects (or lack coordinates) are dropped; the result + * should be validated with `validateStructure` before rendering. + */ +export function toStructure(item: RawStructureRecord): MolecularStructure { + const id = asString(item.id) ?? asString(item.structure_id) ?? '' + const atoms = Array.isArray(item.atoms) + ? item.atoms.map(toAtom).filter((atom): atom is StructureAtom => atom !== undefined) + : [] + const bonds = Array.isArray(item.bonds) + ? item.bonds.map(toBond).filter((bond): bond is StructureBond => bond !== undefined) + : [] + const chains = Array.isArray(item.chains) + ? item.chains.map(toChain).filter((chain): chain is StructureChain => chain !== undefined) + : [] + + const name = asString(item.name) ?? asString(item.molecule_name) + const kind = asKind(item.kind) ?? asKind(item.molecule_type) + const organism = asString(item.organism) + const description = asString(item.description) + const metadata = toMetadata(item.metadata) + + return { + id, + ...(name !== undefined ? { name } : {}), + ...(kind !== undefined ? { kind } : {}), + ...(organism !== undefined ? { organism } : {}), + ...(description !== undefined ? { description } : {}), + chains, + atoms, + bonds, + ...(metadata !== undefined ? { metadata } : {}), + } +} + +/** True when a raw record could plausibly describe a structure. */ +export function isStructureRecord(value: unknown): value is RawStructureRecord { + return isObject(value) +} + +/** + * Fetches a single structure by id and normalizes it, reusing the caller's + * `AbortSignal`. Only usable once the backend exposes `GET /structures/{id}`; + * it throws a descriptive error otherwise. + */ +export async function fetchMolecularStructure( + structureId: string, + signal?: AbortSignal, +): Promise { + const response = await fetch(`${API_BASE_URL}/structures/${encodeURIComponent(structureId)}`, { + headers: { 'Content-Type': 'application/json' }, + signal, + }) + + if (!response.ok) { + throw new GenomeApiError( + `GenomeAI API returned ${response.status} for structure ${structureId}`, + response.status, + ) + } + + const payload = (await response.json()) as RawStructureRecord | null + if (!isStructureRecord(payload)) { + throw new GenomeApiError( + `GenomeAI API returned an invalid payload for structure ${structureId}`, + ) + } + + const structure = toStructure(payload) + const error = firstStructureError(structure) + if (error !== null) { + throw new GenomeApiError( + `GenomeAI API returned an invalid structure for ${structureId}: ${error}`, + ) + } + return structure +} diff --git a/apps/web/src/lib/molecular/geometry.test.ts b/apps/web/src/lib/molecular/geometry.test.ts new file mode 100644 index 0000000..af3b3a1 --- /dev/null +++ b/apps/web/src/lib/molecular/geometry.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' + +import { + backboneTrace, + cameraFocusForStructure, + elementColor, + elementCounts, + elementPresentation, + structureBounds, + structureCentroid, + structureRadius, + structureSummary, +} from './geometry' +import type { MolecularStructure } from './types' + +function structure(overrides: Partial = {}): MolecularStructure { + return { + id: 'geom', + name: 'Test helix', + chains: [ + { + id: 'A', + residues: [ + { index: 1, name: 'ALA', atomIndices: [1, 2] }, + { index: 2, name: 'GLY', atomIndices: [3] }, + ], + }, + { id: 'B', residues: [{ index: 1, atomIndices: [4] }] }, + ], + atoms: [ + { index: 1, element: 'C', x: 0, y: 0, z: 0, residueIndex: 1, chainId: 'A', atomName: 'CA' }, + { index: 2, element: 'N', x: 2, y: 0, z: 0, residueIndex: 1, chainId: 'A', atomName: 'N' }, + { index: 3, element: 'O', x: 0, y: 4, z: 0, residueIndex: 2, chainId: 'A', atomName: 'CA' }, + { index: 4, element: 'S', x: 0, y: 0, z: 6, residueIndex: 1, chainId: 'B', atomName: 'CA' }, + ], + bonds: [ + { atomA: 1, atomB: 2 }, + { atomA: 3, atomB: 4 }, + ], + ...overrides, + } +} + +describe('elementPresentation', () => { + it('returns CPK defaults for a known element', () => { + expect(elementPresentation('C').color).toBe('909090') + expect(elementPresentation('o').color).toBe('ff0d0d') + }) + + it('falls back to a default presentation for unknown elements', () => { + expect(elementPresentation('Xe').color).toBe('ff7f7f') + }) + + it('exposes elementColor', () => { + expect(elementColor('FE')).toBe('e06633') + }) + + it('keeps van der Waals radii for space filling larger than ball radii', () => { + for (const symbol of ['C', 'N', 'O', 'S', 'P', 'ZN']) { + const presentation = elementPresentation(symbol) + expect(presentation.vanDerWaalsRadius).toBeGreaterThan(presentation.ballRadius) + } + }) +}) + +describe('structureBounds', () => { + it('returns undefined for no atoms', () => { + expect(structureBounds([])).toBeUndefined() + }) + + it('computes the bounding box', () => { + const bounds = structureBounds(structure().atoms) + expect(bounds).toEqual({ + min: { x: 0, y: 0, z: 0 }, + max: { x: 2, y: 4, z: 6 }, + }) + }) +}) + +describe('structureCentroid and structureRadius', () => { + it('returns the mean position', () => { + expect(structureCentroid(structure().atoms)).toEqual({ x: 0.5, y: 1, z: 1.5 }) + }) + + it('returns the origin for an empty structure', () => { + expect(structureCentroid([])).toEqual({ x: 0, y: 0, z: 0 }) + }) + + it('computes the largest distance from the centroid', () => { + const centroid = structureCentroid(structure().atoms) + const radius = structureRadius(structure().atoms, centroid) + expect(radius).toBeCloseTo(Math.sqrt(21.5)) + }) +}) + +describe('cameraFocusForStructure', () => { + it('frames around the centroid with a minimum radius', () => { + const focus = cameraFocusForStructure(structure()) + expect(focus.target).toEqual({ x: 0.5, y: 1, z: 1.5 }) + expect(focus.radius).toBeGreaterThanOrEqual(1) + }) +}) + +describe('backboneTrace', () => { + it('traces one point per residue, preferring the CA atom', () => { + const trace = backboneTrace(structure()) + expect(trace.get('A')).toEqual([ + { x: 0, y: 0, z: 0 }, + { x: 0, y: 4, z: 0 }, + ]) + expect(trace.get('B')).toEqual([{ x: 0, y: 0, z: 6 }]) + }) + + it('falls back to the first atom of a residue without a CA', () => { + const modified = structure() + modified.chains[0].residues[1].atomIndices = [5] + modified.atoms = [ + ...modified.atoms, + { index: 5, element: 'C', x: 9, y: 9, z: 9, residueIndex: 2, chainId: 'A', atomName: 'N' }, + ] + const trace = backboneTrace(modified) + expect(trace.get('A')?.[1]).toEqual({ x: 9, y: 9, z: 9 }) + }) + + it('omits chains with no residues', () => { + const modified = structure() + modified.chains = [{ id: 'C', residues: [] }] + expect(backboneTrace(modified).size).toBe(0) + }) +}) + +describe('elementCounts and structureSummary', () => { + it('tallies atoms per element case-insensitively', () => { + expect(elementCounts(structure().atoms)).toEqual({ C: 1, N: 1, O: 1, S: 1 }) + }) + + it('summarizes chains, residues, atoms, and bonds', () => { + const summary = structureSummary(structure()) + expect(summary.name).toBe('Test helix') + expect(summary.chains).toBe(2) + expect(summary.residues).toBe(3) + expect(summary.atoms).toBe(4) + expect(summary.bonds).toBe(2) + expect(summary.elements.C).toBe(1) + expect(summary.radius).toBeGreaterThan(0) + }) +}) diff --git a/apps/web/src/lib/molecular/geometry.ts b/apps/web/src/lib/molecular/geometry.ts new file mode 100644 index 0000000..3f0a50a --- /dev/null +++ b/apps/web/src/lib/molecular/geometry.ts @@ -0,0 +1,187 @@ +/** + * Pure structure geometry (Phase 6.12). + * + * Coordinate math, element presentation, and camera framing for the + * Molecular Structure Viewer. Everything here is plain data — no Three.js, + * no DOM, no WebGL — so it is fully unit-testable and shared by the viewer + * and its tests. + */ + +import type { MolecularStructure, StructureAtom } from './types' + +/** A point in 3D space (angstroms). */ +export interface Point3 { + x: number + y: number + z: number +} + +/** Presentation defaults for a chemical element. */ +export interface ElementPresentation { + /** CPK-style display colour as a hex string (no leading `#`). */ + color: string + /** Van der Waals radius in angstroms (space-filling spheres). */ + vanDerWaalsRadius: number + /** Ball-and-stick sphere radius in angstroms. */ + ballRadius: number +} + +const ELEMENTS: Record = { + C: { color: '909090', vanDerWaalsRadius: 1.7, ballRadius: 0.35 }, + N: { color: '3050f8', vanDerWaalsRadius: 1.55, ballRadius: 0.35 }, + O: { color: 'ff0d0d', vanDerWaalsRadius: 1.52, ballRadius: 0.35 }, + S: { color: 'ffff30', vanDerWaalsRadius: 1.8, ballRadius: 0.4 }, + H: { color: 'ffffff', vanDerWaalsRadius: 1.2, ballRadius: 0.2 }, + P: { color: 'ff8000', vanDerWaalsRadius: 1.8, ballRadius: 0.4 }, + FE: { color: 'e06633', vanDerWaalsRadius: 1.8, ballRadius: 0.45 }, + ZN: { color: '7f80cc', vanDerWaalsRadius: 1.39, ballRadius: 0.45 }, + CL: { color: '1ff01f', vanDerWaalsRadius: 1.75, ballRadius: 0.4 }, + BR: { color: 'a62929', vanDerWaalsRadius: 1.85, ballRadius: 0.45 }, + CA: { color: '3dff3d', vanDerWaalsRadius: 1.8, ballRadius: 0.45 }, +} + +/** Default presentation used for any element without an entry. */ +const DEFAULT_ELEMENT: ElementPresentation = { + color: 'ff7f7f', + vanDerWaalsRadius: 1.6, + ballRadius: 0.35, +} + +/** Atom serial → atom lookup for the geometry helpers. */ +export type AtomIndex = Map + +export function atomIndex(atoms: StructureAtom[]): AtomIndex { + return new Map(atoms.map((atom) => [atom.index, atom])) +} + +/** Presentation defaults for an element symbol (uppercase, unknown-safe). */ +export function elementPresentation(element: string): ElementPresentation { + return ELEMENTS[element.toUpperCase()] ?? DEFAULT_ELEMENT +} + +/** Display colour (hex, no `#`) for an element symbol. */ +export function elementColor(element: string): string { + return elementPresentation(element).color +} + +/** Bounding-box span of a set of atoms, or `undefined` when empty. */ +export function structureBounds(atoms: StructureAtom[]): { min: Point3; max: Point3 } | undefined { + if (atoms.length === 0) return undefined + const min = { + x: Number.POSITIVE_INFINITY, + y: Number.POSITIVE_INFINITY, + z: Number.POSITIVE_INFINITY, + } + const max = { + x: Number.NEGATIVE_INFINITY, + y: Number.NEGATIVE_INFINITY, + z: Number.NEGATIVE_INFINITY, + } + for (const atom of atoms) { + min.x = Math.min(min.x, atom.x) + min.y = Math.min(min.y, atom.y) + min.z = Math.min(min.z, atom.z) + max.x = Math.max(max.x, atom.x) + max.y = Math.max(max.y, atom.y) + max.z = Math.max(max.z, atom.z) + } + return { min, max } +} + +/** Centroid (mean) of all atoms, or `{0,0,0}` when empty. */ +export function structureCentroid(atoms: StructureAtom[]): Point3 { + if (atoms.length === 0) return { x: 0, y: 0, z: 0 } + let x = 0 + let y = 0 + let z = 0 + for (const atom of atoms) { + x += atom.x + y += atom.y + z += atom.z + } + const count = atoms.length + return { x: x / count, y: y / count, z: z / count } +} + +/** Largest distance from the centroid to any atom (angstroms). */ +export function structureRadius(atoms: StructureAtom[], centroid: Point3): number { + let radius = 0 + for (const atom of atoms) { + const dx = atom.x - centroid.x + const dy = atom.y - centroid.y + const dz = atom.z - centroid.z + radius = Math.max(radius, Math.sqrt(dx * dx + dy * dy + dz * dz)) + } + return radius +} + +/** Camera framing for a structure: the point to look at and its radius. */ +export function cameraFocusForStructure(structure: MolecularStructure): { + target: Point3 + radius: number +} { + const centroid = structureCentroid(structure.atoms) + return { target: centroid, radius: Math.max(structureRadius(structure.atoms, centroid), 1) } +} + +/** + * Backbone trace points per chain, in residue order. Uses the C-alpha atom of + * each residue when present, otherwise the first atom of the residue — the + * simplest meaningful spline backbone for the cartoon representation. + */ +export function backboneTrace( + structure: MolecularStructure, + atoms: StructureAtom[] = structure.atoms, +): Map { + const byChain = new Map() + const byIndex = atomIndex(atoms) + for (const chain of structure.chains) { + const points: Point3[] = [] + for (const residue of chain.residues) { + const residueAtoms = residue.atomIndices + .map((serial) => byIndex.get(serial)) + .filter((atom): atom is StructureAtom => atom !== undefined) + const backbone = + residueAtoms.find((atom) => (atom.atomName ?? '').toUpperCase() === 'CA') ?? residueAtoms[0] + if (backbone !== undefined) { + points.push({ x: backbone.x, y: backbone.y, z: backbone.z }) + } + } + if (points.length > 0) byChain.set(chain.id, points) + } + return byChain +} + +/** Tally of atoms per element symbol, keyed by uppercase symbol. */ +export function elementCounts(atoms: StructureAtom[]): Record { + const counts: Record = {} + for (const atom of atoms) { + const element = atom.element.toUpperCase() + counts[element] = (counts[element] ?? 0) + 1 + } + return counts +} + +/** Human-readable summary of a structure for status lines and tests. */ +export function structureSummary(structure: MolecularStructure): { + name: string + chains: number + residues: number + atoms: number + bonds: number + elements: Record + radius: number + centroid: Point3 +} { + const residues = structure.chains.reduce((total, chain) => total + chain.residues.length, 0) + return { + name: structure.name ?? structure.id, + chains: structure.chains.length, + residues, + atoms: structure.atoms.length, + bonds: structure.bonds.length, + elements: elementCounts(structure.atoms), + radius: structureRadius(structure.atoms, structureCentroid(structure.atoms)), + centroid: structureCentroid(structure.atoms), + } +} diff --git a/apps/web/src/lib/molecular/molecular.fixtures.test.ts b/apps/web/src/lib/molecular/molecular.fixtures.test.ts new file mode 100644 index 0000000..a83c76b --- /dev/null +++ b/apps/web/src/lib/molecular/molecular.fixtures.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' + +import { P53_HELIX_STRUCTURE_FIXTURE } from './molecular.fixtures' +import { isValidStructure, validateStructure } from './validate' + +describe('P53_HELIX_STRUCTURE_FIXTURE', () => { + it('is a structurally valid, usable fixture', () => { + expect(validateStructure(P53_HELIX_STRUCTURE_FIXTURE)).toEqual([]) + expect(isValidStructure(P53_HELIX_STRUCTURE_FIXTURE)).toBe(true) + }) + + it('is stable across repeated access (deterministic generation)', () => { + expect(JSON.stringify(P53_HELIX_STRUCTURE_FIXTURE.atoms)).toBe( + JSON.stringify(P53_HELIX_STRUCTURE_FIXTURE.atoms), + ) + const bonds = P53_HELIX_STRUCTURE_FIXTURE.bonds + expect(bonds.length).toBeGreaterThan(0) + expect(P53_HELIX_STRUCTURE_FIXTURE.chains).toHaveLength(1) + expect(P53_HELIX_STRUCTURE_FIXTURE.chains[0].id).toBe('A') + expect(P53_HELIX_STRUCTURE_FIXTURE.kind).toBe('protein') + }) + + it('describes a small polymer: one chain, many residues, more atoms than residues', () => { + const residues = P53_HELIX_STRUCTURE_FIXTURE.chains[0].residues.length + expect(residues).toBeGreaterThanOrEqual(10) + expect(P53_HELIX_STRUCTURE_FIXTURE.atoms.length).toBeGreaterThan(residues) + expect(P53_HELIX_STRUCTURE_FIXTURE.bonds.length).toBeGreaterThan(residues) + }) + + it('uses the synthetic marker metadata', () => { + expect(P53_HELIX_STRUCTURE_FIXTURE.metadata?.source).toBe('fixture') + }) +}) diff --git a/apps/web/src/lib/molecular/molecular.fixtures.ts b/apps/web/src/lib/molecular/molecular.fixtures.ts new file mode 100644 index 0000000..dc79ddd --- /dev/null +++ b/apps/web/src/lib/molecular/molecular.fixtures.ts @@ -0,0 +1,152 @@ +/** + * Development fixture for the Molecular Structure Viewer (Phase 6.12). + * + * The GenomeAI backend does **not yet expose any molecular structure + * endpoint**. This module provides a small, clearly isolated, synthetic + * structure that mimics what a future structure source would return, so the + * viewer, its geometry math, and its tests can be developed now. + * + * ## Boundary + * + * This is a **development fixture, not a real API or a real molecule**. It is + * generated deterministically (a straight alpha-helix-like trace with + * backbone atoms N/CA/C/O per residue and a few side-chain atoms) purely so + * the 3D rendering pipeline has realistic input. It flows through the same + * `toStructure` normalizer the production adapter uses, so the seam is + * exercised exactly as production would. See + * `docs/visualization/molecular-structure.md`. + */ + +import { type RawStructureRecord, toStructure } from './api' +import type { MolecularStructure } from './types' + +const RESIDUE_NAMES = [ + 'MET', + 'GLU', + 'GLU', + 'PRO', + 'GLN', + 'SER', + 'ASP', + 'PRO', + 'SER', + 'VAL', + 'GLU', + 'PRO', + 'PRO', + 'LEU', + 'SER', +] + +const RESIDUES_COUNT = RESIDUE_NAMES.length +const HELIX_RADIUS = 4.5 +const RISE_PER_RESIDUE = 1.5 +const RESIDUES_PER_TURN = 3.6 + +/** + * Synthetic raw structure record: a short alpha-helix-like trace over the + * N-terminal residues of TP53. Coordinates are generated deterministically + * (no random values), so the fixture is stable for tests and rendering. + */ +function buildRawFixture(): RawStructureRecord { + const atoms: unknown[] = [] + const bonds: unknown[] = [] + const residues: unknown[] = [] + const chainId = 'A' + + let serial = 0 + const previousCarbon: number[] = [] + const cAlphaByResidue: number[] = [] + + for (let residueIndex = 0; residueIndex < RESIDUES_COUNT; residueIndex++) { + const angle = (residueIndex / RESIDUES_PER_TURN) * 2 * Math.PI + const z = residueIndex * RISE_PER_RESIDUE + const caX = HELIX_RADIUS * Math.cos(angle) + const caY = HELIX_RADIUS * Math.sin(angle) + + const backbone = [ + { name: 'N', dx: 0.5, dy: 0.4, dz: -0.6 }, + { name: 'CA', dx: 0, dy: 0, dz: 0 }, + { name: 'C', dx: -0.4, dy: -0.5, dz: 0.6 }, + { name: 'O', dx: -0.9, dy: -0.6, dz: 0.6 }, + ] + + const residueAtoms: number[] = [] + for (const position of backbone) { + serial += 1 + residueAtoms.push(serial) + const isCarbon = position.name === 'CA' || position.name === 'C' + atoms.push({ + index: serial, + element: position.name === 'O' ? 'O' : isCarbon ? 'C' : 'N', + atom_name: position.name, + residue_name: RESIDUE_NAMES[residueIndex], + residue_index: residueIndex + 1, + chain_id: chainId, + x: caX + position.dx, + y: caY + position.dy, + z: z + position.dz, + }) + if (position.name === 'CA') cAlphaByResidue.push(serial) + } + + // Peptide bond from the previous residue's carbonyl carbon to this N. + if (previousCarbon.length > 0) { + bonds.push({ atom_a: previousCarbon[0], atom_b: residueAtoms[0], order: 1 }) + } + bonds.push({ atom_a: residueAtoms[0], atom_b: residueAtoms[1], order: 1 }) + bonds.push({ atom_a: residueAtoms[1], atom_b: residueAtoms[2], order: 1 }) + bonds.push({ atom_a: residueAtoms[2], atom_b: residueAtoms[3], order: 1 }) + previousCarbon[0] = residueAtoms[2] + + // A few side-chain atoms (CA-CB stub) for visual variety. + if (residueIndex % 3 === 0) { + serial += 1 + atoms.push({ + index: serial, + element: 'C', + atom_name: 'CB', + residue_name: RESIDUE_NAMES[residueIndex], + residue_index: residueIndex + 1, + chain_id: chainId, + x: caX + 1.1, + y: caY - 0.4, + z: z + 0.3, + }) + bonds.push({ atom_a: cAlphaByResidue[residueIndex], atom_b: serial, order: 1 }) + residueAtoms.push(serial) + } + + residues.push({ + index: residueIndex + 1, + name: RESIDUE_NAMES[residueIndex], + atom_indices: residueAtoms, + }) + } + + return { + id: 'fixture-mini-p53-helix', + name: 'p53 N-terminal helix (synthetic fixture)', + kind: 'protein', + organism: 'Homo sapiens', + description: + 'Synthetic alpha-helix-like trace over the N-terminal residues of TP53. Development fixture, not a real molecule.', + chains: [{ id: chainId, residues }], + atoms, + bonds, + metadata: { source: 'fixture', format: 'synthetic-helix' }, + } +} + +/** The raw fixture record, routed through the production normalizer. */ +export const P53_HELIX_STRUCTURE_FIXTURE: MolecularStructure = toStructure(buildRawFixture()) + +/** A deliberately empty structure fixture for the empty-state tests/demo. */ +export const EMPTY_STRUCTURE_FIXTURE: MolecularStructure = toStructure({ + id: 'fixture-empty', + name: 'Empty structure', + kind: 'protein', + chains: [], + atoms: [], + bonds: [], +}) diff --git a/apps/web/src/lib/molecular/render/representationBuilder.test.ts b/apps/web/src/lib/molecular/render/representationBuilder.test.ts new file mode 100644 index 0000000..ec2b14d --- /dev/null +++ b/apps/web/src/lib/molecular/render/representationBuilder.test.ts @@ -0,0 +1,107 @@ +import type * as THREE from 'three' +import { describe, expect, it, vi } from 'vitest' + +import type { MolecularStructure } from '../types' +import { buildStructureGroup, disposeGroup } from './representationBuilder' + +function structure(): MolecularStructure { + return { + id: 'builder', + chains: [ + { + id: 'A', + residues: [ + { index: 1, atomIndices: [1, 2] }, + { index: 2, atomIndices: [3, 4] }, + ], + }, + ], + atoms: [ + { index: 1, element: 'C', x: 0, y: 0, z: 0, residueIndex: 1, chainId: 'A', atomName: 'CA' }, + { + index: 2, + element: 'N', + x: 0.5, + y: 0.4, + z: -0.6, + residueIndex: 1, + chainId: 'A', + atomName: 'N', + }, + { index: 3, element: 'C', x: 4, y: 0, z: 0, residueIndex: 2, chainId: 'A', atomName: 'CA' }, + { + index: 4, + element: 'O', + x: 4.4, + y: -0.6, + z: 0.5, + residueIndex: 2, + chainId: 'A', + atomName: 'O', + }, + ], + bonds: [ + { atomA: 1, atomB: 2 }, + { atomA: 2, atomB: 3 }, + { atomA: 3, atomB: 4 }, + ], + } +} + +function countMeshes(group: THREE.Group): number { + let count = 0 + group.traverse((object) => { + if ((object as THREE.Mesh).isMesh) count += 1 + }) + return count +} + +describe('buildStructureGroup', () => { + it('builds one sphere per atom for ball-and-stick', () => { + const group = buildStructureGroup(structure(), 'ball-and-stick') + expect(countMeshes(group)).toBe(4 + 3) + }) + + it('builds spheres only for space-filling (no bonds)', () => { + const group = buildStructureGroup(structure(), 'space-filling') + expect(countMeshes(group)).toBe(4) + }) + + it('builds a cartoon ribbon tube per chain backbone', () => { + const group = buildStructureGroup(structure(), 'cartoon') + const ribbons = group.children.filter( + (child) => (child as THREE.Mesh).isMesh && child.name.startsWith('cartoon-'), + ) + expect(ribbons).toHaveLength(1) + }) + + it('returns an empty group for a structure without atoms', () => { + const empty: MolecularStructure = { id: 'empty', chains: [], atoms: [], bonds: [] } + expect(buildStructureGroup(empty, 'ball-and-stick').children).toHaveLength(0) + expect(buildStructureGroup(empty, 'cartoon').children).toHaveLength(0) + }) +}) + +describe('disposeGroup', () => { + it('disposes every geometry and material owned by the group', () => { + const group = buildStructureGroup(structure(), 'ball-and-stick') + const disposeCalls: string[] = [] + group.traverse((object) => { + const mesh = object as THREE.Mesh + if (mesh.isMesh) { + const geometry = mesh.geometry + const material = mesh.material as THREE.Material + vi.spyOn(geometry, 'dispose').mockImplementation(() => { + disposeCalls.push('geometry') + }) + vi.spyOn(material, 'dispose').mockImplementation(() => { + disposeCalls.push('material') + }) + } + }) + + disposeGroup(group) + expect(disposeCalls.filter((entry) => entry === 'geometry')).toHaveLength(7) + expect(disposeCalls.filter((entry) => entry === 'material')).toHaveLength(7) + }) +}) diff --git a/apps/web/src/lib/molecular/render/representationBuilder.ts b/apps/web/src/lib/molecular/render/representationBuilder.ts new file mode 100644 index 0000000..c7c5eeb --- /dev/null +++ b/apps/web/src/lib/molecular/render/representationBuilder.ts @@ -0,0 +1,135 @@ +/** + * Structure representation builder (Phase 6.12). + * + * Builds the Three.js object graph for one representation of a structure. + * This module only creates Three.js **objects** (geometries, materials, + * meshes, curves) — it never touches WebGL — so it is fully unit-testable in + * jsdom. Rendering and lifecycle live in `threeViewer.ts`. + * + * Representation extensions: add a branch in `buildStructureGroup` and a + * catalog entry in `lib/molecular/representations.ts`. + */ + +import * as THREE from 'three' + +import { backboneTrace, elementPresentation } from '@/lib/molecular/geometry' +import type { Point3 } from '@/lib/molecular/geometry' +import type { RepresentationId } from '@/lib/molecular/representations' +import type { MolecularStructure, StructureAtom } from '@/lib/molecular/types' + +/** Colour used by the cartoon ribbon. */ +const CARTOON_COLOR = 0x4f7cc0 +/** Radius of the cartoon ribbon tube, in angstroms. */ +const CARTOON_TUBE_RADIUS = 0.6 +/** Cylinder radius for ball-and-stick bonds, in angstroms. */ +const BOND_RADIUS = 0.14 + +function atomMesh(atom: StructureAtom, radius: number): THREE.Mesh { + const presentation = elementPresentation(atom.element) + const geometry = new THREE.SphereGeometry(radius, 16, 12) + const material = new THREE.MeshStandardMaterial({ color: `#${presentation.color}` }) + const mesh = new THREE.Mesh(geometry, material) + mesh.position.set(atom.x, atom.y, atom.z) + return mesh +} + +/** + * A cylinder between two points, oriented along the bond axis. Returns a + * mesh whose disposal must be tracked alongside its geometry/material. + */ +function bondCylinder(a: Point3, b: Point3, radius: number): THREE.Mesh { + const start = new THREE.Vector3(a.x, a.y, a.z) + const end = new THREE.Vector3(b.x, b.y, b.z) + const direction = new THREE.Vector3().subVectors(end, start) + const length = direction.length() + const geometry = new THREE.CylinderGeometry(radius, radius, length, 8, 1) + const material = new THREE.MeshStandardMaterial({ color: 0xb0b0b0 }) + const mesh = new THREE.Mesh(geometry, material) + const midpoint = new THREE.Vector3().addVectors(start, end).multiplyScalar(0.5) + mesh.position.copy(midpoint) + if (length > 0) { + mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction.clone().normalize()) + } + return mesh +} + +function buildBallAndStick(structure: MolecularStructure): THREE.Group { + const group = new THREE.Group() + const byIndex = new Map(structure.atoms.map((atom) => [atom.index, atom])) + + for (const atom of structure.atoms) { + group.add(atomMesh(atom, elementPresentation(atom.element).ballRadius)) + } + for (const bond of structure.bonds) { + const atomA = byIndex.get(bond.atomA) + const atomB = byIndex.get(bond.atomB) + if (atomA === undefined || atomB === undefined) continue + group.add(bondCylinder(atomA, atomB, BOND_RADIUS)) + } + return group +} + +function buildSpaceFilling(structure: MolecularStructure): THREE.Group { + const group = new THREE.Group() + for (const atom of structure.atoms) { + group.add(atomMesh(atom, elementPresentation(atom.element).vanDerWaalsRadius)) + } + return group +} + +function buildCartoon(structure: MolecularStructure): THREE.Group { + const group = new THREE.Group() + const traces = backboneTrace(structure) + for (const [chainId, points] of traces) { + if (points.length < 2) continue + const curve = new THREE.CatmullRomCurve3( + points.map((point) => new THREE.Vector3(point.x, point.y, point.z)), + ) + const geometry = new THREE.TubeGeometry( + curve, + Math.max(points.length * 4, 16), + CARTOON_TUBE_RADIUS, + 8, + false, + ) + const material = new THREE.MeshStandardMaterial({ color: CARTOON_COLOR }) + const mesh = new THREE.Mesh(geometry, material) + mesh.name = `cartoon-${chainId}` + group.add(mesh) + } + return group +} + +/** + * Builds the object graph for a representation. Callers own the returned + * group and must dispose its geometries/materials (see `disposeGroup`). + */ +export function buildStructureGroup( + structure: MolecularStructure, + representation: RepresentationId, +): THREE.Group { + switch (representation) { + case 'ball-and-stick': + return buildBallAndStick(structure) + case 'space-filling': + return buildSpaceFilling(structure) + case 'cartoon': + return buildCartoon(structure) + } +} + +/** Disposes every geometry and material owned by a group (recursively). */ +export function disposeGroup(group: THREE.Object3D): void { + group.traverse((object) => { + const mesh = object as THREE.Mesh + if (mesh.isMesh) { + mesh.geometry?.dispose() + const material = mesh.material + if (Array.isArray(material)) { + for (const entry of material) entry.dispose() + } else { + material?.dispose() + } + } + }) +} diff --git a/apps/web/src/lib/molecular/render/threeViewer.test.ts b/apps/web/src/lib/molecular/render/threeViewer.test.ts new file mode 100644 index 0000000..ff28055 --- /dev/null +++ b/apps/web/src/lib/molecular/render/threeViewer.test.ts @@ -0,0 +1,176 @@ +import * as THREE from 'three' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { MolecularStructure } from '../types' +import { createThreeViewer } from './threeViewer' +import type { ThreeRenderer } from './types' + +interface FakeRendererHarness { + renderer: ThreeRenderer & { + setPixelRatio: ReturnType + setSize: ReturnType + setClearColor: ReturnType + dispose: ReturnType + } + loop: ((time: number) => void) | null + lastScene: unknown + lastCamera: unknown + setAnimationLoopCalls: Array<((time: number) => void) | null> +} + +function createFakeRenderer(): FakeRendererHarness { + const harness: FakeRendererHarness = { + renderer: { + domElement: document.createElement('canvas'), + setPixelRatio: vi.fn(), + setSize: vi.fn(), + setClearColor: vi.fn(), + setAnimationLoop: vi.fn(), + render: vi.fn(), + dispose: vi.fn(), + }, + loop: null, + lastScene: undefined, + lastCamera: undefined, + setAnimationLoopCalls: [], + } + const renderer = harness.renderer + renderer.setAnimationLoop = vi.fn((callback: ((time: number) => void) | null) => { + harness.setAnimationLoopCalls.push(callback) + harness.loop = callback + }) + renderer.render = vi.fn((scene: unknown, camera: unknown) => { + harness.lastScene = scene + harness.lastCamera = camera + }) + return harness +} + +class MockResizeObserver implements ResizeObserver { + callback: ResizeObserverCallback + observed = new Set() + constructor(callback: ResizeObserverCallback) { + this.callback = callback + } + observe(target: Element) { + this.observed.add(target) + } + unobserve(target: Element) { + this.observed.delete(target) + } + disconnect() { + this.observed.clear() + } +} + +function structure(): MolecularStructure { + return { + id: 'viewer', + chains: [ + { + id: 'A', + residues: [ + { index: 1, atomIndices: [1] }, + { index: 2, atomIndices: [2] }, + ], + }, + ], + atoms: [ + { index: 1, element: 'C', x: 0, y: 0, z: 0, residueIndex: 1, chainId: 'A', atomName: 'CA' }, + { index: 2, element: 'N', x: 4, y: 0, z: 0, residueIndex: 2, chainId: 'A', atomName: 'CA' }, + ], + bonds: [], + } +} + +let previousResizeObserver: typeof ResizeObserver | undefined + +afterEach(() => { + if (previousResizeObserver === undefined) { + ;(globalThis as Record).ResizeObserver = undefined + } else { + globalThis.ResizeObserver = previousResizeObserver + } + previousResizeObserver = undefined + vi.restoreAllMocks() +}) + +describe('createThreeViewer', () => { + it('appends the canvas and renders frames through the animation loop', () => { + const fake = createFakeRenderer() + const container = document.createElement('div') + const viewer = createThreeViewer(container, { createRenderer: () => fake.renderer }) + + expect(container.contains(fake.renderer.domElement)).toBe(true) + viewer.setStructure(structure(), 'cartoon') + fake.loop?.(0) + + expect(fake.renderer.render).toHaveBeenCalledTimes(1) + expect(fake.lastCamera).toBeInstanceOf(THREE.PerspectiveCamera) + expect(fake.lastScene).toBeInstanceOf(THREE.Scene) + }) + + it('frames the camera around a target with a distance proportional to the radius', () => { + const fake = createFakeRenderer() + const container = document.createElement('div') + const viewer = createThreeViewer(container, { createRenderer: () => fake.renderer }) + + fake.loop?.(0) + const camera = fake.lastCamera as THREE.PerspectiveCamera + + viewer.focusCamera({ x: 0, y: 0, z: 0 }, 5) + expect(camera.position.length()).toBeCloseTo(15) + expect(camera.near).toBeCloseTo(0.05) + expect(camera.far).toBe(250) + + viewer.focusCamera({ x: 10, y: 0, z: 0 }, 1) + const position = camera.position + const distance = new THREE.Vector3(position.x - 10, position.y, position.z).length() + expect(distance).toBeCloseTo(Math.max(1 * 3, 2)) + }) + + it('handles an explicit resize', () => { + const fake = createFakeRenderer() + const container = document.createElement('div') + const viewer = createThreeViewer(container, { createRenderer: () => fake.renderer }) + viewer.resize(400, 300) + expect(fake.renderer.setSize).toHaveBeenCalledWith(400, 300) + }) + + it('observes the container size when ResizeObserver is available', () => { + previousResizeObserver = globalThis.ResizeObserver + globalThis.ResizeObserver = MockResizeObserver + const fake = createFakeRenderer() + const container = document.createElement('div') + const viewer = createThreeViewer(container, { createRenderer: () => fake.renderer }) + viewer.setStructure(structure(), 'ball-and-stick') + expect(globalThis.ResizeObserver).toBe(MockResizeObserver) + viewer.dispose() + }) + + it('disposes the renderer, stops the loop, and detaches the canvas', () => { + const fake = createFakeRenderer() + const container = document.createElement('div') + const viewer = createThreeViewer(container, { createRenderer: () => fake.renderer }) + viewer.setStructure(structure(), 'space-filling') + + viewer.dispose() + expect(fake.renderer.dispose).toHaveBeenCalledTimes(1) + expect(fake.setAnimationLoopCalls.at(-1)).toBeNull() + expect(fake.renderer.domElement.parentNode).toBeNull() + }) + + it('keeps the render loop healthy across representation and visibility changes', () => { + const fake = createFakeRenderer() + const container = document.createElement('div') + const viewer = createThreeViewer(container, { createRenderer: () => fake.renderer }) + viewer.setStructure(structure(), 'cartoon') + viewer.setRepresentation('ball-and-stick') + viewer.setVisible(false) + viewer.setVisible(true) + + fake.loop?.(0) + fake.loop?.(0) + expect(fake.renderer.render).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/web/src/lib/molecular/render/threeViewer.ts b/apps/web/src/lib/molecular/render/threeViewer.ts new file mode 100644 index 0000000..6b24422 --- /dev/null +++ b/apps/web/src/lib/molecular/render/threeViewer.ts @@ -0,0 +1,159 @@ +/** + * Three.js Molecular Structure viewer (Phase 6.12). + * + * The WebGL implementation behind the `MolecularViewer` contract. It owns the + * scene graph, camera, orbit controls, lights, resize handling, animation + * loop, and — critically — full resource disposal. The React layer never + * touches Three.js directly; it creates one viewer per mounted component and + * drives it through the `MolecularViewer` methods. + * + * WebGL is isolated to `createRenderer` so tests can inject a fake renderer + * and exercise the full lifecycle (frame, focus, representation, dispose) + * without a GPU. + */ + +import * as THREE from 'three' +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' + +import type { Point3 } from '@/lib/molecular/geometry' +import type { RepresentationId } from '@/lib/molecular/representations' +import type { MolecularStructure } from '@/lib/molecular/types' + +import { buildStructureGroup, disposeGroup } from './representationBuilder' +import type { CreateThreeViewerOptions, MolecularViewer, ThreeRenderer } from './types' + +/** Camera-to-structure distance as a multiple of the structure radius. */ +const CAMERA_DISTANCE_FACTOR = 3 +/** Minimum camera distance in angstroms (tiny structures). */ +const MIN_CAMERA_DISTANCE = 2 +/** Default view direction used when the camera has no prior orientation. */ +const DEFAULT_VIEW_DIRECTION = new THREE.Vector3(1, 1, 1).normalize() + +function defaultRenderer(): ThreeRenderer { + return new THREE.WebGLRenderer({ antialias: true, alpha: true }) +} + +/** + * Creates a Three.js molecular viewer attached to `container`. The renderer + * factory is injectable so unit tests can supply a fake WebGL-free renderer. + */ +export function createThreeViewer( + container: HTMLElement, + options: CreateThreeViewerOptions = {}, +): MolecularViewer { + const renderer = options.createRenderer ? options.createRenderer() : defaultRenderer() + renderer.setPixelRatio(typeof window !== 'undefined' ? window.devicePixelRatio : 1) + renderer.setClearColor(0xffffff, 0) + renderer.domElement.style.width = '100%' + renderer.domElement.style.height = '100%' + renderer.domElement.style.display = 'block' + container.appendChild(renderer.domElement) + + const scene = new THREE.Scene() + const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 10_000) + camera.position.set( + DEFAULT_VIEW_DIRECTION.x * 10, + DEFAULT_VIEW_DIRECTION.y * 10, + DEFAULT_VIEW_DIRECTION.z * 10, + ) + + const controls = new OrbitControls(camera, renderer.domElement) + controls.enableDamping = true + controls.dampingFactor = 0.1 + + scene.add(new THREE.AmbientLight(0xffffff, 0.7)) + const keyLight = new THREE.DirectionalLight(0xffffff, 0.9) + keyLight.position.set(2, 2, 2) + scene.add(keyLight) + const fillLight = new THREE.DirectionalLight(0xffffff, 0.4) + fillLight.position.set(-2, -1, 1) + scene.add(fillLight) + + const structureGroup = new THREE.Group() + scene.add(structureGroup) + + let disposed = false + let currentStructure: MolecularStructure | undefined + let currentRepresentation: RepresentationId | undefined + let groupVisible = true + + function renderFrame(): void { + controls.update() + renderer.render(scene, camera) + } + + function applySize(): void { + const width = Math.max(container.clientWidth || 1, 1) + const height = Math.max(container.clientHeight || 1, 1) + camera.aspect = width / height + camera.updateProjectionMatrix() + renderer.setSize(width, height) + } + + applySize() + renderer.setAnimationLoop(() => renderFrame()) + + let resizeObserver: ResizeObserver | null = null + if (typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver(() => applySize()) + resizeObserver.observe(container) + } + + function rebuild(): void { + if (currentStructure === undefined || currentRepresentation === undefined) return + disposeGroup(structureGroup) + structureGroup.clear() + structureGroup.visible = groupVisible + structureGroup.add(buildStructureGroup(currentStructure, currentRepresentation)) + } + + return { + setStructure(structure, representation) { + currentStructure = structure + currentRepresentation = representation + rebuild() + }, + setRepresentation(representation) { + currentRepresentation = representation + rebuild() + }, + setVisible(visible) { + groupVisible = visible + structureGroup.visible = visible + }, + focusCamera(target: Point3, radius: number) { + const center = new THREE.Vector3(target.x, target.y, target.z) + const distance = Math.max(radius * CAMERA_DISTANCE_FACTOR, MIN_CAMERA_DISTANCE) + + const offset = new THREE.Vector3().subVectors(camera.position, controls.target) + const direction = offset.lengthSq() > 0 ? offset.normalize() : DEFAULT_VIEW_DIRECTION + + camera.position.copy(center).addScaledVector(direction, distance) + camera.near = Math.max(radius / 100, 0.001) + camera.far = Math.max(radius * 50, 100) + camera.updateProjectionMatrix() + camera.lookAt(center) + controls.target.copy(center) + controls.update() + }, + resize(width, height) { + camera.aspect = width / height + camera.updateProjectionMatrix() + renderer.setSize(Math.max(width, 1), Math.max(height, 1)) + }, + dispose() { + if (disposed) return + disposed = true + resizeObserver?.disconnect() + resizeObserver = null + renderer.setAnimationLoop(null) + controls.dispose() + disposeGroup(structureGroup) + structureGroup.clear() + renderer.dispose() + if (renderer.domElement.parentNode === container) { + container.removeChild(renderer.domElement) + } + }, + } +} diff --git a/apps/web/src/lib/molecular/render/types.ts b/apps/web/src/lib/molecular/render/types.ts new file mode 100644 index 0000000..6ca11be --- /dev/null +++ b/apps/web/src/lib/molecular/render/types.ts @@ -0,0 +1,51 @@ +/** + * Molecular viewer renderer contract (Phase 6.12). + * + * The React layer talks only to this interface. It decouples the component + * and hook from Three.js: tests inject a fake `MolecularViewer` (or fake + * renderer) and never need a GPU, while production uses + * `createThreeViewer` from `lib/molecular/render/threeViewer.ts`. + */ + +import type { Point3 } from '@/lib/molecular/geometry' +import type { RepresentationId } from '@/lib/molecular/representations' +import type { MolecularStructure } from '@/lib/molecular/types' + +/** Minimal renderer surface the Three.js viewer relies on. */ +export interface ThreeRenderer { + domElement: HTMLCanvasElement + setPixelRatio(ratio: number): void + setSize(width: number, height: number): void + setClearColor(color: number | string, alpha?: number): void + setAnimationLoop(callback: ((time: number) => void) | null): void + render(scene: unknown, camera: unknown): void + dispose(): void +} + +/** Options accepted when creating a Three.js viewer. */ +export interface CreateThreeViewerOptions { + /** Injectable renderer factory (defaults to `THREE.WebGLRenderer`). */ + createRenderer?: () => ThreeRenderer +} + +/** The imperative 3D viewer surface used by the React component. */ +export interface MolecularViewer { + /** Builds (or rebuilds) the structure group for a representation. */ + setStructure(structure: MolecularStructure, representation: RepresentationId): void + /** Rebuilds the structure group in another representation. */ + setRepresentation(representation: RepresentationId): void + /** Shows or hides the structure group. */ + setVisible(visible: boolean): void + /** Frames the camera around a target with the given radius (angstroms). */ + focusCamera(target: Point3, radius: number): void + /** Handles an explicit container resize. */ + resize(width: number, height: number): void + /** Disposes renderer, controls, geometries, materials, and listeners. */ + dispose(): void +} + +/** Factory signature used to inject a viewer into the React component. */ +export type CreateMolecularViewer = ( + container: HTMLElement, + options?: CreateThreeViewerOptions, +) => MolecularViewer diff --git a/apps/web/src/lib/molecular/representations.test.ts b/apps/web/src/lib/molecular/representations.test.ts new file mode 100644 index 0000000..71ffe71 --- /dev/null +++ b/apps/web/src/lib/molecular/representations.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' + +import type { RepresentationId } from './representations' +import { + DEFAULT_REPRESENTATION, + REPRESENTATIONS, + isRepresentationId, + representationLabel, +} from './representations' + +describe('REPRESENTATIONS', () => { + it('covers every supported representation id with labels and descriptions', () => { + expect(REPRESENTATIONS.map((option) => option.id)).toEqual([ + 'cartoon', + 'ball-and-stick', + 'space-filling', + ]) + for (const option of REPRESENTATIONS) { + expect(option.label.length).toBeGreaterThan(0) + expect(option.description.length).toBeGreaterThan(0) + } + }) + + it('defaults to the cartoon representation', () => { + expect(DEFAULT_REPRESENTATION).toBe('cartoon') + expect(REPRESENTATIONS.some((option) => option.id === DEFAULT_REPRESENTATION)).toBe(true) + }) +}) + +describe('isRepresentationId', () => { + it('accepts every catalogued id', () => { + for (const option of REPRESENTATIONS) { + expect(isRepresentationId(option.id)).toBe(true) + } + }) + + it('rejects unknown values', () => { + expect(isRepresentationId('ribbon')).toBe(false) + expect(isRepresentationId(undefined)).toBe(false) + expect(isRepresentationId(42)).toBe(false) + }) +}) + +describe('representationLabel', () => { + it('returns the control label for a known id', () => { + expect(representationLabel('ball-and-stick')).toBe('Ball and stick') + expect(representationLabel('cartoon')).toBe('Cartoon / ribbon') + }) + + it('falls back to the id itself when unknown', () => { + expect(representationLabel('ribbon' as RepresentationId)).toBe('ribbon') + }) +}) diff --git a/apps/web/src/lib/molecular/representations.ts b/apps/web/src/lib/molecular/representations.ts new file mode 100644 index 0000000..2901309 --- /dev/null +++ b/apps/web/src/lib/molecular/representations.ts @@ -0,0 +1,54 @@ +/** + * Structure representations (Phase 6.12). + * + * A small, extensible set of renderings the Molecular Structure Viewer can + * draw. Representation ids are the extension point: the builder + * (`lib/molecular/render/representationBuilder.ts`) switches on the id, and + * the control catalog below drives the labelled representation select. New + * representations are added by registering an option here and a builder + * branch there. + */ + +/** Identifier of a supported structure representation. */ +export type RepresentationId = 'cartoon' | 'ball-and-stick' | 'space-filling' + +/** A representation the viewer offers, with its control label. */ +export interface RepresentationOption { + id: RepresentationId + /** Short label used in the representation select. */ + label: string + /** Longer description for tooltips / documentation. */ + description: string +} + +/** The full representation catalog, in control order. */ +export const REPRESENTATIONS: readonly RepresentationOption[] = [ + { + id: 'cartoon', + label: 'Cartoon / ribbon', + description: 'Trace the polymer backbone as a smooth ribbon through the C-alpha trace.', + }, + { + id: 'ball-and-stick', + label: 'Ball and stick', + description: 'Atoms as coloured spheres with covalent bonds drawn between them.', + }, + { + id: 'space-filling', + label: 'Space filling', + description: 'Atoms as van der Waals spheres, so the molecular surface is visible.', + }, +] + +/** The representation shown when a structure first loads. */ +export const DEFAULT_REPRESENTATION: RepresentationId = 'cartoon' + +/** Type guard for a `RepresentationId`. */ +export function isRepresentationId(value: unknown): value is RepresentationId { + return value === 'cartoon' || value === 'ball-and-stick' || value === 'space-filling' +} + +/** Short label for a representation id (falls back to the id itself). */ +export function representationLabel(id: RepresentationId): string { + return REPRESENTATIONS.find((option) => option.id === id)?.label ?? id +} diff --git a/apps/web/src/lib/molecular/types.ts b/apps/web/src/lib/molecular/types.ts new file mode 100644 index 0000000..a6ad28c --- /dev/null +++ b/apps/web/src/lib/molecular/types.ts @@ -0,0 +1,95 @@ +/** + * TypeScript types for the Molecular Structure Viewer (Phase 6.12). + * + * ## Coordinate conventions + * + * Atoms carry **Cartesian coordinates in angstroms**. Atom serials, residue + * numbers, and bond endpoints are **one-based**, mirroring the 1-based + * residue convention of the Phase 6.5 Protein Viewer. A residue's + * `atomIndices` refer to 1-based atom serials; a bond's `atomA`/`atomB` + * refer to the same serials. + * + * ## Data boundary + * + * These types are the canonical structure model the viewer renders. They are + * deliberately independent of any particular file format or backend: the + * loader boundary (`lib/molecular/api.ts`) normalizes whatever a future + * source returns into this model, so swapping data sources never touches the + * viewer or its representations. + */ + +/** Free-form metadata carried by a structure without extra types. */ +export type StructureMetadata = Record + +/** Molecule class used for default presentation. */ +export type StructureKind = 'protein' | 'nucleic-acid' | 'other' + +/** A single atom with its 3D position and residue context. */ +export interface StructureAtom { + /** 1-based atom serial; bonds and residues reference this. */ + index: number + /** Chemical element symbol, uppercase (e.g. `C`, `N`, `O`, `S`). */ + element: string + /** Cartesian coordinates in angstroms. */ + x: number + y: number + z: number + /** 1-based residue number within the chain. */ + residueIndex: number + /** Chain identifier (opaque string). */ + chainId: string + /** Residue / group name, e.g. `ALA`. */ + residueName?: string + /** Atom name, e.g. `CA`, `N`, `C`, `O`. */ + atomName?: string +} + +/** A covalent bond between two atoms. */ +export interface StructureBond { + /** 1-based serial of the first atom. */ + atomA: number + /** 1-based serial of the second atom. */ + atomB: number + /** Bond order (single/double/triple); defaults to `1`. */ + order?: number +} + +/** A residue (or other group) belonging to a chain. */ +export interface StructureResidue { + /** 1-based residue number within the chain. */ + index: number + /** Residue / group name, e.g. `ALA`. */ + name?: string + /** 1-based atom serials belonging to this residue. */ + atomIndices: number[] +} + +/** A polymer chain made of ordered residues. */ +export interface StructureChain { + /** Chain identifier (opaque string). */ + id: string + /** Residues in chain order. */ + residues: StructureResidue[] +} + +/** The canonical molecular structure model rendered by the viewer. */ +export interface MolecularStructure { + /** Stable structure identifier (e.g. a PDB accession or future API id). */ + id: string + /** Molecule name, e.g. `p53 DNA-binding domain`. */ + name?: string + /** Molecule class used for default presentation. */ + kind?: StructureKind + /** Organism, when known. */ + organism?: string + /** Free-text description / function. */ + description?: string + /** Polymer chains in structure order. */ + chains: StructureChain[] + /** All atoms. */ + atoms: StructureAtom[] + /** Covalent bonds, when available. */ + bonds: StructureBond[] + /** Optional free-form metadata. */ + metadata?: StructureMetadata +} diff --git a/apps/web/src/lib/molecular/useMolecularStructureViewer.test.tsx b/apps/web/src/lib/molecular/useMolecularStructureViewer.test.tsx new file mode 100644 index 0000000..06fa574 --- /dev/null +++ b/apps/web/src/lib/molecular/useMolecularStructureViewer.test.tsx @@ -0,0 +1,123 @@ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { P53_HELIX_STRUCTURE_FIXTURE } from './molecular.fixtures' +import { useMolecularStructureViewer } from './useMolecularStructureViewer' + +function Harness({ + onResult, + options = {}, +}: { + onResult: (result: ReturnType) => void + options?: Parameters[0] +}) { + const result = useMolecularStructureViewer(options) + onResult(result) + return {result.status} +} + +function renderHook(options: Parameters[0] = {}) { + let result: ReturnType | undefined + render( + { + result = next + }} + />, + ) + return { + get result(): ReturnType { + if (result === undefined) { + throw new Error('useMolecularStructureViewer did not capture a result') + } + return result + }, + } +} + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('useMolecularStructureViewer', () => { + it('loads a structure and reports success with a summary', async () => { + const loader = vi.fn(async () => P53_HELIX_STRUCTURE_FIXTURE) + const captured = renderHook({ loader }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success')) + expect(loader).toHaveBeenCalledTimes(1) + expect(captured.result.structure?.id).toBe('fixture-mini-p53-helix') + expect(captured.result.summary?.chains).toBe(1) + expect(captured.result.summary?.atoms).toBeGreaterThan(0) + }) + + it('reports empty for a structure with no atoms', async () => { + const loader = vi.fn(async () => ({ id: 'empty', chains: [], atoms: [], bonds: [] })) + const captured = renderHook({ loader }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('empty')) + expect(captured.result.structure).toBeDefined() + expect(captured.result.summary).toBeUndefined() + }) + + it('reports error when the loader rejects and refetch retries', async () => { + const loader = vi + .fn() + .mockRejectedValueOnce(new Error('structure down')) + .mockResolvedValueOnce(P53_HELIX_STRUCTURE_FIXTURE) + const captured = renderHook({ loader }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('error')) + expect(captured.result.error?.message).toBe('structure down') + + captured.result.refetch() + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success')) + expect(loader).toHaveBeenCalledTimes(2) + }) + + it('defaults to the cartoon representation and updates it', async () => { + const loader = vi.fn(async () => P53_HELIX_STRUCTURE_FIXTURE) + const captured = renderHook({ loader }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success')) + expect(captured.result.representation).toBe('cartoon') + + act(() => captured.result.setRepresentation('space-filling')) + await waitFor(() => expect(captured.result.representation).toBe('space-filling')) + }) + + it('tracks visibility, defaulting to visible', async () => { + const loader = vi.fn(async () => P53_HELIX_STRUCTURE_FIXTURE) + const captured = renderHook({ loader }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success')) + expect(captured.result.visible).toBe(true) + + act(() => captured.result.setVisible(false)) + await waitFor(() => expect(captured.result.visible).toBe(false)) + }) + + it('frames the camera around the structure centroid on load', async () => { + const loader = vi.fn(async () => P53_HELIX_STRUCTURE_FIXTURE) + const captured = renderHook({ loader }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success')) + expect(captured.result.focus).toBeDefined() + expect(captured.result.focus?.radius).toBeGreaterThanOrEqual(1) + }) + + it('bumps the focus version on reset and fit', async () => { + const loader = vi.fn(async () => P53_HELIX_STRUCTURE_FIXTURE) + const captured = renderHook({ loader }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success')) + const initial = captured.result.focus?.version ?? 0 + + act(() => captured.result.resetView()) + await waitFor(() => expect(captured.result.focus?.version).toBe(initial + 1)) + + act(() => captured.result.fitToView()) + await waitFor(() => expect(captured.result.focus?.version).toBe(initial + 2)) + }) + + it('reports an error when no loader or structure id is provided', async () => { + const captured = renderHook() + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('error')) + expect(captured.result.error?.message).toMatch(/No structure loader provided/) + }) +}) diff --git a/apps/web/src/lib/molecular/useMolecularStructureViewer.ts b/apps/web/src/lib/molecular/useMolecularStructureViewer.ts new file mode 100644 index 0000000..2b3dd9d --- /dev/null +++ b/apps/web/src/lib/molecular/useMolecularStructureViewer.ts @@ -0,0 +1,132 @@ +/** + * Molecular Structure Viewer view-model hook (Phase 6.12). + * + * Composes the Phase 6.1 visualization data lifecycle + * (`useVisualizationData`) with representation selection, show/hide state, + * and camera framing. The hook owns only **data and intent** — the 3D camera + * lives in the renderer, so `focus` is a plain value + * (`{ target, radius, version }`) the component applies to its viewer + * whenever it changes (on load or on reset/fit). + */ + +import { useCallback, useMemo, useState } from 'react' + +import { fetchMolecularStructure } from '@/lib/molecular/api' +import { cameraFocusForStructure, structureSummary } from '@/lib/molecular/geometry' +import { DEFAULT_REPRESENTATION, type RepresentationId } from '@/lib/molecular/representations' +import type { MolecularStructure } from '@/lib/molecular/types' +import { isUsableStructure } from '@/lib/molecular/validate' +import type { VisualizationError, VisualizationStatus } from '@/lib/visualization/types' +import { useVisualizationData } from '@/lib/visualization/useVisualizationData' + +/** Camera framing intent handed to the 3D viewer. */ +export interface StructureFocus { + target: { x: number; y: number; z: number } + radius: number + /** Bumped on every explicit reset/fit so the component re-frames. */ + version: number +} + +/** Human-readable structure summary for status lines and the canvas label. */ +export interface MolecularStructureSummary { + name: string + chains: number + residues: number + atoms: number + bonds: number +} + +/** Result shape consumed by `MolecularStructureViewer`. */ +export interface MolecularStructureViewerResult { + status: VisualizationStatus + error: VisualizationError | undefined + /** Re-runs the structure load request. */ + refetch: () => void + /** Loaded structure, or `undefined` until success. */ + structure: MolecularStructure | undefined + /** Derived human-readable summary of the loaded structure. */ + summary: MolecularStructureSummary | undefined + /** Active representation. */ + representation: RepresentationId + setRepresentation: (representation: RepresentationId) => void + /** Whether the structure group is visible. */ + visible: boolean + setVisible: (visible: boolean) => void + /** Current camera framing intent (changes on load and on reset/fit). */ + focus: StructureFocus | undefined + /** Re-frames the camera to fit the structure. */ + resetView: () => void + /** Same as `resetView`; provided as the conventional "fit" control. */ + fitToView: () => void +} + +export interface UseMolecularStructureViewerOptions { + /** Loads a structure (defaults to nothing; a loader or id is required). */ + loader?: (signal: AbortSignal) => Promise + /** Future backend structure id used when no custom loader is provided. */ + structureId?: string +} + +export function useMolecularStructureViewer( + options: UseMolecularStructureViewerOptions = {}, +): MolecularStructureViewerResult { + const { loader: customLoader, structureId } = options + + const loader = useCallback( + (signal: AbortSignal) => { + if (customLoader !== undefined) return customLoader(signal) + if (structureId !== undefined) { + return fetchMolecularStructure(structureId, signal) + } + return Promise.reject( + new Error('No structure loader provided to useMolecularStructureViewer.'), + ) + }, + [customLoader, structureId], + ) + + const { status, data, error, refetch } = useVisualizationData(loader, { + isEmpty: (structure) => structure.atoms.length === 0, + }) + + const structure = data + const [representation, setRepresentation] = useState(DEFAULT_REPRESENTATION) + const [visible, setVisible] = useState(true) + const [focusVersion, setFocusVersion] = useState(0) + + const summary = useMemo(() => { + if (structure === undefined || !isUsableStructure(structure)) return undefined + const computed = structureSummary(structure) + return { + name: computed.name, + chains: computed.chains, + residues: computed.residues, + atoms: computed.atoms, + bonds: computed.bonds, + } + }, [structure]) + + const focus = useMemo(() => { + if (structure === undefined || !isUsableStructure(structure)) return undefined + const computed = cameraFocusForStructure(structure) + return { target: computed.target, radius: computed.radius, version: focusVersion } + }, [structure, focusVersion]) + + const resetView = useCallback(() => setFocusVersion((version) => version + 1), []) + const fitToView = useCallback(() => setFocusVersion((version) => version + 1), []) + + return { + status, + error, + refetch, + structure, + summary, + representation, + setRepresentation, + visible, + setVisible, + focus, + resetView, + fitToView, + } +} diff --git a/apps/web/src/lib/molecular/validate.test.ts b/apps/web/src/lib/molecular/validate.test.ts new file mode 100644 index 0000000..1e2abad --- /dev/null +++ b/apps/web/src/lib/molecular/validate.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest' + +import type { MolecularStructure } from './types' +import { + firstStructureError, + isUsableStructure, + isValidStructure, + validateStructure, +} from './validate' + +function validStructure(): MolecularStructure { + return { + id: 'test-1', + chains: [{ id: 'A', residues: [{ index: 1, atomIndices: [1, 2] }] }], + atoms: [ + { index: 1, element: 'C', x: 0, y: 0, z: 0, residueIndex: 1, chainId: 'A' }, + { index: 2, element: 'N', x: 1, y: 1, z: 1, residueIndex: 1, chainId: 'A' }, + ], + bonds: [{ atomA: 1, atomB: 2 }], + } +} + +function codes(structure: MolecularStructure): string[] { + return validateStructure(structure).map((issue) => issue.code) +} + +describe('validateStructure', () => { + it('accepts a structurally consistent structure', () => { + expect(isValidStructure(validStructure())).toBe(true) + expect(validateStructure(validStructure())).toEqual([]) + }) + + it('rejects a missing structure id', () => { + expect(codes({ ...validStructure(), id: '' })).toContain('structure.missing-id') + }) + + it('rejects an empty structure', () => { + const issues = codes({ ...validStructure(), atoms: [], bonds: [] }) + expect(issues).toContain('structure.no-atoms') + }) + + it('rejects non-positive and non-integer atom serials', () => { + expect( + codes({ ...validStructure(), atoms: [{ ...validStructure().atoms[0], index: 0 }] }), + ).toContain('atom.invalid-index') + expect( + codes({ ...validStructure(), atoms: [{ ...validStructure().atoms[0], index: 1.5 }] }), + ).toContain('atom.invalid-index') + }) + + it('rejects duplicate atom serials', () => { + const duplicate = { + ...validStructure(), + atoms: [ + { ...validStructure().atoms[0], index: 1 }, + { ...validStructure().atoms[1], index: 1 }, + ], + } + expect(codes(duplicate)).toContain('atom.duplicate-index') + }) + + it('rejects non-finite coordinates', () => { + const structure = { + ...validStructure(), + atoms: [{ ...validStructure().atoms[0], x: Number.NaN }], + } + expect(codes(structure)).toContain('atom.non-finite-coordinates') + }) + + it('rejects an invalid residue number on an atom', () => { + const structure = { + ...validStructure(), + atoms: [{ ...validStructure().atoms[0], residueIndex: 0 }], + } + expect(codes(structure)).toContain('atom.invalid-residue') + }) + + it('rejects atoms without an element', () => { + const structure = { + ...validStructure(), + atoms: [{ ...validStructure().atoms[0], element: '' }], + } + expect(codes(structure)).toContain('atom.missing-element') + }) + + it('rejects a bond referencing a missing atom', () => { + const structure = { + ...validStructure(), + bonds: [{ atomA: 1, atomB: 999 }], + } + expect(codes(structure)).toContain('bond.dangling') + }) + + it('rejects a bond that joins an atom to itself', () => { + const structure = { + ...validStructure(), + bonds: [{ atomA: 1, atomB: 1 }], + } + expect(codes(structure)).toContain('bond.self-loop') + }) + + it('rejects duplicate bonds regardless of endpoint order', () => { + const structure = { + ...validStructure(), + bonds: [ + { atomA: 1, atomB: 2 }, + { atomA: 2, atomB: 1 }, + ], + } + expect(codes(structure)).toContain('bond.duplicate') + }) + + it('rejects a chain without an identifier', () => { + const structure = { ...validStructure(), chains: [{ ...validStructure().chains[0], id: '' }] } + expect(codes(structure)).toContain('chain.missing-id') + }) + + it('rejects an invalid residue number', () => { + const structure = { + ...validStructure(), + chains: [{ ...validStructure().chains[0], residues: [{ index: 0, atomIndices: [1, 2] }] }], + } + expect(codes(structure)).toContain('residue.invalid-index') + }) + + it('rejects duplicate residue numbers within a chain', () => { + const chain = validStructure().chains[0] + const structure = { + ...validStructure(), + chains: [ + { + id: chain.id, + residues: [ + { index: 1, atomIndices: [1] }, + { index: 1, atomIndices: [2] }, + ], + }, + ], + } + expect(codes(structure)).toContain('residue.duplicate-index') + }) + + it('rejects a residue referencing a missing atom', () => { + const structure = { + ...validStructure(), + chains: [{ ...validStructure().chains[0], residues: [{ index: 1, atomIndices: [999] }] }], + } + expect(codes(structure)).toContain('residue.dangling-atom') + }) + + it('rejects atoms that belong to no residue', () => { + const structure = { + ...validStructure(), + chains: [{ ...validStructure().chains[0], residues: [{ index: 1, atomIndices: [1] }] }], + } + expect(codes(structure)).toContain('atom.unreferenced') + }) + + it('reports every issue found, not just the first', () => { + const structure: MolecularStructure = { + id: '', + chains: [], + atoms: [{ index: 0, element: '', x: Number.NaN, y: 0, z: 0, residueIndex: 0, chainId: '' }], + bonds: [], + } + const found = codes(structure) + expect(found).toEqual( + expect.arrayContaining([ + 'structure.missing-id', + 'atom.invalid-index', + 'atom.non-finite-coordinates', + 'atom.invalid-residue', + 'atom.missing-element', + 'atom.unreferenced', + ]), + ) + }) +}) + +describe('isUsableStructure and firstStructureError', () => { + it('is usable for a valid non-empty structure', () => { + expect(isUsableStructure(validStructure())).toBe(true) + }) + + it('is not usable for an empty structure', () => { + expect(isUsableStructure({ ...validStructure(), atoms: [], bonds: [] })).toBe(false) + }) + + it('is not usable for a malformed structure', () => { + expect(isUsableStructure({ ...validStructure(), bonds: [{ atomA: 1, atomB: 999 }] })).toBe( + false, + ) + }) + + it('returns the first issue message or null', () => { + expect(firstStructureError(validStructure())).toBeNull() + const message = firstStructureError({ ...validStructure(), id: '' }) + expect(message).toBe('Structure has no identifier.') + }) +}) diff --git a/apps/web/src/lib/molecular/validate.ts b/apps/web/src/lib/molecular/validate.ts new file mode 100644 index 0000000..3ba2072 --- /dev/null +++ b/apps/web/src/lib/molecular/validate.ts @@ -0,0 +1,170 @@ +/** + * Structure validation (Phase 6.12). + * + * Pure validation over the canonical `MolecularStructure` model. It reports + * structural issues that would break rendering or lie about the data: + * non-finite / missing coordinates, broken atom serials, bonds or residue + * references that point nowhere, self-bonds, and empty structures. The viewer + * uses `isUsableStructure` to decide whether a loaded structure can render; + * malformed records are still typed and testable rather than silently fixed. + */ + +import type { MolecularStructure, StructureAtom, StructureBond } from './types' + +/** A single validation finding. */ +export interface StructureValidationIssue { + /** Stable machine-readable code, e.g. `atom.non-finite-coordinates`. */ + code: string + /** Human-readable description of the problem. */ + message: string +} + +const MAX_ATOM_INDEX = 1_000_000 + +/** True when a value is a positive integer usable as an atom serial. */ +function isPositiveInteger(value: number): boolean { + return Number.isInteger(value) && value >= 1 && value <= MAX_ATOM_INDEX +} + +function isFinitePoint(atom: StructureAtom): boolean { + return Number.isFinite(atom.x) && Number.isFinite(atom.y) && Number.isFinite(atom.z) +} + +function residueAtomIndices(structure: MolecularStructure): number[] { + return structure.chains.flatMap((chain) => + chain.residues.flatMap((residue) => residue.atomIndices), + ) +} + +/** + * Validates a structure and returns every issue found. An empty array means + * the structure is structurally consistent and renderable. + */ +export function validateStructure(structure: MolecularStructure): StructureValidationIssue[] { + const issues: StructureValidationIssue[] = [] + + if (structure.id.length === 0) { + issues.push({ code: 'structure.missing-id', message: 'Structure has no identifier.' }) + } + if (structure.atoms.length === 0) { + issues.push({ code: 'structure.no-atoms', message: 'Structure has no atoms to render.' }) + } + + const atomByIndex = new Map() + for (const atom of structure.atoms) { + if (!isPositiveInteger(atom.index)) { + issues.push({ + code: 'atom.invalid-index', + message: `Atom has an invalid serial ${atom.index}.`, + }) + } else if (atomByIndex.has(atom.index)) { + issues.push({ + code: 'atom.duplicate-index', + message: `Atom serial ${atom.index} appears more than once.`, + }) + } else { + atomByIndex.set(atom.index, atom) + } + if (!isFinitePoint(atom)) { + issues.push({ + code: 'atom.non-finite-coordinates', + message: `Atom ${atom.index} has a non-finite coordinate.`, + }) + } + if (!isPositiveInteger(atom.residueIndex)) { + issues.push({ + code: 'atom.invalid-residue', + message: `Atom ${atom.index} has an invalid residue number ${atom.residueIndex}.`, + }) + } + if (atom.element.length === 0) { + issues.push({ code: 'atom.missing-element', message: `Atom ${atom.index} has no element.` }) + } + } + + const seenBonds = new Set() + for (const bond of structure.bonds) { + if (!atomByIndex.has(bond.atomA) || !atomByIndex.has(bond.atomB)) { + issues.push({ + code: 'bond.dangling', + message: `Bond ${bond.atomA}-${bond.atomB} references a missing atom.`, + }) + } + if (bond.atomA === bond.atomB) { + issues.push({ + code: 'bond.self-loop', + message: `Bond ${bond.atomA}-${bond.atomA} joins an atom to itself.`, + }) + } + const key = + bond.atomA < bond.atomB ? `${bond.atomA}-${bond.atomB}` : `${bond.atomB}-${bond.atomA}` + if (seenBonds.has(key)) { + issues.push({ + code: 'bond.duplicate', + message: `Bond ${key} appears more than once.`, + }) + } + seenBonds.add(key) + } + + for (const chain of structure.chains) { + if (chain.id.length === 0) { + issues.push({ code: 'chain.missing-id', message: 'A chain has no identifier.' }) + } + const residueNumbers = new Set() + for (const residue of chain.residues) { + if (!isPositiveInteger(residue.index)) { + issues.push({ + code: 'residue.invalid-index', + message: `Chain ${chain.id || '?'} has a residue with an invalid number.`, + }) + } else if (residueNumbers.has(residue.index)) { + issues.push({ + code: 'residue.duplicate-index', + message: `Chain ${chain.id || '?'} has residue ${residue.index} more than once.`, + }) + } + residueNumbers.add(residue.index) + for (const atomIndex of residue.atomIndices) { + if (!atomByIndex.has(atomIndex)) { + issues.push({ + code: 'residue.dangling-atom', + message: `Residue ${residue.index} of chain ${chain.id || '?'} references missing atom ${atomIndex}.`, + }) + } + } + } + } + + const referencedByResidues = new Set(residueAtomIndices(structure)) + for (const atom of structure.atoms) { + if (!referencedByResidues.has(atom.index)) { + issues.push({ + code: 'atom.unreferenced', + message: `Atom ${atom.index} belongs to no residue.`, + }) + } + } + + return issues +} + +/** True when a structure has no validation issues at all. */ +export function isValidStructure(structure: MolecularStructure): boolean { + return validateStructure(structure).length === 0 +} + +/** + * True when a structure can actually be rendered: it must pass validation and + * contain at least one atom. `empty` structures are handled by the data + * lifecycle (`isEmpty`), not by rendering. + */ +export function isUsableStructure(structure: MolecularStructure): boolean { + return structure.atoms.length > 0 && isValidStructure(structure) +} + +/** The first issue's message, or `null` when the structure is valid. */ +export function firstStructureError(structure: MolecularStructure): string | null { + const issues = validateStructure(structure) + return issues.length > 0 ? issues[0].message : null +} diff --git a/apps/web/src/lib/visualization/visualizationModules.test.ts b/apps/web/src/lib/visualization/visualizationModules.test.ts index 633801b..6122347 100644 --- a/apps/web/src/lib/visualization/visualizationModules.test.ts +++ b/apps/web/src/lib/visualization/visualizationModules.test.ts @@ -21,6 +21,7 @@ describe('fetchVisualizationModules', () => { 'integrated-research-workspace', 'performance-large-datasets', 'testing-documentation', + 'molecular-structure-viewer', ]) for (const module of modules) { expect(module.title).toBeTruthy() @@ -35,7 +36,7 @@ describe('fetchVisualizationModules', () => { const second = await fetchVisualizationModules(signal, { delayMs: 0 }) expect(first).not.toBe(second) first.pop() - expect((await fetchVisualizationModules(signal, { delayMs: 0 })).length).toBe(10) + expect((await fetchVisualizationModules(signal, { delayMs: 0 })).length).toBe(11) }) it('honours the simulated latency before resolving', async () => { diff --git a/apps/web/src/lib/visualization/visualizationModules.ts b/apps/web/src/lib/visualization/visualizationModules.ts index 914db8a..78b4d4f 100644 --- a/apps/web/src/lib/visualization/visualizationModules.ts +++ b/apps/web/src/lib/visualization/visualizationModules.ts @@ -4,7 +4,7 @@ import type { VisualizationDataSource, VisualizationMetadata } from './types' * Catalog of the visualization modules delivered across Phase 6. * * This started as a placeholder catalog for the Phase 6.1 foundation-only - * demo. It now reflects the modules actually implemented in Phase 6.2–6.11, so + * demo. It now reflects the modules actually implemented in Phase 6.2–6.12, so * the demo catalog is an accurate map of the platform. */ export interface VisualizationModule extends VisualizationMetadata { @@ -90,6 +90,14 @@ const MODULES: readonly VisualizationModule[] = [ milestone: '6.11', source: { kind: 'api', reference: '/api/visualization/testing' }, }, + { + id: 'molecular-structure-viewer', + title: 'Molecular Structure Viewer', + description: + 'Interactive 3D molecular structure rendering: cartoon, ball-and-stick, and space-filling representations over a synthetic development fixture.', + milestone: '6.12', + source: { kind: 'api', reference: '/api/visualization/structures' }, + }, ] export interface FetchVisualizationModulesOptions { diff --git a/docs/visualization/README.md b/docs/visualization/README.md index 435140e..89e7378 100644 --- a/docs/visualization/README.md +++ b/docs/visualization/README.md @@ -28,6 +28,16 @@ accessibility and component tests for previously untested chart primitives and workspace panels, and reconciles the docs with the shipped platform. See [Testing](./testing.md) and the [Roadmap](./roadmap.md). +**Phase 6.12 — Molecular Structure Viewer** is implemented: a first +production-quality 3D molecular structure viewer. It renders structures +through Three.js behind a pure `MolecularViewer` seam (cartoon / ribbon, +ball-and-stick, and space-filling representations), with orbit/zoom/pan +camera controls, reset/fit framing, show/hide, a labelled select and +`aria-pressed` controls, and full GPU/geometry disposal. The backend exposes +no structure endpoint yet, so the demo renders a clearly isolated synthetic +development fixture through the same typed normalizer the future structure +adapter will use. See [Molecular Structure](./molecular-structure.md). + | Milestone | Description | Status | |-----------|-------------|--------| | 6.1 | Visualization Foundation | ✅ Implemented | @@ -41,6 +51,7 @@ workspace panels, and reconciles the docs with the shipped platform. See | 6.9 | Integrated Research Workspace | ✅ Implemented | | 6.10 | Visualization Performance & Optimization | ✅ Implemented | | 6.11 | Visualization Testing & Documentation | ✅ Implemented | +| 6.12 | Molecular Structure Viewer | ✅ Implemented | ## What Phase 6.1 Provides @@ -239,6 +250,43 @@ workspace panels, and reconciles the docs with the shipped platform. See - The web suite grows from 725 to 813 tests with no flaky or timing-based benchmarks. +## What Phase 6.12 Provides + +- A **Molecular Structure Viewer** (see + [Molecular Structure](./molecular-structure.md)): interactive 3D rendering of + a molecular structure — orbit, zoom, and pan the camera; switch between + cartoon / ribbon, ball-and-stick, and space-filling representations; reset + or fit the view; and show/hide the structure. +- A **typed canonical structure model** (`lib/molecular/types.ts`) with + angstrom coordinates, 1-based atom serials and residue numbers (mirroring the + Phase 6.5 protein residue convention), chains/residues/bonds, and a pure + validator (`lib/molecular/validate.ts`) that reports structural issues + (missing ids, dangling bonds, duplicate serials, non-finite coordinates) + instead of silently rendering malformed data. +- Pure, unit-tested geometry (`lib/molecular/geometry.ts`): CPK element + presentation, bounding boxes, centroids, camera framing, backbone traces, + and structure summaries — shared by the viewer and its tests. +- **Three.js** behind a pure seam: the React layer talks only to a + `MolecularViewer` interface (`lib/molecular/render/types.ts`); the WebGL + implementation (`render/threeViewer.ts`) owns the scene, camera, orbit + controls, resize handling, and full disposal, with an injectable renderer so + tests never need a GPU. The object-graph builder + (`render/representationBuilder.ts`) creates only Three.js objects and is + unit-tested in jsdom. +- A **data boundary**: the backend exposes no structure endpoint yet, so + `lib/molecular/api.ts` documents the future `GET /structures/{id}` contract + and normalizes whatever a source returns via `toStructure`; the demo renders + a clearly isolated, deterministically generated synthetic development + fixture routed through the same normalizer as production. +- A reusable `useMolecularStructureViewer` hook and `MolecularStructureViewer` + component (`/visualization/molecular-structure`), with an accessible canvas + (`role="img"` + `aria-label`), labelled controls, a live structure summary, + and the shared `VisualizationContainer` lifecycle. +- 84 new tests across the molecular module (validation, geometry, + representations, API normalization, hook lifecycle, component lifecycle with + an injected fake viewer, and Three.js lifecycle with a fake renderer), plus + the catalog entry and data-contract updates for the module catalog. + ## Documents | Document | Description | @@ -254,17 +302,21 @@ workspace panels, and reconciles the docs with the shipped platform. See | [Research Workspace](workspace.md) | Phase 6.9 Integrated Research Workspace: context, panels, state flow, API usage, fixture boundary, a11y, tests | | [Performance](performance.md) | Phase 6.10 Visualization performance & large-dataset handling: data flow, strategies, downsampling limitations, a11y, testing | | [Testing](testing.md) | Phase 6.11 Testing & documentation pass: coverage map, test conventions, and how to run the suite | +| [Molecular Structure](molecular-structure.md) | Phase 6.12 Molecular Structure Viewer: 3D representations, Three.js design decision, data model, API, fixture boundary, a11y, tests | | [Roadmap](roadmap.md) | Detailed phase tracking and future work | ## Technology Notes -The visualization platform is intentionally lightweight. It uses only the -existing web stack (React, TypeScript, Tailwind CSS) plus SVG layout -primitives. No C++, WebAssembly, WebGPU, Three.js, Cytoscape.js, or D3.js is -used — those are introduced only when the milestone that actually requires -them arrives: +The visualization platform is intentionally lightweight. It uses the existing +web stack (React, TypeScript, Tailwind CSS) plus SVG layout primitives, and — +for Phase 6.12's 3D molecular structure milestone only — **Three.js** (runtime +`three` + `@types/three`). No C++, WebAssembly, WebGPU, Cytoscape.js, or D3.js +is used; those remain deferred until a milestone actually requires them: -- Three.js → a future 3D molecular structure milestone +- Three.js → Phase 6.12 uses Three.js directly (not a molecular-viewer + library such as NGL or bio3d-viewer) behind a pure `MolecularViewer` seam; + see [molecular-structure.md](./molecular-structure.md) for the design + decision - Cytoscape.js → deferred: Phase 6.6 ships a deterministic pure-SVG layout behind the `createLayout` seam instead (see [network-viewer.md](./network-viewer.md)); Cytoscape.js remains available diff --git a/docs/visualization/architecture.md b/docs/visualization/architecture.md index 79ae639..9504b38 100644 --- a/docs/visualization/architecture.md +++ b/docs/visualization/architecture.md @@ -42,6 +42,7 @@ apps/web/src/ │ ├── scientific/ ExpressionChart, Heatmap, VolcanoPlot, │ │ CoverageChart, DistributionChart, ChartAxes, │ │ ChartLegend, ChartTooltip +│ ├── molecular/ MolecularStructureViewer (Three.js-backed) │ └── workspace/ ResearchWorkspace + panels + fixture data source ├── lib/ │ ├── visualization/ types, useVisualizationData, module catalog @@ -51,11 +52,15 @@ apps/web/src/ │ ├── network/ types, model/normalize/filter/layout/viewport, API │ ├── scientific/ chart types/scales/geometry, statistics, downsample, │ │ heatmap/volcano/coverage/distribution, hooks, API adapters +│ ├── molecular/ structure types/validation/geometry, representations, +│ │ useMolecularStructureViewer, render (Three.js seam), API │ └── workspace/ researchContext, dataSources └── app/visualization/ ├── page.tsx Route (/visualization) — demo catalog ├── VisualizationDemo.tsx Client demo proving the architecture - └── workspace/page.tsx Research Workspace route (/visualization/workspace) + ├── workspace/page.tsx Research Workspace route (/visualization/workspace) + └── molecular-structure/ Molecular Structure Viewer route + (/visualization/molecular-structure) ``` ## Data Flow diff --git a/docs/visualization/molecular-structure.md b/docs/visualization/molecular-structure.md new file mode 100644 index 0000000..d408aa2 --- /dev/null +++ b/docs/visualization/molecular-structure.md @@ -0,0 +1,226 @@ +# Molecular Structure Viewer + +Phase 6.12 introduces the first 3D rendering in the GenomeAI visualization +platform: an interactive molecular structure viewer at +`/visualization/molecular-structure` (linked from `/visualization`). + +This document covers the scope, the Three.js design decision, the canonical +data model, the data boundary, the viewer lifecycle, accessibility, testing, +and future extensions. + +## Scope + +- Interactive 3D rendering of a molecular structure: orbit, zoom, and pan the + camera (mouse drag / wheel / touch, via `OrbitControls`). +- Three representations, switchable at runtime: + - **Cartoon / ribbon** — a smooth tube through the polymer backbone trace. + - **Ball and stick** — CPK-coloured atoms as spheres with covalent bonds as + cylinders. + - **Space filling** — atoms as van der Waals spheres so the molecular + surface is visible. +- Camera controls: **Reset view** and **Fit to structure** re-frame the camera + around the structure; **Show/Hide structure** toggles the structure group. +- A live, human-readable structure summary (name, chains, residues, atoms, + bonds) exposed both as a description and as a labelled `role="img"` canvas. +- The shared Phase 6.1 lifecycle (`loading` / `empty` / `error` + retry) via + `VisualizationContainer`. + +## Out of scope (deferred by design) + +The viewer is a **visualization layer only**. It performs no docking, molecular +dynamics, folding, prediction, alignment, or analysis. It does not parse PDB / +MMCIF files directly (a future source adapter can — see +[Data boundary](#data-boundary)). It makes no claim about the biological +meaning of coordinates; it renders whatever the loader provides. + +## Design decision: Three.js behind a pure seam + +Phase 6.1–6.11 deliberately shipped without Three.js, Cytoscape.js, or D3.js, +deferring each to the milestone that truly requires it. A 3D molecular +structure viewer genuinely requires a 3D engine, so Phase 6.12 introduces +**Three.js** (`three`, runtime; `@types/three`, dev) — the only new runtime +dependency in the phase. + +Why Three.js directly rather than a specialized molecular viewer library +(NGL, bio3d-viewer, Mol*): + +- The repo convention is **minimal dependencies behind pure seams** (native + scales instead of D3.js, a deterministic layout instead of Cytoscape.js). + NGL/bio3d-viewer bring their own component, event, and state models on top + of Three.js, which would fight the existing `useVisualizationData` / + `VisualizationContainer` lifecycle. +- The required representations (cartoon ribbon, ball-and-stick, space-filling) + are simple to build directly on core Three.js geometry + (`TubeGeometry`, `SphereGeometry`, `CylinderGeometry`), giving full control + over colors, materials, and — critically — resource disposal. +- WebGL stays **isolated** behind the `MolecularViewer` seam + (`lib/molecular/render/types.ts`): the React layer never imports Three.js. + `createThreeViewer` owns the scene, camera, controls, and renderer; tests + inject a fake renderer and never touch a GPU, matching the repo's + jsdom-testing convention. + +Seam layout: + +``` +lib/molecular/types.ts canonical model (no deps) +lib/molecular/validate.ts pure validation (no deps) +lib/molecular/geometry.ts pure geometry (no deps) +lib/molecular/representations.ts representation catalog (no deps) +lib/molecular/api.ts future GET /structures/{id} adapter +lib/molecular/molecular.fixtures.ts dev fixture -> toStructure +lib/molecular/useMolecularStructureViewer.ts view-model hook (React) +lib/molecular/render/types.ts MolecularViewer interface +lib/molecular/render/representationBuilder.ts pure Three.js object graph +lib/molecular/render/threeViewer.ts WebGL implementation +components/molecular/MolecularStructureViewer.tsx React component +app/visualization/molecular-structure/ route + demo +``` + +## Data model + +`lib/molecular/types.ts` defines the canonical `MolecularStructure` model: + +- `StructureAtom` — 1-based atom serial, element symbol, Cartesian **angstrom** + coordinates, 1-based residue number, chain id, optional residue/atom names. +- `StructureBond` — two 1-based atom serials plus an optional bond order. +- `StructureResidue` — 1-based residue number, optional name, atom serials. +- `StructureChain` — chain id plus ordered residues. +- `MolecularStructure` — id, optional name/kind (`protein` | `nucleic-acid` | + `other`)/organism/description/metadata, plus chains/atoms/bonds. + +Conventions mirror the Phase 6.5 protein viewer (1-based residue numbering) and +the genome viewer (1-based, inclusive intervals): atom serials and residue +numbers are **one-based**, coordinates are in **angstroms**. + +## Validation + +`lib/molecular/validate.ts` reports structural issues rather than silently +rendering bad data: + +- `structure.missing-id`, `structure.no-atoms` +- `atom.invalid-index` / `atom.duplicate-index` / `atom.non-finite-coordinates` + / `atom.invalid-residue` / `atom.missing-element` / `atom.unreferenced` +- `bond.dangling` / `bond.self-loop` / `bond.duplicate` +- `chain.missing-id` / `residue.invalid-index` / `residue.duplicate-index` / + `residue.dangling-atom` + +`isUsableStructure` gates rendering; the hook only produces a summary and a +camera focus for usable structures. + +## Geometry + +`lib/molecular/geometry.ts` is pure and dependency-free: + +- `elementPresentation` / `elementColor` — CPK-style colors and + van der Waals / ball radii for C, N, O, S, H, P, FE, ZN, CL, BR, CA (with a + safe default). +- `structureBounds` / `structureCentroid` / `structureRadius` / + `cameraFocusForStructure` — camera framing (target + radius). +- `backboneTrace` — per-chain C-alpha-first trace for the cartoon ribbon. +- `elementCounts` / `structureSummary` — summaries for status lines and the + accessible label. + +## Data boundary + +The GenomeAI backend **does not yet expose any molecular structure endpoint**. +The module follows the established Phase 6.5/6.6/6.7/6.8 fixture pattern: + +- `lib/molecular/api.ts` documents the future contract + (`GET /structures/{structure_id}`) and provides `toStructure`, the typed + normalizer from raw records to `MolecularStructure`. A real endpoint can be + wired in by pointing a loader at `fetchMolecularStructure` without touching + the viewer, representations, or geometry. +- `lib/molecular/molecular.fixtures.ts` provides a **deterministically + generated synthetic development fixture** (a small alpha-helix-like trace + over TP53 N-terminal residues: backbone N/CA/C/O per residue plus a few side + chains, with peptide and covalent bonds). It is generated in code (no random + values), flows through the same `toStructure` normalizer production would + use, and is clearly marked as synthetic (`metadata.source: 'fixture'`). + +This keeps the seam honest: the moment a real structure source exists, the +fixture is replaced by a loader, and everything else stays. + +## Viewer lifecycle + +`useMolecularStructureViewer` composes the Phase 6.1 `useVisualizationData` +lifecycle with: + +- `representation` / `setRepresentation` (default `cartoon`) +- `visible` / `setVisible` +- `focus` — `{ target, radius, version }` computed from the structure; + `resetView` / `fitToView` bump `version` so the component re-frames the + camera. + +`MolecularStructureViewer` creates the Three.js viewer **once per mount** (and +only when the success-state container exists) using a `createdRef` guard, then +updates it in place via separate effects for structure/representation, focus, +and visibility. The viewer is fully disposed on unmount. + +`render/threeViewer.ts` owns: + +- `THREE.WebGLRenderer` (antialiased, alpha background) with an injectable + factory. +- A `PerspectiveCamera` framed by `focusCamera` (distance ∝ structure radius, + near/far from radius). +- `OrbitControls` with damping, ambient + key + fill lights. +- A `ResizeObserver` for responsive sizing and an animation loop. +- `dispose()` — stops the loop, disposes controls, geometries/materials + (`disposeGroup`), the renderer, and detaches the canvas. + +`render/representationBuilder.ts` builds only Three.js objects (groups, +geometries, materials) and is safe to test in jsdom; only the renderer touches +WebGL. + +## Accessibility + +The WebGL canvas is a supplementary visual; the controls and the textual +summary carry the keyboard/assistive interaction: + +- The canvas container is `role="img"` with a descriptive `aria-label` + (structure name, chains, residues, atoms, bonds), and the same summary is + repeated in an `aria-live="polite"` output for updates. +- All controls are labelled buttons/selects with descriptive names: + **Reset view**, **Fit to structure**, a labelled **Representation** select, + and a **Show/Hide structure** toggle with `aria-pressed`. +- Loading / empty / error states reuse the shared `VisualizationContainer` + semantics (labelled loading, message, retry button). + +Known limitation: orbit/zoom/pan is pointer-driven; there is no keyboard +camera manipulation, so the textual summary is the assistive window into the +3D scene. Extending keyboard camera control is a documented future extension. + +## Testing + +84 new web tests, all running in jsdom (no GPU): + +- `validate.test.ts` — every validation code, plus `isUsableStructure` / + `firstStructureError`. +- `geometry.test.ts` — element presentation, bounds, centroid, radius, camera + framing, backbone traces, element counts, summaries. +- `representations.test.ts` — catalog, default, type guard, labels. +- `api.test.ts` — `toStructure` normalization, malformed-record dropping, + field fallbacks, metadata filtering; `fetchMolecularStructure` success / + non-2xx / invalid-payload / invalid-structure with a mocked `fetch`. +- `molecular.fixtures.test.ts` — the fixture is valid, deterministic, and + clearly synthetic. +- `render/representationBuilder.test.ts` — mesh counts per representation and + full disposal of geometries/materials. +- `render/threeViewer.test.ts` — fake-renderer lifecycle: canvas attach, frame + loop, camera framing distances, resize, dispose/detach. +- `useMolecularStructureViewer.test.tsx` — success / empty / error + retry, + representation, visibility, focus versions, missing-loader error. +- `MolecularStructureViewer.test.tsx` — injected fake viewer: create-once, + `setStructure`/`focusCamera`, representation select, visibility toggle, + reset/fit controls, dispose on unmount, loading/empty/error states. + +## Extensions + +- Parse real structure formats (PDB / MMCIF) in a future source adapter and + normalize through `toStructure`. +- More representations (licorice, surface/mesh, CA trace) by adding a + `RepresentationId` catalog entry and a builder branch. +- Keyboard camera control and focus/search to pick atoms or residues. +- Multi-structure comparison or structure alignments (analysis, separate + milestone). +- Render structures from the workspace by reusing this component behind a + `WorkspaceDataSource` provider, mirroring the Phase 6.9 panel pattern. diff --git a/docs/visualization/protein-viewer.md b/docs/visualization/protein-viewer.md index 5ed1c45..41fa552 100644 --- a/docs/visualization/protein-viewer.md +++ b/docs/visualization/protein-viewer.md @@ -35,9 +35,9 @@ Implemented on branch `feat/visualization-protein-viewer`. ## Out of scope (later milestones or explicitly excluded) - **3D molecular structure rendering** — this phase is sequence/annotation - only. Structure viewing is a future milestone that would introduce - Three.js only if 3D is truly required (see - [README](README.md) technology notes). + only. 3D structure viewing shipped separately in Phase 6.12 (the + [Molecular Structure Viewer](molecular-structure.md), which introduced + Three.js exactly because 3D is truly required there). - D3.js / Cytoscape.js / WebAssembly / WebGPU / C++ - Backend annotation-feature support (see [Feature data boundary](#feature-data-boundary)) diff --git a/docs/visualization/roadmap.md b/docs/visualization/roadmap.md index 5de7eb3..6720b13 100644 --- a/docs/visualization/roadmap.md +++ b/docs/visualization/roadmap.md @@ -4,7 +4,78 @@ Tracks the Phase 6 visualization platform milestones. See [Phase 6 of the project ROADMAP]() for the authoritative milestone list. -## Current Milestone: 6.11 — Visualization Testing & Documentation ✅ +## Current Milestone: 6.12 — Molecular Structure Viewer ✅ + +The first production-quality 3D molecular structure viewer, on top of the +whole Phase 6 platform. + +Delivered: + +- **Typed canonical structure model** (`lib/molecular/types.ts`) — angstrom + coordinates, 1-based atom serials and residue numbers (mirroring the Phase + 6.5 protein residue convention), chains/residues/bonds, and free-form + metadata; deliberately independent of any file format or backend. +- **Pure validation** (`lib/molecular/validate.ts`) — reports structural + issues (missing ids, no atoms, invalid/duplicate serials, non-finite + coordinates, dangling/self/duplicate bonds, missing chain/residue ids, + dangling residue references, unreferenced atoms) instead of silently + rendering malformed data; `isUsableStructure` gates rendering. +- **Pure geometry** (`lib/molecular/geometry.ts`) — CPK element presentation, + bounding boxes, centroids, camera framing (`cameraFocusForStructure`), + per-chain backbone traces, element counts, and human-readable summaries. +- **Three.js behind a pure seam** — the React layer talks only to a + `MolecularViewer` interface (`render/types.ts`); the WebGL implementation + (`render/threeViewer.ts`) owns the scene, perspective camera, lights, + `OrbitControls`, `ResizeObserver`, animation loop, and full disposal, with + an injectable renderer so tests never need a GPU. The object-graph builder + (`render/representationBuilder.ts`) creates only Three.js objects and is + unit-tested in jsdom. +- **Three representations** — cartoon / ribbon (smooth tube through the + C-alpha trace), ball-and-stick (CPK-coloured spheres + bond cylinders), and + space-filling (van der Waals spheres), behind a small extensible + `RepresentationId` catalog. +- **`useMolecularStructureViewer` hook** — composes the Phase 6.1 data + lifecycle with representation, visibility, and camera-framing intent + (target/radius/version bumped on reset/fit). +- **`MolecularStructureViewer` component** — creates the viewer once per mount + and updates it in place; labelled `role="img"` canvas, live structure + summary, representation select, reset/fit buttons, and show/hide with + `aria-pressed`. +- **Data boundary** — the backend exposes no structure endpoint yet, so + `lib/molecular/api.ts` documents the future `GET /structures/{id}` contract + and normalizes any source via `toStructure`; the demo renders a clearly + isolated, deterministically generated synthetic development fixture routed + through the same normalizer as production (mirroring the Phase 6.5/6.6/6.7 + fixture boundaries). +- **Route + demo** — `/visualization/molecular-structure` (linked from + `/visualization`), a catalog entry (`molecular-structure-viewer`, milestone + `6.12`) with its data-contract test updated to 11 modules. +- **Tests** (84 new web tests) — validation codes, geometry math, catalog + contracts, API normalization + malformed records, hook lifecycle (success / + empty / error / representation / visibility / focus), component lifecycle + with an injected fake viewer (create-once, setStructure/focusCamera, + representation select, visibility toggle, dispose on unmount), and Three.js + lifecycle with a fake renderer (frame loop, camera framing, resize, dispose). +- **Docs** — this roadmap, [Molecular Structure](./molecular-structure.md), + README milestone table + "What Phase 6.12 Provides" + technology notes + (Three.js is now used, behind the seam). + +Constraints honored: + +- No C++, WebAssembly, WebGPU, CUDA, or a second framework; Three.js is the + only new runtime dependency and is isolated behind the `MolecularViewer` + seam (WebGL is only touched by `render/threeViewer.ts`) +- No docking, dynamics, prediction, alignment, or analysis work; the viewer is + a visualization layer only +- No invented backend structure endpoint; the fixture boundary is documented +- Existing Genome Browser, Workspace, charts, and network viewers keep working + unchanged; the visualization UI is not redesigned +- All new tests pass in jsdom (no GPU required); no flaky or timing-based + benchmarks + +## Previous milestones + +### 6.11 — Visualization Testing & Documentation ✅ A stabilization + docs pass over the whole Phase 6 platform, on top of 6.10. @@ -44,16 +115,14 @@ Delivered: Constraints honored: -- No new visualization features, architecture redesign, or Phase 6.12 work; - no C++/WebAssembly/WebGPU/Three.js/Cytoscape.js/D3.js; no new runtime - dependencies; Phase 5 untouched +- No new visualization features, architecture redesign, or Phase 6.12 work at + the time; no C++/WebAssembly/WebGPU/Three.js/Cytoscape.js/D3.js; no new + runtime dependencies; Phase 5 untouched - No tests deleted, weakened, or skipped; additions are behavior-oriented (never implementation-detail fakes) - Docs claims are accurate against the current repo (test counts, milestone status, component tree) -## Previous milestones - ### 6.9 — Integrated Research Workspace ✅ Implemented on top of 6.8 (and the Phase 6.10 performance work). @@ -438,4 +507,3 @@ Constraints honored: | # | Milestone | Notes | |---|-----------|-------| -| 6.12 | Molecular Structure Viewer (3D) | 3D protein structures; Three.js only if 3D is truly required | \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2cb04b..3f66c4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.2.8(react@19.2.8) + three: + specifier: ^0.185.1 + version: 0.185.1 devDependencies: '@testing-library/dom': specifier: ^10.4.0 @@ -52,6 +55,9 @@ importers: '@types/react-dom': specifier: ^19.0.0 version: 19.2.3(@types/react@19.2.17) + '@types/three': + specifier: ^0.185.4 + version: 0.185.4 '@vitejs/plugin-react': specifier: ^4.4.0 version: 4.7.0(vite@7.3.6(@types/node@20.19.43)(jiti@1.21.7)) @@ -269,6 +275,9 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@emnapi/runtime@1.11.2': resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} @@ -837,6 +846,9 @@ packages: cpu: [arm64] os: [win32] + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -872,6 +884,15 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.185.4': + resolution: {integrity: sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==} + + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1099,6 +1120,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -1229,6 +1253,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + meshoptimizer@1.1.1: + resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -1510,6 +1537,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + three@0.185.1: + resolution: {integrity: sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1882,6 +1912,8 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@dimforge/rapier3d-compat@0.12.0': {} + '@emnapi/runtime@1.11.2': dependencies: tslib: 2.8.1 @@ -2258,6 +2290,8 @@ snapshots: '@turbo/windows-arm64@2.10.5': optional: true + '@tweenjs/tween.js@23.1.3': {} + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -2302,6 +2336,19 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/stats.js@0.17.4': {} + + '@types/three@0.185.4': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + fflate: 0.8.3 + meshoptimizer: 1.1.1 + + '@types/webxr@0.5.24': {} + '@vitejs/plugin-react@4.7.0(vite@7.3.6(@types/node@20.19.43)(jiti@1.21.7))': dependencies: '@babel/core': 7.29.7 @@ -2536,6 +2583,8 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + fflate@0.8.3: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -2660,6 +2709,8 @@ snapshots: merge2@1.4.1: {} + meshoptimizer@1.1.1: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -2968,6 +3019,8 @@ snapshots: dependencies: any-promise: 1.3.0 + three@0.185.1: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {}