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
70 changes: 67 additions & 3 deletions extensions/rust/src/nanoscroll.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ use nalgebra::Vector3;

use crate::element::Element;
use crate::error::{FerroxError, Result};
use crate::lattice::Lattice;
use crate::species::Species;
use crate::structure::Structure;

Expand All @@ -36,6 +37,9 @@ pub const DEFAULT_INTERLAYER_GAP: f64 = 3.3;
/// Default local-strain threshold above which a curvature warning is emitted.
pub const DEFAULT_STRAIN_WARN_THRESHOLD: f64 = 0.15;

/// Vacuum padding added around the generated non-periodic scroll cell (Å).
const DEFAULT_CELL_PADDING: f64 = 10.0;

/// In-plane roll direction (which lattice vector becomes the rolling axis).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RollDir {
Expand Down Expand Up @@ -275,6 +279,23 @@ pub fn min_interwinding_gap(pitch: f64, monolayer_thickness: f64) -> f64 {
pitch - monolayer_thickness
}

fn bounding_box(points: &[Vector3<f64>]) -> Option<(Vector3<f64>, Vector3<f64>)> {
if points.is_empty() {
return None;
}
let mut min = Vector3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
let mut max = Vector3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for p in points {
min.x = min.x.min(p.x);
min.y = min.y.min(p.y);
min.z = min.z.min(p.z);
max.x = max.x.max(p.x);
max.y = max.y.max(p.y);
max.z = max.z.max(p.z);
}
Some((min, max))
}

/// Build a nanoscroll from a monolayer structure.
///
/// Returns the rolled structure (non-periodic molecule, pbc = false) together
Expand Down Expand Up @@ -379,8 +400,6 @@ pub fn build_nanoscroll(
warning: warning.clone(),
};

// Build a non-periodic molecule structure (matches the reference, which
// writes xyz with no cell). The molecule constructor keeps frac == cart.
let species: Vec<Species> = elements.into_iter().map(Species::neutral).collect();

let mut properties: HashMap<String, serde_json::Value> = HashMap::new();
Expand Down Expand Up @@ -410,7 +429,31 @@ pub fn build_nanoscroll(
properties.insert("warning".into(), serde_json::json!(w));
}

let structure = Structure::try_new_molecule(species, rolled, 0.0, properties)?;
let (min, max) = bounding_box(&rolled).ok_or_else(|| FerroxError::InvalidStructure {
index: 0,
reason: "nanoscroll produced no atoms".to_string(),
})?;
let span = max - min;
let cell = Vector3::new(
(span.x + 2.0 * DEFAULT_CELL_PADDING).max(1.0),
(span.y + 2.0 * DEFAULT_CELL_PADDING).max(1.0),
(span.z + 2.0 * DEFAULT_CELL_PADDING).max(1.0),
);
let shifted_cart: Vec<Vector3<f64>> = rolled
.iter()
.map(|p| *p - min + (cell - span) * 0.5)
.collect();
let mut lattice = Lattice::orthorhombic(cell.x, cell.y, cell.z);
lattice.pbc = [false, false, false];
let frac_coords = lattice.get_fractional_coords(&shifted_cart);
let structure = Structure::try_new_full(
lattice,
species.into_iter().map(crate::species::SiteOccupancy::ordered).collect(),
frac_coords,
[false, false, false],
0.0,
properties,
)?;

Ok((structure, info))
}
Expand Down Expand Up @@ -482,6 +525,27 @@ mod tests {
}
}

#[test]
fn scroll_cell_encloses_all_atoms() {
let mono = graphene_like();
let params = NanoscrollParams {
turns: 4,
inner_radius: 18.0,
length: 9.0,
..Default::default()
};
let (scroll, _info) = build_nanoscroll(&mono, &params).unwrap();
let lengths = scroll.lattice.lengths();
assert_eq!(scroll.pbc, [false, false, false]);
assert!(lengths.x > 2.0 * params.inner_radius);
assert!(lengths.y > 2.0 * params.inner_radius);
for c in scroll.cart_coords() {
assert!(c.x >= -1e-8 && c.x <= lengths.x + 1e-8);
assert!(c.y >= -1e-8 && c.y <= lengths.y + 1e-8);
assert!(c.z >= -1e-8 && c.z <= lengths.z + 1e-8);
}
}

#[test]
fn gap_fix_increases_pitch() {
let mono = graphene_like();
Expand Down
3 changes: 2 additions & 1 deletion src/lib/structure/Structure.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,7 @@
push_to_undo: () => push_to_undo(),
inc_center_camera: () => { center_camera_trigger++ },
inc_reset_camera_up: () => { reset_camera_up_trigger++ },
reset_camera_position: () => { scene_props.camera_position = [0, 0, 0] },
align_view_to_lattice: () => align_view_to_lattice(),
initial_bulk,
})
Expand Down Expand Up @@ -3143,7 +3144,7 @@
bind:structure={structure as PymatgenStructure}
pane_open={true}
on_push_undo={push_to_undo}
on_structure_change={(new_struct) => build.handle_structure_replace(new_struct)}
on_structure_change={(new_struct) => build.handle_structure_replace_and_fit(new_struct)}
/>
{:else if build.active_build_tab === 'heterostructure'}
<HeterostructurePane
Expand Down
37 changes: 28 additions & 9 deletions src/lib/structure/StructureScene.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -1059,6 +1059,24 @@
// This prevents the target from changing when structure changes (e.g., after slab cut)
let current_camera_target = $state<Vec3>([0, 0, 0])

