forked from 1b-agent/opencadstudio-upstream
feat(viewport): persist model tiled layout in VPort table
A multi-pane Model view (split / VPORTS 2H|2V|4) used to collapse back to a single tile after save→reopen — only the *Active VPort entry was written, the rest of model_tiles was discarded. Each ModelTile now round-trips through a dedicated `*OCS_Tile_<i>` entry in the VPort table: - save_model_tiles_to_vports stashes the live camera into the active tile, removes any previous tile entries, and writes one VPort per tile (rect via lower_left/upper_right, camera via view_target/direction/height); the active tile is mirrored to `*Active` so apps that only read that entry still get a sensible view. - restore_model_tiles_from_vports rebuilds the tile list on file open and selects the active tile by matching the entry whose view_target equals `*Active`'s, falling back to index 0. Single-tile configurations and files from other apps skip the tile entries entirely — restore returns false and the existing *Active / View-table path runs, so behaviour for non-tiled files is unchanged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
8d64426087
commit
a058ded74f
1 file changed
+178
-16
+178
-16
@@ -3933,18 +3933,26 @@ impl Scene {
|
||||
Some(v) => v.clone(),
|
||||
None => return false,
|
||||
};
|
||||
|
||||
if vp.view_height.abs() < 1e-9 {
|
||||
let Some(new_cam) = self.camera_from_vport(&vp) else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
*self.camera.borrow_mut() = new_cam;
|
||||
self.camera_generation += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Decode a VPort table entry into a `Camera`. Returns `None` if the
|
||||
/// entry has a zero view_height (i.e. is uninitialised).
|
||||
fn camera_from_vport(&self, vp: &acadrust::tables::VPort) -> Option<Camera> {
|
||||
if vp.view_height.abs() < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let vd = glam::Vec3::new(
|
||||
vp.view_direction.x as f32,
|
||||
vp.view_direction.y as f32,
|
||||
vp.view_direction.z as f32,
|
||||
)
|
||||
.normalize_or(glam::Vec3::Z);
|
||||
|
||||
let pitch = vd.z.clamp(-1.0, 1.0).asin();
|
||||
// view_dir = (sin(yaw)*cos(pitch), -cos(yaw)*cos(pitch), sin(pitch))
|
||||
// → yaw = atan2(x, -y), but when looking straight up/down cos(pitch)≈0
|
||||
@@ -3957,7 +3965,6 @@ impl Scene {
|
||||
let rotation = camera::yaw_pitch_to_quat(yaw, pitch, 0.0);
|
||||
let view_right = rotation * glam::Vec3::X;
|
||||
let view_up = rotation * glam::Vec3::Y;
|
||||
|
||||
// view_target is WCS; wire-space subtracts world_offset.
|
||||
let base = glam::Vec3::new(
|
||||
(vp.view_target.x - self.world_offset[0]) as f32,
|
||||
@@ -3966,24 +3973,172 @@ impl Scene {
|
||||
);
|
||||
let target =
|
||||
base + view_right * vp.view_center.x as f32 + view_up * vp.view_center.y as f32;
|
||||
|
||||
let fov_y = 45.0_f32.to_radians();
|
||||
let distance = ((vp.view_height as f32 / 2.0) / (fov_y * 0.5).tan()).max(0.001);
|
||||
Some(Camera {
|
||||
target,
|
||||
rotation,
|
||||
distance,
|
||||
fov_y,
|
||||
projection: camera::Projection::Orthographic,
|
||||
yaw,
|
||||
pitch,
|
||||
})
|
||||
}
|
||||
|
||||
let mut cam = self.camera.borrow_mut();
|
||||
cam.target = target;
|
||||
cam.rotation = rotation;
|
||||
cam.distance = distance;
|
||||
cam.yaw = yaw;
|
||||
cam.pitch = pitch;
|
||||
cam.fov_y = fov_y;
|
||||
cam.projection = camera::Projection::Orthographic;
|
||||
drop(cam);
|
||||
/// Reverse of `camera_from_vport`: write `cam`'s view target / direction
|
||||
/// / height onto a fresh VPort entry with the given `name` and screen
|
||||
/// rectangle (0..1 normalized, DXF bottom-left origin convention).
|
||||
fn vport_from_camera(
|
||||
&self,
|
||||
name: &str,
|
||||
cam: &Camera,
|
||||
lower_left: acadrust::types::Vector2,
|
||||
upper_right: acadrust::types::Vector2,
|
||||
) -> acadrust::tables::VPort {
|
||||
let view_dir = cam.rotation * glam::Vec3::Z;
|
||||
let view_height = cam.ortho_size() * 2.0;
|
||||
let target_wcs = acadrust::types::Vector3 {
|
||||
x: (cam.target.x as f64) + self.world_offset[0],
|
||||
y: (cam.target.y as f64) + self.world_offset[1],
|
||||
z: (cam.target.z as f64) + self.world_offset[2],
|
||||
};
|
||||
let mut entry = acadrust::tables::VPort::new(name);
|
||||
entry.lower_left = lower_left;
|
||||
entry.upper_right = upper_right;
|
||||
entry.view_target = target_wcs;
|
||||
entry.view_direction = acadrust::types::Vector3 {
|
||||
x: view_dir.x as f64,
|
||||
y: view_dir.y as f64,
|
||||
z: view_dir.z as f64,
|
||||
};
|
||||
entry.view_height = view_height as f64;
|
||||
entry.view_center = acadrust::types::Vector2::ZERO;
|
||||
entry
|
||||
}
|
||||
|
||||
/// Convert a `ModelTile`'s normalized iced rectangle (top-left origin) to
|
||||
/// the (lower_left, upper_right) pair the VPort table uses (bottom-left
|
||||
/// origin).
|
||||
fn tile_rect_to_vport(rect: iced::Rectangle) -> (acadrust::types::Vector2, acadrust::types::Vector2) {
|
||||
let lower_left = acadrust::types::Vector2 {
|
||||
x: rect.x as f64,
|
||||
y: (1.0 - rect.y - rect.height) as f64,
|
||||
};
|
||||
let upper_right = acadrust::types::Vector2 {
|
||||
x: (rect.x + rect.width) as f64,
|
||||
y: (1.0 - rect.y) as f64,
|
||||
};
|
||||
(lower_left, upper_right)
|
||||
}
|
||||
|
||||
/// Inverse of `tile_rect_to_vport`.
|
||||
fn vport_to_tile_rect(lower_left: acadrust::types::Vector2, upper_right: acadrust::types::Vector2) -> iced::Rectangle {
|
||||
iced::Rectangle {
|
||||
x: lower_left.x as f32,
|
||||
y: (1.0 - upper_right.y) as f32,
|
||||
width: (upper_right.x - lower_left.x) as f32,
|
||||
height: (upper_right.y - lower_left.y) as f32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore `model_tiles` from `*OCS_Tile_*` VPort entries that a previous
|
||||
/// save left in the document. Returns true on success — the caller skips
|
||||
/// `apply_active_vport_camera` in that case because the active tile's
|
||||
/// camera has already been loaded into `self.camera`.
|
||||
fn restore_model_tiles_from_vports(&mut self) -> bool {
|
||||
let mut indexed: Vec<(usize, acadrust::tables::VPort)> = self
|
||||
.document
|
||||
.vports
|
||||
.iter()
|
||||
.filter_map(|v| {
|
||||
let idx: usize = v
|
||||
.name
|
||||
.strip_prefix("*OCS_Tile_")
|
||||
.and_then(|s| s.parse().ok())?;
|
||||
Some((idx, v.clone()))
|
||||
})
|
||||
.collect();
|
||||
if indexed.len() < 2 {
|
||||
return false;
|
||||
}
|
||||
indexed.sort_by_key(|(i, _)| *i);
|
||||
let mut tiles = Vec::with_capacity(indexed.len());
|
||||
for (_, vp) in &indexed {
|
||||
let Some(cam) = self.camera_from_vport(vp) else {
|
||||
return false;
|
||||
};
|
||||
tiles.push(ModelTile {
|
||||
rect: Self::vport_to_tile_rect(vp.lower_left, vp.upper_right),
|
||||
camera: cam,
|
||||
});
|
||||
}
|
||||
// Active tile = the one whose stored camera fields match `*Active`.
|
||||
// The save path writes `*Active` to mirror the active tile's view, so
|
||||
// this matches by view_target (the most discriminating field).
|
||||
let active_idx = self
|
||||
.document
|
||||
.vports
|
||||
.iter()
|
||||
.find(|v| v.name == "*Active")
|
||||
.and_then(|act| {
|
||||
indexed.iter().position(|(_, vp)| {
|
||||
let dx = vp.view_target.x - act.view_target.x;
|
||||
let dy = vp.view_target.y - act.view_target.y;
|
||||
let dz = vp.view_target.z - act.view_target.z;
|
||||
dx * dx + dy * dy + dz * dz < 1e-12
|
||||
})
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let active_cam = tiles[active_idx].camera.clone();
|
||||
*self.model_tiles.borrow_mut() = tiles;
|
||||
self.active_model_tile.set(active_idx);
|
||||
*self.camera.borrow_mut() = active_cam;
|
||||
self.camera_generation += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Persist `model_tiles` to the VPort table. The active tile is also
|
||||
/// mirrored to `*Active` (already written by `sync_camera_to_document`);
|
||||
/// every tile (including a single full-screen one) is written as a
|
||||
/// `*OCS_Tile_<i>` entry so the layout survives roundtrip.
|
||||
fn save_model_tiles_to_vports(&mut self) {
|
||||
// Stash the live camera into the active tile so the about-to-write
|
||||
// snapshot reflects the user's most recent orbit / pan / zoom.
|
||||
{
|
||||
let live_cam = self.camera.borrow().clone();
|
||||
let mut tiles = self.model_tiles.borrow_mut();
|
||||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||||
if let Some(t) = tiles.get_mut(active) {
|
||||
t.camera = live_cam;
|
||||
}
|
||||
}
|
||||
// Remove every previous tile entry — names are reassigned by index.
|
||||
let to_remove: Vec<String> = self
|
||||
.document
|
||||
.vports
|
||||
.iter()
|
||||
.filter(|v| v.name.starts_with("*OCS_Tile_"))
|
||||
.map(|v| v.name.clone())
|
||||
.collect();
|
||||
for name in to_remove {
|
||||
self.document.vports.remove(&name);
|
||||
}
|
||||
// Skip writing tile entries for the trivial single-tile config —
|
||||
// `*Active` alone is enough and keeps the file noise-free.
|
||||
let tiles = self.model_tiles.borrow().clone();
|
||||
if tiles.len() <= 1 {
|
||||
return;
|
||||
}
|
||||
for (i, tile) in tiles.iter().enumerate() {
|
||||
let (ll, ur) = Self::tile_rect_to_vport(tile.rect);
|
||||
let name = format!("*OCS_Tile_{i}");
|
||||
let mut entry = self.vport_from_camera(&name, &tile.camera, ll, ur);
|
||||
entry.handle = self.document.allocate_handle();
|
||||
self.document.vports.add_or_replace(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the paper-space camera from the sheet viewport's stored view.
|
||||
/// Returns true if a valid sheet viewport was found and the camera was set.
|
||||
fn apply_sheet_viewport_camera(&mut self) -> bool {
|
||||
@@ -4103,6 +4258,11 @@ impl Scene {
|
||||
vp.view_height = view_height as f64;
|
||||
}
|
||||
|
||||
// Persist the tiled layout (multiple `*OCS_Tile_<i>` entries) so
|
||||
// the panel split survives save→reopen. Single-tile configs skip
|
||||
// this — `*Active` alone is enough.
|
||||
self.save_model_tiles_to_vports();
|
||||
|
||||
// Also write to View table — survives DWG save without override.
|
||||
self.write_camera_view_entry("OpenCADStudio_Camera_Model", target_wcs, vd3, view_height);
|
||||
true
|
||||
@@ -4176,7 +4336,9 @@ impl Scene {
|
||||
/// Falls back to fit_all() if no saved view is available.
|
||||
pub fn restore_saved_camera(&mut self) {
|
||||
let restored = if self.current_layout == "Model" {
|
||||
self.apply_active_vport_camera()
|
||||
// Tiled-layout restore takes precedence — it sets the camera too.
|
||||
// Single-tile files fall through to the *Active branch.
|
||||
self.restore_model_tiles_from_vports() || self.apply_active_vport_camera()
|
||||
} else {
|
||||
self.apply_sheet_viewport_camera()
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user