diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 4964453..d55d067 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -25,11 +25,24 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install DXF acceptance dependency + run: python -m pip install -r v3/tools/requirements-dxf.txt - name: Validate application source and engine run: node v3/tools/check-fs.js --no-browser + - name: Validate browser workflow and DXF round trip + env: + FS_HEADLESS: '1' + run: node v3/tools/check-fs.js + - name: Validate deployment configuration run: | node -e "JSON.parse(require('fs').readFileSync('vercel.json', 'utf8')); console.log('vercel.json: valid')" diff --git a/README.md b/README.md index 89c93d3..9f36568 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ FutolStructure is a browser-based structural engineering workbench for reinforce Security

-[![Build](https://img.shields.io/badge/build-v3.16.116-2563eb)](https://futolstructure.vercel.app) +[![Build](https://img.shields.io/badge/build-v3.16.117-2563eb)](https://futolstructure.vercel.app) [![Validation](https://github.com/michaelfutol/futolstructure/actions/workflows/validate.yml/badge.svg)](https://github.com/michaelfutol/futolstructure/actions/workflows/validate.yml) ![Platform](https://img.shields.io/badge/platform-browser-0f766e) ![Maturity](https://img.shields.io/badge/maturity-technical%20preview-f59e0b) @@ -64,7 +64,7 @@ FutolStructure uses a shared active-model payload so geometry counts and member | Target | Current status | | --- | --- | -| DXF | Every floor exports layout and tributary plans, plus foundation/base reactions, synchronized schedules, load summary, and preliminary BOQ tables on governed structural layers. | +| DXF | AutoCAD R12 ASCII (`AC1009`) exports every floor layout and tributary plan, foundation/base reactions, synchronized schedules, load summary, and preliminary BOQ tables on governed structural layers. Output is parser-audited and round-trip checked with `ezdxf`; the recovered Olango package also opens in AutoCAD 2025 Core Engine without recovery. | | IFC2x3 | Active columns, beams, slabs, and storey organization are exported for BIM review. | | STAAD.Pro | The gravity baseline, frame/plate geometry, beam insertion offsets, and statics balance were validated in STAAD.Pro 2024. | | ETABS 22 | The OAPI builder creates a dated working copy, assigns the governed mass baseline, runs modal analysis, and exports audit artifacts. | @@ -95,10 +95,17 @@ node v3/tools/check-fs.js --no-browser Run the full browser smoke check with Chrome or Edge installed: ```bash +python -m pip install -r v3/tools/requirements-dxf.txt node v3/tools/check-fs.js ``` -The browser smoke covers initialization, plan geometry, slab ownership, cantilever behavior, persistence and recovery guards, member locking, measurement tools, stair persistence, 3D rendering, coordinated DXF package completeness, and export payload parity. +The browser smoke covers initialization, plan geometry, slab ownership, cantilever behavior, persistence and recovery guards, member locking, measurement tools, stair persistence, 3D rendering, coordinated DXF completeness, strict DXF open/audit/save/reopen validation, and export payload parity. + +Where AutoCAD 2025 is installed, run the native read gate against a generated package without modifying it: + +```powershell +powershell -File v3/tools/check-dxf-autocad.ps1 -DxfPath "path/to/package.dxf" +``` ## Repository Layout diff --git a/docs/FS-117_RELEASE_NOTES.md b/docs/FS-117_RELEASE_NOTES.md new file mode 100644 index 0000000..3f24330 --- /dev/null +++ b/docs/FS-117_RELEASE_NOTES.md @@ -0,0 +1,31 @@ +# FS-117 DXF R12 Hotfix + +## Scope + +FS-117 repairs the coordinated DXF package envelope without changing structural model geometry, analysis behavior, schedules, or BOQ generation. + +## DXF Contract + +- AutoCAD R12 ASCII (`AC1009`). +- Windows-safe CRLF line endings and a valid EOF terminator. +- Mandatory layer `0`. +- Standard `BYBLOCK`, `BYLAYER`, and `CONTINUOUS` linetypes. +- Corrected `CENTER2` pattern length and `HIDDEN2` support. +- `txt.shx` standard text style. +- No R2000-only layer lineweight group `370`. +- No unsupported R12 linetype alignment group `74`. + +## Acceptance + +Release acceptance requires: + +1. Source and full browser regression. +2. Current three-floor Olango project regression. +3. Strict `ezdxf` open, audit, save, and reopen with zero errors or fixes. +4. Exact layer and entity retention after round trip. +5. AutoCAD 2025 Core Engine open/regenerate without recovery. +6. Visual inspection of the complete coordinated drawing package. + +## Exclusions + +Protected Project Revisions, historical helper scripts, broad UI changes, solver changes, and unrelated modeling changes are not part of FS-117. diff --git a/docs/RELEASE_HYGIENE.md b/docs/RELEASE_HYGIENE.md index 7cdddf4..ae00444 100644 --- a/docs/RELEASE_HYGIENE.md +++ b/docs/RELEASE_HYGIENE.md @@ -20,7 +20,11 @@ Use this checklist before pushing a public FutolStructure update. - `v3/assets/futolstructure-icon.png` - `v3/assets/screenshots/` - `v3/engine/` +- `v3/dxf-export.js` - `v3/tools/check-fs.js` +- `v3/tools/check-dxf-autocad.ps1` +- `v3/tools/validate-dxf.py` +- `v3/tools/requirements-dxf.txt` - `docs/` - deployment config such as `vercel.json` @@ -35,9 +39,16 @@ git diff --check For browser-sensitive changes, also run: ```bash +python -m pip install -r v3/tools/requirements-dxf.txt node v3/tools/check-fs.js ``` +For a DXF release on a workstation with AutoCAD 2025: + +```powershell +powershell -File v3/tools/check-dxf-autocad.ps1 -DxfPath "path/to/package.dxf" +``` + ## If The Working Tree Is Dirty This repo often has historical or recovery edits. Do not stage everything blindly. diff --git a/v3/dxf-export.js b/v3/dxf-export.js index f34250e..502449d 100644 --- a/v3/dxf-export.js +++ b/v3/dxf-export.js @@ -1,7 +1,7 @@ (function () { 'use strict'; - const DXF_PACKAGE_BUILD = 'FS-116'; + const DXF_PACKAGE_BUILD = 'FS-117'; const DXF_TEXT_LAYER = 'S-TEXT'; const GRID_LETTERS = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; @@ -122,16 +122,11 @@ } function generatePackageLinetypeTable() { - let dxf = '0\nTABLE\n2\nLTYPE\n70\n3\n'; - dxf += '0\nLTYPE\n2\nCONTINUOUS\n70\n0\n3\nSolid line\n72\n65\n73\n0\n40\n0.0\n'; - dxf += '0\nLTYPE\n2\nCENTER2\n70\n0\n3\nCenter ____ _ ____ _ ____\n72\n65\n73\n4\n40\n1.25\n49\n0.75\n74\n0\n49\n-0.125\n74\n0\n49\n0.125\n74\n0\n49\n-0.125\n74\n0\n'; - dxf += '0\nLTYPE\n2\nHIDDEN2\n70\n0\n3\nHidden __ __ __ __\n72\n65\n73\n2\n40\n0.5\n49\n0.25\n74\n0\n49\n-0.25\n74\n0\n'; - dxf += '0\nENDTAB\n'; - return dxf; + return generateDXFLinetypeTable(); } function generatePackageTextStyleTable() { - return '0\nTABLE\n2\nSTYLE\n70\n1\n0\nSTYLE\n2\nSTANDARD\n70\n0\n40\n0\n41\n1\n50\n0\n71\n0\n42\n0.2\n3\ntxt\n4\n\n0\nENDTAB\n'; + return generateDXFTextStyleTable(); } function getGridCoordinates() { @@ -730,6 +725,8 @@ const modelRows = [ { item: 'Build', value: DXF_PACKAGE_BUILD }, + { item: 'FSTR schema', value: window.getProjectProvenance?.().fstrSchemaVersion || 'unknown' }, + { item: 'Source revision', value: window.getProjectProvenance?.().sourceRevisionId || 'unsaved working state' }, { item: 'Floors', value: state.floors?.length || 0 }, { item: 'Grid', value: `${state.xSpans?.length || 0}x${state.ySpans?.length || 0}` }, { item: 'Concrete', value: `fc'=${finite(state.fc, 21)} MPa` }, @@ -830,9 +827,7 @@ ? writer.bounds : { minX: 0, minY: 0, maxX: 100, maxY: 100 }; let dxf = '0\nSECTION\n2\nHEADER\n'; - dxf += '9\n$ACADVER\n1\nAC1015\n'; - dxf += '9\n$INSUNITS\n70\n6\n'; - dxf += '9\n$MEASUREMENT\n70\n1\n'; + dxf += '9\n$ACADVER\n1\nAC1009\n'; dxf += `9\n$EXTMIN\n10\n${fixed(bounds.minX)}\n20\n${fixed(bounds.minY)}\n30\n0\n`; dxf += `9\n$EXTMAX\n10\n${fixed(bounds.maxX)}\n20\n${fixed(bounds.maxY)}\n30\n0\n`; dxf += '0\nENDSEC\n'; @@ -844,7 +839,7 @@ dxf += '0\nSECTION\n2\nENTITIES\n'; dxf += writer.content(); dxf += '0\nENDSEC\n0\nEOF\n'; - return dxf; + return dxf.replace(/\r?\n/g, '\r\n'); } function generateCoordinatedDXFContent() { @@ -893,7 +888,9 @@ audit.entityCounts = { ...writer.entityCounts }; audit.layerUsage = { ...writer.layerUsage }; audit.bytes = new TextEncoder().encode(dxf).length; - audit.validTerminator = dxf.endsWith('0\nEOF\n'); + audit.dxfVersion = 'AC1009'; + audit.lineEnding = 'CRLF'; + audit.validTerminator = dxf.endsWith('0\r\nEOF\r\n'); audit.requiredLayerEntities = [DXF_LAYER.GRID, DXF_LAYER.TEXT, DXF_LAYER.COLUMN, DXF_LAYER.BEAM, DXF_LAYER.SLAB] .every(layer => (writer.layerUsage[layer] || 0) > 0); window.lastDXFExportAudit = audit; diff --git a/v3/index.html b/v3/index.html index 9475571..8e16f5a 100644 --- a/v3/index.html +++ b/v3/index.html @@ -1821,7 +1821,7 @@ FutolStructure FutolStructure - v3.16.116 +
@@ -3915,6 +3915,67 @@

{ - dxf += `0\nLAYER\n2\n${layer.name}\n70\n0\n62\n${layer.colorIndex}\n6\n${layer.linetype}\n370\n${getDxfLineweightCode(layer.lineweightMm)}\n`; + dxf += `0\nLAYER\n2\n${layer.name}\n70\n0\n62\n${layer.colorIndex}\n6\n${layer.linetype}\n`; }); dxf += '0\nENDTAB\n'; return dxf; } + function generateDXFTextStyleTable() { + return '0\nTABLE\n2\nSTYLE\n70\n1\n0\nSTYLE\n2\nSTANDARD\n70\n0\n40\n0\n41\n1\n50\n0\n71\n0\n42\n0.2\n3\ntxt.shx\n4\n\n0\nENDTAB\n'; + } + // Update member dimensions from the table and sync with left panel function updateMemberDimensions() { // Read values from dimension table @@ -19566,6 +19635,7 @@

