@@ -82,6 +82,59 @@ function normalizeInteractionIndex(runtime) {
8282 return normalized ;
8383}
8484
85+ function normalizeSafeZoneEntry ( entry ) {
86+ if ( ! entry || typeof entry !== 'object' ) {
87+ return null ;
88+ }
89+ const x = Number ( entry . x ) ;
90+ const y = Number ( entry . y ) ;
91+ const width = Number ( entry . width ) ;
92+ const height = Number ( entry . height ) ;
93+ if ( ! Number . isFinite ( x ) || ! Number . isFinite ( y ) || ! Number . isFinite ( width ) || ! Number . isFinite ( height ) ) {
94+ return null ;
95+ }
96+ if ( width <= 0 || height <= 0 ) {
97+ return null ;
98+ }
99+ return Object . freeze ( {
100+ id : String ( entry . id || '' ) . trim ( ) ,
101+ x,
102+ y,
103+ width,
104+ height,
105+ } ) ;
106+ }
107+
108+ function normalizeSafeZones ( safeZones ) {
109+ if ( ! Array . isArray ( safeZones ) || safeZones . length === 0 ) {
110+ return Object . freeze ( [ ] ) ;
111+ }
112+ const normalized = [ ] ;
113+ for ( let i = 0 ; i < safeZones . length ; i += 1 ) {
114+ const candidate = normalizeSafeZoneEntry ( safeZones [ i ] ) ;
115+ if ( ! candidate ) {
116+ continue ;
117+ }
118+ normalized . push ( candidate ) ;
119+ }
120+ return Object . freeze ( normalized ) ;
121+ }
122+
123+ function resolveLayoutSafeZones ( context = { } ) {
124+ const fromContext = normalizeSafeZones ( context ?. safeZones ) ;
125+ if ( fromContext . length > 0 ) {
126+ return fromContext ;
127+ }
128+ if ( typeof context ?. scene ?. getOverlayLayoutSafeZones === 'function' ) {
129+ return normalizeSafeZones ( context . scene . getOverlayLayoutSafeZones ( ) ) ;
130+ }
131+ return Object . freeze ( [ ] ) ;
132+ }
133+
134+ function rectsOverlap ( a , b ) {
135+ return a . x < b . x + b . width && a . x + a . width > b . x && a . y < b . y + b . height && a . y + a . height > b . y ;
136+ }
137+
85138function getComposedRuntimeFrames ( runtime , activeOverlayId ) {
86139 if ( ! runtime || ! Array . isArray ( runtime . runtimeExtensions ) || runtime . runtimeExtensions . length === 0 ) {
87140 return [ ] ;
@@ -118,7 +171,7 @@ function getComposedRuntimeFrames(runtime, activeOverlayId) {
118171 return frames ;
119172}
120173
121- function attachCompositionSlots ( frames , renderer ) {
174+ function attachCompositionSlots ( frames , renderer , safeZones = [ ] ) {
122175 if ( ! Array . isArray ( frames ) || frames . length === 0 ) {
123176 return frames || [ ] ;
124177 }
@@ -128,22 +181,135 @@ function attachCompositionSlots(frames, renderer) {
128181 const height = Math . max ( 180 , Number ( canvasSize . height ) || 540 ) ;
129182 const margin = 16 ;
130183 const gap = 10 ;
131- let cursorY = height - margin ;
184+ const anchorCounts = {
185+ 'bottom-right' : 0 ,
186+ 'top-right' : 0 ,
187+ 'bottom-left' : 0 ,
188+ 'top-left' : 0 ,
189+ } ;
190+ const placedSlots = [ ] ;
191+
192+ function createAnchorSlot ( anchor , slotWidth , slotHeight , index = 0 ) {
193+ const stackOffset = index * ( slotHeight + gap ) ;
194+ const x = anchor . endsWith ( 'right' )
195+ ? Math . round ( width - margin - slotWidth )
196+ : Math . round ( margin ) ;
197+ const y = anchor . startsWith ( 'bottom' )
198+ ? Math . round ( height - margin - slotHeight - stackOffset )
199+ : Math . round ( margin + stackOffset ) ;
200+ return {
201+ x,
202+ y,
203+ width : slotWidth ,
204+ height : slotHeight ,
205+ anchor,
206+ } ;
207+ }
208+
209+ function isSlotWithinBounds ( slot ) {
210+ if ( ! slot ) {
211+ return false ;
212+ }
213+ return ! ( slot . x < 0 || slot . y < 0 || slot . x + slot . width > width || slot . y + slot . height > height ) ;
214+ }
215+
216+ function slotOverlapsPlaced ( slot ) {
217+ if ( ! slot ) {
218+ return false ;
219+ }
220+ for ( let i = 0 ; i < placedSlots . length ; i += 1 ) {
221+ if ( rectsOverlap ( slot , placedSlots [ i ] ) ) {
222+ return true ;
223+ }
224+ }
225+ return false ;
226+ }
227+
228+ function slotOverlapsSafeZones ( slot ) {
229+ if ( ! slot ) {
230+ return false ;
231+ }
232+ for ( let i = 0 ; i < safeZones . length ; i += 1 ) {
233+ if ( rectsOverlap ( slot , safeZones [ i ] ) ) {
234+ return true ;
235+ }
236+ }
237+ return false ;
238+ }
239+
240+ function isSlotUsable ( slot ) {
241+ if ( ! isSlotWithinBounds ( slot ) ) {
242+ return false ;
243+ }
244+ return ! slotOverlapsPlaced ( slot ) && ! slotOverlapsSafeZones ( slot ) ;
245+ }
132246
133247 for ( let i = 0 ; i < frames . length ; i += 1 ) {
134248 const frame = frames [ i ] ;
135249 const slotWidth = Math . max ( 120 , Number ( frame . extension . panelWidth ) || 260 ) ;
136250 const slotHeight = Math . max ( 32 , Number ( frame . extension . panelHeight ) || 96 ) ;
137- const slotX = Math . round ( width - margin - slotWidth ) ;
138- const slotY = Math . round ( cursorY - slotHeight ) ;
251+ const anchorOrder = [ 'bottom-right' , 'top-right' , 'bottom-left' , 'top-left' ] ;
252+
253+ let slot = null ;
254+ for ( let j = 0 ; j < anchorOrder . length ; j += 1 ) {
255+ const anchor = anchorOrder [ j ] ;
256+ const startStackIndex = anchorCounts [ anchor ] || 0 ;
257+ const maxStackIndexExclusive = Math . max (
258+ startStackIndex + 1 ,
259+ startStackIndex + frames . length + safeZones . length + 2
260+ ) ;
261+ for ( let stackIndex = startStackIndex ; stackIndex < maxStackIndexExclusive ; stackIndex += 1 ) {
262+ const candidate = createAnchorSlot ( anchor , slotWidth , slotHeight , stackIndex ) ;
263+ if ( ! isSlotUsable ( candidate ) ) {
264+ continue ;
265+ }
266+ slot = candidate ;
267+ anchorCounts [ anchor ] = stackIndex + 1 ;
268+ break ;
269+ }
270+ if ( slot ) {
271+ break ;
272+ }
273+ }
274+
275+ if ( ! slot ) {
276+ for ( let j = 0 ; j < anchorOrder . length ; j += 1 ) {
277+ const anchor = anchorOrder [ j ] ;
278+ const startStackIndex = anchorCounts [ anchor ] || 0 ;
279+ const maxStackIndexExclusive = Math . max (
280+ startStackIndex + 1 ,
281+ startStackIndex + frames . length + safeZones . length + 2
282+ ) ;
283+ for ( let stackIndex = startStackIndex ; stackIndex < maxStackIndexExclusive ; stackIndex += 1 ) {
284+ const candidate = createAnchorSlot ( anchor , slotWidth , slotHeight , stackIndex ) ;
285+ if ( ! isSlotWithinBounds ( candidate ) || slotOverlapsPlaced ( candidate ) ) {
286+ continue ;
287+ }
288+ slot = candidate ;
289+ anchorCounts [ anchor ] = stackIndex + 1 ;
290+ break ;
291+ }
292+ if ( slot ) {
293+ break ;
294+ }
295+ }
296+ }
297+
298+ if ( ! slot ) {
299+ const fallbackAnchor = 'bottom-right' ;
300+ const fallbackStackIndex = anchorCounts [ fallbackAnchor ] || 0 ;
301+ slot = createAnchorSlot ( fallbackAnchor , slotWidth , slotHeight , fallbackStackIndex ) ;
302+ anchorCounts [ fallbackAnchor ] = fallbackStackIndex + 1 ;
303+ }
304+
139305 frame . slot = Object . freeze ( {
140- x : slotX ,
141- y : slotY ,
142- width : slotWidth ,
143- height : slotHeight ,
144- anchor : 'bottom-right' ,
306+ x : slot . x ,
307+ y : slot . y ,
308+ width : slot . width ,
309+ height : slot . height ,
310+ anchor : slot . anchor ,
145311 } ) ;
146- cursorY = slotY - gap ;
312+ placedSlots . push ( slot ) ;
147313 }
148314
149315 return frames ;
@@ -203,9 +369,11 @@ export function getOverlayGameplayRuntimeInteractionSnapshot(runtime) {
203369
204370export function getOverlayGameplayRuntimeCompositionSnapshot ( runtime , context = { } ) {
205371 const activeOverlayId = String ( context ?. activeOverlayId || '' ) . trim ( ) ;
372+ const safeZones = resolveLayoutSafeZones ( context ) ;
206373 const frames = attachCompositionSlots (
207374 getComposedRuntimeFrames ( runtime , activeOverlayId ) ,
208- context ?. renderer
375+ context ?. renderer ,
376+ safeZones
209377 ) ;
210378 return frames . map ( ( frame , index ) => ( {
211379 index,
@@ -352,9 +520,11 @@ export function renderOverlayGameplayRuntime(runtime, context = {}) {
352520 }
353521
354522 const activeOverlayId = String ( context . activeOverlayId || '' ) . trim ( ) ;
523+ const safeZones = resolveLayoutSafeZones ( context ) ;
355524 const frames = attachCompositionSlots (
356525 getComposedRuntimeFrames ( runtime , activeOverlayId ) ,
357- context . renderer
526+ context . renderer ,
527+ safeZones
358528 ) ;
359529 if ( frames . length === 0 ) {
360530 return 0 ;
0 commit comments