VisionMetalKotlinJsExternalsSmokeTest.kt
src/jvmMain/kotlin/afield/examples/VisionMetalKotlinJsExternalsSmokeTest.kt · 328 lines
package afield.examples
import afield.graphics.visionos.VISION_METAL_VERTEX_FLOATS
import afield.graphics.visionos.VisionMetalGeometryOrigin
import afield.graphics.visionos.VisionMetalKotlinJs
import afield.graphics.visionos.VisionMetalProgramPayload
import afield.graphics.visionos.VisionMetalScriptPart
import afield.graphics.visionos.VisionMetalShaderPart
import afield.graphics.visionos.visionMetalWireJson
import afield.lang.remotejs.KotlinToJsCompiler
import java.io.File
import java.nio.file.Files
import afield.media.image.util.NodeTool
FIELD_TEST id: visionmetal.kotlinjs-externals runner: jvm-main entrypoint: afield.examples.VisionMetalKotlinJsExternalsSmokeTest tags: [smoke, visionos, kotlinjs] timeoutSec: 240 expect: exitCode: 0 stdoutContains: - "VisionMetalKotlinJsExternalsSmokeTest ok"
object VisionMetalKotlinJsExternalsSmokeTest
Compiles the typed surface and, when Node is present, links it against a faithful host shim.
The geometry assertions matter more than the compile does: the builder writes the packed layout the
Metal vertex buffer expects, so this checks the real float count, winding-independent index range,
position.w/normal.w, and the actual arithmetic of cube/plane/sphere/tube — not just that
the names bind.
fun main(args: Array<String>)
body — 50 lines, from line 37
@JvmStatic
fun main(args: Array<String>) {
val userSource = """
fun main() {
onStart {
geometry {
color(1.0, 0.35, 0.15)
cube(0.18, cx = 0.45, cz = -1.2, tintFaces = true)
}
uniform("amount", 0.025)
uniform("tint", 0.1, 0.8, 1.0, 0.35)
onFrame { frame ->
uniform("phase", frame.time * 3.0 + frame.deltaTime)
log("frame " + frame.predictedDisplayTime)
}
onStop { log("stopped") }
}
}
""".trimIndent()
val result = KotlinToJsCompiler.compile(
VisionMetalKotlinJs.appendTo(userSource),
moduleName = "vision_metal_externals",
)
require(result.success && result.js != null) {
"Vision Metal Kotlin/JS surface did not compile:\n${result.rawLog.takeLast(1600)}"
}
require(result.diagnostics.none { it.severity == "error" }) {
"Unexpected diagnostics: ${result.diagnostics}"
}
verifyWirePartsAreIndependent()
verifyBuilderShapesCompile()
val js = result.js
require(js.contains("amount") && js.contains("phase")) {
"Compiled module does not contain the expected uniform writes"
}
val node = NodeTool.resolveNode()
if (node != null) {
runNode(node, hostShim + "\n" + js + "\n" + hostProbe, "vision-metal-host-ok")
val shapesJs = compile(shapeSource, "vision_metal_shapes")
runNode(node, hostShim + "\n" + shapesJs + "\n" + shapeProbe, "vision-metal-shapes-ok")
} else {
println("skipping host-link execution: ${NodeTool.unavailableMessage()}; compile proof passed")
}
println("VisionMetalKotlinJsExternalsSmokeTest ok")
}
Wire v2's whole point: a shader reload is a payload carrying the shader and nothing else, so the head leaves the resident script, uniforms and geometry alone.
private fun verifyWirePartsAreIndependent() {
val shaderOnly = visionMetalWireJson.encodeToString(
VisionMetalProgramPayload(
uid = "smoke",
revision = 7,
shader = VisionMetalShaderPart(revision = 7, source = "// msl"),
),
)
require("\"channel\":\"__visionmetal__\"" in shaderOnly) { "wire payload lost its channel: $shaderOnly" }
require("\"version\":2" in shaderOnly) { "Vision Metal wire version regressed: $shaderOnly" }
require("\"shader\":{" in shaderOnly) { "shader part missing from a shader reload: $shaderOnly" }
for (absent in listOf("script", "uniforms", "geometry")) {
require("\"$absent\":null" in shaderOnly) {
"a shader-only reload must leave '$absent' unchanged (null), got: $shaderOnly"
}
}
// Execute (as opposed to Launch) still has to reach the head as an explicitly inert script.
val executePayload = visionMetalWireJson.encodeToString(
VisionMetalProgramPayload(
uid = "smoke",
revision = 8,
script = VisionMetalScriptPart(revision = 8, source = "", launchable = false),
),
)
require("\"launchable\":false" in executePayload) {
"Vision Metal wire payload lost Execute/Launch intent: $executePayload"
}
// The Swift enum decodes these exact lowercase strings.
val origins = VisionMetalGeometryOrigin.entries.map {
visionMetalWireJson.encodeToString(it).trim('"')
}
require(origins.containsAll(listOf("wire", "builtin", "script"))) {
"geometry origins must serialise as the lowercase names the Swift enum decodes, got $origins"
}
}
The completion mirror has to stay compilable Kotlin, since the sidecar parses it as source.
source — 48 lines, from line 131
private fun verifyBuilderShapesCompile() {
require(VisionMetalKotlinJs.completionDeclarations.contains("class MeshBuilder")) {
"the completion mirror lost the geometry builder"
}
require(!VisionMetalKotlinJs.completionDeclarations.contains("external")) {
"the completion mirror must stay free of Kotlin/JS-only syntax"
}
require(!VisionMetalKotlinJs.completionDeclarations.contains("@kotlin.js.JsName")) {
"the completion mirror must stay free of @JsName"
}
}
private val shapeSource = """
fun main() {
onStart {
geometry { plane(1.0, 2.0, cols = 2, rows = 3) }
geometry { sphere(0.5, segments = 6, rings = 4) }
geometry { tube(listOf(p(0.0, 0.0, 0.0), p(0.0, 1.0, 0.0), p(0.0, 2.0, 0.0)), 0.1, sides = 4) }
geometry { }
}
}
""".trimIndent()
private fun compile(source: String, module: String): String {
val result = KotlinToJsCompiler.compile(
VisionMetalKotlinJs.appendTo(source),
moduleName = module,
)
require(result.success && result.js != null) {
"$module did not compile:\n${result.rawLog.takeLast(1600)}"
}
return result.js
}
private fun runNode(node: String, source: String, expected: String) {
val script = Files.createTempFile("field-vision-metal-kotlinjs", ".js").toFile()
try {
script.writeText(source)
val process = ProcessBuilder(node, script.absolutePath).redirectErrorStream(true).start()
val output = process.inputStream.bufferedReader().readText().trim()
process.waitFor()
require(process.exitValue() == 0) { "Host-link run failed:\n$output" }
require(output.contains(expected)) { "Host probe did not pass:\n$output" }
println(output)
} finally {
script.delete()
}
}
Mirrors LiveMetalJavaScript.swift: __fieldSetGeometry coerces to typed arrays before the
bridge sees them, so the probe can assert on the exact buffers Metal would receive.
source — 144 lines, from line 184
private val hostShim = """
const __visionMetalWrites = [];
const __visionMetalMeshes = [];
let __visionMetalFrame = null;
let __visionMetalStop = null;
const VERTEX_FLOATS = $VISION_METAL_VERTEX_FLOATS;
function uniform(name, x, y = 0, z = 0, w = 0) {
__visionMetalWrites.push([String(name), Number(x), Number(y), Number(z), Number(w)]);
}
function __fieldSetGeometry(vertices, indices) {
if (!(vertices instanceof Float32Array)) {
throw new Error("geometry vertices reached the bridge as " + Object.prototype.toString.call(vertices));
}
if (!(indices instanceof Int32Array)) {
throw new Error("geometry indices reached the bridge as " + Object.prototype.toString.call(indices));
}
__visionMetalMeshes.push({ vertices, indices });
}
function __fieldOnStart(callback) { callback(); }
function __fieldOnStop(callback) { __visionMetalStop = callback; }
function __fieldOnFrame(callback) {
if (typeof callback !== "function") throw new TypeError("onFrame expects a function");
__visionMetalFrame = callback;
}
function __fieldKeepAlive() {}
function print(value) {
console.log("vision-metal-log:" + value);
}
function vertexOf(mesh, i) {
const o = i * VERTEX_FLOATS;
return {
position: [mesh.vertices[o], mesh.vertices[o+1], mesh.vertices[o+2], mesh.vertices[o+3]],
normal: [mesh.vertices[o+4], mesh.vertices[o+5], mesh.vertices[o+6], mesh.vertices[o+7]],
color: [mesh.vertices[o+8], mesh.vertices[o+9], mesh.vertices[o+10], mesh.vertices[o+11]],
uv: [mesh.vertices[o+12], mesh.vertices[o+13], mesh.vertices[o+14], mesh.vertices[o+15]],
};
}
function checkMesh(mesh, label) {
if (mesh.vertices.length % VERTEX_FLOATS !== 0) {
throw new Error(label + ": " + mesh.vertices.length + " floats is not a whole number of vertices");
}
const count = mesh.vertices.length / VERTEX_FLOATS;
if (mesh.indices.length % 3 !== 0) throw new Error(label + ": indices are not whole triangles");
for (const index of mesh.indices) {
if (index < 0 || index >= count) throw new Error(label + ": index " + index + " outside " + count + " vertices");
}
for (let i = 0; i < count; i++) {
const v = vertexOf(mesh, i);
if (v.position[3] !== 1) throw new Error(label + ": position.w must be 1, got " + v.position[3]);
if (v.normal[3] !== 0) throw new Error(label + ": normal.w must be 0, got " + v.normal[3]);
}
return count;
}
""".trimIndent()
private val hostProbe = """
if (__visionMetalMeshes.length !== 1) {
throw new Error("expected exactly one geometry upload, got " + __visionMetalMeshes.length);
}
const cube = __visionMetalMeshes[0];
const cubeCount = checkMesh(cube, "cube");
// Six faces, four own vertices each so facet normals survive; two triangles per face.
if (cubeCount !== 24) throw new Error("cube should have 24 vertices, got " + cubeCount);
if (cube.indices.length !== 36) throw new Error("cube should have 36 indices, got " + cube.indices.length);
// cube(0.18, cx = 0.45, cz = -1.2) must land exactly on that centre and half-extent.
let lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
const normals = new Set(), colors = new Set();
for (let i = 0; i < cubeCount; i++) {
const v = vertexOf(cube, i);
for (let a = 0; a < 3; a++) {
lo[a] = Math.min(lo[a], v.position[a]);
hi[a] = Math.max(hi[a], v.position[a]);
}
normals.add(v.normal.slice(0, 3).map(n => n.toFixed(3)).join(","));
colors.add(v.color.map(c => c.toFixed(3)).join(","));
}
const near = (a, b) => Math.abs(a - b) < 1e-5;
if (!near(lo[0], 0.27) || !near(hi[0], 0.63)) throw new Error("cube x extent " + lo[0] + ".." + hi[0]);
if (!near(lo[1], -0.18) || !near(hi[1], 0.18)) throw new Error("cube y extent " + lo[1] + ".." + hi[1]);
if (!near(lo[2], -1.38) || !near(hi[2], -1.02)) throw new Error("cube z extent " + lo[2] + ".." + hi[2]);
// Six axis-aligned facet normals, and tintFaces gave each face its own colour.
if (normals.size !== 6) throw new Error("expected 6 facet normals, got " + normals.size + ": " + [...normals]);
if (colors.size !== 6) throw new Error("tintFaces should give 6 colours, got " + colors.size);
if (typeof __visionMetalFrame !== "function") throw new Error("Kotlin did not install onFrame");
__visionMetalFrame(2.0, 0.25, 7, 2.5);
const phase = __visionMetalWrites.find(entry => entry[0] === "phase");
if (!phase || Math.abs(phase[1] - 6.25) > 1e-9) {
throw new Error("unexpected phase write: " + JSON.stringify(__visionMetalWrites));
}
const tint = __visionMetalWrites.find(entry => entry[0] === "tint");
if (!tint || Math.abs(tint[4] - 0.35) > 1e-9) throw new Error("float4 uniform lost its w: " + JSON.stringify(tint));
if (typeof __visionMetalStop !== "function") throw new Error("Kotlin did not install onStop");
__visionMetalStop();
console.log("vision-metal-host-ok:" + cubeCount + " vertices, " + (cube.indices.length / 3) + " triangles");
""".trimIndent()
private val shapeProbe = """
if (__visionMetalMeshes.length !== 4) {
throw new Error("expected four geometry uploads, got " + __visionMetalMeshes.length);
}
const [plane, sphere, tube, empty] = __visionMetalMeshes;
// plane(cols=2, rows=3): a (cols+1) x (rows+1) vertex lattice, two triangles per cell.
if (checkMesh(plane, "plane") !== 12) throw new Error("plane vertices: " + plane.vertices.length / VERTEX_FLOATS);
if (plane.indices.length !== 36) throw new Error("plane indices: " + plane.indices.length);
for (let i = 0; i < 12; i++) {
const v = vertexOf(plane, i);
if (Math.abs(v.normal[2] - 1) > 1e-6) throw new Error("plane normals should face +Z");
if (Math.abs(v.position[0]) > 0.5 + 1e-6) throw new Error("plane width should be 1.0, got x=" + v.position[0]);
if (Math.abs(v.position[1]) > 1.0 + 1e-6) throw new Error("plane height should be 2.0, got y=" + v.position[1]);
}
// sphere(segments=6, rings=4): (segments+1) x (rings+1) vertices; every one at |r| from centre.
if (checkMesh(sphere, "sphere") !== 35) throw new Error("sphere vertices: " + sphere.vertices.length / VERTEX_FLOATS);
if (sphere.indices.length !== 144) throw new Error("sphere indices: " + sphere.indices.length);
for (let i = 0; i < 35; i++) {
const v = vertexOf(sphere, i);
const radius = Math.hypot(v.position[0], v.position[1], v.position[2]);
if (Math.abs(radius - 0.5) > 1e-5) throw new Error("sphere radius " + radius);
const nLen = Math.hypot(v.normal[0], v.normal[1], v.normal[2]);
if (Math.abs(nLen - 1) > 1e-5) throw new Error("sphere normal length " + nLen);
}
// tube(3 points, sides=4): points x (sides+1) vertices, (points-1) x sides quads.
if (checkMesh(tube, "tube") !== 15) throw new Error("tube vertices: " + tube.vertices.length / VERTEX_FLOATS);
if (tube.indices.length !== 48) throw new Error("tube indices: " + tube.indices.length);
for (let i = 0; i < 15; i++) {
const v = vertexOf(tube, i);
// The polyline runs up +Y, so every ring vertex sits 0.1 from the axis in the XZ plane.
const offset = Math.hypot(v.position[0], v.position[2]);
if (Math.abs(offset - 0.1) > 1e-5) throw new Error("tube radius " + offset + " at vertex " + i);
const nLen = Math.hypot(v.normal[0], v.normal[1], v.normal[2]);
if (Math.abs(nLen - 1) > 1e-5) throw new Error("tube normal length " + nLen);
}
// An empty builder is a legitimate way to draw nothing, and must still reach the bridge.
if (empty.vertices.length !== 0 || empty.indices.length !== 0) {
throw new Error("an empty geometry { } should upload empty arrays");
}
console.log("vision-metal-shapes-ok");
""".trimIndent()
}