Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions src/app/PdfWorkbench.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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<string, number> = {};
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);
Expand Down Expand Up @@ -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),
}]}
/>
</Suspense>
</div>
Expand Down
49 changes: 41 additions & 8 deletions src/app/PowderWorkbench.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = { twoTheta: "2θ", q: "Q (Å⁻¹)", dSpacing: "d (Å)", tof: "TOF (µs)" };

Expand Down Expand Up @@ -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 };
Expand All @@ -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 {
Expand Down Expand Up @@ -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)]}
/>
</Suspense>
<p style={{ marginTop: 8, fontSize: 12, color: theme.secondary }}>
Expand Down
5 changes: 4 additions & 1 deletion src/app/SingleCrystalWorkbench.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
125 changes: 123 additions & 2 deletions src/app/ui/StructureView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[])[];
Expand All @@ -113,6 +174,7 @@ export function StructureView({
magneticOperations,
standardCell,
displacements,
exports,
minCanvasHeight = 360,
}: {
structure: StructureModel;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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));
}
}

Expand Down Expand Up @@ -666,6 +756,37 @@ export function StructureView({
</span>
</span>
)}
{exports && exports.length > 0 && (
<span style={{ marginLeft: "auto", display: "inline-flex", alignItems: "center", gap: 6 }}>
{exports.map((x) => (
<button
key={x.label}
onClick={x.run}
title={x.title}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
border: `1px solid ${theme.border}`,
borderRadius: 6,
padding: "1px 8px",
fontSize: 11.5,
fontFamily: "inherit",
cursor: "pointer",
background: "#fff",
color: theme.ink,
}}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M12 3v12" />
<path d="M7 12l5 5 5-5" />
<path d="M4 21h16" />
</svg>
{x.label}
</button>
))}
</span>
)}
</div>
<div ref={mountRef} style={{ width: "100%", flex: 1, minHeight: minCanvasHeight, cursor: "grab", borderRadius: 10, overflow: "hidden" }} />
{/* View presets (bottom-right, above the legend): look straight down an axis. */}
Expand Down
28 changes: 28 additions & 0 deletions src/components/KSearchPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<StructureExport>(() => {
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
Expand Down Expand Up @@ -799,6 +826,7 @@ export function KSearchPanel({
{...(magBuild ? { magneticOperations: magBuild.magnetic.operations ?? [] } : {})}
{...(momentEntries ? { moments: momentEntries } : {})}
{...(standardCell ? { standardCell } : {})}
exports={[viewerCifExport]}
/>
</Suspense>
</section>
Expand Down
Loading
Loading