From 5b0b30dcce3927f9ea2f39918a313a055acde142 Mon Sep 17 00:00:00 2001 From: Michael B Reiser Date: Fri, 2 Oct 2026 14:57:08 -0400 Subject: [PATCH 01/17] fix(studio): ISP time estimate ~5 s/panel, not ~20 s (#244) The Program set confirm said ~8 min for a 20-panel arena; the batch on rig5-mr (2026-09-30) took ~2 min: ~4.4 s program + reboot and ~1 s verify + STOP per panel. Use 5 s + 1 s for the estimate and fix the "~20 s" in the dialog and the Flash / Program set tooltips. Co-Authored-By: Claude Opus 5.5 --- arena_studio.html | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/arena_studio.html b/arena_studio.html index 0cd898fb..8c00d0d7 100644 --- a/arena_studio.html +++ b/arena_studio.html @@ -3243,7 +3243,7 @@

Import error

single - +
fleet @@ -3253,7 +3253,7 @@

Import error

- +
@@ -8659,9 +8659,10 @@

Import error

'Upload a .bin first (Choose…), or click OK to try anyway.')) return; } const verify = $('cIspVerify').checked; - const est = Math.ceil((panels.length * (20 + (verify ? 2 : 0))) / 60); + // Bench 2026-09-30 (#244): ~4.4 s program + reboot, ~1 s verify + STOP per panel. + const est = Math.ceil((panels.length * (5 + (verify ? 1 : 0))) / 60); if (!confirm('Program ' + panels.length + ' panel(s) [' + fmtRange(panels) + '] from /firmware/panel.bin?\n' + - 'Each panel is reflashed over SPI and reboots (~20 s). Do NOT power off mid-flash.\n' + + 'Each panel is reflashed over SPI and reboots (~5 s). Do NOT power off mid-flash.\n' + 'Estimated time ~' + est + ' min.')) return; isp.running = true; From 4d258b035a002c4623acb1997d80161c5bd04730 Mon Sep 17 00:00:00 2001 From: Michael B Reiser Date: Tue, 6 Oct 2026 14:58:19 -0400 Subject: [PATCH 02/17] fix(pattern-designer): degrees per pixel uses the full circle on partial arenas getDegreesPerPixel() divided 360 deg by the INSTALLED pixel columns instead of the full circumference. On G4 3x12of18 (the IAS talk arena) that gave 1.875 deg/px instead of 1.25, so the px<->deg helper labels were wrong and a spatial period typed in pixels generated the wrong grating (24 px came out as 45 deg, 36 frames). A step size typed in degrees was off the same way. G6 3x12of18 (1.5 -> 1.0 deg/px) and G6 2x8of10 (2.25 -> 1.8) had the same bug; full arenas were already right. The generator already used the full circle (arenaCoordinates numCircle = num_cols). The pitch now lives in one shared place, PatternGenerator.getDegreesPerPixel(), and getArenaDimensions() reports circleCols / azimuthPixels, which the three spherical generators also read, so the editor and generator cannot drift apart again. Also fixes the Animate tab's frame-shift hint on partial arenas ("360 deg = 192 px" -> "240 deg = 192 px"), and cache-busts generator.js because the editor's init now calls the new helper. tests/test-pattern-degrees-per-pixel.js runs the editor's own conversion functions (extracted from pattern_editor.html) for every standard config, plus end-to-end gratings typed in px and deg; it failed 15 checks before the fix. Co-Authored-By: Claude Opus 5.5 --- js/pattern-editor/tools/generator.js | 25 ++- pattern_editor.html | 22 +- tests/test-pattern-degrees-per-pixel.js | 255 ++++++++++++++++++++++++ 3 files changed, 289 insertions(+), 13 deletions(-) create mode 100644 tests/test-pattern-degrees-per-pixel.js diff --git a/js/pattern-editor/tools/generator.js b/js/pattern-editor/tools/generator.js index aaf6e1c9..d518e3f8 100644 --- a/js/pattern-editor/tools/generator.js +++ b/js/pattern-editor/tools/generator.js @@ -98,7 +98,8 @@ const PatternGenerator = { /** * Calculate arena dimensions * @param {Object} arena - Arena configuration - * @returns {Object} Dimensions {rows, cols, pixelRows, pixelCols, panelSize} + * @returns {Object} Dimensions {rows, cols, installedCols, circleCols, pixelRows, + * pixelCols, azimuthPixels, panelSize} */ getArenaDimensions(arena) { const generation = arena.generation || arena.arena?.generation; @@ -107,6 +108,9 @@ const PatternGenerator = { // For partial arenas, use columns_installed length instead of num_cols const columnsInstalled = arena.columns_installed || arena.arena?.columns_installed; const installedCols = columnsInstalled?.length || numCols; + // Panels in the full 360° circle (Pcircle): sets the angular pitch, which a + // partial arena shares with the full one + const circleCols = arena.numCircle || arena.num_cols_full || arena.Pcircle || numCols; const specs = this.getPanelSpecs(generation); const panelSize = specs.pixels_per_panel; @@ -116,12 +120,25 @@ const PatternGenerator = { rows: numRows, cols: numCols, // Total arena slots (for geometry calculations) installedCols, // Actual installed columns (for pattern dimensions) + circleCols, pixelRows: numRows * panelSize, pixelCols: installedCols * panelSize, // Use installed columns for pattern width + azimuthPixels: circleCols * panelSize, // Pixels around the full circle panelSize }; }, + /** + * Azimuthal pitch in degrees per pixel: 360° over the FULL circle's pixels, never the + * installed ones. G4 3×12of18 = 360 / (18 × 16) = 1.25°/px. The spherical generators + * use the same pitch (arenaCoordinates numCircle), so px↔° conversions must use this. + * @param {Object} arena - Arena configuration (generator, config.arena or config form) + * @returns {number} Degrees per pixel + */ + getDegreesPerPixel(arena) { + return 360 / this.getArenaDimensions(arena).azimuthPixels; + }, + /** * Create an empty frame (all zeros) * @param {number} pixelRows - Number of pixel rows @@ -359,7 +376,7 @@ const PatternGenerator = { // Determine number of columns for full circle (Pcircle) // For partial arenas, numCircle is the FULL arena size (for correct angular spacing) // but numCols should be the installed columns (for pattern dimensions) - const numCircle = arena.numCircle || arena.num_cols_full || arena.Pcircle || cols; + const numCircle = dims.circleCols; // Generate arena coordinates // numCols = installed columns (pattern dimension) @@ -578,7 +595,7 @@ const PatternGenerator = { const { pixelRows, pixelCols, generation, rows, cols, installedCols, panelSize } = dims; // Determine number of columns for full circle (Pcircle) - const numCircle = arena.numCircle || arena.num_cols_full || arena.Pcircle || cols; + const numCircle = dims.circleCols; // Generate arena coordinates for projection const arenaConfig = { @@ -862,7 +879,7 @@ const PatternGenerator = { const { pixelRows, pixelCols, generation, rows, cols, installedCols, panelSize } = dims; // Determine number of columns for full circle (Pcircle) - const numCircle = arena.numCircle || arena.num_cols_full || arena.Pcircle || cols; + const numCircle = dims.circleCols; // Generate arena coordinates const arenaConfig = { diff --git a/pattern_editor.html b/pattern_editor.html index 9ce1aa12..e1385c5f 100644 --- a/pattern_editor.html +++ b/pattern_editor.html @@ -26,7 +26,9 @@ - + + @@ -4016,12 +4018,10 @@ */ function getDegreesPerPixel() { if (!state.arena.config) return 1.8; // Default for G6 2x10 - const config = state.arena.config.arena; - const specs = PANEL_SPECS[config.generation]; - // Use columns_installed for partial arenas, fallback to num_cols for full arenas - const installedCols = config.columns_installed?.length || config.num_cols; - const pixelCols = installedCols * specs.pixels_per_panel; - return 360 / pixelCols; + // Full circumference (num_cols), never the installed columns: a partial arena keeps + // the full arena's pitch (G4 3x12of18 = 360 / (18 x 16) = 1.25 deg/px), the same + // pitch the generator uses. Shared helper so the two can't drift apart. + return PatternGenerator.getDegreesPerPixel(state.arena.config.arena); } /** @@ -6239,9 +6239,13 @@ let defaultFrames; let hint; if (direction.startsWith('h-')) { - // Horizontal motion: full 360° loop + // Horizontal motion: one full wrap of the pattern width (360° on a full + // arena; a partial arena's installed span, e.g. 240° on G4 3x12of18) defaultFrames = pixelCols; - hint = `(360° = ${pixelCols} px)`; + const degrees = state.arena.config + ? Math.round(pixelCols * getDegreesPerPixel()) + : 360; + hint = `(${degrees}° = ${pixelCols} px)`; } else { // Vertical motion: full vertical traverse defaultFrames = pixelRows; diff --git a/tests/test-pattern-degrees-per-pixel.js b/tests/test-pattern-degrees-per-pixel.js new file mode 100644 index 00000000..e5a36281 --- /dev/null +++ b/tests/test-pattern-degrees-per-pixel.js @@ -0,0 +1,255 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Pattern Designer degrees-per-pixel regression (partial arenas). + * + * A partial arena keeps the FULL arena's pixel pitch: G4 3×12of18 has 12 of 18 columns + * installed, 16 px per panel, so its pitch is 360° / (18 × 16) = 1.25°/px, not + * 360° / (12 × 16) = 1.875°/px. The pattern generator already uses the full circle + * (`numCircle = num_cols`); the editor's px↔° conversions must use the same pitch, or a + * spatial period typed in pixels generates the wrong grating (24 px came out as 45°). + * + * The editor's helpers live inside pattern_editor.html's module script, so this test + * extracts them by name and runs them against a stub `state`, together with the real + * PatternGenerator and arena configs. + */ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const ROOT = path.join(__dirname, '..'); +const { STANDARD_CONFIGS, PANEL_SPECS } = require('../js/arena-configs.js'); +global.PANEL_SPECS = PANEL_SPECS; +const PatternGenerator = require('../js/pattern-editor/tools/generator.js'); + +const html = fs.readFileSync(path.join(ROOT, 'pattern_editor.html'), 'utf8'); + +let checks = 0; +let failures = 0; + +function check(name, condition, detail) { + checks++; + if (condition) { + console.log(` PASS ${name}`); + return; + } + failures++; + console.log(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); +} + +function near(a, b, tol = 1e-9) { + return Number.isFinite(a) && Math.abs(a - b) <= tol; +} + +/** Source of `function name(...) {...}` in the editor, by brace matching. */ +function extractFunction(name) { + const start = html.indexOf(`function ${name}(`); + if (start < 0) throw new Error(`pattern_editor.html: function ${name} not found`); + const open = html.indexOf('{', start); + let depth = 0; + for (let i = open; i < html.length; i++) { + if (html[i] === '{') depth++; + else if (html[i] === '}' && --depth === 0) return html.slice(start, i + 1); + } + throw new Error(`pattern_editor.html: unbalanced braces in ${name}`); +} + +const EDITOR_FUNCTIONS = [ + 'getDegreesPerPixel', + 'convertSpatialFreqToRadians', + 'convertStepSizeToPixels', + 'convertStepSizeToRadians' +]; + +/** The editor's conversion helpers, bound to a stub state for one arena config. */ +function editorFor(configName) { + const sandbox = { + PANEL_SPECS, + PatternGenerator, + Math, + state: { arena: { config: configName ? STANDARD_CONFIGS[configName] : null } } + }; + vm.createContext(sandbox); + vm.runInContext(EDITOR_FUNCTIONS.map(extractFunction).join('\n'), sandbox); + return sandbox; +} + +function arenaFor(configName) { + const a = STANDARD_CONFIGS[configName].arena; + // Same object the editor's handleGenerate builds. + return { + generation: a.generation, + rows: a.num_rows, + cols: a.num_cols, + columns_installed: a.columns_installed + }; +} + +/** Expected pitch: full circumference = num_cols × pixels_per_panel around 360°. */ +function expectedDegPerPx(configName) { + const a = STANDARD_CONFIGS[configName].arena; + return 360 / (a.num_cols * PANEL_SPECS[a.generation].pixels_per_panel); +} + +console.log('\n=== editor getDegreesPerPixel: full circumference, every standard config ==='); +const PINNED = { + G4_3x12of18: 1.25, // 18 × 16 = 288 px around (12 installed) + G6_3x12of18: 1.0, // 18 × 20 = 360 px around (12 installed) + G6_2x8of10: 1.8, // 10 × 20 = 200 px around (8 installed) + G6_2x10: 1.8, + G6_3x10: 1.8, + G6_4x10: 1.8, + G6_3x16_full: 1.125, + G4_3x12: 1.875, + G4_4x12: 1.875, + G41_2x12_cw: 1.875, + G3_3x24: 1.875, + G3_4x12: 3.75 +}; +for (const name of Object.keys(STANDARD_CONFIGS)) { + const got = editorFor(name).getDegreesPerPixel(); + const want = PINNED[name] ?? expectedDegPerPx(name); + check(`${name}: ${want}°/px`, near(got, want), `got ${got}`); + check( + `${name}: pinned value = 360 / (num_cols × px/panel)`, + near(want, expectedDegPerPx(name)) + ); +} +check( + 'no arena selected: G6 2×10 default 1.8°/px', + near(editorFor(null).getDegreesPerPixel(), 1.8) +); + +console.log('\n=== shared helper: PatternGenerator.getDegreesPerPixel ==='); +check( + 'PatternGenerator.getDegreesPerPixel exists', + typeof PatternGenerator.getDegreesPerPixel === 'function' +); +if (typeof PatternGenerator.getDegreesPerPixel === 'function') { + for (const name of Object.keys(STANDARD_CONFIGS)) { + const cfg = STANDARD_CONFIGS[name]; + check( + `${name}: generator arena object`, + near(PatternGenerator.getDegreesPerPixel(arenaFor(name)), expectedDegPerPx(name)) + ); + check( + `${name}: raw config.arena (num_cols form)`, + near(PatternGenerator.getDegreesPerPixel(cfg.arena), expectedDegPerPx(name)) + ); + check( + `${name}: whole config ({arena: …} form)`, + near(PatternGenerator.getDegreesPerPixel(cfg), expectedDegPerPx(name)) + ); + } + const dims = PatternGenerator.getArenaDimensions(arenaFor('G4_3x12of18')); + check('G4 12of18 dims: pattern is 192 px wide (installed)', dims.pixelCols === 192); + check('G4 12of18 dims: 288 azimuth px (full circle)', dims.azimuthPixels === 288); + check('G4 12of18 dims: circleCols = 18', dims.circleCols === 18); +} + +console.log('\n=== px ↔ ° conversions on G4 3×12of18 (1.25°/px) ==='); +{ + const ed = editorFor('G4_3x12of18'); + const rad = Math.PI / 180; + check('24 px period = 30°', near(ed.convertSpatialFreqToRadians(24, 'px'), 30 * rad)); + check('30° period = 30°', near(ed.convertSpatialFreqToRadians(30, 'deg'), 30 * rad)); + check('1.25° step = 1 px', near(ed.convertStepSizeToPixels(1.25, 'deg'), 1)); + check('1 px step = 1 px', near(ed.convertStepSizeToPixels(1, 'px'), 1)); + check('1 px step = 1.25° (radians)', near(ed.convertStepSizeToRadians(1, 'px'), 1.25 * rad)); +} + +console.log('\n=== end to end: a period typed in px generates that period ==='); +/** + * Generate a rotation grating the way handleGenerate does and measure it: frame count + * (= one period at a 1 px step) and the bright-run length along the middle row. + */ +function generateGrating(configName, spatialValue, spatialUnit, stepValue, stepUnit) { + const ed = editorFor(configName); + return PatternGenerator.generateSphericalGrating( + { + high: 15, + low: 0, + gsMode: 2, + stretch: 1, + spatFreq: ed.convertSpatialFreqToRadians(spatialValue, spatialUnit), + motionType: 'rotation', + waveform: 'square', + dutyCycle: 50, + stepSize: ed.convertStepSizeToPixels(stepValue, stepUnit), + aaSamples: 1, + arenaModel: 'smooth', + phaseShift: 0, + poleCoord: [0, -Math.PI / 2] + }, + arenaFor(configName) + ); +} + +function runLengths(pattern) { + const row = Math.floor(pattern.pixelRows / 2); + const f = pattern.frames[0]; + const vals = []; + for (let c = 0; c < pattern.pixelCols; c++) vals.push(f[row * pattern.pixelCols + c] > 0); + const runs = []; + let len = 1; + for (let c = 1; c < vals.length; c++) { + if (vals[c] === vals[c - 1]) len++; + else { + runs.push(len); + len = 1; + } + } + runs.push(len); + return runs.slice(1, -1); // interior runs only (the edges may be clipped) +} + +const CASES = [ + // [config, value, unit, expected period px] + ['G4_3x12of18', 24, 'px', 24], + ['G4_3x12of18', 30, 'deg', 24], + ['G6_3x12of18', 30, 'px', 30], + ['G6_3x12of18', 30, 'deg', 30], + ['G6_2x8of10', 20, 'px', 20], + ['G6_2x8of10', 36, 'deg', 20], + ['G6_2x10', 20, 'px', 20], + ['G4_4x12', 24, 'px', 24] +]; +for (const [name, value, unit, period] of CASES) { + const pattern = generateGrating(name, value, unit, 1, 'px'); + const runs = runLengths(pattern); + check( + `${name}: ${value} ${unit} period → ${period} frames at a 1 px step`, + pattern.numFrames === period, + `got ${pattern.numFrames}` + ); + check( + `${name}: ${value} ${unit} period → ${period / 2}-px bars`, + runs.length > 0 && runs.every((r) => r === period / 2), + `runs ${JSON.stringify(runs)}` + ); +} +{ + const pattern = generateGrating('G4_3x12of18', 30, 'deg', 1.25, 'deg'); + check( + 'G4_3x12of18: 1.25° step on a 30° period → 24 frames', + pattern.numFrames === 24, + `got ${pattern.numFrames}` + ); +} + +console.log('\n=== editor wiring ==='); +check( + 'pattern_editor.html cache-busts generator.js (init calls the new helper)', + / + - + diff --git a/js/arena-replay-viewer.js b/js/arena-replay-viewer.js index ebcd7118..58ca252e 100644 --- a/js/arena-replay-viewer.js +++ b/js/arena-replay-viewer.js @@ -1,6 +1,6 @@ import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.182.0/build/three.module.js'; import PatParser from './pat-parser.js'; -import ThreeViewer from './pattern-editor/viewers/three-viewer.js?v=0713-solid-ball'; +import ThreeViewer from './pattern-editor/viewers/three-viewer.js?v=1006-partial-cols'; import { PANEL_SPECS, STANDARD_CONFIGS, getArenaName, getConfig } from './arena-configs.js'; const Protocol = window.ArenaReplayViewerProtocol; diff --git a/js/pattern-editor/viewers/three-viewer.js b/js/pattern-editor/viewers/three-viewer.js index 49f41b3f..cfd2bf37 100644 --- a/js/pattern-editor/viewers/three-viewer.js +++ b/js/pattern-editor/viewers/three-viewer.js @@ -631,6 +631,18 @@ class ThreeViewer { } } + /** + * Map each installed physical column to its panel block in the PAT. A partial arena's + * pattern stores only the installed columns, in columns_installed order, so block k is + * physical column columns_installed[k] (G6_2x8of10: column 1 is block 0). Full arenas + * map identically. Built from the CONFIGURED columns, never the preview-visible set, so + * the CSHL rear-panel omission does not shift the PAT address map. + * @returns {Map} physical column index -> pattern panel block + */ + _getPatternColumnMap() { + return new Map([...this._getInstalledColumnsSet()].map((col, block) => [col, block])); + } + /** * Return columns rendered by the preview. Panels 8 and 18 are a physical * CSHL omission that is deliberately absent from the rig/config registry. @@ -721,6 +733,7 @@ class ThreeViewer { // Note: Three.js uses right-handed coords but top-down view has +Z toward viewer, // so we negate Z to match MATLAB's top-down appearance const visibleColumns = this._getVisibleColumnsSet(); + const patternColumns = this._getPatternColumnMap(); for (let col = 0; col < numCols; col++) { // Skip config-level omissions and the visual-only CSHL panels 8/18 rear gap. @@ -747,7 +760,8 @@ class ThreeViewer { numRows, col, numCols, - columnOrder + columnOrder, + patternColumns.get(col) ); columnGroup.position.set(x, 0, z); columnGroup.userData.columnIndex = col; @@ -885,7 +899,18 @@ class ThreeViewer { this.controls.update(); } - _createColumn(specs, width, height, depth, angle, numRows, colIndex, numCols, columnOrder) { + _createColumn( + specs, + width, + height, + depth, + angle, + numRows, + colIndex, + numCols, + columnOrder, + patternCol = colIndex + ) { const group = new THREE.Group(); // Apply rotation to face center (matches standalone viewer exactly) @@ -1023,6 +1048,7 @@ class ThreeViewer { this.ledMeshes.push({ mesh: rect, colIndex: colIndex, + patternCol: patternCol, px: px, py: py, totalPixelsH: totalPixelsH, @@ -1078,6 +1104,7 @@ class ThreeViewer { this.ledMeshes.push({ mesh: led, colIndex: colIndex, + patternCol: patternCol, px: px, py: py, totalPixelsH: totalPixelsH, @@ -1185,7 +1212,10 @@ class ThreeViewer { } _getLEDBrightness(ledRef) { - const { px, py, colIndex, totalPixelsH, numCols, columnOrder } = ledRef; + const { px, py, colIndex, totalPixelsH, columnOrder } = ledRef; + // The LED's panel block in the PAT (see _getPatternColumnMap); refs built without + // one fall back to the physical column, which is the same on full arenas. + const patternCol = ledRef.patternCol ?? colIndex; const pattern = this.state.pattern; if (!pattern || !pattern.frames || pattern.frames.length === 0) { @@ -1196,7 +1226,9 @@ class ThreeViewer { if (!frame) return 0.0; const pixelsPerPanel = totalPixelsH; - const totalAzimuthPixels = numCols * totalPixelsH; + // Wrap within the pattern's own width: a partial arena's PAT holds only the + // installed columns, so wrapping at the full circle would spill into the next row. + const patternWidth = pattern.pixelCols; // For CCW mode, mirror the pixel index within each panel // to ensure grating tiles correctly when columns are placed clockwise @@ -1205,8 +1237,9 @@ class ThreeViewer { // Calculate global X with phase offset support const phaseOffset = this.state.phaseOffset || 0; const globalX = - (colIndex * pixelsPerPanel + effectivePx + phaseOffset + totalAzimuthPixels) % - totalAzimuthPixels; + (((patternCol * pixelsPerPanel + effectivePx + phaseOffset) % patternWidth) + + patternWidth) % + patternWidth; const globalY = py; // Row-major index: row * numCols + col diff --git a/pattern_editor.html b/pattern_editor.html index e1385c5f..09534c85 100644 --- a/pattern_editor.html +++ b/pattern_editor.html @@ -2565,7 +2565,7 @@ + - + @@ -6554,6 +6554,13 @@

