use cjseq::{CityJSON, CityJSONFeature, Transform as CjTransform};
use clap::{ArgAction, Parser, Subcommand};
use console::{style, Term};
use fcb_cli::CliError;
use fcb_core::error::Error;
use fcb_core::{
attribute::{AttributeSchema, AttributeSchemaMethods},
deserializer,
header_writer::HeaderWriterOptions,
FcbReader, FcbWriter,
};
use glob::glob;
use indicatif::{ProgressBar, ProgressStyle};
use std::{
fs::File,
io::{self, BufReader, BufWriter, Read, Write},
path::PathBuf,
};
#[derive(Parser)]
#[command(
name = "fcb",
author,
version,
about = "CLI tool for CityJSON <-> FCB conversion"
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Ser {
#[arg(required = true, num_args = 1..)]
input: Vec<String>,
output: String,
#[arg(short = 'a', long)]
attr_index: Option<String>,
#[arg(short = 'A', long, action = ArgAction::SetTrue)]
index_all_attributes: bool,
#[arg(short = 's', long, action = ArgAction::SetTrue)]
no_spatial_index: bool,
#[arg(long)]
attr_branching_factor: Option<u16>,
#[arg(long)]
index_node_size: Option<u16>,
#[arg(long, action = ArgAction::SetTrue)]
no_feature_count: bool,
#[arg(short = 'b', long)]
bbox: Option<String>,
#[arg(short = 'g', long, action = ArgAction::SetTrue)]
ge: bool,
},
Deser {
input: String,
output: String,
},
Cbor {
input: String,
output: String,
},
Bson {
input: String,
output: String,
},
Inspect {
source: String,
#[arg(long = "static", action = ArgAction::SetTrue)]
static_report: bool,
},
}
fn get_reader(input: &str) -> Result<Box<dyn Read>, Error> {
match input {
"-" => Ok(Box::new(io::stdin())),
path => Ok(Box::new(File::open(path)?)),
}
}
fn get_writer(output: &str) -> Result<Box<dyn Write>, Error> {
match output {
"-" => Ok(Box::new(io::stdout())),
path => Ok(Box::new(File::create(path)?)),
}
}
struct SerializeOptions {
attr_index: Option<String>,
index_all_attributes: bool,
no_spatial_index: bool,
attr_branching_factor: Option<u16>,
index_node_size: Option<u16>,
no_feature_count: bool,
bbox: Option<String>,
ge: bool,
}
fn serialize(inputs: &[String], output: &str, options: SerializeOptions) -> Result<(), CliError> {
let term = Term::stderr();
let is_stdout = output == "-";
if !is_stdout {
term.write_line(&format!(
"\n{} {}",
style("━━━").bold().cyan(),
style("FlatCityBuf Serialization").bold().cyan()
))
.ok();
term.write_line(&format!(
"{} {}",
style("━━━").bold().cyan(),
style("━━━━━━━━━━━━━━━━━━━━━━━━").bold().cyan()
))
.ok();
}
let mut input_paths: Vec<PathBuf> = Vec::new();
for pattern in inputs {
let paths: Vec<PathBuf> = glob(pattern)?.filter_map(|entry| entry.ok()).collect();
if paths.is_empty() {
input_paths.push(PathBuf::from(pattern));
} else {
input_paths.extend(paths);
}
}
if input_paths.is_empty() {
return Err(CliError::NoInputFiles);
}
let writer = get_writer(output)?;
let writer = BufWriter::new(writer);
let bbox_parsed = if let Some(bbox_str) = &options.bbox {
Some(parse_bbox(bbox_str).map_err(|e| {
CliError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("failed to parse bbox: {e}"),
))
})?)
} else {
None
};
if !is_stdout {
term.write_line("").ok();
term.write_line(&format!("{} Configuration", style("▶").bold().green()))
.ok();
term.write_line(&format!(
" {} {} file(s)",
style("Input:").dim(),
style(input_paths.len()).yellow()
))
.ok();
for (i, path) in input_paths.iter().enumerate().take(5) {
term.write_line(&format!(
" {}. {}",
style(i + 1).dim(),
style(path.display()).yellow()
))
.ok();
}
if input_paths.len() > 5 {
term.write_line(&format!(
" {} {} more files...",
style("...").dim(),
style(input_paths.len() - 5).dim()
))
.ok();
}
term.write_line(&format!(
" {} {}",
style("Output:").dim(),
style(output).yellow()
))
.ok();
term.write_line(&format!(
" {} {}",
style("Spatial Index:").dim(),
if options.no_spatial_index {
style("disabled").red()
} else {
style("enabled").green()
}
))
.ok();
if let Some(bbox) = &bbox_parsed {
term.write_line(&format!(
" {} [{:.2}, {:.2}, {:.2}, {:.2}]",
style("Bounding Box:").dim(),
bbox[0],
bbox[1],
bbox[2],
bbox[3]
))
.ok();
}
if options.index_all_attributes {
term.write_line(&format!(
" {} {}",
style("Attribute Index:").dim(),
style("all attributes").green()
))
.ok();
} else if let Some(attrs) = &options.attr_index {
term.write_line(&format!(
" {} {}",
style("Attribute Index:").dim(),
style(attrs).green()
))
.ok();
}
if let Some(bf) = options.attr_branching_factor {
term.write_line(&format!(
" {} {}",
style("Branching Factor:").dim(),
style(bf).yellow()
))
.ok();
}
term.write_line(&format!(
" {} {}",
style("Geospatial Extent:").dim(),
if options.ge {
style("auto-calculate").green()
} else {
style("not set").dim()
}
))
.ok();
term.write_line("").ok();
}
if !is_stdout {
term.write_line(&format!(
"{} Reading CityJSON...",
style("▶").bold().green()
))
.ok();
}
let merge_result = fcb_cli::merger::merge_files(input_paths)?;
let cj = merge_result.metadata;
let features = merge_result.features;
if !is_stdout {
term.write_line(&format!(
" {} {} features",
style("✓").bold().green(),
style(features.len()).bold().yellow()
))
.ok();
}
if !is_stdout && bbox_parsed.is_some() {
term.write_line(&format!(
"{} Filtering by bounding box...",
style("▶").bold().green()
))
.ok();
}
let filtered_features = if let Some(bbox) = &bbox_parsed {
features
.into_iter()
.filter(|feature| feature_intersects_bbox(feature, bbox, &cj.transform))
.collect()
} else {
features
};
if filtered_features.is_empty() {
if !is_stdout {
term.write_line(&format!(
" {} No features found within the specified bbox",
style("⚠").bold().yellow()
))
.ok();
}
} else if !is_stdout && bbox_parsed.is_some() {
term.write_line(&format!(
" {} {} features after filtering",
style("✓").bold().green(),
style(filtered_features.len()).bold().yellow()
))
.ok();
}
if !is_stdout {
term.write_line(&format!(
"{} Building attribute schema...",
style("▶").bold().green()
))
.ok();
}
let attr_schema = {
let mut schema = AttributeSchema::new();
for feature in filtered_features.iter().take(1000) {
let mut ids: Vec<&String> = feature.city_objects.keys().collect();
ids.sort_unstable();
for co in ids
.into_iter()
.filter_map(|id| feature.city_objects.get(id))
{
if let Some(attributes) = &co.attributes {
schema.add_attributes(attributes);
}
}
}
if schema.is_empty() {
None
} else {
Some(schema)
}
};
if !is_stdout {
if let Some(ref schema) = attr_schema {
term.write_line(&format!(
" {} {} unique attributes found",
style("✓").bold().green(),
style(schema.len()).bold().yellow()
))
.ok();
} else {
term.write_line(&format!(
" {} No attributes found",
style("✓").bold().green()
))
.ok();
}
}
let semantic_attr_schema = {
let mut schema = AttributeSchema::new();
for feature in filtered_features.iter() {
let mut ids: Vec<&String> = feature.city_objects.keys().collect();
ids.sort_unstable();
for co in ids
.into_iter()
.filter_map(|id| feature.city_objects.get(id))
{
if let Some(geometry) = &co.geometry {
for geom in geometry.iter() {
if let Some(semantics) = geom.common().and_then(|c| c.semantics.as_ref()) {
for sem_obj in semantics.surfaces.iter() {
if !sem_obj.other.is_empty() {
let other = serde_json::Value::Object(
sem_obj.other.clone().into_iter().collect(),
);
schema.add_attributes(&other);
}
}
}
}
}
}
}
if schema.is_empty() {
None
} else {
Some(schema)
}
};
let attr_index_vec: Option<Vec<(String, Option<u16>)>> =
if options.index_all_attributes && attr_schema.is_some() {
Some(
attr_schema
.clone()
.unwrap()
.iter()
.map(|attr| {
(
attr.0.to_string(),
Some(options.attr_branching_factor.unwrap_or(256)),
)
})
.collect::<Vec<(String, Option<u16>)>>(),
)
} else {
options.attr_index.map(|s| {
s.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.map(|s| (s, options.attr_branching_factor))
.collect::<Vec<(String, Option<u16>)>>()
})
};
let geo_extent = if options.ge {
if !is_stdout {
term.write_line(&format!(
"{} Calculating geospatial extent...",
style("▶").bold().green()
))
.ok();
}
let extent = calculate_geospatial_extent(&filtered_features, &cj.transform);
if !is_stdout {
term.write_line(&format!(
" {} Min: [{:.2}, {:.2}, {:.2}]",
style("✓").bold().green(),
extent[0],
extent[1],
extent[2]
))
.ok();
term.write_line(&format!(
" Max: [{:.2}, {:.2}, {:.2}]",
extent[3], extent[4], extent[5]
))
.ok();
}
Some(extent)
} else {
None
};
let header_options = HeaderWriterOptions {
write_index: !options.no_spatial_index,
feature_count: if options.no_feature_count {
0
} else {
filtered_features.len() as u64
},
index_node_size: options.index_node_size.unwrap_or(16),
attribute_indices: attr_index_vec.clone(),
geographical_extent: geo_extent,
};
if !is_stdout {
term.write_line(&format!(
"{} Building indices...",
style("▶").bold().green()
))
.ok();
if !options.no_spatial_index {
term.write_line(&format!(
" {} Spatial R-tree index (node size: {})",
style("✓").bold().green(),
style(header_options.index_node_size).yellow()
))
.ok();
}
if let Some(ref indices) = attr_index_vec {
term.write_line(&format!(
" {} Attribute B+Tree indices for {} attributes:",
style("✓").bold().green(),
style(indices.len()).yellow()
))
.ok();
for (attr_name, bf) in indices.iter().take(5) {
term.write_line(&format!(
" • {} (branching factor: {})",
style(attr_name).cyan(),
style(bf.unwrap_or(16)).dim()
))
.ok();
}
if indices.len() > 5 {
term.write_line(&format!(
" {} {} more attributes...",
style("...").dim(),
style(indices.len() - 5).dim()
))
.ok();
}
}
term.write_line("").ok();
}
if !is_stdout {
term.write_line(&format!(
"{} Writing FCB file...",
style("▶").bold().green()
))
.ok();
}
let mut fcb = FcbWriter::new(cj, Some(header_options), attr_schema, semantic_attr_schema)?;
let pb = if !is_stdout {
let pb = ProgressBar::new(filtered_features.len() as u64);
pb.set_style(
ProgressStyle::default_bar()
.template(" {bar:40.cyan/blue} {pos}/{len} features ({percent}%)")
.unwrap()
.progress_chars("━━╾─"),
);
Some(pb)
} else {
None
};
for feature in filtered_features.iter() {
fcb.add_feature(feature)?;
if let Some(ref pb) = pb {
pb.inc(1);
}
}
if let Some(ref pb) = pb {
pb.finish_and_clear();
}
fcb.write(writer)?;
if !is_stdout {
term.write_line(&format!(
" {} File written successfully",
style("✓").bold().green()
))
.ok();
term.write_line("").ok();
term.write_line(&format!(
"{} {}",
style("━━━").bold().cyan(),
style("Serialization Complete").bold().cyan()
))
.ok();
term.write_line(&format!(
"{} {}",
style("━━━").bold().cyan(),
style("━━━━━━━━━━━━━━━━━━━━━━").bold().cyan()
))
.ok();
term.write_line("").ok();
}
Ok(())
}
fn parse_bbox(bbox_str: &str) -> Result<[f64; 4], String> {
let parts: Vec<&str> = bbox_str.split(',').collect();
if parts.len() != 4 {
return Err(format!(
"Invalid bounding box format. Expected 'minx,miny,maxx,maxy', got '{bbox_str}'"
));
}
let mut bbox = [0.0; 4];
for (i, part) in parts.iter().enumerate() {
bbox[i] = part
.trim()
.parse::<f64>()
.map_err(|e| format!("Failed to parse bbox component: {e}"))?;
}
if bbox[0] > bbox[2] || bbox[1] > bbox[3] {
return Err(
"Invalid bounding box: min values must be less than or equal to max values".to_string(),
);
}
Ok(bbox)
}
fn get_vertices_from_feature(feature: &CityJSONFeature, transform: &CjTransform) -> Vec<[f64; 3]> {
let mut result = Vec::new();
for vertex in &feature.vertices {
if vertex.len() >= 3 {
let x = (vertex[0] as f64 * transform.scale[0]) + transform.translate[0];
let y = (vertex[1] as f64 * transform.scale[1]) + transform.translate[1];
let z = (vertex[2] as f64 * transform.scale[2]) + transform.translate[2];
result.push([x, y, z]);
}
}
result
}
fn feature_intersects_bbox(
feature: &CityJSONFeature,
bbox: &[f64; 4],
transform: &CjTransform,
) -> bool {
let vertices = get_vertices_from_feature(feature, transform);
if city_object_intersects_bbox(bbox, &vertices) {
return true;
}
false
}
fn city_object_intersects_bbox(bbox: &[f64; 4], feature_vertices: &[[f64; 3]]) -> bool {
for vertex in feature_vertices {
if point_in_bbox_2d(vertex, bbox) {
return true;
}
}
false
}
fn point_in_bbox_2d(point: &[f64; 3], bbox: &[f64; 4]) -> bool {
point[0] >= bbox[0] && point[0] <= bbox[2] && point[1] >= bbox[1] && point[1] <= bbox[3]
}
fn calculate_geospatial_extent(features: &[CityJSONFeature], transform: &CjTransform) -> [f64; 6] {
let mut min_x = f64::MAX;
let mut min_y = f64::MAX;
let mut min_z = f64::MAX;
let mut max_x = f64::MIN;
let mut max_y = f64::MIN;
let mut max_z = f64::MIN;
for feature in features {
let vertices = get_vertices_from_feature(feature, transform);
for [x, y, z] in vertices {
min_x = min_x.min(x);
min_y = min_y.min(y);
min_z = min_z.min(z);
max_x = max_x.max(x);
max_y = max_y.max(y);
max_z = max_z.max(z);
}
}
if min_x == f64::MAX {
return [0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
}
[min_x, min_y, min_z, max_x, max_y, max_z]
}
fn deserialize(input: &str, output: &str) -> Result<(), Error> {
let reader = BufReader::new(get_reader(input)?);
let mut writer = BufWriter::new(get_writer(output)?);
let mut fcb_reader = FcbReader::open(reader)?.select_all_seq()?;
let header = fcb_reader.header();
let cj = deserializer::to_cj_metadata(&header)?;
writeln!(writer, "{}", serde_json::to_string(&cj)?)?;
while let Some(feat_buf) = fcb_reader.next()? {
let feature = feat_buf.cur_cj_feature()?;
writeln!(writer, "{}", serde_json::to_string(&feature)?)?;
}
if output != "-" {
eprintln!("Successfully decoded to CityJSON");
}
Ok(())
}
fn encode_cbor(input: &str, output: &str) -> Result<(), Error> {
let reader = BufReader::new(get_reader(input)?);
let writer = BufWriter::new(get_writer(output)?);
let value: serde_json::Value = serde_json::from_reader(reader)?;
serde_cbor::to_writer(writer, &value).map_err(|e| {
Error::IoError(std::io::Error::other(format!(
"failed to encode to cbor: {e}"
)))
})?;
if output != "-" {
eprintln!("successfully encoded to cbor");
}
Ok(())
}
fn encode_bson(input: &str, output: &str) -> Result<(), Error> {
let mut reader = BufReader::new(get_reader(input)?);
let json_str = {
let mut s = String::new();
reader.read_to_string(&mut s)?;
s
};
let cityjson: CityJSON = serde_json::from_str(&json_str)?;
let bson = bson::to_bson(&cityjson).map_err(|e| {
Error::IoError(std::io::Error::other(format!(
"failed to encode to bson: {e}"
)))
})?;
let doc = bson.as_document().unwrap();
let mut writer = get_writer(output)?;
doc.to_writer(&mut writer).map_err(|e| {
Error::IoError(std::io::Error::other(format!(
"failed to encode to bson: {e}"
)))
})?;
if output != "-" {
eprintln!("successfully encoded to bson");
}
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
match cli.command {
Commands::Ser {
input,
output,
attr_index,
index_all_attributes,
no_spatial_index,
attr_branching_factor,
index_node_size,
no_feature_count,
bbox,
ge,
} => serialize(
&input,
&output,
SerializeOptions {
attr_index,
index_all_attributes,
no_spatial_index,
attr_branching_factor,
index_node_size,
no_feature_count,
bbox,
ge,
},
)?,
Commands::Deser { input, output } => deserialize(&input, &output)?,
Commands::Cbor { input, output } => encode_cbor(&input, &output)?,
Commands::Bson { input, output } => encode_bson(&input, &output)?,
Commands::Inspect {
source,
static_report,
} => {
if let Err(err) = fcb_cli::inspect::run_inspect(&source, static_report) {
eprintln!("{err}");
std::process::exit(1);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verify_cli() {
use clap::CommandFactory;
Cli::command().debug_assert();
}
}