/** * 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 || [], sourceData), 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. * Filters out regular walls that share exact coordinates with doors to prevent * duplicate walls blocking door functionality. * * @param {Array} wallsArray - Array of raw wall data objects * @returns {NormalizedWallData[]} Array of normalized wall documents */ normalizeWallsData(wallsArray) { const normalizedWalls = wallsArray.map(wall => this.normalizeWall(wall)); return this.filterDuplicateWallsAtDoorLocations(normalizedWalls); } /** * Filter out regular walls that have the same coordinates as doors. * Some map exports include both a wall segment and a door at the same location, * which causes the door to be blocked by the overlapping wall. * * @param {NormalizedWallData[]} walls - Array of normalized wall data * @returns {NormalizedWallData[]} Filtered array with duplicate walls removed */ filterDuplicateWallsAtDoorLocations(walls) { // Collect coordinates of all doors const doorCoordinates = new Set(); for (const wall of walls) { if (wall.door > 0 && Array.isArray(wall.c) && wall.c.length >= 4) { // Store both forward and reverse coordinate strings to handle different orderings const coordKey = wall.c.slice(0, 4).join(','); const reverseKey = [wall.c[2], wall.c[3], wall.c[0], wall.c[1]].join(','); doorCoordinates.add(coordKey); doorCoordinates.add(reverseKey); } } // If no doors, return walls as-is if (doorCoordinates.size === 0) { return walls; } // Filter out regular walls (door === 0) that share coordinates with doors return walls.filter(wall => { // Keep all doors if (wall.door > 0) { return true; } // Check if this regular wall overlaps with a door location if (Array.isArray(wall.c) && wall.c.length >= 4) { const coordKey = wall.c.slice(0, 4).join(','); if (doorCoordinates.has(coordKey)) { return false; // Remove this wall - there's a door here } } return true; }); } /** * 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(); const doorType = this.ensureFiniteNumber(wall.door, 0); // Doors should default to NORMAL restrictions (blocking) when closed, // while regular walls default to NONE (the source data usually specifies restrictions) const isDoor = doorType > 0; const defaultRestriction = isDoor ? restrictionTypes.NORMAL : restrictionTypes.NONE; return { c: this.normalizeWallCoordinates(wall.c), door: doorType, ds: this.ensureFiniteNumber(wall.ds, 0), dir: this.ensureFiniteNumber(wall.dir, 0), move: this.parseRestrictionValue(wall.move, defaultRestriction, restrictionTypes), sound: this.parseRestrictionValue(wall.sound, defaultRestriction, restrictionTypes), sight: this.parseRestrictionValue(wall.sense ?? wall.sight, defaultRestriction, restrictionTypes), light: this.parseRestrictionValue(wall.light, defaultRestriction, 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 * @param {Object} sourceData - Source scene data for context (grid settings, etc.) * @returns {NormalizedLightData[]} Array of normalized light documents */ normalizeLightsData(lightsArray, sourceData = {}) { // Calculate the conversion factor from source units to grid units // Dungeon Alchemist exports light radii in map units (e.g., feet) // Foundry expects radii in grid units (number of grid squares) const gridDistance = sourceData.gridDistance ?? sourceData.grid?.distance ?? GRID_DEFAULTS.DISTANCE; return lightsArray.map(light => this.normalizeLight(light, gridDistance)); } /** * Normalize a single light object to Foundry's expected format. * * @param {Object} light - Raw light data * @param {number} gridDistance - Distance per grid cell for unit conversion * @returns {NormalizedLightData} Normalized light document */ normalizeLight(light, gridDistance = GRID_DEFAULTS.DISTANCE) { // Convert light radii from map units (feet) to grid units // e.g., 33.75 feet / 5 feet per grid = 6.75 grid units const brightRadius = this.convertToGridUnits(light.bright, gridDistance); const dimRadius = this.convertToGridUnits(light.dim, gridDistance); return { x: Number(light.x), y: Number(light.y), rotation: 0, hidden: false, walls: true, vision: false, config: { alpha: Number(light.tintAlpha ?? 0.5), color: light.tintColor ?? null, bright: brightRadius, dim: dimRadius, angle: 360 } }; } /** * Convert a distance value from map units to grid units. * * @param {number|undefined} value - The value in map units (e.g., feet) * @param {number} gridDistance - Distance per grid cell * @returns {number} The value in grid units */ convertToGridUnits(value, gridDistance) { const numValue = Number(value); if (!Number.isFinite(numValue) || numValue <= 0) { return 0; } // Avoid division by zero if (!Number.isFinite(gridDistance) || gridDistance <= 0) { return numValue; } return numValue / gridDistance; } /** * 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; } }