C# for AutoLISP Programmers: The 10 Concepts That Translate (and Where the Analogy Breaks)
How LISP defun, setq, and lists map to C# methods, variables, and collections — translated for AutoCAD solar engineers who need to read modern .NET plugin code.
C# for AutoLISP Programmers: The 10 Concepts That Translate (and Where the Analogy Breaks)
You built the Hello World plugin in the previous post. It worked. You opened the .cs file again to add a second command and the syntax was foreign noise. using, namespace, [CommandMethod(...)], public class, void, semicolons everywhere. LISP has none of these. Every example you find online assumes you already know what they mean.
By the end of this post you'll be able to read a real AutoCAD .NET plugin file and know what every line does. We'll translate every C# concept from a LISP equivalent first. Plan for 35 minutes.
1. Variables: setq → typed assignment
In LISP you just name a variable and assign it. The type doesn't matter and the interpreter doesn't care:
(setq panel-count 60)
(setq panel-width 1.046)
(setq panel-name "REC400")
In C# every variable has a type declared up front, and the compiler enforces it:
int panelCount = 60;
double panelWidth = 1.046;
string panelName = "REC400";
Three things to notice immediately:
- The type (
int,double,string) comes before the variable name. Assigning a string to anintvariable is a build error — it won't compile. - Every statement ends with a semicolon.
- Convention is camelCase (
panelCount), not hyphenated (panel-count). Hyphens aren't legal in C# identifiers because-is the subtraction operator.
You'll also see the var shortcut constantly:
var panelCount = 60; // compiler infers int
var panelWidth = 1.046; // compiler infers double
var doesn't mean "untyped." The compiler infers the type from the right side of the assignment and enforces it from that point forward. It's purely a convenience — you're not giving up type safety when you use it.
2. Methods: defun → public void / public TYPE
A LISP function that calculates string length looks like this:
(defun calc-string-length (panel-count panel-width spacing)
(+ (* panel-count panel-width)
(* (- panel-count 1) spacing)))
The C# equivalent:
public double CalcStringLength(int panelCount, double panelWidth, double spacing)
{
return panelCount * panelWidth + (panelCount - 1) * spacing;
}
What changed:
publiccontrols visibility (more in section 4). For now, read it as "this method is accessible."doubleis the return type — declared before the method name. Every method states what it returns.- Method names use PascalCase (
CalcStringLength). - Parameters are declared with type first, then name:
int panelCount, notpanelCount int. - Curly braces
{ }wrap the method body instead of parentheses. returnis required and explicit. LISP returns the value of the last expression automatically. C# won't compile without thereturnkeyword.
If a method does something but doesn't return a value, the return type is void:
public void PrintPanelCount()
{
Console.WriteLine("60 panels");
}
LISP's princ becomes Console.WriteLine. Same idea, different syntax.
3. Classes: the container LISP didn't have
In LISP your defuns lived at the top level of a file, globally accessible once loaded. In C# every method must live inside a class. There is no top-level code in a standard .NET plugin.
Your Hello World plugin had this structure:
public class Commands
{
[CommandMethod("HELLOSOLAR")]
public void HelloSolar()
{
// ...
}
}
public class Commands { ... } is the container. Methods and variables live inside it. You can put multiple commands in the same class:
public class Commands
{
[CommandMethod("HELLOSOLAR")]
public void HelloSolar() { /* ... */ }
[CommandMethod("COUNTPANELS")]
public void CountPanels() { /* ... */ }
}
A class is also a type. You can create instances of it (var c = new Commands();) and call its methods. For AutoCAD command classes you don't usually instantiate them yourself — AutoCAD does it via reflection when you NETLOAD the .dll. But understanding that a class is a type matters once you start passing data between methods.
Why classes exist: they group related data and behavior together. When you build a real solar plugin you'll have a Panel class that holds X, Y, and Type properties alongside methods like DistanceTo(Panel other). The class keeps the panel data and the panel logic in one place instead of scattered across global defuns.
4. Namespaces: like a package, but enforced
At the top of your Hello World file, wrapping the class:
namespace HelloAutoCAD
{
public class Commands { /* ... */ }
}
A namespace is a container for classes. It prevents name collisions: if you have a Commands class in HelloAutoCAD and another Commands class in SomeOtherPlugin, they don't conflict because their full names are HelloAutoCAD.Commands and SomeOtherPlugin.Commands.
The using statements at the top of every file let you skip the namespace prefix when calling into other libraries:
using Autodesk.AutoCAD.Runtime; // makes [CommandMethod] available
using Autodesk.AutoCAD.ApplicationServices; // makes Application, Document available
Without those using lines you'd have to write Autodesk.AutoCAD.Runtime.CommandMethod every time you referenced it. Nobody does that. The using lines are how you tell C# which namespaces to search when it resolves a name.
LISP has no equivalent. Every defun in a loaded .lsp file was global. C# enforces module boundaries by design.
5. Lists → List<T>: typed collections
LISP lists are untyped — you can put anything in them:
(setq panels '("P001" "P002" "P003"))
(length panels)
(setq panels (cons "P000" panels))
C# lists are typed:
List<string> panels = new List<string> { "P001", "P002", "P003" };
panels.Count;
panels.Insert(0, "P000");
Breaking down List<string>:
List<string>is a list that holds strings. The<string>is a type parameter — it tells the compiler what kind of objects this list contains.new List<string> { ... }creates a new list with initial values..Count(a property) replaces LISP'slengthfunction. Property access uses dot notation with no parentheses..Insert(0, ...)adds an element at a specific index. Methods on the list use dot notation with parentheses.
A list of panel objects works the same way:
List<Panel> panels = new List<Panel>();
panels.Add(new Panel("P001", 10.5, 20.3));
Type safety means you cannot accidentally put a string in a list of panels. The compiler will refuse at build time, not at runtime when a user is running your NETLOAD command at 2pm on a job site.
6. Dictionaries: assoc lists → Dictionary<TKey, TValue>
LISP uses association lists for key-value lookup. C# has dictionaries:
Dictionary<string, int> stringSizeByMppt = new Dictionary<string, int>
{
{ "MPPT-1", 14 },
{ "MPPT-2", 12 }
};
stringSizeByMppt["MPPT-1"]; // 14
stringSizeByMppt["MPPT-3"] = 10; // add a new entry
stringSizeByMppt.ContainsKey("MPPT-1"); // true
The <string, int> declares the key type and value type. The compiler enforces both — you can't look up an entry with an integer key if the dictionary uses string keys.
Dictionaries show up everywhere in real C# code: config maps, lookup tables, caches. If you've written Python, they're identical to Python dicts. If you've worked with JSON, think of a dictionary as a typed JSON object.
7. nil → null (and the trap)
LISP's nil is the empty value. C# has null. They look the same but behave differently:
string panelName = null;
Console.WriteLine(panelName.Length); // CRASH: NullReferenceException
In LISP, (length nil) returns 0. In C#, calling .Length on a null string crashes the plugin with a NullReferenceException at runtime. This is the single most common bug in C# code written by anyone, at any level. Always check before accessing properties on something that could be null:
if (panelName != null)
{
Console.WriteLine(panelName.Length);
}
Modern C# has a shortcut called the null-conditional operator:
Console.WriteLine(panelName?.Length); // prints nothing if null, prints length if not
The ?. returns null instead of crashing. Use it whenever you're calling a method or accessing a property on something that might be null.
One important distinction: value types like int and double cannot be null at all. They always have a default value (0 for numbers, false for booleans). Only reference types — string, classes, lists — can be null. If you need a nullable integer, C# has int? syntax for that, but you won't need it in most plugin work.
8. Conditionals: cond → if/else, switch
LISP's cond handles multi-way branching:
(cond
((> panel-count 20) "long string")
((> panel-count 10) "medium string")
(t "short string"))
C# uses if/else if/else:
string label;
if (panelCount > 20)
{
label = "long string";
}
else if (panelCount > 10)
{
label = "medium string";
}
else
{
label = "short string";
}
The compact ternary works for simple two-way decisions:
string label = panelCount > 20 ? "long string" : "short string";
You can chain ternaries for multi-way branching, though this gets harder to read quickly:
string label = panelCount > 20 ? "long string"
: panelCount > 10 ? "medium string"
: "short string";
For branching on a single value with multiple exact matches, use switch:
switch (mpptInput)
{
case 1: Console.WriteLine("MPPT 1"); break;
case 2: Console.WriteLine("MPPT 2"); break;
default: Console.WriteLine("Unknown"); break;
}
The break statements are required. Unlike LISP's cond — which stops at the first matching clause automatically — C#'s switch will fall through to the next case unless you explicitly break out. Forgetting a break is a common source of bugs when you're new to C#.
9. Loops: foreach is your friend
LISP's foreach:
(foreach panel panels
(princ (cdr (assoc 'id panel))))
C#'s foreach:
foreach (Panel panel in panels)
{
Console.WriteLine(panel.Id);
}
The structure is foreach (TYPE name in collection). Read it as "for each panel in panels." The type declaration (Panel) tells C# what type each element is — you get type-safe access to panel.Id, panel.X, and any other properties on the Panel class.
C# also has the C-style for loop for index-based iteration:
for (int i = 0; i < panels.Count; i++)
{
Console.WriteLine($"Panel {i}: {panels[i].Id}");
}
The $"..." syntax is string interpolation — expressions inside {...} get inserted into the string at runtime. It's C#'s equivalent of Python's f-strings. You'll use it constantly: $"Found {count} panels in {doc.Name}". It's cleaner than string concatenation and you'll see it in every real plugin.
10. Reading an AutoCAD plugin handler
You now have enough to read a real plugin without the noise feeling foreign. Here's a command that counts solar panel blocks in model space:
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.Runtime;
namespace SolarPlugin
{
public class PanelCommands
{
[CommandMethod("COUNTPANELS")]
public void CountPanels()
{
Document doc = Application.DocumentManager.MdiActiveDocument;
Database db = doc.Database;
int count = 0;
using (Transaction tr = db.TransactionManager.StartTransaction())
{
BlockTable bt = (BlockTable)tr.GetObject(db.BlockTableId, OpenMode.ForRead);
BlockTableRecord ms = (BlockTableRecord)tr.GetObject(bt[BlockTableRecord.ModelSpace], OpenMode.ForRead);
foreach (ObjectId id in ms)
{
Entity ent = (Entity)tr.GetObject(id, OpenMode.ForRead);
if (ent is BlockReference br && br.Name == "PV_PANEL")
{
count++;
}
}
tr.Commit();
}
doc.Editor.WriteMessage($"\nFound {count} panels.");
}
}
}
Line by line:
usinglines at the top: import three AutoCAD namespaces so you can referenceDocument,Database,Transaction, andCommandMethodwithout the full namespace prefix every time.namespace SolarPlugin { ... }: wraps the class in this plugin's namespace.public class PanelCommands: the container class for this plugin's commands.[CommandMethod("COUNTPANELS")]: the attribute that registersCOUNTPANELSas a typed AutoCAD command. AutoCAD finds this via reflection when youNETLOADthe.dll.using (Transaction tr = db.TransactionManager.StartTransaction()): opens a database transaction. Thisusingis not the importusing— it's a resource-disposal block that guarantees the transaction is closed when the block exits, even if something throws an exception. Same keyword, completely different meaning. This trips everyone up the first time.(BlockTable)tr.GetObject(...): a cast.GetObjectreturns a genericDBObject— C# requires you to cast it to the specific type you actually need. The parenthesized type before the expression is the cast syntax.foreach (ObjectId id in ms): iterates every entity in model space.if (ent is BlockReference br && br.Name == "PV_PANEL"): pattern matching.is BlockReference brchecks whetherentis aBlockReferenceand, if so, stores it in a new variablebr— all in one expression. Then&& br.Name == "PV_PANEL"checks the block name. This is more compact than a separate cast and null check.tr.Commit(): commits the transaction. Reads don't strictly require a commit, but it's good practice.$"\nFound {count} panels.": string interpolation writing the result to the AutoCAD command line.
You don't need to memorize the AutoCAD API calls yet. The point is that the structure of the file is now readable — you can map every line to a concept you understand.
Where to go next
- Migrating Solar AutoLISP Routines to .NET — port your existing LISP work to C# without rewriting everything from scratch. Covers the incremental approach: keep LISP for in-AutoCAD geometry, call C# for everything else. (~45 minutes)
- AutoCAD Solar Plugin in C# with Claude (Complete Guide) — the production deep-dive. Builds a real solar plugin end-to-end, covering the AutoCAD API patterns you'll use on actual jobs. (~60 minutes)
If you'd rather skip the plugin maintenance entirely, Branch handles panel routing, string layout, and PDF import directly inside AutoCAD — no .NET code required. We do the drafting. You do the engineering.
- How to Write a Custom AutoCAD Solar Plugin in C# with Claude as Your Pair ProgrammerA working guide for solar engineers who want to build their own AutoCAD plugin in C# — the setup that compiles, the Transaction trap, the selection-set wall, and where Claude actually helps versus where it hallucinates LISP from 2005.Deep Dive~60 min read
- Why Your Solar AutoLISP Routines Break on AutoCAD 2024+ (And How to Migrate to .NET)AutoCAD 2024 quietly broke selection set calls that worked since 2003. Here's what changed, why (command) is 66x slower than entmake, and the migration path solar engineers actually need.Deep Dive~45 min read