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).
+
+
+
+
+ Back to Visualization
+
+
+
+
+ )
+}
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
+
+ Reset view
+
+
+ Fit to structure
+
+
+
+
+ Representation
+
+
+ {REPRESENTATIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ result.setVisible(!result.visible)}
+ aria-pressed={result.visible}
+ aria-label={result.visible ? 'Hide structure' : 'Show structure'}
+ className="rounded-md border border-gray-300 px-2 py-1 text-sm text-gray-700 hover:bg-gray-50"
+ >
+ {result.visible ? 'Hide structure' : 'Show structure'}
+
+
+
+ ) : 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: {}