Skip to content
Open
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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Unreleased

- Moment direction is now drawn, not just numbered. One convention, defined once
in the library: arrows are a top view along the surface normal, screen right is
the guide field H, and the angle drawn is φ = θM − 270° (a negative ρM points
the other way, with the signed parameter in the tooltip). A magnetic layer with
a negligible moment gets a hollow dot; a non-magnetic layer gets nothing, and a
gradient assembly - which has no single moment - gets nothing either.
- **Structure tab**: an arrow in every magnetic layer's box, between the name
and the thickness annotation, with no switch to find - attaching magnetism is
the request. Tooltips lead with φ, then θM and signed ρM, then the M∥/M⊥ split.
A magnetism edit now refreshes the boxes; previously a θM change updated the
chart while the boxes kept the old value.
- **Magnetism group**: the selected magnetic layer's angle as a 0-360° slider,
with the H reference and the resulting arrow beside it. Dragging it sets θM
in 5° steps - the same write the θM column makes - and it is read-only while
a fit runs or θM follows a constraint.
- An "H →" reference is on screen wherever an arrow is.
- A project with no magnetic layer is unchanged: no arrows, no slider, and the
same Structure and SLD layout as before.

- Added a **Structure** tab on the Model page: a schematic view of the layer stack with one
colored box per layer (colors per material, heights following thickness, "× N" badges for
collapsed repeating multilayers, legend and total-thickness caption). Boxes show tooltips
Expand Down
6 changes: 3 additions & 3 deletions EasyReflectometryApp/Backends/Mock/Sample.qml
Original file line number Diff line number Diff line change
Expand Up @@ -306,9 +306,9 @@ QtObject {
// Layer magnetism (polarized analysis)
readonly property bool magnetismSupported: true
readonly property var layersMagnetism: [
{ 'label': 'label 1', 'magnetic': 'True', 'rho_m': '5.0', 'theta_m': '40.0' },
{ 'label': 'label 2', 'magnetic': 'False', 'rho_m': '0.0', 'theta_m': '270.0' },
{ 'label': 'label 3', 'magnetic': 'False', 'rho_m': '0.0', 'theta_m': '270.0' },
{ 'label': 'label 1', 'magnetic': 'True', 'rho_m': '5.0', 'theta_m': '40.0', 'phi': '130.0', 'editable': 'True' },
{ 'label': 'label 2', 'magnetic': 'False', 'rho_m': '0.0', 'theta_m': '270.0', 'phi': '', 'editable': '' },
{ 'label': 'label 3', 'magnetic': 'False', 'rho_m': '0.0', 'theta_m': '270.0', 'phi': '', 'editable': '' },
]
function setLayerMagneticAtIndex(index, value) {
console.debug(`setLayerMagneticAtIndex ${index} ${value}`)
Expand Down
9 changes: 8 additions & 1 deletion EasyReflectometryApp/Backends/Py/logic/layers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import Union

from easyreflectometry import Project as ProjectLib
from easyreflectometry.project import magnetic_vector_for_layer
from easyreflectometry.sample import LayerAreaPerMolecule
from easyreflectometry.sample import LayerCollection
from easyreflectometry.sample import LayerMagnetism
Expand Down Expand Up @@ -224,7 +225,11 @@ def magnetism(self) -> list[dict[str, str]]:

``magnetic`` is 'True'/'False'; ``rho_m``/``theta_m`` carry the defaults
of a fresh :class:`LayerMagnetism` for non-magnetic layers so the fields
show what attaching magnetism would start from.
show what attaching magnetism would start from. ``phi`` is the direction
the moment points, in degrees from the guide field - what the compass
draws - and ``editable`` whether that direction can be set by dragging
it (a constrained ``theta_m`` follows its expression, not the pointer).
Both are empty for a non-magnetic layer, which has no direction.
"""
rows = []
for layer in self._layers:
Expand All @@ -235,6 +240,8 @@ def magnetism(self) -> list[dict[str, str]]:
'magnetic': str(magnetism is not None),
'rho_m': str(magnetism.rho_m.value if magnetism is not None else _DEFAULT_RHO_M),
'theta_m': str(magnetism.theta_m.value if magnetism is not None else _DEFAULT_THETA_M),
'phi': '' if magnetism is None else str(magnetic_vector_for_layer(magnetism)['phi']),
'editable': '' if magnetism is None else str(magnetism.theta_m.independent),
}
)
return rows
Expand Down
36 changes: 33 additions & 3 deletions EasyReflectometryApp/Backends/Py/logic/structure.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from easyreflectometry import Project as ProjectLib
from easyreflectometry.model.model import COLORS
from easyreflectometry.project import MAGNETIC_MOMENT_FLOOR_FRACTION
from easyreflectometry.project import magnetic_vector_for_layer

# An assembly whose expanded box count would exceed this collapses to its repeat unit
MAX_EXPANDED_BOXES_PER_ASSEMBLY = 12
Expand All @@ -19,6 +21,11 @@ def flatten(project_lib: ProjectLib) -> tuple[list[dict], list[dict], float]:
assembly assembly name, and assembly_index/layer_index to address the layer
kind 'layer' | 'gradient' | 'superphase' | 'subphase'
repetitions n for a collapsed repeating multilayer, else 1
A magnetic layer carries, in addition, `magnetic` (True), `has_moment` and the
keys of `magnetic_vector_for_layer` (rho_m, theta_m, phi_param, phi, m, m_par,
m_perp). Every other box omits them entirely, so the view gates on
`magnetic === true` and a non-magnetic project's boxes are exactly as before.
Gradient boxes never carry them: a gradient has no assembly-level moment.
- legend: distinct {label, color} pairs in stack order
- total_thickness: physical total in Angstrom (collapsed repeats counted n times, caps excluded)
"""
Expand All @@ -28,6 +35,7 @@ def flatten(project_lib: ProjectLib) -> tuple[list[dict], list[dict], float]:
sample = project_lib._models[model_index].sample

colors = _ColorMap(project_lib._materials)
moment_floor = _moment_floor(sample)
boxes = []
total_thickness = 0.0

Expand All @@ -45,7 +53,7 @@ def flatten(project_lib: ProjectLib) -> tuple[list[dict], list[dict], float]:

for _ in range(1 if collapsed else repetitions):
for layer_index, layer in enumerate(assembly.layers):
boxes.append(_layer_box(layer, assembly, assembly_index, layer_index, colors))
boxes.append(_layer_box(layer, assembly, assembly_index, layer_index, colors, moment_floor))
if collapsed:
boxes[-len(assembly.layers)]['repetitions'] = repetitions

Expand Down Expand Up @@ -73,9 +81,24 @@ def _value(quantity) -> float:
return float(getattr(quantity, 'value', quantity))


def _layer_box(layer, assembly, assembly_index: int, layer_index: int, colors: '_ColorMap') -> dict:
def _moment_floor(sample) -> float:
"""Below this |rho_m| a layer's moment has no direction worth drawing.

The same relative floor that masks the theta_m depth curve, so a box never
shows an arrow while the angle curve beside it is hidden.
"""
moments = [
abs(float(layer.magnetism.rho_m.value))
for assembly in sample
for layer in assembly.layers
if getattr(layer, 'magnetism', None) is not None
]
return MAGNETIC_MOMENT_FLOOR_FRACTION * max(moments, default=0.0)


def _layer_box(layer, assembly, assembly_index: int, layer_index: int, colors: '_ColorMap', moment_floor: float) -> dict:
material = layer.material
return {
box = {
'label': layer.name,
'material': material.name,
'color': colors.get(material),
Expand All @@ -90,6 +113,13 @@ def _layer_box(layer, assembly, assembly_index: int, layer_index: int, colors: '
'kind': 'layer',
'repetitions': 1,
}
magnetism = getattr(layer, 'magnetism', None)
if magnetism is not None:
vector = magnetic_vector_for_layer(magnetism)
box.update(vector)
box['magnetic'] = True
box['has_moment'] = vector['m'] > moment_floor
return box


def _gradient_box(assembly, assembly_index: int, colors: '_ColorMap') -> dict:
Expand Down
6 changes: 6 additions & 0 deletions EasyReflectometryApp/Backends/Py/sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,12 @@ def setLayerThetaMAtIndex(self, index: int, new_value: float) -> None:
def _emitMagnetismChanged(self) -> None:
"""Magnetism edits change the model, its parameters and every curve."""
self._clearCacheAndEmitLayersChanged()
# The structure boxes carry the moment direction, so a theta_m or rho_m
# edit changes them without changing their number: without dropping the
# cache the Structure arrows keep pointing the old way until an
# unrelated layer edit happens to rebuild it. `externalRefreshPlot` is
# no substitute - it refreshes the charts, not the structure model.
self._clearStructureCacheAndEmit()
self.externalRefreshPlot.emit()
self.externalSampleChanged.emit()

Expand Down
43 changes: 43 additions & 0 deletions EasyReflectometryApp/Gui/GuideFieldLegend.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors <support@easyreflectometry.org>
// SPDX-License-Identifier: BSD-3-Clause
// © 2026 Contributors to the EasyReflectometry project <https://github.com/easyscience/EasyReflectometry>

import QtQuick
import QtQuick.Controls

import EasyApplication.Gui.Style as EaStyle
import EasyApplication.Gui.Elements as EaElements

// The "H →" reference every arrow view puts on screen, so the direction the
// moment angles are measured from is visible rather than remembered. Drawn with
// the same component as the moment arrows, at phi = 0 by definition: the guide
// field *is* the zero of phi.
Row {
id: root

property real glyphSize: EaStyle.Sizes.fontPixelSize * 1.5

spacing: EaStyle.Sizes.fontPixelSize * 0.25

EaElements.Label {
anchors.verticalCenter: parent.verticalCenter
text: "H"
color: EaStyle.Colors.themeForegroundMinor
}

MagnetizationArrow {
anchors.verticalCenter: parent.verticalCenter
width: root.glyphSize
height: root.glyphSize
phi: 0
color: EaStyle.Colors.themeForegroundMinor
outlineColor: "transparent"
}

ToolTip.visible: hover.hovered
ToolTip.text: qsTr("Guide field direction. Moment angles are measured from it: θM = 270° points along H (no spin flip).")

HoverHandler {
id: hover
}
}
115 changes: 115 additions & 0 deletions EasyReflectometryApp/Gui/MagnetizationAngleSlider.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors <support@easyreflectometry.org>
// SPDX-License-Identifier: BSD-3-Clause
// © 2026 Contributors to the EasyReflectometry project <https://github.com/easyscience/EasyReflectometry>

import QtQuick
import QtQuick.Controls

import EasyApplication.Gui.Style as EaStyle
import EasyApplication.Gui.Elements as EaElements

// The selected layer's moment angle as a slider across the full 0-360° range,
// with the guide field reference and the resulting arrow beside it.
//
// The slider edits θM - the parameter the table shows and a fit varies - so it
// writes exactly what the θM text field writes, and 0-360 is that parameter's
// own range. The arrow draws φ, the direction the moment *physically* points,
// which the backend derives: a negative ρM is the same moment reversed, so the
// arrow can turn while θM stays where it was put.
Row {
id: root

// The parameter being edited, in degrees.
property real thetaM: 270
// Physical moment direction, degrees counterclockwise from H. Display only.
property real phi: 0
property bool hasMoment: true
// Whether the slider may be moved at all.
property bool editable: false
// The drag resolution, as on the dial this replaces.
property int snapDegrees: 5

signal thetaMRequested(real thetaM)

readonly property real glyphSize: EaStyle.Sizes.fontPixelSize * 1.5

width: EaStyle.Sizes.sideBarContentWidth
spacing: EaStyle.Sizes.fontPixelSize * 0.5

EaElements.Label {
id: nameLabel
anchors.verticalCenter: parent.verticalCenter
width: EaStyle.Sizes.fontPixelSize * 2
text: qsTr("θM")
}

EaElements.Slider {
id: slider

anchors.verticalCenter: parent.verticalCenter
// Whatever the fixed columns do not take.
width: root.width - nameLabel.width - valueLabel.width - guideField.width - root.glyphSize
- 4 * root.spacing

from: 0
to: 360
stepSize: root.snapDegrees
// stepSize alone only snaps the keyboard and the wheel; the dial this
// replaces snapped the drag too.
snapMode: Slider.SnapAlways
enabled: root.editable

onMoved: root.thetaMRequested(value)
}

// The model owns the value; a drag only borrows it. Not a plain `value:`
// binding: dragging assigns `value` imperatively and would break it for
// good, after which the handle stops following the model. A Binding stands
// aside while the handle is held and reasserts itself on release, so a
// write the model refused or clamped snaps the handle back instead of
// leaving it showing an angle the layer does not have.
Binding {
target: slider
property: "value"
value: root.thetaM
when: !slider.pressed
restoreMode: Binding.RestoreNone
}

EaElements.Label {
id: valueLabel
anchors.verticalCenter: parent.verticalCenter
width: EaStyle.Sizes.fontPixelSize * 3.5
horizontalAlignment: Text.AlignRight
text: root.thetaM.toFixed(1) + "°"
color: EaStyle.Colors.themeForegroundMinor
}

// The reference the arrow is measured from, next to the arrow itself.
GuideFieldLegend {
id: guideField
anchors.verticalCenter: parent.verticalCenter
glyphSize: root.glyphSize
}

MagnetizationArrow {
anchors.verticalCenter: parent.verticalCenter
width: root.glyphSize
height: root.glyphSize
phi: root.phi
hasMoment: root.hasMoment
color: EaStyle.Colors.themeForegroundHovered
outlineColor: "transparent"
}

ToolTip.visible: hover.hovered
ToolTip.text: root.hasMoment
? qsTr("Moment: %1° from H (θM %2°)").arg(root.phi.toFixed(1)).arg(root.thetaM.toFixed(1))
+ (root.editable ? '\n' + qsTr("Drag to set θM (%1° steps)").arg(root.snapDegrees)
: '\n' + qsTr("θM follows a constraint or a running fit and cannot be dragged"))
: qsTr("ρM is zero: there is no direction to show")

HoverHandler {
id: hover
}
}
Loading
Loading