Skip to content

Commit fba3ccd

Browse files
Julusianclaude
andcommitted
feat: estimate a surface's face more closely when it describes none
A display bigger than the others in its row or column, like an info bar under a row of keys, now spills into the empty cells beside it rather than widening its own track, so the keys around it stay evenly spaced. A cell two would both spill into is left to neither. A control with no display fills the cell the displays around it leave, rather than being drawn a fixed square, while one with a ring of leds stays round. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 1eec842 commit fba3ccd

3 files changed

Lines changed: 434 additions & 109 deletions

File tree

‎shared-lib/lib/SurfaceLayout.ts‎

Lines changed: 24 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
SurfaceSchemaLedsConfig,
1818
} from './Model/Surfaces.js'
1919
import type { UserConfigGridSize } from './Model/UserConfigModel.js'
20+
import { estimateFace } from './SurfaceLayoutEstimate.js'
2021

2122
/**
2223
* The style a control is drawn with: its named preset if it has one, otherwise the required default preset.
@@ -217,41 +218,19 @@ export interface SurfaceGridPlacement {
217218
panelGridSize: GridSize
218219
}
219220

220-
/** The gap left between controls, as a fraction of the smallest control. Stands in for the bezel of a real device. */
221-
const CONTROL_GAP_RATIO = 0.14
222-
223221
/** How round a control's corners are, as a fraction of its shorter side. A Stream Deck key, near enough. */
224222
const CONTROL_CORNER_RATIO = 0.12
225223

226224
/** The size of a control which declares no bitmap, when the surface has nothing else to go on */
227225
const NOMINAL_CONTROL_SIZE = 72
228226

