Kinogaki Studio / Plugin SDK

Studio SDK

Kinogaki Studio is an environment. With no plugins installed it organises a scene: Parts, Cameras, an empty Material Library and a File node that reads nothing. Kernels, nodes, file formats and render engines come from plugins. The SDK is the set of Rust crates those plugins compile against.

Preview · Rust · Studio does not load plugin libraries yet

01 / WHAT A PLUGIN ADDS

Kernels

Data types: values that flow on wires, with how Studio shows, picks, saves and renders each one.

Nodes

What a node computes from its inputs and parameters. Studio runs the network, caches results and cancels stale cooks.

File formats

Readers and writers of outside files, offered by the File node, Import and Export.

Render engines

Engines draw neutral triangle sources, whatever kernel made them.

CrateWhat it holds
kinogaki-studio-apiIds, Data, DataType and its facets, the node cook contract, object kinds, file formats, session slots, Package and the Registry.
kinogaki-studio-ui-apiThe UI layer: panels, tool cards, overlays and settings pages ask the host to draw.
kinogaki-render-apiThe interface between Studio and render engines, and the triangle source every data type renders through.

The SDK depends on the Rust standard library only (wgpu with one optional feature). A plugin can use any library inside and converts at its own boundary.

02 / EXAMPLE: A BOX NODE

A plugin that adds a Box node

