From aa7606fe4bf0ef5cbcdbdc8572fc10488db00d23 Mon Sep 17 00:00:00 2001 From: "Aarav.Patel" <157186654+aaravpatel0@users.noreply.github.com> Date: Wed, 27 May 2026 19:05:39 -0700 Subject: [PATCH 1/6] feat(parametric): add annotated design tree viewer --- README.md | 24 ++ shared/parseDesignTree.test.ts | 94 ++++++++ shared/parseDesignTree.ts | 151 +++++++++++++ shared/types.ts | 34 +++ .../design-tree/DesignTreeViewer.tsx | 208 ++++++++++++++++++ src/components/parameter/ParameterSection.tsx | 21 +- .../parameter/ParameterSheetContent.tsx | 21 +- src/views/EditorView.tsx | 38 +++- src/views/ShareView.tsx | 43 +++- 9 files changed, 625 insertions(+), 9 deletions(-) create mode 100644 shared/parseDesignTree.test.ts create mode 100644 shared/parseDesignTree.ts create mode 100644 src/components/design-tree/DesignTreeViewer.tsx diff --git a/README.md b/README.md index d97e95de..2c98e274 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/shared/parseDesignTree.test.ts b/shared/parseDesignTree.test.ts new file mode 100644 index 00000000..8dca3fde --- /dev/null +++ b/shared/parseDesignTree.test.ts @@ -0,0 +1,94 @@ +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 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: [] }); + }); +}); diff --git a/shared/parseDesignTree.ts b/shared/parseDesignTree.ts new file mode 100644 index 00000000..2f1bf528 --- /dev/null +++ b/shared/parseDesignTree.ts @@ -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([ + 'part', + 'operation', + 'group', + 'parameter', +]); + +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(); + + 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); +} + +function isRecord(value: unknown): value is Record { + 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; +} + +function lineNumberForIndex(source: string, index: number) { + return source.slice(0, index).split('\n').length; +} diff --git a/shared/types.ts b/shared/types.ts index 039f895e..1dd5d352 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -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' diff --git a/src/components/design-tree/DesignTreeViewer.tsx b/src/components/design-tree/DesignTreeViewer.tsx new file mode 100644 index 00000000..0cf992aa --- /dev/null +++ b/src/components/design-tree/DesignTreeViewer.tsx @@ -0,0 +1,208 @@ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { cn } from '@/lib/utils'; +import type { + CadamDesignTreeNode, + CadamDesignTreeParseWarning, +} from '@shared/types'; +import { + AlertTriangle, + Box, + ChevronDown, + CircleDot, + Folder, + SlidersHorizontal, + Wrench, +} from 'lucide-react'; +import { useMemo, useState } from 'react'; + +interface DesignTreeViewerProps { + nodes: CadamDesignTreeNode[]; + selectedNodeId?: string | null; + onSelectNode?: (nodeId: string) => void; + warnings: CadamDesignTreeParseWarning[]; +} + +type TreeNode = CadamDesignTreeNode & { + children: TreeNode[]; +}; + +export function DesignTreeViewer({ + nodes, + selectedNodeId, + onSelectNode, + warnings, +}: DesignTreeViewerProps) { + const treeNodes = useMemo(() => buildTree(nodes), [nodes]); + const selectedNode = useMemo( + () => nodes.find((node) => node.id === selectedNodeId), + [nodes, selectedNodeId], + ); + + return ( +
+
+
+

+ Design Tree +

+

+ Source annotations +

