From aa323af351fa5a99872f41b4d9b817a3f90aa909 Mon Sep 17 00:00:00 2001 From: Tsung-Han Yang Date: Tue, 21 Jul 2026 11:43:35 -0400 Subject: [PATCH] feat(ui): solid mode-displacement arrows + CIF/mCIF export from the 3D card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arrows (requirement 1). Two independent problems with the green distortion-mode arrows on the PDF page: Too long — the scale was span·0.34 with span the bounding-box body DIAGONAL, so on GaTa4Se8 (10.36 A cubic) the longest arrow ran ~6 A, well past the neighbouring site; the pattern read as a vector field over the cell instead of a displacement of each atom. Now min(shortestCellEdge·0.26, span·0.12) ~ 1.8 A, about one bond. Tying it to the cell edge rather than the diagonal also stops the arrows growing when a supercell is displayed. Stems too thin — THREE.ArrowHelper draws its shaft with LineBasicMaterial, which WebGL rasterizes at a single device pixel however close the camera gets, so the stem was a hairline that disappeared against the lit spheres. New `arrowBuilder` emits a real cylinder shaft + cone head in MeshPhongMaterial, lit by the same lights as the atoms and bonds. Shaft/head sizes are constant across a set so length alone carries the amplitude and the smallest arrow of a pattern stays legible; unit geometries are built once per overlay and scaled per arrow. Head length is capped at a fraction of a short arrow so a small one keeps a shaft instead of collapsing to a bare cone. Verified on the GaTa4Se8 demo, mode 1 @ Gamma (Ta1): the <111> arrows on the 16 Ta copies now read as solid local displacements. Export (requirement 2). StructureView gains `exports` — a list of host-supplied {label, title, run} descriptors rendered as download buttons in its toolbar. Descriptors rather than a flag so the viewer never learns which flavour it is writing: the label tracks the content, which is what makes room for the mCIF work ahead (moments today, mode displacements once the writer carries them). Wired into three hosts: the PDF card (phase on screen), the Rietveld card (phase the picker selects, mCIF when the primary phase carries moments) and the magnetic analysis card (mCIF at the current amplitudes as soon as a group is previewed, so file and arrows agree). Fix found while wiring: EVERY CIF export wrote the STARTING structure. structureToCif reads coordinates and cell straight off the model and consults the parameters only for their esds, but all three workbenches passed the pre-refinement `structure` — so exported files carried starting coordinates annotated with refined uncertainties and refined R-factors. All three now pass the model with parameters applied, and the powder path routes bindings by phase (as refinedPhases does) so a multi-phase session cannot attach one phase's esds to another's identically-labelled site. cif.test pins the contract both ways. Full suite green (1126 passed); typecheck clean. Co-Authored-By: Claude Opus 4.8 --- src/app/PdfWorkbench.tsx | 30 ++++++- src/app/PowderWorkbench.tsx | 49 +++++++++-- src/app/SingleCrystalWorkbench.tsx | 5 +- src/app/ui/StructureView.tsx | 125 ++++++++++++++++++++++++++++- src/components/KSearchPanel.tsx | 28 +++++++ src/core/export/cif.test.ts | 26 ++++++ 6 files changed, 248 insertions(+), 15 deletions(-) diff --git a/src/app/PdfWorkbench.tsx b/src/app/PdfWorkbench.tsx index b5b779e..763a772 100644 --- a/src/app/PdfWorkbench.tsx +++ b/src/app/PdfWorkbench.tsx @@ -667,8 +667,13 @@ export function PdfWorkbench({ structure, pattern, extraPhases = [], ownStructur const rows = curves.x.map((r, i) => `${r},${curves.yObs[i]},${curves.yCalc[i]},${curves.diff[i]}`); downloadText(`${pattern.id}.csv`, `r,Gobs,Gcalc,diff\n${rows.join("\n")}\n`, "text/csv"); }; - const exportCifRef = useRef<() => void>(noop); - exportCifRef.current = (): void => { + // CIF for one phase. `target` MUST already have the parameters applied (as + // `viewStructure` does), never `phase.structure`: structureToCif reads the + // coordinates and cell straight off the model and takes the parameters only + // for their esds, so the starting model would be written with pre-refinement + // values annotated by post-refinement uncertainties. + const exportPhaseCifRef = useRef<(target: StructureModel, id: string) => void>(() => {}); + exportPhaseCifRef.current = (target: StructureModel, id: string): void => { const withEsd = params.map((p) => { const esd = result?.esd[p.id] ?? p.esd; return esd !== undefined ? { ...p, esd } : { ...p }; @@ -678,9 +683,18 @@ export function PdfWorkbench({ structure, pattern, extraPhases = [], ownStructur nRef: curves.x.filter((r) => r >= fitRange.min && r <= fitRange.max).length, nParam: nFree, }; + const cif = structureToCif(target, { params: withEsd, bindings: spec.bindings, refinement: meta }); + downloadText(`${target.name || id}_pdf.cif`, cif, "chemical/x-cif"); + }; + const exportCifRef = useRef<() => void>(noop); + exportCifRef.current = (): void => { + // The header export writes every phase; each one needs its own bindings so + // a multi-phase fit does not cross-apply another phase's cell. + const values: Record = {}; + for (const p of activeParams) values[p.id] = p.value; for (const phase of phases) { - const cif = structureToCif(phase.structure, { params: withEsd, bindings: spec.bindings, refinement: meta }); - downloadText(`${phase.structure.name || phase.id}_pdf.cif`, cif, "chemical/x-cif"); + const b = multiPhase ? pdfPhaseBindingsFor(spec.bindings, phase.id) : spec.bindings; + exportPhaseCifRef.current(applyParameters(phase.structure, b, values).model, phase.id); } }; const exportReportRef = useRef<() => void>(noop); @@ -860,6 +874,14 @@ export function PdfWorkbench({ structure, pattern, extraPhases = [], ownStructur structure={viewStructure} minCanvasHeight={200} {...(shownMode ? { displacements: shownMode.axes } : {})} + exports={[{ + label: "CIF", + title: `Download ${viewStructure.name || viewStructure.id} as CIF — refined cell and sites, with esds`, + // The phase on screen, not every phase (that is the + // header export). Displacement modes are not in the file + // yet; they land here as mCIF when the writer supports them. + run: () => exportPhaseCifRef.current(viewStructure, phases[Math.min(viewPhase, phases.length - 1)]!.id), + }]} /> diff --git a/src/app/PowderWorkbench.tsx b/src/app/PowderWorkbench.tsx index 877b68a..61ebef5 100644 --- a/src/app/PowderWorkbench.tsx +++ b/src/app/PowderWorkbench.tsx @@ -80,6 +80,8 @@ import type { EngineExportsRef } from "@/app/workbenchEngine"; // Lazy so three.js (~550 kB) only loads when the user opens the 3D view. const StructureView = lazy(() => import("@/app/ui/StructureView").then((m) => ({ default: m.StructureView }))); +// Type-only — erased at build, so it does not pull the viewer out of its chunk. +import type { StructureExport } from "@/app/ui/StructureView"; const UNIT_LABEL: Record = { twoTheta: "2θ", q: "Q (Å⁻¹)", dSpacing: "d (Å)", tof: "TOF (µs)" }; @@ -904,10 +906,16 @@ export function PowderWorkbench({ downloadText(`${pattern.id}.csv`, powderPatternCsv(curves), "text/csv"); } - // Refined CIF / mCIF (M5): the current structure with refined values, esds - // merged from the last result, and agreement recorded. mCIF when a magnetic - // model with moments is present, plain CIF otherwise. - function exportCif(): void { + // Refined CIF / mCIF (M5): esds merged from the last result and agreement + // recorded. mCIF when a magnetic model with moments is present, plain CIF + // otherwise. + // + // `target` MUST be a phase with the parameters already applied (refinedPhases), + // never the session's starting `structure`: structureToCif reads coordinates + // and cell straight off the model and takes the parameters only for their + // esds, so passing the starting model writes pre-refinement values annotated + // with post-refinement uncertainties. + function exportPhaseCif(target: StructureModel, withMoments: boolean): void { const withEsd = powderParams.map((p) => { const e = powderResult?.esd[p.id]; return e !== undefined ? { ...p, esd: e } : { ...p }; @@ -916,15 +924,39 @@ export function PowderWorkbench({ const refinement: CifRefinementMeta | undefined = powderResult ? { rwp: Number(wRpct), ...(s !== undefined ? { gof: s } : {}), nParam: withEsd.filter((p) => !p.fixed && !p.expression).length } : undefined; - const opts = { params: withEsd, bindings: pBindings, ...(refinement ? { refinement } : {}) }; + // Route the bindings by phase exactly as `refinedPhases` does: in a + // multi-phase session two phases can carry the same site label, so the + // unfiltered set would attach another phase's esds to this one's sites. + const bindings = session.extraPhases.length > 0 ? pBindings.filter((b) => b.targetId === target.id) : pBindings; + const opts = { params: withEsd, bindings, ...(refinement ? { refinement } : {}) }; const mag = session.magnetic; - if (mag && mag.moments.length > 0) { - downloadText(`${structure.id}.mcif`, magneticStructureToMcif(structure, mag, opts), "chemical/x-cif"); + if (withMoments && mag && mag.moments.length > 0) { + downloadText(`${target.id}.mcif`, magneticStructureToMcif(target, mag, opts), "chemical/x-cif"); } else { - downloadText(`${structure.id}.cif`, structureToCif(structure, opts), "chemical/x-cif"); + downloadText(`${target.id}.cif`, structureToCif(target, opts), "chemical/x-cif"); } } + // The header export always writes the primary phase; the moments belong to it. + function exportCif(): void { + exportPhaseCif(refinedStructure, true); + } + + // The 3D card's own download: whichever phase the viewer is showing, so a + // multi-phase session can export the secondary phase the header never offers. + // Only the primary phase carries the magnetic model, so only it writes mCIF. + function viewerCifExport(target: StructureModel, isPrimary: boolean): StructureExport { + const magnetic = isPrimary && !!session.magnetic && session.magnetic.moments.length > 0; + const name = target.name || target.id; + return { + label: magnetic ? "mCIF" : "CIF", + title: magnetic + ? `Download ${name} as mCIF — refined cell and sites (with esds) plus the magnetic moment loop` + : `Download ${name} as CIF — refined cell and sites, with esds`, + run: () => exportPhaseCif(target, isPrimary), + }; + } + // A one-click cross-check bundle (.zip) for an established package: the refined // model + data (+ instrument + build script), assembled and zipped in-browser. function exportBundle(target: "fullprof" | "gsas2"): void { @@ -1197,6 +1229,7 @@ export function PowderWorkbench({ {...(isPrimary && sessionMoments ? { moments: sessionMoments } : {})} {...(isPrimary && session.magnetic?.propagation[0] ? { propagation: session.magnetic.propagation[0] } : {})} {...(isPrimary && session.magnetic?.operations ? { magneticOperations: session.magnetic.operations } : {})} + exports={[viewerCifExport(viewStructure, isPrimary)]} />

diff --git a/src/app/SingleCrystalWorkbench.tsx b/src/app/SingleCrystalWorkbench.tsx index 57ad089..3c19a01 100644 --- a/src/app/SingleCrystalWorkbench.tsx +++ b/src/app/SingleCrystalWorkbench.tsx @@ -385,7 +385,10 @@ export function SingleCrystalWorkbench({ structure, dataset, magneticDataset, cl nRef: activeDataset.reflections.length, nParam: nFree, }; - downloadText(`${structure.id}.cif`, structureToCif(structure, { params: withEsd, bindings, refinement: meta }), "chemical/x-cif"); + // `refinedStructure`, not `structure`: structureToCif reads coordinates and + // cell off the model and takes the parameters only for their esds, so the + // starting model would export pre-refinement values with refined esds. + downloadText(`${structure.id}.cif`, structureToCif(refinedStructure, { params: withEsd, bindings, refinement: meta }), "chemical/x-cif"); } // Export the reflection data as FullProf `.int`. When a joint session is loaded diff --git a/src/app/ui/StructureView.tsx b/src/app/ui/StructureView.tsx index c6a3218..a6493d3 100644 --- a/src/app/ui/StructureView.tsx +++ b/src/app/ui/StructureView.tsx @@ -97,6 +97,67 @@ function makeLabelSprite(text: string, worldHeight: number, colorCss: string): T return sprite; } +/** + * Builder for a set of solid arrows sharing one look: a cylinder shaft capped + * by a cone head, both lit by the scene exactly like the atoms and bonds. + * + * This exists because `THREE.ArrowHelper` — the obvious choice — draws its + * shaft with `LineBasicMaterial`, which WebGL rasterizes at a single device + * pixel however close the camera gets. Against a lit sphere the stem reads as a + * hairline (or disappears under the atom entirely); a mesh shaft has real + * world-space thickness that zooms with everything else. + * + * Shaft and head are the SAME size for every arrow a builder makes: only the + * length varies, so length alone carries the amplitude and the shortest arrow + * of a pattern is still legible. Head length is capped at a fraction of a short + * arrow so a small one keeps a shaft instead of collapsing to a bare cone. + * + * The unit geometries (radius 1, height 1, along +Y with the base at the + * origin) and the material are built once per set and shared by every arrow — + * N arrows cost N meshes, not N geometries. + */ +function arrowBuilder(opts: { + readonly color: number; + /** World-space shaft radius, shared across the set. */ + readonly shaftRadius: number; + /** World-space head radius and length, shared across the set. */ + readonly headRadius: number; + readonly headLength: number; +}): (dir: THREE.Vector3, origin: THREE.Vector3, length: number) => THREE.Object3D { + const shaftGeo = new THREE.CylinderGeometry(1, 1, 1, 16); + shaftGeo.translate(0, 0.5, 0); // base at the origin, tip at +Y + const headGeo = new THREE.ConeGeometry(1, 1, 20); + headGeo.translate(0, 0.5, 0); + const mat = new THREE.MeshPhongMaterial({ color: new THREE.Color(opts.color), shininess: 55, specular: 0x333333 }); + const Y0 = new THREE.Vector3(0, 1, 0); + return (dir, origin, length) => { + const head = Math.min(opts.headLength, length * 0.55); + const shaft = Math.max(length - head, 1e-4); + const group = new THREE.Group(); + const s = new THREE.Mesh(shaftGeo, mat); + s.scale.set(opts.shaftRadius, shaft, opts.shaftRadius); + group.add(s); + const h = new THREE.Mesh(headGeo, mat); + h.scale.set(opts.headRadius, head, opts.headRadius); + h.position.y = shaft; + group.add(h); + group.position.copy(origin); + group.quaternion.setFromUnitVectors(Y0, dir); // dir must be normalized + return group; + }; +} + +/** A download the host offers from the viewer's toolbar (see `exports`). */ +export interface StructureExport { + /** Format shown on the button, e.g. "CIF" or "mCIF". */ + readonly label: string; + /** Tooltip — say exactly what the file will contain. */ + readonly title: string; + /** Serialize and download. The host owns this: only it holds the refined + * parameters, their esds and the agreement factors the file should carry. */ + readonly run: () => void; +} + export interface StandardCellOverlay { /** Columns = standard-setting basis vectors in parent fractional coords. */ readonly P: readonly (readonly number[])[]; @@ -113,6 +174,7 @@ export function StructureView({ magneticOperations, standardCell, displacements, + exports, minCanvasHeight = 360, }: { structure: StructureModel; @@ -138,6 +200,14 @@ export function StructureView({ * moments). Arrow lengths are relative (the pattern, not the amplitude). */ displacements?: readonly { readonly siteLabel: string; readonly axis: Vec3 }[]; + /** + * Downloads offered from the viewer's toolbar for the structure on screen — + * normally a single CIF. Host-supplied descriptors rather than a flag, so the + * viewer never has to know which flavour is being written: a magnetic page + * hands it an mCIF (moment loop) under an "mCIF" label, and the PDF page will + * hand it one carrying the mode displacements, with no change here. + */ + exports?: readonly StructureExport[]; /** Canvas minimum height (px) — hosts that stack panels below the viewer in * a fixed-height card pass a smaller value so the layout can settle. */ minCanvasHeight?: number; @@ -404,7 +474,27 @@ export function StructureView({ const c = fractionalToCartesian(structure.cell, ax); maxLen = Math.max(maxLen, Math.hypot(c[0]!, c[1]!, c[2]!)); } - const unit = maxLen > 1e-9 ? (span * 0.34) / maxLen : 0; + // Longest arrow ≈ one bond, not a traverse of the cell. The eigenvector + // is a *pattern* — unit amplitude, arbitrary scale — so the scale's only + // job is to read as a local displacement of the atom it sits on; at the + // old span·0.34 the tip landed well past the neighbouring site and the + // pattern read as a vector field over the cell instead. Tied to the + // shortest cell edge so it does not grow with the supercell, and clamped + // by the box diagonal so a very flat cell keeps its arrows in bounds. + const minEdge = Math.min(structure.cell.a, structure.cell.b, structure.cell.c); + const longest = Math.min(minEdge * 0.26, span * 0.12); + const unit = maxLen > 1e-9 ? longest / maxLen : 0; + const arrow = arrowBuilder({ + color: 0x16a34a, + // Nearly twice a bond's radius (bonds are 0.09) at a typical cell size, + // so the stem is unmistakably solid against the spheres it crosses. + // The tail sits at the atom centre, so roughly a sphere radius of every + // arrow is buried — the head takes a modest share of the length to keep + // the visible stem longer than the cone that caps it. + shaftRadius: longest * 0.075, + headRadius: longest * 0.165, + headLength: longest * 0.28, + }); for (const at of atoms) { const ax = unit > 0 ? bySite.get(at.label) : undefined; if (!ax) continue; @@ -419,7 +509,7 @@ export function StructureView({ const dir = new THREE.Vector3(cart[0]! / len, cart[1]! / len, cart[2]! / len); const L = len * unit; const start = new THREE.Vector3(at.xyz[0], at.xyz[1], at.xyz[2]); - scene.add(new THREE.ArrowHelper(dir, start, L, 0x16a34a, L * 0.3, L * 0.18)); + scene.add(arrow(dir, start, L)); } } @@ -666,6 +756,37 @@ export function StructureView({ )} + {exports && exports.length > 0 && ( + + {exports.map((x) => ( + + ))} + + )}

{/* View presets (bottom-right, above the legend): look straight down an axis. */} diff --git a/src/components/KSearchPanel.tsx b/src/components/KSearchPanel.tsx index 608dfe4..b880db1 100644 --- a/src/components/KSearchPanel.tsx +++ b/src/components/KSearchPanel.tsx @@ -48,11 +48,14 @@ import { WorkbenchPlot, type FitRangeSelection } from "@/app/ui/WorkbenchPlot"; import { InfoBadge } from "@/app/ui/InfoBadge"; import { momentEntriesFrom } from "@/app/ui/cellModel"; import { magneticReportHtml, type MagneticReportGroup } from "@/core/export/magneticReport"; +import { structureToCif, magneticStructureToMcif } from "@/core/export/cif"; import { downloadText } from "@/app/download"; import { card as themeCard, color as theme, mono as themeMono, uppercaseLabel as themeLabel } from "@/app/theme"; // Lazy so three.js stays in its own chunk (only loaded when a group is previewed). const StructureView = lazy(() => import("@/app/ui/StructureView").then((m) => ({ default: m.StructureView }))); +// Type-only — erased at build, so it does not pull the viewer out of its chunk. +import type { StructureExport } from "@/app/ui/StructureView"; /** * Parse one k component, accepting fractions ("1/2", "-1/3") as well as @@ -488,6 +491,30 @@ export function KSearchPanel({ [appliedMagnetic], ); + // The 3D card's own download. This view is where the moments live, so it + // writes mCIF as soon as a group is previewed: the refined nuclear structure + // plus the magnetic symmetry and the moment loop at the current amplitudes + // (`appliedMagnetic` — the very model drawn as arrows, so file and picture + // agree). Before a group is picked there are no moments and it is plain CIF. + // No `magneticLabel`: the BNS symbol is assembled inside the report export, + // so the writer falls back to naming the parent group, as the header mCIF does. + const viewerCifExport = useMemo(() => { + const mag = appliedMagnetic && appliedMagnetic.moments.length > 0 ? appliedMagnetic : null; + return { + label: mag ? "mCIF" : "CIF", + title: mag + ? `Download ${structure.name || structure.id} as mCIF — refined cell and sites plus the magnetic moment loop at the current amplitudes` + : "Download the refined nuclear structure as CIF — pick a magnetic group to include the moments", + run: () => { + if (mag) { + downloadText(`${structure.id}.mcif`, magneticStructureToMcif(structure, mag), "chemical/x-cif"); + } else { + downloadText(`${structure.id}.cif`, structureToCif(structure), "chemical/x-cif"); + } + }, + }; + }, [appliedMagnetic, structure]); + // Tick rows for the pattern preview: every crystallographic phase (from the // refinement page, so impurity peaks index correctly), then where this k CAN // put magnetic intensity (satellite positions G ± k), where the selected @@ -799,6 +826,7 @@ export function KSearchPanel({ {...(magBuild ? { magneticOperations: magBuild.magnetic.operations ?? [] } : {})} {...(momentEntries ? { moments: momentEntries } : {})} {...(standardCell ? { standardCell } : {})} + exports={[viewerCifExport]} /> diff --git a/src/core/export/cif.test.ts b/src/core/export/cif.test.ts index 5048b90..6ad7bf2 100644 --- a/src/core/export/cif.test.ts +++ b/src/core/export/cif.test.ts @@ -63,6 +63,32 @@ describe("structureToCif — round-trips through parseCif", () => { }); }); +describe("structureToCif — writes the model's own values, never the parameters'", () => { + // The writer takes `params` ONLY for their esds; the numbers it prints come + // straight off the model. Callers must therefore hand it a structure with the + // refinement already applied. Every workbench used to pass its *starting* + // model here, so exported CIFs carried pre-refinement coordinates annotated + // with post-refinement uncertainties. Pinning the contract in both directions. + const params: RefinementParameter[] = [ + { id: "cell_a", label: "a", kind: "cellLength", value: 5.9, initialValue: 5.4321, fixed: false, esd: 0.0006 }, + ]; + const bindings: ParameterBinding[] = [ + { parameterId: "cell_a", kind: "cellLength", targetId: "s", targetKey: "a" }, + ]; + + it("does NOT pick up a refined value the caller left in the parameters", () => { + const cif = structureToCif(structure, { params, bindings }); + expect(cif).toContain("_cell_length_a 5.43210(60)"); // the model's 5.4321 + expect(cif).not.toContain("5.90000"); + }); + + it("writes the refined value once the caller applies it to the model", () => { + const refined: StructureModel = { ...structure, cell: { ...structure.cell, a: 5.9 } }; + const cif = structureToCif(refined, { params, bindings }); + expect(cif).toContain("_cell_length_a 5.90000(60)"); + }); +}); + describe("structureToCif — standard uncertainties", () => { it("annotates cell and position with value(su) from refined esds", () => { const params: RefinementParameter[] = [