1use std::io::Cursor;
8use std::sync::Arc;
9use std::sync::atomic::Ordering;
10
11use axum::Json;
12use axum::extract::{Multipart, State};
13use bytes::Bytes;
14use chrono::Utc;
15use image::GenericImageView as _;
16use image::{ImageFormat, Rgba};
17use serde::{Deserialize, Serialize};
18use sl_map_apis::map_tiles::{Map, MapProgressEvent};
19use sl_map_apis::region::usb_notecard_to_grid_rectangle;
20use sl_types::map::{
21 GridCoordinates, GridRectangle, GridRectangleLike as _, RegionCoordinates, USBNotecard,
22 ZoomLevel,
23};
24use tokio_stream::wrappers::ReceiverStream;
25use uuid::Uuid;
26
27use crate::auth::CurrentUser;
28use crate::error::Error;
29use crate::jobs::{JobId, JobOutcome, JobState, Metadata, ProgressDto, record_event};
30use crate::library::{self, Destination};
31use crate::routes::notecards as notecard_routes;
32use crate::state::AppState;
33use crate::storage;
34
35const MAX_OUTPUT_DIMENSION: u32 = 0x8000;
42
43const MAX_OUTPUT_AREA: u64 = 0x4000 * 0x4000;
51
52const MAX_CONCURRENT_RENDERS_PER_USER: i64 = 3;
58
59const MIN_PIXELS_PER_REGION_FOR_REGION_LABELS: f32 = 64.0;
65
66const MAX_REGIONS_FOR_REGION_LABELS: usize = 1024;
72
73const REGION_LABEL_FONT_FACTOR: f32 = 0.12;
77
78const REGION_LABEL_FONT_MIN_PX: f32 = 8.0;
80
81const REGION_LABEL_FONT_MAX_PX: f32 = 22.0;
83
84const REGION_LABEL_PADDING: i32 = 3;
87
88const REGION_RECTANGLE_COLOR: Rgba<u8> = Rgba([255, 255, 255, 255]);
91
92#[derive(Debug, Clone, Copy, Default)]
95struct RegionOverlayOptions {
96 rectangles: bool,
98 names: bool,
100 coordinates: bool,
103}
104
105impl RegionOverlayOptions {
106 const fn any(self) -> bool {
108 self.rectangles || self.names || self.coordinates
109 }
110
111 const fn any_text(self) -> bool {
114 self.names || self.coordinates
115 }
116}
117
118#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
120#[serde(rename_all = "lowercase")]
121pub enum OutputFormat {
122 #[default]
124 Png,
125 #[serde(alias = "jpg")]
127 Jpeg,
128}
129
130impl OutputFormat {
131 const fn image_format(self) -> ImageFormat {
133 match self {
134 Self::Png => ImageFormat::Png,
135 Self::Jpeg => ImageFormat::Jpeg,
136 }
137 }
138
139 const fn content_type(self) -> &'static str {
141 match self {
142 Self::Png => "image/png",
143 Self::Jpeg => "image/jpeg",
144 }
145 }
146}
147
148#[derive(Debug, Clone)]
150struct CommonParams {
151 max_width: u32,
153 max_height: u32,
155 missing_map_tile_color: Option<Rgba<u8>>,
157 missing_region_color: Option<Rgba<u8>>,
159 format: OutputFormat,
161 region_overlay: RegionOverlayOptions,
163 region_label_font_id: Option<String>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
169#[serde(tag = "type", rename_all = "snake_case")]
170pub enum GlwSource {
171 SavedId {
173 glw_data_id: Uuid,
175 },
176 EventId {
179 event_id: u32,
181 },
182 EventKey {
185 event_key: String,
187 },
188 PastedJson {
191 payload: String,
194 },
195}
196
197#[derive(Debug, Clone, Default, Serialize, Deserialize)]
201pub struct GlwStyleOverrides {
202 #[serde(default)]
204 pub margin_band: bool,
205 #[serde(default)]
207 pub area_outline_color: Option<String>,
208 #[serde(default)]
210 pub circle_outline_color: Option<String>,
211 #[serde(default)]
213 pub margin_outline_color: Option<String>,
214 #[serde(default)]
216 pub wind_color: Option<String>,
217 #[serde(default)]
219 pub current_color: Option<String>,
220 #[serde(default)]
222 pub wave_color: Option<String>,
223 #[serde(default)]
225 pub label_color: Option<String>,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct TextLabel {
232 pub slot: String,
234 pub lines: Vec<String>,
236 pub font_id: String,
239 pub font_px: f32,
241 pub color: String,
243 #[serde(default)]
246 pub h_align: Option<String>,
247 #[serde(default)]
250 pub v_align: Option<String>,
251 #[serde(default)]
256 pub slots: Vec<String>,
257}
258
259const fn default_logo_scale() -> u8 {
261 1
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct LogoPlacement {
269 pub slot: String,
271 pub logo_id: Uuid,
273 #[serde(default = "default_logo_scale")]
276 pub scale: u8,
277 #[serde(default)]
282 pub slots: Vec<String>,
283 #[serde(default)]
286 pub h_align: Option<String>,
287 #[serde(default)]
290 pub v_align: Option<String>,
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct GlwRenderOptions {
296 pub source: GlwSource,
298 pub font_id: String,
301 #[serde(default)]
306 pub save_as: Option<String>,
307 #[serde(default)]
309 pub style: GlwStyleOverrides,
310 #[serde(default)]
314 pub legend_slot: Option<String>,
315}
316
317#[derive(Debug, Clone, Deserialize)]
319pub struct GridRectangleRequest {
320 pub lower_left_x: u16,
322 pub lower_left_y: u16,
324 pub upper_right_x: u16,
326 pub upper_right_y: u16,
328 pub max_width: u32,
330 pub max_height: u32,
332 #[serde(default)]
334 pub missing_map_tile_color: Option<String>,
335 #[serde(default)]
337 pub missing_region_color: Option<String>,
338 #[serde(default)]
340 pub format: OutputFormat,
341 #[serde(default)]
343 pub draw_region_rectangles: bool,
344 #[serde(default)]
346 pub draw_region_names: bool,
347 #[serde(default)]
349 pub draw_region_coordinates: bool,
350 #[serde(default)]
353 pub region_label_font_id: Option<String>,
354 #[serde(default)]
357 pub save_to: Option<String>,
358 #[serde(default)]
361 pub glw: Option<GlwRenderOptions>,
362 #[serde(default)]
365 pub labels: Vec<TextLabel>,
366 #[serde(default)]
368 pub logos: Vec<LogoPlacement>,
369 #[serde(default)]
374 pub groups: Vec<Vec<String>>,
375}
376
377#[derive(Debug, Clone, Serialize)]
379pub struct StartedResponse {
380 pub job_id: Uuid,
382 #[serde(skip_serializing_if = "Option::is_none")]
388 pub notecard: Option<NotecardSummary>,
389}
390
391#[derive(Debug, Clone, Serialize)]
395pub struct NotecardSummary {
396 pub notecard_id: Uuid,
399 pub name: String,
401 pub scope: String,
403}
404
405#[derive(Debug, Clone, Serialize, Deserialize)]
408#[serde(tag = "kind", rename_all = "snake_case")]
409pub enum SavedRenderSettings {
410 GridRectangle(SavedGridRectangleSettings),
412 UsbNotecard(SavedUsbNotecardSettings),
414}
415
416#[derive(Debug, Clone, Serialize, Deserialize)]
418pub struct SavedGridRectangleSettings {
419 pub lower_left_x: u16,
421 pub lower_left_y: u16,
423 pub upper_right_x: u16,
425 pub upper_right_y: u16,
427 pub max_width: u32,
429 pub max_height: u32,
431 pub missing_map_tile_color: Option<String>,
433 pub missing_region_color: Option<String>,
435 pub format: String,
437 #[serde(default)]
439 pub draw_region_rectangles: bool,
440 #[serde(default)]
442 pub draw_region_names: bool,
443 #[serde(default)]
445 pub draw_region_coordinates: bool,
446 #[serde(default)]
448 pub region_label_font_id: Option<String>,
449 #[serde(default)]
453 pub glw: Option<GlwRenderOptions>,
454 #[serde(default)]
456 pub labels: Vec<TextLabel>,
457 #[serde(default)]
459 pub logos: Vec<LogoPlacement>,
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize)]
464#[expect(
465 clippy::struct_excessive_bools,
466 reason = "this is a flat persisted form-settings record; each bool maps directly to one independent checkbox in the render form"
467)]
468pub struct SavedUsbNotecardSettings {
469 pub notecard_id: Uuid,
471 pub border_north: u16,
474 pub border_south: u16,
477 pub border_east: u16,
480 pub border_west: u16,
483 pub color: String,
485 pub max_width: u32,
487 pub max_height: u32,
489 pub missing_map_tile_color: Option<String>,
491 pub missing_region_color: Option<String>,
493 pub format: String,
495 #[serde(default)]
497 pub draw_region_rectangles: bool,
498 #[serde(default)]
500 pub draw_region_names: bool,
501 #[serde(default)]
503 pub draw_region_coordinates: bool,
504 #[serde(default)]
506 pub region_label_font_id: Option<String>,
507 pub save_without_route: bool,
509 #[serde(default)]
511 pub glw: Option<GlwRenderOptions>,
512 #[serde(default)]
514 pub labels: Vec<TextLabel>,
515 #[serde(default)]
517 pub logos: Vec<LogoPlacement>,
518}
519
520pub async fn grid_rectangle(
527 user: CurrentUser,
528 State(state): State<AppState>,
529 Json(req): Json<GridRectangleRequest>,
530) -> Result<Json<StartedResponse>, Error> {
531 validate_dimensions(req.max_width, req.max_height)?;
532 let common = CommonParams {
533 max_width: req.max_width,
534 max_height: req.max_height,
535 missing_map_tile_color: req
536 .missing_map_tile_color
537 .as_deref()
538 .map(parse_color)
539 .transpose()?,
540 missing_region_color: req
541 .missing_region_color
542 .as_deref()
543 .map(parse_color)
544 .transpose()?,
545 format: req.format,
546 region_overlay: RegionOverlayOptions {
547 rectangles: req.draw_region_rectangles,
548 names: req.draw_region_names,
549 coordinates: req.draw_region_coordinates,
550 },
551 region_label_font_id: req.region_label_font_id.clone(),
552 };
553 assert_region_overlay_font(
554 &state,
555 common.region_overlay,
556 common.region_label_font_id.as_deref(),
557 )?;
558 let destination = Destination::parse(req.save_to.as_deref().unwrap_or("personal"))?;
559 library::assert_can_write(&state.db, user.user_id, destination).await?;
560 assert_under_concurrent_limit(&state.db, user.user_id).await?;
561 let logo_ids = validate_logos(&state, user.user_id, destination, &req.logos).await?;
562 let rect = GridRectangle::new(
563 GridCoordinates::new(req.lower_left_x, req.lower_left_y),
564 GridCoordinates::new(req.upper_right_x, req.upper_right_y),
565 );
566 let glw_ctx = req.glw.clone().map(|opts| GlwJobCtx {
567 options: opts,
568 destination,
569 created_by: user.user_id,
570 });
571 plan_placements(
574 &state,
575 rect.clone(),
576 &common,
577 glw_ctx.as_ref(),
578 None,
579 &req.labels,
580 &req.logos,
581 )
582 .await?;
583 let settings = SavedRenderSettings::GridRectangle(SavedGridRectangleSettings {
584 lower_left_x: req.lower_left_x,
585 lower_left_y: req.lower_left_y,
586 upper_right_x: req.upper_right_x,
587 upper_right_y: req.upper_right_y,
588 max_width: req.max_width,
589 max_height: req.max_height,
590 missing_map_tile_color: common.missing_map_tile_color.map(hex_from_rgba),
591 missing_region_color: common.missing_region_color.map(hex_from_rgba),
592 format: format_name(req.format).to_owned(),
593 draw_region_rectangles: req.draw_region_rectangles,
594 draw_region_names: req.draw_region_names,
595 draw_region_coordinates: req.draw_region_coordinates,
596 region_label_font_id: req.region_label_font_id.clone(),
597 glw: req.glw.clone(),
601 labels: req.labels.clone(),
602 logos: req.logos.clone(),
603 });
604 let render_id = Uuid::new_v4();
605 insert_render_row(
606 &state,
607 render_id,
608 destination,
609 user.user_id,
610 "grid_rectangle",
611 None,
612 &settings,
613 Some(&rect),
614 )
615 .await?;
616 link_render_logos_or_fail(&state, render_id, &logo_ids).await?;
617 let (job_id, job) = state.jobs.create_with_id(render_id).await;
618 spawn_grid_rectangle_job(
619 state, job_id, job, rect, common, glw_ctx, req.labels, req.logos,
620 );
621 Ok(Json(StartedResponse {
622 job_id,
623 notecard: None,
624 }))
625}
626
627pub async fn usb_notecard(
635 user: CurrentUser,
636 State(state): State<AppState>,
637 multipart: Multipart,
638) -> Result<Json<StartedResponse>, Error> {
639 let parsed = parse_render_form(multipart).await?;
640 library::assert_can_write(&state.db, user.user_id, parsed.destination).await?;
641 assert_region_overlay_font(
642 &state,
643 parsed.common.region_overlay,
644 parsed.common.region_label_font_id.as_deref(),
645 )?;
646 assert_under_concurrent_limit(&state.db, user.user_id).await?;
647 let logo_ids = validate_logos(&state, user.user_id, parsed.destination, &parsed.logos).await?;
648
649 let (notecard, notecard_summary) = resolve_notecard(&state, &user, &parsed).await?;
654
655 let glw_ctx = parsed.glw.clone().map(|opts| GlwJobCtx {
656 options: opts,
657 destination: parsed.destination,
658 created_by: user.user_id,
659 });
660 let (_bare_rect, rect) = resolve_notecard_rect(&state, ¬ecard, parsed.borders).await?;
664 plan_placements(
665 &state,
666 rect,
667 &parsed.common,
668 glw_ctx.as_ref(),
669 Some((¬ecard, parsed.color)),
670 &parsed.labels,
671 &parsed.logos,
672 )
673 .await?;
674
675 let settings = SavedRenderSettings::UsbNotecard(SavedUsbNotecardSettings {
676 notecard_id: notecard_summary.notecard_id,
677 border_north: parsed.borders.0,
678 border_south: parsed.borders.1,
679 border_east: parsed.borders.2,
680 border_west: parsed.borders.3,
681 color: hex_from_rgba(parsed.color),
682 max_width: parsed.common.max_width,
683 max_height: parsed.common.max_height,
684 missing_map_tile_color: parsed.common.missing_map_tile_color.map(hex_from_rgba),
685 missing_region_color: parsed.common.missing_region_color.map(hex_from_rgba),
686 format: format_name(parsed.common.format).to_owned(),
687 draw_region_rectangles: parsed.common.region_overlay.rectangles,
688 draw_region_names: parsed.common.region_overlay.names,
689 draw_region_coordinates: parsed.common.region_overlay.coordinates,
690 region_label_font_id: parsed.common.region_label_font_id.clone(),
691 save_without_route: parsed.with_without_route,
692 glw: parsed.glw.clone(),
695 labels: parsed.labels.clone(),
696 logos: parsed.logos.clone(),
697 });
698
699 let render_id = Uuid::new_v4();
700 insert_render_row(
701 &state,
702 render_id,
703 parsed.destination,
704 user.user_id,
705 "usb_notecard",
706 Some(notecard_summary.notecard_id),
707 &settings,
708 None,
709 )
710 .await?;
711 link_render_logos_or_fail(&state, render_id, &logo_ids).await?;
712
713 let (job_id, job) = state.jobs.create_with_id(render_id).await;
714 spawn_usb_notecard_job(
715 state,
716 job_id,
717 job,
718 notecard_summary.notecard_id,
719 notecard,
720 parsed.borders,
721 parsed.color,
722 parsed.common,
723 parsed.with_without_route,
724 glw_ctx,
725 parsed.labels,
726 parsed.logos,
727 );
728 Ok(Json(StartedResponse {
729 job_id,
730 notecard: Some(notecard_summary),
731 }))
732}
733
734#[derive(Debug, Clone, Copy, Serialize)]
747pub struct PixelRectDto {
748 pub x: u32,
750 pub y: u32,
752 pub width: u32,
754 pub height: u32,
756}
757
758#[derive(Debug, Clone, Serialize)]
760pub struct SlotDto {
761 pub slot: &'static str,
764 pub available: bool,
766 #[serde(skip_serializing_if = "Option::is_none")]
770 pub free_rect: Option<PixelRectDto>,
771 pub free_width: u32,
773 pub free_height: u32,
775 pub occupied_fraction: f32,
778 pub connected_neighbours: Vec<&'static str>,
781}
782
783#[derive(Debug, Clone, Serialize)]
786pub struct GroupDto {
787 pub slots: Vec<&'static str>,
790 pub available: bool,
792 #[serde(skip_serializing_if = "Option::is_none")]
795 pub free_rect: Option<PixelRectDto>,
796 pub free_width: u32,
798 pub free_height: u32,
800}
801
802#[derive(Debug, Clone, Serialize)]
804pub struct PlacementSlotsResponse {
805 pub image_width: u32,
807 pub image_height: u32,
809 pub slots: Vec<SlotDto>,
811 pub groups: Vec<GroupDto>,
814}
815
816fn compute_placement_slots(
819 map: &Map,
820 groups: &[Vec<sl_map_apis::coverage::PlacementSlot>],
821) -> PlacementSlotsResponse {
822 use sl_map_apis::coverage::PlacementSlot;
823 let image = sl_map_apis::map_tiles::MapLike::image(map);
824 let (image_width, image_height) = image.dimensions();
825 let grid = sl_map_apis::coverage::OccupancyGrid::from_map(
826 map,
827 sl_map_apis::coverage::DEFAULT_COVERAGE_GRID,
828 );
829 let slots = grid
830 .evaluate_slots()
831 .into_iter()
832 .map(|info| SlotDto {
833 slot: info.slot.as_str(),
834 available: info.available,
835 free_rect: info.free_rect.map(|r| PixelRectDto {
836 x: r.x,
837 y: r.y,
838 width: r.width,
839 height: r.height,
840 }),
841 free_width: info.free_size.0,
842 free_height: info.free_size.1,
843 occupied_fraction: info.occupied_fraction,
844 connected_neighbours: info
845 .connected_neighbours
846 .into_iter()
847 .map(PlacementSlot::as_str)
848 .collect(),
849 })
850 .collect();
851 let group_dtos = groups
852 .iter()
853 .filter(|g| g.len() > 1)
854 .map(|g| {
855 let rect = grid.subset_rect(g);
856 GroupDto {
857 slots: g.iter().map(|s| s.as_str()).collect(),
858 available: rect.is_some(),
859 free_rect: rect.map(|r| PixelRectDto {
860 x: r.x,
861 y: r.y,
862 width: r.width,
863 height: r.height,
864 }),
865 free_width: rect.map_or(0, |r| r.width),
866 free_height: rect.map_or(0, |r| r.height),
867 }
868 })
869 .collect();
870 PlacementSlotsResponse {
871 image_width,
872 image_height,
873 slots,
874 groups: group_dtos,
875 }
876}
877
878fn parse_groups(
881 raw: &[Vec<String>],
882) -> Result<Vec<Vec<sl_map_apis::coverage::PlacementSlot>>, Error> {
883 raw.iter()
884 .map(|g| {
885 if g.is_empty() {
886 return Err(Error::BadRequest(
887 "a placement group must not be empty".to_owned(),
888 ));
889 }
890 let group = g
891 .iter()
892 .map(|name| {
893 name.parse::<sl_map_apis::coverage::PlacementSlot>()
894 .map_err(|err: sl_map_apis::coverage::ParsePlacementSlotError| {
895 Error::BadRequest(err.to_string())
896 })
897 })
898 .collect::<Result<Vec<_>, _>>()?;
899 if !sl_map_apis::coverage::PlacementSlot::slots_form_rectangle(&group) {
900 return Err(Error::BadRequest(format!(
901 "the combined slot group `{}` does not form a solid rectangle",
902 g.join("+")
903 )));
904 }
905 Ok(group)
906 })
907 .collect()
908}
909
910async fn resolve_glw_event_readonly(
915 state: &AppState,
916 user_id: Uuid,
917 source: &GlwSource,
918) -> Result<Option<sl_glw::GlwEvent>, Error> {
919 match source {
920 GlwSource::SavedId { glw_data_id } => {
921 let row =
922 crate::library::assert_can_read_glw_data(&state.db, user_id, *glw_data_id).await?;
923 Ok(Some(serde_json::from_str(&row.payload_json)?))
924 }
925 GlwSource::EventId { event_id } => {
926 let id = sl_glw::EventId::new(*event_id);
927 let mut cache = state.glw_event_cache.lock().await;
928 Ok(cache.get_event_by_id(id).await?)
929 }
930 GlwSource::EventKey { event_key } => {
931 let key = sl_glw::GlwEventKey::new(event_key);
932 let mut cache = state.glw_event_cache.lock().await;
933 Ok(cache.get_event_by_key(&key).await?)
934 }
935 GlwSource::PastedJson { payload } => Ok(Some(serde_json::from_str(payload)?)),
936 }
937}
938
939async fn apply_glw_overlay_readonly(
944 state: &AppState,
945 user_id: Uuid,
946 options: &GlwRenderOptions,
947 map: &mut Map,
948) -> Result<(), Error> {
949 use sl_glw::MapLikeGlwExt as _;
950 let font_path = state
951 .fonts
952 .path_for(&options.font_id)
953 .ok_or_else(|| Error::BadRequest(format!("unknown font_id `{}`", options.font_id)))?;
954 let font = sl_map_apis::text::load_font(font_path)?;
955 let Some(event) = resolve_glw_event_readonly(state, user_id, &options.source).await? else {
956 tracing::warn!("GLW event not found; placement slots computed without overlay");
957 return Ok(());
958 };
959 let mut style = build_glw_style(&options.style, None)?;
962 style.legend_position = None;
963 map.draw_glw_event_with_font(&event, &style, &font);
964 Ok(())
965}
966
967pub async fn free_placement_slots_grid_rectangle(
976 user: CurrentUser,
977 State(state): State<AppState>,
978 Json(req): Json<GridRectangleRequest>,
979) -> Result<Json<PlacementSlotsResponse>, Error> {
980 validate_dimensions(req.max_width, req.max_height)?;
981 let rect = GridRectangle::new(
982 GridCoordinates::new(req.lower_left_x, req.lower_left_y),
983 GridCoordinates::new(req.upper_right_x, req.upper_right_y),
984 );
985 let groups = parse_groups(&req.groups)?;
986 let mut map = Map::blank_fit(rect, req.max_width, req.max_height)?;
987 if let Some(glw) = req.glw.as_ref() {
988 apply_glw_overlay_readonly(&state, user.user_id, glw, &mut map).await?;
989 }
990 Ok(Json(compute_placement_slots(&map, &groups)))
991}
992
993pub async fn free_placement_slots_usb_notecard(
1003 user: CurrentUser,
1004 State(state): State<AppState>,
1005 multipart: Multipart,
1006) -> Result<Json<PlacementSlotsResponse>, Error> {
1007 let parsed = parse_render_form(multipart).await?;
1008 let notecard: USBNotecard = match &parsed.notecard_source {
1010 NotecardSource::Fresh { text, .. } => text.parse()?,
1011 NotecardSource::Existing { notecard_id } => {
1012 let row =
1013 crate::library::assert_can_read_notecard(&state.db, user.user_id, *notecard_id)
1014 .await?;
1015 row.body.parse()?
1016 }
1017 };
1018 let (border_north, border_south, border_east, border_west) = parsed.borders;
1019 let rect = {
1020 let mut region = state.region_cache.lock().await;
1021 usb_notecard_to_grid_rectangle(&mut region, ¬ecard).await?
1022 }
1023 .expanded_west(border_west)
1024 .expanded_east(border_east)
1025 .expanded_south(border_south)
1026 .expanded_north(border_north);
1027 let mut map = Map::blank_fit(rect, parsed.common.max_width, parsed.common.max_height)?;
1028 if let Some(glw) = parsed.glw.as_ref() {
1030 apply_glw_overlay_readonly(&state, user.user_id, glw, &mut map).await?;
1031 }
1032 {
1033 let mut region = state.region_cache.lock().await;
1034 map.draw_route_with_progress(&mut region, ¬ecard, parsed.color, None)
1035 .await?;
1036 }
1037 let groups = parse_groups(&parsed.groups)?;
1038 Ok(Json(compute_placement_slots(&map, &groups)))
1039}
1040
1041#[derive(Debug, Clone, Deserialize)]
1043pub struct GlwPreviewRequest {
1044 pub lower_left_x: u16,
1046 pub lower_left_y: u16,
1048 pub upper_right_x: u16,
1050 pub upper_right_y: u16,
1052 pub zoom: u8,
1057 pub glw: GlwRenderOptions,
1059}
1060
1061pub async fn glw_preview(
1079 user: CurrentUser,
1080 State(state): State<AppState>,
1081 Json(req): Json<GlwPreviewRequest>,
1082) -> Result<axum::response::Response, Error> {
1083 use axum::response::IntoResponse as _;
1084 use sl_glw::MapLikeGlwExt as _;
1085 let zoom = ZoomLevel::try_new(req.zoom)
1086 .map_err(|err| Error::BadRequest(format!("invalid zoom: {err}")))?;
1087 let rect = GridRectangle::new(
1088 GridCoordinates::new(req.lower_left_x, req.lower_left_y),
1089 GridCoordinates::new(req.upper_right_x, req.upper_right_y),
1090 );
1091 let pixels_per_region = u32::from(zoom.pixels_per_region());
1098 let width = pixels_per_region.saturating_mul(u32::from(rect.size_x()));
1099 let height = pixels_per_region.saturating_mul(u32::from(rect.size_y()));
1100 validate_dimensions(width, height)?;
1101 let mut map = Map::blank(rect, zoom);
1102 let font_path = state
1104 .fonts
1105 .path_for(&req.glw.font_id)
1106 .ok_or_else(|| Error::BadRequest(format!("unknown font_id `{}`", req.glw.font_id)))?;
1107 let font = sl_map_apis::text::load_font(font_path)?;
1108 if let Some(event) = resolve_glw_event_readonly(&state, user.user_id, &req.glw.source).await? {
1110 let mut style = build_glw_style(&req.glw.style, None)?;
1116 style.legend_position = None;
1117 map.draw_glw_event_with_font(&event, &style, &font);
1118 } else {
1119 tracing::warn!("GLW event not found; preview overlay is fully transparent");
1120 }
1121 let image = encode_map(&map, OutputFormat::Png)?;
1124 Ok(([(axum::http::header::CONTENT_TYPE, "image/png")], image).into_response())
1125}
1126
1127pub async fn glw_legend_preview(
1148 user: CurrentUser,
1149 State(state): State<AppState>,
1150 Json(req): Json<GridRectangleRequest>,
1151) -> Result<axum::response::Response, Error> {
1152 use axum::response::IntoResponse as _;
1153 use sl_glw::MapLikeGlwExt as _;
1154 validate_dimensions(req.max_width, req.max_height)?;
1155 let rect = GridRectangle::new(
1156 GridCoordinates::new(req.lower_left_x, req.lower_left_y),
1157 GridCoordinates::new(req.upper_right_x, req.upper_right_y),
1158 );
1159 let mut map = Map::blank_fit(rect, req.max_width, req.max_height)?;
1161 if let Some(glw) = req.glw.as_ref() {
1162 let font_path = state
1164 .fonts
1165 .path_for(&glw.font_id)
1166 .ok_or_else(|| Error::BadRequest(format!("unknown font_id `{}`", glw.font_id)))?;
1167 let font = sl_map_apis::text::load_font(font_path)?;
1168 let style = build_glw_style(&glw.style, glw.legend_slot.as_deref())?;
1169 if style.legend_position.is_some() {
1172 if let Some(event) =
1173 resolve_glw_event_readonly(&state, user.user_id, &glw.source).await?
1174 {
1175 map.draw_glw_base_legend(&event.base, &style, &font);
1176 } else {
1177 tracing::warn!("GLW event not found; legend preview is fully transparent");
1178 }
1179 }
1180 }
1181 let image = encode_map(&map, OutputFormat::Png)?;
1184 Ok(([(axum::http::header::CONTENT_TYPE, "image/png")], image).into_response())
1185}
1186
1187#[derive(Debug, Clone, Deserialize)]
1194pub struct RoutePreviewWaypoint {
1195 pub region_x: u16,
1197 pub region_y: u16,
1199 pub x: f32,
1201 pub y: f32,
1203}
1204
1205#[derive(Debug, Clone, Deserialize)]
1207pub struct RoutePreviewRequest {
1208 pub lower_left_x: u16,
1210 pub lower_left_y: u16,
1212 pub upper_right_x: u16,
1214 pub upper_right_y: u16,
1216 pub max_width: u32,
1219 pub max_height: u32,
1221 pub color: String,
1223 pub waypoints: Vec<RoutePreviewWaypoint>,
1225}
1226
1227pub async fn route_preview(
1248 _user: CurrentUser,
1249 Json(req): Json<RoutePreviewRequest>,
1250) -> Result<axum::response::Response, Error> {
1251 use axum::response::IntoResponse as _;
1252 use sl_map_apis::map_tiles::MapLike as _;
1253 validate_dimensions(req.max_width, req.max_height)?;
1254 let color = parse_color(&req.color)?;
1255 let rect = GridRectangle::new(
1256 GridCoordinates::new(req.lower_left_x, req.lower_left_y),
1257 GridCoordinates::new(req.upper_right_x, req.upper_right_y),
1258 );
1259 let mut map = Map::blank_fit(rect, req.max_width, req.max_height)?;
1262 let mut pixel_waypoints = Vec::with_capacity(req.waypoints.len());
1263 for waypoint in &req.waypoints {
1264 let grid = GridCoordinates::new(waypoint.region_x, waypoint.region_y);
1265 let region_coordinates = RegionCoordinates::new(waypoint.x, waypoint.y, 0f32);
1266 let (x, y) = map
1267 .pixel_coordinates_for_coordinates(&grid, ®ion_coordinates)
1268 .ok_or_else(|| {
1269 Error::BadRequest("a route waypoint falls outside the rectangle".to_owned())
1270 })?;
1271 #[expect(
1272 clippy::as_conversions,
1273 clippy::cast_precision_loss,
1274 reason = "matches draw_route_with_progress: pixel coordinates never approach 2^23"
1275 )]
1276 pixel_waypoints.push((x as f32, y as f32));
1277 }
1278 map.draw_pixel_waypoint_route(&pixel_waypoints, color)
1279 .map_err(|err| Error::BadRequest(format!("route rasterisation failed: {err}")))?;
1280 let image = encode_map(&map, OutputFormat::Png)?;
1283 Ok(([(axum::http::header::CONTENT_TYPE, "image/png")], image).into_response())
1284}
1285
1286#[derive(Debug, Clone, Deserialize)]
1288pub struct RegionOverlayPreviewRequest {
1289 pub lower_left_x: u16,
1291 pub lower_left_y: u16,
1293 pub upper_right_x: u16,
1295 pub upper_right_y: u16,
1297 pub max_width: u32,
1301 pub max_height: u32,
1303 #[serde(default)]
1305 pub draw_region_rectangles: bool,
1306 #[serde(default)]
1308 pub draw_region_names: bool,
1309 #[serde(default)]
1311 pub draw_region_coordinates: bool,
1312 #[serde(default)]
1314 pub region_label_font_id: Option<String>,
1315 #[serde(default)]
1320 pub missing_region_color: Option<String>,
1321}
1322
1323pub async fn region_overlay_preview(
1345 _user: CurrentUser,
1346 State(state): State<AppState>,
1347 Json(req): Json<RegionOverlayPreviewRequest>,
1348) -> Result<axum::response::Response, Error> {
1349 use axum::response::IntoResponse as _;
1350 use futures::StreamExt as _;
1351 validate_dimensions(req.max_width, req.max_height)?;
1352 let rect = GridRectangle::new(
1353 GridCoordinates::new(req.lower_left_x, req.lower_left_y),
1354 GridCoordinates::new(req.upper_right_x, req.upper_right_y),
1355 );
1356 let opts = RegionOverlayOptions {
1357 rectangles: req.draw_region_rectangles,
1358 names: req.draw_region_names,
1359 coordinates: req.draw_region_coordinates,
1360 };
1361 assert_region_overlay_font(&state, opts, req.region_label_font_id.as_deref())?;
1364 let font_id = req.region_label_font_id.clone();
1365 let missing_region_color = req
1366 .missing_region_color
1367 .as_deref()
1368 .map(parse_color)
1369 .transpose()?;
1370 let (max_width, max_height) = (req.max_width, req.max_height);
1371
1372 let (line_tx, line_rx) = tokio::sync::mpsc::channel::<Bytes>(64);
1376 drop(tokio::spawn(run_region_overlay_preview_stream(
1377 state,
1378 rect,
1379 opts,
1380 font_id,
1381 missing_region_color,
1382 max_width,
1383 max_height,
1384 line_tx,
1385 )));
1386 let stream = ReceiverStream::new(line_rx).map(Ok::<Bytes, std::convert::Infallible>);
1387 let body = axum::body::Body::from_stream(stream);
1388 Ok((
1389 [(axum::http::header::CONTENT_TYPE, "application/x-ndjson")],
1390 body,
1391 )
1392 .into_response())
1393}
1394
1395#[expect(
1400 clippy::too_many_arguments,
1401 reason = "a one-shot helper carrying the preview request fields into the streaming task"
1402)]
1403async fn run_region_overlay_preview_stream(
1404 state: AppState,
1405 rect: GridRectangle,
1406 opts: RegionOverlayOptions,
1407 font_id: Option<String>,
1408 missing_region_color: Option<Rgba<u8>>,
1409 max_width: u32,
1410 max_height: u32,
1411 line_tx: tokio::sync::mpsc::Sender<Bytes>,
1412) {
1413 let (ev_tx, ev_rx) = tokio::sync::mpsc::channel::<MapProgressEvent>(256);
1414 let forwarder = tokio::spawn(forward_region_overlay_progress(ev_rx, line_tx.clone()));
1415 let result = async {
1416 let mut map = Map::blank_fit(rect, max_width, max_height)?;
1417 apply_region_overlay(
1418 &state,
1419 opts,
1420 font_id.as_deref(),
1421 missing_region_color,
1422 &mut map,
1423 Some(&ev_tx),
1424 )
1425 .await?;
1426 encode_map(&map, OutputFormat::Png)
1427 }
1428 .await;
1429 drop(ev_tx);
1430 let _join = forwarder.await;
1431 let final_line = match result {
1432 Ok(png) => {
1433 use base64::Engine as _;
1434 let data = base64::engine::general_purpose::STANDARD.encode(&png);
1435 serde_json::json!({ "type": "image", "data": data }).to_string()
1436 }
1437 Err(err) => serde_json::to_string(&ProgressDto::Error {
1438 message: err.to_string(),
1439 })
1440 .unwrap_or_else(|_| {
1441 r#"{"type":"error","message":"region overlay preview failed"}"#.to_owned()
1442 }),
1443 };
1444 drop(line_tx.send(Bytes::from(format!("{final_line}\n"))).await);
1445}
1446
1447async fn forward_region_overlay_progress(
1451 mut ev_rx: tokio::sync::mpsc::Receiver<MapProgressEvent>,
1452 line_tx: tokio::sync::mpsc::Sender<Bytes>,
1453) {
1454 while let Some(event) = ev_rx.recv().await {
1455 let Ok(json) = serde_json::to_string(&ProgressDto::from(event)) else {
1456 continue;
1457 };
1458 if line_tx
1459 .send(Bytes::from(format!("{json}\n")))
1460 .await
1461 .is_err()
1462 {
1463 break;
1464 }
1465 }
1466}
1467
1468#[expect(
1474 clippy::too_many_arguments,
1475 reason = "a one-shot helper shared by the two placement-preview endpoints"
1476)]
1477async fn render_placement_elements_png(
1478 state: &AppState,
1479 occupancy: &Map,
1480 target_rect: GridRectangle,
1481 max_width: u32,
1482 max_height: u32,
1483 legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
1484 labels: &[TextLabel],
1485 logos: &[LogoPlacement],
1486) -> Result<Bytes, Error> {
1487 let (label_draws, label_slots) =
1488 plan_labels(&state.fonts, labels, legend_slot, &[], occupancy)?;
1489 let (logo_draws, _) = plan_logos(state, logos, legend_slot, &label_slots, occupancy).await?;
1490 let mut target = Map::blank_fit(target_rect, max_width, max_height)?;
1491 execute_labels(&label_draws, &mut target);
1492 execute_logos(&logo_draws, &mut target);
1493 encode_map(&target, OutputFormat::Png)
1494}
1495
1496pub async fn placement_preview_grid_rectangle(
1508 user: CurrentUser,
1509 State(state): State<AppState>,
1510 Json(req): Json<GridRectangleRequest>,
1511) -> Result<axum::response::Response, Error> {
1512 use axum::response::IntoResponse as _;
1513 validate_dimensions(req.max_width, req.max_height)?;
1514 assert_can_read_logos(&state, user.user_id, &req.logos).await?;
1518 let rect = GridRectangle::new(
1519 GridCoordinates::new(req.lower_left_x, req.lower_left_y),
1520 GridCoordinates::new(req.upper_right_x, req.upper_right_y),
1521 );
1522 let mut occ = Map::blank_fit(rect.clone(), req.max_width, req.max_height)?;
1523 let legend_slot = if let Some(glw) = req.glw.as_ref() {
1524 apply_glw_overlay_readonly(&state, user.user_id, glw, &mut occ).await?;
1525 legend_position_from_slot(glw.legend_slot.as_deref())?
1526 } else {
1527 None
1528 };
1529 let png = render_placement_elements_png(
1530 &state,
1531 &occ,
1532 rect,
1533 req.max_width,
1534 req.max_height,
1535 legend_slot,
1536 &req.labels,
1537 &req.logos,
1538 )
1539 .await?;
1540 Ok(([(axum::http::header::CONTENT_TYPE, "image/png")], png).into_response())
1541}
1542
1543pub async fn placement_preview_usb_notecard(
1554 user: CurrentUser,
1555 State(state): State<AppState>,
1556 multipart: Multipart,
1557) -> Result<axum::response::Response, Error> {
1558 use axum::response::IntoResponse as _;
1559 let parsed = parse_render_form(multipart).await?;
1560 assert_can_read_logos(&state, user.user_id, &parsed.logos).await?;
1564 let notecard: USBNotecard = match &parsed.notecard_source {
1565 NotecardSource::Fresh { text, .. } => text.parse()?,
1566 NotecardSource::Existing { notecard_id } => {
1567 let row =
1568 crate::library::assert_can_read_notecard(&state.db, user.user_id, *notecard_id)
1569 .await?;
1570 row.body.parse()?
1571 }
1572 };
1573 let (border_north, border_south, border_east, border_west) = parsed.borders;
1574 let rect = {
1575 let mut region = state.region_cache.lock().await;
1576 usb_notecard_to_grid_rectangle(&mut region, ¬ecard).await?
1577 }
1578 .expanded_west(border_west)
1579 .expanded_east(border_east)
1580 .expanded_south(border_south)
1581 .expanded_north(border_north);
1582 let mut occ = Map::blank_fit(
1585 rect.clone(),
1586 parsed.common.max_width,
1587 parsed.common.max_height,
1588 )?;
1589 let legend_slot = if let Some(glw) = parsed.glw.as_ref() {
1590 apply_glw_overlay_readonly(&state, user.user_id, glw, &mut occ).await?;
1591 legend_position_from_slot(glw.legend_slot.as_deref())?
1592 } else {
1593 None
1594 };
1595 {
1596 let mut region = state.region_cache.lock().await;
1597 occ.draw_route_with_progress(&mut region, ¬ecard, parsed.color, None)
1598 .await?;
1599 }
1600 let png = render_placement_elements_png(
1601 &state,
1602 &occ,
1603 rect,
1604 parsed.common.max_width,
1605 parsed.common.max_height,
1606 legend_slot,
1607 &parsed.labels,
1608 &parsed.logos,
1609 )
1610 .await?;
1611 Ok(([(axum::http::header::CONTENT_TYPE, "image/png")], png).into_response())
1612}
1613
1614const fn format_name(format: OutputFormat) -> &'static str {
1617 match format {
1618 OutputFormat::Png => "png",
1619 OutputFormat::Jpeg => "jpeg",
1620 }
1621}
1622
1623fn hex_from_rgba(rgba: Rgba<u8>) -> String {
1626 let Rgba(parts) = rgba;
1627 let r = parts.first().copied().unwrap_or(0);
1628 let g = parts.get(1).copied().unwrap_or(0);
1629 let b = parts.get(2).copied().unwrap_or(0);
1630 format!("#{r:02x}{g:02x}{b:02x}")
1631}
1632
1633fn parse_color(s: &str) -> Result<Rgba<u8>, Error> {
1635 let parsed = hex_color::HexColor::parse(s)
1636 .map_err(|e| Error::BadRequest(format!("invalid colour `{s}`: {e}")))?;
1637 Ok(Rgba(parsed.to_be_bytes()))
1638}
1639
1640struct ParsedRenderForm {
1642 notecard_source: NotecardSource,
1645 destination: Destination,
1647 borders: (u16, u16, u16, u16),
1649 color: Rgba<u8>,
1651 common: CommonParams,
1653 with_without_route: bool,
1655 glw: Option<GlwRenderOptions>,
1660 labels: Vec<TextLabel>,
1663 logos: Vec<LogoPlacement>,
1666 groups: Vec<Vec<String>>,
1670}
1671
1672enum NotecardSource {
1674 Existing {
1676 notecard_id: Uuid,
1678 },
1679 Fresh {
1683 text: String,
1685 name: String,
1687 },
1688}
1689
1690async fn parse_render_form(multipart: Multipart) -> Result<ParsedRenderForm, Error> {
1692 let mut form = crate::usb_notecard::NotecardForm::default();
1693 let mut color: Rgba<u8> = Rgba([0xff, 0x00, 0x00, 0xff]);
1694 let mut max_width: Option<u32> = None;
1695 let mut max_height: Option<u32> = None;
1696 let mut missing_map_tile_color: Option<Rgba<u8>> = None;
1697 let mut missing_region_color: Option<Rgba<u8>> = None;
1698 let mut format = OutputFormat::default();
1699 let mut with_without_route = false;
1700 let mut draw_region_rectangles = false;
1701 let mut draw_region_names = false;
1702 let mut draw_region_coordinates = false;
1703 let mut region_label_font_id: Option<String> = None;
1704 let mut notecard_text: Option<String> = None;
1705 let mut notecard_file: Option<String> = None;
1706 let mut notecard_id: Option<Uuid> = None;
1707 let mut destination_raw: Option<String> = None;
1708 let mut notecard_name: Option<String> = None;
1709 let mut glw: Option<GlwRenderOptions> = None;
1710 let mut labels: Vec<TextLabel> = Vec::new();
1711 let mut logos: Vec<LogoPlacement> = Vec::new();
1712 let mut groups: Vec<Vec<String>> = Vec::new();
1713 let mut multipart = multipart;
1714 while let Some(field) = multipart.next_field().await? {
1715 let Some(name) = field.name().map(str::to_owned) else {
1716 continue;
1717 };
1718 match name.as_str() {
1719 "notecard" => {
1720 let bytes = field.bytes().await?;
1721 if !bytes.is_empty() {
1722 let text = String::from_utf8(bytes.to_vec())
1723 .map_err(|e| Error::BadRequest(format!("notecard is not UTF-8: {e}")))?;
1724 notecard_file = Some(text);
1725 }
1726 }
1727 "notecard_text" => {
1728 let text = field.text().await?;
1729 if !text.trim().is_empty() {
1730 notecard_text = Some(text);
1731 }
1732 }
1733 "notecard_id" => {
1734 let raw = field.text().await?;
1735 let trimmed = raw.trim();
1736 if !trimmed.is_empty() {
1737 notecard_id = Some(
1738 Uuid::parse_str(trimmed)
1739 .map_err(|e| Error::BadRequest(format!("invalid notecard_id: {e}")))?,
1740 );
1741 }
1742 }
1743 "border_regions" => form.border_regions = parse_optional_u16(&field.text().await?)?,
1744 "border_north" => form.border_north = parse_optional_u16(&field.text().await?)?,
1745 "border_south" => form.border_south = parse_optional_u16(&field.text().await?)?,
1746 "border_east" => form.border_east = parse_optional_u16(&field.text().await?)?,
1747 "border_west" => form.border_west = parse_optional_u16(&field.text().await?)?,
1748 "color" => {
1749 let raw = field.text().await?;
1750 color = parse_color(raw.trim())?;
1751 }
1752 "max_width" => max_width = Some(parse_u32(&field.text().await?)?),
1753 "max_height" => max_height = Some(parse_u32(&field.text().await?)?),
1754 "missing_map_tile_color" => {
1755 let raw = field.text().await?;
1756 let trimmed = raw.trim();
1757 if !trimmed.is_empty() {
1758 missing_map_tile_color = Some(parse_color(trimmed)?);
1759 }
1760 }
1761 "missing_region_color" => {
1762 let raw = field.text().await?;
1763 let trimmed = raw.trim();
1764 if !trimmed.is_empty() {
1765 missing_region_color = Some(parse_color(trimmed)?);
1766 }
1767 }
1768 "format" => {
1769 let raw = field.text().await?;
1770 format = match raw.trim().to_ascii_lowercase().as_str() {
1771 "png" => OutputFormat::Png,
1772 "jpeg" | "jpg" => OutputFormat::Jpeg,
1773 other => {
1774 return Err(Error::BadRequest(format!("unknown format `{other}`")));
1775 }
1776 };
1777 }
1778 "save_without_route" => {
1779 let raw = field.text().await?;
1780 with_without_route = parse_form_bool(&raw);
1781 }
1782 "draw_region_rectangles" => {
1783 let raw = field.text().await?;
1784 draw_region_rectangles = parse_form_bool(&raw);
1785 }
1786 "draw_region_names" => {
1787 let raw = field.text().await?;
1788 draw_region_names = parse_form_bool(&raw);
1789 }
1790 "draw_region_coordinates" => {
1791 let raw = field.text().await?;
1792 draw_region_coordinates = parse_form_bool(&raw);
1793 }
1794 "region_label_font_id" => {
1795 let raw = field.text().await?;
1796 let trimmed = raw.trim();
1797 if !trimmed.is_empty() {
1798 region_label_font_id = Some(trimmed.to_owned());
1799 }
1800 }
1801 "save_to" => destination_raw = Some(field.text().await?),
1802 "notecard_name" => {
1803 let t = field.text().await?;
1804 if !t.trim().is_empty() {
1805 notecard_name = Some(t);
1806 }
1807 }
1808 "glw_json" => {
1809 let raw = field.text().await?;
1810 if !raw.trim().is_empty() {
1811 glw = Some(
1812 serde_json::from_str::<GlwRenderOptions>(&raw)
1813 .map_err(|e| Error::BadRequest(format!("invalid glw_json: {e}")))?,
1814 );
1815 }
1816 }
1817 "labels_json" => {
1818 let raw = field.text().await?;
1819 if !raw.trim().is_empty() {
1820 labels = serde_json::from_str::<Vec<TextLabel>>(&raw)
1821 .map_err(|e| Error::BadRequest(format!("invalid labels_json: {e}")))?;
1822 }
1823 }
1824 "logos_json" => {
1825 let raw = field.text().await?;
1826 if !raw.trim().is_empty() {
1827 logos = serde_json::from_str::<Vec<LogoPlacement>>(&raw)
1828 .map_err(|e| Error::BadRequest(format!("invalid logos_json: {e}")))?;
1829 }
1830 }
1831 "groups_json" => {
1832 let raw = field.text().await?;
1833 if !raw.trim().is_empty() {
1834 groups = serde_json::from_str::<Vec<Vec<String>>>(&raw)
1835 .map_err(|e| Error::BadRequest(format!("invalid groups_json: {e}")))?;
1836 }
1837 }
1838 _ => {}
1839 }
1840 }
1841 let destination = Destination::parse(destination_raw.as_deref().unwrap_or("personal"))?;
1842 let max_width = max_width.ok_or_else(|| Error::BadRequest("missing max_width".to_owned()))?;
1843 let max_height =
1844 max_height.ok_or_else(|| Error::BadRequest("missing max_height".to_owned()))?;
1845 validate_dimensions(max_width, max_height)?;
1846 let borders = form.borders();
1847 let common = CommonParams {
1848 max_width,
1849 max_height,
1850 missing_map_tile_color,
1851 missing_region_color,
1852 format,
1853 region_overlay: RegionOverlayOptions {
1854 rectangles: draw_region_rectangles,
1855 names: draw_region_names,
1856 coordinates: draw_region_coordinates,
1857 },
1858 region_label_font_id,
1859 };
1860 let notecard_source = if let Some(id) = notecard_id {
1861 if notecard_file.is_some() || notecard_text.is_some() {
1862 return Err(Error::BadRequest(
1863 "supply either `notecard_id` or notecard text/file, not both".to_owned(),
1864 ));
1865 }
1866 NotecardSource::Existing { notecard_id: id }
1867 } else {
1868 let raw = notecard_file.or(notecard_text).ok_or_else(|| {
1869 Error::BadRequest(
1870 "supply `notecard_id`, `notecard` file upload, or `notecard_text`".to_owned(),
1871 )
1872 })?;
1873 let name = notecard_name.map_or_else(default_notecard_name, |n| n.trim().to_owned());
1874 NotecardSource::Fresh { text: raw, name }
1875 };
1876 Ok(ParsedRenderForm {
1877 notecard_source,
1878 destination,
1879 borders,
1880 color,
1881 common,
1882 with_without_route,
1883 glw,
1884 labels,
1885 logos,
1886 groups,
1887 })
1888}
1889
1890fn default_notecard_name() -> String {
1892 let now = Utc::now();
1893 format!("Uploaded {}", now.format("%Y-%m-%d %H:%M:%S UTC"))
1894}
1895
1896async fn resolve_notecard(
1905 state: &AppState,
1906 user: &CurrentUser,
1907 parsed: &ParsedRenderForm,
1908) -> Result<(USBNotecard, NotecardSummary), Error> {
1909 match &parsed.notecard_source {
1910 NotecardSource::Existing { notecard_id } => {
1911 let row =
1912 library::assert_can_read_notecard(&state.db, user.user_id, *notecard_id).await?;
1913 let parsed_notecard: USBNotecard = row.body.parse()?;
1914 let notecard_scope = library::destination_from_columns(
1915 row.owner_user_id.clone(),
1916 row.owner_group_id.clone(),
1917 )?;
1918 let effective_id = if notecard_scope == parsed.destination {
1919 *notecard_id
1920 } else {
1921 notecard_routes::insert_notecard_row(
1922 state,
1923 parsed.destination,
1924 user.user_id,
1925 &row.name,
1926 &row.body,
1927 )
1928 .await?
1929 };
1930 Ok((
1931 parsed_notecard,
1932 NotecardSummary {
1933 notecard_id: effective_id,
1934 name: row.name,
1935 scope: parsed.destination.render_string(),
1936 },
1937 ))
1938 }
1939 NotecardSource::Fresh { text, name, .. } => {
1940 let parsed_notecard: USBNotecard = text.parse()?;
1941 let notecard_id = notecard_routes::insert_notecard_row(
1942 state,
1943 parsed.destination,
1944 user.user_id,
1945 name,
1946 text,
1947 )
1948 .await?;
1949 Ok((
1950 parsed_notecard,
1951 NotecardSummary {
1952 notecard_id,
1953 name: name.clone(),
1954 scope: parsed.destination.render_string(),
1955 },
1956 ))
1957 }
1958 }
1959}
1960
1961#[expect(
1967 clippy::too_many_arguments,
1968 reason = "every argument is a distinct row column; bundling them into a struct would just be noise at the single call sites"
1969)]
1970async fn insert_render_row(
1971 state: &AppState,
1972 render_id: Uuid,
1973 destination: Destination,
1974 created_by: Uuid,
1975 kind: &str,
1976 notecard_id: Option<Uuid>,
1977 settings: &SavedRenderSettings,
1978 bounds: Option<&GridRectangle>,
1979) -> Result<(), Error> {
1980 let (owner_user, owner_group) = match destination {
1981 Destination::Personal => (Some(created_by.as_bytes().to_vec()), None),
1982 Destination::Group { group_id } => (None, Some(group_id.as_bytes().to_vec())),
1983 };
1984 let now = Utc::now();
1985 let settings_json = serde_json::to_string(settings)?;
1986 let (ll_x, ll_y, ur_x, ur_y) = match bounds {
1987 Some(rect) => (
1988 Some(i64::from(rect.lower_left_corner().x())),
1989 Some(i64::from(rect.lower_left_corner().y())),
1990 Some(i64::from(rect.upper_right_corner().x())),
1991 Some(i64::from(rect.upper_right_corner().y())),
1992 ),
1993 None => (None, None, None, None),
1994 };
1995 sqlx::query(
1996 "INSERT INTO saved_renders \
1997 (render_id, owner_user_id, owner_group_id, created_by, notecard_id, kind, \
1998 status, settings_json, created_at, \
1999 lower_left_x, lower_left_y, upper_right_x, upper_right_y) \
2000 VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'in_progress', ?7, ?8, ?9, ?10, ?11, ?12)",
2001 )
2002 .bind(render_id.as_bytes().to_vec())
2003 .bind(owner_user)
2004 .bind(owner_group)
2005 .bind(created_by.as_bytes().to_vec())
2006 .bind(notecard_id.map(|id| id.as_bytes().to_vec()))
2007 .bind(kind)
2008 .bind(settings_json)
2009 .bind(now)
2010 .bind(ll_x)
2011 .bind(ll_y)
2012 .bind(ur_x)
2013 .bind(ur_y)
2014 .execute(&state.db)
2015 .await
2016 .map_err(|err| {
2017 tracing::error!("insert saved_renders failed: {err}");
2018 Error::Database
2019 })?;
2020 Ok(())
2021}
2022
2023async fn update_notecard_bounds(
2033 db: &sqlx::SqlitePool,
2034 notecard_id: Uuid,
2035 rect: &GridRectangle,
2036) -> Result<(), Error> {
2037 sqlx::query(
2038 "UPDATE saved_notecards \
2039 SET lower_left_x = ?1, lower_left_y = ?2, \
2040 upper_right_x = ?3, upper_right_y = ?4 \
2041 WHERE notecard_id = ?5",
2042 )
2043 .bind(i64::from(rect.lower_left_corner().x()))
2044 .bind(i64::from(rect.lower_left_corner().y()))
2045 .bind(i64::from(rect.upper_right_corner().x()))
2046 .bind(i64::from(rect.upper_right_corner().y()))
2047 .bind(notecard_id.as_bytes().to_vec())
2048 .execute(db)
2049 .await
2050 .map_err(|err| {
2051 tracing::error!("update saved_notecards bounds failed: {err}");
2052 Error::Database
2053 })?;
2054 Ok(())
2055}
2056
2057async fn update_render_bounds(
2063 db: &sqlx::SqlitePool,
2064 render_id: Uuid,
2065 rect: &GridRectangle,
2066) -> Result<(), Error> {
2067 sqlx::query(
2068 "UPDATE saved_renders \
2069 SET lower_left_x = ?1, lower_left_y = ?2, \
2070 upper_right_x = ?3, upper_right_y = ?4 \
2071 WHERE render_id = ?5",
2072 )
2073 .bind(i64::from(rect.lower_left_corner().x()))
2074 .bind(i64::from(rect.lower_left_corner().y()))
2075 .bind(i64::from(rect.upper_right_corner().x()))
2076 .bind(i64::from(rect.upper_right_corner().y()))
2077 .bind(render_id.as_bytes().to_vec())
2078 .execute(db)
2079 .await
2080 .map_err(|err| {
2081 tracing::error!("update saved_renders bounds failed: {err}");
2082 Error::Database
2083 })?;
2084 Ok(())
2085}
2086
2087async fn finalize_render_row(state: &AppState, render_id: Uuid, outcome: &JobOutcome) {
2091 let now = Utc::now();
2092 match outcome {
2093 JobOutcome::Ok {
2094 image,
2095 image_without_route,
2096 content_type,
2097 metadata,
2098 glw_data_id,
2099 } => {
2100 let ext = storage::ext_for_content_type(content_type);
2101 let image_filename = match storage::write_render_file(
2102 &state.config.storage_dir,
2103 render_id,
2104 storage::IMAGE_SUFFIX,
2105 ext,
2106 image.clone(),
2107 )
2108 .await
2109 {
2110 Ok(f) => Some(f),
2111 Err(err) => {
2112 tracing::error!("write primary render image failed: {err}");
2113 update_failed(state, render_id, &format!("write image failed: {err}"), now)
2114 .await;
2115 return;
2116 }
2117 };
2118 let without_filename = if let Some(bytes) = image_without_route {
2119 match storage::write_render_file(
2120 &state.config.storage_dir,
2121 render_id,
2122 storage::IMAGE_WITHOUT_ROUTE_SUFFIX,
2123 ext,
2124 bytes.clone(),
2125 )
2126 .await
2127 {
2128 Ok(f) => Some(f),
2129 Err(err) => {
2130 tracing::warn!("write without-route render image failed: {err}");
2131 None
2132 }
2133 }
2134 } else {
2135 None
2136 };
2137 let metadata_json = match serde_json::to_string(metadata) {
2138 Ok(s) => s,
2139 Err(err) => {
2140 tracing::error!("serialize metadata failed: {err}");
2141 update_failed(
2142 state,
2143 render_id,
2144 &format!("serialize metadata failed: {err}"),
2145 now,
2146 )
2147 .await;
2148 return;
2149 }
2150 };
2151 let rewritten_settings_json = match glw_data_id {
2157 Some(id) => match rewrite_settings_json_with_saved_glw(state, render_id, *id).await
2158 {
2159 Ok(updated) => updated,
2160 Err(err) => {
2161 tracing::error!("could not rewrite settings_json with glw_data_id: {err}");
2162 None
2163 }
2164 },
2165 None => None,
2166 };
2167 let result = sqlx::query(
2168 "UPDATE saved_renders SET status = 'done', finished_at = ?1, \
2169 metadata_json = ?2, content_type = ?3, \
2170 image_filename = ?4, image_without_route_filename = ?5, \
2171 glw_data_id = COALESCE(?7, glw_data_id), \
2172 settings_json = COALESCE(?8, settings_json) \
2173 WHERE render_id = ?6",
2174 )
2175 .bind(now)
2176 .bind(metadata_json)
2177 .bind(*content_type)
2178 .bind(image_filename)
2179 .bind(without_filename)
2180 .bind(render_id.as_bytes().to_vec())
2181 .bind(glw_data_id.map(|id| id.as_bytes().to_vec()))
2182 .bind(rewritten_settings_json)
2183 .execute(&state.db)
2184 .await;
2185 if let Err(err) = result {
2186 tracing::error!("update saved_renders to done failed: {err}");
2187 state.library_cleanup_dirty.store(true, Ordering::Release);
2188 }
2189 }
2190 JobOutcome::Err(msg) => {
2191 update_failed(state, render_id, msg, now).await;
2192 }
2193 }
2194}
2195
2196async fn update_failed(
2198 state: &AppState,
2199 render_id: Uuid,
2200 message: &str,
2201 now: chrono::DateTime<Utc>,
2202) {
2203 if let Err(err) = sqlx::query(
2204 "UPDATE saved_renders SET status = 'failed', finished_at = ?1, error_message = ?2 \
2205 WHERE render_id = ?3",
2206 )
2207 .bind(now)
2208 .bind(message)
2209 .bind(render_id.as_bytes().to_vec())
2210 .execute(&state.db)
2211 .await
2212 {
2213 tracing::error!("update saved_renders to failed failed: {err}");
2214 }
2215}
2216
2217#[expect(
2219 clippy::too_many_arguments,
2220 reason = "this is a one-shot helper that wires together every form field"
2221)]
2222fn spawn_grid_rectangle_job(
2223 state: AppState,
2224 job_id: JobId,
2225 job: Arc<JobState>,
2226 rect: GridRectangle,
2227 common: CommonParams,
2228 glw_ctx: Option<GlwJobCtx>,
2229 labels: Vec<TextLabel>,
2230 logos: Vec<LogoPlacement>,
2231) {
2232 drop(tokio::spawn(async move {
2233 let result = run_grid_rectangle_job(
2234 state.clone(),
2235 Arc::clone(&job),
2236 rect,
2237 common,
2238 glw_ctx,
2239 labels,
2240 logos,
2241 )
2242 .await;
2243 let outcome = finish_job(&job, result).await;
2244 finalize_render_row(&state, job_id, &outcome).await;
2245 tracing::info!("render job {job_id} finished");
2246 }));
2247}
2248
2249#[expect(
2251 clippy::too_many_arguments,
2252 reason = "this is a one-shot helper that wires together every form field"
2253)]
2254fn spawn_usb_notecard_job(
2255 state: AppState,
2256 job_id: JobId,
2257 job: Arc<JobState>,
2258 notecard_id: Uuid,
2259 notecard: USBNotecard,
2260 borders: (u16, u16, u16, u16),
2261 route_color: Rgba<u8>,
2262 common: CommonParams,
2263 with_without_route: bool,
2264 glw_ctx: Option<GlwJobCtx>,
2265 labels: Vec<TextLabel>,
2266 logos: Vec<LogoPlacement>,
2267) {
2268 drop(tokio::spawn(async move {
2269 let result = run_usb_notecard_job(
2270 state.clone(),
2271 Arc::clone(&job),
2272 job_id,
2273 notecard_id,
2274 notecard,
2275 borders,
2276 route_color,
2277 common,
2278 with_without_route,
2279 glw_ctx,
2280 labels,
2281 logos,
2282 )
2283 .await;
2284 let outcome = finish_job(&job, result).await;
2285 finalize_render_row(&state, job_id, &outcome).await;
2286 tracing::info!("render job {job_id} finished");
2287 }));
2288}
2289
2290fn assert_region_overlay_font(
2301 state: &AppState,
2302 opts: RegionOverlayOptions,
2303 font_id: Option<&str>,
2304) -> Result<(), Error> {
2305 if !opts.any_text() {
2306 return Ok(());
2307 }
2308 let font_id = font_id.ok_or_else(|| {
2309 Error::BadRequest("select a font for the region name / coordinate overlay".to_owned())
2310 })?;
2311 if state.fonts.path_for(font_id).is_none() {
2312 return Err(Error::BadRequest(format!(
2313 "unknown region label font_id `{font_id}`"
2314 )));
2315 }
2316 Ok(())
2317}
2318
2319const fn region_text_overlay_allowed(pixels_per_region: f32, region_count: usize) -> bool {
2325 pixels_per_region >= MIN_PIXELS_PER_REGION_FOR_REGION_LABELS
2326 && region_count <= MAX_REGIONS_FOR_REGION_LABELS
2327}
2328
2329async fn apply_region_overlay(
2340 state: &AppState,
2341 opts: RegionOverlayOptions,
2342 font_id: Option<&str>,
2343 missing_region_color: Option<Rgba<u8>>,
2344 map: &mut Map,
2345 progress: Option<&tokio::sync::mpsc::Sender<MapProgressEvent>>,
2346) -> Result<(), Error> {
2347 use sl_map_apis::map_tiles::MapLike as _;
2348 use sl_types::map::GridRectangleLike as _;
2349 if !opts.any() {
2350 return Ok(());
2351 }
2352 let pixels_per_region = map.pixels_per_region();
2357 let region_count = usize::from(map.size_x()).saturating_mul(usize::from(map.size_y()));
2358 let run_loop = opts.any_text() && region_text_overlay_allowed(pixels_per_region, region_count);
2359 if !run_loop {
2360 if opts.any_text() {
2361 tracing::info!(
2362 pixels_per_region,
2363 region_count,
2364 "skipping region name/coordinate overlay (regions too small or too many)"
2365 );
2366 }
2367 if opts.rectangles {
2369 draw_region_rectangles(map);
2370 }
2371 return Ok(());
2372 }
2373 let font_id = font_id.ok_or_else(|| {
2374 Error::BadRequest("select a font for the region name / coordinate overlay".to_owned())
2375 })?;
2376 let font_path = state
2377 .fonts
2378 .path_for(font_id)
2379 .ok_or_else(|| Error::BadRequest(format!("unknown region label font_id `{font_id}`")))?;
2380 let font = sl_map_apis::text::load_font(font_path)?;
2381 let scale = ab_glyph::PxScale::from(
2382 (pixels_per_region * REGION_LABEL_FONT_FACTOR)
2383 .clamp(REGION_LABEL_FONT_MIN_PX, REGION_LABEL_FONT_MAX_PX),
2384 );
2385 let style = sl_map_apis::text::LabelStyle {
2386 scale,
2387 fg: Rgba([255, 255, 255, 255]),
2388 shadow: Rgba([0, 0, 0, 180]),
2389 align: sl_map_apis::coverage::HAlign::Left,
2390 };
2391 let total_regions = u32::try_from(region_count).unwrap_or(u32::MAX);
2395 if let Some(tx) = progress.filter(|_| opts.names) {
2396 drop(tx.try_send(MapProgressEvent::RegionNamesPlanned { total_regions }));
2397 }
2398 let mut resolved: u32 = 0;
2399 for x in map.x_range() {
2400 for y in map.y_range() {
2401 let grid = GridCoordinates::new(x, y);
2402 let Some((left, top, width, height)) = region_pixel_rect(map, &grid) else {
2403 continue;
2404 };
2405 let mut region_name: Option<String> = None;
2410 let mut missing = false;
2411 if opts.names {
2412 let lookup = {
2413 let mut region = state.region_cache.lock().await;
2414 region.get_region_name(&grid).await
2415 };
2416 match lookup {
2417 Ok(Some(name)) => region_name = Some(name.to_string()),
2418 Ok(None) => missing = true,
2419 Err(err) => {
2420 tracing::debug!("region name lookup failed for {grid:?}: {err}");
2421 }
2422 }
2423 if let Some(tx) = progress {
2424 drop(tx.try_send(MapProgressEvent::RegionNameResolved {
2425 index: resolved,
2426 total: total_regions,
2427 }));
2428 }
2429 resolved = resolved.saturating_add(1);
2430 }
2431 if let Some(color) = missing_region_color.filter(|_| missing) {
2434 map.draw_filled_rect(left, top, width, height, color);
2435 }
2436 if opts.rectangles {
2437 map.draw_hollow_rect(left, top, width, height, REGION_RECTANGLE_COLOR);
2438 }
2439 let mut lines: Vec<String> = Vec::new();
2440 if opts.coordinates {
2441 lines.push(format!("({x}, {y})"));
2442 }
2443 if let Some(name) = region_name {
2444 lines.push(name);
2445 }
2446 if lines.is_empty() {
2447 continue;
2448 }
2449 let (_text_w, text_h) = sl_map_apis::text::measure_text(scale, &font, &lines);
2450 let bottom = top.saturating_add(height);
2453 let origin_x = i32::try_from(left)
2454 .unwrap_or(0)
2455 .saturating_add(REGION_LABEL_PADDING);
2456 let origin_y = i32::try_from(bottom)
2457 .unwrap_or(0)
2458 .saturating_sub(REGION_LABEL_PADDING)
2459 .saturating_sub(i32::try_from(text_h).unwrap_or(0));
2460 map.draw_text_label((origin_x, origin_y), &lines, &style, &font);
2461 }
2462 }
2463 Ok(())
2464}
2465
2466fn region_pixel_rect(map: &Map, grid: &GridCoordinates) -> Option<(u32, u32, u32, u32)> {
2471 use sl_map_apis::map_tiles::MapLike as _;
2472 let (x0, y0) =
2473 map.pixel_coordinates_for_coordinates(grid, &RegionCoordinates::new(0f32, 0f32, 0f32))?;
2474 let (x1, y1) =
2475 map.pixel_coordinates_for_coordinates(grid, &RegionCoordinates::new(256f32, 256f32, 0f32))?;
2476 Some((x0.min(x1), y0.min(y1), x0.abs_diff(x1), y0.abs_diff(y1)))
2477}
2478
2479fn draw_region_rectangles(map: &mut Map) {
2481 use sl_map_apis::map_tiles::MapLike as _;
2482 use sl_types::map::GridRectangleLike as _;
2483 for x in map.x_range() {
2484 for y in map.y_range() {
2485 let grid = GridCoordinates::new(x, y);
2486 if let Some((left, top, width, height)) = region_pixel_rect(map, &grid) {
2487 map.draw_hollow_rect(left, top, width, height, REGION_RECTANGLE_COLOR);
2488 }
2489 }
2490 }
2491}
2492
2493async fn run_grid_rectangle_job(
2495 state: AppState,
2496 job: Arc<JobState>,
2497 rect: GridRectangle,
2498 common: CommonParams,
2499 glw_ctx: Option<GlwJobCtx>,
2500 labels: Vec<TextLabel>,
2501 logos: Vec<LogoPlacement>,
2502) -> Result<JobOutcome, Error> {
2503 let metadata = build_metadata(&rect);
2504 let occ_rect = rect.clone();
2507 let (tx, rx) = tokio::sync::mpsc::channel::<MapProgressEvent>(256);
2508 let forwarder = tokio::spawn(forward_events(Arc::clone(&job), rx));
2509 let mut map = {
2510 let mut cache = state.map_tile_cache.lock().await;
2511 Map::new_with_progress(
2512 &mut cache,
2513 common.max_width,
2514 common.max_height,
2515 rect,
2516 common.missing_map_tile_color,
2517 common.missing_region_color,
2518 Some(&tx),
2519 )
2520 .await?
2521 };
2522 apply_region_overlay(
2528 &state,
2529 common.region_overlay,
2530 common.region_label_font_id.as_deref(),
2531 None,
2534 &mut map,
2535 Some(&tx),
2536 )
2537 .await?;
2538 drop(tx);
2539 let _join = forwarder.await;
2542 let glw_data_id = apply_glw_overlay_to_map(&state, glw_ctx.as_ref(), &mut map).await?;
2543 let (label_draws, logo_draws) = plan_placements(
2547 &state,
2548 occ_rect,
2549 &common,
2550 glw_ctx.as_ref(),
2551 None,
2552 &labels,
2553 &logos,
2554 )
2555 .await?;
2556 execute_labels(&label_draws, &mut map);
2557 execute_logos(&logo_draws, &mut map);
2558 let image = encode_map(&map, common.format)?;
2559 Ok(JobOutcome::Ok {
2560 image,
2561 image_without_route: None,
2562 content_type: common.format.content_type(),
2563 metadata,
2564 glw_data_id,
2565 })
2566}
2567
2568#[expect(
2570 clippy::too_many_arguments,
2571 reason = "this is a one-shot helper invoked from a single spawn site"
2572)]
2573async fn run_usb_notecard_job(
2574 state: AppState,
2575 job: Arc<JobState>,
2576 render_id: Uuid,
2577 notecard_id: Uuid,
2578 notecard: USBNotecard,
2579 borders: (u16, u16, u16, u16),
2580 route_color: Rgba<u8>,
2581 common: CommonParams,
2582 with_without_route: bool,
2583 glw_ctx: Option<GlwJobCtx>,
2584 labels: Vec<TextLabel>,
2585 logos: Vec<LogoPlacement>,
2586) -> Result<JobOutcome, Error> {
2587 let (bare_rect, rect) = resolve_notecard_rect(&state, ¬ecard, borders).await?;
2588 update_notecard_bounds(&state.db, notecard_id, &bare_rect).await?;
2592 update_render_bounds(&state.db, render_id, &rect).await?;
2595 let metadata = build_metadata(&rect);
2596 let occ_rect = rect.clone();
2599 let (tx, rx) = tokio::sync::mpsc::channel::<MapProgressEvent>(256);
2600 let forwarder = tokio::spawn(forward_events(Arc::clone(&job), rx));
2601 let (image_without_route, map, glw_data_id) = {
2602 let mut map = {
2603 let mut cache = state.map_tile_cache.lock().await;
2604 Map::new_with_progress(
2605 &mut cache,
2606 common.max_width,
2607 common.max_height,
2608 rect,
2609 common.missing_map_tile_color,
2610 common.missing_region_color,
2611 Some(&tx),
2612 )
2613 .await?
2614 };
2615 let without_route = if with_without_route {
2618 Some(encode_map(&map, common.format)?)
2619 } else {
2620 None
2621 };
2622 apply_region_overlay(
2626 &state,
2627 common.region_overlay,
2628 common.region_label_font_id.as_deref(),
2629 None,
2633 &mut map,
2634 Some(&tx),
2635 )
2636 .await?;
2637 let glw_data_id = apply_glw_overlay_to_map(&state, glw_ctx.as_ref(), &mut map).await?;
2641 {
2642 let mut region = state.region_cache.lock().await;
2643 map.draw_route_with_progress(&mut region, ¬ecard, route_color, Some(&tx))
2644 .await?;
2645 }
2646 let (label_draws, logo_draws) = plan_placements(
2652 &state,
2653 occ_rect,
2654 &common,
2655 glw_ctx.as_ref(),
2656 Some((¬ecard, route_color)),
2657 &labels,
2658 &logos,
2659 )
2660 .await?;
2661 execute_labels(&label_draws, &mut map);
2662 execute_logos(&logo_draws, &mut map);
2663 (without_route, map, glw_data_id)
2664 };
2665 drop(tx);
2666 let _join = forwarder.await;
2667 let image = encode_map(&map, common.format)?;
2668 Ok(JobOutcome::Ok {
2669 image,
2670 image_without_route,
2671 content_type: common.format.content_type(),
2672 metadata,
2673 glw_data_id,
2674 })
2675}
2676
2677async fn forward_events(job: Arc<JobState>, mut rx: tokio::sync::mpsc::Receiver<MapProgressEvent>) {
2680 while let Some(event) = rx.recv().await {
2681 record_event(&job, ProgressDto::from(event)).await;
2682 }
2683}
2684
2685fn build_metadata(rect: &GridRectangle) -> Metadata {
2688 let aspect_x = rect.size_x();
2689 let aspect_y = rect.size_y();
2690 let aspect_ratio = f32::from(aspect_x) / f32::from(aspect_y);
2691 Metadata {
2692 aspect_x,
2693 aspect_y,
2694 aspect_ratio,
2695 pps_hud_config: rect.pps_hud_config(),
2696 }
2697}
2698
2699fn encode_map(map: &Map, format: OutputFormat) -> Result<Bytes, Error> {
2701 let mut buf: Vec<u8> = Vec::new();
2702 let mut cursor = Cursor::new(&mut buf);
2703 sl_map_apis::map_tiles::MapLike::image(map).write_to(&mut cursor, format.image_format())?;
2704 Ok(Bytes::from(buf))
2705}
2706
2707async fn finish_job(job: &Arc<JobState>, result: Result<JobOutcome, Error>) -> JobOutcome {
2711 let outcome = match result {
2712 Ok(o) => o,
2713 Err(e) => {
2714 let message = format!("{e}");
2715 record_event(
2716 job,
2717 ProgressDto::Error {
2718 message: message.clone(),
2719 },
2720 )
2721 .await;
2722 JobOutcome::Err(message)
2723 }
2724 };
2725 if matches!(outcome, JobOutcome::Ok { .. }) {
2726 record_event(job, ProgressDto::Done).await;
2727 }
2728 let arc = Arc::new(outcome.clone());
2729 drop(job.outcome.send_replace(Some(arc)));
2730 outcome
2731}
2732
2733fn validate_dimensions(max_width: u32, max_height: u32) -> Result<(), Error> {
2736 if max_width == 0 || max_height == 0 {
2737 return Err(Error::BadRequest(
2738 "max_width and max_height must be greater than zero".to_owned(),
2739 ));
2740 }
2741 if max_width > MAX_OUTPUT_DIMENSION || max_height > MAX_OUTPUT_DIMENSION {
2742 return Err(Error::BadRequest(format!(
2743 "max_width and max_height must each be <= {MAX_OUTPUT_DIMENSION}"
2744 )));
2745 }
2746 if u64::from(max_width).saturating_mul(u64::from(max_height)) > MAX_OUTPUT_AREA {
2749 return Err(Error::BadRequest(format!(
2750 "max_width * max_height must be <= {MAX_OUTPUT_AREA} pixels"
2751 )));
2752 }
2753 Ok(())
2754}
2755
2756async fn assert_under_concurrent_limit(db: &sqlx::SqlitePool, user_id: Uuid) -> Result<(), Error> {
2763 let count: i64 = sqlx::query_scalar(
2764 "SELECT COUNT(*) FROM saved_renders \
2765 WHERE created_by = ?1 AND status = 'in_progress'",
2766 )
2767 .bind(user_id.as_bytes().to_vec())
2768 .fetch_one(db)
2769 .await
2770 .map_err(|err| {
2771 tracing::error!("count in-progress renders failed: {err}");
2772 Error::Database
2773 })?;
2774 if count >= MAX_CONCURRENT_RENDERS_PER_USER {
2775 return Err(Error::Forbidden(format!(
2776 "at most {MAX_CONCURRENT_RENDERS_PER_USER} renders may be in progress per user; \
2777 wait for one to finish"
2778 )));
2779 }
2780 Ok(())
2781}
2782
2783fn parse_optional_u16(s: &str) -> Result<Option<u16>, Error> {
2785 let trimmed = s.trim();
2786 if trimmed.is_empty() {
2787 return Ok(None);
2788 }
2789 trimmed
2790 .parse::<u16>()
2791 .map(Some)
2792 .map_err(|e| Error::BadRequest(format!("invalid u16 `{trimmed}`: {e}")))
2793}
2794
2795fn parse_u32(s: &str) -> Result<u32, Error> {
2797 s.trim()
2798 .parse::<u32>()
2799 .map_err(|e| Error::BadRequest(format!("invalid u32 `{s}`: {e}")))
2800}
2801
2802fn parse_form_bool(raw: &str) -> bool {
2806 matches!(
2807 raw.trim().to_ascii_lowercase().as_str(),
2808 "1" | "on" | "true" | "yes"
2809 )
2810}
2811
2812struct GlwJobCtx {
2818 options: GlwRenderOptions,
2820 destination: Destination,
2823 created_by: Uuid,
2825}
2826
2827struct ResolvedGlwEvent {
2830 event: sl_glw::GlwEvent,
2832 glw_data_id: Uuid,
2834}
2835
2836async fn resolve_glw_event(
2854 state: &AppState,
2855 ctx: &GlwJobCtx,
2856) -> Result<Option<ResolvedGlwEvent>, Error> {
2857 match &ctx.options.source {
2858 GlwSource::SavedId { glw_data_id } => {
2859 let row =
2860 crate::library::assert_can_read_glw_data(&state.db, ctx.created_by, *glw_data_id)
2861 .await?;
2862 let glw_dest = crate::library::destination_from_columns(
2867 row.owner_user_id.clone(),
2868 row.owner_group_id.clone(),
2869 )?;
2870 if glw_dest != ctx.destination {
2871 return Err(Error::BadRequest(format!(
2872 "GLW data {glw_data_id} is not in the same library as this render; \
2873 copy it into the render's scope first"
2874 )));
2875 }
2876 let event: sl_glw::GlwEvent = serde_json::from_str(&row.payload_json)?;
2877 Ok(Some(ResolvedGlwEvent {
2878 event,
2879 glw_data_id: *glw_data_id,
2880 }))
2881 }
2882 GlwSource::EventId { event_id } => {
2883 let id = sl_glw::EventId::new(*event_id);
2884 let event_opt = {
2885 let mut cache = state.glw_event_cache.lock().await;
2886 cache.get_event_by_id(id).await?
2887 };
2888 let Some(event) = event_opt else {
2889 return Ok(None);
2890 };
2891 let now = Utc::now();
2892 let name = default_or_user_name(
2893 ctx.options.save_as.as_deref(),
2894 &format!(
2895 "Event {event_id} fetched {ts}",
2896 ts = now.format("%Y-%m-%d %H:%M UTC")
2897 ),
2898 );
2899 let payload_json = serde_json::to_string(&event)?;
2900 let glw_data_id = crate::routes::glw::insert_glw_data_row(
2901 state,
2902 &crate::routes::glw::InsertGlwData {
2903 destination: ctx.destination,
2904 created_by: ctx.created_by,
2905 name: &name,
2906 source_kind: crate::library::GlwDataSourceKind::EventId,
2907 source_event_id: Some(*event_id),
2908 source_event_key: None,
2909 payload_json: &payload_json,
2910 event_id: Some(event.event_id.get()),
2911 event_key: Some(event.event_key.as_str()),
2912 event_name: Some(event.event_name.as_str()),
2913 fetched_at: now,
2914 },
2915 )
2916 .await?;
2917 Ok(Some(ResolvedGlwEvent { event, glw_data_id }))
2918 }
2919 GlwSource::EventKey { event_key } => {
2920 let key = sl_glw::GlwEventKey::new(event_key);
2921 let event_opt = {
2922 let mut cache = state.glw_event_cache.lock().await;
2923 cache.get_event_by_key(&key).await?
2924 };
2925 let Some(event) = event_opt else {
2926 return Ok(None);
2927 };
2928 let now = Utc::now();
2929 let name = default_or_user_name(
2930 ctx.options.save_as.as_deref(),
2931 &format!(
2932 "Key \"{event_key}\" fetched {ts}",
2933 ts = now.format("%Y-%m-%d %H:%M UTC")
2934 ),
2935 );
2936 let payload_json = serde_json::to_string(&event)?;
2937 let glw_data_id = crate::routes::glw::insert_glw_data_row(
2938 state,
2939 &crate::routes::glw::InsertGlwData {
2940 destination: ctx.destination,
2941 created_by: ctx.created_by,
2942 name: &name,
2943 source_kind: crate::library::GlwDataSourceKind::EventKey,
2944 source_event_id: None,
2945 source_event_key: Some(event_key.as_str()),
2946 payload_json: &payload_json,
2947 event_id: Some(event.event_id.get()),
2948 event_key: Some(event.event_key.as_str()),
2949 event_name: Some(event.event_name.as_str()),
2950 fetched_at: now,
2951 },
2952 )
2953 .await?;
2954 Ok(Some(ResolvedGlwEvent { event, glw_data_id }))
2955 }
2956 GlwSource::PastedJson { payload } => {
2957 let event: sl_glw::GlwEvent = serde_json::from_str(payload)?;
2958 let now = Utc::now();
2959 let name = default_or_user_name(
2960 ctx.options.save_as.as_deref(),
2961 &format!("Pasted JSON {}", now.format("%Y-%m-%d %H:%M UTC")),
2962 );
2963 let payload_json = serde_json::to_string(&event)?;
2964 let glw_data_id = crate::routes::glw::insert_glw_data_row(
2965 state,
2966 &crate::routes::glw::InsertGlwData {
2967 destination: ctx.destination,
2968 created_by: ctx.created_by,
2969 name: &name,
2970 source_kind: crate::library::GlwDataSourceKind::PastedJson,
2971 source_event_id: None,
2972 source_event_key: None,
2973 payload_json: &payload_json,
2974 event_id: Some(event.event_id.get()),
2975 event_key: Some(event.event_key.as_str()),
2976 event_name: Some(event.event_name.as_str()),
2977 fetched_at: now,
2978 },
2979 )
2980 .await?;
2981 Ok(Some(ResolvedGlwEvent { event, glw_data_id }))
2982 }
2983 }
2984}
2985
2986fn default_or_user_name(supplied: Option<&str>, default: &str) -> String {
2989 let trimmed = supplied.map(str::trim).filter(|s| !s.is_empty());
2990 match trimmed {
2991 Some(s) => s.to_owned(),
2992 None => default.to_owned(),
2993 }
2994}
2995
2996async fn apply_glw_overlay_to_map(
3001 state: &AppState,
3002 ctx: Option<&GlwJobCtx>,
3003 map: &mut Map,
3004) -> Result<Option<Uuid>, Error> {
3005 use sl_glw::MapLikeGlwExt as _;
3006 let Some(ctx) = ctx else {
3007 return Ok(None);
3008 };
3009 let font_path = state
3012 .fonts
3013 .path_for(&ctx.options.font_id)
3014 .ok_or_else(|| Error::BadRequest(format!("unknown font_id `{}`", ctx.options.font_id)))?;
3015 let font = sl_map_apis::text::load_font(font_path)?;
3016
3017 let resolved = resolve_glw_event(state, ctx).await?;
3018 let Some(resolved) = resolved else {
3019 tracing::warn!("GLW event not found (server returned no event); rendering without overlay");
3020 return Ok(None);
3021 };
3022 let style = build_glw_style(&ctx.options.style, ctx.options.legend_slot.as_deref())?;
3023 map.draw_glw_event_with_font(&resolved.event, &style, &font);
3024 Ok(Some(resolved.glw_data_id))
3025}
3026
3027fn build_glw_style(
3031 overrides: &GlwStyleOverrides,
3032 legend_slot: Option<&str>,
3033) -> Result<sl_glw::GlwStyle, Error> {
3034 let mut style = sl_glw::GlwStyle {
3035 legend_position: legend_position_from_slot(legend_slot)?,
3036 draw_margin_band: overrides.margin_band,
3037 ..sl_glw::GlwStyle::default()
3038 };
3039 if let Some(c) = overrides.area_outline_color.as_deref() {
3040 style.palette.area_outline = parse_color(c.trim())?;
3041 }
3042 if let Some(c) = overrides.circle_outline_color.as_deref() {
3043 style.palette.circle_outline = parse_color(c.trim())?;
3044 }
3045 if let Some(c) = overrides.margin_outline_color.as_deref() {
3046 style.palette.margin_outline = parse_color(c.trim())?;
3047 }
3048 if let Some(c) = overrides.wind_color.as_deref() {
3049 style.palette.wind_arrow = parse_color(c.trim())?;
3050 }
3051 if let Some(c) = overrides.current_color.as_deref() {
3052 style.palette.current_arrow = parse_color(c.trim())?;
3053 }
3054 if let Some(c) = overrides.wave_color.as_deref() {
3055 style.palette.wave_glyph = parse_color(c.trim())?;
3056 }
3057 if let Some(c) = overrides.label_color.as_deref() {
3058 style.palette.label_fg = parse_color(c.trim())?;
3059 }
3060 Ok(style)
3061}
3062
3063fn legend_position_from_slot(
3067 slot: Option<&str>,
3068) -> Result<Option<sl_map_apis::coverage::PlacementSlot>, Error> {
3069 use sl_map_apis::coverage::PlacementSlot;
3070 let name = match slot {
3071 None => return Ok(Some(PlacementSlot::TopLeft)),
3072 Some(s) => s.trim(),
3073 };
3074 match name {
3075 "" => Ok(Some(PlacementSlot::TopLeft)),
3076 "none" => Ok(None),
3077 other => other
3078 .parse::<PlacementSlot>()
3079 .map(Some)
3080 .map_err(|err| Error::BadRequest(err.to_string())),
3081 }
3082}
3083
3084fn legend_slot_of(ctx: Option<&GlwJobCtx>) -> Option<sl_map_apis::coverage::PlacementSlot> {
3088 let ctx = ctx?;
3089 legend_position_from_slot(ctx.options.legend_slot.as_deref())
3090 .ok()
3091 .flatten()
3092}
3093
3094fn parse_h_align(value: Option<&str>) -> Result<Option<sl_map_apis::coverage::HAlign>, Error> {
3097 use sl_map_apis::coverage::HAlign;
3098 match value.map(str::trim).filter(|s| !s.is_empty()) {
3099 None => Ok(None),
3100 Some("left") => Ok(Some(HAlign::Left)),
3101 Some("center") => Ok(Some(HAlign::Center)),
3102 Some("right") => Ok(Some(HAlign::Right)),
3103 Some(other) => Err(Error::BadRequest(format!("invalid h_align `{other}`"))),
3104 }
3105}
3106
3107fn parse_v_align(value: Option<&str>) -> Result<Option<sl_map_apis::coverage::VAlign>, Error> {
3110 use sl_map_apis::coverage::VAlign;
3111 match value.map(str::trim).filter(|s| !s.is_empty()) {
3112 None => Ok(None),
3113 Some("top") => Ok(Some(VAlign::Top)),
3114 Some("center") => Ok(Some(VAlign::Center)),
3115 Some("bottom") => Ok(Some(VAlign::Bottom)),
3116 Some(other) => Err(Error::BadRequest(format!("invalid v_align `{other}`"))),
3117 }
3118}
3119
3120fn parse_slot_group(
3125 anchor: sl_map_apis::coverage::PlacementSlot,
3126 names: &[String],
3127) -> Result<Vec<sl_map_apis::coverage::PlacementSlot>, Error> {
3128 if names.is_empty() {
3129 return Ok(vec![anchor]);
3130 }
3131 let mut group: Vec<sl_map_apis::coverage::PlacementSlot> = Vec::with_capacity(names.len());
3132 for name in names {
3133 let slot = name
3134 .parse::<sl_map_apis::coverage::PlacementSlot>()
3135 .map_err(|err: sl_map_apis::coverage::ParsePlacementSlotError| {
3136 Error::BadRequest(err.to_string())
3137 })?;
3138 if !group.contains(&slot) {
3139 group.push(slot);
3140 }
3141 }
3142 if !group.contains(&anchor) {
3143 group.push(anchor);
3144 }
3145 if !sl_map_apis::coverage::PlacementSlot::slots_form_rectangle(&group) {
3146 let joined = group
3147 .iter()
3148 .map(|s| s.as_str())
3149 .collect::<Vec<_>>()
3150 .join("+");
3151 return Err(Error::BadRequest(format!(
3152 "the combined slot group `{joined}` does not form a solid rectangle"
3153 )));
3154 }
3155 Ok(group)
3156}
3157
3158fn resolve_placement(
3164 anchor: sl_map_apis::coverage::PlacementSlot,
3165 group: &[sl_map_apis::coverage::PlacementSlot],
3166 slots: &[sl_map_apis::coverage::PlacementSlotInfo],
3167 grid: &sl_map_apis::coverage::OccupancyGrid,
3168) -> Result<
3169 (
3170 Vec<sl_map_apis::coverage::PlacementSlot>,
3171 sl_map_apis::coverage::PixelRect,
3172 ),
3173 Error,
3174> {
3175 if group.len() > 1 {
3176 let rect = grid.subset_rect(group).ok_or_else(|| {
3177 Error::BadRequest(format!(
3178 "the combined slot at `{anchor}` is fully covered; no room for content"
3179 ))
3180 })?;
3181 Ok((group.to_vec(), rect))
3182 } else {
3183 let info = slots
3184 .iter()
3185 .find(|info| info.slot == anchor)
3186 .ok_or_else(|| Error::BadRequest(format!("slot `{anchor}` not found")))?;
3187 let rect = info.free_rect.ok_or_else(|| {
3188 Error::BadRequest(format!(
3189 "slot `{anchor}` is fully covered; no room for content"
3190 ))
3191 })?;
3192 Ok((vec![anchor], rect))
3193 }
3194}
3195
3196fn reserve(
3201 reserved: &[sl_map_apis::coverage::PlacementSlot],
3202 used: &mut Vec<sl_map_apis::coverage::PlacementSlot>,
3203 others: &[sl_map_apis::coverage::PlacementSlot],
3204 legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
3205 what: &str,
3206) -> Result<(), Error> {
3207 for &slot in reserved {
3208 if legend_slot == Some(slot) {
3209 return Err(Error::BadRequest(format!(
3210 "a {what} uses slot `{slot}` which is occupied by the legend"
3211 )));
3212 }
3213 if used.contains(&slot) || others.contains(&slot) {
3214 return Err(Error::BadRequest(format!(
3215 "two placements target the same slot `{slot}`"
3216 )));
3217 }
3218 used.push(slot);
3219 }
3220 Ok(())
3221}
3222
3223struct LabelDraw {
3225 lines: Vec<String>,
3227 font: ab_glyph::FontVec,
3229 style: sl_map_apis::text::LabelStyle,
3231 origin: (i32, i32),
3233}
3234
3235struct LogoDraw {
3238 img: image::RgbaImage,
3240 x: i64,
3242 y: i64,
3244}
3245
3246fn plan_labels(
3256 fonts: &crate::fonts::FontDirectory,
3257 labels: &[TextLabel],
3258 legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
3259 reserved_by_others: &[sl_map_apis::coverage::PlacementSlot],
3260 occupancy: &Map,
3261) -> Result<(Vec<LabelDraw>, Vec<sl_map_apis::coverage::PlacementSlot>), Error> {
3262 let mut used: Vec<sl_map_apis::coverage::PlacementSlot> = Vec::new();
3263 let mut draws: Vec<LabelDraw> = Vec::new();
3264 if labels.is_empty() {
3265 return Ok((draws, used));
3266 }
3267 let grid = sl_map_apis::coverage::OccupancyGrid::from_map(
3269 occupancy,
3270 sl_map_apis::coverage::DEFAULT_COVERAGE_GRID,
3271 );
3272 let slots = grid.evaluate_slots();
3273 for label in labels {
3276 let lines: Vec<String> = label.lines.clone();
3277 if lines.iter().all(|line| line.trim().is_empty()) {
3278 continue;
3280 }
3281 if !(label.font_px.is_finite() && label.font_px > 0f32) {
3282 return Err(Error::BadRequest(format!(
3283 "label font size must be a positive number of pixels, got {}",
3284 label.font_px
3285 )));
3286 }
3287 let anchor: sl_map_apis::coverage::PlacementSlot =
3288 label
3289 .slot
3290 .parse()
3291 .map_err(|err: sl_map_apis::coverage::ParsePlacementSlotError| {
3292 Error::BadRequest(err.to_string())
3293 })?;
3294 let group = parse_slot_group(anchor, &label.slots)?;
3295 let (reserved, rect) = resolve_placement(anchor, &group, &slots, &grid)?;
3296 reserve(
3297 &reserved,
3298 &mut used,
3299 reserved_by_others,
3300 legend_slot,
3301 "label",
3302 )?;
3303 let font_path = fonts
3304 .path_for(&label.font_id)
3305 .ok_or_else(|| Error::BadRequest(format!("unknown font_id `{}`", label.font_id)))?;
3306 let font = sl_map_apis::text::load_font(font_path)?;
3307 let color = parse_color(label.color.trim())?;
3308 let scale = ab_glyph::PxScale::from(label.font_px);
3309 let (text_w, text_h) = sl_map_apis::text::measure_text(scale, &font, &lines);
3310 if text_w > rect.width || text_h > rect.height {
3311 return Err(Error::BadRequest(format!(
3312 "label text renders at {text_w}x{text_h} px but the free area at slot `{anchor}` only has {}x{} px",
3313 rect.width, rect.height
3314 )));
3315 }
3316 let (default_h, default_v) = anchor.default_alignment();
3319 let h = parse_h_align(label.h_align.as_deref())?.unwrap_or(default_h);
3320 let v = parse_v_align(label.v_align.as_deref())?.unwrap_or(default_v);
3321 let origin_x = rect.x.saturating_add(h.offset(text_w, rect.width));
3322 let origin_y = rect.y.saturating_add(v.offset(text_h, rect.height));
3323 draws.push(LabelDraw {
3324 lines,
3325 font,
3326 style: sl_map_apis::text::LabelStyle {
3327 scale,
3328 fg: color,
3329 shadow: Rgba([0, 0, 0, 180]),
3330 align: h,
3333 },
3334 origin: (
3335 i32::try_from(origin_x).unwrap_or(0),
3336 i32::try_from(origin_y).unwrap_or(0),
3337 ),
3338 });
3339 }
3340 Ok((draws, used))
3341}
3342
3343fn execute_labels(draws: &[LabelDraw], map: &mut Map) {
3345 use sl_map_apis::map_tiles::MapLike as _;
3346 for d in draws {
3347 map.draw_text_label(d.origin, &d.lines, &d.style, &d.font);
3348 }
3349}
3350
3351#[cfg(test)]
3357fn draw_labels_on_map(
3358 fonts: &crate::fonts::FontDirectory,
3359 labels: &[TextLabel],
3360 legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
3361 reserved_by_others: &[sl_map_apis::coverage::PlacementSlot],
3362 map: &mut Map,
3363) -> Result<Vec<sl_map_apis::coverage::PlacementSlot>, Error> {
3364 let (draws, used) = plan_labels(fonts, labels, legend_slot, reserved_by_others, map)?;
3365 execute_labels(&draws, map);
3366 Ok(used)
3367}
3368
3369async fn plan_logos(
3386 state: &AppState,
3387 logos: &[LogoPlacement],
3388 legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
3389 reserved_by_others: &[sl_map_apis::coverage::PlacementSlot],
3390 occupancy: &Map,
3391) -> Result<(Vec<LogoDraw>, Vec<sl_map_apis::coverage::PlacementSlot>), Error> {
3392 let mut used: Vec<sl_map_apis::coverage::PlacementSlot> = Vec::new();
3393 let mut draws: Vec<LogoDraw> = Vec::new();
3394 if logos.is_empty() {
3395 return Ok((draws, used));
3396 }
3397 let grid = sl_map_apis::coverage::OccupancyGrid::from_map(
3398 occupancy,
3399 sl_map_apis::coverage::DEFAULT_COVERAGE_GRID,
3400 );
3401 let slots = grid.evaluate_slots();
3402 for logo in logos {
3405 if logo.scale != 1 && logo.scale != 2 && logo.scale != 4 {
3406 return Err(Error::BadRequest(format!(
3407 "logo scale must be 1, 2 or 4, got {}",
3408 logo.scale
3409 )));
3410 }
3411 let anchor: sl_map_apis::coverage::PlacementSlot =
3412 logo.slot
3413 .parse()
3414 .map_err(|err: sl_map_apis::coverage::ParsePlacementSlotError| {
3415 Error::BadRequest(err.to_string())
3416 })?;
3417 let group = parse_slot_group(anchor, &logo.slots)?;
3418 let (reserved, rect) = resolve_placement(anchor, &group, &slots, &grid)?;
3419 reserve(
3420 &reserved,
3421 &mut used,
3422 reserved_by_others,
3423 legend_slot,
3424 "logo",
3425 )?;
3426 let row: Option<(String,)> =
3429 sqlx::query_as("SELECT image_filename FROM saved_logos WHERE logo_id = ?1")
3430 .bind(logo.logo_id.as_bytes().to_vec())
3431 .fetch_optional(&state.db)
3432 .await
3433 .map_err(|err| {
3434 tracing::error!("logo file lookup failed: {err}");
3435 Error::Database
3436 })?;
3437 let (image_filename,) = row
3438 .ok_or_else(|| Error::BadRequest(format!("logo {} no longer exists", logo.logo_id)))?;
3439 let bytes = storage::read_logo_file(&state.config.storage_dir, &image_filename).await?;
3440 let decoded = image::load_from_memory(&bytes).map_err(|e| {
3441 Error::BadRequest(format!("could not decode logo {}: {e}", logo.logo_id))
3442 })?;
3443 let mut rgba = decoded.to_rgba8();
3444 if logo.scale != 1 {
3445 let factor = u32::from(logo.scale);
3446 let (w, h) = (rgba.width(), rgba.height());
3447 rgba = image::imageops::resize(
3448 &rgba,
3449 w.saturating_mul(factor),
3450 h.saturating_mul(factor),
3451 image::imageops::FilterType::Nearest,
3452 );
3453 }
3454 let (w, h) = (rgba.width(), rgba.height());
3455 if w > rect.width || h > rect.height {
3456 return Err(Error::BadRequest(format!(
3457 "logo renders at {w}x{h} px but the free area at slot `{anchor}` only has {}x{} px",
3458 rect.width, rect.height
3459 )));
3460 }
3461 let (default_h, default_v) = anchor.default_alignment();
3462 let hh = parse_h_align(logo.h_align.as_deref())?.unwrap_or(default_h);
3463 let vv = parse_v_align(logo.v_align.as_deref())?.unwrap_or(default_v);
3464 let origin_x = rect.x.saturating_add(hh.offset(w, rect.width));
3465 let origin_y = rect.y.saturating_add(vv.offset(h, rect.height));
3466 draws.push(LogoDraw {
3467 img: rgba,
3468 x: i64::from(origin_x),
3469 y: i64::from(origin_y),
3470 });
3471 }
3472 Ok((draws, used))
3473}
3474
3475fn execute_logos(draws: &[LogoDraw], map: &mut Map) {
3478 use sl_map_apis::map_tiles::MapLike as _;
3479 for d in draws {
3480 image::imageops::overlay(map.image_mut(), &d.img, d.x, d.y);
3481 }
3482}
3483
3484async fn resolve_notecard_rect(
3488 state: &AppState,
3489 notecard: &USBNotecard,
3490 borders: (u16, u16, u16, u16),
3491) -> Result<(GridRectangle, GridRectangle), Error> {
3492 let bare_rect = {
3493 let mut region = state.region_cache.lock().await;
3494 usb_notecard_to_grid_rectangle(&mut region, notecard).await?
3495 };
3496 let (border_north, border_south, border_east, border_west) = borders;
3497 let rect = bare_rect
3498 .expanded_west(border_west)
3499 .expanded_east(border_east)
3500 .expanded_south(border_south)
3501 .expanded_north(border_north);
3502 Ok((bare_rect, rect))
3503}
3504
3505async fn plan_placements(
3517 state: &AppState,
3518 occ_rect: GridRectangle,
3519 common: &CommonParams,
3520 glw_ctx: Option<&GlwJobCtx>,
3521 route: Option<(&USBNotecard, Rgba<u8>)>,
3522 labels: &[TextLabel],
3523 logos: &[LogoPlacement],
3524) -> Result<(Vec<LabelDraw>, Vec<LogoDraw>), Error> {
3525 let legend_slot = legend_slot_of(glw_ctx);
3526 let occ = {
3527 let mut occ = Map::blank_fit(occ_rect, common.max_width, common.max_height)?;
3528 if let Some(ctx) = glw_ctx {
3529 apply_glw_overlay_readonly(state, ctx.created_by, &ctx.options, &mut occ).await?;
3530 }
3531 if let Some((notecard, color)) = route {
3532 let mut region = state.region_cache.lock().await;
3533 occ.draw_route_with_progress(&mut region, notecard, color, None)
3534 .await?;
3535 }
3536 occ
3537 };
3538 let (label_draws, label_slots) = plan_labels(&state.fonts, labels, legend_slot, &[], &occ)?;
3539 let (logo_draws, _) = plan_logos(state, logos, legend_slot, &label_slots, &occ).await?;
3540 Ok((label_draws, logo_draws))
3541}
3542
3543async fn validate_logos(
3548 state: &AppState,
3549 current_user: Uuid,
3550 destination: Destination,
3551 logos: &[LogoPlacement],
3552) -> Result<Vec<Uuid>, Error> {
3553 let mut ids: Vec<Uuid> = Vec::new();
3554 for logo in logos {
3555 let row = library::assert_can_read_logo(&state.db, current_user, logo.logo_id).await?;
3556 let logo_dest = library::destination_from_columns(
3557 row.owner_user_id.clone(),
3558 row.owner_group_id.clone(),
3559 )?;
3560 if logo_dest != destination {
3561 return Err(Error::BadRequest(format!(
3562 "logo {} is not in the same library as this render; copy it into the render's \
3563 scope first",
3564 logo.logo_id
3565 )));
3566 }
3567 if !ids.contains(&logo.logo_id) {
3568 ids.push(logo.logo_id);
3569 }
3570 }
3571 Ok(ids)
3572}
3573
3574async fn assert_can_read_logos(
3582 state: &AppState,
3583 current_user: Uuid,
3584 logos: &[LogoPlacement],
3585) -> Result<(), Error> {
3586 for logo in logos {
3587 library::assert_can_read_logo(&state.db, current_user, logo.logo_id).await?;
3588 }
3589 Ok(())
3590}
3591
3592async fn link_render_logos_or_fail(
3599 state: &AppState,
3600 render_id: Uuid,
3601 logo_ids: &[Uuid],
3602) -> Result<(), Error> {
3603 if let Err(err) = link_render_logos(state, render_id, logo_ids).await {
3604 update_failed(
3605 state,
3606 render_id,
3607 &format!("linking render logos failed: {err}"),
3608 Utc::now(),
3609 )
3610 .await;
3611 return Err(err);
3612 }
3613 Ok(())
3614}
3615
3616async fn link_render_logos(
3619 state: &AppState,
3620 render_id: Uuid,
3621 logo_ids: &[Uuid],
3622) -> Result<(), Error> {
3623 for id in logo_ids {
3624 sqlx::query(
3625 "INSERT OR IGNORE INTO saved_render_logos (render_id, logo_id) VALUES (?1, ?2)",
3626 )
3627 .bind(render_id.as_bytes().to_vec())
3628 .bind(id.as_bytes().to_vec())
3629 .execute(&state.db)
3630 .await
3631 .map_err(|err| {
3632 tracing::error!("link render logo failed: {err}");
3633 Error::Database
3634 })?;
3635 }
3636 Ok(())
3637}
3638
3639async fn rewrite_settings_json_with_saved_glw(
3644 state: &AppState,
3645 render_id: Uuid,
3646 glw_data_id: Uuid,
3647) -> Result<Option<String>, Error> {
3648 let row: Option<(String,)> =
3649 sqlx::query_as("SELECT settings_json FROM saved_renders WHERE render_id = ?1")
3650 .bind(render_id.as_bytes().to_vec())
3651 .fetch_optional(&state.db)
3652 .await
3653 .map_err(|err| {
3654 tracing::error!("settings_json fetch failed: {err}");
3655 Error::Database
3656 })?;
3657 let Some((settings_json,)) = row else {
3658 return Ok(None);
3659 };
3660 let mut parsed: SavedRenderSettings = serde_json::from_str(&settings_json)?;
3661 let glw_slot: &mut Option<GlwRenderOptions> = match &mut parsed {
3662 SavedRenderSettings::GridRectangle(s) => &mut s.glw,
3663 SavedRenderSettings::UsbNotecard(s) => &mut s.glw,
3664 };
3665 let Some(opts) = glw_slot.as_mut() else {
3666 return Ok(None);
3667 };
3668 if matches!(opts.source, GlwSource::SavedId { .. }) {
3671 return Ok(None);
3672 }
3673 opts.source = GlwSource::SavedId { glw_data_id };
3674 let json = serde_json::to_string(&parsed)?;
3675 Ok(Some(json))
3676}
3677
3678#[cfg(test)]
3679mod placement_slots_tests {
3680 use super::*;
3681 use pretty_assertions::assert_eq;
3682
3683 fn blank_map() -> Result<Map, Box<dyn std::error::Error>> {
3684 let rect = GridRectangle::new(
3686 GridCoordinates::new(1000, 1000),
3687 GridCoordinates::new(1003, 1003),
3688 );
3689 Ok(Map::blank(rect, ZoomLevel::try_new(4)?))
3690 }
3691
3692 #[test]
3693 fn empty_map_reports_nine_free_slots() -> Result<(), Box<dyn std::error::Error>> {
3694 let map = blank_map()?;
3695 let resp = compute_placement_slots(&map, &[]);
3696 assert_eq!(resp.slots.len(), 9);
3697 assert_eq!((resp.image_width, resp.image_height), (128, 128));
3698 for s in &resp.slots {
3701 assert!(s.available, "{} should be free", s.slot);
3702 }
3703 let top_left = resp
3704 .slots
3705 .iter()
3706 .find(|s| s.slot == "top_left")
3707 .ok_or("missing top_left slot")?;
3708 assert!(top_left.free_rect.is_some());
3709 Ok(())
3710 }
3711
3712 #[test]
3713 fn route_blocks_centre_slot() -> Result<(), Box<dyn std::error::Error>> {
3714 let mut map = blank_map()?;
3715 map.draw_pixel_waypoint_route(
3716 &[(10f32, 10f32), (64f32, 64f32), (118f32, 118f32)],
3717 Rgba([255, 0, 0, 255]),
3718 )?;
3719 let resp = compute_placement_slots(&map, &[]);
3720 let center = resp
3721 .slots
3722 .iter()
3723 .find(|s| s.slot == "center")
3724 .ok_or("missing center slot")?;
3725 assert!(!center.available);
3726 let top_right = resp
3727 .slots
3728 .iter()
3729 .find(|s| s.slot == "top_right")
3730 .ok_or("missing top_right slot")?;
3731 assert!(top_right.available);
3732 Ok(())
3733 }
3734
3735 #[test]
3736 fn requested_group_reports_combined_rect() -> Result<(), Box<dyn std::error::Error>> {
3737 use sl_map_apis::coverage::PlacementSlot as P;
3738 let map = blank_map()?;
3739 let resp = compute_placement_slots(&map, &[vec![P::TopLeft, P::TopCenter]]);
3740 assert_eq!(resp.groups.len(), 1);
3741 let g = resp.groups.first().ok_or("missing group")?;
3742 assert_eq!(g.slots, vec!["top_left", "top_center"]);
3743 assert!(g.available && g.free_rect.is_some());
3744 let tl = resp
3746 .slots
3747 .iter()
3748 .find(|s| s.slot == "top_left")
3749 .ok_or("missing top_left")?;
3750 assert!(g.free_width > tl.free_width);
3751 Ok(())
3752 }
3753}
3754
3755#[cfg(test)]
3756mod glw_preview_tests {
3757 use super::*;
3763 use pretty_assertions::assert_eq;
3764 use sl_glw::{GlwEvent, MapLikeGlwExt as _};
3765
3766 const SAMPLE_JSON: &str = r#"{
3769 "eventId": 6910,
3770 "eventName": "test cruise",
3771 "eventKey": "key cruise",
3772 "directorName": "LaliaCasau Resident",
3773 "directorKey": "b609826a-b167-41e0-8e67-9fc0e78b97a1",
3774 "base": {
3775 "wind": { "dir": 175, "speed": 17, "gusts": 8, "shifts": 5, "period": 90 },
3776 "waves": {
3777 "height": 1.5, "speed": 3, "length": 35,
3778 "heightVar": 5, "lengthVar": 5,
3779 "effects": { "speed": 1, "steer": 1 }
3780 },
3781 "currents": { "speed": 0, "dir": 180, "waterDepth": 0 }
3782 },
3783 "areas": {
3784 "area1": {
3785 "coordSW": { "x": 1133, "y": 1048 },
3786 "coordNE": { "x": 1135, "y": 1049 },
3787 "margin": 25, "overlap": 0,
3788 "currents": { "speed": 1, "dir": 225, "waterDepth": 8 }
3789 }
3790 },
3791 "circles": {
3792 "circle1": {
3793 "centerSim": { "x": 1136, "y": 1051 },
3794 "centerPoint": { "x": 90, "y": 175 },
3795 "radius": 127, "margin": 25, "overlap": 0,
3796 "wind": { "speed": 15 },
3797 "currents": { "speed": 0.1, "dir": 225, "waterDepth": 6 }
3798 }
3799 }
3800 }"#;
3801
3802 fn test_font() -> Result<ab_glyph::FontVec, Box<dyn std::error::Error>> {
3805 let root = std::path::PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/.."));
3806 let fonts = crate::fonts::FontDirectory::scan(root)?;
3807 let path = fonts
3808 .path_for("DejaVuSans.ttf")
3809 .ok_or("bundled DejaVuSans.ttf font is missing")?;
3810 Ok(sl_map_apis::text::load_font(path)?)
3811 }
3812
3813 #[test]
3818 fn blank_overlay_dimensions_match_client_bounds() -> Result<(), Box<dyn std::error::Error>> {
3819 let rect = GridRectangle::new(
3820 GridCoordinates::new(1130, 1045),
3821 GridCoordinates::new(1140, 1055),
3822 );
3823 let zoom = ZoomLevel::try_new(4)?;
3824 let map = Map::blank(rect, zoom);
3825 let (w, h) = image::GenericImageView::dimensions(&map);
3826 assert_eq!((w, h), (11 * 32, 11 * 32));
3828 for y in 0..h {
3831 for x in 0..w {
3832 let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(&map, x, y);
3833 assert_eq!(a, 0, "blank overlay must start fully transparent");
3834 }
3835 }
3836 Ok(())
3837 }
3838
3839 fn top_left_has_pixels(map: &Map, n: u32) -> bool {
3844 for y in 0..n {
3845 for x in 0..n {
3846 let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(map, x, y);
3847 if a != 0 {
3848 return true;
3849 }
3850 }
3851 }
3852 false
3853 }
3854
3855 #[test]
3859 fn preview_overlay_omits_the_legend() -> Result<(), Box<dyn std::error::Error>> {
3860 let event: GlwEvent = serde_json::from_str(SAMPLE_JSON)?;
3861 let font = test_font()?;
3862 let rect = GridRectangle::new(
3863 GridCoordinates::new(1130, 1045),
3864 GridCoordinates::new(1140, 1055),
3865 );
3866 let zoom = ZoomLevel::try_new(4)?;
3867
3868 let mut with_legend = Map::blank(rect.clone(), zoom);
3871 let style_with = build_glw_style(&GlwStyleOverrides::default(), Some("top_left"))?;
3872 with_legend.draw_glw_event_with_font(&event, &style_with, &font);
3873 assert!(
3874 top_left_has_pixels(&with_legend, 40),
3875 "the legend should fill the top-left corner when enabled"
3876 );
3877
3878 let mut without_legend = Map::blank(rect, zoom);
3881 let mut style_without = build_glw_style(&GlwStyleOverrides::default(), None)?;
3882 style_without.legend_position = None;
3883 without_legend.draw_glw_event_with_font(&event, &style_without, &font);
3884 assert!(
3885 !top_left_has_pixels(&without_legend, 40),
3886 "the preview overlay must not draw the legend"
3887 );
3888
3889 let (w, h) = image::GenericImageView::dimensions(&without_legend);
3891 let mut any = false;
3892 'outer: for y in 0..h {
3893 for x in 0..w {
3894 let image::Rgba([_, _, _, a]) =
3895 image::GenericImageView::get_pixel(&without_legend, x, y);
3896 if a != 0 {
3897 any = true;
3898 break 'outer;
3899 }
3900 }
3901 }
3902 assert!(any, "the GLW shapes should still be drawn");
3903 Ok(())
3904 }
3905}
3906
3907#[cfg(test)]
3908mod route_preview_tests {
3909 use super::*;
3916 use pretty_assertions::assert_eq;
3917 use sl_map_apis::map_tiles::MapLike as _;
3918
3919 const ROUTE_COLOR: Rgba<u8> = Rgba([255, 0, 0, 255]);
3923
3924 #[expect(
3928 clippy::as_conversions,
3929 clippy::cast_precision_loss,
3930 reason = "pixel coordinates are bounded by the output dimensions and never approach 2^23"
3931 )]
3932 fn widen(v: u32) -> f32 {
3933 v as f32
3934 }
3935
3936 fn pixel_for(map: &Map, rx: u16, ry: u16, x: f32, y: f32) -> Option<(f32, f32)> {
3939 let (px, py) = map.pixel_coordinates_for_coordinates(
3940 &GridCoordinates::new(rx, ry),
3941 &RegionCoordinates::new(x, y, 0f32),
3942 )?;
3943 Some((widen(px), widen(py)))
3944 }
3945
3946 fn distance_to_segment(p: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 {
3948 let (px, py) = p;
3949 let (ax, ay) = a;
3950 let (bx, by) = b;
3951 let (dx, dy) = (bx - ax, by - ay);
3952 let len_sq = (dx * dx) + (dy * dy);
3953 let t = if len_sq <= f32::EPSILON {
3954 0f32
3955 } else {
3956 ((((px - ax) * dx) + ((py - ay) * dy)) / len_sq).clamp(0f32, 1f32)
3957 };
3958 let (cx, cy) = (ax + (t * dx), ay + (t * dy));
3959 ((px - cx).powi(2) + (py - cy).powi(2)).sqrt()
3960 }
3961
3962 #[test]
3966 fn route_drawn_in_colour_on_transparent_background() -> Result<(), Box<dyn std::error::Error>> {
3967 let rect = GridRectangle::new(
3968 GridCoordinates::new(1000, 1000),
3969 GridCoordinates::new(1010, 1010),
3970 );
3971 let mut map = Map::blank_fit(rect, 512, 512)?;
3972 let pixel_waypoints = vec![
3973 pixel_for(&map, 1001, 1001, 128f32, 128f32).ok_or("waypoint outside rect")?,
3974 pixel_for(&map, 1009, 1009, 128f32, 128f32).ok_or("waypoint outside rect")?,
3975 ];
3976 map.draw_pixel_waypoint_route(&pixel_waypoints, ROUTE_COLOR)?;
3977
3978 let (w, h) = image::GenericImageView::dimensions(&map);
3979 let mut coloured = 0u32;
3980 let mut foreign = 0u32;
3981 for y in 0..h {
3982 for x in 0..w {
3983 let image::Rgba([r, g, b, a]) = image::GenericImageView::get_pixel(&map, x, y);
3984 if image::Rgba([r, g, b, a]) == ROUTE_COLOR {
3985 coloured += 1;
3986 } else if a != 0 {
3987 foreign += 1;
3989 }
3990 }
3991 }
3992 assert!(
3993 coloured > 0,
3994 "the route must be drawn in the requested colour"
3995 );
3996 assert_eq!(
3997 foreign, 0,
3998 "only the route colour and full transparency may appear"
3999 );
4000 Ok(())
4001 }
4002
4003 #[test]
4008 fn route_curves_between_waypoints() -> Result<(), Box<dyn std::error::Error>> {
4009 let rect = GridRectangle::new(
4010 GridCoordinates::new(1000, 1000),
4011 GridCoordinates::new(1012, 1012),
4012 );
4013 let mut map = Map::blank_fit(rect, 512, 512)?;
4014 let p0 = pixel_for(&map, 1001, 1001, 128f32, 128f32).ok_or("waypoint outside rect")?;
4017 let p1 = pixel_for(&map, 1006, 1006, 128f32, 128f32).ok_or("waypoint outside rect")?;
4018 let p2 = pixel_for(&map, 1011, 1001, 128f32, 128f32).ok_or("waypoint outside rect")?;
4019 map.draw_pixel_waypoint_route(&[p0, p1, p2], ROUTE_COLOR)?;
4020
4021 let (w, h) = image::GenericImageView::dimensions(&map);
4022 let mut max_off = 0f32;
4023 for y in 0..h {
4024 for x in 0..w {
4025 if image::GenericImageView::get_pixel(&map, x, y) == ROUTE_COLOR {
4026 let p = (widen(x), widen(y));
4027 let off = distance_to_segment(p, p0, p1).min(distance_to_segment(p, p1, p2));
4028 max_off = max_off.max(off);
4029 }
4030 }
4031 }
4032 assert!(
4033 max_off > 4f32,
4034 "the route should curve away from the straight chords (max off-chord \
4035 distance was {max_off:.1} px); a straight polyline would stay on them"
4036 );
4037 Ok(())
4038 }
4039}
4040
4041#[cfg(test)]
4042mod glw_legend_preview_tests {
4043 use super::*;
4049 use pretty_assertions::assert_eq;
4050 use sl_glw::{GlwEvent, MapLikeGlwExt as _};
4051
4052 const SAMPLE_JSON: &str = r#"{
4055 "eventId": 6910,
4056 "eventName": "test cruise",
4057 "eventKey": "key cruise",
4058 "directorName": "LaliaCasau Resident",
4059 "directorKey": "b609826a-b167-41e0-8e67-9fc0e78b97a1",
4060 "base": {
4061 "wind": { "dir": 175, "speed": 17, "gusts": 8, "shifts": 5, "period": 90 },
4062 "waves": {
4063 "height": 1.5, "speed": 3, "length": 35,
4064 "heightVar": 5, "lengthVar": 5,
4065 "effects": { "speed": 1, "steer": 1 }
4066 },
4067 "currents": { "speed": 0, "dir": 180, "waterDepth": 0 }
4068 },
4069 "areas": {},
4070 "circles": {}
4071 }"#;
4072
4073 fn test_font() -> Result<ab_glyph::FontVec, Box<dyn std::error::Error>> {
4075 let root = std::path::PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/.."));
4076 let fonts = crate::fonts::FontDirectory::scan(root)?;
4077 let path = fonts
4078 .path_for("DejaVuSans.ttf")
4079 .ok_or("bundled DejaVuSans.ttf font is missing")?;
4080 Ok(sl_map_apis::text::load_font(path)?)
4081 }
4082
4083 fn corner_has_pixels(map: &Map, ox: u32, oy: u32, w: u32, h: u32) -> bool {
4086 for y in oy..oy.saturating_add(h) {
4087 for x in ox..ox.saturating_add(w) {
4088 let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(map, x, y);
4089 if a != 0 {
4090 return true;
4091 }
4092 }
4093 }
4094 false
4095 }
4096
4097 #[test]
4101 fn legend_drawn_only_in_chosen_slot() -> Result<(), Box<dyn std::error::Error>> {
4102 let event: GlwEvent = serde_json::from_str(SAMPLE_JSON)?;
4103 let font = test_font()?;
4104 let rect = GridRectangle::new(
4105 GridCoordinates::new(1130, 1045),
4106 GridCoordinates::new(1140, 1055),
4107 );
4108 let mut map = Map::blank_fit(rect, 512, 512)?;
4109 let style = build_glw_style(&GlwStyleOverrides::default(), Some("bottom_right"))?;
4110 map.draw_glw_base_legend(&event.base, &style, &font);
4112 let (w, h) = image::GenericImageView::dimensions(&map);
4113 assert!(
4114 corner_has_pixels(&map, w.saturating_sub(80), h.saturating_sub(80), 80, 80),
4115 "the bottom_right legend should fill the bottom-right corner"
4116 );
4117 assert!(
4118 !corner_has_pixels(&map, 0, 0, 80, 80),
4119 "no legend should appear in the unselected top-left corner"
4120 );
4121 Ok(())
4122 }
4123
4124 #[test]
4127 fn legend_slot_none_draws_nothing() -> Result<(), Box<dyn std::error::Error>> {
4128 let event: GlwEvent = serde_json::from_str(SAMPLE_JSON)?;
4129 let font = test_font()?;
4130 let rect = GridRectangle::new(
4131 GridCoordinates::new(1130, 1045),
4132 GridCoordinates::new(1140, 1055),
4133 );
4134 let mut map = Map::blank_fit(rect, 512, 512)?;
4135 let style = build_glw_style(&GlwStyleOverrides::default(), Some("none"))?;
4136 assert!(
4137 style.legend_position.is_none(),
4138 "the `none` slot must disable the legend"
4139 );
4140 if style.legend_position.is_some() {
4143 map.draw_glw_base_legend(&event.base, &style, &font);
4144 }
4145 let (w, h) = image::GenericImageView::dimensions(&map);
4146 for y in 0..h {
4147 for x in 0..w {
4148 let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(&map, x, y);
4149 assert_eq!(a, 0, "a `none` legend slot must leave the map transparent");
4150 }
4151 }
4152 Ok(())
4153 }
4154}
4155
4156#[cfg(test)]
4157mod label_tests {
4158 use super::*;
4159 use pretty_assertions::assert_eq;
4160 use pretty_assertions::assert_matches;
4161 use sl_map_apis::coverage::{HAlign, PlacementSlot, VAlign};
4162
4163 fn test_fonts() -> Result<crate::fonts::FontDirectory, Box<dyn std::error::Error>> {
4166 let root = std::path::PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/.."));
4167 Ok(crate::fonts::FontDirectory::scan(root)?)
4168 }
4169
4170 fn blank_map() -> Result<Map, Box<dyn std::error::Error>> {
4172 let rect = GridRectangle::new(
4173 GridCoordinates::new(1130, 1130),
4174 GridCoordinates::new(1140, 1140),
4175 );
4176 Ok(Map::blank(rect, ZoomLevel::try_new(4)?))
4177 }
4178
4179 fn label(slot: &str, font_px: f32, lines: &[&str]) -> TextLabel {
4180 TextLabel {
4181 slot: slot.to_owned(),
4182 lines: lines.iter().map(|s| (*s).to_owned()).collect(),
4183 font_id: "DejaVuSans.ttf".to_owned(),
4184 font_px,
4185 color: "#ffffff".to_owned(),
4186 h_align: None,
4187 v_align: None,
4188 slots: Vec::new(),
4189 }
4190 }
4191
4192 fn any_pixel_drawn(map: &Map) -> bool {
4193 let (w, h) = image::GenericImageView::dimensions(map);
4194 for y in 0..h {
4195 for x in 0..w {
4196 let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(map, x, y);
4197 if a != 0 {
4198 return true;
4199 }
4200 }
4201 }
4202 false
4203 }
4204
4205 fn opaque_map() -> Result<Map, Box<dyn std::error::Error>> {
4208 use image::GenericImage as _;
4209 use sl_map_apis::map_tiles::MapLike as _;
4210 let mut map = blank_map()?;
4211 let (w, h) = image::GenericImageView::dimensions(&map);
4212 for y in 0..h {
4213 for x in 0..w {
4214 map.image_mut().put_pixel(x, y, image::Rgba([5, 5, 5, 255]));
4215 }
4216 }
4217 Ok(map)
4218 }
4219
4220 #[test]
4225 fn labels_plan_on_overlay_then_draw_onto_opaque_target()
4226 -> Result<(), Box<dyn std::error::Error>> {
4227 let fonts = test_fonts()?;
4228 let lbls = [label("top_left", 18f32, &["Hi"])];
4229
4230 let occ = blank_map()?;
4233 let (draws, used) = plan_labels(&fonts, &lbls, None, &[], &occ)?;
4234 assert!(!draws.is_empty(), "the label should be planned");
4235 assert!(used.contains(&PlacementSlot::TopLeft));
4236 let mut target = opaque_map()?;
4237 execute_labels(&draws, &mut target);
4238 let (w, h) = image::GenericImageView::dimensions(&target);
4239 let mut changed = false;
4240 'outer: for y in 0..h {
4241 for x in 0..w {
4242 let image::Rgba([r, g, b, _]) = image::GenericImageView::get_pixel(&target, x, y);
4243 if (r, g, b) != (5, 5, 5) {
4244 changed = true;
4245 break 'outer;
4246 }
4247 }
4248 }
4249 assert!(changed, "the label must draw onto the opaque target");
4250
4251 let opaque_occ = opaque_map()?;
4253 assert!(
4254 plan_labels(&fonts, &lbls, None, &[], &opaque_occ).is_err(),
4255 "an opaque occupancy map leaves no room — the regression the split fixes"
4256 );
4257 Ok(())
4258 }
4259
4260 #[test]
4261 fn legend_position_from_slot_maps_names() -> Result<(), Box<dyn std::error::Error>> {
4262 use sl_map_apis::coverage::PlacementSlot as P;
4263 assert_eq!(legend_position_from_slot(None)?, Some(P::TopLeft));
4264 assert_eq!(legend_position_from_slot(Some(""))?, Some(P::TopLeft));
4265 assert_eq!(legend_position_from_slot(Some("none"))?, None);
4266 assert_eq!(legend_position_from_slot(Some("center"))?, Some(P::Center));
4267 assert_eq!(
4268 legend_position_from_slot(Some("bottom_right"))?,
4269 Some(P::BottomRight)
4270 );
4271 assert_matches!(legend_position_from_slot(Some("nonsense")), Err(_));
4272 Ok(())
4273 }
4274
4275 #[test]
4276 fn slot_parsers_and_alignment() -> Result<(), Box<dyn std::error::Error>> {
4277 assert_eq!(
4278 "middle_left".parse::<PlacementSlot>()?,
4279 PlacementSlot::MiddleLeft
4280 );
4281 assert_matches!("nope".parse::<PlacementSlot>(), Err(_));
4282 assert_eq!(parse_h_align(None)?, None);
4283 assert_eq!(parse_h_align(Some("right"))?, Some(HAlign::Right));
4284 assert_eq!(parse_v_align(Some("bottom"))?, Some(VAlign::Bottom));
4285 assert_matches!(parse_h_align(Some("sideways")), Err(_));
4286 Ok(())
4287 }
4288
4289 #[test]
4290 fn small_label_draws_and_oversized_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
4291 let fonts = test_fonts()?;
4292
4293 let mut map = blank_map()?;
4294 draw_labels_on_map(
4295 &fonts,
4296 &[label("top_left", 18f32, &["Hi"])],
4297 None,
4298 &[],
4299 &mut map,
4300 )?;
4301 assert!(any_pixel_drawn(&map), "a fitting label should draw pixels");
4302
4303 let mut map2 = blank_map()?;
4305 let err = draw_labels_on_map(
4306 &fonts,
4307 &[label("center", 4000f32, &["BIG"])],
4308 None,
4309 &[],
4310 &mut map2,
4311 );
4312 assert_matches!(
4313 err,
4314 Err(Error::BadRequest(_)),
4315 "oversized label must be rejected"
4316 );
4317 Ok(())
4318 }
4319
4320 #[test]
4321 fn slot_collisions_are_rejected() -> Result<(), Box<dyn std::error::Error>> {
4322 let fonts = test_fonts()?;
4323
4324 let mut map = blank_map()?;
4326 let dup = draw_labels_on_map(
4327 &fonts,
4328 &[
4329 label("top_right", 16f32, &["a"]),
4330 label("top_right", 16f32, &["b"]),
4331 ],
4332 None,
4333 &[],
4334 &mut map,
4335 );
4336 assert_matches!(
4337 dup,
4338 Err(Error::BadRequest(_)),
4339 "duplicate slot must be rejected"
4340 );
4341
4342 let mut map2 = blank_map()?;
4344 let clash = draw_labels_on_map(
4345 &fonts,
4346 &[label("top_left", 16f32, &["x"])],
4347 Some(PlacementSlot::TopLeft),
4348 &[],
4349 &mut map2,
4350 );
4351 assert_matches!(
4352 clash,
4353 Err(Error::BadRequest(_)),
4354 "legend collision must be rejected"
4355 );
4356 Ok(())
4357 }
4358
4359 #[test]
4360 fn blank_label_is_skipped() -> Result<(), Box<dyn std::error::Error>> {
4361 let fonts = test_fonts()?;
4362 let mut map = blank_map()?;
4363 draw_labels_on_map(
4364 &fonts,
4365 &[label("center", 16f32, &["", " "])],
4366 None,
4367 &[],
4368 &mut map,
4369 )?;
4370 assert!(!any_pixel_drawn(&map), "an all-blank label draws nothing");
4371 Ok(())
4372 }
4373
4374 #[test]
4375 fn reserve_rejects_legend_duplicate_and_other_pool() -> Result<(), Box<dyn std::error::Error>> {
4376 let mut used = Vec::new();
4378 assert_matches!(
4379 reserve(
4380 &[PlacementSlot::TopLeft],
4381 &mut used,
4382 &[],
4383 Some(PlacementSlot::TopLeft),
4384 "label",
4385 ),
4386 Err(Error::BadRequest(_))
4387 );
4388
4389 let mut used = Vec::new();
4391 reserve(&[PlacementSlot::TopRight], &mut used, &[], None, "label")?;
4392 assert_matches!(
4393 reserve(&[PlacementSlot::TopRight], &mut used, &[], None, "logo"),
4394 Err(Error::BadRequest(_))
4395 );
4396
4397 let mut used = Vec::new();
4399 assert_matches!(
4400 reserve(
4401 &[PlacementSlot::Center],
4402 &mut used,
4403 &[PlacementSlot::Center],
4404 None,
4405 "logo",
4406 ),
4407 Err(Error::BadRequest(_))
4408 );
4409 Ok(())
4410 }
4411
4412 #[test]
4413 fn resolve_placement_single_vs_combined() -> Result<(), Box<dyn std::error::Error>> {
4414 let map = blank_map()?;
4415 let grid = sl_map_apis::coverage::OccupancyGrid::from_map(
4416 &map,
4417 sl_map_apis::coverage::DEFAULT_COVERAGE_GRID,
4418 );
4419 let slots = grid.evaluate_slots();
4420
4421 let (reserved, rect) = resolve_placement(
4423 PlacementSlot::TopLeft,
4424 &[PlacementSlot::TopLeft],
4425 &slots,
4426 &grid,
4427 )?;
4428 assert_eq!(reserved, vec![PlacementSlot::TopLeft]);
4429 assert!(rect.width > 0 && rect.height > 0);
4430
4431 let group = vec![PlacementSlot::TopLeft, PlacementSlot::TopCenter];
4434 let (all, combined) = resolve_placement(PlacementSlot::TopLeft, &group, &slots, &grid)?;
4435 assert_eq!(all, group);
4436 assert!(combined.width > rect.width);
4437 Ok(())
4438 }
4439
4440 #[test]
4441 fn parse_slot_group_defaults_and_includes_anchor() -> Result<(), Box<dyn std::error::Error>> {
4442 use sl_map_apis::coverage::PlacementSlot as P;
4443 assert_eq!(parse_slot_group(P::TopLeft, &[])?, vec![P::TopLeft]);
4445 let g = parse_slot_group(
4447 P::TopLeft,
4448 &["top_center".to_owned(), "top_center".to_owned()],
4449 )?;
4450 assert_eq!(g, vec![P::TopCenter, P::TopLeft]);
4451 assert_matches!(parse_slot_group(P::TopLeft, &["nope".to_owned()]), Err(_));
4452 Ok(())
4453 }
4454
4455 #[test]
4456 fn parse_slot_group_rejects_non_rectangular_groups() {
4457 use sl_map_apis::coverage::PlacementSlot as P;
4458 assert_matches!(
4460 parse_slot_group(P::TopLeft, &["top_center".to_owned()]),
4461 Ok(_)
4462 );
4463 assert_matches!(
4465 parse_slot_group(P::TopLeft, &["bottom_right".to_owned()]),
4466 Err(_)
4467 );
4468 assert_matches!(
4470 parse_slot_group(
4471 P::TopLeft,
4472 &["top_center".to_owned(), "middle_left".to_owned()],
4473 ),
4474 Err(_)
4475 );
4476 }
4477
4478 #[test]
4479 fn parse_groups_rejects_non_rectangular_groups() {
4480 assert_matches!(
4482 parse_groups(&[vec!["top_left".to_owned(), "top_center".to_owned()]]),
4483 Ok(_)
4484 );
4485 assert_matches!(
4487 parse_groups(&[vec!["top_left".to_owned(), "bottom_left".to_owned()]]),
4488 Err(_)
4489 );
4490 }
4491}
4492
4493#[cfg(test)]
4494mod region_overlay_tests {
4495 use super::*;
4496 use pretty_assertions::assert_eq;
4497
4498 #[test]
4499 fn text_overlay_gate_requires_both_size_and_count() {
4500 assert!(region_text_overlay_allowed(
4502 MIN_PIXELS_PER_REGION_FOR_REGION_LABELS,
4503 MAX_REGIONS_FOR_REGION_LABELS
4504 ));
4505 assert!(!region_text_overlay_allowed(
4507 MIN_PIXELS_PER_REGION_FOR_REGION_LABELS - 1.0,
4508 1
4509 ));
4510 assert!(!region_text_overlay_allowed(
4512 256.0,
4513 MAX_REGIONS_FOR_REGION_LABELS + 1
4514 ));
4515 assert!(region_text_overlay_allowed(256.0, 4));
4517 }
4518
4519 #[test]
4520 fn rectangles_write_visible_pixels() -> Result<(), Box<dyn std::error::Error>> {
4521 let rect = GridRectangle::new(
4523 GridCoordinates::new(1000, 1000),
4524 GridCoordinates::new(1003, 1003),
4525 );
4526 let mut map = Map::blank(rect, ZoomLevel::try_new(4)?);
4527 let before = (0..map.height())
4529 .flat_map(|y| (0..map.width()).map(move |x| (x, y)))
4530 .any(|(x, y)| {
4531 let image::Rgba([_, _, _, a]) = map.get_pixel(x, y);
4532 a != 0
4533 });
4534 assert!(!before, "blank map should start fully transparent");
4535
4536 draw_region_rectangles(&mut map);
4537
4538 let mut drawn = false;
4540 for y in 0..map.height() {
4541 for x in 0..map.width() {
4542 if map.get_pixel(x, y) == image::Rgba([255, 255, 255, 255]) {
4543 drawn = true;
4544 }
4545 }
4546 }
4547 assert!(
4548 drawn,
4549 "draw_region_rectangles should write opaque white pixels"
4550 );
4551 Ok(())
4552 }
4553
4554 #[test]
4555 fn missing_region_fill_paints_the_region_rect() -> Result<(), Box<dyn std::error::Error>> {
4556 use sl_map_apis::map_tiles::MapLike as _;
4557 let rect = GridRectangle::new(
4559 GridCoordinates::new(1000, 1000),
4560 GridCoordinates::new(1001, 1001),
4561 );
4562 let mut map = Map::blank(rect, ZoomLevel::try_new(4)?);
4563 let grid = GridCoordinates::new(1000, 1000);
4566 let (left, top, width, height) =
4567 region_pixel_rect(&map, &grid).ok_or("region not in map")?;
4568 let fill = Rgba([0x19, 0x48, 0x5a, 0xff]);
4569 map.draw_filled_rect(left, top, width, height, fill);
4570
4571 assert_eq!(
4573 map.get_pixel(left + 1, top + 1),
4574 fill,
4575 "fill colour painted"
4576 );
4577 let other = region_pixel_rect(&map, &GridCoordinates::new(1001, 1001))
4579 .ok_or("other region not in map")?;
4580 let image::Rgba([_, _, _, a]) = map.get_pixel(other.0 + 1, other.1 + 1);
4581 assert_eq!(a, 0, "unfilled region stays transparent");
4582 Ok(())
4583 }
4584}