function get_atom_bounds_center(structure: AnyStructure | undefined): Vec3 | null {
if (!structure?.sites?.length) return null
let min_x = Infinity, max_x = -Infinity
let min_y = Infinity, max_y = -Infinity
let min_z = Infinity, max_z = -Infinity
for (const site of structure.sites) {
const [x, y, z] = site.xyz
if (x < min_x) min_x = x; if (x > max_x) max_x = x
if (y < min_y) min_y = y; if (y > max_y) max_y = y
if (z < min_z) min_z = z; if (z > max_z) max_z = z
}
return [(min_x + max_x) / 2, (min_y + max_y) / 2, (min_z + max_z) / 2]
}

function get_camera_fit_target(): Vec3 {
return get_atom_bounds_center(structure) ?? rotation_target ?? [0, 0, 0]
}

// Apply target to orbit controls imperatively (outside reactive tracking).
// Uses untrack to avoid Svelte proxy reads on Three.js internals causing
// cascading reactive updates that freeze the app.
Expand Down Expand Up @@ -1098,19 +1116,19 @@
const controls_ready = !!orbit_controls?.target

if (!initial_target_set) {
current_camera_target = rotation_target
current_camera_target = get_camera_fit_target()
last_center_trigger = center_camera_trigger
if (controls_ready) {
apply_orbit_target(rotation_target)
apply_orbit_target(current_camera_target)
initial_target_set = true
}
return
}

if (center_camera_trigger === last_center_trigger) return
last_center_trigger = center_camera_trigger
current_camera_target = rotation_target
apply_orbit_target(rotation_target)
current_camera_target = get_camera_fit_target()
apply_orbit_target(current_camera_target)
})

// Reset camera to default +Z viewing direction when lattice_align_trigger changes.
Expand Down Expand Up @@ -1246,12 +1264,13 @@
if (!initial_target_set || !rotation_target) return
// Only read orbit_controls presence, not its deep properties
if (!orbit_controls) return
const [rx, ry, rz] = rotation_target
const target = get_camera_fit_target()
const [rx, ry, rz] = target
const [cx, cy, cz] = current_camera_target
const dist_sq = (rx - cx) ** 2 + (ry - cy) ** 2 + (rz - cz) ** 2
if (dist_sq > 4) {
current_camera_target = rotation_target
apply_orbit_target(rotation_target)
current_camera_target = target
apply_orbit_target(target)
}
})

Expand Down Expand Up @@ -1727,7 +1746,7 @@
let computed_zoom = $state<number>(untrack(() => initial_zoom))
$effect(() => {
if (!(width > 0) || !(height > 0)) return
const structure_max_dim = Math.max(1, untrack(() => structure_size))
const structure_max_dim = Math.max(1, structure_size)
const viewer_min_dim = Math.min(width, height)
const scale_factor = viewer_min_dim / (structure_max_dim * 30) // 30px per unit — fills more of the viewport
let new_zoom = initial_zoom * scale_factor
Expand Down Expand Up @@ -1784,7 +1803,7 @@
}
const distance = Math.max(1, view_size) * (60 / fov)
// Camera on -Y axis looking into +Y, so Z is up and Y goes into screen
const center = rotation_target || [0, 0, 0]
const center = get_camera_fit_target()
camera_position = [
center[0],
center[1] - distance,
Expand Down
13 changes: 13 additions & 0 deletions src/lib/structure/controllers/build-tools.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface BuildToolsDeps {
// ── Camera triggers ──
inc_center_camera: () => void
inc_reset_camera_up: () => void
reset_camera_position: () => void
align_view_to_lattice: () => void

// ── Optional: bulk structure for passivation when opening a slab directly ──
Expand All @@ -62,6 +63,7 @@ export interface BuildToolsDeps {
* push_to_undo: () => push_to_undo(),
* inc_center_camera: () => { center_camera_trigger++ },
* inc_reset_camera_up: () => { reset_camera_up_trigger++ },
* reset_camera_position: () => { scene_props.camera_position = [0, 0, 0] },
* align_view_to_lattice: () => align_view_to_lattice(),
* })
* ```
Expand Down Expand Up @@ -184,6 +186,16 @@ export function create_build_tools_controller(deps: BuildToolsDeps) {
deps.set_supercell_scaling(`1x1x1`)
}

/**
* Handle structure replacement and refit the camera to the new geometry.
* Useful for tools that can greatly change the bounding box, such as nanoscrolls.
*/
function handle_structure_replace_and_fit(new_struct: AnyStructure) {
handle_structure_replace(new_struct)
deps.reset_camera_position()
deps.inc_center_camera()
}

/**
* Handle structure change from slab cutter (also resets camera).
*/
Expand Down Expand Up @@ -291,6 +303,7 @@ export function create_build_tools_controller(deps: BuildToolsDeps) {
// ── Functions ──
open_build_tab,
handle_structure_replace,
handle_structure_replace_and_fit,
handle_slab_structure_change,
handle_structure_modify,
handle_slab_camera_transition,
Expand Down
Loading