The plugin defines a box data type and a Box node that makes one from its width, depth and height. It defines its own data type because the SDK has no mesh type: Studio's mesh comes from a plugin too. The code on this page is examples/box-node in the SDK repository at c0238a2, where its tests run with the SDK's own.

  1. Create a crate

    cargo new --lib box-node
  2. Depend on the SDK

    The plugin builds as a Rust dylib: Studio and its plugins share one standard library and one copy of the SDK. Plugins are built with the same Rust release as Studio; the SDK requires Rust 1.98.

    Cargo.toml
    [package]
    name = "box-node"
    version = "0.1.0"
    edition = "2024"
    
    [lib]
    # A Rust dylib: Studio and its plugins share one standard library and one
    # copy of the SDK.
    crate-type = ["dylib"]
    
    [dependencies]
    kinogaki-studio-api = { git = "https://github.com/kinogaki/studio-sdk" }
  3. Define the data type's value

    A box is a plain Rust value. Typed pairs it with its id, example.boxes.box, and wraps it as Data, the values on Studio's wires.

    src/lib.rs
    //! A Kinogaki Studio plugin that adds a Box node.
    //!
    //! The package defines a data type, `example.boxes.box` (a box's size),
    //! and a node type, `example.boxes.box_node`, that makes a box from its
    //! width, depth and height. The data type tells Studio how a box shows in
    //! the viewport, how it renders and how it saves.
    use kinogaki_studio_api::element::Field;
    use kinogaki_studio_api::params::{Number, Parameter, ParameterKind};
    use kinogaki_studio_api::*;
    use std::sync::{Arc, LazyLock};
    
    /// A box centered on the origin: its size along x, y and z (z is up).
    #[derive(Clone, Copy, Debug, PartialEq)]
    pub struct BoxShape {
        /// Width, depth and height.
        pub size: [f64; 3],
    }
    
    impl BoxShape {
        /// Its eight corners: the bottom four, then the top four.
        pub fn points(&self) -> [[f32; 3]; 8] {
            let [x, y, z] = self.size.map(|s| (s / 2.0) as f32);
            [
                [-x, -y, -z],
                [x, -y, -z],
                [x, y, -z],
                [-x, y, -z],
                [-x, -y, z],
                [x, -y, z],
                [x, y, z],
                [-x, y, z],
            ]
        }
    }
    
    /// The six faces, as corner numbers counterclockwise seen from outside:
    /// bottom, top, front, back, right, left.
    const FACES: [[u32; 4]; 6] = [
        [0, 3, 2, 1],
        [4, 5, 6, 7],
        [0, 1, 5, 4],
        [2, 3, 7, 6],
        [1, 2, 6, 5],
        [3, 0, 4, 7],
    ];
    
    /// The connectivity key: every box has the same faces.
    const TOPOLOGY: u64 = 1;
    
    /// Wraps boxes as `Data` and reads them back.
    pub static BOX: LazyLock<Typed<BoxShape>> =
        LazyLock::new(|| Typed::new(DataTypeId::declared("example.boxes.box")));
    
    fn box_of(data: &Data) -> Result<&BoxShape, Error> {
        BOX.get(data).ok_or_else(|| Error::invalid("not a box"))
    }
  4. Tell Studio how to show, render and save it

    DataType answers what Studio asks of any value: label, wire color, a content stamp, bounds and info rows. The facets are optional. Displayable gives the viewport a surface, Renderable gives render engines triangles, and DataCodec writes the value into the project file.

    src/lib.rs
    /// The box data type, with its display, render and save facets.
    pub struct BoxType;
    
    impl DataType for BoxType {
        fn id(&self) -> DataTypeId {
            BOX.id()
        }
        fn label(&self) -> &str {
            "Box"
        }
        fn wire_color(&self) -> Color {
            Color::rgb(232, 164, 58)
        }
        fn stamp(&self, data: &Data) -> Stamp {
            let mut stamp = [0; 32];
            if let Some(shape) = BOX.get(data) {
                for (bytes, side) in stamp.chunks_mut(8).zip(shape.size) {
                    bytes.copy_from_slice(&side.to_le_bytes());
                }
            }
            stamp
        }
        fn memory(&self, _data: &Data) -> usize {
            size_of::<BoxShape>()
        }
        fn bounds(&self, data: &Data) -> Option<Bounds> {
            let [x, y, z] = BOX.get(data)?.size.map(|s| s / 2.0);
            Some(Bounds {
                min: Vec3::new(-x, -y, -z),
                max: Vec3::new(x, y, z),
            })
        }
        fn info(&self, data: &Data) -> Vec<InfoRow> {
            let Some(BoxShape { size: [w, d, h] }) = BOX.get(data) else {
                return Vec::new();
            };
            vec![InfoRow::new("Size", format!("{w} × {d} × {h}"))]
        }
        fn codec(&self) -> &dyn DataCodec {
            self
        }
        fn displayable(&self) -> Option<&dyn Displayable> {
            Some(self)
        }
        fn renderable(&self) -> Option<&dyn Renderable> {
            Some(self)
        }
    }
    
    /// In the viewport: six flat faces and their twelve edges.
    impl Displayable for BoxType {
        fn levels(&self) -> &[Level] {
            &[] // selected as a whole object only
        }
        fn prepare(&self, data: &Data, _: DisplayQuality, _: &CancelToken) -> Result<Drawn, Error> {
            let surface = Surface::polygons(box_of(data)?.points(), FACES, TOPOLOGY);
            Ok(Drawn::new(surface))
        }
        fn highlight(&self, _: &Data, _: &Drawn, _: &Components) -> Elements {
            Elements::new(Level::Face, [])
        }
        fn convert(&self, _: &Data, _: &Components, _: Level) -> Option<Components> {
            None
        }
    }
    
    /// In renders: twelve triangles, two a face.
    impl Renderable for BoxType {
        fn items(&self, data: &Data, _: Time, _: &CancelToken) -> Result<Vec<RenderItem>, Error> {
            let triangles: Vec<[u32; 3]> = FACES
                .iter()
                .flat_map(|&[a, b, c, d]| [[a, b, c], [a, c, d]])
                .collect();
            let source = PlainTriangles::new(box_of(data)?.points(), triangles, TOPOLOGY);
            Ok(vec![RenderItem::triangles(0, Arc::new(source))])
        }
    }
    
    /// In project files: the size.
    impl DataCodec for BoxType {
        fn write(
            &self,
            data: &Data,
            _: Option<&Data>,
            out: &mut dyn ElementWriter,
        ) -> Result<(), Error> {
            out.set("size", Field::F64s(box_of(data)?.size.to_vec()))
        }
        fn read(&self, element: &dyn ElementReader, _: Option<&Data>) -> Result<Data, Error> {
            let size = element.f64s("size")?;
            let size = size
                .try_into()
                .map_err(|_| Error::invalid("a box has three sides"))?;
            Ok(BOX.wrap(BoxShape { size }))
        }
    }
  5. Define the Box node

    cook reads the node's parameters from CookCx and returns its outputs. Studio decides when a node cooks and keeps its result.

    src/lib.rs
    /// The Box node: makes a box from its parameters.
    pub struct BoxNode;
    
    impl BoxNode {
        /// Its parameters and their defaults: width, depth and height, in
        /// scene units. The SDK's `NodeType` doesn't carry a node's
        /// description (label, ports, parameters) yet; the tests cook with
        /// these values.
        pub fn parameters() -> Schema {
            let side = |name: &str, label: &str| {
                let length = ParameterKind::Number(Number::length().range(0.001, f64::INFINITY));
                Parameter::new(name, label, length, 1.0)
            };
            Schema::default()
                .parameter(side("width", "Width"))
                .parameter(side("depth", "Depth"))
                .parameter(side("height", "Height"))
        }
    }
    
    impl NodeType for BoxNode {
        fn id(&self) -> NodeTypeId {
            NodeTypeId::declared("example.boxes.box_node")
        }
        fn cook(&self, cx: &mut CookCx<'_>, _inputs: &[Data]) -> Result<Outputs, CookError> {
            let size = [
                cx.number("width")?,
                cx.number("depth")?,
                cx.number("height")?,
            ];
            if size.iter().any(|&side| side <= 0.0) {
                return Err(CookError::message("every side must be longer than zero"));
            }
            Ok(Outputs::one(BOX.wrap(BoxShape { size })))
        }
    }
  6. Register the package

    Package lists what the plugin contributes. Every id starts with the package's vendor: example.boxes registers example.boxes.box. kinogaki_package is the function Studio looks up in the library.

    src/lib.rs
    /// The package: what Studio registers when it loads the plugin.
    pub fn package() -> Package {
        Package::new(PackageId::declared("example.boxes"), "Boxes")
            .with_data_type(BoxType)
            .with_node_type(BoxNode)
    }
    
    /// The entry point Studio looks up in the plugin's library.
    #[allow(unsafe_code)] // `no_mangle` keeps the name Studio looks for.
    #[unsafe(no_mangle)]
    pub fn kinogaki_package() -> Package {
        package()
    }
  7. Test it

    The tests cook the node as Studio does and ask the data type for its bounds, surface, triangles and saved form.

    cargo test
    test result: ok. 4 passed; 0 failed
  8. Build

    cargo build --release
    target/release/libbox_node.dylib
  9. Install Planned

    Studio does not load plugin libraries yet. When it does, a plugin is installed by copying its library into the user plugin folder, and Studio refuses a library built for another Studio release or Rust release.

    cp target/release/libbox_node.dylib \
      ~/Library/Application\ Support/Kinogaki\ Studio/plugins/nodes/
