dsh-molbio-tools 0.24.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,67 @@
10
10
  改为按**包名**引用后模块缓存问题随之消失——没有拷贝,也就没有需要保持同步的版本目录。
11
11
  下文历史条目里出现的 `vN` 目录保持原样,作为当时的记录。
12
12
 
13
+ ## [0.25.0] — 2026-11-10(浏览器内 3D 结构预览:真实原子模型)
14
+
15
+ **工具数不变(64),benchmark 不变(65 题 / 64 覆盖)。** 这一版把结构文件接进已有的
16
+ **Molbio** 右栏面板:选中 `.pdb` / `.ent` / `.cif` / `.mmcif` / `.sdf` / `.mol`,
17
+ 浏览器当场渲染一个可旋转、可缩放的**真实 3D 原子模型**(ball-and-stick / space-filling),
18
+ 不需要工具调用、不落盘 SVG、不弹系统查看器,也没有引入任何第三方库。
19
+
20
+ > 0.25.0 在发布前从 2D PCA 投影改成了 3D。2D 预览只有一张固定投影,丢失深度、遮挡和
21
+ > 原子空间关系;学术界常见的结构配图是立体原子/表面模型,而不是单张 PCA 线图。
22
+
23
+ ### 1. 解析:`structure-preview.mjs`
24
+
25
+ - **PDB**:只读第一个 MODEL,跳过非 A/A 的 altLoc;读 `ATOM`/`HETATM` 的坐标、元素、
26
+ 残基、链与 `CONECT`。
27
+ - **mmCIF**:读 `_atom_site` 循环,quote 与 `;` 文本块都能切词;优先 `auth_*` 列,
28
+ 缺列时回退 `label_*`;只取第一个 model。
29
+ - **SDF/MOL**:只读第一条记录(`$$$$` 之前),解析 V2000 的原子与键;**V3000 明确拒绝**
30
+ 并提示导出 V2000,而不是猜。
31
+ - 解析器只负责坐标/元素/链/键;渲染在独立模块里,浏览器与 Node 测试共用同一份源码。
32
+
33
+ ### 2. 软件渲染:`structure-render.mjs`
34
+
35
+ - **不依赖 WebGL / Three.js**:纯 JS 软件光栅化,返回 RGB 缓冲,Canvas 只负责
36
+ `putImageData`。这样同一套像素可以被 Node 测试和文档截图复用。
37
+ - 透视投影 + **z-buffer** + 球面/柱面 Phong 光照 + 深度雾化;CPK 配色
38
+ (C 灰、N 蓝、O 红、S 黄、P 橙)。
39
+ - 两种表示:**ball-and-stick**(小球 + 键)与 **space-filling**(范德华球)。
40
+ - 键图:SDF 用显式键;PDB/mmCIF 用共价半径空间网格推断,并与 `CONECT` 合并
41
+ (1CRN 的 CONECT 只有 6 条,不能直接当完整键表)。
42
+ - 边界:面板最多渲染 **12,000 个重原子**;水与氢默认不画;拖动时 1× 采样、松开后 2×;
43
+ 单帧约 50–120 ms(1CRN 327 原子 / 1BNA 486 原子),不做分子表面、静电势或测量。
44
+ - 明确写"preview, not for measurements":这是看的模型,不是分析工具。
45
+
46
+ ### 3. 面板
47
+
48
+ - Molbio tab 的文件分类新增 `structure`;列表、排序、元信息(标题/格式/原子数/链数/残基数)
49
+ 复用现有状态机。
50
+ - 结构记录改用 `<canvas>`:**拖拽旋转、滚轮缩放、双击复位**;工具栏切换
51
+ ball-and-stick / space-filling;元素图例与操作提示在画布下方。
52
+ - 质粒图谱与 sequence logo 仍用原来的 SVG 宿主;只有结构记录走 canvas。
53
+ - 只改浏览器半也改变了 `lib/client.js`,所以根包 `0.24.1 → 0.25.0`、面板包
54
+ `0.1.10 → 0.1.11` 同时升版本。
55
+
56
+ ### 4. 顺带修掉的 bundler 缺陷
57
+
58
+ 客户端 bundler 在生成 re-export 时丢掉了 imported name,产物里所有
59
+ `browser-api.mjs` 再导出都变成 `require(target).undefined`。格式检查一直是绿的,但真实
60
+ 浏览器半会拿到 `undefined`。现在 re-export 保留 `imported`,并在 `test/client.mjs` 里
61
+ 加了"产物不得含 `require(...).undefined`"的结构断言。
62
+
63
+ ### 5. 测试与边界
64
+
65
+ - `test/structure.mjs`:PDB/mmCIF/SDF 已知坐标与计数;共价键推断;渲染像素
66
+ (中心原子、非背景像素、确定性、两种表示、PNG 编码);V3000/空坐标/原子 cap 的错误路径。
67
+ - `test/panel-render.mjs`:真实 PDB 点选后 canvas 被绘制,拖拽会触发重绘。
68
+ - `test/client.mjs`、`test/client-mount.mjs`、`test/contract.mjs`(17 项)与
69
+ `build/client-bundle.mjs --check` 全绿;工具数仍 64,benchmark 仍 65 题。
70
+ - 没有采用 `ctx.documentPreviews.register`:现有 Molbio tab 已有文件列表、canvas 与错误态;
71
+ 新增 keyed 容器换不来新的用户能力。
72
+ - **不做**:卡通 ribbon、分子表面、静电势、对称、V3000、多 model 动画、测量工具。
73
+
13
74
  ## [0.24.1] — 2026-11-08(输入边界与数据安全加固;工具数不变)
