1use serde::{Deserialize, Serialize};
11use xtalforge_core::Structure;
12use xtalforge_view::{ViewSettings, build_scene};
13
14use crate::Error;
15use crate::png::RgbImage;
16
17pub const MIN_DIMENSION: u32 = 32;
19pub const MAX_DIMENSION: u32 = 4096;
21
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24pub struct RenderOptions {
25 pub width: u32,
27 pub height: u32,
29 pub direction: [f64; 3],
31 pub up: [f64; 3],
33 pub margin: f64,
35 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 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
215pub 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 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 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
291pub 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}