Point clouds: colour and filter by any property the file carries (#8816) (#8819)

* Point clouds: colour and filter by any property of a PLY file, on range tracks (#5151)

A PLY file's other vertex properties are kept as named numbers on the
points (whole-numbered for integer types; a comment `property: 0 name,
1 name` names a label's values). A cloud is coloured by any of them, a
ramp or a palette for whole numbers, and filtered by up to eight, each a
range or a set of named values. The GPU colours and filters the points
itself, so editing a range uploads nothing.

In the Edit Point Cloud card each range is a track, as in an audio
editor: a histogram of the property over what the other filters keep,
stacked by colour, under an overview strip. Ranges are drawn, moved,
snapped to colour changes, typed (Start, End, Length, relative values)
and played (Space or Play). The automatic colour range is exactly the
values the property takes over what the filters keep.

pointcloud.insert and pointcloud.edit take color_by: <property>,
color_range and filters; pointcloud.list reports them with the
properties. docs/point-clouds.md describes it all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Point clouds: note the saved properties, colouring and filters as a pending format change (#8816)

A design now saves a cloud's per-point columns and their value names, colouring by a property and
filters; an older release must refuse it instead of dropping them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Mario Geiger
2026-10-30 14:02:29 +00:00
committed by GitHub
parent 56e752c182
commit 332f96c26a
31 changed files with 3044 additions and 299 deletions
@@ -0,0 +1,58 @@
{
"en": {
"ui.point_cloud_add_filter": "Add filter…",
"ui.point_cloud_filter_by": "Filter by",
"ui.point_cloud_legend_filter": "Shown and hidden in the legend above",
"ui.point_cloud_pause": "Pause",
"ui.point_cloud_play": "Play"
},
"fr": {
"ui.point_cloud_add_filter": "Ajouter un filtre…",
"ui.point_cloud_filter_by": "Filtrer par",
"ui.point_cloud_legend_filter": "Affichées et masquées dans la légende ci-dessus",
"ui.point_cloud_pause": "Pause",
"ui.point_cloud_play": "Lire"
},
"de": {
"ui.point_cloud_add_filter": "Filter hinzufügen…",
"ui.point_cloud_filter_by": "Filtern nach",
"ui.point_cloud_legend_filter": "In der Legende oben ein- und ausgeblendet",
"ui.point_cloud_pause": "Pause",
"ui.point_cloud_play": "Abspielen"
},
"ja": {
"ui.point_cloud_add_filter": "フィルターを追加…",
"ui.point_cloud_filter_by": "フィルター",
"ui.point_cloud_legend_filter": "上の凡例で表示・非表示を切り替え",
"ui.point_cloud_pause": "一時停止",
"ui.point_cloud_play": "再生"
},
"zh": {
"ui.point_cloud_add_filter": "添加筛选…",
"ui.point_cloud_filter_by": "筛选依据",
"ui.point_cloud_legend_filter": "在上方图例中显示或隐藏",
"ui.point_cloud_pause": "暂停",
"ui.point_cloud_play": "播放"
},
"ar": {
"ui.point_cloud_add_filter": "إضافة مرشح…",
"ui.point_cloud_filter_by": "التصفية حسب",
"ui.point_cloud_legend_filter": "تُعرض وتُخفى في وسيلة الإيضاح أعلاه",
"ui.point_cloud_pause": "إيقاف مؤقت",
"ui.point_cloud_play": "تشغيل"
},
"he": {
"ui.point_cloud_add_filter": "הוספת מסנן…",
"ui.point_cloud_filter_by": "סינון לפי",
"ui.point_cloud_legend_filter": "מוצגים ומוסתרים במקרא שלמעלה",
"ui.point_cloud_pause": "השהיה",
"ui.point_cloud_play": "הפעלה"
},
"vi": {
"ui.point_cloud_add_filter": "Thêm bộ lọc…",
"ui.point_cloud_filter_by": "Lọc theo",
"ui.point_cloud_legend_filter": "Hiện và ẩn trong chú giải ở trên",
"ui.point_cloud_pause": "Tạm dừng",
"ui.point_cloud_play": "Phát"
}
}
+77 -19
View File
@@ -697,38 +697,96 @@ fn fs_line_all(i: LOut) -> @location(0) vec4<f32> {
return out_color(i.c);
}
// Point clouds: each point is a round dot a few pixels across, facing the screen, depth-tested
// and written like the model's faces, so the model hides the points behind it and they hide it.
// The colour's alpha carries the dot's size (eighths of a pixel).
// Point clouds (`view/clouds.rs`): each point is a round dot a few pixels across, facing the
// screen, depth-tested and written like the model's faces, so the model hides the points behind
// it and they hide it. The cloud's style colours them by their own colour, a ramp or a palette,
// and shows only those whose filtered values lie in its ranges or sets.
struct POut {
struct Cloud {
style: vec4<f32>, // x: colour (0 own, 1 ramp, 2 palette), y: dot size (px), z, w: ramp range
filters: array<vec4<f32>, 4>, // x: kind (0 off, 1 range y…z, 2 whole values y + i for the bits i of w)
};
@group(1) @binding(3) var<uniform> cloud: Cloud;
struct CIn {
@location(0) p: vec3<f32>,
@location(1) v: f32,
@location(2) f: vec4<f32>,
@location(3) rgb: vec4<f32>,
};
struct COut {
@builtin(position) pos: vec4<f32>,
@location(0) c: vec4<f32>,
@location(1) wp: vec3<f32>,
@location(2) at: vec2<f32>,
@location(0) wp: vec3<f32>,
@location(1) v: f32,
@location(2) f: vec4<f32>,
@location(3) rgb: vec3<f32>,
@location(4) at: vec2<f32>, // the place on its disc
@location(5) @interpolate(flat) has: u32,
};
@vertex
fn vs_point(@builtin(vertex_index) vi: u32, @location(0) p: vec3<f32>, @location(1) c: vec4<f32>) -> POut {
fn vs_cloud_dot(@builtin(vertex_index) vi: u32, i: CIn) -> COut {
var xs = array<f32, 6>(-1.0, 1.0, 1.0, -1.0, 1.0, -1.0);
var ys = array<f32, 6>(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0);
let at = vec2<f32>(xs[vi], ys[vi]);
let size = max(c.a * 255.0 / 8.0, 1.0);
let centre = u.vp * vec4<f32>(p, 1.0);
let half = u.screen.xy * 0.5;
var o: POut;
o.pos = vec4<f32>(centre.xy + at * size * 0.5 / half * centre.w, centre.z, centre.w);
let size = max(cloud.style.y, 1.0);
let centre = u.vp * vec4<f32>(i.p, 1.0);
var o: COut;
o.pos = vec4<f32>(centre.xy + at * size / u.screen.xy * centre.w, centre.z, centre.w);
// Behind the eye: nothing.
if (near_z(centre) < 0.0) { o.pos = vec4<f32>(0.0, 0.0, 0.0, -1.0); }
o.c = vec4<f32>(c.rgb, 1.0);
o.wp = p;
o.wp = i.p;
o.v = i.v;
o.f = i.f;
o.rgb = i.rgb.rgb;
o.has = u32(round(i.rgb.a * 255.0));
// Dots under two pixels are squares: a disc that small loses its centre pixel.
o.at = at * select(1.0, 0.0, size < 2.0);
return o;
}
@fragment
fn fs_point(i: POut) -> @location(0) vec4<f32> {
if (clipped(i.wp) || dot(i.at, i.at) > 1.0) { discard; }
return out_color(i.c);
/// Whether filter `f` keeps the value `v`.
fn cloud_keeps(f: vec4<f32>, v: f32) -> bool {
if (f.x < 0.5) { return true; }
if (f.x < 1.5) { return v >= f.y && v <= f.z; }
let k = i32(round(v)) - i32(round(f.y));
return k >= 0 && k < 32 && ((bitcast<u32>(f.w) >> u32(k)) & 1u) != 0u;
}
fn cloud_ramp(t: f32) -> vec3<f32> {
var stops = array<vec3<f32>, 5>(
vec3<f32>(40.0, 70.0, 200.0), vec3<f32>(30.0, 170.0, 230.0), vec3<f32>(60.0, 200.0, 90.0),
vec3<f32>(240.0, 220.0, 60.0), vec3<f32>(230.0, 70.0, 50.0));
let k = clamp(t, 0.0, 1.0) * 4.0;
let j = min(u32(k), 3u);
return mix(stops[j], stops[j + 1u], k - f32(j)) / 255.0;
}
fn cloud_palette(v: f32) -> vec3<f32> {
var colours = array<vec3<f32>, 10>(
vec3<f32>(240.0, 140.0, 40.0), vec3<f32>(245.0, 205.0, 65.0), vec3<f32>(230.0, 65.0, 65.0),
vec3<f32>(165.0, 75.0, 190.0), vec3<f32>(65.0, 190.0, 115.0), vec3<f32>(75.0, 150.0, 220.0),
vec3<f32>(240.0, 120.0, 180.0), vec3<f32>(140.0, 105.0, 75.0), vec3<f32>(120.0, 215.0, 215.0),
vec3<f32>(190.0, 190.0, 80.0));
if (v < -0.5) { return vec3<f32>(150.0, 160.0, 176.0) / 255.0; }
return colours[u32(round(v)) % 10u] / 255.0;
}
@fragment
fn fs_cloud(o: COut) -> @location(0) vec4<f32> {
var out = clipped(o.wp) || dot(o.at, o.at) > 1.0;
for (var k = 0u; k < 4u; k++) {
out = out || (((o.has >> k) & 1u) != 0u && !cloud_keeps(cloud.filters[k], o.f[k]));
}
if (out) { discard; }
var c = o.rgb;
let mode = cloud.style.x;
if ((o.has & 16u) != 0u && mode > 1.5) {
c = cloud_palette(o.v);
} else if ((o.has & 16u) != 0u && mode > 0.5) {
c = cloud_ramp((o.v - cloud.style.z) / max(cloud.style.w - cloud.style.z, 1e-12));
}
return out_color(vec4<f32>(c, 1.0));
}
@@ -0,0 +1,7 @@
//! A point cloud keeps the other properties its file gives its points (#8816): named numbers per
//! point in `complex` (`columns`, each whole-numbered or not, and the `names` a whole-numbered one's
//! values carry), and it can be coloured by one of them (`color_by: "property"`, with
//! `color_property`, the ramp over `height_range`) and shown only where `filters` keep it (ranges,
//! or sets of named values). Older designs need no transformation: their clouds have no other
//! properties and no filters. The last release refuses a design saved with this (the newer format
//! number) instead of opening its clouds without their properties, colouring and filters.
+2
View File
@@ -53,6 +53,8 @@ pub mod physical_material;
mod piece_names;
pub mod pipe_thread_catalog;
pub mod point_cloud;
mod point_cloud_complex;
pub mod point_cloud_distribution;
mod points;
mod profile_refs;
pub mod progress;
+136 -9
View File
@@ -11,6 +11,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
use solidcraft_geom::Vec3;
use crate::canvas::{base64_decode, base64_encode};
pub use crate::point_cloud_complex::{Column, Complex, Filter, Floats, Keep, MAX_COLUMNS, MAX_NAMES, Names};
use crate::{DocError, IDENTITY, Mat, Result};
/// Most points one cloud may hold.
@@ -19,6 +20,10 @@ pub const MAX_POINTS: usize = 20_000_000;
pub const MAX_COORD: f64 = 1e9;
/// Point sizes on screen (pixels).
pub const POINT_SIZE: std::ops::RangeInclusive<f64> = 0.5..=16.0;
/// Most filters on one cloud.
pub const MAX_FILTERS: usize = 8;
/// Properties every cloud has besides its file's: the coordinates in the design.
pub const COORDINATES: [&str; 3] = ["x", "y", "z"];
/// Length units a point cloud file may be in, and millimetres per unit.
pub const UNITS: [(&str, f64); 5] = [("mm", 1.0), ("cm", 10.0), ("m", 1000.0), ("in", 25.4), ("ft", 304.8)];
@@ -65,6 +70,24 @@ pub struct PointCloud {
pub height_range: Option<[f64; 2]>,
#[serde(default = "yes")]
pub visible: bool,
/// The file's other properties of the points.
#[serde(default, skip_serializing_if = "Complex::is_empty")]
pub complex: Complex,
/// The property `color_by: property` colours by.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color_property: Option<String>,
/// Only the points every filter keeps are shown.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub filters: Vec<Filter>,
}
/// Where a property's values come from.
#[derive(Clone, Copy, Debug)]
pub enum Source<'a> {
/// A coordinate of each point in the design.
Coordinate(usize),
/// A column of the file: each point's.
Column(&'a [f32]),
}
/// How a cloud's points are coloured.
@@ -78,6 +101,8 @@ pub enum ColorBy {
Single,
/// By height in the design: blue low, through cyan, green and yellow, to red high.
Height,
/// By `color_property`: the height ramp over its range or, for whole numbers, a palette.
Property,
}
impl ColorBy {
@@ -90,6 +115,7 @@ impl ColorBy {
"file" => Some(ColorBy::File),
"single" => Some(ColorBy::Single),
"height" => Some(ColorBy::Height),
"property" => Some(ColorBy::Property),
_ => None,
}
}
@@ -98,6 +124,7 @@ impl ColorBy {
ColorBy::File => "file",
ColorBy::Single => "single",
ColorBy::Height => "height",
ColorBy::Property => "property",
}
}
}
@@ -120,6 +147,27 @@ pub fn height_ramp(t: f64) -> [u8; 3] {
std::array::from_fn(|c| (a[c] * (1.0 - f) + b[c] * f).round().clamp(0.0, 255.0) as u8)
}
/// Colours of whole-number labels, the label modulo their count; negative labels are grey.
pub const PALETTE: [[u8; 3]; 10] = [
[240, 140, 40],
[245, 205, 65],
[230, 65, 65],
[165, 75, 190],
[65, 190, 115],
[75, 150, 220],
[240, 120, 180],
[140, 105, 75],
[120, 215, 215],
[190, 190, 80],
];
pub fn label_color(v: f64) -> [u8; 3] {
if !(v.is_finite() && v >= 0.0) {
return PLAIN.map(byte);
}
PALETTE[(v.round() as usize) % PALETTE.len()]
}
/// A colour channel (0..1) as a byte.
pub fn byte(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0).round() as u8
@@ -182,15 +230,57 @@ impl PointCloud {
let (lo, hi) = (at(HEIGHT_TAIL)?, at(1.0 - HEIGHT_TAIL)?);
Some(if hi - lo > 1e-9 { [lo, hi] } else { [lo - 0.5, lo + 0.5] })
}
/// The property colours come from: a column, or a coordinate (`z` for the height ramp).
pub fn colored_property(&self) -> Option<&str> {
match self.color_by {
ColorBy::Height => Some("z"),
ColorBy::Property => self.color_property.as_deref(),
ColorBy::File | ColorBy::Single => None,
}
}
/// Where the values of `name` are: a coordinate of each point or a column of the file.
pub fn source(&self, name: &str) -> Option<Source<'_>> {
if let Some(k) = COORDINATES.iter().position(|n| *n == name) {
return Some(Source::Coordinate(k));
}
self.complex.column(name).map(|col| Source::Column(&col.values.0))
}
/// Every property the cloud can be coloured and filtered by: its coordinates, then its columns.
pub fn property_names(&self) -> impl Iterator<Item = &str> {
COORDINATES.into_iter().chain(self.complex.columns.iter().map(|c| c.name.as_str()))
}
/// The smallest and largest value of a property, the coordinates in the design.
pub fn span(&self, property: &str) -> Option<[f64; 2]> {
if let Some(span) = self.complex.span(property) {
return Some(span);
}
let k = COORDINATES.iter().position(|n| *n == property)?;
let v = self.world_corners()?.map(|p| [p.x, p.y, p.z][k]);
Some([v.iter().copied().fold(f64::INFINITY, f64::min), v.iter().copied().fold(f64::NEG_INFINITY, f64::max)])
}
/// The ramp's range as far as it can be told without the elements: the one set, or else the
/// coloured property's span within every range filtering it. The automatic range colouring
/// by a property is exactly what the filters keep
/// ([`crate::point_cloud_distribution::color_range`]), which this bounds.
pub fn color_range(&self) -> Option<[f64; 2]> {
if self.height_range.is_some() {
return self.height_range;
}
let property = self.colored_property()?;
let span = if self.color_by == ColorBy::Height { self.auto_height_range() } else { self.span(property) }?;
let shown =
self.filters.iter().filter(|f| f.property == property).filter_map(Filter::range).fold(span, |[lo, hi], [a, b]| [lo.max(a), hi.min(b)]);
Some(if shown[0] <= shown[1] { crate::point_cloud_distribution::ramp_range(shown) } else { span })
}
/// The heights the height ramp runs between now: the cloud's own, else the automatic ones.
pub fn height_range_now(&self) -> Option<[f64; 2]> {
self.height_range.or_else(|| self.auto_height_range())
}
/// The colour of each point (by index) as the cloud is shown.
pub fn shade(&self) -> impl Fn(usize) -> [u8; 3] + '_ {
/// The colour of each point (by index) as the cloud is shown, the ramp over `range`.
pub fn shade(&self, range: Option<[f64; 2]>) -> impl Fn(usize) -> [u8; 3] + '_ {
let single = self.color.unwrap_or(PLAIN).map(byte);
let plain = PLAIN.map(byte);
let range = if self.color_by == ColorBy::Height { self.height_range_now() } else { None };
let column = self.color_property.as_deref().and_then(|n| self.complex.column(n));
move |i| match self.color_by {
ColorBy::File => self.colors.get(i).unwrap_or(plain),
ColorBy::Single => single,
@@ -198,6 +288,14 @@ impl PointCloud {
(Some([lo, hi]), Some(p)) => height_ramp((self.world_z(p) - lo) / (hi - lo)),
_ => plain,
},
ColorBy::Property => match (column, column.and_then(|c| c.values.0.get(i))) {
(Some(c), Some(v)) if c.integer => label_color(f64::from(*v)),
(Some(_), Some(v)) => match range {
Some([lo, hi]) if hi > lo => height_ramp((f64::from(*v) - lo) / (hi - lo)),
_ => plain,
},
_ => plain,
},
}
}
/// The corners of the box around the points, placed in the design (mm).
@@ -240,6 +338,18 @@ impl PointCloud {
if !rigid(&self.transform) {
return bad("its placement is not a rigid move");
}
self.complex.check(self.points.len())?;
if self.filters.len() > MAX_FILTERS
|| self.filters.iter().any(|f| match &f.keep {
Keep::Range { range: [lo, hi] } => !(lo.is_finite() && hi.is_finite() && lo <= hi),
Keep::Values { values } => values.len() > MAX_NAMES,
})
{
return bad("its filters are not ranges, lowest first");
}
if self.color_by == ColorBy::Property && self.color_property.is_none() {
return bad("it is coloured by a property without naming it");
}
Ok(())
}
}
@@ -422,6 +532,9 @@ mod tests {
color: None,
height_range: None,
visible: true,
complex: Complex::default(),
color_property: None,
filters: Vec::new(),
}
}
@@ -443,6 +556,20 @@ mod tests {
assert_eq!((unit_scale("M"), unit_scale("yd")), (Some(1000.0), None));
}
#[test]
fn the_ramp_spans_what_is_shown_unless_set() {
let mut c = cloud(vec![0.0; 9], Vec::new());
c.complex.columns.push(Column { name: "t".into(), integer: false, values: Floats(vec![0.0, 10.0, 20.0].into()) });
(c.color_by, c.color_property) = (ColorBy::Property, Some("t".into()));
assert_eq!(c.color_range(), Some([0.0, 20.0]), "the whole span");
c.filters = vec![Filter { property: "t".into(), keep: Keep::Range { range: [5.0, 50.0] } }];
assert_eq!(c.color_range(), Some([5.0, 20.0]), "the span within the filter");
c.filters.push(Filter { property: "layer".into(), keep: Keep::Range { range: [0.0, 1.0] } });
assert_eq!(c.color_range(), Some([5.0, 20.0]), "another property's filter changes nothing");
c.height_range = Some([1.0, 2.0]);
assert_eq!(c.color_range(), Some([1.0, 2.0]), "a range set is kept");
}
#[test]
fn damaged_clouds_are_refused() {
// Not whole points, not finite, not base64.
@@ -474,26 +601,26 @@ mod tests {
let colors: Vec<u8> = (0..102).flat_map(|i| [i as u8, 7, 9]).collect();
let mut c = cloud(values, colors);
c.origin = Vec3::ZERO;
assert_eq!(c.shade()(3), [3, 7, 9], "the file's colours");
assert_eq!(c.shade(c.color_range())(3), [3, 7, 9], "the file's colours");
c.colors = Colors::default();
assert_eq!(c.shade()(3), PLAIN.map(byte), "none: plain");
assert_eq!(c.shade(c.color_range())(3), PLAIN.map(byte), "none: plain");
c.color_by = ColorBy::Single;
c.color = Some([1.0, 0.5, 0.0]);
assert_eq!(c.shade()(50), [255, 128, 0]);
assert_eq!(c.shade(c.color_range())(50), [255, 128, 0]);
// The stray point doesn't stretch the automatic range.
c.color_by = ColorBy::Height;
// 102 points: the 2nd and the 99th (of 0…101) once sorted.
assert_eq!(c.auto_height_range(), Some([2.0, 99.0]));
{
let shade = c.shade();
let shade = c.shade(c.color_range());
assert_eq!((shade(0), shade(100), shade(101)), (height_ramp(0.0), height_ramp(1.0), height_ramp(1.0)));
assert_eq!(shade(50), height_ramp(48.0 / 97.0));
}
// A range of its own; moved up 100 mm, everything is above it.
c.height_range = Some([0.0, 50.0]);
assert_eq!(c.shade()(25), height_ramp(0.5));
assert_eq!(c.shade(c.color_range())(25), height_ramp(0.5));
c.transform[3][2] = 100.0;
assert_eq!(c.shade()(0), height_ramp(1.0));
assert_eq!(c.shade(c.color_range())(0), height_ramp(1.0));
assert_eq!((height_ramp(0.0), height_ramp(1.0), height_ramp(f64::NAN)), ([40, 70, 200], [230, 70, 50], [40, 70, 200]));
let back: PointCloud = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
assert_eq!(back, c);
+215
View File
@@ -0,0 +1,215 @@
//! What a point cloud holds besides its points' places and colours: named numbers on its points
//! (a PLY file's other vertex properties), to colour the cloud by and to filter it with. Whole
//! numbers may have names (`role: 0 outer wall, 1 inner wall` in a PLY file's comments): they
//! are labels, filtered by a set of values rather than a range.
use std::sync::Arc;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::canvas::{base64_decode, base64_encode};
use crate::point_cloud::MAX_POINTS;
use crate::{DocError, Result};
/// Most named numbers per point.
pub const MAX_COLUMNS: usize = 64;
/// One named number on every point.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Column {
pub name: String,
/// Whole numbers (labels, counts): coloured by a palette rather than a ramp.
#[serde(default)]
pub integer: bool,
pub values: Floats,
}
impl Column {
/// The smallest and largest finite value.
pub fn span(&self) -> Option<[f64; 2]> {
let mut it = self.values.0.iter().filter(|v| v.is_finite()).map(|v| f64::from(*v));
let first = it.next()?;
Some(it.fold([first, first], |[lo, hi], v| [lo.min(v), hi.max(v)]))
}
}
/// The complex a cloud's points form: named numbers on the points, and the names of
/// whole-numbered ones. Points alone are a complex of dimension 0.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Complex {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub columns: Vec<Column>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub names: Vec<Names>,
}
/// Most named values of one label.
pub const MAX_NAMES: usize = 256;
/// The names of a label's values.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Names {
pub property: String,
pub values: Vec<(i32, String)>,
}
impl Names {
/// `property: 0 name, 1 another name, -1 none`, or `None` for any other text.
pub fn parse(text: &str) -> Option<Names> {
let (property, rest) = text.split_once(':')?;
let property = property.trim();
if property.is_empty() || property.contains(char::is_whitespace) {
return None;
}
let values = rest
.split(',')
.map(|entry| {
let (value, name) = entry.trim().split_once(' ')?;
let name = name.trim();
(!name.is_empty()).then(|| Some((value.parse::<i32>().ok()?, name.to_string()))).flatten()
})
.collect::<Option<Vec<_>>>()?;
(!values.is_empty() && values.len() <= MAX_NAMES).then(|| Names { property: property.to_string(), values })
}
pub fn name(&self, value: i32) -> Option<&str> {
self.values.iter().find(|(v, _)| *v == value).map(|(_, n)| n.as_str())
}
}
impl Complex {
pub fn is_empty(&self) -> bool {
self.columns.is_empty() && self.names.is_empty()
}
/// The column named `name`.
pub fn column(&self, name: &str) -> Option<&Column> {
self.columns.iter().find(|c| c.name == name)
}
/// The smallest and largest value named `name`.
pub fn span(&self, name: &str) -> Option<[f64; 2]> {
self.column(name).and_then(Column::span)
}
/// The names of `property`'s values, when it is a label.
pub fn names(&self, property: &str) -> Option<&Names> {
self.names.iter().find(|n| n.property == property).filter(|_| self.integer(property))
}
/// Whether the values named `name` are whole numbers.
pub fn integer(&self, name: &str) -> bool {
self.column(name).is_some_and(|c| c.integer)
}
/// Every column has one number per point, the cloud having `points` points.
pub fn check(&self, points: usize) -> Result<()> {
let bad = |what: String| Err(DocError::Invalid(format!("point cloud properties: {what}")));
if self.columns.len() > MAX_COLUMNS {
return bad("too many properties".into());
}
for c in &self.columns {
if c.values.0.len() != points {
return bad(format!("property `{}` does not have one number per point", c.name));
}
}
if self.names.len() > MAX_COLUMNS || self.names.iter().any(|n| n.values.is_empty() || n.values.len() > MAX_NAMES) {
return bad("too many named values".into());
}
Ok(())
}
}
/// Which points are shown: those whose property lies in a range (both ends included), or, for a
/// label, is one of a set of values.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Filter {
pub property: String,
#[serde(flatten)]
pub keep: Keep,
}
/// The values a filter keeps.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Keep {
Range { range: [f64; 2] },
Values { values: Vec<i32> },
}
impl Filter {
pub fn range(&self) -> Option<[f64; 2]> {
match &self.keep {
Keep::Range { range } => Some(*range),
Keep::Values { .. } => None,
}
}
}
/// Numbers, shared between copies of the design.
#[derive(Clone, Debug, Default)]
pub struct Floats(pub Arc<[f32]>);
impl PartialEq for Floats {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.0, &other.0) || self.0.iter().map(|v| v.to_bits()).eq(other.0.iter().map(|v| v.to_bits()))
}
}
/// The largest base64 text of `n` four-byte words.
fn base64_len(n: usize) -> usize {
(n * 4).div_ceil(3) * 4 + 16
}
fn words<'de, D: Deserializer<'de>>(d: D, max: usize) -> std::result::Result<Vec<[u8; 4]>, D::Error> {
let s = String::deserialize(d)?;
if s.len() > base64_len(max) {
return Err(serde::de::Error::custom("too many numbers"));
}
let b = base64_decode(&s).ok_or_else(|| serde::de::Error::custom("numbers are not base64"))?;
if !b.len().is_multiple_of(4) {
return Err(serde::de::Error::custom("numbers are not whole four-byte words"));
}
Ok(b.as_chunks::<4>().0.to_vec())
}
impl Serialize for Floats {
fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
s.serialize_str(&base64_encode(&self.0.iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<u8>>()))
}
}
impl<'de> Deserialize<'de> for Floats {
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
Ok(Floats(words(d, MAX_POINTS)?.into_iter().map(f32::from_le_bytes).collect()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn properties_round_trip_and_are_checked_against_the_points() {
let complex = Complex {
columns: vec![
Column { name: "t".into(), integer: false, values: Floats(vec![0.0, 0.5, 1.0].into()) },
Column { name: "role".into(), integer: true, values: Floats(vec![0.0, 4.0, 4.0].into()) },
],
names: Vec::new(),
};
let back: Complex = serde_json::from_str(&serde_json::to_string(&complex).unwrap()).unwrap();
assert_eq!(back, complex);
complex.check(3).unwrap();
assert!(complex.check(2).is_err(), "three numbers for two points");
assert_eq!(complex.span("role"), Some([0.0, 4.0]));
assert!(complex.integer("role") && !complex.integer("t") && !complex.integer("nope"));
// Named values make a label, and a filter keeps a range or a set of them.
let names = Names::parse("role: 0 outer wall, 4 bridge, -1 none").unwrap();
assert_eq!((names.name(4), names.name(-1), names.name(2)), (Some("bridge"), Some("none"), None));
for text in ["t: seconds since the start", "role 0 wall", ": 0 a", "role: x wall", "role: 0 "] {
assert!(Names::parse(text).is_none(), "{text}");
}
let mut named = complex.clone();
named.names = vec![names, Names::parse("t: 0 zero").unwrap()];
assert!(named.names("role").is_some() && named.names("t").is_none(), "only whole numbers are labels");
let set: Filter = serde_json::from_str(r#"{"property": "role", "values": [0, 4]}"#).unwrap();
let range: Filter = serde_json::from_str(r#"{"property": "t", "range": [1, 3]}"#).unwrap();
assert_eq!((set.range(), range.range()), (None, Some([1.0, 3.0])));
assert_eq!(serde_json::to_value(&set).unwrap(), serde_json::json!({"property": "role", "values": [0, 4]}));
}
}
+246
View File
@@ -0,0 +1,246 @@
//! How much of a point cloud lies at each value of one of its properties, over what its filters
//! keep: the histograms the Edit Point Cloud card draws in its range tracks, split into the
//! colours the view draws, and the values where a label changes along a property, which the
//! tracks snap to.
//!
//! Each point weighs the same, the cloud weighing 1. A range filter keeps the points between its
//! ends, as the view does; a filter by values keeps those whose rounded value it holds; the
//! colour sorts the points kept into the colours they are drawn in. The values the points kept
//! take, from the least to the greatest, are the ramp's automatic range: the colours span exactly
//! what is shown.
use crate::point_cloud::{ColorBy, Filter, Keep, PALETTE, PointCloud, Source};
/// Most bins of one histogram.
pub const MAX_BINS: usize = 8192;
/// What the histograms need of a cloud's geometry, worked out once for its points.
#[derive(Clone, Debug, Default)]
pub struct Geometry {
/// Each point in the design.
world: Vec<[f64; 3]>,
}
impl Geometry {
pub fn of(c: &PointCloud) -> Geometry {
Geometry { world: (0..c.len()).map(|i| c.world_point(i).map_or([0.0; 3], |p| [p.x, p.y, p.z])).collect() }
}
}
/// How a histogram is split by colour, into slots; the last slot holds what has no value to be
/// coloured by.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Colour<'a> {
/// One colour.
One,
/// The ramp over `range` of `property`, in `bands` equal bands; below and above the range
/// count with the first and the last.
Ramp { property: &'a str, range: [f64; 2], bands: usize },
/// The palette of a whole-numbered `property`: a slot per colour of [`PALETTE`], then one
/// for negative values.
Palette { property: &'a str },
}
impl Colour<'_> {
/// How many slots the histogram has.
pub fn slots(&self) -> usize {
match self {
Colour::One => 1,
Colour::Ramp { bands, .. } => bands + 1,
Colour::Palette { .. } => PALETTE.len() + 2,
}
}
fn property(&self) -> Option<&str> {
match self {
Colour::One => None,
Colour::Ramp { property, .. } | Colour::Palette { property } => Some(property),
}
}
/// How a property's values are cut into bands, and the slot of each band.
fn cut(&self) -> Option<Cut> {
match *self {
Colour::One => None,
Colour::Ramp { range: [lo, hi], bands, .. } => Some(Cut::Grid { start: lo, step: (hi - lo) / bands as f64, count: bands + 1 }),
Colour::Palette { .. } => Some(INTEGERS),
}
}
fn slot(&self, band: usize) -> usize {
match *self {
Colour::One => 0,
Colour::Ramp { bands, .. } => band.saturating_sub(1).min(bands.saturating_sub(1)),
Colour::Palette { .. } => match integer(band) {
Some(v) if v >= 0 => v as usize % PALETTE.len(),
_ => PALETTE.len(),
},
}
}
}
/// How much lies in each bin, by colour slot.
#[derive(Clone, Debug, PartialEq)]
pub struct Histogram {
pub bins: usize,
/// `slots[s][b]`: the measure of slot `s` in bin `b`.
pub slots: Vec<Vec<f64>>,
/// The least and the greatest value the points kept take, within the axis or not.
pub extent: Option<[f64; 2]>,
}
/// Where values are cut into bands, by increasing values.
#[derive(Clone, Copy, Debug)]
enum Cut {
/// Below (band 0), within (1, both ends included) and above (2) a range.
Range([f64; 2]),
/// `count` values `start + k·step`: band 0 lies below the first, band `k` from the `k`th on.
Grid { start: f64, step: f64, count: usize },
}
/// The bands of whole numbers: band `k` holds the values rounding to `k - 2³¹`.
const INTEGERS: Cut = Cut::Grid { start: 0.5 - 2_147_483_648.0, step: 1.0, count: u32::MAX as usize };
/// The whole number a band of [`INTEGERS`] holds.
fn integer(band: usize) -> Option<i64> {
i64::try_from(band).ok().map(|k| k - 2_147_483_648)
}
impl Cut {
fn band(&self, v: f64) -> usize {
match *self {
Cut::Range([a, b]) => usize::from(v >= a) + usize::from(v > b),
Cut::Grid { start, step, count } => {
if !(v >= start) {
return 0;
}
(((v - start) / step).floor() as usize).saturating_add(1).min(count)
}
}
}
}
/// A filter as a cut and the bands it keeps.
struct Kept<'a> {
cut: Cut,
values: Option<&'a [i32]>,
}
impl Kept<'_> {
fn of(f: &Filter) -> Kept<'_> {
match &f.keep {
Keep::Range { range } => Kept { cut: Cut::Range(*range), values: None },
Keep::Values { values } => Kept { cut: INTEGERS, values: Some(values) },
}
}
fn keeps(&self, v: f64) -> bool {
let band = self.cut.band(v);
match self.values {
None => band == 1,
Some(values) => integer(band).is_some_and(|v| values.iter().any(|k| i64::from(*k) == v)),
}
}
}
/// The value of `s` at point `i`.
fn value(s: Source, i: usize, world: &[[f64; 3]]) -> f64 {
match s {
Source::Coordinate(k) => world.get(i).and_then(|p| p.get(k)).copied().unwrap_or(f64::NAN),
Source::Column(v) => v.get(i).map_or(f64::NAN, |x| f64::from(*x)),
}
}
/// How much of the cloud lies in each of `bins` bins over `axis` of `property`, by `colour`,
/// within every one of `filters`; `None` for an axis or a number of bins it can't have.
pub fn histogram(
c: &PointCloud,
g: &Geometry,
property: &str,
axis: [f64; 2],
bins: usize,
colour: &Colour,
filters: &[Filter],
) -> Option<Histogram> {
if !(axis[0].is_finite() && axis[1].is_finite() && axis[1] > axis[0]) || bins == 0 || bins > MAX_BINS {
return None;
}
if let Colour::Ramp { range: [lo, hi], bands, .. } = *colour
&& !(hi > lo && bands > 0 && bands <= MAX_BINS)
{
return None;
}
let slots = colour.slots();
let mut out = vec![vec![0.0; bins]; slots];
let mut extent = None;
let Some(p) = c.source(property) else { return Some(Histogram { bins, slots: out, extent }) };
// A point is not filtered by a property the cloud doesn't have, as in the view.
let kept: Vec<(Kept, Source)> = filters.iter().filter_map(|f| Some((Kept::of(f), c.source(&f.property)?))).collect();
let coloured = colour.property().and_then(|n| c.source(n));
let (colour_cut, plain) = (colour.cut(), slots - 1);
let m = 1.0 / g.world.len().max(1) as f64;
let width = (axis[1] - axis[0]) / bins as f64;
for i in 0..g.world.len() {
if !kept.iter().all(|(k, s)| k.keeps(value(*s, i, &g.world))) {
continue;
}
let v = value(p, i, &g.world);
if !v.is_finite() {
continue;
}
extent = Some(extent.map_or([v, v], |[lo, hi]: [f64; 2]| [lo.min(v), hi.max(v)]));
if !(v >= axis[0] && v <= axis[1]) {
continue;
}
let slot = match (colour_cut, coloured) {
(Some(cut), Some(s)) => colour.slot(cut.band(value(s, i, &g.world))),
(Some(_), None) => plain,
(None, _) => 0,
};
// The last bin holds the axis' upper end too.
let bin = (((v - axis[0]) / width).floor() as usize).min(bins - 1);
if let Some(b) = out.get_mut(slot).and_then(|s| s.get_mut(bin)) {
*b += m;
}
}
Some(Histogram { bins, slots: out, extent })
}
/// A range for a ramp from the values it spans: a single value gets one unit around it.
pub fn ramp_range([lo, hi]: [f64; 2]) -> [f64; 2] {
if hi > lo { [lo, hi] } else { [lo - 0.5, lo + 0.5] }
}
/// The ramp's range: the one set; else, coloured by a property, exactly the values it takes over
/// what `filters` keep (the cloud's own bound, [`PointCloud::color_range`], when nothing is
/// kept); coloured by height, the cloud's own (its points but the highest and lowest few).
pub fn color_range(c: &PointCloud, g: &Geometry, filters: &[Filter]) -> Option<[f64; 2]> {
color_range_by(c, |property, axis| histogram(c, g, property, axis, 1, &Colour::One, filters).and_then(|h| h.extent))
}
/// [`color_range`], with the values the coloured property takes over what is kept given by
/// `extent` (of the property and an axis over its span): worked out elsewhere, or not yet.
pub fn color_range_by(c: &PointCloud, extent: impl FnOnce(&str, [f64; 2]) -> Option<[f64; 2]>) -> Option<[f64; 2]> {
let property = c.colored_property()?;
if c.height_range.is_some() || c.color_by != ColorBy::Property {
return c.color_range();
}
let axis = ramp_range(c.span(property)?);
extent(property, axis).map(ramp_range).or_else(|| c.color_range())
}
/// The values of `property` where the whole-numbered `label` changes, point after point in order
/// of `property`, lowest first.
pub fn label_changes(c: &PointCloud, g: &Geometry, label: &str, property: &str) -> Vec<f64> {
let (Some(p), Some(l)) = (c.source(property), c.source(label)) else { return Vec::new() };
let mut at: Vec<(f64, i64)> = (0..g.world.len())
.filter_map(|i| {
let (v, l) = (value(p, i, &g.world), value(l, i, &g.world));
v.is_finite().then(|| (v, if l.is_finite() { l.round() as i64 } else { i64::MIN }))
})
.collect();
at.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut out: Vec<f64> = at.windows(2).filter(|w| w[0].1 != w[1].1).map(|w| w[1].0).collect();
out.dedup();
out
}
#[cfg(test)]
#[path = "point_cloud_distribution_tests.rs"]
mod tests;
@@ -0,0 +1,125 @@
use solidcraft_geom::Vec3;
use super::*;
use crate::IDENTITY;
use crate::point_cloud::{ColorBy, Colors, Column, Complex, Floats, Points};
/// A cloud of `points` with `columns` on them.
fn cloud(points: &[[f32; 3]], columns: Vec<Column>) -> PointCloud {
PointCloud {
id: 1,
name: "c".into(),
file: String::new(),
origin: Vec3::new(0.0, 0.0, 0.0),
points: Points::new(points.iter().flatten().copied().collect::<Vec<f32>>().into()),
colors: Colors::default(),
source_points: points.len() as u64,
units: "mm".into(),
transform: IDENTITY,
point_size: 2.0,
color_by: ColorBy::File,
color: None,
height_range: None,
visible: true,
complex: Complex { columns, names: Vec::new() },
color_property: None,
filters: Vec::new(),
}
}
fn column(name: &str, integer: bool, values: &[f32]) -> Column {
Column { name: name.into(), integer, values: Floats(values.to_vec().into()) }
}
fn range(property: &str, range: [f64; 2]) -> Filter {
Filter { property: property.into(), keep: Keep::Range { range } }
}
fn close(a: &[f64], b: &[f64]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9)
}
/// Ten points along x, `t` the same as x.
fn row() -> PointCloud {
let points: Vec<[f32; 3]> = (0..10).map(|i| [i as f32 + 0.5, 0.0, 0.0]).collect();
let t: Vec<f32> = points.iter().map(|p| p[0]).collect();
cloud(&points, vec![column("t", false, &t)])
}
#[test]
fn each_point_weighs_alike_and_a_range_keeps_those_within_it() {
let c = row();
let g = Geometry::of(&c);
let all = histogram(&c, &g, "t", [0.0, 10.0], 5, &Colour::One, &[]).unwrap();
assert!(close(&all.slots[0], &[0.2; 5]));
// Kept from t = 2 to 5: three points, ends included.
let kept = histogram(&c, &g, "t", [0.0, 10.0], 5, &Colour::One, &[range("t", [2.0, 5.0])]).unwrap();
assert!(close(&kept.slots[0], &[0.0, 0.2, 0.1, 0.0, 0.0]));
// Filtered by another property: x from 0 to 4 keeps t from 0 to 4.
let by_x = histogram(&c, &g, "t", [0.0, 10.0], 5, &Colour::One, &[range("x", [0.0, 4.0])]).unwrap();
assert!(close(&by_x.slots[0], &[0.2, 0.2, 0.0, 0.0, 0.0]));
// A filter on a property the cloud doesn't have keeps everything, as in the view.
assert_eq!(histogram(&c, &g, "t", [0.0, 10.0], 5, &Colour::One, &[range("nope", [0.0, 1.0])]), Some(all));
// An axis or a number of bins it can't have.
for (axis, bins) in [([1.0, 1.0], 4), ([0.0, f64::NAN], 4), ([0.0, 1.0], 0), ([0.0, 1.0], MAX_BINS + 1)] {
assert!(histogram(&c, &g, "t", axis, bins, &Colour::One, &[]).is_none());
}
}
#[test]
fn colours_split_what_is_kept() {
// Four points, roles 0, 3, 3 and -1, t from 0 to 3.
let c = cloud(
&[[0.0; 3], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0], [3.0, 0.0, 0.0]],
vec![column("t", false, &[0.0, 1.0, 2.0, 3.0]), column("role", true, &[0.0, 3.0, 3.0, -1.0])],
);
let g = Geometry::of(&c);
let colour = Colour::Palette { property: "role" };
let h = histogram(&c, &g, "t", [0.0, 4.0], 4, &colour, &[]).unwrap();
assert_eq!(h.slots.len(), colour.slots());
assert!(close(&h.slots[0], &[0.25, 0.0, 0.0, 0.0]));
assert!(close(&h.slots[3], &[0.0, 0.25, 0.25, 0.0]));
assert!(close(&h.slots[PALETTE.len()], &[0.0, 0.0, 0.0, 0.25]), "a negative label");
// A filter by values keeps role 3 only.
let three = Filter { property: "role".into(), keep: Keep::Values { values: vec![3] } };
let kept = histogram(&c, &g, "t", [0.0, 4.0], 4, &colour, &[three]).unwrap();
let total: f64 = kept.slots.iter().flatten().sum();
assert!((total - 0.5).abs() < 1e-9 && close(&kept.slots[3], &h.slots[3]));
// A ramp over t in two bands, and coloured by a property the cloud doesn't have: plain.
let ramp = Colour::Ramp { property: "t", range: [0.0, 4.0], bands: 2 };
let r = histogram(&c, &g, "t", [0.0, 4.0], 4, &ramp, &[]).unwrap();
assert!(close(&r.slots[0], &[0.25, 0.25, 0.0, 0.0]) && close(&r.slots[1], &[0.0, 0.0, 0.25, 0.25]));
let none = histogram(&c, &g, "t", [0.0, 4.0], 4, &Colour::Palette { property: "nope" }, &[]).unwrap();
assert!(close(&none.slots[PALETTE.len() + 1], &[0.25; 4]));
}
#[test]
fn labels_change_where_the_next_point_has_another() {
// Six points in order of t, layers 0 0 1 1 2 2.
let points: Vec<[f32; 3]> = (0..6).map(|i| [i as f32, 0.0, 0.0]).collect();
let c = cloud(&points, vec![column("t", false, &[0.0, 1.0, 2.0, 3.0, 4.0, 5.0]), column("layer", true, &[0.0, 0.0, 1.0, 1.0, 2.0, 2.0])]);
let g = Geometry::of(&c);
assert_eq!(label_changes(&c, &g, "layer", "t"), vec![2.0, 4.0]);
assert!(label_changes(&c, &g, "nope", "t").is_empty());
}
#[test]
fn the_automatic_range_is_what_every_filter_keeps() {
// Filtered by x from 2 to 4, coloured by t: the range is what is kept of t, though no filter
// is on t (its span within filters on t would be all of it).
let mut c = cloud(&[[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [4.0, 0.0, 0.0], [20.0, 0.0, 0.0]], vec![column("t", false, &[0.0, 3.0, 5.0, 7.0])]);
c.color_by = ColorBy::Property;
c.color_property = Some("t".into());
let g = Geometry::of(&c);
let x = [range("x", [2.0, 4.0])];
assert_eq!(c.color_range(), Some([0.0, 7.0]));
assert_eq!(color_range(&c, &g, &x), Some([3.0, 5.0]));
// The point at x = 20 alone: one value, one unit wide.
assert_eq!(color_range(&c, &g, &[range("x", [19.0, 21.0])]), Some([6.5, 7.5]));
// Nothing kept: the cloud's own bound; a range set is kept as it is.
assert_eq!(color_range(&c, &g, &[range("x", [50.0, 60.0])]), Some([0.0, 7.0]));
c.height_range = Some([1.0, 2.0]);
assert_eq!(color_range(&c, &g, &x), Some([1.0, 2.0]));
let h = histogram(&c, &g, "t", [0.0, 1.0], 4, &Colour::One, &x).unwrap();
assert_eq!(h.extent, Some([3.0, 5.0]), "the extent reaches past the axis");
}
+90 -12
View File
@@ -3,7 +3,9 @@
use serde_json::{Value, json};
use solidcraft_doc::Mat;
use solidcraft_doc::point_cloud::{ColorBy, Colors, MAX_COORD, MAX_POINTS, POINT_SIZE, PointCloud, Points, UNITS, unit_scale};
use solidcraft_doc::point_cloud::{
ColorBy, Colors, Filter, Keep, MAX_COORD, MAX_FILTERS, MAX_NAMES, MAX_POINTS, POINT_SIZE, PointCloud, Points, UNITS, unit_scale,
};
use super::CommandSpec;
use crate::params::{bad, bool_, str_};
@@ -13,16 +15,16 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("pointcloud.insert", "Insert Point Cloud", insert)
.at("SOLID", "INSERT")
.icon("import")
.params("path: .ply, .xyz or .pts file (its points join the design as a point cloud: drawn as points, never a body; a PLY's red, green and blue are its colours) → cloud, name, points, source_points, bounds, warnings; name?; units?: mm|cm|m|in|ft (the file's length unit, default mm); max_points?: most points kept (default 5000000, at most 20000000; a larger file is thinned evenly); center?: bool (move the cloud so the middle of its base sits at the origin); point_size?: pixels (0.5…16, default 2); color_by?: file|single|height (the file's colours, the default; one colour; or a ramp by height, blue low to red high); color?: [r, g, b] 0…1 (the single colour; given alone it sets color_by single, null sets color_by file); height_range?: [low, high] design Z in mm for the height ramp, or null to work it out from the points (all but the lowest and highest 2 %)"),
.params("path: .ply, .xyz or .pts file (its points join the design as a point cloud: drawn as points, never a body; a PLY's red, green and blue are its colours, its other vertex properties can colour and filter it) → cloud, name, points, source_points, bounds, warnings; name?; units?: mm|cm|m|in|ft (the file's length unit, default mm); max_points?: most points kept (default 5000000, at most 20000000; a larger file is thinned evenly); center?: bool (move the cloud so the middle of its base sits at the origin); point_size?: pixels (0.5…16, default 2); color_by?: file|single|height or a property's name (the file's colours, the default; one colour; a ramp by height, blue low to red high; or by any property of the file, a ramp over its values or a palette for whole numbers); color?: [r, g, b] 0…1 (the single colour; given alone it sets color_by single, null sets color_by file); color_range?: [low, high] the ramp runs between (design Z in mm by height), or null for the automatic range (by height all but the lowest and highest 2 % of the points; by a property the values it takes over what the filters keep); height_range?: the same as color_range; filters?: [{property, range: [low, high]} or {property, values: [whole numbers]} for a property with named values], or null (only what every filter keeps is shown)"),
CommandSpec::new("pointcloud.edit", "Edit Point Cloud", edit)
.icon("edit")
.params("cloud: id or name; name?; visible?; point_size?: pixels (0.5…16); color_by?: file|single|height (the file's colours, the default; one colour; or a ramp by height, blue low to red high); color?: [r, g, b] 0…1 (the single colour; given alone it sets color_by single, null sets color_by file); height_range?: [low, high] design Z in mm for the height ramp, or null to work it out from the points (all but the lowest and highest 2 %); units?: mm|cm|m|in|ft (the file's length unit: the points are scaled from the unit they came in as); matrix?: [12 or 16 numbers] (where the cloud sits: a rigid row-major 3x4 or 4x4 matrix, as occurrence.move takes it); tolerance? → cloud"),
.params("cloud: id or name; name?; visible?; point_size?: pixels (0.5…16); color_by?: file|single|height or a property's name (the file's colours, the default; one colour; a ramp by height, blue low to red high; or by any property of the file, a ramp over its values or a palette for whole numbers); color?: [r, g, b] 0…1 (the single colour; given alone it sets color_by single, null sets color_by file); color_range?: [low, high] the ramp runs between (design Z in mm by height), or null for the automatic range (by height all but the lowest and highest 2 % of the points; by a property the values it takes over what the filters keep); height_range?: the same as color_range; units?: mm|cm|m|in|ft (the file's length unit: the points are scaled from the unit they came in as); matrix?: [12 or 16 numbers] (where the cloud sits: a rigid row-major 3x4 or 4x4 matrix, as occurrence.move takes it); tolerance?; filters?: [{property, range: [low, high]} or {property, values: [whole numbers]} for a property with named values], or null (only what every filter keeps is shown) → cloud"),
CommandSpec::new("pointcloud.delete", "Delete Point Cloud", delete).icon("delete").params("cloud: id or name → deleted"),
CommandSpec::new("pointcloud.list", "Point Clouds", list)
.icon("list")
.noundo()
.doc_only()
.params("→ clouds: [{cloud, name, file, points, source_points, units, visible, point_size, color_by, color, colored, height_range (the ramp's now, its own or worked out), height_auto, bounds: [[x,y,z], [x,y,z]] in the design, matrix}]"),
.params("→ clouds: [{cloud, name, file, points, source_points, units, visible, point_size, color_by (file, single, height or a property's name), color, colored, properties: [{name, integer, span, names}], filters, color_range (the ramp's now, exact over what the filters keep), height_range (the ramp's own range or the automatic one by height), height_auto, bounds: [[x,y,z], [x,y,z]] in the design, matrix}]"),
];
fn units_of(p: &Value, cmd: &str) -> Result<Option<(&'static str, f64)>> {
@@ -64,18 +66,74 @@ fn color(p: &Value, cmd: &str) -> Result<Option<Option<[f32; 3]>>> {
}
}
/// `color_by`: absent (`None`), or file, single or height.
fn color_by(p: &Value, cmd: &str) -> Result<Option<ColorBy>> {
/// `color_by`: absent (`None`), file, single or height, or the name of a property.
fn color_by(p: &Value, cmd: &str) -> Result<Option<(ColorBy, Option<String>)>> {
match p.get("color_by") {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_str().and_then(ColorBy::parse).map(Some).ok_or_else(|| bad(cmd, "`color_by` must be file, single or height")),
Some(Value::String(v)) => Ok(Some(match ColorBy::parse(v) {
Some(b) if b != ColorBy::Property => (b, None),
_ => (ColorBy::Property, Some(v.clone())),
})),
Some(_) => Err(bad(cmd, "`color_by` must be file, single, height or a property's name")),
}
}
/// `filters`: absent (`None`), null for none, or [{property, range: [low, high]} or
/// {property, values: [whole numbers]}].
fn filters(p: &Value, cmd: &str) -> Result<Option<Vec<Filter>>> {
let refuse = || {
bad(
cmd,
format!(
"`filters` must be at most {MAX_FILTERS} {{property, range: [low, high]}} (lowest first) or {{property, values: [whole numbers]}}, or null"
),
)
};
match p.get("filters") {
None => Ok(None),
Some(Value::Null) => Ok(Some(Vec::new())),
Some(Value::Array(a)) if a.len() <= MAX_FILTERS => a
.iter()
.map(|f| {
let property = f.get("property").and_then(Value::as_str).ok_or_else(refuse)?.to_string();
if let Some(values) = f.get("values") {
let values = values.as_array().ok_or_else(refuse)?;
let whole = |v: &Value| v.as_i64().and_then(|n| i32::try_from(n).ok());
let values = values.iter().map(whole).collect::<Option<Vec<i32>>>().filter(|v| v.len() <= MAX_NAMES).ok_or_else(refuse)?;
return Ok(Filter { property, keep: Keep::Values { values } });
}
match f.get("range").and_then(Value::as_array).map(|r| r.iter().map(Value::as_f64).collect::<Option<Vec<f64>>>()) {
Some(Some(r)) if matches!(r.as_slice(), &[lo, hi] if lo.is_finite() && hi.is_finite() && lo <= hi) => {
Ok(Filter { property, keep: Keep::Range { range: [r[0], r[1]] } })
}
_ => Err(refuse()),
}
})
.collect::<Result<Vec<Filter>>>()
.map(Some),
Some(_) => Err(refuse()),
}
}
/// A cloud colours and filters only by properties it has.
fn check_properties(c: &PointCloud, cmd: &str) -> Result<()> {
let names: Vec<&str> = c.property_names().collect();
for n in c.color_property.iter().filter(|_| c.color_by == ColorBy::Property).chain(c.filters.iter().map(|f| &f.property)) {
if !names.contains(&n.as_str()) {
return Err(bad(cmd, format!("the point cloud has no property `{n}` (it has {})", names.join(", "))));
}
}
// A set of values filters a label: a property with named whole numbers.
if let Some(f) = c.filters.iter().find(|f| matches!(f.keep, Keep::Values { .. }) && c.complex.names(&f.property).is_none()) {
return Err(bad(cmd, format!("`{}` has no named values: filter it with a range", f.property)));
}
Ok(())
}
/// `height_range`: absent (`None`), null for the automatic one (`Some(None)`), or [low, high].
fn height_range(p: &Value, cmd: &str) -> Result<Option<Option<[f64; 2]>>> {
let refuse = || bad(cmd, "`height_range` must be [low, high] heights in mm, lowest first, or null");
match p.get("height_range") {
let refuse = || bad(cmd, "`height_range` (or `color_range`) must be [low, high], lowest first, or null");
match p.get("color_range").or_else(|| p.get("height_range")) {
None => Ok(None),
Some(Value::Null) => Ok(Some(None)),
Some(Value::Array(a)) => match a.iter().map(Value::as_f64).collect::<Option<Vec<f64>>>().as_deref() {
@@ -90,13 +148,18 @@ fn height_range(p: &Value, cmd: &str) -> Result<Option<Option<[f64; 2]>>> {
/// Apply `color_by`, `color` and `height_range`: a colour given alone means one colour, and
/// `color: null` the file's.
fn set_colors(c: &mut PointCloud, by: Option<ColorBy>, color: Option<Option<[f32; 3]>>, range: Option<Option<[f64; 2]>>) {
fn set_colors(c: &mut PointCloud, by: Option<(ColorBy, Option<String>)>, color: Option<Option<[f32; 3]>>, range: Option<Option<[f64; 2]>>) {
if let Some(k) = color {
c.color = k.or(c.color);
c.color_by = if k.is_some() { ColorBy::Single } else { ColorBy::File };
}
if let Some(b) = by {
if let Some((b, property)) = by {
if property != c.color_property && range.is_none() {
// Another property's values have another range.
c.height_range = None;
}
c.color_by = b;
c.color_property = property.or(c.color_property.take().filter(|_| b == ColorBy::Property));
}
if let Some(r) = range {
c.height_range = r;
@@ -172,8 +235,12 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
color: None,
height_range: None,
visible: true,
complex: read.complex,
color_property: None,
filters: filters(p, cmd)?.unwrap_or_default(),
};
set_colors(&mut cloud, colors.0, colors.1, colors.2);
check_properties(&cloud, cmd)?;
if center && let Some((lo, hi)) = cloud.local_bounds() {
cloud.transform[3] = [-(lo.x + hi.x) / 2.0, -(lo.y + hi.y) / 2.0, -lo.z, 1.0];
}
@@ -207,6 +274,7 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
let i = find(s, p, cmd)?;
let size = point_size(p, cmd)?;
let colors = (color_by(p, cmd)?, color(p, cmd)?, height_range(p, cmd)?);
let filters = filters(p, cmd)?;
let units = units_of(p, cmd)?;
let placement = match p.get("matrix") {
None | Some(Value::Null) => None,
@@ -236,6 +304,10 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
c.point_size = v;
}
set_colors(&mut c, colors.0, colors.1, colors.2);
if let Some(f) = filters {
c.filters = f;
}
check_properties(&c, cmd)?;
if let Some(v) = bool_(p, "visible") {
c.visible = v;
}
@@ -271,7 +343,13 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
"units": c.units,
"visible": c.visible,
"point_size": c.point_size,
"color_by": c.color_by.name(),
"color_by": c.colored_property().filter(|_| c.color_by == ColorBy::Property).unwrap_or(c.color_by.name()),
"properties": c
.property_names()
.map(|n| json!({"name": n, "integer": c.complex.integer(n), "span": c.span(n), "names": c.complex.names(n).map(|m| &m.values)}))
.collect::<Vec<_>>(),
"filters": c.filters,
"color_range": solidcraft_doc::point_cloud_distribution::color_range(c, &solidcraft_doc::point_cloud_distribution::Geometry::of(c), &c.filters),
"color": c.color,
"colored": !c.colors.is_empty(),
"height_range": c.height_range_now(),
+1 -1
View File
@@ -397,7 +397,7 @@ fn frame(scene: &Scene, cam: &Camera, [width, height]: [u32; 2], key: u64, acces
clip: scene.clip.map_or([0.0; 4], |(n, d)| [n.x as f32, n.y as f32, n.z as f32, d as f32]),
cap: [f32::from(cap.0) / 255.0, f32::from(cap.1) / 255.0, f32::from(cap.2) / 255.0, if scene.plain_cap { 0.0 } else { 1.0 }],
images: Vec::new(),
points: Vec::new(),
clouds: Vec::new(),
analysis: analysis_uniform(mode, access.as_ref()),
analysis_colors: scene.analysis.map_or([0.0; 36], |a| analysis_colors(&a.colors)),
access,
+41 -81
View File
@@ -17,6 +17,8 @@ use crate::pbr;
use wgpu::util::DeviceExt;
mod ao;
mod clouds;
pub use clouds::{CloudFilter, CloudStyle, ColorMode, DOT_SIZE, Dot, FILTERS, GpuCloud, cloud_dot};
#[cfg(not(target_arch = "wasm32"))]
mod msaa_check;
mod oit;
@@ -921,34 +923,6 @@ pub struct GpuImage {
/// Image vertex: position (3 × f32), uv (2 × f32), opacity (f32).
const IMG_SIZE: usize = 24;
/// A point cloud: round dots of a size on screen, depth-tested and written like the model's
/// faces.
#[derive(Clone)]
pub struct GpuPoints {
pub id: u64,
/// Changes when the points change (they are uploaded again).
pub version: u64,
/// [`CLOUD_POINT_SIZE`] bytes per point: position (3 × f32, in the design), colour (RGBA8,
/// the alpha the dot's size in eighths of a pixel, see [`cloud_point`]).
pub bytes: Arc<Vec<u8>>,
}
/// Bytes per point of a [`GpuPoints`].
pub const CLOUD_POINT_SIZE: usize = 16;
/// A point of a [`GpuPoints`]: where it is, its colour and its size on screen (pixels, up to 31).
pub fn cloud_point(out: &mut Vec<u8>, p: [f32; 3], rgb: [u8; 3], size: f32) {
for v in p {
out.extend_from_slice(&v.to_le_bytes());
}
out.extend_from_slice(&rgb);
out.push((size * 8.0).round().clamp(8.0, 255.0) as u8);
}
fn point_layout() -> wgpu::VertexBufferLayout<'static> {
const A: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Unorm8x4];
wgpu::VertexBufferLayout { array_stride: CLOUD_POINT_SIZE as u64, step_mode: wgpu::VertexStepMode::Instance, attributes: &A }
}
/// Ambient occlusion samples per shaded pixel in a still picture (the shader's most).
pub const AO_SAMPLES: u32 = 32;
@@ -990,7 +964,7 @@ pub struct Frame {
/// Canvases and decals.
pub images: Vec<GpuImage>,
/// Point clouds.
pub points: Vec<GpuPoints>,
pub clouds: Vec<GpuCloud>,
/// Surface analysis: (mode: 0 none, 1 zebra, 2 draft, 3 curvature, 4 environment,
/// 5 accessibility; parameter; scale; size) and the pull / access direction.
pub analysis: [f32; 8],
@@ -2060,10 +2034,8 @@ pub struct Renderer {
textures: std::collections::HashMap<u64, (u64, wgpu::BindGroup)>,
/// This frame's quads: (image id, vertices).
quads: Vec<(u64, Batch)>,
/// Point clouds' dots.
point: wgpu::RenderPipeline,
/// Point clouds by id: (version, points).
clouds: std::collections::HashMap<u64, (u64, Batch)>,
cloud_pipes: clouds::CloudPipes,
clouds: clouds::CloudDraws,
access_bgl: wgpu::BindGroupLayout,
/// The accessibility depth map (a 1 × 1 stand-in when there is none) and its version.
access_bind: wgpu::BindGroup,
@@ -2875,7 +2847,7 @@ impl Renderer {
pipeline("sc_guide_lines", "vs_line", "fs_line_all", line_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
)
};
let point = || pipeline("sc_points", "vs_point", "fs_point", point_layout(), depth(wgpu::CompareFunction::Less, true), None);
let cloud_pipes = || clouds::CloudPipes::new(device, &module, &bgl, t, depth(wgpu::CompareFunction::Less, true));
let guide_trans = || pipeline("sc_guide_trans", "vs_tri", "fs_tri_all", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha);
// Each job on a thread of its own where the device allows it: D3D12 compiles every shader
// as its pipeline is made, which took seconds one after another (#4860).
@@ -2892,7 +2864,7 @@ impl Renderer {
let lines = jobs.spawn(lines);
let lines_all = jobs.spawn(lines_all);
let guide_trans = jobs.spawn(guide_trans);
let point = jobs.spawn(point);
let cloud_pipes = jobs.spawn(cloud_pipes);
let image = jobs.spawn(image);
let ao = jobs.spawn(ao);
let oit = jobs.spawn(oit);
@@ -2941,7 +2913,7 @@ impl Renderer {
sampler,
textures: Default::default(),
quads: Vec::new(),
point: point.join(),
cloud_pipes: cloud_pipes.join(),
clouds: Default::default(),
tri,
tri_cut: cut(tri_marks, tri_cap),
@@ -3277,10 +3249,7 @@ fn frame_key(cb: &Frame) -> u64 {
img.corners.iter().flatten().for_each(|v| v.to_bits().hash(&mut h));
img.opacity.to_bits().hash(&mut h);
}
for cloud in &cb.points {
cloud.id.hash(&mut h);
cloud.version.hash(&mut h);
}
clouds::hash_clouds(&cb.clouds, &mut h);
match &cb.access {
Some(a) => {
true.hash(&mut h);
@@ -3430,20 +3399,7 @@ impl Renderer {
res.quads.push((img.id, b));
}
}
// Point clouds: upload new or changed ones, drop removed ones.
res.clouds.retain(|id, _| cb.points.iter().any(|c| c.id == *id));
for c in &cb.points {
if res.clouds.get(&c.id).is_none_or(|(v, _)| *v != c.version) {
match upload(device, "sc_points", &c.bytes, CLOUD_POINT_SIZE) {
Some(b) => {
res.clouds.insert(c.id, (c.version, b));
}
None => {
res.clouds.remove(&c.id);
}
}
}
}
res.clouds.prepare(device, queue, &res.cloud_pipes, &cb.clouds);
res.ss_ready = (cb.ssaa != 1.0 || see_through || res.window_differs()) && res.supersample(device, encoder, cb, see_through);
res.ss_key = res.ss_ready.then_some(key);
}
@@ -3673,14 +3629,7 @@ impl Renderer {
pass.set_bind_group(0, scene_bind(m, &res.bind), &[]);
tris(pass, &res.pbr, &m.pbr);
if !cb.product {
for (_, b) in res.clouds.values() {
pass.set_pipeline(&res.point);
for (buffer, count) in &b.parts {
pass.set_vertex_buffer(0, buffer.slice(..));
pass.draw(0..6, 0..*count);
tally.triangles(6, *count);
}
}
res.clouds.paint(pass, &res.cloud_pipes, tally);
}
// Whole-body tints first, so a face or an edge highlighted over the body (in `h.tris`
// and `h.lines`) keeps its own colour, as it did when the body's triangles were built
@@ -4377,7 +4326,7 @@ mod tests {
clip: [0.0; 4],
cap: [0.0; 4],
images: Vec::new(),
points: Vec::new(),
clouds: Vec::new(),
analysis: [0.0; 8],
analysis_colors: [0.0; 36],
access: None,
@@ -4764,7 +4713,7 @@ mod glass_tests {
clip: [0.0; 4],
cap: [0.0; 4],
images: Vec::new(),
points: Vec::new(),
clouds: Vec::new(),
analysis: [0.0; 8],
analysis_colors: [0.0; 36],
access: None,
@@ -4939,7 +4888,7 @@ mod glass_tests {
clip: [0.0; 4],
cap: [0.0; 4],
images: Vec::new(),
points: Vec::new(),
clouds: Vec::new(),
analysis: [0.0; 8],
analysis_colors: [0.0; 36],
access: None,
@@ -4964,28 +4913,39 @@ mod glass_tests {
}
#[test]
fn a_cached_point_cloud_picture_updates_after_recoloring_and_hiding() {
fn a_cached_cloud_picture_follows_its_style_without_uploading_again() {
let Some((_, device, queue)) = crate::open_headless().ok() else { return };
let depth = crate::depth_format(&device, true);
let format = wgpu::TextureFormat::Rgba8Unorm;
let mut renderer = Renderer::new(&device, GpuTarget { format, depth: Some(depth), samples: 1, tess: false, oit: false }, None);
let mut frame = test_frame(GpuScene::default(), 2.0);
let mut bytes = Vec::new();
cloud_point(&mut bytes, [0.0; 3], [255, 0, 0], 20.0);
frame.points.push(GpuPoints { id: 1, version: 1, bytes: Arc::new(bytes) });
let red = render_frame(&mut renderer, &device, &queue, &frame, true);
assert!(red.as_chunks::<4>().0.iter().any(|p| p[0] > 200 && p[1] < 30 && p[2] < 30), "the cloud is drawn");
render_frame(&mut renderer, &device, &queue, &frame, true);
assert!(renderer.cached.is_some());
let mut bytes = Vec::new();
cloud_point(&mut bytes, [0.0; 3], [0, 0, 255], 20.0);
frame.points = vec![GpuPoints { id: 1, version: 2, bytes: Arc::new(bytes) }];
let blue = render_frame(&mut renderer, &device, &queue, &frame, true);
assert!(blue.as_chunks::<4>().0.iter().any(|p| p[0] < 30 && p[1] < 30 && p[2] > 200), "the edited cloud replaces the cached picture");
assert_ne!(red, blue);
frame.points.clear();
// A dot labelled 2 (red in the palette) and a dot filtered by 5 (a range) and the label
// 2 (a set).
let mut dots = Vec::new();
cloud_dot(&mut dots, &Dot { at: [5.0, 5.0, 0.0], rgb: [0, 0, 255], value: 2.0, has: 16, ..Default::default() });
let filtered = Dot { at: [-3.0, -3.0, 0.0], value: 1.0, filtered: [5.0, 2.0, 0.0, 0.0], rgb: [0, 255, 0], has: 0b10011 };
cloud_dot(&mut dots, &filtered);
let style = CloudStyle { mode: ColorMode::Own, range: [0.0, 1.0], filters: Default::default(), point_size: 20.0 };
frame.clouds.push(GpuCloud { id: 1, version: 1, dots: Arc::new(dots), style });
let has = |img: &[u8], f: &dyn Fn(&[u8; 4]) -> bool| img.as_chunks::<4>().0.iter().any(f);
let green = |p: &[u8; 4]| p[1] > 120 && p[0] < 30 && p[2] < 30;
let own = render_frame(&mut renderer, &device, &queue, &frame, true);
assert!(has(&own, &|p| p[2] > 200 && p[0] < 30), "a dot in its own colour");
assert!(has(&own, &green), "the other in its own colour");
// Values from -1: bit 3 is the label 2, bit 2 the label 1.
frame.clouds[0].style.filters[1] = CloudFilter::Values { offset: -1, mask: 0b1000 };
assert!(has(&render_frame(&mut renderer, &device, &queue, &frame, true), &green), "label 2 kept");
frame.clouds[0].style.filters[1] = CloudFilter::Values { offset: -1, mask: 0b0100 };
assert!(!has(&render_frame(&mut renderer, &device, &queue, &frame, true), &green), "label 2 left out");
frame.clouds[0].style.filters[1] = CloudFilter::Off;
frame.clouds[0].style.filters[0] = CloudFilter::Range([0.0, 4.0]);
frame.clouds[0].style.mode = ColorMode::Palette;
let filtered = render_frame(&mut renderer, &device, &queue, &frame, true);
assert!(!has(&filtered, &green), "its 5 lies outside the range");
assert!(has(&filtered, &|p| p[0] > 200 && p[1] < 90 && p[2] < 90), "the first, without that value, in the palette's red");
frame.clouds.clear();
let hidden = render_frame(&mut renderer, &device, &queue, &frame, true);
assert!(!hidden.as_chunks::<4>().0.iter().any(|p| p[0] < 30 && p[1] < 30 && p[2] > 200), "hiding the cloud removes its dots");
assert!(!has(&hidden, &|p| p[0] > 200 && p[1] < 90 && p[2] < 90), "hiding the cloud removes it");
}
/// A still view shows the picture it drew last: a reused frame (whose filtered picture is
+227
View File
@@ -0,0 +1,227 @@
//! Point clouds: each point a round dot a few pixels across, facing the screen, depth-tested and
//! written like the model's faces. Every point is uploaded once, with the values it is coloured
//! and filtered by; how they are coloured and which are shown is a [`CloudStyle`] of a few
//! numbers the shaders read, so a new colour range or filter range draws again at once without
//! uploading anything.
use std::sync::Arc;
use super::{Batch, Tally, upload};
/// A point cloud as the GPU draws it.
#[derive(Clone)]
pub struct GpuCloud {
pub id: u64,
/// Changes when the records change (they are uploaded again).
pub version: u64,
/// [`DOT_SIZE`] bytes per point, written by [`cloud_dot`].
pub dots: Arc<Vec<u8>>,
pub style: CloudStyle,
}
/// How a cloud is coloured and which of its points are shown.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct CloudStyle {
pub mode: ColorMode,
/// The ramp's range of values.
pub range: [f32; 2],
/// A point is shown where every filter keeps its filtered value.
pub filters: [CloudFilter; FILTERS],
/// A dot's size on screen, in pixels.
pub point_size: f32,
}
/// Filters the GPU applies at once.
pub const FILTERS: usize = 4;
/// The values a filter keeps.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum CloudFilter {
/// All of them.
#[default]
Off,
/// Those within a range, ends included.
Range([f32; 2]),
/// Whole values `offset + i` for each bit `i` set in `mask`.
Values { offset: i32, mask: u32 },
}
/// Where a point's colour comes from.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ColorMode {
/// Its own colour ([`Dot::rgb`]).
#[default]
Own,
/// The ramp over [`CloudStyle::range`] at its value.
Ramp,
/// The palette at its whole value.
Palette,
}
/// One point: where it is, the coloured value and the four filtered values, its own colour, and
/// which of the four filters and the value it has (bits 0 to 3, and 4, of `has`).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Dot {
pub at: [f32; 3],
pub value: f32,
pub filtered: [f32; FILTERS],
pub rgb: [u8; 3],
pub has: u8,
}
/// Bytes per [`Dot`].
pub const DOT_SIZE: usize = 36;
/// Append a dot's record.
pub fn cloud_dot(out: &mut Vec<u8>, d: &Dot) {
for v in d.at.iter().chain([&d.value]).chain(&d.filtered) {
out.extend_from_slice(&v.to_le_bytes());
}
out.extend_from_slice(&d.rgb);
out.push(d.has);
}
fn dot_layout() -> wgpu::VertexBufferLayout<'static> {
const A: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32, 2 => Float32x4, 3 => Unorm8x4];
wgpu::VertexBufferLayout { array_stride: DOT_SIZE as u64, step_mode: wgpu::VertexStepMode::Instance, attributes: &A }
}
/// The uniform a cloud's style is written to, bytes: the colouring, then a `vec4` per filter.
const STYLE_SIZE: u64 = 16 + 16 * FILTERS as u64;
fn style_bytes(s: &CloudStyle) -> [u8; STYLE_SIZE as usize] {
let mode = match s.mode {
ColorMode::Own => 0.0f32,
ColorMode::Ramp => 1.0,
ColorMode::Palette => 2.0,
};
let mut words = vec![mode.to_bits(), s.point_size.to_bits(), s.range[0].to_bits(), s.range[1].to_bits()];
for f in &s.filters {
// Kind, then a range's ends, or the first value and the mask of those kept.
words.extend(match *f {
CloudFilter::Off => [0.0f32.to_bits(), 0, 0, 0],
CloudFilter::Range([lo, hi]) => [1.0f32.to_bits(), lo.to_bits(), hi.to_bits(), 0],
CloudFilter::Values { offset, mask } => [2.0f32.to_bits(), (offset as f32).to_bits(), 0, mask],
});
}
let mut out = [0u8; STYLE_SIZE as usize];
for (o, w) in out.as_chunks_mut::<4>().0.iter_mut().zip(words) {
*o = w.to_le_bytes();
}
out
}
/// The pipeline that draws clouds' dots, and the layout of their styles.
pub(super) struct CloudPipes {
layout: wgpu::BindGroupLayout,
dots: wgpu::RenderPipeline,
}
impl CloudPipes {
pub(super) fn new(
device: &wgpu::Device,
module: &wgpu::ShaderModule,
viewport: &wgpu::BindGroupLayout,
target: super::GpuTarget,
depth: Option<wgpu::DepthStencilState>,
) -> CloudPipes {
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("sc_cloud"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
count: None,
}],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("sc_cloud"),
bind_group_layouts: &[Some(viewport), Some(&layout)],
immediate_size: 0,
});
let dots = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("vs_cloud_dot"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module,
entry_point: Some("vs_cloud_dot"),
buffers: &[Some(dot_layout())],
compilation_options: Default::default(),
},
primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() },
depth_stencil: depth,
multisample: wgpu::MultisampleState { count: target.samples.max(1), mask: !0, alpha_to_coverage_enabled: false },
fragment: Some(wgpu::FragmentState {
module,
entry_point: Some("fs_cloud"),
targets: &[Some(wgpu::ColorTargetState { format: target.format, blend: None, write_mask: wgpu::ColorWrites::ALL })],
compilation_options: Default::default(),
}),
multiview_mask: None,
cache: None,
});
CloudPipes { layout, dots }
}
}
/// A cloud on the GPU: its dots and its style's uniform.
struct Uploaded {
version: u64,
dots: Option<Batch>,
style: wgpu::Buffer,
bind: wgpu::BindGroup,
}
/// The clouds on the GPU, by id.
#[derive(Default)]
pub(super) struct CloudDraws(std::collections::HashMap<u64, Uploaded>);
impl CloudDraws {
/// Upload new or changed clouds, drop removed ones, and write every style.
pub(super) fn prepare(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pipes: &CloudPipes, clouds: &[GpuCloud]) {
self.0.retain(|id, _| clouds.iter().any(|c| c.id == *id));
for c in clouds {
if self.0.get(&c.id).is_none_or(|u| u.version != c.version) {
let style = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("sc_cloud_style"),
size: STYLE_SIZE,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("sc_cloud_style"),
layout: &pipes.layout,
entries: &[wgpu::BindGroupEntry { binding: 3, resource: style.as_entire_binding() }],
});
let dots = upload(device, "sc_cloud", &c.dots, DOT_SIZE);
self.0.insert(c.id, Uploaded { version: c.version, dots, style, bind });
}
if let Some(u) = self.0.get(&c.id) {
queue.write_buffer(&u.style, 0, &style_bytes(&c.style));
}
}
}
pub(super) fn paint(&self, pass: &mut wgpu::RenderPass<'_>, pipes: &CloudPipes, tally: &Tally) {
for u in self.0.values() {
let Some(batch) = &u.dots else { continue };
pass.set_bind_group(1, &u.bind, &[]);
pass.set_pipeline(&pipes.dots);
for (buffer, count) in &batch.parts {
pass.set_vertex_buffer(0, buffer.slice(..));
pass.draw(0..6, 0..*count);
tally.triangles(6, *count);
}
}
}
}
/// What a frame's clouds add to its picture's key.
pub(super) fn hash_clouds(clouds: &[GpuCloud], h: &mut impl std::hash::Hasher) {
use std::hash::Hash as _;
for c in clouds {
c.id.hash(h);
c.version.hash(h);
style_bytes(&c.style).hash(h);
}
}
+1 -1
View File
@@ -113,7 +113,7 @@ impl Renderer {
clip: [0.0; 4],
cap: [0.0; 4],
images: Vec::new(),
points: Vec::new(),
clouds: Vec::new(),
analysis: [0.0; 8],
analysis_colors: [0.0; 36],
access: None,
+1
View File
@@ -28,6 +28,7 @@ mod native_cad;
pub use native_cad::{NATIVE_CAD_EXTS, is_native_cad_path, native_cad_to_step};
pub mod paper;
mod ply;
mod ply_complex;
pub use ply::{ply_counts, read_ply_mesh};
mod point_cloud;
pub use point_cloud::{CloudFile, DEFAULT_MAX_POINTS, MAX_POINT_CLOUD_BYTES, is_ply_path, is_point_cloud_path, read_point_cloud};
+41 -41
View File
@@ -25,7 +25,7 @@ fn invalid(m: impl std::fmt::Display) -> IoError {
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Scalar {
pub(crate) enum Scalar {
I8,
U8,
I16,
@@ -50,7 +50,7 @@ impl Scalar {
_ => return None,
})
}
fn size(self) -> usize {
pub(crate) fn size(self) -> usize {
match self {
Scalar::I8 | Scalar::U8 => 1,
Scalar::I16 | Scalar::U16 => 2,
@@ -79,7 +79,7 @@ impl Scalar {
})
}
/// A colour channel of this type as a byte: integers by their range, floats from 0..1.
fn channel(self, v: f64) -> u8 {
pub(crate) fn channel(self, v: f64) -> u8 {
let v = match self {
Scalar::F32 | Scalar::F64 => v * 255.0,
Scalar::U16 | Scalar::I16 => v / 257.0,
@@ -91,23 +91,23 @@ impl Scalar {
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Kind {
pub(crate) enum Kind {
Scalar(Scalar),
/// The count's type and the items' type.
List(Scalar, Scalar),
}
#[derive(Clone, Debug)]
struct Property {
name: String,
kind: Kind,
pub(crate) struct Property {
pub(crate) name: String,
pub(crate) kind: Kind,
}
#[derive(Clone, Debug)]
struct Element {
name: String,
count: u64,
properties: Vec<Property>,
pub(crate) struct Element {
pub(crate) name: String,
pub(crate) count: u64,
pub(crate) properties: Vec<Property>,
}
impl Element {
@@ -124,7 +124,7 @@ impl Element {
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Encoding {
pub(crate) enum Encoding {
Ascii,
Little,
Big,
@@ -132,10 +132,12 @@ enum Encoding {
/// What the header says, and where the rows start.
#[derive(Debug)]
struct Header {
encoding: Encoding,
elements: Vec<Element>,
body: usize,
pub(crate) struct Header {
pub(crate) encoding: Encoding,
pub(crate) elements: Vec<Element>,
pub(crate) body: usize,
/// The text of the `comment` lines.
pub(crate) comments: Vec<String>,
}
/// Does `bytes` start like a PLY file?
@@ -143,7 +145,7 @@ pub fn is_ply(bytes: &[u8]) -> bool {
bytes.starts_with(b"ply") && bytes.get(3).is_some_and(|b| matches!(b, b'\n' | b'\r'))
}
fn header(bytes: &[u8]) -> Result<Header> {
pub(crate) fn header(bytes: &[u8]) -> Result<Header> {
if !is_ply(bytes) {
return Err(invalid("not a PLY file (it must start with `ply`)"));
}
@@ -163,7 +165,12 @@ fn header(bytes: &[u8]) -> Result<Header> {
let text = String::from_utf8_lossy(head.get(..end).unwrap_or_default());
let mut encoding = None;
let mut elements: Vec<Element> = Vec::new();
let mut comments = Vec::new();
for line in text.split(['\n', '\r']).skip(1) {
if let Some(c) = line.strip_prefix("comment ") {
comments.push(c.trim().to_string());
continue;
}
let mut it = line.split_whitespace();
match it.next() {
Some("format") => {
@@ -206,11 +213,11 @@ fn header(bytes: &[u8]) -> Result<Header> {
}
}
let encoding = encoding.ok_or_else(|| invalid("no `format` line"))?;
Ok(Header { encoding, elements, body })
Ok(Header { encoding, elements, body, comments })
}
/// A row's values, read one after another.
enum Rows<'a> {
pub(crate) enum Rows<'a> {
Binary { b: &'a [u8], pos: usize, big: bool },
Ascii { tokens: std::slice::Split<'a, u8, fn(&u8) -> bool> },
}
@@ -220,7 +227,7 @@ fn space(b: &u8) -> bool {
}
impl<'a> Rows<'a> {
fn new(h: &Header, bytes: &'a [u8]) -> Rows<'a> {
pub(crate) fn new(h: &Header, bytes: &'a [u8]) -> Rows<'a> {
let b = bytes.get(h.body..).unwrap_or_default();
match h.encoding {
Encoding::Ascii => Rows::Ascii { tokens: b.split(space as fn(&u8) -> bool) },
@@ -228,7 +235,7 @@ impl<'a> Rows<'a> {
Encoding::Big => Rows::Binary { b, pos: 0, big: true },
}
}
fn value(&mut self, t: Scalar) -> Result<f64> {
pub(crate) fn value(&mut self, t: Scalar) -> Result<f64> {
match self {
Rows::Binary { b, pos, big } => {
let v = b.get(*pos..).and_then(|r| t.read(r, *big)).ok_or_else(|| invalid("the file ends early"))?;
@@ -265,7 +272,7 @@ impl<'a> Rows<'a> {
}
}
}
fn count(&mut self, t: Scalar) -> Result<usize> {
pub(crate) fn count(&mut self, t: Scalar) -> Result<usize> {
let n = self.value(t)?;
if !(n >= 0.0 && n <= MAX_FACE as f64 && n.fract() == 0.0) {
return Err(invalid("a list is longer than SolidCraft reads"));
@@ -316,7 +323,6 @@ pub(crate) type Vertex = ([f64; 3], Option<[u8; 3]>);
/// into `faces` (a fan of triangles per face) when given.
struct Read {
vertices: u64,
colored: bool,
bad_faces: usize,
}
@@ -327,7 +333,7 @@ fn walk(bytes: &[u8], keep: &mut dyn FnMut(u64) -> bool, on_vertex: &mut dyn FnM
if w.xyz.is_none() {
return Err(invalid("the vertices have no `x`, `y` and `z`"));
}
let mut out = Read { vertices: vertex.count, colored: w.rgb.is_some(), bad_faces: 0 };
let mut out = Read { vertices: vertex.count, bad_faces: 0 };
let body = bytes.len().saturating_sub(h.body) as u64;
let mut rows = Rows::new(&h, bytes);
let mut slots = vec![0.0f64; MAX_PROPERTIES];
@@ -447,14 +453,6 @@ fn walk(bytes: &[u8], keep: &mut dyn FnMut(u64) -> bool, on_vertex: &mut dyn FnM
Ok(out)
}
/// The vertices of a PLY file, every `stride`th one (`1`: all), and how many the file has.
/// The colour of each is `Some` when the file has colours.
pub(crate) fn read_vertices(bytes: &[u8], stride: u64, on_vertex: &mut dyn FnMut(Vertex)) -> Result<(u64, bool)> {
let stride = stride.max(1);
let r = walk(bytes, &mut |i| i.is_multiple_of(stride), on_vertex, None)?;
Ok((r.vertices, r.colored))
}
/// How many vertices and faces a PLY file says it has (from its header alone).
pub fn ply_counts(bytes: &[u8]) -> Result<(u64, u64)> {
let h = header(bytes)?;
@@ -501,10 +499,12 @@ pub fn read_ply_mesh(bytes: &[u8], name: &str) -> Result<(Vec<MeshObject>, Vec<S
mod tests {
use super::*;
fn vertices(bytes: &[u8], stride: u64) -> Result<(Vec<Vertex>, u64, bool)> {
let mut out = Vec::new();
let (n, c) = read_vertices(bytes, stride, &mut |v| out.push(v))?;
Ok((out, n, c))
/// The vertices kept from at most `max` of them, how many the file has, and whether it has colours.
fn vertices(bytes: &[u8], max: usize) -> Result<(Vec<Vertex>, u64, bool)> {
let r = crate::ply_complex::read_ply_cloud(bytes, max)?;
let colors = r.colors.as_ref();
let out = r.positions.iter().enumerate().map(|(i, p)| (*p, colors.and_then(|c| c.get(i).copied()))).collect();
Ok((out, r.vertices, colors.is_some()))
}
fn binary(big: bool) -> Vec<u8> {
@@ -532,7 +532,7 @@ mod tests {
#[test]
fn binary_little_and_big_endian_with_colours_and_other_elements() {
for big in [false, true] {
let (v, n, colored) = vertices(&binary(big), 1).unwrap();
let (v, n, colored) = vertices(&binary(big), 3).unwrap();
assert_eq!((n, colored), (3, true));
assert_eq!(v[1], ([1.5, 0.0, 0.0], Some([10, 20, 255])));
assert_eq!(v[2].0, [0.0, 2.5, -3.0]);
@@ -551,7 +551,7 @@ mod tests {
let src = "ply\nformat ascii 1.0\nelement vertex 4\nproperty double x\nproperty double y\nproperty double z\nproperty float red\nproperty float green\nproperty float blue\n\
element face 1\nproperty list uchar uint vertex_index\nend_header\n\
0 0 0 1 0 0\n1 0 0 0 1 0\n1 1 0 0 0 1\n0 1 0 0.5 0.5 0.5\n4 0 1 2 3\n";
let (v, n, colored) = vertices(src.as_bytes(), 1).unwrap();
let (v, n, colored) = vertices(src.as_bytes(), 4).unwrap();
assert_eq!((n, colored, v.len()), (4, true, 4));
assert_eq!(v[0].1, Some([255, 0, 0]));
assert_eq!(v[3], ([0.0, 1.0, 0.0], Some([128, 128, 128])));
@@ -560,7 +560,7 @@ mod tests {
// Only points (as the glim maps are written): no colours, no faces.
let points =
"ply\nformat ascii 1.0\nelement vertex 2\nproperty float32 x\nproperty float32 y\nproperty float32 z\nend_header\n1 2 3\n4 5 6\n";
let (v, n, colored) = vertices(points.as_bytes(), 1).unwrap();
let (v, n, colored) = vertices(points.as_bytes(), 2).unwrap();
assert_eq!((v.len(), n, colored), (2, 2, false));
assert_eq!(v[1], ([4.0, 5.0, 6.0], None));
assert!(read_ply_mesh(points.as_bytes(), "x").is_err(), "a point cloud is not a mesh");
@@ -589,13 +589,13 @@ mod tests {
for c in &cases {
let r = std::panic::catch_unwind(|| {
let _ = vertices(c, 1);
let _ = vertices(c, 3);
let _ = vertices(c, 1000);
let _ = read_ply_mesh(c, "x");
let _ = ply_counts(c);
});
assert!(r.is_ok(), "{:?}", String::from_utf8_lossy(c));
}
assert!(vertices(&good[..good.len() - 30], 1).is_err(), "a cut-off file is refused");
assert!(vertices(&good[..good.len() - 30], 1000).is_err(), "a cut-off file is refused");
// A face naming a vertex that isn't there is left out, with a warning.
let tri = "ply\nformat ascii 1.0\nelement vertex 3\nproperty float x\nproperty float y\nproperty float z\nelement face 2\nproperty list uchar int vertex_indices\nend_header\n0 0 0 1 0 0 0 1 0\n3 0 1 2\n3 0 1 99\n";
let (mesh, warnings) = read_ply_mesh(tri.as_bytes(), "x").unwrap();
+147
View File
@@ -0,0 +1,147 @@
//! A PLY file read with everything a point cloud can hold: its vertices with all their
//! properties. Positions are `x`, `y`, `z` and colours `red`, `green`, `blue`; every other scalar
//! property becomes a column named as in the file, whole-numbered when its type is an integer. A
//! comment `property: 0 name, 1 name` names the values of a whole-numbered property. Every
//! `stride`th vertex is kept.
use solidcraft_doc::point_cloud::{Column, Complex, Floats, MAX_COLUMNS, Names};
use crate::ply::{Element, Kind, Rows, Scalar, header};
use crate::{IoError, Result};
fn invalid(m: impl std::fmt::Display) -> IoError {
IoError::Invalid(format!("PLY: {m}"))
}
/// What a PLY file holds, positions in the file's units.
pub(crate) struct PlyCloud {
pub positions: Vec<[f64; 3]>,
pub colors: Option<Vec<[u8; 3]>>,
pub complex: Complex,
/// Vertices in the file.
pub vertices: u64,
}
fn integer(s: Scalar) -> bool {
!matches!(s, Scalar::F32 | Scalar::F64)
}
fn named(e: &Element, names: &[&str]) -> Option<usize> {
names.iter().find_map(|n| e.properties.iter().position(|p| p.name.eq_ignore_ascii_case(n) && matches!(p.kind, Kind::Scalar(_))))
}
/// The columns an element's scalar properties fill, leaving out those at `taken`.
fn columns_of(e: &Element, taken: &[usize]) -> Vec<(usize, Column)> {
e.properties
.iter()
.enumerate()
.filter(|(i, _)| !taken.contains(i))
.filter_map(|(i, p)| match p.kind {
Kind::Scalar(s) => Some((i, Column { name: p.name.clone(), integer: integer(s), values: Floats::default() })),
Kind::List(..) => None,
})
.collect()
}
/// Read a PLY file's vertices, every `stride`th one of those beyond `max_points`.
pub(crate) fn read_ply_cloud(bytes: &[u8], max_points: usize) -> Result<PlyCloud> {
let h = header(bytes)?;
let vertex = h.elements.iter().find(|e| e.name == "vertex").ok_or_else(|| invalid("no `vertex` element"))?;
let stride = vertex.count.div_ceil(max_points.max(1) as u64).max(1);
let xyz = [named(vertex, &["x"]), named(vertex, &["y"]), named(vertex, &["z"])];
let [Some(x), Some(y), Some(z)] = xyz else { return Err(invalid("the vertices have no `x`, `y` and `z`")) };
let rgb =
[named(vertex, &["red", "r", "diffuse_red"]), named(vertex, &["green", "g", "diffuse_green"]), named(vertex, &["blue", "b", "diffuse_blue"])];
let rgb = match rgb {
[Some(r), Some(g), Some(b)] => Some([r, g, b]),
_ => None,
};
let mut taken = vec![x, y, z];
taken.extend(rgb.into_iter().flatten());
let mine = columns_of(vertex, &taken);
if mine.len() > MAX_COLUMNS {
return Err(invalid(format!("more than {MAX_COLUMNS} properties")));
}
let body = bytes.len().saturating_sub(h.body) as u64;
let mut rows = Rows::new(&h, bytes);
let mut out = PlyCloud { positions: Vec::new(), colors: rgb.map(|_| Vec::new()), complex: Complex::default(), vertices: vertex.count };
let mut columns: Vec<(usize, Vec<f32>, Column)> = mine.into_iter().map(|(i, c)| (i, Vec::new(), c)).collect();
let mut row = vec![0.0f64; vertex.properties.len()];
// The elements before the vertices are read past.
for e in h.elements.iter().take_while(|e| e.name != "vertex").chain([vertex]) {
if e.count > body {
return Err(invalid(format!("the file is too short for its {} {}", e.count, e.name)));
}
let vertices = std::ptr::eq(e, vertex);
for r in 0..e.count {
for (i, p) in e.properties.iter().enumerate() {
match p.kind {
Kind::Scalar(t) => {
let v = rows.value(t)?;
if let Some(s) = row.get_mut(i).filter(|_| vertices) {
*s = v;
}
}
Kind::List(c, t) => {
for _ in 0..rows.count(c)? {
rows.value(t)?;
}
}
}
}
if !vertices || !r.is_multiple_of(stride) {
continue;
}
let v = |i: usize| row.get(i).copied().unwrap_or(0.0);
out.positions.push([v(x), v(y), v(z)]);
if let (Some(c), Some(ks)) = (out.colors.as_mut(), rgb) {
let t = |i: usize| match vertex.properties.get(i).map(|p| p.kind) {
Some(Kind::Scalar(s)) => s.channel(v(i)),
_ => 0,
};
c.push(ks.map(t));
}
for (slot, values, _) in &mut columns {
values.push(v(*slot) as f32);
}
}
}
let columns: Vec<Column> = columns.into_iter().map(|(_, values, c)| Column { values: Floats(values.into()), ..c }).collect();
// Names only for whole-numbered properties the file has, the first comment for each.
let mut names: Vec<Names> = Vec::new();
for n in h.comments.iter().filter_map(|c| Names::parse(c)) {
let label = columns.iter().any(|c| c.name == n.property && c.integer);
if label && !names.iter().any(|m| m.property == n.property) && names.len() < MAX_COLUMNS {
names.push(n);
}
}
out.complex = Complex { columns, names };
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_vertex_property_is_kept() {
let text = "ply\nformat ascii 1.0\ncomment role: 7 wall, -1 none\ncomment t: seconds since the start\ncomment width: 0 thin\n\
element vertex 4\nproperty float x\nproperty float y\nproperty float z\nproperty double t\nproperty int role\nproperty float width\n\
element face 1\nproperty list uchar int vertex_indices\nend_header\n\
0 0 0 0.5 7 1\n1 0 0 1.5 7 1\n1 1 0 2.5 -1 2\n0 1 0 3.5 -1 2\n4 0 1 2 3\n";
let r = read_ply_cloud(text.as_bytes(), 10).unwrap();
assert_eq!(r.positions.len(), 4);
let col = |n: &str| r.complex.column(n).unwrap();
assert_eq!(&*col("t").values.0, &[0.5, 1.5, 2.5, 3.5]);
assert!(col("role").integer && !col("width").integer);
assert_eq!(&*col("role").values.0, &[7.0, 7.0, -1.0, -1.0]);
r.complex.check(4).unwrap();
// Names for the whole-numbered `role` only: `t` is no list and `width` is not whole.
assert_eq!(r.complex.names.len(), 1);
assert_eq!(r.complex.names("role").and_then(|n| n.name(-1)), Some("none"));
// Every second vertex, with its numbers.
let half = read_ply_cloud(text.as_bytes(), 2).unwrap();
assert_eq!((half.positions.len(), &*half.complex.column("t").unwrap().values.0), (2, &[0.5f32, 2.5][..]));
half.complex.check(2).unwrap();
}
}
+15 -14
View File
@@ -34,6 +34,8 @@ pub struct CloudFile {
pub colors: Vec<u8>,
/// Points in the file.
pub source_points: u64,
/// The file's other properties of the points (PLY).
pub complex: solidcraft_doc::point_cloud::Complex,
pub warnings: Vec<String>,
}
@@ -77,20 +79,19 @@ pub fn read_point_cloud(bytes: &[u8], file: &str, scale: f64, max_points: usize)
let ply = crate::ply::is_ply(bytes) || (is_ply_path(file) && !bytes.is_empty());
let stride;
if ply {
let (count, _) = crate::ply::ply_counts(bytes)?;
stride = count.div_ceil(max as u64).max(1);
keep.out.points.reserve(count.div_ceil(stride).min(max as u64) as usize * 3);
// Colours are known once the header is read: the first vertex says.
let mut first = true;
let (count, colored) = crate::ply::read_vertices(bytes, stride, &mut |(p, rgb)| {
if first {
keep.colored = rgb.is_some();
first = false;
}
keep.push(p, rgb);
})?;
keep.out.source_points = count;
keep.colored &= colored;
let r = crate::ply_complex::read_ply_cloud(bytes, max)?;
stride = r.vertices.div_ceil(r.positions.len().max(1) as u64).max(1);
keep.colored = r.colors.is_some();
let colors = r.colors.unwrap_or_default();
for (i, p) in r.positions.iter().enumerate() {
keep.push(*p, colors.get(i).copied());
}
// A point left out would shift every number after it.
if keep.bad > 0 && !r.complex.is_empty() {
return Err(IoError::Invalid(format!("{} point(s) are not finite numbers within range", keep.bad)));
}
keep.out.source_points = r.vertices;
keep.out.complex = r.complex;
} else {
stride = read_xyz(bytes, &mut keep)?;
}
+1
View File
@@ -24,6 +24,7 @@ mod model;
mod features;
mod point_clouds;
pub use point_clouds::CloudPanel;
pub(crate) use point_clouds::point_cloud_tracks;
pub(crate) fn reveal_feature(app: &mut SolveApp, id: u64, body: &str) {
features::reveal(app, id, body);
@@ -0,0 +1,219 @@
//! What the Edit Point Cloud card's range tracks draw, worked out off the frame: each track's
//! bars over the whole span and over its loupe, and the values its ends snap to. A track asks
//! every frame; a worker thread answers the newest asks (on the web, where there are no threads,
//! they are answered at once), and until then the track shows its last answer. The view asks too,
//! for the values the coloured property takes over what is kept: the ramp's automatic range.
use std::collections::HashMap;
use std::sync::{Arc, Condvar, Mutex};
use egui::Color32;
use solidcraft_engine::doc::point_cloud::{Filter, PALETTE, PLAIN, PointCloud, byte, height_ramp};
use solidcraft_engine::doc::point_cloud_distribution::{Colour, Geometry, histogram, label_changes};
use super::range_track::Bars;
use crate::theme::model_color;
/// Equal bands a ramp's bars are split into.
const RAMP_BANDS: usize = 32;
/// How a track's bars are coloured.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Paint {
/// One colour.
One,
/// The ramp over `range` of `property`; painted at each value when the track is that property.
Ramp { property: String, range: [f64; 2] },
/// The palette of a whole-numbered `property`.
Palette { property: String },
}
/// What a track asks for: its property's bars over the whole span and over the loupe, in `bins`
/// bins each, over what `filters` keep; and the values where `changes_of` changes along it.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Ask {
pub property: String,
pub span: [f64; 2],
pub loupe: [f64; 2],
pub bins: usize,
pub paint: Paint,
pub filters: Vec<Filter>,
pub changes_of: Option<String>,
}
/// A track's answer.
#[derive(Clone, Debug, Default)]
pub(crate) struct Answer {
pub(super) overview: Bars,
pub(super) loupe: Bars,
pub(super) changes: Arc<Vec<f64>>,
/// The least and the greatest value the property takes over what is kept.
pub extent: Option<[f64; 2]>,
}
/// The cloud's data as far as the tracks are concerned: its points, columns and placement.
type DataKey = Vec<u64>;
fn data_key(c: &PointCloud) -> DataKey {
let mut k = vec![Arc::as_ptr(c.points.values()).cast::<f32>() as u64, c.origin.x.to_bits(), c.origin.y.to_bits(), c.origin.z.to_bits()];
k.extend(c.complex.columns.iter().map(|col| Arc::as_ptr(&col.values.0).cast::<f32>() as u64));
k.extend(c.transform.iter().flatten().map(|v| v.to_bits()));
k
}
/// What answers need of a cloud, worked out once for its data.
struct Prepared {
key: DataKey,
cloud: Arc<PointCloud>,
geometry: Option<Arc<Geometry>>,
changes: HashMap<(String, String), Arc<Vec<f64>>>,
}
impl Prepared {
fn answer(&mut self, ask: &Ask) -> Answer {
let c = &self.cloud;
let geometry = self.geometry.get_or_insert_with(|| Arc::new(Geometry::of(c))).clone();
let (colour, colours, ramp) = match &ask.paint {
// Coloured by its own property, each value has its colour: the bars are one slot,
// painted along the ramp.
Paint::Ramp { property, range } if *property == ask.property => (Colour::One, vec![plain()], Some(*range)),
Paint::Ramp { property, range } => (
Colour::Ramp { property, range: *range, bands: RAMP_BANDS },
(0..RAMP_BANDS).map(|k| model_color(height_ramp((k as f64 + 0.5) / RAMP_BANDS as f64))).chain([plain()]).collect(),
None,
),
Paint::Palette { property } => {
(Colour::Palette { property }, PALETTE.iter().map(|c| model_color(*c)).chain([plain(), plain()]).collect(), None)
}
Paint::One => (Colour::One, vec![plain()], None),
};
let bars = |axis: [f64; 2]| {
let h = histogram(c, &geometry, &ask.property, axis, ask.bins, &colour, &ask.filters);
let extent = h.as_ref().and_then(|h| h.extent);
(Bars { axis, slots: h.map(|h| h.slots).unwrap_or_default(), colours: colours.clone(), ramp }, extent)
};
let (overview, extent) = bars(ask.span);
let loupe = if ask.loupe == ask.span { overview.clone() } else { bars(ask.loupe).0 };
let changes = match &ask.changes_of {
Some(label) => self
.changes
.entry((label.clone(), ask.property.clone()))
.or_insert_with(|| Arc::new(label_changes(c, &geometry, label, &ask.property)))
.clone(),
None => Arc::default(),
};
Answer { overview, loupe, changes, extent }
}
}
/// What has no colour of its own: the cloud's plain colour.
fn plain() -> Color32 {
model_color(PLAIN.map(byte))
}
#[derive(Default)]
struct Inner {
cloud: Option<(DataKey, Arc<PointCloud>)>,
asked: HashMap<String, Ask>,
answers: HashMap<String, (Ask, Arc<Answer>)>,
}
#[derive(Default)]
struct Shared {
inner: Mutex<Inner>,
asked: Condvar,
}
/// The answers of one card's tracks; clones share them.
#[derive(Clone, Default)]
pub(crate) struct Tracks {
shared: Arc<Shared>,
#[cfg(not(target_arch = "wasm32"))]
worker: Arc<std::sync::OnceLock<()>>,
}
impl std::fmt::Debug for Tracks {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Tracks")
}
}
impl Tracks {
/// The last answer to the track `key` (maybe to an older ask), asking `ask` of `cloud` when
/// that is new.
pub(crate) fn answer(&self, ctx: &egui::Context, cloud: &PointCloud, key: &str, ask: Ask) -> Option<Arc<Answer>> {
let Ok(mut inner) = self.shared.inner.lock() else { return None };
let data = data_key(cloud);
if inner.cloud.as_ref().is_none_or(|(k, _)| *k != data) {
inner.cloud = Some((data, Arc::new(cloud.clone())));
}
let last = inner.answers.get(key).cloned();
let answered = last.as_ref().is_some_and(|(a, _)| *a == ask);
if !answered && inner.asked.get(key) != Some(&ask) {
inner.asked.insert(key.to_string(), ask);
drop(inner);
self.work(ctx);
}
last.map(|(_, a)| a)
}
/// Whether the track `key` has an answer to its last ask.
#[cfg(test)]
pub(super) fn answered(&self, key: &str) -> bool {
self.shared.inner.lock().is_ok_and(|i| !i.asked.contains_key(key) && i.answers.contains_key(key))
}
#[cfg(not(target_arch = "wasm32"))]
fn work(&self, ctx: &egui::Context) {
self.shared.asked.notify_one();
self.worker.get_or_init(|| {
let shared = Arc::downgrade(&self.shared);
let ctx = ctx.clone();
let _ = std::thread::Builder::new().name("point-cloud-tracks".into()).spawn(move || work(&shared, &ctx));
});
}
#[cfg(target_arch = "wasm32")]
fn work(&self, ctx: &egui::Context) {
let mut prepared = None;
while answer_one(&self.shared, &mut prepared) {}
ctx.request_repaint();
}
}
/// Answer one ask, if there is one.
fn answer_one(shared: &Shared, prepared: &mut Option<Prepared>) -> bool {
let (key, ask, data, cloud) = {
let Ok(mut inner) = shared.inner.lock() else { return false };
let Some(key) = inner.asked.keys().next().cloned() else { return false };
let Some(ask) = inner.asked.remove(&key) else { return false };
let Some((data, cloud)) = inner.cloud.clone() else { return false };
(key, ask, data, cloud)
};
if prepared.as_ref().is_none_or(|p| p.key != data) {
*prepared = Some(Prepared { key: data, cloud, geometry: None, changes: HashMap::new() });
}
let Some(p) = prepared.as_mut() else { return false };
let answer = Arc::new(p.answer(&ask));
if let Ok(mut inner) = shared.inner.lock() {
inner.answers.insert(key, (ask, answer));
}
true
}
/// The worker: answers asks as they come, until the card is gone.
#[cfg(not(target_arch = "wasm32"))]
fn work(shared: &std::sync::Weak<Shared>, ctx: &egui::Context) {
let mut prepared = None;
loop {
let Some(s) = shared.upgrade() else { return };
if answer_one(&s, &mut prepared) {
ctx.request_repaint();
continue;
}
let Ok(inner) = s.inner.lock() else { return };
if inner.asked.is_empty() {
let _ = s.asked.wait_timeout(inner, std::time::Duration::from_millis(500));
}
}
}
+477 -46
View File
@@ -1,10 +1,13 @@
//! The Point Clouds folder of the Browser (#5151): an eye per cloud and for the folder, a menu,
//! and the Edit Point Cloud card (the file's units, the size of the dots, how they are coloured:
//! the file's colours, one colour, or by height between two heights), whose live edits of one
//! opening are one undo step.
//! and the Edit Point Cloud card: the file's units, the size of the dots, how the cloud is
//! coloured (its colours, one colour, a ramp over a property, its range on a track, or the
//! palette of a label, with its legend), and its filters (a range on a track, played with ▶ or
//! Space, or the values of a label picked in its legend). Each track shows how much of the cloud
//! lies at each value, over what the other filters keep. The live edits of one opening are one
//! undo step.
use serde_json::{Value, json};
use solidcraft_engine::doc::point_cloud::{ColorBy, PLAIN, PointCloud, UNITS, byte};
use solidcraft_engine::doc::point_cloud::{COORDINATES, ColorBy, Filter, Keep, MAX_FILTERS, Names, PLAIN, PointCloud, UNITS, byte, label_color};
use solidcraft_engine::view::POINT_CLOUDS_FOLDER;
use super::{Actions, Row, draw_row, is_open, panel_pos, toggle, toggle_folder};
@@ -12,18 +15,86 @@ use crate::SolveApp;
use crate::context_menu::{Item, Target};
use crate::ui_kit;
/// The Edit Point Cloud card: the cloud, the undo depth when it opened, and the automatic
/// height range of the points it was worked out for (it sorts a sample of them).
#[path = "point_cloud_tracks.rs"]
pub(crate) mod point_cloud_tracks;
#[path = "range_track.rs"]
mod range_track;
use point_cloud_tracks::{Ask, Paint, Tracks};
use range_track::{Part, Track, range_track};
/// Filters the GPU applies; the others are kept but not drawn.
pub const DRAWN_FILTERS: usize = solidcraft_gpu::view::FILTERS;
/// The Edit Point Cloud card: the cloud, the undo depth when it opened, what it worked out once
/// for the cloud's data (the automatic height range), its tracks' bars, and a filter playing.
#[derive(Clone, Debug)]
pub struct CloudPanel {
pub cloud: u64,
pub(crate) depth: usize,
auto: Option<(usize, [u64; 16], Option<[f64; 2]>)>,
tracks: Tracks,
pub play: Option<Play>,
}
/// A range filter moving on its own: the part of it touched last moves forward (its upper end
/// unrolls, its band slides, its lower end wipes), across its track's loupe in [`PLAY_SECONDS`].
/// It is written to the design once, when it stops.
#[derive(Clone, Debug)]
pub struct Play {
pub filter: usize,
part: Part,
/// When it last moved, and where it is exactly.
at: f64,
exact: [f64; 2],
/// The range now (a whole-numbered property's ends whole).
pub now: [f64; 2],
}
/// How long playing a filter across its track's loupe takes, in seconds: zooming in slows it.
const PLAY_SECONDS: f64 = 20.0;
/// The names of a cloud's tracks.
fn filter_key(cloud: u64, k: usize) -> String {
format!("point_cloud_{cloud}_filter_{k}")
}
fn colour_key(cloud: u64) -> String {
format!("point_cloud_{cloud}_colour")
}
impl Play {
/// Start playing `part` of `range` within `span`, from the start again when it is at the end.
fn new(filter: usize, part: Part, [lo, hi]: [f64; 2], span: [f64; 2], at: f64) -> Play {
let exact = match part {
Part::High if hi >= span[1] => [lo, lo],
Part::Band if hi >= span[1] => [span[0], span[0] + hi - lo],
Part::Low if lo >= hi => [span[0], hi],
_ => [lo, hi],
};
Play { filter, part, at, exact, now: exact }
}
/// Move on to time `now` at `speed` units a second; whether it reached the end.
fn step(&mut self, now: f64, speed: f64, span: [f64; 2], integer: bool) -> bool {
let d = speed * (now - self.at).max(0.0);
self.at = now;
let [lo, hi] = self.exact;
let (exact, end) = match self.part {
Part::High => ([lo, (hi + d).min(span[1])], hi + d >= span[1]),
Part::Low => ([(lo + d).min(hi), hi], lo + d >= hi),
Part::Band => {
let w = hi - lo;
let start = (lo + d).min(span[1] - w);
([start, start + w], lo + d >= span[1] - w)
}
};
self.exact = exact;
self.now = if integer { exact.map(f64::floor) } else { exact };
end
}
}
impl CloudPanel {
pub fn new(app: &SolveApp, cloud: u64) -> Self {
CloudPanel { cloud, depth: app.session.undo.len(), auto: None }
CloudPanel { cloud, depth: app.session.undo.len(), auto: None, tracks: Tracks::default(), play: None }
}
/// The cloud's automatic height range, worked out again only when its points or placement
/// change.
@@ -38,11 +109,80 @@ impl CloudPanel {
}
}
}
/// The range filter `k` of this card's cloud shows now, while it plays.
pub fn playing(&self, cloud: u64, k: usize) -> Option<[f64; 2]> {
self.play.as_ref().filter(|p| self.cloud == cloud && p.filter == k).map(|p| p.now)
}
}
/// The ways the card colours the points, as the dropdown lists them.
const COLOR_BY: [(ColorBy, &str); 3] = [(ColorBy::File, "File Colors"), (ColorBy::Single, "Single Color"), (ColorBy::Height, "Height")];
/// The names of `property` when it is filtered by a set of values: a label whose values span
/// fewer than the 32 the view's mask holds. Any other property is filtered by a range.
fn label<'a>(c: &'a PointCloud, property: &str) -> Option<&'a Names> {
let names = c.complex.names(property)?;
let (lo, hi) = names.values.iter().fold((i32::MAX, i32::MIN), |(lo, hi), (v, _)| (lo.min(*v), hi.max(*v)));
(i64::from(hi) - i64::from(lo) < 32).then_some(names)
}
/// Bins of a track's bars: about one a point across the card.
fn bins(ui: &egui::Ui) -> usize {
(ui.available_width().round() as usize).clamp(16, 1024)
}
/// The filters as `pointcloud.edit` takes them.
fn filters_json(filters: &[Filter]) -> Vec<Value> {
filters.iter().map(|f| serde_json::to_value(f).unwrap_or(Value::Null)).collect()
}
/// The filters with `value` of the label `property` shown or hidden: in its set filter, or in a
/// new one keeping every named value but it.
fn toggled(c: &PointCloud, names: &Names, value: i32) -> Vec<Value> {
let mut filters = c.filters.clone();
match filters.iter_mut().find(|f| f.property == names.property && matches!(f.keep, Keep::Values { .. })) {
Some(Filter { keep: Keep::Values { values }, .. }) => {
if let Some(k) = values.iter().position(|v| *v == value) {
values.remove(k);
} else {
values.push(value);
}
}
_ => filters.push(Filter {
property: names.property.clone(),
keep: Keep::Values { values: names.values.iter().map(|(v, _)| *v).filter(|v| *v != value).collect() },
}),
}
filters_json(&filters)
}
/// A label's legend: a swatch and a name per value, dimmed when a filter hides it. The value
/// clicked, to show or hide.
fn legend(ui: &mut egui::Ui, c: &PointCloud, names: &Names) -> Option<i32> {
let kept = c.filters.iter().find_map(|f| match &f.keep {
Keep::Values { values } if f.property == names.property => Some(values.clone()),
_ => None,
});
let mut clicked = None;
for (v, name) in &names.values {
let shown = kept.as_ref().is_none_or(|k| k.contains(v));
let colour = crate::theme::model_color(label_color(f64::from(*v)));
let swatch = if shown { colour } else { colour.gamma_multiply(0.16) };
let response = ui
.horizontal(|ui| {
let (rect, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover());
ui.painter().rect_filled(rect, 3.0, swatch);
let text = egui::RichText::new(name.as_str());
ui.add(egui::Label::new(if shown { text } else { text.weak().strikethrough() }).sense(egui::Sense::click()))
})
.inner;
if response.clicked() {
clicked = Some(*v);
}
}
clicked
}
pub(super) fn rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize, acts: &mut Actions) {
let clouds: Vec<(u64, String, bool)> = app.session.doc.point_clouds.iter().map(|c| (c.id, c.name.clone(), c.visible)).collect();
if clouds.is_empty() {
@@ -104,8 +244,29 @@ pub(crate) fn panel(app: &mut SolveApp, ctx: &egui::Context) {
};
let mut edit: Option<Value> = None;
let title = crate::i18n::format("browser.edit_point_cloud_title", &[("name", &c.name)]);
let (units, decimals) = (app.session.doc.units.clone(), app.preferences.length_decimals);
let auto = if c.color_by == ColorBy::Height { m.auto_range(&c) } else { None };
let now = ctx.input(|i| i.time);
// A playing filter moves on; at the end of its span it stops, and is written once.
let mut stop_play = false;
if let Some(p) = m.play.as_mut()
&& let Some(span) = c.filters.get(p.filter).and_then(|f| c.span(&f.property))
{
let integer = c.filters.get(p.filter).is_some_and(|f| c.complex.integer(&f.property));
let [a, b] = range_track::loupe(ctx, &filter_key(c.id, p.filter), span);
stop_play = p.step(now, (b - a) / PLAY_SECONDS, span, integer);
ctx.request_repaint();
}
// The cloud as the view draws it now, a playing filter where it is: the filters the tracks
// apply, how their bars are coloured, as the cloud is, and the values they snap to, where a
// label changes.
let live = crate::point_clouds::as_drawn(&c, |k| m.playing(c.id, k)).into_owned();
let drawn: Vec<Filter> = live.filters.iter().take(DRAWN_FILTERS).cloned().collect();
let paint = match (c.colored_property(), crate::point_clouds::color_range(ctx, &live)) {
(Some(n), _) if c.complex.integer(n) => Paint::Palette { property: n.to_string() },
(Some(n), Some(range)) => Paint::Ramp { property: n.to_string(), range },
_ => Paint::One,
};
let changes_of = c.colored_property().filter(|n| c.complex.integer(n)).map(str::to_string);
let at = panel_pos(app);
let (closed, action) = ui_kit::Window::new(egui::Id::new("sc_point_cloud_panel"), "point", &title)
.placed(egui::Align2::LEFT_TOP, at)
@@ -113,29 +274,39 @@ pub(crate) fn panel(app: &mut SolveApp, ctx: &egui::Context) {
.actions("", crate::i18n::tr("ui.close"), None)
.show(ctx, true, |ui| {
ui_kit::row(ui, crate::i18n::tr_label("Points"), |ui| ui.label(c.len().to_string()));
let names: Vec<&str> = UNITS.iter().map(|(u, _)| *u).collect();
let was = names.iter().position(|u| *u == c.units).unwrap_or(0);
let mut unit = was;
// A dropdown's response doesn't report the pick: compare the index.
ui_kit::row(ui, crate::i18n::tr("ui.point_cloud_units"), |ui| ui_kit::dropdown(ui, "point_cloud_units", &mut unit, &names));
if unit != was
&& let Some(u) = names.get(unit)
{
edit = Some(json!({ "cloud": c.id, "units": u }));
}
let mut size = c.point_size;
if ui_kit::row(ui, crate::i18n::tr("ui.point_cloud_size"), |ui| ui_kit::slider(ui, &mut size, 0.5..=16.0, Default::default())).changed() {
edit = Some(json!({ "cloud": c.id, "point_size": size }));
}
let labels: Vec<&str> = COLOR_BY.iter().map(|(_, l)| *l).collect();
let was = COLOR_BY.iter().position(|(b, _)| *b == c.color_by).unwrap_or(0);
let units: Vec<&str> = UNITS.iter().map(|(u, _)| *u).collect();
let was = units.iter().position(|u| *u == c.units).unwrap_or(0);
let mut unit = was;
// A dropdown's response doesn't report the pick: compare the index.
ui_kit::row(ui, crate::i18n::tr("ui.point_cloud_units"), |ui| ui_kit::dropdown(ui, "point_cloud_units", &mut unit, &units));
if unit != was
&& let Some(u) = units.get(unit)
{
edit = Some(json!({ "cloud": c.id, "units": u }));
}
// The ways of colouring, then every property of the file.
let properties: Vec<&str> = c.property_names().filter(|n| !COORDINATES.contains(n)).collect();
let labels: Vec<&str> = COLOR_BY.iter().map(|(_, l)| *l).chain(properties.iter().copied()).collect();
let was = match c.color_by {
ColorBy::Property => {
c.color_property.as_deref().and_then(|n| properties.iter().position(|p| *p == n)).map_or(0, |k| COLOR_BY.len() + k)
}
by => COLOR_BY.iter().position(|(b, _)| *b == by).unwrap_or(0),
};
let mut by = was;
ui_kit::row(ui, crate::i18n::tr("ui.point_cloud_color_by"), |ui| ui_kit::dropdown(ui, "point_cloud_color_by", &mut by, &labels));
if by != was
&& let Some((b, _)) = COLOR_BY.get(by)
{
edit = Some(json!({ "cloud": c.id, "color_by": b.name() }));
if by != was {
let name = COLOR_BY.get(by).map(|(b, _)| b.name()).or_else(|| properties.get(by - COLOR_BY.len()).copied());
if let Some(name) = name {
edit = Some(json!({ "cloud": c.id, "color_by": name }));
}
}
let colored = c.colored_property();
let legend_shown = colored.and_then(|n| label(&c, n));
match c.color_by {
ColorBy::File if c.colors.is_empty() => {
ui_kit::row(ui, "", |ui| ui.weak(crate::i18n::tr("ui.point_cloud_no_file_colors")));
@@ -147,33 +318,201 @@ pub(crate) fn panel(app: &mut SolveApp, ctx: &egui::Context) {
edit = Some(json!({ "cloud": c.id, "color": rgb.map(|v| f64::from(v) / 255.0) }));
}
}
ColorBy::Height => {
let automatic = c.height_range.is_none();
if ui_kit::checkbox_row(ui, "point_cloud_auto_range", crate::i18n::tr("ui.point_cloud_auto_range"), automatic) {
// Off: start from the range worked out, which the fields then show.
edit = Some(json!({ "cloud": c.id, "height_range": if automatic { json!(auto) } else { Value::Null } }));
}
if let Some([mut lo, mut hi]) = c.height_range.or(auto) {
let mut changed = false;
ui.add_enabled_ui(!automatic, |ui| {
changed |= ui_kit::row(ui, crate::i18n::tr("ui.point_cloud_height_low"), |ui| {
ui_kit::length_field(ui, &mut lo, &units, decimals)
})
.changed();
changed |= ui_kit::row(ui, crate::i18n::tr("ui.point_cloud_height_high"), |ui| {
ui_kit::length_field(ui, &mut hi, &units, decimals)
})
.changed();
});
// The two heights stay apart, lowest first.
if changed && !automatic {
let hi = hi.max(lo + 1e-3);
edit = Some(json!({ "cloud": c.id, "height_range": [lo, hi] }));
ColorBy::Height | ColorBy::Property => {
let name = colored.unwrap_or_default();
if let Some(names) = legend_shown {
// A label: its legend, which also shows and hides its values.
if let Some(v) = legend(ui, &c, names) {
edit = Some(json!({ "cloud": c.id, "filters": toggled(&c, names, v) }));
}
} else if !c.complex.integer(name)
&& let Some(span) = if c.color_by == ColorBy::Height { c.span("z").or(auto) } else { c.span(name) }
{
// A ramp over what is shown, unless its range is set.
let automatic = c.height_range.is_none();
if ui_kit::checkbox_row(ui, "point_cloud_auto_range", crate::i18n::tr("ui.point_cloud_auto_range"), automatic) {
edit = Some(json!({ "cloud": c.id, "color_range": if automatic { json!(crate::point_clouds::color_range(ui.ctx(), &live)) } else { Value::Null } }));
}
// The ramp's range is a range on a track of the property, over what the
// filters keep; a double click beside it gives it back to the filters.
let shown = crate::point_clouds::color_range(ui.ctx(), &live).unwrap_or(span);
let key = colour_key(c.id);
let ask = Ask {
property: name.to_string(),
span,
loupe: range_track::loupe(ui.ctx(), &key, span),
bins: bins(ui),
paint: Paint::Ramp { property: name.to_string(), range: shown },
filters: drawn.clone(),
changes_of: None,
};
let answer = m.tracks.answer(ui.ctx(), &c, &key, ask);
let out = range_track(
ui,
&Track {
key: &key,
range: shown,
span,
integer: false,
overview: answer.as_ref().map(|a| &a.overview),
loupe: answer.as_ref().map(|a| &a.loupe),
changes: &[],
// An automatic range moves with a playing filter: its moving end
// stays in the loupe.
follow: m.play.as_ref().filter(|_| automatic).map(|p| if p.part == Part::Low { shown[0] } else { shown[1] }),
},
);
if let Some(range) = out.range {
edit = Some(json!({ "cloud": c.id, "color_range": range }));
} else if out.whole {
edit = Some(json!({ "cloud": c.id, "color_range": Value::Null }));
}
}
}
}
// Filters: only what every one of them keeps is shown.
let mut filters = c.filters.clone();
let mut changed = false;
let mut removed = None;
for (k, f) in c.filters.iter().enumerate() {
let span = c.span(&f.property);
let integer = c.complex.integer(&f.property);
// Play or stop, by ▶ or Space on the track.
let mut toggle = None;
ui.horizontal(|ui| {
ui.label(crate::i18n::tr_dyn(&format!("Filter: {}", f.property)));
let playing = m.play.as_ref().is_some_and(|p| p.filter == k);
if let (Some(range), Some(span)) = (f.range(), span)
&& ui_kit::button(
ui,
if playing {
format!("⏸ {}", crate::i18n::tr_label("Pause"))
} else {
format!("▶ {}", crate::i18n::tr_label("Play"))
},
ui_kit::Button::Small,
)
.clicked()
{
toggle = Some((m.playing(c.id, k).unwrap_or(range), span));
}
if ui_kit::button(ui, "×", ui_kit::Button::Small).clicked() {
removed = Some(k);
}
});
if k >= DRAWN_FILTERS {
ui.weak(crate::i18n::tr_dyn(&format!("Not drawn: the view applies {DRAWN_FILTERS} filters at most")));
}
match (&f.keep, span) {
(Keep::Values { .. }, _) => match label(&c, &f.property) {
Some(names) if legend_shown.is_some_and(|l| l.property == names.property) => {
ui.weak(crate::i18n::tr_label("Shown and hidden in the legend above"));
}
Some(names) => {
if let Some(v) = legend(ui, &c, names) {
edit = Some(json!({ "cloud": c.id, "filters": toggled(&c, names, v) }));
}
}
None => {}
},
(Keep::Range { range }, Some(span)) => {
let shown = m.playing(c.id, k).unwrap_or(*range);
let key = filter_key(c.id, k);
// The track shows what the other filters keep.
let others = drawn.iter().enumerate().filter(|(j, _)| *j != k).map(|(_, f)| f.clone()).collect();
let ask = Ask {
property: f.property.clone(),
span,
loupe: range_track::loupe(ui.ctx(), &key, span),
bins: bins(ui),
paint: paint.clone(),
filters: others,
changes_of: changes_of.clone(),
};
let answer = m.tracks.answer(ui.ctx(), &c, &key, ask);
let moving = m.play.as_ref().filter(|p| p.filter == k).map(|p| if p.part == Part::Low { shown[0] } else { shown[1] });
let out = range_track(
ui,
&Track {
key: &key,
range: shown,
span,
integer,
overview: answer.as_ref().map(|a| &a.overview),
loupe: answer.as_ref().map(|a| &a.loupe),
changes: answer.as_ref().map_or(&[][..], |a| &a.changes[..]),
follow: moving,
},
);
let moved = out.range.or(out.whole.then_some(span));
if let Some(range) = moved
&& let Some(slot) = filters.get_mut(k)
{
slot.keep = Keep::Range { range };
changed = true;
// Moving it by hand stops it playing.
if m.play.as_ref().is_some_and(|p| p.filter == k) {
m.play = None;
}
}
if out.play {
toggle = Some((moved.unwrap_or(shown), span));
}
}
(Keep::Range { .. }, None) => {}
}
if let Some((range, span)) = toggle {
if m.play.as_ref().is_some_and(|p| p.filter == k) {
stop_play = true;
} else {
// Another filter playing stops where it is.
if let Some(p) = m.play.take()
&& let Some(slot) = filters.get_mut(p.filter)
{
slot.keep = Keep::Range { range: p.now };
changed = true;
}
let part = range_track::last_part(ui.ctx(), &filter_key(c.id, k));
m.play = Some(Play::new(k, part, range, span, now));
}
}
}
if let Some(k) = removed {
filters.remove(k);
changed = true;
m.play = None;
}
let all: Vec<&str> = c.property_names().collect();
if c.filters.len() < MAX_FILTERS {
let mut add_labels = vec![crate::i18n::tr_label("Add filter…")];
add_labels.extend(all.iter().copied());
let mut pick = 0;
ui_kit::row(ui, crate::i18n::tr_label("Filter by"), |ui| ui_kit::dropdown(ui, "point_cloud_add_filter", &mut pick, &add_labels));
if let Some(name) = all.get(pick.wrapping_sub(1)) {
// A label keeps all its values at first, anything else its whole span.
let keep = match (label(&c, name), c.span(name)) {
(Some(names), _) => Some(Keep::Values { values: names.values.iter().map(|(v, _)| *v).collect() }),
(None, Some(span)) => Some(Keep::Range { range: span }),
(None, None) => None,
};
if let Some(keep) = keep {
filters.push(Filter { property: name.to_string(), keep });
changed = true;
}
}
}
if changed {
edit = Some(json!({ "cloud": c.id, "filters": filters_json(&filters) }));
}
});
// A filter that stops playing is written where it stopped.
if stop_play && let Some(p) = m.play.take() {
let mut filters = c.filters.clone();
if let Some(f) = filters.get_mut(p.filter) {
f.keep = Keep::Range { range: p.now };
edit = Some(json!({ "cloud": c.id, "filters": filters_json(&filters) }));
}
}
if let Some(p) = edit
&& app.run("pointcloud.edit", p).is_ok()
{
@@ -225,4 +564,96 @@ mod tests {
assert_eq!(h.app.session.doc.point_clouds.len(), 1);
let _ = std::fs::remove_dir_all(dir);
}
/// A PLY file of points along x, `t` running 0 to 10 by halves, a layer every 2: the layer
/// changes at t = 2, 4, 6 and 8.
fn layered_points(dir: &std::path::Path) -> std::path::PathBuf {
let mut ply = String::from(
"ply\nformat ascii 1.0\nelement vertex 21\nproperty float x\nproperty float y\nproperty float z\nproperty float t\nproperty int layer\nend_header\n",
);
for i in 0..=20 {
let t = f64::from(i) / 2.0;
ply += &format!("{t} 0 0 {t} {}\n", (i / 4).min(4));
}
let path = dir.join("layers.ply");
std::fs::write(&path, ply).unwrap();
path
}
#[test]
fn a_filter_track_draws_snaps_steps_frames_and_plays() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
let dir = std::env::temp_dir().join(format!("solidcraft-browser-track-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = layered_points(&dir);
let id = h.app.run("pointcloud.insert", json!({"path": path.to_string_lossy()})).unwrap()["cloud"].as_u64().unwrap();
h.app.run("pointcloud.edit", json!({"cloud": id, "color_by": "layer", "filters": [{"property": "t", "range": [2.0, 5.0]}]})).unwrap();
h.app.tree.cloud_panel = Some(super::CloudPanel::new(&h.app, id));
let key = super::filter_key(id, 0);
let range = |h: &crate::scenario::Harness| h.app.session.doc.point_clouds[0].filters[0].range().unwrap();
// The bars come from the worker: wait for them, and for the layer changes to snap to.
let mut bars = None;
for _ in 0..200 {
h.frames(1);
bars = crate::scenario::rect_of(&format!("range_track:{key}:bars"));
if bars.is_some() && h.app.tree.cloud_panel.as_ref().is_some_and(|m| m.tracks.answered(&key)) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
h.frames(2);
let r = bars.expect("the track is drawn");
let x = |t: f64| f64::from(r.left()) + t / 10.0 * f64::from(r.width());
let y = f64::from(r.center().y);
let drag = |h: &mut crate::scenario::Harness, a: f64, b: f64| {
h.call("ui.drag", json!({"x0": x(a), "y0": y, "x1": x(b), "y1": y, "steps": 8}));
h.frames(2);
};
// Drawn beside the range: a new range, its start snapped to the layer change at 6.
drag(&mut h, 6.04, 8.9);
let [lo, hi] = range(&h);
assert!(lo == 6.0 && (hi - 8.9).abs() < 0.05, "{lo} {hi}");
// Its end dragged near 8 snaps to it; its start, far from any change, does not.
drag(&mut h, 8.9, 7.97);
drag(&mut h, 6.0, 6.5);
let [lo, hi] = range(&h);
assert!((lo - 6.5).abs() < 0.05 && hi == 8.0, "{lo} {hi}");
// ← steps the start, touched last, to the change before it, then the one before that.
for _ in 0..2 {
h.call("ui.key", json!({"key": "ArrowLeft"}));
h.frames(2);
}
assert_eq!(range(&h), [4.0, 8.0]);
// A double click on the range frames the loupe on it; beside it, the whole span.
h.call("ui.click", json!({"x": x(6.0), "y": y, "double": true}));
h.frames(2);
let [a, b] = super::range_track::loupe(&h.ctx, &key, [0.0, 10.0]);
assert!(a > 3.0 && a < 4.0 && b > 8.0 && b < 9.0, "{a} {b}");
// A double click on the strip above shows the whole span again; beside the range, the
// range becomes the whole span.
// (Past the double click's time, so that it is not a third click.)
h.frames(30);
let strip = crate::scenario::rect_of(&format!("range_track:{key}:strip")).unwrap();
h.call("ui.click", json!({"x": f64::from(strip.center().x), "y": f64::from(strip.center().y), "double": true}));
h.frames(2);
assert_eq!(super::range_track::loupe(&h.ctx, &key, [0.0, 10.0]), [0.0, 10.0]);
h.frames(30);
h.call("ui.click", json!({"x": x(9.5), "y": y, "double": true}));
h.frames(2);
assert_eq!(range(&h), [0.0, 10.0]);
// Space plays the part touched last (here the end, unrolling), written once when it stops.
drag(&mut h, 10.0, 7.0);
assert!((range(&h)[1] - 7.0).abs() < 0.05);
h.call("ui.key", json!({"key": "Space"}));
h.frames(2);
assert!(h.app.tree.cloud_panel.as_ref().unwrap().play.is_some());
h.frames(20);
h.call("ui.key", json!({"key": "Space"}));
h.frames(2);
assert!(h.app.tree.cloud_panel.as_ref().unwrap().play.is_none());
let [lo, hi] = range(&h);
assert!(lo == 0.0 && hi > 7.0 && hi < 10.0, "played on from 7: {hi}");
let _ = std::fs::remove_dir_all(dir);
}
}
+580
View File
@@ -0,0 +1,580 @@
//! A range on a track, as in an audio editor. The track draws how much of the cloud lies at each
//! value (its bars) under the range. A thin strip above shows the whole span, with the part the
//! track shows (its loupe) framed: the strip is the same range widget, its range being the loupe.
//!
//! Gestures: drag a range's end or its band to move them; drag elsewhere to draw a new range;
//! Shift-click to bring the nearer end to the pointer; double-click the band to frame the loupe on
//! it, elsewhere to ask for the whole (`Outcome::whole`); the wheel or a pinch zooms the loupe
//! around the pointer, a sideways scroll moves it. An end snaps to a value where the colour
//! changes when that value stands alone at this zoom (nothing else within twice the reach), and
//! a whole-numbered property's ends to whole numbers; ⌥ moves ten times slower, without
//! snapping. With the keyboard, once the track is touched: ← and → bring the part touched last to
//! the previous or next change (one pixel with ⌥ or where there is none), Space asks to play it.
//! The ends and the length are fields too; a field's value may be typed relative, as `+30`.
use egui::{Color32, Mesh, Pos2, Rect, Sense, Stroke, Ui, vec2};
use crate::ui_kit;
use crate::theme::Tokens;
/// The part of a range a gesture moves, and that playing moves.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) enum Part {
Low,
#[default]
High,
Band,
}
/// How much lies at each value of an axis: `slots[s][b]`, stacked in slot order, slot `s` in
/// `colours[s]`; or, with `ramp`, one slot painted at each value with the ramp over that range.
#[derive(Clone, Debug, Default, PartialEq)]
pub(super) struct Bars {
pub axis: [f64; 2],
pub slots: Vec<Vec<f64>>,
pub colours: Vec<Color32>,
pub ramp: Option<[f64; 2]>,
}
/// What a track shows and snaps to.
pub(super) struct Track<'a> {
/// Names the track among all of them.
pub key: &'a str,
pub range: [f64; 2],
pub span: [f64; 2],
pub integer: bool,
pub overview: Option<&'a Bars>,
pub loupe: Option<&'a Bars>,
/// Values the ends snap to and the arrows step through, lowest first.
pub changes: &'a [f64],
/// A value moving on its own (a range playing), kept in the loupe.
pub follow: Option<f64>,
}
/// What a frame's input asks for.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(super) struct Outcome {
/// The range moved.
pub range: Option<[f64; 2]>,
/// The whole: a double click beside the range.
pub whole: bool,
/// Play or stop: Space.
pub play: bool,
/// The range was touched by a gesture, a field or a key.
pub touched: bool,
}
/// What a drag moves: an end, the band, or a new range from where it started.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Grab {
Low,
High,
Band,
New(f64),
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct Drag {
/// On the strip (moving the loupe) or the track (moving the range).
strip: bool,
grab: Grab,
/// The range when it started, and the value under the pointer now (slowed by ⌥).
start: [f64; 2],
at: f64,
from: f64,
last_x: f32,
}
/// What the track keeps between frames.
#[derive(Clone, Copy, Debug, Default)]
struct State {
loupe: Option<[f64; 2]>,
drag: Option<Drag>,
last: Part,
/// Where an end snapped during the drag.
snapped: Option<f64>,
}
/// The loupe's narrowest width, as a fraction of the span.
const MIN_LOUPE: f64 = 1e-6;
/// Zoom per point of wheel scroll.
const ZOOM: f64 = 0.004;
/// How close to an end a press grabs it, and an end snaps, in points.
const REACH: f32 = 6.0;
/// ⌥'s slowing.
const FINE: f64 = 0.1;
const STRIP: f32 = 12.0;
const RULER: f32 = 14.0;
const BARS: f32 = 40.0;
fn id(key: &str) -> egui::Id {
egui::Id::new(("sc_range_track", key))
}
fn state(ctx: &egui::Context, key: &str) -> State {
ctx.data(|d| d.get_temp(id(key))).unwrap_or_default()
}
fn whole(span: [f64; 2]) -> [f64; 2] {
[span[0], span[1].max(span[0] + 1e-9)]
}
/// The part of `span` a track shows now.
pub(super) fn loupe(ctx: &egui::Context, key: &str, span: [f64; 2]) -> [f64; 2] {
state(ctx, key).loupe.map_or(whole(span), |l| fit(l, whole(span)))
}
/// The part of a track's range touched last.
pub(super) fn last_part(ctx: &egui::Context, key: &str) -> Part {
state(ctx, key).last
}
/// `l` moved and narrowed to lie within `span`, at least its narrowest.
fn fit([a, b]: [f64; 2], span: [f64; 2]) -> [f64; 2] {
let full = span[1] - span[0];
let w = (b - a).clamp(full * MIN_LOUPE, full);
let left = (a + (b - a - w) / 2.0).clamp(span[0], span[1] - w);
[left, left + w]
}
/// The change nearest `v` within `r`, when no other change is within `2r` of it.
fn lone_change(changes: &[f64], v: f64, r: f64) -> Option<f64> {
let i = changes.partition_point(|c| *c < v);
let near = [i.checked_sub(1), Some(i)].into_iter().flatten().filter_map(|k| changes.get(k).map(|c| (k, *c)));
let (k, c) = near.min_by(|a, b| (a.1 - v).abs().total_cmp(&(b.1 - v).abs()))?;
let alone = k.checked_sub(1).and_then(|j| changes.get(j)).is_none_or(|p| c - p > 2.0 * r) && changes.get(k + 1).is_none_or(|n| n - c > 2.0 * r);
((c - v).abs() <= r && alone).then_some(c)
}
/// The changes standing alone at this zoom within `[a, b]`, at most `n`.
fn lone_changes(changes: &[f64], [a, b]: [f64; 2], r: f64, n: usize) -> Vec<f64> {
let (i, j) = (changes.partition_point(|c| *c < a), changes.partition_point(|c| *c <= b));
(i..j)
.filter(|k| {
let c = changes.get(*k).copied().unwrap_or(f64::NAN);
k.checked_sub(1).and_then(|p| changes.get(p)).is_none_or(|p| c - p > 2.0 * r) && changes.get(k + 1).is_none_or(|q| q - c > 2.0 * r)
})
.filter_map(|k| changes.get(k).copied())
.take(n)
.collect()
}
/// The next change after `v` (`dir` 1) or before it (-1).
fn next_change(changes: &[f64], v: f64, dir: f64) -> Option<f64> {
let eps = v.abs().max(1.0) * 1e-12;
if dir > 0.0 {
changes.get(changes.partition_point(|c| *c <= v + eps)).copied()
} else {
changes.partition_point(|c| *c < v - eps).checked_sub(1).and_then(|k| changes.get(k)).copied()
}
}
/// Bar and tick steps: 1, 2 or 5 times a power of ten, at least `min` apart.
fn nice_step(min: f64) -> f64 {
let k = 10f64.powf(min.max(1e-300).log10().floor());
[1.0, 2.0, 5.0, 10.0].into_iter().map(|m| m * k).find(|s| *s >= min).unwrap_or(10.0 * k)
}
/// Paint `bars` within `rect`, showing `axis`; each column scaled to the tallest shown.
fn paint_bars(painter: &egui::Painter, rect: Rect, axis: [f64; 2], bars: &Bars, fill: Color32, ink: Color32) {
painter.rect_filled(rect, 2.0, fill);
let n = bars.slots.first().map_or(0, Vec::len);
if n == 0 || !(bars.axis[1] > bars.axis[0]) {
return;
}
let width = (bars.axis[1] - bars.axis[0]) / n as f64;
let x_of = |v: f64| rect.left() + ((v - axis[0]) / (axis[1] - axis[0])) as f32 * rect.width();
let total = |b: usize| bars.slots.iter().filter_map(|s| s.get(b)).sum::<f64>();
let shown: Vec<usize> = (0..n)
.filter(|b| {
let lo = bars.axis[0] + *b as f64 * width;
lo + width >= axis[0] && lo <= axis[1]
})
.collect();
let peak = shown.iter().map(|b| total(*b)).fold(0.0, f64::max);
if !(peak > 0.0) {
return;
}
let mut mesh = Mesh::default();
for b in shown {
let lo = bars.axis[0] + b as f64 * width;
let (x0, x1) = (x_of(lo).max(rect.left()), x_of(lo + width).min(rect.right()).max(x_of(lo).max(rect.left()) + 1.0));
let mut y = rect.bottom();
for (s, slot) in bars.slots.iter().enumerate() {
let h = (slot.get(b).copied().unwrap_or(0.0) / peak) as f32 * (rect.height() - 2.0);
if h <= 0.0 {
continue;
}
let colour = match bars.ramp {
Some([a, z]) if z > a => {
crate::theme::model_color(solidcraft_engine::doc::point_cloud::height_ramp((lo + width / 2.0 - a) / (z - a)))
}
_ => bars.colours.get(s).copied().unwrap_or(ink),
};
mesh.add_colored_rect(Rect::from_min_max(Pos2::new(x0, y - h), Pos2::new(x1, y)), colour);
y -= h;
}
}
painter.add(mesh);
}
/// The range within `span` after this frame's input, and what else the input asks for.
pub(super) fn range_track(ui: &mut Ui, track: &Track) -> Outcome {
let t = Tokens::get();
let ctx = ui.ctx().clone();
let mut st = state(&ctx, track.key);
let span = whole(track.span);
let mut out = Outcome::default();
let mut range = track.range;
let mut view = st.loupe.map_or(span, |l| fit(l, span));
if let Some(f) = track.follow
&& (f < view[0] || f > view[1])
{
let w = view[1] - view[0];
view = fit([f - 0.1 * w, f + 0.9 * w], span);
st.loupe = Some(view);
}
let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), STRIP + RULER + BARS + 4.0), Sense::click_and_drag());
let inner = rect.shrink2(vec2(REACH, 0.0));
let strip = Rect::from_min_size(inner.min, vec2(inner.width(), STRIP));
let ruler = Rect::from_min_size(Pos2::new(inner.left(), strip.bottom() + 2.0), vec2(inner.width(), RULER));
let bars = Rect::from_min_size(Pos2::new(inner.left(), ruler.bottom() + 2.0), vec2(inner.width(), BARS));
// Named for agents and tests: the strip and the bars.
crate::scenario::publish_rect(&format!("range_track:{}:strip", track.key), strip);
crate::scenario::publish_rect(&format!("range_track:{}:bars", track.key), bars);
let on_strip = |p: Pos2| p.y < strip.bottom() + 1.0;
let axis_of = |strip: bool, view: [f64; 2]| if strip { span } else { view };
let x_of = |axis: [f64; 2], v: f64| inner.left() + ((v - axis[0]) / (axis[1] - axis[0])) as f32 * inner.width();
let v_of = |axis: [f64; 2], x: f32| axis[0] + f64::from((x - inner.left()) / inner.width().max(1.0)) * (axis[1] - axis[0]);
let per_point = (view[1] - view[0]) / f64::from(inner.width().max(1.0));
let reach = f64::from(REACH) * per_point;
let mods = ui.input(|i| i.modifiers);
let snap = |v: f64, fine: bool, snapped: &mut Option<f64>| {
let s = if fine { None } else { lone_change(track.changes, v, reach) };
*snapped = s;
let v = s.unwrap_or(v);
if track.integer { v.round() } else { v }
};
let grab_at = |strip: bool, x: f32, view: [f64; 2], range: [f64; 2]| {
let (axis, [a, b]) = (axis_of(strip, view), if strip { view } else { range });
let (xa, xb) = (x_of(axis, a), x_of(axis, b));
if (x - xa).abs().min((x - xb).abs()) <= REACH {
Some(if (x - xa).abs() < (x - xb).abs() { Grab::Low } else { Grab::High })
} else if x > xa && x < xb {
Some(Grab::Band)
} else {
None
}
};
// Zoom and move the loupe.
if response.hovered()
&& let Some(p) = response.hover_pos()
{
let (zoom, sideways, pinch) = crate::navigation::axis_wheel(&ctx, rect.height());
if sideways != 0.0 {
let d = -f64::from(sideways) * per_point;
view = fit([view[0] + d, view[1] + d], span);
st.loupe = Some(view);
}
if zoom != 0.0 || pinch != 1.0 {
let k = (-f64::from(zoom) * ZOOM).exp() / f64::from(pinch);
let at = v_of(axis_of(on_strip(p), view), p.x).clamp(view[0], view[1]);
view = fit([at - (at - view[0]) * k, at + (view[1] - at) * k], span);
st.loupe = Some(view);
}
}
if response.drag_started()
&& let Some(p) = ui.input(|i| i.pointer.press_origin())
{
response.request_focus();
let strip = on_strip(p);
let axis = axis_of(strip, view);
let v = v_of(axis, p.x);
let start = if strip { view } else { range };
let grab = match grab_at(strip, p.x, view, range) {
Some(g) => g,
None if mods.shift => {
if (v - start[0]).abs() < (v - start[1]).abs() {
Grab::Low
} else {
Grab::High
}
}
None => Grab::New(if strip { v } else { snap(v, mods.alt, &mut st.snapped) }),
};
if !strip {
st.last = match grab {
Grab::Low => Part::Low,
Grab::Band => Part::Band,
Grab::High | Grab::New(_) => Part::High,
};
out.touched = true;
}
st.drag = Some(Drag { strip, grab, start, at: v, from: v, last_x: p.x });
}
if response.dragged()
&& let (Some(mut d), Some(p)) = (st.drag, response.interact_pointer_pos())
{
let axis = if d.strip { span } else { view };
let scale = (axis[1] - axis[0]) / f64::from(inner.width().max(1.0));
d.at += f64::from(p.x - d.last_x) * scale * if mods.alt { FINE } else { 1.0 };
d.last_x = p.x;
let mut snapped = None;
let mut put = |v: f64| if d.strip { v } else { snap(v.clamp(span[0], span[1]), mods.alt, &mut snapped) };
let [a, b] = d.start;
let moved = match d.grab {
Grab::Band => {
let w = b - a;
let mut shift = d.at - d.from;
if !d.strip {
// The band snaps by whichever end lands on a change.
let (la, lb) = (put(a + shift) - (a + shift), put(b + shift) - (b + shift));
shift += if la.abs() > 0.0 && (lb == 0.0 || la.abs() <= lb.abs()) { la } else { lb };
}
let lo = (a + shift).clamp(span[0], span[1] - w);
[lo, lo + w]
}
Grab::New(anchor) => {
let v = put(d.at);
[anchor.min(v), anchor.max(v)]
}
Grab::Low | Grab::High => {
let v = put(d.at);
let other = if d.grab == Grab::Low { b } else { a };
// An end dragged past the other becomes the other.
let low = v < other;
if !d.strip {
st.last = if low { Part::Low } else { Part::High };
}
d.grab = if low { Grab::Low } else { Grab::High };
d.start = if low { [a, other] } else { [other, b] };
[v.min(other), v.max(other)]
}
};
st.snapped = snapped;
if d.strip {
view = fit(moved, span);
st.loupe = Some(view);
} else {
range = moved;
out.touched = true;
}
st.drag = Some(d);
}
if response.drag_stopped() {
st.drag = None;
st.snapped = None;
}
if response.clicked()
&& let Some(p) = response.interact_pointer_pos()
{
response.request_focus();
let strip = on_strip(p);
let v = v_of(axis_of(strip, view), p.x);
if strip && grab_at(true, p.x, view, range).is_none() {
// A click beside the loupe brings it there.
let w = view[1] - view[0];
view = fit([v - w / 2.0, v + w / 2.0], span);
st.loupe = Some(view);
} else if !strip && mods.shift && grab_at(false, p.x, view, range).is_none() {
let v = snap(v, mods.alt, &mut st.snapped);
let low = (v - range[0]).abs() < (v - range[1]).abs();
range = if low { [v.min(range[1]), range[1]] } else { [range[0], v.max(range[0])] };
st.last = if low { Part::Low } else { Part::High };
out.touched = true;
}
}
if response.double_clicked()
&& let Some(p) = response.interact_pointer_pos()
{
if on_strip(p) {
view = span;
st.loupe = None;
} else if grab_at(false, p.x, view, range) == Some(Grab::Band) {
let m = (range[1] - range[0]) * 0.08;
view = fit([range[0] - m, range[1] + m], span);
st.loupe = Some(view);
} else {
out.whole = true;
out.touched = true;
}
}
// The keyboard, while the track has it.
if response.has_focus() {
ui.memory_mut(|m| m.set_focus_lock_filter(response.id, egui::EventFilter { horizontal_arrows: true, ..Default::default() }));
let (left, right, space) = ui.input_mut(|i| {
(
i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowLeft) || i.consume_key(egui::Modifiers::ALT, egui::Key::ArrowLeft),
i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowRight) || i.consume_key(egui::Modifiers::ALT, egui::Key::ArrowRight),
i.consume_key(egui::Modifiers::NONE, egui::Key::Space),
)
});
out.play = space;
if left || right {
let dir = if right { 1.0 } else { -1.0 };
let step = |v: f64| {
let n = if mods.alt { None } else { next_change(track.changes, v, dir) };
let v = n.unwrap_or(v + dir * per_point);
let v = if track.integer && n.is_none() { (v + dir * 0.5).round() } else { v };
v.clamp(span[0], span[1])
};
range = match st.last {
Part::Low => [step(range[0]).min(range[1]), range[1]],
Part::High => [range[0], step(range[1]).max(range[0])],
Part::Band => {
let lead = if right { range[1] } else { range[0] };
let w = range[1] - range[0];
let lo = (range[0] + step(lead) - lead).clamp(span[0], span[1] - w);
[lo, lo + w]
}
};
let moving = match st.last {
Part::Low => range[0],
Part::High => range[1],
Part::Band => {
if right {
range[1]
} else {
range[0]
}
}
};
if moving < view[0] || moving > view[1] {
let w = view[1] - view[0];
view = fit([moving - w / 2.0, moving + w / 2.0], span);
st.loupe = Some(view);
}
out.touched = true;
}
}
// The pointer says what a press would take: an end, the band, or a new range beside them.
if let Some(p) = response.hover_pos().or_else(|| st.drag.and_then(|_| response.interact_pointer_pos())) {
let icon = match st.drag.map(|d| d.grab) {
Some(Grab::Band) => egui::CursorIcon::Grabbing,
Some(_) => egui::CursorIcon::ResizeHorizontal,
None => match grab_at(on_strip(p), p.x, view, range) {
Some(Grab::Band) => egui::CursorIcon::Grab,
Some(_) => egui::CursorIcon::ResizeHorizontal,
None if on_strip(p) => egui::CursorIcon::PointingHand,
None => egui::CursorIcon::Crosshair,
},
};
ctx.set_cursor_icon(icon);
}
// The strip: the whole span, the range, and the loupe framed.
let painter = ui.painter_at(rect);
let empty = Bars::default();
paint_bars(&painter, strip, span, track.overview.unwrap_or(&empty), t.control_fill, t.text_dim);
let band = |r: Rect, axis: [f64; 2], [a, b]: [f64; 2]| {
Rect::from_min_max(Pos2::new(x_of(axis, a).max(r.left()), r.top()), Pos2::new(x_of(axis, b).min(r.right()), r.bottom()))
};
painter.rect_filled(band(strip, span, range), 0.0, t.form_primary.gamma_multiply(0.35));
let frame = band(strip, span, view);
let shade = t.panel.gamma_multiply(0.7);
painter.rect_filled(Rect::from_min_max(strip.min, Pos2::new(frame.left(), strip.bottom())), 0.0, shade);
painter.rect_filled(Rect::from_min_max(Pos2::new(frame.right(), strip.top()), strip.max), 0.0, shade);
painter.rect_stroke(frame, 1.0, Stroke::new(1.5, t.text), egui::StrokeKind::Inside);
// The ruler: steps of 1, 2 or 5 times a power of ten, at least 60 points apart.
let step = nice_step(60.0 * per_point);
let decimals = (-step.log10().floor()).clamp(0.0, 9.0) as usize;
let font = egui::FontId::proportional(t.font_micro);
let mut v = (view[0] / step).ceil() * step;
for _ in 0..64 {
if v > view[1] {
break;
}
let x = x_of(view, v);
painter.line_segment([Pos2::new(x, ruler.bottom() - 5.0), Pos2::new(x, ruler.bottom())], Stroke::new(1.0, t.border));
painter.text(Pos2::new(x + 2.0, ruler.top()), egui::Align2::LEFT_TOP, format!("{v:.decimals$}"), font.clone(), t.text_dim);
v += step;
}
for c in lone_changes(track.changes, view, reach, 512) {
let x = x_of(view, c);
painter.line_segment([Pos2::new(x, ruler.bottom() - 3.0), Pos2::new(x, ruler.bottom() + 1.0)], Stroke::new(1.0, t.warning));
}
// The bars, what lies outside the range shaded, and the range's ends.
paint_bars(&painter, bars, view, track.loupe.unwrap_or(&empty), t.control_fill, t.text_dim);
let r = band(bars, view, range);
painter.rect_filled(Rect::from_min_max(bars.min, Pos2::new(r.left(), bars.bottom())), 0.0, shade);
painter.rect_filled(Rect::from_min_max(Pos2::new(r.right(), bars.top()), bars.max), 0.0, shade);
painter.rect_filled(r, 0.0, t.form_primary.gamma_multiply(0.18));
for (v, part) in [(range[0], Part::Low), (range[1], Part::High)] {
if v < view[0] || v > view[1] {
continue;
}
let x = x_of(view, v);
let width = if response.has_focus() && st.last == part { 3.0 } else { 2.0 };
painter.line_segment([Pos2::new(x, bars.top()), Pos2::new(x, bars.bottom())], Stroke::new(width, t.form_primary));
painter.add(egui::Shape::convex_polygon(
vec![Pos2::new(x - 5.0, bars.top()), Pos2::new(x + 5.0, bars.top()), Pos2::new(x, bars.top() + 6.0)],
t.form_primary,
Stroke::NONE,
));
}
if let (Some(s), Some(_)) = (st.snapped, st.drag) {
let x = x_of(view, s);
painter.line_segment([Pos2::new(x, bars.top()), Pos2::new(x, bars.bottom())], Stroke::new(1.0, t.warning));
}
if response.has_focus() && st.last == Part::Band {
painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.form_primary), egui::StrokeKind::Inside);
}
// The ends and the length as numbers.
let decimals = if track.integer { 0 } else { (-(per_point.max(1e-12)).log10()).ceil().clamp(0.0, 6.0) as usize };
let speed = if track.integer { 1.0 } else { per_point };
ui.horizontal(|ui| {
let field = |ui: &mut Ui, v: &mut f64| ui_kit::track_number(ui, v, speed, decimals).changed();
let (mut lo, mut hi) = (range[0], range[1]);
let mut length = hi - lo;
let changed_lo = field(ui, &mut lo);
ui.label("…");
let changed_hi = field(ui, &mut hi);
ui.weak("↔");
let changed_length = field(ui, &mut length);
let round = |v: f64| if track.integer { v.round() } else { v };
if changed_lo {
range = [round(lo).min(range[1]), range[1]];
st.last = Part::Low;
} else if changed_hi {
range = [range[0], round(hi).max(range[0])];
st.last = Part::High;
} else if changed_length {
range = [range[0], range[0] + round(length).max(0.0)];
st.last = Part::High;
}
if changed_lo || changed_hi || changed_length {
out.touched = true;
}
});
ctx.data_mut(|d| d.insert_temp(id(track.key), st));
out.range = (range != track.range).then_some(range);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_change_snaps_only_when_it_stands_alone_at_this_zoom() {
let changes = [0.0, 10.0, 11.0, 30.0];
assert_eq!(lone_change(&changes, 29.0, 2.0), Some(30.0));
assert_eq!(lone_change(&changes, 10.4, 2.0), None, "10 and 11 are too close to tell apart");
assert_eq!(lone_change(&changes, 10.4, 0.45), Some(10.0));
assert_eq!(lone_changes(&changes, [0.0, 40.0], 2.0, 10), vec![0.0, 30.0]);
assert_eq!(
(next_change(&changes, 10.0, 1.0), next_change(&changes, 10.0, -1.0), next_change(&changes, 30.0, 1.0)),
(Some(11.0), Some(0.0), None)
);
assert_eq!(nice_step(3.2), 5.0);
assert_eq!(nice_step(0.07), 0.1);
}
}
+4 -5
View File
@@ -8,10 +8,9 @@ use std::sync::{Arc, Mutex};
use egui_wgpu::wgpu;
pub use solidcraft_gpu::view::{
AccessMap, BlockBuilder, BlockBytes, BlockTris, CLOUD_POINT_SIZE, DrawStats, DrawStatsHandle, Frame, GpuImage, GpuPoints, GpuScene, GpuTarget,
INSTANCE_SIZE, InstanceLook, InstancedMesh, LINE_SIZE, LOD_PX, MAX_PALETTE, PBR_SIZE, Palette, PaletteIndex, PlannedBody, Renderer, SHARED_SIZE,
SceneBlock, SceneSlot, SharedTris, TRI_SIZE, TriClass, UploadError, cloud_point, extend_runs, level_for, lifted_width, placement_scale,
runs_within, supersampled_size,
AccessMap, BlockBuilder, BlockBytes, BlockTris, DrawStats, DrawStatsHandle, Frame, GpuImage, GpuScene, GpuTarget, INSTANCE_SIZE, InstanceLook,
InstancedMesh, LINE_SIZE, LOD_PX, MAX_PALETTE, PBR_SIZE, Palette, PaletteIndex, PlannedBody, Renderer, SHARED_SIZE, SceneBlock, SceneSlot,
SharedTris, TRI_SIZE, TriClass, UploadError, extend_runs, level_for, lifted_width, placement_scale, runs_within, supersampled_size,
};
/// A frame of the viewport as an egui paint callback.
@@ -454,7 +453,7 @@ fn offscreen(
clip: [0.0; 4],
cap: [0.0; 4],
images: Vec::new(),
points: Vec::new(),
clouds: Vec::new(),
analysis,
analysis_colors,
access: None,
+33
View File
@@ -295,6 +295,39 @@ pub(crate) fn pinch_factor(input: &egui::InputState, options: egui::InputOptions
factor
}
/// The wheel and pinch of this frame for a widget that zooms and moves along one axis itself (a
/// range track), claimed so that nothing under it moves as well: the wheel's vertical points
/// (zoom), its sideways points (the content moves by them) and the pinch factor. Units and
/// modifier routing are egui's, as for the camera.
pub(crate) fn axis_wheel(ctx: &egui::Context, page_height: f32) -> (f32, f32, f32) {
let options = ctx.options(|o| o.input_options);
let (zoom, sideways, pinch) = ctx.input(|i| {
let (mut zoom, mut sideways) = (0.0, 0.0);
for event in &i.events {
let egui::Event::MouseWheel { unit, delta, modifiers, phase } = event else { continue };
if *phase != egui::TouchPhase::Move || modifiers.matches_any(options.zoom_modifier) {
continue;
}
let points = match unit {
egui::MouseWheelUnit::Point => *delta,
egui::MouseWheelUnit::Line => *delta * options.line_scroll_speed,
egui::MouseWheelUnit::Page => *delta * page_height,
};
let up = route_wheel_vertical(points, *modifiers, options);
zoom += up;
sideways += if up == 0.0 { points.x + points.y } else { points.x };
}
// A zoom-modified wheel, skipped above, zooms the track once, as a pinch: no chosen
// trackpad modifier turns it into anything else here.
(zoom, sideways, pinch_factor(i, options, page_height, None))
});
let (zoom, sideways) = (if zoom.is_finite() { zoom } else { 0.0 }, if sideways.is_finite() { sideways } else { 0.0 });
if zoom != 0.0 || sideways != 0.0 || pinch != 1.0 {
claim_wheel(ctx);
}
(zoom, sideways, pinch)
}
/// Give this frame's wheel to the widget under the pointer that uses it itself (a value field
/// stepping its number, `value_step`): no scroll area or camera under it moves as well.
pub(crate) fn claim_wheel(ctx: &egui::Context) {
+194 -57
View File
@@ -1,90 +1,191 @@
//! Point clouds (#5151): with the GPU viewport their points are dots in the 3D pass (depth-tested
//! with the model); the CPU viewport draws some of them over its image.
//! Point clouds (#5151): with the GPU viewport their points are drawn in the 3D pass
//! (depth-tested with the model), coloured and filtered by the shaders; the CPU viewport draws
//! some of them over its image.
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::Arc;
use egui::{Painter, Shape};
use solidcraft_engine::doc::point_cloud::PointCloud;
use solidcraft_engine::doc::point_cloud::Keep;
use solidcraft_engine::doc::point_cloud::{ColorBy, PLAIN, PointCloud, Source, byte};
use solidcraft_engine::doc::point_cloud_distribution::color_range_by;
use solidcraft_gpu::view::{CloudFilter, CloudStyle, ColorMode, Dot, FILTERS, GpuCloud, cloud_dot};
use crate::SolveApp;
use crate::gpu::GpuPoints;
use crate::browser::point_cloud_tracks::{Ask, Paint, Tracks};
use crate::viewport::Proj;
/// Most points the CPU viewport draws of all clouds together.
const MAX_CPU_POINTS: usize = 100_000;
/// What a cloud's dots are made from: they are made again only when one of these changes.
/// What a cloud's records are made from: they are made again only when one of these changes.
/// Its colour range, filter ranges and dot size are its style, read by the shaders.
#[derive(Clone, PartialEq)]
struct Key {
points: usize,
colors: usize,
data: Vec<usize>,
len: usize,
origin: [u64; 3],
transform: [u64; 16],
size: u64,
color_by: solidcraft_engine::doc::point_cloud::ColorBy,
color_by: ColorBy,
color: Option<[u32; 3]>,
height_range: Option<[u64; 2]>,
value: Option<String>,
filtered: [Option<String>; FILTERS],
}
impl Key {
fn of(c: &PointCloud) -> Key {
let mut data = vec![Arc::as_ptr(c.points.values()).cast::<f32>() as usize, Arc::as_ptr(&c.colors.0).cast::<u8>() as usize];
data.extend(c.complex.columns.iter().map(|k| Arc::as_ptr(&k.values.0).cast::<f32>() as usize));
Key {
points: Arc::as_ptr(c.points.values()).cast::<f32>() as usize,
colors: Arc::as_ptr(&c.colors.0).cast::<u8>() as usize,
data,
len: c.len(),
origin: [c.origin.x, c.origin.y, c.origin.z].map(f64::to_bits),
transform: std::array::from_fn(|i| c.transform[i / 4][i % 4].to_bits()),
size: c.point_size.to_bits(),
color_by: c.color_by,
color: c.color.map(|k| k.map(f32::to_bits)),
height_range: c.height_range.map(|r| r.map(f64::to_bits)),
value: c.colored_property().map(str::to_string),
filtered: std::array::from_fn(|k| c.filters.get(k).map(|f| f.property.clone())),
}
}
}
thread_local! {
/// Dots by cloud id: what they were made from, their version and bytes.
static DOTS: RefCell<HashMap<u64, (Key, u64, Arc<Vec<u8>>)>> = RefCell::new(HashMap::new());
/// Records by cloud id: what they were made from, their version and bytes.
static RECORDS: RefCell<HashMap<u64, (Key, u64, Arc<Vec<u8>>)>> = RefCell::new(HashMap::new());
static VERSION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
/// By cloud id, the values its coloured property takes over what is kept, worked out off the
/// frame (one worker a cloud, so that each keeps its own geometry).
static EXTENTS: RefCell<HashMap<u64, Tracks>> = RefCell::new(HashMap::new());
}
fn dots(c: &PointCloud) -> Vec<u8> {
let mut out = Vec::with_capacity(c.len() * crate::gpu::CLOUD_POINT_SIZE);
let size = c.point_size as f32;
let shade = c.shade();
for (i, p) in c.points.iter().enumerate() {
/// The ramp's range of `c` as drawn ([`as_drawn`]): the one set, else, by a property, exactly
/// the values it takes over what the drawn filters keep. Worked out off the frame: until then,
/// the last answer, or the cloud's own bound.
pub(crate) fn color_range(ctx: &egui::Context, c: &PointCloud) -> Option<[f64; 2]> {
color_range_by(c, |property, axis| {
let ask = Ask {
property: property.to_string(),
span: axis,
loupe: axis,
bins: 1,
paint: Paint::One,
filters: c.filters.iter().take(FILTERS).cloned().collect(),
changes_of: None,
};
EXTENTS.with(|e| e.borrow_mut().entry(c.id).or_default().answer(ctx, c, "extent", ask)).and_then(|a| a.extent)
})
}
fn records(c: &PointCloud) -> Arc<Vec<u8>> {
let plain = PLAIN.map(byte);
let single = c.color.unwrap_or(PLAIN).map(byte);
let own = |i: usize| match c.color_by {
ColorBy::Single => single,
ColorBy::File => c.colors.get(i).unwrap_or(plain),
ColorBy::Height | ColorBy::Property => plain,
};
// The coloured property, then the filtered ones.
let sources: [Option<Source>; 1 + FILTERS] = std::array::from_fn(|k| {
let name = if k == 0 { c.colored_property() } else { c.filters.get(k - 1).map(|f| f.property.as_str()) };
name.and_then(|n| c.source(n))
});
let mut out = Vec::with_capacity(c.len() * solidcraft_gpu::view::DOT_SIZE);
for i in 0..c.len() {
// Placed in double precision: the cloud's origin may be far from the design's.
let local = c.origin + solidcraft_engine::geom::Vec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2]));
let w = solidcraft_engine::doc::apply_point(&c.transform, local);
crate::gpu::cloud_point(&mut out, [w.x as f32, w.y as f32, w.z as f32], shade(i), size);
let w = c.world_point(i).unwrap_or_default();
let at = [w.x as f32, w.y as f32, w.z as f32];
let values = sources.map(|s| {
s.map(|s| match s {
Source::Coordinate(k) => at.get(k).copied().unwrap_or(0.0),
Source::Column(v) => v.get(i).copied().unwrap_or(0.0),
})
});
let mut dot = Dot { at, rgb: own(i), ..Default::default() };
let [value, filtered @ ..] = values;
if let Some(v) = value {
dot.value = v;
dot.has |= 1 << FILTERS;
}
for (k, f) in filtered.into_iter().enumerate() {
if let (Some(f), Some(slot)) = (f, dot.filtered.get_mut(k)) {
*slot = f;
dot.has |= 1 << k;
}
}
cloud_dot(&mut out, &dot);
}
out
Arc::new(out)
}
/// The shown point clouds as GPU dots.
pub fn gpu_points(app: &SolveApp) -> Vec<GpuPoints> {
/// The cloud as it is drawn now: a filter `playing` in the Edit Point Cloud card at its range
/// now, so that what follows from the filters (the ramp's automatic range) follows it too.
pub(crate) fn as_drawn(c: &PointCloud, playing: impl Fn(usize) -> Option<[f64; 2]>) -> std::borrow::Cow<'_, PointCloud> {
let mut c = std::borrow::Cow::Borrowed(c);
for k in 0..c.filters.len() {
if let Some(range) = playing(k)
&& let Some(f) = c.to_mut().filters.get_mut(k)
{
f.keep = Keep::Range { range };
}
}
c
}
/// How a cloud is coloured and which of its elements are shown, as the shaders read it; a filter
/// playing in the Edit Point Cloud card shows its range now.
fn style(ctx: &egui::Context, c: &PointCloud, playing: impl Fn(usize) -> Option<[f64; 2]>) -> CloudStyle {
let c = as_drawn(c, playing);
let property = c.colored_property();
let mode = match (c.color_by, property) {
(ColorBy::File | ColorBy::Single, _) | (_, None) => ColorMode::Own,
(_, Some(name)) if c.complex.integer(name) => ColorMode::Palette,
_ => ColorMode::Ramp,
};
let mut filters = [CloudFilter::Off; FILTERS];
for (f, slot) in c.filters.iter().zip(filters.iter_mut()) {
*slot = match &f.keep {
Keep::Range { range } => CloudFilter::Range(range.map(|v| v as f32)),
Keep::Values { values } => {
// Bit `i` is the value `offset + i`, from the label's smallest value.
let offset = c.complex.names(&f.property).and_then(|n| n.values.iter().map(|(v, _)| *v).min()).unwrap_or(0);
let mask = values
.iter()
.filter_map(|v| u32::try_from(i64::from(*v) - i64::from(offset)).ok())
.filter(|i| *i < 32)
.fold(0u32, |m, i| m | 1 << i);
CloudFilter::Values { offset, mask }
}
};
}
let range = color_range(ctx, &c).unwrap_or([0.0, 1.0]).map(|v| v as f32);
CloudStyle { mode, range, filters, point_size: c.point_size as f32 }
}
/// The shown point clouds as the GPU draws them.
pub fn gpu_clouds(app: &SolveApp, ctx: &egui::Context) -> Vec<GpuCloud> {
let doc = &app.session.doc;
DOTS.with(|m| {
EXTENTS.with(|e| e.borrow_mut().retain(|id, _| doc.point_clouds.iter().any(|c| c.id == *id)));
RECORDS.with(|m| {
let mut m = m.borrow_mut();
m.retain(|id, _| doc.point_clouds.iter().any(|c| c.id == *id));
solidcraft_engine::view::shown_point_clouds(&app.session)
.map(|c| {
let key = Key::of(c);
if let Some((k, version, bytes)) = m.get(&c.id)
&& *k == key
{
return GpuPoints { id: c.id, version: *version, bytes: bytes.clone() };
}
let version = VERSION.with(|v| {
v.set(v.get() + 1);
v.get()
});
let bytes = Arc::new(dots(c));
m.insert(c.id, (key, version, bytes.clone()));
GpuPoints { id: c.id, version, bytes }
let (version, dots) = match m.get(&c.id) {
Some((k, version, records)) if *k == key => (*version, records.clone()),
_ => {
let version = VERSION.with(|v| {
v.set(v.get() + 1);
v.get()
});
let records = records(c);
m.insert(c.id, (key, version, records.clone()));
(version, records)
}
};
let playing = |k: usize| app.tree.cloud_panel.as_ref().and_then(|p| p.playing(c.id, k));
GpuCloud { id: c.id, version, dots, style: style(ctx, c, playing) }
})
.collect()
})
@@ -104,7 +205,7 @@ pub fn show(app: &SolveApp, painter: &Painter, proj: &Proj) {
let mut shapes = Vec::new();
for c in clouds {
let r = (c.point_size as f32 / painter.ctx().pixels_per_point() * 0.5).max(0.5);
let shade = c.shade();
let shade = c.shade(color_range(painter.ctx(), c));
for i in (0..c.len()).step_by(stride) {
let Some(p) = c.world_point(i) else { continue };
let Some(q) = proj.to_screen(p) else { continue };
@@ -119,7 +220,7 @@ mod tests {
use super::*;
#[test]
fn dots_follow_the_cloud_and_are_made_again_only_when_it_changes() {
fn records_are_made_again_only_when_the_cloud_changes_and_its_style_follows_it() {
let mut h = crate::scenario::Harness::new();
let dir = std::env::temp_dir().join(format!("solidcraft-ui-cloud-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
@@ -131,32 +232,68 @@ mod tests {
assert!(!crate::browser::is_open(&h.app, "point_clouds", true));
h.app.run("browser.expand_all", serde_json::json!({})).unwrap();
assert!(crate::browser::is_open(&h.app, "point_clouds", true));
let first = gpu_points(&h.app);
let first = gpu_clouds(&h.app, &h.ctx);
assert_eq!(first.len(), 1);
assert_eq!(first[0].bytes.len(), 2 * crate::gpu::CLOUD_POINT_SIZE);
// The second point: at x = 10, green, 3 px (24 eighths).
assert_eq!(&first[0].bytes[16..20], &10f32.to_le_bytes());
assert_eq!(&first[0].bytes[28..32], &[0, 255, 0, 24]);
assert_eq!(gpu_points(&h.app)[0].version, first[0].version, "unchanged: the same dots");
let size = solidcraft_gpu::view::DOT_SIZE;
assert_eq!(first[0].dots.len(), 2 * size);
// The second point: at x = 10, green.
assert_eq!(&first[0].dots[size..size + 4], &10f32.to_le_bytes());
assert_eq!(&first[0].dots[2 * size - 4..2 * size - 1], &[0, 255, 0]);
assert_eq!(first[0].style.point_size, 3.0);
assert_eq!(gpu_clouds(&h.app, &h.ctx)[0].version, first[0].version, "unchanged: the same records");
h.app.run("pointcloud.edit", serde_json::json!({"cloud": id, "color": [0, 0, 1]})).unwrap();
let blue = gpu_points(&h.app);
let blue = gpu_clouds(&h.app, &h.ctx);
assert_ne!(blue[0].version, first[0].version);
assert_eq!(&blue[0].bytes[12..15], &[0, 0, 255]);
// By height across x = 0…10 (the cloud turned so x is up): low end blue, high end red.
let up = serde_json::json!([0, 0, -1, 0, 0, 1, 0, 0, 1, 0, 0, 0]);
h.app.run("pointcloud.edit", serde_json::json!({"cloud": id, "color_by": "height", "matrix": up})).unwrap();
let ramp = gpu_points(&h.app);
let ramp_of = solidcraft_engine::doc::point_cloud::height_ramp;
assert_eq!((&ramp[0].bytes[12..15], &ramp[0].bytes[28..31]), (&ramp_of(0.0)[..], &ramp_of(1.0)[..]));
assert_eq!(&blue[0].dots[size - 4..size - 1], &[0, 0, 255]);
// By height: a ramp over the range, the range a style alone.
h.app.run("pointcloud.edit", serde_json::json!({"cloud": id, "color_by": "height", "height_range": [0, 1]})).unwrap();
let ramp = gpu_clouds(&h.app, &h.ctx);
assert_eq!((ramp[0].style.mode, ramp[0].style.range), (ColorMode::Ramp, [0.0, 1.0]));
h.app.run("pointcloud.edit", serde_json::json!({"cloud": id, "height_range": [0, 2]})).unwrap();
let wider = gpu_clouds(&h.app, &h.ctx);
assert_eq!((wider[0].version, wider[0].style.range), (ramp[0].version, [0.0, 2.0]), "a new range uploads nothing");
// Hidden, alone or with its folder: nothing drawn; the view frames it while shown.
assert!(!solidcraft_engine::view::bounds(&h.app.session).is_empty());
let first_bounds = crate::viewport::model_bounds(&h.app);
assert!(!first_bounds.is_empty());
assert_eq!(crate::viewport::model_bounds(&h.app), first_bounds, "cached sketch bounds keep the point cloud extent");
h.app.run("pointcloud.edit", serde_json::json!({"cloud": id, "visible": false})).unwrap();
assert!(gpu_points(&h.app).is_empty());
assert!(gpu_clouds(&h.app, &h.ctx).is_empty());
assert!(solidcraft_engine::view::bounds(&h.app.session).is_empty());
assert!(crate::viewport::model_bounds(&h.app).is_empty());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn an_automatic_range_is_what_is_kept_and_follows_a_playing_filter() {
let mut h = crate::scenario::Harness::new();
let dir = std::env::temp_dir().join(format!("solidcraft-ui-cloud-play-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("t.ply");
// Eleven points, x and t both running 0 to 10.
let mut ply = String::from(
"ply\nformat ascii 1.0\nelement vertex 11\nproperty float x\nproperty float y\nproperty float z\nproperty float t\nend_header\n",
);
for i in 0..=10 {
ply += &format!("{i} 0 0 {i}\n");
}
std::fs::write(&path, ply).unwrap();
let id = h.app.run("pointcloud.insert", serde_json::json!({"path": path.to_string_lossy()})).unwrap()["cloud"].as_u64().unwrap();
// Filtered by x, not by t: the range is what is kept of t, 2 to 4, once worked out.
h.app.run("pointcloud.edit", serde_json::json!({"cloud": id, "color_by": "t", "filters": [{"property": "x", "range": [2, 4]}]})).unwrap();
let range = |h: &crate::scenario::Harness, playing: Option<[f64; 2]>| style(&h.ctx, &h.app.session.doc.point_clouds[0], |_| playing).range;
let settle = |h: &crate::scenario::Harness, playing: Option<[f64; 2]>, want: [f32; 2]| {
(0..500).any(|_| {
let ok = range(h, playing) == want;
if !ok {
std::thread::sleep(std::time::Duration::from_millis(5));
}
ok
})
};
assert!(settle(&h, None, [2.0, 4.0]), "{:?}", range(&h, None));
// Played on to 8, the filter is drawn there, and the range follows it.
assert!(settle(&h, Some([2.0, 8.0]), [2.0, 8.0]), "{:?}", range(&h, Some([2.0, 8.0])));
let _ = std::fs::remove_dir_all(dir);
}
}
+1 -1
View File
@@ -2058,7 +2058,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
clip: [0.0; 4],
cap: [0.0; 4],
images: Vec::new(),
points: Vec::new(),
clouds: Vec::new(),
analysis: [0.0; 8],
analysis_colors: [0.0; 36],
access: None,
+1 -1
View File
@@ -283,7 +283,7 @@ pub(crate) fn view_frame(
clip: [0.0; 4],
cap: [0.0; 4],
images: Vec::new(),
points: Vec::new(),
clouds: Vec::new(),
analysis: [0.0; 8],
analysis_colors: crate::gpu::analysis_colors(&t.analysis),
access: None,
+30
View File
@@ -923,6 +923,36 @@ pub(crate) fn number_fields<T: egui::emath::Numeric>(
.collect()
}
/// A number on a range track, without unit: dragged `speed` a point, shown with `decimals`, typed
/// as a sum of numbers (`relative_number`).
/// Tokens: scope control/font tokens. Values are caller data.
pub(crate) fn track_number(ui: &mut Ui, value: &mut f64, speed: f64, decimals: usize) -> Response {
let now = *value;
let response = ui.add(egui::DragValue::new(value).speed(speed).fixed_decimals(decimals).custom_parser(move |s| relative_number(s, now)));
Tokens::get().input_bevel(ui, &response);
super::layout::publish_geometry(ui, &response);
response
}
/// A value typed as a sum of numbers (`crate::prefs::parse_number` each), added to `now` when it
/// starts with a sign: "+30" moves the value by 30, "0-5" is −5.
fn relative_number(text: &str, now: f64) -> Option<f64> {
let text: String = text.chars().filter(|c| !c.is_whitespace()).map(|c| if c == '\u{2212}' { '-' } else { c }).collect();
let mut total = if text.starts_with(['+', '-']) { now } else { 0.0 };
let mut term = String::new();
let mut prev = ' ';
for c in text.chars() {
if (c == '+' || c == '-') && !term.is_empty() && !matches!(prev, 'e' | 'E') {
total += crate::prefs::parse_number(&term)?;
term.clear();
}
term.push(c);
prev = c;
}
total += crate::prefs::parse_number(&term)?;
total.is_finite().then_some(total)
}
/// Growing plain-text message editor; its parent owns scrolling and Enter routing.
/// Tokens: control_fill, form_inset, font_label. Use text_area for bounded data input.
#[cfg(any(not(target_arch = "wasm32"), test))]
+12
View File
@@ -665,3 +665,15 @@ fn esc_closes_the_text_menu_alone() {
menu_frame(&ctx, key(egui::Key::Escape), &mut draw);
assert!(owner_saw_esc.get(), "with the menu closed, Esc reaches the owner again");
}
#[test]
fn a_track_number_adds_up_and_a_sign_adds_to_the_value_now() {
let typed = super::relative_number;
assert_eq!(typed("1200", 5.0), Some(1200.0));
assert_eq!(typed("+30", 100.0), Some(130.0));
assert_eq!(typed(" -30 ", 100.0), Some(70.0));
assert_eq!(typed("\u{2212}2,5", 10.0), Some(7.5));
assert_eq!(typed("0-5", 100.0), Some(-5.0));
assert_eq!(typed("1e-3+1", 0.0), Some(1.001));
assert_eq!(typed("abc", 1.0), None);
}
+3
View File
@@ -1177,6 +1177,9 @@ const ELEMENTS: &[Element] = &[
element!("scalar_field", |ui, _| {
scalar_field(ui, &mut 0.5, 0.0..=1.0);
}),
element!("track_number", |ui, _| {
track_number(ui, &mut 1350.0, 0.5, 1);
}),
element!("length_field", |ui, _| {
length_field(ui, &mut 12.7, "in", 2);
}),
+2 -2
View File
@@ -4332,7 +4332,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
back: proj.cam.back().to_f32(),
size_px,
images: crate::ref_images::gpu_images(app),
points: crate::point_clouds::gpu_points(app),
clouds: crate::point_clouds::gpu_clouds(app, ui.ctx()),
analysis: analysis_uniform(app),
analysis_colors: crate::gpu::analysis_colors(&t.analysis),
access: crate::access::map(app),
@@ -7956,7 +7956,7 @@ mod tests {
clip: [0.0; 4],
cap: [0.0; 4],
images: Vec::new(),
points: Vec::new(),
clouds: Vec::new(),
analysis: [0.0; 8],
analysis_colors: crate::gpu::analysis_colors(&Tokens::get().analysis),
access: None,
+57 -9
View File
@@ -9,7 +9,7 @@ depends on it.
| Extension | What is read |
|-----------|--------------|
| `.ply` | ASCII, binary little-endian and binary big-endian PLY: the `vertex` element's `x`, `y`, `z` (any numeric type) and its `red`, `green`, `blue` when present (bytes, 16-bit or 0…1 floats). Other properties (normals, intensity) and elements are passed over. |
| `.ply` | ASCII, binary little-endian and binary big-endian PLY: the `vertex` element's `x`, `y`, `z` (any numeric type) and its `red`, `green`, `blue` when present (bytes, 16-bit or 0…1 floats). Every other scalar vertex property (intensity, a class, a time…) is kept, by its name in the file, to colour and filter the cloud by; it is whole-numbered when its type is an integer. Other elements are passed over. |
| `.xyz`, `.pts` | Text, a point per line: `x y z`, optionally followed by `r g b` (six numbers) or by an intensity and `r g b` (seven or more, as PTS files write it). Spaces, tabs, commas or semicolons separate the numbers; `#` and `//` start comments; a PTS file's first line (its count) is skipped. |
A PLY file that also has faces can be inserted as a mesh body instead with **Insert Mesh**
@@ -27,9 +27,51 @@ or `pointcloud.edit` with `units`): the points are scaled from the unit they cam
- `file` (the default): the file's colour of each point, grey when it has none.
- `single`: one colour (`color`) for every point.
- `height`: a ramp by design Z, blue low through cyan, green and yellow to red high. It runs
between two heights (`height_range`, mm). By default they are worked out from the points: all
but the lowest and highest 2 %, so a few stray points don't flatten the ramp. Turn
**Automatic Range** off in the card to set them.
between two heights (`color_range`, mm). By default they are worked out from the points: all
but the lowest and highest 2 %, so a few stray points don't flatten the ramp.
- any property's name (`x`, `y`, `z` or one of the file's): the same ramp over the property's
values or, for a whole-numbered property, a palette of ten colours (its value modulo ten;
negative values grey). By default the ramp runs exactly over the values the property takes on
the points every filter keeps, so filtering re-spreads the colours over what is left.
A ramp's range is a [range track](#range-tracks) in the card; touching it sets the range and
turns **Automatic Range** off, and ticking the box (or a double click beside the range) gives
it back. `color_range: null` does the same from a command.
A whole-numbered property may name its values with a PLY comment, `property: 0 name, 1 other
name, -1 none` (for example `comment role: 0 outer wall, 1 inner wall`). Coloured by it, the
card shows a legend, a swatch and a name per value; clicking a name shows or hides that value.
## Filters
A filter keeps the points whose property lies in a range (both ends included) or, for a
property with named values, is one of a set of values. Only what every filter keeps is shown; a
cloud has at most 8 filters, and the view applies the first 4. **Filter by** in the card adds
one over a property's whole span (or with all its named values); × removes it. Filters change
what is drawn, never the points the design holds.
## Range tracks
Each range filter, and a ramp's range, is edited on a track, as a range is in an audio editor.
The track draws how much of the cloud lies at each value: a histogram of the property over what
the *other* filters keep, stacked by colour, so it is also the colour legend. A thin strip above
it shows the property's whole span, with the part the track shows framed.
- Drag an end or the band between them to move them; drag elsewhere to draw a new range.
Shift-click brings the nearer end to the pointer.
- Double-click the band to fit the track to it; double-click beside it for the whole span.
The wheel or a pinch zooms around the pointer, a sideways scroll pans, and dragging in the
strip moves the framed part.
- An end snaps to a value where the colour changes when no other change is near at this zoom;
a whole-numbered property's ends snap to whole numbers. ⌥ moves ten times slower, without
snapping.
- Once the track is touched, ← and → bring the part touched last to the previous or next colour
change (one pixel with ⌥, or where there is none).
- **Start**, **End** and **Length** are fields too; Length keeps the start. A value typed with a
sign is relative: `+30` moves it by 30.
- **▶ Play** (or Space) moves the part touched last across the track in 20 seconds: an upper
end grows the range, the band slides it, a lower end wipes it. The track follows it, and an
automatic colour range follows it too. Touching the range stops it.
## Size
@@ -42,12 +84,17 @@ file as a binary part (12 bytes a point, 3 more with colours).
## Commands
- `pointcloud.insert` — `path`; `name?`, `units?`, `max_points?`, `center?` (move the middle of
its base to the origin), `point_size?` (pixels), `color_by?` (`file`, `single`, `height`),
`color?` (`[r, g, b]` 0…1; alone it means `single`, `null` means `file`), `height_range?`
(`[low, high]` mm, or `null` for the automatic range).
its base to the origin), `point_size?` (pixels), `color_by?` (`file`, `single`, `height` or a
property's name), `color?` (`[r, g, b]` 0…1; alone it means `single`, `null` means `file`),
`color_range?` (`[low, high]`, or `null` for the automatic range; `height_range` is the same),
`filters?` (`[{"property": …, "range": [low, high]}` or `{"property": …, "values": [whole
numbers]}]`, or `null` for none).
- `pointcloud.edit` — `cloud` (id or name); `name?`, `visible?`, `point_size?`, `color_by?`,
`color?`, `height_range?`, `units?`, `matrix?` (a rigid placement, as `occurrence.move` takes it).
- `pointcloud.delete`, `pointcloud.list`.
`color?`, `color_range?`, `filters?`, `units?`, `matrix?` (a rigid placement, as
`occurrence.move` takes it).
- `pointcloud.delete`.
- `pointcloud.list` — each cloud with its properties (name, whole-numbered or not, span, named
values), its filters and its ramp's range as drawn (`color_range`).
- `doc.open` opens a point cloud file as a new design holding it; dropping one on the window
adds it to the open design.
@@ -55,4 +102,5 @@ file as a binary part (12 bytes a point, 3 more with colours).
```json
{"command": "pointcloud.insert", "params": {"path": "glim-points.ply", "units": "m", "color_by": "height"}}
{"command": "pointcloud.edit", "params": {"cloud": "glim-points", "color_by": "intensity", "filters": [{"property": "z", "range": [0, 2500]}]}}
```