diff --git a/CHANGELOG.md b/CHANGELOG.md index b99a694..269afa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ Versions follow [semantic versioning](https://semver.org/), with `0.x` meaning that anything may change. +## Unreleased + +- Builder patterns: `seacraft templates hull.fef -o moulds.pdf --skin-mm 6` cuts full-size station moulds from the + refined hull, moved in by the skin thickness along the surface normal, with the mould face that goes on the + station line, the largest bevel, waterlines, the design waterline and optional strongback legs or half moulds. + `--profile` adds the backbone and `--transom X,RAKE` a raked transom drawn true in its own plane. A PDF is tiled + over A4, A3, Letter or any paper size with join marks, a map page and two 100 mm check rules, or put one pattern + on each page (`--single-page`); `.svg` and `.dxf` (millimetres, layered) give one drawing. The patterns are + checked against closed-form shapes (cylinder, cone, box, raked plane) and against poppler's rendering of the + PDF (100 mm drew as 1000 px at 254 dpi). Nobody has printed and measured a set yet, and the PDFs have not been + opened on Windows or macOS. [`docs/building.md`](docs/building.md) is the builder's guide. +- `seacraft offsets --pdf offsets.pdf [--imperial]` prints the table of offsets in millimetres above the keel, and + with `--imperial` the same tables in feet-inches-eighths. +- `seacraft offsets --json` keeps its standard output to the JSON table when it also writes `--pdf`, `--html` or + `--csv`: the "wrote" lines now go to standard error. +- GUI: File ▸ Builder templates… previews the page count on a background job and exports the patterns; File ▸ + Export ▸ Offsets table PDF…. The control channel gains `build.templates`, `model.export` format `offsets_pdf` + (with `stations`, `waterline_steps`, `buttock_steps`, `level`, `imperial`) and `ui.set` `templates`. +- MCP: `export_templates` and `export_offsets_pdf`, headless and connected, and a `seacraft-boatbuilding` skill + that has the agent ask for the skin thickness and relay every warning. +- The DejaVu fonts moved into the core crate; PDFs embed a subset of DejaVu Sans. No change to the GUI's text. +- Fix: cutting a hull into sections lost the last point of the first closed loop and could report a later loop as + open. This changes computed numbers where a section has closed loops. On the example hulls: the Wigley hull's + midship section area and midship coefficient rise 9.5 % (Cm 0.6066 to 0.6639; the Wigley form's own value is + 2/3) and its prismatic coefficient falls 8.6 % (0.7286 to 0.6656, also 2/3 for the form); the demo hull's + section area at its transom falls 1.0 %, so `seacraft sac` gives Cp 0.7097 instead of 0.7029. Every other + quantity `hydro` and `sac` report for the seven example hulls moved by less than 0.01 %, and the demo-hull + reference numbers checked in CI did not change. + ## 0.3.0 (2026-11-13) 0.3.0-beta.1 with one fix: diff --git a/DISCLAIMER.md b/DISCLAIMER.md index 1a00b81..437350f 100644 --- a/DISCLAIMER.md +++ b/DISCLAIMER.md @@ -15,6 +15,11 @@ own earlier output to catch changes. [`docs/oracles.md`](docs/oracles.md) says w from. Passing those tests shows that the code agrees with those cases, not that its results are right for a real vessel. +Building patterns (station moulds, profile and transom) and the printed table of offsets are geometry the +program computed from the hull. They have not been compared with a built boat, and no printed set has been +measured. A builder must print at 100 %, measure the printed calibration rules, and check dimensions against +the design before cutting anything. + Results can be wrong, including results that look plausible. A qualified person must check them independently before anyone relies on them for a design, construction, operating or safety decision. diff --git a/NOTICE b/NOTICE index 4f886d2..45234ac 100644 --- a/NOTICE +++ b/NOTICE @@ -32,11 +32,13 @@ from solidcore's crates/solidcore/src/nurbs/srf_fit.rs, licensed under the Apach the Apache License, Version 2.0, except where otherwise noted. Fonts -The desktop GUI embeds DejaVu Sans and DejaVu Sans Mono 2.37, unmodified, from -crates/seacraft-gui/assets/fonts. They are not under the GPL. Bitstream Vera Fonts: Copyright (c) -2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc. Arev -Fonts: Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. DejaVu changes are in the public -domain. The licence is in crates/seacraft-gui/assets/fonts/LICENSE-DejaVu.txt. +The program embeds DejaVu Sans and DejaVu Sans Mono 2.37 from crates/seacraft-core/assets/fonts. The +desktop GUI uses them unmodified. PDF exports embed a subset of DejaVu Sans: the glyphs the document +uses, with the font's own name table and copyright notice. They are not under the GPL. Bitstream +Vera Fonts: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark +of Bitstream, Inc. Arev Fonts: Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. DejaVu +changes are in the public domain. The licence is in crates/seacraft-core/assets/fonts/LICENSE- +DejaVu.txt. Dependencies Dependency licences are in THIRD-PARTY-LICENSES.txt: a list in this repository, and the full texts diff --git a/README.md b/README.md index b28e735..c7eb991 100644 --- a/README.md +++ b/README.md @@ -569,6 +569,26 @@ surfaces (entity 128) across the resampled starboard and port station curves, and `export::threemf` (`-o hull.3mf`) writes a welded, watertight 3MF 1.3 archive in metres. +### Building patterns + +`seacraft templates` cuts full-size station moulds from the refined hull, and +optionally the profile (backbone) and a raked transom drawn true in its own +plane. Each section is moved inward by the skin thickness (`--skin-mm`), by +`t / cos α` where the hull meets the plane at α off square, so the outside of +the planking lands on the designed surface; a piece of the section the skin +consumes at a corner is dropped, as an inward offset does. Each sheet names the +face of the mould that goes on the station line, the largest bevel and the +longest straight piece of mesh, and can carry strongback legs, waterlines and +the design waterline. The PDF writer is the project's own (no dependency): it +tiles a pattern over A4, A3 or Letter with join marks, a map page and two +100 mm check rules, or puts one pattern on each page for a plotter; SVG and DXF +(millimetres, `CUT`/`MARK`/`REFERENCE`/`TEXT` layers) are one drawing. +`seacraft offsets --pdf` prints the table of offsets in millimetres, with +feet-inches-eighths beside it on `--imperial`. The desktop app has the same +under File ▸ Builder templates…, and the MCP server as `export_templates` and +`export_offsets_pdf`. [`docs/building.md`](docs/building.md) is the builder's +guide. + --- ## 3. Command line @@ -583,7 +603,11 @@ seacraft sac hull.fef [--stations N] [--csv out.csv] [--svg sac.svg] [--js seacraft bonjean hull.fef [--stations N] [--drafts T1,T2,...] [--draft-steps N] [--csv out.csv] [--svg bonjean.svg] [--json] seacraft offsets hull.fef [--stations N] [--waterlines h1,h2,...] [--buttocks y1,y2,...] - [--csv out.csv] [--html offsets.html] [--json] + [--csv out.csv] [--html offsets.html] [--pdf offsets.pdf [--imperial]] [--json] +seacraft templates hull.fef -o moulds.pdf|.svg|.dxf [--stations N | --spacing M | --at x1,x2,...] + [--skin-mm MM] [--half] [--strongback Z] [--waterlines h1,h2,...] + [--profile] [--transom X,RAKE] [--paper a4|a3|letter|WxH] [--single-page] + [--margin-mm MM] [--overlap-mm MM] [--scale 1:N] [--imperial] [--json] seacraft transform hull.fef -o scaled.fef [--lwl L] [--bwl B] [--draft T] [--scale sx,sy,sz] [--translate dx,dy,dz] [--mirror x|y|z] [--json] seacraft resistance hull.fef [--method holtrop|michell|dsyhs] [--cat-spacing S] @@ -752,6 +776,9 @@ software. Comparing them with published data is the most useful contribution any and hydrostatics docks, file actions) is implemented and tested headlessly. * DXF export of the linesplan (2D views plus 3D curve copies) is implemented and audit-clean under `ezdxf`. +* Building patterns are checked against closed-form shapes and against + poppler's rendering of the PDF; nobody has yet measured a printed set or built + on one, and the PDFs have not been opened on Windows or macOS. * Zebra / curvature / draft analysis and a CPU z-buffer renderer with PNG output are implemented, in the CLI and in all four GUI viewports. Curvature combs are drawn both on the linesplan (`--combs`, SVG and DXF) and in the four @@ -773,7 +800,8 @@ software. Comparing them with published data is the most useful contribution any `save_hull`, `move_point`, `set_crease`, `split_face`, `insert_edge`, `transform_hull`, `hydro_compute`, `hydro_table`, `stability_cross_curves`, `bonjean_curves`, `table_of_offsets`, `unfold_plate`, `resistance_curve`, - `planing_curve`, `trim_scan`, `optimize_hull`, `export_linesplan` and `render_png`. + `planing_curve`, `trim_scan`, `optimize_hull`, `export_linesplan`, `export_templates`, + `export_offsets_pdf` and `render_png`. Agent skills that teach an AI agent to use these tools well (hull design, fairing, stability checks) are in [`skills/`](skills/README.md), with install steps and the CI test that keeps them in step with the tools. diff --git a/ROADMAP.md b/ROADMAP.md index 3830b5d..74cdaca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,6 +14,8 @@ see [`DISCLAIMER.md`](DISCLAIMER.md). - Resistance by Holtrop-Mennen, Michell thin-ship theory and Savitsky; a hull optimiser. - Strip-theory heave and pitch motions, irregular head seas, MSI. - Lines plans (SVG, DXF, IGES), mesh exports (OBJ, STL, 3MF, PLY, HTML), an HTML design report. +- Full-size building patterns: station moulds with the skin deducted, profile and raked transom, as a PDF + tiled over ordinary paper, one page a pattern, SVG or DXF; the table of offsets as a PDF. - A command line, a loopback control channel, and an MCP server for AI agents. ## Next @@ -81,7 +83,7 @@ takes about 140 ms to build on the window thread and 12 ms to upload. ### M5: Binaries - [x] Replace egui's default fonts, whose `Ubuntu-Light.ttf` is under the Ubuntu Font Licence, with DejaVu - (`crates/seacraft-gui/assets/fonts`, `NOTICE`). + (`crates/seacraft-core/assets/fonts`, `NOTICE`). - [x] Ship full licence texts with binaries: `cargo xtask dist` writes them from the crates that are compiled in. - [x] Add release packaging: a version tag builds a Linux archive, AppImage and Flatpak bundle, a Windows zip and macOS disk images, then creates a draft release (`.github/workflows/release.yml`, `packaging/`). @@ -89,6 +91,23 @@ takes about 140 ms to build on the window thread and 12 ms to upload. - [x] Decide on publishing to crates.io: not now. The people a release is for download a program, and the library API is not meant to be stable yet. Flathub waits too, since it must build from source. +### M6: Builder's toolkit + +- [x] Station moulds from the refined hull, with the skin deducted along the surface normal, mould face and + bevel on each sheet, strongback legs, half moulds (`crates/seacraft-core/src/templates.rs`). +- [x] Profile (backbone) and raked transom patterns. +- [x] A dependency-free PDF writer with an embedded DejaVu subset, tiled over A4, A3 or Letter with join marks, + a map page and 100 mm check rules, or one page a pattern; SVG and DXF in millimetres (`sheet.rs`, `pdf.rs`). +- [x] The table of offsets as a PDF, metric with feet-inches-eighths beside it. +- [x] The same in the CLI (`seacraft templates`, `offsets --pdf`), the GUI (File ▸ Builder templates…), the + control channel (`build.templates`) and MCP (`export_templates`, `export_offsets_pdf`), with a boatbuilding + skill. +- [ ] Print the calibration page and one station and measure them. +- [ ] Open the PDFs on Windows and macOS viewers. +- [ ] Plywood panels: developed shapes of hull panels for stitch-and-glue, with their joins. +- [ ] Materials and weight: skin area, plywood sheets, glass and resin, and the weight they add. +- [ ] A build pack: patterns, offsets, panels and a cutting list in one document. + ## Gap review | Area | Today | Missing | diff --git a/crates/seacraft-cli/src/main.rs b/crates/seacraft-cli/src/main.rs index 2f965fe..aac4faa 100644 --- a/crates/seacraft-cli/src/main.rs +++ b/crates/seacraft-cli/src/main.rs @@ -86,6 +86,8 @@ enum Command { Seakeeping(SeakeepingArgs), /// Linesplan: stations, buttocks and waterlines as SVG or DXF Linesplan(LinesplanArgs), + /// Full-size building patterns: station moulds, profile and transom as PDF, DXF or SVG + Templates(TemplatesArgs), /// Design dossier: self-contained HTML report with particulars, linesplan, hydrostatics, SAC, stability and resistance Report(ReportArgs), /// Render a preview PNG, including zebra, curvature and draft analysis @@ -429,6 +431,12 @@ struct OffsetsArgs { /// Write the HTML table to this path #[arg(long)] html: Option, + /// Write the tables as a printable PDF, in millimetres, to this path + #[arg(long)] + pdf: Option, + /// Add the tables in feet, inches and eighths to the PDF + #[arg(long)] + imperial: bool, /// Emit JSON instead of a table #[arg(long)] json: bool, @@ -1282,6 +1290,70 @@ struct LinesplanArgs { subdiv: SubdivArgsCli, } +#[derive(Args, Debug)] +struct TemplatesArgs { + /// Input .fef file + file: PathBuf, + /// Output path: `.pdf` (tiled over paper, or one page a pattern), `.svg` or `.dxf` + #[arg(short, long)] + output: PathBuf, + /// Number of stations, at the middles of equal slices of the hull [default: 11] + #[arg(long, conflicts_with_all = ["spacing", "at"])] + stations: Option, + /// A station at every multiple of this many metres of x inside the hull + #[arg(long, value_name = "METRES", conflicts_with = "at")] + spacing: Option, + /// Stations at these x positions in metres, comma separated + #[arg(long, value_name = "X,X,...")] + at: Option, + /// Skin thickness to deduct, in millimetres: plank, plywood or strip plus glass + #[arg( + long, + value_name = "MM", + default_value_t = 0.0, + allow_negative_numbers = true + )] + skin_mm: f64, + /// Draw the starboard half only, to be flipped about the centreline + #[arg(long)] + half: bool, + /// Strongback level, model z in metres: open sections get legs up to it + #[arg(long, value_name = "Z", allow_negative_numbers = true)] + strongback: Option, + /// Waterline heights above the keel to mark, metres, comma separated [default: 6 over the depth] + #[arg(long, value_name = "M,M,...")] + waterlines: Option, + /// Add the profile (backbone) pattern + #[arg(long)] + profile: bool, + /// Add a transom pattern: where the plane meets the baseline and how far its top leans aft + #[arg(long, value_name = "X,RAKE_DEG", allow_negative_numbers = true)] + transom: Option, + /// Paper the PDF is tiled over: a4, a3, letter or WxH in millimetres [default: a4] + #[arg(long)] + paper: Option, + /// One page a pattern, sized to fit it, for a plotter or print shop (PDF only) + #[arg(long)] + single_page: bool, + /// The strip around each page a printer cannot reach, millimetres [default: 10] + #[arg(long, value_name = "MM", allow_negative_numbers = true)] + margin_mm: Option, + /// How much neighbouring tiles share, millimetres [default: 10] + #[arg(long, value_name = "MM", allow_negative_numbers = true)] + overlap_mm: Option, + /// Drawing scale, as 1:N or one number + #[arg(long, default_value = "1:1")] + scale: String, + /// Add imperial readings beside the metric ones + #[arg(long)] + imperial: bool, + /// Emit JSON instead of a table + #[arg(long)] + json: bool, + #[command(flatten)] + subdiv: SubdivArgsCli, +} + fn main() { if let Err(e) = run() { eprintln!("error: {e:#}"); @@ -1315,6 +1387,7 @@ fn run() -> Result<()> { Command::Damage(a) => cmd_damage(a), Command::Seakeeping(a) => cmd_seakeeping(a), Command::Linesplan(a) => cmd_linesplan(a), + Command::Templates(a) => cmd_templates(a), Command::Report(a) => cmd_report(a), Command::Render(a) => cmd_render(a), Command::Mcp(a) => cmd_mcp(a), @@ -2043,6 +2116,9 @@ fn parse_ordinates(text: &str, key: &str) -> Result> { } fn cmd_offsets(a: OffsetsArgs) -> Result<()> { + if a.imperial && a.pdf.is_none() { + bail!("--imperial applies to --pdf: the other outputs are in metres"); + } let s = load(&a.file)?; let opts = HydroOptions::from_surface( &s, @@ -2062,11 +2138,26 @@ fn cmd_offsets(a: OffsetsArgs) -> Result<()> { }; let table = offsets::table_of_offsets(&hull, a.stations, &waterlines, &buttocks); let title = format!("{} - table of offsets", s.settings.name); + // With --json the standard output is the table alone: what was written goes to stderr. + let written = |line: String| { + if a.json { + eprintln!("{line}"); + } else { + println!("{line}"); + } + }; + + if let Some(path) = &a.pdf { + let doc = offsets::pdf(&table, &title, a.imperial).map_err(anyhow::Error::msg)?; + seacraft_core::io::write_atomic(path, doc.bytes) + .with_context(|| format!("cannot write {}", path.display()))?; + written(format!("wrote {}", path.display())); + } if let Some(path) = &a.html { seacraft_core::io::write_atomic(path, offsets::html(&table, &title)) .with_context(|| format!("cannot write {}", path.display()))?; - println!("wrote {}", path.display()); + written(format!("wrote {}", path.display())); } if let Some(path) = &a.csv { // Long format: one row per (station, waterline, buttock) reading would @@ -2086,18 +2177,18 @@ fn cmd_offsets(a: OffsetsArgs) -> Result<()> { } seacraft_core::io::write_atomic(path, text) .with_context(|| format!("cannot write {}", path.display()))?; - println!( + written(format!( "wrote {} ({} stations x {} waterlines)", path.display(), table.stations.len(), table.waterlines_m.len() - ); + )); } if a.json { println!("{}", serde_json::to_string_pretty(&table)?); return Ok(()); } - if a.html.is_some() && a.csv.is_none() { + if (a.html.is_some() || a.pdf.is_some()) && a.csv.is_none() { println!( "{} stations x {} waterlines, widest half-breadth {:.3} m", table.stations.len(), @@ -2963,6 +3054,168 @@ fn cmd_unfold(a: UnfoldArgs) -> Result<()> { Ok(()) } +/// How a page line names the paper: `A4`, `A3`, `Letter`, or its size. +fn paper_name(paper: seacraft_core::sheet::Paper) -> String { + use seacraft_core::sheet::Paper; + match paper { + p if p == Paper::A4 => "A4".into(), + p if p == Paper::A3 => "A3".into(), + p if p == Paper::LETTER => "Letter".into(), + p => format!("{} x {} mm", p.width_mm, p.height_mm), + } +} + +fn cmd_templates(a: TemplatesArgs) -> Result<()> { + use seacraft_core::sheet::{PageSetup, Paper}; + use seacraft_core::templates::{self, Format, MouldFace, SetOptions, StationChoice, Transom}; + use seacraft_core::units; + + // Everything the user typed is checked before the hull is refined and cut. + let extension = a + .output + .extension() + .map(|e| e.to_string_lossy().to_ascii_lowercase()) + .unwrap_or_default(); + if matches!(extension.as_str(), "svg" | "dxf") { + let given: Vec<&str> = [ + (a.paper.is_some(), "--paper"), + (a.single_page, "--single-page"), + (a.margin_mm.is_some(), "--margin-mm"), + (a.overlap_mm.is_some(), "--overlap-mm"), + ] + .into_iter() + .filter_map(|(given, name)| given.then_some(name)) + .collect(); + if !given.is_empty() { + bail!( + "{} apply to PDF only: an SVG or a DXF is one drawing at the scale asked for", + given.join(", ") + ); + } + } + let format = Format::from_path(&a.output, a.single_page).map_err(anyhow::Error::msg)?; + let max_mm = templates::MAX_SKIN_M * 1000.0; + if !a.skin_mm.is_finite() || !(0.0..=max_mm).contains(&a.skin_mm) { + bail!("--skin-mm must be from 0 to {max_mm} mm, not {}", a.skin_mm); + } + let scale = units::parse_scale(&a.scale).map_err(|e| anyhow::anyhow!("--scale: {e}"))?; + let paper = match &a.paper { + Some(text) => Paper::parse(text).map_err(|e| anyhow::anyhow!("--paper: {e}"))?, + None => Paper::A4, + }; + let defaults = PageSetup::default(); + let setup = PageSetup { + paper, + margin_mm: a.margin_mm.unwrap_or(defaults.margin_mm), + overlap_mm: a.overlap_mm.unwrap_or(defaults.overlap_mm), + scale, + imperial: a.imperial, + title: String::new(), + }; + setup.check().map_err(anyhow::Error::msg)?; + let stations = match (&a.stations, &a.spacing, &a.at) { + (_, Some(spacing), _) => StationChoice::Spacing(*spacing), + (_, _, Some(at)) => StationChoice::At(parse_list(at).context("--at")?), + (count, _, _) => StationChoice::Count(count.unwrap_or(11)), + }; + let transom = match &a.transom { + None => None, + Some(text) => match parse_list(text).context("--transom")?.as_slice() { + [x, rake_deg] => Some(Transom { + x: *x, + rake_deg: *rake_deg, + }), + _ => bail!("--transom takes X,RAKE_DEG: where the plane meets the baseline and its rake aft, not `{text}`"), + }, + }; + let waterlines_m = match &a.waterlines { + Some(text) => Some(parse_ordinates(text, "--waterlines")?), + None => None, + }; + + let s = load(&a.file)?; + let opts = SetOptions { + subdivision: SubdivisionOptions { + level: a.subdiv.level, + mode: a.subdiv.mode.into(), + }, + stations, + skin_m: a.skin_mm / 1000.0, + half: a.half, + strongback_z: a.strongback, + waterlines_m, + profile: a.profile, + transom, + imperial: a.imperial, + }; + let set = templates::template_set(&s, &opts).map_err(anyhow::Error::msg)?; + let written = templates::write(&set, format, &setup).map_err(anyhow::Error::msg)?; + seacraft_core::io::write_atomic(&a.output, &written.bytes) + .with_context(|| format!("cannot write {}", a.output.display()))?; + + let missing: Vec = written.missing.iter().map(char::to_string).collect(); + if a.json { + let v = serde_json::json!({ + "path": a.output.display().to_string(), + "format": format.name(), + "pages": written.pages, + "skin_mm": a.skin_mm, + "scale": units::scale_label(scale), + "sheets": set.rows, + "warnings": set.warnings, + "missing_characters": missing, + }); + println!("{}", serde_json::to_string_pretty(&v)?); + return Ok(()); + } + + println!( + "{:<12} {:>8} {:>18} {:>8} {:>11}", + "sheet", "x [m]", "half-breadth [mm]", "face", "bevel [deg]" + ); + for row in &set.rows { + let x = row.x.map_or_else(|| "-".to_owned(), |x| format!("{x:.3}")); + let face = match row.face { + Some(MouldFace::Forward) => "forward", + Some(MouldFace::Aft) => "aft", + Some(MouldFace::Either) => "either", + None => "-", + }; + println!( + "{:<12} {:>8} {:>18.0} {:>8} {:>11.1}", + row.sheet, + x, + row.half_breadth_m * 1000.0, + face, + row.max_bevel_deg + ); + } + let scale_label = units::scale_label(scale); + match (format, written.pages) { + (Format::PdfTiled, Some(pages)) => { + println!("{pages} pages, {}, scale {scale_label}", paper_name(paper)) + } + (_, Some(pages)) => println!("{pages} pages, one a pattern, scale {scale_label}"), + (_, None) => println!("one drawing, scale {scale_label}"), + } + if a.skin_mm > 0.0 { + println!("skin {} mm deducted", a.skin_mm); + } else { + println!("no skin deducted: the cut line is the hull surface"); + } + for warning in &set.warnings { + eprintln!("warning: {warning}"); + } + if !missing.is_empty() { + println!( + "the font has no letter for {}: each is printed as ?", + missing.join(" ") + ); + } + println!("wrote {}", a.output.display()); + Ok(()) +} + fn cmd_linesplan(a: LinesplanArgs) -> Result<()> { let s = load(&a.file)?; let opts = linesplan::LinesplanOptions { diff --git a/crates/seacraft-cli/src/mcp.rs b/crates/seacraft-cli/src/mcp.rs index af75331..e18ade6 100644 --- a/crates/seacraft-cli/src/mcp.rs +++ b/crates/seacraft-cli/src/mcp.rs @@ -107,6 +107,8 @@ seakeeping_raos gives heave and pitch RAOs in regular head seas; seakeeping_irre wave spectrum for significant motions, relative motion, accelerations and MSI. optimize_hull {speed_kn} reshapes the hull (Cp, LCB, L/B) to cut resistance at a speed, holding displacement; it edits the model. Write results with export_linesplan (.svg/.dxf/.igs) and save_hull (.fef). +For building: export_templates writes full-size station moulds, profile and transom patterns (.pdf/.svg/.dxf) with +the skin deducted (ask the builder for skin_mm; it defaults to none), export_offsets_pdf the table of offsets. Project data: project_get and project_set read and write the design sidecar (loading conditions, compartments, damage cases, windage). Evaluate loading conditions with loading_condition {name} (or inline case / case_path). Evaluate damage stability with damage_stability {name} (or inline case / case_path). @@ -850,6 +852,8 @@ impl McpServer { "trim_scan" => self.trim_scan(a), "optimize_hull" => self.optimize_hull(a), "export_linesplan" => self.export_linesplan(a), + "export_templates" => self.export_templates(a), + "export_offsets_pdf" => self.export_offsets_pdf(a), "render_png" => self.render_png(a), "design_report" => self.design_report(a), other => Err(format!("unknown tool `{}`", clip(other, 100))), @@ -2122,6 +2126,52 @@ impl McpServer { })) } + fn export_templates(&mut self, a: &Map) -> Result { + let req = templates_args(a, self.opts.subdivision)?; + let set = seacraft_core::templates::template_set(&self.surface, &req.set) + .map_err(|e| format!("export_templates: {e}"))?; + let written = seacraft_core::templates::write(&set, req.format, &req.setup) + .map_err(|e| format!("export_templates: {e}"))?; + seacraft_core::io::write_atomic(&req.path, &written.bytes) + .map_err(|e| format!("{}: {e}", req.path.display()))?; + let missing: Vec = written.missing.iter().map(char::to_string).collect(); + Ok(json!({ + "path": req.path.to_string_lossy(), + "format": req.format.name(), + "bytes": written.bytes.len(), + "pages": written.pages, + "sheets": set.rows, + "warnings": set.warnings, + "missing_characters": missing, + "backend": "headless", + })) + } + + fn export_offsets_pdf(&mut self, a: &Map) -> Result { + let req = offsets_pdf_args(a)?; + let mut subdiv = self.opts.subdivision; + subdiv.level = req.level.unwrap_or(subdiv.level); + let hull = hydro::Hull::new(&self.surface, subdiv); + let waterlines = offsets::default_waterlines(&hull, req.waterline_steps); + let buttocks = offsets::default_buttocks(&hull, req.buttock_steps); + let table = offsets::table_of_offsets(&hull, req.stations, &waterlines, &buttocks); + let title = format!("{} - table of offsets", self.surface.settings.name); + let doc = offsets::pdf(&table, &title, req.imperial) + .map_err(|e| format!("export_offsets_pdf: {e}"))?; + seacraft_core::io::write_atomic(&req.path, &doc.bytes) + .map_err(|e| format!("{}: {e}", req.path.display()))?; + Ok(json!({ + "path": req.path.to_string_lossy(), + "format": "offsets_pdf", + "bytes": doc.bytes.len(), + "pages": doc.pages, + "stations": req.stations, + "imperial": req.imperial, + "level": subdiv.level, + "backend": "headless", + })) + } + fn render_png(&mut self, a: &Map) -> Result { let path = str_arg(a, "path") .filter(|p| !p.trim().is_empty()) @@ -2404,6 +2454,205 @@ fn positives(v: &Value, key: &str) -> Result, String> { Ok(out) } +/// An optional finite number: absent or null is `None`, any other type an error. +fn opt_number(a: &Map, key: &str) -> Result, String> { + match a.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_f64() + .filter(|n| n.is_finite()) + .map(Some) + .ok_or_else(|| format!("`{key}` must be a finite number")), + } +} + +/// An optional finite number inside `range`. +fn opt_number_in( + a: &Map, + key: &str, + range: std::ops::RangeInclusive, +) -> Result, String> { + match opt_number(a, key)? { + Some(n) if !range.contains(&n) => Err(format!( + "`{key}` must be from {} to {}", + range.start(), + range.end() + )), + n => Ok(n), + } +} + +fn opt_bool(a: &Map, key: &str) -> Result, String> { + match a.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_bool() + .map(Some) + .ok_or_else(|| format!("`{key}` must be true or false")), + } +} + +fn opt_str<'a>(a: &'a Map, key: &str) -> Result, String> { + match a.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_str() + .map(Some) + .ok_or_else(|| format!("`{key}` must be a string")), + } +} + +/// What `export_templates` was asked for, checked. +struct TemplatesArgs { + path: PathBuf, + format: seacraft_core::templates::Format, + set: seacraft_core::templates::SetOptions, + setup: seacraft_core::sheet::PageSetup, +} + +/// Read and check `export_templates`'s arguments, which are `build.templates`'s. `subdivision` +/// is what an omitted `level` means. +fn templates_args( + a: &Map, + subdivision: SubdivisionOptions, +) -> Result { + use seacraft_core::sheet::{PageSetup, Paper}; + use seacraft_core::templates::{ + Format, SetOptions, StationChoice, Transom, MAX_SKIN_M, MAX_STATIONS, + }; + let err = |e: String| format!("export_templates: {e}"); + let path = str_arg(a, "path") + .filter(|p| !p.trim().is_empty()) + .ok_or("export_templates: give `path` (.pdf, .svg or .dxf)")?; + let path = PathBuf::from(path.trim()); + let given: Vec<&str> = ["stations", "spacing", "at"] + .into_iter() + .filter(|k| !matches!(a.get(*k), None | Some(Value::Null))) + .collect(); + if given.len() > 1 { + return Err(err(format!( + "give one of `stations`, `spacing` and `at`, not `{}`", + given.join("` and `") + ))); + } + let stations = if let Some(n) = opt_number_in(a, "stations", 2.0..=MAX_STATIONS as f64)? { + if n.fract() != 0.0 { + return Err(err("`stations` must be a whole number".into())); + } + StationChoice::Count(n as usize) + } else if let Some(spacing) = opt_number_in(a, "spacing", 0.001..=1.0e4)? { + StationChoice::Spacing(spacing) + } else if let Some(v) = a.get("at").filter(|v| !v.is_null()) { + StationChoice::At(numbers(v, "at")?) + } else { + StationChoice::Count(11) + }; + let skin_mm = opt_number_in(a, "skin_mm", 0.0..=MAX_SKIN_M * 1000.0)?.unwrap_or(0.0); + let transom = match ( + opt_number(a, "transom_x")?, + opt_number(a, "transom_rake_deg")?, + ) { + (Some(x), Some(rake_deg)) => Some(Transom { x, rake_deg }), + (None, None) => None, + _ => { + return Err(err( + "`transom_x` and `transom_rake_deg` go together: give both or neither".into(), + )) + } + }; + let waterlines_m = match a.get("waterlines").filter(|v| !v.is_null()) { + Some(v) => Some(positives(v, "waterlines")?), + None => None, + }; + let paper = match opt_str(a, "paper")? { + Some(text) => Paper::parse(text).map_err(|e| err(format!("`paper`: {e}")))?, + None => Paper::A4, + }; + let scale = match opt_str(a, "scale")? { + Some(text) => { + seacraft_core::units::parse_scale(text).map_err(|e| err(format!("`scale`: {e}")))? + } + None => 1.0, + }; + let imperial = opt_bool(a, "imperial")?.unwrap_or(false); + let defaults = PageSetup::default(); + let setup = PageSetup { + paper, + margin_mm: opt_number_in(a, "margin_mm", 0.0..=100.0)?.unwrap_or(defaults.margin_mm), + overlap_mm: opt_number_in(a, "overlap_mm", 0.0..=100.0)?.unwrap_or(defaults.overlap_mm), + scale, + imperial, + title: String::new(), + }; + setup.check().map_err(err)?; + let single_page = opt_bool(a, "single_page")?.unwrap_or(false); + let pdf = path + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("pdf")); + let format = Format::from_path(&path, single_page && pdf).map_err(err)?; + let level = match a.get("level") { + None | Some(Value::Null) => subdivision.level, + Some(_) => level_arg(a)?, + }; + let set = SetOptions { + subdivision: SubdivisionOptions { + level, + mode: subdivision.mode, + }, + stations, + skin_m: skin_mm / 1000.0, + half: opt_bool(a, "half")?.unwrap_or(false), + strongback_z: opt_number(a, "strongback_z")?, + waterlines_m, + profile: opt_bool(a, "profile")?.unwrap_or(false), + transom, + imperial, + }; + Ok(TemplatesArgs { + path, + format, + set, + setup, + }) +} + +/// What `export_offsets_pdf` was asked for, checked. +struct OffsetsPdfArgs { + path: PathBuf, + stations: usize, + waterline_steps: usize, + buttock_steps: usize, + level: Option, + imperial: bool, +} + +fn offsets_pdf_args(a: &Map) -> Result { + let path = str_arg(a, "path") + .filter(|p| !p.trim().is_empty()) + .ok_or("export_offsets_pdf: give `path` (.pdf)")?; + let path = PathBuf::from(path.trim()); + if !path + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("pdf")) + { + return Err(format!( + "export_offsets_pdf: `{}` must end in .pdf", + clip(&path.to_string_lossy(), 200) + )); + } + Ok(OffsetsPdfArgs { + stations: count_arg(a, "stations", 21, 2, 200)?, + waterline_steps: count_arg(a, "waterline_steps", 6, 1, 50)?, + buttock_steps: count_arg(a, "buttock_steps", 4, 1, 50)?, + level: match a.get("level") { + None | Some(Value::Null) => None, + Some(_) => Some(level_arg(a)?), + }, + imperial: opt_bool(a, "imperial")?.unwrap_or(false), + path, + }) +} + // ------------------------------------------------------ tool definitions -- fn string(desc: &str) -> Value { @@ -3039,6 +3288,61 @@ pub fn tool_definitions() -> Vec { ), Effect::Files, ), + tool( + "export_templates", + "Export builder templates", + "Write full-size building patterns: a station mould at each station, and optionally the profile \ + (backbone) and a raked transom, each cut from the refined hull and moved in by the skin thickness. \ + The extension picks the format: `.pdf` (tiled over ordinary paper with alignment marks and 100 mm \ + check rules, or one page a pattern with `single_page`), `.svg` or `.dxf` (one drawing, CUT and MARK \ + layers). `skin_mm` defaults to 0, no deduction: the cut line is then the hull surface; ask the \ + builder for the plank, plywood or strip-plus-glass thickness. Returns the pages, one row per sheet \ + (station x, half-breadth, which mould face goes on the line, bevel) and the warnings. The patterns \ + are not validated: the builder must print at 100 % and measure the rules before cutting.", + obj( + json!({ + "path": string("Output path; .pdf, .svg or .dxf"), + "stations": integer("Number of stations at the middles of equal slices (default 11; give one of stations, spacing and at)"), + "spacing": number("A station at every multiple of this many metres of x inside the hull"), + "at": {"type": "array", "items": {"type": "number"}, "description": "Stations at these x positions in metres"}, + "skin_mm": number("Skin thickness to deduct in millimetres, 0-200 (default 0: none)"), + "half": boolean("Draw the starboard half only, to be flipped about the centreline"), + "strongback_z": number("Strongback level, model z in metres, at or above the sheer: open sections get legs up to it"), + "waterlines": {"type": "array", "items": {"type": "number"}, "description": "Waterline heights above the keel to mark, metres (default: six over the depth)"}, + "profile": boolean("Add the profile (backbone) pattern"), + "transom_x": number("Transom: where its plane meets the baseline, model x in metres (with transom_rake_deg)"), + "transom_rake_deg": number("Transom: how far its top leans aft of upright, degrees (with transom_x)"), + "paper": string("Paper the PDF is tiled over: a4, a3, letter or WxH in millimetres (default a4)"), + "single_page": boolean("One page a pattern, sized to fit it, for a plotter (PDF only)"), + "margin_mm": number("Strip around each page a printer cannot reach, mm (default 10)"), + "overlap_mm": number("How much neighbouring tiles share, mm (default 10)"), + "scale": string("Drawing scale, as 1:N or one number (default 1:1)"), + "imperial": boolean("Add feet and inches beside the metric readings"), + "level": integer("Subdivision level 0-6 (default: the server's)"), + }), + &["path"], + ), + Effect::Files, + ), + tool( + "export_offsets_pdf", + "Export the table of offsets as PDF", + "Write the table of offsets as a printable PDF: half-breadths on waterlines and heights on buttocks, \ + in millimetres above the keel, and with `imperial` the same tables in feet-inches-eighths after them. \ + Returns the pages and the file size.", + obj( + json!({ + "path": string("Output path; .pdf"), + "stations": integer("Stations, slice midpoints (default 21)"), + "waterline_steps": integer("Waterlines spread over the depth (default 6)"), + "buttock_steps": integer("Buttocks spread over the half-breadth (default 4)"), + "imperial": boolean("Add the tables in feet, inches and eighths"), + "level": integer("Subdivision level 0-6 (default: the server's)"), + }), + &["path"], + ), + Effect::Files, + ), tool( "render_png", "Render a preview", @@ -3232,8 +3536,8 @@ fn text_result(v: &Value, is_error: bool) -> Value { /// `transform_hull` -> `model.transform`, `unfold_plate` -> `curves.unfold`, /// `hydro_table` -> `hydro.table`, `trim_scan` -> `hydro.trim`, /// `optimize_hull` -> `model.optimize`, -/// `export_linesplan` -/// -> `model.export`, `render_png` -> `ui.render`. The app's reply is returned +/// `export_linesplan` and `export_offsets_pdf` -> `model.export`, +/// `export_templates` -> `build.templates`, `render_png` -> `ui.render`. The app's reply is returned /// as the tool's result (its own field names); a failure is a tool error. pub struct Remote { addr: String, @@ -3541,6 +3845,29 @@ impl Remote { } self.request("model.export", p) } + "export_templates" => { + // Checked here as the headless tool checks it, then sent as given: the + // arguments are `build.templates`'s, with the same names. + templates_args(a, SubdivisionOptions::default())?; + self.request("build.templates", Value::Object(a.clone())) + } + "export_offsets_pdf" => { + offsets_pdf_args(a)?; + let mut p = json!({"format": "offsets_pdf"}); + for key in [ + "path", + "stations", + "waterline_steps", + "buttock_steps", + "level", + "imperial", + ] { + if let Some(v) = a.get(key).filter(|v| !v.is_null()) { + p[key] = v.clone(); + } + } + self.request("model.export", p) + } "render_png" => { let path = str_arg(a, "path") .filter(|p| !p.trim().is_empty()) @@ -3651,6 +3978,10 @@ mod tests { "seacraft-stability-check", include_str!("../../../skills/seacraft-stability-check/SKILL.md"), ), + ( + "seacraft-boatbuilding", + include_str!("../../../skills/seacraft-boatbuilding/SKILL.md"), + ), ]; let tools: Vec = tool_definitions() .iter() @@ -3850,6 +4181,8 @@ mod tests { "trim_scan", "optimize_hull", "export_linesplan", + "export_templates", + "export_offsets_pdf", "render_png", "design_report", "batch", @@ -4571,6 +4904,14 @@ mod tests { ("table_of_offsets", json!({"buttocks": [-0.5]})), ("export_linesplan", json!({"path": "/tmp/x.obj"})), ("export_linesplan", json!({"path": "/no/such/dir/x.svg"})), + ("export_templates", json!({"path": "x.png"})), + ("export_templates", json!({"path": "/no/such/dir/x.pdf"})), + ( + "export_templates", + json!({"stations": 5, "spacing": 0.5, "path": "x.pdf"}), + ), + ("export_offsets_pdf", json!({"path": "x.png"})), + ("export_offsets_pdf", json!({"path": "/no/such/dir/x.pdf"})), ("render_png", json!({"path": "/no/such/dir/x.png"})), ]; for (i, (tool, args)) in hostile.into_iter().enumerate() { @@ -4717,6 +5058,8 @@ mod tests { "transform_hull", "optimize_hull", "export_linesplan", + "export_templates", + "export_offsets_pdf", "render_png", ] { assert!(!read_only.contains(&n), "{n} is not read-only"); @@ -5784,4 +6127,177 @@ mod tests { ); assert_eq!(seen[1], json!({"path": "/tmp/lines.svg", "format": "svg"})); } + + #[test] + fn export_templates_schema_lists_its_arguments() { + let mut s = McpServer::new(); + let list = request(&mut s, 1, "tools/list", json!({})); + let tool = list["result"]["tools"] + .as_array() + .unwrap() + .iter() + .find(|t| t["name"] == "export_templates") + .expect("export_templates") + .clone(); + let props = tool["inputSchema"]["properties"].as_object().unwrap(); + let mut keys: Vec<&str> = props.keys().map(String::as_str).collect(); + keys.sort_unstable(); + let mut want = vec![ + "path", + "stations", + "spacing", + "at", + "skin_mm", + "half", + "strongback_z", + "waterlines", + "profile", + "transom_x", + "transom_rake_deg", + "paper", + "single_page", + "margin_mm", + "overlap_mm", + "scale", + "imperial", + "level", + ]; + want.sort_unstable(); + assert_eq!(keys, want); + assert_eq!(tool["inputSchema"]["required"], json!(["path"])); + let description = tool["description"].as_str().unwrap(); + for words in ["full-size", "defaults to 0", "not validated"] { + assert!(description.contains(words), "{words}: {description}"); + } + // A wrong type is refused by the schema, before the tool runs. + for (id, name, args) in [ + ( + 2, + "export_templates", + json!({"skin_mm": "thick", "path": "x.pdf"}), + ), + ( + 3, + "export_offsets_pdf", + json!({"imperial": "yes", "path": "x.pdf"}), + ), + ] { + let r = request( + &mut s, + id, + "tools/call", + json!({"name": name, "arguments": args}), + ); + assert_eq!(r["error"]["code"], -32602, "{r}"); + } + assert!(!std::path::Path::new("x.pdf").exists()); + } + + #[test] + fn export_templates_writes_patterns_headless() { + let mut s = McpServer::new(); + ok(&mut s, 1, "example", json!({"name": "runabout-6m"})); + let path = temp_path("pdf"); + let r = ok( + &mut s, + 2, + "export_templates", + json!({"path": path, "skin_mm": 9}), + ); + assert_eq!(r["format"], "pdf", "{r}"); + assert!(r["pages"].as_u64().unwrap() > 0, "{r}"); + assert_eq!(r["sheets"].as_array().unwrap().len(), 11, "{r}"); + assert!(r["warnings"].is_array(), "{r}"); + assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF-")); + let _ = std::fs::remove_file(&path); + // A station that misses is a warning, not a failure. + let dxf = temp_path("dxf"); + let r = ok( + &mut s, + 3, + "export_templates", + json!({"path": dxf, "at": [2.0, 999.0], "profile": true}), + ); + assert_eq!(r["sheets"].as_array().unwrap().len(), 2, "{r}"); + assert!( + r["warnings"].to_string().contains("skipped"), + "{}", + r["warnings"] + ); + let _ = std::fs::remove_file(&dxf); + } + + #[test] + fn export_offsets_pdf_writes_headless() { + let mut s = McpServer::new(); + let path = temp_path("pdf"); + let r = ok(&mut s, 1, "export_offsets_pdf", json!({"path": path})); + assert_eq!(r["pages"], 2, "{r}"); + assert!(r["bytes"].as_u64().unwrap() > 0, "{r}"); + let metric = std::fs::read(&path).unwrap(); + assert!(metric.starts_with(b"%PDF-")); + let r = ok( + &mut s, + 2, + "export_offsets_pdf", + json!({"path": path, "imperial": true, "stations": 40}), + ); + assert_eq!(r["pages"], 8, "{r}"); + let _ = std::fs::remove_file(&path); + } + + /// Connected to the app, the two tools forward to `build.templates` and to `model.export` + /// with the arguments given, after checking them as the headless tools do. + #[test] + fn connected_builder_exports_forward_their_arguments() { + use std::io::Write; + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + let mut reader = std::io::BufReader::new(stream.try_clone().expect("clone")); + let mut seen = Vec::new(); + for _ in 0..2 { + let mut line = String::new(); + reader.read_line(&mut line).expect("read"); + let v: Value = serde_json::from_str(line.trim()).expect("json"); + seen.push((v["method"].clone(), v["params"].clone())); + stream + .write_all(b"{\"ok\":true,\"result\":{\"bytes\":1}}\n") + .expect("write"); + } + seen + }); + let mut s = McpServer::connect_no_auth(&addr.to_string()).expect("connect"); + ok( + &mut s, + 1, + "export_templates", + json!({"path": "/tmp/m.pdf", "skin_mm": 6, "transom_x": 0.1, "transom_rake_deg": 12}), + ); + let err = fails( + &mut s, + 2, + "export_templates", + json!({"path": "/tmp/m.pdf", "stations": 5, "spacing": 0.5}), + ); + assert!(err.contains("spacing"), "{err}"); + ok( + &mut s, + 3, + "export_offsets_pdf", + json!({"path": "/tmp/o.pdf", "stations": 30, "imperial": true}), + ); + let seen = handle.join().expect("server thread finished"); + assert_eq!(seen[0].0, "build.templates"); + assert_eq!( + seen[0].1, + json!({"path": "/tmp/m.pdf", "skin_mm": 6, "transom_x": 0.1, "transom_rake_deg": 12}) + ); + assert_eq!(seen[1].0, "model.export"); + assert_eq!( + seen[1].1, + json!({"path": "/tmp/o.pdf", "format": "offsets_pdf", "stations": 30, "imperial": true}) + ); + } } diff --git a/crates/seacraft-cli/tests/templates.rs b/crates/seacraft-cli/tests/templates.rs new file mode 100644 index 0000000..fa5990f --- /dev/null +++ b/crates/seacraft-cli/tests/templates.rs @@ -0,0 +1,228 @@ +//! `seacraft templates` and `seacraft offsets --pdf`, run as a user runs them: in a fresh +//! directory holding the demo hull. + +use std::path::PathBuf; +use std::process::{Command, Output}; + +/// A fresh directory for one test, with `demo.fef` written into it. +fn workdir(test: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("seacraft-templates-{}-{test}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("make the test directory"); + let out = run(&dir, &["demo", "-o", "demo.fef"]); + assert!(out.status.success(), "{}", text(&out.stderr)); + dir +} + +fn run(dir: &PathBuf, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_seacraft")) + .current_dir(dir) + .args(args) + .output() + .expect("run seacraft") +} + +fn text(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).into_owned() +} + +/// Run and expect success; the standard output. +fn ok(dir: &PathBuf, args: &[&str]) -> String { + let out = run(dir, args); + assert!(out.status.success(), "{args:?}: {}", text(&out.stderr)); + text(&out.stdout) +} + +/// Run and expect failure; the standard error. +fn fails(dir: &PathBuf, args: &[&str]) -> String { + let out = run(dir, args); + assert!( + !out.status.success(), + "{args:?} should fail: {}", + text(&out.stdout) + ); + text(&out.stderr) +} + +#[test] +fn templates_writes_a_pdf_and_says_how_many_pages() { + let dir = workdir("pdf"); + let out = ok(&dir, &["templates", "demo.fef", "-o", "moulds.pdf"]); + let bytes = std::fs::read(dir.join("moulds.pdf")).expect("written"); + assert!(bytes.starts_with(b"%PDF-")); + for row in ["Station 1 ", "Station 11 "] { + assert!(out.lines().any(|l| l.starts_with(row)), "{row} in {out}"); + } + let pages = out + .lines() + .find(|l| l.ends_with(" pages, A4, scale 1:1")) + .unwrap_or_else(|| panic!("a page line in {out}")); + assert!(pages.split(' ').next().unwrap().parse::().unwrap() > 11); + assert!(out.contains("no skin deducted"), "{out}"); + assert!(out.contains("wrote moulds.pdf"), "{out}"); +} + +#[test] +fn json_reports_the_set() { + let dir = workdir("json"); + let out = ok( + &dir, + &[ + "templates", + "demo.fef", + "-o", + "m.pdf", + "--json", + "--skin-mm", + "6", + ], + ); + let v: serde_json::Value = serde_json::from_str(&out).expect("only JSON on stdout"); + assert_eq!(v["format"], "pdf"); + assert!(v["pages"].is_u64(), "{v}"); + assert_eq!(v["skin_mm"], 6.0); + assert_eq!(v["scale"], "1:1"); + assert!(v["warnings"].is_array()); + assert!(v["missing_characters"].is_array()); + let sheets = v["sheets"].as_array().unwrap(); + assert_eq!(sheets.len(), 11); + for key in ["sheet", "x", "half_breadth_m", "face", "max_bevel_deg"] { + assert!(sheets[0].get(key).is_some(), "{key} in {}", sheets[0]); + } +} + +#[test] +fn dxf_and_svg_refuse_paper_options() { + let dir = workdir("dxf"); + for option in [ + &["--paper", "a3"][..], + &["--single-page"], + &["--overlap-mm", "5"], + &["--margin-mm", "5"], + ] { + let mut args = vec!["templates", "demo.fef", "-o", "m.dxf"]; + args.extend_from_slice(option); + let err = fails(&dir, &args); + assert!(err.contains("apply to PDF"), "{option:?}: {err}"); + } + ok(&dir, &["templates", "demo.fef", "-o", "m.dxf"]); + let dxf = std::fs::read_to_string(dir.join("m.dxf")).unwrap(); + assert!(dxf.contains("CUT")); + ok( + &dir, + &["templates", "demo.fef", "-o", "m.svg", "--scale", "1:10"], + ); + assert!(std::fs::read_to_string(dir.join("m.svg")) + .unwrap() + .contains(" metric.len()); + let err = fails(&dir, &["offsets", "demo.fef", "--imperial"]); + assert!(err.contains("--imperial applies to --pdf"), "{err}"); +} + +/// With `--json` the standard output is the table alone, whatever files are written beside it; +/// what was written goes to the standard error. +#[test] +fn offsets_json_stays_json_beside_written_files() { + let dir = workdir("offsets-json"); + let out = run( + &dir, + &[ + "offsets", "demo.fef", "--json", "--pdf", "o.pdf", "--html", "o.html", "--csv", "o.csv", + ], + ); + assert!(out.status.success(), "{}", text(&out.stderr)); + let table: serde_json::Value = serde_json::from_slice(&out.stdout) + .unwrap_or_else(|e| panic!("{e}: {}", text(&out.stdout))); + assert!(table["stations"].is_array()); + let err = text(&out.stderr); + for file in ["o.pdf", "o.html", "o.csv"] { + assert!(dir.join(file).exists(), "{file}"); + assert!(err.contains(&format!("wrote {file}")), "{err}"); + } +} diff --git a/crates/seacraft-gui/assets/fonts/DejaVuSans.ttf b/crates/seacraft-core/assets/fonts/DejaVuSans.ttf similarity index 100% rename from crates/seacraft-gui/assets/fonts/DejaVuSans.ttf rename to crates/seacraft-core/assets/fonts/DejaVuSans.ttf diff --git a/crates/seacraft-gui/assets/fonts/DejaVuSansMono.ttf b/crates/seacraft-core/assets/fonts/DejaVuSansMono.ttf similarity index 100% rename from crates/seacraft-gui/assets/fonts/DejaVuSansMono.ttf rename to crates/seacraft-core/assets/fonts/DejaVuSansMono.ttf diff --git a/crates/seacraft-gui/assets/fonts/LICENSE-DejaVu.txt b/crates/seacraft-core/assets/fonts/LICENSE-DejaVu.txt similarity index 100% rename from crates/seacraft-gui/assets/fonts/LICENSE-DejaVu.txt rename to crates/seacraft-core/assets/fonts/LICENSE-DejaVu.txt diff --git a/crates/seacraft-core/src/dxf.rs b/crates/seacraft-core/src/dxf.rs index 6dac51d..a3dd957 100644 --- a/crates/seacraft-core/src/dxf.rs +++ b/crates/seacraft-core/src/dxf.rs @@ -14,6 +14,8 @@ //! //! Units: SeaCraft measures in metres, so the header declares `$INSUNITS` 6 //! (metres) and metric measurement rather than the original's millimetres. +//! [`write_dxf_in`] writes a drawing whose numbers are millimetres, for +//! full-size patterns sent to a cutting machine. use crate::math::Vec3; use std::fmt::Write as _; @@ -97,6 +99,10 @@ pub enum DxfShape { Polyline { points: Vec, }, + /// A closed outline: the last point joins the first. + ClosedPolyline { + points: Vec, + }, /// A 3D polyline (`POLYLINE` with 3D vertices), for hull curves in place. Polyline3 { points: Vec, @@ -127,7 +133,9 @@ impl DxfShape { fn sample(&self, out: &mut Vec) { match self { DxfShape::Line(a, b) => out.extend([*a, *b]), - DxfShape::Polyline { points } => out.extend(points.iter().copied()), + DxfShape::Polyline { points } | DxfShape::ClosedPolyline { points } => { + out.extend(points.iter().copied()) + } DxfShape::Polyline3 { points } => { out.extend(points.iter().map(|p| Vec2::new(p.x, p.y))) } @@ -274,9 +282,25 @@ fn text_value(s: &str) -> String { .collect() } +/// The unit a drawing's numbers are in, declared in its header. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum DxfUnits { + /// `$INSUNITS` 6, what every hull drawing uses. + #[default] + Metres, + /// `$INSUNITS` 4, for full-size patterns: cutting machines expect millimetres. + Millimetres, +} + /// A DXF drawing (AutoCAD 2000, metres) of `entities`. `layers` are the layers /// they are on; layer `0` is always present. pub fn write_dxf(layers: &[DxfLayer], entities: &[DxfEntity]) -> String { + write_dxf_in(DxfUnits::Metres, layers, entities) +} + +/// [`write_dxf`] for a drawing whose numbers are in `units`. The caller gives +/// every coordinate and text height in that unit; nothing is converted here. +pub fn write_dxf_in(units: DxfUnits, layers: &[DxfLayer], entities: &[DxfEntity]) -> String { let mut pts = Vec::new(); for e in entities { e.shape.sample(&mut pts); @@ -386,7 +410,7 @@ pub fn write_dxf(layers: &[DxfLayer], entities: &[DxfEntity]) -> String { for e in entities { let kind = match &e.shape { DxfShape::Line(..) => "LINE", - DxfShape::Polyline { .. } => "LWPOLYLINE", + DxfShape::Polyline { .. } | DxfShape::ClosedPolyline { .. } => "LWPOLYLINE", DxfShape::Polyline3 { .. } => "POLYLINE", DxfShape::Circle { .. } => "CIRCLE", DxfShape::Arc { .. } => "ARC", @@ -406,11 +430,12 @@ pub fn write_dxf(layers: &[DxfLayer], entities: &[DxfEntity]) -> String { }, ); match &e.shape { - DxfShape::Polyline { points } => { + DxfShape::Polyline { points } | DxfShape::ClosedPolyline { points } => { o.code(100, "AcDbPolyline"); o.code(90, points.len()); - // Bit 128 (PLINEGEN) continues the dash pattern. - o.code(70, 128); + // Bit 128 (PLINEGEN) continues the dash pattern; bit 1 closes the outline. + let closed = matches!(e.shape, DxfShape::ClosedPolyline { .. }); + o.code(70, 128 + i32::from(closed)); for p in points { o.num(10, p.x); o.num(20, p.y); @@ -519,9 +544,15 @@ pub fn write_dxf(layers: &[DxfLayer], entities: &[DxfEntity]) -> String { head.code(1, "AC1015"); head.code(9, "$HANDSEED"); head.code(5, format!("{:X}", o.next)); - // 6 = metres; measurement 1 = metric. + // 6 = metres, 4 = millimetres; measurement 1 = metric. head.code(9, "$INSUNITS"); - head.code(70, 6); + head.code( + 70, + match units { + DxfUnits::Metres => 6, + DxfUnits::Millimetres => 4, + }, + ); head.code(9, "$MEASUREMENT"); head.code(70, 1); head.code(9, "$EXTMIN"); @@ -748,4 +779,38 @@ mod tests { }); assert!(counts.get(&0).copied().unwrap_or(0) > 10); } + /// A closed outline is one LWPOLYLINE with the closed bit (1) beside PLINEGEN (128), and a + /// drawing in millimetres declares `$INSUNITS` 4, as Autodesk's DXF reference numbers them. + #[test] + fn a_closed_outline_in_millimetres_says_both() { + let square = vec![ + Vec2::new(0.0, 0.0), + Vec2::new(1000.0, 0.0), + Vec2::new(1000.0, 500.0), + Vec2::new(0.0, 500.0), + ]; + let items = [DxfEntity::new( + "CUT", + DxfShape::ClosedPolyline { points: square }, + )]; + let dxf = write_dxf_in(DxfUnits::Millimetres, &[DxfLayer::new("CUT", 7)], &items); + let e = entities(&dxf); + assert_eq!(e[0].0, "LWPOLYLINE"); + assert_eq!(values(&e[0], 90), [4.0]); + assert_eq!(values(&e[0], 70), [129.0]); + assert_eq!(values(&e[0], 10), [0.0, 1000.0, 1000.0, 0.0]); + let p = pairs(&dxf); + let units = p + .iter() + .position(|(c, v)| (*c, v.as_str()) == (9, "$INSUNITS")) + .unwrap(); + assert_eq!(p[units + 1], (70, "4".to_string())); + let ext = p + .iter() + .position(|(c, v)| (*c, v.as_str()) == (9, "$EXTMAX")) + .unwrap(); + assert_eq!(p[ext + 1], (10, "1000.0".to_string())); + // The plain call still writes metres. + assert!(write_dxf(&[], &items).contains("$INSUNITS\n 70\n6\n")); + } } diff --git a/crates/seacraft-core/src/fonts.rs b/crates/seacraft-core/src/fonts.rs new file mode 100644 index 0000000..84d4940 --- /dev/null +++ b/crates/seacraft-core/src/fonts.rs @@ -0,0 +1,13 @@ +//! The fonts compiled into the program: DejaVu Sans and DejaVu Sans Mono 2.37, unmodified, from +//! `assets/fonts`, with their licence. The desktop GUI sets its text in them, and PDF exports embed +//! a subset of DejaVu Sans ([`crate::ttf`], [`crate::pdf`]). They are not under the GPL; `NOTICE` +//! carries their copyright lines. + +/// DejaVu Sans 2.37, the whole file. +pub const DEJAVU_SANS: &[u8] = include_bytes!("../assets/fonts/DejaVuSans.ttf"); + +/// DejaVu Sans Mono 2.37, the whole file. +pub const DEJAVU_SANS_MONO: &[u8] = include_bytes!("../assets/fonts/DejaVuSansMono.ttf"); + +/// The licence both fonts are distributed under. +pub const DEJAVU_LICENCE: &str = include_str!("../assets/fonts/LICENSE-DejaVu.txt"); diff --git a/crates/seacraft-core/src/hydro.rs b/crates/seacraft-core/src/hydro.rs index 6150b07..a072539 100644 --- a/crates/seacraft-core/src/hydro.rs +++ b/crates/seacraft-core/src/hydro.rs @@ -580,8 +580,9 @@ pub fn plane_segments(verts: &[Vec3], tris: &[[usize; 3]], plane: &Plane) -> Vec } /// Chain segments into ordered polylines. The returned flag says whether the -/// polyline closed on itself. Endpoints are snapped to `tol` so duplicate -/// vertices from mirrored halves join up. +/// polyline closed on itself; a closed polyline holds each of its points once, +/// without repeating the first at the end. Endpoints are snapped to `tol` so +/// duplicate vertices from mirrored halves join up. pub fn chain_paths(segs: &[(Vec3, Vec3)], tol: f64) -> Vec<(Vec, bool)> { if segs.is_empty() { return Vec::new(); @@ -685,7 +686,8 @@ pub fn chain_paths(segs: &[(Vec3, Vec3)], tol: f64) -> Vec<(Vec, bool)> { } used[start_edge] = true; let (a, b) = edges[start_edge]; - let (fwd, closed) = walk(b, start_edge, &mut used, &adj, &edges); + // The walk closes when it comes back round to `a`, which it does not repeat. + let (fwd, closed) = walk(b, a, &mut used, &adj, &edges); let mut poly: Vec = Vec::new(); poly.push(points[a]); for i in fwd.iter() { @@ -693,14 +695,12 @@ pub fn chain_paths(segs: &[(Vec3, Vec3)], tol: f64) -> Vec<(Vec, bool)> { } if !closed { // Continue on the other side of the start edge. - let (mut back, _) = walk(a, start_edge, &mut used, &adj, &edges); + let (mut back, _) = walk(a, usize::MAX, &mut used, &adj, &edges); back.remove(0); // drop the duplicate of `a` back.reverse(); let mut full: Vec = back.iter().map(|i| points[*i]).collect(); full.extend(poly); poly = full; - } else { - poly.pop(); // drop duplicate closing point } if poly.len() >= 2 { paths.push((poly, closed)); @@ -1264,4 +1264,51 @@ mod tests { "positive trim lifts the bow: {bow} / {stern}" ); } + /// A closed loop keeps every one of its points, whichever segment the walk starts from and + /// however the segments of several loops are mixed: the chain used to drop the last point of + /// the first loop, and to leave a later loop open or cut short. + #[test] + fn chained_loops_keep_every_point_and_are_all_closed() { + let p = |y: f64, z: f64| Vec3::new(0.0, y, z); + let square = |y0: f64| { + vec![ + (p(y0, 0.0), p(y0 + 1.0, 0.0)), + (p(y0 + 1.0, 0.0), p(y0 + 1.0, 1.0)), + (p(y0 + 1.0, 1.0), p(y0, 1.0)), + (p(y0, 1.0), p(y0, 0.0)), + ] + }; + let one = chain_paths(&square(0.0), 1e-7); + assert_eq!(one.len(), 1); + assert_eq!((one[0].0.len(), one[0].1), (4, true)); + + // Two loops with their segments interleaved, as a mesh hands them over, after an open + // line so that neither loop starts at the first segment. + let (a, b) = (square(3.0), square(6.0)); + let mut mixed = vec![(p(-5.0, 0.0), p(-4.0, 0.0)), (p(-4.0, 0.0), p(-3.0, 0.0))]; + for i in 0..4 { + mixed.push(a[i]); + mixed.push(b[(i + 2) % 4]); + } + let paths = chain_paths(&mixed, 1e-7); + let mut shapes: Vec<(usize, bool)> = paths + .iter() + .map(|(pts, closed)| (pts.len(), *closed)) + .collect(); + shapes.sort(); + assert_eq!(shapes, [(3, false), (4, true), (4, true)]); + for (pts, closed) in &paths { + // No point is repeated, and a closed loop's area is the square's. + for (i, q) in pts.iter().enumerate() { + assert!(pts[i + 1..].iter().all(|r| r.dist(*q) > 1e-9)); + } + if *closed { + let n = pts.len(); + let area: f64 = (0..n) + .map(|i| pts[i].y * pts[(i + 1) % n].z - pts[(i + 1) % n].y * pts[i].z) + .sum(); + assert!((area.abs() / 2.0 - 1.0).abs() < 1e-12, "{area}"); + } + } + } } diff --git a/crates/seacraft-core/src/lib.rs b/crates/seacraft-core/src/lib.rs index a3b78f7..2a4e361 100644 --- a/crates/seacraft-core/src/lib.rs +++ b/crates/seacraft-core/src/lib.rs @@ -37,6 +37,8 @@ pub mod dxf; pub mod examples; pub mod export; pub mod fef; +/// The embedded DejaVu fonts and their licence. +pub mod fonts; pub mod hydro; pub mod iges; /// Crash-safe file output: temporary sibling, sync, rename. @@ -51,6 +53,8 @@ pub mod offsets; pub mod optimize; mod par; pub mod parametric; +/// A small PDF writer: pages, reusable drawings, stroked lines, Unicode text. +pub mod pdf; pub mod planing; pub mod project; pub mod raster; @@ -60,13 +64,21 @@ pub mod resistance; pub mod seakeeping; /// Irregular head-sea responses: spectra, significant motions, accelerations, MSI. pub mod seastate; +/// A 2D drawing in metres and its writers: tiled PDF, single-page PDF, SVG, DXF. +pub mod sheet; pub mod stability; pub mod subdiv; pub mod table; +/// Full-size patterns from a hull: station moulds, the profile, the transom. +pub mod templates; pub mod threemf; pub mod topology; pub mod transform; pub mod trimopt; +/// Read a TrueType font and write a subset of it. +pub mod ttf; +/// Lengths as text: metric first, imperial beside it when asked. +pub mod units; pub mod weather; pub use analysis::AnalysisMode; diff --git a/crates/seacraft-core/src/offsets.rs b/crates/seacraft-core/src/offsets.rs index f0c3397..7df9ef4 100644 --- a/crates/seacraft-core/src/offsets.rs +++ b/crates/seacraft-core/src/offsets.rs @@ -296,6 +296,97 @@ pub fn html(table: &TableOfOffsets, title: &str) -> String { out } +/// The table as a printable PDF: half-breadths, then heights on buttocks, in millimetres, and +/// with `imperial` the same two again in feet, inches and eighths. Heights are above the keel +/// datum, [`TableOfOffsets::keel_z`]. +pub fn pdf( + table: &TableOfOffsets, + title: &str, + imperial: bool, +) -> Result { + use crate::pdf::{add_table, Pdf, Table}; + use crate::units::{ft_in_8ths, millimetres}; + if table.stations.is_empty() { + return Err("the table of offsets has no stations: nothing to tabulate".into()); + } + let subtitle = "Heights are above the baseline (keel). \"-\" means the section does not reach."; + let mut doc = Pdf::new(title); + // The unit in the titles, the x column's heading, and how a length and an x are written. + type Write = fn(f64) -> String; + let mut systems: Vec<(&str, &str, Write, Write)> = + vec![("millimetres", "x [m]", millimetres, |x| format!("{x:.3}"))]; + if imperial { + systems.push(("feet-inches-eighths", "x [ft-in-8]", ft_in_8ths, ft_in_8ths)); + } + for (unit, x_head, length, x) in systems { + let cell = |v: Option| v.map_or_else(|| "-".to_owned(), length); + let height = |z: f64| length(z - table.keel_z); + let station = |i: usize| (i + 1).to_string(); + + let mut header = vec!["Station".to_owned(), x_head.to_owned()]; + header.extend( + table + .waterlines_m + .iter() + .map(|h| format!("WL {}", length(*h))), + ); + header.extend(["Keel", "Sheer", "B/2"].map(String::from)); + let rows: Vec> = table + .stations + .iter() + .enumerate() + .map(|(i, s)| { + let mut row = vec![station(i), x(s.x)]; + row.extend(s.waterline_half_breadths.iter().map(|v| cell(*v))); + row.extend([height(s.keel_z), height(s.deck_z), length(s.half_beam)]); + row + }) + .collect(); + let half_breadths = format!("{title}: half-breadths, {unit}"); + add_table( + &mut doc, + &Table { + title: &half_breadths, + subtitle, + header: &header, + rows: &rows, + footer: crate::NOT_VALIDATED_NOTE, + repeat: 2, + }, + ); + + let mut header = vec!["Station".to_owned(), x_head.to_owned()]; + header.extend(table.buttocks_m.iter().map(|b| format!("B {}", length(*b)))); + let rows: Vec> = table + .stations + .iter() + .enumerate() + .map(|(i, s)| { + let mut row = vec![station(i), x(s.x)]; + row.extend( + s.buttock_heights + .iter() + .map(|v| cell(v.map(|z| z - table.keel_z))), + ); + row + }) + .collect(); + let buttocks = format!("{title}: heights on buttocks, {unit}"); + add_table( + &mut doc, + &Table { + title: &buttocks, + subtitle, + header: &header, + rows: &rows, + footer: crate::NOT_VALIDATED_NOTE, + repeat: 2, + }, + ); + } + doc.finish() +} + #[cfg(test)] mod tests { use super::*; @@ -501,4 +592,112 @@ mod tests { // An empty cell is a dash, not a zero. assert!(page.contains("-")); } + + fn box_table(stations: usize, waterlines: &[f64], buttocks: &[f64]) -> TableOfOffsets { + let s = barge(10.0, 4.0, 2.0); + let hull = Hull::new( + &s, + SubdivisionOptions { + level: 2, + mode: SubdivisionMode::Loop, + }, + ); + table_of_offsets(&hull, stations, waterlines, buttocks) + } + + const SUBTITLE: &str = + "Heights are above the baseline (keel). \"-\" means the section does not reach."; + + /// Closed form: the box is 4 m wide, so every half-breadth below the deck is 2000 mm. + #[test] + fn a_box_table_reads_in_millimetres() { + let doc = pdf(&box_table(5, &[1.0], &[1.0]), "Box", false).unwrap(); + assert_eq!(doc.pages, 2); + let text = crate::pdf::shown_text(&doc.bytes); + for want in [ + "2000", + "Box: half-breadths, millimetres", + "Box: heights on buttocks, millimetres", + SUBTITLE, + crate::NOT_VALIDATED_NOTE, + "WL 1000", + "B 1000", + ] { + assert!(text.iter().any(|t| t == want), "{want} in {text:?}"); + } + assert!(doc.missing.is_empty(), "{:?}", doc.missing); + } + + #[test] + fn a_cell_the_section_does_not_reach_is_a_dash() { + // The box is 2 m deep: a waterline at 3 m is above every section. + let doc = pdf(&box_table(5, &[3.0], &[1.0]), "Box", false).unwrap(); + let page = crate::pdf::page_text(&doc.bytes, 0); + // Each row reads station, x, the waterline's cell, keel, sheer, half-breadth. + let row = page.iter().position(|t| t == "1").unwrap(); + assert_eq!(page[row + 2], "-", "{page:?}"); + assert_eq!(&page[row + 3..row + 6], ["0", "2000", "2000"], "{page:?}"); + } + + /// Closed form: 2.0 m = 78.740 in = 1260/16 in = 6 ft 6 12/16 in, so `6-6-6`. + #[test] + fn imperial_adds_its_tables_after_the_metric_ones() { + let table = box_table(5, &[1.0], &[1.0]); + let metric = pdf(&table, "Box", false).unwrap(); + let both = pdf(&table, "Box", true).unwrap(); + assert_eq!(both.pages, 4); + for page in 0..2 { + assert_eq!( + crate::pdf::page_stream(&both.bytes, page), + crate::pdf::page_stream(&metric.bytes, page), + "page {page}" + ); + } + let third = crate::pdf::page_text(&both.bytes, 2); + assert!(third.iter().any(|t| t == "6-6-6"), "{third:?}"); + assert!( + third + .iter() + .any(|t| t == "Box: half-breadths, feet-inches-eighths"), + "{third:?}" + ); + let fourth = crate::pdf::page_text(&both.bytes, 3); + assert!( + fourth + .iter() + .any(|t| t == "Box: heights on buttocks, feet-inches-eighths"), + "{fourth:?}" + ); + } + + #[test] + fn long_tables_continue_with_the_header_repeated() { + // 40 stations are more rows than a page holds. + let doc = pdf(&box_table(40, &[1.0], &[1.0]), "Box", false).unwrap(); + assert_eq!(doc.pages, 4, "two pages for each table"); + for page in 0..2 { + let text = crate::pdf::page_text(&doc.bytes, page); + assert!(text.iter().any(|t| t == "Station"), "page {page}"); + } + // 20 waterlines are more columns than a page holds. + let wls: Vec = (1..=20).map(|i| i as f64 * 0.1).collect(); + let doc = pdf(&box_table(5, &wls, &[1.0]), "Box", false).unwrap(); + assert_eq!(doc.pages, 3, "two pages of half-breadths, one of buttocks"); + for page in 0..2 { + let text = crate::pdf::page_text(&doc.bytes, page); + for head in ["Station", "x [m]"] { + assert!(text.iter().any(|t| t == head), "{head} on page {page}"); + } + } + let second = crate::pdf::page_text(&doc.bytes, 1); + assert!(second.iter().any(|t| t == "B/2"), "{second:?}"); + } + + #[test] + fn an_empty_table_is_an_error() { + let mut table = box_table(5, &[1.0], &[1.0]); + table.stations.clear(); + let err = pdf(&table, "Box", false).unwrap_err(); + assert!(err.contains("nothing to tabulate"), "{err}"); + } } diff --git a/crates/seacraft-core/src/pdf.rs b/crates/seacraft-core/src/pdf.rs new file mode 100644 index 0000000..870997c --- /dev/null +++ b/crates/seacraft-core/src/pdf.rs @@ -0,0 +1,1241 @@ +//! A small PDF writer: pages at true size, drawings reused across pages, stroked lines, and +//! Unicode text in an embedded subset of DejaVu Sans. +//! +//! It exists so that patterns and tables can be printed without a dependency: PDF 1.4, streams +//! not compressed, a classic cross-reference table. The file carries no date and no random id, so +//! the same input gives the same bytes. +//! +//! Lengths here are PDF points, 1/72 inch; [`PT_PER_MM`] converts. Coordinates are written to +//! three decimals, about a third of a micrometre. +//! +//! Text is a Type 0 font over a CIDFontType2 with the `Identity-H` encoding, so a string is the +//! font's own glyph ids, and a `ToUnicode` map lets a reader search and copy it. A character +//! DejaVu Sans has no glyph for is drawn as `?` and reported in [`Document::missing`]; it never +//! fails the document. +//! +//! Written from the PDF 1.4 reference (file structure, content stream operators, composite +//! fonts); nothing here is ported. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as _; +use std::sync::OnceLock; + +use crate::ttf::Font; + +/// Points in one millimetre: a point is 1/72 inch and an inch is 25.4 mm, both by definition. +pub const PT_PER_MM: f64 = 72.0 / 25.4; + +/// The longest side a page may have, 200 inches (5.08 m): the limit PDF readers implement. +pub const MAX_PAGE_PT: f64 = 14_400.0; + +/// The name the embedded subset goes by. The six letter tag marks a subset. +const FONT_NAME: &str = "AAAAAA+DejaVuSans"; + +/// Which point of a line of text sits at the position given. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Anchor { + /// The start of the text: it runs on from the position. + Start, + /// The middle of the text. + Middle, + /// The end of the text: it runs up to the position. + End, +} + +/// How a line is drawn. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Stroke { + /// Width on paper, in millimetres. + pub width_mm: f64, + /// 0 is black, 1 is white. + pub grey: f64, + /// Dashes: millimetres on, millimetres off. `None` is a solid line. + pub dash_mm: Option<(f64, f64)>, +} + +/// A drawing added with [`Pdf::add_form`], to place on pages with [`Content::form`]. +#[derive(Clone, Copy, Debug)] +pub struct FormId(usize); + +/// The embedded font, parsed once. It is compiled in and its tests read it, so it parses. +fn font() -> &'static Font<'static> { + static FONT: OnceLock> = OnceLock::new(); + FONT.get_or_init(|| Font::parse(crate::fonts::DEJAVU_SANS).expect("the embedded font parses")) +} + +/// The glyph that draws `c`, or `None` when the font has none. +fn glyph_for(c: char) -> Option { + font().glyph(c) +} + +/// The glyph drawn in place of a character the font lacks. +fn question_mark() -> u16 { + glyph_for('?').unwrap_or(0) +} + +/// A number as PDF writes it here: three decimals, and never a negative zero. +fn num(v: f64) -> String { + let s = format!("{v:.3}"); + if s.trim_start_matches('-') + .chars() + .all(|c| c == '0' || c == '.') + { + s.trim_start_matches('-').to_owned() + } else { + s + } +} + +/// A rotation's cosine or sine: an exact `0`, `1` or `-1` where it is one, else six decimals. +fn turn(v: f64) -> String { + let r = (v * 1e6).round() / 1e6; + if r == 0.0 { + "0".to_owned() + } else if r == 1.0 { + "1".to_owned() + } else if r == -1.0 { + "-1".to_owned() + } else { + format!("{r:.6}") + } +} + +/// The width of one line of `text` at `size_pt`, in points. Characters the font lacks count as +/// the `?` that is drawn for them. +pub fn text_width_pt(text: &str, size_pt: f64) -> f64 { + let f = font(); + let units: u64 = text + .chars() + .map(|c| f.advance(glyph_for(c).unwrap_or_else(question_mark)) as u64) + .sum(); + units as f64 / f.metrics().units_per_em as f64 * size_pt +} + +/// `text` broken into lines no wider than `width_pt` at `size_pt`, at spaces. A word wider than +/// the line gets a line to itself. An empty text is no lines. +pub fn wrap_text(text: &str, size_pt: f64, width_pt: f64) -> Vec { + let mut lines: Vec = Vec::new(); + for word in text.split_whitespace() { + match lines.last_mut() { + Some(line) if text_width_pt(&format!("{line} {word}"), size_pt) <= width_pt => { + line.push(' '); + line.push_str(word); + } + _ => lines.push(word.to_owned()), + } + } + lines +} + +/// What goes on a page or in a reusable drawing: lines, text and placed drawings, in the order +/// they are added. Coordinates are points, x to the right and y up, from the lower-left corner. +#[derive(Default, Clone, Debug)] +pub struct Content { + ops: String, + /// Glyphs shown, each with the character it stands for. + glyphs: BTreeMap, + missing: BTreeSet, + forms: BTreeSet, +} + +impl Content { + /// Empty content. + pub fn new() -> Self { + Self::default() + } + + /// Remember the graphics state (the clip and the transform) until [`Content::restore`]. + pub fn save(&mut self) { + self.ops.push_str("q\n"); + } + + /// Return to the state at the matching [`Content::save`]. + pub fn restore(&mut self) { + self.ops.push_str("Q\n"); + } + + /// Move everything drawn from here on by `(dx_pt, dy_pt)`. + pub fn translate(&mut self, dx_pt: f64, dy_pt: f64) { + let _ = writeln!(self.ops, "1 0 0 1 {} {} cm", num(dx_pt), num(dy_pt)); + } + + /// Shrink or enlarge everything drawn from here on by `factor`, about the current origin. + /// Line widths and text scale with it. A factor that is not more than zero is ignored. + pub fn scale(&mut self, factor: f64) { + if factor.is_finite() && factor > 0.0 { + let _ = writeln!(self.ops, "{factor:.6} 0 0 {factor:.6} 0 0 cm"); + } + } + + /// Draw only inside this rectangle from here on. + pub fn clip_rect(&mut self, x_pt: f64, y_pt: f64, w_pt: f64, h_pt: f64) { + let _ = writeln!( + self.ops, + "{} {} {} {} re W n", + num(x_pt), + num(y_pt), + num(w_pt), + num(h_pt) + ); + } + + /// A stroked line through `points_pt`, joined back to its start when `closed`. Fewer than two + /// points, or a point that is not a finite number, draws nothing. + pub fn polyline(&mut self, points_pt: &[(f64, f64)], closed: bool, stroke: Stroke) { + if points_pt.len() < 2 + || points_pt + .iter() + .any(|p| !p.0.is_finite() || !p.1.is_finite()) + { + return; + } + let dash = match stroke.dash_mm { + Some((on, off)) if on > 0.0 && off > 0.0 => { + format!("[{} {}]", num(on * PT_PER_MM), num(off * PT_PER_MM)) + } + _ => "[]".to_owned(), + }; + // Round joins, so a line of many short segments has no spikes; butt ends, so a line is + // exactly as long as its end points say. + let _ = writeln!( + self.ops, + "{} w {} G {dash} 0 d 1 j 0 J", + num(stroke.width_mm.max(0.0) * PT_PER_MM), + num(stroke.grey.clamp(0.0, 1.0)), + ); + for (i, (x, y)) in points_pt.iter().enumerate() { + let op = if i == 0 { 'm' } else { 'l' }; + let _ = writeln!(self.ops, "{} {} {op}", num(*x), num(*y)); + } + if closed { + self.ops.push_str("h\n"); + } + self.ops.push_str("S\n"); + } + + /// One line of text. `grey` 0 is black. `angle_deg` turns it anticlockwise about its anchor. + /// Characters without a glyph are drawn as `?` and recorded. + pub fn text( + &mut self, + at_pt: (f64, f64), + size_pt: f64, + angle_deg: f64, + anchor: Anchor, + grey: f64, + text: &str, + ) { + let finite = [at_pt.0, at_pt.1, size_pt, angle_deg] + .iter() + .all(|v| v.is_finite()); + if text.is_empty() || !finite || size_pt <= 0.0 { + return; + } + let mut hex = String::with_capacity(4 * text.len()); + for c in text.chars() { + let glyph = match glyph_for(c) { + Some(g) => { + self.glyphs.entry(g).or_insert(c); + g + } + None => { + self.missing.insert(c); + self.glyphs.entry(question_mark()).or_insert('?'); + question_mark() + } + }; + let _ = write!(hex, "{glyph:04X}"); + } + let back = match anchor { + Anchor::Start => 0.0, + Anchor::Middle => text_width_pt(text, size_pt) / 2.0, + Anchor::End => text_width_pt(text, size_pt), + }; + let (sin, cos) = angle_deg.to_radians().sin_cos(); + let (x, y) = (at_pt.0 - back * cos, at_pt.1 - back * sin); + let _ = writeln!( + self.ops, + "BT /F1 {} Tf {} g {} {} {} {} {} {} Tm <{hex}> Tj ET", + num(size_pt), + num(grey.clamp(0.0, 1.0)), + turn(cos), + turn(sin), + turn(-sin), + turn(cos), + num(x), + num(y), + ); + } + + /// Add everything in `other` after what is here, as if it had been drawn here. + pub fn append(&mut self, other: &Content) { + self.ops.push_str(&other.ops); + for (glyph, c) in &other.glyphs { + self.glyphs.entry(*glyph).or_insert(*c); + } + self.missing.extend(&other.missing); + self.forms.extend(&other.forms); + } + + /// Place a drawing added with [`Pdf::add_form`], under the current transform and clip. + pub fn form(&mut self, id: FormId) { + self.forms.insert(id.0); + let _ = writeln!(self.ops, "/X{} Do", id.0); + } + + /// The `/Resources` dictionary this content needs, given where the font and forms are. + fn resources(&self, font_object: usize, form_object: impl Fn(usize) -> usize) -> String { + let mut out = String::from("<<"); + if !self.glyphs.is_empty() { + let _ = write!(out, " /Font << /F1 {font_object} 0 R >>"); + } + if !self.forms.is_empty() { + out.push_str(" /XObject <<"); + for form in &self.forms { + let _ = write!(out, " /X{form} {} 0 R", form_object(*form)); + } + out.push_str(" >>"); + } + out.push_str(" >>"); + out + } +} + +/// A PDF document being put together. +#[derive(Debug, Clone)] +pub struct Pdf { + title: String, + forms: Vec<([f64; 4], Content)>, + pages: Vec<(f64, f64, Content)>, +} + +/// A finished PDF. +#[derive(Debug, Clone, PartialEq)] +pub struct Document { + /// The file. + pub bytes: Vec, + /// How many pages it has. + pub pages: usize, + /// Characters the font has no glyph for, each drawn as `?`: sorted, no repeats. + pub missing: Vec, +} + +/// The file being written: object bodies appended in number order, with where each began. +struct Objects { + bytes: Vec, + offsets: Vec, +} + +impl Objects { + /// Append the next object; its number is the count so far plus one. + fn add(&mut self, body: &[u8]) { + self.offsets.push(self.bytes.len()); + let header = format!("{} 0 obj\n", self.offsets.len()); + self.bytes.extend(header.as_bytes()); + self.bytes.extend(body); + self.bytes.extend(b"\nendobj\n"); + } + + /// Append a stream object with these extra dictionary entries. + fn add_stream(&mut self, entries: &str, data: &[u8]) { + let mut body = format!("<< /Length {}{entries} >>\nstream\n", data.len()).into_bytes(); + body.extend(data); + body.extend(b"\nendstream"); + self.add(&body); + } +} + +impl Pdf { + /// An empty document. The title is what a reader shows in its window; any text will do. + pub fn new(title: &str) -> Self { + Self { + title: title.to_owned(), + forms: Vec::new(), + pages: Vec::new(), + } + } + + /// Add a drawing that pages (and later drawings) can place any number of times; the file + /// holds it once. `bbox_pt` is `[x min, y min, x max, y max]` in the drawing's coordinates: + /// nothing outside it is drawn. + pub fn add_form(&mut self, bbox_pt: [f64; 4], content: Content) -> FormId { + self.forms.push((bbox_pt, content)); + FormId(self.forms.len() - 1) + } + + /// Add a page of this size, in points. + pub fn add_page(&mut self, width_pt: f64, height_pt: f64, content: Content) { + self.pages.push((width_pt, height_pt, content)); + } + + /// How many pages have been added. + pub fn page_count(&self) -> usize { + self.pages.len() + } + + /// Write the file. Fails when the document has no page, when a page has no size or is larger + /// than a PDF page can be, or when a content names a drawing this document does not have. + pub fn finish(self) -> Result { + if self.pages.is_empty() { + return Err("a PDF needs at least one page".into()); + } + for (i, (w, h, _)) in self.pages.iter().enumerate() { + let page = i + 1; + if !(w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0) { + return Err(format!("page {page} has no size ({w} x {h} pt)")); + } + if *w > MAX_PAGE_PT || *h > MAX_PAGE_PT { + return Err(format!( + "page {page} would be {:.2} x {:.2} m, and a PDF page can be at most 5.08 m \ + (200 in) on a side", + w / PT_PER_MM / 1000.0, + h / PT_PER_MM / 1000.0 + )); + } + } + let contents = || { + self.forms + .iter() + .map(|f| &f.1) + .chain(self.pages.iter().map(|p| &p.2)) + }; + for c in contents() { + if let Some(form) = c.forms.iter().find(|f| **f >= self.forms.len()) { + return Err(format!( + "a page places drawing {form}, which is not in this document" + )); + } + } + if self + .forms + .iter() + .any(|(bbox, _)| bbox.iter().any(|v| !v.is_finite())) + { + return Err("a drawing's bounds are not finite numbers".into()); + } + let mut glyphs: BTreeMap = BTreeMap::new(); + let mut missing: BTreeSet = BTreeSet::new(); + for c in contents() { + for (g, ch) in &c.glyphs { + glyphs.entry(*g).or_insert(*ch); + } + missing.extend(&c.missing); + } + + // Object numbers: 1 catalog, 2 page tree, 3 information, then five for the font when + // there is text, then the forms, then two for each page. + const FONT: usize = 4; + let font_objects = if glyphs.is_empty() { 0 } else { 5 }; + let first_form = 4 + font_objects; + let first_page = first_form + self.forms.len(); + let form_object = |form: usize| first_form + form; + + let mut file = Objects { + // The second line is a comment of bytes over 127, which tells a program that moves + // the file that it is not text. + bytes: b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n".to_vec(), + offsets: Vec::new(), + }; + file.add(b"<< /Type /Catalog /Pages 2 0 R >>"); + let mut kids = String::new(); + for i in 0..self.pages.len() { + let _ = write!(kids, "{} 0 R ", first_page + 2 * i); + } + let tree = format!( + "<< /Type /Pages /Kids [{}] /Count {} >>", + kids.trim_end(), + self.pages.len() + ); + file.add(tree.as_bytes()); + let mut title = String::from("FEFF"); + for unit in self.title.encode_utf16() { + let _ = write!(title, "{unit:04X}"); + } + let info = format!( + "<< /Title <{title}> /Producer (SeaCraft {}) >>", + crate::VERSION + ); + file.add(info.as_bytes()); + + if !glyphs.is_empty() { + Self::write_font(&mut file, &glyphs)?; + } + for (bbox, content) in &self.forms { + let entries = format!( + " /Type /XObject /Subtype /Form /BBox [{} {} {} {}] /Resources {}", + num(bbox[0]), + num(bbox[1]), + num(bbox[2]), + num(bbox[3]), + content.resources(FONT, form_object) + ); + file.add_stream(&entries, content.ops.as_bytes()); + } + for (i, (w, h, content)) in self.pages.iter().enumerate() { + let page = format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Resources {} /Contents {} 0 R >>", + num(*w), + num(*h), + content.resources(FONT, form_object), + first_page + 2 * i + 1 + ); + file.add(page.as_bytes()); + file.add_stream("", content.ops.as_bytes()); + } + + let Objects { mut bytes, offsets } = file; + let xref = bytes.len(); + let size = offsets.len() + 1; + bytes.extend(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes()); + for offset in &offsets { + bytes.extend(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend( + format!( + "trailer\n<< /Size {size} /Root 1 0 R /Info 3 0 R >>\nstartxref\n{xref}\n%%EOF\n" + ) + .as_bytes(), + ); + Ok(Document { + bytes, + pages: self.pages.len(), + missing: missing.into_iter().collect(), + }) + } + + /// The five font objects, numbers 4 to 8: the Type 0 font, its CID font, the descriptor, the + /// subset file and the map back to Unicode. + fn write_font(file: &mut Objects, glyphs: &BTreeMap) -> Result<(), String> { + let f = font(); + let m = f.metrics(); + // Font units to the thousandths of an em PDF measures glyphs in. + let milli = |units: f64| num(units * 1000.0 / m.units_per_em as f64); + file.add( + format!( + "<< /Type /Font /Subtype /Type0 /BaseFont /{FONT_NAME} /Encoding /Identity-H \ + /DescendantFonts [5 0 R] /ToUnicode 8 0 R >>" + ) + .as_bytes(), + ); + let mut widths = String::new(); + for glyph in glyphs.keys() { + let _ = write!(widths, "{glyph} [{}] ", milli(f.advance(*glyph) as f64)); + } + file.add( + format!( + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /{FONT_NAME} /CIDSystemInfo \ + << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor 6 0 R \ + /CIDToGIDMap /Identity /DW 1000 /W [{}] >>", + widths.trim_end() + ) + .as_bytes(), + ); + file.add( + format!( + "<< /Type /FontDescriptor /FontName /{FONT_NAME} /Flags 4 /FontBBox [{} {} {} {}] \ + /ItalicAngle 0 /Ascent {} /Descent {} /CapHeight {} /StemV 80 /FontFile2 7 0 R >>", + milli(m.bbox[0] as f64), + milli(m.bbox[1] as f64), + milli(m.bbox[2] as f64), + milli(m.bbox[3] as f64), + milli(m.ascent as f64), + milli(m.descent as f64), + milli(m.cap_height as f64), + ) + .as_bytes(), + ); + let subset = f.subset(&glyphs.keys().copied().collect())?; + file.add_stream(&format!(" /Length1 {}", subset.len()), &subset); + + let mut map = String::from( + "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n\ + /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n\ + /CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n\ + 1 begincodespacerange\n<0000> \nendcodespacerange\n", + ); + let pairs: Vec<(&u16, &char)> = glyphs.iter().collect(); + // A block holds at most 100 lines. + for block in pairs.chunks(100) { + let _ = writeln!(map, "{} beginbfchar", block.len()); + for (glyph, c) in block { + let _ = write!(map, "<{glyph:04X}> <"); + for unit in c.encode_utf16(&mut [0; 2]) { + let _ = write!(map, "{unit:04X}"); + } + map.push_str(">\n"); + } + map.push_str("endbfchar\n"); + } + map.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend"); + file.add_stream("", map.as_bytes()); + Ok(()) + } +} + +/// Test support: read back what the writer wrote, through the file's own tables. +/// A table of text to print: a title and subtitle at the top of each page, a header row, the +/// rows, and a footer line. +#[derive(Clone, Copy, Debug)] +pub struct Table<'a> { + pub title: &'a str, + pub subtitle: &'a str, + pub header: &'a [String], + pub rows: &'a [Vec], + pub footer: &'a str, + /// How many leading columns are repeated on every page when the columns do not fit one. + /// At least the first is. + pub repeat: usize, +} + +/// The table layout, in millimetres: A4 landscape. +mod table_layout { + pub const PAPER: (f64, f64) = (297.0, 210.0); + pub const MARGIN: f64 = 12.0; + pub const TITLE_BLOCK: f64 = 20.0; + pub const FOOTER: f64 = 8.0; + pub const ROW: f64 = 5.0; + pub const FIRST_COLUMN: f64 = 22.0; + pub const COLUMN: f64 = 18.0; + /// Space between a cell's edge and its text. + pub const PAD: f64 = 1.5; + pub const TEXT_PT: f64 = 8.0; + pub const TITLE_PT: f64 = 12.0; +} + +/// Adds the pages the table needs and returns how many. A4 landscape, 12 mm margins, 8 pt text in +/// 5 mm rows; the first column 22 mm wide and the others 18 mm, numbers right-aligned. Rows that +/// do not fit a page continue on the next with the header repeated; columns that do not fit +/// continue on further pages with the first [`Table::repeat`] columns repeated. Rows go first: +/// every row of the first columns, then every row of the next. +pub fn add_table(pdf: &mut Pdf, table: &Table<'_>) -> usize { + use table_layout::*; + let mm = |v: f64| v * PT_PER_MM; + let (width, height) = PAPER; + let columns = table + .header + .len() + .max(table.rows.iter().map(Vec::len).max().unwrap_or(0)) + .max(1); + let column_width = |c: usize| if c == 0 { FIRST_COLUMN } else { COLUMN }; + let repeat = table.repeat.clamp(1, columns); + let usable = width - 2.0 * MARGIN; + let repeated: f64 = (0..repeat).map(column_width).sum(); + // The other columns in groups that fit beside the repeated ones. + let mut groups: Vec> = Vec::new(); + let mut used = usable; + for c in repeat..columns { + if used + COLUMN > usable - repeated + 1e-9 || groups.is_empty() { + groups.push(Vec::new()); + used = 0.0; + } + groups.last_mut().expect("just pushed").push(c); + used += COLUMN; + } + if groups.is_empty() { + groups.push(Vec::new()); + } + let per_page = ((height - 2.0 * MARGIN - TITLE_BLOCK - FOOTER) / ROW).floor() as usize - 1; + let chunks: Vec<&[Vec]> = if table.rows.is_empty() { + vec![&[]] + } else { + table.rows.chunks(per_page).collect() + }; + let pages = groups.len() * chunks.len(); + let top = height - MARGIN; + let text_pt = |c: &mut Content, x: f64, y: f64, anchor: Anchor, size: f64, text: &str| { + c.text((mm(x), mm(y)), size, 0.0, anchor, 0.0, text) + }; + let mut page = 0; + for group in &groups { + let shown: Vec = (0..repeat).chain(group.iter().copied()).collect(); + let lefts: Vec = shown + .iter() + .scan(MARGIN, |x, c| { + let left = *x; + *x += column_width(*c); + Some(left) + }) + .collect(); + let right = MARGIN + shown.iter().map(|c| column_width(*c)).sum::(); + for chunk in &chunks { + page += 1; + let mut content = Content::new(); + text_pt( + &mut content, + MARGIN, + top - 6.0, + Anchor::Start, + TITLE_PT, + table.title, + ); + text_pt( + &mut content, + MARGIN, + top - 13.0, + Anchor::Start, + TEXT_PT, + table.subtitle, + ); + let row_text = |content: &mut Content, y: f64, cells: &[String]| { + for (i, c) in shown.iter().enumerate() { + let Some(cell) = cells.get(*c) else { continue }; + if *c == 0 { + text_pt(content, lefts[i] + PAD, y, Anchor::Start, TEXT_PT, cell); + } else { + let x = lefts[i] + column_width(*c) - PAD; + text_pt(content, x, y, Anchor::End, TEXT_PT, cell); + } + } + }; + // A row's text sits a little below the middle of its 5 mm. + let first = top - TITLE_BLOCK; + row_text(&mut content, first - 3.5, table.header); + let rule = |content: &mut Content, y: f64, width_mm: f64| { + let stroke = Stroke { + width_mm, + grey: 0.0, + dash_mm: None, + }; + content.polyline(&[(mm(MARGIN), mm(y)), (mm(right), mm(y))], false, stroke); + }; + rule(&mut content, first - ROW, 0.18); + for (r, cells) in chunk.iter().enumerate() { + let row_top = first - ROW * (r + 1) as f64; + row_text(&mut content, row_top - 3.5, cells); + if (r + 1) % 5 == 0 && r + 1 < chunk.len() { + rule(&mut content, row_top - ROW, 0.1); + } + } + let footer_y = MARGIN + FOOTER - 3.5; + let room = mm(usable - 30.0); + for (i, line) in wrap_text(table.footer, TEXT_PT, room).iter().enumerate() { + text_pt( + &mut content, + MARGIN, + footer_y - 3.5 * i as f64, + Anchor::Start, + TEXT_PT, + line, + ); + } + if pages > 1 { + let marker = format!("Page {page} of {pages}"); + text_pt( + &mut content, + width - MARGIN, + footer_y, + Anchor::End, + TEXT_PT, + &marker, + ); + } + pdf.add_page(mm(width), mm(height), content); + } + } + pages +} + +#[cfg(test)] +mod reading { + use std::collections::BTreeMap; + + fn find(hay: &[u8], needle: &[u8], from: usize) -> Option { + hay.get(from..)? + .windows(needle.len()) + .position(|w| w == needle) + .map(|i| i + from) + } + + fn lossy(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).into_owned() + } + + /// Object `n`'s bytes, from after `n 0 obj` to `endobj`, found through the xref table. + fn object(bytes: &[u8], n: usize) -> &[u8] { + let s = lossy(bytes); + let at = s.rfind("startxref\n").expect("startxref") + 10; + let xref: usize = s[at..].lines().next().unwrap().parse().unwrap(); + let entries = find(bytes, b"\n", xref + 5).unwrap() + 1; + let entry = &bytes[entries + 20 * n..entries + 20 * n + 10]; + let offset: usize = lossy(entry).parse().unwrap(); + let start = find(bytes, b"obj\n", offset).unwrap() + 4; + // A stream's length says where it ends; binary data may hold the word "endobj". + let end = match stream_range(&bytes[start..]) { + Some((_, end)) => find(bytes, b"endobj", start + end).unwrap(), + None => find(bytes, b"endobj", start).unwrap(), + }; + &bytes[start..end] + } + + /// Where an object's stream data starts and ends, when it has one. + fn stream_range(object: &[u8]) -> Option<(usize, usize)> { + if !object.starts_with(b"<< /Length ") { + return None; + } + let len: usize = lossy(&object[11..find(object, b" ", 11)?]) + .trim() + .parse() + .ok()?; + let start = find(object, b">>\nstream\n", 0)? + 10; + Some((start, start + len)) + } + + fn stream(bytes: &[u8], n: usize) -> Vec { + let obj = object(bytes, n); + let (start, end) = stream_range(obj).expect("the object is a stream"); + obj[start..end].to_vec() + } + + /// The number before ` 0 R` that follows `key` in an object. + fn reference(object: &[u8], key: &str) -> usize { + let s = lossy(object); + let rest = &s[s.find(key).unwrap_or_else(|| panic!("{key} in {s}")) + key.len()..]; + rest.split_whitespace().next().unwrap().parse().unwrap() + } + + /// Glyph id to text, from the file's ToUnicode map; empty when the file embeds no font. + fn to_unicode(bytes: &[u8]) -> BTreeMap { + let mut map = BTreeMap::new(); + let s = lossy(bytes); + let Some(at) = s.find("/ToUnicode ") else { + return map; + }; + let n: usize = s[at + 11..] + .split_whitespace() + .next() + .unwrap() + .parse() + .unwrap(); + for line in lossy(&stream(bytes, n)).lines() { + let Some((glyph, text)) = line.strip_prefix('<').and_then(|l| l.split_once("> <")) + else { + continue; + }; + let Some(text) = text.strip_suffix('>') else { + continue; + }; + if glyph.len() != 4 || text.len() % 4 != 0 || glyph == "0000" && text == "FFFF" { + continue; + } + let units: Vec = (0..text.len()) + .step_by(4) + .map(|i| u16::from_str_radix(&text[i..i + 4], 16).unwrap()) + .collect(); + map.insert( + u16::from_str_radix(glyph, 16).unwrap(), + String::from_utf16(&units).unwrap(), + ); + } + map + } + + /// The strings one content stream shows, decoded through `map`. + fn strings(content: &[u8], map: &BTreeMap) -> Vec { + lossy(content) + .lines() + .filter_map(|line| { + let hex = &line[line.find('<')? + 1..line.find("> Tj")?]; + Some( + (0..hex.len()) + .step_by(4) + .map(|i| map[&u16::from_str_radix(&hex[i..i + 4], 16).unwrap()].as_str()) + .collect(), + ) + }) + .collect() + } + + /// Every string shown with `Tj` anywhere in the file (forms and pages), decoded through the + /// file's own ToUnicode map, in file order. + pub(crate) fn shown_text(bytes: &[u8]) -> Vec { + let map = to_unicode(bytes); + let s = lossy(bytes); + let size: usize = s[s.rfind("/Size ").unwrap() + 6..] + .split_whitespace() + .next() + .unwrap() + .parse() + .unwrap(); + (1..size) + .filter_map(|n| { + let obj = object(bytes, n); + // The font file is a stream too, but binary: only content streams show text. + let is_content = !lossy(&obj[..obj.len().min(200)]).contains("/Length1"); + stream_range(obj) + .filter(|_| is_content) + .map(|(a, b)| strings(&obj[a..b], &map)) + }) + .flatten() + .collect() + } + + /// One page's own content stream, for comparing pages between two documents. `page` counts + /// from 0. + pub(crate) fn page_stream(bytes: &[u8], page: usize) -> Vec { + let tree = lossy(object(bytes, 2)); + let kids = &tree[tree.find('[').unwrap() + 1..tree.find(']').unwrap()]; + let n: usize = kids + .split(" 0 R") + .nth(page) + .unwrap_or_else(|| panic!("page {page} of {kids}")) + .trim() + .parse() + .unwrap(); + stream(bytes, reference(object(bytes, n), "/Contents ")) + } + + /// The strings shown by one page's own content stream (not its forms). `page` counts from 0. + pub(crate) fn page_text(bytes: &[u8], page: usize) -> Vec { + strings(&page_stream(bytes, page), &to_unicode(bytes)) + } +} + +#[cfg(test)] +pub(crate) use reading::{page_stream, page_text, shown_text}; + +#[cfg(test)] +mod tests { + use super::*; + + const BLACK: Stroke = Stroke { + width_mm: 0.35, + grey: 0.0, + dash_mm: None, + }; + + fn a4() -> (f64, f64) { + (210.0 * PT_PER_MM, 297.0 * PT_PER_MM) + } + + fn text(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).into_owned() + } + + fn count(bytes: &[u8], needle: &str) -> usize { + text(bytes).matches(needle).count() + } + + /// A page with one line of text on it. + fn page_saying(pdf: &mut Pdf, words: &str) { + let mut c = Content::new(); + c.text((100.0, 100.0), 12.0, 0.0, Anchor::Start, 0.0, words); + let (w, h) = a4(); + pdf.add_page(w, h, c); + } + + /// Reads `startxref`, then checks that every in-use entry points at its own object. + fn assert_xref_is_right(bytes: &[u8]) { + let s = text(bytes); + assert!(s.starts_with("%PDF-1.4\n")); + assert!(s.ends_with("%%EOF\n")); + let start: usize = s[s.rfind("startxref\n").unwrap() + 10..] + .lines() + .next() + .unwrap() + .parse() + .unwrap(); + let table = &bytes[start..]; + assert!(table.starts_with(b"xref\n0 ")); + let header_end = 5 + table[5..].iter().position(|b| *b == b'\n').unwrap() + 1; + let n: usize = text(&table[7..header_end - 1]).parse().unwrap(); + assert!(n >= 4); + assert_eq!( + &table[header_end..header_end + 20], + b"0000000000 65535 f \n" + ); + for i in 1..n { + let entry = &table[header_end + 20 * i..header_end + 20 * (i + 1)]; + assert_eq!(&entry[10..], b" 00000 n \n", "entry {i}"); + let offset: usize = text(&entry[..10]).parse().unwrap(); + let expect = format!("{i} 0 obj\n"); + assert!( + bytes[offset..].starts_with(expect.as_bytes()), + "object {i} at {offset}" + ); + } + assert!(s.contains(&format!("/Size {n} "))); + } + + #[test] + fn xref_offsets_land_on_their_objects() { + let mut pdf = Pdf::new("Two pages"); + page_saying(&mut pdf, "one"); + page_saying(&mut pdf, "two"); + assert_eq!(pdf.page_count(), 2); + let doc = pdf.finish().unwrap(); + assert_eq!(doc.pages, 2); + assert_xref_is_right(&doc.bytes); + assert_eq!(count(&doc.bytes, "/Type /Page "), 2); + assert!(text(&doc.bytes).contains("/Count 2")); + } + + /// 210 mm and 297 mm at 72 / 25.4 pt per mm: 595.2756 and 841.8898. + #[test] + fn an_a4_page_has_the_a4_media_box() { + let mut pdf = Pdf::new("A4"); + let (w, h) = a4(); + pdf.add_page(w, h, Content::new()); + let doc = pdf.finish().unwrap(); + assert!(text(&doc.bytes).contains("/MediaBox [0 0 595.276 841.890]")); + } + + /// 1 pt = 1/72 in and 1 in = 25.4 mm by definition, so 10 mm = 28.346 pt and 110 mm = 311.811 pt. + #[test] + fn a_100_mm_line_spans_283_465_pt() { + let mut c = Content::new(); + c.polyline( + &[ + (10.0 * PT_PER_MM, 10.0 * PT_PER_MM), + (110.0 * PT_PER_MM, 10.0 * PT_PER_MM), + ], + false, + BLACK, + ); + let mut pdf = Pdf::new("Line"); + let (w, h) = a4(); + pdf.add_page(w, h, c); + let doc = pdf.finish().unwrap(); + let stream = text(&page_stream(&doc.bytes, 0)); + assert!( + stream.contains("28.346 28.346 m\n311.811 28.346 l\nS\n"), + "{stream}" + ); + // 0.35 mm is 0.992 pt. + assert!(stream.contains("0.992 w"), "{stream}"); + assert!(stream.contains("[] 0 d"), "{stream}"); + } + + #[test] + fn a_dashed_closed_grey_path_says_so() { + let mut c = Content::new(); + let dashed = Stroke { + width_mm: 0.18, + grey: 0.55, + dash_mm: Some((2.0, 1.0)), + }; + c.polyline(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)], true, dashed); + // A path of fewer than two points draws nothing. + c.polyline(&[(5.0, 5.0)], false, BLACK); + c.polyline(&[(f64::NAN, 5.0), (1.0, 1.0)], false, BLACK); + let mut pdf = Pdf::new("Dash"); + pdf.add_page(100.0, 100.0, c); + let doc = pdf.finish().unwrap(); + let stream = text(&page_stream(&doc.bytes, 0)); + assert!(stream.contains("0.550 G"), "{stream}"); + assert!(stream.contains("[5.669 2.835] 0 d"), "{stream}"); + assert!(stream.contains("10.000 10.000 l\nh\nS\n"), "{stream}"); + assert_eq!(stream.matches("S\n").count(), 1, "{stream}"); + assert!(!stream.contains("NaN"), "{stream}"); + } + + #[test] + fn the_same_input_gives_the_same_bytes() { + let build = || { + let mut pdf = Pdf::new("Same"); + page_saying(&mut pdf, "Station 3"); + pdf.finish().unwrap().bytes + }; + let bytes = build(); + assert_eq!(bytes, build()); + assert!(!text(&bytes).contains("/CreationDate")); + assert!(!text(&bytes).contains("/ID")); + let producer = format!("/Producer (SeaCraft {})", crate::VERSION); + assert!(text(&bytes).contains(&producer)); + } + + #[test] + fn a_form_used_on_three_pages_is_written_once() { + let mut pdf = Pdf::new("Forms"); + let mut drawing = Content::new(); + drawing.polyline(&[(0.0, 0.0), (50.0, 50.0)], false, BLACK); + drawing.text((10.0, 10.0), 8.0, 0.0, Anchor::Start, 0.0, "in the form"); + let form = pdf.add_form([0.0, 0.0, 50.0, 50.0], drawing); + for i in 0..3 { + let mut c = Content::new(); + c.save(); + c.clip_rect(10.0, 10.0, 80.0, 80.0); + c.translate(20.0 * i as f64, 0.0); + c.form(form); + c.restore(); + pdf.add_page(100.0, 100.0, c); + } + let doc = pdf.finish().unwrap(); + assert_eq!(count(&doc.bytes, "/Subtype /Form"), 1); + assert_eq!(count(&doc.bytes, " Do\n"), 3); + assert!(text(&doc.bytes).contains("/BBox [0.000 0.000 50.000 50.000]")); + let second = text(&page_stream(&doc.bytes, 1)); + assert_eq!( + second, + "q\n10.000 10.000 80.000 80.000 re W n\n1 0 0 1 20.000 0.000 cm\n/X0 Do\nQ\n" + ); + // The text is in the form, not on the pages, and is found either way. + assert_eq!(shown_text(&doc.bytes), ["in the form"]); + assert!(page_text(&doc.bytes, 0).is_empty()); + assert_xref_is_right(&doc.bytes); + } + + #[test] + fn turkish_text_is_embedded_and_searchable() { + let words = "Kayık Şile ğüöç İ"; + let mut pdf = Pdf::new("Türkçe"); + page_saying(&mut pdf, words); + let doc = pdf.finish().unwrap(); + let s = text(&doc.bytes); + for needle in [ + "/Identity-H", + "/FontFile2", + "/ToUnicode", + "/AAAAAA+DejaVuSans", + ] { + assert!(s.contains(needle), "{needle}"); + } + for needle in [ + "/CIDFontType2", + "/CIDToGIDMap /Identity", + "/Flags 4", + "/DW 1000", + ] { + assert!(s.contains(needle), "{needle}"); + } + assert_eq!(shown_text(&doc.bytes), [words]); + assert_eq!(page_text(&doc.bytes, 0), [words]); + assert!(doc.missing.is_empty()); + assert_xref_is_right(&doc.bytes); + // The embedded font is the subset, not the 757 kB file. + assert!(doc.bytes.len() < 100_000, "{}", doc.bytes.len()); + } + + #[test] + fn a_character_the_font_lacks_becomes_a_question_mark_and_is_reported() { + // U+8239 (a Chinese character) and U+1F6A2 (an emoji), neither in DejaVu Sans. + let mut pdf = Pdf::new("Missing"); + page_saying(&mut pdf, "\u{8239} A"); + page_saying(&mut pdf, "\u{1F6A2}\u{8239}"); + let doc = pdf.finish().unwrap(); + assert_eq!(doc.missing, ['\u{8239}', '\u{1F6A2}']); + assert_eq!(shown_text(&doc.bytes), ["? A", "??"]); + assert_xref_is_right(&doc.bytes); + } + + #[test] + fn a_title_with_brackets_and_turkish_survives() { + let mut pdf = Pdf::new("a(b)\\c Şile"); + page_saying(&mut pdf, "x"); + let doc = pdf.finish().unwrap(); + assert_xref_is_right(&doc.bytes); + // UTF-16BE with a byte order mark, as hex: FEFF, then "a(b)\c Şile". + let s = text(&doc.bytes); + let hex = "FEFF 0061 0028 0062 0029 005C 0063 0020 015E 0069 006C 0065".replace(' ', ""); + assert!( + s.contains(&format!("/Title <{hex}> ")), + "{}", + &s[s.find("/Title").unwrap()..][..80] + ); + } + + #[test] + fn a_page_over_5_08_m_is_an_error() { + let mut pdf = Pdf::new("Big"); + pdf.add_page(14_401.0, 100.0, Content::new()); + let err = pdf.finish().unwrap_err(); + assert!(err.contains("5.08 m"), "{err}"); + let mut pdf = Pdf::new("Just fits"); + pdf.add_page(MAX_PAGE_PT, MAX_PAGE_PT, Content::new()); + assert!(pdf.finish().is_ok()); + } + + #[test] + fn a_page_with_no_size_is_an_error() { + for (w, h) in [ + (0.0, 100.0), + (f64::NAN, 100.0), + (100.0, -5.0), + (100.0, f64::INFINITY), + ] { + let mut pdf = Pdf::new("Bad"); + pdf.add_page(w, h, Content::new()); + assert!(pdf.finish().is_err(), "{w} x {h}"); + } + assert!( + Pdf::new("Empty").finish().is_err(), + "a document needs a page" + ); + } + + #[test] + fn a_document_without_text_embeds_no_font() { + let mut c = Content::new(); + c.polyline(&[(0.0, 0.0), (10.0, 10.0)], false, BLACK); + c.text((0.0, 0.0), 10.0, 0.0, Anchor::Start, 0.0, ""); + let mut pdf = Pdf::new("Lines only"); + pdf.add_page(100.0, 100.0, c); + let doc = pdf.finish().unwrap(); + assert!(!text(&doc.bytes).contains("/FontFile2")); + assert!(!text(&doc.bytes).contains("/Font")); + assert_xref_is_right(&doc.bytes); + } + + #[test] + fn long_text_wraps_at_spaces_and_content_can_be_joined() { + let words = "one two three four five six seven"; + let width = text_width_pt("one two three", 10.0); + let lines = wrap_text(words, 10.0, width + 0.01); + assert_eq!(lines, ["one two three", "four five six", "seven"]); + assert!(lines.iter().all(|l| text_width_pt(l, 10.0) <= width + 0.01)); + assert_eq!(wrap_text("", 10.0, 100.0), Vec::::new()); + assert_eq!(wrap_text("unbreakable", 10.0, 1.0), ["unbreakable"]); + assert_eq!(wrap_text("fits", 10.0, 1000.0), ["fits"]); + + let mut pdf = Pdf::new("Joined"); + let form = pdf.add_form([0.0, 0.0, 10.0, 10.0], Content::new()); + let mut part = Content::new(); + part.text((10.0, 10.0), 10.0, 0.0, Anchor::Start, 0.0, "part \u{8239}"); + part.form(form); + let mut whole = Content::new(); + whole.polyline(&[(0.0, 0.0), (5.0, 5.0)], false, BLACK); + whole.append(&part); + pdf.add_page(100.0, 100.0, whole); + let doc = pdf.finish().unwrap(); + assert_eq!(page_text(&doc.bytes, 0), ["part ?"]); + assert_eq!(doc.missing, ['\u{8239}']); + let stream = text(&page_stream(&doc.bytes, 0)); + assert!(stream.find("S\n").unwrap() < stream.find("Tj").unwrap()); + let mut c = Content::new(); + c.scale(0.25); + c.scale(0.0); + c.scale(f64::NAN); + assert_eq!(c.ops, "0.250000 0 0 0.250000 0 0 cm\n"); + + assert!(stream.ends_with("/X0 Do\n"), "{stream}"); + assert!(text(&doc.bytes).contains("/XObject << /X0 ")); + } + + /// The width is the sum of the font's own advances, read here a second way through `ttf`. + #[test] + fn anchors_shift_text_by_its_width() { + let font = crate::ttf::Font::parse(crate::fonts::DEJAVU_SANS).unwrap(); + let zero = font.advance(font.glyph('0').unwrap()) as f64; + let width = 3.0 * zero / font.metrics().units_per_em as f64 * 10.0; + assert!((text_width_pt("000", 10.0) - width).abs() < 1e-9); + assert!(width > 15.0 && width < 25.0, "{width}"); + assert_eq!(text_width_pt("", 10.0), 0.0); + + let stream = |anchor, angle| { + let mut c = Content::new(); + c.text((100.0, 50.0), 10.0, angle, anchor, 0.5, "000"); + let mut pdf = Pdf::new("Anchor"); + pdf.add_page(200.0, 200.0, c); + text(&page_stream(&pdf.finish().unwrap().bytes, 0)) + }; + let at = |x: f64, y: f64| format!("1 0 0 1 {x:.3} {y:.3} Tm"); + assert!(stream(Anchor::Start, 0.0).contains(&at(100.0, 50.0))); + assert!(stream(Anchor::Middle, 0.0).contains(&at(100.0 - width / 2.0, 50.0))); + assert!(stream(Anchor::End, 0.0).contains(&at(100.0 - width, 50.0))); + assert!(stream(Anchor::Start, 0.0).contains("0.500 g")); + // Turned a quarter turn, the text runs up the page and the anchor shifts it down. + let up = stream(Anchor::End, 90.0); + assert!( + up.contains(&format!("0 1 -1 0 100.000 {:.3} Tm", 50.0 - width)), + "{up}" + ); + } +} diff --git a/crates/seacraft-core/src/sheet.rs b/crates/seacraft-core/src/sheet.rs new file mode 100644 index 0000000..1c0499d --- /dev/null +++ b/crates/seacraft-core/src/sheet.rs @@ -0,0 +1,2267 @@ +//! A drawing sheet: a 2D drawing in metres, and the writers that put it on paper or send it to a +//! machine. +//! +//! Everything a builder prints goes through [`Sheet`], so the tiled PDF, the single-page PDF, the +//! SVG and the DXF of one pattern are the same lines. x runs right and y up. +//! +//! Line widths and label heights are millimetres on paper: a drawing printed at 1:10 has the same +//! line width as one at 1:1. + +use std::fmt::Write as _; + +use crate::dxf::{write_dxf_in, DxfEntity, DxfLayer, DxfShape, DxfUnits, Vec2}; +pub use crate::pdf::Anchor; +use crate::pdf::{ + text_width_pt, wrap_text, Content, Document, Pdf, Stroke, MAX_PAGE_PT, PT_PER_MM, +}; +use crate::units::scale_label; + +/// What a line on a sheet is for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PathKind { + /// Saw here. Drawn 0.35 mm wide, black. + Cut, + /// Transfer to the wood: a centreline, a waterline, a station line. Drawn 0.18 mm wide, black. + Mark, + /// For the eye only, such as the hull surface outside a mould line. Drawn 0.18 mm wide, grey, + /// dashed. + Reference, +} + +/// A line on a sheet, through `points` in metres. +#[derive(Clone, Debug, PartialEq)] +pub struct SheetPath { + /// What the line is for, which decides how it is drawn. + pub kind: PathKind, + /// The points, in order. + pub points: Vec<(f64, f64)>, + /// Whether the last point joins the first. + pub closed: bool, +} + +/// One line of text on a sheet. +#[derive(Clone, Debug, PartialEq)] +pub struct Label { + /// Where its anchor is, in metres. + pub at: (f64, f64), + /// The letter size on paper, in millimetres; the scale does not change it. + pub height_mm: f64, + /// Turned anticlockwise by this much. + pub angle_deg: f64, + /// Which point of the text is at `at`. + pub anchor: Anchor, + /// The words. + pub text: String, +} + +/// A drawing: lines and labels in metres, and notes that go beside it on paper. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Sheet { + /// What the sheet is, as its pages are captioned: `Station 3`. + pub name: String, + /// The lines. + pub paths: Vec, + /// The text on the drawing. + pub labels: Vec