229-
/**
230-
* How big a control is on the face, in layout units.
231-
*
232-
* The one guess in here: the schema says how many pixels a control is drawn with, nothing about its physical size,
233-
* so this treats a pixel as a fixed amount of face - i.e. it assumes every control has the same pixel density.
234-
* Real devices don't (a Stream Deck + draws its touch strip at more pixels per mm than its keys), so a strip comes
235-
* out wider and taller relative to a key than it really is. The shapes and ordering are right; the proportions are
236-
* only as good as that assumption. Best available until a layout can state a physical size per control.
237-
*/
238-
function estimateControlSize(
239-
bitmap: SurfaceLayoutBitmapSize | undefined,
240-
fallbackSide: number
241-
): { width: number; height: number } {
242-
if (!bitmap) return { width: fallbackSide, height: fallbackSide }
243-
244-
return { width: bitmap.w, height: bitmap.h }
245-
}
246-
247227
/**
248228
* Work out where a surface's controls are, and which buttons they drive.
249229
*
250230
* Returns null when the layout describes no controls at all.
251231
*
252-
* The schema gives a row and column per control and a bitmap size per style, so positions are derived: each column
253-
* is as wide as the widest control in it, each row as tall as the tallest. A control smaller than its track sits
254-
* centred in it. Sizes are a guess with a known error - see `estimateControlSize`.
232+
* Drawn exactly from the surface's appearance when it described one; otherwise estimated from the layout, see
233+
* `estimateFace`.
255234
*/
256235
export function resolveSurfaceView(
257236
layout: SurfaceSchemaLayoutDefinition,
@@ -282,7 +261,8 @@ export function resolveSurfaceView(
282261
/** Relative to the surface's own origin, before the offset onto the grid */
283262
gridX: number
284263
gridY: number
285-
size: { width: number; height: number }
264+
/** How big its display is on the face, or null when it has none */
265+
display: { width: number; height: number } | null
286266
feedback: SurfaceControlFeedback
287267
leds: SurfaceSchemaLedsConfig | null
288268
aspectRatio: AspectRatio | null
@@ -296,15 +276,15 @@ export function resolveSurfaceView(
296276

297277
const preset = resolveControlStylePreset(layout, control)
298278
const bitmap = preset.bitmap
299-
const natural = estimateControlSize(bitmap, fallbackSide)
279+
const display = bitmap ? { width: bitmap.w, height: bitmap.h } : null
300280

301281
placed.push({
302282
id,
303283
cell: { row: gridY + placement.offset.rows, column: gridX + placement.offset.columns },
304284
gridX,
305285
gridY,
306286
// A surface on its side presents its controls on their side too
307-
size: quarterTurn ? { width: natural.height, height: natural.width } : natural,
287+
display: display && quarterTurn ? { width: display.height, height: display.width } : display,
308288
feedback: controlFeedback(preset),
309289
leds: preset.leds ?? null,
310290
aspectRatio: bitmap ? reduceAspectRatio(bitmap.w, bitmap.h) : null,
@@ -340,50 +320,29 @@ export function resolveSurfaceView(
340320
}
341321
}
342322

343-
// Empty tracks between occupied ones keep their room: controls numbered 0, 3, 5 left space for what's between,
344-
// and closing it up would draw the device narrower than it is
345-
const columnWidths = trackSizes(
346-
placed.map((control) => ({ track: control.gridX, size: control.size.width })),
347-
fallbackSide
348-
)
349-
const rowHeights = trackSizes(
350-
placed.map((control) => ({ track: control.gridY, size: control.size.height })),
323+
const face = estimateFace(
324+
placed.map((control) => ({
325+
track: { x: control.gridX, y: control.gridY },
326+
display: control.display,
327+
ring: control.leds?.mode === 'full-ring',
328+
})),
351329
fallbackSide
352330
)
353331

354-
const gap = Math.round(Math.min(...[...columnWidths.values(), ...rowHeights.values()]) * CONTROL_GAP_RATIO)
355-
356-
const columnOffsets = trackOffsets(columnWidths, gap)
357-
const rowOffsets = trackOffsets(rowHeights, gap)
358-
359-
const controls: ResolvedSurfaceControl[] = placed.map((control) => {
360-
const trackWidth = columnWidths.get(control.gridX) ?? control.size.width
361-
const trackHeight = rowHeights.get(control.gridY) ?? control.size.height
362-
363-
return {
364-
id: control.id,
365-
cell: control.cell,
366-
bounds: {
367-
// Centred in the room the device leaves for it, rather than stretched to fill it
368-
x: (columnOffsets.get(control.gridX) ?? 0) + (trackWidth - control.size.width) / 2,
369-
y: (rowOffsets.get(control.gridY) ?? 0) + (trackHeight - control.size.height) / 2,
370-
width: control.size.width,
371-
height: control.size.height,
372-
},
373-
shape: { type: 'rect', cornerRadiusRatio: CONTROL_CORNER_RATIO },
374-
feedback: control.feedback,
375-
leds: control.leds,
376-
aspectRatio: control.aspectRatio,
377-
renderSize: control.renderSize,
378-
}
379-
})
332+
const controls: ResolvedSurfaceControl[] = placed.map((control, index) => ({
333+
id: control.id,
334+
cell: control.cell,
335+
bounds: face.bounds[index],
336+
shape: { type: 'rect', cornerRadiusRatio: CONTROL_CORNER_RATIO },
337+
feedback: control.feedback,
338+
leds: control.leds,
339+
aspectRatio: control.aspectRatio,
340+
renderSize: control.renderSize,
341+
}))
380342

381343
return {
382344
controls,
383-
extent: {
384-
width: trackExtent(columnWidths, gap),
385-
height: trackExtent(rowHeights, gap),
386-
},
345+
extent: face.extent,
387346
gridBounds: gridBoundsFromCells(controls),
388347
// No described face; the plain ground shows between the controls
389348
body: null,
@@ -413,47 +372,3 @@ function gridBoundsFromCells(controls: readonly ResolvedSurfaceControl[]): UserC
413372
maxColumn: Math.max(...controls.map((control) => control.cell.column)),
414373
}
415374
}
416-
417-
/**
418-
* How big each track has to be to hold the largest thing in it. Covers every track from first to last, so an empty
419-
* one still takes up the room the surface left for it.
420-
*/
421-
function trackSizes(entries: readonly { track: number; size: number }[], emptySize: number): Map<number, number> {
422-
const sizes = new Map<number, number>()
423-
424-
const tracks = entries.map((entry) => entry.track)
425-
for (let track = Math.min(...tracks); track <= Math.max(...tracks); track++) {
426-
sizes.set(track, 0)
427-
}
428-
429-
for (const { track, size } of entries) {
430-
sizes.set(track, Math.max(sizes.get(track) ?? 0, size))
431-
}
432-
433-
for (const [track, size] of sizes) {
434-
if (size === 0) sizes.set(track, emptySize)
435-
}
436-
437-
return sizes
438-
}
439-
440-
/** Where each track starts, laid out in order with a gap between them */
441-
function trackOffsets(sizes: ReadonlyMap<number, number>, gap: number): Map<number, number> {
442-
const offsets = new Map<number, number>()
443-
444-
let position = 0
445-
for (const track of [...sizes.keys()].sort((a, b) => a - b)) {
446-
offsets.set(track, position)
447-
position += (sizes.get(track) ?? 0) + gap
448-
}
449-
450-
return offsets
451-
}
452-
453-
/** How far the tracks reach in total, without a trailing gap */
454-
function trackExtent(sizes: ReadonlyMap<number, number>, gap: number): number {
455-
let extent = 0
456-
for (const size of sizes.values()) extent += size + gap
457-
458-
return Math.max(0, extent - gap)
459-
}

0 commit comments

Comments
 (0)