/*
 * FloorPlanCanvas.jsx — Konva canvas renderer for Planify
 * Loaded via <script type="text/babel" src="FloorPlanCanvas.jsx"> in index.html.
 * Requires: Konva loaded globally from CDN before this script.
 * Replaces: the SVG-based Editor component.
 *
 * Rendering layers (same order as old SVG Editor):
 *  1. Land fill + garden setbacks
 *  2. Boundary walls + gate symbols + driveway + trees
 *  3. Dimension labels
 *  4. Building shell (polygon or double-rect)
 *  5. Room fills (staircase lines, master-bedroom partitions)
 *  6. Interior partition + exterior wall lines
 *  7. Entrance door arcs (north/south/east/west frontages)
 *  8. Service entrance for driver_room
 *  9. Doors / windows from backend data
 * 10. Garage / driver_room dashed overlays
 * 11. Furniture symbols (draggable)
 * 12. Room labels + area strings + dimension callouts
 * 13. Wall-resize handles for selected room
 *
 * Interactions:
 *  - Stage: pan (drag empty area) + zoom (mouse wheel)
 *  - Rooms: click to select; drag to reposition (syncs to React on dragend)
 *  - Furniture: click to select; drag to reposition
 *  - Wall handles: single-axis drag to resize the selected room
 */

/* ── Constants ──────────────────────────────────────────────────────────── */
/* app.html (v2 redesign) fork — geometry/logic identical to
 * FloorPlanCanvas.jsx; only the palette below changed, to match the new
 * design system (navy #01002A / cyan #27AAE2) instead of the old
 * near-black/teal one. See static/app.html for the redesign entry point;
 * the classic UI (static/index.html) still loads the original file
 * unmodified. */
var _KPX    = 36;          // pixels per metre
var _KWALL  = '#01002A';   // wall colour — new-design navy
var _OPEN_PLAN = {living_room:1, dining_room:1, sitting_hall:1};
// PROMPT X phase 5 — the setback ring was a saturated green that fought
// the plan for attention. A pale neutral reads as 'not building' without
// competing with it. (Maket draws no site at all; this is the quiet
// middle ground, since our plots and setbacks are real information.)
var _KSITE  = '#F2EEE6';
var _KCIRC  = '#F7F5EF';   // open circulation floor (PROMPT Y) — barely there
var _KFLOOR = '#FCFAF6';   // floor fill colour — new-design canvas cream
var _KEXT_W = 11;          // exterior wall stroke ~30 cm at 36 px/m (HEAVY tier)
var _KINT_W = 2;           // interior partition stroke width (px)
var _KPOCHE = '#01002A';   // interior partition poché fill colour — navy
var _KEPS   = 0.3;         // boundary tolerance (metres)
var _KCYAN  = '#27AAE2';   // accent / handle colour — new-design cyan
var _EXT_ROOMS = {driver_room: true, external_annex: true};

/* PROMPT P — line system: exactly three stroke weights, monochrome. */
var _SW_MED   = 1.4;   // openings/stairs (door arcs, window lines, tread lines)
var _SW_THIN  = 0.8;   // furniture hairline
var _KROOMFILL = '#FFFFFF';  // pure white room fill, matches new design's canvas cards

/* ── Furniture symbol drawing — one consistent monochrome family ─────────
 * Shared rules across every symbol: stroke _SW_THIN, corner radius 2 where
 * rectangular, no fills except white, strokeScaleEnabled:false so lines
 * stay crisp hairlines at any zoom. */
function _drawKSym(grp, type, w, h) {
  var S = '#1a1a1a', sw = _SW_THIN;
  if (w < 4 || h < 4) return;
  var RX = 2; // shared corner radius

  function rect(x,y,ww,hh,extra) {
    var cfg = {x:x,y:y,width:ww,height:hh,cornerRadius:RX,fill:'white',stroke:S,strokeWidth:sw,strokeScaleEnabled:false,listening:false};
    if (extra) Object.assign(cfg, extra);
    grp.add(new Konva.Rect(cfg));
  }
  function line(pts,extra) {
    var cfg = {points:pts,stroke:S,strokeWidth:sw,strokeScaleEnabled:false,listening:false};
    if (extra) Object.assign(cfg, extra);
    grp.add(new Konva.Line(cfg));
  }
  function circle(x,y,r,extra) {
    var cfg = {x:x,y:y,radius:r,fill:'white',stroke:S,strokeWidth:sw,strokeScaleEnabled:false,listening:false};
    if (extra) Object.assign(cfg, extra);
    grp.add(new Konva.Circle(cfg));
  }

  if (type === 'bed_double') {
    // PROMPT VV — real CAD block (blocks.draftsperson.net); only the
    // double bed was sourced, so bed_single keeps the hand-drawn symbol
    // below rather than stretching a double bed's proportions to fit.
    _kBlock(grp, 'bed_double', w, h, {fill:'white'});

  } else if (type === 'bed_single') {
    rect(1,1,w-2,h-2);
    line([2,h*0.24,w-2,h*0.24]);
    line([w*0.15,h*0.5,w*0.85,h*0.72]);

  } else if (type === 'sofa') {
    // PROMPT VV — real CAD block (blocks.draftsperson.net): rolled arms
    // + three seat-cushion seams, true 3-seater proportions.
    _kBlock(grp, 'sofa', w, h, {fill:'white'});

  } else if (type === 'armchair') {
    rect(w*0.08,h*0.3,w*0.84,h*0.68);   // seat
    rect(w*0.1,1,w*0.8,h*0.32);         // back rest
    line([w*0.08,h*0.3,w*0.08,h*0.98]); // left arm face
    line([w*0.92,h*0.3,w*0.92,h*0.98]); // right arm face

  } else if (type === 'tv_unit') {
    rect(1,h*0.55,w-2,h*0.4);           // console body
    line([w*0.5-Math.min(w*0.35,20),h*0.3,w*0.5+Math.min(w*0.35,20),h*0.3],{strokeWidth:sw*1.4}); // screen edge
    line([w*0.5,h*0.3,w*0.5,h*0.55]);   // stand

  } else if (type === 'toilet') {
    // PROMPT VV — real CAD block: pan + cistern + seat, plan view.
    _kBlock(grp, 'toilet', w, h, {fill:'white'});

  } else if (type === 'bathtub') {
    rect(1,1,w-2,h-2);
    rect(w*0.1,h*0.12,w*0.8,h*0.72,{strokeWidth:sw*0.85});
    circle(w*0.5,h*0.75,Math.min(w,h)*0.06);

  } else if (type === 'shower') {
    // PROMPT VV — real CAD block: frameless enclosure, crossed-diagonal
    // glass convention, wall-mounted rose + floor waste.
    _kBlock(grp, 'shower', w, h, {fill:'white'});

  } else if (type === 'sink') {
    // PROMPT VV — real CAD block: D-shaped basin, tap and waste marked.
    _kBlock(grp, 'sink', w, h, {fill:'white'});

  } else if (type === 'kitchen_counter') {
    // Counter run: sink bowl (left) + 4-burner stove (right)
    rect(1,1,w-2,h-2);
    var bowlW = Math.min(w*0.32,20);
    rect(3,3,bowlW,h-6,{strokeWidth:sw*0.85});
    var br0 = Math.min(w,h)*0.1, bcx = bowlW+3+(w-bowlW-6)*0.32, bcy2=bowlW+3+(w-bowlW-6)*0.68;
    [[bcx,h*0.32],[bcy2,h*0.32],[bcx,h*0.68],[bcy2,h*0.68]].forEach(function(p){
      circle(p[0],p[1],br0,{strokeWidth:sw*0.85});
    });

  } else if (type === 'stove') {
    // PROMPT VV — real CAD block: compact 4-hotplate cooktop, plan view.
    _kBlock(grp, 'stove', w, h, {fill:'white'});

  } else if (type === 'dining_table') {
    // PROMPT VV — real CAD block: rectangular table, 6 chairs (2 per
    // long side, 1 per end) — matches this type's most common program.
    _kBlock(grp, 'dining_table_6chairs', w, h, {fill:'white'});

  } else if (type === 'wardrobe') {
    // PROMPT VV — real CAD block. No dedicated "wardrobe" block exists on
    // the source site (checked directly, see ASSETS.md); this is its
    // 900x600 plan-view cupboard block instead — same double-door-swing
    // carcass a wardrobe actually is, just documented as a substitute
    // rather than a literal "wardrobe" listing.
    _kBlock(grp, 'wardrobe_cupboard', w, h, {fill:'white'});

  } else if (type === 'fridge') {
    // PROMPT VV — real CAD block, extracted from a multi-fixture kitchen
    // sheet (draftsperson.net has no standalone fridge PLAN block — see
    // ASSETS.md): a plain-rectangle plan symbol, which is genuinely how
    // this source draws a fridge in plan (no door-swing convention).
    _kBlock(grp, 'fridge', w, h, {fill:'white'});

  } else if (type === 'washing_machine') {
    rect(1,1,w-2,h-2);
    circle(w/2,h*0.56,Math.min(w,h)*0.32);

  } else if (type === 'chair') {
    rect(w*0.1,h*0.28,w*0.8,h*0.68);
    rect(w*0.12,1,w*0.76,h*0.26);

  } else if (type === 'majlis_seating') {
    // The perimeter floor-seating arrangement — low bench along the walls
    // (both axes, forming an L) plus a low centre table. Not a Maket symbol.
    var bench = Math.min(w,h)*0.22;
    rect(1,1,bench,h-2);                    // left bench
    rect(1,1,w-2,bench);                    // top bench
    for (var bx = bench+6; bx < w-6; bx += 9) line([bx,3,bx,bench-3],{strokeWidth:sw*0.6}); // cushion seams (top)
    for (var by = bench+6; by < h-6; by += 9) line([3,by,bench-3,by],{strokeWidth:sw*0.6}); // cushion seams (left)
    var tsz = Math.min(w,h)*0.26;
    rect(w-tsz-4,h-tsz-4,tsz,tsz,{strokeWidth:sw*0.9}); // low centre table

  } else {
    rect(2,2,w-4,h-4);
  }
}

/* ── Shell polygon for non-rectangular plots ────────────────────────────── */
function _mkShellPts(shape, BW, BH, ox, oy) {
  var pts;
  if (shape === 'l')  pts = [[0,0],[BW*.6,0],[BW*.6,BH*.45],[BW,BH*.45],[BW,BH],[0,BH]];
  else if (shape === 'u') pts = [[0,0],[BW,0],[BW,BH*.4],[BW*.72,BH*.4],[BW*.72,BH*.6],[BW,BH*.6],[BW,BH],[0,BH]];
  else if (shape === 't') pts = [[0,0],[BW,0],[BW,BH*.35],[BW*.65,BH*.35],[BW*.65,BH],[BW*.35,BH],[BW*.35,BH*.35],[0,BH*.35]];
  else if (shape === 'h') pts = [[0,0],[BW*.35,0],[BW*.35,BH*.35],[BW*.65,BH*.35],[BW*.65,0],[BW,0],[BW,BH],[BW*.65,BH],[BW*.65,BH*.65],[BW*.35,BH*.65],[BW*.35,BH],[0,BH]];
  else if (shape === '+') pts = [[BW*.35,0],[BW*.65,0],[BW*.65,BH*.35],[BW,BH*.35],[BW,BH*.65],[BW*.65,BH*.65],[BW*.65,BH],[BW*.35,BH],[BW*.35,BH*.65],[0,BH*.65],[0,BH*.35],[BW*.35,BH*.35]];
  else return null;
  return pts.reduce(function(a, p){ a.push(ox+p[0], oy+p[1]); return a; }, []);
}

/* ── Inset a simple rectilinear (axis-aligned) polygon inward by `m` ──────
 * Offsets each edge along its inward normal, then re-intersects consecutive
 * offset edges to get the new vertex — exact for orthogonal polygons (no
 * general polygon-clipping library needed). Points must be a flat
 * [x0,y0,x1,y1,...] array, clockwise, y-down. */
function _insetRectilinearPolygon(pts, m) {
  var n = pts.length / 2;
  if (n < 3) return pts;
  var P = [];
  for (var i = 0; i < n; i++) P.push([pts[2*i], pts[2*i+1]]);
  var out = [];
  for (var i = 0; i < n; i++) {
    var prev = P[(i - 1 + n) % n], cur = P[i], next = P[(i + 1) % n];
    var dxIn = cur[0]-prev[0], dyIn = cur[1]-prev[1];
    var lenIn = Math.hypot(dxIn, dyIn) || 1; dxIn/=lenIn; dyIn/=lenIn;
    var nInX = -dyIn, nInY = dxIn;               // inward normal (clockwise, y-down)
    var dxOut = next[0]-cur[0], dyOut = next[1]-cur[1];
    var lenOut = Math.hypot(dxOut, dyOut) || 1; dxOut/=lenOut; dyOut/=lenOut;
    var nOutX = -dyOut, nOutY = dxOut;
    var offIn  = [cur[0]+nInX*m,  cur[1]+nInY*m];
    var offOut = [cur[0]+nOutX*m, cur[1]+nOutY*m];
    var ix, iy;
    if (Math.abs(dxIn) > 0.5) iy = offIn[1]; else ix = offIn[0];
    if (Math.abs(dxOut) > 0.5) iy = offOut[1]; else ix = offOut[0];
    out.push(ix, iy);
  }
  return out;
}

/* PROMPT VV (2026-09-04) -- real CAD-block furniture/vehicle/landscaping
 * symbols, replacing the hand-coded ones below one category at a time.
 * Each entry is one DWG block from blocks.draftsperson.net (free for
 * personal/educational/commercial use, no attribution required -- see
 * ASSETS.md for the exact source page and license text for every block),
 * converted DWG->DXF via the ODA File Converter and DXF->SVG via ezdxf,
 * then reduced to a single flattened path outline in real-world
 * millimetres, origin at the block's own top-left bounding-box corner --
 * a ONE-TIME offline conversion (see scripts/README_cad_blocks.md),
 * never a runtime dependency. */
