Drawing views made by command: drawing.view.base and drawing.view.projected (#4816) (#9254)

* Drawing views made by command: drawing.view.base and drawing.view.projected (#4816)

drawing.view.base {sheet, view?, scale?, position_mm?, the view settings?} adds a base view of the
drawing's design to a sheet. Without a scale it takes the largest ISO 5455 preset at which it lies
inside the sheet's drawing space, centred where it goes, its margin included, as the snapshot fits
it; that scale is saved as the view's own (1:1 with nothing to measure).

drawing.view.projected {parent, side, place?} adds a view projected from a saved view, on its
parent's sheet, following its parent's scale and display settings. Without a place it is lined up
with its parent, its boundary 20 mm past the parent's (DEFAULT_GAP_MM, until a drawing style
gives it).

Each is one undo step through edit_drawing, refused with nothing changed when the record's rules
refuse it; the reply gives the view, its sheet, its scale, its boundary and, when it leaves the
sheet's drawing space, itself as outside (it isn't moved back in). Unknown fields are refused.

One rule, one place: whether a view leaves its drawing space is drawing_view::leaves, and the sheet
of an id is drawing_view::sheet, used by outside() and both commands.

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

* Drawing views made by command: a build fix, and each view one undo step, tested (#4816)

From the review of the view create commands:
- drawing.view.base measures the shown bodies (`measure` takes them, not the session), so it builds.
- The nothing-to-measure case is a design with one sketch and no bodies, as the other view tests
  make it: drawing.create refuses an empty design.
- The schema test gives drawing.view.base its own key list (it takes no snapshot-only keys).
- Every view a test makes is checked to be one undo step, not only the first: a second step made
  under the first (a nested edit) still let one undo take the view away.

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:
CamWight
2026-11-01 22:12:38 +00:00
committed by GitHub
parent 93baf88e2d
commit 71edae80df
6 changed files with 469 additions and 15 deletions
@@ -0,0 +1,26 @@
{
"en": {
"cmd.drawing_view_base": "Base View",
"cmd.drawing_view_projected": "Projected View"
},
"ar": {
"cmd.drawing_view_base": "العرض الأساسي",
"cmd.drawing_view_projected": "العرض المُسقط"
},
"he": {
"cmd.drawing_view_base": "תצוגת בסיס",
"cmd.drawing_view_projected": "תצוגה מוטלת"
},
"ja": {
"cmd.drawing_view_base": "ベースビュー",
"cmd.drawing_view_projected": "投影ビュー"
},
"vi": {
"cmd.drawing_view_base": "Chế độ xem cơ sở",
"cmd.drawing_view_projected": "Chế độ xem chiếu"
},
"zh": {
"cmd.drawing_view_base": "基础视图",
"cmd.drawing_view_projected": "投影视图"
}
}
+132 -7
View File
@@ -11,6 +11,11 @@
//! independent of the views it was copied from (Cameron, 2026-10-26). Each lists the views it puts
//! outside the sheet's drawing space and moves none back in (#3875 S5).
//!
//! `drawing.view.base` and `drawing.view.projected` (#4816) make a saved view: a base view of the
//! drawing's design on a sheet, or a view projected from another, lined up with it a gap past its
//! boundary unless placed. Each is one undo step, refused whole by the record's rules, and lists
//! itself when it lies outside the sheet's drawing space.
//!
//! The view's settings are `doc::drawing_view::ViewSettings`; its basis, scale and map onto
//! paper are `geom::drawing_view`'s. Without a scale it takes the largest ISO 5455 scale at
//! which the view, centred where it goes, lies inside the sheet's drawing border.
@@ -27,7 +32,7 @@ use solidcraft_doc::drawing_view::{Looking, TangentEdges, ViewDirection, ViewOve
use solidcraft_doc::ids;
use solidcraft_geom::Plane;
use solidcraft_geom::drawing_sheet::{DrawingBorderProfile, Sheet};
use solidcraft_geom::drawing_view::{NamedView, PRESETS, Scale};
use solidcraft_geom::drawing_view::{NamedView, PRESETS, ProjectedSide, Scale};
use solidcraft_geom::hlr::{Source, Visibility};
use solidcraft_geom::paper::{MAX_COORDINATE_MM, Picture};
use solidcraft_geom::section::CurveAccuracy;
@@ -39,9 +44,10 @@ use super::CommandSpec;
use crate::drawing_view::{CHORD_RANGE, Extents, LineRequest, Lines, MAX_PATH_IDS, MAX_PATHS, OnSheet, ShownSet, Unmeasured};
use crate::params::bad;
use crate::schema::drawing_view::{
AccuracyResult, BoxResult, ChoicesResult, CopyParams, DPI_RANGE, EditParams, EditResult, ExtentsResult, FromResult, InspectParams, InspectResult,
LineResult, LineSourceResult, LinesStatusResult, MoveParams, OutsideResult, PathSegResult, PictureResult, PieceResult, PlaceParam, ScaleResult,
SceneResult, SettingResult, SettingsResult, SnapshotParams, SnapshotResult, SourceResult, ToSheetResult, ViewResult, ViewSet,
AccuracyResult, BaseParams, BoxResult, ChoicesResult, CopyParams, CreateResult, DPI_RANGE, EditParams, EditResult, ExtentsResult, FromResult,
InspectParams, InspectResult, LineResult, LineSourceResult, LinesStatusResult, MoveParams, OutsideResult, PathSegResult, PictureResult,
PieceResult, PlaceParam, ProjectedParams, ScaleResult, SceneResult, SettingResult, SettingsResult, SnapshotParams, SnapshotResult, SourceResult,
ToSheetResult, ViewResult, ViewSet,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
@@ -56,6 +62,14 @@ const MOVE: &str = "drawing.view.move_to_sheet";
const MOVE_CONTRACT: TypedContract<MoveParams> = TypedContract::new();
const COPY: &str = "drawing.view.copy_to_sheet";
const COPY_CONTRACT: TypedContract<CopyParams> = TypedContract::new();
const BASE: &str = "drawing.view.base";
const BASE_CONTRACT: TypedContract<BaseParams> = TypedContract::new();
const PROJECTED: &str = "drawing.view.projected";
const PROJECTED_CONTRACT: TypedContract<ProjectedParams> = TypedContract::new();
/// How far a new projected view's boundary is from its parent's, in paper millimetres, until a
/// drawing style gives it.
const DEFAULT_GAP_MM: f64 = 20.0;
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Drawing View Snapshot", crate::checked!("drawing.view.snapshot", snapshot))
.icon("export")
@@ -92,6 +106,20 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Drawing View Snap
.contract(&COPY_CONTRACT)
.output(output_schema_for::<ToSheetResult>)
.params("view: a saved view's id; sheet: id or name; position_mm?: [x, y] for the copy's centre (default the sheet's centre); with_linked?: the views lined up with it copied too, lined up with the copy (true), or not (false); needed when it has any → {view: the copy, sheet, views: [the copies, its first], outside?: [those whose boundary leaves the sheet's drawing space]}. A copy follows none of the views it was copied from: a projected view copied alone becomes a base view, looking the way it did, with the settings it had. Each view's dimensions and balloons are copied with it, with ids of their own. One undo step."),
CommandSpec::new(BASE, "Base View", crate::checked!("drawing.view.base", base))
.icon("new")
.in_drawing()
.doc_only()
.contract(&BASE_CONTRACT)
.output(output_schema_for::<CreateResult>)
.params("sheet: id or name; view?: as drawing.view.snapshot's (front by default); scale?: \"paper:model\" (default: the largest ISO 5455 preset at which it lies inside the sheet's drawing space, centred where it goes, or 1:1 with nothing to measure; saved either way); position_mm?: [x, y] for its centre (default the sheet's centre); display_style?, reference_style?, reference_hides_model?, reference_margin_mm?, tangent_edges?: settings it sets itself (each left out follows the default) → {view, sheet, scale: {text, factor, fitted}, boundary_mm, outside?: [it, when its boundary leaves the sheet's drawing space]}. A view of the drawing's design. One undo step; no model is evaluated."),
CommandSpec::new(PROJECTED, "Projected View", crate::checked!("drawing.view.projected", projected))
.icon("new")
.in_drawing()
.doc_only()
.contract(&PROJECTED_CONTRACT)
.output(output_schema_for::<CreateResult>)
.params("parent: a saved view's id; side: left, right, above or below, where it sits (the drawing's projection says which side of the model it shows); place?: {at: [x, y]} or {aligned: n} (default: lined up with its parent, its boundary 20 mm past the parent's) → {view, sheet: its parent's, scale: its parent's, boundary_mm, outside?}. Its scale and display settings follow its parent; drawing.view.edit sets its own. One undo step; no model is evaluated."),
];
fn snapshot(s: &mut Session, p: &Value) -> Result<Value> {
@@ -126,7 +154,7 @@ fn snapshot(s: &mut Session, p: &Value) -> Result<Value> {
let position = request.position_mm.map_or(paper * 0.5, |p| p.0);
let (scale, fitted) = match settings.scale {
Some(scale) => (scale, false),
None => (fit(shown.size(), position, border)?, true),
None => (fit(shown.size(), position, border, CMD)?, true),
};
let frame = crate::drawing_view::frame(basis, scale, &shown, position, CMD)?;
let boundary = frame.boundary(&shown).map_err(|e| bad(CMD, e.to_string()))?;
@@ -603,6 +631,103 @@ fn copy_to_sheet(s: &mut Session, p: &Value) -> Result<Value> {
to_sheet_answer(first, sheet, &copies, outside)
}
fn base(s: &mut Session, p: &Value) -> Result<Value> {
let request: BaseParams = crate::schema::read(BASE, p)?;
let record = s.drawing(BASE)?.clone();
let (sheet, middle) = sheet_named(&record, &request.sheet, BASE)?;
let source = record.sources.first().map(|source| source.id).ok_or_else(|| bad(BASE, "the drawing draws no design"))?;
let direction = request.view.unwrap_or_default().0;
let position = request.position_mm.map_or(middle, |p| p.0);
let id = record.allocate_id(&mut s.ids).map_err(|e| bad(BASE, e.to_string()))?;
let settings = ViewOverrides {
scale: request.scale.map(|scale| scale.0),
display_style: request.display_style.map(|style| style.0),
reference_style: request.reference_style.map(|style| style.0),
reference_hides_model: request.reference_hides_model,
tangent_edges: request.tangent_edges.map(|edges| edges.0),
};
let place = ViewPlace::At([position.x, position.y]);
let kind = ViewKind::Base { source, view: direction };
let mut view = View { id, sheet, kind, place, settings, reference_margin_mm: request.reference_margin_mm.unwrap_or(0.0) };
let mut fitted = false;
if view.settings.scale.is_none() {
// The record's rules first, at 1:1, so a bad direction, margin or place is refused as the
// record says; then the fitted scale, which is saved. With nothing to measure, 1:1.
let one = Scale::new(1.0, 1.0).map_err(|e| bad(BASE, e.to_string()))?;
let mut staged = Drawing::clone(&record);
staged.add_views([Arc::new(View { settings: ViewOverrides { scale: Some(one), ..view.settings }, ..view.clone() })]);
staged.validate().map_err(|e| bad(BASE, e.to_string()))?;
let space = crate::drawing_view::sheet(&staged, sheet, BASE)?.drawing_space().map_err(|e| bad(BASE, e.to_string()))?;
let basis = staged.resolve_view(id).map_err(|e| bad(BASE, e.to_string()))?.basis;
let measured = crate::drawing_view::shown(s).and_then(|set| crate::drawing_view::measure(&set, &basis)).ok();
fitted = measured.is_some();
view.settings.scale = Some(match measured {
Some(extents) => fit(extents.model.inflated(view.reference_margin_mm).size(), position, space, BASE)?,
None => one,
});
}
s.edit_drawing(BASE, |d| {
d.add_views([Arc::new(view)]);
Ok(())
})?;
created(s, BASE, id, fitted)
}
fn projected(s: &mut Session, p: &Value) -> Result<Value> {
let request: ProjectedParams = crate::schema::read(PROJECTED, p)?;
let record = s.drawing(PROJECTED)?.clone();
let (parent, sheet) = find(&record, request.parent.0, PROJECTED).map(|v| (v.id, v.sheet))?;
let side = request.side.0;
let id = record.allocate_id(&mut s.ids).map_err(|e| bad(PROJECTED, e.to_string()))?;
let parent_centre = record.view_centre(parent).map_err(|e| bad(PROJECTED, e.to_string()))?;
let place = match request.place {
Some(PlaceParam::At(at)) => ViewPlace::At(at),
Some(PlaceParam::Aligned(along)) => ViewPlace::Aligned(along),
None => ViewPlace::Aligned(side.along(parent_centre)),
};
let kind = ViewKind::Projected { parent, side };
let mut view = View { id, sheet, kind, place, settings: ViewOverrides::default(), reference_margin_mm: 0.0 };
if request.place.is_none() {
// Staged on its parent's centre to measure it, then moved along the free axis until its
// boundary is the gap past its parent's.
let mut staged = Drawing::clone(&record);
staged.add_views([Arc::new(view.clone())]);
staged.validate().map_err(|e| bad(PROJECTED, e.to_string()))?;
let theirs = crate::drawing_view::on_sheet(s, &staged, parent, PROJECTED)?.boundary;
let ours = crate::drawing_view::on_sheet(s, &staged, id, PROJECTED)?.boundary;
let half = (side.along(ours.max) - side.along(ours.min)) / 2.0;
view.place = ViewPlace::Aligned(match side {
ProjectedSide::Right | ProjectedSide::Above => side.along(theirs.max) + DEFAULT_GAP_MM + half,
ProjectedSide::Left | ProjectedSide::Below => side.along(theirs.min) - DEFAULT_GAP_MM - half,
});
}
s.edit_drawing(PROJECTED, |d| {
d.add_views([Arc::new(view)]);
Ok(())
})?;
created(s, PROJECTED, id, false)
}
/// The reply for view `id`, just made by `cmd`: its sheet, its scale (`fitted` when worked out),
/// its boundary, and itself when it leaves its sheet's drawing space.
fn created(s: &Session, cmd: &str, id: u64, fitted: bool) -> Result<Value> {
let d = s.drawing(cmd)?;
let sheet_id = find(d, id, cmd)?.sheet;
let sheet = crate::drawing_view::sheet(d, sheet_id, cmd)?;
let scale = d.resolve_view(id).map_err(|e| bad(cmd, e.to_string()))?.scale.value;
let placed = crate::drawing_view::on_sheet(s, d, id, cmd)?;
let outside = if crate::drawing_view::leaves(sheet, &placed, cmd)? { vec![(id, placed)] } else { Vec::new() };
let reply = CreateResult {
view: ids::encode(id),
sheet: ids::encode(sheet_id),
scale: ScaleResult { text: scale.to_string(), factor: scale.factor(), fitted },
boundary_mm: BoxResult { min: placed.boundary.min.into(), max: placed.boundary.max.into() },
outside: outside_results(outside),
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| crate::EngineError::Other(e.to_string()))
}
fn to_sheet_answer(view: u64, sheet: u64, views: &[u64], outside: Vec<(u64, OnSheet)>) -> Result<Value> {
let reply = ToSheetResult {
view: ids::encode(view),
@@ -643,13 +768,13 @@ fn direction(basis: &Plane) -> ViewResult {
/// The largest preset scale at which a view `size` (model mm), centred at `position`, lies inside
/// the drawing border: the room is twice the distance from the position to the nearer side,
/// each way.
fn fit(size: Vec2, position: Vec2, border: Aabb2) -> Result<Scale> {
fn fit(size: Vec2, position: Vec2, border: Aabb2, cmd: &str) -> Result<Scale> {
let room =
Vec2::new(2.0 * (position.x - border.min.x).min(border.max.x - position.x), 2.0 * (position.y - border.min.y).min(border.max.y - position.y));
Scale::fit(size, room).ok_or_else(|| {
let name = |s: Option<&Scale>| s.map(|s| s.to_string()).unwrap_or_default();
let presets = format!("from {} to {}", name(PRESETS.first()), name(PRESETS.last()));
bad(CMD, format!("the view fits the sheet's drawing area at no preset scale {presets} here: give a scale, a position or a larger sheet"))
bad(cmd, format!("the view fits the sheet's drawing area at no preset scale {presets} here: give a scale, a position or a larger sheet"))
})
}
+191
View File
@@ -368,6 +368,197 @@ fn a_side_view_copied_alone_is_a_base_view_looking_the_way_it_did() {
assert_eq!(saved(&s, of_right).settings, own, "the copy follows nothing");
}
/// `cmd` makes a view as `p` says, in one undo step: its reply, which matches its schema, and the
/// new view's id.
fn create(s: &mut Session, cmd: &str, p: Value) -> (Value, u64) {
let steps = s.undo.len();
let reply = run(s, cmd, p);
assert_eq!(s.undo.len(), steps + 1, "one undo step");
let schema = output_schema_for::<CreateResult>();
assert!(conforms(schema.as_value(), schema.as_value(), &reply), "{reply}");
let id = ids::decode(reply["view"].as_str().unwrap()).unwrap();
(reply, id)
}
/// `cmd` refuses `p`, leaving the drawing and the undo steps as they were: why.
fn create_refused(s: &mut Session, cmd: &str, p: Value) -> String {
let (record, steps) = (s.drawing("test").ok().cloned(), s.undo.len());
let message = s.execute(cmd, &p).err().map(|e| e.to_string()).unwrap_or_else(|| panic!("accepted: {p}"));
assert_eq!((s.drawing("test").ok().cloned(), s.undo.len()), (record, steps), "{p}");
message
}
/// A drawing of the block, with no views yet.
fn block_drawing() -> Session {
let mut s = block();
run(&mut s, "drawing.create", json!({}));
s
}
/// A base view with no scale takes the largest preset at which it lies inside the sheet's drawing
/// space (not the paper), centred where it goes, its margin included: the 100 x 20 front view on A3
/// at 2:1, as the snapshot fits it; that scale is saved as the view's own. A scale given is kept,
/// the settings given are the view's own, each view is one undo step ([`create`]), and with nothing
/// to measure the view takes 1:1.
#[test]
fn a_base_view_fits_its_sheet_and_saves_the_scale() {
let mut s = block_drawing();
let source = s.drawing("test").unwrap().sources[0].id;
let (made, front) = create(&mut s, BASE, json!({"sheet": "Sheet 1"}));
assert_eq!((made["scale"]["text"].as_str(), made["scale"]["fitted"].as_bool()), (Some("2:1"), Some(true)));
assert_box(&made, [110.0, 128.5], [310.0, 168.5]);
assert!(made.get("outside").is_none(), "{made}");
let view = saved(&s, front);
assert_eq!((view.kind, view.place), (ViewKind::Base { source, view: ViewDirection::Named(NamedView::Front) }, ViewPlace::At([210.0, 148.5])));
assert_eq!(view.settings, ViewOverrides { scale: Some("2:1".parse().unwrap()), ..ViewOverrides::default() }, "the fitted scale is saved");
assert_eq!(inspect_view(&mut s, front)["settings"]["scale"], json!({"value": "2:1", "from": "this", "set": "2:1"}));
let near_the_left = create(&mut s, BASE, json!({"sheet": "Sheet 1", "position_mm": [108, 148.5]})).0;
assert_eq!(near_the_left["scale"]["text"], "1:1", "98 mm to the drawing border: 196 mm of room (the paper's edge would give 216)");
let with_margin = create(&mut s, BASE, json!({"sheet": "Sheet 1", "reference_margin_mm": 60})).0;
assert_eq!(with_margin["scale"]["text"], "1:1", "220 x 140 with the margin: 440 mm at 2:1");
let p = json!({"sheet": "Sheet 1", "view": "top", "scale": "1:5", "position_mm": [100, 100], "display_style": "visible_hidden", "reference_margin_mm": 5});
let (given, top) = create(&mut s, BASE, p);
assert_eq!((given["scale"]["text"].as_str(), given["scale"]["fitted"].as_bool()), (Some("1:5"), Some(false)));
assert_box(&given, [89.0, 94.0], [111.0, 106.0]);
let view = saved(&s, top);
assert_eq!(
view.settings,
ViewOverrides { scale: Some("1:5".parse().unwrap()), display_style: Some(VisualStyle::VisibleHidden), ..ViewOverrides::default() }
);
assert_eq!(view.reference_margin_mm, 5.0);
let before = s.drawing("test").unwrap().views.len();
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.drawing("test").unwrap().views.len(), before - 1, "one undo takes the view away");
assert!(s.drawing("test").unwrap().views.iter().all(|v| v.id != top));
// Nothing to measure: one sketch, something to draw (an empty design isn't), and no bodies.
let mut sketch_only = Session::default();
run(&mut sketch_only, "sketch.create", json!({"plane": "XY"}));
run(&mut sketch_only, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 5]}));
run(&mut sketch_only, "sketch.finish", json!({}));
run(&mut sketch_only, "drawing.create", json!({}));
let (placeholder, _) = create(&mut sketch_only, BASE, json!({"sheet": "Sheet 1"}));
assert_eq!((placeholder["scale"]["text"].as_str(), placeholder["scale"]["fitted"].as_bool()), (Some("1:1"), Some(false)));
assert_box(&placeholder, [200.0, 138.5], [220.0, 158.5]);
}
/// A projected view is lined up with its parent, its boundary 20 mm past the parent's on its side,
/// and follows its parent's scale and settings: right of the 50 x 10 front view at 1:2 (185..235 x
/// 143.5..153.5) the 25 x 10 right view goes to 255..280, left of it to 140..165, the 50 x 25 views
/// above and below to 173.5..198.5 and 98.5..123.5. The view above shows the top in third angle and
/// the bottom in first. A place given is kept; one past the drawing space is listed, not moved.
#[test]
fn a_projected_view_lines_up_a_gap_past_its_parent() {
let mut s = block_drawing();
let (_, front) = create(&mut s, BASE, json!({"sheet": "Sheet 1", "scale": "1:2"}));
let project = |s: &mut Session, side: &str| create(s, PROJECTED, json!({"parent": ids::encode(front), "side": side}));
let (right, right_id) = project(&mut s, "right");
assert_box(&right, [255.0, 143.5], [280.0, 153.5]);
assert_eq!(right["scale"]["text"], "1:2");
assert_eq!(right["sheet"], json!(ids::encode(saved(&s, front).sheet)));
let view = saved(&s, right_id);
assert_eq!(
(view.kind, view.place, view.settings),
(ViewKind::Projected { parent: front, side: ProjectedSide::Right }, ViewPlace::Aligned(267.5), ViewOverrides::default())
);
let d = s.drawing("test").unwrap().clone();
assert!((d.view_centre(right_id).unwrap() - Vec2::new(267.5, 148.5)).len() < EPS, "on the parent's line");
assert_box(&project(&mut s, "left").0, [140.0, 143.5], [165.0, 153.5]);
let (above, above_id) = project(&mut s, "above");
assert_box(&above, [185.0, 173.5], [235.0, 198.5]);
assert_box(&project(&mut s, "below").0, [185.0, 98.5], [235.0, 123.5]);
assert_eq!(inspect_view(&mut s, above_id)["direction"]["named"], "top", "third angle");
set_projection(&mut s, Projection::First);
assert_eq!(inspect_view(&mut s, above_id)["direction"]["named"], "bottom", "first angle");
let at = json!({"parent": ids::encode(front), "side": "right", "place": {"at": [300.0, 60.0]}});
let (_, apart) = create(&mut s, PROJECTED, at);
assert_eq!(saved(&s, apart).place, ViewPlace::At([300.0, 60.0]));
let past = json!({"parent": ids::encode(front), "side": "right", "place": {"aligned": 405.0}});
let (beyond, beyond_id) = create(&mut s, PROJECTED, past);
assert_eq!(outside(&beyond).1, [beyond_id], "listed: {beyond}");
assert_eq!(saved(&s, beyond_id).place, ViewPlace::Aligned(405.0), "not moved");
// A projected view of a projected view lines up with its own parent.
let (_, back) = create(&mut s, PROJECTED, json!({"parent": ids::encode(right_id), "side": "right"}));
assert_eq!(saved(&s, back).place, ViewPlace::Aligned(267.5 + 12.5 + 20.0 + 25.0));
// On its parent's sheet, whichever that is.
let second = second_sheet(&mut s);
let (_, other) = create(&mut s, BASE, json!({"sheet": ids::encode(second), "scale": "1:2"}));
let (beside, beside_id) = create(&mut s, PROJECTED, json!({"parent": ids::encode(other), "side": "below"}));
assert_eq!((beside["sheet"].clone(), saved(&s, beside_id).sheet), (json!(ids::encode(second)), second));
}
/// What view creation refuses, each with the drawing and the undo steps unchanged: no drawing, a
/// drawing that draws no design, unknown or badly given sheets, parents and sides, places and
/// margins past their limits, a scale that doesn't fit, unknown fields, and a 33rd view in a chain.
#[test]
fn hostile_view_creation_is_refused() {
let mut s = block();
assert!(create_refused(&mut s, BASE, json!({"sheet": "Sheet 1"})).contains("drawing"));
let mut s = block_drawing();
let base = |p: Value| {
let mut p = p;
p["sheet"] = p.get("sheet").cloned().unwrap_or(json!("Sheet 1"));
p
};
for p in [
json!({"sheet": "Sheet 9"}),
json!({"sheet": 1}),
json!({"sheet": null}),
base(json!({"view": "sideways"})),
base(json!({"scale": "0:1"})),
base(json!({"scale": "1:1", "position_mm": [1e300, 0]})),
base(json!({"position_mm": [1e300, 0]})),
base(json!({"position_mm": [5, 148.5]})),
base(json!({"reference_margin_mm": 1e6})),
base(json!({"reference_margin_mm": -1})),
base(json!({"display_style": "glossy"})),
base(json!({"colour": "red"})),
base(json!({"position_mm": null})),
] {
create_refused(&mut s, BASE, p);
}
assert!(create_refused(&mut s, BASE, json!({})).contains("sheet"));
let (_, front) = create(&mut s, BASE, json!({"sheet": "Sheet 1", "scale": "1:2"}));
let parent = json!(ids::encode(front));
for p in [
json!({"parent": ids::encode(99), "side": "right"}),
json!({"parent": 1, "side": "right"}),
json!({"parent": parent, "side": "behind"}),
json!({"parent": parent}),
json!({"parent": parent, "side": "right", "place": {"aligned": 1e300}}),
json!({"parent": parent, "side": "right", "place": {"at": [0, 1e300]}}),
json!({"parent": parent, "side": "right", "place": null}),
json!({"parent": parent, "side": "right", "scale": "1:1"}),
] {
create_refused(&mut s, PROJECTED, p);
}
// A chain is at most 32 views: the base view and 31 projected from it, one from the next.
let mut last = front;
for _ in 1..32 {
last = create(&mut s, PROJECTED, json!({"parent": ids::encode(last), "side": "above", "place": {"at": [100.0, 100.0]}})).1;
}
let message = create_refused(&mut s, PROJECTED, json!({"parent": ids::encode(last), "side": "above"}));
assert!(message.contains("at most 32"), "{message}");
// A drawing that draws no design.
let mut d = (**s.drawing("test").unwrap()).clone();
d.sources.clear();
d.views.clear();
s.doc_mut().put_drawing(Arc::new(d));
assert!(create_refused(&mut s, BASE, json!({"sheet": "Sheet 1"})).contains("draws no design"));
}
/// A view's ids come from the session's id source: sessions seeded alike make the same ids.
#[test]
fn a_new_views_id_comes_from_the_sessions_seed() {
let made = |seed: u64| {
let mut s = block_drawing();
s.seed_ids(seed);
let (_, front) = create(&mut s, BASE, json!({"sheet": "Sheet 1"}));
[front, create(&mut s, PROJECTED, json!({"parent": ids::encode(front), "side": "right"})).1]
};
assert_eq!(made(7), made(7));
assert_ne!(made(7), made(8));
}
/// The block's boundary in each view at 1:2, worked by hand: front 50 x 10, top 50 x 25, right
/// 25 x 10, and from an isometric corner (100 + 50)/sqrt(2)/2 by (100 + 50 + 2 x 20)/sqrt(6)/2.
/// The centre lands on the position, and the extents are the model's own millimetres.
+16 -6
View File
@@ -11,7 +11,7 @@ use std::collections::{BTreeMap, HashSet};
use std::fmt;
use std::sync::Arc;
use solidcraft_doc::drawing::Drawing;
use solidcraft_doc::drawing::{Drawing, Sheet};
use solidcraft_doc::{MAX_PLACEMENT_DEPTH, MAX_PLACEMENTS, ModelBody, ModelState, ids};
use solidcraft_geom::drawing_view::{Scale, ViewFrame};
use solidcraft_geom::{Aabb2, EPS, Plane, Vec2};
@@ -221,23 +221,33 @@ pub fn on_sheet(s: &Session, drawing: &Drawing, id: u64, cmd: &str) -> Result<On
}
/// Each of `views` of `drawing` (one of `s`'s design's) whose boundary leaves its sheet's drawing
/// space ([`solidcraft_doc::drawing::Sheet::drawing_space`]), sides included, with where it sits:
/// space ([`Sheet::drawing_space`]), sides included, with where it sits:
/// what a change of paper or a move to another sheet leaves outside. Nothing is moved.
pub fn outside(s: &Session, drawing: &Drawing, views: &[u64], cmd: &str) -> Result<Vec<(u64, OnSheet)>> {
let mut out = Vec::new();
for &id in views {
let view = drawing.view(id).ok_or_else(|| bad(cmd, format!("no view {}", ids::encode(id))))?;
let sheet = drawing.sheets.iter().find(|s| s.id == view.sheet).ok_or_else(|| bad(cmd, format!("no sheet {}", ids::encode(view.sheet))))?;
// A view drawn up to the border, its sides on it, is inside: the margin takes rounding.
let space = sheet.drawing_space().map_err(|e| bad(cmd, e.to_string()))?.inflated(EPS);
let placed = on_sheet(s, drawing, id, cmd)?;
if !(space.contains(placed.boundary.min) && space.contains(placed.boundary.max)) {
if leaves(sheet(drawing, view.sheet, cmd)?, &placed, cmd)? {
out.push((id, placed));
}
}
Ok(out)
}
/// Sheet `id` of `drawing`.
pub fn sheet<'a>(drawing: &'a Drawing, id: u64, cmd: &str) -> Result<&'a Sheet> {
drawing.sheets.iter().find(|s| s.id == id).map(|s| &**s).ok_or_else(|| bad(cmd, format!("no sheet {}", ids::encode(id))))
}
/// Whether a view `placed` on `sheet` leaves its drawing space
/// ([`Sheet::drawing_space`]), sides included: [`outside`]'s rule.
pub fn leaves(sheet: &Sheet, placed: &OnSheet, cmd: &str) -> Result<bool> {
// A view drawn up to the border, its sides on it, is inside: the margin takes rounding.
let space = sheet.drawing_space().map_err(|e| bad(cmd, e.to_string()))?.inflated(EPS);
Ok(!(space.contains(placed.boundary.min) && space.contains(placed.boundary.max)))
}
#[cfg(test)]
mod tests {
use super::*;
+90 -1
View File
@@ -14,7 +14,7 @@ use serde_json::{Value, json};
use solidcraft_doc::drawing_view::{TangentEdges, ViewDirection, ViewSettings};
use solidcraft_geom::Seg2;
use solidcraft_geom::drawing_sheet::{Orientation, PaperSize, SheetSize};
use solidcraft_geom::drawing_view::{NamedView, Scale};
use solidcraft_geom::drawing_view::{NamedView, ProjectedSide, Scale};
use solidcraft_geom::paper::ObjectKind;
use solidcraft_geom::section::CurveAccuracy;
use solidcraft_render::VisualStyle;
@@ -442,6 +442,95 @@ pub struct CopyParams {
pub with_linked: Option<bool>,
}
/// What `drawing.view.base` takes: the sheet, which way the view looks, its scale and where its
/// centre goes, and each setting it sets itself (a setting left out follows the default).
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct BaseParams {
/// The sheet, by id or name.
#[serde(deserialize_with = "super::drawing_sheet::sheet_ref")]
pub sheet: String,
/// Which way it looks (front when not given).
#[serde(default, deserialize_with = "super::present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ViewParam")]
pub view: Option<ViewParam>,
/// Its scale (when not given, the largest preset at which it lies inside the sheet's drawing
/// space, or 1:1 with nothing to measure); saved either way.
#[serde(default, deserialize_with = "super::present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ScaleParam")]
pub scale: Option<ScaleParam>,
/// Where its centre goes, in paper millimetres from the sheet's lower-left corner (the sheet's
/// centre when not given).
#[serde(default, deserialize_with = "super::present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Point2")]
pub position_mm: Option<Point2>,
#[serde(default, deserialize_with = "super::present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "StyleParam")]
pub display_style: Option<StyleParam>,
#[serde(default, deserialize_with = "super::present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "StyleParam")]
pub reference_style: Option<StyleParam>,
#[serde(default, deserialize_with = "super::present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "bool")]
pub reference_hides_model: Option<bool>,
/// Model millimetres added on every side of its extents: 0 to 100000 (0 when not given).
#[serde(default, deserialize_with = "super::present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "f64")]
pub reference_margin_mm: Option<f64>,
#[serde(default, deserialize_with = "super::present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "TangentEdgesParam")]
pub tangent_edges: Option<TangentEdgesParam>,
}
/// What `drawing.view.projected` takes: its parent, the side of it it sits on, and where.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct ProjectedParams {
pub parent: ViewIdParam,
pub side: SideParam,
/// Where it sits (when not given, lined up with its parent, a gap past the parent's boundary).
#[serde(default, deserialize_with = "super::present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "PlaceParam")]
pub place: Option<PlaceParam>,
}
/// The side of its parent a projected view sits on.
#[derive(Clone, Copy, Debug, PartialEq, Deserialize)]
#[serde(transparent)]
pub struct SideParam(pub ProjectedSide);
impl JsonSchema for SideParam {
fn schema_name() -> Cow<'static, str> {
"ProjectedSide".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
let names: Vec<Value> = ProjectedSide::ALL.iter().filter_map(|s| serde_json::to_value(s).ok()).collect();
json_schema!({
"description": "The side of its parent it sits on: left, right, above or below. The drawing's projection says which side of the model it shows.",
"type": "string",
"enum": names
})
}
}
/// What `drawing.view.base` and `drawing.view.projected` return.
#[derive(Debug, Serialize, JsonSchema)]
pub struct CreateResult {
/// The new view.
pub view: String,
pub sheet: String,
/// The scale it draws at: given or fitted for a base view, its parent's for a projected one.
pub scale: ScaleResult,
pub boundary_mm: BoxResult,
/// It, when its boundary leaves the sheet's drawing space; it isn't moved back in.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub outside: Vec<OutsideResult>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// What `drawing.view.move_to_sheet` and `drawing.view.copy_to_sheet` return.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ToSheetResult {
+14 -1
View File
@@ -1,7 +1,7 @@
use serde_json::json;
use solidcraft_doc::drawing_view::ViewSettings;
use super::{CopyParams, EditParams, InspectParams, MoveParams, SnapshotParams, ViewSet};
use super::{BaseParams, CopyParams, EditParams, InspectParams, MoveParams, ProjectedParams, SnapshotParams, ViewSet};
use crate::schema::input_schema_for;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors};
@@ -24,10 +24,23 @@ fn schemas_and_the_key_check_agree() {
"view",
];
assert_keys(input_schema_for::<SnapshotParams>(), &keys);
let base = [
"display_style",
"position_mm",
"reference_hides_model",
"reference_margin_mm",
"reference_style",
"scale",
"sheet",
"tangent_edges",
"view",
];
assert_keys(input_schema_for::<BaseParams>(), &base);
assert_keys(input_schema_for::<InspectParams>(), &["view"]);
assert_keys(input_schema_for::<EditParams>(), &["set", "view"]);
assert_keys(input_schema_for::<MoveParams>(), &["break_alignment", "position_mm", "sheet", "view"]);
assert_keys(input_schema_for::<CopyParams>(), &["position_mm", "sheet", "view", "with_linked"]);
assert_keys(input_schema_for::<ProjectedParams>(), &["parent", "place", "side"]);
}
/// `drawing.view.edit` changes what a saved view sets itself, field for field, and its direction,