use crate::kernel_header::KernelHeader;
use crate::sab::{Record, Token};
use crate::stream_error::{StreamError, StreamFailure, StreamFormat};
use cadmpeg_core::decode::DecodeContext;
use cadmpeg_ir::scalar::{NonNegativeReal, PositiveReal};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Terminator {
Asm,
Acis,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StreamDiagnostic {
pub kind: StreamDiagnosticKind,
pub error: StreamError,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamDiagnosticKind {
Metadata,
Tolerance,
Units,
Terminator,
Unread,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TextUnits {
Declared(PositiveReal),
Unspecified,
}
impl TextUnits {
pub const fn millimetres_per_unit(self) -> PositiveReal {
match self {
Self::Declared(scale) => scale,
Self::Unspecified => PositiveReal::ONE,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextHeader {
pub save_format_version: u32,
pub record_count: Option<u64>,
pub entity_count: u64,
pub flags: u64,
pub product_family: Option<String>,
pub product_version: Option<String>,
pub save_date: Option<String>,
units: TextUnits,
resabs: Option<NonNegativeReal>,
resnor: Option<NonNegativeReal>,
normalized_resabs_cm: Option<NonNegativeReal>,
pub diagnostics: Vec<StreamDiagnostic>,
pub source_span: std::ops::Range<usize>,
}
impl TextHeader {
pub const fn scale(&self) -> PositiveReal {
self.units.millimetres_per_unit()
}
pub const fn units(&self) -> TextUnits {
self.units
}
pub const fn resabs(&self) -> Option<NonNegativeReal> {
self.resabs
}
pub const fn resabs_cm(&self) -> Option<NonNegativeReal> {
self.normalized_resabs_cm
}
pub const fn resnor(&self) -> Option<NonNegativeReal> {
self.resnor
}
pub const fn save_format_branch(&self) -> Terminator {
if self.save_format_version >= FIRST_ASM_SAVE_FORMAT {
Terminator::Asm
} else {
Terminator::Acis
}
}
pub fn as_kernel_header(&self) -> KernelHeader {
KernelHeader {
save_format_version: Some(self.save_format_version),
entity_count: Some(self.entity_count),
flags: Some(self.flags),
product_family: self.product_family.clone(),
product_version: self.product_version.clone(),
save_date: self.save_date.clone(),
scale: Some(10.0),
linear: self.resabs_cm().map(NonNegativeReal::get),
angular: self.resnor.map(NonNegativeReal::get),
}
}
}
fn resabs_cm(scale: PositiveReal, resabs: NonNegativeReal) -> f64 {
let converted = resabs.get() * (scale.get() / 10.0);
if converted == 0.0 && resabs.get() > 0.0 {
(resabs.get() / 10.0) * scale.get()
} else {
converted
}
}
#[derive(Debug, Clone)]
pub struct TextStream {
pub header: TextHeader,
pub records: Vec<Record>,
pub concatenated_streams: bool,
pub unread_stream_layout: bool,
pub terminator: Terminator,
pub has_terminator_line: bool,
pub framing: Vec<StreamDiagnostic>,
pub unread: Option<std::ops::Range<usize>>,
}
pub fn text_header_start(bytes: &[u8]) -> usize {
if !bytes.starts_with(b"X-Sun-") {
return 0;
}
let mut offset = 0;
for line in bytes.split_inclusive(|byte| *byte == b'\n') {
let trimmed = line.trim_ascii();
offset += line.len();
if trimmed.is_empty() {
return offset;
}
if !trimmed.starts_with(b"X-Sun-") || !trimmed.contains(&b':') {
return 0;
}
}
0
}
pub fn has_text_magic(bytes: &[u8]) -> bool {
let bytes = &bytes[text_header_start(bytes)..];
let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
digits >= 3 && bytes.get(digits) == Some(&b' ')
}
mod plane_pcurves;
mod segments;
#[derive(Debug, Clone, PartialEq)]
enum Prim {
Integer(i64),
Real(f64),
Ref(i64),
Str(String),
Open,
Close,
Word(String),
}
fn is_ws(b: u8) -> bool {
matches!(b, b' ' | b'\t' | b'\n' | b'\r')
}
struct FieldReader<'a> {
bytes: &'a [u8],
pos: usize,
invalid_utf8: Option<(usize, usize)>,
}
impl FieldReader<'_> {
fn skip_ws(&mut self) {
while self.pos < self.bytes.len() && is_ws(self.bytes[self.pos]) {
self.pos += 1;
}
}
fn next_field(&mut self) -> Option<(usize, String)> {
self.skip_ws();
if self.pos >= self.bytes.len() {
return None;
}
let start = self.pos;
if matches!(self.bytes[self.pos], b'#' | b'{' | b'}') {
self.pos += 1;
} else {
while self.pos < self.bytes.len()
&& !is_ws(self.bytes[self.pos])
&& !matches!(self.bytes[self.pos], b'#' | b'{' | b'}')
{
self.pos += 1;
}
}
let word = self.text(start, self.pos).into_owned();
Some((start, word))
}
fn text(&mut self, start: usize, end: usize) -> std::borrow::Cow<'_, str> {
let raw = &self.bytes[start..end];
if let Err(error) = std::str::from_utf8(raw) {
let (_, count) = self
.invalid_utf8
.get_or_insert((start + error.valid_up_to(), 0));
*count += 1;
}
String::from_utf8_lossy(raw)
}
fn read_str_payload(&mut self, len: usize, at: usize) -> Result<String, StreamFailure> {
if !self.bytes.get(self.pos).is_some_and(|byte| is_ws(*byte)) {
return Err(StreamError {
format: StreamFormat::Text,
offset: at,
reason: "counted string has no whitespace separator".to_string(),
}
.into());
}
self.pos += 1; let end = self
.pos
.checked_add(len)
.filter(|end| *end <= self.bytes.len());
let Some(end) = end else {
return Err(StreamError {
format: StreamFormat::Text,
offset: at,
reason: format!("truncated @{len} string"),
}
.into());
};
let payload = self.text(self.pos, end).into_owned();
self.pos = end;
Ok(payload)
}
}
fn header_line<'a>(bytes: &'a [u8], pos: &mut usize, what: &str) -> Result<&'a [u8], StreamError> {
let start = *pos;
let end = bytes[start..]
.iter()
.position(|b| *b == b'\n')
.map(|off| start + off)
.ok_or_else(|| StreamError {
format: StreamFormat::Text,
offset: start,
reason: format!("missing {what} line"),
})?;
*pos = end + 1;
Ok(&bytes[start..end])
}
fn header_int<T: std::str::FromStr>(
field: Option<&[u8]>,
at: usize,
what: &str,
) -> Result<T, StreamError> {
field
.and_then(|field| std::str::from_utf8(field).ok())
.and_then(|field| field.parse().ok())
.ok_or_else(|| StreamError {
format: StreamFormat::Text,
offset: at,
reason: format!("header line has no {what} field"),
})
}
fn counted_string<'a>(
line: &'a [u8],
pos: &mut usize,
at: usize,
what: &str,
) -> Result<&'a [u8], StreamError> {
while *pos < line.len() && is_ws(line[*pos]) {
*pos += 1;
}
let start = *pos;
while *pos < line.len() && line[*pos].is_ascii_digit() {
*pos += 1;
}
let len: usize = std::str::from_utf8(&line[start..*pos])
.ok()
.and_then(|digits| digits.parse().ok())
.ok_or_else(|| StreamError {
format: StreamFormat::Text,
offset: at,
reason: format!("header line has no {what} count"),
})?;
if line.get(*pos).is_none_or(|byte| !is_ws(*byte)) {
return Err(StreamError {
format: StreamFormat::Text,
offset: at,
reason: format!("header {what} count has no separator"),
});
}
*pos += 1;
let end = pos
.checked_add(len)
.filter(|end| *end <= line.len())
.ok_or_else(|| StreamError {
format: StreamFormat::Text,
offset: at,
reason: format!("truncated {what} string"),
})?;
let value = &line[*pos..end];
*pos = end;
Ok(value)
}
const FIRST_UNITS_LINE_SAVE_FORMAT: u32 = 200;
const FIRST_ASM_SAVE_FORMAT: u32 = 10_000;
fn push_diagnostic(
diagnostics: &mut Vec<StreamDiagnostic>,
kind: StreamDiagnosticKind,
offset: usize,
reason: std::fmt::Arguments<'_>,
) {
diagnostics.push(StreamDiagnostic {
kind,
error: StreamError {
format: StreamFormat::Text,
offset,
reason: reason.to_string(),
},
});
}
fn parse_header(bytes: &[u8], pos: &mut usize) -> Result<TextHeader, StreamFailure> {
let header_start = *pos;
*pos += text_header_start(&bytes[*pos..]);
let at = *pos;
let line1 = header_line(bytes, pos, "save-format")?;
let mut fields = line1.split(|b| is_ws(*b)).filter(|field| !field.is_empty());
let line1: [Option<&[u8]>; 4] = std::array::from_fn(|_| fields.next());
if line1.iter().any(Option::is_none) || fields.next().is_some() {
return Err(StreamError {
format: StreamFormat::Text,
offset: at,
reason: "save-format header line must contain four fields".to_string(),
}
.into());
}
let save_format_version = header_int(line1[0], at, "save format")?;
let record_count = header_int::<u32>(line1[1], at, "record count")
.ok()
.map(u64::from);
let entity_count = header_int(line1[2], at, "entity count")?;
let flags = header_int(line1[3], at, "flags")?;
let mut diagnostics = Vec::new();
if at != header_start {
push_diagnostic(
&mut diagnostics,
StreamDiagnosticKind::Metadata,
header_start,
format_args!(
"Sun attachment headers precede the SAT header; record grammar starts at byte {at}"
),
);
}
if let Err(error) = header_int::<u32>(line1[1], at, "record count") {
diagnostics.push(StreamDiagnostic {
kind: StreamDiagnosticKind::Metadata,
error,
});
}
if save_format_version < FIRST_UNITS_LINE_SAVE_FORMAT {
push_diagnostic(
&mut diagnostics,
StreamDiagnosticKind::Units,
at,
format_args!(
"save format {save_format_version} stores no product or units line; \
lengths are read unscaled as millimetres"
),
);
return Ok(TextHeader {
save_format_version,
record_count,
entity_count,
flags,
product_family: None,
product_version: None,
save_date: None,
units: TextUnits::Unspecified,
resabs: None,
resnor: None,
normalized_resabs_cm: None,
diagnostics,
source_span: header_start..*pos,
});
}
let at = *pos;
let line2_start = *pos;
let line2_end = bytes[line2_start..]
.iter()
.position(|b| *b == b'\n')
.map(|off| line2_start + off)
.ok_or_else(|| StreamError {
format: StreamFormat::Text,
offset: at,
reason: "missing product line".to_string(),
})?;
let line2 = &bytes[line2_start..line2_end];
*pos = line2_end + 1;
let mut cursor = 0usize;
let mut products = [None, None, None];
for (slot, what) in products
.iter_mut()
.zip(["product family", "product version", "save date"])
{
let field = counted_string(line2, &mut cursor, at, what);
let framed = field.is_ok();
let text = field.and_then(|bytes| {
std::str::from_utf8(bytes).map_err(|error| StreamError {
format: StreamFormat::Text,
offset: at + cursor - bytes.len() + error.valid_up_to(),
reason: format!("header {what} string is not valid UTF-8"),
})
});
match text {
Ok(value) => {
*slot = Some(value.to_owned());
}
Err(error) => {
diagnostics.push(StreamDiagnostic {
kind: StreamDiagnosticKind::Metadata,
error,
});
if !framed {
cursor = line2.len();
break;
}
}
}
}
if line2[cursor..].iter().any(|byte| !is_ws(*byte)) {
diagnostics.push(StreamDiagnostic {
kind: StreamDiagnosticKind::Metadata,
error: StreamError {
format: StreamFormat::Text,
offset: at + cursor,
reason: "product header line has extra fields".to_owned(),
},
});
}
let [product_family, product_version, save_date] = products;
let at = *pos;
let line3 = header_line(bytes, pos, "tolerance")?;
let mut fields = line3.split(|b| is_ws(*b)).filter(|field| !field.is_empty());
let line3: [Option<&[u8]>; 3] = std::array::from_fn(|_| fields.next());
let extra_fields = fields.next().is_some();
let float = |field: Option<&[u8]>| -> Option<f64> {
field
.and_then(|field| std::str::from_utf8(field).ok())
.and_then(|field| field.parse().ok())
};
let units = match float(line3[0]).and_then(PositiveReal::new) {
Some(scale) => TextUnits::Declared(scale),
None => {
push_diagnostic(
&mut diagnostics,
StreamDiagnosticKind::Units,
at,
format_args!(
"header units word declares no finite positive length unit; \
lengths are read unscaled as millimetres"
),
);
TextUnits::Unspecified
}
};
let scale = units.millimetres_per_unit();
let mut tolerance = |field, what| -> Result<Option<NonNegativeReal>, StreamFailure> {
let value = float(field)
.and_then(NonNegativeReal::new)
.filter(|value| value.get() > 0.0);
if value.is_none() {
diagnostics.push(StreamDiagnostic {
kind: StreamDiagnosticKind::Tolerance,
error: StreamError { format: StreamFormat::Text, offset: at,
reason: format!("header {what} must be finite and positive for document tolerance; tolerance omitted") },
});
}
Ok(value)
};
let resabs = tolerance(line3[1], "resabs")?;
let resnor = tolerance(line3[2], "resnor")?;
let normalized_resabs_cm = resabs
.and_then(|value| NonNegativeReal::new(resabs_cm(scale, value)))
.filter(|value| value.get() > 0.0);
for reason in [
extra_fields.then_some("tolerance header line has extra fields"),
(resabs.is_some() && normalized_resabs_cm.is_none())
.then_some("header resabs cannot be represented in centimetres; tolerance omitted"),
]
.into_iter()
.flatten()
{
diagnostics.push(StreamDiagnostic {
kind: StreamDiagnosticKind::Tolerance,
error: StreamError {
format: StreamFormat::Text,
offset: at,
reason: reason.to_owned(),
},
});
}
Ok(TextHeader {
save_format_version,
record_count,
entity_count,
flags,
product_family,
product_version,
save_date,
units,
resabs,
resnor,
normalized_resabs_cm,
diagnostics,
source_span: header_start..*pos,
})
}
fn record_error_reason(name: &str, description: &'static str) -> String {
format!("record `{name}` {description}")
}
pub fn parse_container(bytes: &[u8]) -> Result<(TextHeader, Terminator), StreamFailure> {
let mut position = 0;
let header = parse_header(bytes, &mut position)?;
let tail = bytes.trim_ascii_end();
let marker = tail.rsplit(|byte| is_ws(*byte)).next();
let branch = match marker {
Some(b"End-of-ASM-data") => Terminator::Asm,
Some(b"End-of-ACIS-data") => Terminator::Acis,
_ => header.save_format_branch(),
};
Ok((header, branch))
}
fn is_sequence_number(field: &str) -> bool {
field
.strip_prefix('-')
.is_some_and(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
}
const fn terminator_name(branch: Terminator) -> &'static str {
match branch {
Terminator::Asm => "ASM",
Terminator::Acis => "ACIS",
}
}
pub fn parse(ctx: &DecodeContext<'_>, bytes: &[u8]) -> Result<TextStream, StreamFailure> {
let mut pos = 0usize;
let header = parse_header(bytes, &mut pos)?;
let mut scale = header.scale().get();
let mut segment_scales = vec![(0, scale)];
let mut record_count = header.record_count;
let mut segment_start = 0;
let mut record_segments = Vec::new();
let mut concatenated_streams = false;
let mut reader = FieldReader {
bytes,
pos,
invalid_utf8: None,
};
let mut records = Vec::new();
let mut terminator = None;
let mut framing = Vec::new();
let mut incomplete = None;
let mut unread = None;
let mut unread_stream_layout = false;
let mut admitted_entities = 0_u64;
ctx.admit_entities(
header.entity_count,
&mut admitted_entities,
"preflight SAT header entities",
)
.map_err(StreamFailure::from_operation)?;
'stream: loop {
let table_complete = terminator.is_some()
|| record_count.is_some_and(|count| {
count > 0
&& cadmpeg_core::decode::u64_from_index(records.len() - segment_start) >= count
});
if let Some(next) = segments::next_header(bytes, reader.pos, table_complete) {
let unread_start = bytes[reader.pos..next]
.iter()
.position(|byte| !is_ws(*byte))
.map_or(next, |offset| reader.pos + offset);
let mut end = next;
let mut next_header = match parse_header(bytes, &mut end) {
Ok(header) => header,
Err(error @ (StreamFailure::Resource(_) | StreamFailure::Operation(_))) => {
return Err(error)
}
Err(error) => {
push_diagnostic(&mut framing, StreamDiagnosticKind::Unread, unread_start, format_args!("the next stream header at byte {next} cannot select a native layout: {error}; the later stream is not read as records"));
unread = Some(unread_start..bytes.len());
unread_stream_layout = true;
break 'stream;
}
};
if next_header.save_format_version != header.save_format_version {
push_diagnostic(&mut framing, StreamDiagnosticKind::Unread, unread_start, format_args!("the next stream header at byte {next} declares save format {}; the active native layout uses {}; the later stream is not read as records",
next_header.save_format_version, header.save_format_version));
unread = Some(unread_start..bytes.len());
unread_stream_layout = true;
break 'stream;
}
record_segments.push(segment_start..records.len());
push_diagnostic(&mut framing, StreamDiagnosticKind::Metadata, reader.pos, format_args!("a new SAT header at byte {next} starts an independent reference table; tables are rebased separately"));
framing.append(&mut next_header.diagnostics);
let population = cadmpeg_core::decode::u64_from_index(records.len())
.checked_add(next_header.entity_count)
.ok_or_else(|| {
StreamFailure::Malformed(StreamError {
format: StreamFormat::Text,
offset: next,
reason: "segment entity count overflows the stream population".to_owned(),
})
})?;
ctx.admit_entities(
population,
&mut admitted_entities,
"preflight SAT segment entities",
)
.map_err(StreamFailure::from_operation)?;
scale = next_header.scale().get();
segment_scales.push((records.len(), scale));
record_count = next_header.record_count;
segment_start = records.len();
reader.pos = end;
terminator = None;
concatenated_streams = true;
continue;
}
let Some((rec_start, mut name)) = reader.next_field() else {
break;
};
if is_sequence_number(&name) {
let Some((_, record_name)) = reader.next_field() else {
incomplete = Some((rec_start, name));
break;
};
name = record_name;
}
match name.as_str() {
"End-of-ASM-data" | "End-of-ACIS-data" => {
terminator = Some(if name == "End-of-ASM-data" {
Terminator::Asm
} else {
Terminator::Acis
});
if segments::next_header(bytes, reader.pos, true).is_some() {
continue 'stream;
}
break 'stream;
}
_ => {}
}
let mut prims = Vec::new();
let mut subtype_depth = 0usize;
loop {
let Some((at, field)) = reader.next_field() else {
incomplete = Some((rec_start, name));
break 'stream;
};
if field == "#" {
if subtype_depth != 0 {
return Err(StreamError {
format: StreamFormat::Text,
offset: at,
reason: record_error_reason(&name, "terminates inside a subtype scope"),
}
.into());
}
break;
}
let prim = lex_prim(&mut reader, at, field)?;
match prim {
Prim::Open => subtype_depth += 1,
Prim::Close if subtype_depth == 0 => {
return Err(StreamError {
format: StreamFormat::Text,
offset: at,
reason: record_error_reason(&name, "closes an unopened subtype scope"),
}
.into());
}
Prim::Close => subtype_depth -= 1,
_ => {}
}
prims.push(prim);
}
let head = name.split_once('-').map_or(name.as_str(), |(head, _)| head);
let tokens = type_record(ctx, head, &prims, scale, header.save_format_version).map_err(
|failure| match failure {
TypedRecordFailure::Resource(error) => StreamFailure::from_operation(error),
TypedRecordFailure::Type(failure) => {
let error = StreamError {
format: StreamFormat::Text,
offset: rec_start,
reason: failure.reason().to_string(),
};
match failure {
TypeFailure::UnrepresentableLength => StreamFailure::NotImplemented(error),
TypeFailure::InvalidSplineCount => StreamFailure::Malformed(error),
}
}
},
)?;
let population = cadmpeg_core::decode::u64_from_index(records.len() + 1);
if population > admitted_entities {
ctx.admit_entities(
population,
&mut admitted_entities,
"admit SAT native records",
)
.map_err(StreamFailure::from_operation)?;
}
records.push(Record {
index: records.len(),
name,
tokens: tokens.into(),
offset: rec_start,
len: reader.pos - rec_start,
});
}
let has_terminator_line = terminator.is_some();
let terminator = match terminator {
Some(terminator) => {
reader.skip_ws();
if unread.is_none() && reader.pos != bytes.len() {
push_diagnostic(
&mut framing,
StreamDiagnosticKind::Unread,
reader.pos,
format_args!(
"{} bytes after the terminator line are not read as records",
bytes.len() - reader.pos
),
);
unread = Some(reader.pos..bytes.len());
}
terminator
}
None => {
let branch = header.save_format_branch();
if unread.is_none() {
match incomplete {
Some((start, name)) => {
push_diagnostic(
&mut framing,
StreamDiagnosticKind::Unread,
start,
format_args!(
"input ends inside record `{name}` before its `#` terminator; \
the incomplete record is not read, and the stream is read on \
the {} branch its save format selects",
terminator_name(branch)
),
);
unread = Some(start..bytes.len());
}
None => push_diagnostic(
&mut framing,
StreamDiagnosticKind::Terminator,
reader.pos,
format_args!(
"input ends without an End-of-ASM-data or End-of-ACIS-data line; \
the stream is read on the {} branch its save format selects",
terminator_name(branch)
),
),
}
}
branch
}
};
if let Some((first, count)) = reader.invalid_utf8 {
push_diagnostic(
&mut framing,
StreamDiagnosticKind::Metadata,
first,
format_args!(
"{count} field(s) are not valid UTF-8; they are read with replacement characters"
),
);
}
if concatenated_streams {
record_segments.push(segment_start..records.len());
segments::rebase(&mut records, &record_segments).map_err(StreamFailure::from_operation)?;
}
plane_pcurves::convert(&mut records, &segment_scales).map_err(|index| {
StreamFailure::NotImplemented(StreamError {
format: StreamFormat::Text,
offset: records[index].offset,
reason: TypeFailure::UnrepresentableLength.reason().to_string(),
})
})?;
Ok(TextStream {
header,
records,
concatenated_streams,
unread_stream_layout,
terminator,
has_terminator_line,
framing,
unread,
})
}
fn lex_prim(reader: &mut FieldReader<'_>, at: usize, field: String) -> Result<Prim, StreamFailure> {
if let Some(rest) = field.strip_prefix('$') {
let index = rest.parse::<i64>().map_err(|_| StreamError {
format: StreamFormat::Text,
offset: at,
reason: "reference field has no valid decimal index".to_string(),
})?;
return Ok(Prim::Ref(index));
}
if let Some(rest) = field.strip_prefix('@') {
let len = rest.parse::<usize>().map_err(|_| StreamError {
format: StreamFormat::Text,
offset: at,
reason: "string field has no valid decimal byte count".to_string(),
})?;
return Ok(Prim::Str(reader.read_str_payload(len, at)?));
}
if field == "{" {
return Ok(Prim::Open);
}
if field == "}" {
return Ok(Prim::Close);
}
let digits = field
.strip_prefix('+')
.or_else(|| field.strip_prefix('-'))
.unwrap_or(&field);
if !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) {
return field.parse::<i64>().map(Prim::Integer).map_err(|_| {
StreamError {
format: StreamFormat::Text,
offset: at,
reason: "integer field is outside the signed 64-bit range".to_string(),
}
.into()
});
}
if let Ok(value) = field.parse::<f64>() {
return Ok(Prim::Real(value));
}
Ok(Prim::Word(field))
}
#[derive(Clone, Copy)]
enum Slot {
R,
L,
D,
DLen,
DLenSentinel,
S,
P,
VLen,
VUnit,
Sense,
Sides,
UvSense,
B,
OptB,
Sub,
}
fn logical_word(word: &str) -> Option<Token> {
match word {
"T" | "in" | "rotate" | "reflect" | "shear" => Some(Token::True),
"F" | "out" | "no_rotate" | "no_reflect" | "no_shear" => Some(Token::False),
_ => None,
}
}
const CLOSURE: &[(&str, i64)] = &[
("open", 0),
("closed", 1),
("periodic", 2),
("OPEN", 0),
("CLOSED", 1),
("PERIODIC", 2),
];
const SINGULARITY: &[(&str, i64)] = &[("none", 0), ("NON_SINGULAR", 0)];
const CACHE_FORM: &[(&str, i64)] = &[
("full", 0),
("summary", 1),
("none", 2),
("historical", 3),
("optimal", 4),
];
const EXTENSION: &[(&str, i64)] = &[("UNEXTENDED", 0), ("EXTEND_G1", 1), ("EXTEND_213_G2", -1)];
const CURV_DIR: &[(&str, i64)] = &[("left", 0), ("right", 2)];
macro_rules! push_token {
($cur:ident, $out:expr, $token:expr) => {{
let token = $token;
$cur.push_token($out, token);
}};
}
struct Cur<'a, 'c, 'p> {
prims: &'a [Prim],
pos: usize,
scale: f64,
save_format: u32,
failure: Option<TypeFailure>,
resource: Option<cadmpeg_core::CodecError>,
ctx: &'c DecodeContext<'p>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TypeFailure {
UnrepresentableLength,
InvalidSplineCount,
}
enum TypedRecordFailure {
Type(TypeFailure),
Resource(cadmpeg_core::CodecError),
}
impl From<TypeFailure> for TypedRecordFailure {
fn from(failure: TypeFailure) -> Self {
Self::Type(failure)
}
}
impl TypeFailure {
fn reason(self) -> &'static str {
match self {
Self::UnrepresentableLength => "source length cannot be represented in centimetres",
Self::InvalidSplineCount => "spline multiplicity or degree is invalid",
}
}
}
fn length_cm(value: f64, scale: f64) -> Option<f64> {
let direct = value * (scale / 10.0);
let converted = if value != 0.0 && direct == 0.0 {
(value / 10.0) * scale
} else {
direct
};
(converted.is_finite() && (value == 0.0 || converted != 0.0)).then_some(converted)
}
impl<'a> Cur<'a, '_, '_> {
fn push_token(&mut self, out: &mut Vec<Token>, token: Token) {
if self.resource.is_some() {
return;
}
out.push(token);
}
fn push_text_token(&mut self, out: &mut Vec<Token>, value: &str, string: bool) {
if self.resource.is_some() {
return;
}
let copy = value.to_owned();
self.push_token(
out,
if string {
Token::Str(copy)
} else {
Token::Ident(copy)
},
);
}
fn push_lexical_token(&mut self, out: &mut Vec<Token>, prim: &Prim) {
match prim {
Prim::Integer(value) => self.push_token(out, Token::Long(*value)),
Prim::Real(value) => self.push_token(out, Token::Double(*value)),
Prim::Ref(index) => self.push_token(out, Token::Ref(*index)),
Prim::Str(value) => self.push_text_token(out, value, true),
Prim::Open => self.push_token(out, Token::SubtypeOpen),
Prim::Close => self.push_token(out, Token::SubtypeClose),
Prim::Word(word) => match word.as_str() {
"forward" | "single" | "forward_v" | "I" | "F" | "out" => {
self.push_token(out, Token::False);
}
"reversed" | "double" | "reverse_v" | "T" | "in" => {
self.push_token(out, Token::True);
}
_ => self.push_text_token(out, word, false),
},
}
}
fn length(&mut self, value: f64) -> Option<f64> {
match length_cm(value, self.scale) {
Some(converted) => Some(converted),
None => {
self.failure = Some(TypeFailure::UnrepresentableLength);
None
}
}
}
fn invalid_spline_count<T>(&mut self) -> Option<T> {
self.failure = Some(TypeFailure::InvalidSplineCount);
None
}
fn peek(&self) -> Option<&'a Prim> {
self.prims.get(self.pos)
}
fn bump(&mut self) -> Option<&'a Prim> {
let prim = self.prims.get(self.pos)?;
self.pos += 1;
Some(prim)
}
fn done(&self) -> bool {
self.pos == self.prims.len()
}
fn num(&mut self) -> Option<f64> {
match self.peek()? {
Prim::Real(value) => {
self.pos += 1;
Some(*value)
}
Prim::Integer(value) => {
self.pos += 1;
cadmpeg_core::convert::f64_from_i64(*value)
}
_ => None,
}
}
fn long(&mut self) -> Option<i64> {
match self.peek()? {
Prim::Integer(value) => {
self.pos += 1;
Some(*value)
}
_ => None,
}
}
fn word(&mut self) -> Option<&'a str> {
match self.peek()? {
Prim::Word(word) => {
self.pos += 1;
Some(word)
}
_ => None,
}
}
fn word_is(&mut self, expected: &str) -> Option<()> {
match self.peek()? {
Prim::Word(word) if word == expected => {
self.pos += 1;
Some(())
}
_ => None,
}
}
fn enum_word(&mut self, vocab: &[(&str, i64)], out: &mut Vec<Token>) -> Option<()> {
if self.save_format < 200 {
if let Some(Prim::Integer(value)) = self.peek() {
let value = *value;
self.bump();
push_token!(self, out, Token::Enum(value));
return Some(());
}
}
let word = self.word()?;
let (_, value) = vocab.iter().find(|(name, _)| *name == word)?;
push_token!(self, out, Token::Enum(*value));
Some(())
}
fn opt_bound(&mut self, out: &mut Vec<Token>) -> Option<()> {
match self.word()? {
"I" => {
push_token!(self, out, Token::False);
Some(())
}
"F" => {
let value = self.num()?;
push_token!(self, out, Token::True);
push_token!(self, out, Token::Double(value));
Some(())
}
_ => None,
}
}
fn triple(&mut self, scale: bool) -> Option<[f64; 3]> {
let mark = self.pos;
let mut values = [0.0; 3];
for value in &mut values {
let Some(number) = self.num() else {
self.pos = mark;
return None;
};
*value = if scale { self.length(number)? } else { number };
}
Some(values)
}
fn float_array(&mut self, out: &mut Vec<Token>) -> Option<()> {
let mark = self.pos;
let count = self.long()?;
let count = usize::try_from(count).ok()?;
if count > self.prims.len().checked_sub(self.pos)? {
self.pos = mark;
return None;
}
let mut values = Vec::new();
for _ in 0..count {
let Some(value) = self.num() else {
self.pos = mark;
return None;
};
values.push(value);
}
out.push(Token::Long(i64::try_from(count).ok()?));
out.extend(values.into_iter().map(Token::Double));
Some(())
}
}
fn take_slot(cur: &mut Cur<'_, '_, '_>, slot: Slot, out: &mut Vec<Token>) -> Option<()> {
if cur.save_format < 200 && matches!(slot, Slot::Sense | Slot::Sides | Slot::UvSense | Slot::B)
{
if let Some(Prim::Integer(value)) = cur.peek() {
let token = match value {
0 => Token::False,
1 => Token::True,
_ => return None,
};
cur.bump();
push_token!(cur, out, token);
return Some(());
}
}
match slot {
Slot::R => match cur.bump()? {
Prim::Ref(index) => {
push_token!(cur, out, Token::Ref(*index));
Some(())
}
Prim::Integer(index) if cur.save_format < 103 => {
push_token!(cur, out, Token::Ref(*index));
Some(())
}
_ => None,
},
Slot::L => {
let value = cur.long()?;
push_token!(cur, out, Token::Long(value));
Some(())
}
Slot::D => {
let value = cur.num()?;
push_token!(cur, out, Token::Double(value));
Some(())
}
Slot::DLen => {
let value = cur.num()?;
push_token!(cur, out, Token::Double(cur.length(value)?));
Some(())
}
Slot::DLenSentinel => {
let value = cur.num()?;
let converted = if value == -1.0 {
value
} else {
cur.length(value)?
};
push_token!(cur, out, Token::Double(converted));
Some(())
}
Slot::S => match cur.bump()? {
Prim::Str(value) => {
cur.push_text_token(out, value, true);
Some(())
}
Prim::Integer(0) if cur.save_format < 700 => {
cur.push_text_token(out, "", true);
Some(())
}
Prim::Integer(count) if cur.save_format < 700 => {
let count = usize::try_from(*count).ok()?;
let value = cur.word()?;
if value.len() != count {
return None;
}
cur.push_text_token(out, value, true);
Some(())
}
_ => None,
},
Slot::P => {
let triple = cur.triple(true)?;
push_token!(cur, out, Token::Position(triple));
Some(())
}
Slot::VLen => {
let triple = cur.triple(true)?;
push_token!(cur, out, Token::Vector3(triple));
Some(())
}
Slot::VUnit => {
let triple = cur.triple(false)?;
push_token!(cur, out, Token::Vector3(triple));
Some(())
}
Slot::Sense => match cur.word()? {
"forward" => {
push_token!(cur, out, Token::False);
Some(())
}
"reversed" => {
push_token!(cur, out, Token::True);
Some(())
}
_ => None,
},
Slot::Sides => match cur.word()? {
"single" => {
push_token!(cur, out, Token::False);
Some(())
}
"double" => {
push_token!(cur, out, Token::True);
Some(())
}
_ => None,
},
Slot::UvSense => match cur.word()? {
"forward_v" => {
push_token!(cur, out, Token::False);
Some(())
}
"reverse_v" => {
push_token!(cur, out, Token::True);
Some(())
}
_ => None,
},
Slot::B => {
let token = logical_word(cur.word()?)?;
push_token!(cur, out, token);
Some(())
}
Slot::OptB => cur.opt_bound(out),
Slot::Sub => type_subtype(cur, out),
}
}
fn run_shape(cur: &mut Cur<'_, '_, '_>, slots: &[Slot], out: &mut Vec<Token>) -> Option<()> {
for slot in slots {
take_slot(cur, *slot, out)?;
}
Some(())
}
fn try_shape(
ctx: &DecodeContext<'_>,
prims: &[Prim],
scale: f64,
slots: &[Slot],
save_format: u32,
) -> Result<Option<Vec<Token>>, TypedRecordFailure> {
let mut cur = Cur {
prims,
pos: 0,
scale,
save_format,
failure: None,
resource: None,
ctx,
};
let mut out = Vec::new();
let matched = run_shape(&mut cur, slots, &mut out).is_some() && cur.done();
match (cur.resource, cur.failure) {
(Some(error), _) => Err(TypedRecordFailure::Resource(error)),
(None, Some(failure)) => Err(TypedRecordFailure::Type(failure)),
(None, None) => Ok(matched.then_some(out)),
}
}
#[derive(Clone, Copy)]
enum BsKind {
Parameter,
PlaneLength,
Model,
}
fn spline_poles(
cur: &mut Cur<'_, '_, '_>,
knots: i64,
degree: i64,
out: &mut Vec<Token>,
) -> Option<usize> {
let Some(knot_count) = usize::try_from(knots).ok() else {
return cur.invalid_spline_count();
};
if degree < 1 || knot_count < 2 || knot_count > (cur.prims.len() - cur.pos) / 2 {
return cur.invalid_spline_count();
}
let mut sum = 0i64;
for _ in 0..knot_count {
let knot = cur.num()?;
let mult = cur.long()?;
if mult < 1 {
return cur.invalid_spline_count();
}
sum = match sum.checked_add(mult) {
Some(value) => value,
None => return cur.invalid_spline_count(),
};
push_token!(cur, out, Token::Double(knot));
push_token!(cur, out, Token::Long(mult));
}
let Some(poles) = degree
.checked_sub(1)
.and_then(|endpoint_adjustment| sum.checked_sub(endpoint_adjustment))
.and_then(|count| usize::try_from(count).ok())
else {
return cur.invalid_spline_count();
};
if poles < 2 {
return cur.invalid_spline_count();
}
Some(poles)
}
fn bs_curve_block(cur: &mut Cur<'_, '_, '_>, kind: BsKind, out: &mut Vec<Token>) -> Option<()> {
let marker = cur.word()?;
let rational = match marker {
"nubs" => false,
"nurbs" => true,
_ => return None,
};
push_token!(cur, out, Token::Ident(marker.to_string()));
let degree = cur.long()?;
push_token!(cur, out, Token::Long(degree));
cur.enum_word(CLOSURE, out)?;
let knots = cur.long()?;
push_token!(cur, out, Token::Long(knots));
let poles = spline_poles(cur, knots, degree, out)?;
let (coords, scaled_coords) = match kind {
BsKind::Parameter => (2, 0),
BsKind::PlaneLength => (2, 2),
BsKind::Model => (3, 3),
};
let per_pole = coords + usize::from(rational);
if poles > (cur.prims.len() - cur.pos) / per_pole {
return cur.invalid_spline_count();
}
for _ in 0..poles {
for coordinate in 0..per_pole {
let value = cur.num()?;
let scaled = coordinate < scaled_coords;
push_token!(
cur,
out,
Token::Double(if scaled { cur.length(value)? } else { value })
);
}
}
Some(())
}
fn bs_surface_block(cur: &mut Cur<'_, '_, '_>, out: &mut Vec<Token>) -> Option<()> {
let marker = cur.word()?;
let rational = match marker {
"nubs" => false,
"nurbs" => true,
_ => return None,
};
push_token!(cur, out, Token::Ident(marker.to_string()));
let degree_u = cur.long()?;
let degree_v = cur.long()?;
push_token!(cur, out, Token::Long(degree_u));
push_token!(cur, out, Token::Long(degree_v));
if matches!(cur.peek(), Some(Prim::Word(word)) if matches!(word.as_str(), "u" | "v" | "both")) {
let scope = cur.word()?;
cur.push_text_token(out, scope, false);
}
cur.enum_word(CLOSURE, out)?;
cur.enum_word(CLOSURE, out)?;
cur.enum_word(SINGULARITY, out)?;
cur.enum_word(SINGULARITY, out)?;
let knots_u = cur.long()?;
let knots_v = cur.long()?;
push_token!(cur, out, Token::Long(knots_u));
push_token!(cur, out, Token::Long(knots_v));
let mut poles = [0usize; 2];
for (direction, (knots, degree)) in [(knots_u, degree_u), (knots_v, degree_v)]
.into_iter()
.enumerate()
{
poles[direction] = spline_poles(cur, knots, degree, out)?;
}
let per_pole = 3 + usize::from(rational);
let Some(total_poles) = poles[0].checked_mul(poles[1]) else {
return cur.invalid_spline_count();
};
if total_poles > (cur.prims.len() - cur.pos) / per_pole {
return cur.invalid_spline_count();
}
for _ in 0..total_poles {
for coordinate in 0..per_pole {
let value = cur.num()?;
push_token!(
cur,
out,
Token::Double(if coordinate < 3 {
cur.length(value)?
} else {
value
})
);
}
}
Some(())
}
fn exact_int_cur_tail(cur: &mut Cur<'_, '_, '_>, out: &mut Vec<Token>) -> Option<()> {
if let Some(stamp) = match cur.peek() {
Some(Prim::Integer(value)) => Some(*value),
_ => None,
} {
if stamp >= 100 {
cur.long();
push_token!(cur, out, Token::Long(stamp));
}
}
cur.enum_word(CACHE_FORM, out)?;
bs_curve_block(cur, BsKind::Model, out)?;
let tolerance = cur.num()?;
push_token!(cur, out, Token::Double(cur.length(tolerance)?));
for _ in 0..2 {
cur.word_is("null_surface")?;
push_token!(cur, out, Token::Ident("null_surface".to_string()));
}
for _ in 0..2 {
cur.word_is("nullbs")?;
push_token!(cur, out, Token::Ident("nullbs".to_string()));
}
cur.opt_bound(out)?;
cur.opt_bound(out)?;
for _ in 0..3 {
cur.float_array(out)?;
}
let extension = cur.long()?;
push_token!(cur, out, Token::Long(extension));
cur.opt_bound(out)?;
cur.opt_bound(out)?;
if matches!(cur.peek(), Some(Prim::Word(word)) if word == "UNEXTENDED") {
cur.enum_word(EXTENSION, out)?;
cur.enum_word(EXTENSION, out)?;
}
Some(())
}
fn exp_par_cur_tail(cur: &mut Cur<'_, '_, '_>, out: &mut Vec<Token>) -> Option<()> {
bs_curve_block(cur, BsKind::Parameter, out)?;
let tolerance = cur.num()?;
push_token!(cur, out, Token::Double(tolerance));
cur.word_is("spline")?;
push_token!(cur, out, Token::Ident("spline".to_string()));
let sense = cur.word()?;
push_token!(
cur,
out,
match sense {
"forward" => Token::False,
"reversed" => Token::True,
_ => return None,
}
);
type_subtype(cur, out)?;
for _ in 0..4 {
cur.opt_bound(out)?;
}
Some(())
}
fn exact_spl_sur_tail(cur: &mut Cur<'_, '_, '_>, out: &mut Vec<Token>) -> Option<()> {
if let Some(stamp) = match cur.peek() {
Some(Prim::Integer(value)) => Some(*value),
_ => None,
} {
if stamp >= 100 {
cur.long();
push_token!(cur, out, Token::Long(stamp));
}
}
cur.enum_word(CACHE_FORM, out)?;
bs_surface_block(cur, out)?;
let tolerance = cur.num()?;
push_token!(cur, out, Token::Double(cur.length(tolerance)?));
for _ in 0..4 {
let value = cur.num()?;
push_token!(cur, out, Token::Double(value));
}
let extension = cur.long()?;
push_token!(cur, out, Token::Long(extension));
Some(())
}
fn sense_word(cur: &mut Cur<'_, '_, '_>, out: &mut Vec<Token>) -> Option<()> {
take_slot(cur, Slot::Sense, out)
}
fn nullable_surface(cur: &mut Cur<'_, '_, '_>, out: &mut Vec<Token>) -> Option<BsKind> {
let word = match cur.peek()? {
Prim::Word(word) => word.as_str(),
_ => return None,
};
match word {
"null_surface" => {
cur.bump();
push_token!(cur, out, Token::Ident("null_surface".to_string()));
Some(BsKind::Parameter)
}
"spline" => {
cur.bump();
push_token!(cur, out, Token::Ident("spline".to_string()));
sense_word(cur, out)?;
type_subtype_tabled(cur, out)?;
for _ in 0..4 {
cur.opt_bound(out)?;
}
Some(BsKind::Parameter)
}
"plane" => {
cur.bump();
push_token!(cur, out, Token::Ident("plane".to_string()));
for slot in [Slot::P, Slot::VUnit, Slot::VLen, Slot::UvSense] {
take_slot(cur, slot, out)?;
}
for _ in 0..4 {
cur.opt_bound(out)?;
}
Some(BsKind::PlaneLength)
}
"cone" => {
cur.bump();
push_token!(cur, out, Token::Ident("cone".to_string()));
for slot in [Slot::P, Slot::VUnit, Slot::VLen, Slot::D] {
take_slot(cur, slot, out)?;
}
cur.opt_bound(out)?;
cur.opt_bound(out)?;
for slot in [Slot::D, Slot::D, Slot::DLen, Slot::Sense] {
take_slot(cur, slot, out)?;
}
for _ in 0..4 {
cur.opt_bound(out)?;
}
Some(BsKind::Parameter)
}
"sphere" => {
cur.bump();
push_token!(cur, out, Token::Ident("sphere".to_string()));
for slot in [Slot::P, Slot::DLen, Slot::VUnit, Slot::VUnit, Slot::UvSense] {
take_slot(cur, slot, out)?;
}
for _ in 0..4 {
cur.opt_bound(out)?;
}
Some(BsKind::Parameter)
}
"torus" => {
cur.bump();
push_token!(cur, out, Token::Ident("torus".to_string()));
for slot in [
Slot::P,
Slot::VUnit,
Slot::DLen,
Slot::DLen,
Slot::VUnit,
Slot::UvSense,
] {
take_slot(cur, slot, out)?;
}
for _ in 0..4 {
cur.opt_bound(out)?;
}
Some(BsKind::Parameter)
}
_ => None,
}
}
fn nullable_bs2(cur: &mut Cur<'_, '_, '_>, kind: BsKind, out: &mut Vec<Token>) -> Option<()> {
if matches!(cur.peek(), Some(Prim::Word(word)) if word == "nullbs") {
cur.bump();
push_token!(cur, out, Token::Ident("nullbs".to_string()));
return Some(());
}
bs_curve_block(cur, kind, out)
}
fn cache_first_curve_context(cur: &mut Cur<'_, '_, '_>, out: &mut Vec<Token>) -> Option<()> {
let stamp = cur.long()?;
push_token!(cur, out, Token::Long(stamp));
cur.word_is("full")?;
push_token!(cur, out, Token::Enum(0));
bs_curve_block(cur, BsKind::Model, out)?;
let tolerance = cur.num()?;
push_token!(cur, out, Token::Double(cur.length(tolerance)?));
let first = nullable_surface(cur, out)?;
let second = nullable_surface(cur, out)?;
nullable_bs2(cur, first, out)?;
nullable_bs2(cur, second, out)?;
cur.opt_bound(out)?;
cur.opt_bound(out)?;
for _ in 0..3 {
cur.float_array(out)?;
}
let extension = cur.long()?;
push_token!(cur, out, Token::Long(extension));
Some(())
}
fn revision_surface_tail(cur: &mut Cur<'_, '_, '_>, out: &mut Vec<Token>) -> Option<()> {
match cur.word()? {
"full" => {
push_token!(cur, out, Token::Enum(0));
bs_surface_block(cur, out)?;
let tolerance = cur.num()?;
push_token!(cur, out, Token::Double(cur.length(tolerance)?));
}
"none" => {
push_token!(cur, out, Token::Enum(2));
for _ in 0..4 {
cur.opt_bound(out)?;
}
cur.enum_word(CLOSURE, out)?;
cur.enum_word(CLOSURE, out)?;
cur.enum_word(SINGULARITY, out)?;
cur.enum_word(SINGULARITY, out)?;
}
_ => return None,
}
for _ in 0..6 {
cur.float_array(out)?;
}
let flag = logical_word(cur.word()?)?;
push_token!(cur, out, flag);
Some(())
}
fn cyl_spl_sur_tail(cur: &mut Cur<'_, '_, '_>, out: &mut Vec<Token>) -> Option<()> {
let stamp = cur.long()?;
push_token!(cur, out, Token::Long(stamp));
cur.word_is("intcurve")?;
push_token!(cur, out, Token::Ident("intcurve".to_string()));
sense_word(cur, out)?;
type_subtype_tabled(cur, out)?;
cur.opt_bound(out)?;
cur.opt_bound(out)?;
take_slot(cur, Slot::VLen, out)?;
take_slot(cur, Slot::P, out)?;
revision_surface_tail(cur, out)
}
fn exact_spl_sur_revision_tail(cur: &mut Cur<'_, '_, '_>, out: &mut Vec<Token>) -> Option<()> {
let stamp = cur.long()?;
push_token!(cur, out, Token::Long(stamp));
revision_surface_tail(cur, out)?;
for _ in 0..4 {
cur.opt_bound(out)?;
}
cur.enum_word(EXTENSION, out)
}
fn type_subtype(cur: &mut Cur<'_, '_, '_>, out: &mut Vec<Token>) -> Option<()> {
let scope_start = cur.pos;
let out_mark = out.len();
if type_subtype_tabled(cur, out).is_some() {
return Some(());
}
if cur.resource.is_some() {
return None;
}
cur.pos = scope_start;
out.truncate(out_mark);
fallback_scope(cur, out)
}
fn type_subtype_tabled(cur: &mut Cur<'_, '_, '_>, out: &mut Vec<Token>) -> Option<()> {
if cur.resource.is_some() {
return None;
}
if !matches!(cur.peek(), Some(Prim::Open)) {
return None;
}
let ctx = cur.ctx;
let _depth = match ctx.enter_nested("SAT subtype typing") {
Ok(depth) => depth,
Err(error) => {
cur.resource = Some(error);
return None;
}
};
let scope_start = cur.pos;
let out_mark = out.len();
cur.bump();
push_token!(cur, out, Token::SubtypeOpen);
let Some(name) = cur.word() else {
cur.pos = scope_start;
out.truncate(out_mark);
return None;
};
cur.push_text_token(out, name, false);
let matched = if cur.save_format < 700
&& matches!(cur.peek(), Some(Prim::Word(word)) if matches!(word.as_str(), "nubs" | "nurbs" | "full"))
&& matches!(name, "exactcur" | "surfintcur" | "exactsur")
{
legacy::cache_tail(cur, out, name == "exactsur")
} else {
match name {
"ref" => cur
.long()
.map(|index| push_token!(cur, out, Token::Long(index))),
"exp_par_cur" | "exppc" => exp_par_cur_tail(cur, out),
"exact_int_cur" | "exactcur" => exact_int_cur_tail(cur, out),
"exact_spl_sur" | "exactsur" => {
let mark = (cur.pos, out.len());
exact_spl_sur_tail(cur, out).or_else(|| {
cur.pos = mark.0;
out.truncate(mark.1);
exact_spl_sur_revision_tail(cur, out)
})
}
"int_int_cur" => cache_first_curve_context(cur, out),
"par_int_cur" => cache_first_curve_context(cur, out).and_then(|()| {
for _ in 0..2 {
let flag = logical_word(cur.word()?)?;
push_token!(cur, out, flag);
}
Some(())
}),
"blend_int_cur" => cache_first_curve_context(cur, out).and_then(|()| {
let flag = logical_word(cur.word()?)?;
push_token!(cur, out, flag);
Some(())
}),
"spring_int_cur" => {
cache_first_curve_context(cur, out).and_then(|()| cur.enum_word(CURV_DIR, out))
}
"cyl_spl_sur" => cyl_spl_sur_tail(cur, out),
_ => None,
}
};
let closed = matched.and_then(|()| match cur.peek() {
Some(Prim::Close) => {
cur.bump();
push_token!(cur, out, Token::SubtypeClose);
Some(())
}
_ => None,
});
if closed.is_some() {
return Some(());
}
cur.pos = scope_start;
out.truncate(out_mark);
None
}
fn fallback_scope(cur: &mut Cur<'_, '_, '_>, out: &mut Vec<Token>) -> Option<()> {
if !matches!(cur.peek(), Some(Prim::Open)) {
return None;
}
let mut depth = 0usize;
loop {
let prim = cur.bump()?;
cur.push_lexical_token(out, prim);
match prim {
Prim::Open => depth += 1,
Prim::Close => {
depth -= 1;
if depth == 0 {
return Some(());
}
}
_ => {}
}
}
}
mod legacy;
use Slot::{DLen, DLenSentinel, OptB, Sense, Sides, Sub, UvSense, VLen, VUnit, B, D, L, P, R, S};
macro_rules! shape {
($($slot:expr),* $(,)?) => {
&[R, L, R, $($slot),*]
};
}
fn head_shapes(head: &str) -> &'static [&'static [Slot]] {
match head {
"asmheader" => &[&[R, L, S]],
"body" => &[shape![R, R, R]],
"lump" => &[shape![R, R, R]],
"shell" | "subshell" => &[shape![R, R, R, R, R]],
"wire" => &[shape![R, R, R, R, B]],
"face" => &[
shape![R, R, R, R, R, Sense, Sides],
shape![R, R, R, R, R, Sense, Sides, B],
],
"loop" => &[shape![R, R, R]],
"coedge" => &[
shape![R, R, R, R, Sense, R, L, R],
shape![R, R, R, R, Sense, R, R],
],
"tcoedge" => &[
shape![R, R, R, R, Sense, R, L, R, D, D, R, L, L],
shape![R, R, R, R, Sense, R, L, R, D, D, R],
shape![R, R, R, R, Sense, R, L, R, D, D],
shape![R, R, R, R, Sense, R, R, D, D],
],
"edge" => &[shape![R, D, R, D, R, R, Sense, S]],
"tedge" => &[
shape![R, D, R, D, R, R, Sense, S, DLen, L, L],
shape![R, D, R, D, R, R, Sense, S, DLen, L],
shape![R, D, R, D, R, R, Sense, S, DLen],
],
"vertex" => &[shape![R, L, R], shape![R, R]],
"tvertex" => &[
shape![R, L, R, DLenSentinel, DLenSentinel, DLenSentinel, L],
shape![R, L, R, DLenSentinel, DLenSentinel, DLenSentinel],
shape![R, R, DLenSentinel],
],
"point" => &[shape![P]],
"transform" => &[&[R, L, VUnit, VUnit, VUnit, VLen, D, B, B, B]],
"straight" => &[shape![P, VLen, OptB, OptB]],
"ellipse" => &[shape![P, VUnit, VLen, D, OptB, OptB]],
"degenerate_curve" => &[shape![P, OptB, OptB]],
"intcurve" => &[shape![Sense, Sub, OptB, OptB]],
"plane" => &[shape![P, VUnit, VLen, UvSense, OptB, OptB, OptB, OptB]],
"cone" => &[shape![
P, VUnit, VLen, D, OptB, OptB, D, D, DLen, Sense, OptB, OptB, OptB, OptB
]],
"sphere" => &[shape![
P, DLen, VUnit, VUnit, UvSense, OptB, OptB, OptB, OptB
]],
"torus" => &[shape![
P, VUnit, DLen, DLen, VUnit, UvSense, OptB, OptB, OptB, OptB
]],
"spline" => &[shape![Sense, Sub, OptB, OptB, OptB, OptB]],
"pcurve" => &[
shape![L, R, D, D],
shape![L, Sense, Sub, D, D],
],
"rgb_color" => &[shape![R, R, D, D, D, D], shape![R, R, D, D, D]],
"ATTRIB_CUSTOM" => &[shape![R, R, S, L, D], shape![R, R, S, L, L, L, S]],
"DXID" => &[shape![R, R, S, S]],
_ => &[],
}
}
fn type_record(
ctx: &DecodeContext<'_>,
head: &str,
prims: &[Prim],
scale: f64,
save_format: u32,
) -> Result<Vec<Token>, TypedRecordFailure> {
if save_format < 700 {
if let Some(tokens) = legacy::type_record(ctx, head, prims, scale, save_format)? {
return Ok(tokens);
}
}
if (1100..10_000).contains(&save_format) {
if let Some(tokens) = legacy::type_extended_record(ctx, head, prims, scale, save_format)? {
return Ok(tokens);
}
}
for slots in head_shapes(head) {
if let Some(tokens) = try_shape(ctx, prims, scale, slots, save_format)? {
return Ok(tokens);
}
}
let mut cur = Cur {
prims,
pos: 0,
scale,
save_format,
failure: None,
resource: None,
ctx,
};
let mut tokens = Vec::new();
for prim in prims {
cur.push_lexical_token(&mut tokens, prim);
}
if let Some(error) = cur.resource {
return Err(TypedRecordFailure::Resource(error));
}
Ok(tokens)
}
#[cfg(test)]
mod tests {
#[test]
fn sat_subtype_typing_refuses_depth_before_recursive_descent() {
let prefix = "{ cyl_spl_sur 0 intcurve forward { int_int_cur 0 full nubs 1 open 2 0 2 1 2 0 0 0 1 0 0 2 0 0 3 0 0 0 spline forward ";
for (repetitions, limit) in [(1, 0), (4096, 2)] {
let source = asm_stream(&format!(
"spline $-1 -1 $-1 forward {}{} #\n",
prefix.repeat(repetitions),
"} } ".repeat(repetitions),
));
crate::test_support::with_service_context(&source, |service| {
let mut policy = *service.policy();
policy.limits.max_recursion_depth = limit;
let arena = DecodeArena::new();
let (ctx, _) = DecodeContext::from_root_bytes(&source, &arena, &policy)
.expect("source fits input limit");
let error = super::parse(&ctx, &source).expect_err("depth must refuse");
let StreamFailure::Resource(refusal) = error else {
panic!("expected resource refusal, got {error:?}");
};
assert_eq!(refusal.dimension, ResourceDimension::RecursionDepth);
assert_eq!(refusal.operation, "SAT subtype typing");
assert_eq!(refusal.limit, limit);
assert_eq!(refusal.used, limit);
assert_eq!(refusal.additional, 1);
})
.expect("fixture fits service profile");
}
}
#[test]
fn counted_float_array_refuses_an_unavailable_huge_count_without_allocating() {
let prims = [Prim::Integer(i64::MAX)];
crate::test_support::with_service_context(&[], |ctx| {
let mut cur = Cur {
prims: &prims,
pos: 0,
scale: 10.0,
save_format: 23_200,
failure: None,
resource: None,
ctx,
};
let mut output = Vec::new();
assert_eq!(cur.float_array(&mut output), None);
assert_eq!(cur.pos, 0);
assert!(output.is_empty());
})
.expect("fixture fits service profile");
}
#[test]
fn numerical_audit_sat_integer_fields_preserve_exact_values_and_reject_overflow() {
let stream = parse(&asm_stream(
"audit-integer 9007199254740993 -9223372036854775808 9223372036854775807 #\n",
))
.unwrap();
let tokens = &stream.records[0].tokens;
assert!(tokens.contains(&Token::Long(9_007_199_254_740_993)));
assert!(tokens.contains(&Token::Long(i64::MIN)));
assert!(tokens.contains(&Token::Long(i64::MAX)));
for value in ["9223372036854775808", "-9223372036854775809"] {
assert!(parse(&asm_stream(&format!("audit-integer {value} #\n"))).is_err());
}
}
use super::{
bs_curve_block, bs_surface_block, BsKind, Cur, Prim, Terminator, TextStream, TypeFailure,
};
use crate::sab::Token;
use crate::stream_error::{StreamError, StreamFailure};
use cadmpeg_core::decode::{DecodeArena, DecodeContext, DecodePolicy, ResourceDimension};
use cadmpeg_ir::scalar::{NonNegativeReal, PositiveReal};
fn kernel_header(header: &super::TextHeader) -> crate::kernel_header::KernelHeader {
header.as_kernel_header()
}
#[test]
fn sat_framing_refuses_entities_before_record_materialization() {
let source = asm_stream("point $-1 -1 $-1 10 0 0 #\n");
let arena = DecodeArena::new();
let mut policy = DecodePolicy::service();
policy.limits.max_entities = 1;
let (ctx, _) = DecodeContext::from_root_bytes(&source, &arena, &policy)
.expect("source fits input limit");
let error = super::parse(&ctx, &source).expect_err("entity limit must refuse");
let StreamFailure::Resource(limit) = error else {
panic!("expected resource refusal, got {error:?}");
};
assert_eq!(limit.dimension, ResourceDimension::Entities);
assert_eq!(
parse(&source)
.expect("parse the point stream")
.records
.len(),
1
);
}
#[test]
fn sat_typed_text_keeps_its_payload_and_tokens() {
for (body, payload, token_count) in [
("mystery @4 test #\n", 4, 1),
(
"face $-1 -1 $-1 $-1 $-1 $-1 $-1 $-1 forward single #\n",
0,
10,
),
] {
let stream = parse(&asm_stream(body)).expect("typed text parses");
assert_eq!(stream.records.len(), 1);
assert_eq!(stream.records[0].tokens.len(), token_count);
if payload != 0 {
assert_eq!(stream.records[0].tokens[0], Token::Str("test".into()));
} else {
assert_eq!(
&stream.records[0].tokens[8..],
&[Token::False, Token::False]
);
}
}
}
fn parse(bytes: &[u8]) -> Result<TextStream, StreamError> {
let arena = DecodeArena::new();
let (ctx, _) = DecodeContext::from_root_bytes(bytes, &arena, &DecodePolicy::service())
.expect("test stream fits the service input limit");
match super::parse(&ctx, bytes) {
Ok(stream) => Ok(stream),
Err(
StreamFailure::Parse(error)
| StreamFailure::Malformed(error)
| StreamFailure::NotImplemented(error),
) => Err(error),
Err(StreamFailure::Operation(error)) => {
panic!("test context operation failed: {error}")
}
Err(StreamFailure::Resource(error)) => {
panic!(
"test stream exhausted a resource: {}",
StreamFailure::Resource(error)
)
}
}
}
fn approx(a: f64, b: f64) -> bool {
(a - b).abs() <= 1.0e-12 * a.abs().max(b.abs()).max(1.0)
}
fn asm_stream(body: &str) -> Vec<u8> {
let mut text = String::from("23200 0 2 2 \n");
text.push_str(
"16 Autodesk Neutron 21 ASM 232.4.0.65535 OSX 24 Fri Jul 17 14:46:47 2026 \n",
);
text.push_str("1 1e-06 1.0e-10 \n");
text.push_str(body);
text.push_str("End-of-ASM-data \n");
text.into_bytes()
}
#[test]
fn both_dialect_headers_parse_and_record_their_terminator() {
let asm = parse(&asm_stream("asmheader $-1 -1 @13 232.4.0.65535 #\n")).expect("asm stream");
assert_eq!(asm.terminator, Terminator::Asm);
assert_eq!(asm.header.save_format_version, 23200);
assert_eq!(asm.header.entity_count, 2);
assert_eq!(asm.header.flags, 2);
assert_eq!(
asm.header.product_family.as_deref(),
Some("Autodesk Neutron")
);
assert_eq!(
asm.header.product_version.as_deref(),
Some("ASM 232.4.0.65535 OSX")
);
assert_eq!(
asm.header.save_date.as_deref(),
Some("Fri Jul 17 14:46:47 2026")
);
assert!(approx(asm.header.scale().get(), 1.0));
assert!(approx(asm.header.resabs().unwrap().get(), 1.0e-6));
assert!(approx(asm.header.resnor().unwrap().get(), 1.0e-10));
assert_eq!(asm.records.len(), 1);
assert_eq!(asm.records[0].name, "asmheader");
assert_eq!(
&*asm.records[0].tokens,
[
Token::Ref(-1),
Token::Long(-1),
Token::Str("232.4.0.65535".to_string())
]
);
let text = "700 0 1 0 \n30 Autodesk Translation Framework 21 ASM 232.4.0.65535 OSX 24 Fri \
Jul 17 14:48:06 2026 \n25.4 1e-06 1.0e-10 \nbody $-1 -1 $-1 $-1 $-1 $-1 \
#\nEnd-of-ACIS-data \n";
let acis = parse(text.as_bytes()).expect("acis stream");
assert_eq!(acis.terminator, Terminator::Acis);
assert_eq!(acis.header.save_format_version, 700);
assert!(approx(acis.header.scale().get(), 25.4));
assert_eq!(acis.records[0].head(), "body");
assert_eq!(acis.records[0].index, 0);
}
#[test]
fn text_reference_tokens_do_not_declare_a_binary_width() {
let stream = parse(&asm_stream(
"asmheader $4294967296 -1 @13 232.4.0.65535 #\n",
))
.unwrap();
assert_eq!(stream.records[0].ref_at(0), Some(4_294_967_296));
let header = kernel_header(&stream.header);
for family in [
crate::dialect::KernelHeaderRef::TextAsm(&header),
crate::dialect::KernelHeaderRef::TextAcis(&header),
] {
let classified = crate::dialect::classify(family);
assert!(!classified
.declared()
.contains_key(crate::dialect::DECLARED_REFERENCE_WIDTH));
}
}
#[test]
fn header_conversion_reports_the_centimetre_convention() {
let stream = parse(&asm_stream("asmheader $-1 -1 @13 232.4.0.65535 #\n")).expect("stream");
let header = kernel_header(&stream.header);
assert_eq!(header.save_format_version, Some(23200));
assert_eq!(header.entity_count, Some(2));
assert_eq!(header.flags, Some(2));
assert_eq!(header.scale, Some(10.0));
let expected_resabs_cm = stream.header.resabs().unwrap().get() / 10.0;
assert_eq!(header.linear, Some(expected_resabs_cm));
}
#[test]
fn text_header_resabs_uses_declared_length_scale() {
let source = String::from_utf8(asm_stream("asmheader $-1 -1 @13 232.4.0.65535 #\n"))
.expect("ASCII stream");
let source = source.replacen("1 1e-06 1.0e-10", "25.4 1e-06 1.0e-10", 1);
let stream = parse(source.as_bytes()).expect("inch-scale stream");
let actual = kernel_header(&stream.header).linear.expect("resabs");
let expected_resabs_cm = stream.header.resabs().unwrap().get() * 2.54;
assert!((actual / expected_resabs_cm - 1.0).abs() < f64::EPSILON);
}
#[test]
fn text_header_retains_admitted_tolerances_and_conversion_bits() {
let source = String::from_utf8(asm_stream("asmheader $-1 -1 @13 232.4.0.65535 #\n"))
.expect("ASCII stream");
let source = source.replacen("1 1e-06 1.0e-10", "25.4 1e-06 1.0e-10", 1);
let stream = parse(source.as_bytes()).expect("positive scale and nonnegative tolerances");
let scale: PositiveReal = stream.header.scale();
let resabs: NonNegativeReal = stream.header.resabs().unwrap();
let resnor: NonNegativeReal = stream.header.resnor().unwrap();
assert_eq!(scale.get(), 25.4);
assert_eq!(resabs.get(), 1.0e-6);
assert_eq!(resnor.get(), 1.0e-10);
let expected = resabs.get() * (scale.get() / 10.0);
assert_eq!(kernel_header(&stream.header).linear, Some(expected));
}
#[test]
fn text_header_omits_unrepresentable_resabs_conversion() {
let source = String::from_utf8(asm_stream("asmheader $-1 -1 @13 232.4.0.65535 #\n"))
.expect("ASCII stream");
for replacement in ["20 1.7976931348623157e308 1.0e-10", "5e-324 1 1.0e-10"] {
let source = source.replacen("1 1e-06 1.0e-10", replacement, 1);
let stream =
parse(source.as_bytes()).expect("independent records survive invalid tolerance");
assert_eq!(kernel_header(&stream.header).linear, None);
assert_eq!(stream.header.diagnostics.len(), 1);
assert!(stream.header.diagnostics[0]
.error
.reason
.contains("cannot be represented"));
}
}
#[test]
fn unusable_header_units_read_lengths_as_millimetres() {
for scale in ["0", "-1", "NaN", "inf"] {
let mut stream = asm_stream("asmheader $-1 -1 @13 232.4.0.65535 #\n");
let scale_start = stream
.windows(b"1 1e-06 1.0e-10".len())
.position(|window| window == b"1 1e-06 1.0e-10")
.expect("tolerance line");
stream.splice(scale_start..=scale_start, scale.bytes());
let parsed = parse(&stream).expect("records survive an unusable units word");
assert_eq!(parsed.header.units(), super::TextUnits::Unspecified);
assert_eq!(parsed.header.scale(), PositiveReal::ONE);
assert_eq!(parsed.records.len(), 1);
let units: Vec<_> = parsed
.header
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.kind == super::StreamDiagnosticKind::Units)
.collect();
assert_eq!(units.len(), 1);
assert_eq!(units[0].error.offset, scale_start);
}
}
#[test]
fn legacy_save_formats_store_one_header_line() {
let stream = parse(
b"106 0 1 0 \nbody $-1 $1 $-1 $-1 #\nlump $-1 $-1 $-1 $0 #\nEnd-of-ACIS-data \n",
)
.expect("legacy stream frames");
assert_eq!(stream.header.save_format_version, 106);
assert_eq!(stream.header.product_family, None);
assert_eq!(stream.header.units(), super::TextUnits::Unspecified);
assert_eq!(stream.header.resabs(), None);
assert_eq!(stream.header.source_span, 0..21);
assert_eq!(
stream
.records
.iter()
.map(|record| record.name.as_str())
.collect::<Vec<_>>(),
["body", "lump"]
);
assert_eq!(stream.header.diagnostics.len(), 1);
assert_eq!(
stream.header.diagnostics[0].kind,
super::StreamDiagnosticKind::Units
);
}
#[test]
fn sequence_numbers_do_not_change_record_indices() {
let stream = parse(
b"106 0 1 0 \n-0 body $-1 $1 $-1 $-1 #\n-1 lump $-1 $-1 $-1 $0 #\nEnd-of-ACIS-data \n",
)
.expect("sequence-numbered stream frames");
let framed: Vec<_> = stream
.records
.iter()
.map(|record| (record.index, record.name.as_str()))
.collect();
assert_eq!(framed, [(0, "body"), (1, "lump")]);
assert!(stream.framing.is_empty());
}
#[test]
fn unterminated_streams_keep_complete_records() {
let mut asm = asm_stream("asmheader $-1 -1 @13 232.4.0.65535 #\nbody $-1 -1 ");
asm.truncate(asm.len() - b"End-of-ASM-data \n".len());
let incomplete = asm.len() - b"body $-1 -1 ".len();
let stream = parse(&asm).expect("complete records survive truncation");
assert_eq!(stream.records.len(), 1);
assert_eq!(stream.terminator, Terminator::Asm);
assert!(!stream.has_terminator_line);
assert_eq!(stream.unread, Some(incomplete..asm.len()));
assert_eq!(stream.framing.len(), 1);
assert_eq!(stream.framing[0].kind, super::StreamDiagnosticKind::Unread);
assert_eq!(stream.framing[0].error.offset, incomplete);
let header_only = b"700 0 1 0 \n14 cadmpeg-tests 4 ACIS 0 \n-1 1e-06 1e-10 \n";
let stream = parse(header_only).expect("a header-only stream frames no records");
assert!(stream.records.is_empty());
assert_eq!(stream.terminator, Terminator::Acis);
assert_eq!(stream.unread, None);
assert_eq!(stream.framing.len(), 1);
assert_eq!(
stream.framing[0].kind,
super::StreamDiagnosticKind::Terminator
);
}
#[test]
fn data_after_the_terminator_line_is_left_unread() {
let mut source = asm_stream("asmheader $-1 -1 @13 232.4.0.65535 #\n");
let tail = source.len();
source.extend_from_slice(b"body $-1 #\n");
let stream = parse(&source).expect("terminated records survive trailing data");
assert_eq!(stream.records.len(), 1);
assert!(stream.has_terminator_line);
assert_eq!(stream.unread, Some(tail..source.len()));
assert_eq!(stream.framing[0].kind, super::StreamDiagnosticKind::Unread);
}
#[test]
fn non_utf8_fields_keep_their_record_boundaries() {
let mut source = asm_stream("");
let body_start = source.len() - b"End-of-ASM-data \n".len();
let body = b"name_attrib-attrib $-1 $-1 $-1 $-1 7 \x02y\xff\x87 0 #\nbody $-1 #\n";
source.splice(body_start..body_start, body.iter().copied());
let stream = parse(&source).expect("record boundaries survive invalid UTF-8");
assert_eq!(stream.records.len(), 2);
assert_eq!(stream.records[1].name, "body");
let notes: Vec<_> = stream
.framing
.iter()
.filter(|diagnostic| diagnostic.kind == super::StreamDiagnosticKind::Metadata)
.collect();
assert_eq!(notes.len(), 1);
assert!(notes[0].error.reason.starts_with("1 field(s)"));
}
#[test]
fn invalid_header_tolerances_are_omitted_without_losing_records() {
for (resabs, resnor) in [
("-1", "1.0e-10"),
("NaN", "1.0e-10"),
("inf", "1.0e-10"),
("1.0e-6", "-1"),
("1.0e-6", "NaN"),
("1.0e-6", "inf"),
] {
let mut stream = asm_stream("asmheader $-1 -1 @13 232.4.0.65535 #\n");
let tolerance_start = stream
.windows(b"1 1e-06 1.0e-10".len())
.position(|window| window == b"1 1e-06 1.0e-10")
.expect("tolerance line");
let replacement = format!("1 {resabs} {resnor}");
stream.splice(
tolerance_start..tolerance_start + b"1 1e-06 1.0e-10".len(),
replacement.bytes(),
);
let parsed = parse(&stream).expect("tolerance metadata does not frame records");
assert_eq!(parsed.records.len(), 1);
assert_eq!(parsed.header.diagnostics.len(), 1);
assert_eq!(parsed.header.diagnostics[0].error.offset, tolerance_start);
assert!(parsed.header.resabs().is_none() || parsed.header.resnor().is_none());
}
}
#[test]
fn header_lines_reject_extra_fields() {
let valid = asm_stream("asmheader $-1 -1 @13 232.4.0.65535 #\n");
let lines = valid
.split_inclusive(|byte| *byte == b'\n')
.collect::<Vec<_>>();
assert!(lines.len() >= 3);
for (line, extra) in [(0, b" 9".as_slice()), (1, b" extra"), (2, b" 9")] {
let mut malformed = Vec::new();
for (ordinal, bytes) in lines.iter().enumerate() {
if ordinal == line {
malformed.extend_from_slice(&bytes[..bytes.len() - 1]);
malformed.extend_from_slice(extra);
malformed.push(b'\n');
} else {
malformed.extend_from_slice(bytes);
}
}
if line == 0 {
assert!(
parse(&malformed).is_err(),
"structural header words must frame"
);
} else {
let parsed = parse(&malformed).expect("extra metadata is independently framed");
assert_eq!(parsed.header.diagnostics.len(), 1);
assert_eq!(parsed.records.len(), 1);
}
}
}
#[test]
fn counted_header_strings_require_a_separator_after_the_count() {
let valid = asm_stream("asmheader $-1 -1 @13 232.4.0.65535 #\n");
let mut malformed = valid.clone();
let separator = malformed
.windows(b"16 Autodesk".len())
.position(|window| window == b"16 Autodesk")
.map(|start| start + 2)
.expect("product-family separator");
malformed.remove(separator);
let parsed = parse(&malformed).expect("product-line newline preserves records");
assert_eq!(parsed.header.product_family, None);
assert_eq!(parsed.records.len(), 1);
assert_eq!(
parsed.header.diagnostics[0].error.offset,
valid.iter().position(|byte| *byte == b'\n').unwrap() + 1
);
}
#[test]
fn counted_string_bytes_may_contain_whitespace_and_newlines() {
let stream = parse(&asm_stream(
"ATTRIB_CUSTOM-attrib $-1 -1 $-1 $-1 $0 @9 a b\nc d e 1 7 #\n",
))
.expect("string with newline");
assert_eq!(
stream.records[0].tokens[5],
Token::Str("a b\nc d e".to_string())
);
assert_eq!(stream.records[0].tokens[6], Token::Long(1));
assert!(matches!(stream.records[0].tokens[7], Token::Double(v) if approx(v, 7.0)));
let stream = parse(&asm_stream("mystery @2 é #\n")).expect("multibyte UTF-8 string");
assert_eq!(stream.records[0].tokens[0], Token::Str("é".to_string()));
}
#[test]
fn text_fields_report_invalid_utf8_at_the_source_byte() {
let mut header = asm_stream("mystery 1 #\n");
let header_offset = header
.windows(b"Autodesk".len())
.position(|window| window == b"Autodesk")
.expect("header string offset");
header[header_offset] = 0xff;
let mut bare = asm_stream("mystery invalid #\n");
let bare_offset = bare
.windows(b"invalid".len())
.position(|window| window == b"invalid")
.expect("bare field offset");
bare[bare_offset] = 0xff;
let mut counted = asm_stream("mystery @1 x #\n");
let counted_offset = counted
.windows(b"@1 x".len())
.position(|window| window == b"@1 x")
.map(|offset| offset + 3)
.expect("counted string offset");
counted[counted_offset] = 0xff;
let recovered = parse(&header).expect("header UTF-8 defect does not affect record framing");
assert_eq!(recovered.header.diagnostics[0].error.offset, header_offset);
assert_eq!(recovered.header.product_family, None);
assert_eq!(
recovered.header.product_version.as_deref(),
Some("ASM 232.4.0.65535 OSX")
);
assert!(recovered.header.save_date.is_some());
assert_eq!(recovered.records.len(), 1);
for (bytes, expected_offset) in [(bare, bare_offset), (counted, counted_offset)] {
let stream = parse(&bytes).expect("framed fields survive invalid UTF-8");
assert_eq!(stream.records.len(), 1);
assert_eq!(stream.framing.len(), 1);
assert_eq!(
stream.framing[0].kind,
super::StreamDiagnosticKind::Metadata
);
assert_eq!(stream.framing[0].error.offset, expected_offset);
}
}
#[test]
fn prefixed_fields_require_decimal_operands() {
for malformed_field in [
"$record",
"@length",
"$99999999999999999999999999999999999999999999999999",
"@99999999999999999999999999999999999999999999999999",
] {
let stream = asm_stream(&format!("mystery {malformed_field} #\n"));
let field_offset = stream
.windows(malformed_field.len())
.position(|window| window == malformed_field.as_bytes())
.expect("malformed field offset");
let error = parse(&stream).expect_err("malformed prefixed field must fail");
assert_eq!(error.offset, field_offset);
}
}
#[test]
fn subtype_scope_delimiters_must_balance() {
for (body, error_field) in [("mystery { scope #\n", "#"), ("mystery } #\n", "}")] {
let stream = asm_stream(body);
let error_offset = stream
.iter()
.position(|byte| *byte == error_field.as_bytes()[0])
.expect("delimiter offset");
let error = parse(&stream).expect_err("unbalanced subtype scope must fail");
assert_eq!(error.offset, error_offset);
}
}
#[test]
fn records_span_lines_until_their_terminator() {
let stream = parse(&asm_stream("point $-1 -1 $-1 1 \n\t2 \n\t3 #\n")).expect("wrapped");
assert_eq!(stream.records.len(), 1);
assert_eq!(stream.records[0].tokens.len(), 4);
}
#[test]
fn numeric_slots_accept_exact_integer_above_contiguous_f64_range() {
let source = asm_stream("point $-1 -1 $-1 9007199254740994 0 0 #\n");
let stream = parse(&source).expect("exact integer coordinate");
assert_eq!(
stream.records[0].tokens[3],
Token::Position([9_007_199_254_740_994.0 * 0.1, 0.0, 0.0])
);
}
#[test]
fn numeric_slots_refuse_inexact_integer_coordinates() {
let source = asm_stream("point $-1 -1 $-1 9007199254740993 0 0 #\n");
let stream = parse(&source).expect("inexact numeric shape keeps lexical tokens");
assert_eq!(stream.records[0].tokens.len(), 6);
assert_eq!(
stream.records[0].tokens[3],
Token::Long(9_007_199_254_740_993)
);
}
#[test]
fn position_slots_coalesce_three_numbers_with_unit_conversion() {
let stream = parse(&asm_stream("point $-1 -1 $-1 180 30 20 #\n")).expect("point");
let Token::Position(p) = &stream.records[0].tokens[3] else {
panic!("position token expected");
};
assert!(approx(p[0], 18.0) && approx(p[1], 3.0) && approx(p[2], 2.0));
}
#[test]
fn range_bound_words_and_logical_words_type_by_slot_class() {
let stream = parse(&asm_stream(
"straight-curve $-1 -1 $-1 0 0 0 10 0 0 F 0 F 1 #\n",
))
.expect("straight");
let tokens = &stream.records[0].tokens;
assert_eq!(tokens[5], Token::True);
assert!(matches!(tokens[6], Token::Double(v) if approx(v, 0.0)));
assert_eq!(tokens[7], Token::True);
assert!(matches!(tokens[8], Token::Double(v) if approx(v, 1.0)));
let stream = parse(&asm_stream(
"straight-curve $-1 -1 $-1 0 0 0 10 0 0 I I #\n",
))
.expect("straight unbounded");
let tokens = &stream.records[0].tokens;
assert_eq!(&tokens[5..], [Token::False, Token::False]);
let stream = parse(&asm_stream("mystery $-1 -1 F T #\n")).expect("mystery");
assert_eq!(
&*stream.records[0].tokens,
[Token::Ref(-1), Token::Long(-1), Token::False, Token::True]
);
}
#[test]
fn sense_and_sidedness_words_map_onto_booleans() {
let stream = parse(&asm_stream(
"face $1 -1 $-1 $-1 $0 $0 $-1 $0 reversed single #\n",
))
.expect("face");
let tokens = &stream.records[0].tokens;
assert_eq!(tokens[8], Token::True);
assert_eq!(tokens[9], Token::False);
}
#[test]
fn subtype_reference_scope_types_as_ident_and_long() {
let stream = parse(&asm_stream(
"spline-surface $-1 -1 $-1 forward { ref 6 } I I I I #\n",
))
.expect("spline ref");
assert_eq!(
&*stream.records[0].tokens,
[
Token::Ref(-1),
Token::Long(-1),
Token::Ref(-1),
Token::False,
Token::SubtypeOpen,
Token::Ident("ref".to_string()),
Token::Long(6),
Token::SubtypeClose,
Token::False,
Token::False,
Token::False,
Token::False
]
);
}
#[test]
fn legacy_and_modern_pcurve_spellings_share_the_wrapped_grammar() {
for name in ["exp_par_cur", "exppc"] {
let body = format!(
"pcurve $-1 -1 $-1 0 forward {{ {name} nubs 1 open 2 0 1 1 1 0 0 1 0 0 spline \
forward {{ ref 1 }} I I I I }} 0 0 #\n"
);
let stream = parse(&asm_stream(&body)).expect("wrapped pcurve");
let tokens = &stream.records[0].tokens;
assert_eq!(tokens[3], Token::Long(0));
assert_eq!(tokens[4], Token::False);
assert_eq!(tokens[5], Token::SubtypeOpen);
assert_eq!(tokens[6], Token::Ident(name.to_string()));
assert_eq!(tokens[7], Token::Ident("nubs".to_string()));
assert_eq!(tokens[8], Token::Long(1));
assert_eq!(tokens[9], Token::Enum(0));
assert_eq!(tokens[10], Token::Long(2));
assert!(matches!(tokens[11], Token::Double(v) if approx(v, 0.0)));
assert_eq!(tokens[12], Token::Long(1));
assert!(matches!(tokens[tokens.len() - 1], Token::Double(_)));
assert!(matches!(tokens[tokens.len() - 2], Token::Double(_)));
}
}
#[test]
fn exact_int_cur_grammar_types_the_cache_and_scales_control_points() {
let text = "700 0 1 0 \n30 Autodesk Translation Framework 21 ASM 232.4.0.65535 OSX 24 \
Fri Jul 17 14:48:06 2026 \n25.4 1e-06 1.0e-10 \nintcurve-curve $-1 -1 $-1 \
forward { exact_int_cur 23100 full nubs 1 open 2 0 1 1 1 1 2 3 4 5 6 0 \
null_surface null_surface nullbs nullbs I I 0 0 0 0 F 1 F 0 UNEXTENDED \
UNEXTENDED } I I #\nEnd-of-ACIS-data \n";
let stream = parse(text.as_bytes()).expect("exact intcurve");
let tokens = &stream.records[0].tokens;
assert_eq!(tokens[5], Token::Ident("exact_int_cur".to_string()));
assert_eq!(tokens[6], Token::Long(23100));
assert_eq!(tokens[7], Token::Enum(0));
assert_eq!(tokens[8], Token::Ident("nubs".to_string()));
assert!(matches!(tokens[12], Token::Double(v) if approx(v, 0.0)));
let cp0 = tokens
.iter()
.filter_map(|token| match token {
Token::Double(v) => Some(*v),
_ => None,
})
.nth(2)
.expect("first control coordinate");
assert!(approx(cp0, 2.54));
assert_eq!(
tokens.iter().filter(|t| **t == Token::Enum(0)).count(),
4 );
}
#[test]
fn positive_subnormal_scale_preserves_or_refuses_each_point_coordinate() {
let source = String::from_utf8(asm_stream("point $-1 -1 $-1 10 0 0 #\n"))
.expect("ASCII stream")
.replacen("1 1e-06 1.0e-10", "5e-324 0 0", 1);
let stream = parse(source.as_bytes()).expect("representable subnormal coordinate");
assert_eq!(stream.header.scale().get(), f64::from_bits(1));
assert_eq!(
stream.records[0].tokens[3],
Token::Position([f64::from_bits(1), 0.0, 0.0])
);
let collapsed = source.replacen(" 10 0 0 #", " 1 0 0 #", 1);
let error = parse(collapsed.as_bytes()).expect_err("unrepresentable coordinate");
assert_eq!(
error.reason,
"source length cannot be represented in centimetres"
);
}
#[test]
fn curve_multiplicity_overflow_is_a_malformed_count() {
let prims = [
Prim::Word("nubs".into()),
Prim::Integer(1),
Prim::Word("open".into()),
Prim::Integer(2),
Prim::Real(0.0),
Prim::Integer(i64::MAX),
Prim::Real(1.0),
Prim::Integer(1),
];
crate::test_support::with_service_context(&[], |ctx| {
let mut cur = Cur {
prims: &prims,
pos: 0,
scale: 10.0,
save_format: 23_200,
failure: None,
resource: None,
ctx,
};
assert_eq!(
bs_curve_block(&mut cur, BsKind::Model, &mut Vec::new()),
None
);
assert_eq!(cur.failure, Some(TypeFailure::InvalidSplineCount));
})
.expect("fixture fits service profile");
let text = asm_stream(
"intcurve-curve $-1 -1 $-1 forward { exact_int_cur 23100 full nubs 1 open 2 0 9223372036854775807 1 1 1 2 3 4 5 6 0 null_surface null_surface nullbs nullbs I I 0 0 0 0 F 1 F 0 UNEXTENDED UNEXTENDED } I I #\n",
);
let arena = DecodeArena::new();
let (ctx, _) = DecodeContext::from_root_bytes(&text, &arena, &DecodePolicy::service())
.expect("source fits input limit");
assert!(matches!(
super::parse(&ctx, &text),
Err(StreamFailure::Malformed(error))
if error.reason == "spline multiplicity or degree is invalid"
));
}
#[test]
fn surface_multiplicity_overflow_is_a_malformed_count() {
let prims = [
Prim::Word("nubs".into()),
Prim::Integer(1),
Prim::Integer(1),
Prim::Word("open".into()),
Prim::Word("open".into()),
Prim::Word("none".into()),
Prim::Word("none".into()),
Prim::Integer(2),
Prim::Integer(2),
Prim::Real(0.0),
Prim::Integer(i64::MAX),
Prim::Real(1.0),
Prim::Integer(1),
];
crate::test_support::with_service_context(&[], |ctx| {
let mut cur = Cur {
prims: &prims,
pos: 0,
scale: 10.0,
save_format: 23_200,
failure: None,
resource: None,
ctx,
};
assert_eq!(bs_surface_block(&mut cur, &mut Vec::new()), None);
assert_eq!(cur.failure, Some(TypeFailure::InvalidSplineCount));
})
.expect("fixture fits service profile");
}
#[test]
fn untabled_heads_keep_one_token_per_field() {
let stream = parse(&asm_stream(
"oddity-attrib $-1 -1 $-1 keep copy @3 abc 1.5 2 { weird 3 } #\n",
))
.expect("untabled");
let tokens = &stream.records[0].tokens;
assert_eq!(tokens.len(), 12);
assert_eq!(tokens[3], Token::Ident("keep".to_string()));
assert_eq!(tokens[5], Token::Str("abc".to_string()));
assert!(matches!(tokens[6], Token::Double(v) if approx(v, 1.5)));
assert_eq!(tokens[7], Token::Long(2));
assert_eq!(tokens[8], Token::SubtypeOpen);
}
#[test]
fn record_indices_count_file_order_and_resolve_references() {
let text = "700 0 2 0 \n30 Autodesk Translation Framework 21 ASM 232.4.0.65535 OSX 24 \
Fri Jul 17 14:48:06 2026 \n1 1e-06 1.0e-10 \nbody $-1 -1 $-1 $2 $-1 $-1 #\nbody \
$-1 -1 $-1 $3 $-1 $-1 #\nlump $-1 -1 $-1 $-1 $-1 $0 #\nlump $-1 -1 $-1 $-1 \
$-1 $1 #\nEnd-of-ACIS-data \n";
let stream = parse(text.as_bytes()).expect("bodies");
assert_eq!(stream.records.len(), 4);
let lump = stream.records[0].ref_at(3).expect("first lump ref");
assert_eq!(
stream.records[usize::try_from(lump).expect("index")].head(),
"lump"
);
let owner = stream.records[2].ref_at(5).expect("lump owner");
assert_eq!(owner, 0);
}
#[test]
fn a_stream_without_a_terminator_line_takes_its_save_format_branch() {
let text = "700 0 1 0 \n30 Autodesk Translation Framework 21 ASM 232.4.0.65535 OSX 24 \
Fri Jul 17 14:48:06 2026 \n1 1e-06 1.0e-10 \nbody $-1 -1 $-1 $-1 $-1 $-1 #\n";
let stream = parse(text.as_bytes()).expect("complete records frame");
assert_eq!(stream.records.len(), 1);
assert_eq!(stream.terminator, Terminator::Acis);
assert!(!stream.has_terminator_line);
assert_eq!(stream.unread, None);
assert_eq!(stream.framing.len(), 1);
assert_eq!(
stream.framing[0].kind,
super::StreamDiagnosticKind::Terminator
);
}
fn inch_stream(body: &str) -> Vec<u8> {
let mut text = String::from("23200 0 2 2 \n");
text.push_str("16 Autodesk Neutron 21 ASM 232.4.0.65535 OSX 9 Synthetic \n");
text.push_str("25.4 1e-06 1.0e-10 \n");
text.push_str(body);
text.push_str("End-of-ASM-data \n");
text.into_bytes()
}
fn bs2_poles(tokens: &[Token], ordinal: usize) -> Vec<f64> {
let marker = tokens
.iter()
.enumerate()
.filter(|(_, token)| matches!(token, Token::Ident(name) if name == "nubs"))
.nth(ordinal)
.map(|(pos, _)| pos)
.expect("BS2 marker");
tokens[marker + 8..marker + 12]
.iter()
.map(|token| match token {
Token::Double(value) => *value,
other => panic!("pole coordinate {other:?}"),
})
.collect()
}
#[test]
fn plane_support_pcurve_lengths_take_the_stream_unit() {
let stream = parse(&inch_stream(
"intcurve-curve $-1 -1 $-1 forward { int_int_cur 23100 full nubs 1 open 2 0 1 1 1 \
1 2 3 4 5 6 0 plane 0 0 0 0 0 1 1 0 0 reverse_v I I I I cone 0 0 0 0 0 1 1 0 0 1 \
I I 0 1 1 forward I I I I nubs 1 open 2 0 1 1 1 1 2 3 4 nubs 1 open 2 0 1 1 1 1 2 \
3 4 I I 0 0 0 0 } I I #\n",
))
.expect("intersection curve");
let tokens = &stream.records[0].tokens;
let plane = bs2_poles(tokens, 1);
let cone = bs2_poles(tokens, 2);
for (converted, stored) in plane.iter().zip([1.0, 2.0, 3.0, 4.0]) {
assert!(approx(*converted, stored * 2.54), "{plane:?}");
}
assert_eq!(cone, [1.0, 2.0, 3.0, 4.0]);
}
#[test]
fn face_bound_pcurve_on_a_plane_face_takes_the_stream_unit() {
let pcurve = |u: f64| {
format!(
"pcurve $-1 -1 $-1 0 forward {{ exp_par_cur nubs 1 open 2 0 1 1 1 {u} 2 3 4 0 \
spline forward {{ ref 9 }} I I I I }} 0 0 #\n"
)
};
let mut body = String::from(
"face $-1 -1 $-1 $1 $2 $-1 $-1 $4 forward single #\n\
face $-1 -1 $-1 $-1 $3 $-1 $-1 $5 forward single #\n\
loop $-1 -1 $-1 $-1 $6 $0 #\n\
loop $-1 -1 $-1 $-1 $7 $1 #\n\
plane-surface $-1 -1 $-1 0 0 0 0 0 1 1 0 0 forward_v I I I I #\n\
cone-surface $-1 -1 $-1 0 0 0 0 0 1 1 0 0 1 I I 0 1 1 forward I I I I #\n\
coedge $-1 -1 $-1 $6 $6 $-1 $-1 forward $2 0 $8 #\n\
coedge $-1 -1 $-1 $7 $7 $-1 $-1 forward $3 0 $9 #\n",
);
body.push_str(&pcurve(1.0));
body.push_str(&pcurve(1.0));
let stream = parse(&inch_stream(&body)).expect("two faces");
let plane = bs2_poles(&stream.records[8].tokens, 0);
for (converted, stored) in plane.iter().zip([1.0, 2.0, 3.0, 4.0]) {
assert!(approx(*converted, stored * 2.54), "{plane:?}");
}
assert_eq!(
bs2_poles(&stream.records[9].tokens, 0),
[1.0, 2.0, 3.0, 4.0]
);
}
}