&1 + $exitCode = $LASTEXITCODE + $text = (($output -join [Environment]::NewLine) -replace "`0", '') + $rejectionPattern = 'Invalid or incomplete DXF|Unknown group|drawing discarded|ErrorStatus=' + $rejected = $text -match $rejectionPattern + + if ($exitCode -ne 0 -or $rejected) { + $tail = ($text -split "`r?`n" | Select-Object -Last 30) -join [Environment]::NewLine + throw "AutoCAD Core Engine rejected the DXF (exit $exitCode).`n$tail" + } + + [pscustomobject]@{ + ok = $true + reader = 'AutoCAD 2025 Core Engine' + path = $resolvedDxf + exitCode = $exitCode + regeneratedModel = $text -match 'Regenerating model' + recoveryMessage = $text -match $rejectionPattern + } | ConvertTo-Json +} finally { + Remove-Item -LiteralPath $scriptPath -Force -ErrorAction SilentlyContinue +} diff --git a/v3/tools/check-fs.js b/v3/tools/check-fs.js index 48d3454..46bfa0d 100644 --- a/v3/tools/check-fs.js +++ b/v3/tools/check-fs.js @@ -5,11 +5,12 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const vm = require('vm'); -const { spawn, execFileSync } = require('child_process'); +const { spawn, spawnSync, execFileSync } = require('child_process'); const ROOT = path.resolve(__dirname, '..', '..'); const V3 = path.join(ROOT, 'v3'); const INDEX = path.join(V3, 'index.html'); +const DXF_VALIDATOR = path.join(V3, 'tools', 'validate-dxf.py'); const DEFAULT_PORT = Number(process.env.FS_CDP_PORT || 9234); const KEEP_BROWSER = process.env.FS_KEEP_BROWSER === '1' || process.argv.includes('--keep-browser'); const HIDDEN_GEOMETRY_POLICY = 'preserve-intentional-hidden-geometry'; @@ -40,12 +41,117 @@ function parseInlineScripts() { return { inlineScripts: scripts.length }; } +function checkReleaseManifest() { + const manifestPath = path.join(V3, 'release-manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const html = fs.readFileSync(INDEX, 'utf8'); + assert(manifest.appVersion === '3.16.117', 'Release manifest app version is stale', manifest); + assert(manifest.buildId === 'FS-117', 'Release manifest build ID is stale', manifest); + assert(manifest.releaseName === 'DXF R12 Hotfix', 'Release manifest name is stale', manifest); + assert(manifest.fstrSchemaVersion === '0.1.0', 'Release manifest FSTR schema is stale', manifest); + const allowUnstampedManifest = process.env.FS_ALLOW_UNSTAMPED_MANIFEST === '1'; + assert( + /^[0-9a-f]{40}$/.test(manifest.gitCommit || '') || (allowUnstampedManifest && !manifest.gitCommit), + 'Release manifest must contain the real FS-117 release commit SHA', + manifest + ); + assert(html.includes('v' + manifest.appVersion), 'Build badge does not match release manifest', manifest); + assert(html.includes(`const FSTR_BUILD_ID = '${manifest.buildId}'`), 'Runtime build ID does not match release manifest', manifest); + assert(!html.includes('persistence/project-revisions.js'), 'FS-117 must not include Protected Project Revisions'); + return manifest; +} + +function checkDxfSourceContract() { + const html = fs.readFileSync(INDEX, 'utf8'); + const writer = fs.readFileSync(path.join(V3, 'dxf-export.js'), 'utf8'); + const requirementsPath = path.join(V3, 'tools', 'requirements-dxf.txt'); + assert(!html.includes('AC1015') && !writer.includes('AC1015'), 'DXF source still advertises AutoCAD 2000 while using the R12 writer'); + assert(html.includes('AC1009') && writer.includes('AC1009'), 'DXF source does not advertise AutoCAD R12'); + assert(html.includes('BYLAYER') && html.includes('BYBLOCK') && html.includes('txt.shx'), 'DXF R12 standard table records are incomplete'); + assert(!html.includes('\\n370\\n') && !writer.includes('\\n370\\n'), 'DXF R12 writer still emits the R2000 lineweight group'); + assert(!html.includes('\\n74\\n') && !writer.includes('\\n74\\n'), 'DXF R12 writer still emits unsupported linetype alignment groups'); + assert(fs.existsSync(DXF_VALIDATOR), 'DXF strict validator is missing', { path: DXF_VALIDATOR }); + assert(fs.existsSync(requirementsPath) && fs.readFileSync(requirementsPath, 'utf8').includes('ezdxf'), 'DXF parser dependency is not pinned'); + return { + version: 'AC1009', + validator: path.relative(ROOT, DXF_VALIDATOR), + requirements: path.relative(ROOT, requirementsPath) + }; +} + function checkNodeSyntax(relativeFile) { execFileSync(process.execPath, ['--check', path.join(ROOT, relativeFile)], { stdio: 'pipe' }); } +let cachedDxfPython = null; + +function resolveDxfPython() { + if (cachedDxfPython) return cachedDxfPython; + const candidates = []; + if (process.env.FS_PYTHON) candidates.push({ command: process.env.FS_PYTHON, args: [] }); + if (process.platform === 'win32') { + candidates.push( + { command: 'py', args: ['-3.12'] }, + { command: 'py', args: ['-3.11'] }, + { command: 'python', args: [] } + ); + } else { + candidates.push({ command: 'python3', args: [] }, { command: 'python', args: [] }); + } + + const attempts = []; + for (const candidate of candidates) { + const probe = spawnSync(candidate.command, [...candidate.args, '-c', 'import ezdxf'], { + encoding: 'utf8', + windowsHide: true + }); + attempts.push({ + command: [candidate.command, ...candidate.args].join(' '), + status: probe.status, + error: probe.error?.message || '', + stderr: String(probe.stderr || '').trim() + }); + if (probe.status === 0) { + cachedDxfPython = candidate; + return candidate; + } + } + throw Object.assign(new Error('DXF acceptance requires Python with ezdxf installed.'), { details: attempts }); +} + +function validateDxfWithEzdxf(dxfContent, label = 'generated') { + assert(fs.existsSync(DXF_VALIDATOR), 'DXF parser validator is missing', { path: DXF_VALIDATOR }); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'futolstructure-dxf-')); + const dxfPath = path.join(tempDir, `${String(label).replace(/[^A-Za-z0-9_.-]+/g, '-')}.dxf`); + fs.writeFileSync(dxfPath, dxfContent, 'utf8'); + try { + const python = resolveDxfPython(); + const validation = spawnSync( + python.command, + [...python.args, DXF_VALIDATOR, dxfPath, '--expected-version', 'AC1009'], + { encoding: 'utf8', windowsHide: true } + ); + let audit = null; + try { + audit = JSON.parse(String(validation.stdout || '').trim()); + } catch (error) { + throw Object.assign(new Error('DXF parser validator did not return JSON.'), { + details: { + status: validation.status, + stdout: String(validation.stdout || '').trim(), + stderr: String(validation.stderr || '').trim() + } + }); + } + assert(validation.status === 0 && audit.ok === true, 'DXF failed strict parser or round-trip validation', audit); + return audit; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + function sortSlabsTopLeftForQa(a, b) { const ay = Math.min(Number(a?.y1) || 0, Number(a?.y2) || 0); const by = Math.min(Number(b?.y1) || 0, Number(b?.y2) || 0); @@ -119,7 +225,11 @@ function findChrome() { 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', - 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe' + 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/chromium', + '/usr/bin/chromium-browser' ].filter(Boolean); return candidates.find(candidate => fs.existsSync(candidate)); @@ -147,29 +257,48 @@ async function ensureBrowser(port) { } fs.mkdirSync(profileDir, { recursive: true }); - const child = spawn(browser, [ + const browserArgs = [ `--remote-debugging-port=${port}`, + '--remote-debugging-address=127.0.0.1', `--user-data-dir=${profileDir}`, '--no-first-run', '--disable-extensions', - '--disable-background-networking', - 'about:blank' - ], { + '--disable-background-networking' + ]; + if (process.env.CI === 'true' || process.env.CI === '1' || process.env.FS_HEADLESS === '1') { + browserArgs.push('--headless', '--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu'); + } + browserArgs.push('about:blank'); + + const child = spawn(browser, browserArgs, { detached: false, - stdio: 'ignore' + stdio: ['ignore', 'pipe', 'pipe'] }); - child.unref(); + let browserOutput = ''; + const captureOutput = chunk => { + browserOutput = `${browserOutput}${chunk.toString()}`.slice(-4000); + }; + child.stdout.on('data', captureOutput); + child.stderr.on('data', captureOutput); - for (let attempt = 0; attempt < 30; attempt += 1) { + for (let attempt = 0; attempt < 120; attempt += 1) { try { await fetchJson(`${base}/json/version`); return { base, process: child }; } catch (err) { + if (child.exitCode !== null) break; await wait(250); } } - throw new Error(`Browser did not open a CDP endpoint on port ${port}`); + const exitState = child.exitCode === null ? 'still running' : `exit ${child.exitCode}`; + if (child.exitCode === null) { + try { child.kill(); } catch (err) { /* noop */ } + } + throw new Error( + `Browser did not open a CDP endpoint on port ${port} (${browser}; ${exitState}).` + + (browserOutput.trim() ? `\n${browserOutput.trim()}` : '') + ); } class CdpTab { @@ -285,7 +414,8 @@ async function openAppTab(base) { async function waitForAppReady(tab) { let lastError = null; - for (let attempt = 0; attempt < 80; attempt += 1) { + let lastReady = null; + for (let attempt = 0; attempt < 240; attempt += 1) { try { const ready = await tab.evaluate(`(() => ({ body: !!document.body, @@ -295,6 +425,7 @@ async function waitForAppReady(tab) { hasCanvas: typeof canvas !== 'undefined' && !!canvas && canvas.width > 0 && canvas.height > 0, title: document.title || '' }))()`); + lastReady = ready; if (ready.body && ready.readyState === 'complete' && ready.hasState && ready.hasCalculate && ready.hasCanvas && ready.title.includes('FutolStructure')) { return; } @@ -303,7 +434,8 @@ async function waitForAppReady(tab) { } await wait(250); } - throw new Error(`App did not become ready for browser smoke check${lastError ? `: ${lastError.message}` : ''}`); + const diagnostic = lastReady ? ` Last state: ${JSON.stringify(lastReady)}` : ''; + throw new Error(`App did not become ready for browser smoke check${lastError ? `: ${lastError.message}` : ''}.${diagnostic}`); } async function runBrowserSmoke() { @@ -357,7 +489,7 @@ async function runBrowserSmoke() { const previousTab = currentPlanTab || 'structural'; if (typeof showSchedulesModal === 'function') showSchedulesModal(); const audit = { - buildBadge: Array.from(document.querySelectorAll('header span')).map(el => el.textContent.trim()).find(text => /^v3\\./.test(text)) || '', + buildBadge: document.getElementById('buildVersionBadge')?.textContent.trim() || '', rebuildButton: Array.from(document.querySelectorAll('.header-actions .tool-btn')).some(btn => btn.textContent.trim() === 'Rebuild'), etabsButton: Array.from(document.querySelectorAll('.header-actions .tool-btn')).some(btn => btn.textContent.trim() === 'ETABS'), etabsQaBadge: document.querySelectorAll('.header-actions .export-validation-badge').length, @@ -1397,6 +1529,7 @@ async function runBrowserSmoke() { const dxfLayerAudit = (() => { const dxf = generateDXFContent(); + const normalizedDxf = dxf.replace(/\\r\\n/g, '\\n'); const packageAudit = JSON.parse(JSON.stringify(window.lastDXFExportAudit || {})); const requiredLayers = [ 'S-CONC-FOUND', @@ -1431,7 +1564,7 @@ async function runBrowserSmoke() { const missingPlanTitles = floorIds.flatMap(floorId => [ floorId + ' - STRUCTURAL LAYOUT PLAN', floorId + ' - TRIBUTARY PLAN' - ]).filter(title => !dxf.includes('\\n1\\n' + title + '\\n')); + ]).filter(title => !normalizedDxf.includes('\\n1\\n' + title + '\\n')); const requiredTableTitles = [ 'DRAWING PACKAGE INDEX', 'MODEL SUMMARY', @@ -1444,20 +1577,28 @@ async function runBrowserSmoke() { ]; const layerChunk = (name) => { const marker = '0\\nLAYER\\n2\\n' + name + '\\n'; - const start = dxf.indexOf(marker); - return start >= 0 ? dxf.slice(start, start + 180) : ''; + const start = normalizedDxf.indexOf(marker); + return start >= 0 ? normalizedDxf.slice(start, start + 180) : ''; }; return { - missingRequiredLayers: requiredLayers.filter(name => !dxf.includes('\\n2\\n' + name + '\\n')), - missingEntityLayers: exportedEntityLayers.filter(name => !dxf.includes('\\n8\\n' + name + '\\n')), - legacyLayerHits: legacyLayers.filter(name => dxf.includes('\\n2\\n' + name + '\\n') || dxf.includes('\\n8\\n' + name + '\\n')), - hasCenter2Linetype: dxf.includes('\\n2\\nCENTER2\\n') && dxf.includes('\\n6\\nCENTER2\\n'), - hasHidden2Linetype: dxf.includes('\\n2\\nHIDDEN2\\n'), - hasFoundationPlanTitle: dxf.includes('\\n1\\nFOUNDATION PLAN\\n') || dxf.includes('\\n1\\nBASE REACTION PLAN\\n'), + missingRequiredLayers: requiredLayers.filter(name => !normalizedDxf.includes('\\n2\\n' + name + '\\n')), + missingEntityLayers: exportedEntityLayers.filter(name => !normalizedDxf.includes('\\n8\\n' + name + '\\n')), + legacyLayerHits: legacyLayers.filter(name => normalizedDxf.includes('\\n2\\n' + name + '\\n') || normalizedDxf.includes('\\n8\\n' + name + '\\n')), + hasR12Version: normalizedDxf.includes('9\\n$ACADVER\\n1\\nAC1009\\n'), + hasLayerZero: normalizedDxf.includes('0\\nLAYER\\n2\\n0\\n'), + hasByLayerByBlock: normalizedDxf.includes('\\n2\\nBYLAYER\\n') && normalizedDxf.includes('\\n2\\nBYBLOCK\\n'), + hasCenter2Linetype: normalizedDxf.includes('\\n2\\nCENTER2\\n') && normalizedDxf.includes('\\n6\\nCENTER2\\n'), + hasCorrectCenter2Length: normalizedDxf.includes('2\\nCENTER2\\n70\\n0\\n3\\nCenter ____ _ ____ _ ____\\n72\\n65\\n73\\n4\\n40\\n1.125\\n'), + hasHidden2Linetype: normalizedDxf.includes('\\n2\\nHIDDEN2\\n'), + hasR2000LineweightCodes: normalizedDxf.includes('\\n370\\n'), + hasR2000LinetypeAlignmentCodes: normalizedDxf.includes('\\n74\\n'), + hasTxtShxStyle: normalizedDxf.includes('\\n3\\ntxt.shx\\n'), + crlfOnly: dxf.includes('\\r\\n') && !dxf.replace(/\\r\\n/g, '').includes('\\n'), + hasFoundationPlanTitle: normalizedDxf.includes('\\n1\\nFOUNDATION PLAN\\n') || normalizedDxf.includes('\\n1\\nBASE REACTION PLAN\\n'), missingPlanTitles, - missingTableTitles: requiredTableTitles.filter(title => !dxf.includes('\\n1\\n' + title + '\\n')), - validTerminator: dxf.endsWith('0\\nEOF\\n'), + missingTableTitles: requiredTableTitles.filter(title => !normalizedDxf.includes('\\n1\\n' + title + '\\n')), + validTerminator: dxf.endsWith('0\\r\\nEOF\\r\\n'), packageAudit, beamLayerChunk: layerChunk('S-CONC-BEAM'), columnLayerChunk: layerChunk('S-CONC-COL'), @@ -1546,7 +1687,7 @@ async function runBrowserSmoke() { assert(!result.initial.initError && !result.initError, 'Init error shown in app', result); assert(result.initial.columns === 9, 'Default 2x2 model did not initialize 9 columns', result.initial); assert( - result.uiCleanupAudit.buildBadge === 'v3.16.116' && + result.uiCleanupAudit.buildBadge === 'v3.16.117' && result.uiCleanupAudit.rebuildButton === true && result.uiCleanupAudit.etabsButton === true && result.uiCleanupAudit.etabsQaBadge === 1 && @@ -1933,6 +2074,20 @@ async function runBrowserSmoke() { assert(result.dxfLayerAudit.missingRequiredLayers.length === 0, 'DXF export is missing structural layer-map layers', result.dxfLayerAudit); assert(result.dxfLayerAudit.missingEntityLayers.length === 0, 'DXF export did not place generated entities on structural layers', result.dxfLayerAudit); assert(result.dxfLayerAudit.legacyLayerHits.length === 0, 'DXF export still emits legacy layer names', result.dxfLayerAudit); + assert( + result.dxfLayerAudit.hasR12Version === true && + result.dxfLayerAudit.hasLayerZero === true && + result.dxfLayerAudit.hasByLayerByBlock === true && + result.dxfLayerAudit.hasCorrectCenter2Length === true && + result.dxfLayerAudit.hasTxtShxStyle === true && + result.dxfLayerAudit.hasR2000LineweightCodes === false && + result.dxfLayerAudit.hasR2000LinetypeAlignmentCodes === false && + result.dxfLayerAudit.crlfOnly === true && + result.dxfLayerAudit.packageAudit.dxfVersion === 'AC1009' && + result.dxfLayerAudit.packageAudit.lineEnding === 'CRLF', + 'DXF envelope is not a clean AutoCAD R12 ASCII document', + result.dxfLayerAudit + ); assert(result.dxfLayerAudit.hasCenter2Linetype === true, 'DXF export did not define/use CENTER2 for grid lines', result.dxfLayerAudit); assert(result.dxfLayerAudit.hasHidden2Linetype === true, 'DXF export did not define HIDDEN2 for hidden corner-slab framing', result.dxfLayerAudit); assert( @@ -1963,8 +2118,8 @@ async function runBrowserSmoke() { 'DXF package is incomplete or structurally invalid', result.dxfLayerAudit ); - assert(/62\n1\n/.test(result.dxfLayerAudit.beamLayerChunk) && /370\n50\n/.test(result.dxfLayerAudit.beamLayerChunk), 'DXF beam layer does not match FT layer color/lineweight', result.dxfLayerAudit); - assert(/62\n2\n/.test(result.dxfLayerAudit.columnLayerChunk) && /370\n70\n/.test(result.dxfLayerAudit.columnLayerChunk), 'DXF column layer does not match FT layer color/lineweight', result.dxfLayerAudit); + assert(/62\n1\n/.test(result.dxfLayerAudit.beamLayerChunk) && /6\nCONTINUOUS\n/.test(result.dxfLayerAudit.beamLayerChunk), 'DXF beam layer does not match FT layer color/linetype', result.dxfLayerAudit); + assert(/62\n2\n/.test(result.dxfLayerAudit.columnLayerChunk) && /6\nCONTINUOUS\n/.test(result.dxfLayerAudit.columnLayerChunk), 'DXF column layer does not match FT layer color/linetype', result.dxfLayerAudit); assert(/62\n250\n/.test(result.dxfLayerAudit.gridLayerChunk) && /6\nCENTER2\n/.test(result.dxfLayerAudit.gridLayerChunk), 'DXF grid layer does not match FT layer color/linetype', result.dxfLayerAudit); assert( result.memberTagAudit.tag.startsWith(`B - ${result.memberTagAudit.floorId} -`) && @@ -1976,6 +2131,21 @@ async function runBrowserSmoke() { result.memberTagAudit ); + const dxfParserAudit = validateDxfWithEzdxf( + await tab.evaluate('generateDXFContent()'), + 'browser-drawing-package' + ); + const parserRequiredLayers = ['0', 'S-GRID', 'S-CONC-COL', 'S-CONC-BEAM', 'S-CONC-SLAB', 'S-TEXT']; + assert( + parserRequiredLayers.every(layer => dxfParserAudit.original.layers.includes(layer)) && + dxfParserAudit.original.entityCounts.LINE > 0 && + dxfParserAudit.original.entityCounts.TEXT > 0 && + dxfParserAudit.original.entityCounts.CIRCLE > 0 && + Object.values(dxfParserAudit.retained).every(Boolean), + 'DXF parser audit lost required layers or entities', + dxfParserAudit + ); + await tab.evaluate(`(() => { state.xSpans = [2.05, 4.38, 4.58, 2.91]; state.ySpans = [4.06, 4.0, 2.8]; @@ -2106,7 +2276,7 @@ async function runBrowserSmoke() { await wait(150); await tab.screenshot(stair3DScreenshotPath); const relevantLogs = tab.logs.filter(log => ['error', 'warning', 'exception'].includes(log.type)); - return { result, afterReload, screenshotPath, stair3DScreenshotPath, relevantLogs }; + return { result, dxfParserAudit, afterReload, screenshotPath, stair3DScreenshotPath, relevantLogs }; } finally { tab.close(); if (browser.process && !KEEP_BROWSER) { @@ -2766,6 +2936,10 @@ async function runProjectSmoke(projectPath, etabsScriptPath = null, staadPath = fs.writeFileSync(resolvedDXFPath, dxfContent, 'utf8'); result.dxfExportAudit = await tab.evaluate('JSON.parse(JSON.stringify(window.lastDXFExportAudit || {}))'); result.dxfExportAudit.writtenPath = resolvedDXFPath; + result.dxfParserAudit = validateDxfWithEzdxf( + dxfContent, + path.basename(resolvedDXFPath, path.extname(resolvedDXFPath)) + ); } await tab.screenshot(screenshotPath); @@ -2783,7 +2957,9 @@ async function main() { const summary = { syntax: parseInlineScripts(), engines: [], - displayMarks: checkDisplayMarkOrdering() + displayMarks: checkDisplayMarkOrdering(), + releaseManifest: checkReleaseManifest(), + dxfSourceContract: checkDxfSourceContract() }; const projectPath = getArgValue('--project'); const etabsScriptPath = getArgValue('--write-etabs-script'); diff --git a/v3/tools/requirements-dxf.txt b/v3/tools/requirements-dxf.txt new file mode 100644 index 0000000..c72c608 --- /dev/null +++ b/v3/tools/requirements-dxf.txt @@ -0,0 +1 @@ +ezdxf==1.4.3 diff --git a/v3/tools/validate-dxf.py b/v3/tools/validate-dxf.py new file mode 100644 index 0000000..b92fb59 --- /dev/null +++ b/v3/tools/validate-dxf.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Strict parser and round-trip acceptance check for FutolStructure DXF output.""" + +from __future__ import annotations + +import argparse +from collections import Counter +import json +from pathlib import Path +import tempfile + +import ezdxf + + +def audit_entries(entries): + return [ + { + "code": getattr(entry, "code", None), + "message": str(getattr(entry, "message", entry)), + } + for entry in entries + ] + + +def document_snapshot(document): + auditor = document.audit() + modelspace = document.modelspace() + entity_counts = Counter(entity.dxftype() for entity in modelspace) + entity_layer_counts = Counter(str(entity.dxf.layer) for entity in modelspace) + return { + "dxfVersion": document.dxfversion, + "layers": sorted(str(layer.dxf.name) for layer in document.layers), + "linetypes": sorted(str(linetype.dxf.name) for linetype in document.linetypes), + "entityCount": len(modelspace), + "entityCounts": dict(sorted(entity_counts.items())), + "entityLayerCounts": dict(sorted(entity_layer_counts.items())), + "auditErrors": audit_entries(auditor.errors), + "auditFixes": audit_entries(auditor.fixes), + } + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("path", type=Path) + parser.add_argument("--expected-version", default="AC1009") + args = parser.parse_args() + + raw = args.path.read_bytes() + lf_count = raw.count(b"\n") + crlf_count = raw.count(b"\r\n") + result = { + "path": str(args.path.resolve()), + "bytes": len(raw), + "lineEndings": { + "lf": lf_count, + "crlf": crlf_count, + "bareLf": lf_count - crlf_count, + "bareCr": raw.count(b"\r") - crlf_count, + }, + "validTerminator": raw.endswith(b"0\r\nEOF\r\n"), + } + + try: + document = ezdxf.readfile(args.path) + original = document_snapshot(document) + result["original"] = original + + with tempfile.TemporaryDirectory(prefix="futolstructure-dxf-roundtrip-") as temp_dir: + roundtrip_path = Path(temp_dir) / "roundtrip.dxf" + document.saveas(roundtrip_path) + reopened = ezdxf.readfile(roundtrip_path) + roundtrip = document_snapshot(reopened) + result["roundTrip"] = roundtrip + + result["retained"] = { + "entityCount": original["entityCount"] == roundtrip["entityCount"], + "entityCounts": original["entityCounts"] == roundtrip["entityCounts"], + "entityLayerCounts": original["entityLayerCounts"] == roundtrip["entityLayerCounts"], + "layers": original["layers"] == roundtrip["layers"], + } + result["ok"] = all( + ( + original["dxfVersion"] == args.expected_version, + not original["auditErrors"], + not original["auditFixes"], + not roundtrip["auditErrors"], + not roundtrip["auditFixes"], + all(result["retained"].values()), + result["lineEndings"]["bareLf"] == 0, + result["lineEndings"]["bareCr"] == 0, + result["validTerminator"], + ) + ) + except Exception as error: # Return machine-readable diagnostics to the Node gate. + result["ok"] = False + result["exception"] = f"{type(error).__name__}: {error}" + + print(json.dumps(result, indent=2)) + raise SystemExit(0 if result["ok"] else 1) + + +if __name__ == "__main__": + main()