+
+ {warnings.length > 0 && ( + + + {warnings.length} + + )} +
+ + {warnings.length > 0 && ( +
+ {warnings.length === 1 + ? warnings[0].message + : `${warnings.length} annotations could not be shown.`} +
+ )} + + {treeNodes.length === 0 ? ( +
+ No annotated design nodes found. +
+ ) : ( +
+ {treeNodes.map((node) => ( + + ))} +
+ )} + + {selectedNode?.params && selectedNode.params.length > 0 && ( +
+ Linked params: {selectedNode.params.join(', ')} + + Parameter focus is not wired up yet. + +
+ )} +
+ ); +} + +function DesignTreeRow({ + node, + selectedNodeId, + onSelectNode, +}: { + node: TreeNode; + selectedNodeId?: string | null; + onSelectNode?: (nodeId: string) => void; +}) { + const hasChildren = node.children.length > 0; + const [open, setOpen] = useState(true); + const isSelected = selectedNodeId === node.id; + const Icon = iconForKind(node.kind); + + const content = ( + + ); + + if (!hasChildren) { + return
{content}
; + } + + return ( + +
+ + + + {content} +
+ +
+ {node.children.map((child) => ( + + ))} +
+
+
+ ); +} + +function buildTree(nodes: CadamDesignTreeNode[]) { + const byId = new Map(); + const roots: TreeNode[] = []; + + for (const node of nodes) { + byId.set(node.id, { ...node, children: [] }); + } + + for (const node of byId.values()) { + const parent = node.parentId ? byId.get(node.parentId) : undefined; + if (parent) { + parent.children.push(node); + } else { + roots.push(node); + } + } + + return roots; +} + +function iconForKind(kind: CadamDesignTreeNode['kind']) { + switch (kind) { + case 'group': + return Folder; + case 'operation': + return Wrench; + case 'parameter': + return SlidersHorizontal; + case 'part': + return Box; + default: + return CircleDot; + } +} diff --git a/src/components/parameter/ParameterSection.tsx b/src/components/parameter/ParameterSection.tsx index c70c3046..5415b814 100644 --- a/src/components/parameter/ParameterSection.tsx +++ b/src/components/parameter/ParameterSection.tsx @@ -6,9 +6,14 @@ import { Loader2, } from 'lucide-react'; import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; +import { DesignTreeViewer } from '@/components/design-tree/DesignTreeViewer'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; -import type { Parameter } from '@shared/types'; +import type { + CadamDesignTreeNode, + CadamDesignTreeParseWarning, + Parameter, +} from '@shared/types'; import { Tooltip, TooltipContent, @@ -45,6 +50,10 @@ interface ParameterSectionProps { currentOutput?: Blob; dxfExporter?: DxfExporter | null; code?: string; + designTreeNodes?: CadamDesignTreeNode[]; + designTreeWarnings?: CadamDesignTreeParseWarning[]; + selectedDesignTreeNodeId?: string | null; + onSelectDesignTreeNode?: (nodeId: string) => void; } type DownloadFormat = 'stl' | 'scad' | 'dxf'; @@ -55,6 +64,10 @@ export function ParameterSection({ currentOutput, dxfExporter, code, + designTreeNodes = [], + designTreeWarnings = [], + selectedDesignTreeNodeId, + onSelectDesignTreeNode, }: ParameterSectionProps) { const { toast } = useToast(); const [selectedFormat, setSelectedFormat] = useState('stl'); @@ -209,6 +222,12 @@ export function ParameterSection({
+ {mainParameters.length > 0 && ( void; } type DownloadFormat = 'stl' | 'scad' | 'dxf'; @@ -35,6 +44,10 @@ export function ParameterSheetContent({ currentOutput, dxfExporter, code, + designTreeNodes = [], + designTreeWarnings = [], + selectedDesignTreeNodeId, + onSelectDesignTreeNode, }: ParameterSheetContentProps) { const { toast } = useToast(); const [selectedFormat, setSelectedFormat] = useState('stl'); @@ -139,6 +152,12 @@ export function ParameterSheetContent({
+ {parameters.map((param) => ( (null); const [parameters, setParameters] = useState([]); + const [designTreeResult, setDesignTreeResult] = + useState({ nodes: [], warnings: [] }); + const [selectedDesignTreeNodeId, setSelectedDesignTreeNodeId] = useState< + string | null + >(null); const [currentOutput, setCurrentOutput] = useState(); const [dxfExporter, setDxfExporter] = useState(null); const [mobilePreviewVersion, setMobilePreviewVersion] = useState(0); @@ -215,6 +222,16 @@ function ConversationEditor() { [], ); + const updateDesignTree = useCallback((code: string) => { + const result = parseDesignTree(code); + setDesignTreeResult(result); + setSelectedDesignTreeNodeId((currentId) => + currentId && result.nodes.some((node) => node.id === currentId) + ? currentId + : null, + ); + }, []); + // ── Source of truth: DB messages → tree → branch ─────────────────────── const { data: dbMessages = [], isFetched: areMessagesFetched } = useMessagesQuery(); @@ -432,16 +449,19 @@ function ConversationEditor() { // always yields the same ``, no matter which // model wrote it. setParameters(parseParameters(artifact.code)); + updateDesignTree(artifact.code); setCurrentOutput(undefined); setDxfExporter(() => null); setActivePreview({ type: 'artifact', messageId, artifact }); setMobilePreviewVersion((version) => version + 1); }, - [], + [updateDesignTree], ); const handleViewMesh = useCallback((meshId: string, messageId: string) => { setCurrentOutput(undefined); setDxfExporter(() => null); + setDesignTreeResult({ nodes: [], warnings: [] }); + setSelectedDesignTreeNodeId(null); setActivePreview({ type: 'mesh', messageId, meshId }); setMobilePreviewVersion((version) => version + 1); }, []); @@ -454,6 +474,7 @@ function ConversationEditor() { nextCode = updateParameter(nextCode, parameter); } setParameters(nextParameters); + updateDesignTree(nextCode); setActivePreview({ ...activePreview, artifact: { @@ -462,7 +483,7 @@ function ConversationEditor() { }, }); }, - [activePreview], + [activePreview, updateDesignTree], ); const updatePrivacy = useCallback( @@ -483,7 +504,10 @@ function ConversationEditor() { const sharePreview = activePreview ?? persistedLatestPreview; const hasArtifact = - activePreview?.type === 'artifact' && parameters.length > 0; + activePreview?.type === 'artifact' && + (parameters.length > 0 || + designTreeResult.nodes.length > 0 || + designTreeResult.warnings.length > 0); // `useCachedAiChat` captures `initialBranch` once at Chat construction; // if the messages query hasn't completed its first fetch yet the @@ -650,6 +674,10 @@ function ConversationEditor() { onParameterChange={changeParameters} currentOutput={currentOutput} dxfExporter={dxfExporter} + designTreeNodes={designTreeResult.nodes} + designTreeWarnings={designTreeResult.warnings} + selectedDesignTreeNodeId={selectedDesignTreeNodeId} + onSelectDesignTreeNode={setSelectedDesignTreeNodeId} code={ activePreview?.type === 'artifact' ? activePreview.artifact.code @@ -664,6 +692,10 @@ function ConversationEditor() { onParameterChange={changeParameters} currentOutput={currentOutput} dxfExporter={dxfExporter} + designTreeNodes={designTreeResult.nodes} + designTreeWarnings={designTreeResult.warnings} + selectedDesignTreeNodeId={selectedDesignTreeNodeId} + onSelectDesignTreeNode={setSelectedDesignTreeNodeId} code={ activePreview?.type === 'artifact' ? activePreview.artifact.code diff --git a/src/views/ShareView.tsx b/src/views/ShareView.tsx index 08a314c7..79b15469 100644 --- a/src/views/ShareView.tsx +++ b/src/views/ShareView.tsx @@ -12,8 +12,10 @@ import { updateParameter } from '@/lib/utils'; import parseParameters from '@shared/parseParameters'; import type { AppUIMessage } from '@shared/chatAi'; import { isParametricArtifact } from '@shared/parametricParts'; +import parseDesignTree from '@shared/parseDesignTree'; import Tree from '@shared/Tree'; import type { + CadamDesignTreeParseResult, Conversation, Message, Parameter, @@ -138,10 +140,25 @@ function ConversationShare({ conversation, messages }: ConversationShareProps) { // server-side persistence. const [activePreview, setActivePreview] = useState(null); const [parameters, setParameters] = useState([]); + const [designTreeResult, setDesignTreeResult] = + useState({ nodes: [], warnings: [] }); + const [selectedDesignTreeNodeId, setSelectedDesignTreeNodeId] = useState< + string | null + >(null); const [currentOutput, setCurrentOutput] = useState(); const [mobilePreviewVersion, setMobilePreviewVersion] = useState(0); const baseCodeRef = useRef(null); + const updateDesignTree = useCallback((code: string) => { + const result = parseDesignTree(code); + setDesignTreeResult(result); + setSelectedDesignTreeNodeId((currentId) => + currentId && result.nodes.some((node) => node.id === currentId) + ? currentId + : null, + ); + }, []); + // Auto-switch the preview pane to the latest artifact / mesh in the // current branch when it changes. const lastAutoAppliedPreviewKeyRef = useRef(null); @@ -157,6 +174,7 @@ function ConversationShare({ conversation, messages }: ConversationShareProps) { if (latest.type === 'artifact') { baseCodeRef.current = latest.artifact.code; setParameters(parseParameters(latest.artifact.code)); + updateDesignTree(latest.artifact.code); setCurrentOutput(undefined); setActivePreview({ type: 'artifact', @@ -166,6 +184,8 @@ function ConversationShare({ conversation, messages }: ConversationShareProps) { setMobilePreviewVersion((version) => version + 1); } else { setCurrentOutput(undefined); + setDesignTreeResult({ nodes: [], warnings: [] }); + setSelectedDesignTreeNodeId(null); setActivePreview({ type: 'mesh', messageId: latest.messageId, @@ -173,20 +193,23 @@ function ConversationShare({ conversation, messages }: ConversationShareProps) { }); setMobilePreviewVersion((version) => version + 1); } - }, [branch]); + }, [branch, updateDesignTree]); const handleViewArtifact = useCallback( (artifact: ParametricArtifact, messageId: string) => { baseCodeRef.current = artifact.code; setParameters(parseParameters(artifact.code)); + updateDesignTree(artifact.code); setCurrentOutput(undefined); setActivePreview({ type: 'artifact', messageId, artifact }); setMobilePreviewVersion((version) => version + 1); }, - [], + [updateDesignTree], ); const handleViewMesh = useCallback((meshId: string, messageId: string) => { setCurrentOutput(undefined); + setDesignTreeResult({ nodes: [], warnings: [] }); + setSelectedDesignTreeNodeId(null); setActivePreview({ type: 'mesh', messageId, meshId }); setMobilePreviewVersion((version) => version + 1); }, []); @@ -199,6 +222,7 @@ function ConversationShare({ conversation, messages }: ConversationShareProps) { nextCode = updateParameter(nextCode, parameter); } setParameters(nextParameters); + updateDesignTree(nextCode); setActivePreview({ ...activePreview, artifact: { @@ -207,11 +231,14 @@ function ConversationShare({ conversation, messages }: ConversationShareProps) { }, }); }, - [activePreview], + [activePreview, updateDesignTree], ); const hasArtifact = - activePreview?.type === 'artifact' && parameters.length > 0; + activePreview?.type === 'artifact' && + (parameters.length > 0 || + designTreeResult.nodes.length > 0 || + designTreeResult.warnings.length > 0); return ( Date: Wed, 27 May 2026 21:52:11 -0700 Subject: [PATCH 2/6] fix(parametric): wire design tree selection to parameters --- package.json | 1 + .../design-tree/DesignTreeViewer.tsx | 19 +------ src/components/parameter/ParameterSection.tsx | 55 ++++++++++++++++--- .../parameter/ParameterSheetContent.tsx | 36 +++++++++++- 4 files changed, 85 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index a66a49e9..89187bc9 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint . --ignore-pattern 'supabase/**'", + "test:design-tree": "node --test --experimental-strip-types shared/parseDesignTree.test.ts", "typecheck": "tsc -b --noEmit", "format": "prettier --write .", "preview": "vite preview", diff --git a/src/components/design-tree/DesignTreeViewer.tsx b/src/components/design-tree/DesignTreeViewer.tsx index 0cf992aa..28985746 100644 --- a/src/components/design-tree/DesignTreeViewer.tsx +++ b/src/components/design-tree/DesignTreeViewer.tsx @@ -24,7 +24,7 @@ import { useMemo, useState } from 'react'; interface DesignTreeViewerProps { nodes: CadamDesignTreeNode[]; selectedNodeId?: string | null; - onSelectNode?: (nodeId: string) => void; + onSelectNode?: (nodeId: string | null) => void; warnings: CadamDesignTreeParseWarning[]; } @@ -39,10 +39,6 @@ export function DesignTreeViewer({ warnings, }: DesignTreeViewerProps) { const treeNodes = useMemo(() => buildTree(nodes), [nodes]); - const selectedNode = useMemo( - () => nodes.find((node) => node.id === selectedNodeId), - [nodes, selectedNodeId], - ); return (
@@ -87,15 +83,6 @@ export function DesignTreeViewer({ ))}
)} - - {selectedNode?.params && selectedNode.params.length > 0 && ( -
- Linked params: {selectedNode.params.join(', ')} - - Parameter focus is not wired up yet. - -
- )} ); } @@ -107,7 +94,7 @@ function DesignTreeRow({ }: { node: TreeNode; selectedNodeId?: string | null; - onSelectNode?: (nodeId: string) => void; + onSelectNode?: (nodeId: string | null) => void; }) { const hasChildren = node.children.length > 0; const [open, setOpen] = useState(true); @@ -118,7 +105,7 @@ function DesignTreeRow({ +
+ )} + {visibleMainParameters.length > 0 && ( Dimensions - {mainParameters.length} + {visibleMainParameters.length}
- {mainParameters.map((param) => ( + {visibleMainParameters.map((param) => ( )} - {colorParameters.length > 0 && ( + {visibleColorParameters.length > 0 && ( Colors - {colorParameters.length} + {visibleColorParameters.length}
- {colorParameters.map((param) => ( + {visibleColorParameters.map((param) => ( void; + onSelectDesignTreeNode?: (nodeId: string | null) => void; } type DownloadFormat = 'stl' | 'scad' | 'dxf'; @@ -55,6 +55,21 @@ export function ParameterSheetContent({ const debounceTimerRef = useRef | null>(null); const pendingParametersRef = useRef(null); const latestParametersRef = useRef(parameters); + const selectedDesignTreeNode = useMemo( + () => designTreeNodes.find((node) => node.id === selectedDesignTreeNodeId), + [designTreeNodes, selectedDesignTreeNodeId], + ); + const selectedParameterNames = useMemo(() => { + if (!selectedDesignTreeNode?.params?.length) return null; + return new Set(selectedDesignTreeNode.params); + }, [selectedDesignTreeNode]); + const visibleParameters = useMemo( + () => + selectedParameterNames + ? parameters.filter((param) => selectedParameterNames.has(param.name)) + : parameters, + [parameters, selectedParameterNames], + ); useEffect(() => { latestParametersRef.current = parameters; @@ -158,7 +173,22 @@ export function ParameterSheetContent({ onSelectNode={onSelectDesignTreeNode} warnings={designTreeWarnings} /> - {parameters.map((param) => ( + {selectedParameterNames && selectedDesignTreeNode && ( +
+ + Showing parameters for {selectedDesignTreeNode.name} + + +
+ )} + {visibleParameters.map((param) => ( Date: Wed, 27 May 2026 22:03:04 -0700 Subject: [PATCH 3/6] chore(parametric): make design tree tests portable --- package.json | 1 - shared/parseDesignTree.test.ts | 9 +++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 89187bc9..a66a49e9 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint . --ignore-pattern 'supabase/**'", - "test:design-tree": "node --test --experimental-strip-types shared/parseDesignTree.test.ts", "typecheck": "tsc -b --noEmit", "format": "prettier --write .", "preview": "vite preview", diff --git a/shared/parseDesignTree.test.ts b/shared/parseDesignTree.test.ts index 8dca3fde..7b6d4abd 100644 --- a/shared/parseDesignTree.test.ts +++ b/shared/parseDesignTree.test.ts @@ -70,6 +70,15 @@ describe('parseDesignTree', () => { 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"}', From 2396d6f2c7af295ab156070dc7238f48384a0dbf Mon Sep 17 00:00:00 2001 From: "Aarav.Patel" <157186654+aaravpatel0@users.noreply.github.com> Date: Sat, 30 May 2026 16:20:51 -0700 Subject: [PATCH 4/6] fix(parametric): address design tree review feedback --- shared/parseDesignTree.test.ts | 13 +++++++++ shared/parseDesignTree.ts | 23 ++++++++++----- shared/types.ts | 3 +- .../design-tree/DesignTreeViewer.tsx | 7 ++--- src/components/parameter/ParameterSection.tsx | 21 +++++++++----- .../parameter/ParameterSheetContent.tsx | 17 +++++++---- src/views/EditorView.tsx | 27 ++++++++++------- src/views/ShareView.tsx | 29 ++++++++++++------- 8 files changed, 93 insertions(+), 47 deletions(-) diff --git a/shared/parseDesignTree.test.ts b/shared/parseDesignTree.test.ts index 7b6d4abd..8ba9bb71 100644 --- a/shared/parseDesignTree.test.ts +++ b/shared/parseDesignTree.test.ts @@ -90,6 +90,19 @@ describe('parseDesignTree', () => { assert.equal(result.warnings[0].kind, 'sketch'); }); + it('returns a warning for non-string params entries', () => { + const result = parseDesignTree( + '// @cadam-node {"id":"base","kind":"part","params":["width",42,"height"]}', + ); + + assert.deepEqual(result.nodes, [ + { id: 'base', kind: 'part', name: 'base', params: ['width', 'height'] }, + ]); + assert.equal(result.warnings.length, 1); + assert.equal(result.warnings[0].code, 'invalid-param-entry'); + assert.equal(result.warnings[0].id, 'base'); + }); + it('returns an empty result when there are no annotations', () => { const result = parseDesignTree(` width = 10; diff --git a/shared/parseDesignTree.ts b/shared/parseDesignTree.ts index 2f1bf528..f0440896 100644 --- a/shared/parseDesignTree.ts +++ b/shared/parseDesignTree.ts @@ -6,7 +6,7 @@ import type { } from './types.ts'; const CADAM_NODE_COMMENT_REGEX = /^\s*\/\/\s*@cadam-node\s+(.+?)\s*$/gm; -const KNOWN_NODE_KINDS = new Set([ +const KNOWN_NODE_KINDS = new Set([ 'part', 'operation', 'group', @@ -113,8 +113,17 @@ export default function parseDesignTree( }; const parentId = stringOrUndefined(payload.parentId); if (parentId) node.parentId = parentId; - const params = stringArrayOrUndefined(payload.params); - if (params) node.params = params; + const params = stringArrayResult(payload.params); + if (params?.hasInvalidEntry) { + warnings.push({ + code: 'invalid-param-entry', + message: '@cadam-node params must contain only strings.', + line, + raw, + id, + }); + } + if (params && params.values.length > 0) node.params = params.values; const moduleName = stringOrUndefined(payload.moduleName); if (moduleName) node.moduleName = moduleName; @@ -125,7 +134,7 @@ export default function parseDesignTree( } function isKnownNodeKind(kind: string): kind is CadamDesignTreeNodeKind { - return KNOWN_NODE_KINDS.has(kind as CadamDesignTreeNodeKind); + return KNOWN_NODE_KINDS.has(kind); } function isRecord(value: unknown): value is Record { @@ -138,12 +147,12 @@ function stringOrUndefined(value: unknown) { return trimmed.length > 0 ? trimmed : undefined; } -function stringArrayOrUndefined(value: unknown) { +function stringArrayResult(value: unknown) { if (!Array.isArray(value)) return undefined; - const strings = value.filter( + const values = value.filter( (item): item is string => typeof item === 'string', ); - return strings.length > 0 ? strings : undefined; + return { values, hasInvalidEntry: values.length !== value.length }; } function lineNumberForIndex(source: string, index: number) { diff --git a/shared/types.ts b/shared/types.ts index 1dd5d352..668a6c4e 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -88,7 +88,8 @@ export type CadamDesignTreeParseWarning = { | 'missing-id' | 'missing-kind' | 'duplicate-id' - | 'unknown-kind'; + | 'unknown-kind' + | 'invalid-param-entry'; message: string; line: number; raw: string; diff --git a/src/components/design-tree/DesignTreeViewer.tsx b/src/components/design-tree/DesignTreeViewer.tsx index 28985746..7a0140f4 100644 --- a/src/components/design-tree/DesignTreeViewer.tsx +++ b/src/components/design-tree/DesignTreeViewer.tsx @@ -39,6 +39,7 @@ export function DesignTreeViewer({ warnings, }: DesignTreeViewerProps) { const treeNodes = useMemo(() => buildTree(nodes), [nodes]); + if (nodes.length === 0 && warnings.length === 0) return null; return (
@@ -67,11 +68,7 @@ export function DesignTreeViewer({
)} - {treeNodes.length === 0 ? ( -
- No annotated design nodes found. -
- ) : ( + {treeNodes.length > 0 && (
{treeNodes.map((node) => ( - selectedParameterNames + activeSelectedParameterNames ? mainParameters.filter((param) => - selectedParameterNames.has(param.name), + activeSelectedParameterNames.has(param.name), ) : mainParameters, - [mainParameters, selectedParameterNames], + [mainParameters, activeSelectedParameterNames], ); const visibleColorParameters = useMemo( () => - selectedParameterNames + activeSelectedParameterNames ? colorParameters.filter((param) => - selectedParameterNames.has(param.name), + activeSelectedParameterNames.has(param.name), ) : colorParameters, - [colorParameters, selectedParameterNames], + [colorParameters, activeSelectedParameterNames], ); const [colorsOpen, setColorsOpen] = useState(true); const [dimensionsOpen, setDimensionsOpen] = useState(true); @@ -250,11 +253,13 @@ export function ParameterSection({
- {selectedParameterNames && selectedDesignTreeNode && ( + {activeSelectedParameterNames && selectedDesignTreeNode && (
Showing parameters for {selectedDesignTreeNode.name} diff --git a/src/components/parameter/ParameterSheetContent.tsx b/src/components/parameter/ParameterSheetContent.tsx index e99b8c9e..775bee35 100644 --- a/src/components/parameter/ParameterSheetContent.tsx +++ b/src/components/parameter/ParameterSheetContent.tsx @@ -63,12 +63,17 @@ export function ParameterSheetContent({ if (!selectedDesignTreeNode?.params?.length) return null; return new Set(selectedDesignTreeNode.params); }, [selectedDesignTreeNode]); + const activeSelectedParameterNames = onSelectDesignTreeNode + ? selectedParameterNames + : null; const visibleParameters = useMemo( () => - selectedParameterNames - ? parameters.filter((param) => selectedParameterNames.has(param.name)) + activeSelectedParameterNames + ? parameters.filter((param) => + activeSelectedParameterNames.has(param.name), + ) : parameters, - [parameters, selectedParameterNames], + [parameters, activeSelectedParameterNames], ); useEffect(() => { @@ -169,11 +174,13 @@ export function ParameterSheetContent({
- {selectedParameterNames && selectedDesignTreeNode && ( + {activeSelectedParameterNames && selectedDesignTreeNode && (
Showing parameters for {selectedDesignTreeNode.name} diff --git a/src/views/EditorView.tsx b/src/views/EditorView.tsx index eab076cd..721fb9d7 100644 --- a/src/views/EditorView.tsx +++ b/src/views/EditorView.tsx @@ -222,15 +222,22 @@ function ConversationEditor() { [], ); - const updateDesignTree = useCallback((code: string) => { - const result = parseDesignTree(code); - setDesignTreeResult(result); - setSelectedDesignTreeNodeId((currentId) => - currentId && result.nodes.some((node) => node.id === currentId) - ? currentId - : null, - ); - }, []); + const updateDesignTree = useCallback( + (code: string, options: { preserveSelection?: boolean } = {}) => { + const result = parseDesignTree(code); + setDesignTreeResult(result); + if (options.preserveSelection === false) { + setSelectedDesignTreeNodeId(null); + return; + } + setSelectedDesignTreeNodeId((currentId) => + currentId && result.nodes.some((node) => node.id === currentId) + ? currentId + : null, + ); + }, + [], + ); // ── Source of truth: DB messages → tree → branch ─────────────────────── const { data: dbMessages = [], isFetched: areMessagesFetched } = @@ -449,7 +456,7 @@ function ConversationEditor() { // always yields the same ``, no matter which // model wrote it. setParameters(parseParameters(artifact.code)); - updateDesignTree(artifact.code); + updateDesignTree(artifact.code, { preserveSelection: false }); setCurrentOutput(undefined); setDxfExporter(() => null); setActivePreview({ type: 'artifact', messageId, artifact }); diff --git a/src/views/ShareView.tsx b/src/views/ShareView.tsx index 79b15469..362dfe74 100644 --- a/src/views/ShareView.tsx +++ b/src/views/ShareView.tsx @@ -149,15 +149,22 @@ function ConversationShare({ conversation, messages }: ConversationShareProps) { const [mobilePreviewVersion, setMobilePreviewVersion] = useState(0); const baseCodeRef = useRef(null); - const updateDesignTree = useCallback((code: string) => { - const result = parseDesignTree(code); - setDesignTreeResult(result); - setSelectedDesignTreeNodeId((currentId) => - currentId && result.nodes.some((node) => node.id === currentId) - ? currentId - : null, - ); - }, []); + const updateDesignTree = useCallback( + (code: string, options: { preserveSelection?: boolean } = {}) => { + const result = parseDesignTree(code); + setDesignTreeResult(result); + if (options.preserveSelection === false) { + setSelectedDesignTreeNodeId(null); + return; + } + setSelectedDesignTreeNodeId((currentId) => + currentId && result.nodes.some((node) => node.id === currentId) + ? currentId + : null, + ); + }, + [], + ); // Auto-switch the preview pane to the latest artifact / mesh in the // current branch when it changes. @@ -174,7 +181,7 @@ function ConversationShare({ conversation, messages }: ConversationShareProps) { if (latest.type === 'artifact') { baseCodeRef.current = latest.artifact.code; setParameters(parseParameters(latest.artifact.code)); - updateDesignTree(latest.artifact.code); + updateDesignTree(latest.artifact.code, { preserveSelection: false }); setCurrentOutput(undefined); setActivePreview({ type: 'artifact', @@ -199,7 +206,7 @@ function ConversationShare({ conversation, messages }: ConversationShareProps) { (artifact: ParametricArtifact, messageId: string) => { baseCodeRef.current = artifact.code; setParameters(parseParameters(artifact.code)); - updateDesignTree(artifact.code); + updateDesignTree(artifact.code, { preserveSelection: false }); setCurrentOutput(undefined); setActivePreview({ type: 'artifact', messageId, artifact }); setMobilePreviewVersion((version) => version + 1); From 1be6057c4369e8b0ae001922a8755ac67547aea9 Mon Sep 17 00:00:00 2001 From: "Aarav.Patel" <157186654+aaravpatel0@users.noreply.github.com> Date: Sat, 30 May 2026 16:57:49 -0700 Subject: [PATCH 5/6] fix(parametric): handle invalid design tree parent links --- shared/parseDesignTree.test.ts | 28 ++++++++ shared/parseDesignTree.ts | 68 +++++++++++++++++++ shared/types.ts | 5 +- .../design-tree/DesignTreeViewer.tsx | 20 +++++- 4 files changed, 119 insertions(+), 2 deletions(-) diff --git a/shared/parseDesignTree.test.ts b/shared/parseDesignTree.test.ts index 8ba9bb71..fc94e71f 100644 --- a/shared/parseDesignTree.test.ts +++ b/shared/parseDesignTree.test.ts @@ -103,6 +103,34 @@ describe('parseDesignTree', () => { assert.equal(result.warnings[0].id, 'base'); }); + it('returns a warning for a dangling parentId', () => { + const result = parseDesignTree( + '// @cadam-node {"id":"child","kind":"part","parentId":"missing"}', + ); + + assert.deepEqual(result.nodes, [ + { id: 'child', kind: 'part', name: 'child', parentId: 'missing' }, + ]); + assert.equal(result.warnings.length, 1); + assert.equal(result.warnings[0].code, 'missing-parent'); + assert.equal(result.warnings[0].id, 'child'); + assert.equal(result.warnings[0].parentId, 'missing'); + }); + + it('returns a warning for a circular parentId cycle', () => { + const result = parseDesignTree(` +// @cadam-node {"id":"a","kind":"part","parentId":"b"} +// @cadam-node {"id":"b","kind":"part","parentId":"a"} +`); + + assert.deepEqual(result.nodes, [ + { id: 'a', kind: 'part', name: 'a', parentId: 'b' }, + { id: 'b', kind: 'part', name: 'b', parentId: 'a' }, + ]); + assert.equal(result.warnings.length, 1); + assert.equal(result.warnings[0].code, 'circular-parent'); + }); + it('returns an empty result when there are no annotations', () => { const result = parseDesignTree(` width = 10; diff --git a/shared/parseDesignTree.ts b/shared/parseDesignTree.ts index f0440896..01696949 100644 --- a/shared/parseDesignTree.ts +++ b/shared/parseDesignTree.ts @@ -22,12 +22,18 @@ type CadamNodePayload = { moduleName?: unknown; }; +type CadamNodeSource = { + line: number; + raw: string; +}; + export default function parseDesignTree( source: string, ): CadamDesignTreeParseResult { const nodes: CadamDesignTreeNode[] = []; const warnings: CadamDesignTreeParseWarning[] = []; const seenIds = new Set(); + const nodeSources = new Map(); let match: RegExpExecArray | null; while ((match = CADAM_NODE_COMMENT_REGEX.exec(source)) !== null) { @@ -128,11 +134,73 @@ export default function parseDesignTree( if (moduleName) node.moduleName = moduleName; nodes.push(node); + nodeSources.set(id, { line, raw }); } + validateParentLinks(nodes, warnings, nodeSources); + return { nodes, warnings }; } +function validateParentLinks( + nodes: CadamDesignTreeNode[], + warnings: CadamDesignTreeParseWarning[], + nodeSources: Map, +) { + const byId = new Map(nodes.map((node) => [node.id, node])); + + for (const node of nodes) { + if (!node.parentId || byId.has(node.parentId)) continue; + const source = nodeSources.get(node.id); + warnings.push({ + code: 'missing-parent', + message: `@cadam-node parentId "${node.parentId}" does not match another node.`, + line: source?.line ?? 0, + raw: source?.raw ?? '', + id: node.id, + parentId: node.parentId, + }); + } + + const warnedCycles = new Set(); + for (const node of nodes) { + const path: string[] = []; + const pathIndex = new Map(); + let currentId: string | undefined = node.id; + + while (currentId) { + const existingIndex = pathIndex.get(currentId); + if (existingIndex !== undefined) { + const cycleIds = path.slice(existingIndex); + const cycleKey = [...cycleIds].sort().join('\0'); + if (!warnedCycles.has(cycleKey)) { + warnedCycles.add(cycleKey); + const warningNode = byId.get(cycleIds[0]); + const source = warningNode + ? nodeSources.get(warningNode.id) + : undefined; + warnings.push({ + code: 'circular-parent', + message: `@cadam-node parentId chain contains a cycle: ${cycleIds.join(' -> ')}.`, + line: source?.line ?? 0, + raw: source?.raw ?? '', + id: warningNode?.id, + parentId: warningNode?.parentId, + }); + } + break; + } + + pathIndex.set(currentId, path.length); + path.push(currentId); + + const current = byId.get(currentId); + if (!current?.parentId || !byId.has(current.parentId)) break; + currentId = current.parentId; + } + } +} + function isKnownNodeKind(kind: string): kind is CadamDesignTreeNodeKind { return KNOWN_NODE_KINDS.has(kind); } diff --git a/shared/types.ts b/shared/types.ts index 668a6c4e..ab063298 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -89,12 +89,15 @@ export type CadamDesignTreeParseWarning = { | 'missing-kind' | 'duplicate-id' | 'unknown-kind' - | 'invalid-param-entry'; + | 'invalid-param-entry' + | 'missing-parent' + | 'circular-parent'; message: string; line: number; raw: string; id?: string; kind?: string; + parentId?: string; }; export type CadamDesignTreeParseResult = { diff --git a/src/components/design-tree/DesignTreeViewer.tsx b/src/components/design-tree/DesignTreeViewer.tsx index 7a0140f4..1d5df6b2 100644 --- a/src/components/design-tree/DesignTreeViewer.tsx +++ b/src/components/design-tree/DesignTreeViewer.tsx @@ -166,7 +166,7 @@ function buildTree(nodes: CadamDesignTreeNode[]) { for (const node of byId.values()) { const parent = node.parentId ? byId.get(node.parentId) : undefined; - if (parent) { + if (parent && !wouldCreateCycle(node, parent, byId)) { parent.children.push(node); } else { roots.push(node); @@ -176,6 +176,24 @@ function buildTree(nodes: CadamDesignTreeNode[]) { return roots; } +function wouldCreateCycle( + node: TreeNode, + parent: TreeNode, + byId: Map, +) { + const seenIds = new Set(); + let current: TreeNode | undefined = parent; + + while (current) { + if (current.id === node.id) return true; + if (!current.parentId || seenIds.has(current.id)) return false; + seenIds.add(current.id); + current = byId.get(current.parentId); + } + + return false; +} + function iconForKind(kind: CadamDesignTreeNode['kind']) { switch (kind) { case 'group': From 2c23e8655d40d749c84db84254ebdc1be1ff10e3 Mon Sep 17 00:00:00 2001 From: "Aarav.Patel" <157186654+aaravpatel0@users.noreply.github.com> Date: Sat, 30 May 2026 17:14:33 -0700 Subject: [PATCH 6/6] fix(parametric): clarify design tree warning copy --- src/components/design-tree/DesignTreeViewer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/design-tree/DesignTreeViewer.tsx b/src/components/design-tree/DesignTreeViewer.tsx index 1d5df6b2..7b4c8094 100644 --- a/src/components/design-tree/DesignTreeViewer.tsx +++ b/src/components/design-tree/DesignTreeViewer.tsx @@ -64,7 +64,7 @@ export function DesignTreeViewer({
{warnings.length === 1 ? warnings[0].message - : `${warnings.length} annotations could not be shown.`} + : `${warnings.length} design tree annotation warnings.`}
)}