-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathindex.html
More file actions
1 lines (1 loc) · 153 KB
/
Copy pathindex.html
File metadata and controls
1 lines (1 loc) · 153 KB
1
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Adams Museum</title></head><body style="margin:0;overflow:hidden;background:#0a0a0a"><script type="__bundler/template">"<!DOCTYPE html>\n<html lang=\"en\"><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n<title>Adam's Gallery \u2014 Collection No. 01 / 2026<\/title>\n<style>\n :root{\n --ink:#16140f;\n --paper:#f6f4ee;\n --muted:#8a857a;\n --line:rgba(20,18,14,.14);\n --font:\"Helvetica Neue\",Helvetica,Arial,\"Inter\",system-ui,sans-serif;\n }\n *{box-sizing:border-box}\n html,body{margin:0;height:100%;background:#0c0c0d;overflow:hidden;font-family:var(--font);color:var(--ink);-webkit-font-smoothing:antialiased;}\n #app{position:fixed;inset:0}\n canvas{display:block}\n\n /* ---------- Crosshair ---------- */\n #crosshair{position:fixed;left:50%;top:50%;width:7px;height:7px;margin:-3.5px 0 0 -3.5px;border-radius:50%;\n background:rgba(255,255,255,.85);box-shadow:0 0 0 1.5px rgba(0,0,0,.25),0 0 8px rgba(0,0,0,.2);\n pointer-events:none;z-index:20;opacity:0;transition:opacity .35s, transform .25s, background .25s, box-shadow .25s;}\n #crosshair.show{opacity:.9}\n #crosshair.hot{transform:scale(1.9);background:rgba(255,255,255,1);box-shadow:0 0 0 1.5px rgba(0,0,0,.35),0 0 14px rgba(255,255,255,.55)}\n\n /* ---------- Hover label ---------- */\n #hoverlabel{position:fixed;left:50%;top:calc(50% + 26px);transform:translate(-50%,4px);z-index:21;pointer-events:none;\n opacity:0;transition:opacity .28s ease, transform .28s ease;text-align:center;}\n #hoverlabel.show{opacity:1;transform:translate(-50%,0)}\n #hoverlabel .t{display:inline-block;background:rgba(18,17,14,.62);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);\n color:#fff;padding:7px 14px;border-radius:2px;font-size:12.5px;letter-spacing:.14em;text-transform:uppercase;font-weight:500;\n border:1px solid rgba(255,255,255,.14);}\n #hoverlabel .sub{display:block;margin-top:7px;color:rgba(255,255,255,.72);font-size:11px;letter-spacing:.18em;text-transform:uppercase}\n\n /* ---------- HUD top ---------- */\n #hud{position:fixed;top:18px;left:20px;z-index:18;color:#fff;mix-blend-mode:difference;pointer-events:none;\n opacity:0;transition:opacity .6s;}\n #hud.show{opacity:1}\n #hud .brand{font-size:13px;letter-spacing:.26em;text-transform:uppercase;font-weight:600}\n #hud .meta{font-size:10.5px;letter-spacing:.22em;text-transform:uppercase;margin-top:4px;opacity:.7}\n\n /* ---------- Minimap ---------- */\n #map{position:fixed;top:16px;right:16px;z-index:18;width:178px;background:rgba(246,244,238,.92);\n backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid var(--line);border-radius:3px;\n padding:11px 11px 9px;box-shadow:0 12px 40px rgba(0,0,0,.28);opacity:0;transition:opacity .6s;transform:translateY(-4px)}\n #map.show{opacity:1;transform:none}\n #map .hd{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:7px}\n #map .hd b{font-size:9.5px;letter-spacing:.22em;text-transform:uppercase;font-weight:600}\n #map .hd span{font-size:9px;letter-spacing:.14em;color:#8a857a}\n #mapcanvas{display:block;width:100%;height:auto;border-radius:2px}\n #wings{display:flex;flex-direction:column;gap:4px;margin-top:9px}\n #wings button{font-family:var(--font);font-size:10px;letter-spacing:.16em;text-transform:uppercase;\n border:1px solid var(--line);background:transparent;color:var(--ink);padding:6px 8px;border-radius:2px;cursor:pointer;\n display:flex;justify-content:space-between;align-items:center;transition:background .18s,border-color .18s}\n #wings button:hover{background:rgba(20,18,14,.06)}\n #wings button .n{opacity:.45;font-size:9px}\n\n /* ---------- Sound toggle ---------- */\n #sound{position:fixed;bottom:18px;right:16px;z-index:18;display:flex;align-items:center;gap:9px;\n background:rgba(246,244,238,.92);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);\n border:1px solid var(--line);border-radius:40px;padding:8px 14px 8px 12px;cursor:pointer;\n font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--ink);box-shadow:0 8px 28px rgba(0,0,0,.22);\n opacity:0;transition:opacity .6s}\n #sound.show{opacity:1}\n #sound .dot{width:9px;height:9px;border-radius:50%;background:#c4bfb2;transition:background .25s, box-shadow .25s}\n #sound.on .dot{background:#3a7d52;box-shadow:0 0 0 4px rgba(58,125,82,.18)}\n\n /* ---------- Mouse-look toggle (sits above sound) ---------- */\n #mlook{position:fixed;bottom:54px;right:16px;z-index:18;display:flex;align-items:center;gap:9px;\n background:rgba(246,244,238,.92);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);\n border:1px solid var(--line);border-radius:40px;padding:8px 14px 8px 12px;cursor:pointer;\n font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--ink);box-shadow:0 8px 28px rgba(0,0,0,.22);\n opacity:0;transition:opacity .6s}\n #mlook.show{opacity:1}\n #mlook .dot{width:9px;height:9px;border-radius:50%;background:#c4bfb2;transition:background .25s, box-shadow .25s}\n #mlook.on .dot{background:#b06a2c;box-shadow:0 0 0 4px rgba(176,106,44,.18)}\n\n /* ---------- Help button + glossary ---------- */\n #helpbtn{position:fixed;bottom:18px;right:152px;z-index:18;width:34px;height:34px;border-radius:50%;\n background:rgba(246,244,238,.92);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);\n border:1px solid var(--line);cursor:pointer;color:var(--ink);font-size:14px;font-weight:600;\n display:flex;align-items:center;justify-content:center;box-shadow:0 8px 28px rgba(0,0,0,.22);\n opacity:0;transition:opacity .6s}\n #helpbtn.show{opacity:1}\n #glossary{position:fixed;inset:0;z-index:55;display:none;align-items:center;justify-content:center;\n background:rgba(10,10,12,.62);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);opacity:0;transition:opacity .3s}\n #glossary.open{display:flex}\n #glossary.in{opacity:1}\n #glossary .panel{background:var(--paper);color:var(--ink);border-radius:4px;padding:34px 40px 30px;max-width:430px;width:90%;\n box-shadow:0 40px 120px rgba(0,0,0,.5);transform:translateY(8px);transition:transform .35s cubic-bezier(.16,1,.3,1)}\n #glossary.in .panel{transform:none}\n #glossary h2{margin:0 0 4px;font-size:14px;letter-spacing:.22em;text-transform:uppercase;font-weight:600}\n #glossary .kick{font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:#8a857a;margin-bottom:22px}\n #glossary .row{display:flex;align-items:center;justify-content:space-between;padding:11px 0;border-top:1px solid var(--line);font-size:12.5px;letter-spacing:.02em}\n #glossary .row:first-of-type{border-top:none}\n #glossary .row .lab{color:#46433c}\n #glossary kbd{display:inline-block;border:1px solid var(--line);border-radius:4px;padding:3px 9px;margin-left:4px;\n font-family:var(--font);font-size:11px;background:#fff;letter-spacing:.04em}\n #glossary .close{margin-top:24px;width:100%;border:1px solid var(--line);background:transparent;cursor:pointer;\n font-family:var(--font);padding:12px;border-radius:3px;font-size:10.5px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink);transition:background .2s}\n #glossary .close:hover{background:rgba(20,18,14,.06)}\n\n /* ---------- Guard greeting toast ---------- */\n #greet{position:fixed;top:84px;left:50%;z-index:19;pointer-events:none;\n transform:translateX(-50%) translateY(-8px);opacity:0;transition:opacity .5s, transform .5s;text-align:center}\n #greet.show{opacity:1;transform:translateX(-50%) translateY(0)}\n #greet .b{display:inline-block;background:rgba(18,17,14,.7);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);\n color:#f6f4ee;padding:12px 22px;border-radius:3px;border:1px solid rgba(255,255,255,.14)}\n #greet .b .n{font-size:9.5px;letter-spacing:.28em;text-transform:uppercase;color:rgba(246,244,238,.55);display:block;margin-bottom:5px}\n #greet .b .m{font-size:13.5px;letter-spacing:.02em}\n\n /* ---------- Controls hint ---------- */\n #hint{position:fixed;bottom:18px;left:20px;z-index:18;color:#fff;mix-blend-mode:difference;pointer-events:none;\n font-size:10.5px;letter-spacing:.2em;text-transform:uppercase;opacity:0;transition:opacity .6s;line-height:1.9}\n #hint.show{opacity:.78}\n #hint kbd{font-family:var(--font);border:1px solid rgba(255,255,255,.5);border-radius:3px;padding:1px 6px;margin:0 1px;font-size:10px}\n /* ---- Mobile joysticks ---- */\n .jpad{position:fixed;bottom:38px;width:108px;height:108px;border-radius:50%;background:rgba(255,255,255,0.10);border:2px solid rgba(255,255,255,0.22);touch-action:none;display:none;z-index:200;pointer-events:auto;backdrop-filter:blur(4px)}\n .jpad.show{display:block}\n #jleft{left:28px}\n #jright{right:28px}\n .jpad-knob{position:absolute;width:46px;height:46px;border-radius:50%;background:rgba(255,255,255,0.30);border:2px solid rgba(255,255,255,0.55);top:50%;left:50%;transform:translate(-50%,-50%);pointer-events:none;transition:background .1s}\n .jpad:active .jpad-knob{background:rgba(255,255,255,0.45)}\n .jpad-label{position:absolute;bottom:-22px;left:50%;transform:translateX(-50%);font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:rgba(255,255,255,.45);pointer-events:none;white-space:nowrap}\n body.mobile-mode #mlook,body.mobile-mode #helpbtn{display:none}\n @media(max-height:600px){#brochure .card{max-height:92vh;overflow-y:auto}}\n\n /* ---------- Contextual prompt (sit on bench) ---------- */\n #sitprompt{position:fixed;left:50%;bottom:80px;transform:translateX(-50%) translateY(6px);z-index:19;pointer-events:none;\n background:rgba(18,17,14,.62);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);\n color:#f6f4ee;padding:9px 18px;border-radius:40px;border:1px solid rgba(255,255,255,.14);\n font-size:11.5px;letter-spacing:.14em;text-transform:uppercase;opacity:0;transition:opacity .25s, transform .25s}\n #sitprompt.show{opacity:1;transform:translateX(-50%) translateY(0)}\n #sitprompt kbd{font-family:var(--font);border:1px solid rgba(246,244,238,.5);border-radius:3px;padding:1px 7px;margin:0 2px;font-size:11px}\n\n /* ---------- Lightbox ---------- */\n #lightbox{position:fixed;inset:0;z-index:40;display:none;align-items:center;justify-content:center;\n background:rgba(8,8,10,.74);backdrop-filter:blur(22px) saturate(.9);-webkit-backdrop-filter:blur(22px) saturate(.9);opacity:0;transition:opacity .4s}\n #lightbox.open{display:flex}\n #lightbox.in{opacity:1}\n #lbframe{position:relative;max-width:min(92vw,1320px);transform:scale(.965);transition:transform .45s cubic-bezier(.16,1,.3,1);\n display:flex;flex-direction:row;align-items:flex-start;gap:24px}\n #lightbox.in #lbframe{transform:none}\n #lbphoto-wrap{position:relative;display:flex;flex-direction:column;align-items:center;flex-shrink:0}\n #lbimg{max-height:78vh;max-width:min(72vw,900px);background:#fff;padding:14px;border-radius:2px;\n box-shadow:0 40px 120px rgba(0,0,0,.6);display:block;\n -webkit-touch-callout:none;-webkit-user-select:none;user-select:none;\n pointer-events:none}\n #lbimg-shield{position:absolute;inset:0;z-index:2;cursor:default}\n #lbskel{position:absolute;inset:14px;background:linear-gradient(110deg,#e9e7e1 30%,#f3f1ec 50%,#e9e7e1 70%);\n background-size:200% 100%;animation:shimmer 1.3s infinite;border-radius:2px;pointer-events:none;opacity:1;transition:opacity .35s}\n @keyframes shimmer{to{background-position:-200% 0}}\n #lbcap{margin-top:16px;text-align:center;color:#f6f4ee}\n #lbcap .ti{font-size:15px;letter-spacing:.04em;font-weight:500}\n #lbcap .me{font-size:10.5px;letter-spacing:.26em;text-transform:uppercase;color:rgba(246,244,238,.6);margin-top:7px}\n #lbclose{position:absolute;top:-14px;right:-14px;width:42px;height:42px;border-radius:50%;border:1px solid rgba(255,255,255,.25);\n background:rgba(20,20,22,.7);color:#fff;cursor:pointer;font-size:18px;line-height:1;display:flex;align-items:center;justify-content:center;\n transition:background .2s,transform .2s}\n #lbclose:hover{background:rgba(40,40,44,.95);transform:rotate(90deg)}\n #lbnav{position:absolute;top:50%;display:flex;width:calc(100% + 120px);left:-60px;justify-content:space-between;transform:translateY(-50%);pointer-events:none}\n #lbnav button{pointer-events:auto;width:46px;height:46px;border-radius:50%;border:1px solid rgba(255,255,255,.2);\n background:rgba(20,20,22,.55);color:#fff;cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center;transition:background .2s}\n #lbnav button:hover{background:rgba(40,40,44,.9)}\n #lbhint{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);color:rgba(246,244,238,.5);\n font-size:10px;letter-spacing:.2em;text-transform:uppercase;z-index:41;pointer-events:none}\n /* caption panel */\n #lbcaption-panel{width:260px;min-width:220px;flex-shrink:0;padding:28px 22px 28px 22px;\n background:rgba(245,241,232,0.06);border:1px solid rgba(246,244,238,0.13);\n border-radius:3px;color:#f6f4ee;align-self:stretch;display:none;overflow:hidden}\n #lbcaption-panel.visible{display:flex;flex-direction:column}\n #lbcp-id{font-size:10.5px;letter-spacing:.3em;text-transform:uppercase;color:rgba(246,244,238,.4);margin-bottom:10px}\n #lbcp-title{font-size:21px;font-weight:600;line-height:1.2;letter-spacing:-.01em;margin-bottom:6px;color:#f6f4ee}\n #lbcp-medium{font-size:12px;font-style:italic;color:rgba(246,244,238,.48);margin-bottom:18px;letter-spacing:.02em}\n #lbcp-divider{height:1px;background:rgba(246,244,238,.13);margin-bottom:18px;flex-shrink:0}\n #lbcp-body{font-size:13px;line-height:1.75;font-style:italic;color:rgba(246,244,238,.72);letter-spacing:.01em}\n @media(max-width:820px){\n #lbframe{flex-direction:column;align-items:center;max-width:min(92vw,600px)}\n #lbimg{max-width:100%}\n #lbcaption-panel{width:100%;align-self:auto}\n }\n\n /* ---------- Start / pause overlay ---------- */\n #gate{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;\n background:radial-gradient(120% 120% at 50% 30%, #1b1a17 0%, #0b0b0c 70%);color:#f6f4ee;transition:opacity .6s}\n #gate.hide{opacity:0;pointer-events:none}\n #gate .card{text-align:center;max-width:560px;padding:0 28px;transform:translateY(0)}\n #gate .kick{font-size:11px;letter-spacing:.4em;text-transform:uppercase;color:rgba(246,244,238,.5);margin-bottom:22px}\n #gate h1{font-size:clamp(30px,6vw,58px);font-weight:300;letter-spacing:-.01em;margin:0;line-height:1.04}\n #gate h1 b{font-weight:600}\n #gate .sub{margin-top:18px;font-size:13.5px;line-height:1.7;color:rgba(246,244,238,.66);letter-spacing:.02em}\n #gate .keys{margin:30px auto 0;display:flex;gap:26px;justify-content:center;flex-wrap:wrap;color:rgba(246,244,238,.78)}\n #gate .keys div{font-size:10.5px;letter-spacing:.18em;text-transform:uppercase}\n #gate .keys b{display:block;margin-bottom:8px;font-size:11px;letter-spacing:.06em}\n #gate kbd{display:inline-block;border:1px solid rgba(246,244,238,.35);border-radius:4px;padding:3px 8px;margin:2px;font-family:var(--font);font-size:11px}\n #enter{margin-top:40px;display:inline-flex;align-items:center;gap:12px;cursor:pointer;\n border:1px solid rgba(246,244,238,.4);background:transparent;color:#f6f4ee;font-family:var(--font);\n padding:15px 34px;border-radius:40px;font-size:12px;letter-spacing:.26em;text-transform:uppercase;\n transition:background .25s,border-color .25s,letter-spacing .25s}\n #enter:hover{background:#f6f4ee;color:#16140f;border-color:#f6f4ee;letter-spacing:.32em}\n #gate .load{margin-top:24px;font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:rgba(246,244,238,.4);height:12px}\n\n #err{position:fixed;inset:0;z-index:80;display:none;align-items:center;justify-content:center;background:#0b0b0c;color:#f6f4ee;text-align:center;padding:30px}\n #err.show{display:flex}\n #err div{max-width:440px;font-size:14px;line-height:1.7;letter-spacing:.02em}\n\n @media (max-width:640px){\n #map{width:140px} #wings button{font-size:9px}\n #gate .keys{gap:16px}\n }\n\n /* ---------- Welcome brochure ---------- */\n #brochure{position:fixed;inset:0;z-index:65;display:none;align-items:center;justify-content:center;\n background:rgba(8,8,10,.78);backdrop-filter:blur(18px);-webkit-backdrop-filter:blur(18px);opacity:0;transition:opacity .45s}\n #brochure.open{display:flex}\n #brochure.in{opacity:1}\n #brochure .card{background:var(--paper);color:var(--ink);border-radius:4px;max-width:520px;width:90%;\n box-shadow:0 40px 120px rgba(0,0,0,.55);transform:translateY(14px);transition:transform .5s cubic-bezier(.16,1,.3,1);\n overflow:hidden}\n #brochure.in .card{transform:none}\n #brochure .bh{background:#1b1a17;color:#f6f4ee;padding:28px 32px 24px;text-align:center}\n #brochure .bh .kick{font-size:9.5px;letter-spacing:.42em;text-transform:uppercase;opacity:.55;margin-bottom:14px}\n #brochure .bh h2{font-size:clamp(22px,5vw,32px);font-weight:300;letter-spacing:-.01em;margin:0 0 6px}\n #brochure .bh h2 b{font-weight:600}\n #brochure .bh .line{width:40px;height:1px;background:rgba(246,244,238,.3);margin:16px auto 0}\n #brochure .bb{padding:26px 32px 28px}\n #brochure .wings{display:flex;flex-direction:column;gap:0;margin-bottom:22px}\n #brochure .wing-row{display:flex;gap:14px;padding:11px 0;border-bottom:1px solid var(--line);align-items:flex-start}\n #brochure .wing-row:first-child{border-top:1px solid var(--line)}\n #brochure .wing-num{font-size:9px;letter-spacing:.28em;text-transform:uppercase;color:#8a857a;min-width:52px;padding-top:2px}\n #brochure .wing-txt .wn{font-size:13px;font-weight:600;letter-spacing:.04em;margin-bottom:3px}\n #brochure .wing-txt .wd{font-size:12px;color:#6a6660;line-height:1.55;letter-spacing:.01em}\n #brochure .note{font-size:12.5px;line-height:1.65;color:#46433c;letter-spacing:.01em;margin-bottom:24px;\n padding:14px 16px;background:rgba(20,18,14,.04);border-left:2px solid #b06a2c;border-radius:1px}\n #brochure .note cite{display:block;margin-top:10px;font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;color:#8a857a;font-style:normal}\n #brochure .benter{width:100%;border:1px solid #1b1a17;background:#1b1a17;color:#f6f4ee;cursor:pointer;\n font-family:var(--font);padding:14px;border-radius:3px;font-size:10.5px;letter-spacing:.28em;text-transform:uppercase;transition:background .2s,color .2s}\n #brochure .benter:hover{background:transparent;color:#1b1a17}\n\n /* ---------- Contact form ---------- */\n #contactoverlay{position:fixed;inset:0;z-index:62;display:none;align-items:center;justify-content:center;\n background:rgba(8,8,10,.72);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);opacity:0;transition:opacity .35s}\n #contactoverlay.open{display:flex}\n #contactoverlay.in{opacity:1}\n #contactoverlay .cp{background:var(--paper);color:var(--ink);border-radius:4px;max-width:440px;width:90%;\n padding:32px 36px 28px;box-shadow:0 40px 120px rgba(0,0,0,.5);transform:translateY(10px);transition:transform .4s cubic-bezier(.16,1,.3,1)}\n #contactoverlay.in .cp{transform:none}\n #contactoverlay h3{margin:0 0 4px;font-size:13px;letter-spacing:.22em;text-transform:uppercase;font-weight:600}\n #contactoverlay .ck{font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:#8a857a;margin-bottom:22px}\n #contactoverlay label{display:block;font-size:10.5px;letter-spacing:.18em;text-transform:uppercase;color:#6a6660;margin-bottom:6px;margin-top:16px}\n #contactoverlay label:first-of-type{margin-top:0}\n #contactoverlay input,#contactoverlay textarea{width:100%;border:1px solid var(--line);background:#fff;\n font-family:var(--font);font-size:13px;padding:10px 12px;border-radius:2px;outline:none;color:var(--ink);\n transition:border-color .18s}\n #contactoverlay input:focus,#contactoverlay textarea:focus{border-color:#b06a2c}\n #contactoverlay textarea{height:100px;resize:vertical}\n #contactoverlay .cbtns{display:flex;gap:10px;margin-top:22px}\n #contactoverlay .csend{flex:1;border:1px solid #1b1a17;background:#1b1a17;color:#f6f4ee;cursor:pointer;\n font-family:var(--font);padding:12px;border-radius:3px;font-size:10.5px;letter-spacing:.24em;text-transform:uppercase;transition:background .2s,color .2s}\n #contactoverlay .csend:hover{background:transparent;color:#1b1a17}\n #contactoverlay .csend:disabled{opacity:.5;cursor:default}\n #contactoverlay .ccancel{border:1px solid var(--line);background:transparent;cursor:pointer;\n font-family:var(--font);padding:12px 18px;border-radius:3px;font-size:10.5px;letter-spacing:.2em;text-transform:uppercase;color:var(--ink);transition:background .2s}\n #contactoverlay .ccancel:hover{background:rgba(20,18,14,.06)}\n #contactoverlay .cmsg{font-size:11.5px;margin-top:12px;color:#3a7d52;letter-spacing:.02em;min-height:16px}\n #contactoverlay .cmsg.err{color:#c0392b}\n #contactoverlay .cctx{font-size:11.5px;background:rgba(176,106,44,.1);border-left:2px solid #b06a2c;\n padding:9px 12px;border-radius:1px;color:#46433c;letter-spacing:.01em;margin-bottom:4px;display:none}\n #contactoverlay .cctx.show{display:block}\n\n /* ---------- Lightbox contact button ---------- */\n #lbcontact{margin-top:14px;border:1px solid rgba(246,244,238,.3);background:transparent;color:rgba(246,244,238,.78);\n cursor:pointer;font-family:var(--font);padding:9px 22px;border-radius:40px;font-size:10.5px;letter-spacing:.2em;\n text-transform:uppercase;transition:background .2s,color .2s,border-color .2s}\n #lbcontact:hover{background:rgba(246,244,238,.14);border-color:rgba(246,244,238,.55);color:#f6f4ee}\n<\/style>\n<\/head>\n<body>\n\n<div id=\"app\"><\/div>\n\n<div id=\"crosshair\"><\/div>\n<div id=\"hoverlabel\"><span class=\"t\" id=\"hl-t\">Frame<\/span><span class=\"sub\" id=\"hl-s\"><\/span><\/div>\n\n<div id=\"hud\">\n <div class=\"brand\">Adam's Gallery<\/div>\n <div class=\"meta\">Collection No. 01 / 2026 \u2014 Light & People<\/div>\n<\/div>\n\n<div id=\"map\">\n <div class=\"hd\"><b>Floor Plan<\/b><span id=\"map-here\">Entrance<\/span><\/div>\n <canvas id=\"mapcanvas\" width=\"312\" height=\"416\"><\/canvas>\n <div id=\"wings\">\n <button data-wing=\"street\"><span>Street<\/span><span class=\"n\">28<\/span><\/button>\n <button data-wing=\"portraits\"><span>Portraits<\/span><span class=\"n\">16<\/span><\/button>\n <button data-wing=\"places\"><span>Places<\/span><span class=\"n\">45<\/span><\/button>\n <\/div>\n<\/div>\n\n<div id=\"sound\"><span class=\"dot\"><\/span><span id=\"sound-txt\">Ambience Off<\/span><\/div>\n<div id=\"mlook\"><span class=\"dot\"><\/span><span id=\"mlook-txt\">Free Look<\/span><span style=\"font-size:10px;opacity:.45;margin-left:6px\">[F]<\/span><\/div>\n<button id=\"helpbtn\" aria-label=\"Controls\">?<\/button>\n\n<div id=\"greet\"><div class=\"b\"><span class=\"n\">Gallery Attendant<\/span><span class=\"m\">Welcome to Adam's Gallery \u2014 enjoy the collection.<\/span><\/div><\/div>\n\n<div id=\"glossary\">\n <div class=\"panel\">\n <h2>Controls<\/h2>\n <div class=\"kick\">Adam's Gallery \u2014 Collection 01<\/div>\n <div class=\"row\"><span class=\"lab\">Walk<\/span><span><kbd>W<\/kbd><kbd>A<\/kbd><kbd>S<\/kbd><kbd>D<\/kbd> / <kbd>\u2191<\/kbd><kbd>\u2190<\/kbd><kbd>\u2193<\/kbd><kbd>\u2192<\/kbd><\/span><\/div>\n <div class=\"row\"><span class=\"lab\">Look around<\/span><span>Drag the mouse<\/span><\/div>\n <div class=\"row\"><span class=\"lab\">View a work<\/span><span>Click a photo<\/span><\/div>\n <div class=\"row\"><span class=\"lab\">Hands-free look<\/span><span>Click <kbd>Free Look<\/kbd> or press <kbd>F<\/kbd><\/span><\/div>\n <div class=\"row\"><span class=\"lab\">This panel<\/span><span><kbd>H<\/kbd><\/span><\/div>\n <div class=\"row\"><span class=\"lab\">Pause / close<\/span><span><kbd>Esc<\/kbd><\/span><\/div>\n <button class=\"close\" id=\"glossclose\">Close<\/button>\n <\/div>\n<\/div>\n\n<div id=\"sitprompt\">Press <kbd>E<\/kbd> to sit<\/div>\n\n<div id=\"hint\">\n <div><kbd>W<\/kbd><kbd>A<\/kbd><kbd>S<\/kbd><kbd>D<\/kbd> Walk \u00b7 Mouse Look<\/div>\n <div>Look at a work & <kbd>Click<\/kbd> to view \u00b7 <kbd>Esc<\/kbd> Pause<\/div>\n<\/div>\n\n<div id=\"lightbox\">\n <div id=\"lbframe\">\n <div id=\"lbphoto-wrap\">\n <button id=\"lbclose\" aria-label=\"Close\">\u2715<\/button>\n <div id=\"lbnav\"><button id=\"lbprev\" aria-label=\"Previous\">\u2039<\/button><button id=\"lbnext\" aria-label=\"Next\">\u203a<\/button><\/div>\n <div style=\"position:relative\"><img id=\"lbimg\" alt=\"\"><div id=\"lbskel\"><\/div><div id=\"lbimg-shield\"><\/div><\/div>\n <div id=\"lbcap\"><div class=\"ti\" id=\"lbti\">Untitled<\/div><div class=\"me\" id=\"lbme\"><\/div><\/div>\n <button id=\"lbcontact\">Contact Adam about this photo<\/button>\n <\/div>\n <div id=\"lbcaption-panel\">\n <div id=\"lbcp-id\"><\/div>\n <div id=\"lbcp-title\"><\/div>\n <div id=\"lbcp-medium\"><\/div>\n <div id=\"lbcp-divider\"><\/div>\n <div id=\"lbcp-body\"><\/div>\n <\/div>\n <\/div>\n <div id=\"lbhint\">Click anywhere or press Esc to return to the gallery<\/div>\n<\/div>\n\n<div id=\"gate\">\n <div class=\"card\">\n <div class=\"kick\">Film Photography \u00b7 89 Works<\/div>\n <h1>Adam's Gallery<br><b>Collection 01<\/b><\/h1>\n <div class=\"sub\">A first-person walkthrough of three wings \u2014<br>Street, Portraits & Places.<\/div>\n <div class=\"keys\">\n <div><b>Move<\/b><kbd>W<\/kbd><kbd>A<\/kbd><kbd>S<\/kbd><kbd>D<\/kbd><\/div>\n <div><b>Look<\/b>Mouse<\/div>\n <div><b>View a work<\/b>Click<\/div>\n <\/div>\n <button id=\"enter\">Enter the Gallery<\/button>\n <div class=\"load\" id=\"load\">Preparing the rooms\u2026<\/div>\n <\/div>\n<\/div>\n\n<div id=\"err\"><div>This experience needs WebGL and network access to load. Please open it in an up-to-date browser with hardware acceleration enabled.<\/div><\/div>\n\n<!-- Welcome brochure \u2014 shown once on first entry -->\n<div id=\"brochure\">\n <div class=\"card\">\n <div class=\"bh\">\n <div class=\"kick\">Film Photography \u00b7 2026<\/div>\n <h2>Adam's Gallery<br><b>Collection 01<\/b><\/h2>\n <div style=\"font-size:12px;opacity:.6;letter-spacing:.08em;margin-top:8px\">Light & People<\/div>\n <div class=\"line\"><\/div>\n <\/div>\n <div class=\"bb\">\n <div class=\"wings\">\n <div class=\"wing-row\">\n <span class=\"wing-num\">Wing I<\/span>\n <div class=\"wing-txt\"><div class=\"wn\">Street<\/div><div class=\"wd\">Twenty-eight fragments of city life \u2014 the accidental geometry of people moving through shared space.<\/div><\/div>\n <\/div>\n <div class=\"wing-row\">\n <span class=\"wing-num\">Wing II<\/span>\n <div class=\"wing-txt\"><div class=\"wn\">Portraits<\/div><div class=\"wd\">Sixteen studies of stillness \u2014 faces caught between moments, each frame a quiet negotiation between subject and lens.<\/div><\/div>\n <\/div>\n <div class=\"wing-row\">\n <span class=\"wing-num\">Wing III<\/span>\n <div class=\"wing-txt\"><div class=\"wn\">Places<\/div><div class=\"wd\">Forty-five photographs of the world standing still \u2014 light on walls, empty chairs, rooms that hold their breath.<\/div><\/div>\n <\/div>\n <\/div>\n <div class=\"note\">\n \"These photographs were taken all over, capturing various stages of life. I used a variety of film cameras, and for the most part, developed and scanned my own film. I had no plans - what you see is simply what the light allowed.\"\n <cite>\u2014 Adam<\/cite>\n <\/div>\n <button class=\"benter\" id=\"brochure-enter\">Step Inside the Gallery<\/button>\n <\/div>\n <\/div>\n<\/div>\n\n<!-- Contact form overlay -->\n<div id=\"contactoverlay\">\n <div class=\"cp\">\n <h3>Write to Adam<\/h3>\n <div class=\"ck\">Adam's Gallery \u00b7 Collection 01 / 2026<\/div>\n <div class=\"cctx\" id=\"cf-context\"><\/div>\n <label for=\"cf-name\">Your name<\/label>\n <input type=\"text\" id=\"cf-name\" placeholder=\"Your name\" autocomplete=\"name\">\n <label for=\"cf-email\">Email address<\/label>\n <input type=\"email\" id=\"cf-email\" placeholder=\"you@example.com\" autocomplete=\"email\" required>\n <label for=\"cf-msg\">Message<\/label>\n <textarea id=\"cf-msg\" placeholder=\"What would you like to say?\"><\/textarea>\n <div class=\"cbtns\">\n <button class=\"csend\" id=\"cf-send\">Send Message<\/button>\n <button class=\"ccancel\" id=\"cf-cancel\">Cancel<\/button>\n <\/div>\n <div class=\"cmsg\" id=\"cf-status\"><\/div>\n <\/div>\n<\/div>\n\n<script src=\"gallery-data.js\"><\/script>\n<script type=\"importmap\">\n{\n \"imports\": {\n \"three\": \"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js\",\n \"three/addons/\": \"https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/\"\n }\n}\n<\/script>\n\n<script type=\"module\">\n/* ============================================================================\n ADAM'S GALLERY \u2014 3D Virtual Museum\n Pure WebGL (Three.js) + HTML/CSS chrome.\n\n \u25ba IMAGE SOURCE / WIX CONNECTION\n Photos are placeholders (Lorem Picsum). To wire this to your Wix site,\n replace the two functions below \u2014 thumbURL() / fullURL() \u2014 to return the\n URL of each artwork from your Wix Media / data collection, keyed by\n art.id (e.g. \"P01\") or art.seed. Everything else stays the same.\n ============================================================================ */\n\nlet THREE, PointerLockControls;\ntry {\n THREE = await import('three');\n ({ PointerLockControls } = await import('three/addons/controls/PointerLockControls.js'));\n} catch (e) {\n document.getElementById('err').classList.add('show');\n throw e;\n}\n\n/* ---------- IMAGE CONFIG \u2014 Wix media (static.wixstatic.com) ----------\n Photos come from the user's Wix site \"Adam's Gallery\" (Store catalog).\n Each art carries a Wix mediaId; we build resized, cross-origin-friendly URLs.\n To refresh the set, re-run the Wix connector to regenerate gallery-data.js. */\nfunction wixURL(art, w, h, q){\n // Wix on-the-fly transform: fit within w\u00d7h, keep aspect, no crop.\n return `https://static.wixstatic.com/media/${art.mediaId}/v1/fit/w_${w},h_${h},q_${q||85},enc_auto/file.jpg`;\n}\nfunction thumbURL(art){\n // wall texture \u2014 sized to the frame's on-wall footprint\n const w = Math.min(1280, Math.round((art.fw||1.4)*620));\n const h = Math.min(1280, Math.round((art.fh||1.4)*620));\n return wixURL(art, w, h, 82);\n}\nfunction fullURL(art){ return wixURL(art, 2000, 2000, 90); }\n\n/* ============================== SCENE SETUP ============================== */\nconst app = document.getElementById('app');\nconst renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:'high-performance', preserveDrawingBuffer:true });\nrenderer.setSize(innerWidth, innerHeight);\nrenderer.setPixelRatio(Math.min(devicePixelRatio, 2));\nrenderer.shadowMap.enabled = true;\nrenderer.shadowMap.type = THREE.PCFSoftShadowMap;\nrenderer.outputColorSpace = THREE.SRGBColorSpace;\nrenderer.toneMapping = THREE.ACESFilmicToneMapping;\nrenderer.toneMappingExposure = 1.06;\napp.appendChild(renderer.domElement);\n\nconst scene = new THREE.Scene();\nscene.background = new THREE.Color('#eceae3');\nscene.fog = new THREE.Fog('#eceae3', 46, 120);\n\nconst camera = new THREE.PerspectiveCamera(62, innerWidth/innerHeight, 0.1, 400);\nconst EYE_Y = 1.68;\ncamera.position.set(0, EYE_Y, 44);\n\n/* ============================== LIGHTING (bright MoMA daylight) ========== */\nscene.add(new THREE.HemisphereLight('#ffffff', '#d9d6cc', 1.05));\nscene.add(new THREE.AmbientLight('#ffffff', 0.34));\n\nconst sun = new THREE.DirectionalLight('#fff7ec', 0.92);\nsun.position.set(26, 52, 30);\nsun.castShadow = true;\nsun.shadow.mapSize.set(2048, 2048);\nsun.shadow.camera.near = 1; sun.shadow.camera.far = 180;\nconst S = 70;\nsun.shadow.camera.left=-S; sun.shadow.camera.right=S; sun.shadow.camera.top=S; sun.shadow.camera.bottom=-S;\nsun.shadow.bias = -0.0004;\nsun.shadow.normalBias = 0.05;\nsun.shadow.radius = 3;\nscene.add(sun);\n// soft fill from opposite side (no shadow)\nconst fill = new THREE.DirectionalLight('#eaf0ff', 0.28);\nfill.position.set(-30, 30, -20);\nscene.add(fill);\n\n/* ============================== MATERIALS / GEOMETRY ===================== */\nconst HALF_W = 11; // room half-width (x)\nconst WALL_H = 6.2; // wall height\nconst WALL_T = 0.34; // wall thickness\nconst Z_SOUTH = 48; // back wall of entrance\nconst Z_NORTH = -24; // far wall of Places\n\nconst wallMat = new THREE.MeshStandardMaterial({ color:'#f4f2ec', roughness:0.94, metalness:0 });\nconst partMat = new THREE.MeshStandardMaterial({ color:'#efece4', roughness:0.95, metalness:0 });\nconst trimMat = new THREE.MeshStandardMaterial({ color:'#e7e3d8', roughness:0.9 });\n\nconst colliders = []; // {x1,z1,x2,z2,half}\nfunction addWall(x1,z1,x2,z2,{h=WALL_H,t=WALL_T,mat=wallMat,collide=true,y=h/2}={}){\n const dx=x2-x1, dz=z2-z1, len=Math.hypot(dx,dz);\n const geo = new THREE.BoxGeometry(len, h, t);\n const m = new THREE.Mesh(geo, mat);\n m.position.set((x1+x2)/2, y, (z1+z2)/2);\n m.rotation.y = -Math.atan2(dz, dx);\n m.castShadow = true; m.receiveShadow = true;\n scene.add(m);\n if(collide) colliders.push({x1,z1,x2,z2,half:t/2});\n return m;\n}\n\n/* ----- outer shell ----- */\naddWall(-HALF_W, Z_SOUTH, -HALF_W, Z_NORTH); // west\naddWall( HALF_W, Z_SOUTH, HALF_W, Z_NORTH); // east\naddWall(-HALF_W, Z_SOUTH, HALF_W, Z_SOUTH); // south (entrance back)\naddWall(-HALF_W, Z_NORTH, HALF_W, Z_NORTH); // north (places back)\n\n/* ----- cross walls with central 5m doorway ----- */\nconst DOOR = 2.6;\nfunction crossWall(z){\n addWall(-HALF_W, z, -DOOR, z);\n addWall( DOOR, z, HALF_W, z);\n // lintel above doorway (visual only)\n const lin = new THREE.Mesh(new THREE.BoxGeometry(DOOR*2, WALL_H-3.4, WALL_T), trimMat);\n lin.position.set(0, 3.4+(WALL_H-3.4)/2, z); lin.castShadow=true; lin.receiveShadow=true; scene.add(lin);\n}\n[36, 18, 4].forEach(crossWall);\n\n/* ----- floor ----- */\nconst floorTex = makeFloorTexture();\nfloorTex.wrapS = floorTex.wrapT = THREE.RepeatWrapping;\nfloorTex.repeat.set(11, 36);\nfloorTex.anisotropy = renderer.capabilities.getMaxAnisotropy();\nfloorTex.colorSpace = THREE.SRGBColorSpace;\nconst floor = new THREE.Mesh(\n new THREE.PlaneGeometry(HALF_W*2, Z_SOUTH-Z_NORTH),\n new THREE.MeshStandardMaterial({ map:floorTex, roughness:0.4, metalness:0.04, color:'#e9e4d8' })\n);\nfloor.rotation.x = -Math.PI/2;\nfloor.position.set(0, 0, (Z_SOUTH+Z_NORTH)/2);\nfloor.receiveShadow = true;\nscene.add(floor);\n\n/* ----- ceiling + skylight panels ----- */\nconst ceil = new THREE.Mesh(\n new THREE.PlaneGeometry(HALF_W*2, Z_SOUTH-Z_NORTH),\n new THREE.MeshStandardMaterial({ color:'#f7f6f1', roughness:1 })\n);\nceil.rotation.x = Math.PI/2;\nceil.position.set(0, WALL_H, (Z_SOUTH+Z_NORTH)/2);\nscene.add(ceil);\n// glowing skylight strips\nconst skyMat = new THREE.MeshStandardMaterial({ color:'#ffffff', emissive:'#fffaf0', emissiveIntensity:0.85, roughness:1 });\nfor(let z=Z_NORTH+4; z<Z_SOUTH; z+=8){\n for(const x of [-5.5, 5.5]){\n const p = new THREE.Mesh(new THREE.PlaneGeometry(5.4, 1.7), skyMat);\n p.rotation.x = Math.PI/2; p.position.set(x, WALL_H-0.02, z);\n scene.add(p);\n }\n}\n// baseboard trims along side walls\nfor(const x of [-HALF_W+WALL_T/2, HALF_W-WALL_T/2]){\n const bb = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.16, Z_SOUTH-Z_NORTH), trimMat);\n bb.position.set(x, 0.08, (Z_SOUTH+Z_NORTH)/2); scene.add(bb);\n}\n\n/* ============================== HALLS + PARTITIONS ======================= */\nconst WIX = window.GALLERY_WIX || { counts:{portraits:30,street:30,places:29}, photos:[] };\nconst PHOTOS_BY_WING = {\n portraits: WIX.photos.filter(p=>p.wingKey==='portraits'),\n street: WIX.photos.filter(p=>p.wingKey==='street'),\n places: WIX.photos.filter(p=>p.wingKey==='places'),\n};\nconst HALLS = [\n { key:'street', name:'Street', prefix:'S', z0:18, z1:36, quota:WIX.counts.street, teleport:33 },\n { key:'portraits', name:'Portraits', prefix:'P', z0:4, z1:18, quota:WIX.counts.portraits, teleport:11 },\n { key:'places', name:'Places', prefix:'L', z0:-24, z1:4, quota:WIX.counts.places, teleport:-10, northEnd:true },\n];\n\n// central partitions (double-sided hanging walls)\nfor(const h of HALLS){\n const pa = h.z0+4.5, pb = h.z1-4.5;\n addWall(0, pa, 0, pb, { h:4.4, t:0.4, mat:partMat });\n}\n\n/* ============================== ARTWORK PLACEMENT ======================== */\nconst EYE = 2.02;\nconst MARGIN = 1.25;\nconst SPACING_MAX = 2.7, SPACING_MIN = 1.95; // per-wing adaptive hang density\n\nfunction buildFaces(h){\n const faces = [];\n // side walls (full length)\n faces.push({ axis:'z', fixed:-HALF_W+WALL_T/2+0.02, nx:1, nz:0, a:h.z0, b:h.z1 });\n faces.push({ axis:'z', fixed: HALF_W-WALL_T/2-0.02, nx:-1, nz:0, a:h.z0, b:h.z1 });\n // partition both faces\n const pa=h.z0+4.5, pb=h.z1-4.5;\n faces.push({ axis:'z', fixed:-0.22, nx:-1, nz:0, a:pa, b:pb });\n faces.push({ axis:'z', fixed: 0.22, nx:1, nz:0, a:pa, b:pb });\n // south cross wall (z=z1) faces toward interior (-z), two segments\n faces.push({ axis:'x', fixed:h.z1-WALL_T/2-0.02, nx:0, nz:-1, a:-HALF_W, b:-DOOR });\n faces.push({ axis:'x', fixed:h.z1-WALL_T/2-0.02, nx:0, nz:-1, a:DOOR, b:HALF_W });\n // north wall (z=z0) faces +z\n if(h.northEnd){\n faces.push({ axis:'x', fixed:h.z0+WALL_T/2+0.02, nx:0, nz:1, a:-HALF_W, b:HALF_W });\n } else {\n faces.push({ axis:'x', fixed:h.z0+WALL_T/2+0.02, nx:0, nz:1, a:-HALF_W, b:-DOOR });\n faces.push({ axis:'x', fixed:h.z0+WALL_T/2+0.02, nx:0, nz:1, a:DOOR, b:HALF_W });\n }\n return faces;\n}\nconst capAt = (f, sp) => Math.max(0, Math.floor((Math.abs(f.b-f.a)-2*MARGIN)/sp)+1);\n\n// frame sizing from a photo's real aspect ratio (fit within a bounding box)\nconst FRAME_H_MAX = 1.6, FRAME_W_MAX = 1.78;\nfunction frameSize(w, h){\n const a = (w && h) ? w/h : 1.3;\n let fh = Math.min(FRAME_H_MAX, FRAME_W_MAX/a);\n let fw = fh*a;\n if(fw > FRAME_W_MAX){ fw = FRAME_W_MAX; fh = fw/a; }\n return { fw, fh };\n}\n\nconst artworks = []; // {group, photo, art, basePos, hovered}\nconst photoMeshes = []; // for raycaster\n\nconst matBorderMat = new THREE.MeshStandardMaterial({ color:'#fbfaf6', roughness:0.85 });\nconst frameEdgeMat = new THREE.MeshStandardMaterial({ color:'#26241f', roughness:0.6, metalness:0.1 });\nconst placeholderMat = () => new THREE.MeshStandardMaterial({ color:'#d8d4ca', roughness:0.9 });\n\nconst CAPTIONS = {\n 'N04':{b:'The frame ruptures at its midpoint, gravity betraying the viewer\\'s ability to orient \u2014 what reads as ground turns out to be sky, what reads as solid becomes its own inversion. The film grain dissolves the boundary between reflection and fact so the city refuses to confirm which version of itself is real. This is a quiet psychological trap: the moment you settle on an orientation, the other half reasserts its competing claim.'},\n 'N19':{b:'An aggressive chromatic intervention converts the warmth of use and occupation into cold institutional distance, except for a single amber form that resists the blue field\\'s totalizing logic. The shallow focus evacuates the background into abstraction, stranding that warm node in a space that no longer belongs to anyone. The tension sits between the democratic legibility of the scene and the color\\'s insistence on rendering it alien.'},\n 'M25':{b:'The aging surface is itself the argument \u2014 oxidized metal, blistered lacquer, the ghost of a former paint job bleeding through \u2014 a material autobiography laid across a single body. The warm film stock amplifies rather than softens the decay, giving corrosion a physical weight equal to intact form. The chromatic rupture between pale and saturated panels within a single continuous surface mirrors a broader logic of partial survival.'},\n 'M07':{b:'Isolated against a near-featureless gray field, a typographic sign loses all functional meaning and becomes pure geometry, forcing the viewer to reckon with the symbol as object before language. The fine film grain and a faint seam bisecting the background introduce just enough tactility to prevent the image from collapsing into graphic design. The radical negative space weaponizes emptiness \u2014 the form floats in void, cut free from any ledger or system that would stabilize it.'},\n '076':{b:'The ascending geometry compresses spatial sense, converging walls and stacking planes reducing depth to a forced climb with no guarantee of arrival. Warm, overexposed light at the upper register reads not as sun but as pressure \u2014 something that draws without revealing what it holds. The film\\'s slight softness fuses organic growth and stone into a single resistant mass, so the passage feels less traversable than inevitable.'},\n '012':{b:'Bilateral symmetry constructs a false promise of arrival \u2014 the vanishing point recedes rather than resolves, the hazy blown-out terminus functioning as a perceptual drain that consumes forward motion. The warm film cast converts what is structurally an ordered civic corridor into something closer to interior experience. The viewer is propelled forward without closing the distance, held in a perpetual approach that the image refuses to complete.'},\n '082':{b:'The disembodied hand entering the left edge breaks the scene\\'s hermetic seal, collapsing the distance between observer and observed into something that implicates the viewer rather than positions them. Nocturnal warmth from the shop window layers two spatial registers \u2014 interior and exterior \u2014 without resolving which is the container for the other. The film grain thickens the shadows to the point of solidity, giving darkness a material presence equal to the lit figure.'},\n '072':{b:'The image redistributes visual mass so radically \u2014 atmosphere claiming three-quarters of the frame \u2014 that density reads as fragility and the built environment is rendered peripheral to its own portrait. Film grain blooms across the gradient sky, making atmospheric transition tactile rather than optical, a surface you could touch. At this compression, the city becomes a geological stratum rather than a human construction, and the viewer\\'s sense of scale quietly inverts.'},\n 'M32':{b:'A bicycle in the foreground functions as both compositional barrier and social threshold \u2014 the viewer is architecturally excluded from the gathering it partially obscures, made aware of their own position as outside. The cool-shifted film palette presses the warm human scene into something slightly removed, a register of past tense despite its evident present-ness. The graffiti wall competes for spatial authority at equal scale, so the social interaction exists simultaneously as subject and as background to someone else\\'s prior claim.'},\n 'M31':{b:'Three visual registers \u2014 the figure absorbed in private ritual, the monumental graphic field behind, and the cat moving autonomously at the right edge \u2014 operate in genuine independence rather than hierarchy, each refusing to serve the others. The flat midday light denies depth, pressing everything into a single plane where each element competes equally for primacy. The viewer\\'s eye cycles without finding a fixed entry point, kept in motion by the image\\'s refusal to subordinate any of its parts to a center.'},\n '055':{b:'The heavy film grain collapses the tunnel\\'s depth into a single oppressive plane, compressing space rather than receding through it, so the viewer is denied the psychological relief of distance. Acid-green light punctures the dark at the far end \u2014 not as illumination but as something biological, a wrongness that the surrounding blackness cannot metabolize. The digital timestamp overhead introduces an almost absurd exactitude against the atmospheric decay, suspending the image between transit and abandonment.'},\n 'M20':{b:'The expected medium is gone, inverting the spatial logic entirely so that what was built to disappear becomes the only surface \u2014 pale, cracked, overlit. A shadow cuts diagonally with the precision of a graphic decision, dividing the frame into latency and damage. The chrome curves still perform their intended elegance, which makes them the most unsettling element: function persisting after purpose has evacuated.'},\n 'N03':{b:'Shot from below on medium format, the perspective is stretched past comfort \u2014 the repeating stair texture dissolves before reaching the white void at the top, so the viewer cannot determine scale or terminus. Parallel glass panels multiply the recession at the periphery, stacking reflections that destabilize any single fixed viewpoint. Movement is structurally implied everywhere; arrival is withheld entirely, and the blank ceiling above reads not as destination but as erasure.'},\n '057':{b:'The saturated orange field functions less as color than as pressure \u2014 it compresses everything within the frame into a single thermal plane, reducing figure and debris alike to incidental marks against an overwhelming surface. Film grain adds a granular heat that keeps the image from settling into documentation, holding it closer to sensation than reportage. The glass pane\\'s layered reflections introduce a depth that the flat red repels, leaving the viewer caught between surface and recession with no resolution offered.'},\n 'M13':{b:'The frame\\'s most deliberate act is its omission \u2014 identity severed, the body reduced to posture alone, which reads here as collapse without diagnosis or context. Coarse concrete grain and a flat, textureless grey light refuse any softening, pressing the physical weight into the surface rather than against it. The precision of the crop is what finally unnerves: this is a formal decision about withholding, not a documentary accident of proximity.'},\n 'N05':{b:'The vast red field to the left carries so much chromatic pressure that the bronze face reads as displaced rather than composed \u2014 not placed but pushed, occupying the right edge as a function of force rather than design. The patina absorbs and reflects the surrounding red simultaneously, collapsing the material boundary between surface and object until the eye cannot settle on either. The ring suspended below the gaze is the image\\'s one unresolved element: an open form that introduces demand without specifying what it wants.'},\n 'M02':{b:'The photograph turns looking back on itself \u2014 the seated figure attending to large painted surfaces forces the viewer into the position of the observed rather than the observer. The coffered grid overhead competes with the painted geometry below in an equal contest of pattern and weight, so the architecture refuses to recede and the art refuses to dominate. Scale becomes reversible: the monumental diminishes the human only if you decide that\\'s what\\'s happening.'},\n '011':{b:'The grain is thick enough to erode specificity \u2014 what would be legible as ordinary urban transaction becomes archaeological, as though the image is already documenting something receding. Stillness and transit share the same plane without hierarchy: those anchored absorb time at a different rate than those moving through, and the image holds both registers in genuine tension without resolving the difference. The street corner is the formal fulcrum \u2014 everything moves around or against it, but the corner itself remains structurally indifferent.'},\n 'M11':{b:'The tree trunk bisects the frame with the bluntness of an editorial cut, forcing the oversaturated lettering behind it into peripheral vision and making legibility a structural problem rather than an incidental one. Film grain and soft, diffuse light flatten depth so that organic form, painted surface, and open air occupy the same visual register \u2014 depth is asserted and then denied within the same image. The solitary figure moving rightward is the only clear directional statement the work makes, and the fact that they are moving away is its only concession to resolution.'},\n 'N22':{b:'Two figures on opposite trajectories \u2014 one descending into the camera\\'s gaze, one ascending away from it \u2014 occupy parallel tracks converging toward a light source neither will reach at the same moment, giving the image a quiet structural grief. The raw concrete overhead softens the architecture\\'s institutional authority into something more geological, which complicates the mechanical symmetry of metal and glass below. The descending figure\\'s returned gaze breaks the image\\'s drift toward abstraction; it refuses the passivity the composition was constructing around it.'},\n 'M10':{b:'The film\\'s warm chemical cast \u2014 that sulfurous yellowing \u2014 makes the eye work through a scrim of time before settling on the figures gathered behind. A diagonal intrusion in the foreground functions as a social barrier, rendering the act of looking itself a form of trespass rather than casual witness. The overlit architecture above pulls the composition apart from the sheltered warmth below, refusing to let either register dominate.'},\n 'M04':{b:'The bottom half of the frame holds its inversion so calmly \u2014 forms hanging from a tiled ceiling, poles growing downward \u2014 that the viewer must actively reconstruct which register is reflected before they can name what they\\'re seeing. What should be indexical here becomes an instrument of genuine spatial doubt, the photograph denying its own claim on the real. The warm monochrome seals the seam between the two orientations, making the image\\'s duplicity feel structural rather than accidental.'},\n 'N01':{b:'The frame has been surgically closed \u2014 no sky, no periphery, no exit \u2014 leaving the viewer locked inside a self-replicating geometry with no scale anchor to rescue them. A single raking light source drags shadow consistently across every cell, but the accumulated regularity tips from pattern into something closer to pressure. What began as architecture becomes a system, and the system feels both indifferent and total.'},\n '001':{b:'The painted marks on the surrounding surfaces create a density of observation that exceeds any single witness \u2014 the viewer is watched by imagery as much as by persons. The living figure\\'s bowed posture compresses inward against this visual accumulation, enacting a private disappearance inside a public space already saturated with faces. Silver gelatin grain flattens the material distinction between painted surface and skin, collapsing the distance between the drawn and the animate.'},\n 'M01':{b:'The camera refuses to offer a stable horizon \u2014 every plane is a diagonal, compressing spatial logic until the image approaches pure abstraction. The figure caught mid-distance provides the only human scale, but rather than orienting the viewer it emphasizes how thoroughly the architecture has swallowed proportion. Light falls flat and even, stripping shadow depth so that the surfaces read as cut paper rather than built space.'},\n '066':{b:'The upward angle subjects the viewer to structural vertigo \u2014 each flight presents its railing as a new barrier rather than a guide, multiplying the sensation of height without delivering arrival. Film grain and slight overexposure bleed the concrete edges into the sky, softening the boundary between built form and atmosphere so the structure reads as provisional, mid-collapse. The rust marks at the joints are the only concession to time.'},\n 'M22':{b:'Three planes of reality compete in a single glance \u2014 the solid figure, its glass reflection, and a displaced apparition in the adjacent window \u2014 and the camera refuses to adjudicate between them. The sharp foreground and soft background establish a focal hierarchy the eye continuously overrides, drawn to the receding figure even as optics insist on the present. This is a picture about the unreliability of presence as a category.'},\n 'N21':{b:'The human figure, reduced to a silhouette the width of a thumb, is framed by volumes so large they read as geological rather than constructed. The whiteness of the surfaces absorbs tonal contrast and eliminates texture, producing a cleanliness so extreme it registers as unreality \u2014 a condition the figure\\'s dark shadow intensifies by contrast. The viewer cannot determine whether they are looking down into a void or up toward a ceiling, and the image withholds resolution.'},\n 'N13':{b:'Light grazes only the surface of the face, holding the interior in darkness \u2014 this is less a portrait than a threshold between the illuminated and the withheld. Two soft points of light in the background function not as environmental context but as punctuation, marking the depth of the dark the subject is half-submerged in. The downcast eyes deny the exchange the composition otherwise demands, and that refusal is the work\\'s entire psychological weight.'},\n 'N15':{b:'The face occupies the right third of the frame and presses against an unseen support \u2014 the body behind barely legible as dark mass \u2014 so the figure reads as both held and hemmed in simultaneously. The left half of the image is pure tonal gradient, a void the gaze extends into rather than filling, displacing the viewer\\'s expectation of centering. In the grain and the soft dissolution of the background, particularity begins to collapse into archetype.'},\n 'N23':{b:'The shallow focal plane does something peculiar here \u2014 the face is rendered with near-clinical resolution while everything behind it dissolves into a warm, indeterminate haze, collapsing the sense of place without eliminating it. The gaze, fractionally offset from the lens, creates a social gap that proximity cannot close; you are invited into intimate distance that the subject declines to ratify. Color works against sentimentality rather than toward it \u2014 the cool lavender and plaid register as formal rather than warm, pulling the work away from document toward portrait.'},\n 'N16':{b:'The tonal compression of monochrome has stripped this face down to topography \u2014 the deep furrows read less as skin and more as geological strata, a surface worked by time with the same indifference as erosion. The massive void of the left half \u2014 flat, featureless white \u2014 functions as a spatial pressure, as if the blank is actively displacing the figure toward the edge of its own frame. With eyes cast down or closed, the figure withdraws perceptual access entirely, inverting the portrait\\'s basic contract.'},\n 'N24':{b:'The directness of this gaze is confrontational in a way that unsettles scale \u2014 the child occupies the viewer\\'s field with an authority that refuses the usual condescension of the photographed-child genre. The chalky residue across the forehead reads as trace evidence of a world already pressing its matter onto this face, while the garish signifiers on the headwear introduce a semiotic noise that the unblinking expression refuses to resolve. Something about that stillness does not match the chaos of the visual surface \u2014 the disconnect is the work.'},\n 'N17':{b:'The thick-framed lenses function as a second optical system within the frame, creating a subtle recursion \u2014 eyes behind glass behind glass, a viewer confronting someone equipped with the apparatus of looking back. The bilateral symmetry of the composition is almost unsettling in its formality, as if the subject is presenting not a person but a position. The out-of-focus architectural background implies a public social space while telling you nothing about it, a deliberate withholding that shifts all weight onto the face.'},\n 'N14':{b:'Light falls in a hard diagonal from above, carving the upper face into deep shadow while leaving the lower half and the hand in near-overexposure \u2014 the hierarchy of legibility is inverted, the eyes inaccessible, the hand and mouth dominant. The dense calligraphy behind reads simultaneously as decoration and as language, a semiotic field the figure has turned away from, absorbed in something smaller and more immediate. This double withdrawal \u2014 from the viewer\\'s gaze, from the text behind \u2014 creates a spatial isolation that the tight framing intensifies rather than relieves.'},\n 'N11':{b:'The laugh is so physiologically extreme that the monochrome\\'s flattening of affect produces a perceptual wobble \u2014 the expression sits at the threshold between joy and something rawer, and the gray tonal register refuses to settle the question. The horizontal banding of the spent field behind creates a grinding textural contrast against the burst of the face, making the emotional intensity read as stranger than it would in isolation. The figure is small relative to the expanse of emptied ground and flat sky; the laughter expands to fill it, but the scale relationship never tips into comfort.'},\n 'N10':{b:'By refusing the face \u2014 the single piece of information portraiture conventionally delivers \u2014 this image throws its full weight onto silhouette and gesture: a dense dark mass against a dissolving world of circular light. The extreme bokeh dematerializes everything the figure faces into pure luminous abstraction, so that what lies ahead is literally unknowable within the frame. The small ember held aloft at the fingertips becomes the only sharp-edged anchor in a composition otherwise given over to dissolution.'},\n 'M09':{b:'The architectural partition bisects the frame vertically and organizes the entire spatial logic, confining the figure to a narrow zone between two kinds of blankness \u2014 the art-laden wall behind and the white void beside. The film grain is coarse and unmistakable, situating the image in an analog temporal register that the gallery\\'s clinical modernism actively resists. The head-forward weight of the seated posture reads not as contemplation but as waiting, and the ambiguity of what exactly is being waited for \u2014 rest, recognition, exit \u2014 is precisely what the image refuses to resolve.'},\n 'M03':{b:'The rough plank surface behind occupies more than half the frame, pressing the figure into the right edge with the weight of raw material, its grain visible and insistent even in deep shadow. The smile is knowing and slightly withheld \u2014 warmth the face chooses to share rather than perform \u2014 and the distinction between offered and performed expression is the exact tension the composition holds. Displaced to the margin, nearly cut from the frame, the figure appears simultaneously present and in the process of departing.'},\n 'M05':{b:'The amber cast is so total and uniform that it eliminates depth cues, flattening the scene into something closer to a monochrome in a single hue \u2014 near and far rendered equally in the same warm glaze, space made ambiguous. The direct gaze and the raised fist under the chin create a posture of deliberate self-composition \u2014 this is someone aware of being looked at, and the awareness is not uncomfortable. The partially resolved figure behind introduces a doubling that troubles the singularity of the encounter without explaining it.'},\n 'N07':{b:'Raking light from above slices the face into a sliver of presence before the right side is surrendered entirely to darkness \u2014 not shadow as drama but as literal erasure of identity. The heavy silver grain refuses documentary; it reads more like disintegration, the emulsion eating the subject inward from the edges. What remains is a partial record, and the viewer must resist the instinct to fill in what has been deliberately withheld.'},\n 'M19':{b:'The near-closed eyes and the slight downward tilt deflect rather than return the gaze, creating an asymmetry of attention \u2014 we see everything, the subject registers nothing. Behind her the landscape collapses into warm chromatic noise, spatial recession flattened into a single atmospheric plane that makes proximity and distance indistinguishable. The film grain grounds what would otherwise feel suspended, making the moment feel less caught than endured.'},\n 'N06':{b:'The face in full contraction \u2014 eyes sealed, every crease deepened \u2014 renders the subject simultaneously maximally present and entirely unreachable, offering the viewer complete access with zero entry. The wire grid behind operates as a structural counterpoint to the organic topography of the skin, and the monochrome strips any warmth from the exchange, leaving only form and its formal opposite. It is a portrait of a closed door mistaken for an open one.'},\n 'M06':{b:'The frame is divided by a physical threshold into two zones that refuse to communicate \u2014 one figure withdrawn inward with eyes shut, the other turned entirely away. The repeating cylindrical forms lining the top band echo across both halves of the split, mocking the idea that both figures share the same world. Two people occupying the same space is not the same thing as two people being present to each other, and the photograph understands this distinction precisely.'},\n 'M30':{b:'The stems\\' downward arc and the upward-facing blooms produce a slow interior argument \u2014 aspiration against entropy \u2014 that the dense, almost audible silver grain makes feel urgent rather than elegiac. Shallow focus strips the surrounding room of specificity, placing the vessel in no identifiable hour, no particular domestic context, a stasis that is not peace. The work refuses nostalgia even as it deploys every material condition \u2014 analog grain, organic form, diffuse backlight \u2014 that nostalgia usually commandeers.'},\n '020':{b:'The light pressed low at the horizon destabilizes the image\\'s vertical axis, merging dark earth and warm atmospheric glow into an unresolvable tonal register where ground and depth become indistinguishable. The migratory formation crossing the upper frame introduces the only legible geometry in an otherwise diffuse field, functioning less as subject than as evidence of directed movement through an undirected space. Film dust and scratches layer two timescales simultaneously \u2014 the moment of exposure and the decades the negative has been alive.'},\n 'M29':{b:'The aggressive graphic architecture behind the seated figure doesn\\'t frame him \u2014 it threatens to absorb him, reducing the human to a subordinate detail inside a composition that predates and will outlast his presence. His face is refused to the viewer (head bowed, brim down, hand raised), so the image offers two illegible surfaces in parallel: a face and a wall, both closed. The high-contrast rendering converts what was almost certainly a vivid painted surface into a vocabulary of mourning gray, applying grief to an environment built for assertion.'},\n 'M28':{b:'The wall\\'s scarred, pocked surface \u2014 each block weathered into its own particular form \u2014 is the event, and the frame offers no competing interest to relieve the viewer. The sealed aperture left of center is the image\\'s lone gesture toward interior depth, and it refuses: louvers shut, nothing beyond accessible, the architecture performing pure surface. Pushed slightly off-axis, the composition insists there is a decision behind the framing and withholds what that decision was.'},\n 'M26':{b:'The composition inverts landscape hierarchy entirely \u2014 the pale near-empty field dominates, and the forms anchoring the earth are pushed so far into the lower margin they read as almost excluded from the frame. The film\\'s chromatic drift renders those forms in warm pink against a cool, neutralized sky, an opposition that refuses to resolve into atmosphere or depth. Scale is entirely absent; the sky presses down like a wall the forms are pinned against rather than open air they reach into.'},\n '051':{b:'Two adjacent faces \u2014 one absorbing light, one radiating it \u2014 share a surround that refuses to acknowledge their difference, and the bilateral geometry sits close enough to symmetry that every deviation registers as argument rather than variation. The warm film rendering works to reconcile what the chromatic contrast insists on keeping apart, and the sequential numbering imposes narrative continuity on what is visually a rupture. The viewer\\'s eye is forced into oscillation, reading them simultaneously as a pair and as irreconcilable opposites.'},\n '018':{b:'The luminous column rising from below and the biological geometry of the flock above split the viewer\\'s attention between ascent and descent simultaneously, with neither vector resolving. Film grain dissolves the boundary between atmosphere and solid matter until the entire frame feels suspended mid-state. The silhouettes at the base anchor nothing \u2014 they\\'re afterthoughts that refuse to ground what happens above them.'},\n '005':{b:'Two ascending planes compete without resolution \u2014 one near, one receding \u2014 and the single blazing rectangle at the back is pure destination denied, intercepted by the railing\\'s diagonal before the eye can arrive. Grain-heavy shadows press the walls inward, collapsing the spatial distance the composition promises. The architecture performs circulation without delivering it.'},\n '006':{b:'The diagonal slash renders depth illegible \u2014 what reads as a recession into space may equally be a flat plane tipped at an angle, and the image offers no ground to arbitrate between the two readings. Grain so dense and uniform that surface and void become tonally equivalent, stripping architectural space of hierarchy. The single hardware element at the top insists the frame has an orientation; the diagonal ignores it.'},\n '043':{b:'By exiling the structure to the frame\\'s corner and removing its base, the work strips the form of function and reveals only geometry \u2014 a curved lattice that reads as architecture before it reads as anything else. Film grain imprints nostalgia onto a blue the blue actively resists, producing a temporal dissonance between medium and subject. The radical decentering forces the eye across empty space before it arrives at form, reversing the hierarchy of figure and ground.'},\n '042':{b:'The composition divides between a reflective surface that operates as a second sky and a dense, warm-toned settlement clinging to the margins, creating a spatial wobble where the viewer cannot determine whether they are looking out or looking down. The film\\'s chromatic drift measures distance in temperature rather than meters \u2014 amber architecture, violet water \u2014 so the bay reads as both near and abstract. Scale refuses to fix: the scattered white forms on the water hover between intimacy and insignificance depending on where the eye lands.'},\n '059':{b:'The collective frontal orientation \u2014 uncoordinated yet unanimous \u2014 collapses the distinction between observed and observer, placing the viewer on the wrong end of the gaze without warning. The film\\'s warm shift bleaches the sky to near-white while pushing foliage toward near-black, compressing tonal range so the light reads as both abundant and suffocating. The single vertical pole at the right edge punctuates what might otherwise resolve as pastoral with the infrastructure of something else entirely.'},\n '025':{b:'The blank screen exerts a pressure that exceeds its emptiness \u2014 it functions not as absence but as mass, a surface that has withheld something the warm seating below was arranged to receive. The green exit sign is the only active light source and the only legible instruction in the frame. At the edges, grain thickens until seat forms dematerialize, as if the room is already in the process of forgetting itself.'},\n '024':{b:'The low angle collapses the figure against a sky bleached to void, making the gesture of shading the eyes \u2014 a search for horizon \u2014 structurally futile from the start. Fragmentary text intrudes from the left with its incomplete message, trapping the viewer in a reading that never resolves. The rigging lines pull toward the upper right as if the entire image is listing, giving the figure\\'s braced posture an urgency the blank sky neither confirms nor denies.'},\n '002':{b:'The suspended spray occupies middle space as neither liquid nor air, defeating the horizon\\'s attempt to legislate a boundary between elements. Heavy grain creates tonal equivalence between the explosion and the overcast sky above it, so the violence reads simultaneously as intrusion and dissolution. The hard geometric darkness of the foreground makes the chaos above feel borrowed \u2014 a force that doesn\\'t belong to the same physical register as what it strikes.'},\n '017':{b:'The formation\\'s near-symmetry is just off enough to register as stress rather than equilibrium, and the grain degrades that geometric precision until individual marks become uncertain \u2014 birds, or emulsion artifacts. The glowing pressure in the upper right seems to push the formation leftward, so the spatial logic of the frame works actively against the movement it depicts. The purple-to-warm thermal gradient reads less as color than as a medium the formation is passing through.'},\n '008':{b:'The horizon is all but extinguished \u2014 a thin strip of earth clings to the bottom as the sky claims the rest without apology. Film grain and a warm cast on the clouds tip the light into the register of memory rather than observation, and the incidental structure marooned in the lower right only deepens the sense that the ground is actively leaving.'},\n 'N09':{b:'Scale is the first casualty: shallow focus and a close focal plane transform domestic fabric into terrain \u2014 ridgelines, troughs, slow drift. The cool light refuses warmth to material that is by definition warm, and two competing textures \u2014 tight cellular weave against pooled linen \u2014 hold the frame in a state of unresolved physical tension.'},\n '034':{b:'The composition is structurally wrong by design \u2014 subjects hard-right, the left two-thirds almost entirely void, the frame\\'s weight distributed in a way that refuses pictorial comfort. The warm light grounds the scene in the late-day register of the real, but the crumbling surface below reads as terminus, and the two figures angled slightly apart refuse to consolidate their gaze into anything the viewer can follow.'},\n 'N18':{b:'The image is a spatial trap: the subject occupies only a narrow corridor in the middle third, glimpsed through a gap between two blurred foreground planes \u2014 one warm-fleshed, one white \u2014 that reduce the viewer to enforced, partial sight. The amber light at the far stair\\'s landing pulls the eye toward a destination the frame structurally denies access to.'},\n '053':{b:'Proximity is weaponized: one form consumes the left half of the frame while the second retreats low and right, collapsing the viewer\\'s ability to hold the two as spatial equivalents. The tessellated steel surfaces carry a fractured, near-digital texture that refuses the organic, and film grain laid over machined geometry creates a material contradiction the eye cannot settle \u2014 scale implied, scale withheld.'},\n 'N12':{b:'The subject is reduced to a single luminous fragment compressed between two crushing masses of dark, rough-sawn timber, monochrome rendering the wood\\'s grain as geological in weight. Darkness behind the gap refuses to disclose what lies beyond, and the viewer is positioned as witness to something simultaneously intimate and structurally constrained \u2014 the frame performing confinement rather than just depicting it.'},\n '052':{b:'The figure is too small for the space and seems to accept that: seated low, off-center, absorbed into open ground while an overcast sky layers above and a Gothic spire punctuates the treeline at the exact horizontal midpoint. Desaturated film greens and the fallen, arrested bicycle introduce a quiet instability into what would otherwise read as mere stillness.'},\n 'N08':{b:'The square format creates strict bilateral tension \u2014 the bench anchors both sides with equal pressure, and the figure, back fully turned, is the only asymmetric element the composition offers. The vivid horizontal stripe is the frame\\'s sole sharp chromatic insistence, pulling the eye to a body that withholds a face entirely; medium format film renders the mid-ground foliage with a tactile density that makes the background feel more present than the subject in front of it.'},\n '050':{b:'The image runs in sedimentary bands \u2014 clustered vessels in the foreground, exposed tidal flat, town silhouette, treeline, sky \u2014 each layer a different register of time and motion, all of them arrested. Film stock reads the raking warm light with a slight oversaturation that tips the scene toward unreality, the sand glowing too orange, the shadows too hard for a scene that is otherwise meant to pass as ordinary.'},\n 'N20':{b:'The architectural form is denied its identity \u2014 cropped to its curved upper edge alone, it becomes pure white geometry dividing the frame from a dynamic backlit sky. Shot against hard light, the structure nearly dematerializes at its border, and without a single scale reference the viewer cannot fix whether what they are looking at is monumental or incidental.'},\n '049':{b:'The foreground mass absorbs nearly all light, reducing the primary subjects to pure silhouette while the space behind them \u2014 technically the background \u2014 becomes the composition\\'s only source of luminance. This inversion of figure-ground logic forces the viewer to read presence as absence and vice versa. The warm film grain weaves through both zones, making the radical tonal split feel earned rather than theatrical.'},\n 'M34':{b:'A lens artifact \u2014 circular, rust-orange, unmistakably mechanical \u2014 floats over the canopy like a second sun that the film itself introduced into the world. The right-weighted composition creates a gravitational lean the frame cannot resolve; the tree presses outward as much as upward. What might have been documentary becomes an argument about the camera\\'s complicity in what it records.'},\n '028':{b:'Shot from just above the waterline, the image dissolves its own spatial logic: the foreground is simultaneously inside and outside the water, and a single shadow cast by an absent figure is the only vertical anchor in a composition otherwise ruled by horizontal shimmer. The film\\'s warm, overexposed palette flattens recession, making near and far feel equally pressurized. Warning text in two languages intrudes on the visual calm without irony \u2014 depth is the point.'},\n '084':{b:'The sky occupies roughly two-thirds of the frame, rendered in clean, unglamorous blue that the film grain quietly destabilizes at the edges. A single form is pressed to the lower-left periphery \u2014 not centered, not symmetrical \u2014 creating a spatial pull that the void refuses to resolve. The viewer keeps returning to that lone form not out of interest in it, but because the emptiness surrounding it provides no other foothold.'},\n 'M24':{b:'The camera looks straight up, stripping the scene of any horizon and leaving only vertical columns arrayed at competing depths. Film renders the sky as near-white pale blue \u2014 barely a sky at all, more like a bleached surface behind the forms. Without a visible ground plane, the viewer loses the body\\'s primary spatial reference; the composition becomes a diagram of height rather than a depiction of place.'},\n 'M23':{b:'The hill\\'s slope runs diagonally across the frame in two distinct tonal bands \u2014 a green base surrendering to dry tan at the crest \u2014 and the sharpness of that transition makes the landform read as constructed rather than natural. Scale is entirely withheld: the mound could be intimate or monumental, and the framing refuses to decide. The tree at left is an anchor but an unreliable one, soft against the crisp geometry of the hill behind it.'},\n 'N02':{b:'Gravity is suspended entirely: the black planes cut across each other at competing diagonals, and the sole point of white light at the top center provides no orientation, only a destination the eye reaches and then retreats from. The tonal compression \u2014 dense black against a single bright wedge at the lower right \u2014 makes the space feel less like an interior and more like a theoretical proposition about enclosure. Medium-format grain lends the shadows a fine, breathing texture that keeps them from reading as pure abstraction.'},\n 'M21':{b:'The galactic core runs vertically down the frame like a river, inverting every expectation the viewer brings to astronomical imagery \u2014 we are trained to find the arc, and instead we fall. Heavy sensor noise throughout unifies the sky\\'s distant light and the horizon\\'s earthbound glow into a single grainy fabric, dissolving the boundary that normally separates the cosmic from the terrestrial. The warm light pollution at the base pulls the image toward the intimate while the scale above it insists on the opposite.'},\n 'M18':{b:'Shallow depth of field fractures the space into competing planes: the saturated primary colors at upper-left are pin-sharp, the middle ground blurs into an impressionistic smear, and the foreground stools return to clarity. Film grain amplifies the tungsten warmth, making the space feel simultaneously preserved and slightly wrong \u2014 a vernacular interior made strange by the camera\\'s selective attention. Two spatial lines pull against each other without resolution, and photographs within this photograph multiply the layers of representation until the room\\'s reality becomes uncertain.'},\n '022':{b:'The entire foreground collapses into a near-featureless dark mass, and the sky above it compresses into a narrow band where the only drama unfolds \u2014 a burning core bisected by a vertical silhouette, hemmed above by deepening blue. Film grain saturates both the dark ground and the glowing sky, giving the image a tactile, almost smeared quality that pushes it away from document and toward memory. An infrastructure lattice of wires and poles maps a human geometry onto the sky that complicates any reading of the work as pastoral or elegiac.'},\n 'M17':{b:'The diagonal procession of amber filaments doesn\\'t illuminate so much as stage \u2014 the space performs warmth rather than harboring it. Framed images nested within the photograph pull the eye toward a quiet recursion that destabilizes any claim to documenting a single place. Film grain smooths the seam between the real and the reproduced, leaving the viewer uncertain whether they are inside a room or inside a representation of one.'},\n 'M16':{b:'The organic form reads against a field of repeating modules whose color refuses to resolve \u2014 gradient or deterioration, dye or damage. The mechanical regularity of the textile ground renders the natural subject strange, its familiar geometry suddenly legible as pattern rather than growth. Scale becomes genuinely indeterminate: no stable spatial relationship between the two surfaces survives scrutiny.'},\n 'M15':{b:'Two surfaces meet at a diagonal that refuses to explain itself \u2014 no ground, no horizon, no scale reference, nothing to orient the body. The structured weave against the loose, wrinkled plane sets up a dialectic between intention and entropy, but the tight framing ensures neither wins. The film grain operates as a third texture, collapsing the material distance between the two and making the image read as topographic rather than domestic.'},\n '023':{b:'The figure is grammatically subordinate \u2014 dark punctuation at the edge of a composition that belongs entirely to the kinetic surge ahead. The film stock\\'s faded palette erodes the horizon, merging sky and sea into a single atmospheric register and with it any clear boundary between the human and what faces them. Temporality suspends: the wave neither arriving nor receding, the posture neither preparing nor reacting.'},\n 'M08':{b:'The wire cuts the frame diagonally with the authority of a drafting instrument, and the lamp burns in full daylight \u2014 an anachronism that makes the pale sky feel slightly wrong. The birds clustered at the fulcrum introduce biological weight to what would otherwise read as pure infrastructure drawing. The radical compression of the upward angle evacuates depth, turning a vertical urban condition into a surface problem.'},\n '062':{b:'The grain is thick enough to read as memory rather than documentation \u2014 not how light fell in the room, but how it persists afterward. Flowers in varying states of opening collapse linear time into a single exposure, making the image feel like duration rather than moment. The domestic blur behind asserts intimacy while withholding legibility, trapping the viewer in a relationship to a space they cannot fully enter.'},\n '014':{b:'Saturated yellow holds the frame against surrounding entropy \u2014 a single chromatic anchor in a field of institutional grey and accumulated disorder. Diagonal light enters from outside and performs the room\\'s chaos as chiaroscuro, briefly aestheticizing infrastructure that had no expectation of being seen this way. The consumer artifact cradled in the machine\\'s arms conflates two economies of scale in a way that neither explains nor resolves.'},\n 'M27':{b:'The film border bleeds into the left edge, reminding you this is an index not a window \u2014 a distinction the crashing foam immediately complicates by dissolving into the sky\\'s tonal register. Kinetic energy frozen at peak dispersion turns the water indeterminate, a substance the eye reads as solid, liquid, and atmospheric simultaneously. Three horizontal bands \u2014 rock, spray, cloud \u2014 impose structural firmness that the content actively refuses to honor.'},\n 'M12':{b:'The architectural surface fills three-quarters of the frame with near-featureless grey, and the diagonal lines bisect it with geometric certainty \u2014 the figures caught between them register as incidental rather than central. Tonal compression flattens depth until spatial recession becomes indistinguishable from surface, stranding two bodies in an environment with no measurable distance. They move in opposite directions without acknowledgment, and the work\\'s entire emotional weight arrives through that silence.'}\n};\n\nfor(const h of HALLS){\n const faces = buildFaces(h);\n const pool = (PHOTOS_BY_WING[h.key] || []).slice(); // real photos for this wing, in order\n const N = pool.length;\n if(!N) continue;\n\n // Build a continuous perimeter from all wall faces, then place photos at EVEN\n // arc-spacing around it \u2014 so frames never clump in corners or leave big gaps.\n const segs = faces.map(f=>{\n const len = Math.abs(f.b-f.a);\n return { f, usable: Math.max(0, len - 2*MARGIN) };\n }).filter(s=>s.usable > 0.2);\n const totalUsable = segs.reduce((s,x)=>s+x.usable, 0) || 1;\n\n for(let i=0;i<N;i++){\n const arc = (i + 0.5) * totalUsable / N; // centered, uniform spacing\n let acc=0, seg=segs[segs.length-1], local=segs[segs.length-1].usable;\n for(const s of segs){ if(arc <= acc + s.usable){ seg=s; local=arc-acc; break; } acc+=s.usable; }\n const f = seg.f;\n const dir = Math.sign(f.b - f.a) || 1;\n const t = f.a + dir*(MARGIN + local);\n let x,z;\n if(f.axis==='z'){ x=f.fixed; z=t; } else { x=t; z=f.fixed; }\n placeArt({ x, z, nx:f.nx, nz:f.nz, hall:h, photo: pool[i] });\n }\n}\n\nfunction placeArt({x,z,nx,nz,hall,photo}){\n const group = new THREE.Group();\n group.position.set(x, EYE, z);\n // orient so +z of group points along (nx,nz)\n group.rotation.y = Math.atan2(nx, nz);\n\n const { fw, fh } = frameSize(photo.w, photo.h);\n // black float-frame backplate (sits flat against wall, has real depth)\n const edge = new THREE.Mesh(new THREE.BoxGeometry(fw+0.22, fh+0.22, 0.05), frameEdgeMat);\n edge.position.z = 0.025; edge.castShadow=true; edge.receiveShadow=true; group.add(edge);\n // white matte border, proud of the frame\n const mat = new THREE.Mesh(new THREE.BoxGeometry(fw+0.14, fh+0.14, 0.04), matBorderMat);\n mat.position.z = 0.058; mat.castShadow=true; mat.receiveShadow=true; group.add(mat);\n // photo, in front of the matte\n const photoMesh = new THREE.Mesh(new THREE.PlaneGeometry(fw, fh), placeholderMat());\n photoMesh.position.z = 0.082; group.add(photoMesh);\n\n scene.add(group);\n const art = {\n id: photo.id, mediaId: photo.mediaId, name: photo.name,\n fw, fh, w:photo.w, h:photo.h,\n wing:hall.name, wingKey:hall.key,\n title: photo.name\n };\n const entry = { group, photo:photoMesh, edge, mat, art, base:1, hovered:false, loaded:false, loading:false, pos:new THREE.Vector3(x,EYE,z) };\n photoMesh.userData.entry = entry;\n artworks.push(entry);\n photoMeshes.push(photoMesh);\n\n // museum caption plaque \u2014 bottom-right of frame\n const cap = CAPTIONS[photo.id];\n if(cap){\n const CW=1024, pad=28;\n // measure body text wrapping with a temp canvas\n const tmpCv=document.createElement('canvas'); tmpCv.width=CW; tmpCv.height=64;\n const tmpC=tmpCv.getContext('2d');\n tmpC.font='italic 21px \"Helvetica Neue\",Helvetica,Arial,sans-serif';\n const bodyWords=cap.b.split(' '); const bodyLines=[]; let bLine='';\n for(const w of bodyWords){\n const t2=bLine?bLine+' '+w:w;\n if(tmpC.measureText(t2).width>CW-pad*2&&bLine){bodyLines.push(bLine);bLine=w;}else bLine=t2;\n }\n if(bLine)bodyLines.push(bLine);\n const CH=150+bodyLines.length*30+pad;\n const cv2=document.createElement('canvas'); cv2.width=CW; cv2.height=CH;\n const c2=cv2.getContext('2d');\n c2.fillStyle='rgba(245,241,232,0.93)';\n c2.fillRect(0,0,CW,CH);\n c2.strokeStyle='rgba(140,118,82,0.30)';\n c2.lineWidth=2; c2.strokeRect(1,1,CW-2,CH-2);\n c2.textAlign='left'; c2.textBaseline='alphabetic';\n // artist attribution\n c2.fillStyle='rgba(42,34,24,0.38)';\n c2.font='500 20px \"Helvetica Neue\",Helvetica,Arial,sans-serif';\n c2.fillText('Adam Disatnik', pad, 36);\n // title: \"Frame N04\"\n c2.fillStyle='#2a2218';\n c2.font='600 40px \"Helvetica Neue\",Helvetica,Arial,sans-serif';\n c2.fillText('Frame '+photo.id, pad, 86);\n // medium\n c2.fillStyle='rgba(42,34,24,0.52)';\n c2.font='italic 22px \"Helvetica Neue\",Helvetica,Arial,sans-serif';\n c2.fillText('35mm Film', pad, 116);\n // separator\n c2.strokeStyle='rgba(42,34,24,0.13)';\n c2.lineWidth=1;\n c2.beginPath(); c2.moveTo(pad,132); c2.lineTo(CW-pad,132); c2.stroke();\n // body lines\n c2.fillStyle='rgba(42,34,24,0.68)';\n c2.font='italic 21px \"Helvetica Neue\",Helvetica,Arial,sans-serif';\n let ty=150;\n for(const bl of bodyLines){ c2.fillText(bl, pad, ty); ty+=30; }\n const capTex=new THREE.CanvasTexture(cv2);\n capTex.colorSpace=THREE.SRGBColorSpace;\n const capW=0.40, capH=capW*CH/CW;\n const capMesh=new THREE.Mesh(\n new THREE.PlaneGeometry(capW,capH),\n new THREE.MeshBasicMaterial({map:capTex,transparent:true,side:THREE.DoubleSide})\n );\n capMesh.position.set(\n fw/2+0.07-capW/2,\n -(fh+0.14)/2-0.028-capH/2,\n 0.09\n );\n group.add(capMesh);\n }\n}\n\n/* ============================== 3D TEXT (title + wing labels) ============ */\nfunction makeTextPlane(lines, {width, color='#1b1813', sub=[], font=600, sl=0.07, ch=1024, size=null, subSize=62}){\n const canvas = document.createElement('canvas');\n const W=2048, H=ch; canvas.width=W; canvas.height=H;\n const c = canvas.getContext('2d');\n c.clearRect(0,0,W,H);\n c.textAlign='center'; c.textBaseline='middle';\n c.fillStyle=color;\n let y = sub.length ? H*0.40 : H*0.5;\n const big = size || (lines.length>1 ? 168 : 210);\n lines.forEach((ln,i)=>{\n c.font = `${font} ${big}px \"Helvetica Neue\",Helvetica,Arial,sans-serif`;\n c.save();\n // letter-spacing emulation\n drawSpaced(c, ln, W/2, y + i*big*1.08, sl*big);\n c.restore();\n });\n if(sub.length){\n c.fillStyle = color;\n c.globalAlpha = 0.62;\n let sy = H*0.66;\n sub.forEach((ln,i)=>{\n c.font = `400 ${subSize}px \"Helvetica Neue\",Helvetica,Arial,sans-serif`;\n drawSpaced(c, ln.toUpperCase(), W/2, sy+i*(subSize*1.5), subSize*0.16);\n });\n c.globalAlpha=1;\n }\n const tex = new THREE.CanvasTexture(canvas);\n tex.colorSpace = THREE.SRGBColorSpace;\n tex.anisotropy = renderer.capabilities.getMaxAnisotropy();\n const aspect = W/H;\n const mesh = new THREE.Mesh(\n new THREE.PlaneGeometry(width, width/aspect),\n new THREE.MeshBasicMaterial({ map:tex, transparent:true })\n );\n return mesh;\n}\nfunction drawSpaced(c, text, cx, cy, ls){\n const widths = [...text].map(ch=>c.measureText(ch).width+ls);\n const total = widths.reduce((a,b)=>a+b,0)-ls;\n let x = cx - total/2;\n for(let i=0;i<text.length;i++){ c.fillText(text[i], x+widths[i]/2-ls/2, cy); x+=widths[i]; }\n}\n\n// Entrance banner \u2014 on the lobby wall (z=36), ABOVE the doorway so it isn't cut by the opening\nconst title = makeTextPlane(\n ['ADAM\\u2019S GALLERY'],\n { width:10.5, size:150, subSize:52, ch:500, sl:0.03,\n sub:['Collection 01 \u00b7 89 Works \u00b7 Light & People','W A S D \u2014 walk \u00b7 Mouse \u2014 look'] }\n);\ntitle.position.set(0, 4.8, 36+WALL_T/2+0.06);\ntitle.rotation.y = 0; // front faces +z (toward the spawn / lobby)\nscene.add(title);\n\n// Big room-name labels \u2014 double-sided, moved to y=4.65 (above all photo frames)\n// so they never overlap artwork. Visible from both entrance and inside each wing.\nconst wingLabelDefs = [\n { name:'STREET', z:36 },\n { name:'PORTRAITS', z:18 },\n { name:'PLACES', z:4 },\n];\nwingLabelDefs.forEach(d=>{\n // south-facing (visible from entrance / lobby side)\n const labS = makeTextPlane([d.name], { width:5.0, sl:0.12 });\n labS.position.set(-7.5, 4.65, d.z+WALL_T/2+0.08);\n labS.rotation.y = 0;\n scene.add(labS);\n // north-facing (visible from inside the wing)\n const labN = makeTextPlane([d.name], { width:5.0, sl:0.12 });\n labN.position.set(-7.5, 4.65, d.z-WALL_T/2-0.08);\n labN.rotation.y = Math.PI;\n scene.add(labN);\n});\n\n/* ---- Directory wayfinding plaques \u2014 hung at x=0 (doorway centre, no photos\n hang there) so they can never overlap frames. Double-sided. ---- */\nfunction makeSign(rows, {width=2.6}={}){\n const W=1024, H=384;\n const cv=document.createElement('canvas'); cv.width=W; cv.height=H;\n const c=cv.getContext('2d');\n // subtle parchment backing\n c.fillStyle='rgba(245,238,220,0.82)';\n const rx=28; c.beginPath(); c.moveTo(rx,0); c.lineTo(W-rx,0); c.quadraticCurveTo(W,0,W,rx);\n c.lineTo(W,H-rx); c.quadraticCurveTo(W,H,W-rx,H); c.lineTo(rx,H); c.quadraticCurveTo(0,H,0,H-rx);\n c.lineTo(0,rx); c.quadraticCurveTo(0,0,rx,0); c.closePath(); c.fill();\n c.strokeStyle='rgba(140,118,82,0.45)'; c.lineWidth=6; c.stroke();\n c.textAlign='center'; c.textBaseline='middle'; c.fillStyle='#2a2218';\n rows.forEach((r,i)=>{\n const y = rows.length===1 ? H*0.5 : H*(0.32 + i*0.38);\n c.font = `500 56px \"Helvetica Neue\",Helvetica,Arial,sans-serif`;\n const arr = r.dir==='left' ? '\\u25c4' : '\\u25ba'; // solid triangle arrows\n const s = r.dir==='left' ? `${arr} ${r.t}` : `${r.t} ${arr}`;\n c.fillText(s, W/2, y);\n });\n const tex=new THREE.CanvasTexture(cv); tex.colorSpace=THREE.SRGBColorSpace;\n tex.anisotropy=renderer.capabilities.getMaxAnisotropy();\n const mesh=new THREE.Mesh(new THREE.PlaneGeometry(width, width*H/W),\n new THREE.MeshBasicMaterial({map:tex, transparent:true, side:THREE.DoubleSide}));\n return mesh;\n}\nconst dirSignDefs = [\n { z:36, rows:[{t:'ENTRANCE',dir:'left'}, {t:'STREET',dir:'right'}] },\n { z:18, rows:[{t:'STREET',dir:'left'}, {t:'PORTRAITS',dir:'right'}] },\n { z:4, rows:[{t:'PORTRAITS',dir:'left'},{t:'PLACES',dir:'right'}] },\n];\ndirSignDefs.forEach(d=>{\n const s = makeSign(d.rows, {width:2.6});\n s.position.set(0, 2.3, d.z);\n s.rotation.y = 0;\n scene.add(s);\n});\n\n/* ============================== BENCHES ================================= */\nconst SEAT_Y = 0.5;\nfunction makeBench(x, z){\n const g = new THREE.Group(); g.position.set(x, 0, z);\n const woodMat = new THREE.MeshStandardMaterial({ color:'#7a6446', roughness:0.55, metalness:0.04 });\n const legMat = new THREE.MeshStandardMaterial({ color:'#33291f', roughness:0.7 });\n const seat = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.09, 1.7), woodMat);\n seat.position.y = SEAT_Y-0.045; seat.castShadow=true; seat.receiveShadow=true; g.add(seat);\n for(const lx of [-0.19, 0.19]) for(const lz of [-0.74, 0.74]){\n const lg = new THREE.Mesh(new THREE.BoxGeometry(0.08, SEAT_Y-0.09, 0.08), legMat);\n lg.position.set(lx, (SEAT_Y-0.09)/2, lz); lg.castShadow=true; g.add(lg);\n }\n scene.add(g);\n colliders.push({ x1:x, z1:z-0.85, x2:x, z2:z+0.85, half:0.34 });\n return g;\n}\n// benches in the aisles, facing a side wall (so a sitter looks at the photos)\nconst BENCHES = [\n // Street wing z0=18 z1=36\n { x: 4.2, z: 30, wingKey:'street', faceYaw: Math.PI/2 },\n { x:-4.2, z: 24, wingKey:'street', faceYaw: -Math.PI/2 },\n // Portraits wing z0=4 z1=18\n { x: 4.2, z: 14, wingKey:'portraits', faceYaw: Math.PI/2 },\n { x:-4.2, z: 8, wingKey:'portraits', faceYaw: -Math.PI/2 },\n // Places wing z0=-24 z1=4\n { x: 4.2, z: -5, wingKey:'places', faceYaw: Math.PI/2 },\n { x:-4.2, z:-14, wingKey:'places', faceYaw: -Math.PI/2 },\n];\nconst benches = BENCHES.map(b=>{ makeBench(b.x, b.z); return { ...b, occupied:false }; });\n\n/* ============================== CONTACT FRAME (entrance) ================ */\n// Clickable \"Write to Adam\" plaque near the entrance on the east wall\nconst clickables = []; // non-photo interactable meshes\n\n(function makeContactFrame(){\n const g = new THREE.Group();\n // east wall, entrance \u2014 immediately visible on entry\n g.position.set(HALF_W-WALL_T/2-0.04, 1.85, 43.0);\n g.rotation.y = -Math.PI/2;\n\n // === GOLD FRAME ===\n const goldMat = new THREE.MeshBasicMaterial({ color:'#c9a84c' });\n // outer frame box\n const frame = new THREE.Mesh(new THREE.BoxGeometry(1.28, 0.76, 0.055), goldMat);\n frame.castShadow = true; g.add(frame);\n // inner recess to simulate moulding depth \u2014 slightly inset dark plate\n const matMat = new THREE.MeshBasicMaterial({ color:'#0f0e0c' });\n const mat = new THREE.Mesh(new THREE.BoxGeometry(1.10, 0.58, 0.02), matMat);\n mat.position.z = 0.025; g.add(mat);\n\n // === CANVAS ARTWORK ===\n const cw=1024, ch=512;\n const cv=document.createElement('canvas'); cv.width=cw; cv.height=ch;\n const cx=cv.getContext('2d');\n\n // rich dark background with very slight warm tint\n cx.fillStyle='#100f0d'; cx.fillRect(0,0,cw,ch);\n\n // outer gold rule border\n cx.strokeStyle='rgba(201,168,76,0.55)'; cx.lineWidth=3;\n cx.strokeRect(38, 38, cw-76, ch-76);\n\n // inner gold rule border (tighter)\n cx.strokeStyle='rgba(201,168,76,0.28)'; cx.lineWidth=1.5;\n cx.strokeRect(56, 56, cw-112, ch-112);\n\n // corner diamond ornaments at outer border corners\n cx.fillStyle='rgba(201,168,76,0.75)';\n function diamond(x, y, r){\n cx.beginPath();\n cx.moveTo(x, y-r); cx.lineTo(x+r, y); cx.lineTo(x, y+r); cx.lineTo(x-r, y);\n cx.closePath(); cx.fill();\n }\n const cr = 9;\n diamond(38, 38, cr); diamond(cw-38, 38, cr);\n diamond(38, ch-38, cr); diamond(cw-38, ch-38, cr);\n\n // small accent diamonds at inner corners\n cx.fillStyle='rgba(201,168,76,0.40)';\n const ir = 5;\n diamond(56, 56, ir); diamond(cw-56, 56, ir);\n diamond(56, ch-56, ir); diamond(cw-56, ch-56, ir);\n\n // gallery byline \u2014 spaced small caps\n cx.fillStyle='rgba(201,168,76,0.65)';\n cx.font='300 26px \"Helvetica Neue\",Helvetica,Arial,sans-serif';\n cx.textAlign='center'; cx.textBaseline='middle';\n cx.letterSpacing='0.3em';\n cx.fillText('A D A M \u2019 S G A L L E R Y', cw/2, ch*0.24);\n\n // thin gold rule under byline\n const rx=cw*0.28, rw=cw*0.44;\n cx.strokeStyle='rgba(201,168,76,0.5)'; cx.lineWidth=1;\n cx.beginPath(); cx.moveTo(rx, ch*0.33); cx.lineTo(rx+rw, ch*0.33); cx.stroke();\n // small centre diamond on rule\n cx.fillStyle='rgba(201,168,76,0.6)';\n diamond(cw/2, ch*0.33, 5);\n\n // main title\n cx.fillStyle='#f0ebe0';\n cx.font='600 80px \"Helvetica Neue\",Helvetica,Arial,sans-serif';\n cx.fillText('CONTACT ADAM', cw/2, ch*0.57);\n\n // lower rule above subtitle\n cx.strokeStyle='rgba(201,168,76,0.5)'; cx.lineWidth=1;\n cx.beginPath(); cx.moveTo(rx, ch*0.70); cx.lineTo(rx+rw, ch*0.70); cx.stroke();\n cx.fillStyle='rgba(201,168,76,0.6)'; diamond(cw/2, ch*0.70, 5);\n\n // subtitle \u2014 elegant instruction\n cx.fillStyle='rgba(240,235,224,0.42)';\n cx.font='300 32px \"Helvetica Neue\",Helvetica,Arial,sans-serif';\n cx.fillText('W R I T E A M E S S A G E', cw/2, ch*0.82);\n\n const tex=new THREE.CanvasTexture(cv);\n tex.colorSpace=THREE.SRGBColorSpace;\n tex.anisotropy=renderer.capabilities.getMaxAnisotropy();\n const label=new THREE.Mesh(new THREE.PlaneGeometry(1.06, 0.54),\n new THREE.MeshBasicMaterial({map:tex}));\n label.position.z=0.038; g.add(label);\n\n scene.add(g);\n label.userData = { type:'contact', label:'Contact Adam' };\n clickables.push(label);\n})();\n\n/* ============================== PEOPLE (visitors + guard) ================ */\nfunction makePerson({coat='#4a4742', pants='#2a2824', head='#bba98f', hair='#2e2820', shoes='#1c1a16', accent=null}={}){\n const g = new THREE.Group();\n const coatMat = new THREE.MeshStandardMaterial({ color:coat, roughness:0.82, metalness:0 });\n const pantsMat = new THREE.MeshStandardMaterial({ color:pants, roughness:0.88, metalness:0 });\n const headMat = new THREE.MeshStandardMaterial({ color:head, roughness:0.76, metalness:0 });\n const hairMat = new THREE.MeshStandardMaterial({ color:hair, roughness:0.90, metalness:0 });\n const shoesMat = new THREE.MeshStandardMaterial({ color:shoes, roughness:0.55, metalness:0.08 });\n\n const hipY = 0.95, legLen = 0.88;\n\n // legs \u2014 upper (pants) + shoe at bottom\n function leg(sx){\n const pivot = new THREE.Group(); pivot.position.set(sx, hipY, 0);\n // upper leg\n const upper = new THREE.Mesh(new THREE.CylinderGeometry(0.075,0.062,legLen*0.54,12), pantsMat);\n upper.position.y = -legLen*0.27; upper.castShadow=true; pivot.add(upper);\n // lower leg\n const lower = new THREE.Mesh(new THREE.CylinderGeometry(0.058,0.050,legLen*0.44,12), pantsMat);\n lower.position.y = -legLen*0.72; lower.castShadow=true; pivot.add(lower);\n // shoe\n const shoe = new THREE.Mesh(new THREE.BoxGeometry(0.115, 0.065, 0.22), shoesMat);\n shoe.position.set(0, -legLen+0.024, 0.04); shoe.castShadow=true; pivot.add(shoe);\n g.add(pivot); return pivot;\n }\n const legL = leg(0.095), legR = leg(-0.095);\n\n // torso \u2014 shirt/coat with slight taper\n const torso = new THREE.Mesh(new THREE.CylinderGeometry(0.195,0.245,0.62,16), coatMat);\n torso.position.y = hipY+0.28; torso.castShadow=true; g.add(torso);\n // collar detail (slightly lighter)\n const collarCol = new THREE.Color(coat).lerp(new THREE.Color('#ffffff'), 0.12);\n const collar = new THREE.Mesh(new THREE.CylinderGeometry(0.100,0.100,0.07,14),\n new THREE.MeshStandardMaterial({color:collarCol, roughness:0.7}));\n collar.position.y = hipY+0.59; g.add(collar);\n // shoulders cap \u2014 slight ellipsoid\n const sh = new THREE.Mesh(new THREE.SphereGeometry(0.245,16,12), coatMat);\n sh.scale.set(1,0.46,0.80); sh.position.y = hipY+0.58; sh.castShadow=true; g.add(sh);\n\n // arms (pivot at shoulder) \u2014 upper arm + forearm two-part\n const shY = hipY+0.54, armLen=0.60;\n function arm(sx){\n const pivot=new THREE.Group(); pivot.position.set(sx, shY, 0);\n // upper arm\n const ua=new THREE.Mesh(new THREE.CylinderGeometry(0.056,0.048,armLen*0.52,12), coatMat);\n ua.position.y=-armLen*0.26; ua.castShadow=true; pivot.add(ua);\n // forearm\n const fa=new THREE.Mesh(new THREE.CylinderGeometry(0.044,0.038,armLen*0.44,12), headMat);\n fa.position.y=-armLen*0.74; fa.castShadow=true; pivot.add(fa);\n // hand \u2014 sphere\n const hand=new THREE.Mesh(new THREE.SphereGeometry(0.048,10,8), headMat);\n hand.scale.set(0.9,0.75,1.1); hand.position.y=-armLen-0.02; hand.castShadow=true; pivot.add(hand);\n g.add(pivot); return pivot;\n }\n const armL=arm(0.265), armR=arm(-0.265);\n\n // neck\n const neck=new THREE.Mesh(new THREE.CylinderGeometry(0.058,0.068,0.096,12), headMat);\n neck.position.y=hipY+0.638; g.add(neck);\n\n // head \u2014 slightly elongated sphere\n const headM=new THREE.Mesh(new THREE.SphereGeometry(0.132,20,16), headMat);\n headM.scale.set(0.97,1.08,0.96);\n headM.position.y=hipY+0.806; headM.castShadow=true; g.add(headM);\n\n // ears \u2014 flattened spheres\n for(const ex of [0.132, -0.132]){\n const ear=new THREE.Mesh(new THREE.SphereGeometry(0.038,8,6), headMat);\n ear.scale.set(0.38,0.62,0.55); ear.position.set(ex, hipY+0.80, 0); g.add(ear);\n }\n\n // hair cap \u2014 upper hemisphere\n const hairCap=new THREE.Mesh(new THREE.SphereGeometry(0.140,16,8,0,Math.PI*2,0,Math.PI*0.56), hairMat);\n hairCap.position.y=hipY+0.820; hairCap.castShadow=true; g.add(hairCap);\n\n // eyes \u2014 tiny dark spheres\n const eyeCol=new THREE.Color(hair).lerp(new THREE.Color('#060606'),0.7);\n const eyeMat=new THREE.MeshStandardMaterial({color:eyeCol, roughness:0.4});\n for(const ex of [0.048,-0.048]){\n const eye=new THREE.Mesh(new THREE.SphereGeometry(0.018,6,5), eyeMat);\n eye.position.set(ex, hipY+0.832, 0.118); g.add(eye);\n }\n\n if(accent){ // guard cap\n const capMat=new THREE.MeshStandardMaterial({color:accent,roughness:0.55});\n const cap=new THREE.Mesh(new THREE.CylinderGeometry(0.148,0.148,0.085,18), capMat);\n cap.position.y=hipY+0.938; cap.castShadow=true; g.add(cap);\n const brim=new THREE.Mesh(new THREE.CylinderGeometry(0.192,0.192,0.018,18), capMat);\n brim.position.set(0,hipY+0.902,0.045); g.add(brim);\n }\n scene.add(g);\n return { group:g, legL, legR, armL, armR, baseY:0, targetYaw:0, phase:Math.random()*6 };\n}\nfunction animateGait(p, amp){\n const s=Math.sin(p.phase);\n p.legL.rotation.x = s*0.55*amp;\n p.legR.rotation.x = -s*0.55*amp;\n p.armL.rotation.x = -s*0.40*amp;\n p.armR.rotation.x = s*0.40*amp;\n p.group.position.y = amp>0 ? Math.abs(Math.sin(p.phase))*0.028 : p.baseY;\n}\nfunction setSeatedPose(p){\n // lower the body so the hips rest on the bench seat, thighs forward\n p.group.position.y = SEAT_Y - 0.92;\n p.legL.rotation.x = 1.45; p.legR.rotation.x = 1.45;\n p.armL.rotation.x = 0.35; p.armR.rotation.x = 0.35;\n}\nfunction lerpAngle(a,b,t){ let d=((b-a+Math.PI)%(Math.PI*2))-Math.PI; if(d<-Math.PI)d+=Math.PI*2; return a+d*t; }\n\n// visitors patrol within a single wing (never cross the doorway walls at z=36/18/4)\nconst VISITORS = [\n // Street wing z0=18 z1=36\n { x: 6.2, zA:20, zB:34, coat:'#4a4742', pants:'#2e2b28', head:'#c2b29a', hair:'#1e1810', shoes:'#181614', speed:1.05, wingKey:'street' },\n { x:-6.2, zA:34, zB:20, coat:'#6b6256', pants:'#343026', head:'#b59c82', hair:'#8a5c28', shoes:'#1c1814', speed:0.95, wingKey:'street' },\n // paired couple: Street\n { x: 5.6, zA:22, zB:33, coat:'#c8bfb0', pants:'#3a3630', head:'#d4a882', hair:'#4a3420', shoes:'#2a2420', speed:0.88, wingKey:'street', pairedIdx:3 },\n { x: 6.4, zA:22, zB:33, coat:'#3c5248', pants:'#282e2a', head:'#c0a888', hair:'#2a2018', shoes:'#1e1c18', speed:0.88, wingKey:'street', pairedIdx:2 },\n // Portraits wing z0=4 z1=18\n { x: 6.0, zA:6, zB:16, coat:'#2c2a26', pants:'#1e1c18', head:'#a88e72', hair:'#0e0c0a', shoes:'#141210', speed:1.15, wingKey:'portraits' },\n { x:-6.0, zA:16, zB:6, coat:'#8a8175', pants:'#4a4640', head:'#cab08e', hair:'#c8a258', shoes:'#2a2620', speed:0.9, wingKey:'portraits' },\n // Places wing z0=-24 z1=4\n { x: 6.2, zA:-21,zB:-4, coat:'#46504e', pants:'#282e2c', head:'#9c8268', hair:'#1a1614', shoes:'#1c1e1c', speed:1.0, wingKey:'places' },\n { x:-5.8, zA:-4, zB:-21,coat:'#5a4f46', pants:'#302a26', head:'#bba98f', hair:'#3c3028', shoes:'#1e1a16', speed:1.1, wingKey:'places' },\n];\nconst visitors = VISITORS.map((c,i)=>{\n const person = makePerson({ coat:c.coat, pants:c.pants||'#2a2824', head:c.head, hair:c.hair||'#1e1810', shoes:c.shoes||'#1c1a16' });\n const ptA={x:c.x,z:c.zA}, ptB={x:c.x,z:c.zB};\n person.group.position.set(c.x, 0, c.zA);\n return { person, x:c.x, ptA, ptB, target:ptB, state:'walk', timer:0, speed:c.speed, wingKey:c.wingKey, bench:null, artTarget:null, pairedIdx:c.pairedIdx??null };\n});\n\n// guard at the entrance, just inside the doorway, off to one side\nconst guard = makePerson({ coat:'#23262b', pants:'#1c1e22', head:'#c2b29a', hair:'#1a1612', shoes:'#0e1012', accent:'#1a1c20' });\nguard.group.position.set(-5.2, 0, 41.5);\nguard.targetYaw = 0.6;\n\nconst NPC_R = 0.38; // NPC collision radius\nfunction resolveNPC(px, pz){\n for(let it=0;it<4;it++){\n for(const s of colliders){\n const [cx,cz]=closestOnSeg(px,pz,s);\n let dx=px-cx, dz=pz-cz; let d=Math.hypot(dx,dz);\n const min=NPC_R+s.half;\n if(d<min){ if(d<1e-4){dx=1;dz=0;d=1;} px=cx+dx/d*min; pz=cz+dz/d*min; }\n }\n }\n px=Math.max(-HALF_W+NPC_R+0.05, Math.min(HALF_W-NPC_R-0.05, px));\n pz=Math.max(Z_NORTH+NPC_R+0.05, Math.min(Z_SOUTH-NPC_R-0.05, pz));\n return [px,pz];\n}\n\nfunction moveToward(p, tx, tz, sp, dt){\n const px=p.group.position.x, pz=p.group.position.z;\n const dx=tx-px, dz=tz-pz, d=Math.hypot(dx,dz);\n if(d<0.22) return true;\n const ux=dx/d, uz=dz/d;\n p._yaw = Math.atan2(ux,uz);\n let nx=px+ux*sp*dt, nz=pz+uz*sp*dt;\n // don't walk through the player: pause politely if too close\n const o=controls.getObject().position;\n if(Math.hypot(nx-o.x, nz-o.z) < 0.95) return false;\n // apply wall/bench collision\n [nx,nz] = resolveNPC(nx, nz);\n p.group.position.x=nx; p.group.position.z=nz;\n p.phase += dt*sp*3.4;\n return false;\n}\n\n// wing z-bounds for clamping NPC z so they never cross a doorway\nconst WING_Z = {\n portraits: { min:12.6, max:35.4 },\n street: { min:-11.4, max:11.4 },\n places: { min:-35.4, max:-12.6 },\n};\n\nfunction updatePeople(dt, time){\n const o = controls.getObject();\n for(let vi=0;vi<visitors.length;vi++){\n const v=visitors[vi];\n const p=v.person;\n\n if(v.state==='walk'){\n // clamp target to wing bounds so NPCs never cross doorways\n const wb=WING_Z[v.wingKey];\n const tz=Math.max(wb.min, Math.min(wb.max, v.target.z));\n const _bx=p.group.position.x, _bz=p.group.position.z;\n if(moveToward(p, v.target.x, tz, v.speed, dt)){\n v._stuckTime=0;\n // clamp current position too\n p.group.position.z = Math.max(wb.min, Math.min(wb.max, p.group.position.z));\n // decide next state: look at art, bench, or patrol view\n const artInWing = artworks.filter(a=>a.art.wingKey===v.wingKey);\n // only pick artworks on the same side as this visitor (avoids cross-room walks)\n const sideArt = artInWing.filter(a=>{\n const fwd2=new THREE.Vector3(0,0,1).applyEuler(a.group.rotation);\n const sx=a.group.position.x+fwd2.x*1.7;\n return v.x>=0 ? sx>-1.0 : sx<1.0;\n });\n const artPool = sideArt.length ? sideArt : artInWing;\n const roll = Math.random();\n if(artPool.length && roll<0.58){\n // pick a random artwork to stand in front of\n const pick = artPool[Math.floor(Math.random()*artPool.length)];\n // stand ~1.6m in front of the artwork, facing it\n const ag=pick.group;\n const fwd=new THREE.Vector3(0,0,1).applyEuler(ag.rotation);\n const rawX=ag.position.x+fwd.x*1.6, rawZ=ag.position.z+fwd.z*1.6;\n const clampedZ=Math.max(wb.min,Math.min(wb.max,rawZ));\n // resolve stand position through collision so it's never inside a wall/bench\n const [sx,sz]=resolveNPC(rawX, clampedZ);\n v.artTarget={ x:sx, z:sz, yaw:Math.atan2(-fwd.x,-fwd.z) };\n v.state='toArt'; v.timer=0;\n } else if(artPool.length===0 || roll<0.76){\n v.state='view'; v.timer=2+Math.random()*3;\n } else {\n const free=benches.filter(b=>b.wingKey===v.wingKey&&!b.occupied);\n if(free.length){\n v.bench=free[Math.floor(Math.random()*free.length)];\n v.bench.occupied=true; v.state='toBench';\n } else { v.state='view'; v.timer=2+Math.random()*3; }\n }\n } else {\n // clamp z while walking\n const wb2=WING_Z[v.wingKey];\n p.group.position.z=Math.max(wb2.min, Math.min(wb2.max, p.group.position.z));\n // always pull x back toward the patrol column to undo any sideways drift\n p.group.position.x += (v.x - p.group.position.x)*Math.min(1, dt*2.5);\n v.targetYaw=p._yaw; animateGait(p, 1);\n // stuck detection: if barely moving, flip to other patrol point\n const _moved=Math.hypot(p.group.position.x-_bx, p.group.position.z-_bz);\n if(_moved < v.speed*dt*0.25){ v._stuckTime=(v._stuckTime||0)+dt; }\n else { v._stuckTime=0; }\n if((v._stuckTime||0)>2.5){\n v._stuckTime=0;\n v.target=(v.target===v.ptA)?v.ptB:v.ptA;\n p.group.position.x = v.x; // hard snap to column on stuck\n }\n }\n // paired: offset slightly to walk side-by-side\n if(v.pairedIdx!=null && v.state==='walk'){\n const partner=visitors[v.pairedIdx];\n if(partner && partner.state==='walk'){\n // nudge our x slightly toward our natural column\n const nudge=(v.x-p.group.position.x)*0.6*dt;\n p.group.position.x=Math.max(-HALF_W+NPC_R, Math.min(HALF_W-NPC_R, p.group.position.x+nudge));\n }\n }\n } else if(v.state==='view'){\n v.timer-=dt;\n v.targetYaw=v.x>0 ? Math.PI/2 : -Math.PI/2;\n animateGait(p, 0);\n if(v.timer<=0){ v.target=(v.target===v.ptA)?v.ptB:v.ptA; v.state='walk'; }\n } else if(v.state==='toArt'){\n const _ax=p.group.position.x, _az=p.group.position.z;\n if(moveToward(p, v.artTarget.x, v.artTarget.z, v.speed*0.75, dt)){\n v._stuckTime=0;\n v.state='lookArt'; v.timer=12+Math.random()*14;\n v.targetYaw=v.artTarget.yaw;\n } else {\n v.targetYaw=p._yaw; animateGait(p, 1);\n const _am=Math.hypot(p.group.position.x-_ax, p.group.position.z-_az);\n if(_am < v.speed*dt*0.25){ v._stuckTime=(v._stuckTime||0)+dt; }\n else { v._stuckTime=0; }\n if((v._stuckTime||0)>2.5){\n v._stuckTime=0; v.artTarget=null;\n v.target=(v.target===v.ptA)?v.ptB:v.ptA; v.state='walk';\n }\n }\n } else if(v.state==='lookArt'){\n v.timer-=dt;\n v.targetYaw=v.artTarget.yaw;\n animateGait(p, 0);\n // slight head-tilt idle: rock arms gently\n p.armL.rotation.x=Math.sin(time*0.7+vi)*0.04;\n p.armR.rotation.x=Math.sin(time*0.7+vi+1)*0.04;\n if(v.timer<=0){\n v.artTarget=null;\n const free=benches.filter(b=>b.wingKey===v.wingKey&&!b.occupied);\n if(free.length && Math.random()<0.52){\n v.bench=free[Math.floor(Math.random()*free.length)];\n v.bench.occupied=true; v.state='toBench';\n } else { v.target=(v.target===v.ptA)?v.ptB:v.ptA; v.state='walk'; }\n }\n } else if(v.state==='toBench'){\n const _bbx=p.group.position.x, _bbz=p.group.position.z;\n if(moveToward(p, v.bench.x, v.bench.z, v.speed*0.82, dt)){\n v._stuckTime=0;\n v.state='sit'; v.timer=18+Math.random()*22;\n p.group.position.set(v.bench.x, SEAT_Y-0.92, v.bench.z);\n v.targetYaw=v.bench.faceYaw;\n p.group.rotation.y=v.bench.faceYaw;\n } else {\n v.targetYaw=p._yaw; animateGait(p, 1);\n const _bm=Math.hypot(p.group.position.x-_bbx, p.group.position.z-_bbz);\n if(_bm < v.speed*dt*0.25){ v._stuckTime=(v._stuckTime||0)+dt; }\n else { v._stuckTime=0; }\n if((v._stuckTime||0)>2.5){\n v._stuckTime=0;\n v.bench.occupied=false; v.bench=null;\n v.target=(v.target===v.ptA)?v.ptB:v.ptA; v.state='walk';\n }\n }\n } else if(v.state==='sit'){\n v.timer-=dt;\n setSeatedPose(p);\n p.group.rotation.y=v.bench.faceYaw;\n if(v.timer<=0){\n p.group.position.y=0;\n v.bench.occupied=false; v.bench=null;\n v.target=(v.target===v.ptA)?v.ptB:v.ptA; v.state='walk';\n }\n continue;\n }\n p.group.rotation.y=lerpAngle(p.group.rotation.y, v.targetYaw, Math.min(1,dt*5));\n }\n // guard: gentle idle; turn to face & greet an approaching visitor\n const gdx=o.position.x-guard.group.position.x, gdz=o.position.z-guard.group.position.z;\n const gd=Math.hypot(gdx,gdz);\n if(gd<11 && active()){ guard.targetYaw = Math.atan2(gdx,gdz); }\n else { guard.targetYaw = 0.6 + Math.sin(time*0.4)*0.12; }\n guard.group.rotation.y = lerpAngle(guard.group.rotation.y, guard.targetYaw, Math.min(1,dt*3));\n animateGait(guard, 0);\n // greeting toast\n const near = gd<7.5 && active() && !lbOpen;\n if(near && !greetShown){ greetShown=true; greetEl.classList.add('show'); }\n else if(!near && greetShown){ greetShown=false; greetEl.classList.remove('show'); }\n}\nlet greetShown=false;\nconst greetEl=document.getElementById('greet');\n\n/* ============================== CONTROLS ================================= */\nconst controls = new PointerLockControls(camera, renderer.domElement);\nscene.add(controls.getObject());\ncamera.rotation.order = 'YXZ';\n\n/* ---- Look state: pointer-lock when available, drag-look fallback otherwise ---- */\nlet fallback = false; // true when pointer lock is unavailable (e.g. embedded iframe)\nlet paused = false; // fallback pause state\nlet _brochureActive = false; // true while welcome brochure is visible (blocks look in all modes)\nconst lookEuler = new THREE.Euler(0, 0, 0, 'YXZ');\n\nconst keys = {};\n// Mobile detection: touch device with a narrow viewport\nconst isMobile = ('ontouchstart' in window) && navigator.maxTouchPoints > 0 && window.innerWidth < 1024;\nif(isMobile) document.body.classList.add('mobile-mode');\n// Joystick axes \u2014 set by setupJoystick(), consumed by updateMovement + animate loop\nlet jMoveX = 0, jMoveY = 0; // left pad: x=strafe, y=forward (-1..1)\nlet jLookX = 0, jLookY = 0; // right pad: x=yaw, y=pitch (raw)\nlet jLookXS = 0, jLookYS = 0; // smoothed look axes\n\naddEventListener('keydown', e=>{\n keys[e.code]=true;\n if(e.code==='Escape'){\n if(lbOpen){ closeLightbox(); }\n else if(fallback && active()){ pauseFallback(); }\n }\n});\naddEventListener('keyup', e=>{ keys[e.code]=false; });\n\nconst gate = document.getElementById('gate');\nconst enterBtn = document.getElementById('enter');\n\n// Is the player currently \"in\" the gallery (either locked or fallback-active)?\nfunction active(){ return controls.isLocked || (fallback && !paused); }\n\nfunction showChrome(){\n gate.classList.add('hide');\n ['hud','map','sound','hint','helpbtn'].forEach(id=>document.getElementById(id).classList.add('show'));\n document.getElementById('crosshair').classList.add('show');\n ensureAudio();\n // show welcome brochure once on first entry (brochureShown + openBrochure defined further below)\n if(typeof brochureShown!=='undefined' && !brochureShown){\n brochureShown=true;\n _brochureActive=true;\n if(fallback) paused=true; // freeze drag-look immediately\n else if(controls.isLocked) controls.unlock(); // release pointer lock so camera doesn't spin\n setTimeout(()=>{ if(typeof openBrochure==='function') openBrochure(null); }, 160);\n }\n}\nfunction hideChrome(){\n document.getElementById('crosshair').classList.remove('show');\n}\n\nlet lockAttempts = 0;\nfunction enterGallery(){\n if(fallback){ resumeFallback(); return; }\n // Mobile: pointer lock doesn't exist; go straight to fallback drag/joystick mode\n if(isMobile){ enableFallback(); return; }\n lockAttempts++;\n // Pointer lock requires the 'allow-pointer-lock' permission, which embedded\n // (sandboxed) iframes don't have. In that case go straight to free mouse-look.\n const inIframe = (()=>{ try { return window.self !== window.top; } catch(e){ return true; } })();\n if(inIframe){ enableFallback(); return; }\n // Standalone tab: try true pointer lock; fall back if the environment blocks it.\n let resolved = false;\n const onErr = ()=>{ resolved = true; document.removeEventListener('pointerlockerror', onErr); enableFallback(); };\n document.addEventListener('pointerlockerror', onErr, { once:true });\n try {\n const p = renderer.domElement.requestPointerLock();\n if(p && p.catch) p.catch(()=>{ if(!fallback) enableFallback(); });\n } catch(e){ enableFallback(); return; }\n setTimeout(()=>{\n document.removeEventListener('pointerlockerror', onErr);\n if(!resolved && !controls.isLocked && !fallback){ enableFallback(); }\n }, 400);\n}\nenterBtn.addEventListener('click', ()=>{\n // Show the brochure BEFORE acquiring pointer lock / enabling fallback.\n // This ensures the camera is completely static during the welcome screen.\n // enterGallery() (pointer lock or fallback) is only called when \"Step Inside\" is clicked.\n if(!brochureShown){\n brochureShown = true;\n _brochureActive = true;\n gate.classList.add('hide');\n setTimeout(()=>{ openBrochure(null); }, 160);\n } else {\n enterGallery();\n }\n});\n\n/* ---- Mobile joystick setup ---- */\n(function(){\n if(!isMobile) return;\n\n function setupJoystick(padId, knobId, onMove){\n const pad = document.getElementById(padId);\n const knob = document.getElementById(knobId);\n const MAXR = 30; // max knob travel radius (pad radius 54 - knob radius 23 - 1)\n let tid = -1;\n function move(cx, cy){\n const rect = pad.getBoundingClientRect();\n const dx = cx - (rect.left + rect.width/2);\n const dy = cy - (rect.top + rect.height/2);\n const dist = Math.hypot(dx, dy);\n const clamped = Math.min(dist, MAXR);\n const ratio = clamped / (dist || 1);\n const ox = dx * ratio, oy = dy * ratio;\n knob.style.transform = `translate(calc(-50% + ${ox}px), calc(-50% + ${oy}px))`;\n onMove(ox / MAXR, oy / MAXR);\n }\n function reset(){ tid = -1; knob.style.transform='translate(-50%,-50%)'; onMove(0,0); }\n pad.addEventListener('touchstart', e=>{\n e.preventDefault();\n if(tid !== -1) return;\n tid = e.changedTouches[0].identifier;\n move(e.changedTouches[0].clientX, e.changedTouches[0].clientY);\n }, {passive:false});\n pad.addEventListener('touchmove', e=>{\n e.preventDefault();\n for(const t of e.changedTouches){ if(t.identifier===tid) move(t.clientX, t.clientY); }\n }, {passive:false});\n pad.addEventListener('touchend', e=>{\n for(const t of e.changedTouches){ if(t.identifier===tid) reset(); }\n });\n pad.addEventListener('touchcancel', e=>{\n for(const t of e.changedTouches){ if(t.identifier===tid) reset(); }\n });\n }\n\n setupJoystick('jleft', 'jlknob', (x,y)=>{ jMoveX=x; jMoveY=-y; }); // -y: up=forward\n setupJoystick('jright', 'jrknob', (x,y)=>{ jLookX=x; jLookY=y; });\n\n // Touch-tap on canvas \u2192 open lightbox / contact (only if it's a true tap, not a joystick drag)\n let tapX=0, tapY=0, tapT=0;\n renderer.domElement.addEventListener('touchstart', e=>{\n if(e.touches.length!==1) return;\n tapX=e.touches[0].clientX; tapY=e.touches[0].clientY; tapT=Date.now();\n }, {passive:true});\n renderer.domElement.addEventListener('touchend', e=>{\n if(e.changedTouches.length!==1) return;\n const dx=e.changedTouches[0].clientX-tapX, dy=e.changedTouches[0].clientY-tapY;\n if(Math.hypot(dx,dy)<18 && Date.now()-tapT<320){\n if(!active()||lbOpen||contactOpen||_brochureActive) return;\n if(hoveredClickable && hoveredClickable.userData.type==='contact') openContactForm();\n else if(hovered) openLightbox(hovered);\n }\n }, {passive:true});\n})();\n\n// Swallow benign pointer-lock rejections (sandboxed iframes block the API).\naddEventListener('unhandledrejection', e=>{\n if(e.reason && /pointer ?lock/i.test(String(e.reason.message||e.reason))){ e.preventDefault(); }\n});\n\ncontrols.addEventListener('lock', ()=>{ showChrome(); });\ncontrols.addEventListener('unlock', ()=>{\n if(lbOpen || fallback || _brochureActive) return; // lightbox / fallback / brochure manage their own state\n gate.classList.remove('hide');\n gate.querySelector('h1').innerHTML='Paused';\n gate.querySelector('.sub').innerHTML='Take a breath. The gallery is right where you left it.';\n enterBtn.textContent='Resume';\n document.getElementById('load').textContent='';\n hideChrome();\n});\n\n/* ---- Fallback drag-look (works inside iframes / when pointer lock is denied) ---- */\nfunction enableFallback(){\n fallback = true; paused = false;\n // sync look euler from current camera orientation\n lookEuler.setFromQuaternion(camera.quaternion);\n lookEuler.z = 0;\n showChrome();\n mlookEl.classList.add('show');\n freeBaseYaw = lookEuler.y;\n document.getElementById('hint').innerHTML = isMobile\n ? '<div>Left stick to walk \u00b7 Right stick to look<\/div><div>Tap a work to view it<\/div>'\n : '<div><kbd>W<\/kbd><kbd>A<\/kbd><kbd>S<\/kbd><kbd>D<\/kbd> Walk \u00b7 Drag mouse to look<\/div>'+\n '<div>Click a work to view \u00b7 <kbd>H<\/kbd> Controls \u00b7 <kbd>Esc<\/kbd> Pause<\/div>';\n}\nfunction pauseFallback(){\n paused = true;\n gate.classList.remove('hide');\n gate.querySelector('h1').innerHTML='Paused';\n gate.querySelector('.sub').innerHTML='Drag to look \u00b7 WASD to walk. The gallery is right where you left it.';\n enterBtn.textContent='Resume';\n document.getElementById('load').textContent='';\n hideChrome();\n}\nfunction resumeFallback(){\n paused = false;\n showChrome();\n}\n\nlet dragging=false, lastX=0, lastY=0, dragMoved=0;\nconst LOOK_SENS = 0.0024;\nfunction setCursor(){\n const cv = renderer.domElement;\n if(!fallback || paused || lbOpen || glossOpen || _brochureActive){ cv.style.cursor='default'; return; }\n cv.style.cursor = mouseLook ? 'crosshair' : (dragging ? 'grabbing' : 'grab');\n}\nrenderer.domElement.addEventListener('pointerdown', e=>{\n if(!fallback || paused || lbOpen || _brochureActive) return;\n dragging=true; dragMoved=0; lastX=e.clientX; lastY=e.clientY;\n renderer.domElement.setPointerCapture?.(e.pointerId);\n setCursor();\n});\naddEventListener('pointermove', e=>{\n if(!dragging || mouseLook || _brochureActive) return; // when free-look is on, steering handles rotation\n const dx=e.clientX-lastX, dy=e.clientY-lastY;\n lastX=e.clientX; lastY=e.clientY;\n dragMoved += Math.abs(dx)+Math.abs(dy);\n lookEuler.y -= dx*LOOK_SENS;\n lookEuler.x -= dy*LOOK_SENS;\n lookEuler.x = Math.max(-Math.PI/2+0.08, Math.min(Math.PI/2-0.08, lookEuler.x));\n camera.quaternion.setFromEuler(lookEuler);\n});\naddEventListener('pointerup', e=>{\n if(!dragging) return;\n dragging=false; setCursor();\n // a tap (negligible drag) on a hovered work opens it\n if(dragMoved < 7 && !lbOpen && !contactOpen){\n if(hoveredClickable && hoveredClickable.userData.type==='contact'){ openContactForm(); }\n else if(hovered){ openLightbox(hovered); }\n }\n});\n\n/* ---- Hands-free look (toggle): eased, settling; pans only near the screen edges ---- */\nlet mouseLook = false; // default OFF \u2192 smooth drag-look (most predictable)\nlet mInside = false, mX = 0, mY = 0;\nlet freeBaseYaw = 0; // yaw the view eases around / pans from\nrenderer.domElement.addEventListener('mousemove', e=>{\n const r = renderer.domElement.getBoundingClientRect();\n mX = ((e.clientX-r.left)/r.width)*2 - 1;\n mY = ((e.clientY-r.top)/r.height)*2 - 1;\n mInside = true;\n});\nrenderer.domElement.addEventListener('mouseleave', ()=>{ mInside=false; });\nfunction updateMouseLook(dt){\n if(!mouseLook || lbOpen || paused || _brochureActive) return;\n if(!(fallback && active())) return;\n const k = Math.min(1, dt*14); // fast, responsive easing\n if(mInside){\n // edge pan: outer 35% of screen continuously rotates the base yaw\n const EDGE = 0.65, PAN = 2.4;\n if(Math.abs(mX) > EDGE){\n freeBaseYaw -= Math.sign(mX) * ((Math.abs(mX)-EDGE)/(1-EDGE)) * PAN * dt;\n }\n // cursor maps to eased look angle \u2014 wider range for intuitive sweep\n const innerX = Math.max(-1, Math.min(1, mX / EDGE));\n const yawTarget = freeBaseYaw - innerX * 1.1; // \u00b1~63\u00b0\n const pitchTarget = -Math.max(-1, Math.min(1, mY)) * 0.65; // \u00b1~37\u00b0\n lookEuler.y += (yawTarget - lookEuler.y) * k;\n lookEuler.x += (pitchTarget - lookEuler.x) * k;\n }\n lookEuler.x = Math.max(-Math.PI/2+0.08, Math.min(Math.PI/2-0.08, lookEuler.x));\n camera.quaternion.setFromEuler(lookEuler);\n}\nconst mlookEl = document.getElementById('mlook');\nfunction setMouseLook(on){\n mouseLook = on;\n mlookEl.classList.toggle('on', on);\n document.getElementById('mlook-txt').textContent = on ? 'Free Look On' : 'Free Look';\n if(on) freeBaseYaw = lookEuler.y; // start panning from wherever we're facing\n setCursor();\n}\nmlookEl.addEventListener('click', ()=> setMouseLook(!mouseLook));\n\n/* ---- Controls glossary (H / ? button) ---- */\nconst glossary = document.getElementById('glossary');\nlet glossOpen = false;\nfunction openGlossary(){\n glossOpen = true;\n if(controls.isLocked) controls.unlock();\n glossary.classList.add('open');\n requestAnimationFrame(()=>glossary.classList.add('in'));\n}\nfunction closeGlossary(){\n glossOpen = false;\n glossary.classList.remove('in');\n setTimeout(()=>glossary.classList.remove('open'), 300);\n}\nfunction toggleGlossary(){ glossOpen ? closeGlossary() : openGlossary(); }\ndocument.getElementById('helpbtn').addEventListener('click', toggleGlossary);\ndocument.getElementById('glossclose').addEventListener('click', closeGlossary);\nglossary.addEventListener('click', e=>{ if(e.target===glossary) closeGlossary(); });\naddEventListener('keydown', e=>{\n if(e.code==='KeyH' && !lbOpen){ e.preventDefault(); toggleGlossary(); }\n else if(e.code==='Escape' && glossOpen){ closeGlossary(); }\n else if(e.code==='KeyF' && active() && !lbOpen){ e.preventDefault(); setMouseLook(!mouseLook); }\n});\n\n/* ============================== COLLISION ================================ */\nconst PR = 0.42; // player radius\nfunction closestOnSeg(px,pz,s){\n const dx=s.x2-s.x1, dz=s.z2-s.z1;\n const l2=dx*dx+dz*dz || 1e-6;\n let t=((px-s.x1)*dx+(pz-s.z1)*dz)/l2; t=Math.max(0,Math.min(1,t));\n return [s.x1+t*dx, s.z1+t*dz];\n}\nfunction resolve(px,pz){\n for(let it=0;it<3;it++){\n for(const s of colliders){\n const [cx,cz]=closestOnSeg(px,pz,s);\n let dx=px-cx, dz=pz-cz; let d=Math.hypot(dx,dz);\n const min=PR+s.half;\n if(d<min){\n if(d<1e-4){ dx=1; dz=0; d=1; }\n px=cx+dx/d*min; pz=cz+dz/d*min;\n }\n }\n }\n // hard outer clamp\n px=Math.max(-HALF_W+PR+0.05, Math.min(HALF_W-PR-0.05, px));\n pz=Math.max(Z_NORTH+PR+0.05, Math.min(Z_SOUTH-PR-0.05, pz));\n return [px,pz];\n}\n\n/* ============================== MOVEMENT + HEADBOB ====================== */\nconst SPEED = 6.2;\nlet bobT = 0, walkDist = 0, lastStepDist = 0;\nconst _fwd = new THREE.Vector3(), _right = new THREE.Vector3(), _up = new THREE.Vector3(0,1,0);\nconst velocity = new THREE.Vector3();\n\n/* ---- player sit-on-bench ---- */\nlet seated=false, sitBench=null;\nconst sitPromptEl = document.getElementById('sitprompt');\nfunction nearestBench(){\n const o=controls.getObject().position;\n let best=null, bd=1e9;\n for(const b of benches){ const d=Math.hypot(o.x-b.x, o.z-b.z); if(d<bd){bd=d;best=b;} }\n return { b:best, d:bd };\n}\nfunction sitOnBench(b){\n seated=true; sitBench=b;\n const o=controls.getObject();\n o.position.set(b.x, SEAT_Y+0.62, b.z); // seated eye height\n velocity.set(0,0,0);\n lookEuler.set(0, b.faceYaw, 0); camera.quaternion.setFromEuler(lookEuler);\n camera.rotation.z = 0;\n}\nfunction standUp(){\n if(!seated) return;\n seated=false;\n const o=controls.getObject();\n o.position.set(sitBench.x, EYE_Y, sitBench.z);\n sitBench=null;\n}\nfunction updateSitPrompt(){\n if(!sitPromptEl) return;\n if(seated){ sitPromptEl.innerHTML='Press <kbd>E<\/kbd> or move to stand'; sitPromptEl.classList.add('show'); return; }\n if(active() && !lbOpen){\n const {d}=nearestBench();\n if(d < 1.7){ sitPromptEl.innerHTML='Press <kbd>E<\/kbd> to sit'; sitPromptEl.classList.add('show'); return; }\n }\n sitPromptEl.classList.remove('show');\n}\naddEventListener('keydown', e=>{\n if(e.code!=='KeyE' || lbOpen) return;\n if(seated){ standUp(); return; }\n if(active()){ const {b,d}=nearestBench(); if(d<1.7) sitOnBench(b); }\n});\n\nfunction updateMovement(dt){\n if(seated){\n // any movement key stands you back up\n if(keys['KeyW']||keys['KeyS']||keys['KeyA']||keys['KeyD']||keys['ArrowUp']||keys['ArrowDown']||keys['ArrowLeft']||keys['ArrowRight']) standUp();\n return;\n }\n let f = (keys['KeyW']||keys['ArrowUp']?1:0) - (keys['KeyS']||keys['ArrowDown']?1:0) + jMoveY;\n let r = (keys['KeyD']||keys['ArrowRight']?1:0) - (keys['KeyA']||keys['ArrowLeft']?1:0) + jMoveX;\n f = Math.max(-1, Math.min(1, f)); r = Math.max(-1, Math.min(1, r));\n camera.getWorldDirection(_fwd); _fwd.y=0; _fwd.normalize();\n _right.crossVectors(_fwd, _up).normalize();\n const want = new THREE.Vector3()\n .addScaledVector(_fwd, f)\n .addScaledVector(_right, r);\n const moving = want.lengthSq()>0;\n if(moving) want.normalize();\n // smooth accel\n const target = want.multiplyScalar(SPEED);\n velocity.x += (target.x - velocity.x)*Math.min(1, dt*12);\n velocity.z += (target.z - velocity.z)*Math.min(1, dt*12);\n\n const obj = controls.getObject();\n let nx = obj.position.x + velocity.x*dt;\n let nz = obj.position.z + velocity.z*dt;\n [nx,nz] = resolve(nx,nz);\n const moved = Math.hypot(nx-obj.position.x, nz-obj.position.z);\n obj.position.x = nx; obj.position.z = nz;\n\n // headbob\n const speedNow = Math.hypot(velocity.x, velocity.z);\n if(speedNow>0.4){\n bobT += dt * speedNow * 1.55;\n walkDist += moved;\n } else { bobT += dt*0.6; }\n const bob = Math.sin(bobT*2)*0.035*Math.min(1, speedNow/SPEED);\n const sway = Math.cos(bobT)*0.022*Math.min(1, speedNow/SPEED);\n obj.position.y = EYE_Y + bob;\n camera.rotation.z = sway*0.5;\n\n // footsteps\n if(walkDist - lastStepDist > 2.05 && speedNow>1){\n lastStepDist = walkDist; footstep(Math.min(1, speedNow/SPEED));\n }\n}\n\n/* ============================== HOVER (raycaster) ======================= */\nconst ray = new THREE.Raycaster();\nray.far = 16;\nconst center = new THREE.Vector2(0,0);\nlet hovered = null;\nconst crosshair = document.getElementById('crosshair');\nconst hoverlabel = document.getElementById('hoverlabel');\nconst hlT = document.getElementById('hl-t'), hlS = document.getElementById('hl-s');\n\nlet hoveredClickable = null; // non-photo clickable currently hovered\n\nfunction updateHover(){\n if(lbOpen || contactOpen || !active()){ setHover(null); setHoverClickable(null); return; }\n ray.setFromCamera(center, camera);\n // check clickables first (contact plaque etc.)\n const chits = ray.intersectObjects(clickables, false);\n if(chits.length && chits[0].distance < 8){\n setHover(null);\n setHoverClickable(chits[0].object);\n return;\n }\n setHoverClickable(null);\n const hits = ray.intersectObjects(photoMeshes, false);\n let found=null;\n if(hits.length){ found = hits[0].object.userData.entry; }\n setHover(found);\n}\nfunction setHoverClickable(obj){\n if(hoveredClickable===obj) return;\n hoveredClickable=obj;\n if(obj){\n crosshair.classList.add('hot');\n hlT.textContent = obj.userData.label || 'Contact';\n hlS.textContent = 'Click to open';\n hoverlabel.classList.add('show');\n } else if(!hovered){\n crosshair.classList.remove('hot');\n hoverlabel.classList.remove('show');\n }\n}\nfunction setHover(e){\n if(hovered===e) return;\n hovered = e;\n if(e){\n crosshair.classList.add('hot');\n hlT.textContent = `Frame ${e.art.id} \u2014 ${e.art.wing}`;\n hlS.textContent = 'Click to view';\n hoverlabel.classList.add('show');\n } else {\n crosshair.classList.remove('hot');\n hoverlabel.classList.remove('show');\n }\n}\n\n/* ============================== LAZY TEXTURE LOADING ==================== */\nconst texLoader = new THREE.TextureLoader();\ntexLoader.crossOrigin = 'anonymous';\nlet activeLoads = 0;\nconst LOAD_DIST = 30;\n\nfunction pumpLoads(){\n if(activeLoads>=6) return;\n // sort nearest unloaded\n const obj = controls.getObject();\n let best=null, bestD=Infinity;\n for(const e of artworks){\n if(e.loaded||e.loading) continue;\n const d = e.pos.distanceTo(obj.position);\n if(d<LOAD_DIST && d<bestD){ bestD=d; best=e; }\n }\n if(!best) return;\n best.loading = true; activeLoads++;\n texLoader.load(thumbURL(best.art),\n tex=>{\n tex.colorSpace = THREE.SRGBColorSpace;\n tex.anisotropy = renderer.capabilities.getMaxAnisotropy();\n best.photo.material.dispose();\n best.photo.material = new THREE.MeshStandardMaterial({ map:tex, roughness:0.62, metalness:0 });\n best.loaded=true; best.loading=false; activeLoads--;\n loadedCount++;\n },\n undefined,\n ()=>{ best.loading=false; activeLoads--; best.loaded=true; /* leave placeholder */ loadedCount++; }\n );\n}\nlet loadedCount=0;\n\n/* ============================== LIGHTBOX ================================ */\nconst lightbox=document.getElementById('lightbox');\nconst lbimg=document.getElementById('lbimg'), lbskel=document.getElementById('lbskel');\nconst lbti=document.getElementById('lbti'), lbme=document.getElementById('lbme');\nlet lbOpen=false, lbIndex=-1;\n\nfunction openLightbox(entry){\n lbIndex = artworks.indexOf(entry);\n lbOpen = true;\n if(controls.isLocked) controls.unlock();\n dragging = false;\n document.getElementById('crosshair').classList.remove('show');\n hoverlabel.classList.remove('show');\n setHover(null);\n lightbox.classList.add('open');\n requestAnimationFrame(()=>lightbox.classList.add('in'));\n loadLightbox(entry);\n}\nfunction loadLightbox(entry){\n const a = entry.art;\n lbskel.style.opacity='1';\n lbti.textContent = a.title;\n lbme.textContent = `${a.wing} \u00b7 Adam's Gallery \u00b7 Film, 2026`;\n lbimg.style.opacity='0';\n // populate caption panel\n const cap = CAPTIONS[a.id];\n const panel = document.getElementById('lbcaption-panel');\n if(cap){\n document.getElementById('lbcp-id').textContent = 'Adam Disatnik';\n document.getElementById('lbcp-title').textContent = 'Frame ' + a.id + ', 2026';\n document.getElementById('lbcp-medium').textContent = '35mm Film';\n document.getElementById('lbcp-body').textContent = cap.b;\n panel.classList.add('visible');\n } else {\n panel.classList.remove('visible');\n }\n const img = new Image();\n img.crossOrigin='anonymous';\n img.onload = ()=>{ lbimg.src=img.src; lbimg.style.opacity='1'; lbskel.style.opacity='0'; };\n img.onerror = ()=>{ lbskel.style.opacity='0'; };\n img.src = fullURL(a);\n}\nfunction stepLightbox(dir){\n if(lbIndex<0) return;\n lbIndex = (lbIndex+dir+artworks.length)%artworks.length;\n loadLightbox(artworks[lbIndex]);\n}\nfunction closeLightbox(){\n if(!lbOpen) return;\n lbOpen=false;\n lightbox.classList.remove('in');\n setTimeout(()=>{ lightbox.classList.remove('open'); }, 400);\n // return to gallery at exact same position\n setTimeout(()=>{\n if(lbOpen || contactOpen) return;\n if(fallback){ document.getElementById('crosshair').classList.add('show'); }\n else { controls.lock(); }\n }, 120);\n}\ndocument.getElementById('lbclose').addEventListener('click', e=>{ e.stopPropagation(); closeLightbox(); });\ndocument.getElementById('lbprev').addEventListener('click', e=>{ e.stopPropagation(); stepLightbox(-1); });\ndocument.getElementById('lbnext').addEventListener('click', e=>{ e.stopPropagation(); stepLightbox(1); });\nlightbox.addEventListener('click', e=>{ if(e.target===lightbox || e.target.id==='lbframe' || e.target.id==='lbphoto-wrap') closeLightbox(); });\n// Block casual photo saving \u2014 right-click, drag, iOS long-press\nlbimg.addEventListener('contextmenu', e=>e.preventDefault());\nlbimg.addEventListener('dragstart', e=>e.preventDefault());\ndocument.getElementById('lbimg-shield').addEventListener('contextmenu', e=>e.preventDefault());\ndocument.getElementById('lbimg-shield').addEventListener('dragstart', e=>e.preventDefault());\n// Block canvas right-click too\nrenderer.domElement.addEventListener('contextmenu', e=>e.preventDefault());\ndocument.getElementById('lbcontact').addEventListener('click', e=>{\n e.stopPropagation();\n const entry = lbIndex >= 0 ? artworks[lbIndex] : null;\n const photoRef = entry ? `${entry.art.title} (${entry.art.id})` : '';\n closeLightbox();\n setTimeout(()=> openContactForm(photoRef), 80);\n});\n\n// click in 3D world to open (pointer-lock mode)\nrenderer.domElement.addEventListener('click', ()=>{\n if(controls.isLocked && hoveredClickable && hoveredClickable.userData.type==='contact'){\n openContactForm(); return;\n }\n if(controls.isLocked && hovered){ openLightbox(hovered); }\n});\n\n/* ============================== MINIMAP ================================= */\nconst mapCanvas=document.getElementById('mapcanvas');\nconst mc=mapCanvas.getContext('2d');\nconst MAP_W=mapCanvas.width, MAP_H=mapCanvas.height;\n// world bounds for map\nconst wxMin=-HALF_W-1, wxMax=HALF_W+1, wzMin=Z_NORTH-1, wzMax=Z_SOUTH+1;\nfunction w2m(x,z){\n const u=(x-wxMin)/(wxMax-wxMin);\n const v=(z-wzMin)/(wzMax-wzMin); // z increases downward south; we want south at bottom\n return [u*MAP_W, (1-v)*MAP_H]; // invert so north(top) ... actually map south(big z)=bottom\n}\nconst HERE = document.getElementById('map-here');\nfunction drawMap(){\n mc.clearRect(0,0,MAP_W,MAP_H);\n // rooms\n const rooms=[\n {z0:36, z1:48, label:'Entrance', fill:'#efe9dc'},\n {z0:18, z1:36, label:'Street', fill:'#f5f2ea'},\n {z0:4, z1:18, label:'Portraits', fill:'#f5f2ea'},\n {z0:-24,z1:4, label:'Places', fill:'#f5f2ea'},\n ];\n rooms.forEach(r=>{\n const [x1,y1]=w2m(-HALF_W,r.z1), [x2,y2]=w2m(HALF_W,r.z0);\n mc.fillStyle=r.fill; mc.fillRect(x1,y1,x2-x1,y2-y1);\n mc.strokeStyle='rgba(20,18,14,.28)'; mc.lineWidth=2; mc.strokeRect(x1,y1,x2-x1,y2-y1);\n mc.fillStyle='rgba(20,18,14,.5)'; mc.font='600 17px Helvetica,Arial'; mc.textAlign='center';\n mc.fillText(r.label.toUpperCase(), (x1+x2)/2, (y1+y2)/2+5);\n });\n // partitions\n mc.strokeStyle='rgba(20,18,14,.32)'; mc.lineWidth=3;\n HALLS.forEach(h=>{\n const [px,py1]=w2m(0,h.z1-4.5),[,py2]=w2m(0,h.z0+4.5);\n mc.beginPath(); mc.moveTo(px,py1); mc.lineTo(px,py2); mc.stroke();\n });\n // player\n const obj=controls.getObject();\n const [px,py]=w2m(obj.position.x, obj.position.z);\n camera.getWorldDirection(_fwd);\n const ang=Math.atan2(_fwd.x, _fwd.z); // heading\n mc.save(); mc.translate(px,py); mc.rotate(-ang);\n // view cone\n mc.fillStyle='rgba(58,125,82,.22)';\n mc.beginPath(); mc.moveTo(0,0); mc.arc(0,0,34,-Math.PI/2-0.5,-Math.PI/2+0.5); mc.closePath(); mc.fill();\n // dot\n mc.fillStyle='#3a7d52'; mc.beginPath(); mc.arc(0,0,6,0,7); mc.fill();\n mc.fillStyle='#fff'; mc.beginPath(); mc.arc(0,0,2.4,0,7); mc.fill();\n mc.restore();\n\n // update \"here\"\n const z=obj.position.z;\n let here='Entrance';\n if(z<=36&&z>12)here='Portraits'; else if(z<=12&&z>-12)here='Street'; else if(z<=-12)here='Places';\n if(HERE.textContent!==here) HERE.textContent=here;\n}\n\ndocument.querySelectorAll('#wings button').forEach(btn=>{\n // reflect the real per-wing photo counts\n const c = (window.GALLERY_WIX && window.GALLERY_WIX.counts) ? window.GALLERY_WIX.counts[btn.dataset.wing] : null;\n if(c!=null){ const n = btn.querySelector('.n'); if(n) n.textContent = c; }\n btn.addEventListener('click', ()=>{\n const wing=btn.dataset.wing;\n const h=HALLS.find(x=>x.key===wing);\n const obj=controls.getObject();\n obj.position.set(0, EYE_Y, h.teleport);\n velocity.set(0,0,0);\n if(!controls.isLocked && !lbOpen) controls.lock();\n });\n});\n\n/* ============================== AUDIO (synth) ========================== */\nlet audioCtx=null, master=null, ambientGain=null, ambientOn=false, ambientBuilt=false;\nfunction ensureAudio(){\n if(!audioCtx){\n try{ audioCtx=new (window.AudioContext||window.webkitAudioContext)(); }catch(e){ return; }\n master=audioCtx.createGain(); master.gain.value=0.85; master.connect(audioCtx.destination);\n }\n if(audioCtx.state==='suspended') audioCtx.resume();\n}\nfunction noiseBuffer(sec){\n const len=audioCtx.sampleRate*sec;\n const buf=audioCtx.createBuffer(1,len,audioCtx.sampleRate);\n const d=buf.getChannelData(0);\n let last=0;\n for(let i=0;i<len;i++){ const w=Math.random()*2-1; d[i]=(last+0.02*w)/1.02; last=d[i]; d[i]*=3.2; }\n return buf;\n}\nfunction buildAmbient(){\n if(ambientBuilt) return; ambientBuilt=true;\n ambientGain=audioCtx.createGain(); ambientGain.gain.value=0; ambientGain.connect(master);\n // airy room tone\n const src=audioCtx.createBufferSource(); src.buffer=noiseBuffer(4); src.loop=true;\n const bp=audioCtx.createBiquadFilter(); bp.type='bandpass'; bp.frequency.value=520; bp.Q.value=0.6;\n const ng=audioCtx.createGain(); ng.gain.value=0.10;\n src.connect(bp); bp.connect(ng); ng.connect(ambientGain);\n // slow filter sweep\n const lfo=audioCtx.createOscillator(); lfo.frequency.value=0.05;\n const lfoG=audioCtx.createGain(); lfoG.gain.value=180;\n lfo.connect(lfoG); lfoG.connect(bp.frequency);\n // low pad\n [60,90.5,135].forEach((f,i)=>{\n const o=audioCtx.createOscillator(); o.type='sine'; o.frequency.value=f;\n const g=audioCtx.createGain(); g.gain.value=0.018-(i*0.004);\n o.connect(g); g.connect(ambientGain); o.start();\n });\n src.start(); lfo.start();\n\n // ---- soft distant chatter / murmur (part of the ambience bed) ----\n const chSrc=audioCtx.createBufferSource(); chSrc.buffer=noiseBuffer(6); chSrc.loop=true;\n const chbp=audioCtx.createBiquadFilter(); chbp.type='bandpass'; chbp.frequency.value=760; chbp.Q.value=1.4;\n const chhp=audioCtx.createBiquadFilter(); chhp.type='highpass'; chhp.frequency.value=320;\n const chGain=audioCtx.createGain(); chGain.gain.value=0.0;\n chSrc.connect(chbp); chbp.connect(chhp); chhp.connect(chGain); chGain.connect(ambientGain);\n // murmur swells (voices rising & falling)\n const chBase=audioCtx.createConstantSource(); chBase.offset.value=0.05;\n const chSwell=audioCtx.createOscillator(); chSwell.type='sine'; chSwell.frequency.value=0.11;\n const chSwellG=audioCtx.createGain(); chSwellG.gain.value=0.035;\n chBase.connect(chGain.gain); chSwell.connect(chSwellG); chSwellG.connect(chGain.gain);\n // formant wobble so it reads as speech, not hiss\n const chForm=audioCtx.createOscillator(); chForm.type='sine'; chForm.frequency.value=0.7;\n const chFormG=audioCtx.createGain(); chFormG.gain.value=240;\n chForm.connect(chFormG); chFormG.connect(chbp.frequency);\n chSrc.start(); chBase.start(); chSwell.start(); chForm.start();\n}\nfunction toggleAmbient(){\n ensureAudio(); if(!audioCtx) return;\n buildAmbient();\n ambientOn=!ambientOn;\n const t=audioCtx.currentTime;\n ambientGain.gain.cancelScheduledValues(t);\n ambientGain.gain.setTargetAtTime(ambientOn?1:0, t, 0.6);\n const el=document.getElementById('sound');\n el.classList.toggle('on', ambientOn);\n document.getElementById('sound-txt').textContent = ambientOn?'Ambience On':'Ambience Off';\n}\ndocument.getElementById('sound').addEventListener('click', toggleAmbient);\n\nlet stepBuf=null;\nfunction footstep(vol){\n ensureAudio(); if(!audioCtx) return;\n if(!stepBuf) stepBuf=noiseBuffer(0.16);\n const t=audioCtx.currentTime;\n const s=audioCtx.createBufferSource(); s.buffer=stepBuf;\n const lp=audioCtx.createBiquadFilter(); lp.type='lowpass'; lp.frequency.value=360+Math.random()*80;\n const g=audioCtx.createGain();\n const peak=0.022*vol;\n g.gain.setValueAtTime(0,t); g.gain.linearRampToValueAtTime(peak,t+0.008);\n g.gain.exponentialRampToValueAtTime(0.0005,t+0.13);\n s.connect(lp); lp.connect(g); g.connect(master);\n s.start(t); s.stop(t+0.16);\n}\n\n/* ============================== FLOOR TEXTURE =========================== */\nfunction makeFloorTexture(){\n const c=document.createElement('canvas'); c.width=256; c.height=512;\n const x=c.getContext('2d');\n // pale oak base\n x.fillStyle='#e7e1d3'; x.fillRect(0,0,256,512);\n // planks\n const pw=256/3;\n for(let i=0;i<3;i++){\n const base=[233,225,210][0]; // unused\n const tint = 222 + (i%2)*6;\n x.fillStyle=`rgb(${tint},${tint-8},${tint-22})`;\n x.fillRect(i*pw,0,pw-1.5,512);\n // grain\n for(let g=0; g<60; g++){\n x.strokeStyle=`rgba(150,135,110,${0.04+Math.random()*0.05})`;\n x.lineWidth=Math.random()*1.2;\n x.beginPath();\n const gx=i*pw+Math.random()*pw;\n x.moveTo(gx,0); x.bezierCurveTo(gx+ (Math.random()*8-4),170, gx+(Math.random()*8-4),340, gx,512);\n x.stroke();\n }\n // plank seam\n x.fillStyle='rgba(120,105,85,.28)'; x.fillRect(i*pw,0,1.5,512);\n }\n return new THREE.CanvasTexture(c);\n}\n\n/* ============================== WELCOME BROCHURE ======================== */\nlet brochureShown = false;\nconst brochureEl = document.getElementById('brochure');\n\nfunction openBrochure(onClose){\n brochureEl.classList.add('open');\n requestAnimationFrame(()=>brochureEl.classList.add('in'));\n document.getElementById('brochure-enter').onclick = ()=>{\n _brochureActive=false;\n brochureEl.classList.remove('in');\n setTimeout(()=>{\n brochureEl.classList.remove('open');\n if(onClose) onClose();\n if(fallback){ mInside=false; paused=false; setMouseLook(true); }\n else { enterGallery(); } // pointer lock only AFTER brochure is fully gone\n }, 350);\n };\n}\n\n// Brochure fires once via the showChrome function (see its definition above).\n\n/* ============================== CONTACT FORM ============================ */\nconst contactOverlay = document.getElementById('contactoverlay');\nlet contactOpen = false;\n\nfunction openContactForm(photoRef){\n contactOpen = true;\n if(controls.isLocked) controls.unlock();\n dragging = false;\n contactOverlay.classList.add('open');\n requestAnimationFrame(()=>contactOverlay.classList.add('in'));\n document.getElementById('cf-status').textContent = '';\n document.getElementById('cf-name').value = '';\n document.getElementById('cf-email').value = '';\n const ctxEl = document.getElementById('cf-context');\n if(photoRef){\n ctxEl.textContent = `Re: ${photoRef}`;\n ctxEl.classList.add('show');\n document.getElementById('cf-msg').value = `Hi Adam,\\n\\nI'd like to ask about \"${photoRef}\".\\n\\n`;\n } else {\n ctxEl.classList.remove('show');\n document.getElementById('cf-msg').value = '';\n }\n // focus email if name is filled, else focus name\n setTimeout(()=> document.getElementById('cf-name').focus(), 80);\n}\nfunction closeContactForm(){\n if(!contactOpen) return;\n contactOpen = false;\n contactOverlay.classList.remove('in');\n document.getElementById('cf-context').classList.remove('show');\n setTimeout(()=>{ contactOverlay.classList.remove('open'); }, 350);\n setTimeout(()=>{ if(!lbOpen && !fallback) controls.lock(); }, 150);\n}\ndocument.getElementById('cf-cancel').addEventListener('click', closeContactForm);\ncontactOverlay.addEventListener('click', e=>{ if(e.target===contactOverlay) closeContactForm(); });\naddEventListener('keydown', e=>{ if(e.code==='Escape' && contactOpen){ closeContactForm(); } });\n\n// Wix anonymous token \u2192 Form submission (Gallery Contact Form)\nconst _SITE_ID = 'b5ad9dc2-082a-4284-8838-ae817fa63c81';\nconst _APP_ID = '68196db0-0877-4d46-97e5-26ecca8479c8';\nconst _FORM_ID = 'd79b9afe-6c1b-46c1-9d7b-d74fa8b22b5a';\n\nasync function submitViaWixForm(name, email, message, photoRef){\n const tokResp = await fetch('https://www.wixapis.com/oauth2/token', {\n method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'},\n body:`grant_type=anonymous&client_id=${_APP_ID}`\n });\n if(!tokResp.ok) throw new Error('token '+tokResp.status);\n const { access_token } = await tokResp.json();\n const hdrs = { 'Authorization':'Bearer '+access_token, 'Content-Type':'application/json' };\n const submissions = {\n first_name_05df: name,\n email_39f4: email,\n long_answer_ea78: message\n };\n if(photoRef) submissions.short_answer_8148 = photoRef;\n const resp = await fetch('https://www.wixapis.com/form-submission-service/v4/submissions', {\n method:'POST', headers:hdrs,\n body:JSON.stringify({ submission:{ formId:_FORM_ID, submissions } })\n });\n if(!resp.ok) throw new Error('submit '+resp.status);\n}\n\ndocument.getElementById('cf-send').addEventListener('click', async ()=>{\n const name = document.getElementById('cf-name').value.trim();\n const email = document.getElementById('cf-email').value.trim();\n const msg = document.getElementById('cf-msg').value.trim();\n const status = document.getElementById('cf-status');\n if(!name){ status.textContent = 'Please enter your name.'; status.className='cmsg err'; return; }\n if(!email || !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email)){\n status.textContent = 'A valid email address is required.';\n status.className = 'cmsg err';\n document.getElementById('cf-email').focus();\n return;\n }\n if(!msg){ status.textContent = 'Please write a message.'; status.className='cmsg err'; return; }\n const btn = document.getElementById('cf-send');\n btn.disabled = true; btn.textContent = 'Sending\u2026';\n status.textContent = ''; status.className = 'cmsg';\n const photoRef = (document.getElementById('cf-context').textContent || '').replace(/^Re:\\s*/,'').trim();\n try {\n await submitViaWixForm(name, email, msg, photoRef||null);\n status.textContent = 'Message sent \u2014 thank you.';\n status.className = 'cmsg';\n btn.textContent = 'Sent \u2713';\n setTimeout(closeContactForm, 2200);\n } catch(err){\n console.warn('Wix form error:', err);\n status.textContent = 'Something went wrong \u2014 please try again.';\n status.className = 'cmsg err';\n btn.disabled=false; btn.textContent='Send Message';\n }\n});\n\n/* ============================== LOADING PROGRESS ======================== */\nconst loadEl=document.getElementById('load');\n\n/* ============================== RESIZE / LOOP =========================== */\naddEventListener('resize', ()=>{\n camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix();\n renderer.setSize(innerWidth, innerHeight);\n});\n\nconst clock=new THREE.Clock();\nlet hoverScaleTarget;\nfunction animate(){\n requestAnimationFrame(animate);\n const dt=Math.min(clock.getDelta(), 0.05);\n const overlayOpen = lbOpen || contactOpen || _brochureActive || (typeof brochureShown!=='undefined' && brochureEl && brochureEl.classList.contains('open'));\n if(active() && !overlayOpen){\n updateMovement(dt);\n updateMouseLook(dt);\n if(isMobile){\n // smooth the raw axis toward target \u2014 feels fluid, not jerky\n jLookXS += (jLookX - jLookXS) * Math.min(1, dt * 9);\n jLookYS += (jLookY - jLookYS) * Math.min(1, dt * 9);\n // dead-zone: ignore micro drift near center\n const lx = Math.abs(jLookXS) > 0.07 ? jLookXS : 0;\n const ly = Math.abs(jLookYS) > 0.07 ? jLookYS : 0;\n if(lx !== 0 || ly !== 0){\n // apply gentle curve: sign(v)*v^1.6 \u2014 slow near center, faster at edge\n const curve = v => Math.sign(v) * Math.pow(Math.abs(v), 1.6);\n lookEuler.y -= curve(lx) * 1.5 * dt;\n lookEuler.x -= curve(ly) * 1.1 * dt;\n lookEuler.x = Math.max(-Math.PI/2+0.08, Math.min(Math.PI/2-0.08, lookEuler.x));\n camera.quaternion.setFromEuler(lookEuler);\n }\n }\n }\n if(isMobile){\n const showJoy = fallback && !paused && !lbOpen && !contactOpen && !_brochureActive &&\n !(typeof brochureShown!=='undefined' && brochureEl && brochureEl.classList.contains('open'));\n document.getElementById('jleft').classList.toggle('show', showJoy);\n document.getElementById('jright').classList.toggle('show', showJoy);\n }\n setCursor();\n updateSitPrompt();\n updatePeople(dt, clock.elapsedTime);\n updateHover();\n\n // smooth hover scale + accent\n for(const e of artworks){\n const want = (e===hovered)?1.021:1.0;\n e.group.scale.lerp(new THREE.Vector3(want,want,want), Math.min(1,dt*10));\n }\n pumpLoads();\n drawMap();\n renderer.render(scene, camera);\n}\nanimate();\n\n// initial preload near spawn so first room looks ready\nlet warm=0; const warmTimer=setInterval(()=>{\n pumpLoads(); warm++;\n if(loadedCount>=10 || warm>40){ loadEl.textContent='Ready'; clearInterval(warmTimer); }\n else loadEl.textContent=`Preparing the rooms\u2026 ${loadedCount}/89`;\n}, 250);\n\nwindow.__gallery = { scene, camera, artworks, controls };\n\n// --- debug preview (real capture only): #debug positions camera in a wing ---\nif(location.hash.includes('debug')){\n gate.style.transition='none';\n gate.classList.add('hide');\n ['hud','map','sound','hint'].forEach(id=>document.getElementById(id).classList.add('show'));\n crosshair.classList.add('show');\n const o=controls.getObject();\n const dv = (location.hash.match(/view=(\\w+)/)||[])[1] || 'wing';\n if(dv==='lobby'){ o.position.set(0,1.9,46); camera.lookAt(0,4.7,36); }\n else if(dv==='room'){ o.position.set(-2,1.68,8); camera.lookAt(7,1.4,0); }\n else { o.position.set(8.6,1.68,21); camera.lookAt(-11,2.0,21); }\n const warm2=setInterval(()=>{for(let i=0;i<8;i++)pumpLoads();},120);\n setTimeout(()=>clearInterval(warm2),6000);\n}\n<\/script>\n\n<!-- Mobile joystick pads \u2014 only shown on touch devices after entering gallery -->\n<div id=\"jleft\" class=\"jpad\"><div class=\"jpad-knob\" id=\"jlknob\"><\/div><span class=\"jpad-label\">Move<\/span><\/div>\n<div id=\"jright\" class=\"jpad\"><div class=\"jpad-knob\" id=\"jrknob\"><\/div><span class=\"jpad-label\">Look<\/span><\/div>\n\n<\/body><\/html>"</script><script>(function(){var el=document.querySelector('script[type="__bundler/template"]');try{var html=JSON.parse(el.textContent);document.open();document.write(html);document.close();}catch(e){document.body.innerHTML='<pre style="color:red">Error unpacking: '+e.message+'</pre>';}})();</script></body></html>