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[] = [