14
75
 
15
76
  **不新增工具:工具数保持 64,benchmark 保持 65 题 / 64 覆盖。** 这一版只修“已经在产品里、
package/README.md CHANGED
@@ -383,7 +383,7 @@ mutations remain deferred")。所以插件无法在工作区里合法地落一
383
383
 
384
384
  | 落点 | 内容 |
385
385
  | --- | --- |
386
- | **Molbio**(右栏 tab) | 列出当前会话工作区的序列文件,选中**当场在面板里画出来**:`.dna`/`.gb`/`.gbk` 出质粒图谱 + 特征表,`.fa`/`.fasta` 出序列标识图 |
386
+ | **Molbio**(右栏 tab) | 列出当前会话工作区的序列与结构文件,选中**当场在面板里画出来**:`.dna`/`.gb`/`.gbk` 出质粒图谱 + 特征表,`.fa`/`.fasta` 出序列标识图,`.pdb`/`.cif`/`.sdf` 等出可旋转的 3D ball-and-stick / space-filling 模型 |
387
387
  | **Papers**(右栏 tab) | 把 `papers.json` 渲染成可搜索的阅读列表(标题直达 PubMed/原 URL) |
388
388
  | **图谱调用卡**(对话内) | 画图谱的工具**直接在对话里画出这次产出的图谱**,不必再去开文件 |
389
389
 
@@ -767,6 +767,8 @@ dsh-molbio-tools/
767
767
  ├── methylation.mjs # 甲基化敏感位点检查 + 双酶切 buffer 兼容
768
768
  ├── protein.mjs # 蛋白性质 / 肽段酶切 / 密码子优化
769
769
  ├── protein-structure.mjs # 螺旋轮与疏水性图
770
+ ├── structure-preview.mjs # PDB/mmCIF/SDF 解析器(浏览器面板)
771
+ ├── structure-render.mjs # 软件光栅化 3D ball-and-stick / space-filling 渲染器
770
772
  ├── protein-charge.mjs # 电荷-pH 滴定曲线 + pI(bjellqvist/emboss 两套 pK)
771
773
  ├── genbank.mjs # GenBank flatfile 解析器
772
774
  ├── snapgene.mjs # SnapGene .dna 二进制解析器
@@ -5,12 +5,13 @@
5
5
  * re-exported from the SAME source files the Node plugin ships, so the browser
6
6
  * bundle can never drift from the plugin's behaviour.
7
7
  *
8
- * Only these modules are browser-safe (they import nothing but `./lib.mjs`,
9
- * which is pure computation): lib, genbank, snapgene, plasmid, msa, logo,
10
- * crispr, align, seqio, plot, design. The Node-only ones (view, papers,
11
- * records, sanger, protein is fine but unused here, index) are deliberately
12
- * absent — importing them would drag `node:child_process` and friends into the
13
- * browser bundle, which the bundler rejects loudly.
8
+ * Only these modules are browser-safe (they import nothing but `./lib.mjs` and
9
+ * `./svgio.mjs`, which are pure computation): lib, genbank, snapgene, plasmid,
10
+ * msa, logo, crispr, align, seqio, plot, design, protein-structure,
11
+ * structure-preview, structure-render. The Node-only ones (view, papers, records,
12
+ * sanger, index)
13
+ * are deliberately absent — importing them would drag `node:child_process` and
14
+ * friends into the browser bundle, which the bundler rejects loudly.
14
15
  */
