diff --git a/extensions/rust/src/nanoscroll.rs b/extensions/rust/src/nanoscroll.rs index 18f9c0f44..1855103a4 100644 --- a/extensions/rust/src/nanoscroll.rs +++ b/extensions/rust/src/nanoscroll.rs @@ -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; @@ -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 { @@ -275,6 +279,23 @@ pub fn min_interwinding_gap(pitch: f64, monolayer_thickness: f64) -> f64 { pitch - monolayer_thickness } +fn bounding_box(points: &[Vector3]) -> Option<(Vector3, Vector3)> { + 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 @@ -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 = elements.into_iter().map(Species::neutral).collect(); let mut properties: HashMap = HashMap::new(); @@ -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> = 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)) } @@ -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, ¶ms).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(); diff --git a/src/lib/structure/Structure.svelte b/src/lib/structure/Structure.svelte index b4c33fdfa..1e086fc80 100644 --- a/src/lib/structure/Structure.svelte +++ b/src/lib/structure/Structure.svelte @@ -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, }) @@ -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'} ([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. @@ -1098,10 +1116,10 @@ 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 @@ -1109,8 +1127,8 @@ 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. @@ -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) } }) @@ -1727,7 +1746,7 @@ let computed_zoom = $state(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 @@ -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, diff --git a/src/lib/structure/controllers/build-tools.svelte.ts b/src/lib/structure/controllers/build-tools.svelte.ts index f87783827..2a879de43 100644 --- a/src/lib/structure/controllers/build-tools.svelte.ts +++ b/src/lib/structure/controllers/build-tools.svelte.ts @@ -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 ── @@ -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(), * }) * ``` @@ -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). */ @@ -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,