@@ -119,6 +119,42 @@ function emptyPlaceholderEntities(device: Device): string {
119119 ) ;
120120}
121121
122+ /**
123+ * Emit a device-outline rectangle from device spans (canvas µm → DXF mm).
124+ * Always use params spans rather than re-emitting imported EDGE geometry, which
125+ * may still be in original DXF coordinates and would misalign with ports/channels.
126+ */
127+ function exportDeviceBorderEntities ( device : Device , layerName : string ) : string {
128+ const widthUm = device . getXSpan ( ) ;
129+ const heightUm = device . getYSpan ( ) ;
130+ if ( ! Number . isFinite ( widthUm ) || ! Number . isFinite ( heightUm ) || widthUm <= 0 || heightUm <= 0 ) {
131+ return "" ;
132+ }
133+ const corners : Array < [ number , number ] > = [
134+ [ 0 , 0 ] ,
135+ [ widthUm , 0 ] ,
136+ [ widthUm , heightUm ] ,
137+ [ 0 , heightUm ] ,
138+ [ 0 , 0 ]
139+ ] ;
140+ let out = "" ;
141+ for ( let i = 0 ; i < corners . length - 1 ; i ++ ) {
142+ const a = canvasUmToDxfMm ( corners [ i ] [ 0 ] , corners [ i ] [ 1 ] , heightUm ) ;
143+ const b = canvasUmToDxfMm ( corners [ i + 1 ] [ 0 ] , corners [ i + 1 ] [ 1 ] , heightUm ) ;
144+ out += writeLine (
145+ {
146+ type : "LINE" ,
147+ vertices : [
148+ { x : a . x , y : a . y , z : 0 } ,
149+ { x : b . x , y : b . y , z : 0 }
150+ ]
151+ } ,
152+ layerName + "_border"
153+ ) ;
154+ }
155+ return out ;
156+ }
157+
122158function readCircleRadiusUm ( feature : Feature ) : number | null {
123159 const candidates = [ "portRadius" , "valveRadius" , "radius1" , "radius" ] ;
124160 for ( const key of candidates ) {
@@ -134,10 +170,98 @@ function readCircleRadiusUm(feature: Feature): number | null {
134170 return null ;
135171}
136172
173+ function tryGetNumber ( feature : Feature , key : string ) : number | null {
174+ try {
175+ const value = Number ( feature . getValue ( key ) ) ;
176+ return Number . isFinite ( value ) ? value : null ;
177+ } catch ( _err ) {
178+ return null ;
179+ }
180+ }
181+
182+ /** Normalize [x,y] or ["Point", x, y] canvas coordinates. */
183+ function asUmPoint ( value : unknown ) : [ number , number ] | null {
184+ if ( ! Array . isArray ( value ) || value . length < 2 ) {
185+ return null ;
186+ }
187+ if ( value . length >= 3 && value [ 0 ] === "Point" ) {
188+ const x = Number ( value [ 1 ] ) ;
189+ const y = Number ( value [ 2 ] ) ;
190+ return Number . isFinite ( x ) && Number . isFinite ( y ) ? [ x , y ] : null ;
191+ }
192+ const x = Number ( value [ 0 ] ) ;
193+ const y = Number ( value [ 1 ] ) ;
194+ return Number . isFinite ( x ) && Number . isFinite ( y ) ? [ x , y ] : null ;
195+ }
196+
197+ /**
198+ * Export one channel segment as two parallel wall lines spaced by channelWidth.
199+ * Matches typical manufacturing DXF (and the original inlet-channel-outlet sketch):
200+ * width is the gap between walls — not a closed rectangle that import can mistake for the device border.
201+ */
202+ function exportChannelSegmentOutline (
203+ p1 : [ number , number ] ,
204+ p2 : [ number , number ] ,
205+ channelWidthUm : number ,
206+ deviceHeightUm : number ,
207+ layer : string ,
208+ z : number
209+ ) : string {
210+ if ( ! ( channelWidthUm > 0 ) ) {
211+ return "" ;
212+ }
213+ const dx = p2 [ 0 ] - p1 [ 0 ] ;
214+ const dy = p2 [ 1 ] - p1 [ 1 ] ;
215+ const len = Math . hypot ( dx , dy ) ;
216+ if ( len < 1e-9 ) {
217+ return "" ;
218+ }
219+ const ux = dx / len ;
220+ const uy = dy / len ;
221+ const px = - uy ;
222+ const py = ux ;
223+ const half = channelWidthUm / 2 ;
224+ const wallA1 : [ number , number ] = [ p1 [ 0 ] - px * half , p1 [ 1 ] - py * half ] ;
225+ const wallA2 : [ number , number ] = [ p2 [ 0 ] - px * half , p2 [ 1 ] - py * half ] ;
226+ const wallB1 : [ number , number ] = [ p1 [ 0 ] + px * half , p1 [ 1 ] + py * half ] ;
227+ const wallB2 : [ number , number ] = [ p2 [ 0 ] + px * half , p2 [ 1 ] + py * half ] ;
228+
229+ const a1 = canvasUmToDxfMm ( wallA1 [ 0 ] , wallA1 [ 1 ] , deviceHeightUm ) ;
230+ const a2 = canvasUmToDxfMm ( wallA2 [ 0 ] , wallA2 [ 1 ] , deviceHeightUm ) ;
231+ const b1 = canvasUmToDxfMm ( wallB1 [ 0 ] , wallB1 [ 1 ] , deviceHeightUm ) ;
232+ const b2 = canvasUmToDxfMm ( wallB2 [ 0 ] , wallB2 [ 1 ] , deviceHeightUm ) ;
233+
234+ let out = "" ;
235+ out += writeLine (
236+ {
237+ type : "LINE" ,
238+ vertices : [
239+ { x : a1 . x , y : a1 . y , z } ,
240+ { x : a2 . x , y : a2 . y , z }
241+ ]
242+ } ,
243+ layer
244+ ) ;
245+ out += writeLine (
246+ {
247+ type : "LINE" ,
248+ vertices : [
249+ { x : b1 . x , y : b1 . y , z } ,
250+ { x : b2 . x , y : b2 . y , z }
251+ ]
252+ } ,
253+ layer
254+ ) ;
255+ return out ;
256+ }
257+
137258function isStructuredDesignFeature ( type : string ) : boolean {
138259 return (
139260 type === "Port" ||
140261 type === "Connection" ||
262+ type === "Channel" ||
263+ type === "RoundedChannel" ||
264+ type === "RoundedChannelConnection" ||
141265 type === "BetterMixer" ||
142266 type === "Mixer" ||
143267 type === "CurvedMixer" ||
@@ -166,9 +290,13 @@ function exportFeatureEntities(
166290 const type = feature . getType ( ) ;
167291 let entities = "" ;
168292
169- // After DXF import + canvas edits, Port/Connection/etc. are the live geometry.
170- // Skip stale EDGE/DxfSketch payloads when structured features exist.
171- if ( type === "EDGE" || type === "DxfSketch" ) {
293+ // Device outline is always synthesized from getXSpan()/getYSpan() below.
294+ // Skip EDGE (imported coords may not match canvas-space features) and skip
295+ // stale DxfSketch payloads when structured features exist.
296+ if ( type === "EDGE" ) {
297+ return entities ;
298+ }
299+ if ( type === "DxfSketch" ) {
172300 if ( ! includeRawDxfObjects ) {
173301 return entities ;
174302 }
@@ -202,41 +330,55 @@ function exportFeatureEntities(
202330 return entities ;
203331 }
204332
205- if ( type === "Connection" ) {
206- let segments : Array < [ [ number , number ] , [ number , number ] ] > | null = null ;
207- let heightUm = NaN ;
208- try {
209- segments = feature . getValue ( "segments" ) as Array < [ [ number , number ] , [ number , number ] ] > ;
210- } catch ( _err ) {
333+ if ( type === "Connection" || type === "Channel" || type === "RoundedChannel" || type === "RoundedChannelConnection" ) {
334+ const channelWidthUm = tryGetNumber ( feature , "channelWidth" ) ;
335+ if ( channelWidthUm == null || ! ( channelWidthUm > 0 ) ) {
211336 return entities ;
212337 }
213- try {
214- heightUm = Number ( feature . getValue ( "height" ) ) ;
215- } catch ( _err ) {
216- heightUm = 250 ;
217- }
218- const z = Number . isFinite ( heightUm ) ? heightUm * UM_TO_MM : 0.25 ;
219- if ( ! segments || ! Array . isArray ( segments ) ) {
220- return entities ;
338+ const z = 0 ;
339+ const channelLayer = layerName + "_channels" ;
340+
341+ let segments : Array < [ [ number , number ] , [ number , number ] ] > = [ ] ;
342+ if ( type === "Connection" || type === "RoundedChannelConnection" ) {
343+ try {
344+ const raw = feature . getValue ( "segments" ) as Array < [ [ number , number ] , [ number , number ] ] > ;
345+ if ( Array . isArray ( raw ) ) {
346+ segments = raw ;
347+ }
348+ } catch ( _err ) {
349+ // Fall through to start/end if segments are missing.
350+ }
351+ if ( ! segments . length ) {
352+ try {
353+ const start = asUmPoint ( feature . getValue ( "start" ) ) ;
354+ const end = asUmPoint ( feature . getValue ( "end" ) ) ;
355+ if ( start && end ) {
356+ segments = [ [ start , end ] ] ;
357+ }
358+ } catch ( _err ) {
359+ return entities ;
360+ }
361+ }
362+ } else {
363+ let start : [ number , number ] | null = null ;
364+ let end : [ number , number ] | null = null ;
365+ try {
366+ start = asUmPoint ( feature . getValue ( "start" ) ) ;
367+ end = asUmPoint ( feature . getValue ( "end" ) ) ;
368+ } catch ( _err ) {
369+ return entities ;
370+ }
371+ if ( start && end ) {
372+ segments = [ [ start , end ] ] ;
373+ }
221374 }
375+
222376 for ( const seg of segments ) {
223377 if ( ! seg || seg . length < 2 ) continue ;
224- const p1 = seg [ 0 ] ;
225- const p2 = seg [ 1 ] ;
226- if ( ! Array . isArray ( p1 ) || ! Array . isArray ( p2 ) ) continue ;
227- const a = canvasUmToDxfMm ( p1 [ 0 ] , p1 [ 1 ] , deviceHeightUm ) ;
228- const b = canvasUmToDxfMm ( p2 [ 0 ] , p2 [ 1 ] , deviceHeightUm ) ;
229- if ( Math . hypot ( a . x - b . x , a . y - b . y ) < 1e-9 ) continue ;
230- entities += writeLine (
231- {
232- type : "LINE" ,
233- vertices : [
234- { x : a . x , y : a . y , z } ,
235- { x : b . x , y : b . y , z }
236- ]
237- } ,
238- layerName + "_channels"
239- ) ;
378+ const p1 = asUmPoint ( seg [ 0 ] ) ;
379+ const p2 = asUmPoint ( seg [ 1 ] ) ;
380+ if ( ! p1 || ! p2 ) continue ;
381+ entities += exportChannelSegmentOutline ( p1 , p2 , channelWidthUm , deviceHeightUm , channelLayer , z ) ;
240382 }
241383 return entities ;
242384 }
@@ -248,9 +390,7 @@ function exportFeatureEntities(
248390 const bendLength = Number ( feature . getValue ( "bendLength" ) ) ;
249391 const bendSpacing = Number ( feature . getValue ( "bendSpacing" ) ) ;
250392 const numberOfBends = Number ( feature . getValue ( "numberOfBends" ) ) ;
251- const heightUm = Number ( feature . getValue ( "height" ) ) ;
252- const z = Number . isFinite ( heightUm ) ? heightUm * UM_TO_MM : 0.25 ;
253- if ( ! Array . isArray ( position ) || ! Number . isFinite ( channelWidth ) ) {
393+ if ( ! Array . isArray ( position ) || ! Number . isFinite ( channelWidth ) || ! ( channelWidth > 0 ) ) {
254394 return entities ;
255395 }
256396 const segments = betterMixerCenterlineSegments ( {
@@ -261,18 +401,13 @@ function exportFeatureEntities(
261401 numberOfBends : Number . isFinite ( numberOfBends ) ? numberOfBends : 1
262402 } ) ;
263403 for ( const seg of segments ) {
264- const a = canvasUmToDxfMm ( seg [ 0 ] [ 0 ] , seg [ 0 ] [ 1 ] , deviceHeightUm ) ;
265- const b = canvasUmToDxfMm ( seg [ 1 ] [ 0 ] , seg [ 1 ] [ 1 ] , deviceHeightUm ) ;
266- if ( Math . hypot ( a . x - b . x , a . y - b . y ) < 1e-9 ) continue ;
267- entities += writeLine (
268- {
269- type : "LINE" ,
270- vertices : [
271- { x : a . x , y : a . y , z } ,
272- { x : b . x , y : b . y , z }
273- ]
274- } ,
275- layerName + "_mixer"
404+ entities += exportChannelSegmentOutline (
405+ seg [ 0 ] ,
406+ seg [ 1 ] ,
407+ channelWidth ,
408+ deviceHeightUm ,
409+ layerName + "_mixer" ,
410+ 0
276411 ) ;
277412 }
278413 } catch ( _err ) {
@@ -297,6 +432,9 @@ function exportLayerEntities(device: Device, layer: Layer): string {
297432 console . warn ( "[DXF export] Skipping feature" , key , err ) ;
298433 }
299434 }
435+ // Always write the device border from current spans so CAD extents match the
436+ // canvas device size (JSON params.width/length), even when EDGE is skipped.
437+ entities += exportDeviceBorderEntities ( device , layerName ) ;
300438 if ( ! entities ) {
301439 entities = emptyPlaceholderEntities ( device ) ;
302440 }
0 commit comments