@@ -3,11 +3,22 @@ import type { AssetDescriptor } from '@tempad-dev/mcp-shared'
33import type { SemanticNode } from '@/mcp/semantic-tree'
44import type { CodegenConfig } from '@/utils/codegen'
55
6+ import { toDecimalPlace } from '@/utils/number'
7+
68import type { SvgEntry } from './assets'
79
810import { exportSvgEntry , hasImageFills , replaceImageUrlsWithAssets } from './assets'
911import { preprocessStyles , stripInertShadows } from './style'
1012
13+ const VECTOR_LIKE_TYPES = new Set < SceneNode [ 'type' ] > ( [
14+ 'VECTOR' ,
15+ 'BOOLEAN_OPERATION' ,
16+ 'STAR' ,
17+ 'LINE' ,
18+ 'ELLIPSE' ,
19+ 'POLYGON'
20+ ] )
21+
1122export type CollectedSceneData = {
1223 nodes : Map < string , SceneNode >
1324 styles : Map < string , Record < string , string > >
@@ -20,20 +31,47 @@ export async function collectSceneData(
2031 assetRegistry : Map < string , AssetDescriptor >
2132) : Promise < CollectedSceneData > {
2233 const semanticNodes = flattenSemanticNodes ( roots )
34+ const parentById = buildSemanticParentMap ( roots )
2335 const nodes = new Map < string , SceneNode > ( )
2436 const styles = new Map < string , Record < string , string > > ( )
2537 const svgs = new Map < string , SvgEntry > ( )
38+ const skipped = new Set < string > ( )
2639
2740 for ( const semantic of semanticNodes ) {
41+ if ( skipped . has ( semantic . id ) ) continue
42+
2843 const node = figma . getNodeById ( semantic . id ) as SceneNode | null
2944 if ( ! node || ! node . visible ) continue
3045
3146 nodes . set ( semantic . id , node )
3247
33- if ( semantic . assetKind === 'vector' ) {
48+ const isSemanticVectorOnly = semantic . children . length > 0 && isVectorSubtree ( semantic )
49+
50+ const isVectorOnlyContainer = isVectorContainer ( node ) || isSemanticVectorOnly
51+
52+ if ( isVectorOnlyContainer && node . width > 0 && node . height > 0 ) {
3453 const svgEntry = await exportSvgEntry ( node , config , assetRegistry )
3554 if ( svgEntry ) {
3655 svgs . set ( semantic . id , svgEntry )
56+ skipDescendants ( semantic , skipped )
57+ const processed = await collectNodeStyle ( node , semantic , parentById , nodes , styles )
58+ if ( processed ) {
59+ styles . set ( semantic . id , processed )
60+ }
61+ }
62+ continue
63+ }
64+
65+ const shouldExportVector = semantic . assetKind === 'vector' && node . width > 0 && node . height > 0
66+
67+ if ( shouldExportVector ) {
68+ const svgEntry = await exportSvgEntry ( node , config , assetRegistry )
69+ if ( svgEntry ) {
70+ svgs . set ( semantic . id , svgEntry )
71+ const processed = await collectNodeStyle ( node , semantic , parentById , nodes , styles )
72+ if ( processed ) {
73+ styles . set ( semantic . id , processed )
74+ }
3775 }
3876 continue
3977 }
@@ -43,6 +81,16 @@ export async function collectSceneData(
4381
4482 let processed = preprocessStyles ( css , node )
4583
84+ const parentSemantic = parentById . get ( semantic . id )
85+ if ( parentSemantic ) {
86+ const parentNode =
87+ nodes . get ( parentSemantic . id ) ?? ( figma . getNodeById ( parentSemantic . id ) as SceneNode | null )
88+ const parentStyle = parentSemantic ? styles . get ( parentSemantic . id ) : undefined
89+ if ( parentNode ) {
90+ processed = applyConstraintsPosition ( processed , node , parentNode , parentStyle )
91+ }
92+ }
93+
4694 if ( hasImageFills ( node ) ) {
4795 processed = await replaceImageUrlsWithAssets ( processed , node , config , assetRegistry )
4896 }
@@ -55,9 +103,172 @@ export async function collectSceneData(
55103 }
56104 }
57105
106+ for ( const [ id , entry ] of Array . from ( svgs . entries ( ) ) ) {
107+ const node = nodes . get ( id )
108+ if ( ! node ) {
109+ svgs . delete ( id )
110+ continue
111+ }
112+ if ( node . width <= 0 || node . height <= 0 ) {
113+ svgs . delete ( id )
114+ continue
115+ }
116+ const widthAttr = Number ( entry . props ?. width ?? 0 )
117+ const heightAttr = Number ( entry . props ?. height ?? 0 )
118+ if ( widthAttr <= 0 || heightAttr <= 0 ) {
119+ svgs . delete ( id )
120+ }
121+ }
122+
58123 return { nodes, styles, svgs }
59124}
60125
126+ function isVectorContainer ( node : SceneNode ) : boolean {
127+ if ( ! ( 'children' in node ) ) return false
128+ const visibleChildren = node . children . filter ( ( child ) => child . visible )
129+ if ( ! visibleChildren . length ) return false
130+ return visibleChildren . every ( ( child ) => VECTOR_LIKE_TYPES . has ( child . type ) )
131+ }
132+
133+ function isVectorSubtree ( semantic : SemanticNode ) : boolean {
134+ if ( ! semantic . children . length ) return semantic . assetKind === 'vector'
135+ return semantic . children . every ( ( child ) => isVectorSubtree ( child ) )
136+ }
137+
138+ function buildSemanticParentMap ( roots : SemanticNode [ ] ) : Map < string , SemanticNode | undefined > {
139+ const map = new Map < string , SemanticNode | undefined > ( )
140+ const walk = ( node : SemanticNode , parent ?: SemanticNode ) => {
141+ map . set ( node . id , parent )
142+ node . children . forEach ( ( child ) => walk ( child , node ) )
143+ }
144+ roots . forEach ( ( root ) => walk ( root , undefined ) )
145+ return map
146+ }
147+
148+ async function collectNodeStyle (
149+ node : SceneNode ,
150+ semantic : SemanticNode ,
151+ parentById : Map < string , SemanticNode | undefined > ,
152+ nodes : Map < string , SceneNode > ,
153+ styles : Map < string , Record < string , string > >
154+ ) : Promise < Record < string , string > | null > {
155+ try {
156+ const css = await node . getCSSAsync ( )
157+ let processed = preprocessStyles ( css , node )
158+
159+ const parentSemantic = parentById . get ( semantic . id )
160+ if ( parentSemantic ) {
161+ const parentNode =
162+ nodes . get ( parentSemantic . id ) ?? ( figma . getNodeById ( parentSemantic . id ) as SceneNode | null )
163+ const parentStyle = parentSemantic ? styles . get ( parentSemantic . id ) : undefined
164+ if ( parentNode ) {
165+ processed = applyConstraintsPosition ( processed , node , parentNode , parentStyle )
166+ }
167+ }
168+
169+ return processed
170+ } catch ( error ) {
171+ console . warn ( '[tempad-dev] Failed to process node styles:' , error )
172+ return null
173+ }
174+ }
175+
176+ function applyConstraintsPosition (
177+ style : Record < string , string > ,
178+ node : SceneNode ,
179+ parent : SceneNode ,
180+ parentStyle ?: Record < string , string >
181+ ) : Record < string , string > {
182+ const constraints = ( node as { constraints ?: Constraints } ) . constraints
183+ if ( ! constraints ) return style
184+
185+ const layoutMode = ( parent as { layoutMode ?: string } ) . layoutMode
186+ if ( layoutMode && layoutMode !== 'NONE' ) return style
187+
188+ const parentDisplay = parentStyle ?. display ?. toLowerCase ( )
189+ if ( parentDisplay && ( parentDisplay . includes ( 'flex' ) || parentDisplay . includes ( 'grid' ) ) ) {
190+ return style
191+ }
192+
193+ const width = ( node as { width ?: number } ) . width ?? 0
194+ const height = ( node as { height ?: number } ) . height ?? 0
195+ const parentWidth = ( parent as { width ?: number } ) . width ?? 0
196+ const parentHeight = ( parent as { height ?: number } ) . height ?? 0
197+
198+ if ( ! parentWidth || ! parentHeight ) return style
199+
200+ const transform = ( node as { relativeTransform ?: Transform } ) . relativeTransform
201+ if ( ! transform || transform . length < 2 || transform [ 0 ] . length < 3 || transform [ 1 ] . length < 3 ) {
202+ return style
203+ }
204+
205+ const left = transform [ 0 ] [ 2 ]
206+ const top = transform [ 1 ] [ 2 ]
207+ const right = parentWidth - width - left
208+ const bottom = parentHeight - height - top
209+
210+ const result : Record < string , string > = { ...style , position : 'absolute' }
211+
212+ const h = constraints . horizontal
213+ switch ( h ) {
214+ case 'MIN' :
215+ result . left = `${ toDecimalPlace ( left ) } px`
216+ break
217+ case 'MAX' :
218+ result . right = `${ toDecimalPlace ( right ) } px`
219+ break
220+ case 'CENTER' : {
221+ const offset = width / 2 + ( parentWidth / 2 - width / 2 - left )
222+ result . left = `calc(50% - ${ toDecimalPlace ( offset ) } px)`
223+ break
224+ }
225+ case 'STRETCH' :
226+ result . left = `${ toDecimalPlace ( left ) } px`
227+ result . right = `${ toDecimalPlace ( right ) } px`
228+ break
229+ case 'SCALE' :
230+ result . left = `${ toDecimalPlace ( ( left / parentWidth ) * 100 ) } %`
231+ result . right = `${ toDecimalPlace ( ( right / parentWidth ) * 100 ) } %`
232+ break
233+ default :
234+ break
235+ }
236+
237+ const v = constraints . vertical
238+ switch ( v ) {
239+ case 'MIN' :
240+ result . top = `${ toDecimalPlace ( top ) } px`
241+ break
242+ case 'MAX' :
243+ result . bottom = `${ toDecimalPlace ( bottom ) } px`
244+ break
245+ case 'CENTER' : {
246+ const offset = height / 2 + ( parentHeight / 2 - height / 2 - top )
247+ result . top = `calc(50% - ${ toDecimalPlace ( offset ) } px)`
248+ break
249+ }
250+ case 'STRETCH' :
251+ result . top = `${ toDecimalPlace ( top ) } px`
252+ result . bottom = `${ toDecimalPlace ( bottom ) } px`
253+ break
254+ case 'SCALE' :
255+ result . top = `${ toDecimalPlace ( ( top / parentHeight ) * 100 ) } %`
256+ result . bottom = `${ toDecimalPlace ( ( bottom / parentHeight ) * 100 ) } %`
257+ break
258+ default :
259+ break
260+ }
261+
262+ return result
263+ }
264+
265+ function skipDescendants ( semantic : SemanticNode , bucket : Set < string > ) : void {
266+ semantic . children . forEach ( ( child ) => {
267+ bucket . add ( child . id )
268+ skipDescendants ( child , bucket )
269+ } )
270+ }
271+
61272function flattenSemanticNodes ( nodes : SemanticNode [ ] ) : SemanticNode [ ] {
62273 const res : SemanticNode [ ] = [ ]
63274 const traverse = ( n : SemanticNode ) => {
0 commit comments