Our own vocabularies in the styles of a growing core and a motor-plan set, built from cited open sources, never from a vendor's words, positions or names. Growing Core: one core board through eleven sizes (1x1 to 8x10). Every item lives at one point of the screen and is drawn, at every size, in the cell that contains it, so a word learned at 3x3 is in the same region at 8x10. The board is full at every size and nothing that appeared ever goes. The first boards are designed by hand; a solver places the rest by Fitzgerald region. Ten pages of more words, each with Back in the top-left corner; social messages with repairs; a Word forms page; hide-and-reveal at a fixed size from each word's stage. Fixed Paths: one meaning per location and one path per word, of 1 to 3 hits, at 30, 60 and 84 locations. The heaviest words (spoken frequency, more for Universal Core and Banajee 2003 words) take one hit in the easiest places for a blend of touch, scanning and gaze; a page is closed by the place of the key that opened it. No multi-meaning icon sequences. A vocabulary filter (starter) hides the rest in place. tools/pagesets: design sources (YAML), the solver (run once, committed as layouts), and a reproducible build that writes 19 .obz packages, an index, metrics and a per-language report to data/pagesets. aac-core tests/pagesets.rs imports every package with the app's own importer and checks positions, actions, word classes, that every symbol is the one the app draws, that each button says what the app's own export would, the growth and path rules, and language honesty. Co-Authored-By: Claude Opus 5.5 <[email protected]>
134 lines
5 KiB
Python
134 lines
5 KiB
Python
"""Screens, target sizes, and what "easy to reach" means for each access method.
|
|
|
|
These are models, stated so they can be argued with; none is a measurement of
|
|
a person. DESIGN-motor.md gives the reasoning and shows the trade-offs.
|
|
|
|
Screens (CSS pixels and millimetres per CSS pixel, from the devices' published
|
|
resolutions):
|
|
tablet: a 10.2-inch tablet (iPad 9th generation class), 2160x1620 px at
|
|
264 ppi, 1080x810 CSS px -> 0.1924 mm per CSS px.
|
|
phone: a 6.1-inch phone (iPhone 14 class), 1170x2532 px at 460 ppi,
|
|
390x844 CSS px -> 0.1657 mm per CSS px; safe areas 47 px top and
|
|
34 px bottom in portrait, 47 px each side and 21 px bottom in
|
|
landscape.
|
|
The app's chrome (aac-web style/main.css): message bar 66 px, suggestion row
|
|
52 px (3.25em), navigation bar 54 px; board padding 4 px; 4 px between cells
|
|
(the default cell_spacing_px). Text mode and the word-finding bar are off.
|
|
|
|
Target sizes are compared with the app's own recommendations
|
|
(access-core input.rs recommended_target_mm): touch 10 mm, switch 15 mm,
|
|
head 18 mm, eye gaze 20 mm.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from dataclasses import dataclass
|
|
|
|
from .grid import Size
|
|
|
|
CHROME_PX = 66 + 52 + 54
|
|
PAD_PX = 4
|
|
GAP_PX = 4
|
|
|
|
RECOMMENDED_MM = {"touch": 10.0, "switch": 15.0, "head": 18.0, "gaze": 20.0}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Screen:
|
|
name: str
|
|
width_px: int
|
|
height_px: int
|
|
mm_per_px: float
|
|
safe_x: int = 0 # both sides together
|
|
safe_y: int = 0 # top and bottom together
|
|
|
|
def board_px(self) -> tuple[float, float]:
|
|
w = self.width_px - self.safe_x - 2 * PAD_PX
|
|
h = self.height_px - self.safe_y - CHROME_PX - 2 * PAD_PX
|
|
return w, h
|
|
|
|
def cell_mm(self, size: Size) -> tuple[float, float]:
|
|
w, h = self.board_px()
|
|
cw = (w - (size.cols - 1) * GAP_PX) / size.cols
|
|
ch = (h - (size.rows - 1) * GAP_PX) / size.rows
|
|
return cw * self.mm_per_px, ch * self.mm_per_px
|
|
|
|
|
|
SCREENS = {
|
|
"tablet-landscape": Screen("10.2-inch tablet, landscape", 1080, 810, 0.1924),
|
|
"tablet-portrait": Screen("10.2-inch tablet, portrait", 810, 1080, 0.1924),
|
|
"phone-portrait": Screen("6.1-inch phone, portrait", 390, 844, 0.1657, safe_y=47 + 34),
|
|
"phone-landscape": Screen("6.1-inch phone, landscape", 844, 390, 0.1657, safe_x=47 + 47, safe_y=21),
|
|
}
|
|
|
|
# The screen the motor costs are computed on.
|
|
DESIGN_SCREEN = SCREENS["tablet-landscape"]
|
|
|
|
|
|
def touch_cost(cell: tuple[int, int], size: Size, screen: Screen = DESIGN_SCREEN) -> float:
|
|
"""Fitts' index of difficulty, log2(1 + D/W), from a hand resting at the
|
|
bottom centre below the board (the navigation bar's height under it) to
|
|
the cell's centre; W is the smaller side of the cell."""
|
|
w_px, h_px = screen.board_px()
|
|
cw = (w_px - (size.cols - 1) * GAP_PX) / size.cols
|
|
ch = (h_px - (size.rows - 1) * GAP_PX) / size.rows
|
|
r, c = cell
|
|
x = c * (cw + GAP_PX) + cw / 2
|
|
y = r * (ch + GAP_PX) + ch / 2
|
|
rest_x, rest_y = w_px / 2, h_px + 54
|
|
d = math.hypot(x - rest_x, y - rest_y)
|
|
return math.log2(1 + d / min(cw, ch))
|
|
|
|
|
|
def scan_steps(cell: tuple[int, int], size: Size) -> int:
|
|
"""Row-column scanning from the top left: highlights until the cell is
|
|
chosen (rows stepped through, then cells in the row), the selection
|
|
presses included. The app's default places the bar and the navigation
|
|
after the board (SPEC §5.5, §5.6), so they add nothing before it."""
|
|
r, c = cell
|
|
return (r + 1) + (c + 1)
|
|
|
|
|
|
def gaze_cost(cell: tuple[int, int], size: Size) -> float:
|
|
"""Distance of the cell's centre from the middle of the board, 0 at the
|
|
centre and 1 at a corner: gaze estimates (a webcam's above all) are least
|
|
accurate towards the edges, and a target at the edge has nowhere to put
|
|
the overshoot."""
|
|
r, c = cell
|
|
y = (r + 0.5) / size.rows - 0.5
|
|
x = (c + 0.5) / size.cols - 0.5
|
|
return math.hypot(x, y) / math.hypot(0.5, 0.5)
|
|
|
|
|
|
METHODS = {"touch": touch_cost, "scan": scan_steps, "gaze": gaze_cost}
|
|
|
|
|
|
def cost_table(size: Size, method: str) -> dict[tuple[int, int], float]:
|
|
fn = METHODS[method]
|
|
return {(r, c): float(fn((r, c), size)) for r in range(size.rows) for c in range(size.cols)}
|
|
|
|
|
|
def blended(size: Size, weights: dict[str, float]) -> dict[tuple[int, int], float]:
|
|
"""Each method's cost scaled to 0..1 over the grid, then weighted."""
|
|
out = {(r, c): 0.0 for r in range(size.rows) for c in range(size.cols)}
|
|
for m, w in weights.items():
|
|
t = cost_table(size, m)
|
|
lo, hi = min(t.values()), max(t.values())
|
|
for k, v in t.items():
|
|
out[k] += w * ((v - lo) / (hi - lo) if hi > lo else 0.0)
|
|
return out
|
|
|
|
|
|
def target_sizes(size: Size) -> dict[str, dict]:
|
|
out = {}
|
|
for key, screen in SCREENS.items():
|
|
w, h = screen.cell_mm(size)
|
|
smaller = min(w, h)
|
|
out[key] = {
|
|
"width_mm": round(w, 1),
|
|
"height_mm": round(h, 1),
|
|
"smaller_mm": round(smaller, 1),
|
|
"meets": [m for m, mm in RECOMMENDED_MM.items() if smaller >= mm],
|
|
}
|
|
return out
|