Creating a Custom Command in MLightCAD
This page explains how to create a custom command in MLightCAD and register it so that it can be triggered by the user.
Overview
Commands are the primary way users interact with the MLightCAD. Each command represents a specific operation, such as drawing a line, creating a circle, zooming, or selecting objects.
The system provides a command stack (AcEdCommandStack) to manage commands in groups, look them up by name, and handle their lifecycle events through the editor.
Command Lifecycle
Each command follows a well-defined lifecycle managed by AcEdCommand:
- The command is triggered via
trigger() - The
commandWillStartevent is fired - The
execute()method is called with the current context - The
commandEndedevent is fired (always, even if an error occurs)
Internally, this lifecycle is implemented as follows:
async trigger(context: AcApContext) {
try {
this.onCommandWillStart(context)
context.view.editor.events.commandWillStart.dispatch({ command: this })
await this.execute(context)
} finally {
context.view.editor.events.commandEnded.dispatch({ command: this })
this.onCommandEnded(context)
}
}Key Guarantees
commandWillStartis always fired beforeexecute()commandEndedis always fired after execution, even if the command throws or is cancelledCommands can hook into lifecycle logic by overriding:
onCommandWillStart(context)onCommandEnded(context)
Creating a Command
All commands should extend the abstract AcEdCommand class:
import {
AcEdCommand,
AcApContext,
AcApDocManager,
AcEdPromptPointOptions,
AcEdPromptDistanceOptions
} from '@mlightcad/cad-simple-viewer'
import { AcDbCircle } from '@mlightcad/data-model'
export class AcApCircleCmd extends AcEdCommand {
async execute(context: AcApContext) {
// Prompt for center point
const centerPrompt = new AcEdPromptPointOptions(
'Specify center point of circle:'
)
const center = await AcApDocManager.instance.editor.getPoint(centerPrompt)
// Prompt for radius
const radiusPrompt = new AcEdPromptDistanceOptions(
'Specify radius of circle:'
)
const radius = await AcApDocManager.instance.editor.getDistance(radiusPrompt)
// Create circle entity and add it to model space
const db = context.doc.database
const circle = new AcDbCircle(center, radius)
db.tables.blockTable.modelSpace.appendEntity(circle)
}
}Notes
execute()receives the currentAcApContext, which contains:context.viewcontext.doc
If you are already inside a command, always prefer:
context.view.editorinstead of accessing the global editor instance.
Complex Commands (Multi-Branch) with State Machines
For commands with continuous input + multiple branches + real-time preview (for example, PLINE), avoid putting all command logic into a single Jig. This quickly becomes hard to maintain.
Recommended approach:
- Command logic is handled by a state machine (branch control)
- Jig only handles preview for the current state
This separation keeps the command readable and lets you reuse the state machine pattern in other complex commands.
Why Use a State Machine
- Complex commands need to react to multiple keyword branches
- Each branch has different prompt rules and preview behavior
- The state machine keeps these concerns local to each state
Recommended Architecture
Command Controller
│
├── State Machine
│ ├── LineState
│ ├── ArcState
│ ├── CloseState
│ ├── UndoState
│ └── ...
│
└── Jig (preview only)State Machine Base Class
The project provides a reusable state machine helper for prompt-driven commands:
export interface AcEdPromptState<TPromptOptions, TResult> {
buildPrompt(): TPromptOptions
handleResult(result: TResult): Promise<'continue' | 'finish'> | 'continue' | 'finish'
}
export class AcEdPromptStateMachine<TPromptOptions, TResult> {
setState(state: AcEdPromptState<TPromptOptions, TResult>): void
run(getResult: (prompt: TPromptOptions) => Promise<TResult>): Promise<void>
}Interaction Between State and Jig
- Each state builds its own prompt options
- The prompt attaches a Jig for preview only
- The Jig does not know about keywords or branching
In other words:
- The state decides what to ask and how to transition
- The Jig only draws the current preview for that state
Example: Multi-Branch Polyline (Simplified)
class LineState implements AcEdPromptState<AcEdPromptPointOptions, AcEdPromptPointResult> {
buildPrompt() {
const prompt = new AcEdPromptPointOptions('Specify next point or')
prompt.keywords.add('Arc(A)', 'Arc', 'Arc')
prompt.keywords.add('Undo(U)', 'Undo', 'Undo')
prompt.keywords.add('Close(C)', 'Close', 'Close')
prompt.jig = new AcApPolylineJig(view, points, bulges)
return prompt
}
async handleResult(result: AcEdPromptPointResult) {
if (result.status === AcEdPromptStatus.Keyword) {
if (result.stringResult === 'Arc') machine.setState(new ArcState(machine))
if (result.stringResult === 'Undo') undo()
if (result.stringResult === 'Close') return 'finish'
return 'continue'
}
if (result.status === AcEdPromptStatus.OK) {
appendPoint(result.value!)
return 'continue'
}
return 'finish'
}
}Practical Notes
- Keep the data model (points, bulges, preview state) in the command controller
- Each state should be small and focused
- Prefer
AcEdPromptStateMachinefor shared workflow patterns - The command controller owns the final entity creation
For a full example, see the polyline command implementation.
Listening to Command Lifecycle Events
Command lifecycle events are exposed by the editor, not by the command itself.
Available Editor Events
public readonly events = {
sysVarChanged: new AcCmEventManager<AcDbSysVarEventArgs>(),
/** Fired just before the command starts executing */
commandWillStart: new AcCmEventManager<AcEdCommandEventArgs>(),
/** Fired after the command finishes executing */
commandEnded: new AcCmEventManager<AcEdCommandEventArgs>()
}Getting the AcEditor Instance
Depending on where you are in the application:
From anywhere (global access)
const editor =
AcApDocManager.instance.curView.editorInside a command execution
const editor = context.view.editorExample: Listening to Command Events
const editor = AcApDocManager.instance.curView.editor
editor.events.commandWillStart.addEventListener(({ command }) => {
console.log('Command will start:', command.globalName)
})
editor.events.commandEnded.addEventListener(({ command }) => {
console.log('Command ended:', command.globalName)
})Typical Use Cases
- Tracking command history
- Updating UI state (toolbars, panels)
- Locking or unlocking interactions
- Implementing analytics or telemetry
- Emulating ObjectARX
commandWillStart/commandEndedbehavior
Command Access Mode
AcEdCommand now supports a minimum access mode requirement, allowing commands to declare what document access level they need.
Open Modes
MLightCAD supports three document open modes:
export enum AcEdOpenMode {
/** Read-only mode */
Read = 0,
/** Review mode (compatible with Read) */
Review = 4,
/** Write mode (compatible with Review and Read) */
Write = 8
}Higher-value modes are compatible with lower-value modes:
Write≥Review≥Read
You can access the current document’s open mode via:
context.doc.openModeor
AcApDocument.openModeSetting Command Mode
Each command can declare the minimum required access mode:
import { AcEdOpenMode } from '@mlightcad/cad-simple-viewer'
export class AcApCircleCmd extends AcEdCommand {
constructor() {
super()
this.mode = AcEdOpenMode.Write
}
async execute(context: AcApContext) {
// write operations
}
}Mode Property
get mode(): AcEdOpenMode
set mode(value: AcEdOpenMode)Behavior
A command can only execute if:
document.openMode >= command.modeExamples:
- A
Writecommand cannot run inReadmode - A
Reviewcommand can run inWritemode - A
Readcommand runs in all modes
- A
This enables:
- Safe read-only viewers
- Review-only workflows
- Strict write protection for editing commands
Registering a Command
Commands are registered through AcApDocManager.instance.commandManager.
import { AcApDocManager } from '@mlightcad/cad-simple-viewer'
import { AcApCircleCmd } from './AcApCircleCmd'
const register = AcApDocManager.instance.commandManager
const circleCommand = new AcApCircleCmd()
circleCommand.globalName = 'CIRCLE'
circleCommand.localName = 'CIRCLE'
register.addCommand(
'USER',
circleCommand.globalName,
circleCommand.localName,
circleCommand
)Notes
globalNamemust be unique within the command grouplocalNameis user-facing and can be localizedCommand groups:
ACAD– system commandsUSER– custom commands
Command Alias
MLightCAD supports AutoCAD-style command aliases (similar to acad.pgp behavior).
Register Alias with addCommand
You can provide aliases in the 5th argument of addCommand:
register.addCommand(
'USER',
'MYCMD',
'MyCmd',
myCommand,
['MC', 'MY']
)Alias rules:
- Alias matching is case-insensitive (internally normalized to uppercase)
- Alias must be unique inside the same command group
- Alias cannot conflict with existing global/local command names in the same group
Configure Aliases in AcApDocManager
AcApDocManagerOptions provides commandAliases so you can override alias mapping at initialization:
AcApDocManager.createInstance({
commandAliases: {
LINE: ['L', 'LN'],
CIRCLE: 'CI',
ZOOM: ['Z']
}
})Behavior:
- If a command has user-configured aliases, they replace built-in defaults for that command
- If a command is not configured, built-in default aliases are used
Lookup and UI Behavior
lookupGlobalCmd()andlookupLocalCmd()both support alias lookupsearchCommandsByPrefix()supports alias prefix matching- Command line auto-complete shows aliases in the list, for example:
LINE(L, LN) - description
Triggering a Command
Once registered, a command can be executed programmatically:
AcApDocManager.instance.sendStringToExecute('CIRCLE')Complete Workflow
- Create a command by extending
AcEdCommand - (Optional) Set the command’s required access
mode - Register it with
commandManager - (Optional) Listen to lifecycle events via
AcEditorif needed - Trigger the command by name or programmatically
Localizing a Custom Command
In https://github.com/mlightcad/cad-simple-viewer-example you can find a full example demonstrating custom commands with localization support.