@@ -50,6 +50,29 @@ type HistorySnapshot = {
5050 currentFormat : CharFormat ;
5151} ;
5252
53+ /**
54+ * Local X of the MTEXT column's left edge relative to the insertion point (DXF 71),
55+ * matching {@link MText.calculateAnchorPoint} / `mtext-renderer` frame semantics.
56+ * Uses numeric codes so unit tests can partially mock `@mlightcad/mtext-renderer`.
57+ */
58+ function attachmentColumnMinLocal (
59+ width : number ,
60+ attachment : MTextAttachmentPoint | undefined
61+ ) : number {
62+ const w = Math . max ( 1 , width ) ;
63+ const a = attachment as number | undefined ;
64+ // Left column: 1 TL, 4 ML, 7 BL, 10 baseline-left
65+ if ( a === undefined || a === 1 || a === 4 || a === 7 || a === 10 ) return 0 ;
66+ // Center column: 2 TC, 5 MC, 8 BC, 11 baseline-center
67+ if ( a === 2 || a === 5 || a === 8 || a === 11 ) return - w / 2 ;
68+ // Right column: 3 TR, 6 MR, 9 BR, 12 baseline-right
69+ if ( a === 3 || a === 6 || a === 9 || a === 12 ) return - w ;
70+ return 0 ;
71+ }
72+
73+ /** Marks MTEXT roots added by {@link MTextInputBox} so stray scene objects can be scavenged. */
74+ const MTEXT_INPUT_BOX_SCENE_ROOT_KEY = '__mlightcadMTextInputBoxSceneRoot' ;
75+
5376/**
5477 * Three.js based MText editor component with core text editing interaction.
5578 */
@@ -294,6 +317,9 @@ export class MTextInputBox {
294317 this . colorSettings = options . colorSettings ;
295318 this . width = Math . max ( 1 , options . width ) ;
296319 this . position = options . position ?. clone ( ) ?? new THREE . Vector3 ( 0 , 0 , 0 ) ;
320+ if ( options . initialAttachmentPoint !== undefined ) {
321+ this . editorAttachmentPoint = options . initialAttachmentPoint ;
322+ }
297323 this . enableWordWrap = options . enableWordWrap ?? true ;
298324
299325 const textStyle = options . textStyle ?? MTextInputBox . DEFAULT_TEXT_STYLE ;
@@ -751,6 +777,50 @@ export class MTextInputBox {
751777 public setAttachmentPoint ( attachmentPoint : string ) : void {
752778 const next = this . mapRibbonAttachmentCode ( attachmentPoint ) ;
753779 if ( next === undefined || next === this . editorAttachmentPoint ) return ;
780+
781+ // Keep the on-screen text frame fixed: DXF insertion point is the selected
782+ // attachment location on the bounding box, so changing attachment must move
783+ // `position` by the delta between the old and new anchor on the same box.
784+ //
785+ // Do not gate this on `rendererReady`: while fonts load, `relayout()` still
786+ // fills `layoutContainer` from the fallback layout. Skipping the move here
787+ // left `position` at the top-left anchor even after the user chose middle
788+ // center, so committed MTEXT used the wrong insertion vs attachment (DXF 71).
789+ if (
790+ Number . isFinite ( this . layoutContainer . width ) &&
791+ Number . isFinite ( this . layoutContainer . height )
792+ ) {
793+ const left = this . position . x + this . layoutContainer . x ;
794+ const right = left + this . layoutContainer . width ;
795+ const bottom = this . position . y + this . layoutContainer . y ;
796+ const top = bottom + this . layoutContainer . height ;
797+ if (
798+ Number . isFinite ( left ) &&
799+ Number . isFinite ( right ) &&
800+ Number . isFinite ( bottom ) &&
801+ Number . isFinite ( top ) &&
802+ right >= left - 1e-9 &&
803+ top >= bottom - 1e-9
804+ ) {
805+ const oldAnchor = this . computeAttachmentAnchorOnBounds (
806+ left ,
807+ right ,
808+ bottom ,
809+ top ,
810+ this . editorAttachmentPoint
811+ ) ;
812+ const newAnchor = this . computeAttachmentAnchorOnBounds ( left , right , bottom , top , next ) ;
813+ this . position . x += newAnchor . x - oldAnchor . x ;
814+ this . position . y += newAnchor . y - oldAnchor . y ;
815+ this . cursorRenderer . setViewTransform ( {
816+ x : this . position . x ,
817+ y : this . position . y ,
818+ scaleX : 1 ,
819+ scaleY : 1
820+ } ) ;
821+ }
822+ }
823+
754824 this . editorAttachmentPoint = next ;
755825 this . relayout ( ) ;
756826 this . emit ( 'change' ) ;
@@ -761,6 +831,11 @@ export class MTextInputBox {
761831 return this . attachmentPointToRibbonCode ( this . editorAttachmentPoint ) ;
762832 }
763833
834+ /** Returns the current attachment as an {@link MTextAttachmentPoint} value (DXF 71). */
835+ public getMTextAttachmentPoint ( ) : MTextAttachmentPoint {
836+ return this . editorAttachmentPoint ;
837+ }
838+
764839 /** Toggles selected alphabetic text between upper and lower case. */
765840 public toggleCase ( ) : void {
766841 const selection = this . getSelectionRange ( ) ;
@@ -1370,6 +1445,10 @@ export class MTextInputBox {
13701445 const style = this . createTextStyle ( ) ;
13711446 const colorSettings = this . resolveRenderColorSettings ( ) ;
13721447 const object = this . mtextRenderer . syncRenderMText ( mtextData , style , colorSettings ) ;
1448+ // `MText.syncDraw()` appends a fresh layout group without clearing prior children.
1449+ // If the renderer ever reuses one root for multiple draws, prune stale roots so
1450+ // attachment / justify changes cannot leave duplicate glyph trees in the scene.
1451+ this . pruneExtraMTextLayoutRoots ( object ) ;
13731452
13741453 this . replaceRenderedObject ( object ) ;
13751454
@@ -1466,6 +1545,60 @@ export class MTextInputBox {
14661545 }
14671546
14681547 this . syncStateFromCursor ( ) ;
1548+
1549+ // Renderer-driven vertical bounds can omit a trailing empty row when a single
1550+ // inflated line strip overlaps all glyphs (we then keep only glyph extents).
1551+ // Cursor geometry for empty rows still carries those rows (break fallbacks), so
1552+ // union only empty lines — non-empty rows are already covered by glyph bounds,
1553+ // and their LineInfo can still reflect oversized strips (reintroducing leading).
1554+ const expanded = this . unionLayoutContainerWithCursorLines ( this . layoutContainer ) ;
1555+ if (
1556+ Math . abs ( expanded . y - this . layoutContainer . y ) > 1e-6 ||
1557+ Math . abs ( expanded . height - this . layoutContainer . height ) > 1e-6
1558+ ) {
1559+ this . layoutContainer = expanded ;
1560+ this . latestCursorLayoutData . containerBox = { ...this . layoutContainer } ;
1561+ this . cursorLogic . updateData ( this . layoutContainer , charBoxes , lineBreakIndices , lineLayouts ) ;
1562+ this . cursorLogic . moveTo ( nextIndex , pendingLineHint ) ;
1563+ if ( this . selectionStart !== this . selectionEnd ) {
1564+ this . cursorLogic . setSelection ( this . selectionStart , this . selectionEnd ) ;
1565+ } else {
1566+ this . cursorLogic . clearSelection ( ) ;
1567+ }
1568+ this . syncStateFromCursor ( ) ;
1569+ }
1570+ }
1571+
1572+ /**
1573+ * Extends {@link layoutContainer} vertically so empty logical rows (no glyphs)
1574+ * remain inside the chrome bounds. Intentionally ignores non-empty rows: their
1575+ * `LineInfo` may still use inflated renderer line strips, while
1576+ * {@link computeEditorVerticalBounds} already tightened using glyph boxes.
1577+ */
1578+ private unionLayoutContainerWithCursorLines ( container : Box ) : Box {
1579+ const lines = this . cursorLogic . getLines ( ) ;
1580+ if ( lines . length === 0 ) return container ;
1581+
1582+ let low = container . y ;
1583+ let high = container . y + container . height ;
1584+
1585+ for ( const line of lines ) {
1586+ if ( line . charCount !== 0 ) continue ;
1587+ const lineLo = line . y - line . height / 2 ;
1588+ const lineHi = line . y + line . height / 2 ;
1589+ if ( Number . isFinite ( lineLo ) ) low = Math . min ( low , lineLo ) ;
1590+ if ( Number . isFinite ( lineHi ) ) high = Math . max ( high , lineHi ) ;
1591+ }
1592+
1593+ if ( ! Number . isFinite ( low ) || ! Number . isFinite ( high ) || high < low ) {
1594+ return container ;
1595+ }
1596+
1597+ return {
1598+ ...container ,
1599+ y : low ,
1600+ height : high - low
1601+ } ;
14691602 }
14701603
14711604 /**
@@ -1593,13 +1726,11 @@ export class MTextInputBox {
15931726 ) ;
15941727
15951728 const containerBox = {
1596- x : local . x ,
1729+ x : attachmentColumnMinLocal ( this . width , this . editorAttachmentPoint ) ,
15971730 y : containerTop ,
1598- width : local . width ,
1731+ width : Math . max ( 1 , this . width ) ,
15991732 height : Math . max ( 0 , containerBottom - containerTop )
16001733 } ;
1601- containerBox . x = 0 ;
1602- containerBox . width = this . width ;
16031734 const minHeight = this . getFallbackLineAdvance ( ) ;
16041735 if ( containerBox . height < minHeight ) {
16051736 const delta = minHeight - containerBox . height ;
@@ -1691,7 +1822,7 @@ export class MTextInputBox {
16911822
16921823 return {
16931824 containerBox : {
1694- x : 0 ,
1825+ x : attachmentColumnMinLocal ( this . width , this . editorAttachmentPoint ) ,
16951826 y : minY ,
16961827 width : this . width ,
16971828 height : Math . max ( 1 , - minY )
@@ -1759,11 +1890,31 @@ export class MTextInputBox {
17591890
17601891 private replaceRenderedObject ( object : MTextObject ) : void {
17611892 this . disposeRenderedObject ( this . renderedObject ) ;
1893+ this . removeStrayInputBoxSceneRootsExcept ( object ) ;
17621894 this . forceVisibleMaterialState ( object ) ;
1895+ ( object as unknown as THREE . Object3D ) . userData [ MTEXT_INPUT_BOX_SCENE_ROOT_KEY ] = this ;
17631896 this . renderedObject = object ;
17641897 this . scene . add ( object ) ;
17651898 }
17661899
1900+ /**
1901+ * Removes any previous MTEXT scene roots tagged for this input box. Defensive
1902+ * cleanup when a prior root was not fully detached before the next relayout.
1903+ */
1904+ private removeStrayInputBoxSceneRootsExcept ( keep : MTextObject ) : void {
1905+ const keepObj = keep as unknown as THREE . Object3D ;
1906+ for ( let i = this . scene . children . length - 1 ; i >= 0 ; i -- ) {
1907+ const ch = this . scene . children [ i ] ;
1908+ if ( ! ch || ch === keepObj ) continue ;
1909+ const owner = ( ch . userData as Record < string , unknown > ) [ MTEXT_INPUT_BOX_SCENE_ROOT_KEY ] ;
1910+ if ( owner === this ) {
1911+ this . scene . remove ( ch ) ;
1912+ this . disposeMTextRootResources ( ch as MTextObject ) ;
1913+ delete ( ch . userData as Record < string , unknown > ) [ MTEXT_INPUT_BOX_SCENE_ROOT_KEY ] ;
1914+ }
1915+ }
1916+ }
1917+
17671918 private forceVisibleMaterialState ( object : MTextObject ) : void {
17681919 object . traverse ( ( child : THREE . Object3D ) => {
17691920 const meshLike = child as THREE . Mesh ;
@@ -1795,17 +1946,8 @@ export class MTextInputBox {
17951946 } ) ;
17961947 }
17971948
1798- private disposeRenderedObject ( object : MTextObject | null ) : void {
1799- if ( ! object ) return ;
1800- object . removeFromParent ( ) ;
1801-
1802- const withDispose = object as MTextObject & { dispose ?: ( ) => void } ;
1803- if ( typeof withDispose . dispose === 'function' ) {
1804- withDispose . dispose ( ) ;
1805- return ;
1806- }
1807-
1808- object . traverse ( ( child : THREE . Object3D ) => {
1949+ private disposeDetachedThreeSubtree ( root : THREE . Object3D ) : void {
1950+ root . traverse ( ( child : THREE . Object3D ) => {
18091951 const mesh = child as THREE . Mesh ;
18101952 if ( mesh . geometry ) {
18111953 mesh . geometry . dispose ( ) ;
@@ -1821,6 +1963,36 @@ export class MTextInputBox {
18211963 } ) ;
18221964 }
18231965
1966+ /**
1967+ * Keeps a single layout root on the MTEXT object. {@link MText.syncDraw} only calls
1968+ * `add()` for the new layout; multiple draws on the same instance would otherwise
1969+ * stack several full copies of the text (e.g. after attachment-point changes).
1970+ */
1971+ private pruneExtraMTextLayoutRoots ( object : MTextObject ) : void {
1972+ while ( object . children . length > 1 ) {
1973+ const stale = object . children [ 0 ] ;
1974+ if ( ! stale ) break ;
1975+ object . remove ( stale ) ;
1976+ this . disposeDetachedThreeSubtree ( stale ) ;
1977+ }
1978+ }
1979+
1980+ private disposeMTextRootResources ( object : MTextObject ) : void {
1981+ const withDispose = object as MTextObject & { dispose ?: ( ) => void } ;
1982+ if ( typeof withDispose . dispose === 'function' ) {
1983+ withDispose . dispose ( ) ;
1984+ return ;
1985+ }
1986+ this . disposeDetachedThreeSubtree ( object ) ;
1987+ }
1988+
1989+ private disposeRenderedObject ( object : MTextObject | null ) : void {
1990+ if ( ! object ) return ;
1991+ object . removeFromParent ( ) ;
1992+ delete ( object as unknown as THREE . Object3D ) . userData [ MTEXT_INPUT_BOX_SCENE_ROOT_KEY ] ;
1993+ this . disposeMTextRootResources ( object ) ;
1994+ }
1995+
18241996 private isExplicitAci ( aci : number | null ) : aci is number {
18251997 return aci !== null && Number . isInteger ( aci ) && aci > 0 && aci < 256 ;
18261998 }
@@ -2831,6 +3003,47 @@ export class MTextInputBox {
28313003 return new MTextDocument ( normalizedAst ) ;
28323004 }
28333005
3006+ /**
3007+ * World-space point on the layout bounds that matches the given MTEXT
3008+ * attachment (DXF 71), using the same box convention as {@link updateBoundingBoxGeometry}.
3009+ */
3010+ private computeAttachmentAnchorOnBounds (
3011+ left : number ,
3012+ right : number ,
3013+ bottom : number ,
3014+ top : number ,
3015+ point : MTextAttachmentPoint
3016+ ) : THREE . Vector3 {
3017+ const midX = ( left + right ) * 0.5 ;
3018+ const midY = ( bottom + top ) * 0.5 ;
3019+ const z = this . position . z ;
3020+ switch ( point ) {
3021+ case MTextAttachmentPoint . TopLeft :
3022+ return new THREE . Vector3 ( left , top , z ) ;
3023+ case MTextAttachmentPoint . TopCenter :
3024+ return new THREE . Vector3 ( midX , top , z ) ;
3025+ case MTextAttachmentPoint . TopRight :
3026+ return new THREE . Vector3 ( right , top , z ) ;
3027+ case MTextAttachmentPoint . MiddleLeft :
3028+ return new THREE . Vector3 ( left , midY , z ) ;
3029+ case MTextAttachmentPoint . MiddleCenter :
3030+ return new THREE . Vector3 ( midX , midY , z ) ;
3031+ case MTextAttachmentPoint . MiddleRight :
3032+ return new THREE . Vector3 ( right , midY , z ) ;
3033+ case MTextAttachmentPoint . BottomLeft :
3034+ case MTextAttachmentPoint . BaselineLeft :
3035+ return new THREE . Vector3 ( left , bottom , z ) ;
3036+ case MTextAttachmentPoint . BottomCenter :
3037+ case MTextAttachmentPoint . BaselineCenter :
3038+ return new THREE . Vector3 ( midX , bottom , z ) ;
3039+ case MTextAttachmentPoint . BottomRight :
3040+ case MTextAttachmentPoint . BaselineRight :
3041+ return new THREE . Vector3 ( right , bottom , z ) ;
3042+ default :
3043+ return new THREE . Vector3 ( left , top , z ) ;
3044+ }
3045+ }
3046+
28343047 private mapRibbonParagraphAlignment ( alignment : string ) : MTextParagraphAlignment | undefined {
28353048 switch ( alignment ) {
28363049 case 'default' :
0 commit comments