local PLUGIN_NAME = 'CSV Stage Builder' local DEFAULT_FILE = 'Stage-xyz.csv' local REPORT_FILE = 'CSV_Stage_Builder_Report.txt' local function echo(text) if gma and gma.echo then gma.echo('[' .. PLUGIN_NAME .. '] ' .. tostring(text)) end end local function feedback(text) echo(text) if gma and gma.feedback then gma.feedback('[' .. PLUGIN_NAME .. '] ' .. tostring(text)) end end local function popup(title, text) feedback(text) if gma and gma.gui and gma.gui.msgbox then gma.gui.msgbox(tostring(title or PLUGIN_NAME), tostring(text or '')) end end local function trim(text) text = tostring(text or '') return (text:gsub('^%s+', ''):gsub('%s+$', '')) end local function normalize_filename(text) return trim(text):lower() end local function strip_bom(text) return tostring(text or ''):gsub('^\239\187\191', '') end local function normalize_header(text) text = trim(text):lower() text = text:gsub('/.*$', '') text = text:gsub('[%s_%-%(%)]', '') return text end local function count_char(text, char) local _, count = tostring(text or ''):gsub('%' .. char, '') return count end local function detect_delimiter(line) local comma = count_char(line, ',') local semicolon = count_char(line, ';') local tab = count_char(line, '\t') if tab >= comma and tab >= semicolon and tab > 0 then return '\t' end if semicolon > comma then return ';' end return ',' end local function split_csv_line(line, delimiter) local fields = {} local field = '' local in_quotes = false local i = 1 line = tostring(line or '') while i <= #line do local char = line:sub(i, i) if char == '"' then if in_quotes and line:sub(i + 1, i + 1) == '"' then field = field .. '"' i = i + 1 else in_quotes = not in_quotes end elseif char == delimiter and not in_quotes then fields[#fields + 1] = trim(field) field = '' else field = field .. char end i = i + 1 end fields[#fields + 1] = trim(field) return fields end local function parse_number(value) value = trim(value) value = value:gsub(',', '.') value = value:gsub('[mM]$', '') value = trim(value) return tonumber(value) end local function parse_fixture_id(value) value = trim(value) value = value:gsub('^[Ff][Ii][Xx][Tt][Uu][Rr][Ee]%s+', '') value = value:gsub('^[Ff][Ii][Xx]%s+', '') value = trim(value) if value:match('^%d+%.?%d*$') then return value end return nil end local function build_import_path() local base = '' if gma and gma.show and gma.show.getvar then base = gma.show.getvar('PATH') or '' end if base == '' then return '' end return base .. '/importexport/' end local function file_exists(path) local file = io.open(path, 'r') if file then file:close() return true end return false end local function resolve_csv_path(filename) filename = trim(filename) if filename == '' then return nil end local import_path = build_import_path() local candidates = {} candidates[#candidates + 1] = import_path .. filename if not filename:lower():match('%.csv$') then candidates[#candidates + 1] = import_path .. filename .. '.csv' end candidates[#candidates + 1] = filename if not filename:lower():match('%.csv$') then candidates[#candidates + 1] = filename .. '.csv' end for _, path in ipairs(candidates) do if file_exists(path) then return path, import_path end end return nil, import_path end local function header_index(headers) local map = {} for index, value in ipairs(headers) do local key = normalize_header(value) if key == 'fixture' or key == 'fixtureid' or key == 'fid' or key == 'id' or key == 'no' or key == 'number' then map.fixture = index elseif key == 'x' or key == 'xm' or key == 'xmeter' or key == 'xmetre' or key == 'posx' or key == 'posxm' or key == 'positionx' or key == 'stagex' then map.x = index elseif key == 'y' or key == 'ym' or key == 'ymeter' or key == 'ymetre' or key == 'posy' or key == 'posym' or key == 'positiony' or key == 'stagey' then map.y = index elseif key == 'z' or key == 'zm' or key == 'zmeter' or key == 'zmetre' or key == 'posz' or key == 'poszm' or key == 'positionz' or key == 'stagez' then map.z = index elseif key == 'rx' or key == 'rxdeg' or key == 'rotx' or key == 'rotxdeg' or key == 'rotatex' or key == 'rotatexdeg' or key == 'rotationx' or key == 'rotationxdeg' or key == 'stagerotx' then map.rx = index elseif key == 'ry' or key == 'rydeg' or key == 'roty' or key == 'rotydeg' or key == 'rotatey' or key == 'rotateydeg' or key == 'rotationy' or key == 'rotationydeg' or key == 'stageroty' then map.ry = index elseif key == 'rz' or key == 'rzdeg' or key == 'rotz' or key == 'rotzdeg' or key == 'rotatez' or key == 'rotatezdeg' or key == 'rotationz' or key == 'rotationzdeg' or key == 'stagerotz' then map.rz = index end end if map.fixture and map.x and map.y and map.z then return map end return nil end local function read_csv(path) local file = io.open(path, 'r') if not file then return nil, 'Cannot open CSV: ' .. tostring(path) end local lines = {} for line in file:lines() do line = strip_bom(line) if trim(line) ~= '' and not trim(line):match('^#') then lines[#lines + 1] = line end end file:close() if #lines == 0 then return nil, 'CSV is empty' end local delimiter = detect_delimiter(lines[1]) local first = split_csv_line(lines[1], delimiter) local map = header_index(first) local start_line = 1 if map then start_line = 2 else map = {fixture = 1, x = 2, y = 3, z = 4} end local rows = {} local errors = {} for line_number = start_line, #lines do local fields = split_csv_line(lines[line_number], delimiter) local fixture_id = parse_fixture_id(fields[map.fixture]) local x = parse_number(fields[map.x]) local y = parse_number(fields[map.y]) local z = parse_number(fields[map.z]) local has_rotation_columns = map.rx or map.ry or map.rz local rx = map.rx and parse_number(fields[map.rx]) or nil local ry = map.ry and parse_number(fields[map.ry]) or nil local rz = map.rz and parse_number(fields[map.rz]) or nil if fixture_id and x and y and z then local row = {line = line_number, fixture_id = fixture_id, x = x, y = y, z = z} if rx and ry and rz then row.rx = rx row.ry = ry row.rz = rz row.has_rotation = true elseif has_rotation_columns then errors[#errors + 1] = 'Line ' .. line_number .. ' rotation skipped: need RotX,RotY,RotZ' end rows[#rows + 1] = row else errors[#errors + 1] = 'Line ' .. line_number .. ' skipped: need FixtureID,X,Y,Z' end end return rows, nil, errors end local function format_number(value) local text = string.format('%.4f', value) text = text:gsub('0+$', '') text = text:gsub('%.$', '') if text == '-0' then text = '0' end return text end local function safe_cmd(command) feedback(command) gma.cmd(command) end local function write_report(import_path, source_path, scale, rows, errors) if import_path == '' then return end local path = import_path .. REPORT_FILE local file = io.open(path, 'w') if not file then return end file:write(PLUGIN_NAME .. '\n') file:write('Source: ' .. tostring(source_path) .. '\n') file:write('Scale: ' .. tostring(scale) .. '\n') file:write('Applied rows: ' .. tostring(#rows) .. '\n') file:write('Skipped rows: ' .. tostring(#errors) .. '\n\n') for _, row in ipairs(rows) do local rotation = '' if row.has_rotation then rotation = ' rotate ' .. row.rx .. ', ' .. row.ry .. ', ' .. row.rz end file:write('Fixture ' .. row.fixture_id .. ' -> ' .. row.x .. ', ' .. row.y .. ', ' .. row.z .. rotation .. '\n') end if #errors > 0 then file:write('\nErrors:\n') for _, message in ipairs(errors) do file:write(message .. '\n') end end file:close() end local function run() local last_file = '' if gma and gma.show and gma.show.getvar then last_file = gma.show.getvar('CSVStageBuilderFile') or '' end local normalized_last_file = normalize_filename(last_file) if last_file == '' or normalized_last_file == 'stage_xyz_template.csv' then last_file = DEFAULT_FILE end local filename = gma.textinput('CSV file in gma2/importexport', last_file) if not filename then feedback('Canceled') return end filename = trim(filename) if filename == '' then popup('CSV Stage Builder', 'Canceled: empty filename') return end local scale_text = gma.textinput('Scale factor (1 = use CSV values unchanged)', '1') if not scale_text then feedback('Canceled') return end local scale = parse_number(scale_text) or 1 local path, import_path = resolve_csv_path(filename) if not path then popup('CSV not found', 'Cannot find "' .. filename .. '". Put the CSV in:\n' .. import_path .. '\n\nCSV header should be:\nFixtureID,X,Y,Z') return end local rows, err, errors = read_csv(path) errors = errors or {} if err then popup('CSV error', err) return end if #rows == 0 then popup('No valid rows', 'No valid rows found in:\n' .. path .. '\n\nCSV header should be:\nFixtureID,X,Y,Z') write_report(import_path, path, scale, rows, errors) return end if gma and gma.show and gma.show.setvar then gma.show.setvar('CSVStageBuilderFile', filename) end feedback('Applying ' .. tostring(#rows) .. ' fixture positions') safe_cmd('ClearSelection') for _, row in ipairs(rows) do local x = format_number(row.x * scale) local y = format_number(row.y * scale) local z = format_number(row.z * scale) safe_cmd('Fixture ' .. row.fixture_id) safe_cmd('Move3D At ' .. x .. ' ' .. y .. ' ' .. z) if row.has_rotation then local rx = format_number(row.rx) local ry = format_number(row.ry) local rz = format_number(row.rz) safe_cmd('Rotate3D At ' .. rx .. ' ' .. ry .. ' ' .. rz) end safe_cmd('ClearSelection') end write_report(import_path, path, scale, rows, errors) popup('CSV Stage Builder Done', 'Applied ' .. tostring(#rows) .. ' fixture positions.\nSkipped ' .. tostring(#errors) .. ' rows.\n\nScale factor: ' .. tostring(scale) .. '\nSource:\n' .. path) end return run