var _KBLOCK = {  // native path units: millimetres (matches w/h below)
  bed_double: {d:"M 0,1970 l 0,-1940 c 0,-16.568 13.432,-30 30,-30 l 1440,0 c 16.568,0 30,13.432 30,30 l 0,1940 c 0,16.568 -13.432,30 -30,30 l -1440,0 c -16.568,0 -30,-13.432 -30,-30 M 842.332,1903.93 c 177.912,38.778 362.088,38.778 540,0 c 24.55,-118.744 24.55,-241.256 0,-360 c -177.912,-38.778 -362.088,-38.778 -540,0 c -24.55,118.744 -24.55,241.256 0,360 M 117.668,1903.93 c 177.912,38.778 362.088,38.778 540,0 c 24.55,-118.744 24.55,-241.256 0,-360 c -177.912,-38.778 -362.088,-38.778 -540,0 c -24.55,118.744 -24.55,241.256 0,360 M 1500,1239.412 l -1500,-216.052 M 1500,1454.346 l -1500,0", w:1500.0, h:2000.0},
  sofa: {d:"M 300.003,799.997 l 399.9996,0 c 55.2288,0 100.001,-44.7722 100.001,-100.001 l 0,-500.0006 l -500.0006,0 c -55.2288,0 -100.001,44.7722 -100.001,100.001 l 0,399.9996 c 0,55.2288 44.7722,100.001 100.001,100.001 M 900.0024,799.997 l 399.9996,0 c 55.2288,0 100.001,-44.7722 100.001,-100.001 l 0,-500.0006 l -599.9994,0 l 0,500.0006 c 0,55.2288 44.7722,100.001 100.001,100.001 M 1500.0018,799.997 l 399.9996,0 c 55.2288,0 100.001,-44.7722 100.001,-100.001 l 0,-399.9996 c 0,-55.2288 -44.7722,-100.001 -100.001,-100.001 l -500.0006,0 l 0,500.0006 c 0,55.2288 44.7722,100.001 100.001,100.001 M 2000.0024,699.9982 l 0,-399.9996 c 0,-55.2288 -44.7722,-100.001 -100.001,-100.001 l -1600.0006,0 c -55.2288,0 -100.001,44.7722 -100.001,100.001 l 0,399.9996 c 0,55.2288 -44.7722,100.001 -100.001,100.001 l 0,0 c -26.521,0 -51.9574,-10.5358 -70.7102,-29.2886 c -18.7528,-18.7528 -29.2886,-44.1892 -29.2886,-70.7102 l 0,-399.9996 c 0,-165.6864 134.3144,-300.0008 300.0008,-300.0008 l 1600.0006,0 c 165.6864,0 300.0008,134.3144 300.0008,300.0008 l 0,399.9996 c 0,55.2288 -44.7722,100.001 -100.001,100.001 l 0,0 c -55.2288,0 -100.001,-44.7722 -100.001,-100.001", w:2200.0, h:800.0},
  wardrobe_cupboard: {d:"M 899.983228791,448.210198976 l -899.983228791,0 l 0,599.989517994 l 899.983228791,0 l 0,-599.989517994 M 39.9993011996,0 c 232.09971873,20.713474607 409.992837296,215.188064496 409.992837296,448.210198976 c 0,-233.02213448 177.892070366,-427.497772569 409.992837296,-448.210198976 M 0,448.210198976 l 39.9993011996,0 l 0,-448.210198976 l -39.9993011996,0 l 0,448.210198976 M 899.983228791,448.210198976 l -39.9993011996,0 l 0,-448.210198976 l 39.9993011996,0 l 0,448.210198976", w:899.98, h:1048.2},
  dining_table_6chairs: {d:"M 64.0000000265,46.000000019 l 0,0 M 126.000000052,38.0000000157 c 0,-0.552320000228 -0.447744000185,-0.999936000413 -0.999936000413,-0.999936000413 M 126.000000052,36.9999360153 l 0,-2.00000000083 M 128.000000053,35.0000640145 l -2.00000000083,0 M 124.999936052,54.9999360227 c 0.26521600011,0 0.519552000215,-0.105344000044 0.707072000292,-0.292864000121 c 0.187520000078,-0.187520000078 0.292864000121,-0.441856000183 0.292864000121,-0.707072000292 M 126.000000052,56.9999360236 l 0,-2.00000000083 M 126.000000052,56.9999360236 l 2.00000000083,0 M 128.000000053,56.9999360236 l 0,-22.0000000091 M 126.000000052,35.0000640145 l 0,22.0000000091 M 128.000000053,56.9999360236 l 0,-22.0000000091 M 107.000064044,35.0000640145 c -0.552320000228,0 -0.999936000413,0.447744000185 -0.999936000413,0.999936000413 M 106.000000044,35.0000640145 l 0,2.00000000083 M 106.000000044,56.0000000232 c 0,0.552320000228 0.447744000185,0.999936000413 0.999936000413,0.999936000413 M 106.000000044,54.9999360227 l 0,2.00000000083 M 106.000000044,36.0000000149 l 0,20.0000000083 M 106.000000044,56.9999360236 l 20.0000000083,0 M 124.999936052,54.9999360227 l -18.9999360079,0 M 106.000000044,36.9999360153 l 18.9999360079,0 M 126.000000052,35.0000640145 l -20.0000000083,0 M 107.000064044,56.9999360236 l 20.9999360087,0 M 128.000000053,35.0000640145 l -20.9999360087,0 M 28.0000000116,64.0000000265 l 0,-36.0000000149 l 72.0000000298,0 l 0,36.0000000149 l -72.0000000298,0 M 54.0000000223,90.0000000372 c 0.552320000228,0 0.999936000413,-0.447744000185 0.999936000413,-0.999936000413 M 55.0000640227,90.0000000372 l 2.00000000083,0 M 57.0000640236,92.000000038 l 0,-2.00000000083 M 37.0000640153,88.9999360368 c 0,0.26521600011 0.105344000044,0.519552000215 0.292864000121,0.707072000292 c 0.187520000078,0.187520000078 0.441856000183,0.292864000121 0.707072000292,0.292864000121 M 35.0000640145,90.0000000372 l 2.00000000083,0 M 35.0000640145,90.0000000372 l 0,2.00000000083 M 35.0000640145,92.000000038 l 22.0000000091,0 M 57.0000640236,90.0000000372 l -22.0000000091,0 M 35.0000640145,92.000000038 l 22.0000000091,0 M 57.0000640236,70.9999360294 c 0,-0.552320000228 -0.447744000185,-0.999936000413 -0.999936000413,-0.999936000413 M 57.0000640236,70.0000000289 l -2.00000000083,0 M 36.0000000149,70.0000000289 c -0.552320000228,0 -0.999936000413,0.447744000185 -0.999936000413,0.999936000413 M 37.0000640153,70.0000000289 l -2.00000000083,0 M 56.0000000232,70.0000000289 l -20.0000000083,0 M 35.0000640145,70.0000000289 l 0,20.0000000083 M 37.0000640153,88.9999360368 l 0,-18.9999360079 M 55.0000640227,70.0000000289 l 0,18.9999360079 M 57.0000640236,90.0000000372 l 0,-20.0000000083 M 35.0000640145,70.9999360294 l 0,20.9999360087 M 57.0000640236,92.000000038 l 0,-20.9999360087 M 38.0000000157,2.00000000083 c -0.552320000228,0 -0.999936000413,0.447744000185 -0.999936000413,0.999936000413 M 37.0000640153,2.00000000083 l -2.00000000083,0 M 35.0000640145,0 l 0,2.00000000083 M 55.0000640227,3.00006400124 c 0,-0.26521600011 -0.105344000044,-0.519552000215 -0.292864000121,-0.707072000292 c -0.187520000078,-0.187520000078 -0.441856000183,-0.292864000121 -0.707072000292,-0.292864000121 M 57.0000640236,2.00000000083 l -2.00000000083,0 M 57.0000640236,2.00000000083 l 0,-2.00000000083 M 57.0000640236,0 l -22.0000000091,0 M 35.0000640145,2.00000000083 l 22.0000000091,0 M 57.0000640236,0 l -22.0000000091,0 M 35.0000640145,21.0000640087 c 0,0.552320000228 0.447744000185,0.999936000413 0.999936000413,0.999936000413 M 35.0000640145,22.0000000091 l 2.00000000083,0 M 56.0000000232,22.0000000091 c 0.552320000228,0 0.999936000413,-0.447744000185 0.999936000413,-0.999936000413 M 55.0000640227,22.0000000091 l 2.00000000083,0 M 36.0000000149,22.0000000091 l 20.0000000083,0 M 57.0000640236,22.0000000091 l 0,-20.0000000083 M 55.0000640227,3.00006400124 l 0,18.9999360079 M 37.0000640153,22.0000000091 l 0,-18.9999360079 M 35.0000640145,2.00000000083 l 0,20.0000000083 M 57.0000640236,21.0000640087 l 0,-20.9999360087 M 35.0000640145,0 l 0,20.9999360087 M 74.0000000306,2.00000000083 c -0.552320000228,0 -0.999936000413,0.447744000185 -0.999936000413,0.999936000413 M 73.0000640302,2.00000000083 l -2.00000000083,0 M 71.0000640294,0 l 0,2.00000000083 M 91.0000640376,3.00006400124 c 0,-0.26521600011 -0.105344000044,-0.519552000215 -0.292864000121,-0.707072000292 c -0.187520000078,-0.187520000078 -0.441856000183,-0.292864000121 -0.707072000292,-0.292864000121 M 93.0000640384,2.00000000083 l -2.00000000083,0 M 93.0000640384,2.00000000083 l 0,-2.00000000083 M 93.0000640384,0 l -22.0000000091,0 M 71.0000640294,2.00000000083 l 22.0000000091,0 M 93.0000640384,0 l -22.0000000091,0 M 71.0000640294,21.0000640087 c 0,0.552320000228 0.447744000185,0.999936000413 0.999936000413,0.999936000413 M 71.0000640294,22.0000000091 l 2.00000000083,0 M 92.000000038,22.0000000091 c 0.552320000228,0 0.999936000413,-0.447744000185 0.999936000413,-0.999936000413 M 91.0000640376,22.0000000091 l 2.00000000083,0 M 72.0000000298,22.0000000091 l 20.0000000083,0 M 93.0000640384,22.0000000091 l 0,-20.0000000083 M 91.0000640376,3.00006400124 l 0,18.9999360079 M 73.0000640302,22.0000000091 l 0,-18.9999360079 M 71.0000640294,2.00000000083 l 0,20.0000000083 M 93.0000640384,21.0000640087 l 0,-20.9999360087 M 71.0000640294,0 l 0,20.9999360087 M 90.0000000372,90.0000000372 c 0.552320000228,0 0.999936000413,-0.447744000185 0.999936000413,-0.999936000413 M 91.0000640376,90.0000000372 l 2.00000000083,0 M 93.0000640384,92.000000038 l 0,-2.00000000083 M 73.0000640302,88.9999360368 c 0,0.26521600011 0.105344000044,0.519552000215 0.292864000121,0.707072000292 c 0.187520000078,0.187520000078 0.441856000183,0.292864000121 0.707072000292,0.292864000121 M 71.0000640294,90.0000000372 l 2.00000000083,0 M 71.0000640294,90.0000000372 l 0,2.00000000083 M 71.0000640294,92.000000038 l 22.0000000091,0 M 93.0000640384,90.0000000372 l -22.0000000091,0 M 71.0000640294,92.000000038 l 22.0000000091,0 M 93.0000640384,70.9999360294 c 0,-0.552320000228 -0.447744000185,-0.999936000413 -0.999936000413,-0.999936000413 M 93.0000640384,70.0000000289 l -2.00000000083,0 M 72.0000000298,70.0000000289 c -0.552320000228,0 -0.999936000413,0.447744000185 -0.999936000413,0.999936000413 M 73.0000640302,70.0000000289 l -2.00000000083,0 M 92.000000038,70.0000000289 l -20.0000000083,0 M 71.0000640294,70.0000000289 l 0,20.0000000083 M 73.0000640302,88.9999360368 l 0,-18.9999360079 M 91.0000640376,70.0000000289 l 0,18.9999360079 M 93.0000640384,90.0000000372 l 0,-20.0000000083 M 71.0000640294,70.9999360294 l 0,20.9999360087 M 93.0000640384,92.000000038 l 0,-20.9999360087 M 2.00000000083,54.5376000225 c 0,0.552320000228 0.447744000185,0.999936000413 0.999936000413,0.999936000413 M 2.00000000083,55.537536023 l 0,2.00000000083 M 0,57.5375360238 l 2.00000000083,0 M 3.00006400124,37.5375360155 c -0.26521600011,0 -0.519552000215,0.105344000044 -0.707072000292,0.292864000121 c -0.187520000078,0.187520000078 -0.292864000121,0.441856000183 -0.292864000121,0.707072000292 M 2.00000000083,35.5375360147 l 0,2.00000000083 M 2.00000000083,35.5375360147 l -2.00000000083,0 M 0,35.5375360147 l 0,22.0000000091 M 2.00000000083,57.5375360238 l 0,-22.0000000091 M 0,35.5375360147 l 0,22.0000000091 M 20.9999360087,57.5375360238 c 0.552320000228,0 0.999936000413,-0.447744000185 0.999936000413,-0.999936000413 M 22.0000000091,57.5375360238 l 0,-2.00000000083 M 22.0000000091,36.5376000151 c 0,-0.552320000228 -0.447744000185,-0.999936000413 -0.999936000413,-0.999936000413 M 22.0000000091,37.5375360155 l 0,-2.00000000083 M 22.0000000091,56.5376000234 l 0,-20.0000000083 M 22.0000000091,35.5375360147 l -20.0000000083,0 M 3.00006400124,37.5375360155 l 18.9999360079,0 M 22.0000000091,55.537536023 l -18.9999360079,0 M 2.00000000083,57.5375360238 l 20.0000000083,0 M 20.9999360087,35.5375360147 l -20.9999360087,0 M 0,57.5375360238 l 20.9999360087,0", w:128.0, h:92.0},
  stove: {d:"M 214.724,362.424 c 0,46.2015 -37.454,83.6555 -83.6555,83.6555 c -46.2015,0 -83.6555,-37.454 -83.6555,-83.6555 c 0,-46.2015 37.454,-83.6555 83.6555,-83.6555 c 46.2015,0 83.6555,37.454 83.6555,83.6555 M 452.5865,362.424 c 0,46.2015 -37.454,83.6555 -83.6555,83.6555 c -46.2015,0 -83.6555,-37.454 -83.6555,-83.6555 c 0,-46.2015 37.454,-83.6555 83.6555,-83.6555 c 46.2015,0 83.6555,37.454 83.6555,83.6555 M 0,500 l 500,0 l 0,-500 l -500,0 l 0,500 M 214.724,137.576 c 0,46.2015 -37.454,83.6555 -83.6555,83.6555 c -46.2015,0 -83.6555,-37.454 -83.6555,-83.6555 c 0,-46.2015 37.454,-83.6555 83.6555,-83.6555 c 46.2015,0 83.6555,37.454 83.6555,83.6555 M 452.5865,137.576 c 0,46.2015 -37.454,83.6555 -83.6555,83.6555 c -46.2015,0 -83.6555,-37.454 -83.6555,-83.6555 c 0,-46.2015 37.454,-83.6555 83.6555,-83.6555 c 46.2015,0 83.6555,37.454 83.6555,83.6555", w:500.0, h:500.0},
  toilet: {d:"M 65.938947199,109.833254146 l -20.8036463997,291.137771195 M 139.410907998,12.3107877475 c -43.4995879993,12.4831431998 -73.4726431989,52.2670631992 -73.4719607989,97.5224663985 M 225.501809597,0.226166147655 c -29.1944367995,-1.27062879998 -58.3738607991,2.82513599996 -86.0902191987,12.0846215998 M 311.592028795,12.3107877475 c -27.7163583996,-9.25948559986 -56.8957823991,-13.3559327998 -86.0902191987,-12.0846215998 M 385.065354394,109.833254146 c 0,-45.2560855993 -29.9730551995,-85.0393231987 -73.4733255989,-97.5224663985 M 405.868318394,400.971025341 l -20.8036463997,-291.137771195 M 379.086165594,430.968646941 c 7.89741519988,0.728803199989 15.6719983998,-2.33107839996 20.9537743997,-8.24680399987 c 5.28177599992,-5.91572559991 7.44430159988,-13.9864703998 5.82837839991,-21.7508175997 M 71.9174535989,430.968646941 l 307.168711995,0 M 46.1056735993,416.285446141 c 4.85322879992,9.65391279985 15.0332719998,15.4454415998 25.8117799996,14.6832007998 M 45.1353007993,400.971025341 l 0.969690399985,15.3144207998 M 225.199506397,578.360905339 l -59.9952431991,0 c -4.14148559994,0 -7.49957599988,-3.35740799995 -7.49957599988,-7.49957599988 c 0,-4.14148559994 3.35740799995,-7.49957599988 7.49957599988,-7.49957599988 l 59.9952431991,0 l 0,14.9991519998 M 225.501809597,578.360905339 l 59.9952431991,0 c 4.14148559994,0 7.49957599988,-3.35740799995 7.49957599988,-7.49957599988 c 0,-4.14148559994 -3.35740799995,-7.49957599988 -7.49957599988,-7.49957599988 l -59.9952431991,0 l 0,14.9991519998 M 450.699951193,681.297533337 l -12.6489663998,-134.855887998 c -2.58970799996,-25.5272191996 -24.0832607996,-44.9496879993 -49.7408183992,-44.9496879993 l -325.616713595,0 c -25.6582399996,0 -47.1511103993,19.4231511997 -49.7401359992,44.9496879993 l -12.9533167998,134.855887998 l 450.699951193,0 M 412.989844794,508.00751254 l -8.77975839986,-91.7220663986 M 38.0130919994,508.00751254 l 8.09258159987,-91.7220663986", w:450.7, h:681.3},
  sink: {d:"M 250.744900056,322.870206076 l -25.4012640056,0 M 250.744900056,411.775106096 l 0,-88.9053760198 M 225.34363605,411.775106096 l 25.4012640056,0 M 225.34363605,322.870206076 l 0,88.9053760198 M 263.445532059,284.767834068 c 0,14.0286720031 -11.3725920025,25.4012640056 -25.4012640056,25.4012640056 c -14.0286720031,0 -25.4012640056,-11.3725920025 -25.4012640056,-25.4012640056 c 0,-14.0286720031 11.3725920025,-25.4012640056 25.4012640056,-25.4012640056 c 14.0286720031,0 25.4012640056,11.3725920025 25.4012640056,25.4012640056 M 250.744900056,349.270594082 l 45.5013160101,0 c 39.5855880088,0 76.9077960171,-18.4573760041 100.935324022,-49.9166920111 c 24.0275280053,-31.459316007 32.0129040071,-72.3234400161 21.5956440048,-110.513396025 c -22.2306280049,-81.4988160181 -96.2562440214,-138.037144031 -180.73291604,-138.037144031 c -84.4761960188,0 -158.502288035,56.5383280126 -180.73291604,138.037144031 c -10.4172600023,38.1904320085 -2.43188400054,79.0540800176 21.5956440048,110.513396025 c 24.0275280053,31.459316007 61.3497360136,49.9166920111 100.935324022,49.9166920111 l 45.5013160101,0 M 425.997152095,457.484434106 c 27.6156160061,0 50.0028480111,-22.387232005 50.0028480111,-50.0028480111 l 0,-178.68040404 c -5.03465200112,-128.297708029 -110.874204025,-229.505304051 -239.269016053,-228.797492051 c -128.394336029,0.708288000157 -233.111480052,103.076848023 -236.730984053,231.422156051 l 0.0885360000197,176.055264039 c 0,27.6156160061 22.387232005,50.0028480111 50.0028480111,50.0028480111 l 375.905768084,0", w:476.0, h:457.48},
  shower: {d:"M 899.97334,0 l 0,20.00008 M 899.97334,0 l -899.97156,0 M 0.00178,20.00008 l 0,1005.2016 M 260.86612,507.71652 c 6.56642,-46.814 48.23266,-80.56992 95.3902,-77.2787 c 47.15754,3.29122 83.73298,42.5064 83.73298,89.77964 M 439.98752,520.21568 c 0,47.27324 -36.57366,86.48842 -83.73298,89.77964 c -47.15754,3.29122 -88.82378,-30.4647 -95.3902,-77.2787 M 349.99072,507.71652 c 6.90284,0 12.49916,5.59632 12.49916,12.49916 c 0,6.90284 -5.59632,12.49916 -12.49916,12.49916 M 20.00008,1025.1999 l 0,-1005.2016 M 20.00008,20.00008 l 879.97326,0 M 1759.94652,1025.1999 l -1739.94644,0 M 20.00008,1045.19998 l 1759.94474,0 M 1779.94482,1035.19994 l 0,-1035.19994 M 1759.94652,0 l 10.00004,0 M 1769.94656,0 l 10.00004,0 M 1759.94652,0 l 0,1025.1999 M 20.00008,507.71652 l 329.98886,0 M 349.99072,532.71662 l -329.98886,0 M 1779.94482,1045.19998 l 0,-1045.19998 M 1779.94482,0 l -1779.94482,0 M 0.00178,0 l 0,1045.19998 M 0.00178,1045.19998 l 1779.94482,0 M 1779.94482,1045.19998 l -1779.94482,-1045.19998 M 1779.94482,0 l -1779.94482,1045.19998", w:1779.95, h:1045.2},
  fridge: {d:"M 30.9999978244,29.0000000938 l 0,-0.999998865303 M 31.9999966897,29.0000000938 l -0.999998865303,0 M 31.9999966897,28.0000012285 l 0,0.999998865303 M 0,27.0000023632 l 32.999995555,0 M 0,28.0000012285 l 0,-28.0000012285 l 32.999995555,0 l 0,28.0000012285 l -32.999995555,0", w:33.0, h:29.0},
  car: {d:"M 4410.41314976,1482.89903159 l 0,-1257.66889079 M 4368.41465756,1479.39878519 l 0,-1250.66839799 M 4326.41170076,1475.89853879 l 0,-1243.66790519 M 4284.41320856,1472.39382779 l 0,-1236.66741239 M 4242.41025176,1465.39779959 l 0,-1222.66642679 M 4200.41175956,1465.39779959 l 0,-1222.66642679 M 4088.41280396,1456.06232099 l 0,-1204.00439879 M 4459.42552856,1486.95288839 l -966.402851391,-80.5324547992 M 3415.99939797,1322.70918359 c 0,43.6861109996 33.4844999997,80.0815301992 77.0188145993,83.7112499992 M 3415.99939797,1322.70918359 l -0.00892920000024,-468.707101796 M 4459.42552856,221.042345998 l -966.402851391,80.5413839992 M 3493.02267717,301.579265397 c -43.5343145996,3.62971979997 -77.0232791993,40.0251389996 -77.0188145993,83.7112499992 M 3415.99939797,385.290515396 l -0.00892920000024,468.707101796 M 980.002022991,1167.60005039 l 0,-627.200401794 M 0,1148.00045639 l 40.3198025996,322.603066797 M 40.3198025996,1470.60352319 c 1.74119399998,13.9116935999 5.21911739995,27.5555111997 10.3489427999,40.6055369996 M 50.6687453995,1511.20906019 c 21.7872479998,55.4503319995 71.3130557993,95.2120595991 130.160948399,104.502892199 M 180.829693798,1615.70748778 l 133.478146199,21.0371951998 M 314.307839997,1636.74914758 c 10.7239691999,1.69208339998 21.5416949998,2.75465819997 32.3906729997,3.19218899997 M 346.698512997,1639.93687198 l 1011.53102819,40.4805281996 M 1358.22954119,1680.41740018 l 1046.66296559,26.8054583997 M 2404.89250678,1707.22285858 l 871.110428992,-27.2206661997 M 3276.00293577,1680.00219238 l 1030.13948099,-62.3481389994 M 4306.14241676,1617.65405338 c 89.1000221992,-5.40216599995 158.421866398,-79.5368489992 157.832539199,-168.802061398 M 4463.97495596,1448.85199199 l -3.92884799996,-594.791870394 M 4460.04610796,854.060121592 l 3.92884799996,-594.791870394 M 4463.97495596,259.268251198 c 0.589327199995,-89.2607477992 -68.7325169993,-163.399895398 -157.832539199,-168.802061398 M 4306.14241676,90.4706543991 l -1029.72873779,-62.4106433994 M 3276.41367897,28.0600109997 l -871.521172192,-27.2920997997 M 2404.89250678,0.767911199993 l -1047.10049639,26.8679627997 M 1357.79201039,27.6403385997 l -1011.09349739,40.5519617996 M 346.698512997,68.1878357994 c -10.8489779999,0.433066199996 -21.6622391998,1.50010559999 -32.3906729997,3.18772439997 M 314.307839997,71.3800247993 l -133.491539999,21.0282659998 M 180.820764598,92.4082907991 c -73.8980591993,11.6347475999 -131.187806399,70.7505161993 -140.500961999,144.978955799 M 40.3198025996,237.391711198 l -40.3198025996,322.611995997 M 0,559.999242595 l 0,293.998374597 M 0,853.997617192 l 0,294.002839197 M 1175.93099399,1177.39538279 l -1175.93099399,-58.7943173994 M 1175.93099399,530.599851595 l -1175.93099399,58.7987819994 M 1259.99941199,1540.00126559 l -84.0014489992,-363.998837997 M 1175.99796299,1175.99796299 l 0,-645.398111394 M 1259.99941199,167.998433398 l -84.0014489992,363.998837997 M 1400.00033879,1596.00074338 l -140.000926799,-55.9994777995 M 1259.99941199,1540.00126559 l -1209.33066659,-28.7922053997 M 1400.00033879,111.998955599 l -140.000926799,55.9994777995 M 1259.99941199,167.998433398 l -1209.33066659,28.7922053997 M 2380.00236178,1623.99824998 l 92.0421935991,-184.079922598 M 2472.04009078,1439.92279199 l -512.040509395,-11.9204819999 M 1959.99958138,1427.99784539 l -503.999764795,167.998433398 M 2380.00236178,84.0014489992 l 92.0421935991,184.079922598 M 2472.04009078,268.081371597 l -512.040509395,11.9204819999 M 1959.99958138,280.001853597 l -503.999764795,-167.998433398 M 3220.00345797,1596.00074338 l -84.0014489992,-140.000926799 M 3136.00200897,1455.99981659 l -610.998368394,-15.0189143999 M 2524.99917598,1440.98090219 l -60.9998297994,183.021812398 M 3220.00345797,111.998955599 l -84.0014489992,140.000926799 M 3136.00200897,251.999882398 l -610.998368394,15.0189143999 M 2524.99917598,267.018796797 l -60.9998297994,-183.021812398 M 3530.94945417,1583.47754038 l -254.950982998,-127.477723799 M 3276.00293577,1455.99981659 l -84.0014489992,0 M 3192.00148677,1455.99981659 l 84.0014489992,140.000926799 M 3530.94945417,124.522158599 l -254.946518398,127.482188399 M 3276.00293577,251.999882398 l -84.0014489992,0 M 3192.00148677,251.999882398 l 84.0014489992,-140.000926799 M 3760.40757116,1572.20888999 l -176.414204398,-88.2071021992 M 3583.99783137,1484.00178779 l 815.749388992,56.8031057995 M 3760.40757116,135.790808999 l -176.414204398,88.2115667992 M 3583.99783137,224.002375798 l 815.749388992,-56.8120349995 M 1357.81879799,1680.35489578 l 42.1815407996,-84.3541523992 M 1357.81879799,27.6403385997 l 42.1815407996,84.3586169992 M 2436.00183958,1623.99824998 l -55.9994777995,84.0014489992 M 2436.00183958,84.0014489992 l -55.9994777995,-84.0014489992 M 3276.00293577,1680.00219238 l -28.0019711997,-84.0014489992 M 3276.00293577,28.0019711997 l -28.0019711997,84.0014489992 M 4438.04009456,1538.92083239 l -1162.03715879,57.0799109995 M 3276.00293577,1596.00074338 l -840.001096192,28.0019711997 M 2436.00183958,1623.99824998 l -1036.00150079,-28.0019711997 M 1400.00033879,1596.00074338 l -1335.20113439,-56.8164995995 M 4438.04009456,169.078866598 l -1162.03715879,-57.0799109995 M 3276.00293577,111.998955599 l -840.001096192,-28.0019711997 M 2436.00183958,84.0014489992 l -1036.00150079,28.0019711997 M 1400.00033879,111.998955599 l -1335.20113439,56.8120349995 M 1138.12922579,1131.03944099 l -119.530735799,0 M 1018.59848999,1131.03944099 l 0,-542.855178595 M 1018.59848999,588.179797794 l 119.530735799,0 M 1138.12922579,588.179797794 l 0,542.855178595 M 1538.01005399,1527.59414219 l 270.358317597,-79.5189905992 M 1808.37283618,1448.07515159 c 39.0652499996,-11.4874157999 64.2634523994,-49.3427591995 59.7899231994,-89.8143581991 M 1868.16275938,349.868378997 c -37.0472507996,335.108411397 -37.0472507996,673.279538394 0,1008.38794979 M 1868.16275938,349.868378997 c 4.47352919996,-40.4760635996 -20.7246731998,-78.3314069993 -59.7899231994,-89.8188227991 M 1808.37283618,260.049556198 l -272.590617597,-80.1797513992 M 1535.78221859,179.869804798 c -81.5191313992,-23.9749019998 -168.105583798,16.6886747998 -201.710627998,94.7298827991 M 1334.07159059,274.599687597 c -159.350503198,370.061764796 -158.435260198,789.573439193 2.53142819998,1158.93426179 M 1336.59855419,1433.53394939 c 33.8327387997,77.6349293993 120.164708999,117.950267399 201.411499798,94.0557281991", w:4463.98, h:1708.0},
  tree: {d:"M 853.265104843,824.357828942 l 13.9662685007,108.697419106 l -139.894676607,18.662227501 l 28.0111148014,119.962108006 l 97.917293305,26.2300180013 l 62.9258506032,26.2281471013 l 111.965881406,48.6752053025 l 97.917293305,-41.1916053021 l -52.4899704027,-48.7593958025 l 59.474040103,-33.7098762017 l -66.4581098034,-78.690054004 l -17.4966568009,-56.1569344029 l 97.919164205,-33.7080053017 l 41.9755124021,-93.7339609048 l -59.472169203,-59.9417651031 l -115.417691906,63.7228540032 l -101.370974705,18.744547101 l -59.474040103,14.9672000008 M 409.098477621,1408.87189057 l 52.4899704027,104.914459305 l -62.9258506032,104.914459305 l 115.417691906,157.370753508 l 181.791611209,41.1953471021 l -10.4340093005,-33.7117471017 l -73.4421795037,-71.2027122036 l 73.4421795037,11.2628180006 l 27.9325370014,74.9875429038 l 41.9773833021,26.1458275013 l 55.9417809028,-7.47985820038 l -13.9681394007,-82.3850815042 l -24.4788556012,-67.4253651034 l 87.4065771044,-93.7320900048 l 34.9914427018,-108.615099506 l 48.9595821025,-82.4674011042 l 34.9933136018,22.5275069011 l 13.9681394007,86.1699122044 l -41.9773833021,112.394317506 l 59.475911003,183.604513309 l 111.883561806,37.4067746019 l 153.862816008,0 l 31.4610544016,-146.106064607 l 31.4629253016,0 l 48.9614530025,59.9417651031 l -52.4113926027,86.1661704044 l 83.9491539043,-29.9250455015 l 24.4788556012,-63.7228540032 l -17.4966568009,-63.6405344032 l 62.9277215032,29.9306582015 l 6.98219880036,33.7117471017 l -3.45368140018,37.4909651019 l 45.4291938023,30.0111069015 l 55.9455227028,-74.9875429038 l -73.4421795037,-146.107935507 l -62.9258506032,-82.4674011042 l 66.4581098034,-41.1916053021 l 94.3869050048,11.2646889006 l 80.4225074041,11.1804984006 l 83.9510248043,-48.6733344025 l 80.4225074041,-30.0092360015 l -41.9755124021,-116.098699506 l 48.9595821025,-119.962108006 l -59.397333203,-78.684441304 l -69.9847563036,-44.8922455023 l -101.372845605,-44.9764360023 l 73.4384377037,-56.2392540029 l -56.0203587029,-101.135241305 l -181.795353009,-26.2300180013 l -132.912477807,11.2684307006 l 17.4985277009,-86.2503609044 l 129.382089507,-52.4581651027 l 129.380218607,0 l 185.323870409,41.2739249021 l 38.523701902,-33.7098762017 l 3.45368140018,-168.637313309 l -45.4291938023,-41.1953471021 l -80.4225074041,3.70251110019 l -55.9436518028,-44.9764360023 l 73.4384377037,-41.1953471021 l -7.05703480036,-59.9398942031 l -108.356915306,-48.7575249025 l -108.433622206,142.411037107 l -143.348358007,30.0092360015 l 69.9099203036,-149.892766208 l 55.9436518028,-93.6478995048 l -13.9662685007,-82.4692720042 l -62.9277215032,22.4451873011 l 34.9184776018,-93.6516413048 l -104.903233905,3.78108890019 l -83.9528957043,59.9417651031 l -52.4132635027,-78.688183104 l -3.53038830018,-37.4965778019 l -55.9417809028,33.7136180017 l -83.9528957043,176.120913309 l -45.4291938023,74.9856720038 l -48.9595821025,-108.697419106 l -10.5144580005,-187.38373131 l -104.901363005,0 l 0,22.5293778011 l 48.9614530025,18.746418001 l -34.9951845018,48.6733344025 l -59.395462303,-22.4451873011 l -76.9669551039,-67.5039429034 l -90.9388363046,0 l 14.0429754007,48.7575249025 l 31.4629253016,18.746418001 l -104.903233905,56.1588053029 l -28.0092439014,26.2262762013 l 55.9436518028,78.688183104 l -10.5144580005,86.1680413044 l 108.433622206,74.9875429038 l 111.885432706,97.434601105 l -111.885432706,-15.0476487008 l -157.393204308,-112.399930206 l -87.4047062044,-243.540665712 l -66.4581098034,-11.2646889006 l -66.4562389034,74.9875429038 l -3.45368140018,67.4216233034 l 10.5163289005,59.9380233031 l -76.9706969039,22.5312487011 l -139.894676607,29.9250455015 l -80.4225074041,41.2776667021 l -45.4273229023,33.7117471017 l 69.9099203036,63.6367926032 l 136.364288307,3.78108890019 l 24.4769847012,18.750159801 l -94.3887759048,37.4890942019 l -104.901363005,37.4142582019 l -24.4788556012,59.9398942031 l 41.9755124021,33.7940667017 l 122.401761606,-26.2318889013 l 41.8969346021,48.6770762025 l 87.4832840045,78.688183104 l 188.85425871,3.78108890019 l -45.5059007023,78.688183104 l -328.750806217,41.1897344021 l -59.395462303,78.690054004 l -41.9773833021,86.1680413044 l 122.398019806,29.9269164015 l -24.4788556012,52.4581651027 l -34.9914427018,86.2503609044 l 153.860945108,194.78501171 l 28.0092439014,-3.70064020019 l 150.332427708,-30.0092360015", w:1870.9, h:1824.75},
};

/* Adds one _KBLOCK entry to `grp`/`layer` as a Konva.Path, uniformly
 * scaled so its native (mm) bounding box fits exactly into the target
 * (w,h) box in px (non-uniform scaleX/scaleY -- Konva applies this as a
 * plain 2D transform on the whole path, which renders correctly even
 * though the source SVG's arc commands aren't "meant" to be stretched;
 * strokeScaleEnabled:false keeps the stroke a constant, correct px width
 * regardless of that scale, same as every hand-drawn symbol already
 * does). Stroke-only (fill:'none') by default -- matches how these
 * blocks were drawn (real CAD line-work, not filled icon shapes) -- pass
 * fill:'white' via opts for the few types (e.g. bed) that want a solid
 * base like their hand-drawn predecessor. */
function _kBlock(target, key, w, h, opts) {
  var b = _KBLOCK[key];
  if (!b) return;
  var node = new Konva.Path(Object.assign({
    data: b.d, x: 0, y: 0,
    scaleX: w / b.w, scaleY: h / b.h,
    stroke: '#1a1a1a', strokeWidth: _SW_THIN, fill: 'none',
    strokeScaleEnabled: false, listening: false,
  }, opts || {}));
  target.add(node);
  return node;
}

// PROMPT WW — room types a wall-edit can never touch (structural/circulation
// rooms whose shape the solver depends on). Mirrors app.html's own
// _NEVER_MERGE_T exactly — duplicated here (not shared via import; this
// codebase has no build step) rather than left only on the React side, so
// the canvas's own double-click-to-delete and drag-to-draw gestures can't
// even attempt an edit the server would reject anyway.
var _NEVER_MERGE_T2 = {staircase:1, elevator:1, entrance:1, corridor:1, garage:1, niche:1};

// Given a room and one of its edges (dir: n/s/e/w) plus the specific
// interior sub-interval `iv` of that edge (a wall can be partly exterior,
// partly shared — see _complementIv above), finds the OTHER room sharing
// that exact wall stretch — same shared-full-edge test app.html's
// ReviewPanel already uses for its "Delete wall & merge with" list, just
// callable for one arbitrary edge instead of only the selected room.
function _findWallNeighbor(fd, room, dir, iv) {
  // PROMPT WW follow-up #4 — used to bail out here whenever either side
  // was a circulation/structural type (staircase, elevator, corridor,
  // niche), matching the backend's own now-removed `_NEVER_MERGE` set in
  // compute_room_merge(). Removed at the user's explicit request ("any
  // wall can be deleted"): the backend's geometry audit is the real
  // safety net (still runs on every delete_wall call, still rejects a
  // merge that actually breaks reachability or drops a required room) —
  // this was only a pre-emptive client-side ban on top of it. Left
  // untouched in `_roomAtPoint` below (the drag-to-DRAW path) — splitting
  // a corridor/staircase into two pieces is a different question the user
  // didn't ask about.
  var MEPS = 0.4;
  var ox2 = room.x+room.width, oy2 = room.y+room.height;
  var mid = (iv[0]+iv[1])/2;
  for (var i = 0; i < fd.rooms.length; i++) {
    var r = fd.rooms[i];
    if (r.id === room.id) continue;
    var rx2 = r.x+r.width, ry2 = r.y+r.height;
    if (dir==='e' && Math.abs(ox2-r.x)<MEPS && mid>r.y-MEPS && mid<ry2+MEPS) return r;
    if (dir==='w' && Math.abs(rx2-room.x)<MEPS && mid>r.y-MEPS && mid<ry2+MEPS) return r;
    if (dir==='s' && Math.abs(oy2-r.y)<MEPS && mid>r.x-MEPS && mid<rx2+MEPS) return r;
    if (dir==='n' && Math.abs(ry2-room.y)<MEPS && mid>r.x-MEPS && mid<rx2+MEPS) return r;
  }
  return null;
}

// PROMPT WW follow-up #6 — draw-wall's own room-type exclusion, narrower
// than _NEVER_MERGE_T2: corridor is a valid draw-wall target (splitting a
// corridor into two pieces is a well-defined rectangle split the backend
// already allows, per compute_room_split's own now-matching exclusion
// list), it just never used to be reachable because _roomAtPoint banned
// it categorically. Staircase/elevator/entrance/garage/niche stay
// excluded here — those carry real structural data a plain rectangle
// split can't account for.
var _NEVER_SPLIT_T = {staircase:1, elevator:1, entrance:1, garage:1, niche:1};

