Files
OpenNest/OpenNest.IO/ProgramReader.cs
T
ajandClaude Opus 5.5 ba39c3dc55 feat(cad): add tabbed cutouts with draggable tabs
A hole can now be declared a tabbed cutout in the CAD Converter: pick a
circle or any edge of a closed hole, set the tab count and width, and drag
the tabs around the hole. The definition persists on the drawing and in the
nest file, and stays editable from the source entities.

The builder replaces the hole with open cut pieces and uncut spans on a new
Tab layer. Tabs are material, so area, nesting and overlap checks still see
a closed hole, but they are never cut. Plan Cutting and Assign Lead-ins cut
each piece from the slug with the internal lead-in and no lead-out. A lead
that would reach back over a tab is mirrored, and an arc lead onto an arc
pierces straight in. Planning accounts for each span between tabs and runs
the open-cut proof, so its own tabbed cutouts certify; tabs followed by a
lead-out are still refused. Pre-post review flags programs that still
contain tab motions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:50:11 -04:00

768 lines
25 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.IO
{
public sealed class ProgramReader
{
private const int BufferSize = 200;
private int codeIndex;
private CodeBlock block;
private CodeSection section;
private Program program;
private StreamReader reader;
private Dictionary<string, double> resolvedVariables;
public ProgramReader(Stream stream)
{
reader = new StreamReader(stream);
program = new Program();
}
public Program Read()
{
// First pass: read all lines, collect variable definitions
var allLines = new List<string>();
var variableDefs = new Dictionary<
string,
(string expression, bool inline, bool global)
>(StringComparer.OrdinalIgnoreCase);
var codeLines = new List<string>();
string line;
while ((line = reader.ReadLine()) != null)
{
allLines.Add(line);
if (
TryParseVariableDefinition(
line,
out var name,
out var expression,
out var isInline,
out var isGlobal
)
)
variableDefs[name] = (expression, isInline, isGlobal);
else
codeLines.Add(line);
}
// Evaluate variables with topological sort for dependency ordering
resolvedVariables = ResolveVariables(variableDefs);
// Store evaluated variables on the program
foreach (var kvp in variableDefs)
{
var name = kvp.Key;
var (expression, isInline, isGlobal) = kvp.Value;
var value = resolvedVariables[name];
program.Variables[name] = new VariableDefinition(
name,
expression,
value,
isInline,
isGlobal
);
}
// Second pass: parse G-code lines with variable substitution
foreach (var codeLine in codeLines)
{
block = ParseBlock(codeLine);
ProcessCurrentBlock();
}
return program;
}
private CodeBlock ParseBlock(string line)
{
var block = new CodeBlock();
Code code = null;
for (var i = 0; i < line.Length; ++i)
{
var c = line[i];
if (c == '$' && code != null && resolvedVariables != null)
{
// Read the maximal variable name (letters, digits, underscores)
var start = i + 1;
while (
start < line.Length
&& (char.IsLetterOrDigit(line[start]) || line[start] == '_')
)
start++;
var maxName = line.Substring(i + 1, start - i - 1);
// Try longest match first, then progressively shorter to handle
// cases like X$widthY0 where "widthY" isn't a variable but "width" is
string lookupKey = null;
var nameLen = maxName.Length;
while (nameLen > 0)
{
var candidate = maxName.Substring(0, nameLen);
lookupKey = resolvedVariables.Keys.FirstOrDefault(k =>
string.Equals(k, candidate, StringComparison.OrdinalIgnoreCase)
);
if (lookupKey != null)
break;
nameLen--;
}
if (lookupKey != null)
{
code.Value = resolvedVariables[lookupKey]
.ToString(CultureInfo.InvariantCulture);
code.VariableRef = lookupKey;
i += nameLen; // advance past the matched variable name
}
else
{
i = start - 1; // no match, skip the whole thing
}
}
else if (char.IsLetter(c))
block.Add((code = new Code(c)));
else if (c == ':')
{
block.Add((new Code(c, line.Remove(0, i + 1).Trim())));
break;
}
else if (code != null)
code.Value += c;
}
return block;
}
private void ProcessCurrentBlock()
{
var code = GetFirstCode();
while (code != null)
{
switch (code.Id)
{
case ':':
program.Codes.Add(new Comment(code.Value));
code = GetNextCode();
break;
case 'G':
int value = int.Parse(code.Value);
switch (value)
{
case 0:
case 1:
section = CodeSection.Line;
ReadLine(value == 0);
code = GetCurrentCode();
break;
case 2:
case 3:
section = CodeSection.Arc;
ReadArc(value == 2 ? RotationType.CW : RotationType.CCW);
code = GetCurrentCode();
break;
case 65:
section = CodeSection.SubProgram;
ReadSubProgram();
code = GetCurrentCode();
break;
case 40:
program.Codes.Add(new Kerf() { Value = KerfType.None });
code = GetNextCode();
break;
case 41:
program.Codes.Add(new Kerf() { Value = KerfType.Left });
code = GetNextCode();
break;
case 42:
program.Codes.Add(new Kerf() { Value = KerfType.Right });
code = GetNextCode();
break;
case 90:
program.Mode = Mode.Absolute;
code = GetNextCode();
break;
case 91:
program.Mode = Mode.Incremental;
code = GetNextCode();
break;
default:
code = GetNextCode();
break;
}
break;
case 'F':
var feedrate = new Feedrate() { Value = double.Parse(code.Value) };
if (code.VariableRef != null)
feedrate.VariableRef = code.VariableRef;
program.Codes.Add(feedrate);
code = GetNextCode();
break;
default:
code = GetNextCode();
break;
}
}
}
private void ReadLine(bool isRapid)
{
var line = new LinearMove();
double x = 0;
double y = 0;
var layer = LayerType.Cut;
var suppressed = false;
string xRef = null,
yRef = null;
while (section == CodeSection.Line)
{
var code = GetNextCode();
if (code == null)
{
section = CodeSection.Unknown;
break;
}
switch (code.Id)
{
case 'X':
x = double.Parse(code.Value);
xRef = code.VariableRef;
break;
case 'Y':
y = double.Parse(code.Value);
yRef = code.VariableRef;
break;
case ':':
{
var tags = code.Value.Trim().ToUpper().Split(':');
foreach (var tag in tags)
{
switch (tag)
{
case "DISPLAY":
layer = LayerType.Display;
break;
case "LEADIN":
layer = LayerType.Leadin;
break;
case "LEADOUT":
layer = LayerType.Leadout;
break;
case "SCRIBE":
layer = LayerType.Scribe;
break;
case "BREAK":
layer = LayerType.Break;
break;
case "TAB":
layer = LayerType.Tab;
break;
case "SUPPRESSED":
suppressed = true;
break;
}
}
break;
}
default:
section = CodeSection.Unknown;
break;
}
}
var refs = BuildVariableRefs(("X", xRef), ("Y", yRef));
if (isRapid)
program.Codes.Add(new RapidMove(x, y) { VariableRefs = refs });
else
program.Codes.Add(
new LinearMove(x, y)
{
Layer = layer,
Suppressed = suppressed,
VariableRefs = refs,
}
);
}
private void ReadArc(RotationType rotation)
{
double x = 0;
double y = 0;
double i = 0;
double j = 0;
var layer = LayerType.Cut;
var suppressed = false;
string xRef = null,
yRef = null,
iRef = null,
jRef = null;
while (section == CodeSection.Arc)
{
var code = GetNextCode();
if (code == null)
{
section = CodeSection.Unknown;
break;
}
switch (code.Id)
{
case 'X':
x = double.Parse(code.Value);
xRef = code.VariableRef;
break;
case 'Y':
y = double.Parse(code.Value);
yRef = code.VariableRef;
break;
case 'I':
i = double.Parse(code.Value);
iRef = code.VariableRef;
break;
case 'J':
j = double.Parse(code.Value);
jRef = code.VariableRef;
break;
case ':':
{
var tags = code.Value.Trim().ToUpper().Split(':');
foreach (var tag in tags)
{
switch (tag)
{
case "DISPLAY":
layer = LayerType.Display;
break;
case "LEADIN":
layer = LayerType.Leadin;
break;
case "LEADOUT":
layer = LayerType.Leadout;
break;
case "SCRIBE":
layer = LayerType.Scribe;
break;
case "BREAK":
layer = LayerType.Break;
break;
case "TAB":
layer = LayerType.Tab;
break;
case "SUPPRESSED":
suppressed = true;
break;
}
}
break;
}
default:
section = CodeSection.Unknown;
break;
}
}
program.Codes.Add(
new ArcMove()
{
EndPoint = new Vector(x, y),
CenterPoint = new Vector(i, j),
Rotation = rotation,
Layer = layer,
Suppressed = suppressed,
VariableRefs = BuildVariableRefs(
("X", xRef),
("Y", yRef),
("I", iRef),
("J", jRef)
),
}
);
}
private void ReadSubProgram()
{
var p = 0;
var r = 0.0;
var x = 0.0;
var y = 0.0;
while (section == CodeSection.SubProgram)
{
var code = GetNextCode();
if (code == null)
{
section = CodeSection.Unknown;
break;
}
switch (code.Id)
{
case 'P':
p = int.Parse(code.Value);
break;
case 'R':
r = double.Parse(code.Value);
break;
case 'X':
x = double.Parse(code.Value);
break;
case 'Y':
y = double.Parse(code.Value);
break;
default:
section = CodeSection.Unknown;
break;
}
}
program.Codes.Add(
new SubProgramCall
{
Id = p,
Rotation = r,
Offset = new Geometry.Vector(x, y),
}
);
}
private Code GetNextCode()
{
codeIndex++;
if (codeIndex >= block.Count)
return null;
return block[codeIndex];
}
private Code GetCurrentCode()
{
if (codeIndex >= block.Count)
return null;
return block[codeIndex];
}
private Code GetFirstCode()
{
if (block.Count == 0)
return null;
codeIndex = 0;
return block[codeIndex];
}
private static bool TryParseVariableDefinition(
string line,
out string name,
out string expression,
out bool isInline,
out bool isGlobal
)
{
name = null;
expression = null;
isInline = false;
isGlobal = false;
var trimmed = line.Trim();
if (trimmed.Length == 0)
return false;
// Must start with a letter or underscore (not a G-code letter followed by a digit)
var firstChar = trimmed[0];
if (!char.IsLetter(firstChar) && firstChar != '_')
return false;
// If line starts with a known G-code letter followed by a digit, it's not a variable
if (trimmed.Length >= 2 && char.IsDigit(trimmed[1]))
{
var upper = char.ToUpper(firstChar);
if (
upper
is 'G'
or 'M'
or 'N'
or 'F'
or 'X'
or 'Y'
or 'I'
or 'J'
or 'T'
or 'S'
or 'O'
or 'P'
or 'R'
)
return false;
}
// Must contain '='
var eqIndex = trimmed.IndexOf('=');
if (eqIndex < 1)
return false;
// Extract name (everything before '=', trimmed)
var rawName = trimmed.Substring(0, eqIndex).Trim();
// Validate name: must be identifier (letter/underscore followed by alphanumeric/underscore)
if (rawName.Length == 0 || (!char.IsLetter(rawName[0]) && rawName[0] != '_'))
return false;
for (var i = 1; i < rawName.Length; i++)
{
if (!char.IsLetterOrDigit(rawName[i]) && rawName[i] != '_')
return false;
}
// Extract expression and flags from the remainder after '='
var remainder = trimmed.Substring(eqIndex + 1).Trim();
// Check for trailing flags: inline and/or global
// Parse from the end to separate expression from flags
var words = remainder.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var flagStart = words.Length;
for (var i = words.Length - 1; i >= 0; i--)
{
var word = words[i].ToLowerInvariant();
if (word == "inline" || word == "global")
flagStart = i;
else
break;
}
// Build expression from non-flag words
var expressionParts = words.Take(flagStart).ToArray();
if (expressionParts.Length == 0)
return false;
expression = string.Join(" ", expressionParts);
// Parse flags
for (var i = flagStart; i < words.Length; i++)
{
var word = words[i].ToLowerInvariant();
if (word == "inline")
isInline = true;
else if (word == "global")
isGlobal = true;
}
name = rawName;
return true;
}
private static Dictionary<string, double> ResolveVariables(
Dictionary<string, (string expression, bool inline, bool global)> variableDefs
)
{
if (variableDefs.Count == 0)
return new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
// Build dependency graph
var dependencies = new Dictionary<string, List<string>>(
StringComparer.OrdinalIgnoreCase
);
foreach (var kvp in variableDefs)
{
var deps = new List<string>();
var expr = kvp.Value.expression;
for (var i = 0; i < expr.Length; i++)
{
if (expr[i] == '$')
{
var start = i + 1;
while (
start < expr.Length
&& (char.IsLetterOrDigit(expr[start]) || expr[start] == '_')
)
start++;
var refName = expr.Substring(i + 1, start - i - 1);
// Find the canonical name (case-insensitive match)
var canonical = variableDefs.Keys.FirstOrDefault(k =>
string.Equals(k, refName, StringComparison.OrdinalIgnoreCase)
);
if (canonical != null)
deps.Add(canonical);
i = start - 1;
}
}
dependencies[kvp.Key] = deps;
}
// Topological sort (Kahn's algorithm)
var inDegree = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var name in variableDefs.Keys)
inDegree[name] = 0;
foreach (var kvp in dependencies)
{
foreach (var dep in kvp.Value)
{
if (inDegree.ContainsKey(dep))
inDegree[kvp.Key]++;
}
}
var queue = new Queue<string>();
foreach (var kvp in inDegree)
{
if (kvp.Value == 0)
queue.Enqueue(kvp.Key);
}
var resolved = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
var order = new List<string>();
while (queue.Count > 0)
{
var current = queue.Dequeue();
order.Add(current);
// Evaluate this variable
var expr = variableDefs[current].expression;
var value = ExpressionEvaluator.Evaluate(expr, resolved);
resolved[current] = value;
// Reduce in-degree of dependents
foreach (var kvp in dependencies)
{
if (kvp.Value.Contains(current, StringComparer.OrdinalIgnoreCase))
{
inDegree[kvp.Key]--;
if (inDegree[kvp.Key] == 0 && !order.Contains(kvp.Key))
queue.Enqueue(kvp.Key);
}
}
}
if (order.Count != variableDefs.Count)
throw new InvalidOperationException(
"Circular dependency detected among variables."
);
return resolved;
}
private static Dictionary<string, string> BuildVariableRefs(
params (string axis, string varRef)[] refs
)
{
Dictionary<string, string> result = null;
foreach (var (axis, varRef) in refs)
{
if (varRef != null)
{
result ??= new Dictionary<string, string>();
result[axis] = varRef;
}
}
return result;
}
public void Close()
{
reader.Close();
}
private class Code
{
public Code(char id)
{
Id = id;
Value = string.Empty;
}
public Code(char id, string value)
{
Id = id;
Value = value;
}
public char Id { get; private set; }
public string Value { get; set; }
public string VariableRef { get; set; }
public override string ToString()
{
return Id + Value;
}
}
private class CodeBlock : List<Code>
{
public void Add(char id, string value)
{
Add(new Code(id, value));
}
public override string ToString()
{
var builder = new StringBuilder();
foreach (var code in this)
builder.Append(code.ToString() + " ");
return builder.ToString();
}
}
private enum CodeSection
{
Unknown,
Arc,
Line,
SubProgram,
}
}
}