diff --git a/README.md b/README.md
index c2afa66..a4024cf 100644
--- a/README.md
+++ b/README.md
@@ -1,59 +1,85 @@
-# Easy Battlemap for Foundry VTT
+
-This module allows you to quickly create battlemaps in Foundry VTT by simply dragging and dropping a background image and a JSON file containing wall data.
+
+## Myxelium's Battlemap Importer
+
+Effortlessly turn single images or exported map data into ready‑to‑play Foundry VTT scenes. Drop an image or video for the background, optionally drop a JSON export with walls and lights, and create a complete scene in seconds.
+
+### What it is
+
+Quick Battlemap Importer is a Foundry VTT module that adds a simple "Quick import" button to the Scenes sidebar. It opens a window where you can drag and drop a background image or video and, if you have it, a JSON configuration file. The module uploads the media, applies grid settings, creates walls, lights and doors etc, and builds a new scene for you automatically.
+
+### Why it exists
+
+Setting up scenes manually can be slow: uploading backgrounds, measuring grid size, placing walls, and configuring lights. This module removes repetitive steps so you can spend more time playing and less time navigating configuration windows.
+
+
+### Inside foundry
+
+
+
+
+
+### The problem it solves
+
+- Converts a plain map image into a playable scene with minimal effort
+- It attempts to apply grid settings automatically from submitted image
+- Imports walls and lights from common JSON exports (ex, Dungeon Alchemist)
+- Saves time for game masters managing many scenes
+
+## Key features
+
+- Drag-and-drop panel for images, videos, and JSON configuration files
+- Automatic grid detection for images when no JSON is provided
+- Imports walls and ambient lights from supported JSON
+- Creates and activates a new scene with the uploaded background
+- Optional "No grid" toggle for gridless maps
+- GM-only quick access button in the Scenes directory
+
+## Compatibility
+
+- Foundry VTT compatibility: minimum 10, verified 12
## Installation
-1. In the Foundry VTT setup screen, go to "Add-on Modules" tab
+1. Open Foundry VTT and go to the "Add-on Modules" tab
2. Click "Install Module"
-3. Paste the following URL in the "Manifest URL" field:
- `https://github.com/MyxeliumI/easy-battlemap/releases/latest/download/module.json`
+3. Paste this Manifest URL:
+ https://github.com/Myxelium/FoundryVTT-Quick-Import/releases/latest/download/module.json
4. Click "Install"
+Manual download (optional):
+
+- Download ZIP: https://github.com/Myxelium/FoundryVTT-Quick-Import/releases/latest/download/quick-battlemap-importer.zip
+
## Usage
-1. Enable the module in your game world
-2. Navigate to the "Scenes" tab
-3. You'll see a new "Easy Battlemap Creator" panel
-4. Drag and drop your background image (jpg, png)
-5. Drag and drop your JSON file with wall data
-6. Once both files are loaded, click "Create Battlemap Scene"
+1. Enable the module in your world
+2. Open the Scenes sidebar
+3. Click the "Quick import" button (GM only)
+4. In the panel, drag and drop one of the following:
+ - Background image (png, jpg, jpeg) or video (webm, mp4)
+ - Optional JSON export with walls and lights (for example, from tools like Dungeon Alchemist or compatible Foundry exports)
+5. Optionally enable "No grid" if the map is gridless
+6. Click "Create Scene"
-## JSON Format
+The module uploads the media to your world, applies grid settings (auto-detected if no JSON was supplied), and creates walls and lights when present.
-The JSON file should contain wall data in the following format:
+## Supported inputs
-```json
-{
- "walls": [
- {
- "c": [x1, y1, x2, y2],
- "door": 0,
- "move": 0,
- "sense": 0,
- "dir": 0,
- "ds": 0,
- "flags": {}
- },
- // ... more walls
- ],
- "lights": [
- // optional light data
- ],
- "notes": [
- // optional note data
- ],
- "tokens": [
- // optional token data
- ],
- "drawings": [
- // optional drawing data
- ]
-}
-```
+- Media: PNG, JPG/JPEG, WEBM, MP4
+- JSON configuration: walls and ambient lights in common Foundry-compatible formats; many Dungeon Alchemist exports should work out of the box
-For the specific format details, refer to the [Foundry VTT REST API Relay documentation](https://github.com/ThreeHats/foundryvtt-rest-api-relay/wiki/create-POST#request-payload).
+## Notes and limitations
+
+- Grid auto-detection runs for images when no JSON is provided and may not succeed on all artwork
+- Auto-detection is intentionally skipped for videos
+- You can always create a scene with only a background; adjust grid later if needed
+
+## Credits
+
+- Author: Myxelium (https://github.com/Myxelium)
## License
-This module is licensed under the MIT License.
\ No newline at end of file
+MIT
diff --git a/languages/en.json b/languages/en.json
index a7db2db..562e722 100644
--- a/languages/en.json
+++ b/languages/en.json
@@ -1,16 +1,28 @@
{
- "EASYBATTLEMAP": {
- "Ready": "Easy Battlemap module is ready",
- "DropAreaTitle": "Easy Battlemap Creator",
- "DropInstructions": "Drop a background image and a JSON file with wall data here",
- "BackgroundStatus": "Background Image",
- "WallDataStatus": "Wall Data",
- "CreateScene": "Create Battlemap Scene",
- "MissingFiles": "Both background image and wall data JSON are required",
- "CreatingScene": "Creating battlemap scene...",
- "SceneCreated": "Battlemap scene created",
- "UploadFailed": "Failed to upload background image",
- "SceneCreationFailed": "Failed to create scene",
- "InvalidJSON": "The JSON file could not be parsed"
+ "QUICKBATTLEMAP": {
+ "Ready": "Quick Battlemap module is ready",
+ "DropAreaTitle": "Quick Battlemap Importer",
+ "DropInstructions": "Drop a background image or video, and optionally a JSON file with walls",
+ "DropInstructionsMore": "If no JSON is provided, the module can try to auto-detect grid size from the image. You can also choose to create a scene with no grid.",
+ "BackgroundStatus": "Background Media",
+ "WallDataStatus": "Wall Data",
+ "CreateScene": "Create Battlemap Scene",
+ "MissingFiles": "A background image or video is required",
+ "CreatingScene": "Creating battlemap scene...",
+ "SceneCreated": "Battlemap scene created",
+ "UploadFailed": "Failed to upload background media",
+ "SceneCreationFailed": "Failed to create scene",
+ "InvalidJSON": "The JSON file could not be parsed",
+ "DefaultSceneName": "New Battlemap",
+ "ControlTitle": "Quick Battlemap Importer",
+ "Options": "Options",
+ "NoGridLabel": "Create scene with no grid",
+ "NoGridHint": "Skips applying or detecting a grid. Useful for maps without visible grid lines.",
+ "ProgressLabel": "Progress",
+ "ProgressIdle": "Waiting for files...",
+ "ProgressAnalyzing": "Analyzing image to auto-detect grid...",
+ "ProgressUploading": "Uploading background media...",
+ "ProgressNote": "Shows ongoing tasks like uploading and auto-detecting grid size.",
+ "Reset": "Reset"
}
}
\ No newline at end of file
diff --git a/module.json b/module.json
index 31db47b..3b23f50 100644
--- a/module.json
+++ b/module.json
@@ -1,11 +1,11 @@
{
- "id": "easy-battlemap",
- "title": "Easy Battlemap",
- "description": "Create battlemaps by simply dragging in a background image and wall data JSON file",
- "version": "1.0.0",
+ "id": "quick-battlemap-importer",
+ "title": "Quick Battlemap Importer",
+ "description": "Import battlemaps by simply dragging in a background image and wall/light data JSON file",
+ "version": "1.4.2",
"compatibility": {
"minimum": "10",
- "verified": "11"
+ "verified": "12"
},
"authors": [
{
@@ -13,8 +13,8 @@
"url": "https://github.com/Myxelium"
}
],
- "esmodules": ["scripts/easy-battlemap.js"],
- "styles": ["styles/easy-battlemap.css"],
+ "esmodules": ["scripts/quick-battlemap.js"],
+ "styles": ["styles/quick-battlemap.css"],
"languages": [
{
"lang": "en",
@@ -22,7 +22,7 @@
"path": "languages/en.json"
}
],
- "url": "https://github.com/Myxelium/test-module",
- "manifest": "https://github.com/Myxelium/test-module/releases/latest/download/module.json",
- "download": "https://github.com/Myxelium/test-module/releases/latest/download/easy-battlemap.zip"
+ "url": "https://github.com/Myxelium/FoundryVTT-Quick-Import",
+ "manifest": "https://github.com/Myxelium/FoundryVTT-Quick-Import/releases/latest/download/module.json",
+ "download": "https://github.com/Myxelium/FoundryVTT-Quick-Import/releases/latest/download/quick-battlemap-importer.zip"
}
diff --git a/scripts/easy-battlemap.js b/scripts/easy-battlemap.js
deleted file mode 100644
index bcf1587..0000000
--- a/scripts/easy-battlemap.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { EasyBattlemapDropHandler } from './lib/drop-handler.js';
-
-Hooks.once('init', async function() {
- console.log('Easy Battlemap | Initializing Easy Battlemap');
-});
-
-Hooks.once('ready', async function() {
- console.log('Easy Battlemap | Ready');
-
- // Register the drop handler when Foundry is ready
- new EasyBattlemapDropHandler().registerDropHandler();
-
- ui.notifications.info(game.i18n.localize("EASYBATTLEMAP.Ready"));
-});
\ No newline at end of file
diff --git a/scripts/lib/drop-handler.js b/scripts/lib/drop-handler.js
deleted file mode 100644
index 195d433..0000000
--- a/scripts/lib/drop-handler.js
+++ /dev/null
@@ -1,253 +0,0 @@
-export class EasyBattlemapDropHandler {
- constructor() {
- this.backgroundImage = null;
- this.wallData = null;
- this.processingQueue = {};
- }
-
- registerDropHandler() {
- // Add the drop area to the sidebar
- this._addDropArea();
-
- // Register the drop handler on the canvas
- this._registerCanvasDropHandler();
-
- // Register the drop handler for the custom drop area
- this._registerDropAreaHandler();
- }
-
- _addDropArea() {
- const dropAreaHTML = `
-
-
-
-
- ${game.i18n.localize("EASYBATTLEMAP.DropInstructions")}
-
-
-
- ${game.i18n.localize("EASYBATTLEMAP.BackgroundStatus")}: ❌
-
-
- ${game.i18n.localize("EASYBATTLEMAP.WallDataStatus")}: ❌
-
-
-
- ${game.i18n.localize("EASYBATTLEMAP.CreateScene")}
-
-
-
- `;
-
- // Add to sidebar
- const sidebarTab = document.getElementById('scenes');
- if (sidebarTab) {
- sidebarTab.insertAdjacentHTML('afterend', dropAreaHTML);
-
- // Add click handler for the create button
- document.querySelector('.create-scene-button').addEventListener('click', () => {
- this._createScene();
- });
- }
- }
-
- _registerCanvasDropHandler() {
- // Handle drops on the canvas
- canvas.stage.on('drop', (event) => {
- this._handleDrop(event.data.originalEvent);
- });
- }
-
- _registerDropAreaHandler() {
- const dropArea = document.getElementById('easy-battlemap-drop-area');
- if (!dropArea) return;
-
- // Add event listeners for drag and drop
- ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
- dropArea.addEventListener(eventName, (e) => {
- e.preventDefault();
- e.stopPropagation();
- }, false);
- });
-
- // Handle drag enter/over
- ['dragenter', 'dragover'].forEach(eventName => {
- dropArea.addEventListener(eventName, () => {
- dropArea.classList.add('highlight');
- }, false);
- });
-
- // Handle drag leave/drop
- ['dragleave', 'drop'].forEach(eventName => {
- dropArea.addEventListener(eventName, () => {
- dropArea.classList.remove('highlight');
- }, false);
- });
-
- // Handle drop
- dropArea.addEventListener('drop', (e) => {
- this._handleDrop(e);
- }, false);
- }
-
- _handleDrop(event) {
- const files = event.dataTransfer.files;
-
- for (let i = 0; i < files.length; i++) {
- const file = files[i];
-
- if (file.type.match('image.*')) {
- this._processImageFile(file);
- } else if (file.name.endsWith('.json')) {
- this._processJSONFile(file);
- }
- }
- }
-
- _processImageFile(file) {
- const reader = new FileReader();
-
- reader.onload = (e) => {
- this.backgroundImage = {
- data: e.target.result,
- filename: file.name
- };
-
- // Update the UI to show the background is ready
- document.querySelector('.background-status .status-value').textContent = '✅';
- document.querySelector('.background-status .status-value').title = file.name;
-
- this._checkReadyToCreate();
- };
-
- reader.readAsDataURL(file);
- }
-
- _processJSONFile(file) {
- const reader = new FileReader();
-
- reader.onload = (e) => {
- try {
- const jsonData = JSON.parse(e.target.result);
- this.wallData = jsonData;
-
- // Update the UI to show the wall data is ready
- document.querySelector('.wall-data-status .status-value').textContent = '✅';
- document.querySelector('.wall-data-status .status-value').title = file.name;
-
- this._checkReadyToCreate();
- } catch (error) {
- ui.notifications.error(game.i18n.localize("EASYBATTLEMAP.InvalidJSON"));
- }
- };
-
- reader.readAsText(file);
- }
-
- _checkReadyToCreate() {
- const createButton = document.querySelector('.create-scene-button');
- if (this.backgroundImage && this.wallData) {
- createButton.disabled = false;
- } else {
- createButton.disabled = true;
- }
- }
-
- async _createScene() {
- if (!this.backgroundImage || !this.wallData) {
- ui.notifications.error(game.i18n.localize("EASYBATTLEMAP.MissingFiles"));
- return;
- }
-
- try {
- ui.notifications.info(game.i18n.localize("EASYBATTLEMAP.CreatingScene"));
-
- // First, we need to upload the background image to the server
- const uploadResponse = await this._uploadImage(this.backgroundImage);
-
- if (!uploadResponse.path) {
- ui.notifications.error(game.i18n.localize("EASYBATTLEMAP.UploadFailed"));
- return;
- }
-
- // Extract scene name from the image filename
- const sceneName = this.backgroundImage.filename.split('.').slice(0, -1).join('.');
-
- // Get image dimensions
- const img = new Image();
- img.src = this.backgroundImage.data;
-
- await new Promise(resolve => {
- img.onload = resolve;
- });
-
- // Create the scene
- const sceneData = {
- name: sceneName,
- img: uploadResponse.path,
- width: img.width,
- height: img.height,
- padding: 0,
- backgroundColor: "#000000",
- grid: {
- type: 1, // Square grid
- size: 100,
- color: "#000000",
- alpha: 0.2
- },
- walls: this.wallData.walls || [],
- lights: this.wallData.lights || [],
- notes: this.wallData.notes || [],
- tokens: this.wallData.tokens || [],
- drawings: this.wallData.drawings || []
- };
-
- const scene = await Scene.create(sceneData);
-
- // Reset the data after creating the scene
- this.backgroundImage = null;
- this.wallData = null;
-
- // Reset the UI
- document.querySelector('.background-status .status-value').textContent = '❌';
- document.querySelector('.wall-data-status .status-value').textContent = '❌';
- document.querySelector('.create-scene-button').disabled = true;
-
- ui.notifications.success(`${game.i18n.localize("EASYBATTLEMAP.SceneCreated")}: ${sceneName}`);
-
- // Activate the scene
- await scene.activate();
-
- } catch (error) {
- console.error(error);
- ui.notifications.error(game.i18n.localize("EASYBATTLEMAP.SceneCreationFailed"));
- }
- }
-
- async _uploadImage(imageData) {
- const formData = new FormData();
-
- // Convert base64 to a blob
- const base64Response = await fetch(imageData.data);
- const blob = await base64Response.blob();
-
- formData.append('file', blob, imageData.filename);
- formData.append('path', 'scenes');
-
- const response = await fetch('/upload', {
- method: 'POST',
- headers: {
- 'Authorization': `Bearer ${game.data.session}`
- },
- body: formData
- });
-
- if (!response.ok) {
- throw new Error('Upload failed');
- }
-
- return await response.json();
- }
-}
\ No newline at end of file
diff --git a/scripts/lib/file-processor.js b/scripts/lib/file-processor.js
new file mode 100644
index 0000000..a4e6051
--- /dev/null
+++ b/scripts/lib/file-processor.js
@@ -0,0 +1,285 @@
+/**
+ * File Processor
+ *
+ * Handles processing of dropped files (images, videos, and JSON configs).
+ * Extracts file reading and type detection logic for cleaner separation of concerns.
+ *
+ * @module FileProcessor
+ */
+
+/** Module identifier for console logging */
+const MODULE_LOG_PREFIX = 'Quick Battlemap Importer';
+
+/**
+ * @typedef {Object} ProcessedImageData
+ * @property {string} dataUrl - Base64 data URL of the image
+ * @property {string} filename - Original filename
+ * @property {File} file - The original File object
+ * @property {boolean} isVideo - Always false for images
+ */
+
+/**
+ * @typedef {Object} ProcessedVideoData
+ * @property {string} blobUrl - Blob URL for the video
+ * @property {string} filename - Original filename
+ * @property {File} file - The original File object
+ * @property {boolean} isVideo - Always true for videos
+ */
+
+/**
+ * @typedef {Object} ProcessedJsonData
+ * @property {Object} parsedContent - The parsed JSON object
+ * @property {string} filename - Original filename
+ */
+
+/** Supported image file extensions */
+const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'];
+
+/** Supported video file extensions */
+const VIDEO_EXTENSIONS = ['.webm', '.mp4', '.mov', '.m4v', '.avi', '.mkv', '.ogv', '.ogg'];
+
+/**
+ * Service class for processing dropped files.
+ * Handles reading files and determining their types.
+ */
+export class FileProcessor {
+
+ /**
+ * Process a dropped image file and return its data.
+ * Reads the file as a base64 data URL.
+ *
+ * @param {File} imageFile - The image file to process
+ * @returns {Promise} Processed image data
+ *
+ * @example
+ * const processor = new FileProcessor();
+ * const imageData = await processor.processImageFile(droppedFile);
+ * // imageData.dataUrl contains the base64 image
+ */
+ processImageFile(imageFile) {
+ return new Promise((resolve, reject) => {
+ const fileReader = new FileReader();
+
+ fileReader.onload = (loadEvent) => {
+ resolve({
+ dataUrl: loadEvent.target.result,
+ filename: imageFile.name,
+ file: imageFile,
+ isVideo: false
+ });
+ };
+
+ fileReader.onerror = (error) => {
+ reject(new Error(`Failed to read image file: ${error.message}`));
+ };
+
+ fileReader.readAsDataURL(imageFile);
+ });
+ }
+
+ /**
+ * Process a dropped video file and return its data.
+ * Creates a blob URL for the video.
+ *
+ * @param {File} videoFile - The video file to process
+ * @returns {ProcessedVideoData} Processed video data
+ *
+ * @example
+ * const processor = new FileProcessor();
+ * const videoData = processor.processVideoFile(droppedFile);
+ * // videoData.blobUrl can be used as video src
+ */
+ processVideoFile(videoFile) {
+ const blobUrl = URL.createObjectURL(videoFile);
+
+ return {
+ blobUrl: blobUrl,
+ filename: videoFile.name,
+ file: videoFile,
+ isVideo: true
+ };
+ }
+
+ /**
+ * Process a dropped JSON configuration file.
+ * Reads and parses the JSON content.
+ *
+ * @param {File} jsonFile - The JSON file to process
+ * @returns {Promise} Processed JSON data
+ * @throws {Error} If the JSON is invalid
+ *
+ * @example
+ * const processor = new FileProcessor();
+ * const config = await processor.processJsonFile(droppedFile);
+ * // config.parsedContent contains the parsed object
+ */
+ processJsonFile(jsonFile) {
+ return new Promise((resolve, reject) => {
+ const fileReader = new FileReader();
+
+ fileReader.onload = (loadEvent) => {
+ try {
+ const parsedContent = JSON.parse(loadEvent.target.result);
+ resolve({
+ parsedContent: parsedContent,
+ filename: jsonFile.name
+ });
+ } catch (parseError) {
+ reject(new Error(`Invalid JSON: ${parseError.message}`));
+ }
+ };
+
+ fileReader.onerror = (error) => {
+ reject(new Error(`Failed to read JSON file: ${error.message}`));
+ };
+
+ fileReader.readAsText(jsonFile);
+ });
+ }
+
+ /**
+ * Determine the type of a file based on MIME type and extension.
+ *
+ * @param {File} file - The file to classify
+ * @returns {'image' | 'video' | 'json' | 'unknown'} The file type category
+ *
+ * @example
+ * const fileType = processor.getFileType(droppedFile);
+ * if (fileType === 'image') { ... }
+ */
+ getFileType(file) {
+ const lowercaseFilename = file.name.toLowerCase();
+ const mimeType = file.type.toLowerCase();
+
+ // Check by MIME type first
+ if (mimeType.startsWith('image/')) {
+ return 'image';
+ }
+
+ if (mimeType.startsWith('video/')) {
+ return 'video';
+ }
+
+ if (mimeType === 'application/json') {
+ return 'json';
+ }
+
+ // Fall back to extension checking
+ if (this.hasExtension(lowercaseFilename, IMAGE_EXTENSIONS)) {
+ return 'image';
+ }
+
+ if (this.hasExtension(lowercaseFilename, VIDEO_EXTENSIONS)) {
+ return 'video';
+ }
+
+ if (lowercaseFilename.endsWith('.json')) {
+ return 'json';
+ }
+
+ return 'unknown';
+ }
+
+ /**
+ * Check if a filename has one of the specified extensions.
+ *
+ * @param {string} filename - The filename to check (lowercase)
+ * @param {string[]} extensions - Array of extensions to match
+ * @returns {boolean} True if filename ends with one of the extensions
+ */
+ hasExtension(filename, extensions) {
+ return extensions.some(ext => filename.endsWith(ext));
+ }
+
+ /**
+ * Revoke a blob URL to free memory.
+ * Safe to call with non-blob URLs (will be ignored).
+ *
+ * @param {string} url - The URL to potentially revoke
+ */
+ revokeBlobUrl(url) {
+ try {
+ if (url && typeof url === 'string' && url.startsWith('blob:')) {
+ URL.revokeObjectURL(url);
+ }
+ } catch (_error) {
+ // Ignore errors during cleanup
+ }
+ }
+
+ /**
+ * Get the dimensions of an image from a data URL.
+ *
+ * @param {string} imageDataUrl - Base64 data URL of the image
+ * @returns {Promise<{width: number, height: number}>} Image dimensions
+ */
+ async getImageDimensions(imageDataUrl) {
+ return new Promise((resolve, reject) => {
+ const imageElement = new Image();
+
+ imageElement.onload = () => {
+ resolve({
+ width: imageElement.width,
+ height: imageElement.height
+ });
+ };
+
+ imageElement.onerror = () => {
+ reject(new Error('Failed to load image for dimension measurement'));
+ };
+
+ imageElement.src = imageDataUrl;
+ });
+ }
+
+ /**
+ * Get the dimensions of a video from a URL.
+ *
+ * @param {string} videoUrl - URL or blob URL of the video
+ * @returns {Promise<{width: number|undefined, height: number|undefined}>} Video dimensions
+ */
+ async getVideoDimensions(videoUrl) {
+ return new Promise((resolve) => {
+ const videoElement = document.createElement('video');
+ videoElement.preload = 'metadata';
+
+ const cleanup = () => {
+ videoElement.onloadedmetadata = null;
+ videoElement.onerror = null;
+ };
+
+ videoElement.onloadedmetadata = () => {
+ cleanup();
+ resolve({
+ width: videoElement.videoWidth || undefined,
+ height: videoElement.videoHeight || undefined
+ });
+ };
+
+ videoElement.onerror = () => {
+ cleanup();
+ resolve({ width: undefined, height: undefined });
+ };
+
+ videoElement.src = videoUrl;
+ });
+ }
+
+ /**
+ * Get dimensions of media (either image or video).
+ *
+ * @param {Object} mediaData - Media data object with data/blobUrl and isVideo flag
+ * @returns {Promise<{width: number|undefined, height: number|undefined}>} Media dimensions
+ */
+ async getMediaDimensions(mediaData) {
+ try {
+ if (mediaData?.isVideo) {
+ return await this.getVideoDimensions(mediaData.data || mediaData.blobUrl);
+ }
+ return await this.getImageDimensions(mediaData?.data || mediaData?.dataUrl);
+ } catch (error) {
+ console.warn(`${MODULE_LOG_PREFIX} | Could not read media dimensions:`, error);
+ return { width: undefined, height: undefined };
+ }
+ }
+}
diff --git a/scripts/lib/grid-detection-service.js b/scripts/lib/grid-detection-service.js
new file mode 100644
index 0000000..83fcade
--- /dev/null
+++ b/scripts/lib/grid-detection-service.js
@@ -0,0 +1,300 @@
+/**
+ * Grid Detection Service
+ *
+ * Provides automatic grid detection for battlemap images using signal processing.
+ * Analyzes edge patterns in images to detect periodic grid lines and calculate
+ * grid size and offset values.
+ *
+ * The algorithm works by:
+ * 1. Scaling the image for processing efficiency
+ * 2. Converting to grayscale and detecting edges using Sobel operators
+ * 3. Projecting edges onto X and Y axes
+ * 4. Applying high-pass filter to emphasize periodic patterns
+ * 5. Using autocorrelation to find the dominant period (grid size)
+ * 6. Estimating offset to align grid with detected lines
+ *
+ * @module GridDetectionService
+ */
+
+import {
+ computeAutocorrelation,
+ applyHighPassFilter,
+ normalizeSignal,
+ findBestPeriodFromAutocorrelation,
+ combinePeriodCandidates,
+ estimateGridOffset,
+ clampValue
+} from './signal-processing-utils.js';
+
+/** Maximum dimension for image processing (larger images are scaled down) */
+const MAX_PROCESSING_DIMENSION = 1600;
+
+/** Minimum valid grid period to filter out noise */
+const MIN_VALID_PERIOD = 6;
+
+/**
+ * @typedef {Object} GridDetectionResult
+ * @property {number} gridSize - Detected grid cell size in pixels (in original image coordinates)
+ * @property {number} xOffset - Horizontal offset for grid alignment
+ * @property {number} yOffset - Vertical offset for grid alignment
+ */
+
+/**
+ * Service class for detecting grid patterns in battlemap images.
+ * Uses signal processing techniques to find periodic grid lines.
+ */
+export class GridDetectionService {
+
+ /**
+ * Detect grid settings from an image file.
+ * Analyzes the image for periodic patterns that indicate grid lines.
+ *
+ * @param {File} imageFile - The image file to analyze
+ * @param {Array<{x: number, y: number}>} [manualPoints] - Optional manual grid points for fallback
+ * @returns {Promise} Detected grid settings
+ * @throws {Error} If grid detection fails
+ *
+ * @example
+ * const detector = new GridDetectionService();
+ * try {
+ * const result = await detector.detectGridFromImage(imageFile);
+ * console.log(`Grid size: ${result.gridSize}px`);
+ * } catch (error) {
+ * console.log('Could not detect grid automatically');
+ * }
+ */
+ async detectGridFromImage(imageFile, manualPoints = null) {
+ const imageElement = await this.loadImageFromFile(imageFile);
+ const { scaledCanvas, scaleFactor } = this.createScaledCanvas(imageElement);
+
+ const grayscaleData = this.extractGrayscaleData(scaledCanvas);
+ const edgeMagnitude = this.computeSobelMagnitude(grayscaleData, scaledCanvas.width, scaledCanvas.height);
+ const { projectionX, projectionY } = this.computeEdgeProjections(edgeMagnitude, scaledCanvas.width, scaledCanvas.height);
+
+ const filteredX = this.processProjection(projectionX, scaledCanvas.width);
+ const filteredY = this.processProjection(projectionY, scaledCanvas.height);
+
+ const detectedPeriod = this.detectPeriodFromProjections(filteredX, filteredY, scaledCanvas.width, scaledCanvas.height);
+
+ if (detectedPeriod && Number.isFinite(detectedPeriod) && detectedPeriod >= MIN_VALID_PERIOD) {
+ return this.buildDetectionResult(detectedPeriod, filteredX, filteredY, scaleFactor);
+ }
+
+ if (manualPoints && manualPoints.length >= 2) {
+ return this.detectFromManualPoints(manualPoints);
+ }
+
+ throw new Error('Grid detection failed; insufficient periodic signal.');
+ }
+
+ /**
+ * Load an image from a File object into an HTMLImageElement.
+ *
+ * @param {File} file - The image file to load
+ * @returns {Promise} The loaded image element
+ */
+ loadImageFromFile(file) {
+ return new Promise((resolve, reject) => {
+ const imageElement = new Image();
+ const objectUrl = URL.createObjectURL(file);
+
+ imageElement.onload = () => {
+ URL.revokeObjectURL(objectUrl);
+ resolve(imageElement);
+ };
+
+ imageElement.onerror = (error) => {
+ URL.revokeObjectURL(objectUrl);
+ reject(error);
+ };
+
+ imageElement.src = objectUrl;
+ });
+ }
+
+ /**
+ * Create a scaled canvas for processing. Large images are scaled down for performance.
+ *
+ * @param {HTMLImageElement} image - The source image
+ * @returns {{scaledCanvas: HTMLCanvasElement, scaleFactor: number}} Canvas and scale info
+ */
+ createScaledCanvas(image) {
+ const scaleFactor = Math.min(1, MAX_PROCESSING_DIMENSION / Math.max(image.width, image.height));
+ const scaledWidth = Math.max(1, Math.round(image.width * scaleFactor));
+ const scaledHeight = Math.max(1, Math.round(image.height * scaleFactor));
+
+ const canvas = document.createElement('canvas');
+ canvas.width = scaledWidth;
+ canvas.height = scaledHeight;
+
+ const context = canvas.getContext('2d', { willReadFrequently: true });
+ context.drawImage(image, 0, 0, scaledWidth, scaledHeight);
+
+ return { scaledCanvas: canvas, scaleFactor };
+ }
+
+ /**
+ * Extract grayscale pixel data from a canvas using luminance formula.
+ *
+ * @param {HTMLCanvasElement} canvas - The source canvas
+ * @returns {Float32Array} Grayscale values (0-255)
+ */
+ extractGrayscaleData(canvas) {
+ const context = canvas.getContext('2d', { willReadFrequently: true });
+ const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
+ const rgbaPixels = imageData.data;
+ const pixelCount = canvas.width * canvas.height;
+ const grayscale = new Float32Array(pixelCount);
+
+ for (let pixelIndex = 0, rgbaIndex = 0; pixelIndex < pixelCount; pixelIndex++, rgbaIndex += 4) {
+ const red = rgbaPixels[rgbaIndex];
+ const green = rgbaPixels[rgbaIndex + 1];
+ const blue = rgbaPixels[rgbaIndex + 2];
+ grayscale[pixelIndex] = 0.299 * red + 0.587 * green + 0.114 * blue;
+ }
+
+ return grayscale;
+ }
+
+ /**
+ * Compute edge magnitude using Sobel operators for gradient detection.
+ *
+ * @param {Float32Array} grayscale - Grayscale pixel data
+ * @param {number} width - Image width
+ * @param {number} height - Image height
+ * @returns {Float32Array} Edge magnitude for each pixel
+ */
+ computeSobelMagnitude(grayscale, width, height) {
+ const output = new Float32Array(width * height);
+ const sobelX = [-1, 0, 1, -2, 0, 2, -1, 0, 1];
+ const sobelY = [-1, -2, -1, 0, 0, 0, 1, 2, 1];
+
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ let gradientX = 0, gradientY = 0, kernelIndex = 0;
+
+ for (let kernelY = -1; kernelY <= 1; kernelY++) {
+ const sampleY = clampValue(y + kernelY, 0, height - 1);
+ for (let kernelX = -1; kernelX <= 1; kernelX++) {
+ const sampleX = clampValue(x + kernelX, 0, width - 1);
+ const pixelValue = grayscale[sampleY * width + sampleX];
+ gradientX += pixelValue * sobelX[kernelIndex];
+ gradientY += pixelValue * sobelY[kernelIndex];
+ kernelIndex++;
+ }
+ }
+
+ output[y * width + x] = Math.hypot(gradientX, gradientY);
+ }
+ }
+
+ return output;
+ }
+
+ /**
+ * Compute edge projections onto X and Y axes by accumulating edge intensity.
+ *
+ * @param {Float32Array} edgeMagnitude - Edge magnitude data
+ * @param {number} width - Image width
+ * @param {number} height - Image height
+ * @returns {{projectionX: Float32Array, projectionY: Float32Array}} Axis projections
+ */
+ computeEdgeProjections(edgeMagnitude, width, height) {
+ const projectionX = new Float32Array(width);
+ const projectionY = new Float32Array(height);
+
+ for (let y = 0; y < height; y++) {
+ let rowSum = 0;
+ for (let x = 0; x < width; x++) {
+ const edgeValue = edgeMagnitude[y * width + x];
+ projectionX[x] += edgeValue;
+ rowSum += edgeValue;
+ }
+ projectionY[y] = rowSum;
+ }
+
+ return { projectionX, projectionY };
+ }
+
+ /**
+ * Process a projection signal with high-pass filtering and normalization.
+ *
+ * @param {Float32Array} projection - Raw projection data
+ * @param {number} dimension - Image dimension (width or height)
+ * @returns {Float32Array} Processed and normalized signal
+ */
+ processProjection(projection, dimension) {
+ const windowSize = Math.max(5, Math.floor(dimension / 50));
+ const highPassed = applyHighPassFilter(projection, windowSize);
+ return normalizeSignal(highPassed);
+ }
+
+ /**
+ * Detect the dominant period from X and Y projections using autocorrelation.
+ *
+ * @param {Float32Array} signalX - Normalized X projection
+ * @param {Float32Array} signalY - Normalized Y projection
+ * @param {number} width - Image width
+ * @param {number} height - Image height
+ * @returns {number|null} Detected period or null
+ */
+ detectPeriodFromProjections(signalX, signalY, width, height) {
+ const minLagX = Math.max(8, Math.floor(width / 200));
+ const minLagY = Math.max(8, Math.floor(height / 200));
+ const maxLagX = Math.min(Math.floor(width / 2), 1024);
+ const maxLagY = Math.min(Math.floor(height / 2), 1024);
+
+ const autocorrX = computeAutocorrelation(signalX, minLagX, maxLagX);
+ const autocorrY = computeAutocorrelation(signalY, minLagY, maxLagY);
+
+ const periodX = findBestPeriodFromAutocorrelation(autocorrX);
+ const periodY = findBestPeriodFromAutocorrelation(autocorrY);
+
+ return combinePeriodCandidates(periodX, periodY);
+ }
+
+ /**
+ * Build the final detection result, scaling back to original image coordinates.
+ *
+ * @param {number} period - Detected period in scaled coordinates
+ * @param {Float32Array} signalX - X projection for offset calculation
+ * @param {Float32Array} signalY - Y projection for offset calculation
+ * @param {number} scaleFactor - Scale factor used during processing
+ * @returns {GridDetectionResult} Final grid detection result
+ */
+ buildDetectionResult(period, signalX, signalY, scaleFactor) {
+ const offsetX = estimateGridOffset(signalX, Math.round(period));
+ const offsetY = estimateGridOffset(signalY, Math.round(period));
+ const inverseScale = 1 / scaleFactor;
+
+ return {
+ gridSize: period * inverseScale,
+ xOffset: offsetX * inverseScale,
+ yOffset: offsetY * inverseScale
+ };
+ }
+
+ /**
+ * Detect grid from manually placed points (fallback when auto-detection fails).
+ *
+ * @param {Array<{x: number, y: number}>} points - Array of grid intersection points
+ * @returns {GridDetectionResult} Grid detection result
+ */
+ detectFromManualPoints(points) {
+ const xCoords = points.map(p => p.x);
+ const yCoords = points.map(p => p.y);
+
+ const minX = Math.min(...xCoords), maxX = Math.max(...xCoords);
+ const minY = Math.min(...yCoords), maxY = Math.max(...yCoords);
+
+ const avgSpacingX = (maxX - minX) / (points.length - 1);
+ const avgSpacingY = (maxY - minY) / (points.length - 1);
+ const gridSize = Math.round((avgSpacingX + avgSpacingY) / 2);
+
+ return {
+ gridSize: gridSize,
+ xOffset: minX % gridSize,
+ yOffset: minY % gridSize
+ };
+ }
+}
diff --git a/scripts/lib/import-panel-view.js b/scripts/lib/import-panel-view.js
new file mode 100644
index 0000000..6750dfe
--- /dev/null
+++ b/scripts/lib/import-panel-view.js
@@ -0,0 +1,461 @@
+/**
+ * Import Panel View
+ *
+ * Manages the drag-and-drop UI panel for the battlemap import workflow.
+ * Handles panel creation, visibility, status updates, and user interactions.
+ * This is a pure view component - it only manages DOM and emits events.
+ *
+ * @module ImportPanelView
+ */
+
+/**
+ * @typedef {Object} PanelCallbacks
+ * @property {Function} onCreateSceneRequested - Called when user clicks "Create Scene"
+ * @property {Function} onResetRequested - Called when user clicks "Reset"
+ * @property {Function} onCloseRequested - Called when user clicks close button
+ * @property {Function} onFilesDropped - Called when files are dropped on the panel
+ * @property {Function} onNoGridPreferenceChanged - Called when no-grid checkbox changes
+ */
+
+/** CSS selectors for frequently accessed elements */
+const PANEL_SELECTORS = {
+ PANEL_ROOT: '#quick-battlemap-drop-area',
+ CREATE_BUTTON: '.create-scene-button',
+ RESET_BUTTON: '.reset-button',
+ CLOSE_BUTTON: '.header-button.close',
+ NO_GRID_CHECKBOX: 'input.ebm-no-grid',
+ BACKGROUND_STATUS: '.background-status .status-value',
+ WALL_DATA_STATUS: '.wall-data-status .status-value',
+ PROGRESS_GROUP: '.progress-group',
+ PROGRESS_TEXT: '.ebm-progress-text'
+};
+
+/** LocalStorage key for persisting no-grid preference */
+const NO_GRID_STORAGE_KEY = 'quick-battlemap:no-grid';
+
+/**
+ * View class that manages the import panel DOM and user interactions.
+ * Follows a callback pattern for communicating events to the controller.
+ */
+export class ImportPanelView {
+ constructor() {
+ /** @type {boolean} Whether the panel DOM has been created */
+ this.isPanelCreated = false;
+
+ /** @type {boolean} Whether the panel is currently visible */
+ this.isCurrentlyVisible = false;
+
+ /** @type {boolean} Whether a background operation is in progress */
+ this.isShowingBusyState = false;
+
+ // Event callbacks - set by the controller
+ /** @type {Function|null} */
+ this.onCreateSceneRequested = null;
+ /** @type {Function|null} */
+ this.onResetRequested = null;
+ /** @type {Function|null} */
+ this.onCloseRequested = null;
+ /** @type {Function|null} */
+ this.onFilesDropped = null;
+ /** @type {Function|null} */
+ this.onNoGridPreferenceChanged = null;
+ }
+
+ /**
+ * Ensure the panel DOM structure exists.
+ * Creates the panel if it doesn't exist, removes duplicates if found.
+ */
+ ensureCreated() {
+ if (this.isPanelCreated) {
+ return;
+ }
+
+ // Remove any existing panel to prevent duplicates
+ const existingPanel = document.getElementById('quick-battlemap-drop-area');
+ if (existingPanel) {
+ existingPanel.remove();
+ }
+
+ // Create and insert the panel HTML
+ const panelHtml = this.buildPanelHtml();
+ document.body.insertAdjacentHTML('beforeend', panelHtml);
+
+ // Set up event listeners
+ this.attachButtonEventListeners();
+ this.attachNoGridCheckboxListener();
+ this.attachDragHandlers();
+ this.attachDropZoneHandlers();
+
+ this.isPanelCreated = true;
+ }
+
+ /**
+ * Build the complete HTML structure for the import panel.
+ * Uses Foundry's built-in styles and i18n for localization.
+ *
+ * @returns {string} The complete panel HTML
+ */
+ buildPanelHtml() {
+ const i18n = (key) => game.i18n.localize(key);
+ const closeText = i18n('Close') ?? 'Close';
+
+ return `
+ `;
+ }
+
+ /**
+ * Attach click event listeners to the panel's action buttons.
+ */
+ attachButtonEventListeners() {
+ const panel = this.getPanelElement();
+ if (!panel) return;
+
+ const createButton = panel.querySelector(PANEL_SELECTORS.CREATE_BUTTON);
+ const resetButton = panel.querySelector(PANEL_SELECTORS.RESET_BUTTON);
+ const closeButton = panel.querySelector(PANEL_SELECTORS.CLOSE_BUTTON);
+
+ createButton?.addEventListener('click', () => {
+ this.onCreateSceneRequested?.();
+ });
+
+ resetButton?.addEventListener('click', () => {
+ this.onResetRequested?.();
+ });
+
+ closeButton?.addEventListener('click', () => {
+ this.onCloseRequested?.();
+ });
+ }
+
+ /**
+ * Attach change listener to the no-grid checkbox.
+ * Loads and applies the persisted preference.
+ */
+ attachNoGridCheckboxListener() {
+ const panel = this.getPanelElement();
+ if (!panel) return;
+
+ // Load persisted preference
+ const persistedValue = this.loadNoGridPreference();
+ const checkbox = panel.querySelector(PANEL_SELECTORS.NO_GRID_CHECKBOX);
+
+ if (checkbox) {
+ checkbox.checked = persistedValue;
+
+ checkbox.addEventListener('change', (event) => {
+ const isChecked = !!event.currentTarget.checked;
+ this.saveNoGridPreference(isChecked);
+ this.onNoGridPreferenceChanged?.(isChecked);
+ });
+ }
+ }
+
+ /**
+ * Attach drag event handlers to make the panel header draggable.
+ */
+ attachDragHandlers() {
+ const panel = this.getPanelElement();
+ const header = panel?.querySelector('header');
+
+ if (!panel || !header) return;
+
+ let isDragging = false;
+ let dragStartX = 0;
+ let dragStartY = 0;
+ let panelStartLeft = 0;
+ let panelStartTop = 0;
+
+ const handleMouseMove = (event) => {
+ if (!isDragging) return;
+
+ const deltaX = event.clientX - dragStartX;
+ const deltaY = event.clientY - dragStartY;
+
+ // Keep panel within viewport bounds
+ const newLeft = Math.max(0, panelStartLeft + deltaX);
+ const newTop = Math.max(0, panelStartTop + deltaY);
+
+ panel.style.left = `${newLeft}px`;
+ panel.style.top = `${newTop}px`;
+ };
+
+ const handleMouseUp = () => {
+ isDragging = false;
+ window.removeEventListener('mousemove', handleMouseMove);
+ window.removeEventListener('mouseup', handleMouseUp);
+ };
+
+ header.addEventListener('mousedown', (event) => {
+ // Only respond to left mouse button
+ if (event.button !== 0) return;
+
+ isDragging = true;
+
+ const panelRect = panel.getBoundingClientRect();
+ dragStartX = event.clientX;
+ dragStartY = event.clientY;
+ panelStartLeft = panelRect.left + window.scrollX;
+ panelStartTop = panelRect.top + window.scrollY;
+
+ window.addEventListener('mousemove', handleMouseMove);
+ window.addEventListener('mouseup', handleMouseUp);
+ });
+ }
+
+ /**
+ * Attach drag-and-drop event handlers to the drop zone.
+ */
+ attachDropZoneHandlers() {
+ const dropArea = this.getPanelElement();
+ if (!dropArea) return;
+
+ // Prevent default browser behavior for all drag events
+ const preventDefaults = (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ };
+
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
+ dropArea.addEventListener(eventName, preventDefaults, false);
+ });
+
+ // Add visual highlight during drag
+ ['dragenter', 'dragover'].forEach(eventName => {
+ dropArea.addEventListener(eventName, () => {
+ dropArea.classList.add('highlight');
+ }, false);
+ });
+
+ // Remove highlight when drag ends
+ ['dragleave', 'drop'].forEach(eventName => {
+ dropArea.addEventListener(eventName, () => {
+ dropArea.classList.remove('highlight');
+ }, false);
+ });
+
+ // Forward drop events to callback
+ dropArea.addEventListener('drop', (event) => {
+ this.onFilesDropped?.(event);
+ }, false);
+ }
+
+ /**
+ * Load the no-grid preference from localStorage.
+ * @returns {boolean} Whether no-grid mode is enabled
+ */
+ loadNoGridPreference() {
+ try {
+ return localStorage.getItem(NO_GRID_STORAGE_KEY) === 'true';
+ } catch (_error) {
+ return false;
+ }
+ }
+
+ /**
+ * Save the no-grid preference to localStorage.
+ * @param {boolean} isEnabled - Whether no-grid mode is enabled
+ */
+ saveNoGridPreference(isEnabled) {
+ try {
+ localStorage.setItem(NO_GRID_STORAGE_KEY, String(isEnabled));
+ } catch (_error) {
+ // localStorage may not be available
+ }
+ }
+
+ /**
+ * Get the panel's root DOM element.
+ * @returns {HTMLElement|null} The panel element
+ */
+ getPanelElement() {
+ return document.getElementById('quick-battlemap-drop-area');
+ }
+
+ /**
+ * Show the import panel.
+ */
+ show() {
+ this.ensureCreated();
+ const panel = this.getPanelElement();
+
+ if (panel) {
+ panel.style.display = '';
+ this.isCurrentlyVisible = true;
+ }
+ }
+
+ /**
+ * Hide the import panel.
+ */
+ hide() {
+ const panel = this.getPanelElement();
+
+ if (panel) {
+ panel.style.display = 'none';
+ this.isCurrentlyVisible = false;
+ }
+ }
+
+ /**
+ * Enable or disable the Create Scene button.
+ * @param {boolean} isEnabled - Whether the button should be enabled
+ */
+ setCreateButtonEnabled(isEnabled) {
+ const button = document.querySelector(PANEL_SELECTORS.CREATE_BUTTON);
+ if (button) {
+ button.disabled = !isEnabled;
+ }
+ }
+
+ /**
+ * Update the background media status indicator.
+ * @param {boolean} isLoaded - Whether background media is loaded
+ * @param {string} tooltipText - Tooltip text (usually the filename)
+ */
+ updateBackgroundMediaStatus(isLoaded, tooltipText) {
+ const statusElement = document.querySelector(PANEL_SELECTORS.BACKGROUND_STATUS);
+
+ if (statusElement) {
+ statusElement.textContent = isLoaded ? '✅' : '❌';
+ statusElement.title = tooltipText || '';
+ }
+ }
+
+ /**
+ * Update the wall/grid data status indicator.
+ * @param {boolean} isLoaded - Whether wall data is loaded
+ * @param {string} tooltipText - Tooltip text (usually the filename or "Auto-detected")
+ */
+ updateWallDataStatus(isLoaded, tooltipText) {
+ const statusElement = document.querySelector(PANEL_SELECTORS.WALL_DATA_STATUS);
+
+ if (statusElement) {
+ statusElement.textContent = isLoaded ? '✅' : '❌';
+ statusElement.title = tooltipText || '';
+ }
+ }
+
+ /**
+ * Show the progress/busy indicator with a message.
+ * @param {string} statusMessage - Message to display
+ */
+ showBusyState(statusMessage) {
+ const progressGroup = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_GROUP}`);
+ const progressText = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_TEXT}`);
+
+ if (progressGroup) {
+ progressGroup.style.display = '';
+ }
+
+ if (progressText && statusMessage) {
+ progressText.textContent = statusMessage;
+ }
+
+ this.isShowingBusyState = true;
+ }
+
+ /**
+ * Hide the progress/busy indicator.
+ */
+ clearBusyState() {
+ const progressGroup = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_GROUP}`);
+ const progressText = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_TEXT}`);
+
+ if (progressGroup) {
+ progressGroup.style.display = 'none';
+ }
+
+ if (progressText) {
+ progressText.textContent = game.i18n.localize('QUICKBATTLEMAP.ProgressIdle');
+ }
+
+ this.isShowingBusyState = false;
+ }
+
+ /**
+ * Reset all status indicators to their default (empty) state.
+ * @param {boolean} persistedNoGridValue - Whether to check the no-grid checkbox
+ */
+ resetAllStatuses(persistedNoGridValue) {
+ this.updateBackgroundMediaStatus(false, '');
+ this.updateWallDataStatus(false, '');
+ this.setCreateButtonEnabled(false);
+
+ // Hide and reset progress indicator
+ const progressGroup = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_GROUP}`);
+ const progressText = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_TEXT}`);
+
+ if (progressGroup) {
+ progressGroup.style.display = 'none';
+ }
+
+ if (progressText) {
+ progressText.textContent = game.i18n.localize('QUICKBATTLEMAP.ProgressIdle');
+ }
+
+ // Restore no-grid checkbox to persisted value
+ const noGridCheckbox = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.NO_GRID_CHECKBOX}`);
+ if (noGridCheckbox) {
+ noGridCheckbox.checked = !!persistedNoGridValue;
+ }
+ }
+
+ /**
+ * Get the current state of the no-grid checkbox.
+ * @returns {boolean} Whether no-grid mode is selected
+ */
+ getNoGridCheckboxState() {
+ const checkbox = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.NO_GRID_CHECKBOX}`);
+ return !!checkbox?.checked;
+ }
+
+ /**
+ * Set the state of the no-grid checkbox.
+ * @param {boolean} isChecked - Whether the checkbox should be checked
+ */
+ setNoGridCheckboxState(isChecked) {
+ const checkbox = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.NO_GRID_CHECKBOX}`);
+ if (checkbox) {
+ checkbox.checked = !!isChecked;
+ }
+ }
+}
diff --git a/scripts/lib/media-storage-service.js b/scripts/lib/media-storage-service.js
new file mode 100644
index 0000000..6e99bb1
--- /dev/null
+++ b/scripts/lib/media-storage-service.js
@@ -0,0 +1,172 @@
+/**
+ * Media Storage Service
+ *
+ * Handles uploading background media files (images and videos) to Foundry VTT's
+ * file storage system. Manages directory creation and file organization.
+ *
+ * @module MediaStorageService
+ */
+
+/** Module identifier for console logging */
+const MODULE_LOG_PREFIX = 'Quick Battlemap Importer';
+
+/** Storage source for file operations (Foundry's data directory) */
+const STORAGE_SOURCE = 'data';
+
+/**
+ * @typedef {Object} BackgroundMediaData
+ * @property {string} data - Base64 data URL or blob URL for the media
+ * @property {string} filename - Original filename of the media
+ * @property {File} [file] - The original File object (if available)
+ * @property {boolean} isVideo - Whether the media is a video file
+ */
+
+/**
+ * @typedef {Object} UploadResult
+ * @property {string} path - The path to the uploaded file in Foundry's storage
+ */
+
+/**
+ * Service class responsible for uploading media files to Foundry's storage.
+ * Handles directory creation and file upload with error handling.
+ */
+export class MediaStorageService {
+
+ /**
+ * Upload a background media file (image or video) to Foundry's storage.
+ * Creates the target directory if it doesn't exist.
+ *
+ * @param {BackgroundMediaData} mediaData - The media data to upload
+ * @param {string} worldId - The current world's identifier for directory naming
+ * @returns {Promise} Upload result with file path, or null on failure
+ *
+ * @example
+ * const storage = new MediaStorageService();
+ * const result = await storage.uploadBackgroundMedia(mediaData, game.world.id);
+ * if (result?.path) {
+ * // Use result.path as the scene background
+ * }
+ */
+ async uploadBackgroundMedia(mediaData, worldId) {
+ try {
+ // Get or create a File object from the media data
+ const fileToUpload = await this.prepareFileForUpload(mediaData);
+
+ // Build the target directory path
+ const targetDirectory = this.buildTargetDirectory(worldId);
+
+ // Ensure the directory exists
+ await this.ensureDirectoryExists(STORAGE_SOURCE, targetDirectory);
+
+ // Upload the file
+ const uploadResult = await FilePicker.upload(
+ STORAGE_SOURCE,
+ targetDirectory,
+ fileToUpload,
+ { overwrite: true }
+ );
+
+ return { path: uploadResult?.path };
+
+ } catch (uploadError) {
+ this.handleUploadError(uploadError);
+ return null;
+ }
+ }
+
+ /**
+ * Prepare a File object for upload from media data.
+ * If a File object already exists, uses it directly.
+ * Otherwise, fetches the data URL and converts to a File.
+ *
+ * @param {BackgroundMediaData} mediaData - The media data
+ * @returns {Promise} A File object ready for upload
+ */
+ async prepareFileForUpload(mediaData) {
+ // If we already have a File object, use it directly
+ if (mediaData.file) {
+ return mediaData.file;
+ }
+
+ // Otherwise, fetch the data URL and create a File from it
+ const response = await fetch(mediaData.data);
+ const blobData = await response.blob();
+
+ // Determine MIME type from blob or based on media type
+ const mimeType = blobData.type || this.getDefaultMimeType(mediaData.isVideo);
+
+ return new File([blobData], mediaData.filename, { type: mimeType });
+ }
+
+ /**
+ * Get the default MIME type based on whether the media is video or image.
+ *
+ * @param {boolean} isVideo - Whether the media is a video
+ * @returns {string} The default MIME type
+ */
+ getDefaultMimeType(isVideo) {
+ return isVideo ? 'video/webm' : 'image/png';
+ }
+
+ /**
+ * Build the target directory path for storing battlemap media.
+ * Uses the world ID to organize files by world.
+ *
+ * @param {string} worldId - The world identifier
+ * @returns {string} The target directory path
+ */
+ buildTargetDirectory(worldId) {
+ return `worlds/${worldId}/quick-battlemap`;
+ }
+
+ /**
+ * Ensure a directory exists in Foundry's file storage.
+ * Creates the directory if it doesn't exist, handling race conditions.
+ *
+ * @param {string} storageSource - The storage source (typically 'data')
+ * @param {string} directoryPath - The path to the directory
+ * @throws {Error} If directory creation fails for reasons other than already existing
+ */
+ async ensureDirectoryExists(storageSource, directoryPath) {
+ try {
+ // Try to browse the directory to see if it exists
+ await FilePicker.browse(storageSource, directoryPath);
+ } catch (_browseError) {
+ // Directory doesn't exist, try to create it
+ await this.createDirectorySafely(storageSource, directoryPath);
+ }
+ }
+
+ /**
+ * Safely create a directory, handling the case where it already exists.
+ * Multiple simultaneous requests might try to create the same directory.
+ *
+ * @param {string} storageSource - The storage source
+ * @param {string} directoryPath - The path to create
+ */
+ async createDirectorySafely(storageSource, directoryPath) {
+ try {
+ await FilePicker.createDirectory(storageSource, directoryPath, {});
+ } catch (createError) {
+ // EEXIST means directory was created by another request - that's fine
+ const errorMessage = String(createError || '');
+ if (!errorMessage.includes('EEXIST')) {
+ throw createError;
+ }
+ }
+ }
+
+ /**
+ * Handle upload errors by logging and notifying the user.
+ *
+ * @param {Error} uploadError - The error that occurred
+ */
+ handleUploadError(uploadError) {
+ console.error(`${MODULE_LOG_PREFIX} | Upload failed:`, uploadError);
+
+ const localizedMessage = game.i18n.localize('QUICKBATTLEMAP.UploadFailed');
+ const errorDetail = uploadError?.message ?? String(uploadError);
+
+ ui.notifications.error(`${localizedMessage}: ${errorDetail}`);
+ }
+}
diff --git a/scripts/lib/scene-builder.js b/scripts/lib/scene-builder.js
new file mode 100644
index 0000000..d6bc56f
--- /dev/null
+++ b/scripts/lib/scene-builder.js
@@ -0,0 +1,355 @@
+/**
+ * Scene Builder
+ *
+ * Handles the creation and configuration of Foundry VTT Scene documents.
+ * Extracts scene-specific logic for cleaner separation from the import controller.
+ *
+ * @module SceneBuilder
+ */
+
+/** Module identifier for console logging */
+const MODULE_LOG_PREFIX = 'Quick Battlemap Importer';
+
+/** Maximum time to wait for canvas to be ready (milliseconds) */
+const CANVAS_READY_TIMEOUT_MS = 8000;
+
+/** Interval between canvas ready checks (milliseconds) */
+const CANVAS_READY_CHECK_INTERVAL_MS = 100;
+
+/**
+ * @typedef {Object} SceneCreationOptions
+ * @property {string} backgroundPath - Path to the uploaded background media
+ * @property {string} sceneName - Name for the new scene
+ * @property {number} width - Scene width in pixels
+ * @property {number} height - Scene height in pixels
+ * @property {number} [padding=0] - Scene padding multiplier
+ * @property {string} [backgroundColor='#000000'] - Background color
+ * @property {boolean} [globalLight=false] - Enable global illumination
+ * @property {number} [darkness=0] - Darkness level (0-1)
+ */
+
+/**
+ * @typedef {Object} GridSettings
+ * @property {number} size - Grid cell size in pixels
+ * @property {number} type - Grid type (0=none, 1=square, etc.)
+ * @property {number} distance - Distance per grid cell
+ * @property {string} units - Distance units
+ * @property {string} color - Grid line color
+ * @property {number} alpha - Grid line opacity
+ * @property {Object} offset - Grid offset
+ * @property {number} offset.x - Horizontal offset
+ * @property {number} offset.y - Vertical offset
+ */
+
+/**
+ * Service class responsible for building and configuring Foundry scenes.
+ * Handles scene creation, grid settings, walls, and lights.
+ */
+export class SceneBuilder {
+
+ /**
+ * Create a new scene builder instance.
+ *
+ * @param {boolean} [enableDebugLogging=false] - Whether to log debug information
+ */
+ constructor(enableDebugLogging = false) {
+ /** @type {boolean} Enable verbose console logging */
+ this.isDebugLoggingEnabled = enableDebugLogging;
+ }
+
+ /**
+ * Create a new Foundry Scene document with the specified options.
+ *
+ * @param {SceneCreationOptions} options - Scene creation options
+ * @returns {Promise} The created Scene document
+ *
+ * @example
+ * const builder = new SceneBuilder();
+ * const scene = await builder.createScene({
+ * backgroundPath: 'worlds/myworld/maps/dungeon.png',
+ * sceneName: 'Dungeon Level 1',
+ * width: 2048,
+ * height: 2048
+ * });
+ */
+ async createScene(options) {
+ const sceneDocumentData = {
+ name: options.sceneName,
+ img: options.backgroundPath,
+ background: { src: options.backgroundPath },
+ width: options.width,
+ height: options.height,
+ padding: options.padding ?? 0,
+ backgroundColor: options.backgroundColor ?? '#000000',
+ globalLight: options.globalLight ?? false,
+ darkness: options.darkness ?? 0
+ };
+
+ const createdScene = await Scene.create(sceneDocumentData);
+
+ if (this.isDebugLoggingEnabled) {
+ console.log(`${MODULE_LOG_PREFIX} | Created scene:`, createdScene.name);
+ }
+
+ return createdScene;
+ }
+
+ /**
+ * Activate a scene and wait for the canvas to be fully ready.
+ *
+ * @param {Scene} scene - The scene to activate
+ * @returns {Promise} Resolves when canvas is ready
+ */
+ async activateAndWaitForCanvas(scene) {
+ await scene.activate();
+ await this.waitForCanvasReady(scene);
+ }
+
+ /**
+ * Wait for the canvas to be fully ready after scene activation.
+ * Times out after CANVAS_READY_TIMEOUT_MS to prevent infinite waiting.
+ *
+ * @param {Scene} targetScene - The scene to wait for
+ * @returns {Promise} Resolves when canvas is ready or timeout reached
+ */
+ async waitForCanvasReady(targetScene) {
+ const timeoutDeadline = Date.now() + CANVAS_READY_TIMEOUT_MS;
+
+ while (Date.now() < timeoutDeadline) {
+ const isCanvasReady = this.checkCanvasReadyState(targetScene);
+
+ if (isCanvasReady) {
+ return;
+ }
+
+ await this.delay(CANVAS_READY_CHECK_INTERVAL_MS);
+ }
+
+ if (this.isDebugLoggingEnabled) {
+ console.warn(`${MODULE_LOG_PREFIX} | Canvas ready timeout reached`);
+ }
+ }
+
+ /**
+ * Check if the canvas is fully initialized for a scene.
+ *
+ * @param {Scene} targetScene - The scene to check
+ * @returns {boolean} True if canvas is ready
+ */
+ checkCanvasReadyState(targetScene) {
+ return (
+ canvas?.ready &&
+ canvas?.scene?.id === targetScene.id &&
+ canvas?.walls?.initialized !== false &&
+ canvas?.lighting?.initialized !== false
+ );
+ }
+
+ /**
+ * Apply grid settings to a scene, handling different Foundry versions.
+ *
+ * @param {Scene} scene - The scene to update
+ * @param {GridSettings} gridSettings - Grid configuration to apply
+ * @param {boolean} [useNoGridMode=false] - Override grid type to 0 (none)
+ */
+ async applyGridSettings(scene, gridSettings, useNoGridMode = false) {
+ // Override grid type if no-grid mode is enabled
+ const effectiveGridSettings = { ...gridSettings };
+ if (useNoGridMode) {
+ effectiveGridSettings.type = 0;
+ }
+
+ const sceneSnapshot = duplicate(scene.toObject());
+ const usesObjectGridFormat = typeof sceneSnapshot.grid === 'object';
+
+ if (this.isDebugLoggingEnabled) {
+ console.log(`${MODULE_LOG_PREFIX} | Scene grid snapshot:`, sceneSnapshot.grid);
+ }
+
+ if (usesObjectGridFormat) {
+ await this.applyObjectGridSettings(scene, sceneSnapshot, effectiveGridSettings);
+ } else {
+ await this.applyLegacyGridSettings(scene, effectiveGridSettings);
+ }
+
+ if (this.isDebugLoggingEnabled) {
+ const updatedSnapshot = duplicate(scene.toObject());
+ console.log(`${MODULE_LOG_PREFIX} | Grid after update:`, updatedSnapshot.grid);
+ }
+ }
+
+ /**
+ * Apply grid settings using modern object format (Foundry v10+).
+ * Falls back to legacy format if update fails.
+ *
+ * @param {Scene} scene - The scene to update
+ * @param {Object} snapshot - Current scene data snapshot
+ * @param {GridSettings} gridSettings - Grid settings to apply
+ */
+ async applyObjectGridSettings(scene, snapshot, gridSettings) {
+ const gridUpdateData = {
+ ...(snapshot.grid || {}),
+ size: gridSettings.size,
+ type: gridSettings.type,
+ distance: gridSettings.distance,
+ units: gridSettings.units,
+ color: gridSettings.color,
+ alpha: gridSettings.alpha,
+ offset: {
+ x: gridSettings.offset?.x ?? 0,
+ y: gridSettings.offset?.y ?? 0
+ }
+ };
+
+ try {
+ await scene.update({ grid: gridUpdateData });
+ } catch (updateError) {
+ console.warn(`${MODULE_LOG_PREFIX} | Grid object update failed; using legacy format`, updateError);
+ await this.applyLegacyGridSettings(scene, gridSettings);
+ }
+ }
+
+ /**
+ * Apply grid settings using legacy flat property format.
+ * Used for older Foundry versions or as fallback.
+ *
+ * @param {Scene} scene - The scene to update
+ * @param {GridSettings} gridSettings - Grid settings to apply
+ */
+ async applyLegacyGridSettings(scene, gridSettings) {
+ await scene.update({
+ grid: gridSettings.size,
+ gridType: gridSettings.type,
+ gridDistance: gridSettings.distance,
+ gridUnits: gridSettings.units,
+ gridColor: gridSettings.color,
+ gridAlpha: gridSettings.alpha,
+ shiftX: gridSettings.offset?.x ?? 0,
+ shiftY: gridSettings.offset?.y ?? 0
+ });
+ }
+
+ /**
+ * Create wall documents in a scene.
+ * Filters invalid walls and retries once on failure.
+ *
+ * @param {Scene} scene - The scene to add walls to
+ * @param {Array} wallsData - Array of wall document data
+ * @returns {Promise}
+ */
+ async createWalls(scene, wallsData) {
+ const validWalls = this.filterValidWalls(wallsData || []);
+
+ if (!validWalls.length) {
+ return;
+ }
+
+ if (this.isDebugLoggingEnabled) {
+ console.log(`${MODULE_LOG_PREFIX} | Creating ${validWalls.length} walls`);
+ }
+
+ const wallCountBefore = scene.walls?.size ?? 0;
+
+ try {
+ await scene.createEmbeddedDocuments('Wall', validWalls);
+ } catch (firstAttemptError) {
+ console.warn(`${MODULE_LOG_PREFIX} | Wall creation failed, retrying...`, firstAttemptError);
+ await this.retryWallCreation(scene, validWalls, wallCountBefore);
+ }
+ }
+
+ /**
+ * Filter wall data to remove invalid entries.
+ * Walls must have valid coordinates and non-zero length.
+ *
+ * @param {Array} wallsData - Raw wall data array
+ * @returns {Array} Filtered array of valid walls
+ */
+ filterValidWalls(wallsData) {
+ return wallsData.filter(wall => {
+ // Must have coordinate array with at least 4 values
+ if (!Array.isArray(wall.c) || wall.c.length < 4) {
+ return false;
+ }
+
+ const [startX, startY, endX, endY] = wall.c.map(n => Number(n));
+ const coordinates = [startX, startY, endX, endY];
+
+ // All coordinates must be finite numbers
+ if (coordinates.some(coord => !Number.isFinite(coord))) {
+ return false;
+ }
+
+ // Wall must have non-zero length (not a point)
+ if (startX === endX && startY === endY) {
+ return false;
+ }
+
+ return true;
+ });
+ }
+
+ /**
+ * Retry wall creation after waiting for canvas stability.
+ *
+ * @param {Scene} scene - The scene to add walls to
+ * @param {Array} validWalls - Valid wall data array
+ * @param {number} wallCountBefore - Wall count before first attempt
+ */
+ async retryWallCreation(scene, validWalls, wallCountBefore) {
+ await this.waitForCanvasReady(scene);
+ await this.delay(200);
+
+ try {
+ await scene.createEmbeddedDocuments('Wall', validWalls);
+ } catch (retryError) {
+ const wallCountAfter = scene.walls?.size ?? 0;
+
+ if (wallCountAfter > wallCountBefore) {
+ // Walls were actually created despite the error
+ console.warn(`${MODULE_LOG_PREFIX} | Walls created despite error`);
+ } else {
+ console.error(`${MODULE_LOG_PREFIX} | Failed to create walls:`, validWalls.slice(0, 5));
+ console.error(retryError);
+ ui.notifications.warn('Some walls could not be created. See console.');
+ }
+ }
+ }
+
+ /**
+ * Create ambient light documents in a scene.
+ *
+ * @param {Scene} scene - The scene to add lights to
+ * @param {Array} lightsData - Array of light document data
+ * @returns {Promise}
+ */
+ async createLights(scene, lightsData) {
+ const lights = lightsData || [];
+
+ if (!lights.length) {
+ return;
+ }
+
+ if (this.isDebugLoggingEnabled) {
+ console.log(`${MODULE_LOG_PREFIX} | Creating ${lights.length} lights`);
+ }
+
+ try {
+ await scene.createEmbeddedDocuments('AmbientLight', lights);
+ } catch (creationError) {
+ console.error(`${MODULE_LOG_PREFIX} | Failed to create lights:`, lights.slice(0, 5));
+ console.error(creationError);
+ ui.notifications.warn('Some lights could not be created. See console.');
+ }
+ }
+
+ /**
+ * Utility method to create a delay.
+ *
+ * @param {number} milliseconds - Duration to wait
+ * @returns {Promise} Resolves after the delay
+ */
+ delay(milliseconds) {
+ return new Promise(resolve => setTimeout(resolve, milliseconds));
+ }
+}
diff --git a/scripts/lib/scene-data-normalizer.js b/scripts/lib/scene-data-normalizer.js
new file mode 100644
index 0000000..fe37f46
--- /dev/null
+++ b/scripts/lib/scene-data-normalizer.js
@@ -0,0 +1,355 @@
+/**
+ * Scene Data Normalizer
+ *
+ * Transforms imported scene configuration data (from JSON exports like Dungeon Alchemist)
+ * into Foundry VTT's expected document format. Handles various input formats and
+ * provides sensible defaults for missing values.
+ *
+ * @module SceneDataNormalizer
+ */
+
+/**
+ * @typedef {Object} NormalizedGridSettings
+ * @property {number} size - Grid cell size in pixels
+ * @property {number} type - Grid type (0=none, 1=square, 2=hex-row, 3=hex-col)
+ * @property {number} distance - Real-world distance per grid cell
+ * @property {string} units - Unit of measurement (ft, m, etc.)
+ * @property {number} alpha - Grid line opacity (0-1)
+ * @property {string} color - Grid line color (hex)
+ * @property {Object} offset - Grid offset for alignment
+ * @property {number} offset.x - Horizontal offset in pixels
+ * @property {number} offset.y - Vertical offset in pixels
+ */
+
+/**
+ * @typedef {Object} NormalizedWallData
+ * @property {number[]} c - Wall coordinates [x1, y1, x2, y2]
+ * @property {number} door - Door type (0=none, 1=door, 2=secret)
+ * @property {number} ds - Door state (0=closed, 1=open, 2=locked)
+ * @property {number} dir - Wall direction for one-way walls
+ * @property {number} move - Movement restriction type
+ * @property {number} sound - Sound restriction type
+ * @property {number} sight - Vision restriction type
+ * @property {number} light - Light restriction type
+ * @property {Object} flags - Custom module flags
+ */
+
+/**
+ * @typedef {Object} NormalizedLightData
+ * @property {number} x - X coordinate
+ * @property {number} y - Y coordinate
+ * @property {number} rotation - Light rotation angle
+ * @property {boolean} hidden - Whether light is hidden from players
+ * @property {boolean} walls - Whether light is blocked by walls
+ * @property {boolean} vision - Whether light provides vision
+ * @property {Object} config - Light configuration object
+ */
+
+/**
+ * @typedef {Object} NormalizedSceneData
+ * @property {string} [name] - Scene name
+ * @property {number} [width] - Scene width in pixels
+ * @property {number} [height] - Scene height in pixels
+ * @property {NormalizedGridSettings} grid - Grid configuration
+ * @property {number} padding - Scene padding multiplier
+ * @property {string} backgroundColor - Background color (hex)
+ * @property {boolean} globalLight - Whether global illumination is enabled
+ * @property {number} darkness - Darkness level (0-1)
+ * @property {NormalizedWallData[]} walls - Wall documents
+ * @property {NormalizedLightData[]} lights - Ambient light documents
+ * @property {Array} tokens - Token documents
+ * @property {Array} notes - Note documents
+ * @property {Array} drawings - Drawing documents
+ */
+
+/** Default values for grid configuration */
+const GRID_DEFAULTS = {
+ SIZE: 100,
+ TYPE: 1, // Square grid
+ DISTANCE: 5,
+ UNITS: 'ft',
+ ALPHA: 0.2,
+ COLOR: '#000000'
+};
+
+/** Default scene settings */
+const SCENE_DEFAULTS = {
+ PADDING: 0,
+ BACKGROUND_COLOR: '#000000',
+ DARKNESS: 0
+};
+
+/**
+ * Service class that normalizes imported scene data to Foundry's expected format.
+ * Handles various JSON export formats and provides sensible defaults.
+ */
+export class SceneDataNormalizer {
+
+ /**
+ * Transform imported scene configuration into Foundry's internal document format.
+ * Handles multiple input formats and normalizes all values.
+ *
+ * @param {Object|null|undefined} inputData - Raw imported scene data (may be null/undefined)
+ * @returns {NormalizedSceneData} Normalized scene configuration ready for Foundry
+ *
+ * @example
+ * const normalizer = new SceneDataNormalizer();
+ * const normalized = normalizer.normalizeToFoundryFormat(importedJson);
+ * // normalized.grid, normalized.walls, etc. are ready for Scene.create()
+ */
+ normalizeToFoundryFormat(inputData) {
+ const sourceData = inputData || {};
+
+ const normalizedData = {
+ name: sourceData.name,
+ width: this.parseNumberOrUndefined(sourceData.width),
+ height: this.parseNumberOrUndefined(sourceData.height),
+ grid: this.normalizeGridSettings(sourceData),
+ padding: this.parseNumberWithDefault(sourceData.padding, SCENE_DEFAULTS.PADDING),
+ backgroundColor: sourceData.backgroundColor ?? sourceData.gridColor ?? SCENE_DEFAULTS.BACKGROUND_COLOR,
+ globalLight: !!sourceData.globalLight,
+ darkness: this.parseNumberWithDefault(sourceData.darkness, SCENE_DEFAULTS.DARKNESS),
+ walls: this.normalizeWallsData(sourceData.walls || []),
+ lights: this.normalizeLightsData(sourceData.lights || []),
+ tokens: sourceData.tokens ?? [],
+ notes: sourceData.notes ?? [],
+ drawings: sourceData.drawings ?? []
+ };
+
+ return normalizedData;
+ }
+
+ /**
+ * Normalize grid settings from various input formats.
+ * Supports both flat properties and nested grid object.
+ *
+ * @param {Object} sourceData - Source data containing grid information
+ * @returns {NormalizedGridSettings} Normalized grid configuration
+ */
+ normalizeGridSettings(sourceData) {
+ // Extract grid values from either flat properties or nested grid object
+ const gridSize = this.extractGridValue(sourceData, 'size', 'grid', GRID_DEFAULTS.SIZE);
+ const gridType = this.extractGridValue(sourceData, 'gridType', 'type', GRID_DEFAULTS.TYPE);
+ const gridDistance = sourceData.gridDistance ?? sourceData.grid?.distance ?? GRID_DEFAULTS.DISTANCE;
+ const gridUnits = sourceData.gridUnits ?? sourceData.grid?.units ?? GRID_DEFAULTS.UNITS;
+ const gridAlpha = this.parseNumberWithDefault(
+ sourceData.gridAlpha ?? sourceData.grid?.alpha,
+ GRID_DEFAULTS.ALPHA
+ );
+ const gridColor = sourceData.gridColor ?? sourceData.grid?.color ?? GRID_DEFAULTS.COLOR;
+ const offsetX = this.parseNumberWithDefault(sourceData.shiftX ?? sourceData.grid?.shiftX, 0);
+ const offsetY = this.parseNumberWithDefault(sourceData.shiftY ?? sourceData.grid?.shiftY, 0);
+
+ return {
+ size: gridSize,
+ type: gridType,
+ distance: gridDistance,
+ units: gridUnits,
+ alpha: gridAlpha,
+ color: gridColor,
+ offset: {
+ x: offsetX,
+ y: offsetY
+ }
+ };
+ }
+
+ /**
+ * Extract a grid value from source data, handling both number and object formats.
+ *
+ * @param {Object} sourceData - Source data object
+ * @param {string} flatKey - Key for flat property (e.g., 'gridType')
+ * @param {string} nestedKey - Key within grid object (e.g., 'type')
+ * @param {number} defaultValue - Default value if not found
+ * @returns {number} The extracted grid value
+ */
+ extractGridValue(sourceData, flatKey, nestedKey, defaultValue) {
+ // Handle the special case where grid can be a number (size) or an object
+ if (nestedKey === 'grid' || flatKey === 'size') {
+ const rawGridValue = typeof sourceData.grid === 'number'
+ ? sourceData.grid
+ : sourceData.grid?.size;
+ return this.parseNumberWithDefault(rawGridValue, defaultValue);
+ }
+
+ const flatValue = sourceData[flatKey];
+ const nestedValue = sourceData.grid?.[nestedKey];
+
+ return this.parseNumberWithDefault(
+ flatValue !== undefined ? flatValue : nestedValue,
+ defaultValue
+ );
+ }
+
+ /**
+ * Normalize an array of wall data to Foundry's Wall document format.
+ *
+ * @param {Array} wallsArray - Array of raw wall data objects
+ * @returns {NormalizedWallData[]} Array of normalized wall documents
+ */
+ normalizeWallsData(wallsArray) {
+ return wallsArray.map(wall => this.normalizeWall(wall));
+ }
+
+ /**
+ * Normalize a single wall object to Foundry's expected format.
+ *
+ * @param {Object} wall - Raw wall data
+ * @returns {NormalizedWallData} Normalized wall document
+ */
+ normalizeWall(wall) {
+ const restrictionTypes = this.getWallRestrictionTypes();
+
+ return {
+ c: this.normalizeWallCoordinates(wall.c),
+ door: this.ensureFiniteNumber(wall.door, 0),
+ ds: this.ensureFiniteNumber(wall.ds, 0),
+ dir: this.ensureFiniteNumber(wall.dir, 0),
+ move: this.parseRestrictionValue(wall.move, restrictionTypes.NONE, restrictionTypes),
+ sound: this.parseRestrictionValue(wall.sound, restrictionTypes.NONE, restrictionTypes),
+ sight: this.parseRestrictionValue(wall.sense ?? wall.sight, restrictionTypes.NONE, restrictionTypes),
+ light: this.parseRestrictionValue(wall.light, restrictionTypes.NONE, restrictionTypes),
+ flags: wall.flags ?? {}
+ };
+ }
+
+ /**
+ * Normalize wall coordinates to ensure they are numbers.
+ *
+ * @param {Array} coordinates - Raw coordinate array
+ * @returns {number[]} Array of numeric coordinates [x1, y1, x2, y2]
+ */
+ normalizeWallCoordinates(coordinates) {
+ if (!Array.isArray(coordinates)) {
+ return coordinates;
+ }
+ return coordinates.slice(0, 4).map(coord => Number(coord));
+ }
+
+ /**
+ * Get wall restriction type constants from Foundry or use defaults.
+ *
+ * @returns {{NONE: number, LIMITED: number, NORMAL: number}} Restriction type values
+ */
+ getWallRestrictionTypes() {
+ return globalThis?.CONST?.WALL_RESTRICTION_TYPES || {
+ NONE: 0,
+ LIMITED: 10,
+ NORMAL: 20
+ };
+ }
+
+ /**
+ * Parse a wall restriction value from various input formats.
+ * Handles numbers, strings, and boolean values.
+ *
+ * @param {*} value - The value to parse
+ * @param {number} defaultValue - Default if parsing fails
+ * @param {Object} restrictionTypes - Available restriction type constants
+ * @returns {number} The restriction type value
+ */
+ parseRestrictionValue(value, defaultValue, restrictionTypes) {
+ const validValues = new Set(Object.values(restrictionTypes));
+
+ // Already a valid restriction number
+ if (typeof value === 'number' && validValues.has(value)) {
+ return value;
+ }
+
+ // Falsy values map to NONE
+ if (value === 0 || value === '0' || value === false || value == null) {
+ return restrictionTypes.NONE;
+ }
+
+ // Truthy numeric values map to NORMAL
+ if (value === 1 || value === '1' || value === true) {
+ return restrictionTypes.NORMAL;
+ }
+
+ // Parse string values
+ if (typeof value === 'string') {
+ const lowercaseValue = value.toLowerCase();
+
+ if (lowercaseValue.startsWith('none')) {
+ return restrictionTypes.NONE;
+ }
+ if (lowercaseValue.startsWith('limit')) {
+ return restrictionTypes.LIMITED;
+ }
+ if (lowercaseValue.startsWith('norm')) {
+ return restrictionTypes.NORMAL;
+ }
+ }
+
+ return defaultValue;
+ }
+
+ /**
+ * Normalize an array of light data to Foundry's AmbientLight document format.
+ *
+ * @param {Array} lightsArray - Array of raw light data objects
+ * @returns {NormalizedLightData[]} Array of normalized light documents
+ */
+ normalizeLightsData(lightsArray) {
+ return lightsArray.map(light => this.normalizeLight(light));
+ }
+
+ /**
+ * Normalize a single light object to Foundry's expected format.
+ *
+ * @param {Object} light - Raw light data
+ * @returns {NormalizedLightData} Normalized light document
+ */
+ normalizeLight(light) {
+ return {
+ x: Number(light.x),
+ y: Number(light.y),
+ rotation: 0,
+ hidden: false,
+ walls: true,
+ vision: false,
+ config: {
+ alpha: Number(light.tintAlpha ?? 0),
+ color: light.tintColor ?? null,
+ bright: Number(light.bright ?? 0),
+ dim: Number(light.dim ?? 0),
+ angle: 360
+ }
+ };
+ }
+
+ /**
+ * Parse a value as a number, returning undefined if invalid.
+ *
+ * @param {*} value - Value to parse
+ * @returns {number|undefined} Parsed number or undefined
+ */
+ parseNumberOrUndefined(value) {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : undefined;
+ }
+
+ /**
+ * Parse a value as a number, returning a default if invalid.
+ *
+ * @param {*} value - Value to parse
+ * @param {number} defaultValue - Default value if parsing fails
+ * @returns {number} Parsed number or default
+ */
+ parseNumberWithDefault(value, defaultValue) {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : defaultValue;
+ }
+
+ /**
+ * Ensure a value is a finite number, returning default if not.
+ *
+ * @param {*} value - Value to check
+ * @param {number} defaultValue - Default value
+ * @returns {number} The value if finite, otherwise default
+ */
+ ensureFiniteNumber(value, defaultValue) {
+ const numValue = Number(value);
+ return Number.isFinite(numValue) ? numValue : defaultValue;
+ }
+}
diff --git a/scripts/lib/scene-import-controller.js b/scripts/lib/scene-import-controller.js
new file mode 100644
index 0000000..29bd7ef
--- /dev/null
+++ b/scripts/lib/scene-import-controller.js
@@ -0,0 +1,445 @@
+/**
+ * Scene Import Controller
+ *
+ * Main orchestration class that coordinates the battlemap import workflow.
+ * Handles file drops, manages state, and delegates to specialized services
+ * for grid detection, data normalization, storage, and UI updates.
+ *
+ * @module SceneImportController
+ */
+
+import { ImportPanelView } from './import-panel-view.js';
+import { SceneDataNormalizer } from './scene-data-normalizer.js';
+import { MediaStorageService } from './media-storage-service.js';
+import { GridDetectionService } from './grid-detection-service.js';
+import { FileProcessor } from './file-processor.js';
+import { SceneBuilder } from './scene-builder.js';
+
+/**
+ * @typedef {Object} BackgroundMediaData
+ * @property {string} data - Base64 data URL or blob URL for the media
+ * @property {string} filename - Original filename of the media
+ * @property {File} file - The original File object
+ * @property {boolean} isVideo - Whether the media is a video file
+ */
+
+/** Module identifier for console logging */
+const MODULE_LOG_PREFIX = 'Quick Battlemap Importer';
+
+/** LocalStorage key for persisting no-grid preference */
+const NO_GRID_STORAGE_KEY = 'quick-battlemap:no-grid';
+
+/**
+ * Controller class that manages the battlemap import process.
+ * Coordinates between the UI panel, storage service, and data processing.
+ */
+export class SceneImportController {
+ constructor() {
+ /** @type {BackgroundMediaData|null} Currently loaded background media */
+ this.backgroundMediaData = null;
+
+ /** @type {Object|null} Parsed scene configuration from JSON */
+ this.importedSceneStructure = null;
+
+ /** @type {boolean} Enable verbose console logging for debugging */
+ this.isDebugLoggingEnabled = true;
+
+ /** @type {number} Counter for tracking concurrent async operations */
+ this.pendingOperationCount = 0;
+
+ /** @type {boolean} User preference to skip grid detection/application */
+ this.isNoGridModeEnabled = false;
+
+ // Initialize services
+ this.panelView = new ImportPanelView();
+ this.dataNormalizer = new SceneDataNormalizer();
+ this.storageService = new MediaStorageService();
+ this.gridDetectionService = new GridDetectionService();
+ this.fileProcessor = new FileProcessor();
+ this.sceneBuilder = new SceneBuilder(this.isDebugLoggingEnabled);
+ }
+
+ /**
+ * Initialize the controller by setting up event handlers and loading preferences.
+ */
+ initialize() {
+ this.registerCanvasDropHandler();
+ this.setupPanelEventCallbacks();
+ }
+
+ /**
+ * Show the import panel to the user. Validates GM permissions before displaying.
+ */
+ showImportPanel() {
+ if (!game.user?.isGM) {
+ ui.notifications?.warn?.(game.i18n.localize('QUICKBATTLEMAP.GMOnly') ?? 'GM only');
+ return;
+ }
+ this.panelView.ensureCreated();
+ this.panelView.show();
+ }
+
+ /**
+ * Hide the import panel from view.
+ */
+ hideImportPanel() {
+ this.panelView.hide();
+ }
+
+ /**
+ * Register a drop event handler on the Foundry canvas.
+ */
+ registerCanvasDropHandler() {
+ canvas.stage?.on?.('drop', (event) => {
+ this.processDroppedFiles(event.data.originalEvent);
+ });
+ }
+
+ /**
+ * Wire up all callback functions for the panel view.
+ */
+ setupPanelEventCallbacks() {
+ this.isNoGridModeEnabled = this.loadNoGridPreference();
+ this.panelView.setNoGridCheckboxState(this.isNoGridModeEnabled);
+
+ this.panelView.onCreateSceneRequested = () => this.executeSceneCreation();
+ this.panelView.onResetRequested = () => this.resetImportState();
+ this.panelView.onCloseRequested = () => this.hideImportPanel();
+ this.panelView.onFilesDropped = (event) => this.processDroppedFiles(event);
+ this.panelView.onNoGridPreferenceChanged = (isEnabled) => this.handleNoGridPreferenceChange(isEnabled);
+ }
+
+ /**
+ * Load the no-grid preference from browser localStorage.
+ * @returns {boolean} True if no-grid mode was previously enabled
+ */
+ loadNoGridPreference() {
+ try {
+ return localStorage.getItem(NO_GRID_STORAGE_KEY) === 'true';
+ } catch (_error) {
+ return false;
+ }
+ }
+
+ /**
+ * Handle changes to the no-grid preference checkbox.
+ * @param {boolean} isEnabled - Whether no-grid mode is now enabled
+ */
+ handleNoGridPreferenceChange(isEnabled) {
+ this.isNoGridModeEnabled = !!isEnabled;
+ try {
+ localStorage.setItem(NO_GRID_STORAGE_KEY, String(this.isNoGridModeEnabled));
+ } catch (_error) { /* localStorage may not be available */ }
+
+ const wallStatusElement = document.querySelector('.wall-data-status .status-value');
+ if (wallStatusElement && this.isNoGridModeEnabled && wallStatusElement.title === 'Auto-detected grid') {
+ wallStatusElement.textContent = '❌';
+ wallStatusElement.title = '';
+ }
+ }
+
+ /**
+ * Process files dropped onto the panel or canvas.
+ * @param {DragEvent} dropEvent - The native drag-and-drop event
+ */
+ processDroppedFiles(dropEvent) {
+ const droppedFiles = dropEvent.dataTransfer?.files;
+ if (!droppedFiles) return;
+
+ for (let i = 0; i < droppedFiles.length; i++) {
+ const file = droppedFiles[i];
+ const fileType = this.fileProcessor.getFileType(file);
+
+ switch (fileType) {
+ case 'image':
+ this.handleImageFile(file);
+ break;
+ case 'video':
+ this.handleVideoFile(file);
+ break;
+ case 'json':
+ this.handleJsonConfigFile(file);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Process an image file for use as scene background.
+ * @param {File} imageFile - The dropped image file
+ */
+ async handleImageFile(imageFile) {
+ try {
+ const processedImage = await this.fileProcessor.processImageFile(imageFile);
+ this.backgroundMediaData = {
+ data: processedImage.dataUrl,
+ filename: processedImage.filename,
+ file: processedImage.file,
+ isVideo: false
+ };
+
+ this.panelView.updateBackgroundMediaStatus(true, imageFile.name);
+ this.updateCreateButtonState();
+
+ if (!this.isNoGridModeEnabled) {
+ await this.runGridAutoDetection(imageFile);
+ }
+ } catch (error) {
+ console.error(`${MODULE_LOG_PREFIX} | Image processing failed:`, error);
+ }
+ }
+
+ /**
+ * Run automatic grid detection on an image file.
+ * @param {File} imageFile - The image file to analyze
+ */
+ async runGridAutoDetection(imageFile) {
+ this.showProgressIndicator(game.i18n.localize('QUICKBATTLEMAP.ProgressAnalyzing'));
+ try {
+ await this.detectAndApplyGridFromImage(imageFile);
+ } catch (error) {
+ if (this.isDebugLoggingEnabled) {
+ console.warn(`${MODULE_LOG_PREFIX} | Auto grid detection failed:`, error);
+ }
+ } finally {
+ this.hideProgressIndicator();
+ }
+ }
+
+ /**
+ * Process a video file for use as scene background.
+ * @param {File} videoFile - The dropped video file
+ */
+ handleVideoFile(videoFile) {
+ const processedVideo = this.fileProcessor.processVideoFile(videoFile);
+ this.backgroundMediaData = {
+ data: processedVideo.blobUrl,
+ filename: processedVideo.filename,
+ file: processedVideo.file,
+ isVideo: true
+ };
+ this.panelView.updateBackgroundMediaStatus(true, videoFile.name);
+ this.updateCreateButtonState();
+ }
+
+ /**
+ * Process a JSON configuration file.
+ * @param {File} jsonFile - The dropped JSON file
+ */
+ async handleJsonConfigFile(jsonFile) {
+ try {
+ const processedJson = await this.fileProcessor.processJsonFile(jsonFile);
+ this.importedSceneStructure = processedJson.parsedContent;
+ this.panelView.updateWallDataStatus(true, jsonFile.name);
+ this.updateCreateButtonState();
+ } catch (error) {
+ console.error(`${MODULE_LOG_PREFIX} | JSON parse error:`, error);
+ ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.InvalidJSON"));
+ }
+ }
+
+ /**
+ * Update the enabled state of the "Create Scene" button.
+ */
+ updateCreateButtonState() {
+ const createButton = document.querySelector('.create-scene-button');
+ if (createButton) {
+ createButton.disabled = !this.backgroundMediaData;
+ }
+ }
+
+ /**
+ * Main scene creation workflow.
+ */
+ async executeSceneCreation() {
+ if (!this.backgroundMediaData) {
+ ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.MissingFiles"));
+ return;
+ }
+
+ await this.ensureGridDataExists();
+ this.warnIfGridDataMissing();
+
+ try {
+ ui.notifications.info(game.i18n.localize("QUICKBATTLEMAP.CreatingScene"));
+
+ const uploadResult = await this.uploadBackgroundMedia();
+ if (!uploadResult?.path) {
+ ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.UploadFailed"));
+ return;
+ }
+
+ const mediaDimensions = await this.fileProcessor.getMediaDimensions(this.backgroundMediaData);
+ const normalizedData = this.dataNormalizer.normalizeToFoundryFormat(this.importedSceneStructure);
+
+ this.logNormalizedData(normalizedData);
+
+ const sceneName = this.determineSceneName(normalizedData.name);
+ const createdScene = await this.sceneBuilder.createScene({
+ backgroundPath: uploadResult.path,
+ sceneName: sceneName,
+ width: normalizedData.width || mediaDimensions.width || 1920,
+ height: normalizedData.height || mediaDimensions.height || 1080,
+ padding: normalizedData.padding,
+ backgroundColor: normalizedData.backgroundColor,
+ globalLight: normalizedData.globalLight,
+ darkness: normalizedData.darkness
+ });
+
+ await this.sceneBuilder.activateAndWaitForCanvas(createdScene);
+ await this.sceneBuilder.applyGridSettings(createdScene, normalizedData.grid, this.isNoGridModeEnabled);
+ await this.sceneBuilder.createWalls(createdScene, normalizedData.walls);
+ await this.sceneBuilder.createLights(createdScene, normalizedData.lights);
+
+ this.cleanupAfterCreation(sceneName);
+
+ } catch (error) {
+ console.error(`${MODULE_LOG_PREFIX} | Scene creation failed:`, error);
+ ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.SceneCreationFailed"));
+ }
+ }
+
+ /**
+ * Ensure grid data exists before scene creation.
+ */
+ async ensureGridDataExists() {
+ const shouldDetect = !this.isNoGridModeEnabled &&
+ !this.importedSceneStructure &&
+ this.backgroundMediaData?.file &&
+ !this.backgroundMediaData?.isVideo;
+
+ if (shouldDetect) {
+ try {
+ this.showProgressIndicator(game.i18n.localize('QUICKBATTLEMAP.ProgressAnalyzing'));
+ await this.detectAndApplyGridFromImage(this.backgroundMediaData.file);
+ } catch (_error) { /* handled below */ }
+ finally {
+ this.hideProgressIndicator();
+ }
+ }
+ }
+
+ /**
+ * Display a warning if grid data is missing.
+ */
+ warnIfGridDataMissing() {
+ if (this.isNoGridModeEnabled || this.importedSceneStructure) return;
+
+ const message = this.backgroundMediaData?.isVideo
+ ? "No grid data provided for video. Drop a JSON export or enable the No Grid option."
+ : "Grid data missing and auto-detection failed. Drop a JSON export or set grid manually.";
+ ui.notifications.error(message);
+ }
+
+ /**
+ * Upload the background media to Foundry's storage.
+ * @returns {Promise<{path: string}|null>} Upload result
+ */
+ async uploadBackgroundMedia() {
+ this.showProgressIndicator(game.i18n.localize('QUICKBATTLEMAP.ProgressUploading'));
+ try {
+ return await this.storageService.uploadBackgroundMedia(this.backgroundMediaData, game.world.id);
+ } finally {
+ this.hideProgressIndicator();
+ }
+ }
+
+ /**
+ * Log normalized scene data for debugging.
+ * @param {Object} data - The normalized scene configuration
+ */
+ logNormalizedData(data) {
+ if (!this.isDebugLoggingEnabled) return;
+ console.log(`${MODULE_LOG_PREFIX} | Normalized grid:`, data.grid);
+ console.log(`${MODULE_LOG_PREFIX} | First wall:`, data.walls?.[0]);
+ console.log(`${MODULE_LOG_PREFIX} | First light:`, data.lights?.[0]);
+ }
+
+ /**
+ * Determine the scene name from config or filename.
+ * @param {string|undefined} configuredName - Name from JSON config
+ * @returns {string} The scene name to use
+ */
+ determineSceneName(configuredName) {
+ if (configuredName) return configuredName;
+ const nameFromFile = this.backgroundMediaData.filename.split('.').slice(0, -1).join('.');
+ return nameFromFile || game.i18n.localize("QUICKBATTLEMAP.DefaultSceneName");
+ }
+
+ /**
+ * Clean up state after successful scene creation.
+ * @param {string} sceneName - Name of the created scene
+ */
+ cleanupAfterCreation(sceneName) {
+ this.fileProcessor.revokeBlobUrl(this.backgroundMediaData?.data);
+ this.backgroundMediaData = null;
+ this.importedSceneStructure = null;
+
+ this.panelView.updateBackgroundMediaStatus(false, '');
+ this.panelView.updateWallDataStatus(false, '');
+
+ const createButton = document.querySelector('.create-scene-button');
+ if (createButton) createButton.disabled = true;
+
+ ui.notifications.info(`${game.i18n.localize("QUICKBATTLEMAP.SceneCreated")}: ${sceneName}`);
+ }
+
+ /**
+ * Detect grid settings from an image file.
+ * @param {File} imageFile - The image file to analyze
+ */
+ async detectAndApplyGridFromImage(imageFile) {
+ if (this.importedSceneStructure) return;
+
+ const result = await this.gridDetectionService.detectGridFromImage(imageFile);
+ if (!result || !Number.isFinite(result.gridSize) || result.gridSize <= 0) return;
+
+ this.importedSceneStructure = {
+ grid: {
+ size: Math.round(result.gridSize),
+ type: 1, distance: 5, units: 'ft', alpha: 0.2, color: '#000000'
+ },
+ shiftX: Math.round(result.xOffset || 0),
+ shiftY: Math.round(result.yOffset || 0),
+ walls: [], lights: []
+ };
+
+ this.panelView.updateWallDataStatus(true, 'Auto-detected grid');
+ this.updateCreateButtonState();
+
+ if (this.isDebugLoggingEnabled) {
+ console.log(`${MODULE_LOG_PREFIX} | Auto grid detection success:`, this.importedSceneStructure);
+ }
+ }
+
+ /**
+ * Show progress indicator with a status message.
+ * @param {string} message - Message to display
+ */
+ showProgressIndicator(message) {
+ this.pendingOperationCount = Math.max(0, this.pendingOperationCount) + 1;
+ this.panelView.showBusyState(message);
+ }
+
+ /**
+ * Hide progress indicator when operation completes.
+ */
+ hideProgressIndicator() {
+ this.pendingOperationCount = Math.max(0, this.pendingOperationCount - 1);
+ if (this.pendingOperationCount === 0) {
+ this.panelView.clearBusyState();
+ }
+ }
+
+ /**
+ * Reset all import state to initial values.
+ */
+ resetImportState() {
+ this.fileProcessor.revokeBlobUrl(this.backgroundMediaData?.data);
+ this.backgroundMediaData = null;
+ this.importedSceneStructure = null;
+ this.pendingOperationCount = 0;
+ this.isNoGridModeEnabled = this.loadNoGridPreference();
+ this.panelView.resetAllStatuses(this.isNoGridModeEnabled);
+ }
+}
diff --git a/scripts/lib/signal-processing-utils.js b/scripts/lib/signal-processing-utils.js
new file mode 100644
index 0000000..1d9d888
--- /dev/null
+++ b/scripts/lib/signal-processing-utils.js
@@ -0,0 +1,287 @@
+/**
+ * Signal Processing Utilities
+ *
+ * Low-level signal processing functions for grid detection.
+ * Provides autocorrelation, filtering, and normalization algorithms
+ * used by the GridDetectionService.
+ *
+ * @module SignalProcessingUtils
+ */
+
+/**
+ * @typedef {Object} AutocorrelationEntry
+ * @property {number} lag - The lag value (distance between compared samples)
+ * @property {number} val - The autocorrelation coefficient at this lag
+ */
+
+/**
+ * @typedef {Object} PeriodCandidate
+ * @property {number} value - The detected period (lag) value
+ * @property {number} score - Confidence score for this period
+ */
+
+/**
+ * Compute normalized autocorrelation of a signal.
+ * Autocorrelation measures how similar a signal is to a delayed version of itself.
+ * Peaks in autocorrelation indicate periodic patterns.
+ *
+ * @param {Float32Array} signal - Input signal
+ * @param {number} minLag - Minimum lag to consider (avoids self-correlation peak)
+ * @param {number} maxLag - Maximum lag to consider
+ * @returns {AutocorrelationEntry[]} Array of autocorrelation values for each lag
+ *
+ * @example
+ * const signal = new Float32Array([1, 0, 1, 0, 1, 0]); // Periodic signal
+ * const autocorr = computeAutocorrelation(signal, 1, 3);
+ * // autocorr[1].val would be high (period of 2)
+ */
+export function computeAutocorrelation(signal, minLag, maxLag) {
+ const signalLength = signal.length;
+
+ // Calculate mean of signal
+ let sum = 0;
+ for (let i = 0; i < signalLength; i++) {
+ sum += signal[i];
+ }
+ const mean = sum / signalLength;
+
+ // Calculate denominator (variance-like term for normalization)
+ let denominator = 0;
+ for (let i = 0; i < signalLength; i++) {
+ const deviation = signal[i] - mean;
+ denominator += deviation * deviation;
+ }
+ denominator = denominator || 1; // Prevent division by zero
+
+ // Calculate autocorrelation for each lag value
+ const result = [];
+ for (let lag = minLag; lag <= maxLag; lag++) {
+ let numerator = 0;
+
+ for (let i = 0; i + lag < signalLength; i++) {
+ numerator += (signal[i] - mean) * (signal[i + lag] - mean);
+ }
+
+ result.push({
+ lag: lag,
+ val: numerator / denominator
+ });
+ }
+
+ return result;
+}
+
+/**
+ * Apply a high-pass filter by subtracting a moving average.
+ * This removes low-frequency trends and emphasizes periodic patterns (like grid lines).
+ *
+ * @param {Float32Array} signal - Input signal
+ * @param {number} windowSize - Size of the averaging window
+ * @returns {Float32Array} High-pass filtered signal
+ *
+ * @example
+ * const signal = new Float32Array([10, 11, 10, 11, 10]); // Signal with DC offset
+ * const filtered = applyHighPassFilter(signal, 3);
+ * // filtered values will oscillate around 0
+ */
+export function applyHighPassFilter(signal, windowSize) {
+ const length = signal.length;
+ const effectiveWindow = Math.max(3, windowSize | 0);
+ const halfWindow = (effectiveWindow / 2) | 0;
+ const output = new Float32Array(length);
+
+ let runningSum = 0;
+
+ // Initialize running sum with first window
+ const initialWindowSize = Math.min(length, effectiveWindow);
+ for (let i = 0; i < initialWindowSize; i++) {
+ runningSum += signal[i];
+ }
+
+ // Compute high-passed values using sliding window
+ for (let i = 0; i < length; i++) {
+ const leftBoundary = i - halfWindow - 1;
+ const rightBoundary = i + halfWindow;
+
+ // Update running sum with sliding window
+ if (rightBoundary < length && i + halfWindow < length) {
+ runningSum += signal[rightBoundary];
+ }
+ if (leftBoundary >= 0) {
+ runningSum -= signal[leftBoundary];
+ }
+
+ // Calculate local average
+ const spanStart = Math.max(0, i - halfWindow);
+ const spanEnd = Math.min(length - 1, i + halfWindow);
+ const spanSize = (spanEnd - spanStart + 1) || 1;
+ const localAverage = runningSum / spanSize;
+
+ // High-pass = original value minus local average
+ output[i] = signal[i] - localAverage;
+ }
+
+ // Suppress negative values (edge responses are positive peaks)
+ for (let i = 0; i < length; i++) {
+ if (output[i] < 0) {
+ output[i] *= 0.2;
+ }
+ }
+
+ return output;
+}
+
+/**
+ * Normalize a signal to the range [0, 1].
+ *
+ * @param {Float32Array} signal - Input signal
+ * @returns {Float32Array} Normalized signal with values between 0 and 1
+ *
+ * @example
+ * const signal = new Float32Array([10, 20, 30]);
+ * const normalized = normalizeSignal(signal);
+ * // normalized = [0, 0.5, 1]
+ */
+export function normalizeSignal(signal) {
+ let maxValue = -Infinity;
+ let minValue = Infinity;
+
+ // Find min and max values
+ for (let i = 0; i < signal.length; i++) {
+ if (signal[i] > maxValue) maxValue = signal[i];
+ if (signal[i] < minValue) minValue = signal[i];
+ }
+
+ const range = (maxValue - minValue) || 1; // Prevent division by zero
+ const normalized = new Float32Array(signal.length);
+
+ // Apply min-max normalization
+ for (let i = 0; i < signal.length; i++) {
+ normalized[i] = (signal[i] - minValue) / range;
+ }
+
+ return normalized;
+}
+
+/**
+ * Find the best period from autocorrelation data.
+ * Looks for the first significant peak in the autocorrelation.
+ *
+ * @param {AutocorrelationEntry[]} autocorrelation - Autocorrelation data
+ * @returns {PeriodCandidate|null} Best period candidate or null if none found
+ */
+export function findBestPeriodFromAutocorrelation(autocorrelation) {
+ if (!autocorrelation || !autocorrelation.length) {
+ return null;
+ }
+
+ // Find all local peaks (values higher than both neighbors)
+ const peaks = [];
+ for (let i = 1; i < autocorrelation.length - 1; i++) {
+ const isPeak = autocorrelation[i].val > autocorrelation[i - 1].val &&
+ autocorrelation[i].val >= autocorrelation[i + 1].val;
+
+ if (isPeak) {
+ peaks.push(autocorrelation[i]);
+ }
+ }
+
+ if (!peaks.length) {
+ return null;
+ }
+
+ // Sort by value (strongest peaks first)
+ peaks.sort((a, b) => b.val - a.val);
+
+ // Take top peaks and sort by lag (prefer smaller periods = fundamental frequency)
+ const topPeaks = peaks.slice(0, 5).sort((a, b) => a.lag - b.lag);
+ const bestPeak = topPeaks[0];
+
+ return {
+ value: bestPeak.lag,
+ score: bestPeak.val
+ };
+}
+
+/**
+ * Combine period candidates from X and Y axis analysis.
+ * Uses the more confident estimate, or averages if both agree.
+ *
+ * @param {PeriodCandidate|null} periodX - X-axis period candidate
+ * @param {PeriodCandidate|null} periodY - Y-axis period candidate
+ * @returns {number|null} Combined period estimate or null
+ */
+export function combinePeriodCandidates(periodX, periodY) {
+ if (periodX && periodY) {
+ // If both axes agree (within 2 pixels), average them for better accuracy
+ if (Math.abs(periodX.value - periodY.value) <= 2) {
+ return (periodX.value + periodY.value) / 2;
+ }
+ // Otherwise, use the one with higher confidence score
+ return periodX.score >= periodY.score ? periodX.value : periodY.value;
+ }
+
+ // Use whichever axis gave a result
+ if (periodX) return periodX.value;
+ if (periodY) return periodY.value;
+
+ return null;
+}
+
+/**
+ * Estimate the optimal grid offset from a projection signal.
+ * Finds the shift value that best aligns with periodic peaks.
+ *
+ * @param {Float32Array} signal - Normalized projection signal
+ * @param {number} period - Detected grid period
+ * @returns {number} Optimal offset value (0 to period-1)
+ */
+export function estimateGridOffset(signal, period) {
+ if (!period || period < 2) {
+ return 0;
+ }
+
+ const length = signal.length;
+ let bestOffset = 0;
+ let bestScore = -Infinity;
+
+ // Normalize signal for fair scoring
+ let maxValue = -Infinity;
+ for (const value of signal) {
+ if (value > maxValue) maxValue = value;
+ }
+ const normalizer = maxValue ? 1 / maxValue : 1;
+
+ // Try each possible offset and find the one with highest sum at periodic intervals
+ for (let offset = 0; offset < period; offset++) {
+ let sum = 0;
+ let count = 0;
+
+ // Sum signal values at periodic intervals starting from this offset
+ for (let i = offset; i < length; i += period) {
+ sum += signal[i] * normalizer;
+ count++;
+ }
+
+ const score = count ? sum / count : -Infinity;
+
+ if (score > bestScore) {
+ bestScore = score;
+ bestOffset = offset;
+ }
+ }
+
+ return bestOffset;
+}
+
+/**
+ * Clamp a numeric value to a specified range.
+ *
+ * @param {number} value - The value to clamp
+ * @param {number} min - Minimum allowed value
+ * @param {number} max - Maximum allowed value
+ * @returns {number} The clamped value
+ */
+export function clampValue(value, min, max) {
+ return value < min ? min : value > max ? max : value;
+}
diff --git a/scripts/quick-battlemap.js b/scripts/quick-battlemap.js
new file mode 100644
index 0000000..25b9a5e
--- /dev/null
+++ b/scripts/quick-battlemap.js
@@ -0,0 +1,139 @@
+/**
+ * Quick Battlemap Importer - Main Entry Point
+ *
+ * This module provides a streamlined way to import battlemaps into Foundry VTT.
+ * It adds a "Quick import" button to the Scenes sidebar that opens a drag-and-drop
+ * panel for creating scenes from images/videos and optional JSON configuration files.
+ *
+ * @module QuickBattlemap
+ * @author Myxelium
+ * @license MIT
+ */
+
+import { SceneImportController } from './lib/scene-import-controller.js';
+
+/** @type {SceneImportController|null} Singleton instance of the import controller */
+let sceneImportController = null;
+
+/**
+ * Module identifier used for logging and namespacing
+ * @constant {string}
+ */
+const MODULE_ID = 'Quick Battlemap';
+
+/**
+ * CSS class name for the quick import button to prevent duplicate insertion
+ * @constant {string}
+ */
+const QUICK_IMPORT_BUTTON_CLASS = 'quick-battlemap-quick-import';
+
+/**
+ * Initialize the module when Foundry is ready.
+ * Sets up the import controller and registers necessary handlers.
+ */
+Hooks.once('init', async function () {
+ console.log(`${MODULE_ID} | Initializing module`);
+});
+
+/**
+ * Complete module setup after Foundry is fully loaded.
+ * Creates the controller instance and displays a ready notification.
+ */
+Hooks.once('ready', async function () {
+ console.log(`${MODULE_ID} | Module ready`);
+
+ sceneImportController = new SceneImportController();
+ sceneImportController.initialize();
+
+ ui.notifications.info(game.i18n.localize("QUICKBATTLEMAP.Ready"));
+});
+
+/**
+ * Add the "Quick import" button to the Scenes directory header.
+ * This hook fires whenever the SceneDirectory is rendered.
+ *
+ * @param {Application} _app - The SceneDirectory application instance (unused)
+ * @param {jQuery|HTMLElement} html - The rendered HTML element
+ */
+Hooks.on('renderSceneDirectory', (_app, html) => {
+ // Only GMs can use the quick import feature
+ if (!game.user?.isGM) {
+ return;
+ }
+
+ // Handle different HTML element formats across Foundry versions
+ const rootElement = html?.[0] || html?.element || html;
+
+ if (!(rootElement instanceof HTMLElement)) {
+ return;
+ }
+
+ // Prevent adding duplicate buttons
+ const existingButton = rootElement.querySelector(`button.${QUICK_IMPORT_BUTTON_CLASS}`);
+ if (existingButton) {
+ return;
+ }
+
+ // Find a suitable container for the button
+ const buttonContainer = findButtonContainer(rootElement);
+ if (!buttonContainer) {
+ return;
+ }
+
+ // Create and append the quick import button
+ const quickImportButton = createQuickImportButton();
+ buttonContainer.appendChild(quickImportButton);
+});
+
+/**
+ * Find a suitable container element for the quick import button.
+ * Tries multiple selectors for compatibility across Foundry versions.
+ *
+ * @param {HTMLElement} rootElement - The root element to search within
+ * @returns {HTMLElement|null} The container element or null if not found
+ */
+function findButtonContainer(rootElement) {
+ const containerSelectors = [
+ '.header-actions',
+ '.action-buttons',
+ '.directory-header'
+ ];
+
+ for (const selector of containerSelectors) {
+ const container = rootElement.querySelector(selector);
+ if (container) {
+ return container;
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Create the quick import button element with icon and click handler.
+ *
+ * @returns {HTMLButtonElement} The configured button element
+ */
+function createQuickImportButton() {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = QUICK_IMPORT_BUTTON_CLASS;
+ button.innerHTML = ' Quick import ';
+
+ button.addEventListener('click', handleQuickImportClick);
+
+ return button;
+}
+
+/**
+ * Handle click events on the quick import button.
+ * Creates the controller if needed and shows the import panel.
+ */
+function handleQuickImportClick() {
+ if (!sceneImportController) {
+ sceneImportController = new SceneImportController();
+ sceneImportController.initialize();
+ }
+
+ sceneImportController.showImportPanel();
+}
diff --git a/styles/easy-battlemap.css b/styles/easy-battlemap.css
deleted file mode 100644
index 6668030..0000000
--- a/styles/easy-battlemap.css
+++ /dev/null
@@ -1,48 +0,0 @@
-#easy-battlemap-drop-area {
- margin-bottom: 10px;
- background: rgba(0, 0, 0, 0.1);
- border: 1px solid #7a7971;
- border-radius: 5px;
-}
-
-#easy-battlemap-drop-area header {
- background: rgba(0, 0, 0, 0.2);
- padding: 5px;
- border-bottom: 1px solid #7a7971;
- text-align: center;
-}
-
-.easy-battlemap-dropzone {
- padding: 15px;
- text-align: center;
- min-height: 120px;
- display: flex;
- flex-direction: column;
- gap: 10px;
-}
-
-.easy-battlemap-instructions {
- font-style: italic;
- color: #777;
- margin-bottom: 10px;
-}
-
-.easy-battlemap-status {
- display: flex;
- justify-content: space-around;
- margin: 10px 0;
-}
-
-.easy-battlemap-status .status-value {
- font-weight: bold;
-}
-
-.create-scene-button {
- width: 80%;
- margin: 0 auto;
-}
-
-#easy-battlemap-drop-area.highlight {
- border-color: #ff6400;
- background-color: rgba(255, 100, 0, 0.1);
-}
\ No newline at end of file
diff --git a/styles/quick-battlemap.css b/styles/quick-battlemap.css
new file mode 100644
index 0000000..22a4bb4
--- /dev/null
+++ b/styles/quick-battlemap.css
@@ -0,0 +1,64 @@
+/* Keep it minimal and lean on Foundry's built-in styles */
+#quick-battlemap-drop-area .window-header {
+ user-select: none;
+ cursor: move;
+}
+
+#quick-battlemap-drop-area .quick-battlemap-instructions {
+ font-style: italic;
+ margin: 0 0 0.5rem 0;
+ border-bottom: 1px solid var(--color-border-light-primary);
+}
+
+#quick-battlemap-drop-area .status-value {
+ font-weight: bold;
+}
+
+#quick-battlemap-drop-area .create-scene-button {
+ margin-left: auto;
+}
+
+/* Drag-and-drop highlight */
+#quick-battlemap-drop-area.highlight .window-content {
+ outline: 2px solid var(--color-border-highlight, #ff6400);
+ background-color: rgba(255, 100, 0, 0.08);
+}
+
+/* Spinner row */
+#quick-battlemap-drop-area .ebm-progress-row {
+ align-items: center;
+ gap: 8px;
+}
+
+#quick-battlemap-drop-area .ebm-spinner {
+ width: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-primary, #ff6400);
+}
+
+#quick-battlemap-drop-area .ebm-no-grid {
+ justify-content: unset !important;
+}
+
+#quick-battlemap-drop-area .ebm-no-grid input {
+ margin: 0px 0px !important;
+}
+
+#quick-battlemap-drop-area .area {
+ width: 100%;
+ padding: 15px;
+ border: 1px solid #333;
+ background: rgba(0, 0, 0, 0.7);
+}
+
+#quick-battlemap-drop-area #dropZone {
+ border: 2px dashed #bbb;
+ -webkit-border-radius: 5px;
+ border-radius: 5px;
+ padding: 50px;
+ text-align: center;
+ font: 21pt bold arial;
+ color: #bbb;
+}
\ No newline at end of file