///|
/// A reproducible export job describing one family of generated deliverables.
pub(all) struct ExportJob {
name : String
seed : UInt
width : Double
height : Double
palette : Palette
variants : Array[(String, Double, Double)]
tags : Array[String]
} derive(Debug)
///|
pub(all) struct ExportVariant {
name : String
document : DesignDocument
asset : SvgAsset
metrics : DesignQualityMetrics
checksum : UInt
} derive(Debug)
///|
pub(all) struct ExportPlan {
job : ExportJob
variants : Array[ExportVariant]
bundle : SvgAssetBundle
total_bytes : Int
quality_min : Double
quality_mean : Double
} derive(Debug)
///|
pub fn ExportJob::new(
name : String,
seed : UInt,
width : Double,
height : Double,
palette : Palette,
) -> ExportJob {
{
name,
seed,
width: width.max(1.0),
height: height.max(1.0),
palette,
variants: [],
tags: [],
}
}
///|
pub fn ExportJob::add_variant(
self : ExportJob,
name : String,
width : Double,
height : Double,
) -> Unit {
self.variants.push((name, width.max(1.0), height.max(1.0)))
}
///|
pub fn ExportJob::tag(self : ExportJob, value : String) -> ExportJob {
if value.length() > 0 && !self.tags.contains(value) {
self.tags.push(value)
}
self
}
///|
pub fn ExportJob::ensure_default_variants(self : ExportJob) -> ExportJob {
if self.variants.length() == 0 {
self.variants.push(("master", self.width, self.height))
self.variants.push(
("thumbnail", self.width.min(512.0), self.height.min(512.0)),
)
self.variants.push(
(
"social-square",
self.width.min(self.height),
self.width.min(self.height),
),
)
}
self
}
///|
pub fn ExportJob::validate(self : ExportJob) -> Array[String] {
let errors = []
if self.name.length() == 0 {
errors.push("export job name is empty")
}
if self.width <= 0.0 || self.height <= 0.0 {
errors.push("export job dimensions are invalid")
}
if self.palette.colors.length() == 0 {
errors.push("export job palette is empty")
}
let seen = []
for variant in self.variants {
if variant.0.length() == 0 {
errors.push("export variant name is empty")
}
if variant.1 <= 0.0 || variant.2 <= 0.0 {
errors.push("export variant dimensions are invalid")
}
if seen.contains(variant.0) {
errors.push("duplicate export variant: " + variant.0)
} else {
seen.push(variant.0)
}
}
errors
}
///|
pub fn export_job_document(
job : ExportJob,
variant : (String, Double, Double),
) -> DesignDocument {
let composition = SceneRecipe::new(
job.seed,
variant.1,
variant.2,
18,
18,
job.palette,
).build()
let metadata = DocumentMetadata::new(
job.name + " / " + variant.0,
"MoonBit Generative Patterns",
job.seed,
"production SVG export",
)
.tag("export")
.tag(variant.0)
DesignDocument::new(composition, metadata)
}
///|
pub fn export_variant(
job : ExportJob,
variant : (String, Double, Double),
) -> ExportVariant {
let document = export_job_document(job, variant)
let asset = svg_asset_from_document(
"export",
job.name + "-" + variant.0,
1,
document.composition.render(),
"batch export",
)
let asset = svg_asset_with_tags(asset, job.tags)
{
name: variant.0,
document,
asset,
metrics: composition_quality_metrics(document.composition),
checksum: svg_asset_checksum(asset),
}
}
///|
pub fn export_plan(job : ExportJob) -> ExportPlan {
let configured = job.ensure_default_variants()
let variants = []
let bundle = SvgAssetBundle::new(configured.name, "1.0.0")
for variant in configured.variants {
let output = export_variant(configured, variant)
let _ = bundle.add(output.asset)
variants.push(output)
}
let mut total = 0
let mut minimum = 1.0
let mut sum = 0.0
for variant in variants {
total += variant.asset.bytes()
minimum = minimum.min(variant.metrics.score)
sum += variant.metrics.score
}
{
job: configured,
variants,
bundle,
total_bytes: total,
quality_min: if configured.variants.length() == 0 {
0.0
} else {
minimum
},
quality_mean: if configured.variants.length() == 0 {
0.0
} else {
sum / configured.variants.length().to_double()
},
}
}
///|
pub fn export_plan_validate(plan : ExportPlan) -> Array[String] {
let errors = plan.job.validate()
for variant in plan.variants {
if variant.asset.document.width <= 0.0 {
errors.push(variant.name + " has invalid document width")
}
if variant.checksum == 0 {
errors.push(variant.name + " has empty checksum")
}
if variant.metrics.score < 0.0 || variant.metrics.score > 1.0 {
errors.push(variant.name + " quality score is outside [0,1]")
}
}
for error in svg_bundle_validate(plan.bundle) {
errors.push(error)
}
errors
}
///|
pub fn export_plan_manifest(plan : ExportPlan) -> String {
let builder = StringBuilder()
builder.write_string(
"name,variant,width,height,bytes,checksum,quality,coverage\n",
)
for variant in plan.variants {
builder.write_string(
plan.job.name +
"," +
variant.name +
"," +
variant.asset.view_box.width.to_string() +
"," +
variant.asset.view_box.height.to_string() +
"," +
variant.asset.bytes().to_string() +
"," +
variant.checksum.to_string() +
"," +
variant.metrics.score.to_string() +
"," +
variant.metrics.coverage.to_string() +
"\n",
)
}
builder.to_string()
}
///|
pub fn export_plan_report(plan : ExportPlan) -> String {
"job=" +
plan.job.name +
" variants=" +
plan.variants.length().to_string() +
" bytes=" +
plan.total_bytes.to_string() +
" quality_min=" +
plan.quality_min.to_string() +
" quality_mean=" +
plan.quality_mean.to_string() +
" bundle_checksum=" +
svg_bundle_checksum(plan.bundle).to_string()
}
///|
pub fn export_plan_render_index(plan : ExportPlan) -> String {
let builder = StringBuilder()
builder.write_string(
"" +
escape_svg(plan.job.name) +
"
",
)
for variant in plan.variants {
builder.write_string(
"- " +
escape_svg(variant.name) +
" score=" +
variant.metrics.score.to_string() +
"
",
)
}
builder.write_string("
")
builder.to_string()
}
///|
pub fn export_job_from_sizes(
name : String,
seed : UInt,
palette : Palette,
sizes : Array[(String, Double, Double)],
) -> ExportJob {
let width = if sizes.length() == 0 { 1024.0 } else { sizes[0].1 }
let height = if sizes.length() == 0 { 1024.0 } else { sizes[0].2 }
let job = ExportJob::new(name, seed, width, height, palette)
for size in sizes {
job.add_variant(size.0, size.1, size.2)
}
job
}
///|
pub fn export_job_standard(
name : String,
seed : UInt,
palette : Palette,
) -> ExportJob {
ExportJob::new(name, seed, 2048.0, 2048.0, palette)
.tag("standard")
.tag("svg")
.ensure_default_variants()
}
///|
pub fn export_job_square(
name : String,
seed : UInt,
size : Double,
palette : Palette,
) -> ExportJob {
ExportJob::new(name, seed, size, size, palette)
.tag("square")
.ensure_default_variants()
}
///|
pub fn export_job_poster(
name : String,
seed : UInt,
width : Double,
height : Double,
palette : Palette,
) -> ExportJob {
ExportJob::new(name, seed, width, height, palette)
.tag("poster")
.tag("print")
.ensure_default_variants()
}
///|
pub fn export_plan_thumbnail(
plan : ExportPlan,
width : Double,
height : Double,
) -> SvgDocument {
let document = SvgDocument::new(width.max(1.0), height.max(1.0))
let mut cursor = 0.0
for variant in plan.variants {
let scaled = svg_document_scale(
variant.asset.document,
(width / variant.asset.view_box.width).min(
height / variant.asset.view_box.height,
),
)
document.add(
SvgElement::SvgGroup(
scaled.elements,
transform=Matrix2D::translate(cursor, 0.0),
),
)
cursor += scaled.width
}
document
}
///|
pub fn export_plan_quality_gate(
plan : ExportPlan,
minimum_score : Double,
minimum_coverage : Double,
) -> Bool {
if plan.variants.length() == 0 {
false
} else {
for variant in plan.variants {
if !quality_threshold_pass(
variant.metrics,
minimum_score,
minimum_coverage,
) {
return false
}
}
true
}
}
///|
pub fn export_plan_rechecksum(plan : ExportPlan) -> Array[(String, UInt)] {
let result = []
for variant in plan.variants {
result.push((variant.name, svg_asset_checksum(variant.asset)))
}
result
}
///|
pub fn export_plan_names(plan : ExportPlan) -> Array[String] {
let result = []
for variant in plan.variants {
result.push(variant.name)
}
result
}
///|
pub fn export_job_palette_report(job : ExportJob) -> String {
color_analysis_report_text(analyze_palette(job.palette)) +
"\n" +
palette_report_csv(job.palette)
}
///|
pub fn export_plan_assets(plan : ExportPlan) -> Array[SvgAsset] {
let result = []
for variant in plan.variants {
result.push(variant.asset)
}
result
}
///|
pub fn export_plan_archive_name(plan : ExportPlan) -> String {
plan.job.name +
"-" +
plan.job.seed.to_string() +
"-" +
plan.variants.length().to_string() +
"-assets"
}