Canonical specification¶
Hakowan's canonical specification is the stable, JSON-safe description of a
visualization. It is intended for saved figures, cache keys, web tools, and
constrained AI output. Runtime objects such as SurfaceMesh, Python callables,
compiler attributes, and backend objects do not appear directly in the document.
The normative machine contract is the published v1 JSON Schema.
hkw.schema() returns the same document. Every object rejects unknown fields,
and every polymorphic object uses a required kind discriminator.
API¶
import hakowan as hkw
spec = hkw.to_spec(layer)
payload = spec.to_dict()
text = spec.to_json()
canonical = spec.to_json(canonical=True)
spec.save("figure.json")
parsed = hkw.load_spec("figure.json") # FigureSpec only
layer = hkw.from_spec(parsed) # FigureSpec/dict -> Layer
layer = hkw.from_json(text) # JSON -> Layer
layer = hkw.load_layer("figure.json") # file -> Layer, resolves relative paths
schema = hkw.schema() # JSON Schema dict
Layer.to_spec() and Layer.to_json() are equivalent convenience methods.
The complete Python reference is under Canonical specification API.
Minimal scalar-field recipe¶
For constrained generation, the shortest canonical surface-coloring document
needs one data source, one diffuse material, and one scalar-field texture. The
attribute domain, viridis colormap, and legend are inferred:
{
"$schema": "https://hakowan.github.io/hakowan/schema/v1.json",
"version": "1.0",
"root": {
"kind": "layer",
"spec": {
"data": {"kind": "mesh_file", "path": "mesh.ply"},
"channels": {
"material": {
"kind": "diffuse",
"reflectance": {
"kind": "scalar_field",
"data": {"name": "temperature"}
}
}
}
}
}
}
Do not emit default fields unless a consumer requires fully expanded output.
Use hkw.inspect() or the companion MCP service to discover real attribute
names before authoring the document; never guess them.
Atomic patches¶
Use hkw.patch() to make small validated changes without regenerating a whole
specification:
updated = hkw.patch(
figure,
[
{
"op": "replace",
"path": "/scene/camera/eye",
"value": [2, 3, 4],
}
],
)
Paths use JSON Pointer syntax. The
supported operations are add, remove, and replace; - appends to an
array. Operations run against a private canonical copy. Hakowan then validates
the complete schema, reconstructs the runtime object, and runs semantic and
backend validation. Any failure raises PatchError and leaves the input
unchanged.
try:
updated = hkw.patch(figure, operations, backend="webgl")
except hkw.PatchError as error:
print(error.failure.code, error.failure.path, error.failure.message)
if error.validation_report is not None:
print(error.validation_report.to_dict())
Backend degradations are warnings by default. Pass strict=True to reject
them, or semantic=False when deliberately constructing an intermediate spec.
In-memory meshes and Python callables are rebound automatically. A patch that
introduces a new external identifier must supply data_resolver= or
function_resolver=.
hkw.patch_spec() applies the same atomic operations to a FigureSpec or
mapping and performs schema validation only. It always returns an immutable
FigureSpec.
Canonical documents, runtime conversion graphs, and individual patch values are limited to 64 nesting levels. Inputs beyond that limit fail with structured validation or patch errors before recursive conversion begins.
Document root¶
| Field | Required | Value |
|---|---|---|
$schema |
no | Defaults to https://hakowan.github.io/hakowan/schema/v1.json |
version |
no | Defaults to 1.1; accepted values are 1.0 and 1.1 |
root |
yes | A composition node |
scene |
no | Figure-level camera, lights, environment, and output intent (1.1) |
Unknown versions, fields, and kind values are rejected during parsing.
Scene configuration¶
Schema 1.1 adds an optional top-level scene block:
{
"camera": {
"kind": "perspective",
"eye": [2, 3, 4],
"target": [0, 0, 0],
"up": [0, 1, 0],
"fov": 35,
"fov_axis": "y",
"near": 0.01,
"far": 100
},
"lights": [
{
"kind": "directional",
"direction": [0, 0, -1],
"color": "white",
"intensity": 2
}
],
"environment": {
"enabled": true,
"path": "studio.exr",
"scale": 1,
"up": [0, 1, 0],
"rotation": 180,
"visible": false
},
"output": {
"width": 1024,
"height": 800,
"background": "dark",
"passes": ["beauty", "depth"],
"sampler_seed": 0
}
}
Camera kinds are perspective, orthographic, and thin_lens. Light kinds
are point and directional. A null scene component preserves runtime
defaults; lights: [] explicitly removes direct lights. Environment paths use
the same relative-resolution rules as mesh and image paths.
When rendering, precedence is:
Output filenames, backend selection, browser executable, and device selection remain invocation-only.
Composition nodes¶
Every node contributes a spec containing partial layer properties. Parent
properties have precedence over descendant properties, matching Hakowan's
runtime layer tree.
kind |
Fields | Semantics |
|---|---|---|
layer |
spec |
Leaf visualization layer. |
inherit |
spec, child |
Applies this node's properties to one child. |
overlay |
spec, children |
Renders at least two children in one coordinate space; runtime +. |
layout |
spec, axis, gap, normalize, children |
Places at least two children along x, y, or z; runtime |, &, or juxtapose(). |
gap defaults to 0.05 times the mean cell diameter. normalize=false
preserves relative object scale. Child order is significant.
Layer properties¶
A node's spec accepts:
| Field | Type | Meaning |
|---|---|---|
data |
data reference or null |
Mesh and optional region of interest. |
mark |
point, curve, surface, or null |
Mark override. The compiler defaults unresolved marks to surface. |
channels |
object | At most one value for each visual channel. |
transforms |
array | Transform operations in application order. |
name |
string or null |
Viewer-facing layer label. |
annotations |
array | Screen-space text annotations contributed by the node. |
Defaults and explicit null values are retained in canonical output so two
canonical documents can be compared byte-for-byte.
Data references¶
Mesh file¶
path may be relative or absolute. roi_box is optional and contains the
minimum and maximum 3D corners.
External in-memory data¶
Mesh buffers are deliberately not embedded. Serialize an in-memory mesh by providing an identifier:
Restore it through a mapping or callable:
layer = hkw.from_spec(
spec,
data_resolver={"simulation-frame-42": mesh},
)
# Callable form: Callable[[str], DataFrameLike]
layer = hkw.from_spec(spec, data_resolver=load_mesh_by_id)
Missing identifiers raise SpecConversionError; they never produce placeholder
data.
Attributes and scales¶
An attribute reference contains its mesh attribute name, optional physical
unit, and an ordered scale pipeline:
{
"name": "velocity",
"unit": "m/s",
"scales": [
{"kind": "norm", "order": 2},
{
"kind": "normalize",
"range_min": 0.005,
"range_max": 0.02,
"domain_min": null,
"domain_max": null
}
]
}
Scale arrays are evaluated from first to last.
kind |
Fields | Meaning |
|---|---|---|
uniform |
factor |
Multiply values by a constant. |
log |
base=10 |
Apply a logarithm. Non-positive values should be rejected by strict validation. |
clip |
domain |
Clamp to [minimum, maximum]. |
normalize |
range_min, range_max, domain_min=null, domain_max=null |
Map an explicit or inferred domain into a target range. |
affine |
matrix |
Apply a linear or homogeneous matrix. |
norm |
order=2 |
Reduce vectors to scalar magnitudes. |
offset |
offset |
Add another AttributeSpec. |
custom |
function |
Apply a restricted expression or trusted external function. |
Channels¶
channels is keyed rather than represented as a list. Duplicate channels are
rejected at the canonical boundary instead of relying on runtime shadowing.
| Key | kind |
Fields | Applicable marks |
|---|---|---|---|
position |
position |
data |
all |
normal |
normal |
data |
surface |
size |
size |
data constant or attribute, space=world |
point, curve |
shape |
shape |
base_shape=sphere, orientation=null |
point |
vector_field |
vector_field |
data, refinement_level=0, style=null, end_type=point, normalize=false |
curve |
covariance |
covariance |
data, full=false |
point |
material |
material variant | material-specific fields | all |
bump_map |
bump_map |
texture, scale=1 |
surface |
normal_map |
normal_map |
texture |
surface |
A vector-field style, when present, currently supports:
bend_type is n (normal), r (ribbon), or s (smooth).
Textures¶
A texture-valued field accepts one of the following objects. Color-valued positions additionally accept a number, named/hex color string, or numeric list.
kind |
Fields | Meaning |
|---|---|---|
uniform |
color |
Constant color or scalar. |
image |
path, uv=null, raw=false, saturation=1, whiteness=0 |
Image sampled through UV coordinates. |
checkerboard |
uv=null, texture1=0.8, texture2=0.2, size=8 |
Alternating UV-space textures. |
isocontour |
data, ratio=0.1, texture1=0.4, texture2=0.2, num_contours=8 |
Scalar contour bands. |
scalar_field |
data, colormap=viridis (set1 when categories=true), domain=null, range=null, categories=false, reverse=false, legend=true |
Scalar-to-color mapping with an automatic legend. |
legend may be false, true, or an object with title, units, ticks,
format, position, category_labels, and raster width. See
Legends and annotations.
Annotations¶
Each layer property object accepts an annotations array. An annotation has
text, normalized screen position, color, font_size, horizontal anchor,
optional background, and padding.
{
"text": "Peak stress",
"position": [0.5, 0.06],
"color": "white",
"font_size": 18,
"anchor": "center",
"background": "black",
"padding": 5
}
Materials¶
Every material also accepts two_sided=false and back_side=null.
kind |
Fields beyond common material fields |
|---|---|
diffuse |
reflectance=0.5 |
conductor |
material preset name |
rough_conductor |
material, distribution=beckmann, alpha=0.1 |
plastic |
diffuse_reflectance=0.5, specular_reflectance=1 |
rough_plastic |
plastic fields, distribution=beckmann, alpha=0.1 |
principled |
color=0.5, roughness=0.5, metallic=0, anisotropic=0, spec_trans=0, eta=1.5, spec_tint=0, sheen=0, sheen_tint=0, flatness=0 |
thin_principled |
principled fields, diff_trans=0 |
dielectric |
int_ior=bk7, ext_ior=air, medium=null, specular_reflectance=1, specular_transmittance=1 |
thin_dielectric |
dielectric fields |
rough_dielectric |
dielectric fields, distribution=beckmann, alpha=0.1 |
hair |
eumelanin=1.3, pheomelanin=0.2, color=null, root_color=null, tip_color=null, color_variation=0 |
A dielectric medium has albedo=0.75 and scale=1.
Backend-specific material degradation is reported by
hkw.validate(..., strict=True).
Transforms¶
Transform arrays are applied from first to last.
kind |
Fields and defaults |
|---|---|
filter |
data=null, condition=null; null data means vertex positions and null condition keeps all elements. |
clip |
point=[0,0,0], normal=[1,0,0] |
uv_mesh |
uv=null; null selects the mesh UV attribute. |
affine |
required matrix |
principal_axes |
identity frame, orthonormalize_frame=true |
normalize |
normalize_normals=true, normalize_tangents_bitangents=true |
compute |
optional output names: x, y, z, normal, vertex_normal, facet_normal, component |
explode |
pieces, magnitude=1 |
norm |
data, norm_attr_name, order=2 |
boundary |
attributes=[] |
streamline |
vec_field, n=50, cross_field=true, length=null, seed=0, min_length=3, max_steps=null, id_attr_name=_hakowan_streamline_id |
fur |
vec_field, n=2000, length=null, lift=30, curl=0.35, segments=6, root_radius=null, tip_radius=0, randomness=0.3, follow_surface=false, children=0, clump=0.6, spread=null, seed=0 |
Expressions and functions¶
Restricted expressions¶
Available values:
value: complete scalar or vector value;x,y,z: components 0, 1, and 2, ornullwhen absent;true,false,null.
Allowed operations:
- scalar and vector literals (sequence repetition is rejected);
- numeric
+,-,*,/,//,%, and one bounded literal power; - comparisons,
in, andnot in; and,or, andnot;- integer indexing;
abs,min,max,isfinite, andnorm.
Attribute access, imports, comprehensions, keyword arguments, arbitrary calls,
sequence repetition, chained powers, and non-literal or large exponents are
rejected. Expressions are limited to 1024 characters, 128 AST nodes, 4096-item
sequence or array results, and 4096-bit integer results. Hakowan interprets the
validated AST; it does not call eval().
External functions¶
Resolvers may be mappings or callables:
# Serialization
function_ids: Mapping[Callable, str] | Callable[[Callable], str]
# Reconstruction
function_resolver: Mapping[str, Callable] | Callable[[str], Callable]
An arbitrary callable without an identifier raises SpecConversionError.
Python source, bytecode, closures, and pickle payloads are never serialized.
Path resolution¶
hkw.from_spec(spec)andhkw.from_json(text)interpret relative paths using the process working directory unlessbase_dir=is supplied.hkw.load_layer("dir/figure.json")resolves relative mesh and image paths againstdir/.- The unresolved logical path is retained when converting the restored layer back to a specification.
- Paths are serialized with POSIX separators.
- Absolute paths remain absolute.
Canonicalization guarantees¶
spec.to_json(canonical=True) guarantees for the same FigureSpec:
- sorted object keys;
- compact separators and no insignificant whitespace;
- explicit defaults and nulls;
- UTF-8-compatible JSON text;
- rejection of NaN and Infinity;
- deterministic output suitable for hashing and cache keys.
Canonicalization does not normalize file contents, hash external resources, resolve symbolic links, or claim identical pixels across renderer versions. Child order, transform order, numeric types, and resource identifiers remain semantically significant.
Version compatibility¶
Versions 1.0 and 1.1 are accepted. Version 1.0 contains only the layer tree;
version 1.1 adds the optional scene block and is the default for Figure
serialization. Loading a 1.0 document remains backward-compatible and returns a
Layer. A 1.0 document containing scene is rejected rather than interpreted
as 1.1. Loading a 1.1 document with scene settings returns a Figure. Unknown
versions, fields, and variants are rejected rather than migrated implicitly.
Unsupported serialization cases¶
The canonical boundary deliberately rejects:
- in-memory meshes without a
data_idsmapping or callable; - arbitrary callables without a
function_idsmapping or callable; - duplicate channel kinds within one runtime layer node;
- unknown runtime subclasses of marks, channels, materials, textures, scales, transforms, or curve styles;
- NaN and Infinity;
- binary mesh or image payloads embedded directly in JSON.
Backend performance controls such as device selection, browser executable, temporary paths, and output filename remain invocation-only. The scene schema stores semantic output intent but does not promise pixel-identical results across renderer versions or hardware.
Complete handwritten example¶
{
"$schema": "https://hakowan.github.io/hakowan/schema/v1.json",
"version": "1.1",
"root": {
"kind": "overlay",
"spec": {
"data": {"kind": "mesh_file", "path": "mesh.ply", "roi_box": null},
"mark": null,
"channels": {},
"transforms": [],
"name": null
},
"children": [
{
"kind": "layer",
"spec": {
"data": null,
"mark": "surface",
"channels": {
"material": {
"kind": "diffuse",
"two_sided": false,
"back_side": null,
"reflectance": {
"kind": "scalar_field",
"data": {
"name": "temperature",
"scales": [
{
"kind": "normalize",
"range_min": 0,
"range_max": 1,
"domain_min": 0,
"domain_max": 100
}
]
},
"colormap": "viridis",
"domain": null,
"range": null,
"categories": false,
"legend": {
"title": "Temperature",
"units": "°C",
"ticks": 5,
"format": ".3g",
"position": "right",
"category_labels": null,
"width": 180
},
"reverse": false
}
}
},
"transforms": [],
"name": "temperature",
"annotations": [
{
"text": "Simulation A",
"position": [0.02, 0.02],
"color": "white",
"font_size": 16,
"anchor": "left",
"background": "black",
"padding": 4
}
]
}
},
{
"kind": "layer",
"spec": {
"data": null,
"mark": "curve",
"channels": {
"size": {"kind": "size", "data": 0.005},
"material": {
"kind": "diffuse",
"two_sided": false,
"back_side": null,
"reflectance": "black"
}
},
"transforms": [
{"kind": "boundary", "attributes": []}
],
"name": "boundary"
}
}
]
},
"scene": {
"camera": {
"kind": "perspective",
"eye": [2, 3, 4],
"target": [0, 0, 0],
"up": [0, 1, 0],
"fov": 35,
"fov_axis": "y",
"near": 0.01,
"far": 100
},
"lights": [
{
"kind": "directional",
"direction": [0, 0, -1],
"color": "white",
"intensity": 2
}
],
"environment": {
"enabled": false,
"path": null,
"scale": 1,
"up": [0, 1, 0],
"rotation": 180,
"visible": false
},
"output": {
"width": 1024,
"height": 800,
"background": "dark",
"passes": ["beauty", "depth"],
"sampler_seed": 0
}
}
}
Omitted optional fields receive the defaults recorded in the JSON Schema.
spec.to_json() emits them explicitly in canonical runtime output.