Import error

let done = 0, failed = 0; const uploaded = []; clog('SD upload: ' + files.length + ' file(s)' + tag + '…', 'info'); + // G6 playback: warn (never block) when the controller would reject a pattern at load + // — the rig is a partial arena, or the file's grid differs from the rig's. + const PS = window.PatternSet; + const rigArena = Studio.rigArena ? Studio.rigArena() : null; + if (PS && PS.isPartialG6Arena && PS.isPartialG6Arena(rigArena)) { + clog('⚠ the session rig (' + Studio.currentRig.arenaConfig + ') is a partial arena: ' + PS.PARTIAL_G6_NOTE, 'warn'); + } for (let i = 0; i < files.length; i++) { const f = files[i]; const pos = (i + 1) + '/' + files.length; @@ -6566,6 +6573,8 @@

Import error

if (onCard) clog('⚠ "' + f.name + '" is already on the SD card — this copy will be stored as X001_… next to it; delete the old one to replace it', 'warn'); const nameChk = window.PatternSet && PatternSet.checkSdFilename ? PatternSet.checkSdFilename(f.name) : null; if (nameChk && nameChk.level === 'warn') clog('⚠ ' + f.name + ': ' + nameChk.problems.join('; '), 'warn'); + const playWarn = PS && PS.g6PlaybackWarning ? PS.g6PlaybackWarning(f.bytes, rigArena) : null; + if (playWarn) clog('⚠ ' + f.name + ': ' + playWarn, 'warn'); let r = null; try { r = await sdUploadOne(f.name, f.bytes); @@ -7303,7 +7312,7 @@

Import error

} from './js/v3-import.js'; import { getConfig } from './js/arena-configs.js'; // side effect: sets window.PANEL_SPECS import PatParser from './js/pat-parser.js'; -import { generatePatternIcon } from './js/icon-generator.js'; +import { generatePatternIcon } from './js/icon-generator.js?v=1006-partial-cols'; import * as YAML from 'yaml'; // vendored build has NAMED exports only (no default) const Studio = window.Studio; @@ -7998,7 +8007,7 @@

