Model Norn and TeX as embedded Eve surfaces

This commit is contained in:
Meta Vens 2026-05-31 13:31:16 +01:00
commit f1caec28cd
3 changed files with 220 additions and 43 deletions

105
dist/eve.js vendored
View file

@ -37,6 +37,22 @@
});
}
function scenePlacement(input = {}) {
return compact({
space: input.space || "scene",
anchor: input.anchor,
slot: input.slot,
mode: input.mode || input.fit || "overlay",
quad: input.quad,
transform: input.transform,
zIndex: input.zIndex,
opacity: input.opacity,
chromaKey: input.chromaKey,
occlusion: input.occlusion,
lighting: input.lighting,
});
}
function styleTokens(input = {}) {
return {
themeId: input.themeId || "sai.vn.default",
@ -81,7 +97,7 @@
};
}
function graphComponent(graph = {}) {
function nornComponent(graph = {}) {
if (!graph.nodes?.length) return null;
const rawXs = graph.nodes.map((node) => node.viewX ?? node.x ?? 0);
const rawYs = graph.nodes.map((node) => node.viewY ?? node.y ?? 0);
@ -98,31 +114,76 @@
return component(
"sai.graph",
"graph",
"embed.norn",
{
label: graph.label || "Ink knot graph",
layout: graph.layout || "norn.2d",
nodes: graph.nodes.map((node) =>
compact({
id: node.id,
label: node.label || node.id,
target: node.target || node.knot || node.id,
x: normalizeX(node.viewX ?? node.x ?? 0),
y: normalizeY(node.viewY ?? node.y ?? 0),
rank: node.rank,
order: node.order,
current: Boolean(node.current),
visited: Boolean(node.visited),
available: Boolean(node.available),
weight: node.weight,
}),
),
edges: graph.edges || [],
engine: {
id: "norn",
contract: "gamecult.norn.surface.v1",
web: {
wasm: graph.solver_wasm || graph.solverWasm,
package: "@gamecult/norn-viewer",
},
native: {
rustCrate: "norn-rs",
direct2dAdapter: "fensalir.norn.direct2d",
},
},
layout: {
mode: graph.layout || "norn.2d",
iterations: graph.iterations,
rankGap: graph.rank_gap || graph.rankGap,
nodeGap: graph.node_gap || graph.nodeGap,
edgeLength: graph.edge_length || graph.edgeLength,
},
graph: {
nodes: graph.nodes.map((node) =>
compact({
id: node.id,
label: node.label || node.id,
target: node.target || node.knot || node.id,
x: normalizeX(node.viewX ?? node.x ?? 0),
y: normalizeY(node.viewY ?? node.y ?? 0),
rank: node.rank,
order: node.order,
current: Boolean(node.current),
visited: Boolean(node.visited),
available: Boolean(node.available),
weight: node.weight,
}),
),
edges: graph.edges || [],
},
interaction: {
nodeAction: "story.jump",
focusFollowsStory: true,
rendererMustUseNorn: true,
},
placement: scenePlacement(graph.placement || graph.diegetic),
},
[],
);
}
function texComponents(items = []) {
return items.map((item, index) =>
component(`sai.tex.${item.id || index}`, "embed.tex", {
label: item.label,
source: item.source || item.tex || item.latex,
sourceUri: item.sourceUri,
format: item.format || "latex",
display: item.display || "block",
macros: item.macros,
renderer: {
web: item.webRenderer || "katex",
native: item.nativeRenderer || "tex-renderer",
direct2d: item.direct2dRenderer || "tex-to-directwrite",
},
placement: scenePlacement(item.placement || item.diegetic),
}),
);
}
function spriteComponents(sprites = []) {
return sprites.map((sprite, index) =>
component(`sai.sprite.${sprite.actorName || index}`, "image.sprite", {
@ -186,8 +247,9 @@
const speaker = input.speaker || metadata.speaker || "Void";
const line = input.line || "";
const title = input.title || scene.label || "Sai Visual Novel";
const graph = graphComponent(input.graph);
const norn = nornComponent(input.graph || input.norn);
const variables = variableComponents(input.variables || []);
const tex = texComponents(input.tex || input.texSurfaces || []);
const stageChildren = [
component("sai.background", "image.background", {
@ -196,7 +258,8 @@
fit: "cover",
pixelArt: style.tokens.pixelArt,
}),
graph,
norn,
component("sai.embeds", "layer.embedded-surfaces", {}, tex),
component("sai.sprites", "layer.sprites", {}, spriteComponents(input.sprites)),
component("sai.cards", "layer.cards", {}, domCardComponents(input.domCards)),
component(

View file

@ -41,12 +41,31 @@ const surface = window.SaiEve.createVisualNovelSurface({
graph: {
label: "GameCult Ink Knot Graph",
layout: "norn.2d",
solver_wasm: "/static/interactive/norn/norn-rs.wasm",
placement: {
space: "scene",
anchor: "whiteboard",
mode: "keystone",
quad: [[0.16, 0.18], [0.58, 0.13], [0.62, 0.50], [0.14, 0.56]]
},
nodes: [
{ id: "hub", label: "Hub", target: "hub", x: 0, y: 0, current: true },
{ id: "eve", label: "Eve", target: "eve", x: 1, y: 0 },
],
edges: [{ source: "hub", target: "eve" }],
},
tex: [
{
id: "bifrost-log-power",
label: "Bifrost voting weight",
source: "\\\\mathrm{votes}(p)=1+\\\\lfloor\\\\log_b(1+p)\\\\rfloor",
placement: {
space: "scene",
anchor: "whiteboard",
mode: "keystone"
}
}
],
choices: [
{ text: "What is Eve trying to turn the web into?", targetPath: "eve" },
],
@ -73,13 +92,45 @@ without each renderer inventing its own theme protocol. A renderer may expose
native color pickers, segmented controls, or accessibility settings, but the
accepted style state belongs to the provider and comes back as surface tokens.
## Embedded Scene Surfaces
The point of the Eve module is not to flatten Sai into a dashboard. A VN scene
can contain portable interactive surfaces that are diegetically placed inside
the scene. The graph map is one of those surfaces.
`embed.norn` declares that a renderer must embed a Norn graph runtime, not draw
a decorative graph lookalike. Web can use Norn WASM and `@gamecult/norn-viewer`;
native targets can use `norn-rs` directly or through a platform adapter.
`embed.tex` declares TeX/LaTeX content as a portable rich math/layout surface.
Web renderers may use KaTeX or another TeX path; native renderers should lower
the same source into their local text/vector stack.
Both kinds accept `placement`:
```json
{
"space": "scene",
"anchor": "whiteboard",
"mode": "keystone",
"quad": [[0.16, 0.18], [0.58, 0.13], [0.62, 0.50], [0.14, 0.56]],
"chromaKey": { "color": "#00ff00", "tolerance": 0.12 },
"occlusion": "sprite-mask:whiteboard-hand"
}
```
That is the hook for character-pointing-at-whiteboard shots, chromakey panels in
sprite assets, and perspective/skew/keystone fitting onto scene props.
## Component Kinds
The first VN surface uses:
- `vn.stage`
- `image.background`
- `graph`
- `embed.norn`
- `embed.tex`
- `layer.embedded-surfaces`
- `layer.sprites`
- `image.sprite`
- `layer.cards`

View file

@ -37,6 +37,22 @@
});
}
function scenePlacement(input = {}) {
return compact({
space: input.space || "scene",
anchor: input.anchor,
slot: input.slot,
mode: input.mode || input.fit || "overlay",
quad: input.quad,
transform: input.transform,
zIndex: input.zIndex,
opacity: input.opacity,
chromaKey: input.chromaKey,
occlusion: input.occlusion,
lighting: input.lighting,
});
}
function styleTokens(input = {}) {
return {
themeId: input.themeId || "sai.vn.default",
@ -81,7 +97,7 @@
};
}
function graphComponent(graph = {}) {
function nornComponent(graph = {}) {
if (!graph.nodes?.length) return null;
const rawXs = graph.nodes.map((node) => node.viewX ?? node.x ?? 0);
const rawYs = graph.nodes.map((node) => node.viewY ?? node.y ?? 0);
@ -98,31 +114,76 @@
return component(
"sai.graph",
"graph",
"embed.norn",
{
label: graph.label || "Ink knot graph",
layout: graph.layout || "norn.2d",
nodes: graph.nodes.map((node) =>
compact({
id: node.id,
label: node.label || node.id,
target: node.target || node.knot || node.id,
x: normalizeX(node.viewX ?? node.x ?? 0),
y: normalizeY(node.viewY ?? node.y ?? 0),
rank: node.rank,
order: node.order,
current: Boolean(node.current),
visited: Boolean(node.visited),
available: Boolean(node.available),
weight: node.weight,
}),
),
edges: graph.edges || [],
engine: {
id: "norn",
contract: "gamecult.norn.surface.v1",
web: {
wasm: graph.solver_wasm || graph.solverWasm,
package: "@gamecult/norn-viewer",
},
native: {
rustCrate: "norn-rs",
direct2dAdapter: "fensalir.norn.direct2d",
},
},
layout: {
mode: graph.layout || "norn.2d",
iterations: graph.iterations,
rankGap: graph.rank_gap || graph.rankGap,
nodeGap: graph.node_gap || graph.nodeGap,
edgeLength: graph.edge_length || graph.edgeLength,
},
graph: {
nodes: graph.nodes.map((node) =>
compact({
id: node.id,
label: node.label || node.id,
target: node.target || node.knot || node.id,
x: normalizeX(node.viewX ?? node.x ?? 0),
y: normalizeY(node.viewY ?? node.y ?? 0),
rank: node.rank,
order: node.order,
current: Boolean(node.current),
visited: Boolean(node.visited),
available: Boolean(node.available),
weight: node.weight,
}),
),
edges: graph.edges || [],
},
interaction: {
nodeAction: "story.jump",
focusFollowsStory: true,
rendererMustUseNorn: true,
},
placement: scenePlacement(graph.placement || graph.diegetic),
},
[],
);
}
function texComponents(items = []) {
return items.map((item, index) =>
component(`sai.tex.${item.id || index}`, "embed.tex", {
label: item.label,
source: item.source || item.tex || item.latex,
sourceUri: item.sourceUri,
format: item.format || "latex",
display: item.display || "block",
macros: item.macros,
renderer: {
web: item.webRenderer || "katex",
native: item.nativeRenderer || "tex-renderer",
direct2d: item.direct2dRenderer || "tex-to-directwrite",
},
placement: scenePlacement(item.placement || item.diegetic),
}),
);
}
function spriteComponents(sprites = []) {
return sprites.map((sprite, index) =>
component(`sai.sprite.${sprite.actorName || index}`, "image.sprite", {
@ -186,8 +247,9 @@
const speaker = input.speaker || metadata.speaker || "Void";
const line = input.line || "";
const title = input.title || scene.label || "Sai Visual Novel";
const graph = graphComponent(input.graph);
const norn = nornComponent(input.graph || input.norn);
const variables = variableComponents(input.variables || []);
const tex = texComponents(input.tex || input.texSurfaces || []);
const stageChildren = [
component("sai.background", "image.background", {
@ -196,7 +258,8 @@
fit: "cover",
pixelArt: style.tokens.pixelArt,
}),
graph,
norn,
component("sai.embeds", "layer.embedded-surfaces", {}, tex),
component("sai.sprites", "layer.sprites", {}, spriteComponents(input.sprites)),
component("sai.cards", "layer.cards", {}, domCardComponents(input.domCards)),
component(