Skip to main content

brep_kernel/feature_pipeline/features/
nurbs_solid.rs

1//! NS — NURBS Solid: a smooth body shaped by a control cage.
2//!
3//! The feature wraps a [`ControlCage`] around a primitive (`primitive`,
4//! `sizeX/Y/Z`, `segments`, `rings`) and builds the bicubic B-spline solid the
5//! cage controls — one closed face with a seam edge and two pole edges, named
6//! `{id}` like the sphere's. The editor deforms the cage by moving its points
7//! and refines it by inserting loops; both write the edited cage to
8//! `persistentData.cage`, and from then on THAT cage is the body — the
9//! primitive params are the starting shape only, until the editor's Reset
10//! drops the persisted cage. [`resolve_cage`] is the ONE place that decision
11//! is made, and the editor reads its points through it, so what is drawn as
12//! a handle is exactly what the kernel builds from.
13//!
14//! The cage lives in world space: there is no `transform` param, because a
15//! point dragged in the viewport must be the point the kernel builds from,
16//! not a point seen through a bake. Move the whole body by selecting every
17//! point, or transform the result downstream.
18
19use crate::feature_pipeline::features::common;
20use crate::feature_pipeline::{Env, FeatureContext, FeatureResult};
21use crate::{CagePrimitive, ControlCage, MAX_RINGS, MAX_SEGMENTS, MIN_RINGS, MIN_SEGMENTS};
22use serde_json::Value;
23
24/// The `persistentData` key the edited cage is stored under.
25pub const PERSISTENT_KEY: &str = "cage";
26/// The primitive a fresh feature wraps.
27pub const DEFAULT_PRIMITIVE: &str = "BOX";
28pub const DEFAULT_SIZE: f64 = 10.0;
29pub const DEFAULT_SEGMENTS: usize = 8;
30pub const DEFAULT_RINGS: usize = 3;
31
32pub fn execute(ctx: &FeatureContext) -> FeatureResult {
33    match build(ctx) {
34        Ok(result) => result,
35        Err(error) => ctx.fail(error),
36    }
37}
38
39fn build(ctx: &FeatureContext) -> Result<FeatureResult, String> {
40    let cage = resolve_cage(ctx.env, ctx.params, ctx.persistent)?;
41    let solid = cage.solid(Some(ctx.id.as_str()))?;
42    Ok(common::finalize_solid(ctx, solid, &ctx.id))
43}
44
45/// Whether the document carries an edited cage (the primitive params are then
46/// only the shape it started from).
47pub fn has_persisted_cage(persistent: &Value) -> bool {
48    persistent.get(PERSISTENT_KEY).is_some_and(|cage| !cage.is_null())
49}
50
51/// The cage this feature builds from: the persisted one when the document has
52/// it, otherwise the primitive the params describe. The editor reads its
53/// handles through this, so a handle is always a point the kernel builds from.
54pub fn resolve_cage(env: &Env, params: &Value, persistent: &Value) -> Result<ControlCage, String> {
55    if let Some(stored) = persistent.get(PERSISTENT_KEY).filter(|cage| !cage.is_null()) {
56        return ControlCage::from_json(stored)
57            .map_err(|error| format!("nurbs solid: the stored cage is unusable: {error}"));
58    }
59    primitive_cage(env, params)
60}
61
62/// The initial cage the params describe, ignoring any persisted cage — what
63/// Reset returns to.
64pub fn primitive_cage(env: &Env, params: &Value) -> Result<ControlCage, String> {
65    let kind_name = params
66        .get("primitive")
67        .and_then(Value::as_str)
68        .unwrap_or(DEFAULT_PRIMITIVE);
69    let kind = CagePrimitive::parse(kind_name)
70        .ok_or_else(|| format!("nurbs solid: unknown primitive `{kind_name}` (BOX, CYLINDER or SPHERE)"))?;
71    let size = [
72        number(env, params, "sizeX", DEFAULT_SIZE)?,
73        number(env, params, "sizeY", DEFAULT_SIZE)?,
74        number(env, params, "sizeZ", DEFAULT_SIZE)?,
75    ];
76    let count = |key: &str, default: usize, low: usize, high: usize| -> Result<usize, String> {
77        let value = number(env, params, key, default as f64)?;
78        let rounded = value.round();
79        if rounded < low as f64 || rounded > high as f64 {
80            return Err(format!("nurbs solid: `{key}` must be between {low} and {high}, got {value}"));
81        }
82        Ok(rounded as usize)
83    };
84    let segments = count("segments", DEFAULT_SEGMENTS, MIN_SEGMENTS, MAX_SEGMENTS)?;
85    let rings = count("rings", DEFAULT_RINGS, MIN_RINGS, MAX_RINGS)?;
86    ControlCage::primitive(kind, size, segments, rings).map_err(|error| format!("nurbs solid: {error}"))
87}
88
89/// A numeric param by the pipeline's convention: a number passes through, a
90/// string is an expression against `env`, absent / null is the default.
91fn number(env: &Env, params: &Value, key: &str, default: f64) -> Result<f64, String> {
92    let value = match params.get(key) {
93        None | Some(Value::Null) => return Ok(default),
94        Some(Value::Number(number)) => number
95            .as_f64()
96            .ok_or_else(|| format!("nurbs solid: param `{key}` is not a finite number"))?,
97        Some(Value::String(source)) => env
98            .eval(source)
99            .map_err(|error| format!("nurbs solid: param `{key}`: {error}"))?,
100        Some(other) => {
101            return Err(format!(
102                "nurbs solid: param `{key}` must be a number or expression string, found {other}"
103            ))
104        }
105    };
106    if !value.is_finite() {
107        return Err(format!("nurbs solid: param `{key}` evaluated to {value}"));
108    }
109    Ok(value)
110}
111
112/// Context-bar applicability ([`crate::feature_pipeline::context_offer`]):
113/// never offered from a selection (no reference inputs).
114pub fn context_applicable(_probe: &crate::feature_pipeline::SelectionProbe) -> bool {
115    false
116}
117
118pub fn schema() -> serde_json::Value {
119    serde_json::json!({
120    "type": "NS",
121    "shortName": "NS",
122    "longName": "NURBS Solid",
123    "ribbonPath": "Home/Primitives/NURBS Solid",
124    "commandSize": "Compact",
125    "displayBuilder": false,
126    "inputParamsSchema": {
127        "id": {
128            "type": "string",
129            "default_value": null,
130            "hint": "Unique identifier for the feature"
131        },
132        "primitive": {
133            "type": "options",
134            "label": "Primitive",
135            "options": CagePrimitive::OPTIONS,
136            "default_value": DEFAULT_PRIMITIVE,
137            "hint": "The primitive the control cage starts as; the smooth body lies inside the cage"
138        },
139        "sizeX": {
140            "type": "number",
141            "default_value": DEFAULT_SIZE,
142            "hint": "Cage extent along X (centred on the origin)"
143        },
144        "sizeY": {
145            "type": "number",
146            "default_value": DEFAULT_SIZE,
147            "hint": "Cage extent along Y, the pole-to-pole axis"
148        },
149        "sizeZ": {
150            "type": "number",
151            "default_value": DEFAULT_SIZE,
152            "hint": "Cage extent along Z"
153        },
154        "segments": {
155            "type": "number",
156            "default_value": DEFAULT_SEGMENTS,
157            "min": MIN_SEGMENTS,
158            "step": 1,
159            "hint": "Control points around each ring of the initial cage (at least 4)"
160        },
161        "rings": {
162            "type": "number",
163            "default_value": DEFAULT_RINGS,
164            "min": MIN_RINGS,
165            "step": 1,
166            "hint": "Rings of control points between the two poles of the initial cage (at least 2)"
167        },
168        "boolean": common::optional_boolean_schema()
169    }
170})
171}
172