Skip to content

⚠ DO NOT REMOVE

Scope: spec for deterministic 2D DXF → 3D BIM in the BIM Modeller. Spec-first: no code or tests until a section below is written and agreed. Read the log after every run. Every output traces to a DXF entity; no guessed or synthetic values.

Credits: the American Farmhouse test drawing is by Jay Osborne (freefarmhouse.com), CC BY-SA; not included in the repo (see tests/dxf2bim/README.md).

DXF2BIM — Deterministic 2D DXF → 3D BIM

Status: SPEC v0.2 (2026-10-11), reviewed once, decisions §8 agreed by user. Code starts with S1-S3 + T1-T3 (see §10).

1. Goal

Same DXF in → same 3D BIM out, every run. Each 3D element records the DXF entity (handle, layer) it came from. Anything the DXF cannot supply is flagged as a gap, never filled by guess.

2. Why (sourced)

  • Internal research: 2Dto3D/docs/RESEARCH_2D_TO_3D_APPROACHES.md line ~663 "Market Gap": no open-source tool does automated 2D→3D. Revit/ArchiCAD are manual trace.
  • Prior art, checked 2026-10-11 by fetching the pages (summaries from the fetch, not full source reads):
  • CrossBIM (github.com/AMostafaH/CrossBIM), SOURCE READ 2026-10-11 (clone, HelperClasses/HelperFunctions.cs, view models): student project, 2 commits, both 2021-12-12, no LICENSE file. ASP.NET Core 3.1 web form. Structural only: columns, beams, slabs (+openings, drop panels), isolated footings, strip footings. No walls, doors or windows in the code. User types, per category, the DXF layer name plus depth/width/thickness/top level into a form; height per storey and repeat count replicate one DXF floor upward. Output via Xbim to IFC4 (+ifcxml, wexBIM preview uploaded to Cloudinary). The DXF reading and IFC geometry code (CrossBIMLib: DXFBeam/DXFColumn/DXFSlab, IFCBeam etc.) is a prebuilt CrossBIMLib.dll with no source in the repo, so its internals are unreadable. Deterministic in the sense that user-supplied layer→dimension tables drive output; no provenance, no gap reporting, no undo. Same idea as our R1/R3, narrower scope (structure only).
  • IfcOpenShell issue #1950 (open; reopened 2026-07-26): the script there (ezdxf + ifcopenshell) maps LINE/ARC/CIRCLE/POLYLINE/SPLINE/ELLIPSE to IFC curves only. No walls, no extrusion, no heights. Also src/blenderbim/dxf2ifc.py (v0.7.0), handles solids; current existence not confirmed.
  • co2tools (github.com/Mambix/co2tools): DXF→STL extrude per part, YAML config (extrude, translate, rotate). Manufacturing panels, not buildings, no IFC. Config-driven extrusion is the same pattern as our R3.
  • IFCraft: not fetched; Gemini's description (civil/alignments focus) unverified.
  • Claim to use: "no general, deterministic FOSS DXF→BIM tool found" (not "none exists"). CrossBIM source read: it supports the claim (structural only, closed DLL, no walls/doors/windows).

3. Target: Modeller, not Viewer

  • Rationale: Viewer only displays. Modeller owns the kernel-op log (bim-compiler/docs/BIM_Modeller_OOTB.md).
  • RESOLVED (2026-10-11): Modeller = bim-compiler/deploy/dev/index.html ("BIM OOTB v3", loads kernel_ops.js, import.js, import_worker.js, dxf-parser.js sits beside it). Write path = kernel_ops table (op_type, parameters, input_guids, output_guid, undone). DXF2BIM must emit kernel ops, not write elements directly.
  • Note: deploy/live/index.html differs from dev; build in dev only (per bim-compiler CLAUDE.md deploy flow).

4. Existing assets (do not rebuild)

Asset Path Gives
DXF→DB extractor, spatial filter, parametric mesh 2Dto3D/Scripts/, docs/user-guides/2D_TO_3D_COMPLETE_WORKFLOW.md Proven on Terminal 1: 1,037 elements, ~15 s. Geometry quality was placeholder-level per roadmap.
building_config.json topology→config→generate 2Dto3D/building_config.json, docs/BUILDING_CONFIG_INTEGRATION_GUIDE.md Config-driven approach
Browser DXF parser bim-compiler/deploy/dev/dxf-parser.js, 2d.html Reader in browser
3D→DXF writer (reverse direction) bim-compiler/2D_Layout/python/drawing_writer_dxf.py, DAGCompiler/.../drawing2d/dxf/ AIA layer map 2d_dxf_layer; round-trip test test_2d_bim_roundtrip.py
Rosetta samples bim-compiler/2D_Layout/reference_samples/ AIA/NCS layer guides, jscad_floorplan.dxf

5. Determinism rules (draft)

  • R1 Fixed layer→element-type map (table in DB, versioned), no inference outside it.
  • R2 Fixed processing order (sort by handle).
  • R3 Missing height/thickness/material → explicit gap record; value only from a config the user supplied.
  • R4 Provenance column on every element: DXF handle + layer.
  • R5 Round-trip: DXF → 3D → DXF (existing writer) must match input geometry within stated tolerance.

6. Pipeline stages

Runs in the Modeller (deploy/dev/). Each stage is a pure function of its input plus the two user-supplied tables (layer map, config). Each stage writes a log line per decision (kept/skipped/gap + reason). Read the log after every run.

Inputs (all user-supplied, versioned, hashed into the run record): - dxf — the file, read as bytes - layer_map — layer name pattern → element type (R1). Starts from AIA/NCS guides in 2D_Layout/reference_samples/ and the 2d_dxf_layer table. - config — storey heights, wall thickness/height per type, units (R3). Same role as 2Dto3D/building_config.json.

# Stage Input → Output Deterministic because Gap case
S0 Hash file bytes → run_id = hash(dxf, layer_map, config) no clock/random in id —
S1 Parse DXF → entity list {handle, type, layer, geometry} via dxf-parser.js sorted by handle (R2) unsupported entity type → GAP:unsupported_entity
S2 Units/origin $INSUNITS + config → metres; origin = config value, else DXF 0,0 explicit rule, no bbox guessing $INSUNITS missing and no config units → stop, ask
S3 Classify entity.layer → element type via layer_map table lookup only (R1) layer not in map → GAP:unmapped_layer, entity skipped
S4 Topology classified 2D → candidates: LINE/LWPOLYLINE on wall layers → wall centreline segments; closed polylines → slab/room outlines; INSERT → component placement (block name, point, rotation, scale); ARC → curved segment snap tolerance is a config number; endpoints joined in handle order open outline where a closed one is required → GAP:unclosed
S5 Extrude candidates + config → 3D parameters (wall: path, thickness, height; slab: outline, thickness, level; component: block ref + transform) heights/thicknesses read from config only (R3) no config value for the type → GAP:no_dimension, no element
S6 Emit 3D parameters → kernel ops via commitOp (kernel_ops.js); element GUID = hash(run_id, handle) same input → same GUID; provenance (R4) in parameters —
S7 Report counts per stage, all GAP records, run_id — —

Notes: - New op_type values: see §8.4 (DXF_RUN, DXF_WALL, DXF_SLAB, DXF_COMPONENT, DXF_OPENING, DXF_GAP). commitOp doc lists only GRID_MOVE | VIEW_FILTER | GRID_DETECT today. - Annotation layers (A-DIMS, A-NOTE, TEXT, MTEXT, DIMENSION, Defpoints) are mapped to ignore in layer_map, not hardcoded. Logged as skipped. - Dimension/text use: may later feed config suggestions. Out of scope for v1; suggestions never auto-applied. - INSERT to component: block name must resolve in the component library. Unresolved → GAP:unknown_block. - Walls: wall_mode = centreline only in v1 (§8.5).

Sample reality check (jscad_floorplan.dxf, counted from the file): 1,989 LINE, 402 LWPOLYLINE, 285 INSERT, 126 DIMENSION, 80 ARC. Layers include xref-prefixed ones (xref-Bishop-Overland-08$0$A-WALL, …A-OPENING, …R-TRUSS, …S-FOOTER). So it is not a small clean file. layer_map must match on pattern, e.g. suffix after the last $. Openings: see §9.

7. Layer map format and tests

7.1 layer_map (R1)

JSON, versioned, hashed into run_id. Ordered list, first match wins (order is part of the hash).

{ "version": 1,
  "rules": [
    { "match": "suffix", "pattern": "A-WALL",    "type": "wall" },
    { "match": "suffix", "pattern": "S-SLAB",    "type": "slab" },
    { "match": "suffix", "pattern": "A-DOOR",    "type": "component" },
    { "match": "suffix", "pattern": "A-OPENING", "type": "opening" },
    { "match": "exact",  "pattern": "A-DIMS-1",  "type": "ignore" }
  ] }
- match: exact | suffix (text after the last $, so xref-X$0$A-WALL matches A-WALL) | prefix. No regex in v1. - type: wall | slab | component | opening | ignore. Anything else is rejected at load. - No rule matches → GAP:unmapped_layer. - Seed rules come from the AIA/NCS PDFs in 2D_Layout/reference_samples/ and the 2d_dxf_layer table. Seeding is a copy step by a human, not inference.

7.2 config (R3)

{ "version": 1, "units": "m", "snap_tol": 0.001, "wall_mode": "centreline",
  "wall": { "thickness": 0.2, "height": 3.0, "base": 0.0 },
  "slab": { "thickness": 0.2, "level": 0.0 } }
Missing key → GAP at S5, never a default. (wall.base added at coding: a wall without a base level cannot be placed.)

7.3 Fixture F1 f1_minimal.dxf (hand-made, to be written by script, not by CAD)

  • $INSUNITS = 6 (metres)
  • Layer A-WALL: 4 LINEs, rectangle 5.0 x 4.0 from (0,0)
  • Layer S-SLAB: 1 closed LWPOLYLINE, same rectangle
  • Layer A-DOOR: 1 INSERT, block DOOR900, at (2.0, 0), rotation 0
  • Layer A-DIMS-1: 1 DIMENSION
  • Layer JUNK: 1 LINE Expected with config 7.2 and map 7.1: 4 walls (lengths 5,4,5,4, thickness 0.2, height 3.0) · 1 slab (area 20.0, thickness 0.2) · 1 component (F1 library contains DOOR900) · 1 ignored · 1 GAP:unmapped_layer (JUNK). Variant F1b: empty library → 0 components, 1 GAP:unknown_block (test T9). Expected values are hand-computed from the fixture, not taken from program output.

7.4 Tests (each names the issue it proves)

ID Test Issue it proves or disproves
T1 Run F1 twice, compare hash of all emitted ops (excluding timestamp) Output depends on anything other than inputs (clock, random, order). Fails if any difference.
T2 Shuffle entity order in F1 (same entities, different file order), compare ops Output depends on file order instead of handle order (R2).
T3 F1 counts and values equal the hand-computed list in 7.3 Pipeline produces wrong or invented geometry.
T4 Remove wall.height from config → 0 walls, 4 × GAP:no_dimension A default height is silently invented (R3).
T5 F1 with S-SLAB polyline left open → GAP:unclosed, 0 slabs Open outline silently auto-closed.
T6 Round-trip: emitted walls/slab → existing DXF writer → compare entity endpoints to F1 within snap_tol Geometry lost or added between 2D and 3D (R5).
T7 Every emitted op has handle and layer in parameters, and parameters parses to an object (not a string); every F1 entity is either emitted, ignored or GAP-logged Entity vanishes with no trace (R4). Count in = count out + ignored + gaps.
T8 Run on jscad_floorplan.dxf with a seeded map: log has GAP lines for every unmapped layer; T7 accounting holds Real-world messy file breaks the rules F1 does not exercise. No expected geometry claimed.

| T9 | F1b (library without DOOR900) → 0 components, 1 GAP:unknown_block | Unknown block silently placed with guessed size. |

T1-T7, T9 run on F1 only. T8 reports, it does not assert geometry. Test code is not written until this section is agreed.

8. Decisions (AGREED by user 2026-10-11)

  1. Modeller file: CLOSED, §3.
  2. Extractor: port to JS, do not reuse the Python. Evidence: 2Dto3D/Scripts/generate_3d_geometry.py hardcodes DEFAULT_WALL_THICKNESS=0.2, DEFAULT_WALL_HEIGHT=3.0, door 0.9x2.1, window 1.2x1.5. That breaks R3 (no invented values). The Python stays as reference only. Reader: dxf-parser.js already in deploy/dev/.
  3. Test DXFs: CLOSED. F1 (§7.3) for T1-T7, jscad_floorplan.dxf for T8 (report only).
  4. op_type names: follow the existing NOUN_VERB upper-case style (GRID_ADD, GRID_MOVE, DISC_SWITCH): DXF_RUN (one per import, holds run_id, hashes, counts), DXF_WALL, DXF_SLAB, DXF_COMPONENT, DXF_OPENING, DXF_GAP. Undo of a run = undo all ops with that run_id.
  5. Walls v1: wall_mode = centreline only. Double-line (pair) walls are v2; a drawing that uses pairs gets GAP:wall_pair_unsupported rather than a guess.
  6. Openings v1: add layer_map type opening. Each opening entity is emitted as DXF_OPENING (position, width from the entity). No cut into the wall in v1; each gets GAP:no_host_cut. Cutting needs a rule for which wall hosts the opening and a sill/head height from config; that is its own spec section (§9, not yet written). -> Detailed in §9.
  7. Double-encoding: CONFIRMED by code reading 2026-10-11 (not run against a live DB). commitOp does JSON.stringify(params) itself (kernel_ops.js ~l.53). 9 call sites pass an already-stringified value: doc_canvas.js (GRID_ADD x2, GRID_CALIBRATE, DISC_SWITCH, GRID_DELETE x2, GRID_MOVE x2) and route_walker.js (ELEMENT_PLACE). Those rows store a JSON string inside JSON. replayOps/undoOp parse once and return a string, so op.parameters.axis etc. are undefined for them. Worst case in compact (kernel_ops.js ~l.168): for such GRID_MOVE rows pCurr.label === pNext.label is undefined === undefined, so consecutive ones may be collapsed regardless of label. Call sites passing objects are fine (grid_drag.js, grid_overlay.js, grid_dims.js, grid_recompose.js, picking.js, section_cut.js). Also route_walker.js passes inputGuids as a bare string, but the doc says Array.
  8. Rule for DXF2BIM: always pass an object to commitOp, never a string; inputGuids an array.
  9. T7 adds: typeof JSON.parse(parameters) === 'object' for every DXF op.
  10. Existing bug handled in KERNEL_OPS_ENCODING_SPEC.md (partly fixed in deploy/dev/kernel_ops.js, uncommitted). 9 call sites, not 10.

9. Openings (doors/windows)

Status: SPEC 2026-10-11. Extends §6 S4/S5 and decision §8.6.

9.1 Evidence (counted from jscad_floorplan.dxf, layer containing "OPENING")

xref-Bishop-Overland-08$0$A-OPENING: 145 LINE, 11 ARC, 3 LWPOLYLINE, 0 INSERT. So in this real drawing openings are raw geometry (jamb/sill lines and door-swing arcs), not named blocks. Other layers hold INSERTs, but those are fixtures (FIXT-*, P-Toilet), not openings. Other drawings may use blocks; v1 must handle both without guessing.

9.2 Opening sources (v1)

Source on an opening layer Rule Result
INSERT whose block name is in the component library position, rotation from entity; width/height from library entry DXF_OPENING
ARC door-swing: hinge = arc centre; leaf width = arc radius (value from the DXF, not a default); swing sector = start/end angle DXF_OPENING kind door
LINE / LWPOLYLINE no deterministic rule in v1 GAP:opening_unclassified (one per entity)
INSERT not in library — GAP:unknown_block
Window detection from line pairs is OPEN (needs its own evidence: how sample drawings draw windows).

9.3 Host wall rule

For each DXF_OPENING with a point P (hinge or insert point): 1. Candidates = wall centreline segments (S4) with point-to-segment distance ≤ host_tol (config, no default). 2. Pick smallest distance; tie → lowest DXF handle (numeric). 3. No candidate → GAP:no_host. Result stored as host_wall = that wall's element GUID. Depends only on geometry and handles, so order-independent (R2).

9.4 Dimensions

Width: from the entity (ARC radius or library entry). Door head height, window sill/head: config keys opening.door.height, opening.window.sill, opening.window.height; missing → GAP:no_dimension. Never defaulted (contrast generate_3d_geometry.py DEFAULT_DOOR_*).

9.5 Cut

v1: no boolean cut; each opening carries host_wall, hinge/centre, width, and GAP:no_host_cut. Cut is enabled (v1.1) only after tests O1–O5 pass, as a separate op DXF_WALL_CUT referencing the opening, so it is undoable on its own.

9.6 Fixture F2 f2_openings.dxf (F1 plus)

  • A-OPENING: ARC centre (2.0, 0.0), radius 0.9, angles 0°–90°
  • A-OPENING: ARC centre (50.0, 50.0), radius 0.9 (far from every wall)
  • A-OPENING: 3 LINEs near (4.0, 4.0)
  • A-OPENING: ARC centre (0.0, 0.0), radius 0.8 (corner: equidistant to two walls) Config adds host_tol = 0.3, opening.door.height = 2.1.

9.7 Tests

ID Test Issue it proves
O1 ARC (2,0) r0.9 → opening width 0.9, host = the y=0 wall (length 5), hinge (2,0) Width invented or wrong host. Value must equal the ARC radius.
O2 ARC (50,50) → GAP:no_host, opening not attached Opening silently snapped to a far wall.
O3 3 LINEs → exactly 3 GAP:opening_unclassified, 0 openings LINEs guessed into a window.
O4 Corner ARC (0,0): host identical across 20 random entity orderings and equals the lowest-handle wall Host choice depends on file order (R2).
O5 Remove opening.door.height → door gets GAP:no_dimension Default door height invented.
Hand-computed expected values only. Test code not written until this section is agreed.

10. Review notes and added rules (2026-10-11)

Gaps found on review, now rules: - N1 Hash: SHA-256 hex over canonical JSON (keys sorted, no whitespace) of {dxf hash, layer_map, config, sorted component-library block names} (the library is an input). crypto.subtle is async in browsers; Node tests use crypto. The module takes a hash function as an injected dependency. - N2 Sort by handle = numeric value of the hex handle, not string order. - N3 Scope v1: modelspace ENTITIES section only. BLOCK definitions and xref contents are not expanded; an INSERT is one placement. Anything inside blocks is not read (logged once as SKIP:block_content count). - N4 Element GUID = hash(run_id, handle, part_index). Needed because one entity can yield several elements (a polyline wall gives one per segment). - N5 Numbers: coordinates rounded to 6 decimals before comparison, hashing and emit, so float noise cannot change output. - N6 Wall corners: v1 emits one box per segment about the centreline; boxes overlap at corners. No mitre/join. Quantities at corners are therefore approximate; stated, not hidden. - N7 T6 feasibility OPEN: the existing writer (drawing_writer_dxf.py / Java DxfWriter) reads output.db, not kernel ops. A round trip needs either an op-to-DXF step or the browser deploy/dev/dxf_export.js (308 lines, unread). T6 stays unwritten until this is checked. - N8 Slab level and wall base level both come from config; no storey inference in v1.

10b. Added after sandbox probe (2026-10-11)

  • N9 Units: jscad_floorplan.dxf has $INSUNITS=4 (millimetres) and 948 modelspace entities (earlier "1,989 LINE" etc. counted BLOCK definitions too; §6 sample note is therefore wrong for modelspace). v1 must convert, not stop. Table (DXF $INSUNITS): 1 inch 0.0254, 2 foot 0.3048, 4 mm 0.001, 5 cm 0.01, 6 m 1. config.units (one of in|ft|mm|cm|m) overrides the header; unknown code and no config → stop. All DXF coordinates are multiplied by the factor and then rounded (N5); all config dimensions are in metres. Output is always metres. Test U1: F1 drawn in mm equals F1 in m (params).
  • N10 Sandbox config values (thickness, height, base, level) for a messy file are placeholders chosen to exercise the pipeline, labelled SANDBOX in the log. They are not read from the drawing.