// Which room (if any) contains a given point in real-world metres —
// used by the drag-to-draw-a-wall gesture to figure out which room the
// user started dragging inside of. Prefers the smallest matching room
// (relevant only for pathological overlaps; normal layouts never have
// two rooms both containing the same point).
function _roomAtPoint(fd, mx, my) {
  var best = null, bestArea = Infinity;
  for (var i = 0; i < fd.rooms.length; i++) {
    var r = fd.rooms[i];
    if (_NEVER_SPLIT_T[r.type] || _EXT_ROOMS[r.type]) continue;
    if (mx >= r.x && mx <= r.x+r.width && my >= r.y && my <= r.y+r.height) {
      var area = r.width*r.height;
      if (area < bestArea) { best = r; bestArea = area; }
    }
  }
  return best;
}
/* Top-view car symbol (rounded body + inset cabin + windshield bands + 4
 * wheel marks), oriented along whichever axis of the box is longer. Returns
 * an array of Konva shapes to add to a layer/group — caller supplies the
 * center (cx, cy) and the available box size (cw, ch) in px. */
function _carSymbolNodes(cx, cy, cw, ch) {
  // PROMPT VV — real CAD block (blocks.draftsperson.net): a mid-size
  // sedan drawn from above, bonnet/glasshouse/boot panels in clean
  // outline. The block's native art runs length-along-X; for a vertical
  // parking bay it's rotated 90° in place (offsetX/Y center it on cx,cy
  // first, so the rotation is a true in-place turn, not an orbit).
  // PROMPT WW follow-up #4 — these caps used to be 30/52px, sized for the
  // tightest case (a garage bay); in a normal driveway/gate bay (plenty
  // of room to spare) that made the car look tiny and lost in open space.
  // Raised ~50% — a genuinely tight garage still scales down via the
  // *0.7/*0.86 fractions same as before; only the generous case (a bay
  // with room to spare) actually gets visibly bigger.
  var vertical = ch >= cw;
  var bw = vertical ? Math.min(cw*0.7, 45) : Math.min(cw*0.86, 78);
  var bh = vertical ? Math.min(ch*0.86, 78) : Math.min(ch*0.7, 45);
  var L = vertical ? bh : bw, W = vertical ? bw : bh;
  var cb = _KBLOCK.car;
  return [new Konva.Path({
    data: cb.d, offsetX: cb.w/2, offsetY: cb.h/2,
    scaleX: L/cb.w, scaleY: W/cb.h,
    x: cx, y: cy, rotation: vertical ? 90 : 0,
    stroke: '#2a2a2a', strokeWidth: 1.1, fill: 'white',
    strokeScaleEnabled: false, listening: false,
  })];
}
/* ── Main component ─────────────────────────────────────────────────────── */
function FloorPlanCanvas(props) {
  var containerRef = useRef(null);
  var stageRef     = useRef(null);
  // PROMPT O: The Reveal — track what triggered this rebuild so a brand-new
  // plan gets the full staged draw-in, a variant switch gets a quick
  // crossfade, and an in-place edit (wall drag, furniture, fix-rule) gets
  // no reveal treatment at all.
  var lastRevealSeedRef = useRef(null);
  var lastVariantKeyRef = useRef(null);


  var fd          = props.fd;
  var pw          = Number(props.pw) || 20;
  var ph          = Number(props.ph) || 20;
  var sb          = props.sb || { front:2, back:2, left:1.5, right:1.5 };
  var plotShape   = props.plotShape || 'rect';
  var frontageList = (props.frontages && props.frontages.length) ? props.frontages : ['north'];
  var selR        = props.selR;
  var selF        = props.selF;
  // UI-011 — isAr was never read anywhere in this renderer despite being
  // passed in; every canvas-drawn label (GATE, ENTRANCE, UP/DN, the plot
  // title block) was English-only, and "GATE" specifically was bilingual
  // at one call site (§2 gate symbol) and English-only at another
  // (driveway gate) — an accidental inconsistency, not a deliberate
  // split. Both are now driven from this one flag.
  var isAr        = !!props.isAr;

  var sbLeft  = Number(sb.left)  || 1.5;
  var sbRight = Number(sb.right) || 1.5;
  var sbFront = Number(sb.front) || 2;
  var sbBack  = Number(sb.back)  || 2;
  var bw = pw - sbLeft - sbRight;
  var bh = ph - sbFront - sbBack;
  var PX = _KPX;
  var plotOx = 40, plotOy = 40;
  var sbx = plotOx + sbLeft * PX;
  var sby = plotOy + sbFront * PX;

  /* ── Create stage once ──────────────────────────────────────────────── */
  useEffect(function() {
    if (!containerRef.current || stageRef.current) return;

    var stage = new Konva.Stage({
      container: containerRef.current,
      width:  containerRef.current.clientWidth  || 800,
      height: containerRef.current.clientHeight || 600,
      draggable: true,
    });

    // Zoom on wheel
    stage.on('wheel', function(e) {
      e.evt.preventDefault();
      var s   = stage.scaleX();
      var ptr = stage.getPointerPosition();
      var mx  = (ptr.x - stage.x()) / s;
      var my  = (ptr.y - stage.y()) / s;
      var ns  = Math.max(0.25, Math.min(4, s * (e.evt.deltaY > 0 ? 0.92 : 1/0.92)));
      stage.scale({ x: ns, y: ns });
      stage.position({ x: ptr.x - mx*ns, y: ptr.y - my*ns });
    });

    stageRef.current = stage;
  }, []);

  /* ── Resize observer keeps stage sized to container ─────────────────── */
  useEffect(function() {
    if (!containerRef.current) return;
    var ro = new ResizeObserver(function(entries) {
      if (stageRef.current && entries[0]) {
        stageRef.current.width(entries[0].contentRect.width);
        stageRef.current.height(entries[0].contentRect.height);
      }
    });
    ro.observe(containerRef.current);
    return function() { ro.disconnect(); };
  }, []);

  /* ── Rebuild entire canvas on data changes ───────────────────────────── */
  useEffect(function() {
    var stage = stageRef.current;
    if (!stage) return;

    stage.destroyChildren();
    var layer = new Konva.Layer();
    stage.add(layer);

    // Empty state: nothing to draw
    if (!fd || !fd.rooms || !fd.rooms.length || bw <= 0 || bh <= 0) {
      layer.batchDraw();
      return;
    }

    var plW = pw * PX, plH = ph * PX;
    var hasN = frontageList.indexOf('north') >= 0;
    var hasS = frontageList.indexOf('south') >= 0;
    var hasE = frontageList.indexOf('east')  >= 0;
    var hasW = frontageList.indexOf('west')  >= 0;

    /* ── 1. Land fill + garden setbacks ──────────────────────────────── */
    layer.add(new Konva.Rect({x:plotOx, y:plotOy, width:plW, height:plH, fill:'#F3F0EB', listening:false}));
    if (sbFront > 0) layer.add(new Konva.Rect({x:plotOx, y:plotOy, width:plW, height:sbFront*PX, fill:_KSITE, listening:false}));
    if (sbBack  > 0) layer.add(new Konva.Rect({x:plotOx, y:plotOy+plH-sbBack*PX,  width:plW, height:sbBack*PX,  fill:_KSITE, listening:false}));
    if (sbLeft  > 0) layer.add(new Konva.Rect({x:plotOx, y:plotOy+sbFront*PX, width:sbLeft*PX,  height:plH-sbFront*PX-sbBack*PX, fill:_KSITE, listening:false}));
    if (sbRight > 0) layer.add(new Konva.Rect({x:plotOx+plW-sbRight*PX, y:plotOy+sbFront*PX, width:sbRight*PX, height:plH-sbFront*PX-sbBack*PX, fill:_KSITE, listening:false}));

    /* ── 2. Boundary walls + gate symbols ────────────────────────────── */
    var wCol = _KWALL, wSW = 6;
    var garRm  = (fd.rooms||[]).find(function(r){ return r.type === 'garage'; });
    var vGWL = 5*PX, pGWL = 1.2*PX, pilB = PX, pilS = 0.5*PX, pPilB = 0.7*PX, pPilS = 0.3*PX, gapL = PX;

    // Top-view car in the driveway — PROMPT WW follow-up: this used to be
    // a separate, cruder hand-drawn rounded-rect-plus-circles car (the
    // "old bad-looking car" that appeared on every generation's driveway,
    // regardless of whether a garage existed), independent of PROMPT VV's
    // real CAD-block car symbol — which only ever rendered inside a garage
    // room, so most floorplans (no garage in the room program) never saw
    // it. Now reuses the exact same CAD block everywhere a car is drawn.
    function drawTopCar(cx, cy, carW, carH) {
      _carSymbolNodes(cx, cy, carW, carH).forEach(function(node) {
        node.listening(false);
        layer.add(node);
      });
    }

    function addHGate(dir) {
      var atN = (dir === 'north');
      var wY  = atN ? plotOy : plotOy + plH;
      // Upper floors: just a solid boundary line (no gate gap, no symbols)
      if (!fd || fd.floor !== 0) {
        layer.add(new Konva.Line({points:[plotOx,wY,plotOx+plW,wY], stroke:wCol, strokeWidth:wSW, listening:false}));
        return;
      }
      var garCX = garRm ? sbx + (garRm.x + garRm.width/2)*PX : null;
      var rX = garCX
        ? Math.max(plotOx+wSW, Math.min(plotOx+plW-vGWL-gapL-pGWL-wSW, garCX-vGWL/2))
        : plotOx + plW/2 - vGWL/2;
      var pX = rX + vGWL + gapL;
      // Wall segments with gate gap
      layer.add(new Konva.Line({points:[plotOx,wY,rX,wY],           stroke:wCol, strokeWidth:wSW, listening:false}));
      layer.add(new Konva.Line({points:[rX+vGWL,wY,pX,wY],         stroke:wCol, strokeWidth:wSW, listening:false}));
      layer.add(new Konva.Line({points:[pX+pGWL,wY,plotOx+plW,wY], stroke:wCol, strokeWidth:wSW, listening:false}));
      // Vehicle gate pillars
      var vPY = atN ? wY : wY - pilB;
      layer.add(new Konva.Rect({x:rX-pilS, y:vPY, width:pilS, height:pilB, fill:wCol, listening:false}));
      layer.add(new Konva.Rect({x:rX+vGWL, y:vPY, width:pilS, height:pilB, fill:wCol, listening:false}));
      // Gate leaf (dashed V-shape)
      var gMx=rX+vGWL/2, gMy=atN?wY+vGWL/2:wY-vGWL/2;
      layer.add(new Konva.Line({points:[rX,wY,gMx,gMy],      stroke:wCol, strokeWidth:1.5, dash:[6,3], listening:false}));
      layer.add(new Konva.Line({points:[rX+vGWL,wY,gMx,gMy], stroke:wCol, strokeWidth:1.5, dash:[6,3], listening:false}));
      var lY = atN ? wY-8 : wY+12;
      layer.add(new Konva.Text({x:rX, y:lY-5, width:vGWL, text:isAr?'بوابة':'GATE', fontSize:8, fill:'#555', align:'center', listening:false}));
      // Pedestrian gate
      var pPY = atN ? wY : wY - pPilB;
      layer.add(new Konva.Rect({x:pX-pPilS, y:pPY, width:pPilS, height:pPilB, fill:wCol, listening:false}));
      layer.add(new Konva.Rect({x:pX+pGWL,  y:pPY, width:pPilS, height:pPilB, fill:wCol, listening:false}));
      layer.add(new Konva.Line({points:[pX,wY,pX,atN?wY+pGWL:wY-pGWL], stroke:wCol, strokeWidth:1.2, dash:[4,2], listening:false}));
      layer.add(new Konva.Text({x:pX, y:lY-5, width:pGWL, text:'مدخل', fontSize:7, fill:'#666', align:'center', listening:false}));
      // Driveway fill
      var drvY = atN ? wY : sby+bh*PX;
      var drvH = atN ? sbFront*PX : sbBack*PX;
      layer.add(new Konva.Rect({x:rX, y:drvY, width:vGWL, height:drvH, fill:'#D4D4D4', opacity:0.75, listening:false}));
      // Top-view car parked in driveway
      var carW = Math.min(vGWL - 8, 100), carH = Math.min(drvH - 12, 200);
      if (carH > 40 && carW > 30) {
        drawTopCar(rX + vGWL/2, drvY + drvH/2, carW, carH);
      }
    }

    function addVGate(dir) {
      var atE = (dir === 'east');
      var wX  = atE ? plotOx+plW : plotOx;
      // Upper floors: just a solid boundary line
      if (!fd || fd.floor !== 0) {
        layer.add(new Konva.Line({points:[wX,plotOy,wX,plotOy+plH], stroke:wCol, strokeWidth:wSW, listening:false}));
        return;
      }
      var garCY = garRm ? sby + (garRm.y + garRm.height/2)*PX : null;
      var rY = garCY
        ? Math.max(plotOy+wSW, Math.min(plotOy+plH-vGWL-gapL-pGWL-wSW, garCY-vGWL/2))
        : plotOy + plH/2 - vGWL/2;
      var pY = rY + vGWL + gapL;
      layer.add(new Konva.Line({points:[wX,plotOy,wX,rY],           stroke:wCol, strokeWidth:wSW, listening:false}));
      layer.add(new Konva.Line({points:[wX,rY+vGWL,wX,pY],         stroke:wCol, strokeWidth:wSW, listening:false}));
      layer.add(new Konva.Line({points:[wX,pY+pGWL,wX,plotOy+plH], stroke:wCol, strokeWidth:wSW, listening:false}));
      var vPX = atE ? wX-pilB : wX;
      layer.add(new Konva.Rect({x:vPX, y:rY-pilS,    width:pilB, height:pilS, fill:wCol, listening:false}));
      layer.add(new Konva.Rect({x:vPX, y:rY+vGWL,    width:pilB, height:pilS, fill:wCol, listening:false}));
      var gMy=rY+vGWL/2, gMx=atE?wX-vGWL/2:wX+vGWL/2;
      layer.add(new Konva.Line({points:[wX,rY,gMx,gMy],       stroke:wCol, strokeWidth:1.5, dash:[6,3], listening:false}));
      layer.add(new Konva.Line({points:[wX,rY+vGWL,gMx,gMy],  stroke:wCol, strokeWidth:1.5, dash:[6,3], listening:false}));
      var lTextX = atE ? wX+2 : wX-48;
      layer.add(new Konva.Text({x:lTextX, y:rY+vGWL/2-5, width:46, text:isAr?'بوابة':'GATE', fontSize:8, fill:'#555', align: atE?'left':'right', listening:false}));
      var ppX = atE ? wX-pPilB : wX;
      layer.add(new Konva.Rect({x:ppX, y:pY-pPilS,  width:pPilB, height:pPilS, fill:wCol, listening:false}));
      layer.add(new Konva.Rect({x:ppX, y:pY+pGWL,   width:pPilB, height:pPilS, fill:wCol, listening:false}));
      layer.add(new Konva.Line({points:[wX,pY,atE?wX-pGWL:wX+pGWL,pY], stroke:wCol, strokeWidth:1.2, dash:[4,2], listening:false}));
      var drvX = atE ? sbx+bw*PX : wX;
      var drvW = atE ? sbRight*PX : sbLeft*PX;
      layer.add(new Konva.Rect({x:drvX, y:rY, width:drvW, height:vGWL, fill:'#D4D4D4', opacity:0.75, listening:false}));
      // Top-view car in side driveway — same CAD block as the horizontal
      // gate's drawTopCar() above, not a separately hand-drawn duplicate.
      var carH2 = Math.min(vGWL - 8, 100), carW2 = Math.min(drvW - 12, 200);
      if (carW2 > 40 && carH2 > 30) {
        drawTopCar(drvX+drvW/2, rY+vGWL/2, carW2, carH2);
      }
    }

    if (hasN) addHGate('north'); else layer.add(new Konva.Line({points:[plotOx,plotOy,plotOx+plW,plotOy], stroke:wCol, strokeWidth:wSW, listening:false}));
    if (hasS) addHGate('south'); else layer.add(new Konva.Line({points:[plotOx,plotOy+plH,plotOx+plW,plotOy+plH], stroke:wCol, strokeWidth:wSW, listening:false}));
    if (hasE) addVGate('east');  else layer.add(new Konva.Line({points:[plotOx+plW,plotOy,plotOx+plW,plotOy+plH], stroke:wCol, strokeWidth:wSW, listening:false}));
    if (hasW) addVGate('west');  else layer.add(new Konva.Line({points:[plotOx,plotOy,plotOx,plotOy+plH], stroke:wCol, strokeWidth:wSW, listening:false}));

    // Tree — CAD style: simple circle outline
    var TREE_R = 12;
    function drawTree(ttx, tty) {
      // PROMPT VV — real CAD block (blocks.draftsperson.net): a compact
      // scalloped canopy outline with internal foliage marks, in place of
      // the plain circle+dot. Sized to the same ~2*TREE_R footprint the
      // old symbol used, so setback layouts don't visually jump in scale.
      var tb = _KBLOCK.tree, td = TREE_R * 2.2;
      layer.add(new Konva.Path({
        data: tb.d, offsetX: tb.w/2, offsetY: tb.h/2,
        scaleX: td/tb.w, scaleY: td/tb.h,
        x: ttx, y: tty,
        stroke: '#1a1a1a', strokeWidth: 1, fill: 'white',
        strokeScaleEnabled: false, listening: false,
      }));
    }
    // PROMPT X phase 5 — trees ONLY on the street side, and only a pair. They
    // used to be sprinkled on every setback, which is where the "weird dots
    // around the plot" came from: four identical circles floating in green,
    // carrying no information and pulling the eye off the plan.
    if (sbFront > 0) { drawTree(plotOx+plW*0.22, plotOy+sbFront*0.5*PX); drawTree(plotOx+plW*0.78, plotOy+sbFront*0.5*PX); }

    var _revealMark_boundary = layer.children.length; // PROMPT O reveal bucket boundary
    /* ── 3. (Dimension strings drawn after bbX0 is computed — see §4.5) ── */

    /* ── 4. Building bounding box (interior rooms only) ──────────────── */
    var bbX0=Infinity,bbY0=Infinity,bbX1=-Infinity,bbY1=-Infinity;
    fd.rooms.forEach(function(rm){
      if (_EXT_ROOMS[rm.type]) return;   // exterior structures sit outside the building shell
      if(rm.x<bbX0)bbX0=rm.x; if(rm.y<bbY0)bbY0=rm.y;
      if(rm.x+rm.width>bbX1)bbX1=rm.x+rm.width;
      if(rm.y+rm.height>bbY1)bbY1=rm.y+rm.height;
    });

    /* PROMPT X / LANDING B — the OUTLINE is the authority for "is this wall on
     * the outside of the building", not the room bounding box.
     *
     * These four predicates used to compare a room edge against bbX0..bbY1,
     * the bounding box of all rooms. A bounding box is not a footprint the
     * moment the plan is anything but a solid rectangle: on an L-shaped floor
     * it calls interior walls "exterior" (heavy stroke in the middle of the
     * plan) and exterior walls "interior". Combined with Konva CENTERING a
     * stroke on its path, that is what drew wall bands outside the villa
     * outline — the symptom that started PROMPT X.
     *
     * The backend now ships the true rectilinear room union as fd.outline
     * (backend/outline.py). A wall is exterior iff it lies ON that polygon. */
    // Every connected component of the footprint. Until corridors are solved
    // rooms a floor genuinely splits into more than one cluster, so drawing
    // (and wall-classifying against) only the largest would leave the rest
    // looking like it fell outside the building.
    var _outlineParts = (fd.outline_parts && fd.outline_parts.length)
      ? fd.outline_parts.filter(function(p){ return p && p.length >= 3; })
      : ((fd.outline && fd.outline.length >= 3) ? [fd.outline] : []);

    var _outlineSegs = (function(){
      if (!_outlineParts.length) return null;
      var segs = {h:[], v:[]};   // h: {y, x0, x1}   v: {x, y0, y1}
      _outlineParts.forEach(function(pts){
        for (var i = 0; i < pts.length; i++) {
          var a = pts[i], b = pts[(i+1) % pts.length];
          if (Math.abs(a[1]-b[1]) < 1e-6) segs.h.push({y:a[1], x0:Math.min(a[0],b[0]), x1:Math.max(a[0],b[0])});
          else if (Math.abs(a[0]-b[0]) < 1e-6) segs.v.push({x:a[0], y0:Math.min(a[1],b[1]), y1:Math.max(a[1],b[1])});
        }
      });
      // MERGE collinear, touching/overlapping segments. A room wall can lie
      // on the outline while spanning TWO consecutive outline segments (the
      // polygon has a vertex partway along it, e.g. where a neighbouring room
      // steps in). Testing against one segment at a time then reports "not
      // exterior", the wall is drawn as a CENTERED interior poché, and half of
      // it lands outside the building — the very protrusion LANDING B exists
      // to remove. Found with the ?debug=1 overlay.
      ['h','v'].forEach(function(kind){
        var key = kind === 'h' ? 'y' : 'x';
        var lo  = kind === 'h' ? 'x0' : 'y0';
        var hi  = kind === 'h' ? 'x1' : 'y1';
        var buckets = {};
        segs[kind].forEach(function(s){
          var k = s[key].toFixed(4);
          (buckets[k] = buckets[k] || []).push(s);
        });
        var merged = [];
        Object.keys(buckets).forEach(function(k){
          var arr = buckets[k].slice().sort(function(p,q){ return p[lo]-q[lo]; });
          var cur = null;
          arr.forEach(function(s){
            if (cur && s[lo] <= cur[hi] + 1e-6) { cur[hi] = Math.max(cur[hi], s[hi]); }
            else { cur = {}; cur[key] = s[key]; cur[lo] = s[lo]; cur[hi] = s[hi]; merged.push(cur); }
          });
        });
        segs[kind] = merged;
      });
      return segs;
    })();

    /* Which PORTIONS of a room edge lie on the outline.
     *
     * A room edge is frequently only PARTLY exterior: the majlis's south edge
     * may run 7.70->13.30 while the outline covers only 7.70->12.80, the rest
     * facing the dining room. Classifying the edge as a single yes/no gets it
     * wrong either way — measured 16 such edges on the frozen trace, each one
     * drawn as a centered interior poche with half its thickness outside the
     * villa. So return the covered intervals and let the caller draw the
     * exterior and interior stretches separately. */
    function _coverH(y, x0, x1) {
      if (!_outlineSegs) return [];
      var out = [];
      for (var i = 0; i < _outlineSegs.h.length; i++) {
        var s = _outlineSegs.h[i];
        if (Math.abs(s.y - y) >= _KEPS) continue;
        var lo = Math.max(x0, s.x0), hi = Math.min(x1, s.x1);
        if (hi - lo > 1e-6) out.push([lo, hi]);
      }
      return _mergeIv(out);
    }
    function _coverV(x, y0, y1) {
      if (!_outlineSegs) return [];
      var out = [];
      for (var i = 0; i < _outlineSegs.v.length; i++) {
        var s = _outlineSegs.v[i];
        if (Math.abs(s.x - x) >= _KEPS) continue;
        var lo = Math.max(y0, s.y0), hi = Math.min(y1, s.y1);
        if (hi - lo > 1e-6) out.push([lo, hi]);
      }
      return _mergeIv(out);
    }
    function _mergeIv(iv) {
      if (!iv.length) return iv;
      iv.sort(function(a,b){ return a[0]-b[0]; });
      var out = [iv[0].slice()];
      for (var i = 1; i < iv.length; i++) {
        if (iv[i][0] <= out[out.length-1][1] + 1e-6) out[out.length-1][1] = Math.max(out[out.length-1][1], iv[i][1]);
        else out.push(iv[i].slice());
      }
      return out;
    }
    function _complementIv(a, b, iv) {
      var gaps = [], cur = a;
      for (var i = 0; i < iv.length; i++) {
        if (iv[i][0] - cur > 1e-6) gaps.push([cur, iv[i][0]]);
        cur = Math.max(cur, iv[i][1]);
      }
      if (b - cur > 1e-6) gaps.push([cur, b]);
      return gaps;
    }

    // Legacy bbox predicates, kept ONLY as the fallback for payloads with no
    // outline (older cached responses). Never used when fd.outline is present.
    function onExtN(y){ return _outlineSegs ? false : Math.abs(y-bbY0) < _KEPS; }
    function onExtS(y){ return _outlineSegs ? false : Math.abs(y-bbY1) < _KEPS; }
    function onExtWa(x){ return _outlineSegs ? false : Math.abs(x-bbX0) < _KEPS; }
    function onExtE(x){ return _outlineSegs ? false : Math.abs(x-bbX1) < _KEPS; }

    // A door/window's own span (in metres) is a single yes/no case the
    // outline-segment system was built to answer correctly — unlike a full
    // room edge it isn't expected to straddle exterior/interior, so "is any
    // real part of this opening's span on the true outline boundary" is the
    // right question, answered via the same _coverH/_coverV interval data
    // the wall-drawing code (§6) already uses instead of the disabled
    // onExtN/S/Wa/E point predicates above. Falls back to the legacy bbox
    // check when there's no outline at all (older cached payloads).
    function onExtSpanH(y, x0, x1) {
      if (!_outlineSegs) return Math.abs(y-bbY0) < _KEPS || Math.abs(y-bbY1) < _KEPS;
      var cov = _coverH(y, x0, x1), total = 0;
      for (var i = 0; i < cov.length; i++) total += cov[i][1] - cov[i][0];
      return total > (x1 - x0) * 0.4;
    }
    function onExtSpanV(x, y0, y1) {
      if (!_outlineSegs) return Math.abs(x-bbX0) < _KEPS || Math.abs(x-bbX1) < _KEPS;
      var cov = _coverV(x, y0, y1), total = 0;
      for (var i = 0; i < cov.length; i++) total += cov[i][1] - cov[i][0];
      return total > (y1 - y0) * 0.4;
    }

    /* ── 4.4. Roof/terrace: floor-below footprint not covered by this floor ──
     * Approximates the true footprint difference as a 4-strip "frame" around
     * this floor's bounding box, clipped to the floor-below's bounding box —
     * exact for the common case where this floor's footprint sits fully
     * inside the floor below's, an approximation otherwise (no polygon
     * boolean difference, so an L/U-shaped mismatch isn't traced exactly). */
    if (props.belowFloor && props.belowFloor.rooms && props.belowFloor.rooms.length && isFinite(bbX0)) {
      var belX0=Infinity, belY0=Infinity, belX1=-Infinity, belY1=-Infinity;
      props.belowFloor.rooms.forEach(function(rm){
        if (_EXT_ROOMS[rm.type]) return;
        if (rm.x<belX0) belX0=rm.x; if (rm.y<belY0) belY0=rm.y;
        if (rm.x+rm.width>belX1) belX1=rm.x+rm.width;
        if (rm.y+rm.height>belY1) belY1=rm.y+rm.height;
      });
      if (isFinite(belX0) && (belX1-belX0)*(belY1-belY0) > (bbX1-bbX0)*(bbY1-bbY0) + 1) {
        var cx0=Math.max(bbX0,belX0), cy0=Math.max(bbY0,belY0);
        var cx1=Math.min(bbX1,belX1), cy1=Math.min(bbY1,belY1);
        var roofStrips = [];
        if (cy0>belY0) roofStrips.push([belX0, belY0, belX1-belX0, cy0-belY0]);           // top
        if (cy1<belY1) roofStrips.push([belX0, cy1, belX1-belX0, belY1-cy1]);             // bottom
        if (cx0>belX0) roofStrips.push([belX0, cy0, cx0-belX0, Math.max(0,cy1-cy0)]);     // left
        if (cx1<belX1) roofStrips.push([cx1, cy0, belX1-cx1, Math.max(0,cy1-cy0)]);       // right
        roofStrips.forEach(function(s, si){
          var rrx=sbx+s[0]*PX, rry=sby+s[1]*PX, rrw=s[2]*PX, rrh=s[3]*PX;
          if (rrw<=0 || rrh<=0) return;
          layer.add(new Konva.Rect({x:rrx, y:rry, width:rrw, height:rrh, fill:'#F2EFE8', stroke:'#9a9a9a', strokeWidth:1, listening:false}));
          var hatchStep = 10;
          for (var hx=-rrh; hx<rrw; hx+=hatchStep) {
            // Diagonal hatch line clipped to the strip rectangle
            var xa=rrx+Math.max(0,hx), ya=rry+Math.max(0,-(hx));
            var xb=rrx+Math.min(rrw, hx+rrh), yb=rry+Math.min(rrh, rrw-hx);
            layer.add(new Konva.Line({points:[xa,ya,xb,yb], stroke:'#c9c2b0', strokeWidth:0.7, listening:false}));
          }
          if (rrw>26 && rrh>14) {
            layer.add(new Konva.Text({x:rrx, y:rry+rrh/2-6, width:rrw, text:'ROOF / سطح', fontSize:8, fontStyle:'bold', fill:'#777', align:'center', listening:false}));
            layer.add(new Konva.Text({x:rrx, y:rry+rrh/2+5, width:rrw, text:(s[2]*s[3]).toFixed(1)+'m²', fontSize:7, fill:'#999', align:'center', listening:false}));
          }
        });
      }
    }

    /* ── 4.5. Dimension strings — overall + room-by-room ─────────────── */
    if (isFinite(bbX0)) {
      var DIM_OFF  = 26;   // px from building edge to first dim line
      var TICK_H   = 5;    // half-height of tick mark
      var DIM_FS   = 7.5;  // label font size
      var dimCol   = '#666';
      var extCol   = '#bbb';

      var bldgS = sby + bbY1*PX, bldgN = sby + bbY0*PX;
      var bldgE = sbx + bbX1*PX, bldgW = sbx + bbX0*PX;

      // Collect sorted break positions from rooms touching each boundary
      var xBrks = {}; xBrks[bbX0] = 1; xBrks[bbX1] = 1;
      var yBrks = {}; yBrks[bbY0] = 1; yBrks[bbY1] = 1;
      fd.rooms.forEach(function(rm) {
        if (_EXT_ROOMS[rm.type]) return;
        if (Math.abs(rm.y - bbY0) < _KEPS || Math.abs(rm.y + rm.height - bbY1) < _KEPS) {
          xBrks[rm.x] = 1; xBrks[rm.x + rm.width] = 1;
        }
        if (Math.abs(rm.x - bbX0) < _KEPS || Math.abs(rm.x + rm.width - bbX1) < _KEPS) {
          yBrks[rm.y] = 1; yBrks[rm.y + rm.height] = 1;
        }
      });
      var xBreaks = Object.keys(xBrks).map(Number)
        .filter(function(v){ return v >= bbX0 && v <= bbX1; })
        .sort(function(a,b){ return a-b; });
      var yBreaks = Object.keys(yBrks).map(Number)
        .filter(function(v){ return v >= bbY0 && v <= bbY1; })
        .sort(function(a,b){ return a-b; });

      // ── South side: room-by-room then overall ──
      var dY1 = bldgS + DIM_OFF;
      var dY2 = bldgS + DIM_OFF + 20;
      for (var xi = 0; xi < xBreaks.length - 1; xi++) {
        var xA = sbx + xBreaks[xi]*PX, xB = sbx + xBreaks[xi+1]*PX;
        layer.add(new Konva.Line({points:[xA,dY1,xB,dY1], stroke:dimCol, strokeWidth:0.8, listening:false}));
        layer.add(new Konva.Line({points:[xA,dY1-TICK_H,xA,dY1+TICK_H], stroke:dimCol, strokeWidth:0.8, listening:false}));
        layer.add(new Konva.Line({points:[xB,dY1-TICK_H,xB,dY1+TICK_H], stroke:dimCol, strokeWidth:0.8, listening:false}));
        layer.add(new Konva.Line({points:[xA,bldgS,xA,dY1-TICK_H], stroke:extCol, strokeWidth:0.5, dash:[2,3], listening:false}));
        layer.add(new Konva.Line({points:[xB,bldgS,xB,dY1-TICK_H], stroke:extCol, strokeWidth:0.5, dash:[2,3], listening:false}));
        var midX = (xA+xB)/2;
        layer.add(new Konva.Text({x:midX-22, y:dY1+TICK_H+1, width:44,
          text:(xBreaks[xi+1]-xBreaks[xi]).toFixed(2)+'m',
          fontSize:DIM_FS, fill:'#555', align:'center', listening:false}));
      }
      // Overall X
      layer.add(new Konva.Line({points:[bldgW,dY2,bldgE,dY2], stroke:'#444', strokeWidth:1.2, listening:false}));
      layer.add(new Konva.Line({points:[bldgW,dY2-TICK_H,bldgW,dY2+TICK_H], stroke:'#444', strokeWidth:1.2, listening:false}));
      layer.add(new Konva.Line({points:[bldgE,dY2-TICK_H,bldgE,dY2+TICK_H], stroke:'#444', strokeWidth:1.2, listening:false}));
      layer.add(new Konva.Text({x:(bldgW+bldgE)/2-32, y:dY2+TICK_H+1, width:64,
        text:(bbX1-bbX0).toFixed(2)+'m', fontSize:DIM_FS+1, fontStyle:'bold', fill:'#333', align:'center', listening:false}));

      // ── East side: room-by-room then overall ──
      var dX1 = bldgE + DIM_OFF;
      var dX2 = bldgE + DIM_OFF + 20;
      for (var yi = 0; yi < yBreaks.length - 1; yi++) {
        var yA = sby + yBreaks[yi]*PX, yB = sby + yBreaks[yi+1]*PX;
        layer.add(new Konva.Line({points:[dX1,yA,dX1,yB], stroke:dimCol, strokeWidth:0.8, listening:false}));
        layer.add(new Konva.Line({points:[dX1-TICK_H,yA,dX1+TICK_H,yA], stroke:dimCol, strokeWidth:0.8, listening:false}));
        layer.add(new Konva.Line({points:[dX1-TICK_H,yB,dX1+TICK_H,yB], stroke:dimCol, strokeWidth:0.8, listening:false}));
        layer.add(new Konva.Line({points:[bldgE,yA,dX1-TICK_H,yA], stroke:extCol, strokeWidth:0.5, dash:[2,3], listening:false}));
        layer.add(new Konva.Line({points:[bldgE,yB,dX1-TICK_H,yB], stroke:extCol, strokeWidth:0.5, dash:[2,3], listening:false}));
        var midY = (yA+yB)/2;
        layer.add(new Konva.Text({x:dX1+TICK_H+2, y:midY-DIM_FS/2, width:42,
          text:(yBreaks[yi+1]-yBreaks[yi]).toFixed(2)+'m',
          fontSize:DIM_FS, fill:'#555', listening:false}));
      }
      // Overall Y
      layer.add(new Konva.Line({points:[dX2,bldgN,dX2,bldgS], stroke:'#444', strokeWidth:1.2, listening:false}));
      layer.add(new Konva.Line({points:[dX2-TICK_H,bldgN,dX2+TICK_H,bldgN], stroke:'#444', strokeWidth:1.2, listening:false}));
      layer.add(new Konva.Line({points:[dX2-TICK_H,bldgS,dX2+TICK_H,bldgS], stroke:'#444', strokeWidth:1.2, listening:false}));
      layer.add(new Konva.Text({x:dX2+TICK_H+2, y:(bldgN+bldgS)/2-5, width:62,
        text:(bbY1-bbY0).toFixed(2)+'m', fontSize:DIM_FS+1, fontStyle:'bold', fill:'#333', listening:false}));
    }

    /* ── 5. Building shell — true footprint polygon from the backend ────── */
    if (isFinite(bbX0)) {
      var shOx=sbx+bbX0*PX, shOy=sby+bbY0*PX, shBW=(bbX1-bbX0)*PX, shBH=(bbY1-bbY0)*PX;
      var outlinePts = _outlineParts.length ? _outlineParts[0] : null;
      if (outlinePts) {
        // Backend sends the villa footprint (buildable-area metres, y-down,
        // clockwise) — a plain box for rect plots, the true notched polygon
        // for L/U. Convert to screen coords and inset for the floor fill so
        // the notch actually reads as missing area instead of being papered
        // over by a rectangular fill.
        _outlineParts.forEach(function(part){
          var outlineFlat = part.reduce(function(a,p){ a.push(sbx+p[0]*PX, sby+p[1]*PX); return a; }, []);
          layer.add(new Konva.Line({points:outlineFlat, closed:true, fill:_KWALL, listening:false}));
          var insetFlat = _insetRectilinearPolygon(outlineFlat, _KEXT_W);
          layer.add(new Konva.Line({points:insetFlat, closed:true, fill:_KFLOOR, listening:false}));
        });
      } else {
        // Fallback for responses without an outline field (older cache).
        var shellFlat = _mkShellPts(plotShape, shBW, shBH, shOx, shOy);
        if (shellFlat) {
          layer.add(new Konva.Line({points:shellFlat, closed:true, fill:_KWALL, listening:false}));
          layer.add(new Konva.Rect({x:shOx+_KEXT_W, y:shOy+_KEXT_W, width:Math.max(0,shBW-2*_KEXT_W), height:Math.max(0,shBH-2*_KEXT_W), fill:_KFLOOR, listening:false}));
        } else {
          layer.add(new Konva.Rect({x:shOx, y:shOy, width:shBW, height:shBH, fill:_KWALL, listening:false}));
          layer.add(new Konva.Rect({x:shOx+_KEXT_W, y:shOy+_KEXT_W, width:Math.max(0,shBW-2*_KEXT_W), height:Math.max(0,shBH-2*_KEXT_W), fill:_KFLOOR, listening:false}));
        }
      }
    }

    /* ── 5.2. OPEN CIRCULATION (PROMPT Y item 2) ─────────────────────────
     * The complement of the closed rooms inside the footprint. Given a very
     * subtle floor treatment so it reads as continuous open space — a صالة the
     * staircase stands in — rather than as leftover void or, worse, a walled
     * corridor tube. NO walls are drawn between circulation rectangles: they
     * are one room. Only elongated stretches get a label, so a broad hall is
     * not peppered with text. */
    if (fd.circulation && fd.circulation.length) {
      fd.circulation.forEach(function(cp) {
        var cx0 = sbx + cp.x*PX, cy0 = sby + cp.y*PX;
        var cw = cp.width*PX, ch = cp.height*PX;
        layer.add(new Konva.Rect({x:cx0, y:cy0, width:cw, height:ch,
          fill:_KCIRC, listening:false}));
      });
      // Label the largest piece once, and any genuinely passage-shaped piece.
      var biggest = null, bigA = 0;
      fd.circulation.forEach(function(cp) {
        var a = cp.width*cp.height;
        if (a > bigA) { bigA = a; biggest = cp; }
      });
      fd.circulation.forEach(function(cp) {
        var isBig = (cp === biggest);
        if (!isBig && cp.kind !== 'passage') return;
        if (cp.width*cp.height < 4) return;          // too small to letter
        var cx0 = sbx + cp.x*PX, cy0 = sby + cp.y*PX;
        var cw = cp.width*PX, ch = cp.height*PX;
        var en = isBig ? 'HALL' : 'PASSAGE', ar = isBig ? 'صالة' : 'ممر';
        var fs = Math.max(6, Math.min(10, cw/8, ch/3));
        layer.add(new Konva.Text({x:cx0, y:cy0 + ch/2 - fs, width:cw, align:'center',
          text:en, fontSize:fs, fill:'#9A9384', listening:false}));
        layer.add(new Konva.Text({x:cx0, y:cy0 + ch/2 + 1, width:cw, align:'center',
          text:ar, fontSize:fs-1, fill:'#B0A896', listening:false}));
      });
    }

    /* ── 5.4. PROMPT Y item 3 — THE PERIMETER WALL, ALWAYS ───────────────
     * The villa can never render unenclosed. Drawn from the footprint itself,
     * unconditionally, AFTER the circulation fill — the open space runs right
     * up to the footprint edge, so filling it was painting over the wall band
     * and leaving the building looking like loose boxes with no envelope.
     *
     * This also replaces the old behaviour where an exterior wall only
     * appeared where a ROOM happened to sit against the boundary. With rooms
     * filling ~70% of the footprint, the other 30% of the perimeter simply had
     * no wall — which is exactly the "the villa has no walls" defect. */
    if (_outlineParts.length) {
      _outlineParts.forEach(function(part) {
        var flat = part.reduce(function(a,p){ a.push(sbx+p[0]*PX, sby+p[1]*PX); return a; }, []);
        // Offset inward by half the stroke so the band lies wholly inside the
        // footprint (same rule as the per-room exterior walls, LANDING B).
        var inset = _insetRectilinearPolygon(flat, _KEXT_W/2);
        layer.add(new Konva.Line({points:inset, closed:true, stroke:_KWALL,
          strokeWidth:_KEXT_W, lineJoin:'miter', listening:false}));
      });
    }

    /* ── 5.5. Exterior standalone structures (driver_room, external_annex) ── */
    if (fd.floor === 0) {
      fd.rooms.forEach(function(rm) {
        if (!_EXT_ROOMS[rm.type]) return;
        var rx=sbx+rm.x*PX, ry=sby+rm.y*PX, rw=rm.width*PX, rh=rm.height*PX;
        var extFill = rm.type === 'external_annex' ? '#F5EDD8' : '#EEF0F4';
        // Outer wall (thick)
        layer.add(new Konva.Rect({x:rx, y:ry, width:rw, height:rh, fill:_KWALL, listening:false}));
        // Inner floor
        layer.add(new Konva.Rect({x:rx+_KEXT_W, y:ry+_KEXT_W, width:Math.max(0,rw-2*_KEXT_W), height:Math.max(0,rh-2*_KEXT_W), fill:extFill, listening:false}));
        // Service entrance on north wall (faces toward main building)
        var sdoorW = 0.9*PX;
        var sdx = rx + (rw - sdoorW)/2, sdy = ry;
        layer.add(new Konva.Rect({x:sdx-1, y:sdy-_KEXT_W, width:sdoorW+2, height:_KEXT_W*2, fill:extFill, listening:false}));
        layer.add(new Konva.Line({points:[sdx, sdy, sdx, sdy-sdoorW], stroke:_KWALL, strokeWidth:1.5, listening:false}));
        layer.add(new Konva.Path({data:'M'+sdx+' '+(sdy-sdoorW)+' A'+sdoorW+' '+sdoorW+' 0 0 1 '+(sdx+sdoorW)+' '+sdy, stroke:_KWALL, strokeWidth:1.2, dash:[4,2], listening:false}));
        // Label above the structure
        var extLbl = rm.type === 'external_annex' ? 'ANNEX / ملحق' : 'DRIVER / سائق';
        layer.add(new Konva.Text({x:rx, y:ry-18, width:rw, text:extLbl, fontSize:8, fontStyle:'bold', fill:'#555', align:'center', listening:false}));
      });

      // Render fd.external_structures array (new backend field — garage, driver_room etc.)
      // Coordinates are in PLOT metres (0,0 = NW corner of the plot, per
      // backend/models.py ExternalStructure) — must use plotOx/plotOy, NOT
      // sbx/sby (the buildable-area origin, inset by the setback). Using
      // sbx/sby here previously shifted every external structure inward by
      // the setback amount, landing it under the staircase/elevator room
      // fills (drawn later, in step 6) and hiding it completely.
      (fd.external_structures || []).forEach(function(es) {
        var rx=plotOx+es.x*PX, ry=plotOy+es.y*PX, rw=es.width*PX, rh=es.height*PX;
        var esFill = es.type === 'garage' ? '#E8EBF0' : (es.type === 'external_annex' ? '#F5EDD8' : '#EEF0F4');
        layer.add(new Konva.Rect({x:rx, y:ry, width:rw, height:rh, fill:_KWALL, listening:false}));
        layer.add(new Konva.Rect({x:rx+_KEXT_W, y:ry+_KEXT_W, width:Math.max(0,rw-2*_KEXT_W), height:Math.max(0,rh-2*_KEXT_W), fill:esFill, listening:false}));
        // Gate break if present
        var gate = es.gate;
        if (gate && gate.wall) {
          var gw = (es.width > es.height ? es.width : es.height) * 0.6 * PX;
          gw = Math.min(gw, 5*PX);
          var gcx, gcy;
          if (gate.wall === 'north') { gcx = rx + rw/2; gcy = ry; layer.add(new Konva.Rect({x:gcx-gw/2, y:gcy-3, width:gw, height:_KEXT_W+6, fill:esFill, listening:false})); }
          else if (gate.wall === 'south') { gcx = rx + rw/2; gcy = ry+rh; layer.add(new Konva.Rect({x:gcx-gw/2, y:gcy-_KEXT_W-3, width:gw, height:_KEXT_W+6, fill:esFill, listening:false})); }
          else if (gate.wall === 'west')  { gcx = rx;    gcy = ry+rh/2; layer.add(new Konva.Rect({x:gcx-3, y:gcy-gw/2, width:_KEXT_W+6, height:gw, fill:esFill, listening:false})); }
          else if (gate.wall === 'east')  { gcx = rx+rw; gcy = ry+rh/2; layer.add(new Konva.Rect({x:gcx-_KEXT_W-3, y:gcy-gw/2, width:_KEXT_W+6, height:gw, fill:esFill, listening:false})); }
        }
        var esLbl = es.type === 'garage' ? 'GARAGE / كراج' : (es.type === 'driver_room' ? 'DRIVER / سائق' : 'ANNEX / ملحق');
        layer.add(new Konva.Text({x:rx, y:ry-18, width:rw, text:esLbl, fontSize:8, fontStyle:'bold', fill:'#555', align:'center', listening:false}));
        if (es.type === 'garage') {
          _carSymbolNodes(rx+rw/2, ry+rh/2, rw*0.86, rh*0.86).forEach(function(node) {
            node.listening(false);
            layer.add(node);
          });
        }
      });
    }

    var _revealMark_shell = layer.children.length; // PROMPT O reveal bucket boundary
    /* ── 6. Room fills (interactive groups, drawn at absolute px) ────── */
    fd.rooms.forEach(function(rm) {
      if (_EXT_ROOMS[rm.type]) return;  // rendered as standalone structures in 5.5
      var rx=sbx+rm.x*PX, ry=sby+rm.y*PX, rw=rm.width*PX, rh=rm.height*PX;
      var isSel = (selR === rm.id);

      // Visual fill group (not draggable — interaction handled separately below).
      // Tagged with roomId so wall-drag can hide/show it wholesale (see §14.5).
      var fg = new Konva.Group({listening:false, roomId: rm.id});

      if (rm.type === 'staircase') {
        /* PROMPT UU (2026-09-04) — two-lane "up flight / down flight" stair
         * symbol, replacing PROMPT MM's dog-leg-with-mid-landing version at
         * the user's explicit request (reference: a standard architectural
         * stair block — two EQUAL-width lanes spanning the FULL room
         * length, split by one continuous center divider, uniform tread
         * grid crossing both lanes, a dot+arrow per lane pointing in
         * opposite directions). PROMPT MM's version split each lane into a
         * shorter half-flight around a small landing rectangle, which
         * looked like a single blurred run rather than two clearly
         * distinguishable lanes at real room sizes (as small as ~1.7m
         * wide) — this version has no landing box and no half-flight
         * split, matching the reference exactly. rm.stair is set by
         * furnisher.annotate_vertical_circulation(); purely a rendering
         * convention, no new backend data needed. */
        var st = rm.stair || {};
        var vertRun   = (st.run_axis || (rm.height >= rm.width ? 'vertical' : 'horizontal')) === 'vertical';
        var risers    = st.risers || Math.max(4, Math.round((vertRun ? rm.height : rm.width) / 0.28));
        var upDir     = st.up_dir || (vertRun ? 'n' : 'e');
        var role      = st.floor_role || 'ground';
        // sign: +1 means "up" runs toward increasing x/y, -1 toward
        // decreasing x/y. Only n/s matter for a vertical run and e/w for a
        // horizontal run — the arrow-direction letter already encodes this.
        var sign = vertRun ? (upDir === 's' ? 1 : -1) : (upDir === 'e' ? 1 : -1);

        // No stroke here — section 7 below draws this room's real walls,
        // and (PROMPT AA) skips whichever edge faces the open circulation
        // void so the stair is actually walkable-into. A second hard-
        // stroked box drawn here regardless used to seal the shaft on all
        // four sides no matter what the wall loop decided, which is what
        // made every staircase look sealed in with no way to reach it.
        fg.add(new Konva.Rect({x:rx, y:ry, width:rw, height:rh, fill:'#FAFAFA'}));

        var nTread = Math.max(4, risers - 1);
        var dotR = 2.5, arrGap = 6;

        if (vertRun) {
          var midX = rx + rw/2;
          fg.add(new Konva.Line({points:[midX, ry, midX, ry+rh], stroke:'#1a1a1a', strokeWidth:1}));
          for (var si=1; si<=nTread; si++) {
            var yA = ry + (si/(nTread+1)) * rh;
            fg.add(new Konva.Line({points:[rx+2, yA, rx+rw-2, yA], stroke:'#1a1a1a', strokeWidth:1}));
          }
          // "Up" lane starts at the entry edge, arrow points toward the
          // arrival edge; "down" lane is the mirror image.
          var entryY = sign < 0 ? ry + rh : ry, arriveY = sign < 0 ? ry : ry + rh;
          var upX = rx + rw*0.25, dnX = rx + rw*0.75;
          fg.add(new Konva.Circle({x:upX, y:entryY, radius:dotR, fill:'#333'}));
          fg.add(new Konva.Arrow({points:[upX, entryY, upX, arriveY - (sign<0?-1:1)*arrGap],
            stroke:'#333', strokeWidth:1.2, fill:'#333', pointerLength:7, pointerWidth:6}));
          fg.add(new Konva.Circle({x:dnX, y:arriveY, radius:dotR, fill:'#333'}));
          fg.add(new Konva.Arrow({points:[dnX, arriveY, dnX, entryY + (sign<0?-1:1)*arrGap],
            stroke:'#333', strokeWidth:1.2, fill:'#333', pointerLength:7, pointerWidth:6}));

          // Break line across the "up" lane (the flight that continues
          // past this floor) — only when there IS more stair above.
          if (role !== 'top') {
            var bmY = ry + rh*0.5;
            var zw = (rw/2) / 6, zp = [];
            for (var zi=0; zi<=6; zi++) {
              var t = zi*zw, off = (zi%2===0) ? 0 : (zi%4===1 ? -5 : 5);
              zp.push(rx+t, bmY+off);
            }
            fg.add(new Konva.Line({points:zp, stroke:'#1a1a1a', strokeWidth:1.5}));
          }

        } else {
          var midY = ry + rh/2;
          fg.add(new Konva.Line({points:[rx, midY, rx+rw, midY], stroke:'#1a1a1a', strokeWidth:1}));
          for (var ti=1; ti<=nTread; ti++) {
            var xA = rx + (ti/(nTread+1)) * rw;
            fg.add(new Konva.Line({points:[xA, ry+2, xA, ry+rh-2], stroke:'#1a1a1a', strokeWidth:1}));
          }
          var entryX = sign < 0 ? rx + rw : rx, arriveX = sign < 0 ? rx : rx + rw;
          var upY = ry + rh*0.25, dnY = ry + rh*0.75;
          fg.add(new Konva.Circle({x:entryX, y:upY, radius:dotR, fill:'#333'}));
          fg.add(new Konva.Arrow({points:[entryX, upY, arriveX - (sign<0?-1:1)*arrGap, upY],
            stroke:'#333', strokeWidth:1.2, fill:'#333', pointerLength:7, pointerWidth:6}));
          fg.add(new Konva.Circle({x:arriveX, y:dnY, radius:dotR, fill:'#333'}));
          fg.add(new Konva.Arrow({points:[arriveX, dnY, entryX + (sign<0?-1:1)*arrGap, dnY],
            stroke:'#333', strokeWidth:1.2, fill:'#333', pointerLength:7, pointerWidth:6}));

          if (role !== 'top') {
            var bmX = rx + rw*0.5;
            var zh = (rh/2) / 6, zp2 = [];
            for (var zj=0; zj<=6; zj++) {
              var t2 = zj*zh, off2 = (zj%2===0) ? 0 : (zj%4===1 ? -5 : 5);
              zp2.push(bmX+off2, ry+t2);
            }
            fg.add(new Konva.Line({points:zp2, stroke:'#1a1a1a', strokeWidth:1.5}));
          }
        }

      } else if (rm.type === 'bedroom' && rm.sub_rooms && rm.sub_rooms.length >= 2) {
        var sr0=rm.sub_rooms[0], sr1=rm.sub_rooms[1];
        var isHoriz = sr0.rel_y > 0.1;
        // PROMPT P item 2 — was RCOL['bedroom'] (a saturated colored fill);
        // room fills are white/2% warm gray only, no exceptions per type.
        fg.add(new Konva.Rect({x:rx, y:ry, width:rw, height:rh, fill:_KROOMFILL}));
        if (isHoriz) {
          var partY = ry + sr0.rel_y*PX;
          fg.add(new Konva.Line({points:[rx,partY,rx+rw,partY], stroke:_KWALL, strokeWidth:2.5, lineCap:'square'}));
          fg.add(new Konva.Rect({x:rx+sr0.rel_x*PX, y:partY, width:sr0.width*PX, height:sr0.height*PX, fill:'rgba(180,165,200,0.18)'}));
          fg.add(new Konva.Rect({x:rx+sr1.rel_x*PX, y:partY, width:sr1.width*PX, height:sr1.height*PX, fill:'rgba(100,160,200,0.18)'}));
          var vpX = rx + sr0.width*PX;
          fg.add(new Konva.Line({points:[vpX,partY,vpX,ry+rh], stroke:_KWALL, strokeWidth:2.5}));
          var dp=0.65*PX, doX=rx+sr1.rel_x*PX, doY=partY;
          fg.add(new Konva.Line({points:[doX,doY,doX,doY+dp], stroke:_KWALL, strokeWidth:1.2}));
          fg.add(new Konva.Path({data:'M'+doX+' '+(doY+dp)+' A'+dp+' '+dp+' 0 0 1 '+(doX+dp)+' '+doY, stroke:_KWALL, strokeWidth:0.9, dash:[3,2]}));
        } else {
          var partX = rx + sr0.rel_x*PX;
          fg.add(new Konva.Line({points:[partX,ry,partX,ry+rh], stroke:_KWALL, strokeWidth:2.5, lineCap:'square'}));
          fg.add(new Konva.Rect({x:partX, y:ry+sr0.rel_y*PX, width:sr0.width*PX, height:sr0.height*PX, fill:'rgba(180,165,200,0.18)'}));
          fg.add(new Konva.Rect({x:partX, y:ry+sr1.rel_y*PX, width:sr1.width*PX, height:sr1.height*PX, fill:'rgba(100,160,200,0.18)'}));
          var hpY = ry + sr0.height*PX;
          fg.add(new Konva.Line({points:[partX,hpY,rx+rw,hpY], stroke:_KWALL, strokeWidth:2.5}));
          var dp2=0.65*PX, doX2=partX, doY2=ry+sr1.rel_y*PX;
          fg.add(new Konva.Line({points:[doX2,doY2,doX2+dp2,doY2], stroke:_KWALL, strokeWidth:1.2}));
          fg.add(new Konva.Path({data:'M'+(doX2+dp2)+' '+doY2+' A'+dp2+' '+dp2+' 0 0 1 '+doX2+' '+(doY2+dp2), stroke:_KWALL, strokeWidth:0.9, dash:[3,2]}));
        }

      } else if (rm.type === 'balcony') {
        fg.add(new Konva.Rect({x:rx, y:ry, width:rw, height:rh, fill:_KROOMFILL}));
        // Railing: a double line + evenly-spaced baluster ticks along whichever
        // edge(s) sit on the building's exterior — the projecting/outward side.
        var railGap = 3, tickLen = 5, tickStep = 8;
        var edges = [
          {on: onExtN(ry), x1:rx, y1:ry+railGap, x2:rx+rw, y2:ry+railGap, horiz:true},
          {on: onExtS(ry+rh), x1:rx, y1:ry+rh-railGap, x2:rx+rw, y2:ry+rh-railGap, horiz:true},
          {on: onExtWa(rx), x1:rx+railGap, y1:ry, x2:rx+railGap, y2:ry+rh, horiz:false},
          {on: onExtE(rx+rw), x1:rx+rw-railGap, y1:ry, x2:rx+rw-railGap, y2:ry+rh, horiz:false},
        ];
        edges.forEach(function(e) {
          if (!e.on) return;
          fg.add(new Konva.Line({points:[e.x1, e.y1, e.x2, e.y2], stroke:'#2a2a2a', strokeWidth:1.2}));
          var len = e.horiz ? rw : rh;
          var n = Math.max(2, Math.floor(len / tickStep));
          for (var ti=0; ti<=n; ti++) {
            var t = ti / n;
            if (e.horiz) {
              var tx = e.x1 + t*rw;
              fg.add(new Konva.Line({points:[tx, e.y1-tickLen/2, tx, e.y1+tickLen/2], stroke:'#2a2a2a', strokeWidth:0.8}));
            } else {
              var ty = e.y1 + t*rh;
              fg.add(new Konva.Line({points:[e.x1-tickLen/2, ty, e.x1+tickLen/2, ty], stroke:'#2a2a2a', strokeWidth:0.8}));
            }
          }
        });

      } else if (rm.type === 'elevator') {
        // PROMPT MM — plain box + full corner-to-corner X, matching the
        // reference's standard elevator-shaft symbol (no inset "car" rect).
        fg.add(new Konva.Rect({x:rx, y:ry, width:rw, height:rh, fill:_KROOMFILL, stroke:'#1a1a1a', strokeWidth:1.5}));
        fg.add(new Konva.Line({points:[rx, ry, rx+rw, ry+rh], stroke:'#1a1a1a', strokeWidth:1}));
        fg.add(new Konva.Line({points:[rx+rw, ry, rx, ry+rh], stroke:'#1a1a1a', strokeWidth:1}));

      } else if (rm.type === 'courtyard') {
        // PROMPT O: designed court, not empty lawn — hardscape hatch (paver
        // joints), 2-3 trees, and a path strip toward the nearest entrance-
        // facing edge so it reads as a real outdoor room, not leftover space.
        // PROMPT P: monochrome inside the villa — hardscape hatch and path
        // are thin gray lines, not colored fills.
        fg.add(new Konva.Rect({x:rx, y:ry, width:rw, height:rh, fill:_KROOMFILL}));
        var hatchGap = 11;
        for (var hy2 = 0; hy2 < rw + rh; hy2 += hatchGap) {
          var hx0 = rx + Math.max(0, hy2 - rh), hy0 = ry + Math.min(hy2, rh);
          var hx1 = rx + Math.min(hy2, rw), hy1 = ry + Math.max(0, hy2 - rw);
          fg.add(new Konva.Line({points:[hx0, hy0, hx1, hy1], stroke:'#B8B4A8', strokeWidth:0.5, opacity:0.55, strokeScaleEnabled:false}));
        }
        // Entrance path: a paved strip from the north edge to the courtyard centre.
        var pathW = Math.min(rw * 0.22, 30);
        fg.add(new Konva.Rect({x:rx+rw/2-pathW/2, y:ry, width:pathW, height:rh*0.42, fill:_KROOMFILL, stroke:'#B8B4A8', strokeWidth:0.6, strokeScaleEnabled:false}));
        for (var pty = ry+6; pty < ry+rh*0.42-4; pty += 9) {
          fg.add(new Konva.Line({points:[rx+rw/2-pathW/2+2, pty, rx+rw/2+pathW/2-2, pty], stroke:'#B8B4A8', strokeWidth:0.5, strokeScaleEnabled:false}));
        }
        // Trees — 2-3 depending on courtyard size, placed off the path.
        var ctTrees = (rw*rh > 2600) ? 3 : 2;
        var treePositions = [[0.22,0.72],[0.78,0.72],[0.78,0.28]];
        for (var tri = 0; tri < ctTrees; tri++) {
          var tp = treePositions[tri];
          var ttx = rx + rw*tp[0], tty = ry + rh*tp[1];
          var tr = Math.min(rw, rh) * 0.09;
          fg.add(new Konva.Circle({x:ttx, y:tty, radius:tr, fill:'white', stroke:'#1a1a1a', strokeWidth:_SW_THIN, strokeScaleEnabled:false}));
          fg.add(new Konva.Circle({x:ttx, y:tty, radius:tr*0.3, fill:'none', stroke:'#1a1a1a', strokeWidth:_SW_THIN*0.8, strokeScaleEnabled:false}));
        }
      } else {
        // PROMPT P item 2 — was RCOL[rm.type]||_KROOMFILL: RCOL has an entry
        // for nearly every room type, so this generic fallback branch was
        // colored for almost all ordinary rooms. Monochrome, no exceptions.
        fg.add(new Konva.Rect({x:rx, y:ry, width:rw, height:rh, fill:_KROOMFILL}));
      }

      /* PROMPT X / LANDING C item 2 — a niche is a shallow storage pocket, not
       * a room. Drawn as a closet: light fill, thin outline and 45-degree
       * hatching, which is the standard convention and reads as "built-in
       * cupboard" instead of competing with real rooms for attention. Its
       * label is suppressed in section 13 and it is absent from the ROOMS
       * schedule (see index.html) — four "NICHE 1.0m²" entries stacked on the
       * plan edge is exactly the spam PROMPT X caught. */
      if (rm.type === 'niche') {
        fg.destroyChildren();
        fg.add(new Konva.Rect({x:rx, y:ry, width:rw, height:rh, fill:'#F2F0EA', stroke:'#6b6b6b', strokeWidth:1}));
        var hstep = 6;
        for (var hx = -rh; hx < rw; hx += hstep) {
          var x1 = rx + Math.max(0, hx), y1 = ry + Math.max(0, -hx);
          var x2 = rx + Math.min(rw, hx + rh), y2 = ry + Math.min(rh, rh - (hx + rh - rw));
          if (x2 > x1 && y2 > y1) fg.add(new Konva.Line({points:[x1, y1 + (y2-y1), x2, y1], stroke:'#9a9a9a', strokeWidth:0.6, listening:false}));
        }
      }

      if (isSel) {
        fg.add(new Konva.Rect({x:rx-2, y:ry-2, width:rw+4, height:rh+4, fill:'transparent', stroke:_KCYAN, strokeWidth:2.5, dash:[6,3]}));
      }

      layer.add(fg);

      // Transparent interactive hit area (separate so it goes on top of fill)
      // PROMPT WW — Draw Wall mode repurposes a drag-inside-a-room gesture
      // to draw a new dividing wall instead of moving the room (see the
      // stage-level 'mousedown.newwall' handler below), so ordinary
      // drag-to-move must be OFF while that mode is active — the two
      // can't coexist on the same gesture.
      var hitGrp = new Konva.Group({ draggable: !props.wallDrawMode });
      var origLayerX = 0, origLayerY = 0;

      var hitR = new Konva.Rect({x:rx, y:ry, width:rw, height:rh, fill:'transparent', stroke:'transparent', strokeWidth:0});
      hitGrp.add(hitR);
      layer.add(hitGrp);

      // Constrain drag to building area
      hitGrp.dragBoundFunc(function(pos) {
        var minX = sbx - rx;
        var maxX = sbx + bw*PX - rw - rx;
        var minY = sby - ry;
        var maxY = sby + bh*PX - rh - ry;
        return {
          x: Math.max(minX, Math.min(maxX, pos.x)),
          y: Math.max(minY, Math.min(maxY, pos.y))
        };
      });

      hitGrp.on('click tap', function(e) {
        // PROMPT WW follow-up #4 — this fired even when the SAME mousedown
        // just started a Draw Wall gesture: Konva synthesizes 'click' from
        // its own internal pointerdown/pointerup tracking, independent of
        // `evt.cancelBubble` set on the earlier 'mousedown' event (that
        // only stops bubbling for the mousedown itself, not later event
        // types on the same node). The resulting setSelR() re-render tore
        // down and rebuilt the whole canvas mid-gesture — silently wiping
        // out the just-started wall before the user's second click ever
        // landed, which is exactly what "I can't actually draw anything"
        // looked like. Room selection makes no sense while this tool owns
        // the click anyway, so it simply steps aside here.
        if (props.wallDrawMode) return;
        e.cancelBubble = true;
        if (props.setSelR) props.setSelR(rm.id);
        if (props.setSelF) props.setSelF(null);
        if (props.setSelD) props.setSelD(null);
      });

      hitGrp.on('dragstart', function() {
        origLayerX = hitGrp.x();
        origLayerY = hitGrp.y();
        stage.draggable(false);
        if (props.setSelR) props.setSelR(rm.id);
      });

      hitGrp.on('dragend', function() {
        stage.draggable(true);
        var dx = hitGrp.x() - origLayerX;
        var dy = hitGrp.y() - origLayerY;
        var newX = Math.max(0, Math.min(bw - rm.width, rm.x + dx/PX));
        var newY = Math.max(0, Math.min(bh - rm.height, rm.y + dy/PX));
        newX = Math.round(newX*4)/4; newY = Math.round(newY*4)/4;
        hitGrp.position({x:0, y:0});
        // PROMPT EE item 5 / BUG-017 — collision guard. Previously this
        // only clamped the drop point to stay inside the buildable area
        // and committed unconditionally — nothing checked whether the new
        // position overlapped another room, so a drag could silently
        // produce two rooms occupying the same floor space. Mirrors the
        // furniture-drag clearance check below (§12): compute overlap
        // against every other real room and snap back instead of
        // committing when the drop would collide.
        var newX2 = newX + rm.width, newY2 = newY + rm.height;
        var blockedBy = null;
        fd.rooms.forEach(function(other) {
          if (blockedBy || other.id === rm.id || _EXT_ROOMS[other.type]) return;
          var ox = Math.min(newX2, other.x+other.width) - Math.max(newX, other.x);
          var oy = Math.min(newY2, other.y+other.height) - Math.max(newY, other.y);
          if (ox > 0.05 && oy > 0.05) blockedBy = other;
        });
        if (blockedBy) {
          if (props.onFlash) props.onFlash('Would overlap ' + (blockedBy.label || blockedBy.type.replace(/_/g,' ')));
          return; // snap back — no setFd call, position already reset above
        }
        if (props.setFd) props.setFd(function(prev) {
          if (!prev) return prev;
          return Object.assign({}, prev, {rooms: prev.rooms.map(function(r) {
            return r.id === rm.id ? Object.assign({}, r, {x: newX, y: newY}) : r;
          })});
        });
      });
    });

    /* ── 6.5 "Fix this" working state (PROMPT O) — pulse the room(s) the
     * in-flight re-solve targets, so the user sees which part of the plan
     * is being worked on instead of just a generic busy overlay. ────────── */
    if (props.pulseTypes && props.pulseTypes.length) {
      fd.rooms.forEach(function(rm) {
        if (props.pulseTypes.indexOf(rm.type) === -1) return;
        var prx=sbx+rm.x*PX, pry=sby+rm.y*PX, prw=rm.width*PX, prh=rm.height*PX;
        var pulseRect = new Konva.Rect({x:prx-3, y:pry-3, width:prw+6, height:prh+6, fill:'transparent',
          stroke:_KCYAN, strokeWidth:2.5, cornerRadius:4, listening:false, opacity:0.9});
        layer.add(pulseRect);
        (function loop(){
          pulseRect.to({opacity:0.15, duration:0.55, onFinish:function(){
            if (!pulseRect.isDestroyed) pulseRect.to({opacity:0.9, duration:0.55, onFinish:loop});
          }});
        })();
      });
    }

    var _revealMark_rooms = layer.children.length; // PROMPT O reveal bucket boundary
    /* ── 6.7 PROMPT AA — shared open-edge test, used by BOTH the wall loop
     * (section 7) and the door loop (section 10) so the two can never
     * disagree. Previously each door was drawn unconditionally regardless
     * of whether the wall loop had decided that same edge was open (no
     * wall at all) — that produced doors floating with no wall around
     * them, "doors without walls". Also extends the PROMPT Y "open-plan"
     * treatment (living/dining/sitting hall) to the staircase: a stair
     * shaft with a hard wall on every side, even the side facing the open
     * circulation void, is not actually climbable from outside it. */
    var _CIRC_OPEN = Object.assign({staircase:1}, _OPEN_PLAN);
    // A single point 0.35m past the edge can land inside genuine open
    // circulation even when the room actually facing this edge is ANOTHER
    // REAL ROOM (e.g. a hall sits further beyond a staircase that is
    // directly against the dining room) — that false positive was opening
    // the wall between two enclosed rooms and made them read as one merged
    // space with no divider at all. So first check whether another real
    // room already occupies this edge; if it does, this is an ordinary
    // shared interior wall and circulation is irrelevant to the decision.
    function _edgeHasRealRoomNeighbor(rm, dir) {
      var horiz = (dir === 'n' || dir === 's');
      var a = horiz ? rm.x : rm.y, b = horiz ? rm.x + rm.width : rm.y + rm.height;
      var k = (dir === 'n') ? rm.y : (dir === 's') ? rm.y + rm.height
            : (dir === 'w') ? rm.x : rm.x + rm.width;
      var oppDir = {n:'s', s:'n', e:'w', w:'e'}[dir];
      return (fd.rooms||[]).some(function(r2) {
        if (r2.id === rm.id || _EXT_ROOMS[r2.type]) return false;
        var r2k = (oppDir === 'n') ? r2.y : (oppDir === 's') ? r2.y + r2.height
                : (oppDir === 'w') ? r2.x : r2.x + r2.width;
        if (Math.abs(r2k - k) > 0.05) return false;
        var r2a = horiz ? r2.x : r2.y, r2b = horiz ? r2.x + r2.width : r2.y + r2.height;
        var overlap = Math.min(b, r2b) - Math.max(a, r2a);
        return overlap >= 0.3;
      });
    }
    /* PROMPT OO follow-up (2026-09-01) — `_edgeHasRealRoomNeighbor` treats
     * ANY real room on the other side of an edge as an ordinary shared
     * interior wall, corridor included, since LANDING D/PROMPT OO made the
     * corridor a real solved room instead of stripped leftover space. That
     * makes a staircase whose CP-SAT-guaranteed neighbor is the corridor
     * render with a solid wall and no door on every side (staircase is
     * _DOOR_SKIP — it never gets a door of its own), i.e. sealed in, even
     * though the backend genuinely placed it touching the hallway. A
     * corridor is walkable space by definition, not an enclosed room the
     * open-plan/void check needs protecting against — so it's checked
     * directly, ahead of and independent of the fd.circulation void probe
     * below (which only knows about genuinely leftover, unbuilt space and
     * has no idea a corridor ROOM is sitting right there). */
    /* PROMPT OO follow-up #3 (2026-09-02) — walls are drawn ONCE PER ROOM,
     * each room stroking its own 4 edges independently; the two sides of a
     * shared boundary never coordinate, so BOTH rooms on a boundary have to
     * independently agree it's open or the side that doesn't agree wins by
     * default (a wall drawn is a wall drawn). `_CIRC_ANCHOR` — corridor
     * plus every `_CIRC_OPEN` type (staircase, and the open-plan public
     * rooms) — is the full set of room types meant to be walkable into
     * each other with no wall between them. `_edgeAdjoinsCircAnchor` is
     * symmetric: called from EITHER side of a boundary, for EITHER type,
     * it gives the same answer, so a shared edge between any two anchor
     * types opens correctly regardless of which side's iteration runs
     * first. Originally two separate one-directional checks (corridor-only
     * from the staircase's side, `_CIRC_OPEN`-only from the corridor's
     * side) — that pair still missed a real case: a staircase touching an
     * open-plan room DIRECTLY, no corridor involved at all (confirmed live
     * — a reproduction of a user-reported "wall in front of the stair"
     * case had the staircase touching majlis/kitchen/dining_room with zero
     * corridor on that floor; dining_room is `_CIRC_OPEN`-eligible but the
     * old corridor-only check never looked at it). Ordinary private rooms
     * (bedroom/bathroom, and majlis/kitchen — deliberately never made
     * `_CIRC_OPEN`) are unaffected either way and still get a real wall +
     * door on both fronts. */
    var _CIRC_ANCHOR = Object.assign({corridor:1}, _CIRC_OPEN);
    /* PROMPT WW follow-up #13 (2026-09-05) — a user reported drawing a wall
     * to split a living room into two, and getting two correctly-separate
     * ROOM objects with no visible wall between them at all. Root cause:
     * `_CIRC_ANCHOR` (corridor + staircase + every open-plan public type)
     * exists so an open-plan room's edge facing genuine circulation — the
     * corridor, or a staircase with no corridor between them — draws no
     * wall. But the check that decides "is this neighbor an anchor" never
     * excluded the case where the neighbor is the IDENTICAL open-plan type
     * as the room itself — living_room touching another living_room is
     * overwhelmingly a user-drawn split of what used to be one room (this
     * codebase only ever solves one instance of each open-plan type per
     * floor), not two independently-placed public rooms meant to flow into
     * each other. Corridor and staircase are deliberately NOT covered by
     * this exclusion — two corridor/staircase pieces meeting each other
     * legitimately should still read as one continuous open run. */
    function _isAnchorNeighbor(rm, r2) {
      if (!_CIRC_ANCHOR[r2.type]) return false;
      if (_OPEN_PLAN[r2.type] && r2.type === rm.type) return false;
      return true;
    }
    function _edgeAdjoinsCircAnchor(rm, dir) {
      var horiz = (dir === 'n' || dir === 's');
      var a = horiz ? rm.x : rm.y, b = horiz ? rm.x + rm.width : rm.y + rm.height;
      var k = (dir === 'n') ? rm.y : (dir === 's') ? rm.y + rm.height
            : (dir === 'w') ? rm.x : rm.x + rm.width;
      var oppDir = {n:'s', s:'n', e:'w', w:'e'}[dir];
      return (fd.rooms||[]).some(function(r2) {
        if (r2.id === rm.id || !_isAnchorNeighbor(rm, r2)) return false;
        var r2k = (oppDir === 'n') ? r2.y : (oppDir === 's') ? r2.y + r2.height
                : (oppDir === 'w') ? r2.x : r2.x + r2.width;
        if (Math.abs(r2k - k) > 0.05) return false;
        var r2a = horiz ? r2.x : r2.y, r2b = horiz ? r2.x + r2.width : r2.y + r2.height;
        var overlap = Math.min(b, r2b) - Math.max(a, r2a);
        return overlap >= 0.3;
      });
    }
    function _edgeFacesCirculation(rm, dir) {
      if (!_CIRC_ANCHOR[rm.type]) return false;
      if (_edgeAdjoinsCircAnchor(rm, dir)) return true;
      if (!fd.circulation || !fd.circulation.length) return false;
      if (_edgeHasRealRoomNeighbor(rm, dir)) return false;
      var horiz = (dir === 'n' || dir === 's');
      var a = horiz ? rm.x : rm.y;
      var b = horiz ? rm.x + rm.width : rm.y + rm.height;
      var k = (dir === 'n') ? rm.y : (dir === 's') ? rm.y + rm.height
            : (dir === 'w') ? rm.x : rm.x + rm.width;
      var probe = (dir === 'n') ? [ (a+b)/2, k - 0.35 ]
                : (dir === 's') ? [ (a+b)/2, k + 0.35 ]
                : (dir === 'w') ? [ k - 0.35, (a+b)/2 ]
                :                 [ k + 0.35, (a+b)/2 ];
      return fd.circulation.some(function(cp) {
        return probe[0] >= cp.x - 0.05 && probe[0] <= cp.x + cp.width + 0.05 &&
               probe[1] >= cp.y - 0.05 && probe[1] <= cp.y + cp.height + 0.05;
      });
    }
    /* PROMPT WW follow-up #9 (2026-09-05) — the whole-edge boolean above is
     * exactly right for a SHORT edge (a staircase's own width) but wrong for
     * a long one: a 14.5m corridor wall was tested with ONE probe point at
     * its own midpoint, so a door directly across from a genuine circulation
     * void 6m away from that midpoint still got a fully solid wall drawn
     * opposite it — reproduced live (a kitchen door opening into a real
     * `fd.circulation` "passage" pocket, sealed shut by the corridor's south
     * wall because the midpoint probe landed on ordinary solid space
     * elsewhere on that same long edge). `_edgeHasRealRoomNeighbor`'s
     * whole-edge short-circuit had the identical flaw: dining_room touches
     * only PART of the corridor's south edge, but its presence anywhere on
     * that edge suppressed the circulation-void check for the ENTIRE edge,
     * including the genuinely-void portion right by the kitchen door.
     * These three functions redo both checks per-SEGMENT instead of as one
     * pass/fail for the whole span, mirroring the exterior/interior interval
     * split (`_coverH`/`_coverV`/`_complementIv`) already used above for the
     * same "a single yes/no is wrong for a long edge" reason. */
    function _realRoomCoverIv(rm, dir, a, b) {
      var horiz = (dir === 'n' || dir === 's');
      var k = (dir === 'n') ? rm.y : (dir === 's') ? rm.y + rm.height
            : (dir === 'w') ? rm.x : rm.x + rm.width;
      var oppDir = {n:'s', s:'n', e:'w', w:'e'}[dir];
      var out = [];
      (fd.rooms||[]).forEach(function(r2) {
        if (r2.id === rm.id || _EXT_ROOMS[r2.type]) return;
        var r2k = (oppDir === 'n') ? r2.y : (oppDir === 's') ? r2.y + r2.height
                : (oppDir === 'w') ? r2.x : r2.x + r2.width;
        if (Math.abs(r2k - k) > 0.05) return;
        var r2a = horiz ? r2.x : r2.y, r2b = horiz ? r2.x + r2.width : r2.y + r2.height;
        var lo = Math.max(a, r2a), hi = Math.min(b, r2b);
        if (hi - lo > 1e-6) out.push([lo, hi]);
      });
      return _mergeIv(out);
    }
    function _anchorCoverIv(rm, dir, a, b) {
      var horiz = (dir === 'n' || dir === 's');
      var k = (dir === 'n') ? rm.y : (dir === 's') ? rm.y + rm.height
            : (dir === 'w') ? rm.x : rm.x + rm.width;
      var oppDir = {n:'s', s:'n', e:'w', w:'e'}[dir];
      var out = [];
      (fd.rooms||[]).forEach(function(r2) {
        if (r2.id === rm.id || !_isAnchorNeighbor(rm, r2)) return;
        var r2k = (oppDir === 'n') ? r2.y : (oppDir === 's') ? r2.y + r2.height
                : (oppDir === 'w') ? r2.x : r2.x + r2.width;
        if (Math.abs(r2k - k) > 0.05) return;
        var r2a = horiz ? r2.x : r2.y, r2b = horiz ? r2.x + r2.width : r2.y + r2.height;
        var lo = Math.max(a, r2a), hi = Math.min(b, r2b);
        if (hi - lo > 1e-6) out.push([lo, hi]);
      });
      return _mergeIv(out);
    }
    function _circVoidCoverIv(rm, dir, a, b, k, excludeIv) {
      if (!fd.circulation || !fd.circulation.length) return [];
      var free = _complementIv(a, b, excludeIv);
      var offset = (dir === 'n') ? -0.35 : (dir === 's') ? 0.35 : (dir === 'w') ? -0.35 : 0.35;
      var STEP = 0.1, out = [];
      free.forEach(function(seg) {
        for (var t = seg[0]; t < seg[1] - 1e-6; t += STEP) {
          var t2 = Math.min(t + STEP, seg[1]);
          var mid = (t + t2) / 2;
          var px = (dir === 'n' || dir === 's') ? mid : k + offset;
          var py = (dir === 'n' || dir === 's') ? k + offset : mid;
          var open = fd.circulation.some(function(cp) {
            return px >= cp.x - 0.05 && px <= cp.x + cp.width + 0.05 &&
                   py >= cp.y - 0.05 && py <= cp.y + cp.height + 0.05;
          });
          if (open) out.push([t, t2]);
        }
      });
      return _mergeIv(out);
    }
    function _subtractIv(ivList, subList) {
      if (!subList.length) return ivList;
      var out = [];
      ivList.forEach(function(iv) {
        var segs = [iv.slice()];
        subList.forEach(function(sub) {
          var next = [];
          segs.forEach(function(s) {
            if (sub[1] <= s[0] || sub[0] >= s[1]) { next.push(s); return; }
            if (sub[0] > s[0]) next.push([s[0], Math.min(sub[0], s[1])]);
            if (sub[1] < s[1]) next.push([Math.max(sub[1], s[0]), s[1]]);
          });
          segs = next.filter(function(s) { return s[1] - s[0] > 1e-6; });
        });
        segs.forEach(function(s) { out.push(s); });
      });
      return out;
    }
    function _circOpenCoverIv(rm, dir, a, b, k) {
      if (!_CIRC_ANCHOR[rm.type]) return [];
      var anchorIv = _anchorCoverIv(rm, dir, a, b);
      var realRoomIv = _realRoomCoverIv(rm, dir, a, b);
      var voidIv = _circVoidCoverIv(rm, dir, a, b, k, realRoomIv);
      return _mergeIv(anchorIv.concat(voidIv));
    }

    /* ── 7. Wall lines — exterior heavy single, interior double-line poché ── */
    var _INT_T  = Math.round(0.15 * PX);   // interior partition thickness px (~5)
    var _INT_T2 = _INT_T / 2;
    fd.rooms.forEach(function(rm) {
      if (_EXT_ROOMS[rm.type]) return;
      // All of this room's wall-face nodes live in one group, tagged with
      // roomId, so wall-drag can hide/show them as a unit (see §14.5).
      var wallGrp = new Konva.Group({roomId: rm.id});
      layer.add(wallGrp);
      [
        {dir:'n', xa:rm.x,           ya:rm.y,            xb:rm.x+rm.width, yb:rm.y},
        {dir:'s', xa:rm.x,           ya:rm.y+rm.height,  xb:rm.x+rm.width, yb:rm.y+rm.height},
        {dir:'w', xa:rm.x,           ya:rm.y,            xb:rm.x,          yb:rm.y+rm.height},
        {dir:'e', xa:rm.x+rm.width,  ya:rm.y,            xb:rm.x+rm.width, yb:rm.y+rm.height}
      ].forEach(function(e) {
        var horiz = (e.dir === 'n' || e.dir === 's');
        var a = horiz ? Math.min(e.xa, e.xb) : Math.min(e.ya, e.yb);
        var b = horiz ? Math.max(e.xa, e.xb) : Math.max(e.ya, e.yb);
        var k = horiz ? e.ya : e.xa;

        // Portions of this edge that lie on the villa outline.
        var extIv;
        if (_outlineSegs) {
          extIv = horiz ? _coverH(k, a, b) : _coverV(k, a, b);
        } else {
          var legacyExt = (e.dir==='n'&&onExtN(e.ya)) || (e.dir==='s'&&onExtS(e.ya)) ||
                          (e.dir==='w'&&onExtWa(e.xa)) || (e.dir==='e'&&onExtE(e.xa));
          extIv = legacyExt ? [[a, b]] : [];
        }
        var intIvFull = _complementIv(a, b, extIv);

        /* PROMPT Y item 3 / PROMPT AA — OPEN-PLAN ROOMS (+ staircase) HAVE NO
         * WALL AGAINST THE HALL. living / dining / sitting hall are real rooms
         * with names, areas and furniture, but the side facing the open
         * circulation is not walled — that is what makes the plan read as
         * flowing open space instead of a grid of boxes around a void.
         * Majlis is excluded on purpose: it stays fully enclosed for privacy.
         * Staircases need this even more: a shaft walled on every side has no
         * way to actually reach it. See _edgeFacesCirculation above.
         * PROMPT WW follow-up #9 — this used to be one whole-edge boolean
         * (`_edgeFacesCirculation`) gating an all-or-nothing early return;
         * now computed per-segment via `_circOpenCoverIv` so a long edge that
         * is only PARTLY open (e.g. a corridor touching one real room along
         * part of its length and genuine circulation void along the rest)
         * draws solid only where it should, instead of the whole edge
         * inheriting whichever verdict one single probe point happened to
         * land on. */
        var circOpenIv = _circOpenCoverIv(rm, e.dir, a, b, k);
        var intIv = circOpenIv.length ? _subtractIv(intIvFull, circOpenIv) : intIvFull;
        if (intIv.length === 0 && extIv.length === 0) return;   // fully open edge: draw nothing

        // EXTERIOR stretches: single heavy line, offset INWARD by half its
        // width. PROMPT X / LANDING B item 2 — Konva centers a stroke on its
        // path, so drawing on the outline put ~0.15m of wall OUTSIDE the
        // building while the shell poche was inset inward. Offsetting means
        // the band lies wholly inside and can never cross the outline.
        var half = _KEXT_W / 2;
        var offX = (e.dir === 'w') ? half : (e.dir === 'e') ? -half : 0;
        var offY = (e.dir === 'n') ? half : (e.dir === 's') ? -half : 0;
        extIv.forEach(function(iv) {
          var p1x, p1y, p2x, p2y;
          if (horiz) { p1x = sbx+iv[0]*PX; p2x = sbx+iv[1]*PX; p1y = p2y = sby+k*PX; }
          else       { p1y = sby+iv[0]*PX; p2y = sby+iv[1]*PX; p1x = p2x = sbx+k*PX; }
          wallGrp.add(new Konva.Line({points:[p1x+offX, p1y+offY, p2x+offX, p2y+offY],
            stroke:_KWALL, strokeWidth:_KEXT_W, lineCap:'butt', listening:false}));
        });

        // INTERIOR stretches: filled poche + two thin face lines, centered on
        // the shared wall (correct here — there is a room on the other side).
        intIv.forEach(function(iv) {
          if (horiz) {
            var wx = sbx+iv[0]*PX, wy = sby+k*PX - _INT_T2, wln = (iv[1]-iv[0])*PX;
            wallGrp.add(new Konva.Rect({x:wx, y:wy, width:wln, height:_INT_T,
              fill:_KPOCHE, listening:false}));
            wallGrp.add(new Konva.Line({points:[wx,wy,        wx+wln,wy],        stroke:_KWALL, strokeWidth:0.8, listening:false}));
            wallGrp.add(new Konva.Line({points:[wx,wy+_INT_T, wx+wln,wy+_INT_T], stroke:_KWALL, strokeWidth:0.8, listening:false}));
          } else {
            var wxx = sbx+k*PX - _INT_T2, wyy = sby+iv[0]*PX, wln2 = (iv[1]-iv[0])*PX;
            wallGrp.add(new Konva.Rect({x:wxx, y:wyy, width:_INT_T, height:wln2,
              fill:_KPOCHE, listening:false}));
            wallGrp.add(new Konva.Line({points:[wxx,        wyy,wxx,        wyy+wln2], stroke:_KWALL, strokeWidth:0.8, listening:false}));
            wallGrp.add(new Konva.Line({points:[wxx+_INT_T, wyy,wxx+_INT_T, wyy+wln2], stroke:_KWALL, strokeWidth:0.8, listening:false}));
          }
          // PROMPT WW — double-click-to-delete: a wider transparent hit
          // strip over each interior wall stretch (the poché itself is
          // only ~5px, too thin to reliably double-click) that highlights
          // on hover and, on dblclick, deletes the wall + merges the two
          // rooms it separates — the exact same server-guarded
          // /api/delete_wall path the "Merge with X" panel button already
          // uses, just triggered by clicking the wall itself instead of
          // hunting for it in a list.
          if (props.onDeleteWall) {
            var HIT_PAD = 5;
            var hx2, hy2, hw2, hh2;
            if (horiz) { hx2 = wx; hy2 = wy - HIT_PAD; hw2 = wln; hh2 = _INT_T + HIT_PAD*2; }
            else       { hx2 = wxx - HIT_PAD; hy2 = wyy; hw2 = _INT_T + HIT_PAD*2; hh2 = wln2; }
            var wallHit = new Konva.Rect({x:hx2, y:hy2, width:hw2, height:hh2, fill:'rgba(0,0,0,0.001)'});
            var wallHighlight = horiz
              ? new Konva.Rect({x:wx, y:wy, width:wln, height:_INT_T, fill:_KCYAN, opacity:0, listening:false})
              : new Konva.Rect({x:wxx, y:wyy, width:_INT_T, height:wln2, fill:_KCYAN, opacity:0, listening:false});
            wallGrp.add(wallHighlight);
            wallGrp.add(wallHit);
            // PROMPT WW follow-up — a plain "This wall can't be deleted" flash
            // on every double-click of a non-mergeable wall (facing the
            // corridor, an exterior wall, or a structural room type) read as
            // broken/random ("any wall I click says it can't be deleted")
            // because nothing distinguished those walls from real candidates
            // BEFORE the user tried. _findWallNeighbor is cheap and the
            // geometry is static per redraw, so it's computed once per wall
            // here and used to give hover its own honest affordance (cyan +
            // pointer cursor only for a wall that actually has a mergeable
            // neighbor; a dim gray + not-allowed cursor otherwise) instead of
            // promising "clickable" on every wall alike.
            (function(rmRef, dirRef, ivRef) {
              var nb = _findWallNeighbor(fd, rmRef, dirRef, ivRef);
              wallHit.on('mouseenter', function() {
                if (props.wallBusy) return;
                this.getStage().container().style.cursor = nb ? 'pointer' : 'not-allowed';
                wallHighlight.fill(nb ? _KCYAN : '#9a9a9a');
                wallHighlight.opacity(nb ? 0.5 : 0.28);
                layer.batchDraw();
              });
              wallHit.on('mouseleave', function() {
                this.getStage().container().style.cursor = '';
                wallHighlight.opacity(0);
                layer.batchDraw();
              });
              wallHit.on('dblclick dbltap', function(evt) {
                evt.cancelBubble = true;
                if (props.wallBusy) return;
                if (nb) props.onDeleteWall(rmRef.id, nb.id);
                else if (props.onFlash) props.onFlash(isAr
                  ? 'لا يوجد غرفة على الجانب الآخر من هذا الجدار لدمجها (على الأرجح جدار خارجي)'
                  : "There's no room on the other side of this wall to merge with (likely an exterior wall)");
              });
            })(rm, e.dir, iv);
          }
        });
      });
    });

    /* ── 8. Entrance doors (ground floor only) ───────────────────────── */
    // The visual "ENTRANCE" gap+swing-arc used to be drawn purely from the
    // frontage direction and the plot's bounding-box CENTER — it never read
    // fd.doors_windows at all. The real audited entrance door
    // (`main_entrance_door`, placed by the backend's BFS-based
    // door-by-connectivity logic, LANDING D) is very often NOT at the bbox
    // center — connectivity picks whichever spot on the frontage wall
    // actually reaches circulation, not the geometric middle. Section 10's
    // generic door loop skips every exterior door outright (`if (onExt)
    // return`), so nothing else ever drew the real one either. Net result:
    // a decorative opening at a made-up position, and a REAL, solid,
    // never-opened wall segment at the actual entrance door's true
    // position — reported live as "a wall in the middle of the entrance."
    // Fixed by using the real door's own x/y/width when one exists for
    // this direction, falling back to the bbox-center guess only for
    // older cached data that predates doors_windows carrying it.
    if (isFinite(bbX0) && fd.floor === 0) {
      var _entDoorByWall = {};
      (fd.doors_windows||[]).forEach(function(dw) {
        if (dw.id === 'main_entrance_door') _entDoorByWall[dw.wall] = dw;
      });
      frontageList.forEach(function(fDir) {
        var _ed = _entDoorByWall[fDir];
        if (fDir === 'north') {
          var edw=_ed?_ed.width*PX:1.5*PX;
          var edx=_ed?sbx+_ed.x*PX:sbx+((bbX0+bbX1)/2)*PX-edw/2, edy=sby+bbY0*PX;
          layer.add(new Konva.Rect({x:edx-1,y:edy-_KEXT_W,width:edw+2,height:_KEXT_W*2,fill:_KFLOOR,listening:false}));
          layer.add(new Konva.Line({points:[edx,edy,edx,edy+edw],stroke:_KWALL,strokeWidth:1.5,listening:false}));
          layer.add(new Konva.Path({data:'M'+edx+' '+(edy+edw)+' A'+edw+' '+edw+' 0 0 1 '+(edx+edw)+' '+edy,stroke:_KWALL,strokeWidth:1.2,dash:[4,2],listening:false}));
          layer.add(new Konva.Text({x:edx-10,y:edy-_KEXT_W-13,width:edw+20,text:isAr?'مدخل':'ENTRANCE',fontSize:8,fontStyle:'bold',fill:'#555',align:'center',listening:false}));
        } else if (fDir === 'south') {
          var edwS=_ed?_ed.width*PX:1.5*PX;
          var edxS=_ed?sbx+_ed.x*PX:sbx+((bbX0+bbX1)/2)*PX-edwS/2, edyS=sby+bbY1*PX;
          layer.add(new Konva.Rect({x:edxS-1,y:edyS-_KEXT_W,width:edwS+2,height:_KEXT_W*2,fill:_KFLOOR,listening:false}));
          layer.add(new Konva.Line({points:[edxS,edyS,edxS,edyS-edwS],stroke:_KWALL,strokeWidth:1.5,listening:false}));
          layer.add(new Konva.Path({data:'M'+edxS+' '+(edyS-edwS)+' A'+edwS+' '+edwS+' 0 0 0 '+(edxS+edwS)+' '+edyS,stroke:_KWALL,strokeWidth:1.2,dash:[4,2],listening:false}));
          layer.add(new Konva.Text({x:edxS-10,y:edyS+_KEXT_W+2,width:edwS+20,text:isAr?'مدخل':'ENTRANCE',fontSize:8,fontStyle:'bold',fill:'#555',align:'center',listening:false}));
        } else if (fDir === 'east') {
          var edwE=_ed?_ed.height*PX:1.5*PX;
          var edyE=_ed?sby+_ed.y*PX:sby+((bbY0+bbY1)/2)*PX-edwE/2, edxE=sbx+bbX1*PX;
          layer.add(new Konva.Rect({x:edxE-_KEXT_W,y:edyE-1,width:_KEXT_W*2,height:edwE+2,fill:_KFLOOR,listening:false}));
          layer.add(new Konva.Line({points:[edxE,edyE,edxE-edwE,edyE],stroke:_KWALL,strokeWidth:1.5,listening:false}));
          layer.add(new Konva.Path({data:'M'+(edxE-edwE)+' '+edyE+' A'+edwE+' '+edwE+' 0 0 1 '+edxE+' '+(edyE+edwE),stroke:_KWALL,strokeWidth:1.2,dash:[4,2],listening:false}));
          layer.add(new Konva.Text({x:edxE+_KEXT_W+2,y:edyE+edwE/2-4,text:isAr?'مدخل':'ENTRANCE',fontSize:8,fontStyle:'bold',fill:'#555',listening:false}));
        } else if (fDir === 'west') {
          var edwW=_ed?_ed.height*PX:1.5*PX;
          var edyW=_ed?sby+_ed.y*PX:sby+((bbY0+bbY1)/2)*PX-edwW/2, edxW=sbx+bbX0*PX;
          layer.add(new Konva.Rect({x:edxW-_KEXT_W,y:edyW-1,width:_KEXT_W*2,height:edwW+2,fill:_KFLOOR,listening:false}));
          layer.add(new Konva.Line({points:[edxW,edyW,edxW+edwW,edyW],stroke:_KWALL,strokeWidth:1.5,listening:false}));
          layer.add(new Konva.Path({data:'M'+(edxW+edwW)+' '+edyW+' A'+edwW+' '+edwW+' 0 0 0 '+edxW+' '+(edyW+edwW),stroke:_KWALL,strokeWidth:1.2,dash:[4,2],listening:false}));
          layer.add(new Konva.Text({x:edxW-_KEXT_W-70,y:edyW+edwW/2-4,width:66,text:isAr?'مدخل':'ENTRANCE',fontSize:8,fontStyle:'bold',fill:'#555',align:'right',listening:false}));
        }
      });
    }

    /* ── 9. Service entrance for driver_room ─────────────────────────── */
    if (isFinite(bbX0) && fd.floor === 0) {
      (fd.rooms||[]).forEach(function(rm) {
        if (rm.type !== 'driver_room') return;
        var rx=sbx+rm.x*PX, ry=sby+rm.y*PX, rw2=rm.width*PX, rh2=rm.height*PX;
        var doorW = 0.9*PX;
        var onSouth = Math.abs((rm.y+rm.height)-bbY1) < _KEPS;
        var onWest2  = Math.abs(rm.x-bbX0) < _KEPS;
        if (onSouth) {
          var sdx=rx+rw2/2-doorW/2, sdy=ry+rh2;
          layer.add(new Konva.Rect({x:sdx-1,y:sdy-_KEXT_W,width:doorW+2,height:_KEXT_W*2,fill:_KFLOOR,listening:false}));
          layer.add(new Konva.Line({points:[sdx,sdy,sdx,sdy-doorW],stroke:_KWALL,strokeWidth:1.5,listening:false}));
          layer.add(new Konva.Path({data:'M'+sdx+' '+(sdy-doorW)+' A'+doorW+' '+doorW+' 0 0 1 '+(sdx+doorW)+' '+sdy,stroke:_KWALL,strokeWidth:1,dash:[3,2],listening:false}));
          layer.add(new Konva.Text({x:sdx-5,y:sdy+_KEXT_W+2,width:doorW+10,text:'SERVICE',fontSize:7,fontStyle:'italic',fill:'#888',align:'center',listening:false}));
        } else if (onWest2) {
          var sdx2=rx, sdy2=ry+rh2/2-doorW/2;
          layer.add(new Konva.Rect({x:sdx2-_KEXT_W,y:sdy2-1,width:_KEXT_W*2,height:doorW+2,fill:_KFLOOR,listening:false}));
          layer.add(new Konva.Line({points:[sdx2,sdy2,sdx2+doorW,sdy2],stroke:_KWALL,strokeWidth:1.5,listening:false}));
          layer.add(new Konva.Path({data:'M'+(sdx2+doorW)+' '+sdy2+' A'+doorW+' '+doorW+' 0 0 1 '+sdx2+' '+(sdy2+doorW),stroke:_KWALL,strokeWidth:1,dash:[3,2],listening:false}));
          layer.add(new Konva.Text({x:sdx2-_KEXT_W-42,y:sdy2+doorW/2-4,width:38,text:'SERVICE',fontSize:7,fontStyle:'italic',fill:'#888',align:'right',listening:false}));
        }
      });
    }

    /* ── 7.5. (removed — was a duplicate procedural door arc drawn for
     * every room regardless of fd.doors_windows; the backend already places
     * exactly one real DoorWindow per room via _place_all_doors, so this
     * doubled every interior door and, worse, would have redrawn an arc
     * over a door the user just deleted, defeating PROMPT Q's "wall heals
     * solid" behaviour. Section 10 below is the single source of truth. ── */

    /* ── 10. Doors + windows from backend data (PROMPT Q: selectable + deletable) ── */
    var selD = props.selD;
    // PROMPT Z item 1 — door-reposition-along-wall constants. Mirrors
    // backend/geometry_audit.py's _MAIN_ENTRANCE_ID exactly (the string
    // both sides key the same door id on).
    var _MAIN_ENTRANCE_ID = 'main_entrance_door';
    var _DOOR_SNAP = 0.1, _DOOR_GAP_MIN = 0.1;
    (fd.doors_windows||[]).forEach(function(dw) {
      var ddx=sbx+dw.x*PX, ddy=sby+dw.y*PX, ddw=dw.width*PX, ddh=dw.height*PX;
      var dwGrp = new Konva.Group({dwId: dw.id});
      var isSelD = selD === dw.id;
      if (dw.type === 'door') {
        var isHoriz = ddw >= ddh;
        var gap = isHoriz ? ddw : ddh;
        var onExt = isHoriz ? onExtSpanH(dw.y, dw.x, dw.x+dw.width) : onExtSpanV(dw.x, dw.y, dw.y+dw.height);
        if (onExt) return;
        // PROMPT AA — a door on the same wall the loop above just decided is
        // OPEN (no wall drawn — an open-plan room or staircase facing the
        // circulation void) has nothing to be a door IN. Previously this
        // wasn't checked at all, so the backend's real interior door for
        // that room still drew its swing arc floating in open space with no
        // wall behind it — "doors without walls".
        var _dwWallDir = {north:'n', south:'s', east:'e', west:'w'}[dw.wall];
        var _dwRoomForEdge = dw.room_id ? findRoomById(dw.room_id) : null;
        if (_dwRoomForEdge && _dwWallDir && _edgeFacesCirculation(_dwRoomForEdge, _dwWallDir)) return;
        // Interior door — gap rect uses interior wall thickness
        if (isHoriz) {
          var openUp = (dw.wall === 'south');
          dwGrp.add(new Konva.Rect({x:ddx-1,y:ddy-_INT_T2,width:ddw+2,height:_INT_T,fill:_KFLOOR,listening:false}));
          var leafY = openUp ? ddy-gap : ddy+gap;
          dwGrp.add(new Konva.Line({points:[ddx,ddy,ddx,leafY],stroke:_KWALL,strokeWidth:1.5,listening:false}));
          dwGrp.add(new Konva.Path({data:'M'+ddx+' '+leafY+' A'+gap+' '+gap+' 0 0 '+(openUp?0:1)+' '+(ddx+ddw)+' '+ddy,stroke:_KWALL,strokeWidth:1,dash:[3,2],listening:false}));
        } else {
          var openRight = (dw.wall === 'west');
          dwGrp.add(new Konva.Rect({x:ddx-_INT_T2,y:ddy-1,width:_INT_T,height:ddh+2,fill:_KFLOOR,listening:false}));
          var leafX = openRight ? ddx+gap : ddx-gap;
          dwGrp.add(new Konva.Line({points:[ddx,ddy,leafX,ddy],stroke:_KWALL,strokeWidth:1.5,listening:false}));
          dwGrp.add(new Konva.Path({data:'M'+leafX+' '+ddy+' A'+gap+' '+gap+' 0 0 '+(openRight?0:1)+' '+ddx+' '+(ddy+ddh),stroke:_KWALL,strokeWidth:1,dash:[3,2],listening:false}));
        }
      } else {
        var isHorizW = ddw > ddh;
        var onExtWW = isHorizW ? onExtSpanH(dw.y, dw.x, dw.x+dw.width) : onExtSpanV(dw.x, dw.y, dw.y+dw.height);
        if (!onExtWW) return;
        if (isHorizW) {
          var midY=ddy+ddh/2;
          dwGrp.add(new Konva.Rect({x:ddx,y:ddy-3,width:ddw,height:ddh+6,fill:_KFLOOR,listening:false}));
          dwGrp.add(new Konva.Line({points:[ddx,ddy,ddx+ddw,ddy],stroke:_KCYAN,strokeWidth:1.5,listening:false}));
          dwGrp.add(new Konva.Line({points:[ddx,midY,ddx+ddw,midY],stroke:_KCYAN,strokeWidth:1,listening:false}));
          dwGrp.add(new Konva.Line({points:[ddx,ddy+ddh,ddx+ddw,ddy+ddh],stroke:_KCYAN,strokeWidth:1.5,listening:false}));
        } else {
          var midX=ddx+ddw/2;
          dwGrp.add(new Konva.Rect({x:ddx-3,y:ddy,width:ddw+6,height:ddh,fill:_KFLOOR,listening:false}));
          dwGrp.add(new Konva.Line({points:[ddx,ddy,ddx,ddy+ddh],stroke:_KCYAN,strokeWidth:1.5,listening:false}));
          dwGrp.add(new Konva.Line({points:[midX,ddy,midX,ddy+ddh],stroke:_KCYAN,strokeWidth:1,listening:false}));
          dwGrp.add(new Konva.Line({points:[ddx+ddw,ddy,ddx+ddw,ddy+ddh],stroke:_KCYAN,strokeWidth:1.5,listening:false}));
        }
      }
      // Selection hit area + hover pre-highlight + selected highlight — sized
      // a bit larger than the door/window gap so it's easy to grab.
      var padH = 6;
      var hlX = Math.min(ddx, ddx+ddw) - padH, hlY = Math.min(ddy, ddy+ddh) - padH;
      var hlW = Math.abs(ddw) + padH*2, hlH = Math.abs(ddh) + padH*2;
      var dwHover = new Konva.Rect({x:hlX,y:hlY,width:hlW,height:hlH,fill:'transparent',stroke:'transparent',strokeWidth:1.5,cornerRadius:3,name:'dwHover'});
      dwGrp.add(dwHover);
      if (isSelD) {
        dwGrp.add(new Konva.Rect({x:hlX,y:hlY,width:hlW,height:hlH,fill:'transparent',stroke:_KCYAN,strokeWidth:2,cornerRadius:3,listening:false,name:'dwSelOutline'}));
      }
      dwGrp.on('mouseenter', function() {
        if (!isSelD) dwHover.stroke('rgba(43,187,214,0.55)');
        var c = stage.container(); if (c) c.style.cursor = 'pointer';
        layer.batchDraw();
      });
      dwGrp.on('mouseleave', function() {
        dwHover.stroke('transparent');
        var c = stage.container(); if (c) c.style.cursor = '';
        layer.batchDraw();
      });
      dwGrp.on('click tap', function(e) {
        // PROMPT WW follow-up #4 — same fix as hitGrp/fGrp above.
        if (props.wallDrawMode) return;
        e.cancelBubble = true;
        if (props.setSelD) props.setSelD(dw.id);
        if (props.setSelR) props.setSelR(null);
        if (props.setSelF) props.setSelF(null);
      });

      // PROMPT Z item 1 — door reposition along its own wall. Only
      // interior doors (a real owning room, not the street-facing main
      // entrance or a door that opens straight onto the derived
      // circulation with no room of its own) are draggable; exterior/
      // entrance reposition is a separate, not-yet-built scope.
      var dwIsMainEntrance = (dw.id||'').indexOf(_MAIN_ENTRANCE_ID) === 0;
      var dwRoom = (dw.type === 'door' && dw.room_id && dw.room_id !== '__circulation__' && !dwIsMainEntrance)
                   ? findRoomById(dw.room_id) : null;
      if (dwRoom) {
        var axis = (dw.wall === 'north' || dw.wall === 'south') ? 'x' : 'y';
        var lenM = axis === 'x' ? dw.width : dw.height;
        var minM = (axis === 'x' ? dwRoom.x : dwRoom.y);
        var maxM = (axis === 'x' ? (dwRoom.x + dwRoom.width) : (dwRoom.y + dwRoom.height)) - lenM;
        // Other openings on the SAME wall of the SAME room — a cheap,
        // purely local per-frame clamp so this door can never be dragged
        // into another one. The authoritative reachability/door-graph
        // check happens once, server-side, on release (see dragend).
        var dwNeighbors = (fd.doors_windows||[]).filter(function(o) {
          return o.id !== dw.id && o.room_id === dw.room_id && o.wall === dw.wall;
        }).map(function(o) { return axis === 'x' ? [o.x, o.x+o.width] : [o.y, o.y+o.height]; });
        // PROMPT WW follow-up #4 — was unconditionally draggable, which
        // meant a click starting on a door handle while Draw Wall mode was
        // active got swallowed as "the user grabbed a draggable thing"
        // (see nwIsOnDraggable) instead of starting a wall. Draw Wall mode
        // now owns every click inside a room, same as it already did for
        // room-drag-to-move via hitGrp above.
        dwGrp.draggable(!props.wallDrawMode);
        var dwBusy = false;
        dwGrp.dragBoundFunc(function(pos) {
          var raw = axis === 'x' ? (pos.x - sbx) / PX : (pos.y - sby) / PX;
          var snapped = Math.round(raw / _DOOR_SNAP) * _DOOR_SNAP;
          dwNeighbors.forEach(function(n) {
            if (snapped + lenM > n[0] - _DOOR_GAP_MIN && snapped < n[1] + _DOOR_GAP_MIN) {
              // Push to whichever side of the neighbor the raw position is closer to.
              snapped = (raw < (n[0]+n[1])/2)
                ? Math.min(snapped, n[0] - _DOOR_GAP_MIN - lenM)
                : Math.max(snapped, n[1] + _DOOR_GAP_MIN);
            }
          });
          snapped = Math.max(minM, Math.min(snapped, maxM));
          return axis === 'x'
            ? {x: sbx + snapped*PX, y: 0}
            : {x: 0, y: sby + snapped*PX};
        });
        dwGrp.on('dragstart', function() {
          if (dwBusy) return;
          stage.draggable(false);
          // No React state changes here — PROMPT Z item 1 debugging found
          // that calling a setSel* here (as furniture/wall-drag's own
          // dragstart handlers might look tempting to copy) triggers a
          // re-render that rebuilds the whole Konva scene from scratch
          // mid-drag, tearing down this very node and killing the drag
          // session outright (observed as an immediate isDragging=false
          // plus a flood of "Tween ... not in a layer" Konva errors).
          // Selection is set on dragend instead, once the drag is
          // mechanically finished and a rebuild can't interrupt it.
        });
        dwGrp.on('dragend', function() {
          stage.draggable(true);
          // Same reasoning as dragstart — a setSel* call rebuilds the whole
          // scene (fd/selD are both in the redraw effect's dependency
          // list), so it must never fire while this handler still needs to
          // touch the CURRENT dwGrp/layer directly (the reset-position
          // rollback, the pending-request guard). It's deferred to the very
          // end of each branch below, once nothing here needs those
          // references again — including across the async /api/audit_edit
          // round-trip, since a rebuild mid-request would orphan dwGrp
          // just as surely as one mid-drag would.
          var selectThisDoor = function() {
            if (props.setSelD) props.setSelD(dw.id);
            if (props.setSelR) props.setSelR(null);
            if (props.setSelF) props.setSelF(null);
          };
          if (dwBusy) { dwGrp.position({x:0,y:0}); layer.batchDraw(); return; }
          var newX = axis === 'x' ? Math.round(((dwGrp.x() - sbx) / PX) * 1000) / 1000 : dw.x;
          var newY = axis === 'y' ? Math.round(((dwGrp.y() - sby) / PX) * 1000) / 1000 : dw.y;
          if (Math.abs(newX - dw.x) < 0.001 && Math.abs(newY - dw.y) < 0.001) {
            dwGrp.position({x:0,y:0}); layer.batchDraw(); selectThisDoor(); return;
          }
          var reject = function(reason) {
            dwGrp.position({x:0,y:0});
            layer.batchDraw();
            selectThisDoor();
            if (props.onFlash) props.onFlash(reason);
          };
          if (!props.apiFetch) { reject('Cannot validate this move right now'); return; }
          var proposedFloor = Object.assign({}, fd, {
            doors_windows: (fd.doors_windows||[]).map(function(d) {
              return d.id === dw.id ? Object.assign({}, d, {x:newX, y:newY}) : d;
            })
          });
          dwBusy = true;
          stage.listening(false);
          props.apiFetch(API_BASE+'/api/audit_edit', {
            method:'POST', headers:{'Content-Type':'application/json'},
            body: JSON.stringify({floor_index: props.floorIndex, floor: proposedFloor, variant_id: props.variantId}),
          }).then(function(r) { return r.json(); }).then(function(res) {
            dwBusy = false;
            stage.listening(true);
            if (res.ok) {
              if (props.pushUndo) props.pushUndo(fd);
              selectThisDoor();
              if (props.setFd) props.setFd(proposedFloor);
            } else {
              var reason = (res.violations && res.violations[0] && res.violations[0].detail)
                || res.error || 'This move would break the layout — reverted';
              reject(reason);
            }
          }).catch(function() {
            dwBusy = false;
            stage.listening(true);
            reject('Connection error — move not saved');
          });
        });
      }
      layer.add(dwGrp);
    });

    /* ── 11. Garage dashed overlay ───────────────────────────────────── */
    (fd.rooms||[]).forEach(function(rm) {
      if (rm.type !== 'garage') return;
      layer.add(new Konva.Rect({x:sbx+rm.x*PX, y:sby+rm.y*PX, width:rm.width*PX, height:rm.height*PX, fill:'transparent', stroke:'#888', strokeWidth:2, dash:[6,3], listening:false}));
    });

    var _revealMark_walls = layer.children.length; // PROMPT O reveal bucket boundary
    /* ── 12. Furniture (interactive) ─────────────────────────────────── */
    // PROMPT Q3 — wall-snap + clearance for furniture drag. Types that
    // naturally sit against a wall snap their back edge flush to the
    // nearest room wall within WALL_SNAP_DIST; everything snaps to a 0.1m
    // grid and is clamped inside its own room. Rotated pieces are skipped
    // (their bbox math doesn't line up with fGrp's offset-based rotation
    // pivot) — a disclosed scope cut, not a bug.
    var _WALL_AFFINITY = {bed_single:true, bed_double:true, sofa:true, wardrobe:true, toilet:true};
    var _F_SNAP = 0.1, _F_WALL_SNAP_DIST = 0.3;
    function findRoomById(rid) { return (fd.rooms||[]).find(function(r){ return r.id === rid; }); }
    (fd.furniture||[]).forEach(function(fn) {
      var ffx=sbx+fn.x*PX, ffy=sby+fn.y*PX;
      var isSel = (selF === fn.id);
      // PROMPT WW follow-up #4 — furniture used to stay draggable even in
      // Draw Wall mode, on the theory that dragging furniture and drawing
      // a wall are unrelated gestures. In practice most rooms are mostly
      // covered by furniture, so "click anywhere in the room" usually
      // landed on a furniture piece instead of bare floor — silently
      // swallowed as a furniture-drag attempt (nwIsOnDraggable) instead of
      // starting a wall. A dedicated drawing tool should own every click
      // inside a room while it's active, same as room-drag-to-move already
      // yields to it via hitGrp above.
      var fGrp = new Konva.Group({x:ffx, y:ffy, draggable:!props.wallDrawMode, furnId: fn.id});

      // Apply rotation if set (around centre)
      if (fn.rotation) {
        fGrp.offsetX(fn.width*PX/2);
        fGrp.offsetY(fn.height*PX/2);
        fGrp.x(ffx + fn.width*PX/2);
        fGrp.y(ffy + fn.height*PX/2);
        fGrp.rotation(fn.rotation);
      }

      _drawKSym(fGrp, fn.type, fn.width*PX, fn.height*PX);

      if (isSel) {
        fGrp.add(new Konva.Rect({x:0, y:0, width:fn.width*PX, height:fn.height*PX, fill:'transparent', stroke:_KCYAN, strokeWidth:2, cornerRadius:3, listening:false, name:'fSelOutline'}));
      }
      var fHover = new Konva.Rect({x:-3, y:-3, width:fn.width*PX+6, height:fn.height*PX+6, fill:'transparent', stroke:'transparent', strokeWidth:1.5, cornerRadius:3, name:'fHover'});
      fGrp.add(fHover);

      // Transparent hit area inside the group
      var fHit = new Konva.Rect({x:0, y:0, width:fn.width*PX, height:fn.height*PX, fill:'transparent'});
      fGrp.add(fHit);
      layer.add(fGrp);

      var fOrigX = 0, fOrigY = 0;
      fGrp.on('mouseenter', function() {
        if (!isSel) fHover.stroke('rgba(43,187,214,0.55)');
        var c = stage.container(); if (c) c.style.cursor = 'move';
        layer.batchDraw();
      });
      fGrp.on('mouseleave', function() {
        fHover.stroke('transparent');
        var c = stage.container(); if (c) c.style.cursor = '';
        layer.batchDraw();
      });
      fGrp.on('click tap', function(e) {
        // PROMPT WW follow-up #4 — same fix as hitGrp's click handler above:
        // Konva's synthesized 'click' isn't stopped by cancelBubble set on
        // an earlier 'mousedown', so this could still fire setSelF() (and
        // the re-render it triggers) on the very click that just started a
        // wall draw over this furniture piece, wiping the gesture out.
        if (props.wallDrawMode) return;
        e.cancelBubble = true;
        if (props.setSelF) props.setSelF(fn.id);
        if (props.setSelR) props.setSelR(null);
        if (props.setSelD) props.setSelD(null);
      });
      fGrp.on('dragstart', function() {
        stage.draggable(false);
        fOrigX = fGrp.x(); fOrigY = fGrp.y();
        if (props.setSelF) props.setSelF(fn.id);
      });
      fGrp.on('dragmove', function() {
        if (fn.rotation) return; // rotated pieces: free-drag only (scope cut)
        var room = findRoomById(fn.room_id);
        var rawX = (fGrp.x() - sbx) / PX;
        var rawY = (fGrp.y() - sby) / PX;
        var sx = Math.round(rawX / _F_SNAP) * _F_SNAP;
        var sy = Math.round(rawY / _F_SNAP) * _F_SNAP;
        if (room && _WALL_AFFINITY[fn.type]) {
          var distN = Math.abs(sy - room.y);
          var distS = Math.abs((sy+fn.height) - (room.y+room.height));
          var distW = Math.abs(sx - room.x);
          var distE = Math.abs((sx+fn.width) - (room.x+room.width));
          var minDist = Math.min(distN, distS, distW, distE);
          if (minDist <= _F_WALL_SNAP_DIST) {
            if (minDist === distN) sy = room.y;
            else if (minDist === distS) sy = room.y + room.height - fn.height;
            else if (minDist === distW) sx = room.x;
            else sx = room.x + room.width - fn.width;
          }
        }
        if (room) {
          sx = Math.max(room.x, Math.min(sx, room.x + room.width - fn.width));
          sy = Math.max(room.y, Math.min(sy, room.y + room.height - fn.height));
        }
        fGrp.position({x: sbx+sx*PX, y: sby+sy*PX});
      });
      fGrp.on('dragend', function() {
        stage.draggable(true);
        var newX = (fGrp.x() - sbx) / PX;
        var newY = (fGrp.y() - sby) / PX;
        // Clearance check: no overlap with other furniture in the same room,
        // and stay clear of that room's door swing.
        if (!fn.rotation) {
          var blocked = null;
          (fd.furniture||[]).forEach(function(other) {
            if (blocked || other.id === fn.id || other.room_id !== fn.room_id) return;
            var ox = Math.min(newX+fn.width, other.x+other.width) - Math.max(newX, other.x);
            var oy = Math.min(newY+fn.height, other.y+other.height) - Math.max(newY, other.y);
            if (ox > 0.05 && oy > 0.05) blocked = 'Would overlap the ' + other.type.replace(/_/g,' ');
          });
          (fd.doors_windows||[]).forEach(function(dw) {
            if (blocked || dw.type !== 'door' || dw.room_id !== fn.room_id) return;
            var swingLen = Math.max(dw.width, dw.height);
            var zx=dw.x, zy=dw.y;
            if (dw.wall==='south') zy = dw.y - swingLen;
            else if (dw.wall==='east') zx = dw.x - swingLen;
            var ox = Math.min(newX+fn.width, zx+swingLen) - Math.max(newX, zx);
            var oy = Math.min(newY+fn.height, zy+swingLen) - Math.max(newY, zy);
            if (ox > 0.05 && oy > 0.05) blocked = 'Would block the door swing';
          });
          if (blocked) {
            fGrp.position({x: fOrigX, y: fOrigY});
            layer.batchDraw();
            if (props.onFlash) props.onFlash(blocked);
            return;
          }
        }
        if (props.setFd) props.setFd(function(prev) {
          if (!prev) return prev;
          return Object.assign({}, prev, {furniture: (prev.furniture||[]).map(function(f) {
            return f.id === fn.id ? Object.assign({}, f, {x:newX, y:newY}) : f;
          })});
        });
      });
    });

    /* ── 12.5 Wall-drag controller (PROMPT N rebuild) ─────────────────────
     * No React state and no network calls while the mouse is down. On
     * press we hide the two real rooms' Konva nodes (tagged with roomId in
     * §6/§7/§13) and draw a lightweight overlay that a requestAnimationFrame
     * loop updates straight from the raw pointer position — axis-locked,
     * snapped to 0.1m, hard-stopped at minimum room size and at any other
     * room in the way. React only sees a single commit, on mouseup, and
     * only after a client-side audit (overlap / bounds / min-size — the
     * checks that matter for a two-room edit) passes; a failed audit fades
     * the overlay back to the original geometry and reports why via
     * props.onFlash. Esc cancels mid-drag.
     * Scope note: the overlay renders both rooms as plain rectangles (fill
     * + W×H + area label) — type-specific decoration (stair treads, the
     * elevator's X, bedroom sub-room partitions, doors/windows) is skipped
     * live and reappears at full fidelity once the commit re-renders.
     */
    function minDimFor(type) { return type === 'bathroom' ? 1.5 : 2.4; }

    function findSharedNeighbor(rm, dir) {
      var axis = (dir==='n'||dir==='s') ? 'y' : 'x';
      var edgeVal = (dir==='e') ? rm.x+rm.width : (dir==='w') ? rm.x : (dir==='s') ? rm.y+rm.height : rm.y;
      var spanLo = (axis==='x') ? rm.y : rm.x;
      var spanHi = (axis==='x') ? rm.y+rm.height : rm.x+rm.width;
      var best = null, bestOverlap = 0;
      fd.rooms.forEach(function(other) {
        if (other.id === rm.id || _EXT_ROOMS[other.type]) return;
        var oNear = (dir==='e') ? other.x : (dir==='w') ? other.x+other.width : (dir==='s') ? other.y : other.y+other.height;
        if (Math.abs(oNear - edgeVal) > 0.15) return; // must actually touch this edge
        var oLo = (axis==='x') ? other.y : other.x;
        var oHi = (axis==='x') ? other.y+other.height : other.x+other.width;
        var overlap = Math.min(spanHi, oHi) - Math.max(spanLo, oLo);
        if (overlap > bestOverlap) { bestOverlap = overlap; best = other; }
      });
      return (bestOverlap > 0.3) ? best : null; // require a real shared span, not a corner touch
    }

    function computeDragBounds(rm, neighbor, dir) {
      var axis = (dir==='n'||dir==='s') ? 'y' : 'x';
      var rmMin = minDimFor(rm.type), nbMin = neighbor ? minDimFor(neighbor.type) : 0;
      var lo, hi;
      if (axis==='x') {
        if (dir==='e') { lo = rm.x + rmMin; hi = neighbor ? (neighbor.x+neighbor.width-nbMin) : bw; }
        else           { lo = neighbor ? (neighbor.x+nbMin) : 0; hi = rm.x+rm.width - rmMin; }
      } else {
        if (dir==='s') { lo = rm.y + rmMin; hi = neighbor ? (neighbor.y+neighbor.height-nbMin) : bh; }
        else           { lo = neighbor ? (neighbor.y+nbMin) : 0; hi = rm.y+rm.height - rmMin; }
      }
      // Hard stop before crossing any OTHER room that happens to be in the way.
      var spanLo = (axis==='x') ? rm.y : rm.x, spanHi = (axis==='x') ? rm.y+rm.height : rm.x+rm.width;
      fd.rooms.forEach(function(other) {
        if (other.id===rm.id || (neighbor && other.id===neighbor.id) || _EXT_ROOMS[other.type]) return;
        var oLo = (axis==='x') ? other.y : other.x, oHi = (axis==='x') ? other.y+other.height : other.x+other.width;
        if (Math.min(spanHi,oHi) - Math.max(spanLo,oLo) <= 0.05) return;
        var oNear = (axis==='x') ? other.x : other.y, oFar = (axis==='x') ? other.x+other.width : other.y+other.height;
        if (dir==='e'||dir==='s') { if (oNear >= ((axis==='x')?rm.x:rm.y) - 0.05) hi = Math.min(hi, oNear); }
        else { if (oFar <= ((axis==='x')?(rm.x+rm.width):(rm.y+rm.height)) + 0.05) lo = Math.max(lo, oFar); }
      });
      if (hi < lo) hi = lo;
      return { lo: lo, hi: hi, axis: axis };
    }

    function geomFromWallCoord(rm, neighbor, dir, wallCoord) {
      var nRm = { x: rm.x, y: rm.y, width: rm.width, height: rm.height };
      var nNb = neighbor ? { x: neighbor.x, y: neighbor.y, width: neighbor.width, height: neighbor.height } : null;
      if (dir==='e') { nRm.width = wallCoord - rm.x; if (nNb) { nNb.width = (neighbor.x+neighbor.width) - wallCoord; nNb.x = wallCoord; } }
      else if (dir==='w') { nRm.x = wallCoord; nRm.width = (rm.x+rm.width) - wallCoord; if (nNb) { nNb.width = wallCoord - neighbor.x; } }
      else if (dir==='s') { nRm.height = wallCoord - rm.y; if (nNb) { nNb.height = (neighbor.y+neighbor.height) - wallCoord; nNb.y = wallCoord; } }
      else if (dir==='n') { nRm.y = wallCoord; nRm.height = (rm.y+rm.height) - wallCoord; if (nNb) { nNb.height = wallCoord - neighbor.y; } }
      return { rm: nRm, neighbor: nNb };
    }

    // Client-side port of the checks that matter for a two-room wall edit:
    // minimum dimensions, staying inside the building, and no overlap with
    // any other room on the floor (the full backend geometry_audit also
    // checks reachability/notch clipping etc., which a same-floor two-room
    // resize can't violate on its own).
    function auditPair(rmId, nbId, geom) {
      var updated = fd.rooms.map(function(r) {
        if (r.id === rmId) return Object.assign({}, r, geom.rm);
        if (nbId && r.id === nbId) return Object.assign({}, r, geom.neighbor);
        return r;
      });
      var byId = {}; updated.forEach(function(r){ byId[r.id]=r; });
      var checks = [byId[rmId]]; if (nbId) checks.push(byId[nbId]);
      for (var i=0;i<checks.length;i++) {
        var r = checks[i], mn = minDimFor(r.type);
        if (r.width < mn - 0.01 || r.height < mn - 0.01) return { ok:false, reason:'Room would shrink below the '+mn+'m minimum' };
        if (r.x < -0.01 || r.y < -0.01 || r.x+r.width > bw+0.01 || r.y+r.height > bh+0.01) return { ok:false, reason:'Room would cross the building edge' };
      }
      var interior = updated.filter(function(r){ return !_EXT_ROOMS[r.type]; });
      for (var a=0; a<interior.length; a++) {
        for (var b=a+1; b<interior.length; b++) {
          var ra=interior[a], rb=interior[b];
          var ox = Math.min(ra.x+ra.width, rb.x+rb.width) - Math.max(ra.x, rb.x);
          var oy = Math.min(ra.y+ra.height, rb.y+rb.height) - Math.max(ra.y, rb.y);
          if (ox > 0.05 && oy > 0.05) return { ok:false, reason:'Rooms would overlap' };
        }
      }
      return { ok:true, rooms: updated };
    }

    var hoverHL = new Konva.Group({ listening:false });
    layer.add(hoverHL);
    function setHighlight(rm, neighbor, on) {
      hoverHL.destroyChildren();
      if (on) {
        var mkHL = function(r) {
          hoverHL.add(new Konva.Rect({
            x: sbx+r.x*PX, y: sby+r.y*PX, width: r.width*PX, height: r.height*PX,
            fill: 'rgba(43,187,214,0.12)', stroke: _KCYAN, strokeWidth: 1.5, listening:false
          }));
        };
        mkHL(rm);
        if (neighbor) mkHL(neighbor);
      }
    }

    function startWallDrag(rm, dir) {
      var neighbor = findSharedNeighbor(rm, dir);
      var bounds = computeDragBounds(rm, neighbor, dir);
      var axis = bounds.axis;
      var initialWallCoord = (dir==='e') ? rm.x+rm.width : (dir==='w') ? rm.x : (dir==='s') ? rm.y+rm.height : rm.y;

      setHighlight(rm, neighbor, false);
      stage.draggable(false);
      document.body.style.cursor = (axis==='x') ? 'col-resize' : 'row-resize';

      var hideTargets = layer.find(function(n) {
        var rid = n.getAttr && n.getAttr('roomId');
        return rid === rm.id || (neighbor && rid === neighbor.id);
      });
      hideTargets.forEach(function(n){ n.hide(); });

      var overlay = new Konva.Group({ listening:false });
      layer.add(overlay);

      function drawRoomOverlay(geomRoom) {
        var ox = sbx+geomRoom.x*PX, oy = sby+geomRoom.y*PX, ow = geomRoom.width*PX, oh = geomRoom.height*PX;
        var rect = new Konva.Rect({ x:ox, y:oy, width:ow, height:oh, fill: _KROOMFILL, stroke:_KWALL, strokeWidth:1.5 });
        var dimTxt  = new Konva.Text({ x:ox, y:oy+oh/2-14, width:ow, text:geomRoom.width.toFixed(2)+'m × '+geomRoom.height.toFixed(2)+'m', fontSize:11, fontStyle:'bold', fill:'#222', align:'center' });
        var areaTxt = new Konva.Text({ x:ox, y:oy+oh/2+2,  width:ow, text:(geomRoom.width*geomRoom.height).toFixed(1)+'m²', fontSize:9, fill:'#666', align:'center' });
        overlay.add(rect, dimTxt, areaTxt);
        return { rect:rect, dimTxt:dimTxt, areaTxt:areaTxt };
      }

      var rmVis = drawRoomOverlay(rm);
      var nbVis = neighbor ? drawRoomOverlay(neighbor) : null;
      var wallVis = new Konva.Rect({ fill:_KCYAN, opacity:0.9 });
      overlay.add(wallVis);

      function updateVisual(wallCoord) {
        var g = geomFromWallCoord(rm, neighbor, dir, wallCoord);
        function place(vis, geomRoom) {
          var ox = sbx+geomRoom.x*PX, oy = sby+geomRoom.y*PX, ow = geomRoom.width*PX, oh = geomRoom.height*PX;
          vis.rect.setAttrs({ x:ox, y:oy, width:ow, height:oh });
          vis.dimTxt.setAttrs({ x:ox, y:oy+oh/2-14, width:ow, text:geomRoom.width.toFixed(2)+'m × '+geomRoom.height.toFixed(2)+'m' });
          vis.areaTxt.setAttrs({ x:ox, y:oy+oh/2+2, width:ow, text:(geomRoom.width*geomRoom.height).toFixed(1)+'m²' });
        }
        place(rmVis, g.rm);
        if (nbVis && g.neighbor) place(nbVis, g.neighbor);
        if (axis==='x') {
          var y0 = Math.min(g.rm.y, neighbor ? g.neighbor.y : g.rm.y);
          var y1 = Math.max(g.rm.y+g.rm.height, neighbor ? g.neighbor.y+g.neighbor.height : g.rm.y+g.rm.height);
          wallVis.setAttrs({ x: sbx+wallCoord*PX-1.5, y: sby+y0*PX, width: 3, height: (y1-y0)*PX });
        } else {
          var x0 = Math.min(g.rm.x, neighbor ? g.neighbor.x : g.rm.x);
          var x1 = Math.max(g.rm.x+g.rm.width, neighbor ? g.neighbor.x+g.neighbor.width : g.rm.x+g.rm.width);
          wallVis.setAttrs({ x: sbx+x0*PX, y: sby+wallCoord*PX-1.5, width: (x1-x0)*PX, height: 3 });
        }
        layer.batchDraw();
        return g;
      }

      updateVisual(initialWallCoord);

      var dragging = true, currentWallCoord = initialWallCoord, latestPointer = null, rafId = null;

      function onPointerMove() { latestPointer = stage.getRelativePointerPosition(); }
      function tick() {
        if (!dragging) return;
        if (latestPointer) {
          var raw = (axis==='x') ? (latestPointer.x - sbx)/PX : (latestPointer.y - sby)/PX;
          var snapped = Math.round(raw/0.1)*0.1;
          var clamped = Math.max(bounds.lo, Math.min(bounds.hi, snapped));
          currentWallCoord = clamped;
          updateVisual(clamped);
          latestPointer = null;
        }
        rafId = requestAnimationFrame(tick);
      }
      rafId = requestAnimationFrame(tick);
      stage.on('pointermove.walldrag', onPointerMove);

      function onKeyDown(e) {
        if (e.key === 'Escape') { e.preventDefault(); finishDrag(false); }
      }
      window.addEventListener('keydown', onKeyDown);

      function showHidden() { hideTargets.forEach(function(n){ n.show(); }); }

      function finishDrag(commit) {
        dragging = false;
        if (rafId) cancelAnimationFrame(rafId);
        stage.off('pointermove.walldrag');
        stage.off('mouseup.walldrag touchend.walldrag');
        window.removeEventListener('keydown', onKeyDown);
        stage.draggable(true);
        document.body.style.cursor = 'default';

        if (commit) {
          var geom = geomFromWallCoord(rm, neighbor, dir, currentWallCoord);
          var check = auditPair(rm.id, neighbor ? neighbor.id : null, geom);
          if (!check.ok) {
            updateVisual(initialWallCoord);
            overlay.to({ opacity: 0, duration: 0.25, onFinish: function() {
              overlay.destroy();
              showHidden();
              layer.batchDraw();
            }});
            if (props.onFlash) props.onFlash(check.reason);
            return;
          }
          overlay.destroy();
          showHidden();
          layer.batchDraw();
          if (props.setFd) {
            props.setFd(function(prev) {
              if (!prev) return prev;
              return Object.assign({}, prev, { rooms: prev.rooms.map(function(r) {
                var u = check.rooms.filter(function(x){ return x.id===r.id; })[0];
                return u ? Object.assign({}, r, {x:u.x, y:u.y, width:u.width, height:u.height}) : r;
              })});
            });
          }
        } else {
          overlay.destroy();
          showHidden();
          layer.batchDraw();
        }
      }

      stage.on('mouseup.walldrag touchend.walldrag', function() { finishDrag(true); });
    }

    function makeWallHandle(rm, dir, hx, hy, hw, hh) {
      var isHoriz = (dir==='n'||dir==='s');
      var grp = new Konva.Group({});
      // 12px hit area (near-invisible fill so Konva still hit-tests it),
      // slim visible bar centered within it.
      var hit = new Konva.Rect({ x:hx, y:hy, width:hw, height:hh, fill:'rgba(0,0,0,0.001)' });
      var barInset = Math.max(0, (Math.min(hw,hh) - 6) / 2);
      var bar = isHoriz
        ? new Konva.Rect({ x:hx, y:hy+barInset, width:hw, height:6, fill:_KCYAN, cornerRadius:3, opacity:0.8, listening:false })
        : new Konva.Rect({ x:hx+barInset, y:hy, width:6, height:hh, fill:_KCYAN, cornerRadius:3, opacity:0.8, listening:false });
      grp.add(hit, bar);

      grp.on('mouseover', function() {
        document.body.style.cursor = isHoriz ? 'row-resize' : 'col-resize';
        bar.opacity(1);
        setHighlight(rm, findSharedNeighbor(rm, dir), true);
        layer.batchDraw();
      });
      grp.on('mouseout', function() {
        document.body.style.cursor = 'default';
        bar.opacity(0.8);
        setHighlight(rm, null, false);
        layer.batchDraw();
      });
      grp.on('mousedown touchstart', function(e) {
        e.cancelBubble = true;
        startWallDrag(rm, dir);
      });
      return grp;
    }

    var _revealMark_furniture = layer.children.length; // PROMPT O reveal bucket boundary
    /* ── 13. Room labels + dimension callouts ─────────────────────────── */
    var roomById = {};
    fd.rooms.forEach(function(r) { roomById[r.id] = r; });

    fd.rooms.forEach(function(rm) {
      // Niches carry no label: the box is ~1-2m across, so "NICHE" plus an
      // area string overflowed it and collided with the neighbouring wall.
      // The closet hatch already says what it is (LANDING C item 2).
      if (rm.type === 'niche') return;
      // PROMPT Y item 5 — the staircase draws its own UP/DN marker at mid-
      // shaft; centring the room label there too produced the overlapping
      // "STAIRCA SE / DN" seen in the PROMPT X review. Skip the block label
      // and letter the head of the shaft instead.
      if (rm.type === 'staircase') {
        var _sx = sbx+rm.x*PX, _sy = sby+rm.y*PX, _sw = rm.width*PX;
        var _sfs = Math.max(5, Math.min(8, _sw/5.2));
        layer.add(new Konva.Text({x:_sx, y:_sy+2, width:_sw, align:'center',
          text:'STAIR', fontSize:_sfs, fontStyle:'bold', fill:'#555', listening:false}));
        return;
      }
      var rrx=sbx+rm.x*PX, rry=sby+rm.y*PX, rrw=rm.width*PX, rrh=rm.height*PX;
      // Exterior rooms rendered separately — use simpler label position
      if (_EXT_ROOMS[rm.type]) { rrx=sbx+rm.x*PX; rry=sby+rm.y*PX; }
      var isSel = (selR === rm.id);
      var enLbl = String(rm.label||rm.type.replace(/_/g,' ')).toUpperCase();
      var arLbl = (RLABEL_AR && RLABEL_AR[rm.type]) ? RLABEL_AR[rm.type] : null;
      // PROMPT Y item 5 — auto-shrink so a label never overflows its room.
      // "STAIRCASE" in a 1.7m shaft was rendering as "STAIRCA SE" across the
      // wall, and small bathrooms overflowed the same way.
      var ffs = Math.max(5, Math.min(11, rrw/(Math.max(4, enLbl.length)*0.62), rrh/4.5));
      var areaStr = rm.type==='corridor'
        ? (rm.width.toFixed(1)+'m × '+rm.height.toFixed(1)+'m')
        : (String((rm.width*rm.height).toFixed(1))+'m²');

      // Vertical center: shift up if Arabic label also shown
      var totalTextH = ffs + (arLbl ? (ffs-1.5)+2 : 0) + (ffs-1.5)+2;
      var lblX=rrx, lblY=rry+rrh/2-totalTextH/2, lblW=rrw;
      var arY = lblY + ffs + 1;
      var areaY = arY + (arLbl ? (ffs-1.5)+2 : 0);

      // Label nodes tagged with roomId so wall-drag can hide/show them as a
      // unit (see §14.5) — the area string needs a live refresh mid-drag.
      var lblGrp = new Konva.Group({roomId: rm.id});
      layer.add(lblGrp);

      if (rm.type==='bedroom' && rm.sub_rooms && rm.sub_rooms.length>=2) {
        var sr0b=rm.sub_rooms[0];
        var isHorizB = sr0b.rel_y > 0.1;
        var sleepH = isHorizB ? sr0b.rel_y*PX : rrh;
        var sleepW = isHorizB ? rrw : sr0b.rel_x*PX;
        lblY  = rry + sleepH/2 - totalTextH/2;
        arY   = lblY + ffs + 1;
        areaY = arY + (arLbl ? (ffs-1.5)+2 : 0);
        lblW  = isHorizB ? rrw : sleepW;
        // Sub-room labels
        rm.sub_rooms.forEach(function(sr) {
          var srx=rrx+sr.rel_x*PX, sry=rry+sr.rel_y*PX, srw=sr.width*PX, srh=sr.height*PX;
          var sffs = Math.max(6, Math.min(8, srw/6));
          lblGrp.add(new Konva.Text({x:srx, y:sry+srh/2-sffs/2, width:srw, text:sr.label, fontSize:sffs, fill:'#555', fontStyle:'italic', align:'center', listening:false}));
        });
      }

      lblGrp.add(new Konva.Text({x:lblX, y:lblY,  width:lblW, text:enLbl,   fontSize:ffs,     fontStyle:'bold', fill:'#222', align:'center', listening:false}));
      if (arLbl) {
        lblGrp.add(new Konva.Text({x:lblX, y:arY,  width:lblW, text:arLbl,  fontSize:ffs-1.5, fill:'#555', align:'center', listening:false}));
      }
      var areaTextNode = new Konva.Text({x:rrx,  y:areaY, width:rrw,  text:areaStr, fontSize:ffs-1.5, fill:'#888', align:'center', listening:false});
      lblGrp.add(areaTextNode);

      if (isSel) {
        // UI-010 — these two callouts used a fixed offset outside the
        // room (18px above, 4px right) with no check for whether there
        // was actually room to draw there. A room against the top or
        // right edge of the buildable envelope had its label pushed past
        // the boundary — overlapping the dimension-string row above the
        // plot, or getting visually clipped by the exterior wall on the
        // right. Both now fall back to sitting just inside the room's own
        // edge (with a small background plate for legibility over the
        // room fill) whenever there isn't clear space outside it.
        var wLblOutside = (rry - 18) >= (sby - 14);
        var wLblY = wLblOutside ? rry - 18 : rry + 3;
        var wLblFg = wLblOutside ? _KCYAN : '#fff';
        if (!wLblOutside) {
          lblGrp.add(new Konva.Rect({x:rrx+rrw/2-24, y:wLblY-1, width:48, height:13, fill:_KCYAN, cornerRadius:2, listening:false}));
        }
        lblGrp.add(new Konva.Text({x:rrx, y:wLblY, width:rrw, text:rm.width.toFixed(1)+'m', fontSize:10, fontStyle:'bold', fill:wLblFg, align:'center', listening:false}));

        var hLblOutside = (rrx + rrw + 4 + 32) <= (sbx + bw*PX + 4);
        var hLblX = hLblOutside ? rrx + rrw + 4 : rrx + rrw - 34;
        var hLblFg = hLblOutside ? _KCYAN : '#fff';
        if (!hLblOutside) {
          lblGrp.add(new Konva.Rect({x:hLblX-2, y:rry+rrh/2-7, width:36, height:13, fill:_KCYAN, cornerRadius:2, listening:false}));
        }
        lblGrp.add(new Konva.Text({x:hLblX, y:rry+rrh/2-5, text:rm.height.toFixed(1)+'m', fontSize:10, fontStyle:'bold', fill:hLblFg, listening:false}));
      }

      /* ── 14. Wall resize handles for selected room ─────────────────── */
      if (isSel) {
        if (rrw > 20) {
          layer.add(makeWallHandle(rm, 'n', rrx+6, rry-6, Math.max(8,rrw-12), 12));
          layer.add(makeWallHandle(rm, 's', rrx+6, rry+rrh-6, Math.max(8,rrw-12), 12));
        }
        if (rrh > 20) {
          layer.add(makeWallHandle(rm, 'w', rrx-6, rry+6, 12, Math.max(8,rrh-12)));
          layer.add(makeWallHandle(rm, 'e', rrx+rrw-6, rry+6, 12, Math.max(8,rrh-12)));
        }
      }
    });

    /* ── North arrow + title block (right panel) ─────────────────────── */
    var tbX = plotOx + plW + 60;   // clear of east dimension strings
    var tbW = 110, tbH = 72;
    var tbY = plotOy + plH - tbH - 10;

    // North arrow centred above the title block
    var naX = tbX + tbW / 2;
    var naY = plotOy + 50;
    var naR = 16;
    layer.add(new Konva.Circle({x:naX, y:naY, radius:naR, fill:'transparent', stroke:'#444', strokeWidth:1, listening:false}));
    // Arrow shaft pointing up (north)
    layer.add(new Konva.Arrow({x:naX, y:naY+naR*0.55, points:[0,0,0,-naR*2.1],
      pointerLength:7, pointerWidth:6, fill:'#222', stroke:'#222', strokeWidth:1.5, listening:false}));
    // Filled half to suggest compass needle
    layer.add(new Konva.Line({points:[naX,naY-naR*0.55, naX-naR*0.35,naY+naR*0.55, naX,naY+naR*0.3],
      closed:true, fill:'#ccc', strokeWidth:0, listening:false}));
    layer.add(new Konva.Text({x:naX-7, y:naY-naR-18, text:'N', fontSize:12, fontStyle:'bold', fill:'#222', listening:false}));
    layer.add(new Konva.Rect({x:tbX, y:tbY, width:tbW, height:tbH, fill:'white', stroke:'#444', strokeWidth:1, listening:false}));
    layer.add(new Konva.Line({points:[tbX,tbY+17,tbX+tbW,tbY+17], stroke:'#444', strokeWidth:0.5, listening:false}));
    layer.add(new Konva.Text({x:tbX+4, y:tbY+3, width:tbW-8, text:'PLANIFY — بلانيفاي', fontSize:9, fontStyle:'bold', fill:'#222', align:'center', listening:false}));
    var _tbDate = new Date().toISOString().slice(0,10);
    // UI-011 — this title block already mixed two different bilingual
    // rules (PLANIFY/Floor rows always show both languages; Date/Plot/
    // Frontage were English-only) with no reason for the split. It's a
    // fixed technical stamp, not conversational UI, so "always show both"
    // (the pattern the majority of these rows already used) is the one
    // applied everywhere here now, rather than switching by isAr.
    layer.add(new Konva.Text({x:tbX+4, y:tbY+21, width:tbW-8, text:'Date / التاريخ: '+_tbDate, fontSize:7.5, fill:'#555', listening:false}));
    layer.add(new Konva.Text({x:tbX+4, y:tbY+34, width:tbW-8, text:'Plot / الأرض: '+pw.toFixed(1)+'m × '+ph.toFixed(1)+'m', fontSize:7.5, fill:'#555', listening:false}));
    layer.add(new Konva.Text({x:tbX+4, y:tbY+47, width:tbW-8, text:'Floor '+(fd.floor+1)+' / الطابق '+(fd.floor+1), fontSize:7.5, fill:'#555', listening:false}));
    layer.add(new Konva.Text({x:tbX+4, y:tbY+60, width:tbW-8, text:'Frontage / الاتجاه: '+frontageList.join(', '), fontSize:7, fill:'#888', listening:false}));

    // Click on empty canvas → deselect
    stage.on('click tap', function(e) {
      if (e.target === stage || e.target.getLayer() === null) {
        if (props.setSelR) props.setSelR(null);
        if (props.setSelF) props.setSelF(null);
        if (props.setSelD) props.setSelD(null);
      }
    });

    // Every dependency change re-runs this whole effect against the SAME
    // persistent `stage` (destroyChildren() only clears the layer tree,
    // not stage-level listeners) — without this, toggling Draw Wall mode
    // on/off (or any other redraw while it's on) would stack a fresh
    // 'mousedown.newwall' handler on top of the previous one instead of
    // replacing it. Unconditional so mode OFF also cleans up a mode-ON
    // redraw's listeners.
    stage.off('mousedown.newwall touchstart.newwall mouseup.newwall touchend.newwall pointermove.newwall');
    stage.container().style.cursor = '';

    // PROMPT WW — Draw Wall mode: click-drag inside a room draws a new
    // dividing wall along whichever axis the drag mostly ran (a
    // mostly-vertical drag places a vertical wall at that x, splitting
    // the room left/right; mostly-horizontal places a horizontal wall at
    // that y, splitting top/bottom). Commits via the exact same
    // server-guarded /api/draw_wall call the "Split vertically/
    // horizontally" panel buttons already make (see app.html's
    // drawWall()) — this just feeds it the real drag position instead of
    // always the room's fixed midpoint. Gated on wallDrawMode so it can
    // never fight the room-drag-to-move gesture (hitGrp.draggable is set
    // to !props.wallDrawMode above, so the two are mutually exclusive).
    if (props.wallDrawMode && props.onDrawWall) {
      var nwStartRoom = null, nwStartPt = null, nwPreview = null, nwDragging = false;
      var nwLatestPt = null, nwLatestRaw = null, nwRaf = null;

      function nwToMetres(pos) { return { x: (pos.x - sbx) / PX, y: (pos.y - sby) / PX }; }
      // PROMPT WW follow-up #4 — rooms, furniture, and door handles all
      // give up drag-to-move while this mode is active (each sets its own
      // `draggable: !props.wallDrawMode`), so a click anywhere inside a
      // room always starts a wall instead of being silently swallowed by
      // whatever happens to be underneath the cursor. This walk is now
      // just a defensive backstop — kept in case a future node type is
      // added that forgets to respect wallDrawMode — not the everyday
      // gate it used to be.
      function nwIsOnDraggable(node) {
        // Stop BEFORE the stage itself — the stage's own draggable:true is
        // for panning empty canvas, unrelated to "did the user grab a
        // movable shape".
        while (node && node !== stage) { if (node.draggable && node.draggable()) return true; node = node.getParent && node.getParent(); }
        return false;
      }

      function nwPointerMove() { nwLatestRaw = stage.getRelativePointerPosition(); }
      function nwTick() {
        if (!nwDragging) return;
        if (nwLatestRaw) {
          nwLatestPt = nwToMetres(nwLatestRaw);
          var dx = Math.abs(nwLatestPt.x - nwStartPt.x), dy = Math.abs(nwLatestPt.y - nwStartPt.y);
          var vertical = dx <= dy;
          var x1, y1, x2, y2;
          if (vertical) {
            x1 = x2 = nwStartPt.x;
            y1 = Math.max(nwStartRoom.y, Math.min(nwStartPt.y, nwLatestPt.y));
            y2 = Math.min(nwStartRoom.y+nwStartRoom.height, Math.max(nwStartPt.y, nwLatestPt.y));
          } else {
            y1 = y2 = nwStartPt.y;
            x1 = Math.max(nwStartRoom.x, Math.min(nwStartPt.x, nwLatestPt.x));
            x2 = Math.min(nwStartRoom.x+nwStartRoom.width, Math.max(nwStartPt.x, nwLatestPt.x));
          }
          nwPreview.points([sbx+x1*PX, sby+y1*PX, sbx+x2*PX, sby+y2*PX]);
          layer.batchDraw();
          nwLatestRaw = null;
        }
        nwRaf = requestAnimationFrame(nwTick);
      }
      function nwKeyDown(e) { if (e.key === 'Escape' && nwDragging) { e.preventDefault(); nwFinish(false); } }
      function nwFinish(commit) {
        nwDragging = false;
        if (nwRaf) cancelAnimationFrame(nwRaf);
        stage.off('pointermove.newwall');
        window.removeEventListener('keydown', nwKeyDown);
        stage.draggable(true);
        if (nwPreview) { nwPreview.destroy(); nwPreview = null; }
        layer.batchDraw();
        if (commit && nwStartRoom && nwLatestPt) {
          var dx2 = Math.abs(nwLatestPt.x - nwStartPt.x), dy2 = Math.abs(nwLatestPt.y - nwStartPt.y);
          if (Math.max(dx2, dy2) > 0.3) {   // ignore an accidental click/jitter
            var vertical2 = dx2 <= dy2;
            props.onDrawWall(nwStartRoom.id, vertical2 ? 'x' : 'y', vertical2 ? nwStartPt.x : nwStartPt.y);
          }
        }
        nwStartRoom = null; nwStartPt = null; nwLatestPt = null;
      }

      // PROMPT WW follow-up #4 — this used to be a pure hold-drag-release
      // gesture (mousedown starts, mouseup commits). A real user report
      // ("I click and drag but can't actually draw anything") traced to
      // the natural way people try a click-drag: a plain first click's own
      // mousedown+mouseup pair fires within the same instant, and that
      // paired mouseup used to immediately call nwFinish() — tearing the
      // just-started line down (silently, since near-zero movement failed
      // the jitter guard) before the user ever got to move the mouse. Now
      // supports BOTH styles: click once to start (mousedown), move the
      // mouse freely with the button up, click again to finish (a second
      // mousedown while already drawing) — the explicit, easier gesture
      // asked for — while a genuine press-hold-move-release drag still
      // works too, since mouseup only commits when real movement (>0.3m)
      // happened since the start click; a mouseup with ~0 movement (the
      // tail of a plain start-click) is now a no-op that leaves the line
      // pending for the next click instead of cancelling it.
      stage.on('mousedown.newwall touchstart.newwall', function(evt) {
        if (props.wallBusy) return;
        if (nwDragging) {
          // Second click — finish here (nwFinish's own jitter guard still
          // discards it harmlessly if the two clicks landed on top of
          // each other).
          evt.cancelBubble = true;
          nwFinish(true);
          return;
        }
        if (nwIsOnDraggable(evt.target)) return;
        var p = stage.getRelativePointerPosition();
        if (!p) return;
        var m = nwToMetres(p);
        var room = _roomAtPoint(fd, m.x, m.y);
        if (!room) return;
        evt.cancelBubble = true;
        nwStartRoom = room; nwStartPt = m; nwLatestPt = m; nwDragging = true;
        nwPreview = new Konva.Line({points:[p.x,p.y,p.x,p.y], stroke:_KCYAN, strokeWidth:2.5, dash:[7,4], listening:false});
        layer.add(nwPreview);
        stage.draggable(false);
        stage.on('pointermove.newwall', nwPointerMove);
        window.addEventListener('keydown', nwKeyDown);
        nwRaf = requestAnimationFrame(nwTick);
      });
      stage.on('mouseup.newwall touchend.newwall', function() {
        if (!nwDragging) return;
        var endPt = nwLatestPt || nwStartPt;
        var dx = Math.abs(endPt.x - nwStartPt.x), dy = Math.abs(endPt.y - nwStartPt.y);
        if (Math.max(dx, dy) > 0.3) nwFinish(true);
        // else: just the tail end of the click that started the line —
        // leave it pending, waiting for the finishing click.
      });
      stage.container().style.cursor = 'crosshair';
    }

    /* ── PROMPT O: The Reveal ─────────────────────────────────────────────
     * Boundary wall → villa outline → interior walls sweep in → rooms fill
     * + labels fade up → furniture drops in last, ~2.7s total, staged with
     * Konva tweens from the JSON already sitting in `fd`. Only plays once
     * per genuinely NEW plan (props.revealSeed bump); a variant switch gets
     * a quick 250ms crossfade instead; an in-place edit (wall drag,
     * furniture move, "Fix this") gets neither — it just redraws. */
    /* ── DEBUG OVERLAY (?debug=1) — PROMPT X / LANDING B item 3 ──────────
     * Draws the RAW backend rectangles in red over the styled plan, plus the
     * outline polygon in magenta and each room's id. Makes "the data is wrong"
     * and "the drawing is wrong" separable at a glance, permanently — the
     * distinction PROMPT X's phase 1 had to reconstruct by hand from a frozen
     * trace. Added last so it sits on top of everything, and excluded from the
     * reveal animation buckets below (the marks are taken before this block). */
    var _revealMark_labels = layer.children.length;
    if (typeof window !== 'undefined' && /[?&]debug=1\b/.test(window.location.search)) {
      var dbg = new Konva.Group({listening:false, name:'debug-overlay'});
      fd.rooms.forEach(function(rm){
        var dx0=sbx+rm.x*PX, dy0=sby+rm.y*PX, dw=rm.width*PX, dh=rm.height*PX;
        dbg.add(new Konva.Rect({x:dx0, y:dy0, width:dw, height:dh,
          stroke:'#FF0000', strokeWidth:1, dash:[4,3], listening:false}));
        dbg.add(new Konva.Text({x:dx0+2, y:dy0+2,
          text:rm.id+'\n'+rm.x.toFixed(2)+','+rm.y.toFixed(2)+'\n'+rm.width.toFixed(2)+'x'+rm.height.toFixed(2),
          fontSize:7, fill:'#FF0000', listening:false}));
      });
      _outlineParts.forEach(function(part){
        var oFlat = part.reduce(function(a,p){ a.push(sbx+p[0]*PX, sby+p[1]*PX); return a; }, []);
        dbg.add(new Konva.Line({points:oFlat, closed:true, stroke:'#FF00FF', strokeWidth:2, listening:false}));
        part.forEach(function(p){
          dbg.add(new Konva.Circle({x:sbx+p[0]*PX, y:sby+p[1]*PX, radius:3, fill:'#FF00FF', listening:false}));
        });
      });
      (fd.doors_windows||[]).forEach(function(d){
        if (d.type !== 'door') return;
        dbg.add(new Konva.Rect({x:sbx+d.x*PX, y:sby+d.y*PX, width:Math.max(2,d.width*PX), height:Math.max(2,d.height*PX),
          fill:'#0000FF', opacity:0.7, listening:false}));
      });
      layer.add(dbg);
    }

    var isNewPlan = props.revealSeed != null && props.revealSeed !== lastRevealSeedRef.current;
    var isVariantSwitch = !isNewPlan && props.variantKey != null && props.variantKey !== lastVariantKeyRef.current;
    lastRevealSeedRef.current = props.revealSeed;
    lastVariantKeyRef.current = props.variantKey;

    function fadeInBucket(fromIdx, toIdx, cfg) {
      var nodes = layer.children.slice(fromIdx, toIdx);
      var origOpac = nodes.map(function(n) { return n.opacity(); });
      var origY = nodes.map(function(n) { return n.y(); });
      var origScale = nodes.map(function(n) { return n.scaleX(); });
      nodes.forEach(function(n, i) {
        n.opacity(0);
        if (cfg.fromY) n.y(origY[i] + cfg.fromY);
        if (cfg.fromScale) { n.scale({x: cfg.fromScale, y: cfg.fromScale}); }
      });
      return function play(delayMs) {
        setTimeout(function() {
          nodes.forEach(function(n, i) {
            var stag = cfg.stagger ? i * cfg.stagger : 0;
            setTimeout(function() {
              if (n.isDestroyed && n.isDestroyed()) return;
              var t = {opacity: origOpac[i], duration: cfg.duration || 0.3, easing: Konva.Easings.EaseOut};
              if (cfg.fromY) t.y = origY[i];
              if (cfg.fromScale) { t.scaleX = 1; t.scaleY = 1; }
              n.to(t);
            }, stag);
          });
        }, delayMs);
      };
    }

    if (isNewPlan) {
      // Fit the whole plot into view on every new generation — see
      // FloorPlanCanvas.jsx's identical fix for why: without this, a large
      // plot (e.g. 42x35m) overflows the container at the fixed PX-per-m
      // scale with no auto-fit, making a correctly-solved building that's
      // fully inside the plot look like it's rendered outside it.
      var stageEl = stageRef.current;
      if (stageEl) {
        var contW = stageEl.width() || 800, contH = stageEl.height() || 600;
        var contentW = pw * PX + 80, contentH = ph * PX + 80;
        if (contentW > contW || contentH > contH) {
          var fitScale = Math.min(contW / contentW, contH / contentH);
          stageEl.scale({ x: fitScale, y: fitScale });
          stageEl.position({ x: (contW - contentW * fitScale) / 2, y: (contH - contentH * fitScale) / 2 });
        } else {
          stageEl.scale({ x: 1, y: 1 });
          stageEl.position({ x: 0, y: 0 });
        }
      }
      var playBoundary  = fadeInBucket(0, _revealMark_boundary, {duration: 0.35});
      var playShell     = fadeInBucket(_revealMark_boundary, _revealMark_shell, {duration: 0.3, fromScale: 0.985});
      var playRooms     = fadeInBucket(_revealMark_shell, _revealMark_rooms, {duration: 0.32, fromScale: 0.96, stagger: 12});
      var playWalls     = fadeInBucket(_revealMark_rooms, _revealMark_walls, {duration: 0.28, stagger: 4});
      var playFurniture = fadeInBucket(_revealMark_walls, _revealMark_furniture, {duration: 0.32, fromY: -8, stagger: 30});
      var playLabels    = fadeInBucket(_revealMark_furniture, _revealMark_labels, {duration: 0.3, fromY: 4, stagger: 6});
      playBoundary(0);
      playShell(300);
      playWalls(650);
      playRooms(950);
      playLabels(1650);
      playFurniture(2150);
      layer.batchDraw();
    } else if (isVariantSwitch) {
      var allNodes = layer.children.slice(0);
      var allOpac = allNodes.map(function(n) { return n.opacity(); });
      allNodes.forEach(function(n) { n.opacity(0); });
      layer.batchDraw();
      requestAnimationFrame(function() {
        allNodes.forEach(function(n, i) { n.to({opacity: allOpac[i], duration: 0.25}); });
      });
    } else {
      layer.batchDraw();
    }

  }, [fd, pw, ph, selR, selF, props.selD, bw, bh, sbLeft, sbRight, sbFront, sbBack, plotShape, frontageList.join(','),
      props.wallDrawMode, props.wallBusy, props.onDrawWall, props.onDeleteWall]);

  /* ── Zoom API — exposed to the parent via props.zoomApiRef so the
   * +/–/fit buttons in app.html's .zoom-ctrl (previously plain no-op
   * <button> elements, PROMPT HH) can drive the same stage the wheel
   * handler above already controls. Reassigned every render (cheap,
   * idempotent) so fitToView always closes over the current pw/ph. */
  if (props.zoomApiRef) {
    props.zoomApiRef.current = {
      zoomIn: function() {
        var stage = stageRef.current; if (!stage) return;
        var s = stage.scaleX(), ns = Math.min(4, s * 1.25);
        var cx = stage.width() / 2, cy = stage.height() / 2;
        var mx = (cx - stage.x()) / s, my = (cy - stage.y()) / s;
        stage.scale({ x: ns, y: ns });
        stage.position({ x: cx - mx * ns, y: cy - my * ns });
      },
      zoomOut: function() {
        var stage = stageRef.current; if (!stage) return;
        var s = stage.scaleX(), ns = Math.max(0.25, s * 0.8);
        var cx = stage.width() / 2, cy = stage.height() / 2;
        var mx = (cx - stage.x()) / s, my = (cy - stage.y()) / s;
        stage.scale({ x: ns, y: ns });
        stage.position({ x: cx - mx * ns, y: cy - my * ns });
      },
      fitToView: function() {
        var stage = stageRef.current; if (!stage) return;
        var contW = stage.width() || 800, contH = stage.height() || 600;
        var contentW = pw * PX + 80, contentH = ph * PX + 80;
        var fitScale = Math.min(1, contW / contentW, contH / contentH);
        stage.scale({ x: fitScale, y: fitScale });
        stage.position({ x: (contW - contentW * fitScale) / 2, y: (contH - contentH * fitScale) / 2 });
      },
    };
  }

  /* Container div — Konva mounts its canvas here */
  return h('div', {
    ref: containerRef,
    style: { width:'100%', height:'100%', overflow:'hidden', background:'#fff' },
    onClick: function(e) {
      if (e.target === e.currentTarget) {
        if (props.setSelR) props.setSelR(null);
        if (props.setSelF) props.setSelF(null);
        if (props.setSelD) props.setSelD(null);
      }
    }
  });
}
