Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,30 @@
| **Smart Updates** | Efficient parameter changes without AI re-generation |
| **Custom Fonts** | Built-in Geist font support for text in models |

## Design Tree Annotations

CADAM can show a lightweight design tree for OpenSCAD models that include
structured `@cadam-node` line comments. This first pass is source-driven: it
does not map nodes to mesh faces or viewport selection.

Supported JSON fields are `id` (required string), `kind` (required: `part`,
`operation`, `group`, or `parameter`), `name`, `parentId`, `params`, and
`moduleName`. `name` defaults to `id`, and `params` should be a string array.

```scad
width = 40; // [10:80]
height = 12; // [4:30]

// @cadam-node {"id":"base","kind":"part","name":"Base","params":["width","height"],"moduleName":"base"}
module base() {
cube([width, 20, height]);
}

base();
```

See issue #139 for the initial design tree viewer scope.

## 📸 Demo

<!-- Add demo GIFs or screenshots here -->
Expand Down
103 changes: 103 additions & 0 deletions shared/parseDesignTree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import parseDesignTree from './parseDesignTree.ts';

describe('parseDesignTree', () => {
it('parses a valid single node', () => {
const result = parseDesignTree(
'// @cadam-node {"id":"base","kind":"part","name":"Base","params":["width","height"],"moduleName":"base"}',
);

assert.deepEqual(result, {
nodes: [
{
id: 'base',
kind: 'part',
name: 'Base',
params: ['width', 'height'],
moduleName: 'base',
},
],
warnings: [],
});
});

it('parses valid parent and child nodes', () => {
const result = parseDesignTree(`
// @cadam-node {"id":"assembly","kind":"group","name":"Assembly"}
// @cadam-node {"id":"base-cut","kind":"operation","name":"Base cut","parentId":"assembly"}
`);

assert.deepEqual(result.nodes, [
{ id: 'assembly', kind: 'group', name: 'Assembly' },
{
id: 'base-cut',
kind: 'operation',
name: 'Base cut',
parentId: 'assembly',
},
]);
assert.deepEqual(result.warnings, []);
});

it('returns a warning for invalid JSON', () => {
const result = parseDesignTree('// @cadam-node {"id":"base","kind":"part"');

assert.deepEqual(result.nodes, []);
assert.equal(result.warnings.length, 1);
assert.equal(result.warnings[0].code, 'invalid-json');
});

it('returns a warning for duplicate ids', () => {
const result = parseDesignTree(`
// @cadam-node {"id":"base","kind":"part"}
// @cadam-node {"id":"base","kind":"operation"}
`);

assert.deepEqual(result.nodes, [
{ id: 'base', kind: 'part', name: 'base' },
]);
assert.equal(result.warnings.length, 1);
assert.equal(result.warnings[0].code, 'duplicate-id');
assert.equal(result.warnings[0].id, 'base');
});

it('returns a warning for missing id', () => {
const result = parseDesignTree('// @cadam-node {"kind":"part"}');

assert.deepEqual(result.nodes, []);
assert.equal(result.warnings.length, 1);
assert.equal(result.warnings[0].code, 'missing-id');
});

it('returns a warning for missing kind', () => {
const result = parseDesignTree('// @cadam-node {"id":"base"}');

assert.deepEqual(result.nodes, []);
assert.equal(result.warnings.length, 1);
assert.equal(result.warnings[0].code, 'missing-kind');
assert.equal(result.warnings[0].id, 'base');
});

it('returns a warning for unknown kind', () => {
const result = parseDesignTree(
'// @cadam-node {"id":"base","kind":"sketch"}',
);

assert.deepEqual(result.nodes, []);
assert.equal(result.warnings.length, 1);
assert.equal(result.warnings[0].code, 'unknown-kind');
assert.equal(result.warnings[0].kind, 'sketch');
});

it('returns an empty result when there are no annotations', () => {
const result = parseDesignTree(`
width = 10;
module base() {
cube([width, 20, 5]);
}
`);

assert.deepEqual(result, { nodes: [], warnings: [] });
});
});
151 changes: 151 additions & 0 deletions shared/parseDesignTree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import type {
CadamDesignTreeNode,
CadamDesignTreeNodeKind,
CadamDesignTreeParseResult,
CadamDesignTreeParseWarning,
} from './types.ts';

