From decbbb4b724fb256d6d2e5db64a362bedd21af8c Mon Sep 17 00:00:00 2001 From: Machiel Date: Wed, 14 Oct 2026 11:48:51 +0200 Subject: [PATCH] =?UTF-8?q?v0.7.4:=20light-versie=20voor=20de=20modelpagin?= =?UTF-8?q?a's=20(light.html,=20js/light/,=20css/light.css;=20later=20in?= =?UTF-8?q?=20de=20HUGO-site=20in=20een=20Bootstrap=20col-12):=20=C3=A9?= =?UTF-8?q?=C3=A9n=20model=20in=203D,=20doorsnede=20(snijhoogte=20tot=20de?= =?UTF-8?q?=20goot)=20en=202D-plattegrond,=20met=20beperkte=20keuzes:=20pe?= =?UTF-8?q?r=20unit=20breedte=20en=20diepte=20(binnen=20UnitModel.LIMITS,?= =?UTF-8?q?=20per=20model/pagina=20te=20beperken),=20dakvorm=20met=20richt?= =?UTF-8?q?ing,=20goot-=20en=20nokhoogte=20(helling=20erbij)=20en=20wolfse?= =?UTF-8?q?ind,=20dakbedekking,=20gevelbekleding=20met=20kleur=20en=20rich?= =?UTF-8?q?ting,=20accent=20van=20het=20model,=20trasraam=20(geen/steen,?= =?UTF-8?q?=20hoogte),=20binnenspots=20en=20buitenverlichting;=20zelfde=20?= =?UTF-8?q?berekening=20als=20de=20configurator=20maar=20alleen=20prijs=20?= =?UTF-8?q?(indicatief,=20incl.=20btw),=20maten,=20m=C2=B2,=20inhoud=20en?= =?UTF-8?q?=20gewicht=20zichtbaar;=20LightModel=20past=20keuzes=20toe=20op?= =?UTF-8?q?=20een=20schone=20kopie=20van=20het=20model=20(binnenwanden=20e?= =?UTF-8?q?n=20inrichting=20schuiven=20mee,=20binnenwanden=20volgen=20de?= =?UTF-8?q?=20goot)=20en=20weigert=20keuzes=20die=20de=20indeling=20breken?= =?UTF-8?q?=20(ramen/deuren,=20binnenwanden,=20inrichting,=20ruimtes,=20ov?= =?UTF-8?q?erlap)=20met=20de=20dichtstbijzijnde=20maat=20die=20wel=20past;?= =?UTF-8?q?=20accordeon-paneel,=20responsief=20op=20de=20breedte=20van=20d?= =?UTF-8?q?e=20tool=20(container=20queries),=20scrollwiel=20scrolt=20de=20?= =?UTF-8?q?pagina=20tenzij=20je=20in=20de=203D=20klikt=20of=20Ctrl=20houdt?= =?UTF-8?q?;=20opslaan=20=3D=20PDF=20(LightPdf):=20voorblad=20"Uw=20woning?= =?UTF-8?q?ontwerp"=20met=20render,=20kerncijfers,=20keuzes,=20prijs,=20co?= =?UTF-8?q?ntact=20en=20QR-code=20naar=20de=20modelpagina=20(qrcode-genera?= =?UTF-8?q?tor,=20MIT),=20aanzichten=20en=20plattegrond=20(HousePdf=20klan?= =?UTF-8?q?tvariant=20zonder=20productie-informatie)=20en=20het=20stappenp?= =?UTF-8?q?lan=20met=20maatwerk=20en=20uitnodiging;=20doorsnede-afdekking?= =?UTF-8?q?=20naar=20HouseModel.sectionCaps=20(ook=20in=20de=20configurato?= =?UTF-8?q?r):=201=20mm=20boven=20de=20snede=20en=20over=20de=20wandtop=20?= =?UTF-8?q?van=20wanden=20die=20lager=20blijven,=20geen=20flikkerend=20reg?= =?UTF-8?q?elwerk=20meer;=20OrbitControls=20met=20touch=20(=C3=A9=C3=A9n?= =?UTF-8?q?=20vinger=20draaien,=20twee=20vingers=20knijpen/schuiven);=20mo?= =?UTF-8?q?del=20Vecht=20(U-vorm=2013=20=C3=97=209,5=20m,=20drie=20units?= =?UTF-8?q?=20rond=20een=20holte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- README.md | 57 +- css/light.css | 189 +++ js/app.js | 2 +- js/house/house-model.js | 177 ++- js/house/house-pdf.js | 42 +- js/house/house.js | 39 +- js/light/light-model.js | 337 ++++++ js/light/light-pdf.js | 227 ++++ js/light/light.js | 658 +++++++++++ js/vendor/OrbitControls.js | 74 +- js/vendor/qrcode.js | 2297 ++++++++++++++++++++++++++++++++++++ light.html | 83 ++ 12 files changed, 4104 insertions(+), 78 deletions(-) create mode 100644 css/light.css create mode 100644 js/light/light-model.js create mode 100644 js/light/light-pdf.js create mode 100644 js/light/light.js create mode 100644 js/vendor/qrcode.js create mode 100644 light.html diff --git a/README.md b/README.md index cfd9528..4f3831e 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ build-stap. Direct openen via `file://` werkt niet (texturen/lucht). ``` index.html app-shell: bovenbalk (menu, documenten, opslaan), sneltoetsen, splashscreen css/app.css alle stijl -js/vendor/ three.js r128, eigen OrbitControls, jsPDF +js/vendor/ three.js r128, eigen OrbitControls, jsPDF, qrcode-generator 1.4.4 (MIT, Kazuhiko Arase; QR in de light-PDF) js/core/ gedeelde helpers: geometrie, weergavelagen (lagenmenu), menubalk, eigenschappenpaneel (inspector.js), UI-kit (werkbalk, weergave, popovers), PDF-bladen, DXF-schrijver (dxf-writer.js), IFC-schrijver (ifc-writer.js), OBJ-schrijver @@ -45,6 +45,9 @@ js/unit/ UnitModel: 1 vloer + 4 wanden, naden, wandstukken js/house/ HouseModel (woning) + woning-PDF + CAD-export (house-dxf.js) + BIM-export (house-ifc.js) + woning-editor js/cost/ CostModel (kostenberekening), posten + noodwaarden, CostCsv (CSV's), venster Kosten & tijden js/app.js menubalk, versienummer, actieve editor +light.html light-versie (modelpagina's), los te openen: light.html?model=amstel +js/light/ LightModel (keuzes op een model toepassen, indelingscontrole, kerncijfers), LightPdf (klant-PDF) + light-UI +css/light.css stijl light-versie, alles onder .tectum-light json/defaults/ referentie-JSON's (niet runtime ingeladen) kosten/ intern.csv (eigen waarden) + leveranciers.csv (live prijzen), bij het starten ingeladen assets/ logo, texturen, skybox @@ -331,6 +334,52 @@ assets/ logo, texturen, skybox metaal, doorzichtigheid glas, gloed lampen); textuurherhaling zit in de UV's (`ObjWriter`). +## Light-versie (modelpagina's) + +Beperkte configurator voor de websitepagina van één model (later in de HUGO-build; nu los via +`light.html?model=`). Inbedden: de stylesheets en scripts uit `light.html` +plus `
` (mount vanzelf), of +`TectumLight.mount(el, { model, frames })`. + +- **Rekent als de configurator** (zelfde `HouseModel.computeParts`, totalen en `CostModel`), maar toont + alleen: indicatieve prijs incl. btw (afgerond op € 100), buitenmaten, bouwhoogte, bruto/leefbaar m², + inhoud en gewicht. Geen uren, zaaglijst, inkoop of kostenopbouw. +- **Weergave:** 3D (afgewerkt, dag-omgeving), Doorsnede (wanden op een instelbare hoogte, standaard 1,5 m en hoogstens de laagste goot of de hoogte van een plat dak, afgesneden en wit afgedekt + met `HouseModel.sectionCaps`, dezelfde als de configurator; dak, dakrand, palen en lampen weg) en een + 2D-plattegrond (ruimtes met netto m², wanden, kozijnen met draairichting, inrichting, dakrand, buitenmaten). +- **Keuzes:** per unit breedte, diepte, goothoogte en dakvorm met richting (lessenaar: hoge kant noord/zuid/ + west/oost; zadel- en wolfsdak: nok noord–zuid of west–oost); bij een hellend dak de nokhoogte (lessenaar: + hoge kant, met de helling eronder; de goot omhoog neemt de nok mee), bij een wolfsdak ook de hoogte van het + wolfseind (bij een dak over de hele woning één keer), dakbedekking, gevelbekleding (hout met richting, felsplaten, stuc, steenstrips; kleur), + het accent van het model (de afwijkende gevel; of geen), trasraam (geen of steen, met hoogte; een cementplaat-trasraam van het model wordt geen), binnenspots aan/uit en het type buitenverlichting. Wolfsdak over de hele + woning alleen als de units samen een rechthoek vormen (zoals de configurator). Geen openingen, + binnenwanden, inrichting of elektra; die volgen het model. +- **Keuzes op een schone kopie:** `LightModel.build` past de keuzes elke keer toe op het model zelf; wat niet + afwijkt blijft precies zoals in het model. Bij een andere maat schuiven binnenwanden mee met hun unit en + schuift wat tegen de oost- of zuidgevel staat (binnenwand-eindpunt, inrichting) mee met die gevel (`BAND`). + Bij een andere hoogte gaan de binnenwanden van die unit evenveel mee. Een andere dakvorm dan die van het + model krijgt de helling uit `LightModel.PITCH` (lessenaar 10°, zadel 30°, wolfsdak 35°). +- **Kaders:** standaard `UnitModel.LIMITS` (maat 2,4–14 m, max 4,7 m in één richting; hoogte 2,4–3 m), per + model te beperken in `LightModel.FRAMES` of per pagina met `frames` (zelfde vorm; `null` = maat staat vast). +- **Indelingscontrole** (`LightModel.check`): een keuze waarbij ramen of deuren niet meer passen + (verschoven, ingeklemd, te hoog of over een naad), een binnenwand buiten de units valt, inrichting door + een gevel steekt, ruimtes verdwijnen of units overlappen, wordt geweigerd; vergeleken met het model zelf. + Bij een maat wordt de dichtstbijzijnde maat gezocht die nog past. +- **Indeling:** paneel als accordeon (Afmetingen, Dak, Gevel, Verlichting; één open, de kop vat de keuze samen), + kerncijfers en prijs met PDF-knop onderaan. Responsief op de breedte van de tool zelf (container queries; in de + site in een Bootstrap col-12): vanaf 720 px naast elkaar, smaller 3D boven en de keuzes eronder met prijs en + PDF vast onderin beeld. Stijl alles onder `.tectum-light` (Bootstrap-reboot geneutraliseerd); Bootstrap Icons + nodig (`css/vendor/bootstrap-icons.min.css`). Het scrollwiel scrolt de pagina, tenzij je in de 3D hebt + geklikt of Ctrl/⌘ ingedrukt houdt. Touch: één vinger draaien, twee vingers knijpen/schuiven (eigen + OrbitControls, geldt ook voor de configurator). +- **Opslaan = PDF** (`LightPdf`): voorblad "Uw woningontwerp" (beeld uit de 3D-weergave, kerncijfers, keuzes + in woorden via `LightModel.describe`, indicatieve prijs, contactgegevens en een QR-code naar de modelpagina + op de site, of `data-url`/`url` van de pagina), dan aanzichten en plattegrond + (`HousePdf.generate(state, { customer: true, doc })`: zonder onderdelenlijst, elementcodes, elektra en + kosten) en als laatste het stappenplan van de site ("Hoe verloopt het traject?") met een blok over maatwerk + met de eigen software (keuken op maat, indeling, ramen en deuren). Contact en stappen staan + in `LightPdf.COMPANY` en `STEPS`. + ## Kosten en prijzen (CSV) Alle waarden van het kostenmodel staan in twee CSV-bestanden in `kosten/`, die @@ -424,7 +473,11 @@ hout.balk.38x235;Constructiehout vuren C24 38×235;€/m;4,91;Houthandel Y;;2026 zijwand; aanbouw met plat dak (toilet, berging, entree met open doorgang naar de woonkamer, daktrim i.p.v. boeidelen); zuidgevel en dak in antraciet felsplaten (zuidrand zonder boeideel, de felsplaten lopen over - de zijwand door), verder verticale rabat. De standaardmodellen staan in + de zijwand door), verder verticale rabat. Model **Vecht** (`HouseModel.createVecht`): U-vorm + 13 × 9,5 m, drie units (slaapvleugel west, tussenlid met entree, leefvleugel oost) met de holte open naar + het noorden; de vleugels lessenaars die naar de holte aflopen, het tussenlid plat op dezelfde goot, de + overkapping van het tussenlid dekt de holte (palen niet nodig); zwarte verticale rabat, wit stucwerk aan de + holte. De standaardmodellen staan in `HouseModel.MODELS`; Bestand › Snel nieuw en het venster Nieuw worden daaruit opgebouwd (nieuw model = één regel plus een `create`-functie die `modelKit` gebruikt). De L-woning (8 × 3,6 m + 4,5 × 3,6 m) blijft diff --git a/css/light.css b/css/light.css new file mode 100644 index 0000000..6b6b73d --- /dev/null +++ b/css/light.css @@ -0,0 +1,189 @@ +/*! + * Tectum + * Copyright © 2026 Machiel Heinen. + * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. + * Zie LICENTIE.md. + */ + +/* Light-versie (modelpagina's). Alles onder .tectum-light, zodat de stijl van de site eromheen (Bootstrap) er niet + mee botst; koppen, labels en knoppen krijgen daarom expliciet marge, gewicht en regelhoogte. De indeling volgt de + breedte van de tool zelf (container queries), niet die van het scherm: in de site staat hij in een col-12. */ +.tectum-light { + --tl-primary: #b95913; + --tl-primary-hover: #9c4a0f; + --tl-primary-soft: rgba(185, 89, 19, 0.10); + --tl-text: #212121; + --tl-muted: #5b6673; + --tl-faint: #8a94a0; + --tl-surface: #ffffff; + --tl-bg: #f9faf5; + --tl-border: #c5ced9; + --tl-border-soft: #e2e7ee; + --tl-radius: 10px; + container: tl / inline-size; + display: block; + width: 100%; + color: var(--tl-text); + font-size: 14px; + line-height: 1.4; + text-align: left; +} +.tectum-light *, .tectum-light *::before, .tectum-light *::after { box-sizing: border-box; } +.tectum-light button { font: inherit; color: inherit; line-height: 1.2; margin: 0; text-transform: none; } +.tectum-light h2, .tectum-light h3 { margin: 0; font-weight: 700; line-height: 1.2; } +.tectum-light label { display: block; margin: 0; } +.tectum-light small { font-size: 11px; color: var(--tl-faint); font-weight: 400; } + +.tectum-light .tl-wrap { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + height: clamp(560px, 80vh, 800px); + border: 1px solid var(--tl-border-soft); + border-radius: var(--tl-radius); + overflow: hidden; + background: var(--tl-surface); +} + +/* 3D / plattegrond */ +.tectum-light .tl-stage { position: relative; min-width: 0; min-height: 0; background: #dfe6ec; } +.tectum-light .tl-3d { position: absolute; inset: 0; } +.tectum-light .tl-3d canvas { display: block; width: 100%; height: 100%; touch-action: none; } +.tectum-light .tl-plan { position: absolute; inset: 0; width: 100%; height: 100%; display: none; background: #fff; } +.tectum-light.view-plan .tl-plan { display: block; } +.tectum-light.view-plan .tl-3d { visibility: hidden; } +.tectum-light .tl-viewbar, .tectum-light .tl-cutbar, .tectum-light .tl-busy, .tectum-light .tl-hint { + position: absolute; background: rgba(255, 255, 255, 0.94); border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); +} +.tectum-light .tl-viewbar { top: 10px; left: 10px; display: flex; gap: 2px; padding: 3px; } +.tectum-light .tl-viewbar button { + display: inline-flex; align-items: center; gap: 6px; border: 0; background: none; padding: 6px 10px; + border-radius: 6px; cursor: pointer; color: var(--tl-muted); white-space: nowrap; +} +.tectum-light .tl-viewbar button:hover { color: var(--tl-text); } +.tectum-light .tl-viewbar button.on { background: var(--tl-primary-soft); color: var(--tl-primary); font-weight: 600; } +.tectum-light .tl-cutbar { top: 52px; left: 10px; display: none; align-items: center; gap: 10px; padding: 6px 12px; font-size: 13px; } +.tectum-light.view-cut .tl-cutbar { display: flex; } +.tectum-light .tl-cutbar input { width: 150px; accent-color: var(--tl-primary); } +.tectum-light .tl-cutbar output { min-width: 4.2em; font-weight: 600; font-variant-numeric: tabular-nums; } +.tectum-light .tl-hint { left: 10px; bottom: 10px; font-size: 12px; color: var(--tl-muted); padding: 3px 8px; box-shadow: none; pointer-events: none; } +.tectum-light .tl-busy { top: 10px; right: 10px; display: none; align-items: center; gap: 8px; padding: 6px 12px; } +.tectum-light.is-busy .tl-busy { display: inline-flex; } +.tectum-light.is-busy .tl-panel { pointer-events: none; } +.tectum-light .tl-spinner { + width: 14px; height: 14px; border: 2px solid var(--tl-border); border-top-color: var(--tl-primary); + border-radius: 50%; animation: tl-spin 0.8s linear infinite; +} +@keyframes tl-spin { to { transform: rotate(360deg); } } +.tectum-light .tl-toast { + position: absolute; left: 50%; bottom: 42px; transform: translate(-50%, 8px); width: max-content; max-width: calc(100% - 24px); + background: #2b2f33; color: #fff; padding: 9px 14px; border-radius: 8px; font-size: 13px; + opacity: 0; transition: opacity 0.2s, transform 0.2s; pointer-events: none; +} +.tectum-light .tl-toast.on { opacity: 1; transform: translate(-50%, 0); } + +/* Paneel */ +.tectum-light .tl-panel { display: flex; flex-direction: column; min-height: 0; border-left: 1px solid var(--tl-border-soft); background: var(--tl-surface); } +.tectum-light .tl-head { padding: 14px 16px 10px; } +.tectum-light .tl-title { font-size: 18px; } +.tectum-light .tl-sub { margin-top: 3px; color: var(--tl-muted); font-size: 12px; } +.tectum-light .tl-sections { flex: 1; min-height: 0; overflow-y: auto; border-top: 1px solid var(--tl-border-soft); } + +/* Accordeon */ +.tectum-light .tl-sec { border-bottom: 1px solid var(--tl-border-soft); } +.tectum-light .tl-sec-head { + display: flex; align-items: center; gap: 9px; width: 100%; padding: 11px 16px; border: 0; background: none; cursor: pointer; text-align: left; +} +.tectum-light .tl-sec-head:hover { background: var(--tl-bg); } +.tectum-light .tl-sec-head > .bi:first-child { color: var(--tl-primary); } +.tectum-light .tl-sec-title { font-weight: 600; } +.tectum-light .tl-sec-sum { flex: 1; min-width: 0; text-align: right; font-size: 12px; color: var(--tl-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tectum-light .tl-chev { font-size: 12px; color: var(--tl-faint); transition: transform 0.15s; } +.tectum-light .tl-sec.open .tl-chev { transform: rotate(180deg); } +.tectum-light .tl-sec.open .tl-sec-sum { visibility: hidden; } +.tectum-light .tl-sec-body { padding: 0 16px 12px; } + +/* Velden */ +.tectum-light .tl-field { margin: 8px 0; } +.tectum-light .tl-label { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; color: var(--tl-muted); margin-bottom: 4px; } +.tectum-light .tl-val { color: var(--tl-text); font-weight: 600; text-align: right; } +.tectum-light .tl-note { font-size: 11px; color: var(--tl-faint); } +.tectum-light .tl-seg { display: flex; flex-wrap: wrap; gap: 4px; } +.tectum-light .tl-seg button { + flex: 1 1 auto; border: 1px solid var(--tl-border); background: var(--tl-surface); border-radius: 7px; + padding: 6px 8px; cursor: pointer; white-space: nowrap; font-size: 13px; +} +.tectum-light .tl-seg button:hover:not(:disabled) { border-color: var(--tl-primary); } +.tectum-light .tl-seg button.on { border-color: var(--tl-primary); background: var(--tl-primary-soft); color: var(--tl-primary); font-weight: 600; } +.tectum-light .tl-units .tl-seg button { flex: 0 0 auto; min-width: 40px; } +.tectum-light button:disabled { opacity: 0.4; cursor: not-allowed; } +.tectum-light button:focus-visible, .tectum-light input:focus-visible, .tectum-light .tl-map-u:focus-visible { outline: 2px solid var(--tl-primary); outline-offset: 2px; } + +.tectum-light .tl-swatches { display: flex; flex-wrap: wrap; gap: 6px; } +.tectum-light .tl-sw { + width: 30px; height: 30px; padding: 0; border-radius: 50%; border: 1px solid rgba(0, 0, 0, 0.18); + background: var(--sw); cursor: pointer; overflow: hidden; box-shadow: 0 0 0 2px var(--tl-surface); +} +.tectum-light .tl-sw img { width: 100%; height: 100%; object-fit: cover; display: block; } +.tectum-light .tl-sw.on { box-shadow: 0 0 0 2px var(--tl-surface), 0 0 0 4px var(--tl-primary); } + +.tectum-light .tl-slider { margin: 8px 0; } +.tectum-light .tl-sl-head { display: flex; justify-content: space-between; gap: 8px; font-size: 13px; } +.tectum-light .tl-sl-head output { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; } +.tectum-light .tl-slider input { display: block; width: 100%; margin: 4px 0 0; accent-color: var(--tl-primary); } + +.tectum-light .tl-mapbox { display: flex; align-items: center; gap: 10px; margin: 6px 0 2px; } +.tectum-light .tl-map { display: block; flex: 1; min-width: 0; max-height: 96px; } +.tectum-light .tl-map-u { cursor: pointer; outline: none; } +.tectum-light .tl-map-u rect { fill: var(--tl-bg); stroke: var(--tl-border); stroke-width: 40; } +.tectum-light .tl-map-u text { fill: var(--tl-muted); font-weight: 600; pointer-events: none; } +.tectum-light .tl-map-u:hover rect { stroke: var(--tl-primary); } +.tectum-light .tl-map-u.on rect { fill: var(--tl-primary-soft); stroke: var(--tl-primary); stroke-width: 60; } +.tectum-light .tl-map-u.on text { fill: var(--tl-primary); } + +/* Kerncijfers en prijs */ +.tectum-light .tl-facts { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 14px; padding: 10px 16px; font-size: 12px; border-top: 1px solid var(--tl-border-soft); background: var(--tl-bg); } +.tectum-light .tl-kv { display: flex; justify-content: space-between; gap: 6px; } +.tectum-light .tl-kv span { color: var(--tl-muted); } +.tectum-light .tl-kv b { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; } +.tectum-light .tl-buy { display: flex; align-items: center; gap: 8px; padding: 10px 16px 12px; background: var(--tl-bg); } +.tectum-light .tl-price { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.tectum-light .tl-price-val { font-size: 21px; font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; } +.tectum-light .tl-price-note { font-size: 11px; color: var(--tl-muted); } +.tectum-light .tl-btn { + display: inline-flex; align-items: center; justify-content: center; gap: 7px; height: 40px; padding: 0 14px; + border: 1px solid var(--tl-border); border-radius: 8px; background: var(--tl-surface); cursor: pointer; +} +.tectum-light .tl-btn:hover { border-color: var(--tl-primary); } +.tectum-light .tl-btn.tl-icon { width: 40px; padding: 0; } +.tectum-light .tl-btn.tl-primary { background: var(--tl-primary); border-color: var(--tl-primary); color: #fff; font-weight: 600; } +.tectum-light .tl-btn.tl-primary:hover { background: var(--tl-primary-hover); } + +/* Middel (bijv. tablet, of een smallere kolom): paneel iets smaller. */ +@container tl (max-width: 980px) { + .tectum-light .tl-wrap { grid-template-columns: minmax(0, 1fr) 300px; } + .tectum-light .tl-viewbar button { padding: 6px 8px; } +} + +/* Smal (telefoon): 3D boven, keuzes eronder; prijs en PDF blijven onderin beeld zolang de keuzes in beeld zijn. */ +@container tl (max-width: 720px) { + .tectum-light .tl-wrap { grid-template-columns: 1fr; height: auto; overflow: visible; } + .tectum-light .tl-stage { height: min(62vh, 115cqw); border-radius: var(--tl-radius) var(--tl-radius) 0 0; overflow: hidden; } + .tectum-light .tl-panel { border-left: 0; border-top: 1px solid var(--tl-border-soft); border-radius: 0 0 var(--tl-radius) var(--tl-radius); } + .tectum-light .tl-sections { overflow: visible; } + .tectum-light .tl-buy { position: sticky; bottom: 0; z-index: 2; border-top: 1px solid var(--tl-border-soft); box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06); border-radius: 0 0 var(--tl-radius) var(--tl-radius); } + .tectum-light .tl-hint { display: none; } + .tectum-light .tl-viewbar { left: 8px; right: 8px; top: 8px; } + .tectum-light .tl-viewbar button { flex: 1; justify-content: center; } + .tectum-light .tl-cutbar { left: 8px; right: 8px; top: 50px; } + .tectum-light .tl-cutbar input { flex: 1; width: auto; } + .tectum-light .tl-busy { top: auto; bottom: 10px; right: 10px; } + /* Grotere tikvlakken */ + .tectum-light .tl-seg button { padding: 9px 8px; } + .tectum-light .tl-sw { width: 36px; height: 36px; } + .tectum-light .tl-sec-head { padding: 13px 16px; } + .tectum-light .tl-slider input { height: 28px; } +} +@container tl (max-width: 380px) { + .tectum-light .tl-viewbar button .bi { display: none; } + .tectum-light .tl-facts { grid-template-columns: 1fr; } +} diff --git a/js/app.js b/js/app.js index 9a4eb3c..06d13ec 100644 --- a/js/app.js +++ b/js/app.js @@ -4,7 +4,7 @@ * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ -const APP_VERSION = '0.7.3'; +const APP_VERSION = '0.7.4'; // 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang. let tabs = []; diff --git a/js/house/house-model.js b/js/house/house-model.js index 2775545..e814697 100644 --- a/js/house/house-model.js +++ b/js/house/house-model.js @@ -760,6 +760,122 @@ window.HouseModel = (() => { return st; } + // Model Vecht: U-vorm 13 × 9,5 m (91,5 m²), de holte (4 m breed) open naar het noorden, de entree aan de + // zuidkant (de onderkant van de U). Drie units: slaapvleugel west (4,6 × 9,5 m), tussenlid met entree en + // toilet (4 × 3,6 m) en leefvleugel oost (4,4 × 7,6 m); de vleugels verschillen 1,9 m in lengte. De vleugels + // hebben een lessenaar die naar de holte afloopt (laag 2800, buiten 3600), het tussenlid een plat dak op + // dezelfde 2800: rond de holte één goothoogte. Het dak over de holte is de overkapping van het tussenlid + // (Unit B › Overkapping › Noord): 0 = open holte, 2400 (standaard) = over de verandadeuren, 4000 = tot het eind + // van de leefvleugel. De rand rust aan beide kanten op een vleugel; h.o.h. 4500, dus zonder palen. Buiten + // zwarte verticale rabat, de gevels aan de holte wit stucwerk. Alles buitenwerks vanaf de noordwesthoek. + function createVecht() { + const st = newModelState('Vecht'); + const S = st.settings, LO = 2800, HI = 3600, FELS = 'fels_antraciet'; + Object.assign(S.facade, { type: 'rabat', rabatColor: 'zwart', stucColor: 'wit' }); + S.cladding = 'verticaal'; + S.trasraam.enabled = false; + Object.assign(S.roof, { fasciaColor: 'zwart', overhang: 300, fasciaHeight: 300 }); + S.roof.posts = PostModel.normalizeSettings({ enabled: true, size: 150, wood: 'eiken', maxSpan: 4500 }); + S.lighting.mode = 'spots'; + S.lighting.direction = 'both'; + if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true }); + if (S.elektra) S.elektra.plateColor = 'antraciet'; + + const A = createUnitEntry('A', null, { width: 4600, depth: 9500, height: LO, windowOn: [] }); + const B = createUnitEntry('B', { to: 'A', side: 'rechts', align: 'end', offset: 0 }, { width: 4000, depth: 3600, height: LO, windowOn: [] }); + const C = createUnitEntry('C', { to: 'B', side: 'rechts', align: 'end', offset: 0 }, { width: 4400, depth: 7600, height: LO, windowOn: [] }); + st.units.push(A, B, C); + UnitModel.applyRoofForm(A.unit, { type: 'lessenaar', dir: 'links', low: LO, high: HI }); + UnitModel.applyRoofForm(C.unit, { type: 'lessenaar', dir: 'rechts', low: LO, high: HI }); + A.roof.covering = C.roof.covering = FELS; + B.roof.extend = { f: 2400, r: 0, b: 0, l: 0 }; + const hof = { type: 'stuc', stuc: 'wit' }; + A.facade.walls.rechts = Object.assign({}, hof); + B.facade.walls.voor = Object.assign({}, hof); + C.facade.walls.links = Object.assign({}, hof); + + const { K, binnen, ops, iw, room, fx } = modelKit(st, LO, 90); + // Noord 'right' en zuid 'left' = vanaf de westhoek van de unit; west 'left' en oost 'right' = vanaf de + // binnenkant van de noordgevel (z 256 in de unit). Wanden 256 mm, binnenwanden 91 mm. + ops(A.unit.walls.voor, [['raam', 1600, 1500, 600, 'right', 2600, K('raam')]]); + ops(A.unit.walls.links, [ + ['raam', 1600, 500, 1700, 'left', 600, K('raam')], // boven het bed + ['raam', 800, 600, 1500, 'left', 4644, K('raam')], // badkamer + ['raam', 800, 1400, 700, 'left', 7344, K('raam')]]); // slaapkamer 2 + ops(A.unit.walls.rechts, [ + ['raam', 1400, 2100, 300, 'right', 944, K('raam')], // slaapkamer → holte + ['raam', 1500, 2400, 0, 'right', 3994, K('raam')]]); // gang + ops(A.unit.walls.achter, [['raam', 1200, 1400, 700, 'left', 900, K('raam')]]); + ops(B.unit.walls.voor, [['raam', 2000, 2400, 0, 'right', 400, K('raam')]]); // zicht vanaf de voordeur op de holte + ops(B.unit.walls.achter, [ + ['voordeur', 1100, 2100, 0, 'left', 800, K('deur', { hinge: 'links', swing: 'buiten', glas: true })], + ['raam', 400, 2100, 0, 'left', 2000, K('raam')], + ['raam', 500, 700, 1400, 'left', 3100, K('raam')]]); + ops(C.unit.walls.voor, [['raam', 2400, 2100, 300, 'right', 1000, K('raam')]]); + // Verandadeuren naar de holte, onder de overkapping. + ops(C.unit.walls.links, [ + ['deur', 900, 2100, 0, 'left', 1744, K('deur', { leaf: 'rechts', hinge: 'links', swing: 'buiten', volglas: true })], + ['deur', 900, 2100, 0, 'left', 2644, K('deur', { leaf: 'links', hinge: 'rechts', swing: 'buiten', volglas: true })]]); + ops(C.unit.walls.rechts, [ + ['raam', 2000, 900, 1900, 'right', 500, K('raam')], // boven de bank + ['raam', 1000, 2100, 300, 'right', 2844, K('raam')], + ['raam', 1200, 1100, 1000, 'right', 4744, K('raam')]]); + ops(C.unit.walls.achter, [['raam', 1400, 1000, 1100, 'left', 1400, K('raam')]]); // boven de spoelbak + + const DH = 2115; + // Noord–zuid: 'buiten' = west, 'left' = vanaf de noordkant; west–oost: 'buiten' = zuid, 'left' = vanaf de westkant. + iw(256, 3902, 4344, 3902, [ // slaapkamer | badkamer, gang + ['deur', 930, DH, 'left', 1944, binnen({ hinge: 'rechts', swing: 'buiten' })], + ['deur', 930, DH, 'left', 3124, binnen({ hinge: 'rechts', swing: 'binnen' })]]); + iw(3300, 3948, 3300, 9244, [['deur', 930, DH, 'left', 2452, binnen({ hinge: 'links', swing: 'buiten' })]]); // badkamer, slaapkamer 2 | gang, techniek + iw(256, 6293, 3254, 6293, [['deur', 930, DH, 'left', 1944, binnen({ hinge: 'rechts', swing: 'binnen' })]]); // badkamer | slaapkamer 2 + iw(3346, 7405, 4500, 7405, [['deur', 800, DH, 'middle', 0, binnen({ hinge: 'links', swing: 'buiten' })]]); // gang | techniek + iw(7260, 7555, 8500, 7555); // hal | toilet + iw(7305, 7601, 7305, 9244, [['deur', 850, DH, 'left', 100, binnen({ hinge: 'links', swing: 'buiten' })]]); // toilet → hal + + ensureSeamWalls(st); + const seamOf = (a, b) => st.interiorWalls.find(w => w.seam && sameSeam(w.seam, a, b)); + // Noord–zuid-naadwand: 'left' = vanaf de binnenkant van de zuidgevel (z 9244). Openingen bij de noordkant van de hal. + const sAB = seamOf('A', 'B'), sBC = seamOf('B', 'C'); + if (sAB) sAB.wall.openings = [{ id: 1, type: 'deur', w: 1000, h: 2300, y: 0, anchor: 'left', offset: 1994, // open doorgang hal → gang (z 6250–7250) + fill: Object.assign(KozijnModel.defaultFill('deur'), { type: 'geen' }) }]; + if (sBC) sBC.wall.openings = [{ id: 1, type: 'deur', w: 930, h: DH, y: 0, anchor: 'left', offset: 2014, // hal → keuken (z 6300–7230) + fill: binnen({ hinge: 'rechts', swing: 'binnen', glas: true }) }]; + + const plafond = { ceiling: LO }; + room('A', 2000, 1500, 'slaapkamer', 'Slaapkamer', plafond); + room('A', 2300, 5100, 'badkamer'); + room('A', 1500, 8800, 'slaapkamer', 'Slaapkamer 2', plafond); + room('A', 3900, 5000, 'hal', 'Gang', plafond); + room('A', 3900, 8000, 'wasruimte', 'Techniek/was', plafond); + room('B', 1200, 1500, 'hal', 'Entree'); + room('B', 3325, 2400, 'toilet'); + room('C', 2000, 3500, 'woonkeuken', 'Woonkamer/keuken'); + + const xB = (x) => x - 4600, zB = (z) => z - 5900, xC = (x) => x - 8600, zC = (z) => z - 1900; + fx('bed', 'A', 1306, 1656, 270, { w: 1600, d: 2100 }, { nachtkastjes: false }); + fx('kledingkast', 'A', 1206, 3556, 180, { w: 1800 }); + fx('douche', 'A', 856, 4398, 0, { w: 1200, d: 900 }, { glas: 'rechts', glasLengte: 900 }); + fx('toilet', 'A', 601, 5250, 270); + fx('wastafel', 'A', 900, 6017, 180, { w: 1000 }); + fx('bed', 'A', 1300, 7339, 0, { w: 1400, d: 2000 }, { nachtkastjes: false }); + fx('kledingkast', 'A', 2954, 8300, 90, { w: 1800 }); + fx('wasmachine', 'A', 3700, 8944, 180); + fx('boiler', 'A', 4200, 9019, 180); + fx('toilet', 'B', xB(7925), zB(8899), 180); + fx('fontein', 'B', xB(7925), zB(7726), 0); + fx('keuken', 'C', xC(10944), zC(8944), 180, { w: 3600 }, { front: 'antraciet', hogeKast: 'links', spoelbak: 0.57, kookplaat: false }); + fx('kookeiland', 'C', xC(10800), zC(7094), 0, { w: 2400, d: 900 }, { front: 'antraciet', kookplaat: true }); + fx('eettafel', 'C', xC(10500), zC(5300), 0, { w: 1800, d: 1000 }, { vorm: 'ovaal', stoelen: 6 }); + fx('bank', 'C', xC(12294), zC(3656), 90, { w: 2400, d: 900 }, { chaise: 'geen' }); + fx('salontafel', 'C', xC(11200), zC(3500), 0, { w: 800, d: 800 }, { vorm: 'rond' }); + fx('fauteuil', 'C', xC(10300), zC(2850), 45); + fx('tvmeubel', 'C', xC(9056), zC(3100), 270, { w: 1400 }); + + computeParts(st, { skip3D: true }); + return st; + } + // Standaardmodellen: Bestand › Snel nieuw en het venster Nieuw. De sleutel is de template. const MODELS = { amstel: { label: 'Woning Amstel', icon: 'house-heart', create: createAmstel }, @@ -768,7 +884,8 @@ window.HouseModel = (() => { waal: { label: 'Woning Waal', icon: 'house-add', create: createWaal }, maas: { label: 'Woning Maas', icon: 'house-check', create: createMaas }, rijn: { label: 'Woning Rijn', icon: 'house-gear', create: createRijn }, - reest: { label: 'Woning Reest', icon: 'house-up', create: createReest } + reest: { label: 'Woning Reest', icon: 'house-up', create: createReest }, + vecht: { label: 'Woning Vecht', icon: 'house-down', create: createVecht } }; function createDefaultState(template) { @@ -1858,6 +1975,62 @@ window.HouseModel = (() => { if (parts.roomsGroup && parts.roomsGroup.userData.ceilings) parts.roomsGroup.userData.ceilings.visible = true; } + // Doorsnede: witte afdekking van de wanden. Hij volgt de bovenkant van elke wand, afgetopt op de snijhoogte cutAbs + // (mm, absoluut): op de snede vlak en onderbroken door de openingen, daaronder over de wandtop (ook schuin), zodat + // een wand die niet tot de snede reikt ook wit afgedekt is. Met gevelbekleding vanaf de buitenkant daarvan (latwerk + // is open); zonder beplating tussen de platen. De afdekking ligt CAP_LIFT_MM boven wand of snede: alles wat daar + // eindigt (regelwerk, platen) valt eronder en flikkert niet mee (dieptebuffer op afstand). + const CAP_LIFT_MM = 1; + function sectionCaps(parts, cutAbs, material, layers) { + const toM = 0.001, g = new THREE.Group(); + const S = WallModel.STONE; + const gevel = ViewLayers.shows(layers, 'gevel'), beplating = ViewLayers.shows(layers, 'beplating'); + allWallPieces(parts).forEach(p => { + const y = cutAbs - p.place.floorTop; + if (y <= 0) return; + const W = p.place.width, T = p.place.thickness, tl = WallModel.topLine(p.config); + const topAt = (x) => tl.at(Math.max(-W / 2, Math.min(W / 2, x))); + const hAt = (x) => Math.min(y, topAt(x)); + const outAt = (h) => { + if (!gevel || !p.place.normal || p.buitenMateriaal === 'geen') return 0; + const tr = p.trasraam; + return tr && h < tr.top ? (WallModel.isStoneFinish(tr.finish) ? S.THICK_MM : 0) : WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc); + }; + const sh = beplating ? 0 : p.config.sheathingThick; + const e = p.skinExt || {}, out0 = outAt(hAt(0)); + const xL = -W / 2 - (e.left > 0 ? e.left + out0 : 0), xR = W / 2 + (e.right > 0 ? e.right + out0 : 0); + // Knikpunten: de wandtop en waar die door de snijhoogte gaat; op de snede de openingen eruit. + const xs = [...new Set([xL, xR, ...tl.pts.map(q => q.x), ...tl.crossings(y)].filter(x => x >= xL && x <= xR))].sort((a, b) => a - b); + const gaps = p.config.openings.map(op => WallModel.openingSpanAtY(op, y)).filter(Boolean) + .map(([a, b]) => [-W / 2 + a, -W / 2 + b]).sort((a, b) => a[0] - b[0]); + const spans = []; + for (let i = 0; i < xs.length - 1; i++) { + const a = xs[i], b = xs[i + 1]; + if (b - a <= 1) continue; + const onCut = topAt((a + b) / 2) >= y - 0.5; + if (!onCut) { spans.push([a, b]); continue; } + let cur = a; + gaps.forEach(([g0, g1]) => { if (g1 <= cur || g0 >= b) return; if (g0 > cur) spans.push([cur, g0]); cur = Math.max(cur, g1); }); + if (b > cur) spans.push([cur, b]); + } + const pos = []; + spans.filter(([a, b]) => b - a > 1).forEach(([a, b]) => { + const ha = hAt(a) + CAP_LIFT_MM, hb = hAt(b) + CAP_LIFT_MM, out = outAt((ha + hb) / 2); + const z0 = -T / 2 + sh, z1 = T / 2 + out - (p.buitenMateriaal !== 'geen' ? sh : 0); + // Twee driehoeken, normaal omhoog. + pos.push(a, ha, z0, a, ha, z1, b, hb, z1, a, ha, z0, b, hb, z1, b, hb, z0); + }); + if (!pos.length) return; + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(pos.map(v => v * toM), 3)); + const m = new THREE.Mesh(geo, material); + m.rotation.y = p.place.rotY; + m.position.set(p.place.cx * toM, p.place.floorTop * toM, p.place.cz * toM); + g.add(m); + }); + return g; + } + // 3D-groepen op hun plek in de woning (m); elektra, palen en dakrand staan al in woningcoördinaten. function placeGroups(parts) { const toM = 0.001; @@ -2206,6 +2379,6 @@ window.HouseModel = (() => { clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, MODELS, createDefaultState, unitRects, computeSeams, overlappingPairs, interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls, houseRoofShape, houseRoofFits, setHouseRoof, coverExtensions, EXTEND_MAX, - computeParts, placeGroups, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize + computeParts, placeGroups, sectionCaps, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize }; })(); diff --git a/js/house/house-pdf.js b/js/house/house-pdf.js index 09380e1..ab0a7d7 100644 --- a/js/house/house-pdf.js +++ b/js/house/house-pdf.js @@ -42,11 +42,13 @@ window.HousePdf = (() => { return list; } + // customer (light-versie): zonder onderdelenlijst, elementcodes en elektra; de tekening schuift dan naar links. function drawPlanSheet(doc, parts, sheets, sheetOpts) { const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS; + const customer = !!sheetOpts.customer; doc.setFont('helvetica', 'normal'); - PdfSheet.table(doc, 8, 14, [0, 16, 60], ['Element', 'Onderdeel', 'Blad'], + if (!customer) PdfSheet.table(doc, 8, 14, [0, 16, 60], ['Element', 'Onderdeel', 'Blad'], sheets.map((s, i) => [s.element, s.label, String(i + 1)]).filter(r => r[0] !== PLAN_ELEMENT), { title: 'Onderdelen', lastColW: 8, maxY: 285, rowH: 3.9, fontSize: 6 }); PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts); @@ -54,7 +56,7 @@ window.HousePdf = (() => { const rects = parts.rects; const bx0 = Math.min(...rects.map(r => r.x0)), bx1 = Math.max(...rects.map(r => r.x1)); const bz0 = Math.min(...rects.map(r => r.z0)), bz1 = Math.max(...rects.map(r => r.z1)); - const drawX0 = 105, drawX1 = 405, drawY0 = 20, drawY1 = 228; + const drawX0 = customer ? 15 : 105, drawX1 = 405, drawY0 = 20, drawY1 = 228; const margin = 32; const scale = Math.min((drawX1 - drawX0 - margin * 2) / (bx1 - bx0), (drawY1 - drawY0 - margin * 2) / (bz1 - bz0)); const ox = (drawX0 + drawX1) / 2 - ((bx0 + bx1) / 2) * scale; @@ -155,7 +157,7 @@ window.HousePdf = (() => { doc.rect(px(l.x + l.normal.x * 80) - 0.9, py(l.z + l.normal.z * 80) - 0.9, 1.8, 1.8, 'FD'); }); - ElektraPdf.drawPlan(doc, parts.elektra, px, py); + if (!customer) ElektraPdf.drawPlan(doc, parts.elektra, px, py); doc.setDrawColor(230, 130, 30); doc.setLineWidth(0.4); @@ -173,13 +175,13 @@ window.HousePdf = (() => { const b = UnitModel.localToWorld(p.place, p.place.width / 2, p.place.thickness / 2); const x0 = px(a.x) + n.x * off, y0 = py(a.z) + n.y * off, x1 = px(b.x) + n.x * off, y1 = py(b.z) + n.y * off; PdfSheet.dimLine(doc, x0, y0, x1, y1); - const text = `${p.element}: ${p.place.width}`; + const text = customer ? String(p.place.width) : `${p.element}: ${p.place.width}`; const tx = (x0 + x1) / 2 + n.x * 2.5, ty = (y0 + y1) / 2 + n.y * 2.5 + (n.y > 0 ? 1.5 : 0); if (n.y === 0) doc.text(text, tx, ty, { align: 'center', angle: 90 }); else doc.text(text, tx, ty, { align: 'center' }); }))); doc.setTextColor(...BLACK); - parts.interior.forEach(p => { + if (!customer) parts.interior.forEach(p => { doc.text(`${p.element}: ${p.place.width}`, px(p.place.cx), py(p.place.cz) - 1.5, { align: 'center', angle: Math.abs(Math.sin(p.place.rotY)) < 0.5 ? 0 : 90 }); }); @@ -226,7 +228,7 @@ window.HousePdf = (() => { doc.setFontSize(6); doc.text('Plattegrond woning — oranje gestippeld = naad tussen units (wand vervalt), openingen gekruist, grijs gestippeld = dakrand, zwart gestippeld = ' + 'boeideel (o = spot), grijs = daktrim, geel blokje = gevellamp, kleurvlak = ruimte (netto m²)', drawX0, drawY1 + 4); - if (parts.elektra && parts.elektra.enabled) doc.text(ElektraPdf.PLAN_LEGEND, drawX0, drawY1 + 7.5); + if (!customer && parts.elektra && parts.elektra.enabled) doc.text(ElektraPdf.PLAN_LEGEND, drawX0, drawY1 + 7.5); } const hexRgb = (h) => { const n = parseInt(String(h).replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }; @@ -764,29 +766,37 @@ window.HousePdf = (() => { PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts); } - function generate(state) { + // opts.customer (light-versie): alleen aanzichten en plattegrond, zonder productie-informatie en kosten. + // opts.doc: verder in een bestaand document (nieuwe pagina's erachter); opts.pageOffset/pageTotal: bladnummering + // als er pagina's omheen komen (voorblad e.d.). + function generate(state, opts) { + const o = opts || {}; + const customer = !!o.customer; const parts = HouseModel.computeParts(state, { wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials(), elektraRough: false }); - const totals = HouseModel.computeTotals(parts); - const sheets = sheetList(parts); - const cost = CostModel.compute(parts, totals); + const sheets = sheetList(parts).filter(s => !customer || s.kind === 'views' || s.kind === 'plan'); + const totals = customer ? null : HouseModel.computeTotals(parts); + const cost = customer ? null : CostModel.compute(parts, totals); const costOf = (element) => cost.elements.find(el => el.element === element); const drawWallWithCost = (sheet, opts) => { WallPdf.drawSheet(doc, sheet.piece.config, sheet.piece.frame, Object.assign({ buitenMateriaal: sheet.piece.buitenMateriaal, cladding: sheet.piece.cladding, trasraam: sheet.piece.trasraam, skinExt: sheet.piece.skinExt, facade: sheet.piece.facade, skinFrom: sheet.piece.skinFrom, lamps: sheet.piece.lamps || [], overlay: ElektraPdf.wallOverlay(parts.elektra, sheet.piece) }, opts)); drawElementCost(doc, costOf(sheet.element)); }; - const detailPages = paginateCostRows(costDetailRows(cost)); - sheets.push({ kind: 'cost', element: 'KOST', label: 'Kostenraming' }); - detailPages.forEach((cols, i) => sheets.push({ kind: 'costdetail', element: `KOST-${i + 1}`, label: 'Kostenraming detail' + (detailPages.length > 1 ? ` (${i + 1})` : ''), cols })); + if (!customer) { + const detailPages = paginateCostRows(costDetailRows(cost)); + sheets.push({ kind: 'cost', element: 'KOST', label: 'Kostenraming' }); + detailPages.forEach((cols, i) => sheets.push({ kind: 'costdetail', element: `KOST-${i + 1}`, label: 'Kostenraming detail' + (detailPages.length > 1 ? ` (${i + 1})` : ''), cols })); + } const project = state.project; - const doc = PdfSheet.createDoc(); + const doc = o.doc || PdfSheet.createDoc(); + const offset = o.pageOffset || 0, total = o.pageTotal || sheets.length; sheets.forEach((sheet, i) => { - if (i > 0) doc.addPage('a3', 'landscape'); - const opts = { project, titel: sheet.label, element: sheet.element, blad: `${i + 1} / ${sheets.length}` }; + if (i > 0 || o.doc) doc.addPage('a3', 'landscape'); + const opts = { project, titel: sheet.label, element: sheet.element, blad: `${offset + i + 1} / ${total}`, customer }; if (sheet.kind === 'views') drawViewsSheet(doc, parts, state, opts); else if (sheet.kind === 'plan') drawPlanSheet(doc, parts, sheets, opts); else if (sheet.kind === 'floor') { diff --git a/js/house/house.js b/js/house/house.js index 03a5ea3..87046bd 100644 --- a/js/house/house.js +++ b/js/house/house.js @@ -373,43 +373,8 @@ function mountHouseEditor(container, callbacks) { const cutPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0); renderer.localClippingEnabled = true; const capMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff }); - function buildPlanCaps(cutAbs) { - const toM = 0.001, g = new THREE.Group(); - const S = WallModel.STONE; - // Latwerk is open: pas vanaf de gevel afdekken. - const gevel = view3D.layers.gevel, beplating = view3D.layers.beplating; - HouseModel.allWallPieces(parts).forEach(p => { - const y = cutAbs - p.place.floorTop; - if (y <= 0 || y >= WallModel.topLine(p.config).maxY) return; - const W = p.place.width, T = p.place.thickness; - let out = 0; - if (gevel && p.place.normal && p.buitenMateriaal !== 'geen') { - const tr = p.trasraam; - out = tr && y < tr.top ? (WallModel.isStoneFinish(tr.finish) ? S.THICK_MM : 0) : WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc); - } - const e = p.skinExt || {}; - const xL = -W / 2 - (e.left > 0 ? e.left + out : 0), xR = W / 2 + (e.right > 0 ? e.right + out : 0); - const gaps = p.config.openings.map(op => WallModel.openingSpanAtY(op, y)).filter(Boolean) - .map(([a, b]) => [-W / 2 + a, -W / 2 + b]).sort((a, b) => a[0] - b[0]); - const spans = []; - let cur = xL; - gaps.forEach(([a, b]) => { if (a > cur) spans.push([cur, Math.min(a, xR)]); cur = Math.max(cur, b); }); - if (xR > cur) spans.push([cur, xR]); - const sh = beplating ? 0 : p.config.sheathingThick; - const z0 = -T / 2 + sh, z1 = T / 2 + out - (p.buitenMateriaal !== 'geen' ? sh : 0); - spans.filter(([a, b]) => b - a > 1).forEach(([a, b]) => { - const m = new THREE.Mesh(new THREE.PlaneGeometry((b - a) * toM, (z1 - z0) * toM), capMaterial); - m.rotation.x = -Math.PI / 2; - m.position.set((a + b) / 2 * toM, (y - 0.5) * toM, (z0 + z1) / 2 * toM); - const holder = new THREE.Group(); - holder.rotation.y = p.place.rotY; - holder.position.set(p.place.cx * toM, p.place.floorTop * toM, p.place.cz * toM); - holder.add(m); - g.add(holder); - }); - }); - return g; - } + // Snede wit afgedekt; latwerk is open, pas vanaf de gevel afdekken (HouseModel.sectionCaps). + const buildPlanCaps = (cutAbs) => HouseModel.sectionCaps(parts, cutAbs, capMaterial, view3D.layers); let fullRender = false; let skyMode = Skybox.getMode(); const skyEnv = Skybox.createEnvironment(scene); diff --git a/js/light/light-model.js b/js/light/light-model.js new file mode 100644 index 0000000..0c03afb --- /dev/null +++ b/js/light/light-model.js @@ -0,0 +1,337 @@ +/*! + * Tectum + * Copyright © 2026 Machiel Heinen. + * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. + * Zie LICENTIE.md. + */ + +// Light-versie (modelpagina's): hetzelfde model en dezelfde berekeningen als de configurator, met een beperkte set +// keuzes. Keuzes worden altijd opnieuw toegepast op een schone kopie van het model, zodat terug naar de +// oorspronkelijke keuze precies het model geeft. Een keuze die de indeling breekt (ramen en deuren die niet meer +// passen, binnenwanden of inrichting buiten de unit, ruimtes die verdwijnen) wordt geweigerd (check). +window.LightModel = (() => { + const LIM = UnitModel.LIMITS; + // Kaders (mm) als het model of de pagina niets opgeeft; altijd binnen UnitModel.LIMITS. Per model te + // beperken met FRAMES, per pagina met mount-optie frames (zelfde vorm). + const DEFAULT_FRAME = { width: [2400, LIM.long], depth: [2400, LIM.long], height: [2400, LIM.eave] }; + const FRAMES = { + // amstel: { units: { A: { width: [9000, 13000], depth: null } }, height: [2600, 3000] } + }; + const STEP = 50; + // Helling (graden) bij een andere dakvorm dan die van het model. + const PITCH = { lessenaar: 10, zadel: 30, wolf: 35 }; + const FACADES = { rabat: 'Hout', fels: 'Felsplaten', stuc: 'Stucwerk', steen: 'Steenstrips' }; + const STONES = ['K490', 'K915', 'K911', 'K764', 'K518', 'K706']; + const LIGHTING = { geen: 'Geen', spots: 'Spots in de dakrand', gevel: 'Gevellampen' }; + // Punten tot zo ver van de oost- of zuidrand van een unit horen bij die gevel en schuiven mee. + const BAND = 450; + + const clone = HouseModel.clone; + const rectMap = (st) => Object.fromEntries(HouseModel.unitRects(st).map(r => [r.id, r])); + const houseRoof = (st) => !!(st.settings.roof.house.enabled && st.settings.roof.house.form); + + // Alle gevel-overrides van het model (per wand en per wandstuk): het "accent". + function accents(st) { + const out = []; + st.units.forEach(u => Object.entries(u.unit.walls).forEach(([side, ws]) => { + if (u.facade.walls[side]) out.push({ get: () => u.facade.walls[side], set: (v) => { if (v) u.facade.walls[side] = v; else delete u.facade.walls[side]; } }); + (ws.splits || []).forEach(sp => { if (sp && sp.facade) out.push({ get: () => sp.facade, set: (v) => { sp.facade = v || null; } }); }); + })); + return out; + } + const colorOf = (o) => o.type === 'steen' ? o.stone : o.type === 'stuc' ? o.stuc : o.type === 'fels' ? o.fels : null; + + function frameOf(key, override) { + const f = Object.assign({}, FRAMES[key] || {}, override || {}); + const clampR = (r, lim) => r && [Math.max(1000, Math.min(lim, r[0])), Math.min(lim, r[1])]; + return { + unit: (id) => { + const u = (f.units || {})[id] || {}; + const pick = (k, lim) => u[k] === null ? null : clampR(u[k] || DEFAULT_FRAME[k], lim); + return { width: pick('width', LIM.long), depth: pick('depth', LIM.long) }; + }, + height: clampR(f.height || DEFAULT_FRAME.height, LIM.eave) + }; + } + + // Model inladen: schone basis, oorspronkelijke dakvormen en de keuzes zoals het model ze heeft. + function create(key, opts) { + const def = HouseModel.MODELS[key]; + if (!def) throw new Error(`onbekend model "${key}"`); + const base = def.create(); + const forms = Object.fromEntries(base.units.map(u => [u.id, UnitModel.roofForm(u.unit)])); + if (houseRoof(base)) forms.house = Object.assign({}, base.settings.roof.house.form); + const f = base.settings.facade, acc = accents(base).map(a => a.get()); + const covering = (base.units.find(u => RoofModel.COVERINGS[u.roof.covering] && RoofModel.COVERINGS[u.roof.covering].sloped) || base.units[0]).roof.covering; + const choices = { + units: Object.fromEntries(base.units.map(u => { + const fl = u.unit.floor.floor; + return [u.id, { width: fl.width, depth: fl.depth, low: forms[u.id].low, high: highOf(forms[u.id]), type: forms[u.id].type, cut: cutOf(forms[u.id]), dir: forms[u.id].dir || null, ridge: forms[u.id].ridge || null }]; + })), + house: forms.house ? { low: forms.house.low, high: highOf(forms.house), type: forms.house.type, cut: cutOf(forms.house), dir: forms.house.dir || null, ridge: forms.house.ridge || null } : null, + covering: RoofModel.COVERINGS[covering] ? covering : 'dakleer', + facade: { type: f.type, rabat: f.rabatColor, cladding: base.settings.cladding, stuc: f.stucColor, fels: f.felsColor, stone: f.stone }, + accent: acc.length ? { type: acc[0].type, color: colorOf(acc[0]) } : null, + // Trasraam: alleen geen of steen; een cementplaat-trasraam van het model wordt geen. + trasraam: (() => { + const t = base.settings.trasraam, stone = WallModel.isStoneFinish(t.finish) && t.finish !== 'cementplaat'; + return { on: !!t.enabled && stone, finish: stone ? t.finish : null, height: t.height }; + })(), + lighting: { mode: base.settings.lighting.mode, direction: base.settings.lighting.direction, indoor: base.settings.lighting.indoor !== false } + }; + const model = { key, label: def.label, base, forms, hasAccent: acc.length > 0, frame: frameOf(key, opts && opts.frames), initial: clone(choices) }; + const st = build(model, choices); + model.reference = issuesOf(st, HouseModel.computeParts(st, { skip3D: true })); + return model; + } + + // Wolfseind als verhouding tussen goot (0) en nok (1), zoals de configurator: schaalt mee met goot en nok. + const WOLF_CUT = 0.6; + const cutOf = (form) => form.type !== 'wolf' ? null + : form.cutRatio > 0 ? form.cutRatio : form.cut ? (form.cut - form.low) / Math.max(1, form.high - form.low) : WOLF_CUT; + + // Nok (of hoge kant) als keuze; null = afgeleid (plat dak, of een andere dakvorm dan die van het model). + const RISE_MIN = 300; + const highOf = (form) => form.type === 'plat' ? null : form.high; + + // Dakvorm bij een keuze (c: type, low, high, cut, dir, ridge): dezelfde vorm als het model houdt richting en + // dakhoogte boven de goot, een andere krijgt de helling uit PITCH over de korte kant (lessenaar hoog aan de noord- + // of westgevel, nok in de lengte). Een gekozen richting (c.dir, c.ridge) en nok (c.high) gaan voor. + function formFor(orig, c, W, D) { + const { type, low } = c; + const dir = (f) => { + if (type === 'lessenaar') f.dir = c.dir || orig.dir || (W >= D ? 'voor' : 'links'); + if (UnitModel.hasRidge(type)) f.ridge = c.ridge || orig.ridge || (W >= D ? 'x' : 'z'); + return f; + }; + let f; + if (type === orig.type) { + f = dir(Object.assign({}, orig, { low })); + f.high = type === 'plat' ? low : Math.min(LIM.ridge, orig.high + (low - orig.low)); + } else { + f = { type, low, high: low }; + if (type === 'plat') return f; + dir(f); + const gable = UnitModel.gableOf(f), span = gable === 'voor' || gable === 'achter' ? W : D; + const run = UnitModel.hasRidge(type) ? span / 2 : span; + f.high = Math.round(Math.min(LIM.ridge, low + Math.tan(PITCH[type] * Math.PI / 180) * run)); + } + if (type !== 'plat' && c.high > 0) f.high = Math.round(Math.min(LIM.ridge, Math.max(low + RISE_MIN, c.high))); + if (type === 'wolf') { f.cutRatio = c.cut > 0 ? c.cut : cutOf(orig) || WOLF_CUT; delete f.cut; } + return f; + } + // Helling (graden) van een dakvorm over zijn overspanning (W/D: maten van de unit of de woning). + function pitchOf(f, W, D) { + if (f.type === 'plat') return 0; + const gable = UnitModel.gableOf(f), span = gable === 'voor' || gable === 'achter' ? W : D; + return Math.atan((f.high - f.low) / ((UnitModel.hasRidge(f.type) ? span / 2 : span) || 1)) * 180 / Math.PI; + } + function houseSize(st) { + const xs = HouseModel.unitRects(st); + return [Math.max(...xs.map(r => r.x1)) - Math.min(...xs.map(r => r.x0)), Math.max(...xs.map(r => r.z1)) - Math.min(...xs.map(r => r.z0))]; + } + // Dakvorm zoals build hem toepast: per unit (id), of over de hele woning (id null; maten uit de gebouwde st). + // Met pitch: de helling in graden. + function formOf(model, ch, id, st) { + const [W, D] = id ? [ch.units[id].width, ch.units[id].depth] : houseSize(st); + const f = formFor(id ? model.forms[id] : model.forms.house, id ? ch.units[id] : ch.house, W, D); + return Object.assign(f, { pitch: pitchOf(f, W, D) }); + } + + // Unit van een punt (woningcoördinaten): de rechthoek waar het punt het diepst in ligt. + function ownerOf(rects, x, z) { + let best = null, depth = -1; + Object.values(rects).forEach(r => { + const d = Math.min(x - r.x0, r.x1 - x, z - r.z0, r.z1 - z); + if (d > -1 && d > depth) { best = r.id; depth = d; } + }); + return best; + } + + // Na een maatwijziging: binnenwanden (woningcoördinaten) schuiven mee met hun unit; een eindpunt tegen de oost- of + // zuidgevel van een gewijzigde unit schuift mee met die gevel (de wand rekt mee). Inrichting staat al t.o.v. de + // noordwesthoek (of de oostgevel) van zijn unit; staat een stuk tegen de oost- of zuidgevel, dan schuift het mee. + function follow(st, before, after) { + const shift = (id, x, z) => { + const b = before[id], a = after[id]; + let dx = a.x0 - b.x0, dz = a.z0 - b.z0; + if (a.W !== b.W && x >= b.x1 - BAND) dx = a.x1 - b.x1; + if (a.D !== b.D && z >= b.z1 - BAND) dz = a.z1 - b.z1; + return { x: Math.round(x + dx), z: Math.round(z + dz) }; + }; + st.interiorWalls.forEach(iw => { + if (iw.seam) return; + [['x1', 'z1'], ['x2', 'z2']].forEach(([kx, kz]) => { + const id = ownerOf(before, iw[kx], iw[kz]); + if (!id) return; + const p = shift(id, iw[kx], iw[kz]); + iw[kx] = p.x; iw[kz] = p.z; + }); + }); + st.fixtures.forEach(f => { + const b = before[f.unitId], a = after[f.unitId]; + if (!b || !a) return; + const r = f.rot * Math.PI / 180, c = Math.abs(Math.cos(r)), s = Math.abs(Math.sin(r)); + const ex = c * f.w / 2 + s * f.d / 2, ez = s * f.w / 2 + c * f.d / 2; + if (f.anchor !== 'oost' && a.W !== b.W && f.x + ex >= b.W - BAND) f.x += a.W - b.W; + if (a.D !== b.D && f.z + ez >= b.D - BAND) f.z += a.D - b.D; + }); + } + + // Keuzes toepassen op een schone kopie van het model. + function build(model, ch) { + const st = clone(model.base); + const before = rectMap(st); + st.units.forEach(u => { + const c = ch.units[u.id], fl = u.unit.floor.floor; + fl.width = c.width; fl.depth = c.depth; + UnitModel.clampSize(fl); + }); + const after = rectMap(st); + follow(st, before, after); + + // Dakvorm en hoogte; binnenwanden gaan evenveel mee omhoog of omlaag als de goot. + const lift = {}; + if (model.forms.house) { + st.settings.roof.house.form = formOf(model, ch, null, st); + st.units.forEach(u => { lift[u.id] = ch.house.low - model.forms.house.low; }); + } else { + // Alleen opnieuw opbouwen wat afwijkt van het model; de rest blijft precies zoals het model het heeft. + st.units.forEach(u => { + const c = ch.units[u.id], c0 = model.initial.units[u.id], fl = u.unit.floor.floor; + lift[u.id] = c.low - model.forms[u.id].low; + if (['type', 'low', 'high', 'cut', 'dir', 'ridge', 'width', 'depth'].every(k => c[k] === c0[k])) return; + UnitModel.applyRoofForm(u.unit, formFor(model.forms[u.id], c, fl.width, fl.depth)); + }); + } + st.interiorWalls.forEach(iw => { + if (iw.seam) return; + const id = ownerOf(after, (iw.x1 + iw.x2) / 2, (iw.z1 + iw.z2) / 2), dh = id ? lift[id] : 0; + if (!dh) return; + const w = iw.wall.wall; + ['heightAll', 'heightLeft', 'heightRight'].forEach(k => { if (w[k] > 0) w[k] += dh; }); + }); + + if (ch.covering !== model.initial.covering) st.units.forEach(u => { u.roof.covering = ch.covering; }); + const fc = ch.facade, F = st.settings.facade; + Object.assign(F, { type: fc.type, rabatColor: fc.rabat, stucColor: fc.stuc, felsColor: fc.fels, stone: fc.stone }); + st.settings.cladding = fc.cladding; + Object.assign(st.settings.trasraam, { enabled: ch.trasraam.on }, ch.trasraam.on ? { finish: ch.trasraam.finish, height: ch.trasraam.height } : {}); + if (model.hasAccent && JSON.stringify(ch.accent) !== JSON.stringify(model.initial.accent)) { + const a = ch.accent; + accents(st).forEach(o => o.set(a ? Object.assign({ type: a.type }, a.type === 'steen' ? { stone: a.color } : a.type === 'stuc' ? { stuc: a.color } : a.type === 'fels' ? { fels: a.color } : {}) : null)); + } + Object.assign(st.settings.lighting, { mode: ch.lighting.mode, direction: ch.lighting.direction, indoor: ch.lighting.indoor }); + return st; + } + + // Wat er aan de indeling schort: per opening (verschoven, ingeklemd, te hoog voor de wand of in geen enkel + // wandstuk meer), binnenwand buiten de units, inrichting door een gevel, verdwenen ruimtes en overlappende units. + // check vergelijkt met het model zelf, zodat wat daar al zo is geen keuze blokkeert. + function issuesOf(st, parts) { + const out = new Set(); + parts.overlaps.forEach(p => out.add('units ' + p.join('+'))); + parts.rects.forEach(r => { if (r.error) out.add('units ' + r.id); }); + const ops = (key, config, placed) => config.openings.forEach(o => { + if (o.type === 'schoorsteen') return; + const W = config.wallWidth; + const want = o.anchor === 'right' ? W - o.offset - o.w : o.anchor === 'middle' ? (W - o.w) / 2 + o.offset : o.offset; + if (o.overflowing || Math.abs(o.x - want) > 2) out.add(`opening ${key}.${o.id} plek`); + if (o.h < o.rawH - 2 || o.hR < o.rawHR - 2 || o.y < o.rawY - 2) out.add(`opening ${key}.${o.id} hoogte`); + if (placed && !placed.has(o.id)) out.add(`opening ${key}.${o.id} kwijt`); + }); + // Gevelopeningen staan t.o.v. de hele wand en worden daarna over de wandstukken verdeeld (UnitModel.wallPieces). + st.units.forEach(su => { + const pu = parts.units.find(u => u.id === su.id); + Object.entries(su.unit.walls).forEach(([side, ws]) => { + const w = pu && pu.walls.find(x => x.side === side); + ops(`${su.id}-${side}`, WallModel.configFromState(ws), new Set(w ? w.pieces.flatMap(p => p.config.openings.map(o => o.id)) : [])); + }); + }); + parts.interior.forEach(p => ops(`bw${p.id}${p.seamUnit || ''}`, p.config)); + const inside = (r, x, z, m) => r && x >= r.x0 + m && x <= r.x1 - m && z >= r.z0 + m && z <= r.z1 - m; + st.interiorWalls.filter(iw => !iw.seam).forEach(iw => { + if (![[iw.x1, iw.z1], [iw.x2, iw.z2]].every(([x, z]) => parts.rects.some(r => inside(r, x, z, -1)))) out.add(`indeling bw${iw.id}`); + }); + parts.fixtures.forEach(it => { + const r = parts.rects.find(q => q.id === it.unitId); + if (it.corners.some(c => !inside(r, c.x, c.z, 100))) out.add(`indeling fx${it.id}`); + }); + return { keys: out, rooms: parts.rooms.rooms.length }; + } + + // null = in orde, anders een melding voor de klant. + function check(model, st, parts) { + const now = issuesOf(st, parts), ref = model.reference; + const added = [...now.keys].filter(k => !ref.keys.has(k)); + if (now.rooms < ref.rooms) added.push('indeling ruimtes'); + if (!added.length) return null; + if (added.some(k => k.startsWith('units'))) return 'De units komen dan over elkaar heen.'; + if (added.some(k => k.startsWith('opening'))) return 'De ramen en deuren van dit model passen dan niet meer.'; + return 'De indeling van dit model past dan niet meer.'; + } + + // Grootste of kleinste maat tussen een geldige (ok) en een gevraagde waarde (want) die nog past; rekent zonder 3D. + function fit(model, ch, setter, ok, want) { + let good = ok, bad = want; + while (Math.abs(bad - good) > STEP) { + const mid = Math.round((good + bad) / 2 / STEP) * STEP; + if (mid === good || mid === bad) break; + const c = clone(ch); + setter(c, mid); + const st = build(model, c); + if (check(model, st, HouseModel.computeParts(st, { skip3D: true }))) bad = mid; else good = mid; + } + return good; + } + + // Kerncijfers voor de klant: geen uren, zaagstukken of inkoop. + function summary(st, parts) { + const totals = HouseModel.computeTotals(parts); + const cost = CostModel.compute(parts, totals); + const d = HouseModel.computeDimensions(parts, st.settings.groundLevel); + return { + price: cost.summary.inclBtw, kg: totals.totalKg, units: st.units.length, + lengthX: d.lengthX, lengthZ: d.lengthZ, height: d.height, brutoM2: d.brutoM2, leefbaarM2: d.leefbaarM2, inhoudM3: d.inhoudM3 + }; + } + + // De keuzes in woorden (voorblad van de PDF): [onderwerp, tekst]. + // Lessenaar: hoge kant; zadel/wolf: richting van de nok (UnitModel.RIDGES). + const SIDES = { voor: 'Noord', achter: 'Zuid', links: 'West', rechts: 'Oost' }; + const SIDE_NAMES = { voor: 'noord', achter: 'zuid', links: 'west', rechts: 'oost' }; + const ROOF_NAMES = { plat: 'plat dak', lessenaar: 'lessenaardak', zadel: 'zadeldak', wolf: 'wolfsdak' }; + const cap = (t) => t.charAt(0).toUpperCase() + t.slice(1); + function colorName(type, key) { + if (type === 'steen') return `steen ${key}`; + const list = type === 'rabat' ? WallModel.RABAT_COLORS : type === 'stuc' ? WallModel.STUC_COLORS : WallModel.FELS.COLORS; + return list[key] ? list[key].label.toLowerCase() : ''; + } + function describe(model, ch, st) { + const fc = ch.facade, ids = Object.keys(ch.units), multi = ids.length > 1; + const facade = (type, color) => [FACADES[type].toLowerCase(), type === 'rabat' ? fc.cladding : null, colorName(type, color)].filter(Boolean).join(', '); + const perUnit = (fn) => { + const vals = ids.map(id => fn(ch.units[id], id)); + return !multi || vals.every(v => v === vals[0]) ? vals[0] : ids.map((id, i) => `unit ${id}: ${vals[i]}`).join(', '); + }; + const sloped = ch.house ? ch.house.type !== 'plat' : ids.some(id => ch.units[id].type !== 'plat'); + const nok = (f) => f.type === 'plat' ? '' : `, ${f.type === 'lessenaar' ? 'hoge kant' : 'nok'} ${nl(f.high / 1000)} m`; + const way = (f) => f.type === 'lessenaar' ? ` (hoog aan de ${SIDE_NAMES[f.dir]}kant)` : UnitModel.hasRidge(f.type) ? ` (nok ${UnitModel.RIDGES[f.ridge].toLowerCase()})` : ''; + const rows = [ + ['Dakvorm', cap(ch.house ? `${ROOF_NAMES[ch.house.type]}${way(formOf(model, ch, null, st))} over de hele woning` + : perUnit((u, id) => ROOF_NAMES[u.type] + way(formOf(model, ch, id))))], + ['Hoogte', cap(ch.house ? `goot ${nl(ch.house.low / 1000)} m${nok(formOf(model, ch, null, st))}` : perUnit((u, id) => `goot ${nl(u.low / 1000)} m${nok(formOf(model, ch, id))}`))], + ['Dakbedekking', (RoofModel.COVERINGS[sloped ? ch.covering : 'dakleer'] || RoofModel.COVERINGS.dakleer).label], + ['Gevel', cap(facade(fc.type, ({ rabat: fc.rabat, stuc: fc.stuc, fels: fc.fels, steen: fc.stone })[fc.type]))] + ]; + const tr = ch.trasraam; + rows.push(['Trasraam', tr.on ? `Steen ${tr.finish}, ${nl(tr.height / 1000)} m hoog` : 'Geen']); + if (model.hasAccent) rows.push(['Accentgevel', ch.accent ? cap(facade(ch.accent.type, ch.accent.type === 'rabat' ? fc.rabat : ch.accent.color)) : 'Geen']); + rows.push(['Buitenverlichting', LIGHTING[ch.lighting.mode] + (ch.lighting.mode === 'gevel' ? ` (${LightingModel.DIRECTIONS[ch.lighting.direction].toLowerCase()})` : '')]); + rows.push(['Binnenverlichting', ch.lighting.indoor ? 'Inbouwspots in het plafond' : 'Geen']); + return rows; + } + const nl = (n) => n.toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + + return { DEFAULT_FRAME, FRAMES, STEP, PITCH, SIDES, formOf, FACADES, STONES, LIGHTING, create, build, check, fit, summary, describe, colorName }; +})(); diff --git a/js/light/light-pdf.js b/js/light/light-pdf.js new file mode 100644 index 0000000..9649ecc --- /dev/null +++ b/js/light/light-pdf.js @@ -0,0 +1,227 @@ +/*! + * Tectum + * Copyright © 2026 Machiel Heinen. + * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. + * Zie LICENTIE.md. + */ + +// PDF van de light-versie: voorblad "Uw woningontwerp" (beeld, kerncijfers, keuzes, indicatieve prijs, contact en +// QR-code naar de modelpagina), de aanzichten en plattegrond (HousePdf, klantvariant) en een pagina over de werkwijze. +window.LightPdf = (() => { + const COMPANY = { + naam: 'Van Emous Mantelzorgwoningen', + adres: ['Keizerswoert 19', '3881 LE Putten'], + telefoon: '0341 - 275 147', + email: 'info@vanemousmantelzorgwoningen.nl', + web: 'https://www.vanemousmantelzorgwoningen.nl', + open: 'Ma t/m vr 08:00 - 17:00 uur, za en zo op afspraak' + }; + // Modellen met een eigen pagina op de site; andere verwijzen naar het overzicht. + const SITE_MODELS = ['amstel', 'ijssel', 'maas', 'reest', 'rijn', 'waal', 'wetering', 'eem']; + const modelUrl = (key) => SITE_MODELS.includes(key) ? `${COMPANY.web}/modellen/model-${key}/` : `${COMPANY.web}/modellen/`; + + // Werkwijze, naar de site (werkwijze/stappenplan). + const STEPS = [ + ['Mag u plaatsen?', 'Wij toetsen vrijblijvend uw perceel aan de regels van uw gemeente en geven een eerste indicatie van de mogelijkheden. Voor zekerheid helpen wij u graag bij het overleg met de gemeente.'], + ['Uw ontwerp uitwerken', 'U start vanuit een van onze standaardmodellen of vanuit uw eigen ontwerp. Onze specialisten werken uw wensen uit tot een productietekening. Omdat wij in onze eigen fabriek bouwen, is vrijwel elk ontwerp technisch haalbaar.'], + ['Wij gaan bouwen', 'Zodra het ontwerp definitief is en de omgevingsvergunning er is, start de productie in onze fabriek. Tijdens openingstijden bent u welkom om de voortgang te komen bekijken.'], + ['Transport en plaatsing', 'De afgebouwde modules worden naar uw perceel gebracht, op de fundering geplaatst en aangesloten op de nutsvoorzieningen. Na de laatste afwerking ontvangt u de sleutel.'], + ['Oplevering en service', 'Samen lopen we de woning na, zodat alles klopt met het ontwerp en uw wensen. Ook na de oplevering blijven wij beschikbaar voor service en onderhoud.'] + ]; + + const MAATWERK = ['Dit is pas het begin: alles kan op maat', + 'Dit ontwerp is gemaakt met een vereenvoudigde versie van onze configurator. Met onze eigen ontwerpsoftware werken wij uw woning ' + + 'samen met u tot in detail uit: een keuken op maat, een andere indeling met extra binnenwanden, of ramen en deuren op een andere ' + + 'plek en in een ander formaat. U bent van harte welkom voor een vrijblijvend adviesgesprek, waarin we samen uw wensen en ' + + 'mogelijkheden doornemen. Ook staat onze fabriek in Putten open voor bezoek: kom kijken hoe onze woningen gebouwd worden en ervaar ' + + 'de kwaliteit met eigen ogen. Maak gerust een afspraak; wij denken graag met u mee.']; + + const ORANGE = [185, 89, 19], INK = [33, 33, 33], MUTED = [91, 102, 115], SOFT = [249, 246, 240], LINE = [226, 231, 238]; + const PW = 420, PH = 297, M = 20; + const nl = (n, d = 0) => n.toLocaleString('nl-NL', { minimumFractionDigits: d, maximumFractionDigits: d }); + const meters = (mm) => `${nl(mm / 1000, 2)} m`; + + // QR-code als vlakken (scherp bij elke vergroting), met witte rand. + function drawQr(doc, text, x, y, size) { + const qr = qrcode(0, 'M'); + qr.addData(text); + qr.make(); + const n = qr.getModuleCount(), quiet = 2, s = size / (n + 2 * quiet); + doc.setFillColor(255, 255, 255); + doc.rect(x, y, size, size, 'F'); + doc.setFillColor(0, 0, 0); + for (let r = 0; r < n; r++) { + for (let c = 0; c < n; c++) { + if (!qr.isDark(r, c)) continue; + let e = c; + while (e + 1 < n && qr.isDark(r, e + 1)) e++; + doc.rect(x + (quiet + c) * s, y + (quiet + r) * s, (e - c + 1) * s + 0.02, s + 0.02, 'F'); + c = e; + } + } + } + + function logo(doc, x, y, h) { + const L = window.TECTUM_LOGO; + if (L) doc.addImage(L.png, 'PNG', x, y, h * L.aspect, h, 'tectum-logo', 'FAST'); + else { doc.setFont('helvetica', 'bold'); doc.setFontSize(14); doc.setTextColor(...INK); doc.text(COMPANY.naam, x, y + h * 0.7); } + } + + // Contactblok met QR-code; y = bovenkant. + function contactBand(doc, y, url, label) { + doc.setDrawColor(...LINE); + doc.setLineWidth(0.3); + doc.line(M, y, PW - M, y); + doc.setTextColor(...INK); + doc.setFont('helvetica', 'bold'); + doc.setFontSize(12); + doc.text(COMPANY.naam, M, y + 9); + doc.setFont('helvetica', 'normal'); + doc.setFontSize(10); + doc.setTextColor(...MUTED); + const col = (x, lines) => lines.forEach((t, i) => doc.text(t, x, y + 16 + i * 5.2)); + col(M, COMPANY.adres.concat([COMPANY.open])); + col(M + 95, [`Telefoon ${COMPANY.telefoon}`, COMPANY.email, COMPANY.web.replace(/^https:\/\//, '')]); + const q = 34; + drawQr(doc, url, PW - M - q, y + 4, q); + doc.setFontSize(9); + doc.setTextColor(...INK); + doc.text(doc.splitTextToSize(label, 70), PW - M - q - 4, y + 12, { align: 'right' }); + doc.setTextColor(...MUTED); + doc.setFontSize(8); + doc.text(url.replace(/^https:\/\//, ''), PW - M - q - 4, y + 30, { align: 'right' }); + } + + function keyValues(doc, x, y, w, rows, title) { + doc.setFont('helvetica', 'bold'); + doc.setFontSize(12); + doc.setTextColor(...INK); + doc.text(title, x, y); + let yy = y + 8; + rows.forEach(([k, v]) => { + doc.setFont('helvetica', 'normal'); + doc.setFontSize(10); + doc.setTextColor(...MUTED); + doc.text(k, x, yy); + doc.setTextColor(...INK); + const lines = doc.splitTextToSize(String(v), w - 42); + doc.text(lines, x + 42, yy); + yy += 5.4 * lines.length + 0.8; + }); + return yy; + } + + function cover(doc, d) { + doc.setFillColor(...SOFT); + doc.rect(0, 0, PW, PH, 'F'); + logo(doc, M, 18, 14); + doc.setTextColor(...INK); + doc.setFont('helvetica', 'bold'); + doc.setFontSize(34); + doc.text('Uw woningontwerp', M, 62); + doc.setTextColor(...ORANGE); + doc.setFontSize(20); + doc.text(d.label, M, 74); + doc.setFont('helvetica', 'normal'); + doc.setFontSize(10); + doc.setTextColor(...MUTED); + doc.text(`Samengesteld op ${new Date().toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })}`, M, 82); + + const i = d.info; + let y = keyValues(doc, M, 98, 150, [ + ['Buitenmaten', `${meters(i.lengthX)} × ${meters(i.lengthZ)}`], ['Bouwhoogte', meters(i.height)], + ['Vloeroppervlak', `${nl(i.brutoM2, 1)} m² bruto, ${nl(i.leefbaarM2, 1)} m² leefbaar`], ['Inhoud', `${nl(i.inhoudM3, 0)} m³`], + ['Modules', String(i.units)] + ], 'Kerncijfers'); + y = keyValues(doc, M, y + 6, 150, d.choices, 'Uw keuzes'); + + // Prijs + const py = Math.max(y + 4, 196); + doc.setFillColor(...ORANGE); + doc.roundedRect(M, py, 150, 30, 3, 3, 'F'); + doc.setTextColor(255, 255, 255); + doc.setFont('helvetica', 'normal'); + doc.setFontSize(10); + doc.text('Indicatieve prijs', M + 7, py + 9); + doc.setFont('helvetica', 'bold'); + doc.setFontSize(24); + doc.text(CostModel.eur(Math.round(i.price / 100) * 100), M + 7, py + 21); + doc.setFont('helvetica', 'normal'); + doc.setFontSize(9); + doc.text('incl. btw, onder voorbehoud', M + 7, py + 27); + + // Beeld van de woning + const bx = 190, by = 18, bw = PW - M - bx, bh = 206; + if (d.image) { + const ar = d.image.width / d.image.height, w = Math.min(bw, bh * ar), h = w / ar; + doc.addImage(d.image.url, 'JPEG', bx + (bw - w) / 2, by + (bh - h) / 2, w, h); + } + contactBand(doc, 240, d.url, `Bekijk ${d.label} online`); + doc.setFontSize(7); + doc.setTextColor(...MUTED); + doc.text('Dit ontwerp en de prijs zijn indicatief; hieraan kunnen geen rechten worden ontleend.', M, PH - 6); + } + + function process(doc, d) { + doc.setFillColor(...SOFT); + doc.rect(0, 0, PW, PH, 'F'); + logo(doc, M, 18, 14); + doc.setTextColor(...INK); + doc.setFont('helvetica', 'bold'); + doc.setFontSize(28); + doc.text('Hoe verloopt het traject?', M, 62); + doc.setFont('helvetica', 'normal'); + doc.setFontSize(12); + doc.setTextColor(...MUTED); + doc.text(doc.splitTextToSize('Van het eerste idee tot de sleuteloverdracht: wij begeleiden u in duidelijke stappen naar een mantelzorgwoning die bij u past. Dit ontwerp is de start van stap 2.', 260), M, 74); + + const gap = 8, w = (PW - 2 * M - gap * (STEPS.length - 1)) / STEPS.length, y = 92, h = 98; + STEPS.forEach(([title, text], k) => { + const x = M + k * (w + gap); + doc.setFillColor(255, 255, 255); + doc.setDrawColor(...LINE); + doc.roundedRect(x, y, w, h, 3, 3, 'FD'); + doc.setFillColor(...ORANGE); + doc.circle(x + 12, y + 14, 6.5, 'F'); + doc.setTextColor(255, 255, 255); + doc.setFont('helvetica', 'bold'); + doc.setFontSize(13); + doc.text(String(k + 1), x + 12, y + 14, { align: 'center', baseline: 'middle' }); + doc.setTextColor(...INK); + doc.setFontSize(13); + doc.text(doc.splitTextToSize(title, w - 12), x + 6, y + 32); + doc.setFont('helvetica', 'normal'); + doc.setFontSize(10); + doc.setTextColor(...MUTED); + doc.text(doc.splitTextToSize(text, w - 12), x + 6, y + 44, { lineHeightFactor: 1.4 }); + }); + // Maatwerk: dit ontwerp is een begin, de eigen software kan veel verder. + const by = 195, bh = 40; + doc.setFillColor(255, 255, 255); + doc.setDrawColor(...LINE); + doc.roundedRect(M, by, PW - 2 * M, bh, 3, 3, 'FD'); + doc.setFillColor(...ORANGE); + doc.rect(M, by + 3, 2.5, bh - 6, 'F'); + doc.setFont('helvetica', 'bold'); + doc.setFontSize(13); + doc.setTextColor(...INK); + doc.text(MAATWERK[0], M + 9, by + 10); + doc.setFont('helvetica', 'normal'); + doc.setFontSize(10.5); + doc.setTextColor(...MUTED); + doc.text(doc.splitTextToSize(MAATWERK[1], PW - 2 * M - 18), M + 9, by + 17, { lineHeightFactor: 1.35 }); + contactBand(doc, 240, d.url, `Bekijk ${d.label} online`); + } + + // d: { st, label, info, choices: [[label, tekst]], url, image: { url, width, height } | null } + function generate(d) { + const doc = PdfSheet.createDoc(); + cover(doc, d); + HousePdf.generate(d.st, { customer: true, doc, pageOffset: 1, pageTotal: 4 }); + doc.addPage('a3', 'landscape'); + process(doc, d); + return doc; + } + + return { COMPANY, modelUrl, generate }; +})(); diff --git a/js/light/light.js b/js/light/light.js new file mode 100644 index 0000000..3a8ff91 --- /dev/null +++ b/js/light/light.js @@ -0,0 +1,658 @@ +/*! + * Tectum + * Copyright © 2026 Machiel Heinen. + * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. + * Zie LICENTIE.md. + */ + +// Light-versie voor de modelpagina's: één model in 3D, een paar keuzes en de kerncijfers; opslaan = PDF met +// aanzichten en plattegrond. Alle logica in LightModel (en daarachter de gewone modellen). +// Inbedden:
(vanzelf gemount), of +// TectumLight.mount(el, { model, frames, url }); url = adres voor de QR-code in de PDF (standaard de modelpagina). +window.TectumLight = (() => { + const esc = UI.esc; + const nl = (n, d = 0) => n.toLocaleString('nl-NL', { minimumFractionDigits: d, maximumFractionDigits: d }); + const meters = (mm) => `${nl(mm / 1000, 2)} m`; + const clone = (o) => JSON.parse(JSON.stringify(o)); + + // Doorsnede: snijhoogte boven de vloer (mm), zoals de doorsnede van de configurator. + const SECTION = { start: 1500, min: 300, max: 3000 }; + const HINTS = { '3d': 'Slepen = draaien · rechts slepen = schuiven · klik en scroll = zoomen', cut: 'Slepen = draaien · klik en scroll = zoomen', plan: 'Noord boven · maten in mm' }; + + // tl-wrap is het raster; .tectum-light zelf is de container voor de container queries (breedte van de tool, niet + // van het scherm: in de HUGO-site staat hij in een Bootstrap col-12). + const TEMPLATE = `
+
+
+ +
+ + + +
+ +
+
Berekenen…
+
+
+
`; + + function mount(root, opts) { + const o = opts || {}; + const model = LightModel.create(o.model || root.dataset.model, { frames: o.frames }); + let ch = clone(model.initial); + let unitSel = model.base.units[0].id; + let cur = null; // { st, parts, info } + root.classList.add('tectum-light'); + root.innerHTML = TEMPLATE; + const $ = (sel) => root.querySelector(sel); + $('.tl-title').textContent = model.label; + + // 3D: afgewerkte woning (zoals de render-export), dag-omgeving. + const stage = $('.tl-3d'); + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0xdfe6ec); + const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 400); + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1)); + renderer.shadowMap.enabled = true; + renderer.localClippingEnabled = true; + stage.appendChild(renderer.domElement); + const controls = new THREE.OrbitControls(camera, renderer.domElement); + const ambient = new THREE.AmbientLight(0xffffff, 0.6); + const sun = new THREE.DirectionalLight(0xffffff, 0.8); + sun.position.set(5, 8, 5); + scene.add(ambient, sun); + const sky = Skybox.createEnvironment(scene); + const mats = { + wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials(), + lightingMaterials: LightingModel.createMaterials(), sandwichMaterials: SandwichModel.createMaterials(), postMaterials: PostModel.createMaterials(), + chimneyMaterials: ChimneyModel.createMaterials(), elektraMaterials: ElektraModel.createMaterials() + }; + const S0 = model.base.settings; + if (S0.interiorPaint) mats.wallMaterials.spaanplaatMaterial.color.set(S0.interiorColor); + mats.roofMaterials.ceilingMaterial.color.set(S0.ceilingColor || '#f4f3ef'); + const compute = (st) => HouseModel.computeParts(st, Object.assign({ layers: ViewLayers.all(), finishedOnly: true }, mats)); + const disposeGeo = (g) => g && g.traverse(x => { if (x.geometry) x.geometry.dispose(); }); + const rootsOf = (p) => p.units.map(u => u.floorGroup).concat(HouseModel.allWallPieces(p).map(x => x.group), p.units.map(u => u.roof.group), + [p.roofEdgeGroup, p.postsGroup, p.chimneysGroup, p.sandwichGroup, p.lightingGroup, p.elektraGroup, p.roomsGroup, p.fixturesGroup]).filter(Boolean); + + // Weergave: 3D, doorsnede (dak en lampen weg, wanden en wat erin zit afgesneden, snede wit afgedekt) of plattegrond. + let group = null, caps = null, view = '3d', fitted = false, center = { x: 0, z: 0, size: 10 }, sectionMM = SECTION.start; + const poses = {}; + const cutPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0); + const capMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff }); + function clip(g, planes) { + if (g) g.traverse(x => (Array.isArray(x.material) ? x.material : x.material ? [x.material] : []).forEach(m => { + if ((m.clippingPlanes || []).length === planes.length) return; + m.clippingPlanes = planes; + m.needsUpdate = true; + })); + } + function applyView() { + if (!cur) return; + const p = cur.parts, cut = view === 'cut'; + p.units.forEach(u => { if (u.roof.group) u.roof.group.visible = !cut; }); + [p.roofEdgeGroup, p.postsGroup].forEach(g => { if (g) g.visible = !cut; }); + if (p.roomsGroup && p.roomsGroup.userData.ceilings) p.roomsGroup.userData.ceilings.visible = !cut; + const planes = cut ? [cutPlane] : []; + HouseModel.allWallPieces(p).forEach(x => clip(x.group, planes)); + // Lampen snijden mee: binnenspots onder de snijhoogte blijven zichtbaar, niets zweeft. + [p.elektraGroup, p.chimneysGroup, p.roomsGroup, p.fixturesGroup, p.lightingGroup].forEach(g => clip(g, planes)); + placeCut(); + if (view === 'plan') drawPlan(); + } + // Snijvlak en witte afdekking op de snijhoogte; alleen dit bij het schuiven. + function placeCut() { + const p = cur.parts, floorTop = Math.max(...p.units.map(u => u.layout.floorTop)); + cutPlane.constant = (floorTop + sectionMM) * 0.001; + if (caps) { group.remove(caps); disposeGeo(caps); caps = null; } + if (view === 'cut') { caps = HouseModel.sectionCaps(p, floorTop + sectionMM, capMaterial, ViewLayers.all()); group.add(caps); } + } + const cutInput = $('.tl-cutbar input'); + // Niet hoger dan de goot (plat dak: de dakhoogte); bij units met een andere goot de laagste, zodat de snede altijd + // door alle gevels gaat. Loopt mee met de gekozen hoogte. + function fitCutRange() { + const lows = ch.house ? [ch.house.low] : Object.values(ch.units).map(u => u.low); + const max = Math.max(SECTION.min, Math.floor(Math.min(...lows) / 50) * 50); + cutInput.max = max; + if (sectionMM > max) sectionMM = max; + cutInput.value = sectionMM; + $('.tl-cutbar output').textContent = meters(sectionMM); + } + cutInput.addEventListener('input', () => { + sectionMM = Number(cutInput.value); + $('.tl-cutbar output').textContent = meters(sectionMM); + if (cur) placeCut(); + }); + // Elke weergave onthoudt zijn eigen camerastand; de doorsnede begint schuin van boven. + function setView(v) { + if (v === view) return; + if (view !== 'plan') poses[view] = { pos: camera.position.clone(), target: controls.target.clone() }; + view = v; + if (v !== 'plan') { + const pose = poses[v]; + if (pose) { camera.position.copy(pose.pos); controls.target.copy(pose.target); } + else { + controls.target.set(center.x, 0.6, center.z); + camera.position.set(center.x - center.size * 0.45, center.size * 1.05 + 2, center.z + center.size * 0.75); + } + controls.update(); + } + root.classList.toggle('view-plan', v === 'plan'); + root.classList.toggle('view-cut', v === 'cut'); + root.querySelectorAll('[data-view]').forEach(b => b.classList.toggle('on', b.dataset.view === v)); + $('.tl-hint').textContent = HINTS[v]; + applyView(); + } + $('.tl-hint').textContent = HINTS['3d']; + root.querySelector('.tl-viewbar').addEventListener('click', (e) => { const b = e.target.closest('[data-view]'); if (b) setView(b.dataset.view); }); + + function show(st, parts) { + if (group) { scene.remove(group); disposeGeo(group); caps = null; } + fitCutRange(); + HouseModel.placeGroups(parts); + group = new THREE.Group(); + rootsOf(parts).forEach(g => group.add(g)); + scene.add(group); + cur = { st, parts, info: LightModel.summary(st, parts) }; + const rs = parts.rects, toM = 0.001; + const bx0 = Math.min(...rs.map(r => r.x0)), bx1 = Math.max(...rs.map(r => r.x1)); + const bz0 = Math.min(...rs.map(r => r.z0)), bz1 = Math.max(...rs.map(r => r.z1)); + center = { x: (bx0 + bx1) / 2 * toM, z: (bz0 + bz1) / 2 * toM, size: Math.max(bx1 - bx0, bz1 - bz0) * toM }; + const groundY = (Math.max(...parts.units.map(u => u.layout.floorTop)) - st.settings.groundLevel - 2) * toM; + sky.fitTo(group, groundY); + Skybox.apply('day', { env: sky, controls, sun, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: group }); + if (!fitted) { + controls.target.set(center.x, 1.2, center.z); + camera.position.set(center.x - center.size * 0.9, center.size * 0.7, center.z - center.size * 1.3); + controls.update(); + fitted = true; + } + applyView(); + HousePdf.materialThumbUrls(st).forEach(PdfSheet.preloadImage); + renderPanel(); + renderSummary(); + } + + // Plattegrond (noord boven): ruimtes met naam en netto m², wanden met kozijnen en draairichting, inrichting, + // dakrand, palen en de buitenmaten. Zelfde gegevens als de PDF-plattegrond. + function drawPlan() { + const cv = $('.tl-plan'), W = cv.clientWidth, H = cv.clientHeight; + if (!cur || !W || !H) return; + const dpr = Math.min(2, window.devicePixelRatio || 1); + cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr); + const ctx = cv.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, W, H); + const p = cur.parts, rs = p.rects; + const ext = rs.concat(p.units.map(u => u.roof.rect)); + const bx0 = Math.min(...ext.map(r => r.x0)), bx1 = Math.max(...ext.map(r => r.x1)); + const bz0 = Math.min(...ext.map(r => r.z0)), bz1 = Math.max(...ext.map(r => r.z1)); + const top = 64, side = 56; + const scale = Math.min((W - 2 * side) / (bx1 - bx0), (H - top - side) / (bz1 - bz0)); + const ox = W / 2 - (bx0 + bx1) / 2 * scale, oy = top + (H - top - side) / 2 - (bz0 + bz1) / 2 * scale; + const P = (x, z) => [ox + x * scale, oy + z * scale]; + const path = (pts, closed) => { ctx.beginPath(); pts.forEach((q, i) => ctx[i ? 'lineTo' : 'moveTo'](...P(q.x, q.z))); if (closed) ctx.closePath(); }; + const corners = (pl, lx0, lx1) => { const t = pl.thickness / 2; return [[lx0, -t], [lx1, -t], [lx1, t], [lx0, t]].map(([a, b]) => UnitModel.localToWorld(pl, a, b)); }; + const rgba = (hex, a) => `rgba(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)}, ${a})`; + ctx.lineJoin = 'round'; + + // Dakrand + ctx.setLineDash([5, 4]); ctx.strokeStyle = '#9aa3ad'; ctx.lineWidth = 1; + if (p.roofEdge) p.roofEdge.segments.forEach(sg => { path([sg.outerA, sg.outerB]); ctx.stroke(); }); + else p.units.forEach(u => { const [x, y] = P(u.roof.rect.x0, u.roof.rect.z0); ctx.strokeRect(x, y, u.roof.rect.W * scale, u.roof.rect.D * scale); }); + ctx.setLineDash([]); + + // Ruimtes + const R = p.rooms; + R.rooms.forEach(r => { ctx.fillStyle = rgba(r.color, 0.22); r.rects.forEach(c => { const [x, y] = P(c.x0, c.z0); ctx.fillRect(x, y, (c.x1 - c.x0) * scale, (c.z1 - c.z0) * scale); }); }); + + // Inrichting + (p.fixtures || []).forEach(it => FixtureModel.planShapes(it).forEach(sh => { + ctx.strokeStyle = '#8b939b'; + ctx.lineWidth = sh.kind === 'thin' ? 0.6 : 1; + ctx.setLineDash(sh.kind === 'dash' ? [3, 3] : []); + if (sh.circle) { ctx.beginPath(); ctx.arc(...P(sh.circle.x, sh.circle.z), Math.max(1, sh.circle.r * scale), 0, Math.PI * 2); ctx.stroke(); return; } + if (sh.text) return; + path(sh.pts, sh.closed); + if (sh.fill) { ctx.fillStyle = 'rgba(139, 147, 155, 0.18)'; ctx.fill(); } + ctx.stroke(); + })); + ctx.setLineDash([]); + + // Wanden met openingen en kozijnsymbolen + const piece = (x, fill) => { + const pl = x.place, Wp = pl.width; + path(corners(pl, -Wp / 2, Wp / 2), true); + ctx.fillStyle = fill; ctx.fill(); + x.config.openings.filter(op => op.type !== 'schoorsteen').forEach(op => { + path(corners(pl, -Wp / 2 + op.x, -Wp / 2 + op.x + op.w), true); + ctx.fillStyle = '#ffffff'; ctx.fill(); + ctx.strokeStyle = '#33383d'; ctx.lineWidth = 0.8; ctx.stroke(); + if (!op.fill) return; + KozijnModel.planSymbol(op, op.fill, { zIn: -pl.thickness / 2, zOut: pl.thickness / 2 }).forEach(sh => { + ctx.lineWidth = sh.kind === 'leaf' ? 1.3 : 0.8; + ctx.setLineDash(sh.kind === 'arc' || sh.kind === 'rail' ? [3, 3] : []); + path(sh.pts.map(q => UnitModel.localToWorld(pl, -Wp / 2 + op.x + q.x, q.z)), !!sh.closed); + ctx.stroke(); + }); + ctx.setLineDash([]); + }); + }; + p.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(x => piece(x, '#3d4247')))); + p.interior.forEach(x => piece(x, '#7a828a')); + ctx.fillStyle = '#a07c54'; + p.posts.posts.forEach(pp => { const [x, y] = P(pp.x - pp.size / 2, pp.z - pp.size / 2); ctx.fillRect(x, y, pp.size * scale, pp.size * scale); }); + p.posts.walls.forEach(w => { const [x, y] = P(w.x0, w.z0); ctx.fillStyle = '#3d4247'; ctx.fillRect(x, y, (w.x1 - w.x0) * scale, (w.z1 - w.z0) * scale); }); + (p.chimneys || []).forEach(c => { path(c.footprint, true); ctx.fillStyle = '#8b8f93'; ctx.fill(); }); + // Binnenspots + ctx.fillStyle = '#ffd36b'; ctx.strokeStyle = '#a07a1e'; ctx.lineWidth = 0.8; + (p.lighting.indoor || []).forEach(sp => { ctx.beginPath(); ctx.arc(...P(sp.x, sp.z), Math.max(2.5, 90 * scale), 0, Math.PI * 2); ctx.fill(); ctx.stroke(); }); + + // Ruimtenamen + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + const fs = Math.max(9, Math.min(13, scale * 260)); + R.rooms.forEach(r => { + const [x, y] = P(r.label.x, r.label.z); + ctx.fillStyle = '#212121'; ctx.font = `600 ${fs}px sans-serif`; + ctx.fillText(r.name, x, y - fs * 0.6); + ctx.fillStyle = '#5b6673'; ctx.font = `${fs * 0.9}px sans-serif`; + ctx.fillText(`${nl(r.areaM2, 1)} m²`, x, y + fs * 0.6); + }); + + // Buitenmaten (units) en noordpijl + const ux0 = Math.min(...rs.map(r => r.x0)), ux1 = Math.max(...rs.map(r => r.x1)); + const uz0 = Math.min(...rs.map(r => r.z0)), uz1 = Math.max(...rs.map(r => r.z1)); + ctx.strokeStyle = '#1d5f99'; ctx.fillStyle = '#1d5f99'; ctx.lineWidth = 1; ctx.font = '12px sans-serif'; + const dim = (a, b, horiz, at) => { + const [x0, y0] = horiz ? P(a, 0) : P(0, a), [x1, y1] = horiz ? P(b, 0) : P(0, b); + const A = horiz ? [x0, at] : [at, y0], B = horiz ? [x1, at] : [at, y1]; + ctx.beginPath(); ctx.moveTo(...A); ctx.lineTo(...B); + [A, B].forEach(([x, y]) => { ctx.moveTo(x - 4, y + 4); ctx.lineTo(x + 4, y - 4); ctx.moveTo(...(horiz ? [x, y - 6] : [x - 6, y])); ctx.lineTo(...(horiz ? [x, y + 6] : [x + 6, y])); }); + ctx.stroke(); + ctx.save(); + ctx.translate((A[0] + B[0]) / 2, (A[1] + B[1]) / 2); + if (!horiz) ctx.rotate(-Math.PI / 2); + ctx.fillText(nl(Math.round(b - a)), 0, -9); + ctx.restore(); + }; + dim(ux0, ux1, true, P(0, bz0)[1] - 22); + dim(uz0, uz1, false, P(bx0, 0)[0] - 22); + const nx = W - 28, ny = top + 6; + ctx.beginPath(); ctx.moveTo(nx, ny); ctx.lineTo(nx - 7, ny + 18); ctx.lineTo(nx + 7, ny + 18); ctx.closePath(); ctx.fill(); + ctx.fillText('N', nx, ny + 30); + } + + // Keuze doorrekenen; past de indeling niet, dan blijft de vorige keuze staan. Bij een maat (fit) eerst de + // dichtstbijzijnde maat zoeken die nog wel past. + let toastTimer = null; + function toast(msg) { + const t = $('.tl-toast'); + t.textContent = msg; + t.classList.add('on'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => t.classList.remove('on'), 5000); + } + let busy = false; + function tryApply(next, fit) { + if (busy) return; + busy = true; + root.classList.add('is-busy'); + setTimeout(() => { + try { + let st = LightModel.build(model, next), parts = compute(st); + let msg = LightModel.check(model, st, parts), note = null; + if (msg && fit) { + rootsOf(parts).forEach(disposeGeo); + const v = LightModel.fit(model, next, fit.set, fit.ok, fit.want); + if (v !== fit.ok) { + fit.set(next, v); + st = LightModel.build(model, next); + parts = compute(st); + note = `${msg} Dichtstbijzijnde maat die past: ${meters(v)}.`; + msg = LightModel.check(model, st, parts); + } else parts = null; + } + if (msg) { + if (parts) rootsOf(parts).forEach(disposeGeo); + toast(msg); + renderPanel(); + } else { + ch = next; + show(st, parts); + if (note) toast(note); + } + } catch (err) { + console.error(err); + toast('Dit kon niet worden doorgerekend.'); + renderPanel(); + } + busy = false; + root.classList.remove('is-busy'); + }, 30); + } + + // Paneel: alleen keuzes, geen vrije invoer. + const field = (label, html, extra, cls) => `
${label}${extra ? `${extra}` : ''}
${html}
`; + // disabled(k) geeft false of de reden (tooltip). + const seg = (key, options, value, disabled) => `
${Object.entries(options).map(([k, label]) => { + const why = disabled && disabled(k); + return ``; + }).join('')}
`; + // Kleurstalen; de naam van de gekozen kleur komt in de labelregel (swatchField). + const swatches = (key, items, value, disabled) => { + const sel = items.find(it => it.key === value); + return `
${items.map(it => { + const why = disabled && disabled(it.key); + return ``; + }).join('')}
`; + }; + const swatchField = (label, html) => field(label, html, (html.match(/data-name="([^"]*)"/) || [])[1]); + // Het bereik omvat altijd de maat van het model zelf; extra staat achter de waarde (bijv. de helling). + const slider = (key, label, value, range, hint, extra) => { + const lo = Math.min(range[0], value), hi = Math.max(range[1], value); + return ``; + }; + // Wolfseind: hoogte waarop de kopgevels stoppen (tussen goot en nok); daarboven de schuine wolfseinden. + const wolfSlider = (key, f) => { + const at = (r) => Math.round((f.low + r * (f.high - f.low)) / LightModel.STEP) * LightModel.STEP; + return slider(key, 'Wolfseind', at(f.cutRatio), [at(0.05), at(0.95)], 'Hoogte waarop de kopgevels stoppen; daarboven de schuine wolfseinden tot de nok'); + }; + const colorItems = (obj) => Object.entries(obj).map(([key, c]) => ({ key, label: c.label, hex: c.hex })); + const facadeColors = (type, current) => { + if (type === 'rabat') return colorItems(WallModel.RABAT_COLORS); + if (type === 'stuc') return colorItems(WallModel.STUC_COLORS); + if (type === 'fels') return colorItems(WallModel.FELS.COLORS); + const codes = LightModel.STONES.includes(current) || !current ? LightModel.STONES : [current].concat(LightModel.STONES); + return codes.map(code => ({ key: code, label: `Steenstrip ${code}`, hex: WallModel.STONE_COLORS[code] || '#a08070', img: WallModel.stoneThumbUrl(code, 64) })); + }; + const facadeColor = (fc) => ({ rabat: fc.rabat, stuc: fc.stuc, fels: fc.fels, steen: fc.stone })[fc.type]; + const roofTypes = UnitModel.ROOF_TYPES; + + // Plattegrondje om een unit te kiezen (noord boven). + function unitMap() { + const rs = cur.parts.rects, pad = 300; + const x0 = Math.min(...rs.map(r => r.x0)) - pad, x1 = Math.max(...rs.map(r => r.x1)) + pad; + const z0 = Math.min(...rs.map(r => r.z0)) - pad, z1 = Math.max(...rs.map(r => r.z1)) + pad; + const fs = Math.min(x1 - x0, z1 - z0) * 0.16; + return `${rs.map(r => + ` + + ${esc(r.id)}`).join('')}`; + } + + // Accordeon: één sectie open, in de kop een samenvatting van de keuze. + let openSec = 'maten'; + const roofName = (t) => ({ plat: 'Plat dak', lessenaar: 'Lessenaardak', zadel: 'Zadeldak', wolf: 'Wolfsdak' })[t]; + function renderPanel() { + if (!cur) return; + const multi = model.base.units.length > 1, houseRoof = !!ch.house; + const u = ch.units[unitSel], fr = model.frame.unit(unitSel); + const sloped = cur.parts.units.some(x => x.roof.shape.type !== 'plat'); + const short = UnitModel.LIMITS.short; + // Een unit mag maar in één richting langer zijn dan de transportmaat. + const range = (r, other) => r && [r[0], other > short ? Math.min(r[1], short) : r[1]]; + const wR = range(fr.width, u.depth), dR = range(fr.depth, u.width); + + const sizeHtml = (multi ? `
${unitMap()}Klik op een unit
` : '') + + (wR ? slider('width', 'Breedte west–oost', u.width, wR) : '') + + (dR ? slider('depth', 'Diepte noord–zuid', u.depth, dR) : ''); + + // Dak: per unit (of één keer bij een dak over de hele woning) vorm, richting en hoogtes; de bedekking geldt overal. + const wolfOk = !houseRoof || HouseModel.houseRoofFits(cur.st); + const K = houseRoof ? 'house' : 'unit', c = houseRoof ? ch.house : u, f = NUM[K + 'High'].form(ch); + const covItems = Object.entries(RoofModel.COVERINGS).map(([key, cv]) => ({ key, label: cv.label, hex: cv.color, img: RoofModel.coveringThumbUrl(key, 64) })); + const roofHtml = (multi && !houseRoof ? field('Unit', seg('unit', Object.fromEntries(model.base.units.map(x => [x.id, x.id])), unitSel), null, 'tl-units') : '') + + field('Dakvorm', seg(K + 'Type', UnitModel.ROOF_TYPES, c.type, (k) => k === 'wolf' && !wolfOk && 'Een wolfsdak kan alleen als de units samen een rechthoek vormen')) + + (c.type === 'lessenaar' ? field('Hoge kant', seg(K + 'Dir', LightModel.SIDES, f.dir)) : '') + + (UnitModel.hasRidge(c.type) ? field('Nok', seg(K + 'Ridge', UnitModel.RIDGES, f.ridge)) : '') + + slider(K + 'Low', c.type === 'plat' ? 'Hoogte' : 'Goothoogte', c.low, model.frame.height, 'Vanaf de vloer tot onderkant dak, aan de lage kant') + + (c.type !== 'plat' ? slider(K + 'High', c.type === 'lessenaar' ? 'Hoge kant' : 'Nokhoogte', f.high, + [Math.ceil((f.low + 300) / LightModel.STEP) * LightModel.STEP, UnitModel.LIMITS.ridge], 'Vanaf de vloer tot onderkant dak', ` · ${nl(f.pitch, 0)}°`) : '') + + (c.type === 'wolf' ? wolfSlider(K + 'Cut', f) : '') + + swatchField('Bedekking', swatches('covering', covItems, ch.covering, (k) => !sloped && RoofModel.COVERINGS[k].sloped && 'alleen op een hellend dak; een plat dak krijgt dakleer')); + + const fc = ch.facade; + const facadeHtml = field('Bekleding', seg('facadeType', LightModel.FACADES, fc.type)) + + (fc.type === 'rabat' ? field('Richting', seg('cladding', WallModel.CLADDING_DIRECTIONS, fc.cladding)) : '') + + swatchField('Kleur', swatches('facadeColor', facadeColors(fc.type, fc.stone), facadeColor(fc))) + + (model.hasAccent ? field('Accent afwijkende gevel van het model', seg('accentType', Object.assign({ geen: 'Geen' }, LightModel.FACADES), ch.accent ? ch.accent.type : 'geen')) + + (ch.accent ? swatchField('Kleur accent', swatches('accentColor', facadeColors(ch.accent.type, ch.accent.color), ch.accent.type === 'rabat' ? fc.rabat : ch.accent.color)) : '') : ''); + + // Trasraam: strook onderaan de gevel (onder rabat én steen). + const tr = ch.trasraam; + const trasraamHtml = field('Trasraam stenen strook onderaan de gevel', seg('trasraamType', { geen: 'Geen', steen: 'Steen' }, tr.on ? 'steen' : 'geen')) + + (tr.on ? swatchField('Steen trasraam', swatches('trasraamStone', facadeColors('steen', tr.finish), tr.finish)) : '') + + (tr.on ? slider('trasraamHigh', 'Hoogte trasraam', tr.height, [200, 1000], 'Vanaf bovenkant vloer') : ''); + + const L = ch.lighting; + const lightHtml = field('Binnen', seg('indoor', { aan: 'Inbouwspots', uit: 'Geen' }, L.indoor ? 'aan' : 'uit')) + + field('Buiten', seg('lightMode', LightModel.LIGHTING, L.mode)) + + (L.mode === 'gevel' ? field('Lichtrichting', seg('lightDir', LightingModel.DIRECTIONS, L.direction)) : ''); + + const i = cur.info; + const types = houseRoof ? [ch.house.type] : [...new Set(Object.values(ch.units).map(x => x.type))]; + const covLabel = sloped ? RoofModel.COVERINGS[ch.covering].label : 'Dakleer'; + const facadeSum = `${LightModel.FACADES[fc.type]} · ${LightModel.colorName(fc.type, facadeColor(fc))}${tr.on ? ' · trasraam' : ''}`; + const lightSum = [L.indoor ? 'Spots binnen' : null, L.mode === 'geen' ? 'geen buitenlampen' : LightModel.LIGHTING[L.mode].toLowerCase()].filter(Boolean).join(' · '); + const sec = (key, title, icon, sum, html) => { + const open = openSec === key; + return `
+ + ${open ? `
${html}
` : ''}
`; + }; + const el = $('.tl-sections'), scroll = el.scrollTop; + el.innerHTML = sec('maten', 'Afmetingen', 'rulers', `${meters(i.lengthX)} × ${meters(i.lengthZ)}`, sizeHtml) + + sec('dak', 'Dak', 'house', `${types.length > 1 ? 'Gemengd' : roofName(types[0])} · ${covLabel}`, roofHtml) + + sec('gevel', 'Gevel', 'bricks', facadeSum, facadeHtml + trasraamHtml) + + sec('licht', 'Verlichting', 'lightbulb', lightSum, lightHtml); + el.scrollTop = scroll; + } + + function renderSummary() { + const i = cur.info; + $('.tl-sub').textContent = `${meters(i.lengthX)} × ${meters(i.lengthZ)} · ${nl(i.brutoM2, 1)} m² · ${meters(i.height)} hoog`; + const kv = (k, v) => `
${k}${v}
`; + $('.tl-facts').innerHTML = kv('Vloer bruto', `${nl(i.brutoM2, 1)} m²`) + kv('Leefbaar', `${nl(i.leefbaarM2, 1)} m²`) + + kv('Inhoud', `${nl(i.inhoudM3, 0)} m³`) + kv('Gewicht', `${nl(Math.round(i.kg / 10) * 10, 0)} kg`); + $('.tl-buy').innerHTML = `
${CostModel.eur(Math.round(i.price / 100) * 100)}indicatief, incl. btw
+ + `; + } + + // Keuzes (knoppen en kleuren). + const panel = $('.tl-panel'); + function choose(key, val) { + if (key === 'unit') { unitSel = val; renderPanel(); return; } + const R = (k) => k.startsWith('house') ? next.house : next.units[unitSel]; + const next = clone(ch), fc = next.facade; + // Andere dakvorm: de nok volgt weer uit de helling (of uit het model, bij zijn eigen dakvorm). + if (key === 'unitType' || key === 'houseType') Object.assign(R(key), { type: val, high: null }); + else if (key === 'unitDir' || key === 'houseDir') R(key).dir = val; + else if (key === 'unitRidge' || key === 'houseRidge') R(key).ridge = val; + else if (key === 'indoor') next.lighting.indoor = val === 'aan'; + else if (key === 'covering') next.covering = val; + else if (key === 'facadeType') fc.type = val; + else if (key === 'cladding') fc.cladding = val; + else if (key === 'facadeColor') fc[{ rabat: 'rabat', stuc: 'stuc', fels: 'fels', steen: 'stone' }[fc.type]] = val; + else if (key === 'accentType') next.accent = val === 'geen' ? null : { type: val, color: val === 'rabat' ? null : facadeColor(Object.assign({}, fc, { type: val })) }; + else if (key === 'accentColor') { if (next.accent.type === 'rabat') fc.rabat = val; else next.accent.color = val; } + else if (key === 'trasraamType') { + const t = next.trasraam; + t.on = val === 'steen'; + if (t.on && !t.finish) t.finish = fc.type === 'steen' ? fc.stone : LightModel.STONES[0]; + } else if (key === 'trasraamStone') next.trasraam.finish = val; + else if (key === 'lightMode') next.lighting.mode = val; + else if (key === 'lightDir') next.lighting.direction = val; + else return; + tryApply(next); + } + panel.addEventListener('click', (e) => { + const h = e.target.closest('[data-sec]'); + if (h) { openSec = openSec === h.dataset.sec ? null : h.dataset.sec; renderPanel(); return; } + const b = e.target.closest('[data-set]'); + if (b && !b.disabled) { choose(b.dataset.set, b.dataset.val); return; } + const a = e.target.closest('[data-act]'); + if (!a || !cur) return; + if (a.dataset.act === 'pdf') savePdf(); + else if (a.dataset.act === 'reset') tryApply(clone(model.initial)); + }); + panel.addEventListener('keydown', (e) => { + const g = e.target.closest('g[data-set]'); + if (g && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); choose(g.dataset.set, g.dataset.val); } + }); + // Maten: label volgt het schuiven, doorrekenen bij loslaten. + const NUM = { + width: { get: (c) => c.units[unitSel].width, set: (c, v) => { c.units[unitSel].width = v; } }, + depth: { get: (c) => c.units[unitSel].depth, set: (c, v) => { c.units[unitSel].depth = v; } }, + // De goot omhoog of omlaag neemt een gekozen nok mee (zelfde dak, hoger of lager). + unitLow: { get: (c) => c.units[unitSel].low, set: (c, v) => lift(c.units[unitSel], v) }, + houseLow: { get: (c) => c.house.low, set: (c, v) => lift(c.house, v) }, + trasraamHigh: { get: (c) => c.trasraam.height, set: (c, v) => { c.trasraam.height = v; } } + }; + function lift(x, v) { if (x.high > 0) x.high += v - x.low; x.low = v; } + // Wolfseind: de keuze is een verhouding (goot 0, nok 1), de schuif een hoogte. + const unitForm = (c) => LightModel.formOf(model, c, unitSel), houseForm = (c) => LightModel.formOf(model, c, null, cur.st); + NUM.unitHigh = { form: unitForm, get: (c) => unitForm(c).high, set: (c, v) => { c.units[unitSel].high = v; } }; + NUM.houseHigh = { form: houseForm, get: (c) => houseForm(c).high, set: (c, v) => { c.house.high = v; } }; + [['unitCut', (c) => c.units[unitSel], unitForm], ['houseCut', (c) => c.house, houseForm]].forEach(([k, of, form]) => { + NUM[k] = { + form, + get: (c) => { const f = form(c); return Math.round((f.low + f.cutRatio * (f.high - f.low)) / LightModel.STEP) * LightModel.STEP; }, + set: (c, v) => { const f = form(c); of(c).cut = Math.max(0.05, Math.min(0.95, (v - f.low) / Math.max(1, f.high - f.low))); } + }; + }); + panel.addEventListener('input', (e) => { + const r = e.target.closest('[data-num]'); + if (r) r.closest('.tl-slider').querySelector('output').textContent = meters(Number(r.value)); + }); + panel.addEventListener('change', (e) => { + const r = e.target.closest('[data-num]'); + if (!r) return; + const n = NUM[r.dataset.num], v = Number(r.value), old = n.get(ch); + if (v === old) return; + const next = clone(ch); + n.set(next, v); + tryApply(next, { set: n.set, ok: old, want: v }); + }); + + // Beeld voor het voorblad: de 3D-weergave (ook vanuit doorsnede of plattegrond) op vaste resolutie. + function snapshot() { + const W = 1600, H = 1500, prev = view, pr = renderer.getPixelRatio(); + const keep = { pos: camera.position.clone(), target: controls.target.clone() }; + if (view !== '3d') { + view = '3d'; + applyView(); + if (poses['3d']) { camera.position.copy(poses['3d'].pos); controls.target.copy(poses['3d'].target); } + } + renderer.setPixelRatio(1); + renderer.setSize(W, H, false); + camera.aspect = W / H; + camera.updateProjectionMatrix(); + controls.update(); + renderer.render(scene, camera); + const url = renderer.domElement.toDataURL('image/jpeg', 0.88); + renderer.setPixelRatio(pr); + if (prev !== '3d') { + view = prev; + camera.position.copy(keep.pos); + controls.target.copy(keep.target); + controls.update(); + applyView(); + } + resize(); + return { url, width: W, height: H }; + } + const pageUrl = o.url || root.dataset.url || LightPdf.modelUrl(model.key); + + function savePdf() { + const st = clone(cur.st); + st.project.naam = model.label; + st.project.opmerking = 'Indicatief ontwerp'; + try { + let image = null; + try { image = snapshot(); } catch (err) { console.warn(err); } + LightPdf.generate({ st, label: model.label, info: cur.info, choices: LightModel.describe(model, ch, cur.st), url: pageUrl, image }) + .save(`${model.label.replace(/[^A-Za-z0-9_-]+/g, '-')}-${new Date().toISOString().slice(0, 10)}.pdf`); + } catch (err) { + console.error(err); + toast('De PDF kon niet worden gemaakt.'); + } + } + + // Prijzen komen uit de CSV's (async geladen): bijwerken zodra ze er zijn. + const offCosts = CostModel.onChange(() => { if (cur && cur.parts) { cur.info = LightModel.summary(cur.st, cur.parts); renderSummary(); } }); + + let visible = true, raf = 0; + function resize() { + const w = stage.clientWidth, h = stage.clientHeight; + if (!w || !h) return; + camera.aspect = w / h; + camera.updateProjectionMatrix(); + renderer.setSize(w, h); + } + const ro = new ResizeObserver(() => { resize(); if (view === 'plan') drawPlan(); }); + ro.observe(stage); + // Alleen tekenen als de configurator in beeld is (modelpagina scrolt). + const io = new IntersectionObserver((es) => { visible = es.some(x => x.isIntersecting); }); + // In een pagina: het scrollwiel scrolt de pagina, tenzij je in de 3D hebt geklikt (tot je de muis eruit haalt) of + // Ctrl/⌘ ingedrukt houdt; dan zoomt het. + let engaged = false; + const stageEl = $('.tl-stage'); + stageEl.addEventListener('pointerdown', () => { engaged = true; }); + stageEl.addEventListener('pointerleave', () => { engaged = false; }); + stageEl.addEventListener('wheel', (e) => { + if (engaged || e.ctrlKey || e.metaKey || view === 'plan') return; + e.stopPropagation(); + toast('Klik eerst in de weergave (of houd Ctrl ingedrukt) om te zoomen.'); + }, { capture: true }); + io.observe(stage); + (function loop() { + raf = requestAnimationFrame(loop); + if (visible && view !== 'plan') renderer.render(scene, camera); + })(); + + root.classList.add('is-busy'); + setTimeout(() => { + const st = LightModel.build(model, ch); + show(st, compute(st)); + root.classList.remove('is-busy'); + }, 30); + + return { + unmount() { + cancelAnimationFrame(raf); + ro.disconnect(); io.disconnect(); offCosts(); controls.dispose(); + if (group) disposeGeo(group); + renderer.dispose(); + root.innerHTML = ''; + }, + getChoices: () => clone(ch) + }; + } + + function autoMount() { + document.querySelectorAll('.tectum-light[data-model]').forEach(el => { + if (el.dataset.mounted) return; + el.dataset.mounted = '1'; + try { mount(el); } catch (err) { console.error(err); el.textContent = `Configurator niet beschikbaar: ${err.message}`; } + }); + } + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', autoMount); + else autoMount(); + + return { mount, autoMount }; +})(); diff --git a/js/vendor/OrbitControls.js b/js/vendor/OrbitControls.js index 8cf0bfc..1a1a7f2 100644 --- a/js/vendor/OrbitControls.js +++ b/js/vendor/OrbitControls.js @@ -4,7 +4,8 @@ // hebben om het origineel te vendoren; dekt precies de subset die wall.js // gebruikt (`new THREE.OrbitControls(camera, domElement)`, // `controls.target.set(...)`, `controls.update()`), plus interactie: -// links-slepen = roteren, scrollwiel = zoomen, rechts-slepen = pannen. +// links-slepen = roteren, scrollwiel = zoomen, rechts-slepen = pannen; +// touch: één vinger = roteren, twee vingers = knijpen (zoomen) en samen schuiven (pannen). // // Moet NA three.min.js geladen worden (leunt op window.THREE). // ============================================================ @@ -60,13 +61,7 @@ lastY = event.clientY; } - function onPointerMove(event) { - if (isRotating) { - const dx = event.clientX - lastX; - const dy = event.clientY - lastY; - lastX = event.clientX; - lastY = event.clientY; - + function rotate(dx, dy) { const w = scope.domElement.clientWidth || 1; const h = scope.domElement.clientHeight || 1; @@ -79,12 +74,8 @@ offset.setFromSpherical(spherical); scope.object.position.copy(scope.target).add(offset); scope.object.lookAt(scope.target); - } else if (isPanning) { - const dx = event.clientX - lastX; - const dy = event.clientY - lastY; - lastX = event.clientX; - lastY = event.clientY; - + } + function pan(dx, dy) { const distance = scope.object.position.distanceTo(scope.target); const fov = (scope.object.fov || 50) * Math.PI / 180; const h = scope.domElement.clientHeight || 1; @@ -95,7 +86,22 @@ const yAxis = new THREE.Vector3(te[4], te[5], te[6]).multiplyScalar(dy * factor * scope.panSpeed); scope.target.add(xAxis).add(yAxis); scope.object.position.add(xAxis).add(yAxis); - } + } + function zoom(scale) { + offset.copy(scope.object.position).sub(scope.target); + const distance = Math.max(scope.minDistance, Math.min(scope.maxDistance, offset.length() * scale)); + offset.setLength(distance); + scope.object.position.copy(scope.target).add(offset); + } + + function onPointerMove(event) { + if (!isRotating && !isPanning) return; + const dx = event.clientX - lastX; + const dy = event.clientY - lastY; + lastX = event.clientX; + lastY = event.clientY; + if (isRotating) rotate(dx, dy); + else pan(dx, dy); } function onPointerUp() { isRotating = false; isPanning = false; } @@ -103,13 +109,33 @@ function onWheel(event) { if (!scope.enableZoom) return; event.preventDefault(); - const scale = event.deltaY > 0 ? 1 / getZoomScale() : getZoomScale(); - offset.copy(scope.object.position).sub(scope.target); - const distance = Math.max(scope.minDistance, Math.min(scope.maxDistance, offset.length() * scale)); - offset.setLength(distance); - scope.object.position.copy(scope.target).add(offset); + zoom(event.deltaY > 0 ? 1 / getZoomScale() : getZoomScale()); } + // Touch: midden en afstand van de vingers; bij een wissel van het aantal vingers opnieuw beginnen. + let touch = null; + function touchState(list) { + const a = list[0], b = list[1] || list[0]; + return { n: list.length, x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2, d: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) }; + } + function onTouchStart(event) { + event.preventDefault(); + touch = touchState(event.touches); + } + function onTouchMove(event) { + if (!touch) return; + event.preventDefault(); + const t = touchState(event.touches); + if (t.n !== touch.n) { touch = t; return; } + if (t.n === 1) { if (scope.enableRotate) rotate(t.x - touch.x, t.y - touch.y); } + else { + if (scope.enablePan) pan(t.x - touch.x, t.y - touch.y); + if (scope.enableZoom && t.d > 0 && touch.d > 0) zoom(touch.d / t.d); + } + touch = t; + } + function onTouchEnd(event) { touch = event.touches.length ? touchState(event.touches) : null; } + function onContextMenu(event) { event.preventDefault(); } this.domElement.addEventListener('mousedown', onPointerDown); @@ -117,6 +143,10 @@ window.addEventListener('mouseup', onPointerUp); this.domElement.addEventListener('wheel', onWheel, { passive: false }); this.domElement.addEventListener('contextmenu', onContextMenu); + this.domElement.addEventListener('touchstart', onTouchStart, { passive: false }); + this.domElement.addEventListener('touchmove', onTouchMove, { passive: false }); + this.domElement.addEventListener('touchend', onTouchEnd); + this.domElement.addEventListener('touchcancel', onTouchEnd); this.dispose = function () { scope.domElement.removeEventListener('mousedown', onPointerDown); @@ -124,6 +154,10 @@ window.removeEventListener('mouseup', onPointerUp); scope.domElement.removeEventListener('wheel', onWheel); scope.domElement.removeEventListener('contextmenu', onContextMenu); + scope.domElement.removeEventListener('touchstart', onTouchStart); + scope.domElement.removeEventListener('touchmove', onTouchMove); + scope.domElement.removeEventListener('touchend', onTouchEnd); + scope.domElement.removeEventListener('touchcancel', onTouchEnd); }; } diff --git a/js/vendor/qrcode.js b/js/vendor/qrcode.js new file mode 100644 index 0000000..76889b5 --- /dev/null +++ b/js/vendor/qrcode.js @@ -0,0 +1,2297 @@ +//--------------------------------------------------------------------- +// +// QR Code Generator for JavaScript +// +// Copyright (c) 2009 Kazuhiko Arase +// +// URL: http://www.d-project.com/ +// +// Licensed under the MIT license: +// http://www.opensource.org/licenses/mit-license.php +// +// The word 'QR Code' is registered trademark of +// DENSO WAVE INCORPORATED +// http://www.denso-wave.com/qrcode/faqpatent-e.html +// +//--------------------------------------------------------------------- + +var qrcode = function() { + + //--------------------------------------------------------------------- + // qrcode + //--------------------------------------------------------------------- + + /** + * qrcode + * @param typeNumber 1 to 40 + * @param errorCorrectionLevel 'L','M','Q','H' + */ + var qrcode = function(typeNumber, errorCorrectionLevel) { + + var PAD0 = 0xEC; + var PAD1 = 0x11; + + var _typeNumber = typeNumber; + var _errorCorrectionLevel = QRErrorCorrectionLevel[errorCorrectionLevel]; + var _modules = null; + var _moduleCount = 0; + var _dataCache = null; + var _dataList = []; + + var _this = {}; + + var makeImpl = function(test, maskPattern) { + + _moduleCount = _typeNumber * 4 + 17; + _modules = function(moduleCount) { + var modules = new Array(moduleCount); + for (var row = 0; row < moduleCount; row += 1) { + modules[row] = new Array(moduleCount); + for (var col = 0; col < moduleCount; col += 1) { + modules[row][col] = null; + } + } + return modules; + }(_moduleCount); + + setupPositionProbePattern(0, 0); + setupPositionProbePattern(_moduleCount - 7, 0); + setupPositionProbePattern(0, _moduleCount - 7); + setupPositionAdjustPattern(); + setupTimingPattern(); + setupTypeInfo(test, maskPattern); + + if (_typeNumber >= 7) { + setupTypeNumber(test); + } + + if (_dataCache == null) { + _dataCache = createData(_typeNumber, _errorCorrectionLevel, _dataList); + } + + mapData(_dataCache, maskPattern); + }; + + var setupPositionProbePattern = function(row, col) { + + for (var r = -1; r <= 7; r += 1) { + + if (row + r <= -1 || _moduleCount <= row + r) continue; + + for (var c = -1; c <= 7; c += 1) { + + if (col + c <= -1 || _moduleCount <= col + c) continue; + + if ( (0 <= r && r <= 6 && (c == 0 || c == 6) ) + || (0 <= c && c <= 6 && (r == 0 || r == 6) ) + || (2 <= r && r <= 4 && 2 <= c && c <= 4) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + }; + + var getBestMaskPattern = function() { + + var minLostPoint = 0; + var pattern = 0; + + for (var i = 0; i < 8; i += 1) { + + makeImpl(true, i); + + var lostPoint = QRUtil.getLostPoint(_this); + + if (i == 0 || minLostPoint > lostPoint) { + minLostPoint = lostPoint; + pattern = i; + } + } + + return pattern; + }; + + var setupTimingPattern = function() { + + for (var r = 8; r < _moduleCount - 8; r += 1) { + if (_modules[r][6] != null) { + continue; + } + _modules[r][6] = (r % 2 == 0); + } + + for (var c = 8; c < _moduleCount - 8; c += 1) { + if (_modules[6][c] != null) { + continue; + } + _modules[6][c] = (c % 2 == 0); + } + }; + + var setupPositionAdjustPattern = function() { + + var pos = QRUtil.getPatternPosition(_typeNumber); + + for (var i = 0; i < pos.length; i += 1) { + + for (var j = 0; j < pos.length; j += 1) { + + var row = pos[i]; + var col = pos[j]; + + if (_modules[row][col] != null) { + continue; + } + + for (var r = -2; r <= 2; r += 1) { + + for (var c = -2; c <= 2; c += 1) { + + if (r == -2 || r == 2 || c == -2 || c == 2 + || (r == 0 && c == 0) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + } + } + }; + + var setupTypeNumber = function(test) { + + var bits = QRUtil.getBCHTypeNumber(_typeNumber); + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[Math.floor(i / 3)][i % 3 + _moduleCount - 8 - 3] = mod; + } + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[i % 3 + _moduleCount - 8 - 3][Math.floor(i / 3)] = mod; + } + }; + + var setupTypeInfo = function(test, maskPattern) { + + var data = (_errorCorrectionLevel << 3) | maskPattern; + var bits = QRUtil.getBCHTypeInfo(data); + + // vertical + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 6) { + _modules[i][8] = mod; + } else if (i < 8) { + _modules[i + 1][8] = mod; + } else { + _modules[_moduleCount - 15 + i][8] = mod; + } + } + + // horizontal + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 8) { + _modules[8][_moduleCount - i - 1] = mod; + } else if (i < 9) { + _modules[8][15 - i - 1 + 1] = mod; + } else { + _modules[8][15 - i - 1] = mod; + } + } + + // fixed module + _modules[_moduleCount - 8][8] = (!test); + }; + + var mapData = function(data, maskPattern) { + + var inc = -1; + var row = _moduleCount - 1; + var bitIndex = 7; + var byteIndex = 0; + var maskFunc = QRUtil.getMaskFunction(maskPattern); + + for (var col = _moduleCount - 1; col > 0; col -= 2) { + + if (col == 6) col -= 1; + + while (true) { + + for (var c = 0; c < 2; c += 1) { + + if (_modules[row][col - c] == null) { + + var dark = false; + + if (byteIndex < data.length) { + dark = ( ( (data[byteIndex] >>> bitIndex) & 1) == 1); + } + + var mask = maskFunc(row, col - c); + + if (mask) { + dark = !dark; + } + + _modules[row][col - c] = dark; + bitIndex -= 1; + + if (bitIndex == -1) { + byteIndex += 1; + bitIndex = 7; + } + } + } + + row += inc; + + if (row < 0 || _moduleCount <= row) { + row -= inc; + inc = -inc; + break; + } + } + } + }; + + var createBytes = function(buffer, rsBlocks) { + + var offset = 0; + + var maxDcCount = 0; + var maxEcCount = 0; + + var dcdata = new Array(rsBlocks.length); + var ecdata = new Array(rsBlocks.length); + + for (var r = 0; r < rsBlocks.length; r += 1) { + + var dcCount = rsBlocks[r].dataCount; + var ecCount = rsBlocks[r].totalCount - dcCount; + + maxDcCount = Math.max(maxDcCount, dcCount); + maxEcCount = Math.max(maxEcCount, ecCount); + + dcdata[r] = new Array(dcCount); + + for (var i = 0; i < dcdata[r].length; i += 1) { + dcdata[r][i] = 0xff & buffer.getBuffer()[i + offset]; + } + offset += dcCount; + + var rsPoly = QRUtil.getErrorCorrectPolynomial(ecCount); + var rawPoly = qrPolynomial(dcdata[r], rsPoly.getLength() - 1); + + var modPoly = rawPoly.mod(rsPoly); + ecdata[r] = new Array(rsPoly.getLength() - 1); + for (var i = 0; i < ecdata[r].length; i += 1) { + var modIndex = i + modPoly.getLength() - ecdata[r].length; + ecdata[r][i] = (modIndex >= 0)? modPoly.getAt(modIndex) : 0; + } + } + + var totalCodeCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalCodeCount += rsBlocks[i].totalCount; + } + + var data = new Array(totalCodeCount); + var index = 0; + + for (var i = 0; i < maxDcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < dcdata[r].length) { + data[index] = dcdata[r][i]; + index += 1; + } + } + } + + for (var i = 0; i < maxEcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < ecdata[r].length) { + data[index] = ecdata[r][i]; + index += 1; + } + } + } + + return data; + }; + + var createData = function(typeNumber, errorCorrectionLevel, dataList) { + + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, errorCorrectionLevel); + + var buffer = qrBitBuffer(); + + for (var i = 0; i < dataList.length; i += 1) { + var data = dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + // calc num max data. + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() > totalDataCount * 8) { + throw 'code length overflow. (' + + buffer.getLengthInBits() + + '>' + + totalDataCount * 8 + + ')'; + } + + // end code + if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) { + buffer.put(0, 4); + } + + // padding + while (buffer.getLengthInBits() % 8 != 0) { + buffer.putBit(false); + } + + // padding + while (true) { + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD0, 8); + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD1, 8); + } + + return createBytes(buffer, rsBlocks); + }; + + _this.addData = function(data, mode) { + + mode = mode || 'Byte'; + + var newData = null; + + switch(mode) { + case 'Numeric' : + newData = qrNumber(data); + break; + case 'Alphanumeric' : + newData = qrAlphaNum(data); + break; + case 'Byte' : + newData = qr8BitByte(data); + break; + case 'Kanji' : + newData = qrKanji(data); + break; + default : + throw 'mode:' + mode; + } + + _dataList.push(newData); + _dataCache = null; + }; + + _this.isDark = function(row, col) { + if (row < 0 || _moduleCount <= row || col < 0 || _moduleCount <= col) { + throw row + ',' + col; + } + return _modules[row][col]; + }; + + _this.getModuleCount = function() { + return _moduleCount; + }; + + _this.make = function() { + if (_typeNumber < 1) { + var typeNumber = 1; + + for (; typeNumber < 40; typeNumber++) { + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, _errorCorrectionLevel); + var buffer = qrBitBuffer(); + + for (var i = 0; i < _dataList.length; i++) { + var data = _dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i++) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() <= totalDataCount * 8) { + break; + } + } + + _typeNumber = typeNumber; + } + + makeImpl(false, getBestMaskPattern() ); + }; + + _this.createTableTag = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var qrHtml = ''; + + qrHtml += ''; + qrHtml += ''; + + for (var r = 0; r < _this.getModuleCount(); r += 1) { + + qrHtml += ''; + + for (var c = 0; c < _this.getModuleCount(); c += 1) { + qrHtml += ''; + } + + qrHtml += ''; + qrHtml += '
'; + } + + qrHtml += '
'; + + return qrHtml; + }; + + _this.createSvgTag = function(cellSize, margin, alt, title) { + + var opts = {}; + if (typeof arguments[0] == 'object') { + // Called by options. + opts = arguments[0]; + // overwrite cellSize and margin. + cellSize = opts.cellSize; + margin = opts.margin; + alt = opts.alt; + title = opts.title; + } + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + // Compose alt property surrogate + alt = (typeof alt === 'string') ? {text: alt} : alt || {}; + alt.text = alt.text || null; + alt.id = (alt.text) ? alt.id || 'qrcode-description' : null; + + // Compose title property surrogate + title = (typeof title === 'string') ? {text: title} : title || {}; + title.text = title.text || null; + title.id = (title.text) ? title.id || 'qrcode-title' : null; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var c, mc, r, mr, qrSvg='', rect; + + rect = 'l' + cellSize + ',0 0,' + cellSize + + ' -' + cellSize + ',0 0,-' + cellSize + 'z '; + + qrSvg += '' + + escapeXml(title.text) + '' : ''; + qrSvg += (alt.text) ? '' + + escapeXml(alt.text) + '' : ''; + qrSvg += ''; + qrSvg += ''; + qrSvg += ''; + + return qrSvg; + }; + + _this.createDataURL = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + return createDataURL(size, size, function(x, y) { + if (min <= x && x < max && min <= y && y < max) { + var c = Math.floor( (x - min) / cellSize); + var r = Math.floor( (y - min) / cellSize); + return _this.isDark(r, c)? 0 : 1; + } else { + return 1; + } + } ); + }; + + _this.createImgTag = function(cellSize, margin, alt) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + + var img = ''; + img += '': escaped += '>'; break; + case '&': escaped += '&'; break; + case '"': escaped += '"'; break; + default : escaped += c; break; + } + } + return escaped; + }; + + var _createHalfASCII = function(margin) { + var cellSize = 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r1, r2, p; + + var blocks = { + '██': '█', + '█ ': '▀', + ' █': '▄', + ' ': ' ' + }; + + var blocksLastLineNoMargin = { + '██': '▀', + '█ ': '▀', + ' █': ' ', + ' ': ' ' + }; + + var ascii = ''; + for (y = 0; y < size; y += 2) { + r1 = Math.floor((y - min) / cellSize); + r2 = Math.floor((y + 1 - min) / cellSize); + for (x = 0; x < size; x += 1) { + p = '█'; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r1, Math.floor((x - min) / cellSize))) { + p = ' '; + } + + if (min <= x && x < max && min <= y+1 && y+1 < max && _this.isDark(r2, Math.floor((x - min) / cellSize))) { + p += ' '; + } + else { + p += '█'; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + ascii += (margin < 1 && y+1 >= max) ? blocksLastLineNoMargin[p] : blocks[p]; + } + + ascii += '\n'; + } + + if (size % 2 && margin > 0) { + return ascii.substring(0, ascii.length - size - 1) + Array(size+1).join('▀'); + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.createASCII = function(cellSize, margin) { + cellSize = cellSize || 1; + + if (cellSize < 2) { + return _createHalfASCII(margin); + } + + cellSize -= 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r, p; + + var white = Array(cellSize+1).join('██'); + var black = Array(cellSize+1).join(' '); + + var ascii = ''; + var line = ''; + for (y = 0; y < size; y += 1) { + r = Math.floor( (y - min) / cellSize); + line = ''; + for (x = 0; x < size; x += 1) { + p = 1; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r, Math.floor((x - min) / cellSize))) { + p = 0; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + line += p ? white : black; + } + + for (r = 0; r < cellSize; r += 1) { + ascii += line + '\n'; + } + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.renderTo2dContext = function(context, cellSize) { + cellSize = cellSize || 2; + var length = _this.getModuleCount(); + for (var row = 0; row < length; row++) { + for (var col = 0; col < length; col++) { + context.fillStyle = _this.isDark(row, col) ? 'black' : 'white'; + context.fillRect(row * cellSize, col * cellSize, cellSize, cellSize); + } + } + } + + return _this; + }; + + //--------------------------------------------------------------------- + // qrcode.stringToBytes + //--------------------------------------------------------------------- + + qrcode.stringToBytesFuncs = { + 'default' : function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + bytes.push(c & 0xff); + } + return bytes; + } + }; + + qrcode.stringToBytes = qrcode.stringToBytesFuncs['default']; + + //--------------------------------------------------------------------- + // qrcode.createStringToBytes + //--------------------------------------------------------------------- + + /** + * @param unicodeData base64 string of byte array. + * [16bit Unicode],[16bit Bytes], ... + * @param numChars + */ + qrcode.createStringToBytes = function(unicodeData, numChars) { + + // create conversion map. + + var unicodeMap = function() { + + var bin = base64DecodeInputStream(unicodeData); + var read = function() { + var b = bin.read(); + if (b == -1) throw 'eof'; + return b; + }; + + var count = 0; + var unicodeMap = {}; + while (true) { + var b0 = bin.read(); + if (b0 == -1) break; + var b1 = read(); + var b2 = read(); + var b3 = read(); + var k = String.fromCharCode( (b0 << 8) | b1); + var v = (b2 << 8) | b3; + unicodeMap[k] = v; + count += 1; + } + if (count != numChars) { + throw count + ' != ' + numChars; + } + + return unicodeMap; + }(); + + var unknownChar = '?'.charCodeAt(0); + + return function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + if (c < 128) { + bytes.push(c); + } else { + var b = unicodeMap[s.charAt(i)]; + if (typeof b == 'number') { + if ( (b & 0xff) == b) { + // 1byte + bytes.push(b); + } else { + // 2bytes + bytes.push(b >>> 8); + bytes.push(b & 0xff); + } + } else { + bytes.push(unknownChar); + } + } + } + return bytes; + }; + }; + + //--------------------------------------------------------------------- + // QRMode + //--------------------------------------------------------------------- + + var QRMode = { + MODE_NUMBER : 1 << 0, + MODE_ALPHA_NUM : 1 << 1, + MODE_8BIT_BYTE : 1 << 2, + MODE_KANJI : 1 << 3 + }; + + //--------------------------------------------------------------------- + // QRErrorCorrectionLevel + //--------------------------------------------------------------------- + + var QRErrorCorrectionLevel = { + L : 1, + M : 0, + Q : 3, + H : 2 + }; + + //--------------------------------------------------------------------- + // QRMaskPattern + //--------------------------------------------------------------------- + + var QRMaskPattern = { + PATTERN000 : 0, + PATTERN001 : 1, + PATTERN010 : 2, + PATTERN011 : 3, + PATTERN100 : 4, + PATTERN101 : 5, + PATTERN110 : 6, + PATTERN111 : 7 + }; + + //--------------------------------------------------------------------- + // QRUtil + //--------------------------------------------------------------------- + + var QRUtil = function() { + + var PATTERN_POSITION_TABLE = [ + [], + [6, 18], + [6, 22], + [6, 26], + [6, 30], + [6, 34], + [6, 22, 38], + [6, 24, 42], + [6, 26, 46], + [6, 28, 50], + [6, 30, 54], + [6, 32, 58], + [6, 34, 62], + [6, 26, 46, 66], + [6, 26, 48, 70], + [6, 26, 50, 74], + [6, 30, 54, 78], + [6, 30, 56, 82], + [6, 30, 58, 86], + [6, 34, 62, 90], + [6, 28, 50, 72, 94], + [6, 26, 50, 74, 98], + [6, 30, 54, 78, 102], + [6, 28, 54, 80, 106], + [6, 32, 58, 84, 110], + [6, 30, 58, 86, 114], + [6, 34, 62, 90, 118], + [6, 26, 50, 74, 98, 122], + [6, 30, 54, 78, 102, 126], + [6, 26, 52, 78, 104, 130], + [6, 30, 56, 82, 108, 134], + [6, 34, 60, 86, 112, 138], + [6, 30, 58, 86, 114, 142], + [6, 34, 62, 90, 118, 146], + [6, 30, 54, 78, 102, 126, 150], + [6, 24, 50, 76, 102, 128, 154], + [6, 28, 54, 80, 106, 132, 158], + [6, 32, 58, 84, 110, 136, 162], + [6, 26, 54, 82, 110, 138, 166], + [6, 30, 58, 86, 114, 142, 170] + ]; + var G15 = (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0); + var G18 = (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0); + var G15_MASK = (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1); + + var _this = {}; + + var getBCHDigit = function(data) { + var digit = 0; + while (data != 0) { + digit += 1; + data >>>= 1; + } + return digit; + }; + + _this.getBCHTypeInfo = function(data) { + var d = data << 10; + while (getBCHDigit(d) - getBCHDigit(G15) >= 0) { + d ^= (G15 << (getBCHDigit(d) - getBCHDigit(G15) ) ); + } + return ( (data << 10) | d) ^ G15_MASK; + }; + + _this.getBCHTypeNumber = function(data) { + var d = data << 12; + while (getBCHDigit(d) - getBCHDigit(G18) >= 0) { + d ^= (G18 << (getBCHDigit(d) - getBCHDigit(G18) ) ); + } + return (data << 12) | d; + }; + + _this.getPatternPosition = function(typeNumber) { + return PATTERN_POSITION_TABLE[typeNumber - 1]; + }; + + _this.getMaskFunction = function(maskPattern) { + + switch (maskPattern) { + + case QRMaskPattern.PATTERN000 : + return function(i, j) { return (i + j) % 2 == 0; }; + case QRMaskPattern.PATTERN001 : + return function(i, j) { return i % 2 == 0; }; + case QRMaskPattern.PATTERN010 : + return function(i, j) { return j % 3 == 0; }; + case QRMaskPattern.PATTERN011 : + return function(i, j) { return (i + j) % 3 == 0; }; + case QRMaskPattern.PATTERN100 : + return function(i, j) { return (Math.floor(i / 2) + Math.floor(j / 3) ) % 2 == 0; }; + case QRMaskPattern.PATTERN101 : + return function(i, j) { return (i * j) % 2 + (i * j) % 3 == 0; }; + case QRMaskPattern.PATTERN110 : + return function(i, j) { return ( (i * j) % 2 + (i * j) % 3) % 2 == 0; }; + case QRMaskPattern.PATTERN111 : + return function(i, j) { return ( (i * j) % 3 + (i + j) % 2) % 2 == 0; }; + + default : + throw 'bad maskPattern:' + maskPattern; + } + }; + + _this.getErrorCorrectPolynomial = function(errorCorrectLength) { + var a = qrPolynomial([1], 0); + for (var i = 0; i < errorCorrectLength; i += 1) { + a = a.multiply(qrPolynomial([1, QRMath.gexp(i)], 0) ); + } + return a; + }; + + _this.getLengthInBits = function(mode, type) { + + if (1 <= type && type < 10) { + + // 1 - 9 + + switch(mode) { + case QRMode.MODE_NUMBER : return 10; + case QRMode.MODE_ALPHA_NUM : return 9; + case QRMode.MODE_8BIT_BYTE : return 8; + case QRMode.MODE_KANJI : return 8; + default : + throw 'mode:' + mode; + } + + } else if (type < 27) { + + // 10 - 26 + + switch(mode) { + case QRMode.MODE_NUMBER : return 12; + case QRMode.MODE_ALPHA_NUM : return 11; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 10; + default : + throw 'mode:' + mode; + } + + } else if (type < 41) { + + // 27 - 40 + + switch(mode) { + case QRMode.MODE_NUMBER : return 14; + case QRMode.MODE_ALPHA_NUM : return 13; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 12; + default : + throw 'mode:' + mode; + } + + } else { + throw 'type:' + type; + } + }; + + _this.getLostPoint = function(qrcode) { + + var moduleCount = qrcode.getModuleCount(); + + var lostPoint = 0; + + // LEVEL1 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount; col += 1) { + + var sameCount = 0; + var dark = qrcode.isDark(row, col); + + for (var r = -1; r <= 1; r += 1) { + + if (row + r < 0 || moduleCount <= row + r) { + continue; + } + + for (var c = -1; c <= 1; c += 1) { + + if (col + c < 0 || moduleCount <= col + c) { + continue; + } + + if (r == 0 && c == 0) { + continue; + } + + if (dark == qrcode.isDark(row + r, col + c) ) { + sameCount += 1; + } + } + } + + if (sameCount > 5) { + lostPoint += (3 + sameCount - 5); + } + } + }; + + // LEVEL2 + + for (var row = 0; row < moduleCount - 1; row += 1) { + for (var col = 0; col < moduleCount - 1; col += 1) { + var count = 0; + if (qrcode.isDark(row, col) ) count += 1; + if (qrcode.isDark(row + 1, col) ) count += 1; + if (qrcode.isDark(row, col + 1) ) count += 1; + if (qrcode.isDark(row + 1, col + 1) ) count += 1; + if (count == 0 || count == 4) { + lostPoint += 3; + } + } + } + + // LEVEL3 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount - 6; col += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row, col + 1) + && qrcode.isDark(row, col + 2) + && qrcode.isDark(row, col + 3) + && qrcode.isDark(row, col + 4) + && !qrcode.isDark(row, col + 5) + && qrcode.isDark(row, col + 6) ) { + lostPoint += 40; + } + } + } + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount - 6; row += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row + 1, col) + && qrcode.isDark(row + 2, col) + && qrcode.isDark(row + 3, col) + && qrcode.isDark(row + 4, col) + && !qrcode.isDark(row + 5, col) + && qrcode.isDark(row + 6, col) ) { + lostPoint += 40; + } + } + } + + // LEVEL4 + + var darkCount = 0; + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount; row += 1) { + if (qrcode.isDark(row, col) ) { + darkCount += 1; + } + } + } + + var ratio = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5; + lostPoint += ratio * 10; + + return lostPoint; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // QRMath + //--------------------------------------------------------------------- + + var QRMath = function() { + + var EXP_TABLE = new Array(256); + var LOG_TABLE = new Array(256); + + // initialize tables + for (var i = 0; i < 8; i += 1) { + EXP_TABLE[i] = 1 << i; + } + for (var i = 8; i < 256; i += 1) { + EXP_TABLE[i] = EXP_TABLE[i - 4] + ^ EXP_TABLE[i - 5] + ^ EXP_TABLE[i - 6] + ^ EXP_TABLE[i - 8]; + } + for (var i = 0; i < 255; i += 1) { + LOG_TABLE[EXP_TABLE[i] ] = i; + } + + var _this = {}; + + _this.glog = function(n) { + + if (n < 1) { + throw 'glog(' + n + ')'; + } + + return LOG_TABLE[n]; + }; + + _this.gexp = function(n) { + + while (n < 0) { + n += 255; + } + + while (n >= 256) { + n -= 255; + } + + return EXP_TABLE[n]; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrPolynomial + //--------------------------------------------------------------------- + + function qrPolynomial(num, shift) { + + if (typeof num.length == 'undefined') { + throw num.length + '/' + shift; + } + + var _num = function() { + var offset = 0; + while (offset < num.length && num[offset] == 0) { + offset += 1; + } + var _num = new Array(num.length - offset + shift); + for (var i = 0; i < num.length - offset; i += 1) { + _num[i] = num[i + offset]; + } + return _num; + }(); + + var _this = {}; + + _this.getAt = function(index) { + return _num[index]; + }; + + _this.getLength = function() { + return _num.length; + }; + + _this.multiply = function(e) { + + var num = new Array(_this.getLength() + e.getLength() - 1); + + for (var i = 0; i < _this.getLength(); i += 1) { + for (var j = 0; j < e.getLength(); j += 1) { + num[i + j] ^= QRMath.gexp(QRMath.glog(_this.getAt(i) ) + QRMath.glog(e.getAt(j) ) ); + } + } + + return qrPolynomial(num, 0); + }; + + _this.mod = function(e) { + + if (_this.getLength() - e.getLength() < 0) { + return _this; + } + + var ratio = QRMath.glog(_this.getAt(0) ) - QRMath.glog(e.getAt(0) ); + + var num = new Array(_this.getLength() ); + for (var i = 0; i < _this.getLength(); i += 1) { + num[i] = _this.getAt(i); + } + + for (var i = 0; i < e.getLength(); i += 1) { + num[i] ^= QRMath.gexp(QRMath.glog(e.getAt(i) ) + ratio); + } + + // recursive call + return qrPolynomial(num, 0).mod(e); + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // QRRSBlock + //--------------------------------------------------------------------- + + var QRRSBlock = function() { + + var RS_BLOCK_TABLE = [ + + // L + // M + // Q + // H + + // 1 + [1, 26, 19], + [1, 26, 16], + [1, 26, 13], + [1, 26, 9], + + // 2 + [1, 44, 34], + [1, 44, 28], + [1, 44, 22], + [1, 44, 16], + + // 3 + [1, 70, 55], + [1, 70, 44], + [2, 35, 17], + [2, 35, 13], + + // 4 + [1, 100, 80], + [2, 50, 32], + [2, 50, 24], + [4, 25, 9], + + // 5 + [1, 134, 108], + [2, 67, 43], + [2, 33, 15, 2, 34, 16], + [2, 33, 11, 2, 34, 12], + + // 6 + [2, 86, 68], + [4, 43, 27], + [4, 43, 19], + [4, 43, 15], + + // 7 + [2, 98, 78], + [4, 49, 31], + [2, 32, 14, 4, 33, 15], + [4, 39, 13, 1, 40, 14], + + // 8 + [2, 121, 97], + [2, 60, 38, 2, 61, 39], + [4, 40, 18, 2, 41, 19], + [4, 40, 14, 2, 41, 15], + + // 9 + [2, 146, 116], + [3, 58, 36, 2, 59, 37], + [4, 36, 16, 4, 37, 17], + [4, 36, 12, 4, 37, 13], + + // 10 + [2, 86, 68, 2, 87, 69], + [4, 69, 43, 1, 70, 44], + [6, 43, 19, 2, 44, 20], + [6, 43, 15, 2, 44, 16], + + // 11 + [4, 101, 81], + [1, 80, 50, 4, 81, 51], + [4, 50, 22, 4, 51, 23], + [3, 36, 12, 8, 37, 13], + + // 12 + [2, 116, 92, 2, 117, 93], + [6, 58, 36, 2, 59, 37], + [4, 46, 20, 6, 47, 21], + [7, 42, 14, 4, 43, 15], + + // 13 + [4, 133, 107], + [8, 59, 37, 1, 60, 38], + [8, 44, 20, 4, 45, 21], + [12, 33, 11, 4, 34, 12], + + // 14 + [3, 145, 115, 1, 146, 116], + [4, 64, 40, 5, 65, 41], + [11, 36, 16, 5, 37, 17], + [11, 36, 12, 5, 37, 13], + + // 15 + [5, 109, 87, 1, 110, 88], + [5, 65, 41, 5, 66, 42], + [5, 54, 24, 7, 55, 25], + [11, 36, 12, 7, 37, 13], + + // 16 + [5, 122, 98, 1, 123, 99], + [7, 73, 45, 3, 74, 46], + [15, 43, 19, 2, 44, 20], + [3, 45, 15, 13, 46, 16], + + // 17 + [1, 135, 107, 5, 136, 108], + [10, 74, 46, 1, 75, 47], + [1, 50, 22, 15, 51, 23], + [2, 42, 14, 17, 43, 15], + + // 18 + [5, 150, 120, 1, 151, 121], + [9, 69, 43, 4, 70, 44], + [17, 50, 22, 1, 51, 23], + [2, 42, 14, 19, 43, 15], + + // 19 + [3, 141, 113, 4, 142, 114], + [3, 70, 44, 11, 71, 45], + [17, 47, 21, 4, 48, 22], + [9, 39, 13, 16, 40, 14], + + // 20 + [3, 135, 107, 5, 136, 108], + [3, 67, 41, 13, 68, 42], + [15, 54, 24, 5, 55, 25], + [15, 43, 15, 10, 44, 16], + + // 21 + [4, 144, 116, 4, 145, 117], + [17, 68, 42], + [17, 50, 22, 6, 51, 23], + [19, 46, 16, 6, 47, 17], + + // 22 + [2, 139, 111, 7, 140, 112], + [17, 74, 46], + [7, 54, 24, 16, 55, 25], + [34, 37, 13], + + // 23 + [4, 151, 121, 5, 152, 122], + [4, 75, 47, 14, 76, 48], + [11, 54, 24, 14, 55, 25], + [16, 45, 15, 14, 46, 16], + + // 24 + [6, 147, 117, 4, 148, 118], + [6, 73, 45, 14, 74, 46], + [11, 54, 24, 16, 55, 25], + [30, 46, 16, 2, 47, 17], + + // 25 + [8, 132, 106, 4, 133, 107], + [8, 75, 47, 13, 76, 48], + [7, 54, 24, 22, 55, 25], + [22, 45, 15, 13, 46, 16], + + // 26 + [10, 142, 114, 2, 143, 115], + [19, 74, 46, 4, 75, 47], + [28, 50, 22, 6, 51, 23], + [33, 46, 16, 4, 47, 17], + + // 27 + [8, 152, 122, 4, 153, 123], + [22, 73, 45, 3, 74, 46], + [8, 53, 23, 26, 54, 24], + [12, 45, 15, 28, 46, 16], + + // 28 + [3, 147, 117, 10, 148, 118], + [3, 73, 45, 23, 74, 46], + [4, 54, 24, 31, 55, 25], + [11, 45, 15, 31, 46, 16], + + // 29 + [7, 146, 116, 7, 147, 117], + [21, 73, 45, 7, 74, 46], + [1, 53, 23, 37, 54, 24], + [19, 45, 15, 26, 46, 16], + + // 30 + [5, 145, 115, 10, 146, 116], + [19, 75, 47, 10, 76, 48], + [15, 54, 24, 25, 55, 25], + [23, 45, 15, 25, 46, 16], + + // 31 + [13, 145, 115, 3, 146, 116], + [2, 74, 46, 29, 75, 47], + [42, 54, 24, 1, 55, 25], + [23, 45, 15, 28, 46, 16], + + // 32 + [17, 145, 115], + [10, 74, 46, 23, 75, 47], + [10, 54, 24, 35, 55, 25], + [19, 45, 15, 35, 46, 16], + + // 33 + [17, 145, 115, 1, 146, 116], + [14, 74, 46, 21, 75, 47], + [29, 54, 24, 19, 55, 25], + [11, 45, 15, 46, 46, 16], + + // 34 + [13, 145, 115, 6, 146, 116], + [14, 74, 46, 23, 75, 47], + [44, 54, 24, 7, 55, 25], + [59, 46, 16, 1, 47, 17], + + // 35 + [12, 151, 121, 7, 152, 122], + [12, 75, 47, 26, 76, 48], + [39, 54, 24, 14, 55, 25], + [22, 45, 15, 41, 46, 16], + + // 36 + [6, 151, 121, 14, 152, 122], + [6, 75, 47, 34, 76, 48], + [46, 54, 24, 10, 55, 25], + [2, 45, 15, 64, 46, 16], + + // 37 + [17, 152, 122, 4, 153, 123], + [29, 74, 46, 14, 75, 47], + [49, 54, 24, 10, 55, 25], + [24, 45, 15, 46, 46, 16], + + // 38 + [4, 152, 122, 18, 153, 123], + [13, 74, 46, 32, 75, 47], + [48, 54, 24, 14, 55, 25], + [42, 45, 15, 32, 46, 16], + + // 39 + [20, 147, 117, 4, 148, 118], + [40, 75, 47, 7, 76, 48], + [43, 54, 24, 22, 55, 25], + [10, 45, 15, 67, 46, 16], + + // 40 + [19, 148, 118, 6, 149, 119], + [18, 75, 47, 31, 76, 48], + [34, 54, 24, 34, 55, 25], + [20, 45, 15, 61, 46, 16] + ]; + + var qrRSBlock = function(totalCount, dataCount) { + var _this = {}; + _this.totalCount = totalCount; + _this.dataCount = dataCount; + return _this; + }; + + var _this = {}; + + var getRsBlockTable = function(typeNumber, errorCorrectionLevel) { + + switch(errorCorrectionLevel) { + case QRErrorCorrectionLevel.L : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 0]; + case QRErrorCorrectionLevel.M : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 1]; + case QRErrorCorrectionLevel.Q : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 2]; + case QRErrorCorrectionLevel.H : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 3]; + default : + return undefined; + } + }; + + _this.getRSBlocks = function(typeNumber, errorCorrectionLevel) { + + var rsBlock = getRsBlockTable(typeNumber, errorCorrectionLevel); + + if (typeof rsBlock == 'undefined') { + throw 'bad rs block @ typeNumber:' + typeNumber + + '/errorCorrectionLevel:' + errorCorrectionLevel; + } + + var length = rsBlock.length / 3; + + var list = []; + + for (var i = 0; i < length; i += 1) { + + var count = rsBlock[i * 3 + 0]; + var totalCount = rsBlock[i * 3 + 1]; + var dataCount = rsBlock[i * 3 + 2]; + + for (var j = 0; j < count; j += 1) { + list.push(qrRSBlock(totalCount, dataCount) ); + } + } + + return list; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrBitBuffer + //--------------------------------------------------------------------- + + var qrBitBuffer = function() { + + var _buffer = []; + var _length = 0; + + var _this = {}; + + _this.getBuffer = function() { + return _buffer; + }; + + _this.getAt = function(index) { + var bufIndex = Math.floor(index / 8); + return ( (_buffer[bufIndex] >>> (7 - index % 8) ) & 1) == 1; + }; + + _this.put = function(num, length) { + for (var i = 0; i < length; i += 1) { + _this.putBit( ( (num >>> (length - i - 1) ) & 1) == 1); + } + }; + + _this.getLengthInBits = function() { + return _length; + }; + + _this.putBit = function(bit) { + + var bufIndex = Math.floor(_length / 8); + if (_buffer.length <= bufIndex) { + _buffer.push(0); + } + + if (bit) { + _buffer[bufIndex] |= (0x80 >>> (_length % 8) ); + } + + _length += 1; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrNumber + //--------------------------------------------------------------------- + + var qrNumber = function(data) { + + var _mode = QRMode.MODE_NUMBER; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var data = _data; + + var i = 0; + + while (i + 2 < data.length) { + buffer.put(strToNum(data.substring(i, i + 3) ), 10); + i += 3; + } + + if (i < data.length) { + if (data.length - i == 1) { + buffer.put(strToNum(data.substring(i, i + 1) ), 4); + } else if (data.length - i == 2) { + buffer.put(strToNum(data.substring(i, i + 2) ), 7); + } + } + }; + + var strToNum = function(s) { + var num = 0; + for (var i = 0; i < s.length; i += 1) { + num = num * 10 + chatToNum(s.charAt(i) ); + } + return num; + }; + + var chatToNum = function(c) { + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } + throw 'illegal char :' + c; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrAlphaNum + //--------------------------------------------------------------------- + + var qrAlphaNum = function(data) { + + var _mode = QRMode.MODE_ALPHA_NUM; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var s = _data; + + var i = 0; + + while (i + 1 < s.length) { + buffer.put( + getCode(s.charAt(i) ) * 45 + + getCode(s.charAt(i + 1) ), 11); + i += 2; + } + + if (i < s.length) { + buffer.put(getCode(s.charAt(i) ), 6); + } + }; + + var getCode = function(c) { + + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } else if ('A' <= c && c <= 'Z') { + return c.charCodeAt(0) - 'A'.charCodeAt(0) + 10; + } else { + switch (c) { + case ' ' : return 36; + case '$' : return 37; + case '%' : return 38; + case '*' : return 39; + case '+' : return 40; + case '-' : return 41; + case '.' : return 42; + case '/' : return 43; + case ':' : return 44; + default : + throw 'illegal char :' + c; + } + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qr8BitByte + //--------------------------------------------------------------------- + + var qr8BitByte = function(data) { + + var _mode = QRMode.MODE_8BIT_BYTE; + var _data = data; + var _bytes = qrcode.stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _bytes.length; + }; + + _this.write = function(buffer) { + for (var i = 0; i < _bytes.length; i += 1) { + buffer.put(_bytes[i], 8); + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrKanji + //--------------------------------------------------------------------- + + var qrKanji = function(data) { + + var _mode = QRMode.MODE_KANJI; + var _data = data; + + var stringToBytes = qrcode.stringToBytesFuncs['SJIS']; + if (!stringToBytes) { + throw 'sjis not supported.'; + } + !function(c, code) { + // self test for sjis support. + var test = stringToBytes(c); + if (test.length != 2 || ( (test[0] << 8) | test[1]) != code) { + throw 'sjis not supported.'; + } + }('\u53cb', 0x9746); + + var _bytes = stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return ~~(_bytes.length / 2); + }; + + _this.write = function(buffer) { + + var data = _bytes; + + var i = 0; + + while (i + 1 < data.length) { + + var c = ( (0xff & data[i]) << 8) | (0xff & data[i + 1]); + + if (0x8140 <= c && c <= 0x9FFC) { + c -= 0x8140; + } else if (0xE040 <= c && c <= 0xEBBF) { + c -= 0xC140; + } else { + throw 'illegal char at ' + (i + 1) + '/' + c; + } + + c = ( (c >>> 8) & 0xff) * 0xC0 + (c & 0xff); + + buffer.put(c, 13); + + i += 2; + } + + if (i < data.length) { + throw 'illegal char at ' + (i + 1); + } + }; + + return _this; + }; + + //===================================================================== + // GIF Support etc. + // + + //--------------------------------------------------------------------- + // byteArrayOutputStream + //--------------------------------------------------------------------- + + var byteArrayOutputStream = function() { + + var _bytes = []; + + var _this = {}; + + _this.writeByte = function(b) { + _bytes.push(b & 0xff); + }; + + _this.writeShort = function(i) { + _this.writeByte(i); + _this.writeByte(i >>> 8); + }; + + _this.writeBytes = function(b, off, len) { + off = off || 0; + len = len || b.length; + for (var i = 0; i < len; i += 1) { + _this.writeByte(b[i + off]); + } + }; + + _this.writeString = function(s) { + for (var i = 0; i < s.length; i += 1) { + _this.writeByte(s.charCodeAt(i) ); + } + }; + + _this.toByteArray = function() { + return _bytes; + }; + + _this.toString = function() { + var s = ''; + s += '['; + for (var i = 0; i < _bytes.length; i += 1) { + if (i > 0) { + s += ','; + } + s += _bytes[i]; + } + s += ']'; + return s; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64EncodeOutputStream + //--------------------------------------------------------------------- + + var base64EncodeOutputStream = function() { + + var _buffer = 0; + var _buflen = 0; + var _length = 0; + var _base64 = ''; + + var _this = {}; + + var writeEncoded = function(b) { + _base64 += String.fromCharCode(encode(b & 0x3f) ); + }; + + var encode = function(n) { + if (n < 0) { + // error. + } else if (n < 26) { + return 0x41 + n; + } else if (n < 52) { + return 0x61 + (n - 26); + } else if (n < 62) { + return 0x30 + (n - 52); + } else if (n == 62) { + return 0x2b; + } else if (n == 63) { + return 0x2f; + } + throw 'n:' + n; + }; + + _this.writeByte = function(n) { + + _buffer = (_buffer << 8) | (n & 0xff); + _buflen += 8; + _length += 1; + + while (_buflen >= 6) { + writeEncoded(_buffer >>> (_buflen - 6) ); + _buflen -= 6; + } + }; + + _this.flush = function() { + + if (_buflen > 0) { + writeEncoded(_buffer << (6 - _buflen) ); + _buffer = 0; + _buflen = 0; + } + + if (_length % 3 != 0) { + // padding + var padlen = 3 - _length % 3; + for (var i = 0; i < padlen; i += 1) { + _base64 += '='; + } + } + }; + + _this.toString = function() { + return _base64; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64DecodeInputStream + //--------------------------------------------------------------------- + + var base64DecodeInputStream = function(str) { + + var _str = str; + var _pos = 0; + var _buffer = 0; + var _buflen = 0; + + var _this = {}; + + _this.read = function() { + + while (_buflen < 8) { + + if (_pos >= _str.length) { + if (_buflen == 0) { + return -1; + } + throw 'unexpected end of file./' + _buflen; + } + + var c = _str.charAt(_pos); + _pos += 1; + + if (c == '=') { + _buflen = 0; + return -1; + } else if (c.match(/^\s$/) ) { + // ignore if whitespace. + continue; + } + + _buffer = (_buffer << 6) | decode(c.charCodeAt(0) ); + _buflen += 6; + } + + var n = (_buffer >>> (_buflen - 8) ) & 0xff; + _buflen -= 8; + return n; + }; + + var decode = function(c) { + if (0x41 <= c && c <= 0x5a) { + return c - 0x41; + } else if (0x61 <= c && c <= 0x7a) { + return c - 0x61 + 26; + } else if (0x30 <= c && c <= 0x39) { + return c - 0x30 + 52; + } else if (c == 0x2b) { + return 62; + } else if (c == 0x2f) { + return 63; + } else { + throw 'c:' + c; + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // gifImage (B/W) + //--------------------------------------------------------------------- + + var gifImage = function(width, height) { + + var _width = width; + var _height = height; + var _data = new Array(width * height); + + var _this = {}; + + _this.setPixel = function(x, y, pixel) { + _data[y * _width + x] = pixel; + }; + + _this.write = function(out) { + + //--------------------------------- + // GIF Signature + + out.writeString('GIF87a'); + + //--------------------------------- + // Screen Descriptor + + out.writeShort(_width); + out.writeShort(_height); + + out.writeByte(0x80); // 2bit + out.writeByte(0); + out.writeByte(0); + + //--------------------------------- + // Global Color Map + + // black + out.writeByte(0x00); + out.writeByte(0x00); + out.writeByte(0x00); + + // white + out.writeByte(0xff); + out.writeByte(0xff); + out.writeByte(0xff); + + //--------------------------------- + // Image Descriptor + + out.writeString(','); + out.writeShort(0); + out.writeShort(0); + out.writeShort(_width); + out.writeShort(_height); + out.writeByte(0); + + //--------------------------------- + // Local Color Map + + //--------------------------------- + // Raster Data + + var lzwMinCodeSize = 2; + var raster = getLZWRaster(lzwMinCodeSize); + + out.writeByte(lzwMinCodeSize); + + var offset = 0; + + while (raster.length - offset > 255) { + out.writeByte(255); + out.writeBytes(raster, offset, 255); + offset += 255; + } + + out.writeByte(raster.length - offset); + out.writeBytes(raster, offset, raster.length - offset); + out.writeByte(0x00); + + //--------------------------------- + // GIF Terminator + out.writeString(';'); + }; + + var bitOutputStream = function(out) { + + var _out = out; + var _bitLength = 0; + var _bitBuffer = 0; + + var _this = {}; + + _this.write = function(data, length) { + + if ( (data >>> length) != 0) { + throw 'length over'; + } + + while (_bitLength + length >= 8) { + _out.writeByte(0xff & ( (data << _bitLength) | _bitBuffer) ); + length -= (8 - _bitLength); + data >>>= (8 - _bitLength); + _bitBuffer = 0; + _bitLength = 0; + } + + _bitBuffer = (data << _bitLength) | _bitBuffer; + _bitLength = _bitLength + length; + }; + + _this.flush = function() { + if (_bitLength > 0) { + _out.writeByte(_bitBuffer); + } + }; + + return _this; + }; + + var getLZWRaster = function(lzwMinCodeSize) { + + var clearCode = 1 << lzwMinCodeSize; + var endCode = (1 << lzwMinCodeSize) + 1; + var bitLength = lzwMinCodeSize + 1; + + // Setup LZWTable + var table = lzwTable(); + + for (var i = 0; i < clearCode; i += 1) { + table.add(String.fromCharCode(i) ); + } + table.add(String.fromCharCode(clearCode) ); + table.add(String.fromCharCode(endCode) ); + + var byteOut = byteArrayOutputStream(); + var bitOut = bitOutputStream(byteOut); + + // clear code + bitOut.write(clearCode, bitLength); + + var dataIndex = 0; + + var s = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + while (dataIndex < _data.length) { + + var c = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + if (table.contains(s + c) ) { + + s = s + c; + + } else { + + bitOut.write(table.indexOf(s), bitLength); + + if (table.size() < 0xfff) { + + if (table.size() == (1 << bitLength) ) { + bitLength += 1; + } + + table.add(s + c); + } + + s = c; + } + } + + bitOut.write(table.indexOf(s), bitLength); + + // end code + bitOut.write(endCode, bitLength); + + bitOut.flush(); + + return byteOut.toByteArray(); + }; + + var lzwTable = function() { + + var _map = {}; + var _size = 0; + + var _this = {}; + + _this.add = function(key) { + if (_this.contains(key) ) { + throw 'dup key:' + key; + } + _map[key] = _size; + _size += 1; + }; + + _this.size = function() { + return _size; + }; + + _this.indexOf = function(key) { + return _map[key]; + }; + + _this.contains = function(key) { + return typeof _map[key] != 'undefined'; + }; + + return _this; + }; + + return _this; + }; + + var createDataURL = function(width, height, getPixel) { + var gif = gifImage(width, height); + for (var y = 0; y < height; y += 1) { + for (var x = 0; x < width; x += 1) { + gif.setPixel(x, y, getPixel(x, y) ); + } + } + + var b = byteArrayOutputStream(); + gif.write(b); + + var base64 = base64EncodeOutputStream(); + var bytes = b.toByteArray(); + for (var i = 0; i < bytes.length; i += 1) { + base64.writeByte(bytes[i]); + } + base64.flush(); + + return 'data:image/gif;base64,' + base64; + }; + + //--------------------------------------------------------------------- + // returns qrcode function. + + return qrcode; +}(); + +// multibyte support +!function() { + + qrcode.stringToBytesFuncs['UTF-8'] = function(s) { + // http://stackoverflow.com/questions/18729405/how-to-convert-utf8-string-to-byte-array + function toUTF8Array(str) { + var utf8 = []; + for (var i=0; i < str.length; i++) { + var charcode = str.charCodeAt(i); + if (charcode < 0x80) utf8.push(charcode); + else if (charcode < 0x800) { + utf8.push(0xc0 | (charcode >> 6), + 0x80 | (charcode & 0x3f)); + } + else if (charcode < 0xd800 || charcode >= 0xe000) { + utf8.push(0xe0 | (charcode >> 12), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + // surrogate pair + else { + i++; + // UTF-16 encodes 0x10000-0x10FFFF by + // subtracting 0x10000 and splitting the + // 20 bits of 0x0-0xFFFFF into two halves + charcode = 0x10000 + (((charcode & 0x3ff)<<10) + | (str.charCodeAt(i) & 0x3ff)); + utf8.push(0xf0 | (charcode >>18), + 0x80 | ((charcode>>12) & 0x3f), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + } + return utf8; + } + return toUTF8Array(s); + }; + +}(); + +(function (factory) { + if (typeof define === 'function' && define.amd) { + define([], factory); + } else if (typeof exports === 'object') { + module.exports = factory(); + } +}(function () { + return qrcode; +})); diff --git a/light.html b/light.html new file mode 100644 index 0000000..f4399af --- /dev/null +++ b/light.html @@ -0,0 +1,83 @@ + + + + + + + + Woning samenstellen — Van Emous Mantelzorgwoningen + + + + + + + +
+
+ Van Emous Mantelzorgwoningen + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +