Files
OneCAD-Draft/crates/ocs_plugin_api/src/shm.rs
T
Hakan SevenandClaude Opus 5.5 f537aff75c feat: node graph overlay; move to opencadcodec/opencadkernel/opencadgraph
The codec and kernel repositories and crates are renamed opencadcodec and
opencadkernel. Inside OCS they are depended on under short keys, so code
reads `codec::`, `kernel::` and the new `graph::` (opencadgraph, the node
graph engine). File-format and protocol spellings stay as they were: the
ACIS product id `acadrust`, the ACADRUST_* switches and the plugin
version keys `acadrust_version` / `acadrust_source`.

A button left of MCP on the command line opens a translucent node graph
over the viewport, with a palette of object, input, math, logic, list,
text and geometry nodes added by click or drag. An object node owns
drawing entities and its rows are the Properties panel's own rows for
them: the single-object page is extracted from refresh_properties as
entity_property_sections, values use the control protocol's
property_json, and writes go through set_property_value with a property
target override, so the panel, MCP and the graph share one read/write
path. A list reaching an object row makes one entity per element. Each
graph action is one undo step; the canvas pans and zooms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:34:23 +03:00

753 lines
25 KiB
Rust

//! Shared-memory document view for out-of-process plugins.
//!
//! The host owns a memory-mapped file that contains a small, read-only,
//! rkyv-serialized view of the active document. The plugin maps the same file
//! read-only and reads entity/layer data directly from the mapping without
//! copying the full `CadDocument` into its own address space.
//!
//! This module is generic over the payload type so that the V3 simplified view
//! and the V4 bincode-per-entity view can share the same double-buffered
//! control-page logic.
//!
//! Key types:
//!
//! - [`DocumentSnapshotStore<T>`] — host-side, file-backed double buffer. Call
//! [`DocumentSnapshotStore::publish`] to atomically swap the active segment and
//! increment the version.
//! - [`SharedDocumentReader<T>`] — plugin-side read-only mapping.
//! - [`DocumentViewInfo`] — path + version returned to the plugin so it can
//! open the mapping.
//! - [`DocumentViewData`] / [`DocumentViewDataV4`] — concrete snapshot payloads
//! for V3 and V4.
//!
//! The control page at the start of the mapping stores magic, version, active
//! segment, and length. The two snapshot segments follow it. Publishing writes
//! to the inactive segment, then flips the active segment atomically.
use std::fs::OpenOptions;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, AtomicU64, AtomicU8, AtomicUsize, Ordering};
use codec::{CadDocument, EntityType, Handle};
use memmap2::{Mmap, MmapMut, MmapOptions};
use rkyv::{check_archived_root, to_bytes, Archive, Deserialize, Serialize};
use crate::host::{DocumentReader, ReaderEntity, ReaderEntityKind, ReaderPoint};
/// Magic number identifying a valid control page.
const CONTROL_MAGIC: u32 = 0x4F_43_53_44; // "OCSD"
/// Size of the control region at the start of the mapping. Must be enough for
/// `ControlPage` and aligned to a typical page boundary so the snapshot segments
/// that follow are naturally aligned for rkyv.
const CONTROL_SIZE: usize = 4096;
const MAX_SNAPSHOT_SIZE: usize = 1024 * 1024 * 1024;
/// Information sent to the plugin so it can open the shared mapping.
#[derive(Debug, Clone)]
pub struct DocumentViewInfo {
/// Absolute path to the memory-mapped file.
pub path: String,
/// Snapshot version at the time the view was opened.
pub version: u64,
}
/// Marker trait for types that can be placed in a shared-memory snapshot.
///
/// Implementers provide rkyv serialization/deserialization without exposing
/// rkyv's generic serializer types in the public API.
pub trait SnapshotData: Clone + std::fmt::Debug + Send + Sync + 'static {
/// The archived (zero-copy) type returned by [`Self::check_bytes`].
type Archived: 'static;
/// Serialize `self` into rkyv bytes.
fn to_rkyv_bytes(&self) -> io::Result<Vec<u8>>;
/// Validate `bytes` and return a reference to the archived type.
fn check_rkyv_bytes(bytes: &[u8]) -> Option<&Self::Archived>;
}
/// Host-side, file-backed double buffer for the document view.
pub struct DocumentSnapshotStore<T: SnapshotData> {
path: PathBuf,
mmap: MmapMut,
segment_size: usize,
current_version: u64,
_phantom: std::marker::PhantomData<T>,
}
impl<T: SnapshotData> DocumentSnapshotStore<T> {
/// Create a new store for `tab_id`. `segment_size` is the maximum size of one
/// snapshot buffer; the file is sized to hold two segments plus the control
/// page.
pub fn new(tab_id: u64, segment_size: usize) -> io::Result<Self> {
let segment_size = segment_size.checked_next_multiple_of(4096).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"snapshot segment size overflow",
)
})?;
let total = segment_size
.checked_mul(2)
.and_then(|segments| CONTROL_SIZE.checked_add(segments))
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"snapshot mapping size overflow",
)
})?;
if total > MAX_SNAPSHOT_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"snapshot mapping size {total} exceeds maximum {} MiB",
MAX_SNAPSHOT_SIZE / 1024 / 1024
),
));
}
static STORE_ID: AtomicUsize = AtomicUsize::new(0);
let id = STORE_ID.fetch_add(1, Ordering::Relaxed);
let path = Self::temp_path(tab_id, id);
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&path)?;
#[cfg(unix)]
{
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
file.set_permissions(Permissions::from_mode(0o600))?;
}
file.set_len(total as u64)?;
let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
let control = ControlPage::from_bytes_mut(&mut mmap);
control.magic.store(CONTROL_MAGIC, Ordering::Relaxed);
control.version.store(0, Ordering::Relaxed);
control.active_segment.store(0, Ordering::Relaxed);
control.active_len.store(0, Ordering::Relaxed);
mmap.flush()?;
Ok(Self {
path,
mmap,
segment_size,
current_version: 0,
_phantom: std::marker::PhantomData,
})
}
fn temp_path(tab_id: u64, id: usize) -> PathBuf {
let mut path = std::env::temp_dir();
path.push(format!(
"ocs_plugin_doc_v4_{}_{}_{}_{}.bin",
std::process::id(),
tab_id,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
id,
));
path
}
/// Path the plugin should open to access the mapping.
pub fn path(&self) -> &Path {
&self.path
}
/// Serialize `data` into the inactive segment and atomically publish it.
pub fn publish(&mut self, data: &T) -> io::Result<()> {
let bytes = data.to_rkyv_bytes()?;
if bytes.len() > self.segment_size {
return Err(io::Error::new(
io::ErrorKind::OutOfMemory,
format!(
"document view {} bytes exceeds segment size {}",
bytes.len(),
self.segment_size
),
));
}
let inactive = {
let control = ControlPage::from_bytes_mut(&mut self.mmap);
let active = control.active_segment.load(Ordering::Acquire) as usize;
1 - active
};
let offset = CONTROL_SIZE + inactive * self.segment_size;
self.mmap[offset..offset + bytes.len()].copy_from_slice(&bytes);
let control = ControlPage::from_bytes_mut(&mut self.mmap);
// Ensure the plugin sees the new length before it sees the new version.
control
.active_len
.store(bytes.len() as u64, Ordering::Release);
control
.active_segment
.store(inactive as u8, Ordering::Release);
self.current_version = self.current_version.wrapping_add(1);
control
.version
.store(self.current_version, Ordering::Release);
Ok(())
}
/// Current published version.
pub fn version(&self) -> u64 {
ControlPage::from_bytes(&self.mmap)
.version
.load(Ordering::Acquire)
}
}
impl<T: SnapshotData> Drop for DocumentSnapshotStore<T> {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
/// Plugin-side read-only mapping of the host's document view.
pub struct SharedDocumentReader<T: SnapshotData> {
mmap: Mmap,
segment_size: usize,
cached_version: u64,
_phantom: std::marker::PhantomData<T>,
}
impl<T: SnapshotData> SharedDocumentReader<T> {
/// Open the file at `path` read-only and map it. The mapping may initially
/// contain no valid snapshot; the caller should `refresh()` before use.
pub fn open(path: &Path) -> io::Result<Self> {
let file = OpenOptions::new().read(true).open(path)?;
let metadata = file.metadata()?;
if !metadata.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("snapshot path is not a regular file: {}", path.display()),
));
}
let file_len = metadata.len();
if file_len < CONTROL_SIZE as u64 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"snapshot file size {file_len} is smaller than control region {CONTROL_SIZE}"
),
));
}
if file_len > MAX_SNAPSHOT_SIZE as u64 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"snapshot file size {} exceeds maximum {} MiB; refusing to mmap",
file_len,
MAX_SNAPSHOT_SIZE / 1024 / 1024
),
));
}
let file_len = usize::try_from(file_len).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
"snapshot file size is unsupported",
)
})?;
let mmap = unsafe { MmapOptions::new().len(file_len).map(&file)? };
let segment_size = (file_len - CONTROL_SIZE) / 2;
Ok(Self {
mmap,
segment_size,
cached_version: 0,
_phantom: std::marker::PhantomData,
})
}
/// Check whether the host has published a newer snapshot.
pub fn has_new_version(&self) -> bool {
let control = ControlPage::from_bytes(&self.mmap);
if control.magic.load(Ordering::Acquire) != CONTROL_MAGIC {
return false;
}
control.version.load(Ordering::Acquire) != self.cached_version
}
/// Update the cached version after the caller has re-bound to a new snapshot.
pub fn refresh(&mut self) {
let control = ControlPage::from_bytes(&self.mmap);
self.cached_version = control.version.load(Ordering::Acquire);
}
fn active_segment_bytes(&self) -> &[u8] {
let control = ControlPage::from_bytes(&self.mmap);
let active = control.active_segment.load(Ordering::Acquire) as usize;
if active > 1 {
return &[];
}
let Ok(len) = usize::try_from(control.active_len.load(Ordering::Acquire)) else {
return &[];
};
let Some(offset) = active
.checked_mul(self.segment_size)
.and_then(|offset| CONTROL_SIZE.checked_add(offset))
else {
return &[];
};
let Some(end) = offset.checked_add(len) else {
return &[];
};
if end > self.mmap.len() {
return &[];
}
&self.mmap[offset..end]
}
fn archived(&self) -> Option<&T::Archived> {
let bytes = self.active_segment_bytes();
T::check_rkyv_bytes(bytes)
}
}
impl<T: SnapshotData> SharedDocumentReader<T> {
/// Public accessor for the archived payload. Used by V4 consumers that
/// need typed access to `DocumentViewDataV4` entities.
pub fn payload(&self) -> Option<&T::Archived> {
self.archived()
}
}
impl SharedDocumentReader<DocumentViewData> {
/// V3 convenience accessor for the entity count.
pub fn entity_count(&self) -> usize {
self.archived().map(|doc| doc.entities.len()).unwrap_or(0)
}
}
impl DocumentReader for SharedDocumentReader<DocumentViewData> {
fn entity_count(&self) -> usize {
self.archived().map(|doc| doc.entities.len()).unwrap_or(0)
}
fn for_each_entity(&self, f: &mut dyn FnMut(ReaderEntity<'_>)) {
let Some(doc) = self.archived() else { return };
for entity in doc.entities.iter() {
let handle = Handle::new(entity.handle);
let kind = ReaderEntityKind::from_u8(entity.kind);
let layer_name: &str = entity.layer_name.as_str();
let point = entity.point.as_ref().map(|p| ReaderPoint {
x: p.x,
y: p.y,
z: p.z,
});
f(ReaderEntity {
handle,
kind,
layer_name,
point,
});
}
}
fn layer_name(&self, handle: Handle) -> Option<&str> {
let doc = self.archived()?;
let handle_val = handle.value();
doc.layers
.iter()
.find(|layer| layer.handle == handle_val)
.map(|layer| layer.name.as_str())
}
fn app_id_name(&self, handle: Handle) -> Option<&str> {
let doc = self.archived()?;
let handle_val = handle.value();
doc.app_ids
.iter()
.find(|app| app.handle == handle_val)
.map(|app| app.name.as_str())
}
}
/// V3 alias: host-side snapshot store for the simplified document view.
pub type DocumentSnapshotStoreV3 = DocumentSnapshotStore<DocumentViewData>;
/// V3 alias: plugin-side reader for the simplified document view.
pub type SharedDocumentReaderV3 = SharedDocumentReader<DocumentViewData>;
/// Raw control page shared between host and plugin.
#[repr(C, align(8))]
struct ControlPage {
magic: AtomicU32,
_pad0: [u8; 4],
version: AtomicU64,
active_len: AtomicU64,
active_segment: AtomicU8,
_pad1: [u8; 7],
}
impl ControlPage {
fn from_bytes(mmap: &[u8]) -> &Self {
assert!(mmap.len() >= std::mem::size_of::<Self>());
assert_eq!(mmap.as_ptr() as usize % std::mem::align_of::<Self>(), 0);
unsafe { &*(mmap.as_ptr() as *const Self) }
}
fn from_bytes_mut(mmap: &mut [u8]) -> &mut Self {
assert!(mmap.len() >= std::mem::size_of::<Self>());
assert_eq!(mmap.as_ptr() as usize % std::mem::align_of::<Self>(), 0);
unsafe { &mut *(mmap.as_ptr() as *mut Self) }
}
}
// ── V3 simplified document view ─────────────────────────────────────────────
/// Serializable document view. This is the only data type placed in shared
/// memory, so it must contain no pointers into host memory.
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
#[archive(check_bytes)]
pub struct DocumentViewData {
pub layers: Vec<LayerView>,
pub app_ids: Vec<AppIdView>,
pub entities: Vec<EntityView>,
}
impl From<&CadDocument> for DocumentViewData {
fn from(doc: &CadDocument) -> Self {
Self {
layers: doc.layers.iter().map(LayerView::from).collect(),
app_ids: doc.app_ids.iter().map(AppIdView::from).collect(),
entities: doc.entities().map(EntityView::from).collect(),
}
}
}
impl SnapshotData for DocumentViewData {
type Archived = ArchivedDocumentViewData;
fn to_rkyv_bytes(&self) -> io::Result<Vec<u8>> {
to_bytes::<_, 256>(self)
.map(|av| av.into_vec())
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
}
fn check_rkyv_bytes(bytes: &[u8]) -> Option<&Self::Archived> {
check_archived_root::<Self>(bytes).ok()
}
}
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
#[archive(check_bytes)]
pub struct LayerView {
pub handle: u64,
pub name: String,
}
impl From<&codec::tables::Layer> for LayerView {
fn from(layer: &codec::tables::Layer) -> Self {
Self {
handle: layer.handle.value(),
name: layer.name.clone(),
}
}
}
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
#[archive(check_bytes)]
pub struct AppIdView {
pub handle: u64,
pub name: String,
}
impl From<&codec::tables::AppId> for AppIdView {
fn from(app_id: &codec::tables::AppId) -> Self {
Self {
handle: app_id.handle.value(),
name: app_id.name.clone(),
}
}
}
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
#[archive(check_bytes)]
pub struct EntityView {
pub handle: u64,
pub kind: u8,
pub layer_name: String,
pub point: Option<PointView>,
}
impl From<&EntityType> for EntityView {
fn from(entity: &EntityType) -> Self {
let handle = entity.common().handle.value();
let kind = ReaderEntityKind::from_entity(entity).to_u8();
let layer_name = entity.common().layer.clone();
let point = match entity {
EntityType::Point(p) => Some(PointView {
x: p.location.x,
y: p.location.y,
z: p.location.z,
}),
_ => None,
};
Self {
handle,
kind,
layer_name,
point,
}
}
}
#[derive(Archive, Serialize, Deserialize, Debug, Clone, Copy)]
#[archive(check_bytes)]
pub struct PointView {
pub x: f64,
pub y: f64,
pub z: f64,
}
impl ReaderEntityKind {
/// Convert the simplified kind to a stable `u8` for the shared format.
pub fn to_u8(self) -> u8 {
match self {
ReaderEntityKind::Point => 1,
ReaderEntityKind::Line => 2,
ReaderEntityKind::Circle => 3,
ReaderEntityKind::Arc => 4,
ReaderEntityKind::Polyline => 5,
ReaderEntityKind::Text => 6,
ReaderEntityKind::Other => 0,
}
}
/// Decode a stable `u8` back to the simplified kind.
pub fn from_u8(value: u8) -> Self {
match value {
1 => ReaderEntityKind::Point,
2 => ReaderEntityKind::Line,
3 => ReaderEntityKind::Circle,
4 => ReaderEntityKind::Arc,
5 => ReaderEntityKind::Polyline,
6 => ReaderEntityKind::Text,
_ => ReaderEntityKind::Other,
}
}
}
// ── V4 full-entity document view ────────────────────────────────────────────
/// Serializable V4 document view. The outer structure is rkyv; each entity's
/// `data` is a bincode-encoded `codec::EntityType` so that the plugin can
/// reconstruct the full typed entity without relying on opencadcodec's own rkyv
/// support.
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
#[archive(check_bytes)]
pub struct DocumentViewDataV4 {
pub layers: Vec<LayerView>,
pub app_ids: Vec<AppIdView>,
pub entities: Vec<EntityViewV4>,
}
impl From<&CadDocument> for DocumentViewDataV4 {
fn from(doc: &CadDocument) -> Self {
let mut entities: Vec<_> = doc.entities().map(EntityViewV4::from).collect();
// ATTRIB records live inside their owning INSERT in CadDocument rather
// than in the flat entity index. Flatten them into the plugin snapshot
// as addressable entities while keeping the INSERT's canonical nested
// copy intact for rendering and file serialization.
for entity in doc.entities() {
if let EntityType::Insert(insert) = entity {
entities.extend(insert.attributes.iter().map(|attribute| {
EntityViewV4::from(&EntityType::AttributeEntity(attribute.clone()))
}));
}
}
Self {
layers: doc.layers.iter().map(LayerView::from).collect(),
app_ids: doc.app_ids.iter().map(AppIdView::from).collect(),
entities,
}
}
}
impl SnapshotData for DocumentViewDataV4 {
type Archived = ArchivedDocumentViewDataV4;
fn to_rkyv_bytes(&self) -> io::Result<Vec<u8>> {
to_bytes::<_, 256>(self)
.map(|av| av.into_vec())
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
}
fn check_rkyv_bytes(bytes: &[u8]) -> Option<&Self::Archived> {
check_archived_root::<Self>(bytes).ok()
}
}
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
#[archive(check_bytes)]
pub struct EntityViewV4 {
pub handle: u64,
pub data: Vec<u8>,
}
impl From<&EntityType> for EntityViewV4 {
fn from(entity: &EntityType) -> Self {
let handle = entity.common().handle.value();
let data = bincode::serialize(entity).unwrap_or_default();
Self { handle, data }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host::{DocumentReader, ReaderEntityKind};
use codec::entities::Point;
use codec::tables::Layer;
use codec::{CadDocument, EntityType};
fn unique_path(name: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"ocs_shm_{}_{}_{}",
std::process::id(),
name,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
))
}
fn sample_doc() -> CadDocument {
let mut doc = CadDocument::new();
doc.layers.add(Layer::new("SURVEY")).unwrap();
let mut point = Point::from_coords(10.0, 20.0, 5.0);
point.common.layer = "SURVEY".to_string();
doc.add_entity(EntityType::Point(point)).unwrap();
doc
}
#[test]
fn reader_rejects_oversized_snapshot() {
let path = unique_path("oversized.bin");
let file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.unwrap();
file.set_len(MAX_SNAPSHOT_SIZE as u64 + 1).unwrap();
let error = match SharedDocumentReader::<DocumentViewData>::open(&path) {
Ok(_) => panic!("oversized snapshot was mapped"),
Err(error) => error,
};
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
drop(file);
let _ = std::fs::remove_file(path);
}
#[test]
fn reader_rejects_snapshot_without_control_region() {
let path = unique_path("undersized.bin");
std::fs::write(&path, vec![0; CONTROL_SIZE - 1]).unwrap();
let error = match SharedDocumentReader::<DocumentViewData>::open(&path) {
Ok(_) => panic!("undersized snapshot was mapped"),
Err(error) => error,
};
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
let _ = std::fs::remove_file(path);
}
#[test]
fn store_rejects_oversized_mapping() {
let error = match DocumentSnapshotStore::<DocumentViewData>::new(0, MAX_SNAPSHOT_SIZE) {
Ok(_) => panic!("oversized snapshot store was mapped"),
Err(error) => error,
};
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
}
#[test]
fn shared_document_reader_roundtrip() {
let doc = sample_doc();
let mut store = DocumentSnapshotStore::<DocumentViewData>::new(0, 1024 * 1024).unwrap();
store.publish(&(&doc).into()).unwrap();
let reader = SharedDocumentReader::<DocumentViewData>::open(store.path()).unwrap();
assert_eq!(reader.entity_count(), 1);
let mut seen = Vec::new();
reader.for_each_entity(&mut |e| {
seen.push((e.kind, e.layer_name.to_string(), e.point, e.handle));
});
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].0, ReaderEntityKind::Point);
assert_eq!(seen[0].1, "SURVEY");
assert_eq!(
seen[0].2,
Some(ReaderPoint {
x: 10.0,
y: 20.0,
z: 5.0
})
);
assert!(
seen[0].3.is_valid(),
"reader entity should expose a valid handle"
);
}
#[test]
fn shared_document_reader_updates_after_publish() {
let doc = sample_doc();
let mut store = DocumentSnapshotStore::<DocumentViewData>::new(0, 1024 * 1024).unwrap();
store.publish(&(&doc).into()).unwrap();
let reader = SharedDocumentReader::<DocumentViewData>::open(store.path()).unwrap();
assert_eq!(reader.entity_count(), 1);
let mut doc2 = doc;
let mut point2 = Point::from_coords(1.0, 2.0, 3.0);
point2.common.layer = "SURVEY".to_string();
doc2.add_entity(EntityType::Point(point2)).unwrap();
store.publish(&(&doc2).into()).unwrap();
assert_eq!(reader.entity_count(), 2);
}
#[test]
fn layer_name_lookup_by_handle() {
let doc = sample_doc();
let mut store = DocumentSnapshotStore::<DocumentViewData>::new(0, 1024 * 1024).unwrap();
store.publish(&(&doc).into()).unwrap();
let survey = doc.layers.iter().find(|l| l.name == "SURVEY").unwrap();
let reader = SharedDocumentReader::<DocumentViewData>::open(store.path()).unwrap();
assert_eq!(reader.layer_name(survey.handle), Some("SURVEY"));
}
#[test]
fn v4_document_view_roundtrip() {
let doc = sample_doc();
let mut store = DocumentSnapshotStore::<DocumentViewDataV4>::new(7, 1024 * 1024).unwrap();
let view: DocumentViewDataV4 = (&doc).into();
store.publish(&view).unwrap();
let reader = SharedDocumentReader::<DocumentViewDataV4>::open(store.path()).unwrap();
assert_eq!(reader.archived().map(|d| d.entities.len()).unwrap_or(0), 1);
let archived = reader.archived().unwrap();
let entity = &archived.entities[0];
assert_eq!(
entity.handle,
doc.entities().next().unwrap().common().handle.value()
);
let decoded: EntityType = bincode::deserialize(&entity.data).expect("bincode decode");
assert!(matches!(decoded, EntityType::Point(_)));
}
}