Capture full-scene native editing and probe label candidate feasibility
This commit is contained in:
@@ -330,6 +330,278 @@ fn snapshot(project: &Project, diagram: Id, point: Position, zoom: f32) -> Resul
|
||||
)
|
||||
}
|
||||
|
||||
// Bounded reconstruction of routing.rs Rect predicates, not a production API.
|
||||
// The clearance calculation additionally requires exactly orthogonal segments.
|
||||
#[derive(Clone, Copy)]
|
||||
struct LabelProbeRect {
|
||||
left: f32,
|
||||
top: f32,
|
||||
right: f32,
|
||||
bottom: f32,
|
||||
}
|
||||
|
||||
impl LabelProbeRect {
|
||||
fn new(position: Position, size: oa_core::Size) -> Self {
|
||||
Self {
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
right: position.x + size.width,
|
||||
bottom: position.y + size.height,
|
||||
}
|
||||
}
|
||||
|
||||
fn inflate(self, amount: f32) -> Self {
|
||||
Self {
|
||||
left: self.left - amount,
|
||||
top: self.top - amount,
|
||||
right: self.right + amount,
|
||||
bottom: self.bottom + amount,
|
||||
}
|
||||
}
|
||||
|
||||
fn overlaps(self, other: Self) -> bool {
|
||||
self.left < other.right
|
||||
&& self.right > other.left
|
||||
&& self.top < other.bottom
|
||||
&& self.bottom > other.top
|
||||
}
|
||||
|
||||
fn crosses(self, a: Position, b: Position) -> bool {
|
||||
// Keep the source's strict bounds and epsilon, including point segments.
|
||||
const EPSILON: f32 = 0.001;
|
||||
if a == b {
|
||||
return a.x > self.left + EPSILON
|
||||
&& a.x < self.right - EPSILON
|
||||
&& a.y > self.top + EPSILON
|
||||
&& a.y < self.bottom - EPSILON;
|
||||
}
|
||||
if (a.x - b.x).abs() < EPSILON {
|
||||
a.x > self.left + EPSILON
|
||||
&& a.x < self.right - EPSILON
|
||||
&& a.y.min(b.y) < self.bottom - EPSILON
|
||||
&& a.y.max(b.y) > self.top + EPSILON
|
||||
} else if (a.y - b.y).abs() < EPSILON {
|
||||
a.y > self.top + EPSILON
|
||||
&& a.y < self.bottom - EPSILON
|
||||
&& a.x.min(b.x) < self.right - EPSILON
|
||||
&& a.x.max(b.x) > self.left + EPSILON
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn clearance(self, a: Position, b: Position) -> f32 {
|
||||
let dx = (a.x.min(b.x) - self.right)
|
||||
.max(self.left - a.x.max(b.x))
|
||||
.max(0.);
|
||||
let dy = (a.y.min(b.y) - self.bottom)
|
||||
.max(self.top - a.y.max(b.y))
|
||||
.max(0.);
|
||||
dx.hypot(dy)
|
||||
}
|
||||
}
|
||||
|
||||
fn label_candidates(project: &Project, diagram_id: Id, edge_id: Id) -> Result<Value> {
|
||||
let original = project.clone();
|
||||
let diagram = project.diagrams.get(&diagram_id).ok_or("Unknown diagram")?;
|
||||
let view = projection::project_view(project, diagram.id);
|
||||
if view.nodes.iter().any(|node| node.visual_parent.is_some()) {
|
||||
return Err("Label candidate probe supports only flat projected nodes".into());
|
||||
}
|
||||
let routed = routing::route_diagram(project, diagram, &view);
|
||||
let target = routed.edges.get(&edge_id).ok_or("Unknown routed edge")?;
|
||||
if target.custom_suspended
|
||||
|| diagram
|
||||
.routes
|
||||
.iter()
|
||||
.any(|route| route.edge == edge_id && route.label.is_some())
|
||||
{
|
||||
return Err("Label candidate probe requires an automatic, unsuspended label".into());
|
||||
}
|
||||
if target.label.text.is_empty() || target.points.len() < 2 {
|
||||
return Err("Label candidate probe requires nonempty text and a routed path".into());
|
||||
}
|
||||
for edge in routed.edges.values() {
|
||||
if project
|
||||
.relations
|
||||
.get(&edge.id)
|
||||
.is_some_and(|relation| !routing::association_end_geometry(relation, edge).is_empty())
|
||||
{
|
||||
return Err("Label candidate probe does not support association-end labels".into());
|
||||
}
|
||||
if edge.id < edge_id && !edge.label.text.is_empty() {
|
||||
return Err("Label candidate probe does not support earlier nonempty labels".into());
|
||||
}
|
||||
if edge
|
||||
.points
|
||||
.iter()
|
||||
.any(|p| !p.x.is_finite() || !p.y.is_finite())
|
||||
|| edge
|
||||
.points
|
||||
.windows(2)
|
||||
.any(|p| p[0].x != p[1].x && p[0].y != p[1].y)
|
||||
{
|
||||
return Err("Label candidate probe requires finite, exactly orthogonal paths".into());
|
||||
}
|
||||
}
|
||||
// All projected nodes are flat, so edge_obstacles has no traversable ancestors.
|
||||
let obstacles: Vec<_> = view
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| LabelProbeRect::new(node.position, node.size).inflate(12.))
|
||||
.collect();
|
||||
// Match record_segments' exact undirected deduplication. Semantic ownership
|
||||
// is reported separately below and must not be lost by this score dedup.
|
||||
let mut strokes: Vec<(Position, Position)> = Vec::new();
|
||||
for edge in routed.edges.values() {
|
||||
for pair in edge.points.windows(2) {
|
||||
let (a, b) = (pair[0], pair[1]);
|
||||
if a != b
|
||||
&& !strokes
|
||||
.iter()
|
||||
.any(|&(x, y)| (x == a && y == b) || (x == b && y == a))
|
||||
{
|
||||
strokes.push((a, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
let size = oa_core::Size {
|
||||
width: target.label.text.chars().count() as f32 * 7. + 16.,
|
||||
height: 24.,
|
||||
};
|
||||
if size != target.label.bounds.size {
|
||||
return Err(
|
||||
"Production label dimensions differ from this experiment's reconstruction".into(),
|
||||
);
|
||||
}
|
||||
let length = |a: Position, b: Position| (b.x - a.x).hypot(b.y - a.y);
|
||||
let total: f32 = target.points.windows(2).map(|p| length(p[0], p[1])).sum();
|
||||
let mut alternatives = vec![0.5_f32];
|
||||
if total > 0. {
|
||||
let mut traversed = 0.;
|
||||
for pair in target.points.windows(2) {
|
||||
let len = length(pair[0], pair[1]);
|
||||
alternatives.push((traversed + len / 2.) / total);
|
||||
traversed += len;
|
||||
}
|
||||
alternatives.extend((1..20).map(|i| i as f32 / 20.));
|
||||
}
|
||||
alternatives.sort_by(|a, b| {
|
||||
(a - 0.5)
|
||||
.abs()
|
||||
.total_cmp(&(b - 0.5).abs())
|
||||
.then_with(|| a.total_cmp(b))
|
||||
});
|
||||
alternatives.dedup();
|
||||
alternatives.truncate(32);
|
||||
let offsets = [
|
||||
Position { x: 0., y: -18. },
|
||||
Position { x: 0., y: 18. },
|
||||
Position { x: 0., y: -46. },
|
||||
Position { x: 0., y: 46. },
|
||||
Position { x: 0., y: -74. },
|
||||
Position { x: 0., y: 74. },
|
||||
Position {
|
||||
x: size.width / 2. + 18.,
|
||||
y: 0.,
|
||||
},
|
||||
Position {
|
||||
x: -size.width / 2. - 18.,
|
||||
y: 0.,
|
||||
},
|
||||
];
|
||||
let mut rows = Vec::new();
|
||||
let mut scores = Vec::new();
|
||||
for offset in offsets {
|
||||
for &fraction in &alternatives {
|
||||
let anchor = routing::point_along_polyline(&target.points, fraction)
|
||||
.ok_or("Missing candidate anchor")?;
|
||||
let center = Position {
|
||||
x: anchor.x + offset.x,
|
||||
y: anchor.y + offset.y,
|
||||
};
|
||||
let position = Position {
|
||||
x: center.x - size.width / 2.,
|
||||
y: center.y - size.height / 2.,
|
||||
};
|
||||
let bounds = LabelProbeRect::new(position, size);
|
||||
let inflated = bounds.inflate(3.);
|
||||
let score = (
|
||||
obstacles
|
||||
.iter()
|
||||
.filter(|obstacle| obstacle.overlaps(bounds))
|
||||
.count(),
|
||||
0_usize,
|
||||
strokes
|
||||
.iter()
|
||||
.filter(|&&(a, b)| inflated.crosses(a, b))
|
||||
.count(),
|
||||
);
|
||||
let mut own_clearance: Option<f32> = None;
|
||||
let mut foreign_clearance: Option<f32> = None;
|
||||
let mut own_intersections = Vec::new();
|
||||
let mut foreign_intersections = Vec::new();
|
||||
for edge in routed.edges.values() {
|
||||
let mut indices = Vec::new();
|
||||
for (index, pair) in edge.points.windows(2).enumerate() {
|
||||
if pair[0] == pair[1] {
|
||||
continue;
|
||||
}
|
||||
let clearance = bounds.clearance(pair[0], pair[1]);
|
||||
let nearest = if edge.id == edge_id {
|
||||
&mut own_clearance
|
||||
} else {
|
||||
&mut foreign_clearance
|
||||
};
|
||||
*nearest = Some(nearest.map_or(clearance, |old| old.min(clearance)));
|
||||
if inflated.crosses(pair[0], pair[1]) {
|
||||
indices.push(index);
|
||||
}
|
||||
}
|
||||
if !indices.is_empty() {
|
||||
let entry = json!({"edge":edge.id,
|
||||
"relation_alias":project.relations.get(&edge.id).map(|r| &r.alias),
|
||||
"segments":indices});
|
||||
if edge.id == edge_id {
|
||||
own_intersections.push(entry);
|
||||
} else {
|
||||
foreign_intersections.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
scores.push(score);
|
||||
rows.push(
|
||||
json!({"fraction":fraction,"offset":offset,"anchor":anchor,"center":center,
|
||||
"bounds":{"position":position,"size":size},"collision_tuple":score,
|
||||
"own_intersections":own_intersections,"foreign_intersections":foreign_intersections,
|
||||
"own_clearance":own_clearance,"foreign_clearance":foreign_clearance}),
|
||||
);
|
||||
}
|
||||
}
|
||||
let best = scores.iter().min().copied().ok_or("No label candidates")?;
|
||||
let best_indices: Vec<_> = scores
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, &score)| (score == best).then_some(index))
|
||||
.collect();
|
||||
if *project != original {
|
||||
return Err("Project changed during label candidate experiment".into());
|
||||
}
|
||||
Ok(
|
||||
json!({"experiment":"issue38 exhaustive f32 label candidate feasibility; not usability acceptance",
|
||||
"scope":"Unbudgeted exhaustive superset of existing candidates. No 8192-comparison budget, early pruning, early exit, or production chooser claim.",
|
||||
"restrictions":"Flat projected nodes, automatic unsuspended nonempty target label, no association-end labels or earlier nonempty labels, finite orthogonal paths.",
|
||||
"geometry_policy":"Source-exact f32 candidate construction and collision predicates: nodes inflated 12; label inflated 3 for stroke intersections; exact undirected stroke deduplication. Clearances use the uninflated label box. Semantic intersection lists retain every distinct edge owner; counts are not the deduplicated collision score.",
|
||||
"build_provenance":"Require external source/build identity; this field does not identify the executable",
|
||||
"diagram":diagram.id,"edge":edge_id,"text":target.label.text,
|
||||
"project":project,"project_conserved":true,"diagnostics":validation::validate(project),
|
||||
"fraction_count":alternatives.len(),"offset_count":offsets.len(),"candidate_count":rows.len(),
|
||||
"distinct_stroke_count":strokes.len(),
|
||||
"zero_node_zero_stroke_count":scores.iter().filter(|score| score.0 == 0 && score.2 == 0).count(),
|
||||
"best_collision_tuple":best,"best_candidate_indices":best_indices,"candidates":rows}),
|
||||
)
|
||||
}
|
||||
|
||||
fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
let mut file = OpenOptions::new().write(true).create_new(true).open(path)?;
|
||||
file.write_all(bytes)?;
|
||||
@@ -374,8 +646,19 @@ fn main() -> Result<()> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if args.len() == 6 && args[0] == "label-candidates" {
|
||||
let input = fs::read(&args[1])?;
|
||||
let project = admit(&input, &fs::read(&args[2])?)?;
|
||||
let mut result = label_candidates(&project, args[3].parse()?, args[4].parse()?)?;
|
||||
result["original_input_utf8"] = json!(std::str::from_utf8(&input)?);
|
||||
if fs::read(&args[1])? != input {
|
||||
return Err("Input changed during label candidate experiment".into());
|
||||
}
|
||||
result["input_bytes_conserved"] = json!(true);
|
||||
return write_new(Path::new(&args[5]), &serde_json::to_vec_pretty(&result)?);
|
||||
}
|
||||
if args.len() != 8 || args[0] != "probe" {
|
||||
return Err("Usage: generate NEW_DIRECTORY | probe INPUT_JSON_OR_SINGLE_SOURCE EXPECTED_DIAGNOSTICS DIAGRAM_UUID WORLD_X WORLD_Y ZOOM NEW_OUTPUT_JSON".into());
|
||||
return Err("Usage: generate NEW_DIRECTORY | probe INPUT_JSON_OR_SINGLE_SOURCE EXPECTED_DIAGNOSTICS DIAGRAM_UUID WORLD_X WORLD_Y ZOOM NEW_OUTPUT_JSON | label-candidates INPUT EXPECTED_DIAGNOSTICS DIAGRAM_UUID EDGE_UUID NEW_OUTPUT_JSON".into());
|
||||
}
|
||||
let input = fs::read(&args[1])?;
|
||||
let project = admit(&input, &fs::read(&args[2])?)?;
|
||||
@@ -399,6 +682,56 @@ fn main() -> Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn conversation_existing_candidates_cannot_avoid_foreign_allocation() {
|
||||
let project = representative().unwrap();
|
||||
let original = project.clone();
|
||||
let target: Id = "63009b5f-539e-5914-b630-ec6b7fefb4d5".parse().unwrap();
|
||||
let foreign = "725f2e1b-aa2c-5615-9b1f-28da178168fa";
|
||||
let result = label_candidates(&project, EIGHT.parse().unwrap(), target).unwrap();
|
||||
assert_eq!(project, original);
|
||||
assert_eq!(result["project"], serde_json::to_value(&original).unwrap());
|
||||
assert_eq!(result["fraction_count"], 23);
|
||||
assert_eq!(result["candidate_count"], 184);
|
||||
assert_eq!(result["distinct_stroke_count"], 37);
|
||||
assert_eq!(result["zero_node_zero_stroke_count"], 0);
|
||||
assert_eq!(result["best_collision_tuple"], json!([0, 0, 1]));
|
||||
// Independently declared from the saved baseline geometry and a JS
|
||||
// double-precision reconstruction, not generated by this Rust scorer.
|
||||
// Allow f32 interpolation differences without changing the finding.
|
||||
let expected = [
|
||||
(0.5, 274.5),
|
||||
(0.45, 294.8),
|
||||
(0.55, 254.2),
|
||||
(0.6, 233.9),
|
||||
(0.65, 213.6),
|
||||
(0.6761083743842364, 203.),
|
||||
];
|
||||
let near = |value: &Value, expected: f64| {
|
||||
assert!(
|
||||
(value.as_f64().unwrap() - expected).abs() < 0.0002,
|
||||
"{value} differs from declared {expected}"
|
||||
);
|
||||
};
|
||||
let best = result["best_candidate_indices"].as_array().unwrap();
|
||||
assert_eq!(best.len(), expected.len());
|
||||
for (index, (fraction, y)) in best.iter().zip(expected) {
|
||||
let row = &result["candidates"][index.as_u64().unwrap() as usize];
|
||||
near(&row["fraction"], fraction);
|
||||
near(&row["offset"]["x"], 134.5);
|
||||
near(&row["offset"]["y"], 0.);
|
||||
near(&row["center"]["x"], 1296.5);
|
||||
near(&row["center"]["y"], y);
|
||||
near(&row["own_clearance"], 18.);
|
||||
near(&row["foreign_clearance"], 0.);
|
||||
assert_eq!(row["own_intersections"], json!([]));
|
||||
let intersections = row["foreign_intersections"].as_array().unwrap();
|
||||
assert_eq!(intersections.len(), 1);
|
||||
assert_eq!(intersections[0]["edge"], foreign);
|
||||
assert_eq!(intersections[0]["relation_alias"], "sa_repair_allocation");
|
||||
assert_eq!(intersections[0]["segments"], json!([2]));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn representative_preserves_every_original_record_and_view() {
|
||||
let baseline = admit(RELEASE, RELEASE_WARNINGS).unwrap();
|
||||
let mut generated = representative().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user