03 / FULL SOURCE

The whole plugin

Cargo.toml
[package]
name = "box-node"
version = "0.1.0"
edition = "2024"

[lib]
# A Rust dylib: Studio and its plugins share one standard library and one
# copy of the SDK.
crate-type = ["dylib"]

[dependencies]
kinogaki-studio-api = { git = "https://github.com/kinogaki/studio-sdk" }
src/lib.rs
//! A Kinogaki Studio plugin that adds a Box node.
//!
//! The package defines a data type, `example.boxes.box` (a box's size),
//! and a node type, `example.boxes.box_node`, that makes a box from its
//! width, depth and height. The data type tells Studio how a box shows in
//! the viewport, how it renders and how it saves.
use kinogaki_studio_api::element::Field;
use kinogaki_studio_api::params::{Number, Parameter, ParameterKind};
use kinogaki_studio_api::*;
use std::sync::{Arc, LazyLock};

/// A box centered on the origin: its size along x, y and z (z is up).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BoxShape {
    /// Width, depth and height.
    pub size: [f64; 3],
}

impl BoxShape {
    /// Its eight corners: the bottom four, then the top four.
    pub fn points(&self) -> [[f32; 3]; 8] {
        let [x, y, z] = self.size.map(|s| (s / 2.0) as f32);
        [
            [-x, -y, -z],
            [x, -y, -z],
            [x, y, -z],
            [-x, y, -z],
            [-x, -y, z],
            [x, -y, z],
            [x, y, z],
            [-x, y, z],
        ]
    }
}

/// The six faces, as corner numbers counterclockwise seen from outside:
/// bottom, top, front, back, right, left.
const FACES: [[u32; 4]; 6] = [
    [0, 3, 2, 1],
    [4, 5, 6, 7],
    [0, 1, 5, 4],
    [2, 3, 7, 6],
    [1, 2, 6, 5],
    [3, 0, 4, 7],
];

/// The connectivity key: every box has the same faces.
const TOPOLOGY: u64 = 1;

/// Wraps boxes as `Data` and reads them back.
pub static BOX: LazyLock<Typed<BoxShape>> =
    LazyLock::new(|| Typed::new(DataTypeId::declared("example.boxes.box")));

fn box_of(data: &Data) -> Result<&BoxShape, Error> {
    BOX.get(data).ok_or_else(|| Error::invalid("not a box"))
}

/// The box data type, with its display, render and save facets.
pub struct BoxType;

