mirror of
https://github.com/ruvnet/RuView.git
synced 2026-08-26 18:16:03 +00:00
376 lines
12 KiB
Rust
376 lines
12 KiB
Rust
//! Bounded line parser for the PoseCode 0.2 multi-actor extension.
|
|
|
|
use crate::error::{Error, Result};
|
|
use crate::model::*;
|
|
use crate::validate::{validate_scene, ValidationConfig, ValidationSeverity};
|
|
|
|
const MAX_INPUT_BYTES: usize = 1_048_576;
|
|
const MAX_LINE_BYTES: usize = 4096;
|
|
|
|
pub fn parse_posecode(source: &str) -> Result<Scene> {
|
|
if source.len() > MAX_INPUT_BYTES {
|
|
return Err(Error::parse(0, "document exceeds 1 MiB"));
|
|
}
|
|
let mut scene: Option<Scene> = None;
|
|
let mut current_actor: Option<usize> = None;
|
|
let mut current_phase: Option<usize> = None;
|
|
let mut elapsed_ms = 0_u64;
|
|
|
|
for (index, raw) in source.lines().enumerate() {
|
|
let line_no = index + 1;
|
|
if raw.len() > MAX_LINE_BYTES {
|
|
return Err(Error::parse(line_no, "line exceeds 4096 bytes"));
|
|
}
|
|
let line = strip_comment(raw).trim();
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
if scene.is_none() {
|
|
let rest = line
|
|
.strip_prefix("posecode scene ")
|
|
.ok_or_else(|| Error::parse(line_no, "expected posecode scene header"))?;
|
|
scene = Some(Scene::new(parse_quoted(rest, line_no)?));
|
|
continue;
|
|
}
|
|
let doc = scene.as_mut().expect("initialized above");
|
|
|
|
if let Some(value) = line.strip_prefix("source ") {
|
|
doc.source = match value {
|
|
"authored" => SceneSource::Authored,
|
|
"observed_wifi_csi" => SceneSource::ObservedWifiCsi,
|
|
"imported" => SceneSource::Imported,
|
|
_ => return Err(Error::parse(line_no, "unknown source")),
|
|
};
|
|
continue;
|
|
}
|
|
if let Some(value) = line.strip_prefix("actor ") {
|
|
let id = value
|
|
.strip_suffix(':')
|
|
.ok_or_else(|| Error::parse(line_no, "actor declaration must end with ':'"))?;
|
|
doc.actors.push(Actor::humanoid(id.trim()));
|
|
current_actor = Some(doc.actors.len() - 1);
|
|
current_phase = None;
|
|
continue;
|
|
}
|
|
if let Some(value) = line.strip_prefix("step ") {
|
|
let (name, tail) = take_quoted(value, line_no)?;
|
|
let fields: Vec<&str> = tail.trim_end_matches(':').split_whitespace().collect();
|
|
if fields.len() != 2 {
|
|
return Err(Error::parse(line_no, "step requires duration and timing"));
|
|
}
|
|
let duration_ms = parse_duration(fields[0], line_no)?;
|
|
let timing = parse_timing(fields[1], line_no)?;
|
|
doc.phases.push(Phase {
|
|
name,
|
|
start_ms: elapsed_ms,
|
|
duration_ms,
|
|
timing,
|
|
actors: Vec::new(),
|
|
contacts: Vec::new(),
|
|
});
|
|
elapsed_ms = elapsed_ms
|
|
.checked_add(duration_ms)
|
|
.ok_or_else(|| Error::parse(line_no, "timeline overflow"))?;
|
|
current_phase = Some(doc.phases.len() - 1);
|
|
current_actor = None;
|
|
continue;
|
|
}
|
|
if let Some(value) = line.strip_prefix("repeat ") {
|
|
doc.repeat = value
|
|
.parse()
|
|
.map_err(|_| Error::parse(line_no, "repeat must be an integer"))?;
|
|
if doc.repeat == 0 || doc.repeat > 10_000 {
|
|
return Err(Error::parse(line_no, "repeat must be 1 to 10000"));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if let Some(ai) = current_actor {
|
|
parse_actor_field(&mut doc.actors[ai], line, line_no)?;
|
|
continue;
|
|
}
|
|
if let Some(pi) = current_phase {
|
|
parse_phase_field(&mut doc.phases[pi], line, line_no)?;
|
|
continue;
|
|
}
|
|
return Err(Error::parse(line_no, "directive is outside actor or step"));
|
|
}
|
|
|
|
let scene = scene.ok_or_else(|| Error::parse(0, "empty document"))?;
|
|
if let Some(issue) = validate_scene(&scene, &ValidationConfig::default())
|
|
.into_iter()
|
|
.find(|i| i.severity == ValidationSeverity::Error)
|
|
{
|
|
return Err(Error::Validation(format!(
|
|
"{}: {}",
|
|
issue.path, issue.message
|
|
)));
|
|
}
|
|
Ok(scene)
|
|
}
|
|
|
|
fn parse_actor_field(actor: &mut Actor, line: &str, line_no: usize) -> Result<()> {
|
|
if let Some(value) = line.strip_prefix("rig ") {
|
|
actor.rig = value.trim().into();
|
|
return Ok(());
|
|
}
|
|
if let Some(value) = line.strip_prefix("pose start = ") {
|
|
actor.start_pose = value.trim().into();
|
|
return Ok(());
|
|
}
|
|
if let Some(value) = line.strip_prefix("track ") {
|
|
actor.track_id = Some(
|
|
value
|
|
.parse()
|
|
.map_err(|_| Error::parse(line_no, "track must be an integer"))?,
|
|
);
|
|
return Ok(());
|
|
}
|
|
if let Some(value) = line.strip_prefix("confidence ") {
|
|
actor.confidence = parse_confidence(value, line_no)?;
|
|
return Ok(());
|
|
}
|
|
if let Some(value) = line.strip_prefix("position ") {
|
|
actor.position = parse_vec3(value, line_no)?;
|
|
return Ok(());
|
|
}
|
|
Err(Error::parse(line_no, "unknown actor directive"))
|
|
}
|
|
|
|
fn parse_phase_field(phase: &mut Phase, line: &str, line_no: usize) -> Result<()> {
|
|
if let Some(value) = line.strip_prefix("contact ") {
|
|
let fields: Vec<&str> = value.split_whitespace().collect();
|
|
if !(fields.len() == 2 || fields.len() == 3) {
|
|
return Err(Error::parse(
|
|
line_no,
|
|
"contact requires two effectors and optional confidence",
|
|
));
|
|
}
|
|
phase.contacts.push(Contact {
|
|
from: parse_effector(fields[0], line_no)?,
|
|
to: parse_effector(fields[1], line_no)?,
|
|
confidence: if fields.len() == 3 {
|
|
parse_confidence(fields[2], line_no)?
|
|
} else {
|
|
1.0
|
|
},
|
|
});
|
|
return Ok(());
|
|
}
|
|
let (lhs, rhs) = line
|
|
.split_once(':')
|
|
.ok_or_else(|| Error::parse(line_no, "phase directive requires ':'"))?;
|
|
let (actor_id, target) = lhs
|
|
.trim()
|
|
.split_once('.')
|
|
.ok_or_else(|| Error::parse(line_no, "target must be actor qualified"))?;
|
|
let actor = phase_actor_mut(phase, actor_id);
|
|
match target {
|
|
"ground-lock" => {
|
|
actor.ground_lock = rhs
|
|
.split(',')
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.collect();
|
|
}
|
|
"travel" => {
|
|
actor.travel = Some(parse_vec3_or_xz(rhs, line_no)?);
|
|
}
|
|
joint => {
|
|
let fields: Vec<&str> = rhs.split_whitespace().collect();
|
|
if !(fields.len() == 2 || fields.len() == 3) {
|
|
return Err(Error::parse(
|
|
line_no,
|
|
"joint target requires action, degrees and optional confidence",
|
|
));
|
|
}
|
|
actor.joints.push(JointTarget {
|
|
joint: joint.into(),
|
|
action: fields[0].into(),
|
|
degrees: fields[1]
|
|
.parse()
|
|
.map_err(|_| Error::parse(line_no, "degrees must be numeric"))?,
|
|
confidence: if fields.len() == 3 {
|
|
parse_confidence(fields[2], line_no)?
|
|
} else {
|
|
1.0
|
|
},
|
|
});
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn phase_actor_mut<'a>(phase: &'a mut Phase, id: &str) -> &'a mut ActorTargets {
|
|
if let Some(index) = phase.actors.iter().position(|a| a.actor_id == id) {
|
|
return &mut phase.actors[index];
|
|
}
|
|
phase.actors.push(ActorTargets::new(id));
|
|
phase.actors.last_mut().expect("just pushed")
|
|
}
|
|
|
|
fn parse_effector(value: &str, line: usize) -> Result<EffectorRef> {
|
|
let (actor_id, effector) = value
|
|
.split_once('.')
|
|
.ok_or_else(|| Error::parse(line, "effector must be actor qualified"))?;
|
|
Ok(EffectorRef {
|
|
actor_id: actor_id.into(),
|
|
effector: effector.into(),
|
|
})
|
|
}
|
|
|
|
fn parse_vec3(value: &str, line: usize) -> Result<Vec3> {
|
|
let f: Vec<&str> = value.split_whitespace().collect();
|
|
if f.len() != 3 {
|
|
return Err(Error::parse(line, "position requires x y z"));
|
|
}
|
|
Ok(Vec3::new(
|
|
parse_f32(f[0], line)?,
|
|
parse_f32(f[1], line)?,
|
|
parse_f32(f[2], line)?,
|
|
))
|
|
}
|
|
|
|
fn parse_vec3_or_xz(value: &str, line: usize) -> Result<Vec3> {
|
|
let f: Vec<&str> = value.split_whitespace().collect();
|
|
match f.len() {
|
|
2 => Ok(Vec3::new(
|
|
parse_f32(f[0], line)?,
|
|
0.0,
|
|
parse_f32(f[1], line)?,
|
|
)),
|
|
3 => parse_vec3(value, line),
|
|
_ => Err(Error::parse(line, "travel requires x z or x y z")),
|
|
}
|
|
}
|
|
|
|
fn parse_f32(value: &str, line: usize) -> Result<f32> {
|
|
let v: f32 = value
|
|
.parse()
|
|
.map_err(|_| Error::parse(line, "expected finite number"))?;
|
|
if !v.is_finite() {
|
|
return Err(Error::parse(line, "expected finite number"));
|
|
}
|
|
Ok(v)
|
|
}
|
|
|
|
fn parse_confidence(value: &str, line: usize) -> Result<f32> {
|
|
let v = parse_f32(value, line)?;
|
|
if !(0.0..=1.0).contains(&v) {
|
|
return Err(Error::parse(line, "confidence must be in [0, 1]"));
|
|
}
|
|
Ok(v)
|
|
}
|
|
|
|
fn parse_duration(value: &str, line: usize) -> Result<u64> {
|
|
let seconds = value
|
|
.strip_suffix('s')
|
|
.ok_or_else(|| Error::parse(line, "duration must end in s"))?;
|
|
let seconds = parse_f32(seconds, line)?;
|
|
if seconds <= 0.0 || seconds > 300.0 {
|
|
return Err(Error::parse(line, "duration must be in (0, 300] seconds"));
|
|
}
|
|
Ok((seconds * 1000.0).round() as u64)
|
|
}
|
|
|
|
fn parse_timing(value: &str, line: usize) -> Result<Timing> {
|
|
match value {
|
|
"flow" => Ok(Timing::Flow),
|
|
"settle" => Ok(Timing::Settle),
|
|
"drive" => Ok(Timing::Drive),
|
|
"snap" => Ok(Timing::Snap),
|
|
"linear" => Ok(Timing::Linear),
|
|
"ease-in" => Ok(Timing::EaseIn),
|
|
"ease-out" => Ok(Timing::EaseOut),
|
|
"ease-in-out" => Ok(Timing::EaseInOut),
|
|
_ => Err(Error::parse(line, "unknown timing")),
|
|
}
|
|
}
|
|
|
|
fn parse_quoted(value: &str, line: usize) -> Result<String> {
|
|
let (quoted, tail) = take_quoted(value, line)?;
|
|
if !tail.trim().is_empty() {
|
|
return Err(Error::parse(line, "unexpected text after quoted name"));
|
|
}
|
|
Ok(quoted)
|
|
}
|
|
|
|
fn take_quoted(value: &str, line: usize) -> Result<(String, &str)> {
|
|
let rest = value
|
|
.strip_prefix('"')
|
|
.ok_or_else(|| Error::parse(line, "expected quoted name"))?;
|
|
let end = rest
|
|
.find('"')
|
|
.ok_or_else(|| Error::parse(line, "unterminated quoted name"))?;
|
|
Ok((rest[..end].to_string(), &rest[end + 1..]))
|
|
}
|
|
|
|
fn strip_comment(line: &str) -> &str {
|
|
let hash = line.find('#');
|
|
let slash = line.find("//");
|
|
match (hash, slash) {
|
|
(Some(a), Some(b)) => &line[..a.min(b)],
|
|
(Some(a), None) | (None, Some(a)) => &line[..a],
|
|
(None, None) => line,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const SCENE: &str = r#"posecode scene "Assisted squat"
|
|
source observed_wifi_csi
|
|
actor patient:
|
|
rig humanoid
|
|
pose start = standing
|
|
track 7
|
|
confidence 0.82
|
|
position 0 0 0
|
|
actor therapist:
|
|
rig humanoid
|
|
pose start = standing
|
|
position 1.2 0 0
|
|
step "Lower" 1.5s flow:
|
|
patient.knee_left: flex 95 0.8
|
|
patient.ground-lock: feet
|
|
therapist.shoulder_left: flex 30
|
|
contact therapist.hand_left patient.shoulder_right 0.7
|
|
repeat 2
|
|
"#;
|
|
|
|
#[test]
|
|
fn parses_multiple_actors_and_contacts() {
|
|
let scene = parse_posecode(SCENE).unwrap();
|
|
assert_eq!(scene.actors.len(), 2);
|
|
assert_eq!(scene.phases[0].actors.len(), 2);
|
|
assert_eq!(scene.phases[0].contacts.len(), 1);
|
|
assert_eq!(scene.repeat, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unknown_actor_reference() {
|
|
let bad = "posecode scene \"x\"\nactor p1:\n rig humanoid\nstep \"x\" 1s flow:\n ghost.knee_left: flex 20";
|
|
assert!(parse_posecode(bad).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_non_finite_number() {
|
|
let bad = "posecode scene \"x\"\nactor p1:\n position NaN 0 0";
|
|
assert!(parse_posecode(bad).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_duplicate_actors() {
|
|
let bad = "posecode scene \"x\"\nactor p1:\n rig humanoid\nactor p1:\n rig humanoid";
|
|
assert!(parse_posecode(bad).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_oversized_document_before_parsing() {
|
|
let bad = "x".repeat(MAX_INPUT_BYTES + 1);
|
|
assert!(parse_posecode(&bad).is_err());
|
|
}
|
|
}
|