Skip to main content

kcl_lib/std/
view.rs

1//! Standard library functions for named views.
2
3use kcl_api::UnitLength;
4
5use crate::SourceRange;
6use crate::errors::KclError;
7use crate::errors::KclErrorDetails;
8use crate::execution::Artifact;
9use crate::execution::ArtifactId;
10use crate::execution::CameraView;
11use crate::execution::CodeRef;
12use crate::execution::ExecState;
13use crate::execution::KclValue;
14use crate::execution::NamedViewValue;
15use crate::execution::Orientation;
16use crate::execution::Point3d;
17use crate::execution::Projection;
18use crate::execution::Visibility;
19use crate::execution::named_view_artifact;
20use crate::execution::types::NumericType;
21use crate::execution::types::NumericTypeExt;
22use crate::execution::types::RuntimeType;
23use crate::std::Args;
24use crate::std::args::TyF64;
25
26/// Create a camera view that looks at the model from a standard orientation.
27pub async fn oriented(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
28    // The declared KCL signature has already coerced every argument, so the
29    // runtime types passed here only convert, they do not validate. Enum
30    // coercion is nominal, so an `any` runtime type still cannot let a
31    // different enum through; `wrongly_typed_arguments_are_rejected` pins
32    // that, because the fallback if it stopped holding is an internal error
33    // rather than a diagnostic the author can act on.
34    let orientation: Orientation = args.get_unlabeled_kw_arg("orientation", &RuntimeType::any(), exec_state)?;
35    let target: Option<[TyF64; 3]> = args.get_kw_arg_opt("target", &RuntimeType::point3d(), exec_state)?;
36    let distance: Option<TyF64> = args.get_kw_arg_opt("distance", &RuntimeType::length(), exec_state)?;
37    let projection: Option<Projection> = args.get_kw_arg_opt("projection", &RuntimeType::any(), exec_state)?;
38
39    let view = CameraView::oriented(
40        orientation,
41        target.map(millimeter_point),
42        distance.map(millimeter_length),
43        projection,
44        vec![args.source_range.into()],
45    )
46    .map_err(|err| view_error(err, &args))?;
47    Ok(KclValue::CameraView { value: Box::new(view) })
48}
49
50/// Create a camera view that looks along a custom direction.
51pub async fn directed(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
52    // The declared KCL signature has already coerced every argument, so the
53    // runtime types passed here only convert, they do not validate. Enum
54    // coercion is nominal, so an `any` runtime type still cannot let a
55    // different enum through; `wrongly_typed_arguments_are_rejected` pins
56    // that, because the fallback if it stopped holding is an internal error
57    // rather than a diagnostic the author can act on.
58    let direction: [TyF64; 3] = args.get_unlabeled_kw_arg("direction", &RuntimeType::point3d(), exec_state)?;
59    let up: Option<[TyF64; 3]> = args.get_kw_arg_opt("up", &RuntimeType::point3d(), exec_state)?;
60    let target: Option<[TyF64; 3]> = args.get_kw_arg_opt("target", &RuntimeType::point3d(), exec_state)?;
61    let distance: Option<TyF64> = args.get_kw_arg_opt("distance", &RuntimeType::length(), exec_state)?;
62    let projection: Option<Projection> = args.get_kw_arg_opt("projection", &RuntimeType::any(), exec_state)?;
63
64    let view = CameraView::directed(
65        unitless_direction(direction),
66        up.map(unitless_direction),
67        target.map(millimeter_point),
68        distance.map(millimeter_length),
69        projection,
70        vec![args.source_range.into()],
71    )
72    .map_err(|err| view_error(err, &args))?;
73    Ok(KclValue::CameraView { value: Box::new(view) })
74}
75
76/// Create a named view: a camera paired with the objects it shows or hides.
77pub async fn named(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
78    // As in the camera constructors, the declared KCL signature has already
79    // coerced every argument, so the runtime types here only convert.
80    let name: String = args.get_unlabeled_kw_arg("name", &RuntimeType::string(), exec_state)?;
81    let camera: CameraView = args.get_kw_arg("camera", &RuntimeType::any(), exec_state)?;
82    let baseline: Visibility = args.get_kw_arg("baseline", &RuntimeType::any(), exec_state)?;
83    let except: Option<Vec<KclValue>> = args.get_kw_arg_opt("except", &RuntimeType::any(), exec_state)?;
84
85    let except_ids = except
86        .as_ref()
87        .map(|objects| except_artifact_ids(objects, args.source_range))
88        .transpose()?;
89
90    // The id is taken before the existing views are read, because taking one
91    // borrows `exec_state` mutably and reading them borrows it immutably.
92    let artifact_id = exec_state.next_artifact_id();
93    let view = NamedViewValue::new(
94        artifact_id,
95        name,
96        camera,
97        baseline,
98        except_ids,
99        args.source_range.module_id(),
100        exec_state.registered_named_views(),
101        vec![args.source_range.into()],
102    )
103    .map_err(|err| view_error(err, &args))?;
104    // A view sends no engine command: it is data for a consumer to activate
105    // later, so registering the artifact is the whole effect of the call.
106    exec_state.add_artifact(Artifact::NamedView(named_view_artifact(
107        &view,
108        CodeRef::placeholder(args.source_range),
109    )));
110
111    Ok(KclValue::NamedView { value: Box::new(view) })
112}
113
114/// Reads the artifact id of each object in an `except` list.
115///
116/// The accepted kinds do not share a representation, so each arm reads the
117/// artifact id from the field that owns it. Any other kind of value means
118/// coercion against the declared signature did not do its job, which is an
119/// internal error rather than something the author can act on.
120fn except_artifact_ids(objects: &[KclValue], source_range: SourceRange) -> Result<Vec<ArtifactId>, KclError> {
121    objects
122        .iter()
123        .map(|object| match object {
124            KclValue::Solid { value } => Ok(value.artifact_id),
125            KclValue::Sketch { value } => Ok(value.artifact_id),
126            KclValue::GdtAnnotation { value } => Ok(ArtifactId::new(value.id)),
127            KclValue::Helix { value } => Ok(value.artifact_id),
128            KclValue::Plane { value } => {
129                if value.is_standard() || value.is_uninitialized() {
130                    Err(KclError::new_semantic(KclErrorDetails::new(
131                        "Named views cannot control default planes or other uninitialized planes because their ids do not identify independent engine objects. Use a standalone plane returned by `offsetPlane()`."
132                            .to_owned(),
133                        vec![source_range],
134                    )))
135                } else {
136                    Ok(value.artifact_id)
137                }
138            }
139            KclValue::ImportedGeometry(value) => Ok(ArtifactId::new(value.id)),
140            other => Err(KclError::new_internal(KclErrorDetails::new(
141                format!(
142                    "`except` cannot hold {}; the declared signature should have rejected it",
143                    other.human_friendly_type()
144                ),
145                vec![source_range],
146            ))),
147        })
148        .collect()
149}
150
151/// Reports a rejected argument at the source range of the call that supplied
152/// it. The error's `Display` text is the whole message; the range is what tells
153/// the author which call to look at.
154///
155/// Both `CameraViewError` and `NamedViewError` are written so that their
156/// `Display` text is the author-facing message, which is why one helper serves
157/// every function in this module.
158fn view_error<E: std::fmt::Display>(err: E, args: &Args) -> KclError {
159    KclError::new_semantic(KclErrorDetails::new(err.to_string(), vec![args.source_range]))
160}
161
162// Every length a `CameraView` stores is converted to millimeters here, at the
163// boundary where the author's units are still known. A stored view is read by
164// more than one consumer -- the modeling app and STEP export -- and some of
165// those values become engine commands, which are in millimeters. Storing one
166// canonical unit means no consumer has to share a conversion convention with
167// the others; a consumer that reads the number and ignores the unit tag still
168// gets the right camera.
169//
170// These conversions are written here rather than through the shared
171// `FromKclValue for Point3d` impl (`std/args.rs`), which is deliberate. That
172// impl reconciles the three coordinate types with `NumericType::combine_eq_array`
173// and keeps the values as written, so a genuinely mixed-unit point such as
174// `[1inch, 25.4mm, 0]` is stored as the numbers 1, 25.4, 0 with no units at
175// all -- a different point from the one the author wrote. Converting each
176// coordinate individually, as below, is correct for that case.
177
178/// Converts a coerced point argument to a point in millimeters.
179fn millimeter_point([x, y, z]: [TyF64; 3]) -> Point3d {
180    Point3d {
181        x: x.to_mm(),
182        y: y.to_mm(),
183        z: z.to_mm(),
184        units: Some(UnitLength::Millimeters),
185    }
186}
187
188/// Converts a coerced length argument to a length in millimeters.
189fn millimeter_length(length: TyF64) -> TyF64 {
190    TyF64::new(length.to_mm(), NumericType::mm())
191}
192
193/// Converts a coerced point argument to a unitless direction vector. Each
194/// coordinate is read in millimeters first, so the coordinates of a
195/// mixed-unit vector such as `[1inch, 25.4mm, 0]` are on a common scale and
196/// the vector points where the author wrote it. The magnitude is discarded by
197/// normalization in the constructor, so the choice of millimeters here only
198/// has to be consistent across the three coordinates.
199fn unitless_direction([x, y, z]: [TyF64; 3]) -> Point3d {
200    Point3d {
201        x: x.to_mm(),
202        y: y.to_mm(),
203        z: z.to_mm(),
204        units: None,
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use crate::execution::ArtifactId;
211    use crate::execution::KclValue;
212    use crate::execution::Visibility;
213    use crate::execution::parse_execute;
214
215    /// A sketch V2 program that leaves two solids bound, `plate` and `boss`,
216    /// for a view to except. Mock execution is enough: a view sends no engine
217    /// command, and the artifact ids these solids carry are assigned during
218    /// execution rather than by the engine.
219    const TWO_SOLIDS: &str = r#"@settings(experimentalFeatures = allow)
220
221plateSketch = sketch(on = XY) {
222  edge1 = line(start = [var 0mm, var 0mm], end = [var 40mm, var 0mm])
223  edge2 = line(start = [var 40mm, var 0mm], end = [var 40mm, var 20mm])
224  edge3 = line(start = [var 40mm, var 20mm], end = [var 0mm, var 20mm])
225  edge4 = line(start = [var 0mm, var 20mm], end = [var 0mm, var 0mm])
226  coincident([edge1.end, edge2.start])
227  coincident([edge2.end, edge3.start])
228  coincident([edge3.end, edge4.start])
229  coincident([edge4.end, edge1.start])
230}
231plateRegion = region(point = [20mm, 10mm], sketch = plateSketch)
232plate = extrude(plateRegion, length = 5mm)
233
234bossSketch = sketch(on = XY) {
235  edge1 = line(start = [var 50mm, var 0mm], end = [var 60mm, var 0mm])
236  edge2 = line(start = [var 60mm, var 0mm], end = [var 60mm, var 10mm])
237  edge3 = line(start = [var 60mm, var 10mm], end = [var 50mm, var 10mm])
238  edge4 = line(start = [var 50mm, var 10mm], end = [var 50mm, var 0mm])
239  coincident([edge1.end, edge2.start])
240  coincident([edge2.end, edge3.start])
241  coincident([edge3.end, edge4.start])
242  coincident([edge4.end, edge1.start])
243}
244bossRegion = region(point = [55mm, 5mm], sketch = bossSketch)
245boss = extrude(bossRegion, length = 8mm)
246"#;
247
248    /// A `Show` baseline with no exception list keeps every object visible. The
249    /// baseline is required, so this is the shortest view a file can declare.
250    #[tokio::test(flavor = "multi_thread")]
251    async fn named_shows_everything_under_a_show_baseline() {
252        let program = format!(
253            "{TWO_SOLIDS}\nv = view::named(\n  \"Overview\",\n  camera = view::oriented(view::Orientation::Isometric),\n  baseline = view::Visibility::Show,\n)\n"
254        );
255        let result = parse_execute(&program).await.expect("the program executes");
256
257        let KclValue::NamedView { value } = result.variable("v") else {
258            panic!("`v` is not a named view");
259        };
260        assert_eq!(value.name(), "Overview");
261        assert_eq!(value.baseline(), Visibility::Show);
262        assert!(value.except_ids().is_empty());
263    }
264
265    /// The `except` list accepts more than one kind of object in one call, and
266    /// the artifact ids it stores are those of the objects named, in the order
267    /// written.
268    #[tokio::test(flavor = "multi_thread")]
269    async fn named_excepts_the_objects_it_is_given() {
270        let program = format!(
271            "{TWO_SOLIDS}\nv = view::named(\n  \"Plate only\",\n  camera = view::oriented(view::Orientation::Front),\n  baseline = view::Visibility::Hide,\n  except = [plate, boss, plate],\n)\n"
272        );
273        let result = parse_execute(&program).await.expect("the program executes");
274
275        let KclValue::Solid { value: plate } = result.variable("plate") else {
276            panic!("`plate` is not a solid");
277        };
278        let KclValue::Solid { value: boss } = result.variable("boss") else {
279            panic!("`boss` is not a solid");
280        };
281        let KclValue::NamedView { value } = result.variable("v") else {
282            panic!("`v` is not a named view");
283        };
284
285        assert_eq!(value.baseline(), Visibility::Hide);
286        // The repeated `plate` is dropped, keeping the first occurrence.
287        assert_eq!(value.except_ids().to_vec(), vec![plate.artifact_id, boss.artifact_id]);
288    }
289
290    /// One `except` list may name objects of different kinds, which the
291    /// declared element type `Solid | Sketch | GdtAnnotation` allows. The three
292    /// kinds carry their artifact id differently, so each is read by its own
293    /// arm and this is what pins all three arms at once.
294    #[tokio::test(flavor = "multi_thread")]
295    async fn named_excepts_more_than_one_kind() {
296        let program = format!(
297            "{TWO_SOLIDS}\nnote = gdt::note(note = \"Machine after welding\")\nv = view::named(\n  \"Mixed\",\n  camera = view::oriented(view::Orientation::Top),\n  baseline = view::Visibility::Hide,\n  except = [plate, bossRegion, note],\n)\n"
298        );
299        let result = parse_execute(&program).await.expect("the program executes");
300
301        let KclValue::Solid { value: plate } = result.variable("plate") else {
302            panic!("`plate` is not a solid");
303        };
304        // A region is the sketch value the extrude consumed; the sketch-block
305        // variable itself is an object that coercion converts to a `Sketch`,
306        // so it is the region that carries the artifact id to compare against.
307        let KclValue::Sketch { value: sketch } = result.variable("bossRegion") else {
308            panic!("`bossRegion` is not a sketch");
309        };
310        let KclValue::GdtAnnotation { value: note } = result.variable("note") else {
311            panic!("`note` is not an annotation");
312        };
313        let KclValue::NamedView { value } = result.variable("v") else {
314            panic!("`v` is not a named view");
315        };
316
317        assert_eq!(
318            value.except_ids().to_vec(),
319            vec![plate.artifact_id, sketch.artifact_id, ArtifactId::new(note.id)]
320        );
321    }
322
323    /// An initialized custom plane and a helix each contribute their artifact
324    /// id. Mock execution is sufficient because both ids are assigned before
325    /// the engine processes their creation commands.
326    #[tokio::test(flavor = "multi_thread")]
327    async fn named_excepts_a_custom_plane_and_helix_by_their_artifact_ids() {
328        let program = r#"@settings(experimentalFeatures = allow)
329inspectionPlane = offsetPlane(XY, offset = 20mm)
330spring = helix(
331  axis = Z,
332  radius = 5mm,
333  length = 20mm,
334  revolutions = 4,
335  angleStart = 0deg,
336)
337v = view::named(
338  "Construction geometry",
339  camera = view::oriented(view::Orientation::Isometric),
340  baseline = view::Visibility::Hide,
341  except = [inspectionPlane, spring],
342)
343"#;
344        let result = parse_execute(program).await.expect("the program executes");
345
346        let KclValue::Plane { value: plane } = result.variable("inspectionPlane") else {
347            panic!("`inspectionPlane` is not a plane");
348        };
349        let KclValue::Helix { value: helix } = result.variable("spring") else {
350            panic!("`spring` is not a helix");
351        };
352        let KclValue::NamedView { value } = result.variable("v") else {
353            panic!("`v` is not a named view");
354        };
355
356        assert!(plane.is_initialized());
357        assert_eq!(value.except_ids().to_vec(), vec![plane.artifact_id, helix.artifact_id]);
358    }
359
360    /// Imported geometry has one UUID for its runtime value, artifact and
361    /// engine object. The named view stores that UUID in the artifact-id domain.
362    #[test]
363    fn named_excepts_imported_geometry_by_its_artifact_id() {
364        let id = uuid::Uuid::from_u128(1);
365        let imported = KclValue::ImportedGeometry(crate::execution::ImportedGeometry::new(
366            id,
367            vec!["part.step".to_owned()],
368            Vec::new(),
369        ));
370
371        assert_eq!(
372            super::except_artifact_ids(&[imported], crate::SourceRange::default()).expect("the value is accepted"),
373            vec![ArtifactId::new(id)]
374        );
375    }
376
377    /// Runs `code` with the experimental opt-in these functions require, and
378    /// returns the message it fails with. Panics if the program succeeds.
379    ///
380    /// These cases run against the mock engine rather than as simulation
381    /// tests: rejected arguments never reach the engine
382    /// Two simulation tests remain, one per constructor
383    /// (`named_views_directed_zero_direction` and
384    /// `named_views_negative_distance`), which pin the rendered diagnostic
385    /// with its source range.
386    async fn execution_error(code: &str) -> String {
387        let program = format!("@settings(experimentalFeatures = allow)\n{code}");
388        match parse_execute(&program).await {
389            Ok(_) => panic!("expected `{code}` to be rejected, but it executed"),
390            Err(err) => err.message().to_owned(),
391        }
392    }
393
394    /// Runs `code` WITHOUT the experimental opt-in and returns the diagnostics
395    /// it reports. Experimental use is recorded as a non-fatal issue rather
396    /// than a returned error, so the program still executes and the issue list
397    /// is the only place the gate is visible.
398    async fn issues_without_opt_in(code: &str) -> Vec<String> {
399        let result = parse_execute(code).await.expect("experimental use is not fatal");
400        result.issues().iter().map(|issue| issue.message.clone()).collect()
401    }
402
403    /// Every rejected argument reports which argument to change. Each case
404    /// pairs the offending call with the message the author sees.
405    #[tokio::test(flavor = "multi_thread")]
406    async fn rejected_arguments_report_their_reason() {
407        // `1 / 0` is how a KCL program reaches a non-finite value.
408        assert_eq!(
409            execution_error("v = view::directed([1 / 0, 0, 0])").await,
410            "`direction` must have finite coordinates."
411        );
412        assert_eq!(
413            execution_error("v = view::directed([0, 1, 0], up = [0, 0, 1 / 0])").await,
414            "`up` must have finite coordinates."
415        );
416        assert_eq!(
417            execution_error("v = view::directed([0, 1, 0], target = [1 / 0, 0, 0])").await,
418            "`target` must have finite coordinates."
419        );
420        assert_eq!(
421            execution_error("v = view::oriented(view::Orientation::Front, target = [0, 1 / 0, 0])").await,
422            "`target` must have finite coordinates."
423        );
424        assert_eq!(
425            execution_error("v = view::oriented(view::Orientation::Front, distance = 1 / 0)").await,
426            "`distance` must be a finite number."
427        );
428        assert_eq!(
429            execution_error("v = view::directed([0, 1, 0], distance = 0)").await,
430            "`distance` must be greater than zero."
431        );
432        assert_eq!(
433            execution_error("v = view::oriented(view::Orientation::Front, distance = -50)").await,
434            "`distance` must be greater than zero."
435        );
436        assert_eq!(
437            execution_error("v = view::directed([0, 0, 0])").await,
438            "`direction` must be a non-zero vector."
439        );
440        assert_eq!(
441            execution_error("v = view::directed([0, 1, 0], up = [0, 0, 0])").await,
442            "`up` must be a non-zero vector."
443        );
444        assert_eq!(
445            execution_error("v = view::directed([0, 0, 1])").await,
446            "`direction` and `up` must not be parallel or nearly parallel."
447        );
448    }
449
450    /// Every name a view cannot be identified by reports the message the author
451    /// sees. The rules themselves are pinned in `execution::named_views`, where
452    /// they need no executor; these cases pin that `view::named` reports them
453    /// rather than accepting the name or failing some other way.
454    #[tokio::test(flavor = "multi_thread")]
455    async fn named_rejects_names_it_cannot_identify_a_view_by() {
456        // The two required arguments, so each case below varies only the name.
457        let showing = "camera = view::oriented(view::Orientation::Front), baseline = view::Visibility::Show";
458
459        assert_eq!(
460            execution_error(&format!(r#"v = view::named("", {showing})"#)).await,
461            "A view's name must not be empty."
462        );
463        assert_eq!(
464            execution_error(&format!(r#"v = view::named("   ", {showing})"#)).await,
465            "A view's name must not be only whitespace."
466        );
467        assert_eq!(
468            execution_error(&format!(r#"v = view::named("Front ", {showing})"#)).await,
469            "A view's name must not start or end with whitespace. Use `string::trim()` to remove it."
470        );
471        assert_eq!(
472            execution_error(&format!(r#"v = view::named(" Front", {showing})"#)).await,
473            "A view's name must not start or end with whitespace. Use `string::trim()` to remove it."
474        );
475        assert_eq!(
476            execution_error(&format!(r#"v = view::named("Default View", {showing})"#)).await,
477            "`Default View` is reserved for the view of the scene generated on successful execution of the program. Please give this view a different name."
478        );
479        assert_eq!(
480            execution_error(&format!(
481                "a = view::named(\"Front\", {showing})\nb = view::named(\"Front\", {showing})"
482            ))
483            .await,
484            "A view named `Front` already exists. Every view needs its own name, and names are compared exactly, so `Front` and `front` are different names."
485        );
486    }
487
488    /// Two views in one file may differ only in case or spacing, because the
489    /// uniqueness rule compares names exactly. This is the accepting half of
490    /// the duplicate case above.
491    #[tokio::test(flavor = "multi_thread")]
492    async fn named_accepts_names_that_differ_only_in_case() {
493        let showing = "camera = view::oriented(view::Orientation::Front), baseline = view::Visibility::Show";
494        let program = format!(
495            "@settings(experimentalFeatures = allow)\na = view::named(\"Front\", {showing})\nb = view::named(\"front\", {showing})\n"
496        );
497        let result = parse_execute(&program).await.expect("the program executes");
498
499        let KclValue::NamedView { value: upper } = result.variable("a") else {
500            panic!("`a` is not a named view");
501        };
502        let KclValue::NamedView { value: lower } = result.variable("b") else {
503            panic!("`b` is not a named view");
504        };
505        assert_eq!(upper.name(), "Front");
506        assert_eq!(lower.name(), "front");
507    }
508
509    /// Omitting `baseline` is rejected by the declared signature, before `named`
510    /// runs. It is required so that a view states what it shows rather than
511    /// leaving a reader to know a default, and this pins that the signature is
512    /// what enforces it.
513    #[tokio::test(flavor = "multi_thread")]
514    async fn named_requires_a_baseline() {
515        assert_eq!(
516            execution_error(r#"v = view::named("Front", camera = view::oriented(view::Orientation::Front))"#).await,
517            "The `view::named` function requires a keyword argument `baseline`"
518        );
519    }
520
521    /// `named`'s required arguments and their types are enforced by the declared
522    /// signature. The camera and baseline cases are here for the reason the
523    /// constructors have their own: the alternative to a signature rejection is
524    /// an internal error from `FromKclValue`, which tells the author nothing.
525    #[tokio::test(flavor = "multi_thread")]
526    async fn named_rejects_wrongly_typed_arguments() {
527        // An omitted required argument.
528        assert_eq!(
529            execution_error(r#"v = view::named("Front", baseline = view::Visibility::Show)"#).await,
530            "The `view::named` function requires a keyword argument `camera`"
531        );
532        // A value that is not a camera at all. The wrong value is a string
533        // rather than a number, because a number additionally draws the
534        // incomplete-units hint, which has nothing to do with this rejection.
535        assert_eq!(
536            execution_error(r#"v = view::named("Front", camera = "nope", baseline = view::Visibility::Show)"#).await,
537            "camera requires a value with type `CameraView`, but found a value with type `string`."
538        );
539        // A different enum where `baseline` is declared, which nominal coercion
540        // rejects even though the Rust body reads it with an `any` runtime type.
541        assert_eq!(
542            execution_error(
543                r#"v = view::named("Front", camera = view::oriented(view::Orientation::Front), baseline = view::Orientation::Top)"#
544            )
545            .await,
546            "baseline requires a value with type `Visibility`, but found a value of enum `Orientation` (with type `Orientation`)."
547        );
548        // A name that is not a string.
549        assert_eq!(
550            execution_error(
551                r#"v = view::named(view::Visibility::Show, camera = view::oriented(view::Orientation::Front), baseline = view::Visibility::Show)"#
552            )
553            .await,
554            "The input argument of `view::named` requires a value with type `string`, but found a value of enum `Visibility` (with type `Visibility`)."
555        );
556    }
557
558    /// Declaring a view without the experimental opt-in is reported, as calling
559    /// either camera constructor is. `named` needs its own case: the gate is a
560    /// per-function annotation, so covering the constructors says nothing about
561    /// this function.
562    #[tokio::test(flavor = "multi_thread")]
563    async fn declaring_a_view_requires_the_experimental_opt_in() {
564        assert!(
565            issues_without_opt_in(
566                r#"v = view::named("Front", camera = view::oriented(view::Orientation::Front), baseline = view::Visibility::Show)"#
567            )
568            .await
569            .contains(&"Use of `view::named` is experimental and may change or be removed.".to_owned())
570        );
571    }
572
573    /// A sketch-block variable is accepted in `except`, and this pins which id it
574    /// contributes. A block and the region taken from it are different artifacts:
575    /// the block stores the path artifact of the whole `sketch(on = ...) { ... }`
576    /// expression, the region its own. Both are nodes a consumer can resolve, so
577    /// the two are a choice the author makes rather than one being wrong. The sim
578    /// test `named_views_except_a_sketch_block` shows the node the block names.
579    #[tokio::test(flavor = "multi_thread")]
580    async fn named_excepts_a_sketch_block_by_its_own_id() {
581        let program = format!(
582            "{TWO_SOLIDS}\nv = view::named(\n  \"Block\",\n  camera = view::oriented(view::Orientation::Front),\n  baseline = view::Visibility::Hide,\n  except = [plateSketch],\n)\n"
583        );
584        let result = parse_execute(&program).await.expect("the program executes");
585
586        let KclValue::Sketch { value: region } = result.variable("plateRegion") else {
587            panic!("`plateRegion` is not a sketch");
588        };
589        let KclValue::NamedView { value } = result.variable("v") else {
590            panic!("`v` is not a named view");
591        };
592
593        assert_eq!(value.except_ids().len(), 1);
594        assert_ne!(
595            value.except_ids()[0],
596            region.artifact_id,
597            "the block and the region taken from it are different artifacts"
598        );
599    }
600
601    /// An empty `except` list is rejected by the declared element count `1+`,
602    /// before `named` runs. Nothing in the Rust body depends on that, but a list
603    /// that excepts nothing says nothing, so this pins where it is caught.
604    #[tokio::test(flavor = "multi_thread")]
605    async fn named_rejects_an_empty_except_list() {
606        assert_eq!(
607            execution_error(
608                r#"v = view::named("Front", camera = view::oriented(view::Orientation::Front), baseline = view::Visibility::Hide, except = [])"#
609            )
610            .await,
611            "except requires one or more `Solid`s or `Sketch`s or `GdtAnnotation`s or `Helix`s or `Plane`s or imported geometries (`[Solid | Sketch | GdtAnnotation | Helix | Plane | ImportedGeometry; 1+]`), but found an empty array (with type `[any; 0]`)."
612        );
613    }
614
615    /// The six KCL default-plane values do not identify the six engine-owned
616    /// default-plane objects, so every spelling is rejected explicitly.
617    #[tokio::test(flavor = "multi_thread")]
618    async fn named_rejects_every_default_plane() {
619        for plane in ["XY", "XZ", "YZ", "-XY", "-XZ", "-YZ"] {
620            assert_eq!(
621                execution_error(&format!(
622                    "v = view::named(\"Front\", camera = view::oriented(view::Orientation::Front), baseline = view::Visibility::Hide, except = [{plane}])"
623                ))
624                .await,
625                "Named views cannot control default planes or other uninitialized planes because their ids do not identify independent engine objects. Use a standalone plane returned by `offsetPlane()`."
626            );
627        }
628    }
629
630    /// Structural plane coercion describes a plane but does not send one to the
631    /// engine, so its generated artifact id cannot be used for visibility.
632    #[tokio::test(flavor = "multi_thread")]
633    async fn named_rejects_an_uninitialized_custom_plane() {
634        assert_eq!(
635            execution_error(
636                r#"customPlane = {
637  origin = { x = 0, y = 0, z = 0 },
638  xAxis = { x = 1, y = 0, z = 0 },
639  yAxis = { x = 0, y = 1, z = 0 },
640}
641v = view::named(
642  "Front",
643  camera = view::oriented(view::Orientation::Front),
644  baseline = view::Visibility::Hide,
645  except = [customPlane],
646)"#
647            )
648            .await,
649            "Named views cannot control default planes or other uninitialized planes because their ids do not identify independent engine objects. Use a standalone plane returned by `offsetPlane()`."
650        );
651    }
652
653    /// An argument of the wrong type is rejected by the declared signature,
654    /// before either function runs.
655    ///
656    /// This is what lets the implementations read `orientation` and
657    /// `projection` with an `any` runtime type: enum coercion is nominal, so
658    /// the signature admits only the enum it names. Were that to stop
659    /// holding, the value would reach `FromKclValue` and fail there, and the
660    /// author would get "Mismatch between type coercion and value extraction
661    /// (this isn't your fault)" from `std/args.rs` instead of a diagnostic
662    /// naming the argument. The point of these cases is that the good message
663    /// is the one that appears.
664    #[tokio::test(flavor = "multi_thread")]
665    async fn wrongly_typed_arguments_are_rejected() {
666        // A value of an unrelated type.
667        assert_eq!(
668            execution_error(r#"v = view::oriented("Front")"#).await,
669            "The input argument of `view::oriented` requires a value with type `Orientation`, but found a value with type `string`."
670        );
671        // A different enum from the same module. Nominal coercion rejects it
672        // even though `any` was requested.
673        assert_eq!(
674            execution_error("v = view::oriented(view::Projection::Perspective)").await,
675            "The input argument of `view::oriented` requires a value with type `Orientation`, but found a value of enum `Projection` (with type `Projection`)."
676        );
677        // A user-defined enum declaring a variant of the same name. Coercion
678        // compares the declaring type, not the variant spelling.
679        assert_eq!(
680            execution_error("type MyOrientation { | Front }\nv = view::oriented(MyOrientation::Front)").await,
681            "The input argument of `view::oriented` requires a value with type `Orientation`, but found a value of enum `MyOrientation` (with type `MyOrientation`)."
682        );
683        // A labeled argument, which reports in its own wording.
684        assert_eq!(
685            execution_error("v = view::oriented(view::Orientation::Front, projection = view::Orientation::Top)").await,
686            "projection requires a value with type `Projection`, but found a value of enum `Orientation` (with type `Orientation`)."
687        );
688        // The same protection covers the non-enum arguments.
689        assert_eq!(
690            execution_error(r#"v = view::directed("nope")"#).await,
691            "The input argument of `view::directed` requires a value with type `Point3d`, but found a value with type `string`."
692        );
693    }
694
695    /// Calling either constructor without the experimental opt-in is
696    /// reported, whether or not the call mentions an enum.
697    ///
698    /// The sim test `named_views_module_requires_opt_in` covers the other
699    /// half of the gate, a bare enum variant. This covers the functions
700    /// themselves: `view::directed` with a plain vector names no enum, so the
701    /// only thing gating it is its own `@(experimental = true)`. Named views
702    /// stay unreleasable until enums stabilise precisely because a consumer
703    /// must opt in, so the gate silently lapsing is the failure to catch.
704    #[tokio::test(flavor = "multi_thread")]
705    async fn calling_a_constructor_requires_the_experimental_opt_in() {
706        assert!(
707            issues_without_opt_in("v = view::directed([0, 1, -2])")
708                .await
709                .contains(&"Use of `view::directed` is experimental and may change or be removed.".to_owned())
710        );
711
712        // This call also uses an enum variant, so it reports both halves of
713        // the gate; the function's own diagnostic is the one asserted here.
714        assert!(
715            issues_without_opt_in("v = view::oriented(view::Orientation::Front)")
716                .await
717                .contains(&"Use of `view::oriented` is experimental and may change or be removed.".to_owned())
718        );
719    }
720
721    /// Imported opaque `std::view` types resolve by their bare names in signatures.
722    /// Resolution happens when each declaration executes, so neither function
723    /// needs to be called for this test to exercise signature resolution.
724    #[tokio::test(flavor = "multi_thread")]
725    async fn opaque_types_resolve_in_signatures() {
726        let code = r#"@settings(experimentalFeatures = allow)
727import CameraView, NamedView from "std::view"
728
729fn acceptsCamera(@camera: CameraView) {
730  return 0
731}
732
733fn passesNamed(@input: NamedView): NamedView {
734  return input
735}
736"#;
737        if let Err(err) = parse_execute(code).await {
738            panic!("expected the declarations to resolve, but got: {}", err.message());
739        }
740    }
741
742    /// A qualified `std::view` type resolves where a signature names it.
743    /// The signature is the first reference to the module, so this also verifies
744    /// that type resolution executes a registered standard-library module.
745    #[tokio::test(flavor = "multi_thread")]
746    async fn qualified_type_path_resolves_in_signature() {
747        let code = r#"@settings(experimentalFeatures = allow)
748fn passesOrientation(@input: view::Orientation): view::Orientation {
749  return input
750}
751"#;
752        if let Err(err) = parse_execute(code).await {
753            panic!("expected the declarations to resolve, but got: {}", err.message());
754        }
755    }
756}