const CADAM_NODE_COMMENT_REGEX = /^\s*\/\/\s*@cadam-node\s+(.+?)\s*$/gm;
const KNOWN_NODE_KINDS = new Set<CadamDesignTreeNodeKind>([
'part',
'operation',
'group',
'parameter',
]);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated

type CadamNodePayload = {
id?: unknown;
kind?: unknown;
name?: unknown;
parentId?: unknown;
params?: unknown;
moduleName?: unknown;
};

export default function parseDesignTree(
source: string,
): CadamDesignTreeParseResult {
const nodes: CadamDesignTreeNode[] = [];
const warnings: CadamDesignTreeParseWarning[] = [];
const seenIds = new Set<string>();

let match: RegExpExecArray | null;
while ((match = CADAM_NODE_COMMENT_REGEX.exec(source)) !== null) {
const raw = match[0];
const json = match[1].trim();
const line = lineNumberForIndex(source, match.index);
let payload: CadamNodePayload;

try {
const parsed: unknown = JSON.parse(json);
if (!isRecord(parsed)) {
warnings.push({
code: 'invalid-json',
message: '@cadam-node payload must be a JSON object.',
line,
raw,
});
continue;
}
payload = parsed;
} catch {
warnings.push({
code: 'invalid-json',
message: '@cadam-node payload is not valid JSON.',
line,
raw,
});
continue;
}

const id = stringOrUndefined(payload.id);
if (!id) {
warnings.push({
code: 'missing-id',
message: '@cadam-node payload is missing a string id.',
line,
raw,
});
continue;
}

const kind = stringOrUndefined(payload.kind);
if (!kind) {
warnings.push({
code: 'missing-kind',
message: '@cadam-node payload is missing a string kind.',
line,
raw,
id,
});
continue;
}

if (!isKnownNodeKind(kind)) {
warnings.push({
code: 'unknown-kind',
message: `@cadam-node kind "${kind}" is not supported.`,
line,
raw,
id,
kind,
});
continue;
}

if (seenIds.has(id)) {
warnings.push({
code: 'duplicate-id',
message: `@cadam-node id "${id}" was already used.`,
line,
raw,
id,
});
continue;
}

seenIds.add(id);

const node: CadamDesignTreeNode = {
id,
kind,
name: stringOrUndefined(payload.name) ?? id,
};
const parentId = stringOrUndefined(payload.parentId);
if (parentId) node.parentId = parentId;
const params = stringArrayOrUndefined(payload.params);
if (params) node.params = params;
const moduleName = stringOrUndefined(payload.moduleName);
if (moduleName) node.moduleName = moduleName;

nodes.push(node);
}

return { nodes, warnings };
}

function isKnownNodeKind(kind: string): kind is CadamDesignTreeNodeKind {
return KNOWN_NODE_KINDS.has(kind as CadamDesignTreeNodeKind);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function stringOrUndefined(value: unknown) {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}

function stringArrayOrUndefined(value: unknown) {
if (!Array.isArray(value)) return undefined;
const strings = value.filter(
(item): item is string => typeof item === 'string',
);
return strings.length > 0 ? strings : undefined;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated

function lineNumberForIndex(source: string, index: number) {
return source.slice(0, index).split('\n').length;
}
34 changes: 34 additions & 0 deletions shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,40 @@ export type Parameter = {
maxLength?: number;
};

export type CadamDesignTreeNodeKind =
| 'part'
| 'operation'
| 'group'
| 'parameter';

export type CadamDesignTreeNode = {
id: string;
kind: CadamDesignTreeNodeKind;
name: string;
parentId?: string;
params?: string[];
moduleName?: string;
};

export type CadamDesignTreeParseWarning = {
code:
| 'invalid-json'
| 'missing-id'
| 'missing-kind'
| 'duplicate-id'
| 'unknown-kind';
message: string;
line: number;
raw: string;
id?: string;
kind?: string;
};

export type CadamDesignTreeParseResult = {
nodes: CadamDesignTreeNode[];
warnings: CadamDesignTreeParseWarning[];
};

export type Conversation = Omit<
Database['public']['Tables']['conversations']['Row'],
'settings'
Expand Down
Loading