11. Build log

  • 2026-10-11 code: bim-compiler/deploy/dev/dxf2bim.js (S0-S3, S4/S5 for walls/slabs/components, S6 op list, S7 report; openings NOT implemented, logged GAP:opening_unimplemented), tests bim-compiler/tests/dxf2bim/test_dxf2bim.js, log tests/dxf2bim/test.log.
  • Witness: T1,T2,T3,T4,T5,T7,T9 PASS on F1. Mutation check (code deliberately broken, tests must fail): no handle sort → T2 FAIL; default wall height → T4 FAIL; auto-close slab → T5 FAIL; random in GUID → T1 FAIL. Code restored, 7/7 PASS.
  • Not done: T6 (N7), T8 (messy real file), F2/O1-O5 (openings), browser adapter (sink → KernelOps.commitOp, async sha256), UI hook in Modeller, dev-bucket deploy. All files uncommitted.
  • Note: tests only prove the module on F1 (small, hand-made). Real-world DXF behaviour is untested until T8.
  • 2026-10-11 sandbox T8 (nothing wired into the Modeller): bim-compiler/tests/dxf2bim/t8_sandbox.js on jscad_floorplan.dxf, log tests/dxf2bim/t8_sandbox.log. Unit conversion (N9) added first; U1 PASS, mutation (no scaling) → U1 FAIL, restored.
  • Result: 948 entities → 195 wall segments, 386 ignored, 387 gaps, 0 vanished (accounting PASS), two runs identical (PASS), 67 ms. Gaps by cause: 159 opening_unimplemented (as designed, §9 not coded), ~215 unmapped_layer (S-FOOTER, A-CASE-1, A-HEADER, S-STEM-WALL, R-BEAM, A-FOOTPRINT, …: expected, sandbox map only maps A-WALL/A-OPENING/ignores), 2 zero_length, 1 unsupported_entity (LINE on S-SLAB).
  • Finding (unit sanity): header says $INSUNITS=4 (mm) but the A-WALL extent after conversion is only ~0.8 m x 0.56 m (raw x -648.8..147.2, y -216.6..347.4). That is not a plausible building plan in mm. The header and the drawing disagree (if the raw units were inches the span would be ~20 m x ~14 m, plausible but UNPROVEN). Determinism holds but the geometry scale for this file is unverified. Rule to add: S2 prints the wall bbox in metres in the log and the report flags it when the header units are not overridden; the user sets config.units from the drawing, not from the header.
  • Not asserted: any geometry on this file. Wall thickness/height/base are SANDBOX placeholders (N10).
  • 2026-10-11 unit question SETTLED by evidence (read from the file): A-OPENING door-swing ARC radii are 24, 28, 30, 36 (leaf widths in inches: 2'0", 2'4", 2'6", 3'0"); door/window tags in TEXT read 3068, 2668, 5050 XO (US codes: 3'0"x6'8" etc.); header says $INSUNITS=4 (mm) and $DIMSTYLE=Architectural-Metric, both wrong for this drawing. With config.units="in" the wall extent is 20.2 m x 14.3 m (x -16.48..3.74, y -5.50..8.82), a plausible house plan. Header units were wrong, so: never trust header silently.
  • N11 added and coded: DXF_RUN params carry units, units_source (config|header), extent_m, warnings; report carries the same. WARN:units_from_header is raised whenever units came from the header. Test U2 PASS; mutation (warning disabled) → U2 FAIL, restored. All 9 tests PASS (T1-T5, T7, T9, U1, U2). T8 sandbox re-run with units:"in": accounting PASS, determinism PASS, extent logged.
  • 2026-10-11 openings (§9) coded for ARC door swings: deploy/dev/dxf2bim.js (deferred pass after walls; host = nearest wall segment within config.host_tol, tie → lowest numeric handle then part; width = ARC radius; host_wall = wall GUID; each opening also gets GAP:no_host_cut). Clarification of §9.3/O2: an opening with no host is NOT emitted (GAP:no_host only), same as walls without dimensions. Check order: host_tol and opening.door.height are checked before the host search.
  • Tests tests/dxf2bim/test_openings.js (log test_openings.log): O1-O5 + O6 PASS. Mutations: tie-break reversed → O4 FAIL; tolerance ignored → O2 FAIL; default door height → O5 FAIL (first O5 mutation attempt had a syntax error and was redone); restored, 6/6 and 9/9 PASS.
  • Real file (tests/dxf2bim/t8_openings.log, units in): all 11 door arcs got a host wall even at host_tol 0.05 m; every emitted width equals raw radius x 0.0254 (0.6096 .. 0.9144 m, i.e. 24..36 in); 0 mismatches; 11/11 accounted at tol 0.05, 0.15, 0.30. Reading: door hinges sit within 5 cm of extracted A-WALL segments, an independent consistency check that the inch scale and wall extraction agree. Not proof the host is the architecturally right wall.
  • Still open: windows from line pairs (3 polylines + 145 lines on A-OPENING remain GAP:opening_unclassified), wall cut, INSERT openings with library widths, unmapped structural layers, T6, Modeller wiring. Uncommitted.

12. Opening-layer segments: door parts and rectangle openings (spec, 2026-10-11)

Evidence (script on jscad_floorplan.dxf, join tolerance 0.5 in): 156 segments on the opening layer (145 LINE + 11 from LWPOLYLINE) and 11 ARCs. 42 segments touch an arc centre or arc end point (door leaf/jamb lines). 64 segments form 16 closed 4-sided right-angle rectangles (long sides 18..72 in, short 1..6 in; each lies next to a window/door tag such as 6040 XO, 5050 XO, 2050 SH, 6068 S.G.D.). 0 overlap between the two groups. 50 segments remain. Rules (all geometry only, config.snap_tol metres decides "touching"; tags are NOT used by the pipeline): - D1 Door parts: an opening-layer segment with an endpoint within snap_tol of an EMITTED door's arc centre, arc start point or arc end point is a part of that door. It is listed in the door op as parts: [{handle, part}] and is not a gap. - W1 Rectangle opening: a connected group (endpoints within snap_tol) of exactly 4 unconsumed segments, each endpoint shared by exactly 2 segments, forming two parallel pairs joined at right angles, is one DXF_OPENING with kind: "rect", width = long side, depth = short side, centre (bbox centre of the 4 corners), angle_deg = direction of the long side mod 180. members: [{handle, part}] lists the 4 segments. Host rule §9.3 on centre. Kind is NOT guessed (door vs window vs slider is not decidable from geometry); a later config table can name it. - W2 Heights: opening.rect.sill and opening.rect.height from config; missing → GAP:no_dimension for each member segment. - W3 No host within host_tol → GAP:no_host for each member. Emitted rect openings also carry GAP:no_host_cut. - W4 Everything else on an opening layer (groups of 1, 2, 3, 5+ segments, or non-right-angle) stays GAP:opening_unclassified, one per entity that still has unconsumed segments. NOT guessed. Zero gaps is only reached if the drawing supports it. - W5 Missing snap_tol → GAP:no_dimension (need snap_tol) for each opening-layer LINE/LWPOLYLINE. Accounting: ops list members/parts; test accounting counts these handles as accounted. Tests: O7 door leaf line consumed as a door part · O8 closed rectangle becomes one rect opening with width/depth/centre/angle/host from hand computation · O9 open 3-segment U and a stray line stay gaps · O10 rectangle with a missing opening.rect.height → gaps, no element · O11 tilted rectangle (30°) gets angle 30 and correct width · O12 order independence (20 permutations) for the rectangle host and members. - 2026-10-11 §12 coded (deploy/dev/dxf2bim.js) with tests tests/dxf2bim/test_rect.js (log test_rect.log): O7-O12 PASS. Other suites unchanged: test_dxf2bim.js 9/9, test_openings.js 6/6 (its CFG gained snap_tol). Mutations: door-part consumption off → O7 FAIL; default rect height → O10 FAIL. Mutation "accept 3-segment U as rectangle" did NOT fail: equivalent mutant, other guards (shared-endpoint count, parallel/perpendicular count) also reject a U; the length check alone is not separately proven. O11 first failed on a too-strict expectation (1.000001 vs 1.0): N5 rounds corners to 1e-6 m so derived tilted-rect values carry up to 2e-6 m / 1e-3 deg of quantisation; the test now states that tolerance. - Real file (t8_sandbox.log, sandbox config, snap_tol 0.0127 m = 0.5 in, host_tol 0.15 m): 11 doors with 42 door-part segments (all 11 arcs hosted); 17 rectangle candidates, of which only 2 find a host at host_tol 0.15 (3 at 0.25, 5 at 0.35, 8 at 0.5; never all). Accounting PASS (948/948, members and parts counted), determinism PASS. Leftover opening-layer gaps: 44 entities / 46 segments (43 single LINEs, 1 LWPOLYLINE with 3 parts), logged GAP:opening_unclassified; the opening layer is NOT at zero gaps. - Why rectangles lack hosts (diagnosis from file distances): centres sit 0.05-0.08 m from S-STEM-WALL / A-HEADER geometry but 0.10-0.35 m from A-WALL. S-STEM-WALL (foundation stem walls) and A-HEADER are unmapped in the sandbox layer map. Mapping them as wall would give hosts but is a semantic choice (foundation wall vs above-ground wall, different base level) that the data cannot make; raising host_tol instead lets a rectangle attach to a wall up to 0.5 m away, also a user decision. Neither chosen. - Sensitivity to snap_tol (same file): rectangle candidates 18 at 0.0120/0.0126 m, 17 at 0.0127, 16 at 0.0130. Output is deterministic for a given config but the grouping near 0.5 in is sensitive; snap_tol must be a conscious per-drawing choice and stays in DXF_RUN (hashed into run_id). - Decision needed from the user (cannot be inferred): (1) which layers are walls for hosting: A-WALL only, plus S-STEM-WALL, plus A-HEADER; (2) host_tol; (3) snap_tol. Remaining unclassified opening geometry (46 single segments) has no deterministic rule from this evidence; it stays gaps. Zero gaps is not claimed.

13. Mesh and assembly (spec, 2026-10-11)

Goal: from the ops of one or more storey DXFs, produce a deterministic triangle mesh and one assembled model. Module deploy/dev/dxf2mesh.js, pure, no DOM, no Modeller wiring. Defaults taken from the earlier recommendation (A-WALL hosts only, host_tol 0.15 m, snap_tol 0.0127 m); the user can change them in the config. Decisions made here (tell me to change): - M-a Z convention: storey level L (metres, from the manifest). Wall: z from L + wall.base to L + wall.base + wall.height. Slab: TOP surface at L + slab.level, bottom thickness below. Door opening: z from L (floor level) to L + door.height. Rect opening: z from L + rect.sill to + rect.height. - M-b Wall = box about the centreline: width along the wall = segment length, thickness wall.thickness, normal = left of the start→end direction. Corners overlap (N6); volumes are summed, not unioned. - M-c Openings cut walls by SPLITTING the wall box into boxes (no boolean): full-height pieces between openings, a lintel above, a sill piece below. Door span on the wall axis = from the hinge to whichever of the two arc end points lies nearer the host wall axis (tie → start angle). Rect span = projection of the centre on the axis, half-extent width/2*|cos d| + depth/2*|sin d| (d = angle between rect long axis and wall). Opening thickness = host wall thickness. Openings are voids only; no door leaf or window glass mesh in v1 (stated gap GAP:no_fill_geometry). - M-d Clipping is never silent: an opening that leaves the wall segment (axis range or z range) or overlaps an earlier opening on the same wall is NOT cut and gets GAP:opening_outside_wall / GAP:opening_overlap; the wall stays whole there. - M-e Components (INSERT) have no geometry source (the library carries names only): GAP:no_geometry, not meshed. - M-f Triangulation: boxes = 12 triangles in fixed order; slab = ear clipping (smallest vertex index first), ring forced counter-clockwise; simple polygons only (self-crossing → GAP:bad_outline). - M-g Output: {guid, class, handle, layer, vertices:[[x,y,z]...], faces:[[i,j,k]...]} per element, coordinates rounded 6 dp (N5), plus toObj() text. Element classes: IfcWall, IfcSlab. Guid of a wall piece = hash(wall guid, piece index). - M-h Assembly: manifest {name, storeys:[{name, level, file}]}; each storey is run through dxf2bim.run with the same layer_map/config/library and meshed with its level. All storeys share one coordinate origin (assumed, then checked): per-storey extents are logged and WARN:storey_extent_mismatch is raised when the extent centres differ by more than config.align_tol (required for assembly; missing → stop). Assembled run_id = hash(canonical manifest + storey run_ids in manifest order). 2D elevation/section sets are NOT used for heights in v1 (heights come from config). Checks on every mesh (computed from the triangles, not from the inputs): signed volume > 0 (outward orientation), every edge shared by exactly 2 triangles per element (watertight). Tests (hand volumes, F-mesh = walls A1-A4 5x4 m, t 0.2, h 3.0; slab 5x4, t 0.2; door arc (2,0) r0.9 0..90 on A1; rect 1.5x0.2 centre (1.75,4) on A3, sill 0.9, height 1.2; door height 2.1): - MS1 box volume 50.23 = 3.0, watertight, outward · proves: inside-out or open box. - MS2 slab 5x4x0.2 = 4.0 and an L-shaped outline (area 12) x 0.2 = 2.4, watertight · proves: bad triangulation of concave polygons. - MS3 wall A1 with door: 3.0 - 0.90.22.1 = 2.622; wall A3 with rect: 3.0 - 1.50.21.2 = 2.64; A2, A4 2.4 each; total walls 10.062 · proves: wrong cut / hole not removed. - MS4 door span on A1 = [2.0, 2.9] (arc start point on the wall) · proves: wrong jamb side. - MS5 opening past the wall end → no cut, wall volume 3.0, GAP:opening_outside_wall · proves: silent clipping. - MS6 two overlapping openings → second not cut, GAP:opening_overlap · proves: double subtraction / negative volume. - MS7 determinism: mesh JSON hash identical across runs and across DXF entity order · proves: order/clock dependence. - MS8 assembly of two storeys (levels 0 and 3.0): total volume 2x, elements of storey 2 at z+3.0, extents logged; assembled run_id changes when level changes; mismatching extents → WARN · proves: wrong z offset, unchecked misalignment. - MS9 toObj round trip: parse the OBJ back, volume equals mesh volume · proves: export loses geometry. - M-d2 (added after the real-file mesh run, 2026-10-11): on jscad_floorplan.dxf all 13 openings were first rejected as opening_outside_wall. Diagnosis from the numbers: the drawing already leaves a gap in the wall lines at each door; the host segment (nearest within host_tol) ENDS where the door starts (e.g. host length 0.114 m, door s 0.1143..1.0287) or starts where it ends (s -0.9144..0). Nothing needs cutting. Rule: if the opening's axis interval lies entirely beyond one end of the host segment and touches that end within snap_tol (s1 <= snap_tol or s0 >= L - snap_tol), it is NOTE:opening_in_drawn_gap (list notes, not a gap, no cut, wall unchanged). An opening that STRADDLES a segment end (partly inside) stays GAP:opening_outside_wall: cutting across several collinear segments needs segment merging (v2). meshFromOps takes opts.snapTol. - Test MS10: door whose arc centre is at the end of wall A1 ((5,0), 0..90°): span [5.0,5.9] beyond A1 (L=5) → NOTE:opening_in_drawn_gap, A1 volume stays 3.0, no GAP · proves: valid drawn-gap openings reported as errors or cut into thin air. MS5 (straddling opening) must still produce the GAP.

14. Build log, mesh and assembly (2026-10-11)

  • Code: bim-compiler/deploy/dev/dxf2mesh.js (boxes split around openings, ear-clipped slab prisms, OBJ export, multi-storey assemble). Tests tests/dxf2bim/test_mesh.js MS1-MS10 (log test_mesh.log): 10/10 PASS. Mutation checks, code deliberately broken, tests must fail: inside-out box faces → MS1,MS3,MS6,MS8 FAIL; openings not cut → MS3,MS4,MS5,MS6 FAIL; jamb always arc end → MS3,MS4 FAIL; silent clip → MS5 FAIL; overlap check off → MS6 FAIL; storey level ignored → MS8 FAIL; slab ring not forced CCW → test crashed on null mesh (detected, not a clean FAIL); drawn-gap rule off → MS10 (see run). Code restored; all four suites PASS (9/9, 6/6, 6/6, 10/10).
  • Real file sandbox (t8_mesh.log, sandbox config, units in): 195 wall ops → 195 mesh elements, 2,340 triangles, 131.005 m3 uncut wall volume (placeholder thickness 0.2, height 3.0), 0 non-watertight or inside-out elements, two runs identical, 19 ms. Openings: 11 NOTE:opening_in_drawn_gap (the drawing already leaves the gap; nothing to cut), 2 GAP:opening_outside_wall (rectangles that straddle a segment end), so NO wall was cut on this file. Assembly of the same drawing twice as GF@0 and L1@3.0: 390 elements, 390 unique GUIDs, L1 z 3..6, no misalignment warning (SANDBOX repeat of one floor, not a claim about the building). OBJ files written to tests/dxf2bim/out/ (jscad_gf.obj, jscad_sandbox_2storey.obj).
  • Honest limits: (1) walls are boxes with overlapping corners (N6); (2) openings are voids only, no door leaf or window fill; (3) no slab on the real file (its S-SLAB layer holds one LINE, so no closed outline; A-FOOTPRINT polylines are unmapped, a user decision); (4) components (INSERT) have no geometry source; (5) heights, thickness, sill come from config, not from elevation drawings, so nothing says the building is the right height; (6) 2 straddling rectangles need collinear-segment merging (v2); (7) the 15 unhosted rectangle candidates and 46 unclassified opening segments from §12 remain; (8) not wired into the Modeller; not deployed; nothing committed.
  • State: a set of plan DXFs can now be assembled by storey and meshed deterministically in the sandbox. The next step that changes the picture is wiring (sink → KernelOps.commitOp with object params, async hash in the browser) and a mesh→scene adapter in the Modeller, or reading heights from elevation DXFs.

15. Footprint walls and the Rosetta test (spec, 2026-10-11)

