Skip to content

KotlinJsRemoteArSurfaceSmokeTest.kt

src/jvmMain/kotlin/afield/examples/KotlinJsRemoteArSurfaceSmokeTest.kt · 294 lines

package afield.examples

import afield.lang.remotejs.RemoteArKotlinJs
import afield.lang.remotejs.KotlinJsHeadProfiles
import afield.lang.remotejs.KotlinToJsCompiler

FIELD_TEST id: remotejs.kotlinjs-remotear-surface runner: jvm-main entrypoint: afield.examples.KotlinJsRemoteArSurfaceSmokeTest tags: [smoke, remotejs, kotlinjs] timeoutSec: 120 expect: exitCode: 0 stdoutContains: - "KotlinJsRemoteArSurfaceSmokeTest ok"

object KotlinJsRemoteArSurfaceSmokeTest

Validates the expanded RemoteAR surface (math types + vec/clearAll + the dynamic-with-typed- completion stage) by compiling a sample that exercises it against the GENERATED compiler declarations — the real check that the binding model emits valid external Kotlin/JS for every new name and that they type-check together. Also asserts the dynamic (compiler) vs Stage (completion) split so stage.foo = x stays permissive while stage.add| gets completion.

object KotlinJsRemoteArSurfaceSmokeTest {

fun main(args: Array<String>)

body — 246 lines, from line 29
    @JvmStatic
    fun main(args: Array<String>) {
        val compiler = RemoteArKotlinJs.declarations
        val completion = RemoteArKotlinJs.completionDeclarations

        // dynamic-with-typed-completion split for `stage`.
        require(compiler.contains("external val stage: dynamic")) { "stage must be `dynamic` for the compiler" }
        require(completion.contains("val stage: Stage")) { "stage must be typed `Stage` for completion" }
        require(completion.contains("class Stage")) { "completion mirror must declare Stage" }
        require(!compiler.contains("class Stage")) { "Stage is completion-only; must not enter the JS target" }
        require(completion.contains("operator fun get(name: String)") && completion.contains("fun add(item: Any?)")) {
            "Stage must expose indexed get + add for completion"
        }

        // math types present in BOTH renderings.
        for (t in listOf("Vector4", "Quaternion", "Matrix4", "Color")) {
            require(compiler.contains("class $t")) { "compiler missing THREE.$t" }
            require(completion.contains("class $t")) { "completion missing THREE.$t" }
        }
        require(compiler.contains("external fun vec(")) { "vec must be an installed external global" }

        // transforms / scene / creation helpers are installed globals in the compiler façade.
        for (n in listOf("rotate", "scale", "translate", "rotate3", "findByName", "getCurrentCamera",
            "addTextureTo", "addBox", "addPlane", "addImagePlane", "addVideoPlane", "addPointLight")) {
            require(compiler.contains("external fun $n(")) { "compiler missing installed global `$n`" }
        }
        require(compiler.contains("class PointLight")) { "THREE.PointLight missing" }
        require(compiler.contains("val attach: dynamic")) { "Scene.attach must be `dynamic` for the compiler" }
        require(completion.contains("val attach: AttachProxy")) { "Scene.attach must be typed for completion" }

        // `SCENE[name] = obj` — named scene slots. The public `operator fun THREE.Scene.get/set` must be
        // in BOTH façades (so it type-checks AND completes); the raw `__attachSet`/`__attachGet` they
        // delegate to are compiler-only. SCENE stays typed `THREE.Scene`, so its autocomplete is intact.
        for (op in listOf("get", "set")) {
            require(compiler.contains("operator fun THREE.Scene.$op(")) { "compiler missing THREE.Scene.$op operator" }
            require(completion.contains("operator fun THREE.Scene.$op(")) { "completion missing THREE.Scene.$op operator" }
        }
        require(compiler.contains("external val SCENE: THREE.Scene")) { "SCENE must stay typed `THREE.Scene` (not dynamic) for the compiler" }
        for (raw in listOf("__attachSet", "__attachGet")) {
            require(compiler.contains("fun $raw(")) { "compiler Scene missing raw slot member `$raw`" }
            require(!completion.contains(raw)) { "raw slot member `$raw` must be compiler-only (kept out of completion)" }
        }

        // interaction + XR: pointer registries on FLine (dynamic/typed split) and the XR globals.
        require(compiler.contains("val mouseDown: dynamic")) { "FLine.mouseDown must be `dynamic` for the compiler" }
        require(completion.contains("val mouseDown: PointerCallbacks")) { "FLine.mouseDown must be typed for completion" }
        for (n in listOf("onXRHit", "onXRSelect", "xrHit")) {
            require(compiler.contains("external fun $n(")) { "compiler missing XR global `$n`" }
        }
        for (t in listOf("CameraPose", "PlacedMesh", "ImagePlane", "VideoPlane", "AttachProxy", "PointerCallbacks", "XRHit")) {
            require(completion.contains("class $t")) { "completion missing result type $t" }
            require(!compiler.contains("class $t")) { "$t must be completion-only" }
        }

        // ---- item 1: FLine transform operators. The raw runtime members (__PLUS__ etc.) are
        // compiler-only; the public `operator fun FLine.plus/minus/times/div` must be in BOTH façades. ----
        for (raw in listOf("__PLUS__", "__MINUS__", "__MULTIPLY__", "__DIVIDE__")) {
            require(compiler.contains("fun $raw(")) { "compiler FLine missing raw transform member `$raw`" }
            require(!completion.contains("$raw")) { "raw transform member `$raw` must be compiler-only (kept out of completion)" }
        }
        for (op in listOf("plus", "minus", "times", "div")) {
            require(compiler.contains("fun FLine.$op(")) { "compiler missing FLine.$op operator" }
            require(completion.contains("fun FLine.$op(")) { "completion missing FLine.$op operator" }
        }
        require(compiler.contains("fun byTransforming(")) { "compiler FLine missing byTransforming" }

        // ---- item 2: loaders are installed globals; their payload types are completion-only. ----
        for (n in listOf("loadGLTF", "loadPointCloudPLY", "loadFBX", "loadTexture", "loadJSONScene", "mapDirectory")) {
            require(compiler.contains("external fun $n(")) { "compiler missing installed loader global `$n`" }
        }
        for (t in listOf("GltfAsset", "FbxAsset", "PlyAsset")) {
            require(completion.contains("class $t")) { "completion missing loader payload type $t" }
            require(!compiler.contains("class $t")) { "$t must be completion-only" }
        }

        // ---- item 3: richer THREE materials / geometries / lights present in BOTH façades. ----
        for (t in listOf("MeshStandardMaterial", "MeshLambertMaterial", "MeshPhongMaterial", "PointsMaterial",
            "LineBasicMaterial", "PlaneGeometry", "CylinderGeometry", "ConeGeometry", "TorusGeometry",
            "TorusKnotGeometry", "CircleGeometry", "RingGeometry", "DirectionalLight", "AmbientLight",
            "SpotLight", "HemisphereLight", "Group", "Points", "Line", "Texture", "TextureLoader")) {
            require(compiler.contains("class $t")) { "compiler missing THREE.$t" }
            require(completion.contains("class $t")) { "completion missing THREE.$t" }
        }
        require(compiler.contains("var visible: Boolean")) { "Object3D.visible missing" }
        require(compiler.contains("fun lookAt(")) { "Object3D.lookAt missing" }
        require(compiler.contains("fun applyMatrix4(")) { "Object3D.applyMatrix4 missing" }

        // The whole new surface must type-check against the generated compiler declarations.
        val sample = """
            // {{ remoteAR }}
            val p = vec(1.0, 2.0, 3.0)
            val q = p + vec(4.0, 5.0, 6.0)
            val v4 = THREE.Vector4(1.0, 2.0, 3.0, 4.0) * 2.0
            val col = THREE.Color(1.0, 0.5, 0.0)
            col.setRGB(0.2, 0.4, 0.6)
            val quat = THREE.Quaternion().setFromAxisAngle(vec(0.0, 1.0, 0.0), 1.57)
            val mat = THREE.Matrix4().makeTranslation(q.x, q.y, q.z)
            val flat = vec(1.0, 2.0)
            val line = FLine().moveTo(0.0, 0.0).lineTo(1.0, 1.0)
            stage.add(line)
            stage["named"] = FLine()
            stage.dynamicNamed = FLine()
            val box = addBox("crate", 0.5)
            box.mesh.position.set(1.0, 0.0, 0.0)
            val plane = addPlane("floor")
            val pic = addImagePlane("u.png", "pic")
            val movie = addVideoPlane("v.mp4", "film")
            val light = addPointLight("key")
            light.intensity = 2.0
            light.position.set(0.0, 3.0, 0.0)
            val mR = rotate(0.5)
            val mS = scale(2.0)
            val mT = translate(1.0, 2.0, 3.0)
            val mA = rotate3(vec(0.0, 1.0, 0.0), 1.57)
            val found = findByName("crate")
            found?.position?.set(0.0, 0.0, 0.0)
            val cam = getCurrentCamera()
            addTextureTo("t.png", line)
            SCENE.attach["thing"] = box.mesh
            line.pointed = true
            line.mouseDown["tap"] = { event -> log("down " + event) }
            line.dblClick["dbl"] = { _ -> log("dbl") }
            val curve = FLine().moveTo(0.0, 0.0).cubicTo(0.1, 0.2, 0.3, 0.4, 1.0, 1.0).lineTo(vec(2.0, 2.0))
            val unsub = onXRHit { hit -> log("hit " + hit) }
            onXRSelect { _ -> log("select") }
            val h = xrHit()
            unsub()

            // item 1 — FLine transform operators (Matrix4 + vector + scalar) and byTransforming.
            val base = FLine().moveTo(0.0, 0.0).lineTo(1.0, 1.0)
            val rotated = base + rotate(0.5)
            val shifted = base + vec(1.0, 0.0, 0.0)
            val shifted2 = base + vec(1.0, 0.0)
            val shrunk = base * 0.5
            val back = rotated - translate(0.0, 1.0, 0.0)
            val halved = base / 2.0
            val warped = base.byTransforming { x, y, z -> arrayOf(x * 2.0, y, z) }
            stage.add(rotated); stage.add(shifted); stage.add(shifted2); stage.add(shrunk)
            stage.add(back); stage.add(halved); stage.add(warped)

            // item 3 — richer geometries / materials / lights / Object3D props.
            val std = THREE.MeshStandardMaterial()
            std.roughness = 0.3; std.metalness = 0.8; std.color.setRGB(0.9, 0.2, 0.1)
            val torus = THREE.Mesh(THREE.TorusGeometry(1.0, 0.4, 16, 64), std)
            torus.visible = true
            torus.castShadow = true
            torus.lookAt(0.0, 1.0, 0.0)
            torus.quaternion.setFromEuler(torus.rotation)
            torus.applyMatrix4(rotate(0.25))
            torus.rotateY(0.1)
            val cyl = THREE.Mesh(THREE.CylinderGeometry(0.5, 0.5, 2.0, 24), THREE.MeshLambertMaterial())
            val group = THREE.Group()
            group.add(torus, cyl)
            group.name = "assembly"
            SCENE.add(group)
            val sun = THREE.DirectionalLight(0xffffff, 1.5)
            sun.position.set(3.0, 5.0, 2.0)
            SCENE.add(sun)
            SCENE.add(THREE.AmbientLight(0x404040, 0.6))
            val tex = loadTexture("wood.png")
            tex.needsUpdate = true
            std.map = tex
            val found2 = SCENE.getObjectByName("assembly")

            // named scene slots — `SCENE[name] = obj` adds under a slot; re-assigning replaces it.
            val rig = THREE.Group()
            rig.add(torus)
            SCENE["rig"] = rig
            SCENE["rig"] = THREE.Group()
            val slotted = SCENE["rig"]
            slotted?.position?.set(0.0, 1.0, 0.0)

            // item 2 — asset & animation loaders + the mixer-backed play/apply on the payload.
            mapDirectory("/abs/assets", "/assets")
            loadGLTF("/assets/robot.glb", "robot") { g ->
                g.play()
                g.apply(0.5)
                g.stop()
                log("gltf meshes=" + g.meshes + " anims=" + g.animations)
                g.scene.position.set(0.0, 0.0, -2.0)
            }
            loadPointCloudPLY("/assets/scan.ply", "scan") { p ->
                p.mesh.name = "cloud"
                SCENE.add(p.mesh)
            }
            loadFBX("/assets/legacy.fbx", "legacy") { f -> f.play(true); log("fbx t=" + f.currentTime()) }
            loadJSONScene("/assets/room.json", "room")

            clearAll()
            log("surface ok " + col.getHex() + " " + v4.w + " " + flat.x + " " + quat.w + " " + mat +
                " " + light.intensity + " " + mR + mS + mT + mA + " " + plane + pic + movie + " " + cam +
                " " + curve + h + " " + shifted2 + " " + shrunk + " " + back + " " + halved + " " + warped +
                " " + cyl + " " + found2 + " " + tex.flipY + " " + slotted)
        """.trimIndent()

        val resolved = KotlinJsHeadProfiles.resolve(sample)
        require(resolved.profile === KotlinJsHeadProfiles.remoteAr) { "sample must select the remoteAR head" }
        val prepared = resolved.profile.prepareSource(resolved.source)
        val result = KotlinToJsCompiler.compile(prepared, moduleName = "remotear_surface", wrapImplicitMain = false)
        require(result.success && result.js != null) {
            "expanded RemoteAR surface did not compile:\n${result.rawLog.takeLast(2000)}"
        }
        println("expanded surface compiled: ${prepared.length} chars Kotlin -> ${result.js.length} chars JS")

        // `SCENE[name] = obj` / `SCENE[name]` must lower to the exact runtime hooks installed on the
        // scene in graphics.js — external members emit verbatim, so a rename on either side is caught.
        require(result.js.contains("__attachSet")) { "emitted JS must call runtime slot hook `__attachSet` (graphics.js)" }
        require(result.js.contains("__attachGet")) { "emitted JS must call runtime slot hook `__attachGet` (graphics.js)" }

        // The user's ask: `stage.add|` completes even though `stage` is `dynamic` for the compiler.
        // Skip-tolerant — needs the AA sidecar (feeds `completionDeclarations` as file-scope context).
        System.getProperty("afield.completion.sidecarUrl")
            ?: System.getenv("AFIELD_CODE_USE_SEMANTIC_SIDECAR_URL")?.let { System.setProperty("afield.completion.sidecarUrl", it) }
        val stageDot = complete("stage.")
        if (stageDot == null) {
            println("no AA sidecar reachable; façade completion checks skipped")
        } else {
            require(stageDot.any { it.startsWith("add") } && stageDot.any { it.startsWith("remove") }) {
                "typed `stage.` completion missing add/remove: ${stageDot.take(20)}"
            }
            val threeCo = complete("THREE.Co") ?: emptyList()
            require(threeCo.any { it.startsWith("Color") }) { "THREE.Co did not complete Color: ${threeCo.take(20)}" }
            println("completion: stage. → ${stageDot.filter { it.startsWith("add") || it.startsWith("remove") }}; THREE.Co → ${threeCo.filter { it.startsWith("Color") }}")
            // FLine members (incl. the pointer registries) complete on a fresh FLine receiver.
            val flineDot = complete("FLine().") ?: emptyList()
            require(flineDot.any { it.startsWith("mouseDown") } && flineDot.any { it.startsWith("pointed") }) {
                "FLine(). completion missing mouseDown/pointed: ${flineDot.take(25)}"
            }
            val camDot = complete("getCurrentCamera().") ?: emptyList()
            val boxDot = complete("addBox(\"x\").") ?: emptyList()
            val xrDot = complete("xrHit().") ?: emptyList()
            require(camDot.any { it.startsWith("position") } && camDot.any { it.startsWith("forward") }) {
                "getCurrentCamera(). completion missing typed CameraPose members: ${camDot.take(25)}"
            }
            require(boxDot.any { it.startsWith("mesh") } && boxDot.any { it.startsWith("material") }) {
                "addBox(). completion missing typed PlacedMesh members: ${boxDot.take(25)}"
            }
            require(xrDot.any { it.startsWith("position") } && xrDot.any { it.startsWith("matrix") }) {
                "xrHit(). completion missing typed XRHit members: ${xrDot.take(25)}"
            }
            println("completion: FLine(). → ${flineDot.filter { it.startsWith("mouseDown") || it.startsWith("pointed") || it.startsWith("dblClick") }}")
            println("completion: getCurrentCamera(). → ${camDot.filter { it.startsWith("position") || it.startsWith("forward") || it.startsWith("up") }}; addBox(). → ${boxDot.filter { it.startsWith("mesh") || it.startsWith("material") }}; xrHit(). → ${xrDot.filter { it.startsWith("position") || it.startsWith("matrix") }}")
        }

        println("KotlinJsRemoteArSurfaceSmokeTest ok")
    }

Complete against the generated completion mirror; null if no sidecar answered (→ skip).

    private fun complete(snippet: String): List<String>? {
        val decl = RemoteArKotlinJs.completionDeclarations
        val roots = afield.runtime.completion.AaCompletionClient.kotlinStdlibRoots()
        var unreachable = true
        repeat(8) {
            val o = afield.runtime.completion.AaCompletionClient.complete(
                snippet = snippet, cursor = snippet.length, preamble = "",
                declarations = decl, implicitReceivers = emptyList(), classpathRootsOverride = roots,
                jsTarget = true,
            )
            if (o.detail.let { !it.startsWith("no-sidecar") && !it.startsWith("error") && !it.startsWith("http") }) unreachable = false
            (o.result as? afield.runtime.kotlin.CompletionResult.Success)?.let { return it.metadata.map { m -> m.text } }
            Thread.sleep(400)
        }
        return if (unreachable) null else emptyList()
    }
}