impl DataType for BoxType {
    fn id(&self) -> DataTypeId {
        BOX.id()
    }
    fn label(&self) -> &str {
        "Box"
    }
    fn wire_color(&self) -> Color {
        Color::rgb(232, 164, 58)
    }
    fn stamp(&self, data: &Data) -> Stamp {
        let mut stamp = [0; 32];
        if let Some(shape) = BOX.get(data) {
            for (bytes, side) in stamp.chunks_mut(8).zip(shape.size) {
                bytes.copy_from_slice(&side.to_le_bytes());
            }
        }
        stamp
    }
    fn memory(&self, _data: &Data) -> usize {
        size_of::<BoxShape>()
    }
    fn bounds(&self, data: &Data) -> Option<Bounds> {
        let [x, y, z] = BOX.get(data)?.size.map(|s| s / 2.0);
        Some(Bounds {
            min: Vec3::new(-x, -y, -z),
            max: Vec3::new(x, y, z),
        })
    }
    fn info(&self, data: &Data) -> Vec<InfoRow> {
        let Some(BoxShape { size: [w, d, h] }) = BOX.get(data) else {
            return Vec::new();
        };
        vec![InfoRow::new("Size", format!("{w} × {d} × {h}"))]
    }
    fn codec(&self) -> &dyn DataCodec {
        self
    }
    fn displayable(&self) -> Option<&dyn Displayable> {
        Some(self)
    }
    fn renderable(&self) -> Option<&dyn Renderable> {
        Some(self)
    }
}

/// In the viewport: six flat faces and their twelve edges.
impl Displayable for BoxType {
    fn levels(&self) -> &[Level] {
        &[] // selected as a whole object only
    }
    fn prepare(&self, data: &Data, _: DisplayQuality, _: &CancelToken) -> Result<Drawn, Error> {
        let surface = Surface::polygons(box_of(data)?.points(), FACES, TOPOLOGY);
        Ok(Drawn::new(surface))
    }
    fn highlight(&self, _: &Data, _: &Drawn, _: &Components) -> Elements {
        Elements::new(Level::Face, [])
    }
    fn convert(&self, _: &Data, _: &Components, _: Level) -> Option<Components> {
        None
    }
}

/// In renders: twelve triangles, two a face.
impl Renderable for BoxType {
    fn items(&self, data: &Data, _: Time, _: &CancelToken) -> Result<Vec<RenderItem>, Error> {
        let triangles: Vec<[u32; 3]> = FACES
            .iter()
            .flat_map(|&[a, b, c, d]| [[a, b, c], [a, c, d]])
            .collect();
        let source = PlainTriangles::new(box_of(data)?.points(), triangles, TOPOLOGY);
        Ok(vec![RenderItem::triangles(0, Arc::new(source))])
    }
}

/// In project files: the size.
impl DataCodec for BoxType {
    fn write(
        &self,
        data: &Data,
        _: Option<&Data>,
        out: &mut dyn ElementWriter,
    ) -> Result<(), Error> {
        out.set("size", Field::F64s(box_of(data)?.size.to_vec()))
    }
    fn read(&self, element: &dyn ElementReader, _: Option<&Data>) -> Result<Data, Error> {
        let size = element.f64s("size")?;
        let size = size
            .try_into()
            .map_err(|_| Error::invalid("a box has three sides"))?;
        Ok(BOX.wrap(BoxShape { size }))
    }
}

/// The Box node: makes a box from its parameters.
pub struct BoxNode;

impl BoxNode {
    /// Its parameters and their defaults: width, depth and height, in
    /// scene units. The SDK's `NodeType` doesn't carry a node's
    /// description (label, ports, parameters) yet; the tests cook with
    /// these values.
    pub fn parameters() -> Schema {
        let side = |name: &str, label: &str| {
            let length = ParameterKind::Number(Number::length().range(0.001, f64::INFINITY));
            Parameter::new(name, label, length, 1.0)
        };
        Schema::default()
            .parameter(side("width", "Width"))
            .parameter(side("depth", "Depth"))
            .parameter(side("height", "Height"))
    }
}

impl NodeType for BoxNode {
    fn id(&self) -> NodeTypeId {
        NodeTypeId::declared("example.boxes.box_node")
    }
    fn cook(&self, cx: &mut CookCx<'_>, _inputs: &[Data]) -> Result<Outputs, CookError> {
        let size = [
            cx.number("width")?,
            cx.number("depth")?,
            cx.number("height")?,
        ];
        if size.iter().any(|&side| side <= 0.0) {
            return Err(CookError::message("every side must be longer than zero"));
        }
        Ok(Outputs::one(BOX.wrap(BoxShape { size })))
    }
}