Import error

} // The card stores pattern files as `NNN_.pat` — the zero-padded index // pins the firmware's SD-scan order (== pattern_ID) and the >8.3 name forces a -// FAT long-filename entry (js/pattern-set.js:242). Protocols, though, reference +// FAT long-filename entry (js/pattern-set.js assignIndices). Protocols, though, reference // the LOGICAL name (`all_on`). Normalize a raw SD filename to that logical // name for name↔SD reconciliation: drop a leading index prefix and the .pat // extension. Robust to one card holding several protocols' sets (each set's @@ -9662,6 +9671,13 @@

Import error

// arena's azimuth pixels (num_cols × pixels_per_panel — 200 on a 10-column G6 ⇒ 1.8). A // PATTERN property really (1 px per frame), which every shipped closed-loop pattern has; a // startClosedLoop `deg_per_frame` overrides it for exotic patterns. NOT 360 / frame_count. +// The session rig's config.arena ({generation, num_rows, num_cols, columns_installed, …}) or +// null — for classic-shell code that has no getConfig (the SD upload's G6 playback warning). +Studio.rigArena = function () { + const name = Studio.currentRig && Studio.currentRig.arenaConfig; + const cfg = name ? getConfig(name) : null; + return (cfg && cfg.arena) || null; +}; Studio.rigDegPerFrame = function () { const name = Studio.currentRig && Studio.currentRig.arenaConfig; const cfg = name ? getConfig(name) : null; diff --git a/experiment_designer_v3.html b/experiment_designer_v3.html index 98a8d8d2..5fd3c395 100644 --- a/experiment_designer_v3.html +++ b/experiment_designer_v3.html @@ -1621,7 +1621,7 @@