15
16
  export {
16
17
  DNA_BASES,
@@ -53,4 +54,13 @@ export {
53
54
  export { designTaqmanProbes, evaluateProbe, probeCandidates } from '../taqman.mjs';
54
55
  export { checkMultiplex } from '../multiplex.mjs';
55
56
  export { helicalWheel, hydropathyProfile, renderHelicalWheel, renderHydropathyPlot } from '../protein-structure.mjs';
57
+ export { STRUCTURE_EXTENSIONS, parseStructureText, structureFormatOf } from '../structure-preview.mjs';
58
+ export {
59
+ DEFAULT_STRUCTURE_VIEW,
60
+ MAX_RENDER_ATOMS,
61
+ STRUCTURE_ELEMENT_COLORS,
62
+ STRUCTURE_REPRESENTATIONS,
63
+ inferStructureBonds,
64
+ renderStructureImage,
65
+ } from '../structure-render.mjs';
56
66
  export { analyzeMethylation, bufferReport, planDoubleDigest } from '../methylation.mjs';
@@ -79,7 +79,10 @@ function parseModule(source, path) {
79
79
  for (const match of source.matchAll(EXPORT_LIST_RE)) {
80
80
  for (const name of parseNames(match[1], path)) exports.push({ local: name.local, source: null });
81
81
  }
82
- for (const name of reexports) exports.push({ local: name.local, source: name.specifier });
82
+ // The imported name is what the target namespace must be read for; dropping
83
+ // it emitted `exports.x = require(target).undefined` for EVERY re-export,
84
+ // which silently broke browser-api.mjs while the format checks stayed green.
85
+ for (const name of reexports) exports.push({ local: name.local, imported: name.imported, source: name.specifier });
83
86
  const listCount = (source.match(EXPORT_LIST_RE) ?? []).length;
84
87
  const reexportCount = (source.match(REEXPORT_RE) ?? []).length;
85
88
  const keywordCount = (source.match(/^export\s/gm) ?? []).length;
@@ -53,9 +53,14 @@ import {
53
53
  parseAlignmentFile,
54
54
  parseLibrary,
55
55
  parsePlasmidFile,
56
+ parseStructureFile,
56
57
  plasmidSvg,
57
58
  readWorkspaceText,
58
59
  sortEntries,
60
+ structureImage,
61
+ DEFAULT_STRUCTURE_VIEW,
62
+ STRUCTURE_ELEMENT_COLORS,
63
+ STRUCTURE_REPRESENTATIONS,
59
64
  } from './panel-core.mjs';
60
65
 
61
66
  /** This implementation's identity, and the key its body/title register under. */
@@ -135,6 +140,20 @@ const styles = {
135
140
  error: { color: 'var(--dsh-danger, #c0392b)', padding: '10px 12px', whiteSpace: 'pre-wrap' },
136
141
  muted: { opacity: 0.6, padding: '10px 12px' },
137
142
  svg: { maxWidth: '100%', height: 'auto' },
143
+ canvas: { maxWidth: '100%', height: 'auto', display: 'block', border: '1px solid var(--dsh-border, #e3e6ea)', borderRadius: '6px', cursor: 'grab', touchAction: 'none', background: '#f7f9fb' },
144
+ button: (active) => ({
145
+ fontSize: '11px',
146
+ padding: '2px 8px',
147
+ cursor: 'pointer',
148
+ border: '1px solid var(--dsh-border, #e3e6ea)',
149
+ borderRadius: '4px',
150
+ background: active ? 'var(--dsh-selected, #eaf2ff)' : 'transparent',
151
+ fontWeight: active ? 600 : 400,
152
+ color: 'inherit',
153
+ }),
154
+ legend: { display: 'flex', gap: '10px', flexWrap: 'wrap', marginTop: '8px', fontSize: '11px', opacity: 0.75 },
155
+ legendItem: { display: 'inline-flex', alignItems: 'center', gap: '4px' },
156
+ legendDot: { width: '9px', height: '9px', borderRadius: '50%', display: 'inline-block' },
138
157
  table: { borderCollapse: 'collapse', marginTop: '12px', fontSize: '12px', width: '100%' },
139
158
  th: { textAlign: 'left', padding: '4px 8px', borderBottom: '1px solid var(--dsh-border, #e3e6ea)', opacity: 0.7, fontWeight: 600 },
140
159
  td: { padding: '3px 8px', borderBottom: '1px solid var(--dsh-border-soft, #f0f2f5)' },
@@ -147,7 +166,11 @@ function MolbioPanel({ sessionId, remote, useSessions }) {
147
166
  const [selected, setSelected] = useState(null);
148
167
  const [record, setRecord] = useState(null);
149
168
  const [status, setStatus] = useState({ kind: cwd === undefined ? 'waiting' : 'loading' });
169
+ const [view, setView] = useState({ ...DEFAULT_STRUCTURE_VIEW });
170
+ const [dragging, setDragging] = useState(false);
150
171
  const svgHost = useRef(null);
172
+ const canvasHost = useRef(null);
173
+ const drag = useRef(null);
151
174
 
152
175
  // Directory listing, re-read when the session's workspace root changes.
153
176
  useEffect(() => {
@@ -177,8 +200,13 @@ function MolbioPanel({ sessionId, remote, useSessions }) {
177
200
  const kind = classifyEntry(selected);
178
201
  const parsed = kind === 'plasmid'
179
202
  ? { kind, ...parsePlasmidFile(selected.name, bytes) }
180
- : { kind, ...parseAlignmentFile(bytes) };
203
+ : kind === 'alignment'
204
+ ? { kind, ...parseAlignmentFile(bytes) }
205
+ : { kind, ...parseStructureFile(selected.name, bytes) };
181
206
  setRecord(parsed);
207
+ // A new structure starts from the same deterministic camera; the
208
+ // previous file's yaw/pitch would be meaningless on a different molecule.
209
+ if (parsed.kind === 'structure') setView({ ...DEFAULT_STRUCTURE_VIEW });
182
210
  setStatus({ kind: 'ready' });
183
211
  })
184
212
  .catch((error) => {
@@ -198,7 +226,7 @@ function MolbioPanel({ sessionId, remote, useSessions }) {
198
226
  const host = svgHost.current;
199
227
  if (host === null) return;
200
228
  host.replaceChildren();
201
- if (record === null || status.kind !== 'ready') return;
229
+ if (record === null || status.kind !== 'ready' || record.kind === 'structure') return;
202
230
  // The renderer refuses what it cannot draw (renderPlasmidMap caps a map at
203
231
  // 200 features). Letting that throw out of the effect leaves the pane empty
204
232
  // with no explanation, so it becomes the pane's error state instead.
@@ -219,15 +247,90 @@ function MolbioPanel({ sessionId, remote, useSessions }) {
219
247
  host.append(document.importNode(svg, true));
220
248
  }, [record, status.kind]);
221
249
 
250
+ // Structures are rasterized into an RGB buffer and painted into a canvas.
251
+ // The renderer is deterministic and shared with the Node tests; only the
252
+ // canvas plumbing lives here. During a drag the frame drops to 1x
253
+ // supersampling and returns to 2x when the pointer is released.
254
+ useEffect(() => {
255
+ if (record === null || record.kind !== 'structure' || status.kind !== 'ready') return;
256
+ const canvas = canvasHost.current;
257
+ if (canvas === null || typeof canvas.getContext !== 'function') return;
258
+ const context = canvas.getContext('2d');
259
+ if (context === null || typeof context.createImageData !== 'function') return;
260
+ const width = Number.isInteger(canvas.width) ? canvas.width : 640;
261
+ const height = Number.isInteger(canvas.height) ? canvas.height : 480;
262
+ let image;
263
+ try {
264
+ image = structureImage(record, {
265
+ width,
266
+ height,
267
+ yaw: view.yaw,
268
+ pitch: view.pitch,
269
+ zoom: view.zoom,
270
+ representation: view.representation,
271
+ // Large assemblies stay at interaction quality even when settled;
272
+ // 2× sampling is reserved for the small structures it visibly helps.
273
+ supersample: dragging || record.atoms.length > 4000 ? 1 : 2,
274
+ });
275
+ } catch (error) {
276
+ setStatus({ kind: 'error', message: String(error?.message ?? error) });
277
+ return;
278
+ }
279
+ const imageData = context.createImageData(width, height);
280
+ for (let source = 0, target = 0; source < image.rgb.length; source += 3, target += 4) {
281
+ imageData.data[target] = image.rgb[source];
282
+ imageData.data[target + 1] = image.rgb[source + 1];
283
+ imageData.data[target + 2] = image.rgb[source + 2];
284
+ imageData.data[target + 3] = 255;
285
+ }
286
+ context.putImageData(imageData, 0, 0);
287
+ }, [record, status.kind, view, dragging]);
288
+
289
+ const startDrag = (event) => {
290
+ if (typeof event?.currentTarget?.setPointerCapture === 'function' && event.pointerId !== undefined) {
291
+ try {
292
+ event.currentTarget.setPointerCapture(event.pointerId);
293
+ } catch {
294
+ // A synthetic pointer without capture support is still draggable.
295
+ }
296
+ }
297
+ drag.current = { x: event.clientX, y: event.clientY, yaw: view.yaw, pitch: view.pitch };
298
+ setDragging(true);
299
+ };
300
+
301
+ const moveDrag = (event) => {
302
+ const start = drag.current;
303
+ if (start === null) return;
304
+ const yaw = start.yaw + (event.clientX - start.x) * 0.01;
305
+ const pitch = Math.max(-1.4, Math.min(1.4, start.pitch - (event.clientY - start.y) * 0.01));
306
+ setView((current) => ({ ...current, yaw, pitch }));
307
+ };
308
+
309
+ const endDrag = () => {
310
+ drag.current = null;
311
+ setDragging(false);
312
+ };
313
+
314
+ const zoomView = (event) => {
315
+ // preventDefault keeps the sidebar from scrolling while the wheel zooms.
316
+ if (typeof event.preventDefault === 'function') event.preventDefault();
317
+ setView((current) => ({
318
+ ...current,
319
+ zoom: Math.max(0.35, Math.min(5, current.zoom * Math.exp(-Number(event.deltaY ?? 0) * 0.001))),
320
+ }));
321
+ };
322
+
323
+ const resetView = () => setView({ ...DEFAULT_STRUCTURE_VIEW });
324
+
222
325
  const openable = useMemo(() => entries.filter(isOpenable), [entries]);
223
326
 
224
- const head = h('div', { style: styles.listHead }, 'Sequence files');
327
+ const head = h('div', { style: styles.listHead }, 'Sequence & structure files');
225
328
  const list = h(
226
329
  'div',
227
330
  { style: styles.list },
228
331
  head,
229
332
  ...(openable.length === 0
230
- ? [h('div', { key: 'none', style: styles.muted }, 'No .dna / .gb / .gbk / .fasta files in this workspace')]
333
+ ? [h('div', { key: 'none', style: styles.muted }, 'No .dna / .gb / .gbk / .fasta / .pdb / .cif / .sdf files in this workspace')]
231
334
  : openable.map((entry) => {
232
335
  const kind = classifyEntry(entry);
233
336
  return h(
@@ -282,6 +385,40 @@ function MolbioPanel({ sessionId, remote, useSessions }) {
282
385
  h('span', null, `${record.rows[0]?.length ?? 0} aligned columns`)));
283
386
  body.push(h('div', { key: 'svg', ref: svgHost, style: styles.svg }));
284
387
  }
388
+ if (status.kind === 'ready' && record !== null && record.kind === 'structure') {
389
+ body.push(h('div', { key: 'meta', style: styles.meta },
390
+ h('span', null, record.title === '' ? record.name : record.title),
391
+ h('span', null, record.format.toUpperCase()),
392
+ h('span', null, `${record.atoms.length} atoms`),
393
+ h('span', null, `${record.chain_count} chain(s)`),
394
+ h('span', null, `${record.residue_count} residue(s)`)));
395
+ body.push(h('div', { key: 'controls', style: styles.meta },
396
+ ...STRUCTURE_REPRESENTATIONS.map((representation) => h('button', {
397
+ key: representation,
398
+ style: styles.button(view.representation === representation),
399
+ onClick: () => setView((current) => ({ ...current, representation })),
400
+ }, representation === 'ball-and-stick' ? 'Ball & stick' : 'Space filling')),
401
+ h('button', { key: 'reset', style: styles.button(false), onClick: resetView }, 'Reset view')));
402
+ body.push(h('canvas', {
403
+ key: 'canvas',
404
+ ref: canvasHost,
405
+ width: 640,
406
+ height: 480,
407
+ style: styles.canvas,
408
+ onPointerDown: startDrag,
409
+ onPointerMove: moveDrag,
410
+ onPointerUp: endDrag,
411
+ onPointerCancel: endDrag,
412
+ onWheel: zoomView,
413
+ onDoubleClick: resetView,
414
+ 'aria-label': '3D structure preview; drag to rotate, wheel to zoom',
415
+ }));
416
+ body.push(h('div', { key: 'legend', style: styles.legend },
417
+ ...['C', 'N', 'O', 'S', 'P'].map((element) => h('span', { key: element, style: styles.legendItem },
418
+ h('span', { style: { ...styles.legendDot, background: STRUCTURE_ELEMENT_COLORS[element] } }),
419
+ element))));
420
+ body.push(h('div', { key: 'note', style: styles.note }, 'Software-rendered 3D model. Drag to rotate, wheel to zoom. Ball-and-stick / space-filling; not for measurements.'));
421
+ }
285
422
 
286
423
  return h('div', { style: styles.root }, list, h('div', { style: styles.main }, ...body));
287
424
  }
@@ -18,6 +18,12 @@ import {
18
18
  columnComposition,
19
19
  renderSequenceLogo,
20
20
  progressiveAlign,
21
+ parseStructureText,
22
+ renderStructureImage,
23
+ DEFAULT_STRUCTURE_VIEW,
24
+ STRUCTURE_ELEMENT_COLORS,
25
+ STRUCTURE_EXTENSIONS,
26
+ STRUCTURE_REPRESENTATIONS,
21
27
  } from './browser-api.mjs';
22
28
 
23
29
  /** Extensions the panel can open, mapped to the parser that handles them. */
@@ -36,6 +42,9 @@ export const ALIGNMENT_EXTENSIONS = {
36
42
  fas: 'fasta',
37
43
  };
38
44
 
45
+ /** Structure formats the panel opens, mapped to the parser kind. */
46
+ export { DEFAULT_STRUCTURE_VIEW, STRUCTURE_ELEMENT_COLORS, STRUCTURE_EXTENSIONS, STRUCTURE_REPRESENTATIONS };
47
+
39
48
  /** Lowercase extension without the dot, or '' when there is none. */
40
49
  export function extensionOf(name) {
41
50
  const match = /\.([A-Za-z0-9]+)$/.exec(String(name));
@@ -45,7 +54,7 @@ export function extensionOf(name) {
45
54
  /**
46
55
  * How the panel should treat one workspace entry.
47
56
  * @param {{name?: string, type?: string}} entry a `workspaceFiles.list` row.
48
- * @returns {'plasmid'|'alignment'|'other'|'directory'}
57
+ * @returns {'plasmid'|'alignment'|'structure'|'other'|'directory'}
49
58
  */
50
59
  export function classifyEntry(entry) {
51
60
  if (entry === null || typeof entry !== 'object') return 'other';
@@ -54,18 +63,19 @@ export function classifyEntry(entry) {
54
63
  const extension = extensionOf(entry.name ?? '');
55
64
  if (PLASMID_EXTENSIONS[extension] !== undefined) return 'plasmid';
56
65
  if (ALIGNMENT_EXTENSIONS[extension] !== undefined) return 'alignment';
66
+ if (STRUCTURE_EXTENSIONS[extension] !== undefined) return 'structure';
57
67
  return 'other';
58
68
  }
59
69
 
60
70
  /** Whether the panel can open this entry at all. */
61
71
  export function isOpenable(entry) {
62
72
  const kind = classifyEntry(entry);
63
- return kind === 'plasmid' || kind === 'alignment';
73
+ return kind === 'plasmid' || kind === 'alignment' || kind === 'structure';
64
74
  }
65
75
 
66
- /** Sort entries: plasmid files first, then alignments, then the rest, by name. */
76
+ /** Sort entries: plasmid files first, then alignments, structures, the rest. */
67
77
  export function sortEntries(entries) {
68
- const rank = { plasmid: 0, alignment: 1, other: 2, directory: 3 };
78
+ const rank = { plasmid: 0, alignment: 1, structure: 2, other: 3, directory: 4 };
69
79
  return [...entries].sort((left, right) => {
70
80
  const byKind = rank[classifyEntry(left)] - rank[classifyEntry(right)];
71
81
  if (byKind !== 0) return byKind;
@@ -164,6 +174,44 @@ export function plasmidSvg(record, options = {}) {
164
174
  });
165
175
  }
166
176
 
177
+ /**
178
+ * Parse a structure file into the record the panel draws.
179
+ *
180
+ * The size guard is the same browser-specific cap as {@link parsePlasmidFile}:
181
+ * parsing runs on the UI thread, so a 200 MB PDB must be refused with a message
182
+ * rather than freeze the tab. The format comes from the extension, exactly as
183
+ * the Node tools choose their parser.
184
+ * @param {string} name file name.
185
+ * @param {Uint8Array} bytes raw file content.
186
+ * @returns {object} a parseStructureText record.
187
+ */
188
+ export function parseStructureFile(name, bytes) {
189
+ if (!(bytes instanceof Uint8Array)) throw new MolbioInputError('file content must be a Uint8Array');
190
+ const extension = extensionOf(name);
191
+ const format = STRUCTURE_EXTENSIONS[extension];
192
+ if (format === undefined) {
193
+ throw new MolbioInputError(`the panel does not open .${extension} structure files; use .pdb, .ent, .cif, .mmcif, .sdf or .mol`);
194
+ }
195
+ if (bytes.length > MAX_PANEL_FILE_BYTES) {
196
+ throw new MolbioInputError(
197
+ `${name} is ${(bytes.length / 1024 / 1024).toFixed(1)} MB; the panel opens structure files up to ${MAX_PANEL_FILE_BYTES / 1024 / 1024} MB`,
198
+ );
199
+ }
200
+ return parseStructureText(format, new TextDecoder('utf-8').decode(bytes), name);
201
+ }
202
+
203
+ /**
204
+ * Render a parsed structure to an RGB buffer for the canvas.
205
+ * @param {object} record a {@link parseStructureFile} result.
206
+ * @param {{width?: number, height?: number, yaw?: number, pitch?: number,
207
+ * zoom?: number, representation?: string, supersample?: 1|2}} [options]
208
+ * @returns {{width:number, height:number, rgb:Uint8Array, atoms:number,
209
+ * bonds:number, representation:string}}
210
+ */
211
+ export function structureImage(record, options = {}) {
212
+ return renderStructureImage(record, options);
213
+ }
214
+
167
215
  /**
168
216
  * Parse an alignment file (FASTA) into the rows the logo needs.
169
217
  * @param {Uint8Array} bytes raw FASTA content.
@@ -10,14 +10,14 @@
10
10
 
11
11
  | 落点 | 内容 | 数据来源 |
12
12
  | --- | --- | --- |
13
- | **Molbio**(右栏 tab) | 列出会话工作区里的序列文件,选中即画:`.dna`/`.gb`/`.gbk` → 质粒图谱 + 特征表;`.fa`/`.fasta`(≥2 条)→ 序列标识图 | `workspaceFiles.readAll`(二进制,含 SnapGene) |
13
+ | **Molbio**(右栏 tab) | 列出会话工作区里的序列与结构文件,选中即画:`.dna`/`.gb`/`.gbk` → 质粒图谱 + 特征表;`.fa`/`.fasta`(≥2 条)→ 序列标识图;`.pdb`/`.ent`/`.cif`/`.mmcif`/`.sdf`/`.mol` → 可旋转的 3D ball-and-stick / space-filling 模型 | `workspaceFiles.readAll` / `readBytes`(二进制,含 SnapGene 与结构文件) |
14
14
  | **Papers**(右栏 tab) | 把 `molbio_paper_*` 工具维护的 `papers.json` 渲染成可搜索的阅读列表 + 详情面板(标题/作者/期刊/年份/PMID/URL/标签/笔记,标题链接到 PubMed 或原 URL) | `workspaceFiles.read`(文本) |
15
15
  | **图谱调用卡**(`tool.call.toolview`) | `molbio_plasmid_map` / `molbio_plasmid_map_file` 的调用卡:在对话里直接画出这次调用产出的图谱(摘要行 + 写入路径),失败时显示错误文本与 Inspect 入口 | 工具自己声明的 `output.presentationMeta`(见下) |
16
16
  | **图形调用卡**(`tool.call.toolview`) | `molbio_sequence_logo` / `molbio_grna_design` 的调用卡:logo 直接画图;gRNA 画 `map_path` 并列出前 5 条 guide(无 map 时仍有摘要与表格) | 同上,kind = `molbio-figure` |
17
17
  | **PubMed API key**(`plugins.detail.section`) | 插件 → 已安装插件 → `dsh-molbio-tools` 详情页里的一段表单:password 输入 + Save/Remove,状态只报「已配置 / 来源 / 未配置」,永不回显值 | `ctx.remote.credentials.describe` / `set` / `unset`(host 的凭据 seam) |
18
18
 
19
- **解析与渲染全部在浏览器里跑本仓库自己的模块**(`lib/genbank/snapgene/plasmid/msa/logo`),
20
- 不经过任何工具调用、不落盘 SVG、不弹系统查看器。
19
+ **解析与渲染全部在浏览器里跑本仓库自己的模块**(`lib/genbank/snapgene/plasmid/msa/logo`
20
+ 与 `structure-preview` / `structure-render`),不经过任何工具调用、不落盘 SVG、不弹系统查看器。
21
21
 
22
22
  ### 调用卡的数据通路(`presentationMeta`)
23
23
 
@@ -174,8 +174,9 @@ seed)、禁止动态 `import()`、禁止跨插件值导入。它自己只降
174
174
  生效、以及坏库时列表仍宣称"还没有文献"。插件详情页的 key 段也被真实驱动:
175
175
  `describe` → 输入 → Save(trim 后写入 `NCBI_API_KEY`)→ 状态刷新 + 清空输入 → Remove →
176
176
  provider 拒绝时消息原样显示。
177
- 4. **数据通路(质粒)**:真实 pUC118 `.dna` → 记录(名称/长度/拓扑/特征,AmpR 2102-2962
178
- 反链)→ SVG;GenBank 文本路径与 Node 工具解析结果**逐字段一致**;FASTA → 比对 → logo;
177
+ 4. **数据通路(质粒 / 比对 / 结构)**:真实 pUC118 `.dna` → 记录(名称/长度/拓扑/特征,
178
+ AmpR 2102-2962 反链)→ SVG;GenBank 文本路径与 Node 工具解析结果**逐字段一致**;
179
+ FASTA → 比对 → logo;PDB → 3D 模型(点选后 canvas 被绘制,拖拽触发重绘);
179
180
  扩展名分类、排序、base64 解码、非法输入的错误路径。
180
181
  5. **数据通路(文献库)**:`readWorkspaceText` 的三种结果(有文本 / 文件不存在 / 读取失败)
181
182
  分别对应"有库 / 空库 / 报错";`parseLibrary` 与工具侧契约一致(`{papers: [...]}`)且
@@ -256,6 +257,11 @@ shadow(无冲突),面板的 `remote`/`slot` 服务不受影响。
256
257
  后续可考虑把同样的卡片给 `molbio_dotplot` 等新画图工具。**任何新卡片上线前先跑
257
258
  `test/client.mjs` 的抢座位顺序那一层**:0.7.1 的卡片因为裸 `register()` 抢座位而让 GUI
258
259
  起不来(0.7.2 修复,见 §4 第一条);`contract` 会从产物里的 key 表反推投影数。
260
+ - **结构预览**:✅ 已落地(0.25.0)。Molbio tab 直接读 PDB/mmCIF/SDF-MOL V2000,
261
+ 用 canvas 画可旋转的 3D **ball-and-stick / space-filling** 模型(软件光栅化 + z-buffer +
262
+ Phong 光照);明确的边界是 12,000 重原子、5 MB、无 V3000、无分子表面/静电势、
263
+ “preview, not for measurements”。没有走 `ctx.documentPreviews`:现有 tab 已有文件列表、
264
+ canvas 与错误态,新增 keyed 容器换不来新的用户能力。
259
265
  - **文献库写回**(可选):若要面板内编辑笔记/标签,需要先定义与 `molbio_paper_*` 工具
260
266
  一致的并发契约(乐观版本号或串行化队列),或让工具走同一条 RPC。
261
267
  - **上游提案(路径 B)**:preset 渠道挂 client 仍是真实生态需求,可以在社区反馈时
package/docs/history.md CHANGED
@@ -16,6 +16,7 @@
16
16
 
17
17
  | 包版本 | preset 版本 | 内容 | 工具数 |
18
18
  | --- | --- | --- | --- |
19
+ | 0.25.0 | — | **浏览器内真实 3D 结构预览**:Molbio 面板读 PDB/mmCIF/SDF-MOL V2000,软件光栅化 ball-and-stick / space-filling,拖拽旋转、滚轮缩放;工具数不变 | 64 |
19
20
  | 0.24.1 | — | **输入边界与数据安全加固**:BibTeX 不再覆盖文献库、相对读取按会话工作区、批量/glob/并发声明修复、20 万行输入不再 `RangeError`;工具数不变 | 64 |
20
21
  | 0.24.0 | — | **数据卫生版**:新增 `molbio_contamination_screen`(内置载体/接头短签名,双链、0–2 错配)与 `molbio_fastq_trim`(质量/接头/引物/poly/N/长度,写 FASTQ/FASTA);修 FASTQ QC 接头表 Small RNA 5' 的 RNA→DNA 静默错 | 62 → 64 |
21
22
  | 0.23.0 | — | **2.0 遗留修复 + 2.9a 调用卡 + 两个候选池工具**:跨内含子引物报告真实反向分子(F 在 R 上游)、logo/gRNA 调用卡、`molbio_protein_charge`、`molbio_dotplot`;同版完成 **DSH 0.2.2-rc.1 适配**(删掉上游已移除的 `list-agents` 子路径行、subagent 模型选择改用新键、守卫补上 specifier 解析) | 60 → 62 |
@@ -50,6 +51,35 @@
50
51
 
51
52
  ---
52
53
 
54
+ ## v0.25.0(2026-11-10,包 0.25.0)
55
+
56
+ **三条教训,关于“看得见”“同一份源码”和“产物真的能跑”。**
57
+
58
+ **(1) 2D PCA 不是文献里的结构图。** 第一版把 3D 坐标投到两个主轴,画一张静态 SVG;
59
+ 它保留了相对位置,却丢掉深度、遮挡和空间关系,蛋白质像线稿、核酸像折线。学术界常见的
60
+ 配图是立体的 ball-and-stick、space-filling 或 surface,而不是单张投影。0.25.0 在发布前
61
+ 改为**真实原子模型**:透视、z-buffer、Phong 光照、深度雾化;球和键按元素着色,支持
62
+ ball-and-stick 与 space-filling 两种表示。没有采用卡通 ribbon:它需要二级结构、朝向和
63
+ 带宽的额外几何,且不是这一版要解决的问题。
64
+
65
+ **(2) 软件光栅化让“浏览器里看到的”和“测试里验证的”是同一套像素。** 不引入 WebGL 或
66
+ Three.js;`structure-render.mjs` 返回 RGB 缓冲,面板只把它 `putImageData` 到 canvas。
67
+ Node 测试能直接检查中心原子、非背景像素、两种表示和 PNG 编码;否则 canvas 行为只能靠
68
+ 肉眼或第二个渲染器验证,二者必分叉。代价是 CPU 渲染:拖动时 1× 采样,松开后 2×,
69
+ 12,000 个重原子封顶。
70
+
71
+ **(3) 产物能生成,不等于产物能运行。** 加结构预览时发现 bundler 在生成 re-export 时
72
+ 丢掉了 imported name,所有 `browser-api.mjs` 再导出在产物里都是
73
+ `require(target).undefined`;格式/模块存在性检查全绿,但真实浏览器半拿到 undefined。
74
+ 现在保留 imported name,并在 `test/client.mjs` 加一条“产物不得含
75
+ `require(...).undefined`”的结构断言。这也是为什么“同一份源码”必须配合**执行**产物的
76
+ 守卫,而不只是文本对比。
77
+
78
+ **顺带记录**:只改浏览器半也会改变根包产物,所以根包 `0.24.1 → 0.25.0`、面板包
79
+ `0.1.10 → 0.1.11` 同时升版本。工具数保持 64,benchmark 保持 65 题。结构解析与渲染在
80
+ `structure-preview.mjs` / `structure-render.mjs`,面板用 canvas 承载,不再使用
81
+ `ctx.documentPreviews`(现有 tab 已有文件列表、错误态与画布)。
82
+
53
83
  ## v0.24.1(2026-11-08,包 0.24.1)
54
84
 
55
85
  **三条教训,都是“合法输入 + 默认路径”才会暴露的错。**
@@ -32,7 +32,7 @@
32
32
 
33
33
  - **包**:`dsh-molbio-tools`,零依赖 DSH 插件包,**64 个 `molbio_*` 工具**(其中
34
34
  `molbio_plot` 一个工具承载 8 种图表)。
35
- - **基线**:DSH **0.2.2-rc.1**;`package.json` 版本 0.24.1(DSH 桌面版内嵌 Node 24.21.0)。
35
+ - **基线**:DSH **0.2.2-rc.1**;`package.json` 版本 0.25.0(DSH 桌面版内嵌 Node 24.21.0)。
36
36
  - **两条分发渠道**:根包(工具 + 面板)与 `packages/molbio-panel`(只面板),
37
37
  **两个独立的 npm 条目、两条独立的版本线**,必须分别发布。
38
38
  - **工具只在专属模式里出现**(设计如此):bundle 的 `cordis.patch.yml` 只插一行**惰性宿主锚点**
package/docs/roadmap.md CHANGED
@@ -10,7 +10,7 @@ L = 需要新机制或外部资源)。
10
10
 
11
11
  ---
12
12
 
13
- ## 1. 当前状态(0.24.1)
13
+ ## 1. 当前状态(0.25.0)
14
14
 
15
15
  - 64 个 `molbio_*` 工具,零依赖,随 preset 分发(见 [README](../README.md))。
16
16
  - 离线测试金字塔 + 组合漂移守卫全绿;`benchmark/` 提供**模型使用**维度的评估
@@ -45,6 +45,11 @@ L = 需要新机制或外部资源)。
45
45
  的 key 解析顺序变为 **`api_key` 参数 → DSH 凭据库 → 环境变量**(环境变量会被 agent 自己的 shell
46
46
  子进程继承,凭据库不会);同时插件详情页出现 **PubMed API key** 表单,用户不必改 YAML。
47
47
  没有凭据库的组合行为与 0.22.0 相同。
48
+ - **0.25.0 交付 2.9c 真实 3D 结构预览(工具数不变)**:Molbio 面板新增 PDB/mmCIF/SDF-MOL
49
+ V2000 的 **ball-and-stick / space-filling** 软件光栅化(透视、z-buffer、Phong 光照、
50
+ 深度雾化、CPK 配色),支持拖拽旋转、滚轮缩放与表示切换;12,000 重原子封顶,
51
+ 明确拒绝 V3000,并标注"preview, not for measurements"。根包 `0.24.1 → 0.25.0`、
52
+ 面板包 `0.1.10 → 0.1.11`。同版修掉客户端 bundler 丢掉 re-export imported name 的缺陷。
48
53
  - **0.24.1 是输入边界与数据安全加固版(工具数不变)**:修掉 BibTeX 导出覆盖文献库、
49
54
  相对路径按 provider cwd 解析、四个默认写 SVG 的工具被误标并发安全、批量 glob /
50
55
  `max_files` / 坏路径、文献与协议库坏条目,以及 20 万行表格 / 13 万条 reads 触发的
@@ -170,9 +175,10 @@ v16 的 CRISPR 已有订购 CSV 先例,可直接复用格式。
170
175
  (`molbio-figure` 投影 + `FIGURE_TOOL_KEYS`;logo 画图,gRNA 画图谱并列出前 5 条 guide)。
171
176
  `test/map-card.mjs` 现在同时跑两种卡片,`contract` 从产物里的两张 key 表反推投影数。
172
177
  - 文献库**写回**需先定并发契约(当前只读);
173
- - 结构文件的浏览器内预览(`.pdb/.cif/.sdf/.mol`):`ctx.documentPreviews.register` +
174
- keyed 座位是**容器**,不是现成的 3D 查看器——要么只做 2D 投影(Cα 轨迹/二级结构条带),
175
- 要么单独估工。
178
+ - ✅ **已做(0.25.0)**:结构文件的浏览器内预览(`.pdb/.ent/.cif/.mmcif/.sdf/.mol`)
179
+ 接在现有 Molbio tab 里,做**真实 3D 原子模型**(ball-and-stick / space-filling,
180
+ 透视 + z-buffer + Phong 光照);拖拽旋转、滚轮缩放。没有走 `ctx.documentPreviews`,
181
+ 也没有做卡通 ribbon、分子表面或静电势。边界与测试见 [history.md](history.md) 的 v0.25.0 段。
176
182
 
177
183
  ### 2.10 ✅ 图表八种全部落地(0.17.0 第一批 3 种,0.18.0 第二批 3 种 + 色带)
178
184