/// The package: what Studio registers when it loads the plugin.
pub fn package() -> Package {
    Package::new(PackageId::declared("example.boxes"), "Boxes")
        .with_data_type(BoxType)
        .with_node_type(BoxNode)
}

/// The entry point Studio looks up in the plugin's library.
#[allow(unsafe_code)] // `no_mangle` keeps the name Studio looks for.
#[unsafe(no_mangle)]
pub fn kinogaki_package() -> Package {
    package()
}

#[cfg(test)]
mod tests;
src/tests.rs
src/tests.rs
//! Cooks the Box node the way Studio does, then asks the box data type
//! what Studio asks it: bounds, viewport surface, render triangles, save.
use super::*;
use kinogaki_studio_api::element::MemoryElement;
use kinogaki_studio_api::parallel::Serial;

/// Cooks the Box node with `width`, `depth` and `height`.
fn cook(width: f64, depth: f64, height: f64) -> Result<Outputs, CookError> {
    let mut parameters = BoxNode::parameters().values();
    parameters.insert("width".into(), width.into());
    parameters.insert("depth".into(), depth.into());
    parameters.insert("height".into(), height.into());
    let cancel = CancelToken::default();
    let mut scratch = None;
    let mut cx = CookCx::new(&parameters, &cancel, &Serial, &mut scratch);
    BoxNode.cook(&mut cx, &[])
}

#[test]
fn the_package_registers() {
    let mut registry = Registry::new();
    registry.register(package()).unwrap();
    let node = NodeTypeId::declared("example.boxes.box_node");
    assert!(registry.node_type(node).is_some());
    assert_eq!(
        registry.data_type_owner(BOX.id()).unwrap().as_str(),
        "example.boxes"
    );
}

#[test]
fn the_node_makes_a_box_that_renders_as_twelve_triangles() {
    let registry = {
        let mut registry = Registry::new();
        registry.register(package()).unwrap();
        registry
    };
    let data = cook(2.0, 3.0, 4.0).unwrap().data.remove(0);
    assert_eq!(
        BOX.get(&data),
        Some(&BoxShape {
            size: [2.0, 3.0, 4.0]
        })
    );
    let ty = registry.data_type(data.ty()).unwrap();

    let bounds = ty.bounds(&data).unwrap();
    assert_eq!(bounds.min, Vec3::new(-1.0, -1.5, -2.0));
    assert_eq!(bounds.max, Vec3::new(1.0, 1.5, 2.0));
    assert_eq!(ty.info(&data)[0].value, "2 × 3 × 4");

    let cancel = CancelToken::default();
    let items = ty
        .renderable()
        .unwrap()
        .items(&data, Time::default(), &cancel)
        .unwrap();
    let RenderItem::Triangles { source, .. } = &items[0];
    assert_eq!((items.len(), source.triangle_count()), (1, 12));
    // Every triangle faces away from the center.
    let points = source.points();
    for [a, b, c] in source
        .triangles()
        .iter()
        .map(|t| t.map(|i| points[i as usize]))
    {
        let (u, v) = (sub(b, a), sub(c, a));
        let normal = [
            u[1] * v[2] - u[2] * v[1],
            u[2] * v[0] - u[0] * v[2],
            u[0] * v[1] - u[1] * v[0],
        ];
        let center = [0, 1, 2].map(|k| (a[k] + b[k] + c[k]) / 3.0);
        assert!(dot(normal, center) > 0.0);
    }

    let display = ty.displayable().unwrap();
    let drawn = display
        .prepare(&data, DisplayQuality::Normal, &cancel)
        .unwrap();
    assert_eq!(drawn.surface.faces(), 6);
    assert_eq!(drawn.surface.triangles().len(), 12);
    assert_eq!(drawn.surface.lines().len(), 2 * 12); // twelve edges
}

#[test]
fn a_box_saves_and_reads_back() {
    let data = cook(1.0, 2.0, 0.5).unwrap().data.remove(0);
    let codec = BoxType;
    let codec = codec.codec();
    let mut element = MemoryElement::new("box");
    element.write(|out| codec.write(&data, None, out)).unwrap();
    let read = element.read(|reader| codec.read(reader, None)).unwrap();
    assert_eq!(BOX.get(&read), BOX.get(&data));
    assert_eq!(codec_stamp(&read), codec_stamp(&data));
}

#[test]
fn a_flat_box_is_an_error() {
    assert!(matches!(cook(1.0, 0.0, 1.0), Err(CookError::Failed(_))));
}

fn codec_stamp(data: &Data) -> Stamp {
    BoxType.stamp(data)
}

fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}

fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
04 / MORE

The SDK's license is to be announced.