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
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.
| Crate | What it holds |
|---|---|
kinogaki-studio-api | Ids, Data, DataType and its facets, the node cook contract, object kinds, file formats, session slots, Package and the Registry. |
kinogaki-studio-ui-api | The UI layer: panels, tool cards, overlays and settings pages ask the host to draw. |
kinogaki-render-api | The 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.
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.
-
Create a crate
cargo new --lib box-node -
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" } -
Define the data type's value
A box is a plain Rust value.
Typedpairs it with its id,example.boxes.box, and wraps it asData, 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")) } -
Tell Studio how to show, render and save it
DataTypeanswers what Studio asks of any value: label, wire color, a content stamp, bounds and info rows. The facets are optional.Displayablegives the viewport a surface,Renderablegives render engines triangles, andDataCodecwrites 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 })) } } -
Define the Box node
cookreads the node's parameters fromCookCxand 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 }))) } } -
Register the package
Packagelists what the plugin contributes. Every id starts with the package's vendor:example.boxesregistersexample.boxes.box.kinogaki_packageis 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() } -
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 -
Build
cargo build --release target/release/libbox_node.dylib -
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/
The whole plugin
[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" }//! 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
//! 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(¶meters, &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]
}- Repository README, building and checks
- Architecture What each part of the API is for and the rules packages follow
- examples/box-node This example
The SDK's license is to be announced.
