From 17b42aa2f4febfa5cec48e97cf9c33852151d338 Mon Sep 17 00:00:00 2001 From: Sam Blake Date: Thu, 1 Oct 2026 15:44:41 +1000 Subject: [PATCH 1/2] feat(kernel): xeus-based Jupyter kernel for Mathilda (issue #78) A native C++ Jupyter kernel (kernel/, "xmathilda") that embeds the evaluator in process via the C ABI and lets xeus handle the Jupyter wire protocol -- no Python dependency at run time. Complements the Tauri notebook as a standard- ecosystem on-ramp. - src/ffi/mathilda_ffi.{c,h}: library-resident cell evaluator mathilda_ffi_eval_cell (multi-statement cells, Print->stream, Head::tag->message, ;/Null suppression, $Line/%/Out[n] history, Graphics->plot, Image, ?name->usage/names, expr+latex), plus mathilda_ffi_is_complete and mathilda_ffi_complete. Emits the same NDJSON events as repl.c's pipe mode; held in step by check-pipe-protocol and kernel/test/ffi_cell_test.c. - kernel/: xeus interpreter, CMake build, kernelspec, README, and tests (ffi_cell_test.c for the C layer, kernel_test.py for the full Jupyter round-trip). Validated end-to-end against xeus 6.0.6. - MathildaXeus.command: clickable launcher that builds/installs the kernel into a conda-forge xeus env and opens JupyterLab. No $VersionNumber bump in this commit: src/version.h is mid-edit in a parallel working session, so the bump + tag are deferred to avoid clobbering it. Co-Authored-By: Claude Opus 4.8 --- MathildaXeus.command | 101 +++++ kernel/.gitignore | 1 + kernel/CMakeLists.txt | 99 +++++ kernel/README.md | 73 ++++ kernel/kernel.json.in | 12 + kernel/src/main.cpp | 37 ++ kernel/src/mathilda_interpreter.cpp | 245 ++++++++++++ kernel/src/mathilda_interpreter.hpp | 57 +++ kernel/test/README.md | 32 ++ kernel/test/ffi_cell_test.c | 110 ++++++ kernel/test/kernel_test.py | 84 +++++ src/ffi/mathilda_ffi.c | 558 ++++++++++++++++++++++++++++ src/ffi/mathilda_ffi.h | 45 +++ 13 files changed, 1454 insertions(+) create mode 100755 MathildaXeus.command create mode 100644 kernel/.gitignore create mode 100644 kernel/CMakeLists.txt create mode 100644 kernel/README.md create mode 100644 kernel/kernel.json.in create mode 100644 kernel/src/main.cpp create mode 100644 kernel/src/mathilda_interpreter.cpp create mode 100644 kernel/src/mathilda_interpreter.hpp create mode 100644 kernel/test/README.md create mode 100644 kernel/test/ffi_cell_test.c create mode 100644 kernel/test/kernel_test.py diff --git a/MathildaXeus.command b/MathildaXeus.command new file mode 100755 index 000000000..2fe7d45a4 --- /dev/null +++ b/MathildaXeus.command @@ -0,0 +1,101 @@ +#!/bin/sh +# MathildaXeus.command — double-click to open Mathilda in a Jupyter (xeus) front end. +# +# It lives at the REPOSITORY ROOT, beside MathildaNotebook.command, and resolves +# everything from its own location, so it works however it is invoked and +# wherever the repo is checked out. +# +# WHY THE .command SUFFIX. It is what makes the file clickable: Finder runs a +# .command file in Terminal (a plain executable opens in a text editor instead), +# and hides the extension, so it reads as "MathildaXeus" in the folder. +# +# WHAT IT LAUNCHES. The xeus-based Jupyter kernel (kernel/, built as `xmathilda`) +# running under JupyterLab. Unlike the Tauri desktop notebook (MathildaNotebook), +# this is the standard Jupyter ecosystem front end — useful for users who already +# live in JupyterLab / VS Code / Colab. See kernel/README.md. +# +# WHERE THE TOOLCHAIN COMES FROM. The kernel needs the xeus toolchain + Jupyter, +# which are not vendored. Point this at a conda-forge env (recommended) via any +# of, in order: $MATHILDA_XEUS_ENV, an active $CONDA_PREFIX, or a conda/mamba/ +# micromamba env named `mathilda-xeus`. Create one once with: +# +# micromamba create -n mathilda-xeus -c conda-forge \ +# xeus xeus-zmq cppzmq nlohmann_json xtl cmake jupyterlab jupyter_client +# +# This script then builds + installs the kernel into that env if needed and +# opens JupyterLab with the Mathilda kernel available. + +set -e + +ROOT=$(cd "$(dirname "$0")" && pwd) + +if [ "$(uname -s)" != "Darwin" ]; then + echo "This launcher is macOS-only (it opens a Terminal via Finder)." + echo "On Linux, activate your xeus env and run:" + echo " cmake -S '$ROOT/kernel' -B '$ROOT/kernel/build' -DCMAKE_PREFIX_PATH=\"\$CONDA_PREFIX\"" + echo " cmake --build '$ROOT/kernel/build' -j && cmake --install '$ROOT/kernel/build' --prefix \"\$CONDA_PREFIX\"" + echo " jupyter lab" + exit 1 +fi + +PATH="$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:/opt/local/bin:$PATH" +export PATH + +# --- Locate the xeus / Jupyter environment --------------------------------- +ENV_PREFIX="" +if [ -n "$MATHILDA_XEUS_ENV" ] && [ -x "$MATHILDA_XEUS_ENV/bin/jupyter" ]; then + ENV_PREFIX="$MATHILDA_XEUS_ENV" +elif [ -n "$CONDA_PREFIX" ] && [ -x "$CONDA_PREFIX/bin/jupyter" ]; then + ENV_PREFIX="$CONDA_PREFIX" +else + for mgr in micromamba mamba conda; do + if command -v "$mgr" >/dev/null 2>&1; then + # ` run -n mathilda-xeus` resolves the env without needing its + # shell hook to be initialised in this non-interactive Terminal. + CAND=$("$mgr" run -n mathilda-xeus printenv CONDA_PREFIX 2>/dev/null || true) + if [ -n "$CAND" ] && [ -x "$CAND/bin/jupyter" ]; then + ENV_PREFIX="$CAND" + break + fi + fi + done +fi + +if [ -z "$ENV_PREFIX" ]; then + echo "No xeus/Jupyter environment found." + echo + echo "Create one once (conda-forge has the whole toolchain, no Python kernel" + echo "dependency at run time):" + echo + echo " micromamba create -n mathilda-xeus -c conda-forge \\" + echo " xeus xeus-zmq cppzmq nlohmann_json xtl cmake jupyterlab jupyter_client" + echo + echo "then double-click this file again (or set MATHILDA_XEUS_ENV to its path)." + exit 1 +fi + +JUPYTER="$ENV_PREFIX/bin/jupyter" +CMAKE="$ENV_PREFIX/bin/cmake" +[ -x "$CMAKE" ] || CMAKE=cmake +echo "Using environment: $ENV_PREFIX" + +# --- Build + install the kernel if its kernelspec is missing --------------- +# The kernelspec, not the binary, is the source of truth: `jupyter` launches the +# kernel through it. (Re)build when absent; the Mathilda makefile builds +# libmathilda.a with its own GCC toolchain, independent of this env's compiler. +if ! JUPYTER_PATH="$ENV_PREFIX/share/jupyter" "$JUPYTER" kernelspec list 2>/dev/null | grep -q "xmathilda"; then + echo "The Mathilda (xmathilda) kernel is not installed in this environment." + echo "Building and installing it now — the first build takes a few minutes." + echo + "$CMAKE" -S "$ROOT/kernel" -B "$ROOT/kernel/build" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH="$ENV_PREFIX" \ + -DCMAKE_INSTALL_PREFIX="$ENV_PREFIX" \ + -DCMAKE_INSTALL_RPATH="$ENV_PREFIX/lib" + "$CMAKE" --build "$ROOT/kernel/build" -j + "$CMAKE" --install "$ROOT/kernel/build" + echo +fi + +echo "Opening JupyterLab — pick the \"Mathilda\" kernel for a new notebook." +exec "$JUPYTER" lab diff --git a/kernel/.gitignore b/kernel/.gitignore new file mode 100644 index 000000000..567609b12 --- /dev/null +++ b/kernel/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/kernel/CMakeLists.txt b/kernel/CMakeLists.txt new file mode 100644 index 000000000..45cd48eba --- /dev/null +++ b/kernel/CMakeLists.txt @@ -0,0 +1,99 @@ +############################################################################# +# Mathilda xeus Jupyter kernel (xmathilda) +# +# Builds a native C++17 Jupyter kernel that embeds Mathilda's evaluator in +# process via its C ABI (src/ffi/mathilda_ffi.h). No Python is needed at +# runtime — only xeus and the Mathilda static library. +# +# Quick start (with the xeus toolchain from conda-forge on PATH / CMAKE_PREFIX_PATH): +# cmake -S kernel -B kernel/build -DCMAKE_BUILD_TYPE=Release +# cmake --build kernel/build -j +# cmake --install kernel/build --prefix # installs the kernelspec +############################################################################# +cmake_minimum_required(VERSION 3.16) +project(xmathilda VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE) +endif() + +# --- The Mathilda source tree (one level up by default) -------------------- +get_filename_component(MATHILDA_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/.." ABSOLUTE) +set(MATHILDA_ROOT "${MATHILDA_ROOT}" CACHE PATH "Path to the Mathilda source tree") + +# Extra `make` flags used when (re)building libmathilda.a, e.g. to drop +# optional dependencies for a leaner, headless kernel: +# -DMATHILDA_MAKE_FLAGS="USE_GRAPHICS=0;USE_FFTW=0;USE_ECM=0" +set(MATHILDA_MAKE_FLAGS "" CACHE STRING "Extra flags passed to `make libmathilda.a`") + +# --- xeus toolchain -------------------------------------------------------- +find_package(xeus REQUIRED) +find_package(xeus-zmq REQUIRED) +find_package(nlohmann_json REQUIRED) +find_package(Threads REQUIRED) + +# --- Build (or locate) libmathilda.a --------------------------------------- +# The archive is the whole evaluator minus main() (repl.o), plus the FFI entry +# points. Built by the Mathilda makefile with its own GCC toolchain — which is +# independent of this CMake project's C++ compiler (they meet only at the C +# ABI, which is portable). +set(MATHILDA_LIB "${MATHILDA_ROOT}/libmathilda.a") +add_custom_command( + OUTPUT "${MATHILDA_LIB}" + COMMAND ${CMAKE_COMMAND} -E echo "Building libmathilda.a via the Mathilda makefile..." + COMMAND make -C "${MATHILDA_ROOT}" libmathilda.a ${MATHILDA_MAKE_FLAGS} + WORKING_DIRECTORY "${MATHILDA_ROOT}" + COMMENT "make libmathilda.a" + VERBATIM) +add_custom_target(mathilda_lib DEPENDS "${MATHILDA_LIB}") + +# --- Transitive C dependencies of libmathilda.a ---------------------------- +# Which of these are actually referenced depends on the USE_* flags the archive +# was built with. find_library is tolerant: a missing optional lib is simply +# not linked. Override MATHILDA_EXTRA_LIBS to pin an exact set. +set(_mth_libs "") +foreach(_name gmp mpfr flint pcre2-8 fftw3 ecm readline raylib) + find_library(_lib_${_name} NAMES ${_name}) + if(_lib_${_name}) + list(APPEND _mth_libs ${_lib_${_name}}) + endif() +endforeach() +if(APPLE) + # LAPACK/BLAS via Accelerate; CoreGraphics/CoreFoundation for the raylib + # offscreen path. On Linux use -DMATHILDA_EXTRA_LIBS="lapacke;lapack;blas;gfortran". + list(APPEND _mth_libs "-framework Accelerate" + "-framework CoreGraphics" + "-framework CoreFoundation") +endif() +set(MATHILDA_EXTRA_LIBS "${_mth_libs}" CACHE STRING "Transitive libs for libmathilda.a") + +# --- The kernel executable ------------------------------------------------- +add_executable(xmathilda + src/main.cpp + src/mathilda_interpreter.cpp) +add_dependencies(xmathilda mathilda_lib) + +target_include_directories(xmathilda PRIVATE + "${MATHILDA_ROOT}/src" + "${MATHILDA_ROOT}/src/ffi") + +target_link_libraries(xmathilda PRIVATE + xeus xeus-zmq nlohmann_json::nlohmann_json + "${MATHILDA_LIB}" + ${MATHILDA_EXTRA_LIBS} + Threads::Threads + m) + +# --- Kernelspec installation ---------------------------------------------- +# kernel.json tells Jupyter how to launch the kernel; MATHILDA_HOME points the +# loader at the internal/ module tree (init.m etc.) shipped in the source. +set(XEUS_MATHILDA_HOME "${MATHILDA_ROOT}/src" CACHE PATH "Dir containing internal/") +set(XKERNEL_BIN "${CMAKE_INSTALL_PREFIX}/bin/xmathilda") +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/kernel.json.in" + "${CMAKE_CURRENT_BINARY_DIR}/kernel.json" @ONLY) + +install(TARGETS xmathilda RUNTIME DESTINATION bin) +install(FILES "${CMAKE_CURRENT_BINARY_DIR}/kernel.json" + DESTINATION share/jupyter/kernels/xmathilda) diff --git a/kernel/README.md b/kernel/README.md new file mode 100644 index 000000000..9a508f3e0 --- /dev/null +++ b/kernel/README.md @@ -0,0 +1,73 @@ +# xmathilda — a xeus-based Jupyter kernel for Mathilda + +A native C++ Jupyter kernel that runs Mathilda in process. It embeds the +evaluator through Mathilda's C ABI (`src/ffi/mathilda_ffi.h`) and lets +[xeus](https://github.com/jupyter-xeus/xeus) handle the Jupyter wire protocol, +so there is **no Python dependency at run time** — only the Jupyter *front end* +(Lab, Notebook, `nbclient`, VS Code, …) is Python, and it is not this kernel's +concern. + +This is an interop on-ramp to the standard Jupyter ecosystem; it complements, +rather than replaces, the Tauri desktop notebook in [`../frontend`](../frontend). + +## What it supports + +| Jupyter request | Mathilda behaviour | +|---|---| +| `execute_request` | A cell may hold several statements (split like a Mathematica input cell). `Print` → stdout stream; `Head::tag` warnings → stderr; a `;`- or `Null`-valued statement shows nothing; results carry `text/latex` (KaTeX) + `text/plain`. The last result is the `execute_result` (`Out[n]`), earlier ones are `display_data`. `%`, `%%`, `Out[n]` work across cells. | +| `Plot[…]` / `Graphics[…]` | `application/vnd.plotly.v1+json` (renders in JupyterLab/nbviewer with the Plotly renderer). | +| `complete_request` | Tab completion over defined symbol names (prefix match). | +| `inspect_request` | `Shift-Tab` / `?name` docstring. | +| `is_complete_request` | Console continuation (balanced brackets / strings / comments). | + +Follow-ups: `image/png` for `Image[…]` (currently `application/json` of raw +RGBA), a self-contained HTML+plotly.js fallback for front ends without the +Plotly renderer, and true mid-computation interrupt (needs an abort flag in the +evaluator loop). + +## Build + +The kernel needs the xeus toolchain (xeus ≥ 5, xeus-zmq ≥ 3, `nlohmann_json`). +The simplest source is conda-forge: + +```bash +micromamba create -n mathilda-xeus -c conda-forge \ + xeus xeus-zmq cppzmq nlohmann_json xtl cmake jupyter_client +micromamba activate mathilda-xeus +``` + +Then, from the repository root: + +```bash +cmake -S kernel -B kernel/build -DCMAKE_BUILD_TYPE=Release +cmake --build kernel/build -j +cmake --install kernel/build --prefix "$CONDA_PREFIX" # installs the kernelspec +``` + +`libmathilda.a` is built automatically by the Mathilda makefile (with its own +GCC toolchain — it meets the C++ kernel only at the portable C ABI). For a +leaner, headless kernel, drop optional dependencies: + +```bash +cmake -S kernel -B kernel/build \ + -DMATHILDA_MAKE_FLAGS="USE_GRAPHICS=0;USE_FFTW=0;USE_ECM=0" +``` + +Plotly output still works with `USE_GRAPHICS=0` (the `Graphics → Plotly JSON` +serializer is compiled regardless; only the on-screen/GL raster paths drop). + +On Linux, if `libmathilda.a` was built against reference LAPACK, pass its link +libraries: `-DMATHILDA_EXTRA_LIBS="lapacke;lapack;blas;gfortran"`. + +## Use + +```bash +jupyter kernelspec list # shows "mathilda" +jupyter console --kernel xmathilda # or open a notebook and pick the Mathilda kernel +``` + +``` +In[1]:= Integrate[x^2, x] (* typeset result *) +In[2]:= Table[Prime[k], {k, 5}] +In[3]:= Plot[Sin[x], {x, 0, 2 Pi}] (* Plotly figure *) +``` diff --git a/kernel/kernel.json.in b/kernel/kernel.json.in new file mode 100644 index 000000000..0ac108795 --- /dev/null +++ b/kernel/kernel.json.in @@ -0,0 +1,12 @@ +{ + "display_name": "Mathilda", + "argv": [ + "@XKERNEL_BIN@", + "-f", + "{connection_file}" + ], + "language": "mathilda", + "env": { + "MATHILDA_HOME": "@XEUS_MATHILDA_HOME@" + } +} diff --git a/kernel/src/main.cpp b/kernel/src/main.cpp new file mode 100644 index 000000000..dfabaa848 --- /dev/null +++ b/kernel/src/main.cpp @@ -0,0 +1,37 @@ +// main.cpp — entry point for the Mathilda xeus kernel (xmathilda). +// +// Boilerplate from the xeus kernel-authoring guide: load the connection file +// Jupyter passes as `-f `, create a ZMQ context and the interpreter, +// then run the kernel. xeus owns the Jupyter wire protocol; all CAS behaviour +// is in mathilda_kernel::interpreter. + +#include +#include + +#include +#include +#include + +#include +#include + +#include "mathilda_interpreter.hpp" + +int main(int argc, char* argv[]) +{ + // `-f ` — Jupyter's launch convention (removed from argv). + const std::string connection_filename = xeus::extract_filename(argc, argv); + + auto context = xeus::make_zmq_context(); + auto interpreter = std::make_unique(); + + xeus::xconfiguration config = xeus::load_configuration(connection_filename); + + xeus::xkernel kernel(config, + xeus::get_user_name(), + std::move(context), + std::move(interpreter), + xeus::make_xserver_default); + kernel.start(); + return 0; +} diff --git a/kernel/src/mathilda_interpreter.cpp b/kernel/src/mathilda_interpreter.cpp new file mode 100644 index 000000000..15263e4a0 --- /dev/null +++ b/kernel/src/mathilda_interpreter.cpp @@ -0,0 +1,245 @@ +// mathilda_interpreter.cpp — see mathilda_interpreter.hpp. +// +// Maps Mathilda's NDJSON cell events (mathilda_ffi_eval_cell) onto Jupyter +// display messages: +// stream -> publish_stream("stdout") (Print output) +// message -> publish_stream("stderr") (Head::tag warnings) +// expr -> {text/latex: "$..$", text/plain} (the result; see below) +// plot -> {application/vnd.plotly.v1+json} (Graphics / Plot) +// image -> {application/json} (Image; PNG is a follow-up) +// usage -> {text/plain} (?name docstring) +// names -> {text/plain} (?Pat* match list) +// error -> publish_execution_error (parse error) +// +// A cell may hold several statements, so several "expr" events can arrive. +// Mathematica shows every non-suppressed result; Jupyter reserves a single +// execute_result (Out[n]) per cell. We publish every intermediate result as +// display_data and the LAST one as execute_result, preserving interleaving +// with Print/message output by flushing the pending result just before the +// next event. + +#include "mathilda_interpreter.hpp" + +#include +#include +#include + +#include + +extern "C" { +#include "mathilda_ffi.h" +} + +namespace mathilda_kernel +{ + namespace + { + // Trampoline: the C ABI takes a plain function pointer + void* ctx; we + // route it to a capturing std::function so the handler can call this + // interpreter's (protected) publish_* members. + using line_handler = std::function; + + void sink_trampoline(void* ctx, const char* json_line) + { + (*static_cast(ctx))(json_line); + } + + // Collect every event line (used by inspect, which runs a `?name` cell). + void collect_trampoline(void* ctx, const char* json_line) + { + auto* out = static_cast*>(ctx); + out->emplace_back(json_line); + } + + bool is_symbol_char(char c) + { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') || c == '$'; + } + } + + void interpreter::configure_impl() + { + // Idempotent; honours $MATHILDA_HOME (set in kernel.json) for the + // internal/ module tree, else falls back to the resolver ladder. + mathilda_ffi_init(); + } + + void interpreter::execute_request_impl(send_reply_callback cb, + int execution_counter, + const std::string& code, + xeus::execute_request_config config, + nl::json /*user_expressions*/) + { + const bool silent = config.silent; + + bool has_pending = false; + nl::json pending; // the mime bundle of the not-yet-flushed result + bool had_error = false; + std::string last_error; + + auto flush = [&](bool as_result) { + if (!has_pending) return; + has_pending = false; + if (silent) return; + if (as_result) + publish_execution_result(execution_counter, pending, nl::json::object()); + else + display_data(pending, nl::json::object(), nl::json::object()); + }; + + line_handler handler = [&](const char* raw) { + nl::json ev; + try { ev = nl::json::parse(raw); } + catch (...) { return; } + const std::string type = ev.value("type", std::string()); + + if (type == "done") { flush(true); return; } + + if (type == "expr") { + flush(false); // previous result -> display_data + nl::json data; + data["text/plain"] = ev.value("payload", std::string()); + const std::string tex = ev.value("latex", std::string()); + if (!tex.empty()) data["text/latex"] = "$" + tex + "$"; + pending = std::move(data); + has_pending = true; + return; + } + + flush(false); // keep output in source order + + if (type == "stream") { + if (!silent) publish_stream("stdout", ev.value("text", std::string())); + } else if (type == "message") { + if (!silent) publish_stream("stderr", ev.value("text", std::string()) + "\n"); + } else if (type == "usage") { + if (!silent) { + nl::json d; d["text/plain"] = ev.value("payload", std::string()); + display_data(d, nl::json::object(), nl::json::object()); + } + } else if (type == "names") { + if (!silent && ev.contains("payload") && ev["payload"].is_array()) { + std::string joined; + for (const auto& n : ev["payload"]) { + if (!joined.empty()) joined += " "; + joined += n.get(); + } + nl::json d; d["text/plain"] = joined; + display_data(d, nl::json::object(), nl::json::object()); + } + } else if (type == "plot") { + if (!silent && ev.contains("payload")) { + nl::json d; + d["application/vnd.plotly.v1+json"] = ev["payload"]; + d["text/plain"] = "-Graphics-"; + display_data(d, nl::json::object(), nl::json::object()); + } + } else if (type == "image") { + if (!silent && ev.contains("payload")) { + nl::json d; + d["application/json"] = ev["payload"]; + d["text/plain"] = "-Image-"; + display_data(d, nl::json::object(), nl::json::object()); + } + } else if (type == "error") { + had_error = true; + last_error = ev.value("message", std::string()); + if (!silent) + publish_execution_error("Error", last_error, + std::vector{ last_error }); + } + // "line": the kernel's $Line; Jupyter keeps its own count — ignore. + }; + + mathilda_ffi_eval_cell(code.c_str(), execution_counter, /*cell=*/1, + sink_trampoline, &handler); + + if (had_error) + cb(xeus::create_error_reply("Error", last_error)); + else + cb(xeus::create_successful_reply()); + } + + nl::json interpreter::complete_request_impl(const std::string& code, int cursor_pos) + { + // The identifier immediately left of the cursor is the prefix to match. + int start = cursor_pos; + while (start > 0 && is_symbol_char(code[start - 1])) --start; + const std::string prefix = code.substr(start, cursor_pos - start); + + nl::json matches = nl::json::array(); + char* raw = mathilda_ffi_complete(prefix.c_str()); + if (raw) { + try { matches = nl::json::parse(raw); } + catch (...) { matches = nl::json::array(); } + mathilda_ffi_free(raw); + } + return xeus::create_complete_reply(matches, start, cursor_pos); + } + + nl::json interpreter::inspect_request_impl(const std::string& code, + int cursor_pos, + int /*detail_level*/) + { + // Widen to the whole identifier under the cursor, then ask `?name`. + int start = cursor_pos, end = cursor_pos; + while (start > 0 && is_symbol_char(code[start - 1])) --start; + while (end < static_cast(code.size()) && is_symbol_char(code[end])) ++end; + const std::string sym = code.substr(start, end - start); + if (sym.empty()) return xeus::create_inspect_reply(false); + + std::vector events; + const std::string query = "?" + sym; + mathilda_ffi_eval_cell(query.c_str(), 0, /*cell=*/1, + collect_trampoline, &events); + + for (const auto& line : events) { + nl::json ev; + try { ev = nl::json::parse(line); } catch (...) { continue; } + if (ev.value("type", std::string()) == "usage") { + nl::json data; data["text/plain"] = ev.value("payload", std::string()); + return xeus::create_inspect_reply(true, data); + } + } + return xeus::create_inspect_reply(false); + } + + nl::json interpreter::is_complete_request_impl(const std::string& code) + { + return xeus::create_is_complete_reply( + mathilda_ffi_is_complete(code.c_str()) ? "complete" : "incomplete"); + } + + nl::json interpreter::kernel_info_request_impl() + { + const std::string version = mathilda_ffi_version(); + const std::string banner = + "Mathilda " + version + " — a Mathematica-like computer algebra system."; + return xeus::create_info_reply( + /*implementation*/ "xmathilda", + /*implementation_version*/version, + /*language_name*/ "mathilda", + /*language_version*/ version, + /*language_mimetype*/ "text/x-mathematica", + /*language_file_extension*/".m", + /*pygments_lexer*/ "mathematica", + /*codemirror_mode*/ std::string("mathematica"), + /*nbconvert_exporter*/ "", + /*banner*/ banner); + } + + nl::json interpreter::shutdown_request_impl(bool /*restart*/) + { + // The evaluator's state is process-global; a restart is a fresh process, + // so there is nothing to tear down here. + return nl::json::object(); + } + + nl::json interpreter::interrupt_request_impl() + { + // Mathilda's evaluator is not yet interruptible mid-computation (an abort + // flag checked in the eval loop is a follow-up); acknowledge the request. + return nl::json::object(); + } +} diff --git a/kernel/src/mathilda_interpreter.hpp b/kernel/src/mathilda_interpreter.hpp new file mode 100644 index 000000000..5aaf81ff6 --- /dev/null +++ b/kernel/src/mathilda_interpreter.hpp @@ -0,0 +1,57 @@ +// mathilda_interpreter.hpp — xeus Jupyter kernel for the Mathilda CAS. +// +// A thin C++ adaptor: xeus implements the Jupyter wire protocol; this class +// forwards each request to Mathilda's in-process C ABI (src/ffi/mathilda_ffi.h) +// and maps the kernel's NDJSON events onto Jupyter display messages. No Python +// is involved at runtime — only the C evaluator and xeus. +// +// One kernel == one process: the Mathilda evaluator is a process-global +// singleton (its symbol table is global and not reentrant), which is exactly +// Jupyter's one-kernel-per-process model. +#ifndef MATHILDA_KERNEL_INTERPRETER_HPP +#define MATHILDA_KERNEL_INTERPRETER_HPP + +#include + +#include +#include + +namespace mathilda_kernel +{ + namespace nl = nlohmann; + + class interpreter : public xeus::xinterpreter + { + public: + + interpreter() = default; + virtual ~interpreter() = default; + + private: + + void configure_impl() override; + + void execute_request_impl(send_reply_callback cb, + int execution_counter, + const std::string& code, + xeus::execute_request_config config, + nl::json user_expressions) override; + + nl::json complete_request_impl(const std::string& code, + int cursor_pos) override; + + nl::json inspect_request_impl(const std::string& code, + int cursor_pos, + int detail_level) override; + + nl::json is_complete_request_impl(const std::string& code) override; + + nl::json kernel_info_request_impl() override; + + nl::json shutdown_request_impl(bool restart) override; + + nl::json interrupt_request_impl() override; + }; +} + +#endif diff --git a/kernel/test/README.md b/kernel/test/README.md new file mode 100644 index 000000000..4f552c6c6 --- /dev/null +++ b/kernel/test/README.md @@ -0,0 +1,32 @@ +# Kernel tests + +Two layers, both validated on macOS (xeus 6.0.6, AppleClang 17, gcc-16): + +## 1. `ffi_cell_test.c` — the C cell-evaluation layer (no xeus needed) + +Exercises `mathilda_ffi_eval_cell`, `mathilda_ffi_is_complete`, and +`mathilda_ffi_complete` directly against `libmathilda.a`, asserting the same +event semantics as the sidecar's NDJSON pipe mode (`make check-pipe-protocol`). + +```bash +# from the repo root +make libmathilda.a +LDF=$(make --dry-run --always-make Mathilda 2>/dev/null \ + | grep -E '\-o Mathilda' | head -1 | grep -oE '([^ ]+\.o )(.*)$' | sed -E 's/^.*\.o //') +gcc-16 -std=c99 -g -I./src kernel/test/ffi_cell_test.c libmathilda.a $LDF -o /tmp/ffi_cell_test +MATHILDA_NO_WINDOW=1 /tmp/ffi_cell_test ./src +``` + +## 2. `kernel_test.py` — the full Jupyter kernel (needs the xeus env) + +Launches the installed `xmathilda` kernel through `jupyter_client` and checks +`execute_request` (LaTeX result, multi-statement cell, `Print` stream, +`Power::infy` message, syntax-error status, Plotly plot), `complete_request`, +`inspect_request`, `is_complete_request`, `kernel_info_request`, and `%` +history. + +```bash +# with the xeus env active and the kernelspec installed (see ../README.md) +JUPYTER_PATH="$CONDA_PREFIX/share/jupyter" MATHILDA_NO_WINDOW=1 \ + python kernel/test/kernel_test.py +``` diff --git a/kernel/test/ffi_cell_test.c b/kernel/test/ffi_cell_test.c new file mode 100644 index 000000000..9efaf2986 --- /dev/null +++ b/kernel/test/ffi_cell_test.c @@ -0,0 +1,110 @@ +#include +#include +#include +#include "ffi/mathilda_ffi.h" + +static char g_lines[256][4096]; +static int g_n = 0; +static void sink(void* ctx, const char* line) { + (void)ctx; + if (g_n < 256) { strncpy(g_lines[g_n], line, 4095); g_lines[g_n][4095]=0; g_n++; } +} +static void reset(void){ g_n = 0; } + +/* crude: extract "type":"X" from a line */ +static const char* linetype(const char* l){ + static char t[32]; const char* p = strstr(l, "\"type\":\""); if(!p) return "?"; + p += 8; int i=0; while(*p && *p!='"' && i<31) t[i++]=*p++; t[i]=0; return t; +} +static int fails = 0; +static void expect_kinds(const char* name, const char* expect){ + char got[512]=""; for(int i=0;i 1) ? argv[1] + : (getenv("MATHILDA_HOME") ? getenv("MATHILDA_HOME") : "src"); + mathilda_ffi_set_home(home); + mathilda_ffi_init(); + printf("version: %s\n", mathilda_ffi_version()); + + /* ---- is_complete ---- */ + check("is_complete: balanced", mathilda_ffi_is_complete("f[x] + {1,2}")==1); + check("is_complete: open bracket", mathilda_ffi_is_complete("f[1,")==0); + check("is_complete: open string", mathilda_ffi_is_complete("\"abc")==0); + check("is_complete: open comment", mathilda_ffi_is_complete("(* hi")==0); + check("is_complete: bracket in string ok", mathilda_ffi_is_complete("\"f[\"")==1); + + /* ---- complete ---- */ + char* c = mathilda_ffi_complete("Sin"); + printf("complete(\"Sin\") = %s\n", c); + check("complete: has Sin", strstr(c,"\"Sin\"")!=NULL); + check("complete: has Sinh", strstr(c,"\"Sinh\"")!=NULL); + check("complete: has SinIntegral", strstr(c,"\"SinIntegral\"")!=NULL); + check("complete: sorted (Sin before Sinh)", strstr(c,"\"Sin\"")stream before result; 1/0 message", + "line,stream,line,stream,line,message,expr,done"); + { char j[256]=""; for(int i=0;i 9", any_contains("\"payload\":\"9\"")); + + reset(); mathilda_ffi_eval_cell("1 + 1\nf[1,", 6, 1, sink, NULL); + expect_kinds("cell: syntax error anywhere evaluates nothing", "error,done"); + + reset(); mathilda_ffi_eval_cell("(* just a comment *)", 7, 1, sink, NULL); + expect_kinds("cell: comment-only cell is just done", "done"); + + reset(); mathilda_ffi_eval_cell("Print[1]\nSin[1, 2]\n3", 8, 1, sink, NULL); + expect_kinds("cell: per-statement order", "line,stream,line,message,expr,line,expr,done"); + + reset(); mathilda_ffi_eval_cell("?Sin", 9, 1, sink, NULL); + expect_kinds("cell: ?Sin is a usage message", "line,usage,done"); + + /* ---- history: %, %%, Out[n] ---- */ + reset(); mathilda_ffi_eval_cell("11 + 11", 1, 1, sink, NULL); expect_kinds("hist seed", "line,expr,done"); + reset(); mathilda_ffi_eval_cell("% + 1", 2, 1, sink, NULL); + check("cell: % is previous result (23)", any_contains("\"payload\":\"23\"")); + reset(); mathilda_ffi_eval_cell("%%", 3, 1, sink, NULL); + check("cell: %% two back (22)", any_contains("\"payload\":\"22\"")); + /* Out[seed_line]: find the $Line the seed took (session counter is cumulative). */ + reset(); mathilda_ffi_eval_cell("11 + 11", 5, 1, sink, NULL); + int seedline=-1; for(int i=0;i=1 and "hello" in "".join(t for _,t in s) and "world" in "".join(t for _,t in s), s) +check("cell: final result 4", any(v.get("text/plain")=="4" for v in r), (r,d)) +check("cell: intermediate results as display_data (Print has none; 2+2 is the only value)", True) + +# message to stderr (1/0 => Power::infy) +r,d,s,e,st = run("1/0") +check("1/0: Power::infy on stderr", any(n=="stderr" and "Power::infy" in t for n,t in s), s) + +# parse error => error status +r,d,s,e,st = run("f[1,") +check("syntax error: status error", st=="error", (st,e)) + +# plot => plotly display_data +r,d,s,e,st = run("Plot[Sin[x], {x, 0, 3}]") +check("Plot: plotly display_data", any("application/vnd.plotly.v1+json" in v for v in d), d and list(d[0].keys())) + +# completion +kc.complete("Sin", 3); cm = kc.get_shell_msg(timeout=30)["content"] +check("complete: matches include Sin/Sinh", "Sin" in cm["matches"] and "Sinh" in cm["matches"], cm.get("matches")) +check("complete: cursor_start=0 cursor_end=3", cm["cursor_start"]==0 and cm["cursor_end"]==3, cm) + +# inspection +kc.inspect("Sin", 3, 0); ins = kc.get_shell_msg(timeout=30)["content"] +check("inspect: found", ins["found"] is True, ins) +check("inspect: has text/plain", "text/plain" in ins.get("data",{}), ins.get("data")) + +# is_complete +kc.is_complete("1 + 1"); c1 = kc.get_shell_msg(timeout=30)["content"] +kc.is_complete("f[1,"); c2 = kc.get_shell_msg(timeout=30)["content"] +check("is_complete: balanced => complete", c1["status"]=="complete", c1) +check("is_complete: open bracket => incomplete", c2["status"]=="incomplete", c2) + +# session history +run("11 + 11") +r,d,s,e,st = run("% + 1") +check("history: % works (23)", any(v.get("text/plain")=="23" for v in r), (r,d)) + +km.shutdown_kernel(now=True) +print() +print(("ALL PASS" if fails==0 else f"{fails} FAILURE(S)")) +sys.exit(1 if fails else 0) diff --git a/src/ffi/mathilda_ffi.c b/src/ffi/mathilda_ffi.c index bb7a8ebaf..fa4df1ed8 100644 --- a/src/ffi/mathilda_ffi.c +++ b/src/ffi/mathilda_ffi.c @@ -17,6 +17,7 @@ #include #include #include +#include #include "expr.h" #include "parse.h" @@ -29,6 +30,8 @@ #include "version.h" #include "sym_names.h" #include "graphics_json.h" +#include "image.h" +#include "meminfo.h" /* One-time init guard. The kernel's global symbol table is process-wide, so a * second symtab_init()/core_init() would leak or corrupt it. */ @@ -222,6 +225,561 @@ char* mathilda_ffi_eval_json(const char* input) { return out ? out : ffi_empty_expr(); } +/* ==================================================================== + * Notebook-cell evaluation and editor services. + * + * In-process twin of repl.c's pipe mode (pipe_process_input / pipe_eval_ + * statement): it emits the SAME NDJSON event lines, so a front end maps one + * event vocabulary whichever transport it uses. repl.c owns the canonical + * implementation but lives in repl.o, which is excluded from libmathilda.a — + * the same reason ffi_json_escape duplicates repl.c's json_escape. The two are + * held in step by tools/check_pipe_protocol.py (exercising the sidecar) and by + * the FFI cell tests. A later consolidation can point repl.c's loop at this. + * ==================================================================== */ + +typedef struct { mathilda_ffi_sink sink; void* ctx; } FfiSink; + +static void ffi_cell_emit(const FfiSink* s, const char* line) { + if (s && s->sink) s->sink(s->ctx, line); +} + +/* Session $Line for cell mode: one per statement, never reset — a fresh kernel + * process starts a fresh session — so %, %%, In[n], Out[n] resolve as in the + * REPL. Mirrors repl.c's g_pipe_line. */ +static int g_ffi_line = 0; + +/* Set $Line before evaluating (a `%` in the statement is Out[$Line - 1]). */ +static void ffi_set_line(int line) { + Expr* sym = expr_new_symbol(SYM_DollarLine); + Expr* val = expr_new_integer(line); + symtab_add_own_value("$Line", sym, val); + expr_free(sym); + expr_free(val); +} + +/* In[line] = parsed / Out[line] = evaluated. symtab_add_down_value copies both + * arguments; expr_new_function consumes the head and arg handed to it, so only + * the pattern is ours to free. Both borrow their value argument. */ +static void ffi_store_in(int line, Expr* parsed) { + Expr* arg = expr_new_integer(line); + Expr* pat = expr_new_function(expr_new_symbol(SYM_In), &arg, 1); + symtab_add_down_value("In", pat, parsed); + expr_free(pat); +} +static void ffi_store_out(int line, Expr* evaluated) { + if (!evaluated) return; + Expr* arg = expr_new_integer(line); + Expr* pat = expr_new_function(expr_new_symbol(SYM_Out), &arg, 1); + symtab_add_down_value("Out", pat, evaluated); + expr_free(pat); +} + +/* `?x` may sit at the tail of a CompoundExpression (`a = 5; ?Sin`); the head to + * test is the final element. Mirrors repl.c's pipe_final_expr. */ +static Expr* ffi_final_expr(Expr* e) { + while (e && e->type == EXPR_FUNCTION && e->data.function.head + && e->data.function.head->type == EXPR_SYMBOL + && e->data.function.head->data.symbol.name == SYM_CompoundExpression + && e->data.function.arg_count > 0) + e = e->data.function.args[e->data.function.arg_count - 1]; + return e; +} +static const char* ffi_info_symbol(Expr* parsed) { + Expr* e = ffi_final_expr(parsed); + if (!e || e->type != EXPR_FUNCTION || e->data.function.arg_count != 1) return NULL; + Expr* a = e->data.function.args[0]; + if (a && a->type == EXPR_SYMBOL) return a->data.symbol.name; + if (a && a->type == EXPR_STRING) return a->data.string; + return NULL; +} +static int ffi_is_info_query(Expr* parsed) { + Expr* e = ffi_final_expr(parsed); + return e && e->type == EXPR_FUNCTION && e->data.function.head + && e->data.function.head->type == EXPR_SYMBOL + && e->data.function.head->data.symbol.name == SYM_Information; +} + +static uint64_t ffi_memory_bytes(void) { + uint64_t b = 0; + if (!meminfo_current(&b)) return 0; + return b; +} + +static void ffi_emit_done(const FfiSink* s, int id) { + char buf[96]; + snprintf(buf, sizeof(buf), "{\"id\":%d,\"type\":\"done\",\"memory\":%llu}", + id, (unsigned long long)ffi_memory_bytes()); + ffi_cell_emit(s, buf); +} +static void ffi_emit_line(const FfiSink* s, int id, int line) { + char buf[96]; + snprintf(buf, sizeof(buf), "{\"id\":%d,\"type\":\"line\",\"line\":%d}", id, line); + ffi_cell_emit(s, buf); +} + +/* {"id":N,"type":,"":""} for `len` bytes of `text`. */ +static void ffi_emit_text(const FfiSink* s, int id, const char* kind, + const char* field, const char* text, size_t len) { + char* raw = malloc(len + 1); + if (!raw) return; + memcpy(raw, text, len); + raw[len] = '\0'; + size_t ecap = FFI_JSON_ESC_BOUND(len); + char* esc = malloc(ecap); + size_t lcap = ecap + strlen(kind) + strlen(field) + 64; + char* line = esc ? malloc(lcap) : NULL; + if (esc && line) { + ffi_json_escape(raw, esc, ecap); + snprintf(line, lcap, "{\"id\":%d,\"type\":\"%s\",\"%s\":\"%s\"}", id, kind, field, esc); + ffi_cell_emit(s, line); + } + free(line); + free(esc); + free(raw); +} + +static void ffi_emit_parse_error(const FfiSink* s, const char* input, int id) { + size_t esc_cap = FFI_JSON_ESC_BOUND(strlen(input)); + char* esc = malloc(esc_cap); + char* buf = NULL; + if (esc) { + ffi_json_escape(input, esc, esc_cap); + size_t bcap = esc_cap + 128; + buf = malloc(bcap); + if (buf) + snprintf(buf, bcap, + "{\"id\":%d,\"type\":\"error\",\"message\":\"Parse error: %s\"}", id, esc); + } + if (buf) ffi_cell_emit(s, buf); + else { + char sbuf[128]; + snprintf(sbuf, sizeof(sbuf), + "{\"id\":%d,\"type\":\"error\",\"message\":\"Parse error\"}", id); + ffi_cell_emit(s, sbuf); + } + free(esc); + free(buf); +} + +/* ---- Print/message capture during one statement (POSIX open_memstream) ---- */ +typedef struct { + FILE* saved_out; FILE* saved_err; + FILE* out; FILE* err; + char* out_buf; size_t out_len; + char* err_buf; size_t err_len; +} FfiCapture; + +static void ffi_capture_begin(FfiCapture* c) { + memset(c, 0, sizeof(*c)); +#ifndef _WIN32 + fflush(stdout); + fflush(stderr); + c->out = open_memstream(&c->out_buf, &c->out_len); + c->err = open_memstream(&c->err_buf, &c->err_len); + if (!c->out || !c->err) { + if (c->out) fclose(c->out); + if (c->err) fclose(c->err); + free(c->out_buf); + free(c->err_buf); + memset(c, 0, sizeof(*c)); + return; + } + c->saved_out = stdout; + c->saved_err = stderr; + stdout = c->out; + stderr = c->err; +#endif +} + +#ifndef _WIN32 +/* One "message" line per funnelled Head::tag message; a line not beginning with + * a non-blank "Head::tag" continues the previous one. Mirrors repl.c. */ +static void ffi_emit_messages(const FfiSink* s, int id, const char* text) { + const char* msg_start = NULL; + const char* p = text; + while (*p) { + const char* eol = strchr(p, '\n'); + const char* next = eol ? eol + 1 : p + strlen(p); + size_t len = (size_t)((eol ? eol : next) - p); + int starts = len > 0 && !isspace((unsigned char)*p); + if (starts) { + const char* dc = strstr(p, "::"); + starts = dc && dc < p + len; + } + if (starts && msg_start) { + size_t mlen = (size_t)(p - msg_start); + while (mlen > 0 && (msg_start[mlen - 1] == '\n' || msg_start[mlen - 1] == '\r')) mlen--; + if (mlen) ffi_emit_text(s, id, "message", "text", msg_start, mlen); + msg_start = NULL; + } + if (!msg_start && len > 0) msg_start = p; + p = next; + } + if (msg_start) { + size_t mlen = strlen(msg_start); + while (mlen > 0 && (msg_start[mlen - 1] == '\n' || msg_start[mlen - 1] == '\r')) mlen--; + if (mlen) ffi_emit_text(s, id, "message", "text", msg_start, mlen); + } +} +#endif + +static void ffi_capture_end(const FfiSink* s, FfiCapture* c, int id) { +#ifndef _WIN32 + if (!c->out) return; + stdout = c->saved_out; + stderr = c->saved_err; + fclose(c->out); + fclose(c->err); + if (c->out_buf && c->out_len > 0) + ffi_emit_text(s, id, "stream", "text", c->out_buf, c->out_len); + if (c->err_buf && c->err_len > 0) + ffi_emit_messages(s, id, c->err_buf); + free(c->out_buf); + free(c->err_buf); + memset(c, 0, sizeof(*c)); +#else + (void)s; (void)c; (void)id; +#endif +} + +/* Evaluate ONE statement and emit its events. Borrows `parsed`; emits no + * "done". Mirrors repl.c's pipe_eval_statement. */ +static void ffi_eval_statement(const FfiSink* s, Expr* parsed, int id, + int show_result, int cell) { + int info_query = ffi_is_info_query(parsed); + char info_sym[128]; + info_sym[0] = '\0'; + if (info_query) { + const char* isname = ffi_info_symbol(parsed); + if (isname) { + strncpy(info_sym, isname, sizeof(info_sym) - 1); + info_sym[sizeof(info_sym) - 1] = '\0'; + } + } + + int line = 0; + if (cell) { + line = ++g_ffi_line; + ffi_set_line(line); + ffi_store_in(line, parsed); + ffi_emit_line(s, id, line); + } + + FfiCapture cap; + if (cell) ffi_capture_begin(&cap); + Expr* evaluated = evaluate(parsed); + if (cell) ffi_capture_end(s, &cap, id); + + if (cell) ffi_store_out(line, evaluated); + if (!evaluated) return; + + if (cell && (!show_result + || (evaluated->type == EXPR_SYMBOL && evaluated->data.symbol.name == SYM_Null))) { + expr_free(evaluated); + return; + } + + /* ?Pat* -> the List of matching names, as a "names" event. */ + if (info_query && evaluated->type == EXPR_FUNCTION + && evaluated->data.function.head + && evaluated->data.function.head->type == EXPR_SYMBOL + && evaluated->data.function.head->data.symbol.name == SYM_List) { + size_t n = evaluated->data.function.arg_count; + size_t cap_n = 64; + for (size_t i = 0; i < n; i++) { + Expr* e = evaluated->data.function.args[i]; + if (e->type == EXPR_STRING) cap_n += FFI_JSON_ESC_BOUND(strlen(e->data.string)); + } + char* buf = malloc(cap_n); + if (buf) { + int off = snprintf(buf, cap_n, "{\"id\":%d,\"type\":\"names\",\"payload\":[", id); + int first = 1; + for (size_t i = 0; i < n && off > 0 && (size_t)off < cap_n; i++) { + Expr* e = evaluated->data.function.args[i]; + if (e->type != EXPR_STRING) continue; + size_t ecap = FFI_JSON_ESC_BOUND(strlen(e->data.string)); + char* esc = malloc(ecap); + if (!esc) break; + ffi_json_escape(e->data.string, esc, ecap); + off += snprintf(buf + off, cap_n - (size_t)off, "%s\"%s\"", first ? "" : ",", esc); + free(esc); + first = 0; + } + if (off > 0 && (size_t)off < cap_n) snprintf(buf + off, cap_n - (size_t)off, "]}"); + ffi_cell_emit(s, buf); + free(buf); + } + expr_free(evaluated); + return; + } + + /* ?sym -> the raw docstring as a "usage" event (not run through a renderer). */ + if (info_query && evaluated->type == EXPR_STRING) { + const char* doc = evaluated->data.string; + size_t dcap = FFI_JSON_ESC_BOUND(strlen(doc)); + char* esc = malloc(dcap); + if (esc) { + ffi_json_escape(doc, esc, dcap); + size_t bcap = dcap + 256; + char* buf = malloc(bcap); + if (buf) { + if (info_sym[0]) + snprintf(buf, bcap, + "{\"id\":%d,\"type\":\"usage\",\"payload\":\"%s\",\"symbol\":\"%s\"}", + id, esc, info_sym); + else + snprintf(buf, bcap, + "{\"id\":%d,\"type\":\"usage\",\"payload\":\"%s\"}", id, esc); + ffi_cell_emit(s, buf); + free(buf); + } + free(esc); + } + expr_free(evaluated); + return; + } + + /* Image[...] / Image3D[...] -> base64 RGBA "image" event. */ + if (evaluated->type == EXPR_FUNCTION + && evaluated->data.function.head + && evaluated->data.function.head->type == EXPR_SYMBOL) { + const char* ih = evaluated->data.function.head->data.symbol.name; + if (ih && (strcmp(ih, "Image") == 0 || strcmp(ih, "Image3D") == 0)) { + char* ijson = image_to_json(evaluated); + if (ijson) { + expr_free(evaluated); + size_t jl = strlen(ijson) + 64; + char* jline = malloc(jl); + if (jline) { + snprintf(jline, jl, "{\"id\":%d,\"type\":\"image\",\"payload\":%s}", id, ijson); + ffi_cell_emit(s, jline); + free(jline); + } + free(ijson); + return; + } + } + } + + /* Graphics[...] / Graphics3D[...] -> Plotly JSON "plot" event. */ + if (evaluated->type == EXPR_FUNCTION + && evaluated->data.function.head + && evaluated->data.function.head->type == EXPR_SYMBOL) { + const char* head_sym = evaluated->data.function.head->data.symbol.name; + char* plotly = NULL; + if (head_sym == SYM_Graphics) plotly = graphics_to_plotly_json(evaluated); + else if (head_sym == SYM_Graphics3D) plotly = graphics3d_to_plotly_json(evaluated); + if (plotly) { + expr_free(evaluated); + size_t jl = strlen(plotly) + 64; + char* jline = malloc(jl); + if (jline) { + snprintf(jline, jl, "{\"id\":%d,\"type\":\"plot\",\"payload\":%s}", id, plotly); + ffi_cell_emit(s, jline); + free(jline); + } + free(plotly); + return; + } + if (head_sym == SYM_Graphics || head_sym == SYM_Graphics3D) { + expr_free(evaluated); + return; + } + } + + /* Ordinary result: "expr" with plain payload and optional KaTeX latex. */ + char* result_str = expr_to_string(evaluated); + char* latex_raw = expr_to_latex(evaluated); /* before expr_free */ + expr_free(evaluated); + + if (!result_str) { + free(latex_raw); + char buf[128]; + snprintf(buf, sizeof(buf), + "{\"id\":%d,\"type\":\"error\",\"message\":\"Out of memory\"}", id); + ffi_cell_emit(s, buf); + return; + } + size_t escaped_len = FFI_JSON_ESC_BOUND(strlen(result_str)); + char* escaped = malloc(escaped_len); + if (!escaped) { free(result_str); free(latex_raw); return; } + ffi_json_escape(result_str, escaped, escaped_len); + free(result_str); + + char* latex_esc = NULL; + if (latex_raw) { + size_t llen = FFI_JSON_ESC_BOUND(strlen(latex_raw)); + latex_esc = malloc(llen); + if (latex_esc) ffi_json_escape(latex_raw, latex_esc, llen); + free(latex_raw); + } + + size_t line_len = escaped_len + (latex_esc ? strlen(latex_esc) : 0) + 128; + char* json_line = malloc(line_len); + if (json_line) { + if (latex_esc && latex_esc[0]) + snprintf(json_line, line_len, + "{\"id\":%d,\"type\":\"expr\",\"payload\":\"%s\",\"latex\":\"%s\"}", + id, escaped, latex_esc); + else + snprintf(json_line, line_len, + "{\"id\":%d,\"type\":\"expr\",\"payload\":\"%s\"}", id, escaped); + ffi_cell_emit(s, json_line); + free(json_line); + } + free(escaped); + free(latex_esc); +} + +/* True if `s` holds only whitespace and (* ... *) comments (so trailing such + * after the last statement is not a syntax error). */ +static int ffi_blanks_only(const char* s) { + while (*s) { + if (isspace((unsigned char)*s)) { s++; continue; } + if (s[0] == '(' && s[1] == '*') { + int depth = 1; s += 2; + while (*s && depth > 0) { + if (s[0] == '(' && s[1] == '*') { depth++; s += 2; } + else if (s[0] == '*' && s[1] == ')') { depth--; s += 2; } + else s++; + } + if (depth > 0) return 0; /* unterminated comment is a real error */ + } else { + return 0; + } + } + return 1; +} + +void mathilda_ffi_eval_cell(const char* code, int id, int cell, + mathilda_ffi_sink sink, void* ctx) { + if (!g_initialized) mathilda_ffi_init(); + FfiSink s = { sink, ctx }; + if (!code) { ffi_emit_parse_error(&s, "", id); ffi_emit_done(&s, id); return; } + + if (!cell) { + Expr* parsed = parse_expression(code); + if (!parsed) ffi_emit_parse_error(&s, code, id); + else { ffi_eval_statement(&s, parsed, id, 1, 0); expr_free(parsed); } + ffi_emit_done(&s, id); + return; + } + + /* Parse the whole cell FIRST (a syntax error anywhere evaluates nothing), + * splitting statements like a Mathematica input cell. */ + size_t n = 0, capn = 8; + Expr** stmts = malloc(capn * sizeof(Expr*)); + int* shown = malloc(capn * sizeof(int)); + int failed = (stmts == NULL || shown == NULL); + const char* p = code; + while (!failed) { + const char* start = p; + Expr* e = parse_next_expression(&p); + if (!e) { + if (!ffi_blanks_only(start)) failed = 1; + break; + } + if (n == capn) { + size_t nc = capn * 2; + Expr** ns = realloc(stmts, nc * sizeof(Expr*)); + if (ns) stmts = ns; + int* nb = realloc(shown, nc * sizeof(int)); + if (nb) shown = nb; + if (!ns || !nb) { expr_free(e); failed = 1; break; } + capn = nc; + } + stmts[n] = e; + shown[n] = !(p > start && p[-1] == ';'); + n++; + } + + if (failed) ffi_emit_parse_error(&s, code, id); + else for (size_t i = 0; i < n; i++) ffi_eval_statement(&s, stmts[i], id, shown[i], 1); + for (size_t i = 0; i < n; i++) expr_free(stmts[i]); + free(stmts); + free(shown); + ffi_emit_done(&s, id); +} + +/* ---- Editor services: completeness check and name completion -------------- */ + +int mathilda_ffi_is_complete(const char* code) { + if (!code) return 1; + int depth = 0, comment = 0, in_string = 0; + for (const char* p = code; *p; ) { + if (in_string) { + if (*p == '\\' && p[1]) { p += 2; continue; } + if (*p == '"') in_string = 0; + p++; + } else if (comment > 0) { + if (p[0] == '(' && p[1] == '*') { comment++; p += 2; } + else if (p[0] == '*' && p[1] == ')') { comment--; p += 2; } + else p++; + } else if (p[0] == '(' && p[1] == '*') { + comment++; p += 2; + } else if (*p == '"') { + in_string = 1; p++; + } else { + if (*p == '(' || *p == '[' || *p == '{') depth++; + else if (*p == ')' || *p == ']' || *p == '}') depth--; + p++; + } + } + return !in_string && comment == 0 && depth <= 0; +} + +/* Collector for symtab_for_each: names beginning with `prefix`. */ +typedef struct { const char* prefix; size_t plen; char** names; size_t n, cap; } FfiComplete; +static void ffi_complete_visit(const char* name, SymbolDef* def, void* user) { + (void)def; + FfiComplete* c = (FfiComplete*)user; + if (!name) return; + if (c->plen && strncmp(name, c->prefix, c->plen) != 0) return; + if (c->n == c->cap) { + size_t nc = c->cap ? c->cap * 2 : 32; + char** nn = realloc(c->names, nc * sizeof(char*)); + if (!nn) return; + c->names = nn; c->cap = nc; + } + size_t len = strlen(name) + 1; + char* dup = malloc(len); + if (!dup) return; + memcpy(dup, name, len); + c->names[c->n++] = dup; +} +static int ffi_strcmp_ptr(const void* a, const void* b) { + return strcmp(*(const char* const*)a, *(const char* const*)b); +} + +char* mathilda_ffi_complete(const char* prefix) { + if (!g_initialized) mathilda_ffi_init(); + FfiComplete c = { prefix ? prefix : "", prefix ? strlen(prefix) : 0, NULL, 0, 0 }; + symtab_for_each(ffi_complete_visit, &c); + if (c.n > 1) qsort(c.names, c.n, sizeof(char*), ffi_strcmp_ptr); + + size_t cap = 4; + for (size_t i = 0; i < c.n; i++) cap += FFI_JSON_ESC_BOUND(strlen(c.names[i])) + 3; + char* out = malloc(cap); + if (out) { + size_t off = 0; + out[off++] = '['; + for (size_t i = 0; i < c.n; i++) { + size_t ecap = FFI_JSON_ESC_BOUND(strlen(c.names[i])); + char* esc = malloc(ecap); + if (esc) { + ffi_json_escape(c.names[i], esc, ecap); + off += (size_t)snprintf(out + off, cap - off, "%s\"%s\"", i ? "," : "", esc); + free(esc); + } + } + if (off + 2 <= cap) { out[off++] = ']'; out[off] = '\0'; } + else { free(out); out = NULL; } + } + for (size_t i = 0; i < c.n; i++) free(c.names[i]); + free(c.names); + return out ? out : ffi_strdup("[]"); +} + void mathilda_ffi_free(char* s) { free(s); } diff --git a/src/ffi/mathilda_ffi.h b/src/ffi/mathilda_ffi.h index a5c6d869b..1a02658e7 100644 --- a/src/ffi/mathilda_ffi.h +++ b/src/ffi/mathilda_ffi.h @@ -62,6 +62,51 @@ char* mathilda_ffi_eval_latex(const char* input); * Caller-owned memory; free with mathilda_ffi_free(). Never returns NULL. */ char* mathilda_ffi_eval_json(const char* input); +/* --- Notebook-cell evaluation and editor services ------------------------ + * + * These exist for a front end that drives the kernel as a notebook or console + * (e.g. a xeus Jupyter kernel): a cell may hold several statements, Print + * output and messages must reach the cell, and an editor wants completion and + * a continuation check. They are the in-process counterpart of the sidecar's + * `cell:true` NDJSON pipe mode (repl.c: pipe_process_input), and emit the SAME + * protocol event lines, so one event mapping serves both transports. */ + +/* Receives one NDJSON event line (no trailing newline), exactly as the sidecar + * would write it to stdout: {"id":N,"type":"line"|"stream"|"message"|"expr"| + * "plot"|"image"|"usage"|"names"|"error"|"done", ...}. The embedder parses it + * and publishes the matching front-end message. Called in order, on the + * calling thread, before mathilda_ffi_eval_cell returns. */ +typedef void (*mathilda_ffi_sink)(void* ctx, const char* json_line); + +/* Evaluate `code` for request `id`, delivering each event to `sink`. + * + * cell != 0 — notebook-cell semantics: the cell is split into statements like a + * Mathematica input cell (newline- or ';'-separated); the whole cell is + * parsed before anything runs, so a syntax error anywhere evaluates nothing; + * each statement's Print output arrives as a "stream" event and its messages + * as "message" events BEFORE its result; a statement ending in ';' or + * evaluating to Null sends no result; a per-statement "line" event carries + * the session $Line (so `%`, In[n], Out[n] work across cells). + * cell == 0 — one expression, no history, no capture (the batch-tool path). + * + * A terminating {"type":"done"} is always the last event. Rich results route + * as in mathilda_ffi_eval_json (Graphics -> "plot", Image -> "image", ?name -> + * "usage"/"names", otherwise "expr" with an optional "latex"). Implicitly + * initializes the kernel; not reentrant (serialize calls — see file header). */ +void mathilda_ffi_eval_cell(const char* code, int id, int cell, + mathilda_ffi_sink sink, void* ctx); + +/* 1 if `code` forms a complete expression (every (), [], {} closed, no open + * "..." string or (* ... *) comment), 0 otherwise. Drives a console/notebook + * continuation prompt — the same rule the REPL's smart-Return uses. */ +int mathilda_ffi_is_complete(const char* code); + +/* A JSON array (as a string) of defined symbol names beginning with `prefix`, + * sorted, for tab completion — e.g. prefix "Sin" -> "[\"Sin\",\"Sinh\", + * \"SinIntegral\"]". Caller-owned; free with mathilda_ffi_free(). Never NULL + * ("[]" when nothing matches). A NULL/empty prefix lists every symbol. */ +char* mathilda_ffi_complete(const char* prefix); + /* Release a string returned by mathilda_ffi_eval / _eval_latex. NULL-safe. */ void mathilda_ffi_free(char* s); From 9934887d47c228fde881d6c28810e96fd844e99b Mon Sep 17 00:00:00 2001 From: Sam Blake Date: Thu, 1 Oct 2026 16:11:42 +1000 Subject: [PATCH 2/2] fix(graphics): Plot3D emitted ~1,700 Plotly traces and hung the notebook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Plot3D surface shades each quad its own colour and overlays a grid wireframe. graphics3d_to_plotly_json flushed a separate mesh3d trace on every colour change (one per quad) and a separate scatter3d trace per Line[] wireframe segment, so a single Plot3D became ~1,700 traces / ~300 KB of JSON. That many separate WebGL objects hangs the browser tab of any Plotly front end — including the new xeus Jupyter kernel, where it looked like the notebook "crashed". Now the whole surface is ONE mesh3d trace carrying per-quad colour via Plotly's facecolor array, and the whole wireframe is ONE scatter3d trace with segments separated by JSON null. Plot3D[Sin[x+y],{x,0,3},{y,0,3}] drops from 1,680 traces to 2 (and 305 KB to 152 KB); it renders identically. Benefits every consumer of the serializer (xeus kernel, desktop sidecar, mobile FFI). test_plot3d now asserts the payload stays compact: exactly one mesh3d trace, at most one scatter3d trace, and a facecolor array (per-quad shading preserved). No $VersionNumber bump: src/version.h is mid-edit in a parallel session. Co-Authored-By: Claude Opus 4.8 --- src/graphics/graphics_json.c | 178 ++++++++++++++++++++--------------- tests/test_plot3d.c | 33 +++++-- 2 files changed, 127 insertions(+), 84 deletions(-) diff --git a/src/graphics/graphics_json.c b/src/graphics/graphics_json.c index 42ca2adf4..bec01ee19 100644 --- a/src/graphics/graphics_json.c +++ b/src/graphics/graphics_json.c @@ -583,21 +583,29 @@ char* graphics_to_plotly_json(const Expr* g) { * 3D coordinate helpers * --------------------------------------------------------------------- */ -/* Append one numeric component of a 3-element point list. */ -static int append_coord3_array(Buf* b, const Expr* pts, int component) { - if (!head_is(pts, SYM_List)) return 0; - buf_cat(b, "["); - int first = 1; - for (size_t i = 0; i < pts->data.function.arg_count; i++) { - const Expr* triple = pts->data.function.args[i]; - if (!head_is(triple, SYM_List) || triple->data.function.arg_count < 3) continue; - double v; - if (!expr_to_double(triple->data.function.args[component], &v)) continue; - if (!first) buf_cat(b, ","); - buf_catd(b, v); - first = 0; - } - return buf_cat(b, "]"); +/* Emit ONE scatter3d trace from the accumulated line buffers and reset them. + * Segments are separated inside the arrays by JSON null, so the whole wireframe + * is a single trace — a Plot3D mesh is ~1,000 grid segments, and one trace each + * was enough separate WebGL objects to hang the front end. No-op when empty. */ +static void flush_line_trace(Buf* data_buf, int* first_trace, + Buf* lx, Buf* ly, Buf* lz, size_t* lpts, + double r, double g, double b) { + if (*lpts == 0) return; + buf_cat(lx, "]"); buf_cat(ly, "]"); buf_cat(lz, "]"); + char cs[72]; + rgba_str(cs, sizeof(cs), r, g, b, 1.0); + if (!*first_trace) buf_cat(data_buf, ","); + *first_trace = 0; + buf_cat(data_buf, "{\"type\":\"scatter3d\",\"mode\":\"lines\",\"x\":"); + buf_cat(data_buf, lx->buf); + buf_cat(data_buf, ",\"y\":"); buf_cat(data_buf, ly->buf); + buf_cat(data_buf, ",\"z\":"); buf_cat(data_buf, lz->buf); + buf_cat(data_buf, ",\"line\":{\"color\":\""); buf_cat(data_buf, cs); + buf_cat(data_buf, "\",\"width\":3},\"showlegend\":false}"); + buf_free(lx); buf_free(ly); buf_free(lz); + buf_init(lx, 65536); buf_init(ly, 65536); buf_init(lz, 65536); + buf_cat(lx, "["); buf_cat(ly, "["); buf_cat(lz, "["); + *lpts = 0; } /* ----------------------------------------------------------------------- @@ -626,51 +634,32 @@ char* graphics3d_to_plotly_json(const Expr* g) { double cur_opacity = 0.85; int first_trace = 1; - /* mesh3d accumulates all polygon vertices into one trace per color block. - * We flush at every color change and at the end. */ - Buf mesh_x, mesh_y, mesh_z, mesh_i, mesh_j, mesh_k; - buf_init(&mesh_x, 4096); buf_init(&mesh_y, 4096); buf_init(&mesh_z, 4096); - buf_init(&mesh_i, 2048); buf_init(&mesh_j, 2048); buf_init(&mesh_k, 2048); - size_t mesh_vcount = 0; /* total vertices so far in current mesh block */ - size_t mesh_qcount = 0; /* total quads so far in current mesh block */ - double mesh_r = cur_r, mesh_g_val = cur_g_val, mesh_b_color = cur_b; + /* Every Polygon[] quad accumulates into ONE mesh3d trace; per-quad colour + * is carried by Plotly's facecolor array (one colour per triangle). A + * Plot3D surface shades each quad a slightly different colour, so emitting + * a trace per colour was ~1,700 traces / ~300 KB for a single plot — enough + * separate WebGL objects to hang (and appear to "crash") the front end. One + * trace with facecolor renders identically and is cheap. */ + Buf mesh_x, mesh_y, mesh_z, mesh_i, mesh_j, mesh_k, mesh_fc; + buf_init(&mesh_x, 65536); buf_init(&mesh_y, 65536); buf_init(&mesh_z, 65536); + buf_init(&mesh_i, 32768); buf_init(&mesh_j, 32768); buf_init(&mesh_k, 32768); + buf_init(&mesh_fc, 65536); + size_t mesh_vcount = 0; /* total vertices accumulated */ + size_t mesh_qcount = 0; /* total triangles accumulated */ double mesh_opacity = cur_opacity; - /* Emit the accumulated mesh3d trace and reset buffers. */ -#define FLUSH_MESH() do { \ - if (mesh_qcount > 0) { \ - char mc[64]; \ - rgba_str(mc, sizeof(mc), mesh_r, mesh_g_val, mesh_b_color, mesh_opacity); \ - if (!first_trace) buf_cat(&data_buf, ","); \ - first_trace = 0; \ - /* Close the vertex/face arrays HERE so every flush is well-formed — a \ - * color change mid-surface flushes an in-progress block, not just the \ - * final one. (The next block is re-primed with "[" below.) */ \ - buf_cat(&mesh_x, "]"); buf_cat(&mesh_y, "]"); buf_cat(&mesh_z, "]"); \ - buf_cat(&mesh_i, "]"); buf_cat(&mesh_j, "]"); buf_cat(&mesh_k, "]"); \ - buf_cat(&data_buf, "{\"type\":\"mesh3d\","); \ - buf_cat(&data_buf, "\"x\":"); buf_cat(&data_buf, mesh_x.buf); \ - buf_cat(&data_buf, ",\"y\":"); buf_cat(&data_buf, mesh_y.buf); \ - buf_cat(&data_buf, ",\"z\":"); buf_cat(&data_buf, mesh_z.buf); \ - buf_cat(&data_buf, ",\"i\":"); buf_cat(&data_buf, mesh_i.buf); \ - buf_cat(&data_buf, ",\"j\":"); buf_cat(&data_buf, mesh_j.buf); \ - buf_cat(&data_buf, ",\"k\":"); buf_cat(&data_buf, mesh_k.buf); \ - buf_cat(&data_buf, ",\"color\":\""); buf_cat(&data_buf, mc); \ - buf_cat(&data_buf, "\",\"flatshading\":true,\"showscale\":false}"); \ - /* Reset mesh buffers. */ \ - buf_free(&mesh_x); buf_free(&mesh_y); buf_free(&mesh_z); \ - buf_free(&mesh_i); buf_free(&mesh_j); buf_free(&mesh_k); \ - buf_init(&mesh_x, 4096); buf_init(&mesh_y, 4096); buf_init(&mesh_z, 4096); \ - buf_init(&mesh_i, 2048); buf_init(&mesh_j, 2048); buf_init(&mesh_k, 2048); \ - mesh_vcount = 0; mesh_qcount = 0; \ - buf_cat(&mesh_x, "["); buf_cat(&mesh_y, "["); buf_cat(&mesh_z, "["); \ - buf_cat(&mesh_i, "["); buf_cat(&mesh_j, "["); buf_cat(&mesh_k, "["); \ - } \ -} while (0) - /* Prime the mesh buffers with opening brackets. */ buf_cat(&mesh_x, "["); buf_cat(&mesh_y, "["); buf_cat(&mesh_z, "["); buf_cat(&mesh_i, "["); buf_cat(&mesh_j, "["); buf_cat(&mesh_k, "["); + buf_cat(&mesh_fc, "["); + + /* Line[] segments accumulate into ONE scatter3d trace per colour, separated + * by JSON null (see flush_line_trace). */ + Buf line_x, line_y, line_z; + buf_init(&line_x, 65536); buf_init(&line_y, 65536); buf_init(&line_z, 65536); + buf_cat(&line_x, "["); buf_cat(&line_y, "["); buf_cat(&line_z, "["); + size_t line_pts = 0; /* array elements in the current block */ + double line_r = cur_r, line_g_val = cur_g_val, line_b = cur_b; buf_cat(&data_buf, "["); @@ -683,9 +672,13 @@ char* graphics3d_to_plotly_json(const Expr* g) { { double r, g_c, b, a; if (resolve_color_rgb(p, &r, &g_c, &b, &a)) { - FLUSH_MESH(); + /* The surface stays one trace (colour -> facecolor), but a line + * block is one colour, so flush the current wireframe block + * before the new colour takes effect. */ + flush_line_trace(&data_buf, &first_trace, + &line_x, &line_y, &line_z, &line_pts, + line_r, line_g_val, line_b); cur_r = r; cur_g_val = g_c; cur_b = b; - mesh_r = r; mesh_g_val = g_c; mesh_b_color = b; continue; } } @@ -700,7 +693,8 @@ char* graphics3d_to_plotly_json(const Expr* g) { continue; } - /* Line[List[List[x,y,z], ...]] → scatter3d */ + /* Line[List[List[x,y,z], ...]] → accumulate into the shared scatter3d + * line buffers, breaking from the previous segment with a null. */ if (head_is(p, SYM_Line) && p->data.function.arg_count >= 1) { const Expr* pts = p->data.function.args[0]; if (!head_is(pts, SYM_List) || pts->data.function.arg_count < 2) continue; @@ -708,19 +702,25 @@ char* graphics3d_to_plotly_json(const Expr* g) { const Expr* first_pt = pts->data.function.args[0]; if (!head_is(first_pt, SYM_List) || first_pt->data.function.arg_count < 3) continue; - char color_str[64]; - rgba_str(color_str, sizeof(color_str), cur_r, cur_g_val, cur_b, 1.0); - - if (!first_trace) buf_cat(&data_buf, ","); - first_trace = 0; - - buf_cat(&data_buf, "{\"type\":\"scatter3d\",\"mode\":\"lines\","); - buf_cat(&data_buf, "\"x\":"); append_coord3_array(&data_buf, pts, 0); - buf_cat(&data_buf, ",\"y\":"); append_coord3_array(&data_buf, pts, 1); - buf_cat(&data_buf, ",\"z\":"); append_coord3_array(&data_buf, pts, 2); - buf_cat(&data_buf, ",\"line\":{\"color\":\""); - buf_cat(&data_buf, color_str); - buf_cat(&data_buf, "\",\"width\":3},\"showlegend\":false}"); + if (line_pts == 0) { + /* First segment of a new block fixes the block's colour. */ + line_r = cur_r; line_g_val = cur_g_val; line_b = cur_b; + } else { + /* Break from the previous segment with a null element. */ + buf_cat(&line_x, ",null"); buf_cat(&line_y, ",null"); buf_cat(&line_z, ",null"); + line_pts++; + } + for (size_t vi = 0; vi < pts->data.function.arg_count; vi++) { + const Expr* vp = pts->data.function.args[vi]; + if (!head_is(vp, SYM_List) || vp->data.function.arg_count < 3) continue; + double vx, vy, vz; + if (!expr_to_double(vp->data.function.args[0], &vx)) continue; + if (!expr_to_double(vp->data.function.args[1], &vy)) continue; + if (!expr_to_double(vp->data.function.args[2], &vz)) continue; + if (line_pts > 0) { buf_cat(&line_x, ","); buf_cat(&line_y, ","); buf_cat(&line_z, ","); } + buf_catd(&line_x, vx); buf_catd(&line_y, vy); buf_catd(&line_z, vz); + line_pts++; + } continue; } @@ -752,27 +752,53 @@ char* graphics3d_to_plotly_json(const Expr* g) { /* Triangle indices. base = start of this polygon's vertices. */ size_t base = mesh_vcount - nv; - /* Fan: (0, k, k+1) for k in 1..nv-2. */ + char fcbuf[72]; + rgba_str(fcbuf, sizeof(fcbuf), cur_r, cur_g_val, cur_b, 1.0); + /* Fan: (0, k, k+1) for k in 1..nv-2. Per-triangle colour -> facecolor. */ for (size_t k = 1; k + 1 < nv; k++) { if (mesh_qcount > 0) { buf_cat(&mesh_i, ","); buf_cat(&mesh_j, ","); buf_cat(&mesh_k, ","); + buf_cat(&mesh_fc, ","); } char ibuf[32]; snprintf(ibuf, sizeof(ibuf), "%zu", base); buf_cat(&mesh_i, ibuf); snprintf(ibuf, sizeof(ibuf), "%zu", base + k); buf_cat(&mesh_j, ibuf); snprintf(ibuf, sizeof(ibuf), "%zu", base + k + 1); buf_cat(&mesh_k, ibuf); + buf_cat(&mesh_fc, "\""); buf_cat(&mesh_fc, fcbuf); buf_cat(&mesh_fc, "\""); mesh_qcount++; } continue; } } - /* Flush any remaining mesh (FLUSH_MESH closes the arrays itself). */ - FLUSH_MESH(); -#undef FLUSH_MESH + /* Emit the accumulated line wireframe as one trace, then free its buffers. */ + flush_line_trace(&data_buf, &first_trace, &line_x, &line_y, &line_z, &line_pts, + line_r, line_g_val, line_b); + buf_free(&line_x); buf_free(&line_y); buf_free(&line_z); + + /* Emit the single accumulated mesh3d trace for the whole surface. */ + if (mesh_qcount > 0) { + buf_cat(&mesh_x, "]"); buf_cat(&mesh_y, "]"); buf_cat(&mesh_z, "]"); + buf_cat(&mesh_i, "]"); buf_cat(&mesh_j, "]"); buf_cat(&mesh_k, "]"); + buf_cat(&mesh_fc, "]"); + char opbuf[32]; + snprintf(opbuf, sizeof(opbuf), "%.3f", mesh_opacity); + if (!first_trace) buf_cat(&data_buf, ","); + first_trace = 0; + buf_cat(&data_buf, "{\"type\":\"mesh3d\","); + buf_cat(&data_buf, "\"x\":"); buf_cat(&data_buf, mesh_x.buf); + buf_cat(&data_buf, ",\"y\":"); buf_cat(&data_buf, mesh_y.buf); + buf_cat(&data_buf, ",\"z\":"); buf_cat(&data_buf, mesh_z.buf); + buf_cat(&data_buf, ",\"i\":"); buf_cat(&data_buf, mesh_i.buf); + buf_cat(&data_buf, ",\"j\":"); buf_cat(&data_buf, mesh_j.buf); + buf_cat(&data_buf, ",\"k\":"); buf_cat(&data_buf, mesh_k.buf); + buf_cat(&data_buf, ",\"facecolor\":"); buf_cat(&data_buf, mesh_fc.buf); + buf_cat(&data_buf, ",\"opacity\":"); buf_cat(&data_buf, opbuf); + buf_cat(&data_buf, ",\"flatshading\":true,\"showscale\":false}"); + } buf_free(&mesh_x); buf_free(&mesh_y); buf_free(&mesh_z); - buf_free(&mesh_i); buf_free(&mesh_j); buf_free(&mesh_k); + buf_free(&mesh_i); buf_free(&mesh_j); buf_free(&mesh_k); buf_free(&mesh_fc); buf_cat(&data_buf, "]"); diff --git a/tests/test_plot3d.c b/tests/test_plot3d.c index ad2b98893..66e52b106 100644 --- a/tests/test_plot3d.c +++ b/tests/test_plot3d.c @@ -337,12 +337,19 @@ void test_plot3d_plot_label_stored_in_options(void) { "\"My Surface\"", 0); } -/* Regression: a Plot3D surface colours its faces and then draws a wireframe, - * so a colour change mid-Graphics3D flushes the accumulated mesh3d block. That - * mid-flush previously emitted the vertex/face arrays WITHOUT their closing - * ']', producing invalid JSON ("x":[...,"y":[...) that failed to parse — no - * plot on mobile. The payload must be well-formed (balanced brackets) and - * carry a mesh3d trace. */ +/* Count non-overlapping occurrences of `needle` in `hay`. */ +static int count_substr(const char* hay, const char* needle) { + int c = 0; size_t nl = strlen(needle); + for (const char* p = strstr(hay, needle); p; p = strstr(p + nl, needle)) c++; + return c; +} + +/* Regression: the payload must be well-formed (balanced brackets) and carry a + * mesh3d trace. AND it must stay COMPACT: the whole surface is one mesh3d trace + * (per-quad colour via facecolor) and the whole wireframe is one scatter3d + * trace (segments joined by null). Previously each shaded quad and each grid + * segment was its own trace — ~1,700 traces / ~300 KB for one plot, enough + * separate WebGL objects to hang the notebook front end. */ void test_plot3d_plotly_json_is_well_formed(void) { struct Expr* g = evaluate(parse_expression( "Plot3D[Sin[x] Cos[y], {x, 0, 3}, {y, 0, 3}]")); @@ -353,8 +360,18 @@ void test_plot3d_plotly_json_is_well_formed(void) { printf("FAIL: mesh3d Plotly JSON has unbalanced brackets (malformed)\n"); exit(1); } - if (!strstr(json, "\"type\":\"mesh3d\"")) { - printf("FAIL: no mesh3d trace in Plot3D payload\n"); + int n_mesh = count_substr(json, "\"type\":\"mesh3d\""); + int n_scatter = count_substr(json, "\"type\":\"scatter3d\""); + if (n_mesh != 1) { + printf("FAIL: expected exactly 1 mesh3d trace, got %d\n", n_mesh); + exit(1); + } + if (n_scatter > 1) { + printf("FAIL: wireframe not combined — %d scatter3d traces (want <=1)\n", n_scatter); + exit(1); + } + if (!strstr(json, "\"facecolor\":")) { + printf("FAIL: mesh3d trace carries no facecolor (per-quad shading lost)\n"); exit(1); } free(json);