Lua Plugins
NullCad can be extended with Lua 5.4 plugins. Plugins can add menu commands, interactive canvas tools, panels and dialogs, and automation hooks that react to application events. Every document mutation a script makes goes through the normal operations system, so plugin edits are undoable and appear in the audit trail like hand-drawn geometry.
Installing plugins
Plugins live in the NullCad config directory:
| Platform | Location |
|---|---|
| Linux | ~/.config/nullcad/plugins/ |
| Windows | %APPDATA%\nullcad\plugins\ |
| macOS | ~/Library/Application Support/nullcad/plugins/ |
Two layouts are supported:
- Single file — drop
my_plugin.luadirectly inplugins/. - Directory — a folder containing
plugin.tomland an entry script:
# plugins/survey-helpers/plugin.toml
name = "Survey Helpers"
id = "survey-helpers" # optional, defaults to the folder name
version = "0.1.0"
description = "Point grids, selection reports"
author = "You"
entry = "init.lua" # optional, defaults to init.lua
Plugins load on startup. Use Plugins ▸ Reload Plugins after editing a
script, and Plugins ▸ Plugin Manager… to enable/disable plugins or see
load errors. Plugins ▸ Run Script… executes a one-off .lua file
without installing it (useful for batch automation).
A minimal plugin
-- plugins/hello.lua
nc.register_command{
id = "hello",
title = "Say Hello",
run = function(ctx)
ctx:toast("Hello from Lua!", "success")
end,
}
This adds Plugins ▸ hello ▸ Say Hello to the menu bar.
The nc global
Every plugin runs in its own Lua state with the nc table installed:
| Item | Purpose |
|---|---|
nc.version | NullCad version string |
nc.ctx | Application context (see below) |
nc.register_command{...} | Add a menu command |
nc.register_tool{...} | Add an interactive canvas tool |
nc.register_panel{...} | Add a toggleable panel |
nc.on(event, handler) | Subscribe to an application event |
All callbacks receive ctx (the same object as nc.ctx) as their first
argument.
Commands
nc.register_command{
id = "count", -- required, unique within the plugin
title = "Count Geometry", -- menu label (defaults to id)
run = function(ctx) ... end, -- required
}
Tools
Tools receive snapped canvas clicks in world coordinates. Return true from
on_click to finish the tool (the app returns to selection mode); return
false/nothing to keep receiving clicks. Tool state is just Lua upvalues.
local first = nil
nc.register_tool{
id = "two-point",
name = "Two Point Line",
instructions = "Click start point, then end point",
on_click = function(ctx, x, y)
if not first then
first = {x = x, y = y}
return false
end
ctx:create_line{start_x = first.x, start_y = first.y, end_x = x, end_y = y}
first = nil
return true
end,
}
Registered tools appear in the plugin’s menu; activating one behaves like any built-in tool (Esc cancels, snapping applies).
Panels and dialogs
UI is declarative: a list of widget tables rendered by the app. Button
clicks call your on_event handler with the button id and the current
values of all input widgets (keyed by widget id).
Widget types:
{type = "heading", text = "Section"}
{type = "label", text = "Static text"}
{type = "separator"}
{type = "text", id = "name", label = "Name", value = "default"}
{type = "number", id = "dist", label = "Distance", value = 10.0}
{type = "checkbox", id = "flag", label = "Enabled", value = true}
{type = "select", id = "mode", label = "Mode", options = {"A", "B"}, value = "A"}
{type = "button", id = "go", label = "Go"}
Panels are registered once and toggled from the plugin’s menu (or with
ctx:open_panel(id) / ctx:close_panel(id)); update their contents with
ctx:update_panel(id, widgets).
nc.register_panel{
id = "quick-point",
title = "Quick Point",
widgets = {
{type = "number", id = "x", label = "Easting", value = 0.0},
{type = "number", id = "y", label = "Northing", value = 0.0},
{type = "button", id = "create", label = "Create Point"},
},
on_event = function(ctx, event, values)
if event == "create" then
ctx:create_point{x = values.x, y = values.y}
end
end,
}
Dialogs are opened from any callback with ctx:show_dialog. Return
true from the handler to close the dialog; the window’s ✕ also closes it.
ctx:show_dialog{
title = "Grid Options",
widgets = {
{type = "number", id = "rows", label = "Rows", value = 5},
{type = "number", id = "spacing", label = "Spacing", value = 10.0},
{type = "button", id = "ok", label = "Create"},
{type = "button", id = "cancel", label = "Cancel"},
},
on_event = function(ctx, event, values)
if event == "ok" then make_grid(ctx, values) end
return true -- close on any button
end,
}
Events (automation)
nc.on("document_saved", function(ctx, e)
ctx:log("Saved to " .. e.path)
end)
| Event | Payload |
|---|---|
document_opened | {path} |
document_saved | {path} |
document_new | {} |
operation_applied | {description} |
selection_changed | {ids, count} |
phase_changed | {phase} |
Events are delivered once per frame, after the mutation completes.
Operations performed inside an operation_applied handler do not
re-trigger the event (no feedback loops).
ctx reference
Coordinates are world meters (f64). Geometry and layer ids are UUID strings.
Errors raise Lua errors — wrap calls in pcall if you want to recover.
Reading
| Method | Returns |
|---|---|
ctx:document_info() | {project_name, file_path, geometry_count, layer_count, current_phase, active_layer_id, ...} |
ctx:current_phase() | "SetupSearch" | "BoundaryModel" | "Reinstatement" | "Drafting" |
ctx:list_layers() | {layers = {{id, name, visible, locked, color, order}, ...}} |
ctx:list_geometry{layer_name=, geometry_type=, limit=} | {count, items = {{id, type, name, layer_name, z, summary}, ...}} (all filters optional) |
ctx:get_geometry(id) | Full record incl. geometry table (e.g. {type = "Point", x, y}; Text/Rectangle include rotation_degrees, CCW from east) |
ctx:selection() | Array of selected geometry ids |
Creating geometry
All creators accept optional layer_id/layer_name (defaults to the
default layer), name, description, and (where meaningful) z. They
return the new geometry’s id.
ctx:create_point{x=, y=, z=}
ctx:create_line{start_x=, start_y=, end_x=, end_y=}
ctx:create_polyline{points = {{x=, y=}, ...}} -- ≥ 2 points
ctx:create_polygon{points = {{x=, y=}, ...}} -- ≥ 3 points
ctx:create_circle{center_x=, center_y=, radius=}
ctx:create_arc{center_x=, center_y=, radius=, start_angle=, end_angle=}
ctx:create_rectangle{corner1_x=, corner1_y=, corner2_x=, corner2_y=}
ctx:create_text{x=, y=, content=, height=}
ctx:create_layer{name=, color="#RRGGBB"} -- returns layer id
Modifying
| Method | Effect |
|---|---|
ctx:delete(id) | Soft-delete a geometry object (undoable) |
ctx:move_geometry(ids, dx, dy) | Translate the given ids by a delta |
ctx:rotate_geometry(ids, center_x, center_y, angle_degrees) | Rotate around a center point, clockwise degrees (surveying convention, same as the Rotate tool) |
ctx:set_selection(ids) / ctx:clear_selection() | Change the selection |
Feedback & UI
| Method | Effect |
|---|---|
ctx:toast(message, level?) | Toast notification ("info", "success", "warning", "error") |
ctx:log(message, level?) | Write to the console (F12), attributed to the plugin |
ctx:show_dialog{title=, widgets=, on_event=} | Open a dialog |
ctx:update_panel(id, widgets) | Replace a panel’s widgets (values with matching ids survive) |
ctx:open_panel(id) / ctx:close_panel(id) | Show/hide a registered panel |
Notes & limitations
- Scripts run on the UI thread: a long loop blocks the interface. Keep per-callback work modest.
ctxis only valid while NullCad is calling into your script (load, callbacks). Don’t stash it for use from Lua coroutines/timers — there are none anyway.- Lua’s standard library is available (
math,string,table,io,os), so scripts can read/write files for import/export automation. - Full examples live in
docs/examples/plugins/in the repository:survey-helpers/(commands, tools, panels, events) andalign-text.lua(rotate a text object to match a clicked line direction).