Skip to main content

xtalforge_agent/
render.rs

1//! Pictures for the agent (Task 8.1.6).
2//!
3//! Crystal-Builder renders with VTK. This module keeps the intent — an agent
4//! can look at a structure — with a small pure-Rust orthographic renderer of
5//! the scene data (Task 3.14.4). It writes SVG for documents and PNG for
6//! clients that only accept raster images. There is no shading beyond one
7//! fixed light and no transparency; the picture is good enough to see a
8//! folded linker or a misplaced cluster.
9
10use serde::{Deserialize, Serialize};
11use xtalforge_core::Structure;
12use xtalforge_view::{ViewSettings, build_scene};
13
14use crate::Error;
15use crate::png::RgbImage;
16
17/// Smallest accepted image dimension.
18pub const MIN_DIMENSION: u32 = 32;
19/// Largest accepted image dimension.
20pub const MAX_DIMENSION: u32 = 4096;
21
22/// How to draw a picture.
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24pub struct RenderOptions {
25    /// Width in pixels.
26    pub width: u32,
27    /// Height in pixels.
28    pub height: u32,
29    /// Camera direction: the vector from the structure to the camera.
30    pub direction: [f64; 3],
31    /// Up hint in world coordinates.
32    pub up: [f64; 3],
33    /// Margin around the structure in pixels.
34    pub margin: f64,
35    /// View settings for radii, colours and the cell.
36    pub settings: ViewSettings,
37}
38
39impl Default for RenderOptions {
40    fn default() -> Self {
41        Self {
42            width: 800,
43            height: 600,
44            direction: [1.0, -1.4, 0.9],
45            up: [0.0, 0.0, 1.0],
46            margin: 24.0,
47            settings: ViewSettings::default(),
48        }
49    }
50}
51
52impl RenderOptions {
53    /// Checks the dimensions and normalises the camera.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`Error::Argument`] for a dimension outside the accepted range
58    /// or a degenerate camera.
59    pub fn validate(&self) -> Result<(), Error> {
60        if !(MIN_DIMENSION..=MAX_DIMENSION).contains(&self.width)
61            || !(MIN_DIMENSION..=MAX_DIMENSION).contains(&self.height)
62        {
63            return Err(Error::Argument {
64                name: "width/height".to_owned(),
65                reason: format!("must be between {MIN_DIMENSION} and {MAX_DIMENSION} pixels"),
66            });
67        }
68        if !self.direction.iter().all(|value| value.is_finite()) || norm(self.direction) < 1e-9 {
69            return Err(Error::Argument {
70                name: "direction".to_owned(),
71                reason: "must be a finite non-zero vector".to_owned(),
72            });
73        }
74        Ok(())
75    }
76}
77
78fn norm(vector: [f64; 3]) -> f64 {
79    (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt()
80}
81
82fn normalised(vector: [f64; 3]) -> [f64; 3] {
83    let length = norm(vector);
84    if length == 0.0 {
85        [0.0, 0.0, 0.0]
86    } else {
87        [vector[0] / length, vector[1] / length, vector[2] / length]
88    }
89}
90
91fn dot(left: [f64; 3], right: [f64; 3]) -> f64 {
92    left[0] * right[0] + left[1] * right[1] + left[2] * right[2]
93}
94
95fn cross(left: [f64; 3], right: [f64; 3]) -> [f64; 3] {
96    [
97        left[1] * right[2] - left[2] * right[1],
98        left[2] * right[0] - left[0] * right[2],
99        left[0] * right[1] - left[1] * right[0],
100    ]
101}
102
103fn subtract(left: [f64; 3], right: [f64; 3]) -> [f64; 3] {
104    [left[0] - right[0], left[1] - right[1], left[2] - right[2]]
105}
106
107struct Camera {
108    center: [f64; 3],
109    right: [f64; 3],
110    up: [f64; 3],
111    forward: [f64; 3],
112    scale: f64,
113    width: f64,
114    height: f64,
115}
116
117struct Projected {
118    x: f64,
119    y: f64,
120    depth: f64,
121    radius: f64,
122}
123
124impl Camera {
125    fn project(&self, point: [f64; 3], radius_world: f64) -> Projected {
126        let relative = subtract(point, self.center);
127        Projected {
128            x: self.width / 2.0 + dot(relative, self.right) * self.scale,
129            y: self.height / 2.0 - dot(relative, self.up) * self.scale,
130            depth: dot(relative, self.forward),
131            radius: radius_world * self.scale,
132        }
133    }
134}
135
136fn camera(structure: &Structure, options: &RenderOptions) -> Result<Camera, Error> {
137    let scene = build_scene(structure, &options.settings)?;
138    let forward = normalised(options.direction);
139    let mut right = cross(options.up, forward);
140    if norm(right) < 1e-9 {
141        right = cross([1.0, 0.0, 0.0], forward);
142    }
143    let right = normalised(right);
144    let up = normalised(cross(forward, right));
145
146    let mut minimum = [f64::INFINITY; 3];
147    let mut maximum = [f64::NEG_INFINITY; 3];
148    let mut include = |point: [f64; 3], radius: f64| {
149        for ((low, high), value) in minimum.iter_mut().zip(maximum.iter_mut()).zip(point.iter()) {
150            *low = low.min(*value - radius);
151            *high = high.max(*value + radius);
152        }
153    };
154    for atom in &scene.atoms {
155        if !atom.hidden {
156            include(atom.position, atom.radius.max(0.2));
157        }
158    }
159    for (start, end) in &scene.cell_edges {
160        include(*start, 0.0);
161        include(*end, 0.0);
162    }
163    if !minimum[0].is_finite() {
164        return Err(Error::Core(xtalforge_core::Error::InvalidInput(
165            "a picture needs at least one visible atom".to_owned(),
166        )));
167    }
168    let center = [
169        (minimum[0] + maximum[0]) / 2.0,
170        (minimum[1] + maximum[1]) / 2.0,
171        (minimum[2] + maximum[2]) / 2.0,
172    ];
173    let mut half_width: f64 = 0.0;
174    let mut half_height: f64 = 0.0;
175    let mut measure = |point: [f64; 3], radius: f64| {
176        let relative = subtract(point, center);
177        half_width = half_width.max(dot(relative, right).abs() + radius);
178        half_height = half_height.max(dot(relative, up).abs() + radius);
179    };
180    for atom in &scene.atoms {
181        if !atom.hidden {
182            measure(atom.position, atom.radius.max(0.2));
183        }
184    }
185    for (start, end) in &scene.cell_edges {
186        measure(*start, 0.0);
187        measure(*end, 0.0);
188    }
189    let usable_width = (f64::from(options.width) - 2.0 * options.margin).max(1.0);
190    let usable_height = (f64::from(options.height) - 2.0 * options.margin).max(1.0);
191    let scale = (usable_width / (2.0 * half_width.max(1e-6)))
192        .min(usable_height / (2.0 * half_height.max(1e-6)));
193    Ok(Camera {
194        center,
195        right,
196        up,
197        forward,
198        scale,
199        width: f64::from(options.width),
200        height: f64::from(options.height),
201    })
202}
203
204fn shade(color: [u8; 3], normal: [f64; 3]) -> [u8; 3] {
205    let light = normalised([-0.4, 0.5, 0.75]);
206    let diffuse = dot(normal, light).max(0.0);
207    let intensity = 0.35 + 0.65 * diffuse;
208    [
209        (f64::from(color[0]) * intensity).clamp(0.0, 255.0) as u8,
210        (f64::from(color[1]) * intensity).clamp(0.0, 255.0) as u8,
211        (f64::from(color[2]) * intensity).clamp(0.0, 255.0) as u8,
212    ]
213}
214
215/// Renders a structure to SVG.
216///
217/// # Errors
218///
219/// Returns the scene, P1 expansion or argument error.
220pub fn render_svg(structure: &Structure, options: &RenderOptions) -> Result<String, Error> {
221    options.validate()?;
222    let camera = camera(structure, options)?;
223    let scene = build_scene(structure, &options.settings)?;
224    let mut svg = String::with_capacity(16_384);
225    svg.push_str(&format!(
226        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\" viewBox=\"0 0 {} {}\">\n",
227        options.width, options.height, options.width, options.height
228    ));
229    svg.push_str(&format!(
230        "<rect width=\"{}\" height=\"{}\" fill=\"#{:02x}{:02x}{:02x}\"/>\n",
231        options.width,
232        options.height,
233        options.settings.background[0],
234        options.settings.background[1],
235        options.settings.background[2]
236    ));
237
238    // Cell edges first: they are context, not geometry.
239    let mut cell = String::new();
240    for (start, end) in &scene.cell_edges {
241        let first = camera.project(*start, 0.0);
242        let second = camera.project(*end, 0.0);
243        cell.push_str(&format!(
244            "<line x1=\"{:.1}\" y1=\"{:.1}\" x2=\"{:.1}\" y2=\"{:.1}\" stroke=\"#9aa0a6\" stroke-width=\"1\"/>\n",
245            first.x, first.y, second.x, second.y
246        ));
247    }
248
249    // Bonds and atoms share one depth order so a bond behind a sphere stays
250    // behind it.
251    let mut primitives: Vec<(f64, String)> = Vec::new();
252    for bond in &scene.bonds {
253        let first = camera.project(bond.start, 0.0);
254        let second = camera.project(bond.end, 0.0);
255        let radius = (bond.radius * camera.scale).max(1.0);
256        primitives.push((
257            (first.depth + second.depth) / 2.0 - radius,
258            format!(
259                "<line x1=\"{:.1}\" y1=\"{:.1}\" x2=\"{:.1}\" y2=\"{:.1}\" stroke=\"#{:02x}{:02x}{:02x}\" stroke-width=\"{:.1}\" stroke-linecap=\"round\"/>\n",
260                first.x, first.y, second.x, second.y, bond.color[0], bond.color[1], bond.color[2], radius * 2.0
261            ),
262        ));
263    }
264    for atom in &scene.atoms {
265        if atom.hidden || atom.radius <= 0.0 {
266            continue;
267        }
268        let projected = camera.project(atom.position, atom.radius);
269        primitives.push((
270            projected.depth - projected.radius,
271            format!(
272                "<circle cx=\"{:.1}\" cy=\"{:.1}\" r=\"{:.1}\" fill=\"#{:02x}{:02x}{:02x}\"/>\n",
273                projected.x,
274                projected.y,
275                projected.radius,
276                atom.color[0],
277                atom.color[1],
278                atom.color[2]
279            ),
280        ));
281    }
282    primitives.sort_by(|left, right| left.0.total_cmp(&right.0));
283    svg.push_str(&cell);
284    for (_, primitive) in primitives {
285        svg.push_str(&primitive);
286    }
287    svg.push_str("</svg>\n");
288    Ok(svg)
289}
290
291/// Renders a structure to PNG.
292///
293/// # Errors
294///
295/// Returns the scene, P1 expansion, argument or encoder error.
296pub fn render_png(structure: &Structure, options: &RenderOptions) -> Result<Vec<u8>, Error> {
297    options.validate()?;
298    let camera = camera(structure, options)?;
299    let scene = build_scene(structure, &options.settings)?;
300    let mut image = RgbImage::filled(options.width, options.height, options.settings.background);
301    let mut depth_buffer =
302        vec![f64::NEG_INFINITY; options.width as usize * options.height as usize];
303
304    for (start, end) in &scene.cell_edges {
305        let first = camera.project(*start, 0.0);
306        let second = camera.project(*end, 0.0);
307        draw_capsule(
308            &mut image,
309            &mut depth_buffer,
310            first,
311            second,
312            0.75,
313            [154, 160, 166],
314        );
315    }
316    for bond in &scene.bonds {
317        let first = camera.project(bond.start, 0.0);
318        let second = camera.project(bond.end, 0.0);
319        draw_capsule(
320            &mut image,
321            &mut depth_buffer,
322            first,
323            second,
324            (bond.radius * camera.scale).max(0.75),
325            bond.color,
326        );
327    }
328    for atom in &scene.atoms {
329        if atom.hidden || atom.radius <= 0.0 {
330            continue;
331        }
332        let projected = camera.project(atom.position, atom.radius);
333        draw_sphere(&mut image, &mut depth_buffer, projected, atom.color);
334    }
335    image
336        .to_png()
337        .map_err(|error| Error::Io(format!("PNG encoder: {error}")))
338}
339
340fn draw_sphere(image: &mut RgbImage, depth: &mut [f64], projected: Projected, color: [u8; 3]) {
341    let radius = projected.radius.max(0.75);
342    let left = (projected.x - radius).floor().max(0.0) as u32;
343    let right = (projected.x + radius).ceil().min(f64::from(image.width())) as u32;
344    let top = (projected.y - radius).floor().max(0.0) as u32;
345    let bottom = (projected.y + radius).ceil().min(f64::from(image.height())) as u32;
346    for y in top..bottom {
347        for x in left..right {
348            let dx = f64::from(x) + 0.5 - projected.x;
349            let dy = f64::from(y) + 0.5 - projected.y;
350            let distance_squared = dx * dx + dy * dy;
351            if distance_squared > radius * radius {
352                continue;
353            }
354            let offset = (radius * radius - distance_squared).sqrt();
355            let z = projected.depth + offset;
356            let index = y as usize * image.width() as usize + x as usize;
357            if let Some(slot) = depth.get_mut(index)
358                && z > *slot
359            {
360                *slot = z;
361                let normal = normalised([dx / radius, -dy / radius, offset / radius]);
362                image.set(x, y, shade(color, normal));
363            }
364        }
365    }
366}
367
368fn draw_capsule(
369    image: &mut RgbImage,
370    depth: &mut [f64],
371    start: Projected,
372    end: Projected,
373    radius: f64,
374    color: [u8; 3],
375) {
376    let left = (start.x.min(end.x) - radius).floor().max(0.0) as u32;
377    let right = (start.x.max(end.x) + radius)
378        .ceil()
379        .min(f64::from(image.width())) as u32;
380    let top = (start.y.min(end.y) - radius).floor().max(0.0) as u32;
381    let bottom = (start.y.max(end.y) + radius)
382        .ceil()
383        .min(f64::from(image.height())) as u32;
384    let axis = [end.x - start.x, end.y - start.y];
385    let length_squared = axis[0] * axis[0] + axis[1] * axis[1];
386    for y in top..bottom {
387        for x in left..right {
388            let point = [f64::from(x) + 0.5 - start.x, f64::from(y) + 0.5 - start.y];
389            let t = if length_squared > 0.0 {
390                ((point[0] * axis[0] + point[1] * axis[1]) / length_squared).clamp(0.0, 1.0)
391            } else {
392                0.0
393            };
394            let nearest = [axis[0] * t, axis[1] * t];
395            let dx = point[0] - nearest[0];
396            let dy = point[1] - nearest[1];
397            let distance_squared = dx * dx + dy * dy;
398            if distance_squared > radius * radius {
399                continue;
400            }
401            let z = start.depth + (end.depth - start.depth) * t;
402            let index = y as usize * image.width() as usize + x as usize;
403            if let Some(slot) = depth.get_mut(index)
404                && z > *slot
405            {
406                *slot = z;
407                let normal = normalised([dx / radius, -dy / radius, 0.35]);
408                image.set(x, y, shade(color, normal));
409            }
410        }
411    }
412}