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(kclVersion = "3.0-preview", 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(kclVersion = "3.0-preview", 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` under KCL V3 and returns its error message. Panics if the
378    /// 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(kclVersion = \"3.0-preview\")\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    /// Every rejected argument reports which argument to change. Each case
395    /// pairs the offending call with the message the author sees.
396    #[tokio::test(flavor = "multi_thread")]
397    async fn rejected_arguments_report_their_reason() {
398        // `1 / 0` is how a KCL program reaches a non-finite value.
399        assert_eq!(
400            execution_error("v = view::directed([1 / 0, 0, 0])").await,
401            "`direction` must have finite coordinates."
402        );
403        assert_eq!(
404            execution_error("v = view::directed([0, 1, 0], up = [0, 0, 1 / 0])").await,
405            "`up` must have finite coordinates."
406        );
407        assert_eq!(
408            execution_error("v = view::directed([0, 1, 0], target = [1 / 0, 0, 0])").await,
409            "`target` must have finite coordinates."
410        );
411        assert_eq!(
412            execution_error("v = view::oriented(view::Orientation::Front, target = [0, 1 / 0, 0])").await,
413            "`target` must have finite coordinates."
414        );
415        assert_eq!(
416            execution_error("v = view::oriented(view::Orientation::Front, distance = 1 / 0)").await,
417            "`distance` must be a finite number."
418        );
419        assert_eq!(
420            execution_error("v = view::directed([0, 1, 0], distance = 0)").await,
421            "`distance` must be greater than zero."
422        );
423        assert_eq!(
424            execution_error("v = view::oriented(view::Orientation::Front, distance = -50)").await,
425            "`distance` must be greater than zero."
426        );
427        assert_eq!(
428            execution_error("v = view::directed([0, 0, 0])").await,
429            "`direction` must be a non-zero vector."
430        );
431        assert_eq!(
432            execution_error("v = view::directed([0, 1, 0], up = [0, 0, 0])").await,
433            "`up` must be a non-zero vector."
434        );
435        assert_eq!(
436            execution_error("v = view::directed([0, 0, 1])").await,
437            "`direction` and `up` must not be parallel or nearly parallel."
438        );
439    }
440
441    /// Every name a view cannot be identified by reports the message the author
442    /// sees. The rules themselves are pinned in `execution::named_views`, where
443    /// they need no executor; these cases pin that `view::named` reports them
444    /// rather than accepting the name or failing some other way.
445    #[tokio::test(flavor = "multi_thread")]
446    async fn named_rejects_names_it_cannot_identify_a_view_by() {
447        // The two required arguments, so each case below varies only the name.
448        let showing = "camera = view::oriented(view::Orientation::Front), baseline = view::Visibility::Show";
449
450        assert_eq!(
451            execution_error(&format!(r#"v = view::named("", {showing})"#)).await,
452            "A view's name must not be empty."
453        );
454        assert_eq!(
455            execution_error(&format!(r#"v = view::named("   ", {showing})"#)).await,
456            "A view's name must not be only whitespace."
457        );
458        assert_eq!(
459            execution_error(&format!(r#"v = view::named("Front ", {showing})"#)).await,
460            "A view's name must not start or end with whitespace. Use `string::trim()` to remove it."
461        );
462        assert_eq!(
463            execution_error(&format!(r#"v = view::named(" Front", {showing})"#)).await,
464            "A view's name must not start or end with whitespace. Use `string::trim()` to remove it."
465        );
466        assert_eq!(
467            execution_error(&format!(r#"v = view::named("Default View", {showing})"#)).await,
468            "`Default View` is reserved for the view of the scene generated on successful execution of the program. Please give this view a different name."
469        );
470        assert_eq!(
471            execution_error(&format!(
472                "a = view::named(\"Front\", {showing})\nb = view::named(\"Front\", {showing})"
473            ))
474            .await,
475            "A view named `Front` already exists. Every view needs its own name, and names are compared exactly, so `Front` and `front` are different names."
476        );
477    }
478
479    /// Two views in one file may differ only in case or spacing, because the
480    /// uniqueness rule compares names exactly. This is the accepting half of
481    /// the duplicate case above.
482    #[tokio::test(flavor = "multi_thread")]
483    async fn named_accepts_names_that_differ_only_in_case() {
484        let showing = "camera = view::oriented(view::Orientation::Front), baseline = view::Visibility::Show";
485        let program = format!(
486            "@settings(kclVersion = \"3.0-preview\")\na = view::named(\"Front\", {showing})\nb = view::named(\"front\", {showing})\n"
487        );
488        let result = parse_execute(&program).await.expect("the program executes");
489
490        let KclValue::NamedView { value: upper } = result.variable("a") else {
491            panic!("`a` is not a named view");
492        };
493        let KclValue::NamedView { value: lower } = result.variable("b") else {
494            panic!("`b` is not a named view");
495        };
496        assert_eq!(upper.name(), "Front");
497        assert_eq!(lower.name(), "front");
498    }
499
500    /// Omitting `baseline` is rejected by the declared signature, before `named`
501    /// runs. It is required so that a view states what it shows rather than
502    /// leaving a reader to know a default, and this pins that the signature is
503    /// what enforces it.
504    #[tokio::test(flavor = "multi_thread")]
505    async fn named_requires_a_baseline() {
506        assert_eq!(
507            execution_error(r#"v = view::named("Front", camera = view::oriented(view::Orientation::Front))"#).await,
508            "The `view::named` function requires a keyword argument `baseline`"
509        );
510    }
511
512    /// `named`'s required arguments and their types are enforced by the declared
513    /// signature. The camera and baseline cases are here for the reason the
514    /// constructors have their own: the alternative to a signature rejection is
515    /// an internal error from `FromKclValue`, which tells the author nothing.
516    #[tokio::test(flavor = "multi_thread")]
517    async fn named_rejects_wrongly_typed_arguments() {
518        // An omitted required argument.
519        assert_eq!(
520            execution_error(r#"v = view::named("Front", baseline = view::Visibility::Show)"#).await,
521            "The `view::named` function requires a keyword argument `camera`"
522        );
523        // A value that is not a camera at all. The wrong value is a string
524        // rather than a number, because a number additionally draws the
525        // incomplete-units hint, which has nothing to do with this rejection.
526        assert_eq!(
527            execution_error(r#"v = view::named("Front", camera = "nope", baseline = view::Visibility::Show)"#).await,
528            "camera requires a value with type `CameraView`, but found a value with type `string`."
529        );
530        // A different enum where `baseline` is declared, which nominal coercion
531        // rejects even though the Rust body reads it with an `any` runtime type.
532        assert_eq!(
533            execution_error(
534                r#"v = view::named("Front", camera = view::oriented(view::Orientation::Front), baseline = view::Orientation::Top)"#
535            )
536            .await,
537            "baseline requires a value with type `Visibility`, but found a value of enum `Orientation` (with type `Orientation`)."
538        );
539        // A name that is not a string.
540        assert_eq!(
541            execution_error(
542                r#"v = view::named(view::Visibility::Show, camera = view::oriented(view::Orientation::Front), baseline = view::Visibility::Show)"#
543            )
544            .await,
545            "The input argument of `view::named` requires a value with type `string`, but found a value of enum `Visibility` (with type `Visibility`)."
546        );
547    }
548
549    #[tokio::test(flavor = "multi_thread")]
550    async fn named_view_api_requires_v3_even_with_experimental_opt_in() {
551        for version in ["1.0", "2.0"] {
552            for opt_in in ["", ", experimentalFeatures = allow"] {
553                for (case, body) in [
554                    ("module", "x = view"),
555                    ("enum variant", "x = view::Orientation::Front"),
556                    ("constructor", "x = view::directed([0, 1, -2])"),
557                    ("type position", "fn pass(@x: view::Orientation) { return x }"),
558                ] {
559                    let code = format!("@settings(kclVersion = \"{version}\"{opt_in})\n{body}\n");
560                    let err = parse_execute(&code).await.unwrap_err();
561                    assert!(
562                        err.message()
563                            .contains(&format!("added in KCL 3.0, but this program uses KCL {version}")),
564                        "case: {case}; code: {code}; error: {}",
565                        err.message()
566                    );
567                }
568            }
569
570            let code =
571                format!("@settings(kclVersion = \"{version}\", experimentalFeatures = allow)\nimport \"std::view\"\n");
572            assert_eq!(
573                parse_execute(&code).await.unwrap_err().message(),
574                format!("The `std::view` module requires KCL 3.0-preview, but this program uses KCL {version}.")
575            );
576        }
577    }
578
579    /// The prelude exposes each named-view type and constructor in V3 without
580    /// an experimental setting or diagnostic.
581    #[tokio::test(flavor = "multi_thread")]
582    async fn named_views_are_stable_in_v3_without_opt_in() {
583        let code = r#"@settings(kclVersion = "3.0-preview")
584fn passCamera(@camera: view::CameraView): view::CameraView { return camera }
585fn passView(@named: view::NamedView): view::NamedView { return named }
586front = passCamera(view::oriented(view::Orientation::Front, projection = view::Projection::Perspective))
587custom = view::directed([0, 1, -2])
588named = passView(view::named("Front", camera = front, baseline = view::Visibility::Show))
589"#;
590        let result = parse_execute(code).await.unwrap();
591        assert!(result.issues().is_empty(), "issues: {:?}", result.issues());
592    }
593
594    /// A sketch-block variable is accepted in `except`, and this pins which id it
595    /// contributes. A block and the region taken from it are different artifacts:
596    /// the block stores the path artifact of the whole `sketch(on = ...) { ... }`
597    /// expression, the region its own. Both are nodes a consumer can resolve, so
598    /// the two are a choice the author makes rather than one being wrong. The sim
599    /// test `named_views_except_a_sketch_block` shows the node the block names.
600    #[tokio::test(flavor = "multi_thread")]
601    async fn named_excepts_a_sketch_block_by_its_own_id() {
602        let program = format!(
603            "{TWO_SOLIDS}\nv = view::named(\n  \"Block\",\n  camera = view::oriented(view::Orientation::Front),\n  baseline = view::Visibility::Hide,\n  except = [plateSketch],\n)\n"
604        );
605        let result = parse_execute(&program).await.expect("the program executes");
606
607        let KclValue::Sketch { value: region } = result.variable("plateRegion") else {
608            panic!("`plateRegion` is not a sketch");
609        };
610        let KclValue::NamedView { value } = result.variable("v") else {
611            panic!("`v` is not a named view");
612        };
613
614        assert_eq!(value.except_ids().len(), 1);
615        assert_ne!(
616            value.except_ids()[0],
617            region.artifact_id,
618            "the block and the region taken from it are different artifacts"
619        );
620    }
621
622    /// An empty `except` list is rejected by the declared element count `1+`,
623    /// before `named` runs. Nothing in the Rust body depends on that, but a list
624    /// that excepts nothing says nothing, so this pins where it is caught.
625    #[tokio::test(flavor = "multi_thread")]
626    async fn named_rejects_an_empty_except_list() {
627        assert_eq!(
628            execution_error(
629                r#"v = view::named("Front", camera = view::oriented(view::Orientation::Front), baseline = view::Visibility::Hide, except = [])"#
630            )
631            .await,
632            "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]`)."
633        );
634    }
635
636    /// The six KCL default-plane values do not identify the six engine-owned
637    /// default-plane objects, so every spelling is rejected explicitly.
638    #[tokio::test(flavor = "multi_thread")]
639    async fn named_rejects_every_default_plane() {
640        for plane in ["XY", "XZ", "YZ", "-XY", "-XZ", "-YZ"] {
641            assert_eq!(
642                execution_error(&format!(
643                    "v = view::named(\"Front\", camera = view::oriented(view::Orientation::Front), baseline = view::Visibility::Hide, except = [{plane}])"
644                ))
645                .await,
646                "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()`."
647            );
648        }
649    }
650
651    /// Structural plane coercion describes a plane but does not send one to the
652    /// engine, so its generated artifact id cannot be used for visibility.
653    #[tokio::test(flavor = "multi_thread")]
654    async fn named_rejects_an_uninitialized_custom_plane() {
655        assert_eq!(
656            execution_error(
657                r#"customPlane = {
658  origin = { x = 0, y = 0, z = 0 },
659  xAxis = { x = 1, y = 0, z = 0 },
660  yAxis = { x = 0, y = 1, z = 0 },
661}
662v = view::named(
663  "Front",
664  camera = view::oriented(view::Orientation::Front),
665  baseline = view::Visibility::Hide,
666  except = [customPlane],
667)"#
668            )
669            .await,
670            "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()`."
671        );
672    }
673
674    /// An argument of the wrong type is rejected by the declared signature,
675    /// before either function runs.
676    ///
677    /// This is what lets the implementations read `orientation` and
678    /// `projection` with an `any` runtime type: enum coercion is nominal, so
679    /// the signature admits only the enum it names. Were that to stop
680    /// holding, the value would reach `FromKclValue` and fail there, and the
681    /// author would get "Mismatch between type coercion and value extraction
682    /// (this isn't your fault)" from `std/args.rs` instead of a diagnostic
683    /// naming the argument. The point of these cases is that the good message
684    /// is the one that appears.
685    #[tokio::test(flavor = "multi_thread")]
686    async fn wrongly_typed_arguments_are_rejected() {
687        // A value of an unrelated type.
688        assert_eq!(
689            execution_error(r#"v = view::oriented("Front")"#).await,
690            "The input argument of `view::oriented` requires a value with type `Orientation`, but found a value with type `string`."
691        );
692        // A different enum from the same module. Nominal coercion rejects it
693        // even though `any` was requested.
694        assert_eq!(
695            execution_error("v = view::oriented(view::Projection::Perspective)").await,
696            "The input argument of `view::oriented` requires a value with type `Orientation`, but found a value of enum `Projection` (with type `Projection`)."
697        );
698        // A user-defined enum declaring a variant of the same name. Coercion
699        // compares the declaring type, not the variant spelling.
700        assert_eq!(
701            parse_execute(
702                "@settings(kclVersion = \"3.0-preview\")\ntype MyOrientation { | Front }\nv = view::oriented(MyOrientation::Front)"
703            )
704            .await
705            .unwrap_err()
706            .message(),
707            "The input argument of `view::oriented` requires a value with type `Orientation`, but found a value of enum `MyOrientation` (with type `MyOrientation`)."
708        );
709        // A labeled argument, which reports in its own wording.
710        assert_eq!(
711            execution_error("v = view::oriented(view::Orientation::Front, projection = view::Orientation::Top)").await,
712            "projection requires a value with type `Projection`, but found a value of enum `Orientation` (with type `Orientation`)."
713        );
714        // The same protection covers the non-enum arguments.
715        assert_eq!(
716            execution_error(r#"v = view::directed("nope")"#).await,
717            "The input argument of `view::directed` requires a value with type `Point3d`, but found a value with type `string`."
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(kclVersion = "3.0-preview", 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(kclVersion = "3.0-preview")
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}