Navigation integration guide
To add Rotatrix navigation, connect your application’s camera and scene APIs to the OpenAxis SDK. Your integration can live in a plugin or in the application’s own code. Rotatrix computes navigation; your application applies the resulting poses and renders the scene.
This guide follows the Python, C#, TypeScript and C++ reference integrations. Start with the Navigation quickstart to run a demo and try its controls, then use the matching language tab below to follow its implementation. See SDK installation for adding the SDK to your own project.
Demo structure and API
Section titled “Demo structure and API”The application owns rendering, camera, cursor, selection, picking and pivot drawing. Its integration supplies adapters, scheduling, connection and cleanup. The entry point attaches after startup and detaches before destroying the window.
| Source | Responsibility |
|---|---|
| application.py | Native application API and rendering |
| integration.py | OpenAxis integration |
| main.py | Startup and shutdown |
MyApplication exposes get_camera(), set_camera(), get_viewport_size()
and set_pivot_marker() for the integration to call.
| Source | Responsibility |
|---|---|
| MyApplication.cs | Native application API and rendering |
| MyOpenAxisIntegration.cs | OpenAxis integration |
| Program.cs | Startup and shutdown |
MyApplication exposes Camera, SetCamera(), Viewport and ShowPivot()
for the integration to call.
| Source | Responsibility |
|---|---|
| application.ts | Three.js application API, rendering and input |
| integration.ts | OpenAxis adapters, session, connection and diagnostics |
| main.ts | Startup and shutdown |
The integration’s typed host interface is implemented by MyApplication in application.ts.
Camera/object reads return OpenAxis world-space poses. r is a rotation vector in radians, not Euler angles.
export interface Bounds { min: Vec3; max: Vec3;}export interface PickSample { hit?: { point: Vec3; bounds: Bounds }; target?: object; // Absent when no test ran (for example, no selection or invalid viewport pixel). ray?: [Vec3, Vec3];}export type AppEvent = | "camera" | "object" | "operation" | "invalidate" | "diagnostics" | "navigation";export interface MyEdit { readonly target: object; readonly initialPose: ObjectPoseValue;}export interface MyApplication { alive: boolean; freeCamera: boolean; edit?: MyEdit; readCamera(): CameraPoseValue; writeCamera(pose: CameraPoseValue): boolean; viewport(): { width: number; height: number; cursor?: [number, number] }; bounds(selectionOnly: boolean): Bounds | undefined; // Pixels relative to the viewport, Y down; exclude overlays from picks/bounds. pick(pixel: [number, number], selectionOnly: boolean): PickSample; showPivot(point: Vec3 | undefined, object?: boolean): void; readObject(edit: MyEdit): ObjectPoseValue; writeObject(edit: MyEdit, pose: ObjectPoseValue): boolean; objectBounds(edit: MyEdit): Bounds; setStatus(text: string): void; setDiagnostics(enabled: boolean): void; on(event: AppEvent, callback: () => void): () => void;}C++ application owns the scene, camera, selection, picking and edit transactions. The C++ reference integration defines MyNavigationAdapter, MyQueryCapture, and MyOpenAxisIntegration. It owns the client, manager and session; main.cpp owns startup and the GLFW event loop. The SDK supplies the openaxis types.
How the integration works
Section titled “How the integration works”The SDK handles communication and navigation requests. Integration code connects those requests to an application’s camera and scene API. The main pieces are:
| Component | Provided by | Role |
|---|---|---|
OpenAxisClient | SDK | Exchanges messages with Rotatrix over WebSocket. |
NavigationSession | SDK | Handles navigation requests, asks for scene information, and applies camera updates through the adapter. |
| Application | Host application | Owns the camera and scene and exposes the API used by the integration. |
| Navigation adapter | Integration | Implements the callbacks of NavigationSession by calling the application API. |
| Scheduler | Integration | Queues SDK work on the application’s thread, where its API can safely be called. |
OpenAxisConnectionManager | SDK | Keeps the client connected and reannounces application metadata. |
The client carries the messages; the NavigationSession interprets the navigation requests;
the adapter reads or changes application state. The scheduler lets the session perform that work on the application thread.
A query travels from the server through the client and session to the adapter, which reads the application and returns scene facts. A resulting camera pose travels through the same components to update the native camera. The integration owns these components from application startup until shutdown; the connection manager handles reconnects during that lifetime.
Integration setup
Section titled “Integration setup”The demos implement these roles with MyApplication, MyNavigationAdapter
and MyApplicationScheduler.
At startup, MyOpenAxisIntegration creates the client, adapter and scheduler,
then supplies them to a NavigationSession:
The demo creates the application and integration, then starts the integration
once the application’s event loop is running. url is the Rotatrix WebSocket
address, which defaults to ws://127.0.0.1:6607:
async def main(url): app = MyApplication() integration = MyOpenAxisIntegration(app, url) # The application starts its event loop before loading the integration. # Without OpenAxis, this is simply: await app.run() await app.run(on_started=integration.start, on_stopping=integration.stop)View full implementation: main →
Inside MyOpenAxisIntegration.start(), self.app is the running demo and
self.url is the server address:
app, url = self.app, self.urlclient = OpenAxisClient(client_name='python-demo-3d-app', url=url, target=Target(pid=current_process_id(), app='python-demo-3d-app'))adapter = MyNavigationAdapter(app)scheduler = MyApplicationScheduler(asyncio.get_running_loop())session = NavigationSession(client, adapter, scheduler, # Additional options omitted.)The demo’s Loaded event creates MyOpenAxisIntegration with the running
MyApplication as app. The constructor retains the scheduler and NavigationSession for
later application events. The focused excerpt omits logging and the optional
object and diagnostic configuration explained in recipes:
var client = new OpenAxisClient("csharp-demo-3d-app", url: url, target: new Target { Pid = OpenAxis.ProcessIdentity.Current(), App = "csharp-demo-3d-app" });var adapter = new MyNavigationAdapter(app);scheduler = new MyApplicationScheduler(app.Dispatcher);session = new NavigationSession(client, adapter, scheduler, observation: adapter.Read /* Additional options omitted. */);View full implementation: MyOpenAxisIntegration.MyOpenAxisIntegration →
After creating the host, construct new MyOpenAxisIntegration(app) and call integration.start(). The constructor attaches the NavigationSession before networking starts. Browser callbacks share the event loop; the default scheduler defers adapter work using microtasks. This simplified setup omits the optional features explained in recipes.
this.client = client ?? new OpenAxisClient({ clientName: "typescript-demo-3d-app", url });this.session = new NavigationSession( this.client, new MyNavigationAdapter(app), { observation: (context) => structuredClone(context.readCamera()), // Object and diagnostics options omitted. },);View full implementation: MyOpenAxisIntegration.constructor →
Retain the integration until teardown and await integration.stop() before disposing the host. A browser tab reports focus and does not send a process PID. For an integration running inside a native Node application, import currentProcessId from @openaxis/sdk and set target.pid to await currentProcessId() when constructing the client.
The demo creates MyOpenAxisIntegration after its window exists. The integration constructs the camera and object adapters, uses one scheduler for the client and session, and passes the client to NavigationSession for automatic attachment. The following setup excerpt omits logging, object navigation and offline smoke-test options:
OpenAxisClientOptions client_options;client_options.client_name = "demo 3D app (C++)";client_options.scheduler = &scheduler;OpenAxisClient client(client_options);MyNavigationAdapter adapter(app);NavigationOptions options;options.scheduler = &scheduler;options.observation = [&](const NavigationContext& context) { return adapter.read_camera(context); };NavigationSession session(client, adapter, nullptr, options);The native demos set target.pid with the SDK process identity helper so Rotatrix
can match the connection to the foreground application instance. The helper
returns a string and includes the PID namespace on Linux, including inside Flatpak.
See the target declaration contract for wire format
and matching rules.
The demos start the connection automatically and stop it when the application closes. See connection recovery and shutdown when adapting that lifecycle to your application.
Adapter implementation
Section titled “Adapter implementation”MyNavigationAdapter connects the SDK’s navigation operations to the demo’s
application API. It provides the methods the NavigationSession calls to read and update
the camera, display a pivot, and request scene information. The demo supplies
this adapter because the SDK does not know the application’s camera or scene API.
NavigationSession calls the adapter on the application thread to read the camera,
apply a pose received from Rotatrix, or display a pivot. The application may
constrain a requested camera pose. Returning the resulting pose lets
NavigationSession continue navigation from the camera’s actual state.
The adapter’s context keeps navigation tied to the view it started in.
A user may switch views or close a document while a camera update is pending.
Checking the context lets NavigationSession discard work for a view that is
no longer valid, rather than applying it to whichever view is now active.
class MyNavigationAdapter: def __init__(self, app): self.app = app
def capture_context(self): return self.app if self.app.alive else None
def is_current(self, context): return context is self.app and context.alive
def read(self, context): camera = context.get_camera() return CameraPose(t=camera.position, r=camera.rotation, fov=camera.vertical_fov, ortho_extent=camera.vertical_span)
def begin_query(self, context): return MyQueryCapture(context, self.read(context))
def apply_camera(self, context, desired, navigation, pivot): context.set_camera(MyCamera(desired.t, desired.r, desired.fov, desired.ortho_extent)) return WriteResult(True, self.read(context))
def show_pivot(self, context, point): context.set_pivot_marker(point)View full implementation: MyNavigationAdapter →
In apply_camera(), context is the captured application and desired is the
SDK’s requested CameraPose. MyCamera is the application’s camera type;
WriteResult reports whether the write succeeded and the resulting camera pose.
public sealed class MyNavigationAdapter(MyApplication app) : INavigationAdapter{ public object? CaptureContext() => app.Alive ? app : null; public bool IsCurrent(object context) => ReferenceEquals(context, app) && app.Alive; public CameraPoseValue? Read(object context) => app.Camera; public INavigationCapture BeginQuery(object context) => new MyQueryCapture(app); public NavigationWriteResult ApplyCamera(object context, CameraPoseValue desired, NavigationState? state, Vec3? pivot) { app.SetCamera(desired); return new NavigationWriteResult(true, app.Camera); } public void ShowPivot(object context, Vec3? point) => app.ShowPivot(point);}View full implementation: MyNavigationAdapter →
In ApplyCamera(), context is the captured application and desired is the
SDK’s requested CameraPoseValue. The demo’s SetCamera() accepts that type
directly. NavigationWriteResult reports success and the resulting camera pose.
In MyNavigationAdapter.applyPose(), context is the captured application and
desired is the requested CameraPoseValue. writeCamera() reports success;
realizedPose contains a copy of the resulting camera pose.
export class MyNavigationAdapter implements NavigationAdapter<MyApplication> { constructor( private app: MyApplication, ) {}
captureContext() { return this.app.alive ? this.app : undefined; } isCurrent(context: MyApplication) { return context === this.app && context.alive; } beginQuery(context: MyApplication) { return new MyQueryCapture(context); } applyPose(context: MyApplication, desired: CameraPoseValue) { const success = context.writeCamera(desired); return { success, realizedPose: success ? structuredClone(context.readCamera()) : undefined, }; } showPivot(context: MyApplication, point: Vec3 | undefined) { context.showPivot(point); }}MyNavigationAdapter captures the application and edit/reset generation. Each write validates that context and returns success plus the realized pose. MyQueryCapture answers facts for a query; the optional observation callback reports subsequent native movement.
struct MyNavigationAdapter : NavigationAdapter { MyApplication &app; explicit MyNavigationAdapter(MyApplication &application) : app(application) {} NavigationContext capture_context() override { if (app.width <= 0 || app.height <= 0) return {}; return MyNavigationContext{&app, app.generation}; } bool is_current(const NavigationContext &context) override { const auto &captured = std::any_cast<const MyNavigationContext &>(context); return captured.application == &app && captured.generation == app.generation && app.width > 0 && app.height > 0; } std::unique_ptr<NavigationCapture> begin_query(const NavigationContext &context) override { if (!is_current(context)) return {}; return std::make_unique<MyQueryCapture>(app); } std::optional<Pose> read_camera(const NavigationContext &context) { return is_current(context) ? std::optional<Pose>(app.camera) : std::nullopt; } WriteResult apply_pose(const NavigationContext &context, const NavigationPose &pose, const Value &, std::optional<Vec3>) override { if (!is_current(context)) return {}; app.camera = pose; return {true, app.camera}; } void show_pivot(const NavigationContext &context, std::optional<Vec3> point) override { if (point && !is_current(context)) return; app.pivot = point; if (point) app.target = *point; }};The demos use OpenAxis world conventions; your application may need coordinate conversion.
Answering scene queries
Section titled “Answering scene queries”Rotatrix needs information about the application’s scene to calculate navigation: the camera’s current pose, the viewport’s proportions, and the geometry under the cursor. It requests this information through scene queries.
In Python/C#/TypeScript, a query capture holds the camera, viewport and cursor state used to answer
a query. NavigationSession obtains the capture through the adapter on the
application thread, then asks its resolver for each requested fact. The SDK
sends the responses to Rotatrix.
The excerpts below introduce camera and orientation responses; some also include viewport responses. The complete implementations also answer picking and bounds queries and record diagnostics; those details are omitted here.
The adapter’s begin_query() passes its current camera reading to the capture.
resolve(name) returns the requested value, or UNAVAILABLE when the demo
cannot provide it:
class MyQueryCapture: def __init__(self, app, camera): self.app, self.camera = app, camera self.width, self.height = app.get_viewport_size() self.cursor = app.get_cursor_position()
def initial_camera_observation(self): return self.camera
def resolve(self, name): if name == 'world.orientation': return {'forward': (0, 0, -1), 'up': (0, 1, 0), 'handedness': 'right'} if name == 'camera.pose': return self.camera.value() if name == 'navigation.translation_scale': # World units per quarter ball turn in Rotatrix at unit gain. return 4.0 if name == 'viewport.aspect': return self.width / self.height if name == 'viewport.cursor': return UNAVAILABLE if self.cursor is None else ( 2*self.cursor[0]/self.width - 1, 1 - 2*self.cursor[1]/self.height) # Picking and bounds responses omitted. return UNAVAILABLEThe adapter’s BeginQuery() passes the application to the capture, which reads
the current camera. Resolve(name) returns the requested value, or
NavigationQuery.Unavailable when the demo cannot provide it:
public sealed class MyQueryCapture : INavigationCapture{ readonly MyApplication app; readonly CameraPoseValue pose; readonly double width, height; readonly Point? cursor; public MyQueryCapture(MyApplication app) { this.app = app; pose = app.Camera; width = Math.Max(1, app.ViewWidth); height = Math.Max(1, app.ViewHeight); cursor = app.Cursor; } public CameraPoseValue? InitialCameraObservation() => pose; public object? Resolve(string name) { switch (name) { case "world.orientation": return new Dictionary<string, object> { ["forward"] = new[] { 0, 0, -1 }, ["up"] = new[] { 0, 1, 0 }, ["handedness"] = "right" }; case "camera.pose": return Values.Camera(pose); // World units per quarter ball turn in Rotatrix at unit gain. case "navigation.translation_scale": return 4.0; case "viewport.aspect": return width / height; case "viewport.cursor": return cursor is Point p ? new[] { 2 * p.X / width - 1, 1 - 2 * p.Y / height } : NavigationQuery.Unavailable; // Picking and bounds responses omitted. } return NavigationQuery.Unavailable; }}View full implementation: MyQueryCapture →
Values.Camera() converts the camera pose to the value expected by the protocol.
The adapter’s beginQuery() creates MyQueryCapture with the captured application.
resolve(name) returns the requested value, or UNAVAILABLE when it cannot
provide it:
export class MyQueryCapture { private camera: CameraPoseValue; private width: number; private height: number; private cursor?: [number, number];
constructor( private app: MyApplication, ) { this.camera = structuredClone(app.readCamera()); const { width, height, cursor } = structuredClone(app.viewport()); this.width = width; this.height = height; this.cursor = cursor && cursor[0] >= 0 && cursor[0] <= width && cursor[1] >= 0 && cursor[1] <= height ? cursor : undefined; } initialObservation() { return structuredClone(this.camera); } resolve(name: string): unknown { if (name === "document.id") return "demo-3d-services"; if (name === "camera.pose") return structuredClone(this.camera); if (name === "world.orientation") return { forward: [0, 0, -1], up: [0, 1, 0], handedness: "right" }; // Viewport, picking and bounds branches omitted. return UNAVAILABLE; }}MyQueryCapture snapshots the camera when the query begins. resolve(name) returns each requested fact or JSON null when unavailable. The session memoizes facts and stops at the first available candidate. Other scene reads stay consistent because the synchronous demo does not pump native events during a query.
struct MyQueryCapture : NavigationCapture { MyApplication &app; Pose initial; explicit MyQueryCapture(MyApplication &application) : app(application), initial(app.camera) {} std::optional<Pose> initial_observation() override { return initial; } Value resolve(const std::string &name) override { if (name == "camera.pose") return pose_value(initial); if (name == "navigation.translation_scale") return 4.0; if (name == "document.id") return "cpp-demo-scene"; if (name == "world.orientation") return {{"forward", {0, 0, -1}}, {"up", {0, 1, 0}}, {"handedness", "right"}}; if (name == "viewport.aspect" && app.height > 0) return double(app.width) / app.height; if (name == "viewport.cursor" && app.width > 0 && app.height > 0 && app.cursor_x >= 0 && app.cursor_x <= app.width && app.cursor_y >= 0 && app.cursor_y <= app.height) return {{"x", 2 * app.cursor_x / app.width - 1}, {"y", 1 - 2 * app.cursor_y / app.height}}; // Bounds and picking branches omitted. return nullptr; }};The picking recipe covers geometry queries, including model and selection bounds, and the resulting pivot display. The diagnostics recipe shows how the integration records query activity.
Check the integration
Section titled “Check the integration”In your application, verify that orbit and pan move the camera in the expected direction. Test perspective and orthographic views where supported, resize the viewport, and confirm that closing or replacing it stops writes to the old target. Use the integration checklist for the full host checks.
If the camera does not respond, check the connection using the connection recipe, then use diagnostics to inspect queries and camera writes.
Next steps
Section titled “Next steps”Adapt the demonstrated camera and scene API calls to your application. Consult coordinates when converting native camera values and adapter contracts for exact callback requirements.
Choose the follow-up that matches your next task:
- Shared by both interfaces: connection recovery and shutdown, application context and focus, and session logs.
- Navigation features: verify with diagnostics, add picking, handle concurrent input, or implement object editing.
Use the integration checklist when adapting these application API calls to your own host.