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..fc94e71f --- /dev/null +++ b/shared/parseDesignTree.test.ts @@ -0,0 +1,144 @@ +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 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 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; +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..01696949 --- /dev/null +++ b/shared/parseDesignTree.ts @@ -0,0 +1,228 @@ +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; +}; + +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) { + 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 = 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; + + 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); +} + +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 stringArrayResult(value: unknown) { + if (!Array.isArray(value)) return undefined; + const values = value.filter( + (item): item is string => typeof item === 'string', + ); + return { values, hasInvalidEntry: values.length !== value.length }; +} + +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..ab063298 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -67,6 +67,44 @@ 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' + | 'invalid-param-entry' + | 'missing-parent' + | 'circular-parent'; + message: string; + line: number; + raw: string; + id?: string; + kind?: string; + parentId?: 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..7b4c8094 --- /dev/null +++ b/src/components/design-tree/DesignTreeViewer.tsx @@ -0,0 +1,210 @@ +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 | null) => void; + warnings: CadamDesignTreeParseWarning[]; +} + +type TreeNode = CadamDesignTreeNode & { + children: TreeNode[]; +}; + +export function DesignTreeViewer({ + nodes, + selectedNodeId, + onSelectNode, + warnings, +}: DesignTreeViewerProps) { + const treeNodes = useMemo(() => buildTree(nodes), [nodes]); + if (nodes.length === 0 && warnings.length === 0) return null; + + return ( +
+
+
+

+ Design Tree +

+

+ Source annotations +

+
+ {warnings.length > 0 && ( + + + {warnings.length} + + )} +
+ + {warnings.length > 0 && ( +
+ {warnings.length === 1 + ? warnings[0].message + : `${warnings.length} design tree annotation warnings.`} +
+ )} + + {treeNodes.length > 0 && ( +
+ {treeNodes.map((node) => ( + + ))} +
+ )} +
+ ); +} + +function DesignTreeRow({ + node, + selectedNodeId, + onSelectNode, +}: { + node: TreeNode; + selectedNodeId?: string | null; + onSelectNode?: (nodeId: string | null) => 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 && !wouldCreateCycle(node, parent, byId)) { + parent.children.push(node); + } else { + roots.push(node); + } + } + + 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': + 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..f91a0b39 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 | null) => void; } type DownloadFormat = 'stl' | 'scad' | 'dxf'; @@ -55,10 +64,25 @@ export function ParameterSection({ currentOutput, dxfExporter, code, + designTreeNodes = [], + designTreeWarnings = [], + selectedDesignTreeNodeId, + onSelectDesignTreeNode, }: ParameterSectionProps) { const { toast } = useToast(); const [selectedFormat, setSelectedFormat] = useState('stl'); const [isExporting, setIsExporting] = useState(false); + 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 activeSelectedParameterNames = onSelectDesignTreeNode + ? selectedParameterNames + : null; // Split params into the main list (non-color, shown by default) and a // collapsible Colors group below it. Keeps the dimensions the user @@ -72,6 +96,24 @@ export function ParameterSection({ } return { mainParameters: main, colorParameters: color }; }, [parameters]); + const visibleMainParameters = useMemo( + () => + activeSelectedParameterNames + ? mainParameters.filter((param) => + activeSelectedParameterNames.has(param.name), + ) + : mainParameters, + [mainParameters, activeSelectedParameterNames], + ); + const visibleColorParameters = useMemo( + () => + activeSelectedParameterNames + ? colorParameters.filter((param) => + activeSelectedParameterNames.has(param.name), + ) + : colorParameters, + [colorParameters, activeSelectedParameterNames], + ); const [colorsOpen, setColorsOpen] = useState(true); const [dimensionsOpen, setDimensionsOpen] = useState(true); @@ -209,7 +251,30 @@ export function ParameterSection({
- {mainParameters.length > 0 && ( + + {activeSelectedParameterNames && selectedDesignTreeNode && ( +
+ + Showing parameters for {selectedDesignTreeNode.name} + + +
+ )} + {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; } 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'); @@ -42,6 +55,26 @@ 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 activeSelectedParameterNames = onSelectDesignTreeNode + ? selectedParameterNames + : null; + const visibleParameters = useMemo( + () => + activeSelectedParameterNames + ? parameters.filter((param) => + activeSelectedParameterNames.has(param.name), + ) + : parameters, + [parameters, activeSelectedParameterNames], + ); useEffect(() => { latestParametersRef.current = parameters; @@ -139,7 +172,30 @@ export function ParameterSheetContent({
- {parameters.map((param) => ( + + {activeSelectedParameterNames && selectedDesignTreeNode && ( +
+ + Showing parameters for {selectedDesignTreeNode.name} + + +
+ )} + {visibleParameters.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,23 @@ function ConversationEditor() { [], ); + 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 } = useMessagesQuery(); @@ -432,16 +456,19 @@ function ConversationEditor() { // always yields the same ``, no matter which // model wrote it. setParameters(parseParameters(artifact.code)); + updateDesignTree(artifact.code, { preserveSelection: false }); 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 +481,7 @@ function ConversationEditor() { nextCode = updateParameter(nextCode, parameter); } setParameters(nextParameters); + updateDesignTree(nextCode); setActivePreview({ ...activePreview, artifact: { @@ -462,7 +490,7 @@ function ConversationEditor() { }, }); }, - [activePreview], + [activePreview, updateDesignTree], ); const updatePrivacy = useCallback( @@ -483,7 +511,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 +681,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 +699,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..362dfe74 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,32 @@ 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, 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. const lastAutoAppliedPreviewKeyRef = useRef(null); @@ -157,6 +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, { preserveSelection: false }); setCurrentOutput(undefined); setActivePreview({ type: 'artifact', @@ -166,6 +191,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 +200,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, { preserveSelection: false }); 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 +229,7 @@ function ConversationShare({ conversation, messages }: ConversationShareProps) { nextCode = updateParameter(nextCode, parameter); } setParameters(nextParameters); + updateDesignTree(nextCode); setActivePreview({ ...activePreview, artifact: { @@ -207,11 +238,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 (