Import error

arena-configs.js, and icon-generator.js have bare exports, so the module below imports those. JSZip (CDN, pinned) powers the bundle ZIP export. --> - + @@ -1705,7 +1705,7 @@

Import error

// LAB-96 — animated pattern preview: one cylindrical icon per sampled frame. // arena-configs (imported above) sets window.PANEL_SPECS, which icon-generator // reads at call time. - import { generatePatternIcon } from './js/icon-generator.js'; + import { generatePatternIcon } from './js/icon-generator.js?v=1006-partial-cols'; // ═══════════════════════════════════════════════════════════════ // State diff --git a/icon_generator.html b/icon_generator.html index f7f0387f..fe7413a8 100644 --- a/icon_generator.html +++ b/icon_generator.html @@ -10,7 +10,7 @@ // Cache-busting: v4 adds GIF generation support import { STANDARD_CONFIGS, PANEL_SPECS, getConfig, getConfigsByGeneration } from './js/arena-configs.js?v=4'; import PatParser from './js/pat-parser.js?v=4'; - import { generatePatternIcon, generateMotionIcon, generatePatternGIF } from './js/icon-generator.js?v=4'; + import { generatePatternIcon, generateMotionIcon, generatePatternGIF } from './js/icon-generator.js?v=1006-partial-cols'; // Make available globally for inline script window.STANDARD_CONFIGS = STANDARD_CONFIGS; diff --git a/js/pattern-set.js b/js/pattern-set.js index cb66e8c4..05c635e8 100644 --- a/js/pattern-set.js +++ b/js/pattern-set.js @@ -428,11 +428,16 @@ // The controller keeps pattern names in a 64-byte buffer (incl. NUL): at most 63 chars, // and SET_PATTERN_FILENAME 0x83 refuses longer. A same-name re-upload is stored as // X001_ (5 more chars, truncated to 63) — past 58 that cuts off '.pat' and the file - // drops out of the listing. Names travel as one byte per char, so only plain ASCII - // filename characters are safe. Firmware: SdManager.cpp renamePattern / scan. + // drops out of the listing. Names travel as one byte per char, so the name must be + // printable ASCII; FAT long names also forbid \ / : * ? " < > |. Spaces and parentheses + // are legal (and common: a browser's "… (1).pat" download). The Designer still writes + // tidy names ([A-Za-z0-9._-], SD_TIDY_CHARS) — that is style, not a controller rule. + // Firmware: SdManager.cpp renamePattern / scan. var SD_NAME_MAX = 63; var SD_NAME_SAFE = 58; - var SD_NAME_CHARS = /^[A-Za-z0-9._-]+$/; + var SD_NAME_CHARS = /^[\x20-\x7e]+$/; + var SD_NAME_FORBIDDEN = /[\\/:*?"<>|]/; + var SD_TIDY_CHARS = /^[A-Za-z0-9._-]+$/; /** * Check a pattern filename (with .pat) against the controller's SD rules. @@ -444,7 +449,8 @@ var errors = []; var warnings = []; if (!/\.pat$/i.test(n)) errors.push('must end in .pat'); - if (!SD_NAME_CHARS.test(n)) errors.push('use only letters, digits, "_", "-" and "."'); + if (!SD_NAME_CHARS.test(n)) errors.push('use plain ASCII characters only'); + if (SD_NAME_FORBIDDEN.test(n)) errors.push('must not contain \\ / : * ? " < > |'); if (n.charAt(0) === '.') errors.push('must not start with "."'); if (n.length > SD_NAME_MAX) { errors.push(n.length + ' characters; the controller allows ' + SD_NAME_MAX); @@ -491,6 +497,72 @@ return sanitizeSdFilename(p + m + sfx); } + // ---- G6 playback on the session rig ---------------------------------------------- + // Current arena firmware compiles in one dense panel grid and ignores the G6 panel mask, + // so a partial-arena pattern (or any grid that differs from the controller's) is rejected + // at load (CE_ARENA_MISMATCH, "TRIAL_PARAMS: load failed"). Warn — never block. + var PARTIAL_G6_NOTE = + "current arena firmware can't play partial-arena G6 patterns yet; the controller " + + 'rejects them when loading. Until it can, use a full-grid pattern with the missing ' + + 'columns left blank.'; + + /** {rows, cols, maskPanels} from a G6 .pat header, or null when it isn't one. */ + function g6HeaderGrid(bytes) { + var u8; + try { + u8 = new Uint8Array(toArrayBuffer(bytes)); + } catch (_) { + return null; + } + // 'G6PT' + if ( + u8.length < 18 || + u8[0] !== 0x47 || + u8[1] !== 0x36 || + u8[2] !== 0x50 || + u8[3] !== 0x54 + ) { + return null; + } + var mask = 0; + for (var i = 11; i <= 16; i++) { + for (var v = u8[i]; v; v >>= 1) mask += v & 1; + } + return { rows: u8[8], cols: u8[9], maskPanels: mask }; + } + + /** True for a G6 arena config with missing columns (G6_2x8of10, G6_3x12of18 …). */ + function isPartialG6Arena(arena) { + return !!(arena && arena.generation === 'G6' && arena.columns_installed); + } + + /** + * Why a G6 .pat won't play, judged from its own header and (when it is a FULL G6 arena) + * the session rig's config.arena; null when nothing is wrong or it isn't a G6 pattern. + * A partial session rig is reported once per batch by the caller (isPartialG6Arena). + */ + function g6PlaybackWarning(bytes, arena) { + var g = g6HeaderGrid(bytes); + if (!g) return null; + if (g.maskPanels < g.rows * g.cols) + return 'partial-arena pattern (panel mask): ' + PARTIAL_G6_NOTE; + if (arena && arena.generation === 'G6' && !arena.columns_installed) { + if (g.rows !== arena.num_rows || g.cols !== arena.num_cols) { + return ( + g.rows + + '×' + + g.cols + + ' panels, but the session rig is ' + + arena.num_rows + + '×' + + arena.num_cols + + ' — the controller will reject it when loading' + ); + } + } + return null; + } + var PatternSet = { TOOL: TOOL, MANIFEST_VERSION: MANIFEST_VERSION, @@ -510,6 +582,11 @@ checkSdFilename: checkSdFilename, sanitizeSdFilename: sanitizeSdFilename, fitSdFilename: fitSdFilename, + SD_TIDY_CHARS: SD_TIDY_CHARS, + PARTIAL_G6_NOTE: PARTIAL_G6_NOTE, + g6HeaderGrid: g6HeaderGrid, + isPartialG6Arena: isPartialG6Arena, + g6PlaybackWarning: g6PlaybackWarning, summarizePattern: summarizePattern, validateGeometry: validateGeometry, reencodeForDuty: reencodeForDuty, diff --git a/pattern_editor.html b/pattern_editor.html index 94330d65..e8d7579b 100644 --- a/pattern_editor.html +++ b/pattern_editor.html @@ -36,10 +36,10 @@ (?repo= / ?arena= URL state) — same classic dual-export modules the Studio loads, so behavior matches across the two tools --> - + - +