Something went wrong. Try again.
atproto Thingiverse but good
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091//! Publish-draft data model.//!//! A draft is a server-side, non-public, in-progress publish composition. It is//! persisted as a [`DraftThingInput`]: a mirror of the generated//! [`ThingInput`](polymodel_api::space_polymodel::library::publish_thing::ThingInput)//! with every field optional, so a partial draft (saved before a name, license,//! or any model exists) round-trips losslessly. The same camelCase field names//! keep the payload shape aligned with the publishable record.//!//! Leaf media/reference types (`Image`, `File`, `Bbox`, `StrongRef`) are reused//! from the generated API at their default owned-string type, so an uploaded//! image/file ref stored in a draft is byte-identical to what `publishThing`//! consumes.//!//! [`DraftThingInput::assemble`] is the single conversion point from a draft to//! a publishable `ThingInput`: it validates required structure (which is//! enforced *structurally* by the typed record — `name`/`license`/`models`,//! `name`/`parts`, `name`+`file` are not optional there) and then defers the//! length/grapheme limits to the generated [`LexiconSchema::validate`], so draft//! validation cannot drift from the lexicon.
use jacquard::common::types::string::AtUri;use jacquard_common::DefaultStr;use jacquard_lexicon::schema::LexiconSchema;use polymodel_api::com_atproto::repo::strong_ref::StrongRef;
use crate::thing_detail::thing_uri_from_route;use polymodel_api::space_polymodel::library::publish_thing::{ModelInput, PartInput, ThingInput};use polymodel_api::space_polymodel::library::{File, Image};use polymodel_renderer_protocol::{MeshFormat, ModelLoadBundle, ModelResource, PROTOCOL_VERSION};use serde::{Deserialize, Serialize};use sha2::Digest;
// Re-export leaf types the publish UI edits directly.pub use polymodel_api::space_polymodel::library::Bbox;
/// The owned string type the generated records use (`DefaultStr`).type S = DefaultStr;
// ----------------------------------------------------------------------------// Draft endpoint wire types//// Defined here (cross-target) rather than in the server-only `appview::drafts`// module so the wasm client can share them with the server.// ----------------------------------------------------------------------------
/// Response for create/upsert: the opaque draft id the client should keep.#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]pub struct DraftIdResponse { pub draft_id: String,}
/// A draft listing row, rendered from denormalized columns (no payload parse).#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]#[serde(rename_all = "camelCase")]pub struct DraftSummary { pub draft_id: String, pub name: Option<String>, pub model_count: i64, pub created_at: i64, pub updated_at: i64,}
/// Response for delete: whether a row actually existed.#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]pub struct DeleteDraftResponse { pub deleted: bool,}
/// Response for image upload: a fully-formed `#image` ready to attach.#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]pub struct ImageUploadResponse { pub image: Image,}
/// A `ThingInput`-shaped draft payload with every field optional.#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]#[serde(rename_all = "camelCase")]pub struct DraftThingInput { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub license: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub summary: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub tags: Option<Vec<String>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub instructions: Option<Vec<String>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub cover: Option<Vec<Image>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub previews: Option<Vec<Image>>, /// Remix provenance; design pending. Carried pass-through, not yet surfaced. #[serde(default, skip_serializing_if = "Option::is_none")] pub derived_from: Option<StrongRef>, /// Thing-level compatibility links, surfaced as resolvable "Works with" chips. #[serde(default, skip_serializing_if = "Option::is_none")] pub intended_to_work_with: Option<Vec<StrongRef>>, #[serde(default)] pub models: Vec<DraftModelInput>,}
/// Metadata-only reference to owner-scoped geometry in `upload_staging`./// Geometry bytes are reacquired through the authenticated staging boundary and/// never serialized into draft JSON.#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]#[serde(rename_all = "camelCase")]pub struct DraftStagedResource { pub resource_id: String, pub upload_id: String, /// Immutable filename supplied when the source was staged. pub source_name: String, pub mime_type: String, pub digest: Vec<u8>, pub byte_length: u64, #[serde(default, skip_serializing_if = "Option::is_none")] pub format: Option<String>,}
/// Ephemeral verified bytes paired with persisted staging metadata.#[derive(Debug, Clone, PartialEq, Eq)]pub struct AcquiredDraftResource { pub metadata: DraftStagedResource, pub bytes: Vec<u8>,}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]#[serde(rename_all = "camelCase")]pub struct GeneratedPreview { pub source_resource_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub operation_id: Option<String>, pub image: Image,}
#[derive(Debug, Clone, PartialEq, Eq)]pub enum DraftBundlePlan { Complete(ModelLoadBundle), Incomplete(String),}
pub const MAX_PREVIEW_RESOURCES: usize = 64;pub const MAX_PREVIEW_RESOURCE_BYTES: u64 = 32 * 1024 * 1024;pub const MAX_PREVIEW_AGGREGATE_BYTES: u64 = 128 * 1024 * 1024;
/// Plan a complete transitive draft-local LDraw bundle. Stable IDs choose the/// primary; immutable source names participate only in canonical path resolution.pub fn plan_draft_ldraw_bundle( primary_resource_id: &str, staged: &[AcquiredDraftResource],) -> DraftBundlePlan { if staged.is_empty() || staged.len() > MAX_PREVIEW_RESOURCES { return DraftBundlePlan::Incomplete("preview resource count exceeds bounds".into()); } let mut aggregate = 0_u64; let Some(primary) = staged .iter() .find(|resource| resource.metadata.resource_id == primary_resource_id) else { return DraftBundlePlan::Incomplete("primary staged resource is missing".into()); }; let primary_name = &primary.metadata.source_name; let primary_path = if primary_name.contains('/') { primary_name.clone() } else { format!("models/{primary_name}") }; let primary_path = match polymodel_ldraw_core::NormalizedPath::new(&primary_path) { Ok(path) => path, Err(error) => return DraftBundlePlan::Incomplete(error.to_string()), }; let mut resources = Vec::with_capacity(staged.len()); let mut acquired_by_path = std::collections::HashMap::new(); for acquired in staged { let metadata = &acquired.metadata; if metadata.digest.len() != 32 || metadata.byte_length != acquired.bytes.len() as u64 || metadata.byte_length > MAX_PREVIEW_RESOURCE_BYTES || metadata.digest.as_slice() != &sha2::Sha256::digest(&acquired.bytes)[..] { return DraftBundlePlan::Incomplete("staged resource integrity mismatch".into()); } aggregate = match aggregate.checked_add(metadata.byte_length) { Some(value) if value <= MAX_PREVIEW_AGGREGATE_BYTES => value, _ => return DraftBundlePlan::Incomplete("preview aggregate exceeds bounds".into()), }; let raw_path = if metadata.resource_id == primary_resource_id { primary_path.as_str().to_owned() } else if metadata.source_name.contains('/') { metadata.source_name.clone() } else { format!("p/{}", metadata.source_name) }; let path = match polymodel_ldraw_core::NormalizedPath::new(&raw_path) { Ok(path) => path, Err(error) => return DraftBundlePlan::Incomplete(error.to_string()), }; let key = path.as_str().to_owned(); if acquired_by_path.insert(key.clone(), acquired).is_some() { return DraftBundlePlan::Incomplete(format!("duplicate staged path: {key}")); } let root = key.split('/').next().unwrap_or("models").to_owned(); let resource = ModelResource { key: key.clone(), path: key, root, byte_length: metadata.byte_length, sha256: match metadata.digest.as_slice().try_into() { Ok(digest) => digest, Err(_) => { return DraftBundlePlan::Incomplete( "staged resource digest is not SHA-256".into(), ); } }, mime_type: metadata.mime_type.clone(), target_cids: Box::default(), bytes: acquired.bytes.clone().into_boxed_slice(), }; if let Err(error) = resource.validate() { return DraftBundlePlan::Incomplete(format!("staged resource rejected: {error:?}")); } resources.push(resource); }
let mut pending = std::collections::VecDeque::from([primary_path.clone()]); let mut visited = std::collections::HashSet::new(); while let Some(current) = pending.pop_front() { if !visited.insert(current.as_str().to_owned()) { continue; } let Some(source) = acquired_by_path.get(current.as_str()) else { return DraftBundlePlan::Incomplete(format!("staged companion is missing: {current}")); }; let references = match polymodel_ldraw_core::extract_references(&source.bytes) { Ok(references) => references, Err(error) => return DraftBundlePlan::Incomplete(error.to_string()), }; for reference in references { if reference.target_virtual_file { continue; } let resolved = match polymodel_ldraw_core::resolve_reference_path(¤t, &reference) { Ok(resolved) => resolved, Err(error) => return DraftBundlePlan::Incomplete(error.to_string()), }; if !acquired_by_path.contains_key(resolved.path.as_str()) { return DraftBundlePlan::Incomplete(format!( "staged companion is missing: {}", resolved.path )); } pending.push_back(resolved.path); } } let bundle = ModelLoadBundle { version: PROTOCOL_VERSION, format: MeshFormat::CompoundLdraw, primary: primary_path.as_str().to_owned(), resources: resources.into_boxed_slice(), source_identities: Vec::new(), target_selections: Vec::new(), }; match bundle.validate() { Ok(()) => DraftBundlePlan::Complete(bundle), Err(error) => DraftBundlePlan::Incomplete(format!("model bundle rejected: {error:?}")), }}
/// A model within a draft. `parts` are ordered; an empty draft model is allowed/// (it simply fails publish validation until it has a name and at least one part).#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]#[serde(rename_all = "camelCase")]pub struct DraftModelInput { #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub primary_resource_id: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub generated_preview: Option<GeneratedPreview>, #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub summary: Option<String>, /// Per-model license override. #[serde(default, skip_serializing_if = "Option::is_none")] pub license: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub tags: Option<Vec<String>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub instructions: Option<Vec<String>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub cover: Option<Vec<Image>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub previews: Option<Vec<Image>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub derived_from: Option<StrongRef>, #[serde(default, skip_serializing_if = "Option::is_none")] pub intended_to_work_with: Option<Vec<StrongRef>>, #[serde(default)] pub parts: Vec<DraftPartInput>, /// All geometry staged for this model, including files that are companions /// rather than publishable parts. This is the draft-local preview set. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub staged_resources: Vec<DraftStagedResource>,}
/// A part within a draft model. A part is publishable once it has a name and a/// successfully-staged file (an inline `file` manifest or a stage `upload_id`).#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]#[serde(rename_all = "camelCase")]pub struct DraftPartInput { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub resource_id: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub generated_preview: Option<GeneratedPreview>, /// stageFile upload id; the server resolves it to a `File` manifest at publish. #[serde(default, skip_serializing_if = "Option::is_none")] pub upload_id: Option<String>, /// Inline file manifest (alternative to `upload_id`). #[serde(default, skip_serializing_if = "Option::is_none")] pub file: Option<File>, #[serde(default, skip_serializing_if = "Option::is_none")] pub format: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub notes: Option<String>, /// Unit label for this part's dimensions (distinct from `dimensions.unit`). #[serde(default, skip_serializing_if = "Option::is_none")] pub units: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub print_settings: Option<Vec<String>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub dimensions: Option<Bbox>, #[serde(default, skip_serializing_if = "Option::is_none")] pub previews: Option<Vec<Image>>, /// Remix provenance; design pending. Carried pass-through, not yet surfaced. #[serde(default, skip_serializing_if = "Option::is_none")] pub derived_from: Option<StrongRef>, /// Part-level compatibility links; not yet surfaced (thing-level only). #[serde(default, skip_serializing_if = "Option::is_none")] pub intended_to_work_with: Option<Vec<StrongRef>>,}
/// One actionable validation problem, addressed to a draft field path.#[derive(Debug, Clone, PartialEq, Eq)]pub struct ValidationIssue { pub field: String, pub message: String,}
/// All validation problems found while assembling a draft into a `ThingInput`.#[derive(Debug, Clone, PartialEq, Eq, Default)]pub struct ValidationErrors { pub issues: Vec<ValidationIssue>,}
impl ValidationErrors { fn push(&mut self, field: impl Into<String>, message: impl Into<String>) { self.issues.push(ValidationIssue { field: field.into(), message: message.into(), }); }
pub fn is_empty(&self) -> bool { self.issues.is_empty() }
/// Whether any issue targets the given field path exactly. pub fn has_field(&self, field: &str) -> bool { self.issues.iter().any(|i| i.field == field) }}
impl std::fmt::Display for ValidationErrors { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let mut first = true; for issue in &self.issues { if !first { write!(f, "; ")?; } write!(f, "{}: {}", issue.field, issue.message)?; first = false; } Ok(()) }}
fn nonempty(value: &Option<String>) -> bool { value .as_deref() .map(|v| !v.trim().is_empty()) .unwrap_or(false)}
fn into_s(value: &str) -> S { S::from(value.trim())}
fn opt_s(value: &Option<String>) -> Option<S> { value .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) .map(S::from)}
fn opt_vec_s(value: &Option<Vec<String>>) -> Option<Vec<S>> { value .as_ref() .map(|items| items.iter().map(|s| S::from(s.trim())).collect::<Vec<_>>())}
/// Convert a freeform multi-line text field (stored raw, one entry per line)/// into a list of non-empty steps. Whitespace-only lines are dropped here, at/// assembly, so the live textarea can keep spaces and blank lines while typing.fn clean_lines(value: &Option<Vec<String>>) -> Option<Vec<S>> { let lines: Vec<S> = value .as_ref()? .iter() .map(|line| line.trim_end()) .filter(|line| !line.trim().is_empty()) .map(S::from) .collect(); if lines.is_empty() { None } else { Some(lines) }}
impl DraftPartInput { /// A part is backed by a staged file when it has either an inline manifest /// or a non-empty upload id. pub fn has_file(&self) -> bool { self.file.is_some() || nonempty(&self.upload_id) }
/// A part is publishable when it has a non-empty name and a staged file. #[allow(dead_code)] pub fn is_publishable(&self) -> bool { nonempty(&self.name) && self.has_file() }}
impl DraftModelInput { /// A model is publishable when it has a non-empty name and at least one /// publishable part. #[allow(dead_code)] pub fn is_publishable(&self) -> bool { nonempty(&self.name) && !self.parts.is_empty() && self.parts.iter().all(|p| p.is_publishable()) }}
impl DraftThingInput { /// Display name for draft listings, if set. pub fn display_name(&self) -> Option<&str> { self.name .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) }
/// Number of models (for the denormalized `model_count` listing column). pub fn model_count(&self) -> i64 { self.models.len() as i64 }
/// Basics is complete once the thing has a name. License has an effective /// default and is chosen in the details step, so it never gates Basics. #[allow(dead_code)] pub fn basics_complete(&self) -> bool { nonempty(&self.name) }
/// Step 2 (Files & parts) is complete when there is at least one model and /// every model is publishable. #[allow(dead_code)] pub fn files_complete(&self) -> bool { !self.models.is_empty() && self.models.iter().all(|m| m.is_publishable()) }
/// Validate the draft and convert it into a publishable `ThingInput`. /// /// Required structure is checked here (the draft is all-optional); the /// length/grapheme limits are delegated to the generated /// [`LexiconSchema::validate`] on the assembled records so this code never /// re-declares the lexicon's constraints. pub fn assemble(&self) -> Result<ThingInput<S>, ValidationErrors> { let mut errors = ValidationErrors::default();
if !nonempty(&self.name) { errors.push("name", "A name is required to publish."); } if self.models.is_empty() { errors.push("models", "Add at least one model with a file."); }
for (mi, model) in self.models.iter().enumerate() { if !nonempty(&model.name) { errors.push(format!("models[{mi}].name"), "Each model needs a name."); } if model.parts.is_empty() { errors.push( format!("models[{mi}].parts"), "Each model needs at least one part with a file.", ); } for (pi, part) in model.parts.iter().enumerate() { if !nonempty(&part.name) { errors.push( format!("models[{mi}].parts[{pi}].name"), "Each part needs a name.", ); } if !part.has_file() { errors.push( format!("models[{mi}].parts[{pi}].file"), "Upload a file for this part.", ); } // Dimensions are all-or-none: if any axis is set, all three // must be non-empty. The lexicon only requires the fields exist, // so this presence check is enforced here, not by the validator. if let Some(bbox) = &part.dimensions { let axes = [bbox.x.as_str(), bbox.y.as_str(), bbox.z.as_str()]; let set_count = axes.iter().filter(|a| !a.trim().is_empty()).count(); if set_count != 0 && set_count != 3 { errors.push( format!("models[{mi}].parts[{pi}].dimensions"), "Set all three dimensions (X, Y, Z), or none.", ); } } } }
if !errors.is_empty() { return Err(errors); }
// Presence is satisfied above; this pass only constructs typed records. let models = self .models .iter() .map(|model| { let parts = model .parts .iter() .map(|part| { PartInput::builder() .name(into_s(part.name.as_deref().unwrap_or_default())) .upload_id(opt_s(&part.upload_id)) .file(part.file.clone()) .format(opt_s(&part.format)) .notes(opt_s(&part.notes)) .units(opt_s(&part.units)) .print_settings(opt_vec_s(&part.print_settings)) .dimensions(clean_bbox(part.dimensions.clone())) .previews(part.previews.clone()) .derived_from(part.derived_from.clone()) .intended_to_work_with(part.intended_to_work_with.clone()) .build() }) .collect(); ModelInput { cover: model.cover.clone(), derived_from: model.derived_from.clone(), instructions: opt_vec_s(&model.instructions), intended_to_work_with: model.intended_to_work_with.clone(), license: opt_s(&model.license), name: into_s(model.name.as_deref().unwrap_or_default()), parts, previews: model.previews.clone(), summary: opt_s(&model.summary), tags: opt_vec_s(&model.tags), extra_data: None, } }) .collect::<Vec<_>>();
let thing = ThingInput { cover: self.cover.clone(), derived_from: self.derived_from.clone(), instructions: clean_lines(&self.instructions), intended_to_work_with: self.intended_to_work_with.clone(), license: into_s( self.license .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) .unwrap_or(super::DEFAULT_LICENSE), ), models, name: into_s(self.name.as_deref().unwrap_or_default()), previews: self.previews.clone(), summary: opt_s(&self.summary), tags: opt_vec_s(&self.tags), extra_data: None, };
// Defer length/grapheme limits to the generated lexicon validators so // this code never duplicates (and cannot drift from) the constraints. if let Err(err) = thing.validate() { errors.push("name", lexicon_message(&err)); } for (mi, model) in thing.models.iter().enumerate() { if let Err(err) = model.validate() { errors.push(format!("models[{mi}]"), lexicon_message(&err)); } for (pi, part) in model.parts.iter().enumerate() { if let Err(err) = part.validate() { errors.push(format!("models[{mi}].parts[{pi}]"), lexicon_message(&err)); } } }
if errors.is_empty() { Ok(thing) } else { Err(errors) } }}
fn lexicon_message(err: &jacquard_lexicon::validation::ConstraintError) -> String { format!("Exceeds a lexicon limit: {err}")}
/// Drop a `Bbox` whose axes are all empty so an in-progress edit does not/// serialize as a partial/empty dimensions object. Unit alone is not a dimension.fn clean_bbox(bbox: Option<Bbox>) -> Option<Bbox> { bbox.and_then(|b| { let empty = b.x.as_str().trim().is_empty() && b.y.as_str().trim().is_empty() && b.z.as_str().trim().is_empty(); if empty { None } else { Some(b) } })}
/// Derive a thing/model name from an uploaded filename: drop the extension and/// trim surrounding whitespace. A bare extension (e.g. `.stl`) or extensionless/// name is returned trimmed.pub fn name_from_filename(filename: &str) -> String { let trimmed = filename.trim(); match trimmed.rsplit_once('.') { Some((stem, _ext)) if !stem.trim().is_empty() => stem.trim().to_string(), _ => trimmed.to_string(), }}
/// Give a freshly-uploaded-into draft the structure `assemble()` requires: a/// thing name derived from the first filename (only when currently unset) and at/// least one model carrying a non-empty name. Pure and idempotent — a later drop/// neither renames the thing nor adds a second default model.pub fn ensure_default_model_for_upload(thing: &mut DraftThingInput, first_filename: &str) { let derived = name_from_filename(first_filename); if !nonempty(&thing.name) && !derived.is_empty() { thing.name = Some(derived.clone()); } if thing.models.is_empty() { let model_name = thing .display_name() .map(str::to_string) .filter(|name| !name.is_empty()) .or_else(|| (!derived.is_empty()).then(|| derived.clone())) .unwrap_or_else(|| "Model 1".to_string()); thing.models.push(DraftModelInput { name: Some(model_name), ..Default::default() }); }}
/// Parse one "works with" token — an `at://` URI, a full Polymodel thing URL, or/// a bare `/{repo}/thing/{rkey}` route path — into a thing `AtUri`. Pure; the/// network resolution to a `StrongRef` happens separately. Query strings and/// fragments are ignored; anything that is not a thing link is rejected.pub fn parse_thing_token(token: &str) -> Result<AtUri, String> { let token = token.trim(); if token.is_empty() { return Err("Empty link.".into()); } if token.starts_with("at://") { let uri = AtUri::new_owned(token).map_err(|e| format!("Invalid at:// URI: {e}"))?; // Only thing-record URIs are valid "Works with" links; reject model/part // or other NSIDs early with a clear message rather than a decode error. let is_thing = uri .path() .map(|p| p.collection.as_str() == "space.polymodel.library.thing") .unwrap_or(false); if !is_thing { return Err("Not a thing link.".to_string()); } return Ok(uri); } let rest = match token.split_once("://") { Some((_scheme, rest)) => rest.split_once('/').map(|(_host, path)| path).unwrap_or(""), None => token, }; let path = rest .split(['?', '#']) .next() .unwrap_or("") .trim_matches('/'); let segments: Vec<&str> = path.split('/').collect(); match segments.as_slice() { [repo, "thing", rkey] if !repo.is_empty() && !rkey.is_empty() => { thing_uri_from_route(repo, rkey) } _ => Err("Not a Polymodel thing link.".to_string()), }}
#[cfg(test)]mod tests { use super::*;
fn resource(id: &str, name: &str, bytes: &[u8]) -> AcquiredDraftResource { AcquiredDraftResource { metadata: DraftStagedResource { resource_id: id.into(), upload_id: id.into(), source_name: name.into(), mime_type: "application/x-ldraw".into(), digest: sha2::Sha256::digest(bytes).to_vec(), byte_length: bytes.len() as u64, format: Some("ldraw".into()), }, bytes: bytes.to_vec(), } }
const MAIN: &[u8] = b"1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n"; const CHILD: &[u8] = b"1 16 0 0 0 1 0 0 0 1 0 0 0 0 grand.dat\n"; const GRAND: &[u8] = b"0 Grand\n3 16 0 0 0 1 0 0 0 1 0 0\n";
#[test] fn draft_staging_json_contains_metadata_not_geometry_bytes() { let acquired = resource("upload-main", "main.ldr", MAIN); let json = serde_json::to_string(&acquired.metadata).unwrap(); assert!(json.contains("upload-main")); assert!(!json.contains("bytes")); assert!(!json.contains("49,16")); }
#[test] fn ldraw_preview_requires_complete_transitive_closure_in_any_upload_order() { let main = resource("main-id", "main.ldr", MAIN); let child = resource("child-id", "child.dat", CHILD); let grand = resource("grand-id", "grand.dat", GRAND); assert!(matches!( plan_draft_ldraw_bundle("main-id", &[main.clone(), child.clone()]), DraftBundlePlan::Incomplete(_) )); for resources in [ vec![main.clone(), child.clone(), grand.clone()], vec![grand.clone(), child.clone(), main.clone()], ] { let plan = plan_draft_ldraw_bundle("main-id", &resources); let DraftBundlePlan::Complete(bundle) = plan else { panic!("transitive bundle should be complete: {plan:?}") }; assert_eq!(bundle.primary, "models/main.ldr"); assert_eq!(bundle.resources.len(), 3); } }
#[test] fn ldraw_preview_handles_cycles_but_rejects_duplicate_paths_and_bad_integrity() { let main = resource("main-id", "main.ldr", MAIN); let child_cycle = resource( "child-id", "child.dat", b"1 16 0 0 0 1 0 0 0 1 0 0 0 0 models/main.ldr\n", ); assert!(matches!( plan_draft_ldraw_bundle("main-id", &[main.clone(), child_cycle]), DraftBundlePlan::Complete(_) )); let duplicate = resource("other-id", "child.dat", GRAND); assert!(matches!( plan_draft_ldraw_bundle( "main-id", &[ main.clone(), resource("child-id", "child.dat", GRAND), duplicate ] ), DraftBundlePlan::Incomplete(_) )); let mut bad = resource("main-id", "main.ldr", MAIN); bad.metadata.digest = vec![0; 32]; assert!(matches!( plan_draft_ldraw_bundle("main-id", &[bad]), DraftBundlePlan::Incomplete(_) )); }
fn part_with_upload(name: &str) -> DraftPartInput { DraftPartInput { name: Some(name.to_string()), upload_id: Some("abcdef0123456789-42".to_string()), ..Default::default() } }
fn complete_draft() -> DraftThingInput { DraftThingInput { name: Some("Desk Organizer".to_string()), license: Some("CC-BY-4.0".to_string()), summary: Some("A tidy desk caddy.".to_string()), models: vec![DraftModelInput { name: Some("Tray".to_string()), parts: vec![part_with_upload("tray.stl")], ..Default::default() }], ..Default::default() } }
#[test] fn partial_draft_round_trips_through_json() { let draft = DraftThingInput { name: Some("WIP".to_string()), ..Default::default() }; let json = serde_json::to_string(&draft).unwrap(); let restored: DraftThingInput = serde_json::from_str(&json).unwrap(); assert_eq!(draft, restored); // A bare partial draft must not serialize required-but-absent fields. assert!(!json.contains("license")); assert!(json.contains("\"name\":\"WIP\"")); }
#[test] fn empty_draft_round_trips_and_is_not_publishable() { let draft = DraftThingInput::default(); let json = serde_json::to_string(&draft).unwrap(); let restored: DraftThingInput = serde_json::from_str(&json).unwrap(); assert_eq!(draft, restored); assert!(!draft.basics_complete()); assert!(!draft.files_complete()); assert!(draft.assemble().is_err()); }
#[test] fn complete_draft_assembles_to_valid_thing_input() { let draft = complete_draft(); assert!(draft.basics_complete()); assert!(draft.files_complete()); let thing = draft.assemble().expect("complete draft assembles"); assert_eq!(thing.name.as_str(), "Desk Organizer"); assert_eq!(thing.license.as_str(), "CC-BY-4.0"); assert_eq!(thing.models.len(), 1); assert_eq!(thing.models[0].name.as_str(), "Tray"); assert_eq!(thing.models[0].parts.len(), 1); assert_eq!(thing.models[0].parts[0].name.as_str(), "tray.stl"); // The assembled record passes the generated lexicon validators. assert!(thing.validate().is_ok()); }
#[test] fn missing_thing_fields_are_reported() { let draft = DraftThingInput::default(); let errors = draft.assemble().expect_err("empty draft fails"); assert!(errors.has_field("name")); assert!(errors.has_field("models")); // License is not required: it defaults at assembly, never an error. assert!(!errors.has_field("license")); }
#[test] fn unset_license_defaults_at_assembly() { let mut draft = complete_draft(); draft.license = None; let thing = draft.assemble().expect("unset license defaults"); assert_eq!(thing.license.as_str(), "CC-BY-4.0"); }
#[test] fn missing_model_name_and_parts_are_reported() { let mut draft = complete_draft(); draft.models = vec![DraftModelInput::default()]; let errors = draft .assemble() .expect_err("model with no name/parts fails"); assert!(errors.has_field("models[0].name")); assert!(errors.has_field("models[0].parts")); }
#[test] fn part_without_file_is_reported() { let mut draft = complete_draft(); draft.models[0].parts = vec![DraftPartInput { name: Some("loose.stl".to_string()), ..Default::default() }]; let errors = draft.assemble().expect_err("part with no file fails"); assert!(errors.has_field("models[0].parts[0].file")); assert!(!errors.has_field("models[0].parts[0].name")); }
#[test] fn shared_name_limit_accepts_long_model_name() { let name = "1 x 5 Tile with Groove without Understud with Track (6mm) (Lego-compatible)"; let mut draft = complete_draft(); draft.name = Some(name.to_string()); draft.models[0].name = Some(name.to_string()); draft.models[0].parts[0].name = Some(name.to_string()); draft.models[0].tags = Some(vec!["a".repeat(128)]);
draft .assemble() .expect("shared name and tag limits accept valid values"); }
#[test] fn over_long_name_trips_lexicon_validator() { let mut draft = complete_draft(); draft.name = Some("x".repeat(257)); let errors = draft .assemble() .expect_err("over-long name fails lexicon validate"); assert!(errors.has_field("name")); }
#[test] fn whitespace_only_name_is_not_complete() { let mut draft = complete_draft(); draft.name = Some(" ".to_string()); assert!(!draft.basics_complete()); assert!(draft.assemble().is_err()); }
#[test] fn name_from_filename_strips_extension_and_trims() { assert_eq!( name_from_filename(" Desk Organizer.stl "), "Desk Organizer" ); assert_eq!(name_from_filename("a.b.glb"), "a.b"); assert_eq!(name_from_filename("noext"), "noext"); // A bare dotfile keeps its name rather than collapsing to empty. assert_eq!(name_from_filename(".stl"), ".stl"); }
#[test] fn ensure_default_model_makes_a_one_file_drop_assemblable() { let mut draft = DraftThingInput::default(); ensure_default_model_for_upload(&mut draft, "Bracket.stl"); draft.models[0].parts.push(part_with_upload("Bracket.stl")); assert_eq!(draft.name.as_deref(), Some("Bracket")); assert_eq!(draft.models.len(), 1); assert!(nonempty(&draft.models[0].name)); assert!(draft.assemble().is_ok(), "auto-created draft must publish"); }
#[test] fn ensure_default_model_does_not_overwrite_name_or_add_second_model() { let mut draft = DraftThingInput { name: Some("Chosen".to_string()), ..Default::default() }; ensure_default_model_for_upload(&mut draft, "First.stl"); ensure_default_model_for_upload(&mut draft, "Second.stl"); assert_eq!(draft.name.as_deref(), Some("Chosen")); assert_eq!(draft.models.len(), 1); }
#[test] fn parse_thing_token_accepts_at_uri_url_and_path() { let at = parse_thing_token("at://did:plc:abc123/space.polymodel.library.thing/rkey1") .expect("at:// passes through"); assert_eq!(at.authority().as_str(), "did:plc:abc123");
let url = parse_thing_token("https://polymodel.app/alice.test/thing/rkey2") .expect("full URL resolves"); assert_eq!(url.authority().as_str(), "alice.test"); assert_eq!( url.path() .and_then(|p| p.rkey) .map(|r| r.as_ref().to_string()), Some("rkey2".to_string()) );
let path = parse_thing_token("/alice.test/thing/rkey3").expect("bare path resolves"); assert_eq!(path.authority().as_str(), "alice.test");
// Query strings and fragments are ignored. let q = parse_thing_token("https://host/alice.test/thing/rkey4?ref=x#frag") .expect("query/fragment stripped"); assert_eq!( q.path() .and_then(|p| p.rkey) .map(|r| r.as_ref().to_string()), Some("rkey4".to_string()) ); }
#[test] fn parse_thing_token_rejects_non_thing_links() { assert!(parse_thing_token("").is_err()); assert!(parse_thing_token("not a link").is_err()); assert!(parse_thing_token("https://host/alice.test/profile").is_err()); assert!(parse_thing_token("at://garbage").is_err()); }
#[test] fn per_model_and_per_part_fields_map_through_assemble() { let draft = DraftThingInput { name: Some("Rig".to_string()), models: vec![DraftModelInput { name: Some("Frame".to_string()), summary: Some("Main frame".to_string()), license: Some("MIT".to_string()), tags: Some(vec!["frame".to_string()]), instructions: Some(vec!["Print at 0.2mm".to_string()]), parts: vec![DraftPartInput { name: Some("frame.stl".to_string()), upload_id: Some("abcdef0123456789-1".to_string()), notes: Some("Supports off".to_string()), units: Some("mm".to_string()), print_settings: Some(vec!["0.2mm layers".to_string()]), dimensions: Some(Bbox { unit: Some("mm".into()), x: "10".into(), y: "20".into(), z: "30".into(), extra_data: None, }), ..Default::default() }], ..Default::default() }], ..Default::default() }; let thing = draft.assemble().expect("rich draft assembles"); let model = &thing.models[0]; assert_eq!(model.summary.as_ref().unwrap().as_str(), "Main frame"); assert_eq!(model.license.as_ref().unwrap().as_str(), "MIT"); assert_eq!(model.tags.as_ref().unwrap()[0].as_str(), "frame"); let part = &model.parts[0]; assert_eq!(part.notes.as_ref().unwrap().as_str(), "Supports off"); assert_eq!(part.units.as_ref().unwrap().as_str(), "mm"); assert_eq!( part.print_settings.as_ref().unwrap()[0].as_str(), "0.2mm layers" ); let bbox = part.dimensions.as_ref().unwrap(); assert_eq!(bbox.x.as_str(), "10"); assert_eq!(bbox.unit.as_ref().unwrap().as_str(), "mm"); assert!(thing.validate().is_ok()); }
#[test] fn partial_bbox_is_rejected_by_assemble() { let mut draft = complete_draft(); // Only X set → must fail validation (all-or-none). draft.models[0].parts[0].dimensions = Some(Bbox { unit: Some("mm".into()), x: "10".into(), y: "".into(), z: "".into(), extra_data: None, }); let errors = draft .assemble() .expect_err("partial bbox must fail assemble"); assert!(errors.has_field("models[0].parts[0].dimensions")); }
#[test] fn empty_bbox_axes_are_dropped_at_assemble() { let mut draft = complete_draft(); // All axes empty (unit set) → dimensions dropped entirely, no error. draft.models[0].parts[0].dimensions = Some(Bbox { unit: Some("mm".into()), x: "".into(), y: "".into(), z: "".into(), extra_data: None, }); let thing = draft.assemble().expect("empty-axes bbox is dropped"); assert!(thing.models[0].parts[0].dimensions.is_none()); }}