Evidence: the Duplex plan DXFs (2D_Layout/output/DXF/DX_FLOOR_GF_20260428_0636.dxf, units mm, exported from our own model, so synthetic but with known ground truth in 2D_Layout/input/DX_extracted.db) draw each wall as a closed axis-aligned rectangle outline (9 vertices incl. collinear points and a repeated start): layer A-WALL-FULL 12 (cut exterior pieces), A-WALL-PRTN 15 (partitions), plus a hatch copy A-WALL-PATT (27, includes the same 12 rectangles again). Source Level-1 wall 998 = x 0.417..4.618 m, y -10.218..-9.725 m = drawn rectangle (417, -10218, 4201 x 493 mm): exact. Rules (config.wall_mode: centreline (default when absent) | footprint): - F1 In footprint mode a wall is a CLOSED LWPOLYLINE on a wall layer. Vertices: units-converted, rounded (N5), consecutive duplicates and the closing duplicate removed. Rectangle test: every vertex lies on the bounding box boundary within snap_tol and the shoelace area equals the box area within 1e-6 relative. Pass → one wall; fail → GAP:wall_footprint_not_rect. - F2 The wall axis runs along the long side through the box centre; thickness = the SHORT side from the drawing (not config; wall.thickness is not required in this mode); square → axis along x, params square: true. Params also carry footprint: true and rect: [minx, miny, maxx, maxy] (metres). Height and base still come from config. - F3 In footprint mode a LINE or open polyline on a wall layer → GAP:wall_not_footprint (no centreline guess). - F4 Identical footprint (same rect within snap_tol) already emitted → GAP:duplicate_footprint, not emitted again (guards hatch duplicates). The layer map should still ignore hatch layers; F4 is a safety net, not a substitute. Tests FP1 rectangle with collinear extra vertices → length/thickness/axis hand-computed · FP2 vertical rectangle · FP3 L-shaped outline → gap · FP4 LINE wall → gap · FP5 duplicate rectangle on a second wall layer → gap · FP6 config thickness ignored and absent without gap. Rosetta test (R-series, sandbox): rebuild Level 1 and Level 2 from the DX plan DXFs and compare to the source model DX_extracted.db (metres, elements_rtree bbox + elements_meta): R1 footprint match (a source IfcWall of that storey whose bbox is cut by the plan's section height 1.2 m must equal one of our wall rects within 1 mm, in plan) · R2 count of unmatched on each side, listed with ids · R3 door widths from arcs vs source doors (multiset) · R4 storey z from the source model is used as config (level, wall base/height) and said so. Source values used as config are labelled FROM_SOURCE_MODEL; heights are not a discovery of the drawing.

16. Side track (user, 2026-10-11): BIM → 2D export

Not abandoned work to restart blindly. State found: 2D_Layout/python/drawing_writer_dxf.py and Java DxfWriter already produced the Duplex DXF set (GF, FF, FRONT, REAR, LEFT, RIGHT, ROOF, PLUMBING) from DX_extracted.db; docs/2D_LAYOUT.md says the DXF pipeline was retired as the primary 2D mode in favour of the browser grid overlay (3D model is the drawing). It is also the writer half of T6 (round trip). Parked: no work until the Rosetta test (§15) is read; then decide whether to revive the writer on the side with the same markings (§17) so a DXF it writes can be read back by this pipeline. Finding to carry into that work: the existing writer's LEFT and RIGHT elevations run drawing-x along +y and -y respectively (face minX: x_draw = +y; face maxX: x_draw = -y); FRONT (minY) x_draw = +x, REAR (maxY) x_draw = -x. Evidence: grid label positions and window tag positions in DX_LEFT/RIGHT/FRONT/REAR_20260428_0636.dxf vs plan (tags W3@y-14074 and W7@y-15983 on the minX wall at x -400 appear at LEFT drawing x -14074 and -15983). By the usual architectural convention LEFT/RIGHT would be mirrored the other way; the face NAME must never decide the axis, the marking declares it. - 2026-10-11 Rosetta run 1 (tests/dxf2bim/rosetta_dx.log, before this change): R1 exact-rect matching FAILED on both storeys (L1 10/17, L2 6/17). Diagnosis against the source db, not a code bug: (a) a source wall (e.g. 991, 8.8 m) is drawn as several PIECES split at door/window openings (12 A-WALL-FULL pieces for 4 source walls; partition 996 = pieces 76 + 79 around a 0.762 m door gap); (b) on Level 2 the old writer drew tall cabinets (IfcFurnishingElement, 800 mm, z 3.1-5.1) as thin outline strips on A-WALL-PRTN (strips 13-19 mm), so layer alone is ambiguous. Doors reconciled exactly on L1 (6/6 widths 0.914, 0.965, 1.402 = source). - F5 (spec, 2026-10-11): optional config.wall.min_thickness (metres, user-supplied, no default). In footprint mode a rectangle whose short side is below it → GAP:wall_below_min_thickness. Test FP7: 19 mm strip with min_thickness 0.05 → gap; same strip with the key absent → wall (no hidden default). - Rosetta metric change (R1 is replaced): R1a precision: every wall piece we emit lies inside some source IfcWall rect of that storey within 1 mm; R1b recall: for each source wall cut by the plan, uncovered length along its long axis = sum of widths of source doors/windows whose plan centre lies inside that wall rect, within 5 mm; R1c report exact whole-wall matches (info). Source openings are the ground truth for the gaps; widths from their bbox long side.

17. Rosetta run 2 result (2026-10-11), footprint mode + min_thickness

Code: footprint mode and wall.min_thickness in deploy/dev/dxf2bim.js; tests tests/dxf2bim/test_footprint.js FP1-FP7 PASS (mutations: rectangle test off → FP3 FAIL; duplicate guard off → FP5 FAIL; config thickness used → FP6 FAIL; min_thickness ignored → FP7 FAIL; code restored; 5 suites PASS). Rosetta script tests/dxf2bim/rosetta_dx.js, log rosetta_dx.log. Source: 2D_Layout/input/DX_extracted.db. The DXFs were exported from that same model, so this is a round trip, not independent proof. - Precision R1a: PASS on both storeys. L1 26/26 and L2 22/22 emitted wall pieces lie inside a source wall (1 mm). Nothing invented. - Doors R3: Level 1 6/6 door arc widths equal the source door widths exactly (0.914 x2, 0.965 x2, 1.402 x2). Level 2: 6 of 8 widths match; two arcs read 0.174 m where the source has 0.914/1.016 (unexplained, not yet investigated). - Recall R1b: FAIL as first defined (L1 10/17, L2 6/17 source walls reconstructed from pieces + source opening widths). Causes found: (1) source door bbox includes ~76 mm casing each side: with doors reduced by 0.152 m (diagnostic only, NOT asserted) L1 reconstructs 16/17 and L2 11/17; the L1 miss is wall 999 (a non-rectangular outline logged GAP:wall_footprint_not_rect); (2) Level 2: 9 non-rectangular exterior outlines logged as gaps and source wall 990 (16.97 m x 0.55 m, no piece emitted); 6 source walls remain unreconciled. - Cost of the old writer's conventions: 42 thin cabinet strips on A-WALL-PRTN removed by the user-supplied min_thickness 0.05 m; A-WALL-PATT hatch ignored by the map. A-GLAZ (windows) and 54-138 other entities stay unmapped, so windows are not rebuilt yet. - Verdict: plan geometry of walls is rebuilt without inventing anything and openings widths for doors are right on L1; the pipeline does NOT yet reconstruct the full model (non-rectangular wall outlines, windows, heights from elevations, slabs, stairs). Markings (§16 finding on elevation axes) are specified as evidence but not coded.

18. Rectilinear split of non-rectangular wall outlines (spec, 2026-10-11)

Evidence (script on the Duplex plans): of the 10 closed wall outlines that failed the rectangle test (L1 1, L2 9) only 2 are rectilinear (E6 L1, 25 vertices; 113 L2, 31 vertices: partition frames with all edges axis-aligned); 8 are Level-2 exterior wall pieces with 45 degree mitred ends (trapezoids, e.g. handle 68, 78, 8B), not rectilinear. - F6 In footprint mode a closed outline that is not a rectangle but is RECTILINEAR (after dedupe and collinear-vertex removal every edge is axis-aligned within snap_tol) is split into non-overlapping rectangles that exactly tile it. Method, deterministic: (a) y-sweep: bands between sorted unique y values, x-intervals from the vertical edges crossing the band (pairs), vertically adjacent identical intervals merged; (b) the same with x and y swapped; choose the split with FEWER rectangles, tie → y-sweep; rectangles ordered by (y0, x0). Check: sum of rectangle areas equals the polygon's shoelace area within 1e-6 relative, else GAP:wall_footprint_not_rect. - Each rectangle is one wall piece: part = its index, axis/thickness per F2, split_from: true in params; F4 (duplicate) and F5 (min_thickness) apply per piece. - Which split an L-shape gets is a choice (two valid tilings); fewer rectangles then y-sweep is the stated rule, not a claim about design intent. - F7 NOT split: outlines with non-axis-aligned edges (mitres, chamfers). They stay GAP:wall_footprint_not_rect. A bounding box would add triangles of wall that the drawing does not show, so no guess. Open: whether a user-set wall.mitre: "bbox" should be allowed (decision for the user). Tests: FP8 L-shape → 2 rectangles, hand-computed axes and lengths (A: 2.0 x 0.5, axis y 0.25, x 0..2; B: 1.5 x 0.5, axis x 0.25, y 0.5..2.0) · FP9 U-shape → 3 rectangles (0,0,3000,200), (0,200,1000,1000), (2000,200,3000,1000) in mm · FP10 area conservation: piece areas sum to the polygon area for FP8, FP9 and a rectilinear shape with a hole-like notch · FP11 thin leg: min_thickness drops only the 30 mm piece · FP12 mitred trapezoid stays a gap, nothing emitted · FP13 order independence: the piece list for the same polygon does not change when vertex order is rotated or reversed.

19. Rosetta run 3 result: rectilinear split (2026-10-11)

Code: rectsOf/sweep in deploy/dev/dxf2bim.js (F6 per §18; F7 mitres stay gaps). Tests tests/dxf2bim/test_footprint.js FP1-FP16 PASS (log test_footprint.log). Mutation checks (code broken on purpose, tests must fail): rectangle test off → FP3; duplicate guard off → FP5; config thickness used → FP6; min_thickness ignored → FP7; no vertical merge → FP14 (needed FP14 tightened: first version did not catch it); always y-sweep → FP15; area-conservation check removed → FP16 (needed FP16 added: first version did not catch it). During the work an edit of mine deleted the centreline wall branch; the older suites caught it at once and it was restored from a saved copy; all 5 suites PASS (9, 6, 6, 10, 16 checks). FP3 was changed because §18 made its L-shape splittable (now a diagonal outline). Rosetta (rosetta_dx.log, same source db, round trip): R1a precision PASS: L1 29/29, L2 27/27 emitted wall pieces lie inside a source wall (1 mm); R1b recall under the strict definition still FAILS: L1 11/17, L2 7/17. Diagnostic (not asserted): treating each source door as 0.152 m narrower (2 x 76 mm casing, which is in the source bbox but not in the drawn wall gap): L1 17/17, L2 12/17. Whole L1 model: every source wall is reconstructed from the plan once that casing is allowed. L2 gaps left: 8 mitred exterior outlines (GAP:wall_footprint_not_rect, F7), source wall 990 (16.97 x 0.55 m) with no piece, and 42 cabinet strips dropped by min_thickness. Open decision for the user: allow wall.mitre: "bbox"? Open: should R1b subtract door casing as a stated rule, or keep the strict definition?

20. Decisions agreed by user (2026-10-11)

  • D-1 Mitred (non-axis-aligned) wall outlines stay GAP:wall_footprint_not_rect; no wall.mitre: "bbox" option in v1 (F7 stands).
  • D-2 Rosetta recall rule R1b (revised, stated): for a source wall, expected uncovered length = sum over its source openings of (door: bbox long side minus 2 x 0.076 m casing; window: bbox long side), tolerance 5 mm. 0.076 m is observed in the drawing: the drawn door jamb polylines on A-DOOR are 76 mm wide (evidence assertion in the test). Rule is about the source bbox containing casing, not about the pipeline.
  • D-3 R1c (new): every source wall that R1b does not reconstruct must be EXPLAINED: a logged gap or an ignored/unmapped drawing entity whose bbox overlaps the wall rect. A miss with no explanation is a failure (silent loss).
  • Rosetta run 4 (2026-10-11, D-1/D-2/D-3 applied, rosetta_dx.log): R1a precision PASS (29/29 L1, 27/27 L2). R1-evidence PASS (A-DOOR polylines with a 76 mm side exist on both plans). R1b-stated: Level 1 PASS 17/17; Level 2 FAIL 12/17 (kept as a failure, not relabelled). R1c: PASS on both: all 5 Level-2 misses are explained by logged gaps or ignored hatch layers overlapping them: 4 mitred exterior walls (1010-1013: GAP:wall_footprint_not_rect per D-1) and wall 1035 (20 mm short: a GAP:wall_below_min_thickness strip and an unclassified door line sit inside it). The strict R1b is kept only as an INFO line. So: Level 1 plan walls are reconstructed with no loss and nothing invented; Level 2 is short by the mitred walls, by design.

21. Door arcs that are not doors (spec, 2026-10-11)

Evidence (script on DX_FLOOR_FF_20260428_0636.dxf vs source model): Level-2 door arcs 168 (centre 2444,-6275) and 16A (centre 6356,-11699) have radius 174 mm and their leaf LINEs 167/169 are 174 mm long, running across the wall (depth). The source doors 66 and 67 at those spots are 1.016 m wide x 0.174 m deep (M_Single-Flush 0864 x 2032, bbox 1.016 x 0.174). Doors 68 and 69 on the same wall lines are drawn correctly (r 1016). Cause is in the old exporter (it swung two doors about the wrong axis), not in the reader. This is an export bug found by the round trip; carried to the §16 side track. - F8 optional config.opening.door.min_width (metres, user-supplied, no default). A door arc with radius below it → GAP:door_below_min_width (not emitted, its door-part lines are not consumed). Same pattern as wall.min_thickness. Tests: O13 arc r 0.174 with min_width 0.5 → gap, no opening; same arc with the key absent → opening (no hidden default) · O14 arc r 0.9 with min_width 0.5 still emitted. Rosetta R3 (asserted): every emitted door width equals a source door width (multiset subset); the number of door_below_min_width gaps equals source doors minus emitted doors on the storey; so a missing door is explained and a wrong one is never emitted.

22. Windows from glazing clusters (spec, 2026-10-11)

Evidence (script on the Duplex plans vs source): A-GLAZ entities (LWPOLYLINE frame/glass strips, LINEs) grouped by bounding boxes touching within 2 mm give cluster boxes equal (10 mm) to the source IfcWindow bboxes: Level 1 4/4, Level 2 18/18 (18 windows map onto 10 distinct plan boxes: windows stacked at different heights share one plan box; a plan cannot tell them apart, elevations can). - W6 layer_map gains type glazing. Entities of that type that are LINE, LWPOLYLINE, POLYLINE (segments or vertices) are clustered: two entities join when their bounding boxes touch or overlap within config.snap_tol. Clusters are formed in handle order; one cluster = one window candidate. Missing snap_tol → GAP:no_dimension per entity. - W7 A window candidate becomes DXF_OPENING kind window: centre, width (long side of the cluster bbox), depth (short side), angle_deg (0 when the bbox is wider than deep, else 90), members [{handle, part}], bbox. The bbox is axis-aligned; a rotated window would be over-boxed (stated limit, axis_aligned_bbox: true). - W8 Host: the wall piece (footprint walls carry rect) with the smallest rectangle-to-rectangle distance to the window bbox, at most config.host_tol; ties → lowest handle then part. For centreline walls the point rule of §9.3 on the centre is kept. None → GAP:no_host per member. - W9 Heights: opening.window.sill and opening.window.height from config only; missing → GAP:no_dimension per member. NOT read from the drawing, so any value used for the Duplex is FROM_SOURCE_MODEL or SANDBOX and labelled. - W10 No cut in the pipeline; the mesh stage already treats a window in a drawn wall gap as NOTE:opening_in_drawn_gap. Tests: WN1 one cluster (frame strips + glass line in the gap between two wall rectangles) → one window with hand values (width 2.0, depth 0.2, centre 2.0,0.1) · WN2 two separate clusters stay two windows · WN3 cluster far from any wall → GAP:no_host per member, no window · WN4 opening.window.height missing → GAP:no_dimension, no window · WN5 order independence (20 permutations) · WN6 gap > snap_tol keeps clusters separate, gap <= snap_tol joins them · WN7 accounting: every member entity is in a window members list or a gap. Rosetta R4 (asserted): every window we emit has a bbox equal (10 mm) to a source IfcWindow bbox of that storey (precision); the count of distinct source plan boxes is reported against ours (recall, info).

23. Results: doors (F8), windows (§22), assembly (2026-10-11)

Code in deploy/dev/dxf2bim.js: opening.door.min_width (F8), layer type glazing, window clusters (W6-W10). Tests: test_openings.js O1-O14, test_windows.js WN1-WN7 (log test_windows.log); suites PASS: 9 (T), 14 (O), 6 (O7-O12 file), 10 (MS), 16 (FP), 7 (WN). Mutation checks (broken on purpose, tests must fail): min_width ignored → O13 FAIL; host_tol ignored → WN3; default window height → WN4; snap_tol ignored in clustering → WN6; tie to highest handle → WN1. One survivor: dropping the cluster sort (M33) is an equivalent mutant because S1 already sorts entities by handle. Rosetta (rosetta_dx.log, round trip against DX_extracted.db, sandbox config; source-derived levels labelled FROM_SOURCE_MODEL): - R3 doors PASS both storeys: L1 6/6 widths equal the source (0.914 x2, 0.965 x2, 1.402 x2); L2 6 emitted equal source widths and the 2 export-defect arcs (r 0.174) are withheld as GAP:door_below_min_width, so a missing door is explained and no wrong door exists. - R4 windows PASS both storeys, 0 stray: L1 4/4 windows reproduced (plan bbox equal to the source within 10 mm), L2 10/10 distinct plan boxes (18 source windows, the extra 8 are stacked at other heights and share a plan box; the plan cannot tell them apart). Every window found a host wall (0 no_host). - R1a precision PASS (29/29, 27/27). R1b-stated: L1 17/17 PASS; L2 12/17 FAIL kept; R1c PASS (all 5 misses explained: 4 mitred exterior walls, 1 short wall). 1 failing check in the run, by design (mitres). - Assembly of GF + FF (levels 0 and 3.1 FROM_SOURCE_MODEL) into one model: 61 elements (L1 30, L2 31), 732 triangles, z 0..6.2 m, all elements closed and outward-facing (MESH-DX PASS), unique ids 61/61, no extent-mismatch warning, identical across runs (run_id e6a3f3fa7e7f). OBJ: tests/dxf2bim/out/duplex_assembled.obj. - Honest limits: window and door heights, sills and storey wall height (3.1 on both) are sandbox placeholders or FROM_SOURCE_MODEL, not read from elevations; mesh gaps on the assembled model: 13 opening_outside_wall, 2 opening_overlap, so most openings are NOT cut into wall pieces (windows/doors straddle piece ends or overlap); openings are voids only; slabs, stairs, roof, furniture not built; mitred walls missing on L2; source model round trip, not an independent drawing. - Still open: elevation markings reader (§16 finding), elevation-derived heights, slabs from plan outlines, independent real DXF (Free Farmhouse, awaiting permission to download), Modeller wiring, git commit/deploy of any of this.

24. Markings: view, face, axis, anchors, levels (spec, 2026-10-11)

Rule agreed by user: every DXF in a set carries its own markings; the pipeline never infers view type, orientation, or datum. Markings may be added to a drawing before processing (by a person, or by a tool/model after looking at the drawing); the reader only reads. Format: TEXT entities on layer DXF2BIM-MARK, one marking per entity, text @KEY=VALUE (no spaces around =). The entity's insertion point carries position where a key needs one. Keys: | Key | Where | Value | Position used | |---|---|---|---| | @SET | all | set name (all drawings of one building share it) | none | | @VIEW | all | PLAN or ELEV | none | | @NAME | all | free label (e.g. FRONT) | none; informational only, never decides axes | | @UNITS | all | mm|cm|m|in|ft | none; overrides $INSUNITS | | @LEVEL | PLAN | storey level in metres | none | | @GRIDX | PLAN | grid label of a line at constant x | x | | @GRIDY | PLAN | grid label of a line at constant y | y | | @FACE | ELEV | minX|maxX|minY|maxY = which building face is shown (plan axes) | none | | @HAXIS | ELEV | +X|-X|+Y|-Y = which plan axis the drawing's +x (horizontal) runs along | none | | @GRIDH | ELEV | grid label of a line seen in this elevation | x (horizontal) | | @Z | ELEV | height in metres of a true level line | y (vertical) | Validation (errors, never defaults): unknown key; malformed value; duplicate key with a different value (@GRIDX=A twice at different x is a conflict); PLAN requires @SET @VIEW @LEVEL @UNITS and at least 2 @GRIDX and 2 @GRIDY; ELEV requires @SET @VIEW @FACE @HAXIS @UNITS, at least 2 @GRIDH, at least 2 @Z; @HAXIS must be perpendicular to the face normal (minY/maxY faces take ±X, minX/maxX take ±Y) so a mirrored declaration is allowed but a nonsensical one is not; a missing required key stops processing of that drawing with MARK:missing <keys>. Alignment check (deterministic, reader side): plan grid positions give coordinates per label. For an elevation, with HAXIS = s*axis (s = +1 or -1), the plan coordinate of horizontal position h is c(h) = c_L + s*(h - h_L)*k for any shared label L (k = units to metres). Every shared label must agree within align_tol; fewer than 2 shared labels, or any disagreement, → MARK:align_fail with residuals. Vertical: @Z pairs must satisfy (z_j - z_i) = (y_j - y_i)*k within align_tol, else MARK:level_scale_fail. Height of a drawn feature at y: z = z_i + (y - y_i)*k. Evidence for why @HAXIS is explicit: the old BIM→2D writer's LEFT elevation (face minX) runs drawing-x along +y and its RIGHT (maxX) along -y; FRONT (minY) +x and REAR (maxY) -x (checked on window tags and grid labels in DX_*_20260428_0636.dxf). Face names would mislead. Tests (hand-computed fixtures): MK1 read every key from a fixture and return the structured descriptor · MK2 unknown key, malformed value, conflicting duplicate each rejected with a named error · MK3 missing required keys → MARK:missing listing exactly them · MK4 HAXIS vs FACE perpendicularity (minY with +Y rejected; minX with -Y accepted) · MK5 alignment passes for a mirrored REAR (s=-1) with shared labels A,B at known positions and fails when one grid is moved 100 mm · MK6 @Z scale check: wrong scale fails · MK7 write then read round trip (writeMarks inserts into the ENTITIES section; dxf-parser reads them back) and the original entities are untouched · MK8 the DXF still parses and renders order-independently (marker order in the file does not change the descriptor). Then (§25) the Duplex set is marked by a sandbox tool from evidence in the files (titles, grid labels, level lines), reviewed against rendered images, and verified: alignment residual must be 0 against the known plan, and window tags must land where the markings say.

25. Results: markings reader and the marked Duplex set (2026-10-11)

Code: deploy/dev/dxf2marks.js (read, validate, align, write). Tests tests/dxf2bim/test_marks.js MK1-MK8 PASS (log test_marks.log). MK8 first FAILED and exposed a real flaw: descriptor key order followed marker file order; fixed with canonical (sorted) output. Mutation checks, code broken on purpose, tests must fail: REAR sign ignored → MK5; axis/face check off → MK4; conflicts accepted → MK2; level scale unchecked → MK6; tolerance ignored → MK5; missing keys defaulted → MK3; no canonical sort → MK8 (7/7 caught). Sandbox marking tool tests/dxf2bim/mark_dx_set.js (log mark_dx_set.log): proposes markings only from evidence in each DXF (title text, A-GRID lines with the nearest one-character label, level lines and labels) and from the source model for storey levels (0 and 3.1, FROM_SOURCE_MODEL; the source db stores float32 so 3.0999999 was rounded to 3.1). It refuses on ambiguity (none occurred). Marked COPIES go to tests/dxf2bim/out/marked/; the original DXFs are untouched. HAXIS is found as the unique plan axis+sign that fits the shared grid labels: FRONT +X, REAR -X, LEFT +Y, RIGHT -Y; FACE per the repo name convention (FRONT minY, REAR maxY, LEFT minX, RIGHT maxX); Z pairs only where the label height equals 1000 x z (level labels are nudged elsewhere) or a level line sits at that height: -0.15, 0, 3.2, 5.6. Verification against the SOURCE model (verify_marks_dx.js, log verify_marks_dx.log): 22/22 checks PASS. Markings read back with 0 errors on all 6 drawings; all 8 elevation-to-plan alignments residual 0 (against GF and FF plan); FACE verified: every source window on each declared face (FRONT 5, REAR 5, LEFT 6, RIGHT 6) is reproduced by an elevation glazing cluster mapped through the markings, equal in plan interval AND in sill/head height within 10 mm; the opposite face reproduces 0 (negative control); 0 strays: every elevation glazing cluster matches some source window. Clusters not on the declared face are end-on views of windows in perpendicular walls (width 0.417 m = wall thickness) and, in LEFT/RIGHT, one roof window (1.225 m). Strictness fix: my first check demanded 0 unmatched clusters and failed (4); the cause was the end-on windows, not the markings. Consequence: the elevation drawings carry the true sill and head heights of windows, and the markings make them usable without assumptions. Stacked windows (e.g. source 81, 82, 83 share one plan box) appear as separate clusters in the elevation. Next (§26, not started): assign elevation heights to the plan windows (face, plan interval), producing one window per elevation cluster including stacked ones, replace the sandbox sill/height config for windows, and compare 22 source windows (L1 4, L2 18) one by one. Open: reading storey wall heights and door heights from elevations; mitres; slabs; independent real DXF.

26. Window heights from elevations (spec, 2026-10-11)

Inputs: the plan run (DXF_OPENING kind window ops from §22) and marked elevations (§24: face, axis, grid anchors, @Z levels). Module deploy/dev/dxf2elev.js, pure. - E1 config.opening.window.from_elevation: true switches window heights to elevation mode: sill/height are NOT required in config; plan windows are emitted with sill: null, height: null, pending_elevation: true. In this mode any pending window that is not resolved by an elevation is dropped with a gap (no placeholder). The mesh stage never meshes a window whose sill is null (GAP:no_height). - E2 Elevation clusters: entities on layers the layer_map types glazing are clustered exactly as in W6 (bboxes touching within snap_tol converted to metres). Each cluster is mapped through the alignment: horizontal interval [p0,p1] in plan metres along the marked axis (map.toPlan), vertical interval [z0,z1] in metres (map.zOf). - E3 Which faces a plan window touches: the building envelope is the bounding box of all emitted wall pieces (footprint rects or centreline boxes); a window touches face F when its plan bbox reaches the face plane within snap_tol. A corner window can touch two faces. - E4 A plan window gets one derived window per elevation cluster of a declared elevation of a touched face when the cluster's plan interval equals the window's interval along that face's axis within snap_tol, AND the cluster's z interval lies inside this storey's z range [level, level + wall.height] (+/- snap_tol). Stacked windows (same plan interval, different z) therefore give several windows from one plan window. Derived sill = z0 - level, height = z1 - z0 (both from the drawing). - E5 Gaps, never guesses: a pending window with no cluster → GAP:no_elevation_match; with no elevation declared for any touched face → GAP:no_elevation_for_face. Elevation clusters that match no plan window of any storey are reported in the result (unmatched_elev, info, e.g. roof windows or end-on views). - E6 Derived window params: kind window, plan centre/width/depth/angle_deg/bbox/members/host_wall from the plan window, plus face, from_elevation: true, elev_members (handles), sill, height; output_guid = hash(plan guid | elev | index). Tests: EL1 one cluster → sill 0.9, height 1.2 at level 0 (hand) · EL2 two stacked clusters → two windows · EL3 same plan interval at level 0 and 3.1: each storey takes only the cluster inside its z range · EL4 no cluster → gap, not emitted · EL5 window on a face with no elevation → gap · EL6 REAR (HAXIS -X) mapping gives the same plan interval · EL7 order independence (20 permutations of entity order) · EL8 unmatched elevation cluster reported, not emitted · EL9 corner window touching two faces takes a cluster from either face. Rosetta R5 (asserted): the derived windows of both storeys, compared one by one with the 22 source windows of Level 1 and Level 2 on (plan bbox interval, absolute z0, z1 within 10 mm): precision (every derived window equals a source window) and recall (every source window of those storeys is derived), reported with counts. - E7 (added with coding, 2026-10-11): layer DXF2BIM-MARK is reserved. dxf2bim.run reports its entities as ignored (not GAP:unmapped_layer), never as geometry, with no layer_map rule needed. Test T10: F1 with markings added gives the same element ops and gaps as F1 without (marker entities appear only in the ignored count).

27. Results: window heights from elevations (2026-10-11)

Code: deploy/dev/dxf2elev.js (elevClusters, applyElevations), config.opening.window.from_elevation in dxf2bim.js (pending windows, no placeholder), GAP:no_height in dxf2mesh.js, reserved marker layer (E7). Tests: tests/dxf2bim/test_elev.js EL1-EL9 PASS (log test_elev.log); T10 added to test_dxf2bim.js; all 8 suites PASS (T 10, O 14, rect 6, MS 10, FP 16, WN 7, MK 8, EL 9). Mutation checks (broken on purpose, tests must fail): storey z range ignored → EL3; interval match ignored → EL4; sill absolute not relative → EL3; no z dedupe → EL9; silently dropping unmatched windows → EL4; unmatched elevation clusters not reported → EL8; reserved marker layer not special → T10 (7/7 caught). Rosetta R5 (tests/dxf2bim/rosetta_dx_elev.js, log rosetta_dx_elev.log; marked copies from mark_dx_set.js; round trip against DX_extracted.db): window heights come ONLY from the elevations (no sill/height in config). Level 1: 4 plan windows -> 4 derived. Level 2: 10 plan windows -> 18 derived (stacked windows split by elevation clusters). Compared one by one with the 22 source IfcWindow of Level 1 and Level 2 on plan bbox and absolute sill/head (10 mm): precision 22/22, recall 22/22, 0 stray, 0 missing; 0 no_elevation_match, 0 no_elevation_for_face. 12 elevation clusters belong to no storey's plan window: end-on views of windows in perpendicular walls (width 0.417 m, e.g. LEFT plan[-17.8,-17.383] z[0.1,2.52]) and the roof window (LEFT z 6.457..6.635); listed in the log, not emitted. Meshing with the elevation-derived windows: 61 elements, 0 open or inside-out, 10 openings sit in drawn wall gaps (notes), 21 mesh gaps. Limits (unchanged unless stated): this is a round trip against our own source model, not an independent drawing; wall height 3.1 and door height 2.1 are still config (FROM_SOURCE_MODEL/SANDBOX), not read from elevations; mitred exterior walls on Level 2 are gaps; openings still void-only and many not cut (straddling/overlap gaps); slabs, stairs, roof not built; markings were proposed by a sandbox tool from evidence in the files and verified against the source model, so they are not an independent check of how a stranger would mark an unknown set. Open: door heights and wall heights from elevations; roof; slabs; independent real-world DXF (Free Farmhouse awaiting permission to download); Modeller wiring; commit/deploy of any of this.

28. Wall and door heights from elevations (spec, 2026-10-12)

Evidence (scripts on the marked Duplex elevations vs source): A-ELEV-WALL polylines in FRONT carry storey boundaries: distinct y 0, 3100, 6000 (+ -1250, 6609, 2795); rects x 8383..8800 y 0..3100, x 0..8383 y 0..3100, x 8383..8800 y 3100..6000, ... equal the source exterior walls (Level 1 z 0..3.1, Level 2 z 3.1..6.0, 4 walls each); rect x 6200..6324 y 0..2795 equals source interior wall 1002 (z 0..2.795). A-DOOR polylines in elevations match source exterior doors exactly in plan interval and height: FRONT 62, 73; REAR 63, 72; LEFT 63; RIGHT 62 (z 0..2.086 and 0..2.496). No Level 2 door and no interior door appears in an exterior elevation (4 of 14 source doors visible). Layer maps are per view (the same layer name means different things in plan and elevation): elevation layer_map types glazing (windows, §22), elevwall (wall outlines), elevdoor (door leaves). - H1 config.wall.height_from_elevation: true and config.opening.door.from_elevation: true switch those heights to elevation mode. In that mode wall.height/wall.base and opening.door.height are optional; walls/doors are emitted with height: null (and base: null for walls) and pending_elevation: true. The mesh stage never meshes a pending wall (GAP:no_height) or cuts for a pending door. - H2 Elevation wall rects: entities of type elevwall clustered as in W6 are NOT merged by touching (each closed outline is its own rect, since stacked storey rects touch): each closed polyline = one rect with plan interval [p0,p1] along the elevation axis and z interval [z0,z1] (via the markings). - H3 A plan wall piece W (footprint rect or centreline box) is SEEN in elevation E when (a) its interval along E's axis equals a rect's interval within snap_tol (exact), or (b) W touches E's face plane within snap_tol AND W's interval lies inside the rect's interval within snap_tol (an exterior piece inside the long outline). Candidate z ranges are the matching rects' with z0 within snap_tol of the storey level (+ wall.base when given). Exact matches take priority over containment matches for the same elevation. - H4 All candidates (over all elevations) agree (same z0, z1 within snap_tol) → height = z1 - z0, base = z0 - level, height_source: 'elevation'. They disagree → GAP:elevation_height_conflict listing them (wall dropped, never averaged). None: if config gives wall.height (and wall.base) use it with height_source: 'config', else GAP:no_elevation_height (wall dropped). - H5 Doors: a plan door's interval along a face axis = the span between its hinge and the arc end point lying on the wall axis (same rule as mesh M-c), expressed along that axis; the face = the face plane touched by its host wall piece. It is matched to elevation elevdoor clusters of that face with the same interval within snap_tol and z0 within snap_tol of the level; a match gives height = z1 - z0, height_source: 'elevation'. No match: config opening.door.height if present (height_source: 'config'), else GAP:no_elevation_height. - H6 Every derived height records height_source and the elevation handles used; the report counts walls/doors by source (elevation / config / dropped). Tests: HT1 exterior wall piece inside a long elevation rect → height/base from the rect (hand) · HT2 two storeys: each takes the rect whose z0 equals its level · HT3 interior piece with an exact-interval rect gets that rect's height, not the long exterior rect's · HT4 conflicting candidates → gap, wall dropped · HT5 no candidate and no config → GAP:no_elevation_height; with config → height from config, height_source: 'config' · HT6 exact beats containment · HT7 door matched by interval and level in a mirrored elevation (REAR) · HT8 order independence · HT9 pending wall/door is never meshed. Rosetta R6 (asserted): for each plan wall piece with height_source: 'elevation', its z range equals the z range of the source wall that contains it (10 mm): precision; counts of walls by source reported; same for doors against source doors (10 mm); unmatched are listed, not forced. - H3 (amended while coding, 2026-10-12): an exact-interval match (a) is NOT allowed for a wall piece that touches the face plane OPPOSITE to the elevation's face (it is hidden behind the building; e.g. the back wall must not take the front elevation's height just because both have the same x-interval). Test HT10: back wall without a REAR elevation gets no height from FRONT. H5 tolerance: config.elev_tol (metres, user-supplied) replaces snap_tol for interval equality when present; sandbox value is stated in the log. - H5 (amended after the Duplex run, 2026-10-12): the span used for matching a door to elevation clusters is NOT derived from the host wall's axis (a short wall stub next to a door became the host of Level-1 door 73 and gave a degenerate span). Candidate spans along the face axis are [hinge, start-end-point] and [hinge, end-end-point] (both swing end points); either may match an elevdoor cluster of a face touched by the host wall piece (interval within elev_tol/snap_tol, z0 at the level). Hits with different heights → GAP:elevation_height_conflict; zero → fallback/gap as before. Test HT12: door hosted by a 100 mm stub still gets its height; HT13: two matching spans with different heights → conflict gap.

29. Results: wall and door heights from elevations (2026-10-12)

Code: dxf2elev.js (elevWallRects, applyWallHeights, applyDoorHeights, elevClusters(..., type)), elevation mode in dxf2bim.js (wall.height_from_elevation, opening.door.from_elevation: pending walls/doors, optional fallback tagged height_source), GAP:no_height for pending walls/doors in dxf2mesh.js. Tests: tests/dxf2bim/test_heights.js HT1-HT13 PASS (log test_heights.log); all 9 suites PASS (10, 14, 6, 10, 16, 7, 8, 9, 13 checks). Mutation checks (broken on purpose, tests must fail): hidden-wall rule off → HT10; exact match ignored → HT3, HT4, HT6; storey filter off (walls) → crash in HT2 (detected, not a clean FAIL); conflicts resolved to first → HT4; unlabelled default fallback → HT3, HT5, HT10; door storey filter off → HT11 (after adding HT11; my first mutation attempt was a syntax error and was redone); only one swing span → HT13; door conflict ignored → HT13. Gaps in my own tests found and fixed on the way: HT6 fixture had a one-wall envelope (so every face counted as the hidden opposite face); HT13 first merged its two clusters; HT13's assertion was too strict. A real-data finding changed a rule: Level-1 door 73 sat beside a 100 mm wall stub which became its host; the host-axis-based span was degenerate and the door got no elevation height. H5 was amended to use both swing end points (HT12 reproduces the case; HT12 itself was not mutation-tested against the old rule). Rosetta R6 (rosetta_dx_heights.js, log rosetta_dx_heights.log; round trip against DX_extracted.db; plan runs have NO wall or door heights in config, no fallback; elev_tol 0.003 m sandbox): Level 1: of 29 wall pieces 18 get their height from the elevations, 11 have none (interior walls not visible); all 18 equal the z range of a source wall that contains the piece (10 mm), 0 mismatches. Doors: 4 of the 6 get a height from the elevations (all 4 exterior doors), 4/4 equal the source door at that hinge; the 2 interior doors have none. Level 2: 12 of 27 wall pieces get heights (12/12 equal the source), 15 none; 0 of 8 doors (no Level-2 door appears in an exterior elevation). Mesh with these heights: 35 elements (L1 19, L2 16), 0 open or inside-out; windows heights (§27) unchanged. What this says: the elevations give correct heights wherever a wall or door is visible in them and nothing wrong anywhere (0 mismatches); the plan walls and doors that no exterior elevation shows (interior partitions, interior doors, all Level-2 doors) get a logged gap instead of a number unless the user supplies a fallback height, which is then labelled height_source: config. 26 of 56 wall pieces and 4 of 14 doors now have drawing-derived heights. Open: interior walls and doors (sections, or user fallback); roof; slabs; mitres; independent real-world DXF (Free Farmhouse download); Modeller wiring; commit/deploy.

30. Independent real-world set: Free Farmhouse "American Farmhouse" (2026-10-12)

Source: https://www.freefarmhouse.com/uploads/b/244cd9c0-4894-11ea-8aad-9b21e8eb84db/American%20Farmhouse%20CAD%20Files.zip (author Jay Osborne), downloaded 2026-10-12 after the user's "as you put it" to my stated plan (file name, source and 4.9 MB were given in the earlier message). SHA-256 f3c88893e8aab01f97ba134bb43cf130786e0a4905e8526683fea7b084090084. Kept ONLY in the session scratchpad (.../scratchpad/farmhouse/), outside every project folder; not committed, not republished. Licence: site says Creative Commons Attribution-ShareAlike (links 3.0); a text inside the drawing links licenses/by-sa/4.0. Obligations if anything derived from it (e.g. markings, extracted data) is ever published: attribution, share-alike. Nothing is published. Contents (7 entries, no unsafe paths; extracted only the two DXFs): American Farmhouse 2021-03-01.dxf (10,448,079 bytes, ASCII, $ACADVER AC1032, $INSUNITS 1 = inches), ... oldformat.dxf (8.4 MB, AutoCAD BINARY DXF, AC1015, not read), a DWG (not read). The main DXF parses with the project's dxf-parser.js in 0.3 s. Structure found (farm1.js on the main DXF): ONE modelspace of 17,750 entities holds the whole drawing set laid out as sheets: 48 BORDER 11X17 blocks, 545 blocks, text titles for site plan, 1st/2nd floor plan, foundation plan, roof plans, ceiling/electrical plans, SOUTH/WEST/NORTH/EAST elevations, full/building/longitudinal/transverse sections, interior elevations and details. Types: 12,940 LINE, 713 LWPOLYLINE, 1,648 MTEXT, 705 INSERT (symbol blocks such as window plan 30, door plan 24, window elev 3060, window elev 2448), 553 ARC, 467 DIMENSION, 424 SPLINE, 289 CIRCLE. Only 16 layers and the main ones are numbered 1, 2, 3, 4, 5 (5,915 / 3,916 / 2,137 / 1,160 / 1,553 entities), plus A-TEXTx, A-DIMSx, A-LINK, L-PLNT, A-ELEC, A-SYMB. Extent x -362,584..22,620, y -17,008..154,175 inches (very spread out: the sheets are far apart). What this implies (not yet built): (1) views are regions inside one modelspace, so a marking must say which region of the file a view occupies (new marking e.g. @REGION=x0,y0,x1,y1, entities outside it are not that view); (2) walls are drawn with LINEs on numbered layers, not footprint rectangles, so the layer map is a user decision per drawing and wall_mode will need double-line (pair) walls (deferred in §8.5); (3) windows and doors are symbol blocks (INSERT with names like window plan 30) with sizes implied by names: the library rule (§9.2) and a name-to-size table decided by the user; (4) US units (inches, feet) and metric elevation labels like 3060 (30 x 60 in) need an explicit units marking; (5) the set has no grid labels as in the Duplex, so alignment anchors have to be marked differently (e.g. shared corner points) or declared by hand. This is a much harder test than the Duplex; it is the first one the author did not generate from a model we hold, so there is no source BIM to compare with. Verification here can only be internal consistency (e.g. door and window symbols' plan positions equal the elevation positions after marking) and visual review.

31. Several views in one file: regions (spec, 2026-10-12)

Evidence (rendered and read 2026-10-12, see §30): the American Farmhouse DXF holds every sheet in one modelspace at true size (sheet frame 816 x 528 in = 11 x 17 in at 1/4" = 1'-0"), coordinates in inches. Sheet origins (INSERT BORDER 11X17): foundation plan (0,6375), 1st floor plan (1632,6375), 2nd floor plan (3264,6375), roof plan (4896,6375), south/west/north/east elevations (0,5319), (1632,5319), (3264,5319), (4896,5319), sections row y 4263, details lower. 1st floor plan sheet: 476 entities, mostly LINEs on numbered layers (1 199, 5 98, 2 43, 3 24), 5 ARCs, no INSERT symbol blocks on that sheet; double-line walls, 32'-0" x 16'-0" footprint, north arrow up, porch to the south. South elevation sheet shows level lines with text ROOF RIDGE +29'-2", ROOF EAVE +20'-0", 2ND FLOOR +10'-0" (T.O. FINISH), 1ST FLOOR +0'-0" (T.O. FINISH). - V1 New marking @REGION=x0,y0,x1,y1 (drawing units, any corner order; normalised to min/max): the rectangle of this view in the file. A marker's view is decided by position: the marker belongs to the REGION rectangle that contains its insertion point; zero or more than one containing region → MARK:view_ambiguous. A file with no @REGION marker is one implicit view (all existing behaviour unchanged). @NAME, @VIEW, @LEVEL, @FACE, @HAXIS, @UNITS, grids and @Z are per view; @SET must be the same for all views of a file. - V2 Dxf2Marks.describeAll(marks) returns one {region, desc, errors} per view. describe stays for single-view files. - V3 Region filter in the pipeline: deps.region = [x0,y0,x1,y1] keeps an entity when ALL its points (both ends of a LINE, every vertex, insertion/centre point otherwise) lie inside the region (inclusive). Entities with some points inside and some outside are NOT used: counted straddle and listed in the report; entities fully outside are counted outside_region only (not one gap each: the file has 17,750). DXF2BIM-MARK entities stay reserved/ignored. - V4 Anchors are free labels: where a drawing has no grid lines, the marker author names reference lines or points (e.g. @GRIDX=WEST_FACE, @GRIDX=EAST_FACE at the exterior wall faces in the plan; @GRIDH=WEST_FACE, @GRIDH=EAST_FACE in the elevation). The reader only checks that shared labels agree between plan and elevation (§24); the choice of labels and positions is the marking step, reviewed by looking at the drawing. - V5 @Z values are in metres; a marking tool may compute them from level text such as +10'-0" (= 3.048 m); the reader never parses level text. Tests: MK9 two views in one fixture file each with a REGION: markers are assigned to the right view and descriptors differ · MK10 a marker outside every region → MARK:view_ambiguous; a marker inside two overlapping regions → same error · MK11 file without any REGION still reads as one implicit view with identical result to before · MK12 two REGION markers with the same rectangle are one view (not an error) · T11 region filter: entities of a second sheet in the same file do not appear in the run; straddling entities are counted and not used; the counts are exact (hand-counted fixture) · T12 with region = whole file the result equals no region. Out of scope until the user decides: wall extraction for double-line walls (wall_mode: pair, deferred in §8.5 and now needed by this set), recognition of door and window from arcs and line sets without a library, and any name-to-size table for symbol blocks. Without them this set can still be used for: region splitting, marking, plan-to-elevation alignment, and checks of level lines and envelope dimensions against the dimension strings in the drawing.

32. Results: regions (2026-10-12)

Code: REGION key and describeAll in deploy/dev/dxf2marks.js; deps.region filter and report.region in dxf2bim.js; the region is part of run_id. Tests: MK9-MK12 (marks) and T11-T12 (pipeline) PASS; all 9 suites PASS (checks per suite: T 12, O 8, rect 6, MS 10, FP 16, WN 7, MK 12, EL 9, HT 13). Mutation checks (broken on purpose, tests must fail): straddling entities kept → T11; region left out of run_id → T12; outside counted as straddle → T11; overlapping regions accepted → MK10; duplicate regions not merged → MK12 (5/5 caught). Note MK10 shows that an overlapping @REGION marker itself is reported as in two regions (overlapping regions are an error). - SPLINE anchors: the region filter first left 424 SPLINEs without an anchor (the parser stores control points, not vertices); controlPoints/fitPoints are now anchors; test T13 (mutation: anchors dropped → T13 FAIL). All suites PASS (T 13). - Independent set at scale (farm4.js, whole pipeline entry with ignore-only layer map, 0.3 s per run): 1st floor plan sheet region [1632,6375,2448,6903]: 535 kept, 17,215 outside, 0 straddle, 0 no-anchor (535 + 17,215 = 17,750); south elevation sheet region [0,5319,816,5847]: 1,044 kept, 16,706 outside, 0 straddle, 0 no-anchor. - What the geometry shows (farm5.js, long lines in each region): the plan's west outer wall face is the single line 1: x=1836.00, y 6577.27..6769.27 (192 in = 16'-0", the full depth); the plan's walls are broken into short segments around openings, so no single long wall line exists; the elevation's siding lines span x 214.0..613.5 (399.5 in, wider than the 384 in plan because of corner boards/overhang), chimney at x about 190. So plan-to-elevation anchors cannot be taken from labelled grids here; they must be chosen as reference lines/points (V4), e.g. the west outer wall face in plan (x 1836) and the matching face line in the elevation. NOT yet chosen or verified. - Status of the independent test: region splitting works and is exact at scale; marking, wall extraction (double lines on numbered layers), openings and the plan-to-elevation alignment are not done. Decisions needed from the user are listed in the end of this turn's report.

33. Independent set: alignment and level checks (2026-10-12)

Scope agreed with the user: region splitting, marking, plan-to-elevation alignment and level checks first; wall extraction (pair walls) and openings from arcs/lines later and only after the user decides. Tools (sandbox, in tests/dxf2bim/): farm_mark.js (proposes markings from evidence, refuses when ambiguous, writes a marked COPY outside the project), farm_verify.js (checks the markings against evidence they were not built from). Logs farm_mark.log, farm_verify.log. The DXF and the marked copy stay in the session scratchpad; not committed, not republished (CC BY-SA, work of Jay Osborne, freefarmhouse.com). Views marked: 1st floor plan (sheet region [1632,6375,2448,6903]) and south elevation (sheet region [0,5319,816,5847]); units inches. Marking evidence (all from the file): plan outer wall faces = layer-1 lines at x 1836.000 (west, one 192 in line) and 2220.000 (east), south face y 6577.275, north face y 6769.275 (width 384 in = 32'-0", depth 192 in = 16'-0"). Elevation wall faces: layer-1 vertical lines pairs 384 in apart: three pairs exist ((204.54, 588.54), (216.04, 600.04), (227.54, 611.54)); the tool refused at first (3 candidates) and a stated evidence rule resolved it: only the pair 216.04/600.04 continues below the floor line into the foundation; the other two exist only in the roof band (rel. heights 240-347 in, roof edges). Levels: text +0'-0", +10'-0", +20'-0", +29'-2" paired with the nearest horizontal CENTER-linetype line on layer 3 (text sits 3.3 in below its line): y 5427.942, 5547.942, 5667.942, 5777.969. @Z = 0, 3.048, 6.096, 8.89 m. @HAXIS=+X, @FACE=minY (south). Result (farm_verify.log, 12/12 PASS): markings read back with 0 errors (2 views); alignment residual 0 m at the faces (both faces give plan x = h + 1619.9601 in); NEGATIVE CONTROL: with @HAXIS=-X the check fails (MARK:align_fail, residual 19.5 m). Independent evidence: the plan south wall has 3 openings (x 1898.25..1929.75 = 31.5 in, 2009.25..2046.75 = 37.5 in centred on the building, 2126.25..2157.75 = 31.5 in); the elevation shows 2 window gaps in the siding rows (273..315 and 501.08..543.08, each 42 in) and a door casing 384.04..432.04 (48 in) with a 36 in leaf (390.04..426.04); mapped through the markings, their centres differ from the plan openings by -0.040, 0.000 and +0.038 in, and every elevation opening is exactly 10.5 in wider than its plan gap (5.25 in trim each side, constant for door and windows); with the mapping deliberately 5 in off, the centre check fails (sensitivity control). Levels: sill lines at +2'-0" and +13'-0" (printed dimensions "2'" and "3'" above the floors) read as z 0.6093 m (exact 0.6096, 0.3 mm) and 3.9624 m; level spacing 120, 120, 110 in with the ridge line 0.027 in above exact (drawing drift of 0.7 mm), within the 2 mm tolerance. Caveats stated: the choice of "42 in gaps" and "tall verticals from the floor line" as the elevation openings was made after looking at the data (a consistency demonstration, not a blind test); the printed dimension strings used as level expectations were read by me from the rendered sheet; one plan and one elevation only (not the other three elevations, the 2nd floor plan, sections); no source BIM exists to compare walls, so nothing is claimed about wall or opening extraction on this set; the marks were proposed by a sandbox tool for this specific drawing's conventions. Open decisions for the user: wall_mode: pair (double-line walls on numbered layers) and opening recognition from arcs and line sets without a library; extending alignment to the other elevations and the 2nd floor plan.

34. Pair walls and exclusion regions (spec, 2026-10-12)

Agreed by user (2026-10-12, "agree with your first"): build double-line walls and opening recognition for the independent set. Evidence (script on the Farmhouse 1st floor plan, 370 LINEs, 355 axis-aligned): parallel same-layer line pairs on layer 1 at 3.50 in (140), 7.00 (113), 10.50 (111): nearly all come from the porch decking (boards every 3.5 in), not from walls; the real wall is e.g. layer-1 lines y 6577.27 and 6580.77 (3.5 in apart; segments split at openings: [1836,1898.25], [1929.75,2009.25], ... against [1839.5,1898.25], [1929.75,1980], ...), with cladding (layer 5, y 6575.27) and interior finish (layer 5, y 6581.52) lines outside them. West wall: x 1836.0 (192 in line) and 1839.5. A naive pairing would invent hundreds of walls from the decking, so two safeguards are part of the rule. - P1 wall_mode: "pair"; layer_map type wallline marks the layers whose LINEs are paired (only LINE in v1). Config (all required, no defaults): wall.pair_thicknesses (array of metres, the separations that count as one wall, e.g. [0.0889]), wall.pair_tol (metres), wall.pair_min_overlap (metres). A line is axis-aligned when |dx| or |dy| <= snap_tol; any other LINE on a wallline layer → GAP:wall_line_diagonal. - P2 Pair: two parallel lines of the same orientation whose perpendicular separation is within pair_tol of a listed thickness and whose overlap along the axis is >= pair_min_overlap. - P3 Pattern guard: a line that has MORE than one partner for that thickness (e.g. a run of equally spaced decking boards) and every line paired with it is excluded: GAP:wall_pair_ambiguous for each, no wall emitted. A run's end boards are caught too because their only partner is an ambiguous line. - P4 Unpaired: a wallline line with no partner → GAP:wall_line_unpaired. - P5 Piece: footprint wall (footprint: true, pair: [handleA, handleB]) with cross-axis extent = the two line coordinates (thickness = their separation, from the drawing) and axis extent = the UNION of the two lines' extents when each line's excess beyond the overlap is <= the pair thickness (corners and T-joins: the outer line runs to the corner), else the overlap only. Height/base, min_thickness, F4 duplicate guard and elevation mode apply as for footprint walls (§28). - X1 New marking @EXCLUDE=x0,y0,x1,y1 (repeatable, per view): areas of the drawing that carry no building geometry for this run (decking, hatch fields, notes). Entities with ALL points inside an exclude rectangle are skipped and counted (excluded); entities only partly inside are kept (counted exclude_straddle). describe/describeAll return desc.excludes (sorted); deps.exclude = list of rectangles in the pipeline; the list is part of run_id. Tests: PW1 two lines 3.5 in apart over 10 ft → one wall, hand rect · PW2 an opening gap in both lines → two pieces, none across the gap · PW3 L-corner where the outer line runs to the corner → pieces reach the corner (hand rects) · PW4 run of 5 equally spaced parallel lines → no wall, 5 wall_pair_ambiguous · PW5 lines 4 in apart (outside tolerance) → unpaired gaps · PW6 diagonal line → gap · PW7 missing pair_thicknesses → GAP:no_dimension · PW8 order independence (20 shuffles) · PW9 overlap shorter than pair_min_overlap → no wall · PW10 two thicknesses [3.5, 5.5] each pair recognised with its own thickness · EX1 @EXCLUDE parse (two rects, canonical order) · EX2 entities inside excluded, straddling kept, counts hand-checked · EX3 exclude list changes run_id · PW11 decking next to a wall: with an @EXCLUDE over the decking and without it (pattern guard only): the wall is found both ways and the decking never becomes a wall. Then (§35): openings from the drawn gaps between collinear pair-wall pieces, classified door or window and given heights by the aligned elevation (centre match, since the elevation draws trim around the opening); and the Farmhouse plan checked against its printed dimension strings. - P6 (added after the first real run, 2026-10-12): joining across T-joints. Config wall.pair_join_gap (metres, optional, no default; when absent nothing is joined). Before pairing, wallline lines of the same orientation on the same axis coordinate (within snap_tol) whose end-to-end gap is <= pair_join_gap (and negative gaps, i.e. overlaps) are merged into one logical line (extent = union, members listed). Reason: on the Farmhouse south wall the inner face line is interrupted by a 3.5 in interior partition (T-joint) at x 1980..1983.5, 2072.5..2076, 2108.5..2112, so the outer line had two collinear partners and the pattern guard (P3) wrongly called it ambiguous. A gap larger than pair_join_gap is NOT bridged (an opening); the two-partner outer line is then ambiguous as before. Pieces from joined lines list all member handles (members) besides pair. Tests: PW12 T-joint gap equal to the join gap → one wall (hand rect) · PW13 gap larger than the join gap → no join, ambiguous gaps for the lines involved, nothing vanishes · PW14 without pair_join_gap the T-joint case behaves as before (ambiguous) · PW15 members listed and accounting complete. - X1 note (same run): porch rim beams at x 1853 and 2199.5 (layer-1 pairs 3.5 in apart, y 6501.02..6575.27, the porch frame around the decking) are not walls; they sit outside the decking runs' bounding box. The marking tool now sizes each exclusion rectangle from ALL layer-1 vertical lines whose extent lies inside the decking zone's y-range, not from the decking lines only.

35. Results: pair walls and exclusion regions, Farmhouse 1st floor plan (2026-10-12)

Code: wall_mode: "pair" (layer_map type wallline; P1-P6) and deps.exclude (X1) in dxf2bim.js; @EXCLUDE in dxf2marks.js; the exclusion list is part of run_id. Tests: test_pair.js PW1-PW16, EX2, EX3 PASS; test_marks.js MK13 PASS; all 10 suites PASS (checks per suite: T 13, O 8, rect 6, MS 10, FP 16, WN 7, MK 13, EL 9, HT 13, PW 16+EX 2). Mutation checks (broken on purpose, tests must fail): pattern guard off → PW4, PW11; no ambiguity propagation → PW4, PW11; union rule off → PW3; pair_tol ignored → PW5; min overlap ignored → PW2, PW9; missing thickness defaulted → crash in PW7 (detected); exclusion counts straddlers as excluded → EX2; join disabled → PW12, PW15; any gap bridged → PW13; joined members not accounted → survived (equivalent in the fixtures; accounting is separately proven by PW15 and the real-plan ACCOUNTING check); join without tolerance → PW16 (the real float bug, below). Test-side mistakes found and fixed on the way: PW3 sort, EX2 count, PW16 helper arguments, farm accounting using pair instead of members. Real-data findings that changed rules: (1) first run: the pattern guard wrongly flagged the south wall as ambiguous because an interior partition interrupts the inner face line (T-joint) → P6 join across gaps up to one wall thickness; (2) the join compared a true 3.5 in gap (0.0889000000000024 m after rounding) with a 3.5 in limit and failed by 2.4e-15 m → join limit now has snap_tol tolerance (PW16 reproduces it; mutation without the tolerance fails PW16); (3) porch rim beams (x 1853, 2199.5) looked like walls → marking tool sizes exclusion zones from all layer-1 vertical lines inside the decking zone. Farmhouse 1st floor plan (farm_mark.js, farm_walls.js, logs farm_mark.log, farm_walls.log; sandbox tolerances stated in the scripts; plan region and exclusions come from the markings): 398 entities in the plan region (17,215 outside, 0 straddle); 2 exclusion zones (porch decking x 1851..2205 y 6491.77..6575.47; back steps x 2022.5..2079 y 6770.27..6810.47) hold 105 decking boards in 10 runs plus the rim beams: 162 entities excluded, 27 straddle. Pair rule (layer 1, thickness 3.5 in, join gap 3.5 in, min overlap 12 in): 17 wall pieces. Results 6/6 PASS: ENVELOPE 384.00 x 192.00 in (printed 32'-0" x 16'-0"); SOUTH-OPENINGS exactly 3 gaps of 31.5, 37.5, 31.5 in (measured independently earlier, §33) with 4 pieces; DIMS-12-8-12: interior partition faces at 143.9999 and 239.9999 in from the west face (printed 12' + 8' + 12'); NO-DECKING-WALLS: 0 walls in exclusion zones; ACCOUNTING: all 199 layer-1 lines are excluded (125), in walls (43) or gap-logged (31), 0 lost; DETERMINISM identical. What is NOT claimed: 31 layer-1 lines remain gaps (26 wall_line_unpaired, 5 wall_pair_ambiguous: stairs, kitchen, fixtures and similar, not classified); 7 non-LINE entities on layer 1 are unsupported; the other four exterior walls' openings other than the south wall were checked only through the envelope, not opening by opening; no source model exists, so correctness of the extracted walls beyond the printed dimensions is not established; wall heights are not set (elevation mode, pending); the exclusion rectangles and the choice of layer 1, 3.5 in and tolerances were made by looking at this drawing. Next (not started): openings from the drawn gaps between collinear pair-wall pieces, classified door or window with heights from the aligned elevation by centre match (the elevation draws 5.25 in trim around each opening); then wall heights from the elevation; then the same markings for the other elevations and the 2nd floor plan.

36. Openings from drawn gaps, classified by marked elevation openings (spec, 2026-10-12)

CORRECTION to §33 (found 2026-10-12): check LEVEL-sills there is vacuous. The south elevation has horizontal siding rows every 6 in (rows at rel. y 0, 6, 12, ... 90, 96 above the 1st floor line), so a line at +24 in or +156 in exists whatever the sill height is. The 12/12 result of §33 stands for alignment, openings and level spacing; the sill claim is withdrawn. Evidence on the Farmhouse south elevation (script farm15/16.js, 2026-10-12): the ground-floor windows have NO frame lines. A window is a rectangular absence of siding: left window h 273..315, siding rows missing from rel. y 18 up to 90 (rows at 12 and 96 continuous). The printed dimension strings beside the elevation read (from the render) 2'-0" floor to sill, 6'-6" window, 1'-6" to the 2nd floor, i.e. sill 24 in, head 102 in, which is 6 in higher than the siding gap (18..96 as row positions). So the drawing does not give an exact sill/head by geometry; the printed dimensions and the siding gap disagree by one siding course. Door: casing lines h 384.04..432.04 (outer, 90 in high) and leaf lines 390.04..426.04 (84 in high, layers 3/2), reaching the floor line. Design: the elevation openings are MARKED (the marking step looks at the drawing and states each opening's rectangle, kind and where its z comes from); the pipeline reads them and does not guess. - O1 Marking @OPENING=kind,h0,y0,h1,y1 (view-level, repeatable): kind door or window; rectangle in the elevation's drawing units (horizontal h0..h1, vertical y0..y1; any order, normalised). Through the alignment (§24) it gives a plan interval [p0,p1] and heights [z0,z1]. desc.openings canonical order. - O2 Gap openings (plan): in footprint/pair-wall output, two wall pieces on the same wall axis (collinear: same cross-axis extent within snap_tol, same thickness) separated along the axis by a gap g with opening.gap_min <= g <= opening.gap_max (metres, required, no default) define one candidate opening: kind: "opening", pending_classification: true, centre (middle of the gap), width = g, depth = wall thickness, angle_deg 0 (axis along x) or 90, bbox, gap_between = [guid before, guid after], host_wall = guid of the piece before the gap. Gaps outside the limits, unbounded ends, and non-collinear neighbours produce nothing. - O3 Classification and heights (classifyOpenings): a candidate on a face of the building envelope (neighbour pieces touch the face plane within snap_tol) is matched to marked elevation openings of that face when the centre along the face axis agrees within config.elev_centre_tol (metres, required) and the marked z interval lies in the storey range [level, level + wall.height] (or just above level - tol when wall.height is not given). Each match gives one opening: category = the mark's kind, sill = z0 - level, height = z1 - z0 (door sill from the mark too), plan width/depth/centre from the gap (not from the elevation: the elevation draws trim around the opening). Several marks with the same centre (stacked windows) give several openings. - O4 Gaps, never guesses: a candidate with no matching mark → GAP:no_elevation_match (interior gaps always end here unless an interior elevation is marked); a mark that matches no candidate is reported (unmatched_elev). Candidates on a face with no marked elevation → GAP:no_elevation_for_face. - O5 Mesh: derived openings (kind: "opening") use the same wall-split code as rect openings (centre, angle_deg, width, depth, sill, height); because they lie in a drawn gap they become NOTE:opening_in_drawn_gap. A pending candidate (no sill) is never meshed. - O6 Each derived opening records height_source: "marked_elevation" and the mark index; the marking tool states for every @OPENING rectangle where z came from (siding gap, casing lines, printed dimensions). Tests: MK14 parse of @OPENING (valid, bad kind, canonical order, per view) · GO1 gap between two collinear pieces → candidate with hand centre/width/depth/angle · GO2 gap below gap_min and above gap_max → none · GO3 pieces of different thickness, or offset axes → none · GO4 vertical wall gap → angle 90 · GO5 mark centre within tolerance → opening with category, sill and height from the mark (level 0) · GO6 two marks with the same centre and different z → two openings (stacked) · GO7 door vs window from the mark kind · GO8 no mark → GAP:no_elevation_match, unmatched mark reported · GO9 centre off by tolerance + 1 mm → no match; off by tolerance - 1 mm → match · GO10 mirrored REAR elevation → same plan centre · GO11 order independence · GO12 mesh: derived window in a drawn gap → NOTE:opening_in_drawn_gap, wall volumes unchanged, no opening_outside_wall. Then the Farmhouse: south wall 3 gaps classified by 3 marked elevation openings (2 windows, 1 door); @OPENING rectangles proposed by the sandbox tool from evidence (siding-gap columns for the horizontal extent, casing/leaf lines for the door) with the vertical extent taken from the printed dimension strings and labelled as such; centre agreement checked, and the 6 in disagreement between siding gap and printed dimensions reported, not hidden.

37. Results: openings from gaps (Farmhouse) and type binding (2026-10-12)

Code: gapOpenings, classifyOpenings, elevMarkOpenings in dxf2elev.js; @OPENING in dxf2marks.js; kind: "opening" pending candidates are never meshed (dxf2mesh.js). Tests: test_gaps.js GO1-GO16 PASS (log test_gaps.log), MK14 PASS; all 11 suites PASS (checks per suite: T 13, O 8 [O1-O6, O13, O14], rect 6 [O7-O12], MS 10, FP 16, WN 7, MK 14, EL 9, HT 13, PW+EX 18, GO 16 = 130). Mutation checks (broken on purpose, tests must fail): gap_min ignored → GO2; gap_max ignored → GO2; collinearity ignored → GO1, GO3, GO4, GO10; centre tolerance ignored → GO8, GO9; sill absolute → GO13; stacked marks merged → GO6; unmatched marks not reported → GO8; interior gap given a face → GO14; kind ignored → GO7; storey upper limit ignored → survived first, GO16 added, then caught (10/10). Farmhouse (farm_mark.js, farm_open.js, logs farm_mark.log, farm_open.log): the marking tool proposes @OPENING rectangles for the south elevation from evidence: horizontal extent from siding-row gaps (>= 20 in) at rel y 60, door recognised by casing lines reaching the floor line, window sill and head (24 and 102 in) from the drawing's own vertical DIMENSION entities (2362: floor->24.0, 2370: 24.0->102.0; chain also 3', 7', 1'-6" above), door height 84 in from the leaf lines. Plan run (pair walls as §35): 9 gap candidates; 6/6 checks PASS: FG1 south wall gives exactly the 3 gaps (31.5, 37.5, 31.5 in at x 1914, 2028, 2142); FG2 classified as window, door, window with sill/height 24/78, 0/84, 24/78 in, height_source: marked_elevation; FG3 centre differences elevation-vs-plan 0.040, 0.001, -0.038 in; FG4 9 candidates = 3 classified + 6 logged (4 no_elevation_for_face on faces with no marked elevation, 2 no_elevation_match interior/other); FG5 0 unmatched marks; FG6 deterministic. Evidence about what the "small lines" are (answer to the user's question, from the file): siding rows every 6 in = finishes schedule F-24 "clapboard siding 6" fibre cement"; the 5.25 in trim around openings = F-26 "ext opening casing 5-6" fibre cement .75" thick"; wall layers (cladding line layer 5, structure layer 1, interior finish layer 5) = detail sections ("fiber cement lap siding, ext sheathing w/ building wrap, 3/4" air space ..."); door schedule A/B 36"x84" glazed panelled composite (Marvin), C 24"x80" 1-panel wood, D 30"x80" 5-panel wood; window schedule 1 = 30"x60" and 2 = 24"x48" double hung 2/2 lites (Marvin Ultimate DH). So type, size and material are stated in schedules, details and notes; the small lines are the drawn expression of those. Conflict found and left open: the marked window height from the elevation dimension entities is 78 in, the window schedule says 60 in; the plan gap width 31.5 in is consistent with a 30 in unit (+1.5 in). The pipeline must report such schedule-versus-drawing conflicts, not choose silently. Design (type binding, proposal, NOT implemented): the pipeline's job is the coherent geometry skeleton with provenance: placement, measured plan dimensions, heights, host, face, kind, and a type REQUEST per element (class, measured size, optional label from the drawing such as a schedule mark). A component library supplies the type (layers and materials of a wall, frame and glazing of a window, leaf of a door); binding = match measured size and label to a library entry, with mismatches reported (like the 60 vs 78 in window height); the user can bind explicitly (marking or table). Existing material in the repo to read before designing the binding: docs/BOMBasedCompilation.md, docs/BIM_Modeller_OOTB.md, library/DX_BOM.db, deploy/component_library.db, deploy/component_library_duplex.db. Not read yet: how the Modeller selects a library component and sets its parameters; whether kernel_ops carries a type key.

  • Correction (2026-10-12, at close): earlier build-log lines gave O 14 for the openings suite; it has 8 checks (O1-O6, O13, O14), test_rect.js has 6 (O7-O12). Total 130 checks over 11 suites.

38. Type binding (spec, 2026-10-12; read-only study done, NO code yet)

Evidence read (2026-10-12), from the repo: - Library = deploy/component_library.db. Type tables: ad_wall_type (18 rows; wall_type_id, category EXTERIOR/INTERIOR/PARTY/FOUNDATION, construction, total_mm = geometry truth, stud_mm, is_loadbearing, is_exterior, ifc_class), ad_opening_family (68 rows; family_id, opening_type DOOR/WINDOW, default_width_mm, default_height_mm, depth_mm; 37 windows), material_layers (60 rows: layer_set_name, sequence, material_name, thickness_m), ad_slab_spec, component_types/component_definitions (mesh leaves, 23,888). component_library_duplex.db holds only component_geometries, surface_styles. - Selection today is rule-driven, not by size: ad_wall_type_rule (context + room types -> wall_type_id), ad_wall_face (room face -> wall_type_id). No size-based match exists. Door/window families carry fixed default width/height; the BOM docs say ASI overrides per instance (effective = ASI ?? catalog), the compiler scales the library mesh, never generates geometry (BBC §2, §3.5.1). - kernel_ops has NO type key: columns are op_type, parameters (JSON), input_guids, output_guid. The only placement op the Modeller writes is ELEMENT_PLACE (callers: route_walker.js, time_machine.js) with free-form params (family, cls, name, ...). So a type reference can travel as a param field without a schema change. - NOT found: any code in deploy/dev/*.js or index.html reading ad_wall_type, ad_opening_family or material_layers (grep 0 hits). The Modeller does not select library types by these tables today. How the Modeller picks a component for a wall/door/window is therefore NOT established; the binding target on the Modeller side is open.

Design (decision D-T1 below needs the user): - B1 Pipeline output unchanged in geometry. Each wall/opening/component gets a type_request object: {class, measured: {thickness|width|height|depth, unit m}, label (string from the drawing or marking, else null), label_source (handle or "marking"), }. Values come from the already-measured fields; nothing is added from the library. - B2 Binder is a separate pure function bindTypes(ops, library, bindings) in a new module dxf2types.js. library is plain rows read by the caller (no DB inside the module). bindings = explicit user table {label|element-selector -> type_id} (optional). - B3 Match order: (1) explicit binding; (2) label equals a library id/name (exact, case-sensitive); (3) size match: exactly one library entry of the same class whose size equals the measurement within bind.size_tol (required config, metres, no default). Zero or several candidates -> no bind, GAP:type_unbound (several: list the candidates). Never pick the nearest. - B4 Bound element records type_id, bind_source (explicit|label|size), and type_delta per dimension (measured minus library). Any delta beyond size_tol is GAP:type_size_mismatch (reported, geometry keeps the MEASURED value, because the drawing is the source). - B5 Schedule conflicts: a drawing label with a stated schedule size (provided as data by a marking, e.g. @TYPE=window,1,0.762,1.524) that differs from the measured size -> GAP:schedule_conflict with both values. First test case: Farmhouse window mark 1: schedule 60 in high vs elevation 78 in. - B6 Layered walls: material_layers rows for a wall type give layer order and thickness; the sum must equal the measured wall thickness within size_tol or the bind is refused (GAP:layers_sum_mismatch). Drawn wall-layer lines (cladding/structure/finish layers) are a possible EVIDENCE source for layer order only; not built. - B7 Output op params carry type_id etc. as object fields; no new kernel_ops column.

Tests (each names the issue; written only after the user settles D-T1): TB1 explicit binding wins over label and size · TB2 label hit · TB3 size hit with exactly one candidate · TB4 two candidates in tolerance -> unbound, both listed (issue: silent nearest pick) · TB5 zero candidates -> unbound gap · TB6 size beyond tolerance -> mismatch gap, geometry unchanged (issue: library overwriting the drawing) · TB7 schedule vs measured conflict reported with both numbers · TB8 layer sum mismatch refuses · TB9 order independence and determinism · TB10 missing bind.size_tol -> GAP:no_dimension (no default).

Decision D-T1 (user): where does the type live for the Modeller? (a) as ELEMENT_PLACE params only, Modeller side not touched yet; (b) also map to the Modeller's component mesh (needs a study of scene.js/import.js, not done). Recommended: (a) first, as a sandbox, matching the standing order of work. - D-T1 settled by user (2026-10-12): (a). Principle stated by the user: the 2D set resolves to a 3D OUTLINE; the mesh library is called on demand; each side has its own handling; the 2D→3D line stays clean. Consequence: dxf2bim.js is NOT changed; dxf2types.js (bindTypes) attaches type_request (B1) and type to a COPY of the ops; the outline never depends on the library. - Implementation notes: library passed in metres as {wall:[{id,thickness,layers?}], window:[{id,width,height}], door:[...]} (caller converts from mm rows); wall binds on thickness only, window/door on width+height (depth is the host wall's, not a type property); explicit binding with two different type ids → GAP:type_binding_conflict; explicit id not in library → GAP:type_unknown; label bind is exact id match. Gaps are appended sorted by code+guid.

39. Results: type binding sandbox (2026-10-12)

Code: deploy/dev/dxf2types.js (bindTypes, request); dxf2bim.js untouched. Tests tests/dxf2bim/test_types.js TB1-TB13 PASS (log test_types.log), on hand-built ops (no real drawing, no real library rows yet). Mutation checks (broken on purpose, tests must fail): explicit skipped → TB1, TB6, TB12; label skipped → TB2; nearest pick → TB4; tolerance exact → TB4; mismatch gap off → TB6; library overwrites drawing → TB6; layer check off → TB8; schedule check off → TB7; tol defaulted → TB10; input mutated → TB11; unknown id silent → TB12; conflict ignored → TB12; class ignored → first crashed (not a clean fail), TB13 added, then caught. 13/13 caught. Test-side mistake: TB9's first sort comparator mixed null keys and failed; fixed in the test, module was right. Not done: real library rows (mm→m adapter from ad_wall_type/ad_opening_family), run on the Duplex/Farmhouse ops, labels from drawings (params.label is never set by the pipeline yet), @TYPE marking for schedules, emitting type into ELEMENT_PLACE params, layered walls from drawn layers.

40. Duplex run of type binding (spec, 2026-10-12)

User direction: use the Duplex to see whether the method works both ways between the model and its 2D set, as an abstract template formula. Direction A (model → 2D set → outline → type) is run now with ground truth from the source model; direction B (typed outline → 2D set, T6/§16) needs an op→DXF writer that does not exist and is NOT part of this step. Facts found before designing (library and source, 2026-10-12): the source gives each element a type (elements_meta.element_name/element_type, e.g. Basic Wall:Exterior - Brick on Block, door 0762 x 2032mm, window 819mm x 759mm). The library holds the SAME product twice: nominal (DUPLEX_INTERIOR_762 762x2032) and drawn-bounding-box (US_INT_DOOR_762 914x2108, i.e. nominal + 2x76 mm casing, + 76 mm head); the exported drawing measures the bounding-box size (door widths 1.402, 0.965 in the heights run). Wall types 417 mm exist twice (EXTERIOR_BRICK, FOUNDATION_417). - R-T1 Library is read as rows from deploy/component_library.db (ad_wall_type.total_mm, ad_opening_family.default_*_mm, divided by 1000) with NO filtering: all 18 wall types and 68 families are candidates, so ambiguity is real. - R-T2 Truth key. Doors/windows: the first NNN x NNN pair of the source element_type (digits only) vs the same pair in the library row's description (DUPLEX_* and US_* rows carry it); a bind is CORRECT when keys are equal (either the nominal or the bounding-box row of the same product counts, and the log says which family was hit). Walls: source type name → wall_type_id by a table taken from ad_wall_type.description (labelled FROM_LIBRARY_DESCRIPTION); a wall bind is CORRECT when it equals that id. - R-T3 Truth lookup for our elements: wall piece → containing source wall (as R1a); door → source door containing the hinge (±0.02 m, as R6); window → source window whose plan rectangle contains our window centre (±0.05 m). - R-T4 An element whose measured size has a missing dimension (door height without elevation) is NOT matched on the remaining dimension: GAP:type_request_incomplete with the missing names (new rule in bindTypes; test TB14). - E1 No wrong bind: bound element whose key differs from truth = 0 (issue: size matching picking a wrong product). Unbound/ambiguous elements are listed, not failures. - E2 Control: the same scoring with one deliberately wrong explicit binding must count exactly that element wrong (issue: a scorer that cannot fail). - E3 Coverage report (not pass/fail): per class bound / ambiguous / incomplete / no candidate, and which library family was hit. - E4 The outline fields (rect, thickness, width, height, sill) of every op are identical before and after binding (clean line). Sandbox values: bind.size_tol 0.005 m (SANDBOX, rounding of mm drawing coordinates is 0.001; stated in log).

41. Results: Duplex type binding, direction A (2026-10-12)

Code: tests/dxf2bim/rosetta_dx_types.js (log rosetta_dx_types.log); dxf2types.js gained GAP:type_request_incomplete (R-T4, test TB14, mutant caught) and reads kind for door arcs/glazing clusters. All 12 unit suites still PASS (TB1-TB14: 14 checks in test_types.js). Library unfiltered: 17 wall, 37 window, 31 door rows; bind.size_tol 0.005 m (SANDBOX). Truth = the source model's own element type (round trip: DXFs were exported from that model). Result (56 typed requests over both storeys; the run FAILS 1 of 7 checks, E1-wall): - Doors 4/4 bound and correct (E1-door, E5-door). They hit the bounding-box rows (US_DBL_DOOR_1250 x2, US_GLASS_DOOR_813 x2), not the nominal DUPLEX_* rows, because the drawing shows the casing-inclusive size (D-2). 2 of 6 Level-1 doors and all 6 Level-2 doors have no elevation height → no op to bind (they are existing no_elevation_height gaps). - Windows 0/22 bound; 22/22 ambiguous between two library rows of the same product and size (DUPLEX_* nominal vs US_* row, e.g. DUPLEX_LIVING_WINDOW | US_FIXED_4835). The binder correctly refuses to pick (E5-window: every candidate list contains only the right product). E1-window passes with 0 binds: it proves nothing alone, hence E5. - Walls 30 pieces: 8 correct, 1 WRONG, 21 unbound. Unbound: 417 mm pieces are ambiguous EXTERIOR_BRICK | FOUNDATION_417 (same thickness; storey is the only difference and the drawing does not carry it) and 152 mm pieces EXTERIOR_MY_BRICK_150 | EXTERIOR_V1 | INTERIOR_FURRING_152; 3 pieces on Level 2 (89 mm, 60 mm) have no candidate within tolerance (not in library). WRONG (E1-wall): Level 1 piece E0 bound to INTERIOR_V2 (100 mm). Cause, verified from the log: E0 is a 0.100 x 0.417 m stub between two openings inside the 417 mm Exterior-Brick wall; the pipeline reports the shorter side (0.100) as thickness, but the wall's thickness is the 0.417 side. Size matching trusted a wrong measurement. NOT fixed: needs wall-axis knowledge for pieces shorter than the host depth (a dxf2bim decision, or a binder guard). Recorded as defect. - E2 (scorer control): one deliberately wrong explicit binding raises wrong count from 1 to 2: the scorer can fail. E4: outline fields of all ops identical before/after binding (clean 2D→3D line holds). Mapping used for walls (WALLMAP, FROM_LIBRARY_DESCRIPTION): 8 source wall type names → library ids; PARTY_CMU_550 accepted for the party wall because its description says "2 in Duplex IFC". Open decisions (user): (1) the library holds each Duplex product twice with identical size (nominal and bounding-box rows); how to pick: dedupe in the library, a preferred-family setting, or explicit bindings; (2) 417 mm and 152 mm wall ambiguity: storey/category evidence (e.g. foundation level) or explicit binding; (3) stub thickness defect in dxf2bim. Direction B (typed outline → 2D set) is not built; it needs an op→DXF writer (SPEC §16, T6).

42. Preferred set in type binding (spec, 2026-10-12)

User agreed (2026-10-12) to the recommendation of §41: a preferred-family setting, explicit bindings as override. Generalised to SETS: a set is a named group of library entries (a product family, a BOM, a supplier range). A set is the unit a user picks; "prefer" is the rule that lets a set break a size tie. - S1 A library entry may carry set (string). Entries without set are never preferred. - S2 cfg.bind.prefer = ordered list of set names (optional; absent = no preference, behaviour as §38). Used ONLY in the size step with more than one candidate: take the first set in the list that has at least one candidate; if exactly one candidate in that set → bind, bind_source: "size_prefer", type.preferred_over = the other candidate ids (sorted), type.set. If that set still has several candidates → GAP:type_unbound as before with candidates (all) and prefer_narrowed (the set's). If no listed set has a candidate → as before. - S3 prefer never overrides an explicit binding or a label hit, never rescues a size mismatch (candidates are chosen by size first), and does not apply when there is exactly one candidate (that bind stays size, even if its set is not preferred). Tests: TB15 two same-size candidates in different sets, one preferred → bound to it, preferred_over lists the other · TB16 two candidates inside the preferred set → unbound, prefer_narrowed lists both · TB17 single candidate outside the preferred set → still bound as size · TB18 explicit binding beats prefer · TB19 order of the prefer list decides · TB20 preferred set but candidate sizes outside tolerance → no bind (prefer never rescues). Sandbox set assignment for the Duplex run: by id prefix (DUPLEX_ → set DUPLEX_NOMINAL; US_ → set US_BBOX); labelled SANDBOX, since component_library.db carries no set column. A real set definition is an open decision (below).

Question put by the user (2026-10-12): is a BOM collection / set of the same building materials a good selection, and could the library live online at OCI so the Modeller is not burdened (embedded ones too; the user may opt to load them onto the machine but outside the browser)? Assessment recorded here, nothing built: - A set is the right selection unit: it removes duplicates by construction (one family per set), carries intent (this building uses these materials), and matches the BOM model already in the repo (a BOM groups products under a parent). prefer is then a special case: the chosen set first. - The binder stays pure: it takes plain rows. Where rows come from (embedded db, OCI, local file) is a loader outside dxf2types.js; 2D→3D does not depend on it. This keeps the clean line the user asked for. - Needed for traceability when rows come from a remote source: a set id + version + content hash recorded in the run (run_id input), else the same drawing can bind differently on another day. - NOT known from the repo and not assumed: the OCI endpoints, auth, format and the Modeller's current loading path for component_library.db. These need the user's input before any loader spec.

43. Results: preferred set (2026-10-12)

Code: prefer + set in dxf2types.js (S1-S3). Tests TB15-TB20 PASS (test_types.js now 20 checks); all 12 unit suites fail=0. Mutation checks: prefer off → TB15, TB16; narrowed>=1 binds first → TB16; set ignored → TB15, TB16 (+ crash in TB19, which also detects); prefer before explicit → TB1, TB6, TB12, TB18; preferred_over dropped → TB15; single candidate requires preference → TB3, TB8, TB17; list order reversed → TB19 (7/7 caught; two via a crash as well as failed checks). Duplex run with prefer: ['DUPLEX_NOMINAL'] (sets by id prefix, SANDBOX): windows 10 of 22 bound and all 10 correct (DUPLEX_LIVING_WINDOW 2, DUPLEX_NARROW_WINDOW 4, DUPLEX_BEDROOM_WINDOW 4); the other 12 stay ambiguous INSIDE the preferred set: 819x759 has two library products (DUPLEX_SMALL_FIXED, DUPLEX_CASEMENT); the source has 8 M_Fixed and 4 M_Casement of that size, and the glazing in the drawing does not tell them apart. Without prefer all 22 were ambiguous (E6 control). Doors unchanged (4/4, bounding-box rows, since no nominal-size candidate exists at the drawn size). Walls unchanged (8 correct, 1 wrong stub, 21 unbound). rosetta_dx_types.js: 8 checks, 7 PASS, 1 FAIL by finding (E1-wall). What closes the 12: a label or explicit binding from outside the geometry (a window schedule/legend in the drawing, or the user's table). Nothing in the geometry decides it, and the binder does not guess.

44. Labels and schedules by marking (spec, 2026-10-12)

Why: the geometry cannot tell two products of the same size apart (§43: 12 Duplex windows 819x759, Fixed vs Casement; Farmhouse window mark 1: schedule 60 in vs elevation 78 in). The information is in the drawing's text (a legend, a schedule, a tag next to the element) or in the user's head; the marking step states it. Same principle as @OPENING: the marker author looks at the drawing and states, the pipeline reads and does not guess. - L1 @LABEL=class,x,y,text (PLAN view only; repeatable): class = door|window|wall; x,y = a point in drawing units on or in the element; text = the label (everything after the third comma; non-empty). A label text equal to a library id binds through §38 B3 step 2 (label); any other text is a schedule mark. - L2 @SCHEDULE=text,class,w,h (door|window) or @SCHEDULE=text,wall,t (repeatable, any view; drawing units, converted with @UNITS): the size the drawing's schedule states for that mark. Used only for the conflict check (B5); never to overwrite measured values and never to bind by itself. - L3 describe returns desc.labels ({class,x,y,text}) and desc.schedule ({text,class,w,h|t}), canonical order, exact duplicates merged. Errors: bad class/arity/number → MARK:malformed; same (class,x,y) with two texts, or same schedule text+class with two sizes → MARK:conflict. - L4 applyLabels(ops, desc) (in dxf2types.js, pure): the point (drawing units × UNITS[desc.units]) selects the op(s) of that class whose plan footprint contains it (±snap_tol passed in options): wall → rect; window → bbox if present else centre ± width/2 along the axis and ± depth/2 across; door → within width of hinge (the swing disc; SANDBOX rule, stated). Exactly one op → params.label = text, params.label_source = "marking". Zero → GAP:label_unmatched; more than one → GAP:label_ambiguous, nobody labelled; an op that already has a different label → GAP:label_conflict. - L5 scheduleFromDesc(desc) → {text: {class, width, height | thickness}} in metres for bindTypes opts.schedule; bindTypes ignores a schedule entry whose class differs from the element's. Tests: LB1 parse, canonical order, bad arity/class/number rejected, per-view leak · LB2 label lands on exactly the window it points into · LB3 point in no element → label_unmatched · LB4 point in two overlapping elements → label_ambiguous, none labelled · LB5 door by hinge disc · LB6 unit independence: the same point in mm and in inches labels the same element · LB7 library-id label end-to-end: two same-size library products in one set are told apart by the label (the Duplex 819x759 case) · LB8 schedule conflict end-to-end with inches (60 in vs 78 in) reported with both values in metres · LB9 existing different label → label_conflict · LB10 order independence of markers · LB11 schedule entry of another class ignored by bindTypes. Duplex use (round trip, labelled as such): the marking step for the exported Duplex set takes the window type from the SOURCE model (M_Fixed / M_Casement) and writes @LABEL=window,x,y,DUPLEX_SMALL_FIXED|DUPLEX_CASEMENT. This proves the mechanism, not discovery: on a real drawing the label comes from its legend or from the user. - L1b (added after the Duplex run, 2026-10-12): STACKED openings share one plan footprint (Duplex Level 2: three 819x759 windows per plan position, sills 0.1 / 0.9255 / 1.751 m above the level, source z 3.2 / 4.0255 / 4.851 m). A plan point alone is then GAP:label_ambiguous (correct behaviour, seen in the first Duplex run: 0 of 12 labelled). Fix: an optional 4th field z=<metres> (absolute z, same convention as @Z): @LABEL=window,x,y,z=4.1,TEXT. With z, only elements whose absolute z interval contains z can match: opening → [level+sill, level+sill+height], wall → [level+base, level+base+height]; an element without those numbers never matches a z label. applyLabels takes opts.level (metres, required when any label has z, else GAP:no_dimension). desc.labels[i].z present only when given; z is part of the duplicate/conflict key. Tests: LB13 z picks one of three stacked windows · LB14 z outside every interval → label_unmatched · LB15 parse of z=, malformed z=abc, same point different z are different labels.

45. Results: labels and schedules (2026-10-12)

Code: @LABEL (with optional z=) and @SCHEDULE in dxf2marks.js; applyLabels, scheduleFromDesc in dxf2types.js. Tests: test_types.js now 35 checks (TB1-TB20, LB1-LB15), test_marks.js unchanged 14; all 12 unit suites fail=0. Mutation checks (broken on purpose, tests must fail): ambiguous picks first → LB4; unmatched silent → LB3, LB5; units ignored → LB6; overwrite label → LB9; hinge radius huge → LB5; schedule class ignored → LB11; schedule not scaled → LB8; label/schedule conflict not flagged, label class unchecked, duplicates kept → LB1; class check in hits removed → SURVIVED first (all labels in fixtures hit one class only), LB12 added (window inside wall footprint), then caught; z ignored / z tolerance huge → LB13, LB14; level ignored → LB13; level not required → LB14; z dropped from duplicate key, bad z accepted → LB15 (18/18 caught). Real-data finding that changed the design: first Duplex run with plan-point labels labelled 0 of 12 (all label_ambiguous), because each 819x759 window is three STACKED windows with one plan footprint; the log was read, L1b (z=) added. Duplex (rosetta_dx_types.js, log): labels from the SOURCE model (round trip, FROM_SOURCE_MODEL): 12/12 applied, 0 label gaps; windows 22/22 bound and correct; library rows hit: DUPLEX_LIVING_WINDOW 2, DUPLEX_NARROW_WINDOW 4, DUPLEX_BEDROOM_WINDOW 4, DUPLEX_SMALL_FIXED 8, DUPLEX_CASEMENT 4, equal to the source mix (8 Fixed, 4 Casement). Script totals: 9 checks, 8 PASS, 1 FAIL by finding (E1-wall, stub thickness; unchanged). Doors 4/4, walls 8 correct / 1 wrong / 21 unbound as before. Not done: reading labels from drawing TEXT near elements (legend/tag), Farmhouse schedule run (@SCHEDULE for window mark 1, 30x60 in vs elevation 78 in: unit test LB8 covers the mechanism with these numbers only), wall ambiguity (417/152 mm), stub thickness defect, type into ELEMENT_PLACE, set loader (OCI) and the real set definition.

46. Wall stubs: orientation from neighbours (spec, 2026-10-12)

User agreed (2026-10-12) to fix the stub defect of §41 in dxf2bim, not in the binder. Defect: a footprint wall piece takes its axis from its longer side. A pier between two openings of a thick wall (Duplex Level 1, piece E0: 0.100 x 0.417 m inside the 0.417 m Exterior-Brick wall) is shorter along the wall than the wall is deep, so the longer side is across the wall: the piece was emitted as a 0.417 m long, 0.100 m thick vertical wall. Wrong thickness, then a wrong type bind. - W-S1 Scope: non-square footprint pieces without pair (pair walls get thickness from the drawn separation). Needs opening.gap_max in the config (the same required value as gap openings, §36 O2); absent → no reorientation, behaviour as before (no default invented). - W-S2 Neighbour votes. For a piece P with rectangle r and another footprint piece N with rectangle n (original, own-axis orientation, squares excluded as voters and as subjects): N votes h (P lies in N's horizontal strip) when N is horizontal, P's y-range equals N's y-range within snap_tol, and the x-gap between them (max(n.x0 - r.x1, r.x0 - n.x1)) is within [0, gap_max] (within snap_tol); N votes v symmetric for a vertical N (x-ranges equal, y-gap). - W-S3 Decision. Only h votes → P is horizontal; only v votes → P is vertical; if that differs from P's own orientation the piece is re-emitted with start/end/length/thickness of the voted axis (rect unchanged) and orient_source: "neighbour", orient_from: handle of the lowest-handle voter. Both kinds of vote → P keeps its own orientation and GAP:wall_orientation_ambiguous is logged (piece still emitted, nothing lost). No votes → unchanged. - W-S4 Why equality of the cross-axis range: a perpendicular partition meeting a wall (T-joint) extends beyond the wall strip, so its range does not equal the strip; only a piece lying exactly inside the strip votes. Tests (file test_stub.js): ST1 pier between two openings gets the wall thickness, horizontal, hand values · ST2 no opening.gap_max → unchanged · ST3 gap beyond gap_max → unchanged · ST4 T-joint partition (range exceeds the strip) → unchanged · ST5 both horizontal and vertical neighbours → own orientation kept and ambiguity logged · ST6 square piece untouched · ST7 order independence of the polylines · ST8 accounting: every polyline yields a wall or a gap · ST9 result of a pier voted by a neighbour on one side only (gap on the other side beyond gap_max) · plus the real check: Duplex E1-wall (piece E0).

47. Results: wall stub orientation (2026-10-12)

Code: pass "§46 stub orientation" in dxf2bim.js (before the extent computation), active only with opening.gap_max. Tests tests/dxf2bim/test_stub.js ST1-ST11 PASS (log test_stub.log); all 13 unit suites fail=0 (test_stub.js 11 checks, test_types.js 35). Mutation checks (broken on purpose, tests must fail): gap_max requirement removed → ST2; gap limit ignored → ST3; horizontal strip equality dropped → ST4, ST5; vertical strip equality dropped → survived first, ST10 added, then caught; ambiguity silent → ST5; ambiguity resolved → ST5; squares included → ST6; thickness not updated → ST1, ST9; orient_from dropped → ST1, ST9; negative gap allowed (horizontal) → survived first, ST11 added, then caught; negative gap allowed (vertical) → survived again, ST11 extended, then caught (10/10). Test-side mistakes: ST7 compared GUIDs, which depend on the file's hash (order); changed to handles; ST11 vertical expectation was wrong (own thickness 0.1 is correct there). Duplex (rosetta_dx_types.js, config adds opening.gap_min 0.3, gap_max 5 SANDBOX): piece E0 is now 0.417 m thick; E1-wall PASSES: walls 30 pieces, 10 correct, 0 wrong, 20 unbound (was 8 / 1 / 21). Script: 9 checks, 9 PASS, fail=0. Earlier Duplex scripts (rosetta_dx, rosetta_dx_elev, rosetta_dx_heights, verify_marks_dx) re-run: rosetta_dx_elev 0 failing, rosetta_dx_heights 0 failing, verify_marks_dx 0 failing, rosetta_dx 1 failing (the by-design Level-2 wall recall, D-1, unchanged); they do not set opening.gap_max, so the new pass is inactive there. Still open: the 20 unbound wall pieces (417 mm EXTERIOR_BRICK | FOUNDATION_417, 152 mm three candidates, 3 pieces with no candidate in the library); that needs storey/category evidence or explicit labels (@LABEL=wall,... exists).

48. Wall type by label, Duplex (spec, 2026-10-12)

User chose wall labels (@LABEL=wall,x,y,text, §44) for the 20 unbound Duplex wall pieces of §47. Round trip, labelled as such: the marking step takes the wall type from the SOURCE model (containing source wall name → WALLMAP id, FROM_LIBRARY_DESCRIPTION); the label point is the piece's rectangle centre in metres. Pieces whose source type maps to more than one library id (party wall: PARTY_CMU, PARTY_CMU_550) are NOT labelled; they bind by size already. - E8 After labelling: wrong binds = 0; every labelled piece is bound with bind_source: label; a labelled piece whose thickness is outside bind.size_tol of the labelled type is bound AND logged GAP:type_size_mismatch with its delta, geometry unchanged (B4); no label gaps (label_unmatched/ambiguous/conflict) = 0. Counts reported: bound by label, mismatches with deltas, still unbound. - Expectation stated before the run: the 3 pieces with no size candidate (Level 2: 89 mm, 60 mm) bind by label and appear as size mismatches; the 417 mm pieces resolve to EXTERIOR_BRICK; the 152 mm pieces to their source type.

49. Stub orientation: windows vote too (spec, 2026-10-12)

Found by reading the log of the §48 run: Duplex Level 2 pieces 6B and 10D (0.089 x 0.417 m) are piers of the Exterior-Brick wall between two large windows with NO wall piece beside them; §46 saw no voter, kept the across-wall axis and measured thickness 0.089. The label step then bound them to EXTERIOR_BRICK and the binder reported the true size mismatch (delta -0.328 m), as designed, but the measurement is still wrong. - W-S2b Window elements (glazing clusters, kind: "window") with a bbox are voters too: the axis of the window is its bbox long side; the vote rule is §46 W-S2 applied to the bbox (cross-axis range equal within snap_tol; gap along the axis in [0, gap_max]). Doors are not voters (arc geometry has no strip). Unchanged: squares excluded, ambiguity rule, gap_max required. - orient_from lists the voter's handle (wall or window cluster first handle). Tests: ST12 pier between two windows (glazing rectangles in the wall strip) gets the wall depth, horizontal · ST13 window whose bbox cross-range is offset from the pier does not vote · ST14 window beyond gap_max does not vote.

50. Results: wall labels and window votes (2026-10-12)

Code: window votes (§49, W-S2b) and a tolerance-based isH in the §46 pass of dxf2bim.js. Tests: test_stub.js ST1-ST16 PASS (log test_stub.log); all 13 unit suites fail=0. Mutation checks on the window vote: windows do not vote → ST12; square windows included → survived because isH compared with a strict > and a 0.1 x 0.1 box measures 0.10000000000000009 wide (float noise made it "horizontal"); isH now uses snap_tol, ST15 fixture rebuilt so a vote would change the result, then caught; bbox axis wrong (all windows horizontal) → ST16 added, then caught; window kind unchecked (any opening with a bbox votes) → equivalent mutant: only window elements carry a bbox in the element list at that point (doors have arcs, classified gap openings are built later in dxf2elev.js). Real-data findings (Duplex, rosetta_dx_types.js): Level 2 piers 6B and 10D (0.089 x 0.417) now 0.417 thick via window votes, so the §48 expectation "3 pieces with no candidate become size mismatches" was WRONG: the stub fix removes them, size mismatches reported = 0. The wall still unbound count without labels stays 20 (417 mm EXTERIOR_BRICK | FOUNDATION_417, 152 mm three candidates). E8 (wall labels from the SOURCE model, round trip): 26/26 labels applied, 0 label gaps; with labels all 30 wall pieces bound and correct (26 by label, 4 party-wall pieces by size), 0 unbound, 0 wrong. rosetta_dx_types.js: 10 checks, 10 PASS. rosetta_dx keeps its by-design failure (Level-2 recall 12/17, D-1); rosetta_dx_elev, rosetta_dx_heights, verify_marks_dx unchanged and green. Whole Duplex type picture (1st-pass numbers in §41 replaced): doors 4/4 correct (drawn-size rows); windows 22/22 (10 by size + preferred set, 12 by label with z); walls 30/30 (10 by size, 20 by label). Everything the geometry could not decide came from stated labels; nothing was guessed. Labels were taken from the source model (round trip): this shows the mechanism and that the pieces land on the right types, not that labels can be discovered from a drawing.

51. User guide (2026-10-12)

User direction: the user guide must state the rule that labels are what set the measures/types geometry cannot decide. Written as docs/DXF2BIM_USER_GUIDE.md (rule in §1, workflow in §4, gap table in §6). Every statement in it is taken from §24-§50; items not built are listed in its §8. Keep it in step with this spec when rules change.

52. Tags: canned labels placed on the drawing (spec, 2026-10-12)

User direction: canned labels and markers placed by users on their drawings help a lot; build the label reader. Until now @LABEL needed typed coordinates. A TAG is the same information placed by position: the user puts a text on the drawing at the element (plan) or at the opening (elevation); the reader takes the position from the text's insertion point. - T1 @TAG=class,text (marker text on the marker layer; class = door|window|wall; repeatable; PLAN or ELEV view). PLAN only: @TAG=class,z=<metres>,text (same z= as §44 L1b). Position = the marker's insertion point (x, y) in drawing units. describe returns desc.tags = {class, x, y, [z], text} canonical order; exact duplicates merged; same class+position (+z) with two texts → MARK:conflict; z= in an ELEV view → MARK:malformed (z comes from the position); bad class, empty text → MARK:malformed. - T2 Canned vocabulary (optional): opts.vocab = object {tagText: labelText}. With a vocabulary the tag text must be a key (case-sensitive) and the label becomes the mapped value (short canned codes → library ids); a text not in the vocabulary → GAP:label_unknown_text (typo protection), nothing labelled. Without a vocabulary the tag text is used as the label as is. - T3 PLAN tag → label via the §44 matching (point inside the element's footprint, class, optional z; same gaps label_unmatched/ambiguous/conflict). - T4 ELEV tag: the position (h, y) is mapped through the plan↔elevation alignment (map.toPlan(h) along the face axis, map.zOf(y) height). It labels the opening ops of the tagged class whose face equals the view's face, whose plan interval along the face axis contains the mapped coordinate (±snap_tol), and whose absolute z interval [level+sill, level+sill+height] contains the mapped z. Stacked openings are therefore told apart by the tag's height without typing z=. Zero → GAP:label_unmatched; several → GAP:label_ambiguous; walls cannot be tagged in ELEV (→ GAP:label_unmatched). - T5 API: applyTags(ops, views, units, opts) in dxf2types.js; views = [{desc, map?}] (described views; map from Dxf2Marks.align for ELEV views); opts = {snap_tol, level, vocab}; returns {ops, gaps, labelled} like applyLabels. Tests: TG1 parse (valid, canonical, duplicates merged, malformed, conflict, z= rejected in ELEV) · TG2 plan tag at its insertion point labels the element under it · TG3 plan tag with z= picks one stacked opening · TG4 vocabulary alias resolves to the library id · TG5 text not in the vocabulary → gap, nothing labelled (case-sensitive) · TG6 elevation tag picks the right one of three stacked windows by height · TG7 elevation tag of another face does not label · TG8 elevation tag at a height with no opening → unmatched · TG9 end to end: two same-size products told apart by tags, then bound · TG10 order independence · TG11 a plan tag and an @LABEL on the same element with different texts → label_conflict.

53. Duplex run with tags placed on the drawings (spec, 2026-10-12)

E9 (in rosetta_dx_types.js): the tags are WRITTEN INTO copies of the marked Duplex DXFs as marker texts (MK.writeMarks) and READ BACK from the files (parse, readMarks, describe), so the whole chain marker text → desc → label → type is exercised, not only the in-memory objects. Round trip, labelled as such: the marking step takes the types from the SOURCE model and places the canned codes (FIX, CAS, W1..W8, a vocabulary mapping each code to a library id). - Windows (all 22 that carry a source type; the 819x759 ones are the undecided 12): ELEVATION tags, placed at the opening's drawing position (h from the plan position through the inverse of the alignment, y from its mid height). They test the elevation matching (T4) on stacked windows. Wall pieces with a single acceptable id (26 as in E8): PLAN tags at the piece centre. Each tag is accounted for: labelled exactly once across the two storeys (an elevation shows both floors, so a tag unmatched in one storey is expected, but must match in exactly one); no label_ambiguous, label_conflict, label_unknown_text. - Expected before the run: windows 22/22 and walls 30/30 correct as in E7/E8, same totals; any difference is a defect of tag matching and is reported. - Only tags the geometry cannot decide are strictly needed; all are placed to test the matching on every window and wall piece.

54. Results: tags (2026-10-12)

Code: @TAG in dxf2marks.js (desc.tags), applyTags in dxf2types.js (plan and elevation tags, optional vocabulary). Tests: tests/dxf2bim/test_tags.js TG1-TG11 PASS (log test_tags.log); all 14 unit suites fail=0. Mutation checks (broken on purpose, tests must fail): vocabulary ignored → TG4, TG5; alias not mapped → TG4; vocabulary case-insensitive → TG5; elevation face unchecked → TG7; elevation z ignored → TG6, TG8; along-axis ignored → TG6, TG8; axis swapped → TG6; level ignored → TG6; overwrite existing label → TG11; ambiguity resolved by first → TG3, TG10; plan z ignored → TG3; plan units ignored → TG2-TG5, TG9, TG11; duplicate tags kept, conflict missed, elevation z allowed → TG1; label source wrong → TG2 (16/16 caught). Not tested: elevation in different units than the plan (the alignment map is tested in test_marks.js), tagged doors in an elevation (door interval uses hinge ± width, SANDBOX rule). Duplex E9 (rosetta_dx_types.js, log): 48 tags (22 elevation tags for windows, 26 plan tags for wall pieces) written into copies of the marked DXFs (out/tagged/), read back from the files, 0 marker errors, 48/48 labelled exactly once, no ambiguous/conflict/unknown gaps; windows 22/22 and walls 30/30 correct. A tag written for the other storey is label_unmatched in the storey it does not belong to: expected, not counted as a failure; the check requires each tag to be labelled once overall. Three mutants of the tag code (z ignored, axis swapped, vocabulary off) all FAIL E9, so the check can fail. Script: 11 checks, fail=0. Test-side mistake found by reading the first E9 log: I gave every M_Fixed window the code for DUPLEX_SMALL_FIXED (10 windows of other sizes got a wrong label; the scorer caught it as WRONG). Window codes now come from the source type AND size. Round trip caveat unchanged: the tag types come from the source model; this shows that tags placed on drawings are read, matched (plan and elevation, stacked openings) and drive the binding. It does not show that users will place them. Not done: reading existing drawing TEXT without the @TAG prefix (free legend/schedule text), doors tagged in elevations, tags in the Modeller UI, a tag palette/stamp (a ready set of canned markers a user can drop on a drawing).

55. Farmhouse: marks and schedules from the drawing's own text (spec, 2026-10-12)

Set kept INSIDE the project at the user's request (2026-10-12): tests/dxf2bim/external/farmhouse/ (zip, DXF, ATTRIBUTION.txt, marked/tagged copies); excluded from git through .git/info/exclude (CC BY-SA, work of Jay Osborne; never commit or republish). Zip SHA-256 f3c88893... verified. Baseline re-run on the re-downloaded file: farm_verify, farm_walls, farm_open all fail=0 (same as §33-§37). Evidence read from the file (2026-10-12), plan region [1632,6375,2448,6903] (inches): the plan already carries the schedule marks as plain TEXT next to the openings: south wall 1 at x 1914, A at x 2028, 1 at x 2142 (y about 6569, 8 in south of the wall); north wall 2, B, 2 at x 1914, 2052, 2142 (y about 6779). Their x equals the south gap centres found in §37 (1914, 2028, 2142). The door schedule (MTEXT 4F22) lists A and B 36"x84" composite glazed panelled, C 24"x80" wood 1-panel, D 30"x80" wood 5-panel; the window schedule (MTEXT 4F24) lists 1 = 30"x60" and 2 = 24"x48" double hung. The drawing's general notes say "DO NOT SCALE THESE DRAWINGS. WRITTEN DIMENSIONS TAKE PRECEDENCE OVER MEASUREMENTS" (recorded, not acted on: the pipeline reports both numbers and decides nothing). - F1 Sandbox marking tool (like farm_mark.js): reads the schedule MTEXT rows MARK^I SIZE ... (mark token, W"xH" size); class of a schedule row from its TYPE column (DOUBLE HUNG → window; PANEL/GLAZED → door; a row matching neither → refuse). Proposes @SCHEDULE=mark,class,w,h (inches) for every row, and @TAG=class,mark at the centre of each classified opening when exactly one mark text of the right class (a mark of that class's schedule) lies within mark_dx along the wall and mark_dy across it (SANDBOX 6 in and 12 in, stated in the log); none or several → no tag, logged. Markers are written into a COPY (farm_tagged.dxf) inside the plan region and read back from the file. - F2 Run: pair-wall pipeline + gap openings + elevation classification as farm_open.js (3 south openings), applyTags (no vocabulary), scheduleFromDesc, bindTypes with the unfiltered component library, bind.size_tol 0.005 m (SANDBOX). - Expectations stated before the run: marks 1, A, 1 land on the south window, door, window; schedule conflicts: windows width 31.5 vs 30 in and height 78 vs 60 in, door width 37.5 vs 36 in, door height 84 vs 84 in no conflict; the library (US/MY/UK/Duplex products) has no product at these sizes → type_unbound with no candidates; the north openings have marks but no elevation, so they stay pending (GAP:no_elevation_for_face), not typed. Checks: FT1 each of the 3 openings gets exactly one mark from the drawing's own text, no label gaps · FT2 markers read back from the file equal what was written, 0 marker errors · FT3 schedule rows parsed equal the printed rows (hand values above) · FT4 conflicts reported with both numbers, door height not reported · FT5 outline fields unchanged by labelling and binding · FT6 two runs identical · FT7 no opening bound to a product that matches neither schedule nor measurement (issue: size matching inventing a type) · FC control: with mark_dx 0 no tag is proposed and FT1 fails (the check can fail).

56. Results: Farmhouse marks and schedule conflicts (2026-10-12)

Code: tests/dxf2bim/farm_types.js (sandbox marking tool + run, log farm_types.log); dxf2types.js: a door with no hinge (classified from a drawn gap) is labelled/tagged through its bbox (previously only through the hinge disc; unit test LB16 added, mutant "door always hinge" caught; plan and elevation paths). test_types.js now 36 checks; all 14 unit suites fail=0. Independent set (not a round trip: marks and sizes are the drawing's own text): schedule rows read from MTEXT 4F22 (doors A, B 36x84, C 24x80, D 30x80) and 4F24 (windows 1 = 30x60, 2 = 24x48 in); tag proposals from the mark texts printed next to the openings (text handles 2082, 2087, 2088 at x 1914, 2028, 2142): window 1, door A, window 1; written into farm_tagged.dxf (inside the project folder, git-excluded), read back with 0 marker errors; 3/3 openings labelled once; geometry (67 ops) unchanged. farm_types.js: 7 checks, 7 PASS; control MARK_DX=0 (no mark within tolerance) makes FT1 and FT4 FAIL, so the checks can fail. Findings, as stated before the run (§55): schedule conflicts reported with both numbers: windows 1: width 31.50 in measured vs 30.00 schedule, height 78.00 vs 60.00; door A: width 37.50 vs 36.00, no height conflict (84 vs 84). The plan gap is the schedule width plus 1.5 in for the windows and the door alike (the same 1.5 in; likely a rough-opening allowance, not stated in the drawing and NOT assumed). The window height conflict (78 vs 60 in) stays open as in §37; the drawing's own general note says "DO NOT SCALE THESE DRAWINGS. WRITTEN DIMENSIONS TAKE PRECEDENCE OVER MEASUREMENTS", which suggests the schedule wins, but the pipeline reports and does not decide. The component library has no product at these sizes: 3/3 openings type_unbound with an empty candidate list, none bound (FT7). North-wall openings carry marks (2, B, 2) but have no marked elevation: still pending, untyped. Not done: tags for the north wall (needs a marked north elevation), other floors, a general text reader (the sandbox tool only reads single-character mark texts near classified openings), a schedule allowance setting, Farmhouse library set.

57. Schedule allowance by marking (spec, 2026-10-12)

User agreed to the allowance setting (§56) and asked to insert our labelling system into the DXF where needed. The allowance is a stated value, never a default: it is written into the drawing as a marking. - A1 @ALLOWANCE=class,w,h (class = door|window; w,h in drawing units, any real number; repeatable, one value pair per class; any view): "the drawn opening is the scheduled size PLUS this much" (rough opening over nominal). describe returns desc.allowance = {door?: {w,h}, window?: {w,h}}. Same class twice with different values → MARK:conflict; wrong class, arity or a non-number → MARK:malformed. - A2 allowanceFromDesc(desc, units) → {window: {width, height}, door: {...}} in metres (Dxf2Types). - A3 bindTypes opts.allowance is used ONLY in the schedule comparison (B5): a dimension conflicts when |measured - (schedule + allowance)| > size_tol; diff[k] then also carries allowance (metres). It is NOT used for library size matching and not for walls. Absent → behaviour as §38. - A4 The value for the Farmhouse is taken from the observation of §56 (plan gap = schedule width + 1.5 in on all three openings; door height 84 = 84), width 1.5 in and height 0 in. It is a sandbox value stated by the marking step, changeable by the user; it is NOT claimed to be in the drawing. The window height conflict (78 vs 60 in) is not covered by it and stays reported. Tests: AL1 parse (valid, canonical, duplicate merged, conflict, malformed) · AL2 measured = schedule + allowance within tolerance → no conflict · AL3 measured off by more than the tolerance from schedule + allowance → conflict listing schedule, allowance, measured · AL4 a door allowance does not apply to a window · AL5 allowance given in inches converts to metres · AL6 no allowance marking → conflicts exactly as before.

58. Results: schedule allowance (2026-10-12)

Code: @ALLOWANCE in dxf2marks.js (desc.allowance), allowanceFromDesc and opts.allowance in dxf2types.js. Tests test_types.js AL1-AL6 PASS (now 42 checks); all 14 unit suites fail=0. Mutation checks (broken on purpose, tests must fail): allowance ignored → AL2, AL6; sign wrong → AL2, AL6; class ignored → AL4; not recorded in the diff → AL3, AL6; allowance widening the tolerance → AL3; units ignored → AL5; allowance conflict not detected, bad class accepted, bad number accepted → AL1 (9/9 caught). Farmhouse (farm_types.js, log farm_types.log): @ALLOWANCE=window,1.5,0 and @ALLOWANCE=door,1.5,0 inserted into the tagged copy (farm_tagged.dxf, git-excluded) next to the tags and schedule rows, read back with 0 marker errors. Result: the three width conflicts (+1.5 in) disappear; the two window HEIGHT conflicts remain (78 in measured vs 60 in schedule, allowance: 0 recorded in the diff), the door has no conflict. Control (same run without the allowance marking) gives the 3 width and 2 height conflicts of §56, so the marking does the work. Script: 8 checks, fail=0. The 1.5 in / 0 in values are a sandbox statement taken from the observation, not a fact in the drawing. Still open: the window height 78 vs 60 in (schedule vs elevation dimension entities; the drawing's note "written dimensions take precedence over measurements" points to the schedule, the pipeline does not choose); north-wall marks need a marked north elevation; Farmhouse library set.

59. Decision: window height conflict (2026-10-12)

User accepted the advice on the Farmhouse window height (78 in elevation dimensions and siding gap vs 60 in schedule): geometry keeps 78 in (drawn and dimensioned), the type is taken from the schedule (mark 1, 30"x60" double hung), the 18 in difference stays an open GAP:schedule_conflict for the drawing's author; no allowance for it. Recorded as the standing rule in USER_GUIDE.md §4b. No code change.

60. Farmhouse wall heights from the south elevation (spec, 2026-10-16)

Goal (user, 2026-10-16: "if u get the basic walls together is a good start"): the 17 pair-wall pieces of the Farmhouse 1st floor plan (§35) stand in 3D with heights read from the drawing's own marked south elevation. No module change planned; sandbox script tests/dxf2bim/farm_heights.js (log farm_heights.log), existing applyWallHeights (§28 H3/H4) and dxf2mesh.meshFromOps/toObj. Inputs: external/farmhouse/farm_marked.dxf with its existing markings, unchanged: plan region/exclusions/faces, @LEVEL=0; south elevation region [0,5319,816,5847], @FACE=minY, @HAXIS=+X, @GRIDH WEST_FACE 216.04 / EAST_FACE 600.04, @Z 0, 3.048, 6.096, 8.89 m (level texts paired with layer-3 CENTER lines, §33). Plan run = farm_walls.js config (pair walls, elevation mode, NO wall height or base in config, no fallback). Evidence (probe 2026-10-16, before any code): (a) the elevation region holds no closed outline on a building layer (its only LWPOLYLINE is on A-LINK), so H2 elevWallRects has nothing to read; (b) the facade is drawn as continuous two-storey cladding: corner-board lines layer 2 x 220.04 / 596.04 (handles 629, 628) and layer 4 x 214.04 / 602.04 (451, 5B3) run rel. y -6.41..222.00 in with no break at +10'-0"; the face lines x 216.04/600.04 exist only in the foundation and roof bands; (c) the only storey division drawn is the level markers: CENTER line 2337 (y 5427.942, text 234A +0'-0" (T.O. FINISH), 2339 1ST FLOOR) and 2344 (y 5547.942, text 234B +10'-0" (T.O. FINISH), 234E 2ND FLOOR); above them 2FEA ROOF EAVE +20'-0", 2FFB ROOF RIDGE +29'-2"; (d) the drawing's vertical DIMENSION entities chain floor -> 24 -> 102 -> 120 in (2362, 2370, 31DD). So the elevation gives no per-wall top; the storey band [1st floor, 2nd floor] is the only drawing-sourced vertical extent for a 1st-floor wall. - WH1 Storey band rect: for the plan @LEVEL L, an elevation @Z must equal L (snap_tol); Lnext = the smallest @Z strictly above L. The rect is {p0, p1} = the two @GRIDH face marks mapped to plan by align, {z0, z1} = [L, Lnext], handle LEVELS:<line at L>+<line at Lnext> (the CENTER lines nearest to the @Z marks). Refused (no rect, every piece GAP:no_elevation_height) when no @Z equals L or no Lnext exists. - WH2 Elevation path, unchanged module: applyWallHeights(ops, [{face: minY, walls: [band]}], cfg, {level: L}). Expected by H3 before the run: the 6 pieces touching the south face plane (4 south pieces, the full-depth west piece, the south-east piece) get base 0, height = Lnext - L, height_source: elevation; the other 11 (north, interior, north-east) get GAP:no_elevation_height. - WH3 Storey rule for the pieces the south elevation cannot see (sourced, labelled, separate variant): the level markers are storey-wide data (a floor level, not tied to a face), so a piece left GAP:no_elevation_height by WH2 takes the same band with height_source: storey_level and the two level-line handles. Stated convention: a storey's wall runs from its level to the next storey level (Revit-style base/top constraint); precedent only: the Duplex elevation rects were level-to-level and equal the source (§29). Two outputs: variant A = WH2 only (out/farm_walls_3d_seen.obj), variant B = WH2 + WH3 (out/farm_walls_3d.obj). out/ is git-ignored. - WH4 Mesh: meshFromOps on the wall ops only (no openings, no cuts), level L; log element count, triangle count, SHA-256 of the OBJ text, and the same hash from a second full run. Checks (each names the issue it exposes): FH1 H2 cannot read this elevation (closed outlines on layers 1-5 in the region = 0; if not 0 the band path would ignore drawn outlines) · FH2 band taken from the wrong level line: the text paired with Lnext must name 2ND FLOOR/+10'-0" and a contiguous chain of the elevation's own DIMENSION entities must run from the floor line to Lnext (0.05 in) · FH3 band where no wall is drawn: on each side a vertical line on layers 1-5 within 6 in (sandbox) of the face mark must cover [L, Lnext] · FH4 the elevation path gives a height to a piece the south elevation cannot see, or misses one it sees: elevation set == pieces whose rect touches the south face plane (computed independently) · FH5 wrong units/arithmetic: every height 120.00 in (0.01), base 0 · FH6 a wall vanished or doubled: 17 = elevation + storey_level + gaps · FH7 mesh open/inside-out or element missing: element count = walls with a height, all watertight, volume > 0 · FH8 mesh disagrees with the ops: total volume = sum(rect area x height) (1e-6 m3) · FH9 non-determinism: OBJ hash equal on a second run. Controls (must FAIL): C1 @Z 3.048 removed from the elevation description (Lnext becomes the eave) -> FH2, FH5 FAIL; C2 band given to face maxY -> FH4 FAIL. Not claimed: real top of any wall (plate, 2nd-floor structure depth, interior partitions' ceiling height); the siding below the floor line (rel -6.41 in) and the two-storey continuity of the exterior wall; openings (window/door gaps stay full-height slots: no sills/lintels, §36 openings are not meshed here); 2nd floor; north/east/west elevations (not marked); WH3 is a convention on drawing-sourced levels, not a geometry reading.

61. Results: Farmhouse wall heights from the south elevation (2026-10-16)

Code: none in modules. Sandbox tests/dxf2bim/farm_heights.js, logs farm_heights.log (run), farm_heights_control_drop_l2.log, farm_heights_control_face_maxY.log (controls). Unit suites not re-run (no module touched). WH1: band rect LEVELS:2337+2344 = plan x 1836.00..2220.00 in (from @GRIDH via align, residual within 0.002 m), z 0..3.048 m (0..120.00 in); level line 2337 texts 2339 1ST FLOOR, 234A +0'-0" (T.O. FINISH); line 2344 texts 234B +10'-0" (T.O. FINISH), 234E 2ND FLOOR (and 234F PORCH BEAM, within the 12 in text window, not used). WH2 applyWallHeights report {elevation 6, conflict 0, none 11}, exactly the 6 pieces predicted in §60 (pairs 35E+360, 361+37C, 328+37F, 345+346, west 2A6+319, south-east 375+378); 11 GAP:no_elevation_height (north 4, interior 6, north-east 1). WH3 gives those 11 the same band, height_source: storey_level. All 17 pieces: base 0, height 120.00 in. Checks 11/11 PASS (SUMMARY fail=0): FH1 0 closed outlines on layers 1-5 (only LWPOLYLINE 2353 on A-LINK) · FH2 Lnext text 2ND FLOOR / +10'-0", DIMENSION chain 2362 (24.01) + 2370 (77.99) + 31DD (18.00) = 120.004 in · FH3 corner lines 451/629 (west) and 5B3/628 (east) cover rel. y -6.41..222.00 · FH4 elevation set == the 6 pieces touching the south face · FH5 all 120.00 in · FH6 17 = 6 + 11 (A) / 6 + 11 + 0 gaps (B) · FH7/FH8 both variants watertight, volume>0, mesh volume = ops volume (A 3.824987 m3, B 8.592710 m3) · FH9 hashes equal on a second run. Mesh: variant B tests/dxf2bim/out/farm_walls_3d.obj 17 elements, 204 triangles, 136 vertices, SHA-256 0bd8492a631fa43857fd9ff6f905ea61311b5765186ce1cba39133faab9e8156 (rerun and read-back identical); variant A out/farm_walls_3d_seen.obj 6 elements, 72 triangles, e208a89b22c75e4bb433c30b21cbe4eac85551a2df63ecf9ce327cfa4a484e1c (rerun identical). Controls: C1 drop_l2 (Lnext becomes the eave, 240 in) -> FH2, FH3, FH5 FAIL (fail=3; FH3 also fails because the corner lines stop at 222 in, below the eave band top); C2 face_maxY -> FH4 FAIL (fail=1; the north pieces took the elevation height). The checks can fail. Test-side mistake found and fixed on the way: first run FH2 FAILED on the true file because MTEXT stores the inch mark as \" and the check's pattern wanted a bare "; the pattern now accepts the escaped form (the band, heights and mesh were the same before and after). Housekeeping finding: tests/dxf2bim/ is untracked and out/ is NOT git-ignored (only tests/dxf2bim/external/ is excluded via .git/info/exclude); the OBJ is derived from the CC BY-SA set and must not be added. Not claimed (as §60): real wall tops (plate, floor structure, partition ceiling heights); siding below the floor line; openings (window/door gaps are full-height slots, no sills/lintels); 2nd floor; other elevations. The 11 storey_level heights rest on a convention applied to drawing-sourced levels, not on geometry the south elevation shows; the 6 elevation heights come from the same two level lines (the elevation draws no per-storey wall top), so the two sources differ in visibility, not in evidence strength.