diff --git a/idaptik-ums/src/editor/Editor.res b/idaptik-ums/src/editor/Editor.res deleted file mode 100644 index 58ae744b..00000000 --- a/idaptik-ums/src/editor/Editor.res +++ /dev/null @@ -1,691 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Generated by ReScript, PLEASE EDIT WITH CARE -// -// Editor.res.mjs — Main editor view for the IDApTIK UMS visual level editor. -// -// Author: Jonathan D.A. Jewell -// -// Composes the toolbar, canvas, properties panel, and validation panel into -// a complete TEA application. Handles: -// -// - Canvas mouse events (click to place/select, drag to move/pan) -// - Keyboard shortcuts (Ctrl+Z undo, Ctrl+Y redo, Ctrl+S save, -// Delete remove, G toggle grid, Escape deselect) -// - TEA update function routing all EditorMsg variants -// - Canvas rendering via EditorCanvas on each state change -// - Gossamer command integration via EditorCmd -// -// ## Layout -// -// +-------------------+---------------------+-----------------+ -// | Toolbar | Canvas | Properties | -// | (200px) | (flex) | (250px) | -// +-------------------+---------------------+-----------------+ -// | Validation (120px) | -// +----------------------------------------------------------+ -// | Status Bar (24px) | -// +----------------------------------------------------------+ - -import * as React from "react"; -import * as Tea from "rescript-tea/src/Tea.res.mjs"; -import * as Tea_Cmd from "rescript-tea/src/Tea_Cmd.res.mjs"; -import * as Tea_Sub from "rescript-tea/src/Tea_Sub.res.mjs"; -import * as EditorModel from "./EditorModel.res.mjs"; -import * as EditorEngine from "./EditorEngine.res.mjs"; -import * as EditorCanvas from "./EditorCanvas.res.mjs"; -import * as EditorToolbar from "./EditorToolbar.res.mjs"; -import * as EditorProperties from "./EditorProperties.res.mjs"; -import * as EditorValidation from "./EditorValidation.res.mjs"; -import * as EditorCmd from "./EditorCmd.res.mjs"; - -// --------------------------------------------------------------------------- -// TEA: init -// --------------------------------------------------------------------------- - -/// Initialise the editor with default state and no commands. -/// The canvas ref is set up after mount via a React ref callback. -function init() { - return [ - EditorModel.defaultState, - Tea_Cmd.none - ]; -} - -// --------------------------------------------------------------------------- -// TEA: update -// --------------------------------------------------------------------------- - -/// Process an EditorMsg and return the updated state + commands. -/// -/// Message types: -/// - SetTool(tool) — Change the active tool -/// - CanvasClick(gx, gy) — Grid click: place, select, or erase -/// - CanvasMouseDown(gx, gy) — Begin drag operation -/// - CanvasMouseUp — End drag operation -/// - CanvasMouseMove(gx, gy) — Update drag position -/// - SelectEntity(id) — Select an entity by ID -/// - DeselectEntity — Clear selection -/// - DeleteEntity(id) — Remove entity by ID -/// - UpdateProperty(id, k, v) — Update a property on an entity -/// - ToggleGrid/Zones/Network — Toggle view overlays -/// - Undo / Redo — Undo/redo stack operations -/// - NewLevel — Reset to blank level -/// - OpenLevel — Trigger Gossamer load dialog -/// - SaveLevel — Trigger Gossamer save -/// - ValidateAbi — Trigger Gossamer ABI validation -/// - ExportConfig — Trigger Gossamer config export -/// - LevelLoaded(data) — Received loaded level data -/// - LevelSaved(path) — Received save confirmation -/// - ValidationResult(result) — Received validation result -/// - ExportResult(source) — Received export result -/// - LevelListReceived(list) — Received level file list -/// - ErrorOccurred(msg) — Error from any Gossamer command -function update(msg, state) { - // Handle string messages (simple toggles and actions) - if (typeof msg === "string") { - switch (msg) { - case "ToggleGrid": - return [ - Object.assign({}, state, { showGrid: !state.showGrid }), - Tea_Cmd.none - ]; - - case "ToggleZones": - return [ - Object.assign({}, state, { showZones: !state.showZones }), - Tea_Cmd.none - ]; - - case "ToggleNetwork": - return [ - Object.assign({}, state, { showNetwork: !state.showNetwork }), - Tea_Cmd.none - ]; - - case "DeselectEntity": - return [ - Object.assign({}, state, { selectedEntityId: undefined, activeTool: "Select" }), - Tea_Cmd.none - ]; - - case "Undo": - return [EditorEngine.undo(state), Tea_Cmd.none]; - - case "Redo": - return [EditorEngine.redo(state), Tea_Cmd.none]; - - case "NewLevel": - return [ - Object.assign({}, EditorModel.defaultState, { - canvasRef: state.canvasRef - }), - Tea_Cmd.none - ]; - - case "OpenLevel": - return [ - Object.assign({}, state, { loading: true }), - EditorCmd.listLevels( - function (list) { return { TAG: "LevelListReceived", _0: list }; }, - function (err) { return { TAG: "ErrorOccurred", _0: err }; } - ) - ]; - - case "SaveLevel": { - var levelJson = EditorEngine.levelToJson(state); - return [ - Object.assign({}, state, { loading: true }), - EditorCmd.saveLevel( - levelJson, - function (path) { return { TAG: "LevelSaved", _0: path }; }, - function (err) { return { TAG: "ErrorOccurred", _0: err }; } - ) - ]; - } - - case "ValidateAbi": { - var levelJson$1 = EditorEngine.levelToJson(state); - var jsonStr = JSON.stringify(levelJson$1); - return [ - Object.assign({}, state, { loading: true }), - EditorCmd.validateLevelAbi( - jsonStr, - function (result) { return { TAG: "ValidationResult", _0: result }; }, - function (err) { return { TAG: "ErrorOccurred", _0: err }; } - ) - ]; - } - - case "ExportConfig": { - var levelJson$2 = EditorEngine.levelToJson(state); - var jsonStr$1 = JSON.stringify(levelJson$2); - return [ - Object.assign({}, state, { loading: true }), - EditorCmd.exportLevelConfig( - jsonStr$1, - function (source) { return { TAG: "ExportResult", _0: source }; }, - function (err) { return { TAG: "ErrorOccurred", _0: err }; } - ) - ]; - } - - case "CanvasMouseUp": - return [state, Tea_Cmd.none]; - - default: - return [state, Tea_Cmd.none]; - } - } - - // Handle tagged messages - switch (msg.TAG) { - case "SetTool": - return [ - Object.assign({}, state, { activeTool: msg._0 }), - Tea_Cmd.none - ]; - - case "CanvasClick": { - var gx = msg._0; - var gy = msg._1; - - // Bounds check - if (gx < 0 || gx >= state.gridWidth || gy < 0 || gy >= state.gridHeight) { - return [state, Tea_Cmd.none]; - } - - var tool = state.activeTool; - - // Select tool: find entity at click position - if (tool === "Select") { - var found = EditorEngine.entityAt(state.entities, gx, gy); - if (found !== undefined) { - return [ - Object.assign({}, state, { selectedEntityId: found.id }), - Tea_Cmd.none - ]; - } - return [ - Object.assign({}, state, { selectedEntityId: undefined }), - Tea_Cmd.none - ]; - } - - // Erase tool: remove entity at click position - if (tool === "Erase") { - var found$1 = EditorEngine.entityAt(state.entities, gx, gy); - if (found$1 !== undefined) { - var withUndo = EditorEngine.pushUndo(state); - var newEntities = EditorEngine.removeEntity(withUndo.entities, found$1.id); - return [ - Object.assign({}, withUndo, { - entities: newEntities, - selectedEntityId: undefined, - isDirty: true - }), - Tea_Cmd.none - ]; - } - return [state, Tea_Cmd.none]; - } - - // Pan tool: no grid action - if (tool === "Pan") { - return [state, Tea_Cmd.none]; - } - - // Placement tools: create a new entity - if (typeof tool !== "string") { - // Check if position is already occupied - var existing = EditorEngine.entityAt(state.entities, gx, gy); - if (existing !== undefined) { - return [state, Tea_Cmd.none]; - } - - var withUndo$1 = EditorEngine.pushUndo(state); - var newEntity; - switch (tool.TAG) { - case "PlaceDevice": - newEntity = EditorEngine.makeDeviceEntity(tool._0, gx, gy, state.zones); - break; - case "PlaceGuard": - newEntity = EditorEngine.makeGuardEntity(tool._0, gx, gy, state.zones); - break; - case "PlaceDog": - newEntity = EditorEngine.makeDogEntity(tool._0, gx, gy, state.zones); - break; - case "PlaceDrone": - newEntity = EditorEngine.makeDroneEntity(tool._0, gx, gy); - break; - case "PlaceZone": - newEntity = EditorEngine.makeZoneMarkerEntity(tool._0, gx, gy); - break; - default: - return [state, Tea_Cmd.none]; - } - - var newEntities$1 = EditorEngine.addEntity(withUndo$1.entities, newEntity); - return [ - Object.assign({}, withUndo$1, { - entities: newEntities$1, - selectedEntityId: newEntity.id, - isDirty: true - }), - Tea_Cmd.none - ]; - } - - return [state, Tea_Cmd.none]; - } - - case "SelectEntity": - return [ - Object.assign({}, state, { selectedEntityId: msg._0 }), - Tea_Cmd.none - ]; - - case "DeleteEntity": { - var withUndo$2 = EditorEngine.pushUndo(state); - var newEntities$2 = EditorEngine.removeEntity(withUndo$2.entities, msg._0); - var newSelected = state.selectedEntityId === msg._0 ? undefined : state.selectedEntityId; - return [ - Object.assign({}, withUndo$2, { - entities: newEntities$2, - selectedEntityId: newSelected, - isDirty: true - }), - Tea_Cmd.none - ]; - } - - case "UpdateProperty": { - var newEntities$3 = EditorEngine.updateEntityProperty( - state.entities, msg._0, msg._1, msg._2 - ); - return [ - Object.assign({}, state, { entities: newEntities$3, isDirty: true }), - Tea_Cmd.none - ]; - } - - case "LevelLoaded": { - // Parse the loaded level data and populate the editor. - // For now, we just mark the level as loaded. Full level-to-entities - // conversion would go through EditorBridge.fromModelLevel. - return [ - Object.assign({}, state, { loading: false, error: undefined }), - Tea_Cmd.none - ]; - } - - case "LevelSaved": { - return [ - Object.assign({}, state, { - loading: false, - isDirty: false, - levelPath: msg._0, - error: undefined - }), - Tea_Cmd.none - ]; - } - - case "ValidationResult": { - var result = msg._0; - var proofs = [ - { - name: "GuardsInZones", - passed: result.guards_in_zones === true, - detail: "" - }, - { - name: "DefenceTargetsValid", - passed: result.defence_targets_valid === true, - detail: "" - }, - { - name: "ZonesOrdered", - passed: result.zones_ordered === true, - detail: "" - }, - { - name: "PBXConsistent", - passed: result.pbx_consistent === true, - detail: "" - }, - { - name: "DevicesExist", - passed: result.defence_ips_exist === true, - detail: "" - } - ]; - - // Assign error details to failed proofs - var errors = result.errors || []; - errors.forEach(function (errMsg) { - // Match error to proof by keyword - if (errMsg.indexOf("zone") !== -1 || errMsg.indexOf("Guard") !== -1) { - proofs[0].detail = (proofs[0].detail ? proofs[0].detail + "; " : "") + errMsg; - } else if (errMsg.indexOf("failover") !== -1 || errMsg.indexOf("cascade") !== -1 || errMsg.indexOf("mirror") !== -1) { - proofs[1].detail = (proofs[1].detail ? proofs[1].detail + "; " : "") + errMsg; - } else if (errMsg.indexOf("transition") !== -1 || errMsg.indexOf("ordered") !== -1) { - proofs[2].detail = (proofs[2].detail ? proofs[2].detail + "; " : "") + errMsg; - } else if (errMsg.indexOf("PBX") !== -1 || errMsg.indexOf("pbx") !== -1) { - proofs[3].detail = (proofs[3].detail ? proofs[3].detail + "; " : "") + errMsg; - } else if (errMsg.indexOf("defence") !== -1 || errMsg.indexOf("Defence") !== -1) { - proofs[4].detail = (proofs[4].detail ? proofs[4].detail + "; " : "") + errMsg; - } - }); - - var now = new Date(); - var timestamp = now.getHours().toString().padStart(2, "0") + ":" + - now.getMinutes().toString().padStart(2, "0") + ":" + - now.getSeconds().toString().padStart(2, "0"); - - return [ - Object.assign({}, state, { - loading: false, - validation: { - proofs: proofs, - allPassed: result.valid === true, - lastValidated: timestamp - }, - error: undefined - }), - Tea_Cmd.none - ]; - } - - case "ExportResult": { - // The export source string could be shown in a modal or copied to - // clipboard. For now, we just log it and clear the loading state. - console.log("Export result:", msg._0); - return [ - Object.assign({}, state, { loading: false, error: undefined }), - Tea_Cmd.none - ]; - } - - case "LevelListReceived": { - var list = msg._0; - var files = Array.isArray(list) ? list : []; - return [ - Object.assign({}, state, { loading: false, recentFiles: files, error: undefined }), - Tea_Cmd.none - ]; - } - - case "ErrorOccurred": { - return [ - Object.assign({}, state, { loading: false, error: msg._0 }), - Tea_Cmd.none - ]; - } - - default: - return [state, Tea_Cmd.none]; - } -} - -// --------------------------------------------------------------------------- -// Canvas ref callback + rendering -// --------------------------------------------------------------------------- - -/// Render the canvas contents after a state change. -/// Called from the React effect (useEffect) to keep the canvas in sync. -function renderCanvas(state) { - if (state.canvasRef === undefined || state.canvasRef === null) { - return; - } - var canvas = state.canvasRef; - var ctx = canvas.getContext("2d"); - if (ctx === null) { - return; - } - EditorCanvas.render(ctx, state); -} - -// --------------------------------------------------------------------------- -// TEA: view -// --------------------------------------------------------------------------- - -/// Render the complete editor UI. -/// -/// Layout: -/// - Outer container fills the viewport (flex column) -/// - Middle row: toolbar (left) | canvas (centre) | properties (right) -/// - Bottom: validation panel + status bar -/// -/// @param model — Current editor state. -/// @param dispatch — TEA dispatch function. -/// @returns React element tree for the editor. -function view(model, dispatch) { - - // -- Canvas element with mouse handlers --------------------------------- - - var canvasWidth = model.gridWidth * model.tileSize; - var canvasHeight = model.gridHeight * model.tileSize; - - var canvasEl = React.createElement("canvas", { - key: "editor-canvas", - width: canvasWidth, - height: canvasHeight, - style: { - border: "1px solid #334155", - cursor: model.activeTool === "Pan" ? "grab" : "crosshair", - imageRendering: "pixelated" - }, - ref: function (el) { - if (el !== null && el !== model.canvasRef) { - // Store the canvas ref in the model. We dispatch a SetCanvasRef - // message but for simplicity we mutate directly here since the - // canvas ref is not part of the TEA state diffing. - model.canvasRef = el; - renderCanvas(model); - } - }, - onClick: function (e) { - var rect = e.target.getBoundingClientRect(); - var px = e.clientX - rect.left; - var py = e.clientY - rect.top; - var grid = EditorEngine.canvasToGrid(px, py, model.tileSize); - dispatch({ TAG: "CanvasClick", _0: grid[0], _1: grid[1] }); - }, - onContextMenu: function (e) { - e.preventDefault(); - // Right-click: deselect or show context menu in future - dispatch("DeselectEntity"); - } - }); - - // Trigger canvas re-render on each view call - // (React will call this synchronously after the DOM update) - setTimeout(function () { renderCanvas(model); }, 0); - - // -- Canvas wrapper (centred flex) -------------------------------------- - - var canvasWrapper = React.createElement("div", { - key: "canvas-wrapper", - style: { - flex: "1", - display: "flex", - alignItems: "center", - justifyContent: "center", - backgroundColor: "#0f172a", - overflow: "auto", - padding: "16px" - } - }, canvasEl); - - // -- Middle row: toolbar | canvas | properties -------------------------- - - var middleRow = React.createElement("div", { - key: "middle-row", - style: { - display: "flex", - flex: "1", - overflow: "hidden" - } - }, - EditorToolbar.view(model, dispatch), - canvasWrapper, - EditorProperties.view(model, dispatch) - ); - - // -- Status bar --------------------------------------------------------- - - var dirtyIndicator = model.isDirty ? " [unsaved]" : ""; - var entityCount = model.entities.length; - var validationStatus = model.validation.allPassed - ? "ABI: Passed" - : model.validation.lastValidated !== undefined - ? "ABI: Failed" - : "ABI: Not validated"; - var errorBanner = model.error !== undefined - ? React.createElement("span", { - style: { color: "#ef4444", marginLeft: "12px" } - }, "Error: " + model.error) - : null; - var loadingIndicator = model.loading - ? React.createElement("span", { - style: { color: "#3b82f6", marginLeft: "12px" } - }, "Loading...") - : null; - - var statusBar = React.createElement("div", { - key: "status-bar", - style: { - height: "24px", - minHeight: "24px", - backgroundColor: "#0f172a", - borderTop: "1px solid #334155", - display: "flex", - alignItems: "center", - padding: "0 12px", - gap: "16px", - fontFamily: "monospace", - fontSize: "10px", - color: "#64748b" - } - }, - React.createElement("span", null, model.levelName + dirtyIndicator), - React.createElement("span", null, "Entities: " + String(entityCount)), - React.createElement("span", null, EditorEngine.toolLabel(model.activeTool)), - React.createElement("span", null, validationStatus), - loadingIndicator, - errorBanner - ); - - // -- Keyboard handler --------------------------------------------------- - // Note: This is handled via a global keydown listener rather than - // React's onKeyDown, because the canvas and toolbar buttons steal focus. - // The listener is attached via a useEffect-style approach by dispatching - // from a one-time mount callback. For the TEA pattern, we rely on the - // parent component or index.html to attach the listener and call dispatch. - // - // The keyboard shortcuts are documented here for reference: - // Ctrl+Z -> Undo - // Ctrl+Y -> Redo - // Ctrl+S -> SaveLevel - // Delete -> DeleteEntity (selected) - // G -> ToggleGrid - // Escape -> DeselectEntity / Switch to Select tool - - // -- Outer container ---------------------------------------------------- - - return React.createElement("div", { - key: "editor-root", - style: { - display: "flex", - flexDirection: "column", - width: "100%", - height: "100vh", - backgroundColor: "#0f172a", - overflow: "hidden" - }, - tabIndex: 0, - onKeyDown: function (e) { - // Ctrl+Z: Undo - if (e.ctrlKey && e.key === "z") { - e.preventDefault(); - dispatch("Undo"); - return; - } - // Ctrl+Y: Redo - if (e.ctrlKey && e.key === "y") { - e.preventDefault(); - dispatch("Redo"); - return; - } - // Ctrl+S: Save - if (e.ctrlKey && e.key === "s") { - e.preventDefault(); - dispatch("SaveLevel"); - return; - } - // Delete: Remove selected entity - if (e.key === "Delete" || e.key === "Backspace") { - if (model.selectedEntityId !== undefined) { - e.preventDefault(); - dispatch({ TAG: "DeleteEntity", _0: model.selectedEntityId }); - } - return; - } - // G: Toggle grid - if (e.key === "g" || e.key === "G") { - if (!e.ctrlKey && !e.altKey && !e.metaKey) { - dispatch("ToggleGrid"); - } - return; - } - // Escape: Deselect / switch to Select tool - if (e.key === "Escape") { - dispatch("DeselectEntity"); - return; - } - } - }, - middleRow, - EditorValidation.view(model, dispatch), - statusBar - ); -} - -// --------------------------------------------------------------------------- -// TEA: subscriptions -// --------------------------------------------------------------------------- - -/// No subscriptions needed for the editor (keyboard is handled inline). -function subscriptions(param) { - return Tea_Sub.none; -} - -// --------------------------------------------------------------------------- -// TEA: program -// --------------------------------------------------------------------------- - -/// Wrap init for Tea.MakeWithDispatch (takes unit, returns init tuple). -function init$1(param) { - return init(); -} - -/// Create the TEA program using rescript-tea's MakeWithDispatch functor. -/// This produces a `make` function that can be used as a React component. -var include = Tea.MakeWithDispatch({ - init: init$1, - update: update, - view: view, - subscriptions: subscriptions -}); - -var make = include.make; - -export { - init, - update, - renderCanvas, - view, - subscriptions, - make, -} -/* include Not a pure module */ diff --git a/idaptik-ums/src/editor/EditorCanvas.res b/idaptik-ums/src/editor/EditorCanvas.res deleted file mode 100644 index 69a999fb..00000000 --- a/idaptik-ums/src/editor/EditorCanvas.res +++ /dev/null @@ -1,352 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Generated by ReScript, PLEASE EDIT WITH CARE -// -// EditorCanvas.res.mjs — Canvas 2D rendering for the IDApTIK UMS level editor. -// -// Author: Jonathan D.A. Jewell -// -// Draws the editor viewport using the HTML5 Canvas 2D API. Builds on the -// rendering patterns established in LevelRender.res.mjs but adapted for the -// editor context: entity selection highlights, grid overlays, zone shading, -// and tool cursor previews. -// -// ## Layer order (back to front) -// -// 1. Background (dark slate) -// 2. Zone overlays (translucent colour bands) -// 3. Grid lines (thin, toggleable) -// 4. Entities (devices, guards, dogs, drones, zone markers) -// 5. Selection highlight (yellow outline on selected entity) -// 6. Cursor preview (ghost entity under the mouse) -// -// All drawing functions take the Canvas 2D context as the first argument -// and are pure (no state mutation beyond the canvas). - -// --------------------------------------------------------------------------- -// Canvas property setters (same %raw pattern as LevelRender) -// --------------------------------------------------------------------------- - -/// Set the fill style on a Canvas 2D context. -function setFill(ctx, color) { - ((function(c, v) { c.fillStyle = v }))(ctx, color); -} - -/// Set the stroke style on a Canvas 2D context. -function setStroke(ctx, color) { - ((function(c, v) { c.strokeStyle = v }))(ctx, color); -} - -/// Set the line width on a Canvas 2D context. -function setLine(ctx, w) { - ((function(c, v) { c.lineWidth = v }))(ctx, w); -} - -/// Set the font property on a Canvas 2D context. -function setFont(ctx, f) { - ((function(c, v) { c.font = v }))(ctx, f); -} - -/// Set the text alignment on a Canvas 2D context. -function setAlign(ctx, a) { - ((function(c, v) { c.textAlign = v }))(ctx, a); -} - -/// Set the text baseline on a Canvas 2D context. -function setBaseline(ctx, b) { - ((function(c, v) { c.textBaseline = v }))(ctx, b); -} - -/// Set the global alpha (opacity) on a Canvas 2D context. -function setGlobalAlpha(ctx, a) { - ((function(c, v) { c.globalAlpha = v }))(ctx, a); -} - -// --------------------------------------------------------------------------- -// Colour palette — matches LevelRender.res.mjs -// --------------------------------------------------------------------------- - -/// Background colour for the editor canvas. Matches LevelRender.bgColor. -var bgColor = "#0f172a"; - -/// Entity type colours. Devices are purple, guards orange, dogs cyan, -/// drones rose, zone markers indigo — consistent with LevelRender. -var entityColors = { - device: "#7c3aed", - guard: "#f97316", - dog: "#22d3ee", - drone: "#f43f5e", - zone_marker: "#6366f1" -}; - -/// Zone overlay colours with alpha for translucent rendering. -/// LAN=blue, DMZ=yellow, SCADA=red — matches LevelRender.zoneColor. -var zoneOverlayColors = { - LAN: "rgba(59, 130, 246, 0.15)", - DMZ: "rgba(234, 179, 8, 0.15)", - SCADA: "rgba(239, 68, 68, 0.15)" -}; - -/// Zone label colours (opaque) for zone name text. -var zoneLabelColors = { - LAN: "#3b82f6", - DMZ: "#eab308", - SCADA: "#ef4444" -}; - -/// Selection highlight colour — bright yellow for visibility. -var selectionColor = "#facc15"; - -/// Grid line colour — subtle slate for non-intrusive overlay. -var gridLineColor = "rgba(148, 163, 184, 0.25)"; - -// --------------------------------------------------------------------------- -// Layer 1: Background -// --------------------------------------------------------------------------- - -/// Draw the dark slate background that fills the entire canvas. -/// This is the bottommost layer, clearing any previous frame. -function drawBackground(ctx, width, height) { - setFill(ctx, bgColor); - ctx.fillRect(0.0, 0.0, width, height); -} - -// --------------------------------------------------------------------------- -// Layer 2: Zone overlays -// --------------------------------------------------------------------------- - -/// Draw translucent colour bands for each zone. -/// Each zone spans its startX-endX columns at full grid height. -/// Zone names are drawn at the top of each band. -function drawZones(ctx, zones, gridH, tileSize, showZones) { - if (!showZones) { - return; - } - zones.forEach(function (zone) { - var px = zone.startX * tileSize; - var width = (zone.endX - zone.startX + 1) * tileSize; - var height = gridH * tileSize; - - // Zone overlay band - var overlayColor = zoneOverlayColors[zone.name] || "rgba(148, 163, 184, 0.1)"; - setFill(ctx, overlayColor); - ctx.fillRect(px, 0, width, height); - - // Zone name label at top-centre - var labelColor = zoneLabelColors[zone.name] || "#94a3b8"; - setFill(ctx, labelColor); - setFont(ctx, "bold " + String(tileSize * 0.45) + "px sans-serif"); - setAlign(ctx, "center"); - setBaseline(ctx, "top"); - ctx.fillText(zone.name, px + width / 2.0, 4.0); - - // Security tier label at bottom - setFont(ctx, String(tileSize * 0.35) + "px sans-serif"); - setBaseline(ctx, "bottom"); - ctx.fillText(zone.securityTier, px + width / 2.0, height - 4.0); - }); -} - -// --------------------------------------------------------------------------- -// Layer 3: Grid lines -// --------------------------------------------------------------------------- - -/// Draw the grid overlay: thin lines at each tile boundary. -/// Only rendered when showGrid is true. Lines use subtle slate colour. -function drawGrid(ctx, gridW, gridH, tileSize, showGrid) { - if (!showGrid) { - return; - } - setStroke(ctx, gridLineColor); - setLine(ctx, 0.5); - - var totalW = gridW * tileSize; - var totalH = gridH * tileSize; - - // Vertical lines - for (var x = 0; x <= gridW; x++) { - var px = x * tileSize; - ctx.beginPath(); - ctx.moveTo(px, 0); - ctx.lineTo(px, totalH); - ctx.stroke(); - } - - // Horizontal lines - for (var y = 0; y <= gridH; y++) { - var py = y * tileSize; - ctx.beginPath(); - ctx.moveTo(0, py); - ctx.lineTo(totalW, py); - ctx.stroke(); - } -} - -// --------------------------------------------------------------------------- -// Layer 4: Entities -// --------------------------------------------------------------------------- - -/// Get the colour for an entity based on its kind tag. -/// Falls back to slate grey for unknown kinds. -function entityColor(entity) { - if (typeof entity.kind === "string") { - return "#94a3b8"; - } - switch (entity.kind.TAG) { - case "Device": return entityColors.device; - case "Guard": return entityColors.guard; - case "Dog": return entityColors.dog; - case "Drone": return entityColors.drone; - case "ZoneMarker": return entityColors.zone_marker; - default: return "#94a3b8"; - } -} - -/// Get a single-character abbreviation for an entity's subtype. -/// Used as the text label inside the entity's shape. -function entityAbbrev(entity) { - if (typeof entity.kind === "string") { - return "?"; - } - return entity.kind._0.slice(0, 1); -} - -/// Get the shape type for an entity kind. -/// Devices are circles, guards are diamonds, dogs are triangles, -/// drones are stars (pentagons), zone markers are squares. -function entityShape(entity) { - if (typeof entity.kind === "string") { - return "circle"; - } - switch (entity.kind.TAG) { - case "Device": return "circle"; - case "Guard": return "diamond"; - case "Dog": return "triangle"; - case "Drone": return "pentagon"; - case "ZoneMarker": return "square"; - default: return "circle"; - } -} - -/// Draw a single entity on the canvas. -/// Renders the entity's shape (filled), its abbreviation letter, -/// and an optional selection highlight ring. -function drawEntity(ctx, entity, tileSize, isSelected) { - var cx = entity.x * tileSize + tileSize / 2.0; - var cy = entity.y * tileSize + tileSize / 2.0; - var radius = tileSize * 0.35; - var color = entityColor(entity); - var shape = entityShape(entity); - - // Draw shape based on entity type - ctx.beginPath(); - switch (shape) { - case "circle": - ctx.arc(cx, cy, radius, 0.0, 2.0 * Math.PI); - break; - case "diamond": - ctx.moveTo(cx, cy - radius); - ctx.lineTo(cx + radius, cy); - ctx.lineTo(cx, cy + radius); - ctx.lineTo(cx - radius, cy); - break; - case "triangle": - ctx.moveTo(cx, cy - radius); - ctx.lineTo(cx + radius, cy + radius * 0.7); - ctx.lineTo(cx - radius, cy + radius * 0.7); - break; - case "pentagon": - for (var i = 0; i < 5; i++) { - var angle = (i * 2.0 * Math.PI / 5.0) - Math.PI / 2.0; - var px = cx + radius * Math.cos(angle); - var py = cy + radius * Math.sin(angle); - if (i === 0) { - ctx.moveTo(px, py); - } else { - ctx.lineTo(px, py); - } - } - break; - case "square": - ctx.rect(cx - radius, cy - radius, radius * 2, radius * 2); - break; - default: - ctx.arc(cx, cy, radius, 0.0, 2.0 * Math.PI); - } - ctx.closePath(); - setFill(ctx, color); - ctx.fill(); - - // Draw abbreviation letter - var abbrev = entityAbbrev(entity); - setFill(ctx, "#ffffff"); - setFont(ctx, "bold " + String(tileSize * 0.38) + "px monospace"); - setAlign(ctx, "center"); - setBaseline(ctx, "middle"); - ctx.fillText(abbrev, cx, cy); - - // Selection highlight ring - if (isSelected) { - ctx.beginPath(); - ctx.arc(cx, cy, radius + 3.0, 0.0, 2.0 * Math.PI); - setStroke(ctx, selectionColor); - setLine(ctx, 2.5); - ctx.stroke(); - } -} - -/// Draw all entities in the entity list. -/// The selected entity gets a highlight ring. -function drawEntities(ctx, entities, tileSize, selectedId) { - entities.forEach(function (entity) { - var isSelected = selectedId !== undefined && entity.id === selectedId; - drawEntity(ctx, entity, tileSize, isSelected); - }); -} - -// --------------------------------------------------------------------------- -// Composite render -// --------------------------------------------------------------------------- - -/// Render the complete editor canvas by composing all layers. -/// This is the main entry point called on each frame/state change. -/// -/// Layer order: -/// 1. Background -/// 2. Zone overlays -/// 3. Grid lines -/// 4. Entities with selection highlight -function render(ctx, state) { - var totalW = state.gridWidth * state.tileSize; - var totalH = state.gridHeight * state.tileSize; - - drawBackground(ctx, totalW, totalH); - drawZones(ctx, state.zones, state.gridHeight, state.tileSize, state.showZones); - drawGrid(ctx, state.gridWidth, state.gridHeight, state.tileSize, state.showGrid); - drawEntities(ctx, state.entities, state.tileSize, state.selectedEntityId); -} - -export { - setFill, - setStroke, - setLine, - setFont, - setAlign, - setBaseline, - setGlobalAlpha, - bgColor, - entityColors, - zoneOverlayColors, - zoneLabelColors, - selectionColor, - gridLineColor, - drawBackground, - drawZones, - drawGrid, - entityColor, - entityAbbrev, - entityShape, - drawEntity, - drawEntities, - render, -} -/* No side effect */ diff --git a/idaptik-ums/src/editor/EditorCanvasPixi.res b/idaptik-ums/src/editor/EditorCanvasPixi.res deleted file mode 100644 index e8c984d8..00000000 --- a/idaptik-ums/src/editor/EditorCanvasPixi.res +++ /dev/null @@ -1,716 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Generated by ReScript, PLEASE EDIT WITH CARE -// -// EditorCanvasPixi.res.mjs — PixiJS WebGL-based renderer for the IDApTIK UMS -// level editor. Drop-in replacement for the Canvas 2D EditorCanvas.res. -// -// Author: Jonathan D.A. Jewell -// -// Renders the editor viewport using PixiJS 8.x with a WebGL backend. Provides -// the same visual layers as EditorCanvas but with hardware-accelerated graphics, -// built-in hit testing, and interactive entity manipulation (drag-to-move, -// click-to-select, zoom/pan). -// -// ## Layer order (PixiJS Container children, back to front) -// -// 1. backgroundLayer — Dark slate fill (Graphics rectangle) -// 2. zoneLayer — Translucent zone bands + zone name labels -// 3. gridLayer — Toggleable thin-line grid overlay -// 4. entityLayer — Interactive entity shapes with sprites/graphics -// 5. selectionLayer — Yellow highlight ring on the selected entity -// 6. cursorLayer — Ghost preview entity under the mouse (future) -// -// ## Interaction model -// -// - Click-to-select: PixiJS eventMode="static" hit testing on entities -// - Click-to-place: When a placement tool is active, canvas click places -// a new entity at the cursor's grid position -// - Drag-to-move: mousedown on selected entity starts drag, mousemove -// updates position, mouseup commits the move -// - Zoom/pan: Mouse wheel scales the stage Container; middle-click -// drag translates the stage for panning -// - Undo/redo: Each action (place, move, delete) pushes to the undo -// stack via EditorEngine.pushUndo before mutation -// -// ## Dependencies -// -// - pixi.js 8.8.1 (already in project via Deno/node_modules) -// - EditorModel (type definitions) -// - EditorEngine (entity CRUD, coordinate transforms, undo stack) - -import { Application, Container, Graphics, Text, TextStyle } from "pixi.js"; -import * as EditorEngine from "./EditorEngine.res.mjs"; - -// --------------------------------------------------------------------------- -// Colour palette — matches EditorCanvas.res and LevelRender -// --------------------------------------------------------------------------- - -/// Background colour for the editor canvas. -var bgColor = 0x0f172a; - -/// Entity type colours as hex integers for PixiJS Graphics. -var entityColors = { - device: 0x7c3aed, - guard: 0xf97316, - dog: 0x22d3ee, - drone: 0xf43f5e, - zone_marker: 0x6366f1 -}; - -/// Zone overlay colours as hex integers with separate alpha values. -var zoneOverlayColors = { - LAN: { color: 0x3b82f6, alpha: 0.15 }, - DMZ: { color: 0xeab308, alpha: 0.15 }, - SCADA: { color: 0xef4444, alpha: 0.15 } -}; - -/// Zone label colours (opaque) for zone name text. -var zoneLabelColors = { - LAN: 0x3b82f6, - DMZ: 0xeab308, - SCADA: 0xef4444 -}; - -/// Selection highlight colour — bright yellow for visibility. -var selectionColor = 0xfacc15; - -/// Grid line colour as hex integer. -var gridLineColor = 0x94a3b8; - -/// Grid line alpha (subtle). -var gridLineAlpha = 0.25; - -/// Fallback zone overlay for unknown zone types. -var fallbackZoneColor = { color: 0x94a3b8, alpha: 0.1 }; - -/// Fallback zone label colour for unknown zone types. -var fallbackLabelColor = 0x94a3b8; - -// --------------------------------------------------------------------------- -// PixiJS Application lifecycle -// --------------------------------------------------------------------------- - -/// Create and initialise the PixiJS Application. -/// -/// Mounts the WebGL canvas into the given container element. Returns an -/// object containing the app, stage layers, and interaction state so the -/// caller can drive rendering and respond to editor state changes. -/// -/// @param containerEl — DOM element to append the PixiJS canvas to. -/// @param width — Canvas width in pixels. -/// @param height — Canvas height in pixels. -/// @returns An editorPixi handle with all layer containers and state. -async function createEditorApp(containerEl, width, height) { - var app = new Application(); - - await app.init({ - width: width, - height: height, - backgroundColor: bgColor, - antialias: true, - resolution: window.devicePixelRatio || 1, - autoDensity: true - }); - - // Append the PixiJS-generated canvas to the container element. - containerEl.appendChild(app.canvas); - - // -- Layer containers (added in draw order) -------------------------------- - - var backgroundLayer = new Container(); - backgroundLayer.label = "backgroundLayer"; - app.stage.addChild(backgroundLayer); - - var zoneLayer = new Container(); - zoneLayer.label = "zoneLayer"; - app.stage.addChild(zoneLayer); - - var gridLayer = new Container(); - gridLayer.label = "gridLayer"; - app.stage.addChild(gridLayer); - - var entityLayer = new Container(); - entityLayer.label = "entityLayer"; - app.stage.addChild(entityLayer); - - var selectionLayer = new Container(); - selectionLayer.label = "selectionLayer"; - app.stage.addChild(selectionLayer); - - var cursorLayer = new Container(); - cursorLayer.label = "cursorLayer"; - app.stage.addChild(cursorLayer); - - // -- Interaction state ----------------------------------------------------- - - var handle = { - app: app, - backgroundLayer: backgroundLayer, - zoneLayer: zoneLayer, - gridLayer: gridLayer, - entityLayer: entityLayer, - selectionLayer: selectionLayer, - cursorLayer: cursorLayer, - // Entity Graphics keyed by entity ID for efficient updates. - entityGraphicsMap: {}, - // Zoom/pan state - zoom: 1.0, - panX: 0.0, - panY: 0.0, - // Drag state - isDragging: false, - dragEntityId: undefined, - dragStartX: 0, - dragStartY: 0, - // Middle-button pan state - isPanning: false, - panStartMouseX: 0, - panStartMouseY: 0, - panStartStageX: 0, - panStartStageY: 0, - // Dispatch callback (set by the caller after creation) - dispatch: undefined, - // Current editor state reference (updated on each render call) - currentState: undefined - }; - - // -- Zoom via mouse wheel -------------------------------------------------- - - app.canvas.addEventListener("wheel", function (e) { - e.preventDefault(); - var scaleFactor = e.deltaY < 0 ? 1.1 : 0.9; - handle.zoom = Math.max(0.25, Math.min(4.0, handle.zoom * scaleFactor)); - applyTransform(handle); - }, { passive: false }); - - // -- Middle-click pan ------------------------------------------------------ - - app.canvas.addEventListener("pointerdown", function (e) { - // Button 1 = middle mouse button - if (e.button === 1) { - e.preventDefault(); - handle.isPanning = true; - handle.panStartMouseX = e.clientX; - handle.panStartMouseY = e.clientY; - handle.panStartStageX = handle.panX; - handle.panStartStageY = handle.panY; - } - }); - - app.canvas.addEventListener("pointermove", function (e) { - if (handle.isPanning) { - var dx = e.clientX - handle.panStartMouseX; - var dy = e.clientY - handle.panStartMouseY; - handle.panX = handle.panStartStageX + dx; - handle.panY = handle.panStartStageY + dy; - applyTransform(handle); - } - }); - - app.canvas.addEventListener("pointerup", function (e) { - if (e.button === 1) { - handle.isPanning = false; - } - }); - - return handle; -} - -/// Apply the current zoom and pan transformation to the stage. -/// -/// Translates the stage position by (panX, panY) and scales uniformly by -/// the zoom factor. Called after any zoom or pan change. -/// -/// @param handle — The editorPixi handle. -function applyTransform(handle) { - handle.app.stage.scale.set(handle.zoom, handle.zoom); - handle.app.stage.position.set(handle.panX, handle.panY); -} - -// --------------------------------------------------------------------------- -// Layer 1: Background -// --------------------------------------------------------------------------- - -/// Draw the dark slate background rectangle. -/// -/// Clears and redraws a single filled rectangle covering the full grid area. -/// This is the bottommost visual layer. -/// -/// @param handle — The editorPixi handle. -/// @param gridW — Grid width in tiles. -/// @param gridH — Grid height in tiles. -/// @param tileSize — Tile size in pixels. -function drawBackground(handle, gridW, gridH, tileSize) { - handle.backgroundLayer.removeChildren(); - var gfx = new Graphics(); - gfx.rect(0, 0, gridW * tileSize, gridH * tileSize); - gfx.fill({ color: bgColor }); - handle.backgroundLayer.addChild(gfx); -} - -// --------------------------------------------------------------------------- -// Layer 2: Zone overlays -// --------------------------------------------------------------------------- - -/// Draw translucent zone bands and zone name labels. -/// -/// Each zone is rendered as a filled rectangle spanning its column range at -/// full grid height. The zone name is drawn centred at the top of the band, -/// and the security tier label is drawn at the bottom. -/// -/// @param handle — The editorPixi handle. -/// @param zones — Array of zone objects { name, startX, endX, securityTier }. -/// @param gridH — Grid height in tiles. -/// @param tileSize — Tile size in pixels. -/// @param showZones — Whether zone overlays are visible. -function drawZones(handle, zones, gridH, tileSize, showZones) { - handle.zoneLayer.removeChildren(); - if (!showZones) { - return; - } - - zones.forEach(function (zone) { - var px = zone.startX * tileSize; - var w = (zone.endX - zone.startX + 1) * tileSize; - var h = gridH * tileSize; - - // Zone overlay band - var overlay = zoneOverlayColors[zone.name] || fallbackZoneColor; - var gfx = new Graphics(); - gfx.rect(px, 0, w, h); - gfx.fill({ color: overlay.color, alpha: overlay.alpha }); - handle.zoneLayer.addChild(gfx); - - // Zone name label at top-centre - var labelColor = zoneLabelColors[zone.name] || fallbackLabelColor; - var labelStyle = new TextStyle({ - fontFamily: "sans-serif", - fontSize: tileSize * 0.45, - fontWeight: "bold", - fill: labelColor - }); - var label = new Text({ text: zone.name, style: labelStyle }); - label.anchor.set(0.5, 0); - label.position.set(px + w / 2.0, 4.0); - handle.zoneLayer.addChild(label); - - // Security tier label at bottom-centre - var tierStyle = new TextStyle({ - fontFamily: "sans-serif", - fontSize: tileSize * 0.35, - fill: labelColor - }); - var tierLabel = new Text({ text: zone.securityTier, style: tierStyle }); - tierLabel.anchor.set(0.5, 1); - tierLabel.position.set(px + w / 2.0, h - 4.0); - handle.zoneLayer.addChild(tierLabel); - }); -} - -// --------------------------------------------------------------------------- -// Layer 3: Grid lines -// --------------------------------------------------------------------------- - -/// Draw the grid overlay: thin lines at each tile boundary. -/// -/// Only rendered when showGrid is true. Lines use subtle slate colour with -/// low alpha for non-intrusive appearance. -/// -/// @param handle — The editorPixi handle. -/// @param gridW — Grid width in tiles. -/// @param gridH — Grid height in tiles. -/// @param tileSize — Tile size in pixels. -/// @param showGrid — Whether the grid is visible. -function drawGrid(handle, gridW, gridH, tileSize, showGrid) { - handle.gridLayer.removeChildren(); - if (!showGrid) { - return; - } - - var gfx = new Graphics(); - var totalW = gridW * tileSize; - var totalH = gridH * tileSize; - - // Vertical lines - for (var x = 0; x <= gridW; x++) { - var px = x * tileSize; - gfx.moveTo(px, 0); - gfx.lineTo(px, totalH); - } - - // Horizontal lines - for (var y = 0; y <= gridH; y++) { - var py = y * tileSize; - gfx.moveTo(0, py); - gfx.lineTo(totalW, py); - } - - gfx.stroke({ width: 0.5, color: gridLineColor, alpha: gridLineAlpha }); - handle.gridLayer.addChild(gfx); -} - -// --------------------------------------------------------------------------- -// Layer 4: Entities -// --------------------------------------------------------------------------- - -/// Get the PixiJS hex colour for an entity based on its kind tag. -/// -/// Falls back to slate grey (0x94a3b8) for unknown entity kinds. -/// -/// @param entity — An editor entity object with a `kind` property. -/// @returns Hex colour integer. -function entityColor(entity) { - if (typeof entity.kind === "string") { - return 0x94a3b8; - } - switch (entity.kind.TAG) { - case "Device": return entityColors.device; - case "Guard": return entityColors.guard; - case "Dog": return entityColors.dog; - case "Drone": return entityColors.drone; - case "ZoneMarker": return entityColors.zone_marker; - default: return 0x94a3b8; - } -} - -/// Get a single-character abbreviation for an entity's subtype. -/// -/// Used as the text label inside the entity's shape on the canvas. -/// -/// @param entity — An editor entity object with a `kind` property. -/// @returns Single character string. -function entityAbbrev(entity) { - if (typeof entity.kind === "string") { - return "?"; - } - return entity.kind._0.slice(0, 1); -} - -/// Draw a single entity shape using PixiJS Graphics. -/// -/// Creates a Graphics object for the entity's shape (circle, diamond, -/// triangle, pentagon, or square), fills it with the entity's type colour, -/// overlays the abbreviation letter, and makes the shape interactive for -/// click-to-select and drag-to-move. -/// -/// @param handle — The editorPixi handle. -/// @param entity — The editor entity to draw. -/// @param tileSize — Tile size in pixels. -/// @returns The created Graphics object (also added to entityLayer). -function drawEntity(handle, entity, tileSize) { - var cx = entity.x * tileSize + tileSize / 2.0; - var cy = entity.y * tileSize + tileSize / 2.0; - var radius = tileSize * 0.35; - var color = entityColor(entity); - - var gfx = new Graphics(); - - // Determine shape based on entity kind - var shapeTag = "circle"; - if (typeof entity.kind !== "string") { - switch (entity.kind.TAG) { - case "Device": shapeTag = "circle"; break; - case "Guard": shapeTag = "diamond"; break; - case "Dog": shapeTag = "triangle"; break; - case "Drone": shapeTag = "pentagon"; break; - case "ZoneMarker": shapeTag = "square"; break; - default: shapeTag = "circle"; - } - } - - // Draw the shape - switch (shapeTag) { - case "circle": - gfx.circle(cx, cy, radius); - break; - case "diamond": - gfx.moveTo(cx, cy - radius); - gfx.lineTo(cx + radius, cy); - gfx.lineTo(cx, cy + radius); - gfx.lineTo(cx - radius, cy); - gfx.closePath(); - break; - case "triangle": - gfx.moveTo(cx, cy - radius); - gfx.lineTo(cx + radius, cy + radius * 0.7); - gfx.lineTo(cx - radius, cy + radius * 0.7); - gfx.closePath(); - break; - case "pentagon": - for (var i = 0; i < 5; i++) { - var angle = (i * 2.0 * Math.PI / 5.0) - Math.PI / 2.0; - var px = cx + radius * Math.cos(angle); - var py = cy + radius * Math.sin(angle); - if (i === 0) { - gfx.moveTo(px, py); - } else { - gfx.lineTo(px, py); - } - } - gfx.closePath(); - break; - case "square": - gfx.rect(cx - radius, cy - radius, radius * 2, radius * 2); - break; - default: - gfx.circle(cx, cy, radius); - } - - gfx.fill({ color: color }); - - // -- Abbreviation letter --------------------------------------------------- - - var abbrev = entityAbbrev(entity); - var labelStyle = new TextStyle({ - fontFamily: "monospace", - fontSize: tileSize * 0.38, - fontWeight: "bold", - fill: 0xffffff - }); - var label = new Text({ text: abbrev, style: labelStyle }); - label.anchor.set(0.5, 0.5); - label.position.set(cx, cy); - - // Wrap the shape and label in a container for unified hit testing. - var entityContainer = new Container(); - entityContainer.addChild(gfx); - entityContainer.addChild(label); - - // -- Interaction: click-to-select ------------------------------------------ - - entityContainer.eventMode = "static"; - entityContainer.cursor = "pointer"; - - // Store entity ID on the container for retrieval in event handlers. - entityContainer._entityId = entity.id; - - entityContainer.on("pointerdown", function (e) { - // Prevent middle-button pan from triggering entity select - if (e.data && e.data.button === 1) { - return; - } - if (handle.dispatch) { - // Select the entity - handle.dispatch({ TAG: "SelectEntity", _0: entity.id }); - - // Begin drag if the entity is already selected or just got selected - handle.isDragging = true; - handle.dragEntityId = entity.id; - handle.dragStartX = entity.x; - handle.dragStartY = entity.y; - } - }); - - entityContainer.on("pointerup", function () { - if (handle.isDragging && handle.dragEntityId === entity.id) { - handle.isDragging = false; - handle.dragEntityId = undefined; - } - }); - - entityContainer.on("pointerupoutside", function () { - if (handle.isDragging && handle.dragEntityId === entity.id) { - handle.isDragging = false; - handle.dragEntityId = undefined; - } - }); - - entityContainer.on("globalpointermove", function (e) { - if (!handle.isDragging || handle.dragEntityId !== entity.id) { - return; - } - if (!handle.currentState || !handle.dispatch) { - return; - } - - // Convert global pointer position to grid coordinates, accounting - // for the current zoom and pan transformation. - var localPos = handle.app.stage.toLocal(e.global); - var tileSize = handle.currentState.tileSize; - var gx = Math.floor(localPos.x / tileSize); - var gy = Math.floor(localPos.y / tileSize); - - // Clamp to grid bounds - gx = Math.max(0, Math.min(handle.currentState.gridWidth - 1, gx)); - gy = Math.max(0, Math.min(handle.currentState.gridHeight - 1, gy)); - - // Only dispatch move if position actually changed - if (gx !== entity.x || gy !== entity.y) { - // Push undo before the move - handle.dispatch({ TAG: "MoveEntity", _0: entity.id, _1: gx, _2: gy }); - } - }); - - handle.entityLayer.addChild(entityContainer); - handle.entityGraphicsMap[entity.id] = entityContainer; - - return entityContainer; -} - -/// Draw all entities in the entity list. -/// -/// Clears the entity layer and redraws every entity. The selected entity -/// gets an additional highlight ring drawn in the selection layer. -/// -/// @param handle — The editorPixi handle. -/// @param entities — Array of editor entity objects. -/// @param tileSize — Tile size in pixels. -/// @param selectedId — ID of the currently selected entity, or undefined. -function drawEntities(handle, entities, tileSize, selectedId) { - handle.entityLayer.removeChildren(); - handle.selectionLayer.removeChildren(); - handle.entityGraphicsMap = {}; - - entities.forEach(function (entity) { - drawEntity(handle, entity, tileSize); - - // Draw selection highlight ring if this entity is selected - if (selectedId !== undefined && entity.id === selectedId) { - drawSelectionHighlight(handle, entity, tileSize); - } - }); -} - -/// Draw the yellow selection highlight ring around the selected entity. -/// -/// Renders a circle stroke in bright yellow around the entity's centre -/// position, slightly larger than the entity shape radius. -/// -/// @param handle — The editorPixi handle. -/// @param entity — The selected entity. -/// @param tileSize — Tile size in pixels. -function drawSelectionHighlight(handle, entity, tileSize) { - var cx = entity.x * tileSize + tileSize / 2.0; - var cy = entity.y * tileSize + tileSize / 2.0; - var radius = tileSize * 0.35 + 3.0; - - var gfx = new Graphics(); - gfx.circle(cx, cy, radius); - gfx.stroke({ width: 2.5, color: selectionColor }); - handle.selectionLayer.addChild(gfx); -} - -// --------------------------------------------------------------------------- -// Click-to-place handler (for canvas background clicks) -// --------------------------------------------------------------------------- - -/// Set up click-to-place interaction on the stage background. -/// -/// When the active tool is a placement tool and the user clicks on an empty -/// grid cell (not on an entity), this handler dispatches a CanvasClick -/// message to place a new entity at the clicked grid position. -/// -/// @param handle — The editorPixi handle. -function setupClickToPlace(handle) { - // Make the background layer interactive so clicks on empty space are caught. - handle.backgroundLayer.eventMode = "static"; - handle.backgroundLayer.on("pointerdown", function (e) { - // Ignore middle-button (pan) and right-button (context menu) - if (e.data && (e.data.button === 1 || e.data.button === 2)) { - return; - } - if (!handle.currentState || !handle.dispatch) { - return; - } - - var localPos = handle.app.stage.toLocal(e.global); - var tileSize = handle.currentState.tileSize; - var gx = Math.floor(localPos.x / tileSize); - var gy = Math.floor(localPos.y / tileSize); - - // Dispatch as a standard CanvasClick so the TEA update function handles - // tool-specific logic (placement, select, erase). - handle.dispatch({ TAG: "CanvasClick", _0: gx, _1: gy }); - }); -} - -// --------------------------------------------------------------------------- -// Composite render -// --------------------------------------------------------------------------- - -/// Render the complete editor canvas by composing all PixiJS layers. -/// -/// This is the main entry point called on each editor state change. It -/// redraws all layers from scratch (the PixiJS scene graph is rebuilt on -/// each call for simplicity and correctness). -/// -/// @param handle — The editorPixi handle returned by createEditorApp. -/// @param state — The current editorState from EditorModel. -function render(handle, state) { - handle.currentState = state; - - drawBackground(handle, state.gridWidth, state.gridHeight, state.tileSize); - drawZones(handle, state.zones, state.gridHeight, state.tileSize, state.showZones); - drawGrid(handle, state.gridWidth, state.gridHeight, state.tileSize, state.showGrid); - drawEntities(handle, state.entities, state.tileSize, state.selectedEntityId); -} - -// --------------------------------------------------------------------------- -// Resize -// --------------------------------------------------------------------------- - -/// Resize the PixiJS renderer to match new grid dimensions. -/// -/// Call this when the grid size or tile size changes in the editor state. -/// -/// @param handle — The editorPixi handle. -/// @param width — New canvas width in pixels. -/// @param height — New canvas height in pixels. -function resize(handle, width, height) { - handle.app.renderer.resize(width, height); -} - -// --------------------------------------------------------------------------- -// Cleanup -// --------------------------------------------------------------------------- - -/// Destroy the PixiJS Application and release all GPU resources. -/// -/// Call this when the editor component unmounts to prevent memory leaks. -/// -/// @param handle — The editorPixi handle. -function destroy(handle) { - handle.app.destroy(true, { children: true, texture: true }); -} - -// --------------------------------------------------------------------------- -// Reset zoom/pan -// --------------------------------------------------------------------------- - -/// Reset zoom to 1.0 and pan to (0, 0). -/// -/// Useful for a "Reset View" button in the toolbar. -/// -/// @param handle — The editorPixi handle. -function resetView(handle) { - handle.zoom = 1.0; - handle.panX = 0.0; - handle.panY = 0.0; - applyTransform(handle); -} - -export { - bgColor, - entityColors, - zoneOverlayColors, - zoneLabelColors, - selectionColor, - gridLineColor, - gridLineAlpha, - createEditorApp, - applyTransform, - drawBackground, - drawZones, - drawGrid, - entityColor, - entityAbbrev, - drawEntity, - drawEntities, - drawSelectionHighlight, - setupClickToPlace, - render, - resize, - destroy, - resetView, -} -/* No side effect */ diff --git a/idaptik-ums/src/editor/EditorCmd.res b/idaptik-ums/src/editor/EditorCmd.res deleted file mode 100644 index 4a5aec51..00000000 --- a/idaptik-ums/src/editor/EditorCmd.res +++ /dev/null @@ -1,223 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Generated by ReScript, PLEASE EDIT WITH CARE -// -// EditorCmd.res.mjs — Gossamer IPC command wrappers for the IDApTIK UMS editor. -// -// Author: Jonathan D.A. Jewell -// -// Wraps the six Gossamer shell commands as async functions that produce TEA -// messages via a tagger callback. This module isolates all Gossamer API -// imports so the rest of the editor can be tested without a Gossamer runtime. -// -// ## Commands -// -// | ReScript function | Gossamer command | Purpose | -// |-----------------------|------------------------|---------------------------------| -// | loadLevel | load_level | Read level JSON from disk | -// | saveLevel | save_level | Write level JSON to disk | -// | validateLevelAbi | validate_level_abi | Run 5 ABI proof checks | -// | listLevels | list_levels | List saved level files | -// | exportLevelConfig | export_level_config | Export as ReScript module | -// | getSystemInfo | get_system_info | System info for About dialog | -// -// Each wrapper returns a `Tea_Cmd.t` that dispatches the tagger on success -// or an error tagger on failure. - -import * as Tea_Cmd from "rescript-tea/src/Tea_Cmd.res.mjs"; - -// --------------------------------------------------------------------------- -// Gossamer invoke binding -// --------------------------------------------------------------------------- - -/// Call the Gossamer IPC invoke API. -/// Uses window.gossamer, window.__GOSSAMER__, or window.__gossamer_invoke -/// for command dispatch. Falls back to a no-op if running outside the -/// Gossamer shell (e.g. in tests or standalone browser mode). -var invokeRaw = (function() { - try { - if (typeof window !== "undefined") { - return function(cmd, args) { - var payload = typeof args === "string" ? args : JSON.stringify(args); - if (window.gossamer && typeof window.gossamer[cmd] === "function") { - return window.gossamer[cmd](payload); - } - if (window.__GOSSAMER__ && typeof window.__GOSSAMER__[cmd] === "function") { - return window.__GOSSAMER__[cmd](payload); - } - if (window.__gossamer_invoke) { - return window.__gossamer_invoke(cmd, payload); - } - return Promise.reject("Gossamer command not found: " + cmd); - }; - } - return function(cmd, args) { - return Promise.reject("Gossamer runtime not available"); - }; - } catch (e) { - return function(cmd, args) { - return Promise.reject("Gossamer runtime not available"); - }; - } -})(); - -// --------------------------------------------------------------------------- -// Command: load_level -// --------------------------------------------------------------------------- - -/// Load a level from disk by name or absolute path. -/// -/// Calls `load_level` on the Gossamer backend and dispatches the tagger with -/// the parsed level JSON on success, or the errorTagger with a string -/// error message on failure. -function loadLevel(name, tagger, errorTagger) { - return Tea_Cmd.call(function (callbacks) { - invokeRaw("load_level", { path: name }).then( - function (result) { - if (callbacks.enqueue) { - callbacks.enqueue(tagger(result)); - } - }, - function (err) { - if (callbacks.enqueue) { - callbacks.enqueue(errorTagger(String(err))); - } - } - ); - }); -} - -// --------------------------------------------------------------------------- -// Command: save_level -// --------------------------------------------------------------------------- - -/// Save level data to disk. -/// -/// Calls `save_level` on the Gossamer backend with the complete level JSON. -/// On success, dispatches the tagger with the file path where the level -/// was saved. On failure, dispatches the errorTagger with the error string. -function saveLevel(levelData, tagger, errorTagger) { - return Tea_Cmd.call(function (callbacks) { - invokeRaw("save_level", { level: levelData }).then( - function (result) { - if (callbacks.enqueue) { - callbacks.enqueue(tagger(String(result))); - } - }, - function (err) { - if (callbacks.enqueue) { - callbacks.enqueue(errorTagger(String(err))); - } - } - ); - }); -} - -// --------------------------------------------------------------------------- -// Command: validate_level_abi -// --------------------------------------------------------------------------- - -/// Validate level data against the five ABI proof conditions. -/// -/// Calls `validate_level_abi` on the Gossamer backend with the level data -/// serialised as a JSON string. -function validateLevelAbi(levelDataJson, tagger, errorTagger) { - return Tea_Cmd.call(function (callbacks) { - invokeRaw("validate_level_abi", { level: levelDataJson }).then( - function (result) { - if (callbacks.enqueue) { - callbacks.enqueue(tagger(result)); - } - }, - function (err) { - if (callbacks.enqueue) { - callbacks.enqueue(errorTagger(String(err))); - } - } - ); - }); -} - -// --------------------------------------------------------------------------- -// Command: list_levels -// --------------------------------------------------------------------------- - -/// List all saved level files in the levels directory. -/// -/// Calls `list_levels` on the Gossamer backend. On success, dispatches the -/// tagger with an array of `{ name, path }` objects. -function listLevels(tagger, errorTagger) { - return Tea_Cmd.call(function (callbacks) { - invokeRaw("list_levels", {}).then( - function (result) { - if (callbacks.enqueue) { - callbacks.enqueue(tagger(result)); - } - }, - function (err) { - if (callbacks.enqueue) { - callbacks.enqueue(errorTagger(String(err))); - } - } - ); - }); -} - -// --------------------------------------------------------------------------- -// Command: export_level_config -// --------------------------------------------------------------------------- - -/// Export the level as a ReScript LevelConfig module source string. -/// -/// Calls `export_level_config` on the Gossamer backend with the level data -/// serialised as a JSON string. -function exportLevelConfig(levelDataJson, tagger, errorTagger) { - return Tea_Cmd.call(function (callbacks) { - invokeRaw("export_level_config", { level: levelDataJson }).then( - function (result) { - if (callbacks.enqueue) { - callbacks.enqueue(tagger(String(result))); - } - }, - function (err) { - if (callbacks.enqueue) { - callbacks.enqueue(errorTagger(String(err))); - } - } - ); - }); -} - -// --------------------------------------------------------------------------- -// Command: get_system_info -// --------------------------------------------------------------------------- - -/// Retrieve basic system information for the About dialog. -/// -/// Calls `get_system_info` on the Gossamer backend. -function getSystemInfo(tagger, errorTagger) { - return Tea_Cmd.call(function (callbacks) { - invokeRaw("get_system_info", {}).then( - function (result) { - if (callbacks.enqueue) { - callbacks.enqueue(tagger(result)); - } - }, - function (err) { - if (callbacks.enqueue) { - callbacks.enqueue(errorTagger(String(err))); - } - } - ); - }); -} - -export { - invokeRaw, - loadLevel, - saveLevel, - validateLevelAbi, - listLevels, - exportLevelConfig, - getSystemInfo, -} -/* No side effect */ diff --git a/idaptik-ums/src/editor/EditorEngine.res b/idaptik-ums/src/editor/EditorEngine.res deleted file mode 100644 index 6fe9559b..00000000 --- a/idaptik-ums/src/editor/EditorEngine.res +++ /dev/null @@ -1,575 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Generated by ReScript, PLEASE EDIT WITH CARE -// -// EditorEngine.res.mjs — Pure computation helpers for the IDApTIK UMS editor. -// -// Author: Jonathan D.A. Jewell -// -// This module contains all pure (side-effect-free) functions used by the editor: -// tool metadata, entity CRUD, coordinate transformations, zone lookups, -// serialisation, and undo/redo stack management. -// -// All functions here are deterministic and testable in isolation. Canvas -// rendering and Gossamer IPC live in separate modules (EditorCanvas, EditorCmd). - -import * as EditorModel from "./EditorModel.res.mjs"; - -// --------------------------------------------------------------------------- -// Tool metadata -// --------------------------------------------------------------------------- - -/// Human-readable label for each editor tool. -/// Used in the toolbar buttons and status bar. -function toolLabel(tool) { - if (typeof tool === "string") { - switch (tool) { - case "Select": - return "Select"; - case "Erase": - return "Erase"; - case "Pan": - return "Pan"; - default: - return tool; - } - } - switch (tool.TAG) { - case "PlaceDevice": - return "Device: " + tool._0; - case "PlaceGuard": - return "Guard: " + tool._0; - case "PlaceZone": - return "Zone: " + tool._0; - case "PlaceDog": - return "Dog: " + tool._0; - case "PlaceDrone": - return "Drone: " + tool._0; - default: - return "Tool"; - } -} - -/// Single-character icon for each tool category. -/// Rendered in the toolbar buttons as a visual shorthand. -function toolIcon(tool) { - if (typeof tool === "string") { - switch (tool) { - case "Select": - return "\u2190"; // ← - case "Erase": - return "\u2717"; // ✗ - case "Pan": - return "\u2725"; // ✥ - default: - return "?"; - } - } - switch (tool.TAG) { - case "PlaceDevice": - return "\u25CF"; // ● - case "PlaceGuard": - return "\u25C6"; // ◆ - case "PlaceZone": - return "\u25A0"; // ■ - case "PlaceDog": - return "\u25B2"; // ▲ - case "PlaceDrone": - return "\u2605"; // ★ - default: - return "?"; - } -} - -/// CSS colour for each tool category. -/// Used for toolbar button backgrounds and cursor indicators. -function toolColour(tool) { - if (typeof tool === "string") { - switch (tool) { - case "Select": - return "#94a3b8"; - case "Erase": - return "#ef4444"; - case "Pan": - return "#64748b"; - default: - return "#94a3b8"; - } - } - switch (tool.TAG) { - case "PlaceDevice": - return "#7c3aed"; - case "PlaceGuard": - return "#f97316"; - case "PlaceZone": - return "#6366f1"; - case "PlaceDog": - return "#22d3ee"; - case "PlaceDrone": - return "#f43f5e"; - default: - return "#94a3b8"; - } -} - -// --------------------------------------------------------------------------- -// Kind labels -// --------------------------------------------------------------------------- - -/// Human-readable label for a device kind. -/// Splits camelCase into spaced words for display. -function deviceKindLabel(kind) { - switch (kind) { - case "Terminal": return "Terminal"; - case "Server": return "Server"; - case "Router": return "Router"; - case "Switch": return "Switch"; - case "Camera": return "Camera"; - case "Firewall": return "Firewall"; - case "AccessPoint": return "Access Point"; - case "PatchPanel": return "Patch Panel"; - case "PowerSupply": return "Power Supply"; - case "PhoneSystem": return "Phone System"; - case "FibreHub": return "Fibre Hub"; - default: return kind; - } -} - -/// Human-readable label for a guard rank. -function guardRankLabel(rank) { - switch (rank) { - case "BasicGuard": return "Basic Guard"; - case "Enforcer": return "Enforcer"; - case "AntiHacker": return "Anti-Hacker"; - case "Sentinel": return "Sentinel"; - case "EliteGuard": return "Elite Guard"; - case "SecurityChief": return "Security Chief"; - case "RivalHacker": return "Rival Hacker"; - default: return rank; - } -} - -/// Human-readable label for a dog breed. -function dogBreedLabel(breed) { - switch (breed) { - case "Patrol": return "Patrol Dog"; - case "Bloodhound": return "Bloodhound"; - case "RoboDog": return "Robo-Dog"; - default: return breed; - } -} - -/// Human-readable label for a drone archetype. -function droneArchetypeLabel(archetype) { - switch (archetype) { - case "Helper": return "Helper Drone"; - case "Hunter": return "Hunter Drone"; - case "Killer": return "Killer Drone"; - default: return archetype; - } -} - -// --------------------------------------------------------------------------- -// Entity CRUD -// --------------------------------------------------------------------------- - -/// Find an entity at the given grid coordinates. -/// Returns the first entity whose (x, y) matches, or undefined if none. -function entityAt(entities, x, y) { - return entities.find(function (e) { - return e.x === x && e.y === y; - }); -} - -/// Add an entity to the entity list. -/// Returns a new array with the entity appended. -function addEntity(entities, entity) { - return entities.concat([entity]); -} - -/// Remove an entity by ID. -/// Returns a new array with the matching entity filtered out. -function removeEntity(entities, id) { - return entities.filter(function (e) { - return e.id !== id; - }); -} - -/// Move an entity to new grid coordinates. -/// Returns a new array with the matching entity's position updated. -function moveEntity(entities, id, x, y) { - return entities.map(function (e) { - if (e.id === id) { - return { - id: e.id, - kind: e.kind, - x: x, - y: y, - properties: e.properties - }; - } - return e; - }); -} - -/// Update a property on an entity by ID. -/// Returns a new array with the matching entity's properties dict updated. -function updateEntityProperty(entities, id, key, value) { - return entities.map(function (e) { - if (e.id === id) { - var newProps = Object.assign({}, e.properties); - newProps[key] = value; - return { - id: e.id, - kind: e.kind, - x: e.x, - y: e.y, - properties: newProps - }; - } - return e; - }); -} - -// --------------------------------------------------------------------------- -// Coordinate transformations -// --------------------------------------------------------------------------- - -/// Convert grid coordinates to canvas pixel coordinates. -/// Returns [px, py] where each value is grid * tileSize. -function gridToCanvas(x, y, tileSize) { - return [ - x * tileSize, - y * tileSize - ]; -} - -/// Convert canvas pixel coordinates to grid coordinates. -/// Returns [gx, gy] where each value is floor(px / tileSize). -/// Clamps to valid grid range if provided. -function canvasToGrid(px, py, tileSize) { - return [ - Math.floor(px / tileSize), - Math.floor(py / tileSize) - ]; -} - -// --------------------------------------------------------------------------- -// Zone lookups -// --------------------------------------------------------------------------- - -/// Determine which zone contains the given X grid coordinate. -/// Returns the zone object, or undefined if outside all zones. -function zoneAtX(zones, x) { - return zones.find(function (z) { - return x >= z.startX && x <= z.endX; - }); -} - -// --------------------------------------------------------------------------- -// Entity creation helpers -// --------------------------------------------------------------------------- - -/// Generate a unique entity ID using the current timestamp. -/// Appends a random suffix to avoid collisions within the same millisecond. -function generateEntityId() { - return Date.now().toString() + "-" + Math.floor(Math.random() * 10000).toString(); -} - -/// Create a new device entity at the given grid position. -/// Initialises default properties including zone lookup and empty IP. -function makeDeviceEntity(deviceKind, x, y, zones) { - var zone = zoneAtX(zones, x); - var zoneName = zone !== undefined ? zone.name : "LAN"; - return { - id: generateEntityId(), - kind: { TAG: "Device", _0: deviceKind }, - x: x, - y: y, - properties: { - deviceKind: deviceKind, - ipAddress: "", - securityLevel: "Weak", - name: deviceKindLabel(deviceKind), - zone: zoneName - } - }; -} - -/// Create a new guard entity at the given grid position. -function makeGuardEntity(guardRank, x, y, zones) { - var zone = zoneAtX(zones, x); - var zoneName = zone !== undefined ? zone.name : "LAN"; - return { - id: generateEntityId(), - kind: { TAG: "Guard", _0: guardRank }, - x: x, - y: y, - properties: { - guardRank: guardRank, - zone: zoneName, - patrolRadius: "2" - } - }; -} - -/// Create a new dog entity at the given grid position. -function makeDogEntity(dogBreed, x, y, zones) { - var zone = zoneAtX(zones, x); - var zoneName = zone !== undefined ? zone.name : "LAN"; - return { - id: generateEntityId(), - kind: { TAG: "Dog", _0: dogBreed }, - x: x, - y: y, - properties: { - dogBreed: dogBreed, - zone: zoneName, - patrolRadius: "3" - } - }; -} - -/// Create a new drone entity at the given grid position. -function makeDroneEntity(droneArchetype, x, y) { - return { - id: generateEntityId(), - kind: { TAG: "Drone", _0: droneArchetype }, - x: x, - y: y, - properties: { - droneArchetype: droneArchetype, - altitude: "1" - } - }; -} - -/// Create a zone marker entity at the given grid position. -function makeZoneMarkerEntity(zoneName, x, y) { - return { - id: generateEntityId(), - kind: { TAG: "ZoneMarker", _0: zoneName }, - x: x, - y: y, - properties: { - zoneName: zoneName, - securityTier: "Medium" - } - }; -} - -// --------------------------------------------------------------------------- -// Serialisation -// --------------------------------------------------------------------------- - -/// Serialise a single entity to a JSON-compatible object. -/// Used when building the level data for Gossamer save/validate commands. -function entityToJson(entity) { - var kindStr; - var kindValue; - if (typeof entity.kind === "string") { - kindStr = entity.kind; - kindValue = entity.kind; - } else { - kindStr = entity.kind.TAG; - kindValue = entity.kind._0; - } - return { - id: entity.id, - kindType: kindStr, - kindValue: kindValue, - x: entity.x, - y: entity.y, - properties: entity.properties - }; -} - -/// Serialise the full editor state to a JSON-compatible level object. -/// Produces the structure expected by the Gossamer save_level and -/// validate_level_abi commands (see commands.rs LevelData). -function levelToJson(state) { - var devices = []; - var guards = []; - var zoneObjs = []; - var zoneTransitions = []; - var deviceDefences = []; - var hasPbx = false; - var pbxIp = undefined; - - // Collect devices - state.entities.forEach(function (e) { - if (typeof e.kind !== "string" && e.kind.TAG === "Device") { - var ip = e.properties.ipAddress || ""; - devices.push({ - ip: ip, - kind: e.kind._0, - label: e.properties.name || null - }); - if (e.kind._0 === "PhoneSystem") { - hasPbx = true; - pbxIp = ip; - } - } - }); - - // Collect guards - state.entities.forEach(function (e) { - if (typeof e.kind !== "string" && e.kind.TAG === "Guard") { - guards.push({ - zone: e.properties.zone || "LAN", - rank: e.kind._0 - }); - } - }); - - // Build zones from state.zones - state.zones.forEach(function (z) { - zoneObjs.push({ name: z.name }); - zoneTransitions.push({ - world_x: z.startX * state.tileSize, - target_zone: z.name - }); - }); - - return { - name: state.levelName, - devices: devices, - zones: zoneObjs, - guards: guards, - zone_transitions: zoneTransitions, - device_defences: deviceDefences, - has_pbx: hasPbx, - pbx_ip: pbxIp - }; -} - -// --------------------------------------------------------------------------- -// Undo/Redo -// --------------------------------------------------------------------------- - -/// Push the current entity list onto the undo stack and clear redo. -/// Call this before any mutation to enable undo. -function pushUndo(state) { - var newUndo = state.undoStack.concat([state.entities]); - // Cap undo stack at 50 entries to avoid unbounded memory growth. - if (newUndo.length > 50) { - newUndo = newUndo.slice(newUndo.length - 50); - } - return { - activeTool: state.activeTool, - entities: state.entities, - selectedEntityId: state.selectedEntityId, - gridWidth: state.gridWidth, - gridHeight: state.gridHeight, - tileSize: state.tileSize, - zones: state.zones, - canvasRef: state.canvasRef, - showGrid: state.showGrid, - showZones: state.showZones, - showNetwork: state.showNetwork, - levelName: state.levelName, - levelPath: state.levelPath, - isDirty: state.isDirty, - validation: state.validation, - undoStack: newUndo, - redoStack: [], - loading: state.loading, - error: state.error, - recentFiles: state.recentFiles - }; -} - -/// Pop the undo stack and restore the previous entity list. -/// Pushes the current entities onto the redo stack. -function undo(state) { - if (state.undoStack.length === 0) { - return state; - } - var previous = state.undoStack[state.undoStack.length - 1]; - var newUndo = state.undoStack.slice(0, state.undoStack.length - 1); - var newRedo = state.redoStack.concat([state.entities]); - return { - activeTool: state.activeTool, - entities: previous, - selectedEntityId: undefined, - gridWidth: state.gridWidth, - gridHeight: state.gridHeight, - tileSize: state.tileSize, - zones: state.zones, - canvasRef: state.canvasRef, - showGrid: state.showGrid, - showZones: state.showZones, - showNetwork: state.showNetwork, - levelName: state.levelName, - levelPath: state.levelPath, - isDirty: true, - validation: state.validation, - undoStack: newUndo, - redoStack: newRedo, - loading: state.loading, - error: state.error, - recentFiles: state.recentFiles - }; -} - -/// Pop the redo stack and restore the next entity list. -/// Pushes the current entities onto the undo stack. -function redo(state) { - if (state.redoStack.length === 0) { - return state; - } - var next = state.redoStack[state.redoStack.length - 1]; - var newRedo = state.redoStack.slice(0, state.redoStack.length - 1); - var newUndo = state.undoStack.concat([state.entities]); - return { - activeTool: state.activeTool, - entities: next, - selectedEntityId: undefined, - gridWidth: state.gridWidth, - gridHeight: state.gridHeight, - tileSize: state.tileSize, - zones: state.zones, - canvasRef: state.canvasRef, - showGrid: state.showGrid, - showZones: state.showZones, - showNetwork: state.showNetwork, - levelName: state.levelName, - levelPath: state.levelPath, - isDirty: true, - validation: state.validation, - undoStack: newUndo, - redoStack: newRedo, - loading: state.loading, - error: state.error, - recentFiles: state.recentFiles - }; -} - -export { - toolLabel, - toolIcon, - toolColour, - deviceKindLabel, - guardRankLabel, - dogBreedLabel, - droneArchetypeLabel, - entityAt, - addEntity, - removeEntity, - moveEntity, - updateEntityProperty, - gridToCanvas, - canvasToGrid, - zoneAtX, - generateEntityId, - makeDeviceEntity, - makeGuardEntity, - makeDogEntity, - makeDroneEntity, - makeZoneMarkerEntity, - entityToJson, - levelToJson, - pushUndo, - undo, - redo, -} -/* No side effect */ diff --git a/idaptik-ums/src/editor/EditorModel.res b/idaptik-ums/src/editor/EditorModel.res deleted file mode 100644 index e3614391..00000000 --- a/idaptik-ums/src/editor/EditorModel.res +++ /dev/null @@ -1,167 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Generated by ReScript, PLEASE EDIT WITH CARE -// -// EditorModel.res.mjs — Type definitions for the IDApTIK UMS visual level editor. -// -// Author: Jonathan D.A. Jewell -// -// This module defines all types used by the editor: tools, entity kinds, -// validation state, and the composite editor state record. Types are -// represented as tagged variants (strings/objects) following ReScript -// conventions used throughout the UMS codebase. -// -// ## Type overview -// -// - `editorTool` — Which placement/interaction mode is active -// - `deviceKind` — Network device subtypes the player can place -// - `guardRank` — Guard difficulty tiers -// - `dogBreed` — K-9 unit variants -// - `droneArchetype` — Aerial unit variants -// - `securityLevel` — Zone/device security tiers -// - `editorEntity` — A single placeable object on the grid -// - `abiProofStatus` — Result of one ABI validation check -// - `validationState` — Aggregate validation results -// - `editorState` — Complete editor state for TEA model - -// --------------------------------------------------------------------------- -// Tool variants -// --------------------------------------------------------------------------- - -/// All device kinds available for placement in the editor. -/// Matches the device types used by LevelGen and NetworkGen. -var allDeviceKinds = [ - "Terminal", - "Server", - "Router", - "Switch", - "Camera", - "Firewall", - "AccessPoint", - "PatchPanel", - "PowerSupply", - "PhoneSystem", - "FibreHub" -]; - -/// All guard ranks from lowest to highest difficulty. -/// Maps to the rank field in LevelGen's guard placement. -var allGuardRanks = [ - "BasicGuard", - "Enforcer", - "AntiHacker", - "Sentinel", - "EliteGuard", - "SecurityChief", - "RivalHacker" -]; - -/// All dog breeds available for placement. -/// Dogs are a distinct entity type from guards with different patrol AI. -var allDogBreeds = [ - "Patrol", - "Bloodhound", - "RoboDog" -]; - -/// All drone archetypes available for placement. -/// Drones operate on a separate altitude layer from ground entities. -var allDroneArchetypes = [ - "Helper", - "Hunter", - "Killer" -]; - -/// Security level tiers for zones and devices. -/// Higher tiers require more advanced hacking skills to breach. -var allSecurityLevels = [ - "Open", - "Weak", - "Medium", - "Strong" -]; - -// --------------------------------------------------------------------------- -// Default zones -// --------------------------------------------------------------------------- - -/// The three standard zones matching LevelGen's zoneForColumn layout. -/// LAN occupies columns 0-7, DMZ columns 8-15, SCADA columns 16-22. -var defaultZones = [ - { - name: "LAN", - startX: 0, - endX: 7, - securityTier: "Weak" - }, - { - name: "DMZ", - startX: 8, - endX: 15, - securityTier: "Medium" - }, - { - name: "SCADA", - startX: 16, - endX: 22, - securityTier: "Strong" - } -]; - -// --------------------------------------------------------------------------- -// Empty/default validation state -// --------------------------------------------------------------------------- - -/// Initial validation state before any validation has been run. -/// All proofs are marked as not-yet-checked (passed: false) with empty detail. -var emptyValidation = { - proofs: [ - { name: "GuardsInZones", passed: false, detail: "" }, - { name: "DefenceTargetsValid", passed: false, detail: "" }, - { name: "ZonesOrdered", passed: false, detail: "" }, - { name: "PBXConsistent", passed: false, detail: "" }, - { name: "DevicesExist", passed: false, detail: "" } - ], - allPassed: false, - lastValidated: undefined -}; - -// --------------------------------------------------------------------------- -// Default editor state -// --------------------------------------------------------------------------- - -/// The initial editor state used when the editor is first mounted. -/// Grid dimensions (23x14) and tile size (28px) match LevelGen/LevelRender. -var defaultState = { - activeTool: "Select", - entities: [], - selectedEntityId: undefined, - gridWidth: 23, - gridHeight: 14, - tileSize: 28, - zones: defaultZones, - canvasRef: undefined, - showGrid: true, - showZones: true, - showNetwork: false, - levelName: "Untitled Level", - levelPath: undefined, - isDirty: false, - validation: emptyValidation, - undoStack: [], - redoStack: [], - loading: false, - error: undefined, - recentFiles: [] -}; - -export { - allDeviceKinds, - allGuardRanks, - allDogBreeds, - allDroneArchetypes, - allSecurityLevels, - defaultZones, - emptyValidation, - defaultState, -} -/* No side effect */ diff --git a/idaptik-ums/src/editor/EditorProperties.res b/idaptik-ums/src/editor/EditorProperties.res deleted file mode 100644 index 17a907c7..00000000 --- a/idaptik-ums/src/editor/EditorProperties.res +++ /dev/null @@ -1,378 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Generated by ReScript, PLEASE EDIT WITH CARE -// -// EditorProperties.res.mjs — Property inspector panel for the IDApTIK UMS editor. -// -// Author: Jonathan D.A. Jewell -// -// Renders the right sidebar showing editable properties for the currently -// selected entity. The panel adapts its fields based on entity kind: -// -// - Device: kind, IP address, security level, display name -// - Guard: rank, zone, patrol radius -// - Dog: breed, patrol radius -// - Drone: archetype, altitude -// - Zone marker: zone name, security tier -// -// Uses React.createElement (TEA Html pattern, no JSX). Property changes -// dispatch EditorMsg.UpdateProperty messages. - -import * as React from "react"; -import * as EditorModel from "./EditorModel.res.mjs"; -import * as EditorEngine from "./EditorEngine.res.mjs"; - -// --------------------------------------------------------------------------- -// Style constants -// --------------------------------------------------------------------------- - -/// Base style for the properties panel container (right sidebar). -var panelStyle = { - width: "250px", - minWidth: "250px", - backgroundColor: "#1e293b", - borderLeft: "1px solid #334155", - display: "flex", - flexDirection: "column", - padding: "8px", - gap: "6px", - overflowY: "auto", - fontFamily: "sans-serif", - fontSize: "11px", - color: "#e2e8f0" -}; - -/// Style for the panel header text. -var headerStyle = { - fontSize: "9px", - fontWeight: "800", - textTransform: "uppercase", - letterSpacing: "0.1em", - color: "#64748b", - padding: "4px 4px 2px 4px", - userSelect: "none" -}; - -/// Style for a property row (label + input). -var rowStyle = { - display: "flex", - flexDirection: "column", - gap: "2px", - padding: "2px 4px" -}; - -/// Style for a property label. -var labelStyle = { - fontSize: "9px", - fontWeight: "700", - textTransform: "uppercase", - letterSpacing: "0.05em", - color: "#94a3b8" -}; - -/// Style for a text input field. -var inputStyle = { - backgroundColor: "#0f172a", - border: "1px solid #334155", - borderRadius: "3px", - padding: "4px 6px", - color: "#e2e8f0", - fontSize: "11px", - fontFamily: "monospace", - outline: "none", - width: "100%", - boxSizing: "border-box" -}; - -/// Style for a select dropdown. -var selectStyle = { - backgroundColor: "#0f172a", - border: "1px solid #334155", - borderRadius: "3px", - padding: "4px 6px", - color: "#e2e8f0", - fontSize: "11px", - outline: "none", - width: "100%", - boxSizing: "border-box" -}; - -/// Style for the delete button at the bottom of the panel. -var deleteButtonStyle = { - marginTop: "auto", - padding: "8px", - backgroundColor: "rgba(239, 68, 68, 0.1)", - border: "1px solid #ef4444", - borderRadius: "4px", - color: "#ef4444", - cursor: "pointer", - fontSize: "11px", - fontWeight: "700", - textAlign: "center" -}; - -/// Style for the "no selection" placeholder text. -var emptyStyle = { - color: "#475569", - fontStyle: "italic", - textAlign: "center", - padding: "24px 8px" -}; - -// --------------------------------------------------------------------------- -// Helper: create a property row with label and input -// --------------------------------------------------------------------------- - -/// Create a text input property row. -/// -/// @param key — React key for the row. -/// @param label — Display label for the property. -/// @param value — Current value of the property. -/// @param onChange — Callback receiving the new value string. -/// @returns React element containing a label and text input. -function textRow(key, label, value, onChange) { - return React.createElement("div", { key: key, style: rowStyle }, - React.createElement("span", { style: labelStyle }, label), - React.createElement("input", { - type: "text", - style: inputStyle, - value: value || "", - onChange: function (e) { onChange(e.target.value); } - }) - ); -} - -/// Create a select dropdown property row. -/// -/// @param key — React key for the row. -/// @param label — Display label for the property. -/// @param value — Currently selected value. -/// @param options — Array of [value, label] pairs. -/// @param onChange — Callback receiving the new selected value string. -/// @returns React element containing a label and select dropdown. -function selectRow(key, label, value, options, onChange) { - return React.createElement("div", { key: key, style: rowStyle }, - React.createElement("span", { style: labelStyle }, label), - React.createElement("select", { - style: selectStyle, - value: value || "", - onChange: function (e) { onChange(e.target.value); } - }, options.map(function (opt) { - return React.createElement("option", { - key: opt[0], - value: opt[0] - }, opt[1]); - })) - ); -} - -/// Create a read-only info row (not editable). -/// -/// @param key — React key for the row. -/// @param label — Display label. -/// @param value — Value to display. -/// @returns React element containing a label and non-editable text. -function infoRow(key, label, value) { - return React.createElement("div", { key: key, style: rowStyle }, - React.createElement("span", { style: labelStyle }, label), - React.createElement("span", { - style: { color: "#cbd5e1", fontSize: "11px", fontFamily: "monospace" } - }, value) - ); -} - -// --------------------------------------------------------------------------- -// Property panels for each entity kind -// --------------------------------------------------------------------------- - -/// Render device-specific properties. -/// Shows kind (read-only), IP address, security level, and display name. -function deviceProperties(entity, dispatch) { - var kind = entity.properties.deviceKind || "Terminal"; - var securityOptions = EditorModel.allSecurityLevels.map(function (sl) { - return [sl, sl]; - }); - - return [ - infoRow("kind", "Device Kind", EditorEngine.deviceKindLabel(kind)), - textRow("ip", "IP Address", entity.properties.ipAddress, function (v) { - dispatch({ TAG: "UpdateProperty", _0: entity.id, _1: "ipAddress", _2: v }); - }), - selectRow("sec", "Security Level", entity.properties.securityLevel, - securityOptions, function (v) { - dispatch({ TAG: "UpdateProperty", _0: entity.id, _1: "securityLevel", _2: v }); - }), - textRow("name", "Display Name", entity.properties.name, function (v) { - dispatch({ TAG: "UpdateProperty", _0: entity.id, _1: "name", _2: v }); - }) - ]; -} - -/// Render guard-specific properties. -/// Shows rank (read-only), zone, and patrol radius. -function guardProperties(entity, dispatch) { - var rank = entity.properties.guardRank || "BasicGuard"; - - return [ - infoRow("rank", "Rank", EditorEngine.guardRankLabel(rank)), - textRow("zone", "Zone", entity.properties.zone, function (v) { - dispatch({ TAG: "UpdateProperty", _0: entity.id, _1: "zone", _2: v }); - }), - textRow("radius", "Patrol Radius", entity.properties.patrolRadius, function (v) { - dispatch({ TAG: "UpdateProperty", _0: entity.id, _1: "patrolRadius", _2: v }); - }) - ]; -} - -/// Render dog-specific properties. -/// Shows breed (read-only) and patrol radius. -function dogProperties(entity, dispatch) { - var breed = entity.properties.dogBreed || "Patrol"; - - return [ - infoRow("breed", "Breed", EditorEngine.dogBreedLabel(breed)), - textRow("radius", "Patrol Radius", entity.properties.patrolRadius, function (v) { - dispatch({ TAG: "UpdateProperty", _0: entity.id, _1: "patrolRadius", _2: v }); - }) - ]; -} - -/// Render drone-specific properties. -/// Shows archetype (read-only) and altitude. -function droneProperties(entity, dispatch) { - var archetype = entity.properties.droneArchetype || "Helper"; - - return [ - infoRow("arch", "Archetype", EditorEngine.droneArchetypeLabel(archetype)), - textRow("alt", "Altitude", entity.properties.altitude, function (v) { - dispatch({ TAG: "UpdateProperty", _0: entity.id, _1: "altitude", _2: v }); - }) - ]; -} - -/// Render zone marker-specific properties. -/// Shows zone name and security tier. -function zoneMarkerProperties(entity, dispatch) { - var securityOptions = EditorModel.allSecurityLevels.map(function (sl) { - return [sl, sl]; - }); - - return [ - textRow("zname", "Zone Name", entity.properties.zoneName, function (v) { - dispatch({ TAG: "UpdateProperty", _0: entity.id, _1: "zoneName", _2: v }); - }), - selectRow("tier", "Security Tier", entity.properties.securityTier, - securityOptions, function (v) { - dispatch({ TAG: "UpdateProperty", _0: entity.id, _1: "securityTier", _2: v }); - }) - ]; -} - -// --------------------------------------------------------------------------- -// Main view -// --------------------------------------------------------------------------- - -/// Render the properties panel. -/// -/// Shows a "No selection" placeholder if no entity is selected, or the -/// appropriate property fields for the selected entity's kind. -/// -/// @param state — Current editor state. -/// @param dispatch — TEA dispatch function for sending EditorMsg values. -/// @returns React element for the properties panel. -function view(state, dispatch) { - // Find the selected entity - var selectedEntity = undefined; - if (state.selectedEntityId !== undefined) { - selectedEntity = state.entities.find(function (e) { - return e.id === state.selectedEntityId; - }); - } - - if (selectedEntity === undefined) { - return React.createElement("div", { style: panelStyle }, - React.createElement("div", { style: headerStyle }, "Properties"), - React.createElement("div", { style: emptyStyle }, "No entity selected") - ); - } - - // Entity type label - var typeLabel = "Entity"; - if (typeof selectedEntity.kind !== "string") { - switch (selectedEntity.kind.TAG) { - case "Device": typeLabel = "Device"; break; - case "Guard": typeLabel = "Guard"; break; - case "Dog": typeLabel = "Dog"; break; - case "Drone": typeLabel = "Drone"; break; - case "ZoneMarker": typeLabel = "Zone Marker"; break; - } - } - - // Common info rows - var commonRows = [ - infoRow("type", "Type", typeLabel), - infoRow("pos", "Position", "(" + String(selectedEntity.x) + ", " + String(selectedEntity.y) + ")"), - infoRow("id", "ID", selectedEntity.id.slice(0, 12) + "...") - ]; - - // Kind-specific property rows - var kindRows = []; - if (typeof selectedEntity.kind !== "string") { - switch (selectedEntity.kind.TAG) { - case "Device": - kindRows = deviceProperties(selectedEntity, dispatch); - break; - case "Guard": - kindRows = guardProperties(selectedEntity, dispatch); - break; - case "Dog": - kindRows = dogProperties(selectedEntity, dispatch); - break; - case "Drone": - kindRows = droneProperties(selectedEntity, dispatch); - break; - case "ZoneMarker": - kindRows = zoneMarkerProperties(selectedEntity, dispatch); - break; - } - } - - // Delete button - var deleteBtn = React.createElement("button", { - key: "delete", - style: deleteButtonStyle, - onClick: function () { - dispatch({ TAG: "DeleteEntity", _0: selectedEntity.id }); - } - }, "Delete Entity"); - - return React.createElement("div", { style: panelStyle }, - React.createElement("div", { style: headerStyle }, "Properties"), - commonRows, - React.createElement("hr", { - style: { border: "none", borderTop: "1px solid #334155", margin: "4px 0" } - }), - kindRows, - deleteBtn - ); -} - -export { - panelStyle, - headerStyle, - rowStyle, - labelStyle, - inputStyle, - selectStyle, - deleteButtonStyle, - emptyStyle, - textRow, - selectRow, - infoRow, - deviceProperties, - guardProperties, - dogProperties, - droneProperties, - zoneMarkerProperties, - view, -} -/* No side effect */ diff --git a/idaptik-ums/src/editor/EditorToolbar.res b/idaptik-ums/src/editor/EditorToolbar.res deleted file mode 100644 index 93ecb5da..00000000 --- a/idaptik-ums/src/editor/EditorToolbar.res +++ /dev/null @@ -1,378 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Generated by ReScript, PLEASE EDIT WITH CARE -// -// EditorToolbar.res.mjs — Tool palette sidebar for the IDApTIK UMS editor. -// -// Author: Jonathan D.A. Jewell -// -// Renders the left sidebar containing tool selection buttons, view toggles, -// and file action buttons. Uses React.createElement (TEA Html pattern, no JSX) -// to build the DOM tree. Each button dispatches an EditorMsg via onClick. -// -// ## Layout -// -// - Tool selection (Select, Place Device, Place Guard, Place Dog, -// Place Drone, Place Zone, Erase, Pan) -// - Submenu expansions for placement tools (device kinds, guard ranks, etc.) -// - View toggles (Grid, Zones, Network) -// - File actions (New, Open, Save, Validate ABI, Export) -// -// ## Styling -// -// Uses inline styles for portability (no external CSS dependency). -// Colour scheme matches the dark-theme editor canvas. - -import * as React from "react"; -import * as EditorModel from "./EditorModel.res.mjs"; -import * as EditorEngine from "./EditorEngine.res.mjs"; - -// --------------------------------------------------------------------------- -// Style constants -// --------------------------------------------------------------------------- - -/// Base style for the toolbar container (left sidebar). -var toolbarStyle = { - width: "200px", - minWidth: "200px", - backgroundColor: "#1e293b", - borderRight: "1px solid #334155", - display: "flex", - flexDirection: "column", - padding: "8px", - gap: "4px", - overflowY: "auto", - fontFamily: "sans-serif", - fontSize: "12px", - color: "#e2e8f0" -}; - -/// Style for section headers within the toolbar. -var sectionHeaderStyle = { - fontSize: "9px", - fontWeight: "800", - textTransform: "uppercase", - letterSpacing: "0.1em", - color: "#64748b", - padding: "8px 4px 2px 4px", - userSelect: "none" -}; - -/// Base style for a toolbar button. Active state overrides border colour. -function buttonStyle(isActive, color) { - return { - display: "flex", - alignItems: "center", - gap: "6px", - padding: "6px 8px", - border: isActive ? "1px solid " + color : "1px solid transparent", - borderRadius: "4px", - backgroundColor: isActive ? "rgba(255,255,255,0.08)" : "transparent", - color: isActive ? color : "#94a3b8", - cursor: "pointer", - fontSize: "11px", - fontWeight: isActive ? "700" : "500", - width: "100%", - textAlign: "left" - }; -} - -/// Style for submenu items (indented under a parent tool button). -function submenuItemStyle(isActive, color) { - return { - display: "flex", - alignItems: "center", - gap: "6px", - padding: "3px 8px 3px 20px", - border: isActive ? "1px solid " + color : "1px solid transparent", - borderRadius: "3px", - backgroundColor: isActive ? "rgba(255,255,255,0.06)" : "transparent", - color: isActive ? color : "#64748b", - cursor: "pointer", - fontSize: "10px", - fontWeight: isActive ? "600" : "400", - width: "100%", - textAlign: "left" - }; -} - -/// Style for view toggle buttons. -function toggleStyle(isOn) { - return { - display: "flex", - alignItems: "center", - gap: "6px", - padding: "5px 8px", - border: "1px solid transparent", - borderRadius: "4px", - backgroundColor: isOn ? "rgba(59, 130, 246, 0.15)" : "transparent", - color: isOn ? "#3b82f6" : "#64748b", - cursor: "pointer", - fontSize: "11px", - fontWeight: isOn ? "600" : "400", - width: "100%", - textAlign: "left" - }; -} - -/// Style for action buttons (New, Save, etc.). -var actionButtonStyle = { - display: "flex", - alignItems: "center", - gap: "6px", - padding: "6px 8px", - border: "1px solid #334155", - borderRadius: "4px", - backgroundColor: "transparent", - color: "#e2e8f0", - cursor: "pointer", - fontSize: "11px", - fontWeight: "600", - width: "100%", - textAlign: "left" -}; - -// --------------------------------------------------------------------------- -// Helper: detect active tool category -// --------------------------------------------------------------------------- - -/// Check if the active tool matches a specific tool string or tag category. -function isToolActive(activeTool, toolTag) { - if (typeof activeTool === "string") { - return activeTool === toolTag; - } - return activeTool.TAG === toolTag; -} - -/// Check if a specific subtype is active within a tool category. -function isSubtypeActive(activeTool, tag, subtype) { - if (typeof activeTool !== "string" && activeTool.TAG === tag) { - return activeTool._0 === subtype; - } - return false; -} - -// --------------------------------------------------------------------------- -// View function -// --------------------------------------------------------------------------- - -/// Render the complete toolbar sidebar. -/// -/// @param state — Current editor state (used for active tool, view toggles). -/// @param dispatch — TEA dispatch function for sending EditorMsg values. -/// @returns React element for the toolbar sidebar. -function view(state, dispatch) { - - // -- Tool section: primary tools ---------------------------------------- - - var toolButtons = [ - React.createElement("button", { - key: "select", - style: buttonStyle(state.activeTool === "Select", "#94a3b8"), - onClick: function () { dispatch({ TAG: "SetTool", _0: "Select" }); } - }, "\u2190 Select"), - - React.createElement("button", { - key: "erase", - style: buttonStyle(state.activeTool === "Erase", "#ef4444"), - onClick: function () { dispatch({ TAG: "SetTool", _0: "Erase" }); } - }, "\u2717 Erase"), - - React.createElement("button", { - key: "pan", - style: buttonStyle(state.activeTool === "Pan", "#64748b"), - onClick: function () { dispatch({ TAG: "SetTool", _0: "Pan" }); } - }, "\u2725 Pan") - ]; - - // -- Tool section: Place Device (expandable submenu) -------------------- - - var deviceHeader = React.createElement("button", { - key: "device-header", - style: buttonStyle(isToolActive(state.activeTool, "PlaceDevice"), "#7c3aed"), - onClick: function () { - dispatch({ TAG: "SetTool", _0: { TAG: "PlaceDevice", _0: "Terminal" } }); - } - }, "\u25CF Place Device"); - - var deviceSubmenu = isToolActive(state.activeTool, "PlaceDevice") - ? EditorModel.allDeviceKinds.map(function (kind) { - return React.createElement("button", { - key: "dev-" + kind, - style: submenuItemStyle(isSubtypeActive(state.activeTool, "PlaceDevice", kind), "#7c3aed"), - onClick: function () { - dispatch({ TAG: "SetTool", _0: { TAG: "PlaceDevice", _0: kind } }); - } - }, EditorEngine.deviceKindLabel(kind)); - }) - : []; - - // -- Tool section: Place Guard (expandable submenu) --------------------- - - var guardHeader = React.createElement("button", { - key: "guard-header", - style: buttonStyle(isToolActive(state.activeTool, "PlaceGuard"), "#f97316"), - onClick: function () { - dispatch({ TAG: "SetTool", _0: { TAG: "PlaceGuard", _0: "BasicGuard" } }); - } - }, "\u25C6 Place Guard"); - - var guardSubmenu = isToolActive(state.activeTool, "PlaceGuard") - ? EditorModel.allGuardRanks.map(function (rank) { - return React.createElement("button", { - key: "guard-" + rank, - style: submenuItemStyle(isSubtypeActive(state.activeTool, "PlaceGuard", rank), "#f97316"), - onClick: function () { - dispatch({ TAG: "SetTool", _0: { TAG: "PlaceGuard", _0: rank } }); - } - }, EditorEngine.guardRankLabel(rank)); - }) - : []; - - // -- Tool section: Place Dog (expandable submenu) ----------------------- - - var dogHeader = React.createElement("button", { - key: "dog-header", - style: buttonStyle(isToolActive(state.activeTool, "PlaceDog"), "#22d3ee"), - onClick: function () { - dispatch({ TAG: "SetTool", _0: { TAG: "PlaceDog", _0: "Patrol" } }); - } - }, "\u25B2 Place Dog"); - - var dogSubmenu = isToolActive(state.activeTool, "PlaceDog") - ? EditorModel.allDogBreeds.map(function (breed) { - return React.createElement("button", { - key: "dog-" + breed, - style: submenuItemStyle(isSubtypeActive(state.activeTool, "PlaceDog", breed), "#22d3ee"), - onClick: function () { - dispatch({ TAG: "SetTool", _0: { TAG: "PlaceDog", _0: breed } }); - } - }, EditorEngine.dogBreedLabel(breed)); - }) - : []; - - // -- Tool section: Place Drone (expandable submenu) --------------------- - - var droneHeader = React.createElement("button", { - key: "drone-header", - style: buttonStyle(isToolActive(state.activeTool, "PlaceDrone"), "#f43f5e"), - onClick: function () { - dispatch({ TAG: "SetTool", _0: { TAG: "PlaceDrone", _0: "Helper" } }); - } - }, "\u2605 Place Drone"); - - var droneSubmenu = isToolActive(state.activeTool, "PlaceDrone") - ? EditorModel.allDroneArchetypes.map(function (arch) { - return React.createElement("button", { - key: "drone-" + arch, - style: submenuItemStyle(isSubtypeActive(state.activeTool, "PlaceDrone", arch), "#f43f5e"), - onClick: function () { - dispatch({ TAG: "SetTool", _0: { TAG: "PlaceDrone", _0: arch } }); - } - }, EditorEngine.droneArchetypeLabel(arch)); - }) - : []; - - // -- Tool section: Place Zone ------------------------------------------- - - var zoneButton = React.createElement("button", { - key: "zone", - style: buttonStyle(isToolActive(state.activeTool, "PlaceZone"), "#6366f1"), - onClick: function () { - dispatch({ TAG: "SetTool", _0: { TAG: "PlaceZone", _0: "LAN" } }); - } - }, "\u25A0 Place Zone"); - - // -- View toggles ------------------------------------------------------- - - var viewToggles = [ - React.createElement("button", { - key: "toggle-grid", - style: toggleStyle(state.showGrid), - onClick: function () { dispatch("ToggleGrid"); } - }, (state.showGrid ? "\u2611" : "\u2610") + " Grid"), - - React.createElement("button", { - key: "toggle-zones", - style: toggleStyle(state.showZones), - onClick: function () { dispatch("ToggleZones"); } - }, (state.showZones ? "\u2611" : "\u2610") + " Zones"), - - React.createElement("button", { - key: "toggle-network", - style: toggleStyle(state.showNetwork), - onClick: function () { dispatch("ToggleNetwork"); } - }, (state.showNetwork ? "\u2611" : "\u2610") + " Network") - ]; - - // -- File actions -------------------------------------------------------- - - var fileActions = [ - React.createElement("button", { - key: "action-new", - style: actionButtonStyle, - onClick: function () { dispatch("NewLevel"); } - }, "New Level"), - - React.createElement("button", { - key: "action-open", - style: actionButtonStyle, - onClick: function () { dispatch("OpenLevel"); } - }, "Open Level"), - - React.createElement("button", { - key: "action-save", - style: actionButtonStyle, - onClick: function () { dispatch("SaveLevel"); } - }, "Save Level"), - - React.createElement("button", { - key: "action-validate", - style: Object.assign({}, actionButtonStyle, { - borderColor: "#22c55e", - color: "#22c55e" - }), - onClick: function () { dispatch("ValidateAbi"); } - }, "Validate ABI"), - - React.createElement("button", { - key: "action-export", - style: actionButtonStyle, - onClick: function () { dispatch("ExportConfig"); } - }, "Export Config") - ]; - - // -- Assemble the toolbar ----------------------------------------------- - - return React.createElement("div", { style: toolbarStyle }, - React.createElement("div", { style: sectionHeaderStyle }, "Tools"), - toolButtons, - deviceHeader, - deviceSubmenu, - guardHeader, - guardSubmenu, - dogHeader, - dogSubmenu, - droneHeader, - droneSubmenu, - zoneButton, - - React.createElement("div", { style: sectionHeaderStyle }, "View"), - viewToggles, - - React.createElement("div", { style: sectionHeaderStyle }, "Actions"), - fileActions - ); -} - -export { - toolbarStyle, - sectionHeaderStyle, - buttonStyle, - submenuItemStyle, - toggleStyle, - actionButtonStyle, - isToolActive, - isSubtypeActive, - view, -} -/* No side effect */ diff --git a/idaptik-ums/src/editor/EditorValidation.res b/idaptik-ums/src/editor/EditorValidation.res deleted file mode 100644 index 8bcaa82f..00000000 --- a/idaptik-ums/src/editor/EditorValidation.res +++ /dev/null @@ -1,245 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -// Generated by ReScript, PLEASE EDIT WITH CARE -// -// EditorValidation.res.mjs — ABI validation results panel for the UMS editor. -// -// Author: Jonathan D.A. Jewell -// -// Renders the bottom panel showing the results of the five ABI proof checks. -// The five proofs mirror the Idris2 dependent-type proofs in -// src/abi/Validation.idr and the Rust runtime checks in commands.rs: -// -// 1. GuardsInZones — All guards reference valid zones -// 2. DefenceTargetsValid — Failover/cascade/mirror targets exist -// 3. ZonesOrdered — Zone transitions are monotonically ordered -// 4. PBXConsistent — PBX IP is in the device registry (when enabled) -// 5. DevicesExist — All defence config IPs are in the registry -// -// Each proof is shown as a badge with pass (green) or fail (red) status. -// Failed proofs expand to show the error detail string from the Gossamer backend. - -import * as React from "react"; - -// --------------------------------------------------------------------------- -// Style constants -// --------------------------------------------------------------------------- - -/// Base style for the validation panel container (bottom bar). -var panelStyle = { - height: "120px", - minHeight: "120px", - backgroundColor: "#1e293b", - borderTop: "1px solid #334155", - display: "flex", - flexDirection: "column", - padding: "8px 12px", - gap: "4px", - overflowY: "auto", - fontFamily: "sans-serif", - fontSize: "11px", - color: "#e2e8f0" -}; - -/// Style for the header row containing the title and overall status badge. -var headerRowStyle = { - display: "flex", - justifyContent: "space-between", - alignItems: "center", - paddingBottom: "4px" -}; - -/// Style for the panel title text. -var titleStyle = { - fontSize: "9px", - fontWeight: "800", - textTransform: "uppercase", - letterSpacing: "0.1em", - color: "#64748b", - userSelect: "none" -}; - -/// Style for the proofs list container. -var proofsContainerStyle = { - display: "flex", - flexWrap: "wrap", - gap: "6px", - flex: "1" -}; - -/// Style for a single proof badge. Colour depends on pass/fail status. -function proofBadgeStyle(passed) { - return { - display: "flex", - alignItems: "center", - gap: "4px", - padding: "3px 8px", - borderRadius: "4px", - backgroundColor: passed ? "rgba(34, 197, 94, 0.1)" : "rgba(239, 68, 68, 0.1)", - border: passed ? "1px solid rgba(34, 197, 94, 0.3)" : "1px solid rgba(239, 68, 68, 0.3)", - color: passed ? "#22c55e" : "#ef4444", - fontSize: "10px", - fontWeight: "600", - cursor: "default" - }; -} - -/// Style for the overall status badge. -function overallBadgeStyle(allPassed) { - return { - padding: "3px 10px", - borderRadius: "4px", - backgroundColor: allPassed ? "rgba(34, 197, 94, 0.15)" : "rgba(239, 68, 68, 0.15)", - border: allPassed ? "1px solid #22c55e" : "1px solid #ef4444", - color: allPassed ? "#22c55e" : "#ef4444", - fontSize: "10px", - fontWeight: "800", - textTransform: "uppercase", - letterSpacing: "0.05em" - }; -} - -/// Style for the "Validate" action button. -var validateButtonStyle = { - padding: "4px 12px", - borderRadius: "4px", - backgroundColor: "rgba(34, 197, 94, 0.1)", - border: "1px solid #22c55e", - color: "#22c55e", - fontSize: "10px", - fontWeight: "700", - cursor: "pointer", - textTransform: "uppercase" -}; - -/// Style for the "not yet validated" placeholder text. -var notValidatedStyle = { - color: "#475569", - fontStyle: "italic", - textAlign: "center", - padding: "8px" -}; - -/// Style for error detail text below a failed proof badge. -var errorDetailStyle = { - fontSize: "9px", - color: "#f87171", - paddingLeft: "12px", - fontFamily: "monospace", - wordBreak: "break-all" -}; - -// --------------------------------------------------------------------------- -// Proof name labels -// --------------------------------------------------------------------------- - -/// Human-readable labels for each of the five ABI proof checks. -var proofLabels = { - GuardsInZones: "Guards In Zones", - DefenceTargetsValid: "Defence Targets Valid", - ZonesOrdered: "Zones Ordered", - PBXConsistent: "PBX Consistent", - DevicesExist: "Devices Exist" -}; - -// --------------------------------------------------------------------------- -// View function -// --------------------------------------------------------------------------- - -/// Render the validation results panel. -/// -/// Shows the five proof badges with pass/fail indicators, an overall -/// status badge, and a "Validate" button that triggers a Gossamer -/// `validate_level_abi` call via EditorMsg.ValidateAbi. -/// -/// @param state — Current editor state (validation field used). -/// @param dispatch — TEA dispatch function for sending EditorMsg values. -/// @returns React element for the validation panel. -function view(state, dispatch) { - var validation = state.validation; - var hasBeenValidated = validation.lastValidated !== undefined; - - // Header row: title + overall badge + validate button - var overallBadge = hasBeenValidated - ? React.createElement("span", { - key: "overall", - style: overallBadgeStyle(validation.allPassed) - }, validation.allPassed ? "All Passed" : "Has Failures") - : null; - - var lastValidatedText = hasBeenValidated - ? React.createElement("span", { - key: "timestamp", - style: { fontSize: "9px", color: "#475569" } - }, "Last: " + validation.lastValidated) - : null; - - var validateBtn = React.createElement("button", { - key: "validate-btn", - style: validateButtonStyle, - onClick: function () { dispatch("ValidateAbi"); } - }, "Validate"); - - var headerRow = React.createElement("div", { style: headerRowStyle }, - React.createElement("span", { style: titleStyle }, "ABI Validation"), - React.createElement("div", { - style: { display: "flex", alignItems: "center", gap: "8px" } - }, lastValidatedText, overallBadge, validateBtn) - ); - - // Proof badges - var proofBadges; - if (!hasBeenValidated) { - proofBadges = React.createElement("div", { style: notValidatedStyle }, - "Click 'Validate' to run the five ABI proof checks against this level." - ); - } else { - var badges = validation.proofs.map(function (proof) { - var label = proofLabels[proof.name] || proof.name; - var icon = proof.passed ? "\u2713" : "\u2717"; - - var elements = [ - React.createElement("span", { - key: "badge-" + proof.name, - style: proofBadgeStyle(proof.passed) - }, icon + " " + label) - ]; - - // Show error detail for failed proofs - if (!proof.passed && proof.detail && proof.detail.length > 0) { - elements.push( - React.createElement("div", { - key: "detail-" + proof.name, - style: errorDetailStyle - }, proof.detail) - ); - } - - return React.createElement("div", { - key: "proof-" + proof.name, - style: { display: "flex", flexDirection: "column", gap: "2px" } - }, elements); - }); - - proofBadges = React.createElement("div", { style: proofsContainerStyle }, badges); - } - - return React.createElement("div", { style: panelStyle }, - headerRow, - proofBadges - ); -} - -export { - panelStyle, - headerRowStyle, - titleStyle, - proofsContainerStyle, - proofBadgeStyle, - overallBadgeStyle, - validateButtonStyle, - notValidatedStyle, - errorDetailStyle, - proofLabels, - view, -} -/* No side effect */ diff --git a/idaptik-ums/src/editor/README.adoc b/idaptik-ums/src/editor/README.adoc new file mode 100644 index 00000000..2bb6c0e4 --- /dev/null +++ b/idaptik-ums/src/editor/README.adoc @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + += idaptik-ums/src/editor — Status note + +== What's here + +`EditorLevelCmd.res` — Gossamer IPC wrappers for the 12 level-building commands. Authentic ReScript source, orphan WIP (not currently imported by `App.res`). + +== What was removed (2026-06-01) + +Nine files previously in this directory were misnamed compiled `.res.mjs` outputs masquerading as `.res` source (banner: `// Generated by ReScript, PLEASE EDIT WITH CARE`): + +* `Editor.res` (top-level shell) +* `EditorCanvas.res`, `EditorCanvasPixi.res` (canvas rendering) +* `EditorCmd.res` (commands) +* `EditorEngine.res` (pure helpers) +* `EditorModel.res` (types) +* `EditorProperties.res`, `EditorToolbar.res`, `EditorValidation.res` (UI panels) + +These files were never imported by `App.res` (the actual `idaptik-ums` entry point) and had no `.res.mjs` siblings — they were orphan dead code from the start. Their original ReScript sources were not present in any commit (the very first introduction `17ffc43` already shows the `Generated by ReScript` banner). Per idaptik#116, restoring the originals was not possible. + +== Future plan + +The editor module will be rewritten as `.affine` once AffineScript user-module codegen lands (tracked in affinescript#228 + cross-estate STEP 8 in standards#279). Until then `EditorLevelCmd.res` is the only authentic source here, kept as the IPC-wrapper seam between the future editor UI and Gossamer.